test(network): in-process OVN reconcile harness + live scenarios (#465)

* test(network): in-process OVN NB reconcile harness

Add ovntest.StartNB: an in-process OVN Northbound DB backed by libovsdb's
pure-Go OVSDB server (no ovsdb-server/ovn-northd C daemons) plus a vendored
NB schema subset matching nbdb. Real LiveClient connects over a unix socket,
so reconcile scenarios exercise intent generation, reconcile wiring, LiveClient
and OVSDB transaction semantics in milliseconds without VMs.

Add live reconcile scenarios asserting NB rows and idempotency:
- VPC + subnet + SG + ENI from a hand-built IntentState
- the same seeded through JetStream KV via LoadIntentFromKV (front-to-back)
- IGW attach + drop gate + public-instance exemption (Logical_Router_Policy)

The idempotency snapshot normalizes UUID and pointer churn so it asserts
logical stability; datapath and NB->SB translation stay in the e2e suites.

* test: add orphan-prune and kitchen-sink golden live NB scenarios

Extend the in-process OVN reconcile harness with the two P0 completeness
scenarios. TestScenario_OrphanPrune_Live locks the teardown path and the
ReconcileApplyOnly startup-race guard: apply-only must not prune, full
Reconcile must, and re-pruning is idempotent. TestScenario_KitchenSinkGolden_Live
reconciles a maximal intent exercising every applyX path (VPC, public+private
subnets, two ENIs, SG with tenant rules, IGW, associated EIP, NAT gateway,
IGW/NATGW reroutes, drop gates) and asserts the normalized NB snapshot against
a checked-in golden, mechanically catching an unwired or mis-wired resource
type. Regenerate the golden with UPDATE_GOLDEN=1 go test.
This commit is contained in:
Josh Siviour
2026-07-02 14:33:27 +10:00
committed by Julian Sommer
parent da33e574d4
commit f320e2df41
10 changed files with 1024 additions and 0 deletions
@@ -0,0 +1,79 @@
package ovntest
import (
"context"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/network/ovn"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
)
// TestStartNB_ColumnEncoding exercises the trickier NB column kinds end-to-end:
// a pointer *bool (Enabled), sets (Addresses/PortSecurity) and the Ensure
// wait-op idempotency path. A schema/model optionality mismatch would surface
// here as an OVSDB transaction error rather than at model construction.
func TestStartNB_ColumnEncoding(t *testing.T) {
nb := StartNB(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cli := ovn.NewLiveClient(nb.Endpoint)
if err := cli.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
defer cli.Close()
// EnsureLogicalSwitch twice must converge to a single row, not insert a
// duplicate. UUID equality is not asserted: the insert path returns a
// client-side named UUID while the existing-row path returns the persisted
// one, so identity is checked via the row count and name instead.
ls := &nbdb.LogicalSwitch{Name: "ls-enc"}
if _, err := cli.EnsureLogicalSwitch(ctx, ls); err != nil {
t.Fatalf("EnsureLogicalSwitch #1: %v", err)
}
if _, err := cli.EnsureLogicalSwitch(ctx, &nbdb.LogicalSwitch{Name: "ls-enc"}); err != nil {
t.Fatalf("EnsureLogicalSwitch #2: %v", err)
}
switches, err := cli.ListLogicalSwitches(ctx)
if err != nil {
t.Fatalf("ListLogicalSwitches: %v", err)
}
if n := countByName(switches, "ls-enc"); n != 1 {
t.Fatalf("ls-enc row count = %d, want 1 (Ensure not idempotent)", n)
}
enabled := true
lsp := &nbdb.LogicalSwitchPort{
Name: "lsp-enc",
Type: "",
Addresses: []string{"02:00:00:00:00:01 10.0.0.5"},
PortSecurity: []string{"02:00:00:00:00:01 10.0.0.5"},
Enabled: &enabled,
}
if err := cli.CreateLogicalSwitchPort(ctx, "ls-enc", lsp); err != nil {
t.Fatalf("CreateLogicalSwitchPort: %v", err)
}
got, err := cli.GetLogicalSwitchPort(ctx, "lsp-enc")
if err != nil {
t.Fatalf("GetLogicalSwitchPort: %v", err)
}
if got.Enabled == nil || !*got.Enabled {
t.Fatalf("Enabled = %v, want true", got.Enabled)
}
if len(got.Addresses) != 1 || got.Addresses[0] != "02:00:00:00:00:01 10.0.0.5" {
t.Fatalf("Addresses = %v, want single MAC/IP", got.Addresses)
}
}
// countByName returns how many switches carry the given name.
func countByName(switches []nbdb.LogicalSwitch, name string) int {
n := 0
for i := range switches {
if switches[i].Name == name {
n++
}
}
return n
}
@@ -0,0 +1,145 @@
{
"name": "OVN_Northbound",
"version": "5.35.0",
"tables": {
"Logical_Switch": {
"isRoot": true,
"indexes": [["name"]],
"columns": {
"name": {"type": "string"},
"ports": {"type": {"key": {"type": "uuid", "refTable": "Logical_Switch_Port", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"acls": {"type": {"key": {"type": "uuid", "refTable": "ACL", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"dns_records": {"type": {"key": {"type": "uuid", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},
"other_config": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"Logical_Switch_Port": {
"isRoot": true,
"indexes": [["name"]],
"columns": {
"name": {"type": "string"},
"type": {"type": "string"},
"addresses": {"type": {"key": "string", "min": 0, "max": "unlimited"}},
"port_security": {"type": {"key": "string", "min": 0, "max": "unlimited"}},
"dhcpv4_options": {"type": {"key": {"type": "uuid", "refTable": "DHCP_Options", "refType": "weak"}, "min": 0, "max": 1}},
"enabled": {"type": {"key": "boolean", "min": 0, "max": 1}},
"up": {"type": {"key": "boolean", "min": 0, "max": 1}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},
"options": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"Logical_Router": {
"isRoot": true,
"indexes": [["name"]],
"columns": {
"name": {"type": "string"},
"ports": {"type": {"key": {"type": "uuid", "refTable": "Logical_Router_Port", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"static_routes": {"type": {"key": {"type": "uuid", "refTable": "Logical_Router_Static_Route", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"nat": {"type": {"key": {"type": "uuid", "refTable": "NAT", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"policies": {"type": {"key": {"type": "uuid", "refTable": "Logical_Router_Policy", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"enabled": {"type": {"key": "boolean", "min": 0, "max": 1}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},
"options": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"Logical_Router_Port": {
"isRoot": true,
"indexes": [["name"]],
"columns": {
"name": {"type": "string"},
"mac": {"type": "string"},
"networks": {"type": {"key": "string", "min": 0, "max": "unlimited"}},
"gateway_chassis": {"type": {"key": {"type": "uuid", "refTable": "Gateway_Chassis", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},
"options": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"DHCP_Options": {
"isRoot": true,
"columns": {
"cidr": {"type": "string"},
"options": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"NAT": {
"isRoot": true,
"columns": {
"type": {"type": "string"},
"external_ip": {"type": "string"},
"logical_ip": {"type": "string"},
"logical_port": {"type": {"key": "string", "min": 0, "max": 1}},
"external_mac": {"type": {"key": "string", "min": 0, "max": 1}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},
"options": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"Logical_Router_Static_Route": {
"isRoot": true,
"columns": {
"ip_prefix": {"type": "string"},
"nexthop": {"type": "string"},
"output_port": {"type": {"key": "string", "min": 0, "max": 1}},
"policy": {"type": {"key": "string", "min": 0, "max": 1}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"Logical_Router_Policy": {
"isRoot": true,
"columns": {
"priority": {"type": "integer"},
"match": {"type": "string"},
"action": {"type": "string"},
"nexthop": {"type": {"key": "string", "min": 0, "max": 1}},
"nexthops": {"type": {"key": "string", "min": 0, "max": "unlimited"}},
"options": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"Port_Group": {
"isRoot": true,
"indexes": [["name"]],
"columns": {
"name": {"type": "string"},
"ports": {"type": {"key": {"type": "uuid", "refTable": "Logical_Switch_Port", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"acls": {"type": {"key": {"type": "uuid", "refTable": "ACL", "refType": "weak"}, "min": 0, "max": "unlimited"}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"ACL": {
"isRoot": true,
"columns": {
"name": {"type": {"key": "string", "min": 0, "max": 1}},
"direction": {"type": "string"},
"priority": {"type": "integer"},
"match": {"type": "string"},
"action": {"type": "string"},
"log": {"type": "boolean"},
"severity": {"type": {"key": "string", "min": 0, "max": 1}},
"meter": {"type": {"key": "string", "min": 0, "max": 1}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"Address_Set": {
"isRoot": true,
"indexes": [["name"]],
"columns": {
"name": {"type": "string"},
"addresses": {"type": {"key": "string", "min": 0, "max": "unlimited"}},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
},
"Gateway_Chassis": {
"isRoot": true,
"indexes": [["name"]],
"columns": {
"name": {"type": "string"},
"chassis_name": {"type": "string"},
"priority": {"type": "integer"},
"external_ids": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},
"options": {"type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}}
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
// Package ovntest boots an in-process OVN Northbound database for tests. It
// runs libovsdb's pure-Go OVSDB server (no ovsdb-server / ovn-northd C daemons)
// backed by an in-memory store, so higher layers can drive a real LiveClient
// against a real NB DB in milliseconds. It exercises intent and reconcile
// wiring plus OVSDB transaction semantics; datapath and NB->SB translation stay
// in the VM-level e2e suites.
package ovntest
import (
"context"
_ "embed"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/go-logr/logr"
"github.com/ovn-kubernetes/libovsdb/database/inmemory"
"github.com/ovn-kubernetes/libovsdb/model"
"github.com/ovn-kubernetes/libovsdb/ovsdb"
"github.com/ovn-kubernetes/libovsdb/server"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
)
// nbSchema is the OVN Northbound schema subset covering the tables nbdb models.
// The client model (nbdb.FullDatabaseModel) is a subset of it and validates
// against it at construction.
//
//go:embed ovn-nb.ovsschema
var nbSchema []byte
// NB is a running in-process OVN Northbound server. Endpoint is the libovsdb
// dial string ("unix:/path/to/sock"). Shutdown is registered via t.Cleanup.
type NB struct {
Endpoint string
srv *server.OvsdbServer
sockdir string
}
// StartNB boots the in-memory NB server on a unix socket and returns once it is
// ready to accept connections. It fails the test on any setup error and
// registers cleanup. The socket lives under os.MkdirTemp (short path) to stay
// under the ~108 byte sun_path limit; t.TempDir would overflow in worktrees.
func StartNB(t testing.TB) *NB {
t.Helper()
clientModel, err := nbdb.FullDatabaseModel()
if err != nil {
t.Fatalf("nbdb.FullDatabaseModel: %v", err)
}
var schema ovsdb.DatabaseSchema
if err := json.Unmarshal(nbSchema, &schema); err != nil {
t.Fatalf("parse ovn-nb.ovsschema: %v", err)
}
dbModel, errs := model.NewDatabaseModel(schema, clientModel)
if len(errs) > 0 {
t.Fatalf("model.NewDatabaseModel: %v", errs)
}
logger := logr.Discard()
db := inmemory.NewDatabase(map[string]model.ClientDBModel{"OVN_Northbound": clientModel}, &logger)
srv, err := server.NewOvsdbServer(db, &logger, dbModel)
if err != nil {
t.Fatalf("server.NewOvsdbServer: %v", err)
}
//nolint:usetesting // t.TempDir() yields a path too long for the unix socket
// sun_path (108-byte limit); MkdirTemp("") keeps it short under /tmp.
sockdir, err := os.MkdirTemp("", "ovnnb")
if err != nil {
t.Fatalf("mkdir temp: %v", err)
}
sock := filepath.Join(sockdir, "nb.sock")
go func() {
// Serve blocks until Close; a post-Close Accept error is expected.
_ = srv.Serve("unix", sock)
}()
nb := &NB{Endpoint: "unix:" + sock, srv: srv, sockdir: sockdir}
t.Cleanup(nb.shutdown)
if err := nb.waitReady(2 * time.Second); err != nil {
t.Fatalf("NB server not ready: %v", err)
}
return nb
}
// waitReady polls until Serve has bound the listener or the deadline elapses.
func (nb *NB) waitReady(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if nb.srv.Ready() {
return nil
}
time.Sleep(5 * time.Millisecond)
}
return context.DeadlineExceeded
}
func (nb *NB) shutdown() {
nb.srv.Close()
_ = os.RemoveAll(nb.sockdir)
}
@@ -0,0 +1,44 @@
package ovntest
import (
"context"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/network/ovn"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
)
// TestStartNB_RoundTrip proves the in-process NB server accepts our NB schema,
// a real LiveClient connects to it, and a write round-trips through OVSDB.
func TestStartNB_RoundTrip(t *testing.T) {
nb := StartNB(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cli := ovn.NewLiveClient(nb.Endpoint)
if err := cli.Connect(ctx); err != nil {
t.Fatalf("LiveClient.Connect: %v", err)
}
defer cli.Close()
want := &nbdb.LogicalSwitch{
Name: "ls-test",
ExternalIDs: map[string]string{"spinifex:subnet_id": "subnet-abc"},
}
if err := cli.CreateLogicalSwitch(ctx, want); err != nil {
t.Fatalf("CreateLogicalSwitch: %v", err)
}
got, err := cli.GetLogicalSwitch(ctx, "ls-test")
if err != nil {
t.Fatalf("GetLogicalSwitch: %v", err)
}
if got.Name != "ls-test" {
t.Fatalf("Name = %q, want ls-test", got.Name)
}
if got.ExternalIDs["spinifex:subnet_id"] != "subnet-abc" {
t.Fatalf("ExternalIDs = %v, want spinifex:subnet_id=subnet-abc", got.ExternalIDs)
}
}
@@ -0,0 +1,77 @@
package reconcile
// Egress scenarios extend the live-NB harness to IGW attachment and per-subnet
// egress steering (drop gates + public-instance exemptions). These exercise the
// Logical_Router_Policy path, which the core VPC/subnet/SG/ENI scenario does
// not touch, and re-assert idempotency over the policy-enriched NB snapshot.
import (
"context"
"net/netip"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/network/external"
"github.com/mulgadc/spinifex/spinifex/network/policy"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
// TestScenario_IGWDropGatePublicInstance_Live attaches an IGW to a routeless
// subnet with a public-IP instance and asserts the reconciler installs both the
// drop gate and the /32 public-instance exemption above it, then that a second
// reconcile leaves the NB unchanged. This is the post-reboot regression shape
// from TestReconcile_PublicInstanceExemptFromDropGate, driven against real OVN.
func TestScenario_IGWDropGatePublicInstance_Live(t *testing.T) {
rec, cli := newLiveReconciler(t)
ctx := context.Background()
intent := freshIntent(t)
intent.IGWs["vpc-a"] = external.IGWSpec{VPCID: "vpc-a", InternetGatewayID: "igw-a"}
port := intent.Ports["eni-a"]
port.PublicIP = netip.MustParseAddr("192.168.0.50")
intent.Ports["eni-a"] = port
intent.DropGates[subnetEgressKey("subnet-a", netip.MustParsePrefix("0.0.0.0/0"))] = SubnetEgressIntent{
VPCID: "vpc-a", SubnetID: "subnet-a", DestCIDR: netip.MustParsePrefix("0.0.0.0/0"),
}
if err := rec.Reconcile(ctx, intent); err != nil {
t.Fatalf("Reconcile #1: %v", err)
}
policies, err := cli.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-a"))
if err != nil {
t.Fatalf("ListLogicalRouterPolicies: %v", err)
}
var reroute, drop bool
var reroutePrio, dropPrio int
var rerouteMatch string
for _, p := range policies {
switch p.Priority {
case policy.SystemInstanceEgressPriority:
reroute, reroutePrio, rerouteMatch = true, p.Priority, p.Match
case policy.SubnetEgressPriorityDrop:
drop, dropPrio = true, p.Priority
}
}
if !drop {
t.Fatalf("drop gate (priority %d) missing on routeless IGW subnet", policy.SubnetEgressPriorityDrop)
}
if !reroute {
t.Fatalf("public-instance exemption (priority %d) missing", policy.SystemInstanceEgressPriority)
}
if !strings.Contains(rerouteMatch, "ip4.src == 10.0.1.10/32") {
t.Errorf("exemption match = %q, want ip4.src == 10.0.1.10/32", rerouteMatch)
}
if reroutePrio <= dropPrio {
t.Errorf("exemption priority %d must sit above drop gate %d", reroutePrio, dropPrio)
}
before := snapshotNB(t, ctx, cli)
if err := rec.Reconcile(ctx, intent); err != nil {
t.Fatalf("Reconcile #2: %v", err)
}
after := snapshotNB(t, ctx, cli)
if before != after {
t.Fatalf("reconcile not idempotent; NB changed on second pass:\n--- before ---\n%s\n--- after ---\n%s", before, after)
}
}
@@ -0,0 +1,124 @@
package reconcile
// Kitchen-sink golden scenario. It reconciles an intent that exercises every
// resource type the reconciler wires — VPC, two subnets, two ENIs, an SG with
// real tenant rules, IGW attach, associated EIP, NAT gateway, IGW/NATGW egress
// reroutes and a drop gate — into a real in-process NB, then asserts the whole
// normalized NB snapshot matches a checked-in golden. This mechanically enforces
// failure-mode 2: adding a resource type without wiring it (or wiring it wrong)
// diffs the golden and fails. Regenerate with UPDATE_GOLDEN=1 go test.
import (
"context"
"net"
"net/netip"
"os"
"path/filepath"
"testing"
"github.com/mulgadc/spinifex/spinifex/network/external"
"github.com/mulgadc/spinifex/spinifex/network/policy"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
// kitchenSinkIntent builds a maximal IntentState touching every applyX path:
// a public subnet (IGW egress) and a private subnet (NATGW egress) under one VPC.
func kitchenSinkIntent(t *testing.T) IntentState {
t.Helper()
macA, _ := net.ParseMAC("02:00:00:00:00:01")
macB, _ := net.ParseMAC("02:00:00:00:00:02")
defaultRoute := netip.MustParsePrefix("0.0.0.0/0")
pubCIDR := netip.MustParsePrefix("10.0.1.0/24")
privCIDR := netip.MustParsePrefix("10.0.2.0/24")
return IntentState{
VPCs: map[string]topology.VPCSpec{
"vpc-a": {VPCID: "vpc-a", CIDR: netip.MustParsePrefix("10.0.0.0/16"), VNI: 100},
},
Subnets: map[string]topology.SubnetSpec{
"subnet-pub": {SubnetID: "subnet-pub", VPCID: "vpc-a", CIDR: pubCIDR},
"subnet-priv": {SubnetID: "subnet-priv", VPCID: "vpc-a", CIDR: privCIDR},
},
Ports: map[string]topology.PortSpec{
"eni-pub": {PortID: "eni-pub", SubnetID: "subnet-pub", VPCID: "vpc-a",
PrivateIP: netip.MustParseAddr("10.0.1.10"), MAC: macA, SGIDs: []string{"sg-a"},
PublicIP: netip.MustParseAddr("192.168.0.50")},
"eni-priv": {PortID: "eni-priv", SubnetID: "subnet-priv", VPCID: "vpc-a",
PrivateIP: netip.MustParseAddr("10.0.2.10"), MAC: macB, SGIDs: []string{"sg-a"}},
},
SGs: map[string]policy.SGSpec{
"sg-a": {GroupID: "sg-a", VPCID: "vpc-a",
IngressRules: []policy.Rule{
{IPProtocol: "tcp", FromPort: 22, ToPort: 22, CIDR: "0.0.0.0/0"},
{IPProtocol: "tcp", FromPort: 443, ToPort: 443, CIDR: "0.0.0.0/0"},
},
EgressRules: []policy.Rule{
{IPProtocol: "-1", FromPort: 0, ToPort: 0, CIDR: "0.0.0.0/0"},
}},
},
IGWs: map[string]external.IGWSpec{
"vpc-a": {VPCID: "vpc-a", InternetGatewayID: "igw-a"},
},
EIPs: map[string]policy.EIPSpec{
"10.0.1.10": {VPCID: "vpc-a", ExternalIP: "192.168.0.50", LogicalIP: "10.0.1.10",
PortName: topology.Port("eni-pub"), MAC: "02:00:00:00:00:01"},
},
NATGWs: map[string]policy.NATGWSpec{
natgwSpecKey("nat-a", privCIDR.String()): {VPCID: "vpc-a", NATGatewayID: "nat-a",
PublicIP: "192.168.0.60", SubnetCIDR: privCIDR.String()},
},
IGWRoutes: map[string]SubnetEgressIntent{
subnetEgressKey("subnet-pub", defaultRoute): {VPCID: "vpc-a", SubnetID: "subnet-pub", DestCIDR: defaultRoute},
},
NATGWRoutes: map[string]SubnetEgressIntent{
subnetEgressKey("subnet-priv", defaultRoute): {VPCID: "vpc-a", SubnetID: "subnet-priv", DestCIDR: defaultRoute},
},
DropGates: map[string]SubnetEgressIntent{
subnetEgressKey("subnet-pub", defaultRoute): {VPCID: "vpc-a", SubnetID: "subnet-pub", DestCIDR: defaultRoute},
subnetEgressKey("subnet-priv", defaultRoute): {VPCID: "vpc-a", SubnetID: "subnet-priv", DestCIDR: defaultRoute},
},
}
}
func TestScenario_KitchenSinkGolden_Live(t *testing.T) {
rec, cli := newLiveReconciler(t)
ctx := context.Background()
intent := kitchenSinkIntent(t)
if err := rec.Reconcile(ctx, intent); err != nil {
t.Fatalf("Reconcile #1: %v", err)
}
got := snapshotNB(t, ctx, cli)
goldenPath := filepath.Join("testdata", "kitchen_sink_nb.golden")
if os.Getenv("UPDATE_GOLDEN") != "" {
if err := os.MkdirAll(filepath.Dir(goldenPath), 0o755); err != nil {
t.Fatalf("mkdir testdata: %v", err)
}
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
t.Logf("golden updated: %s", goldenPath)
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (regenerate with UPDATE_GOLDEN=1): %v", err)
}
if got != string(want) {
t.Fatalf("NB snapshot differs from golden; a resource type may be unwired or mis-wired.\n"+
"Run UPDATE_GOLDEN=1 go test to accept intended changes.\n--- want ---\n%s\n--- got ---\n%s", want, got)
}
// Idempotency over the full kitchen-sink state.
before := snapshotNB(t, ctx, cli)
if err := rec.Reconcile(ctx, intent); err != nil {
t.Fatalf("Reconcile #2: %v", err)
}
after := snapshotNB(t, ctx, cli)
if before != after {
t.Fatalf("reconcile not idempotent; NB changed on second pass:\n--- before ---\n%s\n--- after ---\n%s", before, after)
}
}
@@ -0,0 +1,105 @@
package reconcile
// This scenario drives the full front-to-back path: seed the control-plane KV
// (the same JetStream buckets the EC2 VPC handlers write) -> LoadIntentFromKV
// -> reconcile -> real in-process OVN NB. It proves the KV load layer and the
// reconcile wiring agree, not just a hand-built IntentState.
import (
"context"
"encoding/json"
"testing"
vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// seedKV marshals rec and stores it under key in the named JetStream KV bucket,
// creating the bucket if absent — mirroring how the VPC handlers persist state.
func seedKV(t *testing.T, js nats.JetStreamContext, bucket, key string, rec any) {
t.Helper()
kv, err := utils.GetOrCreateKVBucket(js, bucket, 10)
if err != nil {
t.Fatalf("GetOrCreateKVBucket %s: %v", bucket, err)
}
b, err := json.Marshal(rec)
if err != nil {
t.Fatalf("marshal %s/%s: %v", bucket, key, err)
}
if _, err := kv.Put(key, b); err != nil {
t.Fatalf("kv.Put %s/%s: %v", bucket, key, err)
}
}
// TestScenario_LoadIntentFromKV_Live seeds a VPC + subnet + SG + ENI into the
// control-plane KV, loads intent from it, reconciles into a real NB DB, and
// asserts the expected rows plus idempotency.
func TestScenario_LoadIntentFromKV_Live(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
seedKV(t, js, vpc.KVBucketVPCs, "vpc-a", vpc.VPCRecord{
VpcId: "vpc-a", CidrBlock: "10.0.0.0/16", VNI: 100,
})
seedKV(t, js, vpc.KVBucketSubnets, "subnet-a", vpc.SubnetRecord{
SubnetId: "subnet-a", VpcId: "vpc-a", CidrBlock: "10.0.1.0/24",
})
seedKV(t, js, vpc.KVBucketSecurityGroups, "sg-a", vpc.SecurityGroupRecord{
GroupId: "sg-a", VpcId: "vpc-a",
})
seedKV(t, js, vpc.KVBucketENIs, "eni-a", vpc.ENIRecord{
NetworkInterfaceId: "eni-a", SubnetId: "subnet-a", VpcId: "vpc-a",
PrivateIpAddress: "10.0.1.10", MacAddress: "02:00:00:00:00:01",
SecurityGroupIds: []string{"sg-a"},
})
ctx := context.Background()
intent, err := LoadIntentFromKV(ctx, js, "us-east-1a")
if err != nil {
t.Fatalf("LoadIntentFromKV: %v", err)
}
// Spot-check the load layer produced the expected specs.
if v, ok := intent.VPCs["vpc-a"]; !ok || v.VNI != 100 {
t.Fatalf("intent.VPCs[vpc-a] = %+v, ok=%v", v, ok)
}
if _, ok := intent.Subnets["subnet-a"]; !ok {
t.Fatalf("intent.Subnets missing subnet-a: %+v", intent.Subnets)
}
if _, ok := intent.SGs["sg-a"]; !ok {
t.Fatalf("intent.SGs missing sg-a: %+v", intent.SGs)
}
p, ok := intent.Ports["eni-a"]
if !ok {
t.Fatalf("intent.Ports missing eni-a: %+v", intent.Ports)
}
if p.PrivateIP.String() != "10.0.1.10" || len(p.SGIDs) != 1 || p.SGIDs[0] != "sg-a" {
t.Fatalf("intent.Ports[eni-a] = %+v, want IP 10.0.1.10 SG sg-a", p)
}
rec, cli := newLiveReconciler(t)
if err := rec.Reconcile(ctx, intent); err != nil {
t.Fatalf("Reconcile #1: %v", err)
}
if _, err := cli.GetLogicalRouter(ctx, "vpc-vpc-a"); err != nil {
t.Errorf("VPC router vpc-vpc-a: %v", err)
}
if _, err := cli.GetLogicalSwitch(ctx, "subnet-subnet-a"); err != nil {
t.Errorf("subnet switch subnet-subnet-a: %v", err)
}
if _, err := cli.GetLogicalSwitchPort(ctx, "port-eni-a"); err != nil {
t.Errorf("ENI port port-eni-a: %v", err)
}
before := snapshotNB(t, ctx, cli)
if err := rec.Reconcile(ctx, intent); err != nil {
t.Fatalf("Reconcile #2: %v", err)
}
after := snapshotNB(t, ctx, cli)
if before != after {
t.Fatalf("reconcile not idempotent; NB changed on second pass:\n--- before ---\n%s\n--- after ---\n%s", before, after)
}
}
@@ -0,0 +1,243 @@
package reconcile
// Scenario tests drive the real reconciler against a real (in-process) OVN
// Northbound DB via ovntest, instead of the hand-rolled ovn/mock. This
// exercises the full path — intent -> reconcile wiring -> LiveClient -> OVSDB
// transaction -> NB rows — and asserts the reconcile loop is idempotent by
// snapshotting NB state and re-running. Datapath and NB->SB translation remain
// in the VM-level e2e suites.
import (
"context"
"fmt"
"regexp"
"slices"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/network/external"
"github.com/mulgadc/spinifex/spinifex/network/ovn"
"github.com/mulgadc/spinifex/spinifex/network/ovn/ovntest"
"github.com/mulgadc/spinifex/spinifex/network/policy"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
// uuidRe and ptrRe strip identity churn from NB row dumps so the idempotency
// check asserts logical stability, not UUID/pointer identity. Real ovsdb
// assigns fresh UUIDs when a row is replaced (SG ACLs are replaced wholesale
// every reconcile) and %+v renders *string fields as pointer addresses.
var (
uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
ptrRe = regexp.MustCompile(`0x[0-9a-f]+`)
)
// newLiveReconciler builds a reconciler around a real in-process OVN NB DB.
// It mirrors newTestReconciler but swaps ovn/mock for a connected LiveClient.
func newLiveReconciler(t *testing.T) (*reconciler, ovn.Client) {
t.Helper()
nb := ovntest.StartNB(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cli := ovn.NewLiveClient(nb.Endpoint)
if err := cli.Connect(ctx); err != nil {
t.Fatalf("LiveClient.Connect: %v", err)
}
t.Cleanup(cli.Close)
sg := policy.NewSecurityGroupManager(cli)
nat, err := policy.NewNATManager(cli, policy.NATModeDistributed)
if err != nil {
t.Fatalf("NewNATManager: %v", err)
}
routes := policy.NewRouteManager(cli)
igw, err := external.NewIGWManager(external.IGWManagerConfig{
OVN: cli,
Routes: routes,
NAT: nat,
Allocator: external.LinkLocalAllocator{},
NATMode: policy.NATModeDistributed,
})
if err != nil {
t.Fatalf("NewIGWManager: %v", err)
}
topo := topology.NewLiveManager(cli)
rec, err := New(Config{
OVN: cli, SG: sg, NAT: nat, Routes: routes, IGW: igw, Topology: topo,
LocalAZ: "us-east-1a", NodeHostname: "test-host",
})
if err != nil {
t.Fatalf("New: %v", err)
}
r, ok := rec.(*reconciler)
if !ok {
t.Fatalf("New returned %T, want *reconciler", rec)
}
return r, cli
}
// TestScenario_VPCSubnetSGPort_Live reconciles a VPC + subnet + SG + ENI into a
// real NB DB, asserts the expected rows exist, then re-runs reconcile and
// asserts the NB snapshot is byte-for-byte unchanged (idempotency).
func TestScenario_VPCSubnetSGPort_Live(t *testing.T) {
rec, cli := newLiveReconciler(t)
ctx := context.Background()
intent := freshIntent(t)
if err := rec.Reconcile(ctx, intent); err != nil {
t.Fatalf("Reconcile #1: %v", err)
}
if _, err := cli.GetLogicalRouter(ctx, "vpc-vpc-a"); err != nil {
t.Errorf("VPC router vpc-vpc-a: %v", err)
}
if _, err := cli.GetLogicalSwitch(ctx, "subnet-subnet-a"); err != nil {
t.Errorf("subnet switch subnet-subnet-a: %v", err)
}
if _, err := cli.GetLogicalSwitchPort(ctx, "port-eni-a"); err != nil {
t.Errorf("ENI port port-eni-a: %v", err)
}
pgName := topology.SecurityGroupPortGroup("sg-a")
pg, err := cli.GetPortGroup(ctx, pgName)
if err != nil {
t.Fatalf("SG port group %s: %v", pgName, err)
}
if !slices.Contains(pg.Ports, mustPortUUID(t, ctx, cli, "port-eni-a")) {
t.Errorf("ENI LSP not a member of SG port group %s", pgName)
}
before := snapshotNB(t, ctx, cli)
if err := rec.Reconcile(ctx, intent); err != nil {
t.Fatalf("Reconcile #2: %v", err)
}
after := snapshotNB(t, ctx, cli)
if before != after {
t.Fatalf("reconcile not idempotent; NB changed on second pass:\n--- before ---\n%s\n--- after ---\n%s", before, after)
}
}
// mustPortUUID returns the persisted UUID of an LSP by name.
func mustPortUUID(t *testing.T, ctx context.Context, cli ovn.Client, name string) string {
t.Helper()
lsp, err := cli.GetLogicalSwitchPort(ctx, name)
if err != nil {
t.Fatalf("GetLogicalSwitchPort %s: %v", name, err)
}
return lsp.UUID
}
// snapshotNB renders a deterministic, identity-normalized dump of every NB row
// the freshIntent scenario touches. Named rows keep a stable token derived from
// their name (so cross-references stay legible); residual UUIDs (ACLs, which
// have no list accessor here) collapse to "X" and pointer addresses to "PTR".
// A logical change — a row added, removed, or a value edited — still diffs.
func snapshotNB(t *testing.T, ctx context.Context, cli ovn.Client) string {
t.Helper()
switches, err := cli.ListLogicalSwitches(ctx)
if err != nil {
t.Fatalf("ListLogicalSwitches: %v", err)
}
routers, err := cli.ListLogicalRouters(ctx)
if err != nil {
t.Fatalf("ListLogicalRouters: %v", err)
}
ports, err := cli.ListLogicalSwitchPorts(ctx)
if err != nil {
t.Fatalf("ListLogicalSwitchPorts: %v", err)
}
pgs, err := cli.ListPortGroups(ctx)
if err != nil {
t.Fatalf("ListPortGroups: %v", err)
}
dhcp, err := cli.ListDHCPOptions(ctx)
if err != nil {
t.Fatalf("ListDHCPOptions: %v", err)
}
// Map each named row's UUID to a stable token so references resolve to
// names instead of churning UUIDs.
tokens := map[string]string{}
for _, s := range switches {
tokens[s.UUID] = "ls:" + s.Name
}
for _, r := range routers {
tokens[r.UUID] = "lr:" + r.Name
}
for _, p := range ports {
tokens[p.UUID] = "lsp:" + p.Name
}
for _, g := range pgs {
tokens[g.UUID] = "pg:" + g.Name
}
for _, d := range dhcp {
tokens[d.UUID] = "dhcp:" + d.CIDR
}
var lines []string
add := func(kind string, rows []string) {
for i := range rows {
rows[i] = kind + " " + normalize(rows[i], tokens)
}
slices.Sort(rows)
lines = append(lines, rows...)
}
sw := make([]string, len(switches))
for i := range switches {
sw[i] = fmt.Sprintf("%+v", switches[i])
}
add("switch", sw)
rt := make([]string, len(routers))
for i := range routers {
rt[i] = fmt.Sprintf("%+v", routers[i])
}
add("router", rt)
lsp := make([]string, len(ports))
for i := range ports {
lsp[i] = fmt.Sprintf("%+v", ports[i])
}
add("port", lsp)
pg := make([]string, len(pgs))
for i := range pgs {
pg[i] = fmt.Sprintf("%+v", pgs[i])
}
add("portgroup", pg)
dh := make([]string, len(dhcp))
for i := range dhcp {
dh[i] = fmt.Sprintf("%+v", dhcp[i])
}
add("dhcp", dh)
// Logical_Router_Policy rows carry egress steering (IGW/NATGW reroutes and
// drop gates); they have no top-level list accessor, so fetch per router.
for _, r := range routers {
pols, err := cli.ListLogicalRouterPolicies(ctx, r.Name)
if err != nil {
t.Fatalf("ListLogicalRouterPolicies %s: %v", r.Name, err)
}
pl := make([]string, len(pols))
for i := range pols {
pl[i] = fmt.Sprintf("%+v", pols[i])
}
add("policy", pl)
}
slices.Sort(lines)
return strings.Join(lines, "\n")
}
// normalize strips identity churn: known UUIDs become name tokens, residual
// UUIDs collapse to "X", and pointer addresses become "PTR".
func normalize(s string, tokens map[string]string) string {
for uuid, tok := range tokens {
s = strings.ReplaceAll(s, uuid, tok)
}
s = uuidRe.ReplaceAllString(s, "X")
return ptrRe.ReplaceAllString(s, "PTR")
}
@@ -0,0 +1,79 @@
package reconcile
// Orphan-prune scenario: create a VPC/subnet/SG/ENI, then reconcile an intent
// that no longer contains the ENI or SG and assert the reconciler tears the NB
// rows down. It also locks the ReconcileApplyOnly startup-race guard: apply-only
// must NOT prune, full Reconcile must. Missed teardown is a top source of stale
// NB state, and the whole prune path is otherwise only covered against ovn/mock.
import (
"context"
"slices"
"testing"
"github.com/mulgadc/spinifex/spinifex/network/policy"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
func TestScenario_OrphanPrune_Live(t *testing.T) {
rec, cli := newLiveReconciler(t)
ctx := context.Background()
// 1. Converge the full state and capture the ENI LSP UUID.
if err := rec.Reconcile(ctx, freshIntent(t)); err != nil {
t.Fatalf("Reconcile (create): %v", err)
}
pgName := topology.SecurityGroupPortGroup("sg-a")
eni, err := cli.GetLogicalSwitchPort(ctx, "port-eni-a")
if err != nil {
t.Fatalf("precondition: ENI port absent after create: %v", err)
}
eniUUID := eni.UUID
if _, err := cli.GetPortGroup(ctx, pgName); err != nil {
t.Fatalf("precondition: SG port group absent after create: %v", err)
}
// 2. Intent with the ENI and SG removed (VPC + subnet retained).
reduced := freshIntent(t)
reduced.Ports = map[string]topology.PortSpec{}
reduced.SGs = map[string]policy.SGSpec{}
// 3. ReconcileApplyOnly must NOT prune (startup-race guard).
if err := rec.ReconcileApplyOnly(ctx, reduced); err != nil {
t.Fatalf("ReconcileApplyOnly: %v", err)
}
if _, err := cli.GetLogicalSwitchPort(ctx, "port-eni-a"); err != nil {
t.Errorf("ApplyOnly pruned the ENI port; startup-race guard broken: %v", err)
}
if _, err := cli.GetPortGroup(ctx, pgName); err != nil {
t.Errorf("ApplyOnly pruned the SG port group; startup-race guard broken: %v", err)
}
// 4. Full Reconcile must prune both.
if err := rec.Reconcile(ctx, reduced); err != nil {
t.Fatalf("Reconcile (prune): %v", err)
}
if _, err := cli.GetLogicalSwitchPort(ctx, "port-eni-a"); err == nil {
t.Errorf("orphan ENI port not pruned")
}
if _, err := cli.GetPortGroup(ctx, pgName); err == nil {
t.Errorf("orphan SG port group not pruned")
}
ls, err := cli.GetLogicalSwitch(ctx, "subnet-subnet-a")
if err != nil {
t.Fatalf("GetLogicalSwitch after prune: %v", err)
}
if slices.Contains(ls.Ports, eniUUID) {
t.Errorf("subnet switch still references pruned ENI port %s", eniUUID)
}
// 5. Prune is idempotent: a second pass leaves NB unchanged.
before := snapshotNB(t, ctx, cli)
if err := rec.Reconcile(ctx, reduced); err != nil {
t.Fatalf("Reconcile (prune #2): %v", err)
}
after := snapshotNB(t, ctx, cli)
if before != after {
t.Fatalf("prune not idempotent; NB changed on second pass:\n--- before ---\n%s\n--- after ---\n%s", before, after)
}
}
@@ -0,0 +1,18 @@
dhcp {UUID:dhcp:10.0.1.0/24 CIDR:10.0.1.0/24 Options:map[dns_server:{8.8.8.8, 1.1.1.1} lease_time:3600 mtu:1442 router:10.0.1.1 server_id:10.0.1.1 server_mac:02:42:e1:1f:07:a8] ExternalIDs:map[spinifex:subnet_id:subnet-pub spinifex:vpc_id:vpc-a]}
dhcp {UUID:dhcp:10.0.2.0/24 CIDR:10.0.2.0/24 Options:map[dns_server:{8.8.8.8, 1.1.1.1} lease_time:3600 mtu:1442 router:10.0.2.1 server_id:10.0.2.1 server_mac:02:56:6c:1d:90:2a] ExternalIDs:map[spinifex:subnet_id:subnet-priv spinifex:vpc_id:vpc-a]}
policy {UUID:X Priority:1000 Match:inport == "rtr-subnet-pub" && ip4.dst == 0.0.0.0/0 && ip4.dst != 10.0.0.0/16 && ip4.dst != 169.254.0.0/16 Action:reroute Nexthop:PTR Nexthops:[] Options:map[] ExternalIDs:map[spinifex:output_port:gw-vpc-a spinifex:subnet:subnet-pub]}
policy {UUID:X Priority:1100 Match:inport == "rtr-subnet-priv" && ip4.dst == 0.0.0.0/0 && ip4.dst != 10.0.0.0/16 && ip4.dst != 169.254.0.0/16 && ip4.dst != 224.0.0.0/4 Action:drop Nexthop:<nil> Nexthops:[] Options:map[] ExternalIDs:map[spinifex:gate:drop spinifex:subnet:subnet-priv]}
policy {UUID:X Priority:1100 Match:inport == "rtr-subnet-pub" && ip4.dst == 0.0.0.0/0 && ip4.dst != 10.0.0.0/16 && ip4.dst != 169.254.0.0/16 && ip4.dst != 224.0.0.0/4 Action:drop Nexthop:<nil> Nexthops:[] Options:map[] ExternalIDs:map[spinifex:gate:drop spinifex:subnet:subnet-pub]}
policy {UUID:X Priority:1200 Match:inport == "rtr-subnet-pub" && ip4.src == 10.0.1.10/32 && ip4.dst == 0.0.0.0/0 && ip4.dst != 10.0.0.0/16 Action:reroute Nexthop:PTR Nexthops:[] Options:map[] ExternalIDs:map[spinifex:output_port:gw-vpc-a spinifex:role:system-instance-egress spinifex:src_ip:10.0.1.10 spinifex:subnet:subnet-pub]}
policy {UUID:X Priority:900 Match:inport == "rtr-subnet-priv" && ip4.dst == 0.0.0.0/0 && ip4.dst != 10.0.0.0/16 && ip4.dst != 169.254.0.0/16 Action:reroute Nexthop:PTR Nexthops:[] Options:map[] ExternalIDs:map[spinifex:output_port:gw-vpc-a spinifex:subnet:subnet-priv]}
port {UUID:lsp:ext-port-shared Name:ext-port-shared Type:localnet Addresses:[unknown] PortSecurity:[] DHCPv4Options:<nil> Enabled:<nil> Up:<nil> ExternalIDs:map[spinifex:role:external-localnet] Options:map[network_name:external]}
port {UUID:lsp:gw-port-vpc-a Name:gw-port-vpc-a Type:router Addresses:[router] PortSecurity:[] DHCPv4Options:<nil> Enabled:<nil> Up:<nil> ExternalIDs:map[spinifex:igw_id:igw-a spinifex:role:gateway spinifex:vpc_id:vpc-a] Options:map[router-port:gw-vpc-a]}
port {UUID:lsp:port-eni-priv Name:port-eni-priv Type: Addresses:[02:00:00:00:00:02 10.0.2.10] PortSecurity:[02:00:00:00:00:02 10.0.2.10] DHCPv4Options:PTR Enabled:<nil> Up:<nil> ExternalIDs:map[spinifex:eni_id:eni-priv spinifex:subnet_id:subnet-priv spinifex:vpc_id:vpc-a] Options:map[]}
port {UUID:lsp:port-eni-pub Name:port-eni-pub Type: Addresses:[02:00:00:00:00:01 10.0.1.10] PortSecurity:[02:00:00:00:00:01 10.0.1.10] DHCPv4Options:PTR Enabled:<nil> Up:<nil> ExternalIDs:map[spinifex:eni_id:eni-pub spinifex:subnet_id:subnet-pub spinifex:vpc_id:vpc-a] Options:map[]}
port {UUID:lsp:rtr-port-subnet-priv Name:rtr-port-subnet-priv Type:router Addresses:[router] PortSecurity:[] DHCPv4Options:<nil> Enabled:<nil> Up:<nil> ExternalIDs:map[spinifex:subnet_id:subnet-priv spinifex:vpc_id:vpc-a] Options:map[router-port:rtr-subnet-priv]}
port {UUID:lsp:rtr-port-subnet-pub Name:rtr-port-subnet-pub Type:router Addresses:[router] PortSecurity:[] DHCPv4Options:<nil> Enabled:<nil> Up:<nil> ExternalIDs:map[spinifex:subnet_id:subnet-pub spinifex:vpc_id:vpc-a] Options:map[router-port:rtr-subnet-pub]}
portgroup {UUID:pg:sg_a Name:sg_a Ports:[lsp:port-eni-pub lsp:port-eni-priv] ACLs:[X X X X X X X] ExternalIDs:map[]}
router {UUID:lr:vpc-vpc-a Name:vpc-vpc-a Ports:[X X X] StaticRoutes:[X] NAT:[X X] Policies:[X X X X X] Enabled:<nil> ExternalIDs:map[spinifex:cidr:10.0.0.0/16 spinifex:vpc_id:vpc-a] Options:map[]}
switch {UUID:ls:ext-shared Name:ext-shared Ports:[lsp:ext-port-shared lsp:gw-port-vpc-a] ACLs:[] DNSRecords:[] ExternalIDs:map[spinifex:role:external] OtherConfig:map[]}
switch {UUID:ls:subnet-subnet-priv Name:subnet-subnet-priv Ports:[lsp:rtr-port-subnet-priv lsp:port-eni-priv] ACLs:[] DNSRecords:[] ExternalIDs:map[spinifex:subnet_id:subnet-priv spinifex:vpc_id:vpc-a] OtherConfig:map[]}
switch {UUID:ls:subnet-subnet-pub Name:subnet-subnet-pub Ports:[lsp:rtr-port-subnet-pub lsp:port-eni-pub] ACLs:[] DNSRecords:[] ExternalIDs:map[spinifex:subnet_id:subnet-pub spinifex:vpc_id:vpc-a] OtherConfig:map[]}