From fbd66c31e240271b52a1e418d2fec70fb95a4c70 Mon Sep 17 00:00:00 2001 From: npolshakova Date: Fri, 26 Jun 2026 10:29:56 -0700 Subject: [PATCH 1/9] initial tunnel Signed-off-by: npolshakova --- .../internal/controllers/workerpool_apply.go | 39 +- .../controllers/workerpool_apply_test.go | 38 ++ cmd/ateom-gvisor/egress_proxy.go | 138 ++++ cmd/ateom-gvisor/main.go | 45 +- cmd/ateom-microvm/egress_proxy.go | 155 +++++ cmd/ateom-microvm/main.go | 5 + cmd/ateom-microvm/net.go | 19 +- cmd/ateom-microvm/restore.go | 2 +- cmd/ateom-microvm/run.go | 2 +- demos/counter/counter.go | 68 ++ demos/counter/counter_test.go | 67 ++ docs/egress-capture.md | 476 ++++++++++++++ hack/install-ate.sh | 251 +++++++ internal/egresscapture/capture.go | 621 ++++++++++++++++++ internal/egresscapture/capture_test.go | 265 ++++++++ internal/egresscapture/env.go | 38 ++ manifests/ate-install/ate-controller.yaml | 4 + 17 files changed, 2222 insertions(+), 11 deletions(-) create mode 100644 cmd/ateom-gvisor/egress_proxy.go create mode 100644 cmd/ateom-microvm/egress_proxy.go create mode 100644 demos/counter/counter_test.go create mode 100644 docs/egress-capture.md create mode 100644 internal/egresscapture/capture.go create mode 100644 internal/egresscapture/capture_test.go create mode 100644 internal/egresscapture/env.go diff --git a/cmd/atecontroller/internal/controllers/workerpool_apply.go b/cmd/atecontroller/internal/controllers/workerpool_apply.go index faa94f572..fb3fea2dc 100644 --- a/cmd/atecontroller/internal/controllers/workerpool_apply.go +++ b/cmd/atecontroller/internal/controllers/workerpool_apply.go @@ -15,12 +15,16 @@ package controllers import ( + "os" + "strconv" + corev1 "k8s.io/api/core/v1" appsv1ac "k8s.io/client-go/applyconfigurations/apps/v1" corev1ac "k8s.io/client-go/applyconfigurations/core/v1" metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "github.com/agent-substrate/substrate/internal/ateompath" + "github.com/agent-substrate/substrate/internal/egresscapture" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" ) @@ -44,7 +48,9 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool) *appsv1ac.Deployment WithValueFrom(corev1ac.EnvVarSource(). WithFieldRef(corev1ac.ObjectFieldSelector(). WithFieldPath("metadata.uid"))), - ). + ) + containerAC.WithEnv(egressCaptureEnvFromController()...) + containerAC. WithVolumeMounts(corev1ac.VolumeMount(). WithName("run-ateom"). WithMountPath(ateompath.BasePath)) @@ -82,6 +88,37 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool) *appsv1ac.Deployment WithSpec(podSpecAC))) } +func egressCaptureEnvFromController() []*corev1ac.EnvVarApplyConfiguration { + enabled, _ := strconv.ParseBool(os.Getenv(egresscapture.EnvCaptureEnabled)) + if !enabled { + return nil + } + + env := []*corev1ac.EnvVarApplyConfiguration{ + corev1ac.EnvVar(). + WithName(egresscapture.EnvCaptureEnabled). + WithValue("true"), + } + if v := os.Getenv(egresscapture.EnvPEPAddress); v != "" { + env = append(env, corev1ac.EnvVar(). + WithName(egresscapture.EnvPEPAddress). + WithValue(v)) + } + if v := os.Getenv(egresscapture.EnvTunnelProtocol); v != "" { + env = append(env, corev1ac.EnvVar(). + WithName(egresscapture.EnvTunnelProtocol). + WithValue(v)) + } + for _, name := range egresscapture.OptionalEnvNames { + if v := os.Getenv(name); v != "" { + env = append(env, corev1ac.EnvVar(). + WithName(name). + WithValue(v)) + } + } + return env +} + // maybeApplyMicroVMPodShape adds the /dev/kvm device and node placement a // micro-VM (kata + cloud-hypervisor) worker pool needs, on top of any // pod-template settings. No-op unless sandboxClass is the micro-VM class. diff --git a/cmd/atecontroller/internal/controllers/workerpool_apply_test.go b/cmd/atecontroller/internal/controllers/workerpool_apply_test.go index 1cdc972d6..f93a6d74b 100644 --- a/cmd/atecontroller/internal/controllers/workerpool_apply_test.go +++ b/cmd/atecontroller/internal/controllers/workerpool_apply_test.go @@ -26,6 +26,7 @@ import ( metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "github.com/agent-substrate/substrate/internal/ateompath" + "github.com/agent-substrate/substrate/internal/egresscapture" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" ) @@ -261,6 +262,43 @@ func TestMicroVMPodShape(t *testing.T) { } } +func TestWorkerPoolEgressCaptureEnvPropagation(t *testing.T) { + t.Setenv(egresscapture.EnvCaptureEnabled, "1") + t.Setenv(egresscapture.EnvPEPAddress, "ate-egress.agentgateway-system.svc.cluster.local:15008") + t.Setenv(egresscapture.EnvTunnelProtocol, egresscapture.TunnelProtocolConnectTLS) + t.Setenv(egresscapture.EnvConnectTLSServerName, "ate-egress.agentgateway-system.svc.cluster.local") + t.Setenv(egresscapture.EnvConnectTLSCAFile, "/run/egress-ca/ca.crt") + t.Setenv(egresscapture.EnvConnectTLSInsecureSkipVerify, "true") + + wp := testWorkerPoolApplyConfig(nil) + deployment := buildDeploymentApplyConfig(wp) + containers := deployment.Spec.Template.Spec.Containers + if len(containers) != 1 { + t.Fatalf("containers length = %d, want 1", len(containers)) + } + + got := map[string]string{} + for _, env := range containers[0].Env { + if env.Name != nil && env.Value != nil { + got[*env.Name] = *env.Value + } + } + + want := map[string]string{ + egresscapture.EnvCaptureEnabled: "true", + egresscapture.EnvPEPAddress: "ate-egress.agentgateway-system.svc.cluster.local:15008", + egresscapture.EnvTunnelProtocol: egresscapture.TunnelProtocolConnectTLS, + egresscapture.EnvConnectTLSServerName: "ate-egress.agentgateway-system.svc.cluster.local", + egresscapture.EnvConnectTLSCAFile: "/run/egress-ca/ca.crt", + egresscapture.EnvConnectTLSInsecureSkipVerify: "true", + } + for name, value := range want { + if got[name] != value { + t.Errorf("env %s = %q, want %q", name, got[name], value) + } + } +} + func testWorkerPoolApplyConfig(tmpl *atev1alpha1.WorkerPoolPodTemplate) *atev1alpha1.WorkerPool { return &atev1alpha1.WorkerPool{ ObjectMeta: metav1.ObjectMeta{Name: "pool", Namespace: "default", UID: "uid"}, diff --git a/cmd/ateom-gvisor/egress_proxy.go b/cmd/ateom-gvisor/egress_proxy.go new file mode 100644 index 000000000..882cd1b39 --- /dev/null +++ b/cmd/ateom-gvisor/egress_proxy.go @@ -0,0 +1,138 @@ +//go:build linux + +// 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 main + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "syscall" + "unsafe" + + "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" +) + +const ( + egressCapturePort = uint16(15001) + egressOriginalHTTPPort = uint16(80) + egressOriginalHTTPSPort = uint16(443) +) + +var defaultEgressCaptureRedirects = []struct { + originalPort uint16 + capturePort uint16 +}{ + {originalPort: egressOriginalHTTPPort, capturePort: egressCapturePort}, + {originalPort: egressOriginalHTTPSPort, capturePort: egressCapturePort}, +} + +var defaultEgressCaptureListeners = []egresscapture.Listener{ + {Port: egressCapturePort}, +} + +func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egresscapture.ActorIdentity) error { + if !egresscapture.EnabledFromEnv() { + return nil + } + cfg, err := egresscapture.ConfigFromEnv(defaultEgressCaptureListeners) + if err != nil { + return err + } + capture, err := egresscapture.Start(ctx, identity, cfg, originalDestination) + if err != nil { + return fmt.Errorf("while starting actor egress capture: %w", err) + } + s.egressCapture = capture + return nil +} + +func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { + for _, redirect := range defaultEgressCaptureRedirects { + c.AddRule(&nftables.Rule{ + Table: table, + Chain: prerouting, + Exprs: tcpRedirectExprs(sourceIP, redirect.originalPort, redirect.capturePort), + }) + } +} + +func tcpRedirectExprs(sourceIP string, originalPort, capturePort uint16) []expr.Any { + exprs := append(ipSourceEqual(sourceIP), tcpDestinationPortEqual(originalPort)...) + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: binaryutil.BigEndian.PutUint16(capturePort), + }, + &expr.Redir{ + RegisterProtoMin: 1, + }, + ) + return exprs +} + +func originalDestination(conn net.Conn) (net.Addr, error) { + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return nil, fmt.Errorf("captured connection is %T, not *net.TCPConn", conn) + } + + rawConn, err := tcpConn.SyscallConn() + if err != nil { + return nil, err + } + + var addr *net.TCPAddr + var controlErr error + if err := rawConn.Control(func(fd uintptr) { + addr, controlErr = originalDstFromFD(int(fd)) + }); err != nil { + return nil, err + } + if controlErr != nil { + return nil, controlErr + } + return addr, nil +} + +func originalDstFromFD(fd int) (*net.TCPAddr, error) { + var raw unix.RawSockaddrInet4 + size := uint32(unsafe.Sizeof(raw)) + _, _, errno := unix.Syscall6( + unix.SYS_GETSOCKOPT, + uintptr(fd), + uintptr(unix.SOL_IP), + uintptr(unix.SO_ORIGINAL_DST), + uintptr(unsafe.Pointer(&raw)), + uintptr(unsafe.Pointer(&size)), + 0, + ) + if errno != 0 { + return nil, errno + } + if raw.Family != syscall.AF_INET { + return nil, fmt.Errorf("SO_ORIGINAL_DST returned address family %d", raw.Family) + } + return &net.TCPAddr{ + IP: net.IPv4(raw.Addr[0], raw.Addr[1], raw.Addr[2], raw.Addr[3]), + Port: int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&raw.Port))[:])), + }, nil +} diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 5e856e7bd..12bbdb26e 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -32,6 +32,7 @@ import ( "github.com/agent-substrate/substrate/internal/ateinterceptors" "github.com/agent-substrate/substrate/internal/ateompath" "github.com/agent-substrate/substrate/internal/contextlogging" + "github.com/agent-substrate/substrate/internal/egresscapture" "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/readyz" "github.com/agent-substrate/substrate/internal/serverboot" @@ -164,6 +165,7 @@ type AteomService struct { interiorNetNS netns.NsHandle actorLogger *actorlog.ActorLogger + egressCapture *egresscapture.Capture } var _ ateompb.AteomServer = (*AteomService)(nil) @@ -188,7 +190,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload // * Correct runsc version is downloaded and placed on disk. // * All OCI bundles are set up, including for "pause" container. - if err := s.setupActorNetwork(ctx); err != nil { + if err := s.setupActorNetwork(ctx, actorIdentityFromRun(req)); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } defer func() { @@ -362,7 +364,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore // * All OCI bundles are set up, including for "pause" container. // * Checkpoint downloaded and placed on disk - if err := s.setupActorNetwork(ctx); err != nil { + if err := s.setupActorNetwork(ctx, actorIdentityFromRestore(req)); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } defer func() { @@ -438,7 +440,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore return &ateompb.RestoreWorkloadResponse{}, nil } -func (s *AteomService) setupActorNetwork(ctx context.Context) (retErr error) { +func (s *AteomService) setupActorNetwork(ctx context.Context, identity egresscapture.ActorIdentity) (retErr error) { // Build a fresh point-to-point network between the worker pod netns and the // gVisor interior netns. The worker side keeps the pod's real eth0, creates // ateom0 as the gateway, and moves only the veth peer into the actor netns. @@ -506,7 +508,11 @@ func (s *AteomService) setupActorNetwork(ctx context.Context) (retErr error) { if err := enableIPv4Forwarding(); err != nil { return err } - if err := installActorNftablesRules(podIP); err != nil { + if err := s.startEgressCaptureIfEnabled(ctx, identity); err != nil { + return err + } + + if err := installActorNftablesRules(podIP, s.egressCapture != nil); err != nil { return err } @@ -590,6 +596,12 @@ func (s *AteomService) cleanupActorNetwork(ctx context.Context) error { if err := removeActorNftablesRules(); err != nil { return err } + if s.egressCapture != nil { + if err := s.egressCapture.Close(); err != nil { + slog.WarnContext(ctx, "Failed to close actor egress capture", "err", err) + } + s.egressCapture = nil + } var cleanupErr error if link, err := netlink.LinkByName(hostVethName); err == nil { @@ -671,7 +683,7 @@ func enableIPv4Forwarding() error { return nil } -func installActorNftablesRules(podIP net.IP) error { +func installActorNftablesRules(podIP net.IP, egressCapture bool) error { // Install a dedicated nftables table for the active actor. Keeping all // rules in an ateom-owned table makes cleanup simple and avoids mutating // Kubernetes or CNI-managed chains directly. @@ -709,6 +721,13 @@ func installActorNftablesRules(podIP net.IP) error { Hooknum: nftables.ChainHookPrerouting, Priority: nftables.ChainPriorityNATDest, }) + if egressCapture { + addEgressCaptureRedirectRules(c, table, prerouting, actorVethIP) + } + // TODO: Support optional DNS capture for hostname recovery for non-SNI, + // non-HTTP, or DNS-policy egress. The current HTTP/HTTPS path derives + // authority from TLS SNI or HTTP Host, so redirecting UDP/TCP 53 would + // add potential DNS proxy/cache/TTL/search-domain failures // TODO: Support inbound UDP DNAT for actors that expose UDP protocols such // as QUIC. // TODO: Replace the hard-coded HTTP port with the actor's configured @@ -840,6 +859,22 @@ func tcpDestinationPortEqual(port uint16) []expr.Any { } } +func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egresscapture.ActorIdentity { + return egresscapture.ActorIdentity{ + Namespace: req.GetActorTemplateNamespace(), + Template: req.GetActorTemplateName(), + ActorID: req.GetActorId(), + } +} + +func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egresscapture.ActorIdentity { + return egresscapture.ActorIdentity{ + Namespace: req.GetActorTemplateNamespace(), + Template: req.GetActorTemplateName(), + ActorID: req.GetActorId(), + } +} + func createNetNSWithoutSwitching(ctx context.Context, name string) (netns.NsHandle, error) { runtime.LockOSThread() defer runtime.UnlockOSThread() diff --git a/cmd/ateom-microvm/egress_proxy.go b/cmd/ateom-microvm/egress_proxy.go new file mode 100644 index 000000000..4ab5fa6eb --- /dev/null +++ b/cmd/ateom-microvm/egress_proxy.go @@ -0,0 +1,155 @@ +//go:build linux + +// 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 main + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "syscall" + "unsafe" + + "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/agent-substrate/substrate/internal/proto/ateompb" + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" +) + +const ( + egressCapturePort = uint16(15001) + egressOriginalHTTPPort = uint16(80) + egressOriginalHTTPSPort = uint16(443) +) + +var defaultEgressCaptureRedirects = []struct { + originalPort uint16 + capturePort uint16 +}{ + {originalPort: egressOriginalHTTPPort, capturePort: egressCapturePort}, + {originalPort: egressOriginalHTTPSPort, capturePort: egressCapturePort}, +} + +var defaultEgressCaptureListeners = []egresscapture.Listener{ + {Port: egressCapturePort}, +} + +func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egresscapture.ActorIdentity) error { + if !egresscapture.EnabledFromEnv() { + return nil + } + cfg, err := egresscapture.ConfigFromEnv(defaultEgressCaptureListeners) + if err != nil { + return err + } + capture, err := egresscapture.Start(ctx, identity, cfg, originalDestination) + if err != nil { + return fmt.Errorf("while starting actor egress capture: %w", err) + } + s.egressCapture = capture + return nil +} + +func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { + for _, redirect := range defaultEgressCaptureRedirects { + c.AddRule(&nftables.Rule{ + Table: table, + Chain: prerouting, + Exprs: tcpRedirectExprs(sourceIP, redirect.originalPort, redirect.capturePort), + }) + } +} + +func tcpRedirectExprs(sourceIP string, originalPort, capturePort uint16) []expr.Any { + exprs := append(ipSourceEqual(sourceIP), tcpDestinationPortEqual(originalPort)...) + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: binaryutil.BigEndian.PutUint16(capturePort), + }, + &expr.Redir{ + RegisterProtoMin: 1, + }, + ) + return exprs +} + +func originalDestination(conn net.Conn) (net.Addr, error) { + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return nil, fmt.Errorf("captured connection is %T, not *net.TCPConn", conn) + } + + rawConn, err := tcpConn.SyscallConn() + if err != nil { + return nil, err + } + + var addr *net.TCPAddr + var controlErr error + if err := rawConn.Control(func(fd uintptr) { + addr, controlErr = originalDstFromFD(int(fd)) + }); err != nil { + return nil, err + } + if controlErr != nil { + return nil, controlErr + } + return addr, nil +} + +func originalDstFromFD(fd int) (*net.TCPAddr, error) { + var raw unix.RawSockaddrInet4 + size := uint32(unsafe.Sizeof(raw)) + _, _, errno := unix.Syscall6( + unix.SYS_GETSOCKOPT, + uintptr(fd), + uintptr(unix.SOL_IP), + uintptr(unix.SO_ORIGINAL_DST), + uintptr(unsafe.Pointer(&raw)), + uintptr(unsafe.Pointer(&size)), + 0, + ) + if errno != 0 { + return nil, errno + } + if raw.Family != syscall.AF_INET { + return nil, fmt.Errorf("SO_ORIGINAL_DST returned address family %d", raw.Family) + } + return &net.TCPAddr{ + IP: net.IPv4(raw.Addr[0], raw.Addr[1], raw.Addr[2], raw.Addr[3]), + Port: int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&raw.Port))[:])), + }, nil +} + +func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egresscapture.ActorIdentity { + return egresscapture.ActorIdentity{ + Namespace: req.GetActorTemplateNamespace(), + Template: req.GetActorTemplateName(), + ActorID: req.GetActorId(), + } +} + +func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egresscapture.ActorIdentity { + return egresscapture.ActorIdentity{ + Namespace: req.GetActorTemplateNamespace(), + Template: req.GetActorTemplateName(), + ActorID: req.GetActorId(), + } +} diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index c1998da20..4dc9387fd 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -36,6 +36,7 @@ import ( "github.com/agent-substrate/substrate/internal/actorlog" "github.com/agent-substrate/substrate/internal/ateinterceptors" "github.com/agent-substrate/substrate/internal/ateompath" + "github.com/agent-substrate/substrate/internal/egresscapture" "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/serverboot" "github.com/agent-substrate/substrate/internal/version" @@ -205,6 +206,10 @@ type AteomService struct { // with ateom-gvisor). actorLogger *actorlog.ActorLogger + // egressCapture owns the per-activation capture listener when egress capture is + // enabled for this worker pod. + egressCapture *egresscapture.Capture + // running maps actor name -> the live micro-VM, kept so CheckpointWorkload can // pause+snapshot+teardown the same sandbox (and RestoreWorkload can track the // CH it relaunched). diff --git a/cmd/ateom-microvm/net.go b/cmd/ateom-microvm/net.go index cc047d4f2..b8d6fce28 100644 --- a/cmd/ateom-microvm/net.go +++ b/cmd/ateom-microvm/net.go @@ -48,6 +48,7 @@ import ( "github.com/vishvananda/netns" "golang.org/x/sys/unix" + "github.com/agent-substrate/substrate/internal/egresscapture" "github.com/agent-substrate/substrate/internal/serverboot" ) @@ -120,7 +121,7 @@ func mustParseIP(s string) net.IP { // pod netns and the kata interior netns (see the package comment). Idempotent // via cleanup-before-setup; also sweeps stale kata taps out of the interior // netns so the sandbox always builds on a clean slate. -func (s *AteomService) setupActorNetwork(ctx context.Context) (retErr error) { +func (s *AteomService) setupActorNetwork(ctx context.Context, identity egresscapture.ActorIdentity) (retErr error) { s.cleanupActorNetworkOrExit(ctx, "Failed to clean up stale actor network before setup") defer func() { if retErr != nil { @@ -190,7 +191,10 @@ func (s *AteomService) setupActorNetwork(ctx context.Context) (retErr error) { if err := enableIPv4Forwarding(); err != nil { return err } - if err := installActorNftablesRules(podIP); err != nil { + if err := s.startEgressCaptureIfEnabled(ctx, identity); err != nil { + return err + } + if err := installActorNftablesRules(podIP, s.egressCapture != nil); err != nil { return err } @@ -249,6 +253,12 @@ func (s *AteomService) cleanupActorNetwork(ctx context.Context) error { cleanupErr = errors.Join(cleanupErr, fmt.Errorf("while removing actor nftables rules: %w", err)) slog.WarnContext(ctx, "Failed to remove actor nftables rules; continuing actor netns cleanup", slog.Any("err", err)) } + if s.egressCapture != nil { + if err := s.egressCapture.Close(); err != nil { + slog.WarnContext(ctx, "Failed to close actor egress capture", slog.Any("err", err)) + } + s.egressCapture = nil + } if link, err := netlink.LinkByName(hostVethName); err == nil { if err := netlink.LinkDel(link); err != nil { @@ -333,7 +343,7 @@ func enableIPv4Forwarding() error { return nil } -func installActorNftablesRules(podIP net.IP) error { +func installActorNftablesRules(podIP net.IP, egressCapture bool) error { // Dedicated ateom-owned IPv4 table (cheap cleanup, no CNI chain mutation): // * postrouting: masquerade actor egress (169.254.17.2) behind the pod IP. // * prerouting: DNAT pod-IP:80/tcp to the actor veth IP. @@ -357,6 +367,9 @@ func installActorNftablesRules(podIP net.IP) error { Hooknum: nftables.ChainHookPrerouting, Priority: nftables.ChainPriorityNATDest, }) + if egressCapture { + addEgressCaptureRedirectRules(c, table, prerouting, actorVethIP) + } preroutingExprs := append(ipDestinationEqual(podIP.String()), tcpDestinationPortEqual(80)...) preroutingExprs = append(preroutingExprs, &expr.Immediate{ diff --git a/cmd/ateom-microvm/restore.go b/cmd/ateom-microvm/restore.go index 54407693e..486a14e52 100644 --- a/cmd/ateom-microvm/restore.go +++ b/cmd/ateom-microvm/restore.go @@ -112,7 +112,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore // Networking: rebuild the per-activation veth + tap; the snapshot's virtio-net // is fd-backed, so CH needs fresh tap FDs (net_fds) on restore. - if err := s.setupActorNetwork(ctx); err != nil { + if err := s.setupActorNetwork(ctx, actorIdentityFromRestore(req)); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } defer func() { diff --git a/cmd/ateom-microvm/run.go b/cmd/ateom-microvm/run.go index 2038e550a..7c0be8545 100644 --- a/cmd/ateom-microvm/run.go +++ b/cmd/ateom-microvm/run.go @@ -218,7 +218,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload // Networking (host side): per-activation veth into the interior netns. The // tap + TC mirror is built below (after the VM exists) so its FDs are fresh. - if err := s.setupActorNetwork(ctx); err != nil { + if err := s.setupActorNetwork(ctx, actorIdentityFromRun(req)); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } defer func() { diff --git a/demos/counter/counter.go b/demos/counter/counter.go index bd5b90848..db9a68b49 100644 --- a/demos/counter/counter.go +++ b/demos/counter/counter.go @@ -26,6 +26,7 @@ import ( "log/slog" "net" "net/http" + "net/url" "os" "strconv" "sync" @@ -61,6 +62,8 @@ func incrementFileCounter() int { return counter } +const defaultEgressURL = "https://httpbin.org/get" + func main() { pflag.Parse() ctx := context.Background() @@ -92,6 +95,7 @@ func main() { w.WriteHeader(http.StatusOK) w.Write([]byte("ok\n")) }) + defaultMux.HandleFunc("/egress", handleEgress) go func() { slog.InfoContext(ctx, "Starting counter server on port 80") @@ -123,6 +127,70 @@ func main() { } } +func handleEgress(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + targetURL, err := egressTargetURL(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + http.Error(w, fmt.Sprintf("invalid egress target %q: %v", targetURL, err), http.StatusBadRequest) + return + } + + start := time.Now() + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.ErrorContext(ctx, "Egress request failed", slog.String("target", targetURL), slog.Any("err", err)) + http.Error(w, fmt.Sprintf("egress request to %s failed: %v\n", targetURL, err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) + if err != nil { + slog.ErrorContext(ctx, "Failed reading egress response", slog.String("target", targetURL), slog.Any("err", err)) + http.Error(w, fmt.Sprintf("reading egress response from %s failed: %v\n", targetURL, err), http.StatusBadGateway) + return + } + + slog.InfoContext(ctx, "Egress request completed", + slog.String("target", targetURL), + slog.Int("upstream_status", resp.StatusCode), + slog.Duration("duration", time.Since(start))) + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "egress target: %s\n", targetURL) + fmt.Fprintf(w, "upstream status: %s\n", resp.Status) + fmt.Fprintf(w, "body bytes read: %d\n", len(body)) + fmt.Fprintf(w, "body:\n%s\n", body) +} + +func egressTargetURL(r *http.Request) (string, error) { + targetURL := r.URL.Query().Get("url") + if targetURL == "" { + targetURL = defaultEgressURL + } + + parsed, err := url.Parse(targetURL) + if err != nil { + return "", fmt.Errorf("invalid egress target %q: %w", targetURL, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("invalid egress target %q: scheme must be http or https", targetURL) + } + if parsed.Host == "" { + return "", fmt.Errorf("invalid egress target %q: host is required", targetURL) + } + return targetURL, nil +} + func writeRandomFile() error { rf, err := os.Create("/random-content-file") if err != nil { diff --git a/demos/counter/counter_test.go b/demos/counter/counter_test.go new file mode 100644 index 000000000..e5e008574 --- /dev/null +++ b/demos/counter/counter_test.go @@ -0,0 +1,67 @@ +// 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 main + +import ( + "net/http/httptest" + "testing" +) + +func TestEgressTargetURL(t *testing.T) { + for _, tc := range []struct { + name string + path string + want string + wantErr bool + }{ + { + name: "default", + path: "/egress", + want: defaultEgressURL, + }, + { + name: "custom url", + path: "/egress?url=https%3A%2F%2Fhttpbin.org%2Fheaders", + want: "https://httpbin.org/headers", + }, + { + name: "reject missing host", + path: "/egress?url=https%3A%2F%2F", + wantErr: true, + }, + { + name: "reject unsupported scheme", + path: "/egress?url=ftp%3A%2F%2Fexample.com%2Ffile", + wantErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", tc.path, nil) + got, err := egressTargetURL(req) + if tc.wantErr { + if err == nil { + t.Fatal("egressTargetURL() returned nil error, want error") + } + return + } + if err != nil { + t.Fatalf("egressTargetURL() returned error: %v", err) + } + if got != tc.want { + t.Fatalf("egressTargetURL() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/docs/egress-capture.md b/docs/egress-capture.md new file mode 100644 index 000000000..dab03953c --- /dev/null +++ b/docs/egress-capture.md @@ -0,0 +1,476 @@ +# Egress Capture + +This documents the demo install path and the ateom capture setup using +the existing counter demo. The counter demo is useful because an external curl to +the router resumes a real gVisor actor, and its `/egress` path makes that actor +open an outbound HTTPS request to `https://httpbin.org/get` by default. + +## Architecture + +When egress capture is enabled, each worker pod gets `ATE_EGRESS_*` +configuration from the controller. The reusable capture core lives in +`internal/egresscapture`: it owns environment parsing, capture listeners, +authority derivation, CONNECT tunnel transports, and byte proxying. The +runtime-specific `ateom` egress proxy setup supplies the original-destination +lookup and packet-capture rules. + +The current gVisor implementation starts a local capture listener and installs +actor-network redirects for TCP/80 and TCP/443. From the actor's point of view +it still opens a normal HTTP or HTTPS connection to the original destination. +MicroVM or future hypervisor implementations should reuse +`internal/egresscapture` for the local listener, authority derivation, tunnel +transport, and byte proxying. Each runtime still provides its own egress proxy +setup for redirecting actor traffic and recovering the original destination. + +The redirected connection lands on `ateom`, which records the original +destination and derives a stable CONNECT authority from the first bytes of the +actor connection: + +| Actor traffic | Authority source | Example CONNECT authority | +| --- | --- | --- | +| HTTPS / TCP 443 | TLS ClientHello SNI | `httpbin.org:443` | +| Plaintext HTTP / TCP 80 | HTTP `Host` header | `example.com:80` | + +The shared capture core then opens a tunnel selected by +`ATE_EGRESS_TUNNEL_PROTOCOL`. The default `connect` transport opens a plaintext +HTTP/2 CONNECT stream to the agentgateway data plane at +`ate-egress.agentgateway-system.svc.cluster.local:15008`. The transport registry +also supports TLS CONNECT variants and is intended to allow future transports +such as HBONE without changing the runtime-specific capture setup. Agentgateway +maps the CONNECT authority to its configured TCP listener and routes the tunnel +to a Kubernetes Service backed by an EndpointSlice. + +The demo setup configures only `httpbin.org:443` for egress. +Other hosts or plaintext HTTP destinations need their own agentgateway +Service, EndpointSlice, listener, and route. For HTTPS, TLS is still end-to-end +between the actor and the external service; agentgateway only routes the +encrypted bytes after CONNECT succeeds. + +Enabling egress on an already-running ATE system creates or updates the +`ate-egress-capture` ConfigMap, but that ConfigMap does not currently force an +`ate-controller` restart. If egress variables are missing from worker pods after +enabling capture, restart `ate-controller` so it rereads the config and +reconciles WorkerPool deployments. + +## Prerequisites + +- A working Kubernetes cluster and kubeconfig. +- `kubectl`, `helm`, `jq`, and `curl`. +- `kubectl ate` available from this repo, for example: + +```bash +go install ./cmd/kubectl-ate +``` + +## Install with capture enabled + +For a normal cluster: + +```bash +./hack/install-ate.sh --egress --deploy-ate-system +``` + +For kind: + +```bash +./hack/install-ate-kind.sh --egress --deploy-ate-system +``` + +This deploys agentgateway with a static `httpbin.org:443` egress route, creates +the `ate-system/ate-egress-capture` config map, and deploys the ATE system. +When `ATE_EGRESS_CAPTURE_ENABLED=true`, `ATE_EGRESS_PEP_ADDRESS` is required; +the install script sets it to the in-cluster `ate-egress` Service by default. + +The install script resolves `httpbin.org` during install and creates the +`httpbin-egress` Service and EndpointSlice for those IPs. `ateom` derives the +CONNECT authority from SNI for this HTTPS demo. + +Verify the egress config: + +```bash +kubectl get configmap -n ate-system ate-egress-capture -o yaml +``` + +Expected values: + +```yaml +ATE_EGRESS_CAPTURE_ENABLED: "true" +ATE_EGRESS_PEP_ADDRESS: ate-egress.agentgateway-system.svc.cluster.local:15008 +ATE_EGRESS_TUNNEL_PROTOCOL: connect +``` + +Verify the static agentgateway resources: + +```bash +kubectl get gateway -n agentgateway-system ate-egress +kubectl get tcproute -n agentgateway-system httpbin-egress +kubectl get agentgatewaypolicy -n agentgateway-system ate-egress-connect +kubectl get service -n agentgateway-system httpbin-egress +kubectl get endpointslice -n agentgateway-system httpbin-egress +``` + +Expected resources include: + +```text +gateway.gateway.networking.k8s.io/ate-egress +tcproute.gateway.networking.k8s.io/httpbin-egress +agentgatewaypolicy.agentgateway.dev/ate-egress-connect +service/httpbin-egress +endpointslice.discovery.k8s.io/httpbin-egress +``` + +## Deploy and call the counter actor + +Deploy the existing counter demo: + +```bash +./hack/install-ate.sh --deploy-demo-counter +``` + +Create an actor: + +```bash +kubectl ate create actor my-counter-1 --template ate-demo-counter/counter +``` + +Forward the router locally: + +```bash +kubectl port-forward -n ate-system svc/atenet-router 8000:80 +``` + +From another terminal, send an external request through the router to prove +normal ingress still works: + +```bash +curl -i -X POST \ + -H "Host: my-counter-1.actors.resources.substrate.ate.dev" \ + http://localhost:8000 +``` + +Expected response: + +```text +HTTP/1.1 200 OK +hello from: 169.254.17.2 | preserved memory count: 1 +``` + +The exact count can differ. The important part is that the actor reaches +`STATUS_RUNNING` and is assigned to an ateom pod. + +Now send an external request to the demo's egress path: + +```bash +curl -i -X POST \ + -H "Host: my-counter-1.actors.resources.substrate.ate.dev" \ + http://localhost:8000/egress +``` + +Expected response: + +```text +HTTP/1.1 200 OK +egress target: https://httpbin.org/get +upstream status: 200 OK +body bytes read: ... +``` + +The response must name `https://httpbin.org/get`. That proves the actor opened a +TCP connection to `httpbin.org:443` from inside the sandbox. + +To test a different `httpbin.org` path, pass it as the `url` query parameter: + +```bash +curl -i -X POST --get \ + -H "Host: my-counter-1.actors.resources.substrate.ate.dev" \ + --data-urlencode "url=https://httpbin.org/headers" \ + "http://localhost:8000/egress" +``` + +Do not use this query parameter for a different host unless you also update the +agentgateway route. `ateom` will derive the new host from SNI, but the demo +agentgateway config only routes `httpbin.org:443`. + +## Run the microVM counter demo with egress enabled + +The microVM demo uses the same counter container and `/egress` handler, but runs +it with `sandboxClass: microvm` on `ateom-microvm`. This is useful for checking +that egress configuration reaches microVM worker pods and for exercising the +demo request path. + +When egress capture is enabled, `ateom-microvm` starts the same reusable +`internal/egresscapture` listener as the gVisor runtime and installs +microVM-specific redirect rules in the worker pod network namespace. HTTP and +HTTPS egress from the guest is redirected to the local ateom egress proxy, which +recovers `SO_ORIGINAL_DST`; the shared capture core derives CONNECT authority +from HTTP `Host` or TLS SNI and opens the configured tunnel to the egress PEP. + +For kind, deploy the ATE system first so the in-cluster rustfs service exists +before the microVM demo stages runtime assets. Then run the microVM bring-up, +enable the egress resources, and restart microVM workers so the controller's +egress environment is copied into fresh worker pods: + +```bash +KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" ./hack/install-ate-kind.sh --deploy-ate-system +KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" ./hack/run-microvm-demo-kind.sh +KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" ./hack/install-ate-kind.sh --egress --deploy-ate-controller +``` + +For kind, the node must expose `/dev/kvm` to the kind node and carry the +`ate.dev/sandboxClass=microvm` label. `hack/create-kind-cluster.sh` does both +when `/dev/kvm` is available in the Docker environment. Without that label, the +microVM worker pods remain `Pending`, and router requests fail with: + +```text +actor "my-counter-microvm-1" unavailable: no free workers available +``` + +For a normal cluster, run: + +```bash +./hack/run-microvm-demo.sh +./hack/install-ate.sh --egress --deploy-ate-controller +``` + +Before creating the actor, verify that the microVM worker pods are scheduled and +available: + +```bash +kubectl get nodes -L ate.dev/sandboxClass +kubectl get pods -n ate-demo-counter-microvm -o wide + +kubectl wait --for=condition=Available \ + deployment/counter-microvm-deployment -n ate-demo-counter-microvm --timeout=300s + +kubectl wait --for=condition=Ready \ + actortemplate/counter-microvm -n ate-demo-counter-microvm --timeout=600s +``` + +Expected node output includes `microvm` in the `ATE.DEV/SANDBOXCLASS` column, +and the `counter-microvm` pods should be `Running`. If the pods are `Pending`, +inspect the scheduling reason: + +```bash +kubectl describe pod -n ate-demo-counter-microvm \ + -l ate.dev/worker-pool=counter-microvm +``` + +If `/dev/kvm` is mounted but the label is missing, add the label and recreate the +worker pods: + +```bash +kubectl label node kind-control-plane ate.dev/sandboxClass=microvm --overwrite +kubectl rollout restart deployment/counter-microvm-deployment -n ate-demo-counter-microvm +kubectl rollout status deployment/counter-microvm-deployment -n ate-demo-counter-microvm +``` + +If `/dev/kvm` is not mounted into the node, recreate the kind cluster with +`hack/create-kind-cluster.sh` on a host or Docker VM that has KVM available. + +After the worker deployment and golden snapshot are ready, create a microVM actor +and call it through the router: + +```bash +kubectl ate create actor my-counter-microvm-1 \ + --template ate-demo-counter-microvm/counter-microvm + +kubectl port-forward -n ate-system svc/atenet-router 8000:80 +``` + +From another terminal, verify the normal counter path: + +```bash +curl -i -X POST \ + -H "Host: my-counter-microvm-1.actors.resources.substrate.ate.dev" \ + http://localhost:8000 +``` + +Then exercise the demo egress path: + +```bash +curl -i -X POST \ + -H "Host: my-counter-microvm-1.actors.resources.substrate.ate.dev" \ + http://localhost:8000/egress +``` + +Verify that the microVM worker pod received the egress configuration: + +```bash +actor_json=$(kubectl ate get actor my-counter-microvm-1 -o json) +ateom_ns=$(jq -r '.actors[0].ateomPodNamespace' <<<"${actor_json}") +ateom_pod=$(jq -r '.actors[0].ateomPodName' <<<"${actor_json}") + +kubectl get pod -n "${ateom_ns}" "${ateom_pod}" \ + -o jsonpath='{range .spec.containers[?(@.name=="ateom")].env[*]}{.name}={.value}{"\n"}{end}' \ + | grep ATE_EGRESS +``` + +Expected output includes: + +```text +ATE_EGRESS_CAPTURE_ENABLED=true +ATE_EGRESS_PEP_ADDRESS=ate-egress.agentgateway-system.svc.cluster.local:15008 +ATE_EGRESS_TUNNEL_PROTOCOL=connect +``` + +Check that the microVM runtime installed the capture listener: + +```bash +kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Started actor egress capture listener" +``` + +Expected output includes one log line for the local capture listener: + +```text +Started actor egress capture listener ... "port":15001 ... "protocol":"connect" +``` + +After the `/egress` request, the logs should also show the captured stream: + +```bash +kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Proxying captured actor egress" +``` + +Expected output includes: + +```text +Proxying captured actor egress ... "originalDestination":"...:443" ... "connectAuthority":"httpbin.org:443" +``` + +## Verify capture was installed + +Find the worker pod hosting the actor: + +```bash +actor_json=$(kubectl ate get actor my-counter-1 -o json) +ateom_ns=$(jq -r '.actors[0].ateomPodNamespace' <<<"${actor_json}") +ateom_pod=$(jq -r '.actors[0].ateomPodName' <<<"${actor_json}") + +echo "${ateom_ns}/${ateom_pod}" +``` + +Check that the ateom pod received capture configuration: + +```bash +kubectl get pod -n "${ateom_ns}" "${ateom_pod}" \ + -o jsonpath='{range .spec.containers[?(@.name=="ateom")].env[*]}{.name}={.value}{"\n"}{end}' \ + | grep ATE_EGRESS +``` + +Expected output includes: + +```text +ATE_EGRESS_CAPTURE_ENABLED=true +ATE_EGRESS_PEP_ADDRESS=ate-egress.agentgateway-system.svc.cluster.local:15008 +ATE_EGRESS_TUNNEL_PROTOCOL=connect +``` + +Check the ateom logs: + +```bash +kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Started actor egress capture listener" +``` + +Expected output includes one log line for the local capture listener: + +```text +Started actor egress capture listener ... "port":15001 ... "protocol":"connect" +``` + +After the `/egress` request, the logs should also show the captured stream: + +```bash +kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Proxying captured actor egress" +``` + +Expected output includes: + +```text +Proxying captured actor egress ... "originalDestination":"...:443" ... "connectAuthority":"httpbin.org:443" +``` + +## Check agentgateway logs + +The `ate-egress` Gateway creates an agentgateway dataplane pod in the +`agentgateway-system` namespace. Check dataplane logs with: + +```bash +kubectl logs -n agentgateway-system \ + -l gateway.networking.k8s.io/gateway-name=ate-egress \ + --all-containers --tail=200 +``` + +After a successful `/egress` request, dataplane logs should include a TCP route +entry similar to: + +```text +request gateway=agentgateway-system/ate-egress listener=https route=agentgateway-system/httpbin-egress ... protocol=tcp +``` + +If the Gateway, TCPRoute, or policy is not being programmed, check the +agentgateway controller logs: + +```bash +kubectl logs -n agentgateway-system deploy/agentgateway --tail=200 +``` + +## Clean up + +```bash +kubectl ate suspend actor my-counter-1 +kubectl ate delete actor my-counter-1 +./hack/install-ate.sh --delete-demo-counter +``` + +## Troubleshooting + +If the `ATE_EGRESS_*` variables are missing from the worker pod, restart the +controller and recreate the counter WorkerPool pods after creating the config +map: + +```bash +kubectl rollout restart deployment/ate-controller -n ate-system +kubectl rollout status deployment/ate-controller -n ate-system +kubectl rollout restart deployment/counter-deployment -n ate-demo-counter +kubectl rollout status deployment/counter-deployment -n ate-demo-counter +``` + +If the capture listener logs are missing, confirm that the actor is running on a +fresh worker pod created after egress was enabled: + +```bash +kubectl ate get actor my-counter-1 +kubectl get pods -n ate-demo-counter -l ate.dev/worker-pool=counter +``` + +If the microVM curl returns `503 Service Unavailable` with `no free workers +available`, the request reached the router and ate-api, but no eligible microVM +worker was available for assignment. Check the worker pods and node label: + +```bash +kubectl get nodes -L ate.dev/sandboxClass +kubectl get pods -n ate-demo-counter-microvm -o wide +kubectl describe pod -n ate-demo-counter-microvm \ + -l ate.dev/worker-pool=counter-microvm +``` + +The microVM WorkerPool pods require `nodeSelector: +ate.dev/sandboxClass=microvm` and `/dev/kvm`. For kind, recreate the cluster with +`hack/create-kind-cluster.sh`, or label the node if KVM is already mounted: + +```bash +kubectl label node kind-control-plane ate.dev/sandboxClass=microvm --overwrite +kubectl rollout restart deployment/counter-microvm-deployment -n ate-demo-counter-microvm +kubectl rollout status deployment/counter-microvm-deployment -n ate-demo-counter-microvm +``` + +If `/egress` fails after changing the `url` host, remember that this demo only +configures agentgateway for `httpbin.org:443`. Add matching static agentgateway +backend resources for the new destination: + +- HTTPS: Service, EndpointSlice, TCP listener on `443`, and TCPRoute. +- Plaintext HTTP: Service, EndpointSlice, TCP listener on `80`, and TCPRoute. + +Traffic without SNI or a plaintext HTTP `Host` header falls back to the captured +original destination IP and port, which requires matching agentgateway routing +for that address. diff --git a/hack/install-ate.sh b/hack/install-ate.sh index e7e910a24..8731ec371 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -49,6 +49,16 @@ source "${ROOT}"/hack/install-demo-multi-template.sh COLOR_CYAN='\033[1;36m' COLOR_RESET='\033[0m' +ATE_EGRESS_CAPTURE="${ATE_EGRESS_CAPTURE:-false}" +ATE_EGRESS_PEP_ADDRESS="${ATE_EGRESS_PEP_ADDRESS:-ate-egress.agentgateway-system.svc.cluster.local:15008}" +ATE_EGRESS_TUNNEL_PROTOCOL="${ATE_EGRESS_TUNNEL_PROTOCOL:-connect}" +ATE_EGRESS_CAPTURE_ENABLED_ENV="ATE_EGRESS_CAPTURE_ENABLED" +ATE_EGRESS_PEP_ADDRESS_ENV="ATE_EGRESS_PEP_ADDRESS" +ATE_EGRESS_TUNNEL_PROTOCOL_ENV="ATE_EGRESS_TUNNEL_PROTOCOL" +ATE_EGRESS_CONNECT_TLS_SERVER_NAME_ENV="ATE_EGRESS_CONNECT_TLS_SERVER_NAME" +ATE_EGRESS_CONNECT_TLS_CA_FILE_ENV="ATE_EGRESS_CONNECT_TLS_CA_FILE" +ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY_ENV="ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY" + function log_step() { local step_name="$1" echo -e "${COLOR_CYAN}[step]: ${step_name}${COLOR_RESET}" @@ -69,7 +79,9 @@ function usage() { echo "" echo " --deploy-atelet Deploy atelet only" echo " --deploy-ate-apiserver Deploy ate-api-server only" + echo " --deploy-ate-controller Deploy ate-controller only" echo " --deploy-atenet Deploy atenet only" + echo " --egress Enable actor egress capture via agentgateway" echo "" echo "To create individual resources used by ate-system (Note: These are" echo "called automatically by --deploy-ate-system):" @@ -103,6 +115,12 @@ run_kubectl() { "$@" } +run_helm() { + helm \ + ${KUBECTL_CONTEXT:+--kube-context=${KUBECTL_CONTEXT}} \ + "$@" +} + run_kubectl_ate() { go run ./cmd/kubectl-ate \ ${KUBECTL_CONTEXT:+--context=${KUBECTL_CONTEXT}} \ @@ -165,6 +183,216 @@ render_ate_system_manifests() { # Build everything resolved with base manifests for GKE run_ko resolve -f manifests/ate-install fi +resolve_ipv4_addresses() { + local host="$1" + if command -v python3 >/dev/null 2>&1; then + python3 - "${host}" <<'PY' +import socket +import sys + +host = sys.argv[1] +addresses = sorted({ + result[4][0] + for result in socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM) +}) +print(" ".join(addresses)) +PY + return + fi + if command -v python >/dev/null 2>&1; then + python - "${host}" <<'PY' +import socket +import sys + +host = sys.argv[1] +addresses = sorted(set( + result[4][0] + for result in socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM) +)) +print(" ".join(addresses)) +PY + return + fi + if command -v dig >/dev/null 2>&1; then + dig +short A "${host}" | tr '\n' ' ' + return + fi + if command -v nslookup >/dev/null 2>&1; then + nslookup "${host}" | awk '/^Address: / { print $2 }' | tr '\n' ' ' + return + fi + echo "unable to resolve ${host}: python3, python, dig, or nslookup is required" >&2 + return 1 +} + +ensure_gateway_api() { + log_step "ensure_gateway_api" + if run_kubectl get crd \ + gatewayclasses.gateway.networking.k8s.io \ + gateways.gateway.networking.k8s.io \ + tcproutes.gateway.networking.k8s.io >/dev/null 2>&1; then + return + fi + + run_kubectl apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.0/experimental-install.yaml +} + +deploy_agentgateway() { + log_step "deploy_agentgateway" + local httpbin_ips="${HTTPBIN_EGRESS_IPS:-}" + if [[ -z "${httpbin_ips}" ]]; then + httpbin_ips="$(resolve_ipv4_addresses httpbin.org)" + fi + if [[ -z "${httpbin_ips}" ]]; then + echo "failed to resolve httpbin.org IPv4 addresses" >&2 + exit 1 + fi + + local httpbin_endpoints="" + local ip="" + for ip in ${httpbin_ips}; do + httpbin_endpoints+=" - addresses:\n - ${ip}\n" + done + + ensure_gateway_api + run_helm upgrade -i --create-namespace \ + --namespace agentgateway-system \ + --version v1.3.1 agentgateway-crds oci://cr.agentgateway.dev/charts/agentgateway-crds + run_helm upgrade -i -n agentgateway-system agentgateway oci://cr.agentgateway.dev/charts/agentgateway \ + --version v1.3.1 + run_kubectl delete deployment -n agentgateway-system ate-egress --ignore-not-found >/dev/null 2>&1 || true + run_kubectl delete service -n agentgateway-system ate-egress --ignore-not-found >/dev/null 2>&1 || true + run_kubectl delete service -n agentgateway-system httpbin-egress --ignore-not-found >/dev/null 2>&1 || true + run_kubectl delete endpointslice -n agentgateway-system httpbin-egress --ignore-not-found >/dev/null 2>&1 || true + run_kubectl delete gateway -n agentgateway-system ate-egress --ignore-not-found >/dev/null 2>&1 || true + run_kubectl delete agentgatewaypolicy -n agentgateway-system ate-egress-connect --ignore-not-found >/dev/null 2>&1 || true + printf "%b" "$(cat </dev/null || true)" + if [[ -z "${deployments}" ]]; then + echo "No worker deployments found to restart for egress." + return + fi + + local ns name + while read -r ns name; do + if [[ -z "${ns}" || -z "${name}" ]]; then + continue + fi + run_kubectl rollout restart "deployment/${name}" -n "${ns}" + run_kubectl rollout status "deployment/${name}" -n "${ns}" --timeout=120s + done <<< "${deployments}" } create_valkey_ca_certs_secret() { @@ -269,6 +497,11 @@ deploy_ate_system() { log_step "deploy_ate_system" ensure_crds + if [[ "${ATE_EGRESS_CAPTURE}" == "true" ]]; then + deploy_agentgateway + create_egress_capture_config + fi + # Enforce per-class SandboxConfig asset requirements (applied before any # SandboxConfig so the defaults below are validated too). run_kubectl apply -f manifests/ate-install/sandboxconfig-validation.yaml @@ -360,6 +593,19 @@ deploy_atelet() { run_kubectl rollout status daemonset/atelet -n ate-system --timeout=120s } +deploy_ate_controller() { + log_step "deploy_ate_controller" + if [[ "${ATE_EGRESS_CAPTURE}" == "true" ]]; then + deploy_agentgateway + create_egress_capture_config + fi + run_ko apply -f manifests/ate-install/ate-controller.yaml + run_kubectl rollout status deployment/ate-controller -n ate-system --timeout=120s + if [[ "${ATE_EGRESS_CAPTURE}" == "true" ]]; then + rollout_worker_deployments_for_egress + fi +} + deploy_atenet() { log_step "deploy_atenet" ensure_crds @@ -519,6 +765,9 @@ for arg in "$@"; do usage exit 0 ;; + --egress) + ATE_EGRESS_CAPTURE="true" + ;; esac done @@ -577,9 +826,11 @@ while [[ "$#" -gt 0 ]]; do --deploy-atelet) deploy_atelet ;; --deploy-ate-apiserver) deploy_ate_apiserver ;; + --deploy-ate-controller) deploy_ate_controller ;; --deploy-atenet) deploy_atenet ;; --delete-atenet) delete_atenet ;; + --egress) ;; --deploy-benchmarks) deploy_benchmarks ;; --delete-benchmarks) delete_benchmarks ;; diff --git a/internal/egresscapture/capture.go b/internal/egresscapture/capture.go new file mode 100644 index 000000000..c9673d8d2 --- /dev/null +++ b/internal/egresscapture/capture.go @@ -0,0 +1,621 @@ +// 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 egresscapture + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/net/http2" +) + +type ActorIdentity struct { + Namespace string + Template string + ActorID string + // TODO: Include worker_uid once egress identity is modeled as a signed + // first-class Substrate identity rather than plain actor metadata headers. +} + +type Config struct { + PEPAddress string + Protocol string + TLS TLSConfig + Listeners []Listener +} + +type TLSConfig struct { + ServerName string + CAFile string + InsecureSkipVerify bool +} + +type Listener struct { + Port uint16 +} + +type OriginalDestinationFunc func(net.Conn) (net.Addr, error) + +type Capture struct { + cancel context.CancelFunc + listeners []net.Listener + wg sync.WaitGroup +} + +type TunnelTransport interface { + Open(ctx context.Context, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) +} + +type TunnelTransportFactory func(Config) (TunnelTransport, error) + +var tunnelTransportFactories = map[string]TunnelTransportFactory{} + +func init() { + // Keep tunnel protocol support behind factories so additional transports + // such as HBONE can plug in without changing capture/listener logic. + RegisterTunnelTransport(TunnelProtocolConnect, newPlaintextCONNECTTunnelTransport) + RegisterTunnelTransport(TunnelProtocolPlaintext, newPlaintextCONNECTTunnelTransport) + RegisterTunnelTransport(TunnelProtocolH2C, newPlaintextCONNECTTunnelTransport) + RegisterTunnelTransport(TunnelProtocolConnectTLS, newTLSCONNECTTunnelTransport) + RegisterTunnelTransport(TunnelProtocolHTTPSConnect, newTLSCONNECTTunnelTransport) + RegisterTunnelTransport(TunnelProtocolTLSConnect, newTLSCONNECTTunnelTransport) +} + +func RegisterTunnelTransport(protocol string, factory TunnelTransportFactory) { + tunnelTransportFactories[strings.ToLower(strings.TrimSpace(protocol))] = factory +} + +func EnabledFromEnv() bool { + enabled, _ := strconv.ParseBool(os.Getenv(EnvCaptureEnabled)) + return enabled +} + +func ConfigFromEnv(listeners []Listener) (Config, error) { + pepAddress := strings.TrimSpace(os.Getenv(EnvPEPAddress)) + if pepAddress == "" { + return Config{}, fmt.Errorf("%s must be set when egress capture is enabled", EnvPEPAddress) + } + cfg := Config{ + PEPAddress: pepAddress, + Protocol: DefaultTunnelProtocol, + TLS: TLSConfig{ + ServerName: os.Getenv(EnvConnectTLSServerName), + CAFile: os.Getenv(EnvConnectTLSCAFile), + InsecureSkipVerify: boolEnv(EnvConnectTLSInsecureSkipVerify), + }, + Listeners: listeners, + } + if v := os.Getenv(EnvTunnelProtocol); v != "" { + cfg.Protocol = v + } + return cfg, nil +} + +func boolEnv(name string) bool { + enabled, _ := strconv.ParseBool(os.Getenv(name)) + return enabled +} + +func Start(ctx context.Context, identity ActorIdentity, cfg Config, originalDestination OriginalDestinationFunc) (*Capture, error) { + if originalDestination == nil { + return nil, errors.New("original destination resolver must be set") + } + transport, err := NewTunnelTransport(cfg) + if err != nil { + return nil, err + } + + ctx, cancel := newCaptureContext(ctx) + capture := &Capture{cancel: cancel} + for _, listenerCfg := range cfg.Listeners { + lis, err := net.Listen("tcp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(int(listenerCfg.Port)))) + if err != nil { + capture.Close() + return nil, fmt.Errorf("while listening for captured egress on port %d: %w", listenerCfg.Port, err) + } + + capture.listeners = append(capture.listeners, lis) + capture.wg.Add(1) + go capture.serve(ctx, lis, identity, transport, originalDestination) + slog.InfoContext(ctx, "Started actor egress capture listener", + "port", listenerCfg.Port, + "pepAddress", cfg.PEPAddress, + "protocol", cfg.Protocol) + } + return capture, nil +} + +func newCaptureContext(ctx context.Context) (context.Context, context.CancelFunc) { + // The setup request context can be cancelled after the actor is running, but + // egress capture must keep serving until actor network cleanup closes it. + return context.WithCancel(context.WithoutCancel(ctx)) +} + +func (c *Capture) Close() error { + if c.cancel != nil { + c.cancel() + } + + var err error + for _, lis := range c.listeners { + if closeErr := lis.Close(); closeErr != nil && !errors.Is(closeErr, net.ErrClosed) { + err = errors.Join(err, closeErr) + } + } + c.wg.Wait() + return err +} + +func (c *Capture) serve(ctx context.Context, lis net.Listener, identity ActorIdentity, transport TunnelTransport, originalDestination OriginalDestinationFunc) { + defer c.wg.Done() + for { + conn, err := lis.Accept() + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return + } + slog.WarnContext(ctx, "Failed to accept captured egress connection", "err", err) + continue + } + c.wg.Add(1) + go func() { + defer c.wg.Done() + handleCapturedEgress(ctx, conn, identity, transport, originalDestination) + }() + } +} + +func handleCapturedEgress(ctx context.Context, actorConn net.Conn, identity ActorIdentity, transport TunnelTransport, originalDestination OriginalDestinationFunc) { + stopActorClose := context.AfterFunc(ctx, func() { + _ = actorConn.Close() + }) + defer stopActorClose() + defer actorConn.Close() + + originalDst, err := originalDestination(actorConn) + if err != nil { + slog.WarnContext(ctx, "Failed to resolve captured egress original destination", "err", err) + return + } + + authority, initialBytes := deriveConnectAuthority(ctx, actorConn, originalDst) + tunnel, err := transport.Open(ctx, identity, originalDst, authority) + if err != nil { + slog.WarnContext(ctx, "Failed to open egress tunnel", + "originalDestination", originalDst.String(), + "connectAuthority", authority, + "err", err) + return + } + defer tunnel.Close() + + slog.InfoContext(ctx, "Proxying captured actor egress", + "actorID", identity.ActorID, + "actorTemplateNamespace", identity.Namespace, + "actorTemplateName", identity.Template, + "originalDestination", originalDst.String(), + "connectAuthority", authority) + + proxyByteStream(ctx, actorConn, tunnel, initialBytes) +} + +func proxyByteStream(ctx context.Context, actorConn net.Conn, tunnel io.ReadWriteCloser, initialBytes []byte) { + stopProxyClose := context.AfterFunc(ctx, func() { + _ = actorConn.Close() + _ = tunnel.Close() + }) + defer stopProxyClose() + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + if len(initialBytes) > 0 { + if _, err := tunnel.Write(initialBytes); err != nil { + _ = tunnel.Close() + return + } + } + _, _ = io.Copy(tunnel, actorConn) + _ = tunnel.Close() + }() + go func() { + defer wg.Done() + _, _ = io.Copy(actorConn, tunnel) + if tcpConn, ok := actorConn.(*net.TCPConn); ok { + _ = tcpConn.CloseWrite() + } + }() + wg.Wait() +} + +func NewTunnelTransport(cfg Config) (TunnelTransport, error) { + protocol := strings.ToLower(strings.TrimSpace(cfg.Protocol)) + if protocol == "" { + protocol = DefaultTunnelProtocol + } + factory, ok := tunnelTransportFactories[protocol] + if !ok { + return nil, fmt.Errorf("unsupported egress tunnel protocol %q", cfg.Protocol) + } + return factory(cfg) +} + +type PlaintextCONNECTTunnelTransport struct { + PEPAddress string +} + +func newPlaintextCONNECTTunnelTransport(cfg Config) (TunnelTransport, error) { + return &PlaintextCONNECTTunnelTransport{PEPAddress: cfg.PEPAddress}, nil +} + +func (t *PlaintextCONNECTTunnelTransport) Open(ctx context.Context, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) { + req, pr, pw := newConnectRequest(ctx, "http", identity, originalDst, authority) + transport := &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, network, t.PEPAddress) + }, + } + return roundTripConnect(transport, req, pr, pw, authority, t.PEPAddress) +} + +type TLSCONNECTTunnelTransport struct { + PEPAddress string + TLS TLSConfig +} + +func newTLSCONNECTTunnelTransport(cfg Config) (TunnelTransport, error) { + return &TLSCONNECTTunnelTransport{PEPAddress: cfg.PEPAddress, TLS: cfg.TLS}, nil +} + +func (t *TLSCONNECTTunnelTransport) Open(ctx context.Context, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) { + req, pr, pw := newConnectRequest(ctx, "https", identity, originalDst, authority) + tlsConfig, err := t.tlsConfig() + if err != nil { + _ = pr.CloseWithError(err) + _ = pw.CloseWithError(err) + return nil, err + } + transport := &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, network, t.PEPAddress) + if err != nil { + return nil, err + } + tlsConn := tls.Client(conn, tlsConfig) + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = conn.Close() + return nil, err + } + return tlsConn, nil + }, + } + return roundTripConnect(transport, req, pr, pw, authority, t.PEPAddress) +} + +func (t *TLSCONNECTTunnelTransport) tlsConfig() (*tls.Config, error) { + cfg := &tls.Config{ + NextProtos: []string{"h2"}, + ServerName: t.TLS.ServerName, + InsecureSkipVerify: t.TLS.InsecureSkipVerify, + } + if t.TLS.CAFile == "" { + return cfg, nil + } + rootsPEM, err := os.ReadFile(t.TLS.CAFile) + if err != nil { + return nil, fmt.Errorf("while reading CONNECT TLS CA file %q: %w", t.TLS.CAFile, err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(rootsPEM) { + return nil, fmt.Errorf("CONNECT TLS CA file %q contains no certificates", t.TLS.CAFile) + } + cfg.RootCAs = roots + return cfg, nil +} + +func deriveConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst net.Addr) (string, []byte) { + if tcpAddr, ok := originalDst.(*net.TCPAddr); ok && tcpAddr.Port == 443 { + authority, initialBytes := deriveTLSConnectAuthority(ctx, actorConn, tcpAddr) + return authority, initialBytes + } + if tcpAddr, ok := originalDst.(*net.TCPAddr); ok && tcpAddr.Port == 80 { + authority, initialBytes := deriveHTTPConnectAuthority(ctx, actorConn, tcpAddr) + return authority, initialBytes + } + return originalDst.String(), nil +} + +func deriveTLSConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst *net.TCPAddr) (string, []byte) { + const maxClientHelloBytes = 16 * 1024 + _ = actorConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + defer actorConn.SetReadDeadline(time.Time{}) + + var initialBytes []byte + buf := make([]byte, 2048) + for len(initialBytes) < maxClientHelloBytes { + n, err := actorConn.Read(buf) + if n > 0 { + initialBytes = append(initialBytes, buf[:n]...) + if sni, ok, needMore := tlsClientHelloSNI(initialBytes); ok { + return net.JoinHostPort(sni, strconv.Itoa(originalDst.Port)), initialBytes + } else if !needMore { + break + } + } + if err != nil { + if ctx.Err() != nil { + return originalDst.String(), initialBytes + } + break + } + } + return originalDst.String(), initialBytes +} + +func deriveHTTPConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst *net.TCPAddr) (string, []byte) { + const maxHeaderBytes = 16 * 1024 + _ = actorConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + defer actorConn.SetReadDeadline(time.Time{}) + + var initialBytes []byte + buf := make([]byte, 2048) + for len(initialBytes) < maxHeaderBytes { + n, err := actorConn.Read(buf) + if n > 0 { + initialBytes = append(initialBytes, buf[:n]...) + if host, ok, needMore := httpHostHeader(initialBytes); ok { + return authorityWithDefaultPort(host, originalDst.Port), initialBytes + } else if !needMore { + break + } + } + if err != nil { + if ctx.Err() != nil { + return originalDst.String(), initialBytes + } + break + } + } + return originalDst.String(), initialBytes +} + +func httpHostHeader(data []byte) (string, bool, bool) { + headers := string(data) + headerEnd := strings.Index(headers, "\r\n\r\n") + separator := "\r\n" + if headerEnd == -1 { + headerEnd = strings.Index(headers, "\n\n") + separator = "\n" + } + if headerEnd == -1 { + return "", false, len(data) < 16*1024 + } + + lines := strings.Split(headers[:headerEnd], separator) + if len(lines) == 0 || !strings.Contains(lines[0], " ") { + return "", false, false + } + for _, line := range lines[1:] { + name, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + if strings.EqualFold(strings.TrimSpace(name), "host") { + host := strings.TrimSpace(value) + return host, host != "", false + } + } + return "", false, false +} + +func authorityWithDefaultPort(host string, port int) string { + host = strings.TrimSpace(host) + if host == "" { + return "" + } + if _, _, err := net.SplitHostPort(host); err == nil { + return host + } + return net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(port)) +} + +func tlsClientHelloSNI(data []byte) (string, bool, bool) { + if len(data) < 5 { + return "", false, true + } + if data[0] != 0x16 { + return "", false, false + } + recordLen := int(binary.BigEndian.Uint16(data[3:5])) + if len(data) < 5+recordLen { + return "", false, true + } + + record := data[5 : 5+recordLen] + if len(record) < 4 || record[0] != 0x01 { + return "", false, false + } + handshakeLen := int(record[1])<<16 | int(record[2])<<8 | int(record[3]) + if len(record) < 4+handshakeLen { + return "", false, false + } + clientHello := record[4 : 4+handshakeLen] + if len(clientHello) < 34 { + return "", false, false + } + + offset := 34 + if len(clientHello) < offset+1 { + return "", false, false + } + sessionIDLen := int(clientHello[offset]) + offset++ + if len(clientHello) < offset+sessionIDLen+2 { + return "", false, false + } + offset += sessionIDLen + + cipherSuitesLen := int(binary.BigEndian.Uint16(clientHello[offset : offset+2])) + offset += 2 + if len(clientHello) < offset+cipherSuitesLen+1 { + return "", false, false + } + offset += cipherSuitesLen + + compressionMethodsLen := int(clientHello[offset]) + offset++ + if len(clientHello) < offset+compressionMethodsLen+2 { + return "", false, false + } + offset += compressionMethodsLen + + extensionsLen := int(binary.BigEndian.Uint16(clientHello[offset : offset+2])) + offset += 2 + if len(clientHello) < offset+extensionsLen { + return "", false, false + } + extensions := clientHello[offset : offset+extensionsLen] + for len(extensions) >= 4 { + extensionType := binary.BigEndian.Uint16(extensions[0:2]) + extensionLen := int(binary.BigEndian.Uint16(extensions[2:4])) + extensions = extensions[4:] + if len(extensions) < extensionLen { + return "", false, false + } + extensionData := extensions[:extensionLen] + extensions = extensions[extensionLen:] + if extensionType != 0 { + continue + } + if len(extensionData) < 2 { + return "", false, false + } + serverNameListLen := int(binary.BigEndian.Uint16(extensionData[0:2])) + if len(extensionData) < 2+serverNameListLen { + return "", false, false + } + serverNames := extensionData[2 : 2+serverNameListLen] + for len(serverNames) >= 3 { + nameType := serverNames[0] + nameLen := int(binary.BigEndian.Uint16(serverNames[1:3])) + serverNames = serverNames[3:] + if len(serverNames) < nameLen { + return "", false, false + } + name := string(serverNames[:nameLen]) + serverNames = serverNames[nameLen:] + if nameType == 0 && name != "" { + return name, true, false + } + } + return "", false, false + } + return "", false, false +} + +func newConnectRequest(ctx context.Context, scheme string, identity ActorIdentity, originalDst net.Addr, authority string) (*http.Request, *io.PipeReader, *io.PipeWriter) { + pr, pw := io.Pipe() + req := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Scheme: scheme, Host: authority}, + Host: authority, + Header: make(http.Header), + Body: pr, + ContentLength: -1, + } + req = req.WithContext(ctx) + // TODO: Replace these plain identity headers with a signed short-lived actor + // identity token for the PEP. The signed claims should include sub, aud, exp, + // iat, worker_uid, and the original destination so policy is evaluated over + // verified request identity rather than unsigned metadata. + req.Header.Set("x-ate-actor-id", identity.ActorID) + req.Header.Set("x-ate-actor-template", identity.Template) + req.Header.Set("x-ate-actor-template-namespace", identity.Namespace) + req.Header.Set("x-ate-original-destination", originalDst.String()) + if authority != originalDst.String() { + req.Header.Set("x-ate-connect-authority", authority) + } + return req, pr, pw +} + +func roundTripConnect( + transport *http2.Transport, + req *http.Request, + pr *io.PipeReader, + pw *io.PipeWriter, + connectAuthority string, + pepAddress string, +) (io.ReadWriteCloser, error) { + resp, err := transport.RoundTrip(req) + if err != nil { + _ = pr.CloseWithError(err) + _ = pw.CloseWithError(err) + transport.CloseIdleConnections() + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + _ = resp.Body.Close() + err := fmt.Errorf("CONNECT to %s through %s returned %s", connectAuthority, pepAddress, resp.Status) + _ = pr.CloseWithError(err) + _ = pw.CloseWithError(err) + transport.CloseIdleConnections() + return nil, err + } + return &hboneStream{ + requestWriter: pw, + responseBody: resp.Body, + closeIdle: transport.CloseIdleConnections, + }, nil +} + +type hboneStream struct { + requestWriter *io.PipeWriter + responseBody io.ReadCloser + closeIdle func() +} + +func (s *hboneStream) Read(p []byte) (int, error) { + return s.responseBody.Read(p) +} + +func (s *hboneStream) Write(p []byte) (int, error) { + return s.requestWriter.Write(p) +} + +func (s *hboneStream) Close() error { + err := errors.Join(s.requestWriter.Close(), s.responseBody.Close()) + if s.closeIdle != nil { + s.closeIdle() + } + return err +} diff --git a/internal/egresscapture/capture_test.go b/internal/egresscapture/capture_test.go new file mode 100644 index 000000000..3ede130da --- /dev/null +++ b/internal/egresscapture/capture_test.go @@ -0,0 +1,265 @@ +// 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 egresscapture + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "strings" + "testing" + "time" +) + +type contextKey string + +func TestNewCaptureContextIgnoresParentCancellation(t *testing.T) { + parent, parentCancel := context.WithCancel(context.WithValue(context.Background(), contextKey("trace"), "value")) + parentCancel() + + ctx, cancel := newCaptureContext(parent) + defer cancel() + + if err := ctx.Err(); err != nil { + t.Fatalf("capture context is cancelled by parent: %v", err) + } + if got := ctx.Value(contextKey("trace")); got != "value" { + t.Fatalf("capture context did not preserve values: got %v", got) + } + + cancel() + if err := ctx.Err(); err == nil { + t.Fatal("capture context was not cancelled by its own cancel func") + } +} + +func TestConfigFromEnvRequiresPEPAddress(t *testing.T) { + t.Setenv(EnvPEPAddress, "") + + _, err := ConfigFromEnv(nil) + if err == nil { + t.Fatal("ConfigFromEnv() returned nil error, want error") + } + if !strings.Contains(err.Error(), EnvPEPAddress) { + t.Fatalf("ConfigFromEnv() error = %v, want %s", err, EnvPEPAddress) + } +} + +func TestConfigFromEnv(t *testing.T) { + t.Setenv(EnvPEPAddress, "ate-egress.example:15008") + t.Setenv(EnvTunnelProtocol, TunnelProtocolConnectTLS) + t.Setenv(EnvConnectTLSServerName, "ate-egress.example") + t.Setenv(EnvConnectTLSCAFile, "/run/egress-ca/ca.crt") + t.Setenv(EnvConnectTLSInsecureSkipVerify, "true") + + listeners := []Listener{{Port: 15001}} + cfg, err := ConfigFromEnv(listeners) + if err != nil { + t.Fatalf("ConfigFromEnv() returned error: %v", err) + } + if cfg.PEPAddress != "ate-egress.example:15008" { + t.Fatalf("cfg.PEPAddress = %q, want ate-egress.example:15008", cfg.PEPAddress) + } + if cfg.Protocol != TunnelProtocolConnectTLS { + t.Fatalf("cfg.Protocol = %q, want %s", cfg.Protocol, TunnelProtocolConnectTLS) + } + if cfg.TLS.ServerName != "ate-egress.example" { + t.Fatalf("cfg.TLS.ServerName = %q, want ate-egress.example", cfg.TLS.ServerName) + } + if cfg.TLS.CAFile != "/run/egress-ca/ca.crt" { + t.Fatalf("cfg.TLS.CAFile = %q, want /run/egress-ca/ca.crt", cfg.TLS.CAFile) + } + if !cfg.TLS.InsecureSkipVerify { + t.Fatal("cfg.TLS.InsecureSkipVerify = false, want true") + } + if len(cfg.Listeners) != 1 || cfg.Listeners[0].Port != 15001 { + t.Fatalf("cfg.Listeners = %+v, want port 15001", cfg.Listeners) + } +} + +func TestNewTunnelTransportUsesRegisteredFactories(t *testing.T) { + for _, tc := range []struct { + name string + protocol string + wantType any + }{ + { + name: "default connect", + protocol: "", + wantType: &PlaintextCONNECTTunnelTransport{}, + }, + { + name: "plaintext alias", + protocol: TunnelProtocolPlaintext, + wantType: &PlaintextCONNECTTunnelTransport{}, + }, + { + name: "tls connect alias", + protocol: TunnelProtocolTLSConnect, + wantType: &TLSCONNECTTunnelTransport{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := NewTunnelTransport(Config{PEPAddress: "ate-egress.example:15008", Protocol: tc.protocol}) + if err != nil { + t.Fatalf("NewTunnelTransport() returned error: %v", err) + } + if fmt.Sprintf("%T", got) != fmt.Sprintf("%T", tc.wantType) { + t.Fatalf("NewTunnelTransport() = %T, want %T", got, tc.wantType) + } + }) + } + + if _, err := NewTunnelTransport(Config{Protocol: "does-not-exist"}); err == nil { + t.Fatal("NewTunnelTransport() returned nil error for unsupported protocol") + } +} + +func TestNewConnectRequestUsesConfiguredAuthority(t *testing.T) { + originalDst := &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 443} + req, pr, pw := newConnectRequest(context.Background(), "http", ActorIdentity{ + Namespace: "default", + Template: "counter", + ActorID: "my-counter-1", + }, originalDst, "httpbin.org:443") + defer pr.Close() + defer pw.Close() + + if req.Host != "httpbin.org:443" { + t.Fatalf("req.Host = %q, want httpbin.org:443", req.Host) + } + if req.URL.Host != "httpbin.org:443" { + t.Fatalf("req.URL.Host = %q, want httpbin.org:443", req.URL.Host) + } + if got := req.Header.Get("x-ate-original-destination"); got != originalDst.String() { + t.Fatalf("x-ate-original-destination = %q, want %q", got, originalDst.String()) + } + if got := req.Header.Get("x-ate-connect-authority"); got != "httpbin.org:443" { + t.Fatalf("x-ate-connect-authority = %q, want httpbin.org:443", got) + } +} + +func TestDeriveConnectAuthorityFromTLSClientHelloSNI(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + errCh := make(chan error, 1) + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: "httpbin.org", + InsecureSkipVerify: true, + }) + errCh <- tlsConn.Handshake() + }() + + originalDst := &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 443} + authority, initialBytes := deriveConnectAuthority(context.Background(), serverConn, originalDst) + if authority != "httpbin.org:443" { + t.Fatalf("deriveConnectAuthority() authority = %q, want httpbin.org:443", authority) + } + if len(initialBytes) == 0 { + t.Fatal("deriveConnectAuthority() returned no initial bytes") + } + if _, ok, _ := tlsClientHelloSNI(initialBytes); !ok { + t.Fatal("initial bytes do not contain a parseable TLS ClientHello SNI") + } + + _ = clientConn.Close() + if err := <-errCh; err == nil { + t.Fatal("TLS handshake unexpectedly succeeded") + } else if err != io.ErrClosedPipe && !strings.Contains(err.Error(), "closed") { + t.Fatalf("TLS handshake error = %v, want closed connection", err) + } +} + +func TestDeriveConnectAuthorityFromHTTPHost(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + errCh := make(chan error, 1) + go func() { + _, err := clientConn.Write([]byte("GET /get HTTP/1.1\r\nHost: httpbin.org\r\nUser-Agent: test\r\n\r\n")) + errCh <- err + }() + + originalDst := &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 80} + authority, initialBytes := deriveConnectAuthority(context.Background(), serverConn, originalDst) + if authority != "httpbin.org:80" { + t.Fatalf("deriveConnectAuthority() authority = %q, want httpbin.org:80", authority) + } + if string(initialBytes) != "GET /get HTTP/1.1\r\nHost: httpbin.org\r\nUser-Agent: test\r\n\r\n" { + t.Fatalf("initial bytes = %q", string(initialBytes)) + } + if err := <-errCh; err != nil { + t.Fatalf("client write returned error: %v", err) + } +} + +func TestProxyByteStreamStopsWhenContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + actorConn, actorPeer := net.Pipe() + defer actorPeer.Close() + tunnelConn, tunnelPeer := net.Pipe() + defer tunnelPeer.Close() + + done := make(chan struct{}) + go func() { + proxyByteStream(ctx, actorConn, tunnelConn, nil) + close(done) + }() + + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("proxyByteStream did not stop after context cancellation") + } +} + +func TestHBONEStreamCloseClosesIdleTransportConnections(t *testing.T) { + pr, pw := io.Pipe() + defer pr.Close() + + called := false + stream := &hboneStream{ + requestWriter: pw, + responseBody: io.NopCloser(strings.NewReader("")), + closeIdle: func() { + called = true + }, + } + + if err := stream.Close(); err != nil { + t.Fatalf("stream.Close() returned error: %v", err) + } + if !called { + t.Fatal("stream.Close() did not close idle transport connections") + } +} + +func TestHTTPHostHeaderWithPort(t *testing.T) { + host, ok, needMore := httpHostHeader([]byte("GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n")) + if !ok || needMore { + t.Fatalf("httpHostHeader() ok=%t needMore=%t, want ok=true needMore=false", ok, needMore) + } + if got := authorityWithDefaultPort(host, 80); got != "example.com:8080" { + t.Fatalf("authorityWithDefaultPort() = %q, want example.com:8080", got) + } +} diff --git a/internal/egresscapture/env.go b/internal/egresscapture/env.go new file mode 100644 index 000000000..211e81092 --- /dev/null +++ b/internal/egresscapture/env.go @@ -0,0 +1,38 @@ +// 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 egresscapture + +const ( + EnvCaptureEnabled = "ATE_EGRESS_CAPTURE_ENABLED" + EnvPEPAddress = "ATE_EGRESS_PEP_ADDRESS" + EnvTunnelProtocol = "ATE_EGRESS_TUNNEL_PROTOCOL" + EnvConnectTLSServerName = "ATE_EGRESS_CONNECT_TLS_SERVER_NAME" + EnvConnectTLSCAFile = "ATE_EGRESS_CONNECT_TLS_CA_FILE" + EnvConnectTLSInsecureSkipVerify = "ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY" + DefaultTunnelProtocol = TunnelProtocolConnect + TunnelProtocolConnect = "connect" + TunnelProtocolPlaintext = "plaintext" + TunnelProtocolH2C = "h2c" + TunnelProtocolConnectTLS = "connect-tls" + TunnelProtocolHTTPSConnect = "https-connect" + TunnelProtocolTLSConnect = "tls-connect" + FutureTunnelProtocolHBONE = "hbone" +) + +var OptionalEnvNames = []string{ + EnvConnectTLSServerName, + EnvConnectTLSCAFile, + EnvConnectTLSInsecureSkipVerify, +} diff --git a/manifests/ate-install/ate-controller.yaml b/manifests/ate-install/ate-controller.yaml index 65ee46075..1be68126d 100644 --- a/manifests/ate-install/ate-controller.yaml +++ b/manifests/ate-install/ate-controller.yaml @@ -90,6 +90,10 @@ spec: image: ko://github.com/agent-substrate/substrate/cmd/atecontroller args: - --ateapi-ca-file=/run/servicedns-ca/trust-bundle.pem + envFrom: + - configMapRef: + name: ate-egress-capture + optional: true ports: - name: metrics containerPort: 8080 From 41297a40188fe3d2aa0306ca66f9f950d58d3441 Mon Sep 17 00:00:00 2001 From: npolshakova Date: Mon, 29 Jun 2026 16:24:06 -0400 Subject: [PATCH 2/9] fix flag forwarding microvm Signed-off-by: npolshakova --- hack/run-microvm-demo.sh | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/hack/run-microvm-demo.sh b/hack/run-microvm-demo.sh index 2e29f6ba6..ed77eb305 100755 --- a/hack/run-microvm-demo.sh +++ b/hack/run-microvm-demo.sh @@ -31,12 +31,24 @@ # OUT asset dir (default: $PWD/bin/microvm-assets/$ARCH, gitignored). # ATE_INSTALL_KIND "true" for the kind path (stage assets to rustfs + install-ate-kind.sh); # default false uploads assets to GCS + uses install-ate.sh. +# +# Flags: +# --egress Enable actor egress capture via agentgateway (forwarded to +# install-ate.sh / install-ate-kind.sh). set -o errexit -o nounset -o pipefail ROOT="$(git rev-parse --show-toplevel)" cd "${ROOT}" +# Parse flags before sourcing the environment so they are available to all steps. +EGRESS_FLAG="" +for arg in "$@"; do + case "${arg}" in + --egress) EGRESS_FLAG="--egress" ;; + esac +done + # Source the environment (cluster, registry, bucket) like the other hack scripts; # hack/run-microvm-demo-kind.sh sets NO_DEV_ENV to skip this and use kind defaults. if [[ -r .ate-dev-env.sh ]] && [[ -z "${NO_DEV_ENV:-}" ]]; then @@ -52,6 +64,7 @@ ATE_API_AUTH_MODE="${ATE_API_AUTH_MODE:-mtls}" while [[ $# -gt 0 ]]; do case "$1" in + --egress) EGRESS_FLAG="--egress" ;; --auth-mode=*) ATE_API_AUTH_MODE="${1#*=}" ;; --auth-mode) if [[ $# -lt 2 ]]; then @@ -131,10 +144,10 @@ fi log "Deploying the ate control plane (--deploy-ate-system)..." if [[ "${ATE_INSTALL_KIND}" == "true" ]]; then # install-ate-kind.sh sets NO_DEV_ENV/KO_DOCKER_REPO/ARCH/ATE_INSTALL_KIND itself. - KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" hack/install-ate-kind.sh --deploy-ate-system --auth-mode="${ATE_API_AUTH_MODE}" + KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" hack/install-ate-kind.sh --deploy-ate-system --auth-mode="${ATE_API_AUTH_MODE}" ${EGRESS_FLAG} else # GKE path: pass KO_DOCKER_REPO/BUCKET_NAME/KUBECTL_CONTEXT through the env. - KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" hack/install-ate.sh --deploy-ate-system --auth-mode="${ATE_API_AUTH_MODE}" + KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" hack/install-ate.sh --deploy-ate-system --auth-mode="${ATE_API_AUTH_MODE}" ${EGRESS_FLAG} fi # --- 4. apply the demo ------------------------------------------------------ From aa1402c3cab606f1a14cc7dfc5e0cceaf524ac10 Mon Sep 17 00:00:00 2001 From: npolshakova Date: Wed, 1 Jul 2026 09:56:54 -0700 Subject: [PATCH 3/9] address feedback Signed-off-by: npolshakova --- Makefile | 3 +- .../internal/controllers/workerpool_apply.go | 22 +- .../controllers/workerpool_apply_test.go | 18 +- cmd/ateom-gvisor/egress_proxy.go | 32 +-- cmd/ateom-gvisor/main.go | 14 +- cmd/ateom-microvm/egress_proxy.go | 40 +-- cmd/ateom-microvm/main.go | 4 +- cmd/ateom-microvm/net.go | 4 +- demos/counter/counter.go | 68 ----- demos/egress/.gitignore | 2 + demos/egress/README.md | 68 +++++ demos/egress/egress.yaml.tmpl | 56 ++++ demos/egress/main.go | 111 +++++++ .../counter_test.go => egress/main_test.go} | 8 +- docs/egress-capture.md | 272 ++++-------------- hack/install-ate.sh | 16 +- hack/install-demo-egress.sh | 51 ++++ internal/{egresscapture => egress}/capture.go | 167 ++--------- .../{egresscapture => egress}/capture_test.go | 63 +--- internal/egress/env.go | 40 +++ internal/egresscapture/env.go | 38 --- 21 files changed, 448 insertions(+), 649 deletions(-) create mode 100644 demos/egress/.gitignore create mode 100644 demos/egress/README.md create mode 100644 demos/egress/egress.yaml.tmpl create mode 100644 demos/egress/main.go rename demos/{counter/counter_test.go => egress/main_test.go} (89%) create mode 100644 hack/install-demo-egress.sh rename internal/{egresscapture => egress}/capture.go (70%) rename internal/{egresscapture => egress}/capture_test.go (76%) create mode 100644 internal/egress/env.go delete mode 100644 internal/egresscapture/env.go diff --git a/Makefile b/Makefile index c68dbd3ea..78956d61b 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/cmd/atecontroller/internal/controllers/workerpool_apply.go b/cmd/atecontroller/internal/controllers/workerpool_apply.go index fb3fea2dc..0324c1101 100644 --- a/cmd/atecontroller/internal/controllers/workerpool_apply.go +++ b/cmd/atecontroller/internal/controllers/workerpool_apply.go @@ -24,7 +24,7 @@ import ( metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "github.com/agent-substrate/substrate/internal/ateompath" - "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/agent-substrate/substrate/internal/egress" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" ) @@ -89,33 +89,21 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool) *appsv1ac.Deployment } func egressCaptureEnvFromController() []*corev1ac.EnvVarApplyConfiguration { - enabled, _ := strconv.ParseBool(os.Getenv(egresscapture.EnvCaptureEnabled)) + enabled, _ := strconv.ParseBool(os.Getenv(egress.EnvCaptureEnabled)) if !enabled { return nil } env := []*corev1ac.EnvVarApplyConfiguration{ corev1ac.EnvVar(). - WithName(egresscapture.EnvCaptureEnabled). + WithName(egress.EnvCaptureEnabled). WithValue("true"), } - if v := os.Getenv(egresscapture.EnvPEPAddress); v != "" { + if v := os.Getenv(egress.EnvPEPAddress); v != "" { env = append(env, corev1ac.EnvVar(). - WithName(egresscapture.EnvPEPAddress). + WithName(egress.EnvPEPAddress). WithValue(v)) } - if v := os.Getenv(egresscapture.EnvTunnelProtocol); v != "" { - env = append(env, corev1ac.EnvVar(). - WithName(egresscapture.EnvTunnelProtocol). - WithValue(v)) - } - for _, name := range egresscapture.OptionalEnvNames { - if v := os.Getenv(name); v != "" { - env = append(env, corev1ac.EnvVar(). - WithName(name). - WithValue(v)) - } - } return env } diff --git a/cmd/atecontroller/internal/controllers/workerpool_apply_test.go b/cmd/atecontroller/internal/controllers/workerpool_apply_test.go index f93a6d74b..b4f5a6945 100644 --- a/cmd/atecontroller/internal/controllers/workerpool_apply_test.go +++ b/cmd/atecontroller/internal/controllers/workerpool_apply_test.go @@ -26,7 +26,7 @@ import ( metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "github.com/agent-substrate/substrate/internal/ateompath" - "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/agent-substrate/substrate/internal/egress" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" ) @@ -263,12 +263,8 @@ func TestMicroVMPodShape(t *testing.T) { } func TestWorkerPoolEgressCaptureEnvPropagation(t *testing.T) { - t.Setenv(egresscapture.EnvCaptureEnabled, "1") - t.Setenv(egresscapture.EnvPEPAddress, "ate-egress.agentgateway-system.svc.cluster.local:15008") - t.Setenv(egresscapture.EnvTunnelProtocol, egresscapture.TunnelProtocolConnectTLS) - t.Setenv(egresscapture.EnvConnectTLSServerName, "ate-egress.agentgateway-system.svc.cluster.local") - t.Setenv(egresscapture.EnvConnectTLSCAFile, "/run/egress-ca/ca.crt") - t.Setenv(egresscapture.EnvConnectTLSInsecureSkipVerify, "true") + t.Setenv(egress.EnvCaptureEnabled, "1") + t.Setenv(egress.EnvPEPAddress, "ate-egress.agentgateway-system.svc.cluster.local:15008") wp := testWorkerPoolApplyConfig(nil) deployment := buildDeploymentApplyConfig(wp) @@ -285,12 +281,8 @@ func TestWorkerPoolEgressCaptureEnvPropagation(t *testing.T) { } want := map[string]string{ - egresscapture.EnvCaptureEnabled: "true", - egresscapture.EnvPEPAddress: "ate-egress.agentgateway-system.svc.cluster.local:15008", - egresscapture.EnvTunnelProtocol: egresscapture.TunnelProtocolConnectTLS, - egresscapture.EnvConnectTLSServerName: "ate-egress.agentgateway-system.svc.cluster.local", - egresscapture.EnvConnectTLSCAFile: "/run/egress-ca/ca.crt", - egresscapture.EnvConnectTLSInsecureSkipVerify: "true", + egress.EnvCaptureEnabled: "true", + egress.EnvPEPAddress: "ate-egress.agentgateway-system.svc.cluster.local:15008", } for name, value := range want { if got[name] != value { diff --git a/cmd/ateom-gvisor/egress_proxy.go b/cmd/ateom-gvisor/egress_proxy.go index 882cd1b39..0f280453d 100644 --- a/cmd/ateom-gvisor/egress_proxy.go +++ b/cmd/ateom-gvisor/egress_proxy.go @@ -24,40 +24,22 @@ import ( "syscall" "unsafe" - "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/agent-substrate/substrate/internal/egress" "github.com/google/nftables" "github.com/google/nftables/binaryutil" "github.com/google/nftables/expr" "golang.org/x/sys/unix" ) -const ( - egressCapturePort = uint16(15001) - egressOriginalHTTPPort = uint16(80) - egressOriginalHTTPSPort = uint16(443) -) - -var defaultEgressCaptureRedirects = []struct { - originalPort uint16 - capturePort uint16 -}{ - {originalPort: egressOriginalHTTPPort, capturePort: egressCapturePort}, - {originalPort: egressOriginalHTTPSPort, capturePort: egressCapturePort}, -} - -var defaultEgressCaptureListeners = []egresscapture.Listener{ - {Port: egressCapturePort}, -} - -func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egresscapture.ActorIdentity) error { - if !egresscapture.EnabledFromEnv() { +func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egress.ActorIdentity) error { + if !egress.EnabledFromEnv() { return nil } - cfg, err := egresscapture.ConfigFromEnv(defaultEgressCaptureListeners) + cfg, err := egress.ConfigFromEnv(egress.DefaultCaptureListeners) if err != nil { return err } - capture, err := egresscapture.Start(ctx, identity, cfg, originalDestination) + capture, err := egress.Start(ctx, identity, cfg, originalDestination) if err != nil { return fmt.Errorf("while starting actor egress capture: %w", err) } @@ -66,11 +48,11 @@ func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity } func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { - for _, redirect := range defaultEgressCaptureRedirects { + for _, redirect := range egress.DefaultCaptureRedirects { c.AddRule(&nftables.Rule{ Table: table, Chain: prerouting, - Exprs: tcpRedirectExprs(sourceIP, redirect.originalPort, redirect.capturePort), + Exprs: tcpRedirectExprs(sourceIP, redirect.OriginalPort, redirect.CapturePort), }) } } diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 12bbdb26e..16dc73139 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -32,7 +32,7 @@ import ( "github.com/agent-substrate/substrate/internal/ateinterceptors" "github.com/agent-substrate/substrate/internal/ateompath" "github.com/agent-substrate/substrate/internal/contextlogging" - "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/agent-substrate/substrate/internal/egress" "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/readyz" "github.com/agent-substrate/substrate/internal/serverboot" @@ -165,7 +165,7 @@ type AteomService struct { interiorNetNS netns.NsHandle actorLogger *actorlog.ActorLogger - egressCapture *egresscapture.Capture + egressCapture *egress.Capture } var _ ateompb.AteomServer = (*AteomService)(nil) @@ -440,7 +440,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore return &ateompb.RestoreWorkloadResponse{}, nil } -func (s *AteomService) setupActorNetwork(ctx context.Context, identity egresscapture.ActorIdentity) (retErr error) { +func (s *AteomService) setupActorNetwork(ctx context.Context, identity egress.ActorIdentity) (retErr error) { // Build a fresh point-to-point network between the worker pod netns and the // gVisor interior netns. The worker side keeps the pod's real eth0, creates // ateom0 as the gateway, and moves only the veth peer into the actor netns. @@ -859,16 +859,16 @@ func tcpDestinationPortEqual(port uint16) []expr.Any { } } -func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egresscapture.ActorIdentity { - return egresscapture.ActorIdentity{ +func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egress.ActorIdentity { + return egress.ActorIdentity{ Namespace: req.GetActorTemplateNamespace(), Template: req.GetActorTemplateName(), ActorID: req.GetActorId(), } } -func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egresscapture.ActorIdentity { - return egresscapture.ActorIdentity{ +func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egress.ActorIdentity { + return egress.ActorIdentity{ Namespace: req.GetActorTemplateNamespace(), Template: req.GetActorTemplateName(), ActorID: req.GetActorId(), diff --git a/cmd/ateom-microvm/egress_proxy.go b/cmd/ateom-microvm/egress_proxy.go index 4ab5fa6eb..bd54a4e1f 100644 --- a/cmd/ateom-microvm/egress_proxy.go +++ b/cmd/ateom-microvm/egress_proxy.go @@ -24,7 +24,7 @@ import ( "syscall" "unsafe" - "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/agent-substrate/substrate/internal/egress" "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/google/nftables" "github.com/google/nftables/binaryutil" @@ -32,33 +32,15 @@ import ( "golang.org/x/sys/unix" ) -const ( - egressCapturePort = uint16(15001) - egressOriginalHTTPPort = uint16(80) - egressOriginalHTTPSPort = uint16(443) -) - -var defaultEgressCaptureRedirects = []struct { - originalPort uint16 - capturePort uint16 -}{ - {originalPort: egressOriginalHTTPPort, capturePort: egressCapturePort}, - {originalPort: egressOriginalHTTPSPort, capturePort: egressCapturePort}, -} - -var defaultEgressCaptureListeners = []egresscapture.Listener{ - {Port: egressCapturePort}, -} - -func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egresscapture.ActorIdentity) error { - if !egresscapture.EnabledFromEnv() { +func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egress.ActorIdentity) error { + if !egress.EnabledFromEnv() { return nil } - cfg, err := egresscapture.ConfigFromEnv(defaultEgressCaptureListeners) + cfg, err := egress.ConfigFromEnv(egress.DefaultCaptureListeners) if err != nil { return err } - capture, err := egresscapture.Start(ctx, identity, cfg, originalDestination) + capture, err := egress.Start(ctx, identity, cfg, originalDestination) if err != nil { return fmt.Errorf("while starting actor egress capture: %w", err) } @@ -67,11 +49,11 @@ func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity } func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { - for _, redirect := range defaultEgressCaptureRedirects { + for _, redirect := range egress.DefaultCaptureRedirects { c.AddRule(&nftables.Rule{ Table: table, Chain: prerouting, - Exprs: tcpRedirectExprs(sourceIP, redirect.originalPort, redirect.capturePort), + Exprs: tcpRedirectExprs(sourceIP, redirect.OriginalPort, redirect.CapturePort), }) } } @@ -138,16 +120,16 @@ func originalDstFromFD(fd int) (*net.TCPAddr, error) { }, nil } -func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egresscapture.ActorIdentity { - return egresscapture.ActorIdentity{ +func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egress.ActorIdentity { + return egress.ActorIdentity{ Namespace: req.GetActorTemplateNamespace(), Template: req.GetActorTemplateName(), ActorID: req.GetActorId(), } } -func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egresscapture.ActorIdentity { - return egresscapture.ActorIdentity{ +func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egress.ActorIdentity { + return egress.ActorIdentity{ Namespace: req.GetActorTemplateNamespace(), Template: req.GetActorTemplateName(), ActorID: req.GetActorId(), diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 4dc9387fd..6a5c85626 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -36,7 +36,7 @@ import ( "github.com/agent-substrate/substrate/internal/actorlog" "github.com/agent-substrate/substrate/internal/ateinterceptors" "github.com/agent-substrate/substrate/internal/ateompath" - "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/agent-substrate/substrate/internal/egress" "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/serverboot" "github.com/agent-substrate/substrate/internal/version" @@ -208,7 +208,7 @@ type AteomService struct { // egressCapture owns the per-activation capture listener when egress capture is // enabled for this worker pod. - egressCapture *egresscapture.Capture + egressCapture *egress.Capture // running maps actor name -> the live micro-VM, kept so CheckpointWorkload can // pause+snapshot+teardown the same sandbox (and RestoreWorkload can track the diff --git a/cmd/ateom-microvm/net.go b/cmd/ateom-microvm/net.go index b8d6fce28..a03d34932 100644 --- a/cmd/ateom-microvm/net.go +++ b/cmd/ateom-microvm/net.go @@ -48,7 +48,7 @@ import ( "github.com/vishvananda/netns" "golang.org/x/sys/unix" - "github.com/agent-substrate/substrate/internal/egresscapture" + "github.com/agent-substrate/substrate/internal/egress" "github.com/agent-substrate/substrate/internal/serverboot" ) @@ -121,7 +121,7 @@ func mustParseIP(s string) net.IP { // pod netns and the kata interior netns (see the package comment). Idempotent // via cleanup-before-setup; also sweeps stale kata taps out of the interior // netns so the sandbox always builds on a clean slate. -func (s *AteomService) setupActorNetwork(ctx context.Context, identity egresscapture.ActorIdentity) (retErr error) { +func (s *AteomService) setupActorNetwork(ctx context.Context, identity egress.ActorIdentity) (retErr error) { s.cleanupActorNetworkOrExit(ctx, "Failed to clean up stale actor network before setup") defer func() { if retErr != nil { diff --git a/demos/counter/counter.go b/demos/counter/counter.go index db9a68b49..bd5b90848 100644 --- a/demos/counter/counter.go +++ b/demos/counter/counter.go @@ -26,7 +26,6 @@ import ( "log/slog" "net" "net/http" - "net/url" "os" "strconv" "sync" @@ -62,8 +61,6 @@ func incrementFileCounter() int { return counter } -const defaultEgressURL = "https://httpbin.org/get" - func main() { pflag.Parse() ctx := context.Background() @@ -95,7 +92,6 @@ func main() { w.WriteHeader(http.StatusOK) w.Write([]byte("ok\n")) }) - defaultMux.HandleFunc("/egress", handleEgress) go func() { slog.InfoContext(ctx, "Starting counter server on port 80") @@ -127,70 +123,6 @@ func main() { } } -func handleEgress(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - targetURL, err := egressTargetURL(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) - if err != nil { - http.Error(w, fmt.Sprintf("invalid egress target %q: %v", targetURL, err), http.StatusBadRequest) - return - } - - start := time.Now() - resp, err := http.DefaultClient.Do(req) - if err != nil { - slog.ErrorContext(ctx, "Egress request failed", slog.String("target", targetURL), slog.Any("err", err)) - http.Error(w, fmt.Sprintf("egress request to %s failed: %v\n", targetURL, err), http.StatusBadGateway) - return - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) - if err != nil { - slog.ErrorContext(ctx, "Failed reading egress response", slog.String("target", targetURL), slog.Any("err", err)) - http.Error(w, fmt.Sprintf("reading egress response from %s failed: %v\n", targetURL, err), http.StatusBadGateway) - return - } - - slog.InfoContext(ctx, "Egress request completed", - slog.String("target", targetURL), - slog.Int("upstream_status", resp.StatusCode), - slog.Duration("duration", time.Since(start))) - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "egress target: %s\n", targetURL) - fmt.Fprintf(w, "upstream status: %s\n", resp.Status) - fmt.Fprintf(w, "body bytes read: %d\n", len(body)) - fmt.Fprintf(w, "body:\n%s\n", body) -} - -func egressTargetURL(r *http.Request) (string, error) { - targetURL := r.URL.Query().Get("url") - if targetURL == "" { - targetURL = defaultEgressURL - } - - parsed, err := url.Parse(targetURL) - if err != nil { - return "", fmt.Errorf("invalid egress target %q: %w", targetURL, err) - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", fmt.Errorf("invalid egress target %q: scheme must be http or https", targetURL) - } - if parsed.Host == "" { - return "", fmt.Errorf("invalid egress target %q: host is required", targetURL) - } - return targetURL, nil -} - func writeRandomFile() error { rf, err := os.Create("/random-content-file") if err != nil { diff --git a/demos/egress/.gitignore b/demos/egress/.gitignore new file mode 100644 index 000000000..659600455 --- /dev/null +++ b/demos/egress/.gitignore @@ -0,0 +1,2 @@ +*.test +/egress diff --git a/demos/egress/README.md b/demos/egress/README.md new file mode 100644 index 000000000..8a9b18e34 --- /dev/null +++ b/demos/egress/README.md @@ -0,0 +1,68 @@ +# Egress Demo + +This demo deploys a small HTTP actor that opens outbound HTTP or HTTPS requests +from inside the actor sandbox. It is intended for validating actor egress +capture and agentgateway routing. + +## Run + +Install the CLI as a kubectl plugin if needed: + +```bash +go install ./cmd/kubectl-ate +export PATH="$(go env GOPATH)/bin:${PATH}" +``` + +Deploy the ATE system with egress capture enabled, then deploy the demo: + +```bash +./hack/install-ate.sh --egress --deploy-ate-system +./hack/install-ate.sh --deploy-demo-egress +``` + +For kind, use the kind wrapper for both commands: + +```bash +./hack/install-ate-kind.sh --egress --deploy-ate-system +./hack/install-ate-kind.sh --deploy-demo-egress +``` + +Create an actor and port-forward the router: + +```bash +kubectl ate create actor my-egress-1 --template ate-demo-egress/egress +kubectl port-forward -n ate-system svc/atenet-router 8000:80 +``` + +Call the actor: + +```bash +curl -X POST \ + -H "Host: my-egress-1.actors.resources.substrate.ate.dev" \ + http://localhost:8000 +``` + +The default target is `https://httpbin.org/get`. To call another httpbin path: + +```bash +curl -X POST \ + -H "Host: my-egress-1.actors.resources.substrate.ate.dev" \ + --data-urlencode "url=https://httpbin.org/headers" \ + "http://localhost:8000" +``` + +The demo agentgateway setup only routes `httpbin.org:443`. + +## Uninstall + +```bash +kubectl ate suspend actor my-egress-1 +kubectl ate delete actor my-egress-1 +./hack/install-ate.sh --delete-demo-egress +``` + +For kind: + +```bash +./hack/install-ate-kind.sh --delete-demo-egress +``` diff --git a/demos/egress/egress.yaml.tmpl b/demos/egress/egress.yaml.tmpl new file mode 100644 index 000000000..e6f9b080f --- /dev/null +++ b/demos/egress/egress.yaml.tmpl @@ -0,0 +1,56 @@ +# 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. + +apiVersion: v1 +kind: Namespace +metadata: + name: ate-demo-egress + +--- + +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: egress + namespace: ate-demo-egress + labels: + workload: egress +spec: + replicas: 2 + ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor + +--- + +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: egress + namespace: ate-demo-egress +spec: + pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" + containers: + - name: egress + image: ko://github.com/agent-substrate/substrate/demos/egress + command: ["/ko-app/egress"] + readyz: + httpGet: + path: /readyz + port: 80 + workerSelector: + matchLabels: + workload: egress + snapshotsConfig: + onPause: Full + onCommit: Full + location: gs://${BUCKET_NAME}/ate-demo-egress/ diff --git a/demos/egress/main.go b/demos/egress/main.go new file mode 100644 index 000000000..742ca49fe --- /dev/null +++ b/demos/egress/main.go @@ -0,0 +1,111 @@ +// 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. + +// Command egress is a small HTTP workload for exercising captured actor egress. +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "time" +) + +const defaultEgressURL = "https://httpbin.org/get" + +func main() { + ctx := context.Background() + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + + mux := http.NewServeMux() + mux.HandleFunc("/", handleEgress) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok\n")) + }) + + slog.InfoContext(ctx, "Starting egress demo server on port 80") + if err := http.ListenAndServe(":80", mux); err != nil { + slog.ErrorContext(ctx, "Error starting server", slog.Any("err", err)) + os.Exit(1) + } +} + +func handleEgress(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + targetURL, err := egressTargetURL(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + http.Error(w, fmt.Sprintf("invalid egress target %q: %v", targetURL, err), http.StatusBadRequest) + return + } + + start := time.Now() + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.ErrorContext(ctx, "Egress request failed", slog.String("target", targetURL), slog.Any("err", err)) + http.Error(w, fmt.Sprintf("egress request to %s failed: %v\n", targetURL, err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) + if err != nil { + slog.ErrorContext(ctx, "Failed reading egress response", slog.String("target", targetURL), slog.Any("err", err)) + http.Error(w, fmt.Sprintf("reading egress response from %s failed: %v\n", targetURL, err), http.StatusBadGateway) + return + } + + slog.InfoContext(ctx, "Egress request completed", + slog.String("target", targetURL), + slog.Int("upstream_status", resp.StatusCode), + slog.Duration("duration", time.Since(start))) + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "egress target: %s\n", targetURL) + fmt.Fprintf(w, "upstream status: %s\n", resp.Status) + fmt.Fprintf(w, "body bytes read: %d\n", len(body)) + fmt.Fprintf(w, "body:\n%s\n", body) +} + +func egressTargetURL(r *http.Request) (string, error) { + targetURL := r.URL.Query().Get("url") + if targetURL == "" { + targetURL = defaultEgressURL + } + + parsed, err := url.Parse(targetURL) + if err != nil { + return "", fmt.Errorf("invalid egress target %q: %w", targetURL, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("invalid egress target %q: scheme must be http or https", targetURL) + } + if parsed.Host == "" { + return "", fmt.Errorf("invalid egress target %q: host is required", targetURL) + } + return targetURL, nil +} diff --git a/demos/counter/counter_test.go b/demos/egress/main_test.go similarity index 89% rename from demos/counter/counter_test.go rename to demos/egress/main_test.go index e5e008574..5267ec46f 100644 --- a/demos/counter/counter_test.go +++ b/demos/egress/main_test.go @@ -28,22 +28,22 @@ func TestEgressTargetURL(t *testing.T) { }{ { name: "default", - path: "/egress", + path: "/", want: defaultEgressURL, }, { name: "custom url", - path: "/egress?url=https%3A%2F%2Fhttpbin.org%2Fheaders", + path: "/?url=https%3A%2F%2Fhttpbin.org%2Fheaders", want: "https://httpbin.org/headers", }, { name: "reject missing host", - path: "/egress?url=https%3A%2F%2F", + path: "/?url=https%3A%2F%2F", wantErr: true, }, { name: "reject unsupported scheme", - path: "/egress?url=ftp%3A%2F%2Fexample.com%2Ffile", + path: "/?url=ftp%3A%2F%2Fexample.com%2Ffile", wantErr: true, }, } { diff --git a/docs/egress-capture.md b/docs/egress-capture.md index dab03953c..8b3d661a8 100644 --- a/docs/egress-capture.md +++ b/docs/egress-capture.md @@ -1,15 +1,15 @@ # Egress Capture This documents the demo install path and the ateom capture setup using -the existing counter demo. The counter demo is useful because an external curl to -the router resumes a real gVisor actor, and its `/egress` path makes that actor -open an outbound HTTPS request to `https://httpbin.org/get` by default. +the dedicated egress demo. The demo is a small HTTP actor that resumes through +the router and opens an outbound HTTPS request to `https://httpbin.org/get` +by default. ## Architecture When egress capture is enabled, each worker pod gets `ATE_EGRESS_*` configuration from the controller. The reusable capture core lives in -`internal/egresscapture`: it owns environment parsing, capture listeners, +`internal/egress`: it owns environment parsing, capture listeners, authority derivation, CONNECT tunnel transports, and byte proxying. The runtime-specific `ateom` egress proxy setup supplies the original-destination lookup and packet-capture rules. @@ -18,7 +18,7 @@ The current gVisor implementation starts a local capture listener and installs actor-network redirects for TCP/80 and TCP/443. From the actor's point of view it still opens a normal HTTP or HTTPS connection to the original destination. MicroVM or future hypervisor implementations should reuse -`internal/egresscapture` for the local listener, authority derivation, tunnel +`internal/egress` for the local listener, authority derivation, tunnel transport, and byte proxying. Each runtime still provides its own egress proxy setup for redirecting actor traffic and recovering the original destination. @@ -31,16 +31,13 @@ actor connection: | HTTPS / TCP 443 | TLS ClientHello SNI | `httpbin.org:443` | | Plaintext HTTP / TCP 80 | HTTP `Host` header | `example.com:80` | -The shared capture core then opens a tunnel selected by -`ATE_EGRESS_TUNNEL_PROTOCOL`. The default `connect` transport opens a plaintext -HTTP/2 CONNECT stream to the agentgateway data plane at -`ate-egress.agentgateway-system.svc.cluster.local:15008`. The transport registry -also supports TLS CONNECT variants and is intended to allow future transports -such as HBONE without changing the runtime-specific capture setup. Agentgateway -maps the CONNECT authority to its configured TCP listener and routes the tunnel -to a Kubernetes Service backed by an EndpointSlice. +The shared capture core then opens a plaintext HTTP/2 CONNECT stream to the +agentgateway data plane at +`ate-egress.agentgateway-system.svc.cluster.local:15008`. Agentgateway maps the +CONNECT authority to its configured TCP listener and routes the tunnel to a +Kubernetes Service backed by an EndpointSlice. -The demo setup configures only `httpbin.org:443` for egress. +The demo setup configures only `httpbin.org:443` for egress. Other hosts or plaintext HTTP destinations need their own agentgateway Service, EndpointSlice, listener, and route. For HTTPS, TLS is still end-to-end between the actor and the external service; agentgateway only routes the @@ -60,6 +57,7 @@ reconciles WorkerPool deployments. ```bash go install ./cmd/kubectl-ate +export PATH="$(go env GOPATH)/bin:${PATH}" ``` ## Install with capture enabled @@ -96,7 +94,6 @@ Expected values: ```yaml ATE_EGRESS_CAPTURE_ENABLED: "true" ATE_EGRESS_PEP_ADDRESS: ate-egress.agentgateway-system.svc.cluster.local:15008 -ATE_EGRESS_TUNNEL_PROTOCOL: connect ``` Verify the static agentgateway resources: @@ -119,51 +116,40 @@ service/httpbin-egress endpointslice.discovery.k8s.io/httpbin-egress ``` -## Deploy and call the counter actor +## Deploy and call the egress actor -Deploy the existing counter demo: +Deploy the egress demo: ```bash -./hack/install-ate.sh --deploy-demo-counter +./hack/install-ate.sh --deploy-demo-egress ``` -Create an actor: +For kind, keep using the kind wrapper so the demo uses the same local image +registry and snapshot bucket settings: ```bash -kubectl ate create actor my-counter-1 --template ate-demo-counter/counter +./hack/install-ate-kind.sh --deploy-demo-egress ``` -Forward the router locally: +Create an actor: ```bash -kubectl port-forward -n ate-system svc/atenet-router 8000:80 +kubectl ate create actor my-egress-1 --template ate-demo-egress/egress ``` -From another terminal, send an external request through the router to prove -normal ingress still works: +Forward the router locally: ```bash -curl -i -X POST \ - -H "Host: my-counter-1.actors.resources.substrate.ate.dev" \ - http://localhost:8000 -``` - -Expected response: - -```text -HTTP/1.1 200 OK -hello from: 169.254.17.2 | preserved memory count: 1 +kubectl port-forward -n ate-system svc/atenet-router 8000:80 ``` -The exact count can differ. The important part is that the actor reaches -`STATUS_RUNNING` and is assigned to an ateom pod. - -Now send an external request to the demo's egress path: +From another terminal, send an external request through the router. The actor +will make an outbound HTTPS request to `https://httpbin.org/get` by default: ```bash curl -i -X POST \ - -H "Host: my-counter-1.actors.resources.substrate.ate.dev" \ - http://localhost:8000/egress + -H "Host: my-egress-1.actors.resources.substrate.ate.dev" \ + http://localhost:8000 ``` Expected response: @@ -182,167 +168,21 @@ To test a different `httpbin.org` path, pass it as the `url` query parameter: ```bash curl -i -X POST --get \ - -H "Host: my-counter-1.actors.resources.substrate.ate.dev" \ + -H "Host: my-egress-1.actors.resources.substrate.ate.dev" \ --data-urlencode "url=https://httpbin.org/headers" \ - "http://localhost:8000/egress" + "http://localhost:8000" ``` Do not use this query parameter for a different host unless you also update the agentgateway route. `ateom` will derive the new host from SNI, but the demo agentgateway config only routes `httpbin.org:443`. -## Run the microVM counter demo with egress enabled - -The microVM demo uses the same counter container and `/egress` handler, but runs -it with `sandboxClass: microvm` on `ateom-microvm`. This is useful for checking -that egress configuration reaches microVM worker pods and for exercising the -demo request path. - -When egress capture is enabled, `ateom-microvm` starts the same reusable -`internal/egresscapture` listener as the gVisor runtime and installs -microVM-specific redirect rules in the worker pod network namespace. HTTP and -HTTPS egress from the guest is redirected to the local ateom egress proxy, which -recovers `SO_ORIGINAL_DST`; the shared capture core derives CONNECT authority -from HTTP `Host` or TLS SNI and opens the configured tunnel to the egress PEP. - -For kind, deploy the ATE system first so the in-cluster rustfs service exists -before the microVM demo stages runtime assets. Then run the microVM bring-up, -enable the egress resources, and restart microVM workers so the controller's -egress environment is copied into fresh worker pods: - -```bash -KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" ./hack/install-ate-kind.sh --deploy-ate-system -KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" ./hack/run-microvm-demo-kind.sh -KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" ./hack/install-ate-kind.sh --egress --deploy-ate-controller -``` - -For kind, the node must expose `/dev/kvm` to the kind node and carry the -`ate.dev/sandboxClass=microvm` label. `hack/create-kind-cluster.sh` does both -when `/dev/kvm` is available in the Docker environment. Without that label, the -microVM worker pods remain `Pending`, and router requests fail with: - -```text -actor "my-counter-microvm-1" unavailable: no free workers available -``` - -For a normal cluster, run: - -```bash -./hack/run-microvm-demo.sh -./hack/install-ate.sh --egress --deploy-ate-controller -``` - -Before creating the actor, verify that the microVM worker pods are scheduled and -available: - -```bash -kubectl get nodes -L ate.dev/sandboxClass -kubectl get pods -n ate-demo-counter-microvm -o wide - -kubectl wait --for=condition=Available \ - deployment/counter-microvm-deployment -n ate-demo-counter-microvm --timeout=300s - -kubectl wait --for=condition=Ready \ - actortemplate/counter-microvm -n ate-demo-counter-microvm --timeout=600s -``` - -Expected node output includes `microvm` in the `ATE.DEV/SANDBOXCLASS` column, -and the `counter-microvm` pods should be `Running`. If the pods are `Pending`, -inspect the scheduling reason: - -```bash -kubectl describe pod -n ate-demo-counter-microvm \ - -l ate.dev/worker-pool=counter-microvm -``` - -If `/dev/kvm` is mounted but the label is missing, add the label and recreate the -worker pods: - -```bash -kubectl label node kind-control-plane ate.dev/sandboxClass=microvm --overwrite -kubectl rollout restart deployment/counter-microvm-deployment -n ate-demo-counter-microvm -kubectl rollout status deployment/counter-microvm-deployment -n ate-demo-counter-microvm -``` - -If `/dev/kvm` is not mounted into the node, recreate the kind cluster with -`hack/create-kind-cluster.sh` on a host or Docker VM that has KVM available. - -After the worker deployment and golden snapshot are ready, create a microVM actor -and call it through the router: - -```bash -kubectl ate create actor my-counter-microvm-1 \ - --template ate-demo-counter-microvm/counter-microvm - -kubectl port-forward -n ate-system svc/atenet-router 8000:80 -``` - -From another terminal, verify the normal counter path: - -```bash -curl -i -X POST \ - -H "Host: my-counter-microvm-1.actors.resources.substrate.ate.dev" \ - http://localhost:8000 -``` - -Then exercise the demo egress path: - -```bash -curl -i -X POST \ - -H "Host: my-counter-microvm-1.actors.resources.substrate.ate.dev" \ - http://localhost:8000/egress -``` - -Verify that the microVM worker pod received the egress configuration: - -```bash -actor_json=$(kubectl ate get actor my-counter-microvm-1 -o json) -ateom_ns=$(jq -r '.actors[0].ateomPodNamespace' <<<"${actor_json}") -ateom_pod=$(jq -r '.actors[0].ateomPodName' <<<"${actor_json}") - -kubectl get pod -n "${ateom_ns}" "${ateom_pod}" \ - -o jsonpath='{range .spec.containers[?(@.name=="ateom")].env[*]}{.name}={.value}{"\n"}{end}' \ - | grep ATE_EGRESS -``` - -Expected output includes: - -```text -ATE_EGRESS_CAPTURE_ENABLED=true -ATE_EGRESS_PEP_ADDRESS=ate-egress.agentgateway-system.svc.cluster.local:15008 -ATE_EGRESS_TUNNEL_PROTOCOL=connect -``` - -Check that the microVM runtime installed the capture listener: - -```bash -kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Started actor egress capture listener" -``` - -Expected output includes one log line for the local capture listener: - -```text -Started actor egress capture listener ... "port":15001 ... "protocol":"connect" -``` - -After the `/egress` request, the logs should also show the captured stream: - -```bash -kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Proxying captured actor egress" -``` - -Expected output includes: - -```text -Proxying captured actor egress ... "originalDestination":"...:443" ... "connectAuthority":"httpbin.org:443" -``` - ## Verify capture was installed Find the worker pod hosting the actor: ```bash -actor_json=$(kubectl ate get actor my-counter-1 -o json) +actor_json=$(kubectl ate get actor my-egress-1 -o json) ateom_ns=$(jq -r '.actors[0].ateomPodNamespace' <<<"${actor_json}") ateom_pod=$(jq -r '.actors[0].ateomPodName' <<<"${actor_json}") @@ -362,7 +202,6 @@ Expected output includes: ```text ATE_EGRESS_CAPTURE_ENABLED=true ATE_EGRESS_PEP_ADDRESS=ate-egress.agentgateway-system.svc.cluster.local:15008 -ATE_EGRESS_TUNNEL_PROTOCOL=connect ``` Check the ateom logs: @@ -374,10 +213,10 @@ kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Started actor egre Expected output includes one log line for the local capture listener: ```text -Started actor egress capture listener ... "port":15001 ... "protocol":"connect" +Started actor egress capture listener ... "port":15001 ``` -After the `/egress` request, the logs should also show the captured stream: +After the egress request, the logs should also show the captured stream: ```bash kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Proxying captured actor egress" @@ -400,7 +239,7 @@ kubectl logs -n agentgateway-system \ --all-containers --tail=200 ``` -After a successful `/egress` request, dataplane logs should include a TCP route +After a successful egress request, dataplane logs should include a TCP route entry similar to: ```text @@ -417,54 +256,41 @@ kubectl logs -n agentgateway-system deploy/agentgateway --tail=200 ## Clean up ```bash -kubectl ate suspend actor my-counter-1 -kubectl ate delete actor my-counter-1 -./hack/install-ate.sh --delete-demo-counter +kubectl ate suspend actor my-egress-1 +kubectl ate delete actor my-egress-1 +./hack/install-ate.sh --delete-demo-egress ``` ## Troubleshooting +If redeploying fails with `The ActorTemplate "egress" is invalid: spec: +Invalid value: Spec is immutable`, recreate the demo resources: + +```bash +./hack/install-ate-kind.sh --delete-demo-egress +./hack/install-ate-kind.sh --deploy-demo-egress +``` + If the `ATE_EGRESS_*` variables are missing from the worker pod, restart the -controller and recreate the counter WorkerPool pods after creating the config +controller and recreate the egress WorkerPool pods after creating the config map: ```bash kubectl rollout restart deployment/ate-controller -n ate-system kubectl rollout status deployment/ate-controller -n ate-system -kubectl rollout restart deployment/counter-deployment -n ate-demo-counter -kubectl rollout status deployment/counter-deployment -n ate-demo-counter +kubectl rollout restart deployment/egress-deployment -n ate-demo-egress +kubectl rollout status deployment/egress-deployment -n ate-demo-egress ``` If the capture listener logs are missing, confirm that the actor is running on a fresh worker pod created after egress was enabled: ```bash -kubectl ate get actor my-counter-1 -kubectl get pods -n ate-demo-counter -l ate.dev/worker-pool=counter -``` - -If the microVM curl returns `503 Service Unavailable` with `no free workers -available`, the request reached the router and ate-api, but no eligible microVM -worker was available for assignment. Check the worker pods and node label: - -```bash -kubectl get nodes -L ate.dev/sandboxClass -kubectl get pods -n ate-demo-counter-microvm -o wide -kubectl describe pod -n ate-demo-counter-microvm \ - -l ate.dev/worker-pool=counter-microvm -``` - -The microVM WorkerPool pods require `nodeSelector: -ate.dev/sandboxClass=microvm` and `/dev/kvm`. For kind, recreate the cluster with -`hack/create-kind-cluster.sh`, or label the node if KVM is already mounted: - -```bash -kubectl label node kind-control-plane ate.dev/sandboxClass=microvm --overwrite -kubectl rollout restart deployment/counter-microvm-deployment -n ate-demo-counter-microvm -kubectl rollout status deployment/counter-microvm-deployment -n ate-demo-counter-microvm +kubectl ate get actor my-egress-1 +kubectl get pods -n ate-demo-egress -l ate.dev/worker-pool=egress ``` -If `/egress` fails after changing the `url` host, remember that this demo only +If the egress request fails after changing the `url` host, remember that this demo only configures agentgateway for `httpbin.org:443`. Add matching static agentgateway backend resources for the new destination: diff --git a/hack/install-ate.sh b/hack/install-ate.sh index 8731ec371..693472ae4 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -40,6 +40,7 @@ ATE_DEMOS=() # Include demos. source "${ROOT}"/hack/install-demo-counter.sh +source "${ROOT}"/hack/install-demo-egress.sh source "${ROOT}"/hack/install-demo-sandbox.sh source "${ROOT}"/hack/install-demo-claude-code-multiplex.sh source "${ROOT}"/hack/install-demo-agent-secret.sh @@ -51,13 +52,8 @@ COLOR_RESET='\033[0m' ATE_EGRESS_CAPTURE="${ATE_EGRESS_CAPTURE:-false}" ATE_EGRESS_PEP_ADDRESS="${ATE_EGRESS_PEP_ADDRESS:-ate-egress.agentgateway-system.svc.cluster.local:15008}" -ATE_EGRESS_TUNNEL_PROTOCOL="${ATE_EGRESS_TUNNEL_PROTOCOL:-connect}" ATE_EGRESS_CAPTURE_ENABLED_ENV="ATE_EGRESS_CAPTURE_ENABLED" ATE_EGRESS_PEP_ADDRESS_ENV="ATE_EGRESS_PEP_ADDRESS" -ATE_EGRESS_TUNNEL_PROTOCOL_ENV="ATE_EGRESS_TUNNEL_PROTOCOL" -ATE_EGRESS_CONNECT_TLS_SERVER_NAME_ENV="ATE_EGRESS_CONNECT_TLS_SERVER_NAME" -ATE_EGRESS_CONNECT_TLS_CA_FILE_ENV="ATE_EGRESS_CONNECT_TLS_CA_FILE" -ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY_ENV="ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY" function log_step() { local step_name="$1" @@ -355,17 +351,7 @@ create_egress_capture_config() { local literals=( --from-literal="${ATE_EGRESS_CAPTURE_ENABLED_ENV}=true" --from-literal="${ATE_EGRESS_PEP_ADDRESS_ENV}=${ATE_EGRESS_PEP_ADDRESS}" - --from-literal="${ATE_EGRESS_TUNNEL_PROTOCOL_ENV}=${ATE_EGRESS_TUNNEL_PROTOCOL}" ) - if [[ -n "${ATE_EGRESS_CONNECT_TLS_SERVER_NAME:-}" ]]; then - literals+=(--from-literal="${ATE_EGRESS_CONNECT_TLS_SERVER_NAME_ENV}=${ATE_EGRESS_CONNECT_TLS_SERVER_NAME}") - fi - if [[ -n "${ATE_EGRESS_CONNECT_TLS_CA_FILE:-}" ]]; then - literals+=(--from-literal="${ATE_EGRESS_CONNECT_TLS_CA_FILE_ENV}=${ATE_EGRESS_CONNECT_TLS_CA_FILE}") - fi - if [[ -n "${ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY:-}" ]]; then - literals+=(--from-literal="${ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY_ENV}=${ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY}") - fi # TODO: Updating this ConfigMap does not by itself restart an already-running # ate-controller, so enabling egress after a non-egress install may not take # effect until the controller rolls. Wire a pod-template checksum or restart diff --git a/hack/install-demo-egress.sh b/hack/install-demo-egress.sh new file mode 100644 index 000000000..09cc2d216 --- /dev/null +++ b/hack/install-demo-egress.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +# 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. +# +# This is sourced as part of install-ate.sh. Do not run directly. + +ATE_DEMOS+=(demo-egress) # register demo-egress + +demo-egress_cmdline() { + case "${1}" in + --deploy-demo-egress) demo-egress_deploy ;; + --delete-demo-egress) demo-egress_delete ;; + *) + return 1 + ;; + esac + return 0 +} + +demo-egress_deploy() { + log_step "demo-egress_deploy" + ensure_crds + run_kubectl create namespace ate-demo-egress --dry-run=client -o yaml \ + | run_kubectl apply -f - + sed "s|\${BUCKET_NAME}|${BUCKET_NAME}|g" demos/egress/egress.yaml.tmpl \ + | run_ko apply -f - + + log_step "Waiting for egress demo to be ready..." + run_kubectl rollout status deployment/egress-deployment -n ate-demo-egress --timeout=300s + log_step "Waiting for egress ActorTemplate to be ready..." + run_kubectl wait --for=condition=Ready actortemplate/egress -n ate-demo-egress --timeout=300s +} + +demo-egress_delete() { + log_step "demo-egress_delete" + delete_demo_actors ate-demo-egress egress + sed "s|\${BUCKET_NAME}|${BUCKET_NAME}|g" demos/egress/egress.yaml.tmpl \ + | run_kubectl delete --ignore-not-found -f - +} diff --git a/internal/egresscapture/capture.go b/internal/egress/capture.go similarity index 70% rename from internal/egresscapture/capture.go rename to internal/egress/capture.go index c9673d8d2..46aaa3916 100644 --- a/internal/egresscapture/capture.go +++ b/internal/egress/capture.go @@ -12,12 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -package egresscapture +package egress import ( "context" "crypto/tls" - "crypto/x509" "encoding/binary" "errors" "fmt" @@ -45,17 +44,9 @@ type ActorIdentity struct { type Config struct { PEPAddress string - Protocol string - TLS TLSConfig Listeners []Listener } -type TLSConfig struct { - ServerName string - CAFile string - InsecureSkipVerify bool -} - type Listener struct { Port uint16 } @@ -68,29 +59,6 @@ type Capture struct { wg sync.WaitGroup } -type TunnelTransport interface { - Open(ctx context.Context, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) -} - -type TunnelTransportFactory func(Config) (TunnelTransport, error) - -var tunnelTransportFactories = map[string]TunnelTransportFactory{} - -func init() { - // Keep tunnel protocol support behind factories so additional transports - // such as HBONE can plug in without changing capture/listener logic. - RegisterTunnelTransport(TunnelProtocolConnect, newPlaintextCONNECTTunnelTransport) - RegisterTunnelTransport(TunnelProtocolPlaintext, newPlaintextCONNECTTunnelTransport) - RegisterTunnelTransport(TunnelProtocolH2C, newPlaintextCONNECTTunnelTransport) - RegisterTunnelTransport(TunnelProtocolConnectTLS, newTLSCONNECTTunnelTransport) - RegisterTunnelTransport(TunnelProtocolHTTPSConnect, newTLSCONNECTTunnelTransport) - RegisterTunnelTransport(TunnelProtocolTLSConnect, newTLSCONNECTTunnelTransport) -} - -func RegisterTunnelTransport(protocol string, factory TunnelTransportFactory) { - tunnelTransportFactories[strings.ToLower(strings.TrimSpace(protocol))] = factory -} - func EnabledFromEnv() bool { enabled, _ := strconv.ParseBool(os.Getenv(EnvCaptureEnabled)) return enabled @@ -103,33 +71,15 @@ func ConfigFromEnv(listeners []Listener) (Config, error) { } cfg := Config{ PEPAddress: pepAddress, - Protocol: DefaultTunnelProtocol, - TLS: TLSConfig{ - ServerName: os.Getenv(EnvConnectTLSServerName), - CAFile: os.Getenv(EnvConnectTLSCAFile), - InsecureSkipVerify: boolEnv(EnvConnectTLSInsecureSkipVerify), - }, - Listeners: listeners, - } - if v := os.Getenv(EnvTunnelProtocol); v != "" { - cfg.Protocol = v + Listeners: listeners, } return cfg, nil } -func boolEnv(name string) bool { - enabled, _ := strconv.ParseBool(os.Getenv(name)) - return enabled -} - func Start(ctx context.Context, identity ActorIdentity, cfg Config, originalDestination OriginalDestinationFunc) (*Capture, error) { if originalDestination == nil { return nil, errors.New("original destination resolver must be set") } - transport, err := NewTunnelTransport(cfg) - if err != nil { - return nil, err - } ctx, cancel := newCaptureContext(ctx) capture := &Capture{cancel: cancel} @@ -142,11 +92,10 @@ func Start(ctx context.Context, identity ActorIdentity, cfg Config, originalDest capture.listeners = append(capture.listeners, lis) capture.wg.Add(1) - go capture.serve(ctx, lis, identity, transport, originalDestination) + go capture.serve(ctx, lis, identity, cfg.PEPAddress, originalDestination) slog.InfoContext(ctx, "Started actor egress capture listener", "port", listenerCfg.Port, - "pepAddress", cfg.PEPAddress, - "protocol", cfg.Protocol) + "pepAddress", cfg.PEPAddress) } return capture, nil } @@ -172,7 +121,7 @@ func (c *Capture) Close() error { return err } -func (c *Capture) serve(ctx context.Context, lis net.Listener, identity ActorIdentity, transport TunnelTransport, originalDestination OriginalDestinationFunc) { +func (c *Capture) serve(ctx context.Context, lis net.Listener, identity ActorIdentity, pepAddress string, originalDestination OriginalDestinationFunc) { defer c.wg.Done() for { conn, err := lis.Accept() @@ -186,12 +135,12 @@ func (c *Capture) serve(ctx context.Context, lis net.Listener, identity ActorIde c.wg.Add(1) go func() { defer c.wg.Done() - handleCapturedEgress(ctx, conn, identity, transport, originalDestination) + handleCapturedEgress(ctx, conn, identity, pepAddress, originalDestination) }() } } -func handleCapturedEgress(ctx context.Context, actorConn net.Conn, identity ActorIdentity, transport TunnelTransport, originalDestination OriginalDestinationFunc) { +func handleCapturedEgress(ctx context.Context, actorConn net.Conn, identity ActorIdentity, pepAddress string, originalDestination OriginalDestinationFunc) { stopActorClose := context.AfterFunc(ctx, func() { _ = actorConn.Close() }) @@ -205,7 +154,7 @@ func handleCapturedEgress(ctx context.Context, actorConn net.Conn, identity Acto } authority, initialBytes := deriveConnectAuthority(ctx, actorConn, originalDst) - tunnel, err := transport.Open(ctx, identity, originalDst, authority) + tunnel, err := openCONNECTTunnel(ctx, pepAddress, identity, originalDst, authority) if err != nil { slog.WarnContext(ctx, "Failed to open egress tunnel", "originalDestination", originalDst.String(), @@ -255,92 +204,18 @@ func proxyByteStream(ctx context.Context, actorConn net.Conn, tunnel io.ReadWrit wg.Wait() } -func NewTunnelTransport(cfg Config) (TunnelTransport, error) { - protocol := strings.ToLower(strings.TrimSpace(cfg.Protocol)) - if protocol == "" { - protocol = DefaultTunnelProtocol - } - factory, ok := tunnelTransportFactories[protocol] - if !ok { - return nil, fmt.Errorf("unsupported egress tunnel protocol %q", cfg.Protocol) - } - return factory(cfg) -} - -type PlaintextCONNECTTunnelTransport struct { - PEPAddress string -} - -func newPlaintextCONNECTTunnelTransport(cfg Config) (TunnelTransport, error) { - return &PlaintextCONNECTTunnelTransport{PEPAddress: cfg.PEPAddress}, nil -} - -func (t *PlaintextCONNECTTunnelTransport) Open(ctx context.Context, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) { - req, pr, pw := newConnectRequest(ctx, "http", identity, originalDst, authority) +func openCONNECTTunnel(ctx context.Context, pepAddress string, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) { + // TODO: Add a transport selector here when there is a second supported + // egress tunnel protocol, such as TLS CONNECT or HBONE. + req, pr, pw := newConnectRequest(ctx, identity, originalDst, authority) transport := &http2.Transport{ AllowHTTP: true, DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { var dialer net.Dialer - return dialer.DialContext(ctx, network, t.PEPAddress) + return dialer.DialContext(ctx, network, pepAddress) }, } - return roundTripConnect(transport, req, pr, pw, authority, t.PEPAddress) -} - -type TLSCONNECTTunnelTransport struct { - PEPAddress string - TLS TLSConfig -} - -func newTLSCONNECTTunnelTransport(cfg Config) (TunnelTransport, error) { - return &TLSCONNECTTunnelTransport{PEPAddress: cfg.PEPAddress, TLS: cfg.TLS}, nil -} - -func (t *TLSCONNECTTunnelTransport) Open(ctx context.Context, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) { - req, pr, pw := newConnectRequest(ctx, "https", identity, originalDst, authority) - tlsConfig, err := t.tlsConfig() - if err != nil { - _ = pr.CloseWithError(err) - _ = pw.CloseWithError(err) - return nil, err - } - transport := &http2.Transport{ - DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { - var dialer net.Dialer - conn, err := dialer.DialContext(ctx, network, t.PEPAddress) - if err != nil { - return nil, err - } - tlsConn := tls.Client(conn, tlsConfig) - if err := tlsConn.HandshakeContext(ctx); err != nil { - _ = conn.Close() - return nil, err - } - return tlsConn, nil - }, - } - return roundTripConnect(transport, req, pr, pw, authority, t.PEPAddress) -} - -func (t *TLSCONNECTTunnelTransport) tlsConfig() (*tls.Config, error) { - cfg := &tls.Config{ - NextProtos: []string{"h2"}, - ServerName: t.TLS.ServerName, - InsecureSkipVerify: t.TLS.InsecureSkipVerify, - } - if t.TLS.CAFile == "" { - return cfg, nil - } - rootsPEM, err := os.ReadFile(t.TLS.CAFile) - if err != nil { - return nil, fmt.Errorf("while reading CONNECT TLS CA file %q: %w", t.TLS.CAFile, err) - } - roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(rootsPEM) { - return nil, fmt.Errorf("CONNECT TLS CA file %q contains no certificates", t.TLS.CAFile) - } - cfg.RootCAs = roots - return cfg, nil + return roundTripConnect(transport, req, pr, pw, authority, pepAddress) } func deriveConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst net.Addr) (string, []byte) { @@ -543,11 +418,11 @@ func tlsClientHelloSNI(data []byte) (string, bool, bool) { return "", false, false } -func newConnectRequest(ctx context.Context, scheme string, identity ActorIdentity, originalDst net.Addr, authority string) (*http.Request, *io.PipeReader, *io.PipeWriter) { +func newConnectRequest(ctx context.Context, identity ActorIdentity, originalDst net.Addr, authority string) (*http.Request, *io.PipeReader, *io.PipeWriter) { pr, pw := io.Pipe() req := &http.Request{ Method: http.MethodConnect, - URL: &url.URL{Scheme: scheme, Host: authority}, + URL: &url.URL{Scheme: "http", Host: authority}, Host: authority, Header: make(http.Header), Body: pr, @@ -591,28 +466,28 @@ func roundTripConnect( transport.CloseIdleConnections() return nil, err } - return &hboneStream{ + return &connectStream{ requestWriter: pw, responseBody: resp.Body, closeIdle: transport.CloseIdleConnections, }, nil } -type hboneStream struct { +type connectStream struct { requestWriter *io.PipeWriter responseBody io.ReadCloser closeIdle func() } -func (s *hboneStream) Read(p []byte) (int, error) { +func (s *connectStream) Read(p []byte) (int, error) { return s.responseBody.Read(p) } -func (s *hboneStream) Write(p []byte) (int, error) { +func (s *connectStream) Write(p []byte) (int, error) { return s.requestWriter.Write(p) } -func (s *hboneStream) Close() error { +func (s *connectStream) Close() error { err := errors.Join(s.requestWriter.Close(), s.responseBody.Close()) if s.closeIdle != nil { s.closeIdle() diff --git a/internal/egresscapture/capture_test.go b/internal/egress/capture_test.go similarity index 76% rename from internal/egresscapture/capture_test.go rename to internal/egress/capture_test.go index 3ede130da..7d73ff667 100644 --- a/internal/egresscapture/capture_test.go +++ b/internal/egress/capture_test.go @@ -12,12 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -package egresscapture +package egress import ( "context" "crypto/tls" - "fmt" "io" "net" "strings" @@ -61,10 +60,6 @@ func TestConfigFromEnvRequiresPEPAddress(t *testing.T) { func TestConfigFromEnv(t *testing.T) { t.Setenv(EnvPEPAddress, "ate-egress.example:15008") - t.Setenv(EnvTunnelProtocol, TunnelProtocolConnectTLS) - t.Setenv(EnvConnectTLSServerName, "ate-egress.example") - t.Setenv(EnvConnectTLSCAFile, "/run/egress-ca/ca.crt") - t.Setenv(EnvConnectTLSInsecureSkipVerify, "true") listeners := []Listener{{Port: 15001}} cfg, err := ConfigFromEnv(listeners) @@ -74,64 +69,14 @@ func TestConfigFromEnv(t *testing.T) { if cfg.PEPAddress != "ate-egress.example:15008" { t.Fatalf("cfg.PEPAddress = %q, want ate-egress.example:15008", cfg.PEPAddress) } - if cfg.Protocol != TunnelProtocolConnectTLS { - t.Fatalf("cfg.Protocol = %q, want %s", cfg.Protocol, TunnelProtocolConnectTLS) - } - if cfg.TLS.ServerName != "ate-egress.example" { - t.Fatalf("cfg.TLS.ServerName = %q, want ate-egress.example", cfg.TLS.ServerName) - } - if cfg.TLS.CAFile != "/run/egress-ca/ca.crt" { - t.Fatalf("cfg.TLS.CAFile = %q, want /run/egress-ca/ca.crt", cfg.TLS.CAFile) - } - if !cfg.TLS.InsecureSkipVerify { - t.Fatal("cfg.TLS.InsecureSkipVerify = false, want true") - } if len(cfg.Listeners) != 1 || cfg.Listeners[0].Port != 15001 { t.Fatalf("cfg.Listeners = %+v, want port 15001", cfg.Listeners) } } -func TestNewTunnelTransportUsesRegisteredFactories(t *testing.T) { - for _, tc := range []struct { - name string - protocol string - wantType any - }{ - { - name: "default connect", - protocol: "", - wantType: &PlaintextCONNECTTunnelTransport{}, - }, - { - name: "plaintext alias", - protocol: TunnelProtocolPlaintext, - wantType: &PlaintextCONNECTTunnelTransport{}, - }, - { - name: "tls connect alias", - protocol: TunnelProtocolTLSConnect, - wantType: &TLSCONNECTTunnelTransport{}, - }, - } { - t.Run(tc.name, func(t *testing.T) { - got, err := NewTunnelTransport(Config{PEPAddress: "ate-egress.example:15008", Protocol: tc.protocol}) - if err != nil { - t.Fatalf("NewTunnelTransport() returned error: %v", err) - } - if fmt.Sprintf("%T", got) != fmt.Sprintf("%T", tc.wantType) { - t.Fatalf("NewTunnelTransport() = %T, want %T", got, tc.wantType) - } - }) - } - - if _, err := NewTunnelTransport(Config{Protocol: "does-not-exist"}); err == nil { - t.Fatal("NewTunnelTransport() returned nil error for unsupported protocol") - } -} - func TestNewConnectRequestUsesConfiguredAuthority(t *testing.T) { originalDst := &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 443} - req, pr, pw := newConnectRequest(context.Background(), "http", ActorIdentity{ + req, pr, pw := newConnectRequest(context.Background(), ActorIdentity{ Namespace: "default", Template: "counter", ActorID: "my-counter-1", @@ -233,12 +178,12 @@ func TestProxyByteStreamStopsWhenContextCancelled(t *testing.T) { } } -func TestHBONEStreamCloseClosesIdleTransportConnections(t *testing.T) { +func TestConnectStreamCloseClosesIdleTransportConnections(t *testing.T) { pr, pw := io.Pipe() defer pr.Close() called := false - stream := &hboneStream{ + stream := &connectStream{ requestWriter: pw, responseBody: io.NopCloser(strings.NewReader("")), closeIdle: func() { diff --git a/internal/egress/env.go b/internal/egress/env.go new file mode 100644 index 000000000..e345c1f38 --- /dev/null +++ b/internal/egress/env.go @@ -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 egress + +const ( + EnvCaptureEnabled = "ATE_EGRESS_CAPTURE_ENABLED" + EnvPEPAddress = "ATE_EGRESS_PEP_ADDRESS" +) + +const ( + DefaultCapturePort = uint16(15001) + DefaultOriginalHTTPPort = uint16(80) + DefaultOriginalTLSPort = uint16(443) +) + +type Redirect struct { + OriginalPort uint16 + CapturePort uint16 +} + +var DefaultCaptureRedirects = []Redirect{ + {OriginalPort: DefaultOriginalHTTPPort, CapturePort: DefaultCapturePort}, + {OriginalPort: DefaultOriginalTLSPort, CapturePort: DefaultCapturePort}, +} + +var DefaultCaptureListeners = []Listener{ + {Port: DefaultCapturePort}, +} diff --git a/internal/egresscapture/env.go b/internal/egresscapture/env.go deleted file mode 100644 index 211e81092..000000000 --- a/internal/egresscapture/env.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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 egresscapture - -const ( - EnvCaptureEnabled = "ATE_EGRESS_CAPTURE_ENABLED" - EnvPEPAddress = "ATE_EGRESS_PEP_ADDRESS" - EnvTunnelProtocol = "ATE_EGRESS_TUNNEL_PROTOCOL" - EnvConnectTLSServerName = "ATE_EGRESS_CONNECT_TLS_SERVER_NAME" - EnvConnectTLSCAFile = "ATE_EGRESS_CONNECT_TLS_CA_FILE" - EnvConnectTLSInsecureSkipVerify = "ATE_EGRESS_CONNECT_TLS_INSECURE_SKIP_VERIFY" - DefaultTunnelProtocol = TunnelProtocolConnect - TunnelProtocolConnect = "connect" - TunnelProtocolPlaintext = "plaintext" - TunnelProtocolH2C = "h2c" - TunnelProtocolConnectTLS = "connect-tls" - TunnelProtocolHTTPSConnect = "https-connect" - TunnelProtocolTLSConnect = "tls-connect" - FutureTunnelProtocolHBONE = "hbone" -) - -var OptionalEnvNames = []string{ - EnvConnectTLSServerName, - EnvConnectTLSCAFile, - EnvConnectTLSInsecureSkipVerify, -} From 83250b595a98270aba53de869e5e155fbfcb8dfa Mon Sep 17 00:00:00 2001 From: npolshakova Date: Mon, 6 Jul 2026 07:17:14 -0700 Subject: [PATCH 4/9] fix atespace, classify port Signed-off-by: npolshakova --- cmd/ateom-gvisor/egress_proxy.go | 23 ++++++---- cmd/ateom-microvm/egress_proxy.go | 23 ++++++---- docs/egress-capture.md | 15 ++++--- hack/install-ate.sh | 2 + internal/egress/capture.go | 58 +++++++----------------- internal/egress/capture_test.go | 75 +++++++++++++++++++++++++++++++ internal/egress/env.go | 14 +----- 7 files changed, 131 insertions(+), 79 deletions(-) diff --git a/cmd/ateom-gvisor/egress_proxy.go b/cmd/ateom-gvisor/egress_proxy.go index 0f280453d..d57ad61c1 100644 --- a/cmd/ateom-gvisor/egress_proxy.go +++ b/cmd/ateom-gvisor/egress_proxy.go @@ -48,17 +48,22 @@ func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity } func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { - for _, redirect := range egress.DefaultCaptureRedirects { - c.AddRule(&nftables.Rule{ - Table: table, - Chain: prerouting, - Exprs: tcpRedirectExprs(sourceIP, redirect.OriginalPort, redirect.CapturePort), - }) - } + c.AddRule(&nftables.Rule{ + Table: table, + Chain: prerouting, + Exprs: tcpRedirectExprs(sourceIP, egress.DefaultCapturePort), + }) } -func tcpRedirectExprs(sourceIP string, originalPort, capturePort uint16) []expr.Any { - exprs := append(ipSourceEqual(sourceIP), tcpDestinationPortEqual(originalPort)...) +func tcpRedirectExprs(sourceIP string, capturePort uint16) []expr.Any { + exprs := append(ipSourceEqual(sourceIP), + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.IPPROTO_TCP}, + }, + ) exprs = append(exprs, &expr.Immediate{ Register: 1, diff --git a/cmd/ateom-microvm/egress_proxy.go b/cmd/ateom-microvm/egress_proxy.go index bd54a4e1f..e4b8792bd 100644 --- a/cmd/ateom-microvm/egress_proxy.go +++ b/cmd/ateom-microvm/egress_proxy.go @@ -49,17 +49,22 @@ func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity } func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { - for _, redirect := range egress.DefaultCaptureRedirects { - c.AddRule(&nftables.Rule{ - Table: table, - Chain: prerouting, - Exprs: tcpRedirectExprs(sourceIP, redirect.OriginalPort, redirect.CapturePort), - }) - } + c.AddRule(&nftables.Rule{ + Table: table, + Chain: prerouting, + Exprs: tcpRedirectExprs(sourceIP, egress.DefaultCapturePort), + }) } -func tcpRedirectExprs(sourceIP string, originalPort, capturePort uint16) []expr.Any { - exprs := append(ipSourceEqual(sourceIP), tcpDestinationPortEqual(originalPort)...) +func tcpRedirectExprs(sourceIP string, capturePort uint16) []expr.Any { + exprs := append(ipSourceEqual(sourceIP), + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.IPPROTO_TCP}, + }, + ) exprs = append(exprs, &expr.Immediate{ Register: 1, diff --git a/docs/egress-capture.md b/docs/egress-capture.md index 8b3d661a8..3c77047b5 100644 --- a/docs/egress-capture.md +++ b/docs/egress-capture.md @@ -134,7 +134,8 @@ registry and snapshot bucket settings: Create an actor: ```bash -kubectl ate create actor my-egress-1 --template ate-demo-egress/egress +kubectl ate create atespace demo +kubectl ate create actor my-egress-1 --template ate-demo-egress/egress -a demo ``` Forward the router locally: @@ -148,7 +149,7 @@ will make an outbound HTTPS request to `https://httpbin.org/get` by default: ```bash curl -i -X POST \ - -H "Host: my-egress-1.actors.resources.substrate.ate.dev" \ + -H "Host: my-egress-1.demo.actors.resources.substrate.ate.dev" \ http://localhost:8000 ``` @@ -168,7 +169,7 @@ To test a different `httpbin.org` path, pass it as the `url` query parameter: ```bash curl -i -X POST --get \ - -H "Host: my-egress-1.actors.resources.substrate.ate.dev" \ + -H "Host: my-egress-1.demo.actors.resources.substrate.ate.dev" \ --data-urlencode "url=https://httpbin.org/headers" \ "http://localhost:8000" ``` @@ -182,7 +183,7 @@ agentgateway config only routes `httpbin.org:443`. Find the worker pod hosting the actor: ```bash -actor_json=$(kubectl ate get actor my-egress-1 -o json) +actor_json=$(kubectl ate get actor my-egress-1 -a demo -o json) ateom_ns=$(jq -r '.actors[0].ateomPodNamespace' <<<"${actor_json}") ateom_pod=$(jq -r '.actors[0].ateomPodName' <<<"${actor_json}") @@ -256,8 +257,8 @@ kubectl logs -n agentgateway-system deploy/agentgateway --tail=200 ## Clean up ```bash -kubectl ate suspend actor my-egress-1 -kubectl ate delete actor my-egress-1 +kubectl ate suspend actor my-egress-1 -a demo +kubectl ate delete actor my-egress-1 -a demo ./hack/install-ate.sh --delete-demo-egress ``` @@ -286,7 +287,7 @@ If the capture listener logs are missing, confirm that the actor is running on a fresh worker pod created after egress was enabled: ```bash -kubectl ate get actor my-egress-1 +kubectl ate get actor my-egress-1 -a demo kubectl get pods -n ate-demo-egress -l ate.dev/worker-pool=egress ``` diff --git a/hack/install-ate.sh b/hack/install-ate.sh index 693472ae4..1a6a8abc2 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -179,6 +179,8 @@ render_ate_system_manifests() { # Build everything resolved with base manifests for GKE run_ko resolve -f manifests/ate-install fi +} + resolve_ipv4_addresses() { local host="$1" if command -v python3 >/dev/null 2>&1; then diff --git a/internal/egress/capture.go b/internal/egress/capture.go index 46aaa3916..b262ce875 100644 --- a/internal/egress/capture.go +++ b/internal/egress/capture.go @@ -219,59 +219,35 @@ func openCONNECTTunnel(ctx context.Context, pepAddress string, identity ActorIde } func deriveConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst net.Addr) (string, []byte) { - if tcpAddr, ok := originalDst.(*net.TCPAddr); ok && tcpAddr.Port == 443 { - authority, initialBytes := deriveTLSConnectAuthority(ctx, actorConn, tcpAddr) - return authority, initialBytes - } - if tcpAddr, ok := originalDst.(*net.TCPAddr); ok && tcpAddr.Port == 80 { - authority, initialBytes := deriveHTTPConnectAuthority(ctx, actorConn, tcpAddr) - return authority, initialBytes + if tcpAddr, ok := originalDst.(*net.TCPAddr); ok { + return classifyConnectAuthority(ctx, actorConn, tcpAddr) } return originalDst.String(), nil } -func deriveTLSConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst *net.TCPAddr) (string, []byte) { - const maxClientHelloBytes = 16 * 1024 - _ = actorConn.SetReadDeadline(time.Now().Add(2 * time.Second)) - defer actorConn.SetReadDeadline(time.Time{}) - - var initialBytes []byte - buf := make([]byte, 2048) - for len(initialBytes) < maxClientHelloBytes { - n, err := actorConn.Read(buf) - if n > 0 { - initialBytes = append(initialBytes, buf[:n]...) - if sni, ok, needMore := tlsClientHelloSNI(initialBytes); ok { - return net.JoinHostPort(sni, strconv.Itoa(originalDst.Port)), initialBytes - } else if !needMore { - break - } - } - if err != nil { - if ctx.Err() != nil { - return originalDst.String(), initialBytes - } - break - } - } - return originalDst.String(), initialBytes -} - -func deriveHTTPConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst *net.TCPAddr) (string, []byte) { - const maxHeaderBytes = 16 * 1024 +func classifyConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst *net.TCPAddr) (string, []byte) { + const maxSniffBytes = 16 * 1024 _ = actorConn.SetReadDeadline(time.Now().Add(2 * time.Second)) defer actorConn.SetReadDeadline(time.Time{}) var initialBytes []byte buf := make([]byte, 2048) - for len(initialBytes) < maxHeaderBytes { + for len(initialBytes) < maxSniffBytes { n, err := actorConn.Read(buf) if n > 0 { initialBytes = append(initialBytes, buf[:n]...) - if host, ok, needMore := httpHostHeader(initialBytes); ok { - return authorityWithDefaultPort(host, originalDst.Port), initialBytes - } else if !needMore { - break + if initialBytes[0] == 0x16 { + if sni, ok, needMore := tlsClientHelloSNI(initialBytes); ok { + return net.JoinHostPort(sni, strconv.Itoa(originalDst.Port)), initialBytes + } else if !needMore { + break + } + } else { + if host, ok, needMore := httpHostHeader(initialBytes); ok { + return authorityWithDefaultPort(host, originalDst.Port), initialBytes + } else if !needMore { + break + } } } if err != nil { diff --git a/internal/egress/capture_test.go b/internal/egress/capture_test.go index 7d73ff667..ed6c2d1d1 100644 --- a/internal/egress/capture_test.go +++ b/internal/egress/capture_test.go @@ -132,6 +132,35 @@ func TestDeriveConnectAuthorityFromTLSClientHelloSNI(t *testing.T) { } } +func TestDeriveConnectAuthorityFromTLSClientHelloSNIOnAnyPort(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + errCh := make(chan error, 1) + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: "httpbin.org", + InsecureSkipVerify: true, + }) + errCh <- tlsConn.Handshake() + }() + + authority, initialBytes := deriveConnectAuthority(context.Background(), serverConn, &net.TCPAddr{ + IP: net.ParseIP("203.0.113.10"), + Port: 8443, + }) + if authority != "httpbin.org:8443" { + t.Fatalf("deriveConnectAuthority() authority = %q, want httpbin.org:8443", authority) + } + if len(initialBytes) == 0 { + t.Fatal("deriveConnectAuthority() returned no initial bytes") + } + + _ = clientConn.Close() + <-errCh +} + func TestDeriveConnectAuthorityFromHTTPHost(t *testing.T) { clientConn, serverConn := net.Pipe() defer clientConn.Close() @@ -156,6 +185,52 @@ func TestDeriveConnectAuthorityFromHTTPHost(t *testing.T) { } } +func TestDeriveConnectAuthorityFromHTTPHostOnAnyPort(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + errCh := make(chan error, 1) + go func() { + _, err := clientConn.Write([]byte("GET /get HTTP/1.1\r\nHost: httpbin.org\r\n\r\n")) + errCh <- err + }() + + authority, initialBytes := deriveConnectAuthority(context.Background(), serverConn, &net.TCPAddr{ + IP: net.ParseIP("203.0.113.10"), + Port: 8080, + }) + if authority != "httpbin.org:8080" { + t.Fatalf("deriveConnectAuthority() authority = %q, want httpbin.org:8080", authority) + } + if string(initialBytes) != "GET /get HTTP/1.1\r\nHost: httpbin.org\r\n\r\n" { + t.Fatalf("initial bytes = %q", string(initialBytes)) + } + if err := <-errCh; err != nil { + t.Fatalf("client write returned error: %v", err) + } +} + +func TestDeriveConnectAuthorityFallsBackToOriginalDestination(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + go func() { + _, _ = clientConn.Write([]byte("not http or tls")) + _ = clientConn.Close() + }() + + originalDst := &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 2222} + authority, initialBytes := deriveConnectAuthority(context.Background(), serverConn, originalDst) + if authority != originalDst.String() { + t.Fatalf("deriveConnectAuthority() authority = %q, want %q", authority, originalDst.String()) + } + if string(initialBytes) != "not http or tls" { + t.Fatalf("initial bytes = %q", string(initialBytes)) + } +} + func TestProxyByteStreamStopsWhenContextCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) actorConn, actorPeer := net.Pipe() diff --git a/internal/egress/env.go b/internal/egress/env.go index e345c1f38..9b423fa89 100644 --- a/internal/egress/env.go +++ b/internal/egress/env.go @@ -20,21 +20,9 @@ const ( ) const ( - DefaultCapturePort = uint16(15001) - DefaultOriginalHTTPPort = uint16(80) - DefaultOriginalTLSPort = uint16(443) + DefaultCapturePort = uint16(15001) ) -type Redirect struct { - OriginalPort uint16 - CapturePort uint16 -} - -var DefaultCaptureRedirects = []Redirect{ - {OriginalPort: DefaultOriginalHTTPPort, CapturePort: DefaultCapturePort}, - {OriginalPort: DefaultOriginalTLSPort, CapturePort: DefaultCapturePort}, -} - var DefaultCaptureListeners = []Listener{ {Port: DefaultCapturePort}, } From 63c70782451242da833506614a1f60975933e158 Mon Sep 17 00:00:00 2001 From: npolshakova Date: Mon, 6 Jul 2026 09:33:53 -0700 Subject: [PATCH 5/9] gateway label controlled binding Signed-off-by: npolshakova --- cmd/ateapi/internal/controlapi/egress_pep.go | 261 +++++++++++ .../internal/controlapi/egress_pep_test.go | 235 ++++++++++ .../internal/controlapi/functional_test.go | 2 +- cmd/ateapi/internal/controlapi/informer.go | 25 + cmd/ateapi/internal/controlapi/service.go | 4 +- cmd/ateapi/internal/controlapi/syncer.go | 1 + cmd/ateapi/internal/controlapi/workflow.go | 6 +- .../internal/controlapi/workflow_pause.go | 1 + .../internal/controlapi/workflow_resume.go | 36 +- .../internal/controlapi/workflow_suspend.go | 1 + cmd/ateapi/main.go | 73 ++- .../internal/controllers/workerpool_apply.go | 24 - .../controllers/workerpool_apply_test.go | 30 -- cmd/atelet/main.go | 2 + cmd/ateom-gvisor/egress_proxy.go | 125 ----- cmd/ateom-gvisor/main.go | 29 +- cmd/ateom-microvm/net.go | 12 +- cmd/ateom-microvm/restore.go | 3 +- cmd/ateom-microvm/run.go | 3 +- docs/egress-capture.md | 358 +++++++++++--- go.mod | 39 +- go.sum | 41 ++ hack/install-ate.sh | 59 +-- internal/ateomegress/doc.go | 17 + .../ateomegress/egress_proxy_linux.go | 70 +-- internal/egress/capture.go | 127 +++-- internal/egress/capture_test.go | 83 +++- internal/egress/env.go | 5 +- internal/proto/ateletpb/atelet.pb.go | 38 +- internal/proto/ateletpb/atelet.proto | 4 + internal/proto/ateompb/ateom.pb.go | 39 +- internal/proto/ateompb/ateom.proto | 10 + manifests/ate-install/ate-api-server.yaml | 3 + manifests/ate-install/ate-controller.yaml | 4 - pkg/proto/ateapipb/ateapi.pb.go | 22 +- pkg/proto/ateapipb/ateapi.proto | 7 + .../fxamacker/cbor/v2/.golangci.yml | 176 +++---- vendor/github.com/fxamacker/cbor/v2/README.md | 11 +- vendor/github.com/fxamacker/cbor/v2/cache.go | 257 +++++----- vendor/github.com/fxamacker/cbor/v2/decode.go | 440 +++++++++--------- .../fxamacker/cbor/v2/decode_map_utils.go | 98 ++++ .../github.com/fxamacker/cbor/v2/diagnose.go | 25 +- vendor/github.com/fxamacker/cbor/v2/encode.go | 55 ++- .../fxamacker/cbor/v2/simplevalue.go | 2 +- vendor/github.com/fxamacker/cbor/v2/stream.go | 48 +- .../fxamacker/cbor/v2/structfields.go | 65 ++- vendor/github.com/fxamacker/cbor/v2/valid.go | 20 +- .../go-openapi/jsonpointer/.cliff.toml | 181 ------- .../go-openapi/jsonpointer/.gitignore | 1 + .../go-openapi/jsonpointer/.golangci.yml | 1 + .../go-openapi/jsonpointer/CODE_OF_CONDUCT.md | 6 +- .../go-openapi/jsonpointer/CONTRIBUTORS.md | 29 +- .../github.com/go-openapi/jsonpointer/NOTICE | 2 +- .../go-openapi/jsonpointer/README.md | 57 ++- .../go-openapi/jsonpointer/SECURITY.md | 28 +- .../go-openapi/jsonpointer/errors.go | 26 +- .../go-openapi/jsonpointer/ifaces.go | 47 ++ .../go-openapi/jsonpointer/options.go | 86 ++++ .../go-openapi/jsonpointer/pointer.go | 348 ++++++++++---- .../go-openapi/jsonreference/.cliff.toml | 181 ------- .../go-openapi/jsonreference/.gitignore | 7 +- .../go-openapi/jsonreference/.golangci.yml | 1 + .../jsonreference/CODE_OF_CONDUCT.md | 6 +- .../go-openapi/jsonreference/CONTRIBUTORS.md | 4 +- .../go-openapi/jsonreference/NOTICE | 4 +- .../go-openapi/jsonreference/README.md | 36 +- .../go-openapi/jsonreference/SECURITY.md | 28 +- .../go-openapi/jsonreference/reference.go | 1 + vendor/github.com/go-openapi/swag/.gitignore | 1 + .../go-openapi/swag/CODE_OF_CONDUCT.md | 6 +- .../go-openapi/swag/CONTRIBUTORS.md | 36 ++ vendor/github.com/go-openapi/swag/README.md | 285 ++++++------ vendor/github.com/go-openapi/swag/SECURITY.md | 28 +- vendor/github.com/go-openapi/swag/go.work | 2 +- .../swag/jsonname/go_name_provider.go | 286 ++++++++++++ .../go-openapi/swag/jsonname/ifaces.go | 14 + .../go-openapi/swag/jsonname/name_provider.go | 2 + .../go-openapi/swag/jsonutils/README.md | 11 +- .../go-openapi/swag/mangling/BENCHMARK.md | 4 +- .../gnostic-models/extensions/extension.proto | 2 +- .../gnostic-models/openapiv2/OpenAPIv2.proto | 2 +- .../gnostic-models/openapiv3/OpenAPIv3.proto | 2 +- .../openapiv3/annotations.proto | 2 +- .../dynamic/dynamicinformer/informer.go | 200 ++++++++ .../dynamic/dynamicinformer/interface.go | 53 +++ .../dynamic/dynamiclister/interface.go | 40 ++ .../client-go/dynamic/dynamiclister/lister.go | 91 ++++ .../client-go/dynamic/dynamiclister/shim.go | 87 ++++ vendor/modules.txt | 66 +-- .../v6/fieldpath/element.go | 28 +- .../v6/fieldpath/pathelementmap.go | 25 +- .../structured-merge-diff/v6/fieldpath/set.go | 24 +- .../v6/value/allocator.go | 80 ++-- .../v6/value/jsontagutil.go | 7 +- 94 files changed, 3695 insertions(+), 1765 deletions(-) create mode 100644 cmd/ateapi/internal/controlapi/egress_pep.go create mode 100644 cmd/ateapi/internal/controlapi/egress_pep_test.go delete mode 100644 cmd/ateom-gvisor/egress_proxy.go create mode 100644 internal/ateomegress/doc.go rename cmd/ateom-microvm/egress_proxy.go => internal/ateomegress/egress_proxy_linux.go (76%) create mode 100644 vendor/github.com/fxamacker/cbor/v2/decode_map_utils.go delete mode 100644 vendor/github.com/go-openapi/jsonpointer/.cliff.toml create mode 100644 vendor/github.com/go-openapi/jsonpointer/ifaces.go create mode 100644 vendor/github.com/go-openapi/jsonpointer/options.go delete mode 100644 vendor/github.com/go-openapi/jsonreference/.cliff.toml create mode 100644 vendor/github.com/go-openapi/swag/CONTRIBUTORS.md create mode 100644 vendor/github.com/go-openapi/swag/jsonname/go_name_provider.go create mode 100644 vendor/github.com/go-openapi/swag/jsonname/ifaces.go create mode 100644 vendor/k8s.io/client-go/dynamic/dynamicinformer/informer.go create mode 100644 vendor/k8s.io/client-go/dynamic/dynamicinformer/interface.go create mode 100644 vendor/k8s.io/client-go/dynamic/dynamiclister/interface.go create mode 100644 vendor/k8s.io/client-go/dynamic/dynamiclister/lister.go create mode 100644 vendor/k8s.io/client-go/dynamic/dynamiclister/shim.go diff --git a/cmd/ateapi/internal/controlapi/egress_pep.go b/cmd/ateapi/internal/controlapi/egress_pep.go new file mode 100644 index 000000000..c43779304 --- /dev/null +++ b/cmd/ateapi/internal/controlapi/egress_pep.go @@ -0,0 +1,261 @@ +// 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 ( + "context" + "fmt" + "log/slog" + "net" + "sort" + "strconv" + "strings" + + "github.com/agent-substrate/substrate/internal/egress" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/tools/cache" +) + +type egressPEPGateway struct { + namespace string + name string + address string + labels map[string]string +} + +func resolveEgressPEPAddress(ctx context.Context, gatewayIndexer cache.Indexer, atespace, actorID string) (string, error) { + if gatewayIndexer == nil { + return "", nil + } + + var candidates []egressPEPGateway + for _, obj := range gatewayIndexer.List() { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + return "", fmt.Errorf("egress PEP cache contained %T, want *unstructured.Unstructured", obj) + } + pep, ok, err := egressPEPGatewayFromUnstructured(u) + if err != nil { + return "", err + } + if !ok { + continue + } + // An actor-scoped PEP needs both labels: the actor label alone scores 0 + // for every actor (egressPEPGatewayScore requires the atespace to match + // too), so the Gateway silently matches nothing. Warn instead of failing + // so the actor still falls back to the next-best PEP. + if pep.labels[egress.LabelActor] != "" && pep.labels[egress.LabelAtespace] == "" { + slog.WarnContext(ctx, "Egress PEP Gateway has an actor label but no atespace label; it can never match any actor", + "gateway", pep.namespace+"/"+pep.name) + } + candidates = append(candidates, pep) + } + + bestScore := 0 + var best *egressPEPGateway + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].namespace != candidates[j].namespace { + return candidates[i].namespace < candidates[j].namespace + } + return candidates[i].name < candidates[j].name + }) + for i := range candidates { + score := egressPEPGatewayScore(candidates[i].labels, atespace, actorID) + if score > bestScore { + bestScore = score + best = &candidates[i] + } + } + if best == nil { + return "", nil + } + return best.address, nil +} + +func egressPEPGatewayScore(labels map[string]string, atespace, actorID string) int { + if _, ok := labels[egress.LabelPEP]; !ok { + return 0 + } + + pepAtespace := labels[egress.LabelAtespace] + pepActor := labels[egress.LabelActor] + switch { + case pepActor != "": + if pepActor == actorID && pepAtespace == atespace { + return 3 + } + case pepAtespace != "": + if pepAtespace == atespace { + return 2 + } + default: + return 1 + } + return 0 +} + +func egressPEPGatewayFromUnstructured(u *unstructured.Unstructured) (egressPEPGateway, bool, error) { + labels := u.GetLabels() + if _, ok := labels[egress.LabelPEP]; !ok { + return egressPEPGateway{}, false, nil + } + + // Only Programmed Gateways are candidates: an unprovisioned dataplane has no + // working address, and selecting it would hand ateom a dead PEP. Skipping + // (rather than erroring) lets resolution fall back to the next-best PEP + // while a Gateway is still being reconciled. + programmed, err := gatewayProgrammed(u) + if err != nil { + return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err) + } + if !programmed { + return egressPEPGateway{}, false, nil + } + + port, ok, err := gatewayHTTPListenerPort(u) + if err != nil { + return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err) + } + if !ok { + return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s has no HTTP listener", u.GetNamespace(), u.GetName()) + } + + // Prefer the address the Gateway implementation published in + // status.addresses; fall back to the agentgateway convention of a Service + // named after the Gateway when the implementation publishes none (e.g. + // agentgateway on kind, where the LoadBalancer address stays pending). + host, err := gatewayStatusAddress(u) + if err != nil { + return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err) + } + if host == "" { + host = fmt.Sprintf("%s.%s.svc.cluster.local", u.GetName(), u.GetNamespace()) + } + + return egressPEPGateway{ + namespace: u.GetNamespace(), + name: u.GetName(), + address: net.JoinHostPort(host, strconv.FormatInt(port, 10)), + labels: labels, + }, true, nil +} + +// gatewayProgrammed reports whether the Gateway has condition Programmed=True. +func gatewayProgrammed(u *unstructured.Unstructured) (bool, error) { + conditions, ok, err := unstructured.NestedSlice(u.Object, "status", "conditions") + if err != nil { + return false, fmt.Errorf("read status.conditions: %w", err) + } + if !ok { + return false, nil + } + for _, condition := range conditions { + m, ok := condition.(map[string]any) + if !ok { + return false, fmt.Errorf("condition had type %T, want map[string]any", condition) + } + conditionType, _, err := unstructured.NestedString(m, "type") + if err != nil { + return false, fmt.Errorf("read condition type: %w", err) + } + if conditionType != "Programmed" { + continue + } + conditionStatus, _, err := unstructured.NestedString(m, "status") + if err != nil { + return false, fmt.Errorf("read condition status: %w", err) + } + return conditionStatus == "True", nil + } + return false, nil +} + +// gatewayStatusAddress returns the first address the Gateway implementation +// published in status.addresses, or "" if none is published. +func gatewayStatusAddress(u *unstructured.Unstructured) (string, error) { + addresses, ok, err := unstructured.NestedSlice(u.Object, "status", "addresses") + if err != nil { + return "", fmt.Errorf("read status.addresses: %w", err) + } + if !ok { + return "", nil + } + for _, address := range addresses { + m, ok := address.(map[string]any) + if !ok { + return "", fmt.Errorf("address had type %T, want map[string]any", address) + } + value, _, err := unstructured.NestedString(m, "value") + if err != nil { + return "", fmt.Errorf("read address value: %w", err) + } + if value != "" { + return value, nil + } + } + return "", nil +} + +func gatewayHTTPListenerPort(u *unstructured.Unstructured) (int64, bool, error) { + listeners, ok, err := unstructured.NestedSlice(u.Object, "spec", "listeners") + if err != nil { + return 0, false, fmt.Errorf("read spec.listeners: %w", err) + } + if !ok { + return 0, false, nil + } + for _, listener := range listeners { + m, ok := listener.(map[string]any) + if !ok { + return 0, false, fmt.Errorf("listener had type %T, want map[string]any", listener) + } + protocol, _, err := unstructured.NestedString(m, "protocol") + if err != nil { + return 0, false, fmt.Errorf("read listener protocol: %w", err) + } + if !strings.EqualFold(protocol, "HTTP") { + continue + } + port, ok, err := listenerPort(m) + if err != nil { + return 0, false, err + } + if !ok { + return 0, false, fmt.Errorf("HTTP listener has no port") + } + return port, true, nil + } + return 0, false, nil +} + +func listenerPort(listener map[string]any) (int64, bool, error) { + port, ok := listener["port"] + if !ok { + return 0, false, nil + } + switch v := port.(type) { + case int64: + return v, true, nil + case int32: + return int64(v), true, nil + case int: + return int64(v), true, nil + case float64: + return int64(v), true, nil + default: + return 0, false, fmt.Errorf("HTTP listener port had type %T, want integer", port) + } +} diff --git a/cmd/ateapi/internal/controlapi/egress_pep_test.go b/cmd/ateapi/internal/controlapi/egress_pep_test.go new file mode 100644 index 000000000..a81febeaa --- /dev/null +++ b/cmd/ateapi/internal/controlapi/egress_pep_test.go @@ -0,0 +1,235 @@ +// 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 ( + "testing" + + "github.com/agent-substrate/substrate/internal/egress" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/tools/cache" +) + +func TestResolveEgressPEPAddressEmpty(t *testing.T) { + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + + got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") + if err != nil { + t.Fatalf("resolveEgressPEPAddress() error = %v", err) + } + if got != "" { + t.Fatalf("resolveEgressPEPAddress() = %q, want empty", got) + } +} + +func TestResolveEgressPEPAddressSelectsBestGateway(t *testing.T) { + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + for _, gw := range []*unstructured.Unstructured{ + gatewayPEP("global", "ate-system", map[string]string{ + egress.LabelPEP: "true", + }, "HTTP", int64(15080)), + gatewayPEP("space", "ate-system", map[string]string{ + egress.LabelPEP: "true", + egress.LabelAtespace: "space-a", + }, "HTTP", int64(15081)), + gatewayPEP("actor", "ate-system", map[string]string{ + egress.LabelPEP: "true", + egress.LabelAtespace: "space-a", + egress.LabelActor: "actor-a", + }, "HTTP", int64(15082)), + gatewayPEP("other-actor", "ate-system", map[string]string{ + egress.LabelPEP: "true", + egress.LabelAtespace: "space-a", + egress.LabelActor: "actor-b", + }, "HTTP", int64(15083)), + gatewayPEP("other-space", "ate-system", map[string]string{ + egress.LabelPEP: "true", + egress.LabelAtespace: "space-b", + }, "HTTP", int64(15084)), + gatewayPEP("unlabeled", "ate-system", nil, "HTTP", int64(15085)), + } { + if err := indexer.Add(gw); err != nil { + t.Fatalf("indexer.Add() error = %v", err) + } + } + + got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") + if err != nil { + t.Fatalf("resolveEgressPEPAddress() error = %v", err) + } + want := "actor.ate-system.svc.cluster.local:15082" + if got != want { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + } +} + +func TestResolveEgressPEPAddressFallsBackToAtespaceThenGlobal(t *testing.T) { + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + for _, gw := range []*unstructured.Unstructured{ + gatewayPEP("global", "ate-system", map[string]string{ + egress.LabelPEP: "true", + }, "HTTP", int64(15080)), + gatewayPEP("space", "ate-system", map[string]string{ + egress.LabelPEP: "true", + egress.LabelAtespace: "space-a", + }, "HTTP", int64(15081)), + } { + if err := indexer.Add(gw); err != nil { + t.Fatalf("indexer.Add() error = %v", err) + } + } + + got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-without-specific-pep") + if err != nil { + t.Fatalf("resolveEgressPEPAddress() error = %v", err) + } + if want := "space.ate-system.svc.cluster.local:15081"; got != want { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + } + + got, err = resolveEgressPEPAddress(t.Context(), indexer, "space-without-specific-pep", "actor-a") + if err != nil { + t.Fatalf("resolveEgressPEPAddress() error = %v", err) + } + if want := "global.ate-system.svc.cluster.local:15080"; got != want { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + } +} + +func TestResolveEgressPEPAddressRequiresHTTPListener(t *testing.T) { + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + if err := indexer.Add(gatewayPEP("tcp-only", "ate-system", map[string]string{ + egress.LabelPEP: "true", + }, "TCP", int64(15080))); err != nil { + t.Fatalf("indexer.Add() error = %v", err) + } + + if _, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a"); err == nil { + t.Fatal("resolveEgressPEPAddress() error = nil, want error") + } +} + +func gatewayPEP(name, namespace string, labels map[string]string, protocol string, port int64) *unstructured.Unstructured { + u := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "Gateway", + "spec": map[string]any{ + "listeners": []any{ + map[string]any{ + "name": "http", + "protocol": protocol, + "port": port, + }, + }, + }, + "status": map[string]any{ + "conditions": []any{ + map[string]any{ + "type": "Programmed", + "status": "True", + }, + }, + }, + }, + } + u.SetName(name) + u.SetNamespace(namespace) + u.SetLabels(labels) + return u +} + +func TestResolveEgressPEPAddressSkipsUnprogrammedGateway(t *testing.T) { + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + unprogrammed := gatewayPEP("actor", "ate-system", map[string]string{ + egress.LabelPEP: "true", + egress.LabelAtespace: "space-a", + egress.LabelActor: "actor-a", + }, "HTTP", int64(15082)) + if err := unstructured.SetNestedSlice(unprogrammed.Object, []any{ + map[string]any{"type": "Programmed", "status": "False"}, + }, "status", "conditions"); err != nil { + t.Fatalf("SetNestedSlice() error = %v", err) + } + for _, gw := range []*unstructured.Unstructured{ + unprogrammed, + gatewayPEP("global", "ate-system", map[string]string{ + egress.LabelPEP: "true", + }, "HTTP", int64(15080)), + } { + if err := indexer.Add(gw); err != nil { + t.Fatalf("indexer.Add() error = %v", err) + } + } + + // The unprogrammed actor-scoped PEP is skipped; resolution falls back to + // the programmed global PEP. + got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") + if err != nil { + t.Fatalf("resolveEgressPEPAddress() error = %v", err) + } + if want := "global.ate-system.svc.cluster.local:15080"; got != want { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + } +} + +func TestResolveEgressPEPAddressActorLabelWithoutAtespaceMatchesNothing(t *testing.T) { + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + for _, gw := range []*unstructured.Unstructured{ + // Misconfigured: actor label without an atespace label can never match. + gatewayPEP("broken-actor", "ate-system", map[string]string{ + egress.LabelPEP: "true", + egress.LabelActor: "actor-a", + }, "HTTP", int64(15082)), + gatewayPEP("global", "ate-system", map[string]string{ + egress.LabelPEP: "true", + }, "HTTP", int64(15080)), + } { + if err := indexer.Add(gw); err != nil { + t.Fatalf("indexer.Add() error = %v", err) + } + } + + got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") + if err != nil { + t.Fatalf("resolveEgressPEPAddress() error = %v", err) + } + if want := "global.ate-system.svc.cluster.local:15080"; got != want { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + } +} + +func TestResolveEgressPEPAddressPrefersStatusAddress(t *testing.T) { + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + gw := gatewayPEP("global", "ate-system", map[string]string{ + egress.LabelPEP: "true", + }, "HTTP", int64(15080)) + if err := unstructured.SetNestedSlice(gw.Object, []any{ + map[string]any{"type": "Hostname", "value": "pep.example.internal"}, + }, "status", "addresses"); err != nil { + t.Fatalf("SetNestedSlice() error = %v", err) + } + if err := indexer.Add(gw); err != nil { + t.Fatalf("indexer.Add() error = %v", err) + } + + got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") + if err != nil { + t.Fatalf("resolveEgressPEPAddress() error = %v", err) + } + if want := "pep.example.internal:15080"; got != want { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + } +} diff --git a/cmd/ateapi/internal/controlapi/functional_test.go b/cmd/ateapi/internal/controlapi/functional_test.go index 57c08a9c5..966065aca 100644 --- a/cmd/ateapi/internal/controlapi/functional_test.go +++ b/cmd/ateapi/internal/controlapi/functional_test.go @@ -310,7 +310,7 @@ func setupTest(t *testing.T, ns string) *testContext { } dialer := NewAteletDialer(workerInformer.GetIndexer(), ateletInformer.GetIndexer()) - service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient) + service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient, nil) // 5. Start REAL gRPC Server for ATE API grpcServer := grpc.NewServer(grpc.UnaryInterceptor(ateinterceptors.ServerUnaryInterceptor)) diff --git a/cmd/ateapi/internal/controlapi/informer.go b/cmd/ateapi/internal/controlapi/informer.go index 1f082cdd0..cb448e4b8 100644 --- a/cmd/ateapi/internal/controlapi/informer.go +++ b/cmd/ateapi/internal/controlapi/informer.go @@ -17,8 +17,12 @@ package controlapi import ( "time" + "github.com/agent-substrate/substrate/internal/egress" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" @@ -32,6 +36,13 @@ const ( workerPodLabel = "ate.dev/worker-pool" ) +// GatewayGVR is the Gateway API resource ate-api watches for egress PEPs. +var GatewayGVR = schema.GroupVersionResource{ + Group: "gateway.networking.k8s.io", + Version: "v1", + Resource: "gateways", +} + // AteletInformer creates a SharedInformerFactory and SharedIndexInformer for Atelet pods. func AteletInformer(kc kubernetes.Interface) (informers.SharedInformerFactory, cache.SharedIndexInformer) { factory := informers.NewSharedInformerFactoryWithOptions(kc, 0, @@ -73,3 +84,17 @@ func WorkerPodInformer(kc kubernetes.Interface) (informers.SharedInformerFactory return factory, workerPodInformer } + +// GatewayPEPInformer watches labeled Gateways that ate-api can use as egress PEPs. +// +// The watch is cluster-wide and trusts Gateway labels: RBAC on Gateways is the +// security boundary (see docs/egress-capture.md, "Trust model"). If Gateway +// write access can't be restricted to the platform team, scope this watch to +// an allowlist of PEP namespaces instead. +func GatewayPEPInformer(dc dynamic.Interface) (dynamicinformer.DynamicSharedInformerFactory, cache.SharedIndexInformer) { + factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dc, 0, metav1.NamespaceAll, func(options *metav1.ListOptions) { + options.LabelSelector = egress.LabelPEP + }) + gatewayInformer := factory.ForResource(GatewayGVR).Informer() + return factory, gatewayInformer +} diff --git a/cmd/ateapi/internal/controlapi/service.go b/cmd/ateapi/internal/controlapi/service.go index 7ec695b00..7a09f5157 100644 --- a/cmd/ateapi/internal/controlapi/service.go +++ b/cmd/ateapi/internal/controlapi/service.go @@ -20,6 +20,7 @@ import ( listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" ) // Service implements ateapipb.Control @@ -43,13 +44,14 @@ func NewService( sandboxConfigLister listersv1alpha1.SandboxConfigLister, dialer *AteletDialer, kubeClient kubernetes.Interface, + gatewayPEPIndexer cache.Indexer, ) *Service { s := &Service{ persistence: persistence, actorTemplateLister: actorTemplateLister, workerPoolLister: workerPoolLister, dialer: dialer, - actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient), + actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient, gatewayPEPIndexer), } return s diff --git a/cmd/ateapi/internal/controlapi/syncer.go b/cmd/ateapi/internal/controlapi/syncer.go index 82854f826..3945e32d9 100644 --- a/cmd/ateapi/internal/controlapi/syncer.go +++ b/cmd/ateapi/internal/controlapi/syncer.go @@ -229,6 +229,7 @@ func (s *WorkerPoolSyncer) releaseActorOnDeadWorker(ctx context.Context, namespa actor.AteomPodIp = "" actor.InProgressSnapshot = "" actor.WorkerPoolName = "" + actor.EgressPepAddress = "" if _, err := s.persistence.UpdateActor(ctx, actor, actor.GetMetadata().GetVersion()); err != nil && !errors.Is(err, store.ErrPersistenceRetry) { return err } diff --git a/cmd/ateapi/internal/controlapi/workflow.go b/cmd/ateapi/internal/controlapi/workflow.go index 496e1bb89..f332266aa 100644 --- a/cmd/ateapi/internal/controlapi/workflow.go +++ b/cmd/ateapi/internal/controlapi/workflow.go @@ -31,6 +31,7 @@ import ( "google.golang.org/grpc/status" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" ) // WorkflowStep represents a single, idempotent operation in a workflow graph. @@ -122,6 +123,7 @@ type ActorWorkflow struct { workerPoolLister listersv1alpha1.WorkerPoolLister sandboxConfigLister listersv1alpha1.SandboxConfigLister kubeClient kubernetes.Interface + gatewayPEPIndexer cache.Indexer secretCache *envSecretCache } @@ -134,6 +136,7 @@ func NewActorWorkflow( workerPoolLister listersv1alpha1.WorkerPoolLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister, kubeClient kubernetes.Interface, + gatewayPEPIndexer cache.Indexer, ) *ActorWorkflow { return &ActorWorkflow{ store: store, @@ -143,6 +146,7 @@ func NewActorWorkflow( workerPoolLister: workerPoolLister, sandboxConfigLister: sandboxConfigLister, kubeClient: kubeClient, + gatewayPEPIndexer: gatewayPEPIndexer, secretCache: newEnvSecretCache(envSecretCacheTTL), } } @@ -166,7 +170,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, atespace, name string, steps := []WorkflowStep[*ResumeInput, *ResumeState]{ &LoadActorForResumeStep{store: w.store, actorTemplateLister: w.actorTemplateLister}, - &AssignWorkerStep{store: w.store, workerCache: w.workerCache}, + &AssignWorkerStep{store: w.store, workerCache: w.workerCache, gatewayPEPIndexer: w.gatewayPEPIndexer}, &CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister}, &FinalizeRunningStep{store: w.store}, } diff --git a/cmd/ateapi/internal/controlapi/workflow_pause.go b/cmd/ateapi/internal/controlapi/workflow_pause.go index 4499ac6a5..0ecbfa986 100644 --- a/cmd/ateapi/internal/controlapi/workflow_pause.go +++ b/cmd/ateapi/internal/controlapi/workflow_pause.go @@ -222,6 +222,7 @@ func (s *FinalizePausedStep) Execute(ctx context.Context, input *PauseInput, sta latestActor.AteomPodName = "" latestActor.AteomPodIp = "" latestActor.WorkerPoolName = "" + latestActor.EgressPepAddress = "" updatedActor, err := s.store.UpdateActor(ctx, latestActor, latestActor.GetMetadata().GetVersion()) if err != nil { return err diff --git a/cmd/ateapi/internal/controlapi/workflow_resume.go b/cmd/ateapi/internal/controlapi/workflow_resume.go index 3b7c9ab5f..b93d6c962 100644 --- a/cmd/ateapi/internal/controlapi/workflow_resume.go +++ b/cmd/ateapi/internal/controlapi/workflow_resume.go @@ -36,6 +36,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" ) // ResumeInput holds the immutable parameters requested by the client. @@ -106,8 +107,9 @@ func isWorkerEligibleForActor(worker *ateapipb.Worker, templateClass atev1alpha1 } type AssignWorkerStep struct { - store store.Interface - workerCache *workercache.Cache + store store.Interface + workerCache *workercache.Cache + gatewayPEPIndexer cache.Indexer } func (s *AssignWorkerStep) Name() string { return "AssignWorker" } @@ -116,6 +118,29 @@ func (s *AssignWorkerStep) IsComplete(ctx context.Context, input *ResumeInput, s return state.Actor.GetStatus() == ateapipb.Actor_STATUS_RUNNING, nil } func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, state *ResumeState) error { + // Resolve the egress PEP for this actor and record it on the actor so the + // binding is observable (e.g. "which actors use the global PEP?"). The binding + // is computed dynamically against whatever PEP Gateways exist right now, so we + // re-resolve on every resume. An empty address means no PEP matched and egress + // capture stays off. CallAteletRestoreStep reads this back to tell ateom. + // Resolved before any worker is claimed so a resolution error (e.g. a + // malformed labeled Gateway) fails the resume without stranding a worker + // assignment. + egressPEPAddress, err := resolveEgressPEPAddress(ctx, s.gatewayPEPIndexer, state.Actor.GetMetadata().GetAtespace(), state.Actor.GetMetadata().GetName()) + if err != nil { + return err + } + if egressPEPAddress == "" { + slog.InfoContext(ctx, "Resolved no egress PEP for actor; egress capture disabled", + "actorId", state.Actor.GetMetadata().GetName(), + "atespace", state.Actor.GetMetadata().GetAtespace()) + } else { + slog.InfoContext(ctx, "Resolved egress PEP for actor", + "actorId", state.Actor.GetMetadata().GetName(), + "atespace", state.Actor.GetMetadata().GetAtespace(), + "pepAddress", egressPEPAddress) + } + workers, err := s.workerCache.Workers() if err != nil { return fmt.Errorf("while listing workers: %w", err) @@ -190,6 +215,7 @@ func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, stat state.Actor.AteomPodIp = assignedWorker.GetIp() state.Actor.AteomPodUid = assignedWorker.GetWorkerPodUid() state.Actor.WorkerPoolName = assignedWorker.GetWorkerPool() + state.Actor.EgressPepAddress = egressPEPAddress updatedActor, err := s.store.UpdateActor(ctx, state.Actor, state.Actor.GetMetadata().GetVersion()) if err != nil { @@ -265,6 +291,9 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, if err != nil { return err } + // AssignWorkerStep resolved and persisted the egress PEP for this resume; read + // it back to tell ateom where to tunnel captured egress (empty = no redirect). + egressPEPAddress := state.Actor.GetEgressPepAddress() if data := state.Actor.GetLatestSnapshotInfo().GetData(); data != nil { slog.InfoContext(ctx, "Actor has snapshot; Restoring from snapshot") @@ -275,6 +304,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, ActorName: state.Actor.GetMetadata().GetName(), ActorTemplateNamespace: state.Actor.GetActorTemplateNamespace(), ActorTemplateName: state.Actor.GetActorTemplateName(), + EgressPepAddress: egressPEPAddress, Spec: workloadSpec, } switch d := data.(type) { @@ -311,6 +341,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, ActorName: state.Actor.GetMetadata().GetName(), ActorTemplateNamespace: state.Actor.GetActorTemplateNamespace(), ActorTemplateName: state.Actor.GetActorTemplateName(), + EgressPepAddress: egressPEPAddress, Spec: workloadSpec, Type: ateletpb.CheckpointType_CHECKPOINT_TYPE_EXTERNAL, Config: &ateletpb.RestoreRequest_ExternalConfig{ @@ -339,6 +370,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, ActorName: state.Actor.GetMetadata().GetName(), ActorTemplateNamespace: state.Actor.GetActorTemplateNamespace(), ActorTemplateName: state.Actor.GetActorTemplateName(), + EgressPepAddress: egressPEPAddress, SandboxAssets: sandboxAssets, Spec: workloadSpec, } diff --git a/cmd/ateapi/internal/controlapi/workflow_suspend.go b/cmd/ateapi/internal/controlapi/workflow_suspend.go index 6a4cc6cc4..058c9f23c 100644 --- a/cmd/ateapi/internal/controlapi/workflow_suspend.go +++ b/cmd/ateapi/internal/controlapi/workflow_suspend.go @@ -214,6 +214,7 @@ func (s *FinalizeSuspendedStep) Execute(ctx context.Context, input *SuspendInput latestActor.AteomPodName = "" latestActor.AteomPodIp = "" latestActor.WorkerPoolName = "" + latestActor.EgressPepAddress = "" updatedActor, err := s.store.UpdateActor(ctx, latestActor, latestActor.GetMetadata().GetVersion()) if err != nil { return err diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index dd481bc50..c3741c2e8 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -48,8 +48,11 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/reflection" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" ) var ( @@ -112,7 +115,7 @@ func main() { serverboot.Fatal(ctx, "Failed to set up Redis/Valkey", err) } - clientset, ateClient, err := newKubeClients() + clientset, ateClient, dynamicClient, err := newKubeClients() if err != nil { serverboot.Fatal(ctx, "Failed to create Kubernetes clients", err) } @@ -150,8 +153,32 @@ func main() { ateletPodInformerFactory.WaitForCacheSync(stopCh) ateFactory.WaitForCacheSync(stopCh) + // The Gateway PEP watch is optional: clusters without the Gateway API CRD + // (any non-egress install) must start cleanly, so only create the informer + // when the resource is served. Without it the PEP indexer stays nil and + // resolveEgressPEPAddress resolves no PEP, leaving egress capture off. + // Installing the CRD later requires an ate-api restart to start the watch. + var gatewayPEPIndexer cache.Indexer + if gatewayAPIAvailable(ctx, clientset) { + gatewayPEPInformerFactory, gatewayPEPInformer := controlapi.GatewayPEPInformer(dynamicClient) + gatewayPEPInformerFactory.Start(stopCh) + // Bound the sync wait: the CRD can be served while list/watch is still + // denied (e.g. ate-api rolled before the updated ClusterRole), and an + // unbounded wait would hang startup. On timeout the indexer is used + // anyway — it stays empty (capture off) until the watch syncs, and + // recovers live once RBAC is fixed. + syncCtx, cancelSync := context.WithTimeout(ctx, time.Minute) + if !cache.WaitForCacheSync(syncCtx.Done(), gatewayPEPInformer.HasSynced) { + slog.ErrorContext(ctx, "Gateway PEP informer sync timed out; egress PEP resolution degraded until it syncs") + } + cancelSync() + gatewayPEPIndexer = gatewayPEPInformer.GetIndexer() + } else { + slog.InfoContext(ctx, "Gateway API resource not served; egress PEP resolution disabled") + } + dialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer()) - sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, clientset) + sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, clientset, gatewayPEPIndexer) jwtIssuerDiscoveryClient := buildK8sServiceAccountIssuerDiscoveryClient(ctx, *clientJWTCAFile, *clientJWTIssuer) if authModeParsed == ateapiauth.ModeJWT && jwtIssuerDiscoveryClient == nil { @@ -327,22 +354,54 @@ func pingRedisWithRetries(ctx context.Context, client *redis.ClusterClient) erro return fmt.Errorf("ping Redis/Valkey after 30 retries: %w", pingErr) } +// gatewayAPIAvailable reports whether the cluster serves the Gateway API +// gateways resource. Clusters without the Gateway API CRDs must not gate +// ate-api startup on a watch that can never sync. +// +// Checked once at startup: a NotFound means the CRD is absent (expected on +// non-egress PEP setups), any other error means discovery failed. Either way +// egress PEP resolution is disabled until ate-api is restarted — the failed +// case logs at Error since it may fail open on a cluster that serves the CRD. +// +// TODO: missing gateway api currently disables egress until restart, can add retries here +func gatewayAPIAvailable(ctx context.Context, clientset *kubernetes.Clientset) bool { + resources, err := clientset.Discovery().ServerResourcesForGroupVersion(controlapi.GatewayGVR.GroupVersion().String()) + if err != nil { + if apierrors.IsNotFound(err) { + slog.InfoContext(ctx, "Gateway API not served; egress PEP resolution disabled") + } else { + slog.ErrorContext(ctx, "Gateway API discovery failed; egress PEP resolution disabled until restart", slog.Any("err", err)) + } + return false + } + for _, r := range resources.APIResources { + if r.Name == controlapi.GatewayGVR.Resource { + return true + } + } + return false +} + // newKubeClients builds the standard Kubernetes clientset and the ate // (substrate CRD) clientset from in-cluster config. -func newKubeClients() (*kubernetes.Clientset, versioned.Interface, error) { +func newKubeClients() (*kubernetes.Clientset, versioned.Interface, dynamic.Interface, error) { config, err := rest.InClusterConfig() if err != nil { - return nil, nil, fmt.Errorf("get cluster config: %w", err) + return nil, nil, nil, fmt.Errorf("get cluster config: %w", err) } clientset, err := kubernetes.NewForConfig(config) if err != nil { - return nil, nil, fmt.Errorf("create clientset: %w", err) + return nil, nil, nil, fmt.Errorf("create clientset: %w", err) } ateClient, err := versioned.NewForConfig(config) if err != nil { - return nil, nil, fmt.Errorf("create ate clientset: %w", err) + return nil, nil, nil, fmt.Errorf("create ate clientset: %w", err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, nil, nil, fmt.Errorf("create dynamic clientset: %w", err) } - return clientset, ateClient, nil + return clientset, ateClient, dynamicClient, nil } // buildServerCreds loads the workerpool CA pool (if configured) and diff --git a/cmd/atecontroller/internal/controllers/workerpool_apply.go b/cmd/atecontroller/internal/controllers/workerpool_apply.go index 0324c1101..6a586d2af 100644 --- a/cmd/atecontroller/internal/controllers/workerpool_apply.go +++ b/cmd/atecontroller/internal/controllers/workerpool_apply.go @@ -15,16 +15,12 @@ package controllers import ( - "os" - "strconv" - corev1 "k8s.io/api/core/v1" appsv1ac "k8s.io/client-go/applyconfigurations/apps/v1" corev1ac "k8s.io/client-go/applyconfigurations/core/v1" metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "github.com/agent-substrate/substrate/internal/ateompath" - "github.com/agent-substrate/substrate/internal/egress" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" ) @@ -49,7 +45,6 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool) *appsv1ac.Deployment WithFieldRef(corev1ac.ObjectFieldSelector(). WithFieldPath("metadata.uid"))), ) - containerAC.WithEnv(egressCaptureEnvFromController()...) containerAC. WithVolumeMounts(corev1ac.VolumeMount(). WithName("run-ateom"). @@ -88,25 +83,6 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool) *appsv1ac.Deployment WithSpec(podSpecAC))) } -func egressCaptureEnvFromController() []*corev1ac.EnvVarApplyConfiguration { - enabled, _ := strconv.ParseBool(os.Getenv(egress.EnvCaptureEnabled)) - if !enabled { - return nil - } - - env := []*corev1ac.EnvVarApplyConfiguration{ - corev1ac.EnvVar(). - WithName(egress.EnvCaptureEnabled). - WithValue("true"), - } - if v := os.Getenv(egress.EnvPEPAddress); v != "" { - env = append(env, corev1ac.EnvVar(). - WithName(egress.EnvPEPAddress). - WithValue(v)) - } - return env -} - // maybeApplyMicroVMPodShape adds the /dev/kvm device and node placement a // micro-VM (kata + cloud-hypervisor) worker pool needs, on top of any // pod-template settings. No-op unless sandboxClass is the micro-VM class. diff --git a/cmd/atecontroller/internal/controllers/workerpool_apply_test.go b/cmd/atecontroller/internal/controllers/workerpool_apply_test.go index b4f5a6945..1cdc972d6 100644 --- a/cmd/atecontroller/internal/controllers/workerpool_apply_test.go +++ b/cmd/atecontroller/internal/controllers/workerpool_apply_test.go @@ -26,7 +26,6 @@ import ( metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "github.com/agent-substrate/substrate/internal/ateompath" - "github.com/agent-substrate/substrate/internal/egress" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" ) @@ -262,35 +261,6 @@ func TestMicroVMPodShape(t *testing.T) { } } -func TestWorkerPoolEgressCaptureEnvPropagation(t *testing.T) { - t.Setenv(egress.EnvCaptureEnabled, "1") - t.Setenv(egress.EnvPEPAddress, "ate-egress.agentgateway-system.svc.cluster.local:15008") - - wp := testWorkerPoolApplyConfig(nil) - deployment := buildDeploymentApplyConfig(wp) - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 1 { - t.Fatalf("containers length = %d, want 1", len(containers)) - } - - got := map[string]string{} - for _, env := range containers[0].Env { - if env.Name != nil && env.Value != nil { - got[*env.Name] = *env.Value - } - } - - want := map[string]string{ - egress.EnvCaptureEnabled: "true", - egress.EnvPEPAddress: "ate-egress.agentgateway-system.svc.cluster.local:15008", - } - for name, value := range want { - if got[name] != value { - t.Errorf("env %s = %q, want %q", name, got[name], value) - } - } -} - func testWorkerPoolApplyConfig(tmpl *atev1alpha1.WorkerPoolPodTemplate) *atev1alpha1.WorkerPool { return &atev1alpha1.WorkerPool{ ObjectMeta: metav1.ObjectMeta{Name: "pool", Namespace: "default", UID: "uid"}, diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 9a5eaefaa..df6826ab3 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -259,6 +259,7 @@ func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (*atele ActorName: actorName, ActorTemplateNamespace: req.GetActorTemplateNamespace(), ActorTemplateName: req.GetActorTemplateName(), + EgressPepAddress: req.GetEgressPepAddress(), RunscPath: runscPathFor(assetPaths), RuntimeAssetPaths: assetPaths, Spec: buildAteomWorkloadSpec(req.GetSpec()), @@ -554,6 +555,7 @@ func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest) ActorName: actorName, ActorTemplateNamespace: req.GetActorTemplateNamespace(), ActorTemplateName: req.GetActorTemplateName(), + EgressPepAddress: req.GetEgressPepAddress(), RunscPath: runscPathFor(assetPaths), RuntimeAssetPaths: assetPaths, Spec: buildAteomWorkloadSpec(req.GetSpec()), diff --git a/cmd/ateom-gvisor/egress_proxy.go b/cmd/ateom-gvisor/egress_proxy.go deleted file mode 100644 index d57ad61c1..000000000 --- a/cmd/ateom-gvisor/egress_proxy.go +++ /dev/null @@ -1,125 +0,0 @@ -//go:build linux - -// 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 main - -import ( - "context" - "encoding/binary" - "fmt" - "net" - "syscall" - "unsafe" - - "github.com/agent-substrate/substrate/internal/egress" - "github.com/google/nftables" - "github.com/google/nftables/binaryutil" - "github.com/google/nftables/expr" - "golang.org/x/sys/unix" -) - -func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egress.ActorIdentity) error { - if !egress.EnabledFromEnv() { - return nil - } - cfg, err := egress.ConfigFromEnv(egress.DefaultCaptureListeners) - if err != nil { - return err - } - capture, err := egress.Start(ctx, identity, cfg, originalDestination) - if err != nil { - return fmt.Errorf("while starting actor egress capture: %w", err) - } - s.egressCapture = capture - return nil -} - -func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { - c.AddRule(&nftables.Rule{ - Table: table, - Chain: prerouting, - Exprs: tcpRedirectExprs(sourceIP, egress.DefaultCapturePort), - }) -} - -func tcpRedirectExprs(sourceIP string, capturePort uint16) []expr.Any { - exprs := append(ipSourceEqual(sourceIP), - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{unix.IPPROTO_TCP}, - }, - ) - exprs = append(exprs, - &expr.Immediate{ - Register: 1, - Data: binaryutil.BigEndian.PutUint16(capturePort), - }, - &expr.Redir{ - RegisterProtoMin: 1, - }, - ) - return exprs -} - -func originalDestination(conn net.Conn) (net.Addr, error) { - tcpConn, ok := conn.(*net.TCPConn) - if !ok { - return nil, fmt.Errorf("captured connection is %T, not *net.TCPConn", conn) - } - - rawConn, err := tcpConn.SyscallConn() - if err != nil { - return nil, err - } - - var addr *net.TCPAddr - var controlErr error - if err := rawConn.Control(func(fd uintptr) { - addr, controlErr = originalDstFromFD(int(fd)) - }); err != nil { - return nil, err - } - if controlErr != nil { - return nil, controlErr - } - return addr, nil -} - -func originalDstFromFD(fd int) (*net.TCPAddr, error) { - var raw unix.RawSockaddrInet4 - size := uint32(unsafe.Sizeof(raw)) - _, _, errno := unix.Syscall6( - unix.SYS_GETSOCKOPT, - uintptr(fd), - uintptr(unix.SOL_IP), - uintptr(unix.SO_ORIGINAL_DST), - uintptr(unsafe.Pointer(&raw)), - uintptr(unsafe.Pointer(&size)), - 0, - ) - if errno != 0 { - return nil, errno - } - if raw.Family != syscall.AF_INET { - return nil, fmt.Errorf("SO_ORIGINAL_DST returned address family %d", raw.Family) - } - return &net.TCPAddr{ - IP: net.IPv4(raw.Addr[0], raw.Addr[1], raw.Addr[2], raw.Addr[3]), - Port: int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&raw.Port))[:])), - }, nil -} diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 16dc73139..5fdfa27e7 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -30,6 +30,7 @@ import ( "cloud.google.com/go/compute/metadata" "github.com/agent-substrate/substrate/internal/actorlog" "github.com/agent-substrate/substrate/internal/ateinterceptors" + "github.com/agent-substrate/substrate/internal/ateomegress" "github.com/agent-substrate/substrate/internal/ateompath" "github.com/agent-substrate/substrate/internal/contextlogging" "github.com/agent-substrate/substrate/internal/egress" @@ -190,7 +191,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload // * Correct runsc version is downloaded and placed on disk. // * All OCI bundles are set up, including for "pause" container. - if err := s.setupActorNetwork(ctx, actorIdentityFromRun(req)); err != nil { + if err := s.setupActorNetwork(ctx, ateomegress.ActorIdentityFromRun(req), req.GetEgressPepAddress()); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } defer func() { @@ -364,7 +365,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore // * All OCI bundles are set up, including for "pause" container. // * Checkpoint downloaded and placed on disk - if err := s.setupActorNetwork(ctx, actorIdentityFromRestore(req)); err != nil { + if err := s.setupActorNetwork(ctx, ateomegress.ActorIdentityFromRestore(req), req.GetEgressPepAddress()); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } defer func() { @@ -440,7 +441,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore return &ateompb.RestoreWorkloadResponse{}, nil } -func (s *AteomService) setupActorNetwork(ctx context.Context, identity egress.ActorIdentity) (retErr error) { +func (s *AteomService) setupActorNetwork(ctx context.Context, identity egress.ActorIdentity, egressPEPAddress string) (retErr error) { // Build a fresh point-to-point network between the worker pod netns and the // gVisor interior netns. The worker side keeps the pod's real eth0, creates // ateom0 as the gateway, and moves only the veth peer into the actor netns. @@ -508,9 +509,11 @@ func (s *AteomService) setupActorNetwork(ctx context.Context, identity egress.Ac if err := enableIPv4Forwarding(); err != nil { return err } - if err := s.startEgressCaptureIfEnabled(ctx, identity); err != nil { + egressCapture, err := ateomegress.StartCaptureIfEnabled(ctx, identity, egressPEPAddress) + if err != nil { return err } + s.egressCapture = egressCapture if err := installActorNftablesRules(podIP, s.egressCapture != nil); err != nil { return err @@ -722,7 +725,7 @@ func installActorNftablesRules(podIP net.IP, egressCapture bool) error { Priority: nftables.ChainPriorityNATDest, }) if egressCapture { - addEgressCaptureRedirectRules(c, table, prerouting, actorVethIP) + ateomegress.AddCaptureRedirectRules(c, table, prerouting, actorVethIP) } // TODO: Support optional DNS capture for hostname recovery for non-SNI, // non-HTTP, or DNS-policy egress. The current HTTP/HTTPS path derives @@ -859,22 +862,6 @@ func tcpDestinationPortEqual(port uint16) []expr.Any { } } -func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egress.ActorIdentity { - return egress.ActorIdentity{ - Namespace: req.GetActorTemplateNamespace(), - Template: req.GetActorTemplateName(), - ActorID: req.GetActorId(), - } -} - -func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egress.ActorIdentity { - return egress.ActorIdentity{ - Namespace: req.GetActorTemplateNamespace(), - Template: req.GetActorTemplateName(), - ActorID: req.GetActorId(), - } -} - func createNetNSWithoutSwitching(ctx context.Context, name string) (netns.NsHandle, error) { runtime.LockOSThread() defer runtime.UnlockOSThread() diff --git a/cmd/ateom-microvm/net.go b/cmd/ateom-microvm/net.go index a03d34932..1aa3c7a4a 100644 --- a/cmd/ateom-microvm/net.go +++ b/cmd/ateom-microvm/net.go @@ -29,9 +29,6 @@ package main // CONSTANT, a restored guest's frozen network config stays valid on any pod — // no in-guest reconfiguration needed. // -// (Copied with light adaptation from cmd/ateom-gvisor; expected to be -// de-duplicated into a shared package later.) - import ( "context" "errors" @@ -48,6 +45,7 @@ import ( "github.com/vishvananda/netns" "golang.org/x/sys/unix" + "github.com/agent-substrate/substrate/internal/ateomegress" "github.com/agent-substrate/substrate/internal/egress" "github.com/agent-substrate/substrate/internal/serverboot" ) @@ -121,7 +119,7 @@ func mustParseIP(s string) net.IP { // pod netns and the kata interior netns (see the package comment). Idempotent // via cleanup-before-setup; also sweeps stale kata taps out of the interior // netns so the sandbox always builds on a clean slate. -func (s *AteomService) setupActorNetwork(ctx context.Context, identity egress.ActorIdentity) (retErr error) { +func (s *AteomService) setupActorNetwork(ctx context.Context, identity egress.ActorIdentity, egressPEPAddress string) (retErr error) { s.cleanupActorNetworkOrExit(ctx, "Failed to clean up stale actor network before setup") defer func() { if retErr != nil { @@ -191,9 +189,11 @@ func (s *AteomService) setupActorNetwork(ctx context.Context, identity egress.Ac if err := enableIPv4Forwarding(); err != nil { return err } - if err := s.startEgressCaptureIfEnabled(ctx, identity); err != nil { + egressCapture, err := ateomegress.StartCaptureIfEnabled(ctx, identity, egressPEPAddress) + if err != nil { return err } + s.egressCapture = egressCapture if err := installActorNftablesRules(podIP, s.egressCapture != nil); err != nil { return err } @@ -368,7 +368,7 @@ func installActorNftablesRules(podIP net.IP, egressCapture bool) error { Priority: nftables.ChainPriorityNATDest, }) if egressCapture { - addEgressCaptureRedirectRules(c, table, prerouting, actorVethIP) + ateomegress.AddCaptureRedirectRules(c, table, prerouting, actorVethIP) } preroutingExprs := append(ipDestinationEqual(podIP.String()), tcpDestinationPortEqual(80)...) preroutingExprs = append(preroutingExprs, diff --git a/cmd/ateom-microvm/restore.go b/cmd/ateom-microvm/restore.go index 486a14e52..425dda050 100644 --- a/cmd/ateom-microvm/restore.go +++ b/cmd/ateom-microvm/restore.go @@ -28,6 +28,7 @@ import ( "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/ch" "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/kata" + "github.com/agent-substrate/substrate/internal/ateomegress" "github.com/agent-substrate/substrate/internal/ateompath" "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/readyz" @@ -112,7 +113,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore // Networking: rebuild the per-activation veth + tap; the snapshot's virtio-net // is fd-backed, so CH needs fresh tap FDs (net_fds) on restore. - if err := s.setupActorNetwork(ctx, actorIdentityFromRestore(req)); err != nil { + if err := s.setupActorNetwork(ctx, ateomegress.ActorIdentityFromRestore(req), req.GetEgressPepAddress()); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } defer func() { diff --git a/cmd/ateom-microvm/run.go b/cmd/ateom-microvm/run.go index 7c0be8545..e2b56b46f 100644 --- a/cmd/ateom-microvm/run.go +++ b/cmd/ateom-microvm/run.go @@ -29,6 +29,7 @@ import ( "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/ch" "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/kata" "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/third_party/kata/agentpb" + "github.com/agent-substrate/substrate/internal/ateomegress" "github.com/agent-substrate/substrate/internal/ateompath" "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/readyz" @@ -218,7 +219,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload // Networking (host side): per-activation veth into the interior netns. The // tap + TC mirror is built below (after the VM exists) so its FDs are fresh. - if err := s.setupActorNetwork(ctx, actorIdentityFromRun(req)); err != nil { + if err := s.setupActorNetwork(ctx, ateomegress.ActorIdentityFromRun(req), req.GetEgressPepAddress()); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } defer func() { diff --git a/docs/egress-capture.md b/docs/egress-capture.md index 3c77047b5..994e3b01f 100644 --- a/docs/egress-capture.md +++ b/docs/egress-capture.md @@ -7,12 +7,15 @@ by default. ## Architecture -When egress capture is enabled, each worker pod gets `ATE_EGRESS_*` -configuration from the controller. The reusable capture core lives in -`internal/egress`: it owns environment parsing, capture listeners, -authority derivation, CONNECT tunnel transports, and byte proxying. The -runtime-specific `ateom` egress proxy setup supplies the original-destination -lookup and packet-capture rules. +Egress capture has no global on/off switch. ate-api watches Gateways labeled +`ate.dev/egress-pep`. On actor resume, ate-api picks the best matching PEP +Gateway for that actor and sends one optional PEP address to ateom. An empty PEP +address means no redirect, so capture is enabled per actor purely by whether a +matching labeled Gateway exists. The reusable capture core lives in +`internal/egress`: +it owns capture listeners, authority derivation, CONNECT tunnel transports, and +byte proxying. The runtime-specific `ateom` egress proxy setup supplies the +original-destination lookup and packet-capture rules. The current gVisor implementation starts a local capture listener and installs actor-network redirects for TCP/80 and TCP/443. From the actor's point of view @@ -31,10 +34,15 @@ actor connection: | HTTPS / TCP 443 | TLS ClientHello SNI | `httpbin.org:443` | | Plaintext HTTP / TCP 80 | HTTP `Host` header | `example.com:80` | -The shared capture core then opens a plaintext HTTP/2 CONNECT stream to the -agentgateway data plane at -`ate-egress.agentgateway-system.svc.cluster.local:15008`. Agentgateway maps the -CONNECT authority to its configured TCP listener and routes the tunnel to a +The shared capture core then opens a plaintext HTTP/2 CONNECT stream to the PEP +address selected by ate-api. Only Gateways with condition `Programmed=True` are +candidates; an unprovisioned Gateway is skipped so resolution falls back to the +next-best PEP. The address host comes from the Gateway's `status.addresses` +when the implementation publishes one, and otherwise falls back to +`..svc.cluster.local`, matching the agentgateway service +convention (agentgateway on kind publishes no address because the LoadBalancer +stays pending). The port is the Gateway's HTTP listener port. Agentgateway maps +the CONNECT authority to its configured TCP listener and routes the tunnel to a Kubernetes Service backed by an EndpointSlice. The demo setup configures only `httpbin.org:443` for egress. @@ -43,11 +51,79 @@ Service, EndpointSlice, listener, and route. For HTTPS, TLS is still end-to-end between the actor and the external service; agentgateway only routes the encrypted bytes after CONNECT succeeds. -Enabling egress on an already-running ATE system creates or updates the -`ate-egress-capture` ConfigMap, but that ConfigMap does not currently force an -`ate-controller` restart. If egress variables are missing from worker pods after -enabling capture, restart `ate-controller` so it rereads the config and -reconciles WorkerPool deployments. +### Selecting a PEP for an actor + +`ate.dev/egress-pep` is a marker: its value is ignored, any Gateway carrying +the label key is a candidate PEP. The atespace/actor labels scope which actors +a candidate serves: + +| Gateway labels | Scope | Match precedence | +| --- | --- | --- | +| `ate.dev/egress-pep` only | Global (any actor) | lowest | +| `ate.dev/egress-pep` + `ate.dev/atespace=` | All actors in an atespace | medium | +| `ate.dev/egress-pep` + `ate.dev/atespace=` + `ate.dev/actor=` | One actor | highest | + +Actor scoping requires **both** scoping labels; `ate.dev/actor` alone matches +no actor and ate-api logs a warning. On resume, ate-api picks the +highest-precedence match (`resolveEgressPEPAddress` in +`cmd/ateapi/internal/controlapi/egress_pep.go`). + +#### Tie-breaking + +| Situation | Result | +| --- | --- | +| Multiple candidates tied at the top score | Lowest `(namespace, name)` wins; the others are **silently ignored** | +| No labeled candidate | Empty PEP address → no redirect, capture off | + +Don't deploy multiple PEPs at the same tier for the same actor — use the +scoping labels to raise the intended one's tier instead of relying on name +order. + +#### Trust model + +The `ate.dev/egress-pep` label **is** the PEP control surface: ate-api trusts +every labeled Gateway in every namespace, so Gateway RBAC is the security +boundary — restrict Gateway create/update to the platform team. Anyone who can +label a Gateway can: + +- **Intercept actor egress**: copy an actor's `ate.dev/atespace` + + `ate.dev/actor` labels onto their own Gateway to out-score its real PEP. +- **Block all resumes**: label a Gateway with no HTTP listener (broken PEP + config fails resolution cluster-wide by design). + +Substrate deliberately does not second-guess labeled Gateways. If Gateway RBAC +can't be that strict, scope the watch to an allowlist of PEP namespaces in +ate-api first. + +#### When the binding is (re)computed + +The PEP binding is a **snapshot taken at resume**, recorded on the actor as +`egress_pep_address`. Gateway relabels have no effect on RUNNING actors. + +``` + create + │ + ▼ + SUSPENDED ──────────────────────────────────┐ + │ resume / boot │ + ▼ │ + RESUMING ── resolve PEP now: │ + │ AssignWorkerStep scores every │ + │ labeled Gateway and writes │ + │ actor.egress_pep_address │ + ▼ │ + RUNNING ── uses the PEP captured at │ + │ resume for its whole lifetime; │ + │ Gateway relabels are IGNORED │ + │ suspend / pause │ + ▼ │ + SUSPENDED / PAUSED ── egress_pep_address ─────┘ + cleared; re-resolved + on the next resume +``` + +To move a running actor to a different PEP: relabel the Gateways **and** cycle +the actor (see "Point an actor at a different PEP"). ## Prerequisites @@ -74,32 +150,19 @@ For kind: ./hack/install-ate-kind.sh --egress --deploy-ate-system ``` -This deploys agentgateway with a static `httpbin.org:443` egress route, creates -the `ate-system/ate-egress-capture` config map, and deploys the ATE system. -When `ATE_EGRESS_CAPTURE_ENABLED=true`, `ATE_EGRESS_PEP_ADDRESS` is required; -the install script sets it to the in-cluster `ate-egress` Service by default. +This deploys agentgateway with a static `httpbin.org:443` egress route, labels +the `agentgateway-system/ate-egress` Gateway as a PEP, and deploys the ATE +system. No fixed PEP address is configured; ate-api derives the address from the +labeled Gateway's HTTP listener. The install script resolves `httpbin.org` during install and creates the `httpbin-egress` Service and EndpointSlice for those IPs. `ateom` derives the CONNECT authority from SNI for this HTTPS demo. -Verify the egress config: - -```bash -kubectl get configmap -n ate-system ate-egress-capture -o yaml -``` - -Expected values: - -```yaml -ATE_EGRESS_CAPTURE_ENABLED: "true" -ATE_EGRESS_PEP_ADDRESS: ate-egress.agentgateway-system.svc.cluster.local:15008 -``` - -Verify the static agentgateway resources: +Verify the static agentgateway resources and PEP label: ```bash -kubectl get gateway -n agentgateway-system ate-egress +kubectl get gateway -n agentgateway-system ate-egress --show-labels kubectl get tcproute -n agentgateway-system httpbin-egress kubectl get agentgatewaypolicy -n agentgateway-system ate-egress-connect kubectl get service -n agentgateway-system httpbin-egress @@ -109,7 +172,7 @@ kubectl get endpointslice -n agentgateway-system httpbin-egress Expected resources include: ```text -gateway.gateway.networking.k8s.io/ate-egress +gateway.gateway.networking.k8s.io/ate-egress ... ate.dev/egress-pep=true tcproute.gateway.networking.k8s.io/httpbin-egress agentgatewaypolicy.agentgateway.dev/ate-egress-connect service/httpbin-egress @@ -190,43 +253,212 @@ ateom_pod=$(jq -r '.actors[0].ateomPodName' <<<"${actor_json}") echo "${ateom_ns}/${ateom_pod}" ``` -Check that the ateom pod received capture configuration: +Check the ateom logs: ```bash -kubectl get pod -n "${ateom_ns}" "${ateom_pod}" \ - -o jsonpath='{range .spec.containers[?(@.name=="ateom")].env[*]}{.name}={.value}{"\n"}{end}' \ - | grep ATE_EGRESS +kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Started actor egress capture listener" +``` + +Expected output includes one log line for the local capture listener: + +```text +Started actor egress capture listener ... "port":15001 ... "pepAddress":"ate-egress.agentgateway-system.svc.cluster.local:15008" +``` + +After the egress request, the logs should also show the captured stream: + +```bash +kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Proxying captured actor egress" ``` Expected output includes: ```text -ATE_EGRESS_CAPTURE_ENABLED=true -ATE_EGRESS_PEP_ADDRESS=ate-egress.agentgateway-system.svc.cluster.local:15008 +Proxying captured actor egress ... "originalDestination":"...:443" ... "connectAuthority":"httpbin.org:443" ``` -Check the ateom logs: +## Check which PEP an actor uses + +ate-api records the PEP it resolved for the actor on its most recent resume, so +you can read the binding directly instead of inferring it from Gateway labels. + +From the actor status (`null` means no PEP matched — the field is omitted from +JSON when empty — so capture is off): ```bash -kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Started actor egress capture listener" +kubectl ate get actor my-egress-1 -a demo -o json | jq -r '.actors[0].egressPepAddress' ``` -Expected output includes one log line for the local capture listener: +Expected value for the demo: ```text -Started actor egress capture listener ... "port":15001 +ate-egress.agentgateway-system.svc.cluster.local:15008 ``` -After the egress request, the logs should also show the captured stream: +ate-api also logs the resolution on each resume. This is the easiest way to +answer "which actors fall through to a global PEP" — grep the PEP address across +ate-api logs: ```bash -kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Proxying captured actor egress" +kubectl logs -n ate-system deploy/ate-api-server-deployment | grep "Resolved egress PEP for actor" ``` Expected output includes: ```text -Proxying captured actor egress ... "originalDestination":"...:443" ... "connectAuthority":"httpbin.org:443" +Resolved egress PEP for actor ... "actorId":"my-egress-1" "atespace":"demo" "pepAddress":"ate-egress.agentgateway-system.svc.cluster.local:15008" +``` + +Actors that matched no PEP log `Resolved no egress PEP for actor; egress capture +disabled` instead, and their `egressPepAddress` status field is absent. + +## Point an actor at a different PEP + +Suppose you want `my-egress-1` to egress through a second Gateway, +`ate-egress-alt`, instead of the shared `ate-egress` PEP. Because an actor's PEP +is a snapshot taken at resume (see "When the binding is (re)computed"), you both +relabel the Gateways and cycle the actor. + +1. Create the alternate Gateway and label it so it out-scores the global PEP for + this actor. Scoping it to the actor (atespace + actor) gives it the highest + tier, so it wins regardless of the global PEP: + +```bash +kubectl apply -f - <<'EOF' +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: ate-egress-alt + namespace: agentgateway-system + labels: + ate.dev/egress-pep: "true" + ate.dev/atespace: "demo" + ate.dev/actor: "my-egress-1" +spec: + gatewayClassName: agentgateway + listeners: + - name: connect + port: 15008 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same + - name: https + port: 443 + protocol: TCP + allowedRoutes: + kinds: + - group: gateway.networking.k8s.io + kind: TCPRoute + namespaces: + from: Same +EOF +``` + + The `connect` listener must be `protocol: HTTP` — ate-api derives the PEP + address from the Gateway's HTTP listener, and a labeled Gateway without one + is treated as a configuration error that fails PEP resolution. + +2. Give the alternate Gateway the CONNECT policy and a route to the backend. + Labeling alone is not enough: without these the tunnel opens but agentgateway + has nothing routing `httpbin.org:443`, and egress fails with a 502 / connection + reset. The `httpbin-egress` Service and EndpointSlice created by the installer + are backend resources and can be reused as-is; you only need a CONNECT policy + and a TCPRoute parent for the new Gateway: + + ```bash +kubectl apply -f - <<'EOF' +apiVersion: agentgateway.dev/v1alpha1 +kind: AgentgatewayPolicy +metadata: + name: ate-egress-alt-connect + namespace: agentgateway-system +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: ate-egress-alt + frontend: + connect: + mode: Tunnel +EOF + ``` + + Attach the existing `httpbin-egress` TCPRoute to the alternate Gateway by + adding it as a second parent (this leaves `ate-egress` routing intact): + + ```bash + kubectl patch tcproute -n agentgateway-system httpbin-egress --type=json \ + -p '[{"op":"add","path":"/spec/parentRefs/-","value":{"group":"gateway.networking.k8s.io","kind":"Gateway","name":"ate-egress-alt","sectionName":"https"}}]' + ``` + + Confirm both the policy and route attached before continuing: + + ```bash + kubectl get agentgatewaypolicy -n agentgateway-system ate-egress-alt-connect + kubectl get tcproute -n agentgateway-system httpbin-egress \ + -o jsonpath='{range .status.parents[*]}{.parentRef.name}{" Accepted="}{.conditions[?(@.type=="Accepted")].status}{"\n"}{end}' + ``` + +3. Cycle the actor so ate-api re-resolves the PEP. A running actor keeps its old + PEP until it is suspended (or paused) and resumed: + + ```bash + kubectl ate suspend actor my-egress-1 -a demo + kubectl ate resume actor my-egress-1 -a demo + ``` + +4. Confirm the actor now points at the alternate PEP: + + ```bash + kubectl ate get actor my-egress-1 -a demo -o json | jq -r '.actors[0].egressPepAddress' + ``` + + Expected value: + + ```text + ate-egress-alt.agentgateway-system.svc.cluster.local:15008 + ``` + +5. Drive traffic again and verify it goes through the new PEP: + + ```bash + curl -i -X POST \ + -H "Host: my-egress-1.demo.actors.resources.substrate.ate.dev" \ + http://localhost:8000 + ``` + + The authoritative signal is the ateom capture log, which names the PEP the + actor actually tunnelled through (see "Verify capture was installed" for how + to find the worker pod): + + ```bash + kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Started actor egress capture listener" + # ... "pepAddress":"ate-egress-alt.agentgateway-system.svc.cluster.local:15008" + ``` + + On the agentgateway side, match any request the alt Gateway handled rather + than a specific protocol — the CONNECT frontend logs `protocol=http`, and a + `protocol=tcp` line only appears once bytes tunnel end to end (a 5xx from the + upstream can prevent it): + + ```bash + kubectl logs -n agentgateway-system \ + -l gateway.networking.k8s.io/gateway-name=ate-egress-alt \ + --all-containers --tail=200 | grep "gateway=agentgateway-system/ate-egress-alt" + ``` + +To revert, remove the alternate Gateway's route parent and delete the Gateway +and its CONNECT policy, then cycle the actor again; ate-api falls back to the +next-best PEP, the global `ate-egress`: + +```bash +kubectl patch tcproute -n agentgateway-system httpbin-egress --type=json \ + -p '[{"op":"remove","path":"/spec/parentRefs/1"}]' +kubectl delete gateway -n agentgateway-system ate-egress-alt +kubectl delete agentgatewaypolicy -n agentgateway-system ate-egress-alt-connect +kubectl ate suspend actor my-egress-1 -a demo +kubectl ate resume actor my-egress-1 -a demo ``` ## Check agentgateway logs @@ -272,17 +504,33 @@ Invalid value: Spec is immutable`, recreate the demo resources: ./hack/install-ate-kind.sh --deploy-demo-egress ``` -If the `ATE_EGRESS_*` variables are missing from the worker pod, restart the -controller and recreate the egress WorkerPool pods after creating the config -map: +If capture listener logs are missing after labeling a Gateway on an +already-running ATE system, no ate-api restart is needed for the label itself — +the Gateway watcher is a live watch and sees label changes immediately. The +usual cause is that the actor was already running: the PEP binding is a +snapshot taken at resume, so cycle the actor (suspend, then resume) to +re-resolve. + +The one case that does require an ate-api restart is installing the Gateway API +CRDs *after* ate-api started: ate-api checks for the Gateway resource once at +boot and disables PEP resolution if it is absent (it logs +`Gateway API resource not served; egress PEP resolution disabled`): ```bash -kubectl rollout restart deployment/ate-controller -n ate-system -kubectl rollout status deployment/ate-controller -n ate-system -kubectl rollout restart deployment/egress-deployment -n ate-demo-egress -kubectl rollout status deployment/egress-deployment -n ate-demo-egress +kubectl rollout restart deployment/ate-api-server-deployment -n ate-system +kubectl rollout status deployment/ate-api-server-deployment -n ate-system ``` +Capture is decided per resume from the PEP address ate-api sends to ateom, so +worker pods do not need to carry any egress config or be restarted. An actor +already running before its PEP Gateway existed picks up capture on its next +resume. + +Worker images must include egress support: an older ateom silently ignores the +PEP address, so `egressPepAddress` on the actor can report a binding the +sandbox does not enforce. If capture logs are missing despite a resolved PEP, +check the WorkerPool's ateom image version. + If the capture listener logs are missing, confirm that the actor is running on a fresh worker pod created after egress was enabled: diff --git a/go.mod b/go.mod index 0bc9e101e..6a6a82c35 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.45.0 @@ -102,27 +103,27 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect @@ -176,7 +177,6 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect @@ -187,9 +187,10 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gotest.tools/v3 v3.5.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 // indirect k8s.io/streaming v0.36.1 // indirect + sigs.k8s.io/gateway-api v1.6.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect ) diff --git a/go.sum b/go.sum index 555f6c4bb..4c5267e87 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -144,38 +146,69 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -183,6 +216,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= @@ -440,17 +475,23 @@ k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 h1:ngxu1nL4SbFuXwu1EY7cSKcVqSjTQPVbYQT6WNjTXaU= +k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/gateway-api v1.6.0 h1:735YBRj5NXFrOGX0GoSjwzUIzbz8kiEOfADsqHFmHgE= +sigs.k8s.io/gateway-api v1.6.0/go.mod h1:FVfx3t389ybeXOqvDghLbdvJdSCfI/PReqCUI3lu3mY= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/install-ate.sh b/hack/install-ate.sh index 1a6a8abc2..aac2db25a 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -51,9 +51,6 @@ COLOR_CYAN='\033[1;36m' COLOR_RESET='\033[0m' ATE_EGRESS_CAPTURE="${ATE_EGRESS_CAPTURE:-false}" -ATE_EGRESS_PEP_ADDRESS="${ATE_EGRESS_PEP_ADDRESS:-ate-egress.agentgateway-system.svc.cluster.local:15008}" -ATE_EGRESS_CAPTURE_ENABLED_ENV="ATE_EGRESS_CAPTURE_ENABLED" -ATE_EGRESS_PEP_ADDRESS_ENV="ATE_EGRESS_PEP_ADDRESS" function log_step() { local step_name="$1" @@ -77,7 +74,7 @@ function usage() { echo " --deploy-ate-apiserver Deploy ate-api-server only" echo " --deploy-ate-controller Deploy ate-controller only" echo " --deploy-atenet Deploy atenet only" - echo " --egress Enable actor egress capture via agentgateway" + echo " --egress Enable actor egress capture via labeled agentgateway PEP Gateways" echo "" echo "To create individual resources used by ate-system (Note: These are" echo "called automatically by --deploy-ate-system):" @@ -295,6 +292,8 @@ kind: Gateway metadata: name: ate-egress namespace: agentgateway-system + labels: + ate.dev/egress-pep: "true" spec: gatewayClassName: agentgateway listeners: @@ -346,43 +345,6 @@ EOF run_kubectl delete agentgatewayparameters -n agentgateway-system ate-egress-params --ignore-not-found } -create_egress_capture_config() { - log_step "create_egress_capture_config" - run_kubectl create namespace ate-system --dry-run=client -o yaml \ - | run_kubectl apply -f - - local literals=( - --from-literal="${ATE_EGRESS_CAPTURE_ENABLED_ENV}=true" - --from-literal="${ATE_EGRESS_PEP_ADDRESS_ENV}=${ATE_EGRESS_PEP_ADDRESS}" - ) - # TODO: Updating this ConfigMap does not by itself restart an already-running - # ate-controller, so enabling egress after a non-egress install may not take - # effect until the controller rolls. Wire a pod-template checksum or restart - # automation so users do not need to run a manual rollout. - run_kubectl create configmap -n ate-system ate-egress-capture \ - "${literals[@]}" \ - --dry-run=client -o yaml \ - | run_kubectl apply -f - -} - -rollout_worker_deployments_for_egress() { - log_step "rollout_worker_deployments_for_egress" - local deployments - deployments="$(run_kubectl get deployments -A -o go-template='{{range .items}}{{if index .spec.selector.matchLabels "ate.dev/worker-pool"}}{{.metadata.namespace}} {{.metadata.name}}{{"\n"}}{{end}}{{end}}' 2>/dev/null || true)" - if [[ -z "${deployments}" ]]; then - echo "No worker deployments found to restart for egress." - return - fi - - local ns name - while read -r ns name; do - if [[ -z "${ns}" || -z "${name}" ]]; then - continue - fi - run_kubectl rollout restart "deployment/${name}" -n "${ns}" - run_kubectl rollout status "deployment/${name}" -n "${ns}" --timeout=120s - done <<< "${deployments}" -} - create_valkey_ca_certs_secret() { log_step "create_valkey_ca_certs_secret" local ca_certs="" @@ -487,7 +449,6 @@ deploy_ate_system() { if [[ "${ATE_EGRESS_CAPTURE}" == "true" ]]; then deploy_agentgateway - create_egress_capture_config fi # Enforce per-class SandboxConfig asset requirements (applied before any @@ -551,6 +512,13 @@ deploy_ate_apiserver() { log_step "deploy_ate_apiserver" ensure_crds + # ate-api is the component that watches PEP Gateways, and it checks for the + # Gateway API once at startup — deploy agentgateway (which installs the + # Gateway API CRDs) before the apiserver rolls. + if [[ "${ATE_EGRESS_CAPTURE}" == "true" ]]; then + deploy_agentgateway + fi + # Ensure namespace exists run_kubectl apply -f manifests/ate-install/ate-system-namespace.yaml \ && run_kubectl wait --for=jsonpath='{.status.phase}'=Active namespace/ate-system --timeout=60s @@ -583,15 +551,8 @@ deploy_atelet() { deploy_ate_controller() { log_step "deploy_ate_controller" - if [[ "${ATE_EGRESS_CAPTURE}" == "true" ]]; then - deploy_agentgateway - create_egress_capture_config - fi run_ko apply -f manifests/ate-install/ate-controller.yaml run_kubectl rollout status deployment/ate-controller -n ate-system --timeout=120s - if [[ "${ATE_EGRESS_CAPTURE}" == "true" ]]; then - rollout_worker_deployments_for_egress - fi } deploy_atenet() { diff --git a/internal/ateomegress/doc.go b/internal/ateomegress/doc.go new file mode 100644 index 000000000..6a2b35755 --- /dev/null +++ b/internal/ateomegress/doc.go @@ -0,0 +1,17 @@ +// 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 ateomegress contains egress-capture helpers shared by ateom +// implementations. +package ateomegress diff --git a/cmd/ateom-microvm/egress_proxy.go b/internal/ateomegress/egress_proxy_linux.go similarity index 76% rename from cmd/ateom-microvm/egress_proxy.go rename to internal/ateomegress/egress_proxy_linux.go index e4b8792bd..48e5e7c3d 100644 --- a/cmd/ateom-microvm/egress_proxy.go +++ b/internal/ateomegress/egress_proxy_linux.go @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package ateomegress import ( "context" @@ -32,23 +32,19 @@ import ( "golang.org/x/sys/unix" ) -func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egress.ActorIdentity) error { - if !egress.EnabledFromEnv() { - return nil - } - cfg, err := egress.ConfigFromEnv(egress.DefaultCaptureListeners) - if err != nil { - return err +func StartCaptureIfEnabled(ctx context.Context, identity egress.ActorIdentity, egressPEPAddress string) (*egress.Capture, error) { + cfg, ok := egress.ConfigForPEPAddress(egressPEPAddress, egress.DefaultCaptureListeners) + if !ok { + return nil, nil } capture, err := egress.Start(ctx, identity, cfg, originalDestination) if err != nil { - return fmt.Errorf("while starting actor egress capture: %w", err) + return nil, fmt.Errorf("while starting actor egress capture: %w", err) } - s.egressCapture = capture - return nil + return capture, nil } -func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { +func AddCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) { c.AddRule(&nftables.Rule{ Table: table, Chain: prerouting, @@ -56,6 +52,24 @@ func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prer }) } +func ActorIdentityFromRun(req *ateompb.RunWorkloadRequest) egress.ActorIdentity { + return egress.ActorIdentity{ + Namespace: req.GetActorTemplateNamespace(), + Template: req.GetActorTemplateName(), + ActorID: req.GetActorId(), + Atespace: req.GetAtespace(), + } +} + +func ActorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egress.ActorIdentity { + return egress.ActorIdentity{ + Namespace: req.GetActorTemplateNamespace(), + Template: req.GetActorTemplateName(), + ActorID: req.GetActorId(), + Atespace: req.GetAtespace(), + } +} + func tcpRedirectExprs(sourceIP string, capturePort uint16) []expr.Any { exprs := append(ipSourceEqual(sourceIP), &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, @@ -77,6 +91,22 @@ func tcpRedirectExprs(sourceIP string, capturePort uint16) []expr.Any { return exprs } +func ipSourceEqual(ip string) []expr.Any { + return []expr.Any{ + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: 12, + Len: 4, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: net.ParseIP(ip).To4(), + }, + } +} + func originalDestination(conn net.Conn) (net.Addr, error) { tcpConn, ok := conn.(*net.TCPConn) if !ok { @@ -124,19 +154,3 @@ func originalDstFromFD(fd int) (*net.TCPAddr, error) { Port: int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&raw.Port))[:])), }, nil } - -func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egress.ActorIdentity { - return egress.ActorIdentity{ - Namespace: req.GetActorTemplateNamespace(), - Template: req.GetActorTemplateName(), - ActorID: req.GetActorId(), - } -} - -func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egress.ActorIdentity { - return egress.ActorIdentity{ - Namespace: req.GetActorTemplateNamespace(), - Template: req.GetActorTemplateName(), - ActorID: req.GetActorId(), - } -} diff --git a/internal/egress/capture.go b/internal/egress/capture.go index b262ce875..1607e9933 100644 --- a/internal/egress/capture.go +++ b/internal/egress/capture.go @@ -15,6 +15,7 @@ package egress import ( + "bytes" "context" "crypto/tls" "encoding/binary" @@ -25,7 +26,6 @@ import ( "net" "net/http" "net/url" - "os" "strconv" "strings" "sync" @@ -38,6 +38,7 @@ type ActorIdentity struct { Namespace string Template string ActorID string + Atespace string // TODO: Include worker_uid once egress identity is modeled as a signed // first-class Substrate identity rather than plain actor metadata headers. } @@ -56,24 +57,16 @@ type OriginalDestinationFunc func(net.Conn) (net.Addr, error) type Capture struct { cancel context.CancelFunc listeners []net.Listener + transport *http2.Transport wg sync.WaitGroup } -func EnabledFromEnv() bool { - enabled, _ := strconv.ParseBool(os.Getenv(EnvCaptureEnabled)) - return enabled -} - -func ConfigFromEnv(listeners []Listener) (Config, error) { - pepAddress := strings.TrimSpace(os.Getenv(EnvPEPAddress)) +func ConfigForPEPAddress(pepAddress string, listeners []Listener) (Config, bool) { + pepAddress = strings.TrimSpace(pepAddress) if pepAddress == "" { - return Config{}, fmt.Errorf("%s must be set when egress capture is enabled", EnvPEPAddress) + return Config{}, false } - cfg := Config{ - PEPAddress: pepAddress, - Listeners: listeners, - } - return cfg, nil + return Config{PEPAddress: pepAddress, Listeners: listeners}, true } func Start(ctx context.Context, identity ActorIdentity, cfg Config, originalDestination OriginalDestinationFunc) (*Capture, error) { @@ -82,7 +75,11 @@ func Start(ctx context.Context, identity ActorIdentity, cfg Config, originalDest } ctx, cancel := newCaptureContext(ctx) - capture := &Capture{cancel: cancel} + // One HTTP/2 transport for the whole capture. The PEP address is fixed for + // the actor's lifetime, so CONNECT streams to the same destination authority + // multiplex over a pooled connection to the PEP instead of paying a fresh TCP + // dial + h2 handshake per captured connection. + capture := &Capture{cancel: cancel, transport: newPEPTransport(cfg.PEPAddress)} for _, listenerCfg := range cfg.Listeners { lis, err := net.Listen("tcp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(int(listenerCfg.Port)))) if err != nil { @@ -100,6 +97,18 @@ func Start(ctx context.Context, identity ActorIdentity, cfg Config, originalDest return capture, nil } +// newPEPTransport builds the shared HTTP/2 transport whose connections all dial +// the fixed PEP address, regardless of the CONNECT authority. +func newPEPTransport(pepAddress string) *http2.Transport { + return &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, network, pepAddress) + }, + } +} + func newCaptureContext(ctx context.Context) (context.Context, context.CancelFunc) { // The setup request context can be cancelled after the actor is running, but // egress capture must keep serving until actor network cleanup closes it. @@ -118,6 +127,9 @@ func (c *Capture) Close() error { } } c.wg.Wait() + if c.transport != nil { + c.transport.CloseIdleConnections() + } return err } @@ -133,14 +145,15 @@ func (c *Capture) serve(ctx context.Context, lis net.Listener, identity ActorIde continue } c.wg.Add(1) + transport := c.transport go func() { defer c.wg.Done() - handleCapturedEgress(ctx, conn, identity, pepAddress, originalDestination) + handleCapturedEgress(ctx, conn, identity, transport, pepAddress, originalDestination) }() } } -func handleCapturedEgress(ctx context.Context, actorConn net.Conn, identity ActorIdentity, pepAddress string, originalDestination OriginalDestinationFunc) { +func handleCapturedEgress(ctx context.Context, actorConn net.Conn, identity ActorIdentity, transport *http2.Transport, pepAddress string, originalDestination OriginalDestinationFunc) { stopActorClose := context.AfterFunc(ctx, func() { _ = actorConn.Close() }) @@ -154,7 +167,7 @@ func handleCapturedEgress(ctx context.Context, actorConn net.Conn, identity Acto } authority, initialBytes := deriveConnectAuthority(ctx, actorConn, originalDst) - tunnel, err := openCONNECTTunnel(ctx, pepAddress, identity, originalDst, authority) + tunnel, err := openCONNECTTunnel(ctx, transport, pepAddress, identity, originalDst, authority) if err != nil { slog.WarnContext(ctx, "Failed to open egress tunnel", "originalDestination", originalDst.String(), @@ -204,17 +217,10 @@ func proxyByteStream(ctx context.Context, actorConn net.Conn, tunnel io.ReadWrit wg.Wait() } -func openCONNECTTunnel(ctx context.Context, pepAddress string, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) { +func openCONNECTTunnel(ctx context.Context, transport *http2.Transport, pepAddress string, identity ActorIdentity, originalDst net.Addr, authority string) (io.ReadWriteCloser, error) { // TODO: Add a transport selector here when there is a second supported // egress tunnel protocol, such as TLS CONNECT or HBONE. req, pr, pw := newConnectRequest(ctx, identity, originalDst, authority) - transport := &http2.Transport{ - AllowHTTP: true, - DialTLSContext: func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { - var dialer net.Dialer - return dialer.DialContext(ctx, network, pepAddress) - }, - } return roundTripConnect(transport, req, pr, pw, authority, pepAddress) } @@ -225,54 +231,81 @@ func deriveConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst return originalDst.String(), nil } +// sniffReadTimeout bounds how long capture waits for the actor to send the +// bytes that reveal a CONNECT authority (TLS SNI or HTTP Host). The authority +// is derived from those bytes, so the tunnel cannot be opened until they +// arrive; a client that speaks only after the server (SMTP, some databases) +// waits out this deadline and then falls back to the original destination. +const sniffReadTimeout = 2 * time.Second + +const maxSniffBytes = 16 * 1024 + func classifyConnectAuthority(ctx context.Context, actorConn net.Conn, originalDst *net.TCPAddr) (string, []byte) { - const maxSniffBytes = 16 * 1024 - _ = actorConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _ = actorConn.SetReadDeadline(time.Now().Add(sniffReadTimeout)) defer actorConn.SetReadDeadline(time.Time{}) var initialBytes []byte + httpScanned := 0 // bytes already searched for the HTTP header terminator buf := make([]byte, 2048) for len(initialBytes) < maxSniffBytes { n, err := actorConn.Read(buf) if n > 0 { initialBytes = append(initialBytes, buf[:n]...) if initialBytes[0] == 0x16 { + // tlsClientHelloSNI is O(1) on an incomplete record (it checks + // the record length prefix before walking). if sni, ok, needMore := tlsClientHelloSNI(initialBytes); ok { return net.JoinHostPort(sni, strconv.Itoa(originalDst.Port)), initialBytes } else if !needMore { break } - } else { - if host, ok, needMore := httpHostHeader(initialBytes); ok { + } else if httpHeadersComplete(initialBytes, &httpScanned) { + // Parse only once the full header block has arrived so a slow or + // byte-at-a-time sender does not re-parse the buffer each read. + if host, ok, _ := httpHostHeader(initialBytes); ok { return authorityWithDefaultPort(host, originalDst.Port), initialBytes - } else if !needMore { - break } + break } } if err != nil { - if ctx.Err() != nil { - return originalDst.String(), initialBytes - } break } } return originalDst.String(), initialBytes } +// httpHeadersComplete reports whether data contains the end-of-headers marker, +// searching only the bytes appended since the last call (with a small overlap +// for a marker split across reads) so the total scan cost stays linear in the +// sniffed byte count. It advances *scanned to the current length. +func httpHeadersComplete(data []byte, scanned *int) bool { + start := *scanned - 3 + if start < 0 { + start = 0 + } + if bytes.Contains(data[start:], []byte("\r\n\r\n")) || bytes.Contains(data[start:], []byte("\n\n")) { + return true + } + *scanned = len(data) + return false +} + func httpHostHeader(data []byte) (string, bool, bool) { - headers := string(data) - headerEnd := strings.Index(headers, "\r\n\r\n") + // Scan for the end-of-headers marker on the raw bytes so an incomplete + // request does not copy the whole accumulated buffer into a string on every + // sniff read; only the bounded header block below is stringified. + headerEnd := bytes.Index(data, []byte("\r\n\r\n")) separator := "\r\n" if headerEnd == -1 { - headerEnd = strings.Index(headers, "\n\n") + headerEnd = bytes.Index(data, []byte("\n\n")) separator = "\n" } if headerEnd == -1 { - return "", false, len(data) < 16*1024 + return "", false, len(data) < maxSniffBytes } - lines := strings.Split(headers[:headerEnd], separator) + lines := strings.Split(string(data[:headerEnd]), separator) if len(lines) == 0 || !strings.Contains(lines[0], " ") { return "", false, false } @@ -410,6 +443,7 @@ func newConnectRequest(ctx context.Context, identity ActorIdentity, originalDst // iat, worker_uid, and the original destination so policy is evaluated over // verified request identity rather than unsigned metadata. req.Header.Set("x-ate-actor-id", identity.ActorID) + req.Header.Set("x-ate-atespace", identity.Atespace) req.Header.Set("x-ate-actor-template", identity.Template) req.Header.Set("x-ate-actor-template-namespace", identity.Namespace) req.Header.Set("x-ate-original-destination", originalDst.String()) @@ -427,11 +461,13 @@ func roundTripConnect( connectAuthority string, pepAddress string, ) (io.ReadWriteCloser, error) { + // The transport is shared across all captured connections, so failures close + // only this stream's pipes; idle connections to the PEP are reaped once in + // Capture.Close so unrelated in-flight streams keep multiplexing. resp, err := transport.RoundTrip(req) if err != nil { _ = pr.CloseWithError(err) _ = pw.CloseWithError(err) - transport.CloseIdleConnections() return nil, err } if resp.StatusCode < 200 || resp.StatusCode > 299 { @@ -439,20 +475,17 @@ func roundTripConnect( err := fmt.Errorf("CONNECT to %s through %s returned %s", connectAuthority, pepAddress, resp.Status) _ = pr.CloseWithError(err) _ = pw.CloseWithError(err) - transport.CloseIdleConnections() return nil, err } return &connectStream{ requestWriter: pw, responseBody: resp.Body, - closeIdle: transport.CloseIdleConnections, }, nil } type connectStream struct { requestWriter *io.PipeWriter responseBody io.ReadCloser - closeIdle func() } func (s *connectStream) Read(p []byte) (int, error) { @@ -464,9 +497,5 @@ func (s *connectStream) Write(p []byte) (int, error) { } func (s *connectStream) Close() error { - err := errors.Join(s.requestWriter.Close(), s.responseBody.Close()) - if s.closeIdle != nil { - s.closeIdle() - } - return err + return errors.Join(s.requestWriter.Close(), s.responseBody.Close()) } diff --git a/internal/egress/capture_test.go b/internal/egress/capture_test.go index ed6c2d1d1..e48951d7d 100644 --- a/internal/egress/capture_test.go +++ b/internal/egress/capture_test.go @@ -46,25 +46,11 @@ func TestNewCaptureContextIgnoresParentCancellation(t *testing.T) { } } -func TestConfigFromEnvRequiresPEPAddress(t *testing.T) { - t.Setenv(EnvPEPAddress, "") - - _, err := ConfigFromEnv(nil) - if err == nil { - t.Fatal("ConfigFromEnv() returned nil error, want error") - } - if !strings.Contains(err.Error(), EnvPEPAddress) { - t.Fatalf("ConfigFromEnv() error = %v, want %s", err, EnvPEPAddress) - } -} - -func TestConfigFromEnv(t *testing.T) { - t.Setenv(EnvPEPAddress, "ate-egress.example:15008") - +func TestConfigForPEPAddress(t *testing.T) { listeners := []Listener{{Port: 15001}} - cfg, err := ConfigFromEnv(listeners) - if err != nil { - t.Fatalf("ConfigFromEnv() returned error: %v", err) + cfg, ok := ConfigForPEPAddress("ate-egress.example:15008", listeners) + if !ok { + t.Fatal("ConfigForPEPAddress() ok = false, want true") } if cfg.PEPAddress != "ate-egress.example:15008" { t.Fatalf("cfg.PEPAddress = %q, want ate-egress.example:15008", cfg.PEPAddress) @@ -74,12 +60,19 @@ func TestConfigFromEnv(t *testing.T) { } } +func TestConfigForPEPAddressEmpty(t *testing.T) { + if _, ok := ConfigForPEPAddress("", nil); ok { + t.Fatal("ConfigForPEPAddress() ok = true, want false") + } +} + func TestNewConnectRequestUsesConfiguredAuthority(t *testing.T) { originalDst := &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 443} req, pr, pw := newConnectRequest(context.Background(), ActorIdentity{ Namespace: "default", Template: "counter", ActorID: "my-counter-1", + Atespace: "team-a", }, originalDst, "httpbin.org:443") defer pr.Close() defer pw.Close() @@ -96,6 +89,9 @@ func TestNewConnectRequestUsesConfiguredAuthority(t *testing.T) { if got := req.Header.Get("x-ate-connect-authority"); got != "httpbin.org:443" { t.Fatalf("x-ate-connect-authority = %q, want httpbin.org:443", got) } + if got := req.Header.Get("x-ate-atespace"); got != "team-a" { + t.Fatalf("x-ate-atespace = %q, want team-a", got) + } } func TestDeriveConnectAuthorityFromTLSClientHelloSNI(t *testing.T) { @@ -253,24 +249,61 @@ func TestProxyByteStreamStopsWhenContextCancelled(t *testing.T) { } } -func TestConnectStreamCloseClosesIdleTransportConnections(t *testing.T) { +func TestConnectStreamCloseClosesPipes(t *testing.T) { pr, pw := io.Pipe() defer pr.Close() - called := false stream := &connectStream{ requestWriter: pw, responseBody: io.NopCloser(strings.NewReader("")), - closeIdle: func() { - called = true - }, } if err := stream.Close(); err != nil { t.Fatalf("stream.Close() returned error: %v", err) } - if !called { - t.Fatal("stream.Close() did not close idle transport connections") + // The request pipe is closed: a subsequent write fails. + if _, err := pw.Write([]byte("x")); err == nil { + t.Fatal("stream.Close() did not close the request writer") + } +} + +func TestHTTPHeadersCompleteDetectsMarkerSplitAcrossReads(t *testing.T) { + // The "\r\n\r\n" marker is delivered across two calls: "...\r\n\r" then "\n". + // The incremental search must still detect it via the small re-scan overlap. + scanned := 0 + first := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r") + if httpHeadersComplete(first, &scanned) { + t.Fatal("httpHeadersComplete() = true on partial marker, want false") + } + if scanned != len(first) { + t.Fatalf("scanned = %d, want %d", scanned, len(first)) + } + full := append(first, '\n') + if !httpHeadersComplete(full, &scanned) { + t.Fatal("httpHeadersComplete() = false after marker completed, want true") + } +} + +func TestDeriveConnectAuthorityFromHTTPHostByteAtATime(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + request := "GET /get HTTP/1.1\r\nHost: httpbin.org\r\n\r\n" + go func() { + for i := 0; i < len(request); i++ { + if _, err := clientConn.Write([]byte{request[i]}); err != nil { + return + } + } + }() + + authority, _ := deriveConnectAuthority(context.Background(), serverConn, &net.TCPAddr{ + IP: net.ParseIP("203.0.113.10"), + Port: 80, + }) + if authority != "httpbin.org:80" { + t.Fatalf("deriveConnectAuthority() authority = %q, want httpbin.org:80", authority) } } diff --git a/internal/egress/env.go b/internal/egress/env.go index 9b423fa89..6e7094810 100644 --- a/internal/egress/env.go +++ b/internal/egress/env.go @@ -15,8 +15,9 @@ package egress const ( - EnvCaptureEnabled = "ATE_EGRESS_CAPTURE_ENABLED" - EnvPEPAddress = "ATE_EGRESS_PEP_ADDRESS" + LabelPEP = "ate.dev/egress-pep" + LabelAtespace = "ate.dev/atespace" + LabelActor = "ate.dev/actor" ) const ( diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index 4f51a5f2f..f631fa6c0 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -199,9 +199,10 @@ type RunRequest struct { // The sandbox binaries to use for booting this actor from scratch. atelet // fetches the relevant assets and records them with the actor's on-node state // so a later Checkpoint can pin the same version into the snapshot manifest. - SandboxAssets *SandboxAssets `protobuf:"bytes,7,opt,name=sandbox_assets,json=sandboxAssets,proto3" json:"sandbox_assets,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + SandboxAssets *SandboxAssets `protobuf:"bytes,7,opt,name=sandbox_assets,json=sandboxAssets,proto3" json:"sandbox_assets,omitempty"` + EgressPepAddress string `protobuf:"bytes,8,opt,name=egress_pep_address,json=egressPepAddress,proto3" json:"egress_pep_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunRequest) Reset() { @@ -283,6 +284,13 @@ func (x *RunRequest) GetSandboxAssets() *SandboxAssets { return nil } +func (x *RunRequest) GetEgressPepAddress() string { + if x != nil { + return x.EgressPepAddress + } + return "" +} + // AssetFile is one content-addressed file atelet fetches for a sandbox runtime // (e.g. the gVisor runsc binary). type AssetFile struct { @@ -1253,9 +1261,10 @@ type RestoreRequest struct { // *RestoreRequest_ExternalConfig Config isRestoreRequest_Config `protobuf_oneof:"config"` // What content to restore from the checkpoint. - Scope SnapshotScope `protobuf:"varint,10,opt,name=scope,proto3,enum=atelet.SnapshotScope" json:"scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Scope SnapshotScope `protobuf:"varint,10,opt,name=scope,proto3,enum=atelet.SnapshotScope" json:"scope,omitempty"` + EgressPepAddress string `protobuf:"bytes,11,opt,name=egress_pep_address,json=egressPepAddress,proto3" json:"egress_pep_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RestoreRequest) Reset() { @@ -1369,6 +1378,13 @@ func (x *RestoreRequest) GetScope() SnapshotScope { return SnapshotScope_SNAPSHOT_SCOPE_UNSPECIFIED } +func (x *RestoreRequest) GetEgressPepAddress() string { + if x != nil { + return x.EgressPepAddress + } + return "" +} + type isRestoreRequest_Config interface { isRestoreRequest_Config() } @@ -1425,7 +1441,7 @@ var File_atelet_proto protoreflect.FileDescriptor const file_atelet_proto_rawDesc = "" + "\n" + - "\fatelet.proto\x12\x06atelet\"\xc3\x02\n" + + "\fatelet.proto\x12\x06atelet\"\xf1\x02\n" + "\n" + "RunRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + @@ -1435,7 +1451,8 @@ const file_atelet_proto_rawDesc = "" + "\x18actor_template_namespace\x18\x04 \x01(\tR\x16actorTemplateNamespace\x12.\n" + "\x13actor_template_name\x18\x05 \x01(\tR\x11actorTemplateName\x12(\n" + "\x04spec\x18\x06 \x01(\v2\x14.atelet.WorkloadSpecR\x04spec\x12<\n" + - "\x0esandbox_assets\x18\a \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\"5\n" + + "\x0esandbox_assets\x18\a \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\x12,\n" + + "\x12egress_pep_address\x18\b \x01(\tR\x10egressPepAddress\"5\n" + "\tAssetFile\x12\x10\n" + "\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n" + "\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x8e\x01\n" + @@ -1504,7 +1521,7 @@ const file_atelet_proto_rawDesc = "" + "\x05scope\x18\n" + " \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scopeB\b\n" + "\x06config\"\x14\n" + - "\x12CheckpointResponse\"\x8b\x04\n" + + "\x12CheckpointResponse\"\xb9\x04\n" + "\x0eRestoreRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + "\batespace\x18\x02 \x01(\tR\batespace\x12\x1d\n" + @@ -1517,7 +1534,8 @@ const file_atelet_proto_rawDesc = "" + "\flocal_config\x18\b \x01(\v2$.atelet.LocalCheckpointConfigurationH\x00R\vlocalConfig\x12R\n" + "\x0fexternal_config\x18\t \x01(\v2'.atelet.ExternalCheckpointConfigurationH\x00R\x0eexternalConfig\x12+\n" + "\x05scope\x18\n" + - " \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scopeB\b\n" + + " \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scope\x12,\n" + + "\x12egress_pep_address\x18\v \x01(\tR\x10egressPepAddressB\b\n" + "\x06config\"\x11\n" + "\x0fRestoreResponse*F\n" + "\n" + diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 238032789..5030c560e 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -47,6 +47,8 @@ message RunRequest { // fetches the relevant assets and records them with the actor's on-node state // so a later Checkpoint can pin the same version into the snapshot manifest. SandboxAssets sandbox_assets = 7; + + string egress_pep_address = 8; } // AssetFile is one content-addressed file atelet fetches for a sandbox runtime @@ -228,6 +230,8 @@ message RestoreRequest { // What content to restore from the checkpoint. SnapshotScope scope = 10; + + string egress_pep_address = 11; } message RestoreResponse { diff --git a/internal/proto/ateompb/ateom.pb.go b/internal/proto/ateompb/ateom.pb.go index 2df3a1926..f1285d192 100644 --- a/internal/proto/ateompb/ateom.pb.go +++ b/internal/proto/ateompb/ateom.pb.go @@ -103,6 +103,7 @@ type RunWorkloadRequest struct { // to the local on-disk path atelet fetched it to (content-addressed, like // runsc_path). Empty for the gVisor runtime, which uses runsc_path. RuntimeAssetPaths map[string]string `protobuf:"bytes,7,rep,name=runtime_asset_paths,json=runtimeAssetPaths,proto3" json:"runtime_asset_paths,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + EgressPepAddress string `protobuf:"bytes,8,opt,name=egress_pep_address,json=egressPepAddress,proto3" json:"egress_pep_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -186,6 +187,13 @@ func (x *RunWorkloadRequest) GetRuntimeAssetPaths() map[string]string { return nil } +func (x *RunWorkloadRequest) GetEgressPepAddress() string { + if x != nil { + return x.EgressPepAddress + } + return "" +} + // WorkloadSpec parallels Pod, but with far fewer configurable fields. type WorkloadSpec struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -392,6 +400,12 @@ func (x *HTTPGetAction) GetPort() int32 { return 0 } +// TODO: Add an EgressCaptureStatus ack (state enum with UNSPECIFIED=0 + +// requested/active PEP address) to RunWorkloadResponse/RestoreWorkloadResponse +// so ate-api persists the PEP the sandbox actually enforces instead of the one +// it requested. The zero-value enum makes pre-egress binaries detectable. +// Today a stale ateom image silently ignores egress_pep_address while actor +// status reports the binding. type RunWorkloadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -608,9 +622,10 @@ type RestoreWorkloadRequest struct { // atelet fetched it to (see RunWorkloadRequest). Empty for gVisor. RuntimeAssetPaths map[string]string `protobuf:"bytes,8,rep,name=runtime_asset_paths,json=runtimeAssetPaths,proto3" json:"runtime_asset_paths,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // What content to restore from the snapshot. - Scope SnapshotScope `protobuf:"varint,9,opt,name=scope,proto3,enum=ateom.SnapshotScope" json:"scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Scope SnapshotScope `protobuf:"varint,9,opt,name=scope,proto3,enum=ateom.SnapshotScope" json:"scope,omitempty"` + EgressPepAddress string `protobuf:"bytes,10,opt,name=egress_pep_address,json=egressPepAddress,proto3" json:"egress_pep_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RestoreWorkloadRequest) Reset() { @@ -706,6 +721,13 @@ func (x *RestoreWorkloadRequest) GetScope() SnapshotScope { return SnapshotScope_SNAPSHOT_SCOPE_UNSPECIFIED } +func (x *RestoreWorkloadRequest) GetEgressPepAddress() string { + if x != nil { + return x.EgressPepAddress + } + return "" +} + type RestoreWorkloadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -746,7 +768,7 @@ var File_ateom_proto protoreflect.FileDescriptor const file_ateom_proto_rawDesc = "" + "\n" + - "\vateom.proto\x12\x05ateom\"\xa9\x03\n" + + "\vateom.proto\x12\x05ateom\"\xd7\x03\n" + "\x12RunWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + @@ -756,7 +778,8 @@ const file_ateom_proto_rawDesc = "" + "\n" + "runsc_path\x18\x05 \x01(\tR\trunscPath\x12'\n" + "\x04spec\x18\x06 \x01(\v2\x13.ateom.WorkloadSpecR\x04spec\x12`\n" + - "\x13runtime_asset_paths\x18\a \x03(\v20.ateom.RunWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x1aD\n" + + "\x13runtime_asset_paths\x18\a \x03(\v20.ateom.RunWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x12,\n" + + "\x12egress_pep_address\x18\b \x01(\tR\x10egressPepAddress\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"@\n" + @@ -790,7 +813,7 @@ const file_ateom_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"C\n" + "\x1aCheckpointWorkloadResponse\x12%\n" + - "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\x8d\x04\n" + + "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\xbb\x04\n" + "\x16RestoreWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + @@ -802,7 +825,9 @@ const file_ateom_proto_rawDesc = "" + "\x04spec\x18\x06 \x01(\v2\x13.ateom.WorkloadSpecR\x04spec\x12.\n" + "\x13snapshot_uri_prefix\x18\a \x01(\tR\x11snapshotUriPrefix\x12d\n" + "\x13runtime_asset_paths\x18\b \x03(\v24.ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x12*\n" + - "\x05scope\x18\t \x01(\x0e2\x14.ateom.SnapshotScopeR\x05scope\x1aD\n" + + "\x05scope\x18\t \x01(\x0e2\x14.ateom.SnapshotScopeR\x05scope\x12,\n" + + "\x12egress_pep_address\x18\n" + + " \x01(\tR\x10egressPepAddress\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x19\n" + diff --git a/internal/proto/ateompb/ateom.proto b/internal/proto/ateompb/ateom.proto index 619ff4f51..7ad85919b 100644 --- a/internal/proto/ateompb/ateom.proto +++ b/internal/proto/ateompb/ateom.proto @@ -63,6 +63,8 @@ message RunWorkloadRequest { // to the local on-disk path atelet fetched it to (content-addressed, like // runsc_path). Empty for the gVisor runtime, which uses runsc_path. map runtime_asset_paths = 7; + + string egress_pep_address = 8; } // WorkloadSpec parallels Pod, but with far fewer configurable fields. @@ -90,6 +92,12 @@ message HTTPGetAction { int32 port = 2; } +// TODO: Add an EgressCaptureStatus ack (state enum with UNSPECIFIED=0 + +// requested/active PEP address) to RunWorkloadResponse/RestoreWorkloadResponse +// so ate-api persists the PEP the sandbox actually enforces instead of the one +// it requested. The zero-value enum makes pre-egress binaries detectable. +// Today a stale ateom image silently ignores egress_pep_address while actor +// status reports the binding. message RunWorkloadResponse { } @@ -161,6 +169,8 @@ message RestoreWorkloadRequest { // What content to restore from the snapshot. SnapshotScope scope = 9; + + string egress_pep_address = 10; } message RestoreWorkloadResponse { diff --git a/manifests/ate-install/ate-api-server.yaml b/manifests/ate-install/ate-api-server.yaml index 0b7b0c0ee..ca609fdd3 100644 --- a/manifests/ate-install/ate-api-server.yaml +++ b/manifests/ate-install/ate-api-server.yaml @@ -28,6 +28,9 @@ rules: - apiGroups: ["ate.dev"] resources: ["workerpools"] verbs: ["get", "watch", "list"] +- apiGroups: ["gateway.networking.k8s.io"] + resources: ["gateways"] + verbs: ["get", "watch", "list"] # Secret reads for env source resolution are intentionally NOT granted # cluster-wide here. Each demo / tenant is responsible for granting # ate-api-server read access only to the specific Secrets referenced by its diff --git a/manifests/ate-install/ate-controller.yaml b/manifests/ate-install/ate-controller.yaml index 1be68126d..65ee46075 100644 --- a/manifests/ate-install/ate-controller.yaml +++ b/manifests/ate-install/ate-controller.yaml @@ -90,10 +90,6 @@ spec: image: ko://github.com/agent-substrate/substrate/cmd/atecontroller args: - --ateapi-ca-file=/run/servicedns-ca/trust-bundle.pem - envFrom: - - configMapRef: - name: ate-egress-capture - optional: true ports: - name: metrics containerPort: 8080 diff --git a/pkg/proto/ateapipb/ateapi.pb.go b/pkg/proto/ateapipb/ateapi.pb.go index 0350f9fd9..dda0bb9d1 100644 --- a/pkg/proto/ateapipb/ateapi.pb.go +++ b/pkg/proto/ateapipb/ateapi.pb.go @@ -446,8 +446,14 @@ type Actor struct { // suspend/pause since eligibility is no longer a single fixed pool // reference on the ActorTemplate. WorkerPoolName string `protobuf:"bytes,12,opt,name=worker_pool_name,json=workerPoolName,proto3" json:"worker_pool_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The egress PEP address ate-api resolved for this actor on its most recent + // resume, in the form ..svc.cluster.local:. Empty + // means no PEP matched and egress capture is off. Set at worker assignment and + // cleared when the worker is released (suspend/pause). Records which PEP an + // actor is using so global-PEP membership is queryable without scanning logs. + EgressPepAddress string `protobuf:"bytes,13,opt,name=egress_pep_address,json=egressPepAddress,proto3" json:"egress_pep_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Actor) Reset() { @@ -564,6 +570,13 @@ func (x *Actor) GetWorkerPoolName() string { return "" } +func (x *Actor) GetEgressPepAddress() string { + if x != nil { + return x.EgressPepAddress + } + return "" +} + // Atespace is the isolation boundary an Actor is created into. Global-scoped: // metadata.atespace is always empty; the atespace's identity is metadata.name. type Atespace struct { @@ -2161,7 +2174,7 @@ const file_ateapi_proto_rawDesc = "" + "\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + "createTime\x12;\n" + "\vupdate_time\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "updateTime\"\x84\x06\n" + + "updateTime\"\xb2\x06\n" + "\x05Actor\x124\n" + "\bmetadata\x18\x01 \x01(\v2\x18.ateapi.ResourceMetadataR\bmetadata\x128\n" + "\x18actor_template_namespace\x18\x02 \x01(\tR\x16actorTemplateNamespace\x12.\n" + @@ -2176,7 +2189,8 @@ const file_ateapi_proto_rawDesc = "" + "\x14latest_snapshot_info\x18\n" + " \x01(\v2\x14.ateapi.SnapshotInfoR\x12latestSnapshotInfo\x129\n" + "\x0fworker_selector\x18\v \x01(\v2\x10.ateapi.SelectorR\x0eworkerSelector\x12(\n" + - "\x10worker_pool_name\x18\f \x01(\tR\x0eworkerPoolName\"\xb1\x01\n" + + "\x10worker_pool_name\x18\f \x01(\tR\x0eworkerPoolName\x12,\n" + + "\x12egress_pep_address\x18\r \x01(\tR\x10egressPepAddress\"\xb1\x01\n" + "\x06Status\x12\x16\n" + "\x12STATUS_UNSPECIFIED\x10\x00\x12\x13\n" + "\x0fSTATUS_RESUMING\x10\x01\x12\x12\n" + diff --git a/pkg/proto/ateapipb/ateapi.proto b/pkg/proto/ateapipb/ateapi.proto index 4c1d0461f..d890426b2 100644 --- a/pkg/proto/ateapipb/ateapi.proto +++ b/pkg/proto/ateapipb/ateapi.proto @@ -157,6 +157,13 @@ message Actor { // suspend/pause since eligibility is no longer a single fixed pool // reference on the ActorTemplate. string worker_pool_name = 12; + + // The egress PEP address ate-api resolved for this actor on its most recent + // resume, in the form ..svc.cluster.local:. Empty + // means no PEP matched and egress capture is off. Set at worker assignment and + // cleared when the worker is released (suspend/pause). Records which PEP an + // actor is using so global-PEP membership is queryable without scanning logs. + string egress_pep_address = 13; } // Atespace is the isolation boundary an Actor is created into. Global-scoped: diff --git a/vendor/github.com/fxamacker/cbor/v2/.golangci.yml b/vendor/github.com/fxamacker/cbor/v2/.golangci.yml index 38cb9ae10..08081fbde 100644 --- a/vendor/github.com/fxamacker/cbor/v2/.golangci.yml +++ b/vendor/github.com/fxamacker/cbor/v2/.golangci.yml @@ -1,104 +1,116 @@ -# Do not delete linter settings. Linters like gocritic can be enabled on the command line. - -linters-settings: - depguard: - rules: - prevent_unmaintained_packages: - list-mode: strict - files: - - $all - - "!$test" - allow: - - $gostd - - github.com/x448/float16 - deny: - - pkg: io/ioutil - desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil" - dupl: - threshold: 100 - funlen: - lines: 100 - statements: 50 - goconst: - ignore-tests: true - min-len: 2 - min-occurrences: 3 - gocritic: - enabled-tags: - - diagnostic - - experimental - - opinionated - - performance - - style - disabled-checks: - - commentedOutCode - - dupImport # https://github.com/go-critic/go-critic/issues/845 - - ifElseChain - - octalLiteral - - paramTypeCombine - - whyNoLint - gofmt: - simplify: false - goimports: - local-prefixes: github.com/fxamacker/cbor - golint: - min-confidence: 0 - govet: - check-shadowing: true - lll: - line-length: 140 - maligned: - suggest-new: true - misspell: - locale: US - staticcheck: - checks: ["all"] - +version: "2" linters: - disable-all: true + default: none enable: - asciicheck - bidichk - depguard - errcheck - - exportloopref + - forbidigo - goconst - gocritic - gocyclo - - gofmt - - goimports - goprintffuncname - gosec - - gosimple - govet - ineffassign - misspell - nilerr - revive - staticcheck - - stylecheck - - typecheck - unconvert - unused - + settings: + depguard: + rules: + prevent_unmaintained_packages: + list-mode: strict + files: + - $all + - '!$test' + allow: + - $gostd + - github.com/x448/float16 + deny: + - pkg: io/ioutil + desc: 'replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil' + dupl: + threshold: 100 + funlen: + lines: 100 + statements: 50 + goconst: + min-len: 2 + min-occurrences: 3 + gocritic: + disabled-checks: + - commentedOutCode + - dupImport + - ifElseChain + - octalLiteral + - paramTypeCombine + - whyNoLint + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + govet: + enable: + - shadow + lll: + line-length: 140 + misspell: + locale: US + staticcheck: + checks: + - all + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - path: decode.go + text: string ` overflows ` has (\d+) occurrences, make it a constant + - path: decode.go + text: string ` \(range is \[` has (\d+) occurrences, make it a constant + - path: decode.go + text: string `, ` has (\d+) occurrences, make it a constant + - path: decode.go + text: string ` overflows Go's int64` has (\d+) occurrences, make it a constant + - path: decode.go + text: string `\]\)` has (\d+) occurrences, make it a constant + - path: valid.go + text: string ` for type ` has (\d+) occurrences, make it a constant + - path: valid.go + text: 'string `cbor: ` has (\d+) occurrences, make it a constant' + - linters: + - goconst + path: (.+)_test\.go + paths: + - third_party$ + - builtin$ + - examples$ issues: - # max-issues-per-linter default is 50. Set to 0 to disable limit. max-issues-per-linter: 0 - # max-same-issues default is 3. Set to 0 to disable limit. max-same-issues: 0 - - exclude-rules: - - path: decode.go - text: "string ` overflows ` has (\\d+) occurrences, make it a constant" - - path: decode.go - text: "string ` \\(range is \\[` has (\\d+) occurrences, make it a constant" - - path: decode.go - text: "string `, ` has (\\d+) occurrences, make it a constant" - - path: decode.go - text: "string ` overflows Go's int64` has (\\d+) occurrences, make it a constant" - - path: decode.go - text: "string `\\]\\)` has (\\d+) occurrences, make it a constant" - - path: valid.go - text: "string ` for type ` has (\\d+) occurrences, make it a constant" - - path: valid.go - text: "string `cbor: ` has (\\d+) occurrences, make it a constant" +formatters: + enable: + - gofmt + - goimports + settings: + gofmt: + simplify: false + goimports: + local-prefixes: + - github.com/fxamacker/cbor + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/vendor/github.com/fxamacker/cbor/v2/README.md b/vendor/github.com/fxamacker/cbor/v2/README.md index d072b81c7..f9ae78ec9 100644 --- a/vendor/github.com/fxamacker/cbor/v2/README.md +++ b/vendor/github.com/fxamacker/cbor/v2/README.md @@ -702,21 +702,20 @@ Default limits may need to be increased for systems handling very large data (e. ## Status -[v2.9.0](https://github.com/fxamacker/cbor/releases/tag/v2.9.0) (Jul 13, 2025) improved interoperability/transcoding between CBOR & JSON, refactored tests, and improved docs. -- Add opt-in support for `encoding.TextMarshaler` and `encoding.TextUnmarshaler` to encode and decode from CBOR text string. -- Add opt-in support for `json.Marshaler` and `json.Unmarshaler` via user-provided transcoding function. -- Update docs for TimeMode, Tag, RawTag, and add example for Embedded JSON Tag for CBOR. +v2.9.1 (Mar 29-30, 2026) includes important bugfixes, defensive checks, improved code quality, and more tests. Although not public, the fuzzer was also improved by adding more fuzz tests. -v2.9.0 passed fuzz tests and is production quality. +v2.9.1 passed fuzz tests and is production quality. The minimum version of Go required to build: - v2.8.0 and newer releases require go 1.20+. - v2.7.1 and older releases require go 1.17+. -For more details, see [release notes](https://github.com/fxamacker/cbor/releases). +For more details, see [v2.9.1 release notes](https://github.com/fxamacker/cbor/releases). ### Prior Releases +[v2.9.0](https://github.com/fxamacker/cbor/releases/tag/v2.9.0) (Jul 13, 2025) improved interoperability/transcoding between CBOR & JSON, refactored tests, and improved docs. It passed fuzz tests (billions of executions) and is production quality. + [v2.8.0](https://github.com/fxamacker/cbor/releases/tag/v2.8.0) (March 30, 2025) is a small release primarily to add `omitzero` option to struct field tags and fix bugs. It passed fuzz tests (billions of executions) and is production quality. [v2.7.0](https://github.com/fxamacker/cbor/releases/tag/v2.7.0) (June 23, 2024) adds features and improvements that help large projects (e.g. Kubernetes) use CBOR as an alternative to JSON and Protocol Buffers. Other improvements include speedups, improved memory use, bug fixes, new serialization options, etc. It passed fuzz tests (5+ billion executions) and is production quality. diff --git a/vendor/github.com/fxamacker/cbor/v2/cache.go b/vendor/github.com/fxamacker/cbor/v2/cache.go index 5051f110f..5743f3eb2 100644 --- a/vendor/github.com/fxamacker/cbor/v2/cache.go +++ b/vendor/github.com/fxamacker/cbor/v2/cache.go @@ -92,94 +92,126 @@ func newTypeInfo(t reflect.Type) *typeInfo { } type decodingStructType struct { - fields fields - fieldIndicesByName map[string]int - err error - toArray bool + fields decodingFields + fieldIndicesByName map[string]int // Only populated if toArray is false + fieldIndicesByIntKey map[int64]int // Only populated if toArray is false + err error + toArray bool } -// The stdlib errors.Join was introduced in Go 1.20, and we still support Go 1.17, so instead, -// here's a very basic implementation of an aggregated error. -type multierror []error - -func (m multierror) Error() string { - var sb strings.Builder - for i, err := range m { - sb.WriteString(err.Error()) - if i < len(m)-1 { - sb.WriteString(", ") - } - } - return sb.String() -} - -func getDecodingStructType(t reflect.Type) *decodingStructType { +func getDecodingStructType(t reflect.Type) (*decodingStructType, error) { if v, _ := decodingStructTypeCache.Load(t); v != nil { - return v.(*decodingStructType) + structType := v.(*decodingStructType) + if structType.err != nil { + return nil, structType.err + } + return structType, nil } flds, structOptions := getFields(t) toArray := hasToArrayOption(structOptions) - var errs []error - for i := 0; i < len(flds); i++ { - if flds[i].keyAsInt { - nameAsInt, numErr := strconv.Atoi(flds[i].name) - if numErr != nil { - errs = append(errs, errors.New("cbor: failed to parse field name \""+flds[i].name+"\" to int ("+numErr.Error()+")")) - break + if toArray { + return getDecodingStructToArrayType(t, flds) + } + + fieldIndicesByName := make(map[string]int, len(flds)) + var fieldIndicesByIntKey map[int64]int + + decFlds := make(decodingFields, len(flds)) + for i, f := range flds { + // nameAsInt is set in getFields() except for fields with an unparsable tagged name. + // Atoi() is called here to catch and save parsing errors. + if f.keyAsInt && f.nameAsInt == 0 { + if _, numErr := strconv.Atoi(f.name); numErr != nil { + structType := &decodingStructType{ + err: errors.New("cbor: failed to parse field name \"" + f.name + "\" to int (" + numErr.Error() + ")"), + } + decodingStructTypeCache.Store(t, structType) + return nil, structType.err } - flds[i].nameAsInt = int64(nameAsInt) } - flds[i].typInfo = getTypeInfo(flds[i].typ) - } + if f.keyAsInt { + if fieldIndicesByIntKey == nil { + fieldIndicesByIntKey = make(map[int64]int, len(flds)) + } + // The duplication check is only a safeguard, since getFields() already deduplicates fields. + if _, ok := fieldIndicesByIntKey[f.nameAsInt]; ok { + structType := &decodingStructType{ + err: fmt.Errorf("cbor: two or more fields of %v have the same keyasint value %d", t, f.nameAsInt), + } + decodingStructTypeCache.Store(t, structType) + return nil, structType.err + } + fieldIndicesByIntKey[f.nameAsInt] = i + } else { + // The duplication check is only a safeguard, since getFields() already deduplicates fields. + if _, ok := fieldIndicesByName[f.name]; ok { + structType := &decodingStructType{ + err: fmt.Errorf("cbor: two or more fields of %v have the same name %q", t, f.name), + } + decodingStructTypeCache.Store(t, structType) + return nil, structType.err + } + fieldIndicesByName[f.name] = i + } - fieldIndicesByName := make(map[string]int, len(flds)) - for i, fld := range flds { - if _, ok := fieldIndicesByName[fld.name]; ok { - errs = append(errs, fmt.Errorf("cbor: two or more fields of %v have the same name %q", t, fld.name)) - continue + decFlds[i] = &decodingField{ + field: *f, + typInfo: getTypeInfo(f.typ), } - fieldIndicesByName[fld.name] = i } - var err error - { - var multi multierror - for _, each := range errs { - if each != nil { - multi = append(multi, each) + structType := &decodingStructType{ + fields: decFlds, + fieldIndicesByName: fieldIndicesByName, + fieldIndicesByIntKey: fieldIndicesByIntKey, + } + decodingStructTypeCache.Store(t, structType) + return structType, nil +} + +func getDecodingStructToArrayType(t reflect.Type, flds fields) (*decodingStructType, error) { + decFlds := make(decodingFields, len(flds)) + for i, f := range flds { + // nameAsInt is set in getFields() except for fields with an unparsable tagged name. + // Atoi() is called here to catch and save parsing errors. + if f.keyAsInt && f.nameAsInt == 0 { + if _, numErr := strconv.Atoi(f.name); numErr != nil { + structType := &decodingStructType{ + err: errors.New("cbor: failed to parse field name \"" + f.name + "\" to int (" + numErr.Error() + ")"), + } + decodingStructTypeCache.Store(t, structType) + return nil, structType.err } } - if len(multi) == 1 { - err = multi[0] - } else if len(multi) > 1 { - err = multi + + decFlds[i] = &decodingField{ + field: *f, + typInfo: getTypeInfo(f.typ), } } structType := &decodingStructType{ - fields: flds, - fieldIndicesByName: fieldIndicesByName, - err: err, - toArray: toArray, + fields: decFlds, + toArray: true, } decodingStructTypeCache.Store(t, structType) - return structType + return structType, nil } type encodingStructType struct { - fields fields - bytewiseFields fields - lengthFirstFields fields - omitEmptyFieldsIdx []int + fields encodingFields + bytewiseFields encodingFields // Only populated if toArray is false + lengthFirstFields encodingFields // Only populated if toArray is false + omitEmptyFieldsIdx []int // Only populated if toArray is false err error toArray bool } -func (st *encodingStructType) getFields(em *encMode) fields { +func (st *encodingStructType) getFields(em *encMode) encodingFields { switch em.sort { case SortNone, SortFastShuffle: return st.fields @@ -191,7 +223,7 @@ func (st *encodingStructType) getFields(em *encMode) fields { } type bytewiseFieldSorter struct { - fields fields + fields encodingFields } func (x *bytewiseFieldSorter) Len() int { @@ -203,11 +235,11 @@ func (x *bytewiseFieldSorter) Swap(i, j int) { } func (x *bytewiseFieldSorter) Less(i, j int) bool { - return bytes.Compare(x.fields[i].cborName, x.fields[j].cborName) <= 0 + return bytes.Compare(x.fields[i].cborName, x.fields[j].cborName) < 0 } type lengthFirstFieldSorter struct { - fields fields + fields encodingFields } func (x *lengthFirstFieldSorter) Len() int { @@ -222,13 +254,16 @@ func (x *lengthFirstFieldSorter) Less(i, j int) bool { if len(x.fields[i].cborName) != len(x.fields[j].cborName) { return len(x.fields[i].cborName) < len(x.fields[j].cborName) } - return bytes.Compare(x.fields[i].cborName, x.fields[j].cborName) <= 0 + return bytes.Compare(x.fields[i].cborName, x.fields[j].cborName) < 0 } func getEncodingStructType(t reflect.Type) (*encodingStructType, error) { if v, _ := encodingStructTypeCache.Load(t); v != nil { structType := v.(*encodingStructType) - return structType, structType.err + if structType.err != nil { + return nil, structType.err + } + return structType, nil } flds, structOptions := getFields(t) @@ -237,111 +272,119 @@ func getEncodingStructType(t reflect.Type) (*encodingStructType, error) { return getEncodingStructToArrayType(t, flds) } - var err error var hasKeyAsInt bool var hasKeyAsStr bool var omitEmptyIdx []int + + encFlds := make(encodingFields, len(flds)) + e := getEncodeBuffer() - for i := 0; i < len(flds); i++ { + defer putEncodeBuffer(e) + + for i, f := range flds { + encFlds[i] = &encodingField{field: *f} + ef := encFlds[i] + // Get field's encodeFunc - flds[i].ef, flds[i].ief, flds[i].izf = getEncodeFunc(flds[i].typ) - if flds[i].ef == nil { - err = &UnsupportedTypeError{t} - break + ef.ef, ef.ief, ef.izf = getEncodeFunc(f.typ) + if ef.ef == nil { + structType := &encodingStructType{err: &UnsupportedTypeError{t}} + encodingStructTypeCache.Store(t, structType) + return nil, structType.err } // Encode field name - if flds[i].keyAsInt { - nameAsInt, numErr := strconv.Atoi(flds[i].name) - if numErr != nil { - err = errors.New("cbor: failed to parse field name \"" + flds[i].name + "\" to int (" + numErr.Error() + ")") - break + if f.keyAsInt { + if f.nameAsInt == 0 { + // nameAsInt is set in getFields() except for fields with an unparsable tagged name. + // Atoi() is called here to catch and save parsing errors. + if _, numErr := strconv.Atoi(f.name); numErr != nil { + structType := &encodingStructType{ + err: errors.New("cbor: failed to parse field name \"" + f.name + "\" to int (" + numErr.Error() + ")"), + } + encodingStructTypeCache.Store(t, structType) + return nil, structType.err + } } - flds[i].nameAsInt = int64(nameAsInt) + nameAsInt := f.nameAsInt if nameAsInt >= 0 { - encodeHead(e, byte(cborTypePositiveInt), uint64(nameAsInt)) + encodeHead(e, byte(cborTypePositiveInt), uint64(nameAsInt)) //nolint:gosec } else { n := nameAsInt*(-1) - 1 - encodeHead(e, byte(cborTypeNegativeInt), uint64(n)) + encodeHead(e, byte(cborTypeNegativeInt), uint64(n)) //nolint:gosec } - flds[i].cborName = make([]byte, e.Len()) - copy(flds[i].cborName, e.Bytes()) + ef.cborName = make([]byte, e.Len()) + copy(ef.cborName, e.Bytes()) e.Reset() hasKeyAsInt = true } else { - encodeHead(e, byte(cborTypeTextString), uint64(len(flds[i].name))) - flds[i].cborName = make([]byte, e.Len()+len(flds[i].name)) - n := copy(flds[i].cborName, e.Bytes()) - copy(flds[i].cborName[n:], flds[i].name) + encodeHead(e, byte(cborTypeTextString), uint64(len(f.name))) + ef.cborName = make([]byte, e.Len()+len(f.name)) + n := copy(ef.cborName, e.Bytes()) + copy(ef.cborName[n:], f.name) e.Reset() // If cborName contains a text string, then cborNameByteString contains a // string that has the byte string major type but is otherwise identical to // cborName. - flds[i].cborNameByteString = make([]byte, len(flds[i].cborName)) - copy(flds[i].cborNameByteString, flds[i].cborName) + ef.cborNameByteString = make([]byte, len(ef.cborName)) + copy(ef.cborNameByteString, ef.cborName) // Reset encoded CBOR type to byte string, preserving the "additional // information" bits: - flds[i].cborNameByteString[0] = byte(cborTypeByteString) | - getAdditionalInformation(flds[i].cborNameByteString[0]) + ef.cborNameByteString[0] = byte(cborTypeByteString) | + getAdditionalInformation(ef.cborNameByteString[0]) hasKeyAsStr = true } // Check if field can be omitted when empty - if flds[i].omitEmpty { + if f.omitEmpty { omitEmptyIdx = append(omitEmptyIdx, i) } } - putEncodeBuffer(e) - - if err != nil { - structType := &encodingStructType{err: err} - encodingStructTypeCache.Store(t, structType) - return structType, structType.err - } // Sort fields by canonical order - bytewiseFields := make(fields, len(flds)) - copy(bytewiseFields, flds) + bytewiseFields := make(encodingFields, len(encFlds)) + copy(bytewiseFields, encFlds) sort.Sort(&bytewiseFieldSorter{bytewiseFields}) lengthFirstFields := bytewiseFields if hasKeyAsInt && hasKeyAsStr { - lengthFirstFields = make(fields, len(flds)) - copy(lengthFirstFields, flds) + lengthFirstFields = make(encodingFields, len(encFlds)) + copy(lengthFirstFields, encFlds) sort.Sort(&lengthFirstFieldSorter{lengthFirstFields}) } structType := &encodingStructType{ - fields: flds, + fields: encFlds, bytewiseFields: bytewiseFields, lengthFirstFields: lengthFirstFields, omitEmptyFieldsIdx: omitEmptyIdx, } encodingStructTypeCache.Store(t, structType) - return structType, structType.err + return structType, nil } func getEncodingStructToArrayType(t reflect.Type, flds fields) (*encodingStructType, error) { - for i := 0; i < len(flds); i++ { - // Get field's encodeFunc - flds[i].ef, flds[i].ief, flds[i].izf = getEncodeFunc(flds[i].typ) - if flds[i].ef == nil { + encFlds := make(encodingFields, len(flds)) + for i, f := range flds { + encFlds[i] = &encodingField{field: *f} + encFlds[i].ef, encFlds[i].ief, encFlds[i].izf = getEncodeFunc(f.typ) + if encFlds[i].ef == nil { structType := &encodingStructType{err: &UnsupportedTypeError{t}} encodingStructTypeCache.Store(t, structType) - return structType, structType.err + return nil, structType.err } } structType := &encodingStructType{ - fields: flds, + fields: encFlds, toArray: true, } encodingStructTypeCache.Store(t, structType) - return structType, structType.err + return structType, nil } func getEncodeFunc(t reflect.Type) (encodeFunc, isEmptyFunc, isZeroFunc) { diff --git a/vendor/github.com/fxamacker/cbor/v2/decode.go b/vendor/github.com/fxamacker/cbor/v2/decode.go index f0bdc3b38..03fd7f8b0 100644 --- a/vendor/github.com/fxamacker/cbor/v2/decode.go +++ b/vendor/github.com/fxamacker/cbor/v2/decode.go @@ -16,7 +16,6 @@ import ( "math/big" "reflect" "strconv" - "strings" "time" "unicode/utf8" @@ -326,14 +325,14 @@ func (dmkm DupMapKeyMode) valid() bool { return dmkm >= 0 && dmkm < maxDupMapKeyMode } -// IndefLengthMode specifies whether to allow indefinite length items. +// IndefLengthMode specifies whether to allow indefinite-length items. type IndefLengthMode int const ( - // IndefLengthAllowed allows indefinite length items. + // IndefLengthAllowed allows indefinite-length items. IndefLengthAllowed IndefLengthMode = iota - // IndefLengthForbidden disallows indefinite length items. + // IndefLengthForbidden disallows indefinite-length items. IndefLengthForbidden maxIndefLengthMode @@ -378,6 +377,7 @@ const ( // - int64 if value fits // - big.Int or *big.Int (see BigIntDecMode) if value < math.MinInt64 // - return UnmarshalTypeError if value > math.MaxInt64 + // // Deprecated: IntDecConvertSigned should not be used. // Please use other options, such as IntDecConvertSignedOrError, IntDecConvertSignedOrBigInt, IntDecConvertNone. IntDecConvertSigned @@ -811,7 +811,7 @@ type DecOptions struct { // Default is 128*1024=131072 and it can be set to [16, 2147483647] MaxMapPairs int - // IndefLength specifies whether to allow indefinite length CBOR items. + // IndefLength specifies whether to allow indefinite-length CBOR items. IndefLength IndefLengthMode // TagsMd specifies whether to allow CBOR tags (major type 6). @@ -1055,7 +1055,7 @@ func (opts DecOptions) decMode() (*decMode, error) { //nolint:gocritic // ignore } if !opts.ExtraReturnErrors.valid() { - return nil, errors.New("cbor: invalid ExtraReturnErrors " + strconv.Itoa(int(opts.ExtraReturnErrors))) + return nil, errors.New("cbor: invalid ExtraReturnErrors " + strconv.Itoa(int(opts.ExtraReturnErrors))) //nolint:gosec } if opts.DefaultMapType != nil && opts.DefaultMapType.Kind() != reflect.Map { @@ -1149,8 +1149,8 @@ func (opts DecOptions) decMode() (*decMode, error) { //nolint:gocritic // ignore unrecognizedTagToAny: opts.UnrecognizedTagToAny, timeTagToAny: opts.TimeTagToAny, simpleValues: simpleValues, - nanDec: opts.NaN, - infDec: opts.Inf, + nan: opts.NaN, + inf: opts.Inf, byteStringToTime: opts.ByteStringToTime, byteStringExpectedFormat: opts.ByteStringExpectedFormat, bignumTag: opts.BignumTag, @@ -1230,8 +1230,8 @@ type decMode struct { unrecognizedTagToAny UnrecognizedTagToAnyMode timeTagToAny TimeTagToAnyMode simpleValues *SimpleValueRegistry - nanDec NaNMode - infDec InfMode + nan NaNMode + inf InfMode byteStringToTime ByteStringToTimeMode byteStringExpectedFormat ByteStringExpectedFormatMode bignumTag BignumTagMode @@ -1272,8 +1272,8 @@ func (dm *decMode) DecOptions() DecOptions { UnrecognizedTagToAny: dm.unrecognizedTagToAny, TimeTagToAny: dm.timeTagToAny, SimpleValues: simpleValues, - NaN: dm.nanDec, - Inf: dm.infDec, + NaN: dm.nan, + Inf: dm.inf, ByteStringToTime: dm.byteStringToTime, ByteStringExpectedFormat: dm.byteStringExpectedFormat, BignumTag: dm.bignumTag, @@ -1583,11 +1583,11 @@ func (d *decoder) parseToValue(v reflect.Value, tInfo *typeInfo) error { //nolin _, ai, val := d.getHead() switch ai { case additionalInformationAsFloat16: - f := float64(float16.Frombits(uint16(val)).Float32()) + f := float64(float16.Frombits(uint16(val)).Float32()) //nolint:gosec return fillFloat(t, f, v) case additionalInformationAsFloat32: - f := float64(math.Float32frombits(uint32(val))) + f := float64(math.Float32frombits(uint32(val))) //nolint:gosec return fillFloat(t, f, v) case additionalInformationAsFloat64: @@ -1595,10 +1595,10 @@ func (d *decoder) parseToValue(v reflect.Value, tInfo *typeInfo) error { //nolin return fillFloat(t, f, v) default: // ai <= 24 - if d.dm.simpleValues.rejected[SimpleValue(val)] { + if d.dm.simpleValues.rejected[SimpleValue(val)] { //nolint:gosec return &UnacceptableDataItemError{ CBORType: t.String(), - Message: "simple value " + strconv.FormatInt(int64(val), 10) + " is not recognized", + Message: "simple value " + strconv.FormatInt(int64(val), 10) + " is not recognized", //nolint:gosec } } @@ -1677,20 +1677,23 @@ func (d *decoder) parseToValue(v reflect.Value, tInfo *typeInfo) error { //nolin return d.parseToValue(v, tInfo) case cborTypeArray: - if tInfo.nonPtrKind == reflect.Slice { + switch tInfo.nonPtrKind { + case reflect.Slice: return d.parseArrayToSlice(v, tInfo) - } else if tInfo.nonPtrKind == reflect.Array { + case reflect.Array: return d.parseArrayToArray(v, tInfo) - } else if tInfo.nonPtrKind == reflect.Struct { + case reflect.Struct: return d.parseArrayToStruct(v, tInfo) } + d.skip() return &UnmarshalTypeError{CBORType: t.String(), GoType: tInfo.nonPtrType.String()} case cborTypeMap: - if tInfo.nonPtrKind == reflect.Struct { + switch tInfo.nonPtrKind { + case reflect.Struct: return d.parseMapToStruct(v, tInfo) - } else if tInfo.nonPtrKind == reflect.Map { + case reflect.Map: return d.parseMapToMap(v, tInfo) } d.skip() @@ -1745,8 +1748,8 @@ func (d *decoder) parseToTime() (time.Time, bool, error) { // Read tag number _, _, tagNum := d.getHead() if tagNum != 0 && tagNum != 1 { - d.skip() // skip tag content - return time.Time{}, false, errors.New("cbor: wrong tag number for time.Time, got " + strconv.Itoa(int(tagNum)) + ", expect 0 or 1") + d.skip() // skip tag content + return time.Time{}, false, errors.New("cbor: wrong tag number for time.Time, got " + strconv.Itoa(int(tagNum)) + ", expect 0 or 1") //nolint:gosec } } } else { @@ -1815,10 +1818,10 @@ func (d *decoder) parseToTime() (time.Time, bool, error) { var f float64 switch ai { case additionalInformationAsFloat16: - f = float64(float16.Frombits(uint16(val)).Float32()) + f = float64(float16.Frombits(uint16(val)).Float32()) //nolint:gosec case additionalInformationAsFloat32: - f = float64(math.Float32frombits(uint32(val))) + f = float64(math.Float32frombits(uint32(val))) //nolint:gosec case additionalInformationAsFloat64: f = math.Float64frombits(val) @@ -1832,6 +1835,13 @@ func (d *decoder) parseToTime() (time.Time, bool, error) { return time.Time{}, true, nil } seconds, fractional := math.Modf(f) + if seconds > math.MaxInt64 || seconds < math.MinInt64 { + return time.Time{}, false, &UnmarshalTypeError{ + CBORType: t.String(), + GoType: typeTime.String(), + errorMsg: fmt.Sprintf("%v overflows Go's int64", f), + } + } return time.Unix(int64(seconds), int64(fractional*1e9)), true, nil default: @@ -2145,14 +2155,14 @@ func (d *decoder) parse(skipSelfDescribedTag bool) (any, error) { //nolint:gocyc case cborTypePrimitives: _, ai, val := d.getHead() - if ai <= 24 && d.dm.simpleValues.rejected[SimpleValue(val)] { + if ai <= 24 && d.dm.simpleValues.rejected[SimpleValue(val)] { //nolint:gosec return nil, &UnacceptableDataItemError{ CBORType: t.String(), - Message: "simple value " + strconv.FormatInt(int64(val), 10) + " is not recognized", + Message: "simple value " + strconv.FormatInt(int64(val), 10) + " is not recognized", //nolint:gosec } } if ai < 20 || ai == 24 { - return SimpleValue(val), nil + return SimpleValue(val), nil //nolint:gosec } switch ai { @@ -2165,11 +2175,11 @@ func (d *decoder) parse(skipSelfDescribedTag bool) (any, error) { //nolint:gocyc return nil, nil case additionalInformationAsFloat16: - f := float64(float16.Frombits(uint16(val)).Float32()) + f := float64(float16.Frombits(uint16(val)).Float32()) //nolint:gosec return f, nil case additionalInformationAsFloat32: - f := float64(math.Float32frombits(uint32(val))) + f := float64(math.Float32frombits(uint32(val))) //nolint:gosec return f, nil case additionalInformationAsFloat64: @@ -2202,16 +2212,16 @@ func (d *decoder) parse(skipSelfDescribedTag bool) (any, error) { //nolint:gocyc func (d *decoder) parseByteString() ([]byte, bool) { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() if !indefiniteLength { - b := d.data[d.off : d.off+int(val)] - d.off += int(val) + b := d.data[d.off : d.off+int(val)] //nolint:gosec + d.off += int(val) //nolint:gosec return b, false } - // Process indefinite length string chunks. + // Process indefinite-length string chunks. b := []byte{} for !d.foundBreak() { _, _, val = d.getHead() - b = append(b, d.data[d.off:d.off+int(val)]...) - d.off += int(val) + b = append(b, d.data[d.off:d.off+int(val)]...) //nolint:gosec + d.off += int(val) //nolint:gosec } return b, true } @@ -2300,19 +2310,19 @@ func (d *decoder) applyByteStringTextConversion( func (d *decoder) parseTextString() ([]byte, error) { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() if !indefiniteLength { - b := d.data[d.off : d.off+int(val)] - d.off += int(val) + b := d.data[d.off : d.off+int(val)] //nolint:gosec + d.off += int(val) //nolint:gosec if d.dm.utf8 == UTF8RejectInvalid && !utf8.Valid(b) { return nil, &SemanticError{"cbor: invalid UTF-8 string"} } return b, nil } - // Process indefinite length string chunks. + // Process indefinite-length string chunks. b := []byte{} for !d.foundBreak() { _, _, val = d.getHead() - x := d.data[d.off : d.off+int(val)] - d.off += int(val) + x := d.data[d.off : d.off+int(val)] //nolint:gosec + d.off += int(val) //nolint:gosec if d.dm.utf8 == UTF8RejectInvalid && !utf8.Valid(x) { for !d.foundBreak() { d.skip() // Skip remaining chunk on error @@ -2327,7 +2337,7 @@ func (d *decoder) parseTextString() ([]byte, error) { func (d *decoder) parseArray() ([]any, error) { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) + count := int(val) //nolint:gosec if !hasSize { count = d.numOfItemsUntilBreak() // peek ahead to get array size to preallocate slice for better performance } @@ -2349,7 +2359,7 @@ func (d *decoder) parseArray() ([]any, error) { func (d *decoder) parseArrayToSlice(v reflect.Value, tInfo *typeInfo) error { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) + count := int(val) //nolint:gosec if !hasSize { count = d.numOfItemsUntilBreak() // peek ahead to get array size to preallocate slice for better performance } @@ -2371,7 +2381,7 @@ func (d *decoder) parseArrayToSlice(v reflect.Value, tInfo *typeInfo) error { func (d *decoder) parseArrayToArray(v reflect.Value, tInfo *typeInfo) error { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) + count := int(val) //nolint:gosec gi := 0 vLen := v.Len() var err error @@ -2400,7 +2410,7 @@ func (d *decoder) parseArrayToArray(v reflect.Value, tInfo *typeInfo) error { func (d *decoder) parseMap() (any, error) { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) + count := int(val) //nolint:gosec m := make(map[any]any) var k, e any var err, lastErr error @@ -2465,7 +2475,7 @@ func (d *decoder) parseMap() (any, error) { func (d *decoder) parseMapToMap(v reflect.Value, tInfo *typeInfo) error { //nolint:gocyclo _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) + count := int(val) //nolint:gosec if v.IsNil() { mapsize := count if !hasSize { @@ -2566,9 +2576,9 @@ func (d *decoder) parseMapToMap(v reflect.Value, tInfo *typeInfo) error { //noli } func (d *decoder) parseArrayToStruct(v reflect.Value, tInfo *typeInfo) error { - structType := getDecodingStructType(tInfo.nonPtrType) - if structType.err != nil { - return structType.err + structType, structTypeErr := getDecodingStructType(tInfo.nonPtrType) + if structTypeErr != nil { + return structTypeErr } if !structType.toArray { @@ -2584,7 +2594,7 @@ func (d *decoder) parseArrayToStruct(v reflect.Value, tInfo *typeInfo) error { start := d.off _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) + count := int(val) //nolint:gosec if !hasSize { count = d.numOfItemsUntilBreak() // peek ahead to get array size } @@ -2637,11 +2647,72 @@ func (d *decoder) parseArrayToStruct(v reflect.Value, tInfo *typeInfo) error { return err } -// parseMapToStruct needs to be fast so gocyclo can be ignored for now. +// skipMapEntriesFromIndex skips remaining map entries starting from index i. +func (d *decoder) skipMapEntriesFromIndex(i, count int, hasSize bool) { + for ; (hasSize && i < count) || (!hasSize && !d.foundBreak()); i++ { + d.skip() + d.skip() + } +} + +// skipMapForDupKey skips the current map value and all remaining map entries, +// then returns a DupMapKeyError for the given key at map index i. +func (d *decoder) skipMapForDupKey(dupKey any, i, count int, hasSize bool) error { + // Skip the value of the duplicate key. + d.skip() + // Skip all remaining map entries. + d.skipMapEntriesFromIndex(i+1, count, hasSize) + return &DupMapKeyError{dupKey, i} +} + +// skipMapForUnknownField skips the current map value and all remaining map entries, +// then returns a UnknownFieldError for the given key at map index i. +func (d *decoder) skipMapForUnknownField(i, count int, hasSize bool) error { + // Skip the value of the unknown key. + d.skip() + // Skip all remaining map entries. + d.skipMapEntriesFromIndex(i+1, count, hasSize) + return &UnknownFieldError{i} +} + +// decodeToStructField decodes the next CBOR value into the struct field f in v. +// If the field cannot be resolved, the CBOR value is skipped. +func (d *decoder) decodeToStructField(v reflect.Value, f *decodingField, tInfo *typeInfo) error { + var fv reflect.Value + + if len(f.idx) == 1 { + fv = v.Field(f.idx[0]) + } else { + var err error + fv, err = getFieldValue(v, f.idx, func(v reflect.Value) (reflect.Value, error) { + // Return a new value for embedded field null pointer to point to, or return error. + if !v.CanSet() { + return reflect.Value{}, errors.New("cbor: cannot set embedded pointer to unexported struct: " + v.Type().String()) + } + v.Set(reflect.New(v.Type().Elem())) + return v, nil + }) + if !fv.IsValid() { + d.skip() + return err + } + } + + err := d.parseToValue(fv, f.typInfo) + if err != nil { + if typeError, ok := err.(*UnmarshalTypeError); ok { + typeError.StructFieldName = tInfo.nonPtrType.String() + "." + f.name + } + return err + } + + return nil +} + func (d *decoder) parseMapToStruct(v reflect.Value, tInfo *typeInfo) error { //nolint:gocyclo - structType := getDecodingStructType(tInfo.nonPtrType) - if structType.err != nil { - return structType.err + structType, structTypeErr := getDecodingStructType(tInfo.nonPtrType) + if structTypeErr != nil { + return structTypeErr } if structType.toArray { @@ -2654,14 +2725,12 @@ func (d *decoder) parseMapToStruct(v reflect.Value, tInfo *typeInfo) error { //n } } - var err, lastErr error - // Get CBOR map size _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) + count := int(val) //nolint:gosec - // Keeps track of matched struct fields + // Keep track of matched struct fields to detect duplicate map keys. var foundFldIdx []bool { const maxStackFields = 128 @@ -2675,99 +2744,80 @@ func (d *decoder) parseMapToStruct(v reflect.Value, tInfo *typeInfo) error { //n } } - // Keeps track of CBOR map keys to detect duplicate map key - keyCount := 0 - var mapKeys map[any]struct{} - - errOnUnknownField := (d.dm.extraReturnErrors & ExtraDecErrorUnknownField) > 0 + // Keep track of unmatched CBOR map keys to detect duplicate map keys. + var unmatchedMapKeys map[any]struct{} -MapEntryLoop: - for j := 0; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { - var f *field + var err error - // If duplicate field detection is enabled and the key at index j did not match any - // field, k will hold the map key. - var k any + caseInsensitive := d.dm.fieldNameMatching == FieldNameMatchingPreferCaseSensitive + for i := 0; (hasSize && i < count) || (!hasSize && !d.foundBreak()); i++ { t := d.nextCBORType() - if t == cborTypeTextString || (t == cborTypeByteString && d.dm.fieldNameByteString == FieldNameByteStringAllowed) { + + // Reclassify disallowed byte string keys so they fall to the default case. + // keyType is only used for branch control. + keyType := t + if t == cborTypeByteString && d.dm.fieldNameByteString != FieldNameByteStringAllowed { + keyType = 0xff + } + + switch keyType { + case cborTypeTextString, cborTypeByteString: var keyBytes []byte if t == cborTypeTextString { - keyBytes, lastErr = d.parseTextString() - if lastErr != nil { + var parseErr error + keyBytes, parseErr = d.parseTextString() + if parseErr != nil { if err == nil { - err = lastErr + err = parseErr } - d.skip() // skip value + d.skip() // Skip value continue } } else { // cborTypeByteString keyBytes, _ = d.parseByteString() } - // Check for exact match on field name. - if i, ok := structType.fieldIndicesByName[string(keyBytes)]; ok { - fld := structType.fields[i] + // Find matching struct field (exact match, then case-insensitive fallback). + if fldIdx, ok := findStructFieldByKey(structType, keyBytes, caseInsensitive); ok { + fld := structType.fields[fldIdx] - if !foundFldIdx[i] { - f = fld - foundFldIdx[i] = true - } else if d.dm.dupMapKey == DupMapKeyEnforcedAPF { - err = &DupMapKeyError{fld.name, j} - d.skip() // skip value - j++ - // skip the rest of the map - for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { - d.skip() - d.skip() + switch checkDupField(d.dm, foundFldIdx, fldIdx) { + case mapActionParseValueAndContinue: + if fieldErr := d.decodeToStructField(v, fld, tInfo); fieldErr != nil && err == nil { + err = fieldErr } - return err - } else { - // discard repeated match + continue + case mapActionSkipAllAndReturnError: + return d.skipMapForDupKey(string(keyBytes), i, count, hasSize) + case mapActionSkipValueAndContinue: d.skip() - continue MapEntryLoop + continue } } - // Find field with case-insensitive match - if f == nil && d.dm.fieldNameMatching == FieldNameMatchingPreferCaseSensitive { - keyLen := len(keyBytes) - keyString := string(keyBytes) - for i := 0; i < len(structType.fields); i++ { - fld := structType.fields[i] - if len(fld.name) == keyLen && strings.EqualFold(fld.name, keyString) { - if !foundFldIdx[i] { - f = fld - foundFldIdx[i] = true - } else if d.dm.dupMapKey == DupMapKeyEnforcedAPF { - err = &DupMapKeyError{keyString, j} - d.skip() // skip value - j++ - // skip the rest of the map - for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { - d.skip() - d.skip() - } - return err - } else { - // discard repeated match - d.skip() - continue MapEntryLoop - } - break - } - } + // No matching struct field found. + if unmatchedErr := handleUnmatchedMapKey(d, string(keyBytes), i, count, hasSize, &unmatchedMapKeys); unmatchedErr != nil { + return unmatchedErr } - if d.dm.dupMapKey == DupMapKeyEnforcedAPF && f == nil { - k = string(keyBytes) - } - } else if t <= cborTypeNegativeInt { // uint/int + case cborTypePositiveInt, cborTypeNegativeInt: var nameAsInt int64 if t == cborTypePositiveInt { _, _, val := d.getHead() - nameAsInt = int64(val) + if val > math.MaxInt64 { + if err == nil { + err = &UnmarshalTypeError{ + CBORType: t.String(), + GoType: reflect.TypeOf(int64(0)).String(), + errorMsg: strconv.FormatUint(val, 10) + " overflows Go's int64", + } + } + d.skip() // skip value + continue + } + nameAsInt = int64(val) //nolint:gosec } else { _, _, val := d.getHead() if val > math.MaxInt64 { @@ -2781,39 +2831,35 @@ MapEntryLoop: d.skip() // skip value continue } - nameAsInt = int64(-1) ^ int64(val) - } - - // Find field - for i := 0; i < len(structType.fields); i++ { - fld := structType.fields[i] - if fld.keyAsInt && fld.nameAsInt == nameAsInt { - if !foundFldIdx[i] { - f = fld - foundFldIdx[i] = true - } else if d.dm.dupMapKey == DupMapKeyEnforcedAPF { - err = &DupMapKeyError{nameAsInt, j} - d.skip() // skip value - j++ - // skip the rest of the map - for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { - d.skip() - d.skip() - } - return err - } else { - // discard repeated match - d.skip() - continue MapEntryLoop + nameAsInt = int64(-1) ^ int64(val) //nolint:gosec + } + + // Find field by integer key + if fldIdx, ok := structType.fieldIndicesByIntKey[nameAsInt]; ok { + fld := structType.fields[fldIdx] + + switch checkDupField(d.dm, foundFldIdx, fldIdx) { + case mapActionParseValueAndContinue: + if fieldErr := d.decodeToStructField(v, fld, tInfo); fieldErr != nil && err == nil { + err = fieldErr } - break + continue + case mapActionSkipAllAndReturnError: + return d.skipMapForDupKey(nameAsInt, i, count, hasSize) + case mapActionSkipValueAndContinue: + d.skip() + continue } } - if d.dm.dupMapKey == DupMapKeyEnforcedAPF && f == nil { - k = nameAsInt + // No matching struct field found. + if unmatchedErr := handleUnmatchedMapKey(d, nameAsInt, i, count, hasSize, &unmatchedMapKeys); unmatchedErr != nil { + return unmatchedErr } - } else { + + default: + // CBOR map keys that can't be matched to any struct field. + if err == nil { err = &UnmarshalTypeError{ CBORType: t.String(), @@ -2821,97 +2867,31 @@ MapEntryLoop: errorMsg: "map key is of type " + t.String() + " and cannot be used to match struct field name", } } + + var otherKey any if d.dm.dupMapKey == DupMapKeyEnforcedAPF { // parse key - k, lastErr = d.parse(true) - if lastErr != nil { + var parseErr error + otherKey, parseErr = d.parse(true) + if parseErr != nil { d.skip() // skip value continue } // Detect if CBOR map key can be used as Go map key. - if !isHashableValue(reflect.ValueOf(k)) { + if !isHashableValue(reflect.ValueOf(otherKey)) { d.skip() // skip value continue } } else { d.skip() // skip key } - } - - if f == nil { - if errOnUnknownField { - err = &UnknownFieldError{j} - d.skip() // Skip value - j++ - // skip the rest of the map - for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { - d.skip() - d.skip() - } - return err - } - - // Two map keys that match the same struct field are immediately considered - // duplicates. This check detects duplicates between two map keys that do - // not match a struct field. If unknown field errors are enabled, then this - // check is never reached. - if d.dm.dupMapKey == DupMapKeyEnforcedAPF { - if mapKeys == nil { - mapKeys = make(map[any]struct{}, 1) - } - mapKeys[k] = struct{}{} - newKeyCount := len(mapKeys) - if newKeyCount == keyCount { - err = &DupMapKeyError{k, j} - d.skip() // skip value - j++ - // skip the rest of the map - for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { - d.skip() - d.skip() - } - return err - } - keyCount = newKeyCount - } - - d.skip() // Skip value - continue - } - - // Get field value by index - var fv reflect.Value - if len(f.idx) == 1 { - fv = v.Field(f.idx[0]) - } else { - fv, lastErr = getFieldValue(v, f.idx, func(v reflect.Value) (reflect.Value, error) { - // Return a new value for embedded field null pointer to point to, or return error. - if !v.CanSet() { - return reflect.Value{}, errors.New("cbor: cannot set embedded pointer to unexported struct: " + v.Type().String()) - } - v.Set(reflect.New(v.Type().Elem())) - return v, nil - }) - if lastErr != nil && err == nil { - err = lastErr - } - if !fv.IsValid() { - d.skip() - continue - } - } - if lastErr = d.parseToValue(fv, f.typInfo); lastErr != nil { - if err == nil { - if typeError, ok := lastErr.(*UnmarshalTypeError); ok { - typeError.StructFieldName = tInfo.nonPtrType.String() + "." + f.name - err = typeError - } else { - err = lastErr - } + if unmatchedErr := handleUnmatchedMapKey(d, otherKey, i, count, hasSize, &unmatchedMapKeys); unmatchedErr != nil { + return unmatchedErr } } } + return err } @@ -2958,15 +2938,15 @@ func (d *decoder) skip() { switch t { case cborTypeByteString, cborTypeTextString: - d.off += int(val) + d.off += int(val) //nolint:gosec case cborTypeArray: - for i := 0; i < int(val); i++ { + for i := 0; i < int(val); i++ { //nolint:gosec d.skip() } case cborTypeMap: - for i := 0; i < int(val)*2; i++ { + for i := 0; i < int(val)*2; i++ { //nolint:gosec d.skip() } diff --git a/vendor/github.com/fxamacker/cbor/v2/decode_map_utils.go b/vendor/github.com/fxamacker/cbor/v2/decode_map_utils.go new file mode 100644 index 000000000..3c8c423ad --- /dev/null +++ b/vendor/github.com/fxamacker/cbor/v2/decode_map_utils.go @@ -0,0 +1,98 @@ +// Copyright (c) Faye Amacker. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +package cbor + +import "strings" + +// mapAction represents the next action during decoding a CBOR map to a Go struct. +type mapAction int + +const ( + mapActionParseValueAndContinue mapAction = iota // The caller should process the map value. + mapActionSkipValueAndContinue // The caller should skip the map value. + mapActionSkipAllAndReturnError // The caller should skip the rest of the map and return an error. +) + +// checkDupField checks if a struct field at index i has already been matched and returns the next action. +// If not matched, it marks the field as matched and returns mapActionParseValueAndContinue. +// If matched and DupMapKeyEnforcedAPF is specified in the given dm, it returns mapActionSkipAllAndReturnError. +// If matched and DupMapKeyEnforcedAPF is not specified in the given dm, it returns mapActionSkipValueAndContinue. +func checkDupField(dm *decMode, foundFldIdx []bool, i int) mapAction { + if !foundFldIdx[i] { + foundFldIdx[i] = true + return mapActionParseValueAndContinue + } + if dm.dupMapKey == DupMapKeyEnforcedAPF { + return mapActionSkipAllAndReturnError + } + return mapActionSkipValueAndContinue +} + +// findStructFieldByKey finds a struct field matching keyBytes by name. +// It tries an exact match first. If no exact match is found and +// caseInsensitive is true, it falls back to a case-insensitive search. +// findStructFieldByKey returns the field index and true, or -1 and false. +func findStructFieldByKey( + structType *decodingStructType, + keyBytes []byte, + caseInsensitive bool, +) (int, bool) { + if fldIdx, ok := structType.fieldIndicesByName[string(keyBytes)]; ok { + return fldIdx, true + } + if caseInsensitive { + return findFieldCaseInsensitive(structType.fields, string(keyBytes)) + } + return -1, false +} + +// findFieldCaseInsensitive returns the index of the first field whose name +// case-insensitively matches key, or -1 and false if no field matches. +func findFieldCaseInsensitive(flds decodingFields, key string) (int, bool) { + keyLen := len(key) + for i, f := range flds { + if f.keyAsInt { + continue + } + if len(f.name) == keyLen && strings.EqualFold(f.name, key) { + return i, true + } + } + return -1, false +} + +// handleUnmatchedMapKey handles a map entry whose key does not match any struct +// field. It can return UnknownFieldError or DupMapKeyError. +// handleUnmatchedMapKey consumes the CBOR value, so the caller doesn't need to skip any values. +// If an error is returned, the caller should abort parsing the map and return the error. +// If no error is returned, the caller should continue to process the next map pair. +func handleUnmatchedMapKey( + d *decoder, + key any, + i int, + count int, + hasSize bool, + // *map[any]struct{} is used here because we use lazy initialization for uks + uks *map[any]struct{}, //nolint:gocritic +) error { + errOnUnknownField := (d.dm.extraReturnErrors & ExtraDecErrorUnknownField) > 0 + + if errOnUnknownField { + return d.skipMapForUnknownField(i, count, hasSize) + } + + if d.dm.dupMapKey == DupMapKeyEnforcedAPF { + if *uks == nil { + *uks = make(map[any]struct{}) + } + if _, dup := (*uks)[key]; dup { + return d.skipMapForDupKey(key, i, count, hasSize) + } + (*uks)[key] = struct{}{} + } + + // Skip value. + d.skip() + return nil +} diff --git a/vendor/github.com/fxamacker/cbor/v2/diagnose.go b/vendor/github.com/fxamacker/cbor/v2/diagnose.go index 44afb8660..42a67ad11 100644 --- a/vendor/github.com/fxamacker/cbor/v2/diagnose.go +++ b/vendor/github.com/fxamacker/cbor/v2/diagnose.go @@ -51,11 +51,8 @@ const ( maxByteStringEncoding ) -func (bse ByteStringEncoding) valid() error { - if bse >= maxByteStringEncoding { - return errors.New("cbor: invalid ByteStringEncoding " + strconv.Itoa(int(bse))) - } - return nil +func (bse ByteStringEncoding) valid() bool { + return bse < maxByteStringEncoding } // DiagOptions specifies Diag options. @@ -104,8 +101,8 @@ func (opts DiagOptions) DiagMode() (DiagMode, error) { } func (opts DiagOptions) diagMode() (*diagMode, error) { - if err := opts.ByteStringEncoding.valid(); err != nil { - return nil, err + if !opts.ByteStringEncoding.valid() { + return nil, errors.New("cbor: invalid ByteStringEncoding " + strconv.Itoa(int(opts.ByteStringEncoding))) } decMode, err := DecOptions{ @@ -360,7 +357,7 @@ func (di *diagnose) item() error { //nolint:gocyclo case cborTypeArray: _, _, val := di.d.getHead() - count := int(val) + count := int(val) //nolint:gosec di.w.WriteByte('[') for i := 0; i < count; i++ { @@ -376,7 +373,7 @@ func (di *diagnose) item() error { //nolint:gocyclo case cborTypeMap: _, _, val := di.d.getHead() - count := int(val) + count := int(val) //nolint:gosec di.w.WriteByte('{') for i := 0; i < count; i++ { @@ -477,8 +474,8 @@ func (di *diagnose) item() error { //nolint:gocyclo func (di *diagnose) writeU16(val rune) { di.w.WriteString("\\u") var in [2]byte - in[0] = byte(val >> 8) - in[1] = byte(val) + in[0] = byte(val >> 8) //nolint:gosec + in[1] = byte(val) //nolint:gosec sz := hex.EncodedLen(len(in)) di.w.Grow(sz) dst := di.w.Bytes()[di.w.Len() : di.w.Len()+sz] @@ -608,7 +605,7 @@ func (di *diagnose) encodeTextString(val string, quote byte) error { c, size := utf8.DecodeRuneInString(val[i:]) switch { - case c == utf8.RuneError: + case c == utf8.RuneError && size == 1: return &SemanticError{"cbor: invalid UTF-8 string"} case c < utf16SurrSelf: @@ -631,7 +628,7 @@ func (di *diagnose) encodeFloat(ai byte, val uint64) error { f64 := float64(0) switch ai { case additionalInformationAsFloat16: - f16 := float16.Frombits(uint16(val)) + f16 := float16.Frombits(uint16(val)) //nolint:gosec switch { case f16.IsNaN(): di.w.WriteString("NaN") @@ -647,7 +644,7 @@ func (di *diagnose) encodeFloat(ai byte, val uint64) error { } case additionalInformationAsFloat32: - f32 := math.Float32frombits(uint32(val)) + f32 := math.Float32frombits(uint32(val)) //nolint:gosec switch { case f32 != f32: di.w.WriteString("NaN") diff --git a/vendor/github.com/fxamacker/cbor/v2/encode.go b/vendor/github.com/fxamacker/cbor/v2/encode.go index c550617c3..e65a29d8a 100644 --- a/vendor/github.com/fxamacker/cbor/v2/encode.go +++ b/vendor/github.com/fxamacker/cbor/v2/encode.go @@ -30,7 +30,7 @@ import ( // If value implements the Marshaler interface, Marshal calls its // MarshalCBOR method. // -// If value implements encoding.BinaryMarshaler, Marhsal calls its +// If value implements encoding.BinaryMarshaler, Marshal calls its // MarshalBinary method and encode it as CBOR byte string. // // Boolean values encode as CBOR booleans (type 7). @@ -343,7 +343,7 @@ const ( // non-UTC timezone then a "localtime - UTC" numeric offset will be included as specified in RFC3339. // NOTE: User applications can avoid including the RFC3339 numeric offset by: // - providing a time.Time value set to UTC, or - // - using the TimeUnix, TimeUnixMicro, or TimeUnixDynamic option instead of TimeRFC3339. + // - using the TimeUnix, TimeUnixMicro, TimeUnixDynamic, or TimeRFC3339NanoUTC option. TimeRFC3339 // TimeRFC3339Nano causes time.Time to encode to a CBOR time (tag 0) with a text string content @@ -351,9 +351,13 @@ const ( // non-UTC timezone then a "localtime - UTC" numeric offset will be included as specified in RFC3339. // NOTE: User applications can avoid including the RFC3339 numeric offset by: // - providing a time.Time value set to UTC, or - // - using the TimeUnix, TimeUnixMicro, or TimeUnixDynamic option instead of TimeRFC3339Nano. + // - using the TimeUnix, TimeUnixMicro, TimeUnixDynamic, or TimeRFC3339NanoUTC option. TimeRFC3339Nano + // TimeRFC3339NanoUTC causes time.Time to encode to a CBOR time (tag 0) with a text string content + // representing UTC time using nanosecond precision in RFC3339 format. + TimeRFC3339NanoUTC + maxTimeMode ) @@ -436,7 +440,7 @@ const ( // FieldNameToTextString encodes struct fields to CBOR text string (major type 3). FieldNameToTextString FieldNameMode = iota - // FieldNameToTextString encodes struct fields to CBOR byte string (major type 2). + // FieldNameToByteString encodes struct fields to CBOR byte string (major type 2). FieldNameToByteString maxFieldNameMode @@ -567,7 +571,7 @@ type EncOptions struct { // RFC3339 format gets tag number 0, and numeric epoch time tag number 1. TimeTag EncTagMode - // IndefLength specifies whether to allow indefinite length CBOR items. + // IndefLength specifies whether to allow indefinite-length CBOR items. IndefLength IndefLengthMode // NilContainers specifies how to encode nil slices and maps. @@ -1132,10 +1136,11 @@ func encodeFloat(e *bytes.Buffer, em *encMode, v reflect.Value) error { if fopt == ShortestFloat16 { var f16 float16.Float16 p := float16.PrecisionFromfloat32(f32) - if p == float16.PrecisionExact { + switch p { + case float16.PrecisionExact: // Roundtrip float32->float16->float32 test isn't needed. f16 = float16.Fromfloat32(f32) - } else if p == float16.PrecisionUnknown { + case float16.PrecisionUnknown: // Try roundtrip float32->float16->float32 to determine if float32 can fit into float16. f16 = float16.Fromfloat32(f32) if f16.Float32() == f32 { @@ -1293,10 +1298,10 @@ func encodeByteString(e *bytes.Buffer, em *encMode, v reflect.Value) error { if slen == 0 { return e.WriteByte(byte(cborTypeByteString)) } - encodeHead(e, byte(cborTypeByteString), uint64(slen)) + encodeHead(e, byte(cborTypeByteString), uint64(slen)) //nolint:gosec if vk == reflect.Array { for i := 0; i < slen; i++ { - e.WriteByte(byte(v.Index(i).Uint())) + e.WriteByte(byte(v.Index(i).Uint())) //nolint:gosec } return nil } @@ -1333,7 +1338,7 @@ func (ae arrayEncodeFunc) encode(e *bytes.Buffer, em *encMode, v reflect.Value) if alen == 0 { return e.WriteByte(byte(cborTypeArray)) } - encodeHead(e, byte(cborTypeArray), uint64(alen)) + encodeHead(e, byte(cborTypeArray), uint64(alen)) //nolint:gosec for i := 0; i < alen; i++ { if err := ae.f(e, em, v.Index(i)); err != nil { return err @@ -1364,7 +1369,7 @@ func (me mapEncodeFunc) encode(e *bytes.Buffer, em *encMode, v reflect.Value) er return e.WriteByte(byte(cborTypeMap)) } - encodeHead(e, byte(cborTypeMap), uint64(mlen)) + encodeHead(e, byte(cborTypeMap), uint64(mlen)) //nolint:gosec if em.sort == SortNone || em.sort == SortFastShuffle || mlen <= 1 { return me.e(e, em, v, nil) } @@ -1427,7 +1432,7 @@ func (x *bytewiseKeyValueSorter) Swap(i, j int) { func (x *bytewiseKeyValueSorter) Less(i, j int) bool { kvi, kvj := x.kvs[i], x.kvs[j] - return bytes.Compare(x.data[kvi.offset:kvi.valueOffset], x.data[kvj.offset:kvj.valueOffset]) <= 0 + return bytes.Compare(x.data[kvi.offset:kvi.valueOffset], x.data[kvj.offset:kvj.valueOffset]) < 0 } type lengthFirstKeyValueSorter struct { @@ -1448,7 +1453,7 @@ func (x *lengthFirstKeyValueSorter) Less(i, j int) bool { if keyLengthDifference := (kvi.valueOffset - kvi.offset) - (kvj.valueOffset - kvj.offset); keyLengthDifference != 0 { return keyLengthDifference < 0 } - return bytes.Compare(x.data[kvi.offset:kvi.valueOffset], x.data[kvj.offset:kvj.valueOffset]) <= 0 + return bytes.Compare(x.data[kvi.offset:kvi.valueOffset], x.data[kvj.offset:kvj.valueOffset]) < 0 } var keyValuePool = sync.Pool{} @@ -1535,8 +1540,8 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { // Head is rewritten later if actual encoded field count is different from struct field count. encodedHeadLen := encodeHead(e, byte(cborTypeMap), uint64(len(flds))) - kvbegin := e.Len() - kvcount := 0 + kvBeginOffset := e.Len() + kvCount := 0 for offset := 0; offset < len(flds); offset++ { f := flds[(start+offset)%len(flds)] @@ -1582,10 +1587,10 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { return err } - kvcount++ + kvCount++ } - if len(flds) == kvcount { + if len(flds) == kvCount { // Encoded element count in head is the same as actual element count. return nil } @@ -1593,8 +1598,8 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { // Overwrite the bytes that were reserved for the head before encoding the map entries. var actualHeadLen int { - headbuf := *bytes.NewBuffer(e.Bytes()[kvbegin-encodedHeadLen : kvbegin-encodedHeadLen : kvbegin]) - actualHeadLen = encodeHead(&headbuf, byte(cborTypeMap), uint64(kvcount)) + headbuf := *bytes.NewBuffer(e.Bytes()[kvBeginOffset-encodedHeadLen : kvBeginOffset-encodedHeadLen : kvBeginOffset]) + actualHeadLen = encodeHead(&headbuf, byte(cborTypeMap), uint64(kvCount)) } if actualHeadLen == encodedHeadLen { @@ -1607,8 +1612,8 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { // encoded. The encoded entries are offset to the right by the number of excess reserved // bytes. Shift the entries left to remove the gap. excessReservedBytes := encodedHeadLen - actualHeadLen - dst := e.Bytes()[kvbegin-excessReservedBytes : e.Len()-excessReservedBytes] - src := e.Bytes()[kvbegin:e.Len()] + dst := e.Bytes()[kvBeginOffset-excessReservedBytes : e.Len()-excessReservedBytes] + src := e.Bytes()[kvBeginOffset:e.Len()] copy(dst, src) // After shifting, the excess bytes are at the end of the output buffer and they are @@ -1633,7 +1638,7 @@ func encodeTime(e *bytes.Buffer, em *encMode, v reflect.Value) error { } if em.timeTag == EncTagRequired { tagNumber := 1 - if em.time == TimeRFC3339 || em.time == TimeRFC3339Nano { + if em.time == TimeRFC3339 || em.time == TimeRFC3339Nano || em.time == TimeRFC3339NanoUTC { tagNumber = 0 } encodeHead(e, byte(cborTypeTag), uint64(tagNumber)) @@ -1650,7 +1655,7 @@ func encodeTime(e *bytes.Buffer, em *encMode, v reflect.Value) error { case TimeUnixDynamic: t = t.UTC().Round(time.Microsecond) - secs, nsecs := t.Unix(), uint64(t.Nanosecond()) + secs, nsecs := t.Unix(), uint64(t.Nanosecond()) //nolint:gosec if nsecs == 0 { return encodeInt(e, em, reflect.ValueOf(secs)) } @@ -1661,6 +1666,10 @@ func encodeTime(e *bytes.Buffer, em *encMode, v reflect.Value) error { s := t.Format(time.RFC3339) return encodeString(e, em, reflect.ValueOf(s)) + case TimeRFC3339NanoUTC: + s := t.UTC().Format(time.RFC3339Nano) + return encodeString(e, em, reflect.ValueOf(s)) + default: // TimeRFC3339Nano s := t.Format(time.RFC3339Nano) return encodeString(e, em, reflect.ValueOf(s)) diff --git a/vendor/github.com/fxamacker/cbor/v2/simplevalue.go b/vendor/github.com/fxamacker/cbor/v2/simplevalue.go index 30f72814f..9912e855c 100644 --- a/vendor/github.com/fxamacker/cbor/v2/simplevalue.go +++ b/vendor/github.com/fxamacker/cbor/v2/simplevalue.go @@ -93,6 +93,6 @@ func (sv *SimpleValue) unmarshalCBOR(data []byte) error { // It is safe to cast val to uint8 here because // - data is already verified to be well-formed CBOR simple value and // - val is <= math.MaxUint8. - *sv = SimpleValue(val) + *sv = SimpleValue(val) //nolint:gosec return nil } diff --git a/vendor/github.com/fxamacker/cbor/v2/stream.go b/vendor/github.com/fxamacker/cbor/v2/stream.go index 7ac6d7d67..da4c8f210 100644 --- a/vendor/github.com/fxamacker/cbor/v2/stream.go +++ b/vendor/github.com/fxamacker/cbor/v2/stream.go @@ -171,14 +171,20 @@ func NewEncoder(w io.Writer) *Encoder { // Encode writes the CBOR encoding of v. func (enc *Encoder) Encode(v any) error { - if len(enc.indefTypes) > 0 && v != nil { - indefType := enc.indefTypes[len(enc.indefTypes)-1] - if indefType == cborTypeTextString { + if len(enc.indefTypes) > 0 { + switch enc.indefTypes[len(enc.indefTypes)-1] { + case cborTypeTextString: + if v == nil { + return errors.New("cbor: cannot encode nil for indefinite-length text string") + } k := reflect.TypeOf(v).Kind() if k != reflect.String { return errors.New("cbor: cannot encode item type " + k.String() + " for indefinite-length text string") } - } else if indefType == cborTypeByteString { + case cborTypeByteString: + if v == nil { + return errors.New("cbor: cannot encode nil for indefinite-length byte string") + } t := reflect.TypeOf(v) k := t.Kind() if (k != reflect.Array && k != reflect.Slice) || t.Elem().Kind() != reflect.Uint8 { @@ -198,35 +204,35 @@ func (enc *Encoder) Encode(v any) error { return err } -// StartIndefiniteByteString starts byte string encoding of indefinite length. +// StartIndefiniteByteString starts indefinite-length byte string encoding. // Subsequent calls of (*Encoder).Encode() encodes definite length byte strings // ("chunks") as one contiguous string until EndIndefinite is called. func (enc *Encoder) StartIndefiniteByteString() error { return enc.startIndefinite(cborTypeByteString) } -// StartIndefiniteTextString starts text string encoding of indefinite length. +// StartIndefiniteTextString starts indefinite-length text string encoding. // Subsequent calls of (*Encoder).Encode() encodes definite length text strings // ("chunks") as one contiguous string until EndIndefinite is called. func (enc *Encoder) StartIndefiniteTextString() error { return enc.startIndefinite(cborTypeTextString) } -// StartIndefiniteArray starts array encoding of indefinite length. +// StartIndefiniteArray starts indefinite-length array encoding. // Subsequent calls of (*Encoder).Encode() encodes elements of the array // until EndIndefinite is called. func (enc *Encoder) StartIndefiniteArray() error { return enc.startIndefinite(cborTypeArray) } -// StartIndefiniteMap starts array encoding of indefinite length. +// StartIndefiniteMap starts indefinite-length map encoding. // Subsequent calls of (*Encoder).Encode() encodes elements of the map // until EndIndefinite is called. func (enc *Encoder) StartIndefiniteMap() error { return enc.startIndefinite(cborTypeMap) } -// EndIndefinite closes last opened indefinite length value. +// EndIndefinite closes last opened indefinite-length value. func (enc *Encoder) EndIndefinite() error { if len(enc.indefTypes) == 0 { return errors.New("cbor: cannot encode \"break\" code outside indefinite length values") @@ -238,18 +244,22 @@ func (enc *Encoder) EndIndefinite() error { return err } -var cborIndefHeader = map[cborType][]byte{ - cborTypeByteString: {cborByteStringWithIndefiniteLengthHead}, - cborTypeTextString: {cborTextStringWithIndefiniteLengthHead}, - cborTypeArray: {cborArrayWithIndefiniteLengthHead}, - cborTypeMap: {cborMapWithIndefiniteLengthHead}, -} - func (enc *Encoder) startIndefinite(typ cborType) error { if enc.em.indefLength == IndefLengthForbidden { return &IndefiniteLengthError{typ} } - _, err := enc.w.Write(cborIndefHeader[typ]) + var head byte + switch typ { + case cborTypeByteString: + head = cborByteStringWithIndefiniteLengthHead + case cborTypeTextString: + head = cborTextStringWithIndefiniteLengthHead + case cborTypeArray: + head = cborArrayWithIndefiniteLengthHead + case cborTypeMap: + head = cborMapWithIndefiniteLengthHead + } + _, err := enc.w.Write([]byte{head}) if err == nil { enc.indefTypes = append(enc.indefTypes, typ) } @@ -262,7 +272,9 @@ type RawMessage []byte // MarshalCBOR returns m or CBOR nil if m is nil. func (m RawMessage) MarshalCBOR() ([]byte, error) { if len(m) == 0 { - return cborNil, nil + b := make([]byte, len(cborNil)) + copy(b, cborNil) + return b, nil } return m, nil } diff --git a/vendor/github.com/fxamacker/cbor/v2/structfields.go b/vendor/github.com/fxamacker/cbor/v2/structfields.go index cf0a922cd..b2d71f2e9 100644 --- a/vendor/github.com/fxamacker/cbor/v2/structfields.go +++ b/vendor/github.com/fxamacker/cbor/v2/structfields.go @@ -6,27 +6,43 @@ package cbor import ( "reflect" "sort" + "strconv" "strings" ) +// field holds shared struct field metadata returned by getFields(). type field struct { - name string - nameAsInt int64 // used to decoder to match field name with CBOR int + name string + nameAsInt int64 // used to match field name with CBOR int + idx []int + typ reflect.Type // used during cache building only + keyAsInt bool // used to encode/decode field name as int + tagged bool // used to choose dominant field (at the same level tagged fields dominate untagged fields) + omitEmpty bool // used to skip empty field + omitZero bool // used to skip zero field +} + +type fields []*field + +// encodingField extends field with encoding-specific data. +type encodingField struct { + field cborName []byte - cborNameByteString []byte // major type 2 name encoding iff cborName has major type 3 - idx []int - typ reflect.Type + cborNameByteString []byte // major type 2 name encoding if cborName has major type 3 ef encodeFunc ief isEmptyFunc izf isZeroFunc - typInfo *typeInfo // used to decoder to reuse type info - tagged bool // used to choose dominant field (at the same level tagged fields dominate untagged fields) - omitEmpty bool // used to skip empty field - omitZero bool // used to skip zero field - keyAsInt bool // used to encode/decode field name as int } -type fields []*field +type encodingFields []*encodingField + +// decodingField extends field with decoding-specific data. +type decodingField struct { + field + typInfo *typeInfo // used by decoder to reuse type info +} + +type decodingFields []*decodingField // indexFieldSorter sorts fields by field idx at each level, breaking ties with idx depth. type indexFieldSorter struct { @@ -48,7 +64,7 @@ func (x *indexFieldSorter) Less(i, j int) bool { return iIdx[k] < jIdx[k] } } - return len(iIdx) <= len(jIdx) + return len(iIdx) < len(jIdx) } // nameLevelAndTagFieldSorter sorts fields by field name, idx depth, and presence of tag. @@ -69,6 +85,10 @@ func (x *nameLevelAndTagFieldSorter) Less(i, j int) bool { if fi.name != fj.name { return fi.name < fj.name } + // Fields with the same name but different keyAsInt are in separate namespaces. + if fi.keyAsInt != fj.keyAsInt { + return fi.keyAsInt + } if len(fi.idx) != len(fj.idx) { return len(fi.idx) < len(fj.idx) } @@ -117,22 +137,37 @@ func getFields(t reflect.Type) (flds fields, structOptions string) { } } + // Normalize keyasint field names to their canonical integer string form. + // This ensures that "01", "+1", and "1" are treated as the same key + // during deduplication. + for _, f := range flds { + if f.keyAsInt { + nameAsInt, err := strconv.Atoi(f.name) + if err != nil { + continue // Leave invalid names for callers to report. + } + f.nameAsInt = int64(nameAsInt) + f.name = strconv.Itoa(nameAsInt) + } + } + sort.Sort(&nameLevelAndTagFieldSorter{flds}) // Keep visible fields. j := 0 // index of next unique field for i := 0; i < len(flds); { name := flds[i].name + keyAsInt := flds[i].keyAsInt if i == len(flds)-1 || // last field - name != flds[i+1].name || // field i has unique field name + name != flds[i+1].name || flds[i+1].keyAsInt != keyAsInt || // field i has unique (name, keyAsInt) len(flds[i].idx) < len(flds[i+1].idx) || // field i is at a less nested level than field i+1 (flds[i].tagged && !flds[i+1].tagged) { // field i is tagged while field i+1 is not flds[j] = flds[i] j++ } - // Skip fields with the same field name. - for i++; i < len(flds) && name == flds[i].name; i++ { //nolint:revive + // Skip fields with the same (name, keyAsInt). + for i++; i < len(flds) && name == flds[i].name && keyAsInt == flds[i].keyAsInt; i++ { //nolint:revive } } if j != len(flds) { diff --git a/vendor/github.com/fxamacker/cbor/v2/valid.go b/vendor/github.com/fxamacker/cbor/v2/valid.go index b40793b95..850b95019 100644 --- a/vendor/github.com/fxamacker/cbor/v2/valid.go +++ b/vendor/github.com/fxamacker/cbor/v2/valid.go @@ -54,7 +54,7 @@ func (e *MaxMapPairsError) Error() string { return "cbor: exceeded max number of key-value pairs " + strconv.Itoa(e.maxMapPairs) + " for CBOR map" } -// IndefiniteLengthError indicates found disallowed indefinite length items. +// IndefiniteLengthError indicates found disallowed indefinite-length items. type IndefiniteLengthError struct { t cborType } @@ -113,7 +113,7 @@ func (d *decoder) wellformedInternal(depth int, checkBuiltinTags bool) (int, err } return d.wellformedIndefiniteString(t, depth, checkBuiltinTags) } - valInt := int(val) + valInt := int(val) //nolint:gosec if valInt < 0 { // Detect integer overflow return 0, errors.New("cbor: " + t.String() + " length " + strconv.FormatUint(val, 10) + " is too large, causing integer overflow") @@ -136,7 +136,7 @@ func (d *decoder) wellformedInternal(depth int, checkBuiltinTags bool) (int, err return d.wellformedIndefiniteArrayOrMap(t, depth, checkBuiltinTags) } - valInt := int(val) + valInt := int(val) //nolint:gosec if valInt < 0 { // Detect integer overflow return 0, errors.New("cbor: " + t.String() + " length " + strconv.FormatUint(val, 10) + " is too large, it would cause integer overflow") @@ -212,7 +212,7 @@ func (d *decoder) wellformedInternal(depth int, checkBuiltinTags bool) (int, err return depth, nil } -// wellformedIndefiniteString checks indefinite length byte/text string's well-formedness and returns max depth and error. +// wellformedIndefiniteString checks indefinite-length byte/text string's well-formedness and returns max depth and error. func (d *decoder) wellformedIndefiniteString(t cborType, depth int, checkBuiltinTags bool) (int, error) { var err error for { @@ -223,7 +223,7 @@ func (d *decoder) wellformedIndefiniteString(t cborType, depth int, checkBuiltin d.off++ break } - // Peek ahead to get next type and indefinite length status. + // Peek ahead to get next type and indefinite-length status. nt, ai := parseInitialByte(d.data[d.off]) if t != nt { return 0, &SyntaxError{"cbor: wrong element type " + nt.String() + " for indefinite-length " + t.String()} @@ -238,7 +238,7 @@ func (d *decoder) wellformedIndefiniteString(t cborType, depth int, checkBuiltin return depth, nil } -// wellformedIndefiniteArrayOrMap checks indefinite length array/map's well-formedness and returns max depth and error. +// wellformedIndefiniteArrayOrMap checks indefinite-length array/map's well-formedness and returns max depth and error. func (d *decoder) wellformedIndefiniteArrayOrMap(t cborType, depth int, checkBuiltinTags bool) (int, error) { var err error maxDepth := depth @@ -326,7 +326,7 @@ func (d *decoder) wellformedHead() (t cborType, ai byte, val uint64, err error) val = uint64(binary.BigEndian.Uint16(d.data[d.off : d.off+argumentSize])) d.off += argumentSize if t == cborTypePrimitives { - if err := d.acceptableFloat(float64(float16.Frombits(uint16(val)).Float32())); err != nil { + if err := d.acceptableFloat(float64(float16.Frombits(uint16(val)).Float32())); err != nil { //nolint:gosec return 0, 0, 0, err } } @@ -341,7 +341,7 @@ func (d *decoder) wellformedHead() (t cborType, ai byte, val uint64, err error) val = uint64(binary.BigEndian.Uint32(d.data[d.off : d.off+argumentSize])) d.off += argumentSize if t == cborTypePrimitives { - if err := d.acceptableFloat(float64(math.Float32frombits(uint32(val)))); err != nil { + if err := d.acceptableFloat(float64(math.Float32frombits(uint32(val)))); err != nil { //nolint:gosec return 0, 0, 0, err } } @@ -379,12 +379,12 @@ func (d *decoder) wellformedHead() (t cborType, ai byte, val uint64, err error) func (d *decoder) acceptableFloat(f float64) error { switch { - case d.dm.nanDec == NaNDecodeForbidden && math.IsNaN(f): + case d.dm.nan == NaNDecodeForbidden && math.IsNaN(f): return &UnacceptableDataItemError{ CBORType: cborTypePrimitives.String(), Message: "floating-point NaN", } - case d.dm.infDec == InfDecodeForbidden && math.IsInf(f, 0): + case d.dm.inf == InfDecodeForbidden && math.IsInf(f, 0): return &UnacceptableDataItemError{ CBORType: cborTypePrimitives.String(), Message: "floating-point infinity", diff --git a/vendor/github.com/go-openapi/jsonpointer/.cliff.toml b/vendor/github.com/go-openapi/jsonpointer/.cliff.toml deleted file mode 100644 index 702629f5d..000000000 --- a/vendor/github.com/go-openapi/jsonpointer/.cliff.toml +++ /dev/null @@ -1,181 +0,0 @@ -# git-cliff ~ configuration file -# https://git-cliff.org/docs/configuration - -[changelog] -header = """ -""" - -footer = """ - ------ - -**[{{ remote.github.repo }}]({{ self::remote_url() }}) license terms** - -[![License][license-badge]][license-url] - -[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg -[license-url]: {{ self::remote_url() }}/?tab=Apache-2.0-1-ov-file#readme - -{%- macro remote_url() -%} - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} -{%- endmacro -%} -""" - -body = """ -{%- if version %} -## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/tree/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} -{%- else %} -## [unreleased] -{%- endif %} -{%- if message %} - {%- raw %}\n{% endraw %} -{{ message }} - {%- raw %}\n{% endraw %} -{%- endif %} -{%- if version %} - {%- if previous.version %} - -**Full Changelog**: <{{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}> - {%- endif %} -{%- else %} - {%- raw %}\n{% endraw %} -{%- endif %} - -{%- if statistics %}{% if statistics.commit_count %} - {%- raw %}\n{% endraw %} -{{ statistics.commit_count }} commits in this release. - {%- raw %}\n{% endraw %} -{%- endif %}{% endif %} ------ - -{%- for group, commits in commits | group_by(attribute="group") %} - {%- raw %}\n{% endraw %} -### {{ group | upper_first }} - {%- raw %}\n{% endraw %} - {%- for commit in commits %} - {%- if commit.remote.pr_title %} - {%- set commit_message = commit.remote.pr_title %} - {%- else %} - {%- set commit_message = commit.message %} - {%- endif %} -* {{ commit_message | split(pat="\n") | first | trim }} - {%- if commit.remote.username %} -{%- raw %} {% endraw %}by [@{{ commit.remote.username }}](https://github.com/{{ commit.remote.username }}) - {%- endif %} - {%- if commit.remote.pr_number %} -{%- raw %} {% endraw %}in [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) - {%- endif %} -{%- raw %} {% endraw %}[...]({{ self::remote_url() }}/commit/{{ commit.id }}) - {%- endfor %} -{%- endfor %} - -{%- if github %} -{%- raw %}\n{% endraw -%} - {%- set all_contributors = github.contributors | length %} - {%- if github.contributors | filter(attribute="username", value="dependabot[bot]") | length < all_contributors %} ------ - -### People who contributed to this release - {% endif %} - {%- for contributor in github.contributors | filter(attribute="username") | sort(attribute="username") %} - {%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %} -* [@{{ contributor.username }}](https://github.com/{{ contributor.username }}) - {%- endif %} - {%- endfor %} - - {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} ------ - {%- raw %}\n{% endraw %} - -### New Contributors - {%- endif %} - - {%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} - {%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %} -* @{{ contributor.username }} made their first contribution - {%- if contributor.pr_number %} - in [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ - {%- endif %} - {%- endif %} - {%- endfor %} -{%- endif %} - -{%- raw %}\n{% endraw %} - -{%- macro remote_url() -%} - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} -{%- endmacro -%} -""" -# Remove leading and trailing whitespaces from the changelog's body. -trim = true -# Render body even when there are no releases to process. -render_always = true -# An array of regex based postprocessors to modify the changelog. -postprocessors = [ - # Replace the placeholder with a URL. - #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, -] -# output file path -# output = "test.md" - -[git] -# Parse commits according to the conventional commits specification. -# See https://www.conventionalcommits.org -conventional_commits = false -# Exclude commits that do not match the conventional commits specification. -filter_unconventional = false -# Require all commits to be conventional. -# Takes precedence over filter_unconventional. -require_conventional = false -# Split commits on newlines, treating each line as an individual commit. -split_commits = false -# An array of regex based parsers to modify commit messages prior to further processing. -commit_preprocessors = [ - # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. - #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, - # Check spelling of the commit message using https://github.com/crate-ci/typos. - # If the spelling is incorrect, it will be fixed automatically. - #{ pattern = '.*', replace_command = 'typos --write-changes -' } -] -# Prevent commits that are breaking from being excluded by commit parsers. -protect_breaking_commits = false -# An array of regex based parsers for extracting data from the commit message. -# Assigns commits to groups. -# Optionally sets the commit's scope and can decide to exclude commits from further processing. -commit_parsers = [ - { message = "^[Cc]hore\\([Rr]elease\\): prepare for", skip = true }, - { message = "(^[Mm]erge)|([Mm]erge conflict)", skip = true }, - { field = "author.name", pattern = "dependabot*", group = "Updates" }, - { message = "([Ss]ecurity)|([Vv]uln)", group = "Security" }, - { body = "(.*[Ss]ecurity)|([Vv]uln)", group = "Security" }, - { message = "([Cc]hore\\(lint\\))|(style)|(lint)|(codeql)|(golangci)", group = "Code quality" }, - { message = "(^[Dd]oc)|((?i)readme)|(badge)|(typo)|(documentation)", group = "Documentation" }, - { message = "(^[Ff]eat)|(^[Ee]nhancement)", group = "Implemented enhancements" }, - { message = "(^ci)|(\\(ci\\))|(fixup\\s+ci)|(fix\\s+ci)|(license)|(example)", group = "Miscellaneous tasks" }, - { message = "^test", group = "Testing" }, - { message = "(^fix)|(panic)", group = "Fixed bugs" }, - { message = "(^refact)|(rework)", group = "Refactor" }, - { message = "(^[Pp]erf)|(performance)", group = "Performance" }, - { message = "(^[Cc]hore)", group = "Miscellaneous tasks" }, - { message = "^[Rr]evert", group = "Reverted changes" }, - { message = "(upgrade.*?go)|(go\\s+version)", group = "Updates" }, - { message = ".*", group = "Other" }, -] -# Exclude commits that are not matched by any commit parser. -filter_commits = false -# An array of link parsers for extracting external references, and turning them into URLs, using regex. -link_parsers = [] -# Include only the tags that belong to the current branch. -use_branch_tags = false -# Order releases topologically instead of chronologically. -topo_order = false -# Order releases topologically instead of chronologically. -topo_order_commits = true -# Order of commits in each group/release within the changelog. -# Allowed values: newest, oldest -sort_commits = "newest" -# Process submodules commits -recurse_submodules = false - -#[remote.github] -#owner = "go-openapi" diff --git a/vendor/github.com/go-openapi/jsonpointer/.gitignore b/vendor/github.com/go-openapi/jsonpointer/.gitignore index 59cd29489..d8f4186fe 100644 --- a/vendor/github.com/go-openapi/jsonpointer/.gitignore +++ b/vendor/github.com/go-openapi/jsonpointer/.gitignore @@ -2,3 +2,4 @@ *.cov .idea .env +.mcp.json diff --git a/vendor/github.com/go-openapi/jsonpointer/.golangci.yml b/vendor/github.com/go-openapi/jsonpointer/.golangci.yml index fdae591bc..dc7c96053 100644 --- a/vendor/github.com/go-openapi/jsonpointer/.golangci.yml +++ b/vendor/github.com/go-openapi/jsonpointer/.golangci.yml @@ -12,6 +12,7 @@ linters: - paralleltest - recvcheck - testpackage + - thelper - tparallel - varnamelen - whitespace diff --git a/vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md b/vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md index 9322b065e..bac878f21 100644 --- a/vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md +++ b/vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md @@ -23,7 +23,9 @@ include: Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or + advances + * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic @@ -55,7 +57,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at ivan+abuse@flanders.co.nz. All +reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. @@ -68,7 +70,7 @@ members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] +available at [][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/vendor/github.com/go-openapi/jsonpointer/CONTRIBUTORS.md b/vendor/github.com/go-openapi/jsonpointer/CONTRIBUTORS.md index 03c098316..9990f4a35 100644 --- a/vendor/github.com/go-openapi/jsonpointer/CONTRIBUTORS.md +++ b/vendor/github.com/go-openapi/jsonpointer/CONTRIBUTORS.md @@ -4,21 +4,22 @@ | Total Contributors | Total Contributions | | --- | --- | -| 12 | 95 | +| 13 | 111 | | Username | All Time Contribution Count | All Commits | | --- | --- | --- | -| @fredbi | 48 | https://github.com/go-openapi/jsonpointer/commits?author=fredbi | -| @casualjim | 33 | https://github.com/go-openapi/jsonpointer/commits?author=casualjim | -| @magodo | 3 | https://github.com/go-openapi/jsonpointer/commits?author=magodo | -| @youyuanwu | 3 | https://github.com/go-openapi/jsonpointer/commits?author=youyuanwu | -| @gaiaz-iusipov | 1 | https://github.com/go-openapi/jsonpointer/commits?author=gaiaz-iusipov | -| @gbjk | 1 | https://github.com/go-openapi/jsonpointer/commits?author=gbjk | -| @gordallott | 1 | https://github.com/go-openapi/jsonpointer/commits?author=gordallott | -| @ianlancetaylor | 1 | https://github.com/go-openapi/jsonpointer/commits?author=ianlancetaylor | -| @mfleader | 1 | https://github.com/go-openapi/jsonpointer/commits?author=mfleader | -| @Neo2308 | 1 | https://github.com/go-openapi/jsonpointer/commits?author=Neo2308 | -| @olivierlemasle | 1 | https://github.com/go-openapi/jsonpointer/commits?author=olivierlemasle | -| @testwill | 1 | https://github.com/go-openapi/jsonpointer/commits?author=testwill | +| @fredbi | 63 | | +| @casualjim | 33 | | +| @magodo | 3 | | +| @youyuanwu | 3 | | +| @gaiaz-iusipov | 1 | | +| @gbjk | 1 | | +| @gordallott | 1 | | +| @ianlancetaylor | 1 | | +| @mfleader | 1 | | +| @Neo2308 | 1 | | +| @alexandear | 1 | | +| @olivierlemasle | 1 | | +| @testwill | 1 | | - _this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_ + _this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_ diff --git a/vendor/github.com/go-openapi/jsonpointer/NOTICE b/vendor/github.com/go-openapi/jsonpointer/NOTICE index f3b51939a..201908d2f 100644 --- a/vendor/github.com/go-openapi/jsonpointer/NOTICE +++ b/vendor/github.com/go-openapi/jsonpointer/NOTICE @@ -18,7 +18,7 @@ It ships with copies of other software which license terms are recalled below. The original software was authored on 25-02-2013 by sigu-399 (https://github.com/sigu-399, sigu.399@gmail.com). -github.com/sigh-399/jsonpointer +github.com/sigu-399/jsonpointer =========================== // SPDX-FileCopyrightText: Copyright 2013 sigu-399 ( https://github.com/sigu-399 ) diff --git a/vendor/github.com/go-openapi/jsonpointer/README.md b/vendor/github.com/go-openapi/jsonpointer/README.md index b61b63fd9..24fbe1bf6 100644 --- a/vendor/github.com/go-openapi/jsonpointer/README.md +++ b/vendor/github.com/go-openapi/jsonpointer/README.md @@ -8,15 +8,33 @@ [![Release][release-badge]][release-url] [![Go Report Card][gocard-badge]][gocard-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url] -[![GoDoc][godoc-badge]][godoc-url] [![Slack Channel][slack-logo]![slack-badge]][slack-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] +[![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] --- An implementation of JSON Pointer for golang, which supports go `struct`. +## Announcements + +* **2026-04-15** : added support for trailing "-" for arrays (v0.23.0) + * this brings full support of [RFC6901][RFC6901] + * this is supported for types relying on the reflection-based implemented + * API semantics remain essentially unaltered. Exception: `Pointer.Set(document any,value any) (document any, err error)` + can only perform a best-effort to mutate the input document in place. In the case of adding elements to an array with a + trailing "-", either pass a mutable array (`*[]T`) as the input document, or use the returned updated document instead. + * types that implement the `JSONSetable` interface may not implement the mutation implied by the trailing "-" + +* **2026-04-15** : added support for optional alternate JSON name providers + * for struct support the defaults might not suit all situations: there are known limitations + when it comes to handle untagged fields or embedded types. + * the default name provider in use is not fully aligned with go JSON stdlib + * exposed an option (or global setting) to change the provider that resolves a struct into json keys + * the default behavior is not altered + * a new alternate name provider is added (imported from `go-openapi/swag/jsonname`), aligned with JSON stdlib behavior + ## Status -API is stable. +API is stable and feature-complete. ## Import this library in your project @@ -78,7 +96,7 @@ See -also known as [RFC6901](https://www.rfc-editor.org/rfc/rfc6901) +also known as [RFC6901][RFC6901]. ## Licensing @@ -89,19 +107,19 @@ on top of which it has been built. ## Limitations -The 4.Evaluation part of the previous reference, starting with 'If the currently referenced value is a JSON array, -the reference token MUST contain either...' is not implemented. - -That is because our implementation of the JSON pointer only supports explicit references to array elements: -the provision in the spec to resolve non-existent members as "the last element in the array", -using the special trailing character "-" is not implemented. +* [RFC6901][RFC6901] is now fully supported, including trailing "-" semantics for arrays (for `Set` operations). +* Default behavior: JSON name detection in go `struct`s + - Unlike go standard marshaling, untagged fields do not default to the go field name and are ignored. + - anonymous fields are not traversed if untagged + - the above limitations may be overcome by calling `UseGoNameProvider()` at initialization time. + - alternatively, users may inject the desired custom behavior for naming fields as an option. ## Other documentation * [All-time contributors](./CONTRIBUTORS.md) -* [Contributing guidelines](.github/CONTRIBUTING.md) -* [Maintainers documentation](docs/MAINTAINERS.md) -* [Code style](docs/STYLE.md) +* [Contributing guidelines][contributing-doc-site] +* [Maintainers documentation][maintainers-doc-site] +* [Code style][style-doc-site] ## Cutting a new release @@ -124,21 +142,17 @@ Maintainers can cut a new release by either: [release-badge]: https://badge.fury.io/gh/go-openapi%2Fjsonpointer.svg [release-url]: https://badge.fury.io/gh/go-openapi%2Fjsonpointer -[gomod-badge]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fjsonpointer.svg -[gomod-url]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fjsonpointer [gocard-badge]: https://goreportcard.com/badge/github.com/go-openapi/jsonpointer [gocard-url]: https://goreportcard.com/report/github.com/go-openapi/jsonpointer [codefactor-badge]: https://img.shields.io/codefactor/grade/github/go-openapi/jsonpointer [codefactor-url]: https://www.codefactor.io/repository/github/go-openapi/jsonpointer -[doc-badge]: https://img.shields.io/badge/doc-site-blue?link=https%3A%2F%2Fgoswagger.io%2Fgo-openapi%2F -[doc-url]: https://goswagger.io/go-openapi [godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/jsonpointer [godoc-url]: http://pkg.go.dev/github.com/go-openapi/jsonpointer -[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png -[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM -[slack-url]: https://goswagger.slack.com/archives/C04R30YMU +[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue +[discord-url]: https://discord.gg/FfnFYaC3k5 + [license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg [license-url]: https://github.com/go-openapi/jsonpointer/?tab=Apache-2.0-1-ov-file#readme @@ -147,3 +161,8 @@ Maintainers can cut a new release by either: [goversion-url]: https://github.com/go-openapi/jsonpointer/blob/master/go.mod [top-badge]: https://img.shields.io/github/languages/top/go-openapi/jsonpointer [commits-badge]: https://img.shields.io/github/commits-since/go-openapi/jsonpointer/latest +[RFC6901]: https://www.rfc-editor.org/rfc/rfc6901 + +[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html +[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html +[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html diff --git a/vendor/github.com/go-openapi/jsonpointer/SECURITY.md b/vendor/github.com/go-openapi/jsonpointer/SECURITY.md index 2a7b6f091..1fea2c573 100644 --- a/vendor/github.com/go-openapi/jsonpointer/SECURITY.md +++ b/vendor/github.com/go-openapi/jsonpointer/SECURITY.md @@ -6,14 +6,32 @@ This policy outlines the commitment and practices of the go-openapi maintainers | Version | Supported | | ------- | ------------------ | -| 0.22.x | :white_check_mark: | +| O.x | :white_check_mark: | + +## Vulnerability checks in place + +This repository uses automated vulnerability scans, at every merged commit and at least once a week. + +We use: + +* [`GitHub CodeQL`][codeql-url] +* [`trivy`][trivy-url] +* [`govulncheck`][govulncheck-url] + +Reports are centralized in github security reports and visible only to the maintainers. ## Reporting a vulnerability If you become aware of a security vulnerability that affects the current repository, -please report it privately to the maintainers. +**please report it privately to the maintainers** +rather than opening a publicly visible GitHub issue. + +Please follow the instructions provided by github to [Privately report a security vulnerability][github-guidance-url]. -Please follow the instructions provided by github to -[Privately report a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability). +> [!NOTE] +> On Github, navigate to the project's "Security" tab then click on "Report a vulnerability". -TL;DR: on Github, navigate to the project's "Security" tab then click on "Report a vulnerability". +[codeql-url]: https://github.com/github/codeql +[trivy-url]: https://trivy.dev/docs/latest/getting-started +[govulncheck-url]: https://go.dev/blog/govulncheck +[github-guidance-url]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability diff --git a/vendor/github.com/go-openapi/jsonpointer/errors.go b/vendor/github.com/go-openapi/jsonpointer/errors.go index 8c50dde8b..8813474d4 100644 --- a/vendor/github.com/go-openapi/jsonpointer/errors.go +++ b/vendor/github.com/go-openapi/jsonpointer/errors.go @@ -16,12 +16,24 @@ const ( ErrPointer pointerError = "JSON pointer error" // ErrInvalidStart states that a JSON pointer must start with a separator ("/"). - ErrInvalidStart pointerError = `JSON pointer must be empty or start with a "` + pointerSeparator + ErrInvalidStart pointerError = `JSON pointer must be empty or start with a "` + pointerSeparator + `"` // ErrUnsupportedValueType indicates that a value of the wrong type is being set. ErrUnsupportedValueType pointerError = "only structs, pointers, maps and slices are supported for setting values" + + // ErrDashToken indicates use of the RFC 6901 "-" reference token + // in a context where it cannot be resolved. + // + // Per RFC 6901 §4 the "-" token refers to the (nonexistent) element + // after the last array element. It may only be used as the terminal + // token of a [Pointer.Set] against a slice, where it means "append". + // Any other use (get, offset, intermediate traversal, non-slice target) + // is an error condition that wraps this sentinel. + ErrDashToken pointerError = `the "-" array token cannot be resolved here` //nolint:gosec // G101 false positive: this is a JSON Pointer reference token, not a credential. ) +const dashToken = "-" + func errNoKey(key string) error { return fmt.Errorf("object has no key %q: %w", key, ErrPointer) } @@ -33,3 +45,15 @@ func errOutOfBounds(length, idx int) error { func errInvalidReference(token string) error { return fmt.Errorf("invalid token reference %q: %w", token, ErrPointer) } + +func errDashOnGet() error { + return fmt.Errorf("cannot resolve %q token on get: %w: %w", dashToken, ErrDashToken, ErrPointer) +} + +func errDashIntermediate() error { + return fmt.Errorf("the %q token may only appear as the terminal token of a pointer: %w: %w", dashToken, ErrDashToken, ErrPointer) +} + +func errDashOnOffset() error { + return fmt.Errorf("cannot compute offset for %q token (nonexistent element): %w: %w", dashToken, ErrDashToken, ErrPointer) +} diff --git a/vendor/github.com/go-openapi/jsonpointer/ifaces.go b/vendor/github.com/go-openapi/jsonpointer/ifaces.go new file mode 100644 index 000000000..1e56ac044 --- /dev/null +++ b/vendor/github.com/go-openapi/jsonpointer/ifaces.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package jsonpointer + +import "reflect" + +// JSONPointable is an interface for structs to implement, +// when they need to customize the json pointer process or want to avoid the use of reflection. +type JSONPointable interface { + // JSONLookup returns a value pointed at this (unescaped) key. + JSONLookup(key string) (any, error) +} + +// JSONSetable is an interface for structs to implement, +// when they need to customize the json pointer process or want to avoid the use of reflection. +// +// # Handling of the RFC 6901 "-" token +// +// When a type implementing JSONSetable is the terminal parent of a [Pointer.Set] +// call, the library passes the raw reference token to JSONSet without +// interpretation. In particular, the RFC 6901 "-" token (which conventionally +// means "append" for arrays, per RFC 6902) is forwarded verbatim as the key +// argument. Implementations that model an array-like container are expected +// to give "-" the append semantics; implementations that do not should return +// an error wrapping [ErrDashToken] (or [ErrPointer]) for clarity. +// +// Implementations are responsible for any in-place mutation: the library does +// not attempt to rebind the result of JSONSet into a parent container. +type JSONSetable interface { + // JSONSet sets the value pointed at the (unescaped) key. + // + // The key may be the RFC 6901 "-" token when the pointer targets a + // slice-like member; see the interface documentation for details. + JSONSet(key string, value any) error +} + +// NameProvider knows how to resolve go struct fields into json names. +// +// The default provider is brought by [github.com/go-openapi/swag/jsonname.DefaultJSONNameProvider]. +type NameProvider interface { + // GetGoName gets the go name for a json property name + GetGoName(subject any, name string) (string, bool) + + // GetGoNameForType gets the go name for a given type for a json property name + GetGoNameForType(tpe reflect.Type, name string) (string, bool) +} diff --git a/vendor/github.com/go-openapi/jsonpointer/options.go b/vendor/github.com/go-openapi/jsonpointer/options.go new file mode 100644 index 000000000..d52caab22 --- /dev/null +++ b/vendor/github.com/go-openapi/jsonpointer/options.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package jsonpointer + +import ( + "sync" + + "github.com/go-openapi/swag/jsonname" +) + +// Option to tune the behavior of a JSON [Pointer]. +type Option func(*options) + +var ( + //nolint:gochecknoglobals // package level defaults are provided as a convenient, backward-compatible way to adopt options. + defaultOptions = options{ + provider: jsonname.DefaultJSONNameProvider, + } + //nolint:gochecknoglobals // guards defaultOptions against concurrent SetDefaultNameProvider / read races (testing) + defaultOptionsMu sync.RWMutex +) + +// SetDefaultNameProvider sets the [NameProvider] as a package-level default. +// +// By default, the default provider is [jsonname.DefaultJSONNameProvider]. +// +// It is safe to call concurrently with [Pointer.Get], [Pointer.Set], +// [GetForToken] and [SetForToken]. The typical usage is to call it once +// at initialization time. +// +// A nil provider is ignored. +func SetDefaultNameProvider(provider NameProvider) { + if provider == nil { + return + } + + defaultOptionsMu.Lock() + defer defaultOptionsMu.Unlock() + + defaultOptions.provider = provider +} + +// UseGoNameProvider sets the [NameProvider] as a package-level default +// to the alternative provider [jsonname.GoNameProvider], that covers a few areas +// not supported by the default name provider. +// +// This implementation supports untagged exported fields and embedded types in go struct. +// It follows strictly the behavior of the JSON standard library regarding field naming conventions. +// +// It is safe to call concurrently with [Pointer.Get], [Pointer.Set], +// [GetForToken] and [SetForToken]. The typical usage is to call it once +// at initialization time. +func UseGoNameProvider() { + SetDefaultNameProvider(jsonname.NewGoNameProvider()) +} + +// DefaultNameProvider returns the current package-level [NameProvider]. +func DefaultNameProvider() NameProvider { //nolint:ireturn // returning the interface is the point — callers pick their own implementation. + defaultOptionsMu.RLock() + defer defaultOptionsMu.RUnlock() + + return defaultOptions.provider +} + +// WithNameProvider injects a custom [NameProvider] to resolve json names from go struct types. +func WithNameProvider(provider NameProvider) Option { + return func(o *options) { + o.provider = provider + } +} + +type options struct { + provider NameProvider +} + +func optionsWithDefaults(opts []Option) options { + var o options + o.provider = DefaultNameProvider() + + for _, apply := range opts { + apply(&o) + } + + return o +} diff --git a/vendor/github.com/go-openapi/jsonpointer/pointer.go b/vendor/github.com/go-openapi/jsonpointer/pointer.go index 7df49af3b..2369c1827 100644 --- a/vendor/github.com/go-openapi/jsonpointer/pointer.go +++ b/vendor/github.com/go-openapi/jsonpointer/pointer.go @@ -11,8 +11,6 @@ import ( "reflect" "strconv" "strings" - - "github.com/go-openapi/swag/jsonname" ) const ( @@ -20,20 +18,6 @@ const ( pointerSeparator = `/` ) -// JSONPointable is an interface for structs to implement, -// when they need to customize the json pointer process or want to avoid the use of reflection. -type JSONPointable interface { - // JSONLookup returns a value pointed at this (unescaped) key. - JSONLookup(key string) (any, error) -} - -// JSONSetable is an interface for structs to implement, -// when they need to customize the json pointer process or want to avoid the use of reflection. -type JSONSetable interface { - // JSONSet sets the value pointed at the (unescaped) key. - JSONSet(key string, value any) error -} - // Pointer is a representation of a json pointer. // // Use [Pointer.Get] to retrieve a value or [Pointer.Set] to set a value. @@ -41,7 +25,7 @@ type JSONSetable interface { // It works with any go type interpreted as a JSON document, which means: // // - if a type implements [JSONPointable], its [JSONPointable.JSONLookup] method is used to resolve [Pointer.Get] -// - if a type implements [JSONSetable], its [JSONPointable.JSONSet] method is used to resolve [Pointer.Set] +// - if a type implements [JSONSetable], its [JSONSetable.JSONSet] method is used to resolve [Pointer.Set] // - a go map[K]V is interpreted as an object, with type K assignable to a string // - a go slice []T is interpreted as an array // - a go struct is interpreted as an object, with exported fields interpreted as keys @@ -71,16 +55,35 @@ func New(jsonPointerString string) (Pointer, error) { // Get uses the pointer to retrieve a value from a JSON document. // // It returns the value with its type as a [reflect.Kind] or an error. -func (p *Pointer) Get(document any) (any, reflect.Kind, error) { - return p.get(document, jsonname.DefaultJSONNameProvider) +func (p *Pointer) Get(document any, opts ...Option) (any, reflect.Kind, error) { + o := optionsWithDefaults(opts) + + return p.get(document, o.provider) } // Set uses the pointer to set a value from a data type // that represent a JSON document. // -// It returns the updated document. -func (p *Pointer) Set(document any, value any) (any, error) { - return document, p.set(document, value, jsonname.DefaultJSONNameProvider) +// # Mutation contract +// +// Set mutates the provided document in place whenever Go's type system allows +// it: when document is a map, a pointer, or when the targeted value is reached +// through an addressable ancestor (e.g. a struct field traversed via a pointer, +// a slice element). Callers that rely on this in-place behavior may continue +// to ignore the returned document. +// +// The returned document is only load-bearing when Set cannot mutate in place. +// This happens in one specific case: appending to a top-level slice passed by +// value (e.g. document of type []T rather than *[]T) via the RFC 6901 "-" +// terminal token. reflect.Append produces a new slice header that the library +// cannot rebind into the caller's variable; the updated document is returned +// instead. Pass *[]T if you want in-place rebind for that case as well. +// +// See [ErrDashToken] for the semantics of the "-" token. +func (p *Pointer) Set(document any, value any, opts ...Option) (any, error) { + o := optionsWithDefaults(opts) + + return p.set(document, value, o.provider) } // DecodedTokens returns the decoded (unescaped) tokens of this JSON pointer. @@ -109,6 +112,46 @@ func (p *Pointer) String() string { return pointerSeparator + strings.Join(p.referenceTokens, pointerSeparator) } +// Offset returns the byte offset, in the raw JSON text of document, of the +// location referenced by this pointer's terminal token. +// +// Unlike [Pointer.Get] and [Pointer.Set], which operate on a decoded Go value, +// Offset operates directly on the textual JSON source. It drives an +// [encoding/json.Decoder] over the string and stops at the terminal token, +// returning the position at which the decoder was about to read that token. +// +// It is primarily intended for tooling that needs to map a pointer back to a +// region of the original source: reporting line/column for validation or +// parse diagnostics, extracting a sub-document by slicing the raw bytes, or +// highlighting the referenced span in an editor. +// +// # Offset semantics +// +// The meaning of the returned offset depends on whether the terminal token +// addresses an object property or an array element: +// +// - Object property: the offset points to the first byte of the key (its +// opening quote character), not to the associated value. For example, +// pointer "/foo/bar" against {"foo": {"bar": 21}} returns 9, the index of +// the opening quote of "bar". +// - Array element: the offset points to the first byte of the value at that +// index. For example, pointer "/0/1" against [[1,2], [3,4]] returns 4, +// the index of the digit 2. +// +// # Errors +// +// Offset returns an error in any of these cases: +// +// - document is not syntactically valid JSON; +// - the structure of document does not match the pointer (e.g. traversing +// into a scalar, or a token that is neither a valid key nor a valid +// numeric index); +// - a referenced key or index does not exist in document; +// - the pointer's terminal token is the RFC 6901 "-" array token, which +// designates a nonexistent element and therefore has no offset in the +// source. The returned error wraps [ErrDashToken]. +// +// All errors wrap [ErrPointer]. func (p *Pointer) Offset(document string) (int64, error) { dec := json.NewDecoder(strings.NewReader(document)) var offset int64 @@ -137,7 +180,35 @@ func (p *Pointer) Offset(document string) (int64, error) { return 0, fmt.Errorf("invalid token %#v: %w", tk, ErrPointer) } } - return offset, nil + return skipJSONSeparator(document, offset), nil +} + +// skipJSONSeparator advances offset past trailing JSON whitespace and at most +// one value separator (comma) in document, so the result points at the first +// byte of the next JSON token. +// +// The streaming decoder's InputOffset sits right after the most recently +// consumed token, which between values is the comma (or whitespace) — not +// the following token. Normalizing here keeps Offset's contract uniform: +// for both object keys and array elements, and regardless of position within +// the parent container, the returned offset always points at the first byte +// of the addressed token. +func skipJSONSeparator(document string, offset int64) int64 { + n := int64(len(document)) + for offset < n && isJSONWhitespace(document[offset]) { + offset++ + } + if offset < n && document[offset] == ',' { + offset++ + } + for offset < n && isJSONWhitespace(document[offset]) { + offset++ + } + return offset +} + +func isJSONWhitespace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' } // "Constructor", parses the given string JSON pointer. @@ -157,9 +228,9 @@ func (p *Pointer) parse(jsonPointerString string) error { return nil } -func (p *Pointer) get(node any, nameProvider *jsonname.NameProvider) (any, reflect.Kind, error) { +func (p *Pointer) get(node any, nameProvider NameProvider) (any, reflect.Kind, error) { if nameProvider == nil { - nameProvider = jsonname.DefaultJSONNameProvider + nameProvider = defaultOptions.provider } kind := reflect.Invalid @@ -185,50 +256,130 @@ func (p *Pointer) get(node any, nameProvider *jsonname.NameProvider) (any, refle return node, kind, nil } -func (p *Pointer) set(node, data any, nameProvider *jsonname.NameProvider) error { +func (p *Pointer) set(node, data any, nameProvider NameProvider) (any, error) { knd := reflect.ValueOf(node).Kind() if knd != reflect.Pointer && knd != reflect.Struct && knd != reflect.Map && knd != reflect.Slice && knd != reflect.Array { - return errors.Join( + return node, errors.Join( fmt.Errorf("unexpected type: %T", node), //nolint:err113 // err wrapping is carried out by errors.Join, not fmt.Errorf. ErrUnsupportedValueType, ErrPointer, ) } - l := len(p.referenceTokens) - // full document when empty - if l == 0 { - return nil + if len(p.referenceTokens) == 0 { + return node, nil } if nameProvider == nil { - nameProvider = jsonname.DefaultJSONNameProvider + nameProvider = defaultOptions.provider } - var decodedToken string - lastIndex := l - 1 + return p.setAt(node, p.referenceTokens, data, nameProvider) +} - if lastIndex > 0 { // skip if we only have one token in pointer - for _, token := range p.referenceTokens[:lastIndex] { - decodedToken = Unescape(token) - next, err := p.resolveNodeForToken(node, decodedToken, nameProvider) - if err != nil { - return err - } +// setAt recursively walks the token list, setting the data at the terminal +// token and rebinding any new child reference (e.g. a slice header returned +// by an "-" append) into its parent on the way back up. +// +// Returning the (possibly new) node at each level is what makes append work +// at any depth without requiring the caller to pass a pointer to the +// containing slice: the new slice header propagates up and each parent +// rebinds it via the appropriate kind-specific setter. +func (p *Pointer) setAt(node any, tokens []string, data any, nameProvider NameProvider) (any, error) { + decodedToken := Unescape(tokens[0]) + + if len(tokens) == 1 { + return setSingleImpl(node, data, decodedToken, nameProvider) + } - node = next - } + child, err := p.resolveNodeForToken(node, decodedToken, nameProvider) + if err != nil { + return node, err + } + + newChild, err := p.setAt(child, tokens[1:], data, nameProvider) + if err != nil { + return node, err } - // last token - decodedToken = Unescape(p.referenceTokens[lastIndex]) + return rebindChild(node, decodedToken, newChild, nameProvider) +} + +// rebindChild writes newChild back into node at decodedToken. +// +// For cases where the child was already mutated in place (pointer aliasing, +// addressable slice elements) the rebind is a safe no-op. For cases where +// the child was returned by value (map entries holding a slice, slices +// reached through a non-addressable ancestor), the rebind propagates the +// new value into the parent. +// +// Parents implementing [JSONPointable] are left alone: they took ownership +// of the child via JSONLookup and did not opt into a JSONSet-based rebind +// on intermediate tokens. +func rebindChild(node any, decodedToken string, newChild any, nameProvider NameProvider) (any, error) { + if _, ok := node.(JSONPointable); ok { + return node, nil + } + + rValue := reflect.Indirect(reflect.ValueOf(node)) + + switch rValue.Kind() { + case reflect.Struct: + nm, ok := nameProvider.GetGoNameForType(rValue.Type(), decodedToken) + if !ok { + return node, fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) + } + fld := rValue.FieldByName(nm) + if !fld.CanSet() { + return node, nil + } + assignReflectValue(fld, newChild) + return node, nil + + case reflect.Map: + rValue.SetMapIndex(reflect.ValueOf(decodedToken), reflect.ValueOf(newChild)) + return node, nil + + case reflect.Slice: + if decodedToken == dashToken { + return node, errDashIntermediate() + } + idx, err := strconv.Atoi(decodedToken) + if err != nil { + return node, errors.Join(err, ErrPointer) + } + elem := rValue.Index(idx) + if !elem.CanSet() { + return node, nil + } + assignReflectValue(elem, newChild) + return node, nil + + default: + return node, errInvalidReference(decodedToken) + } +} - return setSingleImpl(node, data, decodedToken, nameProvider) +// assignReflectValue assigns src into dst, unwrapping a pointer when dst +// expects the pointee type. This tolerates the pointer-wrapping performed +// by [typeFromValue] for addressable fields. +func assignReflectValue(dst reflect.Value, src any) { + nv := reflect.ValueOf(src) + if !nv.IsValid() { + return + } + if nv.Type().AssignableTo(dst.Type()) { + dst.Set(nv) + return + } + if nv.Kind() == reflect.Pointer && nv.Elem().Type().AssignableTo(dst.Type()) { + dst.Set(nv.Elem()) + } } -func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvider *jsonname.NameProvider) (next any, err error) { +func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvider NameProvider) (next any, err error) { // check for nil during traversal if isNil(node) { return nil, fmt.Errorf("cannot traverse through nil value at %q: %w", decodedToken, ErrPointer) @@ -272,6 +423,9 @@ func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvide return typeFromValue(mv), nil case reflect.Slice: + if decodedToken == dashToken { + return nil, errDashIntermediate() + } tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { return nil, errors.Join(err, ErrPointer) @@ -312,16 +466,23 @@ func typeFromValue(v reflect.Value) any { } // GetForToken gets a value for a json pointer token 1 level deep. -func GetForToken(document any, decodedToken string) (any, reflect.Kind, error) { - return getSingleImpl(document, decodedToken, jsonname.DefaultJSONNameProvider) +func GetForToken(document any, decodedToken string, opts ...Option) (any, reflect.Kind, error) { + o := optionsWithDefaults(opts) + + return getSingleImpl(document, decodedToken, o.provider) } // SetForToken sets a value for a json pointer token 1 level deep. -func SetForToken(document any, decodedToken string, value any) (any, error) { - return document, setSingleImpl(document, value, decodedToken, jsonname.DefaultJSONNameProvider) +// +// See [Pointer.Set] for the mutation contract, in particular the handling of +// the RFC 6901 "-" token on slices. +func SetForToken(document any, decodedToken string, value any, opts ...Option) (any, error) { + o := optionsWithDefaults(opts) + + return setSingleImpl(document, value, decodedToken, o.provider) } -func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NameProvider) (any, reflect.Kind, error) { +func getSingleImpl(node any, decodedToken string, nameProvider NameProvider) (any, reflect.Kind, error) { rValue := reflect.Indirect(reflect.ValueOf(node)) kind := rValue.Kind() if isNil(node) { @@ -361,6 +522,9 @@ func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NamePro return nil, kind, errNoKey(decodedToken) case reflect.Slice: + if decodedToken == dashToken { + return nil, kind, errDashOnGet() + } tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { return nil, kind, errors.Join(err, ErrPointer) @@ -378,14 +542,14 @@ func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NamePro } } -func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.NameProvider) error { +func setSingleImpl(node, data any, decodedToken string, nameProvider NameProvider) (any, error) { // check for nil to prevent panic when calling rValue.Type() if isNil(node) { - return fmt.Errorf("cannot set field %q on nil value: %w", decodedToken, ErrPointer) + return node, fmt.Errorf("cannot set field %q on nil value: %w", decodedToken, ErrPointer) } if ns, ok := node.(JSONSetable); ok { - return ns.JSONSet(decodedToken, data) + return node, ns.JSONSet(decodedToken, data) } rValue := reflect.Indirect(reflect.ValueOf(node)) @@ -394,12 +558,12 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N case reflect.Struct: nm, ok := nameProvider.GetGoNameForType(rValue.Type(), decodedToken) if !ok { - return fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) + return node, fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) } fld := rValue.FieldByName(nm) if !fld.CanSet() { - return fmt.Errorf("can't set struct field %s to %v: %w", nm, data, ErrPointer) + return node, fmt.Errorf("can't set struct field %s to %v: %w", nm, data, ErrPointer) } value := reflect.ValueOf(data) @@ -407,33 +571,51 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N assignedType := fld.Type() if !valueType.AssignableTo(assignedType) { - return fmt.Errorf("can't set value with type %T to field %s with type %v: %w", data, nm, assignedType, ErrPointer) + return node, fmt.Errorf("can't set value with type %T to field %s with type %v: %w", data, nm, assignedType, ErrPointer) } fld.Set(value) - return nil + return node, nil case reflect.Map: kv := reflect.ValueOf(decodedToken) rValue.SetMapIndex(kv, reflect.ValueOf(data)) - return nil + return node, nil case reflect.Slice: + if decodedToken == dashToken { + // RFC 6901 §4 / RFC 6902 append semantics: terminal "-" appends + // the value to the slice. We rebind in place when the slice is + // reachable via an addressable ancestor; otherwise we return the + // new slice header for the parent (or the public Set) to rebind. + value := reflect.ValueOf(data) + elemType := rValue.Type().Elem() + if !value.Type().AssignableTo(elemType) { + return node, fmt.Errorf("can't append value of type %T to slice of %v: %w", data, elemType, ErrPointer) + } + newSlice := reflect.Append(rValue, value) + if rValue.CanSet() { + rValue.Set(newSlice) + return node, nil + } + return newSlice.Interface(), nil + } + tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { - return errors.Join(err, ErrPointer) + return node, errors.Join(err, ErrPointer) } sLength := rValue.Len() if tokenIndex < 0 || tokenIndex >= sLength { - return errOutOfBounds(sLength, tokenIndex) + return node, errOutOfBounds(sLength, tokenIndex) } elem := rValue.Index(tokenIndex) if !elem.CanSet() { - return fmt.Errorf("can't set slice index %s to %v: %w", decodedToken, data, ErrPointer) + return node, fmt.Errorf("can't set slice index %s to %v: %w", decodedToken, data, ErrPointer) } value := reflect.ValueOf(data) @@ -441,15 +623,15 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N assignedType := elem.Type() if !valueType.AssignableTo(assignedType) { - return fmt.Errorf("can't set value with type %T to slice element %d with type %v: %w", data, tokenIndex, assignedType, ErrPointer) + return node, fmt.Errorf("can't set value with type %T to slice element %d with type %v: %w", data, tokenIndex, assignedType, ErrPointer) } elem.Set(value) - return nil + return node, nil default: - return errInvalidReference(decodedToken) + return node, errInvalidReference(decodedToken) } } @@ -460,24 +642,27 @@ func offsetSingleObject(dec *json.Decoder, decodedToken string) (int64, error) { if err != nil { return 0, err } - switch tk := tk.(type) { - case json.Delim: - switch tk { - case '{': - if err = drainSingle(dec); err != nil { - return 0, err - } - case '[': + key, ok := tk.(string) + if !ok { + return 0, fmt.Errorf("invalid key token %#v: %w", tk, ErrPointer) + } + if key == decodedToken { + return offset, nil + } + + // Consume the associated value. Scalars are fully read by a single + // Token() call; composite values must be drained. + tk, err = dec.Token() + if err != nil { + return 0, err + } + if delim, isDelim := tk.(json.Delim); isDelim { + switch delim { + case '{', '[': if err = drainSingle(dec); err != nil { return 0, err } } - case string: - if tk == decodedToken { - return offset, nil - } - default: - return 0, fmt.Errorf("invalid token %#v: %w", tk, ErrPointer) } } @@ -485,6 +670,9 @@ func offsetSingleObject(dec *json.Decoder, decodedToken string) (int64, error) { } func offsetSingleArray(dec *json.Decoder, decodedToken string) (int64, error) { + if decodedToken == dashToken { + return 0, errDashOnOffset() + } idx, err := strconv.Atoi(decodedToken) if err != nil { return 0, fmt.Errorf("token reference %q is not a number: %w: %w", decodedToken, err, ErrPointer) diff --git a/vendor/github.com/go-openapi/jsonreference/.cliff.toml b/vendor/github.com/go-openapi/jsonreference/.cliff.toml deleted file mode 100644 index 702629f5d..000000000 --- a/vendor/github.com/go-openapi/jsonreference/.cliff.toml +++ /dev/null @@ -1,181 +0,0 @@ -# git-cliff ~ configuration file -# https://git-cliff.org/docs/configuration - -[changelog] -header = """ -""" - -footer = """ - ------ - -**[{{ remote.github.repo }}]({{ self::remote_url() }}) license terms** - -[![License][license-badge]][license-url] - -[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg -[license-url]: {{ self::remote_url() }}/?tab=Apache-2.0-1-ov-file#readme - -{%- macro remote_url() -%} - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} -{%- endmacro -%} -""" - -body = """ -{%- if version %} -## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/tree/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} -{%- else %} -## [unreleased] -{%- endif %} -{%- if message %} - {%- raw %}\n{% endraw %} -{{ message }} - {%- raw %}\n{% endraw %} -{%- endif %} -{%- if version %} - {%- if previous.version %} - -**Full Changelog**: <{{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}> - {%- endif %} -{%- else %} - {%- raw %}\n{% endraw %} -{%- endif %} - -{%- if statistics %}{% if statistics.commit_count %} - {%- raw %}\n{% endraw %} -{{ statistics.commit_count }} commits in this release. - {%- raw %}\n{% endraw %} -{%- endif %}{% endif %} ------ - -{%- for group, commits in commits | group_by(attribute="group") %} - {%- raw %}\n{% endraw %} -### {{ group | upper_first }} - {%- raw %}\n{% endraw %} - {%- for commit in commits %} - {%- if commit.remote.pr_title %} - {%- set commit_message = commit.remote.pr_title %} - {%- else %} - {%- set commit_message = commit.message %} - {%- endif %} -* {{ commit_message | split(pat="\n") | first | trim }} - {%- if commit.remote.username %} -{%- raw %} {% endraw %}by [@{{ commit.remote.username }}](https://github.com/{{ commit.remote.username }}) - {%- endif %} - {%- if commit.remote.pr_number %} -{%- raw %} {% endraw %}in [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) - {%- endif %} -{%- raw %} {% endraw %}[...]({{ self::remote_url() }}/commit/{{ commit.id }}) - {%- endfor %} -{%- endfor %} - -{%- if github %} -{%- raw %}\n{% endraw -%} - {%- set all_contributors = github.contributors | length %} - {%- if github.contributors | filter(attribute="username", value="dependabot[bot]") | length < all_contributors %} ------ - -### People who contributed to this release - {% endif %} - {%- for contributor in github.contributors | filter(attribute="username") | sort(attribute="username") %} - {%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %} -* [@{{ contributor.username }}](https://github.com/{{ contributor.username }}) - {%- endif %} - {%- endfor %} - - {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} ------ - {%- raw %}\n{% endraw %} - -### New Contributors - {%- endif %} - - {%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} - {%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %} -* @{{ contributor.username }} made their first contribution - {%- if contributor.pr_number %} - in [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ - {%- endif %} - {%- endif %} - {%- endfor %} -{%- endif %} - -{%- raw %}\n{% endraw %} - -{%- macro remote_url() -%} - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} -{%- endmacro -%} -""" -# Remove leading and trailing whitespaces from the changelog's body. -trim = true -# Render body even when there are no releases to process. -render_always = true -# An array of regex based postprocessors to modify the changelog. -postprocessors = [ - # Replace the placeholder with a URL. - #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, -] -# output file path -# output = "test.md" - -[git] -# Parse commits according to the conventional commits specification. -# See https://www.conventionalcommits.org -conventional_commits = false -# Exclude commits that do not match the conventional commits specification. -filter_unconventional = false -# Require all commits to be conventional. -# Takes precedence over filter_unconventional. -require_conventional = false -# Split commits on newlines, treating each line as an individual commit. -split_commits = false -# An array of regex based parsers to modify commit messages prior to further processing. -commit_preprocessors = [ - # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. - #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, - # Check spelling of the commit message using https://github.com/crate-ci/typos. - # If the spelling is incorrect, it will be fixed automatically. - #{ pattern = '.*', replace_command = 'typos --write-changes -' } -] -# Prevent commits that are breaking from being excluded by commit parsers. -protect_breaking_commits = false -# An array of regex based parsers for extracting data from the commit message. -# Assigns commits to groups. -# Optionally sets the commit's scope and can decide to exclude commits from further processing. -commit_parsers = [ - { message = "^[Cc]hore\\([Rr]elease\\): prepare for", skip = true }, - { message = "(^[Mm]erge)|([Mm]erge conflict)", skip = true }, - { field = "author.name", pattern = "dependabot*", group = "Updates" }, - { message = "([Ss]ecurity)|([Vv]uln)", group = "Security" }, - { body = "(.*[Ss]ecurity)|([Vv]uln)", group = "Security" }, - { message = "([Cc]hore\\(lint\\))|(style)|(lint)|(codeql)|(golangci)", group = "Code quality" }, - { message = "(^[Dd]oc)|((?i)readme)|(badge)|(typo)|(documentation)", group = "Documentation" }, - { message = "(^[Ff]eat)|(^[Ee]nhancement)", group = "Implemented enhancements" }, - { message = "(^ci)|(\\(ci\\))|(fixup\\s+ci)|(fix\\s+ci)|(license)|(example)", group = "Miscellaneous tasks" }, - { message = "^test", group = "Testing" }, - { message = "(^fix)|(panic)", group = "Fixed bugs" }, - { message = "(^refact)|(rework)", group = "Refactor" }, - { message = "(^[Pp]erf)|(performance)", group = "Performance" }, - { message = "(^[Cc]hore)", group = "Miscellaneous tasks" }, - { message = "^[Rr]evert", group = "Reverted changes" }, - { message = "(upgrade.*?go)|(go\\s+version)", group = "Updates" }, - { message = ".*", group = "Other" }, -] -# Exclude commits that are not matched by any commit parser. -filter_commits = false -# An array of link parsers for extracting external references, and turning them into URLs, using regex. -link_parsers = [] -# Include only the tags that belong to the current branch. -use_branch_tags = false -# Order releases topologically instead of chronologically. -topo_order = false -# Order releases topologically instead of chronologically. -topo_order_commits = true -# Order of commits in each group/release within the changelog. -# Allowed values: newest, oldest -sort_commits = "newest" -# Process submodules commits -recurse_submodules = false - -#[remote.github] -#owner = "go-openapi" diff --git a/vendor/github.com/go-openapi/jsonreference/.gitignore b/vendor/github.com/go-openapi/jsonreference/.gitignore index 769c24400..885dc27ab 100644 --- a/vendor/github.com/go-openapi/jsonreference/.gitignore +++ b/vendor/github.com/go-openapi/jsonreference/.gitignore @@ -1 +1,6 @@ -secrets.yml +*.out +*.cov +.idea +.env +.mcp.json +.claude/ diff --git a/vendor/github.com/go-openapi/jsonreference/.golangci.yml b/vendor/github.com/go-openapi/jsonreference/.golangci.yml index fdae591bc..dc7c96053 100644 --- a/vendor/github.com/go-openapi/jsonreference/.golangci.yml +++ b/vendor/github.com/go-openapi/jsonreference/.golangci.yml @@ -12,6 +12,7 @@ linters: - paralleltest - recvcheck - testpackage + - thelper - tparallel - varnamelen - whitespace diff --git a/vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md b/vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md index 9322b065e..bac878f21 100644 --- a/vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md +++ b/vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md @@ -23,7 +23,9 @@ include: Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or + advances + * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic @@ -55,7 +57,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at ivan+abuse@flanders.co.nz. All +reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. @@ -68,7 +70,7 @@ members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] +available at [][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/vendor/github.com/go-openapi/jsonreference/CONTRIBUTORS.md b/vendor/github.com/go-openapi/jsonreference/CONTRIBUTORS.md index 9907d5d21..7faeb83a7 100644 --- a/vendor/github.com/go-openapi/jsonreference/CONTRIBUTORS.md +++ b/vendor/github.com/go-openapi/jsonreference/CONTRIBUTORS.md @@ -4,11 +4,11 @@ | Total Contributors | Total Contributions | | --- | --- | -| 9 | 68 | +| 9 | 73 | | Username | All Time Contribution Count | All Commits | | --- | --- | --- | -| @fredbi | 31 | https://github.com/go-openapi/jsonreference/commits?author=fredbi | +| @fredbi | 36 | https://github.com/go-openapi/jsonreference/commits?author=fredbi | | @casualjim | 25 | https://github.com/go-openapi/jsonreference/commits?author=casualjim | | @youyuanwu | 5 | https://github.com/go-openapi/jsonreference/commits?author=youyuanwu | | @olivierlemasle | 2 | https://github.com/go-openapi/jsonreference/commits?author=olivierlemasle | diff --git a/vendor/github.com/go-openapi/jsonreference/NOTICE b/vendor/github.com/go-openapi/jsonreference/NOTICE index f3b51939a..814e87ef8 100644 --- a/vendor/github.com/go-openapi/jsonreference/NOTICE +++ b/vendor/github.com/go-openapi/jsonreference/NOTICE @@ -3,7 +3,7 @@ Copyright 2015-2025 go-swagger maintainers // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -This software library, github.com/go-openapi/jsonpointer, includes software developed +This software library, github.com/go-openapi/jsonreference, includes software developed by the go-swagger and go-openapi maintainers ("go-swagger maintainers"). Licensed under the Apache License, Version 2.0 (the "License"); @@ -18,7 +18,7 @@ It ships with copies of other software which license terms are recalled below. The original software was authored on 25-02-2013 by sigu-399 (https://github.com/sigu-399, sigu.399@gmail.com). -github.com/sigh-399/jsonpointer +github.com/sigh-399/jsonreference =========================== // SPDX-FileCopyrightText: Copyright 2013 sigu-399 ( https://github.com/sigu-399 ) diff --git a/vendor/github.com/go-openapi/jsonreference/README.md b/vendor/github.com/go-openapi/jsonreference/README.md index d479dbdc7..adea16061 100644 --- a/vendor/github.com/go-openapi/jsonreference/README.md +++ b/vendor/github.com/go-openapi/jsonreference/README.md @@ -8,12 +8,22 @@ [![Release][release-badge]][release-url] [![Go Report Card][gocard-badge]][gocard-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url] -[![GoDoc][godoc-badge]][godoc-url] [![Slack Channel][slack-logo]![slack-badge]][slack-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] +[![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] --- An implementation of JSON Reference for golang. +## Announcements + +* **2025-12-19** : new community chat on discord + * a new discord community channel is available to be notified of changes and support users + * our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31** + +You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url] + +Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url] + ## Status API is stable. @@ -26,18 +36,33 @@ go get github.com/go-openapi/jsonreference ## Dependencies -* https://github.com/go-openapi/jsonpointer +* ## Basic usage +```go +// Creating a new reference +ref, err := jsonreference.New("http://example.com/doc.json#/definitions/Pet") + +// Fragment-only reference +fragRef := jsonreference.MustCreateRef("#/definitions/Pet") + +// Resolving references +parent, _ := jsonreference.New("http://example.com/base.json") +child, _ := jsonreference.New("#/definitions/Pet") +resolved, _ := parent.Inherits(child) +// Result: "http://example.com/base.json#/definitions/Pet" +``` + + ## Change log See ## References -* http://tools.ietf.org/html/draft-ietf-appsawg-json-pointer-07 -* http://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03 +* +* ## Licensing @@ -89,6 +114,9 @@ Maintainers can cut a new release by either: [slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png [slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM [slack-url]: https://goswagger.slack.com/archives/C04R30YMU +[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue +[discord-url]: https://discord.gg/twZ9BwT3 + [license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg [license-url]: https://github.com/go-openapi/jsonreference/?tab=Apache-2.0-1-ov-file#readme diff --git a/vendor/github.com/go-openapi/jsonreference/SECURITY.md b/vendor/github.com/go-openapi/jsonreference/SECURITY.md index 2a7b6f091..1fea2c573 100644 --- a/vendor/github.com/go-openapi/jsonreference/SECURITY.md +++ b/vendor/github.com/go-openapi/jsonreference/SECURITY.md @@ -6,14 +6,32 @@ This policy outlines the commitment and practices of the go-openapi maintainers | Version | Supported | | ------- | ------------------ | -| 0.22.x | :white_check_mark: | +| O.x | :white_check_mark: | + +## Vulnerability checks in place + +This repository uses automated vulnerability scans, at every merged commit and at least once a week. + +We use: + +* [`GitHub CodeQL`][codeql-url] +* [`trivy`][trivy-url] +* [`govulncheck`][govulncheck-url] + +Reports are centralized in github security reports and visible only to the maintainers. ## Reporting a vulnerability If you become aware of a security vulnerability that affects the current repository, -please report it privately to the maintainers. +**please report it privately to the maintainers** +rather than opening a publicly visible GitHub issue. + +Please follow the instructions provided by github to [Privately report a security vulnerability][github-guidance-url]. -Please follow the instructions provided by github to -[Privately report a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability). +> [!NOTE] +> On Github, navigate to the project's "Security" tab then click on "Report a vulnerability". -TL;DR: on Github, navigate to the project's "Security" tab then click on "Report a vulnerability". +[codeql-url]: https://github.com/github/codeql +[trivy-url]: https://trivy.dev/docs/latest/getting-started +[govulncheck-url]: https://go.dev/blog/govulncheck +[github-guidance-url]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability diff --git a/vendor/github.com/go-openapi/jsonreference/reference.go b/vendor/github.com/go-openapi/jsonreference/reference.go index 6e3ae4995..003ba7a83 100644 --- a/vendor/github.com/go-openapi/jsonreference/reference.go +++ b/vendor/github.com/go-openapi/jsonreference/reference.go @@ -16,6 +16,7 @@ const ( fragmentRune = `#` ) +// ErrChildURL is raised when there is no child. var ErrChildURL = errors.New("child url is nil") // Ref represents a json reference object. diff --git a/vendor/github.com/go-openapi/swag/.gitignore b/vendor/github.com/go-openapi/swag/.gitignore index c4b1b64f0..1680db44c 100644 --- a/vendor/github.com/go-openapi/swag/.gitignore +++ b/vendor/github.com/go-openapi/swag/.gitignore @@ -3,3 +3,4 @@ vendor Godeps .idea *.out +.mcp.json diff --git a/vendor/github.com/go-openapi/swag/CODE_OF_CONDUCT.md b/vendor/github.com/go-openapi/swag/CODE_OF_CONDUCT.md index 9322b065e..bac878f21 100644 --- a/vendor/github.com/go-openapi/swag/CODE_OF_CONDUCT.md +++ b/vendor/github.com/go-openapi/swag/CODE_OF_CONDUCT.md @@ -23,7 +23,9 @@ include: Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or + advances + * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic @@ -55,7 +57,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at ivan+abuse@flanders.co.nz. All +reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. @@ -68,7 +70,7 @@ members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] +available at [][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/vendor/github.com/go-openapi/swag/CONTRIBUTORS.md b/vendor/github.com/go-openapi/swag/CONTRIBUTORS.md new file mode 100644 index 000000000..286878acf --- /dev/null +++ b/vendor/github.com/go-openapi/swag/CONTRIBUTORS.md @@ -0,0 +1,36 @@ +# Contributors + +- Repository: ['go-openapi/swag'] + +| Total Contributors | Total Contributions | +| --- | --- | +| 24 | 242 | + +| Username | All Time Contribution Count | All Commits | +| --- | --- | --- | +| @fredbi | 112 | | +| @casualjim | 98 | | +| @alexandear | 4 | | +| @orisano | 3 | | +| @reinerRubin | 2 | | +| @n-inja | 2 | | +| @nitinmohan87 | 2 | | +| @Neo2308 | 2 | | +| @michaelbowler-form3 | 2 | | +| @ujjwalsh | 1 | | +| @griffin-stewie | 1 | | +| @POD666 | 1 | | +| @pytlesk4 | 1 | | +| @shirou | 1 | | +| @seanprince | 1 | | +| @petrkotas | 1 | | +| @mszczygiel | 1 | | +| @sosiska | 1 | | +| @kzys | 1 | | +| @faguirre1 | 1 | | +| @posener | 1 | | +| @diego-fu-hs | 1 | | +| @davidalpert | 1 | | +| @Xe | 1 | | + + _this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_ diff --git a/vendor/github.com/go-openapi/swag/README.md b/vendor/github.com/go-openapi/swag/README.md index 371fd55fd..64f667103 100644 --- a/vendor/github.com/go-openapi/swag/README.md +++ b/vendor/github.com/go-openapi/swag/README.md @@ -1,26 +1,60 @@ -# Swag [![Build Status](https://github.com/go-openapi/swag/actions/workflows/go-test.yml/badge.svg)](https://github.com/go-openapi/swag/actions?query=workflow%3A"go+test") [![codecov](https://codecov.io/gh/go-openapi/swag/branch/master/graph/badge.svg)](https://codecov.io/gh/go-openapi/swag) +# Swag + + +[![Tests][test-badge]][test-url] [![Coverage][cov-badge]][cov-url] [![CI vuln scan][vuln-scan-badge]][vuln-scan-url] [![CodeQL][codeql-badge]][codeql-url] + + + +[![Release][release-badge]][release-url] [![Go Report Card][gocard-badge]][gocard-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url] + + +[![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] -[![Slack Status](https://slackin.goswagger.io/badge.svg)](https://slackin.goswagger.io) -[![license](https://img.shields.io/badge/license-Apache%20v2-orange.svg)](https://raw.githubusercontent.com/go-openapi/swag/master/LICENSE) -[![Go Reference](https://pkg.go.dev/badge/github.com/go-openapi/swag.svg)](https://pkg.go.dev/github.com/go-openapi/swag) -[![Go Report Card](https://goreportcard.com/badge/github.com/go-openapi/swag)](https://goreportcard.com/report/github.com/go-openapi/swag) +--- -Package `swag` contains a bunch of helper functions for go-openapi and go-swagger projects. +A bunch of helper functions for go-openapi and go-swagger projects. You may also use it standalone for your projects. > **NOTE** > `swag` is one of the foundational building blocks of the go-openapi initiative. +> > Most repositories in `github.com/go-openapi/...` depend on it in some way. > And so does our CLI tool `github.com/go-swagger/go-swagger`, > as well as the code generated by this tool. * [Contents](#contents) * [Dependencies](#dependencies) -* [Release Notes](#release-notes) +* [Change log](#change-log) * [Licensing](#licensing) * [Note to contributors](#note-to-contributors) -* [TODOs, suggestions and plans](#todos-suggestions-and-plans) +* [Roadmap](#roadmap) + +## Announcements + +* **2025-12-19** : new community chat on discord + * a new discord community channel is available to be notified of changes and support users + * our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31** + +You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url] + +Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url] + +## Status + +API is stable. + +## Import this library in your project + +```cmd +go get github.com/go-openapi/swag/{module} +``` + +Or for backward compatibility: + +```cmd +go get github.com/go-openapi/swag +``` ## Contents @@ -36,7 +70,7 @@ Child modules will continue to evolve and some new ones may be added in the futu | `cmdutils` | utilities to work with CLIs || | `conv` | type conversion utilities | convert between values and pointers for any types
convert from string to builtin types (wraps `strconv`)
require `./typeutils` (test dependency)
| | `fileutils` | file utilities | | -| `jsonname` | JSON utilities | infer JSON names from `go` properties
| +| `jsonname` | JSON utilities | infer JSON names from `go` properties
| | `jsonutils` | JSON utilities | fast json concatenation
read and write JSON from and to dynamic `go` data structures
~require `github.com/mailru/easyjson`~
| | `loading` | file loading | load from file or http
require `./yamlutils`
| | `mangling` | safe name generation | name mangling for `go`
| @@ -49,84 +83,19 @@ Child modules will continue to evolve and some new ones may be added in the futu ## Dependencies -The root module `github.com/go-openapi/swag` at the repo level maintains a few +The root module `github.com/go-openapi/swag` at the repo level maintains a few dependencies outside of the standard library. * YAML utilities depend on `go.yaml.in/yaml/v3` * JSON utilities depend on their registered adapter module: - * by default, only the standard library is used - * `github.com/mailru/easyjson` is now only a dependency for module - `github.com/go-openapi/swag/jsonutils/adapters/easyjson/json`, - for users willing to import that module. - * integration tests and benchmarks use all the dependencies are published as their own module + * by default, only the standard library is used + * `github.com/mailru/easyjson` is now only a dependency for module + `github.com/go-openapi/swag/jsonutils/adapters/easyjson/json`, + for users willing to import that module. + * integration tests and benchmarks use all the dependencies are published as their own module * other dependencies are test dependencies drawn from `github.com/stretchr/testify` -## Release notes - -### v0.25.4 - -** mangling** - -Bug fix - -* [x] mangler may panic with pluralized overlapping initialisms - -Tests - -* [x] introduced fuzz tests - -### v0.25.3 - -** mangling** - -Bug fix - -* [x] mangler may panic with pluralized initialisms - -### v0.25.2 - -Minor changes due to internal maintenance that don't affect the behavior of the library. - -* [x] removed indirect test dependencies by switching all tests to `go-openapi/testify`, - a fork of `stretch/testify` with zero-dependencies. -* [x] improvements to CI to catch test reports. -* [x] modernized licensing annotations in source code, using the more compact SPDX annotations - rather than the full license terms. -* [x] simplified a bit JSON & YAML testing by using newly available assertions -* started the journey to an OpenSSF score card badge: - * [x] explicited permissions in CI workflows - * [x] published security policy - * pinned dependencies to github actions - * introduced fuzzing in tests - -### v0.25.1 - -* fixes a data race that could occur when using the standard library implementation of a JSON ordered map - -### v0.25.0 - -**New with this release**: - -* requires `go1.24`, as iterators are being introduced -* removes the dependency to `mailru/easyjson` by default (#68) - * functionality remains the same, but performance may somewhat degrade for applications - that relied on `easyjson` - * users of the JSON or YAML utilities who want to use `easyjson` as their preferred JSON serializer library - will be able to do so by registering this the corresponding JSON adapter at runtime. See below. - * ordered keys in JSON and YAML objects: this feature used to rely solely on `easyjson`. - With this release, an implementation relying on the standard `encoding/json` is provided. - * an independent [benchmark](./jsonutils/adapters/testintegration/benchmarks/README.md) to compare the different adapters -* improves the "float is integer" check (`conv.IsFloat64AJSONInteger`) (#59) -* removes the _direct_ dependency to `gopkg.in/yaml.v3` (indirect dependency is still incurred through `stretchr/testify`) (#127) -* exposed `conv.IsNil()` (previously kept private): a safe nil check (accounting for the "non-nil interface with nil value" nonsensical go trick) - -**What coming next?** - -Moving forward, we want to : -* provide an implementation of the JSON adapter based on `encoding/json/v2`, for `go1.25` builds. -* provide similar implementations for `goccy/go-json` and `jsoniterator/go`, and perhaps some other - similar libraries may be interesting too. - +## Usage **How to explicitly register a dependency at runtime**? @@ -150,90 +119,106 @@ or fallback to the standard library. For more details, you may also look at our [integration tests](jsonutils/adapters/testintegration/integration_suite_test.go#29). -### v0.24.0 +--- -With this release, we have largely modernized the API of `swag`: +## Note to contributors -* The traditional `swag` API is still supported: code that imports `swag` will still - compile and work the same. -* A deprecation notice is published to encourage consumers of this library to adopt - the newer API -* **Deprecation notice** - * configuration through global variables is now deprecated, in favor of options passed as parameters - * all helper functions are moved to more specialized packages, which are exposed as - go modules. Importing such a module would reduce the footprint of dependencies. - * _all_ functions, variables, constants exposed by the deprecated API have now moved, so - that consumers of the new API no longer need to import github.com/go-openapi/swag, but - should import the desired sub-module(s). +All kinds of contributions are welcome. -**New with this release**: +This repo is a go mono-repo. See [docs](docs/MAINTAINERS.md). -* [x] type converters and pointer to value helpers now support generic types -* [x] name mangling now support pluralized initialisms (issue #46) - Strings like "contact IDs" are now recognized as such a plural form and mangled as a linter would expect. -* [x] performance: small improvements to reduce the overhead of convert/format wrappers (see issues #110, or PR #108) -* [x] performance: name mangling utilities run ~ 10% faster (PR #115) +More general guidelines are available [here](.github/CONTRIBUTING.md). ---- +## Roadmap -## Licensing +See the current [TODO list](docs/TODOS.md) -This library ships under the [SPDX-License-Identifier: Apache-2.0](./LICENSE). +## Change log -## Note to contributors +See -A mono-repo structure comes with some unavoidable extra pains... +For pre-v0.26.0 releases, see [release notes](./docs/NOTES.md). -* Testing +**What coming next?** -> The usual `go test ./...` command, run from the root of this repo won't work any longer to test all submodules. -> -> Each module constitutes an independant unit of test. So you have to run `go test` inside each module. -> Or you may take a look at how this is achieved by CI -> [here] https://github.com/go-openapi/swag/blob/master/.github/workflows/go-test.yml). -> -> There are also some alternative tricks using `go work`, for local development, if you feel comfortable with -> go workspaces. Perhaps some day, we'll have a `go work test` to run all tests without any hack. +Moving forward, we want to : -* Releasing +* provide an implementation of the JSON adapter based on `encoding/json/v2`, for `go1.25` builds. +* provide similar implementations for `goccy/go-json` and `jsoniterator/go`, and perhaps some other + similar libraries may be interesting too. -> Each module follows its own independant module versioning. -> -> So you have tags like `mangling/v0.24.0`, `fileutils/v0.24.0` etc that are used by `go mod` and `go get` -> to refer to the tagged version of each module specifically. -> -> This means we may release patches etc to each module independently. -> -> We'd like to adopt the rule that modules in this repo would only differ by a patch version -> (e.g. `v0.24.5` vs `v0.24.3`), and we'll level all modules whenever a minor version is introduced. -> -> A script in `./hack` is provided to tag all modules with the same version in one go. + -## Todos, suggestions and plans +## Licensing -All kinds of contributions are welcome. +This library ships under the [SPDX-License-Identifier: Apache-2.0](./LICENSE). -A few ideas: - -* [x] Complete the split of dependencies to isolate easyjson from the rest -* [x] Improve CI to reduce needed tests -* [x] Replace dependency to `gopkg.in/yaml.v3` (`yamlutil`) -* [ ] Improve mangling utilities (improve readability, support for capitalized words, - better word substitution for non-letter symbols...) -* [ ] Move back to this common shared pot a few of the technical features introduced by go-swagger independently - (e.g. mangle go package names, search package with go modules support, ...) -* [ ] Apply a similar mono-repo approach to go-openapi/strfmt which suffer from similar woes: bloated API, - imposed dependency to some database driver. -* [ ] Adapt `go-swagger` (incl. generated code) to the new `swag` API. -* [ ] Factorize some tests, as there is a lot of redundant testing code in `jsonutils` -* [ ] Benchmark & profiling: publish independently the tool built to analyze and chart benchmarks (e.g. similar to `benchvisual`) -* [ ] more thorough testing for nil / null case -* [ ] ci pipeline to manage releases -* [ ] cleaner mockery generation (doesn't work out of the box for all sub-modules) + + + + +## Other documentation + +* [All-time contributors](./CONTRIBUTORS.md) +* [Contributing guidelines](.github/CONTRIBUTING.md) +* [Maintainers documentation](docs/MAINTAINERS.md) +* [Code style](docs/STYLE.md) + +## Cutting a new release + +Maintainers can cut a new release by either: + +* running [this workflow](https://github.com/go-openapi/swag/actions/workflows/bump-release.yml) +* or pushing a semver tag + * signed tags are preferred + * The tag message is prepended to release notes + + +[test-badge]: https://github.com/go-openapi/swag/actions/workflows/go-test.yml/badge.svg +[test-url]: https://github.com/go-openapi/swag/actions/workflows/go-test.yml +[cov-badge]: https://codecov.io/gh/go-openapi/swag/branch/master/graph/badge.svg +[cov-url]: https://codecov.io/gh/go-openapi/swag +[vuln-scan-badge]: https://github.com/go-openapi/swag/actions/workflows/scanner.yml/badge.svg +[vuln-scan-url]: https://github.com/go-openapi/swag/actions/workflows/scanner.yml +[codeql-badge]: https://github.com/go-openapi/swag/actions/workflows/codeql.yml/badge.svg +[codeql-url]: https://github.com/go-openapi/swag/actions/workflows/codeql.yml + +[release-badge]: https://badge.fury.io/gh/go-openapi%2Fswag.svg +[release-url]: https://badge.fury.io/gh/go-openapi%2Fswag +[gomod-badge]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fswag.svg +[gomod-url]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fswag + +[gocard-badge]: https://goreportcard.com/badge/github.com/go-openapi/swag +[gocard-url]: https://goreportcard.com/report/github.com/go-openapi/swag +[codefactor-badge]: https://img.shields.io/codefactor/grade/github/go-openapi/swag +[codefactor-url]: https://www.codefactor.io/repository/github/go-openapi/swag + +[doc-badge]: https://img.shields.io/badge/doc-site-blue?link=https%3A%2F%2Fgoswagger.io%2Fgo-openapi%2F +[doc-url]: https://goswagger.io/go-openapi +[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/swag +[godoc-url]: http://pkg.go.dev/github.com/go-openapi/swag +[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png +[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM +[slack-url]: https://goswagger.slack.com/archives/C04R30YMU +[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue +[discord-url]: https://discord.gg/FfnFYaC3k5 + + +[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg +[license-url]: https://github.com/go-openapi/swag/?tab=Apache-2.0-1-ov-file#readme + +[goversion-badge]: https://img.shields.io/github/go-mod/go-version/go-openapi/swag +[goversion-url]: https://github.com/go-openapi/swag/blob/master/go.mod +[top-badge]: https://img.shields.io/github/languages/top/go-openapi/swag +[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/swag/latest diff --git a/vendor/github.com/go-openapi/swag/SECURITY.md b/vendor/github.com/go-openapi/swag/SECURITY.md index 72296a831..1fea2c573 100644 --- a/vendor/github.com/go-openapi/swag/SECURITY.md +++ b/vendor/github.com/go-openapi/swag/SECURITY.md @@ -6,14 +6,32 @@ This policy outlines the commitment and practices of the go-openapi maintainers | Version | Supported | | ------- | ------------------ | -| 0.25.x | :white_check_mark: | +| O.x | :white_check_mark: | + +## Vulnerability checks in place + +This repository uses automated vulnerability scans, at every merged commit and at least once a week. + +We use: + +* [`GitHub CodeQL`][codeql-url] +* [`trivy`][trivy-url] +* [`govulncheck`][govulncheck-url] + +Reports are centralized in github security reports and visible only to the maintainers. ## Reporting a vulnerability If you become aware of a security vulnerability that affects the current repository, -please report it privately to the maintainers. +**please report it privately to the maintainers** +rather than opening a publicly visible GitHub issue. + +Please follow the instructions provided by github to [Privately report a security vulnerability][github-guidance-url]. -Please follow the instructions provided by github to -[Privately report a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability). +> [!NOTE] +> On Github, navigate to the project's "Security" tab then click on "Report a vulnerability". -TL;DR: on Github, navigate to the project's "Security" tab then click on "Report a vulnerability". +[codeql-url]: https://github.com/github/codeql +[trivy-url]: https://trivy.dev/docs/latest/getting-started +[govulncheck-url]: https://go.dev/blog/govulncheck +[github-guidance-url]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability diff --git a/vendor/github.com/go-openapi/swag/go.work b/vendor/github.com/go-openapi/swag/go.work index 1e537f074..8537cb2a7 100644 --- a/vendor/github.com/go-openapi/swag/go.work +++ b/vendor/github.com/go-openapi/swag/go.work @@ -17,4 +17,4 @@ use ( ./yamlutils ) -go 1.24.0 +go 1.25.0 diff --git a/vendor/github.com/go-openapi/swag/jsonname/go_name_provider.go b/vendor/github.com/go-openapi/swag/jsonname/go_name_provider.go new file mode 100644 index 000000000..adc442687 --- /dev/null +++ b/vendor/github.com/go-openapi/swag/jsonname/go_name_provider.go @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package jsonname + +import ( + "reflect" + "strings" + "sync" +) + +var _ providerIface = (*GoNameProvider)(nil) + +// GoNameProvider resolves json property names to go struct field names following +// the same rules as the standard library's [encoding/json] package. +// +// Contrary to [NameProvider], it considers exported fields without a json tag, +// and promotes fields from anonymous embedded struct types. +// +// Rules (aligned with encoding/json): +// +// - unexported fields are ignored; +// - a field tagged `json:"-"` is ignored; +// - a field tagged `json:"-,"` is kept under the json name "-" (stdlib quirk); +// - a field tagged `json:""` or with no json tag at all keeps its Go name as json name; +// - anonymous struct fields without an explicit json tag have their fields +// promoted into the parent, following breadth-first depth rules: +// a shallower field wins over a deeper one; at equal depth, a conflict +// discards all conflicting fields unless exactly one has an explicit json tag. +// +// This type is safe for concurrent use. +type GoNameProvider struct { + lock sync.Mutex + index map[reflect.Type]nameIndex +} + +// NewGoNameProvider creates a new [GoNameProvider]. +func NewGoNameProvider() *GoNameProvider { + return &GoNameProvider{ + index: make(map[reflect.Type]nameIndex), + } +} + +// GetJSONNames gets all the json property names for a type. +func (n *GoNameProvider) GetJSONNames(subject any) []string { + n.lock.Lock() + defer n.lock.Unlock() + + tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() + names := n.nameIndexFor(tpe) + + res := make([]string, 0, len(names.jsonNames)) + for k := range names.jsonNames { + res = append(res, k) + } + + return res +} + +// GetJSONName gets the json name for a go property name. +func (n *GoNameProvider) GetJSONName(subject any, name string) (string, bool) { + tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() + + return n.GetJSONNameForType(tpe, name) +} + +// GetJSONNameForType gets the json name for a go property name on a given type. +func (n *GoNameProvider) GetJSONNameForType(tpe reflect.Type, name string) (string, bool) { + n.lock.Lock() + defer n.lock.Unlock() + + names := n.nameIndexFor(tpe) + nme, ok := names.goNames[name] + + return nme, ok +} + +// GetGoName gets the go name for a json property name. +func (n *GoNameProvider) GetGoName(subject any, name string) (string, bool) { + tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() + + return n.GetGoNameForType(tpe, name) +} + +// GetGoNameForType gets the go name for a given type for a json property name. +func (n *GoNameProvider) GetGoNameForType(tpe reflect.Type, name string) (string, bool) { + n.lock.Lock() + defer n.lock.Unlock() + + names := n.nameIndexFor(tpe) + nme, ok := names.jsonNames[name] + + return nme, ok +} + +func (n *GoNameProvider) nameIndexFor(tpe reflect.Type) nameIndex { + if names, ok := n.index[tpe]; ok { + return names + } + + names := buildGoNameIndex(tpe) + n.index[tpe] = names + + return names +} + +// fieldEntry captures a candidate field discovered while walking a struct +// along with the indirection path from the root type (used to resolve conflicts +// by depth in the same way encoding/json does). +type fieldEntry struct { + goName string + jsonName string + index []int + tagged bool +} + +func buildGoNameIndex(tpe reflect.Type) nameIndex { + fields := collectGoFields(tpe) + + idx := make(map[string]string, len(fields)) + reverseIdx := make(map[string]string, len(fields)) + for _, f := range fields { + idx[f.jsonName] = f.goName + reverseIdx[f.goName] = f.jsonName + } + + return nameIndex{jsonNames: idx, goNames: reverseIdx} +} + +// collectGoFields walks tpe breadth-first along anonymous struct fields, +// reproducing the field selection performed by encoding/json.typeFields. +func collectGoFields(tpe reflect.Type) []fieldEntry { + if tpe.Kind() != reflect.Struct { + return nil + } + + type queued struct { + typ reflect.Type + index []int + } + + current := []queued{} + next := []queued{{typ: tpe}} + visited := map[reflect.Type]bool{tpe: true} + + var ( + candidates []fieldEntry + count = map[string]int{} + nextCount = map[string]int{} + ) + + for len(next) > 0 { + current, next = next, current[:0] + count, nextCount = nextCount, count + for k := range nextCount { + delete(nextCount, k) + } + + for _, q := range current { + for i := 0; i < q.typ.NumField(); i++ { + sf := q.typ.Field(i) + + if sf.Anonymous { + ft := sf.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if !sf.IsExported() && ft.Kind() != reflect.Struct { + continue + } + } else if !sf.IsExported() { + continue + } + + tag := sf.Tag.Get("json") + if tag == "-" { + continue + } + jsonName, _ := parseJSONTag(tag) + tagged := jsonName != "" + + ft := sf.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + + if sf.Anonymous && ft.Kind() == reflect.Struct && !tagged { + if visited[ft] { + continue + } + visited[ft] = true + + index := make([]int, len(q.index)+1) + copy(index, q.index) + index[len(q.index)] = i + next = append(next, queued{typ: ft, index: index}) + + continue + } + + name := jsonName + if name == "" { + name = sf.Name + } + + index := make([]int, len(q.index)+1) + copy(index, q.index) + index[len(q.index)] = i + + candidates = append(candidates, fieldEntry{ + goName: sf.Name, + jsonName: name, + index: index, + tagged: tagged, + }) + nextCount[name]++ + } + } + } + + return dominantFields(candidates) +} + +// dominantFields applies the Go encoding/json conflict resolution rules: +// at each JSON name, the shallowest field wins; at equal depth, a uniquely +// tagged candidate wins; otherwise all candidates for that name are dropped. +func dominantFields(candidates []fieldEntry) []fieldEntry { + byName := make(map[string][]fieldEntry, len(candidates)) + for _, c := range candidates { + byName[c.jsonName] = append(byName[c.jsonName], c) + } + + out := make([]fieldEntry, 0, len(byName)) + for _, group := range byName { + if len(group) == 1 { + out = append(out, group[0]) + + continue + } + + minDepth := len(group[0].index) + for _, c := range group[1:] { + if len(c.index) < minDepth { + minDepth = len(c.index) + } + } + + var shallow []fieldEntry + for _, c := range group { + if len(c.index) == minDepth { + shallow = append(shallow, c) + } + } + + if len(shallow) == 1 { + out = append(out, shallow[0]) + + continue + } + + var tagged []fieldEntry + for _, c := range shallow { + if c.tagged { + tagged = append(tagged, c) + } + } + if len(tagged) == 1 { + out = append(out, tagged[0]) + } + } + + return out +} + +// parseJSONTag returns the name component of a json struct tag and whether +// it carried any non-name option (kept for future-proofing, e.g. "omitempty"). +func parseJSONTag(tag string) (string, string) { + if tag == "" { + return "", "" + } + if idx := strings.IndexByte(tag, ','); idx >= 0 { + return tag[:idx], tag[idx+1:] + } + + return tag, "" +} diff --git a/vendor/github.com/go-openapi/swag/jsonname/ifaces.go b/vendor/github.com/go-openapi/swag/jsonname/ifaces.go new file mode 100644 index 000000000..812ace563 --- /dev/null +++ b/vendor/github.com/go-openapi/swag/jsonname/ifaces.go @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package jsonname + +import "reflect" + +// providerIface is an unexported compile-time contract that every name provider +// in this package is expected to satisfy. +// It mirrors the interface declared by the main consumer of this module: [github.com/go-openapi/jsonpointer.NameProvider]. +type providerIface interface { + GetGoName(subject any, name string) (string, bool) + GetGoNameForType(tpe reflect.Type, name string) (string, bool) +} diff --git a/vendor/github.com/go-openapi/swag/jsonname/name_provider.go b/vendor/github.com/go-openapi/swag/jsonname/name_provider.go index 8eaf1bece..9f5da7a01 100644 --- a/vendor/github.com/go-openapi/swag/jsonname/name_provider.go +++ b/vendor/github.com/go-openapi/swag/jsonname/name_provider.go @@ -12,6 +12,8 @@ import ( // DefaultJSONNameProvider is the default cache for types. var DefaultJSONNameProvider = NewNameProvider() +var _ providerIface = (*NameProvider)(nil) + // NameProvider represents an object capable of translating from go property names // to json property names. // diff --git a/vendor/github.com/go-openapi/swag/jsonutils/README.md b/vendor/github.com/go-openapi/swag/jsonutils/README.md index d745cdb46..07a2ca1d7 100644 --- a/vendor/github.com/go-openapi/swag/jsonutils/README.md +++ b/vendor/github.com/go-openapi/swag/jsonutils/README.md @@ -1,11 +1,11 @@ - # jsonutils +# jsonutils `jsonutils` exposes a few tools to work with JSON: - a fast, simple `Concat` to concatenate (not merge) JSON objects and arrays - `FromDynamicJSON` to convert a data structure into a "dynamic JSON" data structure - `ReadJSON` and `WriteJSON` behave like `json.Unmarshal` and `json.Marshal`, - with the ability to use another underlying serialization library through an `Adapter` + with the ability to use another underlying serialization library through an `Adapter` configured at runtime - a `JSONMapSlice` structure that may be used to store JSON objects with the order of keys maintained @@ -64,7 +64,7 @@ find a registered implementation that support ordered keys in objects. Our standard library implementation supports this. As of `v0.25.0`, we support through such an adapter the popular `mailru/easyjson` -library, which kicks in when the passed values support the `easyjson.Unmarshaler` +library, which kicks in when the passed values support the `easyjson.Unmarshaler` or `easyjson.Marshaler` interfaces. In the future, we plan to add more similar libraries that compete on the go JSON @@ -77,8 +77,9 @@ In package `github.com/go-openapi/swag/easyjson/adapters`, several adapters are Each adapter is an independent go module. Hence you'll pick its dependencies only if you import it. At this moment we provide: -* `stdlib`: JSON adapter based on the standard library -* `easyjson`: JSON adapter based on the `github.com/mailru/easyjson` + +- `stdlib`: JSON adapter based on the standard library +- `easyjson`: JSON adapter based on the `github.com/mailru/easyjson` The adapters provide the basic `Marshal` and `Unmarshal` capabilities, plus an implementation of the `MapSlice` pattern. diff --git a/vendor/github.com/go-openapi/swag/mangling/BENCHMARK.md b/vendor/github.com/go-openapi/swag/mangling/BENCHMARK.md index 6674c63b7..abe6e9533 100644 --- a/vendor/github.com/go-openapi/swag/mangling/BENCHMARK.md +++ b/vendor/github.com/go-openapi/swag/mangling/BENCHMARK.md @@ -4,7 +4,7 @@ go test -bench XXX -run XXX -benchtime 30s ``` -## Benchmarks at b3e7a5386f996177e4808f11acb2aa93a0f660df +## Benchmarks at `b3e7a5386f996177e4808f11acb2aa93a0f660df` ``` goos: linux @@ -49,7 +49,7 @@ BenchmarkToXXXName/ToHumanNameLower-16 18599661 1946 ns/op 92 B/op BenchmarkToXXXName/ToHumanNameTitle-16 17581353 2054 ns/op 105 B/op 6 allocs/op ``` -## Benchmarks at d7d2d1b895f5b6747afaff312dd2a402e69e818b +## Benchmarks at `d7d2d1b895f5b6747afaff312dd2a402e69e818b` go1.24 diff --git a/vendor/github.com/google/gnostic-models/extensions/extension.proto b/vendor/github.com/google/gnostic-models/extensions/extension.proto index 875137c1a..a60042989 100644 --- a/vendor/github.com/google/gnostic-models/extensions/extension.proto +++ b/vendor/github.com/google/gnostic-models/extensions/extension.proto @@ -42,7 +42,7 @@ option java_package = "org.gnostic.v1"; option objc_class_prefix = "GNX"; // The Go package name. -option go_package = "./extensions;gnostic_extension_v1"; +option go_package = "github.com/google/gnostic-models/extensions;gnostic_extension_v1"; // The version number of Gnostic. message Version { diff --git a/vendor/github.com/google/gnostic-models/openapiv2/OpenAPIv2.proto b/vendor/github.com/google/gnostic-models/openapiv2/OpenAPIv2.proto index 1c59b2f4a..49adafcc8 100644 --- a/vendor/github.com/google/gnostic-models/openapiv2/OpenAPIv2.proto +++ b/vendor/github.com/google/gnostic-models/openapiv2/OpenAPIv2.proto @@ -42,7 +42,7 @@ option java_package = "org.openapi_v2"; option objc_class_prefix = "OAS"; // The Go package name. -option go_package = "./openapiv2;openapi_v2"; +option go_package = "github.com/google/gnostic-models/openapiv2;openapi_v2"; message AdditionalPropertiesItem { oneof oneof { diff --git a/vendor/github.com/google/gnostic-models/openapiv3/OpenAPIv3.proto b/vendor/github.com/google/gnostic-models/openapiv3/OpenAPIv3.proto index 1be335b89..af4b6254b 100644 --- a/vendor/github.com/google/gnostic-models/openapiv3/OpenAPIv3.proto +++ b/vendor/github.com/google/gnostic-models/openapiv3/OpenAPIv3.proto @@ -42,7 +42,7 @@ option java_package = "org.openapi_v3"; option objc_class_prefix = "OAS"; // The Go package name. -option go_package = "./openapiv3;openapi_v3"; +option go_package = "github.com/google/gnostic-models/openapiv3;openapi_v3"; message AdditionalPropertiesItem { oneof oneof { diff --git a/vendor/github.com/google/gnostic-models/openapiv3/annotations.proto b/vendor/github.com/google/gnostic-models/openapiv3/annotations.proto index 09ee0aac5..895b4567c 100644 --- a/vendor/github.com/google/gnostic-models/openapiv3/annotations.proto +++ b/vendor/github.com/google/gnostic-models/openapiv3/annotations.proto @@ -20,7 +20,7 @@ import "google/protobuf/descriptor.proto"; import "openapiv3/OpenAPIv3.proto"; // The Go package name. -option go_package = "./openapiv3;openapi_v3"; +option go_package = "github.com/google/gnostic-models/openapiv3;openapi_v3"; // This option lets the proto compiler generate Java code inside the package // name (see below) instead of inside an outer class. It creates a simpler // developer experience by reducing one-level of name nesting and be diff --git a/vendor/k8s.io/client-go/dynamic/dynamicinformer/informer.go b/vendor/k8s.io/client-go/dynamic/dynamicinformer/informer.go new file mode 100644 index 000000000..b933c1379 --- /dev/null +++ b/vendor/k8s.io/client-go/dynamic/dynamicinformer/informer.go @@ -0,0 +1,200 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 dynamicinformer + +import ( + "context" + "sync" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamiclister" + "k8s.io/client-go/informers" + "k8s.io/client-go/tools/cache" +) + +// NewDynamicSharedInformerFactory constructs a new instance of dynamicSharedInformerFactory for all namespaces. +func NewDynamicSharedInformerFactory(client dynamic.Interface, defaultResync time.Duration) DynamicSharedInformerFactory { + return NewFilteredDynamicSharedInformerFactory(client, defaultResync, metav1.NamespaceAll, nil) +} + +// NewFilteredDynamicSharedInformerFactory constructs a new instance of dynamicSharedInformerFactory. +// Listers obtained via this factory will be subject to the same filters as specified here. +func NewFilteredDynamicSharedInformerFactory(client dynamic.Interface, defaultResync time.Duration, namespace string, tweakListOptions TweakListOptionsFunc) DynamicSharedInformerFactory { + return &dynamicSharedInformerFactory{ + client: client, + defaultResync: defaultResync, + namespace: namespace, + informers: map[schema.GroupVersionResource]informers.GenericInformer{}, + startedInformers: make(map[schema.GroupVersionResource]bool), + tweakListOptions: tweakListOptions, + } +} + +type dynamicSharedInformerFactory struct { + client dynamic.Interface + defaultResync time.Duration + namespace string + + lock sync.Mutex + informers map[schema.GroupVersionResource]informers.GenericInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[schema.GroupVersionResource]bool + tweakListOptions TweakListOptionsFunc + + // wg tracks how many goroutines were started. + wg sync.WaitGroup + // shuttingDown is true when Shutdown has been called. It may still be running + // because it needs to wait for goroutines. + shuttingDown bool +} + +var _ DynamicSharedInformerFactory = &dynamicSharedInformerFactory{} + +func (f *dynamicSharedInformerFactory) ForResource(gvr schema.GroupVersionResource) informers.GenericInformer { + f.lock.Lock() + defer f.lock.Unlock() + + key := gvr + informer, exists := f.informers[key] + if exists { + return informer + } + + informer = NewFilteredDynamicInformer(f.client, gvr, f.namespace, f.defaultResync, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) + f.informers[key] = informer + + return informer +} + +// Start initializes all requested informers. +func (f *dynamicSharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + if f.shuttingDown { + return + } + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + f.wg.Add(1) + // We need a new variable in each loop iteration, + // otherwise the goroutine would use the loop variable + // and that keeps changing. + informer := informer.Informer() + go func() { + defer f.wg.Done() + informer.Run(stopCh) + }() + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *dynamicSharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[schema.GroupVersionResource]bool { + informers := func() map[schema.GroupVersionResource]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[schema.GroupVersionResource]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer.Informer() + } + } + return informers + }() + + res := map[schema.GroupVersionResource]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +func (f *dynamicSharedInformerFactory) Shutdown() { + // Will return immediately if there is nothing to wait for. + defer f.wg.Wait() + + f.lock.Lock() + defer f.lock.Unlock() + f.shuttingDown = true +} + +// NewFilteredDynamicInformer constructs a new informer for a dynamic type. +func NewFilteredDynamicInformer(client dynamic.Interface, gvr schema.GroupVersionResource, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions TweakListOptionsFunc) informers.GenericInformer { + return &dynamicInformer{ + gvr: gvr, + informer: cache.NewSharedIndexInformerWithOptions( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.Resource(gvr).Namespace(namespace).List(context.Background(), options) + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.Resource(gvr).Namespace(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options metav1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.Resource(gvr).Namespace(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options metav1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.Resource(gvr).Namespace(namespace).Watch(ctx, options) + }, + }, client), + &unstructured.Unstructured{}, + cache.SharedIndexInformerOptions{ + ResyncPeriod: resyncPeriod, + Indexers: indexers, + ObjectDescription: gvr.String(), + }, + ), + } +} + +type dynamicInformer struct { + informer cache.SharedIndexInformer + gvr schema.GroupVersionResource +} + +var _ informers.GenericInformer = &dynamicInformer{} + +func (d *dynamicInformer) Informer() cache.SharedIndexInformer { + return d.informer +} + +func (d *dynamicInformer) Lister() cache.GenericLister { + return dynamiclister.NewRuntimeObjectShim(dynamiclister.New(d.informer.GetIndexer(), d.gvr)) +} diff --git a/vendor/k8s.io/client-go/dynamic/dynamicinformer/interface.go b/vendor/k8s.io/client-go/dynamic/dynamicinformer/interface.go new file mode 100644 index 000000000..0419ef4f8 --- /dev/null +++ b/vendor/k8s.io/client-go/dynamic/dynamicinformer/interface.go @@ -0,0 +1,53 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 dynamicinformer + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/informers" +) + +// DynamicSharedInformerFactory provides access to a shared informer and lister for dynamic client +type DynamicSharedInformerFactory interface { + // Start initializes all requested informers. They are handled in goroutines + // which run until the stop channel gets closed. + Start(stopCh <-chan struct{}) + + // ForResource gives generic access to a shared informer of the matching type. + ForResource(gvr schema.GroupVersionResource) informers.GenericInformer + + // WaitForCacheSync blocks until all started informers' caches were synced + // or the stop channel gets closed. + WaitForCacheSync(stopCh <-chan struct{}) map[schema.GroupVersionResource]bool + + // Shutdown marks a factory as shutting down. At that point no new + // informers can be started anymore and Start will return without + // doing anything. + // + // In addition, Shutdown blocks until all goroutines have terminated. For that + // to happen, the close channel(s) that they were started with must be closed, + // either before Shutdown gets called or while it is waiting. + // + // Shutdown may be called multiple times, even concurrently. All such calls will + // block until all goroutines have terminated. + Shutdown() +} + +// TweakListOptionsFunc defines the signature of a helper function +// that wants to provide more listing options to API +type TweakListOptionsFunc func(*metav1.ListOptions) diff --git a/vendor/k8s.io/client-go/dynamic/dynamiclister/interface.go b/vendor/k8s.io/client-go/dynamic/dynamiclister/interface.go new file mode 100644 index 000000000..c39cbee92 --- /dev/null +++ b/vendor/k8s.io/client-go/dynamic/dynamiclister/interface.go @@ -0,0 +1,40 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 dynamiclister + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" +) + +// Lister helps list resources. +type Lister interface { + // List lists all resources in the indexer. + List(selector labels.Selector) (ret []*unstructured.Unstructured, err error) + // Get retrieves a resource from the indexer with the given name + Get(name string) (*unstructured.Unstructured, error) + // Namespace returns an object that can list and get resources in a given namespace. + Namespace(namespace string) NamespaceLister +} + +// NamespaceLister helps list and get resources. +type NamespaceLister interface { + // List lists all resources in the indexer for a given namespace. + List(selector labels.Selector) (ret []*unstructured.Unstructured, err error) + // Get retrieves a resource from the indexer for a given namespace and name. + Get(name string) (*unstructured.Unstructured, error) +} diff --git a/vendor/k8s.io/client-go/dynamic/dynamiclister/lister.go b/vendor/k8s.io/client-go/dynamic/dynamiclister/lister.go new file mode 100644 index 000000000..a50fc471e --- /dev/null +++ b/vendor/k8s.io/client-go/dynamic/dynamiclister/lister.go @@ -0,0 +1,91 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 dynamiclister + +import ( + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/cache" +) + +var _ Lister = &dynamicLister{} +var _ NamespaceLister = &dynamicNamespaceLister{} + +// dynamicLister implements the Lister interface. +type dynamicLister struct { + indexer cache.Indexer + gvr schema.GroupVersionResource +} + +// New returns a new Lister. +func New(indexer cache.Indexer, gvr schema.GroupVersionResource) Lister { + return &dynamicLister{indexer: indexer, gvr: gvr} +} + +// List lists all resources in the indexer. +func (l *dynamicLister) List(selector labels.Selector) (ret []*unstructured.Unstructured, err error) { + err = cache.ListAll(l.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*unstructured.Unstructured)) + }) + return ret, err +} + +// Get retrieves a resource from the indexer with the given name +func (l *dynamicLister) Get(name string) (*unstructured.Unstructured, error) { + obj, exists, err := l.indexer.GetByKey(name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(l.gvr.GroupResource(), name) + } + return obj.(*unstructured.Unstructured), nil +} + +// Namespace returns an object that can list and get resources from a given namespace. +func (l *dynamicLister) Namespace(namespace string) NamespaceLister { + return &dynamicNamespaceLister{indexer: l.indexer, namespace: namespace, gvr: l.gvr} +} + +// dynamicNamespaceLister implements the NamespaceLister interface. +type dynamicNamespaceLister struct { + indexer cache.Indexer + namespace string + gvr schema.GroupVersionResource +} + +// List lists all resources in the indexer for a given namespace. +func (l *dynamicNamespaceLister) List(selector labels.Selector) (ret []*unstructured.Unstructured, err error) { + err = cache.ListAllByNamespace(l.indexer, l.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*unstructured.Unstructured)) + }) + return ret, err +} + +// Get retrieves a resource from the indexer for a given namespace and name. +func (l *dynamicNamespaceLister) Get(name string) (*unstructured.Unstructured, error) { + obj, exists, err := l.indexer.GetByKey(l.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(l.gvr.GroupResource(), name) + } + return obj.(*unstructured.Unstructured), nil +} diff --git a/vendor/k8s.io/client-go/dynamic/dynamiclister/shim.go b/vendor/k8s.io/client-go/dynamic/dynamiclister/shim.go new file mode 100644 index 000000000..92a5f54af --- /dev/null +++ b/vendor/k8s.io/client-go/dynamic/dynamiclister/shim.go @@ -0,0 +1,87 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 dynamiclister + +import ( + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" +) + +var _ cache.GenericLister = &dynamicListerShim{} +var _ cache.GenericNamespaceLister = &dynamicNamespaceListerShim{} + +// dynamicListerShim implements the cache.GenericLister interface. +type dynamicListerShim struct { + lister Lister +} + +// NewRuntimeObjectShim returns a new shim for Lister. +// It wraps Lister so that it implements cache.GenericLister interface +func NewRuntimeObjectShim(lister Lister) cache.GenericLister { + return &dynamicListerShim{lister: lister} +} + +// List will return all objects across namespaces +func (s *dynamicListerShim) List(selector labels.Selector) (ret []runtime.Object, err error) { + objs, err := s.lister.List(selector) + if err != nil { + return nil, err + } + + ret = make([]runtime.Object, len(objs)) + for index, obj := range objs { + ret[index] = obj + } + return ret, err +} + +// Get will attempt to retrieve assuming that name==key +func (s *dynamicListerShim) Get(name string) (runtime.Object, error) { + return s.lister.Get(name) +} + +func (s *dynamicListerShim) ByNamespace(namespace string) cache.GenericNamespaceLister { + return &dynamicNamespaceListerShim{ + namespaceLister: s.lister.Namespace(namespace), + } +} + +// dynamicNamespaceListerShim implements the NamespaceLister interface. +// It wraps NamespaceLister so that it implements cache.GenericNamespaceLister interface +type dynamicNamespaceListerShim struct { + namespaceLister NamespaceLister +} + +// List will return all objects in this namespace +func (ns *dynamicNamespaceListerShim) List(selector labels.Selector) (ret []runtime.Object, err error) { + objs, err := ns.namespaceLister.List(selector) + if err != nil { + return nil, err + } + + ret = make([]runtime.Object, len(objs)) + for index, obj := range objs { + ret[index] = obj + } + return ret, err +} + +// Get will attempt to retrieve by namespace and name +func (ns *dynamicNamespaceListerShim) Get(name string) (runtime.Object, error) { + return ns.namespaceLister.Get(name) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 13877b16e..862f5b685 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -367,7 +367,7 @@ github.com/felixge/httpsnoop ## explicit; go 1.17 github.com/fsnotify/fsnotify github.com/fsnotify/fsnotify/internal -# github.com/fxamacker/cbor/v2 v2.9.0 +# github.com/fxamacker/cbor/v2 v2.9.1 ## explicit; go 1.20 github.com/fxamacker/cbor/v2 # github.com/go-jose/go-jose/v4 v4.1.4 @@ -390,53 +390,53 @@ github.com/go-logr/zapr ## explicit; go 1.12 github.com/go-ole/go-ole github.com/go-ole/go-ole/oleutil -# github.com/go-openapi/jsonpointer v0.22.4 -## explicit; go 1.24.0 +# github.com/go-openapi/jsonpointer v0.23.1 +## explicit; go 1.25.0 github.com/go-openapi/jsonpointer -# github.com/go-openapi/jsonreference v0.21.4 +# github.com/go-openapi/jsonreference v0.21.5 ## explicit; go 1.24.0 github.com/go-openapi/jsonreference github.com/go-openapi/jsonreference/internal -# github.com/go-openapi/swag v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag -# github.com/go-openapi/swag/cmdutils v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/cmdutils v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/cmdutils -# github.com/go-openapi/swag/conv v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/conv v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/conv -# github.com/go-openapi/swag/fileutils v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/fileutils v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/fileutils -# github.com/go-openapi/swag/jsonname v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/jsonname v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/jsonname -# github.com/go-openapi/swag/jsonutils v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/jsonutils v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/jsonutils github.com/go-openapi/swag/jsonutils/adapters github.com/go-openapi/swag/jsonutils/adapters/ifaces github.com/go-openapi/swag/jsonutils/adapters/stdlib/json -# github.com/go-openapi/swag/loading v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/loading v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/loading -# github.com/go-openapi/swag/mangling v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/mangling v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/mangling -# github.com/go-openapi/swag/netutils v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/netutils v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/netutils -# github.com/go-openapi/swag/stringutils v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/stringutils v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/stringutils -# github.com/go-openapi/swag/typeutils v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/typeutils v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/typeutils -# github.com/go-openapi/swag/yamlutils v0.25.4 -## explicit; go 1.24.0 +# github.com/go-openapi/swag/yamlutils v0.26.0 +## explicit; go 1.25.0 github.com/go-openapi/swag/yamlutils -# github.com/google/gnostic-models v0.7.0 +# github.com/google/gnostic-models v0.7.1 ## explicit; go 1.22 github.com/google/gnostic-models/compiler github.com/google/gnostic-models/extensions @@ -1393,6 +1393,8 @@ k8s.io/client-go/applyconfigurations/storagemigration/v1beta1 k8s.io/client-go/discovery k8s.io/client-go/discovery/fake k8s.io/client-go/dynamic +k8s.io/client-go/dynamic/dynamicinformer +k8s.io/client-go/dynamic/dynamiclister k8s.io/client-go/features k8s.io/client-go/gentype k8s.io/client-go/informers @@ -1681,7 +1683,7 @@ k8s.io/klog/v2/internal/severity k8s.io/klog/v2/internal/sloghandler k8s.io/klog/v2/internal/verbosity k8s.io/klog/v2/textlogger -# k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a +# k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 ## explicit; go 1.23.0 k8s.io/kube-openapi/pkg/cached k8s.io/kube-openapi/pkg/common @@ -1767,6 +1769,8 @@ sigs.k8s.io/controller-runtime/pkg/webhook/admission/metrics sigs.k8s.io/controller-runtime/pkg/webhook/conversion sigs.k8s.io/controller-runtime/pkg/webhook/conversion/metrics sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics +# sigs.k8s.io/gateway-api v1.6.0 +## explicit; go 1.26.0 # sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 ## explicit; go 1.23 sigs.k8s.io/json @@ -1775,7 +1779,7 @@ sigs.k8s.io/json/internal/golang/encoding/json ## explicit; go 1.18 sigs.k8s.io/randfill sigs.k8s.io/randfill/bytesource -# sigs.k8s.io/structured-merge-diff/v6 v6.3.2 +# sigs.k8s.io/structured-merge-diff/v6 v6.4.0 ## explicit; go 1.23 sigs.k8s.io/structured-merge-diff/v6/fieldpath sigs.k8s.io/structured-merge-diff/v6/merge diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/element.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/element.go index 73436912c..f1607601c 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/element.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/element.go @@ -19,7 +19,7 @@ package fieldpath import ( "fmt" "iter" - "sort" + "slices" "strings" "sigs.k8s.io/structured-merge-diff/v6/value" @@ -255,19 +255,13 @@ func (s PathElementSet) Copy() PathElementSet { // Insert adds pe to the set. func (s *PathElementSet) Insert(pe PathElement) { - loc := sort.Search(len(s.members), func(i int) bool { - return !s.members[i].Less(pe) + loc, found := slices.BinarySearchFunc(s.members, pe, func(a, b PathElement) int { + return a.Compare(b) }) - if loc == len(s.members) { - s.members = append(s.members, pe) + if found { return } - if s.members[loc].Equals(pe) { - return - } - s.members = append(s.members, PathElement{}) - copy(s.members[loc+1:], s.members[loc:]) - s.members[loc] = pe + s.members = slices.Insert(s.members, loc, pe) } // Union returns a set containing elements that appear in either s or s2. @@ -344,16 +338,10 @@ func (s *PathElementSet) Size() int { return len(s.members) } // Has returns true if pe is a member of the set. func (s *PathElementSet) Has(pe PathElement) bool { - loc := sort.Search(len(s.members), func(i int) bool { - return !s.members[i].Less(pe) + _, found := slices.BinarySearchFunc(s.members, pe, func(a, b PathElement) int { + return a.Compare(b) }) - if loc == len(s.members) { - return false - } - if s.members[loc].Equals(pe) { - return true - } - return false + return found } // Equals returns true if s and s2 have exactly the same members. diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/pathelementmap.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/pathelementmap.go index ff7ee510c..ca50b84e9 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/pathelementmap.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/pathelementmap.go @@ -17,7 +17,7 @@ limitations under the License. package fieldpath import ( - "sort" + "slices" "sigs.k8s.io/structured-merge-diff/v6/value" ) @@ -82,32 +82,23 @@ func MakePathElementMap(size int) PathElementMap { // Insert adds the pathelement and associated value in the map. // If insert is called twice with the same PathElement, the value is replaced. func (s *PathElementMap) Insert(pe PathElement, v interface{}) { - loc := sort.Search(len(s.members), func(i int) bool { - return !s.members[i].PathElement.Less(pe) + loc, found := slices.BinarySearchFunc(s.members, pe, func(a pathElementValue, b PathElement) int { + return a.PathElement.Compare(b) }) - if loc == len(s.members) { - s.members = append(s.members, pathElementValue{pe, v}) - return - } - if s.members[loc].PathElement.Equals(pe) { + if found { s.members[loc].Value = v return } - s.members = append(s.members, pathElementValue{}) - copy(s.members[loc+1:], s.members[loc:]) - s.members[loc] = pathElementValue{pe, v} + s.members = slices.Insert(s.members, loc, pathElementValue{PathElement: pe, Value: v}) } // Get retrieves the value associated with the given PathElement from the map. // (nil, false) is returned if there is no such PathElement. func (s *PathElementMap) Get(pe PathElement) (interface{}, bool) { - loc := sort.Search(len(s.members), func(i int) bool { - return !s.members[i].PathElement.Less(pe) + loc, found := slices.BinarySearchFunc(s.members, pe, func(a pathElementValue, b PathElement) int { + return a.PathElement.Compare(b) }) - if loc == len(s.members) { - return nil, false - } - if s.members[loc].PathElement.Equals(pe) { + if found { return s.members[loc].Value, true } return nil, false diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/set.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/set.go index d2d8c8a42..d7fe643be 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/set.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/set.go @@ -19,6 +19,7 @@ package fieldpath import ( "fmt" "iter" + "slices" "sort" "strings" @@ -486,19 +487,13 @@ func (s *SetNodeMap) Copy() SetNodeMap { // Descend adds pe to the set if necessary, returning the associated subset. func (s *SetNodeMap) Descend(pe PathElement) *Set { - loc := sort.Search(len(s.members), func(i int) bool { - return !s.members[i].pathElement.Less(pe) + loc, found := slices.BinarySearchFunc(s.members, pe, func(a setNode, b PathElement) int { + return a.pathElement.Compare(b) }) - if loc == len(s.members) { - s.members = append(s.members, setNode{pathElement: pe, set: &Set{}}) + if found { return s.members[loc].set } - if s.members[loc].pathElement.Equals(pe) { - return s.members[loc].set - } - s.members = append(s.members, setNode{}) - copy(s.members[loc+1:], s.members[loc:]) - s.members[loc] = setNode{pathElement: pe, set: &Set{}} + s.members = slices.Insert(s.members, loc, setNode{pathElement: pe, set: &Set{}}) return s.members[loc].set } @@ -523,13 +518,10 @@ func (s *SetNodeMap) Empty() bool { // Get returns (the associated set, true) or (nil, false) if there is none. func (s *SetNodeMap) Get(pe PathElement) (*Set, bool) { - loc := sort.Search(len(s.members), func(i int) bool { - return !s.members[i].pathElement.Less(pe) + loc, found := slices.BinarySearchFunc(s.members, pe, func(a setNode, b PathElement) int { + return a.pathElement.Compare(b) }) - if loc == len(s.members) { - return nil, false - } - if s.members[loc].pathElement.Equals(pe) { + if found { return s.members[loc].set, true } return nil, false diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/value/allocator.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/value/allocator.go index f70cd4167..acbfe8ceb 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/value/allocator.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/value/allocator.go @@ -16,6 +16,8 @@ limitations under the License. package value +import "reflect" + // Allocator provides a value object allocation strategy. // Value objects can be allocated by passing an allocator to the "Using" // receiver functions on the value interfaces, e.g. Map.ZipUsing(allocator, ...). @@ -24,8 +26,8 @@ package value type Allocator interface { // Free gives the allocator back any value objects returned by the "Using" // receiver functions on the value interfaces. - // interface{} may be any of: Value, Map, List or Range. - Free(interface{}) + // any may be any of: Value, Map, List or Range. + Free(any) // The unexported functions are for "Using" receiver functions of the value types // to request what they need from the allocator. @@ -74,7 +76,7 @@ func (p *heapAllocator) allocListReflectRange() *listReflectRange { return &listReflectRange{vr: &valueReflect{}} } -func (p *heapAllocator) Free(_ interface{}) {} +func (p *heapAllocator) Free(_ any) {} // NewFreelistAllocator creates freelist based allocator. // This allocator provides fast allocation and freeing of short lived value objects. @@ -89,25 +91,25 @@ func (p *heapAllocator) Free(_ interface{}) {} // for all temporary value access. func NewFreelistAllocator() Allocator { return &freelistAllocator{ - valueUnstructured: &freelist{new: func() interface{} { + valueUnstructured: &freelist[*valueUnstructured]{new: func() *valueUnstructured { return &valueUnstructured{} }}, - listUnstructuredRange: &freelist{new: func() interface{} { + listUnstructuredRange: &freelist[*listUnstructuredRange]{new: func() *listUnstructuredRange { return &listUnstructuredRange{vv: &valueUnstructured{}} }}, - valueReflect: &freelist{new: func() interface{} { + valueReflect: &freelist[*valueReflect]{new: func() *valueReflect { return &valueReflect{} }}, - mapReflect: &freelist{new: func() interface{} { + mapReflect: &freelist[*mapReflect]{new: func() *mapReflect { return &mapReflect{} }}, - structReflect: &freelist{new: func() interface{} { + structReflect: &freelist[*structReflect]{new: func() *structReflect { return &structReflect{} }}, - listReflect: &freelist{new: func() interface{} { + listReflect: &freelist[*listReflect]{new: func() *listReflect { return &listReflect{} }}, - listReflectRange: &freelist{new: func() interface{} { + listReflectRange: &freelist[*listReflectRange]{new: func() *listReflectRange { return &listReflectRange{vr: &valueReflect{}} }}, } @@ -119,22 +121,22 @@ func NewFreelistAllocator() Allocator { const freelistMaxSize = 1000 type freelistAllocator struct { - valueUnstructured *freelist - listUnstructuredRange *freelist - valueReflect *freelist - mapReflect *freelist - structReflect *freelist - listReflect *freelist - listReflectRange *freelist + valueUnstructured *freelist[*valueUnstructured] + listUnstructuredRange *freelist[*listUnstructuredRange] + valueReflect *freelist[*valueReflect] + mapReflect *freelist[*mapReflect] + structReflect *freelist[*structReflect] + listReflect *freelist[*listReflect] + listReflectRange *freelist[*listReflectRange] } -type freelist struct { - list []interface{} - new func() interface{} +type freelist[T any] struct { + list []T + new func() T } -func (f *freelist) allocate() interface{} { - var w2 interface{} +func (f *freelist[T]) allocate() T { + var w2 T if n := len(f.list); n > 0 { w2, f.list = f.list[n-1], f.list[:n-1] } else { @@ -143,61 +145,73 @@ func (f *freelist) allocate() interface{} { return w2 } -func (f *freelist) free(v interface{}) { +func (f *freelist[T]) free(v T) { if len(f.list) < freelistMaxSize { f.list = append(f.list, v) } } -func (w *freelistAllocator) Free(value interface{}) { +func (w *freelistAllocator) Free(value any) { switch v := value.(type) { case *valueUnstructured: v.Value = nil // don't hold references to unstructured objects w.valueUnstructured.free(v) case *listUnstructuredRange: + v.list = nil // don't hold references to unstructured objects v.vv.Value = nil // don't hold references to unstructured objects w.listUnstructuredRange.free(v) case *valueReflect: - v.ParentMapKey = nil v.ParentMap = nil + v.ParentMapKey = nil + v.Value = reflect.Value{} // don't hold references to reflected objects w.valueReflect.free(v) case *mapReflect: + v.valueReflect.ParentMap = nil + v.valueReflect.ParentMapKey = nil + v.valueReflect.Value = reflect.Value{} // don't hold references to reflected objects w.mapReflect.free(v) case *structReflect: + v.valueReflect.ParentMap = nil + v.valueReflect.ParentMapKey = nil + v.valueReflect.Value = reflect.Value{} // don't hold references to reflected objects w.structReflect.free(v) case *listReflect: + v.Value = reflect.Value{} // don't hold references to reflected objects w.listReflect.free(v) case *listReflectRange: - v.vr.ParentMapKey = nil + v.list = reflect.Value{} // don't hold references to reflected objects v.vr.ParentMap = nil + v.vr.ParentMapKey = nil + v.vr.Value = reflect.Value{} // don't hold references to reflected objects + v.entry = nil w.listReflectRange.free(v) } } func (w *freelistAllocator) allocValueUnstructured() *valueUnstructured { - return w.valueUnstructured.allocate().(*valueUnstructured) + return w.valueUnstructured.allocate() } func (w *freelistAllocator) allocListUnstructuredRange() *listUnstructuredRange { - return w.listUnstructuredRange.allocate().(*listUnstructuredRange) + return w.listUnstructuredRange.allocate() } func (w *freelistAllocator) allocValueReflect() *valueReflect { - return w.valueReflect.allocate().(*valueReflect) + return w.valueReflect.allocate() } func (w *freelistAllocator) allocStructReflect() *structReflect { - return w.structReflect.allocate().(*structReflect) + return w.structReflect.allocate() } func (w *freelistAllocator) allocMapReflect() *mapReflect { - return w.mapReflect.allocate().(*mapReflect) + return w.mapReflect.allocate() } func (w *freelistAllocator) allocListReflect() *listReflect { - return w.listReflect.allocate().(*listReflect) + return w.listReflect.allocate() } func (w *freelistAllocator) allocListReflectRange() *listReflectRange { - return w.listReflectRange.allocate().(*listReflectRange) + return w.listReflectRange.allocate() } diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/value/jsontagutil.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/value/jsontagutil.go index 3aadceb22..db2561373 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/value/jsontagutil.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/value/jsontagutil.go @@ -76,11 +76,16 @@ func OmitZeroFunc(t reflect.Type) func(reflect.Value) bool { // but is based on the highly efficient approach from https://golang.org/src/encoding/json/encode.go func lookupJsonTags(f reflect.StructField) (name string, omit bool, inline bool, omitempty bool, omitzero func(reflect.Value) bool) { + // Skip unexported non-embedded fields, matching encoding/json behavior. + if f.PkgPath != "" && !f.Anonymous { + return "", true, false, false, nil + } tag := f.Tag.Get("json") if tag == "-" { return "", true, false, false, nil } name, opts := parseTag(tag) + inline = f.Anonymous && name == "" if name == "" { name = f.Name } @@ -89,7 +94,7 @@ func lookupJsonTags(f reflect.StructField) (name string, omit bool, inline bool, omitzero = OmitZeroFunc(f.Type) } - return name, false, opts.Contains("inline"), opts.Contains("omitempty"), omitzero + return name, false, inline, opts.Contains("omitempty"), omitzero } func isEmpty(v reflect.Value) bool { From 63301ae05d59ca1c60f24a58b9223418fc3f78c5 Mon Sep 17 00:00:00 2001 From: npolshakova Date: Mon, 6 Jul 2026 10:04:30 -0700 Subject: [PATCH 6/9] add diagram to docs Signed-off-by: npolshakova --- docs/egress-capture.md | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/docs/egress-capture.md b/docs/egress-capture.md index 994e3b01f..96ccf3b01 100644 --- a/docs/egress-capture.md +++ b/docs/egress-capture.md @@ -122,6 +122,68 @@ The PEP binding is a **snapshot taken at resume**, recorded on the actor as on the next resume ``` +The full resume / egress / suspend sequence, component by component: + +```mermaid +sequenceDiagram + autonumber + + participant CLI as kubectl ate + participant API as ate-api + participant GW as Gateway indexer + participant ST as Redis/Valkey + participant LET as atelet + participant OM as ateom + participant CAP as capture listener
:15001 + participant PEP as agentgateway PEP
:15008 + participant EXT as httpbin.org:443 + + rect rgb(235, 243, 255) + Note over CLI,ST: Resume + CLI->>API: resume actor my-egress-1 -a demo + API->>ST: Load suspended actor + API->>GW: Resolve egress PEP for actor + GW-->>API: Best programmed Gateway address + API->>ST: Claim worker + API->>ST: Set RESUMING and EgressPepAddress + end + + rect rgb(235, 255, 238) + Note over API,OM: Restore workload + API->>LET: Run/Restore with EgressPepAddress + LET->>OM: RunWorkload/RestoreWorkload with EgressPepAddress + OM->>OM: setupActorNetwork
veth plus netns + OM->>CAP: Start capture listener + OM->>OM: nftables redirects actor TCP to :15001 + OM-->>LET: ok + LET-->>API: ok + API->>ST: Set RUNNING + end + + rect rgb(255, 248, 225) + Note over OM,EXT: Actor egress connection + OM->>CAP: Actor conn (nftables redirect) to httpbin.org:443 + CAP->>CAP: Get original destination
sniff SNI or Host + CAP->>PEP: HTTP/2 CONNECT httpbin.org:443
with actor metadata + PEP->>EXT: Route via TCPRoute + Note over CAP,EXT: TLS remains end-to-end
PEP routes encrypted bytes only + CAP-->>OM: Proxy byte stream + end + + rect rgb(255, 235, 238) + Note over CLI,ST: Suspend + CLI->>API: suspend actor + API->>ST: Set SUSPENDING + API->>LET: Checkpoint + LET->>OM: CheckpointWorkload + OM->>CAP: Close listener and streams + OM-->>LET: snapshot files + LET-->>API: ok (snapshot uploaded) + API->>ST: Release worker
clear EgressPepAddress + Note over API,GW: Next resume re-resolves Gateway binding + end +``` + To move a running actor to a different PEP: relabel the Gateways **and** cycle the actor (see "Point an actor at a different PEP"). From 2eecee19677d632852956656548862f2aa38180a Mon Sep 17 00:00:00 2001 From: npolshakova Date: Mon, 6 Jul 2026 10:11:11 -0700 Subject: [PATCH 7/9] update docs Signed-off-by: npolshakova --- docs/egress-capture.md | 85 ++++++++++++++++++++---------------------- hack/install-ate.sh | 21 ++++++----- 2 files changed, 51 insertions(+), 55 deletions(-) diff --git a/docs/egress-capture.md b/docs/egress-capture.md index 96ccf3b01..11a76920d 100644 --- a/docs/egress-capture.md +++ b/docs/egress-capture.md @@ -10,20 +10,20 @@ by default. Egress capture has no global on/off switch. ate-api watches Gateways labeled `ate.dev/egress-pep`. On actor resume, ate-api picks the best matching PEP Gateway for that actor and sends one optional PEP address to ateom. An empty PEP -address means no redirect, so capture is enabled per actor purely by whether a -matching labeled Gateway exists. The reusable capture core lives in -`internal/egress`: +address means no redirect, so capture is enabled per actor only when a matching +programmed labeled Gateway with an HTTP listener resolves. The reusable capture +core lives in `internal/egress`: it owns capture listeners, authority derivation, CONNECT tunnel transports, and byte proxying. The runtime-specific `ateom` egress proxy setup supplies the original-destination lookup and packet-capture rules. -The current gVisor implementation starts a local capture listener and installs -actor-network redirects for TCP/80 and TCP/443. From the actor's point of view -it still opens a normal HTTP or HTTPS connection to the original destination. -MicroVM or future hypervisor implementations should reuse -`internal/egress` for the local listener, authority derivation, tunnel -transport, and byte proxying. Each runtime still provides its own egress proxy -setup for redirecting actor traffic and recovering the original destination. +The current gVisor and MicroVM implementations start a local capture listener +and install actor-network redirects for TCP egress. From the actor's point of +view it still opens a normal TCP connection to the original destination. Future +hypervisor implementations should reuse `internal/egress` for the local +listener, authority derivation, tunnel transport, and byte proxying. Each +runtime still provides its own egress proxy setup for redirecting actor traffic +and recovering the original destination. The redirected connection lands on `ateom`, which records the original destination and derives a stable CONNECT authority from the first bytes of the @@ -31,8 +31,9 @@ actor connection: | Actor traffic | Authority source | Example CONNECT authority | | --- | --- | --- | -| HTTPS / TCP 443 | TLS ClientHello SNI | `httpbin.org:443` | -| Plaintext HTTP / TCP 80 | HTTP `Host` header | `example.com:80` | +| HTTPS / any TCP port | TLS ClientHello SNI + original destination port | `httpbin.org:443` | +| Plaintext HTTP / any TCP port | HTTP `Host` header, defaulting to original destination port when the header has no port | `example.com:80` | +| Other TCP | Original destination address | `203.0.113.10:2222` | The shared capture core then opens a plaintext HTTP/2 CONNECT stream to the PEP address selected by ate-api. Only Gateways with condition `Programmed=True` are @@ -45,11 +46,12 @@ stays pending). The port is the Gateway's HTTP listener port. Agentgateway maps the CONNECT authority to its configured TCP listener and routes the tunnel to a Kubernetes Service backed by an EndpointSlice. -The demo setup configures only `httpbin.org:443` for egress. -Other hosts or plaintext HTTP destinations need their own agentgateway -Service, EndpointSlice, listener, and route. For HTTPS, TLS is still end-to-end -between the actor and the external service; agentgateway only routes the -encrypted bytes after CONNECT succeeds. +The demo setup configures only `httpbin.org:443` for egress. Any other CONNECT +authority, including plaintext HTTP destinations or fallback original IP:port +authorities, needs its own matching agentgateway Service, EndpointSlice, +listener, and route. For HTTPS, TLS is still end-to-end between the actor and +the external service; agentgateway only routes the encrypted bytes after +CONNECT succeeds. ### Selecting a PEP for an actor @@ -73,7 +75,7 @@ highest-precedence match (`resolveEgressPEPAddress` in | Situation | Result | | --- | --- | | Multiple candidates tied at the top score | Lowest `(namespace, name)` wins; the others are **silently ignored** | -| No labeled candidate | Empty PEP address → no redirect, capture off | +| No matching programmed candidate | Empty PEP address → no redirect, capture off | Don't deploy multiple PEPs at the same tier for the same actor — use the scoping labels to raise the intended one's tier instead of relying on name @@ -88,8 +90,8 @@ label a Gateway can: - **Intercept actor egress**: copy an actor's `ate.dev/atespace` + `ate.dev/actor` labels onto their own Gateway to out-score its real PEP. -- **Block all resumes**: label a Gateway with no HTTP listener (broken PEP - config fails resolution cluster-wide by design). +- **Block resumes**: label a programmed Gateway with no HTTP listener (broken + PEP config fails PEP resolution by design). Substrate deliberately does not second-guess labeled Gateways. If Gateway RBAC can't be that strict, scope the watch to an allowlist of PEP namespaces in @@ -163,7 +165,7 @@ sequenceDiagram rect rgb(255, 248, 225) Note over OM,EXT: Actor egress connection OM->>CAP: Actor conn (nftables redirect) to httpbin.org:443 - CAP->>CAP: Get original destination
sniff SNI or Host + CAP->>CAP: Get original destination
classify SNI, Host, or original dst CAP->>PEP: HTTP/2 CONNECT httpbin.org:443
with actor metadata PEP->>EXT: Route via TCPRoute Note over CAP,EXT: TLS remains end-to-end
PEP routes encrypted bytes only @@ -218,8 +220,9 @@ system. No fixed PEP address is configured; ate-api derives the address from the labeled Gateway's HTTP listener. The install script resolves `httpbin.org` during install and creates the -`httpbin-egress` Service and EndpointSlice for those IPs. `ateom` derives the -CONNECT authority from SNI for this HTTPS demo. +`httpbin-egress` Service and EndpointSlice for those IPs. For the default HTTPS +demo request, `ateom` derives the CONNECT authority from TLS SNI and the +original destination port. Verify the static agentgateway resources and PEP label: @@ -299,8 +302,9 @@ curl -i -X POST --get \ "http://localhost:8000" ``` -Do not use this query parameter for a different host unless you also update the -agentgateway route. `ateom` will derive the new host from SNI, but the demo +Do not use this query parameter for a different host or port unless you also +update the agentgateway route. `ateom` will derive the new CONNECT authority +from TLS SNI, the HTTP `Host` header, or the original destination, but the demo agentgateway config only routes `httpbin.org:443`. ## Verify capture was installed @@ -490,9 +494,9 @@ EOF http://localhost:8000 ``` - The authoritative signal is the ateom capture log, which names the PEP the - actor actually tunnelled through (see "Verify capture was installed" for how - to find the worker pod): + The authoritative signal is the ateom capture log, which names the PEP + configured for the actor's current activation (see "Verify capture was + installed" for how to find the worker pod): ```bash kubectl logs -n "${ateom_ns}" "${ateom_pod}" -c ateom | grep "Started actor egress capture listener" @@ -593,21 +597,12 @@ PEP address, so `egressPepAddress` on the actor can report a binding the sandbox does not enforce. If capture logs are missing despite a resolved PEP, check the WorkerPool's ateom image version. -If the capture listener logs are missing, confirm that the actor is running on a -fresh worker pod created after egress was enabled: +If the egress request fails after changing the `url` host or port, remember that +this demo only configures agentgateway for `httpbin.org:443`. Add matching static +agentgateway backend resources for the CONNECT authority that `ateom` will send: -```bash -kubectl ate get actor my-egress-1 -a demo -kubectl get pods -n ate-demo-egress -l ate.dev/worker-pool=egress -``` - -If the egress request fails after changing the `url` host, remember that this demo only -configures agentgateway for `httpbin.org:443`. Add matching static agentgateway -backend resources for the new destination: - -- HTTPS: Service, EndpointSlice, TCP listener on `443`, and TCPRoute. -- Plaintext HTTP: Service, EndpointSlice, TCP listener on `80`, and TCPRoute. - -Traffic without SNI or a plaintext HTTP `Host` header falls back to the captured -original destination IP and port, which requires matching agentgateway routing -for that address. +- HTTPS: SNI plus the original destination port, for example `example.com:443`. +- Plaintext HTTP: `Host` header authority, defaulting to the original + destination port when the header has no port, for example `example.com:80`. +- Other TCP: captured original destination IP and port, for example + `203.0.113.10:2222`. diff --git a/hack/install-ate.sh b/hack/install-ate.sh index aac2db25a..a7336aeba 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -571,8 +571,8 @@ deploy_atenet() { # get_actor_status echoes the actor's status enum (e.g. STATUS_SUSPENDED). get_actor_status() { - local actor_id="$1" - local atespace="$2" + local atespace="$1" + local actor_id="$2" local json if ! json=$(run_kubectl_ate get actor "${actor_id}" -a "${atespace}" -o json 2>/dev/null); then @@ -584,14 +584,14 @@ get_actor_status() { # prepare_actor_for_delete suspends (or resumes then suspends) until DeleteActor # is allowed. Actors must be STATUS_SUSPENDED before deletion. prepare_actor_for_delete() { - local actor_id="$1" - local atespace="$2" + local atespace="$1" + local actor_id="$2" local timeout_secs="${3:-120}" local deadline=$((SECONDS + timeout_secs)) local status while ((SECONDS < deadline)); do - if ! status=$(get_actor_status "${actor_id}" "${atespace}"); then + if ! status=$(get_actor_status "${atespace}" "${actor_id}"); then return 0 fi @@ -646,7 +646,7 @@ delete_demo_actors() { return 0 fi - local ns tmpl atespace actor_id + local ns tmpl actor_ref atespace actor_id while (($# > 0)); do ns="$1" tmpl="$2" @@ -654,13 +654,14 @@ delete_demo_actors() { log_step "Deleting actors for ${ns}/${tmpl}" while IFS=$'\t' read -r atespace actor_id; do - [[ -z "${actor_id}" ]] && continue - log_step " preparing actor ${atespace}/${actor_id} for delete" - prepare_actor_for_delete "${actor_id}" "${atespace}" + [[ -z "${atespace}" || -z "${actor_id}" ]] && continue + actor_ref="${atespace}/${actor_id}" + log_step " preparing actor ${actor_ref} for delete" + prepare_actor_for_delete "${atespace}" "${actor_id}" run_kubectl_ate delete actor "${actor_id}" -a "${atespace}" done < <( jq -r --arg ns "${ns}" --arg tmpl "${tmpl}" \ - '.actors[]? | select(.actorTemplateNamespace == $ns and .actorTemplateName == $tmpl) | "\(.atespace)\t\(.actorId)"' \ + '.actors[]? | select(.actorTemplateNamespace == $ns and .actorTemplateName == $tmpl) | [.atespace, .actorId] | @tsv' \ <<<"${actors_json}" ) done From 938b88b30b57981673da6650d29aeb5cde2ff744 Mon Sep 17 00:00:00 2001 From: npolshakova Date: Tue, 7 Jul 2026 11:05:23 -0700 Subject: [PATCH 8/9] move to substrate label Signed-off-by: npolshakova --- .../internal/controlapi/create_actor.go | 4 + .../internal/controlapi/create_atespace.go | 3 + cmd/ateapi/internal/controlapi/egress_pep.go | 306 ++++++------------ .../internal/controlapi/egress_pep_test.go | 261 ++++++--------- .../internal/controlapi/functional_test.go | 72 ++++- cmd/ateapi/internal/controlapi/informer.go | 25 -- cmd/ateapi/internal/controlapi/service.go | 5 +- .../internal/controlapi/update_actor.go | 21 ++ cmd/ateapi/internal/controlapi/workflow.go | 9 +- .../internal/controlapi/workflow_resume.go | 38 ++- cmd/ateapi/main.go | 90 ++---- cmd/kubectl-ate/internal/cmd/create_actor.go | 9 + .../internal/cmd/create_atespace.go | 10 + cmd/kubectl-ate/internal/cmd/update.go | 28 ++ cmd/kubectl-ate/internal/cmd/update_actor.go | 81 +++++ docs/egress-capture.md | 225 +++++++------ go.mod | 1 - go.sum | 47 +-- hack/install-ate.sh | 14 +- internal/egress/env.go | 9 +- manifests/ate-install/ate-api-server.yaml | 8 +- pkg/proto/ateapipb/ateapi.pb.go | 211 +++++++----- pkg/proto/ateapipb/ateapi.proto | 21 ++ .../dynamic/dynamicinformer/informer.go | 200 ------------ .../dynamic/dynamicinformer/interface.go | 53 --- .../dynamic/dynamiclister/interface.go | 40 --- .../client-go/dynamic/dynamiclister/lister.go | 91 ------ .../client-go/dynamic/dynamiclister/shim.go | 87 ----- vendor/modules.txt | 4 - 29 files changed, 782 insertions(+), 1191 deletions(-) create mode 100644 cmd/kubectl-ate/internal/cmd/update.go create mode 100644 cmd/kubectl-ate/internal/cmd/update_actor.go delete mode 100644 vendor/k8s.io/client-go/dynamic/dynamicinformer/informer.go delete mode 100644 vendor/k8s.io/client-go/dynamic/dynamicinformer/interface.go delete mode 100644 vendor/k8s.io/client-go/dynamic/dynamiclister/interface.go delete mode 100644 vendor/k8s.io/client-go/dynamic/dynamiclister/lister.go delete mode 100644 vendor/k8s.io/client-go/dynamic/dynamiclister/shim.go diff --git a/cmd/ateapi/internal/controlapi/create_actor.go b/cmd/ateapi/internal/controlapi/create_actor.go index 50eb05473..f35c37ddf 100644 --- a/cmd/ateapi/internal/controlapi/create_actor.go +++ b/cmd/ateapi/internal/controlapi/create_actor.go @@ -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 { @@ -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()) } diff --git a/cmd/ateapi/internal/controlapi/create_atespace.go b/cmd/ateapi/internal/controlapi/create_atespace.go index cba8c12ad..1010b1024 100644 --- a/cmd/ateapi/internal/controlapi/create_atespace.go +++ b/cmd/ateapi/internal/controlapi/create_atespace.go @@ -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 { @@ -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()) } diff --git a/cmd/ateapi/internal/controlapi/egress_pep.go b/cmd/ateapi/internal/controlapi/egress_pep.go index c43779304..23a1e5d8a 100644 --- a/cmd/ateapi/internal/controlapi/egress_pep.go +++ b/cmd/ateapi/internal/controlapi/egress_pep.go @@ -15,247 +15,135 @@ package controlapi import ( - "context" "fmt" - "log/slog" "net" - "sort" - "strconv" - "strings" "github.com/agent-substrate/substrate/internal/egress" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/tools/cache" + "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" ) -type egressPEPGateway struct { - namespace string - name string - address string - labels map[string]string -} - -func resolveEgressPEPAddress(ctx context.Context, gatewayIndexer cache.Indexer, atespace, actorID string) (string, error) { - if gatewayIndexer == nil { - return "", nil - } - - var candidates []egressPEPGateway - for _, obj := range gatewayIndexer.List() { - u, ok := obj.(*unstructured.Unstructured) - if !ok { - return "", fmt.Errorf("egress PEP cache contained %T, want *unstructured.Unstructured", obj) - } - pep, ok, err := egressPEPGatewayFromUnstructured(u) - if err != nil { - return "", err - } - if !ok { +// 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 ":" 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 } - // An actor-scoped PEP needs both labels: the actor label alone scores 0 - // for every actor (egressPEPGatewayScore requires the atespace to match - // too), so the Gateway silently matches nothing. Warn instead of failing - // so the actor still falls back to the next-best PEP. - if pep.labels[egress.LabelActor] != "" && pep.labels[egress.LabelAtespace] == "" { - slog.WarnContext(ctx, "Egress PEP Gateway has an actor label but no atespace label; it can never match any actor", - "gateway", pep.namespace+"/"+pep.name) - } - candidates = append(candidates, pep) - } - - bestScore := 0 - var best *egressPEPGateway - sort.Slice(candidates, func(i, j int) bool { - if candidates[i].namespace != candidates[j].namespace { - return candidates[i].namespace < candidates[j].namespace - } - return candidates[i].name < candidates[j].name - }) - for i := range candidates { - score := egressPEPGatewayScore(candidates[i].labels, atespace, actorID) - if score > bestScore { - bestScore = score - best = &candidates[i] + if err := validatePEPAddress(tier.address); err != nil { + return "", fmt.Errorf("%s egress PEP: %w", tier.source, err) } + return tier.address, nil } - if best == nil { - return "", nil - } - return best.address, nil + return "", nil } -func egressPEPGatewayScore(labels map[string]string, atespace, actorID string) int { - if _, ok := labels[egress.LabelPEP]; !ok { - return 0 +// validateEgressPEPSelector validates the ate.dev/use-egress-pep selector label +// (if present and non-empty) parses as ":". 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 } - - pepAtespace := labels[egress.LabelAtespace] - pepActor := labels[egress.LabelActor] - switch { - case pepActor != "": - if pepActor == actorID && pepAtespace == atespace { - return 3 - } - case pepAtespace != "": - if pepAtespace == atespace { - return 2 - } - default: - return 1 + if err := validatePEPAddress(address); err != nil { + return field.ErrorList{field.Invalid(fldPath.Key(egress.LabelUseEgressPEP), address, err.Error())} } - return 0 + return nil } -func egressPEPGatewayFromUnstructured(u *unstructured.Unstructured) (egressPEPGateway, bool, error) { - labels := u.GetLabels() - if _, ok := labels[egress.LabelPEP]; !ok { - return egressPEPGateway{}, false, nil - } - - // Only Programmed Gateways are candidates: an unprovisioned dataplane has no - // working address, and selecting it would hand ateom a dead PEP. Skipping - // (rather than erroring) lets resolution fall back to the next-best PEP - // while a Gateway is still being reconciled. - programmed, err := gatewayProgrammed(u) - if err != nil { - return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err) - } - if !programmed { - return egressPEPGateway{}, false, nil - } - - port, ok, err := gatewayHTTPListenerPort(u) - if err != nil { - return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err) - } - if !ok { - return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s has no HTTP listener", u.GetNamespace(), u.GetName()) - } - - // Prefer the address the Gateway implementation published in - // status.addresses; fall back to the agentgateway convention of a Service - // named after the Gateway when the implementation publishes none (e.g. - // agentgateway on kind, where the LoadBalancer address stays pending). - host, err := gatewayStatusAddress(u) - if err != nil { - return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err) - } - if host == "" { - host = fmt.Sprintf("%s.%s.svc.cluster.local", u.GetName(), u.GetNamespace()) - } - - return egressPEPGateway{ - namespace: u.GetNamespace(), - name: u.GetName(), - address: net.JoinHostPort(host, strconv.FormatInt(port, 10)), - labels: labels, - }, true, 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 + // ":" address; the cap allows a maximal DNS name (253) plus a + // colon and port with margin. + maxLabelValueLength = 320 +) -// gatewayProgrammed reports whether the Gateway has condition Programmed=True. -func gatewayProgrammed(u *unstructured.Unstructured) (bool, error) { - conditions, ok, err := unstructured.NestedSlice(u.Object, "status", "conditions") - if err != nil { - return false, fmt.Errorf("read status.conditions: %w", err) - } - if !ok { - return false, nil +// 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 _, condition := range conditions { - m, ok := condition.(map[string]any) - if !ok { - return false, fmt.Errorf("condition had type %T, want map[string]any", condition) - } - conditionType, _, err := unstructured.NestedString(m, "type") - if err != nil { - return false, fmt.Errorf("read condition type: %w", err) - } - if conditionType != "Programmed" { - continue + for k, v := range labels { + for _, msg := range content.IsLabelKey(k) { + errs = append(errs, field.Invalid(fldPath.Key(k), k, msg)) } - conditionStatus, _, err := unstructured.NestedString(m, "status") - if err != nil { - return false, fmt.Errorf("read condition status: %w", err) + if len(v) > maxLabelValueLength { + errs = append(errs, field.TooLong(fldPath.Key(k), v, maxLabelValueLength)) } - return conditionStatus == "True", nil } - return false, nil + return errs } -// gatewayStatusAddress returns the first address the Gateway implementation -// published in status.addresses, or "" if none is published. -func gatewayStatusAddress(u *unstructured.Unstructured) (string, error) { - addresses, ok, err := unstructured.NestedSlice(u.Object, "status", "addresses") - if err != nil { - return "", fmt.Errorf("read status.addresses: %w", err) +// 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 } - if !ok { - return "", nil - } - for _, address := range addresses { - m, ok := address.(map[string]any) - if !ok { - return "", fmt.Errorf("address had type %T, want map[string]any", address) - } - value, _, err := unstructured.NestedString(m, "value") - if err != nil { - return "", fmt.Errorf("read address value: %w", err) - } - if value != "" { - return value, nil - } - } - return "", nil + return validatePEPAddress(address) } -func gatewayHTTPListenerPort(u *unstructured.Unstructured) (int64, bool, error) { - listeners, ok, err := unstructured.NestedSlice(u.Object, "spec", "listeners") - if err != nil { - return 0, false, fmt.Errorf("read spec.listeners: %w", err) - } - if !ok { - return 0, false, nil - } - for _, listener := range listeners { - m, ok := listener.(map[string]any) - if !ok { - return 0, false, fmt.Errorf("listener had type %T, want map[string]any", listener) - } - protocol, _, err := unstructured.NestedString(m, "protocol") - if err != nil { - return 0, false, fmt.Errorf("read listener protocol: %w", err) - } - if !strings.EqualFold(protocol, "HTTP") { +// 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 } - port, ok, err := listenerPort(m) - if err != nil { - return 0, false, err - } - if !ok { - return 0, false, fmt.Errorf("HTTP listener has no port") + if out == nil { + out = map[string]string{} } - return port, true, nil + out[k] = v } - return 0, false, nil + return out } -func listenerPort(listener map[string]any) (int64, bool, error) { - port, ok := listener["port"] - if !ok { - return 0, false, nil +// validatePEPAddress checks that an egress PEP address is a ":" 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 :", address) } - switch v := port.(type) { - case int64: - return v, true, nil - case int32: - return int64(v), true, nil - case int: - return int64(v), true, nil - case float64: - return int64(v), true, nil - default: - return 0, false, fmt.Errorf("HTTP listener port had type %T, want integer", port) + // 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 } diff --git a/cmd/ateapi/internal/controlapi/egress_pep_test.go b/cmd/ateapi/internal/controlapi/egress_pep_test.go index a81febeaa..96d3cfc3c 100644 --- a/cmd/ateapi/internal/controlapi/egress_pep_test.go +++ b/cmd/ateapi/internal/controlapi/egress_pep_test.go @@ -15,17 +15,37 @@ package controlapi import ( + "fmt" + "strings" "testing" "github.com/agent-substrate/substrate/internal/egress" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/tools/cache" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" ) -func TestResolveEgressPEPAddressEmpty(t *testing.T) { - indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) +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 +} - got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-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) } @@ -34,202 +54,109 @@ func TestResolveEgressPEPAddressEmpty(t *testing.T) { } } -func TestResolveEgressPEPAddressSelectsBestGateway(t *testing.T) { - indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) - for _, gw := range []*unstructured.Unstructured{ - gatewayPEP("global", "ate-system", map[string]string{ - egress.LabelPEP: "true", - }, "HTTP", int64(15080)), - gatewayPEP("space", "ate-system", map[string]string{ - egress.LabelPEP: "true", - egress.LabelAtespace: "space-a", - }, "HTTP", int64(15081)), - gatewayPEP("actor", "ate-system", map[string]string{ - egress.LabelPEP: "true", - egress.LabelAtespace: "space-a", - egress.LabelActor: "actor-a", - }, "HTTP", int64(15082)), - gatewayPEP("other-actor", "ate-system", map[string]string{ - egress.LabelPEP: "true", - egress.LabelAtespace: "space-a", - egress.LabelActor: "actor-b", - }, "HTTP", int64(15083)), - gatewayPEP("other-space", "ate-system", map[string]string{ - egress.LabelPEP: "true", - egress.LabelAtespace: "space-b", - }, "HTTP", int64(15084)), - gatewayPEP("unlabeled", "ate-system", nil, "HTTP", int64(15085)), - } { - if err := indexer.Add(gw); err != nil { - t.Fatalf("indexer.Add() error = %v", err) - } - } +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" + ) - got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") + // 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) } - want := "actor.ate-system.svc.cluster.local:15082" - if got != want { - t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) - } -} - -func TestResolveEgressPEPAddressFallsBackToAtespaceThenGlobal(t *testing.T) { - indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) - for _, gw := range []*unstructured.Unstructured{ - gatewayPEP("global", "ate-system", map[string]string{ - egress.LabelPEP: "true", - }, "HTTP", int64(15080)), - gatewayPEP("space", "ate-system", map[string]string{ - egress.LabelPEP: "true", - egress.LabelAtespace: "space-a", - }, "HTTP", int64(15081)), - } { - if err := indexer.Add(gw); err != nil { - t.Fatalf("indexer.Add() error = %v", err) - } + if got != actorPEP { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, actorPEP) } - got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-without-specific-pep") + // 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 want := "space.ate-system.svc.cluster.local:15081"; got != want { - t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + if got != spacePEP { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, spacePEP) } - got, err = resolveEgressPEPAddress(t.Context(), indexer, "space-without-specific-pep", "actor-a") + // 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 want := "global.ate-system.svc.cluster.local:15080"; got != want { - t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + if got != globalPEP { + t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, globalPEP) } } -func TestResolveEgressPEPAddressRequiresHTTPListener(t *testing.T) { - indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) - if err := indexer.Add(gatewayPEP("tcp-only", "ate-system", map[string]string{ - egress.LabelPEP: "true", - }, "TCP", int64(15080))); err != nil { - t.Fatalf("indexer.Add() error = %v", err) - } - - if _, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a"); err == nil { - t.Fatal("resolveEgressPEPAddress() error = nil, want error") +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 gatewayPEP(name, namespace string, labels map[string]string, protocol string, port int64) *unstructured.Unstructured { - u := &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "Gateway", - "spec": map[string]any{ - "listeners": []any{ - map[string]any{ - "name": "http", - "protocol": protocol, - "port": port, - }, - }, - }, - "status": map[string]any{ - "conditions": []any{ - map[string]any{ - "type": "Programmed", - "status": "True", - }, - }, - }, - }, +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") } - u.SetName(name) - u.SetNamespace(namespace) - u.SetLabels(labels) - return u } -func TestResolveEgressPEPAddressSkipsUnprogrammedGateway(t *testing.T) { - indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) - unprogrammed := gatewayPEP("actor", "ate-system", map[string]string{ - egress.LabelPEP: "true", - egress.LabelAtespace: "space-a", - egress.LabelActor: "actor-a", - }, "HTTP", int64(15082)) - if err := unstructured.SetNestedSlice(unprogrammed.Object, []any{ - map[string]any{"type": "Programmed", "status": "False"}, - }, "status", "conditions"); err != nil { - t.Fatalf("SetNestedSlice() error = %v", err) - } - for _, gw := range []*unstructured.Unstructured{ - unprogrammed, - gatewayPEP("global", "ate-system", map[string]string{ - egress.LabelPEP: "true", - }, "HTTP", int64(15080)), - } { - if err := indexer.Add(gw); err != nil { - t.Fatalf("indexer.Add() error = %v", err) - } +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) } - - // The unprogrammed actor-scoped PEP is skipped; resolution falls back to - // the programmed global PEP. - got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") - if err != nil { - t.Fatalf("resolveEgressPEPAddress() error = %v", err) + if errs := validateEgressPEPSelector(map[string]string{egress.LabelUseEgressPEP: "missing-port"}, nil); len(errs) == 0 { + t.Fatal("validateEgressPEPSelector() malformed address returned no errors") } - if want := "global.ate-system.svc.cluster.local:15080"; got != want { - t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + // 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 TestResolveEgressPEPAddressActorLabelWithoutAtespaceMatchesNothing(t *testing.T) { - indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) - for _, gw := range []*unstructured.Unstructured{ - // Misconfigured: actor label without an atespace label can never match. - gatewayPEP("broken-actor", "ate-system", map[string]string{ - egress.LabelPEP: "true", - egress.LabelActor: "actor-a", - }, "HTTP", int64(15082)), - gatewayPEP("global", "ate-system", map[string]string{ - egress.LabelPEP: "true", - }, "HTTP", int64(15080)), - } { - if err := indexer.Add(gw); err != nil { - t.Fatalf("indexer.Add() error = %v", err) - } +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) } - - got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") - if err != nil { - t.Fatalf("resolveEgressPEPAddress() error = %v", err) + 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 want := "global.ate-system.svc.cluster.local:15080"; got != want { - t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + if errs := validateLabels(tooMany, nil); len(errs) == 0 { + t.Fatal("validateLabels() too many entries returned no errors") } } -func TestResolveEgressPEPAddressPrefersStatusAddress(t *testing.T) { - indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) - gw := gatewayPEP("global", "ate-system", map[string]string{ - egress.LabelPEP: "true", - }, "HTTP", int64(15080)) - if err := unstructured.SetNestedSlice(gw.Object, []any{ - map[string]any{"type": "Hostname", "value": "pep.example.internal"}, - }, "status", "addresses"); err != nil { - t.Fatalf("SetNestedSlice() error = %v", err) - } - if err := indexer.Add(gw); err != nil { - t.Fatalf("indexer.Add() error = %v", err) +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) } - - got, err := resolveEgressPEPAddress(t.Context(), indexer, "space-a", "actor-a") - if err != nil { - t.Fatalf("resolveEgressPEPAddress() error = %v", err) + if got := normalizeLabels(map[string]string{"drop": ""}); got != nil { + t.Fatalf("normalizeLabels() all-empty = %v, want nil", got) } - if want := "pep.example.internal:15080"; got != want { - t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, want) + if got := normalizeLabels(nil); got != nil { + t.Fatalf("normalizeLabels(nil) = %v, want nil", got) } } diff --git a/cmd/ateapi/internal/controlapi/functional_test.go b/cmd/ateapi/internal/controlapi/functional_test.go index 966065aca..837a94705 100644 --- a/cmd/ateapi/internal/controlapi/functional_test.go +++ b/cmd/ateapi/internal/controlapi/functional_test.go @@ -29,6 +29,7 @@ import ( "github.com/agent-substrate/substrate/cmd/ateapi/internal/store/ateredis" "github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache" "github.com/agent-substrate/substrate/internal/ateinterceptors" + "github.com/agent-substrate/substrate/internal/egress" "github.com/agent-substrate/substrate/internal/envtestbins" "github.com/agent-substrate/substrate/internal/proto/ateletpb" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" @@ -310,7 +311,7 @@ func setupTest(t *testing.T, ns string) *testContext { } dialer := NewAteletDialer(workerInformer.GetIndexer(), ateletInformer.GetIndexer()) - service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient, nil) + service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient, "") // 5. Start REAL gRPC Server for ATE API grpcServer := grpc.NewServer(grpc.UnaryInterceptor(ateinterceptors.ServerUnaryInterceptor)) @@ -1719,6 +1720,75 @@ func TestUpdateActor_Success(t *testing.T) { } } +// TestUpdateActor_MergesLabels verifies UpdateActor merges labels rather than +// replacing them: an absent map leaves labels untouched, a non-empty value sets +// its key, and an empty value deletes it. +func TestUpdateActor_MergesLabels(t *testing.T) { + ns := namespaceForTest("ns-update-actor-labels") + tc := setupTest(t, ns) + defer tc.cleanup() + + createTemplate(t, tc, ns) + + const pep = "pep.example:15008" + actorRef := &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"} + if _, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{ + Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: actorRef.GetAtespace(), Name: actorRef.GetName()}, + ActorTemplateNamespace: ns, + ActorTemplateName: "tmpl1", + Labels: map[string]string{egress.LabelUseEgressPEP: pep, "team": "a"}, + }, + }); err != nil { + t.Fatalf("CreateActor failed: %v", err) + } + + // A labels-unaware update (nil map) must not touch existing labels. + if _, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{Actor: actorRef}); err != nil { + t.Fatalf("UpdateActor (nil labels) failed: %v", err) + } + getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: actorRef}) + if err != nil { + t.Fatalf("GetActor failed: %v", err) + } + wantLabels := map[string]string{egress.LabelUseEgressPEP: pep, "team": "a"} + if diff := cmp.Diff(wantLabels, getResp.GetLabels()); diff != "" { + t.Errorf("labels after nil-labels update mismatch (-want +got):\n%s", diff) + } + + // A partial update sets its key and leaves the others alone. + if _, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{ + Actor: actorRef, + Labels: map[string]string{"team": "b"}, + }); err != nil { + t.Fatalf("UpdateActor (set team) failed: %v", err) + } + getResp, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: actorRef}) + if err != nil { + t.Fatalf("GetActor failed: %v", err) + } + wantLabels = map[string]string{egress.LabelUseEgressPEP: pep, "team": "b"} + if diff := cmp.Diff(wantLabels, getResp.GetLabels()); diff != "" { + t.Errorf("labels after partial update mismatch (-want +got):\n%s", diff) + } + + // An empty value deletes its key. + if _, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{ + Actor: actorRef, + Labels: map[string]string{egress.LabelUseEgressPEP: ""}, + }); err != nil { + t.Fatalf("UpdateActor (clear egress PEP) failed: %v", err) + } + getResp, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: actorRef}) + if err != nil { + t.Fatalf("GetActor failed: %v", err) + } + wantLabels = map[string]string{"team": "b"} + if diff := cmp.Diff(wantLabels, getResp.GetLabels()); diff != "" { + t.Errorf("labels after delete-by-empty mismatch (-want +got):\n%s", diff) + } +} + func TestUpdateActor_NotFound(t *testing.T) { ns := namespaceForTest("ns-update-actor-notfound") tc := setupTest(t, ns) diff --git a/cmd/ateapi/internal/controlapi/informer.go b/cmd/ateapi/internal/controlapi/informer.go index cb448e4b8..1f082cdd0 100644 --- a/cmd/ateapi/internal/controlapi/informer.go +++ b/cmd/ateapi/internal/controlapi/informer.go @@ -17,12 +17,8 @@ package controlapi import ( "time" - "github.com/agent-substrate/substrate/internal/egress" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" @@ -36,13 +32,6 @@ const ( workerPodLabel = "ate.dev/worker-pool" ) -// GatewayGVR is the Gateway API resource ate-api watches for egress PEPs. -var GatewayGVR = schema.GroupVersionResource{ - Group: "gateway.networking.k8s.io", - Version: "v1", - Resource: "gateways", -} - // AteletInformer creates a SharedInformerFactory and SharedIndexInformer for Atelet pods. func AteletInformer(kc kubernetes.Interface) (informers.SharedInformerFactory, cache.SharedIndexInformer) { factory := informers.NewSharedInformerFactoryWithOptions(kc, 0, @@ -84,17 +73,3 @@ func WorkerPodInformer(kc kubernetes.Interface) (informers.SharedInformerFactory return factory, workerPodInformer } - -// GatewayPEPInformer watches labeled Gateways that ate-api can use as egress PEPs. -// -// The watch is cluster-wide and trusts Gateway labels: RBAC on Gateways is the -// security boundary (see docs/egress-capture.md, "Trust model"). If Gateway -// write access can't be restricted to the platform team, scope this watch to -// an allowlist of PEP namespaces instead. -func GatewayPEPInformer(dc dynamic.Interface) (dynamicinformer.DynamicSharedInformerFactory, cache.SharedIndexInformer) { - factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dc, 0, metav1.NamespaceAll, func(options *metav1.ListOptions) { - options.LabelSelector = egress.LabelPEP - }) - gatewayInformer := factory.ForResource(GatewayGVR).Informer() - return factory, gatewayInformer -} diff --git a/cmd/ateapi/internal/controlapi/service.go b/cmd/ateapi/internal/controlapi/service.go index 7a09f5157..9aa44ab9b 100644 --- a/cmd/ateapi/internal/controlapi/service.go +++ b/cmd/ateapi/internal/controlapi/service.go @@ -20,7 +20,6 @@ import ( listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/cache" ) // Service implements ateapipb.Control @@ -44,14 +43,14 @@ func NewService( sandboxConfigLister listersv1alpha1.SandboxConfigLister, dialer *AteletDialer, kubeClient kubernetes.Interface, - gatewayPEPIndexer cache.Indexer, + defaultEgressPEP string, ) *Service { s := &Service{ persistence: persistence, actorTemplateLister: actorTemplateLister, workerPoolLister: workerPoolLister, dialer: dialer, - actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient, gatewayPEPIndexer), + actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient, defaultEgressPEP), } return s diff --git a/cmd/ateapi/internal/controlapi/update_actor.go b/cmd/ateapi/internal/controlapi/update_actor.go index db3384121..56630ea65 100644 --- a/cmd/ateapi/internal/controlapi/update_actor.go +++ b/cmd/ateapi/internal/controlapi/update_actor.go @@ -40,6 +40,24 @@ func (s *Service) UpdateActor(ctx context.Context, req *ateapipb.UpdateActorRequ return nil, fmt.Errorf("while getting actor: %w", err) } actor.WorkerSelector = req.GetWorkerSelector() + // Labels are merged, not replaced: an absent/empty map leaves the actor's + // labels untouched (so a labels-unaware caller cannot wipe selectors set by + // someone else), a key with a non-empty value sets it, and a key with an + // empty value deletes it. Proto3 cannot distinguish a nil map from an empty + // one on the wire, so delete-by-empty-value is the explicit clear path. + for k, v := range req.GetLabels() { + if v == "" { + delete(actor.Labels, k) + continue + } + if actor.Labels == nil { + actor.Labels = map[string]string{} + } + actor.Labels[k] = v + } + if len(actor.Labels) == 0 { + actor.Labels = nil + } updated, err := s.persistence.UpdateActor(ctx, actor, actor.GetMetadata().GetVersion()) if err != nil { @@ -66,6 +84,9 @@ func validateUpdateActorRequest(req *ateapipb.UpdateActorRequest) error { errs = append(errs, validateSelector(val, fldPath.Child("worker_selector"))...) } + errs = append(errs, validateLabels(req.GetLabels(), fldPath.Child("labels"))...) + errs = append(errs, validateEgressPEPSelector(req.GetLabels(), fldPath.Child("labels"))...) + if len(errs) > 0 { return status.Error(codes.InvalidArgument, errs.ToAggregate().Error()) } diff --git a/cmd/ateapi/internal/controlapi/workflow.go b/cmd/ateapi/internal/controlapi/workflow.go index f332266aa..04cdc1de7 100644 --- a/cmd/ateapi/internal/controlapi/workflow.go +++ b/cmd/ateapi/internal/controlapi/workflow.go @@ -31,7 +31,6 @@ import ( "google.golang.org/grpc/status" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/cache" ) // WorkflowStep represents a single, idempotent operation in a workflow graph. @@ -123,7 +122,7 @@ type ActorWorkflow struct { workerPoolLister listersv1alpha1.WorkerPoolLister sandboxConfigLister listersv1alpha1.SandboxConfigLister kubeClient kubernetes.Interface - gatewayPEPIndexer cache.Indexer + defaultEgressPEP string secretCache *envSecretCache } @@ -136,7 +135,7 @@ func NewActorWorkflow( workerPoolLister listersv1alpha1.WorkerPoolLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister, kubeClient kubernetes.Interface, - gatewayPEPIndexer cache.Indexer, + defaultEgressPEP string, ) *ActorWorkflow { return &ActorWorkflow{ store: store, @@ -146,7 +145,7 @@ func NewActorWorkflow( workerPoolLister: workerPoolLister, sandboxConfigLister: sandboxConfigLister, kubeClient: kubeClient, - gatewayPEPIndexer: gatewayPEPIndexer, + defaultEgressPEP: defaultEgressPEP, secretCache: newEnvSecretCache(envSecretCacheTTL), } } @@ -170,7 +169,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, atespace, name string, steps := []WorkflowStep[*ResumeInput, *ResumeState]{ &LoadActorForResumeStep{store: w.store, actorTemplateLister: w.actorTemplateLister}, - &AssignWorkerStep{store: w.store, workerCache: w.workerCache, gatewayPEPIndexer: w.gatewayPEPIndexer}, + &AssignWorkerStep{store: w.store, workerCache: w.workerCache, defaultEgressPEP: w.defaultEgressPEP}, &CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister}, &FinalizeRunningStep{store: w.store}, } diff --git a/cmd/ateapi/internal/controlapi/workflow_resume.go b/cmd/ateapi/internal/controlapi/workflow_resume.go index b93d6c962..c128406b1 100644 --- a/cmd/ateapi/internal/controlapi/workflow_resume.go +++ b/cmd/ateapi/internal/controlapi/workflow_resume.go @@ -36,7 +36,6 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/cache" ) // ResumeInput holds the immutable parameters requested by the client. @@ -107,9 +106,9 @@ func isWorkerEligibleForActor(worker *ateapipb.Worker, templateClass atev1alpha1 } type AssignWorkerStep struct { - store store.Interface - workerCache *workercache.Cache - gatewayPEPIndexer cache.Indexer + store store.Interface + workerCache *workercache.Cache + defaultEgressPEP string } func (s *AssignWorkerStep) Name() string { return "AssignWorker" } @@ -119,14 +118,29 @@ func (s *AssignWorkerStep) IsComplete(ctx context.Context, input *ResumeInput, s } func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, state *ResumeState) error { // Resolve the egress PEP for this actor and record it on the actor so the - // binding is observable (e.g. "which actors use the global PEP?"). The binding - // is computed dynamically against whatever PEP Gateways exist right now, so we - // re-resolve on every resume. An empty address means no PEP matched and egress - // capture stays off. CallAteletRestoreStep reads this back to tell ateom. - // Resolved before any worker is claimed so a resolution error (e.g. a - // malformed labeled Gateway) fails the resume without stranding a worker - // assignment. - egressPEPAddress, err := resolveEgressPEPAddress(ctx, s.gatewayPEPIndexer, state.Actor.GetMetadata().GetAtespace(), state.Actor.GetMetadata().GetName()) + // binding is observable (e.g. "which actors use the global PEP?"). Selection + // is consumer-driven (actor > atespace > global default) and re-resolved on + // every resume, so we load the atespace to read its ate.dev/use-egress-pep + // selector. An empty address means no PEP matched and egress capture stays + // off. CallAteletRestoreStep reads this back to tell ateom. Resolved before + // any worker is claimed so a resolution error (e.g. a malformed address) + // fails the resume without stranding a worker assignment. + // A missing atespace record must not strand the actor: actors can predate + // mandatory atespace records or survive the non-atomic AtespaceExists / + // DeleteAtespace race, and before egress selection resume never read the + // atespace at all. GetLabels is nil-safe, so a nil atespace degrades to + // actor-label / global-default resolution. + atespace, err := s.store.GetAtespace(ctx, state.Actor.GetMetadata().GetAtespace()) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("while loading atespace for egress PEP resolution: %w", err) + } + slog.WarnContext(ctx, "Atespace record not found during egress PEP resolution; using actor/global tiers only", + "actorId", state.Actor.GetMetadata().GetName(), + "atespace", state.Actor.GetMetadata().GetAtespace()) + atespace = nil + } + egressPEPAddress, err := resolveEgressPEPAddress(state.Actor, atespace, s.defaultEgressPEP) if err != nil { return err } diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index c3741c2e8..b13baf643 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -48,11 +48,8 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/reflection" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/cache" ) var ( @@ -73,6 +70,8 @@ var ( sessionIDCAPoolFile = pflag.String("session-id-ca-pool", "", "The file that contains the CA pool for signing session JWTs") workerpoolCACerts = pflag.String("workerpool-ca-certs", "", "The file that contains the CA for verifying workerpool client certificates.") + defaultEgressPEP = pflag.String("default-egress-pep", "", "The global-default egress PEP address, as :. Used for actors and atespaces that set no ate.dev/use-egress-pep selector. Empty disables the global default (capture off unless a per-actor/atespace selector matches).") + showVersion = pflag.Bool("version", false, "Print version and exit.") authMode = pflag.String("auth-mode", "mtls", "Auth mode for incoming gRPC: mtls|jwt. 'mtls' (default) relies on transport-level mTLS for client identity. 'jwt' additionally requires a Kubernetes ServiceAccount Bearer token on every RPC. Substrate will drop support for JWT auth mode once the Pod Certificates feature is enabled by default in the minimum supported Kubernetes version.") clientJWTCAFile = pflag.String("client-jwt-ca-cert", ateapiauth.DefaultServiceAccountCAFile, "CA cert file used to verify TLS when fetching the OIDC discovery document and JWKS for JWT authentication. Defaults to the in-cluster service account CA.") @@ -103,6 +102,19 @@ func main() { defer serverboot.ShutdownProvider("MeterProvider", mp.Shutdown) loadFlagsFromEnv() + + // A malformed --default-egress-pep degrades to "no global default" rather than + // crash-looping the deployment: the flag only affects actors that fall through + // to the global tier, and per-actor/atespace selectors keep working. We clear + // it so the bad value can't reach ateom and fail every fall-through resume at + // connect time; the warning is the operator's signal to fix the flag. Cleared + // before logFlagValues so the "Final flag values" line reports the effective + // (empty) value, not the ignored one. + if err := controlapi.ValidateDefaultEgressPEPAddress(*defaultEgressPEP); err != nil { + slog.WarnContext(ctx, "Ignoring invalid --default-egress-pep; global-default egress PEP disabled", "err", err) + *defaultEgressPEP = "" + } + logFlagValues(ctx) authModeParsed, err := ateapiauth.ParseMode(*authMode) @@ -115,7 +127,7 @@ func main() { serverboot.Fatal(ctx, "Failed to set up Redis/Valkey", err) } - clientset, ateClient, dynamicClient, err := newKubeClients() + clientset, ateClient, err := newKubeClients() if err != nil { serverboot.Fatal(ctx, "Failed to create Kubernetes clients", err) } @@ -153,32 +165,8 @@ func main() { ateletPodInformerFactory.WaitForCacheSync(stopCh) ateFactory.WaitForCacheSync(stopCh) - // The Gateway PEP watch is optional: clusters without the Gateway API CRD - // (any non-egress install) must start cleanly, so only create the informer - // when the resource is served. Without it the PEP indexer stays nil and - // resolveEgressPEPAddress resolves no PEP, leaving egress capture off. - // Installing the CRD later requires an ate-api restart to start the watch. - var gatewayPEPIndexer cache.Indexer - if gatewayAPIAvailable(ctx, clientset) { - gatewayPEPInformerFactory, gatewayPEPInformer := controlapi.GatewayPEPInformer(dynamicClient) - gatewayPEPInformerFactory.Start(stopCh) - // Bound the sync wait: the CRD can be served while list/watch is still - // denied (e.g. ate-api rolled before the updated ClusterRole), and an - // unbounded wait would hang startup. On timeout the indexer is used - // anyway — it stays empty (capture off) until the watch syncs, and - // recovers live once RBAC is fixed. - syncCtx, cancelSync := context.WithTimeout(ctx, time.Minute) - if !cache.WaitForCacheSync(syncCtx.Done(), gatewayPEPInformer.HasSynced) { - slog.ErrorContext(ctx, "Gateway PEP informer sync timed out; egress PEP resolution degraded until it syncs") - } - cancelSync() - gatewayPEPIndexer = gatewayPEPInformer.GetIndexer() - } else { - slog.InfoContext(ctx, "Gateway API resource not served; egress PEP resolution disabled") - } - dialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer()) - sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, clientset, gatewayPEPIndexer) + sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, clientset, *defaultEgressPEP) jwtIssuerDiscoveryClient := buildK8sServiceAccountIssuerDiscoveryClient(ctx, *clientJWTCAFile, *clientJWTIssuer) if authModeParsed == ateapiauth.ModeJWT && jwtIssuerDiscoveryClient == nil { @@ -243,6 +231,7 @@ func loadFlagsFromEnv() { {redisUseIAMAuth, "ATE_API_REDIS_USE_IAM_AUTH"}, {redisTLSServerName, "ATE_API_REDIS_TLS_SERVER_NAME"}, {redisClientCert, "ATE_API_REDIS_CLIENT_CERT"}, + {defaultEgressPEP, "ATE_API_DEFAULT_EGRESS_PEP"}, } for _, o := range overrides { if *o.flag == "@env" { @@ -266,6 +255,7 @@ func logFlagValues(ctx context.Context) { slog.String("session-id-ca-pool", *sessionIDCAPoolFile), slog.String("workerpool-ca-certs", *workerpoolCACerts), slog.String("auth-mode", *authMode), + slog.String("default-egress-pep", *defaultEgressPEP), ) } @@ -354,54 +344,22 @@ func pingRedisWithRetries(ctx context.Context, client *redis.ClusterClient) erro return fmt.Errorf("ping Redis/Valkey after 30 retries: %w", pingErr) } -// gatewayAPIAvailable reports whether the cluster serves the Gateway API -// gateways resource. Clusters without the Gateway API CRDs must not gate -// ate-api startup on a watch that can never sync. -// -// Checked once at startup: a NotFound means the CRD is absent (expected on -// non-egress PEP setups), any other error means discovery failed. Either way -// egress PEP resolution is disabled until ate-api is restarted — the failed -// case logs at Error since it may fail open on a cluster that serves the CRD. -// -// TODO: missing gateway api currently disables egress until restart, can add retries here -func gatewayAPIAvailable(ctx context.Context, clientset *kubernetes.Clientset) bool { - resources, err := clientset.Discovery().ServerResourcesForGroupVersion(controlapi.GatewayGVR.GroupVersion().String()) - if err != nil { - if apierrors.IsNotFound(err) { - slog.InfoContext(ctx, "Gateway API not served; egress PEP resolution disabled") - } else { - slog.ErrorContext(ctx, "Gateway API discovery failed; egress PEP resolution disabled until restart", slog.Any("err", err)) - } - return false - } - for _, r := range resources.APIResources { - if r.Name == controlapi.GatewayGVR.Resource { - return true - } - } - return false -} - // newKubeClients builds the standard Kubernetes clientset and the ate // (substrate CRD) clientset from in-cluster config. -func newKubeClients() (*kubernetes.Clientset, versioned.Interface, dynamic.Interface, error) { +func newKubeClients() (*kubernetes.Clientset, versioned.Interface, error) { config, err := rest.InClusterConfig() if err != nil { - return nil, nil, nil, fmt.Errorf("get cluster config: %w", err) + return nil, nil, fmt.Errorf("get cluster config: %w", err) } clientset, err := kubernetes.NewForConfig(config) if err != nil { - return nil, nil, nil, fmt.Errorf("create clientset: %w", err) + return nil, nil, fmt.Errorf("create clientset: %w", err) } ateClient, err := versioned.NewForConfig(config) if err != nil { - return nil, nil, nil, fmt.Errorf("create ate clientset: %w", err) - } - dynamicClient, err := dynamic.NewForConfig(config) - if err != nil { - return nil, nil, nil, fmt.Errorf("create dynamic clientset: %w", err) + return nil, nil, fmt.Errorf("create ate clientset: %w", err) } - return clientset, ateClient, dynamicClient, nil + return clientset, ateClient, nil } // buildServerCreds loads the workerpool CA pool (if configured) and diff --git a/cmd/kubectl-ate/internal/cmd/create_actor.go b/cmd/kubectl-ate/internal/cmd/create_actor.go index 6a8c750cd..9e967c28d 100644 --- a/cmd/kubectl-ate/internal/cmd/create_actor.go +++ b/cmd/kubectl-ate/internal/cmd/create_actor.go @@ -20,12 +20,14 @@ import ( "github.com/agent-substrate/substrate/cmd/kubectl-ate/internal/printer" "github.com/agent-substrate/substrate/internal/ateclient" + "github.com/agent-substrate/substrate/internal/egress" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/spf13/cobra" ) var templateFlag string var atespaceFlag string +var createActorEgressPEPFlag string var createActorCmd = &cobra.Command{ Use: "actor ", @@ -45,6 +47,11 @@ var createActorCmd = &cobra.Command{ return fmt.Errorf("malformed --template: %s (expected /)", templateFlag) } + var labels map[string]string + if createActorEgressPEPFlag != "" { + labels = map[string]string{egress.LabelUseEgressPEP: createActorEgressPEPFlag} + } + resp, err := apiClient.CreateActor(ctx, &ateapipb.CreateActorRequest{ Actor: &ateapipb.Actor{ Metadata: &ateapipb.ResourceMetadata{ @@ -53,6 +60,7 @@ var createActorCmd = &cobra.Command{ }, ActorTemplateNamespace: parts[0], ActorTemplateName: parts[1], + Labels: labels, }, }) if err != nil { @@ -68,5 +76,6 @@ func init() { _ = createActorCmd.MarkFlagRequired("template") createActorCmd.Flags().StringVarP(&atespaceFlag, "atespace", "a", "", "Atespace to create the actor in (required)") _ = createActorCmd.MarkFlagRequired("atespace") + createActorCmd.Flags().StringVar(&createActorEgressPEPFlag, "egress-pep", "", "Egress PEP address for this actor, as :. Sets the ate.dev/use-egress-pep selector (highest precedence: actor > atespace > global default).") createCmd.AddCommand(createActorCmd) } diff --git a/cmd/kubectl-ate/internal/cmd/create_atespace.go b/cmd/kubectl-ate/internal/cmd/create_atespace.go index 518e31fae..cdd2e695d 100644 --- a/cmd/kubectl-ate/internal/cmd/create_atespace.go +++ b/cmd/kubectl-ate/internal/cmd/create_atespace.go @@ -19,10 +19,13 @@ import ( "github.com/agent-substrate/substrate/cmd/kubectl-ate/internal/printer" "github.com/agent-substrate/substrate/internal/ateclient" + "github.com/agent-substrate/substrate/internal/egress" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/spf13/cobra" ) +var createAtespaceEgressPEPFlag string + var createAtespaceCmd = &cobra.Command{ Use: "atespace [name]", Short: "Create an atespace", @@ -35,11 +38,17 @@ var createAtespaceCmd = &cobra.Command{ } defer apiClient.Close() + var labels map[string]string + if createAtespaceEgressPEPFlag != "" { + labels = map[string]string{egress.LabelUseEgressPEP: createAtespaceEgressPEPFlag} + } + resp, err := apiClient.CreateAtespace(ctx, &ateapipb.CreateAtespaceRequest{ Atespace: &ateapipb.Atespace{ Metadata: &ateapipb.ResourceMetadata{ Name: args[0], }, + Labels: labels, }, }) if err != nil { @@ -51,5 +60,6 @@ var createAtespaceCmd = &cobra.Command{ } func init() { + createAtespaceCmd.Flags().StringVar(&createAtespaceEgressPEPFlag, "egress-pep", "", "Egress PEP address for all actors in this atespace, as :. Sets the ate.dev/use-egress-pep selector (precedence: actor > atespace > global default).") createCmd.AddCommand(createAtespaceCmd) } diff --git a/cmd/kubectl-ate/internal/cmd/update.go b/cmd/kubectl-ate/internal/cmd/update.go new file mode 100644 index 000000000..998548b37 --- /dev/null +++ b/cmd/kubectl-ate/internal/cmd/update.go @@ -0,0 +1,28 @@ +// 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 cmd + +import ( + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update a resource", +} + +func init() { + rootCmd.AddCommand(updateCmd) +} diff --git a/cmd/kubectl-ate/internal/cmd/update_actor.go b/cmd/kubectl-ate/internal/cmd/update_actor.go new file mode 100644 index 000000000..84350acb1 --- /dev/null +++ b/cmd/kubectl-ate/internal/cmd/update_actor.go @@ -0,0 +1,81 @@ +// 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 cmd + +import ( + "fmt" + + "github.com/agent-substrate/substrate/cmd/kubectl-ate/internal/printer" + "github.com/agent-substrate/substrate/internal/ateclient" + "github.com/agent-substrate/substrate/internal/egress" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/spf13/cobra" +) + +var updateActorAtespaceFlag string +var updateActorEgressPEPFlag string + +var updateActorCmd = &cobra.Command{ + Use: "actor ", + Short: "Update an actor", + Long: "Update mutable fields on an actor. Changes take effect on the next resume. " + + "Use --egress-pep to set the ate.dev/use-egress-pep selector to a : " + + "PEP address; pass an empty value to clear it. Other mutable fields (e.g. the " + + "worker selector) are preserved.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + apiClient, err := ateclient.NewClient(ctx, kubeconfig, k8sContext, endpoint, traceEnabled) + if err != nil { + return fmt.Errorf("failed to connect to ate-api-server: %w", err) + } + defer apiClient.Close() + + actorRef := &ateapipb.ObjectRef{Atespace: updateActorAtespaceFlag, Name: args[0]} + + // UpdateActor merges labels (empty value deletes the key), so only the + // changed label is sent. The worker selector is still replaced wholesale, + // so read the current actor first to echo it back unchanged. + // TODO: UpdateActorRequest carries no version token, so a concurrent + // update landing between this read and the write below is silently lost. + current, err := apiClient.GetActor(ctx, &ateapipb.GetActorRequest{Actor: actorRef}) + if err != nil { + return fmt.Errorf("failed to get actor: %w", err) + } + + var labels map[string]string + if cmd.Flags().Changed("egress-pep") { + labels = map[string]string{egress.LabelUseEgressPEP: updateActorEgressPEPFlag} + } + + resp, err := apiClient.UpdateActor(ctx, &ateapipb.UpdateActorRequest{ + Actor: actorRef, + WorkerSelector: current.GetWorkerSelector(), + Labels: labels, + }) + if err != nil { + return fmt.Errorf("failed to update actor: %w", err) + } + + return printer.PrintActor(resp.GetActor(), outputFmt) + }, +} + +func init() { + updateActorCmd.Flags().StringVarP(&updateActorAtespaceFlag, "atespace", "a", "", "Atespace the actor lives in (required)") + _ = updateActorCmd.MarkFlagRequired("atespace") + updateActorCmd.Flags().StringVar(&updateActorEgressPEPFlag, "egress-pep", "", "Egress PEP address for this actor, as :. Sets the ate.dev/use-egress-pep selector; empty clears it. Takes effect on the next resume.") + updateCmd.AddCommand(updateActorCmd) +} diff --git a/docs/egress-capture.md b/docs/egress-capture.md index 11a76920d..1cad80472 100644 --- a/docs/egress-capture.md +++ b/docs/egress-capture.md @@ -7,14 +7,19 @@ by default. ## Architecture -Egress capture has no global on/off switch. ate-api watches Gateways labeled -`ate.dev/egress-pep`. On actor resume, ate-api picks the best matching PEP -Gateway for that actor and sends one optional PEP address to ateom. An empty PEP -address means no redirect, so capture is enabled per actor only when a matching -programmed labeled Gateway with an HTTP listener resolves. The reusable capture -core lives in `internal/egress`: -it owns capture listeners, authority derivation, CONNECT tunnel transports, and -byte proxying. The runtime-specific `ateom` egress proxy setup supplies the +Egress capture has no global on/off switch. PEP selection is **consumer-driven**, +following the Istio ambient waypoint model: an actor (or its atespace, or a +global default) names the egress PEP it wants via the `ate.dev/use-egress-pep` +selector, exactly as Istio's `istio.io/use-waypoint` names a waypoint. The +difference from Istio is that the selector's value is the PEP **address** +(`:`) directly — ate-api has **no Gateway API dependency at all**: it +does not watch, look up, or validate any Gateway resource. On actor resume it +reads the actor's selector (falling back to the atespace's, then the +`--default-egress-pep` flag) and sends that address to ateom as given. An empty +address means no redirect, so capture is enabled per actor only when a selector +resolves to an address. The reusable capture core lives in `internal/egress`: it +owns capture listeners, authority derivation, CONNECT tunnel transports, and byte +proxying. The runtime-specific `ateom` egress proxy setup supplies the original-destination lookup and packet-capture rules. The current gVisor and MicroVM implementations start a local capture listener @@ -36,15 +41,12 @@ actor connection: | Other TCP | Original destination address | `203.0.113.10:2222` | The shared capture core then opens a plaintext HTTP/2 CONNECT stream to the PEP -address selected by ate-api. Only Gateways with condition `Programmed=True` are -candidates; an unprovisioned Gateway is skipped so resolution falls back to the -next-best PEP. The address host comes from the Gateway's `status.addresses` -when the implementation publishes one, and otherwise falls back to -`..svc.cluster.local`, matching the agentgateway service -convention (agentgateway on kind publishes no address because the LoadBalancer -stays pending). The port is the Gateway's HTTP listener port. Agentgateway maps -the CONNECT authority to its configured TCP listener and routes the tunnel to a -Kubernetes Service backed by an EndpointSlice. +address ate-api resolved for the actor. That address is whatever the selector +supplied — for the demo, the agentgateway service DNS name +`ate-egress.agentgateway-system.svc.cluster.local:15008`. ate-api does not +resolve or health-check it; a wrong address simply fails the tunnel at connect +time. Agentgateway maps the CONNECT authority to its configured TCP listener and +routes the tunnel to a Kubernetes Service backed by an EndpointSlice. The demo setup configures only `httpbin.org:443` for egress. Any other CONNECT authority, including plaintext HTTP destinations or fallback original IP:port @@ -55,52 +57,47 @@ CONNECT succeeds. ### Selecting a PEP for an actor -`ate.dev/egress-pep` is a marker: its value is ignored, any Gateway carrying -the label key is a candidate PEP. The atespace/actor labels scope which actors -a candidate serves: +Selection lives on the consumer via the `ate.dev/use-egress-pep` label, whose +value is the PEP address `:`. ate-api reads it from three tiers and +uses the highest-precedence one that is set: -| Gateway labels | Scope | Match precedence | +| Selector | Scope | Precedence | | --- | --- | --- | -| `ate.dev/egress-pep` only | Global (any actor) | lowest | -| `ate.dev/egress-pep` + `ate.dev/atespace=` | All actors in an atespace | medium | -| `ate.dev/egress-pep` + `ate.dev/atespace=` + `ate.dev/actor=` | One actor | highest | +| `--default-egress-pep` flag on ate-api | Global (any actor) | lowest | +| `ate.dev/use-egress-pep` on the **Atespace** | All actors in the atespace | medium | +| `ate.dev/use-egress-pep` on the **Actor** | One actor | highest | -Actor scoping requires **both** scoping labels; `ate.dev/actor` alone matches -no actor and ate-api logs a warning. On resume, ate-api picks the -highest-precedence match (`resolveEgressPEPAddress` in -`cmd/ateapi/internal/controlapi/egress_pep.go`). +On resume, ate-api walks actor → atespace → global default in order and uses the +first tier whose value is set (`resolveEgressPEPAddress` in +`cmd/ateapi/internal/controlapi/egress_pep.go`). The value is passed straight +through to ateom; ate-api never contacts the Gateway API. -#### Tie-breaking +#### Fall-through | Situation | Result | | --- | --- | -| Multiple candidates tied at the top score | Lowest `(namespace, name)` wins; the others are **silently ignored** | -| No matching programmed candidate | Empty PEP address → no redirect, capture off | +| A tier's selector is unset | Skipped; ate-api uses the next tier | +| No tier is set | Empty PEP address → no redirect, capture off | +| An actor/atespace selector is not a valid `:` | Configuration error; rejected at `CreateActor` / `UpdateActor` / `CreateAtespace`, and the resume fails loudly as defense-in-depth | +| The global default (`--default-egress-pep`) is not a valid `:` | ate-api logs a warning at startup and degrades to no global default (the value is cleared, never sent to ateom); actor/atespace selectors are unaffected | -Don't deploy multiple PEPs at the same tier for the same actor — use the -scoping labels to raise the intended one's tier instead of relying on name -order. +Each tier supplies exactly one address, so selection is unambiguous — there is no +tie-breaking. #### Trust model -The `ate.dev/egress-pep` label **is** the PEP control surface: ate-api trusts -every labeled Gateway in every namespace, so Gateway RBAC is the security -boundary — restrict Gateway create/update to the platform team. Anyone who can -label a Gateway can: - -- **Intercept actor egress**: copy an actor's `ate.dev/atespace` + - `ate.dev/actor` labels onto their own Gateway to out-score its real PEP. -- **Block resumes**: label a programmed Gateway with no HTTP listener (broken - PEP config fails PEP resolution by design). - -Substrate deliberately does not second-guess labeled Gateways. If Gateway RBAC -can't be that strict, scope the watch to an allowlist of PEP namespaces in -ate-api first. +The control surface is the ate-api API. Who can point an actor at a PEP is +governed entirely by RBAC on `CreateActor` / `UpdateActor` / `CreateAtespace` +and on the `--default-egress-pep` flag (set at install). Because the selector +carries a raw address and ate-api enforces no allowlist, anyone who can set an +actor/atespace selector can direct that actor's egress tunnel to any reachable +`:`. Restrict those RPCs to the platform team. (This trades the old +Gateway-label allowlist for zero Gateway API dependency — an explicit choice.) #### When the binding is (re)computed The PEP binding is a **snapshot taken at resume**, recorded on the actor as -`egress_pep_address`. Gateway relabels have no effect on RUNNING actors. +`egress_pep_address`. Selector changes have no effect on RUNNING actors. ``` create @@ -110,13 +107,14 @@ The PEP binding is a **snapshot taken at resume**, recorded on the actor as │ resume / boot │ ▼ │ RESUMING ── resolve PEP now: │ - │ AssignWorkerStep scores every │ - │ labeled Gateway and writes │ + │ AssignWorkerStep reads the │ + │ actor/atespace/global selector │ + │ and writes its address to │ │ actor.egress_pep_address │ ▼ │ RUNNING ── uses the PEP captured at │ │ resume for its whole lifetime; │ - │ Gateway relabels are IGNORED │ + │ selector changes are IGNORED │ │ suspend / pause │ ▼ │ SUSPENDED / PAUSED ── egress_pep_address ─────┘ @@ -132,7 +130,6 @@ sequenceDiagram participant CLI as kubectl ate participant API as ate-api - participant GW as Gateway indexer participant ST as Redis/Valkey participant LET as atelet participant OM as ateom @@ -143,9 +140,8 @@ sequenceDiagram rect rgb(235, 243, 255) Note over CLI,ST: Resume CLI->>API: resume actor my-egress-1 -a demo - API->>ST: Load suspended actor - API->>GW: Resolve egress PEP for actor - GW-->>API: Best programmed Gateway address + API->>ST: Load suspended actor + atespace + API->>API: Resolve PEP address
actor > atespace > global default API->>ST: Claim worker API->>ST: Set RESUMING and EgressPepAddress end @@ -173,7 +169,7 @@ sequenceDiagram end rect rgb(255, 235, 238) - Note over CLI,ST: Suspend + Note over CLI,ST: Suspend (pause clears EgressPepAddress the same way) CLI->>API: suspend actor API->>ST: Set SUSPENDING API->>LET: Checkpoint @@ -182,12 +178,12 @@ sequenceDiagram OM-->>LET: snapshot files LET-->>API: ok (snapshot uploaded) API->>ST: Release worker
clear EgressPepAddress - Note over API,GW: Next resume re-resolves Gateway binding + Note over API,ST: Next resume re-resolves the selector end ``` -To move a running actor to a different PEP: relabel the Gateways **and** cycle -the actor (see "Point an actor at a different PEP"). +To move a running actor to a different PEP: change its `ate.dev/use-egress-pep` +selector **and** cycle the actor (see "Point an actor at a different PEP"). ## Prerequisites @@ -214,20 +210,26 @@ For kind: ./hack/install-ate-kind.sh --egress --deploy-ate-system ``` -This deploys agentgateway with a static `httpbin.org:443` egress route, labels -the `agentgateway-system/ate-egress` Gateway as a PEP, and deploys the ATE -system. No fixed PEP address is configured; ate-api derives the address from the -labeled Gateway's HTTP listener. +This deploys agentgateway with a static `httpbin.org:443` egress route, sets +ate-api's +`--default-egress-pep=ate-egress.agentgateway-system.svc.cluster.local:15008` +(the global-default selector address), and deploys the ATE system. Actors and +atespaces can override the default with an `ate.dev/use-egress-pep` selector. + +A malformed `--default-egress-pep` does not crash-loop ate-api; it logs a +warning at startup and degrades to no global default (see the "Fall-through" +table). Grep ate-api startup logs for `Ignoring invalid --default-egress-pep` +to catch a typo. The install script resolves `httpbin.org` during install and creates the `httpbin-egress` Service and EndpointSlice for those IPs. For the default HTTPS demo request, `ateom` derives the CONNECT authority from TLS SNI and the original destination port. -Verify the static agentgateway resources and PEP label: +Verify the static agentgateway resources: ```bash -kubectl get gateway -n agentgateway-system ate-egress --show-labels +kubectl get gateway -n agentgateway-system ate-egress kubectl get tcproute -n agentgateway-system httpbin-egress kubectl get agentgatewaypolicy -n agentgateway-system ate-egress-connect kubectl get service -n agentgateway-system httpbin-egress @@ -237,13 +239,16 @@ kubectl get endpointslice -n agentgateway-system httpbin-egress Expected resources include: ```text -gateway.gateway.networking.k8s.io/ate-egress ... ate.dev/egress-pep=true +gateway.gateway.networking.k8s.io/ate-egress tcproute.gateway.networking.k8s.io/httpbin-egress agentgatewaypolicy.agentgateway.dev/ate-egress-connect service/httpbin-egress endpointslice.discovery.k8s.io/httpbin-egress ``` +ate-api does not read these resources; the Gateway is the agentgateway PEP the +selector address points at. No `ate.dev/egress-pep` marker is needed. + ## Deploy and call the egress actor Deploy the egress demo: @@ -346,7 +351,7 @@ Proxying captured actor egress ... "originalDestination":"...:443" ... "connectA ## Check which PEP an actor uses ate-api records the PEP it resolved for the actor on its most recent resume, so -you can read the binding directly instead of inferring it from Gateway labels. +you can read the binding directly instead of inferring it from selectors. From the actor status (`null` means no PEP matched — the field is omitted from JSON when empty — so capture is off): @@ -383,11 +388,12 @@ disabled` instead, and their `egressPepAddress` status field is absent. Suppose you want `my-egress-1` to egress through a second Gateway, `ate-egress-alt`, instead of the shared `ate-egress` PEP. Because an actor's PEP is a snapshot taken at resume (see "When the binding is (re)computed"), you both -relabel the Gateways and cycle the actor. +set the actor's selector and cycle the actor. You do not touch the actor's +current Gateway — the actor's `ate.dev/use-egress-pep` selector out-ranks the +atespace and global-default tiers regardless. -1. Create the alternate Gateway and label it so it out-scores the global PEP for - this actor. Scoping it to the actor (atespace + actor) gives it the highest - tier, so it wins regardless of the global PEP: +1. Create the alternate Gateway. No labels are needed on it — ate-api never reads + Gateways; selection happens entirely on the actor: ```bash kubectl apply -f - <<'EOF' @@ -396,10 +402,6 @@ kind: Gateway metadata: name: ate-egress-alt namespace: agentgateway-system - labels: - ate.dev/egress-pep: "true" - ate.dev/atespace: "demo" - ate.dev/actor: "my-egress-1" spec: gatewayClassName: agentgateway listeners: @@ -421,12 +423,11 @@ spec: EOF ``` - The `connect` listener must be `protocol: HTTP` — ate-api derives the PEP - address from the Gateway's HTTP listener, and a labeled Gateway without one - is treated as a configuration error that fails PEP resolution. + The `connect` listener on `15008` is the address the selector will point at + (`ate-egress-alt.agentgateway-system.svc.cluster.local:15008`). 2. Give the alternate Gateway the CONNECT policy and a route to the backend. - Labeling alone is not enough: without these the tunnel opens but agentgateway + The Gateway alone is not enough: without these the tunnel opens but agentgateway has nothing routing `httpbin.org:443`, and egress fails with a 502 / connection reset. The `httpbin-egress` Service and EndpointSlice created by the installer are backend resources and can be reused as-is; you only need a CONNECT policy @@ -466,14 +467,41 @@ EOF -o jsonpath='{range .status.parents[*]}{.parentRef.name}{" Accepted="}{.conditions[?(@.type=="Accepted")].status}{"\n"}{end}' ``` -3. Cycle the actor so ate-api re-resolves the PEP. A running actor keeps its old - PEP until it is suspended (or paused) and resumed: +3. Point the actor at the alternate Gateway and cycle it so ate-api re-resolves + the PEP. A running actor keeps its old PEP until it is suspended (or paused) + and resumed: ```bash + kubectl ate update actor my-egress-1 -a demo \ + --egress-pep ate-egress-alt.agentgateway-system.svc.cluster.local:15008 kubectl ate suspend actor my-egress-1 -a demo kubectl ate resume actor my-egress-1 -a demo ``` + `update actor --egress-pep` sets the actor's `ate.dev/use-egress-pep` label to + the given address. Equivalently, set it once at creation with `kubectl ate + create actor ... --egress-pep :`, for example: + + ```bash + kubectl ate create actor my-egress-1 --template ate-demo-egress/egress -a demo \ + --egress-pep ate-egress-alt.agentgateway-system.svc.cluster.local:15008 + ``` + + To scope a whole atespace, set the selector when you create the atespace: + + ```bash + kubectl ate create atespace demo \ + --egress-pep ate-egress.agentgateway-system.svc.cluster.local:15008 + ``` + + The atespace-tier selector can only be set at atespace creation — there is no + `kubectl ate update atespace`. To change the atespace default afterward, + override it per actor with `update actor --egress-pep` (highest precedence); + new actors can pass `--egress-pep` at creation so they never inherit the old + default. Recreating the atespace is only an option while it is empty: + `delete atespace` refuses a non-empty atespace, so once actors exist the + per-actor override is the practical path. + 4. Confirm the actor now points at the alternate PEP: ```bash @@ -514,17 +542,19 @@ EOF --all-containers --tail=200 | grep "gateway=agentgateway-system/ate-egress-alt" ``` -To revert, remove the alternate Gateway's route parent and delete the Gateway -and its CONNECT policy, then cycle the actor again; ate-api falls back to the -next-best PEP, the global `ate-egress`: +To revert, clear the actor's selector so it falls back to the global default +(`ate-egress`), then cycle it; afterward you can remove the alternate Gateway's +route parent and delete the Gateway and its CONNECT policy: ```bash +kubectl ate update actor my-egress-1 -a demo --egress-pep "" +kubectl ate suspend actor my-egress-1 -a demo +kubectl ate resume actor my-egress-1 -a demo + kubectl patch tcproute -n agentgateway-system httpbin-egress --type=json \ -p '[{"op":"remove","path":"/spec/parentRefs/1"}]' kubectl delete gateway -n agentgateway-system ate-egress-alt kubectl delete agentgatewaypolicy -n agentgateway-system ate-egress-alt-connect -kubectl ate suspend actor my-egress-1 -a demo -kubectl ate resume actor my-egress-1 -a demo ``` ## Check agentgateway logs @@ -570,17 +600,17 @@ Invalid value: Spec is immutable`, recreate the demo resources: ./hack/install-ate-kind.sh --deploy-demo-egress ``` -If capture listener logs are missing after labeling a Gateway on an -already-running ATE system, no ate-api restart is needed for the label itself — -the Gateway watcher is a live watch and sees label changes immediately. The -usual cause is that the actor was already running: the PEP binding is a -snapshot taken at resume, so cycle the actor (suspend, then resume) to -re-resolve. +If capture listener logs are missing after setting an actor/atespace selector on +an already-running ATE system, no ate-api restart is needed — the selector is +read from the actor and atespace at resume. The usual cause is that the actor was +already running: the PEP binding is a snapshot taken at resume, so cycle the +actor (suspend, then resume) to re-resolve. Selector changes never affect a +RUNNING actor. Confirm which address resolved from ate-api logs +(`Resolved egress PEP for actor`) or the actor's `egressPepAddress` field; if the +address is wrong, the tunnel fails at connect time in ateom. -The one case that does require an ate-api restart is installing the Gateway API -CRDs *after* ate-api started: ate-api checks for the Gateway resource once at -boot and disables PEP resolution if it is absent (it logs -`Gateway API resource not served; egress PEP resolution disabled`): +Changing the global default (`--default-egress-pep`) does require an ate-api +restart, since it is a process flag / ConfigMap value read at boot: ```bash kubectl rollout restart deployment/ate-api-server-deployment -n ate-system @@ -589,8 +619,7 @@ kubectl rollout status deployment/ate-api-server-deployment -n ate-system Capture is decided per resume from the PEP address ate-api sends to ateom, so worker pods do not need to carry any egress config or be restarted. An actor -already running before its PEP Gateway existed picks up capture on its next -resume. +already running before its selector was set picks up capture on its next resume. Worker images must include egress support: an older ateom silently ignores the PEP address, so `egressPepAddress` on the actor can report a binding the diff --git a/go.mod b/go.mod index 6a6a82c35..30a94012a 100644 --- a/go.mod +++ b/go.mod @@ -189,7 +189,6 @@ require ( k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 // indirect k8s.io/streaming v0.36.1 // indirect - sigs.k8s.io/gateway-api v1.6.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect diff --git a/go.sum b/go.sum index 4c5267e87..59bd369ad 100644 --- a/go.sum +++ b/go.sum @@ -129,8 +129,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -144,78 +142,45 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -473,8 +438,6 @@ k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 h1:ngxu1nL4SbFuXwu1EY7cSKcVqSjTQPVbYQT6WNjTXaU= k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= @@ -483,14 +446,10 @@ k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbe k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= -sigs.k8s.io/gateway-api v1.6.0 h1:735YBRj5NXFrOGX0GoSjwzUIzbz8kiEOfADsqHFmHgE= -sigs.k8s.io/gateway-api v1.6.0/go.mod h1:FVfx3t389ybeXOqvDghLbdvJdSCfI/PReqCUI3lu3mY= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/hack/install-ate.sh b/hack/install-ate.sh index a7336aeba..48e6dcfc2 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -292,8 +292,6 @@ kind: Gateway metadata: name: ate-egress namespace: agentgateway-system - labels: - ate.dev/egress-pep: "true" spec: gatewayClassName: agentgateway listeners: @@ -419,12 +417,24 @@ create_api_server_env_vars() { fi fi + # When egress capture is enabled, point the global-default egress PEP at the + # demo agentgateway address. Actors and atespaces can override this with an + # ate.dev/use-egress-pep selector (precedence: actor > atespace > global). Left + # empty otherwise, which keeps egress capture off. ate-api uses this address + # directly; it has no Gateway API dependency. + local default_egress_pep="" + if [[ "${ATE_EGRESS_CAPTURE}" == "true" ]]; then + default_egress_pep="ate-egress.agentgateway-system.svc.cluster.local:15008" + fi + echo "DEFAULT_EGRESS_PEP: ${default_egress_pep}" + run_kubectl create configmap -n ate-system ate-api-server-envvars \ --from-literal=ATE_API_REDIS_ADDRESS="${redis_address}" \ --from-literal=ATE_API_REDIS_USE_IAM_AUTH="${use_iam_auth}" \ --from-literal=ATE_API_REDIS_TLS_SERVER_NAME="${tls_server_name}" \ --from-literal=ATE_API_REDIS_CLIENT_CERT="${client_cert}" \ --from-literal=ATE_API_K8SJWT_ISSUER="${jwt_issuer}" \ + --from-literal=ATE_API_DEFAULT_EGRESS_PEP="${default_egress_pep}" \ --dry-run=client -o yaml \ | run_kubectl apply -f - } diff --git a/internal/egress/env.go b/internal/egress/env.go index 6e7094810..65c7a3e13 100644 --- a/internal/egress/env.go +++ b/internal/egress/env.go @@ -15,9 +15,12 @@ package egress const ( - LabelPEP = "ate.dev/egress-pep" - LabelAtespace = "ate.dev/atespace" - LabelActor = "ate.dev/actor" + // LabelUseEgressPEP is the consumer-side selector key (on an Actor or + // Atespace) that supplies the egress PEP address to use, as ":" + // (analogous to Istio ambient's istio.io/use-waypoint). Precedence is + // actor > atespace > global default (--default-egress-pep). ate-api uses the + // value directly and has no Gateway API dependency. + LabelUseEgressPEP = "ate.dev/use-egress-pep" ) const ( diff --git a/manifests/ate-install/ate-api-server.yaml b/manifests/ate-install/ate-api-server.yaml index ca609fdd3..319a884a0 100644 --- a/manifests/ate-install/ate-api-server.yaml +++ b/manifests/ate-install/ate-api-server.yaml @@ -28,9 +28,6 @@ rules: - apiGroups: ["ate.dev"] resources: ["workerpools"] verbs: ["get", "watch", "list"] -- apiGroups: ["gateway.networking.k8s.io"] - resources: ["gateways"] - verbs: ["get", "watch", "list"] # Secret reads for env source resolution are intentionally NOT granted # cluster-wide here. Each demo / tenant is responsible for granting # ate-api-server read access only to the specific Secrets referenced by its @@ -94,6 +91,11 @@ spec: - --session-id-jwt-pool=/run/session-id-jwt-pool/pool.json - --session-id-ca-pool=/run/session-id-ca-pool/pool.json - --workerpool-ca-certs=/run/workerpool-ca-certs/trust-bundle.pem + # Global-default egress PEP address (:). Empty when the + # ConfigMap does not set ATE_API_DEFAULT_EGRESS_PEP (non-egress + # installs), which leaves egress capture off unless a per-actor/atespace + # ate.dev/use-egress-pep selector matches. + - --default-egress-pep=@env env: - name: POD_NAME valueFrom: diff --git a/pkg/proto/ateapipb/ateapi.pb.go b/pkg/proto/ateapipb/ateapi.pb.go index dda0bb9d1..1fb420ece 100644 --- a/pkg/proto/ateapipb/ateapi.pb.go +++ b/pkg/proto/ateapipb/ateapi.pb.go @@ -452,8 +452,14 @@ type Actor struct { // cleared when the worker is released (suspend/pause). Records which PEP an // actor is using so global-PEP membership is queryable without scanning logs. EgressPepAddress string `protobuf:"bytes,13,opt,name=egress_pep_address,json=egressPepAddress,proto3" json:"egress_pep_address,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Selector labels on the actor. The `ate.dev/use-egress-pep` key supplies the + // egress PEP address (as :) this actor should use, following + // the Istio ambient `istio.io/use-waypoint` model. This is the highest- + // precedence egress PEP selector (actor > atespace > global default). Set at + // create and mutable via UpdateActor; changes take effect on the next resume. + Labels map[string]string `protobuf:"bytes,14,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Actor) Reset() { @@ -577,12 +583,24 @@ func (x *Actor) GetEgressPepAddress() string { return "" } +func (x *Actor) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + // Atespace is the isolation boundary an Actor is created into. Global-scoped: // metadata.atespace is always empty; the atespace's identity is metadata.name. type Atespace struct { state protoimpl.MessageState `protogen:"open.v1"` // Common resource metadata: name, uid, version, timestamps. - Metadata *ResourceMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + Metadata *ResourceMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Selector labels on the atespace. The `ate.dev/use-egress-pep` key supplies + // the egress PEP address (as :) all actors in this atespace + // should use unless the actor sets its own. Medium-precedence egress PEP + // selector (actor > atespace > global default). + Labels map[string]string `protobuf:"bytes,2,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -624,6 +642,13 @@ func (x *Atespace) GetMetadata() *ResourceMetadata { return nil } +func (x *Atespace) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + // ObjectRef references a Substrate resource by its (atespace, name) identity. type ObjectRef struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -992,8 +1017,15 @@ type UpdateActorRequest struct { // worker_selector replaces the actor's current placement constraint. // Takes effect on the next ResumeActor call. WorkerSelector *Selector `protobuf:"bytes,2,opt,name=worker_selector,json=workerSelector,proto3" json:"worker_selector,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // labels is merged into the actor's selector labels: a key with a non-empty + // value is set (e.g. `ate.dev/use-egress-pep` with a : egress PEP + // address), a key with an empty value is deleted, and keys not present are + // left unchanged — an absent/empty map is a no-op, so callers unaware of + // labels cannot clear selectors set by others. Takes effect on the next + // ResumeActor call. + Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateActorRequest) Reset() { @@ -1040,6 +1072,13 @@ func (x *UpdateActorRequest) GetWorkerSelector() *Selector { return nil } +func (x *UpdateActorRequest) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + type UpdateActorResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Actor *Actor `protobuf:"bytes,1,opt,name=actor,proto3" json:"actor,omitempty"` @@ -2174,7 +2213,7 @@ const file_ateapi_proto_rawDesc = "" + "\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + "createTime\x12;\n" + "\vupdate_time\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "updateTime\"\xb2\x06\n" + + "updateTime\"\xa0\a\n" + "\x05Actor\x124\n" + "\bmetadata\x18\x01 \x01(\v2\x18.ateapi.ResourceMetadataR\bmetadata\x128\n" + "\x18actor_template_namespace\x18\x02 \x01(\tR\x16actorTemplateNamespace\x12.\n" + @@ -2190,7 +2229,11 @@ const file_ateapi_proto_rawDesc = "" + " \x01(\v2\x14.ateapi.SnapshotInfoR\x12latestSnapshotInfo\x129\n" + "\x0fworker_selector\x18\v \x01(\v2\x10.ateapi.SelectorR\x0eworkerSelector\x12(\n" + "\x10worker_pool_name\x18\f \x01(\tR\x0eworkerPoolName\x12,\n" + - "\x12egress_pep_address\x18\r \x01(\tR\x10egressPepAddress\"\xb1\x01\n" + + "\x12egress_pep_address\x18\r \x01(\tR\x10egressPepAddress\x121\n" + + "\x06labels\x18\x0e \x03(\v2\x19.ateapi.Actor.LabelsEntryR\x06labels\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb1\x01\n" + "\x06Status\x12\x16\n" + "\x12STATUS_UNSPECIFIED\x10\x00\x12\x13\n" + "\x0fSTATUS_RESUMING\x10\x01\x12\x12\n" + @@ -2199,9 +2242,13 @@ const file_ateapi_proto_rawDesc = "" + "\x10STATUS_SUSPENDED\x10\x04\x12\x12\n" + "\x0eSTATUS_PAUSING\x10\x05\x12\x11\n" + "\rSTATUS_PAUSED\x10\x06\x12\x12\n" + - "\x0eSTATUS_CRASHED\x10\a\"@\n" + + "\x0eSTATUS_CRASHED\x10\a\"\xb1\x01\n" + "\bAtespace\x124\n" + - "\bmetadata\x18\x01 \x01(\v2\x18.ateapi.ResourceMetadataR\bmetadata\";\n" + + "\bmetadata\x18\x01 \x01(\v2\x18.ateapi.ResourceMetadataR\bmetadata\x124\n" + + "\x06labels\x18\x02 \x03(\v2\x1c.ateapi.Atespace.LabelsEntryR\x06labels\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\";\n" + "\tObjectRef\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\"E\n" + @@ -2217,10 +2264,14 @@ const file_ateapi_proto_rawDesc = "" + "\x0fGetActorRequest\x12'\n" + "\x05actor\x18\x01 \x01(\v2\x11.ateapi.ObjectRefR\x05actor\"9\n" + "\x12CreateActorRequest\x12#\n" + - "\x05actor\x18\x01 \x01(\v2\r.ateapi.ActorR\x05actor\"x\n" + + "\x05actor\x18\x01 \x01(\v2\r.ateapi.ActorR\x05actor\"\xf3\x01\n" + "\x12UpdateActorRequest\x12'\n" + "\x05actor\x18\x01 \x01(\v2\x11.ateapi.ObjectRefR\x05actor\x129\n" + - "\x0fworker_selector\x18\x02 \x01(\v2\x10.ateapi.SelectorR\x0eworkerSelector\":\n" + + "\x0fworker_selector\x18\x02 \x01(\v2\x10.ateapi.SelectorR\x0eworkerSelector\x12>\n" + + "\x06labels\x18\x03 \x03(\v2&.ateapi.UpdateActorRequest.LabelsEntryR\x06labels\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\":\n" + "\x13UpdateActorResponse\x12#\n" + "\x05actor\x18\x01 \x01(\v2\r.ateapi.ActorR\x05actor\">\n" + "\x13SuspendActorRequest\x12'\n" + @@ -2329,7 +2380,7 @@ func file_ateapi_proto_rawDescGZIP() []byte { } var file_ateapi_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_ateapi_proto_msgTypes = make([]protoimpl.MessageInfo, 39) +var file_ateapi_proto_msgTypes = make([]protoimpl.MessageInfo, 42) var file_ateapi_proto_goTypes = []any{ (Actor_Status)(0), // 0: ateapi.Actor.Status (*ExternalSnapshotInfo)(nil), // 1: ateapi.ExternalSnapshotInfo @@ -2370,79 +2421,85 @@ var file_ateapi_proto_goTypes = []any{ (*MintCertRequest)(nil), // 36: ateapi.MintCertRequest (*MintCertResponse)(nil), // 37: ateapi.MintCertResponse nil, // 38: ateapi.Selector.MatchLabelsEntry - nil, // 39: ateapi.Worker.LabelsEntry - (*timestamppb.Timestamp)(nil), // 40: google.protobuf.Timestamp + nil, // 39: ateapi.Actor.LabelsEntry + nil, // 40: ateapi.Atespace.LabelsEntry + nil, // 41: ateapi.UpdateActorRequest.LabelsEntry + nil, // 42: ateapi.Worker.LabelsEntry + (*timestamppb.Timestamp)(nil), // 43: google.protobuf.Timestamp } var file_ateapi_proto_depIdxs = []int32{ 1, // 0: ateapi.SnapshotInfo.external:type_name -> ateapi.ExternalSnapshotInfo 2, // 1: ateapi.SnapshotInfo.local:type_name -> ateapi.LocalSnapshotInfo 38, // 2: ateapi.Selector.match_labels:type_name -> ateapi.Selector.MatchLabelsEntry - 40, // 3: ateapi.ResourceMetadata.create_time:type_name -> google.protobuf.Timestamp - 40, // 4: ateapi.ResourceMetadata.update_time:type_name -> google.protobuf.Timestamp + 43, // 3: ateapi.ResourceMetadata.create_time:type_name -> google.protobuf.Timestamp + 43, // 4: ateapi.ResourceMetadata.update_time:type_name -> google.protobuf.Timestamp 5, // 5: ateapi.Actor.metadata:type_name -> ateapi.ResourceMetadata 0, // 6: ateapi.Actor.status:type_name -> ateapi.Actor.Status 3, // 7: ateapi.Actor.latest_snapshot_info:type_name -> ateapi.SnapshotInfo 4, // 8: ateapi.Actor.worker_selector:type_name -> ateapi.Selector - 5, // 9: ateapi.Atespace.metadata:type_name -> ateapi.ResourceMetadata - 7, // 10: ateapi.CreateAtespaceRequest.atespace:type_name -> ateapi.Atespace - 8, // 11: ateapi.GetAtespaceRequest.atespace:type_name -> ateapi.ObjectRef - 7, // 12: ateapi.ListAtespacesResponse.atespaces:type_name -> ateapi.Atespace - 8, // 13: ateapi.DeleteAtespaceRequest.atespace:type_name -> ateapi.ObjectRef - 8, // 14: ateapi.GetActorRequest.actor:type_name -> ateapi.ObjectRef - 6, // 15: ateapi.CreateActorRequest.actor:type_name -> ateapi.Actor - 8, // 16: ateapi.UpdateActorRequest.actor:type_name -> ateapi.ObjectRef - 4, // 17: ateapi.UpdateActorRequest.worker_selector:type_name -> ateapi.Selector - 6, // 18: ateapi.UpdateActorResponse.actor:type_name -> ateapi.Actor - 8, // 19: ateapi.SuspendActorRequest.actor:type_name -> ateapi.ObjectRef - 6, // 20: ateapi.SuspendActorResponse.actor:type_name -> ateapi.Actor - 8, // 21: ateapi.PauseActorRequest.actor:type_name -> ateapi.ObjectRef - 6, // 22: ateapi.PauseActorResponse.actor:type_name -> ateapi.Actor - 8, // 23: ateapi.ResumeActorRequest.actor:type_name -> ateapi.ObjectRef - 6, // 24: ateapi.ResumeActorResponse.actor:type_name -> ateapi.Actor - 8, // 25: ateapi.DeleteActorRequest.actor:type_name -> ateapi.ObjectRef - 29, // 26: ateapi.ListWorkersResponse.workers:type_name -> ateapi.Worker - 6, // 27: ateapi.ListActorsResponse.actors:type_name -> ateapi.Actor - 30, // 28: ateapi.Worker.assignment:type_name -> ateapi.Assignment - 39, // 29: ateapi.Worker.labels:type_name -> ateapi.Worker.LabelsEntry - 31, // 30: ateapi.Assignment.actor_template:type_name -> ateapi.KubeNamespacedObjectRef - 8, // 31: ateapi.Assignment.actor:type_name -> ateapi.ObjectRef - 14, // 32: ateapi.Control.GetActor:input_type -> ateapi.GetActorRequest - 15, // 33: ateapi.Control.CreateActor:input_type -> ateapi.CreateActorRequest - 16, // 34: ateapi.Control.UpdateActor:input_type -> ateapi.UpdateActorRequest - 18, // 35: ateapi.Control.SuspendActor:input_type -> ateapi.SuspendActorRequest - 20, // 36: ateapi.Control.PauseActor:input_type -> ateapi.PauseActorRequest - 22, // 37: ateapi.Control.ResumeActor:input_type -> ateapi.ResumeActorRequest - 24, // 38: ateapi.Control.DeleteActor:input_type -> ateapi.DeleteActorRequest - 25, // 39: ateapi.Control.ListWorkers:input_type -> ateapi.ListWorkersRequest - 27, // 40: ateapi.Control.ListActors:input_type -> ateapi.ListActorsRequest - 9, // 41: ateapi.Control.CreateAtespace:input_type -> ateapi.CreateAtespaceRequest - 10, // 42: ateapi.Control.GetAtespace:input_type -> ateapi.GetAtespaceRequest - 11, // 43: ateapi.Control.ListAtespaces:input_type -> ateapi.ListAtespacesRequest - 13, // 44: ateapi.Control.DeleteAtespace:input_type -> ateapi.DeleteAtespaceRequest - 32, // 45: ateapi.Control.DebugClear:input_type -> ateapi.DebugClearRequest - 34, // 46: ateapi.SessionIdentity.MintJWT:input_type -> ateapi.MintJWTRequest - 36, // 47: ateapi.SessionIdentity.MintCert:input_type -> ateapi.MintCertRequest - 6, // 48: ateapi.Control.GetActor:output_type -> ateapi.Actor - 6, // 49: ateapi.Control.CreateActor:output_type -> ateapi.Actor - 17, // 50: ateapi.Control.UpdateActor:output_type -> ateapi.UpdateActorResponse - 19, // 51: ateapi.Control.SuspendActor:output_type -> ateapi.SuspendActorResponse - 21, // 52: ateapi.Control.PauseActor:output_type -> ateapi.PauseActorResponse - 23, // 53: ateapi.Control.ResumeActor:output_type -> ateapi.ResumeActorResponse - 6, // 54: ateapi.Control.DeleteActor:output_type -> ateapi.Actor - 26, // 55: ateapi.Control.ListWorkers:output_type -> ateapi.ListWorkersResponse - 28, // 56: ateapi.Control.ListActors:output_type -> ateapi.ListActorsResponse - 7, // 57: ateapi.Control.CreateAtespace:output_type -> ateapi.Atespace - 7, // 58: ateapi.Control.GetAtespace:output_type -> ateapi.Atespace - 12, // 59: ateapi.Control.ListAtespaces:output_type -> ateapi.ListAtespacesResponse - 7, // 60: ateapi.Control.DeleteAtespace:output_type -> ateapi.Atespace - 33, // 61: ateapi.Control.DebugClear:output_type -> ateapi.DebugClearResponse - 35, // 62: ateapi.SessionIdentity.MintJWT:output_type -> ateapi.MintJWTResponse - 37, // 63: ateapi.SessionIdentity.MintCert:output_type -> ateapi.MintCertResponse - 48, // [48:64] is the sub-list for method output_type - 32, // [32:48] is the sub-list for method input_type - 32, // [32:32] is the sub-list for extension type_name - 32, // [32:32] is the sub-list for extension extendee - 0, // [0:32] is the sub-list for field type_name + 39, // 9: ateapi.Actor.labels:type_name -> ateapi.Actor.LabelsEntry + 5, // 10: ateapi.Atespace.metadata:type_name -> ateapi.ResourceMetadata + 40, // 11: ateapi.Atespace.labels:type_name -> ateapi.Atespace.LabelsEntry + 7, // 12: ateapi.CreateAtespaceRequest.atespace:type_name -> ateapi.Atespace + 8, // 13: ateapi.GetAtespaceRequest.atespace:type_name -> ateapi.ObjectRef + 7, // 14: ateapi.ListAtespacesResponse.atespaces:type_name -> ateapi.Atespace + 8, // 15: ateapi.DeleteAtespaceRequest.atespace:type_name -> ateapi.ObjectRef + 8, // 16: ateapi.GetActorRequest.actor:type_name -> ateapi.ObjectRef + 6, // 17: ateapi.CreateActorRequest.actor:type_name -> ateapi.Actor + 8, // 18: ateapi.UpdateActorRequest.actor:type_name -> ateapi.ObjectRef + 4, // 19: ateapi.UpdateActorRequest.worker_selector:type_name -> ateapi.Selector + 41, // 20: ateapi.UpdateActorRequest.labels:type_name -> ateapi.UpdateActorRequest.LabelsEntry + 6, // 21: ateapi.UpdateActorResponse.actor:type_name -> ateapi.Actor + 8, // 22: ateapi.SuspendActorRequest.actor:type_name -> ateapi.ObjectRef + 6, // 23: ateapi.SuspendActorResponse.actor:type_name -> ateapi.Actor + 8, // 24: ateapi.PauseActorRequest.actor:type_name -> ateapi.ObjectRef + 6, // 25: ateapi.PauseActorResponse.actor:type_name -> ateapi.Actor + 8, // 26: ateapi.ResumeActorRequest.actor:type_name -> ateapi.ObjectRef + 6, // 27: ateapi.ResumeActorResponse.actor:type_name -> ateapi.Actor + 8, // 28: ateapi.DeleteActorRequest.actor:type_name -> ateapi.ObjectRef + 29, // 29: ateapi.ListWorkersResponse.workers:type_name -> ateapi.Worker + 6, // 30: ateapi.ListActorsResponse.actors:type_name -> ateapi.Actor + 30, // 31: ateapi.Worker.assignment:type_name -> ateapi.Assignment + 42, // 32: ateapi.Worker.labels:type_name -> ateapi.Worker.LabelsEntry + 31, // 33: ateapi.Assignment.actor_template:type_name -> ateapi.KubeNamespacedObjectRef + 8, // 34: ateapi.Assignment.actor:type_name -> ateapi.ObjectRef + 14, // 35: ateapi.Control.GetActor:input_type -> ateapi.GetActorRequest + 15, // 36: ateapi.Control.CreateActor:input_type -> ateapi.CreateActorRequest + 16, // 37: ateapi.Control.UpdateActor:input_type -> ateapi.UpdateActorRequest + 18, // 38: ateapi.Control.SuspendActor:input_type -> ateapi.SuspendActorRequest + 20, // 39: ateapi.Control.PauseActor:input_type -> ateapi.PauseActorRequest + 22, // 40: ateapi.Control.ResumeActor:input_type -> ateapi.ResumeActorRequest + 24, // 41: ateapi.Control.DeleteActor:input_type -> ateapi.DeleteActorRequest + 25, // 42: ateapi.Control.ListWorkers:input_type -> ateapi.ListWorkersRequest + 27, // 43: ateapi.Control.ListActors:input_type -> ateapi.ListActorsRequest + 9, // 44: ateapi.Control.CreateAtespace:input_type -> ateapi.CreateAtespaceRequest + 10, // 45: ateapi.Control.GetAtespace:input_type -> ateapi.GetAtespaceRequest + 11, // 46: ateapi.Control.ListAtespaces:input_type -> ateapi.ListAtespacesRequest + 13, // 47: ateapi.Control.DeleteAtespace:input_type -> ateapi.DeleteAtespaceRequest + 32, // 48: ateapi.Control.DebugClear:input_type -> ateapi.DebugClearRequest + 34, // 49: ateapi.SessionIdentity.MintJWT:input_type -> ateapi.MintJWTRequest + 36, // 50: ateapi.SessionIdentity.MintCert:input_type -> ateapi.MintCertRequest + 6, // 51: ateapi.Control.GetActor:output_type -> ateapi.Actor + 6, // 52: ateapi.Control.CreateActor:output_type -> ateapi.Actor + 17, // 53: ateapi.Control.UpdateActor:output_type -> ateapi.UpdateActorResponse + 19, // 54: ateapi.Control.SuspendActor:output_type -> ateapi.SuspendActorResponse + 21, // 55: ateapi.Control.PauseActor:output_type -> ateapi.PauseActorResponse + 23, // 56: ateapi.Control.ResumeActor:output_type -> ateapi.ResumeActorResponse + 6, // 57: ateapi.Control.DeleteActor:output_type -> ateapi.Actor + 26, // 58: ateapi.Control.ListWorkers:output_type -> ateapi.ListWorkersResponse + 28, // 59: ateapi.Control.ListActors:output_type -> ateapi.ListActorsResponse + 7, // 60: ateapi.Control.CreateAtespace:output_type -> ateapi.Atespace + 7, // 61: ateapi.Control.GetAtespace:output_type -> ateapi.Atespace + 12, // 62: ateapi.Control.ListAtespaces:output_type -> ateapi.ListAtespacesResponse + 7, // 63: ateapi.Control.DeleteAtespace:output_type -> ateapi.Atespace + 33, // 64: ateapi.Control.DebugClear:output_type -> ateapi.DebugClearResponse + 35, // 65: ateapi.SessionIdentity.MintJWT:output_type -> ateapi.MintJWTResponse + 37, // 66: ateapi.SessionIdentity.MintCert:output_type -> ateapi.MintCertResponse + 51, // [51:67] is the sub-list for method output_type + 35, // [35:51] is the sub-list for method input_type + 35, // [35:35] is the sub-list for extension type_name + 35, // [35:35] is the sub-list for extension extendee + 0, // [0:35] is the sub-list for field type_name } func init() { file_ateapi_proto_init() } @@ -2460,7 +2517,7 @@ func file_ateapi_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ateapi_proto_rawDesc), len(file_ateapi_proto_rawDesc)), NumEnums: 1, - NumMessages: 39, + NumMessages: 42, NumExtensions: 0, NumServices: 2, }, diff --git a/pkg/proto/ateapipb/ateapi.proto b/pkg/proto/ateapipb/ateapi.proto index d890426b2..5deb206c3 100644 --- a/pkg/proto/ateapipb/ateapi.proto +++ b/pkg/proto/ateapipb/ateapi.proto @@ -164,6 +164,13 @@ message Actor { // cleared when the worker is released (suspend/pause). Records which PEP an // actor is using so global-PEP membership is queryable without scanning logs. string egress_pep_address = 13; + + // Selector labels on the actor. The `ate.dev/use-egress-pep` key supplies the + // egress PEP address (as :) this actor should use, following + // the Istio ambient `istio.io/use-waypoint` model. This is the highest- + // precedence egress PEP selector (actor > atespace > global default). Set at + // create and mutable via UpdateActor; changes take effect on the next resume. + map labels = 14; } // Atespace is the isolation boundary an Actor is created into. Global-scoped: @@ -171,6 +178,12 @@ message Actor { message Atespace { // Common resource metadata: name, uid, version, timestamps. ResourceMetadata metadata = 1; + + // Selector labels on the atespace. The `ate.dev/use-egress-pep` key supplies + // the egress PEP address (as :) all actors in this atespace + // should use unless the actor sets its own. Medium-precedence egress PEP + // selector (actor > atespace > global default). + map labels = 2; } // ObjectRef references a Substrate resource by its (atespace, name) identity. @@ -219,6 +232,14 @@ message UpdateActorRequest { // worker_selector replaces the actor's current placement constraint. // Takes effect on the next ResumeActor call. Selector worker_selector = 2; + + // labels is merged into the actor's selector labels: a key with a non-empty + // value is set (e.g. `ate.dev/use-egress-pep` with a : egress PEP + // address), a key with an empty value is deleted, and keys not present are + // left unchanged — an absent/empty map is a no-op, so callers unaware of + // labels cannot clear selectors set by others. Takes effect on the next + // ResumeActor call. + map labels = 3; } message UpdateActorResponse { diff --git a/vendor/k8s.io/client-go/dynamic/dynamicinformer/informer.go b/vendor/k8s.io/client-go/dynamic/dynamicinformer/informer.go deleted file mode 100644 index b933c1379..000000000 --- a/vendor/k8s.io/client-go/dynamic/dynamicinformer/informer.go +++ /dev/null @@ -1,200 +0,0 @@ -/* -Copyright 2018 The Kubernetes Authors. - -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 dynamicinformer - -import ( - "context" - "sync" - "time" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/watch" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/dynamic/dynamiclister" - "k8s.io/client-go/informers" - "k8s.io/client-go/tools/cache" -) - -// NewDynamicSharedInformerFactory constructs a new instance of dynamicSharedInformerFactory for all namespaces. -func NewDynamicSharedInformerFactory(client dynamic.Interface, defaultResync time.Duration) DynamicSharedInformerFactory { - return NewFilteredDynamicSharedInformerFactory(client, defaultResync, metav1.NamespaceAll, nil) -} - -// NewFilteredDynamicSharedInformerFactory constructs a new instance of dynamicSharedInformerFactory. -// Listers obtained via this factory will be subject to the same filters as specified here. -func NewFilteredDynamicSharedInformerFactory(client dynamic.Interface, defaultResync time.Duration, namespace string, tweakListOptions TweakListOptionsFunc) DynamicSharedInformerFactory { - return &dynamicSharedInformerFactory{ - client: client, - defaultResync: defaultResync, - namespace: namespace, - informers: map[schema.GroupVersionResource]informers.GenericInformer{}, - startedInformers: make(map[schema.GroupVersionResource]bool), - tweakListOptions: tweakListOptions, - } -} - -type dynamicSharedInformerFactory struct { - client dynamic.Interface - defaultResync time.Duration - namespace string - - lock sync.Mutex - informers map[schema.GroupVersionResource]informers.GenericInformer - // startedInformers is used for tracking which informers have been started. - // This allows Start() to be called multiple times safely. - startedInformers map[schema.GroupVersionResource]bool - tweakListOptions TweakListOptionsFunc - - // wg tracks how many goroutines were started. - wg sync.WaitGroup - // shuttingDown is true when Shutdown has been called. It may still be running - // because it needs to wait for goroutines. - shuttingDown bool -} - -var _ DynamicSharedInformerFactory = &dynamicSharedInformerFactory{} - -func (f *dynamicSharedInformerFactory) ForResource(gvr schema.GroupVersionResource) informers.GenericInformer { - f.lock.Lock() - defer f.lock.Unlock() - - key := gvr - informer, exists := f.informers[key] - if exists { - return informer - } - - informer = NewFilteredDynamicInformer(f.client, gvr, f.namespace, f.defaultResync, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) - f.informers[key] = informer - - return informer -} - -// Start initializes all requested informers. -func (f *dynamicSharedInformerFactory) Start(stopCh <-chan struct{}) { - f.lock.Lock() - defer f.lock.Unlock() - - if f.shuttingDown { - return - } - - for informerType, informer := range f.informers { - if !f.startedInformers[informerType] { - f.wg.Add(1) - // We need a new variable in each loop iteration, - // otherwise the goroutine would use the loop variable - // and that keeps changing. - informer := informer.Informer() - go func() { - defer f.wg.Done() - informer.Run(stopCh) - }() - f.startedInformers[informerType] = true - } - } -} - -// WaitForCacheSync waits for all started informers' cache were synced. -func (f *dynamicSharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[schema.GroupVersionResource]bool { - informers := func() map[schema.GroupVersionResource]cache.SharedIndexInformer { - f.lock.Lock() - defer f.lock.Unlock() - - informers := map[schema.GroupVersionResource]cache.SharedIndexInformer{} - for informerType, informer := range f.informers { - if f.startedInformers[informerType] { - informers[informerType] = informer.Informer() - } - } - return informers - }() - - res := map[schema.GroupVersionResource]bool{} - for informType, informer := range informers { - res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) - } - return res -} - -func (f *dynamicSharedInformerFactory) Shutdown() { - // Will return immediately if there is nothing to wait for. - defer f.wg.Wait() - - f.lock.Lock() - defer f.lock.Unlock() - f.shuttingDown = true -} - -// NewFilteredDynamicInformer constructs a new informer for a dynamic type. -func NewFilteredDynamicInformer(client dynamic.Interface, gvr schema.GroupVersionResource, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions TweakListOptionsFunc) informers.GenericInformer { - return &dynamicInformer{ - gvr: gvr, - informer: cache.NewSharedIndexInformerWithOptions( - cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ - ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { - if tweakListOptions != nil { - tweakListOptions(&options) - } - return client.Resource(gvr).Namespace(namespace).List(context.Background(), options) - }, - WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { - if tweakListOptions != nil { - tweakListOptions(&options) - } - return client.Resource(gvr).Namespace(namespace).Watch(context.Background(), options) - }, - ListWithContextFunc: func(ctx context.Context, options metav1.ListOptions) (runtime.Object, error) { - if tweakListOptions != nil { - tweakListOptions(&options) - } - return client.Resource(gvr).Namespace(namespace).List(ctx, options) - }, - WatchFuncWithContext: func(ctx context.Context, options metav1.ListOptions) (watch.Interface, error) { - if tweakListOptions != nil { - tweakListOptions(&options) - } - return client.Resource(gvr).Namespace(namespace).Watch(ctx, options) - }, - }, client), - &unstructured.Unstructured{}, - cache.SharedIndexInformerOptions{ - ResyncPeriod: resyncPeriod, - Indexers: indexers, - ObjectDescription: gvr.String(), - }, - ), - } -} - -type dynamicInformer struct { - informer cache.SharedIndexInformer - gvr schema.GroupVersionResource -} - -var _ informers.GenericInformer = &dynamicInformer{} - -func (d *dynamicInformer) Informer() cache.SharedIndexInformer { - return d.informer -} - -func (d *dynamicInformer) Lister() cache.GenericLister { - return dynamiclister.NewRuntimeObjectShim(dynamiclister.New(d.informer.GetIndexer(), d.gvr)) -} diff --git a/vendor/k8s.io/client-go/dynamic/dynamicinformer/interface.go b/vendor/k8s.io/client-go/dynamic/dynamicinformer/interface.go deleted file mode 100644 index 0419ef4f8..000000000 --- a/vendor/k8s.io/client-go/dynamic/dynamicinformer/interface.go +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2018 The Kubernetes Authors. - -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 dynamicinformer - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/informers" -) - -// DynamicSharedInformerFactory provides access to a shared informer and lister for dynamic client -type DynamicSharedInformerFactory interface { - // Start initializes all requested informers. They are handled in goroutines - // which run until the stop channel gets closed. - Start(stopCh <-chan struct{}) - - // ForResource gives generic access to a shared informer of the matching type. - ForResource(gvr schema.GroupVersionResource) informers.GenericInformer - - // WaitForCacheSync blocks until all started informers' caches were synced - // or the stop channel gets closed. - WaitForCacheSync(stopCh <-chan struct{}) map[schema.GroupVersionResource]bool - - // Shutdown marks a factory as shutting down. At that point no new - // informers can be started anymore and Start will return without - // doing anything. - // - // In addition, Shutdown blocks until all goroutines have terminated. For that - // to happen, the close channel(s) that they were started with must be closed, - // either before Shutdown gets called or while it is waiting. - // - // Shutdown may be called multiple times, even concurrently. All such calls will - // block until all goroutines have terminated. - Shutdown() -} - -// TweakListOptionsFunc defines the signature of a helper function -// that wants to provide more listing options to API -type TweakListOptionsFunc func(*metav1.ListOptions) diff --git a/vendor/k8s.io/client-go/dynamic/dynamiclister/interface.go b/vendor/k8s.io/client-go/dynamic/dynamiclister/interface.go deleted file mode 100644 index c39cbee92..000000000 --- a/vendor/k8s.io/client-go/dynamic/dynamiclister/interface.go +++ /dev/null @@ -1,40 +0,0 @@ -/* -Copyright 2018 The Kubernetes Authors. - -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 dynamiclister - -import ( - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" -) - -// Lister helps list resources. -type Lister interface { - // List lists all resources in the indexer. - List(selector labels.Selector) (ret []*unstructured.Unstructured, err error) - // Get retrieves a resource from the indexer with the given name - Get(name string) (*unstructured.Unstructured, error) - // Namespace returns an object that can list and get resources in a given namespace. - Namespace(namespace string) NamespaceLister -} - -// NamespaceLister helps list and get resources. -type NamespaceLister interface { - // List lists all resources in the indexer for a given namespace. - List(selector labels.Selector) (ret []*unstructured.Unstructured, err error) - // Get retrieves a resource from the indexer for a given namespace and name. - Get(name string) (*unstructured.Unstructured, error) -} diff --git a/vendor/k8s.io/client-go/dynamic/dynamiclister/lister.go b/vendor/k8s.io/client-go/dynamic/dynamiclister/lister.go deleted file mode 100644 index a50fc471e..000000000 --- a/vendor/k8s.io/client-go/dynamic/dynamiclister/lister.go +++ /dev/null @@ -1,91 +0,0 @@ -/* -Copyright 2018 The Kubernetes Authors. - -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 dynamiclister - -import ( - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/tools/cache" -) - -var _ Lister = &dynamicLister{} -var _ NamespaceLister = &dynamicNamespaceLister{} - -// dynamicLister implements the Lister interface. -type dynamicLister struct { - indexer cache.Indexer - gvr schema.GroupVersionResource -} - -// New returns a new Lister. -func New(indexer cache.Indexer, gvr schema.GroupVersionResource) Lister { - return &dynamicLister{indexer: indexer, gvr: gvr} -} - -// List lists all resources in the indexer. -func (l *dynamicLister) List(selector labels.Selector) (ret []*unstructured.Unstructured, err error) { - err = cache.ListAll(l.indexer, selector, func(m interface{}) { - ret = append(ret, m.(*unstructured.Unstructured)) - }) - return ret, err -} - -// Get retrieves a resource from the indexer with the given name -func (l *dynamicLister) Get(name string) (*unstructured.Unstructured, error) { - obj, exists, err := l.indexer.GetByKey(name) - if err != nil { - return nil, err - } - if !exists { - return nil, errors.NewNotFound(l.gvr.GroupResource(), name) - } - return obj.(*unstructured.Unstructured), nil -} - -// Namespace returns an object that can list and get resources from a given namespace. -func (l *dynamicLister) Namespace(namespace string) NamespaceLister { - return &dynamicNamespaceLister{indexer: l.indexer, namespace: namespace, gvr: l.gvr} -} - -// dynamicNamespaceLister implements the NamespaceLister interface. -type dynamicNamespaceLister struct { - indexer cache.Indexer - namespace string - gvr schema.GroupVersionResource -} - -// List lists all resources in the indexer for a given namespace. -func (l *dynamicNamespaceLister) List(selector labels.Selector) (ret []*unstructured.Unstructured, err error) { - err = cache.ListAllByNamespace(l.indexer, l.namespace, selector, func(m interface{}) { - ret = append(ret, m.(*unstructured.Unstructured)) - }) - return ret, err -} - -// Get retrieves a resource from the indexer for a given namespace and name. -func (l *dynamicNamespaceLister) Get(name string) (*unstructured.Unstructured, error) { - obj, exists, err := l.indexer.GetByKey(l.namespace + "/" + name) - if err != nil { - return nil, err - } - if !exists { - return nil, errors.NewNotFound(l.gvr.GroupResource(), name) - } - return obj.(*unstructured.Unstructured), nil -} diff --git a/vendor/k8s.io/client-go/dynamic/dynamiclister/shim.go b/vendor/k8s.io/client-go/dynamic/dynamiclister/shim.go deleted file mode 100644 index 92a5f54af..000000000 --- a/vendor/k8s.io/client-go/dynamic/dynamiclister/shim.go +++ /dev/null @@ -1,87 +0,0 @@ -/* -Copyright 2018 The Kubernetes Authors. - -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 dynamiclister - -import ( - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/cache" -) - -var _ cache.GenericLister = &dynamicListerShim{} -var _ cache.GenericNamespaceLister = &dynamicNamespaceListerShim{} - -// dynamicListerShim implements the cache.GenericLister interface. -type dynamicListerShim struct { - lister Lister -} - -// NewRuntimeObjectShim returns a new shim for Lister. -// It wraps Lister so that it implements cache.GenericLister interface -func NewRuntimeObjectShim(lister Lister) cache.GenericLister { - return &dynamicListerShim{lister: lister} -} - -// List will return all objects across namespaces -func (s *dynamicListerShim) List(selector labels.Selector) (ret []runtime.Object, err error) { - objs, err := s.lister.List(selector) - if err != nil { - return nil, err - } - - ret = make([]runtime.Object, len(objs)) - for index, obj := range objs { - ret[index] = obj - } - return ret, err -} - -// Get will attempt to retrieve assuming that name==key -func (s *dynamicListerShim) Get(name string) (runtime.Object, error) { - return s.lister.Get(name) -} - -func (s *dynamicListerShim) ByNamespace(namespace string) cache.GenericNamespaceLister { - return &dynamicNamespaceListerShim{ - namespaceLister: s.lister.Namespace(namespace), - } -} - -// dynamicNamespaceListerShim implements the NamespaceLister interface. -// It wraps NamespaceLister so that it implements cache.GenericNamespaceLister interface -type dynamicNamespaceListerShim struct { - namespaceLister NamespaceLister -} - -// List will return all objects in this namespace -func (ns *dynamicNamespaceListerShim) List(selector labels.Selector) (ret []runtime.Object, err error) { - objs, err := ns.namespaceLister.List(selector) - if err != nil { - return nil, err - } - - ret = make([]runtime.Object, len(objs)) - for index, obj := range objs { - ret[index] = obj - } - return ret, err -} - -// Get will attempt to retrieve by namespace and name -func (ns *dynamicNamespaceListerShim) Get(name string) (runtime.Object, error) { - return ns.namespaceLister.Get(name) -} diff --git a/vendor/modules.txt b/vendor/modules.txt index 862f5b685..f2334b9f5 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1393,8 +1393,6 @@ k8s.io/client-go/applyconfigurations/storagemigration/v1beta1 k8s.io/client-go/discovery k8s.io/client-go/discovery/fake k8s.io/client-go/dynamic -k8s.io/client-go/dynamic/dynamicinformer -k8s.io/client-go/dynamic/dynamiclister k8s.io/client-go/features k8s.io/client-go/gentype k8s.io/client-go/informers @@ -1769,8 +1767,6 @@ sigs.k8s.io/controller-runtime/pkg/webhook/admission/metrics sigs.k8s.io/controller-runtime/pkg/webhook/conversion sigs.k8s.io/controller-runtime/pkg/webhook/conversion/metrics sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics -# sigs.k8s.io/gateway-api v1.6.0 -## explicit; go 1.26.0 # sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 ## explicit; go 1.23 sigs.k8s.io/json From dc38601d74d9317d27215aaebb61a9339d44418f Mon Sep 17 00:00:00 2001 From: npolshakova Date: Wed, 15 Jul 2026 10:51:47 -0700 Subject: [PATCH 9/9] clean up diff Signed-off-by: npolshakova --- go.mod | 36 +- go.sum | 84 ++-- .../fxamacker/cbor/v2/.golangci.yml | 176 ++++--- vendor/github.com/fxamacker/cbor/v2/README.md | 11 +- vendor/github.com/fxamacker/cbor/v2/cache.go | 257 +++++----- vendor/github.com/fxamacker/cbor/v2/decode.go | 440 +++++++++--------- .../fxamacker/cbor/v2/decode_map_utils.go | 98 ---- .../github.com/fxamacker/cbor/v2/diagnose.go | 25 +- vendor/github.com/fxamacker/cbor/v2/encode.go | 55 +-- .../fxamacker/cbor/v2/simplevalue.go | 2 +- vendor/github.com/fxamacker/cbor/v2/stream.go | 48 +- .../fxamacker/cbor/v2/structfields.go | 65 +-- vendor/github.com/fxamacker/cbor/v2/valid.go | 20 +- .../go-openapi/jsonpointer/.cliff.toml | 181 +++++++ .../go-openapi/jsonpointer/.gitignore | 1 - .../go-openapi/jsonpointer/.golangci.yml | 1 - .../go-openapi/jsonpointer/CODE_OF_CONDUCT.md | 6 +- .../go-openapi/jsonpointer/CONTRIBUTORS.md | 29 +- .../github.com/go-openapi/jsonpointer/NOTICE | 2 +- .../go-openapi/jsonpointer/README.md | 57 +-- .../go-openapi/jsonpointer/SECURITY.md | 28 +- .../go-openapi/jsonpointer/errors.go | 26 +- .../go-openapi/jsonpointer/ifaces.go | 47 -- .../go-openapi/jsonpointer/options.go | 86 ---- .../go-openapi/jsonpointer/pointer.go | 348 ++++---------- .../go-openapi/jsonreference/.cliff.toml | 181 +++++++ .../go-openapi/jsonreference/.gitignore | 7 +- .../go-openapi/jsonreference/.golangci.yml | 1 - .../jsonreference/CODE_OF_CONDUCT.md | 6 +- .../go-openapi/jsonreference/CONTRIBUTORS.md | 4 +- .../go-openapi/jsonreference/NOTICE | 4 +- .../go-openapi/jsonreference/README.md | 36 +- .../go-openapi/jsonreference/SECURITY.md | 28 +- .../go-openapi/jsonreference/reference.go | 1 - vendor/github.com/go-openapi/swag/.gitignore | 1 - .../go-openapi/swag/CODE_OF_CONDUCT.md | 6 +- .../go-openapi/swag/CONTRIBUTORS.md | 36 -- vendor/github.com/go-openapi/swag/README.md | 285 ++++++------ vendor/github.com/go-openapi/swag/SECURITY.md | 28 +- vendor/github.com/go-openapi/swag/go.work | 2 +- .../swag/jsonname/go_name_provider.go | 286 ------------ .../go-openapi/swag/jsonname/ifaces.go | 14 - .../go-openapi/swag/jsonname/name_provider.go | 2 - .../go-openapi/swag/jsonutils/README.md | 11 +- .../go-openapi/swag/mangling/BENCHMARK.md | 4 +- .../gnostic-models/extensions/extension.proto | 2 +- .../gnostic-models/openapiv2/OpenAPIv2.proto | 2 +- .../gnostic-models/openapiv3/OpenAPIv3.proto | 2 +- .../openapiv3/annotations.proto | 2 +- vendor/modules.txt | 62 +-- .../v6/fieldpath/element.go | 28 +- .../v6/fieldpath/pathelementmap.go | 25 +- .../structured-merge-diff/v6/fieldpath/set.go | 24 +- .../v6/value/allocator.go | 80 ++-- .../v6/value/jsontagutil.go | 7 +- 55 files changed, 1353 insertions(+), 1953 deletions(-) delete mode 100644 vendor/github.com/fxamacker/cbor/v2/decode_map_utils.go create mode 100644 vendor/github.com/go-openapi/jsonpointer/.cliff.toml delete mode 100644 vendor/github.com/go-openapi/jsonpointer/ifaces.go delete mode 100644 vendor/github.com/go-openapi/jsonpointer/options.go create mode 100644 vendor/github.com/go-openapi/jsonreference/.cliff.toml delete mode 100644 vendor/github.com/go-openapi/swag/CONTRIBUTORS.md delete mode 100644 vendor/github.com/go-openapi/swag/jsonname/go_name_provider.go delete mode 100644 vendor/github.com/go-openapi/swag/jsonname/ifaces.go diff --git a/go.mod b/go.mod index 30a94012a..c7fd2c8b9 100644 --- a/go.mod +++ b/go.mod @@ -103,27 +103,27 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/swag v0.26.0 // indirect - github.com/go-openapi/swag/cmdutils v0.26.0 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/fileutils v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/loading v0.26.0 // indirect - github.com/go-openapi/swag/mangling v0.26.0 // indirect - github.com/go-openapi/swag/netutils v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect - github.com/go-openapi/swag/yamlutils v0.26.0 // indirect - github.com/google/gnostic-models v0.7.1 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect @@ -187,9 +187,9 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gotest.tools/v3 v3.5.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/streaming v0.36.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) diff --git a/go.sum b/go.sum index 59bd369ad..555f6c4bb 100644 --- a/go.sum +++ b/go.sum @@ -129,8 +129,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= -github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -142,47 +142,47 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= -github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= -github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= -github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= -github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= -github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= -github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= -github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= -github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= -github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= -github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= @@ -438,8 +438,8 @@ k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 h1:ngxu1nL4SbFuXwu1EY7cSKcVqSjTQPVbYQT6WNjTXaU= -k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= @@ -450,7 +450,7 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= -sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/vendor/github.com/fxamacker/cbor/v2/.golangci.yml b/vendor/github.com/fxamacker/cbor/v2/.golangci.yml index 08081fbde..38cb9ae10 100644 --- a/vendor/github.com/fxamacker/cbor/v2/.golangci.yml +++ b/vendor/github.com/fxamacker/cbor/v2/.golangci.yml @@ -1,116 +1,104 @@ -version: "2" +# Do not delete linter settings. Linters like gocritic can be enabled on the command line. + +linters-settings: + depguard: + rules: + prevent_unmaintained_packages: + list-mode: strict + files: + - $all + - "!$test" + allow: + - $gostd + - github.com/x448/float16 + deny: + - pkg: io/ioutil + desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil" + dupl: + threshold: 100 + funlen: + lines: 100 + statements: 50 + goconst: + ignore-tests: true + min-len: 2 + min-occurrences: 3 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - commentedOutCode + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - ifElseChain + - octalLiteral + - paramTypeCombine + - whyNoLint + gofmt: + simplify: false + goimports: + local-prefixes: github.com/fxamacker/cbor + golint: + min-confidence: 0 + govet: + check-shadowing: true + lll: + line-length: 140 + maligned: + suggest-new: true + misspell: + locale: US + staticcheck: + checks: ["all"] + linters: - default: none + disable-all: true enable: - asciicheck - bidichk - depguard - errcheck - - forbidigo + - exportloopref - goconst - gocritic - gocyclo + - gofmt + - goimports - goprintffuncname - gosec + - gosimple - govet - ineffassign - misspell - nilerr - revive - staticcheck + - stylecheck + - typecheck - unconvert - unused - settings: - depguard: - rules: - prevent_unmaintained_packages: - list-mode: strict - files: - - $all - - '!$test' - allow: - - $gostd - - github.com/x448/float16 - deny: - - pkg: io/ioutil - desc: 'replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil' - dupl: - threshold: 100 - funlen: - lines: 100 - statements: 50 - goconst: - min-len: 2 - min-occurrences: 3 - gocritic: - disabled-checks: - - commentedOutCode - - dupImport - - ifElseChain - - octalLiteral - - paramTypeCombine - - whyNoLint - enabled-tags: - - diagnostic - - experimental - - opinionated - - performance - - style - govet: - enable: - - shadow - lll: - line-length: 140 - misspell: - locale: US - staticcheck: - checks: - - all - exclusions: - generated: lax - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - rules: - - path: decode.go - text: string ` overflows ` has (\d+) occurrences, make it a constant - - path: decode.go - text: string ` \(range is \[` has (\d+) occurrences, make it a constant - - path: decode.go - text: string `, ` has (\d+) occurrences, make it a constant - - path: decode.go - text: string ` overflows Go's int64` has (\d+) occurrences, make it a constant - - path: decode.go - text: string `\]\)` has (\d+) occurrences, make it a constant - - path: valid.go - text: string ` for type ` has (\d+) occurrences, make it a constant - - path: valid.go - text: 'string `cbor: ` has (\d+) occurrences, make it a constant' - - linters: - - goconst - path: (.+)_test\.go - paths: - - third_party$ - - builtin$ - - examples$ + issues: + # max-issues-per-linter default is 50. Set to 0 to disable limit. max-issues-per-linter: 0 + # max-same-issues default is 3. Set to 0 to disable limit. max-same-issues: 0 -formatters: - enable: - - gofmt - - goimports - settings: - gofmt: - simplify: false - goimports: - local-prefixes: - - github.com/fxamacker/cbor - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ + + exclude-rules: + - path: decode.go + text: "string ` overflows ` has (\\d+) occurrences, make it a constant" + - path: decode.go + text: "string ` \\(range is \\[` has (\\d+) occurrences, make it a constant" + - path: decode.go + text: "string `, ` has (\\d+) occurrences, make it a constant" + - path: decode.go + text: "string ` overflows Go's int64` has (\\d+) occurrences, make it a constant" + - path: decode.go + text: "string `\\]\\)` has (\\d+) occurrences, make it a constant" + - path: valid.go + text: "string ` for type ` has (\\d+) occurrences, make it a constant" + - path: valid.go + text: "string `cbor: ` has (\\d+) occurrences, make it a constant" diff --git a/vendor/github.com/fxamacker/cbor/v2/README.md b/vendor/github.com/fxamacker/cbor/v2/README.md index f9ae78ec9..d072b81c7 100644 --- a/vendor/github.com/fxamacker/cbor/v2/README.md +++ b/vendor/github.com/fxamacker/cbor/v2/README.md @@ -702,20 +702,21 @@ Default limits may need to be increased for systems handling very large data (e. ## Status -v2.9.1 (Mar 29-30, 2026) includes important bugfixes, defensive checks, improved code quality, and more tests. Although not public, the fuzzer was also improved by adding more fuzz tests. +[v2.9.0](https://github.com/fxamacker/cbor/releases/tag/v2.9.0) (Jul 13, 2025) improved interoperability/transcoding between CBOR & JSON, refactored tests, and improved docs. +- Add opt-in support for `encoding.TextMarshaler` and `encoding.TextUnmarshaler` to encode and decode from CBOR text string. +- Add opt-in support for `json.Marshaler` and `json.Unmarshaler` via user-provided transcoding function. +- Update docs for TimeMode, Tag, RawTag, and add example for Embedded JSON Tag for CBOR. -v2.9.1 passed fuzz tests and is production quality. +v2.9.0 passed fuzz tests and is production quality. The minimum version of Go required to build: - v2.8.0 and newer releases require go 1.20+. - v2.7.1 and older releases require go 1.17+. -For more details, see [v2.9.1 release notes](https://github.com/fxamacker/cbor/releases). +For more details, see [release notes](https://github.com/fxamacker/cbor/releases). ### Prior Releases -[v2.9.0](https://github.com/fxamacker/cbor/releases/tag/v2.9.0) (Jul 13, 2025) improved interoperability/transcoding between CBOR & JSON, refactored tests, and improved docs. It passed fuzz tests (billions of executions) and is production quality. - [v2.8.0](https://github.com/fxamacker/cbor/releases/tag/v2.8.0) (March 30, 2025) is a small release primarily to add `omitzero` option to struct field tags and fix bugs. It passed fuzz tests (billions of executions) and is production quality. [v2.7.0](https://github.com/fxamacker/cbor/releases/tag/v2.7.0) (June 23, 2024) adds features and improvements that help large projects (e.g. Kubernetes) use CBOR as an alternative to JSON and Protocol Buffers. Other improvements include speedups, improved memory use, bug fixes, new serialization options, etc. It passed fuzz tests (5+ billion executions) and is production quality. diff --git a/vendor/github.com/fxamacker/cbor/v2/cache.go b/vendor/github.com/fxamacker/cbor/v2/cache.go index 5743f3eb2..5051f110f 100644 --- a/vendor/github.com/fxamacker/cbor/v2/cache.go +++ b/vendor/github.com/fxamacker/cbor/v2/cache.go @@ -92,126 +92,94 @@ func newTypeInfo(t reflect.Type) *typeInfo { } type decodingStructType struct { - fields decodingFields - fieldIndicesByName map[string]int // Only populated if toArray is false - fieldIndicesByIntKey map[int64]int // Only populated if toArray is false - err error - toArray bool + fields fields + fieldIndicesByName map[string]int + err error + toArray bool } -func getDecodingStructType(t reflect.Type) (*decodingStructType, error) { - if v, _ := decodingStructTypeCache.Load(t); v != nil { - structType := v.(*decodingStructType) - if structType.err != nil { - return nil, structType.err +// The stdlib errors.Join was introduced in Go 1.20, and we still support Go 1.17, so instead, +// here's a very basic implementation of an aggregated error. +type multierror []error + +func (m multierror) Error() string { + var sb strings.Builder + for i, err := range m { + sb.WriteString(err.Error()) + if i < len(m)-1 { + sb.WriteString(", ") } - return structType, nil + } + return sb.String() +} + +func getDecodingStructType(t reflect.Type) *decodingStructType { + if v, _ := decodingStructTypeCache.Load(t); v != nil { + return v.(*decodingStructType) } flds, structOptions := getFields(t) toArray := hasToArrayOption(structOptions) - if toArray { - return getDecodingStructToArrayType(t, flds) - } - - fieldIndicesByName := make(map[string]int, len(flds)) - var fieldIndicesByIntKey map[int64]int - - decFlds := make(decodingFields, len(flds)) - for i, f := range flds { - // nameAsInt is set in getFields() except for fields with an unparsable tagged name. - // Atoi() is called here to catch and save parsing errors. - if f.keyAsInt && f.nameAsInt == 0 { - if _, numErr := strconv.Atoi(f.name); numErr != nil { - structType := &decodingStructType{ - err: errors.New("cbor: failed to parse field name \"" + f.name + "\" to int (" + numErr.Error() + ")"), - } - decodingStructTypeCache.Store(t, structType) - return nil, structType.err + var errs []error + for i := 0; i < len(flds); i++ { + if flds[i].keyAsInt { + nameAsInt, numErr := strconv.Atoi(flds[i].name) + if numErr != nil { + errs = append(errs, errors.New("cbor: failed to parse field name \""+flds[i].name+"\" to int ("+numErr.Error()+")")) + break } + flds[i].nameAsInt = int64(nameAsInt) } - if f.keyAsInt { - if fieldIndicesByIntKey == nil { - fieldIndicesByIntKey = make(map[int64]int, len(flds)) - } - // The duplication check is only a safeguard, since getFields() already deduplicates fields. - if _, ok := fieldIndicesByIntKey[f.nameAsInt]; ok { - structType := &decodingStructType{ - err: fmt.Errorf("cbor: two or more fields of %v have the same keyasint value %d", t, f.nameAsInt), - } - decodingStructTypeCache.Store(t, structType) - return nil, structType.err - } - fieldIndicesByIntKey[f.nameAsInt] = i - } else { - // The duplication check is only a safeguard, since getFields() already deduplicates fields. - if _, ok := fieldIndicesByName[f.name]; ok { - structType := &decodingStructType{ - err: fmt.Errorf("cbor: two or more fields of %v have the same name %q", t, f.name), - } - decodingStructTypeCache.Store(t, structType) - return nil, structType.err - } - fieldIndicesByName[f.name] = i - } - - decFlds[i] = &decodingField{ - field: *f, - typInfo: getTypeInfo(f.typ), - } + flds[i].typInfo = getTypeInfo(flds[i].typ) } - structType := &decodingStructType{ - fields: decFlds, - fieldIndicesByName: fieldIndicesByName, - fieldIndicesByIntKey: fieldIndicesByIntKey, + fieldIndicesByName := make(map[string]int, len(flds)) + for i, fld := range flds { + if _, ok := fieldIndicesByName[fld.name]; ok { + errs = append(errs, fmt.Errorf("cbor: two or more fields of %v have the same name %q", t, fld.name)) + continue + } + fieldIndicesByName[fld.name] = i } - decodingStructTypeCache.Store(t, structType) - return structType, nil -} -func getDecodingStructToArrayType(t reflect.Type, flds fields) (*decodingStructType, error) { - decFlds := make(decodingFields, len(flds)) - for i, f := range flds { - // nameAsInt is set in getFields() except for fields with an unparsable tagged name. - // Atoi() is called here to catch and save parsing errors. - if f.keyAsInt && f.nameAsInt == 0 { - if _, numErr := strconv.Atoi(f.name); numErr != nil { - structType := &decodingStructType{ - err: errors.New("cbor: failed to parse field name \"" + f.name + "\" to int (" + numErr.Error() + ")"), - } - decodingStructTypeCache.Store(t, structType) - return nil, structType.err + var err error + { + var multi multierror + for _, each := range errs { + if each != nil { + multi = append(multi, each) } } - - decFlds[i] = &decodingField{ - field: *f, - typInfo: getTypeInfo(f.typ), + if len(multi) == 1 { + err = multi[0] + } else if len(multi) > 1 { + err = multi } } structType := &decodingStructType{ - fields: decFlds, - toArray: true, + fields: flds, + fieldIndicesByName: fieldIndicesByName, + err: err, + toArray: toArray, } decodingStructTypeCache.Store(t, structType) - return structType, nil + return structType } type encodingStructType struct { - fields encodingFields - bytewiseFields encodingFields // Only populated if toArray is false - lengthFirstFields encodingFields // Only populated if toArray is false - omitEmptyFieldsIdx []int // Only populated if toArray is false + fields fields + bytewiseFields fields + lengthFirstFields fields + omitEmptyFieldsIdx []int err error toArray bool } -func (st *encodingStructType) getFields(em *encMode) encodingFields { +func (st *encodingStructType) getFields(em *encMode) fields { switch em.sort { case SortNone, SortFastShuffle: return st.fields @@ -223,7 +191,7 @@ func (st *encodingStructType) getFields(em *encMode) encodingFields { } type bytewiseFieldSorter struct { - fields encodingFields + fields fields } func (x *bytewiseFieldSorter) Len() int { @@ -235,11 +203,11 @@ func (x *bytewiseFieldSorter) Swap(i, j int) { } func (x *bytewiseFieldSorter) Less(i, j int) bool { - return bytes.Compare(x.fields[i].cborName, x.fields[j].cborName) < 0 + return bytes.Compare(x.fields[i].cborName, x.fields[j].cborName) <= 0 } type lengthFirstFieldSorter struct { - fields encodingFields + fields fields } func (x *lengthFirstFieldSorter) Len() int { @@ -254,16 +222,13 @@ func (x *lengthFirstFieldSorter) Less(i, j int) bool { if len(x.fields[i].cborName) != len(x.fields[j].cborName) { return len(x.fields[i].cborName) < len(x.fields[j].cborName) } - return bytes.Compare(x.fields[i].cborName, x.fields[j].cborName) < 0 + return bytes.Compare(x.fields[i].cborName, x.fields[j].cborName) <= 0 } func getEncodingStructType(t reflect.Type) (*encodingStructType, error) { if v, _ := encodingStructTypeCache.Load(t); v != nil { structType := v.(*encodingStructType) - if structType.err != nil { - return nil, structType.err - } - return structType, nil + return structType, structType.err } flds, structOptions := getFields(t) @@ -272,119 +237,111 @@ func getEncodingStructType(t reflect.Type) (*encodingStructType, error) { return getEncodingStructToArrayType(t, flds) } + var err error var hasKeyAsInt bool var hasKeyAsStr bool var omitEmptyIdx []int - - encFlds := make(encodingFields, len(flds)) - e := getEncodeBuffer() - defer putEncodeBuffer(e) - - for i, f := range flds { - encFlds[i] = &encodingField{field: *f} - ef := encFlds[i] - + for i := 0; i < len(flds); i++ { // Get field's encodeFunc - ef.ef, ef.ief, ef.izf = getEncodeFunc(f.typ) - if ef.ef == nil { - structType := &encodingStructType{err: &UnsupportedTypeError{t}} - encodingStructTypeCache.Store(t, structType) - return nil, structType.err + flds[i].ef, flds[i].ief, flds[i].izf = getEncodeFunc(flds[i].typ) + if flds[i].ef == nil { + err = &UnsupportedTypeError{t} + break } // Encode field name - if f.keyAsInt { - if f.nameAsInt == 0 { - // nameAsInt is set in getFields() except for fields with an unparsable tagged name. - // Atoi() is called here to catch and save parsing errors. - if _, numErr := strconv.Atoi(f.name); numErr != nil { - structType := &encodingStructType{ - err: errors.New("cbor: failed to parse field name \"" + f.name + "\" to int (" + numErr.Error() + ")"), - } - encodingStructTypeCache.Store(t, structType) - return nil, structType.err - } + if flds[i].keyAsInt { + nameAsInt, numErr := strconv.Atoi(flds[i].name) + if numErr != nil { + err = errors.New("cbor: failed to parse field name \"" + flds[i].name + "\" to int (" + numErr.Error() + ")") + break } - nameAsInt := f.nameAsInt + flds[i].nameAsInt = int64(nameAsInt) if nameAsInt >= 0 { - encodeHead(e, byte(cborTypePositiveInt), uint64(nameAsInt)) //nolint:gosec + encodeHead(e, byte(cborTypePositiveInt), uint64(nameAsInt)) } else { n := nameAsInt*(-1) - 1 - encodeHead(e, byte(cborTypeNegativeInt), uint64(n)) //nolint:gosec + encodeHead(e, byte(cborTypeNegativeInt), uint64(n)) } - ef.cborName = make([]byte, e.Len()) - copy(ef.cborName, e.Bytes()) + flds[i].cborName = make([]byte, e.Len()) + copy(flds[i].cborName, e.Bytes()) e.Reset() hasKeyAsInt = true } else { - encodeHead(e, byte(cborTypeTextString), uint64(len(f.name))) - ef.cborName = make([]byte, e.Len()+len(f.name)) - n := copy(ef.cborName, e.Bytes()) - copy(ef.cborName[n:], f.name) + encodeHead(e, byte(cborTypeTextString), uint64(len(flds[i].name))) + flds[i].cborName = make([]byte, e.Len()+len(flds[i].name)) + n := copy(flds[i].cborName, e.Bytes()) + copy(flds[i].cborName[n:], flds[i].name) e.Reset() // If cborName contains a text string, then cborNameByteString contains a // string that has the byte string major type but is otherwise identical to // cborName. - ef.cborNameByteString = make([]byte, len(ef.cborName)) - copy(ef.cborNameByteString, ef.cborName) + flds[i].cborNameByteString = make([]byte, len(flds[i].cborName)) + copy(flds[i].cborNameByteString, flds[i].cborName) // Reset encoded CBOR type to byte string, preserving the "additional // information" bits: - ef.cborNameByteString[0] = byte(cborTypeByteString) | - getAdditionalInformation(ef.cborNameByteString[0]) + flds[i].cborNameByteString[0] = byte(cborTypeByteString) | + getAdditionalInformation(flds[i].cborNameByteString[0]) hasKeyAsStr = true } // Check if field can be omitted when empty - if f.omitEmpty { + if flds[i].omitEmpty { omitEmptyIdx = append(omitEmptyIdx, i) } } + putEncodeBuffer(e) + + if err != nil { + structType := &encodingStructType{err: err} + encodingStructTypeCache.Store(t, structType) + return structType, structType.err + } // Sort fields by canonical order - bytewiseFields := make(encodingFields, len(encFlds)) - copy(bytewiseFields, encFlds) + bytewiseFields := make(fields, len(flds)) + copy(bytewiseFields, flds) sort.Sort(&bytewiseFieldSorter{bytewiseFields}) lengthFirstFields := bytewiseFields if hasKeyAsInt && hasKeyAsStr { - lengthFirstFields = make(encodingFields, len(encFlds)) - copy(lengthFirstFields, encFlds) + lengthFirstFields = make(fields, len(flds)) + copy(lengthFirstFields, flds) sort.Sort(&lengthFirstFieldSorter{lengthFirstFields}) } structType := &encodingStructType{ - fields: encFlds, + fields: flds, bytewiseFields: bytewiseFields, lengthFirstFields: lengthFirstFields, omitEmptyFieldsIdx: omitEmptyIdx, } encodingStructTypeCache.Store(t, structType) - return structType, nil + return structType, structType.err } func getEncodingStructToArrayType(t reflect.Type, flds fields) (*encodingStructType, error) { - encFlds := make(encodingFields, len(flds)) - for i, f := range flds { - encFlds[i] = &encodingField{field: *f} - encFlds[i].ef, encFlds[i].ief, encFlds[i].izf = getEncodeFunc(f.typ) - if encFlds[i].ef == nil { + for i := 0; i < len(flds); i++ { + // Get field's encodeFunc + flds[i].ef, flds[i].ief, flds[i].izf = getEncodeFunc(flds[i].typ) + if flds[i].ef == nil { structType := &encodingStructType{err: &UnsupportedTypeError{t}} encodingStructTypeCache.Store(t, structType) - return nil, structType.err + return structType, structType.err } } structType := &encodingStructType{ - fields: encFlds, + fields: flds, toArray: true, } encodingStructTypeCache.Store(t, structType) - return structType, nil + return structType, structType.err } func getEncodeFunc(t reflect.Type) (encodeFunc, isEmptyFunc, isZeroFunc) { diff --git a/vendor/github.com/fxamacker/cbor/v2/decode.go b/vendor/github.com/fxamacker/cbor/v2/decode.go index 03fd7f8b0..f0bdc3b38 100644 --- a/vendor/github.com/fxamacker/cbor/v2/decode.go +++ b/vendor/github.com/fxamacker/cbor/v2/decode.go @@ -16,6 +16,7 @@ import ( "math/big" "reflect" "strconv" + "strings" "time" "unicode/utf8" @@ -325,14 +326,14 @@ func (dmkm DupMapKeyMode) valid() bool { return dmkm >= 0 && dmkm < maxDupMapKeyMode } -// IndefLengthMode specifies whether to allow indefinite-length items. +// IndefLengthMode specifies whether to allow indefinite length items. type IndefLengthMode int const ( - // IndefLengthAllowed allows indefinite-length items. + // IndefLengthAllowed allows indefinite length items. IndefLengthAllowed IndefLengthMode = iota - // IndefLengthForbidden disallows indefinite-length items. + // IndefLengthForbidden disallows indefinite length items. IndefLengthForbidden maxIndefLengthMode @@ -377,7 +378,6 @@ const ( // - int64 if value fits // - big.Int or *big.Int (see BigIntDecMode) if value < math.MinInt64 // - return UnmarshalTypeError if value > math.MaxInt64 - // // Deprecated: IntDecConvertSigned should not be used. // Please use other options, such as IntDecConvertSignedOrError, IntDecConvertSignedOrBigInt, IntDecConvertNone. IntDecConvertSigned @@ -811,7 +811,7 @@ type DecOptions struct { // Default is 128*1024=131072 and it can be set to [16, 2147483647] MaxMapPairs int - // IndefLength specifies whether to allow indefinite-length CBOR items. + // IndefLength specifies whether to allow indefinite length CBOR items. IndefLength IndefLengthMode // TagsMd specifies whether to allow CBOR tags (major type 6). @@ -1055,7 +1055,7 @@ func (opts DecOptions) decMode() (*decMode, error) { //nolint:gocritic // ignore } if !opts.ExtraReturnErrors.valid() { - return nil, errors.New("cbor: invalid ExtraReturnErrors " + strconv.Itoa(int(opts.ExtraReturnErrors))) //nolint:gosec + return nil, errors.New("cbor: invalid ExtraReturnErrors " + strconv.Itoa(int(opts.ExtraReturnErrors))) } if opts.DefaultMapType != nil && opts.DefaultMapType.Kind() != reflect.Map { @@ -1149,8 +1149,8 @@ func (opts DecOptions) decMode() (*decMode, error) { //nolint:gocritic // ignore unrecognizedTagToAny: opts.UnrecognizedTagToAny, timeTagToAny: opts.TimeTagToAny, simpleValues: simpleValues, - nan: opts.NaN, - inf: opts.Inf, + nanDec: opts.NaN, + infDec: opts.Inf, byteStringToTime: opts.ByteStringToTime, byteStringExpectedFormat: opts.ByteStringExpectedFormat, bignumTag: opts.BignumTag, @@ -1230,8 +1230,8 @@ type decMode struct { unrecognizedTagToAny UnrecognizedTagToAnyMode timeTagToAny TimeTagToAnyMode simpleValues *SimpleValueRegistry - nan NaNMode - inf InfMode + nanDec NaNMode + infDec InfMode byteStringToTime ByteStringToTimeMode byteStringExpectedFormat ByteStringExpectedFormatMode bignumTag BignumTagMode @@ -1272,8 +1272,8 @@ func (dm *decMode) DecOptions() DecOptions { UnrecognizedTagToAny: dm.unrecognizedTagToAny, TimeTagToAny: dm.timeTagToAny, SimpleValues: simpleValues, - NaN: dm.nan, - Inf: dm.inf, + NaN: dm.nanDec, + Inf: dm.infDec, ByteStringToTime: dm.byteStringToTime, ByteStringExpectedFormat: dm.byteStringExpectedFormat, BignumTag: dm.bignumTag, @@ -1583,11 +1583,11 @@ func (d *decoder) parseToValue(v reflect.Value, tInfo *typeInfo) error { //nolin _, ai, val := d.getHead() switch ai { case additionalInformationAsFloat16: - f := float64(float16.Frombits(uint16(val)).Float32()) //nolint:gosec + f := float64(float16.Frombits(uint16(val)).Float32()) return fillFloat(t, f, v) case additionalInformationAsFloat32: - f := float64(math.Float32frombits(uint32(val))) //nolint:gosec + f := float64(math.Float32frombits(uint32(val))) return fillFloat(t, f, v) case additionalInformationAsFloat64: @@ -1595,10 +1595,10 @@ func (d *decoder) parseToValue(v reflect.Value, tInfo *typeInfo) error { //nolin return fillFloat(t, f, v) default: // ai <= 24 - if d.dm.simpleValues.rejected[SimpleValue(val)] { //nolint:gosec + if d.dm.simpleValues.rejected[SimpleValue(val)] { return &UnacceptableDataItemError{ CBORType: t.String(), - Message: "simple value " + strconv.FormatInt(int64(val), 10) + " is not recognized", //nolint:gosec + Message: "simple value " + strconv.FormatInt(int64(val), 10) + " is not recognized", } } @@ -1677,23 +1677,20 @@ func (d *decoder) parseToValue(v reflect.Value, tInfo *typeInfo) error { //nolin return d.parseToValue(v, tInfo) case cborTypeArray: - switch tInfo.nonPtrKind { - case reflect.Slice: + if tInfo.nonPtrKind == reflect.Slice { return d.parseArrayToSlice(v, tInfo) - case reflect.Array: + } else if tInfo.nonPtrKind == reflect.Array { return d.parseArrayToArray(v, tInfo) - case reflect.Struct: + } else if tInfo.nonPtrKind == reflect.Struct { return d.parseArrayToStruct(v, tInfo) } - d.skip() return &UnmarshalTypeError{CBORType: t.String(), GoType: tInfo.nonPtrType.String()} case cborTypeMap: - switch tInfo.nonPtrKind { - case reflect.Struct: + if tInfo.nonPtrKind == reflect.Struct { return d.parseMapToStruct(v, tInfo) - case reflect.Map: + } else if tInfo.nonPtrKind == reflect.Map { return d.parseMapToMap(v, tInfo) } d.skip() @@ -1748,8 +1745,8 @@ func (d *decoder) parseToTime() (time.Time, bool, error) { // Read tag number _, _, tagNum := d.getHead() if tagNum != 0 && tagNum != 1 { - d.skip() // skip tag content - return time.Time{}, false, errors.New("cbor: wrong tag number for time.Time, got " + strconv.Itoa(int(tagNum)) + ", expect 0 or 1") //nolint:gosec + d.skip() // skip tag content + return time.Time{}, false, errors.New("cbor: wrong tag number for time.Time, got " + strconv.Itoa(int(tagNum)) + ", expect 0 or 1") } } } else { @@ -1818,10 +1815,10 @@ func (d *decoder) parseToTime() (time.Time, bool, error) { var f float64 switch ai { case additionalInformationAsFloat16: - f = float64(float16.Frombits(uint16(val)).Float32()) //nolint:gosec + f = float64(float16.Frombits(uint16(val)).Float32()) case additionalInformationAsFloat32: - f = float64(math.Float32frombits(uint32(val))) //nolint:gosec + f = float64(math.Float32frombits(uint32(val))) case additionalInformationAsFloat64: f = math.Float64frombits(val) @@ -1835,13 +1832,6 @@ func (d *decoder) parseToTime() (time.Time, bool, error) { return time.Time{}, true, nil } seconds, fractional := math.Modf(f) - if seconds > math.MaxInt64 || seconds < math.MinInt64 { - return time.Time{}, false, &UnmarshalTypeError{ - CBORType: t.String(), - GoType: typeTime.String(), - errorMsg: fmt.Sprintf("%v overflows Go's int64", f), - } - } return time.Unix(int64(seconds), int64(fractional*1e9)), true, nil default: @@ -2155,14 +2145,14 @@ func (d *decoder) parse(skipSelfDescribedTag bool) (any, error) { //nolint:gocyc case cborTypePrimitives: _, ai, val := d.getHead() - if ai <= 24 && d.dm.simpleValues.rejected[SimpleValue(val)] { //nolint:gosec + if ai <= 24 && d.dm.simpleValues.rejected[SimpleValue(val)] { return nil, &UnacceptableDataItemError{ CBORType: t.String(), - Message: "simple value " + strconv.FormatInt(int64(val), 10) + " is not recognized", //nolint:gosec + Message: "simple value " + strconv.FormatInt(int64(val), 10) + " is not recognized", } } if ai < 20 || ai == 24 { - return SimpleValue(val), nil //nolint:gosec + return SimpleValue(val), nil } switch ai { @@ -2175,11 +2165,11 @@ func (d *decoder) parse(skipSelfDescribedTag bool) (any, error) { //nolint:gocyc return nil, nil case additionalInformationAsFloat16: - f := float64(float16.Frombits(uint16(val)).Float32()) //nolint:gosec + f := float64(float16.Frombits(uint16(val)).Float32()) return f, nil case additionalInformationAsFloat32: - f := float64(math.Float32frombits(uint32(val))) //nolint:gosec + f := float64(math.Float32frombits(uint32(val))) return f, nil case additionalInformationAsFloat64: @@ -2212,16 +2202,16 @@ func (d *decoder) parse(skipSelfDescribedTag bool) (any, error) { //nolint:gocyc func (d *decoder) parseByteString() ([]byte, bool) { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() if !indefiniteLength { - b := d.data[d.off : d.off+int(val)] //nolint:gosec - d.off += int(val) //nolint:gosec + b := d.data[d.off : d.off+int(val)] + d.off += int(val) return b, false } - // Process indefinite-length string chunks. + // Process indefinite length string chunks. b := []byte{} for !d.foundBreak() { _, _, val = d.getHead() - b = append(b, d.data[d.off:d.off+int(val)]...) //nolint:gosec - d.off += int(val) //nolint:gosec + b = append(b, d.data[d.off:d.off+int(val)]...) + d.off += int(val) } return b, true } @@ -2310,19 +2300,19 @@ func (d *decoder) applyByteStringTextConversion( func (d *decoder) parseTextString() ([]byte, error) { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() if !indefiniteLength { - b := d.data[d.off : d.off+int(val)] //nolint:gosec - d.off += int(val) //nolint:gosec + b := d.data[d.off : d.off+int(val)] + d.off += int(val) if d.dm.utf8 == UTF8RejectInvalid && !utf8.Valid(b) { return nil, &SemanticError{"cbor: invalid UTF-8 string"} } return b, nil } - // Process indefinite-length string chunks. + // Process indefinite length string chunks. b := []byte{} for !d.foundBreak() { _, _, val = d.getHead() - x := d.data[d.off : d.off+int(val)] //nolint:gosec - d.off += int(val) //nolint:gosec + x := d.data[d.off : d.off+int(val)] + d.off += int(val) if d.dm.utf8 == UTF8RejectInvalid && !utf8.Valid(x) { for !d.foundBreak() { d.skip() // Skip remaining chunk on error @@ -2337,7 +2327,7 @@ func (d *decoder) parseTextString() ([]byte, error) { func (d *decoder) parseArray() ([]any, error) { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) //nolint:gosec + count := int(val) if !hasSize { count = d.numOfItemsUntilBreak() // peek ahead to get array size to preallocate slice for better performance } @@ -2359,7 +2349,7 @@ func (d *decoder) parseArray() ([]any, error) { func (d *decoder) parseArrayToSlice(v reflect.Value, tInfo *typeInfo) error { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) //nolint:gosec + count := int(val) if !hasSize { count = d.numOfItemsUntilBreak() // peek ahead to get array size to preallocate slice for better performance } @@ -2381,7 +2371,7 @@ func (d *decoder) parseArrayToSlice(v reflect.Value, tInfo *typeInfo) error { func (d *decoder) parseArrayToArray(v reflect.Value, tInfo *typeInfo) error { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) //nolint:gosec + count := int(val) gi := 0 vLen := v.Len() var err error @@ -2410,7 +2400,7 @@ func (d *decoder) parseArrayToArray(v reflect.Value, tInfo *typeInfo) error { func (d *decoder) parseMap() (any, error) { _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) //nolint:gosec + count := int(val) m := make(map[any]any) var k, e any var err, lastErr error @@ -2475,7 +2465,7 @@ func (d *decoder) parseMap() (any, error) { func (d *decoder) parseMapToMap(v reflect.Value, tInfo *typeInfo) error { //nolint:gocyclo _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) //nolint:gosec + count := int(val) if v.IsNil() { mapsize := count if !hasSize { @@ -2576,9 +2566,9 @@ func (d *decoder) parseMapToMap(v reflect.Value, tInfo *typeInfo) error { //noli } func (d *decoder) parseArrayToStruct(v reflect.Value, tInfo *typeInfo) error { - structType, structTypeErr := getDecodingStructType(tInfo.nonPtrType) - if structTypeErr != nil { - return structTypeErr + structType := getDecodingStructType(tInfo.nonPtrType) + if structType.err != nil { + return structType.err } if !structType.toArray { @@ -2594,7 +2584,7 @@ func (d *decoder) parseArrayToStruct(v reflect.Value, tInfo *typeInfo) error { start := d.off _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) //nolint:gosec + count := int(val) if !hasSize { count = d.numOfItemsUntilBreak() // peek ahead to get array size } @@ -2647,72 +2637,11 @@ func (d *decoder) parseArrayToStruct(v reflect.Value, tInfo *typeInfo) error { return err } -// skipMapEntriesFromIndex skips remaining map entries starting from index i. -func (d *decoder) skipMapEntriesFromIndex(i, count int, hasSize bool) { - for ; (hasSize && i < count) || (!hasSize && !d.foundBreak()); i++ { - d.skip() - d.skip() - } -} - -// skipMapForDupKey skips the current map value and all remaining map entries, -// then returns a DupMapKeyError for the given key at map index i. -func (d *decoder) skipMapForDupKey(dupKey any, i, count int, hasSize bool) error { - // Skip the value of the duplicate key. - d.skip() - // Skip all remaining map entries. - d.skipMapEntriesFromIndex(i+1, count, hasSize) - return &DupMapKeyError{dupKey, i} -} - -// skipMapForUnknownField skips the current map value and all remaining map entries, -// then returns a UnknownFieldError for the given key at map index i. -func (d *decoder) skipMapForUnknownField(i, count int, hasSize bool) error { - // Skip the value of the unknown key. - d.skip() - // Skip all remaining map entries. - d.skipMapEntriesFromIndex(i+1, count, hasSize) - return &UnknownFieldError{i} -} - -// decodeToStructField decodes the next CBOR value into the struct field f in v. -// If the field cannot be resolved, the CBOR value is skipped. -func (d *decoder) decodeToStructField(v reflect.Value, f *decodingField, tInfo *typeInfo) error { - var fv reflect.Value - - if len(f.idx) == 1 { - fv = v.Field(f.idx[0]) - } else { - var err error - fv, err = getFieldValue(v, f.idx, func(v reflect.Value) (reflect.Value, error) { - // Return a new value for embedded field null pointer to point to, or return error. - if !v.CanSet() { - return reflect.Value{}, errors.New("cbor: cannot set embedded pointer to unexported struct: " + v.Type().String()) - } - v.Set(reflect.New(v.Type().Elem())) - return v, nil - }) - if !fv.IsValid() { - d.skip() - return err - } - } - - err := d.parseToValue(fv, f.typInfo) - if err != nil { - if typeError, ok := err.(*UnmarshalTypeError); ok { - typeError.StructFieldName = tInfo.nonPtrType.String() + "." + f.name - } - return err - } - - return nil -} - +// parseMapToStruct needs to be fast so gocyclo can be ignored for now. func (d *decoder) parseMapToStruct(v reflect.Value, tInfo *typeInfo) error { //nolint:gocyclo - structType, structTypeErr := getDecodingStructType(tInfo.nonPtrType) - if structTypeErr != nil { - return structTypeErr + structType := getDecodingStructType(tInfo.nonPtrType) + if structType.err != nil { + return structType.err } if structType.toArray { @@ -2725,12 +2654,14 @@ func (d *decoder) parseMapToStruct(v reflect.Value, tInfo *typeInfo) error { //n } } + var err, lastErr error + // Get CBOR map size _, _, val, indefiniteLength := d.getHeadWithIndefiniteLengthFlag() hasSize := !indefiniteLength - count := int(val) //nolint:gosec + count := int(val) - // Keep track of matched struct fields to detect duplicate map keys. + // Keeps track of matched struct fields var foundFldIdx []bool { const maxStackFields = 128 @@ -2744,80 +2675,99 @@ func (d *decoder) parseMapToStruct(v reflect.Value, tInfo *typeInfo) error { //n } } - // Keep track of unmatched CBOR map keys to detect duplicate map keys. - var unmatchedMapKeys map[any]struct{} - - var err error + // Keeps track of CBOR map keys to detect duplicate map key + keyCount := 0 + var mapKeys map[any]struct{} - caseInsensitive := d.dm.fieldNameMatching == FieldNameMatchingPreferCaseSensitive + errOnUnknownField := (d.dm.extraReturnErrors & ExtraDecErrorUnknownField) > 0 - for i := 0; (hasSize && i < count) || (!hasSize && !d.foundBreak()); i++ { - t := d.nextCBORType() +MapEntryLoop: + for j := 0; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { + var f *field - // Reclassify disallowed byte string keys so they fall to the default case. - // keyType is only used for branch control. - keyType := t - if t == cborTypeByteString && d.dm.fieldNameByteString != FieldNameByteStringAllowed { - keyType = 0xff - } + // If duplicate field detection is enabled and the key at index j did not match any + // field, k will hold the map key. + var k any - switch keyType { - case cborTypeTextString, cborTypeByteString: + t := d.nextCBORType() + if t == cborTypeTextString || (t == cborTypeByteString && d.dm.fieldNameByteString == FieldNameByteStringAllowed) { var keyBytes []byte if t == cborTypeTextString { - var parseErr error - keyBytes, parseErr = d.parseTextString() - if parseErr != nil { + keyBytes, lastErr = d.parseTextString() + if lastErr != nil { if err == nil { - err = parseErr + err = lastErr } - d.skip() // Skip value + d.skip() // skip value continue } } else { // cborTypeByteString keyBytes, _ = d.parseByteString() } - // Find matching struct field (exact match, then case-insensitive fallback). - if fldIdx, ok := findStructFieldByKey(structType, keyBytes, caseInsensitive); ok { - fld := structType.fields[fldIdx] + // Check for exact match on field name. + if i, ok := structType.fieldIndicesByName[string(keyBytes)]; ok { + fld := structType.fields[i] - switch checkDupField(d.dm, foundFldIdx, fldIdx) { - case mapActionParseValueAndContinue: - if fieldErr := d.decodeToStructField(v, fld, tInfo); fieldErr != nil && err == nil { - err = fieldErr + if !foundFldIdx[i] { + f = fld + foundFldIdx[i] = true + } else if d.dm.dupMapKey == DupMapKeyEnforcedAPF { + err = &DupMapKeyError{fld.name, j} + d.skip() // skip value + j++ + // skip the rest of the map + for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { + d.skip() + d.skip() } - continue - case mapActionSkipAllAndReturnError: - return d.skipMapForDupKey(string(keyBytes), i, count, hasSize) - case mapActionSkipValueAndContinue: + return err + } else { + // discard repeated match d.skip() - continue + continue MapEntryLoop } } - // No matching struct field found. - if unmatchedErr := handleUnmatchedMapKey(d, string(keyBytes), i, count, hasSize, &unmatchedMapKeys); unmatchedErr != nil { - return unmatchedErr + // Find field with case-insensitive match + if f == nil && d.dm.fieldNameMatching == FieldNameMatchingPreferCaseSensitive { + keyLen := len(keyBytes) + keyString := string(keyBytes) + for i := 0; i < len(structType.fields); i++ { + fld := structType.fields[i] + if len(fld.name) == keyLen && strings.EqualFold(fld.name, keyString) { + if !foundFldIdx[i] { + f = fld + foundFldIdx[i] = true + } else if d.dm.dupMapKey == DupMapKeyEnforcedAPF { + err = &DupMapKeyError{keyString, j} + d.skip() // skip value + j++ + // skip the rest of the map + for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { + d.skip() + d.skip() + } + return err + } else { + // discard repeated match + d.skip() + continue MapEntryLoop + } + break + } + } } - case cborTypePositiveInt, cborTypeNegativeInt: + if d.dm.dupMapKey == DupMapKeyEnforcedAPF && f == nil { + k = string(keyBytes) + } + } else if t <= cborTypeNegativeInt { // uint/int var nameAsInt int64 if t == cborTypePositiveInt { _, _, val := d.getHead() - if val > math.MaxInt64 { - if err == nil { - err = &UnmarshalTypeError{ - CBORType: t.String(), - GoType: reflect.TypeOf(int64(0)).String(), - errorMsg: strconv.FormatUint(val, 10) + " overflows Go's int64", - } - } - d.skip() // skip value - continue - } - nameAsInt = int64(val) //nolint:gosec + nameAsInt = int64(val) } else { _, _, val := d.getHead() if val > math.MaxInt64 { @@ -2831,35 +2781,39 @@ func (d *decoder) parseMapToStruct(v reflect.Value, tInfo *typeInfo) error { //n d.skip() // skip value continue } - nameAsInt = int64(-1) ^ int64(val) //nolint:gosec - } - - // Find field by integer key - if fldIdx, ok := structType.fieldIndicesByIntKey[nameAsInt]; ok { - fld := structType.fields[fldIdx] - - switch checkDupField(d.dm, foundFldIdx, fldIdx) { - case mapActionParseValueAndContinue: - if fieldErr := d.decodeToStructField(v, fld, tInfo); fieldErr != nil && err == nil { - err = fieldErr + nameAsInt = int64(-1) ^ int64(val) + } + + // Find field + for i := 0; i < len(structType.fields); i++ { + fld := structType.fields[i] + if fld.keyAsInt && fld.nameAsInt == nameAsInt { + if !foundFldIdx[i] { + f = fld + foundFldIdx[i] = true + } else if d.dm.dupMapKey == DupMapKeyEnforcedAPF { + err = &DupMapKeyError{nameAsInt, j} + d.skip() // skip value + j++ + // skip the rest of the map + for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { + d.skip() + d.skip() + } + return err + } else { + // discard repeated match + d.skip() + continue MapEntryLoop } - continue - case mapActionSkipAllAndReturnError: - return d.skipMapForDupKey(nameAsInt, i, count, hasSize) - case mapActionSkipValueAndContinue: - d.skip() - continue + break } } - // No matching struct field found. - if unmatchedErr := handleUnmatchedMapKey(d, nameAsInt, i, count, hasSize, &unmatchedMapKeys); unmatchedErr != nil { - return unmatchedErr + if d.dm.dupMapKey == DupMapKeyEnforcedAPF && f == nil { + k = nameAsInt } - - default: - // CBOR map keys that can't be matched to any struct field. - + } else { if err == nil { err = &UnmarshalTypeError{ CBORType: t.String(), @@ -2867,31 +2821,97 @@ func (d *decoder) parseMapToStruct(v reflect.Value, tInfo *typeInfo) error { //n errorMsg: "map key is of type " + t.String() + " and cannot be used to match struct field name", } } - - var otherKey any if d.dm.dupMapKey == DupMapKeyEnforcedAPF { // parse key - var parseErr error - otherKey, parseErr = d.parse(true) - if parseErr != nil { + k, lastErr = d.parse(true) + if lastErr != nil { d.skip() // skip value continue } // Detect if CBOR map key can be used as Go map key. - if !isHashableValue(reflect.ValueOf(otherKey)) { + if !isHashableValue(reflect.ValueOf(k)) { d.skip() // skip value continue } } else { d.skip() // skip key } + } + + if f == nil { + if errOnUnknownField { + err = &UnknownFieldError{j} + d.skip() // Skip value + j++ + // skip the rest of the map + for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { + d.skip() + d.skip() + } + return err + } - if unmatchedErr := handleUnmatchedMapKey(d, otherKey, i, count, hasSize, &unmatchedMapKeys); unmatchedErr != nil { - return unmatchedErr + // Two map keys that match the same struct field are immediately considered + // duplicates. This check detects duplicates between two map keys that do + // not match a struct field. If unknown field errors are enabled, then this + // check is never reached. + if d.dm.dupMapKey == DupMapKeyEnforcedAPF { + if mapKeys == nil { + mapKeys = make(map[any]struct{}, 1) + } + mapKeys[k] = struct{}{} + newKeyCount := len(mapKeys) + if newKeyCount == keyCount { + err = &DupMapKeyError{k, j} + d.skip() // skip value + j++ + // skip the rest of the map + for ; (hasSize && j < count) || (!hasSize && !d.foundBreak()); j++ { + d.skip() + d.skip() + } + return err + } + keyCount = newKeyCount } + + d.skip() // Skip value + continue } - } + // Get field value by index + var fv reflect.Value + if len(f.idx) == 1 { + fv = v.Field(f.idx[0]) + } else { + fv, lastErr = getFieldValue(v, f.idx, func(v reflect.Value) (reflect.Value, error) { + // Return a new value for embedded field null pointer to point to, or return error. + if !v.CanSet() { + return reflect.Value{}, errors.New("cbor: cannot set embedded pointer to unexported struct: " + v.Type().String()) + } + v.Set(reflect.New(v.Type().Elem())) + return v, nil + }) + if lastErr != nil && err == nil { + err = lastErr + } + if !fv.IsValid() { + d.skip() + continue + } + } + + if lastErr = d.parseToValue(fv, f.typInfo); lastErr != nil { + if err == nil { + if typeError, ok := lastErr.(*UnmarshalTypeError); ok { + typeError.StructFieldName = tInfo.nonPtrType.String() + "." + f.name + err = typeError + } else { + err = lastErr + } + } + } + } return err } @@ -2938,15 +2958,15 @@ func (d *decoder) skip() { switch t { case cborTypeByteString, cborTypeTextString: - d.off += int(val) //nolint:gosec + d.off += int(val) case cborTypeArray: - for i := 0; i < int(val); i++ { //nolint:gosec + for i := 0; i < int(val); i++ { d.skip() } case cborTypeMap: - for i := 0; i < int(val)*2; i++ { //nolint:gosec + for i := 0; i < int(val)*2; i++ { d.skip() } diff --git a/vendor/github.com/fxamacker/cbor/v2/decode_map_utils.go b/vendor/github.com/fxamacker/cbor/v2/decode_map_utils.go deleted file mode 100644 index 3c8c423ad..000000000 --- a/vendor/github.com/fxamacker/cbor/v2/decode_map_utils.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Faye Amacker. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -package cbor - -import "strings" - -// mapAction represents the next action during decoding a CBOR map to a Go struct. -type mapAction int - -const ( - mapActionParseValueAndContinue mapAction = iota // The caller should process the map value. - mapActionSkipValueAndContinue // The caller should skip the map value. - mapActionSkipAllAndReturnError // The caller should skip the rest of the map and return an error. -) - -// checkDupField checks if a struct field at index i has already been matched and returns the next action. -// If not matched, it marks the field as matched and returns mapActionParseValueAndContinue. -// If matched and DupMapKeyEnforcedAPF is specified in the given dm, it returns mapActionSkipAllAndReturnError. -// If matched and DupMapKeyEnforcedAPF is not specified in the given dm, it returns mapActionSkipValueAndContinue. -func checkDupField(dm *decMode, foundFldIdx []bool, i int) mapAction { - if !foundFldIdx[i] { - foundFldIdx[i] = true - return mapActionParseValueAndContinue - } - if dm.dupMapKey == DupMapKeyEnforcedAPF { - return mapActionSkipAllAndReturnError - } - return mapActionSkipValueAndContinue -} - -// findStructFieldByKey finds a struct field matching keyBytes by name. -// It tries an exact match first. If no exact match is found and -// caseInsensitive is true, it falls back to a case-insensitive search. -// findStructFieldByKey returns the field index and true, or -1 and false. -func findStructFieldByKey( - structType *decodingStructType, - keyBytes []byte, - caseInsensitive bool, -) (int, bool) { - if fldIdx, ok := structType.fieldIndicesByName[string(keyBytes)]; ok { - return fldIdx, true - } - if caseInsensitive { - return findFieldCaseInsensitive(structType.fields, string(keyBytes)) - } - return -1, false -} - -// findFieldCaseInsensitive returns the index of the first field whose name -// case-insensitively matches key, or -1 and false if no field matches. -func findFieldCaseInsensitive(flds decodingFields, key string) (int, bool) { - keyLen := len(key) - for i, f := range flds { - if f.keyAsInt { - continue - } - if len(f.name) == keyLen && strings.EqualFold(f.name, key) { - return i, true - } - } - return -1, false -} - -// handleUnmatchedMapKey handles a map entry whose key does not match any struct -// field. It can return UnknownFieldError or DupMapKeyError. -// handleUnmatchedMapKey consumes the CBOR value, so the caller doesn't need to skip any values. -// If an error is returned, the caller should abort parsing the map and return the error. -// If no error is returned, the caller should continue to process the next map pair. -func handleUnmatchedMapKey( - d *decoder, - key any, - i int, - count int, - hasSize bool, - // *map[any]struct{} is used here because we use lazy initialization for uks - uks *map[any]struct{}, //nolint:gocritic -) error { - errOnUnknownField := (d.dm.extraReturnErrors & ExtraDecErrorUnknownField) > 0 - - if errOnUnknownField { - return d.skipMapForUnknownField(i, count, hasSize) - } - - if d.dm.dupMapKey == DupMapKeyEnforcedAPF { - if *uks == nil { - *uks = make(map[any]struct{}) - } - if _, dup := (*uks)[key]; dup { - return d.skipMapForDupKey(key, i, count, hasSize) - } - (*uks)[key] = struct{}{} - } - - // Skip value. - d.skip() - return nil -} diff --git a/vendor/github.com/fxamacker/cbor/v2/diagnose.go b/vendor/github.com/fxamacker/cbor/v2/diagnose.go index 42a67ad11..44afb8660 100644 --- a/vendor/github.com/fxamacker/cbor/v2/diagnose.go +++ b/vendor/github.com/fxamacker/cbor/v2/diagnose.go @@ -51,8 +51,11 @@ const ( maxByteStringEncoding ) -func (bse ByteStringEncoding) valid() bool { - return bse < maxByteStringEncoding +func (bse ByteStringEncoding) valid() error { + if bse >= maxByteStringEncoding { + return errors.New("cbor: invalid ByteStringEncoding " + strconv.Itoa(int(bse))) + } + return nil } // DiagOptions specifies Diag options. @@ -101,8 +104,8 @@ func (opts DiagOptions) DiagMode() (DiagMode, error) { } func (opts DiagOptions) diagMode() (*diagMode, error) { - if !opts.ByteStringEncoding.valid() { - return nil, errors.New("cbor: invalid ByteStringEncoding " + strconv.Itoa(int(opts.ByteStringEncoding))) + if err := opts.ByteStringEncoding.valid(); err != nil { + return nil, err } decMode, err := DecOptions{ @@ -357,7 +360,7 @@ func (di *diagnose) item() error { //nolint:gocyclo case cborTypeArray: _, _, val := di.d.getHead() - count := int(val) //nolint:gosec + count := int(val) di.w.WriteByte('[') for i := 0; i < count; i++ { @@ -373,7 +376,7 @@ func (di *diagnose) item() error { //nolint:gocyclo case cborTypeMap: _, _, val := di.d.getHead() - count := int(val) //nolint:gosec + count := int(val) di.w.WriteByte('{') for i := 0; i < count; i++ { @@ -474,8 +477,8 @@ func (di *diagnose) item() error { //nolint:gocyclo func (di *diagnose) writeU16(val rune) { di.w.WriteString("\\u") var in [2]byte - in[0] = byte(val >> 8) //nolint:gosec - in[1] = byte(val) //nolint:gosec + in[0] = byte(val >> 8) + in[1] = byte(val) sz := hex.EncodedLen(len(in)) di.w.Grow(sz) dst := di.w.Bytes()[di.w.Len() : di.w.Len()+sz] @@ -605,7 +608,7 @@ func (di *diagnose) encodeTextString(val string, quote byte) error { c, size := utf8.DecodeRuneInString(val[i:]) switch { - case c == utf8.RuneError && size == 1: + case c == utf8.RuneError: return &SemanticError{"cbor: invalid UTF-8 string"} case c < utf16SurrSelf: @@ -628,7 +631,7 @@ func (di *diagnose) encodeFloat(ai byte, val uint64) error { f64 := float64(0) switch ai { case additionalInformationAsFloat16: - f16 := float16.Frombits(uint16(val)) //nolint:gosec + f16 := float16.Frombits(uint16(val)) switch { case f16.IsNaN(): di.w.WriteString("NaN") @@ -644,7 +647,7 @@ func (di *diagnose) encodeFloat(ai byte, val uint64) error { } case additionalInformationAsFloat32: - f32 := math.Float32frombits(uint32(val)) //nolint:gosec + f32 := math.Float32frombits(uint32(val)) switch { case f32 != f32: di.w.WriteString("NaN") diff --git a/vendor/github.com/fxamacker/cbor/v2/encode.go b/vendor/github.com/fxamacker/cbor/v2/encode.go index e65a29d8a..c550617c3 100644 --- a/vendor/github.com/fxamacker/cbor/v2/encode.go +++ b/vendor/github.com/fxamacker/cbor/v2/encode.go @@ -30,7 +30,7 @@ import ( // If value implements the Marshaler interface, Marshal calls its // MarshalCBOR method. // -// If value implements encoding.BinaryMarshaler, Marshal calls its +// If value implements encoding.BinaryMarshaler, Marhsal calls its // MarshalBinary method and encode it as CBOR byte string. // // Boolean values encode as CBOR booleans (type 7). @@ -343,7 +343,7 @@ const ( // non-UTC timezone then a "localtime - UTC" numeric offset will be included as specified in RFC3339. // NOTE: User applications can avoid including the RFC3339 numeric offset by: // - providing a time.Time value set to UTC, or - // - using the TimeUnix, TimeUnixMicro, TimeUnixDynamic, or TimeRFC3339NanoUTC option. + // - using the TimeUnix, TimeUnixMicro, or TimeUnixDynamic option instead of TimeRFC3339. TimeRFC3339 // TimeRFC3339Nano causes time.Time to encode to a CBOR time (tag 0) with a text string content @@ -351,13 +351,9 @@ const ( // non-UTC timezone then a "localtime - UTC" numeric offset will be included as specified in RFC3339. // NOTE: User applications can avoid including the RFC3339 numeric offset by: // - providing a time.Time value set to UTC, or - // - using the TimeUnix, TimeUnixMicro, TimeUnixDynamic, or TimeRFC3339NanoUTC option. + // - using the TimeUnix, TimeUnixMicro, or TimeUnixDynamic option instead of TimeRFC3339Nano. TimeRFC3339Nano - // TimeRFC3339NanoUTC causes time.Time to encode to a CBOR time (tag 0) with a text string content - // representing UTC time using nanosecond precision in RFC3339 format. - TimeRFC3339NanoUTC - maxTimeMode ) @@ -440,7 +436,7 @@ const ( // FieldNameToTextString encodes struct fields to CBOR text string (major type 3). FieldNameToTextString FieldNameMode = iota - // FieldNameToByteString encodes struct fields to CBOR byte string (major type 2). + // FieldNameToTextString encodes struct fields to CBOR byte string (major type 2). FieldNameToByteString maxFieldNameMode @@ -571,7 +567,7 @@ type EncOptions struct { // RFC3339 format gets tag number 0, and numeric epoch time tag number 1. TimeTag EncTagMode - // IndefLength specifies whether to allow indefinite-length CBOR items. + // IndefLength specifies whether to allow indefinite length CBOR items. IndefLength IndefLengthMode // NilContainers specifies how to encode nil slices and maps. @@ -1136,11 +1132,10 @@ func encodeFloat(e *bytes.Buffer, em *encMode, v reflect.Value) error { if fopt == ShortestFloat16 { var f16 float16.Float16 p := float16.PrecisionFromfloat32(f32) - switch p { - case float16.PrecisionExact: + if p == float16.PrecisionExact { // Roundtrip float32->float16->float32 test isn't needed. f16 = float16.Fromfloat32(f32) - case float16.PrecisionUnknown: + } else if p == float16.PrecisionUnknown { // Try roundtrip float32->float16->float32 to determine if float32 can fit into float16. f16 = float16.Fromfloat32(f32) if f16.Float32() == f32 { @@ -1298,10 +1293,10 @@ func encodeByteString(e *bytes.Buffer, em *encMode, v reflect.Value) error { if slen == 0 { return e.WriteByte(byte(cborTypeByteString)) } - encodeHead(e, byte(cborTypeByteString), uint64(slen)) //nolint:gosec + encodeHead(e, byte(cborTypeByteString), uint64(slen)) if vk == reflect.Array { for i := 0; i < slen; i++ { - e.WriteByte(byte(v.Index(i).Uint())) //nolint:gosec + e.WriteByte(byte(v.Index(i).Uint())) } return nil } @@ -1338,7 +1333,7 @@ func (ae arrayEncodeFunc) encode(e *bytes.Buffer, em *encMode, v reflect.Value) if alen == 0 { return e.WriteByte(byte(cborTypeArray)) } - encodeHead(e, byte(cborTypeArray), uint64(alen)) //nolint:gosec + encodeHead(e, byte(cborTypeArray), uint64(alen)) for i := 0; i < alen; i++ { if err := ae.f(e, em, v.Index(i)); err != nil { return err @@ -1369,7 +1364,7 @@ func (me mapEncodeFunc) encode(e *bytes.Buffer, em *encMode, v reflect.Value) er return e.WriteByte(byte(cborTypeMap)) } - encodeHead(e, byte(cborTypeMap), uint64(mlen)) //nolint:gosec + encodeHead(e, byte(cborTypeMap), uint64(mlen)) if em.sort == SortNone || em.sort == SortFastShuffle || mlen <= 1 { return me.e(e, em, v, nil) } @@ -1432,7 +1427,7 @@ func (x *bytewiseKeyValueSorter) Swap(i, j int) { func (x *bytewiseKeyValueSorter) Less(i, j int) bool { kvi, kvj := x.kvs[i], x.kvs[j] - return bytes.Compare(x.data[kvi.offset:kvi.valueOffset], x.data[kvj.offset:kvj.valueOffset]) < 0 + return bytes.Compare(x.data[kvi.offset:kvi.valueOffset], x.data[kvj.offset:kvj.valueOffset]) <= 0 } type lengthFirstKeyValueSorter struct { @@ -1453,7 +1448,7 @@ func (x *lengthFirstKeyValueSorter) Less(i, j int) bool { if keyLengthDifference := (kvi.valueOffset - kvi.offset) - (kvj.valueOffset - kvj.offset); keyLengthDifference != 0 { return keyLengthDifference < 0 } - return bytes.Compare(x.data[kvi.offset:kvi.valueOffset], x.data[kvj.offset:kvj.valueOffset]) < 0 + return bytes.Compare(x.data[kvi.offset:kvi.valueOffset], x.data[kvj.offset:kvj.valueOffset]) <= 0 } var keyValuePool = sync.Pool{} @@ -1540,8 +1535,8 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { // Head is rewritten later if actual encoded field count is different from struct field count. encodedHeadLen := encodeHead(e, byte(cborTypeMap), uint64(len(flds))) - kvBeginOffset := e.Len() - kvCount := 0 + kvbegin := e.Len() + kvcount := 0 for offset := 0; offset < len(flds); offset++ { f := flds[(start+offset)%len(flds)] @@ -1587,10 +1582,10 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { return err } - kvCount++ + kvcount++ } - if len(flds) == kvCount { + if len(flds) == kvcount { // Encoded element count in head is the same as actual element count. return nil } @@ -1598,8 +1593,8 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { // Overwrite the bytes that were reserved for the head before encoding the map entries. var actualHeadLen int { - headbuf := *bytes.NewBuffer(e.Bytes()[kvBeginOffset-encodedHeadLen : kvBeginOffset-encodedHeadLen : kvBeginOffset]) - actualHeadLen = encodeHead(&headbuf, byte(cborTypeMap), uint64(kvCount)) + headbuf := *bytes.NewBuffer(e.Bytes()[kvbegin-encodedHeadLen : kvbegin-encodedHeadLen : kvbegin]) + actualHeadLen = encodeHead(&headbuf, byte(cborTypeMap), uint64(kvcount)) } if actualHeadLen == encodedHeadLen { @@ -1612,8 +1607,8 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { // encoded. The encoded entries are offset to the right by the number of excess reserved // bytes. Shift the entries left to remove the gap. excessReservedBytes := encodedHeadLen - actualHeadLen - dst := e.Bytes()[kvBeginOffset-excessReservedBytes : e.Len()-excessReservedBytes] - src := e.Bytes()[kvBeginOffset:e.Len()] + dst := e.Bytes()[kvbegin-excessReservedBytes : e.Len()-excessReservedBytes] + src := e.Bytes()[kvbegin:e.Len()] copy(dst, src) // After shifting, the excess bytes are at the end of the output buffer and they are @@ -1638,7 +1633,7 @@ func encodeTime(e *bytes.Buffer, em *encMode, v reflect.Value) error { } if em.timeTag == EncTagRequired { tagNumber := 1 - if em.time == TimeRFC3339 || em.time == TimeRFC3339Nano || em.time == TimeRFC3339NanoUTC { + if em.time == TimeRFC3339 || em.time == TimeRFC3339Nano { tagNumber = 0 } encodeHead(e, byte(cborTypeTag), uint64(tagNumber)) @@ -1655,7 +1650,7 @@ func encodeTime(e *bytes.Buffer, em *encMode, v reflect.Value) error { case TimeUnixDynamic: t = t.UTC().Round(time.Microsecond) - secs, nsecs := t.Unix(), uint64(t.Nanosecond()) //nolint:gosec + secs, nsecs := t.Unix(), uint64(t.Nanosecond()) if nsecs == 0 { return encodeInt(e, em, reflect.ValueOf(secs)) } @@ -1666,10 +1661,6 @@ func encodeTime(e *bytes.Buffer, em *encMode, v reflect.Value) error { s := t.Format(time.RFC3339) return encodeString(e, em, reflect.ValueOf(s)) - case TimeRFC3339NanoUTC: - s := t.UTC().Format(time.RFC3339Nano) - return encodeString(e, em, reflect.ValueOf(s)) - default: // TimeRFC3339Nano s := t.Format(time.RFC3339Nano) return encodeString(e, em, reflect.ValueOf(s)) diff --git a/vendor/github.com/fxamacker/cbor/v2/simplevalue.go b/vendor/github.com/fxamacker/cbor/v2/simplevalue.go index 9912e855c..30f72814f 100644 --- a/vendor/github.com/fxamacker/cbor/v2/simplevalue.go +++ b/vendor/github.com/fxamacker/cbor/v2/simplevalue.go @@ -93,6 +93,6 @@ func (sv *SimpleValue) unmarshalCBOR(data []byte) error { // It is safe to cast val to uint8 here because // - data is already verified to be well-formed CBOR simple value and // - val is <= math.MaxUint8. - *sv = SimpleValue(val) //nolint:gosec + *sv = SimpleValue(val) return nil } diff --git a/vendor/github.com/fxamacker/cbor/v2/stream.go b/vendor/github.com/fxamacker/cbor/v2/stream.go index da4c8f210..7ac6d7d67 100644 --- a/vendor/github.com/fxamacker/cbor/v2/stream.go +++ b/vendor/github.com/fxamacker/cbor/v2/stream.go @@ -171,20 +171,14 @@ func NewEncoder(w io.Writer) *Encoder { // Encode writes the CBOR encoding of v. func (enc *Encoder) Encode(v any) error { - if len(enc.indefTypes) > 0 { - switch enc.indefTypes[len(enc.indefTypes)-1] { - case cborTypeTextString: - if v == nil { - return errors.New("cbor: cannot encode nil for indefinite-length text string") - } + if len(enc.indefTypes) > 0 && v != nil { + indefType := enc.indefTypes[len(enc.indefTypes)-1] + if indefType == cborTypeTextString { k := reflect.TypeOf(v).Kind() if k != reflect.String { return errors.New("cbor: cannot encode item type " + k.String() + " for indefinite-length text string") } - case cborTypeByteString: - if v == nil { - return errors.New("cbor: cannot encode nil for indefinite-length byte string") - } + } else if indefType == cborTypeByteString { t := reflect.TypeOf(v) k := t.Kind() if (k != reflect.Array && k != reflect.Slice) || t.Elem().Kind() != reflect.Uint8 { @@ -204,35 +198,35 @@ func (enc *Encoder) Encode(v any) error { return err } -// StartIndefiniteByteString starts indefinite-length byte string encoding. +// StartIndefiniteByteString starts byte string encoding of indefinite length. // Subsequent calls of (*Encoder).Encode() encodes definite length byte strings // ("chunks") as one contiguous string until EndIndefinite is called. func (enc *Encoder) StartIndefiniteByteString() error { return enc.startIndefinite(cborTypeByteString) } -// StartIndefiniteTextString starts indefinite-length text string encoding. +// StartIndefiniteTextString starts text string encoding of indefinite length. // Subsequent calls of (*Encoder).Encode() encodes definite length text strings // ("chunks") as one contiguous string until EndIndefinite is called. func (enc *Encoder) StartIndefiniteTextString() error { return enc.startIndefinite(cborTypeTextString) } -// StartIndefiniteArray starts indefinite-length array encoding. +// StartIndefiniteArray starts array encoding of indefinite length. // Subsequent calls of (*Encoder).Encode() encodes elements of the array // until EndIndefinite is called. func (enc *Encoder) StartIndefiniteArray() error { return enc.startIndefinite(cborTypeArray) } -// StartIndefiniteMap starts indefinite-length map encoding. +// StartIndefiniteMap starts array encoding of indefinite length. // Subsequent calls of (*Encoder).Encode() encodes elements of the map // until EndIndefinite is called. func (enc *Encoder) StartIndefiniteMap() error { return enc.startIndefinite(cborTypeMap) } -// EndIndefinite closes last opened indefinite-length value. +// EndIndefinite closes last opened indefinite length value. func (enc *Encoder) EndIndefinite() error { if len(enc.indefTypes) == 0 { return errors.New("cbor: cannot encode \"break\" code outside indefinite length values") @@ -244,22 +238,18 @@ func (enc *Encoder) EndIndefinite() error { return err } +var cborIndefHeader = map[cborType][]byte{ + cborTypeByteString: {cborByteStringWithIndefiniteLengthHead}, + cborTypeTextString: {cborTextStringWithIndefiniteLengthHead}, + cborTypeArray: {cborArrayWithIndefiniteLengthHead}, + cborTypeMap: {cborMapWithIndefiniteLengthHead}, +} + func (enc *Encoder) startIndefinite(typ cborType) error { if enc.em.indefLength == IndefLengthForbidden { return &IndefiniteLengthError{typ} } - var head byte - switch typ { - case cborTypeByteString: - head = cborByteStringWithIndefiniteLengthHead - case cborTypeTextString: - head = cborTextStringWithIndefiniteLengthHead - case cborTypeArray: - head = cborArrayWithIndefiniteLengthHead - case cborTypeMap: - head = cborMapWithIndefiniteLengthHead - } - _, err := enc.w.Write([]byte{head}) + _, err := enc.w.Write(cborIndefHeader[typ]) if err == nil { enc.indefTypes = append(enc.indefTypes, typ) } @@ -272,9 +262,7 @@ type RawMessage []byte // MarshalCBOR returns m or CBOR nil if m is nil. func (m RawMessage) MarshalCBOR() ([]byte, error) { if len(m) == 0 { - b := make([]byte, len(cborNil)) - copy(b, cborNil) - return b, nil + return cborNil, nil } return m, nil } diff --git a/vendor/github.com/fxamacker/cbor/v2/structfields.go b/vendor/github.com/fxamacker/cbor/v2/structfields.go index b2d71f2e9..cf0a922cd 100644 --- a/vendor/github.com/fxamacker/cbor/v2/structfields.go +++ b/vendor/github.com/fxamacker/cbor/v2/structfields.go @@ -6,43 +6,27 @@ package cbor import ( "reflect" "sort" - "strconv" "strings" ) -// field holds shared struct field metadata returned by getFields(). type field struct { - name string - nameAsInt int64 // used to match field name with CBOR int - idx []int - typ reflect.Type // used during cache building only - keyAsInt bool // used to encode/decode field name as int - tagged bool // used to choose dominant field (at the same level tagged fields dominate untagged fields) - omitEmpty bool // used to skip empty field - omitZero bool // used to skip zero field -} - -type fields []*field - -// encodingField extends field with encoding-specific data. -type encodingField struct { - field + name string + nameAsInt int64 // used to decoder to match field name with CBOR int cborName []byte - cborNameByteString []byte // major type 2 name encoding if cborName has major type 3 + cborNameByteString []byte // major type 2 name encoding iff cborName has major type 3 + idx []int + typ reflect.Type ef encodeFunc ief isEmptyFunc izf isZeroFunc + typInfo *typeInfo // used to decoder to reuse type info + tagged bool // used to choose dominant field (at the same level tagged fields dominate untagged fields) + omitEmpty bool // used to skip empty field + omitZero bool // used to skip zero field + keyAsInt bool // used to encode/decode field name as int } -type encodingFields []*encodingField - -// decodingField extends field with decoding-specific data. -type decodingField struct { - field - typInfo *typeInfo // used by decoder to reuse type info -} - -type decodingFields []*decodingField +type fields []*field // indexFieldSorter sorts fields by field idx at each level, breaking ties with idx depth. type indexFieldSorter struct { @@ -64,7 +48,7 @@ func (x *indexFieldSorter) Less(i, j int) bool { return iIdx[k] < jIdx[k] } } - return len(iIdx) < len(jIdx) + return len(iIdx) <= len(jIdx) } // nameLevelAndTagFieldSorter sorts fields by field name, idx depth, and presence of tag. @@ -85,10 +69,6 @@ func (x *nameLevelAndTagFieldSorter) Less(i, j int) bool { if fi.name != fj.name { return fi.name < fj.name } - // Fields with the same name but different keyAsInt are in separate namespaces. - if fi.keyAsInt != fj.keyAsInt { - return fi.keyAsInt - } if len(fi.idx) != len(fj.idx) { return len(fi.idx) < len(fj.idx) } @@ -137,37 +117,22 @@ func getFields(t reflect.Type) (flds fields, structOptions string) { } } - // Normalize keyasint field names to their canonical integer string form. - // This ensures that "01", "+1", and "1" are treated as the same key - // during deduplication. - for _, f := range flds { - if f.keyAsInt { - nameAsInt, err := strconv.Atoi(f.name) - if err != nil { - continue // Leave invalid names for callers to report. - } - f.nameAsInt = int64(nameAsInt) - f.name = strconv.Itoa(nameAsInt) - } - } - sort.Sort(&nameLevelAndTagFieldSorter{flds}) // Keep visible fields. j := 0 // index of next unique field for i := 0; i < len(flds); { name := flds[i].name - keyAsInt := flds[i].keyAsInt if i == len(flds)-1 || // last field - name != flds[i+1].name || flds[i+1].keyAsInt != keyAsInt || // field i has unique (name, keyAsInt) + name != flds[i+1].name || // field i has unique field name len(flds[i].idx) < len(flds[i+1].idx) || // field i is at a less nested level than field i+1 (flds[i].tagged && !flds[i+1].tagged) { // field i is tagged while field i+1 is not flds[j] = flds[i] j++ } - // Skip fields with the same (name, keyAsInt). - for i++; i < len(flds) && name == flds[i].name && keyAsInt == flds[i].keyAsInt; i++ { //nolint:revive + // Skip fields with the same field name. + for i++; i < len(flds) && name == flds[i].name; i++ { //nolint:revive } } if j != len(flds) { diff --git a/vendor/github.com/fxamacker/cbor/v2/valid.go b/vendor/github.com/fxamacker/cbor/v2/valid.go index 850b95019..b40793b95 100644 --- a/vendor/github.com/fxamacker/cbor/v2/valid.go +++ b/vendor/github.com/fxamacker/cbor/v2/valid.go @@ -54,7 +54,7 @@ func (e *MaxMapPairsError) Error() string { return "cbor: exceeded max number of key-value pairs " + strconv.Itoa(e.maxMapPairs) + " for CBOR map" } -// IndefiniteLengthError indicates found disallowed indefinite-length items. +// IndefiniteLengthError indicates found disallowed indefinite length items. type IndefiniteLengthError struct { t cborType } @@ -113,7 +113,7 @@ func (d *decoder) wellformedInternal(depth int, checkBuiltinTags bool) (int, err } return d.wellformedIndefiniteString(t, depth, checkBuiltinTags) } - valInt := int(val) //nolint:gosec + valInt := int(val) if valInt < 0 { // Detect integer overflow return 0, errors.New("cbor: " + t.String() + " length " + strconv.FormatUint(val, 10) + " is too large, causing integer overflow") @@ -136,7 +136,7 @@ func (d *decoder) wellformedInternal(depth int, checkBuiltinTags bool) (int, err return d.wellformedIndefiniteArrayOrMap(t, depth, checkBuiltinTags) } - valInt := int(val) //nolint:gosec + valInt := int(val) if valInt < 0 { // Detect integer overflow return 0, errors.New("cbor: " + t.String() + " length " + strconv.FormatUint(val, 10) + " is too large, it would cause integer overflow") @@ -212,7 +212,7 @@ func (d *decoder) wellformedInternal(depth int, checkBuiltinTags bool) (int, err return depth, nil } -// wellformedIndefiniteString checks indefinite-length byte/text string's well-formedness and returns max depth and error. +// wellformedIndefiniteString checks indefinite length byte/text string's well-formedness and returns max depth and error. func (d *decoder) wellformedIndefiniteString(t cborType, depth int, checkBuiltinTags bool) (int, error) { var err error for { @@ -223,7 +223,7 @@ func (d *decoder) wellformedIndefiniteString(t cborType, depth int, checkBuiltin d.off++ break } - // Peek ahead to get next type and indefinite-length status. + // Peek ahead to get next type and indefinite length status. nt, ai := parseInitialByte(d.data[d.off]) if t != nt { return 0, &SyntaxError{"cbor: wrong element type " + nt.String() + " for indefinite-length " + t.String()} @@ -238,7 +238,7 @@ func (d *decoder) wellformedIndefiniteString(t cborType, depth int, checkBuiltin return depth, nil } -// wellformedIndefiniteArrayOrMap checks indefinite-length array/map's well-formedness and returns max depth and error. +// wellformedIndefiniteArrayOrMap checks indefinite length array/map's well-formedness and returns max depth and error. func (d *decoder) wellformedIndefiniteArrayOrMap(t cborType, depth int, checkBuiltinTags bool) (int, error) { var err error maxDepth := depth @@ -326,7 +326,7 @@ func (d *decoder) wellformedHead() (t cborType, ai byte, val uint64, err error) val = uint64(binary.BigEndian.Uint16(d.data[d.off : d.off+argumentSize])) d.off += argumentSize if t == cborTypePrimitives { - if err := d.acceptableFloat(float64(float16.Frombits(uint16(val)).Float32())); err != nil { //nolint:gosec + if err := d.acceptableFloat(float64(float16.Frombits(uint16(val)).Float32())); err != nil { return 0, 0, 0, err } } @@ -341,7 +341,7 @@ func (d *decoder) wellformedHead() (t cborType, ai byte, val uint64, err error) val = uint64(binary.BigEndian.Uint32(d.data[d.off : d.off+argumentSize])) d.off += argumentSize if t == cborTypePrimitives { - if err := d.acceptableFloat(float64(math.Float32frombits(uint32(val)))); err != nil { //nolint:gosec + if err := d.acceptableFloat(float64(math.Float32frombits(uint32(val)))); err != nil { return 0, 0, 0, err } } @@ -379,12 +379,12 @@ func (d *decoder) wellformedHead() (t cborType, ai byte, val uint64, err error) func (d *decoder) acceptableFloat(f float64) error { switch { - case d.dm.nan == NaNDecodeForbidden && math.IsNaN(f): + case d.dm.nanDec == NaNDecodeForbidden && math.IsNaN(f): return &UnacceptableDataItemError{ CBORType: cborTypePrimitives.String(), Message: "floating-point NaN", } - case d.dm.inf == InfDecodeForbidden && math.IsInf(f, 0): + case d.dm.infDec == InfDecodeForbidden && math.IsInf(f, 0): return &UnacceptableDataItemError{ CBORType: cborTypePrimitives.String(), Message: "floating-point infinity", diff --git a/vendor/github.com/go-openapi/jsonpointer/.cliff.toml b/vendor/github.com/go-openapi/jsonpointer/.cliff.toml new file mode 100644 index 000000000..702629f5d --- /dev/null +++ b/vendor/github.com/go-openapi/jsonpointer/.cliff.toml @@ -0,0 +1,181 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +header = """ +""" + +footer = """ + +----- + +**[{{ remote.github.repo }}]({{ self::remote_url() }}) license terms** + +[![License][license-badge]][license-url] + +[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg +[license-url]: {{ self::remote_url() }}/?tab=Apache-2.0-1-ov-file#readme + +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +""" + +body = """ +{%- if version %} +## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/tree/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} +{%- else %} +## [unreleased] +{%- endif %} +{%- if message %} + {%- raw %}\n{% endraw %} +{{ message }} + {%- raw %}\n{% endraw %} +{%- endif %} +{%- if version %} + {%- if previous.version %} + +**Full Changelog**: <{{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}> + {%- endif %} +{%- else %} + {%- raw %}\n{% endraw %} +{%- endif %} + +{%- if statistics %}{% if statistics.commit_count %} + {%- raw %}\n{% endraw %} +{{ statistics.commit_count }} commits in this release. + {%- raw %}\n{% endraw %} +{%- endif %}{% endif %} +----- + +{%- for group, commits in commits | group_by(attribute="group") %} + {%- raw %}\n{% endraw %} +### {{ group | upper_first }} + {%- raw %}\n{% endraw %} + {%- for commit in commits %} + {%- if commit.remote.pr_title %} + {%- set commit_message = commit.remote.pr_title %} + {%- else %} + {%- set commit_message = commit.message %} + {%- endif %} +* {{ commit_message | split(pat="\n") | first | trim }} + {%- if commit.remote.username %} +{%- raw %} {% endraw %}by [@{{ commit.remote.username }}](https://github.com/{{ commit.remote.username }}) + {%- endif %} + {%- if commit.remote.pr_number %} +{%- raw %} {% endraw %}in [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) + {%- endif %} +{%- raw %} {% endraw %}[...]({{ self::remote_url() }}/commit/{{ commit.id }}) + {%- endfor %} +{%- endfor %} + +{%- if github %} +{%- raw %}\n{% endraw -%} + {%- set all_contributors = github.contributors | length %} + {%- if github.contributors | filter(attribute="username", value="dependabot[bot]") | length < all_contributors %} +----- + +### People who contributed to this release + {% endif %} + {%- for contributor in github.contributors | filter(attribute="username") | sort(attribute="username") %} + {%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %} +* [@{{ contributor.username }}](https://github.com/{{ contributor.username }}) + {%- endif %} + {%- endfor %} + + {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} +----- + {%- raw %}\n{% endraw %} + +### New Contributors + {%- endif %} + + {%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} + {%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %} +* @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} + in [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} + {%- endif %} + {%- endfor %} +{%- endif %} + +{%- raw %}\n{% endraw %} + +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +""" +# Remove leading and trailing whitespaces from the changelog's body. +trim = true +# Render body even when there are no releases to process. +render_always = true +# An array of regex based postprocessors to modify the changelog. +postprocessors = [ + # Replace the placeholder with a URL. + #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, +] +# output file path +# output = "test.md" + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = false +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = false +# Require all commits to be conventional. +# Takes precedence over filter_unconventional. +require_conventional = false +# Split commits on newlines, treating each line as an individual commit. +split_commits = false +# An array of regex based parsers to modify commit messages prior to further processing. +commit_preprocessors = [ + # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit message using https://github.com/crate-ci/typos. + # If the spelling is incorrect, it will be fixed automatically. + #{ pattern = '.*', replace_command = 'typos --write-changes -' } +] +# Prevent commits that are breaking from being excluded by commit parsers. +protect_breaking_commits = false +# An array of regex based parsers for extracting data from the commit message. +# Assigns commits to groups. +# Optionally sets the commit's scope and can decide to exclude commits from further processing. +commit_parsers = [ + { message = "^[Cc]hore\\([Rr]elease\\): prepare for", skip = true }, + { message = "(^[Mm]erge)|([Mm]erge conflict)", skip = true }, + { field = "author.name", pattern = "dependabot*", group = "Updates" }, + { message = "([Ss]ecurity)|([Vv]uln)", group = "Security" }, + { body = "(.*[Ss]ecurity)|([Vv]uln)", group = "Security" }, + { message = "([Cc]hore\\(lint\\))|(style)|(lint)|(codeql)|(golangci)", group = "Code quality" }, + { message = "(^[Dd]oc)|((?i)readme)|(badge)|(typo)|(documentation)", group = "Documentation" }, + { message = "(^[Ff]eat)|(^[Ee]nhancement)", group = "Implemented enhancements" }, + { message = "(^ci)|(\\(ci\\))|(fixup\\s+ci)|(fix\\s+ci)|(license)|(example)", group = "Miscellaneous tasks" }, + { message = "^test", group = "Testing" }, + { message = "(^fix)|(panic)", group = "Fixed bugs" }, + { message = "(^refact)|(rework)", group = "Refactor" }, + { message = "(^[Pp]erf)|(performance)", group = "Performance" }, + { message = "(^[Cc]hore)", group = "Miscellaneous tasks" }, + { message = "^[Rr]evert", group = "Reverted changes" }, + { message = "(upgrade.*?go)|(go\\s+version)", group = "Updates" }, + { message = ".*", group = "Other" }, +] +# Exclude commits that are not matched by any commit parser. +filter_commits = false +# An array of link parsers for extracting external references, and turning them into URLs, using regex. +link_parsers = [] +# Include only the tags that belong to the current branch. +use_branch_tags = false +# Order releases topologically instead of chronologically. +topo_order = false +# Order releases topologically instead of chronologically. +topo_order_commits = true +# Order of commits in each group/release within the changelog. +# Allowed values: newest, oldest +sort_commits = "newest" +# Process submodules commits +recurse_submodules = false + +#[remote.github] +#owner = "go-openapi" diff --git a/vendor/github.com/go-openapi/jsonpointer/.gitignore b/vendor/github.com/go-openapi/jsonpointer/.gitignore index d8f4186fe..59cd29489 100644 --- a/vendor/github.com/go-openapi/jsonpointer/.gitignore +++ b/vendor/github.com/go-openapi/jsonpointer/.gitignore @@ -2,4 +2,3 @@ *.cov .idea .env -.mcp.json diff --git a/vendor/github.com/go-openapi/jsonpointer/.golangci.yml b/vendor/github.com/go-openapi/jsonpointer/.golangci.yml index dc7c96053..fdae591bc 100644 --- a/vendor/github.com/go-openapi/jsonpointer/.golangci.yml +++ b/vendor/github.com/go-openapi/jsonpointer/.golangci.yml @@ -12,7 +12,6 @@ linters: - paralleltest - recvcheck - testpackage - - thelper - tparallel - varnamelen - whitespace diff --git a/vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md b/vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md index bac878f21..9322b065e 100644 --- a/vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md +++ b/vendor/github.com/go-openapi/jsonpointer/CODE_OF_CONDUCT.md @@ -23,9 +23,7 @@ include: Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or - advances - * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic @@ -57,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at . All +reported by contacting the project team at ivan+abuse@flanders.co.nz. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. @@ -70,7 +68,7 @@ members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [][version] +available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/vendor/github.com/go-openapi/jsonpointer/CONTRIBUTORS.md b/vendor/github.com/go-openapi/jsonpointer/CONTRIBUTORS.md index 9990f4a35..03c098316 100644 --- a/vendor/github.com/go-openapi/jsonpointer/CONTRIBUTORS.md +++ b/vendor/github.com/go-openapi/jsonpointer/CONTRIBUTORS.md @@ -4,22 +4,21 @@ | Total Contributors | Total Contributions | | --- | --- | -| 13 | 111 | +| 12 | 95 | | Username | All Time Contribution Count | All Commits | | --- | --- | --- | -| @fredbi | 63 | | -| @casualjim | 33 | | -| @magodo | 3 | | -| @youyuanwu | 3 | | -| @gaiaz-iusipov | 1 | | -| @gbjk | 1 | | -| @gordallott | 1 | | -| @ianlancetaylor | 1 | | -| @mfleader | 1 | | -| @Neo2308 | 1 | | -| @alexandear | 1 | | -| @olivierlemasle | 1 | | -| @testwill | 1 | | +| @fredbi | 48 | https://github.com/go-openapi/jsonpointer/commits?author=fredbi | +| @casualjim | 33 | https://github.com/go-openapi/jsonpointer/commits?author=casualjim | +| @magodo | 3 | https://github.com/go-openapi/jsonpointer/commits?author=magodo | +| @youyuanwu | 3 | https://github.com/go-openapi/jsonpointer/commits?author=youyuanwu | +| @gaiaz-iusipov | 1 | https://github.com/go-openapi/jsonpointer/commits?author=gaiaz-iusipov | +| @gbjk | 1 | https://github.com/go-openapi/jsonpointer/commits?author=gbjk | +| @gordallott | 1 | https://github.com/go-openapi/jsonpointer/commits?author=gordallott | +| @ianlancetaylor | 1 | https://github.com/go-openapi/jsonpointer/commits?author=ianlancetaylor | +| @mfleader | 1 | https://github.com/go-openapi/jsonpointer/commits?author=mfleader | +| @Neo2308 | 1 | https://github.com/go-openapi/jsonpointer/commits?author=Neo2308 | +| @olivierlemasle | 1 | https://github.com/go-openapi/jsonpointer/commits?author=olivierlemasle | +| @testwill | 1 | https://github.com/go-openapi/jsonpointer/commits?author=testwill | - _this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_ + _this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_ diff --git a/vendor/github.com/go-openapi/jsonpointer/NOTICE b/vendor/github.com/go-openapi/jsonpointer/NOTICE index 201908d2f..f3b51939a 100644 --- a/vendor/github.com/go-openapi/jsonpointer/NOTICE +++ b/vendor/github.com/go-openapi/jsonpointer/NOTICE @@ -18,7 +18,7 @@ It ships with copies of other software which license terms are recalled below. The original software was authored on 25-02-2013 by sigu-399 (https://github.com/sigu-399, sigu.399@gmail.com). -github.com/sigu-399/jsonpointer +github.com/sigh-399/jsonpointer =========================== // SPDX-FileCopyrightText: Copyright 2013 sigu-399 ( https://github.com/sigu-399 ) diff --git a/vendor/github.com/go-openapi/jsonpointer/README.md b/vendor/github.com/go-openapi/jsonpointer/README.md index 24fbe1bf6..b61b63fd9 100644 --- a/vendor/github.com/go-openapi/jsonpointer/README.md +++ b/vendor/github.com/go-openapi/jsonpointer/README.md @@ -8,33 +8,15 @@ [![Release][release-badge]][release-url] [![Go Report Card][gocard-badge]][gocard-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url] -[![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] +[![GoDoc][godoc-badge]][godoc-url] [![Slack Channel][slack-logo]![slack-badge]][slack-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] --- An implementation of JSON Pointer for golang, which supports go `struct`. -## Announcements - -* **2026-04-15** : added support for trailing "-" for arrays (v0.23.0) - * this brings full support of [RFC6901][RFC6901] - * this is supported for types relying on the reflection-based implemented - * API semantics remain essentially unaltered. Exception: `Pointer.Set(document any,value any) (document any, err error)` - can only perform a best-effort to mutate the input document in place. In the case of adding elements to an array with a - trailing "-", either pass a mutable array (`*[]T`) as the input document, or use the returned updated document instead. - * types that implement the `JSONSetable` interface may not implement the mutation implied by the trailing "-" - -* **2026-04-15** : added support for optional alternate JSON name providers - * for struct support the defaults might not suit all situations: there are known limitations - when it comes to handle untagged fields or embedded types. - * the default name provider in use is not fully aligned with go JSON stdlib - * exposed an option (or global setting) to change the provider that resolves a struct into json keys - * the default behavior is not altered - * a new alternate name provider is added (imported from `go-openapi/swag/jsonname`), aligned with JSON stdlib behavior - ## Status -API is stable and feature-complete. +API is stable. ## Import this library in your project @@ -96,7 +78,7 @@ See -also known as [RFC6901][RFC6901]. +also known as [RFC6901](https://www.rfc-editor.org/rfc/rfc6901) ## Licensing @@ -107,19 +89,19 @@ on top of which it has been built. ## Limitations -* [RFC6901][RFC6901] is now fully supported, including trailing "-" semantics for arrays (for `Set` operations). -* Default behavior: JSON name detection in go `struct`s - - Unlike go standard marshaling, untagged fields do not default to the go field name and are ignored. - - anonymous fields are not traversed if untagged - - the above limitations may be overcome by calling `UseGoNameProvider()` at initialization time. - - alternatively, users may inject the desired custom behavior for naming fields as an option. +The 4.Evaluation part of the previous reference, starting with 'If the currently referenced value is a JSON array, +the reference token MUST contain either...' is not implemented. + +That is because our implementation of the JSON pointer only supports explicit references to array elements: +the provision in the spec to resolve non-existent members as "the last element in the array", +using the special trailing character "-" is not implemented. ## Other documentation * [All-time contributors](./CONTRIBUTORS.md) -* [Contributing guidelines][contributing-doc-site] -* [Maintainers documentation][maintainers-doc-site] -* [Code style][style-doc-site] +* [Contributing guidelines](.github/CONTRIBUTING.md) +* [Maintainers documentation](docs/MAINTAINERS.md) +* [Code style](docs/STYLE.md) ## Cutting a new release @@ -142,17 +124,21 @@ Maintainers can cut a new release by either: [release-badge]: https://badge.fury.io/gh/go-openapi%2Fjsonpointer.svg [release-url]: https://badge.fury.io/gh/go-openapi%2Fjsonpointer +[gomod-badge]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fjsonpointer.svg +[gomod-url]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fjsonpointer [gocard-badge]: https://goreportcard.com/badge/github.com/go-openapi/jsonpointer [gocard-url]: https://goreportcard.com/report/github.com/go-openapi/jsonpointer [codefactor-badge]: https://img.shields.io/codefactor/grade/github/go-openapi/jsonpointer [codefactor-url]: https://www.codefactor.io/repository/github/go-openapi/jsonpointer +[doc-badge]: https://img.shields.io/badge/doc-site-blue?link=https%3A%2F%2Fgoswagger.io%2Fgo-openapi%2F +[doc-url]: https://goswagger.io/go-openapi [godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/jsonpointer [godoc-url]: http://pkg.go.dev/github.com/go-openapi/jsonpointer -[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue -[discord-url]: https://discord.gg/FfnFYaC3k5 - +[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png +[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM +[slack-url]: https://goswagger.slack.com/archives/C04R30YMU [license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg [license-url]: https://github.com/go-openapi/jsonpointer/?tab=Apache-2.0-1-ov-file#readme @@ -161,8 +147,3 @@ Maintainers can cut a new release by either: [goversion-url]: https://github.com/go-openapi/jsonpointer/blob/master/go.mod [top-badge]: https://img.shields.io/github/languages/top/go-openapi/jsonpointer [commits-badge]: https://img.shields.io/github/commits-since/go-openapi/jsonpointer/latest -[RFC6901]: https://www.rfc-editor.org/rfc/rfc6901 - -[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html -[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html -[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html diff --git a/vendor/github.com/go-openapi/jsonpointer/SECURITY.md b/vendor/github.com/go-openapi/jsonpointer/SECURITY.md index 1fea2c573..2a7b6f091 100644 --- a/vendor/github.com/go-openapi/jsonpointer/SECURITY.md +++ b/vendor/github.com/go-openapi/jsonpointer/SECURITY.md @@ -6,32 +6,14 @@ This policy outlines the commitment and practices of the go-openapi maintainers | Version | Supported | | ------- | ------------------ | -| O.x | :white_check_mark: | - -## Vulnerability checks in place - -This repository uses automated vulnerability scans, at every merged commit and at least once a week. - -We use: - -* [`GitHub CodeQL`][codeql-url] -* [`trivy`][trivy-url] -* [`govulncheck`][govulncheck-url] - -Reports are centralized in github security reports and visible only to the maintainers. +| 0.22.x | :white_check_mark: | ## Reporting a vulnerability If you become aware of a security vulnerability that affects the current repository, -**please report it privately to the maintainers** -rather than opening a publicly visible GitHub issue. - -Please follow the instructions provided by github to [Privately report a security vulnerability][github-guidance-url]. +please report it privately to the maintainers. -> [!NOTE] -> On Github, navigate to the project's "Security" tab then click on "Report a vulnerability". +Please follow the instructions provided by github to +[Privately report a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability). -[codeql-url]: https://github.com/github/codeql -[trivy-url]: https://trivy.dev/docs/latest/getting-started -[govulncheck-url]: https://go.dev/blog/govulncheck -[github-guidance-url]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability +TL;DR: on Github, navigate to the project's "Security" tab then click on "Report a vulnerability". diff --git a/vendor/github.com/go-openapi/jsonpointer/errors.go b/vendor/github.com/go-openapi/jsonpointer/errors.go index 8813474d4..8c50dde8b 100644 --- a/vendor/github.com/go-openapi/jsonpointer/errors.go +++ b/vendor/github.com/go-openapi/jsonpointer/errors.go @@ -16,24 +16,12 @@ const ( ErrPointer pointerError = "JSON pointer error" // ErrInvalidStart states that a JSON pointer must start with a separator ("/"). - ErrInvalidStart pointerError = `JSON pointer must be empty or start with a "` + pointerSeparator + `"` + ErrInvalidStart pointerError = `JSON pointer must be empty or start with a "` + pointerSeparator // ErrUnsupportedValueType indicates that a value of the wrong type is being set. ErrUnsupportedValueType pointerError = "only structs, pointers, maps and slices are supported for setting values" - - // ErrDashToken indicates use of the RFC 6901 "-" reference token - // in a context where it cannot be resolved. - // - // Per RFC 6901 §4 the "-" token refers to the (nonexistent) element - // after the last array element. It may only be used as the terminal - // token of a [Pointer.Set] against a slice, where it means "append". - // Any other use (get, offset, intermediate traversal, non-slice target) - // is an error condition that wraps this sentinel. - ErrDashToken pointerError = `the "-" array token cannot be resolved here` //nolint:gosec // G101 false positive: this is a JSON Pointer reference token, not a credential. ) -const dashToken = "-" - func errNoKey(key string) error { return fmt.Errorf("object has no key %q: %w", key, ErrPointer) } @@ -45,15 +33,3 @@ func errOutOfBounds(length, idx int) error { func errInvalidReference(token string) error { return fmt.Errorf("invalid token reference %q: %w", token, ErrPointer) } - -func errDashOnGet() error { - return fmt.Errorf("cannot resolve %q token on get: %w: %w", dashToken, ErrDashToken, ErrPointer) -} - -func errDashIntermediate() error { - return fmt.Errorf("the %q token may only appear as the terminal token of a pointer: %w: %w", dashToken, ErrDashToken, ErrPointer) -} - -func errDashOnOffset() error { - return fmt.Errorf("cannot compute offset for %q token (nonexistent element): %w: %w", dashToken, ErrDashToken, ErrPointer) -} diff --git a/vendor/github.com/go-openapi/jsonpointer/ifaces.go b/vendor/github.com/go-openapi/jsonpointer/ifaces.go deleted file mode 100644 index 1e56ac044..000000000 --- a/vendor/github.com/go-openapi/jsonpointer/ifaces.go +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package jsonpointer - -import "reflect" - -// JSONPointable is an interface for structs to implement, -// when they need to customize the json pointer process or want to avoid the use of reflection. -type JSONPointable interface { - // JSONLookup returns a value pointed at this (unescaped) key. - JSONLookup(key string) (any, error) -} - -// JSONSetable is an interface for structs to implement, -// when they need to customize the json pointer process or want to avoid the use of reflection. -// -// # Handling of the RFC 6901 "-" token -// -// When a type implementing JSONSetable is the terminal parent of a [Pointer.Set] -// call, the library passes the raw reference token to JSONSet without -// interpretation. In particular, the RFC 6901 "-" token (which conventionally -// means "append" for arrays, per RFC 6902) is forwarded verbatim as the key -// argument. Implementations that model an array-like container are expected -// to give "-" the append semantics; implementations that do not should return -// an error wrapping [ErrDashToken] (or [ErrPointer]) for clarity. -// -// Implementations are responsible for any in-place mutation: the library does -// not attempt to rebind the result of JSONSet into a parent container. -type JSONSetable interface { - // JSONSet sets the value pointed at the (unescaped) key. - // - // The key may be the RFC 6901 "-" token when the pointer targets a - // slice-like member; see the interface documentation for details. - JSONSet(key string, value any) error -} - -// NameProvider knows how to resolve go struct fields into json names. -// -// The default provider is brought by [github.com/go-openapi/swag/jsonname.DefaultJSONNameProvider]. -type NameProvider interface { - // GetGoName gets the go name for a json property name - GetGoName(subject any, name string) (string, bool) - - // GetGoNameForType gets the go name for a given type for a json property name - GetGoNameForType(tpe reflect.Type, name string) (string, bool) -} diff --git a/vendor/github.com/go-openapi/jsonpointer/options.go b/vendor/github.com/go-openapi/jsonpointer/options.go deleted file mode 100644 index d52caab22..000000000 --- a/vendor/github.com/go-openapi/jsonpointer/options.go +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package jsonpointer - -import ( - "sync" - - "github.com/go-openapi/swag/jsonname" -) - -// Option to tune the behavior of a JSON [Pointer]. -type Option func(*options) - -var ( - //nolint:gochecknoglobals // package level defaults are provided as a convenient, backward-compatible way to adopt options. - defaultOptions = options{ - provider: jsonname.DefaultJSONNameProvider, - } - //nolint:gochecknoglobals // guards defaultOptions against concurrent SetDefaultNameProvider / read races (testing) - defaultOptionsMu sync.RWMutex -) - -// SetDefaultNameProvider sets the [NameProvider] as a package-level default. -// -// By default, the default provider is [jsonname.DefaultJSONNameProvider]. -// -// It is safe to call concurrently with [Pointer.Get], [Pointer.Set], -// [GetForToken] and [SetForToken]. The typical usage is to call it once -// at initialization time. -// -// A nil provider is ignored. -func SetDefaultNameProvider(provider NameProvider) { - if provider == nil { - return - } - - defaultOptionsMu.Lock() - defer defaultOptionsMu.Unlock() - - defaultOptions.provider = provider -} - -// UseGoNameProvider sets the [NameProvider] as a package-level default -// to the alternative provider [jsonname.GoNameProvider], that covers a few areas -// not supported by the default name provider. -// -// This implementation supports untagged exported fields and embedded types in go struct. -// It follows strictly the behavior of the JSON standard library regarding field naming conventions. -// -// It is safe to call concurrently with [Pointer.Get], [Pointer.Set], -// [GetForToken] and [SetForToken]. The typical usage is to call it once -// at initialization time. -func UseGoNameProvider() { - SetDefaultNameProvider(jsonname.NewGoNameProvider()) -} - -// DefaultNameProvider returns the current package-level [NameProvider]. -func DefaultNameProvider() NameProvider { //nolint:ireturn // returning the interface is the point — callers pick their own implementation. - defaultOptionsMu.RLock() - defer defaultOptionsMu.RUnlock() - - return defaultOptions.provider -} - -// WithNameProvider injects a custom [NameProvider] to resolve json names from go struct types. -func WithNameProvider(provider NameProvider) Option { - return func(o *options) { - o.provider = provider - } -} - -type options struct { - provider NameProvider -} - -func optionsWithDefaults(opts []Option) options { - var o options - o.provider = DefaultNameProvider() - - for _, apply := range opts { - apply(&o) - } - - return o -} diff --git a/vendor/github.com/go-openapi/jsonpointer/pointer.go b/vendor/github.com/go-openapi/jsonpointer/pointer.go index 2369c1827..7df49af3b 100644 --- a/vendor/github.com/go-openapi/jsonpointer/pointer.go +++ b/vendor/github.com/go-openapi/jsonpointer/pointer.go @@ -11,6 +11,8 @@ import ( "reflect" "strconv" "strings" + + "github.com/go-openapi/swag/jsonname" ) const ( @@ -18,6 +20,20 @@ const ( pointerSeparator = `/` ) +// JSONPointable is an interface for structs to implement, +// when they need to customize the json pointer process or want to avoid the use of reflection. +type JSONPointable interface { + // JSONLookup returns a value pointed at this (unescaped) key. + JSONLookup(key string) (any, error) +} + +// JSONSetable is an interface for structs to implement, +// when they need to customize the json pointer process or want to avoid the use of reflection. +type JSONSetable interface { + // JSONSet sets the value pointed at the (unescaped) key. + JSONSet(key string, value any) error +} + // Pointer is a representation of a json pointer. // // Use [Pointer.Get] to retrieve a value or [Pointer.Set] to set a value. @@ -25,7 +41,7 @@ const ( // It works with any go type interpreted as a JSON document, which means: // // - if a type implements [JSONPointable], its [JSONPointable.JSONLookup] method is used to resolve [Pointer.Get] -// - if a type implements [JSONSetable], its [JSONSetable.JSONSet] method is used to resolve [Pointer.Set] +// - if a type implements [JSONSetable], its [JSONPointable.JSONSet] method is used to resolve [Pointer.Set] // - a go map[K]V is interpreted as an object, with type K assignable to a string // - a go slice []T is interpreted as an array // - a go struct is interpreted as an object, with exported fields interpreted as keys @@ -55,35 +71,16 @@ func New(jsonPointerString string) (Pointer, error) { // Get uses the pointer to retrieve a value from a JSON document. // // It returns the value with its type as a [reflect.Kind] or an error. -func (p *Pointer) Get(document any, opts ...Option) (any, reflect.Kind, error) { - o := optionsWithDefaults(opts) - - return p.get(document, o.provider) +func (p *Pointer) Get(document any) (any, reflect.Kind, error) { + return p.get(document, jsonname.DefaultJSONNameProvider) } // Set uses the pointer to set a value from a data type // that represent a JSON document. // -// # Mutation contract -// -// Set mutates the provided document in place whenever Go's type system allows -// it: when document is a map, a pointer, or when the targeted value is reached -// through an addressable ancestor (e.g. a struct field traversed via a pointer, -// a slice element). Callers that rely on this in-place behavior may continue -// to ignore the returned document. -// -// The returned document is only load-bearing when Set cannot mutate in place. -// This happens in one specific case: appending to a top-level slice passed by -// value (e.g. document of type []T rather than *[]T) via the RFC 6901 "-" -// terminal token. reflect.Append produces a new slice header that the library -// cannot rebind into the caller's variable; the updated document is returned -// instead. Pass *[]T if you want in-place rebind for that case as well. -// -// See [ErrDashToken] for the semantics of the "-" token. -func (p *Pointer) Set(document any, value any, opts ...Option) (any, error) { - o := optionsWithDefaults(opts) - - return p.set(document, value, o.provider) +// It returns the updated document. +func (p *Pointer) Set(document any, value any) (any, error) { + return document, p.set(document, value, jsonname.DefaultJSONNameProvider) } // DecodedTokens returns the decoded (unescaped) tokens of this JSON pointer. @@ -112,46 +109,6 @@ func (p *Pointer) String() string { return pointerSeparator + strings.Join(p.referenceTokens, pointerSeparator) } -// Offset returns the byte offset, in the raw JSON text of document, of the -// location referenced by this pointer's terminal token. -// -// Unlike [Pointer.Get] and [Pointer.Set], which operate on a decoded Go value, -// Offset operates directly on the textual JSON source. It drives an -// [encoding/json.Decoder] over the string and stops at the terminal token, -// returning the position at which the decoder was about to read that token. -// -// It is primarily intended for tooling that needs to map a pointer back to a -// region of the original source: reporting line/column for validation or -// parse diagnostics, extracting a sub-document by slicing the raw bytes, or -// highlighting the referenced span in an editor. -// -// # Offset semantics -// -// The meaning of the returned offset depends on whether the terminal token -// addresses an object property or an array element: -// -// - Object property: the offset points to the first byte of the key (its -// opening quote character), not to the associated value. For example, -// pointer "/foo/bar" against {"foo": {"bar": 21}} returns 9, the index of -// the opening quote of "bar". -// - Array element: the offset points to the first byte of the value at that -// index. For example, pointer "/0/1" against [[1,2], [3,4]] returns 4, -// the index of the digit 2. -// -// # Errors -// -// Offset returns an error in any of these cases: -// -// - document is not syntactically valid JSON; -// - the structure of document does not match the pointer (e.g. traversing -// into a scalar, or a token that is neither a valid key nor a valid -// numeric index); -// - a referenced key or index does not exist in document; -// - the pointer's terminal token is the RFC 6901 "-" array token, which -// designates a nonexistent element and therefore has no offset in the -// source. The returned error wraps [ErrDashToken]. -// -// All errors wrap [ErrPointer]. func (p *Pointer) Offset(document string) (int64, error) { dec := json.NewDecoder(strings.NewReader(document)) var offset int64 @@ -180,35 +137,7 @@ func (p *Pointer) Offset(document string) (int64, error) { return 0, fmt.Errorf("invalid token %#v: %w", tk, ErrPointer) } } - return skipJSONSeparator(document, offset), nil -} - -// skipJSONSeparator advances offset past trailing JSON whitespace and at most -// one value separator (comma) in document, so the result points at the first -// byte of the next JSON token. -// -// The streaming decoder's InputOffset sits right after the most recently -// consumed token, which between values is the comma (or whitespace) — not -// the following token. Normalizing here keeps Offset's contract uniform: -// for both object keys and array elements, and regardless of position within -// the parent container, the returned offset always points at the first byte -// of the addressed token. -func skipJSONSeparator(document string, offset int64) int64 { - n := int64(len(document)) - for offset < n && isJSONWhitespace(document[offset]) { - offset++ - } - if offset < n && document[offset] == ',' { - offset++ - } - for offset < n && isJSONWhitespace(document[offset]) { - offset++ - } - return offset -} - -func isJSONWhitespace(c byte) bool { - return c == ' ' || c == '\t' || c == '\n' || c == '\r' + return offset, nil } // "Constructor", parses the given string JSON pointer. @@ -228,9 +157,9 @@ func (p *Pointer) parse(jsonPointerString string) error { return nil } -func (p *Pointer) get(node any, nameProvider NameProvider) (any, reflect.Kind, error) { +func (p *Pointer) get(node any, nameProvider *jsonname.NameProvider) (any, reflect.Kind, error) { if nameProvider == nil { - nameProvider = defaultOptions.provider + nameProvider = jsonname.DefaultJSONNameProvider } kind := reflect.Invalid @@ -256,130 +185,50 @@ func (p *Pointer) get(node any, nameProvider NameProvider) (any, reflect.Kind, e return node, kind, nil } -func (p *Pointer) set(node, data any, nameProvider NameProvider) (any, error) { +func (p *Pointer) set(node, data any, nameProvider *jsonname.NameProvider) error { knd := reflect.ValueOf(node).Kind() if knd != reflect.Pointer && knd != reflect.Struct && knd != reflect.Map && knd != reflect.Slice && knd != reflect.Array { - return node, errors.Join( + return errors.Join( fmt.Errorf("unexpected type: %T", node), //nolint:err113 // err wrapping is carried out by errors.Join, not fmt.Errorf. ErrUnsupportedValueType, ErrPointer, ) } + l := len(p.referenceTokens) + // full document when empty - if len(p.referenceTokens) == 0 { - return node, nil + if l == 0 { + return nil } if nameProvider == nil { - nameProvider = defaultOptions.provider - } - - return p.setAt(node, p.referenceTokens, data, nameProvider) -} - -// setAt recursively walks the token list, setting the data at the terminal -// token and rebinding any new child reference (e.g. a slice header returned -// by an "-" append) into its parent on the way back up. -// -// Returning the (possibly new) node at each level is what makes append work -// at any depth without requiring the caller to pass a pointer to the -// containing slice: the new slice header propagates up and each parent -// rebinds it via the appropriate kind-specific setter. -func (p *Pointer) setAt(node any, tokens []string, data any, nameProvider NameProvider) (any, error) { - decodedToken := Unescape(tokens[0]) - - if len(tokens) == 1 { - return setSingleImpl(node, data, decodedToken, nameProvider) - } - - child, err := p.resolveNodeForToken(node, decodedToken, nameProvider) - if err != nil { - return node, err - } - - newChild, err := p.setAt(child, tokens[1:], data, nameProvider) - if err != nil { - return node, err + nameProvider = jsonname.DefaultJSONNameProvider } - return rebindChild(node, decodedToken, newChild, nameProvider) -} - -// rebindChild writes newChild back into node at decodedToken. -// -// For cases where the child was already mutated in place (pointer aliasing, -// addressable slice elements) the rebind is a safe no-op. For cases where -// the child was returned by value (map entries holding a slice, slices -// reached through a non-addressable ancestor), the rebind propagates the -// new value into the parent. -// -// Parents implementing [JSONPointable] are left alone: they took ownership -// of the child via JSONLookup and did not opt into a JSONSet-based rebind -// on intermediate tokens. -func rebindChild(node any, decodedToken string, newChild any, nameProvider NameProvider) (any, error) { - if _, ok := node.(JSONPointable); ok { - return node, nil - } - - rValue := reflect.Indirect(reflect.ValueOf(node)) - - switch rValue.Kind() { - case reflect.Struct: - nm, ok := nameProvider.GetGoNameForType(rValue.Type(), decodedToken) - if !ok { - return node, fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) - } - fld := rValue.FieldByName(nm) - if !fld.CanSet() { - return node, nil - } - assignReflectValue(fld, newChild) - return node, nil + var decodedToken string + lastIndex := l - 1 - case reflect.Map: - rValue.SetMapIndex(reflect.ValueOf(decodedToken), reflect.ValueOf(newChild)) - return node, nil + if lastIndex > 0 { // skip if we only have one token in pointer + for _, token := range p.referenceTokens[:lastIndex] { + decodedToken = Unescape(token) + next, err := p.resolveNodeForToken(node, decodedToken, nameProvider) + if err != nil { + return err + } - case reflect.Slice: - if decodedToken == dashToken { - return node, errDashIntermediate() - } - idx, err := strconv.Atoi(decodedToken) - if err != nil { - return node, errors.Join(err, ErrPointer) + node = next } - elem := rValue.Index(idx) - if !elem.CanSet() { - return node, nil - } - assignReflectValue(elem, newChild) - return node, nil - - default: - return node, errInvalidReference(decodedToken) } -} -// assignReflectValue assigns src into dst, unwrapping a pointer when dst -// expects the pointee type. This tolerates the pointer-wrapping performed -// by [typeFromValue] for addressable fields. -func assignReflectValue(dst reflect.Value, src any) { - nv := reflect.ValueOf(src) - if !nv.IsValid() { - return - } - if nv.Type().AssignableTo(dst.Type()) { - dst.Set(nv) - return - } - if nv.Kind() == reflect.Pointer && nv.Elem().Type().AssignableTo(dst.Type()) { - dst.Set(nv.Elem()) - } + // last token + decodedToken = Unescape(p.referenceTokens[lastIndex]) + + return setSingleImpl(node, data, decodedToken, nameProvider) } -func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvider NameProvider) (next any, err error) { +func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvider *jsonname.NameProvider) (next any, err error) { // check for nil during traversal if isNil(node) { return nil, fmt.Errorf("cannot traverse through nil value at %q: %w", decodedToken, ErrPointer) @@ -423,9 +272,6 @@ func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvide return typeFromValue(mv), nil case reflect.Slice: - if decodedToken == dashToken { - return nil, errDashIntermediate() - } tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { return nil, errors.Join(err, ErrPointer) @@ -466,23 +312,16 @@ func typeFromValue(v reflect.Value) any { } // GetForToken gets a value for a json pointer token 1 level deep. -func GetForToken(document any, decodedToken string, opts ...Option) (any, reflect.Kind, error) { - o := optionsWithDefaults(opts) - - return getSingleImpl(document, decodedToken, o.provider) +func GetForToken(document any, decodedToken string) (any, reflect.Kind, error) { + return getSingleImpl(document, decodedToken, jsonname.DefaultJSONNameProvider) } // SetForToken sets a value for a json pointer token 1 level deep. -// -// See [Pointer.Set] for the mutation contract, in particular the handling of -// the RFC 6901 "-" token on slices. -func SetForToken(document any, decodedToken string, value any, opts ...Option) (any, error) { - o := optionsWithDefaults(opts) - - return setSingleImpl(document, value, decodedToken, o.provider) +func SetForToken(document any, decodedToken string, value any) (any, error) { + return document, setSingleImpl(document, value, decodedToken, jsonname.DefaultJSONNameProvider) } -func getSingleImpl(node any, decodedToken string, nameProvider NameProvider) (any, reflect.Kind, error) { +func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NameProvider) (any, reflect.Kind, error) { rValue := reflect.Indirect(reflect.ValueOf(node)) kind := rValue.Kind() if isNil(node) { @@ -522,9 +361,6 @@ func getSingleImpl(node any, decodedToken string, nameProvider NameProvider) (an return nil, kind, errNoKey(decodedToken) case reflect.Slice: - if decodedToken == dashToken { - return nil, kind, errDashOnGet() - } tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { return nil, kind, errors.Join(err, ErrPointer) @@ -542,14 +378,14 @@ func getSingleImpl(node any, decodedToken string, nameProvider NameProvider) (an } } -func setSingleImpl(node, data any, decodedToken string, nameProvider NameProvider) (any, error) { +func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.NameProvider) error { // check for nil to prevent panic when calling rValue.Type() if isNil(node) { - return node, fmt.Errorf("cannot set field %q on nil value: %w", decodedToken, ErrPointer) + return fmt.Errorf("cannot set field %q on nil value: %w", decodedToken, ErrPointer) } if ns, ok := node.(JSONSetable); ok { - return node, ns.JSONSet(decodedToken, data) + return ns.JSONSet(decodedToken, data) } rValue := reflect.Indirect(reflect.ValueOf(node)) @@ -558,12 +394,12 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider NameProvide case reflect.Struct: nm, ok := nameProvider.GetGoNameForType(rValue.Type(), decodedToken) if !ok { - return node, fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) + return fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) } fld := rValue.FieldByName(nm) if !fld.CanSet() { - return node, fmt.Errorf("can't set struct field %s to %v: %w", nm, data, ErrPointer) + return fmt.Errorf("can't set struct field %s to %v: %w", nm, data, ErrPointer) } value := reflect.ValueOf(data) @@ -571,51 +407,33 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider NameProvide assignedType := fld.Type() if !valueType.AssignableTo(assignedType) { - return node, fmt.Errorf("can't set value with type %T to field %s with type %v: %w", data, nm, assignedType, ErrPointer) + return fmt.Errorf("can't set value with type %T to field %s with type %v: %w", data, nm, assignedType, ErrPointer) } fld.Set(value) - return node, nil + return nil case reflect.Map: kv := reflect.ValueOf(decodedToken) rValue.SetMapIndex(kv, reflect.ValueOf(data)) - return node, nil + return nil case reflect.Slice: - if decodedToken == dashToken { - // RFC 6901 §4 / RFC 6902 append semantics: terminal "-" appends - // the value to the slice. We rebind in place when the slice is - // reachable via an addressable ancestor; otherwise we return the - // new slice header for the parent (or the public Set) to rebind. - value := reflect.ValueOf(data) - elemType := rValue.Type().Elem() - if !value.Type().AssignableTo(elemType) { - return node, fmt.Errorf("can't append value of type %T to slice of %v: %w", data, elemType, ErrPointer) - } - newSlice := reflect.Append(rValue, value) - if rValue.CanSet() { - rValue.Set(newSlice) - return node, nil - } - return newSlice.Interface(), nil - } - tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { - return node, errors.Join(err, ErrPointer) + return errors.Join(err, ErrPointer) } sLength := rValue.Len() if tokenIndex < 0 || tokenIndex >= sLength { - return node, errOutOfBounds(sLength, tokenIndex) + return errOutOfBounds(sLength, tokenIndex) } elem := rValue.Index(tokenIndex) if !elem.CanSet() { - return node, fmt.Errorf("can't set slice index %s to %v: %w", decodedToken, data, ErrPointer) + return fmt.Errorf("can't set slice index %s to %v: %w", decodedToken, data, ErrPointer) } value := reflect.ValueOf(data) @@ -623,15 +441,15 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider NameProvide assignedType := elem.Type() if !valueType.AssignableTo(assignedType) { - return node, fmt.Errorf("can't set value with type %T to slice element %d with type %v: %w", data, tokenIndex, assignedType, ErrPointer) + return fmt.Errorf("can't set value with type %T to slice element %d with type %v: %w", data, tokenIndex, assignedType, ErrPointer) } elem.Set(value) - return node, nil + return nil default: - return node, errInvalidReference(decodedToken) + return errInvalidReference(decodedToken) } } @@ -642,27 +460,24 @@ func offsetSingleObject(dec *json.Decoder, decodedToken string) (int64, error) { if err != nil { return 0, err } - key, ok := tk.(string) - if !ok { - return 0, fmt.Errorf("invalid key token %#v: %w", tk, ErrPointer) - } - if key == decodedToken { - return offset, nil - } - - // Consume the associated value. Scalars are fully read by a single - // Token() call; composite values must be drained. - tk, err = dec.Token() - if err != nil { - return 0, err - } - if delim, isDelim := tk.(json.Delim); isDelim { - switch delim { - case '{', '[': + switch tk := tk.(type) { + case json.Delim: + switch tk { + case '{': if err = drainSingle(dec); err != nil { return 0, err } + case '[': + if err = drainSingle(dec); err != nil { + return 0, err + } + } + case string: + if tk == decodedToken { + return offset, nil } + default: + return 0, fmt.Errorf("invalid token %#v: %w", tk, ErrPointer) } } @@ -670,9 +485,6 @@ func offsetSingleObject(dec *json.Decoder, decodedToken string) (int64, error) { } func offsetSingleArray(dec *json.Decoder, decodedToken string) (int64, error) { - if decodedToken == dashToken { - return 0, errDashOnOffset() - } idx, err := strconv.Atoi(decodedToken) if err != nil { return 0, fmt.Errorf("token reference %q is not a number: %w: %w", decodedToken, err, ErrPointer) diff --git a/vendor/github.com/go-openapi/jsonreference/.cliff.toml b/vendor/github.com/go-openapi/jsonreference/.cliff.toml new file mode 100644 index 000000000..702629f5d --- /dev/null +++ b/vendor/github.com/go-openapi/jsonreference/.cliff.toml @@ -0,0 +1,181 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +header = """ +""" + +footer = """ + +----- + +**[{{ remote.github.repo }}]({{ self::remote_url() }}) license terms** + +[![License][license-badge]][license-url] + +[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg +[license-url]: {{ self::remote_url() }}/?tab=Apache-2.0-1-ov-file#readme + +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +""" + +body = """ +{%- if version %} +## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/tree/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} +{%- else %} +## [unreleased] +{%- endif %} +{%- if message %} + {%- raw %}\n{% endraw %} +{{ message }} + {%- raw %}\n{% endraw %} +{%- endif %} +{%- if version %} + {%- if previous.version %} + +**Full Changelog**: <{{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}> + {%- endif %} +{%- else %} + {%- raw %}\n{% endraw %} +{%- endif %} + +{%- if statistics %}{% if statistics.commit_count %} + {%- raw %}\n{% endraw %} +{{ statistics.commit_count }} commits in this release. + {%- raw %}\n{% endraw %} +{%- endif %}{% endif %} +----- + +{%- for group, commits in commits | group_by(attribute="group") %} + {%- raw %}\n{% endraw %} +### {{ group | upper_first }} + {%- raw %}\n{% endraw %} + {%- for commit in commits %} + {%- if commit.remote.pr_title %} + {%- set commit_message = commit.remote.pr_title %} + {%- else %} + {%- set commit_message = commit.message %} + {%- endif %} +* {{ commit_message | split(pat="\n") | first | trim }} + {%- if commit.remote.username %} +{%- raw %} {% endraw %}by [@{{ commit.remote.username }}](https://github.com/{{ commit.remote.username }}) + {%- endif %} + {%- if commit.remote.pr_number %} +{%- raw %} {% endraw %}in [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) + {%- endif %} +{%- raw %} {% endraw %}[...]({{ self::remote_url() }}/commit/{{ commit.id }}) + {%- endfor %} +{%- endfor %} + +{%- if github %} +{%- raw %}\n{% endraw -%} + {%- set all_contributors = github.contributors | length %} + {%- if github.contributors | filter(attribute="username", value="dependabot[bot]") | length < all_contributors %} +----- + +### People who contributed to this release + {% endif %} + {%- for contributor in github.contributors | filter(attribute="username") | sort(attribute="username") %} + {%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %} +* [@{{ contributor.username }}](https://github.com/{{ contributor.username }}) + {%- endif %} + {%- endfor %} + + {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} +----- + {%- raw %}\n{% endraw %} + +### New Contributors + {%- endif %} + + {%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} + {%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %} +* @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} + in [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} + {%- endif %} + {%- endfor %} +{%- endif %} + +{%- raw %}\n{% endraw %} + +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +""" +# Remove leading and trailing whitespaces from the changelog's body. +trim = true +# Render body even when there are no releases to process. +render_always = true +# An array of regex based postprocessors to modify the changelog. +postprocessors = [ + # Replace the placeholder with a URL. + #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, +] +# output file path +# output = "test.md" + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = false +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = false +# Require all commits to be conventional. +# Takes precedence over filter_unconventional. +require_conventional = false +# Split commits on newlines, treating each line as an individual commit. +split_commits = false +# An array of regex based parsers to modify commit messages prior to further processing. +commit_preprocessors = [ + # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit message using https://github.com/crate-ci/typos. + # If the spelling is incorrect, it will be fixed automatically. + #{ pattern = '.*', replace_command = 'typos --write-changes -' } +] +# Prevent commits that are breaking from being excluded by commit parsers. +protect_breaking_commits = false +# An array of regex based parsers for extracting data from the commit message. +# Assigns commits to groups. +# Optionally sets the commit's scope and can decide to exclude commits from further processing. +commit_parsers = [ + { message = "^[Cc]hore\\([Rr]elease\\): prepare for", skip = true }, + { message = "(^[Mm]erge)|([Mm]erge conflict)", skip = true }, + { field = "author.name", pattern = "dependabot*", group = "Updates" }, + { message = "([Ss]ecurity)|([Vv]uln)", group = "Security" }, + { body = "(.*[Ss]ecurity)|([Vv]uln)", group = "Security" }, + { message = "([Cc]hore\\(lint\\))|(style)|(lint)|(codeql)|(golangci)", group = "Code quality" }, + { message = "(^[Dd]oc)|((?i)readme)|(badge)|(typo)|(documentation)", group = "Documentation" }, + { message = "(^[Ff]eat)|(^[Ee]nhancement)", group = "Implemented enhancements" }, + { message = "(^ci)|(\\(ci\\))|(fixup\\s+ci)|(fix\\s+ci)|(license)|(example)", group = "Miscellaneous tasks" }, + { message = "^test", group = "Testing" }, + { message = "(^fix)|(panic)", group = "Fixed bugs" }, + { message = "(^refact)|(rework)", group = "Refactor" }, + { message = "(^[Pp]erf)|(performance)", group = "Performance" }, + { message = "(^[Cc]hore)", group = "Miscellaneous tasks" }, + { message = "^[Rr]evert", group = "Reverted changes" }, + { message = "(upgrade.*?go)|(go\\s+version)", group = "Updates" }, + { message = ".*", group = "Other" }, +] +# Exclude commits that are not matched by any commit parser. +filter_commits = false +# An array of link parsers for extracting external references, and turning them into URLs, using regex. +link_parsers = [] +# Include only the tags that belong to the current branch. +use_branch_tags = false +# Order releases topologically instead of chronologically. +topo_order = false +# Order releases topologically instead of chronologically. +topo_order_commits = true +# Order of commits in each group/release within the changelog. +# Allowed values: newest, oldest +sort_commits = "newest" +# Process submodules commits +recurse_submodules = false + +#[remote.github] +#owner = "go-openapi" diff --git a/vendor/github.com/go-openapi/jsonreference/.gitignore b/vendor/github.com/go-openapi/jsonreference/.gitignore index 885dc27ab..769c24400 100644 --- a/vendor/github.com/go-openapi/jsonreference/.gitignore +++ b/vendor/github.com/go-openapi/jsonreference/.gitignore @@ -1,6 +1 @@ -*.out -*.cov -.idea -.env -.mcp.json -.claude/ +secrets.yml diff --git a/vendor/github.com/go-openapi/jsonreference/.golangci.yml b/vendor/github.com/go-openapi/jsonreference/.golangci.yml index dc7c96053..fdae591bc 100644 --- a/vendor/github.com/go-openapi/jsonreference/.golangci.yml +++ b/vendor/github.com/go-openapi/jsonreference/.golangci.yml @@ -12,7 +12,6 @@ linters: - paralleltest - recvcheck - testpackage - - thelper - tparallel - varnamelen - whitespace diff --git a/vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md b/vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md index bac878f21..9322b065e 100644 --- a/vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md +++ b/vendor/github.com/go-openapi/jsonreference/CODE_OF_CONDUCT.md @@ -23,9 +23,7 @@ include: Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or - advances - * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic @@ -57,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at . All +reported by contacting the project team at ivan+abuse@flanders.co.nz. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. @@ -70,7 +68,7 @@ members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [][version] +available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/vendor/github.com/go-openapi/jsonreference/CONTRIBUTORS.md b/vendor/github.com/go-openapi/jsonreference/CONTRIBUTORS.md index 7faeb83a7..9907d5d21 100644 --- a/vendor/github.com/go-openapi/jsonreference/CONTRIBUTORS.md +++ b/vendor/github.com/go-openapi/jsonreference/CONTRIBUTORS.md @@ -4,11 +4,11 @@ | Total Contributors | Total Contributions | | --- | --- | -| 9 | 73 | +| 9 | 68 | | Username | All Time Contribution Count | All Commits | | --- | --- | --- | -| @fredbi | 36 | https://github.com/go-openapi/jsonreference/commits?author=fredbi | +| @fredbi | 31 | https://github.com/go-openapi/jsonreference/commits?author=fredbi | | @casualjim | 25 | https://github.com/go-openapi/jsonreference/commits?author=casualjim | | @youyuanwu | 5 | https://github.com/go-openapi/jsonreference/commits?author=youyuanwu | | @olivierlemasle | 2 | https://github.com/go-openapi/jsonreference/commits?author=olivierlemasle | diff --git a/vendor/github.com/go-openapi/jsonreference/NOTICE b/vendor/github.com/go-openapi/jsonreference/NOTICE index 814e87ef8..f3b51939a 100644 --- a/vendor/github.com/go-openapi/jsonreference/NOTICE +++ b/vendor/github.com/go-openapi/jsonreference/NOTICE @@ -3,7 +3,7 @@ Copyright 2015-2025 go-swagger maintainers // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -This software library, github.com/go-openapi/jsonreference, includes software developed +This software library, github.com/go-openapi/jsonpointer, includes software developed by the go-swagger and go-openapi maintainers ("go-swagger maintainers"). Licensed under the Apache License, Version 2.0 (the "License"); @@ -18,7 +18,7 @@ It ships with copies of other software which license terms are recalled below. The original software was authored on 25-02-2013 by sigu-399 (https://github.com/sigu-399, sigu.399@gmail.com). -github.com/sigh-399/jsonreference +github.com/sigh-399/jsonpointer =========================== // SPDX-FileCopyrightText: Copyright 2013 sigu-399 ( https://github.com/sigu-399 ) diff --git a/vendor/github.com/go-openapi/jsonreference/README.md b/vendor/github.com/go-openapi/jsonreference/README.md index adea16061..d479dbdc7 100644 --- a/vendor/github.com/go-openapi/jsonreference/README.md +++ b/vendor/github.com/go-openapi/jsonreference/README.md @@ -8,22 +8,12 @@ [![Release][release-badge]][release-url] [![Go Report Card][gocard-badge]][gocard-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url] -[![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] +[![GoDoc][godoc-badge]][godoc-url] [![Slack Channel][slack-logo]![slack-badge]][slack-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] --- An implementation of JSON Reference for golang. -## Announcements - -* **2025-12-19** : new community chat on discord - * a new discord community channel is available to be notified of changes and support users - * our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31** - -You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url] - -Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url] - ## Status API is stable. @@ -36,33 +26,18 @@ go get github.com/go-openapi/jsonreference ## Dependencies -* +* https://github.com/go-openapi/jsonpointer ## Basic usage -```go -// Creating a new reference -ref, err := jsonreference.New("http://example.com/doc.json#/definitions/Pet") - -// Fragment-only reference -fragRef := jsonreference.MustCreateRef("#/definitions/Pet") - -// Resolving references -parent, _ := jsonreference.New("http://example.com/base.json") -child, _ := jsonreference.New("#/definitions/Pet") -resolved, _ := parent.Inherits(child) -// Result: "http://example.com/base.json#/definitions/Pet" -``` - - ## Change log See ## References -* -* +* http://tools.ietf.org/html/draft-ietf-appsawg-json-pointer-07 +* http://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03 ## Licensing @@ -114,9 +89,6 @@ Maintainers can cut a new release by either: [slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png [slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM [slack-url]: https://goswagger.slack.com/archives/C04R30YMU -[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue -[discord-url]: https://discord.gg/twZ9BwT3 - [license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg [license-url]: https://github.com/go-openapi/jsonreference/?tab=Apache-2.0-1-ov-file#readme diff --git a/vendor/github.com/go-openapi/jsonreference/SECURITY.md b/vendor/github.com/go-openapi/jsonreference/SECURITY.md index 1fea2c573..2a7b6f091 100644 --- a/vendor/github.com/go-openapi/jsonreference/SECURITY.md +++ b/vendor/github.com/go-openapi/jsonreference/SECURITY.md @@ -6,32 +6,14 @@ This policy outlines the commitment and practices of the go-openapi maintainers | Version | Supported | | ------- | ------------------ | -| O.x | :white_check_mark: | - -## Vulnerability checks in place - -This repository uses automated vulnerability scans, at every merged commit and at least once a week. - -We use: - -* [`GitHub CodeQL`][codeql-url] -* [`trivy`][trivy-url] -* [`govulncheck`][govulncheck-url] - -Reports are centralized in github security reports and visible only to the maintainers. +| 0.22.x | :white_check_mark: | ## Reporting a vulnerability If you become aware of a security vulnerability that affects the current repository, -**please report it privately to the maintainers** -rather than opening a publicly visible GitHub issue. - -Please follow the instructions provided by github to [Privately report a security vulnerability][github-guidance-url]. +please report it privately to the maintainers. -> [!NOTE] -> On Github, navigate to the project's "Security" tab then click on "Report a vulnerability". +Please follow the instructions provided by github to +[Privately report a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability). -[codeql-url]: https://github.com/github/codeql -[trivy-url]: https://trivy.dev/docs/latest/getting-started -[govulncheck-url]: https://go.dev/blog/govulncheck -[github-guidance-url]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability +TL;DR: on Github, navigate to the project's "Security" tab then click on "Report a vulnerability". diff --git a/vendor/github.com/go-openapi/jsonreference/reference.go b/vendor/github.com/go-openapi/jsonreference/reference.go index 003ba7a83..6e3ae4995 100644 --- a/vendor/github.com/go-openapi/jsonreference/reference.go +++ b/vendor/github.com/go-openapi/jsonreference/reference.go @@ -16,7 +16,6 @@ const ( fragmentRune = `#` ) -// ErrChildURL is raised when there is no child. var ErrChildURL = errors.New("child url is nil") // Ref represents a json reference object. diff --git a/vendor/github.com/go-openapi/swag/.gitignore b/vendor/github.com/go-openapi/swag/.gitignore index 1680db44c..c4b1b64f0 100644 --- a/vendor/github.com/go-openapi/swag/.gitignore +++ b/vendor/github.com/go-openapi/swag/.gitignore @@ -3,4 +3,3 @@ vendor Godeps .idea *.out -.mcp.json diff --git a/vendor/github.com/go-openapi/swag/CODE_OF_CONDUCT.md b/vendor/github.com/go-openapi/swag/CODE_OF_CONDUCT.md index bac878f21..9322b065e 100644 --- a/vendor/github.com/go-openapi/swag/CODE_OF_CONDUCT.md +++ b/vendor/github.com/go-openapi/swag/CODE_OF_CONDUCT.md @@ -23,9 +23,7 @@ include: Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or - advances - * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic @@ -57,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at . All +reported by contacting the project team at ivan+abuse@flanders.co.nz. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. @@ -70,7 +68,7 @@ members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [][version] +available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/vendor/github.com/go-openapi/swag/CONTRIBUTORS.md b/vendor/github.com/go-openapi/swag/CONTRIBUTORS.md deleted file mode 100644 index 286878acf..000000000 --- a/vendor/github.com/go-openapi/swag/CONTRIBUTORS.md +++ /dev/null @@ -1,36 +0,0 @@ -# Contributors - -- Repository: ['go-openapi/swag'] - -| Total Contributors | Total Contributions | -| --- | --- | -| 24 | 242 | - -| Username | All Time Contribution Count | All Commits | -| --- | --- | --- | -| @fredbi | 112 | | -| @casualjim | 98 | | -| @alexandear | 4 | | -| @orisano | 3 | | -| @reinerRubin | 2 | | -| @n-inja | 2 | | -| @nitinmohan87 | 2 | | -| @Neo2308 | 2 | | -| @michaelbowler-form3 | 2 | | -| @ujjwalsh | 1 | | -| @griffin-stewie | 1 | | -| @POD666 | 1 | | -| @pytlesk4 | 1 | | -| @shirou | 1 | | -| @seanprince | 1 | | -| @petrkotas | 1 | | -| @mszczygiel | 1 | | -| @sosiska | 1 | | -| @kzys | 1 | | -| @faguirre1 | 1 | | -| @posener | 1 | | -| @diego-fu-hs | 1 | | -| @davidalpert | 1 | | -| @Xe | 1 | | - - _this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_ diff --git a/vendor/github.com/go-openapi/swag/README.md b/vendor/github.com/go-openapi/swag/README.md index 64f667103..371fd55fd 100644 --- a/vendor/github.com/go-openapi/swag/README.md +++ b/vendor/github.com/go-openapi/swag/README.md @@ -1,60 +1,26 @@ -# Swag - - -[![Tests][test-badge]][test-url] [![Coverage][cov-badge]][cov-url] [![CI vuln scan][vuln-scan-badge]][vuln-scan-url] [![CodeQL][codeql-badge]][codeql-url] - - - -[![Release][release-badge]][release-url] [![Go Report Card][gocard-badge]][gocard-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url] - - -[![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] +# Swag [![Build Status](https://github.com/go-openapi/swag/actions/workflows/go-test.yml/badge.svg)](https://github.com/go-openapi/swag/actions?query=workflow%3A"go+test") [![codecov](https://codecov.io/gh/go-openapi/swag/branch/master/graph/badge.svg)](https://codecov.io/gh/go-openapi/swag) ---- +[![Slack Status](https://slackin.goswagger.io/badge.svg)](https://slackin.goswagger.io) +[![license](https://img.shields.io/badge/license-Apache%20v2-orange.svg)](https://raw.githubusercontent.com/go-openapi/swag/master/LICENSE) +[![Go Reference](https://pkg.go.dev/badge/github.com/go-openapi/swag.svg)](https://pkg.go.dev/github.com/go-openapi/swag) +[![Go Report Card](https://goreportcard.com/badge/github.com/go-openapi/swag)](https://goreportcard.com/report/github.com/go-openapi/swag) -A bunch of helper functions for go-openapi and go-swagger projects. +Package `swag` contains a bunch of helper functions for go-openapi and go-swagger projects. You may also use it standalone for your projects. > **NOTE** > `swag` is one of the foundational building blocks of the go-openapi initiative. -> > Most repositories in `github.com/go-openapi/...` depend on it in some way. > And so does our CLI tool `github.com/go-swagger/go-swagger`, > as well as the code generated by this tool. * [Contents](#contents) * [Dependencies](#dependencies) -* [Change log](#change-log) +* [Release Notes](#release-notes) * [Licensing](#licensing) * [Note to contributors](#note-to-contributors) -* [Roadmap](#roadmap) - -## Announcements - -* **2025-12-19** : new community chat on discord - * a new discord community channel is available to be notified of changes and support users - * our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31** - -You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url] - -Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url] - -## Status - -API is stable. - -## Import this library in your project - -```cmd -go get github.com/go-openapi/swag/{module} -``` - -Or for backward compatibility: - -```cmd -go get github.com/go-openapi/swag -``` +* [TODOs, suggestions and plans](#todos-suggestions-and-plans) ## Contents @@ -70,7 +36,7 @@ Child modules will continue to evolve and some new ones may be added in the futu | `cmdutils` | utilities to work with CLIs || | `conv` | type conversion utilities | convert between values and pointers for any types
convert from string to builtin types (wraps `strconv`)
require `./typeutils` (test dependency)
| | `fileutils` | file utilities | | -| `jsonname` | JSON utilities | infer JSON names from `go` properties
| +| `jsonname` | JSON utilities | infer JSON names from `go` properties
| | `jsonutils` | JSON utilities | fast json concatenation
read and write JSON from and to dynamic `go` data structures
~require `github.com/mailru/easyjson`~
| | `loading` | file loading | load from file or http
require `./yamlutils`
| | `mangling` | safe name generation | name mangling for `go`
| @@ -83,19 +49,84 @@ Child modules will continue to evolve and some new ones may be added in the futu ## Dependencies -The root module `github.com/go-openapi/swag` at the repo level maintains a few +The root module `github.com/go-openapi/swag` at the repo level maintains a few dependencies outside of the standard library. * YAML utilities depend on `go.yaml.in/yaml/v3` * JSON utilities depend on their registered adapter module: - * by default, only the standard library is used - * `github.com/mailru/easyjson` is now only a dependency for module - `github.com/go-openapi/swag/jsonutils/adapters/easyjson/json`, - for users willing to import that module. - * integration tests and benchmarks use all the dependencies are published as their own module + * by default, only the standard library is used + * `github.com/mailru/easyjson` is now only a dependency for module + `github.com/go-openapi/swag/jsonutils/adapters/easyjson/json`, + for users willing to import that module. + * integration tests and benchmarks use all the dependencies are published as their own module * other dependencies are test dependencies drawn from `github.com/stretchr/testify` -## Usage +## Release notes + +### v0.25.4 + +** mangling** + +Bug fix + +* [x] mangler may panic with pluralized overlapping initialisms + +Tests + +* [x] introduced fuzz tests + +### v0.25.3 + +** mangling** + +Bug fix + +* [x] mangler may panic with pluralized initialisms + +### v0.25.2 + +Minor changes due to internal maintenance that don't affect the behavior of the library. + +* [x] removed indirect test dependencies by switching all tests to `go-openapi/testify`, + a fork of `stretch/testify` with zero-dependencies. +* [x] improvements to CI to catch test reports. +* [x] modernized licensing annotations in source code, using the more compact SPDX annotations + rather than the full license terms. +* [x] simplified a bit JSON & YAML testing by using newly available assertions +* started the journey to an OpenSSF score card badge: + * [x] explicited permissions in CI workflows + * [x] published security policy + * pinned dependencies to github actions + * introduced fuzzing in tests + +### v0.25.1 + +* fixes a data race that could occur when using the standard library implementation of a JSON ordered map + +### v0.25.0 + +**New with this release**: + +* requires `go1.24`, as iterators are being introduced +* removes the dependency to `mailru/easyjson` by default (#68) + * functionality remains the same, but performance may somewhat degrade for applications + that relied on `easyjson` + * users of the JSON or YAML utilities who want to use `easyjson` as their preferred JSON serializer library + will be able to do so by registering this the corresponding JSON adapter at runtime. See below. + * ordered keys in JSON and YAML objects: this feature used to rely solely on `easyjson`. + With this release, an implementation relying on the standard `encoding/json` is provided. + * an independent [benchmark](./jsonutils/adapters/testintegration/benchmarks/README.md) to compare the different adapters +* improves the "float is integer" check (`conv.IsFloat64AJSONInteger`) (#59) +* removes the _direct_ dependency to `gopkg.in/yaml.v3` (indirect dependency is still incurred through `stretchr/testify`) (#127) +* exposed `conv.IsNil()` (previously kept private): a safe nil check (accounting for the "non-nil interface with nil value" nonsensical go trick) + +**What coming next?** + +Moving forward, we want to : +* provide an implementation of the JSON adapter based on `encoding/json/v2`, for `go1.25` builds. +* provide similar implementations for `goccy/go-json` and `jsoniterator/go`, and perhaps some other + similar libraries may be interesting too. + **How to explicitly register a dependency at runtime**? @@ -119,106 +150,90 @@ or fallback to the standard library. For more details, you may also look at our [integration tests](jsonutils/adapters/testintegration/integration_suite_test.go#29). ---- +### v0.24.0 -## Note to contributors +With this release, we have largely modernized the API of `swag`: -All kinds of contributions are welcome. +* The traditional `swag` API is still supported: code that imports `swag` will still + compile and work the same. +* A deprecation notice is published to encourage consumers of this library to adopt + the newer API +* **Deprecation notice** + * configuration through global variables is now deprecated, in favor of options passed as parameters + * all helper functions are moved to more specialized packages, which are exposed as + go modules. Importing such a module would reduce the footprint of dependencies. + * _all_ functions, variables, constants exposed by the deprecated API have now moved, so + that consumers of the new API no longer need to import github.com/go-openapi/swag, but + should import the desired sub-module(s). -This repo is a go mono-repo. See [docs](docs/MAINTAINERS.md). +**New with this release**: -More general guidelines are available [here](.github/CONTRIBUTING.md). +* [x] type converters and pointer to value helpers now support generic types +* [x] name mangling now support pluralized initialisms (issue #46) + Strings like "contact IDs" are now recognized as such a plural form and mangled as a linter would expect. +* [x] performance: small improvements to reduce the overhead of convert/format wrappers (see issues #110, or PR #108) +* [x] performance: name mangling utilities run ~ 10% faster (PR #115) -## Roadmap +--- -See the current [TODO list](docs/TODOS.md) +## Licensing -## Change log +This library ships under the [SPDX-License-Identifier: Apache-2.0](./LICENSE). -See +## Note to contributors -For pre-v0.26.0 releases, see [release notes](./docs/NOTES.md). +A mono-repo structure comes with some unavoidable extra pains... -**What coming next?** +* Testing -Moving forward, we want to : +> The usual `go test ./...` command, run from the root of this repo won't work any longer to test all submodules. +> +> Each module constitutes an independant unit of test. So you have to run `go test` inside each module. +> Or you may take a look at how this is achieved by CI +> [here] https://github.com/go-openapi/swag/blob/master/.github/workflows/go-test.yml). +> +> There are also some alternative tricks using `go work`, for local development, if you feel comfortable with +> go workspaces. Perhaps some day, we'll have a `go work test` to run all tests without any hack. -* provide an implementation of the JSON adapter based on `encoding/json/v2`, for `go1.25` builds. -* provide similar implementations for `goccy/go-json` and `jsoniterator/go`, and perhaps some other - similar libraries may be interesting too. +* Releasing - +> At this moment, all tests in all modules are systematically run over the full test matrix (3 platform x 2 go releases). +> This generates quite a lot of jobs. +> +> We ought to reduce the number of jobs required to test a PR focused on only a few modules. -## Licensing +## Todos, suggestions and plans -This library ships under the [SPDX-License-Identifier: Apache-2.0](./LICENSE). +All kinds of contributions are welcome. - - - - -## Other documentation - -* [All-time contributors](./CONTRIBUTORS.md) -* [Contributing guidelines](.github/CONTRIBUTING.md) -* [Maintainers documentation](docs/MAINTAINERS.md) -* [Code style](docs/STYLE.md) - -## Cutting a new release - -Maintainers can cut a new release by either: - -* running [this workflow](https://github.com/go-openapi/swag/actions/workflows/bump-release.yml) -* or pushing a semver tag - * signed tags are preferred - * The tag message is prepended to release notes - - -[test-badge]: https://github.com/go-openapi/swag/actions/workflows/go-test.yml/badge.svg -[test-url]: https://github.com/go-openapi/swag/actions/workflows/go-test.yml -[cov-badge]: https://codecov.io/gh/go-openapi/swag/branch/master/graph/badge.svg -[cov-url]: https://codecov.io/gh/go-openapi/swag -[vuln-scan-badge]: https://github.com/go-openapi/swag/actions/workflows/scanner.yml/badge.svg -[vuln-scan-url]: https://github.com/go-openapi/swag/actions/workflows/scanner.yml -[codeql-badge]: https://github.com/go-openapi/swag/actions/workflows/codeql.yml/badge.svg -[codeql-url]: https://github.com/go-openapi/swag/actions/workflows/codeql.yml - -[release-badge]: https://badge.fury.io/gh/go-openapi%2Fswag.svg -[release-url]: https://badge.fury.io/gh/go-openapi%2Fswag -[gomod-badge]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fswag.svg -[gomod-url]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fswag - -[gocard-badge]: https://goreportcard.com/badge/github.com/go-openapi/swag -[gocard-url]: https://goreportcard.com/report/github.com/go-openapi/swag -[codefactor-badge]: https://img.shields.io/codefactor/grade/github/go-openapi/swag -[codefactor-url]: https://www.codefactor.io/repository/github/go-openapi/swag - -[doc-badge]: https://img.shields.io/badge/doc-site-blue?link=https%3A%2F%2Fgoswagger.io%2Fgo-openapi%2F -[doc-url]: https://goswagger.io/go-openapi -[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/swag -[godoc-url]: http://pkg.go.dev/github.com/go-openapi/swag -[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png -[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM -[slack-url]: https://goswagger.slack.com/archives/C04R30YMU -[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue -[discord-url]: https://discord.gg/FfnFYaC3k5 - - -[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg -[license-url]: https://github.com/go-openapi/swag/?tab=Apache-2.0-1-ov-file#readme - -[goversion-badge]: https://img.shields.io/github/go-mod/go-version/go-openapi/swag -[goversion-url]: https://github.com/go-openapi/swag/blob/master/go.mod -[top-badge]: https://img.shields.io/github/languages/top/go-openapi/swag -[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/swag/latest +A few ideas: + +* [x] Complete the split of dependencies to isolate easyjson from the rest +* [x] Improve CI to reduce needed tests +* [x] Replace dependency to `gopkg.in/yaml.v3` (`yamlutil`) +* [ ] Improve mangling utilities (improve readability, support for capitalized words, + better word substitution for non-letter symbols...) +* [ ] Move back to this common shared pot a few of the technical features introduced by go-swagger independently + (e.g. mangle go package names, search package with go modules support, ...) +* [ ] Apply a similar mono-repo approach to go-openapi/strfmt which suffer from similar woes: bloated API, + imposed dependency to some database driver. +* [ ] Adapt `go-swagger` (incl. generated code) to the new `swag` API. +* [ ] Factorize some tests, as there is a lot of redundant testing code in `jsonutils` +* [ ] Benchmark & profiling: publish independently the tool built to analyze and chart benchmarks (e.g. similar to `benchvisual`) +* [ ] more thorough testing for nil / null case +* [ ] ci pipeline to manage releases +* [ ] cleaner mockery generation (doesn't work out of the box for all sub-modules) diff --git a/vendor/github.com/go-openapi/swag/SECURITY.md b/vendor/github.com/go-openapi/swag/SECURITY.md index 1fea2c573..72296a831 100644 --- a/vendor/github.com/go-openapi/swag/SECURITY.md +++ b/vendor/github.com/go-openapi/swag/SECURITY.md @@ -6,32 +6,14 @@ This policy outlines the commitment and practices of the go-openapi maintainers | Version | Supported | | ------- | ------------------ | -| O.x | :white_check_mark: | - -## Vulnerability checks in place - -This repository uses automated vulnerability scans, at every merged commit and at least once a week. - -We use: - -* [`GitHub CodeQL`][codeql-url] -* [`trivy`][trivy-url] -* [`govulncheck`][govulncheck-url] - -Reports are centralized in github security reports and visible only to the maintainers. +| 0.25.x | :white_check_mark: | ## Reporting a vulnerability If you become aware of a security vulnerability that affects the current repository, -**please report it privately to the maintainers** -rather than opening a publicly visible GitHub issue. - -Please follow the instructions provided by github to [Privately report a security vulnerability][github-guidance-url]. +please report it privately to the maintainers. -> [!NOTE] -> On Github, navigate to the project's "Security" tab then click on "Report a vulnerability". +Please follow the instructions provided by github to +[Privately report a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability). -[codeql-url]: https://github.com/github/codeql -[trivy-url]: https://trivy.dev/docs/latest/getting-started -[govulncheck-url]: https://go.dev/blog/govulncheck -[github-guidance-url]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability +TL;DR: on Github, navigate to the project's "Security" tab then click on "Report a vulnerability". diff --git a/vendor/github.com/go-openapi/swag/go.work b/vendor/github.com/go-openapi/swag/go.work index 8537cb2a7..1e537f074 100644 --- a/vendor/github.com/go-openapi/swag/go.work +++ b/vendor/github.com/go-openapi/swag/go.work @@ -17,4 +17,4 @@ use ( ./yamlutils ) -go 1.25.0 +go 1.24.0 diff --git a/vendor/github.com/go-openapi/swag/jsonname/go_name_provider.go b/vendor/github.com/go-openapi/swag/jsonname/go_name_provider.go deleted file mode 100644 index adc442687..000000000 --- a/vendor/github.com/go-openapi/swag/jsonname/go_name_provider.go +++ /dev/null @@ -1,286 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package jsonname - -import ( - "reflect" - "strings" - "sync" -) - -var _ providerIface = (*GoNameProvider)(nil) - -// GoNameProvider resolves json property names to go struct field names following -// the same rules as the standard library's [encoding/json] package. -// -// Contrary to [NameProvider], it considers exported fields without a json tag, -// and promotes fields from anonymous embedded struct types. -// -// Rules (aligned with encoding/json): -// -// - unexported fields are ignored; -// - a field tagged `json:"-"` is ignored; -// - a field tagged `json:"-,"` is kept under the json name "-" (stdlib quirk); -// - a field tagged `json:""` or with no json tag at all keeps its Go name as json name; -// - anonymous struct fields without an explicit json tag have their fields -// promoted into the parent, following breadth-first depth rules: -// a shallower field wins over a deeper one; at equal depth, a conflict -// discards all conflicting fields unless exactly one has an explicit json tag. -// -// This type is safe for concurrent use. -type GoNameProvider struct { - lock sync.Mutex - index map[reflect.Type]nameIndex -} - -// NewGoNameProvider creates a new [GoNameProvider]. -func NewGoNameProvider() *GoNameProvider { - return &GoNameProvider{ - index: make(map[reflect.Type]nameIndex), - } -} - -// GetJSONNames gets all the json property names for a type. -func (n *GoNameProvider) GetJSONNames(subject any) []string { - n.lock.Lock() - defer n.lock.Unlock() - - tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() - names := n.nameIndexFor(tpe) - - res := make([]string, 0, len(names.jsonNames)) - for k := range names.jsonNames { - res = append(res, k) - } - - return res -} - -// GetJSONName gets the json name for a go property name. -func (n *GoNameProvider) GetJSONName(subject any, name string) (string, bool) { - tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() - - return n.GetJSONNameForType(tpe, name) -} - -// GetJSONNameForType gets the json name for a go property name on a given type. -func (n *GoNameProvider) GetJSONNameForType(tpe reflect.Type, name string) (string, bool) { - n.lock.Lock() - defer n.lock.Unlock() - - names := n.nameIndexFor(tpe) - nme, ok := names.goNames[name] - - return nme, ok -} - -// GetGoName gets the go name for a json property name. -func (n *GoNameProvider) GetGoName(subject any, name string) (string, bool) { - tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() - - return n.GetGoNameForType(tpe, name) -} - -// GetGoNameForType gets the go name for a given type for a json property name. -func (n *GoNameProvider) GetGoNameForType(tpe reflect.Type, name string) (string, bool) { - n.lock.Lock() - defer n.lock.Unlock() - - names := n.nameIndexFor(tpe) - nme, ok := names.jsonNames[name] - - return nme, ok -} - -func (n *GoNameProvider) nameIndexFor(tpe reflect.Type) nameIndex { - if names, ok := n.index[tpe]; ok { - return names - } - - names := buildGoNameIndex(tpe) - n.index[tpe] = names - - return names -} - -// fieldEntry captures a candidate field discovered while walking a struct -// along with the indirection path from the root type (used to resolve conflicts -// by depth in the same way encoding/json does). -type fieldEntry struct { - goName string - jsonName string - index []int - tagged bool -} - -func buildGoNameIndex(tpe reflect.Type) nameIndex { - fields := collectGoFields(tpe) - - idx := make(map[string]string, len(fields)) - reverseIdx := make(map[string]string, len(fields)) - for _, f := range fields { - idx[f.jsonName] = f.goName - reverseIdx[f.goName] = f.jsonName - } - - return nameIndex{jsonNames: idx, goNames: reverseIdx} -} - -// collectGoFields walks tpe breadth-first along anonymous struct fields, -// reproducing the field selection performed by encoding/json.typeFields. -func collectGoFields(tpe reflect.Type) []fieldEntry { - if tpe.Kind() != reflect.Struct { - return nil - } - - type queued struct { - typ reflect.Type - index []int - } - - current := []queued{} - next := []queued{{typ: tpe}} - visited := map[reflect.Type]bool{tpe: true} - - var ( - candidates []fieldEntry - count = map[string]int{} - nextCount = map[string]int{} - ) - - for len(next) > 0 { - current, next = next, current[:0] - count, nextCount = nextCount, count - for k := range nextCount { - delete(nextCount, k) - } - - for _, q := range current { - for i := 0; i < q.typ.NumField(); i++ { - sf := q.typ.Field(i) - - if sf.Anonymous { - ft := sf.Type - if ft.Kind() == reflect.Ptr { - ft = ft.Elem() - } - if !sf.IsExported() && ft.Kind() != reflect.Struct { - continue - } - } else if !sf.IsExported() { - continue - } - - tag := sf.Tag.Get("json") - if tag == "-" { - continue - } - jsonName, _ := parseJSONTag(tag) - tagged := jsonName != "" - - ft := sf.Type - if ft.Kind() == reflect.Ptr { - ft = ft.Elem() - } - - if sf.Anonymous && ft.Kind() == reflect.Struct && !tagged { - if visited[ft] { - continue - } - visited[ft] = true - - index := make([]int, len(q.index)+1) - copy(index, q.index) - index[len(q.index)] = i - next = append(next, queued{typ: ft, index: index}) - - continue - } - - name := jsonName - if name == "" { - name = sf.Name - } - - index := make([]int, len(q.index)+1) - copy(index, q.index) - index[len(q.index)] = i - - candidates = append(candidates, fieldEntry{ - goName: sf.Name, - jsonName: name, - index: index, - tagged: tagged, - }) - nextCount[name]++ - } - } - } - - return dominantFields(candidates) -} - -// dominantFields applies the Go encoding/json conflict resolution rules: -// at each JSON name, the shallowest field wins; at equal depth, a uniquely -// tagged candidate wins; otherwise all candidates for that name are dropped. -func dominantFields(candidates []fieldEntry) []fieldEntry { - byName := make(map[string][]fieldEntry, len(candidates)) - for _, c := range candidates { - byName[c.jsonName] = append(byName[c.jsonName], c) - } - - out := make([]fieldEntry, 0, len(byName)) - for _, group := range byName { - if len(group) == 1 { - out = append(out, group[0]) - - continue - } - - minDepth := len(group[0].index) - for _, c := range group[1:] { - if len(c.index) < minDepth { - minDepth = len(c.index) - } - } - - var shallow []fieldEntry - for _, c := range group { - if len(c.index) == minDepth { - shallow = append(shallow, c) - } - } - - if len(shallow) == 1 { - out = append(out, shallow[0]) - - continue - } - - var tagged []fieldEntry - for _, c := range shallow { - if c.tagged { - tagged = append(tagged, c) - } - } - if len(tagged) == 1 { - out = append(out, tagged[0]) - } - } - - return out -} - -// parseJSONTag returns the name component of a json struct tag and whether -// it carried any non-name option (kept for future-proofing, e.g. "omitempty"). -func parseJSONTag(tag string) (string, string) { - if tag == "" { - return "", "" - } - if idx := strings.IndexByte(tag, ','); idx >= 0 { - return tag[:idx], tag[idx+1:] - } - - return tag, "" -} diff --git a/vendor/github.com/go-openapi/swag/jsonname/ifaces.go b/vendor/github.com/go-openapi/swag/jsonname/ifaces.go deleted file mode 100644 index 812ace563..000000000 --- a/vendor/github.com/go-openapi/swag/jsonname/ifaces.go +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package jsonname - -import "reflect" - -// providerIface is an unexported compile-time contract that every name provider -// in this package is expected to satisfy. -// It mirrors the interface declared by the main consumer of this module: [github.com/go-openapi/jsonpointer.NameProvider]. -type providerIface interface { - GetGoName(subject any, name string) (string, bool) - GetGoNameForType(tpe reflect.Type, name string) (string, bool) -} diff --git a/vendor/github.com/go-openapi/swag/jsonname/name_provider.go b/vendor/github.com/go-openapi/swag/jsonname/name_provider.go index 9f5da7a01..8eaf1bece 100644 --- a/vendor/github.com/go-openapi/swag/jsonname/name_provider.go +++ b/vendor/github.com/go-openapi/swag/jsonname/name_provider.go @@ -12,8 +12,6 @@ import ( // DefaultJSONNameProvider is the default cache for types. var DefaultJSONNameProvider = NewNameProvider() -var _ providerIface = (*NameProvider)(nil) - // NameProvider represents an object capable of translating from go property names // to json property names. // diff --git a/vendor/github.com/go-openapi/swag/jsonutils/README.md b/vendor/github.com/go-openapi/swag/jsonutils/README.md index 07a2ca1d7..d745cdb46 100644 --- a/vendor/github.com/go-openapi/swag/jsonutils/README.md +++ b/vendor/github.com/go-openapi/swag/jsonutils/README.md @@ -1,11 +1,11 @@ -# jsonutils + # jsonutils `jsonutils` exposes a few tools to work with JSON: - a fast, simple `Concat` to concatenate (not merge) JSON objects and arrays - `FromDynamicJSON` to convert a data structure into a "dynamic JSON" data structure - `ReadJSON` and `WriteJSON` behave like `json.Unmarshal` and `json.Marshal`, - with the ability to use another underlying serialization library through an `Adapter` + with the ability to use another underlying serialization library through an `Adapter` configured at runtime - a `JSONMapSlice` structure that may be used to store JSON objects with the order of keys maintained @@ -64,7 +64,7 @@ find a registered implementation that support ordered keys in objects. Our standard library implementation supports this. As of `v0.25.0`, we support through such an adapter the popular `mailru/easyjson` -library, which kicks in when the passed values support the `easyjson.Unmarshaler` +library, which kicks in when the passed values support the `easyjson.Unmarshaler` or `easyjson.Marshaler` interfaces. In the future, we plan to add more similar libraries that compete on the go JSON @@ -77,9 +77,8 @@ In package `github.com/go-openapi/swag/easyjson/adapters`, several adapters are Each adapter is an independent go module. Hence you'll pick its dependencies only if you import it. At this moment we provide: - -- `stdlib`: JSON adapter based on the standard library -- `easyjson`: JSON adapter based on the `github.com/mailru/easyjson` +* `stdlib`: JSON adapter based on the standard library +* `easyjson`: JSON adapter based on the `github.com/mailru/easyjson` The adapters provide the basic `Marshal` and `Unmarshal` capabilities, plus an implementation of the `MapSlice` pattern. diff --git a/vendor/github.com/go-openapi/swag/mangling/BENCHMARK.md b/vendor/github.com/go-openapi/swag/mangling/BENCHMARK.md index abe6e9533..6674c63b7 100644 --- a/vendor/github.com/go-openapi/swag/mangling/BENCHMARK.md +++ b/vendor/github.com/go-openapi/swag/mangling/BENCHMARK.md @@ -4,7 +4,7 @@ go test -bench XXX -run XXX -benchtime 30s ``` -## Benchmarks at `b3e7a5386f996177e4808f11acb2aa93a0f660df` +## Benchmarks at b3e7a5386f996177e4808f11acb2aa93a0f660df ``` goos: linux @@ -49,7 +49,7 @@ BenchmarkToXXXName/ToHumanNameLower-16 18599661 1946 ns/op 92 B/op BenchmarkToXXXName/ToHumanNameTitle-16 17581353 2054 ns/op 105 B/op 6 allocs/op ``` -## Benchmarks at `d7d2d1b895f5b6747afaff312dd2a402e69e818b` +## Benchmarks at d7d2d1b895f5b6747afaff312dd2a402e69e818b go1.24 diff --git a/vendor/github.com/google/gnostic-models/extensions/extension.proto b/vendor/github.com/google/gnostic-models/extensions/extension.proto index a60042989..875137c1a 100644 --- a/vendor/github.com/google/gnostic-models/extensions/extension.proto +++ b/vendor/github.com/google/gnostic-models/extensions/extension.proto @@ -42,7 +42,7 @@ option java_package = "org.gnostic.v1"; option objc_class_prefix = "GNX"; // The Go package name. -option go_package = "github.com/google/gnostic-models/extensions;gnostic_extension_v1"; +option go_package = "./extensions;gnostic_extension_v1"; // The version number of Gnostic. message Version { diff --git a/vendor/github.com/google/gnostic-models/openapiv2/OpenAPIv2.proto b/vendor/github.com/google/gnostic-models/openapiv2/OpenAPIv2.proto index 49adafcc8..1c59b2f4a 100644 --- a/vendor/github.com/google/gnostic-models/openapiv2/OpenAPIv2.proto +++ b/vendor/github.com/google/gnostic-models/openapiv2/OpenAPIv2.proto @@ -42,7 +42,7 @@ option java_package = "org.openapi_v2"; option objc_class_prefix = "OAS"; // The Go package name. -option go_package = "github.com/google/gnostic-models/openapiv2;openapi_v2"; +option go_package = "./openapiv2;openapi_v2"; message AdditionalPropertiesItem { oneof oneof { diff --git a/vendor/github.com/google/gnostic-models/openapiv3/OpenAPIv3.proto b/vendor/github.com/google/gnostic-models/openapiv3/OpenAPIv3.proto index af4b6254b..1be335b89 100644 --- a/vendor/github.com/google/gnostic-models/openapiv3/OpenAPIv3.proto +++ b/vendor/github.com/google/gnostic-models/openapiv3/OpenAPIv3.proto @@ -42,7 +42,7 @@ option java_package = "org.openapi_v3"; option objc_class_prefix = "OAS"; // The Go package name. -option go_package = "github.com/google/gnostic-models/openapiv3;openapi_v3"; +option go_package = "./openapiv3;openapi_v3"; message AdditionalPropertiesItem { oneof oneof { diff --git a/vendor/github.com/google/gnostic-models/openapiv3/annotations.proto b/vendor/github.com/google/gnostic-models/openapiv3/annotations.proto index 895b4567c..09ee0aac5 100644 --- a/vendor/github.com/google/gnostic-models/openapiv3/annotations.proto +++ b/vendor/github.com/google/gnostic-models/openapiv3/annotations.proto @@ -20,7 +20,7 @@ import "google/protobuf/descriptor.proto"; import "openapiv3/OpenAPIv3.proto"; // The Go package name. -option go_package = "github.com/google/gnostic-models/openapiv3;openapi_v3"; +option go_package = "./openapiv3;openapi_v3"; // This option lets the proto compiler generate Java code inside the package // name (see below) instead of inside an outer class. It creates a simpler // developer experience by reducing one-level of name nesting and be diff --git a/vendor/modules.txt b/vendor/modules.txt index f2334b9f5..13877b16e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -367,7 +367,7 @@ github.com/felixge/httpsnoop ## explicit; go 1.17 github.com/fsnotify/fsnotify github.com/fsnotify/fsnotify/internal -# github.com/fxamacker/cbor/v2 v2.9.1 +# github.com/fxamacker/cbor/v2 v2.9.0 ## explicit; go 1.20 github.com/fxamacker/cbor/v2 # github.com/go-jose/go-jose/v4 v4.1.4 @@ -390,53 +390,53 @@ github.com/go-logr/zapr ## explicit; go 1.12 github.com/go-ole/go-ole github.com/go-ole/go-ole/oleutil -# github.com/go-openapi/jsonpointer v0.23.1 -## explicit; go 1.25.0 +# github.com/go-openapi/jsonpointer v0.22.4 +## explicit; go 1.24.0 github.com/go-openapi/jsonpointer -# github.com/go-openapi/jsonreference v0.21.5 +# github.com/go-openapi/jsonreference v0.21.4 ## explicit; go 1.24.0 github.com/go-openapi/jsonreference github.com/go-openapi/jsonreference/internal -# github.com/go-openapi/swag v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag -# github.com/go-openapi/swag/cmdutils v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/cmdutils v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/cmdutils -# github.com/go-openapi/swag/conv v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/conv v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/conv -# github.com/go-openapi/swag/fileutils v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/fileutils v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/fileutils -# github.com/go-openapi/swag/jsonname v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/jsonname v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/jsonname -# github.com/go-openapi/swag/jsonutils v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/jsonutils v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/jsonutils github.com/go-openapi/swag/jsonutils/adapters github.com/go-openapi/swag/jsonutils/adapters/ifaces github.com/go-openapi/swag/jsonutils/adapters/stdlib/json -# github.com/go-openapi/swag/loading v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/loading v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/loading -# github.com/go-openapi/swag/mangling v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/mangling v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/mangling -# github.com/go-openapi/swag/netutils v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/netutils v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/netutils -# github.com/go-openapi/swag/stringutils v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/stringutils v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/stringutils -# github.com/go-openapi/swag/typeutils v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/typeutils v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/typeutils -# github.com/go-openapi/swag/yamlutils v0.26.0 -## explicit; go 1.25.0 +# github.com/go-openapi/swag/yamlutils v0.25.4 +## explicit; go 1.24.0 github.com/go-openapi/swag/yamlutils -# github.com/google/gnostic-models v0.7.1 +# github.com/google/gnostic-models v0.7.0 ## explicit; go 1.22 github.com/google/gnostic-models/compiler github.com/google/gnostic-models/extensions @@ -1681,7 +1681,7 @@ k8s.io/klog/v2/internal/severity k8s.io/klog/v2/internal/sloghandler k8s.io/klog/v2/internal/verbosity k8s.io/klog/v2/textlogger -# k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 +# k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a ## explicit; go 1.23.0 k8s.io/kube-openapi/pkg/cached k8s.io/kube-openapi/pkg/common @@ -1775,7 +1775,7 @@ sigs.k8s.io/json/internal/golang/encoding/json ## explicit; go 1.18 sigs.k8s.io/randfill sigs.k8s.io/randfill/bytesource -# sigs.k8s.io/structured-merge-diff/v6 v6.4.0 +# sigs.k8s.io/structured-merge-diff/v6 v6.3.2 ## explicit; go 1.23 sigs.k8s.io/structured-merge-diff/v6/fieldpath sigs.k8s.io/structured-merge-diff/v6/merge diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/element.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/element.go index f1607601c..73436912c 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/element.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/element.go @@ -19,7 +19,7 @@ package fieldpath import ( "fmt" "iter" - "slices" + "sort" "strings" "sigs.k8s.io/structured-merge-diff/v6/value" @@ -255,13 +255,19 @@ func (s PathElementSet) Copy() PathElementSet { // Insert adds pe to the set. func (s *PathElementSet) Insert(pe PathElement) { - loc, found := slices.BinarySearchFunc(s.members, pe, func(a, b PathElement) int { - return a.Compare(b) + loc := sort.Search(len(s.members), func(i int) bool { + return !s.members[i].Less(pe) }) - if found { + if loc == len(s.members) { + s.members = append(s.members, pe) return } - s.members = slices.Insert(s.members, loc, pe) + if s.members[loc].Equals(pe) { + return + } + s.members = append(s.members, PathElement{}) + copy(s.members[loc+1:], s.members[loc:]) + s.members[loc] = pe } // Union returns a set containing elements that appear in either s or s2. @@ -338,10 +344,16 @@ func (s *PathElementSet) Size() int { return len(s.members) } // Has returns true if pe is a member of the set. func (s *PathElementSet) Has(pe PathElement) bool { - _, found := slices.BinarySearchFunc(s.members, pe, func(a, b PathElement) int { - return a.Compare(b) + loc := sort.Search(len(s.members), func(i int) bool { + return !s.members[i].Less(pe) }) - return found + if loc == len(s.members) { + return false + } + if s.members[loc].Equals(pe) { + return true + } + return false } // Equals returns true if s and s2 have exactly the same members. diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/pathelementmap.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/pathelementmap.go index ca50b84e9..ff7ee510c 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/pathelementmap.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/pathelementmap.go @@ -17,7 +17,7 @@ limitations under the License. package fieldpath import ( - "slices" + "sort" "sigs.k8s.io/structured-merge-diff/v6/value" ) @@ -82,23 +82,32 @@ func MakePathElementMap(size int) PathElementMap { // Insert adds the pathelement and associated value in the map. // If insert is called twice with the same PathElement, the value is replaced. func (s *PathElementMap) Insert(pe PathElement, v interface{}) { - loc, found := slices.BinarySearchFunc(s.members, pe, func(a pathElementValue, b PathElement) int { - return a.PathElement.Compare(b) + loc := sort.Search(len(s.members), func(i int) bool { + return !s.members[i].PathElement.Less(pe) }) - if found { + if loc == len(s.members) { + s.members = append(s.members, pathElementValue{pe, v}) + return + } + if s.members[loc].PathElement.Equals(pe) { s.members[loc].Value = v return } - s.members = slices.Insert(s.members, loc, pathElementValue{PathElement: pe, Value: v}) + s.members = append(s.members, pathElementValue{}) + copy(s.members[loc+1:], s.members[loc:]) + s.members[loc] = pathElementValue{pe, v} } // Get retrieves the value associated with the given PathElement from the map. // (nil, false) is returned if there is no such PathElement. func (s *PathElementMap) Get(pe PathElement) (interface{}, bool) { - loc, found := slices.BinarySearchFunc(s.members, pe, func(a pathElementValue, b PathElement) int { - return a.PathElement.Compare(b) + loc := sort.Search(len(s.members), func(i int) bool { + return !s.members[i].PathElement.Less(pe) }) - if found { + if loc == len(s.members) { + return nil, false + } + if s.members[loc].PathElement.Equals(pe) { return s.members[loc].Value, true } return nil, false diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/set.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/set.go index d7fe643be..d2d8c8a42 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/set.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/fieldpath/set.go @@ -19,7 +19,6 @@ package fieldpath import ( "fmt" "iter" - "slices" "sort" "strings" @@ -487,13 +486,19 @@ func (s *SetNodeMap) Copy() SetNodeMap { // Descend adds pe to the set if necessary, returning the associated subset. func (s *SetNodeMap) Descend(pe PathElement) *Set { - loc, found := slices.BinarySearchFunc(s.members, pe, func(a setNode, b PathElement) int { - return a.pathElement.Compare(b) + loc := sort.Search(len(s.members), func(i int) bool { + return !s.members[i].pathElement.Less(pe) }) - if found { + if loc == len(s.members) { + s.members = append(s.members, setNode{pathElement: pe, set: &Set{}}) return s.members[loc].set } - s.members = slices.Insert(s.members, loc, setNode{pathElement: pe, set: &Set{}}) + if s.members[loc].pathElement.Equals(pe) { + return s.members[loc].set + } + s.members = append(s.members, setNode{}) + copy(s.members[loc+1:], s.members[loc:]) + s.members[loc] = setNode{pathElement: pe, set: &Set{}} return s.members[loc].set } @@ -518,10 +523,13 @@ func (s *SetNodeMap) Empty() bool { // Get returns (the associated set, true) or (nil, false) if there is none. func (s *SetNodeMap) Get(pe PathElement) (*Set, bool) { - loc, found := slices.BinarySearchFunc(s.members, pe, func(a setNode, b PathElement) int { - return a.pathElement.Compare(b) + loc := sort.Search(len(s.members), func(i int) bool { + return !s.members[i].pathElement.Less(pe) }) - if found { + if loc == len(s.members) { + return nil, false + } + if s.members[loc].pathElement.Equals(pe) { return s.members[loc].set, true } return nil, false diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/value/allocator.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/value/allocator.go index acbfe8ceb..f70cd4167 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/value/allocator.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/value/allocator.go @@ -16,8 +16,6 @@ limitations under the License. package value -import "reflect" - // Allocator provides a value object allocation strategy. // Value objects can be allocated by passing an allocator to the "Using" // receiver functions on the value interfaces, e.g. Map.ZipUsing(allocator, ...). @@ -26,8 +24,8 @@ import "reflect" type Allocator interface { // Free gives the allocator back any value objects returned by the "Using" // receiver functions on the value interfaces. - // any may be any of: Value, Map, List or Range. - Free(any) + // interface{} may be any of: Value, Map, List or Range. + Free(interface{}) // The unexported functions are for "Using" receiver functions of the value types // to request what they need from the allocator. @@ -76,7 +74,7 @@ func (p *heapAllocator) allocListReflectRange() *listReflectRange { return &listReflectRange{vr: &valueReflect{}} } -func (p *heapAllocator) Free(_ any) {} +func (p *heapAllocator) Free(_ interface{}) {} // NewFreelistAllocator creates freelist based allocator. // This allocator provides fast allocation and freeing of short lived value objects. @@ -91,25 +89,25 @@ func (p *heapAllocator) Free(_ any) {} // for all temporary value access. func NewFreelistAllocator() Allocator { return &freelistAllocator{ - valueUnstructured: &freelist[*valueUnstructured]{new: func() *valueUnstructured { + valueUnstructured: &freelist{new: func() interface{} { return &valueUnstructured{} }}, - listUnstructuredRange: &freelist[*listUnstructuredRange]{new: func() *listUnstructuredRange { + listUnstructuredRange: &freelist{new: func() interface{} { return &listUnstructuredRange{vv: &valueUnstructured{}} }}, - valueReflect: &freelist[*valueReflect]{new: func() *valueReflect { + valueReflect: &freelist{new: func() interface{} { return &valueReflect{} }}, - mapReflect: &freelist[*mapReflect]{new: func() *mapReflect { + mapReflect: &freelist{new: func() interface{} { return &mapReflect{} }}, - structReflect: &freelist[*structReflect]{new: func() *structReflect { + structReflect: &freelist{new: func() interface{} { return &structReflect{} }}, - listReflect: &freelist[*listReflect]{new: func() *listReflect { + listReflect: &freelist{new: func() interface{} { return &listReflect{} }}, - listReflectRange: &freelist[*listReflectRange]{new: func() *listReflectRange { + listReflectRange: &freelist{new: func() interface{} { return &listReflectRange{vr: &valueReflect{}} }}, } @@ -121,22 +119,22 @@ func NewFreelistAllocator() Allocator { const freelistMaxSize = 1000 type freelistAllocator struct { - valueUnstructured *freelist[*valueUnstructured] - listUnstructuredRange *freelist[*listUnstructuredRange] - valueReflect *freelist[*valueReflect] - mapReflect *freelist[*mapReflect] - structReflect *freelist[*structReflect] - listReflect *freelist[*listReflect] - listReflectRange *freelist[*listReflectRange] + valueUnstructured *freelist + listUnstructuredRange *freelist + valueReflect *freelist + mapReflect *freelist + structReflect *freelist + listReflect *freelist + listReflectRange *freelist } -type freelist[T any] struct { - list []T - new func() T +type freelist struct { + list []interface{} + new func() interface{} } -func (f *freelist[T]) allocate() T { - var w2 T +func (f *freelist) allocate() interface{} { + var w2 interface{} if n := len(f.list); n > 0 { w2, f.list = f.list[n-1], f.list[:n-1] } else { @@ -145,73 +143,61 @@ func (f *freelist[T]) allocate() T { return w2 } -func (f *freelist[T]) free(v T) { +func (f *freelist) free(v interface{}) { if len(f.list) < freelistMaxSize { f.list = append(f.list, v) } } -func (w *freelistAllocator) Free(value any) { +func (w *freelistAllocator) Free(value interface{}) { switch v := value.(type) { case *valueUnstructured: v.Value = nil // don't hold references to unstructured objects w.valueUnstructured.free(v) case *listUnstructuredRange: - v.list = nil // don't hold references to unstructured objects v.vv.Value = nil // don't hold references to unstructured objects w.listUnstructuredRange.free(v) case *valueReflect: - v.ParentMap = nil v.ParentMapKey = nil - v.Value = reflect.Value{} // don't hold references to reflected objects + v.ParentMap = nil w.valueReflect.free(v) case *mapReflect: - v.valueReflect.ParentMap = nil - v.valueReflect.ParentMapKey = nil - v.valueReflect.Value = reflect.Value{} // don't hold references to reflected objects w.mapReflect.free(v) case *structReflect: - v.valueReflect.ParentMap = nil - v.valueReflect.ParentMapKey = nil - v.valueReflect.Value = reflect.Value{} // don't hold references to reflected objects w.structReflect.free(v) case *listReflect: - v.Value = reflect.Value{} // don't hold references to reflected objects w.listReflect.free(v) case *listReflectRange: - v.list = reflect.Value{} // don't hold references to reflected objects - v.vr.ParentMap = nil v.vr.ParentMapKey = nil - v.vr.Value = reflect.Value{} // don't hold references to reflected objects - v.entry = nil + v.vr.ParentMap = nil w.listReflectRange.free(v) } } func (w *freelistAllocator) allocValueUnstructured() *valueUnstructured { - return w.valueUnstructured.allocate() + return w.valueUnstructured.allocate().(*valueUnstructured) } func (w *freelistAllocator) allocListUnstructuredRange() *listUnstructuredRange { - return w.listUnstructuredRange.allocate() + return w.listUnstructuredRange.allocate().(*listUnstructuredRange) } func (w *freelistAllocator) allocValueReflect() *valueReflect { - return w.valueReflect.allocate() + return w.valueReflect.allocate().(*valueReflect) } func (w *freelistAllocator) allocStructReflect() *structReflect { - return w.structReflect.allocate() + return w.structReflect.allocate().(*structReflect) } func (w *freelistAllocator) allocMapReflect() *mapReflect { - return w.mapReflect.allocate() + return w.mapReflect.allocate().(*mapReflect) } func (w *freelistAllocator) allocListReflect() *listReflect { - return w.listReflect.allocate() + return w.listReflect.allocate().(*listReflect) } func (w *freelistAllocator) allocListReflectRange() *listReflectRange { - return w.listReflectRange.allocate() + return w.listReflectRange.allocate().(*listReflectRange) } diff --git a/vendor/sigs.k8s.io/structured-merge-diff/v6/value/jsontagutil.go b/vendor/sigs.k8s.io/structured-merge-diff/v6/value/jsontagutil.go index db2561373..3aadceb22 100644 --- a/vendor/sigs.k8s.io/structured-merge-diff/v6/value/jsontagutil.go +++ b/vendor/sigs.k8s.io/structured-merge-diff/v6/value/jsontagutil.go @@ -76,16 +76,11 @@ func OmitZeroFunc(t reflect.Type) func(reflect.Value) bool { // but is based on the highly efficient approach from https://golang.org/src/encoding/json/encode.go func lookupJsonTags(f reflect.StructField) (name string, omit bool, inline bool, omitempty bool, omitzero func(reflect.Value) bool) { - // Skip unexported non-embedded fields, matching encoding/json behavior. - if f.PkgPath != "" && !f.Anonymous { - return "", true, false, false, nil - } tag := f.Tag.Get("json") if tag == "-" { return "", true, false, false, nil } name, opts := parseTag(tag) - inline = f.Anonymous && name == "" if name == "" { name = f.Name } @@ -94,7 +89,7 @@ func lookupJsonTags(f reflect.StructField) (name string, omit bool, inline bool, omitzero = OmitZeroFunc(f.Type) } - return name, false, inline, opts.Contains("omitempty"), omitzero + return name, false, opts.Contains("inline"), opts.Contains("omitempty"), omitzero } func isEmpty(v reflect.Value) bool {