mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 03:11:46 +00:00
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:
committed by
Julian Sommer
parent
da33e574d4
commit
f320e2df41
@@ -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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[]}
|
||||
Reference in New Issue
Block a user