From 0f531c4a739bb076dfb45fd8e2eb4c67200ea724 Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Wed, 28 Oct 2026 13:52:52 -0400 Subject: [PATCH 1/2] fix(masque): Keep a connector's /96 when its grant record fails grant create allocates the connector's /96 in the ledger before it writes the grant record. When that write failed, the ledger kept an entry no record pointed at. grant retire could not tombstone it, because Retire reads the record first, and a plain retry allocated a second /96. Each failure cost one of the ledger's 256 entries for good. Create now names the connector and its /96 when the record write fails, so creating the same connector again reuses that allocation. When the record already exists after a lost reply, the error says to rotate it. Retire tombstones the allocation of a connector that has no record by finding it in the authority's ledgers, then disables the record in case a racing create wrote one. Serving and refresh refuse a retired entry, so neither the stranded /96 nor a raced record serves after the retire. Refs #978 --- docs/edge/masque-authority.md | 2 + internal/edge/masque/authority/grants.go | 45 ++++- internal/edge/masque/authority/grants_test.go | 163 ++++++++++++++++++ internal/edge/masque/authority/ledger.go | 20 +++ internal/edge/masque/authority/ledger_test.go | 11 +- 5 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 internal/edge/masque/authority/grants_test.go diff --git a/docs/edge/masque-authority.md b/docs/edge/masque-authority.md index c161d52c..d11372db 100644 --- a/docs/edge/masque-authority.md +++ b/docs/edge/masque-authority.md @@ -73,6 +73,8 @@ masque-authority grant retire # revoke and tombstone the allocati `ledger init` refuses ranges that aren't masked IPv6 /80s inside one of the VPC's networks, or that overlap each other. It also refuses ranges that overlap those of another cell's ledger for the same VPC, so two cells are never handed overlapping /80s. And it refuses while any EdgeService the authority issued for the VPC in that cell still exists, so a recreated ledger can't hand out a /96 or pool that one still holds. Issued services of other cells don't block it. Choose ranges that overlap no workload subnet; core refuses a delegated route that does. +`grant create` allocates the /96 before it writes the grant record. If the record write fails, the error names the connector and its /96. Run `grant create` again with `--connector ` to keep that /96, or `grant retire ` to tombstone it. A plain retry allocates another /96. If the error says the connector already has a grant record, the write succeeded but its reply was lost: run `grant rotate ` for a credential. + Run the server with: ```sh diff --git a/internal/edge/masque/authority/grants.go b/internal/edge/masque/authority/grants.go index 4b8f8d39..be21148e 100644 --- a/internal/edge/masque/authority/grants.go +++ b/internal/edge/masque/authority/grants.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -68,6 +69,11 @@ func (g Grants) expiry(lifetime time.Duration) (time.Time, error) { // Create allocates the connector's /96 in the ledger of its VPC and cell, // writes its grant record with a new credential and returns that // credential. It refuses a connector that already has a record. +// +// The allocation is written before the record. When the record write +// fails, the error names the connector: creating it again with the same +// Connector reuses its allocation rather than taking another /96, and +// Retire tombstones it. func (g Grants) Create(ctx context.Context, req CreateRequest) (Issued, error) { expires, err := g.expiry(req.Lifetime) if err != nil { @@ -98,7 +104,12 @@ func (g Grants) Create(ctx context.Context, req CreateRequest) (Issued, error) { return Issued{}, fmt.Errorf("allocate connector %s: %w", r.Connector, err) } if err := g.Client.Create(ctx, s); err != nil { - return Issued{}, fmt.Errorf("create grant record %s: %w", s.Name, err) + if apierrors.IsAlreadyExists(err) { + return Issued{}, fmt.Errorf("connector %s already has a grant record; rotate it for a new credential: %w", + r.Connector, err) + } + return Issued{}, fmt.Errorf("create grant record of connector %s, which holds %s; create it again with "+ + "this connector to keep that /96, or retire it: %w", r.Connector, e.Prefix, err) } return Issued{Connector: r.Connector, Credential: cred, Revision: r.Revision, VPC: r.VPC.Selector(), Prefix: e.Prefix, Expires: expires}, nil @@ -153,9 +164,13 @@ func (g Grants) Disable(ctx context.Context, connector string) error { } // Retire disables the connector and tombstones its allocation, so its /96 -// and attachment pool are never allocated again. +// and attachment pool are never allocated again. A connector whose record +// was never written, because Create failed after allocating, has its +// allocation tombstoned in whichever of the authority's ledgers holds it. func (g Grants) Retire(ctx context.Context, connector string) error { - if err := g.Disable(ctx, connector); err != nil { + if err := g.Disable(ctx, connector); apierrors.IsNotFound(err) { + return g.retireUnrecorded(ctx, connector, err) + } else if err != nil { return err } r, _, err := readRecord(ctx, g.Client, g.Namespace, connector) @@ -165,6 +180,30 @@ func (g Grants) Retire(ctx context.Context, connector string) error { return g.ledgers().Retire(ctx, r.VPC.UID, r.Cell, connector) } +// retireUnrecorded tombstones the allocations of connector, which has no +// record. notFound is the error reading the record returned, for a +// connector no ledger holds either. +func (g Grants) retireUnrecorded(ctx context.Context, connector string, notFound error) error { + held, err := g.ledgers().holding(ctx, connector) + if err != nil { + return err + } + if len(held) == 0 { + return notFound + } + for _, l := range held { + if err := g.ledgers().Retire(ctx, l.VPC.UID, l.Cell, connector); err != nil { + return err + } + } + // A Create that wrote the record since Disable found none leaves it + // enabled; disable it as Retire does a recorded connector. + if err := g.Disable(ctx, connector); err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil +} + // update applies change to a fresh read of the connector's record and // writes it back conditioned on the read's UID and resourceVersion. func (g Grants) update(ctx context.Context, connector string, change func(*Record) error) error { diff --git a/internal/edge/masque/authority/grants_test.go b/internal/edge/masque/authority/grants_test.go new file mode 100644 index 00000000..6bc64721 --- /dev/null +++ b/internal/edge/masque/authority/grants_test.go @@ -0,0 +1,163 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package authority + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +// recordWrites decides the outcome of each grant record create: nil +// writes it, errLost writes it and still fails, any other error fails +// without writing. +type recordWrites func() error + +var errLost = errors.New("reply lost") + +// faultyGrants returns Grants over a fake API holding one VPC and its +// ledger, whose grant record creates go through writes. +func faultyGrants(t *testing.T, writes recordWrites) (Grants, VPCRef) { + t.Helper() + vpc := testVPC() + c := fake.NewClientBuilder().WithScheme(testScheme()).WithObjects(vpc).WithInterceptorFuncs(interceptor.Funcs{ + Create: func(ctx context.Context, c client.WithWatch, o client.Object, opts ...client.CreateOption) error { + if _, ok := o.(*corev1.Secret); !ok { + return c.Create(ctx, o, opts...) + } + switch err := writes(); { + case err == nil: + return c.Create(ctx, o, opts...) + case errors.Is(err, errLost): + if cerr := c.Create(ctx, o, opts...); cerr != nil { + return cerr + } + return err + default: + return err + } + }, + }).Build() + g := Grants{Client: c, Namespace: testNS, Now: time.Now} + ref := VPCRef{Namespace: tenantNS, Name: vpcName, UID: string(vpc.UID)} + if err := g.ledgers().Init(context.Background(), ref, testCell, testConnectors, testPools); err != nil { + t.Fatal(err) + } + return g, VPCRef{Namespace: tenantNS, Name: vpcName} +} + +// entries returns the test VPC's ledger entries. +func (g Grants) entries(t *testing.T) []Entry { + t.Helper() + l, _, err := g.ledgers().Read(context.Background(), string(testVPC().UID), testCell) + if err != nil { + t.Fatal(err) + } + return l.Entries +} + +// failOnce fails the first record write without writing it. +func failOnce() recordWrites { + n := 0 + return func() error { + if n++; n == 1 { + return errors.New("injected") + } + return nil + } +} + +// TestCreateRetryKeepsAllocation: a record write that fails leaves the +// connector's /96 allocated, and creating the same connector again uses +// that /96 rather than another. +func TestCreateRetryKeepsAllocation(t *testing.T) { + g, ref := faultyGrants(t, failOnce()) + ctx := context.Background() + _, err := g.Create(ctx, CreateRequest{VPC: ref, Cell: testCell, Lifetime: time.Hour}) + if err == nil { + t.Fatal("create with a failed record write succeeded") + } + e := g.entries(t) + if len(e) != 1 || !strings.Contains(err.Error(), e[0].Connector) || + !strings.Contains(err.Error(), e[0].Prefix.String()) { + t.Fatalf("failed create = %v with entries %+v; want an error naming the one connector and its /96", err, e) + } + issued, err := g.Create(ctx, CreateRequest{VPC: ref, Cell: testCell, Connector: e[0].Connector, Lifetime: time.Hour}) + if err != nil { + t.Fatal(err) + } + if issued.Prefix != e[0].Prefix || len(g.entries(t)) != 1 { + t.Fatalf("retry got %s with %d entries; want %s and one entry", issued.Prefix, len(g.entries(t)), + e[0].Prefix) + } +} + +// TestCreateLostReply: a record written whose reply was lost is refused on +// retry, and rotating it issues a credential for the same /96. +func TestCreateLostReply(t *testing.T) { + n := 0 + g, ref := faultyGrants(t, func() error { + if n++; n == 1 { + return errLost + } + return nil + }) + ctx := context.Background() + if _, err := g.Create(ctx, CreateRequest{VPC: ref, Cell: testCell, Lifetime: time.Hour}); !errors.Is(err, errLost) { + t.Fatalf("create with a lost reply = %v", err) + } + e := g.entries(t) + _, err := g.Create(ctx, CreateRequest{VPC: ref, Cell: testCell, Connector: e[0].Connector, Lifetime: time.Hour}) + if !apierrors.IsAlreadyExists(err) || !strings.Contains(err.Error(), "rotate") { + t.Fatalf("retry after a lost reply = %v; want AlreadyExists pointing at rotate", err) + } + issued, err := g.Rotate(ctx, e[0].Connector, time.Hour) + if err != nil { + t.Fatal(err) + } + if issued.Prefix != e[0].Prefix || issued.Credential == "" || len(g.entries(t)) != 1 { + t.Fatalf("rotate after a lost reply = %+v with %d entries; want %s", issued, len(g.entries(t)), e[0].Prefix) + } +} + +// TestRetireUnrecorded: a connector whose record was never written can be +// retired, which tombstones its /96 so no connector gets it again. +func TestRetireUnrecorded(t *testing.T) { + g, ref := faultyGrants(t, failOnce()) + ctx := context.Background() + if _, err := g.Create(ctx, CreateRequest{VPC: ref, Cell: testCell, Lifetime: time.Hour}); err == nil { + t.Fatal("create with a failed record write succeeded") + } + stranded := g.entries(t)[0] + if err := g.Retire(ctx, stranded.Connector); err != nil { + t.Fatalf("retire a connector without a record: %v", err) + } + if e := g.entries(t); len(e) != 1 || !e[0].Retired { + t.Fatalf("entries after retire %+v; want one tombstone", e) + } + if _, err := g.Create(ctx, CreateRequest{VPC: ref, Cell: testCell, Connector: stranded.Connector, + Lifetime: time.Hour}); err == nil { + t.Fatal("a retired connector was created again") + } + issued, err := g.Create(ctx, CreateRequest{VPC: ref, Cell: testCell, Lifetime: time.Hour}) + if err != nil { + t.Fatal(err) + } + if issued.Prefix == stranded.Prefix { + t.Fatalf("new connector got the retired %s", stranded.Prefix) + } + err = g.Retire(ctx, "33333333-3333-3333-3333-333333333333") + if !apierrors.IsNotFound(err) { + t.Fatalf("retire an unknown connector = %v, want NotFound", err) + } +} diff --git a/internal/edge/masque/authority/ledger.go b/internal/edge/masque/authority/ledger.go index 4356360e..4f9b6a8d 100644 --- a/internal/edge/masque/authority/ledger.go +++ b/internal/edge/masque/authority/ledger.go @@ -172,6 +172,26 @@ func (s Ledgers) Init(ctx context.Context, vpc VPCRef, cell string, connectors, return nil } +// holding returns every ledger with an entry for connector. +func (s Ledgers) holding(ctx context.Context, connector string) ([]*Ledger, error) { + var cms corev1.ConfigMapList + if err := s.Client.List(ctx, &cms, client.InNamespace(s.Namespace), + client.MatchingLabels{LabelLedger: yes}); err != nil { + return nil, fmt.Errorf("list ledgers: %w", err) + } + var out []*Ledger + for _, cm := range cms.Items { + var l Ledger + if err := json.Unmarshal([]byte(cm.Data[ledgerKey]), &l); err != nil { + return nil, fmt.Errorf("decode ledger %s: %w", cm.Name, err) + } + if _, ok := l.Lookup(connector); ok { + out = append(out, &l) + } + } + return out, nil +} + // inNetworks reports whether p lies inside one of networks. func inNetworks(networks []cloudv1alpha1.Network, p netip.Prefix) bool { for _, n := range networks { diff --git a/internal/edge/masque/authority/ledger_test.go b/internal/edge/masque/authority/ledger_test.go index 7d59c6c2..1479fc68 100644 --- a/internal/edge/masque/authority/ledger_test.go +++ b/internal/edge/masque/authority/ledger_test.go @@ -45,12 +45,17 @@ func testScheme() *runtime.Scheme { return s } -func fakeLedgers(t *testing.T) (Ledgers, VPCRef) { - t.Helper() - vpc := &cloudv1alpha1.VPC{ +// testVPC returns the VPC the ledgers under test belong to. +func testVPC() *cloudv1alpha1.VPC { + return &cloudv1alpha1.VPC{ ObjectMeta: metav1.ObjectMeta{Namespace: tenantNS, Name: vpcName, UID: "11111111-1111-1111-1111-111111111111"}, Spec: cloudv1alpha1.VPCSpec{Networks: []cloudv1alpha1.Network{"fd20:a1b:2c3d::/48"}}, } +} + +func fakeLedgers(t *testing.T) (Ledgers, VPCRef) { + t.Helper() + vpc := testVPC() c := fake.NewClientBuilder().WithScheme(testScheme()).WithObjects(vpc).Build() l := Ledgers{Client: c, Namespace: testNS, Now: time.Now} ref := VPCRef{Namespace: tenantNS, Name: vpcName, UID: string(vpc.UID)} From 09b0f3d3f75b3232030033c8e16a54dfa99055e9 Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Wed, 28 Oct 2026 13:52:53 -0400 Subject: [PATCH 2/2] test(edgeconformance): Route a connector's subdivisions in the lab Add the M12 row to TestMASQUE. The probe client sends from four addresses in two /112 subdivisions of VPC 10's connector /96 to a workload on the compute node, a workload on the fixture's node and the off-fabric host through NAT. Every pair must be answered. While the connector is connected, the row requires exactly one BGPAdvertisement of the connector's route, carrying only the /96, and none for a longer prefix inside it. edgeprobe connectip takes several -src addresses for this. The provider guide now says how return traffic reaches addresses inside the /96. Fixes #978 --- docs/edge/masque-provider.md | 2 + tests/edgeconformance/edgeprobe/connectip.go | 32 +++-- .../edgeconformance/masque_addressing_test.go | 110 ++++++++++++++++++ tests/edgeconformance/masque_test.go | 5 +- 4 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 tests/edgeconformance/masque_addressing_test.go diff --git a/docs/edge/masque-provider.md b/docs/edge/masque-provider.md index 662ca33c..e88e4edb 100644 --- a/docs/edge/masque-provider.md +++ b/docs/edge/masque-provider.md @@ -26,6 +26,8 @@ Only then does the request get its packet path. The request waits up to 30 secon Connectors of one service share its attachment and device. +The route is the connector's whole /96, and it is the only route the connector gets. The client numbers addresses and longer subprefixes inside the /96 itself. The device delivers a packet for any address inside the /96 to that connector's request, so no subdivision needs its own route or allocation. + ## Replacing a connector's request A connector forwards through at most one request. A new request for a connector that already has one open, such as a reconnect or an HTTP/3 to HTTP/2 fallback, replaces it: diff --git a/tests/edgeconformance/edgeprobe/connectip.go b/tests/edgeconformance/edgeprobe/connectip.go index 2a889f2c..758feae1 100644 --- a/tests/edgeconformance/edgeprobe/connectip.go +++ b/tests/edgeconformance/edgeprobe/connectip.go @@ -42,7 +42,8 @@ func connectIP(args []string) error { token := fs.String("token", "", "bearer credential") vpc := fs.String("vpc", "", "Galactic-VPC header value") to := fs.String("to", "", "comma-separated echo destinations") - srcFlag := fs.String("src", "", "source address; default the first address after the assigned prefix's") + srcFlag := fs.String("src", "", "comma-separated source addresses, each sent to every destination; "+ + "default the first address after the assigned prefix's") id := fs.Int("id", os.Getpid()&0xffff, "ICMPv6 echo identifier") size := fs.Int("size", 64, "IPv6 packet size in bytes") interval := fs.Duration("interval", 50*time.Millisecond, "time between echo requests") @@ -112,10 +113,15 @@ func connectIP(args []string) error { } prefix := assigned[0].IPPrefix _ = out.Encode(cipEvent{T: boottime(), Assigned: prefix.String()}) - src := prefix.Addr().Next() + srcs := []netip.Addr{prefix.Addr().Next()} if *srcFlag != "" { - if src, err = netip.ParseAddr(*srcFlag); err != nil { - return fmt.Errorf("-src: %w", err) + srcs = nil + for _, a := range strings.Split(*srcFlag, ",") { + src, err := netip.ParseAddr(a) + if err != nil { + return fmt.Errorf("-src: %w", err) + } + srcs = append(srcs, src) } } @@ -138,15 +144,17 @@ func connectIP(args []string) error { sent := 0 loop: for time.Now().Before(deadline) { - for _, dst := range dsts { - pkt, err := echoRequest(src, dst, *id, sent, *size) - if err != nil { - return err - } - if _, err := conn.WritePacket(pkt); err != nil { - break loop + for _, src := range srcs { + for _, dst := range dsts { + pkt, err := echoRequest(src, dst, *id, sent, *size) + if err != nil { + return err + } + if _, err := conn.WritePacket(pkt); err != nil { + break loop + } + sent++ } - sent++ } select { case <-tick.C: diff --git a/tests/edgeconformance/masque_addressing_test.go b/tests/edgeconformance/masque_addressing_test.go new file mode 100644 index 00000000..7776ec27 --- /dev/null +++ b/tests/edgeconformance/masque_addressing_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package edgeconformance_test + +import ( + "context" + "fmt" + "net/netip" + "slices" + "strings" + "testing" + "time" + + "sigs.k8s.io/controller-runtime/pkg/client" + + networkv1alpha1 "go.datum.net/galactic/api/network/v1alpha1" + "go.datum.net/galactic/internal/crdnames" + "go.datum.net/galactic/internal/edge/provider" +) + +// masqueSubdivided are client-managed addresses in two /112 subdivisions +// of VPC 10's connector /96, none of which core or the provider allocated. +var masqueSubdivided = []string{ + "fd00:ed9e:300:1::1:1", "fd00:ed9e:300:1::1:2", + "fd00:ed9e:300:1::2:1", "fd00:ed9e:300:1::2:2", +} + +// rowAggregateRouting (M12) sends from every address of masqueSubdivided to +// VPC 10's workload on the compute node, its workload on the fixture's node +// and the off-fabric host through NAT, and requires each pair answered: the +// one aggregate /96 carries return traffic to any address inside it. While +// the connector is connected it requires exactly one BGPAdvertisement of the +// connector's route, for the /96 itself, and none for anything longer inside +// the /96. +func (m *masque) rowAggregateRouting(t *testing.T) { + mt := m.a + dsts := []string{m.workload(t, mt.tn, 1).addr, m.workload(t, mt.tn, 2).addr, remoteHost} + p, err := startProbe(m.pod.Spec.NodeName, 1, "aggregate-vpc-"+mt.tn.vpc, + append(connectArgs(m.pod, mt.token, mt.tn.vpc, mt.id, 5*time.Second, dsts), + "-src", strings.Join(masqueSubdivided, ","))...) + if err != nil { + t.Fatal(err) + } + own := m.routeAdvertisements(t) + evs := p.wait(m.l, t) + pairs := len(masqueSubdivided) * len(dsts) + requireSession(t, "VPC "+mt.tn.vpc+"'s request", evs, 5*time.Second, pairs) + perPair := sentCount(evs) / pairs + for _, src := range masqueSubdivided { + for _, dst := range dsts { + n := 0 + for _, e := range echoes(evs, dst) { + if e.Dst == src { + n++ + } + } + m.l.record(t, fmt.Sprintf("%s to %s", src, dst), fmt.Sprintf("%d/%d", n, perPair)) + if perPair == 0 || n < perPair*9/10 { + t.Errorf("%s to %s: %d of %d answered", src, dst, n, perPair) + } + } + } + if n := others(evs); n > 0 { + t.Errorf("the client received %d packets that were not its replies", n) + } + + m.l.record(t, "advertisements of the connector's route", own) + if len(own) != 1 { + t.Errorf("route %s has %d BGPAdvertisements %v, want one", provider.RouteName(mt.vpcUID, + netip.MustParsePrefix(masquePrefix)), len(own), own) + } +} + +// routeAdvertisements waits while VPC 10's connector is connected until its +// route is advertised, and returns its BGPAdvertisements. It fails for one +// that carries anything but the /96, and for any BGPAdvertisement carrying a +// longer prefix inside the /96. +func (m *masque) routeAdvertisements(t *testing.T) []string { + t.Helper() + prefix := netip.MustParsePrefix(masquePrefix) + route := masqueNS + "/" + provider.RouteName(m.a.vpcUID, prefix) + var advs networkv1alpha1.BGPAdvertisementList + eventually(t, 4*time.Second, 200*time.Millisecond, "route "+route+" advertised", func() (bool, error) { + if err := m.l.c.List(context.Background(), &advs, client.InNamespace(systemNS)); err != nil { + return false, err + } + return slices.ContainsFunc(advs.Items, func(a networkv1alpha1.BGPAdvertisement) bool { + return a.Annotations[crdnames.AnnotationEdgeRoute] == route + }), nil + }) + var own []string + for _, a := range advs.Items { + if a.Annotations[crdnames.AnnotationEdgeRoute] == route { + own = append(own, fmt.Sprintf("%s %v", a.Name, a.Spec.Prefixes)) + if len(a.Spec.Prefixes) != 1 || string(a.Spec.Prefixes[0]) != masquePrefix { + t.Errorf("BGPAdvertisement %s of route %s carries %v, want only %s", a.Name, route, + a.Spec.Prefixes, masquePrefix) + } + } + for _, s := range a.Spec.Prefixes { + if q, err := netip.ParsePrefix(string(s)); err == nil && q.Bits() > prefix.Bits() && + prefix.Contains(q.Addr()) { + t.Errorf("BGPAdvertisement %s carries %s, inside the connector's %s", a.Name, q, prefix) + } + } + } + return own +} diff --git a/tests/edgeconformance/masque_test.go b/tests/edgeconformance/masque_test.go index f3987cd0..41517b0b 100644 --- a/tests/edgeconformance/masque_test.go +++ b/tests/edgeconformance/masque_test.go @@ -102,7 +102,9 @@ type masque struct { // deployed agent creates the fixture's PacketAccess devices and the // unprivileged fixture opens them, two VPCs with identical connector /96s // stay isolated in both directions, NAT egress works from the attachment -// node, two connectors share one attachment and ending one leaves the +// node, return traffic reaches client-managed addresses in several +// subdivisions of a connector's /96 through its one aggregate route, two +// connectors share one attachment and ending one leaves the // other forwarding, a restarted provider process adopts its attachments, // router maintenance keeps an attachment in use, a connector's second // request replaces its first without both forwarding, a stalled provider, @@ -127,6 +129,7 @@ func TestMASQUE(t *testing.T) { t.Run("A06-packet-access-registration", m.rowRegistration) t.Run("M07-isolation", func(t *testing.T) { m.isolation(t, "cold") }) t.Run("nat-egress", m.rowNATEgress) + t.Run("M12-aggregate-routing", m.rowAggregateRouting) t.Run("A16-pod-link-route-drift", m.rowRouteDrift) t.Run("shared-attachment-cancel", m.rowSharedCancel) t.Run("maintenance-during-use", m.rowMaintenance)