From f320e2df41664f160bce535cf934fff15b289938 Mon Sep 17 00:00:00 2001 From: Josh Siviour Date: Thu, 2 Jul 2026 14:33:27 +1000 Subject: [PATCH] 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. --- spinifex/network/ovn/ovntest/encoding_test.go | 79 ++++++ spinifex/network/ovn/ovntest/ovn-nb.ovsschema | 145 +++++++++++ spinifex/network/ovn/ovntest/server.go | 110 ++++++++ spinifex/network/ovn/ovntest/server_test.go | 44 ++++ .../reconcile/scenario_egress_live_test.go | 77 ++++++ .../scenario_kitchensink_live_test.go | 124 +++++++++ .../reconcile/scenario_kv_live_test.go | 105 ++++++++ .../network/reconcile/scenario_live_test.go | 243 ++++++++++++++++++ .../reconcile/scenario_prune_live_test.go | 79 ++++++ .../reconcile/testdata/kitchen_sink_nb.golden | 18 ++ 10 files changed, 1024 insertions(+) create mode 100644 spinifex/network/ovn/ovntest/encoding_test.go create mode 100644 spinifex/network/ovn/ovntest/ovn-nb.ovsschema create mode 100644 spinifex/network/ovn/ovntest/server.go create mode 100644 spinifex/network/ovn/ovntest/server_test.go create mode 100644 spinifex/network/reconcile/scenario_egress_live_test.go create mode 100644 spinifex/network/reconcile/scenario_kitchensink_live_test.go create mode 100644 spinifex/network/reconcile/scenario_kv_live_test.go create mode 100644 spinifex/network/reconcile/scenario_live_test.go create mode 100644 spinifex/network/reconcile/scenario_prune_live_test.go create mode 100644 spinifex/network/reconcile/testdata/kitchen_sink_nb.golden diff --git a/spinifex/network/ovn/ovntest/encoding_test.go b/spinifex/network/ovn/ovntest/encoding_test.go new file mode 100644 index 000000000..3f063bd26 --- /dev/null +++ b/spinifex/network/ovn/ovntest/encoding_test.go @@ -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 +} diff --git a/spinifex/network/ovn/ovntest/ovn-nb.ovsschema b/spinifex/network/ovn/ovntest/ovn-nb.ovsschema new file mode 100644 index 000000000..d48e517cf --- /dev/null +++ b/spinifex/network/ovn/ovntest/ovn-nb.ovsschema @@ -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"}} + } + } + } +} diff --git a/spinifex/network/ovn/ovntest/server.go b/spinifex/network/ovn/ovntest/server.go new file mode 100644 index 000000000..354ffce1f --- /dev/null +++ b/spinifex/network/ovn/ovntest/server.go @@ -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) +} diff --git a/spinifex/network/ovn/ovntest/server_test.go b/spinifex/network/ovn/ovntest/server_test.go new file mode 100644 index 000000000..06655f78e --- /dev/null +++ b/spinifex/network/ovn/ovntest/server_test.go @@ -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) + } +} diff --git a/spinifex/network/reconcile/scenario_egress_live_test.go b/spinifex/network/reconcile/scenario_egress_live_test.go new file mode 100644 index 000000000..12796700f --- /dev/null +++ b/spinifex/network/reconcile/scenario_egress_live_test.go @@ -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) + } +} diff --git a/spinifex/network/reconcile/scenario_kitchensink_live_test.go b/spinifex/network/reconcile/scenario_kitchensink_live_test.go new file mode 100644 index 000000000..3e69f0023 --- /dev/null +++ b/spinifex/network/reconcile/scenario_kitchensink_live_test.go @@ -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) + } +} diff --git a/spinifex/network/reconcile/scenario_kv_live_test.go b/spinifex/network/reconcile/scenario_kv_live_test.go new file mode 100644 index 000000000..6c90bdedc --- /dev/null +++ b/spinifex/network/reconcile/scenario_kv_live_test.go @@ -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) + } +} diff --git a/spinifex/network/reconcile/scenario_live_test.go b/spinifex/network/reconcile/scenario_live_test.go new file mode 100644 index 000000000..b7676d570 --- /dev/null +++ b/spinifex/network/reconcile/scenario_live_test.go @@ -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") +} diff --git a/spinifex/network/reconcile/scenario_prune_live_test.go b/spinifex/network/reconcile/scenario_prune_live_test.go new file mode 100644 index 000000000..6ccbbaf32 --- /dev/null +++ b/spinifex/network/reconcile/scenario_prune_live_test.go @@ -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) + } +} diff --git a/spinifex/network/reconcile/testdata/kitchen_sink_nb.golden b/spinifex/network/reconcile/testdata/kitchen_sink_nb.golden new file mode 100644 index 000000000..037598355 --- /dev/null +++ b/spinifex/network/reconcile/testdata/kitchen_sink_nb.golden @@ -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: 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: 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: Enabled: Up: 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: Enabled: Up: 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: Up: 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: Up: 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: Enabled: Up: 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: Enabled: Up: 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: 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[]} \ No newline at end of file