mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 19:01:42 +00:00
1873f45d26
* fix: idempotent Delete* across VPC family + volume (RLC1) Delete handlers now return empty success when the resource is already absent (nats.ErrKeyNotFound), instead of *NotFound. Transient read errors still map to ServerInternal. Covers DeleteVpc, DeleteSubnet, DeleteSecurityGroup, DeleteNetworkInterface, DeleteInternetGateway, DeleteNatGateway, DeleteRouteTable and DeleteVolume. Makes destroy retries converge instead of failing mid-graph, removing a source of orphaned/zombie resources between deploys. Adds a TestRLC1_*DeleteIdempotentOnAbsent invariant per package and updates the daemon delegation table; supersedes the old absent->NotFound tests. * feat: prune orphan ENI LSPs in reconcile applyPorts (ADR-0003 §4) applyPorts was create-or-update only, so a guest LSP whose spinifex:eni_id no longer maps to an intent ENI survived instance terminate and VM-host reinstall — the orphan-LSP capacity-drift root cause. It now takes pruneOrphans (mirroring applySGs): on a full reconcile it lists LSPs and tears down each managed ENI port absent from intent via the composed topology.DeletePort cascade (clears port-group memberships, then deletes the LSP). The apply-only startup path does not prune, preserving in-flight ports until subscribers converge. Adds ListLogicalSwitchPorts to the OVN client (live + mock). Invariant TestRLC5_OrphanLSPPrunedAfterReconcile guards ADR-0003 §4; a startup-race test guards the apply-only path. * feat: idempotent + tracked instance terminate (ADR-0003 §1/§2) Make terminate idempotent across all three layers and break the un-terminable-ENI deadlock, and record per-dependency teardown so the GC backstop can later tell a fully-reaped instance from a dangling one. - vm.Manager.Terminate: absent / already-terminated / shutting-down now return nil instead of NotFound / invalid-transition (ADR-0003 §2). - VM.Teardown map + teardown.go marks: sync steps stamped Done/Failed via cleaner error returns; fire-and-forget OVN LSP and NAT rule stamped Pending for the reconciler / GC reaper (ADR-0003 §1). - InstanceCleaner: DeleteVolumes / ReleasePublicIP / DetachAndDeleteENI / RemoveFromPlacementGroup / ReleaseGPU now return error. - vpc ForceDeleteInstanceENI bypasses the in-use guard for the owning instance, breaking the failed-detach deadlock; plain delete still rejects an in-use ENI held by another instance. - gateway TerminateInstances: KV-health-gated idempotent absence (a failed terminated-bucket query does not fabricate success, ADR-0003 §3). - Invariants: TestRLC1_TerminateIdempotentOnAbsent, TestRLC4 teardown-map stamping + failed-dependency, TestRLC_ENINonDeadlock. * feat: reconciler GC backstop framework + terminated-teardown reaper (ADR-0003 §3) Add the shared reality->desired garbage-collection framework (inverse of Restore) and its first reaper, closing the gap 295.3 deferred: nothing retried pending/failed teardown or purged the terminated record when its dependents reached done. - vm/gc.go: GarbageCollector + Reaper interface (Class/Scope/Sweep). KV-health gated — holds and alarms when the desired-state cannot be trusted, never reaps against it. ClusterWide reapers run only on the elected leader (optional elector); NodeLocal run on every node. Start sweeps immediately then every GCInterval. - vm/teardown_reaper.go: TerminatedTeardownReaper (node-local). Scans this node's terminated records, re-drives each not-done dependent through the idempotent cleaner, and purges the record once all reach done. Records already complete on arrival are left to the bucket's 1h TTL. - StateStore gains DeleteTerminatedInstance (JetStream impl already existed). - daemon: wires the GC after the pending watchdog, gated on a cheap JetStream AccountInfo health probe. - Cluster OVN-LSP arm already lands via the reconciler drift prune (295.2). - Invariants: TestRLC5_GCRetriesPendingTeardownToDoneThenPurges, TestRLC5_GCNeverPurgesIncompleteRecord, TestRLC5_GCHoldsWhenKVDegraded, TestRLC5_GCSkipsOtherNodesRecords. * feat: VPC-family dependency guards (ADR-0004 rule #3) DeleteSubnet blocks while a live ENI attachment (a resident instance) remains; available/orphan ENIs do not pin it. DeleteNetworkInterface guards on the structured attachment (eniIsLiveAttachment), not a bare in-use string. DeleteNatGateway blocks while a live route table forwards to it and disassociates its EIP keeping the allocation (AWS parity); CreateNatGateway now marks the EIP associated so the disassociate is real and a second NAT GW cannot reuse the allocation. Binding invariants: TestRLC3 dependency-guard completeness across the subnet/ENI/NAT-gateway handlers. * feat: EBS volume data-safety GC override (ADR-0005) The shared GC backstop reaps actual-desired for every other class; for volumes that default is forbidden because they carry customer data. Add VolumeLeakReaper (node-local, rides the 295.5 GC): it marks a volume left attached to a definitively-gone instance as orphaned (a non-destructive tag) and alarms, but NEVER issues a delete. The daemon feeds it only this node's terminated instances whose volumes-teardown failed, so the shared predastore scan can never false-mark another node's live-instance volume. DeleteVolume idempotency + the detach/snapshot guards + the DeleteOnTermination teardown retry already landed (295.1/295.3/295.5). Binding invariants: TestRLC5 (GC marks+alarms, never deletes data), TestRLC3 (detach-before-delete). * test(elbv2): lock ADR-0002 §3 live-only TG in-use guard (mulga-siv-295.4) * feat(eks): idempotent DeleteCluster on absent + ADR-0006 lifecycle invariants (mulga-siv-295.8) DeleteCluster returns empty success when the cluster meta is already swept (Common Resource Lifecycle Contract #1), so tofu destroy retries converge. Adds TestRLC1 (idempotent delete), TestRLC2 (billable-before-KV-sweep ordering) and TestRLC3 (NLB target-group no-orphan, riding ADR-0002/172). * fix(eks): GC reaper terminates orphan control-plane VM after cluster delete (mulga-siv-294) A normal DeleteCluster can leave a running control-plane VM behind (terminate completes async; a daemon restart re-launches it from saved running-state) after the cluster meta is swept — a billable orphan with no owner. Add EKSBillableReaper on the 0003 GC backstop: a running managed-by=eks CP VM whose cluster meta is definitively gone is terminated. The CP ENI now carries the customer-account tag so a system-account VM can be mapped back to its cluster meta; the reaper never reaps on uncertainty (ENI gone/untagged or meta present). * feat: reclaim orphan EKS NLB + NAT-GW EIP in billable reaper The billable GC reaper terminated an orphan control-plane VM but left the cluster's NAT-gateway EIP and NLB front-end NAT behind when the meta was gone. After a successful terminate, run the name/tag-driven DeleteClusterNLB + DeleteClusterCPVPC teardown (idempotent, meta-independent) so the billable NAT-GW EIP and NLB NAT are reclaimed too. ADR-0006 §5. * fix: persist NAT-GW & route-table tags and reorder CP-VPC teardown to reclaim EKS NAT-GW EIP CreateNatGateway and CreateRouteTable both dropped input.TagSpecifications, so the tag-driven DeleteClusterCPVPC teardown could not discover the cluster NAT gateway or its private route table. The explicit DeleteNatGateway + ReleaseAddress were skipped and the billable NAT-GW EIP leaked under the system account on every clean DeleteCluster. Three fixes: - CreateNatGateway persists ExtractTags(TagSpecifications, natgateway). - CreateRouteTable persists ExtractTags(TagSpecifications, route-table). - DeleteClusterCPVPC tears down route tables before the NAT gateway, so the rule #3 route-ref guard (DependencyViolation / InvalidAddress.Locked) is not tripped and the EIP releases. Adds tag-persistence regression tests for both create paths and a teardown ordering invariant (TestRLC5). Validated live: NAT-GW EIP released cleanly, no DependencyViolation/Locked. * fix: route EKS control-plane VM terminate cross-node + DELETING teardown backstop Multi-node EKS cluster delete wedged in DELETING and stranded the billable NAT-GW EIP. Two root causes, both invisible on a single-node box: Cross-node terminate was never routed. TerminateSystemInstance was node-local, so terminating a CP VM owned by another node returned NotFound (treated as idempotent) and the teardown deleted a still-attached ENI (InvalidNetworkInterface.InUse). That error short-circuited purgeClusterInfra before the managed CP-VPC teardown that releases the NAT-GW EIP. Split TerminateSystemInstance into a local body plus a routing wrapper that issues system.TerminateInstance.<id> to the owning node. Bind that subscription for every system-managed VM (elbv2 + eks) via the OnInstanceUp funnel, so CP VMs placed on the coordinator's own node (and recovered after a daemon restart) are reachable too, not only remotely-launched ones. No DELETING backstop. A failed synchronous DeleteCluster left the cluster DELETING with nothing to re-drive it. Add EKSDeletingReaper (vm.Reaper, class eks-deleting) that re-runs purgeClusterInfra for clusters wedged past a min-age, gated by the per-cluster reconciler leader lease. Stamp ClusterMeta.DeletingSince on the transition into DELETING. TerminateK3sServerVM additionally waits out any residual ENI-InUse with a bounded budget as defense-in-depth. Validated live on env19 3-node: first delete-cluster converges in 29s, both remote CP VMs terminated cross-node, NAT-GW EIP released, backstop never fires. * fix(network): gate gateway reconcile on real datapath reachability Post-reboot the external OVN datapath can stay dead while every control-plane signal is green: the chassisredirect SB binding is claimed (up=true), NAT rows are present and the host neigh is primed, yet ovn-controller leaves stale gateway/localnet flows so EIP north-south and the VM->gateway heartbeat forward nothing. ensureGatewayClaimed only nudges recompute when the binding is unclaimed, so a claimed-but-dead datapath is never detected or repaired. Add ensureGatewayDatapath: after the SB claim converges, probe the gateway LRP IP (OVN answers ICMP natively, no guest or security-group dependency), and on a miss force one ovn-controller recompute and re-probe until a short deadline. Reuses the existing recompute nudge and no-ops when no verifier is wired or no gateway IP resolves. * fix: self-heal external uplink after reboot boot-race Post-reboot, ovs-vswitchd/ovn-controller order after the passive network.target rather than network-online.target, so they start concurrently with systemd-networkd. Under a heavy boot the WAN-glue veth (veth-wan-ovs) can come up admin-down before networkd brings it up, leaving br-wan with no carrier and the external datapath dead while every control-plane signal reconciles green. Broaden the datapath health-gate remediation from recompute-only to RepairDatapath: re-assert the veth admin state (covers the admin-down link, which a flow recompute cannot revive) then recompute (covers a stale gateway ofport). Idempotent; the veth ops are skipped in physical-uplink mode where the device is absent. Also capture ip -d link (veth admin/carrier) and ovs-ofctl show br-ext (ofport) in the reboot diag bundle to disambiguate the two failure shapes from CI artifacts. * fix: restore AWS-faithful Delete* NotFound; move idempotency to teardown callers (mulga-siv-306) RLC1 (f9218982) made EC2/VPC/EBS Delete* handlers return success on an absent resource, breaking AWS compatibility and the cross-account isolation signal (a cross-account delete reads as absent -> success instead of *.NotFound). Restore the AWS contract at the API while keeping lifecycle convergence: - Handlers return canonical *.NotFound on absent: DeleteVpc, DeleteSubnet, DeleteSecurityGroup, DeleteInternetGateway, DeleteNatGateway, DeleteRouteTable, DeleteVolume, and the public DeleteNetworkInterface (ForceDeleteInstanceENI keeps its idempotent force path, ADR-0003 section 2). - Add awserrors.IsNotFound; destroy orchestration (instance terminate, EKS CP-VPC teardown, EKS IGW/SG cleanup, k3s rollback, ELBv2 ENI/SG teardown) tolerates NotFound so re-issued/double-targeted deletes converge. - Flip the RLC1 unit invariants to assert *.NotFound on absent; revert the daemon round-trip cases; add IsNotFound + CP-VPC convergence tests. ELBv2 deletes and TerminateInstances stay idempotent (AWS itself is). * fix(network): gate ingress EIP datapath on guest SB Port_Binding convergence TestMultinodeGuestSSH intermittently times out (Connection timed out, not #355's reset-by-peer) connecting to a fresh trio instance's public IP after the suite churns instances across all three chassis. The instance is running and AddEIP has installed the DNAT row, flushed/primed the host neigh, and barriered the gateway-chassis flow — but none of that proves the gatewayLRP->guest geneve hop: until the guest ENI's southbound Port_Binding is up on its hosting chassis the cross-node flow is missing and the DNAT-translated packet blackholes. Add ensureGuestPortDatapath, the guest-side analog of ensureGatewayDatapath: after AddEIP, probe the guest LSP's SB Port_Binding (ovn-sbctl up column); on a miss force one ovn-controller recompute (binds the port / installs the remote flow) and re-probe until a short deadline. Recompute, not RepairDatapath: this is flow/binding convergence, not a WAN-uplink fault. No-op when no verifier is wired or the EIP carries no guest port. spec.PortName is populated for every associated EIP regardless of NAT mode. Extends GatewayClaimVerifier with GuestPortUp and wires the gate into applyEIPs so it runs in the continuous reconcile loop on every node. * Revert "fix(network): gate ingress EIP datapath on guest SB Port_Binding convergence" This reverts commit 728b1a9cec6eaf5c7ef5e9eda4469f239b4d8fc0. * fix(network): key EIP DNAT delete on external IP, not logical IP A dnat_and_snat row's identity on the VPC gateway router is its external IP. The logical (private) IP is not a stable key: the EIP pool round-robins and instance private IPs are reused as instances recycle, so the same logical IP is held by different EIPs over time. DeleteEIP deleted the row by logical IP, so a stale or retried delete for a freed EIP clobbered whichever live EIP now held that recycled private IP -- the next owner's row vanished with no re-add and its public IP went dark (SSH SYN never left br-wan: Connection timed out). Lifecycle-resilience teardown/GC retries amplified this by firing vpc.delete-nat repeatedly. Key the delete on the external IP via DeleteNATByExternalIP and skip when no external IP is supplied (no EIP means no dnat_and_snat row). A stale delete now targets the freed EIP's own external IP (ErrNATNotFound no-op) and can no longer touch the live row. * refactor: relax DeleteNatGateway to AWS-faithful Remove the route-reference dependency guard (checkNatGatewayRouteRefs) so a NAT gateway deletes even while route tables still forward to it, matching AWS (the route blackholes). The reconciler's loadNATGWs drops the SNAT once the record leaves the active bucket, so a forwarding route is not a delete-blocking dependent. EIP is still disassociated on delete (no leak). * test: invert NAT-GW route-ref invariant to AWS-faithful TestRLC3_NatGatewayBlocksWhileRouted asserted DependencyViolation while a route forwarded to the NAT gateway, which is AWS-infaithful. Replace with TestRLC3_NatGatewayDeletesWhileRouted: delete must succeed whether or not a live route still targets it (the route blackholes; the reconciler drops the SNAT). Per ADR-0004 §2. * fix: tear down NAT-GW SNAT on delete (restore delete-time fan-out) The guard commit (53ed55a9) dropped publishDeleteEventsForNatGateway on the assumption a routed NAT GW could never be deleted. With the route-ref guard removed, deleting a NAT GW while routes still target it left the SNAT in place (applyNATGWs is create-only, so the reconciler never prunes it) and egress kept working — TestNATGateway's post-teardown 'lost internet' check failed. Restore the fan-out: on delete, scan route tables and publish vpc.delete-nat-gateway per associated subnet so egress stops, while the route entry survives as a blackhole (AWS-faithful). * fix: probe EIP datapath in reboot self-heal, not the gateway LRP IP The post-reboot gateway datapath self-heal probed the gateway LRP IP for liveness, which OVN answers natively at the router — so it stayed green even when the EIP NAT pipeline was stranded (ofproto/trace dropped EIP traffic at table 0). RepairDatapath never fired and TestRebootResilience timed out, with the drift loop re-probing the same false-green every cycle. Prefer probing an associated EIP: force a fresh ARP of the dnat_and_snat external IP (flush neigh, ping to trigger, read the resolved MAC). OVN answers ARP for the external IP from the gateway chassis independent of the guest, so a resolved MAC proves the WAN uplink forwards and the NAT flows are installed — the signal the LRP IP cannot give. Fall back to the LRP IP for VPCs with no EIP. The existing drift-loop cadence now re-heals a later flow strand. * fix: lb-agent heartbeats over mgmt port, not the WAN/EIP datapath The lb-agent heartbeat to awsgw is control-plane and must survive a host reboot that strands the OVN/EIP north-south data plane. Customer (internet-facing) lb-agents heartbeated over the WAN GatewayURL, riding the EIP datapath, so after a reboot the ALB never reported healthy until the datapath self-healed. System LBs already heartbeated over the on-link br-mgmt NIC; this extends that path to every lb-agent. buildLBAgentEnv now always uses mgmtGatewayURL (on-link MgmtBridgeIP, the mgmt NIC the lb-agent VM already carries), falling back to the WAN URL only when no mgmt bridge exists. Drops the account-id branch. * fix: re-heal guest ENI SB Port_Binding post-reboot so EIP ingress converges Post-reboot every EIP in a VPC goes dark north-south: the DNAT row, host neigh and gateway-chassis flow are all in place (ARP to the EIP resolves) but the gatewayLRP->guest flow is missing until ovn-controller binds the guest ENI's SB Port_Binding, so the DNAT-translated packet blackholes and the public IP is unreachable on an otherwise-running instance. Restore the guest-port convergence gate in applyEIPs: probe the guest LSP's SB Port_Binding up column; on a miss force an ovn-controller recompute and re-probe until a deadline. Unlike the earlier reverted form, recompute on every miss, not once: after a host reboot the guest tap is replumbed several seconds after the reconcile runs, so a single early nudge fires before the port exists and never binds it. The window is widened to span guest boot. This is the southbound analog of the gateway datapath self-heal; the EIP ARP probe cannot detect it because the gateway chassis answers ARP for the external IP even while the guest delivery flow is absent. * fix(network): force post-reboot EIP recompute on guest-port bind, startup-only The reboot suite passed only by accident: the pre-#351 gateway-claim verifier polled the always-chassis-less gateway LRP, so it fired an ovn-controller inc-engine/recompute on every reconcile. That spurious recompute was load-bearing - it reinstalled the centralised gateway DNAT flows that ovn-controller's incremental engine misses on a cold (post-reboot) start. #351 correctly switched the check to the chassisredirect port, eliminating the every-cycle recompute, and the EIP datapath went dark post-reboot with every control-plane signal green. Restore the kick, scoped to the post-reboot startup path: applyEIPs now gates a single recompute on the guest ENI's SB Port_Binding chassis claim - the reliable signal the tap is back, unlike the up column which stays stale-true across a reboot. Drift never recomputes, preserving the #351 fix for freshly attached EIPs. Probe renamed GuestPortUp -> GuestPortBound (chassis, not up). * Revert "fix(network): force post-reboot EIP recompute on guest-port bind, startup-only" This reverts commit 792e318211ef03efbe24febeb470749d88c0b03c. * test(e2e/reboot): capture dataplane diag on ALB serve failure The post-reboot ALB-serve assertion (8.6) failed with no flow/ARP evidence: the OVN/OVS dataplane dumps only ran on the 8.5 targets-unhealthy path, but 8.5 passes on the daemon's own health view while the north-south EIP datapath is dark, so the serve failure (8.6) left nothing to localise the drop. On a serve failure, capture dumpALBProbe/dumpOVNDataplane/dumpOVNClaimState/ dumpHeartbeatDiagnostics/dumpAppDiagnostics before failing. Add to the OVN dataplane dump: dump-flows br-ext and fdb/show br-ext (north-south bridge, previously only br-int), and a real ARP probe that flushes the neigh entry first so primeReachability's pre-seeded reachable entry can no longer mask an unanswered ARP. Pure diagnostics; no production change. * test(e2e/reboot): decisive datapath diag — untruncate br-int, real-ingress ofproto/trace, conntrack + tap capture * test(e2e/reboot): add south->north return-path ofproto/trace to localize egress drop * test(e2e/reboot): derive LRP MAC from realized flow + remediation experiment (recompute vs ovn-controller restart) * test(e2e/reboot): dump persisted SB MAC_Binding + external MACs; flush experiments (conntrack vs MAC_Binding) to isolate recompute-resistant return-path drop * test(e2e/reboot): untruncate Port_Binding + enumerate DGP/chassisredirect claim state (does cr-gw row exist + resident chassis) * test(e2e/reboot): dump NB source of return-path drop (lflow stage, NAT external_mac/options, LR policy, LSP port_security) * fix: exempt public-IP instances from subnet egress drop gate (mulga-siv-311) Post-reboot reconcile installs the priority-1100 subnet egress drop gate for an IGW-attached subnet that has no 0.0.0.0/0 route, but nothing punched the subnet's public-IP instances through it. lr_in_policy runs before lr_out un-DNAT/SNAT, so an inbound connection's reply still carries its private source when it hits the gate and is dropped. The ALB/EIP datapath goes dark south->north post-reboot while NAT, ARP, chassis and guest-port binding all read green; the pre-reboot path survives only because the event-driven gate never evaluates a subnet created after IGW attach. Install the existing priority-1200 /32 reroute (above the gate) for every public-IP instance during reconcile. Reroute only: the instance's dnat_and_snat already supplies SNAT, unlike EnsureSystemInstanceEgress which also adds a plain snat. Public IPs come from two disjoint sources -- auto-assigned/ELB addresses on the ENI record and user EIPs in the EIP bucket -- so drive the exemption from both intent.Ports (new PortSpec.PublicIP) and intent.EIPs. * refactor: scope public-IP drop-gate exemption to gated subnets Only install the priority-1200 reroute when the instance's subnet actually carries a drop gate. Routed subnets egress via their priority-1000 reroute and need no exemption, so bounding on gate presence keeps the change inert for IGW-routed and NATGW-routed subnets (no redundant or overriding policy). Adds a negative test. * refactor: drop unused RemoveEIPInstanceEgress No production caller; the exemption is install-only in reconcile. Removed the interface method and impl rather than leave dead API surface. * test: dump guest console + OVN/datapath diag on SG-to-SG client SSH timeout TestSGToSGDatapath waited for client-vm SSH via EventuallyErr and reported only 'exit status 255' on timeout — no guest-side or datapath state. When the fresh public-IP VM is unreachable for the full window the failure was undiagnosable from CI artifacts. Swap the wait to the non-fatal trySSHReady probe and, on timeout, call DumpVPCFlowDiagnostics (guest console tail, ovn-nbctl/sbctl show, NAT rows, br-int flows / conntrack / host ARP keyed on the public + private IP) before Fatal, mirroring vpc_test/baseline_test. Captures persist to the artifact dir for the Stage 2 analyzer bundle. * fix: ownership-scope public-IP teardown to survive IP recycling A live instance's dnat_and_snat rule, host ARP, and IPAM lease were torn down by the delayed garbage-collection teardown of a different, long-dead instance that previously held the same recycled public IP. The 2-min vm/gc instance-teardown reaper re-runs ReleasePublicIP on a stale terminated record; because the external IPAM round-robins a small pool, the IP had already been reassigned to a live instance, and both teardown primitives acted on the IP by value with no ownership check: - policy.DeleteEIP deleted the dnat_and_snat row by external IP alone, discarding the logical IP it receives. - StaticPoolAllocator.Release freed the pool entry by IP value. Scope both to the current owner so a stale or duplicated teardown for a recycled IP is a no-op: - DeleteEIP looks up the row by external IP and skips the delete (and the ARP flush) when its logical IP no longer matches, mirroring AddEIP's idempotency lookup. Defeats both external- and logical-IP reuse races. - Allocator.Release / ExternalIPAM.ReleaseIP take an ownerENIID; the static allocator no-ops a release whose ENI differs from the live allocation, and treats an already-freed owner-scoped release as idempotent. Teardown and rollback callers pass their ENI; user-driven EIP release passes none. Proven via E2E single TestSGToSGDatapath (run 27515293563): client-vm SSH black-holed after its NAT was deleted by the dead instance's GC sweep. * fix(eks): single-flight DeleteCluster teardown per cluster SDK retries of one DeleteCluster get fanned across nodes by the worker queue group, so purgeClusterInfra raced the same cluster on multiple nodes: duplicate ENI/NLB teardown, duplicate TargetGroupNotFound deregisters, and a real double-release risk on the billable NAT-GW EIP. Idempotency kept it correct but noisy. Add a dedicated per-cluster teardown lease (teardownLeaderKey = reconcilerLeaderKey + "/teardown"), distinct from the reconciler lease so a delete never contends with the still-live reconciler that holds the reconciler lease until it observes DELETING and exits. DeleteCluster acquires it after the NotFound-idempotency check: the loser returns the cluster as DELETING (AWS-async semantics) without running the teardown; the winner drives purgeClusterInfra and releases. The DELETING backstop reaper shares the same lease, so at most one purgeClusterInfra runs per cluster cluster-wide. A nil leaderKV (single-node) skips gating, so local behavior is unchanged. * fix: env-driven SSH-ready budget for slow baremetal guests Add SPINIFEX_SSH_READY_TIMEOUT override for the single-node SSH-handshake probes (default unchanged at 3m). Baremetal boots q35 guests slower (OVMF + NBD-backed disks) than CI loop devices and intermittently misses the 3m window; the harness can now widen it to 5m without affecting shared-runner e2e. Applied at all single-node probe sites; failure messages now report the actual budget. * fix: shared external switch to stop per-VPC localnet ARP collision Per-VPC external switches each attached an untagged localnet port to the same physical network (br-ext). OVN cannot keep multiple untagged localnets on one physical network L2-separate, so with two or more concurrent public-IP VPCs ARP for a public IP landed on the wrong VPC's switch and the owning gateway router's ARP responder never fired, leaving the public IP unreachable. Move to a single shared external switch (provider-network pattern): one switch with one localnet on network_name=external. Each VPC's gateway LRP attaches via its own router-type switch port; per-VPC NAT isolation is unchanged. AttachIGW ensures the shared switch idempotently and is migration-safe; DetachIGW removes only the per-VPC port + LRP and preserves the shared switch. DeleteVPC removes the per-VPC gateway switch port from the shared switch so it does not dangle. Reconcile detects attachment via the gateway LRP since the shared switch carries no vpc_id. * fix: owner-scope DeleteEIP by ENI port to survive identical pair recycling A stale or GC-re-emitted vpc.delete-nat for a terminated instance tore down a live instance's dnat_and_snat rule (and flushed the host ARP) when the public IP and private IP both recycled, together, to that live instance in the same VPC. The (external_ip, logical_ip) pair-key matches under identical recycling, so the existing logical-IP guard fell through. Stamp the owning ENI port (spinifex:logical_port) on the dnat_and_snat row at AddEIP, thread the port through handleDeleteNAT -> DetachEIP -> DeleteEIP, and skip the delete when the row's stamped port differs from the incoming one. The IPAM release was already owner-scoped; this mirrors that guard at the NAT layer so any stale delete for a recycled pair is a no-op. Empty port preserves the prior pair-key behaviour.
1436 lines
54 KiB
Go
1436 lines
54 KiB
Go
//go:build e2e
|
|
|
|
// Package reboot is the single-node host-reboot resilience suite. It MUST run outside
|
|
// the cluster (on the CI runner) because systemctl reboot kills any in-VM process.
|
|
// AWS API traffic crosses the WAN via the SDK; SSH ops use harness.PeerSSH.
|
|
package reboot
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/aws/aws-sdk-go/service/elbv2"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
keyName = "reboot-e2e-key"
|
|
tgName = "reboot-e2e-tg"
|
|
albName = "reboot-e2e-alb"
|
|
sgName = "reboot-e2e-sg"
|
|
vpcCIDR = "10.210.0.0/16"
|
|
subnetCIDR = "10.210.1.0/24"
|
|
httpPort = 80
|
|
probesPerRun = 20
|
|
appHTTPUnit = "reboot-e2e-httpd.service"
|
|
rebootDoctype = "reboot-e2e"
|
|
)
|
|
|
|
// appUserData provisions a systemd-managed HTTP responder. Using a unit (not nohup) ensures
|
|
// the responder survives the guest's own restart after host reboot.
|
|
const appUserData = `#!/bin/bash
|
|
set -e
|
|
INSTANCE_ID=$(hostname)
|
|
install -d -m 0755 /var/lib/httpd
|
|
echo "{\"instance_id\": \"${INSTANCE_ID}\"}" > /var/lib/httpd/index.html
|
|
cat > /etc/systemd/system/reboot-e2e-httpd.service <<'UNIT'
|
|
[Unit]
|
|
Description=Reboot E2E HTTP responder
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
ExecStart=/usr/bin/python3 -m http.server 80 --bind 0.0.0.0 --directory /var/lib/httpd
|
|
Restart=always
|
|
RestartSec=1
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
UNIT
|
|
systemctl daemon-reload
|
|
systemctl enable --now reboot-e2e-httpd.service
|
|
|
|
# Workaround for viperblock WAL <4MB skip-upload bug:
|
|
# WriteWALToChunk(false) is a no-op when the WAL is under 4MB so small
|
|
# user-data writes (the unit file, index.html) sit in the on-host WAL
|
|
# and are lost when the host reboots before the WAL hits the threshold.
|
|
# Write 8MB of zeros + sync to push WAL over the threshold so the
|
|
# unit-file write definitely lands in predastore before the host reboot.
|
|
# Drop when upstream fix lands.
|
|
dd if=/dev/zero of=/var/lib/spinifex-e2e-pad bs=1M count=8 status=none
|
|
sync
|
|
rm -f /var/lib/spinifex-e2e-pad
|
|
sync
|
|
`
|
|
|
|
type fixture struct {
|
|
env *harness.Env
|
|
artifacts string
|
|
ssh *harness.PeerSSH
|
|
aws *harness.AWSClient
|
|
|
|
instanceType string
|
|
amiID string
|
|
|
|
vpcID string
|
|
subnetID string
|
|
igwID string
|
|
sgID string
|
|
|
|
appInstanceIDs []string
|
|
preIPs map[string]string
|
|
privateKeyPath string // local PEM written from CreateKeyPair KeyMaterial
|
|
|
|
tgArn string
|
|
albArn string
|
|
albID string
|
|
listenerArn string
|
|
albPublicIP string
|
|
albEniMAC string
|
|
|
|
preSwitches int
|
|
prePorts int
|
|
|
|
rebootStart time.Time
|
|
rebootDone bool
|
|
mu sync.Mutex // guards rebootDone for the on-failure dump
|
|
|
|
timeouts timeouts
|
|
}
|
|
|
|
type timeouts struct {
|
|
rebootWait time.Duration
|
|
daemonReady time.Duration
|
|
instanceRunning time.Duration
|
|
lbRecover time.Duration
|
|
}
|
|
|
|
// TestRebootResilience runs the 8-phase reboot resilience sequence.
|
|
// Phases are sequential: the reboot is a global event and each phase depends on the prior.
|
|
func TestRebootResilience(t *testing.T) {
|
|
env := harness.LoadEnv(t)
|
|
requireRunnerMode(t, env)
|
|
|
|
fix := &fixture{
|
|
env: env,
|
|
artifacts: harness.ArtifactDir(t, env),
|
|
ssh: harness.NewPeerSSH(),
|
|
preIPs: map[string]string{},
|
|
timeouts: timeouts{
|
|
rebootWait: durationEnv("REBOOT_WAIT_SECS", 300*time.Second),
|
|
daemonReady: durationEnv("DAEMON_READY_SECS", 180*time.Second),
|
|
instanceRunning: durationEnv("INSTANCE_RUNNING_SECS", 120*time.Second),
|
|
// 5min budget: post-reboot the daemon relaunches app VMs and the ALB sys.micro
|
|
// simultaneously; HC probing starts immediately but targets honestly report
|
|
// unhealthy until the responder unit finishes starting (no cloud-init shortcut).
|
|
lbRecover: durationEnv("LB_RECOVER_SECS", 300*time.Second),
|
|
},
|
|
}
|
|
|
|
resolveTrust(t, fix)
|
|
fix.aws = harness.NewAWSClient(t, env)
|
|
|
|
t.Cleanup(func() { cleanup(t, fix) })
|
|
|
|
harness.OnFailure(t, func() {
|
|
// Dump diagnostics windowed from reboot onwards (if reboot fired) so
|
|
// the analysis surfaces journal lines from the recovery path rather
|
|
// than the per-instance state-transition spam during setup.
|
|
fix.mu.Lock()
|
|
since := fix.rebootStart
|
|
done := fix.rebootDone
|
|
fix.mu.Unlock()
|
|
if !done {
|
|
since = time.Time{}
|
|
}
|
|
dumpDiagnostics(t, fix, since)
|
|
dumpAppDiagnostics(t, fix)
|
|
})
|
|
|
|
harness.Phase(t, "Phase 0 — Prerequisites")
|
|
phase0Prereqs(t, fix)
|
|
|
|
harness.Phase(t, "Phase 1 — VPC + Subnet + Security Group")
|
|
phase1Network(t, fix)
|
|
|
|
harness.Phase(t, "Phase 2 — App instances")
|
|
phase2Instances(t, fix)
|
|
|
|
harness.Phase(t, "Phase 3 — ALB + target group + listener")
|
|
phase3LB(t, fix)
|
|
|
|
harness.Phase(t, "Phase 4 — Pre-reboot traffic")
|
|
runHTTPBurst(t, fix, "ALB pre-reboot")
|
|
|
|
harness.Phase(t, "Phase 5 — Snapshot pre-reboot state")
|
|
phase5Snapshot(t, fix)
|
|
|
|
harness.Phase(t, "Phase 6 — systemctl reboot")
|
|
phase6Reboot(t, fix)
|
|
|
|
harness.Phase(t, "Phase 7 — Wait for spinifex readiness")
|
|
phase7Readiness(t, fix)
|
|
|
|
harness.Phase(t, "Phase 8 — Post-reboot assertions")
|
|
phase8Asserts(t, fix)
|
|
}
|
|
|
|
// --- Setup helpers -------------------------------------------------------
|
|
|
|
// requireRunnerMode bails early if the env isn't runner-resident (SPINIFEX_WAN_IP or endpoint missing).
|
|
func requireRunnerMode(t *testing.T, env *harness.Env) {
|
|
t.Helper()
|
|
if env.WANHost == "" {
|
|
t.Fatalf("reboot suite requires SPINIFEX_WAN_IP (no WAN host resolved)")
|
|
}
|
|
if env.Mode != harness.ModeSingle {
|
|
t.Skipf("reboot suite is single-node only (mode=%s)", env.Mode)
|
|
}
|
|
if endpoint := os.Getenv("SPINIFEX_AWS_ENDPOINT"); endpoint == "" {
|
|
t.Fatalf("reboot suite requires SPINIFEX_AWS_ENDPOINT=https://%s:%d", env.WANHost, env.AWSGWPort)
|
|
}
|
|
}
|
|
|
|
// resolveTrust SCPs the spinifex CA to a tmp file for real TLS verification.
|
|
// Falls back to SPINIFEX_AWS_INSECURE=1 on any error; CA trust is the cert suite's concern.
|
|
func resolveTrust(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
if os.Getenv("SPINIFEX_AWS_INSECURE") == "1" {
|
|
t.Logf("trust: SPINIFEX_AWS_INSECURE=1 set; skipping CA fetch")
|
|
return
|
|
}
|
|
if os.Getenv("SPINIFEX_CA_CERT") != "" {
|
|
t.Logf("trust: SPINIFEX_CA_CERT already set; skipping CA fetch")
|
|
return
|
|
}
|
|
|
|
tmpCA := filepath.Join(fix.artifacts, "spinifex-ca.pem")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
candidates := []string{
|
|
"/etc/spinifex/ca.pem",
|
|
"$HOME/spinifex/config/ca.pem",
|
|
}
|
|
var lastErr error
|
|
for _, remote := range candidates {
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, "cat "+remote)
|
|
if err == nil && len(out) > 0 {
|
|
if writeErr := os.WriteFile(tmpCA, out, 0o600); writeErr == nil {
|
|
t.Logf("trust: fetched CA from %s -> %s", remote, tmpCA)
|
|
t.Setenv("SPINIFEX_CA_CERT", tmpCA)
|
|
return
|
|
} else {
|
|
lastErr = writeErr
|
|
}
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
}
|
|
t.Logf("trust: CA fetch failed (%v); falling back to InsecureSkipVerify", lastErr)
|
|
t.Setenv("SPINIFEX_AWS_INSECURE", "1")
|
|
}
|
|
|
|
// --- Phases --------------------------------------------------------------
|
|
|
|
func phase0Prereqs(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
harness.Step(t, "WAN_IP=%s", fix.env.WANHost)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
require.NoError(t, fix.ssh.Ping(ctx, fix.env.WANHost), "ssh to %s", fix.env.WANHost)
|
|
harness.Detail(t, "ssh", "ok")
|
|
|
|
fix.instanceType = discoverNanoInstanceType(t, fix.aws)
|
|
harness.Detail(t, "instance_type", fix.instanceType)
|
|
|
|
fix.amiID = discoverAMI(t, fix.aws)
|
|
harness.Detail(t, "ami", fix.amiID)
|
|
|
|
// Capture KeyMaterial so post-reboot diagnostics can SSH into guest VMs.
|
|
_, _ = fix.aws.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(keyName)})
|
|
kpOut, err := fix.aws.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(keyName)})
|
|
require.NoErrorf(t, err, "create-key-pair %s", keyName)
|
|
material := aws.StringValue(kpOut.KeyMaterial)
|
|
require.NotEmptyf(t, material, "create-key-pair %s returned no KeyMaterial", keyName)
|
|
fix.privateKeyPath = filepath.Join(fix.artifacts, keyName+".pem")
|
|
require.NoError(t, os.WriteFile(fix.privateKeyPath, []byte(material), 0o600), "write private key")
|
|
harness.Detail(t, "key_pair", keyName)
|
|
}
|
|
|
|
func phase1Network(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
|
|
vpcOut, err := fix.aws.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String(vpcCIDR)})
|
|
require.NoError(t, err, "create-vpc")
|
|
fix.vpcID = aws.StringValue(vpcOut.Vpc.VpcId)
|
|
harness.Detail(t, "vpc", fix.vpcID)
|
|
|
|
igwOut, err := fix.aws.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{})
|
|
require.NoError(t, err, "create-internet-gateway")
|
|
fix.igwID = aws.StringValue(igwOut.InternetGateway.InternetGatewayId)
|
|
_, err = fix.aws.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(fix.igwID),
|
|
VpcId: aws.String(fix.vpcID),
|
|
})
|
|
require.NoError(t, err, "attach-internet-gateway")
|
|
harness.Detail(t, "igw", fix.igwID)
|
|
|
|
subnetOut, err := fix.aws.EC2.CreateSubnet(&ec2.CreateSubnetInput{
|
|
VpcId: aws.String(fix.vpcID),
|
|
CidrBlock: aws.String(subnetCIDR),
|
|
})
|
|
require.NoError(t, err, "create-subnet")
|
|
fix.subnetID = aws.StringValue(subnetOut.Subnet.SubnetId)
|
|
_, _ = fix.aws.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
|
|
SubnetId: aws.String(fix.subnetID),
|
|
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
|
})
|
|
harness.Detail(t, "subnet", fix.subnetID)
|
|
|
|
sgOut, err := fix.aws.EC2.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
|
|
GroupName: aws.String(sgName),
|
|
Description: aws.String("Reboot E2E shared SG (ALB + app instances)"),
|
|
VpcId: aws.String(fix.vpcID),
|
|
})
|
|
require.NoError(t, err, "create-security-group")
|
|
fix.sgID = aws.StringValue(sgOut.GroupId)
|
|
|
|
// Use IpPermissions form — vpcd ignores the top-level shorthand field.
|
|
// tcp/22 is opened so on-failure diagnostics can SSH into app guests.
|
|
_, err = fix.aws.EC2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
|
|
GroupId: aws.String(fix.sgID),
|
|
IpPermissions: []*ec2.IpPermission{{
|
|
IpProtocol: aws.String("tcp"),
|
|
FromPort: aws.Int64(httpPort),
|
|
ToPort: aws.Int64(httpPort),
|
|
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
|
|
}, {
|
|
IpProtocol: aws.String("tcp"),
|
|
FromPort: aws.Int64(22),
|
|
ToPort: aws.Int64(22),
|
|
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
|
|
}},
|
|
})
|
|
require.NoError(t, err, "authorize tcp/80 on %s", fix.sgID)
|
|
harness.Detail(t, "sg", fix.sgID)
|
|
}
|
|
|
|
func phase2Instances(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
for i := 0; i < 2; i++ {
|
|
out, err := fix.aws.EC2.RunInstances(&ec2.RunInstancesInput{
|
|
ImageId: aws.String(fix.amiID),
|
|
InstanceType: aws.String(fix.instanceType),
|
|
KeyName: aws.String(keyName),
|
|
SubnetId: aws.String(fix.subnetID),
|
|
SecurityGroupIds: []*string{aws.String(fix.sgID)},
|
|
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(appUserData))),
|
|
MinCount: aws.Int64(1),
|
|
MaxCount: aws.Int64(1),
|
|
})
|
|
require.NoErrorf(t, err, "run-instances app%d", i+1)
|
|
require.NotEmpty(t, out.Instances)
|
|
id := aws.StringValue(out.Instances[0].InstanceId)
|
|
fix.appInstanceIDs = append(fix.appInstanceIDs, id)
|
|
harness.Detail(t, fmt.Sprintf("app%d", i+1), id)
|
|
}
|
|
|
|
for _, id := range fix.appInstanceIDs {
|
|
harness.WaitForInstanceRunning(t, fix.aws, id, fix.timeouts.instanceRunning)
|
|
}
|
|
|
|
for _, id := range fix.appInstanceIDs {
|
|
ip := describePrivateIP(t, fix.aws, id)
|
|
require.NotEmptyf(t, ip, "%s has no PrivateIpAddress", id)
|
|
fix.preIPs[id] = ip
|
|
harness.Detail(t, id, ip)
|
|
}
|
|
}
|
|
|
|
func phase3LB(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
|
|
tgOut, err := fix.aws.ELBv2.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
|
Name: aws.String(tgName),
|
|
Protocol: aws.String("HTTP"),
|
|
Port: aws.Int64(httpPort),
|
|
VpcId: aws.String(fix.vpcID),
|
|
HealthCheckPath: aws.String("/index.html"),
|
|
HealthCheckIntervalSeconds: aws.Int64(5),
|
|
HealthyThresholdCount: aws.Int64(2),
|
|
UnhealthyThresholdCount: aws.Int64(2),
|
|
})
|
|
require.NoError(t, err, "create-target-group")
|
|
fix.tgArn = aws.StringValue(tgOut.TargetGroups[0].TargetGroupArn)
|
|
harness.Detail(t, "tg", fix.tgArn)
|
|
|
|
targets := make([]*elbv2.TargetDescription, len(fix.appInstanceIDs))
|
|
for i, id := range fix.appInstanceIDs {
|
|
targets[i] = &elbv2.TargetDescription{Id: aws.String(id)}
|
|
}
|
|
_, err = fix.aws.ELBv2.RegisterTargets(&elbv2.RegisterTargetsInput{
|
|
TargetGroupArn: aws.String(fix.tgArn),
|
|
Targets: targets,
|
|
})
|
|
require.NoError(t, err, "register-targets")
|
|
|
|
lbOut, err := fix.aws.ELBv2.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
|
Name: aws.String(albName),
|
|
Scheme: aws.String("internet-facing"),
|
|
Subnets: []*string{aws.String(fix.subnetID)},
|
|
SecurityGroups: []*string{aws.String(fix.sgID)},
|
|
})
|
|
require.NoError(t, err, "create-load-balancer")
|
|
require.NotEmpty(t, lbOut.LoadBalancers)
|
|
fix.albArn = aws.StringValue(lbOut.LoadBalancers[0].LoadBalancerArn)
|
|
parts := strings.Split(fix.albArn, "/")
|
|
fix.albID = parts[len(parts)-1]
|
|
harness.Detail(t, "alb", fix.albArn)
|
|
|
|
listenerOut, err := fix.aws.ELBv2.CreateListener(&elbv2.CreateListenerInput{
|
|
LoadBalancerArn: aws.String(fix.albArn),
|
|
Protocol: aws.String("HTTP"),
|
|
Port: aws.Int64(httpPort),
|
|
DefaultActions: []*elbv2.Action{{
|
|
Type: aws.String("forward"),
|
|
TargetGroupArn: aws.String(fix.tgArn),
|
|
}},
|
|
})
|
|
require.NoError(t, err, "create-listener")
|
|
fix.listenerArn = aws.StringValue(listenerOut.Listeners[0].ListenerArn)
|
|
|
|
harness.WaitForLBActive(t, fix.aws, fix.albArn, "ALB", 5*time.Minute)
|
|
|
|
eniDesc := fmt.Sprintf("ELB app/%s/%s", albName, fix.albID)
|
|
var eni *ec2.NetworkInterface
|
|
harness.EventuallyErr(t, func() error {
|
|
out, err := fix.aws.EC2.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
|
Filters: []*ec2.Filter{{
|
|
Name: aws.String("description"),
|
|
Values: []*string{aws.String(eniDesc)},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(out.NetworkInterfaces) == 0 {
|
|
return fmt.Errorf("no ENI for %s", eniDesc)
|
|
}
|
|
eni = out.NetworkInterfaces[0]
|
|
return nil
|
|
}, 30*time.Second, 2*time.Second)
|
|
|
|
if eni.Association != nil {
|
|
fix.albPublicIP = aws.StringValue(eni.Association.PublicIp)
|
|
}
|
|
fix.albEniMAC = aws.StringValue(eni.MacAddress)
|
|
require.NotEmpty(t, fix.albPublicIP, "ALB has no public IP")
|
|
harness.Detail(t, "alb_public_ip", fix.albPublicIP, "alb_mac", fix.albEniMAC)
|
|
|
|
harness.WaitForTargetsHealthy(t, fix.aws, fix.tgArn, 2, "ALB", 3*time.Minute)
|
|
}
|
|
|
|
func phase5Snapshot(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, "sudo ovn-nbctl show 2>/dev/null || true")
|
|
if err != nil {
|
|
t.Logf("ovn-nbctl pre-snapshot: %v (continuing with zero counts)", err)
|
|
return
|
|
}
|
|
fix.preSwitches, fix.prePorts = countOVN(string(out))
|
|
harness.Detail(t, "pre_switches", fix.preSwitches, "pre_ports", fix.prePorts)
|
|
harness.DumpFile(t, fix.artifacts, "ovn-pre.txt", out)
|
|
|
|
// Capture OVN chassis identity pre-reboot so the post-reboot dump can be diffed;
|
|
// SB Chassis _uuid churn across reboot orphans every Port_Binding claim.
|
|
cstate, err := fix.ssh.Run(ctx, fix.env.WANHost, ovnChassisStateBundle)
|
|
if err != nil {
|
|
t.Logf("ovn chassis pre-snapshot: %v (non-fatal)", err)
|
|
}
|
|
harness.DumpFile(t, fix.artifacts, "ovn-pre-chassis.txt", cstate)
|
|
}
|
|
|
|
func phase6Reboot(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
fix.mu.Lock()
|
|
fix.rebootStart = time.Now()
|
|
fix.mu.Unlock()
|
|
harness.Step(t, "issuing reboot via ssh (connection will drop)")
|
|
|
|
// SSH dies mid-command when the host shuts down; ignore the error.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
_, _ = fix.ssh.Run(ctx, fix.env.WANHost, "sudo systemctl reboot")
|
|
|
|
// Wait for shutdown to begin so the pre-reboot SSH window isn't mistaken for "back up".
|
|
time.Sleep(5 * time.Second)
|
|
|
|
harness.Step(t, "polling TCP/22 (timeout %s)", fix.timeouts.rebootWait)
|
|
deadline := time.Now().Add(fix.timeouts.rebootWait)
|
|
var lastErr error
|
|
for {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("host SSH did not come back within %s (last err: %v)", fix.timeouts.rebootWait, lastErr)
|
|
}
|
|
conn, dialErr := net.DialTimeout("tcp", net.JoinHostPort(fix.env.WANHost, "22"), 5*time.Second)
|
|
if dialErr == nil {
|
|
_ = conn.Close()
|
|
pingCtx, pingCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
err := fix.ssh.Ping(pingCtx, fix.env.WANHost)
|
|
pingCancel()
|
|
if err == nil {
|
|
break
|
|
}
|
|
lastErr = err
|
|
} else {
|
|
lastErr = dialErr
|
|
}
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
|
|
fix.mu.Lock()
|
|
fix.rebootDone = true
|
|
elapsed := time.Since(fix.rebootStart).Round(time.Second)
|
|
fix.mu.Unlock()
|
|
harness.Detail(t, "ssh_back_after", elapsed)
|
|
}
|
|
|
|
func phase7Readiness(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
for _, svc := range serviceList {
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, "systemctl is-active "+svc)
|
|
state := strings.TrimSpace(string(out))
|
|
if err != nil && state == "" {
|
|
state = "n/a"
|
|
}
|
|
harness.Detail(t, svc, state)
|
|
}
|
|
|
|
harness.Step(t, "polling describe-instance-types (timeout %s)", fix.timeouts.daemonReady)
|
|
harness.EventuallyErr(t, func() error {
|
|
_, err := fix.aws.EC2.DescribeInstanceTypes(&ec2.DescribeInstanceTypesInput{})
|
|
return err
|
|
}, fix.timeouts.daemonReady, 5*time.Second)
|
|
harness.Detail(t, "gateway", "responding")
|
|
}
|
|
|
|
func phase8Asserts(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
|
|
// 8.1 instances back to running
|
|
harness.Step(t, "polling instance state (timeout %s)", fix.timeouts.instanceRunning)
|
|
harness.EventuallyErr(t, func() error {
|
|
for _, id := range fix.appInstanceIDs {
|
|
state := describeInstanceState(t, fix.aws, id)
|
|
if state != "running" {
|
|
return fmt.Errorf("%s state=%s", id, state)
|
|
}
|
|
}
|
|
return nil
|
|
}, fix.timeouts.instanceRunning, 5*time.Second)
|
|
harness.Detail(t, "instances", "all running")
|
|
|
|
// 8.2 private IPs preserved
|
|
for _, id := range fix.appInstanceIDs {
|
|
post := describePrivateIP(t, fix.aws, id)
|
|
assert.Equalf(t, fix.preIPs[id], post, "%s IP drift", id)
|
|
}
|
|
|
|
// 8.3 LaunchTime > REBOOT_START (relaunch signal)
|
|
for _, id := range fix.appInstanceIDs {
|
|
lt := describeLaunchTime(t, fix.aws, id)
|
|
require.Falsef(t, lt.IsZero(), "%s missing LaunchTime", id)
|
|
assert.Truef(t, !lt.Before(fix.rebootStart),
|
|
"%s LaunchTime=%s predates reboot=%s", id, lt, fix.rebootStart)
|
|
}
|
|
|
|
// 8.4 ALB active
|
|
harness.WaitForLBActive(t, fix.aws, fix.albArn, "ALB post-reboot", fix.timeouts.lbRecover)
|
|
|
|
// 8.5 targets healthy — capture diagnostics before failing so a timeout can be
|
|
// pinned to network-level drop vs. app/gateway stall.
|
|
if !pollTargetsHealthy(fix.aws, fix.tgArn, 2, fix.timeouts.lbRecover) {
|
|
harness.Step(t, "ALB post-reboot: targets NOT healthy — capturing diagnostics")
|
|
dctx, dcancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
dumpHeartbeatDiagnostics(dctx, t, fix)
|
|
dumpOVNDataplane(dctx, t, fix)
|
|
dumpOVNClaimState(dctx, t, fix)
|
|
dumpAppDiagnostics(t, fix)
|
|
dcancel()
|
|
t.Fatalf("ALB post-reboot: targets not healthy within %s (see diag-heartbeat.txt / diag-ovn-flows.txt / diag-ovn-claim.txt / diag-host-net.txt)", fix.timeouts.lbRecover)
|
|
}
|
|
harness.Step(t, "ALB post-reboot: 2 targets healthy")
|
|
|
|
// 8.6 Probe until both backends' instance_ids are seen before asserting round-robin:
|
|
// healthy-cache state may show stale "healthy" until both responders are actually up.
|
|
harness.Step(t, "waiting for ALB to serve HTTP from both backends")
|
|
probeCtx, probeCancel := context.WithTimeout(context.Background(), 180*time.Second)
|
|
defer probeCancel()
|
|
seen := map[string]bool{}
|
|
served := false
|
|
serveDeadline := time.Now().Add(180 * time.Second)
|
|
for time.Now().Before(serveDeadline) {
|
|
out, err := fix.ssh.Run(probeCtx, fix.env.WANHost,
|
|
fmt.Sprintf("curl -s --connect-timeout 2 --max-time 3 'http://%s:%d/' 2>/dev/null", fix.albPublicIP, httpPort))
|
|
if err == nil {
|
|
if id := instanceIDFromResponse(out); id != "" {
|
|
seen[id] = true
|
|
}
|
|
}
|
|
if len(seen) >= 2 {
|
|
served = true
|
|
break
|
|
}
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
// 8.5 reports targets healthy via the daemon's own view, which can be green
|
|
// while the north-south EIP datapath is dark. Capture dataplane diagnostics on
|
|
// a serve failure too — otherwise this path leaves no flow/ARP evidence.
|
|
if !served {
|
|
harness.Step(t, "ALB serve FAILED — capturing datapath diagnostics")
|
|
dctx, dcancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
dumpALBProbe(dctx, t, fix)
|
|
dumpOVNDataplane(dctx, t, fix)
|
|
dumpOVNClaimState(dctx, t, fix)
|
|
dumpHeartbeatDiagnostics(dctx, t, fix)
|
|
dumpAppDiagnostics(t, fix)
|
|
dcancel()
|
|
t.Fatalf("ALB did not serve from both backends within 180s post-reboot (saw=%v) "+
|
|
"— see diag-ovn-flows.txt / diag-alb-probe.txt / diag-heartbeat.txt", seen)
|
|
}
|
|
|
|
runHTTPBurst(t, fix, "ALB post-reboot")
|
|
|
|
// 8.6.5 Always capture in-guest responder state (pass or fail) to confirm genuine liveness.
|
|
dumpAppDiagnostics(t, fix)
|
|
|
|
// 8.7 ovn-nbctl drift check
|
|
dumpCtx, dumpCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer dumpCancel()
|
|
out, err := fix.ssh.Run(dumpCtx, fix.env.WANHost, "sudo ovn-nbctl show 2>/dev/null || true")
|
|
if err != nil {
|
|
t.Logf("ovn-nbctl post-snapshot: %v (skipping drift assert)", err)
|
|
return
|
|
}
|
|
harness.DumpFile(t, fix.artifacts, "ovn-post.txt", out)
|
|
postSwitches, postPorts := countOVN(string(out))
|
|
harness.Detail(t, "post_switches", postSwitches, "post_ports", postPorts)
|
|
assert.Equalf(t, fix.preSwitches, postSwitches,
|
|
"ovn switch count drift: pre=%d post=%d", fix.preSwitches, postSwitches)
|
|
assert.Equalf(t, fix.prePorts, postPorts,
|
|
"ovn port count drift: pre=%d post=%d", fix.prePorts, postPorts)
|
|
}
|
|
|
|
// --- HTTP burst ----------------------------------------------------------
|
|
|
|
func runHTTPBurst(t *testing.T, fix *fixture, label string) {
|
|
t.Helper()
|
|
harness.Step(t, "sending %d HTTP requests (%s)", probesPerRun, label)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
cmd := fmt.Sprintf(
|
|
"for i in $(seq 1 %d); do curl -s --max-time 5 'http://%s:%d/' 2>/dev/null; echo; done",
|
|
probesPerRun, fix.albPublicIP, httpPort,
|
|
)
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, cmd)
|
|
if err != nil {
|
|
t.Logf("%s: ssh burst error: %v", label, err)
|
|
}
|
|
|
|
counts := map[string]int{}
|
|
totalOK := 0
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var payload struct {
|
|
InstanceID string `json:"instance_id"`
|
|
}
|
|
if jsonErr := json.Unmarshal([]byte(line), &payload); jsonErr != nil || payload.InstanceID == "" {
|
|
continue
|
|
}
|
|
counts[payload.InstanceID]++
|
|
totalOK++
|
|
}
|
|
|
|
for id, n := range counts {
|
|
harness.Detail(t, id, fmt.Sprintf("%d responses", n))
|
|
}
|
|
assert.GreaterOrEqualf(t, len(counts), 2,
|
|
"%s round-robin: expected >=2 unique responders, got %d (counts=%v)", label, len(counts), counts)
|
|
assert.GreaterOrEqualf(t, totalOK, probesPerRun/2,
|
|
"%s success rate: only %d/%d", label, totalOK, probesPerRun)
|
|
}
|
|
|
|
// --- Discovery -----------------------------------------------------------
|
|
|
|
func discoverNanoInstanceType(t *testing.T, c *harness.AWSClient) string {
|
|
t.Helper()
|
|
out, err := c.EC2.DescribeInstanceTypes(&ec2.DescribeInstanceTypesInput{})
|
|
require.NoError(t, err, "describe-instance-types")
|
|
for _, it := range out.InstanceTypes {
|
|
name := aws.StringValue(it.InstanceType)
|
|
if strings.Contains(name, "nano") {
|
|
return name
|
|
}
|
|
}
|
|
t.Fatal("no nano instance type available")
|
|
return ""
|
|
}
|
|
|
|
// discoverAMI prefers ubuntu, then non-alpine, then anything — matches
|
|
// run-reboot-e2e.sh's three-stage AMI fallback chain.
|
|
func discoverAMI(t *testing.T, c *harness.AWSClient) string {
|
|
t.Helper()
|
|
out, err := c.EC2.DescribeImages(&ec2.DescribeImagesInput{})
|
|
require.NoError(t, err, "describe-images")
|
|
var ubuntu, nonAlpine, anyAMI string
|
|
for _, img := range out.Images {
|
|
id := aws.StringValue(img.ImageId)
|
|
name := aws.StringValue(img.Name)
|
|
if anyAMI == "" {
|
|
anyAMI = id
|
|
}
|
|
if !strings.Contains(strings.ToLower(name), "alpine") && nonAlpine == "" {
|
|
nonAlpine = id
|
|
}
|
|
if strings.HasPrefix(name, "ami-ubuntu") {
|
|
ubuntu = id
|
|
break
|
|
}
|
|
}
|
|
for _, candidate := range []string{ubuntu, nonAlpine, anyAMI} {
|
|
if candidate != "" {
|
|
return candidate
|
|
}
|
|
}
|
|
t.Fatal("no AMIs available")
|
|
return ""
|
|
}
|
|
|
|
func describePrivateIP(t *testing.T, c *harness.AWSClient, id string) string {
|
|
t.Helper()
|
|
out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
|
InstanceIds: []*string{aws.String(id)},
|
|
})
|
|
if err != nil {
|
|
t.Logf("describe %s: %v", id, err)
|
|
return ""
|
|
}
|
|
if len(out.Reservations) == 0 || len(out.Reservations[0].Instances) == 0 {
|
|
return ""
|
|
}
|
|
return aws.StringValue(out.Reservations[0].Instances[0].PrivateIpAddress)
|
|
}
|
|
|
|
func describePublicIP(t *testing.T, c *harness.AWSClient, id string) string {
|
|
t.Helper()
|
|
out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
|
InstanceIds: []*string{aws.String(id)},
|
|
})
|
|
if err != nil {
|
|
t.Logf("describe %s: %v", id, err)
|
|
return ""
|
|
}
|
|
if len(out.Reservations) == 0 || len(out.Reservations[0].Instances) == 0 {
|
|
return ""
|
|
}
|
|
return aws.StringValue(out.Reservations[0].Instances[0].PublicIpAddress)
|
|
}
|
|
|
|
func describeInstanceState(t *testing.T, c *harness.AWSClient, id string) string {
|
|
t.Helper()
|
|
out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
|
InstanceIds: []*string{aws.String(id)},
|
|
})
|
|
if err != nil {
|
|
return "missing"
|
|
}
|
|
if len(out.Reservations) == 0 || len(out.Reservations[0].Instances) == 0 {
|
|
return "missing"
|
|
}
|
|
return aws.StringValue(out.Reservations[0].Instances[0].State.Name)
|
|
}
|
|
|
|
func describeLaunchTime(t *testing.T, c *harness.AWSClient, id string) time.Time {
|
|
t.Helper()
|
|
out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
|
InstanceIds: []*string{aws.String(id)},
|
|
})
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
if len(out.Reservations) == 0 || len(out.Reservations[0].Instances) == 0 {
|
|
return time.Time{}
|
|
}
|
|
lt := out.Reservations[0].Instances[0].LaunchTime
|
|
if lt == nil {
|
|
return time.Time{}
|
|
}
|
|
return *lt
|
|
}
|
|
|
|
// --- OVN parsing ---------------------------------------------------------
|
|
|
|
// countOVN extracts logical-switch and port counts from `ovn-nbctl show`
|
|
// output by line-prefix matching (matches the bash `grep -c '^switch '` and
|
|
// `grep -c '^ port '` checks 1:1).
|
|
func countOVN(out string) (switches, ports int) {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
switch {
|
|
case strings.HasPrefix(line, "switch "):
|
|
switches++
|
|
case strings.HasPrefix(line, " port "):
|
|
ports++
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func instanceIDFromResponse(out []byte) string {
|
|
var payload struct {
|
|
InstanceID string `json:"instance_id"`
|
|
}
|
|
if err := json.Unmarshal(out, &payload); err != nil {
|
|
return ""
|
|
}
|
|
return payload.InstanceID
|
|
}
|
|
|
|
// --- Diagnostics ---------------------------------------------------------
|
|
|
|
var serviceList = []string{
|
|
"spinifex-nats", "spinifex-predastore", "spinifex-viperblock",
|
|
"spinifex-daemon", "spinifex-awsgw", "spinifex-vpcd",
|
|
"ovn-controller", "ovs-vswitchd", "ovn-northd",
|
|
}
|
|
|
|
func dumpDiagnostics(t *testing.T, fix *fixture, since time.Time) {
|
|
t.Helper()
|
|
var modeFlag string
|
|
if since.IsZero() {
|
|
modeFlag = "-n 200"
|
|
harness.Step(t, "diagnostics: last 200 lines per service")
|
|
} else {
|
|
modeFlag = fmt.Sprintf("--since=@%d", since.Unix())
|
|
harness.Step(t, "diagnostics: since reboot @%d", since.Unix())
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
|
|
if !fix.ssh.IsReachable(ctx, fix.env.WANHost) {
|
|
harness.Step(t, "diagnostics: node unreachable; skipping journal dump")
|
|
return
|
|
}
|
|
|
|
for _, svc := range serviceList {
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost,
|
|
fmt.Sprintf("sudo journalctl -u %s --no-pager %s 2>/dev/null || true", svc, modeFlag))
|
|
if err != nil {
|
|
harness.Detail(t, svc, fmt.Sprintf("journal err: %v", err))
|
|
continue
|
|
}
|
|
harness.DumpFile(t, fix.artifacts, fmt.Sprintf("journal-%s.log", svc), out)
|
|
}
|
|
}
|
|
|
|
// dumpAppDiagnostics probes each app instance's public IP from the host.
|
|
// App VMs have no QEMU hostfwd, so public-IP probes are the only available path.
|
|
func dumpAppDiagnostics(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
if len(fix.appInstanceIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
if !fix.ssh.IsReachable(ctx, fix.env.WANHost) {
|
|
harness.Step(t, "app diagnostics: host unreachable; skipping")
|
|
return
|
|
}
|
|
|
|
harness.Step(t, "app diagnostics: probing %d app instance(s)", len(fix.appInstanceIDs))
|
|
|
|
dumpHostNetState(ctx, t, fix)
|
|
dumpALBProbe(ctx, t, fix)
|
|
|
|
for _, id := range fix.appInstanceIDs {
|
|
diagnoseAppInstance(ctx, t, fix, id)
|
|
}
|
|
}
|
|
|
|
// dumpHostNetState captures OVN/OVS/route state on the host in a single round-trip.
|
|
func dumpHostNetState(ctx context.Context, t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
bundle := `set +e
|
|
echo "=== ip route show ==="
|
|
ip route show 2>&1
|
|
echo "=== ip route show 192.168.0.0/24 ==="
|
|
ip route show 192.168.0.0/24 2>&1
|
|
echo "=== ip neigh show dev br-wan ==="
|
|
ip neigh show dev br-wan 2>&1 || ip neigh show 2>&1
|
|
echo "=== ovn-nbctl list nat ==="
|
|
sudo ovn-nbctl list nat 2>&1
|
|
echo "=== ovn-nbctl show ==="
|
|
sudo ovn-nbctl show 2>&1
|
|
echo "=== ovs-vsctl show ==="
|
|
sudo ovs-vsctl show 2>&1
|
|
echo "=== ovs-vsctl list-ports br-int ==="
|
|
sudo ovs-vsctl list-ports br-int 2>&1
|
|
echo "=== ip -d link show veth-wan-ovs (admin/carrier state) ==="
|
|
ip -d link show veth-wan-ovs 2>&1
|
|
echo "=== ip -d link show veth-wan-br (admin/carrier state) ==="
|
|
ip -d link show veth-wan-br 2>&1
|
|
echo "=== ovs-ofctl show br-ext (ofport numbering) ==="
|
|
sudo ovs-ofctl show br-ext 2>&1
|
|
`
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, bundle)
|
|
harness.DumpFile(t, fix.artifacts, "diag-host-net.txt", out)
|
|
if err != nil {
|
|
harness.Detail(t, "host-net", fmt.Sprintf("dump err: %v", err))
|
|
}
|
|
}
|
|
|
|
// pollTargetsHealthy is the non-fatal twin of harness.WaitForTargetsHealthy:
|
|
// returns true on healthy, false on timeout, so the caller can capture diagnostics before aborting.
|
|
func pollTargetsHealthy(c *harness.AWSClient, tgArn string, expected int, timeout time.Duration) bool {
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
out, err := c.ELBv2.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
|
|
TargetGroupArn: aws.String(tgArn),
|
|
})
|
|
if err == nil {
|
|
healthy := 0
|
|
for _, th := range out.TargetHealthDescriptions {
|
|
if aws.StringValue(th.TargetHealth.State) == "healthy" {
|
|
healthy++
|
|
}
|
|
}
|
|
if healthy >= expected {
|
|
return true
|
|
}
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return false
|
|
}
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
}
|
|
|
|
// dumpHeartbeatDiagnostics captures whether the lb-agent's heartbeat to the gateway is
|
|
// failing at the network layer or the app/gateway layer. Tcpdumps both the gateway port
|
|
// (any iface) and the br-wan north-south path; also pulls lb-agent console and awsgw journal.
|
|
func dumpHeartbeatDiagnostics(ctx context.Context, t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
if fix.albPublicIP == "" {
|
|
harness.Detail(t, "heartbeat", "no ALB public IP captured")
|
|
return
|
|
}
|
|
bundle := fmt.Sprintf(`set +e
|
|
echo "=== tcpdump ANY iface, gateway port :%[2]d (real heartbeat path), 14s ==="
|
|
sudo timeout 14 tcpdump -ni any -c 80 "tcp port %[2]d" 2>&1
|
|
echo "=== tcpdump br-wan ALB floating IP %[1]s + ARP (north-south), 8s ==="
|
|
sudo timeout 8 tcpdump -ni br-wan -c 40 "host %[1]s or (arp and host %[1]s)" 2>&1
|
|
echo "=== neigh for ALB floating IP %[1]s ==="
|
|
ip neigh show %[1]s 2>&1 || ip neigh show 2>&1 | grep -F %[1]s
|
|
echo "=== conntrack for gateway port %[2]d ==="
|
|
sudo conntrack -L 2>/dev/null | grep -E "dport=%[2]d|sport=%[2]d" | head || echo "(no conntrack entries / tool absent)"
|
|
echo "=== lb-agent console: Heartbeat / Config / Agent lines ==="
|
|
sudo grep -aE "Heartbeat|Config (hash|applied)|Agent started" /run/spinifex/console-*.log 2>/dev/null | tail -25
|
|
echo "=== awsgw journal: did the gateway RECEIVE heartbeats? ==="
|
|
sudo journalctl -u spinifex-awsgw --no-pager --since "3 min ago" 2>/dev/null | grep -iE "heartbeat|lb-agent|/lb/|target.?health" | tail -20
|
|
`, fix.albPublicIP, fix.env.AWSGWPort)
|
|
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, bundle)
|
|
harness.DumpFile(t, fix.artifacts, "diag-heartbeat.txt", out)
|
|
if err != nil {
|
|
harness.Detail(t, "heartbeat", fmt.Sprintf("dump err: %v", err))
|
|
return
|
|
}
|
|
body := string(out)
|
|
// Detect any L4 packet on the gateway port.
|
|
sawGatewayL4 := strings.Contains(body, fmt.Sprintf(".%d:", fix.env.AWSGWPort)) ||
|
|
strings.Contains(body, fmt.Sprintf(".%d >", fix.env.AWSGWPort)) ||
|
|
strings.Contains(body, fmt.Sprintf("> %s.%d", fix.env.WANHost, fix.env.AWSGWPort))
|
|
switch {
|
|
case sawGatewayL4:
|
|
harness.Detail(t, "heartbeat", "gateway-port PACKETS FLOW — network path healthy; failure is app/gateway-layer (TLS/HTTP/gateway state)")
|
|
default:
|
|
harness.Detail(t, "heartbeat", "NO packets on gateway port — network-level drop; check diag-ovn-flows.txt for br-int forwarding")
|
|
}
|
|
}
|
|
|
|
// dumpOVNDataplane captures OVN/OVS dataplane state at failure time: topology, br-int flows,
|
|
// Port_Bindings, NAT rows, neigh table, and ALB floating-IP reachability from the host.
|
|
func dumpOVNDataplane(ctx context.Context, t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
bundle := fmt.Sprintf(`set +e
|
|
EIP=%[1]s
|
|
echo "=== ovs-vsctl show (bridges + ports) ==="
|
|
sudo ovs-vsctl show 2>&1 | head -120
|
|
echo "=== ovs-ofctl show br-int (port->ofport map) ==="
|
|
sudo ovs-ofctl show br-int 2>&1 | grep -aE '\(' | head -50
|
|
echo "=== br-int flows: DNAT/conntrack (ct_/nat/ct) ==="
|
|
sudo ovs-ofctl dump-flows br-int 2>&1 | grep -aE 'ct_|nat\(|ct\(' | head -120
|
|
echo "=== br-int flows: EIP + guest subnet 10.210 (forwarding) ==="
|
|
sudo ovs-ofctl dump-flows br-int 2>&1 | grep -aE "nw_dst=$EIP|10\.210\.1\.|192\.168\.0\.21" | head -150
|
|
echo "=== br-int flows: terminal output: actions ==="
|
|
sudo ovs-ofctl dump-flows br-int 2>&1 | grep -aE 'output:|resubmit' | grep -avE 'table=(0|8|9|10|11),' | head -60
|
|
echo "=== ovs-ofctl dump-flows br-ext ==="
|
|
sudo ovs-ofctl dump-flows br-ext 2>&1 | head -120
|
|
echo "=== ovs-appctl fdb/show br-ext (WAN-side MAC learning) ==="
|
|
sudo ovs-appctl fdb/show br-ext 2>&1 | head -40
|
|
echo "=== ovn-sbctl Port_Binding (chassis assignment) ==="
|
|
sudo ovn-sbctl --no-leader-only list Port_Binding 2>&1 | grep -E "logical_port|chassis|mac|type " | head -400
|
|
echo "=== DGP / chassisredirect claim state (untruncated) ==="
|
|
echo "--- every chassisredirect Port_Binding -> resident chassis ---"
|
|
for cr in $(sudo ovn-sbctl --no-leader-only --bare --columns=logical_port find Port_Binding type=chassisredirect 2>/dev/null); do
|
|
ch=$(sudo ovn-sbctl --no-leader-only --bare --columns=chassis find Port_Binding logical_port="$cr" 2>/dev/null)
|
|
hg=$(sudo ovn-sbctl --no-leader-only --bare --columns=ha_chassis_group find Port_Binding logical_port="$cr" 2>/dev/null)
|
|
echo " CR=[$cr] chassis=[$ch] ha_chassis_group=[$hg]"
|
|
done
|
|
echo "--- every gw-vpc LRP -> its referenced chassis-redirect-port (does the CR row exist?) ---"
|
|
for gp in $(sudo ovn-sbctl --no-leader-only --bare --columns=logical_port find Port_Binding 2>/dev/null | tr ' ' '\n' | grep -aE '^gw-vpc'); do
|
|
crp=$(sudo ovn-sbctl --no-leader-only --bare --columns=options find Port_Binding logical_port="$gp" 2>/dev/null | tr ',' '\n' | grep -a chassis-redirect-port | cut -d= -f2)
|
|
exists=$(sudo ovn-sbctl --no-leader-only --bare --columns=_uuid find Port_Binding logical_port="$crp" 2>/dev/null)
|
|
echo " GW-LRP=[$gp] redirect-port=[$crp] CR_row_exists=[$exists]"
|
|
done
|
|
echo "--- NB Gateway_Chassis bindings (DGP HA) ---"
|
|
sudo ovn-nbctl --no-leader-only list Gateway_Chassis 2>&1 | grep -aE 'name|chassis_name|priority' | head -60
|
|
echo "--- NB Logical_Router_Port (name + gateway_chassis + networks) ---"
|
|
sudo ovn-nbctl --no-leader-only --columns=name,gateway_chassis,networks list Logical_Router_Port 2>&1 | head -80
|
|
echo "=== ovn-nbctl dnat_and_snat NAT rows ==="
|
|
sudo ovn-nbctl --no-leader-only list NAT 2>&1 | grep -E "external_ip|logical_ip|external_mac|type " | head -60
|
|
echo "=== host neigh (all floating IPs) ==="
|
|
ip neigh show 2>&1 | grep -E "br-wan|br-int" | head -40
|
|
echo "=== REAL ARP to EIP $EIP (flush primed neigh first) ==="
|
|
sudo ip neigh flush $EIP 2>&1 || true
|
|
ping -c 2 -W 2 $EIP 2>&1
|
|
ip neigh show $EIP 2>&1
|
|
GWMAC=$(ip neigh show $EIP | awk '{for(i=1;i<=NF;i++) if($i=="lladdr") print $(i+1)}')
|
|
echo "discovered cr-gw MAC for EIP: [$GWMAC]"
|
|
echo "=== ofproto/trace north-south DNAT (real ingress per br-int patch port, dl_dst=cr-gw) ==="
|
|
if [ -n "$GWMAC" ]; then
|
|
for p in $(sudo ovs-vsctl list-ports br-int 2>/dev/null | grep -aE 'patch-br-int-to-ext'); do
|
|
echo "--- in_port=$p ---"
|
|
sudo ovs-appctl ofproto/trace br-int "in_port=$p,tcp,dl_src=02:00:00:00:00:01,dl_dst=$GWMAC,nw_src=192.168.0.11,nw_dst=$EIP,tp_src=40000,tp_dst=80" 2>&1 \
|
|
| grep -aE 'bridge|ct_|nat|dnat|load|output|resubmit|drop|Final flow|Megaflow|Datapath actions' | head -120
|
|
done
|
|
else
|
|
echo "(no cr-gw MAC resolved — ARP itself failed)"
|
|
fi
|
|
echo "=== south->north RETURN trace (guest reply -> un-DNAT -> WAN) ==="
|
|
PRIV=$(sudo ovn-nbctl --no-leader-only --bare --columns=logical_ip find NAT external_ip="\"$EIP\"" 2>/dev/null | head -1)
|
|
CLIENT=$({ sudo ovs-appctl dpctl/dump-conntrack 2>/dev/null || sudo conntrack -L 2>/dev/null; } | grep -aE "dst=$EIP" | grep -aoE 'src=[0-9.]+' | head -1 | cut -d= -f2)
|
|
[ -z "$CLIENT" ] && CLIENT=192.168.1.1
|
|
GLSP=""; GMAC=""
|
|
for lp in $(sudo ovn-sbctl --no-leader-only --bare --columns=logical_port find Port_Binding type='""' 2>/dev/null); do
|
|
m=$(sudo ovn-sbctl --no-leader-only --bare --columns=mac find Port_Binding logical_port="$lp" 2>/dev/null)
|
|
case "$m" in *"$PRIV"*) GLSP="$lp"; GMAC=$(echo "$m" | awk '{print $1}'); break;; esac
|
|
done
|
|
TAP=$(sudo ovs-vsctl --bare --columns=name find Interface external_ids:iface-id="$GLSP" 2>/dev/null | head -1)
|
|
SUBNET_GW=$(echo "$PRIV" | awk -F. '{print $1"."$2"."$3".1"}')
|
|
LRPMAC=$(sudo ovs-ofctl dump-flows br-int 2>/dev/null | grep -a "arp_tpa=$SUBNET_GW," | grep -aoE 'mod_dl_src:[0-9a-f:]+' | head -1 | cut -d: -f2-)
|
|
echo "PRIV=[$PRIV] CLIENT=[$CLIENT] GLSP=[$GLSP] GMAC=[$GMAC] TAP=[$TAP] LRPMAC=[$LRPMAC]"
|
|
if [ -n "$TAP" ] && [ -n "$GMAC" ] && [ -n "$LRPMAC" ]; then
|
|
sudo ovs-appctl ofproto/trace br-int "in_port=$TAP,tcp,dl_src=$GMAC,dl_dst=$LRPMAC,nw_src=$PRIV,nw_dst=$CLIENT,tp_src=80,tp_dst=40000,tcp_flags=ack" 2>&1 \
|
|
| grep -aE 'bridge|ct_|nat|dnat|snat|load|output|resubmit|drop|Final flow|Megaflow|Datapath actions' | head -150
|
|
else
|
|
echo "(return-trace discovery incomplete — skipping)"
|
|
fi
|
|
echo "=== conntrack DNAT entries (EIP/guest) ==="
|
|
{ sudo ovs-appctl dpctl/dump-conntrack 2>/dev/null || sudo conntrack -L 2>/dev/null; } | grep -aE "$EIP|10\.210\.1\." | head -30 || echo "(none)"
|
|
echo "=== tap capture: does the SYN reach the guest? (tcpdump -i any while curling EIP) ==="
|
|
sudo timeout 6 tcpdump -i any -nn -c 25 "host $EIP or net 10.210.0.0/16" 2>&1 &
|
|
TCPID=$!
|
|
sleep 1
|
|
curl -s --connect-timeout 2 --max-time 4 "http://$EIP/" >/dev/null 2>&1 &
|
|
wait $TCPID 2>/dev/null
|
|
echo "=== (end tap capture) ==="
|
|
echo "=== PERSISTED-STATE DUMP (survives ovn-controller restart) ==="
|
|
echo "--- external next-hop MACs (regenerated each boot) ---"
|
|
for IF in veth-wan-ovs veth-wan-br br-wan br-ext; do
|
|
M=$(ip link show "$IF" 2>/dev/null | grep -aoE 'link/ether [0-9a-f:]+' | awk '{print $2}')
|
|
echo " $IF mac=[$M]"
|
|
done
|
|
echo "--- SB MAC_Binding (logical_port ip mac) ---"
|
|
sudo ovn-sbctl --no-leader-only list MAC_Binding 2>&1 | grep -aE 'ip|mac|logical_port' | head -40 || echo "(none)"
|
|
echo "--- host neigh on external subnet ---"
|
|
ip neigh show 2>/dev/null | grep -aE '192\.168\.' | head -20 || echo "(none)"
|
|
echo "=== live failure marker (confirms drop is active during dump) ==="
|
|
curl -s -o /dev/null -w "eip-probe HTTP=%%{http_code} time=%%{time_total}\n" --connect-timeout 2 --max-time 5 "http://$EIP/" 2>&1
|
|
echo "=== NB-SOURCE OF THE RETURN-PATH DROP (names what installs table-79 drop) ==="
|
|
echo "--- SB lflow stages that DROP the guest reply (match nw_src=$PRIV) ---"
|
|
sudo ovn-sbctl --no-leader-only lflow-list 2>/dev/null | grep -aiE 'drop' | grep -aF "$PRIV" | head -20 || echo "(none — drop is not a northd logical flow; likely ovn-controller port-security)"
|
|
echo "--- SB lflow lines mentioning guest MAC $GMAC (stage + match context) ---"
|
|
sudo ovn-sbctl --no-leader-only lflow-list 2>/dev/null | grep -aiF "$GMAC" | head -20 || echo "(none)"
|
|
echo "--- NB NAT rows: type/external_mac/logical_port/options (centralised=[] vs distributed) ---"
|
|
sudo ovn-nbctl --no-leader-only --columns=external_ip,logical_ip,type,external_mac,logical_port,options list NAT 2>/dev/null | head -60 || echo "(none)"
|
|
echo "--- NB Logical_Router_Policy (drop-gate + per-instance reroute) ---"
|
|
sudo ovn-nbctl --no-leader-only --columns=priority,match,action,nexthop list Logical_Router_Policy 2>/dev/null | head -40 || echo "(none)"
|
|
echo "--- NB LSP addresses + port_security (source if drop is physical port-sec) ---"
|
|
sudo ovn-nbctl --no-leader-only --columns=name,addresses,port_security list Logical_Switch_Port 2>/dev/null | grep -aF -A0 "$PRIV" | head -20 || echo "(none)"
|
|
echo "=== (end NB-source dump) ==="
|
|
`, fix.albPublicIP)
|
|
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, bundle)
|
|
harness.DumpFile(t, fix.artifacts, "diag-ovn-flows.txt", out)
|
|
if err != nil {
|
|
harness.Detail(t, "ovn", fmt.Sprintf("dump err: %v", err))
|
|
return
|
|
}
|
|
body := string(out)
|
|
reachable := strings.Contains(body, " 0% packet loss")
|
|
hasFlows := strings.Contains(body, "cookie=") && strings.Contains(body, "table=")
|
|
switch {
|
|
case reachable:
|
|
harness.Detail(t, "ovn", "ALB floating IP reachable from host — forwarding OK, failure is elsewhere")
|
|
case hasFlows:
|
|
harness.Detail(t, "ovn", "br-int has flows but floating IP unreachable — flow/forwarding mismatch (inspect dump-flows + ofproto/trace)")
|
|
default:
|
|
harness.Detail(t, "ovn", "br-int flows MISSING and floating IP unreachable — dataplane flow-restore gap post-reboot")
|
|
}
|
|
}
|
|
|
|
// ovnChassisStateBundle captures OVN chassis identity + SB records for pre/post diff.
|
|
// _uuid churn across reboot orphans every Port_Binding claim.
|
|
const ovnChassisStateBundle = `set +e
|
|
echo "=== OVS system-id (chassis identity; must be stable across reboot) ==="
|
|
sudo cat /etc/openvswitch/system-id.conf 2>&1
|
|
sudo ovs-vsctl get Open_vSwitch . external_ids:system-id 2>&1
|
|
echo "=== ovn-sbctl list Chassis (name/_uuid/hostname/encaps) ==="
|
|
sudo ovn-sbctl --no-leader-only list Chassis 2>&1 | grep -E "_uuid|name|hostname|ip " | head -40
|
|
echo "=== ovn-sbctl list Chassis_Private (claimed-by + nb_cfg) ==="
|
|
sudo ovn-sbctl --no-leader-only list Chassis_Private 2>&1 | grep -E "_uuid|name|chassis|nb_cfg" | head -40
|
|
echo "=== ovn get-connection (did set-connection persist?) ==="
|
|
sudo ovn-nbctl get-connection 2>&1
|
|
sudo ovn-sbctl get-connection 2>&1
|
|
`
|
|
|
|
// dumpOVNClaimState captures evidence for why ovn-controller is not claiming Port_Bindings.
|
|
// Pulls /var/log/ovn directly (not journald), SB connection state, and boot ordering
|
|
// to distinguish chassis-identity churn from an SB/NB startup-ordering race.
|
|
func dumpOVNClaimState(ctx context.Context, t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
bundle := ovnChassisStateBundle + `echo "=== ovn-controller SB connection-status ==="
|
|
sudo env OVS_RUNDIR=/var/run/ovn ovs-appctl -t ovn-controller connection-status 2>&1
|
|
echo "=== ovn-controller debug/status ==="
|
|
sudo env OVS_RUNDIR=/var/run/ovn ovs-appctl -t ovn-controller debug/status 2>&1 | head -20
|
|
echo "=== gw/eni Port_Binding claim detail (chassis/up/requested_chassis) ==="
|
|
sudo ovn-sbctl --no-leader-only --columns=logical_port,type,chassis,up,requested_chassis find Port_Binding 2>&1 | grep -A4 -E "gw-vpc|port-eni" | head -60
|
|
echo "=== ovn-central vs ovn-controller boot ordering ==="
|
|
systemctl show ovn-central ovn-controller openvswitch-switch -p Id -p ActiveEnterTimestamp -p ExecMainStartTimestamp 2>&1
|
|
echo "=== tail /var/log/ovn/ovn-controller.log ==="
|
|
sudo tail -n 60 /var/log/ovn/ovn-controller.log 2>&1
|
|
echo "=== tail /var/log/ovn/ovn-northd.log ==="
|
|
sudo tail -n 40 /var/log/ovn/ovn-northd.log 2>&1
|
|
`
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, bundle)
|
|
harness.DumpFile(t, fix.artifacts, "diag-ovn-claim.txt", out)
|
|
if err != nil {
|
|
harness.Detail(t, "ovn-claim", fmt.Sprintf("dump err: %v", err))
|
|
return
|
|
}
|
|
body := string(out)
|
|
switch {
|
|
case strings.Contains(body, "not connected"):
|
|
harness.Detail(t, "ovn-claim", "ovn-controller NOT connected to SB — claim failure is an SB-connection/ordering issue")
|
|
case strings.Contains(body, "chassis : []"), strings.Contains(body, "chassis : []"):
|
|
harness.Detail(t, "ovn-claim", "gw/eni Port_Binding unclaimed (chassis empty) — diff ovn-pre-chassis.txt vs this for identity churn")
|
|
default:
|
|
harness.Detail(t, "ovn-claim", "see diag-ovn-claim.txt — compare Chassis _uuid against ovn-pre-chassis.txt")
|
|
}
|
|
}
|
|
|
|
// dumpALBProbe probes the ALB public IP from host — pre-reboot this path
|
|
// worked (the burst hit it). If post-reboot ALB is also unreachable, the
|
|
// gateway/ext-bridge is broken; if ALB reaches but apps don't, only the
|
|
// app-VM side is broken. Complements per-app probes.
|
|
func dumpALBProbe(ctx context.Context, t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
if fix.albPublicIP == "" {
|
|
harness.Detail(t, "alb", "no public IP captured")
|
|
return
|
|
}
|
|
probe := fmt.Sprintf(`set +e
|
|
echo "=== ping ALB %[1]s ==="
|
|
ping -c 2 -W 2 %[1]s 2>&1
|
|
echo "=== tcp-connect ALB %[1]s:80 ==="
|
|
timeout 5 bash -c 'exec 3<>/dev/tcp/%[1]s/80 && echo "tcp OPEN" || echo "tcp CLOSED/UNREACH"'
|
|
echo "=== curl ALB http://%[1]s/ ==="
|
|
curl -sS -m 5 -w 'HTTP=%%{http_code} time=%%{time_total}s\n' http://%[1]s/ 2>&1
|
|
`, fix.albPublicIP)
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, probe)
|
|
harness.DumpFile(t, fix.artifacts, "diag-alb-probe.txt", out)
|
|
if err != nil {
|
|
harness.Detail(t, "alb", fmt.Sprintf("probe err: %v", err))
|
|
return
|
|
}
|
|
body := string(out)
|
|
switch {
|
|
case strings.Contains(body, `"instance_id"`):
|
|
harness.Detail(t, "alb", "ALB serving (responder reachable through HAProxy)")
|
|
case strings.Contains(body, "tcp OPEN"):
|
|
harness.Detail(t, "alb", "ALB tcp open, no body (HAProxy up but backends silent)")
|
|
case strings.Contains(body, "tcp CLOSED"):
|
|
harness.Detail(t, "alb", "ALB tcp closed/unreach (gateway or sys.micro broken)")
|
|
default:
|
|
harness.Detail(t, "alb", "probe inconclusive — see artifact")
|
|
}
|
|
}
|
|
|
|
// diagnoseAppInstance probes one instance from the host VM: qemu running,
|
|
// ping, TCP-connect on :80, HTTP GET. Single shell session per instance.
|
|
func diagnoseAppInstance(ctx context.Context, t *testing.T, fix *fixture, id string) {
|
|
t.Helper()
|
|
|
|
psOut, err := fix.ssh.Run(ctx, fix.env.WANHost,
|
|
fmt.Sprintf("ps -eo args= | grep -F %q | grep -v grep || true", id))
|
|
harness.DumpFile(t, fix.artifacts, fmt.Sprintf("diag-%s-qemu-ps.txt", id), psOut)
|
|
switch {
|
|
case err != nil:
|
|
harness.Detail(t, id, fmt.Sprintf("qemu ps err: %v", err))
|
|
case len(strings.TrimSpace(string(psOut))) == 0:
|
|
harness.Detail(t, id, "qemu NOT running on host")
|
|
default:
|
|
harness.Detail(t, id, "qemu running")
|
|
}
|
|
|
|
// Serial console (ttyS0) — cloud-init, kernel oops, systemd boot events.
|
|
consoleOut, _ := fix.ssh.Run(ctx, fix.env.WANHost,
|
|
fmt.Sprintf("sudo tail -c 262144 /run/spinifex/console-%s.log 2>/dev/null || true", id))
|
|
harness.DumpFile(t, fix.artifacts, fmt.Sprintf("diag-%s-console.log", id), consoleOut)
|
|
|
|
priv := fix.preIPs[id]
|
|
pub := describePublicIP(t, fix.aws, id)
|
|
harness.Detail(t, id, fmt.Sprintf("priv=%s pub=%s", priv, pub))
|
|
if pub == "" {
|
|
harness.Detail(t, id, "no public IP — cannot probe from host")
|
|
return
|
|
}
|
|
|
|
probe := fmt.Sprintf(`set +e
|
|
echo "=== ping %[1]s ==="
|
|
ping -c 2 -W 2 %[1]s 2>&1
|
|
echo "=== tcp-connect %[1]s:80 ==="
|
|
timeout 5 bash -c 'exec 3<>/dev/tcp/%[1]s/80 && echo "tcp OPEN" || echo "tcp CLOSED/UNREACH"'
|
|
echo "=== curl http://%[1]s/ ==="
|
|
curl -sS -m 5 -o /tmp/diag-body-%[2]s -w 'HTTP=%%{http_code} time=%%{time_total}s size=%%{size_download}\n' http://%[1]s/
|
|
echo "=== body ==="
|
|
cat /tmp/diag-body-%[2]s 2>/dev/null; echo
|
|
rm -f /tmp/diag-body-%[2]s
|
|
`, pub, id)
|
|
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, probe)
|
|
harness.DumpFile(t, fix.artifacts, fmt.Sprintf("diag-%s-net-probe.txt", id), out)
|
|
if err != nil {
|
|
harness.Detail(t, id, fmt.Sprintf("probe err: %v", err))
|
|
return
|
|
}
|
|
|
|
body := string(out)
|
|
switch {
|
|
case strings.Contains(body, `"instance_id"`):
|
|
harness.Detail(t, id, "responder OK from host")
|
|
case strings.Contains(body, "tcp OPEN"):
|
|
harness.Detail(t, id, "tcp open but responder silent")
|
|
case strings.Contains(body, "tcp CLOSED"):
|
|
harness.Detail(t, id, "tcp closed/unreach (responder down OR OVN path broken)")
|
|
default:
|
|
harness.Detail(t, id, "probe inconclusive — see artifact")
|
|
}
|
|
|
|
// SSH-in via IGW; dumps responder unit + cloud-init state inside the guest.
|
|
sshIntoApp(ctx, t, fix, id, pub)
|
|
}
|
|
|
|
// sshIntoApp uploads the key to the host then SSHes into the app guest to dump
|
|
// responder, cloud-init, and network state. Best-effort: no-ops if key unavailable or SSH fails.
|
|
func sshIntoApp(ctx context.Context, t *testing.T, fix *fixture, id, pub string) {
|
|
t.Helper()
|
|
if fix.privateKeyPath == "" {
|
|
return
|
|
}
|
|
keyData, err := os.ReadFile(fix.privateKeyPath)
|
|
if err != nil {
|
|
harness.Detail(t, id, fmt.Sprintf("ssh-in: read key err: %v", err))
|
|
return
|
|
}
|
|
enc := base64.StdEncoding.EncodeToString(keyData)
|
|
remoteKey := fmt.Sprintf("/tmp/reboot-e2e-%s.pem", id)
|
|
|
|
diag := `set +e
|
|
echo "=== uname / uptime ==="
|
|
uname -a; uptime
|
|
echo "=== systemctl is-active reboot-e2e-httpd.service ==="
|
|
systemctl is-active reboot-e2e-httpd.service
|
|
systemctl is-enabled reboot-e2e-httpd.service
|
|
echo "=== systemctl status (head 40) ==="
|
|
systemctl status --no-pager reboot-e2e-httpd.service 2>&1 | head -40
|
|
echo "=== unit file ==="
|
|
ls -l /etc/systemd/system/reboot-e2e-httpd.service 2>&1
|
|
echo "=== unit file content ==="
|
|
sudo cat /etc/systemd/system/reboot-e2e-httpd.service 2>&1
|
|
echo "=== enable symlink ==="
|
|
ls -l /etc/systemd/system/multi-user.target.wants/reboot-e2e-httpd.service 2>&1
|
|
echo "=== network-online.target ==="
|
|
systemctl is-active network-online.target
|
|
systemctl status --no-pager network-online.target 2>&1 | head -20
|
|
echo "=== networkctl ==="
|
|
networkctl status 2>&1 | head -40
|
|
echo "=== ip addr ==="
|
|
ip -br addr
|
|
echo "=== ip route ==="
|
|
ip route
|
|
echo "=== port :80 listeners ==="
|
|
sudo ss -tlnp 'sport = :80' 2>/dev/null
|
|
echo "=== curl localhost ==="
|
|
curl -s -m 3 -w '\nHTTP=%{http_code}\n' http://127.0.0.1/ || echo "curl localhost FAILED"
|
|
echo "=== /var/lib/httpd ==="
|
|
ls -l /var/lib/httpd 2>&1
|
|
cat /var/lib/httpd/index.html 2>&1
|
|
echo "=== responder journal ==="
|
|
sudo journalctl -u reboot-e2e-httpd.service --no-pager -n 60 2>&1
|
|
echo "=== boot list ==="
|
|
sudo journalctl --list-boots 2>&1 | tail -5
|
|
echo "=== last-boot journal grep responder ==="
|
|
sudo journalctl -b -1 --no-pager 2>&1 | grep -i "reboot-e2e\|network-online\|cloud-init" | tail -40
|
|
echo "=== this-boot journal grep responder ==="
|
|
sudo journalctl -b --no-pager 2>&1 | grep -i "reboot-e2e\|network-online\|cloud-init" | tail -40
|
|
echo "=== cloud-init result.json ==="
|
|
cat /run/cloud-init/result.json 2>&1
|
|
echo "=== cloud-init.log tail ==="
|
|
sudo tail -80 /var/log/cloud-init.log 2>&1
|
|
echo "=== cloud-init-output.log tail ==="
|
|
sudo tail -40 /var/log/cloud-init-output.log 2>&1
|
|
echo "=== instance-id sem ==="
|
|
ls -l /var/lib/cloud/instance 2>&1
|
|
ls -l /var/lib/cloud/instances/ 2>&1
|
|
echo "=== /var/lib/cloud/instances/*/sem ==="
|
|
ls -l /var/lib/cloud/instances/*/sem/ 2>&1
|
|
`
|
|
// Single outer SSH: write the key then hop into the guest. One round trip.
|
|
outerCmd := fmt.Sprintf(`set +e
|
|
umask 077 && printf '%%s' '%s' | base64 -d > %s && chmod 600 %s
|
|
ssh -i %s -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
-o ConnectTimeout=8 -o BatchMode=yes -o ServerAliveInterval=5 \
|
|
ec2-user@%s bash -s <<'EOFDIAG'
|
|
%s
|
|
EOFDIAG
|
|
rm -f %s
|
|
`, enc, remoteKey, remoteKey, remoteKey, pub, diag, remoteKey)
|
|
|
|
out, err := fix.ssh.Run(ctx, fix.env.WANHost, outerCmd)
|
|
harness.DumpFile(t, fix.artifacts, fmt.Sprintf("diag-%s-guest-ssh.txt", id), out)
|
|
if err != nil {
|
|
harness.Detail(t, id, fmt.Sprintf("ssh-in err: %v", err))
|
|
return
|
|
}
|
|
harness.Detail(t, id, "ssh-in OK (see diag-*-guest-ssh.txt)")
|
|
}
|
|
|
|
// --- Cleanup -------------------------------------------------------------
|
|
|
|
// cleanup tears down all test resources; SG is deleted last as it can't be removed
|
|
// while instance or ALB ENIs still reference it.
|
|
func cleanup(t *testing.T, fix *fixture) {
|
|
t.Helper()
|
|
harness.Phase(t, "Cleanup")
|
|
|
|
if !fix.ssh.IsReachable(context.Background(), fix.env.WANHost) {
|
|
harness.Step(t, "cleanup skipped: node unreachable")
|
|
return
|
|
}
|
|
|
|
if fix.listenerArn != "" {
|
|
_, _ = fix.aws.ELBv2.DeleteListener(&elbv2.DeleteListenerInput{ListenerArn: aws.String(fix.listenerArn)})
|
|
}
|
|
if fix.albArn != "" {
|
|
_, _ = fix.aws.ELBv2.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: aws.String(fix.albArn)})
|
|
}
|
|
if fix.tgArn != "" {
|
|
_, _ = fix.aws.ELBv2.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{TargetGroupArn: aws.String(fix.tgArn)})
|
|
}
|
|
|
|
if len(fix.appInstanceIDs) > 0 {
|
|
ids := make([]*string, len(fix.appInstanceIDs))
|
|
for i, id := range fix.appInstanceIDs {
|
|
ids[i] = aws.String(id)
|
|
}
|
|
_, _ = fix.aws.EC2.TerminateInstances(&ec2.TerminateInstancesInput{InstanceIds: ids})
|
|
harness.WaitForInstanceTerminated(t, fix.aws, fix.appInstanceIDs, 60*time.Second)
|
|
}
|
|
|
|
_, _ = fix.aws.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(keyName)})
|
|
|
|
// SG must come after instances + ALB ENIs are gone.
|
|
if fix.sgID != "" {
|
|
if _, err := fix.aws.EC2.DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{
|
|
GroupId: aws.String(fix.sgID),
|
|
}); err != nil {
|
|
var aerr awserr.Error
|
|
if !errors.As(err, &aerr) || aerr.Code() != "InvalidGroup.NotFound" {
|
|
t.Logf("delete SG %s: %v", fix.sgID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if fix.igwID != "" && fix.vpcID != "" {
|
|
_, _ = fix.aws.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(fix.igwID),
|
|
VpcId: aws.String(fix.vpcID),
|
|
})
|
|
_, _ = fix.aws.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{
|
|
InternetGatewayId: aws.String(fix.igwID),
|
|
})
|
|
}
|
|
if fix.subnetID != "" {
|
|
_, _ = fix.aws.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(fix.subnetID)})
|
|
}
|
|
if fix.vpcID != "" {
|
|
_, _ = fix.aws.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(fix.vpcID)})
|
|
}
|
|
}
|
|
|
|
// --- Misc ----------------------------------------------------------------
|
|
|
|
func durationEnv(key string, def time.Duration) time.Duration {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return def
|
|
}
|
|
// Accept a bare integer (treated as seconds) or a time.ParseDuration string.
|
|
if d, err := time.ParseDuration(v); err == nil {
|
|
return d
|
|
}
|
|
if d, err := time.ParseDuration(v + "s"); err == nil {
|
|
return d
|
|
}
|
|
return def
|
|
}
|