Files
weave/lb/egress_api.go
T
levaliciousandClaude Opus 5.5 305ac4f3d3
images / images (push) Successful in 18m34s
lb: EgressService - a LoadBalancer Service's pods leave from its address
EgressService (reweave.levk.dev/v1alpha1, named after a LoadBalancer
Service of class reweave.levk.dev/node-address) makes every pod the
Service selects (by pod IP, never readiness) leave the cluster from the
Service's address, or not at all. spec.emit is explicit: Holder (the only
mode so far) tunnels members' packets to the node holding the address,
which emits them.

The holder (S1's: the one node with the address on an interface) is the
status' sole writer: host, address, and a 512-port block per node in
61000-65535 (above the default ephemeral range), stable across churn.
A member's node SNATs it to address:its-block before it leaves, so the
holder forwards it unchanged and routes replies back by port alone: ip
rules (to address, ipproto tcp/udp, dport block) ahead of the local
table, which moves from 0 to 100, since the address is local on the
holder. Members' packets travel both ways in reweave-eg, an ipip tunnel
between weave bridges (NPC drops new routed packets into weave), with
accept_local and loose rp_filter on that device only.

Members' new connections are classed at the tail of nat PREROUTING,
after kube-proxy's DNAT has ended the nat walk for Services: the pod
network, multicast and the nodes' addresses are not outside. Marked
packets follow a per-member rule to the holder's table (or to an
unreachable one), are SNATed into the tunnel, and a FORWARD guard drops
anything else a member sends outside (other protocols, other ways out).
The tunnel is accepted only from the nodes' bridges, a holder accepts
from it only the blocks' ports, and TCP MSS is clamped into it.

Fail closed throughout: until the holder has published the status,
without a block, with no single holder, or for a pod two EgressServices
select, a member's outside traffic is dropped. The CNI plugin asks
weave-npc to admit every pod (unix socket /run/weave-npc/egress.sock,
conflist "egressAdmission": true, now rewritten when it differs) after
IPAM and before the pod has an interface, so a member's first packet
already leaves from the address; no answer, no pod. weave-npc applies
nothing about egress from caches that are not synced (unless the API is
absent): a restart never strips members' rules.

reweave/test/egress/run.sh checks the design's assertions against a
live cluster; both end-to-end scripts now re-evaluate each check on
every retry (it was expanded once, before the loop).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 14:39:44 -04:00

145 lines
4.8 KiB
Go

package lb
import (
"context"
"encoding/json"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
)
// EgressService (reweave.levk.dev/v1alpha1) makes the pods a LoadBalancer Service selects leave the cluster
// from the Service's address. It is named after the Service (same namespace, same name). Its semantics follow
// OVN-Kubernetes' EgressService (k8s.ovn.org/v1, sourceIPBy: LoadBalancerIP) where they agree; they differ in
// that membership is every pod the Service's selector matches (by pod IP, never by readiness), the holder is
// the node that holds the address (no election), and a member's traffic to the outside leaves as the address
// or not at all.
type EgressService struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec EgressServiceSpec `json:"spec"`
Status EgressServiceStatus `json:"status,omitempty"`
}
// EgressServiceSpec is what the operator asks for.
type EgressServiceSpec struct {
// SourceIPBy is LoadBalancerIP: members leave from the Service's load-balancer address (the only mode in v1).
SourceIPBy string `json:"sourceIPBy,omitempty"`
// Emit is where members' packets leave the cluster. Explicit, no default: Holder (tunnelled to the node
// holding the address, which emits them) is the only mode in v1.
Emit string `json:"emit"`
}
// EgressServiceStatus is written by the holder alone.
type EgressServiceStatus struct {
// Host is the node that holds the address and emits members' packets.
Host string `json:"host,omitempty"`
// Address is the address members leave from.
Address string `json:"address,omitempty"`
// Blocks gives every node its own source ports on Address, so the holder routes replies back by port.
Blocks []PortBlock `json:"blocks,omitempty"`
}
// PortBlock is the source-port range [From, To] on the address that one node's members use.
type PortBlock struct {
Node string `json:"node"`
From int32 `json:"from"`
To int32 `json:"to"`
}
// EgressServiceList is a list of EgressServices.
type EgressServiceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []EgressService `json:"items"`
}
const (
// Group and Version of the EgressService API.
Group = "reweave.levk.dev"
Version = "v1alpha1"
// EgressServices is the resource name.
EgressServices = "egressservices"
SourceIPByLoadBalancerIP = "LoadBalancerIP"
EmitHolder = "Holder"
)
// GroupVersion of the EgressService API.
var GroupVersion = schema.GroupVersion{Group: Group, Version: Version}
// DeepCopyObject implements runtime.Object.
func (e *EgressService) DeepCopyObject() runtime.Object { return e.DeepCopy() }
// DeepCopy copies the whole object.
func (e *EgressService) DeepCopy() *EgressService {
if e == nil {
return nil
}
out := *e
e.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Status.Blocks = append([]PortBlock(nil), e.Status.Blocks...)
return &out
}
// DeepCopyObject implements runtime.Object.
func (l *EgressServiceList) DeepCopyObject() runtime.Object {
if l == nil {
return nil
}
out := *l
l.ListMeta.DeepCopyInto(&out.ListMeta)
out.Items = make([]EgressService, len(l.Items))
for i := range l.Items {
out.Items[i] = *l.Items[i].DeepCopy()
}
return &out
}
func addKnownTypes(s *runtime.Scheme) error {
s.AddKnownTypes(GroupVersion, &EgressService{}, &EgressServiceList{})
metav1.AddToGroupVersion(s, GroupVersion)
return nil
}
// NewEgressClient is a REST client for the EgressService API (no generated clientset: two plain types).
func NewEgressClient(base *rest.Config) (*rest.RESTClient, error) {
scheme := runtime.NewScheme()
if err := addKnownTypes(scheme); err != nil {
return nil, err
}
cfg := *base
cfg.GroupVersion = &GroupVersion
cfg.APIPath = "/apis"
cfg.ContentType = runtime.ContentTypeJSON
cfg.NegotiatedSerializer = serializer.NewCodecFactory(scheme).WithoutConversion()
if cfg.UserAgent == "" {
cfg.UserAgent = rest.DefaultKubernetesUserAgent()
}
return rest.RESTClientFor(&cfg)
}
// PatchEgressStatus merge-patches an EgressService's status (nil fields are removed).
func PatchEgressStatus(ctx context.Context, c rest.Interface, namespace, name string, st EgressServiceStatus) error {
body := map[string]interface{}{"host": nil, "address": nil, "blocks": nil}
if st.Host != "" {
body["host"] = st.Host
}
if st.Address != "" {
body["address"] = st.Address
}
if len(st.Blocks) > 0 {
body["blocks"] = st.Blocks
}
patch, err := json.Marshal(map[string]interface{}{"status": body})
if err != nil {
return err
}
return c.Patch(types.MergePatchType).Namespace(namespace).Resource(EgressServices).Name(name).
SubResource("status").Body(patch).Do(ctx).Error()
}