feat(network): ADR-0006 layer redesign + IPsec/OVN hardening (#255)

* refactor(vpcd): remove macvlan bridge mode

Macvlan was a single-NIC SSH-safe hack that predated veth mode and was
never selected by any active config path. Deletion is Phase 0 item 1 of
the network layer redesign (mulga-siv-125.1).

Removed:
- BridgeModeMacvlan const + setMacvlanMAC + ifaceIsMacvlan probe
- Macvlan MAC alignment block in launchService (only fired on dead path)
- Macvlan branch in detectBridgeMode + matching tests
- Macvlan duplicates of IGW/AddNAT centralized-NAT tests (veth versions
  already cover the same path)
- --macvlan flag + macvlan case branch in setup-ovn.sh

Refactored:
- useCentralizedNAT() now == BridgeModeVeth (was != BridgeModeDirect);
  verifyBridgeMode already rejects empty/unknown at startup
- Three IGW-pool tests now explicitly WithBridgeMode(BridgeModeVeth)
  (previously relied on empty-mode defaulting to centralized)

Kept intentionally:
- Deprecation notes in docs/compute/vpc-networking/README.md
- Legacy macvlan-interface cleanup in reset-dev-env.sh + ansible
  teardown role (migration for operator systems with stale spx-ext-*
  interfaces from pre-veth installs)

* feat(vpcd): resolve external CIDR at startup with bounded retry

Block service start until the WAN bridge has an IPv4 address, with a 30s
retry window. Phase 0 item 2 of the network layer redesign
(mulga-siv-125.1). Guards the boot race where vpcd starts before
systemd-networkd finishes DHCP on br-wan, or before netplan applies a
static config.

Skipped when ExternalMode is empty (overlay-only deployments).

Forward-compatible with ADR-0006: the new resolveExternalCIDR /
externalCIDRFromBridge helpers become the backing implementation of
HostWiring.ExternalCIDR() once the network/host package lands in Phase 1.
Until then the resolved value is only used to fail-start on missing
uplink.

* fix(vpcd): cover external CIDR startup paths

Extract launchService's CIDR-resolve block to ensureExternalCIDRReady
helper so it's unit-testable, and add direct tests for
externalCIDRFromBridge (loopback + unknown-interface) to exercise the
original literal body. Closes the diff-coverage gap on Phase 0 commit
15b564d which left these paths unreachable from unit tests.

* refactor(network/ovn): extract L1 OVN client to network/ovn package

Move OVNClient interface, LiveOVNClient, ACLSpec, ErrNATNotFound and
namedUUID helper from services/vpcd to a new spinifex/network/ovn
package. The mock relocates to spinifex/network/ovn/mock as a
sub-package. Storage fields on the mock become exported so vpcd tests
can continue to seed/inspect state across the new package boundary.

services/vpcd retains thin type-alias and var-alias shims so existing
callers compile unchanged; Phase 2 removes those shims as vpcd is torn
down.

Adds -coverpkg=./spinifex/... to test-cover so cross-package test
exercise of mock methods continues to count toward coverage now that
the mock lives outside vpcd.

First sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2). Pure refactor; no functional change. All
existing tests pass; preflight green.

* feat(network/topology): TopologyManager interface + vpcd adapter

Add spinifex/network/topology with Manager interface, VPC/Subnet/Port
specs, and OVN object naming helpers covering the L2 stable contract
(§7 of the redesign plan). Phase 1 keeps existing in-tree OVN names
(rtr-, ext-port-, sg_) and notes the §7.3 targets for a later
reconciliation migration.

TopologyHandler in services/vpcd grows EnsureVPC / DeleteVPC /
EnsureSubnet / DeleteSubnet / EnsurePort / DeletePort /
SetPortSecurityGroups methods that mirror the corresponding NATS
handlers' OVN ops with Spec inputs instead of nats.Msg envelopes,
satisfying topology.Manager via a compile-time check. Existing handlers
are unchanged; the duplication is short-lived (Phase 2 collapses them).

Smoke tests in topology_manager_test.go cover the idempotent create
path, delete cascade, and declarative SG membership move. Deliverable:
TopologyManager.EnsureVPC/Subnet/Port callable; all existing tests
green.

Second sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2).

* feat(network/host): L0 HostWiring interface + physical/veth impls

Phase 1 deliverable for mulga-siv-125.2. Introduces network/host (L0 of
ADR-0006): UplinkMode enum, Wiring interface, and Physical/Veth
implementations with injectable Runner + InterfaceReader for testability.
Not yet wired into daemon/vpcd — Phase 2 replaces daemon.SetupComputeNode
and vpcd's resolveExternalCIDR with calls through this package.

* feat(network/policy): L3 SecurityGroup/NAT/Route managers

Introduce network/policy as the data-plane policy layer (ADR-0006 L3).
Three managers, all backed by network/ovn:

- SecurityGroupManager: clear+add ACL replacement on an existing port
  group; port group lifecycle stays with L2 (topology.Manager).
- NATManager: bound to one NATMode at construction (derived from
  host.UplinkMode). Distributed sets logical_port/external_mac on EIP
  dnat_and_snat rules; centralised leaves them unset. Per-EIP, NAT GW,
  and IGW-default SNAT entrypoints; all deletes idempotent.
- RouteManager: AddStaticRoute is read-then-decide so retries no longer
  duplicate rows (closing the long-standing non-idempotency bug in
  vpcd/topology.go's IGW default-route path).

ACL priority constants and infrastructure ACL builders move from
vpcd/{security,acl}.go into policy/acl.go with their semantics preserved
(default-deny 900/800 logged, DHCP-allow 1050, tenant 1000). No vpcd
wiring changes here — additive only; the reconciler (bead .3.2) and
network/external (bead .3.4) will consume these managers.

bead: mulga-siv-125.3.1

* feat(network/external): L5 IGW/EIP/NATGW managers

Pure additive — new package, no topology.Manager changes, no rewiring of
existing vpcd handlers. IGWManager builds the external switch + localnet +
gateway LRP + default route + chassis HA, with a flows barrier callers can
inject (production: ovn-nbctl --wait=hv sync). EIP/NATGW are thin L5
wrappers over policy.NATManager. GatewayIPAllocator interface lets the
DHCP-backed allocator stay in vpcd until the DHCP-mgr removal bead lands;
LinkLocalAllocator + StaticRangeAllocator ship here.

* feat(network/reconcile): L6 intent-actual reconciler replaces 5-pass startup

Build spinifex/spinifex/network/reconcile/ package per Phase 2.2 of the
network layer redesign. Reads desired state from NATS KV filtered by
vpc.AZ (with legacy empty-AZ matches-all rule), scans actual state from
OVN NB DB, diffs and applies with VPC → Subnet → Port → SG → IGW →
EIP/NATGW topological ordering.

Atomic port-group join via CreateLogicalSwitchPortInGroups closes the
racy gap the legacy scanENIPortMembership pass used to revive 30s later.
A 5-minute drift goroutine (leader-gated via JetStream KV CAS) replaces
the old 30s ReconcileSGsLoop.

Adds AZ field to VPCRecord stamped at create time. Empty AZ on pre-Phase
2.2 records matches every local AZ, so no migration pass is required.

Replaces and removes the 5-pass startup sequence in services/vpcd:
  - Reconcile + ReconcileFromKV
  - ReconcileSGsOnce + ReconcileSGsLoop
  - RetrofitAllExternalLocalnetOptions
  - RetrofitAllGatewayPortNetworks

IGW fresh-create continues to flow through the legacy NATS handler path
(handleIGWAttach) so the dhcpManager wiring stays intact until Phase
2.3; the reconciler only rebinds gateway-chassis on existing IGW
topology.

Closes mulga-siv-125.3.2.

* refactor(network/topology): promote live Manager impl to network/topology

Move the topology.Manager implementation out of services/vpcd/topology_manager.go
into network/topology/manager_live.go as a standalone liveManager constructed
via NewLiveManager(ovn.Client, opts...). Inputs that varied per-handler
(DNS server resolution from the external pool) are now passed as functional
options (WithDNSServer).

TopologyHandler still satisfies topology.Manager but its methods delegate
to a cached liveManager. The shim stays until the rest of mulga-siv-129
routes NATS subscribers through topology.Manager directly.

Also exports SubnetGatewayCIDR + FormatDNSServerList from network/topology
so the reconciler in network/reconcile can share helpers with the live
manager (audit pass to follow per plan §6).

Test cases ported one-for-one from services/vpcd/topology_manager_test.go
to network/topology/manager_live_test.go; the vpcd-side test file is
deleted. New WithDNSServer test asserts the option propagates into
DHCPOptions rows.

Refs mulga-siv-129 (Phase 2.6 vpcd subscriber migration, Slice 1 of N).

* refactor(vpcd): collapse SG subscribers onto network/{topology,policy} managers

Phase 2.6 / mulga-siv-129 slice 2. Rewrites the vpc.create-sg /
vpc.update-sg / vpc.delete-sg NATS subscribers in services/vpcd as
thin wrappers over topology.Manager (port group lifecycle) and
policy.SecurityGroupManager (ACL programming). Deletes the ~1000
lines of duplicated ACL builder and provisioning logic — its
authoritative copy lives in network/policy.

Surface additions:

- ovn.ErrPortGroupNotFound sentinel; mock + live GetPortGroup wrap
  not-found via fmt.Errorf("%w: ...") so callers can use errors.Is.
- topology.Manager grows EnsureSGPortGroup / DeleteSGPortGroup
  (resolves the apply.go TODO from §2.1 of the redesign plan).
  liveManager implements them with GetPortGroup-then-Create /
  GetPortGroup-then-Clear+Delete idempotency.

Behaviour delta: the old provisionSG rolled back the OVN port group
on AddACLs failure ("all-or-nothing"). The new path lets the port
group survive and relies on network/reconcile to retry EnsureSG on
its next sweep. The all-or-nothing fail-fast test cases are dropped;
ACL emission semantics are covered by network/policy/sg_test.go.

* refactor(vpcd): migrate VPC/Subnet/ENI subscribers to topology.Manager

Phase 2.6 / mulga-siv-129 slice 3. Rewrites vpc.create / vpc.delete /
vpc.create-subnet / vpc.delete-subnet / vpc.create-port /
vpc.delete-port / vpc.update-port-sgs NATS subscribers as thin
wrappers (≤30 lines each) over topology.Manager. Subscriber bodies
now: parse event → translate to spec → delegate to manager.

Deletes:

- vpcd.subnetGateway (and helpers_test.go + topology_test.go's
  TestSubnetGateway block); parity coverage lives in
  network/topology.TestSubnetGatewayCIDR.
- vpcd.reconcilePortSGs (the live manager already exposes the same
  diff-then-transact via reconcilePortSGs on the L2 side).
- vpcd.TopologyHandler.dnsServer; lmDNSServer (passed via
  topology.WithDNSServer to the live manager) is the live path.

Behaviour delta: handlers now strict-parse PrivateIpAddress /
MacAddress / CidrBlock at the NATS boundary and reject malformed
payloads instead of degrading to "no DHCP options". The reconciler
guarantees that field validity is upheld upstream of the publish.

* refactor(network/reconcile): drive SG port groups via topology.Manager

applySGs now calls topology.Manager.EnsureSGPortGroup /
DeleteSGPortGroupByName instead of reaching directly into the OVN client.
Reconcile.Config gains a Topology field (required); the production caller
in services/vpcd passes the TopologyHandler shim, the reconcile package
tests construct a fresh topology.NewLiveManager bound to the mock OVN
client.

DeleteSGPortGroupByName is added to the topology.Manager interface for the
reconciler's orphan-removal path, which iterates raw OVN port-group names
without a groupID round-trip. The existing DeleteSGPortGroup(groupID)
delegates to it.

Resolves the TODO at apply.go:137 carried over from slice 2 of
mulga-siv-129.

* feat(network/policy): add FlowsBarrier option to NATManager

NATManager.AddEIP needs to block on `ovn-nbctl --wait=hv sync` after
writing the dnat_and_snat row so the NATS reply only returns once every
chassis has the rewrite flow. The legacy vpc.add-nat handler does this at
the tail of handleAddNAT; the manager has lacked it since slice 2.1.

NewNATManager grows a functional-options tail (Option / WithFlowsBarrier).
Defaults to a no-op for tests; production wiring in services/vpcd passes
waitForFlowsHV. AddEIP now invokes the barrier after AddNAT, logging at
warn level on failure to match the legacy handler's _ = waitForFlowsHV()
swallowing semantics.

Pre-work for slice 4 of mulga-siv-129 — without the barrier the NAT EIP
subscriber migration would lose the post-write flow-install gate.

* refactor(vpcd): migrate NAT GW subscribers to policy.NATManager

handleAddNATGateway / handleDeleteNATGateway now decode the NATS event
and delegate to policy.NATManager.{AddNATGateway,DeleteNATGateway}. The
manager carries identical OVN logic — same snat type, same ExternalIDs
(spinifex:vpc_id + spinifex:nat_gateway_id) — so existing
TestTopologyHandler_AddNATGateway / DeleteNATGateway suites stay green.

Lazy-constructed NATManager added on TopologyHandler (natmOnce). Mode is
derived from the bridge mode (veth → centralized, direct → distributed)
and the FlowsBarrier is bound to waitForFlowsHV for parity with the
legacy EIP path's tail barrier (used by the next slice, vpc.add-nat).

Slice 4 of mulga-siv-129, sub-step b.

* refactor(vpcd): migrate EIP NAT subscribers to policy.NATManager

handleAddNAT / handleDeleteNAT now decode the NATS event and delegate to
policy.NATManager.{AddEIP,DeleteEIP}. The behavioural contract is
preserved end-to-end:

- Idempotency-skip on a matching dnat_and_snat row (same VPC + LogicalIP,
  plus MAC + LogicalPort under distributed NAT) moves into AddEIP so
  duplicate vpc.add-nat publishes never tear down the rule the previous
  invocation just installed (mulga-siv-124).
- Stale-rule cleanup across every router stays in AddEIP.
- Post-write waitForFlowsHV barrier is now driven by the manager via the
  FlowsBarrier option wired in services/vpcd (slice 4a).
- DeleteEIP returns nil on ErrNATNotFound, matching the handler's
  fire-and-forget "already absent = success" semantic.

The vpcd "errors" import drops out with the inline error-comparison.

Slice 4 of mulga-siv-129, sub-step c.

* refactor(vpcd): migrate IGW subscribers to external.IGWManager (partial)

handleIGWAttach now branches at decode time:
- Centralised NAT + DHCP-sourced pool keeps using attachIGWLegacy because
  the gateway-LRP IP comes from vpc.dhcp.acquire, and that DHCP client is
  the vpcd-local manager that bead mulga-siv-125.3.3 will retire.
- All other shapes (distributed NAT, static-range pool, no pool)
  delegate to external.IGWManager.AttachIGW. The manager mirrors every
  step the legacy handler ran — external switch + localnet port +
  gateway LRP + switch-side router peer + default route + chassis HA +
  waitForFlowsHV tail barrier — so observable behaviour is unchanged.

handleIGWDetach drops its inline OVN walk entirely; both paths exit
through external.IGWManager.DetachIGW followed by releaseGatewayLRPLease.
The lease release stays in vpcd because the upstream client lives in
services/vpcd/dhcp; calling it after every detach is safe because the
upstream server expires unknown leases on its own.

Lazy-constructed IGWManager added on TopologyHandler (igwmOnce). Pool,
allocator (StaticRangeAllocator), chassis, NAT mode, NAT manager, route
manager, and FlowsBarrier are all derived from the existing
TopologyHandler state.

The DHCP-coupled legacy branch retains its full inline OVN logic; it
will disappear in slice-after-125.3.3 follow-on. Slice 4 of
mulga-siv-129, sub-step d.

* feat(ec2/instance): honor AssociatePublicIpAddress per-NIC override

RunInstances --network-interfaces "AssociatePublicIpAddress=true|false"
was silently ignored, only subnet.MapPublicIpOnLaunch gated public IP
assignment. AWS contract is per-NIC override wins.

Extend TestPrepareRunInstances_PublicIPAutoAssigned to table-driven
3 cases: subnet true no-override, subnet false override true, subnet
true override false.

* refactor(config): drop upstream-DHCP config surface (static WAN pool only)

Hard-error in LoadConfig on legacy keys: [network] external_dhcp,
[nodes.<n>.vpcd] dhcp_bind_bridge, and [[network.external_pools]]
source = "dhcp". Add pool range validation (must fit inside the
gateway/prefix_len CIDR; per-cluster non-overlap).

Strip ExternalDHCP from config.NetworkConfig + formation.NetworkConfig
+ admin.ConfigSettings, and DhcpBindBridge from config.VPCDConfig +
admin.ConfigSettings + vpcd.Config. vpcd.ExternalPoolConfig.IsDHCP()
short-circuits to false; the field and downstream DHCP branches in
external_ipam / topology.go remain for Slice C to delete together with
dhcp_manager.

cmd/spinifex/cmd/admin.go: drop useExternalDHCP path and the
detectedDhcpBindBridge helper; require --external-pool when
--external-mode=pool; auto-detect default no longer infers source=dhcp.

service.go's wan_bridge legacy-key rejection now advises removing the
key entirely (vpcd auto-detects br-wan), not renaming to
dhcp_bind_bridge.

Two topology_test cases that exercise the DHCP gateway-LRP path are
skipped — Slice C deletes attachIGWLegacy and these tests together.

* refactor(vpcd): delete dhcp_manager + DHCP gateway-LRP path (Slice C)

Removes upstream-DHCP-client gateway-LRP allocation in favor of static
WAN-pool allocation (StaticRangeAllocator). Part of bead mulga-siv-125.3.3.

- delete services/vpcd/dhcp_manager.go + dhcp/ subpackage (~865 lines)
- delete allocateGatewayLRPIPViaDHCP + releaseGatewayLRPLease paths
- drop ExternalPoolConfig.Source + DhcpBindBridge + IsDHCP() fields
- drop ExternalIPAllocation lease fields (Expiry/ServerID/HWAddr) and
  ExternalIPAMRecord.Source
- drop NewExternalIPAM nc *nats.Conn argument; update all 4 call sites
- rename verifyBridgeMode local dhcpBindBridge -> wanBridge; update error
  strings and tests

Slice D (delete attachIGWLegacy + dispatcher) intentionally deferred.

* refactor(vpcd): delete attachIGWLegacy + IsDHCP stub (Slice D)

Closes mulga-siv-129 slice 4d. Part of bead mulga-siv-125.3.3.

handleIGWAttach now unconditionally delegates to external.IGWManager.
attachIGWLegacy (~207 lines, ~5 OVN write steps + chassis scheduling)
deleted along with its dispatcher branch in handleIGWAttach. The
ExternalPoolConfig.IsDHCP() dead stub is gone.

No code paths or NATS subjects regress: the static-pool / distributed
branches were already going through external.IGWManager since Slice 4d
(6719dc3).

* feat(ipam): Purpose field on every IPAM record (Slice B)

Part of bead mulga-siv-125.3.3.

Adds a Purpose enum (handlers/ec2/vpc/purpose.go) tagging every IP
allocation with its semantic role (eni-primary, eni-public, eip,
igw-lrp, natgw-external, …) so multi-VPC clusters can reclaim/audit
by (owner, purpose).

External IPAM (spinifex-external-ipam, v1→v2):
- ExternalIPAllocation.Type → .Purpose; legacy values mapped via
  LegacyExternalTypeToPurpose (gateway→igw-lrp, auto_assign→eni-public,
  elastic_ip→eip). Unknown legacy values warn and become "unknown".
- AllocateIP / AllocateFromPool / allocateFromPool param rename
  allocType→purpose. All call sites pass Purpose* constants.
- migrate/002_external_ipam_purpose.go: idempotent CAS-retry migration
  with v1/v2-union record shape so partial migrations resume cleanly.

Internal IPAM (spinifex-vpc-ipam, v1→v2):
- IPAMRecord.Allocated []string → []IPEntry{IP, Purpose, OwnerID}.
- AllocateIP gains purpose+ownerID args; ReleaseIP unchanged (still
  keyed on IP).
- AllocatedIPs returns []IPEntry.
- eni.go reorders eniId generation before AllocateIP so the owning
  resource ID lands on the new entry.
- migrate/003_ipam_purpose.go: cross-bucket backfill that reads the
  ENI bucket via the new RunKVWithJetStream path. Orphaned IPs
  (no owning ENI) warn and become Purpose=unknown.

Migrate framework:
- KVContext gains an optional JetStream field; new
  Registry.RunKVWithJetStream method threads it through. RunKV
  signature unchanged — all other callers are unaffected.

Tests:
- ipam_test.go: AllocateIP signature updates; AllocatedIPs now asserts
  IP / Purpose / OwnerID on the returned entries.
- external_ipam_test.go: replaces "auto_assign"/"elastic_ip" literals
  with PurposeENIPublic/PurposeEIP and asserts alloc.Purpose.
- migrate/002 + 003 tests: schema-detect, idempotent rerun,
  no-JetStream fallback, empty bucket.

* refactor(daemon): tombstone obsolete spinifex-dhcp-leases KV bucket

The dhcp_manager removed in Phase 2.3 (bead mulga-siv-125.3.3) owned the
spinifex-dhcp-leases KV bucket. The migrate framework only handles
per-bucket schema version bumps, not bucket deletion, so the obsolete
bucket would linger on upgraded clusters.

Add a one-shot tombstone after initJetStream that deletes the bucket if
present. DeleteKVBucketIfExists treats ErrBucketNotFound and
ErrStreamNotFound as success, so the call is idempotent across restarts.

* feat(config): add network.ipsec_enabled cluster flag (default true)

Add IPSecEnabled to NetworkConfig in both the cluster TOML schema and
the formation handshake payload. Default true so AWS-parity intra-AZ
encryption is on by default. Operators can flip false in
[network] for trusted single-rack lab topologies.

Phase 2.5 (bead mulga-siv-125.3.5) consumes the flag from admin init
(strongSwan provisioning), formation server (propagates to joiners),
and daemon startup (ovs-vsctl ipsec_encapsulation toggle).

* feat(admin): IPsec peer cert generator signed by cluster CA

Add GenerateIPSecPeerCert + IPSecCertPaths in spinifex/admin so admin
init (and the future admin add-node path) can issue per-node IKEv2 peer
credentials signed by the existing cluster CA. The cert carries
id-kp-ipsecIKE (RFC 4945) so strongSwan accepts it as an IKEv2 peer
credential when ovs-monitor-ipsec materialises tunnel configs.

Cluster CA is reused directly — no intermediate strongSwan PKI today;
when intermediate CAs land that becomes a separate workstream.

The cert/key pair lives under {configDir}/ipsec/peer.{pem,key}; the
daemon points OVS Open_vSwitch.other_config (certificate, private_key,
ca_cert) at these paths when network.ipsec_enabled is true.

* feat(admin): wire IPsec peer cert into admin init + join paths

Add --ipsec bool flag (default true) on adminInitCmd. Single-node init
and the multi-node leader path both emit an IPsec peer cert after the
existing CA+server-cert generation. The multi-node formation server
propagates the IPSecEnabled flag to joining nodes via NetworkConfig;
admin join honours it and emits its own peer cert during the same
cert-issuance step that already produces the server cert.

spinifex.toml template gains an unconditional [network] block carrying
ipsec_enabled so the value is visible to operators on every install
regardless of whether external networking is configured. The daemon
reads it via cluster config (NetworkConfig.IPSecEnabled).

Bead mulga-siv-125.3.5.

* build: install openvswitch-ipsec for OVN native IPsec

openvswitch-ipsec provides ovs-monitor-ipsec, which reads the
Open_vSwitch.other_config cert/key/CA pointers and materialises
strongSwan configs for every Geneve tunnel managed by ovn-controller.
Pulls in strongswan + strongswan-pki as transitive deps.

Required by bead mulga-siv-125.3.5.

* feat(daemon): enable OVN native IPsec on intra-AZ Geneve at startup

When network.ipsec_enabled is true (default), the daemon points the
local OVS DB other_config at the per-node IPsec peer cert that admin
init / admin join provisioned (configDir/ipsec/peer.{pem,key} + the
cluster CA at configDir/ca.pem), then sets
other_config:ipsec_encapsulation=true.

ovs-monitor-ipsec (shipped by the openvswitch-ipsec package) consumes
those pointers and materialises strongSwan configs for every Geneve
tunnel ovn-controller programs — including OVN-IC inter-AZ tunnels
when federation lands in Phase 3a.

Missing cert files log a warning and leave Geneve plaintext; operators
upgrading an older cluster reissue certs via spx admin to close the
gap.

Bead mulga-siv-125.3.5.

* test(e2e): multinode IPsec wiring + xfrm SAs + ESP capture

TestMultinodeIPSec asserts:
- OVS DB on every node carries certificate/private_key/ca_cert pointers
  and other_config:ipsec_encapsulation=true (what daemon.enableOVNIPSec
  writes).
- ip xfrm state on every node has at least one AEAD SA in AES-GCM mode
  (rfc4106(gcm(aes)) or gcm(aes)) within 90s.
- tcpdump catches ESP (ip proto 50) on the underlay (best-effort —
  logged only; geneve may be idle during the 5s capture window).

Skips cleanly when the cluster was bootstrapped with --ipsec=false
(no ipsec_encapsulation key on first node).

Read-only over SSH; runs in the parallel bucket alongside the other
non-mutating multinode tests.

* fix(igw): write attachment State="attached" for AWS parity

Reconciler intent loader (network/reconcile/intent.go::loadIGWs) filters
IGW records with `State == "attached"`, but AttachInternetGateway was
writing `State = "available"`. The reconciler therefore never picked up
any attached IGW — `intent_igws=0` on every pass — so the OVN external
switch + gateway LRP + WAN bridge wiring built by IGWManager.AttachIGW
never ran for default-VPC IGWs (whose live `vpc.igw-attach` publish at
daemon bootstrap is lost — vpcd subscriber not yet wired).

Net effect on single-node e2e: every SSH probe to 192.168.x public IPs
timed out (TestSSHProbe, TestModifyInstanceAttribute, TestRebootInstance,
TestNATGateway, TestSGToSGDatapath) because SNAT/DNAT rules on the VPC
router had no path to the external network.

Match AWS InternetGatewayAttachment.State semantics:
 - Attach: State="attached"
 - Detach: State="detached"

Detach value is internal — recordToEC2 gates Attachments on VpcId != "",
so the detached State is never surfaced to API consumers.

Bead: mulga-siv-131

* fix(reconcile): call AttachIGW when IGW topology missing

applyIGWs previously deferred to the NATS handler when an intent IGW
had no external switch in OVN. When the vpc.igw-attach event is lost
(bootstrap-time default-VPC IGW published before vpcd subscriber is
ready), deferral never resolves and VM public-IP traffic dies.

Call IGWManager.AttachIGW directly — it is idempotent (short-circuits
when the external switch already exists), so the reconciler converges
default-VPC IGWs on first pass without duplicating work on subsequent
passes.

Closes mulga-siv-132.

* fix(test): align NewExternalIPAM call with 2-arg signature

Rebase onto dev pulled in daemon_system_instance_test.go which used
the legacy 3-arg NewExternalIPAM(jsNC, js, pools). The feature branch
already narrowed the signature to (js, pools) via the upstream-DHCP
config drop; align the test with the current signature.

* fix(vpcd): EnsureSubnet must EnsureVPC first to avoid NATS race

vpc.create-vpc and vpc.create-subnet are independent NATS events with
no inter-event ordering guarantee. Tenant bootstrap on iam.account.created
publishes both within <1ms, so the subnet handler's
CreateLogicalRouterPort can race the parent VPC's CreateLogicalRouter and
fail with "logical router not found". The cascade fails CreateNetworkInterface
which fails RunInstances which surfaces as InsufficientInstanceCapacity to
the SDK — masking the underlying topology race.

handleSubnetCreate now calls EnsureVPC(VPCID-only) before EnsureSubnet.
EnsureVPC short-circuits on existing LR (idempotent), so the race-winning
vpc.create-vpc handler's full CIDR/VNI ExternalIDs are preserved; if subnet
wins the race the LR is created with bare ExternalIDs (cosmetic) and the
follow-on vpc.create-vpc short-circuits.

Adds TestIntegration_SubnetBeforeVPC regression covering both orderings.

Closes mulga-siv-133.

* feat(ansible): require --external-pool, drop NAT, forward EXTRA_VARS

spx admin init now rejects --external-mode=nat and demands --external-pool
when mode=pool. The ansible init role silently skipped pool flags when the
range was unset, so installs fell over inside the spx validator. Make
targets also swallowed `-e` (make's env-override flag), so operators could
not override pool start/end without editing defaults.yml.

- vars/defaults.yml: rename nat_gateway_ip → external_gateway_ip; add
  spinifex_external_no_external opt-out; document the spx flag contract.
- roles/init/tasks/main.yml: drop NAT branch; assert pool range upfront
  with a fail_msg pointing at EXTRA_VARS / defaults.yml; emit gateway,
  prefix-len, gateway-ip only when explicitly set (let spx auto-detect);
  honour --no-external.
- playbooks/dev-reset.yml: rename captured var; coerce legacy
  external_mode=nat to pool with a warn; tighten gateway regex with \b.
- Makefile: ansible-dev-* targets now forward EXTRA_VARS="key=val ..."
  through --extra-vars so `make` callers can override without editing
  defaults.yml.
- scripts/ansible/README.md: document pool requirement, both invocation
  styles (direct -e vs make EXTRA_VARS), NAT removal.

* fix(ipsec): guard ipsec_encapsulation on ovs-monitor-ipsec liveness

enableOVNIPSec set OVS cert pointers + ipsec_encapsulation=true without
ensuring ovs-monitor-ipsec.service was running. Without that daemon, OVS
holds the cert pointers unused, strongSwan never gets IKE config, and
tunnel traffic ovn-controller marks for encryption gets silently
dropped. TestMultinodeIPSec fails on `node1 xfrm has no AEAD SAs`.

- systemd: spinifex-daemon Wants=/After= ovs-monitor-ipsec.service
- daemon: ensureOVSMonitorIPSecActive — systemctl enable --now +
  is-active poll; refuses to flip ipsec_encapsulation if daemon is dead
- daemon: single-node short-circuit — no peers means no tunnels means
  the flag is at best inert and at worst load-bearing-silent-drop
- ci: capture-net-diag.sh + workflow steps to snapshot OVS/OVN/xfrm
  state on failure, fan out across all 3 multinode nodes

Refs: mulga-siv-136

* test(e2e): sticky-skip downstream SSH tests when probe fails

When TestSSHProbe times out (3 min, typically because of mulga-siv-134
datapath bug), four downstream tests each re-probe SSH on the same
broken datapath and time out identically — single-node job runs ~24 min
instead of ~12 min, with the same error logged five times.

First waitForSSHReady timeout now sets a package-level atomic flag;
subsequent waitForSSHReady / trySSHReady / requireSSHHealthy calls skip
immediately. Preserves the original red signal on TestSSHProbe and on
any test that genuinely needed SSH (Skip ≠ Pass). Bool variant
(trySSHReady) consults but does not set the flag — its caller already
treats False as advisory warning, not fatal.

TestSGToSGDatapath has its own EventuallyErr SSH wait (not via
waitForSSHReady) and needs an explicit requireSSHHealthy(t) gate at
the top of runSGToSGDatapath. TestNATGateway already calls
waitForSSHReady first so the gate fires naturally.

Refs: mulga-siv-137

* fix(ipsec): use Debian unit name openvswitch-ipsec.service

CI artifact shows `Unit ovs-monitor-ipsec.service could not be found`
on every node. Debian's openvswitch-ipsec package ships the unit as
openvswitch-ipsec.service (the binary inside is ovs-monitor-ipsec, but
systemd's name follows the package). The previous commit guarded on
the wrong unit name, so:

- spinifex-daemon.service Wants=/After= silently no-op'd (missing unit)
- enableOVNIPSec's `systemctl enable --now ovs-monitor-ipsec.service`
  failed, daemon logged a warning and continued without flipping
  ipsec_encapsulation=true
- TestMultinodeIPSec skipped with `node1 reports ""` because OVS DB
  never received the cert pointers

Rename across all 5 spots (daemon, systemd unit, test, CI diag script,
workflow journal capture) to match the Debian package layout.

Note: the package itself still must be present on the CI VM image —
that's tracked in the mulga repo (image-builder provision.sh +
bootstrap-install.sh shim).

Refs: mulga-siv-136

* fix(e2e): authorize default SG tcp/22 + icmp in single-node fixture

admin init leaves the default SG closed (AWS parity); the Go single suite
never ported the Phase-5 SG-ingress block that run-e2e.sh has had since day
one, so every test runner SSH/ping probe hits the OVN port-group ACL drop.

- ec2helpers: add AuthorizeICMPIngress + EnsureDefaultSGOpen helpers.
- single/main_test: open default SG once per process in pkgFixOnce.Do.
- witness: extend ensureDefaultSGSSHIngress to authorize ICMP too.
- run-e2e.sh: append matching ICMP authorize next to tcp/22 block.

mulga-siv-134

* fix(network/reconcile): key DHCP_Options lookup by subnet_id not CIDR

Multi-tenant default VPCs share the same subnet CIDR (172.31.0.0/20) per
account. The existing FindDHCPOptionsByCIDR gate at apply.go:107 found
the first account's DHCP_Options row and skipped creation for every
subsequent subnet with the same CIDR. Those subnets' ports then attached
nothing (FindDHCPOptionsByExternalID misses on subnet_id), VMs got no
DHCPv4 lease, and SSH/ICMP into them silently failed even though SG
ACLs and NAT were correct.

Switch the gate to FindDHCPOptionsByExternalID keyed on spinifex:subnet_id
so each subnet owns its own row, matching the attach lookup at line 203.

Verified: account 000000000001 default VPC RunInstances now produces an
LSP with dhcpv4_options populated, VM cloud-init reports a 172.31.0.5/20
lease + default route via 172.31.0.1, and ping + ssh TCP handshake to
the EIP succeed end-to-end. Stale DHCP_Options row for the prior dead
subnet was wiped manually; reconciler recreated the correct row for the
live subnet on its next pass.

* fix(ipsec): enable openvswitch-ipsec at provision time, drop daemon write

Provisioning now runs `systemctl enable --now openvswitch-ipsec.service`
alongside openvswitch-switch and ovn-controller. The spinifex-daemon
process drops its `enable --now` shell-out and only polls is-active.

Sudoers gains one read-only line:
  spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/systemctl is-active openvswitch-ipsec.service

This mirrors the existing vpcd ovn-controller is-active precedent and
removes the daemon's need for systemd-write capability. Aligns with
least-privilege service account pattern: provision enables services,
daemon only reads status.

If the unit is inactive at runtime, the daemon refuses to flip
ipsec_encapsulation=true (silent-drop guard from mulga-siv-136) and
surfaces a clear error pointing at scripts/setup-ovn.sh.

* chore: unblock preflight on feat/network-layer-redesign

Two pre-existing failures blocked preflight on the branch:

- govulncheck flagged golang.org/x/net@v0.54.0 (GO-2026-5026, ASCII Punycode
  rejection bug in idna.ToASCII); reached via utils.DownloadFileWithProgress.
  Bump to v0.55.0; go mod tidy carries x/sys to v0.45.0.
- TestTopologyHandler_SubnetCreate_RouterPortFails asserted subnet create
  fails when the parent VPC router is missing. That contract changed in
  aed7b2e (mulga-siv-133), which made handleSubnetCreate call EnsureVPC
  idempotently before EnsureSubnet to avoid a NATS publish-order race.
  Delete the now-impossible failure case.

* refactor(network/ovn): relocate nbdb model package from services/vpcd

Phase 4 / mulga-siv-125.6 Slice A. Pure import rename, no logic change.

The OVN Northbound DB model (LogicalSwitch, LogicalRouter, DHCPOptions,
etc.) lived under services/vpcd/nbdb/ because that was the original home
of every OVN caller. After Phase 1+2 the L1 client (network/ovn) and all
L3/L5 managers (network/policy, network/external, network/topology,
network/reconcile) became the primary callers — 8 importers in network/*
vs 2 in services/vpcd/. The directory move puts the schema model where
its consumers are, and unblocks deleting services/vpcd/ entirely once
the subscriber/launcher relocations land (Slices B–D).

* refactor(vpcd): delete dead retrofit/reconcileIGW helpers + bridge-mode bleed

Phase 4 / mulga-siv-125.6 Slice B.

Production path no longer touches any of:

- RetrofitAllGatewayPortNetworks / RetrofitAllExternalLocalnetOptions —
  the 5-pass startup leftovers that network/reconcile (Phase 2.2) made
  redundant. Nothing called them outside tests.
- reconcileIGW + its allocateGatewayLRPIP / gwLrpRange helpers + the
  ipv4ToUint32 / uint32ToIPv4 utilities — superseded by
  external.IGWManager (Phase 2.4) which carries its own pool logic.
- ensureLocalnetOptions / ensureGatewayPortNetworks /
  expectedGatewayPortNetwork — read-before-write retrofit shims for the
  same code paths.
- generateMAC — only callers were reconcileIGW + its tests.

useCentralizedNAT (the bridge-mode bleed flagged in plan §2.2) is
replaced by a policy.NATMode value injected via WithNATMode(...). The
mode is resolved once in vpcd.go from host.UplinkMode and threaded into
both the topology-handler lazy managers and the reconciler stack, so
"is this veth?" is no longer a question topology.go can ask. The
TopologyHandler defaults to NATModeDistributed when WithNATMode is
omitted, matching the previous default.

Drops services/vpcd/topology.go from 1220 LOC to 693, deletes ~700 LOC
of obsolete tests (TestGenerateMAC, TestEnsureLocalnetOptions_*,
TestRetrofitAllExternalLocalnetOptions_FixesClearedOptions,
TestReconcileIGW_*, TestGwLrpRange), and frees the nbdb + utils +
net imports in topology.go.

Stale "phase 4 migrates those onto the new managers" comment in vpcd.go
fixed in passing — the subscribers were migrated in Phase 2.6
(mulga-siv-129); only the file-relocation parts of Phase 4 remain.
Stale ensureLocalnetOptions / ensureGatewayPortNetworks references in
network/ovn/{live.go,mock/mock.go} doc comments also cleaned up.

* refactor(network): move NATS VPC subscribers to network/subscribers

Phase 4 Slice C (mulga-siv-125.6). Promotes the NATS event handlers out of
services/vpcd into a new spinifex/network/subscribers package. Subscriber
holds the topology/policy/external managers directly — the lazy *Once
adapter shim (topology_manager.go) is deleted along with vpcd's local
event-struct copies.

launchService now constructs topology.LiveManager eagerly with a DNS
callback derived from external pools (preserving the unscoped-pool lookup
old TopologyHandler.lmDNSServer did), builds every manager up front, then
hands them to subscribers.New + Subscribe. reconcile.New receives
topology.Manager directly instead of through the adapter.

Bridge-mode constants and waitForFlowsHV move into vpcd.go alongside the
launcher; they were left behind as topology.go's last responsibilities.

Handler-layer tests (topology_test.go, security_test.go, integration_test.go)
are dropped: substance is covered by network/topology, network/policy,
network/external manager tests. subscribers/subscribers_test.go adds
focused coverage: missing-dep rejection, topic registration smoke, and
bad-JSON envelope per topic.

services/vpcd is now down to the launcher + the ovn/mock compat aliases
(removed in Slice D when the launcher promotes to cmd/vpcd/).

* refactor(vpcd): relocate services/vpcd → spinifex/vpcd; drop dead aliases

Phase 4 Slice D (mulga-siv-125.6). Final cleanup of the network-redesign
package layout. services/vpcd/ disappears entirely; the launcher remains
an in-process Service impl, just rehomed under spinifex/vpcd/ (no separate
binary — spx vpcd start stays the cobra entrypoint).

- Delete services/vpcd/mock_ovn.go + ovn.go (compat aliases now dead post
  Slice C; only callsite was launchService's NewLiveOVNClient, inlined to
  ovn.NewLiveClient).
- git-rename services/vpcd/vpcd.go + vpcd_test.go to spinifex/vpcd/.
- Retarget three importers (spinifex/service/service.go +
  service_test.go, cmd/spinifex/cmd/service.go) to the new path.
- Fix stale services/vpcd / TopologyHandler comments in network/reconcile/
  + network/external/types.go + spinifex/vpcd/vpcd.go.
- service-interfaces.yaml vpcd path updated.
- Plan doc §13/§14/§16 + §9.2/§19 updated: drop cmd/vpcd/main.go (was
  speculative; never built) in favour of in-process Service impl at
  spinifex/vpcd/vpcd.go. Plan §16 Created list now lists subscribers/.

Phase 4 plan items 1+2 (remove services/vpcd, entrypoint location)
satisfied. Items 3+4 remain blocked on mulga-siv-125.4 + 125.5 (bundled
join token, plan archive).

* fix(ovn): install openvswitch-ipsec + strongswan explicitly

ovs-monitor-ipsec.service fails to start without an IKE daemon. The
openvswitch-ipsec package only Recommends strongswan, so any apt
config that drops Recommends (or a base image with the recommends
already skipped) leaves the service in a Restart loop with 'IKE
daemon is not installed'. Hoist both packages into setup-ovn.sh so
provisioning is the single source of truth — the bootstrap shim in
mulga is no longer needed.

* test(network): pin ADR-0006 invariants to executable suite

Add static-analysis + behavioural tests that bind every safety and
liveness clause in ADR-0006 (S1-S9, L1-L3) to a named Go test. Failure
output quotes the ADR clause verbatim and points at the offending
file:line so future debugging sessions can't quietly bypass a layer
contract to make a test green.

Static-analysis suite (spinifex/network/invariants/):
- S1 layers_test.go: parses `go list -json`; rejects upward layer
  imports and layers pulling from cross-cutter packages
- S2 host_containment_test.go: AST-detects ovs-vsctl / ovn-nbctl /
  ovs-ofctl shell-out outside network/host/
- S3 nat_mode_test.go: rejects .UplinkMode() / NATModeFromUplinkMode()
  calls outside the init-time resolver locations
- S4 names_test.go: AST-detects OVN object name construction (concat,
  fmt.Sprintf, strings.Join) outside topology/names.go
- S5 policy_no_create_test.go: rejects CreateLogical{Switch,Router,...}
  and DeleteLogical{...} calls inside network/policy/
- S6 federation_isolation_test.go: rejects TransitSwitch /
  TransitRouterPort references and ovn-ic literals outside
  network/federation/ (pre-protects the layer before it lands)
- S8 ipsec_test.go: rejects strongswan / xfrm / IKEv2 / libreswan /
  openswan imports anywhere in network/
- S9 reconciler_singleton_test.go: rejects Reconcile…Loop and
  Reconcile…Pass<n> function names anywhere in network/
- S7 nats_subjects_test.go: scans for `vpc.<verb>` subjects missing
  the {azID} segment; calls t.Skip with the gap report so every test
  run prints the work-to-do without breaking CI. Flip to t.Fatalf
  when the cluster-wide subject rename ships.

Behavioural suite (spinifex/network/reconcile/liveness_test.go):
- L1 reconciler converges: scaled-intent (N=1,3,8) double-Reconcile
  property test; asserts second pass is a no-op
- L2 drift bound: structural check that DriftInterval is positive and
  ≤30 min support window; full fake-clock behavioural test deferred
- L3 federation re-enables: t.Skip stub pointing at the missing
  network/federation/ package

Fixes the one production S4 violation the new walker found:
network/external/igw.go was constructing "gw-port-"+vpcID inline.
Adds topology.GatewaySwitchPort(vpcID) helper and retargets both
AttachIGW and DetachIGW callsites through L2.

Plan + per-clause mechanic + phasing in
docs/development/improvements/network-invariants.md.

Refs: mulga-siv-140

* fix(ovn): install strongswan-charon (not strongswan metapackage)

The strongswan metapackage's '\''charon-systemd | strongswan-charon'\''
alternative resolves to charon-systemd by default, which ships
/usr/sbin/charon-systemd but not the legacy /usr/sbin/ipsec helper or
/usr/lib/ipsec/charon binary that ovs-monitor-ipsec --ike-daemon=strongswan
shells out to. Install strongswan-charon explicitly to pull
strongswan-starter (legacy ipsec controller) + the matching charon binary,
matching what openvswitch-ipsec.service expects.

Also reset-failed + restart openvswitch-ipsec.service after enable to
recover from the deb post-install starting the service before its
dependencies finished unpacking.

* chore: add CODEOWNERS — joshsiv-mulga gates network/ + vpcd/

Fall-through reviewer for any change is the maintainer.
network/ and vpcd/ are explicitly listed so the matching branch
protection rule ("Require review from Code Owners") routes those
PRs to joshsiv-mulga. Those trees are governed by ADR-0006
invariant tests (spinifex/network/invariants/); CODEOWNERS adds a
human review gate on top of the mechanical fence.

Refs: mulga-siv-141

* chore: CODEOWNERS fallthrough → @mulgadc/mulgadc-engineers team

Fallthrough reviewer should be the team, not a single maintainer, so
any engineer can approve PRs outside the explicitly-gated paths.
network/ and vpcd/ keep joshsiv-mulga as the required reviewer per
ADR-0006 invariant ownership.

Refs: mulga-siv-141

* feat(ci): add test_run workflow input for -test.run filter

Pass an optional regex through to each suite's '\''go test -test.run'\'' so
dispatches can target a single test (e.g. '\''^TestMultinodeIPSec$'\'') without
running the full multinode suite. Threaded through single, multinode, and
reboot suite invocations; empty input preserves current behaviour.

* fix(daemon): flip NB_Global.ipsec=true on management node

Setting Open_vSwitch.other_config:ipsec_encapsulation=true on each
chassis is necessary but not sufficient for OVN native IPsec — without
NB_Global.ipsec=true, ovn-controller never adds options:remote_name to
the Geneve tunnel ports, so ovs-monitor-ipsec sees no IPsec-enabled
tunnels and the kernel xfrm tables stay empty. After enabling
ipsec_encapsulation locally, also write NB_Global.ipsec=true when the
local ovnnb_db socket is present (management node only); workers skip
silently — one writer is enough for a cluster-wide flag.

Adds a scoped sudoers entry pinning the daemon to that exact ovn-nbctl
invocation so the grant can't be reused for other NB mutations.

* fix(ovn): grant charon AppArmor read on /etc/spinifex/ipsec

The strongswan-charon profile only allows reads from /etc/ipsec.*,
/etc/strongswan.*, and a small fixed allowlist. ovs-monitor-ipsec writes
the per-tunnel strongSwan config with absolute paths to our peer cert +
key under /etc/spinifex/ipsec/, so charon hits 'Permission denied'
loading them (it runs as uid 0 but AppArmor still blocks) and no xfrm
SAs come up. Drop a read grant for /etc/spinifex/ipsec/** into the
profile's local override hook + reload.

* fix(ovn): pin OVS system-id to hostname for IPsec cert identity match

ovs-monitor-ipsec uses chassis-id as the IKEv2 `@<name>` peer identity in
the strongSwan config it generates. Our per-node IPsec peer cert
(admin.GenerateIPSecPeerCert) carries the node name as CN + dnsName SAN.
With chassis-id left at the package-generated UUID, charon validates
`@<UUID>` against a cert with dnsName=<hostname> — mismatch returns
AUTHENTICATION_FAILED and no xfrm SAs are negotiated.

Pin system-id to hostname (which equals the node name on Mulga clusters)
by writing both the on-disk source of truth (/etc/openvswitch/system-id.conf,
read by ovs-ctl on boot) and the running OVS DB value. Writing only one
would drift on the next reboot — the original "do not override" comment
was right about that hazard, just wrong about the resolution.

Cross-link the invariant in admin/ipsec.go: changing the cert identity
without updating setup-ovn.sh (or vice versa) breaks IKE silently.

Refs: mulga-siv-136

* fix(ovn): take --node-name flag so chassis-id matches cluster identity

CI hostnames are bead-themed (env20-casuarina) and don't match the
cluster node name (node1/node2) baked into the IPsec peer cert dnsName
SAN at `spx admin init/join --node NAME`. Pinning system-id to
`hostname -s` produced env20-casuarina; charon then validated
`@node1` against dnsName=env20-casuarina and IKE failed with
AUTHENTICATION_FAILED.

Add a --node-name=NAME flag to setup-ovn.sh, default to hostname for
manual/dev invocations, and update the ansible install role to pass
{{ spinifex_node_name }}. The matching bootstrap-install.sh change
lives in mulga.

Refs: mulga-siv-136

* refactor(daemon): relocate L0 helpers to network/host + widen ADR-0006 S2

Phase 4 Slice E of mulga-siv-125.6. Cleans up spinifex/daemon/network.go
per the audit and widens the S2 invariant to forbid OVS/OVN shell-outs
in spinifex/daemon/ (daemon is an L0 consumer; S2 applies).

Moved to network/host/:
- OVSNetworkPlumber + SetupTap/CleanupTap → host.OVSPlumber (host.NewOVSPlumber)
- GetBridgeIPv4 → host.GetBridgeIPv4
- OVNHealthStatus + CheckOVNHealth → host.OVNHealth + host.HealthStatus
- daemon/ipsec.go OVS/OVN ops → host.SetIPSecCertPaths, host.EnableIPSecEncapsulation, host.SetNBGlobalIPSec

Moved to utils:
- sudoCommand → utils.SudoCommand + utils.SetSudoCommandForTest test seam
  (cross-package reassign would trip the reassign linter; helper keeps
  the indirection inside utils).

Moved to vm:
- generateMgmtMAC → vm.GenerateMgmtMAC (pairs with vm.GenerateDevMAC)

Deleted (dead):
- SetupComputeNode (replaced by network/host/bridges.go:ensureBridges +
  scripts/setup-ovn.sh:572-579)
- EnsureDataRoute
- findInterfaceByIP
- daemon/network.go + dead tests in daemon/network_test.go (duplicates of
  vm package tests retained; mgmt MAC test moved to vm/lifecycle_test.go)

S2 invariant (network/invariants/host_containment_test.go) now walks
both spinifex/network/ and spinifex/daemon/, skipping network/host/.

* fix(ovn): broaden AppArmor read grant to /etc/spinifex (covers CA cert)

The earlier override granted reads on /etc/spinifex/ipsec/** only, which
covers the peer cert + key but NOT the cluster CA at /etc/spinifex/ca.pem.
charon then can't load the CA, can't validate the peer cert chain, and
surfaces the failure as `no trusted RSA public key found for '<peer>'`
even though identity matching now succeeds — IKE_AUTH fails with
AUTHENTICATION_FAILED and no xfrm SAs negotiate.

Grant /etc/spinifex/** instead (covers ca.pem + ipsec/peer.{pem,key})
and always rewrite the override so re-runs replace the older narrower
rule.

Refs: mulga-siv-136

* fix(ipsec): symlink cluster CA into charon's default cacerts dir

ovs-monitor-ipsec writes per-conn `cacert=/etc/spinifex/ca.pem` references
in the strongSwan config, but strongSwan 6.0 only loads CAs declared in
standalone `ca` sections or files placed in /etc/ipsec.d/cacerts at
startup. Without this, charon authenticates the local side fine but
rejects every inbound peer cert as `no trusted RSA public key found for
'<peer>'`, AUTH_FAILED, and no xfrm SAs negotiate.

Do the symlink in admin.GenerateIPSecPeerCert (right after writing the
peer cert) — setup-ovn.sh runs before `spx admin init/join` creates
/etc/spinifex/ca.pem, so the wiring can't live there. Use a stage-and-
rename pattern so concurrent test invocations don't race on the symlink.

/etc/ipsec.d/cacerts is on charon's default AppArmor allowlist so no
profile change needed.

Refs: mulga-siv-136

* chore(setup-ovn): drop apt-get install_packages block

OVS/OVN/openvswitch-ipsec/strongswan-charon are baked into the CI gold
image via mulga's image-builder/scripts/provision.sh (commits 116bb05,
a9b3a9c). Keeping a runtime apt-get install path here pulled CI through
apt-cacher-ng on every bootstrap and tripped on intermittent "Message
has been manipulated" InRelease cache corruption (env21/env20 runs).

Drop the install block. Gold-image rebuild is the canonical place to
add new system packages. If a package is missing, downstream ovs/ovn
commands fail loudly during setup-ovn.sh's later steps — clearer signal
than silently re-installing through a flaky proxy.

setup.sh already supports INSTALL_SPINIFEX_SKIP_APT=1 (passed by mulga's
bootstrap-install.sh:237) so this removes the last runtime apt-get call
on the CI provisioning path.

* fix(ovn): gate system-id pin on explicit --node-name

setup-ovn.sh used to fall back to `hostname -s` when --node-name wasn't
passed, and then unconditionally wrote that value to
/etc/openvswitch/system-id.conf + ovs-vsctl external_ids:system-id.

On CI single-node, the gold image already registered a chassis with
name=<UUID>; setup-ovn.sh then rewrote system-id to the bead-themed
hostname (env10-banksia). vpcd's discoverChassis treats any chassis row
whose name != local system-id but hostname == local hostname as a stale
local chassis and skips it, so vpcd reported "no OVN chassis registered
in SBDB" and refused to start, cascading into the e2e + e2e-go failures
on run 26430839508.

Drop the hostname fallback. If --node-name is given, pin both the
on-disk file and the running OVS DB to it (callers that care about
IPsec cert identity matching still get it). If no --node-name is given,
leave the existing system-id alone — that's always safe whether the
value came from a gold-image UUID, bootstrap-install.sh writing nodeN
before this script runs, or a manual ansible value.

* fix(network/reconcile): skip orphan prune on startup; drift owns GC

The vpcd startup reconcile races against the daemon's EnsureDefaultVPC:
the daemon writes SG KV rows + publishes vpc.create-sg on each node in
parallel with vpcd starting up. The leader-gated reconcile can load
intent before any SG row is visible on its local JetStream replica
while subscribers on peer nodes are concurrently creating port groups
from the same vpc.create-sg events. A startup orphan-port-group sweep
then deletes managed sg_* port groups that intent hasn't caught up to.

CI run 26434124790 trace:
  node2 reconcile/lock: elected holder=env20-bottlebrush
  node2 reconcile: starting intent_sgs=0
  node2 reconcile/apply: removed orphan port group pg=sg_161c70ce23e704a2a
  node2 reconcile/apply: removed orphan port group pg=sg_a0d078650a3150391
  node2 reconcile/apply: removed orphan port group pg=sg_54b564b374e9162bb

Every subsequent RunInstances → vpc.create-port failed with
"port group not found" for ~3 minutes until the 5-minute drift tick
recreated the port groups. Tests give up after 60s; six multinode
suites cascade-fail on InsufficientInstanceCapacity.

Split the reconciler into Reconcile (full prune, drift loop) and
ReconcileApplyOnly (no prune, startup). applySGs takes a pruneOrphans
bool. vpcd startup calls ReconcileApplyOnly; drift loop unchanged.
Legitimate orphans get garbage-collected on the next drift tick — at
worst 5 minutes of lingering rows, no production impact.

Add TestReconcile_ApplyOnlyKeepsOrphanPortGroup to lock the contract.

* fix(ipsec): trigger charon rereadcacerts after CA symlink install

strongSwan scans /etc/ipsec.d/cacerts only at charon startup. The unit
openvswitch-ipsec.service is enabled at provision time and starts before
admin init writes the cluster CA symlink, so charon ends up running with
zero CAs loaded. Every IKE_AUTH then fails with `no trusted RSA public
key found for '<peer>'` and no SAs negotiate — the failure mode exposed
by TestMultinodeIPSec.

Append a stroke rereadcacerts call at the end of
installCAIntoCharonTrustStore so an already-running charon picks up the
freshly-placed CA without a service restart. Failure is swallowed at
debug — a missing/stopped charon at admin-init time is expected and a
later startup will scan cacerts naturally.

* refactor(network/host): move IPSec orchestrator from daemon (ADR-0006 S8)

ADR-0006 S8: "IPSec is OVN-native only. No layer implements custom IKEv2,
XFRM rules, or direct strongSwan management. IPSec SA lifecycle is delegated
entirely to OVN native IPSec and is invisible above L0."

The enableOVNIPSec orchestrator and its ensureOVSMonitorIPSecActive helper
lived in daemon/ipsec.go but composed L0 primitives (SetIPSecCertPaths,
EnableIPSecEncapsulation, SetNBGlobalIPSec) that already lived in
network/host. The orchestrator now lives alongside its callees as
host.EnableOVNIPSec, taking configPath and clusterConfig from the daemon at
the call site. Helper and test-override package vars (systemctlActiveTimeout,
ovnNBSocketPath) move with it; tests are translated to call EnableOVNIPSec
directly rather than via the Daemon struct.

Closes mulga-siv-142

* refactor(daemon): use topology.Port for OVN port names (ADR-0006 S4)

ADR-0006 S4 forbids any layer above L2 from constructing OVN object
names. Three daemon sites formatted "port-" + ENIId inline; route them
through topology.Port instead.

Closes mulga-siv-143

* refactor: use topology.Port for OVN port names in handlers + vm (ADR-0006 S4)

Replace inline "port-"+eniID formatting across five sites (eip, instance x2,
vpc/eni, vm/network_helpers) with topology.Port. OVSIfaceID stays as a thin
wrapper to preserve the OVN binding contract comment and existing call sites.

Closes mulga-siv-144

* test(network/invariants): widen scan roots to daemon/handlers/vm/testutil/utils (ADR-0006)

Widen ADR-0006 invariant scan roots so drift in upstack consumers surfaces
in CI. Backlog of violations in widened roots was drained by
mulga-siv-142/-143/-144; this commit makes the wider scan permanent.

  - S2 (host_containment): add vm/ alongside network/ + daemon/
  - S3 (nat_mode):         add daemon/ alongside network/ + vpcd/
  - S4 (names):            add daemon/, handlers/, vm/ alongside
                           network/ + vpcd/; add narrow AWS-ID exemption
                           (vpc-cidr-assoc-) so AWS resource IDs are not
                           misclassified as OVN object names
  - S6 (federation):       add daemon/, handlers/ alongside network/;
                           convert single-root walk to slice
  - S7 (nats_subjects):    add daemon/, handlers/, testutil/, utils/ —
                           stays t.Skip pending cluster-wide subject
                           rename; gap report now lists 58 subjects

CODEOWNERS: add /spinifex/network/invariants/ rule so widening or
narrowing the scan needs explicit owner review.

Closes mulga-siv-145
Refs: audit-adr-0006.md

* chore(e2e): disable IPsec in pseudo multi-node test

Pseudo multi-node runs three simulated nodes as unprivileged tf-user.
The charon trust-store install in 904b45e writes /etc/ipsec.d/cacerts,
which fails with EACCES under a non-root caller and aborts admin init.

Disable --ipsec for this suite — loopback-only nodes don't exercise
real Geneve encryption anyway. Real-multi-node + single-node e2e
(both run admin init via sudo) continue to cover the IPsec path.

* fix(ovn): atomic Ensure* primitives prevent duplicate logical entities

OVN NB has no unique constraint on Name. The previous
GetLogicalRouter→CreateLogicalRouter sequence in EnsureVPC was a TOCTOU
race: when vpc.create on one node and the defensive vpc.create-subnet
EnsureVPC call on another both observed absence, both inserted, yielding
two routers per VPC. The orphan held DNAT references blocking EIP
detach, and DNAT/flow installation landing on the orphan caused
TestMultinodeGuestSSH SSH timeouts.

Add EnsureLogicalRouter / EnsureLogicalSwitch / EnsurePortGroup to the
libovsdb client. Each builds a single OVSDB transaction combining a
wait-op (Until=equal, Rows=[]) gated on absence of a Name match with the
insert. The server serialises the wait+insert atomically; concurrent
callers see exactly one survivor and the rest fall through to a re-fetch
that returns the canonical row.

Switch all production callers (topology.EnsureVPC / EnsureSubnet /
EnsureSGPortGroup, external.AttachIGW, reconcile.applyVPCs /
applySubnets) to the Ensure* variants. Bare Create* remains available to
the L1 wrapper.

Add invariant test TestS4_OVNNamedCreatesUseEnsure (AST walk) to block
future reintroduction of the bare Create* call sites outside
network/ovn.

Mock implements Ensure* under its existing mutex so the concurrency
tests (50/30/30 goroutines for Router/Switch/PortGroup) prove a single
UUID survives.

Refs: mulga-siv-146

* fix(ovn): wait-op rows must be non-empty; flip to NotEqual predicate

The Ensure* primitives sent a wait-op with `Rows: []ovsdb.Row{}` plus
`Until: ==` (RFC 7047 §5.2.6 absence test). The libovsdb Operation
struct tags Rows as `json:"rows,omitempty"`, so the empty slice was
dropped on the wire and ovsdb-server rejected every transaction with
"Required 'rows' member is missing", breaking reconciler bootstrap and
every downstream create-port lookup ("logical switch X not found").

Flip the predicate to the equivalent `Until: !=` formulation with a
single-row projection containing the Name we want absent:

  no row    → projection []           != [{name}] → predicate holds
  row exists → projection [{name}]    == [{name}] → predicate fails

Same atomic guarantee, JSON now well-formed. Mock tests can't catch
this — only a real ovsdb-server validates the envelope.

* chore: remove generateMAC wrapper

* chore: remove all bead / plan references

* fix(network/policy): wait for chassis flows after AddNATGateway

AddEIP fires m.barrier() to block until ovn-northd compiles NB→SB and every
chassis installs the new dnat_and_snat flows; AddNATGateway returned right
after the NB commit. So `aws ec2 create-route … --nat-gateway-id` would
publish vpc.add-nat-gateway, the SNAT row would land in NB, and the API
call would succeed before the gateway chassis had programmed the SNAT flow
for the private subnet.

Phase 8d's 10×5s=50s ping budget races that flow-install latency on slow
CI hosts (last seen run 26455602615 single-node, identical flake on
unmodified dev run 26434711010 — pre-existing, not introduced by Ensure*).

Mirror AddEIP: call m.barrier() after AddNAT returns. Add unit test
TestNATManager_AddNATGateway_FlowsBarrier_Fires covering the new path.

* chore: remove more plan refs

* chore: remove GenerateIPSecPeerCertIfEnabled

* chore: remove dupliate subnetGatewayCIDR function

* chore(network): trim superfluous comments across network/ tree

Collapse multi-paragraph godocs to 1-2 sentences, drop WHAT-restatement
comments, remove section dividers grouping <10 items, and cut prose
narration in tests. ADR clause references, one-line WHY comments for
non-obvious OVN/OVSDB/AWS/NATS semantics, and exported-symbol godoc are
preserved. No behavioural changes.

Packages: invariants, topology, host, external, ovn, reconcile, policy,
subscribers.

* fix(igw): restore attachment State="available" for AWS parity

AWS InternetGatewayAttachment.State is "available" when an IGW is
attached to a VPC, per the AWS SDK doc ("the state is available when
attached to a VPC; otherwise, this value is not returned"). Commit
b7505f8 wrote "attached"/"detached" to align with the reconciler
filter, which inverted AWS parity.

Fix the producer to write "available" again and update the consumer:
 - handlers/ec2/igw: AttachInternetGateway and DetachInternetGateway
   both persist State="available"; IGWRecord comment narrowed.
 - network/reconcile/intent.loadIGWs: filter accepts "available" (was
   "attached"); attachment is identified by VpcId != "".
 - tests updated: igw service_impl_test, routetable service_impl_test
   fixture, reconcile intent_test fixture.

* Update spinifex.toml

* refactor(subscribers): route EIP/NATGW through L5 wrappers (ADR-0006 §S1)

Subscriber previously called policy.NATManager directly for EIP and NAT
Gateway operations, bypassing the L5 wrappers (EIPManager, NATGWManager)
that existed on paper. ADR-0006 §S1 requires every cross-layer call to
pass through the immediate lower neighbor's typed interface; IGW already
followed this pattern.

Wire EIPManager and NATGWManager into the Subscriber struct, construct
them in vpcd alongside IGWManager, and swap the four direct-L3 call
sites (AddEIP, DeleteEIP, AddNATGateway, DeleteNATGateway) onto the
L5 surface (AttachEIP, DetachEIP, AttachNATGateway, DetachNATGateway).
AttachEIP now fires the flows-barrier, giving callers the same
"reachable on return" guarantee IGW already had.

The reconciler still calls policy.NATManager directly; that's a
cross-cutter and a separate concern.

* test(reboot): port 8MB WAL pad workaround from bash run-reboot-e2e.sh

App VMs' user-data writes (unit file + index.html) sit in the on-host
WAL until WriteWALToChunk crosses the 4MB threshold. Host reboot before
that loses them — systemd then refuses to load the zero-byte unit and
the responder never starts. Mirror the bash driver's dd+sync pad so the
WAL flushes pre-reboot. Drop when the upstream WAL fix lands.

* test(e2e): dump OVN/OVS/SG state on Phase 8d NATGW failure

Phase 8d's existing failure path only prints lr-nat-list and
lr-route-list. CI artifacts are not uploaded when run-e2e.sh exits
nonzero, so debugging the NATGW regression requires inline diagnostics
in the journal. Expand the dump to include nb/sb show, chassis bindings,
port_group ACLs, br-int flows filtered to the NATGW IP/private subnet,
host routes, and the private VM's own ip/route/neigh state.

* fix(ovn,reconcile): scope NAT deletes to router; NATGW snat per associated subnet

Two compounding bugs caused Phase 8d NATGW E2E flake (~60% CI failure):

1. ovn.DeleteNAT/DeleteNATByExternalIP searched all NAT rows globally and
   only mutated the named router afterwards. With AWS subnet CIDRs repeating
   across VPCs, (type, logicalIP/externalIP) is not globally unique — the
   delete picked a NAT row owned by a different router, the mutate was a
   no-op, and the row delete failed with "remaining reference(s)", leaving
   the original router's NAT orphaned. Both calls now resolve the LR first
   and filter NAT lookups to lr.NAT UUIDs. Mock client mirrors the scoping.

2. reconcile.loadNATGWs used the NATGW's home (public) subnet CIDR as
   SubnetCIDR, producing an snat for the wrong CIDR that collided with the
   correct event-published snat. loadNATGWs now walks route tables and
   emits one NATGWSpec per (NATGW, associated private subnet) pair,
   mirroring publishNatGatewayEventsForAssociation. Intent map keyed by
   natgwID|subnetCIDR so multi-subnet NATGWs produce distinct specs.

Tests:
- nat_test.go: cross-router same-logicalIP isolation for DeleteEIP and
  DeleteNATGateway
- intent_test.go: NATGW snat uses associated private subnet CIDR, not the
  NATGW home subnet; NATGW with no associations produces no spec

---------

Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
This commit is contained in:
Josh Siviour
2026-05-27 21:05:01 +10:00
committed by GitHub
parent 3f3ba32317
commit fd3dc6b680
172 changed files with 13790 additions and 15758 deletions
+21
View File
@@ -0,0 +1,21 @@
# CODEOWNERS — GitHub PR review enforcement
#
# Format: <path pattern> <owner> [<owner> …]
# Order matters — last matching pattern wins.
#
# Set the matching branch protection rule on `dev` and `main` to
# "Require review from Code Owners" or this file is advisory only.
# Fall-through: any change needs a team review.
* @mulgadc/mulgadc-engineers
# Network layer is governed by ADR-0006 invariant tests.
# Changes here must respect those clauses — see
# spinifex/network/invariants/ and
# docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md
/spinifex/network/ @joshsiv-mulga
/spinifex/vpcd/ @joshsiv-mulga
# Invariants suite is the executable ADR-0006 fence — any widening or
# narrowing of the scan roots needs an explicit owner review.
/spinifex/network/invariants/ @joshsiv-mulga
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# capture-net-diag.sh — write a snapshot of network / OVN / OVS / IPsec
# state into $1 (a directory the caller already created on the local host).
# Designed to run on a spinifex node after an E2E suite, before tofu destroy
# pulls the rug out. Every command is independent — `|| true` so a missing
# binary on one path doesn't abort the rest of the capture.
#
# Output layout (under $1/net-diag/):
# openvswitch-ipsec.status systemctl status (output-only)
# openvswitch-ipsec.journal last 200 journal lines
# ipsec.openvswitch_conf ovs-vsctl get Open_vSwitch . other_config
# ipsec.xfrm_state ip xfrm state list
# ipsec.xfrm_policy ip xfrm policy list
# ovn.sb_show ovn-sbctl show
# ovn.nb_show ovn-nbctl show
# ovs.show ovs-vsctl show
# ovs.flows_br_int ovs-ofctl dump-flows br-int
# ovs.tunnels ovs-appctl ofproto/list-tunnels br-int
# net.links ip -s link show
# net.routes ip route show table all
# net.addrs ip -br addr show
# systemd.failed_units systemctl list-units --state=failed
set +e
OUT="${1:?usage: capture-net-diag.sh <output-dir>}"
mkdir -p "$OUT"
run() {
local label="$1"; shift
{
echo "# $*"
"$@" 2>&1
} > "$OUT/$label" || true
}
run openvswitch-ipsec.status sudo systemctl status openvswitch-ipsec.service --no-pager --full --lines=50
run openvswitch-ipsec.journal sudo journalctl -u openvswitch-ipsec.service --no-pager --output=short-iso -n 200
run ipsec.openvswitch_conf sudo ovs-vsctl get Open_vSwitch . other_config
run ipsec.xfrm_state sudo ip xfrm state list
run ipsec.xfrm_policy sudo ip xfrm policy list
run ovn.sb_show sudo ovn-sbctl show
run ovn.nb_show sudo ovn-nbctl show
run ovn.sb_chassis sudo ovn-sbctl list chassis
run ovs.show sudo ovs-vsctl show
run ovs.flows_br_int sudo ovs-ofctl dump-flows br-int
run ovs.tunnels sudo ovs-appctl ofproto/list-tunnels br-int
run net.links ip -s link show
run net.routes ip route show table all
run net.addrs ip -br addr show
run systemd.failed_units sudo systemctl list-units --state=failed --no-pager
exit 0
+90 -6
View File
@@ -4,8 +4,8 @@ name: E2E (Go)
# Mirrors e2e.yml's single-node provisioning chain but runs `go test` via
# gotestsum so we get JUnit XML + per-test artifact directories surfaced to
# GitHub Actions. Manual-dispatch only while the conversion lands — push
# trigger lifts once the single-node Go port (mulga-siv-89) is green and
# replaces run-e2e.sh; matches e2e-ddil.yml's Phase 1 trigger model.
# trigger lifts once the single-node Go port is green and replaces
# run-e2e.sh; matches e2e-ddil.yml's Phase 1 trigger model.
on:
workflow_dispatch:
@@ -17,6 +17,10 @@ on:
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb')"
required: false
default: "e2e-single e2e-multinode e2e-cert e2e-lb"
test_run:
description: "Optional regex for -test.run (e.g. '^TestMultinodeIPSec$'). Empty = run all tests in suite."
required: false
default: ""
permissions:
contents: read
@@ -32,6 +36,7 @@ concurrency:
env:
GIT_BRANCH: ${{ github.ref_name }}
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb' }}
TEST_RUN: ${{ inputs.test_run || '' }}
jobs:
e2e_go_single:
@@ -226,6 +231,13 @@ jobs:
echo "rc=0" >> "$GITHUB_OUTPUT"
exit 0
fi
# Optional regex passed through to `go test -test.run`. Empty input
# means run all tests in the suite (default behaviour).
if [ -n "${TEST_RUN}" ]; then
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
else
TEST_RUN_FLAG=""
fi
VM_HOST="tf-user@${{ steps.vm.outputs.wan_ip }}"
SSH_KEY="${{ steps.vm.outputs.ssh_key }}"
# On cancel / runner SIGTERM / step timeout, force-kill any test
@@ -258,7 +270,7 @@ jobs:
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=30m \
./\$SUITE.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log' \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
rc=\${PIPESTATUS[0]}; \
@@ -277,6 +289,23 @@ jobs:
echo "rc=${E2E_RC}" >> "$GITHUB_OUTPUT"
exit 0 # don't fail the step — artifact upload + final gate happen below
- name: Capture network/IPsec diagnostics on VM
if: always()
run: |
set +e
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
# Ship the diag script, run it into the VM's ARTIFACT_DIR, leave the
# output for the next step's tar to pull down. Captures OVN/OVS/IPsec
# state at failure time — before teardown destroys the VM.
scp "${SSH_OPTS[@]}" \
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/capture-net-diag.sh" \
"$VM":/tmp/capture-net-diag.sh
ssh "${SSH_OPTS[@]}" "$VM" "bash /tmp/capture-net-diag.sh '${ARTIFACT_DIR}/net-diag'"
ok "net diagnostics captured"
- name: Collect artifacts from VM
if: always()
run: |
@@ -605,6 +634,11 @@ jobs:
echo "rc=0" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "${TEST_RUN}" ]; then
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
else
TEST_RUN_FLAG=""
fi
VM_HOST="tf-user@${{ steps.cluster.outputs.node1_ip }}"
SSH_KEY="${{ steps.cluster.outputs.ssh_key }}"
NODES_CSV="${{ steps.cluster.outputs.nodes_csv }}"
@@ -634,7 +668,7 @@ jobs:
SPINIFEX_SSH_USER=tf-user \
SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=30m \
./\$SUITE.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log' \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
rc=\${PIPESTATUS[0]}; \
@@ -653,6 +687,42 @@ jobs:
echo "rc=${E2E_RC}" >> "$GITHUB_OUTPUT"
exit 0
- name: Capture network/IPsec diagnostics on all nodes
if: always()
run: |
set +e
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.cluster.outputs.ssh_key }}")
# Capture per-node into the node1 ARTIFACT_DIR (where Collect pulls
# from). One subdir per node so xfrm SA state is comparable across
# peers — critical for diagnosing the IPsec wiring.
IFS=',' read -ra NODE_LIST <<< "${{ steps.cluster.outputs.nodes_csv }}"
NODE1_IP="${NODE_LIST[0]}"
scp "${SSH_OPTS[@]}" \
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/capture-net-diag.sh" \
tf-user@"$NODE1_IP":/tmp/capture-net-diag.sh
for i in "${!NODE_LIST[@]}"; do
idx=$((i + 1))
ip="${NODE_LIST[$i]}"
if [ "$idx" -ne 1 ]; then
scp "${SSH_OPTS[@]}" \
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/capture-net-diag.sh" \
tf-user@"$ip":/tmp/capture-net-diag.sh
fi
ssh "${SSH_OPTS[@]}" tf-user@"$ip" \
"bash /tmp/capture-net-diag.sh '${ARTIFACT_DIR}/net-diag-node${idx}'"
# node1 keeps its diag locally; pull node2..N back into node1's
# ARTIFACT_DIR so the existing tar-pull below catches everything.
if [ "$idx" -ne 1 ]; then
ssh "${SSH_OPTS[@]}" tf-user@"$ip" \
"tar -C '${ARTIFACT_DIR}' -cf - 'net-diag-node${idx}' 2>/dev/null || true" \
| ssh "${SSH_OPTS[@]}" tf-user@"$NODE1_IP" \
"tar -C '${ARTIFACT_DIR}' -xf - 2>/dev/null"
fi
ok "net diagnostics node${idx} captured"
done
- name: Collect artifacts from node1
if: always()
run: |
@@ -675,6 +745,15 @@ jobs:
> "${ARTIFACT_DIR}/spinifex-journal-node${idx}.log" 2>/dev/null
ok "journal node${idx}: $(wc -l < "${ARTIFACT_DIR}/spinifex-journal-node${idx}.log" 2>/dev/null || echo 0) lines"
done
# openvswitch-ipsec journal per node (separate file).
# Debian unit name; the binary inside is /usr/share/openvswitch/scripts/ovs-ctl
# which spawns ovs-monitor-ipsec as a child.
for i in "${!NODE_LIST[@]}"; do
idx=$((i + 1))
ip="${NODE_LIST[$i]}"
ssh "${SSH_OPTS[@]}" tf-user@"$ip" 'sudo journalctl -u openvswitch-ipsec.service --no-pager --output=short-iso --since "60 min ago"' \
> "${ARTIFACT_DIR}/openvswitch-ipsec-node${idx}.log" 2>/dev/null
done
- name: Convert test logs to JUnit XML
if: always()
@@ -919,6 +998,11 @@ jobs:
R=$'\033[0m'; B=$'\033[1m'; C=$'\033[36m'
printf '\n%s%s━━ suite reboot ━━%s\n' "$B" "$C" "$R"
if [ -n "${TEST_RUN}" ]; then
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
else
TEST_RUN_FLAG=""
fi
GITHUB_ACTIONS=true FORCE_COLOR=1 \
SPINIFEX_E2E=1 SPINIFEX_MODE=single \
SPINIFEX_WAN_IP="${{ steps.vm.outputs.wan_ip }}" \
@@ -927,7 +1011,7 @@ jobs:
SPINIFEX_AWS_ACCESS_KEY_ID="${{ steps.creds.outputs.ak }}" \
SPINIFEX_AWS_SECRET_ACCESS_KEY="${{ steps.creds.outputs.sk }}" \
ARTIFACT_DIR="${ARTIFACT_DIR}" \
./spinifex/tests/e2e/_bin/reboot.test -test.v -test.timeout=30m \
./spinifex/tests/e2e/_bin/reboot.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
2>&1 | tee "${ARTIFACT_DIR}/test-reboot.log" \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'
rc=${PIPESTATUS[0]}
@@ -1029,7 +1113,7 @@ jobs:
# dispatched ref, always present. The previous `&& inputs.sha` gate
# plus `statuses/${{ inputs.sha }}` produced an 'invalid hash' error
# whenever the workflow was dispatched without the optional input
# (tests passed, status step silently failed). Refs mulga-siv-112.
# (tests passed, status step silently failed).
if: always()
needs: [ e2e_go_single, e2e_go_multinode, e2e_go_reboot ]
runs-on: [ self-hosted, ci-single ]
+14 -6
View File
@@ -132,7 +132,7 @@ test:
COVERPROFILE ?= coverage.out
test-cover:
@echo -e "\n....Running tests with coverage for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test -timeout 120s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... $(_COVQ)
$(_Q)LOG_IGNORE=1 go test -timeout 120s -coverpkg=./spinifex/... -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... $(_COVQ)
@scripts/check-coverage.sh $(COVERPROFILE) $(QUIET)
# Run unit tests with race detector
@@ -281,20 +281,28 @@ distro-clean:
# Ansible dev lifecycle (experimental, parallel to dev-*.sh / reset-dev-env.sh).
# See scripts/ansible/README.md and docs/development/improvements/ansible-dev-lifecycle.md.
#
# Variable overrides: pass EXTRA_VARS="key=val key2=val2" to forward as
# `--extra-vars` to ansible-playbook. Plain `-e` on the make command line is
# make's own flag and does NOT reach ansible.
# make ansible-dev-install EXTRA_VARS="spinifex_external_pool_start=192.168.1.90 spinifex_external_pool_end=192.168.1.99"
EXTRA_VARS ?=
_ANSIBLE_EXTRA = $(if $(strip $(EXTRA_VARS)),--extra-vars "$(EXTRA_VARS)",)
ansible-dev-preflight:
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml $(_ANSIBLE_EXTRA)
ansible-dev-teardown:
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml $(_ANSIBLE_EXTRA)
ansible-dev-install:
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml $(_ANSIBLE_EXTRA)
ansible-dev-reset:
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml $(_ANSIBLE_EXTRA)
ansible-dev-deploy:
cd scripts/ansible && ansible-playbook playbooks/dev-deploy.yml
cd scripts/ansible && ansible-playbook playbooks/dev-deploy.yml $(_ANSIBLE_EXTRA)
ansible-dev-status:
cd scripts/ansible && ansible-playbook playbooks/dev-status.yml
+10 -3
View File
@@ -1,7 +1,14 @@
[Unit]
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service openvswitch-ipsec.service
# Wants=openvswitch-ipsec.service so systemd brings the IPsec userspace
# daemon up before spinifex flips other_config:ipsec_encapsulation=true.
# Without that daemon, OVS holds the cert pointers unused and strongSwan
# never gets IKE config — tunnel traffic is encrypted with no SAs and
# silently dropped. Wants= (not Requires=) keeps daemon bootable on
# single-node / lab hosts where the package is intentionally absent.
Wants=openvswitch-ipsec.service
# No Wants=spinifex-nats.service: Wants= reactivates a stopped unit during
# `systemctl restart spinifex-daemon`, which would mask the standalone-startup
# path the Tier 1 DDIL Scenario B exercises. spinifex.target Wants= covers
@@ -23,7 +30,7 @@ Group=spinifex
ExecStart=/usr/local/bin/spx service spinifex start
Restart=on-failure
RestartSec=5
# DDIL Tier 1 (mulga-siv-58): KillMode=process so systemd only signals the
# DDIL Tier 1: KillMode=process so systemd only signals the
# daemon's main PID, not the cgroup. QEMU children stay alive across
# `systemctl restart spinifex-daemon` and the new daemon reattaches to them
# via the persisted local state file + pidfile + QMP socket. With the
@@ -38,7 +45,7 @@ OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
# DDIL Tier 1 (mulga-siv-58): QEMU pidfiles, QMP sockets, and the
# DDIL Tier 1: QEMU pidfiles, QMP sockets, and the
# shared qemu.log all resolve via utils.pidPath() — which falls back
# to os.TempDir() (=/tmp) when $HOME/spinifex doesn't exist. With
# PrivateTmp=yes, each daemon start gets a fresh /tmp namespace, so
+2 -2
View File
@@ -89,7 +89,7 @@ func main() {
}
}
// Build nclient4 options — identical to spinifex/services/vpcd/dhcp/nclient4.go.
// Build nclient4 options — standalone DHCP probe for upstream-router debugging.
opts := []nclient4.ClientOpt{
nclient4.WithHWAddr(hwAddr),
nclient4.WithTimeout(*timeout),
@@ -105,7 +105,7 @@ func main() {
}
defer client.Close()
// Identity modifiers — identical to identityModifiers() in vpcd dhcp package.
// Identity modifiers — client-id / hostname / vendor-class for upstream lease tagging.
var mods []dhcpv4.Modifier
if cid != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(cid))))
+4 -3
View File
@@ -304,9 +304,10 @@ func installSpinifex(cfg *Config) error {
return err
}
// dhcpcd-base is present on the installed system (used by setup-ovn.sh for
// macvlan mode). Mask the standalone dhcpcd.service so it never auto-starts
// and races with systemd-networkd's built-in DHCP client on br-wan.
// dhcpcd-base is present on the installed system (used by setup-ovn.sh
// for WAN DHCP acquisition). Mask the standalone dhcpcd.service so it
// never auto-starts and races with systemd-networkd's built-in DHCP
// client on br-wan.
if err := maskSystemdUnit(mountRoot, "dhcpcd.service"); err != nil {
slog.Warn("installSpinifex: failed to mask dhcpcd.service", "err", err)
}
+82 -95
View File
@@ -300,6 +300,7 @@ func init() {
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
adminInitCmd.Flags().Bool("no-external", false, "Disable external networking (overlay-only, no internet for VMs)")
adminInitCmd.Flags().Bool("gpu-passthrough", false, "Enable VFIO GPU passthrough (sets gpu_passthrough = true in daemon config)")
adminInitCmd.Flags().Bool("ipsec", true, "Encrypt intra-AZ Geneve via OVN native IPsec (cluster-wide); disable only for trusted single-rack lab")
// Flags for admin join
adminJoinCmd.Flags().String("region", "ap-southeast-2", "Region for this node")
@@ -844,6 +845,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
noExternal, _ := cmd.Flags().GetBool("no-external")
gpuPassthrough, _ := cmd.Flags().GetBool("gpu-passthrough")
ipsecEnabled, _ := cmd.Flags().GetBool("ipsec")
// Fire telemetry in background (completes during init work, waited at end)
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
@@ -893,9 +895,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", iface.Name, iface.IP, iface.Subnet, gw, strings.ToUpper(iface.Role))
}
if detected.LANCount == 0 {
fmt.Println("\n Mode: single-NIC (macvlan for external bridge)")
fmt.Println("\n Mode: single-NIC (veth-bridged external)")
} else {
fmt.Printf("\n Mode: %d LAN + 1 WAN (macvlan for external bridge)\n", detected.LANCount)
fmt.Printf("\n Mode: %d LAN + 1 WAN (veth-bridged external)\n", detected.LANCount)
}
// Apply auto-detected values when flags not explicitly set
@@ -910,13 +912,11 @@ func runAdminInit(cmd *cobra.Command, args []string) {
externalPrefixLen = detected.WAN.PrefixLen
}
// Default mode: always "pool" — with static range if --external-pool
// is given, otherwise with source=dhcp (gateway IP from router DHCP)
// Default mode: always "pool". Source defaults to "static"; if
// --external-pool is omitted the validator below will error with
// a SuggestPoolRange hint.
if externalMode == "" && !cmd.Flags().Changed("external-mode") {
externalMode = "pool"
if externalPool == "" && externalSource == "" {
externalSource = "dhcp"
}
}
}
}
@@ -928,42 +928,29 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
if externalMode == "pool" {
// Resolve source: if not explicitly set, infer from whether a pool range was given
if externalSource == "" {
if externalPool != "" {
externalSource = "static"
} else {
externalSource = "dhcp"
}
if externalSource != "" && externalSource != "static" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-source must be 'static' (only supported value), got: %s\n", externalSource)
os.Exit(1)
}
if externalSource == "dhcp" {
// DHCP source: no static range needed, just gateway
if externalGateway == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-gateway is required when --external-mode=pool\n")
os.Exit(1)
externalSource = "static"
if externalPool == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-pool is required when --external-mode=pool (e.g., 192.168.1.150-192.168.1.250)\n")
if detectedNet != nil && detectedNet.WAN != nil {
sugStart, sugEnd := admin.SuggestPoolRange(detectedNet.WAN)
fmt.Fprintf(os.Stderr, " Suggested: --external-pool=%s-%s\n", sugStart, sugEnd)
}
} else {
// Static source: need pool range
if externalPool == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-pool is required when --external-mode=pool (e.g., 192.168.1.150-192.168.1.250)\n")
fmt.Fprintf(os.Stderr, " Or use --external-source=dhcp to get IPs from router DHCP\n")
if detectedNet != nil && detectedNet.WAN != nil {
sugStart, sugEnd := admin.SuggestPoolRange(detectedNet.WAN)
fmt.Fprintf(os.Stderr, " Suggested: --external-pool=%s-%s\n", sugStart, sugEnd)
}
os.Exit(1)
}
if externalGateway == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-gateway is required when --external-mode=pool\n")
os.Exit(1)
}
parts := strings.SplitN(externalPool, "-", 2)
if len(parts) != 2 || net.ParseIP(parts[0]) == nil || net.ParseIP(parts[1]) == nil {
fmt.Fprintf(os.Stderr, "❌ Error: --external-pool must be start-end IPs (e.g., 192.168.1.150-192.168.1.250), got: %s\n", externalPool)
os.Exit(1)
}
poolStart, poolEnd = parts[0], parts[1]
os.Exit(1)
}
if externalGateway == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-gateway is required when --external-mode=pool\n")
os.Exit(1)
}
parts := strings.SplitN(externalPool, "-", 2)
if len(parts) != 2 || net.ParseIP(parts[0]) == nil || net.ParseIP(parts[1]) == nil {
fmt.Fprintf(os.Stderr, "❌ Error: --external-pool must be start-end IPs (e.g., 192.168.1.150-192.168.1.250), got: %s\n", externalPool)
os.Exit(1)
}
poolStart, poolEnd = parts[0], parts[1]
}
if externalGateway != "" && net.ParseIP(externalGateway) == nil {
fmt.Fprintf(os.Stderr, "❌ Error: --external-gateway is not a valid IP: %s\n", externalGateway)
@@ -983,10 +970,6 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
}
// For pool/dhcp source, the gateway IP is obtained via DHCP on the
// macvlan/bridge interface (no static pool range or explicit gateway-ip).
useExternalDHCP := externalMode == "pool" && externalSource == "dhcp" && gatewayIP == ""
// Validate IP address format
if net.ParseIP(bindIP) == nil {
fmt.Fprintf(os.Stderr, "❌ Error: Invalid IP address for --bind: %s\n", bindIP)
@@ -1104,6 +1087,18 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// Generate SSL certificates (with bind IP in SANs for multi-node support)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP)
// Generate per-node IPsec peer cert when cluster-wide IPsec is enabled
// (default true). Reuses the cluster CA — no intermediate strongSwan PKI.
if ipsecEnabled {
caCertPath := filepath.Join(configDir, "ca.pem")
caKeyPath := filepath.Join(configDir, "ca.key")
if err := admin.GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, node, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating IPsec peer certificate: %v\n", err)
os.Exit(1)
}
fmt.Println("🔐 IPsec peer certificate generated (intra-AZ Geneve encryption ON)")
}
// Install CA certificate into system trust store
installCACertificate(filepath.Join(configDir, "ca.pem"))
@@ -1125,30 +1120,31 @@ func runAdminInit(cmd *cobra.Command, args []string) {
isMultiNode := nodes >= 2
if isMultiNode {
// Build cluster-wide network config for propagation to joining nodes
var networkConfig *formation.NetworkConfig
// Build cluster-wide network config for propagation to joining nodes.
// Always emit so the IPSecEnabled flag reaches joiners even when
// external networking is disabled.
networkConfig := &formation.NetworkConfig{
IPSecEnabled: ipsecEnabled,
}
if externalMode != "" {
bootstrapVpcId := utils.GenerateResourceID("vpc")
bootstrapSubnetId := utils.GenerateResourceID("subnet")
bootstrapIgwId := utils.GenerateResourceID("igw")
networkConfig = &formation.NetworkConfig{
ExternalMode: externalMode,
ExternalDHCP: useExternalDHCP,
PoolName: "wan",
PoolSource: externalSource,
PoolStart: poolStart,
PoolEnd: poolEnd,
PoolGateway: externalGateway,
PoolGatewayIP: gatewayIP,
PoolPrefixLen: externalPrefixLen,
PoolDNSServers: dnsServers,
BootstrapAccountId: admin.DefaultAccountID(),
BootstrapVpcId: bootstrapVpcId,
BootstrapSubnetId: bootstrapSubnetId,
BootstrapIgwId: bootstrapIgwId,
BootstrapCidr: handlers_ec2_vpc.DefaultVPCCidr,
BootstrapSubnetCidr: handlers_ec2_vpc.DefaultSubnetCidr,
}
networkConfig.ExternalMode = externalMode
networkConfig.PoolName = "wan"
networkConfig.PoolSource = externalSource
networkConfig.PoolStart = poolStart
networkConfig.PoolEnd = poolEnd
networkConfig.PoolGateway = externalGateway
networkConfig.PoolGatewayIP = gatewayIP
networkConfig.PoolPrefixLen = externalPrefixLen
networkConfig.PoolDNSServers = dnsServers
networkConfig.BootstrapAccountId = admin.DefaultAccountID()
networkConfig.BootstrapVpcId = bootstrapVpcId
networkConfig.BootstrapSubnetId = bootstrapSubnetId
networkConfig.BootstrapIgwId = bootstrapIgwId
networkConfig.BootstrapCidr = handlers_ec2_vpc.DefaultVPCCidr
networkConfig.BootstrapSubnetCidr = handlers_ec2_vpc.DefaultSubnetCidr
}
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, natsToken, clusterName,
@@ -1248,8 +1244,6 @@ func runAdminInit(cmd *cobra.Command, args []string) {
ExternalMode: externalMode,
ExternalIface: externalIface,
DhcpBindBridge: detectedDhcpBindBridge(detectedNet),
ExternalDHCP: useExternalDHCP,
PoolName: "wan",
PoolSource: externalSource,
PoolStart: poolStart,
@@ -1268,6 +1262,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
BootstrapSubnetCidr: handlers_ec2_vpc.DefaultSubnetCidr,
GPUPassthrough: gpuPassthrough,
IPSecEnabled: ipsecEnabled,
}
// Print external networking summary
@@ -1278,10 +1273,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Printf(" Source: static (IP range)\n")
fmt.Printf(" IP pool: %s - %s\n", poolStart, poolEnd)
fmt.Printf(" ⚠️ Ensure %s-%s is excluded from your router's DHCP range.\n", poolStart, poolEnd)
} else if useExternalDHCP {
fmt.Printf(" Source: dhcp (from router DHCP)\n")
fmt.Println(" Gateway IP: DHCP (obtained during OVN setup)")
} else if gatewayIP != "" {
}
if gatewayIP != "" {
fmt.Printf(" Gateway IP: %s (static)\n", gatewayIP)
}
}
@@ -1861,6 +1854,24 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Server certificate generated with bind IP: %s\n\n", bindIP)
// Match the leader's intra-AZ IPsec posture: NetworkConfig.IPSecEnabled is
// authoritative. When the formation response omits NetworkConfig entirely
// (no current code path; defensive guard against future regressions) fall
// back to the AWS-parity default of ON.
ipsecEnabled := true
if statusResp.NetworkConfig != nil {
ipsecEnabled = statusResp.NetworkConfig.IPSecEnabled
}
if ipsecEnabled {
caCertPath := filepath.Join(configDir, "ca.pem")
caKeyPath := filepath.Join(configDir, "ca.key")
if err := admin.GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, node, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating IPsec peer certificate: %v\n", err)
os.Exit(1)
}
fmt.Println("🔐 IPsec peer certificate generated (intra-AZ Geneve encryption ON)")
}
// Build cluster topology from formation data
clusterRoutes := formation.BuildClusterRoutes(statusResp.Nodes)
predastoreNodes := formation.BuildPredastoreNodes(statusResp.Nodes)
@@ -2270,8 +2281,8 @@ func finalizeNodeSetup(dataDir, certPath, adminAccessKey, adminSecretKey, region
// applyNetworkConfig copies cluster-wide network settings from a formation
// NetworkConfig into ConfigSettings and auto-detects the local WAN interface.
func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkConfig) {
settings.IPSecEnabled = nc.IPSecEnabled
settings.ExternalMode = nc.ExternalMode
settings.ExternalDHCP = nc.ExternalDHCP
settings.PoolName = nc.PoolName
settings.PoolSource = nc.PoolSource
settings.PoolStart = nc.PoolStart
@@ -2292,7 +2303,6 @@ func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkCon
detected, err := admin.DetectNetwork()
if err == nil && detected.WAN != nil {
settings.ExternalIface = detected.WAN.Name
settings.DhcpBindBridge = detectedDhcpBindBridge(detected)
}
}
}
@@ -2401,29 +2411,6 @@ func writeBootstrapFilesWithAdmin(configDir, bootstrapDir string, masterKey []by
return handlers_iam.SaveBootstrapData(filepath.Join(bootstrapDir, "bootstrap.json"), bd)
}
// detectedDhcpBindBridge returns the bridge name where the vpcd DHCP client
// should bind its AF_PACKET socket — i.e. the interface that physically sees
// LAN DHCP traffic.
//
// - Default route on a bridge (Linux or OVS, `br-*` prefix): return the
// bridge name verbatim. In veth mode this is the Linux bridge holding the
// WAN NIC; in direct mode this is the OVS bridge holding the WAN NIC.
// - Default route on a bare physical NIC: return "br-wan" as the name the
// installer will create.
//
// Never returns "br-ext". br-ext is the OVN-side bridge owned by
// setup-ovn.sh's ovn-bridge-mappings and never sees LAN DHCP frames.
func detectedDhcpBindBridge(detected *admin.DetectedNetwork) string {
if detected == nil || detected.WAN == nil {
return ""
}
name := detected.WAN.Name
if strings.HasPrefix(name, "br-") {
return name
}
return "br-wan"
}
// detectDNSServers auto-detects DNS servers from the host for the specified
// interface. Uses resolvectl (systemd-resolved) first, then falls back to
// /etc/resolv.conf. Returns up to 3 servers. Falls back to public DNS if none found.
+3 -46
View File
@@ -142,49 +142,6 @@ func TestSpinifexTomlTemplate_AdvertiseOmittedWhenEmpty(t *testing.T) {
assert.NotContains(t, string(data), "advertise =")
}
// detectedDhcpBindBridge must return the default-route interface name verbatim
// when it's a bridge (Linux or OVS, br-* prefix). The old detectedWanBridge()
// returned hardcoded "br-ext" for Linux bridges — that value broke DHCP on
// consumer-router LANs because br-ext never sees LAN DHCP traffic (mulga-998).
func TestDetectedDhcpBindBridge(t *testing.T) {
tests := []struct {
name string
detected *admin.DetectedNetwork
want string
}{
{
name: "Linux bridge default route returns bridge name (not 'br-ext')",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-wan"}},
want: "br-wan",
},
{
name: "OVS bridge default route returned verbatim",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-ext"}},
want: "br-ext",
},
{
name: "physical NIC defaults to 'br-wan' (bridge the installer creates)",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "enp0s3"}},
want: "br-wan",
},
{
name: "nil DetectedNetwork",
detected: nil,
want: "",
},
{
name: "DetectedNetwork with nil WAN",
detected: &admin.DetectedNetwork{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, detectedDhcpBindBridge(tt.detected))
})
}
}
// Legacy `wan_bridge` TOML key must fail-start vpcd with guidance, not silently
// alias (per mulga-998 D3). Prevents the footgun where operators inherited the
// old key pointing at 'br-ext' and got broken DHCP on veth-mode hosts.
@@ -206,8 +163,8 @@ wan_bridge = "br-ext"
err := checkLegacyWanBridgeKey("node1", cfgPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
assert.Contains(t, err.Error(), "wan_bridge")
assert.Contains(t, err.Error(), "Remove")
}
func TestCheckLegacyWanBridgeKey_EnvVarRejected(t *testing.T) {
@@ -218,7 +175,7 @@ func TestCheckLegacyWanBridgeKey_EnvVarRejected(t *testing.T) {
err := checkLegacyWanBridgeKey("node1", "/tmp/unused.toml")
require.Error(t, err)
assert.Contains(t, err.Error(), "SPINIFEX_VPCD_WAN_BRIDGE")
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
assert.Contains(t, err.Error(), "Remove")
}
func TestCheckLegacyWanBridgeKey_CleanConfigPasses(t *testing.T) {
@@ -228,7 +185,7 @@ func TestCheckLegacyWanBridgeKey_CleanConfigPasses(t *testing.T) {
node = "node1"
[nodes.node1.vpcd]
ovn_nb_addr = "tcp:127.0.0.1:6641"
dhcp_bind_bridge = "br-wan"
external_interface = "enp0s3"
`), 0o644))
viper.Reset()
+5 -12
View File
@@ -29,7 +29,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/services/predastore"
"github.com/mulgadc/spinifex/spinifex/services/spinifexui"
"github.com/mulgadc/spinifex/spinifex/services/viperblockd"
"github.com/mulgadc/spinifex/spinifex/services/vpcd"
"github.com/mulgadc/spinifex/spinifex/vpcd"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@@ -678,11 +678,8 @@ var spinifexUIStatusCmd = &cobra.Command{
}
// checkLegacyWanBridgeKey fails vpcd startup if the deprecated `wan_bridge`
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. The key was
// renamed to `dhcp_bind_bridge` (see mulga-998.a) because the old name misled
// operators into pointing at the OVN-side bridge ("br-ext"), which never sees
// LAN DHCP traffic. Per D3: no silent alias, no auto-rewrite — operator must
// rename the key before vpcd will start.
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. Per D3: no silent
// alias, no auto-rewrite — operator must remove the key before vpcd will start.
func checkLegacyWanBridgeKey(node, cfgFile string) error {
legacyInTOML := viper.IsSet("nodes." + node + ".vpcd.wan_bridge")
legacyInEnv := os.Getenv("SPINIFEX_VPCD_WAN_BRIDGE") != ""
@@ -695,9 +692,7 @@ func checkLegacyWanBridgeKey(node, cfgFile string) error {
}
return fmt.Errorf(
"vpcd: deprecated 'wan_bridge' key found in %s. "+
"Rename 'wan_bridge' to 'dhcp_bind_bridge'. The value may also be wrong — verify it is "+
"the Linux bridge holding your WAN NIC (veth mode) or the OVS bridge holding your WAN NIC (direct mode); "+
"never 'br-ext'. Typical value on a consumer-router LAN: 'br-wan'. "+
"Remove the key entirely; vpcd auto-detects the WAN bridge (br-wan). "+
"Then: sudo systemctl restart spinifex-vpcd",
source,
)
@@ -733,7 +728,6 @@ var vpcdStartCmd = &cobra.Command{
for _, p := range clusterConfig.Network.ExternalPools {
extPools = append(extPools, vpcd.ExternalPoolConfig{
Name: p.Name,
Source: p.Source,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Gateway: p.Gateway,
@@ -742,7 +736,6 @@ var vpcdStartCmd = &cobra.Command{
DNSServers: p.DNSServers,
Region: p.Region,
AZ: p.AZ,
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
GwLrpRangeStart: p.GwLrpRangeStart,
GwLrpRangeEnd: p.GwLrpRangeEnd,
})
@@ -778,8 +771,8 @@ var vpcdStartCmd = &cobra.Command{
ExternalPools: extPools,
Bootstrap: bootstrap,
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
BridgeMode: nodeConfig.VPCD.BridgeMode,
AZ: nodeConfig.AZ,
})
if err != nil {
fmt.Println("Error starting vpcd service:", err)
+3 -14
View File
@@ -1,5 +1,5 @@
# Domain configuration file in TOML format.
version = "2"
version = "3"
epoch = 1
node = "{{.Node}}"
{{- if .OperatorEmail}}
@@ -7,13 +7,11 @@ node = "{{.Node}}"
[operator]
email = "{{.OperatorEmail}}"
{{- end}}
{{- if .ExternalMode}}
[network]
ipsec_enabled = {{.IPSecEnabled}}
{{- if .ExternalMode}}
external_mode = "{{.ExternalMode}}"
{{- if .ExternalDHCP}}
external_dhcp = true
{{- end}}
[[network.external_pools]]
name = "{{.PoolName}}"
@@ -97,15 +95,6 @@ ovn_sb_addr = "{{.OVNSBAddr}}"
{{- if .ExternalIface}}
external_interface = "{{.ExternalIface}}"
{{- end}}
{{- if .DhcpBindBridge}}
# dhcp_bind_bridge: the bridge where vpcd's DHCP client binds its AF_PACKET
# socket. On Linux-bridge-WAN hosts (netplan default with the WAN NIC enslaved),
# this is the Linux bridge name (e.g. "br-wan"). On direct-OVS hosts, this is
# the OVS bridge holding the WAN NIC.
# NEVER set this to "br-ext" — that is the OVN-side bridge and never sees LAN
# DHCP traffic.
dhcp_bind_bridge = "{{.DhcpBindBridge}}"
{{- end}}
[nodes.{{.Node}}.predastore]
host = "{{.BindIP}}:8443"
+1 -1
View File
@@ -131,7 +131,7 @@ services:
- "s3.*"
vpcd:
path: spinifex/services/vpcd
path: spinifex/vpcd
# OVN-backed VPC / subnet / SG / NAT / IGW data plane.
subscribes:
- "vpc.create"
+31 -5
View File
@@ -5,7 +5,6 @@ Lives alongside the existing `scripts/dev-*.sh` and `scripts/reset-dev-env.sh`;
neither set is removed. Decide which to keep after parity testing.
Plan: `docs/development/improvements/ansible-dev-lifecycle.md` (mulga top-level).
Bead: `mulga-siv-9`.
## Prerequisites
@@ -107,12 +106,39 @@ Useful overrides:
| `teardown_process_wait_seconds` | `30` | Grace period for processes to exit |
| `spinifex_region` | `ap-southeast-2` | Region passed to `spx admin init` |
| `spinifex_az` | `{{ region }}a` | AZ passed to `spx admin init` |
| `spinifex_external_mode` | `pool` | `pool` \| `nat` external networking |
| `spinifex_external_mode` | `pool` | External networking mode — `pool` or empty (NAT removed) |
| `spinifex_external_no_external` | `false` | `true` → emit `--no-external` and skip external networking |
| `spinifex_external_pool_start` | `""` | **Required** for `pool` mode — first IP of the static WAN pool |
| `spinifex_external_pool_end` | `""` | **Required** for `pool` mode — last IP of the static WAN pool |
| `spinifex_external_gateway` | `""` | WAN gateway (auto-detected when blank) |
| `spinifex_external_prefix_len` | `""` | WAN prefix length (auto-detected when blank) |
| `spinifex_external_gateway_ip` | `""` | OVN router external-IP override (`--gateway-ip`) |
`pool` mode is mandatory unless you set `spinifex_external_no_external=true`.
`spx admin init` rejects the run without a `--external-pool` range, so the
init role asserts `spinifex_external_pool_start/_end` upfront. Either persist
them in `vars/defaults.yml`, or pass them at invocation:
Direct `ansible-playbook` (use `-e` here — this is ansible's flag):
```
ansible-playbook playbooks/dev-install.yml \
-e spinifex_external_pool_start=192.168.1.150 \
-e spinifex_external_pool_end=192.168.1.250
```
Via `make` (use `EXTRA_VARS=` — make eats its own `-e`):
```
make ansible-dev-install \
EXTRA_VARS="spinifex_external_pool_start=192.168.1.150 spinifex_external_pool_end=192.168.1.250"
```
`dev-reset.yml` automatically captures `region`, `az`, `external_mode`,
pool range, gateway, prefix-len, nat gateway_ip from the existing
pool range, gateway, prefix-len and `gateway_ip` from the existing
`/etc/spinifex/spinifex.toml` before teardown and replays them into
`init`. Use `-e` to override.
`init`. Legacy `external_mode = "nat"` is coerced to `"pool"` with a warn.
Use `-e` to override.
## Helper scripts
@@ -129,7 +155,7 @@ calling user (no `sudo -u` invoked → no `initgroups(3)` → supplementary
groups inherited from the operator's shell). On a fresh login the
operator's shell would have `spinifex` (added by `setup.sh`); on shells
that predate the group add it does not. The helpers sidestep this
entirely. See bead `mulga-siv-92`.
entirely.
## Leak catalog
+18 -3
View File
@@ -23,6 +23,7 @@
when: reset_config_stat.stat.exists
# List-concat-and-take-first pattern: missing regex match falls back to current default.
# `gateway\s*=` deliberately does not match `gateway_ip` / `gateway_lrp_*`.
- name: capture — extract scalars
ansible.builtin.set_fact:
spinifex_region: "{{ (reset_config_txt | regex_findall('region\\s*=\\s*\"([^\"]+)\"') + [spinifex_region])[0] }}"
@@ -30,11 +31,25 @@
spinifex_external_mode: "{{ (reset_config_txt | regex_findall('external_mode\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_mode])[0] }}"
spinifex_external_pool_start: "{{ (reset_config_txt | regex_findall('range_start\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_pool_start])[0] }}"
spinifex_external_pool_end: "{{ (reset_config_txt | regex_findall('range_end\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_pool_end])[0] }}"
spinifex_external_gateway: "{{ (reset_config_txt | regex_findall('gateway\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_gateway])[0] }}"
spinifex_external_gateway: "{{ (reset_config_txt | regex_findall('\\bgateway\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_gateway])[0] }}"
spinifex_external_prefix_len: "{{ (reset_config_txt | regex_findall('prefix_len\\s*=\\s*([0-9]+)') + [spinifex_external_prefix_len | string])[0] }}"
spinifex_nat_gateway_ip: "{{ (reset_config_txt | regex_findall('gateway_ip\\s*=\\s*\"([^\"]+)\"') + [spinifex_nat_gateway_ip])[0] }}"
spinifex_external_gateway_ip: "{{ (reset_config_txt | regex_findall('gateway_ip\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_gateway_ip])[0] }}"
when: reset_config_stat.stat.exists
# Legacy: external_mode = "nat" was removed from spx. Coerce to "pool"
# so old configs still reset cleanly; warn so the operator notices.
- name: capture — coerce legacy external_mode=nat to pool
ansible.builtin.set_fact:
spinifex_external_mode: "pool"
when: spinifex_external_mode == 'nat'
- name: capture — warn on legacy nat coercion
ansible.builtin.debug:
msg: "⚠️ Captured external_mode=nat from legacy config; coerced to 'pool' (NAT mode removed from spx admin init)."
when:
- reset_config_stat.stat.exists
- (reset_config_txt | regex_findall('external_mode\\s*=\\s*\"nat\"')) | length > 0
- name: capture — summary
ansible.builtin.debug:
msg: |
@@ -44,7 +59,7 @@
external_mode={{ spinifex_external_mode }}
pool={{ spinifex_external_pool_start }}-{{ spinifex_external_pool_end }}
gateway={{ spinifex_external_gateway }}, prefix={{ spinifex_external_prefix_len }}
nat_gateway={{ spinifex_nat_gateway_ip }}
gateway_ip={{ spinifex_external_gateway_ip }}
{% else %}
No existing config; using defaults from vars/defaults.yml (or -e overrides).
{% endif %}
+26 -16
View File
@@ -8,28 +8,38 @@
--node {{ spinifex_node_name }}
--nodes {{ spinifex_node_count }}
- name: init — no-external opt-out
ansible.builtin.set_fact:
init_external_flags: "--no-external"
when: spinifex_external_no_external | bool
- name: init — assert pool range supplied when mode=pool
ansible.builtin.assert:
that:
- spinifex_external_pool_start | length > 0
- spinifex_external_pool_end | length > 0
fail_msg: |
spx admin init requires --external-pool when --external-mode=pool.
Set spinifex_external_pool_start / spinifex_external_pool_end in
scripts/ansible/vars/defaults.yml, or pass:
-e spinifex_external_pool_start=192.168.1.150
-e spinifex_external_pool_end=192.168.1.250
Pass -e spinifex_external_no_external=true to skip external networking.
when:
- not (spinifex_external_no_external | bool)
- spinifex_external_mode == 'pool'
- name: init — pool-mode external flags
ansible.builtin.set_fact:
init_external_flags: >-
--external-mode=pool
--external-pool={{ spinifex_external_pool_start }}-{{ spinifex_external_pool_end }}
--external-gateway={{ spinifex_external_gateway }}
--external-prefix-len={{ spinifex_external_prefix_len }}
{% if spinifex_external_gateway | length > 0 %}--external-gateway={{ spinifex_external_gateway }}{% endif %}
{% if spinifex_external_prefix_len | string | length > 0 %}--external-prefix-len={{ spinifex_external_prefix_len }}{% endif %}
{% if spinifex_external_gateway_ip | length > 0 %}--gateway-ip={{ spinifex_external_gateway_ip }}{% endif %}
when:
- not (spinifex_external_no_external | bool)
- spinifex_external_mode == 'pool'
- spinifex_external_pool_start | length > 0
- spinifex_external_pool_end | length > 0
- name: init — NAT-mode external flags
ansible.builtin.set_fact:
init_external_flags: >-
--external-mode=nat
--gateway-ip={{ spinifex_nat_gateway_ip }}
--external-gateway={{ spinifex_external_gateway }}
--external-prefix-len={{ spinifex_external_prefix_len }}
when:
- spinifex_external_mode == 'nat'
- spinifex_nat_gateway_ip | length > 0
- name: init — assemble full flag string
ansible.builtin.set_fact:
@@ -75,6 +85,6 @@
msg: |
Init complete:
region={{ spinifex_region }}, az={{ spinifex_az }}, node={{ spinifex_node_name }}
external_mode={{ spinifex_external_mode }}
external_mode={{ 'disabled' if (spinifex_external_no_external | bool) else spinifex_external_mode }}
spinifex.target: started
CA: {{ spinifex_ca_trust_path }}
+1 -1
View File
@@ -57,7 +57,7 @@
- name: install — run setup-ovn.sh --management
become: true
ansible.builtin.command:
cmd: /usr/local/share/spinifex/setup-ovn.sh --management
cmd: "/usr/local/share/spinifex/setup-ovn.sh --management --node-name={{ spinifex_node_name }}"
changed_when: true
- name: install — summary
+10 -2
View File
@@ -23,12 +23,20 @@ spinifex_node_name: node1
spinifex_node_count: 1
# External network.
# spx admin init contract (as of network-layer-redesign):
# external_mode : "pool" or "" (NAT path removed)
# external_pool : MANDATORY when mode=pool — set start/end below or pass -e.
# external_gw : auto-detected from default route when blank.
# external_prefix: auto-detected from WAN when blank.
# external_gw_ip : OVN router external-IP override (was nat_gateway_ip).
# no_external : true → emit --no-external (skip external networking).
spinifex_external_mode: pool
spinifex_external_no_external: false
spinifex_external_pool_start: ""
spinifex_external_pool_end: ""
spinifex_external_gateway: ""
spinifex_external_prefix_len: ""
spinifex_nat_gateway_ip: ""
spinifex_external_gateway_ip: ""
# Preflight checks.
spinifex_ports:
@@ -80,7 +88,7 @@ mulga_root: "{{ lookup('pipe', 'readlink -f ' ~ playbook_dir ~ '/../../../..') }
# `spinifex` supplementary group active. Needed because ansible's
# `become_user` is a no-op when target user equals current user, so
# supplementary groups are never re-initialised and /etc/spinifex/ca.pem
# (drwxr-x--- root:spinifex) is unreadable. See mulga-siv-92.
# (drwxr-x--- root:spinifex) is unreadable.
spinifex_aws_cli: "{{ playbook_dir }}/../scripts/aws-as-spinifex.sh"
spinifex_spx_cli: "{{ playbook_dir }}/../scripts/spx-as-spinifex.sh"
+3 -2
View File
@@ -170,9 +170,10 @@ if [ -e /etc/systemd/network/15-spinifex-veth-wan.netdev ] || \
sudo networkctl reload 2>/dev/null || true
fi
# Remove macvlan interfaces created by setup-ovn.sh
# Legacy: clean up macvlan interfaces left over from pre-veth setup-ovn.sh
# runs. Safe to remove once no operator system retains these.
for iface in $(ip -o link show type macvlan 2>/dev/null | awk -F': ' '{print $2}' | grep '^spx-ext-'); do
echo " Deleting macvlan: $iface"
echo " Deleting legacy macvlan: $iface"
sudo ip link del "$iface" 2>/dev/null || true
done
+80 -105
View File
@@ -16,9 +16,7 @@
# Options:
# --management Also start OVN central services (NB DB, SB DB, ovn-northd)
# --wan-bridge=NAME OVS bridge for WAN traffic (default: auto-detect from default route)
# --wan-iface=NAME Physical NIC to add to the WAN bridge (use with --wan-bridge or --macvlan)
# --macvlan Create macvlan off --wan-iface instead of moving NIC directly.
# SSH-safe for single-NIC hosts where WAN NIC carries SSH.
# --wan-iface=NAME Physical NIC to add to the WAN bridge (use with --wan-bridge)
# --dhcp Obtain gateway IP via DHCP on the WAN bridge interface
# --mgmt-bridge=NAME OVS bridge for system-instance control plane (default: br-mgmt)
# --mgmt-cidr=CIDR IPv4 CIDR to assign on the mgmt bridge (default: 10.15.8.1/24)
@@ -41,9 +39,6 @@
# # Dedicated WAN NIC (not your SSH NIC — you take responsibility):
# ./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
#
# # Single-NIC host (SSH-safe macvlan):
# ./scripts/setup-ovn.sh --management --macvlan --wan-iface=eth0
#
# # Compute node joining an existing cluster:
# ./scripts/setup-ovn.sh --ovn-remote=tcp:10.0.0.1:6642 --encap-ip=10.0.0.2
#
@@ -56,7 +51,6 @@ set -e
MANAGEMENT=false
WAN_BRIDGE=""
WAN_IFACE=""
MACVLAN_MODE=false
EXTERNAL_DHCP=false
MGMT_BRIDGE_ENABLED=true
MGMT_BRIDGE="br-mgmt"
@@ -64,12 +58,19 @@ MGMT_CIDR="10.15.8.1/24"
MGMT_IFACE=""
OVN_REMOTE="tcp:127.0.0.1:6642"
ENCAP_IP=""
# NODE_NAME is left empty by default. The chassis-id pin block at Step 4
# only runs when --node-name=NAME is explicitly given. Passing nothing
# preserves whatever system-id already lives in OVS (gold-image UUID,
# bootstrap-install.sh's pre-written nodeN, manual ansible value, etc.).
# Bootstrap callers that want IPsec-cert-identity matching pin the
# system-id themselves before / after invoking setup-ovn.sh — they
# don't need this script to do it for them.
NODE_NAME=""
# Parse arguments
for arg in "$@"; do
case "$arg" in
--management) MANAGEMENT=true ;;
--macvlan) MACVLAN_MODE=true ;;
--dhcp) EXTERNAL_DHCP=true ;;
--wan-bridge=*) WAN_BRIDGE="${arg#*=}" ;;
--wan-iface=*) WAN_IFACE="${arg#*=}" ;;
@@ -79,6 +80,7 @@ for arg in "$@"; do
--no-mgmt-bridge) MGMT_BRIDGE_ENABLED=false ;;
--ovn-remote=*) OVN_REMOTE="${arg#*=}" ;;
--encap-ip=*) ENCAP_IP="${arg#*=}" ;;
--node-name=*) NODE_NAME="${arg#*=}" ;;
--help|-h)
head -50 "$0" | tail -48
exit 0
@@ -92,15 +94,13 @@ done
# --- WAN bridge auto-detection ---
# Determine the WAN bridge name and how to set it up.
WAN_BRIDGE_MODE="" # "existing", "veth", "direct", "macvlan", or ""
WAN_BRIDGE_MODE="" # "existing", "veth", "direct", or ""
LINUX_BRIDGE="" # Set when WAN_BRIDGE_MODE="veth" — the Linux bridge behind the veth pair
detect_wan_bridge() {
# If --wan-bridge was explicitly given, use it
if [ -n "$WAN_BRIDGE" ]; then
if [ "$MACVLAN_MODE" = true ] && [ -n "$WAN_IFACE" ]; then
WAN_BRIDGE_MODE="macvlan"
elif [ -n "$WAN_IFACE" ]; then
if [ -n "$WAN_IFACE" ]; then
WAN_BRIDGE_MODE="direct"
elif sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then
WAN_BRIDGE_MODE="existing"
@@ -111,17 +111,6 @@ detect_wan_bridge() {
return
fi
# If --macvlan was given without --wan-bridge, we need --wan-iface
if [ "$MACVLAN_MODE" = true ]; then
if [ -z "$WAN_IFACE" ]; then
echo "ERROR: --macvlan requires --wan-iface=<NIC>"
exit 1
fi
WAN_BRIDGE="br-wan"
WAN_BRIDGE_MODE="macvlan"
return
fi
# Auto-detect: find the default route interface
local default_dev
default_dev=$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -1)
@@ -176,10 +165,7 @@ detect_wan_bridge() {
echo " 2. Dedicated WAN NIC (NOT your SSH connection):"
echo " ./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=$default_dev"
echo ""
echo " 3. Single-NIC host (SSH-safe macvlan):"
echo " ./scripts/setup-ovn.sh --management --macvlan --wan-iface=$default_dev"
echo ""
echo " 4. No external networking (overlay-only):"
echo " 3. No external networking (overlay-only):"
echo " ./scripts/setup-ovn.sh --management --encap-ip=$wan_ip"
echo "============================================================"
echo ""
@@ -224,32 +210,31 @@ echo " OVN Remote (SB): $OVN_REMOTE"
echo " Encap IP: $ENCAP_IP"
echo ""
# --- Step 1: Install packages ---
echo "Step 1: Checking OVN/OVS packages..."
# Packages (openvswitch-switch, ovn-host, openvswitch-ipsec, strongswan-charon,
# and ovn-central on management nodes) are baked into the gold image via
# scripts/tofu-cluster/image-builder/scripts/provision.sh. Runtime apt-get
# was removed to keep CI off the (flaky) apt-cacher-ng path. If a package is
# missing, the downstream ovs/ovn commands will fail loudly — the fix is to
# rebuild the gold image, not to re-add apt-get here.
install_packages() {
local missing=()
for pkg in openvswitch-switch ovn-host; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
missing+=("$pkg")
fi
done
if [ "$MANAGEMENT" = true ]; then
if ! dpkg -s ovn-central >/dev/null 2>&1; then
missing+=("ovn-central")
fi
fi
if [ ${#missing[@]} -gt 0 ]; then
echo " Installing: ${missing[*]}"
sudo apt-get update -qq
sudo apt-get install -y -qq "${missing[@]}"
else
echo " All packages installed"
fi
}
install_packages
# strongswan-charon ships an AppArmor profile for /usr/lib/ipsec/charon that
# only allows reading from /etc/ipsec.*, /etc/strongswan.*, and a few other
# fixed paths. ovs-monitor-ipsec writes the per-tunnel strongSwan config with
# absolute paths to our peer cert + key under /etc/spinifex/ipsec/ AND to the
# cluster CA at /etc/spinifex/ca.pem, so charon hits "Permission denied" on
# both load paths and surfaces as `no trusted RSA public key found for
# '<peer>'` (CA can't be loaded → can't validate peer cert chain → no SAs).
# The profile includes a 'local' override file expressly for site additions
# — grant reads on /etc/spinifex/** (covers ca.pem AND ipsec/peer.{pem,key})
# and reload.
if [ -d /etc/apparmor.d/local ]; then
LOCAL_OVERRIDE=/etc/apparmor.d/local/usr.lib.ipsec.charon
# Always rewrite — re-runs may have an older, narrower rule (e.g. the
# initial /etc/spinifex/ipsec/** only) that misses /etc/spinifex/ca.pem.
echo " Adding AppArmor read grant for /etc/spinifex (cert + CA) to charon profile"
echo "/etc/spinifex/** r," | sudo tee "$LOCAL_OVERRIDE" >/dev/null
sudo apparmor_parser -r /etc/apparmor.d/usr.lib.ipsec.charon 2>/dev/null || true
fi
# --- Step 2: Enable services ---
echo ""
@@ -296,7 +281,6 @@ echo " br-int: created, fail-mode=secure, up"
# rule => default management => no carrier => link down). OVS dataplane
# still forwards, but ovn-controller flow programming and any tooling that
# probes link state misbehave. Unmanaged=yes keeps OVS in sole control.
# (mulga-siv-37)
OVS_INTERNAL_NET=/etc/systemd/network/05-spinifex-ovs-internal.network
if [ ! -f "$OVS_INTERNAL_NET" ]; then
sudo tee "$OVS_INTERNAL_NET" >/dev/null <<'NETWORK'
@@ -477,36 +461,6 @@ NETWORK
echo " $WAN_BRIDGE: direct bridge on $WAN_IFACE"
echo " NOTE: $WAN_IFACE is now an OVS port — no host IP on this NIC"
;;
macvlan)
# Create a macvlan sub-interface in bridge mode off the WAN NIC.
# The host keeps its IP on the parent NIC — SSH-safe. OVN localnet
# traffic flows through the macvlan to the physical wire.
if ! ip link show "$WAN_IFACE" >/dev/null 2>&1; then
echo " ERROR: interface $WAN_IFACE does not exist"
echo " Available interfaces:"
ip -o link show | awk -F': ' '{print " " $2}'
exit 1
fi
MACVLAN_NAME="spx-ext-${WAN_IFACE}"
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
sudo ip link set "$WAN_BRIDGE" up
if ip link show "$MACVLAN_NAME" >/dev/null 2>&1; then
echo " macvlan $MACVLAN_NAME already exists"
else
sudo ip link add "$MACVLAN_NAME" link "$WAN_IFACE" type macvlan mode bridge
echo " created macvlan: $MACVLAN_NAME (bridge mode) on $WAN_IFACE"
fi
sudo ip link set "$MACVLAN_NAME" up
sudo ovs-vsctl --may-exist add-port "$WAN_BRIDGE" "$MACVLAN_NAME"
echo " $WAN_BRIDGE: macvlan port $MACVLAN_NAME on $WAN_IFACE"
echo " NOTE: host keeps its IP on $WAN_IFACE (SSH-safe)"
echo " QUIRK: host cannot reach VMs at their public IPs (macvlan isolation)"
;;
esac
# --- DHCP: obtain gateway IP for OVN SNAT ---
@@ -514,13 +468,8 @@ NETWORK
echo ""
echo "Step 3c: Obtaining external gateway IP via DHCP..."
# For macvlan mode, DHCP on the macvlan interface (it has L2 access to WAN).
# For direct/existing bridge, DHCP on the bridge itself.
if [ "$WAN_BRIDGE_MODE" = "macvlan" ]; then
DHCP_IFACE="spx-ext-${WAN_IFACE}"
else
DHCP_IFACE="$WAN_BRIDGE"
fi
# DHCP on the WAN bridge itself (direct/existing/veth).
DHCP_IFACE="$WAN_BRIDGE"
# Run DHCP client to get a lease
if command -v dhcpcd >/dev/null 2>&1; then
@@ -629,11 +578,33 @@ else
external_ids:ovn-encap-type="geneve"
fi
# system-id is owned by the openvswitch-switch package (persisted in
# /etc/openvswitch/system-id.conf and re-applied on every boot). Read it back
# rather than overriding — overriding here would drift from the on-disk value
# and the next reboot would silently flip the chassis identity (mulga-999).
echo " system-id: $(sudo ovs-vsctl get open . external_ids:system-id)"
# Pin OVS system-id (= OVN chassis-id) to NODE_NAME when --node-name was
# explicitly given. Reasons to pin:
# 1. IPsec identity: ovs-monitor-ipsec uses chassis-id as the IKEv2
# `@<name>` peer identity. Our per-node IPsec peer cert
# (admin.GenerateIPSecPeerCert) carries the cluster node name as CN
# + dnsName SAN — see spx admin init/join, which take --node NAME and
# bake NAME into the cert. Leaving chassis-id as the package-generated
# UUID would cause `received AUTHENTICATION_FAILED` because charon
# validates `@<UUID>` against a cert dnsName=<NODE_NAME>.
# 2. Ops legibility: `ovn-sbctl show` lists chassis by name, not UUID.
#
# When --node-name is NOT given (CI bootstrap-install.sh today, dev
# single-node bring-up, ansible roles that don't yet pass it), leave the
# system-id alone. A hostname fallback used to live here, but on CI
# single-node it rewrote system-id from the gold-image UUID to a
# different hostname while the existing SBDB chassis row kept name=UUID;
# vpcd's discoverChassis then logged "skipping stale local chassis" and
# refused to start. Preserving the on-disk value is always safe — any
# caller that needs IPsec cert identity matching pins it themselves.
if [ -n "$NODE_NAME" ]; then
echo "$NODE_NAME" | sudo tee /etc/openvswitch/system-id.conf >/dev/null
sudo ovs-vsctl set Open_vSwitch . external_ids:system-id="$NODE_NAME"
echo " system-id: $NODE_NAME (pinned via --node-name)"
else
CURRENT_ID=$(sudo ovs-vsctl get Open_vSwitch . external_ids:system-id 2>/dev/null | tr -d '"')
echo " system-id: ${CURRENT_ID:-<unset>} (preserved; no --node-name given)"
fi
echo " ovn-remote: $OVN_REMOTE"
echo " ovn-encap-ip: $ENCAP_IP"
echo " ovn-encap-type: geneve"
@@ -779,8 +750,20 @@ echo ""
echo "Step 10: Enabling OVN auto-start on boot..."
sudo systemctl enable openvswitch-switch 2>/dev/null || true
sudo systemctl enable ovn-controller 2>/dev/null || true
echo " openvswitch-switch: enabled on boot"
echo " ovn-controller: enabled on boot"
# ovs-monitor-ipsec drives strongSwan from OVS DB cert pointers. The daemon's
# enableOVNIPSec() flips ipsec_encapsulation=true at runtime and silently drops
# tunnel traffic if this unit isn't already up — enable at provision time so
# daemon never needs systemd-write capability (only is-active read).
sudo systemctl enable openvswitch-ipsec.service 2>/dev/null || true
# When openvswitch-ipsec is installed before strongswan-starter, the deb
# post-install starts the service against a missing /usr/sbin/ipsec and
# leaves it in 'failed' state. Clear the failure and restart unconditionally
# now that strongswan-starter is present.
sudo systemctl reset-failed openvswitch-ipsec.service 2>/dev/null || true
sudo systemctl restart openvswitch-ipsec.service 2>/dev/null || true
echo " openvswitch-switch: enabled on boot"
echo " ovn-controller: enabled on boot"
echo " openvswitch-ipsec: enabled on boot"
# --- Step 11: Health check ---
echo ""
@@ -815,14 +798,6 @@ if [ -n "$WAN_BRIDGE" ]; then
echo " direct bridge: FAILED ($WAN_IFACE not on $WAN_BRIDGE)"
OK=false
fi
elif [ "$WAN_BRIDGE_MODE" = "macvlan" ]; then
MACVLAN_NAME="spx-ext-${WAN_IFACE}"
if ip link show "$MACVLAN_NAME" >/dev/null 2>&1; then
echo " macvlan: OK ($MACVLAN_NAME)"
else
echo " macvlan: FAILED ($MACVLAN_NAME not found)"
OK=false
fi
fi
else
echo " $WAN_BRIDGE:$(printf '%*s' $((15 - ${#WAN_BRIDGE})) '') FAILED"
+3 -1
View File
@@ -186,6 +186,8 @@ install_sudoers() {
spinifex-daemon ALL=(root) NOPASSWD: /sbin/ip, /usr/sbin/ip
spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
spinifex-daemon ALL=(root) NOPASSWD: /usr/sbin/dhcpcd
spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/systemctl is-active openvswitch-ipsec.service
spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/ovn-nbctl set NB_Global . ipsec=true
# Spinifex VPC daemon: OVN and OVS read/write, OVN controller status check and DHCP
spinifex-vpcd ALL=(root) NOPASSWD: /usr/sbin/dhcpcd
@@ -218,7 +220,7 @@ install_apt_deps() {
libvirt-daemon-system libvirt-clients \
pciutils \
jq curl iproute2 netcat-openbsd wget unzip xz-utils file \
ovn-central ovn-host openvswitch-switch dhcpcd-base \
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base \
> /dev/null
info "System dependencies installed"
+9 -6
View File
@@ -72,14 +72,12 @@ type ConfigSettings struct {
// External networking for public subnets
ExternalMode string // "pool" or "" (disabled)
ExternalIface string // WAN NIC name (e.g., "eth0", "eth1")
DhcpBindBridge string // Bridge where the DHCP AF_PACKET socket binds (Linux bridge in veth mode, OVS bridge in direct mode; never "br-ext")
ExternalDHCP bool // Obtain gateway IP via DHCP on macvlan/bridge
PoolName string // External pool name (e.g., "wan")
PoolSource string // IP source: "static" (default) or "dhcp" (from router DHCP)
PoolStart string // First IP in external pool range (static source only)
PoolEnd string // Last IP in external pool range (static source only)
PoolSource string // IP source: "static" (default; only valid value)
PoolStart string // First IP in external pool range
PoolEnd string // Last IP in external pool range
PoolGateway string // WAN gateway IP
PoolGatewayIP string // Explicit SNAT IP (for nat mode without DHCP)
PoolGatewayIP string // Explicit SNAT IP (overrides default of first IP in range)
PoolPrefixLen int // Subnet prefix length (default 24)
PoolDNSServers []string // DNS servers for VM DHCP (auto-detected from host)
@@ -104,6 +102,11 @@ type ConfigSettings struct {
// GPUPassthrough enables VFIO GPU passthrough in the daemon config.
// Sets gpu_passthrough = true under [nodes.<node>.daemon].
GPUPassthrough bool
// IPSecEnabled toggles cluster-wide OVN native IPsec on intra-AZ Geneve
// (default true). Surfaced under [network] in spinifex.toml so the value
// is visible/auditable; the daemon reads it via cluster config.
IPSecEnabled bool
}
// PredastoreNodeConfig describes a single Predastore node for multi-node config generation.
+235
View File
@@ -0,0 +1,235 @@
package admin
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// IPSec peer-cert layout under configDir. ovs-monitor-ipsec consumes these
// paths via Open_vSwitch.other_config (certificate, private_key, ca_cert)
// set by the daemon at startup when network.ipsec_enabled is true.
const (
ipsecDirName = "ipsec"
ipsecCertFileName = "peer.pem"
ipsecKeyFileName = "peer.key"
ipsecCACertSymlink = "ca.pem"
)
// id-kp-ipsecIKE per RFC 4945 §5.1.3.12. strongSwan accepts certs carrying
// this EKU as IKEv2 peer credentials; ovs-monitor-ipsec's auto-generated
// strongSwan config consumes them as-is.
var oidExtKeyUsageIPSecIKE = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 17}
// IPSecCertPaths returns the canonical paths for the per-node IPsec peer
// credential pair under configDir. Both paths live in
// {configDir}/ipsec/. The CA trust anchor is the existing cluster CA in
// configDir/ca.pem; ovs-monitor-ipsec is pointed at that path directly.
func IPSecCertPaths(configDir string) (certPath, keyPath string) {
dir := filepath.Join(configDir, ipsecDirName)
return filepath.Join(dir, ipsecCertFileName), filepath.Join(dir, ipsecKeyFileName)
}
// GenerateIPSecPeerCert issues an IKEv2 peer certificate for this node,
// signed by the existing cluster CA. The cert carries the IPSec-IKE
// extended key usage so strongSwan accepts it as a peer credential.
//
// hostname is the node's primary hostname (used as Common Name + DNS SAN).
// nodeIP is the node's primary cluster IP (added as an IP SAN). Additional
// SANs discovered from the local machine's interfaces are not added here —
// IPSec peer identity should be the explicit cluster identity only, not
// every interface address, to keep peer-identity matching strict.
//
// Cross-cutting invariant: this dnsName SAN must equal the OVS chassis-id
// (Open_vSwitch.external_ids:system-id) on every node, because
// ovs-monitor-ipsec generates strongSwan configs with leftid=@<chassis-id>
// / rightid=@<chassis-id> and charon validates those names against the
// peer cert's dnsName SAN. setup-ovn.sh pins system-id to `hostname -s`
// for exactly this reason — see the comment in setup-ovn.sh near
// `system-id.conf`. Changing the cert identity here without updating
// setup-ovn.sh (or vice versa) breaks IKE with AUTHENTICATION_FAILED.
func GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, hostname, nodeIP string) error {
if hostname == "" {
return fmt.Errorf("ipsec peer cert: hostname required")
}
if nodeIP == "" {
return fmt.Errorf("ipsec peer cert: nodeIP required")
}
parsedIP := net.ParseIP(nodeIP)
if parsedIP == nil {
return fmt.Errorf("ipsec peer cert: invalid nodeIP %q", nodeIP)
}
caCert, caKey, err := loadCAKeyPair(caCertPath, caKeyPath)
if err != nil {
return err
}
peerKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("ipsec peer cert: generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("ipsec peer cert: serial: %w", err)
}
notBefore := time.Now()
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: hostname,
Organization: []string{"Spinifex Platform"},
},
NotBefore: notBefore,
NotAfter: notBefore.Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
UnknownExtKeyUsage: []asn1.ObjectIdentifier{oidExtKeyUsageIPSecIKE},
BasicConstraintsValid: true,
DNSNames: []string{hostname},
IPAddresses: []net.IP{parsedIP},
}
der, err := x509.CreateCertificate(rand.Reader, &template, caCert, &peerKey.PublicKey, caKey)
if err != nil {
return fmt.Errorf("ipsec peer cert: create certificate: %w", err)
}
dir := filepath.Join(configDir, ipsecDirName)
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("ipsec peer cert: mkdir %s: %w", dir, err)
}
certPath, keyPath := IPSecCertPaths(configDir)
if err := writePEMFile(certPath, "CERTIFICATE", der, 0644); err != nil {
return fmt.Errorf("ipsec peer cert: write cert: %w", err)
}
keyBytes, err := x509.MarshalPKCS8PrivateKey(peerKey)
if err != nil {
return fmt.Errorf("ipsec peer cert: marshal key: %w", err)
}
if err := writePEMFile(keyPath, "PRIVATE KEY", keyBytes, 0600); err != nil {
return fmt.Errorf("ipsec peer cert: write key: %w", err)
}
// Symlink the cluster CA into charon's default cacerts scan dir.
// ovs-monitor-ipsec writes per-conn `cacert=` references, but
// strongSwan 6.0 only loads CAs declared in standalone `ca` sections
// or files placed in /etc/ipsec.d/cacerts at startup. Without this,
// charon authenticates the local side OK but rejects every inbound
// peer cert as `no trusted RSA public key found for '<peer>'` and
// no SAs negotiate. The dir is on charon's default AppArmor allowlist
// so no profile change is needed (unlike /etc/spinifex/).
if err := installCAIntoCharonTrustStore(caCertPath); err != nil {
return fmt.Errorf("ipsec peer cert: install CA into charon trust store: %w", err)
}
return nil
}
// charonCATrustDir is the strongSwan default cacerts scan dir. Overridden
// in tests so they don't pollute the real /etc/ipsec.d/cacerts.
var charonCATrustDir = "/etc/ipsec.d/cacerts"
const charonCATrustLink = "spinifex-ca.pem"
func installCAIntoCharonTrustStore(caCertPath string) error {
if err := os.MkdirAll(charonCATrustDir, 0750); err != nil {
return fmt.Errorf("mkdir %s: %w", charonCATrustDir, err)
}
link := filepath.Join(charonCATrustDir, charonCATrustLink)
tmp, err := os.MkdirTemp(charonCATrustDir, ".cainstall-")
if err != nil {
return fmt.Errorf("tmpdir: %w", err)
}
defer os.RemoveAll(tmp)
staging := filepath.Join(tmp, charonCATrustLink)
if err := os.Symlink(caCertPath, staging); err != nil {
return fmt.Errorf("symlink %s -> %s: %w", staging, caCertPath, err)
}
if err := os.Rename(staging, link); err != nil {
return fmt.Errorf("rename %s -> %s: %w", staging, link, err)
}
// strongSwan scans /etc/ipsec.d/cacerts only at charon startup. If
// openvswitch-ipsec.service started before this install (the common
// case — the unit is enabled at provision time), charon is running
// with zero CAs loaded and every IKE_AUTH ends in `no trusted RSA
// public key found for '<peer>'`. Trigger a stroke rereadcacerts so
// the symlink we just placed is loaded without bouncing charon.
// Silent failure is expected when charon isn't running yet (admin
// init can run before setup-ovn.sh enables the unit); a later charon
// start will scan cacerts naturally.
if err := charonRereadCAs(); err != nil {
slog.Debug("ipsec: rereadcacerts skipped", "err", err)
}
return nil
}
// charonRereadCAs triggers strongSwan to re-scan /etc/ipsec.d/cacerts so a
// freshly-symlinked CA is picked up by an already-running charon. Overridden
// in tests to avoid execing ipsec.
var charonRereadCAs = func() error {
out, err := exec.Command("/usr/sbin/ipsec", "rereadcacerts").CombinedOutput()
if err != nil {
return fmt.Errorf("ipsec rereadcacerts: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func loadCAKeyPair(caCertPath, caKeyPath string) (*x509.Certificate, *rsa.PrivateKey, error) {
certPEM, err := os.ReadFile(caCertPath)
if err != nil {
return nil, nil, fmt.Errorf("read CA cert %s: %w", caCertPath, err)
}
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, nil, fmt.Errorf("decode CA cert PEM at %s", caCertPath)
}
caCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("parse CA cert: %w", err)
}
keyPEM, err := os.ReadFile(caKeyPath)
if err != nil {
return nil, nil, fmt.Errorf("read CA key %s: %w", caKeyPath, err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return nil, nil, fmt.Errorf("decode CA key PEM at %s", caKeyPath)
}
caKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("parse CA key: %w", err)
}
rsaKey, ok := caKey.(*rsa.PrivateKey)
if !ok {
return nil, nil, fmt.Errorf("CA key is not RSA")
}
return caCert, rsaKey, nil
}
func writePEMFile(path, blockType string, der []byte, mode os.FileMode) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
defer f.Close()
return pem.Encode(f, &pem.Block{Type: blockType, Bytes: der})
}
+189
View File
@@ -0,0 +1,189 @@
package admin
import (
"crypto/x509"
"encoding/pem"
"net"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGenerateIPSecPeerCert shares a CA across subtests so the 4096-bit
// CA-key generation runs once (~0.7s).
func TestGenerateIPSecPeerCert(t *testing.T) {
// Redirect the charon trust-store symlink at a temp dir so the test
// doesn't try to write to /etc/ipsec.d/cacerts on the host. Restored
// in cleanup. Cannot use t.Parallel() with this var override.
origTrustDir := charonCATrustDir
charonCATrustDir = t.TempDir()
t.Cleanup(func() { charonCATrustDir = origTrustDir })
// Stub the ipsec rereadcacerts shell-out so tests don't depend on
// strongSwan being installed/running on the test host.
origReread := charonRereadCAs
charonRereadCAs = func() error { return nil }
t.Cleanup(func() { charonRereadCAs = origReread })
caDir := t.TempDir()
caCertPath := filepath.Join(caDir, "ca.pem")
caKeyPath := filepath.Join(caDir, "ca.key")
require.NoError(t, GenerateCACert(caCertPath, caKeyPath))
t.Run("happy path", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-1", "10.0.0.5"))
certPath, keyPath := IPSecCertPaths(dir)
certPEM, err := os.ReadFile(certPath)
require.NoError(t, err)
block, _ := pem.Decode(certPEM)
require.NotNil(t, block)
cert, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err)
assert.Equal(t, "node-1", cert.Subject.CommonName)
assert.Equal(t, []string{"node-1"}, cert.DNSNames)
require.Len(t, cert.IPAddresses, 1)
assert.True(t, cert.IPAddresses[0].Equal(net.ParseIP("10.0.0.5")))
// IPSec EKU must be present so strongSwan accepts the cert as an IKEv2 peer.
var hasIPSecIKE bool
for _, oid := range cert.UnknownExtKeyUsage {
if oid.Equal(oidExtKeyUsageIPSecIKE) {
hasIPSecIKE = true
break
}
}
assert.True(t, hasIPSecIKE, "cert missing id-kp-ipsecIKE EKU")
caCertPEM, _ := os.ReadFile(caCertPath)
caBlock, _ := pem.Decode(caCertPEM)
caCert, _ := x509.ParseCertificate(caBlock.Bytes)
pool := x509.NewCertPool()
pool.AddCert(caCert)
_, err = cert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}})
assert.NoError(t, err, "cert must verify against cluster CA")
info, err := os.Stat(keyPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "key must be 0600")
})
t.Run("idempotent overwrite", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-2", "10.0.0.6"))
require.NoError(t, GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-2", "10.0.0.7"))
certPath, _ := IPSecCertPaths(dir)
certPEM, _ := os.ReadFile(certPath)
block, _ := pem.Decode(certPEM)
cert, _ := x509.ParseCertificate(block.Bytes)
assert.True(t, cert.IPAddresses[0].Equal(net.ParseIP("10.0.0.7")), "second call should rotate the SAN IP")
})
t.Run("rejects missing hostname", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "", "10.0.0.5")
require.Error(t, err)
assert.Contains(t, err.Error(), "hostname required")
})
t.Run("rejects missing nodeIP", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-1", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "nodeIP required")
})
t.Run("rejects invalid nodeIP", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-1", "not-an-ip")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid nodeIP")
})
t.Run("missing CA file", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := GenerateIPSecPeerCert(dir, filepath.Join(dir, "ghost-ca.pem"), caKeyPath, "node-1", "10.0.0.5")
require.Error(t, err)
assert.Contains(t, err.Error(), "read CA cert")
})
}
func TestIPSecCertPaths(t *testing.T) {
t.Parallel()
certPath, keyPath := IPSecCertPaths("/etc/spinifex")
assert.Equal(t, "/etc/spinifex/ipsec/peer.pem", certPath)
assert.Equal(t, "/etc/spinifex/ipsec/peer.key", keyPath)
}
// TestInstallCAIntoCharonTrustStore_TriggersRereadCAs locks the fix for the
// race where openvswitch-ipsec started before admin init's CA install: every
// IKE_AUTH failed with `no trusted RSA public key found for '<peer>'` because
// charon had scanned cacerts before the symlink was placed. The trust-store
// installer must trigger `ipsec rereadcacerts` so an already-running charon
// picks up the CA without a restart.
func TestInstallCAIntoCharonTrustStore_TriggersRereadCAs(t *testing.T) {
origTrustDir := charonCATrustDir
charonCATrustDir = t.TempDir()
t.Cleanup(func() { charonCATrustDir = origTrustDir })
origReread := charonRereadCAs
var called int
charonRereadCAs = func() error {
called++
return nil
}
t.Cleanup(func() { charonRereadCAs = origReread })
caDir := t.TempDir()
caCertPath := filepath.Join(caDir, "ca.pem")
require.NoError(t, os.WriteFile(caCertPath, []byte("dummy"), 0644))
require.NoError(t, installCAIntoCharonTrustStore(caCertPath))
assert.Equal(t, 1, called, "rereadcacerts must be invoked exactly once after symlink placement")
link := filepath.Join(charonCATrustDir, charonCATrustLink)
target, err := os.Readlink(link)
require.NoError(t, err)
assert.Equal(t, caCertPath, target, "symlink target must match CA cert path")
}
// TestInstallCAIntoCharonTrustStore_SwallowsRereadError verifies that a failed
// rereadcacerts (e.g. charon not running, ipsec binary missing on a fresh
// install) does not break admin init. The CA symlink must still be in place so
// a later charon start picks it up via the normal cacerts scan.
func TestInstallCAIntoCharonTrustStore_SwallowsRereadError(t *testing.T) {
origTrustDir := charonCATrustDir
charonCATrustDir = t.TempDir()
t.Cleanup(func() { charonCATrustDir = origTrustDir })
origReread := charonRereadCAs
charonRereadCAs = func() error { return stubError("charon not running") }
t.Cleanup(func() { charonRereadCAs = origReread })
caDir := t.TempDir()
caCertPath := filepath.Join(caDir, "ca.pem")
require.NoError(t, os.WriteFile(caCertPath, []byte("dummy"), 0644))
require.NoError(t, installCAIntoCharonTrustStore(caCertPath),
"reread failure must not propagate; admin init has to succeed even when charon is offline")
link := filepath.Join(charonCATrustDir, charonCATrustLink)
_, err := os.Readlink(link)
require.NoError(t, err, "symlink must persist even when reread fails")
}
type stubError string
func (e stubError) Error() string { return string(e) }
+75 -13
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"log/slog"
"net/netip"
"os"
"slices"
"strings"
@@ -22,7 +23,7 @@ type ClusterConfig struct {
// ExternalPool defines a range of routable IPs that Spinifex manages for public subnets.
type ExternalPool struct {
Name string `mapstructure:"name"` // Pool identifier (e.g., "wan", "dc1-primary")
Source string `mapstructure:"source"` // IP source: "static" (default) or "dhcp" (from router DHCP)
Source string `mapstructure:"source"` // IP source: "static" (default; only valid value)
RangeStart string `mapstructure:"range_start"` // First IP in range (static source only)
RangeEnd string `mapstructure:"range_end"` // Last IP in range (static source only)
Gateway string `mapstructure:"gateway"` // WAN default gateway (next hop for 0.0.0.0/0)
@@ -32,11 +33,11 @@ type ExternalPool struct {
Region string `mapstructure:"region"` // Scope to region (optional — empty means any region)
AZ string `mapstructure:"az"` // Scope to AZ (optional — empty means any AZ in region)
// GwLrpRangeStart/End reserve a sub-range of the LAN for OVN gateway LRP IPs
// in centralized NAT mode (veth/macvlan). Each VPC consumes one IP from this
// in centralized NAT mode (veth). Each VPC consumes one IP from this
// range so its gateway router port can ARP with a sender IP on the LAN
// subnet — link-local 169.254.0.1/30 makes upstream routers reject ARP per
// RFC 826 (mulga-siv-36). Must NOT overlap [RangeStart, RangeEnd] or IPAM
// and vpcd will fight over the same IP.
// RFC 826. Must NOT overlap [RangeStart, RangeEnd] or IPAM and vpcd will
// fight over the same IP.
GwLrpRangeStart string `mapstructure:"gw_lrp_range_start"`
GwLrpRangeEnd string `mapstructure:"gw_lrp_range_end"`
}
@@ -44,8 +45,11 @@ type ExternalPool struct {
// NetworkConfig holds cluster-wide external network settings.
type NetworkConfig struct {
ExternalMode string `mapstructure:"external_mode"` // "pool" or "" (disabled)
ExternalDHCP bool `mapstructure:"external_dhcp"` // Gateway IP obtained via DHCP (pool/dhcp source)
ExternalPools []ExternalPool `mapstructure:"external_pools"` // One or more IP pools
// IPSecEnabled toggles OVN native IPsec on every node for intra-AZ Geneve
// (AES-256-GCM via strongSwan). Default true. Operators can flip off for
// trusted single-rack lab topologies; production edge stays on.
IPSecEnabled bool `mapstructure:"ipsec_enabled"`
}
// BootstrapConfig holds the default VPC infrastructure IDs written by admin init.
@@ -103,14 +107,7 @@ type VPCDConfig struct {
OVNNBAddr string `json:"OVNNBAddr" mapstructure:"ovn_nb_addr"` // OVN Northbound DB address (e.g., "tcp:127.0.0.1:6641")
OVNSBAddr string `json:"OVNSBAddr" mapstructure:"ovn_sb_addr"` // OVN Southbound DB address (e.g., "tcp:127.0.0.1:6642")
ExternalInterface string `json:"ExternalInterface" mapstructure:"external_interface"` // WAN NIC name (e.g., "eth1", "enp0s3") — the physical NIC on the WAN bridge
// DhcpBindBridge is the bridge where the DHCP client binds its AF_PACKET
// socket — the interface that physically sees LAN DHCP traffic. On hosts
// where the WAN NIC is enslaved to a Linux bridge (netplan default), this
// is the Linux bridge name (e.g. "br-wan"). On direct-OVS hosts, it is
// the OVS bridge holding the WAN NIC. Never set to the OVN-side bridge
// ("br-ext") — that never sees LAN DHCP traffic.
DhcpBindBridge string `json:"DhcpBindBridge" mapstructure:"dhcp_bind_bridge"`
BridgeMode string `json:"BridgeMode" mapstructure:"bridge_mode"` // "direct" or "veth" (auto-detected if empty)
BridgeMode string `json:"BridgeMode" mapstructure:"bridge_mode"` // "direct" or "veth" (auto-detected if empty)
}
type PredastoreConfig struct {
@@ -212,6 +209,10 @@ func LoadConfig(configPath string) (*ClusterConfig, error) {
viper.SetEnvPrefix("SPINIFEX")
viper.AutomaticEnv()
// network.ipsec_enabled defaults to true (intra-AZ Geneve encrypted via OVN
// native IPsec). Operators must explicitly set false to disable.
viper.SetDefault("network.ipsec_enabled", true)
// Try to load config file if it exists
if configPath != "" {
// Check if file exists
@@ -244,5 +245,66 @@ func LoadConfig(configPath string) (*ClusterConfig, error) {
}
}
if err := validateClusterConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// validateClusterConfig rejects legacy upstream-DHCP config and validates
// external pool ranges. The upstream-DHCP-client model was replaced by static
// WAN-pool allocation; legacy keys must be removed from the operator's TOML.
func validateClusterConfig(cc *ClusterConfig) error {
if viper.IsSet("network.external_dhcp") {
return fmt.Errorf("config: [network] external_dhcp is no longer supported; remove the key (static WAN-pool allocation only)")
}
for nodeName := range cc.Nodes {
if viper.IsSet("nodes." + nodeName + ".vpcd.dhcp_bind_bridge") {
return fmt.Errorf("config: [nodes.%s.vpcd] dhcp_bind_bridge is no longer supported; remove the key (vpcd no longer runs a DHCP client)", nodeName)
}
}
type poolRange struct {
name string
start netip.Addr
end netip.Addr
}
var ranges []poolRange
for _, p := range cc.Network.ExternalPools {
if p.Source != "" && p.Source != "static" {
return fmt.Errorf("config: [[network.external_pools]] %q: source=%q is no longer supported; use source=\"static\" with explicit range_start/range_end", p.Name, p.Source)
}
if p.RangeStart == "" || p.RangeEnd == "" {
continue
}
start, err := netip.ParseAddr(p.RangeStart)
if err != nil {
return fmt.Errorf("config: pool %q range_start %q: %w", p.Name, p.RangeStart, err)
}
end, err := netip.ParseAddr(p.RangeEnd)
if err != nil {
return fmt.Errorf("config: pool %q range_end %q: %w", p.Name, p.RangeEnd, err)
}
if start.Compare(end) > 0 {
return fmt.Errorf("config: pool %q range_start %s > range_end %s", p.Name, start, end)
}
if p.Gateway != "" && p.PrefixLen > 0 {
gw, err := netip.ParseAddr(p.Gateway)
if err != nil {
return fmt.Errorf("config: pool %q gateway %q: %w", p.Name, p.Gateway, err)
}
cidr := netip.PrefixFrom(gw, p.PrefixLen).Masked()
if !cidr.Contains(start) || !cidr.Contains(end) {
return fmt.Errorf("config: pool %q range [%s, %s] not inside %s", p.Name, start, end, cidr)
}
}
for _, prior := range ranges {
if start.Compare(prior.end) <= 0 && prior.start.Compare(end) <= 0 {
return fmt.Errorf("config: pool %q range [%s, %s] overlaps pool %q [%s, %s]", p.Name, start, end, prior.name, prior.start, prior.end)
}
}
ranges = append(ranges, poolRange{name: p.Name, start: start, end: end})
}
return nil
}
+153 -6
View File
@@ -375,7 +375,49 @@ external_interface = "enp0s3"
assert.Equal(t, "enp0s3", n.VPCD.ExternalInterface)
}
func TestLoadConfig_NetworkPoolDHCPMode(t *testing.T) {
func TestLoadConfig_NetworkIPSecEnabledDefaultsTrue(t *testing.T) {
resetViper(t)
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
// No [network] block at all — IPSec must default to true so AWS-parity
// edge deployments encrypt intra-AZ Geneve without operator opt-in.
toml := `
node = "n1"
[nodes.n1]
region = "us-east-1"
`
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
cfg, err := LoadConfig(path)
require.NoError(t, err)
assert.True(t, cfg.Network.IPSecEnabled, "default")
}
func TestLoadConfig_NetworkIPSecEnabledExplicitFalse(t *testing.T) {
resetViper(t)
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
// Operator escape hatch for trusted single-rack lab deployments.
toml := `
node = "n1"
[network]
ipsec_enabled = false
[nodes.n1]
region = "us-east-1"
`
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
cfg, err := LoadConfig(path)
require.NoError(t, err)
assert.False(t, cfg.Network.IPSecEnabled)
}
func TestLoadConfig_NetworkPoolDHCPSourceRejected(t *testing.T) {
resetViper(t)
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
@@ -399,12 +441,117 @@ region = "us-east-1"
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
cfg, err := LoadConfig(path)
require.NoError(t, err)
require.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "source=")
assert.Contains(t, err.Error(), "dhcp")
}
assert.Equal(t, "pool", cfg.Network.ExternalMode)
require.Len(t, cfg.Network.ExternalPools, 1)
assert.Equal(t, "dhcp", cfg.Network.ExternalPools[0].Source)
assert.Equal(t, "192.168.1.100", cfg.Network.ExternalPools[0].GatewayIP)
func TestLoadConfig_ExternalDHCPRejected(t *testing.T) {
resetViper(t)
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
toml := `
node = "n1"
[network]
external_mode = "pool"
external_dhcp = true
[nodes.n1]
region = "us-east-1"
`
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
cfg, err := LoadConfig(path)
require.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "external_dhcp")
}
func TestLoadConfig_DhcpBindBridgeRejected(t *testing.T) {
resetViper(t)
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
toml := `
node = "n1"
[nodes.n1.vpcd]
dhcp_bind_bridge = "br-wan"
`
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
cfg, err := LoadConfig(path)
require.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
}
func TestLoadConfig_PoolRangeOutsideCIDR(t *testing.T) {
resetViper(t)
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
toml := `
node = "n1"
[network]
external_mode = "pool"
[[network.external_pools]]
name = "wan"
range_start = "10.99.0.10"
range_end = "10.99.0.50"
gateway = "192.168.1.1"
prefix_len = 24
[nodes.n1]
region = "us-east-1"
`
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
cfg, err := LoadConfig(path)
require.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "not inside")
}
func TestLoadConfig_PoolsOverlap(t *testing.T) {
resetViper(t)
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
toml := `
node = "n1"
[network]
external_mode = "pool"
[[network.external_pools]]
name = "a"
range_start = "192.168.1.10"
range_end = "192.168.1.50"
gateway = "192.168.1.1"
prefix_len = 24
[[network.external_pools]]
name = "b"
range_start = "192.168.1.40"
range_end = "192.168.1.80"
gateway = "192.168.1.1"
prefix_len = 24
[nodes.n1]
region = "us-east-1"
`
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
cfg, err := LoadConfig(path)
require.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "overlaps")
}
func TestLoadConfig_NetworkDisabledByDefault(t *testing.T) {
+25 -18
View File
@@ -49,6 +49,7 @@ import (
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/network/host"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -343,7 +344,7 @@ func (d *Daemon) onNATSReconnect(_ *nats.Conn) {
}
// execCommand wraps exec.Command so tests can substitute a fake builder.
// Mirrors the sudoCommand seam in network.go — getSystemMemory shells out to
// Mirrors the utils.SudoCommand seam — getSystemMemory shells out to
// sysctl (darwin) or grep /proc/meminfo (linux), neither of which can be
// faked without an indirection that the test can swap.
var execCommand = exec.Command
@@ -890,7 +891,7 @@ func (d *Daemon) startLocal() error {
if d.config.Daemon.MgmtBridge != "" {
mgmtBridge = d.config.Daemon.MgmtBridge
}
bridgeIP, bridgeErr := GetBridgeIPv4(mgmtBridge)
bridgeIP, bridgeErr := host.GetBridgeIPv4(mgmtBridge)
if bridgeErr != nil {
slog.Warn("Management bridge not detected, system instances will not get mgmt NIC", "bridge", mgmtBridge, "err", bridgeErr)
} else if bridgeIP == "" {
@@ -908,7 +909,7 @@ func (d *Daemon) startLocal() error {
// Initialise OVS network plumber (no NATS dep).
if d.networkPlumber == nil {
d.networkPlumber = &OVSNetworkPlumber{}
d.networkPlumber = host.NewOVSPlumber()
}
// Protect daemon from OOM killer (prefer killing QEMU VMs instead).
@@ -1019,6 +1020,24 @@ func (d *Daemon) startCluster() error {
return fmt.Errorf("initialize JetStream: %w", err)
}
// Tombstone the spinifex-dhcp-leases bucket left over from the upstream-DHCP
// client removed in Phase 2.3. Idempotent — first daemon start on an upgraded
// cluster sweeps the bucket; later restarts are no-ops.
if js, jsErr := d.natsConn.JetStream(); jsErr == nil {
if err := utils.DeleteKVBucketIfExists(js, "spinifex-dhcp-leases"); err != nil {
slog.Warn("Failed to delete obsolete spinifex-dhcp-leases KV bucket", "err", err)
}
}
// Enable OVN native IPsec on intra-AZ Geneve when the cluster flag is set.
// Idempotent — ovs-monitor-ipsec materialises strongSwan configs from the
// cert pointers each time ovn-controller programs a tunnel.
if d.clusterConfig != nil && d.clusterConfig.Network.IPSecEnabled {
if err := host.EnableOVNIPSec(d.configPath, d.clusterConfig); err != nil {
slog.Warn("Failed to enable OVN native IPsec; intra-AZ Geneve will be plaintext", "err", err)
}
}
// Write service manifest so other nodes know what this node runs
if d.jsManager != nil {
if err := d.jsManager.WriteServiceManifest(
@@ -1103,19 +1122,9 @@ func (d *Daemon) startCluster() error {
slog.Warn("Failed to get JetStream for external IPAM", "err", jsErr)
} else {
var pools []handlers_ec2_vpc.ExternalPoolConfig
// Resolve DHCP bind bridge name for DHCP pools (where AF_PACKET binds).
dhcpBindBridge := ""
if node, ok := d.clusterConfig.Nodes[d.clusterConfig.Node]; ok {
dhcpBindBridge = node.VPCD.DhcpBindBridge
}
gwMAC := ""
if d.clusterConfig.Bootstrap.VpcId != "" {
gwMAC = utils.HashMAC("gw-" + d.clusterConfig.Bootstrap.VpcId)
}
for _, p := range d.clusterConfig.Network.ExternalPools {
pools = append(pools, handlers_ec2_vpc.ExternalPoolConfig{
Name: p.Name,
Source: p.Source,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Gateway: p.Gateway,
@@ -1123,13 +1132,11 @@ func (d *Daemon) startCluster() error {
PrefixLen: p.PrefixLen,
Region: p.Region,
AZ: p.AZ,
DhcpBindBridge: dhcpBindBridge,
GatewayMAC: gwMAC,
GwLrpRangeStart: p.GwLrpRangeStart,
GwLrpRangeEnd: p.GwLrpRangeEnd,
})
}
d.externalIPAM, err = handlers_ec2_vpc.NewExternalIPAM(d.natsConn, js, pools)
d.externalIPAM, err = handlers_ec2_vpc.NewExternalIPAM(js, pools)
if err != nil {
slog.Warn("Failed to initialize external IPAM", "err", err)
} else {
@@ -1563,7 +1570,7 @@ func (d *Daemon) ClusterManager() error {
}
// Check OVN networking readiness
ovnHealth := CheckOVNHealth()
ovnHealth := host.HealthStatus()
if ovnHealth.BrIntExists {
serviceHealth["br-int"] = "ok"
} else {
@@ -1792,7 +1799,7 @@ func (d *Daemon) setupShutdown() {
// Cancel context to stop heartbeat and other goroutines
d.cancel()
// DDIL Tier 1 (mulga-siv-58): SIGTERM alone never stops local VMs.
// DDIL Tier 1: SIGTERM alone never stops local VMs.
// `systemctl restart spinifex-daemon` must preserve every QEMU child so
// the new daemon can reattach via the persisted local state file
// (Scenario B). VMs are only stopped when coordinated cluster shutdown
+1 -1
View File
@@ -3548,7 +3548,7 @@ func TestDelegateHandlers_EIP(t *testing.T) {
js, err := nc.JetStream()
require.NoError(t, err)
ipam, err := handlers_ec2_vpc.NewExternalIPAM(nc, js, []handlers_ec2_vpc.ExternalPoolConfig{
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []handlers_ec2_vpc.ExternalPoolConfig{
{Name: "test-pool", RangeStart: "192.168.100.2", RangeEnd: "192.168.100.254", Gateway: "192.168.100.1", PrefixLen: 24},
})
require.NoError(t, err)
+5 -3
View File
@@ -10,7 +10,9 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/mulgadc/spinifex/spinifex/network/topology"
"github.com/mulgadc/spinifex/spinifex/tags"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
@@ -209,7 +211,7 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
region = d.config.Region
az = d.config.AZ
}
allocatedIP, poolName, allocErr := d.externalIPAM.AllocateIP(region, az, "auto_assign", "", instance.ENIId, instance.ID)
allocatedIP, poolName, allocErr := d.externalIPAM.AllocateIP(region, az, handlers_ec2_vpc.PurposeENIPublic, "", instance.ENIId, instance.ID)
if allocErr != nil {
slog.Error("LaunchSystemInstance: failed to allocate public IP for internet-facing ALB", "instanceId", instance.ID, "err", allocErr)
d.cleanupFailedSystemInstance(instance, instanceType)
@@ -226,7 +228,7 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
if descErr == nil && len(result.NetworkInterfaces) > 0 && result.NetworkInterfaces[0].VpcId != nil {
vpcID = *result.NetworkInterfaces[0].VpcId
}
portName := "port-" + instance.ENIId
portName := topology.Port(instance.ENIId)
if natErr := utils.AddNAT(d.natsConn, vpcID, publicIP, privateIP, portName, instance.ENIMac); natErr != nil {
slog.Error("LaunchSystemInstance: vpc.add-nat failed for ALB public IP — rolling back to avoid surfacing an unreachable address",
"instanceId", instance.ID, "publicIp", publicIP, "pool", poolName, "err", natErr)
@@ -290,7 +292,7 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
}
slog.Warn("LaunchSystemInstance: failed to allocate mgmt IP, skipping mgmt NIC", "instanceId", instance.ID, "err", allocErr)
} else {
instance.MgmtMAC = generateMgmtMAC(instance.ID)
instance.MgmtMAC = vm.GenerateMgmtMAC(instance.ID)
instance.MgmtIP = mgmtIP
// Inject the allocated MAC and CIDR into NIC[1] so writeFwCfgBlobs
@@ -485,7 +485,7 @@ func TestLaunchSystemInstance_NATFailureRollsBackPublicIP(t *testing.T) {
js, err := jsNC.JetStream()
require.NoError(t, err)
ipam, err := handlers_ec2_vpc.NewExternalIPAM(jsNC, js, []handlers_ec2_vpc.ExternalPoolConfig{
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []handlers_ec2_vpc.ExternalPoolConfig{
{Name: "wan-test", RangeStart: "203.0.113.10", RangeEnd: "203.0.113.20", Gateway: "203.0.113.1", PrefixLen: 24},
})
require.NoError(t, err)
-320
View File
@@ -1,320 +0,0 @@
package daemon
import (
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
)
// sudoCommand wraps exec.Command with sudo when running as non-root.
// OVS/OVN and ip commands require elevated privileges; in Docker and
// production the daemon runs as root, but in dev environments it may not.
//
// Bound to a var (not a plain func) so tests can swap in a stub — running
// the live binary against the dev host's OVS would mutate `external_ids` on
// the running cluster (see TestSetupComputeNode_ValidatesArgs).
var sudoCommand = func(name string, args ...string) *exec.Cmd {
if os.Getuid() == 0 {
return exec.Command(name, args...)
}
return exec.Command("sudo", append([]string{name}, args...)...)
}
// OVSNetworkPlumber implements vm.NetworkPlumber using system commands
// (ip, ovs-vsctl); tests use a mock satisfying the same interface.
type OVSNetworkPlumber struct{}
var _ vm.NetworkPlumber = (*OVSNetworkPlumber)(nil)
// SetupTap creates the kernel tap, brings it up, and attaches it to spec.Bridge.
// The pre-create del-port is unconditional because OVS conf.db survives reboot
// while kernel taps don't, so a /sys/class/net check would miss orphan ports.
// Rejected alternative: `--may-exist add-port` — it would silently keep stale
// external_ids from a prior launch with a different ENI/MAC.
func (p *OVSNetworkPlumber) SetupTap(spec vm.TapSpec) error {
if err := sudoCommand("ovs-vsctl", "--if-exists", "del-port", spec.Bridge, spec.Name).Run(); err != nil {
slog.Warn("Pre-create del-port failed (continuing)", "tap", spec.Name, "bridge", spec.Bridge, "err", err)
}
if _, err := os.Stat("/sys/class/net/" + spec.Name); err == nil {
if err := sudoCommand("ip", "tuntap", "del", "dev", spec.Name, "mode", "tap").Run(); err != nil {
slog.Warn("Pre-create tap del failed (continuing)", "tap", spec.Name, "err", err)
}
}
// When the daemon runs non-root (production: User=spinifex-daemon), child
// QEMU processes inherit that uid without CAP_NET_ADMIN. The kernel's tun
// attach check then requires the tap to be owned by the calling euid
// (drivers/net/tun.c: tun_chr_open + TUNSETIFF perm check). Without the
// `user` flag the tap defaults to root:root and qemu's TUNSETIFF fails
// with EPERM ("could not configure /dev/net/tun: Operation not permitted").
// Pass numeric uid/gid directly — user.Current() can fail silently under
// hardened systemd units / static builds, dropping the flags and leaving
// the tap root-owned (LB microvm in cell-17/tofu-examples). Numeric values
// always work and skip the NSS lookup. Root daemons skip this — they have
// CAP_NET_ADMIN ambient.
addArgs := []string{"tuntap", "add", "dev", spec.Name, "mode", "tap"}
if uid := os.Geteuid(); uid != 0 {
addArgs = append(addArgs, "user", strconv.Itoa(uid), "group", strconv.Itoa(os.Getegid()))
}
if out, err := sudoCommand("ip", addArgs...).CombinedOutput(); err != nil {
return fmt.Errorf("create tap %s: %s: %w", spec.Name, strings.TrimSpace(string(out)), err)
}
if out, err := sudoCommand("ip", "link", "set", spec.Name, "up").CombinedOutput(); err != nil {
if cleanErr := sudoCommand("ip", "tuntap", "del", "dev", spec.Name, "mode", "tap").Run(); cleanErr != nil {
slog.Warn("Failed to clean up tap after bring-up failure", "tap", spec.Name, "err", cleanErr)
}
return fmt.Errorf("bring up tap %s: %s: %w", spec.Name, strings.TrimSpace(string(out)), err)
}
addPortArgs := []string{"add-port", spec.Bridge, spec.Name}
if len(spec.ExternalIDs) > 0 {
addPortArgs = append(addPortArgs, "--", "set", "Interface", spec.Name)
// Sort keys for deterministic command construction (test assertions, log readability).
keys := make([]string, 0, len(spec.ExternalIDs))
for k := range spec.ExternalIDs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
addPortArgs = append(addPortArgs, fmt.Sprintf("external_ids:%s=%s", k, spec.ExternalIDs[k]))
}
}
if out, err := sudoCommand("ovs-vsctl", addPortArgs...).CombinedOutput(); err != nil {
if cleanErr := sudoCommand("ip", "tuntap", "del", "dev", spec.Name, "mode", "tap").Run(); cleanErr != nil {
slog.Warn("Failed to clean up tap after OVS failure", "tap", spec.Name, "err", cleanErr)
}
return fmt.Errorf("add tap %s to %s: %s: %w", spec.Name, spec.Bridge, strings.TrimSpace(string(out)), err)
}
slog.Info("Tap plumbing complete", "tap", spec.Name, "bridge", spec.Bridge, "external_ids", spec.ExternalIDs)
return nil
}
// CleanupTap removes the named tap from OVS (any bridge) and the kernel.
// Idempotent: callers may invoke for an instance that never reached SetupTap
// (e.g. terminate that races mid-launch).
func (p *OVSNetworkPlumber) CleanupTap(name string) error {
if out, err := sudoCommand("ovs-vsctl", "--if-exists", "del-port", name).CombinedOutput(); err != nil {
slog.Warn("Failed to remove tap from OVS", "tap", name, "err", err, "out", strings.TrimSpace(string(out)))
}
if _, err := os.Stat("/sys/class/net/" + name); os.IsNotExist(err) {
slog.Info("Tap already absent", "tap", name)
return nil
}
if out, err := sudoCommand("ip", "tuntap", "del", "dev", name, "mode", "tap").CombinedOutput(); err != nil {
return fmt.Errorf("delete tap %s: %s: %w", name, strings.TrimSpace(string(out)), err)
}
slog.Info("Tap cleanup complete", "tap", name)
return nil
}
// generateMgmtMAC creates a locally-administered unicast MAC for the
// management NIC. The "mgmt:" tag disambiguates from the dev NIC of the
// same instance (which shares instanceId).
func generateMgmtMAC(instanceId string) string {
return utils.HashMAC("mgmt:" + instanceId)
}
// GetBridgeIPv4 returns the first IPv4 address on the named Linux bridge.
// Returns "", nil if the bridge does not exist.
func GetBridgeIPv4(bridgeName string) (string, error) {
iface, err := net.InterfaceByName(bridgeName)
if err != nil {
// "no such network interface" means the bridge doesn't exist yet — expected.
// Other errors (permission denied on /sys/class/net/, etc.) are real failures.
if strings.Contains(err.Error(), "no such network interface") {
return "", nil
}
return "", fmt.Errorf("lookup %s: %w", bridgeName, err)
}
addrs, err := iface.Addrs()
if err != nil {
return "", fmt.Errorf("list addrs on %s: %w", bridgeName, err)
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipNet.IP.To4() != nil {
return ipNet.IP.String(), nil
}
}
return "", fmt.Errorf("no IPv4 address on %s", bridgeName)
}
// OVNHealthStatus reports the readiness of OVN networking on this compute node.
type OVNHealthStatus struct {
BrIntExists bool `json:"br_int_exists"`
OVNControllerUp bool `json:"ovn_controller_up"`
ChassisID string `json:"chassis_id,omitempty"`
EncapIP string `json:"encap_ip,omitempty"`
OVNRemote string `json:"ovn_remote,omitempty"`
}
// CheckOVNHealth probes local OVS/OVN state to determine network readiness.
func CheckOVNHealth() OVNHealthStatus {
status := OVNHealthStatus{}
// Check br-int exists
if err := sudoCommand("ovs-vsctl", "br-exists", "br-int").Run(); err == nil {
status.BrIntExists = true
}
// Check ovn-controller is running via ovs-appctl. Trixie OVN moved the
// pid file to /var/run/ovn/; older installs use /var/run/openvswitch/.
// Probe at runtime so the same binary works on both layouts.
ovnTarget := "ovn-controller"
if _, err := os.Stat("/var/run/ovn/ovn-controller.pid"); err == nil {
ovnTarget = "/var/run/ovn/ovn-controller"
}
if out, err := sudoCommand("ovs-appctl", "-t", ovnTarget, "version").CombinedOutput(); err == nil && len(out) > 0 {
status.OVNControllerUp = true
}
// Read chassis identity from OVS external_ids
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:system-id").CombinedOutput(); err == nil {
status.ChassisID = strings.Trim(strings.TrimSpace(string(out)), "\"")
}
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:ovn-encap-ip").CombinedOutput(); err == nil {
status.EncapIP = strings.Trim(strings.TrimSpace(string(out)), "\"")
}
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:ovn-remote").CombinedOutput(); err == nil {
status.OVNRemote = strings.Trim(strings.TrimSpace(string(out)), "\"")
}
return status
}
// SetupComputeNode configures OVS for OVN on this compute node.
// It creates br-int with secure fail-mode and sets the OVN external_ids.
func SetupComputeNode(chassisID, ovnRemote, encapIP string) error {
// Create br-int if it doesn't exist
if out, err := sudoCommand("ovs-vsctl", "--may-exist", "add-br", "br-int").CombinedOutput(); err != nil {
return fmt.Errorf("create br-int: %s: %w", strings.TrimSpace(string(out)), err)
}
// Set fail-mode=secure (preserves flows during ovn-controller restart)
if out, err := sudoCommand("ovs-vsctl", "set", "Bridge", "br-int", "fail-mode=secure").CombinedOutput(); err != nil {
return fmt.Errorf("set br-int fail-mode: %s: %w", strings.TrimSpace(string(out)), err)
}
// Disable in-band management (prevents OVS from adding its own flows)
if out, err := sudoCommand("ovs-vsctl", "set", "Bridge", "br-int", "other-config:disable-in-band=true").CombinedOutput(); err != nil {
return fmt.Errorf("set br-int disable-in-band: %s: %w", strings.TrimSpace(string(out)), err)
}
// Bring br-int up
if out, err := sudoCommand("ip", "link", "set", "br-int", "up").CombinedOutput(); err != nil {
return fmt.Errorf("bring up br-int: %s: %w", strings.TrimSpace(string(out)), err)
}
// Set OVN external_ids on the Open_vSwitch table
if out, err := sudoCommand("ovs-vsctl", "set", "Open_vSwitch", ".",
fmt.Sprintf("external_ids:system-id=%s", chassisID),
fmt.Sprintf("external_ids:ovn-remote=%s", ovnRemote),
fmt.Sprintf("external_ids:ovn-encap-ip=%s", encapIP),
"external_ids:ovn-encap-type=geneve",
).CombinedOutput(); err != nil {
return fmt.Errorf("set OVN external_ids: %s: %w", strings.TrimSpace(string(out)), err)
}
slog.Info("OVN compute node configured",
"chassis_id", chassisID,
"ovn_remote", ovnRemote,
"encap_ip", encapIP,
)
// Ensure the data NIC is preferred for Geneve tunnel routing.
if err := EnsureDataRoute(encapIP); err != nil {
slog.Warn("Failed to configure data NIC routing (Geneve tunnels may use wrong source IP)", "err", err)
}
return nil
}
// EnsureDataRoute ensures the kernel routes Geneve tunnel traffic through the
// data NIC (the interface holding the encap IP). When management and data NICs
// share the same subnet with equal route metrics, the kernel may pick the
// management NIC, causing Geneve packets to have the wrong source IP. Remote
// OVS nodes then drop these packets because the source doesn't match the
// expected tunnel remote_ip.
//
// Fix: find the data NIC's subnet route and replace it with a lower metric (50)
// so it's preferred over the management NIC's route (typically metric 100+).
func EnsureDataRoute(encapIP string) error {
dataIface, err := findInterfaceByIP(encapIP)
if err != nil {
return fmt.Errorf("find data interface for %s: %w", encapIP, err)
}
// Read the existing subnet route for the data interface
out, err := sudoCommand("ip", "-o", "-4", "route", "show", "dev", dataIface, "proto", "kernel", "scope", "link").CombinedOutput()
if err != nil {
return fmt.Errorf("read routes for %s: %s: %w", dataIface, strings.TrimSpace(string(out)), err)
}
// Parse the subnet CIDR from the route output (e.g. "10.1.0.0/16 dev eth1 ...")
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) == 0 {
return fmt.Errorf("no kernel route found for %s", dataIface)
}
subnet := fields[0]
// Replace the route with a lower metric so the data NIC is preferred
if out, err := sudoCommand("ip", "route", "replace", subnet,
"dev", dataIface, "src", encapIP, "metric", "50",
).CombinedOutput(); err != nil {
return fmt.Errorf("replace route for %s: %s: %w", subnet, strings.TrimSpace(string(out)), err)
}
slog.Info("Data NIC route configured for Geneve tunnels",
"interface", dataIface,
"subnet", subnet,
"encap_ip", encapIP,
"metric", 50,
)
return nil
}
// findInterfaceByIP returns the network interface name that holds the given IP address.
func findInterfaceByIP(ipAddr string) (string, error) {
targetIP := net.ParseIP(ipAddr)
if targetIP == nil {
return "", fmt.Errorf("invalid IP address: %s", ipAddr)
}
ifaces, err := net.Interfaces()
if err != nil {
return "", fmt.Errorf("list interfaces: %w", err)
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipNet.IP.Equal(targetIP) {
return iface.Name, nil
}
}
}
return "", fmt.Errorf("no interface found with IP %s", ipAddr)
}
-538
View File
@@ -1,538 +0,0 @@
package daemon
import (
"net"
"os"
"os/exec"
"slices"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/stretchr/testify/require"
)
func TestTapDeviceName(t *testing.T) {
tests := []struct {
eniId string
expected string
}{
{"eni-abc123", "tapabc123"}, // Short ID
{"eni-abc123def456789", "tapabc123def456"}, // Truncated to 15 chars
{"eni-a", "tapa"}, // Minimal
{"eni-123456789abcdef", "tap123456789abc"}, // Exactly 15 chars
{"eni-123456789abcdefg", "tap123456789abc"}, // Truncated at 15
}
for _, tt := range tests {
t.Run(tt.eniId, func(t *testing.T) {
got := vm.TapDeviceName(tt.eniId)
if got != tt.expected {
t.Errorf("vm.TapDeviceName(%q) = %q, want %q", tt.eniId, got, tt.expected)
}
if len(got) > 15 {
t.Errorf("vm.TapDeviceName(%q) = %q (len %d), exceeds IFNAMSIZ limit of 15", tt.eniId, got, len(got))
}
})
}
}
func TestSudoCommand_NonRoot(t *testing.T) {
// When not root, sudoCommand should prefix with sudo
cmd := sudoCommand("ovs-vsctl", "br-exists", "br-int")
args := cmd.Args
if os.Getuid() == 0 {
// Running as root: should NOT use sudo
if args[0] != "ovs-vsctl" {
t.Errorf("as root, expected args[0]='ovs-vsctl', got %q", args[0])
}
} else {
// Running as non-root: should use sudo
if args[0] != "sudo" {
t.Errorf("as non-root, expected args[0]='sudo', got %q", args[0])
}
if args[1] != "ovs-vsctl" {
t.Errorf("as non-root, expected args[1]='ovs-vsctl', got %q", args[1])
}
if len(args) != 4 {
t.Errorf("expected 4 args [sudo ovs-vsctl br-exists br-int], got %d: %v", len(args), args)
}
}
}
func TestGenerateDevMAC(t *testing.T) {
tests := []struct {
instanceId string
}{
{"i-abc123"},
{"i-def456"},
{"i-ghi789"},
}
// All MACs must be valid locally-administered unicast and unique. First
// octet is hash-derived (not the literal class prefix of the old impl).
seen := make(map[string]bool)
for _, tt := range tests {
mac := vm.GenerateDevMAC(tt.instanceId)
hw, err := net.ParseMAC(mac)
if err != nil {
t.Errorf("vm.GenerateDevMAC(%q) = %q: invalid MAC: %v", tt.instanceId, mac, err)
continue
}
if hw[0]&0x03 != 0x02 {
t.Errorf("vm.GenerateDevMAC(%q) = %q: expected unicast+LAA bits, got %#x",
tt.instanceId, mac, hw[0])
}
if seen[mac] {
t.Errorf("vm.GenerateDevMAC(%q) = %q, duplicate MAC", tt.instanceId, mac)
}
seen[mac] = true
}
// Class separation: dev and mgmt MACs for the same instance must differ.
if vm.GenerateDevMAC("i-abc123") == generateMgmtMAC("i-abc123") {
t.Error("expected dev and mgmt MACs for same instance to differ")
}
// Same input should produce same output (deterministic)
mac1 := vm.GenerateDevMAC("i-test123")
mac2 := vm.GenerateDevMAC("i-test123")
if mac1 != mac2 {
t.Errorf("generateDevMAC not deterministic: %q != %q", mac1, mac2)
}
}
func TestFindInterfaceByIP_InvalidIP(t *testing.T) {
_, err := findInterfaceByIP("not-an-ip")
if err == nil {
t.Fatal("expected error for invalid IP")
}
if !strings.Contains(err.Error(), "invalid IP address") {
t.Errorf("expected 'invalid IP address' error, got: %v", err)
}
}
func TestFindInterfaceByIP_Loopback(t *testing.T) {
// 127.0.0.1 should always be on the loopback interface
iface, err := findInterfaceByIP("127.0.0.1")
if err != nil {
t.Fatalf("findInterfaceByIP(127.0.0.1): %v", err)
}
if iface != "lo" {
t.Errorf("findInterfaceByIP(127.0.0.1) = %q, want 'lo'", iface)
}
}
func TestFindInterfaceByIP_NotFound(t *testing.T) {
_, err := findInterfaceByIP("192.0.2.1")
if err == nil {
t.Fatal("expected error for non-existent IP")
}
if !strings.Contains(err.Error(), "no interface found") {
t.Errorf("expected 'no interface found' error, got: %v", err)
}
}
// TestOVSNetworkPlumber_SetupTap_AddPortArgs captures the ovs-vsctl invocations
// for both VPC-style (populated ExternalIDs → `set Interface external_ids:k=v`)
// and management-style (empty ExternalIDs → bare `add-port`) calls. This is the
// functional difference that previously lived in two diverged setup functions.
func TestOVSNetworkPlumber_SetupTap_AddPortArgs(t *testing.T) {
cases := []struct {
name string
spec vm.TapSpec
wantAddPort []string // expected tail of ovs-vsctl args (after add-port <bridge> <name>)
}{
{
name: "vpc style with external_ids",
spec: vm.TapSpec{
Name: "tapeni-test",
Bridge: "br-int",
ExternalIDs: map[string]string{
"iface-id": "port-eni-test",
"attached-mac": "02:00:00:aa:bb:cc",
},
},
wantAddPort: []string{
"add-port", "br-int", "tapeni-test",
"--", "set", "Interface", "tapeni-test",
"external_ids:attached-mac=02:00:00:aa:bb:cc",
"external_ids:iface-id=port-eni-test",
},
},
{
name: "mgmt style without external_ids",
spec: vm.TapSpec{
Name: "mg-test",
Bridge: "br-mgmt",
},
wantAddPort: []string{"add-port", "br-mgmt", "mg-test"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
orig := sudoCommand
t.Cleanup(func() { sudoCommand = orig })
var calls [][]string
sudoCommand = func(name string, args ...string) *exec.Cmd {
call := append([]string{name}, args...)
calls = append(calls, call)
return exec.Command("/bin/true")
}
p := &OVSNetworkPlumber{}
if err := p.SetupTap(tc.spec); err != nil {
t.Fatalf("SetupTap: %v", err)
}
// Find the add-port invocation (last ovs-vsctl call).
var addPort []string
for _, c := range calls {
if len(c) >= 2 && c[0] == "ovs-vsctl" && c[1] != "--if-exists" {
addPort = c[1:]
}
}
if addPort == nil {
t.Fatalf("no add-port invocation captured; calls=%v", calls)
}
if !slices.Equal(addPort, tc.wantAddPort) {
t.Errorf("add-port args = %v, want %v", addPort, tc.wantAddPort)
}
})
}
}
// TestOVSNetworkPlumber_SetupTap_PreExistingKernelTap exercises the
// pre-create kernel-side cleanup branch (lines guarded by /sys/class/net/<name>
// stat). Uses "lo" as a name that's always present so os.Stat returns success.
func TestOVSNetworkPlumber_SetupTap_PreExistingKernelTap(t *testing.T) {
orig := sudoCommand
t.Cleanup(func() { sudoCommand = orig })
failTuntapDel := false
sudoCommand = func(name string, args ...string) *exec.Cmd {
if name == "ip" && len(args) >= 2 && args[0] == "tuntap" && args[1] == "del" && failTuntapDel {
failTuntapDel = false
return exec.Command("/bin/false")
}
return exec.Command("/bin/true")
}
failTuntapDel = true
p := &OVSNetworkPlumber{}
if err := p.SetupTap(vm.TapSpec{Name: "lo", Bridge: "br-int"}); err != nil {
t.Fatalf("SetupTap with stubs should succeed: %v", err)
}
}
// TestOVSNetworkPlumber_SetupTap_ErrorBranches stubs sudoCommand to fail at
// each step in turn and asserts the corresponding error is returned. Covers
// the cleanup-on-failure paths that the success-path tests miss.
func TestOVSNetworkPlumber_SetupTap_ErrorBranches(t *testing.T) {
cases := []struct {
name string
failMatch func(name string, args []string) bool
wantErr string
}{
{
name: "ovs del-port failure is logged-warn (continues)",
failMatch: func(name string, args []string) bool {
return name == "ovs-vsctl" && len(args) >= 1 && args[0] == "--if-exists"
},
wantErr: "", // pre-clear failure is swallowed; SetupTap returns nil
},
{
name: "tuntap add failure surfaces",
failMatch: func(name string, args []string) bool {
return name == "ip" && len(args) >= 2 && args[0] == "tuntap" && args[1] == "add"
},
wantErr: "create tap",
},
{
name: "ip link set up failure surfaces and triggers tap cleanup",
failMatch: func(name string, args []string) bool {
return name == "ip" && len(args) >= 1 && args[0] == "link"
},
wantErr: "bring up tap",
},
{
name: "ovs add-port failure surfaces and triggers tap cleanup",
failMatch: func(name string, args []string) bool {
return name == "ovs-vsctl" && len(args) >= 1 && args[0] == "add-port"
},
wantErr: "add tap",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
orig := sudoCommand
t.Cleanup(func() { sudoCommand = orig })
sudoCommand = func(name string, args ...string) *exec.Cmd {
if tc.failMatch(name, args) {
return exec.Command("/bin/false")
}
return exec.Command("/bin/true")
}
p := &OVSNetworkPlumber{}
err := p.SetupTap(vm.TapSpec{Name: "tap-test-noexist", Bridge: "br-int"})
if tc.wantErr == "" {
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
})
}
}
// TestOVSNetworkPlumber_CleanupTap_PresentKernelTap exercises the success
// path where the kernel device is present (uses "lo" so /sys/class/net/lo
// stat succeeds). Asserts no error and that ip tuntap del was attempted.
func TestOVSNetworkPlumber_CleanupTap_PresentKernelTap(t *testing.T) {
orig := sudoCommand
t.Cleanup(func() { sudoCommand = orig })
var sawTuntapDel bool
sudoCommand = func(name string, args ...string) *exec.Cmd {
if name == "ip" && len(args) >= 2 && args[0] == "tuntap" && args[1] == "del" {
sawTuntapDel = true
}
return exec.Command("/bin/true")
}
p := &OVSNetworkPlumber{}
if err := p.CleanupTap("lo"); err != nil {
t.Fatalf("CleanupTap: %v", err)
}
if !sawTuntapDel {
t.Errorf("expected ip tuntap del to be invoked, got no call")
}
}
// TestOVSNetworkPlumber_CleanupTap_DelPortLoggedWarn covers the OVS del-port
// failure branch (logged warn, continues to kernel-side cleanup).
func TestOVSNetworkPlumber_CleanupTap_DelPortLoggedWarn(t *testing.T) {
orig := sudoCommand
t.Cleanup(func() { sudoCommand = orig })
sudoCommand = func(name string, args ...string) *exec.Cmd {
if name == "ovs-vsctl" {
return exec.Command("/bin/false")
}
return exec.Command("/bin/true")
}
p := &OVSNetworkPlumber{}
// Tap doesn't exist on this test system → kernel-presence gate short-circuits
// after the OVS warn, so CleanupTap returns nil.
if err := p.CleanupTap("tap-test-noexist"); err != nil {
t.Fatalf("CleanupTap: %v", err)
}
}
// TestOVSNetworkPlumber_CleanupTap_TuntapDelFailure exercises the kernel-side
// failure branch (real kernel device present + ip tuntap del returns nonzero).
func TestOVSNetworkPlumber_CleanupTap_TuntapDelFailure(t *testing.T) {
orig := sudoCommand
t.Cleanup(func() { sudoCommand = orig })
sudoCommand = func(name string, args ...string) *exec.Cmd {
if name == "ip" && len(args) >= 2 && args[0] == "tuntap" && args[1] == "del" {
return exec.Command("/bin/false")
}
return exec.Command("/bin/true")
}
p := &OVSNetworkPlumber{}
err := p.CleanupTap("lo") // /sys/class/net/lo is present, so we reach tuntap del
if err == nil || !strings.Contains(err.Error(), "delete tap") {
t.Fatalf("expected 'delete tap' error, got %v", err)
}
}
// TestOVSNetworkPlumber_CleanupTap_MissingKernelTap verifies the nil-safe branch:
// callers may invoke CleanupTap on a name that has neither an OVS port nor a
// kernel tap (e.g. a terminate that races mid-launch, or an instance that
// never reached SetupTap) without producing a misleading "Device does not
// exist" error from `ip tuntap del`.
func TestOVSNetworkPlumber_CleanupTap_MissingKernelTap(t *testing.T) {
// 15-char IFNAMSIZ-compliant name that does not exist in /sys/class/net.
// The OVS del-port call may fail on CI without ovs-vsctl, but is
// best-effort + logged-warn; the kernel-presence gate short-circuits
// before `ip tuntap del` runs.
p := &OVSNetworkPlumber{}
if err := p.CleanupTap("mg-test-noexist"); err != nil {
t.Fatalf("expected nil for missing kernel tap, got: %v", err)
}
}
func TestEnsureDataRoute_NoKernelRoute(t *testing.T) {
// 127.0.0.1 resolves to "lo", which has no kernel/scope-link subnet route.
// EnsureDataRoute must surface this as an error rather than silently
// succeed against a non-existent route — a regression that drops the
// error would leave Geneve traffic egressing the wrong NIC.
err := EnsureDataRoute("127.0.0.1")
require.Error(t, err, "expected error for IP without a kernel subnet route")
require.ErrorContains(t, err, "no kernel route found",
"error must identify the missing-route condition, not a generic interface lookup failure")
}
func TestSetupComputeNode_ValidatesArgs(t *testing.T) {
// Stub sudoCommand so the test never shells out to the host's real
// ovs-vsctl. Without this stub, on a dev box with OVS installed the call
// silently mutated external_ids:system-id (and ovn-remote, ovn-encap-ip)
// on the live cluster, breaking vpcd's chassis discovery until reboot.
orig := sudoCommand
t.Cleanup(func() { sudoCommand = orig })
sudoCommand = func(string, ...string) *exec.Cmd {
return exec.Command("/bin/false")
}
if err := SetupComputeNode("chassis-test", "tcp:127.0.0.1:6642", "10.0.0.1"); err == nil {
t.Fatal("expected error from stubbed sudoCommand, got nil")
}
}
func TestGenerateDevMAC_Format(t *testing.T) {
tests := []string{
"i-abc123",
"i-def456",
"i-00000000",
"i-ffffffff",
"i-a",
"i-very-long-instance-id-with-many-characters",
}
for _, id := range tests {
mac := vm.GenerateDevMAC(id)
hw, err := net.ParseMAC(mac)
if err != nil {
t.Errorf("vm.GenerateDevMAC(%q) = %q: invalid MAC: %v", id, mac, err)
continue
}
if hw[0]&0x03 != 0x02 {
t.Errorf("vm.GenerateDevMAC(%q) = %q: expected unicast+LAA bits, got %#x",
id, mac, hw[0])
}
}
}
func TestTapDeviceName_EmptyInput(t *testing.T) {
// Even with empty string (no eni- prefix), should not panic
name := vm.TapDeviceName("")
if name != "tap" {
t.Errorf("vm.TapDeviceName('') = %q, want 'tap'", name)
}
}
func TestOVSIfaceID_Format(t *testing.T) {
tests := []struct {
eniId string
expected string
}{
{"eni-abc123", "port-eni-abc123"},
{"eni-abc123def456789", "port-eni-abc123def456789"},
{"eni-short", "port-eni-short"},
{"eni-", "port-eni-"},
{"", "port-"},
}
for _, tt := range tests {
got := vm.OVSIfaceID(tt.eniId)
if got != tt.expected {
t.Errorf("vm.OVSIfaceID(%q) = %q, want %q", tt.eniId, got, tt.expected)
}
}
}
func TestGenerateMgmtMAC(t *testing.T) {
tests := []string{
"i-abc123",
"i-def456",
"i-ghi789",
}
seen := make(map[string]bool)
for _, id := range tests {
mac := generateMgmtMAC(id)
hw, err := net.ParseMAC(mac)
if err != nil {
t.Errorf("generateMgmtMAC(%q) = %q: invalid MAC: %v", id, mac, err)
continue
}
if hw[0]&0x03 != 0x02 {
t.Errorf("generateMgmtMAC(%q) = %q: expected unicast+LAA bits, got %#x",
id, mac, hw[0])
}
if seen[mac] {
t.Errorf("generateMgmtMAC(%q) = %q, duplicate MAC", id, mac)
}
seen[mac] = true
}
// Deterministic
mac1 := generateMgmtMAC("i-test123")
mac2 := generateMgmtMAC("i-test123")
if mac1 != mac2 {
t.Errorf("generateMgmtMAC not deterministic: %q != %q", mac1, mac2)
}
// Different from dev MAC for same instance
devMAC := vm.GenerateDevMAC("i-test123")
mgmtMAC := generateMgmtMAC("i-test123")
if devMAC == mgmtMAC {
t.Errorf("dev and mgmt MACs should differ for same instance: both %q", devMAC)
}
}
func TestMgmtTapName(t *testing.T) {
tests := []struct {
instanceID string
expected string
}{
{"i-abc123", "mgabc123"},
{"i-abc123def456789", "mgabc123def4567"}, // Truncated to 15 chars
{"i-a", "mga"},
{"abc123", "mgabc123"}, // No i- prefix
}
for _, tt := range tests {
t.Run(tt.instanceID, func(t *testing.T) {
got := vm.MgmtTapName(tt.instanceID)
if got != tt.expected {
t.Errorf("vm.MgmtTapName(%q) = %q, want %q", tt.instanceID, got, tt.expected)
}
if len(got) > 15 {
t.Errorf("vm.MgmtTapName(%q) = %q (len %d), exceeds IFNAMSIZ limit of 15", tt.instanceID, got, len(got))
}
})
}
}
func TestGetBridgeIPv4_Loopback(t *testing.T) {
// "lo" is always present and has 127.0.0.1
ip, err := GetBridgeIPv4("lo")
if err != nil {
t.Fatalf("GetBridgeIPv4(lo): %v", err)
}
if ip != "127.0.0.1" {
t.Errorf("GetBridgeIPv4(lo) = %q, want 127.0.0.1", ip)
}
}
func TestGetBridgeIPv4_NonexistentBridge(t *testing.T) {
ip, err := GetBridgeIPv4("br-nonexistent-test-xyz")
if err != nil {
t.Fatalf("expected nil error for absent bridge, got: %v", err)
}
if ip != "" {
t.Errorf("expected empty IP for absent bridge, got %q", ip)
}
}
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
"github.com/mulgadc/spinifex/spinifex/network/topology"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
@@ -431,7 +432,7 @@ func (d *Daemon) onInstanceUpHook() func(*vm.VM) error {
privateIP = *instance.Instance.PrivateIpAddress
}
if vpcID != "" && privateIP != "" {
portName := "port-" + instance.ENIId
portName := topology.Port(instance.ENIId)
utils.PublishNATEvent(d.natsConn, "vpc.add-nat", vpcID, instance.PublicIP, privateIP, portName, instance.ENIMac)
}
}
@@ -586,7 +587,7 @@ func (a *instanceCleanerAdapter) ReleasePublicIP(instance *vm.VM) {
return
}
portName := "port-" + instance.ENIId
portName := topology.Port(instance.ENIId)
vpcId := ""
logicalIP := ""
if instance.Instance != nil {
+5 -1
View File
@@ -62,7 +62,6 @@ type StatusResponse struct {
// propagated from the init node to joining nodes during formation.
type NetworkConfig struct {
ExternalMode string `json:"external_mode"`
ExternalDHCP bool `json:"external_dhcp,omitempty"`
PoolName string `json:"pool_name"`
PoolSource string `json:"pool_source,omitempty"`
PoolStart string `json:"pool_start,omitempty"`
@@ -72,6 +71,11 @@ type NetworkConfig struct {
PoolPrefixLen int `json:"pool_prefix_len"`
PoolDNSServers []string `json:"pool_dns_servers,omitempty"`
// IPSecEnabled propagates the cluster-wide intra-AZ IPsec toggle so the
// joining node provisions strongSwan and flips OVS ipsec_encapsulation to
// match the rest of the cluster.
IPSecEnabled bool `json:"ipsec_enabled"`
BootstrapAccountId string `json:"bootstrap_account_id,omitempty"`
BootstrapVpcId string `json:"bootstrap_vpc_id,omitempty"`
BootstrapSubnetId string `json:"bootstrap_subnet_id,omitempty"`
+4 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/mulgadc/spinifex/spinifex/network/topology"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
@@ -73,7 +74,7 @@ func (s *EIPServiceImpl) AllocateAddress(input *ec2.AllocateAddressInput, accoun
if input.PublicIpv4Pool != nil && *input.PublicIpv4Pool != "" {
// Allocate from a specific named pool.
poolName = *input.PublicIpv4Pool
publicIP, err = s.externalIPAM.AllocateFromPool(poolName, "elastic_ip", allocID, "", "")
publicIP, err = s.externalIPAM.AllocateFromPool(poolName, handlers_ec2_vpc.PurposeEIP, allocID, "", "")
if err != nil {
slog.Error("AllocateAddress: IPAM pool allocation failed", "pool", poolName, "err", err)
return nil, errors.New(awserrors.ErrorInsufficientAddressCapacity)
@@ -82,7 +83,7 @@ func (s *EIPServiceImpl) AllocateAddress(input *ec2.AllocateAddressInput, accoun
// Allocate from the best pool matching region/AZ (empty strings = global fallback).
region := ""
az := ""
publicIP, poolName, err = s.externalIPAM.AllocateIP(region, az, "elastic_ip", allocID, "", "")
publicIP, poolName, err = s.externalIPAM.AllocateIP(region, az, handlers_ec2_vpc.PurposeEIP, allocID, "", "")
if err != nil {
slog.Error("AllocateAddress: IPAM allocation failed", "err", err)
return nil, errors.New(awserrors.ErrorInsufficientAddressCapacity)
@@ -579,7 +580,7 @@ func (s *EIPServiceImpl) publishNATEvent(topic, vpcID, externalIP, logicalIP, en
VpcId: vpcID,
ExternalIP: externalIP,
LogicalIP: logicalIP,
PortName: "port-" + eniID,
PortName: topology.Port(eniID),
MAC: mac,
})
}
@@ -30,7 +30,7 @@ func setupTestEIP(t *testing.T) (*EIPServiceImpl, *handlers_ec2_vpc.ExternalIPAM
_, nc, js := testutil.StartTestJetStream(t)
pool := testPool()
ipam, err := handlers_ec2_vpc.NewExternalIPAM(nc, js, []handlers_ec2_vpc.ExternalPoolConfig{pool})
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []handlers_ec2_vpc.ExternalPoolConfig{pool})
require.NoError(t, err)
svc, err := NewEIPServiceImpl(nc, ipam, nil)
@@ -562,7 +562,7 @@ func TestEIP_DescribeAddresses_FilterByTag(t *testing.T) {
// writes PortName into NAT.LogicalPort in distributed NAT mode (direct bridge),
// so a mismatch with the OVN logical switch port name ("port-<eni>") produces
// a dnat_and_snat row pointing at a nonexistent port — OVN never programs the
// flow and the EIP black-holes. See mulga-js-17.
// flow and the EIP black-holes.
func TestEIP_PublishNATEvent_PortNameHasPortPrefix(t *testing.T) {
svc, _ := setupTestEIP(t)
+1 -1
View File
@@ -32,7 +32,7 @@ const (
type IGWRecord struct {
InternetGatewayId string `json:"internet_gateway_id"`
VpcId string `json:"vpc_id,omitempty"` // empty when detached
State string `json:"state"` // "available", "attached", "detached"
State string `json:"state"` // "available" — AWS attachment.state is "available" when attached
Tags map[string]string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
+11 -6
View File
@@ -22,7 +22,9 @@ import (
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/network/topology"
"github.com/mulgadc/spinifex/spinifex/objectstore"
spxtypes "github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -698,17 +700,21 @@ func (s *InstanceServiceImpl) PrepareRunInstances(input *ec2.RunInstancesInput,
if s.ipAllocator != nil {
subnet, subErr := s.eniCreator.GetSubnet(accountID, *input.SubnetId)
if subErr == nil && subnet != nil && subnet.MapPublicIpOnLaunch {
wantPublic := subErr == nil && subnet != nil && subnet.MapPublicIpOnLaunch
if len(input.NetworkInterfaces) > 0 && input.NetworkInterfaces[0] != nil && input.NetworkInterfaces[0].AssociatePublicIpAddress != nil {
wantPublic = *input.NetworkInterfaces[0].AssociatePublicIpAddress
}
if wantPublic {
region := s.config.Region
az := s.config.AZ
publicIP, poolName, allocErr := s.ipAllocator.AllocateIP(region, az, "auto_assign", "", *eni.NetworkInterfaceId, instance.ID)
publicIP, poolName, allocErr := s.ipAllocator.AllocateIP(region, az, handlers_ec2_vpc.PurposeENIPublic, "", *eni.NetworkInterfaceId, instance.ID)
if allocErr != nil {
slog.Warn("PrepareRunInstances: failed to allocate public IP", "instanceId", instance.ID, "err", allocErr)
} else {
if updateErr := s.eniCreator.UpdateENIPublicIP(accountID, *eni.NetworkInterfaceId, publicIP, poolName); updateErr != nil {
slog.Warn("PrepareRunInstances: failed to update ENI with public IP", "eniId", *eni.NetworkInterfaceId, "err", updateErr)
}
portName := "port-" + *eni.NetworkInterfaceId
portName := topology.Port(*eni.NetworkInterfaceId)
if natErr := utils.AddNAT(s.natsConn, *eni.VpcId, publicIP, *eni.PrivateIpAddress, portName, *eni.MacAddress); natErr != nil {
slog.Error("PrepareRunInstances: vpc.add-nat failed — rolling back public IP to avoid surfacing an unreachable address",
"instanceId", instance.ID, "publicIp", publicIP, "pool", poolName, "err", natErr)
@@ -1365,8 +1371,7 @@ func (s *InstanceServiceImpl) newViperblock(volumeName string, size int, volumeC
// restoreSlogDefault re-installs the daemon's Info-level slog handler after
// viperblock.New mutates the global slog default via its SetDebug method
// (see viperblock.go SetDebug — it calls slog.SetDefault with LevelError,
// silencing every Info/Warn in the entire process). Tracked for proper fix
// in viperblock as mulga-siv-70.
// silencing every Info/Warn in the entire process).
func restoreSlogDefault() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
@@ -2602,7 +2607,7 @@ func (s *InstanceServiceImpl) releaseInstancePublicIP(instance *vm.VM, instanceI
if instance.PublicIP == "" || instance.PublicIPPool == "" || s.ipReleaser == nil {
return
}
portName := "port-" + instance.ENIId
portName := topology.Port(instance.ENIId)
vpcID := ""
logicalIP := ""
if instance.Instance != nil {
@@ -2536,40 +2536,68 @@ func TestPrepareRunInstances_DefaultSubnetResolved(t *testing.T) {
}
func TestPrepareRunInstances_PublicIPAutoAssigned(t *testing.T) {
eni := &fakeENICreator{
subnet: &SubnetInfo{SubnetID: "subnet-1", VpcID: "vpc-1", MapPublicIpOnLaunch: true},
createOut: &ec2.CreateNetworkInterfaceOutput{
NetworkInterface: &ec2.NetworkInterface{
NetworkInterfaceId: aws.String("eni-2"),
MacAddress: aws.String("aa:bb:cc:dd:ee:00"),
PrivateIpAddress: aws.String("10.0.0.20"),
VpcId: aws.String("vpc-1"),
},
},
cases := []struct {
name string
mapOnLaunch bool
nicOverride *bool
wantPublic bool
}{
{name: "subnet_true_no_override", mapOnLaunch: true, nicOverride: nil, wantPublic: true},
{name: "subnet_false_override_true", mapOnLaunch: false, nicOverride: aws.Bool(true), wantPublic: true},
{name: "subnet_true_override_false", mapOnLaunch: true, nicOverride: aws.Bool(false), wantPublic: false},
}
ipam := &fakeIPAllocator{publicIP: "203.0.113.5", poolName: "pool-a"}
svc, _ := prepareSvcWithENI(t, eni, ipam)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
eni := &fakeENICreator{
subnet: &SubnetInfo{SubnetID: "subnet-1", VpcID: "vpc-1", MapPublicIpOnLaunch: tc.mapOnLaunch},
createOut: &ec2.CreateNetworkInterfaceOutput{
NetworkInterface: &ec2.NetworkInterface{
NetworkInterfaceId: aws.String("eni-2"),
MacAddress: aws.String("aa:bb:cc:dd:ee:00"),
PrivateIpAddress: aws.String("10.0.0.20"),
VpcId: aws.String("vpc-1"),
},
},
}
ipam := &fakeIPAllocator{publicIP: "203.0.113.5", poolName: "pool-a"}
svc, _ := prepareSvcWithENI(t, eni, ipam)
// vpc.add-nat is now request-reply: the helper waits for vpcd to ack
// before the launch proceeds. The happy path needs a success responder.
sub, err := svc.natsConn.Subscribe("vpc.add-nat", func(msg *nats.Msg) {
_ = msg.Respond([]byte(`{"success":true}`))
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
// vpc.add-nat is now request-reply: the helper waits for vpcd to
// ack before the launch proceeds. The happy path needs a success
// responder.
sub, err := svc.natsConn.Subscribe("vpc.add-nat", func(msg *nats.Msg) {
_ = msg.Respond([]byte(`{"success":true}`))
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
_, instances, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
InstanceType: aws.String("t3.micro"),
ImageId: aws.String("ami-1"),
SubnetId: aws.String("subnet-1"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
}, "acc")
require.NoError(t, err)
require.Len(t, instances, 1)
assert.Equal(t, "203.0.113.5", instances[0].PublicIP)
assert.Equal(t, "pool-a", instances[0].PublicIPPool)
assert.Equal(t, 1, eni.updateCalls)
input := &ec2.RunInstancesInput{
InstanceType: aws.String("t3.micro"),
ImageId: aws.String("ami-1"),
SubnetId: aws.String("subnet-1"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
}
if tc.nicOverride != nil {
input.NetworkInterfaces = []*ec2.InstanceNetworkInterfaceSpecification{
{AssociatePublicIpAddress: tc.nicOverride},
}
}
_, instances, _, err := svc.PrepareRunInstances(input, "acc")
require.NoError(t, err)
require.Len(t, instances, 1)
if tc.wantPublic {
assert.Equal(t, "203.0.113.5", instances[0].PublicIP)
assert.Equal(t, "pool-a", instances[0].PublicIPPool)
assert.Equal(t, 1, eni.updateCalls)
} else {
assert.Empty(t, instances[0].PublicIP)
assert.Empty(t, instances[0].PublicIPPool)
assert.Equal(t, 0, eni.updateCalls)
}
})
}
}
// TestPrepareRunInstances_NATFailureRollsBackPublicIP regresses the silent
@@ -31,7 +31,7 @@ func setupTestService(t *testing.T) *RouteTableServiceImpl {
// Seed IGW KV (attached to vpc-test1)
testutil.SeedKV(t, js, handlers_ec2_igw.KVBucketIGW, map[string][]byte{
utils.AccountKey(testAccountID, "igw-test1"): []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"attached"}`),
utils.AccountKey(testAccountID, "igw-test1"): []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"available"}`),
})
// Seed subnet KV
+7 -6
View File
@@ -12,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/network/topology"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
@@ -78,21 +79,21 @@ func (s *VPCServiceImpl) CreateNetworkInterface(input *ec2.CreateNetworkInterfac
return nil, err
}
eniId := utils.GenerateResourceID("eni")
// Allocate IP from subnet
var privateIP string
if input.PrivateIpAddress != nil && *input.PrivateIpAddress != "" {
// TODO: validate the requested IP is in the subnet range and not already allocated
privateIP = *input.PrivateIpAddress
} else {
ip, err := s.ipam.AllocateIP(subnetId, subnet.CidrBlock)
ip, err := s.ipam.AllocateIP(subnetId, subnet.CidrBlock, PurposeENIPrimary, eniId)
if err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
privateIP = ip
}
eniId := utils.GenerateResourceID("eni")
// Generate a deterministic MAC address
macAddr := generateENIMac(eniId)
@@ -178,7 +179,7 @@ func (s *VPCServiceImpl) DeleteNetworkInterface(input *ec2.DeleteNetworkInterfac
} else if owned {
slog.Info("DeleteNetworkInterface: public IP owned by EIP, skipping release", "eniId", eniId, "publicIp", record.PublicIpAddress)
} else {
portName := "port-" + eniId
portName := topology.Port(eniId)
s.publishNATEvent("vpc.delete-nat", record.VpcId, record.PublicIpAddress, record.PrivateIpAddress, portName, record.MacAddress)
if err := s.externalIPAM.ReleaseIP(record.PublicIpPool, record.PublicIpAddress); err != nil {
slog.Warn("Failed to release public IP during ENI delete", "eni", eniId, "ip", record.PublicIpAddress, "pool", record.PublicIpPool, "err", err)
@@ -588,8 +589,8 @@ func generateENIMac(eniId string) string {
}
// portEventPayload is the wire shape for vpc.create-port / vpc.delete-port.
// Mirrors vpcd.PortEvent — duplicated here to avoid a vpcd → handlers import
// cycle.
// Mirrors network/subscribers.PortEvent — duplicated here to avoid a
// subscribers → handlers import cycle.
type portEventPayload struct {
NetworkInterfaceId string `json:"network_interface_id"`
SubnetId string `json:"subnet_id"`
+71 -236
View File
@@ -9,38 +9,29 @@ import (
"net"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/mulgadc/spinifex/spinifex/services/vpcd/dhcp"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
const (
KVBucketExternalIPAM = "spinifex-external-ipam"
KVBucketExternalIPAMVersion = 1
KVBucketExternalIPAMVersion = 2
)
// ExternalIPAllocation describes how an external IP is being used.
// ExternalIPAllocation describes how an external IP is being used. Purpose
// values come from the Purpose enum in purpose.go.
type ExternalIPAllocation struct {
Type string `json:"type"` // "gateway", "auto_assign", "elastic_ip"
Purpose string `json:"purpose"` // Purpose enum: igw-lrp, eni-public, eip, natgw-external
AllocationID string `json:"allocation_id,omitempty"` // For elastic IPs
Association string `json:"association,omitempty"` // ENI ID for elastic IPs
ENIId string `json:"eni_id,omitempty"` // ENI owning this IP
InstanceId string `json:"instance_id,omitempty"` // Instance owning this IP
Note string `json:"note,omitempty"` // Human-readable note
// DHCP-sourced metadata — set only for allocations whose pool has
// source="dhcp" (see dhcp.RequestAcquire). These fields let describe
// surfaces and operators trace a given IP back to the upstream lease
// without re-querying vpcd.
LeaseExpiresUnix int64 `json:"lease_expires_unix,omitempty"` // Absolute expiry from the server's ACK
DHCPServerID string `json:"dhcp_server_id,omitempty"` // Option 54 of the ACK
HWAddr string `json:"hw_addr,omitempty"` // chaddr placed in the DHCP payload
}
// ExternalIPAMRecord tracks allocated external IPs for a single pool.
type ExternalIPAMRecord struct {
PoolName string `json:"pool_name"`
Source string `json:"source"` // "static" (default) or "dhcp"
RangeStart string `json:"range_start"`
RangeEnd string `json:"range_end"`
Gateway string `json:"gateway"`
@@ -50,7 +41,7 @@ type ExternalIPAMRecord struct {
AZ string `json:"az,omitempty"`
// GwLrpRangeStart/End mirrors ExternalPoolConfig — IPAM skips this
// sub-range so it doesn't collide with vpcd's gateway LRP IPs in
// centralized NAT (mulga-siv-36).
// centralized NAT.
GwLrpRangeStart string `json:"gw_lrp_range_start,omitempty"`
GwLrpRangeEnd string `json:"gw_lrp_range_end,omitempty"`
Allocated map[string]ExternalIPAllocation `json:"allocated"`
@@ -58,49 +49,29 @@ type ExternalIPAMRecord struct {
// ExternalPoolConfig is the admin-defined pool from spinifex.toml.
type ExternalPoolConfig struct {
Name string
Source string // "static" (default) or "dhcp"
RangeStart string
RangeEnd string
Gateway string
GatewayIP string
PrefixLen int
Region string
AZ string
DhcpBindBridge string // Bridge where the DHCP AF_PACKET socket binds (e.g. "br-wan"). Linux bridge in veth mode; OVS bridge in direct mode. Never "br-ext".
// GatewayMAC, when set, is used as the chaddr in per-VM DHCP DORAs so
// that the upstream DHCP binding matches the MAC that OVN GARPs use (the
// gateway LRP MAC). Without this, the DHCP server binds the public IP to
// the generated ENI-derived MAC while OVN announces a different MAC via
// GARP, causing home routers that use their DHCP lease table as ARP to
// forward traffic to a non-existent interface.
GatewayMAC string
Name string
RangeStart string
RangeEnd string
Gateway string
GatewayIP string
PrefixLen int
Region string
AZ string
// GwLrpRangeStart/End reserves a sub-range of the LAN for OVN gateway
// LRP IPs in centralized NAT mode (mulga-siv-36). IPAM must skip these
// addresses or the per-VM EIP allocator and vpcd will fight over them.
// LRP IPs in centralized NAT mode. IPAM must skip these addresses or
// the per-VM EIP allocator and vpcd will fight over them.
GwLrpRangeStart string
GwLrpRangeEnd string
}
// IsDHCP returns true if this pool obtains IPs from router DHCP.
func (p *ExternalPoolConfig) IsDHCP() bool {
return p.Source == "dhcp"
}
// ExternalIPAM manages external IP allocation from admin-defined pools using NATS KV with CAS.
type ExternalIPAM struct {
kv nats.KeyValue
pools []ExternalPoolConfig
// nc is used to talk to spinifex-vpcd for DHCP-sourced pools via
// vpc.dhcp.acquire / vpc.dhcp.release. May be nil when no pool has
// Source="dhcp" (callers should still pass it if available).
nc *nats.Conn
}
// NewExternalIPAM creates a new ExternalIPAM backed by NATS JetStream KV.
// nc is required for DHCP-sourced pools and may be nil for static-only
// deployments.
func NewExternalIPAM(nc *nats.Conn, js nats.JetStreamContext, pools []ExternalPoolConfig) (*ExternalIPAM, error) {
func NewExternalIPAM(js nats.JetStreamContext, pools []ExternalPoolConfig) (*ExternalIPAM, error) {
kv, err := utils.GetOrCreateKVBucket(js, KVBucketExternalIPAM, 5)
if err != nil {
return nil, fmt.Errorf("create external IPAM KV bucket: %w", err)
@@ -108,17 +79,16 @@ func NewExternalIPAM(nc *nats.Conn, js nats.JetStreamContext, pools []ExternalPo
if err := migrate.DefaultRegistry.RunKV(KVBucketExternalIPAM, kv, KVBucketExternalIPAMVersion); err != nil {
return nil, fmt.Errorf("migrate %s: %w", KVBucketExternalIPAM, err)
}
ipam := &ExternalIPAM{kv: kv, pools: pools, nc: nc}
ipam := &ExternalIPAM{kv: kv, pools: pools}
if err := ipam.initPools(); err != nil {
return nil, fmt.Errorf("init external IPAM pools: %w", err)
}
return ipam, nil
}
// NewExternalIPAMWithKV creates an ExternalIPAM with an existing KV bucket
// (for testing). nc may be nil when the test exercises only static pools.
func NewExternalIPAMWithKV(nc *nats.Conn, kv nats.KeyValue, pools []ExternalPoolConfig) *ExternalIPAM {
return &ExternalIPAM{kv: kv, pools: pools, nc: nc}
// NewExternalIPAMWithKV creates an ExternalIPAM with an existing KV bucket (for testing).
func NewExternalIPAMWithKV(kv nats.KeyValue, pools []ExternalPoolConfig) *ExternalIPAM {
return &ExternalIPAM{kv: kv, pools: pools}
}
// initPools ensures each configured pool has a KV record. Idempotent — safe to call
@@ -140,18 +110,16 @@ func (m *ExternalIPAM) initPool(pool ExternalPoolConfig) error {
}
if err == nil {
if chk.RangeStart != pool.RangeStart || chk.RangeEnd != pool.RangeEnd || chk.Source != pool.Source ||
if chk.RangeStart != pool.RangeStart || chk.RangeEnd != pool.RangeEnd ||
chk.GwLrpRangeStart != pool.GwLrpRangeStart || chk.GwLrpRangeEnd != pool.GwLrpRangeEnd {
slog.Info("external IPAM pool config drift, reconciling KV",
"pool", pool.Name,
"old_range", chk.RangeStart+"-"+chk.RangeEnd, "new_range", pool.RangeStart+"-"+pool.RangeEnd,
"old_source", chk.Source, "new_source", pool.Source,
"old_gw_lrp_range", chk.GwLrpRangeStart+"-"+chk.GwLrpRangeEnd,
"new_gw_lrp_range", pool.GwLrpRangeStart+"-"+pool.GwLrpRangeEnd)
chk.RangeStart = pool.RangeStart
chk.RangeEnd = pool.RangeEnd
chk.Source = pool.Source
chk.GwLrpRangeStart = pool.GwLrpRangeStart
chk.GwLrpRangeEnd = pool.GwLrpRangeEnd
@@ -172,39 +140,11 @@ func (m *ExternalIPAM) initPool(pool ExternalPoolConfig) error {
slog.Info("external IPAM pool not found, creating", "pool", pool.Name)
// TODO: Confirm gateway IP defined, should error, given we route traffic to the upstream DHCP gateway
gwIP := pool.GatewayIP
if gwIP == "" && !pool.IsDHCP() {
if gwIP == "" {
gwIP = pool.RangeStart
}
// For DHCP pools, obtain the gateway IP from router DHCP if not set.
// The gateway lease is long-lived: vpcd's renewal goroutine keeps it
// refreshed until the pool is torn down.
var gatewayLease *dhcp.LeaseResult
if gwIP == "" && pool.IsDHCP() {
lease, dhcpErr := dhcp.RequestAcquire(m.nc, pool.DhcpBindBridge,
"gateway-"+pool.Name,
"gateway-"+pool.Name,
"mulga-spinifex-gw",
pool.Name,
"",
)
if dhcpErr != nil {
return fmt.Errorf("DHCP gateway lease for pool %q: %w", pool.Name, dhcpErr)
}
gatewayLease = &lease
gwIP = lease.IP
slog.Info("external IPAM obtained gateway IP via DHCP", "pool", pool.Name, "gateway_ip", gwIP)
}
gatewayAlloc := ExternalIPAllocation{Type: "gateway", Note: "OVN router SNAT address"}
if gatewayLease != nil {
gatewayAlloc.LeaseExpiresUnix = gatewayLease.ExpiresUnix
gatewayAlloc.DHCPServerID = gatewayLease.ServerID
gatewayAlloc.HWAddr = gatewayLease.HWAddr
}
record := &ExternalIPAMRecord{
PoolName: pool.Name,
RangeStart: pool.RangeStart,
@@ -217,7 +157,7 @@ func (m *ExternalIPAM) initPool(pool ExternalPoolConfig) error {
GwLrpRangeStart: pool.GwLrpRangeStart,
GwLrpRangeEnd: pool.GwLrpRangeEnd,
Allocated: map[string]ExternalIPAllocation{
gwIP: gatewayAlloc,
gwIP: {Purpose: PurposeIGWLRP, Note: "OVN router SNAT address"},
},
}
@@ -234,19 +174,20 @@ func (m *ExternalIPAM) initPool(pool ExternalPoolConfig) error {
return fmt.Errorf("create pool KV entry: %w", err)
}
slog.Info("external IPAM pool initialized", "pool", pool.Name, "gateway_ip", gwIP, "source", pool.Source)
slog.Info("external IPAM pool initialized", "pool", pool.Name, "gateway_ip", gwIP)
return nil
}
// AllocateIP allocates the next available external IP from the best pool
// matching the given region/AZ. Returns the allocated IP and pool name.
// allocID is the EIP allocation ID (for elastic IPs); pass "" for other types.
func (m *ExternalIPAM) AllocateIP(region, az, allocType, allocID, eniID, instanceID string) (string, string, error) {
// purpose is one of the Purpose* constants in purpose.go; allocID is the
// EIP allocation ID (for PurposeEIP) and "" otherwise.
func (m *ExternalIPAM) AllocateIP(region, az, purpose, allocID, eniID, instanceID string) (string, string, error) {
pool := m.findPool(region, az)
if pool == nil {
return "", "", fmt.Errorf("InsufficientAddressCapacity: no external pool available for region=%q az=%q", region, az)
}
ip, err := m.allocateFromPool(pool.Name, allocType, allocID, eniID, instanceID)
ip, err := m.allocateFromPool(pool.Name, purpose, allocID, eniID, instanceID)
if err != nil {
return "", "", err
}
@@ -254,76 +195,31 @@ func (m *ExternalIPAM) AllocateIP(region, az, allocType, allocID, eniID, instanc
}
// AllocateFromPool allocates an IP from a specific named pool.
func (m *ExternalIPAM) AllocateFromPool(poolName, allocType, allocID, eniID, instanceID string) (string, error) {
return m.allocateFromPool(poolName, allocType, allocID, eniID, instanceID)
func (m *ExternalIPAM) AllocateFromPool(poolName, purpose, allocID, eniID, instanceID string) (string, error) {
return m.allocateFromPool(poolName, purpose, allocID, eniID, instanceID)
}
func (m *ExternalIPAM) allocateFromPool(poolName, allocType, allocID, eniID, instanceID string) (string, error) {
pool := m.findPoolByName(poolName)
// DHCP client-id: prefer allocID (EIPs), then eniID (ENIs), then instanceID.
// At least one must be non-empty for DHCP pools — the upstream DHCP server
// uses this to keep per-client leases distinct.
clientID := allocID
if clientID == "" {
clientID = eniID
}
if clientID == "" {
clientID = instanceID
}
// For DHCP-sourced pools: acquire the lease once, up front. vpcd's
// Manager is idempotent on ClientID, so CAS retries below will not
// trigger a second DORA — we only release at the end if no CAS
// attempt succeeded.
var dhcpLease *dhcp.LeaseResult
if pool != nil && pool.IsDHCP() {
hostname, vendorClass := dhcpIdentityOptions(eniID, instanceID, poolName)
lease, err := dhcp.RequestAcquire(m.nc, pool.DhcpBindBridge, clientID, hostname, vendorClass, poolName, pool.GatewayMAC)
if err != nil {
return "", fmt.Errorf("DHCP lease for %s: %w", clientID, err)
}
dhcpLease = &lease
}
func (m *ExternalIPAM) allocateFromPool(poolName, purpose, allocID, eniID, instanceID string) (string, error) {
for attempt := range 5 {
record, revision, err := m.getRecord(poolName)
if err != nil {
if dhcpLease != nil {
_ = dhcp.RequestRelease(m.nc, clientID)
}
return "", fmt.Errorf("get external IPAM record: %w", err)
}
var ip string
if dhcpLease != nil {
ip = dhcpLease.IP
} else {
// Static source: pick next IP from range
ip, err = nextAvailableExternalIP(record)
if err != nil {
return "", err
}
ip, err := nextAvailableExternalIP(record)
if err != nil {
return "", err
}
alloc := ExternalIPAllocation{
Type: allocType,
record.Allocated[ip] = ExternalIPAllocation{
Purpose: purpose,
AllocationID: allocID,
ENIId: eniID,
InstanceId: instanceID,
}
if dhcpLease != nil {
alloc.LeaseExpiresUnix = dhcpLease.ExpiresUnix
alloc.DHCPServerID = dhcpLease.ServerID
alloc.HWAddr = dhcpLease.HWAddr
}
record.Allocated[ip] = alloc
data, err := json.Marshal(record)
if err != nil {
if dhcpLease != nil {
_ = dhcp.RequestRelease(m.nc, clientID)
}
return "", fmt.Errorf("marshal external IPAM record: %w", err)
}
@@ -332,47 +228,15 @@ func (m *ExternalIPAM) allocateFromPool(poolName, allocType, allocID, eniID, ins
continue
}
source := "static"
if pool != nil {
source = pool.Source
}
slog.Info("external IPAM allocated IP", "pool", poolName, "ip", ip, "type", allocType, "source", source)
slog.Info("external IPAM allocated IP", "pool", poolName, "ip", ip, "purpose", purpose)
return ip, nil
}
// All CAS attempts exhausted — release any DHCP lease we acquired so
// vpcd doesn't hold a binding for an IP we won't use.
if dhcpLease != nil {
_ = dhcp.RequestRelease(m.nc, clientID)
}
return "", fmt.Errorf("external IPAM allocation failed after CAS retries for pool %s", poolName)
}
// dhcpIdentityOptions composes the option 12 (hostname) and option 60
// (vendor class) values for a per-allocation lease. Empty eniID /
// instanceID fall through to identifiers that still group usefully on
// the upstream server's lease table. Instance and ENI identifiers are
// used as-is (they already carry their "i-" / "eni-" prefixes).
func dhcpIdentityOptions(eniID, instanceID, poolName string) (hostname, vendorClass string) {
hostname = eniID
if hostname == "" {
if instanceID != "" {
hostname = "spinifex-" + instanceID
} else {
hostname = "spinifex-" + poolName
}
}
vendorClass = "mulga-spinifex"
if instanceID != "" {
vendorClass = instanceID
}
return hostname, vendorClass
}
// ReleaseIP releases a previously allocated external IP back to its pool.
func (m *ExternalIPAM) ReleaseIP(poolName, ip string) error {
pool := m.findPoolByName(poolName)
for attempt := range 5 {
record, revision, err := m.getRecord(poolName)
if err != nil {
@@ -383,26 +247,10 @@ func (m *ExternalIPAM) ReleaseIP(poolName, ip string) error {
if !ok {
return fmt.Errorf("IP %s not allocated in pool %s", ip, poolName)
}
if alloc.Type == "gateway" {
if alloc.Purpose == PurposeIGWLRP {
return fmt.Errorf("cannot release gateway IP %s in pool %s", ip, poolName)
}
// Release DHCP lease if this is a DHCP-sourced pool. clientID
// selection mirrors allocateFromPool so vpcd's Manager can find the
// lease it originally acquired.
if pool != nil && pool.IsDHCP() {
clientID := alloc.AllocationID
if clientID == "" {
clientID = alloc.ENIId
}
if clientID == "" {
clientID = alloc.InstanceId
}
if releaseErr := dhcp.RequestRelease(m.nc, clientID); releaseErr != nil {
slog.Warn("Failed to release DHCP lease", "pool", poolName, "ip", ip, "err", releaseErr)
}
}
delete(record.Allocated, ip)
data, err := json.Marshal(record)
@@ -455,16 +303,6 @@ func (m *ExternalIPAM) findPool(region, az string) *ExternalPoolConfig {
return nil
}
// findPoolByName returns the pool config by name.
func (m *ExternalIPAM) findPoolByName(name string) *ExternalPoolConfig {
for i := range m.pools {
if m.pools[i].Name == name {
return &m.pools[i]
}
}
return nil
}
func (m *ExternalIPAM) getRecord(poolName string) (*ExternalIPAMRecord, uint64, error) {
entry, err := m.kv.Get(poolName)
if err != nil {
@@ -481,7 +319,7 @@ func (m *ExternalIPAM) getRecord(poolName string) (*ExternalIPAMRecord, uint64,
// nextAvailableExternalIP finds the next unallocated IP in the pool's
// range. Addresses inside [GwLrpRangeStart, GwLrpRangeEnd] are skipped —
// vpcd reserves them for OVN gateway LRPs (mulga-siv-36).
// vpcd reserves them for OVN gateway LRPs.
func nextAvailableExternalIP(record *ExternalIPAMRecord) (string, error) {
startIP := net.ParseIP(record.RangeStart).To4()
endIP := net.ParseIP(record.RangeEnd).To4()
@@ -529,44 +367,41 @@ func ValidatePoolConfig(pool ExternalPoolConfig) error {
if pool.GatewayIP != "" && net.ParseIP(pool.GatewayIP) == nil {
return fmt.Errorf("invalid gateway_ip: %q", pool.GatewayIP)
}
// DHCP pools don't need range_start/range_end
if !pool.IsDHCP() {
startIP := net.ParseIP(pool.RangeStart)
if startIP == nil {
return fmt.Errorf("invalid range_start: %q", pool.RangeStart)
startIP := net.ParseIP(pool.RangeStart)
if startIP == nil {
return fmt.Errorf("invalid range_start: %q", pool.RangeStart)
}
endIP := net.ParseIP(pool.RangeEnd)
if endIP == nil {
return fmt.Errorf("invalid range_end: %q", pool.RangeEnd)
}
if ipToInt(startIP.To4()).Cmp(ipToInt(endIP.To4())) > 0 {
return fmt.Errorf("range_start %s is greater than range_end %s", pool.RangeStart, pool.RangeEnd)
}
// gw_lrp_range must be valid IPs and must NOT overlap range_start/end
// — otherwise vpcd's gateway LRP allocator and per-VM EIP allocator
// would fight over the same address.
if pool.GwLrpRangeStart != "" || pool.GwLrpRangeEnd != "" {
gwS := net.ParseIP(pool.GwLrpRangeStart)
gwE := net.ParseIP(pool.GwLrpRangeEnd)
if gwS == nil {
return fmt.Errorf("invalid gw_lrp_range_start: %q", pool.GwLrpRangeStart)
}
endIP := net.ParseIP(pool.RangeEnd)
if endIP == nil {
return fmt.Errorf("invalid range_end: %q", pool.RangeEnd)
if gwE == nil {
return fmt.Errorf("invalid gw_lrp_range_end: %q", pool.GwLrpRangeEnd)
}
if ipToInt(startIP.To4()).Cmp(ipToInt(endIP.To4())) > 0 {
return fmt.Errorf("range_start %s is greater than range_end %s", pool.RangeStart, pool.RangeEnd)
gwSi := ipToInt(gwS.To4())
gwEi := ipToInt(gwE.To4())
if gwSi.Cmp(gwEi) > 0 {
return fmt.Errorf("gw_lrp_range_start %s is greater than gw_lrp_range_end %s",
pool.GwLrpRangeStart, pool.GwLrpRangeEnd)
}
// gw_lrp_range must be valid IPs and must NOT overlap range_start/end
// — otherwise vpcd's gateway LRP allocator and per-VM EIP allocator
// would fight over the same address (mulga-siv-36).
if pool.GwLrpRangeStart != "" || pool.GwLrpRangeEnd != "" {
gwS := net.ParseIP(pool.GwLrpRangeStart)
gwE := net.ParseIP(pool.GwLrpRangeEnd)
if gwS == nil {
return fmt.Errorf("invalid gw_lrp_range_start: %q", pool.GwLrpRangeStart)
}
if gwE == nil {
return fmt.Errorf("invalid gw_lrp_range_end: %q", pool.GwLrpRangeEnd)
}
gwSi := ipToInt(gwS.To4())
gwEi := ipToInt(gwE.To4())
if gwSi.Cmp(gwEi) > 0 {
return fmt.Errorf("gw_lrp_range_start %s is greater than gw_lrp_range_end %s",
pool.GwLrpRangeStart, pool.GwLrpRangeEnd)
}
rangeSi := ipToInt(startIP.To4())
rangeEi := ipToInt(endIP.To4())
// Overlap test: !(gwE < rangeS || gwS > rangeE)
if gwEi.Cmp(rangeSi) >= 0 && gwSi.Cmp(rangeEi) <= 0 {
return fmt.Errorf("gw_lrp_range %s-%s overlaps range %s-%s",
pool.GwLrpRangeStart, pool.GwLrpRangeEnd, pool.RangeStart, pool.RangeEnd)
}
rangeSi := ipToInt(startIP.To4())
rangeEi := ipToInt(endIP.To4())
// Overlap test: !(gwE < rangeS || gwS > rangeE)
if gwEi.Cmp(rangeSi) >= 0 && gwSi.Cmp(rangeEi) <= 0 {
return fmt.Errorf("gw_lrp_range %s-%s overlaps range %s-%s",
pool.GwLrpRangeStart, pool.GwLrpRangeEnd, pool.RangeStart, pool.RangeEnd)
}
}
return nil
+29 -267
View File
@@ -1,24 +1,19 @@
package handlers_ec2_vpc
import (
"encoding/json"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/services/vpcd/dhcp"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestExternalIPAM(t *testing.T, pools []ExternalPoolConfig) *ExternalIPAM {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
ipam, err := NewExternalIPAM(nc, js, pools)
ipam, err := NewExternalIPAM(js, pools)
require.NoError(t, err)
return ipam
}
@@ -38,16 +33,16 @@ func TestExternalIPAM_AllocateSequential(t *testing.T) {
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
// .150 is reserved for gateway, so first allocable is .151
ip1, poolName, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-1", "i-1")
ip1, poolName, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "192.168.1.151", ip1)
assert.Equal(t, "wan", poolName)
ip2, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-2", "i-2")
ip2, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "192.168.1.152", ip2)
ip3, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-3", "i-3")
ip3, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-3", "i-3")
require.NoError(t, err)
assert.Equal(t, "192.168.1.153", ip3)
}
@@ -62,7 +57,7 @@ func TestExternalIPAM_GatewayIPReserved(t *testing.T) {
// Gateway IP (.150) should be reserved
alloc, ok := record.Allocated["192.168.1.150"]
assert.True(t, ok, "gateway IP should be in allocated map")
assert.Equal(t, "gateway", alloc.Type)
assert.Equal(t, PurposeIGWLRP, alloc.Purpose)
// Cannot release gateway IP
err = ipam.ReleaseIP("wan", "192.168.1.150")
@@ -84,7 +79,7 @@ func TestExternalIPAM_ExplicitGatewayIP(t *testing.T) {
assert.False(t, ok)
// First allocable is .150 since .155 is the gateway
ip1, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-1", "i-1")
ip1, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "192.168.1.150", ip1)
}
@@ -93,11 +88,11 @@ func TestExternalIPAM_Release(t *testing.T) {
pool := testPool()
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
ip1, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-1", "i-1")
ip1, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "192.168.1.151", ip1)
ip2, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-2", "i-2")
ip2, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "192.168.1.152", ip2)
@@ -106,7 +101,7 @@ func TestExternalIPAM_Release(t *testing.T) {
require.NoError(t, err)
// Next allocation reuses released IP
ip3, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-3", "i-3")
ip3, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-3", "i-3")
require.NoError(t, err)
assert.Equal(t, "192.168.1.151", ip3)
}
@@ -122,16 +117,16 @@ func TestExternalIPAM_Exhaustion(t *testing.T) {
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
// .1 reserved for gateway, .2 and .3 allocable
ip1, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-1", "i-1")
ip1, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "10.0.0.2", ip1)
ip2, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-2", "i-2")
ip2, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "10.0.0.3", ip2)
// Pool exhausted
_, _, err = ipam.AllocateIP("", "", "auto_assign", "", "eni-3", "i-3")
_, _, err = ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-3", "i-3")
assert.ErrorContains(t, err, "InsufficientAddressCapacity")
}
@@ -148,7 +143,7 @@ func TestExternalIPAM_CASConflict(t *testing.T) {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ip, _, err := ipam.AllocateIP("", "", "auto_assign", "", "eni-"+itoa(idx), "i-"+itoa(idx))
ip, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-"+itoa(idx), "i-"+itoa(idx))
results[idx] = ip
errs[idx] = err
}(i)
@@ -192,13 +187,13 @@ func TestExternalIPAM_MultiPool(t *testing.T) {
ipam := setupTestExternalIPAM(t, pools)
// Allocate from US pool
ip1, poolName1, err := ipam.AllocateIP("us-east-1", "us-east-1a", "auto_assign", "", "eni-1", "i-1")
ip1, poolName1, err := ipam.AllocateIP("us-east-1", "us-east-1a", PurposeENIPublic, "", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "203.0.113.3", ip1) // .2 reserved for gateway
assert.Equal(t, "us-east", poolName1)
// Allocate from EU pool
ip2, poolName2, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", "auto_assign", "", "eni-2", "i-2")
ip2, poolName2, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", PurposeENIPublic, "", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "198.51.100.3", ip2)
assert.Equal(t, "eu-west", poolName2)
@@ -234,19 +229,19 @@ func TestExternalIPAM_PoolFallback(t *testing.T) {
ipam := setupTestExternalIPAM(t, pools)
// AZ match: us-east-1a → az-pool
ip1, pool1, err := ipam.AllocateIP("us-east-1", "us-east-1a", "auto_assign", "", "eni-1", "i-1")
ip1, pool1, err := ipam.AllocateIP("us-east-1", "us-east-1a", PurposeENIPublic, "", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "az-pool", pool1)
assert.Equal(t, "10.0.0.2", ip1) // .1 reserved for gw
// Region match (different AZ): us-east-1b → region-pool
ip2, pool2, err := ipam.AllocateIP("us-east-1", "us-east-1b", "auto_assign", "", "eni-2", "i-2")
ip2, pool2, err := ipam.AllocateIP("us-east-1", "us-east-1b", PurposeENIPublic, "", "eni-2", "i-2")
require.NoError(t, err)
assert.Equal(t, "region-pool", pool2)
assert.Equal(t, "10.0.1.2", ip2)
// No match: eu-west-1 → global-pool
ip3, pool3, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", "auto_assign", "", "eni-3", "i-3")
ip3, pool3, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", PurposeENIPublic, "", "eni-3", "i-3")
require.NoError(t, err)
assert.Equal(t, "global-pool", pool3)
assert.Equal(t, "10.0.2.2", ip3)
@@ -272,7 +267,7 @@ func TestExternalIPAM_SpecificPool(t *testing.T) {
ipam := setupTestExternalIPAM(t, pools)
// Allocate specifically from pool-b
ip, err := ipam.AllocateFromPool("pool-b", "elastic_ip", "eipalloc-test-b", "", "")
ip, err := ipam.AllocateFromPool("pool-b", PurposeEIP, "eipalloc-test-b", "", "")
require.NoError(t, err)
assert.Equal(t, "10.0.1.2", ip) // .1 reserved for gw
}
@@ -339,19 +334,19 @@ func TestExternalIPAM_RangeValidation(t *testing.T) {
func TestExternalIPAM_InitFromConfig(t *testing.T) {
pool := testPool()
// Create IPAM twice — second init should be idempotent
_, nc, js := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
// First init
ipam1, err := NewExternalIPAM(nc, js, []ExternalPoolConfig{pool})
ipam1, err := NewExternalIPAM(js, []ExternalPoolConfig{pool})
require.NoError(t, err)
// Allocate an IP
ip, _, err := ipam1.AllocateIP("", "", "auto_assign", "", "eni-1", "i-1")
ip, _, err := ipam1.AllocateIP("", "", PurposeENIPublic, "", "eni-1", "i-1")
require.NoError(t, err)
assert.Equal(t, "192.168.1.151", ip)
// Second init (simulating restart) — should not lose allocation
ipam2, err := NewExternalIPAM(nc, js, []ExternalPoolConfig{pool})
ipam2, err := NewExternalIPAM(js, []ExternalPoolConfig{pool})
require.NoError(t, err)
record, err := ipam2.GetPoolRecord("wan")
@@ -381,7 +376,7 @@ func TestExternalIPAM_NoPoolAvailable(t *testing.T) {
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
// No pool matches eu-west
_, _, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", "auto_assign", "", "eni-1", "i-1")
_, _, err := ipam.AllocateIP("eu-west-1", "eu-west-1a", PurposeENIPublic, "", "eni-1", "i-1")
assert.ErrorContains(t, err, "InsufficientAddressCapacity")
}
@@ -389,247 +384,14 @@ func TestExternalIPAM_FindPoolByName_NotFound(t *testing.T) {
pool := testPool()
ipam := setupTestExternalIPAM(t, []ExternalPoolConfig{pool})
// findPoolByName is private, but exercised via AllocateFromPool with unknown pool.
// The function returns nil when no pool matches, which means static allocation path.
// We verify by using NewExternalIPAMWithKV to directly check findPoolByName returns nil.
// AllocateFromPool with a non-existent pool name: the pool "nonexistent"
// has no KV record, so getRecord fails.
kv := ipam.kv
ipam2 := NewExternalIPAMWithKV(nil, kv, []ExternalPoolConfig{pool})
// AllocateFromPool with a non-existent pool name: findPoolByName returns nil,
// so pool.IsDHCP() is skipped and static allocation is used.
// The pool "nonexistent" has no KV record, so getRecord fails.
_, err := ipam2.AllocateFromPool("nonexistent", "auto_assign", "", "eni-1", "i-1")
ipam2 := NewExternalIPAMWithKV(kv, []ExternalPoolConfig{pool})
_, err := ipam2.AllocateFromPool("nonexistent", PurposeENIPublic, "", "eni-1", "i-1")
assert.Error(t, err)
}
// dhcpStub captures acquire/release requests and replies with a
// deterministic lease so the handler-side IPAM code can be exercised
// without running vpcd.
type dhcpStub struct {
mu sync.Mutex
acquired []dhcp.AcquireRequestMsg
released []dhcp.ReleaseRequestMsg
nextIP string
acquireErr string
acquireCalls atomic.Int32
releaseCalls atomic.Int32
serverID string
leaseSeconds int64
hwAddrOverride string
releaseErrOnce atomic.Bool
}
func newDHCPStub(t *testing.T, nc *nats.Conn) *dhcpStub {
t.Helper()
s := &dhcpStub{
nextIP: "192.168.1.151",
serverID: "192.168.1.1",
leaseSeconds: 3600,
}
acquire, err := nc.Subscribe(dhcp.TopicAcquire, func(msg *nats.Msg) {
s.acquireCalls.Add(1)
var req dhcp.AcquireRequestMsg
if err := json.Unmarshal(msg.Data, &req); err != nil {
respondTestJSON(t, msg, dhcp.AcquireReplyMsg{Error: "malformed request"})
return
}
s.mu.Lock()
s.acquired = append(s.acquired, req)
ip := s.nextIP
errMsg := s.acquireErr
serverID := s.serverID
expires := time.Now().Add(time.Duration(s.leaseSeconds) * time.Second).Unix()
hw := s.hwAddrOverride
s.mu.Unlock()
if errMsg != "" {
respondTestJSON(t, msg, dhcp.AcquireReplyMsg{Error: errMsg})
return
}
if hw == "" {
hw = "02:00:00:aa:bb:cc"
}
respondTestJSON(t, msg, dhcp.AcquireReplyMsg{
IP: ip,
SubnetMask: "255.255.255.0",
Routers: []string{"192.168.1.1"},
DNS: []string{"192.168.1.1"},
ServerID: serverID,
HWAddr: hw,
ExpiresUnix: expires,
})
})
require.NoError(t, err)
t.Cleanup(func() { _ = acquire.Unsubscribe() })
release, err := nc.Subscribe(dhcp.TopicRelease, func(msg *nats.Msg) {
s.releaseCalls.Add(1)
var req dhcp.ReleaseRequestMsg
_ = json.Unmarshal(msg.Data, &req)
s.mu.Lock()
s.released = append(s.released, req)
s.mu.Unlock()
reply := dhcp.ReleaseReplyMsg{}
if s.releaseErrOnce.CompareAndSwap(true, false) {
reply.Error = "first release fails"
}
respondTestJSON(t, msg, reply)
})
require.NoError(t, err)
t.Cleanup(func() { _ = release.Unsubscribe() })
return s
}
func respondTestJSON(t *testing.T, msg *nats.Msg, v any) {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
require.NoError(t, msg.Respond(data))
}
func TestExternalIPAM_DHCPAllocateAndRelease(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
stub := newDHCPStub(t, nc)
stub.nextIP = "192.168.1.200"
pool := ExternalPoolConfig{
Name: "wan-dhcp",
Source: "dhcp",
Gateway: "192.168.1.1",
GatewayIP: "192.168.1.1", // pre-set so initPool skips gateway DORA
PrefixLen: 24,
DhcpBindBridge: "br-wan",
}
ipam, err := NewExternalIPAM(nc, js, []ExternalPoolConfig{pool})
require.NoError(t, err)
ip, err := ipam.AllocateFromPool("wan-dhcp", "elastic_ip", "eipalloc-123", "eni-abc", "i-xyz")
require.NoError(t, err)
assert.Equal(t, "192.168.1.200", ip)
require.EqualValues(t, 1, stub.acquireCalls.Load())
stub.mu.Lock()
got := stub.acquired[0]
stub.mu.Unlock()
assert.Equal(t, "eipalloc-123", got.ClientID, "clientID precedence: allocID first")
assert.Equal(t, "eni-abc", got.Hostname, "hostname defaults to eniID")
assert.Equal(t, "i-xyz", got.VendorClass, "vendor class i-<instanceID>")
assert.Equal(t, "wan-dhcp", got.PoolName)
assert.Equal(t, "br-wan", got.Bridge)
record, err := ipam.GetPoolRecord("wan-dhcp")
require.NoError(t, err)
alloc, ok := record.Allocated["192.168.1.200"]
require.True(t, ok)
assert.Equal(t, "192.168.1.1", alloc.DHCPServerID)
assert.NotZero(t, alloc.LeaseExpiresUnix)
assert.Equal(t, "02:00:00:aa:bb:cc", alloc.HWAddr)
require.NoError(t, ipam.ReleaseIP("wan-dhcp", "192.168.1.200"))
require.EqualValues(t, 1, stub.releaseCalls.Load())
stub.mu.Lock()
assert.Equal(t, "eipalloc-123", stub.released[0].ClientID)
stub.mu.Unlock()
}
func TestExternalIPAM_DHCPAcquireErrorPropagates(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
stub := newDHCPStub(t, nc)
stub.acquireErr = "server unreachable"
pool := ExternalPoolConfig{
Name: "wan-dhcp-err", Source: "dhcp", Gateway: "192.168.1.1",
GatewayIP: "192.168.1.1", PrefixLen: 24, DhcpBindBridge: "br-wan",
}
ipam, err := NewExternalIPAM(nc, js, []ExternalPoolConfig{pool})
require.NoError(t, err)
_, err = ipam.AllocateFromPool("wan-dhcp-err", "elastic_ip", "eipalloc-err", "", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "server unreachable")
}
func TestExternalIPAM_DHCPGatewayFromLease(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
stub := newDHCPStub(t, nc)
stub.nextIP = "192.168.3.247"
pool := ExternalPoolConfig{
Name: "wan-dhcp-gw", Source: "dhcp", Gateway: "192.168.3.1",
PrefixLen: 24, DhcpBindBridge: "br-wan",
// GatewayIP intentionally empty — initPool must pull it from DHCP.
}
_, err := NewExternalIPAM(nc, js, []ExternalPoolConfig{pool})
require.NoError(t, err)
assert.EqualValues(t, 1, stub.acquireCalls.Load())
stub.mu.Lock()
got := stub.acquired[0]
stub.mu.Unlock()
assert.Equal(t, "gateway-wan-dhcp-gw", got.ClientID)
assert.Equal(t, "mulga-spinifex-gw", got.VendorClass)
}
func TestExternalIPAM_DHCPGatewayErrorPropagates(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
stub := newDHCPStub(t, nc)
stub.acquireErr = "no response from upstream"
pool := ExternalPoolConfig{
Name: "wan-gw-err", Source: "dhcp", Gateway: "192.168.1.1",
PrefixLen: 24, DhcpBindBridge: "br-wan",
}
_, err := NewExternalIPAM(nc, js, []ExternalPoolConfig{pool})
require.Error(t, err)
assert.Contains(t, err.Error(), "no response from upstream")
}
func TestExternalIPAM_DHCPReleaseErrorIsNonFatal(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
stub := newDHCPStub(t, nc)
stub.nextIP = "192.168.1.155"
stub.releaseErrOnce.Store(true)
pool := ExternalPoolConfig{
Name: "wan-rel-err", Source: "dhcp", Gateway: "192.168.1.1",
GatewayIP: "192.168.1.1", PrefixLen: 24, DhcpBindBridge: "br-wan",
}
ipam, err := NewExternalIPAM(nc, js, []ExternalPoolConfig{pool})
require.NoError(t, err)
ip, err := ipam.AllocateFromPool("wan-rel-err", "elastic_ip", "eipalloc-rel", "", "")
require.NoError(t, err)
// Release reports vpcd failure via slog.Warn but ReleaseIP itself
// still succeeds — the allocation is removed from KV.
require.NoError(t, ipam.ReleaseIP("wan-rel-err", ip))
rec, err := ipam.GetPoolRecord("wan-rel-err")
require.NoError(t, err)
_, stillThere := rec.Allocated[ip]
assert.False(t, stillThere)
}
func TestDHCPIdentityOptions(t *testing.T) {
tests := []struct {
name string
eniID string
instanceID string
pool string
wantHost string
wantVendor string
}{
{"eni+instance", "eni-1", "i-abcd", "wan", "eni-1", "i-abcd"},
{"instance only", "", "i-xyz", "wan", "spinifex-i-xyz", "i-xyz"},
{"neither", "", "", "wan", "spinifex-wan", "mulga-spinifex"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, vendor := dhcpIdentityOptions(tc.eniID, tc.instanceID, tc.pool)
assert.Equal(t, tc.wantHost, host)
assert.Equal(t, tc.wantVendor, vendor)
})
}
}
// TestNextAvailableExternalIP_SkipsGwLrpRange verifies the per-VM EIP allocator
// honors gw_lrp_range by skipping IPs reserved for vpcd's gateway LRPs (siv-36).
func TestNextAvailableExternalIP_SkipsGwLrpRange(t *testing.T) {
+25 -15
View File
@@ -15,14 +15,22 @@ import (
const (
KVBucketIPAM = "spinifex-vpc-ipam"
KVBucketIPAMVersion = 1
KVBucketIPAMVersion = 2
)
// IPEntry tags one IP allocation with its Purpose + owner (eni-, eipalloc-,
// etc) so multi-VPC clusters can reclaim/audit by (owner, purpose).
type IPEntry struct {
IP string `json:"ip"`
Purpose string `json:"purpose"` // one of the Purpose* constants
OwnerID string `json:"owner_id,omitempty"` // ENI / EIP / NATGW / IGW resource ID
}
// IPAMRecord tracks allocated IPs for a subnet.
type IPAMRecord struct {
SubnetId string `json:"subnet_id"`
CidrBlock string `json:"cidr_block"`
Allocated []string `json:"allocated"` // List of allocated IPs
SubnetId string `json:"subnet_id"`
CidrBlock string `json:"cidr_block"`
Allocated []IPEntry `json:"allocated"`
}
// IPAM manages IP address allocation for VPC subnets using NATS KV with CAS.
@@ -36,7 +44,7 @@ func NewIPAM(js nats.JetStreamContext) (*IPAM, error) {
if err != nil {
return nil, fmt.Errorf("failed to create IPAM KV bucket: %w", err)
}
if err := migrate.DefaultRegistry.RunKV(KVBucketIPAM, kv, KVBucketIPAMVersion); err != nil {
if err := migrate.DefaultRegistry.RunKVWithJetStream(KVBucketIPAM, kv, js, KVBucketIPAMVersion); err != nil {
return nil, fmt.Errorf("migrate %s: %w", KVBucketIPAM, err)
}
return &IPAM{kv: kv}, nil
@@ -50,8 +58,10 @@ func NewIPAMWithKV(kv nats.KeyValue) *IPAM {
// AllocateIP allocates an IP address from the given subnet.
// It reserves the first 4 and last IP per AWS convention:
// .0=network, .1=gateway, .2=DNS, .3=reserved, .255=broadcast (for /24).
// Uses CAS for conflict-free allocation across nodes.
func (m *IPAM) AllocateIP(subnetId, cidrBlock string) (string, error) {
// Uses CAS for conflict-free allocation across nodes. purpose is one of the
// Purpose* constants in purpose.go; ownerID is the ENI / instance / EIP
// resource ID that owns this allocation.
func (m *IPAM) AllocateIP(subnetId, cidrBlock, purpose, ownerID string) (string, error) {
for attempt := range 5 {
record, revision, err := m.getRecord(subnetId)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
@@ -71,7 +81,7 @@ func (m *IPAM) AllocateIP(subnetId, cidrBlock string) (string, error) {
return "", err
}
record.Allocated = append(record.Allocated, ip)
record.Allocated = append(record.Allocated, IPEntry{IP: ip, Purpose: purpose, OwnerID: ownerID})
// CAS write
data, err := json.Marshal(record)
@@ -90,7 +100,7 @@ func (m *IPAM) AllocateIP(subnetId, cidrBlock string) (string, error) {
continue // CAS conflict, retry
}
slog.Info("IPAM allocated IP", "subnet", subnetId, "ip", ip)
slog.Info("IPAM allocated IP", "subnet", subnetId, "ip", ip, "purpose", purpose, "owner", ownerID)
return ip, nil
}
@@ -109,8 +119,8 @@ func (m *IPAM) ReleaseIP(subnetId, ip string) error {
}
found := false
for i, allocated := range record.Allocated {
if allocated == ip {
for i, entry := range record.Allocated {
if entry.IP == ip {
record.Allocated = append(record.Allocated[:i], record.Allocated[i+1:]...)
found = true
break
@@ -137,8 +147,8 @@ func (m *IPAM) ReleaseIP(subnetId, ip string) error {
return fmt.Errorf("IPAM release failed after CAS retries for subnet %s", subnetId)
}
// AllocatedIPs returns the list of allocated IPs for a subnet.
func (m *IPAM) AllocatedIPs(subnetId string) ([]string, error) {
// AllocatedIPs returns the list of allocated IP entries for a subnet.
func (m *IPAM) AllocatedIPs(subnetId string) ([]IPEntry, error) {
record, _, err := m.getRecord(subnetId)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
@@ -177,8 +187,8 @@ func (m *IPAM) nextAvailableIP(record *IPAMRecord) (string, error) {
}
allocated := make(map[string]bool, len(record.Allocated))
for _, ip := range record.Allocated {
allocated[ip] = true
for _, entry := range record.Allocated {
allocated[entry.IP] = true
}
ones, bits := ipNet.Mask.Size()
+23 -21
View File
@@ -22,7 +22,7 @@ func setupTestIPAM(t *testing.T) *IPAM {
func TestIPAM_AllocateFirst(t *testing.T) {
ipam := setupTestIPAM(t)
ip, err := ipam.AllocateIP("subnet-1", "10.0.1.0/24")
ip, err := ipam.AllocateIP("subnet-1", "10.0.1.0/24", PurposeENIPrimary, "eni-1")
require.NoError(t, err)
// First allocable IP is .4 (skip .0 network, .1 gateway, .2 DNS, .3 reserved)
assert.Equal(t, "10.0.1.4", ip)
@@ -31,15 +31,15 @@ func TestIPAM_AllocateFirst(t *testing.T) {
func TestIPAM_AllocateSequential(t *testing.T) {
ipam := setupTestIPAM(t)
ip1, err := ipam.AllocateIP("subnet-seq", "10.0.2.0/24")
ip1, err := ipam.AllocateIP("subnet-seq", "10.0.2.0/24", PurposeENIPrimary, "eni-seq")
require.NoError(t, err)
assert.Equal(t, "10.0.2.4", ip1)
ip2, err := ipam.AllocateIP("subnet-seq", "10.0.2.0/24")
ip2, err := ipam.AllocateIP("subnet-seq", "10.0.2.0/24", PurposeENIPrimary, "eni-seq")
require.NoError(t, err)
assert.Equal(t, "10.0.2.5", ip2)
ip3, err := ipam.AllocateIP("subnet-seq", "10.0.2.0/24")
ip3, err := ipam.AllocateIP("subnet-seq", "10.0.2.0/24", PurposeENIPrimary, "eni-seq")
require.NoError(t, err)
assert.Equal(t, "10.0.2.6", ip3)
}
@@ -47,11 +47,11 @@ func TestIPAM_AllocateSequential(t *testing.T) {
func TestIPAM_Release(t *testing.T) {
ipam := setupTestIPAM(t)
ip1, err := ipam.AllocateIP("subnet-rel", "10.0.3.0/24")
ip1, err := ipam.AllocateIP("subnet-rel", "10.0.3.0/24", PurposeENIPrimary, "eni-rel")
require.NoError(t, err)
assert.Equal(t, "10.0.3.4", ip1)
ip2, err := ipam.AllocateIP("subnet-rel", "10.0.3.0/24")
ip2, err := ipam.AllocateIP("subnet-rel", "10.0.3.0/24", PurposeENIPrimary, "eni-rel")
require.NoError(t, err)
assert.Equal(t, "10.0.3.5", ip2)
@@ -60,7 +60,7 @@ func TestIPAM_Release(t *testing.T) {
require.NoError(t, err)
// Next allocation should reuse the released IP
ip3, err := ipam.AllocateIP("subnet-rel", "10.0.3.0/24")
ip3, err := ipam.AllocateIP("subnet-rel", "10.0.3.0/24", PurposeENIPrimary, "eni-rel")
require.NoError(t, err)
assert.Equal(t, "10.0.3.4", ip3)
}
@@ -68,7 +68,7 @@ func TestIPAM_Release(t *testing.T) {
func TestIPAM_ReleaseNotAllocated(t *testing.T) {
ipam := setupTestIPAM(t)
_, err := ipam.AllocateIP("subnet-rna", "10.0.4.0/24")
_, err := ipam.AllocateIP("subnet-rna", "10.0.4.0/24", PurposeENIPrimary, "eni-rna")
require.NoError(t, err)
err = ipam.ReleaseIP("subnet-rna", "10.0.4.99")
@@ -91,7 +91,7 @@ func TestIPAM_Exhaustion(t *testing.T) {
var allocated []string
for i := range 11 {
ip, err := ipam.AllocateIP(subnetId, cidr)
ip, err := ipam.AllocateIP(subnetId, cidr, PurposeENIPrimary, "eni-test")
require.NoError(t, err, "allocation %d should succeed", i)
allocated = append(allocated, ip)
}
@@ -101,7 +101,7 @@ func TestIPAM_Exhaustion(t *testing.T) {
assert.Equal(t, "10.0.5.14", allocated[len(allocated)-1])
// Next allocation should fail — subnet exhausted
_, err := ipam.AllocateIP(subnetId, cidr)
_, err := ipam.AllocateIP(subnetId, cidr, PurposeENIPrimary, "eni-test")
assert.ErrorContains(t, err, "exhausted")
}
@@ -113,7 +113,7 @@ func TestIPAM_ReservedIPs(t *testing.T) {
// First 4 allocations should be .4, .5, .6, .7 (skipping .0-.3)
for i := 4; i <= 7; i++ {
ip, err := ipam.AllocateIP(subnetId, cidr)
ip, err := ipam.AllocateIP(subnetId, cidr, PurposeENIPrimary, "eni-test")
require.NoError(t, err)
expected := "10.0.6." + itoa(i)
assert.Equal(t, expected, ip)
@@ -129,29 +129,31 @@ func TestIPAM_AllocatedIPs(t *testing.T) {
assert.Nil(t, ips)
// Allocate some IPs
_, _ = ipam.AllocateIP("subnet-list", "10.0.7.0/24")
_, _ = ipam.AllocateIP("subnet-list", "10.0.7.0/24")
_, _ = ipam.AllocateIP("subnet-list", "10.0.7.0/24", PurposeENIPrimary, "eni-list")
_, _ = ipam.AllocateIP("subnet-list", "10.0.7.0/24", PurposeENIPrimary, "eni-list")
ips, err = ipam.AllocatedIPs("subnet-list")
require.NoError(t, err)
assert.Len(t, ips, 2)
assert.Equal(t, "10.0.7.4", ips[0])
assert.Equal(t, "10.0.7.5", ips[1])
assert.Equal(t, "10.0.7.4", ips[0].IP)
assert.Equal(t, PurposeENIPrimary, ips[0].Purpose)
assert.Equal(t, "eni-list", ips[0].OwnerID)
assert.Equal(t, "10.0.7.5", ips[1].IP)
}
func TestIPAM_MultipleSubnets(t *testing.T) {
ipam := setupTestIPAM(t)
ip1, err := ipam.AllocateIP("subnet-a", "10.0.10.0/24")
ip1, err := ipam.AllocateIP("subnet-a", "10.0.10.0/24", PurposeENIPrimary, "eni-a")
require.NoError(t, err)
assert.Equal(t, "10.0.10.4", ip1)
ip2, err := ipam.AllocateIP("subnet-b", "10.0.20.0/24")
ip2, err := ipam.AllocateIP("subnet-b", "10.0.20.0/24", PurposeENIPrimary, "eni-b")
require.NoError(t, err)
assert.Equal(t, "10.0.20.4", ip2)
// Each subnet tracks independently
ip3, err := ipam.AllocateIP("subnet-a", "10.0.10.0/24")
ip3, err := ipam.AllocateIP("subnet-a", "10.0.10.0/24", PurposeENIPrimary, "eni-a")
require.NoError(t, err)
assert.Equal(t, "10.0.10.5", ip3)
}
@@ -160,7 +162,7 @@ func TestIPAM_LargerSubnet(t *testing.T) {
ipam := setupTestIPAM(t)
// /20 subnet — first allocable is still .4
ip, err := ipam.AllocateIP("subnet-big", "172.16.0.0/20")
ip, err := ipam.AllocateIP("subnet-big", "172.16.0.0/20", PurposeENIPrimary, "eni-big")
require.NoError(t, err)
assert.Equal(t, "172.16.0.4", ip)
}
@@ -177,14 +179,14 @@ func TestIPAM_NewIPAMWithKV(t *testing.T) {
require.NotNil(t, ipam)
// Should work the same as regular IPAM
ip, err := ipam.AllocateIP("subnet-kv", "10.0.1.0/24")
ip, err := ipam.AllocateIP("subnet-kv", "10.0.1.0/24", PurposeENIPrimary, "eni-kv")
require.NoError(t, err)
assert.Equal(t, "10.0.1.4", ip)
}
func TestIPAM_InvalidCIDR(t *testing.T) {
ipam := setupTestIPAM(t)
_, err := ipam.AllocateIP("subnet-bad", "not-a-cidr")
_, err := ipam.AllocateIP("subnet-bad", "not-a-cidr", PurposeENIPrimary, "eni-bad")
assert.Error(t, err)
assert.Contains(t, err.Error(), "parse CIDR")
}
+31
View File
@@ -0,0 +1,31 @@
package handlers_ec2_vpc
// Purpose tags every IP allocation with its semantic role so multi-VPC
// clusters can reclaim / audit by (owner, purpose). The constants match the
// JSON values written to KV records.
const (
PurposeENIPrimary = "eni-primary"
PurposeENISecondary = "eni-secondary"
PurposeENIPublic = "eni-public" // auto-assigned public IP
PurposeEIP = "eip"
PurposeNATGWExternal = "natgw-external"
PurposeIGWLRP = "igw-lrp"
// Future-reserved values; defined now so call sites and migrations can
// reference them without a follow-up rename.
PurposeEdgeLRExternal = "edge-lr-external"
PurposeNodeMgmt = "node-mgmt" // currently .env
PurposeSubnetRouter = "subnet-router" // .1
PurposeSubnetDNS = "subnet-dns" // .2
PurposeSubnetReserved = "subnet-reserved" // .0, .3, .last
// PurposeUnknown tags records the migration could not classify (e.g.
// orphan IPs with no owning ENIRecord). Surfaces in logs for triage.
PurposeUnknown = "unknown"
)
// LegacyExternalTypeToPurpose maps the old ExternalIPAllocation.Type values
// onto the new Purpose enum. Used by the v1→v2 external-IPAM migration.
var LegacyExternalTypeToPurpose = map[string]string{
"gateway": PurposeIGWLRP,
"auto_assign": PurposeENIPublic,
"elastic_ip": PurposeEIP,
}
+1 -2
View File
@@ -1150,8 +1150,7 @@ func (s *VPCServiceImpl) createDefaultSecurityGroupInternal(accountID, vpcId str
// deleteSecurityGroupInternal removes an SG record without the public-API
// CannotDelete guard for default SGs. Used by DeleteVpc to cascade-delete the
// per-VPC default SG. Honors the Phase 7 sync contract so vpcd tear-down
// failures propagate.
// per-VPC default SG. Surfaces vpcd tear-down failures to the caller.
func (s *VPCServiceImpl) deleteSecurityGroupInternal(accountID, groupId string) error {
key := utils.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(key)
+18 -1
View File
@@ -35,13 +35,18 @@ const (
KVBucketVNICounterVersion = 1
)
// VPCRecord represents a stored VPC
// VPCRecord represents a stored VPC. AZ stamps the local availability zone
// at create time; the network/reconcile package filters its intent set by
// matching `vpc.AZ == localAZ` (with the legacy rule that empty matches
// every AZ for pre-Phase-2.2 records). New records always populate AZ from
// the daemon's config.AZ.
type VPCRecord struct {
VpcId string `json:"vpc_id"`
CidrBlock string `json:"cidr_block"`
State string `json:"state"`
IsDefault bool `json:"is_default"`
VNI int64 `json:"vni"`
AZ string `json:"az,omitempty"`
EnableDnsHostnames bool `json:"enable_dns_hostnames"`
EnableDnsSupport bool `json:"enable_dns_support"`
EnableNetworkAddressUsageMetrics bool `json:"enable_network_address_usage_metrics"`
@@ -86,6 +91,16 @@ func (s *VPCServiceImpl) SetExternalIPAM(ipam *ExternalIPAM, eipKV nats.KeyValue
s.eipKV = eipKV
}
// localAZ returns the node's local availability zone, sourced from
// s.config.AZ. Returns "" when no config is wired (mostly test paths),
// which the reconciler treats as a legacy record matching every AZ.
func (s *VPCServiceImpl) localAZ() string {
if s.config == nil {
return ""
}
return s.config.AZ
}
// NewVPCServiceImplWithNATS creates a VPC service with NATS JetStream for persistence
func NewVPCServiceImplWithNATS(cfg *config.Config, natsConn *nats.Conn) (*VPCServiceImpl, error) {
js, err := natsConn.JetStream()
@@ -231,6 +246,7 @@ func (s *VPCServiceImpl) CreateVpc(input *ec2.CreateVpcInput, accountID string)
State: "available",
IsDefault: false,
VNI: vni,
AZ: s.localAZ(),
EnableDnsSupport: true, // AWS default
EnableDnsHostnames: false, // AWS default
Tags: utils.ExtractTags(input.TagSpecifications, "vpc"),
@@ -1026,6 +1042,7 @@ func (s *VPCServiceImpl) EnsureDefaultVPC(accountID string, bootstrap ...Bootstr
State: "available",
IsDefault: true,
VNI: vni,
AZ: s.localAZ(),
EnableDnsSupport: true, // AWS default
EnableDnsHostnames: true, // AWS default for default VPC
Tags: map[string]string{"Name": "default"},
@@ -0,0 +1,134 @@
package migrate
import (
"encoding/json"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// extIPAMBucket mirrors handlers/ec2/vpc.KVBucketExternalIPAM. Duplicated to
// keep the migrate package free of any handler dependency; if the bucket name
// changes, both constants must move together.
const extIPAMBucket = "spinifex-external-ipam"
const extIPAMMaxRetries = 3
// extLegacyTypeToPurpose maps the v1 ExternalIPAllocation.Type values onto
// the v2 Purpose enum. Frozen copy of vpc.LegacyExternalTypeToPurpose to
// keep this migration self-contained.
var extLegacyTypeToPurpose = map[string]string{
"gateway": "igw-lrp",
"auto_assign": "eni-public",
"elastic_ip": "eip",
}
// extIPAllocV1V2 is a union of the v1 and v2 ExternalIPAllocation schemas.
// The migration reads the legacy Type field, writes the new Purpose field,
// and clears Type so the on-disk record matches the v2 struct exactly.
type extIPAllocV1V2 struct {
Type string `json:"type,omitempty"`
Purpose string `json:"purpose,omitempty"`
AllocationID string `json:"allocation_id,omitempty"`
Association string `json:"association,omitempty"`
ENIId string `json:"eni_id,omitempty"`
InstanceId string `json:"instance_id,omitempty"`
Note string `json:"note,omitempty"`
}
type extIPAMRecordV1V2 struct {
PoolName string `json:"pool_name"`
RangeStart string `json:"range_start"`
RangeEnd string `json:"range_end"`
Gateway string `json:"gateway"`
GatewayIP string `json:"gateway_ip"`
PrefixLen int `json:"prefix_len"`
Region string `json:"region,omitempty"`
AZ string `json:"az,omitempty"`
GwLrpRangeStart string `json:"gw_lrp_range_start,omitempty"`
GwLrpRangeEnd string `json:"gw_lrp_range_end,omitempty"`
Allocated map[string]extIPAllocV1V2 `json:"allocated"`
}
func init() {
DefaultRegistry.RegisterKV(extIPAMBucket, KVMigration{
FromVersion: 1,
ToVersion: 2,
Description: "rename ExternalIPAllocation.Type → .Purpose with enum mapping",
Run: func(ctx KVContext) error {
keys, err := ctx.KV.Keys()
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
return nil
}
return fmt.Errorf("list keys: %w", err)
}
for _, key := range keys {
if key == utils.VersionKey {
continue
}
if err := renameExternalIPAMType(ctx, key); err != nil {
return err
}
}
return nil
},
})
}
func renameExternalIPAMType(ctx KVContext, key string) error {
var lastErr error
for attempt := range extIPAMMaxRetries {
entry, err := ctx.KV.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
return nil
}
return fmt.Errorf("get %s: %w", key, err)
}
var record extIPAMRecordV1V2
if err := json.Unmarshal(entry.Value(), &record); err != nil {
return fmt.Errorf("unmarshal %s: %w", key, err)
}
changed := false
for ip, alloc := range record.Allocated {
if alloc.Purpose != "" {
continue
}
purpose := extLegacyTypeToPurpose[alloc.Type]
if purpose == "" {
ctx.Logger.Warn("external IPAM v1→v2 migration: unknown legacy type, tagging unknown",
"pool", key, "ip", ip, "legacy_type", alloc.Type)
purpose = "unknown"
}
alloc.Purpose = purpose
alloc.Type = ""
record.Allocated[ip] = alloc
changed = true
}
if !changed {
return nil
}
data, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("marshal %s: %w", key, err)
}
if _, err := ctx.KV.Update(key, data, entry.Revision()); err != nil {
if errors.Is(err, nats.ErrKeyExists) {
ctx.Logger.Warn("external IPAM migration CAS conflict, retrying", "key", key, "attempt", attempt+1)
lastErr = err
continue
}
return fmt.Errorf("update %s: %w", key, err)
}
return nil
}
return fmt.Errorf("rename Type→Purpose for %s: exceeded %d CAS retries: %w", key, extIPAMMaxRetries, lastErr)
}
@@ -0,0 +1,103 @@
package migrate
import (
"encoding/json"
"log/slog"
"testing"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func extIPAMMigrationKV(t *testing.T) nats.KeyValue {
t.Helper()
_, nc := startTestNATS(t)
return createTestBucket(t, nc, "ext-ipam-migration-test")
}
func runExtIPAMMigration(t *testing.T, kv nats.KeyValue) {
t.Helper()
for _, m := range DefaultRegistry.kvMigrations[extIPAMBucket] {
if m.FromVersion == 1 && m.ToVersion == 2 {
require.NoError(t, m.Run(KVContext{KV: kv, Logger: slog.Default()}))
return
}
}
t.Fatalf("v1→v2 migration not registered for %s", extIPAMBucket)
}
func TestExtIPAMMigration_RenamesType(t *testing.T) {
kv := extIPAMMigrationKV(t)
record := extIPAMRecordV1V2{
PoolName: "wan",
RangeStart: "192.168.1.10",
RangeEnd: "192.168.1.250",
Gateway: "192.168.1.1",
PrefixLen: 24,
Allocated: map[string]extIPAllocV1V2{
"192.168.1.10": {Type: "gateway", Note: "OVN router SNAT address"},
"192.168.1.11": {Type: "auto_assign", ENIId: "eni-1", InstanceId: "i-1"},
"192.168.1.12": {Type: "elastic_ip", AllocationID: "eipalloc-1"},
"192.168.1.13": {Type: "junk-legacy", ENIId: "eni-zombie"},
},
}
data, err := json.Marshal(record)
require.NoError(t, err)
_, err = kv.Put("wan", data)
require.NoError(t, err)
runExtIPAMMigration(t, kv)
entry, err := kv.Get("wan")
require.NoError(t, err)
var got extIPAMRecordV1V2
require.NoError(t, json.Unmarshal(entry.Value(), &got))
assert.Equal(t, "igw-lrp", got.Allocated["192.168.1.10"].Purpose)
assert.Equal(t, "eni-public", got.Allocated["192.168.1.11"].Purpose)
assert.Equal(t, "eip", got.Allocated["192.168.1.12"].Purpose)
assert.Equal(t, "unknown", got.Allocated["192.168.1.13"].Purpose, "unrecognized legacy types tagged unknown")
for ip, alloc := range got.Allocated {
assert.Empty(t, alloc.Type, "legacy Type field cleared for %s", ip)
}
assert.Equal(t, "eni-1", got.Allocated["192.168.1.11"].ENIId, "non-Type fields preserved")
assert.Equal(t, "eipalloc-1", got.Allocated["192.168.1.12"].AllocationID)
}
func TestExtIPAMMigration_IdempotentOnAlreadyMigrated(t *testing.T) {
kv := extIPAMMigrationKV(t)
record := extIPAMRecordV1V2{
PoolName: "wan",
Allocated: map[string]extIPAllocV1V2{
"192.168.1.10": {Purpose: "igw-lrp"},
"192.168.1.11": {Purpose: "eni-public", ENIId: "eni-1"},
},
}
data, err := json.Marshal(record)
require.NoError(t, err)
_, err = kv.Put("wan", data)
require.NoError(t, err)
rev := getKVRevision(t, kv, "wan")
runExtIPAMMigration(t, kv)
newRev := getKVRevision(t, kv, "wan")
assert.Equal(t, rev, newRev, "no write when all records already have Purpose")
}
func TestExtIPAMMigration_EmptyBucket(t *testing.T) {
kv := extIPAMMigrationKV(t)
runExtIPAMMigration(t, kv)
}
func getKVRevision(t *testing.T, kv nats.KeyValue, key string) uint64 {
t.Helper()
entry, err := kv.Get(key)
require.NoError(t, err)
return entry.Revision()
}
+224
View File
@@ -0,0 +1,224 @@
package migrate
import (
"encoding/json"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// ipamBucket / eniBucket mirror handlers/ec2/vpc.KVBucketIPAM and
// .KVBucketENIs. Duplicated here so the migrate package stays handler-free.
const (
ipamBucket = "spinifex-vpc-ipam"
eniBucket = "spinifex-vpc-enis"
)
const ipamMigrationMaxRetries = 3
// ipEntryV2 mirrors handlers/ec2/vpc.IPEntry (frozen snapshot).
type ipEntryV2 struct {
IP string `json:"ip"`
Purpose string `json:"purpose"`
OwnerID string `json:"owner_id,omitempty"`
}
// ipamRecordV1V2 is a union of v1 ([]string) and v2 ([]IPEntry) schemas.
// Allocated is left as raw JSON so we can detect which schema is on-disk
// and re-marshal as IPEntry.
type ipamRecordV1V2 struct {
SubnetId string `json:"subnet_id"`
CidrBlock string `json:"cidr_block"`
Allocated json.RawMessage `json:"allocated"`
}
// ipamRecordV2 is the post-migration shape.
type ipamRecordV2 struct {
SubnetId string `json:"subnet_id"`
CidrBlock string `json:"cidr_block"`
Allocated []ipEntryV2 `json:"allocated"`
}
// eniRecordSnapshot is the minimum ENIRecord shape needed to attribute IPAM
// entries to their owning ENI. Frozen copy keeps the migration self-contained.
type eniRecordSnapshot struct {
NetworkInterfaceId string `json:"network_interface_id"`
SubnetId string `json:"subnet_id"`
PrivateIpAddress string `json:"private_ip_address"`
}
func init() {
DefaultRegistry.RegisterKV(ipamBucket, KVMigration{
FromVersion: 1,
ToVersion: 2,
Description: "convert subnet IPAM Allocated []string → []IPEntry; backfill Purpose+OwnerID from ENIs",
Run: func(ctx KVContext) error {
ownerIndex, err := buildIPAMOwnerIndex(ctx.JetStream)
if err != nil {
return fmt.Errorf("build owner index: %w", err)
}
keys, err := ctx.KV.Keys()
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
return nil
}
return fmt.Errorf("list keys: %w", err)
}
for _, key := range keys {
if key == utils.VersionKey {
continue
}
if err := convertIPAMRecord(ctx, key, ownerIndex); err != nil {
return err
}
}
return nil
},
})
}
// buildIPAMOwnerIndex returns map[subnetID]map[ip]eniID by walking the ENI
// bucket. Returns an empty index if the ENI bucket is missing
// (fresh-install case) so the migration still runs cleanly.
func buildIPAMOwnerIndex(js nats.JetStreamContext) (map[string]map[string]string, error) {
if js == nil {
// Called via the bare RunKV path (e.g. in tests with no JS). Skip
// owner attribution — all entries become Purpose=unknown so the
// schema-only conversion still succeeds.
return map[string]map[string]string{}, nil
}
eniKV, err := js.KeyValue(eniBucket)
if err != nil {
if errors.Is(err, nats.ErrBucketNotFound) {
return map[string]map[string]string{}, nil
}
return nil, fmt.Errorf("open ENI bucket: %w", err)
}
keys, err := eniKV.Keys()
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
return map[string]map[string]string{}, nil
}
return nil, fmt.Errorf("list ENI keys: %w", err)
}
index := make(map[string]map[string]string)
for _, key := range keys {
if key == utils.VersionKey {
continue
}
entry, err := eniKV.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
continue
}
return nil, fmt.Errorf("get ENI %s: %w", key, err)
}
var eni eniRecordSnapshot
if err := json.Unmarshal(entry.Value(), &eni); err != nil {
continue
}
if eni.SubnetId == "" || eni.PrivateIpAddress == "" {
continue
}
if _, ok := index[eni.SubnetId]; !ok {
index[eni.SubnetId] = make(map[string]string)
}
index[eni.SubnetId][eni.PrivateIpAddress] = eni.NetworkInterfaceId
}
return index, nil
}
func convertIPAMRecord(ctx KVContext, key string, ownerIndex map[string]map[string]string) error {
var lastErr error
for attempt := range ipamMigrationMaxRetries {
entry, err := ctx.KV.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
return nil
}
return fmt.Errorf("get %s: %w", key, err)
}
var raw ipamRecordV1V2
if err := json.Unmarshal(entry.Value(), &raw); err != nil {
return fmt.Errorf("unmarshal %s: %w", key, err)
}
// Detect schema: v2 records start with `[{`, v1 with `["`.
// Idempotent skip when already migrated.
if v2Entries, ok := tryDecodeV2(raw.Allocated); ok {
_ = v2Entries
return nil
}
var legacy []string
if len(raw.Allocated) > 0 {
if err := json.Unmarshal(raw.Allocated, &legacy); err != nil {
return fmt.Errorf("unmarshal legacy []string for %s: %w", key, err)
}
}
ownerByIP := ownerIndex[raw.SubnetId]
converted := make([]ipEntryV2, 0, len(legacy))
for _, ip := range legacy {
ownerID, found := ownerByIP[ip]
purpose := "eni-primary"
if !found {
ownerID = ""
purpose = "unknown"
ctx.Logger.Warn("internal IPAM v1→v2 migration: no owning ENI, tagging unknown",
"subnet", raw.SubnetId, "ip", ip)
}
converted = append(converted, ipEntryV2{IP: ip, Purpose: purpose, OwnerID: ownerID})
}
newRecord := ipamRecordV2{
SubnetId: raw.SubnetId,
CidrBlock: raw.CidrBlock,
Allocated: converted,
}
data, err := json.Marshal(newRecord)
if err != nil {
return fmt.Errorf("marshal %s: %w", key, err)
}
if _, err := ctx.KV.Update(key, data, entry.Revision()); err != nil {
if errors.Is(err, nats.ErrKeyExists) {
ctx.Logger.Warn("internal IPAM migration CAS conflict, retrying", "key", key, "attempt", attempt+1)
lastErr = err
continue
}
return fmt.Errorf("update %s: %w", key, err)
}
return nil
}
return fmt.Errorf("convert IPAM record %s: exceeded %d CAS retries: %w", key, ipamMigrationMaxRetries, lastErr)
}
// tryDecodeV2 returns true when raw decodes as []ipEntryV2 with at least one
// entry whose IP field is set — the cheapest reliable v2 detector. Empty
// arrays decode as either schema; we treat them as already migrated.
func tryDecodeV2(raw json.RawMessage) ([]ipEntryV2, bool) {
if len(raw) == 0 {
return nil, true
}
var entries []ipEntryV2
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, false
}
for _, e := range entries {
if e.IP != "" {
return entries, true
}
}
// Zero-length array — already v2-shaped.
if len(entries) == 0 {
return entries, true
}
return nil, false
}
+158
View File
@@ -0,0 +1,158 @@
package migrate
import (
"encoding/json"
"log/slog"
"testing"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupIPAMMigrationKV(t *testing.T) (nats.KeyValue, nats.JetStreamContext) {
t.Helper()
_, nc := startTestNATS(t)
js, err := nc.JetStream()
require.NoError(t, err)
kv := createTestBucket(t, nc, ipamBucket)
return kv, js
}
func runIPAMMigration(t *testing.T, kv nats.KeyValue, js nats.JetStreamContext) {
t.Helper()
for _, m := range DefaultRegistry.kvMigrations[ipamBucket] {
if m.FromVersion == 1 && m.ToVersion == 2 {
require.NoError(t, m.Run(KVContext{KV: kv, JetStream: js, Logger: slog.Default()}))
return
}
}
t.Fatalf("v1→v2 migration not registered for %s", ipamBucket)
}
func seedENIBucket(t *testing.T, js nats.JetStreamContext, enis []eniRecordSnapshot) {
t.Helper()
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: eniBucket, History: 1})
require.NoError(t, err)
for _, eni := range enis {
data, err := json.Marshal(eni)
require.NoError(t, err)
_, err = kv.Put(eni.NetworkInterfaceId, data)
require.NoError(t, err)
}
}
func TestIPAMMigration_ConvertsV1ToV2WithOwnerBackfill(t *testing.T) {
kv, js := setupIPAMMigrationKV(t)
seedENIBucket(t, js, []eniRecordSnapshot{
{NetworkInterfaceId: "eni-aaa", SubnetId: "subnet-1", PrivateIpAddress: "10.0.1.4"},
{NetworkInterfaceId: "eni-bbb", SubnetId: "subnet-1", PrivateIpAddress: "10.0.1.5"},
{NetworkInterfaceId: "eni-ccc", SubnetId: "subnet-2", PrivateIpAddress: "10.0.2.4"},
})
type legacyRecord struct {
SubnetId string `json:"subnet_id"`
CidrBlock string `json:"cidr_block"`
Allocated []string `json:"allocated"`
}
subnet1 := legacyRecord{
SubnetId: "subnet-1",
CidrBlock: "10.0.1.0/24",
Allocated: []string{"10.0.1.4", "10.0.1.5", "10.0.1.99"}, // .99 has no owning ENI
}
data, err := json.Marshal(subnet1)
require.NoError(t, err)
_, err = kv.Put("subnet-1", data)
require.NoError(t, err)
subnet2 := legacyRecord{
SubnetId: "subnet-2",
CidrBlock: "10.0.2.0/24",
Allocated: []string{"10.0.2.4"},
}
data, err = json.Marshal(subnet2)
require.NoError(t, err)
_, err = kv.Put("subnet-2", data)
require.NoError(t, err)
runIPAMMigration(t, kv, js)
entry, err := kv.Get("subnet-1")
require.NoError(t, err)
var got ipamRecordV2
require.NoError(t, json.Unmarshal(entry.Value(), &got))
require.Len(t, got.Allocated, 3)
assert.Equal(t, "10.0.1.4", got.Allocated[0].IP)
assert.Equal(t, "eni-primary", got.Allocated[0].Purpose)
assert.Equal(t, "eni-aaa", got.Allocated[0].OwnerID)
assert.Equal(t, "10.0.1.5", got.Allocated[1].IP)
assert.Equal(t, "eni-bbb", got.Allocated[1].OwnerID)
assert.Equal(t, "10.0.1.99", got.Allocated[2].IP)
assert.Equal(t, "unknown", got.Allocated[2].Purpose, "orphan IP tagged unknown")
assert.Empty(t, got.Allocated[2].OwnerID)
entry, err = kv.Get("subnet-2")
require.NoError(t, err)
require.NoError(t, json.Unmarshal(entry.Value(), &got))
require.Len(t, got.Allocated, 1)
assert.Equal(t, "eni-ccc", got.Allocated[0].OwnerID)
}
func TestIPAMMigration_IdempotentOnAlreadyV2(t *testing.T) {
kv, js := setupIPAMMigrationKV(t)
v2 := ipamRecordV2{
SubnetId: "subnet-1",
CidrBlock: "10.0.1.0/24",
Allocated: []ipEntryV2{
{IP: "10.0.1.4", Purpose: "eni-primary", OwnerID: "eni-aaa"},
},
}
data, err := json.Marshal(v2)
require.NoError(t, err)
_, err = kv.Put("subnet-1", data)
require.NoError(t, err)
rev := getKVRevision(t, kv, "subnet-1")
runIPAMMigration(t, kv, js)
newRev := getKVRevision(t, kv, "subnet-1")
assert.Equal(t, rev, newRev, "no write when record is already v2")
}
func TestIPAMMigration_NoJetStream_TagsUnknown(t *testing.T) {
_, nc := startTestNATS(t)
kv := createTestBucket(t, nc, ipamBucket)
type legacyRecord struct {
SubnetId string `json:"subnet_id"`
CidrBlock string `json:"cidr_block"`
Allocated []string `json:"allocated"`
}
rec := legacyRecord{
SubnetId: "subnet-1",
CidrBlock: "10.0.1.0/24",
Allocated: []string{"10.0.1.4"},
}
data, err := json.Marshal(rec)
require.NoError(t, err)
_, err = kv.Put("subnet-1", data)
require.NoError(t, err)
// Run migration with nil JetStream — schema conversion still runs but
// every entry becomes Purpose=unknown.
runIPAMMigration(t, kv, nil)
entry, err := kv.Get("subnet-1")
require.NoError(t, err)
var got ipamRecordV2
require.NoError(t, json.Unmarshal(entry.Value(), &got))
require.Len(t, got.Allocated, 1)
assert.Equal(t, "unknown", got.Allocated[0].Purpose)
}
func TestIPAMMigration_EmptyBucket(t *testing.T) {
kv, js := setupIPAMMigrationKV(t)
runIPAMMigration(t, kv, js)
}
+17 -3
View File
@@ -21,9 +21,12 @@ type KVMigration struct {
}
// KVContext provides KV migration functions access to the bucket being migrated.
// JetStream is non-nil only when the caller used RunKVWithJetStream — used by
// migrations that need to read sibling buckets (e.g. owner-attribution lookups).
type KVContext struct {
KV nats.KeyValue
Logger *slog.Logger
KV nats.KeyValue
JetStream nats.JetStreamContext
Logger *slog.Logger
}
// ConfigMigration represents a versioned transformation of on-disk config files.
@@ -97,6 +100,13 @@ func (r *Registry) RegisterConfig(target string, m ConfigMigration) {
})
}
// RunKVWithJetStream is RunKV with a JetStream handle attached to each
// migration's KVContext, enabling cross-bucket reads (e.g. owner-attribution
// during a backfill). Prefer plain RunKV when the migration is self-contained.
func (r *Registry) RunKVWithJetStream(bucket string, kv nats.KeyValue, js nats.JetStreamContext, targetVersion int) error {
return r.runKV(bucket, kv, js, targetVersion)
}
// RunKV executes all pending migrations for a KV bucket up to targetVersion.
// It reads the current version via utils.ReadVersion, runs each migration in
// order, and stamps the version via utils.WriteVersion after each step.
@@ -109,6 +119,10 @@ func (r *Registry) RegisterConfig(target string, m ConfigMigration) {
// an error — migration authors must register migrations before bumping version
// constants.
func (r *Registry) RunKV(bucket string, kv nats.KeyValue, targetVersion int) error {
return r.runKV(bucket, kv, nil, targetVersion)
}
func (r *Registry) runKV(bucket string, kv nats.KeyValue, js nats.JetStreamContext, targetVersion int) error {
current, err := utils.ReadVersion(kv)
if err != nil {
return fmt.Errorf("read version for %s: %w", bucket, err)
@@ -168,7 +182,7 @@ func (r *Registry) RunKV(bucket string, kv nats.KeyValue, targetVersion int) err
logger := slog.Default()
for _, m := range pending {
logger.Info("Running KV migration", "bucket", bucket, "from", m.FromVersion, "to", m.ToVersion, "description", m.Description)
ctx := KVContext{KV: kv, Logger: logger}
ctx := KVContext{KV: kv, JetStream: js, Logger: logger}
if err := m.Run(ctx); err != nil {
return fmt.Errorf("KV migration %s %d→%d failed: %w", bucket, m.FromVersion, m.ToVersion, err)
}
+49
View File
@@ -0,0 +1,49 @@
package external
import (
"context"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/network/policy"
)
// EIPManager attaches/detaches EIP NAT rules. Thin L5 wrapper over policy.NATManager
// that adds the flows-barrier so callers see the EIP reachable on return.
type EIPManager interface {
AttachEIP(ctx context.Context, eip policy.EIPSpec) error
DetachEIP(ctx context.Context, vpcID, logicalIP string) error
}
type eipManager struct {
nat policy.NATManager
barrier FlowsBarrier
}
var _ EIPManager = (*eipManager)(nil)
// NewEIPManager constructs an EIPManager. barrier may be nil (no wait).
func NewEIPManager(nat policy.NATManager, barrier FlowsBarrier) (EIPManager, error) {
if nat == nil {
return nil, errors.New("EIPManager: NATManager required")
}
if barrier == nil {
barrier = func() error { return nil }
}
return &eipManager{nat: nat, barrier: barrier}, nil
}
func (m *eipManager) AttachEIP(ctx context.Context, eip policy.EIPSpec) error {
if eip.VPCID == "" || eip.ExternalIP == "" || eip.LogicalIP == "" {
return fmt.Errorf("AttachEIP: VPCID, ExternalIP, LogicalIP all required")
}
if err := m.nat.AddEIP(ctx, eip); err != nil {
return err
}
_ = m.barrier()
return nil
}
func (m *eipManager) DetachEIP(ctx context.Context, vpcID, logicalIP string) error {
return m.nat.DeleteEIP(ctx, vpcID, logicalIP)
}
+63
View File
@@ -0,0 +1,63 @@
package external
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/spinifex/network/ovn/mock"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
"github.com/mulgadc/spinifex/spinifex/network/policy"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
func TestNewEIPManager_RejectsNilNAT(t *testing.T) {
_, err := NewEIPManager(nil, nil)
require.Error(t, err)
}
func TestEIPManager_AttachAndDetach(t *testing.T) {
ctx := context.Background()
m := mock.New()
require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")}))
nm, _ := policy.NewNATManager(m, policy.NATModeDistributed)
barrierCalls := 0
mgr, err := NewEIPManager(nm, func() error { barrierCalls++; return nil })
require.NoError(t, err)
require.NoError(t, mgr.AttachEIP(ctx, policy.EIPSpec{
VPCID: "vpc-1", ExternalIP: "1.2.3.4", LogicalIP: "10.0.0.5",
PortName: "port-a", MAC: "aa:bb:cc:dd:ee:ff",
}))
var found *nbdb.NAT
for _, n := range m.NATs {
if n.LogicalIP == "10.0.0.5" {
found = n
}
}
require.NotNil(t, found)
assert.Equal(t, 1, barrierCalls)
require.NoError(t, mgr.DetachEIP(ctx, "vpc-1", "10.0.0.5"))
for _, n := range m.NATs {
assert.NotEqual(t, "10.0.0.5", n.LogicalIP)
}
}
func TestEIPManager_AttachRejectsEmptyFields(t *testing.T) {
nm, _ := policy.NewNATManager(mock.New(), policy.NATModeDistributed)
mgr, _ := NewEIPManager(nm, nil)
require.Error(t, mgr.AttachEIP(context.Background(), policy.EIPSpec{}))
}
func TestEIPManager_DetachIdempotent(t *testing.T) {
ctx := context.Background()
m := mock.New()
require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")}))
nm, _ := policy.NewNATManager(m, policy.NATModeDistributed)
mgr, _ := NewEIPManager(nm, nil)
require.NoError(t, mgr.DetachEIP(ctx, "vpc-1", "10.0.0.99"))
}
+4
View File
@@ -0,0 +1,4 @@
// Package external is L5 of the spinifex network stack: VPC-facing external
// connectivity (IGW, EIP, NAT Gateway). Uses L1 (ovn), L3 (policy), and L0
// (host) uplink CIDR resolved at startup.
package external
+14
View File
@@ -0,0 +1,14 @@
package external
import (
"net"
)
func ipv4ToUint32(ip net.IP) uint32 {
v := ip.To4()
return uint32(v[0])<<24 | uint32(v[1])<<16 | uint32(v[2])<<8 | uint32(v[3])
}
func uint32ToIPv4(n uint32) net.IP {
return net.IPv4(byte(n>>24&0xff), byte(n>>16&0xff), byte(n>>8&0xff), byte(n&0xff)).To4()
}
+274
View File
@@ -0,0 +1,274 @@
package external
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/network/ovn"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
"github.com/mulgadc/spinifex/spinifex/network/policy"
"github.com/mulgadc/spinifex/spinifex/network/topology"
"github.com/mulgadc/spinifex/spinifex/utils"
)
// FlowsBarrier blocks until ovn-northd compiles NB→SB and chassis install flows.
// Production wraps `ovn-nbctl --wait=hv sync`; tests stub.
type FlowsBarrier func() error
// IGWManager attaches/detaches Internet Gateways to VPCs. Idempotent.
type IGWManager interface {
AttachIGW(ctx context.Context, spec IGWSpec) error
DetachIGW(ctx context.Context, vpcID string) error
}
// IGWManagerConfig is the construction-time bag for igwManager.
// FlowsBarrier defaults to a no-op when nil.
type IGWManagerConfig struct {
OVN ovn.Client
Routes policy.RouteManager
NAT policy.NATManager
Pool *ExternalPoolConfig
Allocator GatewayIPAllocator
Chassis []string
NATMode policy.NATMode
FlowsBarrier FlowsBarrier
}
type igwManager struct {
ovn ovn.Client
routes policy.RouteManager
nat policy.NATManager
pool *ExternalPoolConfig
allocator GatewayIPAllocator
chassis []string
natMode policy.NATMode
barrier FlowsBarrier
}
var _ IGWManager = (*igwManager)(nil)
// NewIGWManager constructs an IGWManager from cfg.
func NewIGWManager(cfg IGWManagerConfig) (IGWManager, error) {
switch {
case cfg.OVN == nil:
return nil, errors.New("IGWManager: OVN client required")
case cfg.Routes == nil:
return nil, errors.New("IGWManager: RouteManager required")
case cfg.NAT == nil:
return nil, errors.New("IGWManager: NATManager required")
case cfg.Allocator == nil:
return nil, errors.New("IGWManager: GatewayIPAllocator required")
case cfg.NATMode == policy.NATModeUnknown:
return nil, errors.New("IGWManager: NATMode unknown; resolve from host.Wiring.UplinkMode()")
}
barrier := cfg.FlowsBarrier
if barrier == nil {
barrier = func() error { return nil }
}
return &igwManager{
ovn: cfg.OVN,
routes: cfg.Routes,
nat: cfg.NAT,
pool: cfg.Pool,
allocator: cfg.Allocator,
chassis: cfg.Chassis,
natMode: cfg.NATMode,
barrier: barrier,
}, nil
}
// AttachIGW wires external connectivity for spec.VPCID: external switch +
// localnet + gateway LRP + default route + chassis + flows barrier.
// Idempotent: returns nil if the external switch already exists.
func (m *igwManager) AttachIGW(ctx context.Context, spec IGWSpec) error {
if spec.VPCID == "" {
return errors.New("AttachIGW: VPCID required")
}
extSwitchName := topology.ExternalSwitch(spec.VPCID)
extPortName := topology.ExternalLocalnetPort(spec.VPCID)
gwPortName := topology.GatewayRouterPort(spec.VPCID)
switchGWPortName := topology.GatewaySwitchPort(spec.VPCID)
routerName := topology.VPCRouter(spec.VPCID)
extSwitch := &nbdb.LogicalSwitch{
Name: extSwitchName,
ExternalIDs: map[string]string{
"spinifex:vpc_id": spec.VPCID,
"spinifex:igw_id": spec.InternetGatewayID,
"spinifex:role": "external",
},
}
existingSwitch, err := m.ovn.EnsureLogicalSwitch(ctx, extSwitch)
if err != nil {
return fmt.Errorf("ensure external switch %s: %w", extSwitchName, err)
}
if existingSwitch.UUID != extSwitch.UUID {
slog.Debug("external: IGW topology already exists, skipping",
"vpc_id", spec.VPCID, "ext_switch", extSwitchName)
return nil
}
localnetOpts := map[string]string{"network_name": "external"}
if m.natMode == policy.NATModeCentralized {
localnetOpts["nat-addresses"] = "router"
}
if err := m.ovn.CreateLogicalSwitchPort(ctx, extSwitchName, &nbdb.LogicalSwitchPort{
Name: extPortName,
Type: "localnet",
Addresses: []string{"unknown"},
Options: localnetOpts,
ExternalIDs: map[string]string{
"spinifex:vpc_id": spec.VPCID,
"spinifex:igw_id": spec.InternetGatewayID,
},
}); err != nil {
_ = m.ovn.DeleteLogicalSwitch(ctx, extSwitchName)
return fmt.Errorf("create localnet port %s: %w", extPortName, err)
}
gwNetwork, wanNexthop, gwLrpIP, err := m.resolveGatewayNetwork(ctx, spec.VPCID)
if err != nil {
_ = m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, extPortName)
_ = m.ovn.DeleteLogicalSwitch(ctx, extSwitchName)
return err
}
lrpExtIDs := map[string]string{
"spinifex:vpc_id": spec.VPCID,
"spinifex:igw_id": spec.InternetGatewayID,
"spinifex:role": "gateway",
}
if gwLrpIP != "" {
lrpExtIDs[gatewayIPExtIDKey] = gwLrpIP
}
if err := m.ovn.CreateLogicalRouterPort(ctx, routerName, &nbdb.LogicalRouterPort{
Name: gwPortName,
MAC: utils.HashMAC(gwPortName),
Networks: []string{gwNetwork},
ExternalIDs: lrpExtIDs,
}); err != nil {
_ = m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, extPortName)
_ = m.ovn.DeleteLogicalSwitch(ctx, extSwitchName)
return fmt.Errorf("create gateway router port %s: %w", gwPortName, err)
}
if err := m.ovn.CreateLogicalSwitchPort(ctx, extSwitchName, &nbdb.LogicalSwitchPort{
Name: switchGWPortName,
Type: "router",
Addresses: []string{"router"},
Options: map[string]string{"router-port": gwPortName},
ExternalIDs: map[string]string{
"spinifex:vpc_id": spec.VPCID,
"spinifex:igw_id": spec.InternetGatewayID,
},
}); err != nil {
_ = m.ovn.DeleteLogicalRouterPort(ctx, routerName, gwPortName)
_ = m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, extPortName)
_ = m.ovn.DeleteLogicalSwitch(ctx, extSwitchName)
return fmt.Errorf("create switch gateway port %s: %w", switchGWPortName, err)
}
if err := m.routes.AddDefaultRoute(ctx, spec.VPCID, wanNexthop, gwPortName); err != nil {
return fmt.Errorf("add default route on %s: %w", routerName, err)
}
if len(m.chassis) == 0 {
slog.Warn("external: no chassis configured — gateway port has no chassis binding, external traffic will not flow",
"vpc_id", spec.VPCID, "gw_port", gwPortName)
}
for i, chassis := range m.chassis {
priority := max(20-(i*5), 1)
if err := m.ovn.SetGatewayChassis(ctx, gwPortName, chassis, priority); err != nil {
slog.Warn("external: failed to set gateway chassis",
"gw_port", gwPortName, "chassis", chassis, "priority", priority, "err", err)
}
}
slog.Info("external: attached internet gateway",
"vpc_id", spec.VPCID,
"igw_id", spec.InternetGatewayID,
"ext_switch", extSwitchName,
"gw_port", gwPortName,
"lrp_network", gwNetwork,
"wan_nexthop", wanNexthop,
"chassis_count", len(m.chassis),
)
_ = m.barrier()
return nil
}
// DetachIGW reverses AttachIGW. Idempotent.
func (m *igwManager) DetachIGW(ctx context.Context, vpcID string) error {
if vpcID == "" {
return errors.New("DetachIGW: vpcID required")
}
extSwitchName := topology.ExternalSwitch(vpcID)
extPortName := topology.ExternalLocalnetPort(vpcID)
gwPortName := topology.GatewayRouterPort(vpcID)
switchGWPortName := topology.GatewaySwitchPort(vpcID)
routerName := topology.VPCRouter(vpcID)
if err := m.routes.DeleteDefaultRoute(ctx, vpcID); err != nil {
slog.Warn("external: delete default route failed", "router", routerName, "err", err)
}
if router, err := m.ovn.GetLogicalRouter(ctx, routerName); err == nil {
vpcCIDR := router.ExternalIDs["spinifex:cidr"]
if vpcCIDR != "" {
if err := m.nat.DeleteSNAT(ctx, vpcID, vpcCIDR); err != nil {
slog.Warn("external: delete IGW SNAT failed", "router", routerName, "cidr", vpcCIDR, "err", err)
}
}
} else {
slog.Warn("external: get router for NAT cleanup failed", "router", routerName, "err", err)
}
if err := m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, switchGWPortName); err != nil {
slog.Warn("external: delete switch gateway port failed", "port", switchGWPortName, "err", err)
}
if err := m.ovn.DeleteLogicalRouterPort(ctx, routerName, gwPortName); err != nil {
slog.Warn("external: delete gateway router port failed", "port", gwPortName, "err", err)
}
if err := m.ovn.DeleteLogicalSwitchPort(ctx, extSwitchName, extPortName); err != nil {
slog.Warn("external: delete localnet port failed", "port", extPortName, "err", err)
}
if err := m.ovn.DeleteLogicalSwitch(ctx, extSwitchName); err != nil {
return fmt.Errorf("delete external switch %s: %w", extSwitchName, err)
}
if err := m.allocator.Release(ctx, vpcID); err != nil {
slog.Warn("external: allocator release failed", "vpc_id", vpcID, "err", err)
}
slog.Info("external: detached internet gateway", "vpc_id", vpcID)
return nil
}
// resolveGatewayNetwork picks LRP Networks/nexthop/IP. Distributed: link-local.
// Centralised: WAN-subnet IP (LRP is on-wire).
func (m *igwManager) resolveGatewayNetwork(ctx context.Context, vpcID string) (network, nexthop, gwIP string, err error) {
network = linkLocalGatewayNetwork
nexthop = linkLocalGatewayNexthop
if m.pool != nil && m.pool.Gateway != "" {
nexthop = m.pool.Gateway
}
if m.natMode != policy.NATModeCentralized || m.pool == nil {
return network, nexthop, "", nil
}
ip, prefix, ok, allocErr := m.allocator.Allocate(ctx, vpcID, m.pool)
if allocErr != nil {
return "", "", "", fmt.Errorf("allocate gateway LRP IP: %w", allocErr)
}
if !ok {
return network, nexthop, "", nil
}
return fmt.Sprintf("%s/%d", ip, prefix), nexthop, ip, nil
}
+211
View File
@@ -0,0 +1,211 @@
package external
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/spinifex/network/ovn/mock"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
"github.com/mulgadc/spinifex/spinifex/network/policy"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
func seedVPCRouter(t *testing.T, m *mock.Client, vpcID, cidr string) {
t.Helper()
extIDs := map[string]string{"spinifex:vpc_id": vpcID}
if cidr != "" {
extIDs["spinifex:cidr"] = cidr
}
require.NoError(t, m.CreateLogicalRouter(context.Background(), &nbdb.LogicalRouter{
Name: topology.VPCRouter(vpcID),
ExternalIDs: extIDs,
}))
}
func newTestIGWManager(t *testing.T, m *mock.Client, mode policy.NATMode, pool *ExternalPoolConfig, allocator GatewayIPAllocator, chassis []string) (IGWManager, *int) {
t.Helper()
nm, err := policy.NewNATManager(m, mode)
require.NoError(t, err)
barrierCalls := 0
mgr, err := NewIGWManager(IGWManagerConfig{
OVN: m,
Routes: policy.NewRouteManager(m),
NAT: nm,
Pool: pool,
Allocator: allocator,
Chassis: chassis,
NATMode: mode,
FlowsBarrier: func() error {
barrierCalls++
return nil
},
})
require.NoError(t, err)
return mgr, &barrierCalls
}
func TestNewIGWManager_RejectsMissingDeps(t *testing.T) {
_, err := NewIGWManager(IGWManagerConfig{NATMode: policy.NATModeDistributed})
require.Error(t, err)
nm, _ := policy.NewNATManager(mock.New(), policy.NATModeDistributed)
_, err = NewIGWManager(IGWManagerConfig{
OVN: mock.New(), Routes: policy.NewRouteManager(mock.New()), NAT: nm,
Allocator: LinkLocalAllocator{},
})
require.Error(t, err, "NATMode unknown must be rejected")
}
func TestAttachIGW_Distributed_LinkLocalLRP(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24}
mgr, calls := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, []string{"chassis-a", "chassis-b"})
require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
// External switch + ports created.
_, err := m.GetLogicalSwitch(ctx, topology.ExternalSwitch("vpc-1"))
require.NoError(t, err)
localnet, err := m.GetLogicalSwitchPort(ctx, topology.ExternalLocalnetPort("vpc-1"))
require.NoError(t, err)
assert.Equal(t, "external", localnet.Options["network_name"])
_, hasNat := localnet.Options["nat-addresses"]
assert.False(t, hasNat, "distributed mode must NOT set nat-addresses")
// Gateway LRP exists with link-local network.
lrp, err := m.GetLogicalRouterPort(ctx, topology.GatewayRouterPort("vpc-1"))
require.NoError(t, err)
assert.Equal(t, []string{linkLocalGatewayNetwork}, lrp.Networks)
_, hasGwIP := lrp.ExternalIDs[gatewayIPExtIDKey]
assert.False(t, hasGwIP, "link-local LRP must not record a gateway IP")
// Default route points at pool.Gateway, OutputPort pinned.
route, err := m.FindStaticRoute(ctx, topology.VPCRouter("vpc-1"), "0.0.0.0/0")
require.NoError(t, err)
require.NotNil(t, route)
assert.Equal(t, pool.Gateway, route.Nexthop)
require.NotNil(t, route.OutputPort)
assert.Equal(t, topology.GatewayRouterPort("vpc-1"), *route.OutputPort)
// Gateway chassis set once per chassis.
assert.Equal(t, 2, m.SetGatewayChassisCalls)
// Flows barrier ran.
assert.Equal(t, 1, *calls)
}
func TestAttachIGW_Centralized_AllocatesGwLrpIP(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
pool := &ExternalPoolConfig{
Name: "p", Gateway: "192.168.1.1", PrefixLen: 24,
GwLrpRangeStart: "192.168.1.240", GwLrpRangeEnd: "192.168.1.243",
}
mgr, _ := newTestIGWManager(t, m, policy.NATModeCentralized, pool, NewStaticRangeAllocator(m), []string{"chassis-a"})
require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
localnet, err := m.GetLogicalSwitchPort(ctx, topology.ExternalLocalnetPort("vpc-1"))
require.NoError(t, err)
assert.Equal(t, "router", localnet.Options["nat-addresses"], "centralized mode must set nat-addresses=router")
lrp, err := m.GetLogicalRouterPort(ctx, topology.GatewayRouterPort("vpc-1"))
require.NoError(t, err)
assert.Equal(t, []string{"192.168.1.240/24"}, lrp.Networks)
assert.Equal(t, "192.168.1.240", lrp.ExternalIDs[gatewayIPExtIDKey])
}
func TestAttachIGW_IsIdempotent(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, nil, LinkLocalAllocator{}, nil)
require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
// Still only one LRP.
lrps, err := m.ListLogicalRouterPorts(ctx)
require.NoError(t, err)
gwCount := 0
for _, lrp := range lrps {
if lrp.Name == topology.GatewayRouterPort("vpc-1") {
gwCount++
}
}
assert.Equal(t, 1, gwCount)
}
func TestAttachIGW_NoPoolNoChassis_UsesLinkLocalFallbacks(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, nil, LinkLocalAllocator{}, nil)
require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
route, err := m.FindStaticRoute(ctx, topology.VPCRouter("vpc-1"), "0.0.0.0/0")
require.NoError(t, err)
require.NotNil(t, route)
assert.Equal(t, linkLocalGatewayNexthop, route.Nexthop)
assert.Equal(t, 0, m.SetGatewayChassisCalls)
}
func TestDetachIGW_RemovesEverything(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24}
mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, []string{"chassis-a"})
require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
require.NoError(t, mgr.DetachIGW(ctx, "vpc-1"))
_, err := m.GetLogicalSwitch(ctx, topology.ExternalSwitch("vpc-1"))
assert.Error(t, err)
_, err = m.GetLogicalRouterPort(ctx, topology.GatewayRouterPort("vpc-1"))
assert.Error(t, err)
route, err := m.FindStaticRoute(ctx, topology.VPCRouter("vpc-1"), "0.0.0.0/0")
require.NoError(t, err)
assert.Nil(t, route)
}
func TestDetachIGW_IdempotentOnAbsent(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, nil, LinkLocalAllocator{}, nil)
err := mgr.DetachIGW(ctx, "vpc-1")
require.Error(t, err) // external switch absent
}
type failingAllocator struct{}
var _ GatewayIPAllocator = failingAllocator{}
func (failingAllocator) Allocate(_ context.Context, _ string, _ *ExternalPoolConfig) (string, int, bool, error) {
return "", 0, false, errors.New("boom")
}
func (failingAllocator) Release(_ context.Context, _ string) error { return nil }
func TestAttachIGW_AllocatorFailureUnwindsExtSwitch(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24}
mgr, _ := newTestIGWManager(t, m, policy.NATModeCentralized, pool, failingAllocator{}, nil)
require.Error(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
_, err := m.GetLogicalSwitch(ctx, topology.ExternalSwitch("vpc-1"))
assert.Error(t, err, "external switch must be unwound on allocator failure")
}
+41
View File
@@ -0,0 +1,41 @@
package external
import (
"context"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/network/policy"
)
// NATGWManager attaches/detaches NAT Gateway SNAT rules. Thin L5 wrapper
// over policy.NATManager. OVN type is snat (vs dnat_and_snat for EIP).
type NATGWManager interface {
AttachNATGateway(ctx context.Context, gw policy.NATGWSpec) error
DetachNATGateway(ctx context.Context, vpcID, subnetCIDR string) error
}
type natGWManager struct {
nat policy.NATManager
}
var _ NATGWManager = (*natGWManager)(nil)
// NewNATGWManager constructs a NATGWManager.
func NewNATGWManager(nat policy.NATManager) (NATGWManager, error) {
if nat == nil {
return nil, errors.New("NATGWManager: NATManager required")
}
return &natGWManager{nat: nat}, nil
}
func (m *natGWManager) AttachNATGateway(ctx context.Context, gw policy.NATGWSpec) error {
if gw.VPCID == "" || gw.PublicIP == "" || gw.SubnetCIDR == "" {
return fmt.Errorf("AttachNATGateway: VPCID, PublicIP, SubnetCIDR all required")
}
return m.nat.AddNATGateway(ctx, gw)
}
func (m *natGWManager) DetachNATGateway(ctx context.Context, vpcID, subnetCIDR string) error {
return m.nat.DeleteNATGateway(ctx, vpcID, subnetCIDR)
}
+51
View File
@@ -0,0 +1,51 @@
package external
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/spinifex/network/ovn/mock"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
"github.com/mulgadc/spinifex/spinifex/network/policy"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
func TestNewNATGWManager_RejectsNilNAT(t *testing.T) {
_, err := NewNATGWManager(nil)
require.Error(t, err)
}
func TestNATGWManager_AttachAndDetach(t *testing.T) {
ctx := context.Background()
m := mock.New()
require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")}))
nm, _ := policy.NewNATManager(m, policy.NATModeCentralized)
mgr, err := NewNATGWManager(nm)
require.NoError(t, err)
require.NoError(t, mgr.AttachNATGateway(ctx, policy.NATGWSpec{
VPCID: "vpc-1", NATGatewayID: "nat-xyz",
PublicIP: "9.9.9.9", SubnetCIDR: "10.0.1.0/24",
}))
var found *nbdb.NAT
for _, n := range m.NATs {
if n.Type == "snat" && n.LogicalIP == "10.0.1.0/24" {
found = n
}
}
require.NotNil(t, found)
assert.Equal(t, "9.9.9.9", found.ExternalIP)
require.NoError(t, mgr.DetachNATGateway(ctx, "vpc-1", "10.0.1.0/24"))
require.NoError(t, mgr.DetachNATGateway(ctx, "vpc-1", "10.0.1.0/24")) // idempotent
}
func TestNATGWManager_AttachRejectsEmptyFields(t *testing.T) {
nm, _ := policy.NewNATManager(mock.New(), policy.NATModeCentralized)
mgr, _ := NewNATGWManager(nm)
require.Error(t, mgr.AttachNATGateway(context.Background(), policy.NATGWSpec{}))
}
+199
View File
@@ -0,0 +1,199 @@
package external
import (
"context"
"fmt"
"log/slog"
"net"
"github.com/mulgadc/spinifex/spinifex/network/ovn"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
// linkLocalGatewayNetwork is the link-local /30 the IGW gateway LRP carries
// in distributed-NAT mode. LRP IP never goes on the wire — per-VM dnat_and_snat
// with external_mac/logical_port handles upstream ARP per chassis.
// Centralised NAT requires a WAN-subnet IP from gw_lrp_range instead.
const linkLocalGatewayNetwork = "169.254.0.1/30"
const linkLocalGatewayNexthop = "169.254.0.2"
// gatewayIPExtIDKey is the LRP external_ids key for the allocated gateway IP.
const gatewayIPExtIDKey = "spinifex:gateway_ip"
// FindPool returns the first matching pool: AZ-scoped → region-scoped → unscoped.
func FindPool(pools []ExternalPoolConfig, region, az string) *ExternalPoolConfig {
for i := range pools {
p := &pools[i]
if p.AZ != "" && p.AZ == az && p.Region == region {
return p
}
}
for i := range pools {
p := &pools[i]
if p.AZ == "" && p.Region != "" && p.Region == region {
return p
}
}
for i := range pools {
p := &pools[i]
if p.Region == "" && p.AZ == "" {
return p
}
}
return nil
}
// GatewayIPAllocator resolves the gateway LRP IP for a VPC.
// ok=false means caller falls back to link-local; error means abort.
type GatewayIPAllocator interface {
Allocate(ctx context.Context, vpcID string, pool *ExternalPoolConfig) (ip string, prefixLen int, ok bool, err error)
Release(ctx context.Context, vpcID string) error
}
// LinkLocalAllocator always returns ok=false (distributed-NAT, link-local LRP).
type LinkLocalAllocator struct{}
var _ GatewayIPAllocator = LinkLocalAllocator{}
func (LinkLocalAllocator) Allocate(_ context.Context, _ string, _ *ExternalPoolConfig) (string, int, bool, error) {
return "", 0, false, nil
}
func (LinkLocalAllocator) Release(_ context.Context, _ string) error { return nil }
// StaticRangeAllocator picks the next free IP in pool.gw_lrp_range.
// Read-only: persistence is via IGWManager's LRP external_ids.
type StaticRangeAllocator struct {
OVN ovn.Client
}
var _ GatewayIPAllocator = (*StaticRangeAllocator)(nil)
func NewStaticRangeAllocator(client ovn.Client) *StaticRangeAllocator {
return &StaticRangeAllocator{OVN: client}
}
// Allocate returns the next free gateway LRP IP for vpcID. Idempotent: if the
// LRP already exists with a recorded allocation, returns the existing IP.
func (a *StaticRangeAllocator) Allocate(ctx context.Context, vpcID string, pool *ExternalPoolConfig) (string, int, bool, error) {
start, end, prefix, ok := gwLrpRange(pool)
if !ok {
return "", 0, false, nil
}
gwPortName := topology.GatewayRouterPort(vpcID)
lrps, err := a.OVN.ListLogicalRouterPorts(ctx)
if err != nil {
return "", 0, false, fmt.Errorf("list LRPs for gw IP allocation: %w", err)
}
used := make(map[uint32]struct{}, len(lrps))
for _, lrp := range lrps {
existing := lrp.ExternalIDs[gatewayIPExtIDKey]
if existing == "" {
continue
}
if lrp.Name == gwPortName {
return existing, prefix, true, nil
}
if v := net.ParseIP(existing).To4(); v != nil {
used[ipv4ToUint32(v)] = struct{}{}
}
}
startU := ipv4ToUint32(start)
endU := ipv4ToUint32(end)
for n := startU; n <= endU; n++ {
if _, taken := used[n]; taken {
continue
}
return uint32ToIPv4(n).String(), prefix, true, nil
}
return "", 0, false, fmt.Errorf("gw_lrp_range exhausted for pool %q (%s-%s)", pool.Name, pool.GwLrpRangeStart, pool.GwLrpRangeEnd)
}
// Release is a no-op — LRP external_id is source of truth, cleared on detach.
func (a *StaticRangeAllocator) Release(_ context.Context, _ string) error { return nil }
// gwLrpRange returns the gateway LRP IP range: explicit pool.GwLrpRange* if set,
// else last 16 host IPs of the WAN subnet (shifted below RangeStart on overlap).
func gwLrpRange(pool *ExternalPoolConfig) (start, end net.IP, prefix int, ok bool) {
if pool == nil {
return nil, nil, 0, false
}
prefix = pool.PrefixLen
if prefix <= 0 || prefix > 32 {
prefix = 24
}
if pool.GwLrpRangeStart != "" || pool.GwLrpRangeEnd != "" {
s := net.ParseIP(pool.GwLrpRangeStart).To4()
e := net.ParseIP(pool.GwLrpRangeEnd).To4()
if s != nil && e != nil && ipv4ToUint32(s) <= ipv4ToUint32(e) {
return s, e, prefix, true
}
slog.Warn("external: invalid explicit gw_lrp_range, attempting auto-derive",
"pool", pool.Name, "start", pool.GwLrpRangeStart, "end", pool.GwLrpRangeEnd)
}
gw := net.ParseIP(pool.Gateway).To4()
if gw == nil {
return nil, nil, 0, false
}
mask := net.CIDRMask(prefix, 32)
network := gw.Mask(mask)
bcast := make(net.IP, 4)
for i := range 4 {
bcast[i] = network[i] | ^mask[i]
}
bcastU := ipv4ToUint32(bcast)
if bcastU < 17 {
return nil, nil, 0, false
}
autoEndU := bcastU - 1
autoStartU := bcastU - 16
if pool.RangeStart != "" && pool.RangeEnd != "" {
rs := net.ParseIP(pool.RangeStart).To4()
re := net.ParseIP(pool.RangeEnd).To4()
if rs != nil && re != nil {
rsU := ipv4ToUint32(rs)
reU := ipv4ToUint32(re)
if autoEndU >= rsU && autoStartU <= reU {
if rsU < 17 {
return nil, nil, 0, false
}
autoEndU = rsU - 1
autoStartU = rsU - 16
}
}
}
netU := ipv4ToUint32(network)
if autoStartU <= netU {
autoStartU = netU + 1
}
if autoEndU >= bcastU {
autoEndU = bcastU - 1
}
if autoStartU > autoEndU {
return nil, nil, 0, false
}
gwU := ipv4ToUint32(gw)
if gwU >= autoStartU && gwU <= autoEndU {
switch gwU {
case autoStartU:
autoStartU++
case autoEndU:
autoEndU--
default:
autoStartU = gwU + 1
}
if autoStartU > autoEndU {
return nil, nil, 0, false
}
}
return uint32ToIPv4(autoStartU), uint32ToIPv4(autoEndU), prefix, true
}
+182
View File
@@ -0,0 +1,182 @@
package external
import (
"context"
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/spinifex/network/ovn/mock"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
func TestFindPool_AZBeatsRegionBeatsUnscoped(t *testing.T) {
pools := []ExternalPoolConfig{
{Name: "default"},
{Name: "region-us", Region: "us"},
{Name: "az-us-1a", Region: "us", AZ: "us-1a"},
}
assert.Equal(t, "az-us-1a", FindPool(pools, "us", "us-1a").Name)
assert.Equal(t, "region-us", FindPool(pools, "us", "us-1b").Name)
assert.Equal(t, "default", FindPool(pools, "eu", "eu-1a").Name)
}
func TestFindPool_NoMatchReturnsNil(t *testing.T) {
pools := []ExternalPoolConfig{{Name: "region-us", Region: "us"}}
assert.Nil(t, FindPool(pools, "eu", "eu-1a"))
}
func TestLinkLocalAllocator_AlwaysReturnsNotOK(t *testing.T) {
a := LinkLocalAllocator{}
ip, prefix, ok, err := a.Allocate(context.Background(), "vpc-1", &ExternalPoolConfig{Gateway: "192.168.1.1", PrefixLen: 24})
require.NoError(t, err)
assert.False(t, ok)
assert.Empty(t, ip)
assert.Zero(t, prefix)
require.NoError(t, a.Release(context.Background(), "vpc-1"))
}
func TestStaticRangeAllocator_ExplicitRange_AllocatesFirstFree(t *testing.T) {
ctx := context.Background()
m := mock.New()
a := NewStaticRangeAllocator(m)
pool := &ExternalPoolConfig{
Name: "p",
Gateway: "192.168.1.1",
PrefixLen: 24,
GwLrpRangeStart: "192.168.1.240",
GwLrpRangeEnd: "192.168.1.243",
}
ip, prefix, ok, err := a.Allocate(ctx, "vpc-1", pool)
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "192.168.1.240", ip)
assert.Equal(t, 24, prefix)
}
func TestStaticRangeAllocator_SkipsUsedIPs(t *testing.T) {
ctx := context.Background()
m := mock.New()
// Seed an existing LRP on a sibling VPC already holding .240.
require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-other")}))
require.NoError(t, m.CreateLogicalRouterPort(ctx, topology.VPCRouter("vpc-other"), &nbdb.LogicalRouterPort{
Name: topology.GatewayRouterPort("vpc-other"),
ExternalIDs: map[string]string{gatewayIPExtIDKey: "192.168.1.240"},
}))
a := NewStaticRangeAllocator(m)
pool := &ExternalPoolConfig{
Gateway: "192.168.1.1", PrefixLen: 24,
GwLrpRangeStart: "192.168.1.240", GwLrpRangeEnd: "192.168.1.243",
}
ip, _, ok, err := a.Allocate(ctx, "vpc-new", pool)
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "192.168.1.241", ip)
}
func TestStaticRangeAllocator_ReturnsExistingForSameVPC(t *testing.T) {
ctx := context.Background()
m := mock.New()
require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")}))
require.NoError(t, m.CreateLogicalRouterPort(ctx, topology.VPCRouter("vpc-1"), &nbdb.LogicalRouterPort{
Name: topology.GatewayRouterPort("vpc-1"),
ExternalIDs: map[string]string{gatewayIPExtIDKey: "192.168.1.242"},
}))
a := NewStaticRangeAllocator(m)
pool := &ExternalPoolConfig{
Gateway: "192.168.1.1", PrefixLen: 24,
GwLrpRangeStart: "192.168.1.240", GwLrpRangeEnd: "192.168.1.243",
}
ip, _, ok, err := a.Allocate(ctx, "vpc-1", pool)
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "192.168.1.242", ip)
}
func TestStaticRangeAllocator_RangeExhaustedReturnsError(t *testing.T) {
ctx := context.Background()
m := mock.New()
require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-other")}))
require.NoError(t, m.CreateLogicalRouterPort(ctx, topology.VPCRouter("vpc-other"), &nbdb.LogicalRouterPort{
Name: topology.GatewayRouterPort("vpc-other"),
ExternalIDs: map[string]string{gatewayIPExtIDKey: "192.168.1.240"},
}))
a := NewStaticRangeAllocator(m)
pool := &ExternalPoolConfig{
Gateway: "192.168.1.1", PrefixLen: 24,
GwLrpRangeStart: "192.168.1.240", GwLrpRangeEnd: "192.168.1.240",
}
_, _, ok, err := a.Allocate(ctx, "vpc-new", pool)
require.Error(t, err)
assert.False(t, ok)
}
func TestStaticRangeAllocator_NoRangeReturnsNotOK(t *testing.T) {
a := NewStaticRangeAllocator(mock.New())
_, _, ok, err := a.Allocate(context.Background(), "vpc-1", &ExternalPoolConfig{})
require.NoError(t, err)
assert.False(t, ok)
}
func TestGwLrpRange_AutoDeriveTopOfSubnet(t *testing.T) {
pool := &ExternalPoolConfig{Gateway: "192.168.1.1", PrefixLen: 24}
start, end, prefix, ok := gwLrpRange(pool)
require.True(t, ok)
assert.Equal(t, "192.168.1.239", start.String())
assert.Equal(t, "192.168.1.254", end.String())
assert.Equal(t, 24, prefix)
}
func TestGwLrpRange_ShiftsBelowEIPRangeOnOverlap(t *testing.T) {
pool := &ExternalPoolConfig{
Gateway: "192.168.1.1",
PrefixLen: 24,
RangeStart: "192.168.1.240",
RangeEnd: "192.168.1.250",
}
start, end, _, ok := gwLrpRange(pool)
require.True(t, ok)
assert.Equal(t, "192.168.1.224", start.String())
assert.Equal(t, "192.168.1.239", end.String())
}
func TestGwLrpRange_ExplicitTakesPriority(t *testing.T) {
pool := &ExternalPoolConfig{
Gateway: "192.168.1.1", PrefixLen: 24,
GwLrpRangeStart: "192.168.1.10",
GwLrpRangeEnd: "192.168.1.20",
}
start, end, _, ok := gwLrpRange(pool)
require.True(t, ok)
assert.Equal(t, "192.168.1.10", start.String())
assert.Equal(t, "192.168.1.20", end.String())
}
func TestGwLrpRange_NilPoolReturnsNotOK(t *testing.T) {
_, _, _, ok := gwLrpRange(nil)
assert.False(t, ok)
}
func TestGwLrpRange_GatewayInsideAutoRangeIsExcluded(t *testing.T) {
// Tiny /29 forces the auto-range to overlap the gateway IP.
pool := &ExternalPoolConfig{Gateway: "192.168.1.1", PrefixLen: 29}
start, end, _, ok := gwLrpRange(pool)
require.True(t, ok)
startU := ipv4ToUint32(start)
endU := ipv4ToUint32(end)
gwU := ipv4ToUint32(net.ParseIP(pool.Gateway).To4())
for n := startU; n <= endU; n++ {
assert.NotEqual(t, gwU, n, "gateway IP must not appear in auto-range")
}
}
+24
View File
@@ -0,0 +1,24 @@
package external
// ExternalPoolConfig describes one external IP pool wired to a VPC.
// Mirrors [external_pools] in spinifex.toml.
type ExternalPoolConfig struct {
Name string
RangeStart string
RangeEnd string
Gateway string
GatewayIP string
PrefixLen int
DNSServers []string
Region string
AZ string
GwLrpRangeStart string
GwLrpRangeEnd string
}
// IGWSpec is the L5 input for IGWManager.AttachIGW / DetachIGW.
// InternetGatewayID is propagated into OVN external_ids for reconcile correlation.
type IGWSpec struct {
VPCID string
InternetGatewayID string
}
+33
View File
@@ -0,0 +1,33 @@
package host
import (
"fmt"
"net"
"strings"
)
// GetBridgeIPv4 returns the first IPv4 address on the named Linux bridge.
// Returns "", nil if the bridge does not exist.
func GetBridgeIPv4(bridgeName string) (string, error) {
iface, err := net.InterfaceByName(bridgeName)
if err != nil {
if strings.Contains(err.Error(), "no such network interface") {
return "", nil
}
return "", fmt.Errorf("lookup %s: %w", bridgeName, err)
}
addrs, err := iface.Addrs()
if err != nil {
return "", fmt.Errorf("list addrs on %s: %w", bridgeName, err)
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipNet.IP.To4() != nil {
return ipNet.IP.String(), nil
}
}
return "", fmt.Errorf("no IPv4 address on %s", bridgeName)
}
+24
View File
@@ -0,0 +1,24 @@
package host
import "testing"
func TestGetBridgeIPv4_Loopback(t *testing.T) {
// "lo" is always present and has 127.0.0.1
ip, err := GetBridgeIPv4("lo")
if err != nil {
t.Fatalf("GetBridgeIPv4(lo): %v", err)
}
if ip != "127.0.0.1" {
t.Errorf("GetBridgeIPv4(lo) = %q, want 127.0.0.1", ip)
}
}
func TestGetBridgeIPv4_NonexistentBridge(t *testing.T) {
ip, err := GetBridgeIPv4("br-nonexistent-test-xyz")
if err != nil {
t.Fatalf("expected nil error for absent bridge, got: %v", err)
}
if ip != "" {
t.Errorf("expected empty IP for absent bridge, got %q", ip)
}
}
+34
View File
@@ -0,0 +1,34 @@
package host
import (
"context"
"fmt"
)
// ensureBridges idempotently creates br-int and uplinkBridge with OVN settings.
// br-int needs fail-mode=secure + disable-in-band (canonical OVN integration bridge).
// Shared by Physical and Veth — only uplink port wiring differs.
func ensureBridges(ctx context.Context, r Runner, uplinkBridge string) error {
if uplinkBridge == "" {
return fmt.Errorf("host: ensureBridges UplinkBridge required")
}
if _, err := r.Run(ctx, "ovs-vsctl", "--may-exist", "add-br", "br-int"); err != nil {
return fmt.Errorf("create br-int: %w", err)
}
if _, err := r.Run(ctx, "ovs-vsctl", "set", "Bridge", "br-int", "fail-mode=secure"); err != nil {
return fmt.Errorf("set br-int fail-mode: %w", err)
}
if _, err := r.Run(ctx, "ovs-vsctl", "set", "Bridge", "br-int", "other-config:disable-in-band=true"); err != nil {
return fmt.Errorf("set br-int disable-in-band: %w", err)
}
if _, err := r.Run(ctx, "ip", "link", "set", "br-int", "up"); err != nil {
return fmt.Errorf("bring up br-int: %w", err)
}
if _, err := r.Run(ctx, "ovs-vsctl", "--may-exist", "add-br", uplinkBridge); err != nil {
return fmt.Errorf("create %s: %w", uplinkBridge, err)
}
if _, err := r.Run(ctx, "ip", "link", "set", uplinkBridge, "up"); err != nil {
return fmt.Errorf("bring up %s: %w", uplinkBridge, err)
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package host
import (
"os"
"strings"
"github.com/mulgadc/spinifex/spinifex/utils"
)
// OVNHealth reports the readiness of OVN networking on this compute node.
type OVNHealth struct {
BrIntExists bool `json:"br_int_exists"`
OVNControllerUp bool `json:"ovn_controller_up"`
ChassisID string `json:"chassis_id,omitempty"`
EncapIP string `json:"encap_ip,omitempty"`
OVNRemote string `json:"ovn_remote,omitempty"`
}
// HealthStatus probes local OVS/OVN state to determine network readiness.
func HealthStatus() OVNHealth {
status := OVNHealth{}
if err := utils.SudoCommand("ovs-vsctl", "br-exists", "br-int").Run(); err == nil {
status.BrIntExists = true
}
// Trixie OVN moved the pid file to /var/run/ovn/; older installs use
// /var/run/openvswitch/. Probe at runtime so the same binary works on both.
ovnTarget := "ovn-controller"
if _, err := os.Stat("/var/run/ovn/ovn-controller.pid"); err == nil {
ovnTarget = "/var/run/ovn/ovn-controller"
}
if out, err := utils.SudoCommand("ovs-appctl", "-t", ovnTarget, "version").CombinedOutput(); err == nil && len(out) > 0 {
status.OVNControllerUp = true
}
if out, err := utils.SudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:system-id").CombinedOutput(); err == nil {
status.ChassisID = strings.Trim(strings.TrimSpace(string(out)), "\"")
}
if out, err := utils.SudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:ovn-encap-ip").CombinedOutput(); err == nil {
status.EncapIP = strings.Trim(strings.TrimSpace(string(out)), "\"")
}
if out, err := utils.SudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:ovn-remote").CombinedOutput(); err == nil {
status.OVNRemote = strings.Trim(strings.TrimSpace(string(out)), "\"")
}
return status
}
+77
View File
@@ -0,0 +1,77 @@
// Package host is L0 of the spinifex network stack: kernel/OVS host wiring.
// Owns OVS bridges (br-int, br-ext), the uplink port attaching br-ext to WAN,
// and the external CIDR. Bridge mode (physical vs veth) is resolved here and
// invisible above L0. See ADR-0006 (S2, S3).
package host
import (
"context"
"fmt"
"net"
"net/netip"
)
// UplinkMode names the physical wiring strategy attaching br-ext to the WAN.
type UplinkMode int
const (
UplinkModeUnknown UplinkMode = iota
// UplinkModePhysical enslaves the WAN NIC directly to br-ext (distributed NAT).
UplinkModePhysical
// UplinkModeVeth bridges a Linux bridge to br-ext via a veth pair (centralised NAT).
UplinkModeVeth
)
func (m UplinkMode) String() string {
switch m {
case UplinkModePhysical:
return "physical"
case UplinkModeVeth:
return "veth"
default:
return "unknown"
}
}
// Wiring owns the host's L0 network state. Methods are idempotent.
type Wiring interface {
// EnsureBridges ensures br-int and br-ext exist with the OVS settings
// ovn-controller requires (br-int: fail-mode=secure, disable-in-band).
EnsureBridges(ctx context.Context) error
// EnsureUplinkPort attaches the uplink port to br-ext and returns its MAC.
// L2 programs the gateway LRP with this MAC so the LRP shares L2 identity
// with the uplink (upstream switch MAC learning).
EnsureUplinkPort(ctx context.Context) (net.HardwareAddr, error)
// UplinkMode returns the configured mode. L3 reads once at startup
// (ADR-0006 S3); never re-reads at runtime.
UplinkMode() UplinkMode
// ExternalCIDR returns the IPv4 prefix on this node's uplink bridge.
// Only valid after EnsureUplinkPort succeeds (absorbs boot-race vs DHCP).
ExternalCIDR(ctx context.Context) (netip.Prefix, error)
}
// Runner executes a privileged host command and returns combined stdout/stderr.
// Stubbable for tests; production impl is execRunner in run.go.
type Runner interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
}
// InterfaceReader observes kernel network state via net.InterfaceByName / /sys/class/net.
type InterfaceReader interface {
// BridgeCIDR returns the first IPv4 prefix on the named link, or error if none.
BridgeCIDR(name string) (netip.Prefix, error)
// LinkMAC returns the hardware address of the named link.
LinkMAC(name string) (net.HardwareAddr, error)
// LinkMaster returns the master link name, or "" when none.
LinkMaster(name string) (string, error)
}
// ErrNoUplinkAddr is returned by ExternalCIDR when the WAN bridge has no IPv4 yet.
var ErrNoUplinkAddr = fmt.Errorf("uplink bridge has no IPv4 address")
+289
View File
@@ -0,0 +1,289 @@
package host
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"slices"
"strings"
"sync"
"testing"
)
// stubRunner records commands and returns scripted output by prefix.
type stubRunner struct {
mu sync.Mutex
calls []string
resp map[string]stubResp
}
type stubResp struct {
out []byte
err error
}
func newStubRunner() *stubRunner { return &stubRunner{resp: map[string]stubResp{}} }
func (s *stubRunner) expect(prefix string, out []byte, err error) {
s.resp[prefix] = stubResp{out: out, err: err}
}
func (s *stubRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
joined := strings.Join(append([]string{name}, args...), " ")
s.calls = append(s.calls, joined)
for prefix, r := range s.resp {
if strings.HasPrefix(joined, prefix) {
return r.out, r.err
}
}
return nil, fmt.Errorf("stubRunner: unexpected command %q", joined)
}
func (s *stubRunner) called(prefix string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
type stubReader struct {
cidrs map[string]netip.Prefix
macs map[string]net.HardwareAddr
master map[string]string
errs map[string]error
}
func newStubReader() *stubReader {
return &stubReader{
cidrs: map[string]netip.Prefix{},
macs: map[string]net.HardwareAddr{},
master: map[string]string{},
errs: map[string]error{},
}
}
func (r *stubReader) BridgeCIDR(name string) (netip.Prefix, error) {
if err, ok := r.errs["cidr:"+name]; ok {
return netip.Prefix{}, err
}
if p, ok := r.cidrs[name]; ok {
return p, nil
}
return netip.Prefix{}, fmt.Errorf("%w: %q", ErrNoUplinkAddr, name)
}
func (r *stubReader) LinkMAC(name string) (net.HardwareAddr, error) {
if err, ok := r.errs["mac:"+name]; ok {
return nil, err
}
if m, ok := r.macs[name]; ok {
return m, nil
}
return nil, fmt.Errorf("no MAC for %q", name)
}
func (r *stubReader) LinkMaster(name string) (string, error) {
if err, ok := r.errs["master:"+name]; ok {
return "", err
}
return r.master[name], nil
}
func mustMAC(t *testing.T, s string) net.HardwareAddr {
t.Helper()
mac, err := net.ParseMAC(s)
if err != nil {
t.Fatalf("parse MAC %q: %v", s, err)
}
return mac
}
func TestUplinkModeString(t *testing.T) {
cases := []struct {
m UplinkMode
want string
}{
{UplinkModePhysical, "physical"},
{UplinkModeVeth, "veth"},
{UplinkModeUnknown, "unknown"},
{UplinkMode(99), "unknown"},
}
for _, c := range cases {
if got := c.m.String(); got != c.want {
t.Errorf("UplinkMode(%d).String() = %q, want %q", c.m, got, c.want)
}
}
}
func TestPhysical_EnsureUplinkPort(t *testing.T) {
r := newStubRunner()
r.expect("ovs-vsctl port-to-br enp0s3", []byte("br-ext\n"), nil)
rd := newStubReader()
rd.macs["enp0s3"] = mustMAC(t, "aa:bb:cc:dd:ee:ff")
p := &Physical{
ExternalInterface: "enp0s3",
UplinkBridge: "br-ext",
Runner: r,
Reader: rd,
}
mac, err := p.EnsureUplinkPort(context.Background())
if err != nil {
t.Fatalf("EnsureUplinkPort: %v", err)
}
if mac.String() != "aa:bb:cc:dd:ee:ff" {
t.Errorf("got MAC %q, want aa:bb:cc:dd:ee:ff", mac)
}
if p.UplinkMode() != UplinkModePhysical {
t.Errorf("UplinkMode = %v, want physical", p.UplinkMode())
}
}
func TestPhysical_EnsureUplinkPort_WrongBridge(t *testing.T) {
r := newStubRunner()
r.expect("ovs-vsctl port-to-br enp0s3", []byte("br-other\n"), nil)
p := &Physical{
ExternalInterface: "enp0s3",
UplinkBridge: "br-ext",
Runner: r,
Reader: newStubReader(),
}
_, err := p.EnsureUplinkPort(context.Background())
if err == nil || !strings.Contains(err.Error(), "br-other") {
t.Fatalf("expected bridge mismatch error, got: %v", err)
}
}
func TestPhysical_ExternalCIDR(t *testing.T) {
rd := newStubReader()
want := netip.MustParsePrefix("192.0.2.5/24")
rd.cidrs["br-ext"] = want
p := &Physical{UplinkBridge: "br-ext", Reader: rd}
got, err := p.ExternalCIDR(context.Background())
if err != nil {
t.Fatalf("ExternalCIDR: %v", err)
}
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func TestPhysical_ExternalCIDR_NoAddr(t *testing.T) {
p := &Physical{UplinkBridge: "br-ext", Reader: newStubReader()}
_, err := p.ExternalCIDR(context.Background())
if !errors.Is(err, ErrNoUplinkAddr) {
t.Fatalf("expected ErrNoUplinkAddr, got: %v", err)
}
}
func TestVeth_EnsureUplinkPort(t *testing.T) {
r := newStubRunner()
r.expect("ovs-vsctl port-to-br veth-wan-ovs", []byte("br-ext\n"), nil)
rd := newStubReader()
rd.macs[VethOVSEnd] = mustMAC(t, "02:11:22:33:44:55")
rd.master[VethLinuxEnd] = "br-wan"
v := &Veth{
LinuxBridge: "br-wan",
UplinkBridge: "br-ext",
Runner: r,
Reader: rd,
}
mac, err := v.EnsureUplinkPort(context.Background())
if err != nil {
t.Fatalf("EnsureUplinkPort: %v", err)
}
if mac.String() != "02:11:22:33:44:55" {
t.Errorf("got MAC %q", mac)
}
if v.UplinkMode() != UplinkModeVeth {
t.Errorf("UplinkMode = %v, want veth", v.UplinkMode())
}
}
func TestVeth_EnsureUplinkPort_WrongMaster(t *testing.T) {
r := newStubRunner()
r.expect("ovs-vsctl port-to-br veth-wan-ovs", []byte("br-ext\n"), nil)
rd := newStubReader()
rd.macs[VethOVSEnd] = mustMAC(t, "02:11:22:33:44:55")
rd.master[VethLinuxEnd] = "br-other"
v := &Veth{LinuxBridge: "br-wan", UplinkBridge: "br-ext", Runner: r, Reader: rd}
_, err := v.EnsureUplinkPort(context.Background())
if err == nil || !strings.Contains(err.Error(), "br-other") {
t.Fatalf("expected master mismatch error, got: %v", err)
}
}
func TestVeth_ExternalCIDR(t *testing.T) {
rd := newStubReader()
want := netip.MustParsePrefix("10.0.0.42/24")
rd.cidrs["br-wan"] = want
v := &Veth{LinuxBridge: "br-wan", UplinkBridge: "br-ext", Reader: rd}
got, err := v.ExternalCIDR(context.Background())
if err != nil {
t.Fatalf("ExternalCIDR: %v", err)
}
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func TestEnsureBridges_RunsExpectedOps(t *testing.T) {
r := newStubRunner()
for _, prefix := range []string{
"ovs-vsctl --may-exist add-br br-int",
"ovs-vsctl set Bridge br-int fail-mode=secure",
"ovs-vsctl set Bridge br-int other-config:disable-in-band=true",
"ip link set br-int up",
"ovs-vsctl --may-exist add-br br-ext",
"ip link set br-ext up",
} {
r.expect(prefix, nil, nil)
}
p := &Physical{ExternalInterface: "enp0s3", UplinkBridge: "br-ext", Runner: r, Reader: newStubReader()}
if err := p.EnsureBridges(context.Background()); err != nil {
t.Fatalf("EnsureBridges: %v", err)
}
for _, want := range []string{
"ovs-vsctl --may-exist add-br br-int",
"ovs-vsctl set Bridge br-int fail-mode=secure",
"ovs-vsctl --may-exist add-br br-ext",
} {
if !r.called(want) {
t.Errorf("missing call %q; got %v", want, r.calls)
}
}
}
func TestEnsureBridges_FailureSurfacesContext(t *testing.T) {
r := newStubRunner()
r.expect("ovs-vsctl --may-exist add-br br-int", []byte("permission denied"), errors.New("exit 1"))
p := &Physical{ExternalInterface: "enp0s3", UplinkBridge: "br-ext", Runner: r}
err := p.EnsureBridges(context.Background())
if err == nil || !strings.Contains(err.Error(), "create br-int") {
t.Fatalf("expected create br-int wrap, got: %v", err)
}
}
var (
_ Wiring = (*Physical)(nil)
_ Wiring = (*Veth)(nil)
_ Runner = execRunner{}
_ InterfaceReader = kernelReader{}
)
func TestUplinkModes_ConstantSet(t *testing.T) {
modes := []UplinkMode{UplinkModeUnknown, UplinkModePhysical, UplinkModeVeth}
if !slices.Contains(modes, UplinkModePhysical) || !slices.Contains(modes, UplinkModeVeth) {
t.Fatal("expected physical and veth in supported mode set")
}
}
+53
View File
@@ -0,0 +1,53 @@
package host
import (
"fmt"
"strings"
"github.com/mulgadc/spinifex/spinifex/utils"
)
// SetIPSecCertPaths writes the local IPsec peer cert pointers into the OVS
// Open_vSwitch table. ovs-monitor-ipsec reads these to materialise strongSwan
// configs for every Geneve tunnel ovn-controller programs.
func SetIPSecCertPaths(certPath, keyPath, caCertPath string) error {
out, err := utils.SudoCommand("ovs-vsctl", "set", "Open_vSwitch", ".",
fmt.Sprintf("other_config:certificate=%s", certPath),
fmt.Sprintf("other_config:private_key=%s", keyPath),
fmt.Sprintf("other_config:ca_cert=%s", caCertPath),
).CombinedOutput()
if err != nil {
return fmt.Errorf("set IPsec cert pointers: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// EnableIPSecEncapsulation flips ipsec_encapsulation=true on the local
// Open_vSwitch row. Caller must first verify ovs-monitor-ipsec is active —
// flipping without a live daemon creates a silent-drop trap.
func EnableIPSecEncapsulation() error {
out, err := utils.SudoCommand("ovs-vsctl", "set", "Open_vSwitch", ".",
"other_config:ipsec_encapsulation=true",
).CombinedOutput()
if err != nil {
return fmt.Errorf("enable ipsec_encapsulation: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// SetNBGlobalIPSec writes NB_Global.ipsec on the local OVN NB DB. Triggers
// ovn-controller on every chassis to add options:remote_name to Geneve tunnel
// ports, which is what ovs-monitor-ipsec keys off to materialise strongSwan
// connections. Only the management node has a local NB socket; callers gate
// the call on socket presence.
func SetNBGlobalIPSec(enable bool) error {
val := "false"
if enable {
val = "true"
}
out, err := utils.SudoCommand("ovn-nbctl", "set", "NB_Global", ".", "ipsec="+val).CombinedOutput()
if err != nil {
return fmt.Errorf("set NB_Global ipsec=%s: %s: %w", val, strings.TrimSpace(string(out)), err)
}
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package host
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/utils"
)
// systemctlActiveTimeout bounds the wait for openvswitch-ipsec.service to become active.
var systemctlActiveTimeout = 5 * time.Second
// ovnNBSocketPath gates the NB_Global ipsec write to the management node.
var ovnNBSocketPath = "/run/ovn/ovnnb_db.sock"
// EnableOVNIPSec wires the local IPsec peer cert and flips ipsec_encapsulation=true.
// Idempotent. Single-node clusters short-circuit (no Geneve tunnels to encrypt).
// Lives in L0 per ADR-0006 S8 (IPSec is OVN-native only; SA lifecycle invisible above L0).
func EnableOVNIPSec(configPath string, clusterConfig *config.ClusterConfig) error {
if configPath == "" {
return fmt.Errorf("config path unset")
}
if clusterConfig != nil && len(clusterConfig.Nodes) <= 1 {
slog.Info("ipsec: single-node cluster, skipping enable (no peers)")
return nil
}
configDir := filepath.Dir(configPath)
certPath, keyPath := admin.IPSecCertPaths(configDir)
caCertPath := filepath.Join(configDir, "ca.pem")
for _, p := range []string{certPath, keyPath, caCertPath} {
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("missing IPsec credential %s: %w", p, err)
}
}
if err := ensureOVSMonitorIPSecActive(); err != nil {
return fmt.Errorf("ovs-monitor-ipsec: %w", err)
}
if err := SetIPSecCertPaths(certPath, keyPath, caCertPath); err != nil {
return err
}
if err := EnableIPSecEncapsulation(); err != nil {
return err
}
// NB_Global.ipsec is cluster-wide; only the management node has a local NB socket.
// Without this flag, ovn-controller skips adding options:remote_name to Geneve
// tunnels and ovs-monitor-ipsec never materialises strongSwan connections.
if _, err := os.Stat(ovnNBSocketPath); err == nil {
if err := SetNBGlobalIPSec(true); err != nil {
return err
}
}
slog.Info("OVN native IPsec enabled on intra-AZ Geneve",
"cert", certPath,
"key", keyPath,
"ca", caCertPath,
)
return nil
}
// ensureOVSMonitorIPSecActive polls openvswitch-ipsec.service for "active".
// If inactive, refuse to flip ipsec_encapsulation=true (would silently drop traffic).
func ensureOVSMonitorIPSecActive() error {
deadline := time.Now().Add(systemctlActiveTimeout)
var lastOut string
for time.Now().Before(deadline) {
out, _ := utils.SudoCommand("systemctl", "is-active", "openvswitch-ipsec.service").CombinedOutput()
lastOut = strings.TrimSpace(string(out))
if lastOut == "active" {
return nil
}
time.Sleep(250 * time.Millisecond)
}
return fmt.Errorf("not active after %s: %s (provision via scripts/setup-ovn.sh)", systemctlActiveTimeout, lastOut)
}
+169
View File
@@ -0,0 +1,169 @@
package host
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func multiNodeClusterConfig() *config.ClusterConfig {
return &config.ClusterConfig{
Node: "node1",
Nodes: map[string]config.Config{
"node1": {},
"node2": {},
},
}
}
type recordingSudo struct {
runs [][]string
activeOutput string
}
func (r *recordingSudo) stub(name string, args ...string) *exec.Cmd {
r.runs = append(r.runs, append([]string{name}, args...))
if name == "systemctl" && len(args) >= 1 && args[0] == "is-active" {
out := r.activeOutput
if out == "" {
out = "active\n"
}
return exec.Command("printf", "%s", out)
}
return exec.Command("true")
}
func TestEnableOVNIPSec(t *testing.T) {
recorder := &recordingSudo{}
t.Cleanup(utils.SetSudoCommandForTest(recorder.stub))
configDir := t.TempDir()
configPath := filepath.Join(configDir, "spinifex.toml")
require.NoError(t, os.WriteFile(configPath, []byte("placeholder"), 0600))
for _, rel := range []string{"ca.pem", "ipsec/peer.pem", "ipsec/peer.key"} {
full := filepath.Join(configDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0750))
require.NoError(t, os.WriteFile(full, []byte("x"), 0600))
}
// Worker path: no local NB socket → no ovn-nbctl call.
origNBSock := ovnNBSocketPath
ovnNBSocketPath = filepath.Join(configDir, "no-such-socket")
t.Cleanup(func() { ovnNBSocketPath = origNBSock })
require.NoError(t, EnableOVNIPSec(configPath, multiNodeClusterConfig()))
require.Len(t, recorder.runs, 3)
assert.Equal(t, []string{"systemctl", "is-active", "openvswitch-ipsec.service"}, recorder.runs[0])
for _, run := range recorder.runs[1:] {
assert.Equal(t, "ovs-vsctl", run[0])
assert.Equal(t, "set", run[1])
assert.Equal(t, "Open_vSwitch", run[2])
}
joined := strings.Join(recorder.runs[1], " ")
assert.Contains(t, joined, "other_config:certificate="+filepath.Join(configDir, "ipsec", "peer.pem"))
assert.Contains(t, joined, "other_config:private_key="+filepath.Join(configDir, "ipsec", "peer.key"))
assert.Contains(t, joined, "other_config:ca_cert="+filepath.Join(configDir, "ca.pem"))
assert.Contains(t, strings.Join(recorder.runs[2], " "), "other_config:ipsec_encapsulation=true")
}
func TestEnableOVNIPSec_Management(t *testing.T) {
recorder := &recordingSudo{}
t.Cleanup(utils.SetSudoCommandForTest(recorder.stub))
configDir := t.TempDir()
configPath := filepath.Join(configDir, "spinifex.toml")
require.NoError(t, os.WriteFile(configPath, []byte("placeholder"), 0600))
for _, rel := range []string{"ca.pem", "ipsec/peer.pem", "ipsec/peer.key"} {
full := filepath.Join(configDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0750))
require.NoError(t, os.WriteFile(full, []byte("x"), 0600))
}
sockPath := filepath.Join(configDir, "ovnnb_db.sock")
require.NoError(t, os.WriteFile(sockPath, []byte{}, 0600))
origNBSock := ovnNBSocketPath
ovnNBSocketPath = sockPath
t.Cleanup(func() { ovnNBSocketPath = origNBSock })
require.NoError(t, EnableOVNIPSec(configPath, multiNodeClusterConfig()))
require.Len(t, recorder.runs, 4)
assert.Equal(t, []string{"ovn-nbctl", "set", "NB_Global", ".", "ipsec=true"}, recorder.runs[3])
}
func TestEnableOVNIPSec_SingleNodeSkip(t *testing.T) {
t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
t.Fatalf("utils.SudoCommand must not run on single-node short-circuit; got %s %v", name, args)
return exec.Command("true")
}))
configDir := t.TempDir()
configPath := filepath.Join(configDir, "spinifex.toml")
require.NoError(t, os.WriteFile(configPath, []byte("placeholder"), 0600))
cfg := &config.ClusterConfig{
Node: "node1",
Nodes: map[string]config.Config{"node1": {}},
}
require.NoError(t, EnableOVNIPSec(configPath, cfg))
}
func TestEnableOVNIPSec_MonitorIPSecInactive(t *testing.T) {
recorder := &recordingSudo{activeOutput: "inactive\n"}
t.Cleanup(utils.SetSudoCommandForTest(recorder.stub))
origTimeout := systemctlActiveTimeout
systemctlActiveTimeout = 100 * time.Millisecond
t.Cleanup(func() { systemctlActiveTimeout = origTimeout })
configDir := t.TempDir()
configPath := filepath.Join(configDir, "spinifex.toml")
require.NoError(t, os.WriteFile(configPath, []byte("placeholder"), 0600))
for _, rel := range []string{"ca.pem", "ipsec/peer.pem", "ipsec/peer.key"} {
full := filepath.Join(configDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0750))
require.NoError(t, os.WriteFile(full, []byte("x"), 0600))
}
err := EnableOVNIPSec(configPath, multiNodeClusterConfig())
require.Error(t, err)
assert.Contains(t, err.Error(), "ovs-monitor-ipsec")
assert.Contains(t, err.Error(), "not active")
// ovs-vsctl must NOT run — flip without live daemon is the silent-drop trap.
for _, run := range recorder.runs {
assert.NotEqual(t, "ovs-vsctl", run[0], "ovs-vsctl invoked despite dead daemon: %v", run)
}
}
func TestEnableOVNIPSec_MissingCert(t *testing.T) {
t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
t.Fatalf("utils.SudoCommand must not run when cert files are absent")
return exec.Command("true")
}))
configDir := t.TempDir()
configPath := filepath.Join(configDir, "spinifex.toml")
require.NoError(t, os.WriteFile(configPath, []byte("placeholder"), 0600))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "ca.pem"), []byte("x"), 0600))
err := EnableOVNIPSec(configPath, multiNodeClusterConfig())
require.Error(t, err)
assert.Contains(t, err.Error(), "missing IPsec credential")
}
func TestEnableOVNIPSec_NoConfigPath(t *testing.T) {
err := EnableOVNIPSec("", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "config path unset")
}
+83
View File
@@ -0,0 +1,83 @@
package host
import (
"context"
"fmt"
"log/slog"
"net"
"net/netip"
"strings"
)
// Physical implements Wiring for the "WAN NIC enslaved to br-ext" model.
// Each chassis owns its uplink; NAT can be distributed.
type Physical struct {
// ExternalInterface is the WAN NIC (e.g. "enp0s3") on UplinkBridge.
ExternalInterface string
// UplinkBridge holds the WAN NIC and IPv4 uplink (conventionally "br-ext").
UplinkBridge string
Runner Runner
Reader InterfaceReader
}
var _ Wiring = (*Physical)(nil)
func (p *Physical) runner() Runner {
if p.Runner != nil {
return p.Runner
}
return NewExecRunner()
}
func (p *Physical) reader() InterfaceReader {
if p.Reader != nil {
return p.Reader
}
return NewKernelReader()
}
// EnsureBridges creates br-int and br-ext idempotently with OVN settings.
func (p *Physical) EnsureBridges(ctx context.Context) error {
return ensureBridges(ctx, p.runner(), p.UplinkBridge)
}
// EnsureUplinkPort verifies the WAN NIC is on UplinkBridge and returns its MAC.
// The gateway LRP shares this MAC so upstream switch CAM stays consistent.
func (p *Physical) EnsureUplinkPort(ctx context.Context) (net.HardwareAddr, error) {
if p.ExternalInterface == "" {
return nil, fmt.Errorf("host.Physical: ExternalInterface required")
}
if p.UplinkBridge == "" {
return nil, fmt.Errorf("host.Physical: UplinkBridge required")
}
out, err := p.runner().Run(ctx, "ovs-vsctl", "port-to-br", p.ExternalInterface)
if err != nil {
return nil, fmt.Errorf("port-to-br %q: %w", p.ExternalInterface, err)
}
br := strings.TrimSpace(string(out))
if br != p.UplinkBridge {
return nil, fmt.Errorf("host.Physical: %q on bridge %q, expected %q", p.ExternalInterface, br, p.UplinkBridge)
}
mac, err := p.reader().LinkMAC(p.ExternalInterface)
if err != nil {
return nil, fmt.Errorf("read MAC for %q: %w", p.ExternalInterface, err)
}
slog.Info("host.Physical: uplink verified", "iface", p.ExternalInterface, "bridge", br, "mac", mac.String())
return mac, nil
}
// UplinkMode returns UplinkModePhysical (distributed NAT eligible).
func (p *Physical) UplinkMode() UplinkMode { return UplinkModePhysical }
// ExternalCIDR returns the IPv4 prefix on UplinkBridge.
// ErrNoUplinkAddr means the OS hasn't assigned an IP yet — caller should retry.
func (p *Physical) ExternalCIDR(_ context.Context) (netip.Prefix, error) {
if p.UplinkBridge == "" {
return netip.Prefix{}, fmt.Errorf("host.Physical: UplinkBridge required")
}
return p.reader().BridgeCIDR(p.UplinkBridge)
}
+108
View File
@@ -0,0 +1,108 @@
package host
import (
"fmt"
"log/slog"
"os"
"sort"
"strconv"
"strings"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
)
// OVSPlumber implements vm.NetworkPlumber using system commands
// (ip, ovs-vsctl); tests use a mock satisfying the same interface.
type OVSPlumber struct{}
var _ vm.NetworkPlumber = (*OVSPlumber)(nil)
// NewOVSPlumber returns the default plumber wired to utils.SudoCommand.
func NewOVSPlumber() *OVSPlumber { return &OVSPlumber{} }
// SetupTap creates the kernel tap, brings it up, and attaches it to spec.Bridge.
// The pre-create del-port is unconditional because OVS conf.db survives reboot
// while kernel taps don't, so a /sys/class/net check would miss orphan ports.
// Rejected alternative: `--may-exist add-port` — it would silently keep stale
// external_ids from a prior launch with a different ENI/MAC.
func (p *OVSPlumber) SetupTap(spec vm.TapSpec) error {
if err := utils.SudoCommand("ovs-vsctl", "--if-exists", "del-port", spec.Bridge, spec.Name).Run(); err != nil {
slog.Warn("Pre-create del-port failed (continuing)", "tap", spec.Name, "bridge", spec.Bridge, "err", err)
}
if _, err := os.Stat("/sys/class/net/" + spec.Name); err == nil {
if err := utils.SudoCommand("ip", "tuntap", "del", "dev", spec.Name, "mode", "tap").Run(); err != nil {
slog.Warn("Pre-create tap del failed (continuing)", "tap", spec.Name, "err", err)
}
}
// When the daemon runs non-root (production: User=spinifex-daemon), child
// QEMU processes inherit that uid without CAP_NET_ADMIN. The kernel's tun
// attach check then requires the tap to be owned by the calling euid
// (drivers/net/tun.c: tun_chr_open + TUNSETIFF perm check). Without the
// `user` flag the tap defaults to root:root and qemu's TUNSETIFF fails
// with EPERM ("could not configure /dev/net/tun: Operation not permitted").
// Pass numeric uid/gid directly — user.Current() can fail silently under
// hardened systemd units / static builds, dropping the flags and leaving
// the tap root-owned (LB microvm in cell-17/tofu-examples). Numeric values
// always work and skip the NSS lookup. Root daemons skip this — they have
// CAP_NET_ADMIN ambient.
addArgs := []string{"tuntap", "add", "dev", spec.Name, "mode", "tap"}
if uid := os.Geteuid(); uid != 0 {
addArgs = append(addArgs, "user", strconv.Itoa(uid), "group", strconv.Itoa(os.Getegid()))
}
if out, err := utils.SudoCommand("ip", addArgs...).CombinedOutput(); err != nil {
return fmt.Errorf("create tap %s: %s: %w", spec.Name, strings.TrimSpace(string(out)), err)
}
if out, err := utils.SudoCommand("ip", "link", "set", spec.Name, "up").CombinedOutput(); err != nil {
if cleanErr := utils.SudoCommand("ip", "tuntap", "del", "dev", spec.Name, "mode", "tap").Run(); cleanErr != nil {
slog.Warn("Failed to clean up tap after bring-up failure", "tap", spec.Name, "err", cleanErr)
}
return fmt.Errorf("bring up tap %s: %s: %w", spec.Name, strings.TrimSpace(string(out)), err)
}
addPortArgs := []string{"add-port", spec.Bridge, spec.Name}
if len(spec.ExternalIDs) > 0 {
addPortArgs = append(addPortArgs, "--", "set", "Interface", spec.Name)
// Sort keys for deterministic command construction (test assertions, log readability).
keys := make([]string, 0, len(spec.ExternalIDs))
for k := range spec.ExternalIDs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
addPortArgs = append(addPortArgs, fmt.Sprintf("external_ids:%s=%s", k, spec.ExternalIDs[k]))
}
}
if out, err := utils.SudoCommand("ovs-vsctl", addPortArgs...).CombinedOutput(); err != nil {
if cleanErr := utils.SudoCommand("ip", "tuntap", "del", "dev", spec.Name, "mode", "tap").Run(); cleanErr != nil {
slog.Warn("Failed to clean up tap after OVS failure", "tap", spec.Name, "err", cleanErr)
}
return fmt.Errorf("add tap %s to %s: %s: %w", spec.Name, spec.Bridge, strings.TrimSpace(string(out)), err)
}
slog.Info("Tap plumbing complete", "tap", spec.Name, "bridge", spec.Bridge, "external_ids", spec.ExternalIDs)
return nil
}
// CleanupTap removes the named tap from OVS (any bridge) and the kernel.
// Idempotent: callers may invoke for an instance that never reached SetupTap
// (e.g. terminate that races mid-launch).
func (p *OVSPlumber) CleanupTap(name string) error {
if out, err := utils.SudoCommand("ovs-vsctl", "--if-exists", "del-port", name).CombinedOutput(); err != nil {
slog.Warn("Failed to remove tap from OVS", "tap", name, "err", err, "out", strings.TrimSpace(string(out)))
}
if _, err := os.Stat("/sys/class/net/" + name); os.IsNotExist(err) {
slog.Info("Tap already absent", "tap", name)
return nil
}
if out, err := utils.SudoCommand("ip", "tuntap", "del", "dev", name, "mode", "tap").CombinedOutput(); err != nil {
return fmt.Errorf("delete tap %s: %s: %w", name, strings.TrimSpace(string(out)), err)
}
slog.Info("Tap cleanup complete", "tap", name)
return nil
}
+207
View File
@@ -0,0 +1,207 @@
package host
import (
"os/exec"
"slices"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
)
func TestOVSPlumber_SetupTap_AddPortArgs(t *testing.T) {
cases := []struct {
name string
spec vm.TapSpec
wantAddPort []string // expected tail of ovs-vsctl args (after add-port <bridge> <name>)
}{
{
name: "vpc style with external_ids",
spec: vm.TapSpec{
Name: "tapeni-test",
Bridge: "br-int",
ExternalIDs: map[string]string{
"iface-id": "port-eni-test",
"attached-mac": "02:00:00:aa:bb:cc",
},
},
wantAddPort: []string{
"add-port", "br-int", "tapeni-test",
"--", "set", "Interface", "tapeni-test",
"external_ids:attached-mac=02:00:00:aa:bb:cc",
"external_ids:iface-id=port-eni-test",
},
},
{
name: "mgmt style without external_ids",
spec: vm.TapSpec{
Name: "mg-test",
Bridge: "br-mgmt",
},
wantAddPort: []string{"add-port", "br-mgmt", "mg-test"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var calls [][]string
t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
call := append([]string{name}, args...)
calls = append(calls, call)
return exec.Command("/bin/true")
}))
p := NewOVSPlumber()
if err := p.SetupTap(tc.spec); err != nil {
t.Fatalf("SetupTap: %v", err)
}
// Find the add-port invocation (last ovs-vsctl call).
var addPort []string
for _, c := range calls {
if len(c) >= 2 && c[0] == "ovs-vsctl" && c[1] != "--if-exists" {
addPort = c[1:]
}
}
if addPort == nil {
t.Fatalf("no add-port invocation captured; calls=%v", calls)
}
if !slices.Equal(addPort, tc.wantAddPort) {
t.Errorf("add-port args = %v, want %v", addPort, tc.wantAddPort)
}
})
}
}
// "lo" is always present so os.Stat hits the pre-create cleanup branch.
func TestOVSPlumber_SetupTap_PreExistingKernelTap(t *testing.T) {
failTuntapDel := true
t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
if name == "ip" && len(args) >= 2 && args[0] == "tuntap" && args[1] == "del" && failTuntapDel {
failTuntapDel = false
return exec.Command("/bin/false")
}
return exec.Command("/bin/true")
}))
p := NewOVSPlumber()
if err := p.SetupTap(vm.TapSpec{Name: "lo", Bridge: "br-int"}); err != nil {
t.Fatalf("SetupTap with stubs should succeed: %v", err)
}
}
func TestOVSPlumber_SetupTap_ErrorBranches(t *testing.T) {
cases := []struct {
name string
failMatch func(name string, args []string) bool
wantErr string
}{
{
name: "ovs del-port failure is logged-warn (continues)",
failMatch: func(name string, args []string) bool {
return name == "ovs-vsctl" && len(args) >= 1 && args[0] == "--if-exists"
},
wantErr: "", // pre-clear failure is swallowed; SetupTap returns nil
},
{
name: "tuntap add failure surfaces",
failMatch: func(name string, args []string) bool {
return name == "ip" && len(args) >= 2 && args[0] == "tuntap" && args[1] == "add"
},
wantErr: "create tap",
},
{
name: "ip link set up failure surfaces and triggers tap cleanup",
failMatch: func(name string, args []string) bool {
return name == "ip" && len(args) >= 1 && args[0] == "link"
},
wantErr: "bring up tap",
},
{
name: "ovs add-port failure surfaces and triggers tap cleanup",
failMatch: func(name string, args []string) bool {
return name == "ovs-vsctl" && len(args) >= 1 && args[0] == "add-port"
},
wantErr: "add tap",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
if tc.failMatch(name, args) {
return exec.Command("/bin/false")
}
return exec.Command("/bin/true")
}))
p := NewOVSPlumber()
err := p.SetupTap(vm.TapSpec{Name: "tap-test-noexist", Bridge: "br-int"})
if tc.wantErr == "" {
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
})
}
}
func TestOVSPlumber_CleanupTap_PresentKernelTap(t *testing.T) {
var sawTuntapDel bool
t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
if name == "ip" && len(args) >= 2 && args[0] == "tuntap" && args[1] == "del" {
sawTuntapDel = true
}
return exec.Command("/bin/true")
}))
p := NewOVSPlumber()
if err := p.CleanupTap("lo"); err != nil {
t.Fatalf("CleanupTap: %v", err)
}
if !sawTuntapDel {
t.Errorf("expected ip tuntap del to be invoked, got no call")
}
}
func TestOVSPlumber_CleanupTap_DelPortLoggedWarn(t *testing.T) {
t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
if name == "ovs-vsctl" {
return exec.Command("/bin/false")
}
return exec.Command("/bin/true")
}))
p := NewOVSPlumber()
if err := p.CleanupTap("tap-test-noexist"); err != nil {
t.Fatalf("CleanupTap: %v", err)
}
}
func TestOVSPlumber_CleanupTap_TuntapDelFailure(t *testing.T) {
t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
if name == "ip" && len(args) >= 2 && args[0] == "tuntap" && args[1] == "del" {
return exec.Command("/bin/false")
}
return exec.Command("/bin/true")
}))
p := NewOVSPlumber()
err := p.CleanupTap("lo")
if err == nil || !strings.Contains(err.Error(), "delete tap") {
t.Fatalf("expected 'delete tap' error, got %v", err)
}
}
// CleanupTap on a name with neither OVS port nor kernel tap must return nil.
func TestOVSPlumber_CleanupTap_MissingKernelTap(t *testing.T) {
p := NewOVSPlumber()
if err := p.CleanupTap("mg-test-noexist"); err != nil {
t.Fatalf("expected nil for missing kernel tap, got: %v", err)
}
}
+89
View File
@@ -0,0 +1,89 @@
package host
import (
"context"
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"strings"
)
// execRunner runs the command directly when uid 0, otherwise via sudo.
type execRunner struct{}
var _ Runner = execRunner{}
// NewExecRunner returns the default Runner.
func NewExecRunner() Runner { return execRunner{} }
func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
var cmd *exec.Cmd
if os.Getuid() == 0 {
cmd = exec.CommandContext(ctx, name, args...)
} else {
cmd = exec.CommandContext(ctx, "sudo", append([]string{name}, args...)...)
}
out, err := cmd.CombinedOutput()
if err != nil {
return out, fmt.Errorf("%s %s: %s: %w", name, strings.Join(args, " "), strings.TrimSpace(string(out)), err)
}
return out, nil
}
// kernelReader satisfies InterfaceReader via the live kernel.
type kernelReader struct{}
var _ InterfaceReader = kernelReader{}
// NewKernelReader returns the default InterfaceReader.
func NewKernelReader() InterfaceReader { return kernelReader{} }
func (kernelReader) BridgeCIDR(name string) (netip.Prefix, error) {
iface, err := net.InterfaceByName(name)
if err != nil {
return netip.Prefix{}, fmt.Errorf("interface %q: %w", name, err)
}
addrs, err := iface.Addrs()
if err != nil {
return netip.Prefix{}, fmt.Errorf("addrs %q: %w", name, err)
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
v4 := ipnet.IP.To4()
if v4 == nil {
continue
}
ones, _ := ipnet.Mask.Size()
addr, _ := netip.AddrFromSlice(v4)
return netip.PrefixFrom(addr, ones), nil
}
return netip.Prefix{}, fmt.Errorf("%w: %q", ErrNoUplinkAddr, name)
}
func (kernelReader) LinkMAC(name string) (net.HardwareAddr, error) {
iface, err := net.InterfaceByName(name)
if err != nil {
return nil, fmt.Errorf("interface %q: %w", name, err)
}
if len(iface.HardwareAddr) == 0 {
return nil, fmt.Errorf("interface %q: no hardware address", name)
}
return iface.HardwareAddr, nil
}
func (kernelReader) LinkMaster(name string) (string, error) {
target, err := os.Readlink(filepath.Join("/sys/class/net", name, "master"))
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return filepath.Base(target), nil
}
+100
View File
@@ -0,0 +1,100 @@
package host
import (
"context"
"fmt"
"log/slog"
"net"
"net/netip"
"strings"
)
// veth-wan-ovs is the OVS-side endpoint; veth-wan-br is the Linux-bridge-side endpoint.
const (
VethOVSEnd = "veth-wan-ovs"
VethLinuxEnd = "veth-wan-br"
)
// Veth implements Wiring for the "Linux bridge bridged into OVS via veth" model.
// OS-managed Linux bridge holds the IPv4 uplink; NAT is centralised (one chassis owns the IP).
type Veth struct {
// LinuxBridge holds the OS-assigned WAN IPv4 (e.g. "br-wan").
LinuxBridge string
// UplinkBridge is the OVS bridge ovn-bridge-mappings targets (conventionally "br-ext").
UplinkBridge string
Runner Runner
Reader InterfaceReader
}
var _ Wiring = (*Veth)(nil)
func (v *Veth) runner() Runner {
if v.Runner != nil {
return v.Runner
}
return NewExecRunner()
}
func (v *Veth) reader() InterfaceReader {
if v.Reader != nil {
return v.Reader
}
return NewKernelReader()
}
// EnsureBridges ensures br-int and UplinkBridge exist. The veth pair is
// provisioned by spinifex-veth-wan.service.
func (v *Veth) EnsureBridges(ctx context.Context) error {
return ensureBridges(ctx, v.runner(), v.UplinkBridge)
}
// EnsureUplinkPort verifies the veth pair wiring and returns the OVS-side MAC.
// The gateway LRP uses this MAC so OVS-egress frames carry the L2 src the
// upstream router learned.
func (v *Veth) EnsureUplinkPort(ctx context.Context) (net.HardwareAddr, error) {
if v.LinuxBridge == "" {
return nil, fmt.Errorf("host.Veth: LinuxBridge required")
}
if v.UplinkBridge == "" {
return nil, fmt.Errorf("host.Veth: UplinkBridge required")
}
out, err := v.runner().Run(ctx, "ovs-vsctl", "port-to-br", VethOVSEnd)
if err != nil {
return nil, fmt.Errorf("port-to-br %q: not on OVS — is spinifex-veth-wan.service running? %w", VethOVSEnd, err)
}
br := strings.TrimSpace(string(out))
if br != v.UplinkBridge {
return nil, fmt.Errorf("host.Veth: %q on bridge %q, expected %q", VethOVSEnd, br, v.UplinkBridge)
}
master, err := v.reader().LinkMaster(VethLinuxEnd)
if err != nil {
return nil, fmt.Errorf("host.Veth: read master for %q: %w", VethLinuxEnd, err)
}
if master != v.LinuxBridge {
return nil, fmt.Errorf("host.Veth: %q master is %q, expected %q", VethLinuxEnd, master, v.LinuxBridge)
}
mac, err := v.reader().LinkMAC(VethOVSEnd)
if err != nil {
return nil, fmt.Errorf("read MAC for %q: %w", VethOVSEnd, err)
}
slog.Info("host.Veth: uplink verified",
"ovs_end", VethOVSEnd, "linux_end", VethLinuxEnd,
"linux_bridge", v.LinuxBridge, "uplink_bridge", v.UplinkBridge, "mac", mac.String())
return mac, nil
}
// UplinkMode returns UplinkModeVeth (centralised NAT).
func (v *Veth) UplinkMode() UplinkMode { return UplinkModeVeth }
// ExternalCIDR returns the IPv4 prefix on LinuxBridge (OS-assigned).
func (v *Veth) ExternalCIDR(_ context.Context) (netip.Prefix, error) {
if v.LinuxBridge == "" {
return netip.Prefix{}, fmt.Errorf("host.Veth: LinuxBridge required")
}
return v.reader().BridgeCIDR(v.LinuxBridge)
}
+12
View File
@@ -0,0 +1,12 @@
// Package invariants holds executable tests that pin the spinifex network
// layer tree to the safety and liveness invariants declared in ADR-0006
// (docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md).
//
// Each test name encodes its governing ADR clause ID (TestS1_…, TestS4_…,
// TestL1_…). On failure the test quotes the ADR clause verbatim so the fix
// path leads back to the contract, not to the test author.
//
// The package contains no production code; it exists solely so `go test
// ./spinifex/network/invariants/...` runs the suite without polluting any
// layer's own package.
package invariants
@@ -0,0 +1,160 @@
package invariants
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
// TestS6_FederationIsolated enforces ADR-0006 clause S6.
//
// "L4 is the only layer aware of other AZs. No layer below L4 references
// remote AZ identifiers, OVN-IC transit switches, or inter-AZ link state.
// Cross-AZ federation is always mediated by L4."
func TestS6_FederationIsolated(t *testing.T) {
const clause = `ADR-0006 S6: "L4 is the only layer aware of other AZs. ` +
`No layer below L4 references remote AZ identifiers, OVN-IC transit ` +
`switches, or inter-AZ link state. Cross-AZ federation is always ` +
`mediated by L4."`
forbiddenIdents := map[string]struct{}{
"TransitSwitch": {},
"TransitRouterPort": {},
}
forbiddenLiterals := []string{
"ovn-ic",
"ovn-icnbctl",
"ovn-icsbctl",
}
roots := []string{
filepath.Join(repoRoot(t), "spinifex", "network"),
filepath.Join(repoRoot(t), "spinifex", "daemon"),
filepath.Join(repoRoot(t), "spinifex", "handlers"),
}
type hit struct {
file string
line int
sym string
}
var hits []hit
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if isVendoredOrGenerated(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
if isS6Exempt(path) {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
t.Fatalf("parse %s: %v", path, perr)
}
ast.Inspect(file, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.SelectorExpr:
if _, bad := forbiddenIdents[x.Sel.Name]; bad {
pos := fset.Position(x.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, x.Sel.Name})
}
case *ast.Ident:
if _, bad := forbiddenIdents[x.Name]; bad {
pos := fset.Position(x.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, x.Name})
}
case *ast.BasicLit:
if x.Kind != token.STRING {
return true
}
lit, ok := stringLit(x)
if !ok {
return true
}
for _, sub := range forbiddenLiterals {
if strings.Contains(lit, sub) {
pos := fset.Position(x.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, "\"" + lit + "\""})
break
}
}
}
return true
})
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].file != hits[j].file {
return hits[i].file < hits[j].file
}
return hits[i].line < hits[j].line
})
rootForRel := repoRoot(t)
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
limit := 5
for i, h := range hits {
if i >= limit {
b.WriteString(" …\n")
break
}
b.WriteString(" ")
b.WriteString(relTo(h.file, rootForRel))
b.WriteString(":")
b.WriteString(itoa(h.line))
b.WriteString(": federation symbol ")
b.WriteString(h.sym)
b.WriteString(" outside L4\n")
}
b.WriteString(" Fix: route the inter-AZ operation through network/federation/. ")
b.WriteString("Only L4 may reference remote-AZ identifiers, OVN-IC objects, ")
b.WriteString("or inter-AZ link state.\n")
if len(hits) > limit {
b.WriteString(" ")
b.WriteString(itoa(len(hits) - limit))
b.WriteString(" further violations suppressed.\n")
}
t.Fatalf("%s", b.String())
}
func isS6Exempt(path string) bool {
clean := filepath.ToSlash(path)
switch {
case strings.HasSuffix(clean, "/network/topology/names.go"):
return true
case strings.Contains(clean, "/network/federation/"):
return true
case strings.Contains(clean, "/network/invariants/"):
return true
}
return false
}
@@ -0,0 +1,134 @@
package invariants
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
// TestS2_BridgeModeContainedInL0 enforces ADR-0006 clause S2.
//
// "Bridge mode is contained in L0. No layer above L0 reads uplink type,
// bridge mode, or physical NIC state directly. L0's UplinkMode() is the
// only resolver; all layers above receive a typed enum at init time."
func TestS2_BridgeModeContainedInL0(t *testing.T) {
const clause = `ADR-0006 S2: "Bridge mode is contained in L0. No layer ` +
`above L0 reads uplink type, bridge mode, or physical NIC state ` +
`directly. L0's UplinkMode() is the only resolver; all layers ` +
`above receive a typed enum at init time."`
hostTools := map[string]struct{}{
"ovs-vsctl": {},
"ovs-ofctl": {},
"ovn-nbctl": {},
"ovn-sbctl": {},
}
roots := []string{
filepath.Join(repoRoot(t), "spinifex", "network"),
filepath.Join(repoRoot(t), "spinifex", "daemon"),
filepath.Join(repoRoot(t), "spinifex", "vm"),
}
type hit struct {
file string
line int
tool string
}
var hits []hit
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if isVendoredOrGenerated(d.Name()) {
return filepath.SkipDir
}
// network/host/ is L0; only layer that may shell out.
if strings.HasSuffix(filepath.ToSlash(path), "/network/host") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
t.Fatalf("parse %s: %v", path, perr)
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if len(call.Args) < 1 {
return true
}
lit, ok := stringLit(call.Args[0])
if !ok {
return true
}
if _, banned := hostTools[lit]; !banned {
return true
}
pos := fset.Position(call.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, lit})
return true
})
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].file != hits[j].file {
return hits[i].file < hits[j].file
}
return hits[i].line < hits[j].line
})
rootForRel := repoRoot(t)
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
limit := 5
for i, h := range hits {
if i >= limit {
b.WriteString(" …\n")
break
}
b.WriteString(" ")
b.WriteString(relTo(h.file, rootForRel))
b.WriteString(":")
b.WriteString(itoa(h.line))
b.WriteString(": invokes ")
b.WriteString(h.tool)
b.WriteString(" outside network/host/\n")
}
b.WriteString(" Fix: move the bridge/NIC operation into network/host/ ")
b.WriteString("and expose a typed method on host.Wiring. Layers above ")
b.WriteString("L0 may not shell out to host tools directly.\n")
if len(hits) > limit {
b.WriteString(" ")
b.WriteString(itoa(len(hits) - limit))
b.WriteString(" further violations suppressed.\n")
}
t.Fatalf("%s", b.String())
}
+88
View File
@@ -0,0 +1,88 @@
package invariants
import (
"encoding/json"
"os/exec"
"sort"
"strings"
"testing"
)
// TestS8_IPSecOVNNativeOnly enforces ADR-0006 clause S8.
//
// "IPSec is OVN-native only. No layer implements custom IKEv2, XFRM
// rules, or direct strongSwan management. IPSec SA lifecycle is
// delegated entirely to OVN native IPSec and is invisible above L0."
func TestS8_IPSecOVNNativeOnly(t *testing.T) {
const clause = `ADR-0006 S8: "IPSec is OVN-native only. No layer ` +
`implements custom IKEv2, XFRM rules, or direct strongSwan ` +
`management. IPSec SA lifecycle is delegated entirely to OVN ` +
`native IPSec and is invisible above L0."`
deniedSubstrings := []string{
"strongswan",
"strongSwan",
"/xfrm",
"netlink/nl/xfrm",
"ikev2",
"IKEv2",
"libreswan",
"openswan",
}
cmd := exec.Command("go", "list", "-json", "./...")
cmd.Dir = repoRoot(t)
out, err := cmd.Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
type hit struct {
pkg string
imp string
}
var hits []hit
dec := json.NewDecoder(strings.NewReader(string(out)))
for dec.More() {
var p goListPackage
if err := dec.Decode(&p); err != nil {
t.Fatalf("decode go list: %v", err)
}
if !strings.HasPrefix(p.ImportPath, networkRoot) {
continue
}
for _, imp := range p.Imports {
for _, sub := range deniedSubstrings {
if strings.Contains(imp, sub) {
hits = append(hits, hit{p.ImportPath, imp})
}
}
}
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].pkg != hits[j].pkg {
return hits[i].pkg < hits[j].pkg
}
return hits[i].imp < hits[j].imp
})
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
for _, h := range hits {
b.WriteString(" ")
b.WriteString(shortPkg(h.pkg))
b.WriteString(" imports ")
b.WriteString(h.imp)
b.WriteString("\n")
}
b.WriteString(" Fix: remove the import. IPSec SA lifecycle is handled ")
b.WriteString("by OVN native IPSec (strongSwan-as-OVN-internal); do not ")
b.WriteString("manage IKEv2 / XFRM / strongSwan directly from Go.\n")
t.Fatalf("%s", b.String())
}
@@ -0,0 +1,27 @@
package invariants
import "testing"
func TestS1_ClassifyTable(t *testing.T) {
cases := []struct {
name string
from, to packageKind
wantBad bool
}{
{"L3 imports L0 (downward, permitted)", kindL3Policy, kindL0Host, false},
{"L5 imports L1 (downward skip, permitted per ADR L5 description)", kindL5External, kindL1OVN, false},
{"L0 imports L1 (upward, forbidden)", kindL0Host, kindL1OVN, true},
{"L2 imports L3 (upward, forbidden)", kindL2Topology, kindL3Policy, true},
{"reconcile imports L1 (orchestrator, permitted)", kindCrossCutter, kindL1OVN, false},
{"L2 imports reconcile (layer pulling orchestrator, forbidden)", kindL2Topology, kindCrossCutter, true},
{"L1 imports L1 (same layer, permitted)", kindL1OVN, kindL1OVN, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classify(tc.from, tc.to)
if (got != "") != tc.wantBad {
t.Fatalf("classify(%v, %v) = %q; wantBad=%v", tc.from, tc.to, got, tc.wantBad)
}
})
}
}
+277
View File
@@ -0,0 +1,277 @@
package invariants
import (
"encoding/json"
"os/exec"
"sort"
"strings"
"testing"
)
// TestS1_LayerSkipProhibited enforces ADR-0006 clause S1.
//
// "No code above layer Lk calls through to a layer below Lk's immediate
// interface. Every cross-layer call passes through the typed interface
// of the immediate lower neighbor."
func TestS1_LayerSkipProhibited(t *testing.T) {
const clause = `ADR-0006 S1: "No code above layer Lk calls through to a ` +
`layer below Lk's immediate interface. Every cross-layer call ` +
`passes through the typed interface of the immediate lower neighbor."`
pkgs := listNetworkPackages(t)
type violation struct {
from string
fromKind packageKind
to string
toKind packageKind
reason string
}
var bad []violation
for _, p := range pkgs {
fromKind := layerOf(p.ImportPath)
for _, imp := range p.Imports {
if !strings.HasPrefix(imp, networkRoot) {
continue
}
toKind := layerOf(imp)
reason := classify(fromKind, toKind)
if reason == "" {
continue
}
bad = append(bad, violation{
from: p.ImportPath, fromKind: fromKind,
to: imp, toKind: toKind,
reason: reason,
})
}
}
if len(bad) == 0 {
return
}
sort.Slice(bad, func(i, j int) bool {
if bad[i].from != bad[j].from {
return bad[i].from < bad[j].from
}
return bad[i].to < bad[j].to
})
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
limit := 5
for i, v := range bad {
if i >= limit {
b.WriteString(" …\n")
break
}
b.WriteString(" ")
b.WriteString(shortPkg(v.from))
b.WriteString(" (")
b.WriteString(v.fromKind.String())
b.WriteString(") -> ")
b.WriteString(shortPkg(v.to))
b.WriteString(" (")
b.WriteString(v.toKind.String())
b.WriteString("): ")
b.WriteString(v.reason)
b.WriteString("\n")
}
b.WriteString(" Fix: route the call through the immediate lower ")
b.WriteString("layer's typed interface, or move the offending code to ")
b.WriteString("an orchestrator (reconcile / subscribers).\n")
if len(bad) > limit {
b.WriteString(" ")
b.WriteString(itoa(len(bad) - limit))
b.WriteString(" further violations suppressed.\n")
}
t.Fatalf("%s", b.String())
}
// packageKind tags a package by its position in the ADR-0006 layer model.
type packageKind int
const (
kindUnknown packageKind = iota
kindL0Host
kindL1OVN
kindL2Topology
kindL3Policy
kindL4Federation
kindL5External
kindCrossCutter
kindInvariants
)
func (k packageKind) String() string {
switch k {
case kindL0Host:
return "L0/host"
case kindL1OVN:
return "L1/ovn"
case kindL2Topology:
return "L2/topology"
case kindL3Policy:
return "L3/policy"
case kindL4Federation:
return "L4/federation"
case kindL5External:
return "L5/external"
case kindCrossCutter:
return "cross-cutter"
case kindInvariants:
return "invariants"
default:
return "unknown"
}
}
// rank returns the ADR layer number; non-layered packages return -1.
func (k packageKind) rank() int {
switch k {
case kindL0Host:
return 0
case kindL1OVN:
return 1
case kindL2Topology:
return 2
case kindL3Policy:
return 3
case kindL4Federation:
return 4
case kindL5External:
return 5
}
return -1
}
const networkRoot = "github.com/mulgadc/spinifex/spinifex/network"
func layerOf(importPath string) packageKind {
suffix := strings.TrimPrefix(importPath, networkRoot)
suffix = strings.TrimPrefix(suffix, "/")
switch {
case suffix == "host" || strings.HasPrefix(suffix, "host/"):
return kindL0Host
case suffix == "ovn" || strings.HasPrefix(suffix, "ovn/"):
return kindL1OVN
case suffix == "topology" || strings.HasPrefix(suffix, "topology/"):
return kindL2Topology
case suffix == "policy" || strings.HasPrefix(suffix, "policy/"):
return kindL3Policy
case suffix == "federation" || strings.HasPrefix(suffix, "federation/"):
return kindL4Federation
case suffix == "external" || strings.HasPrefix(suffix, "external/"):
return kindL5External
case suffix == "reconcile" || strings.HasPrefix(suffix, "reconcile/"):
return kindCrossCutter
case suffix == "subscribers" || strings.HasPrefix(suffix, "subscribers/"):
return kindCrossCutter
case suffix == "invariants" || strings.HasPrefix(suffix, "invariants/"):
return kindInvariants
}
return kindUnknown
}
// classify returns a non-empty reason iff the edge from→to violates S1.
func classify(from, to packageKind) string {
if from == to {
return ""
}
if from == kindInvariants || to == kindInvariants {
return ""
}
// Layer importing a cross-cutter would re-tangle the tree.
if to == kindCrossCutter && from != kindCrossCutter {
return "layer importing a cross-cutter (reconcile/subscribers)"
}
if from == kindCrossCutter {
return ""
}
fr, tr := from.rank(), to.rank()
if fr < 0 || tr < 0 {
return ""
}
if tr > fr {
return "upward layer import (L" + itoa(fr) + " -> L" + itoa(tr) + ")"
}
return ""
}
type goListPackage struct {
ImportPath string `json:"ImportPath"`
Imports []string `json:"Imports"`
}
func listNetworkPackages(t *testing.T) []goListPackage {
t.Helper()
cmd := exec.Command("go", "list", "-json", "./...")
cmd.Dir = repoRoot(t)
out, err := cmd.Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
var pkgs []goListPackage
dec := json.NewDecoder(strings.NewReader(string(out)))
for dec.More() {
var p goListPackage
if err := dec.Decode(&p); err != nil {
t.Fatalf("decode go list output: %v", err)
}
if !strings.HasPrefix(p.ImportPath, networkRoot) {
continue
}
if layerOf(p.ImportPath) == kindInvariants {
continue
}
pkgs = append(pkgs, p)
}
if len(pkgs) == 0 {
t.Fatalf("go list returned no packages under %s", networkRoot)
}
return pkgs
}
// repoRoot returns the spinifex module root via `go env GOMOD`.
func repoRoot(t *testing.T) string {
t.Helper()
cmd := exec.Command("go", "env", "GOMOD")
out, err := cmd.Output()
if err != nil {
t.Fatalf("go env GOMOD: %v", err)
}
gomod := strings.TrimSpace(string(out))
if gomod == "" || gomod == "/dev/null" {
t.Fatalf("not in a go module (GOMOD=%q)", gomod)
}
return strings.TrimSuffix(gomod, "/go.mod")
}
func shortPkg(p string) string {
return strings.TrimPrefix(p, networkRoot+"/")
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+262
View File
@@ -0,0 +1,262 @@
package invariants
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// TestS4_OVNNamesL2Owned enforces ADR-0006 clause S4.
//
// "All OVN NB DB object names are constructed exclusively by L2 using
// the naming contract table. No layer above or below L2 constructs OVN
// object names independently."
func TestS4_OVNNamesL2Owned(t *testing.T) {
const clause = `ADR-0006 S4: "All OVN NB DB object names are constructed ` +
`exclusively by L2 using the naming contract table. No layer above ` +
`or below L2 constructs OVN object names independently."`
roots := s4ScanRoots(t)
type hit struct {
file string
line int
col int
lit string
via string // "concat", "sprintf", "join"
}
var hits []hit
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if isVendoredOrGenerated(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
if isS4Exempt(path) {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
t.Fatalf("parse %s: %v", path, perr)
}
ast.Inspect(file, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.BinaryExpr:
if x.Op != token.ADD {
return true
}
lit, ok := stringLit(x.X)
if !ok {
return true
}
if !hasOVNPrefix(lit) {
return true
}
pos := fset.Position(x.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, pos.Column, lit, "concat"})
case *ast.CallExpr:
name := calleeName(x.Fun)
switch name {
case "fmt.Sprintf":
if len(x.Args) < 1 {
return true
}
lit, ok := stringLit(x.Args[0])
if !ok {
return true
}
if !hasOVNPrefix(lit) {
return true
}
pos := fset.Position(x.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, pos.Column, lit, "sprintf"})
case "strings.Join":
if len(x.Args) < 1 {
return true
}
comp, ok := x.Args[0].(*ast.CompositeLit)
if !ok {
return true
}
for _, e := range comp.Elts {
lit, ok := stringLit(e)
if !ok {
continue
}
if !hasOVNPrefix(lit) {
continue
}
pos := fset.Position(e.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, pos.Column, lit, "join"})
}
}
}
return true
})
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].file != hits[j].file {
return hits[i].file < hits[j].file
}
return hits[i].line < hits[j].line
})
rootForRel := repoRoot(t)
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
limit := 5
for i, h := range hits {
if i >= limit {
b.WriteString(" …\n")
break
}
b.WriteString(" ")
b.WriteString(relTo(h.file, rootForRel))
b.WriteString(":")
b.WriteString(itoa(h.line))
b.WriteString(":")
b.WriteString(itoa(h.col))
b.WriteString(": ")
b.WriteString(h.via)
b.WriteString(" of ")
b.WriteString(strconv.Quote(h.lit))
b.WriteString("\n")
}
b.WriteString(" Fix: add a helper to spinifex/network/topology/names.go ")
b.WriteString("and call it from the offending site. Do not construct OVN ")
b.WriteString("object names outside L2.\n")
if len(hits) > limit {
b.WriteString(" ")
b.WriteString(itoa(len(hits) - limit))
b.WriteString(" further violations suppressed.\n")
}
t.Fatalf("%s", b.String())
}
// ovnNamePrefixes is the ADR-0006 contract table; keep in lockstep with topology/names.go.
var ovnNamePrefixes = []string{
"vpc-",
"subnet-",
"port-",
"rtr-",
"rtr-port-",
"gw-",
"gw-port-",
"ext-",
"ext-port-",
"ts-",
"trp-",
}
// awsIDPrefixes are AWS resource-IDs that share leading bytes with ovnNamePrefixes; excluded from hasOVNPrefix.
var awsIDPrefixes = []string{
"vpc-cidr-assoc-",
}
func hasOVNPrefix(s string) bool {
for _, a := range awsIDPrefixes {
if strings.HasPrefix(s, a) {
return false
}
}
for _, p := range ovnNamePrefixes {
if len(s) > len(p) && strings.HasPrefix(s, p) {
return true
}
}
return false
}
func stringLit(e ast.Expr) (string, bool) {
bl, ok := e.(*ast.BasicLit)
if !ok || bl.Kind != token.STRING {
return "", false
}
v, err := strconv.Unquote(bl.Value)
if err != nil {
return "", false
}
return v, true
}
func calleeName(fun ast.Expr) string {
switch f := fun.(type) {
case *ast.SelectorExpr:
if id, ok := f.X.(*ast.Ident); ok {
return id.Name + "." + f.Sel.Name
}
case *ast.Ident:
return f.Name
}
return ""
}
func isS4Exempt(path string) bool {
clean := filepath.ToSlash(path)
switch {
case strings.HasSuffix(clean, "/network/topology/names.go"):
return true
case strings.HasSuffix(clean, "/network/topology/names_test.go"):
return true
case strings.Contains(clean, "/network/invariants/"):
return true
}
return false
}
func isVendoredOrGenerated(dir string) bool {
switch dir {
case "vendor", "testdata", "node_modules", ".git":
return true
}
return false
}
func s4ScanRoots(t *testing.T) []string {
t.Helper()
root := repoRoot(t)
return []string{
filepath.Join(root, "spinifex", "network"),
filepath.Join(root, "spinifex", "vpcd"),
filepath.Join(root, "spinifex", "daemon"),
filepath.Join(root, "spinifex", "handlers"),
filepath.Join(root, "spinifex", "vm"),
}
}
func relTo(p, root string) string {
r, err := filepath.Rel(root, p)
if err != nil {
return p
}
return filepath.ToSlash(r)
}
@@ -0,0 +1,26 @@
package invariants
import "testing"
func TestS4_HasOVNPrefix(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"vpc-abc", true},
{"subnet-1", true},
{"gw-port-x", true},
{"ts-az1-vpc1", true},
{"vpc-", false}, // bare prefix, no ID
{"vpc", false}, // not prefixed
{"hello-vpc-x", false},
{"port-to-br", true}, // ovs-vsctl subcommand; precision comes from AST context, not matcher
{"vpc-cidr-assoc-", false},
{"vpc-cidr-assoc-deadbeef", false},
}
for _, tc := range cases {
if got := hasOVNPrefix(tc.in); got != tc.want {
t.Errorf("hasOVNPrefix(%q) = %v; want %v", tc.in, got, tc.want)
}
}
}
@@ -0,0 +1,150 @@
package invariants
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
// TestS3_NATModeInitTimeConstant enforces ADR-0006 clause S3.
//
// "NAT distribution mode is an init-time constant. Centralised-vs-
// distributed NAT is determined once at startup from L0's UplinkMode()
// and never re-evaluated at runtime. No layer above L0 branches on NAT
// mode dynamically."
func TestS3_NATModeInitTimeConstant(t *testing.T) {
const clause = `ADR-0006 S3: "NAT distribution mode is an init-time ` +
`constant. Centralised-vs-distributed NAT is determined once at ` +
`startup from L0's UplinkMode() and never re-evaluated at runtime. ` +
`No layer above L0 branches on NAT mode dynamically."`
type hit struct {
file string
line int
sym string
}
var hits []hit
roots := []string{
filepath.Join(repoRoot(t), "spinifex", "network"),
filepath.Join(repoRoot(t), "spinifex", "vpcd"),
filepath.Join(repoRoot(t), "spinifex", "daemon"),
}
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if isVendoredOrGenerated(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
if isS3Exempt(path) {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
t.Fatalf("parse %s: %v", path, perr)
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
name := calleeName(call.Fun)
selName := selectorTail(call.Fun)
switch {
case selName == "UplinkMode":
pos := fset.Position(call.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, selName + "()"})
case strings.HasSuffix(name, "NATModeFromUplinkMode"):
pos := fset.Position(call.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, "NATModeFromUplinkMode"})
}
return true
})
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].file != hits[j].file {
return hits[i].file < hits[j].file
}
return hits[i].line < hits[j].line
})
rootForRel := repoRoot(t)
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
limit := 5
for i, h := range hits {
if i >= limit {
b.WriteString(" …\n")
break
}
b.WriteString(" ")
b.WriteString(relTo(h.file, rootForRel))
b.WriteString(":")
b.WriteString(itoa(h.line))
b.WriteString(": ")
b.WriteString(h.sym)
b.WriteString(" called outside the init-time resolver location\n")
}
b.WriteString(" Fix: resolve NAT mode once at startup (vpcd entrypoint) ")
b.WriteString("and pass the typed enum to constructors. Store as an ")
b.WriteString("immutable field; do not re-resolve.\n")
if len(hits) > limit {
b.WriteString(" ")
b.WriteString(itoa(len(hits) - limit))
b.WriteString(" further violations suppressed.\n")
}
t.Fatalf("%s", b.String())
}
func isS3Exempt(path string) bool {
clean := filepath.ToSlash(path)
switch {
case strings.HasSuffix(clean, "/network/host"):
return true
case strings.Contains(clean, "/network/host/"):
return true
case strings.HasSuffix(clean, "/network/policy/policy.go"):
return true
case strings.HasSuffix(clean, "/vpcd/vpcd.go"):
return true
case strings.Contains(clean, "/network/invariants/"):
return true
}
return false
}
// selectorTail returns the trailing selector name (`.Foo`) of a call, or "".
func selectorTail(fun ast.Expr) string {
if sel, ok := fun.(*ast.SelectorExpr); ok {
return sel.Sel.Name
}
return ""
}
@@ -0,0 +1,138 @@
package invariants
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// TestS7_NATSSubjectsCarryAZPrefix enforces ADR-0006 clause S7.
//
// "Every vpcd-originated NATS publication uses a subject prefixed
// vpc.{azID}. A subscription never processes a message whose subject
// AZ prefix does not match the local node's AZ identifier."
//
// Aspirational: legacy `vpc.<verb>` subjects still in use pending cluster-wide
// rename. Test runs audit and t.Skip's with gap report; flip to t.Fatalf once
// the rename ships.
func TestS7_NATSSubjectsCarryAZPrefix(t *testing.T) {
const clause = `ADR-0006 S7: "Every vpcd-originated NATS publication ` +
`uses a subject prefixed vpc.{azID}. A subscription never processes ` +
`a message whose subject AZ prefix does not match the local node's ` +
`AZ identifier."`
compliant := regexp.MustCompile(`^vpc\.\{`)
subjectLike := regexp.MustCompile(`^vpc\.[a-zA-Z]`)
roots := []string{
filepath.Join(repoRoot(t), "spinifex", "network"),
filepath.Join(repoRoot(t), "spinifex", "vpcd"),
filepath.Join(repoRoot(t), "spinifex", "daemon"),
filepath.Join(repoRoot(t), "spinifex", "handlers"),
filepath.Join(repoRoot(t), "spinifex", "testutil"),
filepath.Join(repoRoot(t), "spinifex", "utils"),
}
type hit struct {
file string
line int
subject string
}
var hits []hit
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if isVendoredOrGenerated(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
if strings.Contains(filepath.ToSlash(path), "/network/invariants/") {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
t.Fatalf("parse %s: %v", path, perr)
}
ast.Inspect(file, func(n ast.Node) bool {
bl, ok := n.(*ast.BasicLit)
if !ok || bl.Kind != token.STRING {
return true
}
lit, ok := stringLit(bl)
if !ok {
return true
}
if !subjectLike.MatchString(lit) {
return true
}
if compliant.MatchString(lit) {
return true
}
pos := fset.Position(bl.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, lit})
return true
})
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].file != hits[j].file {
return hits[i].file < hits[j].file
}
return hits[i].line < hits[j].line
})
rootForRel := repoRoot(t)
var b strings.Builder
b.WriteString("S7 gap (aspirational): subject rename pending. ")
b.WriteString("Once the cluster-wide cutover lands, switch t.Skip to ")
b.WriteString("t.Fatalf in nats_subjects_test.go.\n")
b.WriteString(clause)
b.WriteString("\n")
limit := 5
for i, h := range hits {
if i >= limit {
b.WriteString(" …\n")
break
}
b.WriteString(" ")
b.WriteString(relTo(h.file, rootForRel))
b.WriteString(":")
b.WriteString(itoa(h.line))
b.WriteString(": subject ")
b.WriteString(h.subject)
b.WriteString(" lacks {azID} segment\n")
}
if len(hits) > limit {
b.WriteString(" ")
b.WriteString(itoa(len(hits) - limit))
b.WriteString(" further subjects suppressed.\n")
}
t.Skip(b.String())
}
@@ -0,0 +1,130 @@
package invariants
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
// TestS4_OVNNamedCreatesUseEnsure enforces ADR-0006 S4.7.
//
// "OVN logical entities owned by spinifex MUST be unique on
// (table, Name). The libovsdb client enforces this via wait-then-insert
// transactions (EnsureLogicalRouter / EnsureLogicalSwitch /
// EnsurePortGroup). Callers outside network/ovn MUST use the Ensure*
// primitive."
func TestS4_OVNNamedCreatesUseEnsure(t *testing.T) {
const clause = `ADR-0006 S4.7: "OVN logical entities owned by spinifex ` +
`MUST be unique on (table, Name). The libovsdb client enforces this ` +
`via wait-then-insert transactions (EnsureLogicalRouter / ` +
`EnsureLogicalSwitch / EnsurePortGroup). Callers outside network/ovn ` +
`MUST use the Ensure* primitive."`
forbidden := map[string]string{
"CreateLogicalRouter": "EnsureLogicalRouter",
"CreateLogicalSwitch": "EnsureLogicalSwitch",
"CreatePortGroup": "EnsurePortGroup",
}
root := filepath.Join(repoRoot(t), "spinifex")
exemptDirs := map[string]struct{}{
filepath.Join(root, "network", "ovn"): {},
filepath.Join(root, "network", "invariants"): {},
}
type hit struct {
file string
line int
sym string
replace string
}
var hits []hit
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if isVendoredOrGenerated(d.Name()) {
return filepath.SkipDir
}
for exempt := range exemptDirs {
if path == exempt || strings.HasPrefix(path, exempt+string(filepath.Separator)) {
return filepath.SkipDir
}
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
t.Fatalf("parse %s: %v", path, perr)
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
tail := selectorTail(call.Fun)
replace, bad := forbidden[tail]
if !bad {
return true
}
pos := fset.Position(call.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, tail, replace})
return true
})
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].file != hits[j].file {
return hits[i].file < hits[j].file
}
return hits[i].line < hits[j].line
})
rootForRel := repoRoot(t)
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
limit := 8
for i, h := range hits {
if i >= limit {
b.WriteString(" …\n")
break
}
b.WriteString(" ")
b.WriteString(relTo(h.file, rootForRel))
b.WriteString(":")
b.WriteString(itoa(h.line))
b.WriteString(": calls ")
b.WriteString(h.sym)
b.WriteString(" — use ")
b.WriteString(h.replace)
b.WriteString(" instead\n")
}
b.WriteString(" Fix: swap the call site to the Ensure* primitive ")
b.WriteString("(returns canonical row; survives cross-node creation race).\n")
if len(hits) > limit {
b.WriteString(" ")
b.WriteString(itoa(len(hits) - limit))
b.WriteString(" further violations suppressed.\n")
}
t.Fatalf("%s", b.String())
}
@@ -0,0 +1,122 @@
package invariants
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
// TestS5_PolicyNeverCreatesOrDeletes enforces ADR-0006 clause S5.
//
// "L3 never creates or deletes logical objects. L3 only attaches policy
// (ACLs, NAT rules, static routes) to objects that L2 already owns. L3
// never calls create or delete on logical switches, routers, or ports."
func TestS5_PolicyNeverCreatesOrDeletes(t *testing.T) {
const clause = `ADR-0006 S5: "L3 never creates or deletes logical ` +
`objects. L3 only attaches policy (ACLs, NAT rules, static routes) ` +
`to objects that L2 already owns. L3 never calls create or delete ` +
`on logical switches, routers, or ports."`
forbidden := map[string]struct{}{
"CreateLogicalSwitch": {},
"CreateLogicalRouter": {},
"CreateLogicalSwitchPort": {},
"CreateLogicalRouterPort": {},
"DeleteLogicalSwitch": {},
"DeleteLogicalRouter": {},
"DeleteLogicalSwitchPort": {},
"DeleteLogicalRouterPort": {},
}
root := filepath.Join(repoRoot(t), "spinifex", "network", "policy")
type hit struct {
file string
line int
sym string
}
var hits []hit
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if isVendoredOrGenerated(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
t.Fatalf("parse %s: %v", path, perr)
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
tail := selectorTail(call.Fun)
if _, bad := forbidden[tail]; !bad {
return true
}
pos := fset.Position(call.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, tail})
return true
})
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].file != hits[j].file {
return hits[i].file < hits[j].file
}
return hits[i].line < hits[j].line
})
rootForRel := repoRoot(t)
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
limit := 5
for i, h := range hits {
if i >= limit {
b.WriteString(" …\n")
break
}
b.WriteString(" ")
b.WriteString(relTo(h.file, rootForRel))
b.WriteString(":")
b.WriteString(itoa(h.line))
b.WriteString(": L3 calls ")
b.WriteString(h.sym)
b.WriteString("\n")
}
b.WriteString(" Fix: move the create/delete to L2 (topology) and have ")
b.WriteString("L3 attach policy only. L3 must never mutate logical ")
b.WriteString("switch/router/port lifecycle.\n")
if len(hits) > limit {
b.WriteString(" ")
b.WriteString(itoa(len(hits) - limit))
b.WriteString(" further violations suppressed.\n")
}
t.Fatalf("%s", b.String())
}
@@ -0,0 +1,110 @@
package invariants
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// TestS9_SingleReconcilerNoRetrofitPasses enforces ADR-0006 clause S9.
//
// "On each leader-gated startup, exactly one intent-actual reconciliation
// pass runs against the NATS KV snapshot and OVN NB DB actual state. No
// additional serial retrofit passes exist."
func TestS9_SingleReconcilerNoRetrofitPasses(t *testing.T) {
const clause = `ADR-0006 S9: "On each leader-gated startup, exactly one ` +
`intent-actual reconciliation pass runs against the NATS KV snapshot ` +
`and OVN NB DB actual state. No additional serial retrofit passes exist."`
loopRE := regexp.MustCompile(`^Reconcile[A-Za-z]*Loop$`)
passRE := regexp.MustCompile(`^Reconcile[A-Za-z]*Pass\d+$`)
root := filepath.Join(repoRoot(t), "spinifex", "network")
type hit struct {
file string
line int
sym string
why string
}
var hits []hit
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if isVendoredOrGenerated(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if perr != nil {
t.Fatalf("parse %s: %v", path, perr)
}
ast.Inspect(file, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name == nil {
return true
}
name := fn.Name.Name
switch {
case loopRE.MatchString(name):
pos := fset.Position(fn.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, name, "Reconcile…Loop (retrofit pass)"})
case passRE.MatchString(name):
pos := fset.Position(fn.Pos())
hits = append(hits, hit{pos.Filename, pos.Line, name, "Reconcile…Pass<n> (serial retrofit)"})
}
return true
})
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if len(hits) == 0 {
return
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].file != hits[j].file {
return hits[i].file < hits[j].file
}
return hits[i].line < hits[j].line
})
rootForRel := repoRoot(t)
var b strings.Builder
b.WriteString(clause)
b.WriteString("\n")
for _, h := range hits {
b.WriteString(" ")
b.WriteString(relTo(h.file, rootForRel))
b.WriteString(":")
b.WriteString(itoa(h.line))
b.WriteString(": ")
b.WriteString(h.sym)
b.WriteString(" — ")
b.WriteString(h.why)
b.WriteString("\n")
}
b.WriteString(" Fix: collapse this function into the single intent-actual ")
b.WriteString("reconciler in network/reconcile/. Periodic re-runs schedule ")
b.WriteString("the same entrypoint; they do not warrant a new function.\n")
t.Fatalf("%s", b.String())
}
+158
View File
@@ -0,0 +1,158 @@
// Package ovn is L1 of the spinifex network stack: the OVN Northbound DB
// client interface and implementations. Higher layers (topology, policy,
// external, federation) must call OVN only through this interface.
package ovn
import (
"context"
"errors"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
)
// ErrNATNotFound is returned when a NAT rule is absent (use errors.Is for idempotency).
var ErrNATNotFound = errors.New("NAT not found")
// ErrPortGroupNotFound is returned when a port group is absent (use errors.Is for idempotency).
var ErrPortGroupNotFound = errors.New("port group not found")
// ACLSpec describes an OVN ACL rule for attachment to a port group.
type ACLSpec struct {
Direction string // "to-lport" or "from-lport"
Priority int
Match string
Action string // "allow-related", "drop", "allow", "reject"
Name string
Log bool
Severity string // "alert", "warning", "notice", "info", "debug"
}
// Client is the OVN Northbound DB abstraction. The vpcd in-tree
// implementation is LiveClient (live.go); tests use network/ovn/mock.
type Client interface {
// Connection lifecycle
Connect(ctx context.Context) error
Close()
Connected() bool
// Logical Switch (subnet)
CreateLogicalSwitch(ctx context.Context, ls *nbdb.LogicalSwitch) error
// EnsureLogicalSwitch atomically creates a logical switch if no row with
// the same Name already exists, or returns the existing row. Wraps the
// insert in an OVSDB wait-op that asserts "zero rows match Name == ls.Name"
// at commit time; concurrent writers across nodes are serialised by
// ovsdb-server rather than by the local libovsdb cache (which can be
// stale relative to another node's just-committed insert). See
// EnsureLogicalRouter for the rationale.
EnsureLogicalSwitch(ctx context.Context, ls *nbdb.LogicalSwitch) (*nbdb.LogicalSwitch, error)
DeleteLogicalSwitch(ctx context.Context, name string) error
GetLogicalSwitch(ctx context.Context, name string) (*nbdb.LogicalSwitch, error)
ListLogicalSwitches(ctx context.Context) ([]nbdb.LogicalSwitch, error)
// Logical Switch Port (VM/ENI)
CreateLogicalSwitchPort(ctx context.Context, switchName string, lsp *nbdb.LogicalSwitchPort) error
// CreateLogicalSwitchPortInGroups creates an LSP, adds it to its switch,
// and joins it to the named port groups — all in a single OVSDB
// transaction. Required for SG enforcement: a non-atomic create-then-join
// leaves a window where the LSP exists outside any port group (OVN
// default = unrestricted). portGroupNames may be empty (e.g. router/
// localnet ports). The per-port-group `_ip4`/`_ip6` Address_Set rows in
// SB are auto-derived by ovn-northd from each port group's port
// addresses; SG-to-SG match expressions resolve against those.
CreateLogicalSwitchPortInGroups(ctx context.Context, switchName string, lsp *nbdb.LogicalSwitchPort, portGroupNames []string) error
DeleteLogicalSwitchPort(ctx context.Context, switchName string, portName string) error
GetLogicalSwitchPort(ctx context.Context, name string) (*nbdb.LogicalSwitchPort, error)
UpdateLogicalSwitchPort(ctx context.Context, lsp *nbdb.LogicalSwitchPort) error
// Logical Router (VPC router)
CreateLogicalRouter(ctx context.Context, lr *nbdb.LogicalRouter) error
// EnsureLogicalRouter atomically creates a logical router if no row with
// the same Name already exists, or returns the existing row. The insert
// is bundled with an OVSDB wait-op that asserts "zero rows match
// Name == lr.Name" at commit time, so two writers (different goroutines
// or different nodes) that both observe absence cannot both succeed —
// ovsdb-server rejects the second transaction. The returned pointer is
// the canonical row: newly created with the supplied fields, or the
// pre-existing row when a competing writer raced us.
//
// Required because OVN NB has no unique constraint on Name. Without the
// wait-op, the cross-handler / cross-node race between vpc.create and
// vpc.create-subnet's defensive EnsureVPC call produces duplicate routers
// per VPC.
EnsureLogicalRouter(ctx context.Context, lr *nbdb.LogicalRouter) (*nbdb.LogicalRouter, error)
DeleteLogicalRouter(ctx context.Context, name string) error
GetLogicalRouter(ctx context.Context, name string) (*nbdb.LogicalRouter, error)
ListLogicalRouters(ctx context.Context) ([]nbdb.LogicalRouter, error)
// UpdateLogicalRouterExternalIDs rewrites the ExternalIDs map of an
// existing logical router. Used by topology.EnsureVPC to backfill the
// `spinifex:cidr` / `spinifex:vni` keys when the row was first created by
// a defensive EnsureVPC call that did not carry the parent VPC's full
// metadata. The whole map is replaced; callers must pass the merged set.
UpdateLogicalRouterExternalIDs(ctx context.Context, name string, externalIDs map[string]string) error
// Logical Router Port
CreateLogicalRouterPort(ctx context.Context, routerName string, lrp *nbdb.LogicalRouterPort) error
DeleteLogicalRouterPort(ctx context.Context, routerName string, portName string) error
GetLogicalRouterPort(ctx context.Context, name string) (*nbdb.LogicalRouterPort, error)
UpdateLogicalRouterPort(ctx context.Context, lrp *nbdb.LogicalRouterPort) error
ListLogicalRouterPorts(ctx context.Context) ([]nbdb.LogicalRouterPort, error)
// DHCP Options
CreateDHCPOptions(ctx context.Context, opts *nbdb.DHCPOptions) (string, error)
DeleteDHCPOptions(ctx context.Context, uuid string) error
FindDHCPOptionsByCIDR(ctx context.Context, cidr string) (*nbdb.DHCPOptions, error)
FindDHCPOptionsByExternalID(ctx context.Context, key, value string) (*nbdb.DHCPOptions, error)
ListDHCPOptions(ctx context.Context) ([]nbdb.DHCPOptions, error)
// NAT rules
AddNAT(ctx context.Context, routerName string, nat *nbdb.NAT) error
DeleteNAT(ctx context.Context, routerName string, natType, logicalIP string) error
DeleteNATByExternalIP(ctx context.Context, routerName string, natType, externalIP string) error
DeleteAllNATsByExternalIP(ctx context.Context, natType, externalIP string) (int, error)
FindNATByExternalIP(ctx context.Context, natType, externalIP string) (*nbdb.NAT, error)
// Static routes
AddStaticRoute(ctx context.Context, routerName string, route *nbdb.LogicalRouterStaticRoute) error
DeleteStaticRoute(ctx context.Context, routerName string, ipPrefix string) error
FindStaticRoute(ctx context.Context, routerName, ipPrefix string) (*nbdb.LogicalRouterStaticRoute, error)
// Port Groups (security group enforcement)
CreatePortGroup(ctx context.Context, name string, ports []string) error
// EnsurePortGroup is the wait-op-protected create-or-get analogue of
// CreatePortGroup. Returns the canonical row. See EnsureLogicalRouter for
// the wait-op pattern and rationale.
EnsurePortGroup(ctx context.Context, name string, ports []string) (*nbdb.PortGroup, error)
DeletePortGroup(ctx context.Context, name string) error
// UpdatePortGroupMemberships applies all port-group joins and leaves for a
// single LSP in one atomic OVSDB transaction. Required by reconcilePortSGs
// so a 5-SG → different-5-SG modify never exposes an intermediate state
// with fewer port groups (which would let the OVN default = unrestricted
// apply for the gap). The per-port-group `_ip4`/`_ip6` Address_Set rows
// in SB are auto-derived by ovn-northd from each port group's port
// addresses; no explicit address-set update is required here.
UpdatePortGroupMemberships(ctx context.Context, lspName string, addPGs, removePGs []string) error
// ListPortGroupsForPort returns the names of every port group whose Ports
// set contains the given LSP. Used by reconcilePortSGs to discover current
// membership before computing the add/remove diff against desired.
ListPortGroupsForPort(ctx context.Context, lspName string) ([]string, error)
// GetPortGroup returns the port group with the given name, or an error if
// it doesn't exist. Used by the reconciler to detect SGs whose port group
// has gone missing in OVN NB.
GetPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error)
// ListPortGroups returns every port group in OVN NB. Used by the
// reconciler's orphan-PG scan to detect spinifex-managed port groups
// (`sg_*`) that no longer have a matching SG record in KV.
ListPortGroups(ctx context.Context) ([]nbdb.PortGroup, error)
// ACLs (attached to port groups). AddACLs creates all rows and links
// them to the port group in one OVSDB transaction — important when a
// single SG can carry up to 60 ingress + 60 egress rules.
AddACLs(ctx context.Context, portGroupName string, specs []ACLSpec) error
ClearACLs(ctx context.Context, portGroupName string) error
// Gateway Chassis (HA scheduling for gateway router ports)
SetGatewayChassis(ctx context.Context, lrpName string, chassisName string, priority int) error
GetGatewayChassisByName(ctx context.Context, name string) (*nbdb.GatewayChassis, error)
ListGatewayChassis(ctx context.Context) ([]nbdb.GatewayChassis, error)
DeleteGatewayChassis(ctx context.Context, lrpName string, gcUUID string) error
}
@@ -1,34 +1,60 @@
package vpcd
package ovn
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"time"
"github.com/mulgadc/spinifex/spinifex/services/vpcd/nbdb"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
"github.com/ovn-kubernetes/libovsdb/client"
"github.com/ovn-kubernetes/libovsdb/model"
"github.com/ovn-kubernetes/libovsdb/ovsdb"
)
// ErrNATNotFound is returned by DeleteNAT / DeleteNATByExternalIP when the
// rule is already absent. Callers that want idempotent semantics (e.g. the
// vpc.delete-nat NATS handler, which races with vm-cleanup paths that emit
// the same delete) match it with errors.Is and treat as success.
var ErrNATNotFound = errors.New("NAT not found")
// ensureRefetchTimeout bounds the post-conflict refetch wait. 200ms tolerates
// clustered NB raft follower lag under steady-state.
const ensureRefetchTimeout = 200 * time.Millisecond
// transactOps executes a set of OVSDB operations as a single transaction,
// checking both the RPC error and individual operation results.
func (c *LiveOVNClient) transactOps(ctx context.Context, ops []ovsdb.Operation) error {
// ensureRefetchInterval polls at libovsdb's monitor flush cadence.
const ensureRefetchInterval = 20 * time.Millisecond
// ensureWaitTimeoutMS=0 makes the wait-op fail fast so we fall through to the
// refetch path on conflict.
const ensureWaitTimeoutMS = 0
// ensureNamedRowOps returns ops that atomically "insert createObj unless a row
// with this Name exists". OVN NB lacks a unique-Name constraint; the paired
// wait+create lets ovsdb-server serialise concurrent writers, and the
// `until=!=` inverse formulation is required because RFC 7047 §5.2.6 mandates
// a non-empty `rows` member (libovsdb drops `[]` via `omitempty`).
func (c *LiveClient) ensureNamedRowOps(table, name string, createObj model.Model) ([]ovsdb.Operation, error) {
createOps, err := c.client.Create(createObj)
if err != nil {
return nil, fmt.Errorf("create %s ops: %w", table, err)
}
timeout := ensureWaitTimeoutMS
waitOp := ovsdb.Operation{
Op: ovsdb.OperationWait,
Table: table,
Timeout: &timeout,
Where: []ovsdb.Condition{{Column: "name", Function: ovsdb.ConditionEqual, Value: name}},
Columns: []string{"name"},
Until: string(ovsdb.WaitConditionNotEqual),
Rows: []ovsdb.Row{{"name": name}},
}
return append([]ovsdb.Operation{waitOp}, createOps...), nil
}
// transactOps runs ops as one transaction and checks per-op results.
func (c *LiveClient) transactOps(ctx context.Context, ops []ovsdb.Operation) error {
results, err := c.client.Transact(ctx, ops...)
if err != nil {
return err
}
_, err = ovsdb.CheckOperationResults(results, ops)
if err != nil {
// Log detailed per-operation errors for debugging
for i, r := range results {
if r.Error != "" {
opTable := ""
@@ -42,126 +68,10 @@ func (c *LiveOVNClient) transactOps(ctx context.Context, ops []ovsdb.Operation)
return err
}
// ACLSpec describes an OVN ACL rule for attachment to a port group.
// Name, Severity, and Meter are optional — set Severity when Log is true.
type ACLSpec struct {
Direction string // "to-lport" or "from-lport"
Priority int
Match string
Action string // "allow-related", "drop", "allow", "reject"
Name string
Log bool
Severity string // "alert", "warning", "notice", "info", "debug"
}
// OVNClient defines the interface for interacting with the OVN Northbound Database.
// This abstraction allows for mock implementations in tests.
type OVNClient interface {
// Connection lifecycle
Connect(ctx context.Context) error
Close()
Connected() bool
// Logical Switch (subnet)
CreateLogicalSwitch(ctx context.Context, ls *nbdb.LogicalSwitch) error
DeleteLogicalSwitch(ctx context.Context, name string) error
GetLogicalSwitch(ctx context.Context, name string) (*nbdb.LogicalSwitch, error)
ListLogicalSwitches(ctx context.Context) ([]nbdb.LogicalSwitch, error)
// Logical Switch Port (VM/ENI)
CreateLogicalSwitchPort(ctx context.Context, switchName string, lsp *nbdb.LogicalSwitchPort) error
// CreateLogicalSwitchPortInGroups creates an LSP, adds it to its switch,
// and joins it to the named port groups — all in a single OVSDB
// transaction. Required for SG enforcement: a non-atomic create-then-join
// leaves a window where the LSP exists outside any port group (OVN
// default = unrestricted). portGroupNames may be empty (e.g. router/
// localnet ports). The per-port-group `_ip4`/`_ip6` Address_Set rows in
// SB are auto-derived by ovn-northd from each port group's port
// addresses; SG-to-SG match expressions resolve against those.
CreateLogicalSwitchPortInGroups(ctx context.Context, switchName string, lsp *nbdb.LogicalSwitchPort, portGroupNames []string) error
DeleteLogicalSwitchPort(ctx context.Context, switchName string, portName string) error
GetLogicalSwitchPort(ctx context.Context, name string) (*nbdb.LogicalSwitchPort, error)
UpdateLogicalSwitchPort(ctx context.Context, lsp *nbdb.LogicalSwitchPort) error
// Logical Router (VPC router)
CreateLogicalRouter(ctx context.Context, lr *nbdb.LogicalRouter) error
DeleteLogicalRouter(ctx context.Context, name string) error
GetLogicalRouter(ctx context.Context, name string) (*nbdb.LogicalRouter, error)
ListLogicalRouters(ctx context.Context) ([]nbdb.LogicalRouter, error)
// Logical Router Port
CreateLogicalRouterPort(ctx context.Context, routerName string, lrp *nbdb.LogicalRouterPort) error
DeleteLogicalRouterPort(ctx context.Context, routerName string, portName string) error
GetLogicalRouterPort(ctx context.Context, name string) (*nbdb.LogicalRouterPort, error)
UpdateLogicalRouterPort(ctx context.Context, lrp *nbdb.LogicalRouterPort) error
ListLogicalRouterPorts(ctx context.Context) ([]nbdb.LogicalRouterPort, error)
// DHCP Options
CreateDHCPOptions(ctx context.Context, opts *nbdb.DHCPOptions) (string, error)
DeleteDHCPOptions(ctx context.Context, uuid string) error
FindDHCPOptionsByCIDR(ctx context.Context, cidr string) (*nbdb.DHCPOptions, error)
FindDHCPOptionsByExternalID(ctx context.Context, key, value string) (*nbdb.DHCPOptions, error)
ListDHCPOptions(ctx context.Context) ([]nbdb.DHCPOptions, error)
// NAT rules
AddNAT(ctx context.Context, routerName string, nat *nbdb.NAT) error
DeleteNAT(ctx context.Context, routerName string, natType, logicalIP string) error
DeleteNATByExternalIP(ctx context.Context, routerName string, natType, externalIP string) error
DeleteAllNATsByExternalIP(ctx context.Context, natType, externalIP string) (int, error)
FindNATByExternalIP(ctx context.Context, natType, externalIP string) (*nbdb.NAT, error)
// Static routes
AddStaticRoute(ctx context.Context, routerName string, route *nbdb.LogicalRouterStaticRoute) error
DeleteStaticRoute(ctx context.Context, routerName string, ipPrefix string) error
FindStaticRoute(ctx context.Context, routerName, ipPrefix string) (*nbdb.LogicalRouterStaticRoute, error)
// Port Groups (security group enforcement)
CreatePortGroup(ctx context.Context, name string, ports []string) error
DeletePortGroup(ctx context.Context, name string) error
// UpdatePortGroupMemberships applies all port-group joins and leaves for a
// single LSP in one atomic OVSDB transaction. Required by reconcilePortSGs
// so a 5-SG → different-5-SG modify never exposes an intermediate state
// with fewer port groups (which would let the OVN default = unrestricted
// apply for the gap). The per-port-group `_ip4`/`_ip6` Address_Set rows
// in SB are auto-derived by ovn-northd from each port group's port
// addresses; no explicit address-set update is required here.
UpdatePortGroupMemberships(ctx context.Context, lspName string, addPGs, removePGs []string) error
// ListPortGroupsForPort returns the names of every port group whose Ports
// set contains the given LSP. Used by reconcilePortSGs to discover current
// membership before computing the add/remove diff against desired.
ListPortGroupsForPort(ctx context.Context, lspName string) ([]string, error)
// GetPortGroup returns the port group with the given name, or an error if
// it doesn't exist. Used by the reconciler to detect SGs whose port group
// has gone missing in OVN NB.
GetPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error)
// ListPortGroups returns every port group in OVN NB. Used by the
// reconciler's orphan-PG scan to detect spinifex-managed port groups
// (`sg_*`) that no longer have a matching SG record in KV.
ListPortGroups(ctx context.Context) ([]nbdb.PortGroup, error)
// ACLs (attached to port groups). AddACLs creates all rows and links
// them to the port group in one OVSDB transaction — important when a
// single SG can carry up to 60 ingress + 60 egress rules.
AddACLs(ctx context.Context, portGroupName string, specs []ACLSpec) error
ClearACLs(ctx context.Context, portGroupName string) error
// Gateway Chassis (HA scheduling for gateway router ports)
SetGatewayChassis(ctx context.Context, lrpName string, chassisName string, priority int) error
GetGatewayChassisByName(ctx context.Context, name string) (*nbdb.GatewayChassis, error)
ListGatewayChassis(ctx context.Context) ([]nbdb.GatewayChassis, error)
DeleteGatewayChassis(ctx context.Context, lrpName string, gcUUID string) error
}
// namedUUID generates a valid OVSDB named-uuid from a prefix and name.
// OVSDB named-uuids must match [_a-zA-Z][_a-zA-Z0-9]* — anything outside
// that class (spaces, '@', '=', '&', '-', '.', '/', etc.) is replaced with
// an underscore. Callers always supply a prefix that begins with a letter,
// so the leading-character constraint is satisfied implicitly.
//
// Without exhaustive sanitisation, ACL named-uuids that embed a Match
// expression like "outport == @pg-sg-XYZ && ip4" produce strings with
// spaces and '@' that OVSDB rejects with "Type mismatch for member
// 'uuid-name'", which silently breaks every default-SG ACL transaction.
// namedUUID builds a valid OVSDB named-uuid by replacing every non
// [_a-zA-Z0-9] byte with `_`. Required for ACL UUIDs that embed Match
// expressions like "outport == @pg-sg-XYZ && ip4"; without this, OVSDB
// rejects them with "Type mismatch for member 'uuid-name'".
func namedUUID(prefix, name string) string {
s := prefix + name
result := make([]byte, len(s))
@@ -180,21 +90,21 @@ func namedUUID(prefix, name string) string {
return string(result)
}
// LiveOVNClient implements OVNClient using libovsdb against a real OVN NB DB.
type LiveOVNClient struct {
// LiveClient implements Client using libovsdb against a real OVN NB DB.
type LiveClient struct {
endpoint string
client client.Client
}
var _ OVNClient = (*LiveOVNClient)(nil)
var _ Client = (*LiveClient)(nil)
// NewLiveOVNClient creates a new LiveOVNClient targeting the given OVN NB DB endpoint.
// The endpoint should be in the format "tcp:host:port" or "unix:/path/to/socket".
func NewLiveOVNClient(endpoint string) *LiveOVNClient {
return &LiveOVNClient{endpoint: endpoint}
// NewLiveClient creates a LiveClient targeting an OVN NB DB endpoint
// ("tcp:host:port" or "unix:/path/to/socket").
func NewLiveClient(endpoint string) *LiveClient {
return &LiveClient{endpoint: endpoint}
}
func (c *LiveOVNClient) Connect(ctx context.Context) error {
func (c *LiveClient) Connect(ctx context.Context) error {
dbModel, err := nbdb.FullDatabaseModel()
if err != nil {
return fmt.Errorf("failed to create database model: %w", err)
@@ -220,18 +130,18 @@ func (c *LiveOVNClient) Connect(ctx context.Context) error {
return nil
}
func (c *LiveOVNClient) Close() {
func (c *LiveClient) Close() {
if c.client != nil {
c.client.Close()
slog.Info("Disconnected from OVN NB DB")
}
}
func (c *LiveOVNClient) Connected() bool {
func (c *LiveClient) Connected() bool {
return c.client != nil
}
func (c *LiveOVNClient) CreateLogicalSwitch(ctx context.Context, ls *nbdb.LogicalSwitch) error {
func (c *LiveClient) CreateLogicalSwitch(ctx context.Context, ls *nbdb.LogicalSwitch) error {
ops, err := c.client.Create(ls)
if err != nil {
return fmt.Errorf("create logical switch ops: %w", err)
@@ -243,7 +153,49 @@ func (c *LiveOVNClient) CreateLogicalSwitch(ctx context.Context, ls *nbdb.Logica
return nil
}
func (c *LiveOVNClient) DeleteLogicalSwitch(ctx context.Context, name string) error {
func (c *LiveClient) EnsureLogicalSwitch(ctx context.Context, ls *nbdb.LogicalSwitch) (*nbdb.LogicalSwitch, error) {
if existing, err := c.GetLogicalSwitch(ctx, ls.Name); err == nil {
return existing, nil
}
if ls.UUID == "" {
ls.UUID = namedUUID("ls_", ls.Name)
}
ops, err := c.ensureNamedRowOps("Logical_Switch", ls.Name, ls)
if err != nil {
return nil, err
}
if err := c.transactOps(ctx, ops); err != nil {
if existing, refErr := c.waitForCachedSwitch(ctx, ls.Name); refErr == nil {
slog.Info("ovn: EnsureLogicalSwitch lost race, reusing existing",
"name", ls.Name, "existing_uuid", existing.UUID)
return existing, nil
}
return nil, fmt.Errorf("ensure logical switch %q: %w", ls.Name, err)
}
return ls, nil
}
func (c *LiveClient) waitForCachedSwitch(ctx context.Context, name string) (*nbdb.LogicalSwitch, error) {
deadline := time.Now().Add(ensureRefetchTimeout)
var lastErr error
for {
v, err := c.GetLogicalSwitch(ctx, name)
if err == nil {
return v, nil
}
lastErr = err
if time.Now().After(deadline) {
return nil, lastErr
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(ensureRefetchInterval):
}
}
}
func (c *LiveClient) DeleteLogicalSwitch(ctx context.Context, name string) error {
ls, err := c.GetLogicalSwitch(ctx, name)
if err != nil {
return fmt.Errorf("delete logical switch lookup: %w", err)
@@ -259,7 +211,7 @@ func (c *LiveOVNClient) DeleteLogicalSwitch(ctx context.Context, name string) er
return nil
}
func (c *LiveOVNClient) GetLogicalSwitch(ctx context.Context, name string) (*nbdb.LogicalSwitch, error) {
func (c *LiveClient) GetLogicalSwitch(ctx context.Context, name string) (*nbdb.LogicalSwitch, error) {
var switches []nbdb.LogicalSwitch
err := c.client.WhereCache(func(ls *nbdb.LogicalSwitch) bool {
return ls.Name == name
@@ -273,7 +225,7 @@ func (c *LiveOVNClient) GetLogicalSwitch(ctx context.Context, name string) (*nbd
return &switches[0], nil
}
func (c *LiveOVNClient) ListLogicalSwitches(ctx context.Context) ([]nbdb.LogicalSwitch, error) {
func (c *LiveClient) ListLogicalSwitches(ctx context.Context) ([]nbdb.LogicalSwitch, error) {
var switches []nbdb.LogicalSwitch
err := c.client.List(ctx, &switches)
if err != nil {
@@ -282,25 +234,22 @@ func (c *LiveOVNClient) ListLogicalSwitches(ctx context.Context) ([]nbdb.Logical
return switches, nil
}
func (c *LiveOVNClient) CreateLogicalSwitchPort(ctx context.Context, switchName string, lsp *nbdb.LogicalSwitchPort) error {
// Set a named UUID so the port can be referenced in the same transaction
func (c *LiveClient) CreateLogicalSwitchPort(ctx context.Context, switchName string, lsp *nbdb.LogicalSwitchPort) error {
// Named UUID lets the same transaction reference the port.
if lsp.UUID == "" {
lsp.UUID = namedUUID("lsp_", lsp.Name)
}
// Create the port
createOps, err := c.client.Create(lsp)
if err != nil {
return fmt.Errorf("create logical switch port ops: %w", err)
}
// Look up the switch to get its UUID for the Where clause
ls, err := c.GetLogicalSwitch(ctx, switchName)
if err != nil {
return fmt.Errorf("get logical switch for port add: %w", err)
}
// Add the port to the switch's ports set (uses named UUID from Create)
mutateOps, err := c.client.Where(ls).Mutate(ls, model.Mutation{
Field: &ls.Ports,
Mutator: "insert",
@@ -318,14 +267,11 @@ func (c *LiveOVNClient) CreateLogicalSwitchPort(ctx context.Context, switchName
return nil
}
// CreateLogicalSwitchPortInGroups bundles LSP create + switch ports mutate +
// per-port-group ports mutates into one transaction. Within the transaction
// the LSP's named UUID resolves consistently across all ops, so the port
// group mutates can reference the not-yet-committed LSP without a cache
// lookup. The per-port-group `_ip4`/`_ip6` Address_Set rows in SB are
// auto-derived by ovn-northd from each port group's port addresses, so vpcd
// must not write them explicitly — see provisionSG for details.
func (c *LiveOVNClient) CreateLogicalSwitchPortInGroups(ctx context.Context, switchName string, lsp *nbdb.LogicalSwitchPort, portGroupNames []string) error {
// CreateLogicalSwitchPortInGroups bundles LSP create, switch-ports mutate, and
// per-port-group ports mutates into one transaction so the LSP's named UUID
// resolves across all ops. The per-PG `_ip4`/`_ip6` Address_Set rows are
// auto-derived by ovn-northd; vpcd must not write them (see provisionSG).
func (c *LiveClient) CreateLogicalSwitchPortInGroups(ctx context.Context, switchName string, lsp *nbdb.LogicalSwitchPort, portGroupNames []string) error {
if lsp.UUID == "" {
lsp.UUID = namedUUID("lsp_", lsp.Name)
}
@@ -373,20 +319,17 @@ func (c *LiveOVNClient) CreateLogicalSwitchPortInGroups(ctx context.Context, swi
return nil
}
func (c *LiveOVNClient) DeleteLogicalSwitchPort(ctx context.Context, switchName string, portName string) error {
// Look up the port to get its UUID
func (c *LiveClient) DeleteLogicalSwitchPort(ctx context.Context, switchName string, portName string) error {
lsp, err := c.GetLogicalSwitchPort(ctx, portName)
if err != nil {
return fmt.Errorf("get logical switch port for delete: %w", err)
}
// Look up the switch to get its UUID
ls, err := c.GetLogicalSwitch(ctx, switchName)
if err != nil {
return fmt.Errorf("get logical switch for port delete: %w", err)
}
// Remove the port from the switch's ports set
mutateOps, err := c.client.Where(ls).Mutate(ls, model.Mutation{
Field: &ls.Ports,
Mutator: "delete",
@@ -396,7 +339,6 @@ func (c *LiveOVNClient) DeleteLogicalSwitchPort(ctx context.Context, switchName
return fmt.Errorf("mutate logical switch ports ops: %w", err)
}
// Delete the port
deleteOps, err := c.client.Where(lsp).Delete()
if err != nil {
return fmt.Errorf("delete logical switch port ops: %w", err)
@@ -410,7 +352,7 @@ func (c *LiveOVNClient) DeleteLogicalSwitchPort(ctx context.Context, switchName
return nil
}
func (c *LiveOVNClient) GetLogicalSwitchPort(ctx context.Context, name string) (*nbdb.LogicalSwitchPort, error) {
func (c *LiveClient) GetLogicalSwitchPort(ctx context.Context, name string) (*nbdb.LogicalSwitchPort, error) {
var ports []nbdb.LogicalSwitchPort
err := c.client.WhereCache(func(lsp *nbdb.LogicalSwitchPort) bool {
return lsp.Name == name
@@ -424,8 +366,7 @@ func (c *LiveOVNClient) GetLogicalSwitchPort(ctx context.Context, name string) (
return &ports[0], nil
}
func (c *LiveOVNClient) UpdateLogicalSwitchPort(ctx context.Context, lsp *nbdb.LogicalSwitchPort) error {
// Ensure we have the UUID for the Where clause
func (c *LiveClient) UpdateLogicalSwitchPort(ctx context.Context, lsp *nbdb.LogicalSwitchPort) error {
if lsp.UUID == "" {
existing, err := c.GetLogicalSwitchPort(ctx, lsp.Name)
if err != nil {
@@ -444,7 +385,7 @@ func (c *LiveOVNClient) UpdateLogicalSwitchPort(ctx context.Context, lsp *nbdb.L
return nil
}
func (c *LiveOVNClient) CreateLogicalRouter(ctx context.Context, lr *nbdb.LogicalRouter) error {
func (c *LiveClient) CreateLogicalRouter(ctx context.Context, lr *nbdb.LogicalRouter) error {
ops, err := c.client.Create(lr)
if err != nil {
return fmt.Errorf("create logical router ops: %w", err)
@@ -456,7 +397,65 @@ func (c *LiveOVNClient) CreateLogicalRouter(ctx context.Context, lr *nbdb.Logica
return nil
}
func (c *LiveOVNClient) DeleteLogicalRouter(ctx context.Context, name string) error {
func (c *LiveClient) EnsureLogicalRouter(ctx context.Context, lr *nbdb.LogicalRouter) (*nbdb.LogicalRouter, error) {
if existing, err := c.GetLogicalRouter(ctx, lr.Name); err == nil {
return existing, nil
}
if lr.UUID == "" {
lr.UUID = namedUUID("lr_", lr.Name)
}
ops, err := c.ensureNamedRowOps("Logical_Router", lr.Name, lr)
if err != nil {
return nil, err
}
if err := c.transactOps(ctx, ops); err != nil {
if existing, refErr := c.waitForCachedRouter(ctx, lr.Name); refErr == nil {
slog.Info("ovn: EnsureLogicalRouter lost race, reusing existing",
"name", lr.Name, "existing_uuid", existing.UUID)
return existing, nil
}
return nil, fmt.Errorf("ensure logical router %q: %w", lr.Name, err)
}
return lr, nil
}
func (c *LiveClient) UpdateLogicalRouterExternalIDs(ctx context.Context, name string, externalIDs map[string]string) error {
lr, err := c.GetLogicalRouter(ctx, name)
if err != nil {
return fmt.Errorf("get logical router for external_ids update: %w", err)
}
lr.ExternalIDs = externalIDs
ops, err := c.client.Where(lr).Update(lr, &lr.ExternalIDs)
if err != nil {
return fmt.Errorf("update logical router external_ids ops: %w", err)
}
if err := c.transactOps(ctx, ops); err != nil {
return fmt.Errorf("update logical router external_ids transact: %w", err)
}
return nil
}
func (c *LiveClient) waitForCachedRouter(ctx context.Context, name string) (*nbdb.LogicalRouter, error) {
deadline := time.Now().Add(ensureRefetchTimeout)
var lastErr error
for {
v, err := c.GetLogicalRouter(ctx, name)
if err == nil {
return v, nil
}
lastErr = err
if time.Now().After(deadline) {
return nil, lastErr
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(ensureRefetchInterval):
}
}
}
func (c *LiveClient) DeleteLogicalRouter(ctx context.Context, name string) error {
lr, err := c.GetLogicalRouter(ctx, name)
if err != nil {
return fmt.Errorf("delete logical router lookup: %w", err)
@@ -472,7 +471,7 @@ func (c *LiveOVNClient) DeleteLogicalRouter(ctx context.Context, name string) er
return nil
}
func (c *LiveOVNClient) GetLogicalRouter(ctx context.Context, name string) (*nbdb.LogicalRouter, error) {
func (c *LiveClient) GetLogicalRouter(ctx context.Context, name string) (*nbdb.LogicalRouter, error) {
var routers []nbdb.LogicalRouter
err := c.client.WhereCache(func(lr *nbdb.LogicalRouter) bool {
return lr.Name == name
@@ -486,7 +485,7 @@ func (c *LiveOVNClient) GetLogicalRouter(ctx context.Context, name string) (*nbd
return &routers[0], nil
}
func (c *LiveOVNClient) ListLogicalRouters(ctx context.Context) ([]nbdb.LogicalRouter, error) {
func (c *LiveClient) ListLogicalRouters(ctx context.Context) ([]nbdb.LogicalRouter, error) {
var routers []nbdb.LogicalRouter
err := c.client.List(ctx, &routers)
if err != nil {
@@ -495,8 +494,8 @@ func (c *LiveOVNClient) ListLogicalRouters(ctx context.Context) ([]nbdb.LogicalR
return routers, nil
}
func (c *LiveOVNClient) CreateLogicalRouterPort(ctx context.Context, routerName string, lrp *nbdb.LogicalRouterPort) error {
// Set a named UUID so the port can be referenced in the same transaction
func (c *LiveClient) CreateLogicalRouterPort(ctx context.Context, routerName string, lrp *nbdb.LogicalRouterPort) error {
// Named UUID lets the same transaction reference the port.
if lrp.UUID == "" {
lrp.UUID = namedUUID("lrp_", lrp.Name)
}
@@ -506,7 +505,6 @@ func (c *LiveOVNClient) CreateLogicalRouterPort(ctx context.Context, routerName
return fmt.Errorf("create logical router port ops: %w", err)
}
// Look up the router to get its UUID for the Where clause
lr, err := c.GetLogicalRouter(ctx, routerName)
if err != nil {
return fmt.Errorf("get logical router for port add: %w", err)
@@ -529,14 +527,12 @@ func (c *LiveOVNClient) CreateLogicalRouterPort(ctx context.Context, routerName
return nil
}
func (c *LiveOVNClient) DeleteLogicalRouterPort(ctx context.Context, routerName string, portName string) error {
// Look up the port to get its UUID
func (c *LiveClient) DeleteLogicalRouterPort(ctx context.Context, routerName string, portName string) error {
lrp, err := c.GetLogicalRouterPort(ctx, portName)
if err != nil {
return fmt.Errorf("get logical router port for delete: %w", err)
}
// Look up the router to get its UUID
lr, err := c.GetLogicalRouter(ctx, routerName)
if err != nil {
return fmt.Errorf("get logical router for port delete: %w", err)
@@ -564,7 +560,7 @@ func (c *LiveOVNClient) DeleteLogicalRouterPort(ctx context.Context, routerName
return nil
}
func (c *LiveOVNClient) GetLogicalRouterPort(ctx context.Context, name string) (*nbdb.LogicalRouterPort, error) {
func (c *LiveClient) GetLogicalRouterPort(ctx context.Context, name string) (*nbdb.LogicalRouterPort, error) {
var ports []nbdb.LogicalRouterPort
err := c.client.WhereCache(func(lrp *nbdb.LogicalRouterPort) bool {
return lrp.Name == name
@@ -579,10 +575,7 @@ func (c *LiveOVNClient) GetLogicalRouterPort(ctx context.Context, name string) (
}
// UpdateLogicalRouterPort rewrites mutable columns on an existing LRP.
// Used by ensureGatewayPortNetworks to retrofit the link-local Networks
// CIDR onto gateway ports created by older code that used pool IPs
// (mulga-siv-26 D8). Mirrors UpdateLogicalSwitchPort.
func (c *LiveOVNClient) UpdateLogicalRouterPort(ctx context.Context, lrp *nbdb.LogicalRouterPort) error {
func (c *LiveClient) UpdateLogicalRouterPort(ctx context.Context, lrp *nbdb.LogicalRouterPort) error {
if lrp.UUID == "" {
existing, err := c.GetLogicalRouterPort(ctx, lrp.Name)
if err != nil {
@@ -600,12 +593,10 @@ func (c *LiveOVNClient) UpdateLogicalRouterPort(ctx context.Context, lrp *nbdb.L
return nil
}
// SetGatewayChassis binds a chassis to an LRP for HA gateway scheduling. Read-
// then-decide so it tolerates re-runs of reconcile/IGW-attach: absent → create
// + LRP-mutate; present + same priority → no-op; present + different priority
// → mutate priority on the existing row. Required for the reconcile-time
// rebind step that recovers from chassis_name drift (mulga-999).
func (c *LiveOVNClient) SetGatewayChassis(ctx context.Context, lrpName string, chassisName string, priority int) error {
// SetGatewayChassis binds a chassis to an LRP for HA gateway scheduling.
// Read-then-decide so reconcile/IGW-attach can re-run safely: absent →
// create + LRP-mutate; same priority → no-op; differing priority → mutate.
func (c *LiveClient) SetGatewayChassis(ctx context.Context, lrpName string, chassisName string, priority int) error {
gcName := lrpName + "-" + chassisName
existing, err := c.GetGatewayChassisByName(ctx, gcName)
if err != nil {
@@ -653,7 +644,7 @@ func (c *LiveOVNClient) SetGatewayChassis(ctx context.Context, lrpName string, c
return nil
}
func (c *LiveOVNClient) updateGatewayChassisPriority(ctx context.Context, gc *nbdb.GatewayChassis, priority int) error {
func (c *LiveClient) updateGatewayChassisPriority(ctx context.Context, gc *nbdb.GatewayChassis, priority int) error {
gc.Priority = priority
ops, err := c.client.Where(gc).Update(gc, &gc.Priority)
if err != nil {
@@ -666,10 +657,8 @@ func (c *LiveOVNClient) updateGatewayChassisPriority(ctx context.Context, gc *nb
}
// GetGatewayChassisByName looks up a Gateway_Chassis row by its `name` column
// (the deterministic "lrpName-chassisName" form). Returns (nil, nil) when no
// row matches — matches the cache-lookup convention used by GetLogicalSwitch
// et al.
func (c *LiveOVNClient) GetGatewayChassisByName(ctx context.Context, name string) (*nbdb.GatewayChassis, error) {
// (the "lrpName-chassisName" form). Returns (nil, nil) when no row matches.
func (c *LiveClient) GetGatewayChassisByName(ctx context.Context, name string) (*nbdb.GatewayChassis, error) {
var rows []nbdb.GatewayChassis
err := c.client.WhereCache(func(gc *nbdb.GatewayChassis) bool {
return gc.Name == name
@@ -683,11 +672,9 @@ func (c *LiveOVNClient) GetGatewayChassisByName(ctx context.Context, name string
return &rows[0], nil
}
// ListGatewayChassis returns every Gateway_Chassis row. The reconcile loop
// uses this to find rows referencing chassis names that no longer exist in
// the SBDB (e.g. because the OVS system-id changed across a reboot —
// mulga-999).
func (c *LiveOVNClient) ListGatewayChassis(ctx context.Context) ([]nbdb.GatewayChassis, error) {
// ListGatewayChassis returns every Gateway_Chassis row; reconcile uses this
// to find rows whose chassis_name no longer exists in SBDB.
func (c *LiveClient) ListGatewayChassis(ctx context.Context) ([]nbdb.GatewayChassis, error) {
var rows []nbdb.GatewayChassis
if err := c.client.List(ctx, &rows); err != nil {
return nil, fmt.Errorf("list gateway_chassis: %w", err)
@@ -695,10 +682,9 @@ func (c *LiveOVNClient) ListGatewayChassis(ctx context.Context) ([]nbdb.GatewayC
return rows, nil
}
// DeleteGatewayChassis removes a Gateway_Chassis row and detaches it from the
// owning LRP in one transaction. lrpName is required so the mutation targets
// the correct router port; gcUUID identifies the row to remove.
func (c *LiveOVNClient) DeleteGatewayChassis(ctx context.Context, lrpName string, gcUUID string) error {
// DeleteGatewayChassis removes a Gateway_Chassis row and detaches it from
// the owning LRP in one transaction.
func (c *LiveClient) DeleteGatewayChassis(ctx context.Context, lrpName string, gcUUID string) error {
lrp, err := c.GetLogicalRouterPort(ctx, lrpName)
if err != nil {
return fmt.Errorf("get logical router port for gateway chassis delete: %w", err)
@@ -726,10 +712,8 @@ func (c *LiveOVNClient) DeleteGatewayChassis(ctx context.Context, lrpName string
return nil
}
// ListLogicalRouterPorts returns every LRP across all routers. Used by the
// reconcile-time gateway-chassis rebind to find every LRP tagged
// external_ids:spinifex:role=gateway (mulga-999).
func (c *LiveOVNClient) ListLogicalRouterPorts(ctx context.Context) ([]nbdb.LogicalRouterPort, error) {
// ListLogicalRouterPorts returns every LRP across all routers.
func (c *LiveClient) ListLogicalRouterPorts(ctx context.Context) ([]nbdb.LogicalRouterPort, error) {
var rows []nbdb.LogicalRouterPort
if err := c.client.List(ctx, &rows); err != nil {
return nil, fmt.Errorf("list logical_router_port: %w", err)
@@ -737,7 +721,7 @@ func (c *LiveOVNClient) ListLogicalRouterPorts(ctx context.Context) ([]nbdb.Logi
return rows, nil
}
func (c *LiveOVNClient) CreateDHCPOptions(ctx context.Context, opts *nbdb.DHCPOptions) (string, error) {
func (c *LiveClient) CreateDHCPOptions(ctx context.Context, opts *nbdb.DHCPOptions) (string, error) {
ops, err := c.client.Create(opts)
if err != nil {
return "", fmt.Errorf("create DHCP options ops: %w", err)
@@ -755,7 +739,7 @@ func (c *LiveOVNClient) CreateDHCPOptions(ctx context.Context, opts *nbdb.DHCPOp
return "", nil
}
func (c *LiveOVNClient) DeleteDHCPOptions(ctx context.Context, uuid string) error {
func (c *LiveClient) DeleteDHCPOptions(ctx context.Context, uuid string) error {
opts := &nbdb.DHCPOptions{UUID: uuid}
ops, err := c.client.Where(opts).Delete()
if err != nil {
@@ -768,7 +752,7 @@ func (c *LiveOVNClient) DeleteDHCPOptions(ctx context.Context, uuid string) erro
return nil
}
func (c *LiveOVNClient) FindDHCPOptionsByCIDR(ctx context.Context, cidr string) (*nbdb.DHCPOptions, error) {
func (c *LiveClient) FindDHCPOptionsByCIDR(ctx context.Context, cidr string) (*nbdb.DHCPOptions, error) {
var options []nbdb.DHCPOptions
err := c.client.WhereCache(func(o *nbdb.DHCPOptions) bool {
return o.CIDR == cidr
@@ -782,7 +766,7 @@ func (c *LiveOVNClient) FindDHCPOptionsByCIDR(ctx context.Context, cidr string)
return &options[0], nil
}
func (c *LiveOVNClient) FindDHCPOptionsByExternalID(ctx context.Context, key, value string) (*nbdb.DHCPOptions, error) {
func (c *LiveClient) FindDHCPOptionsByExternalID(ctx context.Context, key, value string) (*nbdb.DHCPOptions, error) {
var options []nbdb.DHCPOptions
err := c.client.WhereCache(func(o *nbdb.DHCPOptions) bool {
return o.ExternalIDs[key] == value
@@ -796,7 +780,7 @@ func (c *LiveOVNClient) FindDHCPOptionsByExternalID(ctx context.Context, key, va
return &options[0], nil
}
func (c *LiveOVNClient) ListDHCPOptions(ctx context.Context) ([]nbdb.DHCPOptions, error) {
func (c *LiveClient) ListDHCPOptions(ctx context.Context) ([]nbdb.DHCPOptions, error) {
var options []nbdb.DHCPOptions
err := c.client.List(ctx, &options)
if err != nil {
@@ -805,8 +789,7 @@ func (c *LiveOVNClient) ListDHCPOptions(ctx context.Context) ([]nbdb.DHCPOptions
return options, nil
}
func (c *LiveOVNClient) AddNAT(ctx context.Context, routerName string, nat *nbdb.NAT) error {
// Set a named UUID so the NAT can be referenced in the same transaction
func (c *LiveClient) AddNAT(ctx context.Context, routerName string, nat *nbdb.NAT) error {
if nat.UUID == "" {
nat.UUID = namedUUID("nat_", nat.Type+"_"+nat.LogicalIP)
}
@@ -838,25 +821,35 @@ func (c *LiveOVNClient) AddNAT(ctx context.Context, routerName string, nat *nbdb
return nil
}
func (c *LiveOVNClient) DeleteNAT(ctx context.Context, routerName string, natType, logicalIP string) error {
// Find the NAT entry by type and logical IP
// DeleteNAT removes a NAT rule matching (natType, logicalIP) from routerName.
// Lookup is scoped to routerName.NAT — rules owned by other routers with the
// same logical IP are left alone. Required because AWS subnet CIDRs repeat
// across VPCs, so (natType, logicalIP) is not globally unique.
func (c *LiveClient) DeleteNAT(ctx context.Context, routerName string, natType, logicalIP string) error {
lr, err := c.GetLogicalRouter(ctx, routerName)
if err != nil {
return fmt.Errorf("get logical router for NAT delete: %w", err)
}
routerNATs := make(map[string]struct{}, len(lr.NAT))
for _, uuid := range lr.NAT {
routerNATs[uuid] = struct{}{}
}
var nats []nbdb.NAT
err := c.client.WhereCache(func(n *nbdb.NAT) bool {
err = c.client.WhereCache(func(n *nbdb.NAT) bool {
if _, ok := routerNATs[n.UUID]; !ok {
return false
}
return n.Type == natType && n.LogicalIP == logicalIP
}).List(ctx, &nats)
if err != nil {
return fmt.Errorf("find NAT: %w", err)
}
if len(nats) == 0 {
return fmt.Errorf("NAT %s %s: %w", natType, logicalIP, ErrNATNotFound)
return fmt.Errorf("NAT %s %s on %s: %w", natType, logicalIP, routerName, ErrNATNotFound)
}
nat := &nats[0]
lr, err := c.GetLogicalRouter(ctx, routerName)
if err != nil {
return fmt.Errorf("get logical router for NAT delete: %w", err)
}
mutateOps, err := c.client.Where(lr).Mutate(lr, model.Mutation{
Field: &lr.NAT,
Mutator: "delete",
@@ -879,23 +872,33 @@ func (c *LiveOVNClient) DeleteNAT(ctx context.Context, routerName string, natTyp
return nil
}
// DeleteNATByExternalIP removes a NAT rule matching the given external IP.
// Returns an error if no matching rule is found (callers can ignore this).
func (c *LiveOVNClient) DeleteNATByExternalIP(ctx context.Context, routerName string, natType, externalIP string) error {
// DeleteNATByExternalIP removes a NAT rule matching the given external IP from
// routerName only. Lookup is scoped to routerName.NAT — rules on other routers
// with the same external IP are left alone (use DeleteAllNATsByExternalIP for
// cross-router cleanup). Returns ErrNATNotFound if no matching rule is found
// on this router (callers can ignore this).
func (c *LiveClient) DeleteNATByExternalIP(ctx context.Context, routerName string, natType, externalIP string) error {
lr, err := c.GetLogicalRouter(ctx, routerName)
if err != nil {
return fmt.Errorf("get logical router for NAT delete: %w", err)
}
routerNATs := make(map[string]struct{}, len(lr.NAT))
for _, uuid := range lr.NAT {
routerNATs[uuid] = struct{}{}
}
var nats []nbdb.NAT
err := c.client.WhereCache(func(n *nbdb.NAT) bool {
err = c.client.WhereCache(func(n *nbdb.NAT) bool {
if _, ok := routerNATs[n.UUID]; !ok {
return false
}
return n.Type == natType && n.ExternalIP == externalIP
}).List(ctx, &nats)
if err != nil {
return fmt.Errorf("find NAT by external IP: %w", err)
}
if len(nats) == 0 {
return fmt.Errorf("NAT %s external_ip=%s: %w", natType, externalIP, ErrNATNotFound)
}
lr, err := c.GetLogicalRouter(ctx, routerName)
if err != nil {
return fmt.Errorf("get logical router for NAT delete: %w", err)
return fmt.Errorf("NAT %s external_ip=%s on %s: %w", natType, externalIP, routerName, ErrNATNotFound)
}
var allOps []ovsdb.Operation
@@ -923,11 +926,11 @@ func (c *LiveOVNClient) DeleteNATByExternalIP(ctx context.Context, routerName st
return nil
}
// DeleteAllNATsByExternalIP removes all NAT rules matching the given external
// IP from every router that references them. This handles cross-VPC stale NAT
// rules that remain when vpc.delete-nat (fire-and-forget) hasn't been processed
// before an IP is reused by a different VPC. Returns the number of rules deleted.
func (c *LiveOVNClient) DeleteAllNATsByExternalIP(ctx context.Context, natType, externalIP string) (int, error) {
// DeleteAllNATsByExternalIP removes NAT rules matching externalIP from every
// router that references them, handling cross-VPC stale rules left when
// vpc.delete-nat hasn't been processed before the IP is reused. Returns the
// number deleted.
func (c *LiveClient) DeleteAllNATsByExternalIP(ctx context.Context, natType, externalIP string) (int, error) {
var nats []nbdb.NAT
if err := c.client.WhereCache(func(n *nbdb.NAT) bool {
return n.Type == natType && n.ExternalIP == externalIP
@@ -948,7 +951,6 @@ func (c *LiveOVNClient) DeleteAllNATsByExternalIP(ctx context.Context, natType,
staleUUIDs[n.UUID] = struct{}{}
}
// Build a single transaction: remove NAT refs from all routers, then delete NAT rows.
var allOps []ovsdb.Operation
for i := range routers {
lr := &routers[i]
@@ -978,10 +980,9 @@ func (c *LiveOVNClient) DeleteAllNATsByExternalIP(ctx context.Context, natType,
return len(nats), nil
}
// FindNATByExternalIP returns the first NAT rule matching the given type and
// external IP, or nil if none exists. Used by the startup reconcile to check
// whether an EIP already has a dnat_and_snat rule before (re-)creating one.
func (c *LiveOVNClient) FindNATByExternalIP(ctx context.Context, natType, externalIP string) (*nbdb.NAT, error) {
// FindNATByExternalIP returns the first NAT rule matching (natType,
// externalIP) or nil if none exists.
func (c *LiveClient) FindNATByExternalIP(ctx context.Context, natType, externalIP string) (*nbdb.NAT, error) {
var nats []nbdb.NAT
if err := c.client.WhereCache(func(n *nbdb.NAT) bool {
return n.Type == natType && n.ExternalIP == externalIP
@@ -994,8 +995,7 @@ func (c *LiveOVNClient) FindNATByExternalIP(ctx context.Context, natType, extern
return &nats[0], nil
}
func (c *LiveOVNClient) AddStaticRoute(ctx context.Context, routerName string, route *nbdb.LogicalRouterStaticRoute) error {
// Set a named UUID so the route can be referenced in the same transaction
func (c *LiveClient) AddStaticRoute(ctx context.Context, routerName string, route *nbdb.LogicalRouterStaticRoute) error {
if route.UUID == "" {
route.UUID = namedUUID("route_", route.IPPrefix)
}
@@ -1027,12 +1027,10 @@ func (c *LiveOVNClient) AddStaticRoute(ctx context.Context, routerName string, r
return nil
}
// FindStaticRoute returns the first static route on routerName whose IPPrefix
// matches. (nil, nil) when no row matches — caller compares Nexthop and
// OutputPort to detect operator-overridden entries. AddStaticRoute is
// non-idempotent (every retry leaves a fresh duplicate row), so callers that
// run on every reconcile pass must dedupe via this helper first.
func (c *LiveOVNClient) FindStaticRoute(ctx context.Context, routerName, ipPrefix string) (*nbdb.LogicalRouterStaticRoute, error) {
// FindStaticRoute returns the first owned static route on routerName whose
// IPPrefix matches, or (nil, nil). Reconcile callers must dedupe via this
// helper because AddStaticRoute leaves a duplicate row on every retry.
func (c *LiveClient) FindStaticRoute(ctx context.Context, routerName, ipPrefix string) (*nbdb.LogicalRouterStaticRoute, error) {
lr, err := c.GetLogicalRouter(ctx, routerName)
if err != nil {
return nil, fmt.Errorf("get logical router for static route lookup: %w", err)
@@ -1057,8 +1055,7 @@ func (c *LiveOVNClient) FindStaticRoute(ctx context.Context, routerName, ipPrefi
return &routes[0], nil
}
func (c *LiveOVNClient) DeleteStaticRoute(ctx context.Context, routerName string, ipPrefix string) error {
// Find the route by IP prefix
func (c *LiveClient) DeleteStaticRoute(ctx context.Context, routerName string, ipPrefix string) error {
var routes []nbdb.LogicalRouterStaticRoute
err := c.client.WhereCache(func(r *nbdb.LogicalRouterStaticRoute) bool {
return r.IPPrefix == ipPrefix
@@ -1100,7 +1097,7 @@ func (c *LiveOVNClient) DeleteStaticRoute(ctx context.Context, routerName string
// Port Groups
func (c *LiveOVNClient) CreatePortGroup(ctx context.Context, name string, ports []string) error {
func (c *LiveClient) CreatePortGroup(ctx context.Context, name string, ports []string) error {
pg := &nbdb.PortGroup{
UUID: namedUUID("pg_", name),
Name: name,
@@ -1117,7 +1114,52 @@ func (c *LiveOVNClient) CreatePortGroup(ctx context.Context, name string, ports
return nil
}
func (c *LiveOVNClient) DeletePortGroup(ctx context.Context, name string) error {
func (c *LiveClient) EnsurePortGroup(ctx context.Context, name string, ports []string) (*nbdb.PortGroup, error) {
if existing, err := c.getPortGroup(ctx, name); err == nil {
return existing, nil
}
pg := &nbdb.PortGroup{
UUID: namedUUID("pg_", name),
Name: name,
Ports: ports,
ExternalIDs: map[string]string{},
}
ops, err := c.ensureNamedRowOps("Port_Group", name, pg)
if err != nil {
return nil, err
}
if err := c.transactOps(ctx, ops); err != nil {
if existing, refErr := c.waitForCachedPortGroup(ctx, name); refErr == nil {
slog.Info("ovn: EnsurePortGroup lost race, reusing existing",
"name", name, "existing_uuid", existing.UUID)
return existing, nil
}
return nil, fmt.Errorf("ensure port group %q: %w", name, err)
}
return pg, nil
}
func (c *LiveClient) waitForCachedPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error) {
deadline := time.Now().Add(ensureRefetchTimeout)
var lastErr error
for {
v, err := c.getPortGroup(ctx, name)
if err == nil {
return v, nil
}
lastErr = err
if time.Now().After(deadline) {
return nil, lastErr
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(ensureRefetchInterval):
}
}
}
func (c *LiveClient) DeletePortGroup(ctx context.Context, name string) error {
pg, err := c.getPortGroup(ctx, name)
if err != nil {
return fmt.Errorf("delete port group lookup: %w", err)
@@ -1132,7 +1174,7 @@ func (c *LiveOVNClient) DeletePortGroup(ctx context.Context, name string) error
return nil
}
func (c *LiveOVNClient) UpdatePortGroupMemberships(ctx context.Context, lspName string, addPGs, removePGs []string) error {
func (c *LiveClient) UpdatePortGroupMemberships(ctx context.Context, lspName string, addPGs, removePGs []string) error {
if len(addPGs) == 0 && len(removePGs) == 0 {
return nil
}
@@ -1176,7 +1218,7 @@ func (c *LiveOVNClient) UpdatePortGroupMemberships(ctx context.Context, lspName
return nil
}
func (c *LiveOVNClient) getPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error) {
func (c *LiveClient) getPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error) {
var pgs []nbdb.PortGroup
err := c.client.WhereCache(func(pg *nbdb.PortGroup) bool {
return pg.Name == name
@@ -1185,18 +1227,17 @@ func (c *LiveOVNClient) getPortGroup(ctx context.Context, name string) (*nbdb.Po
return nil, fmt.Errorf("get port group: %w", err)
}
if len(pgs) == 0 {
return nil, fmt.Errorf("port group %q not found", name)
return nil, fmt.Errorf("%w: %q", ErrPortGroupNotFound, name)
}
return &pgs[0], nil
}
// GetPortGroup is the exported wrapper around getPortGroup. Returns an error
// when the named port group is not present in the cache.
func (c *LiveOVNClient) GetPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error) {
// GetPortGroup is the exported wrapper around getPortGroup.
func (c *LiveClient) GetPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error) {
return c.getPortGroup(ctx, name)
}
func (c *LiveOVNClient) ListPortGroups(ctx context.Context) ([]nbdb.PortGroup, error) {
func (c *LiveClient) ListPortGroups(ctx context.Context) ([]nbdb.PortGroup, error) {
var pgs []nbdb.PortGroup
if err := c.client.List(ctx, &pgs); err != nil {
return nil, fmt.Errorf("list port groups: %w", err)
@@ -1204,11 +1245,9 @@ func (c *LiveOVNClient) ListPortGroups(ctx context.Context) ([]nbdb.PortGroup, e
return pgs, nil
}
// ListPortGroupsForPort scans the cache for port groups whose Ports set
// contains the given LSP's UUID. Returns the names. Returns an empty slice
// (not an error) when the LSP has no memberships, so reconcilePortSGs handles
// the "not currently in any group" case naturally.
func (c *LiveOVNClient) ListPortGroupsForPort(ctx context.Context, lspName string) ([]string, error) {
// ListPortGroupsForPort returns the names of port groups whose Ports set
// contains the LSP's UUID. Empty slice (not error) when no memberships.
func (c *LiveClient) ListPortGroupsForPort(ctx context.Context, lspName string) ([]string, error) {
lsp, err := c.GetLogicalSwitchPort(ctx, lspName)
if err != nil {
return nil, fmt.Errorf("list port groups for port lookup: %w", err)
@@ -1228,7 +1267,7 @@ func (c *LiveOVNClient) ListPortGroupsForPort(ctx context.Context, lspName strin
// ACLs
func (c *LiveOVNClient) AddACLs(ctx context.Context, portGroupName string, specs []ACLSpec) error {
func (c *LiveClient) AddACLs(ctx context.Context, portGroupName string, specs []ACLSpec) error {
if len(specs) == 0 {
return nil
}
@@ -1240,10 +1279,9 @@ func (c *LiveOVNClient) AddACLs(ctx context.Context, portGroupName string, specs
var ops []ovsdb.Operation
uuids := make([]string, 0, len(specs))
for i, spec := range specs {
// Index disambiguates ACLs that collapse to the same (direction,match)
// after sanitisation — e.g. a default-deny egress and a 0.0.0.0/0
// allow egress both produce "inport == @pg && ip4". Without the
// index, OVSDB rejects the second insert with "duplicate uuid-name".
// Index `i` disambiguates ACLs that collapse to the same
// (direction,match) — without it OVSDB rejects the second insert
// with "duplicate uuid-name".
acl := &nbdb.ACL{
UUID: namedUUID("acl_", fmt.Sprintf("%s_%d_%s", portGroupName, i, spec.Direction)),
Direction: spec.Direction,
@@ -1287,7 +1325,7 @@ func (c *LiveOVNClient) AddACLs(ctx context.Context, portGroupName string, specs
// ClearACLs removes every ACL row referenced by the port group and detaches
// them from the port group's ACLs set in one transaction.
func (c *LiveOVNClient) ClearACLs(ctx context.Context, portGroupName string) error {
func (c *LiveClient) ClearACLs(ctx context.Context, portGroupName string) error {
pg, err := c.getPortGroup(ctx, portGroupName)
if err != nil {
return fmt.Errorf("clear ACLs port group lookup: %w", err)
+38
View File
@@ -0,0 +1,38 @@
package ovn
import "testing"
func TestNamedUUID(t *testing.T) {
tests := []struct {
name string
prefix string
input string
expected string
}{
{"hyphen replacement", "sw_", "vpc-abc123", "sw_vpc_abc123"},
{"slash and dot replacement", "rp_", "10.0.0.1/24", "rp_10_0_0_1_24"},
{"no special characters", "lr_", "vpcabc", "lr_vpcabc"},
{"empty name", "sw_", "", "sw_"},
{"empty prefix", "", "vpc-1", "vpc_1"},
{"both empty", "", "", ""},
{"multiple consecutive hyphens", "x_", "a--b", "x_a__b"},
{"mixed special chars", "p_", "a.b-c/d", "p_a_b_c_d"},
// ACL match expressions embed @, =, &, space; OVSDB rejects unsanitised
// named-uuids with "Type mismatch for member 'uuid-name'".
{"acl match at sign", "acl_", "outport == @pg-sg-XYZ && ip4", "acl_outport_____pg_sg_XYZ____ip4"},
{"space replacement", "acl_", "a b c", "acl_a_b_c"},
{"equals replacement", "acl_", "a==b", "acl_a__b"},
{"ampersand replacement", "acl_", "a&&b", "acl_a__b"},
{"colon replacement", "acl_", "ip6.src == fe80::1", "acl_ip6_src____fe80__1"},
{"parens and quotes", "acl_", `ip4.src == "10.0.0.1"`, "acl_ip4_src_____10_0_0_1_"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := namedUUID(tt.prefix, tt.input)
if got != tt.expected {
t.Errorf("namedUUID(%q, %q) = %q, want %q", tt.prefix, tt.input, got, tt.expected)
}
})
}
}
+921
View File
@@ -0,0 +1,921 @@
// Package mock provides an in-memory implementation of ovn.Client for tests.
package mock
import (
"context"
"fmt"
"maps"
"slices"
"sync"
"github.com/mulgadc/spinifex/spinifex/network/ovn"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
"github.com/mulgadc/spinifex/spinifex/utils"
)
// Client implements ovn.Client with in-memory storage for testing.
type Client struct {
Mu sync.Mutex
connected bool
Switches map[string]*nbdb.LogicalSwitch
Ports map[string]*nbdb.LogicalSwitchPort
Routers map[string]*nbdb.LogicalRouter
RouterPorts map[string]*nbdb.LogicalRouterPort
DHCPOpts map[string]*nbdb.DHCPOptions
NATs map[string]*nbdb.NAT // keyed by UUID
StaticRoutes map[string]*nbdb.LogicalRouterStaticRoute // keyed by UUID
PortGroups map[string]*nbdb.PortGroup // keyed by name
ACLs map[string]*nbdb.ACL // keyed by UUID
GatewayChassis map[string]*nbdb.GatewayChassis // keyed by UUID
// Call counters so tests can assert idempotent / drift-only write paths.
UpdateLogicalSwitchPortCalls int
UpdateLogicalRouterPortCalls int
SetGatewayChassisCalls int
DeleteGatewayChassisCalls int
UpdateGatewayChassisPriorityCalls int
// AddACLErrAfter, when > 0, makes AddACLs return an error on the Nth
// (1-indexed) call. Lets tests inject a transient failure mid-flow.
AddACLErrAfter int
AddACLCalls int
}
var _ ovn.Client = (*Client)(nil)
// New creates a new mock Client for testing.
func New() *Client {
return &Client{
Switches: make(map[string]*nbdb.LogicalSwitch),
Ports: make(map[string]*nbdb.LogicalSwitchPort),
Routers: make(map[string]*nbdb.LogicalRouter),
RouterPorts: make(map[string]*nbdb.LogicalRouterPort),
DHCPOpts: make(map[string]*nbdb.DHCPOptions),
NATs: make(map[string]*nbdb.NAT),
StaticRoutes: make(map[string]*nbdb.LogicalRouterStaticRoute),
PortGroups: make(map[string]*nbdb.PortGroup),
ACLs: make(map[string]*nbdb.ACL),
GatewayChassis: make(map[string]*nbdb.GatewayChassis),
}
}
func (m *Client) Connect(_ context.Context) error {
m.Mu.Lock()
defer m.Mu.Unlock()
m.connected = true
return nil
}
func (m *Client) Close() {
m.Mu.Lock()
defer m.Mu.Unlock()
m.connected = false
}
func (m *Client) Connected() bool {
m.Mu.Lock()
defer m.Mu.Unlock()
return m.connected
}
// Logical Switch
func (m *Client) CreateLogicalSwitch(_ context.Context, ls *nbdb.LogicalSwitch) error {
m.Mu.Lock()
defer m.Mu.Unlock()
if _, exists := m.Switches[ls.Name]; exists {
return fmt.Errorf("logical switch %q already exists", ls.Name)
}
if ls.UUID == "" {
ls.UUID = utils.GenerateResourceID("ovn")
}
stored := *ls
m.Switches[ls.Name] = &stored
return nil
}
// EnsureLogicalSwitch mirrors the live client's wait-op semantics under the
// mock's single mutex: concurrent callers see the row on the second arrival.
func (m *Client) EnsureLogicalSwitch(_ context.Context, ls *nbdb.LogicalSwitch) (*nbdb.LogicalSwitch, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
if existing, ok := m.Switches[ls.Name]; ok {
result := *existing
return &result, nil
}
if ls.UUID == "" {
ls.UUID = utils.GenerateResourceID("ovn")
}
stored := *ls
m.Switches[ls.Name] = &stored
result := stored
return &result, nil
}
func (m *Client) DeleteLogicalSwitch(_ context.Context, name string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
if _, exists := m.Switches[name]; !exists {
return fmt.Errorf("logical switch %q not found", name)
}
delete(m.Switches, name)
return nil
}
func (m *Client) GetLogicalSwitch(_ context.Context, name string) (*nbdb.LogicalSwitch, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
ls, exists := m.Switches[name]
if !exists {
return nil, fmt.Errorf("logical switch %q not found", name)
}
result := *ls
return &result, nil
}
func (m *Client) ListLogicalSwitches(_ context.Context) ([]nbdb.LogicalSwitch, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
result := make([]nbdb.LogicalSwitch, 0, len(m.Switches))
for _, ls := range m.Switches {
result = append(result, *ls)
}
return result, nil
}
// Logical Switch Port
func (m *Client) CreateLogicalSwitchPort(_ context.Context, switchName string, lsp *nbdb.LogicalSwitchPort) error {
m.Mu.Lock()
defer m.Mu.Unlock()
ls, exists := m.Switches[switchName]
if !exists {
return fmt.Errorf("logical switch %q not found", switchName)
}
if _, exists := m.Ports[lsp.Name]; exists {
return fmt.Errorf("logical switch port %q already exists", lsp.Name)
}
if lsp.UUID == "" {
lsp.UUID = utils.GenerateResourceID("ovn")
}
stored := *lsp
m.Ports[lsp.Name] = &stored
ls.Ports = append(ls.Ports, lsp.UUID)
return nil
}
// CreateLogicalSwitchPortInGroups mirrors the live client's atomic create +
// port-group join: validates all port groups up front so a missing PG leaves
// no LSP behind. SB Address_Set rows are derived by ovn-northd and not
// modelled here.
func (m *Client) CreateLogicalSwitchPortInGroups(_ context.Context, switchName string, lsp *nbdb.LogicalSwitchPort, portGroupNames []string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
ls, exists := m.Switches[switchName]
if !exists {
return fmt.Errorf("logical switch %q not found", switchName)
}
if _, exists := m.Ports[lsp.Name]; exists {
return fmt.Errorf("logical switch port %q already exists", lsp.Name)
}
for _, pgName := range portGroupNames {
if _, ok := m.PortGroups[pgName]; !ok {
return fmt.Errorf("port group %q not found", pgName)
}
}
if lsp.UUID == "" {
lsp.UUID = utils.GenerateResourceID("ovn")
}
stored := *lsp
m.Ports[lsp.Name] = &stored
ls.Ports = append(ls.Ports, lsp.UUID)
for _, pgName := range portGroupNames {
pg := m.PortGroups[pgName]
if !slices.Contains(pg.Ports, lsp.UUID) {
pg.Ports = append(pg.Ports, lsp.UUID)
}
}
return nil
}
func (m *Client) DeleteLogicalSwitchPort(_ context.Context, switchName string, portName string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
port, exists := m.Ports[portName]
if !exists {
return fmt.Errorf("logical switch port %q not found", portName)
}
ls, exists := m.Switches[switchName]
if !exists {
return fmt.Errorf("logical switch %q not found", switchName)
}
// Mirror libovsdb's reference-integrity rejection: an LSP still in any
// port group's Ports set cannot be deleted.
for pgName, pg := range m.PortGroups {
if slices.Contains(pg.Ports, port.UUID) {
return fmt.Errorf("logical switch port %q still in port group %q", portName, pgName)
}
}
for i, uuid := range ls.Ports {
if uuid == port.UUID {
ls.Ports = append(ls.Ports[:i], ls.Ports[i+1:]...)
break
}
}
delete(m.Ports, portName)
return nil
}
func (m *Client) GetLogicalSwitchPort(_ context.Context, name string) (*nbdb.LogicalSwitchPort, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
lsp, exists := m.Ports[name]
if !exists {
return nil, fmt.Errorf("logical switch port %q not found", name)
}
result := *lsp
return &result, nil
}
func (m *Client) UpdateLogicalSwitchPort(_ context.Context, lsp *nbdb.LogicalSwitchPort) error {
m.Mu.Lock()
defer m.Mu.Unlock()
if _, exists := m.Ports[lsp.Name]; !exists {
return fmt.Errorf("logical switch port %q not found", lsp.Name)
}
stored := *lsp
m.Ports[lsp.Name] = &stored
m.UpdateLogicalSwitchPortCalls++
return nil
}
// Logical Router
func (m *Client) CreateLogicalRouter(_ context.Context, lr *nbdb.LogicalRouter) error {
m.Mu.Lock()
defer m.Mu.Unlock()
if _, exists := m.Routers[lr.Name]; exists {
return fmt.Errorf("logical router %q already exists", lr.Name)
}
if lr.UUID == "" {
lr.UUID = utils.GenerateResourceID("ovn")
}
stored := *lr
m.Routers[lr.Name] = &stored
return nil
}
// EnsureLogicalRouter mirrors the live client's wait-op semantics under the
// mock's single mutex.
func (m *Client) EnsureLogicalRouter(_ context.Context, lr *nbdb.LogicalRouter) (*nbdb.LogicalRouter, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
if existing, ok := m.Routers[lr.Name]; ok {
result := *existing
return &result, nil
}
if lr.UUID == "" {
lr.UUID = utils.GenerateResourceID("ovn")
}
stored := *lr
m.Routers[lr.Name] = &stored
result := stored
return &result, nil
}
func (m *Client) DeleteLogicalRouter(_ context.Context, name string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
if _, exists := m.Routers[name]; !exists {
return fmt.Errorf("logical router %q not found", name)
}
delete(m.Routers, name)
return nil
}
func (m *Client) GetLogicalRouter(_ context.Context, name string) (*nbdb.LogicalRouter, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, exists := m.Routers[name]
if !exists {
return nil, fmt.Errorf("logical router %q not found", name)
}
result := *lr
return &result, nil
}
func (m *Client) ListLogicalRouters(_ context.Context) ([]nbdb.LogicalRouter, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
result := make([]nbdb.LogicalRouter, 0, len(m.Routers))
for _, lr := range m.Routers {
result = append(result, *lr)
}
return result, nil
}
func (m *Client) UpdateLogicalRouterExternalIDs(_ context.Context, name string, externalIDs map[string]string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, ok := m.Routers[name]
if !ok {
return fmt.Errorf("logical router %q not found", name)
}
lr.ExternalIDs = make(map[string]string, len(externalIDs))
maps.Copy(lr.ExternalIDs, externalIDs)
return nil
}
// Logical Router Port
func (m *Client) CreateLogicalRouterPort(_ context.Context, routerName string, lrp *nbdb.LogicalRouterPort) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, exists := m.Routers[routerName]
if !exists {
return fmt.Errorf("logical router %q not found", routerName)
}
if _, exists := m.RouterPorts[lrp.Name]; exists {
return fmt.Errorf("logical router port %q already exists", lrp.Name)
}
if lrp.UUID == "" {
lrp.UUID = utils.GenerateResourceID("ovn")
}
stored := *lrp
m.RouterPorts[lrp.Name] = &stored
lr.Ports = append(lr.Ports, lrp.UUID)
return nil
}
func (m *Client) DeleteLogicalRouterPort(_ context.Context, routerName string, portName string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
port, exists := m.RouterPorts[portName]
if !exists {
return fmt.Errorf("logical router port %q not found", portName)
}
lr, exists := m.Routers[routerName]
if !exists {
return fmt.Errorf("logical router %q not found", routerName)
}
for i, uuid := range lr.Ports {
if uuid == port.UUID {
lr.Ports = append(lr.Ports[:i], lr.Ports[i+1:]...)
break
}
}
delete(m.RouterPorts, portName)
return nil
}
func (m *Client) GetLogicalRouterPort(_ context.Context, name string) (*nbdb.LogicalRouterPort, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
lrp, exists := m.RouterPorts[name]
if !exists {
return nil, fmt.Errorf("logical router port %q not found", name)
}
result := *lrp
return &result, nil
}
func (m *Client) UpdateLogicalRouterPort(_ context.Context, lrp *nbdb.LogicalRouterPort) error {
m.Mu.Lock()
defer m.Mu.Unlock()
if _, exists := m.RouterPorts[lrp.Name]; !exists {
return fmt.Errorf("logical router port %q not found", lrp.Name)
}
stored := *lrp
m.RouterPorts[lrp.Name] = &stored
m.UpdateLogicalRouterPortCalls++
return nil
}
// DHCP Options
func (m *Client) CreateDHCPOptions(_ context.Context, opts *nbdb.DHCPOptions) (string, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
if opts.UUID == "" {
opts.UUID = utils.GenerateResourceID("dhcp")
}
stored := *opts
m.DHCPOpts[opts.UUID] = &stored
return opts.UUID, nil
}
func (m *Client) DeleteDHCPOptions(_ context.Context, uuid string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
if _, exists := m.DHCPOpts[uuid]; !exists {
return fmt.Errorf("DHCP options %q not found", uuid)
}
delete(m.DHCPOpts, uuid)
return nil
}
func (m *Client) FindDHCPOptionsByCIDR(_ context.Context, cidr string) (*nbdb.DHCPOptions, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
for _, opts := range m.DHCPOpts {
if opts.CIDR == cidr {
result := *opts
return &result, nil
}
}
return nil, fmt.Errorf("DHCP options for CIDR %q not found", cidr)
}
func (m *Client) FindDHCPOptionsByExternalID(_ context.Context, key, value string) (*nbdb.DHCPOptions, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
for _, opts := range m.DHCPOpts {
if opts.ExternalIDs[key] == value {
result := *opts
return &result, nil
}
}
return nil, fmt.Errorf("DHCP options with external_id %s=%s not found", key, value)
}
func (m *Client) ListDHCPOptions(_ context.Context) ([]nbdb.DHCPOptions, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
result := make([]nbdb.DHCPOptions, 0, len(m.DHCPOpts))
for _, opts := range m.DHCPOpts {
result = append(result, *opts)
}
return result, nil
}
// NAT
func (m *Client) AddNAT(_ context.Context, routerName string, nat *nbdb.NAT) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, exists := m.Routers[routerName]
if !exists {
return fmt.Errorf("logical router %q not found", routerName)
}
if nat.UUID == "" {
nat.UUID = utils.GenerateResourceID("nat")
}
stored := *nat
m.NATs[nat.UUID] = &stored
lr.NAT = append(lr.NAT, nat.UUID)
return nil
}
func (m *Client) DeleteNAT(_ context.Context, routerName string, natType, logicalIP string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, exists := m.Routers[routerName]
if !exists {
return fmt.Errorf("logical router %q not found", routerName)
}
var foundUUID string
var foundIdx int
for i, uuid := range lr.NAT {
n, ok := m.NATs[uuid]
if !ok {
continue
}
if n.Type == natType && n.LogicalIP == logicalIP {
foundUUID = uuid
foundIdx = i
break
}
}
if foundUUID == "" {
return fmt.Errorf("NAT %s %s on %s: %w", natType, logicalIP, routerName, ovn.ErrNATNotFound)
}
lr.NAT = append(lr.NAT[:foundIdx], lr.NAT[foundIdx+1:]...)
delete(m.NATs, foundUUID)
return nil
}
func (m *Client) DeleteNATByExternalIP(_ context.Context, routerName string, natType, externalIP string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, exists := m.Routers[routerName]
if !exists {
return fmt.Errorf("logical router %q not found", routerName)
}
var foundUUIDs []string
for _, uuid := range lr.NAT {
n, ok := m.NATs[uuid]
if !ok {
continue
}
if n.Type == natType && n.ExternalIP == externalIP {
foundUUIDs = append(foundUUIDs, uuid)
}
}
if len(foundUUIDs) == 0 {
return fmt.Errorf("NAT %s external_ip=%s on %s: %w", natType, externalIP, routerName, ovn.ErrNATNotFound)
}
stale := make(map[string]struct{}, len(foundUUIDs))
for _, u := range foundUUIDs {
stale[u] = struct{}{}
}
filtered := lr.NAT[:0]
for _, uuid := range lr.NAT {
if _, drop := stale[uuid]; drop {
continue
}
filtered = append(filtered, uuid)
}
lr.NAT = filtered
for _, u := range foundUUIDs {
delete(m.NATs, u)
}
return nil
}
func (m *Client) DeleteAllNATsByExternalIP(_ context.Context, natType, externalIP string) (int, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
var foundUUIDs []string
for uuid, n := range m.NATs {
if n.Type == natType && n.ExternalIP == externalIP {
foundUUIDs = append(foundUUIDs, uuid)
}
}
if len(foundUUIDs) == 0 {
return 0, nil
}
uuidSet := make(map[string]struct{}, len(foundUUIDs))
for _, u := range foundUUIDs {
uuidSet[u] = struct{}{}
}
for _, lr := range m.Routers {
filtered := lr.NAT[:0]
for _, uuid := range lr.NAT {
if _, stale := uuidSet[uuid]; !stale {
filtered = append(filtered, uuid)
}
}
lr.NAT = filtered
}
for _, u := range foundUUIDs {
delete(m.NATs, u)
}
return len(foundUUIDs), nil
}
func (m *Client) FindNATByExternalIP(_ context.Context, natType, externalIP string) (*nbdb.NAT, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
for _, n := range m.NATs {
if n.Type == natType && n.ExternalIP == externalIP {
return n, nil
}
}
return nil, nil
}
// Static Routes
func (m *Client) AddStaticRoute(_ context.Context, routerName string, route *nbdb.LogicalRouterStaticRoute) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, exists := m.Routers[routerName]
if !exists {
return fmt.Errorf("logical router %q not found", routerName)
}
if route.UUID == "" {
route.UUID = utils.GenerateResourceID("route")
}
stored := *route
m.StaticRoutes[route.UUID] = &stored
lr.StaticRoutes = append(lr.StaticRoutes, route.UUID)
return nil
}
func (m *Client) FindStaticRoute(_ context.Context, routerName, ipPrefix string) (*nbdb.LogicalRouterStaticRoute, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, exists := m.Routers[routerName]
if !exists {
return nil, fmt.Errorf("logical router %q not found", routerName)
}
for _, uuid := range lr.StaticRoutes {
r, ok := m.StaticRoutes[uuid]
if !ok {
continue
}
if r.IPPrefix == ipPrefix {
result := *r
return &result, nil
}
}
return nil, nil
}
func (m *Client) DeleteStaticRoute(_ context.Context, routerName string, ipPrefix string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lr, exists := m.Routers[routerName]
if !exists {
return fmt.Errorf("logical router %q not found", routerName)
}
var foundUUID string
for uuid, r := range m.StaticRoutes {
if r.IPPrefix == ipPrefix {
foundUUID = uuid
break
}
}
if foundUUID == "" {
return fmt.Errorf("static route %s not found", ipPrefix)
}
for i, uuid := range lr.StaticRoutes {
if uuid == foundUUID {
lr.StaticRoutes = append(lr.StaticRoutes[:i], lr.StaticRoutes[i+1:]...)
break
}
}
delete(m.StaticRoutes, foundUUID)
return nil
}
// Port Groups
func (m *Client) CreatePortGroup(_ context.Context, name string, ports []string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
if _, exists := m.PortGroups[name]; exists {
return fmt.Errorf("port group %q already exists", name)
}
pg := &nbdb.PortGroup{
UUID: utils.GenerateResourceID("pg"),
Name: name,
Ports: ports,
}
m.PortGroups[name] = pg
return nil
}
// EnsurePortGroup mirrors the live client's wait-op semantics.
func (m *Client) EnsurePortGroup(_ context.Context, name string, ports []string) (*nbdb.PortGroup, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
if existing, ok := m.PortGroups[name]; ok {
result := *existing
return &result, nil
}
pg := &nbdb.PortGroup{
UUID: utils.GenerateResourceID("pg"),
Name: name,
Ports: ports,
}
m.PortGroups[name] = pg
result := *pg
return &result, nil
}
func (m *Client) DeletePortGroup(_ context.Context, name string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
pg, exists := m.PortGroups[name]
if !exists {
return fmt.Errorf("port group %q not found", name)
}
// Mirror libovsdb: PG with referenced ACL rows cannot be deleted; live
// `Where(pg).Delete()` would leak the ACLs. Force ClearACLs first.
if len(pg.ACLs) > 0 {
return fmt.Errorf("port group %q has %d ACLs still attached; clear ACLs before delete", name, len(pg.ACLs))
}
delete(m.PortGroups, name)
return nil
}
// UpdatePortGroupMemberships applies all add/remove PG joins for one LSP.
func (m *Client) UpdatePortGroupMemberships(_ context.Context, lspName string, addPGs, removePGs []string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lsp, exists := m.Ports[lspName]
if !exists {
return fmt.Errorf("logical switch port %q not found", lspName)
}
for _, pgName := range addPGs {
pg, exists := m.PortGroups[pgName]
if !exists {
return fmt.Errorf("port group %q not found", pgName)
}
if !slices.Contains(pg.Ports, lsp.UUID) {
pg.Ports = append(pg.Ports, lsp.UUID)
}
}
for _, pgName := range removePGs {
pg, exists := m.PortGroups[pgName]
if !exists {
return fmt.Errorf("port group %q not found", pgName)
}
for i, u := range pg.Ports {
if u == lsp.UUID {
pg.Ports = append(pg.Ports[:i], pg.Ports[i+1:]...)
break
}
}
}
return nil
}
// ListPortGroupsForPort returns names of PGs containing the LSP's UUID.
// Empty slice (not error) when no memberships.
func (m *Client) ListPortGroupsForPort(_ context.Context, lspName string) ([]string, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
lsp, exists := m.Ports[lspName]
if !exists {
return nil, fmt.Errorf("logical switch port %q not found", lspName)
}
names := make([]string, 0)
for name, pg := range m.PortGroups {
if slices.Contains(pg.Ports, lsp.UUID) {
names = append(names, name)
}
}
return names, nil
}
// GetPortGroup returns the port group, or ovn.ErrPortGroupNotFound.
func (m *Client) GetPortGroup(_ context.Context, name string) (*nbdb.PortGroup, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
pg, exists := m.PortGroups[name]
if !exists {
return nil, fmt.Errorf("%w: %q", ovn.ErrPortGroupNotFound, name)
}
return pg, nil
}
// ListPortGroups returns a snapshot of every port group.
func (m *Client) ListPortGroups(_ context.Context) ([]nbdb.PortGroup, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
out := make([]nbdb.PortGroup, 0, len(m.PortGroups))
for _, pg := range m.PortGroups {
out = append(out, *pg)
}
return out, nil
}
// ACLs
func (m *Client) AddACLs(_ context.Context, portGroupName string, specs []ovn.ACLSpec) error {
m.Mu.Lock()
defer m.Mu.Unlock()
m.AddACLCalls++
if m.AddACLErrAfter > 0 && m.AddACLCalls == m.AddACLErrAfter {
return fmt.Errorf("injected AddACLs failure on call %d", m.AddACLCalls)
}
pg, exists := m.PortGroups[portGroupName]
if !exists {
return fmt.Errorf("port group %q not found", portGroupName)
}
for _, spec := range specs {
acl := &nbdb.ACL{
UUID: utils.GenerateResourceID("acl"),
Direction: spec.Direction,
Priority: spec.Priority,
Match: spec.Match,
Action: spec.Action,
Log: spec.Log,
}
if spec.Name != "" {
name := spec.Name
acl.Name = &name
}
if spec.Severity != "" {
severity := spec.Severity
acl.Severity = &severity
}
m.ACLs[acl.UUID] = acl
pg.ACLs = append(pg.ACLs, acl.UUID)
}
return nil
}
func (m *Client) ClearACLs(_ context.Context, portGroupName string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
pg, exists := m.PortGroups[portGroupName]
if !exists {
return fmt.Errorf("port group %q not found", portGroupName)
}
for _, aclUUID := range pg.ACLs {
delete(m.ACLs, aclUUID)
}
pg.ACLs = nil
return nil
}
// ListLogicalRouterPorts returns every LRP across the mock state.
func (m *Client) ListLogicalRouterPorts(_ context.Context) ([]nbdb.LogicalRouterPort, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
result := make([]nbdb.LogicalRouterPort, 0, len(m.RouterPorts))
for _, lrp := range m.RouterPorts {
result = append(result, *lrp)
}
return result, nil
}
// SetGatewayChassis is the idempotent read-then-decide path mirroring
// LiveClient; counters distinguish create vs. update vs. no-op.
func (m *Client) SetGatewayChassis(_ context.Context, lrpName string, chassisName string, priority int) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lrp, exists := m.RouterPorts[lrpName]
if !exists {
return fmt.Errorf("logical router port %q not found", lrpName)
}
gcName := lrpName + "-" + chassisName
for _, gc := range m.GatewayChassis {
if gc.Name != gcName {
continue
}
if gc.Priority == priority {
return nil
}
gc.Priority = priority
m.UpdateGatewayChassisPriorityCalls++
return nil
}
gc := &nbdb.GatewayChassis{
UUID: utils.GenerateResourceID("gc"),
Name: gcName,
ChassisName: chassisName,
Priority: priority,
ExternalIDs: map[string]string{},
Options: map[string]string{},
}
m.GatewayChassis[gc.UUID] = gc
lrp.GatewayChassis = append(lrp.GatewayChassis, gc.UUID)
m.SetGatewayChassisCalls++
return nil
}
func (m *Client) GetGatewayChassisByName(_ context.Context, name string) (*nbdb.GatewayChassis, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
for _, gc := range m.GatewayChassis {
if gc.Name == name {
result := *gc
return &result, nil
}
}
return nil, nil
}
func (m *Client) ListGatewayChassis(_ context.Context) ([]nbdb.GatewayChassis, error) {
m.Mu.Lock()
defer m.Mu.Unlock()
result := make([]nbdb.GatewayChassis, 0, len(m.GatewayChassis))
for _, gc := range m.GatewayChassis {
result = append(result, *gc)
}
return result, nil
}
func (m *Client) DeleteGatewayChassis(_ context.Context, lrpName string, gcUUID string) error {
m.Mu.Lock()
defer m.Mu.Unlock()
lrp, exists := m.RouterPorts[lrpName]
if !exists {
return fmt.Errorf("logical router port %q not found", lrpName)
}
if _, ok := m.GatewayChassis[gcUUID]; !ok {
return fmt.Errorf("gateway_chassis %q not found", gcUUID)
}
for i, u := range lrp.GatewayChassis {
if u == gcUUID {
lrp.GatewayChassis = append(lrp.GatewayChassis[:i], lrp.GatewayChassis[i+1:]...)
break
}
}
delete(m.GatewayChassis, gcUUID)
m.DeleteGatewayChassisCalls++
return nil
}
// SeedGatewayChassis pre-populates a Gateway_Chassis row, bypassing
// SetGatewayChassis. Used to construct "stale row" scenarios in tests.
func (m *Client) SeedGatewayChassis(lrpName string, gc *nbdb.GatewayChassis) {
m.Mu.Lock()
defer m.Mu.Unlock()
if gc.UUID == "" {
gc.UUID = utils.GenerateResourceID("gc")
}
m.GatewayChassis[gc.UUID] = gc
if lrp, ok := m.RouterPorts[lrpName]; ok {
lrp.GatewayChassis = append(lrp.GatewayChassis, gc.UUID)
}
}
+287
View File
@@ -0,0 +1,287 @@
package mock
import (
"context"
"sync"
"testing"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
)
func TestClient_DeleteAllNATsByExternalIP(t *testing.T) {
m := New()
_ = m.Connect(context.Background())
ctx := context.Background()
_ = m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{
Name: "vpc-r1",
ExternalIDs: map[string]string{"spinifex:vpc_id": "r1"},
})
_ = m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{
Name: "vpc-r2",
ExternalIDs: map[string]string{"spinifex:vpc_id": "r2"},
})
_ = m.AddNAT(ctx, "vpc-r1", &nbdb.NAT{
Type: "dnat_and_snat", ExternalIP: "192.168.1.50", LogicalIP: "10.0.0.1",
})
_ = m.AddNAT(ctx, "vpc-r2", &nbdb.NAT{
Type: "dnat_and_snat", ExternalIP: "192.168.1.50", LogicalIP: "10.200.0.1",
})
// Unrelated NAT on r1 that should be untouched.
_ = m.AddNAT(ctx, "vpc-r1", &nbdb.NAT{
Type: "dnat_and_snat", ExternalIP: "192.168.1.99", LogicalIP: "10.0.0.2",
})
removed, err := m.DeleteAllNATsByExternalIP(ctx, "dnat_and_snat", "192.168.1.50")
if err != nil {
t.Fatalf("DeleteAllNATsByExternalIP: %v", err)
}
if removed != 2 {
t.Errorf("expected 2 removed, got %d", removed)
}
r1, _ := m.GetLogicalRouter(ctx, "vpc-r1")
if len(r1.NAT) != 1 {
t.Errorf("r1 should retain 1 unrelated NAT, got %d", len(r1.NAT))
}
r2, _ := m.GetLogicalRouter(ctx, "vpc-r2")
if len(r2.NAT) != 0 {
t.Errorf("r2 should have 0 NAT rules, got %d", len(r2.NAT))
}
removed, err = m.DeleteAllNATsByExternalIP(ctx, "dnat_and_snat", "192.168.1.50")
if err != nil {
t.Fatalf("second DeleteAllNATsByExternalIP: %v", err)
}
if removed != 0 {
t.Errorf("expected 0 removed on second call, got %d", removed)
}
}
func TestSetGatewayChassis_Idempotent(t *testing.T) {
m := New()
_ = m.Connect(context.Background())
ctx := context.Background()
if err := m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: "vpc-A"}); err != nil {
t.Fatalf("CreateLogicalRouter: %v", err)
}
if err := m.CreateLogicalRouterPort(ctx, "vpc-A", &nbdb.LogicalRouterPort{Name: "gw-A"}); err != nil {
t.Fatalf("CreateLogicalRouterPort: %v", err)
}
if err := m.SetGatewayChassis(ctx, "gw-A", "chassis-1", 20); err != nil {
t.Fatalf("SetGatewayChassis (1): %v", err)
}
if err := m.SetGatewayChassis(ctx, "gw-A", "chassis-1", 20); err != nil {
t.Fatalf("SetGatewayChassis (2): %v", err)
}
rows, err := m.ListGatewayChassis(ctx)
if err != nil {
t.Fatalf("ListGatewayChassis: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d (%v)", len(rows), rows)
}
if m.SetGatewayChassisCalls != 1 {
t.Errorf("expected 1 create call, got %d", m.SetGatewayChassisCalls)
}
if m.UpdateGatewayChassisPriorityCalls != 0 {
t.Errorf("expected 0 priority updates, got %d", m.UpdateGatewayChassisPriorityCalls)
}
}
func TestSetGatewayChassis_UpdatesPriority(t *testing.T) {
m := New()
_ = m.Connect(context.Background())
ctx := context.Background()
if err := m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: "vpc-A"}); err != nil {
t.Fatalf("CreateLogicalRouter: %v", err)
}
if err := m.CreateLogicalRouterPort(ctx, "vpc-A", &nbdb.LogicalRouterPort{Name: "gw-A"}); err != nil {
t.Fatalf("CreateLogicalRouterPort: %v", err)
}
if err := m.SetGatewayChassis(ctx, "gw-A", "chassis-1", 20); err != nil {
t.Fatalf("SetGatewayChassis at 20: %v", err)
}
if err := m.SetGatewayChassis(ctx, "gw-A", "chassis-1", 15); err != nil {
t.Fatalf("SetGatewayChassis at 15: %v", err)
}
rows, err := m.ListGatewayChassis(ctx)
if err != nil {
t.Fatalf("ListGatewayChassis: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row (mutated, not duplicated), got %d", len(rows))
}
if rows[0].Priority != 15 {
t.Errorf("expected priority 15 after update, got %d", rows[0].Priority)
}
if m.UpdateGatewayChassisPriorityCalls != 1 {
t.Errorf("expected 1 priority update, got %d", m.UpdateGatewayChassisPriorityCalls)
}
}
// N concurrent EnsureLogicalRouter calls for the same Name must converge to
// a single row.
func TestEnsureLogicalRouter_ConcurrentSingleSurvivor(t *testing.T) {
const callers = 50
m := New()
_ = m.Connect(context.Background())
ctx := context.Background()
uuids := make([]string, callers)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range callers {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
lr, err := m.EnsureLogicalRouter(ctx, &nbdb.LogicalRouter{
Name: "vpc-vpc-X",
ExternalIDs: map[string]string{"spinifex:vpc_id": "vpc-X"},
})
if err != nil {
t.Errorf("EnsureLogicalRouter[%d]: %v", i, err)
return
}
uuids[i] = lr.UUID
}(i)
}
close(start)
wg.Wait()
first := uuids[0]
if first == "" {
t.Fatalf("caller 0 got empty UUID")
}
for i, u := range uuids {
if u != first {
t.Errorf("caller %d UUID mismatch: %q vs %q", i, u, first)
}
}
rows, err := m.ListLogicalRouters(ctx)
if err != nil {
t.Fatalf("ListLogicalRouters: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 router after concurrent EnsureLogicalRouter, got %d", len(rows))
}
}
// Sequential second EnsureLogicalRouter for an existing Name returns the
// pre-existing row.
func TestEnsureLogicalRouter_ReturnsExistingOnSecondCall(t *testing.T) {
m := New()
_ = m.Connect(context.Background())
ctx := context.Background()
first, err := m.EnsureLogicalRouter(ctx, &nbdb.LogicalRouter{
Name: "vpc-vpc-Y",
ExternalIDs: map[string]string{"spinifex:vpc_id": "vpc-Y", "spinifex:cidr": ""},
})
if err != nil {
t.Fatalf("EnsureLogicalRouter first call: %v", err)
}
second, err := m.EnsureLogicalRouter(ctx, &nbdb.LogicalRouter{
Name: "vpc-vpc-Y",
ExternalIDs: map[string]string{"spinifex:vpc_id": "vpc-Y", "spinifex:cidr": "10.0.0.0/16"},
})
if err != nil {
t.Fatalf("EnsureLogicalRouter second call: %v", err)
}
if first.UUID != second.UUID {
t.Errorf("second call returned different UUID: %q vs %q", second.UUID, first.UUID)
}
}
func TestEnsureLogicalSwitch_ConcurrentSingleSurvivor(t *testing.T) {
const callers = 30
m := New()
_ = m.Connect(context.Background())
ctx := context.Background()
uuids := make([]string, callers)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range callers {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
ls, err := m.EnsureLogicalSwitch(ctx, &nbdb.LogicalSwitch{
Name: "subnet-subnet-Z",
ExternalIDs: map[string]string{"spinifex:subnet_id": "subnet-Z"},
})
if err != nil {
t.Errorf("EnsureLogicalSwitch[%d]: %v", i, err)
return
}
uuids[i] = ls.UUID
}(i)
}
close(start)
wg.Wait()
first := uuids[0]
for i, u := range uuids {
if u != first {
t.Errorf("caller %d UUID mismatch: %q vs %q", i, u, first)
}
}
rows, err := m.ListLogicalSwitches(ctx)
if err != nil {
t.Fatalf("ListLogicalSwitches: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 switch, got %d", len(rows))
}
}
func TestEnsurePortGroup_ConcurrentSingleSurvivor(t *testing.T) {
const callers = 30
m := New()
_ = m.Connect(context.Background())
ctx := context.Background()
uuids := make([]string, callers)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range callers {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
pg, err := m.EnsurePortGroup(ctx, "sg-pg-A", nil)
if err != nil {
t.Errorf("EnsurePortGroup[%d]: %v", i, err)
return
}
uuids[i] = pg.UUID
}(i)
}
close(start)
wg.Wait()
first := uuids[0]
for i, u := range uuids {
if u != first {
t.Errorf("caller %d UUID mismatch: %q vs %q", i, u, first)
}
}
rows, err := m.ListPortGroups(ctx)
if err != nil {
t.Fatalf("ListPortGroups: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 port group, got %d", len(rows))
}
}
@@ -1,13 +1,5 @@
// Package nbdb contains Go structs representing the OVN Northbound Database schema.
// These models are used with libovsdb to interact with the OVN NB DB.
//
// The structs cover the core tables needed for Spinifex VPC networking:
// LogicalSwitch, LogicalSwitchPort, LogicalRouter, LogicalRouterPort, and DHCPOptions.
//
// To regenerate from the full OVN NB schema (requires OVN installed):
//
// go install github.com/ovn-kubernetes/libovsdb/cmd/modelgen@latest
// modelgen -p nbdb -o spinifex/services/vpcd/nbdb /usr/share/ovn/ovn-nb.ovsschema
// Package nbdb contains libovsdb model structs for the OVN Northbound tables
// used by Spinifex VPC networking.
package nbdb
import "github.com/ovn-kubernetes/libovsdb/model"
@@ -91,8 +83,7 @@ type LogicalRouterStaticRoute struct {
ExternalIDs map[string]string `ovsdb:"external_ids"`
}
// PortGroup represents an OVN Port_Group for grouping logical switch ports
// (used for security group enforcement).
// PortGroup represents an OVN Port_Group (used for SG enforcement).
type PortGroup struct {
UUID string `ovsdb:"_uuid"`
Name string `ovsdb:"name"`
@@ -115,9 +106,8 @@ type ACL struct {
ExternalIDs map[string]string `ovsdb:"external_ids"`
}
// AddressSet represents an OVN Address_Set — a named set of IP addresses
// referenced by ACL match expressions (e.g. ip4.src == $sg_xxx_ip4) for
// SG-to-SG rule enforcement.
// AddressSet represents an OVN Address_Set referenced by ACL match exprs
// (e.g. ip4.src == $sg_xxx_ip4) for SG-to-SG rules.
type AddressSet struct {
UUID string `ovsdb:"_uuid"`
Name string `ovsdb:"name"`
@@ -135,8 +125,7 @@ type GatewayChassis struct {
Options map[string]string `ovsdb:"options"`
}
// FullDatabaseModel returns a ClientDBModel for the OVN Northbound database
// containing all tables needed for Spinifex VPC networking.
// FullDatabaseModel returns a ClientDBModel for the OVN NB tables used.
func FullDatabaseModel() (model.ClientDBModel, error) {
return model.NewClientDBModel("OVN_Northbound", map[string]model.Model{
"Logical_Switch": &LogicalSwitch{},
+187
View File
@@ -0,0 +1,187 @@
package policy
import (
"fmt"
"strings"
"github.com/mulgadc/spinifex/spinifex/network/ovn"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
// ACL priority bands. Higher wins. Tenants only ever populate 1000.
const (
// ACLPriorityAllowIntraSG: unconditional intra-PG allow (reserved).
ACLPriorityAllowIntraSG = 1100
// ACLPriorityAllowDHCP must sit above tenant rules so a narrow-egress SG
// doesn't drop DHCPDISCOVER against the 900 default-deny. AWS users
// can't write a 255.255.255.255 rule.
ACLPriorityAllowDHCP = 1050
// ACLPriorityTenantAllow: tenant ingress/egress allows. Not logged.
ACLPriorityTenantAllow = 1000
// ACLPriorityDefaultDenyIngress: logged drop. CMMC SC.L1-3.13.1.
ACLPriorityDefaultDenyIngress = 900
// ACLPriorityDefaultDenyEgress: egress default-deny.
ACLPriorityDefaultDenyEgress = 800
)
// denyACLSeverity: syslog severity for default-deny hits. "info" captures
// without paging on port scans; operators can promote at the collector.
const denyACLSeverity = "info"
// Rule is the policy-layer view of an AWS-style SG rule. CIDR / SourceSG
// MUST be validated upstream; values are interpolated verbatim.
type Rule struct {
IPProtocol string // "tcp", "udp", "icmp", or "-1" (all)
FromPort int64
ToPort int64
CIDR string
SourceSG string
}
// InfrastructureACLs returns the platform ACLs every PG carries: the
// 900/800 logged default-denies (CMMC SC.L1-3.13.1) and the 1050 DHCPv4
// client/server allows (OVN's DHCP responder runs after the PG ACL stage;
// without these a narrow-egress SG would drop DHCPDISCOVER).
func InfrastructureACLs(portGroupName string) []ovn.ACLSpec {
return []ovn.ACLSpec{
denyIngressACL(portGroupName),
denyEgressACL(portGroupName),
dhcpEgressACL(portGroupName),
dhcpIngressACL(portGroupName),
}
}
// RuleACLSpecs builds priority-1000 allow ACLs. Action is "allow-related"
// so reply traffic on established flows passes without a reverse rule.
func RuleACLSpecs(portGroupName string, ingress, egress []Rule) []ovn.ACLSpec {
specs := make([]ovn.ACLSpec, 0, len(ingress)+len(egress))
for _, rule := range ingress {
specs = append(specs, ovn.ACLSpec{
Direction: "to-lport",
Priority: ACLPriorityTenantAllow,
Match: BuildIngressACLMatch(portGroupName, rule),
Action: "allow-related",
})
}
for _, rule := range egress {
specs = append(specs, ovn.ACLSpec{
Direction: "from-lport",
Priority: ACLPriorityTenantAllow,
Match: BuildEgressACLMatch(portGroupName, rule),
Action: "allow-related",
})
}
return specs
}
// BuildIngressACLMatch builds an OVN to-lport match. Tenant CIDR/SourceSG
// MUST be validated upstream; values are interpolated verbatim.
func BuildIngressACLMatch(portGroupName string, rule Rule) string {
parts := []string{fmt.Sprintf("outport == @%s", portGroupName), "ip4"}
parts = appendProtocolMatch(parts, rule)
if rule.CIDR != "" && rule.CIDR != "0.0.0.0/0" {
parts = append(parts, fmt.Sprintf("ip4.src == %s", rule.CIDR))
}
if rule.SourceSG != "" {
parts = append(parts, fmt.Sprintf("ip4.src == $%s", addressSetName(topology.SecurityGroupPortGroup(rule.SourceSG))))
}
return strings.Join(parts, " && ")
}
// BuildEgressACLMatch builds an OVN from-lport match.
func BuildEgressACLMatch(portGroupName string, rule Rule) string {
parts := []string{fmt.Sprintf("inport == @%s", portGroupName), "ip4"}
parts = appendProtocolMatch(parts, rule)
if rule.CIDR != "" && rule.CIDR != "0.0.0.0/0" {
parts = append(parts, fmt.Sprintf("ip4.dst == %s", rule.CIDR))
}
if rule.SourceSG != "" {
parts = append(parts, fmt.Sprintf("ip4.dst == $%s", addressSetName(topology.SecurityGroupPortGroup(rule.SourceSG))))
}
return strings.Join(parts, " && ")
}
// addressSetName returns the ovn-northd-derived SB Address_Set name for a
// PG's IPv4 addresses. Spinifex must NOT create NB Address_Set rows under
// these names — duplicate SB sync wedges northd in a commit-failed loop.
func addressSetName(portGroupName string) string {
return portGroupName + "_ip4"
}
func appendProtocolMatch(parts []string, rule Rule) []string {
switch rule.IPProtocol {
case "tcp":
parts = appendPortMatch(parts, "tcp", rule.FromPort, rule.ToPort)
case "udp":
parts = appendPortMatch(parts, "udp", rule.FromPort, rule.ToPort)
case "icmp":
parts = append(parts, "icmp4")
case "-1", "":
default:
}
return parts
}
func appendPortMatch(parts []string, proto string, fromPort, toPort int64) []string {
if fromPort == 0 && toPort == 0 {
return append(parts, proto)
}
if fromPort == toPort {
return append(parts, fmt.Sprintf("%s.dst == %d", proto, fromPort))
}
parts = append(parts, fmt.Sprintf("%s.dst >= %d", proto, fromPort))
parts = append(parts, fmt.Sprintf("%s.dst <= %d", proto, toPort))
return parts
}
func denyIngressACL(portGroupName string) ovn.ACLSpec {
return ovn.ACLSpec{
Direction: "to-lport",
Priority: ACLPriorityDefaultDenyIngress,
Match: fmt.Sprintf("outport == @%s && ip4", portGroupName),
Action: "drop",
Name: portGroupName + "-deny-ingress",
Log: true,
Severity: denyACLSeverity,
}
}
func denyEgressACL(portGroupName string) ovn.ACLSpec {
return ovn.ACLSpec{
Direction: "from-lport",
Priority: ACLPriorityDefaultDenyEgress,
Match: fmt.Sprintf("inport == @%s && ip4", portGroupName),
Action: "drop",
Name: portGroupName + "-deny-egress",
Log: true,
Severity: denyACLSeverity,
}
}
// dhcpEgressACL: DHCPDISCOVER/REQUEST out (udp 68→67). "allow" not
// "allow-related" — broadcast UDP interacts oddly with OVN CT zones.
func dhcpEgressACL(portGroupName string) ovn.ACLSpec {
return ovn.ACLSpec{
Direction: "from-lport",
Priority: ACLPriorityAllowDHCP,
Match: fmt.Sprintf("inport == @%s && udp && udp.src == 68 && udp.dst == 67", portGroupName),
Action: "allow",
Name: portGroupName + "-allow-dhcp-egress",
}
}
// dhcpIngressACL: DHCPOFFER/ACK reply (udp 67→68). Required because OVN's
// DHCP responder emits inside the LS pipeline after the ACL stage.
func dhcpIngressACL(portGroupName string) ovn.ACLSpec {
return ovn.ACLSpec{
Direction: "to-lport",
Priority: ACLPriorityAllowDHCP,
Match: fmt.Sprintf("outport == @%s && udp && udp.src == 67 && udp.dst == 68", portGroupName),
Action: "allow",
Name: portGroupName + "-allow-dhcp-ingress",
}
}
+136
View File
@@ -0,0 +1,136 @@
package policy
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestACL_TCPPortFromCIDR(t *testing.T) {
match := BuildIngressACLMatch("sg_test", Rule{
IPProtocol: "tcp",
FromPort: 22,
ToPort: 22,
CIDR: "10.0.0.0/8",
})
assert.Contains(t, match, "tcp.dst == 22")
assert.Contains(t, match, "ip4.src == 10.0.0.0/8")
assert.Contains(t, match, "outport == @sg_test")
assert.Contains(t, match, "ip4")
}
func TestACL_AllTrafficFromSG(t *testing.T) {
match := BuildIngressACLMatch("sg_test", Rule{
IPProtocol: "-1",
SourceSG: "sg-abc123",
})
assert.Contains(t, match, "ip4.src == $sg_abc123_ip4")
assert.Contains(t, match, "outport == @sg_test")
assert.Contains(t, match, "ip4")
}
func TestACL_PortRange(t *testing.T) {
match := BuildIngressACLMatch("sg_test", Rule{
IPProtocol: "udp",
FromPort: 1024,
ToPort: 65535,
})
assert.Contains(t, match, "udp.dst >= 1024")
assert.Contains(t, match, "udp.dst <= 65535")
}
func TestACL_ICMP(t *testing.T) {
match := BuildIngressACLMatch("sg_test", Rule{
IPProtocol: "icmp",
CIDR: "0.0.0.0/0",
})
assert.Contains(t, match, "icmp4")
assert.NotContains(t, match, "tcp.dst")
assert.NotContains(t, match, "udp.dst")
}
func TestACL_AllProtocols(t *testing.T) {
match := BuildIngressACLMatch("sg_test", Rule{
IPProtocol: "-1",
CIDR: "10.0.0.0/16",
})
assert.Contains(t, match, "ip4")
assert.Contains(t, match, "ip4.src == 10.0.0.0/16")
assert.NotContains(t, match, "tcp")
assert.NotContains(t, match, "udp")
assert.NotContains(t, match, "icmp")
}
func TestACL_EgressAll(t *testing.T) {
match := BuildEgressACLMatch("sg_test", Rule{
IPProtocol: "-1",
CIDR: "0.0.0.0/0",
})
assert.Contains(t, match, "inport == @sg_test")
assert.NotContains(t, match, "outport")
assert.Contains(t, match, "ip4")
}
func TestACL_TCPSinglePort(t *testing.T) {
match := BuildIngressACLMatch("sg_test", Rule{
IPProtocol: "tcp",
FromPort: 443,
ToPort: 443,
CIDR: "10.0.0.0/8",
})
assert.Contains(t, match, "tcp.dst == 443")
assert.NotContains(t, match, "tcp.dst >=")
assert.NotContains(t, match, "tcp.dst <=")
}
func TestACL_NoSource(t *testing.T) {
match := BuildIngressACLMatch("sg_test", Rule{
IPProtocol: "tcp",
FromPort: 80,
ToPort: 80,
CIDR: "0.0.0.0/0",
})
assert.Contains(t, match, "tcp.dst == 80")
assert.NotContains(t, match, "ip4.src")
}
func TestACL_EgressFromSGToSG(t *testing.T) {
match := BuildEgressACLMatch("sg_test", Rule{
IPProtocol: "tcp",
FromPort: 3306,
ToPort: 3306,
SourceSG: "sg-db-tier",
})
assert.Contains(t, match, "inport == @sg_test")
assert.Contains(t, match, "tcp.dst == 3306")
assert.Contains(t, match, "ip4.dst == $sg_db_tier_ip4")
}
func TestInfrastructureACLs_Shape(t *testing.T) {
specs := InfrastructureACLs("sg_test")
if assert.Len(t, specs, 4) {
// Priorities: deny-ingress 900, deny-egress 800, dhcp-egress 1050, dhcp-ingress 1050
assert.Equal(t, ACLPriorityDefaultDenyIngress, specs[0].Priority)
assert.Equal(t, "drop", specs[0].Action)
assert.True(t, specs[0].Log)
assert.Equal(t, ACLPriorityDefaultDenyEgress, specs[1].Priority)
assert.True(t, specs[1].Log)
assert.Equal(t, ACLPriorityAllowDHCP, specs[2].Priority)
assert.Equal(t, "allow", specs[2].Action)
assert.Equal(t, ACLPriorityAllowDHCP, specs[3].Priority)
}
}
func TestRuleACLSpecs_PriorityAndAction(t *testing.T) {
specs := RuleACLSpecs("sg_test",
[]Rule{{IPProtocol: "tcp", FromPort: 80, ToPort: 80, CIDR: "0.0.0.0/0"}},
[]Rule{{IPProtocol: "-1", CIDR: "0.0.0.0/0"}},
)
if assert.Len(t, specs, 2) {
assert.Equal(t, ACLPriorityTenantAllow, specs[0].Priority)
assert.Equal(t, "to-lport", specs[0].Direction)
assert.Equal(t, "allow-related", specs[0].Action)
assert.Equal(t, "from-lport", specs[1].Direction)
assert.Equal(t, "allow-related", specs[1].Action)
}
}
+221
View File
@@ -0,0 +1,221 @@
package policy
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/network/ovn"
"github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
"github.com/mulgadc/spinifex/spinifex/network/topology"
)
// EIPSpec is a 1:1 EIP NAT (dnat_and_snat). In NATModeDistributed PortName
// and MAC MUST be set for per-chassis flows; missing values fall back to
// centralised shape (hairpins via gateway chassis).
type EIPSpec struct {
VPCID string
ExternalIP string
LogicalIP string
PortName string
MAC string
}
// NATGWSpec is a NAT Gateway SNAT rule keyed by (snat, SubnetCIDR).
type NATGWSpec struct {
VPCID string
NATGatewayID string
PublicIP string
SubnetCIDR string
}
// NATManager owns NAT-rule lifecycle. Mode is fixed at construction.
type NATManager interface {
// AddEIP installs a dnat_and_snat rule, cleaning stale rules for the
// same ExternalIP on any router first (pool reuse across VPCs).
AddEIP(ctx context.Context, eip EIPSpec) error
// DeleteEIP removes the rule by LogicalIP; idempotent.
DeleteEIP(ctx context.Context, vpcID, logicalIP string) error
// AddNATGateway installs the (snat, SubnetCIDR) rule; rejects overlap.
AddNATGateway(ctx context.Context, gw NATGWSpec) error
// DeleteNATGateway removes the (snat, SubnetCIDR) rule; idempotent.
DeleteNATGateway(ctx context.Context, vpcID, subnetCIDR string) error
// AddSNAT installs the IGW default-outbound snat rewriting VPC CIDR
// to ExternalIP. AWS-parity callers typically skip this.
AddSNAT(ctx context.Context, vpcID, vpcCIDR, externalIP string) error
// DeleteSNAT removes the IGW default-outbound snat for vpcCIDR.
DeleteSNAT(ctx context.Context, vpcID, vpcCIDR string) error
}
// FlowsBarrier blocks until ovn-northd has compiled NB → SB and every
// chassis installed flows. Production wires a closure over
// `ovn-nbctl --wait=hv sync`; tests leave it nil.
type FlowsBarrier func() error
type Option func(*natManager)
// WithFlowsBarrier injects the post-write flow-install barrier so callers
// only see success once every chassis has the rewrite flow.
func WithFlowsBarrier(b FlowsBarrier) Option {
return func(m *natManager) {
if b != nil {
m.barrier = b
}
}
}
type natManager struct {
ovn ovn.Client
mode NATMode
barrier FlowsBarrier
}
var _ NATManager = (*natManager)(nil)
// NewNATManager constructs a NATManager bound to one NAT mode (must not be
// NATModeUnknown).
func NewNATManager(client ovn.Client, mode NATMode, opts ...Option) (NATManager, error) {
if mode == NATModeUnknown {
return nil, fmt.Errorf("NAT mode is unknown; resolve from host.Wiring.UplinkMode()")
}
m := &natManager{
ovn: client,
mode: mode,
barrier: func() error { return nil },
}
for _, opt := range opts {
opt(m)
}
return m, nil
}
func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error {
router := topology.VPCRouter(eip.VPCID)
natRule := &nbdb.NAT{
Type: "dnat_and_snat",
ExternalIP: eip.ExternalIP,
LogicalIP: eip.LogicalIP,
ExternalIDs: map[string]string{
"spinifex:vpc_id": eip.VPCID,
"spinifex:public_ip": eip.ExternalIP,
},
}
distributed := m.mode == NATModeDistributed && eip.PortName != "" && eip.MAC != ""
if distributed {
mac := eip.MAC
port := eip.PortName
natRule.ExternalMAC = &mac
natRule.LogicalPort = &port
}
// Skip when the existing row already matches; avoids the
// delete-then-add flow-install gap on duplicate publishes.
if existing, err := m.ovn.FindNATByExternalIP(ctx, "dnat_and_snat", eip.ExternalIP); err != nil {
slog.Warn("policy: AddEIP idempotency lookup failed", "external_ip", eip.ExternalIP, "err", err)
} else if existing != nil && existing.LogicalIP == eip.LogicalIP &&
existing.ExternalIDs["spinifex:vpc_id"] == eip.VPCID &&
(!distributed ||
(existing.ExternalMAC != nil && *existing.ExternalMAC == eip.MAC &&
existing.LogicalPort != nil && *existing.LogicalPort == eip.PortName)) {
slog.Info("policy: AddEIP idempotent skip — rule already current",
"router", router, "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP)
return nil
}
// Search every router for stale rules — vpc.delete-nat is
// fire-and-forget and may not have run before IP reuse.
if removed, err := m.ovn.DeleteAllNATsByExternalIP(ctx, "dnat_and_snat", eip.ExternalIP); err != nil {
slog.Warn("policy: stale NAT cleanup failed before AddEIP", "external_ip", eip.ExternalIP, "err", err)
} else if removed > 0 {
slog.Info("policy: cleaned stale dnat_and_snat rules before AddEIP", "external_ip", eip.ExternalIP, "removed", removed)
}
if err := m.ovn.AddNAT(ctx, router, natRule); err != nil {
return fmt.Errorf("add dnat_and_snat %s -> %s on %s: %w", eip.LogicalIP, eip.ExternalIP, router, err)
}
if err := m.barrier(); err != nil {
slog.Warn("policy: AddEIP flows barrier failed", "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP, "err", err)
}
return nil
}
func (m *natManager) DeleteEIP(ctx context.Context, vpcID, logicalIP string) error {
router := topology.VPCRouter(vpcID)
if err := m.ovn.DeleteNAT(ctx, router, "dnat_and_snat", logicalIP); err != nil {
if errors.Is(err, ovn.ErrNATNotFound) {
return nil
}
return fmt.Errorf("delete dnat_and_snat %s on %s: %w", logicalIP, router, err)
}
return nil
}
func (m *natManager) AddNATGateway(ctx context.Context, gw NATGWSpec) error {
router := topology.VPCRouter(gw.VPCID)
snatRule := &nbdb.NAT{
Type: "snat",
ExternalIP: gw.PublicIP,
LogicalIP: gw.SubnetCIDR,
ExternalIDs: map[string]string{
"spinifex:vpc_id": gw.VPCID,
"spinifex:nat_gateway_id": gw.NATGatewayID,
},
}
if err := m.ovn.AddNAT(ctx, router, snatRule); err != nil {
return fmt.Errorf("add NAT GW snat %s -> %s on %s: %w", gw.SubnetCIDR, gw.PublicIP, router, err)
}
// Block until SB + chassis have the SNAT flow; otherwise first packets
// from the private subnet drop.
if err := m.barrier(); err != nil {
slog.Warn("policy: AddNATGateway flows barrier failed",
"public_ip", gw.PublicIP, "subnet_cidr", gw.SubnetCIDR, "err", err)
}
return nil
}
func (m *natManager) DeleteNATGateway(ctx context.Context, vpcID, subnetCIDR string) error {
router := topology.VPCRouter(vpcID)
if err := m.ovn.DeleteNAT(ctx, router, "snat", subnetCIDR); err != nil {
if errors.Is(err, ovn.ErrNATNotFound) {
return nil
}
return fmt.Errorf("delete NAT GW snat %s on %s: %w", subnetCIDR, router, err)
}
return nil
}
func (m *natManager) AddSNAT(ctx context.Context, vpcID, vpcCIDR, externalIP string) error {
router := topology.VPCRouter(vpcID)
snatRule := &nbdb.NAT{
Type: "snat",
ExternalIP: externalIP,
LogicalIP: vpcCIDR,
ExternalIDs: map[string]string{
"spinifex:vpc_id": vpcID,
"spinifex:role": "igw-default-snat",
},
}
if err := m.ovn.AddNAT(ctx, router, snatRule); err != nil {
return fmt.Errorf("add IGW snat %s -> %s on %s: %w", vpcCIDR, externalIP, router, err)
}
return nil
}
func (m *natManager) DeleteSNAT(ctx context.Context, vpcID, vpcCIDR string) error {
router := topology.VPCRouter(vpcID)
if err := m.ovn.DeleteNAT(ctx, router, "snat", vpcCIDR); err != nil {
if errors.Is(err, ovn.ErrNATNotFound) {
return nil
}
return fmt.Errorf("delete IGW snat %s on %s: %w", vpcCIDR, router, err)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More