diff --git a/cmd/galactic-router/root.go b/cmd/galactic-router/root.go index 5009e21b..b73e5f90 100644 --- a/cmd/galactic-router/root.go +++ b/cmd/galactic-router/root.go @@ -18,13 +18,16 @@ import ( grpchealth "google.golang.org/grpc/health" "google.golang.org/grpc/health/grpc_health_v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" + ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/controller" "go.datum.net/galactic/internal/hash" @@ -35,6 +38,7 @@ import ( "go.datum.net/galactic/internal/reconcile" galacticruntime "go.datum.net/galactic/internal/runtime" "go.datum.net/galactic/internal/runtime/gobgp" + "go.datum.net/galactic/internal/serviceroute" networkwebhook "go.datum.net/galactic/internal/webhook" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -80,6 +84,7 @@ func runCmd(cfg *config.RouterConfig) error { scheme := runtime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(bgpv1alpha1.AddToScheme(scheme)) + utilruntime.Must(cloudv1alpha1.AddToScheme(scheme)) mgrOptions := ctrl.Options{ Scheme: scheme, @@ -102,6 +107,8 @@ func runCmd(cfg *config.RouterConfig) error { if err != nil { return fmt.Errorf("create manager: %w", err) } + serviceRouteMetrics := serviceroute.NewMetrics() + serviceRouteMetrics.MustRegister(ctrlmetrics.Registry) // ctx carries a cause so an ordinary signal-triggered shutdown can be told // apart from the health server's own Serve failure below. See the cause @@ -264,6 +271,20 @@ func runCmd(cfg *config.RouterConfig) error { return fmt.Errorf("setup ServiceVIPBinding controller: %w", err) } + // Register the node-local platform service route controller. It resolves + // ServiceRoutePolicy against Cloud VPCAttachment.status.node and programs + // only this node's local routes. + if err := (&controller.ServiceRoutePolicyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + NodeName: nodeName, + Programmer: serviceroute.LinuxRouteProgrammer{}, + Metrics: serviceRouteMetrics, + Applied: make(map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent), + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("setup ServiceRoutePolicy controller: %w", err) + } + // Register GC controller for cleaning up orphaned BGP CRDs and VRFs. gcRec := &controller.GCReconciler{ Client: mgr.GetClient(), diff --git a/config/galactic-router/rbac.yaml b/config/galactic-router/rbac.yaml index 8e312570..5d5d47c5 100644 --- a/config/galactic-router/rbac.yaml +++ b/config/galactic-router/rbac.yaml @@ -33,6 +33,11 @@ rules: resources: - servicevipbindings verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["network.datumapis.com"] + resources: + - serviceendpoints + - serviceroutepolicies + verbs: ["get", "list", "watch"] - apiGroups: ["network.datumapis.com"] resources: - bgprouters/status @@ -42,6 +47,10 @@ rules: - bgpvrfinstances/status - servicevipbindings/status verbs: ["get", "update", "patch"] + - apiGroups: ["cloud.datumapis.com"] + resources: + - vpcattachments + verbs: ["get", "list", "watch"] - apiGroups: [""] resources: - secrets diff --git a/docs/enhancements/dns64/README.md b/docs/enhancements/dns64/README.md index c80d8a83..5e94da7a 100644 --- a/docs/enhancements/dns64/README.md +++ b/docs/enhancements/dns64/README.md @@ -1,13 +1,15 @@ # Shared DNS64 service -Status: Proposed. This design has not passed end-to-end Galactic validation. +Status: Prototype. Galactic implements same-node service routing. Production +DNS64/NAT64 packet-path validation is not complete. The prototype is not +production-ready. ## Summary -Provide a managed DNS64 service for IPv6-only Compute instances. PowerDNS -Recursor resolves public names and synthesizes AAAA records from A records -when a name has no usable native AAAA record. Galactic's NAT64 gateway -translates connections to IPv4. +This design proposes a managed DNS64 service for IPv6-only Compute instances. +PowerDNS Recursor resolves public names and synthesizes AAAA records from +A records when a name has no usable native AAAA record. Galactic's NAT64 +gateway translates connections to IPv4. The initial service uses one shared `/96` NAT64 prefix per serving fabric and a shared regional resolver fleet. The planning range is 10,000–100,000 tenants; @@ -44,7 +46,7 @@ Galactic routing and NAT64 path. - Make IPv4 literals work or create connectivity to unreachable destinations. - Preserve established connections when a NAT64 gateway loses session state. -## High-level architectural proposal +## Proposed architecture Use PowerDNS Recursor as the short-term recursive DNS64 engine. Its native `recursor.dns64_prefix` setting supplies the shared `/96` prefix and performs @@ -63,16 +65,14 @@ may reach it. [Container diagram source](containers.puml). -The container diagram is the component and relationship reference. The -resolver service and PowerDNS Recursor are shared regionally; the NAT64 path is -separate from the DNS query path. +The diagrams describe the proposed product architecture. ### Endpoint access and resource controls -The resolver VIP is a managed network service. Galactic authorizes the workload -attachment and routes only eligible network traffic to the endpoint. The -attachment and resolver service must reject spoofed sources and unauthorized -queries. +The proposed resolver VIP is a managed network service. Galactic selects +eligible workload attachments for routing. The attachment and resolver service +must reject spoofed sources and unauthorized queries. The current route +programmer does not enforce the policy's protocol and port restrictions. The first milestone does not require a resolver process or cache per tenant. Bound shard memory, connections, outstanding queries, and query rates. Apply @@ -96,10 +96,82 @@ DNS does not select a NAT64 gateway, the synthesized address uses the shared prefix, and application traffic returns through the same translation path. The [CNI route implementation][cni-routes] and [NAT64 gateway design][nat64-design] -define the existing networking side of this flow. Resolver endpoint delivery -remains a proposed integration. - -### Shared prefix and routing contract +define the egress side of this flow. The service-route prototype supplies the +local resolver route; end-to-end DNS64 integration remains planned. + +### Current service-route prototype + +The [Network API][service-route-api] defines two internal resources: + +- `ServiceEndpoint` publishes a stable service address, protocol, and port. + Its `attachmentRef` identifies the Cloud API `VPCAttachment` that hosts the + service. The current Galactic implementation requires this reference. +- `ServiceRoutePolicy` references an endpoint in the same namespace and selects + consumer attachments by label. The network control plane must assign labels + that grant service access. + +These resources support shared services without a customer-facing DNS64 API +or a Kubernetes route resource for every attachment or node. Policy status +holds durable conditions, not attachment counts. The current reconciler does +not publish those conditions. + +[Galactic's service-route controller][service-route-programming] runs inside +`galactic-router`. It watches both resources and Cloud `VPCAttachment` changes, +including `VPCAttachment.status.node`, the authoritative placement field. +Router RBAC grants read and watch access to these APIs. Each router reconciles +only attachments assigned to its node. The service attachment must also be on +that node. + +For each selected consumer, Galactic uses attachment status to resolve the +consumer subnet, both VPCs, and their host interfaces. On Linux, it programs +bidirectional routes in the VPC virtual routing and forwarding (VRF) tables: + +- The consumer VPC table routes the endpoint address to the service interface. +- The service VPC table routes the consumer subnet to the consumer interface. + +Galactic also programs matching eBPF pass-through route-map entries in both +VRFs. These entries keep service traffic on the local Linux routing path +instead of sending it through an egress gateway. NAT64 translation, NAT64 +routes, and default-route programming remain outside this controller. + +### Route cleanup and rollback + +After it successfully computes the desired routes, the controller removes the +policy's previously tracked routes and pass-through entries before applying +the replacement. This removes stale routes when selection or attachment +placement changes. Policy deletion also triggers cleanup of tracked routes. + +Updates are not atomic. If the return route fails to install, the programmer +attempts to remove the forward route. If the service-side pass-through entry +fails, it attempts to remove the consumer-side entry, but leaves the Linux +routes installed. Rollback does not restore the previous policy state. + +Cleanup relies on in-memory tracking. Restarting the controller loses that +tracking, and failed updates can leave partial state. Endpoint lookup or route +planning errors leave existing routes in place; deleting an endpoint alone +does not withdraw them. Recovery from these cases remains a prototype limit. + +### Current routing observability + +[Service-route metrics][service-route-observability] use the existing +`galactic-router` Prometheus endpoint. They report reconciliation outcomes and +duration, route apply and remove outcomes, and the number of locally tracked +route intents. Labels use only bounded result and operation values. Resource +identities stay out of metric labels to keep cardinality bounded. + +Structured logs include policy and node context, endpoint context where +available, compiled intent counts, and errors from lookup, planning, +programming, or cleanup. The route count reflects controller tracking; it does +not prove resolver health or packet delivery. + +### Planned DNS64 readiness and prefix contract + +DNS64 readiness requires agreement between the resolver VIP, the Galactic +route to that VIP, and the shared NAT64 prefix. The VIP supplied to the instance +must match the routed endpoint. The resolver's synthesis prefix must match +the prefix routed to the assigned NAT64 gateway. A reachable resolver alone +does not establish DNS64 readiness. The service-route controller does not +implement this readiness check. Use one Datum-managed `/96` prefix shared across the fabric. Read it from [`EgressShard.status.nat64Prefix`][egress-status]; do not allocate prefixes in @@ -116,7 +188,7 @@ reason. Native IPv6 resolution can continue. Coordinate prefix changes with route retention for cached synthesized answers; clients can retain earlier answers until their TTL expires. -### Synthesis and DNS security +### Planned synthesis and DNS security Follow [RFC 6147][dns64-rfc]. Synthesize only for an AAAA query when eligible A records exist and no usable native AAAA record exists. Preserve NXDOMAIN, @@ -134,20 +206,20 @@ resolution. Apply ACLs, response-rate limiting, maximum response sizes, and resource limits to reduce cache-poisoning, amplification, and denial-of- service risk. -### Configuration and rollout +### Planned configuration and rollout -The DNS service controller distributes versioned fleet configuration to the -assigned resolver shards. The initial configuration contains the shared +The proposed DNS service controller distributes versioned fleet configuration +to the assigned resolver shards. The initial configuration contains the shared prefix, public upstream policy, ACLs, synthesis exclusions, resource limits, and observability settings. It does not manage tenant zones or tenant DNS records. Validate each revision before activation and retain the previous revision for rollback. Drain a replica before upgrade, verify health and synthesis, then -return it to service. Admit a shard only after its configuration, resolver -health, and NAT64 prefix agree with the active revision. +return it to service. Admit a shard only after its configuration and resolver +health pass the DNS64 readiness checks. -### Availability, capacity, and operations +### Planned availability and capacity Place resolver replicas across failure domains within each region. Keep enough spare capacity to lose a replica. Use a stable IPv6 VIP with health-aware @@ -161,16 +233,21 @@ synthesis rate, cache hit rate, resolver resource use, and NAT64 connection success. Bound metric cardinality and query-log retention. Add tenant-level accounting only when quotas or billing require it. -## Implementation boundary +## Limitations and validation + +The current implementation supports same-node consumer and service attachments +only. Cross-node service routing is future work. Health-based endpoint +withdrawal is not implemented. Route programming requires Linux. The existing prototype exercised dnsdist, BIND, and Jool in a controlled lab. -That implementation is not the short-term production design. The new prototype +That implementation is not the proposed production design. The DNS64 prototype must exercise PowerDNS Recursor with the shared prefix and the intended Galactic/NAT64 path. Jool remains a test fixture only. -The Galactic compatibility profile validates saved configuration offline; it -does not prove the Galactic packet path. Controlled upstreams and same-host -replicas do not establish internet-resolution performance or failure-domain +Production DNS64/NAT64 packet-path validation is not complete. The Galactic +compatibility profile validates saved configuration offline; it does not prove +the Galactic packet path. Controlled upstreams and same-host replicas do not +establish internet-resolution performance or failure-domain availability. Galactic source reviewed on September 16, 2026, supports TCP and UDP. It lacks @@ -213,3 +290,6 @@ plantuml -tsvg docs/enhancements/dns64/containers.puml docs/enhancements/dns64/r [nat64-constraints]: https://github.com/datum-cloud/enhancements/blob/a778a6c3104b60b0e7634af4f3e5d2e3ae303549/enhancements/networking/nat64-gateway-for-vpc-networks.md#L184-L198 [dns64-rfc]: https://www.rfc-editor.org/rfc/rfc6147.html [nat64-rfc]: https://www.rfc-editor.org/rfc/rfc6146.html +[service-route-api]: https://github.com/datum-cloud/network/pull/27 +[service-route-programming]: https://github.com/datum-cloud/galactic/pull/634 +[service-route-observability]: https://github.com/datum-cloud/galactic/pull/635 diff --git a/go.mod b/go.mod index f45eb1be..32190e5c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go.datum.net/galactic -go 1.26.0 +go 1.26.4 require ( github.com/cilium/ebpf v0.22.0 @@ -19,7 +19,8 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/vishvananda/netlink v1.3.2-0.20260831221819-dcee5577542a - go.datum.net/network v0.1.0 + go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c + go.datum.net/network v0.1.1-0.20260930210653-3a8d02541780 golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 google.golang.org/grpc v1.84.0 @@ -40,23 +41,32 @@ require ( github.com/eapache/queue v1.1.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.26.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // 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/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/k-sone/critbitgo v1.4.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mdlayher/socket v0.6.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect @@ -91,19 +101,18 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.41.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.48.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.7.0 // indirect - k8s.io/apiextensions-apiserver v0.36.0 // indirect + k8s.io/apiextensions-apiserver v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/knftables v0.0.18 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index fbc05ed9..2e4e4597 100644 --- a/go.sum +++ b/go.sum @@ -15,7 +15,6 @@ github.com/containernetworking/plugins v1.9.1/go.mod h1:fj7kS55qg3o/RgS+WGsF3+Zx github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -34,8 +33,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -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/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= 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/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= @@ -46,14 +45,40 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +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.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +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.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -64,8 +89,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -77,8 +102,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -89,11 +112,8 @@ github.com/kenshaw/baseconv v0.1.1 h1:oAu/C7ipUT2PqT9DT0mZDGDg4URIglizZMjPv9oCu0 github.com/kenshaw/baseconv v0.1.1/go.mod h1:yy9zGmnnR6vgOxOQb702nVdAG30JhyYZpj/5/m0siRI= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -102,14 +122,10 @@ github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffkt github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lorenzosaino/go-sysctl v0.3.1 h1:3phX80tdITw2fJjZlwbXQnDWs4S30beNcMbw0cn0HtY= github.com/lorenzosaino/go-sysctl v0.3.1/go.mod h1:5grcsBRpspKknNS1qzt1eIeRDLrhpKZAtz8Fcuvs1Rc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o= github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA= github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs= github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM= -github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= -github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= @@ -168,14 +184,9 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -186,18 +197,16 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go.datum.net/network v0.1.0 h1:AmYSwxUWOk26UnK6S6NA7OuucGJniKo/CWqjs+VcSCs= -go.datum.net/network v0.1.0/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE= +go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c h1:k0P5980+kS90zCa0M4MohyS0woJL3bh/uEarRrW9PmY= +go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c/go.mod h1:HTeaepXL0EqiqNsQ13K4/kGEwv+VLJ//BGrUfVIFfEA= +go.datum.net/network v0.1.1-0.20260930210653-3a8d02541780 h1:RPC/5vwvalvHlVzCRL55XH6Wyvgi7GwQ4m4mYLvz39A= +go.datum.net/network v0.1.1-0.20260930210653-3a8d02541780/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -237,24 +246,20 @@ golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= @@ -266,25 +271,24 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= -k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= -k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= +k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= +k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= 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/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= 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/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/internal/controller/serviceroute_controller.go b/internal/controller/serviceroute_controller.go new file mode 100644 index 00000000..d16ca716 --- /dev/null +++ b/internal/controller/serviceroute_controller.go @@ -0,0 +1,188 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package controller + +import ( + "context" + "fmt" + "sync" + "time" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + + "go.datum.net/galactic/internal/serviceroute" +) + +// ServiceRoutePolicyReconciler resolves service policies against local Cloud +// API attachments and programs the resulting bidirectional routes directly on +// this node. It intentionally does not create child Kubernetes resources. +type ServiceRoutePolicyReconciler struct { + client.Client + Scheme *runtime.Scheme + NodeName string + Programmer serviceroute.RouteProgrammer + Metrics *serviceroute.Metrics + + mu sync.Mutex + Applied map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent +} + +// Reconcile resolves one policy and replaces the local routes previously +// programmed for that policy. +func (r *ServiceRoutePolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + start := time.Now() + result := "success" + defer func() { r.Metrics.ObserveReconcile(result, time.Since(start)) }() + logger := log.FromContext(ctx).WithValues("policy", req.NamespacedName.String(), "node", r.NodeName) + policy := &networkv1alpha1.ServiceRoutePolicy{} + if err := r.Get(ctx, req.NamespacedName, policy); err != nil { + if apierrors.IsNotFound(err) { + err := r.removePolicy(req.NamespacedName) + if err != nil { + result = "error" + logger.Error(err, "remove routes for deleted policy") + } + return ctrl.Result{}, err + } + result = "error" + logger.Error(err, "get ServiceRoutePolicy") + return ctrl.Result{}, err + } + endpoint := &networkv1alpha1.ServiceEndpoint{} + if err := r.Get(ctx, types.NamespacedName{Namespace: policy.Namespace, Name: policy.Spec.ServiceRef.Name}, endpoint); err != nil { + result = "error" + logger.Error(err, "get ServiceEndpoint", "endpoint", policy.Spec.ServiceRef.Name) + return ctrl.Result{}, fmt.Errorf("get ServiceEndpoint %s/%s: %w", policy.Namespace, policy.Spec.ServiceRef.Name, err) + } + attachments := &cloudv1alpha1.VPCAttachmentList{} + if err := r.List(ctx, attachments); err != nil { + result = "error" + logger.Error(err, "list VPCAttachments") + return ctrl.Result{}, fmt.Errorf("list VPCAttachments: %w", err) + } + local := make([]*cloudv1alpha1.VPCAttachment, 0, len(attachments.Items)) + for i := range attachments.Items { + if attachments.Items[i].Status.Node == r.NodeName { + local = append(local, &attachments.Items[i]) + } + } + intents, err := serviceroute.Compile(policy, endpoint, local, r.NodeName) + if err != nil { + result = "error" + logger.Error(err, "compile service route intents", "endpoint", endpoint.Name) + return ctrl.Result{}, err + } + logger.Info("compiled service route intents", "endpoint", endpoint.Name, "intents", len(intents)) + if err := r.replacePolicy(req.NamespacedName, intents); err != nil { + result = "error" + logger.Error(err, "program service route intents", "endpoint", endpoint.Name) + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// SetupWithManager watches policies, endpoints, and attachments. Events from +// either backing API requeue matching policies so status.node moves and service +// endpoint changes converge without child resources. +func (r *ServiceRoutePolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&networkv1alpha1.ServiceRoutePolicy{}). + Watches(&networkv1alpha1.ServiceEndpoint{}, handler.EnqueueRequestsFromMapFunc(r.endpointPolicies)). + Watches(&cloudv1alpha1.VPCAttachment{}, handler.EnqueueRequestsFromMapFunc(r.attachmentPolicies)). + Complete(r) +} + +func (r *ServiceRoutePolicyReconciler) endpointPolicies(ctx context.Context, obj client.Object) []ctrl.Request { + return r.policiesForEndpoint(ctx, obj.GetNamespace(), obj.GetName()) +} + +func (r *ServiceRoutePolicyReconciler) attachmentPolicies(ctx context.Context, _ client.Object) []ctrl.Request { + return r.allPolicies(ctx) +} + +func (r *ServiceRoutePolicyReconciler) policiesForEndpoint(ctx context.Context, namespace, name string) []ctrl.Request { + list := &networkv1alpha1.ServiceRoutePolicyList{} + if err := r.List(ctx, list, client.InNamespace(namespace)); err != nil { + return nil + } + requests := make([]ctrl.Request, 0) + for _, policy := range list.Items { + if policy.Spec.ServiceRef.Name == name { + requests = append(requests, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: policy.Namespace, Name: policy.Name}}) + } + } + return requests +} + +func (r *ServiceRoutePolicyReconciler) allPolicies(ctx context.Context) []ctrl.Request { + list := &networkv1alpha1.ServiceRoutePolicyList{} + if err := r.List(ctx, list); err != nil { + return nil + } + requests := make([]ctrl.Request, 0, len(list.Items)) + for _, policy := range list.Items { + requests = append(requests, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: policy.Namespace, Name: policy.Name}}) + } + return requests +} + +func (r *ServiceRoutePolicyReconciler) replacePolicy(key types.NamespacedName, intents []serviceroute.RouteIntent) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, intent := range r.Applied[key] { + if err := r.Programmer.Remove(intent); err != nil { + r.Metrics.ObserveOperation("remove", err) + return err + } + r.Metrics.ObserveOperation("remove", nil) + } + for _, intent := range intents { + if err := r.Programmer.Apply(intent); err != nil { + r.Metrics.ObserveOperation("apply", err) + return err + } + r.Metrics.ObserveOperation("apply", nil) + } + if r.Applied == nil { + r.Applied = make(map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent) + } + r.Applied[key] = make(map[types.NamespacedName]serviceroute.RouteIntent, len(intents)) + for _, intent := range intents { + r.Applied[key][intent.Attachment] = intent + } + var count int + for _, intents := range r.Applied { + count += len(intents) + } + r.Metrics.SetProgrammedRoutes(count) + return nil +} + +func (r *ServiceRoutePolicyReconciler) removePolicy(key types.NamespacedName) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, intent := range r.Applied[key] { + if err := r.Programmer.Remove(intent); err != nil { + r.Metrics.ObserveOperation("remove", err) + return err + } + r.Metrics.ObserveOperation("remove", nil) + } + delete(r.Applied, key) + var count int + for _, intents := range r.Applied { + count += len(intents) + } + r.Metrics.SetProgrammedRoutes(count) + return nil +} diff --git a/internal/controller/vpcattachment_index.go b/internal/controller/vpcattachment_index.go new file mode 100644 index 00000000..ef42b6ec --- /dev/null +++ b/internal/controller/vpcattachment_index.go @@ -0,0 +1,95 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package controller + +import ( + "sort" + "sync" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + "k8s.io/apimachinery/pkg/types" +) + +// VPCAttachmentIndex indexes Cloud API attachments by their observed node. +// +// VPCAttachmentStatus.Node is the source of truth. This index is deliberately +// in-memory: it is a read optimization for node-local Galactic reconcilers, not +// a second API-level ownership field. +type VPCAttachmentIndex struct { + mu sync.RWMutex + byNode map[string]map[types.NamespacedName]*cloudv1alpha1.VPCAttachment + byKey map[types.NamespacedName]string +} + +// NewVPCAttachmentIndex creates an empty attachment index. +func NewVPCAttachmentIndex() *VPCAttachmentIndex { + return &VPCAttachmentIndex{ + byNode: make(map[string]map[types.NamespacedName]*cloudv1alpha1.VPCAttachment), + byKey: make(map[types.NamespacedName]string), + } +} + +// Upsert records the latest attachment state. An attachment with no observed +// node is retained nowhere because it cannot be programmed by a node-local +// dataplane controller yet. +func (i *VPCAttachmentIndex) Upsert(attachment *cloudv1alpha1.VPCAttachment) { + if attachment == nil { + return + } + + key := types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name} + i.mu.Lock() + defer i.mu.Unlock() + i.removeLocked(key) + + node := attachment.Status.Node + if node == "" { + return + } + if i.byNode[node] == nil { + i.byNode[node] = make(map[types.NamespacedName]*cloudv1alpha1.VPCAttachment) + } + i.byNode[node][key] = attachment.DeepCopy() + i.byKey[key] = node +} + +// Delete removes an attachment from the index. +func (i *VPCAttachmentIndex) Delete(attachment *cloudv1alpha1.VPCAttachment) { + if attachment == nil { + return + } + i.mu.Lock() + defer i.mu.Unlock() + i.removeLocked(types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name}) +} + +// ForNode returns a deterministic snapshot of attachments currently assigned +// to node. Callers may safely mutate returned objects. +func (i *VPCAttachmentIndex) ForNode(node string) []*cloudv1alpha1.VPCAttachment { + i.mu.RLock() + defer i.mu.RUnlock() + + attachments := make([]*cloudv1alpha1.VPCAttachment, 0, len(i.byNode[node])) + for _, attachment := range i.byNode[node] { + attachments = append(attachments, attachment.DeepCopy()) + } + sort.Slice(attachments, func(a, b int) bool { + return types.NamespacedName{Namespace: attachments[a].Namespace, Name: attachments[a].Name}.String() < + types.NamespacedName{Namespace: attachments[b].Namespace, Name: attachments[b].Name}.String() + }) + return attachments +} + +func (i *VPCAttachmentIndex) removeLocked(key types.NamespacedName) { + node, ok := i.byKey[key] + if !ok { + return + } + delete(i.byKey, key) + delete(i.byNode[node], key) + if len(i.byNode[node]) == 0 { + delete(i.byNode, node) + } +} diff --git a/internal/controller/vpcattachment_index_test.go b/internal/controller/vpcattachment_index_test.go new file mode 100644 index 00000000..2293b798 --- /dev/null +++ b/internal/controller/vpcattachment_index_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package controller + +import ( + "testing" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestVPCAttachmentIndexTracksObservedNode(t *testing.T) { + index := NewVPCAttachmentIndex() + attachment := &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a", Name: "attachment-a"}, + Status: cloudv1alpha1.VPCAttachmentStatus{Node: "node-a", PodSubnet: "fd20:0:19::2:0:0/96"}, + } + + index.Upsert(attachment) + got := index.ForNode("node-a") + if len(got) != 1 || got[0].Status.PodSubnet != attachment.Status.PodSubnet { + t.Fatalf("ForNode(node-a) = %+v, want attachment", got) + } + if got := index.ForNode("node-b"); len(got) != 0 { + t.Fatalf("ForNode(node-b) = %+v, want empty", got) + } +} + +func TestVPCAttachmentIndexMovesAndDeletes(t *testing.T) { + index := NewVPCAttachmentIndex() + attachment := &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a", Name: "attachment-a"}, + Status: cloudv1alpha1.VPCAttachmentStatus{Node: "node-a"}, + } + index.Upsert(attachment) + + attachment.Status.Node = "node-b" + index.Upsert(attachment) + if len(index.ForNode("node-a")) != 0 || len(index.ForNode("node-b")) != 1 { + t.Fatal("attachment was not moved between node indexes") + } + + index.Delete(attachment) + if len(index.ForNode("node-b")) != 0 { + t.Fatal("attachment remained after delete") + } +} + +func TestVPCAttachmentIndexIgnoresUnassignedAttachment(t *testing.T) { + index := NewVPCAttachmentIndex() + index.Upsert(&cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Name: "pending"}, + Status: cloudv1alpha1.VPCAttachmentStatus{}, + }) + if got := index.ForNode("node-a"); len(got) != 0 { + t.Fatalf("unassigned attachment indexed: %+v", got) + } +} diff --git a/internal/serviceroute/dataplane.go b/internal/serviceroute/dataplane.go new file mode 100644 index 00000000..b0a4d8e6 --- /dev/null +++ b/internal/serviceroute/dataplane.go @@ -0,0 +1,116 @@ +//go:build linux + +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import ( + "fmt" + "net" + + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" + "go.datum.net/galactic/internal/plumbing/vrf" +) + +// RouteProgrammer applies the local bidirectional service path for a route +// intent. Implementations must make repeated Apply calls safe. +type RouteProgrammer interface { + Apply(RouteIntent) error + Remove(RouteIntent) error +} + +// LinuxRouteProgrammer programs plain VRF routes and local pass-through +// entries. It deliberately does not install a default route or NAT64 route; +// those remain owned by Galactic's egress-shard programming. +type LinuxRouteProgrammer struct { + PinDir string +} + +func (p LinuxRouteProgrammer) Apply(intent RouteIntent) error { + consumerTable, serviceTable, err := p.tables(intent) + if err != nil { + return err + } + if err := p.route(consumerTable, intent.Service, intent.ServiceDevice, true); err != nil { + return fmt.Errorf("install consumer-to-service route: %w", err) + } + if err := p.route(serviceTable, intent.Consumer, intent.ConsumerDevice, true); err != nil { + _ = p.route(consumerTable, intent.Service, intent.ServiceDevice, false) + return fmt.Errorf("install service-to-consumer route: %w", err) + } + if err := p.passThrough(consumerTable, intent.Service, true); err != nil { + return fmt.Errorf("install consumer pass-through: %w", err) + } + if err := p.passThrough(serviceTable, intent.Consumer, true); err != nil { + _ = p.passThrough(consumerTable, intent.Service, false) + return fmt.Errorf("install service pass-through: %w", err) + } + return nil +} + +func (p LinuxRouteProgrammer) Remove(intent RouteIntent) error { + consumerTable, serviceTable, err := p.tables(intent) + if err != nil { + return err + } + var first error + for _, op := range []func() error{ + func() error { return p.passThrough(consumerTable, intent.Service, false) }, + func() error { return p.passThrough(serviceTable, intent.Consumer, false) }, + func() error { return p.route(consumerTable, intent.Service, intent.ServiceDevice, false) }, + func() error { return p.route(serviceTable, intent.Consumer, intent.ConsumerDevice, false) }, + } { + if err := op(); err != nil && first == nil { + first = err + } + } + return first +} + +func (p LinuxRouteProgrammer) tables(intent RouteIntent) (uint32, uint32, error) { + consumer, err := vrf.TableID(intent.ConsumerVPC) + if err != nil { + return 0, 0, fmt.Errorf("resolve consumer VRF %q: %w", intent.ConsumerVPC, err) + } + service, err := vrf.TableID(intent.ServiceVPC) + if err != nil { + return 0, 0, fmt.Errorf("resolve service VRF %q: %w", intent.ServiceVPC, err) + } + return consumer, service, nil +} + +func (p LinuxRouteProgrammer) route(table uint32, prefix *net.IPNet, device string, add bool) error { + link, err := netlink.LinkByName(device) + if err != nil { + return err + } + route := &netlink.Route{Dst: prefix, Table: int(table), LinkIndex: link.Attrs().Index} + if add { + return netlink.RouteReplace(route) + } + return netlink.RouteDel(route) +} + +func (p LinuxRouteProgrammer) passThrough(table uint32, prefix *net.IPNet, add bool) error { + t, closer, err := egressroutemap.OpenPinnedEgressRouteTable(p.PinDirOrDefault()) + if err != nil { + return err + } + defer closer.Close() // best effort + if add { + return t.RegisterPassThrough(table, prefix) + } + return t.Unregister(table, prefix) +} + +func (p LinuxRouteProgrammer) PinDirOrDefault() string { + if p.PinDir != "" { + return p.PinDir + } + return attach.PinDir +} diff --git a/internal/serviceroute/dataplane_stub.go b/internal/serviceroute/dataplane_stub.go new file mode 100644 index 00000000..14db525e --- /dev/null +++ b/internal/serviceroute/dataplane_stub.go @@ -0,0 +1,22 @@ +//go:build !linux + +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import "fmt" + +// LinuxRouteProgrammer is unavailable outside Linux. The stub keeps API and +// planner packages testable on developer workstations; CI and production use +// the Linux implementation. +type LinuxRouteProgrammer struct{} + +func (LinuxRouteProgrammer) Apply(RouteIntent) error { + return fmt.Errorf("service route dataplane programming requires linux") +} + +func (LinuxRouteProgrammer) Remove(RouteIntent) error { + return fmt.Errorf("service route dataplane programming requires linux") +} diff --git a/internal/serviceroute/metrics.go b/internal/serviceroute/metrics.go new file mode 100644 index 00000000..5259caf8 --- /dev/null +++ b/internal/serviceroute/metrics.go @@ -0,0 +1,82 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import ( + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +const metricsNamespace = "galactic_service_route" + +// Metrics contains bounded-cardinality metrics for the node-local service +// route controller. Resource names are deliberately kept in logs instead of +// labels so a large tenant population does not create an unbounded registry. +type Metrics struct { + reconciliations *prometheus.CounterVec + reconcileTime prometheus.Histogram + operations *prometheus.CounterVec + programmed prometheus.Gauge +} + +// NewMetrics creates the service-route metrics. Call MustRegister once during +// process startup. +func NewMetrics() *Metrics { + reconcileTime := prometheus.NewHistogram(prometheus.HistogramOpts{ + Namespace: metricsNamespace, + Name: "reconcile_duration_seconds", + Help: "Time spent reconciling a ServiceRoutePolicy.", + Buckets: prometheus.DefBuckets, + }) + return &Metrics{ + reconciliations: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Name: "reconciliations_total", + Help: "ServiceRoutePolicy reconciliations by outcome.", + }, []string{"result"}), + reconcileTime: reconcileTime, + operations: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Name: "route_operations_total", + Help: "Service route dataplane operations by operation and outcome.", + }, []string{"operation", "result"}), + programmed: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: metricsNamespace, + Name: "programmed_routes", + Help: "Number of local route intents currently tracked by the controller.", + }), + } +} + +// MustRegister registers all metrics owned by this package. +func (m *Metrics) MustRegister(reg prometheus.Registerer) { + reg.MustRegister(m.reconciliations, m.reconcileTime, m.operations, m.programmed) +} + +func (m *Metrics) ObserveReconcile(result string, duration time.Duration) { + if m == nil { + return + } + m.reconciliations.WithLabelValues(result).Inc() + m.reconcileTime.Observe(duration.Seconds()) +} + +func (m *Metrics) ObserveOperation(operation string, err error) { + if m == nil { + return + } + result := "success" + if err != nil { + result = "error" + } + m.operations.WithLabelValues(operation, result).Inc() +} + +func (m *Metrics) SetProgrammedRoutes(count int) { + if m != nil { + m.programmed.Set(float64(count)) + } +} diff --git a/internal/serviceroute/planner.go b/internal/serviceroute/planner.go new file mode 100644 index 00000000..a7257698 --- /dev/null +++ b/internal/serviceroute/planner.go @@ -0,0 +1,122 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package serviceroute compiles internal service-routing policy into the +// node-local route intents consumed by Galactic's dataplane programmer. +package serviceroute + +import ( + "fmt" + "net" + "sort" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" +) + +// RouteIntent describes one local bidirectional service route. The consumer +// prefix is taken from VPCAttachment.status.podSubnet; the service prefix is +// the endpoint address as a host route. +type RouteIntent struct { + Attachment types.NamespacedName + Consumer *net.IPNet + Service *net.IPNet + ConsumerVPC string + ConsumerDevice string + ServiceVPC string + ServiceDevice string + Ports []networkv1alpha1.ServiceRouteProtocolPort +} + +// Compile evaluates policy against attachments assigned to localNode and +// returns deterministic route intents. The caller can pass the result to a +// dataplane programmer without creating per-attachment API objects. +func Compile( + policy *networkv1alpha1.ServiceRoutePolicy, + endpoint *networkv1alpha1.ServiceEndpoint, + attachments []*cloudv1alpha1.VPCAttachment, + localNode string, +) ([]RouteIntent, error) { + if policy == nil || endpoint == nil { + return nil, fmt.Errorf("service route policy and endpoint are required") + } + if policy.Spec.Region != "" && endpoint.Spec.Region != "" && policy.Spec.Region != endpoint.Spec.Region { + return nil, fmt.Errorf("policy region %q does not match endpoint region %q", policy.Spec.Region, endpoint.Spec.Region) + } + selector, err := metav1.LabelSelectorAsSelector(&policy.Spec.AttachmentSelector) + if err != nil { + return nil, fmt.Errorf("compile attachment selector: %w", err) + } + service, err := hostPrefix(endpoint.Spec.Address) + if err != nil { + return nil, fmt.Errorf("parse service endpoint address: %w", err) + } + if endpoint.Spec.AttachmentRef == nil { + return nil, fmt.Errorf("service endpoint %s requires attachmentRef for local service routing", endpoint.Name) + } + serviceKey := types.NamespacedName{Namespace: endpoint.Spec.AttachmentRef.Namespace, Name: endpoint.Spec.AttachmentRef.Name} + var serviceAttachment *cloudv1alpha1.VPCAttachment + for _, attachment := range attachments { + if attachment == nil { + continue + } + attachmentKey := types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name} + if attachmentKey == serviceKey { + serviceAttachment = attachment + break + } + } + if serviceAttachment == nil || serviceAttachment.Status.Node != localNode { + return nil, nil + } + if serviceAttachment.Status.VPC == "" || serviceAttachment.Status.HostInterface == "" { + return nil, fmt.Errorf("service attachment %s/%s is missing VPC or host interface status", serviceKey.Namespace, serviceKey.Name) + } + + ports := append([]networkv1alpha1.ServiceRouteProtocolPort(nil), policy.Spec.ProtocolPorts...) + if len(ports) == 0 { + ports = []networkv1alpha1.ServiceRouteProtocolPort{{Protocol: endpoint.Spec.Protocol, Port: endpoint.Spec.Port}} + } + + intents := make([]RouteIntent, 0, len(attachments)) + for _, attachment := range attachments { + if attachment == nil || attachment.Status.Node != localNode || !selector.Matches(labels.Set(attachment.Labels)) { + continue + } + _, consumer, err := net.ParseCIDR(attachment.Status.PodSubnet) + if err != nil { + return nil, fmt.Errorf("attachment %s/%s has invalid podSubnet %q: %w", attachment.Namespace, attachment.Name, attachment.Status.PodSubnet, err) + } + intents = append(intents, RouteIntent{ + Attachment: types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name}, + Consumer: consumer, + Service: cloneIPNet(service), + ConsumerVPC: attachment.Status.VPC, + ConsumerDevice: attachment.Status.HostInterface, + ServiceVPC: serviceAttachment.Status.VPC, + ServiceDevice: serviceAttachment.Status.HostInterface, + Ports: append([]networkv1alpha1.ServiceRouteProtocolPort(nil), ports...), + }) + } + sort.Slice(intents, func(i, j int) bool { return intents[i].Attachment.String() < intents[j].Attachment.String() }) + return intents, nil +} + +func hostPrefix(address string) (*net.IPNet, error) { + ip := net.ParseIP(address) + if ip == nil { + return nil, fmt.Errorf("%q is not an IP address", address) + } + if ip.To4() != nil { + return &net.IPNet{IP: ip.To4(), Mask: net.CIDRMask(32, 32)}, nil + } + return &net.IPNet{IP: ip.To16(), Mask: net.CIDRMask(128, 128)}, nil +} + +func cloneIPNet(in *net.IPNet) *net.IPNet { + return &net.IPNet{IP: append(net.IP(nil), in.IP...), Mask: append(net.IPMask(nil), in.Mask...)} +} diff --git a/internal/serviceroute/planner_test.go b/internal/serviceroute/planner_test.go new file mode 100644 index 00000000..16e622c0 --- /dev/null +++ b/internal/serviceroute/planner_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import ( + "testing" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestCompileUsesObservedNodeAndSelector(t *testing.T) { + policy := &networkv1alpha1.ServiceRoutePolicy{ + Spec: networkv1alpha1.ServiceRoutePolicySpec{ + AttachmentSelector: metav1.LabelSelector{MatchLabels: map[string]string{"egress": "enabled"}}, + ProtocolPorts: []networkv1alpha1.ServiceRouteProtocolPort{{Protocol: networkv1alpha1.NetworkRuleProtocolUDP, Port: 53}}, + }, + } + endpoint := &networkv1alpha1.ServiceEndpoint{Spec: networkv1alpha1.ServiceEndpointSpec{ + Address: "fd20:0:21::1:0:0", Port: 53, Protocol: networkv1alpha1.NetworkRuleProtocolUDP, + AttachmentRef: &networkv1alpha1.ServiceEndpointAttachmentReference{Namespace: "platform", Name: "dns64"}, + }} + attachments := []*cloudv1alpha1.VPCAttachment{ + {ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "dns64"}, Status: cloudv1alpha1.VPCAttachmentStatus{Node: "node-a", VPC: "service-vpc", HostInterface: "Gdns"}}, + {ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "selected", Labels: map[string]string{"egress": "enabled"}}, Status: cloudv1alpha1.VPCAttachmentStatus{Node: "node-a", VPC: "consumer-vpc", HostInterface: "Gconsumer", PodSubnet: "fd20:0:19::2:0:0/96"}}, + {ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "other-node", Labels: map[string]string{"egress": "enabled"}}, Status: cloudv1alpha1.VPCAttachmentStatus{Node: "node-b", PodSubnet: "fd20:0:20::2:0:0/96"}}, + {ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "not-selected", Labels: map[string]string{"egress": "disabled"}}, Status: cloudv1alpha1.VPCAttachmentStatus{Node: "node-a", PodSubnet: "fd20:0:21::2:0:0/96"}}, + } + + got, err := Compile(policy, endpoint, attachments, "node-a") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Attachment != (types.NamespacedName{Namespace: "a", Name: "selected"}) { + t.Fatalf("intents = %+v, want selected attachment only", got) + } + if got[0].Service.String() != "fd20:0:21::1:0:0/128" { + t.Fatalf("service prefix = %s", got[0].Service) + } +} + +func TestCompileUsesEndpointPortByDefault(t *testing.T) { + policy := &networkv1alpha1.ServiceRoutePolicy{Spec: networkv1alpha1.ServiceRoutePolicySpec{ + AttachmentSelector: metav1.LabelSelector{MatchLabels: map[string]string{"egress": "enabled"}}, + }} + endpoint := &networkv1alpha1.ServiceEndpoint{Spec: networkv1alpha1.ServiceEndpointSpec{Address: "192.0.2.10", Port: 53, Protocol: networkv1alpha1.NetworkRuleProtocolTCP, AttachmentRef: &networkv1alpha1.ServiceEndpointAttachmentReference{Namespace: "platform", Name: "dns64"}}} + attachment := &cloudv1alpha1.VPCAttachment{ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "selected", Labels: map[string]string{"egress": "enabled"}}, Status: cloudv1alpha1.VPCAttachmentStatus{Node: "node-a", VPC: "consumer-vpc", HostInterface: "Gconsumer", PodSubnet: "fd20::/96"}} + service := &cloudv1alpha1.VPCAttachment{ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "dns64"}, Status: cloudv1alpha1.VPCAttachmentStatus{Node: "node-a", VPC: "service-vpc", HostInterface: "Gdns"}} + + got, err := Compile(policy, endpoint, []*cloudv1alpha1.VPCAttachment{attachment, service}, "node-a") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || len(got[0].Ports) != 1 || got[0].Ports[0].Port != 53 || got[0].Ports[0].Protocol != networkv1alpha1.NetworkRuleProtocolTCP { + t.Fatalf("ports = %+v", got[0].Ports) + } +}