mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 19:01:42 +00:00
48749576c3
* test: add in-process integration test tier Roughly half of the behavioural tests under tests/e2e/ assert control-plane API shapes, authz and error codes that need no live environment, yet they can only run against a tofu-provisioned environment on a scarce self-hosted runner. This adds the tier they can move down to. The real gateway router runs in-process: SetupRoutes() is a pure function of GatewayConfig, so it serves from an httptest server over embedded NATS JetStream with real IAM/STS services and genuine SigV4 verification. Only the daemon side of the wire is faked -- the ec2.*/ebs.* NATS subjects a live node would answer are stubbed per test. Nothing is provisioned and no fixed ports are bound, so the tier runs concurrently on a shared build host and is safe as a CI front gate ahead of the live suites. It is build-tagged and kept out of preflight, which stays unit-only. * test(integration): add IAM/STS client accessors and port the zero-stub authz tests Adds IAMClient/STSClient plus ClientsWithCreds/ClientsWithSessionCreds so a test can act as a principal other than the seeded root user, and stubs the node-discovery subject in StartGateway so call paths that trigger discovery resolve immediately instead of riding out the fan-out timeout. Ports the four enforcement tests that guard on ec2:DescribeRegions, a gateway-local action, so they need no daemon-side stub at all. * test(integration): port remaining IAM/STS authz tests from e2e Ports TestIAMUserCRUD, TestIAMAccessKeyLifecycle, TestIAMUserAuthentication, TestIAMPolicyCRUD, TestIAMPolicyAttachmentEnforcement, TestIAMPolicyLifecycle, TestIAMGroupsLifecycle, TestIAMRolesAndProfiles and TestSTSAssumeRoleAndGetCallerIdentity from tests/e2e/iam/ into the in-process integration tier. Each test now self-bootstraps its own users/policies/roles instead of relying on shared suite-level state, since every integration test gets its own gateway and embedded NATS. TestIAMRolesAndProfiles stubs ec2.IamProfileAssociation.describe because DeleteInstanceProfile always counts live associations over NATS regardless of which guard actually fires. TestIAMCleanup is not ported: it only swept a shared long-lived E2E environment, and every ordering/dependency assertion it made is already covered by TestIAMPolicyLifecycle, TestIAMGroupsLifecycle and TestIAMRolesAndProfiles's own delete-conflict guards. * test(e2e): remove IAM/STS authz tests migrated to the integration tier All 13 signed-request authz tests plus TestIAMCleanup now have faithful, mutation-proven equivalents in tests/integration/ — the gateway dispatches IAM/STS actions directly with no daemon hop, so the in-process tier is a faithful and far cheaper substitute. The iam/ e2e suite now runs only TestIAMInstanceProfileAssociation and TestIMDS. Drop the now fully-dead iam_helpers_test.go alongside the six deleted test files, and remove harness.IAMGroupARN/IAMDeleteGroupBestEffort, which had no remaining caller anywhere in the repo once iam_groups_test.go was gone. * feat(integration): daemon-lite harness for key/tags/routetable/vpc Adds DaemonLite, an in-process stand-in for a live spinifex daemon that subscribes the real key, tags, route table, and VPC/subnet/SG/IGW service implementations directly to their NATS subjects, backed by memory and embedded-JetStream stores. Ports TestKeyPairs, TestTagManagement, TestRouteTableValidation, and TestReplaceRouteConvergence from tests/e2e/single so the integration tier drives genuine daemon-side business logic instead of StubSubject canned replies. vpcd (OVN) is out of scope, so its "vpc.create-sg"/"vpc.delete-sg" acknowledgements are stubbed with a fixed success reply — the SG record itself is still written by the real service impl before that event fires, so the stub only unblocks the synchronous round-trip rather than replacing tested behavior. * ci(e2e): gate live E2E jobs behind the in-process integration tier Runs the in-process integration suite first, on the ci-single runner host rather than inside a provisioned VM, so a broken control plane is caught before either live job pays the Spinifex install. Both e2e_single and e2e_multi now require it, and e2e_status treats a skipped integration job as a failure so the gating status cannot go green on a gate that never ran. Drops the shadow-mode suite selector: targeted per-diff suite selection is not the direction being pursued, and leaving a non-functional shadow job in the gating workflow only invited it to be mistaken for a live gate. * test(e2e): remove single/ tests superseded by daemon-lite ports TestKeyPairs, TestTagManagement, TestRouteTableValidation, and TestReplaceRouteConvergence now have proven-equivalent ports in tests/integration/ (mutation testing confirmed parity). Delete the e2e originals and their runX helpers, along with keypair_test.go's and tags_test.go's file-local helpers left with no caller. generateImportPubKey is shared by other single/ tests so it moves to helpers_test.go instead of being deleted. Slims the manifest-lint baseline for the removed create-call entries. * ci: run the lb e2e suite on multinode only Both e2e jobs filter SUITES by exclusion, and neither excluded lb, so the suite ran twice per e2e run: 354s on single and 654s on multi. Balancing across instances on one node is not a deployment mode we support, and ELBv2 proper requires two AZs, so the single-node run was redundant. Drops ~354s from the single-node job, which is the gate critical path. * test(integration): port 3 of 7 ECR tests to the in-process tier Wires the gateway's ECR control plane (repository/policy/lifecycle metadata over an in-process daemon-lite NATS subscriber, blob and manifest bytes via a memory object store, JWT issuer/verifier keyed off the existing IAM master key) into the integration harness, and ports TestECRControlPlane, TestECRGetLoginPassword, and TestECRIAMAuthorization_AssumedRolePullOnly from tests/e2e/ecr — each proven equivalent by mutation (evaluatePrincipalPolicy forced to allow, and the repo-describe path forced to not-found) before its E2E original was removed. The other 4 authorization tests (PullOnly, ReadOnly, DetachPolicyDeniesImmediately, DeactivatedKeyReturns401) stay on the E2E tier: AttachUserPolicy lacks the AWS-managed-ARN existence-check bypass that AttachRolePolicy/AttachGroupPolicy already have, so attaching AmazonEC2ContainerRegistryPullOnly/ReadOnly straight to a user fails NoSuchEntity in both tiers alike. Left untouched pending a fix to that shared IAM code. * fix(iam): accept AWS-managed policy ARNs on AttachUserPolicy AttachUserPolicy called getPolicyByARN unconditionally, so attaching an AWS-managed ARN to a user returned NoSuchEntity. AttachRolePolicy and AttachGroupPolicy already skip that lookup for AWS-managed ARNs and store them opaquely; the user path was missed when that guard was introduced. ListAttachedUserPolicies had the mirror of the same gap: it resolved every ARN through getPolicyByARN and silently dropped the ones it could not find, so a managed ARN would disappear from the listing rather than round-trip. Evaluation already handled these correctly via resolveAttachedPolicy, so only the write and list paths needed the guard. * test(integration): port remaining 4 ECR authorization tests Ports TestECRIAMAuthorization_PullOnly, _ReadOnly, _DetachPolicyDeniesImmediately, and _DeactivatedKeyReturns401 from tests/e2e/ecr now that AttachUserPolicy accepts AWS-managed policy ARNs. Each was proven equivalent by mutation before its E2E original was removed: PullOnly/ReadOnly/DetachPolicyDeniesImmediately fail when evaluatePrincipalPolicy is forced to always allow, and DeactivatedKeyReturns401 fails when the access-key-active check in resolveECRLongLivedPrincipal is bypassed. All mutants were reverted and a clean run confirmed before committing. tests/e2e/ecr/iam_authorization_test.go is now fully removed — every test it held has an in-process equivalent, and its helpers had no other callers in the package. * ci: select e2e suites by allow-list instead of deny-list Both jobs filtered SUITES by exclusion, so any suite named in neither deny-list ran in both jobs. That is how lb came to run twice per e2e run, and it makes adding a suite silently fan it out everywhere. Each job now names the suites it is eligible to run. Selection is still driven by SUITES, so this is behaviour-preserving for the current default: single/iam/cert on the single-node job, multinode/lb/cert on the multi-node job, exactly as before. eks, ecs and storagegrowth are listed as eligible on the single-node job but are not yet in the default SUITES. * test(e2e): delete seven tests with no coverage TestFinalClusterStats and TestInstanceLaunch assert nothing beyond what other callers of their shared helpers already imply. The multinode baseline, volume, guest-SSH, and preflight tests duplicate single-node coverage or exercise per-guest behaviour that isn't cluster-specific. TestMultinodeInstanceDistribution can't fail: three independent RunInstances calls with no placement group make a strict spread assertion flaky and a loose one meaningless. Preflight checks (/dev/kvm writability, peer SSH reachability) move into requireMultiNodeFixture so the package fixture singleton still fails fast with a clear message instead of disappearing along with the dedicated test. The remaining callers of needInstanceTrio (CrossNodeOps, CrossNodeGateway, NodeFailure, VolumeDurability) still trigger the shared trio launch on their own, so removing the distribution and volume-lifecycle tests doesn't leave it unbuilt. Also slims the manifest-lint baseline entry for the deleted multinode volume test. * fix(e2e): require full 3-node spread in TestMultinodeSpread SpreadPlacement passed at unique>=3, warned via t.Logf at unique==2, and only failed below that. A 33% spread failure (two instances colocated) passed with a log line. The 243.6s NAT-egress scenario that follows only proves what it claims to if the private trio actually landed on three distinct chassis, so that premise needs to be a hard assertion, not a warning. * refactor(e2e): drop the unused spread-assertion harness helpers AssertSpreadAcrossNodes and CountInstancesPerNode lost their only caller when the instance-distribution test was removed. Beyond being dead, the assertion helper documented minNodes=1 as an acceptable logging-only mode inherited from the bash driver, which is the pattern that let a placement test pass while every instance landed on one node. InstanceHostingNode and its ps helpers stay; five multinode tests use them. * fix(ec2): stop leaked goroutines on prepareEFIVolume error paths newViperblock starts the chunk uploader and WAL syncer unconditionally, but prepareEFIVolume only released them on the happy path via Close(). Six error returns between acquisition and Close left both goroutines running. Add a defer immediately after acquisition, matching the sibling prepareRootVolume; Close() remains the durability boundary. * test(predastore): add real predastore fixture for integration tests Extract predastore_integration_test.go's inline daemon startup into a reusable testutil/predastore package so tests/integration can point DaemonLite-style wiring at a real S3-compatible backend instead of an unreachable host. Kept as its own leaf package rather than folding into the shared testutil package: it transitively imports predastore's quicclient, whose package-level connection pool starts a permanent goroutine at init that would trip goleak checks in any of the ~80 other packages that import testutil for unrelated NATS helpers. * test(integration): migrate CreateVolume to assert real predastore persistence Add StartVolumeDaemonLite, an opt-in DaemonLite variant wired to the real predastore fixture, and migrate TestCreateVolume_PersistsToRealPredastore off memory-backed DaemonLite so CreateVolume/DescribeVolumes exercise viperblock's real S3 backend end to end instead of stopping at input validation. * test(integration): add real storage-state oracle for CreateVolume DescribeVolumes only proves the handler's own read path agrees with its own write path, both via the same deserialization; it cannot catch a bug in a field it never re-derives (e.g. a unit-conversion error in the bytes handed to viperblock, since VolumeMetadata.SizeGiB is copied from the request directly). vbscan reads a volume's persisted config.json and chunk objects straight over S3, independent of any spinifex handler code, so TestCreateVolume_PersistsToRealPredastore can assert on what viperblock actually wrote rather than only on what the handler claims. vbscan is a local reimplementation of scripts/vbrefscan's read-only scan rather than an import: spinifex cannot depend on mulga, the module that contains it. It only reimplements the config/chunk-listing half of vbrefscan — the checkpoint-based reference-integrity half needs viperblock.ParseBlockCheckpointBytes, which does not exist in the viperblock version spinifex currently depends on (v1.12.1) and is also moot for this volume: CreateVolume never calls SaveLiveCheckpoint, so a freshly created volume has no checkpoint to scan regardless of version. * refactor(test): move the predastore daemon fixture out of the coverage scope test-cover runs go test ./spinifex/..., so anything under spinifex/ is compiled and measured. The daemon fixture cannot be exercised by unit tests, so it reported a coverage gap no unit test could close. The e2e harness already avoids this by living under tests/; the fixture now does the same. vbscan stays put, being ordinary library code with its own unit tests. * test(integration): migrate RunInstances multi-count expansion off live guests RunInstances with MinCount/MaxCount=2 expands into a single per-node targeted launch (distributeInstances in gateway/ec2/instance/placement.go) before the daemon-facing NATS hop, so the real gateway router plus a stubbed daemon response is sufficient to prove it — no throwaway VM is needed to exercise the expansion logic itself. The live single-node E2E test's WaitForInstanceState/TerminateInstances sequence was teardown hygiene for the real guests it booted, not an assertion of the expansion behavior, so it is dropped rather than emulated. The migrated test additionally asserts the per-node launch request itself carries MinCount=MaxCount=2, proving the count actually propagates rather than the launch succeeding by coincidence or via the zero-daemon degenerate path in placement.go. * docs: drop a doc reference to the deleted live test * test(integration): migrate launch-template RunInstances expansion off live guests RunInstances --launch-template resolves ImageId/InstanceType from the stored template entirely gateway-side (expandLaunchTemplate, before validation/placement/the daemon-facing NATS hop), so proving it forwards the resolved fields never needed a real guest boot. Ports the assertion to tests/integration via a new opt-in StartLaunchTemplateDaemonLite (real LaunchTemplateServiceImpl over embedded JetStream) plus a direct subscription on the per-node launch subject to capture what the gateway actually dispatched, rather than only the client-visible response. The live test keeps its CRUD/versioning lifecycle (create/modify/delete version, tag-filtered describe) — none of it touches a guest — and drops only the RunInstances-from-template + wait-for-running + terminate tail, whose only job was waiting out a real boot to read back fields already proven resolved. * test(integration): migrate KV-only account-scoping checks off live tenants Ports the KeyPair/VPC-Subnet/IGW-EIGW/AccountSettings cross-account isolation assertions from tests/e2e/single/account_test.go to the in-process integration tier. Each of these resources resolves ownership from a plain account-scoped KV key (utils.AccountKey(accountID, id), or an account-keyed settings record) with no vm.Manager or real guest involved, so DaemonLite now also wires the real EgressOnlyIGWServiceImpl and AccountSettingsServiceImpl alongside the existing VPC/IGW/Key services. Two independently minted tenant accounts (via IAMService.CreateAccount/CreateUser/CreateAccessKey/CreatePolicy/ AttachUserPolicy, in-process — the same calls runAccountCreate makes without the CLI/stdout round-trip) replace the live tier's carousel so isolation is checked between real, separately owned accounts. Left live: instance scoping (Step2) and volume scoping (Step3) — checkInstanceOwnership, the actual cross-account gate for stop/start/ reboot/terminate/attach-volume, runs daemon-side only after a real vm.Manager lookup, so it cannot be exercised without a live guest. Snapshot scoping (Step5) needs a real backing volume for CreateSnapshot (viperblock, out of DaemonLite's scope), so it stays live entirely rather than split off only its two KV-only assertions. Global-resources (Step9) carries no account branching in the handlers at all, so asserting its equality across two accounts wouldn't exercise anything a fake node responder didn't already trivially satisfy — not migrated. * test(integration): migrate launch-template CRUD lifecycle off live guests TestLaunchTemplates booted zero guests: its RunInstances coverage had already moved to TestRunInstances_LaunchTemplateExpansion, leaving only template CRUD/versioning assertions occupying a full live-cluster slot. Port the remaining create/describe/version/modify/delete lifecycle, including the SourceVersion merge and a nested timestamp round-trip, into TestLaunchTemplates_CRUDLifecycle against the in-process integration gateway. * refactor(tests): move spot instance lifecycle coverage to integration tier RequestSpotInstances is a mock over on-demand RunInstances (no bidding, interruption, or reclamation), so booting real VMs in the e2e tier proved nothing spot-specific. Migrate the SIR CRUD lifecycle (request, describe, cancel, close-on-terminate, insufficient-capacity error) to a NATS-level integration test that exercises the real gateway orchestration and daemon service impl without a live guest. Real-VM-boot assertions are already covered by TestRunInstances_MultiCountExpansion and the e2e baseline tests. * fix(tests): share one embedded NATS server across the integration package ClientTokenStore in spinifex/gateway/ec2/instance is a process-wide sync.Once singleton bound to whichever *nats.Conn first initialises it. Every integration test booted its own embedded NATS server, so the first RunInstances-calling test captured the singleton, and any later test inherited a KV handle bound to that server's now-closed connection. TestMain now boots one embedded NATS+JetStream server for the whole package; every StartGateway call connects into its own dynamically provisioned NATS account (accountAuthenticator in shared_nats.go), giving each test a private JetStream namespace despite the shared server, and none of the per-test connections are closed until every test has finished - whichever one the singleton latches onto stays live for the run. Also wire the suite into the required PR gate: it was previously only invoked from e2e.yml's push-triggered (main/dev), self-hosted "Integration (front gate)" job, so a regression here never blocked a PR. The whole package runs in single-digit seconds, so it now runs inline in `make preflight` and as its own step in spinifex.yml's build_and_test job. * test(integration): migrate serial-console access checks off live guests Enable/Disable/GetSerialConsoleAccessEnabled is pure gateway+account-settings logic with no guest involved, so it moves to the in-process integration tier against the real AccountSettingsServiceImpl wired into DaemonLite. * test(integration): migrate DescribeImages round-trip off live guests runImage's AMI describe round-trip needs no real cluster: it registers an AMI against a hand-seeded snapshot via the real ImageServiceImpl, backed by a new StartImageDaemonLite helper wiring RegisterImage/DescribeImages onto an in-memory object store. * test(integration): migrate predastore object lifecycle off live guests runPredastoreObjectLifecycle only needs a real predastore daemon, not a tofu-provisioned cluster: it now runs against tests/fixtures/predastore's in-process fixture, covering the same create/put/get/delete/list contract. * test(integration): migrate ListFoundationModels catalog check off live cluster ListFoundationModels is pure gateway-side static catalog logic with no NATS/daemon hop, so it moves to the integration tier via a new BedrockClient harness accessor; this tier configures no credential resolver, so the Anthropic entry's absence is asserted explicitly rather than left optional. * test(integration): cover RunInstances client-token idempotency Proves replaying the same ClientToken through the real gateway returns the original reservation without a second daemon dispatch, and a different token launches independently. * test(integration): cover RunInstances launch-template expansion Proves version selection (omitted Version resolves to the template default, explicit Version selects that version) and field precedence (explicit RunInstances parameters override the template, unset fields inherit it) through the real gateway. * test(integration): cover RunInstances malformed-input rejection Proves a malformed ImageId and a malformed targeted-launch CapacityReservationId are rejected before any NATS dispatch, with no daemon stub registered so a regression that let either through would fail fast instead of hanging. * test(integration): cover RunInstances iam:PassRole enforcement Proves a caller granted ec2:RunInstances but not iam:PassRole on an instance profile's role is denied, and the identical session succeeds once PassRole is granted for that exact role ARN. * test(integration): cover RunInstances placement-group strategy routing Adds a placement-group daemon-lite wiring a real PlacementGroupServiceImpl over NATS, then proves spread strategy assigns exactly one instance per node while cluster strategy pins the whole launch to the single highest-capacity node. * test(integration): cover RunInstances capacity distribution across nodes Proves the default (non-placement-group) path packs a multi-instance launch across nodes proportionally to spare capacity rather than exhausting one node first. * test(e2e): merge SameSGComms and SGToSGDatapath around one client/target pair Both booted their own instance pair to check SG-to-SG datapath enforcement and same-default-SG east-west connectivity. Merging them around one shared client/target pair cuts four boots to two, and converting the inline harness.Step sequence into named t.Run stages (PortGroupMembership, AllowedTraffic, DeniedTraffic, RevokeAndVerifyBlocked, ReauthorizeAndVerifyRestored, SameSGComms) gives each its own pass/fail attribution instead of one opaque test result. SameSGComms now runs last and joins the default SG onto each ENI via ModifyNetworkInterfaceAttribute only after every SG-to-SG allow/deny assertion has already recorded its own result, since the default SG's self-reference rule admits all traffic between co-members and would otherwise defeat the denied-traffic check. * test(e2e): merge default-SG reachability, guest DNS, and egress enforcement DefaultSGReachabilityBaseline, GuestDNSResolution, and SecurityGroupEgress each booted their own instance to check one facet of "a guest is reachable only when policy says so": ingress deny/authorize, resolver + WAN egress, and egress ACL enforcement. Merging them around one shared guest cuts three boots to one, with named t.Run stages (Deny, Authorize, Resolver, WANEgress, RevokeEgress/RestoreEgress) giving each its own attribution. The egress revoke/restore round-trip now mutates this scenario's own dedicated SG instead of the shared default SG the rest of the suite depends on, isolating its egress-rule mutation from every other test. * fix(ci): single-quote e2e test_run regex across the ssh boundary * fix(e2e): anchor SG egress-enforcement probe to a WAN IP, not the gateway * test(e2e): merge single-node guest churn tests into one durability scenario Combines TestSSHProbe, TestENIHotplug, TestENIHotplugReconcile, TestVolumeDurability, TestModifyInstanceAttribute, and TestRebootInstance into TestGuestChurnDurability, a single shared-guest scenario that re-verifies a data-integrity sentinel sha256 after every perturbation stage (ENI hotplug, daemon restart, ENI detach, instance-type resize, stop/start, reboot). This proves the sentinel survives the full churn sequence rather than just one isolated stop/start. * fix(vpc): clear subnet drop gate when installing egress reroute A subnet with an attached IGW but no 0.0.0.0/0 route carries a priority-1100 drop gate on the VPC router. When a route later lands (NATGW reroute at 900, IGW reroute at 1000) the reroute is installed but the drop gate was never removed, so the higher-priority gate wins and black-holes the subnet's egress in lr_in_policy before it reaches routing or SNAT. This left NAT-gateway private-subnet egress with 100% packet loss: the SNAT stage saw zero packets and no conntrack was committed. Remove the subnet's drop gate for the same prefix once a reroute is installed. Idempotent and prefix-scoped, so a specific-prefix reroute is a no-op and leaves the default gate intact. * test(e2e): merge VPC subnet, NAT gateway, and EIP single-node tests Merges the former TestVPCSubnetE2E, TestNewVPCEgressBaseline, TestNATGateway, and TestInstanceEIP into one staged scenario, TestVPCEgressPaths, around one scenario-owned VPC and its two guests, cutting five guest boots to three. The public guest serves PublicSubnetEgress, then the NAT Gateway bastion hop, then (last, since association rewrites its public IP) the EIP flip. The private guest serves the NAT Gateway path, launched directly into the same VPC's already-existing private subnet instead of a dedicated one. RouteBeforeSubnet keeps its own second VPC and instance since it exercises the implicit main-route-table code path, a different one than the explicit route table this scenario's own VPC uses for its public subnet — collapsing the two would drop coverage of whichever path got merged away. The NAT Gateway up/down flip now repeats for an env-tunable round count (SPINIFEX_VPCEGRESS_NAT_ROUNDS, default 1), matching every other merged single-node scenario. * test: TEMPORARY mutation-check — reverta795c2b2drop-gate clear Reverts the RemoveSubnetEgressDrop call in ensureSubnetEgressAtPriority to confirm TestVPCEgressPaths/Round1/NATGatewayUp actually detects the regression it guards against. This commit will be reverted immediately after the live run confirms a failure. * test: TEMPORARY mutation-check v2 — neutralize RemoveSubnetEgressDrop Restores the reala795c2b2fix in ensureSubnetEgressAtPriority (the first mutation attempt only reverted this call site and was masked by the independent gate-recompute path in routetable service_impl.go's CreateRoute, which also clears the drop gate on every 0.0.0.0/0 route add and predatesa795c2b2). Neutralizing RemoveSubnetEgressDrop itself disables gate-clearing regardless of caller, reproducing the literal regression. Will be reverted immediately after live verification. * Revert "test: TEMPORARY mutation-check v2 — neutralize RemoveSubnetEgressDrop" Mutation-check confirmed: neutralizing RemoveSubnetEgressDrop reproduced a795c2b2's regression exactly (stale priority-1100 drop gate outranking the priority-1000 IGW reroute, confirmed via OVN flow dump; PublicSubnetEgress failed with an SSH handshake timeout to the public instance). Restoring the real fix; igw.go is now byte-identical toa795c2b2. * fix(e2e): stop pruning shared multinode SSH key pem mid-run The multinode package fixture cached Fixture.Artifacts once, computed from harness.ArtifactDir(t, env) against whichever top-level Test* happened to win the pkgFixOnce race. That helper both names the directory after that one test and registers a t.Cleanup that removes it once that test passes. needKeyPair wrote the shared EC2 key pair's pem into that same directory, so as soon as the winning test finished, its own artifact-dir cleanup deleted the pem every later test in the package still needed to SSH into fresh guests, even though the underlying EC2 key pair itself was untouched (its DeleteKeyPair cleanup already correctly drains at process Close() in TestMain). Fix: give Fixture an ArtifactDir(t) method — the same per-call pattern already used by the gpu/single/storagegrowth/iam fixtures — so each diagnostic dump site recomputes its own dir against the CURRENT test. The shared key pair pem now lives under Fixture.TmpDir, which is genuinely package-scoped and only removed once in TestMain after m.Run() completes. * refactor(e2e): parallelize LB e2e scenarios on multinode clusters Each of the 8 TestLoadBalancer scenarios already stands up its own dedicated LB + target group (ModifyListener/ListenerRules mutate their LB's listener in place, so it can never be shared). The bottleneck was running them serially: each teardown made createActiveLB back off waiting for the previous LB's sys.micro slot to be reclaimed before the next one could stand up. On multinode clusters (peer != "", i.e. the 3-node cluster this suite targets) there's spare capacity to stand up all 8 dedicated LBs at once, so scenarios now run as t.Parallel() subtests instead of serial create/teardown/backoff. Since each scenario owns an independent LB, one never reaching active only fails that scenario — it no longer cascades into five sequential 5-minute timeouts, so the serial fail-fast gate is skipped in this mode; concurrency itself bounds the worst case to a single timeout window. Single-node dev boxes (no peer) keep the original serial path, including the fail-fast gate, since they lack the spare capacity for concurrent LB standup. No subtest body, assertion, or call signature changed — only the dispatch/gating logic in TestLoadBalancer. * fix(ci): gate e2e jobs on the suites they actually run * fix(e2e): serialize internet-facing LB subtests among themselves Live multinode verification showed InternetFacing_ALB and InternetFacing_NLB running concurrently each clobber the other's public-LB DNS record mid-registration, so neither ever resolves within the 90s wait. The old serial suite never had two internet-facing LBs alive at once, so this never surfaced. Drop t.Parallel() from the three internet-facing subtests so their register+resolve windows never overlap each other, while leaving the internal subtests parallel as before. They may still run alongside the internal group, which resolves internal (not public) DNS and isn't affected. * fix(e2e): give each ALB LB scenario its own security group Concurrent internal subtests all created ALBs with no explicit SecurityGroups, so each ALB's ENI fell back to the one shared VPC default SG. One scenario authorizing/reconfiguring its own listener racing another mutating the same SG made Internal_ALB_ModifyListener intermittently fail to serve on the modified port under parallel load. Each ALB scenario now creates a dedicated SG (createScenarioSG), authorized only for the ports that scenario's listener(s) use, and passes it to CreateLoadBalancer. The shared app-instance/probe-client SG is untouched, since SG ingress is enforced at the destination ENI regardless of the source ENI's SG. NLBs are unaffected: they already get an auto-managed per-LB SG when none is supplied. * test(e2e): annotate intentional create-path calls for manifest-lint The vpcegress and per-scenario LB security-group tests create raw AWS resources on purpose — the egress datapath, the route-before-subnet ordering, and per-scenario SG isolation are the subjects under test, so they cannot route through a shared Ensure* fixture. Mark each with // e2e:allow-create and slim the baseline of entries for the removed single-node network tests. * test(e2e): restore live spot terminate-close and cancel-running coverage The integration-tier spot test calls CloseForInstance directly and infers cancel-keeps-running from the SIR's InstanceId, because DaemonLite has no vm.Manager to drive the real TerminateInstances -> daemon teardown cleaner -> RemoveFromSpotRequest -> CloseForInstance chain or to observe a real VM's running state. Add a thin live e2e test that drives both terminal transitions through a real backing VM: cancel one spot-backed instance and observe it stays running, terminate the other and observe its SIR reach closed through the genuine async delegation. * fix(network): refresh orphan-EIP prune liveness to spare mid-pass launches pruneOrphanEIPs listed OVN NAT rows live but matched them against the intent snapshot captured at the start of the reconcile pass. Because the apply phase can block for tens of seconds on guest-port datapath waits, a guest launched during the pass had a live dnat_and_snat row absent from the stale snapshot, so the prune swept it and blackholed the guest's auto-assigned public IP until the next pass re-added it — an intermittent multinode SSH-dark flake. Add an optional FreshIntent re-read callback (wired in vpcd to LoadIntentFromKV) and union its ports into the live set before pruning, so a mid-pass launch counts as live. Skip the prune entirely when the re-read fails rather than risk a false sweep against a snapshot known to be stale. Self-healing and idempotent: a genuine orphan absent from both snapshot and fresh read is still pruned next drift. Single-node behaviour is unchanged (callback nil in tests).
896 lines
37 KiB
Go
896 lines
37 KiB
Go
//go:build e2e
|
|
|
|
package single
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// vpcEgressNATRounds returns how many NAT Gateway up/down flip cycles the
|
|
// round loop repeats against the same private instance, overridable via
|
|
// SPINIFEX_VPCEGRESS_NAT_ROUNDS. Defaults to 1, matching the former
|
|
// standalone NAT Gateway test's single-pass behaviour; bump it for a soak
|
|
// run that repeatedly provisions and tears down the gateway to catch
|
|
// route/SNAT propagation flakiness a single cycle would not surface.
|
|
func vpcEgressNATRounds() int {
|
|
return envPositiveIntOr("SPINIFEX_VPCEGRESS_NAT_ROUNDS", 1)
|
|
}
|
|
|
|
// runVPCEgressPaths merges four checks that used to boot their own VPC
|
|
// apiece — normal-order public/private subnet egress, the route-before-subnet
|
|
// ordering regression guard, NAT Gateway egress, and instance Elastic IP
|
|
// association — around one scenario-owned VPC and its two guests, cutting
|
|
// five boots to three.
|
|
//
|
|
// The three ways a guest in this VPC reaches (or is reached from) the
|
|
// internet — a direct IGW route on its own subnet, a NAT Gateway fronting a
|
|
// private subnet, and an Elastic IP flipped onto a running instance — share
|
|
// one public guest and one private guest instead of each paying for its own.
|
|
// The fourth stage, RouteBeforeSubnet, is a regression guard for an ordering
|
|
// edge case in the first path (the IGW route can be installed on a VPC's
|
|
// main route table before any subnet exists) and needs a second VPC to
|
|
// reproduce that ordering, so it is not foldable into the shared guests
|
|
// below without losing the precondition it exists to prove.
|
|
//
|
|
// Stage order and gating:
|
|
// - VPC A (10.99.0.0/16, public + private subnet, explicit route table
|
|
// associated to the public subnet after it exists) and its public guest
|
|
// are unconditional prerequisites; any failure here is fatal in the
|
|
// ordinary Go-test sense (require), matching the originals.
|
|
// - PublicSubnetEgress proves the public guest's own IGW egress works.
|
|
// Every later stage that touches this same guest — the NAT Gateway
|
|
// bastion hop and the EIP flip — depends on its SSH datapath already
|
|
// being healthy, so PublicSubnetEgress's failure aborts the rest of the
|
|
// scenario rather than let two more stages time out for the same reason.
|
|
// - RouteBeforeSubnet builds its own second VPC end to end (IGW route
|
|
// installed on the main route table before the subnet that implicitly
|
|
// joins it even exists) and never shares state with any other stage, so
|
|
// it registers its own cleanup on its own subtest rather than the
|
|
// scenario's — its resources are freed the moment the stage ends instead
|
|
// of lingering to the end of the whole scenario. This ordering
|
|
// precondition is the one piece of coverage here that cannot be merged
|
|
// away: the explicit route table this scenario uses for the public
|
|
// subnet above exercises a different code path (CreateRouteTable +
|
|
// AssociateRouteTable) than the implicit main-route-table membership
|
|
// this stage depends on, so collapsing the two would silently stop
|
|
// testing whichever path got dropped.
|
|
// - The NAT Gateway private guest launches into VPC A's already-existing
|
|
// private subnet — no separate VPC or subnet needed — and every NAT
|
|
// stage reaches it by hopping through the public guest over SSH, reusing
|
|
// it as the bastion rather than booting a dedicated one. NATBaseline
|
|
// (no internet before any NAT Gateway exists) runs once; the up/down
|
|
// flip that follows repeats for vpcEgressNATRounds() rounds (default 1)
|
|
// so a soak run can catch propagation flakiness a single cycle would
|
|
// not surface. Each round's NATGatewayDown only runs if that round's
|
|
// NATGatewayUp succeeded — tearing down a gateway that was never
|
|
// fully provisioned would produce misleading failures rather than
|
|
// useful signal.
|
|
// - EIPFlip runs last and reuses the same public guest as the EIP subject.
|
|
// Associating an Elastic IP rewrites the instance's public IP, which
|
|
// would corrupt every stage above that still expects the guest to
|
|
// answer on its original address — ordering it last is what lets this
|
|
// scenario reuse the guest at all instead of needing a disposable one.
|
|
func runVPCEgressPaths(t *testing.T, fix *Fixture) {
|
|
if !fix.PoolMode {
|
|
t.Skip("VPC egress paths scenario requires pool-mode networking")
|
|
}
|
|
harness.Phase(t, "Single — Three ways in and out: public subnet, NAT Gateway, and Elastic IP egress around one VPC")
|
|
|
|
c := fix.AWS
|
|
amiID := needAMI(t, fix)
|
|
instType, _ := needInstanceTypeArch(t, fix)
|
|
keyName, keyPath := needKeyPair(t, fix)
|
|
|
|
// --- VPC A: public + private subnet, explicit route table, normal ordering (subnet before route) ---
|
|
|
|
harness.Step(t, "create-vpc 10.99.0.0/16")
|
|
vpcOut, err := c.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String("10.99.0.0/16")}) // e2e:allow-create — VPC A is the egress-datapath topology under test.
|
|
require.NoError(t, err, "create-vpc")
|
|
require.NotNil(t, vpcOut.Vpc, "create-vpc returned nil Vpc")
|
|
vpcID := aws.StringValue(vpcOut.Vpc.VpcId)
|
|
require.NotEmpty(t, vpcID, "VpcId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(vpcID)})
|
|
})
|
|
harness.Detail(t, "vpc", vpcID, "cidr", "10.99.0.0/16")
|
|
|
|
harness.Step(t, "create-subnet 10.99.1.0/24 (public)")
|
|
pubSubOut, err := c.EC2.CreateSubnet(&ec2.CreateSubnetInput{ // e2e:allow-create — the public subnet is the IGW-egress datapath under test.
|
|
VpcId: aws.String(vpcID),
|
|
CidrBlock: aws.String("10.99.1.0/24"),
|
|
})
|
|
require.NoError(t, err, "create public subnet")
|
|
require.NotNil(t, pubSubOut.Subnet, "create public subnet returned nil Subnet")
|
|
pubSubnetID := aws.StringValue(pubSubOut.Subnet.SubnetId)
|
|
require.NotEmpty(t, pubSubnetID, "public SubnetId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(pubSubnetID)})
|
|
})
|
|
|
|
harness.Step(t, "modify-subnet-attribute MapPublicIpOnLaunch=true %s", pubSubnetID)
|
|
_, err = c.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
|
|
SubnetId: aws.String(pubSubnetID),
|
|
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
|
})
|
|
require.NoError(t, err, "modify-subnet-attribute MapPublicIpOnLaunch")
|
|
harness.Detail(t, "pub_subnet", pubSubnetID, "map_public_ip", "true")
|
|
|
|
harness.Step(t, "create-subnet 10.99.2.0/24 (private)")
|
|
privSubOut, err := c.EC2.CreateSubnet(&ec2.CreateSubnetInput{ // e2e:allow-create — the private subnet is the NAT-gateway egress datapath under test.
|
|
VpcId: aws.String(vpcID),
|
|
CidrBlock: aws.String("10.99.2.0/24"),
|
|
})
|
|
require.NoError(t, err, "create private subnet")
|
|
require.NotNil(t, privSubOut.Subnet, "create private subnet returned nil Subnet")
|
|
privSubnetID := aws.StringValue(privSubOut.Subnet.SubnetId)
|
|
require.NotEmpty(t, privSubnetID, "private SubnetId empty")
|
|
require.Falsef(t, aws.BoolValue(privSubOut.Subnet.MapPublicIpOnLaunch),
|
|
"private subnet MapPublicIpOnLaunch must default to false (got %v)",
|
|
aws.BoolValue(privSubOut.Subnet.MapPublicIpOnLaunch))
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(privSubnetID)})
|
|
})
|
|
harness.Detail(t, "priv_subnet", privSubnetID, "map_public_ip", "false")
|
|
|
|
harness.Step(t, "create-internet-gateway")
|
|
igwOut, err := c.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{}) // e2e:allow-create — the IGW is the egress path under test.
|
|
require.NoError(t, err, "create-internet-gateway")
|
|
require.NotNil(t, igwOut.InternetGateway, "create-internet-gateway returned nil InternetGateway")
|
|
igwID := aws.StringValue(igwOut.InternetGateway.InternetGatewayId)
|
|
require.NotEmpty(t, igwID, "InternetGatewayId empty")
|
|
igwDetached := false
|
|
t.Cleanup(func() {
|
|
if !igwDetached {
|
|
_, _ = c.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID),
|
|
VpcId: aws.String(vpcID),
|
|
})
|
|
}
|
|
_, _ = c.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID),
|
|
})
|
|
})
|
|
|
|
harness.Step(t, "attach-internet-gateway %s -> %s", igwID, vpcID)
|
|
_, err = c.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID),
|
|
VpcId: aws.String(vpcID),
|
|
})
|
|
require.NoError(t, err, "attach-internet-gateway")
|
|
harness.Detail(t, "igw", igwID)
|
|
|
|
harness.Step(t, "create-route-table (vpc=%s)", vpcID)
|
|
rtOut, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{ // e2e:allow-create — the explicit route table exercises the CreateRouteTable+Associate egress path under test.
|
|
VpcId: aws.String(vpcID),
|
|
})
|
|
require.NoError(t, err, "create-route-table")
|
|
require.NotNil(t, rtOut.RouteTable, "create-route-table returned nil RouteTable")
|
|
rtbID := aws.StringValue(rtOut.RouteTable.RouteTableId)
|
|
require.NotEmpty(t, rtbID, "RouteTableId empty")
|
|
rtbDeleted := false
|
|
t.Cleanup(func() {
|
|
if !rtbDeleted {
|
|
_, _ = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
})
|
|
}
|
|
})
|
|
|
|
harness.Step(t, "associate-route-table %s <- %s", rtbID, pubSubnetID)
|
|
assocOut, err := c.EC2.AssociateRouteTable(&ec2.AssociateRouteTableInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
SubnetId: aws.String(pubSubnetID),
|
|
})
|
|
require.NoError(t, err, "associate-route-table")
|
|
assocID := aws.StringValue(assocOut.AssociationId)
|
|
require.NotEmpty(t, assocID, "AssociationId empty")
|
|
assocReleased := false
|
|
t.Cleanup(func() {
|
|
if !assocReleased {
|
|
_, _ = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{
|
|
AssociationId: aws.String(assocID),
|
|
})
|
|
}
|
|
})
|
|
|
|
harness.Step(t, "create-route 0.0.0.0/0 -> %s", igwID)
|
|
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
GatewayId: aws.String(igwID),
|
|
})
|
|
require.NoError(t, err, "create-route 0.0.0.0/0 -> IGW")
|
|
routeDeleted := false
|
|
t.Cleanup(func() {
|
|
if !routeDeleted {
|
|
_, _ = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
})
|
|
}
|
|
})
|
|
harness.Detail(t, "rtb", rtbID, "assoc", assocID, "route", "0.0.0.0/0->"+igwID)
|
|
|
|
// Fresh VPC's auto-created default SG denies all ingress. Authorize
|
|
// tcp/22 from anywhere so every guest launched into VPC A (public and
|
|
// private alike) can be reached via the bastion hop below.
|
|
harness.Step(t, "describe-security-groups (default) vpc=%s", vpcID)
|
|
sgOut, err := c.EC2.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
|
|
Filters: []*ec2.Filter{
|
|
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpcID)}},
|
|
{Name: aws.String("group-name"), Values: []*string{aws.String("default")}},
|
|
},
|
|
})
|
|
require.NoError(t, err, "describe-security-groups default")
|
|
require.NotEmpty(t, sgOut.SecurityGroups, "no default SG for vpc=%s", vpcID)
|
|
defaultSGID := aws.StringValue(sgOut.SecurityGroups[0].GroupId)
|
|
require.NotEmpty(t, defaultSGID, "default SG GroupId empty")
|
|
harness.AuthorizeSSHIngress(t, c, defaultSGID)
|
|
harness.Detail(t, "default_sg", defaultSGID, "ingress", "tcp/22<-0.0.0.0/0")
|
|
|
|
// --- Public guest: PublicSubnetEgress, then the NAT bastion hop, then (last) EIPFlip ---
|
|
|
|
harness.Step(t, "run-instances ami=%s subnet=%s (public)", amiID, pubSubnetID)
|
|
pubRunOut, err := c.EC2.RunInstances(&ec2.RunInstancesInput{ // e2e:allow-create — the public guest is the egress / EIP subject under test.
|
|
ImageId: aws.String(amiID),
|
|
InstanceType: aws.String(instType),
|
|
KeyName: aws.String(keyName),
|
|
SubnetId: aws.String(pubSubnetID),
|
|
MinCount: aws.Int64(1),
|
|
MaxCount: aws.Int64(1),
|
|
})
|
|
require.NoError(t, err, "run-instances public")
|
|
require.NotEmpty(t, pubRunOut.Instances, "run-instances public returned no Instances")
|
|
pubInstanceID := aws.StringValue(pubRunOut.Instances[0].InstanceId)
|
|
require.NotEmpty(t, pubInstanceID, "public InstanceId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
|
InstanceIds: []*string{aws.String(pubInstanceID)},
|
|
})
|
|
_ = waitForInstanceStateSoft(c, pubInstanceID, "terminated", 5*time.Minute)
|
|
})
|
|
harness.Detail(t, "instance", pubInstanceID)
|
|
|
|
pubInst := harness.WaitForInstanceState(t, c, pubInstanceID, "running")
|
|
pubIP := aws.StringValue(pubInst.PublicIpAddress)
|
|
pubPrivIP := aws.StringValue(pubInst.PrivateIpAddress)
|
|
require.NotEmptyf(t, pubIP,
|
|
"public-subnet instance %s has no PublicIpAddress (MapPublicIpOnLaunch failed?)", pubInstanceID)
|
|
require.NotEmpty(t, pubPrivIP, "PrivateIpAddress empty")
|
|
harness.Detail(t, "public_ip", pubIP, "private_ip", pubPrivIP)
|
|
|
|
pubOK := t.Run("PublicSubnetEgress", func(t *testing.T) {
|
|
if !trySSHReady(pubIP, 22, keyPath, sshReadyBudget) {
|
|
harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID,
|
|
fmt.Sprintf("PublicSubnetEgress SSH timeout — vpc=%s igw=%s pub=%s", vpcID, igwID, pubIP),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: pubIP,
|
|
LogicalIP: pubPrivIP,
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
t.Fatalf("SSH handshake %s:22 never completed within %s (see diagnostics above)", pubIP, sshReadyBudget)
|
|
}
|
|
|
|
tgt := harness.SSHTarget{User: "ubuntu", Host: pubIP, Port: 22, KeyPath: keyPath}
|
|
|
|
harness.Step(t, "ssh id (smoke)")
|
|
idOut := runSSH(t, tgt, "id")
|
|
require.Containsf(t, idOut, "ubuntu", "ssh id did not report ubuntu\n%s", idOut)
|
|
|
|
// Verify external connectivity through the IGW + 0.0.0.0/0 route. ping
|
|
// is a soft check: WAN egress depends on the hosting network and isn't
|
|
// part of the EC2 surface contract.
|
|
harness.Step(t, "verify external connectivity via IGW (ping 8.8.8.8)")
|
|
pingOK := false
|
|
harness.EventuallyErr(t, func() error {
|
|
out, perr := runSSHQuiet(tgt, "ping -c 2 -W 5 8.8.8.8 >/dev/null 2>&1 && echo yes || echo no")
|
|
if perr != nil {
|
|
return fmt.Errorf("ssh ping: %w (out=%q)", perr, out)
|
|
}
|
|
if strings.TrimSpace(out) == "yes" {
|
|
pingOK = true
|
|
return nil
|
|
}
|
|
return fmt.Errorf("ping did not succeed (out=%q)", strings.TrimSpace(out))
|
|
}, 90*time.Second, 5*time.Second)
|
|
if pingOK {
|
|
harness.Detail(t, "external_ping", "ok")
|
|
} else {
|
|
t.Logf("WARN: external ping inconclusive — may depend on WAN gateway")
|
|
}
|
|
})
|
|
if !pubOK {
|
|
t.Fatalf("PublicSubnetEgress stage failed; skipping every later stage that reuses this guest as the NAT bastion / EIP subject")
|
|
}
|
|
|
|
bastionTgt := harness.SSHTarget{User: "ubuntu", Host: pubIP, Port: 22, KeyPath: keyPath}
|
|
|
|
// --- RouteBeforeSubnet: its own second VPC, fully self-contained ---
|
|
|
|
t.Run("RouteBeforeSubnet", func(t *testing.T) {
|
|
az := needAZ(t, fix)
|
|
const vpcCIDR = "10.231.0.0/16"
|
|
const subnetCIDR = "10.231.1.0/24"
|
|
|
|
vOut, err := c.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String(vpcCIDR)}) // e2e:allow-create — a second VPC reproduces the route-before-subnet ordering under test.
|
|
require.NoError(t, err, "create-vpc")
|
|
require.NotNil(t, vOut.Vpc, "create-vpc returned nil Vpc")
|
|
vID := aws.StringValue(vOut.Vpc.VpcId)
|
|
require.NotEmpty(t, vID, "VpcId empty")
|
|
t.Cleanup(func() { _, _ = c.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(vID)}) })
|
|
|
|
iOut, err := c.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{}) // e2e:allow-create — this IGW is routed on the main RT before any subnet exists — the ordering under test.
|
|
require.NoError(t, err, "create-internet-gateway")
|
|
require.NotNil(t, iOut.InternetGateway, "create-internet-gateway returned nil InternetGateway")
|
|
iID := aws.StringValue(iOut.InternetGateway.InternetGatewayId)
|
|
require.NotEmpty(t, iID, "InternetGatewayId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(iID), VpcId: aws.String(vID),
|
|
})
|
|
_, _ = c.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{
|
|
InternetGatewayId: aws.String(iID),
|
|
})
|
|
})
|
|
_, err = c.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(iID), VpcId: aws.String(vID),
|
|
})
|
|
require.NoError(t, err, "attach-internet-gateway")
|
|
|
|
mainRT := mainRouteTableID(t, c, vID)
|
|
harness.Detail(t, "vpc", vID, "igw", iID, "main_rtb", mainRT)
|
|
|
|
// Route FIRST, on the main RT, before the subnet exists.
|
|
harness.Step(t, "create-route 0.0.0.0/0 -> %s on main RT %s (before subnet)", iID, mainRT)
|
|
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
|
RouteTableId: aws.String(mainRT),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
GatewayId: aws.String(iID),
|
|
})
|
|
require.NoError(t, err, "create-route 0.0.0.0/0 -> IGW on main RT")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
|
RouteTableId: aws.String(mainRT),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
})
|
|
})
|
|
|
|
// Subnet AFTER the route (implicit main-RT membership).
|
|
harness.Step(t, "create-subnet %s (implicit main RT, after IGW route)", subnetCIDR)
|
|
sOut, err := c.EC2.CreateSubnet(&ec2.CreateSubnetInput{ // e2e:allow-create — the subnet created after the main-RT IGW route is the ordering regression under test.
|
|
VpcId: aws.String(vID),
|
|
CidrBlock: aws.String(subnetCIDR),
|
|
AvailabilityZone: aws.String(az),
|
|
})
|
|
require.NoError(t, err, "create-subnet")
|
|
require.NotNil(t, sOut.Subnet, "create-subnet returned nil Subnet")
|
|
sID := aws.StringValue(sOut.Subnet.SubnetId)
|
|
require.NotEmpty(t, sID, "SubnetId empty")
|
|
t.Cleanup(func() { _, _ = c.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(sID)}) })
|
|
|
|
_, err = c.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
|
|
SubnetId: aws.String(sID),
|
|
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
|
})
|
|
require.NoError(t, err, "modify-subnet-attribute MapPublicIpOnLaunch on %s", sID)
|
|
|
|
// Open SG so routing is provably the only barrier.
|
|
sgID := harness.EnsureSG(t, fix.Harness, vID, "vpcegress-routebeforesubnet-sg")
|
|
harness.AuthorizeSSHIngress(t, c, sgID)
|
|
harness.Detail(t, "subnet", sID, "cidr", subnetCIDR, "sg", sgID)
|
|
|
|
instanceID := launchBaselineInstance(t, fix, amiID, instType, keyName, sID, []string{sgID})
|
|
rbPubIP := instancePublicIP(t, fix, instanceID)
|
|
harness.Detail(t, "instance", instanceID, "public_ip", rbPubIP)
|
|
|
|
harness.Step(t, "expecting external SSH to instance in fresh-VPC public subnet")
|
|
if !trySSHReady(rbPubIP, 22, keyPath, sshReadyBudget) {
|
|
harness.DumpVPCFlowDiagnostics(t, c, instanceID,
|
|
fmt.Sprintf("RouteBeforeSubnet SSH timeout — vpc=%s igw=%s pub=%s", vID, iID, rbPubIP),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: rbPubIP,
|
|
LogicalIP: instancePrivateIP(t, fix, instanceID),
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
t.Fatalf("tcp/22 to %s in fresh-VPC subnet %s never became reachable — the "+
|
|
"per-subnet IGW egress policy was not installed for a subnet created "+
|
|
"after the main-RT IGW route existed", rbPubIP, sID)
|
|
}
|
|
|
|
tgt := harness.SSHTarget{User: "ubuntu", Host: rbPubIP, Port: 22, KeyPath: keyPath}
|
|
idOut := runSSH(t, tgt, "id")
|
|
assert.Containsf(t, idOut, "ubuntu", "ssh id in fresh-VPC subnet\n%s", idOut)
|
|
})
|
|
|
|
// --- NAT Gateway: private guest in VPC A's private subnet, bastion-hop via the public guest ---
|
|
|
|
harness.Step(t, "run-instances private (VPC A private subnet)")
|
|
privRunOut, err := c.EC2.RunInstances(&ec2.RunInstancesInput{ // e2e:allow-create — the private guest is the NAT-gateway egress subject under test.
|
|
ImageId: aws.String(amiID),
|
|
InstanceType: aws.String(instType),
|
|
KeyName: aws.String(keyName),
|
|
SubnetId: aws.String(privSubnetID),
|
|
MinCount: aws.Int64(1),
|
|
MaxCount: aws.Int64(1),
|
|
})
|
|
require.NoError(t, err, "run-instances private")
|
|
require.NotEmpty(t, privRunOut.Instances, "run-instances private returned no Instances")
|
|
privInstanceID := aws.StringValue(privRunOut.Instances[0].InstanceId)
|
|
require.NotEmpty(t, privInstanceID, "private InstanceId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
|
InstanceIds: []*string{aws.String(privInstanceID)},
|
|
})
|
|
_ = waitForInstanceStateSoft(c, privInstanceID, "terminated", 5*time.Minute)
|
|
})
|
|
|
|
privInst := harness.WaitForInstanceState(t, c, privInstanceID, "running")
|
|
privIP := aws.StringValue(privInst.PrivateIpAddress)
|
|
require.NotEmptyf(t, privIP, "private instance %s has no PrivateIpAddress", privInstanceID)
|
|
require.Emptyf(t, aws.StringValue(privInst.PublicIpAddress),
|
|
"private instance %s unexpectedly has a public IP (got %q)",
|
|
privInstanceID, aws.StringValue(privInst.PublicIpAddress))
|
|
harness.Detail(t, "private_instance", privInstanceID, "private_ip", privIP)
|
|
|
|
// Copy the keypair to the bastion so it can hop into the private guest.
|
|
harness.Step(t, "scp keypair -> bastion:/tmp/key.pem")
|
|
scpKey(t, keyPath, pubIP)
|
|
_ = runSSH(t, bastionTgt, "chmod 600 /tmp/key.pem")
|
|
|
|
privProbe := fmt.Sprintf(
|
|
"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "+
|
|
"-o LogLevel=ERROR -o ConnectTimeout=10 -o BatchMode=yes "+
|
|
"-i /tmp/key.pem ubuntu@%s hostname",
|
|
privIP,
|
|
)
|
|
harness.Step(t, "wait for private SSH via bastion")
|
|
harness.EventuallyErr(t, func() error {
|
|
out, rerr := runSSHQuiet(bastionTgt, privProbe)
|
|
if rerr != nil {
|
|
return fmt.Errorf("bastion->priv hostname: %w (out=%q)", rerr, out)
|
|
}
|
|
if strings.TrimSpace(out) == "" {
|
|
return fmt.Errorf("empty hostname response from private VM")
|
|
}
|
|
return nil
|
|
}, 3*time.Minute, 5*time.Second)
|
|
|
|
pingCmd := func() string {
|
|
return fmt.Sprintf(
|
|
"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "+
|
|
"-o LogLevel=ERROR -o ConnectTimeout=10 -o BatchMode=yes "+
|
|
"-i /tmp/key.pem ubuntu@%s 'ping -c 1 -W 3 8.8.8.8'",
|
|
privIP,
|
|
)
|
|
}
|
|
|
|
t.Run("NATBaseline", func(t *testing.T) {
|
|
harness.Step(t, "baseline: private VM has no internet")
|
|
if _, err := runSSHQuiet(bastionTgt, pingCmd()); err == nil {
|
|
t.Fatalf("baseline ping unexpectedly succeeded — private VM has internet without NAT GW")
|
|
}
|
|
})
|
|
|
|
rounds := vpcEgressNATRounds()
|
|
for round := 1; round <= rounds; round++ {
|
|
round := round
|
|
t.Run(fmt.Sprintf("Round%d", round), func(t *testing.T) {
|
|
roundT := t
|
|
|
|
var natGWID, natAllocID, natPubIP, natRTBID, natAssocID string
|
|
eipReleased := false
|
|
natDeleted := false
|
|
natRTBDeleted := false
|
|
natAssocReleased := false
|
|
natRouteDeleted := false
|
|
|
|
natUpOK := t.Run("NATGatewayUp", func(t *testing.T) {
|
|
harness.Step(t, "allocate-address (NAT EIP)")
|
|
eipOut, err := c.EC2.AllocateAddress(&ec2.AllocateAddressInput{ // e2e:allow-create — the NAT gateway's Elastic IP is part of the egress path under test.
|
|
Domain: aws.String("vpc"),
|
|
})
|
|
require.NoError(t, err, "allocate-address vpc")
|
|
natAllocID = aws.StringValue(eipOut.AllocationId)
|
|
natPubIP = aws.StringValue(eipOut.PublicIp)
|
|
require.NotEmpty(t, natAllocID, "AllocationId empty")
|
|
roundT.Cleanup(func() {
|
|
if eipReleased {
|
|
return
|
|
}
|
|
_, _ = c.EC2.ReleaseAddress(&ec2.ReleaseAddressInput{
|
|
AllocationId: aws.String(natAllocID),
|
|
})
|
|
})
|
|
harness.Detail(t, "eip", natPubIP, "alloc", natAllocID)
|
|
|
|
harness.Step(t, "create-nat-gateway in %s", pubSubnetID)
|
|
natOut, err := c.EC2.CreateNatGateway(&ec2.CreateNatGatewayInput{ // e2e:allow-create — the NAT gateway is the egress datapath under test.
|
|
SubnetId: aws.String(pubSubnetID),
|
|
AllocationId: aws.String(natAllocID),
|
|
})
|
|
require.NoError(t, err, "create-nat-gateway")
|
|
require.NotNil(t, natOut.NatGateway, "create-nat-gateway returned nil NatGateway")
|
|
natGWID = aws.StringValue(natOut.NatGateway.NatGatewayId)
|
|
require.NotEmpty(t, natGWID, "NatGatewayId empty")
|
|
roundT.Cleanup(func() {
|
|
if natDeleted {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DeleteNatGateway(&ec2.DeleteNatGatewayInput{
|
|
NatGatewayId: aws.String(natGWID),
|
|
})
|
|
_ = waitForNATGatewayStateSoft(c, natGWID, "deleted", 5*time.Minute)
|
|
})
|
|
harness.Detail(t, "nat_gw", natGWID)
|
|
|
|
waitForNATGatewayState(t, c, natGWID, "available")
|
|
|
|
// Route table -> associate -> CreateRoute. The association
|
|
// must exist before the route so the SNAT publication fires
|
|
// off it, mirroring the original test's ordering.
|
|
harness.Step(t, "create-route-table")
|
|
rtOut, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{ // e2e:allow-create — the private-subnet route table steers egress through the NAT gateway under test.
|
|
VpcId: aws.String(vpcID),
|
|
})
|
|
require.NoError(t, err, "create-route-table")
|
|
require.NotNil(t, rtOut.RouteTable, "create-route-table returned nil RouteTable")
|
|
natRTBID = aws.StringValue(rtOut.RouteTable.RouteTableId)
|
|
require.NotEmpty(t, natRTBID, "RouteTableId empty")
|
|
roundT.Cleanup(func() {
|
|
if natRTBDeleted {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
})
|
|
})
|
|
|
|
harness.Step(t, "associate-route-table %s <- %s", natRTBID, privSubnetID)
|
|
assocOut, err := c.EC2.AssociateRouteTable(&ec2.AssociateRouteTableInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
SubnetId: aws.String(privSubnetID),
|
|
})
|
|
require.NoError(t, err, "associate-route-table")
|
|
natAssocID = aws.StringValue(assocOut.AssociationId)
|
|
require.NotEmpty(t, natAssocID, "AssociationId empty")
|
|
roundT.Cleanup(func() {
|
|
if natAssocReleased {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{
|
|
AssociationId: aws.String(natAssocID),
|
|
})
|
|
})
|
|
|
|
harness.Step(t, "create-route 0.0.0.0/0 -> %s", natGWID)
|
|
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
NatGatewayId: aws.String(natGWID),
|
|
})
|
|
require.NoError(t, err, "create-route 0.0.0.0/0")
|
|
roundT.Cleanup(func() {
|
|
if natRouteDeleted {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
})
|
|
})
|
|
|
|
// OVN needs a beat to install datapath flows after SNAT
|
|
// publishes. Snapshot the SNAT datapath on failure so the
|
|
// bundle carries OVN/OVS state to root-cause the loss.
|
|
harness.OnFailure(t, func() {
|
|
harness.DumpVPCFlowDiagnostics(t, c, privInstanceID,
|
|
fmt.Sprintf("NAT GW egress — nat_gw=%s external_ip=%s logical_ip=%s", natGWID, natPubIP, privIP),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: natPubIP,
|
|
LogicalIP: privIP,
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
})
|
|
harness.Step(t, "verify private VM reaches 8.8.8.8 via NAT GW")
|
|
harness.EventuallyErr(t, func() error {
|
|
out, perr := runSSHQuiet(bastionTgt, pingCmd())
|
|
if perr != nil {
|
|
return fmt.Errorf("ping via NAT GW: %w (out=%q)", perr, out)
|
|
}
|
|
return nil
|
|
}, 2*time.Minute, 5*time.Second)
|
|
})
|
|
|
|
if !natUpOK {
|
|
t.Logf("round %d: NATGatewayUp failed; skipping NATGatewayDown since there is nothing fully provisioned to tear down", round)
|
|
return
|
|
}
|
|
|
|
t.Run("NATGatewayDown", func(t *testing.T) {
|
|
// Mirror the create-time ordering in reverse: delete
|
|
// nat-gateway -> disassociate route table -> delete route ->
|
|
// delete route table -> release address. Each step flips the
|
|
// corresponding latch so the safety-net roundT.Cleanup
|
|
// callbacks above become no-ops on the success path.
|
|
harness.Step(t, "delete-nat-gateway %s", natGWID)
|
|
_, err := c.EC2.DeleteNatGateway(&ec2.DeleteNatGatewayInput{
|
|
NatGatewayId: aws.String(natGWID),
|
|
})
|
|
require.NoError(t, err, "delete-nat-gateway")
|
|
|
|
_, err = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{
|
|
AssociationId: aws.String(natAssocID),
|
|
})
|
|
require.NoError(t, err, "disassociate-route-table")
|
|
natAssocReleased = true
|
|
|
|
_, err = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
})
|
|
require.NoError(t, err, "delete-route")
|
|
natRouteDeleted = true
|
|
|
|
_, err = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
})
|
|
require.NoError(t, err, "delete-route-table")
|
|
natRTBDeleted = true
|
|
|
|
waitForNATGatewayState(t, c, natGWID, "deleted")
|
|
natDeleted = true
|
|
|
|
_, err = c.EC2.ReleaseAddress(&ec2.ReleaseAddressInput{
|
|
AllocationId: aws.String(natAllocID),
|
|
})
|
|
require.NoError(t, err, "release-address")
|
|
eipReleased = true
|
|
|
|
// OVN datapath can briefly cache the old SNAT flow, so poll
|
|
// for the loss rather than asserting immediately.
|
|
harness.Step(t, "verify private VM lost internet after NAT GW teardown")
|
|
harness.EventuallyErr(t, func() error {
|
|
if _, perr := runSSHQuiet(bastionTgt, pingCmd()); perr == nil {
|
|
return fmt.Errorf("ping still succeeding after NAT GW deletion")
|
|
}
|
|
return nil
|
|
}, 90*time.Second, 5*time.Second)
|
|
})
|
|
})
|
|
}
|
|
|
|
// --- EIPFlip: last, on the same public guest ---
|
|
|
|
t.Run("EIPFlip", func(t *testing.T) {
|
|
harness.SkipIfNoOVN(t)
|
|
|
|
harness.Step(t, "allocate-address (vpc)")
|
|
eipOut, err := c.EC2.AllocateAddress(&ec2.AllocateAddressInput{Domain: aws.String("vpc")}) // e2e:allow-create — the Elastic IP flipped onto the guest is the datapath under test.
|
|
require.NoError(t, err, "allocate-address")
|
|
allocID := aws.StringValue(eipOut.AllocationId)
|
|
eipIP := aws.StringValue(eipOut.PublicIp)
|
|
require.NotEmpty(t, allocID, "AllocationId empty")
|
|
require.NotEmpty(t, eipIP, "EIP PublicIp empty")
|
|
eipReleased := false
|
|
t.Cleanup(func() {
|
|
if eipReleased {
|
|
return
|
|
}
|
|
_, _ = c.EC2.ReleaseAddress(&ec2.ReleaseAddressInput{AllocationId: aws.String(allocID)})
|
|
})
|
|
harness.Detail(t, "eip", eipIP, "alloc", allocID)
|
|
|
|
harness.Step(t, "associate-address %s -> %s", eipIP, pubInstanceID)
|
|
assocOut, err := c.EC2.AssociateAddress(&ec2.AssociateAddressInput{
|
|
AllocationId: aws.String(allocID),
|
|
InstanceId: aws.String(pubInstanceID),
|
|
})
|
|
require.NoError(t, err, "associate-address")
|
|
eipAssocID := aws.StringValue(assocOut.AssociationId)
|
|
require.NotEmpty(t, eipAssocID, "AssociationId empty")
|
|
disassociated := false
|
|
t.Cleanup(func() {
|
|
if disassociated {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DisassociateAddress(&ec2.DisassociateAddressInput{AssociationId: aws.String(eipAssocID)})
|
|
})
|
|
harness.Detail(t, "assoc", eipAssocID)
|
|
|
|
// OVN needs a beat to install the DNAT/SNAT flows after the
|
|
// association publishes; poll the SSH handshake rather than
|
|
// asserting immediately.
|
|
harness.Step(t, "ssh to guest via EIP %s", eipIP)
|
|
if !trySSHReady(eipIP, 22, keyPath, sshReadyBudget) {
|
|
harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID,
|
|
fmt.Sprintf("EIP SSH timeout — eip=%s instance=%s", eipIP, pubInstanceID),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: eipIP,
|
|
LogicalIP: pubPrivIP,
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
t.Fatalf("guest unreachable via EIP %s within %s (see diagnostics above)", eipIP, sshReadyBudget)
|
|
}
|
|
tgt := harness.SSHTarget{User: "ubuntu", Host: eipIP, Port: 22, KeyPath: keyPath}
|
|
idOut := runSSH(t, tgt, "id")
|
|
require.Containsf(t, idOut, "ubuntu", "ssh via EIP id did not report ubuntu\n%s", idOut)
|
|
harness.Detail(t, "datapath", "eip_reachable_ok")
|
|
|
|
harness.Step(t, "disassociate-address %s", eipAssocID)
|
|
_, err = c.EC2.DisassociateAddress(&ec2.DisassociateAddressInput{AssociationId: aws.String(eipAssocID)})
|
|
require.NoError(t, err, "disassociate-address")
|
|
disassociated = true
|
|
|
|
// The EIP no longer maps to the guest, but DNAT teardown can lag and
|
|
// a single transient SSH error must not be read as teardown —
|
|
// require several consecutive unreachable probes before declaring
|
|
// success.
|
|
harness.Step(t, "verify guest unreachable via EIP %s after disassociate", eipIP)
|
|
const wantUnreachable = 3
|
|
consecutive := 0
|
|
harness.EventuallyErr(t, func() error {
|
|
if _, sErr := runSSHQuiet(tgt, "true"); sErr == nil {
|
|
consecutive = 0
|
|
return fmt.Errorf("EIP %s still reaches guest after disassociate", eipIP)
|
|
}
|
|
if consecutive++; consecutive < wantUnreachable {
|
|
return fmt.Errorf("EIP %s unreachable %d/%d — confirming it is not a transient blip",
|
|
eipIP, consecutive, wantUnreachable)
|
|
}
|
|
return nil
|
|
}, 90*time.Second, 5*time.Second)
|
|
|
|
// Rule out an instance-death false pass: the unreachability must be
|
|
// the EIP teardown, not the subject VM having gone away mid-poll.
|
|
harness.WaitForInstanceState(t, c, pubInstanceID, "running", harness.WithTimeout(30*time.Second))
|
|
harness.Detail(t, "datapath", "eip_unreachable_after_disassociate_ok")
|
|
|
|
harness.Step(t, "release-address %s", allocID)
|
|
_, err = c.EC2.ReleaseAddress(&ec2.ReleaseAddressInput{AllocationId: aws.String(allocID)})
|
|
require.NoError(t, err, "release-address")
|
|
eipReleased = true
|
|
})
|
|
}
|
|
|
|
// waitForNATGatewayState polls DescribeNatGateways until State == target.
|
|
func waitForNATGatewayState(t *testing.T, c *harness.AWSClient, id, target string) {
|
|
t.Helper()
|
|
var lastState string
|
|
harness.EventuallyErr(t, func() error {
|
|
out, err := c.EC2.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
|
NatGatewayIds: []*string{aws.String(id)},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("describe-nat-gateways %s: %w", id, err)
|
|
}
|
|
if len(out.NatGateways) == 0 {
|
|
// "deleted" is a terminal state that DescribeNatGateways can
|
|
// stop returning once the record is reaped — treat as success
|
|
// only when that's what the caller wanted.
|
|
if target == "deleted" {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s not found", id)
|
|
}
|
|
lastState = aws.StringValue(out.NatGateways[0].State)
|
|
if lastState == target {
|
|
return nil
|
|
}
|
|
if lastState == "failed" {
|
|
msg := aws.StringValue(out.NatGateways[0].FailureMessage)
|
|
return fmt.Errorf("%s entered failed state: %s", id, msg)
|
|
}
|
|
return fmt.Errorf("%s state=%s want=%s", id, lastState, target)
|
|
}, 5*time.Minute, 2*time.Second)
|
|
t.Logf("nat gateway %s reached state %s", id, target)
|
|
}
|
|
|
|
// waitForNATGatewayStateSoft is the cleanup-time variant: never calls
|
|
// t.Fatal, just polls best-effort with a caller-supplied timeout.
|
|
func waitForNATGatewayStateSoft(c *harness.AWSClient, id, target string, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
out, err := c.EC2.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
|
NatGatewayIds: []*string{aws.String(id)},
|
|
})
|
|
if err == nil {
|
|
if len(out.NatGateways) == 0 && target == "deleted" {
|
|
return nil
|
|
}
|
|
if len(out.NatGateways) > 0 && aws.StringValue(out.NatGateways[0].State) == target {
|
|
return nil
|
|
}
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("nat gw %s did not reach %s within %s", id, target, timeout)
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
}
|
|
|
|
// waitForInstanceStateSoft is the cleanup-time analogue of
|
|
// harness.WaitForInstanceState — no t.Fatal, just polls and returns.
|
|
func waitForInstanceStateSoft(c *harness.AWSClient, id, target string, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
|
InstanceIds: []*string{aws.String(id)},
|
|
})
|
|
if err == nil && len(out.Reservations) > 0 && len(out.Reservations[0].Instances) > 0 {
|
|
if aws.StringValue(out.Reservations[0].Instances[0].State.Name) == target {
|
|
return nil
|
|
}
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("instance %s did not reach %s within %s", id, target, timeout)
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
}
|
|
|
|
// scpKey copies the harness PEM to /tmp/key.pem on the bastion so it can
|
|
// hop into a guest that has no direct SSH ingress of its own.
|
|
func scpKey(t *testing.T, keyPath, host string) {
|
|
t.Helper()
|
|
args := []string{
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "LogLevel=ERROR",
|
|
"-o", "ConnectTimeout=10",
|
|
"-o", "BatchMode=yes",
|
|
"-i", keyPath,
|
|
keyPath,
|
|
"ubuntu@" + host + ":/tmp/key.pem",
|
|
}
|
|
cmd := exec.Command("scp", args...)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("scp %s -> %s:/tmp/key.pem: %v\n%s", keyPath, host, err, string(out))
|
|
}
|
|
}
|
|
|
|
// sshQuietHardTimeout hard-caps one runSSHQuiet call. ConnectTimeout bounds only
|
|
// the TCP connect; a post-connect banner/kex wedge (e.g. probing an EIP whose
|
|
// DNAT was just torn down) is otherwise unbounded and can outlast the caller's
|
|
// EventuallyErr budget. The ctx deadline kills ssh regardless of which phase hangs.
|
|
const sshQuietHardTimeout = 30 * time.Second
|
|
|
|
// runSSHQuiet is the EventuallyErr-friendly variant of runSSH: it returns
|
|
// stdout+stderr and the error rather than calling t.Fatal, so polling
|
|
// loops can iterate on transient failures (cloud-init not done, OVN
|
|
// flow not installed yet, etc).
|
|
func runSSHQuiet(tgt harness.SSHTarget, command string) (string, error) {
|
|
args := []string{
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "LogLevel=ERROR",
|
|
"-o", "ConnectTimeout=5",
|
|
"-o", "BatchMode=yes",
|
|
"-p", strconv.Itoa(tgt.Port),
|
|
"-i", tgt.KeyPath,
|
|
tgt.User + "@" + tgt.Host,
|
|
command,
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), sshQuietHardTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "ssh", args...)
|
|
cmd.WaitDelay = 5 * time.Second // return even if ssh ignores the kill
|
|
out, err := cmd.CombinedOutput()
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return string(out), fmt.Errorf("ssh %s@%s:%d timed out after %s: %w",
|
|
tgt.User, tgt.Host, tgt.Port, sshQuietHardTimeout, ctx.Err())
|
|
}
|
|
return string(out), err
|
|
}
|