mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-16 01:22:00 +00:00
fd3dc6b680
* refactor(vpcd): remove macvlan bridge mode
Macvlan was a single-NIC SSH-safe hack that predated veth mode and was
never selected by any active config path. Deletion is Phase 0 item 1 of
the network layer redesign (mulga-siv-125.1).
Removed:
- BridgeModeMacvlan const + setMacvlanMAC + ifaceIsMacvlan probe
- Macvlan MAC alignment block in launchService (only fired on dead path)
- Macvlan branch in detectBridgeMode + matching tests
- Macvlan duplicates of IGW/AddNAT centralized-NAT tests (veth versions
already cover the same path)
- --macvlan flag + macvlan case branch in setup-ovn.sh
Refactored:
- useCentralizedNAT() now == BridgeModeVeth (was != BridgeModeDirect);
verifyBridgeMode already rejects empty/unknown at startup
- Three IGW-pool tests now explicitly WithBridgeMode(BridgeModeVeth)
(previously relied on empty-mode defaulting to centralized)
Kept intentionally:
- Deprecation notes in docs/compute/vpc-networking/README.md
- Legacy macvlan-interface cleanup in reset-dev-env.sh + ansible
teardown role (migration for operator systems with stale spx-ext-*
interfaces from pre-veth installs)
* feat(vpcd): resolve external CIDR at startup with bounded retry
Block service start until the WAN bridge has an IPv4 address, with a 30s
retry window. Phase 0 item 2 of the network layer redesign
(mulga-siv-125.1). Guards the boot race where vpcd starts before
systemd-networkd finishes DHCP on br-wan, or before netplan applies a
static config.
Skipped when ExternalMode is empty (overlay-only deployments).
Forward-compatible with ADR-0006: the new resolveExternalCIDR /
externalCIDRFromBridge helpers become the backing implementation of
HostWiring.ExternalCIDR() once the network/host package lands in Phase 1.
Until then the resolved value is only used to fail-start on missing
uplink.
* fix(vpcd): cover external CIDR startup paths
Extract launchService's CIDR-resolve block to ensureExternalCIDRReady
helper so it's unit-testable, and add direct tests for
externalCIDRFromBridge (loopback + unknown-interface) to exercise the
original literal body. Closes the diff-coverage gap on Phase 0 commit
15b564d which left these paths unreachable from unit tests.
* refactor(network/ovn): extract L1 OVN client to network/ovn package
Move OVNClient interface, LiveOVNClient, ACLSpec, ErrNATNotFound and
namedUUID helper from services/vpcd to a new spinifex/network/ovn
package. The mock relocates to spinifex/network/ovn/mock as a
sub-package. Storage fields on the mock become exported so vpcd tests
can continue to seed/inspect state across the new package boundary.
services/vpcd retains thin type-alias and var-alias shims so existing
callers compile unchanged; Phase 2 removes those shims as vpcd is torn
down.
Adds -coverpkg=./spinifex/... to test-cover so cross-package test
exercise of mock methods continues to count toward coverage now that
the mock lives outside vpcd.
First sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2). Pure refactor; no functional change. All
existing tests pass; preflight green.
* feat(network/topology): TopologyManager interface + vpcd adapter
Add spinifex/network/topology with Manager interface, VPC/Subnet/Port
specs, and OVN object naming helpers covering the L2 stable contract
(§7 of the redesign plan). Phase 1 keeps existing in-tree OVN names
(rtr-, ext-port-, sg_) and notes the §7.3 targets for a later
reconciliation migration.
TopologyHandler in services/vpcd grows EnsureVPC / DeleteVPC /
EnsureSubnet / DeleteSubnet / EnsurePort / DeletePort /
SetPortSecurityGroups methods that mirror the corresponding NATS
handlers' OVN ops with Spec inputs instead of nats.Msg envelopes,
satisfying topology.Manager via a compile-time check. Existing handlers
are unchanged; the duplication is short-lived (Phase 2 collapses them).
Smoke tests in topology_manager_test.go cover the idempotent create
path, delete cascade, and declarative SG membership move. Deliverable:
TopologyManager.EnsureVPC/Subnet/Port callable; all existing tests
green.
Second sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2).
* feat(network/host): L0 HostWiring interface + physical/veth impls
Phase 1 deliverable for mulga-siv-125.2. Introduces network/host (L0 of
ADR-0006): UplinkMode enum, Wiring interface, and Physical/Veth
implementations with injectable Runner + InterfaceReader for testability.
Not yet wired into daemon/vpcd — Phase 2 replaces daemon.SetupComputeNode
and vpcd's resolveExternalCIDR with calls through this package.
* feat(network/policy): L3 SecurityGroup/NAT/Route managers
Introduce network/policy as the data-plane policy layer (ADR-0006 L3).
Three managers, all backed by network/ovn:
- SecurityGroupManager: clear+add ACL replacement on an existing port
group; port group lifecycle stays with L2 (topology.Manager).
- NATManager: bound to one NATMode at construction (derived from
host.UplinkMode). Distributed sets logical_port/external_mac on EIP
dnat_and_snat rules; centralised leaves them unset. Per-EIP, NAT GW,
and IGW-default SNAT entrypoints; all deletes idempotent.
- RouteManager: AddStaticRoute is read-then-decide so retries no longer
duplicate rows (closing the long-standing non-idempotency bug in
vpcd/topology.go's IGW default-route path).
ACL priority constants and infrastructure ACL builders move from
vpcd/{security,acl}.go into policy/acl.go with their semantics preserved
(default-deny 900/800 logged, DHCP-allow 1050, tenant 1000). No vpcd
wiring changes here — additive only; the reconciler (bead .3.2) and
network/external (bead .3.4) will consume these managers.
bead: mulga-siv-125.3.1
* feat(network/external): L5 IGW/EIP/NATGW managers
Pure additive — new package, no topology.Manager changes, no rewiring of
existing vpcd handlers. IGWManager builds the external switch + localnet +
gateway LRP + default route + chassis HA, with a flows barrier callers can
inject (production: ovn-nbctl --wait=hv sync). EIP/NATGW are thin L5
wrappers over policy.NATManager. GatewayIPAllocator interface lets the
DHCP-backed allocator stay in vpcd until the DHCP-mgr removal bead lands;
LinkLocalAllocator + StaticRangeAllocator ship here.
* feat(network/reconcile): L6 intent-actual reconciler replaces 5-pass startup
Build spinifex/spinifex/network/reconcile/ package per Phase 2.2 of the
network layer redesign. Reads desired state from NATS KV filtered by
vpc.AZ (with legacy empty-AZ matches-all rule), scans actual state from
OVN NB DB, diffs and applies with VPC → Subnet → Port → SG → IGW →
EIP/NATGW topological ordering.
Atomic port-group join via CreateLogicalSwitchPortInGroups closes the
racy gap the legacy scanENIPortMembership pass used to revive 30s later.
A 5-minute drift goroutine (leader-gated via JetStream KV CAS) replaces
the old 30s ReconcileSGsLoop.
Adds AZ field to VPCRecord stamped at create time. Empty AZ on pre-Phase
2.2 records matches every local AZ, so no migration pass is required.
Replaces and removes the 5-pass startup sequence in services/vpcd:
- Reconcile + ReconcileFromKV
- ReconcileSGsOnce + ReconcileSGsLoop
- RetrofitAllExternalLocalnetOptions
- RetrofitAllGatewayPortNetworks
IGW fresh-create continues to flow through the legacy NATS handler path
(handleIGWAttach) so the dhcpManager wiring stays intact until Phase
2.3; the reconciler only rebinds gateway-chassis on existing IGW
topology.
Closes mulga-siv-125.3.2.
* refactor(network/topology): promote live Manager impl to network/topology
Move the topology.Manager implementation out of services/vpcd/topology_manager.go
into network/topology/manager_live.go as a standalone liveManager constructed
via NewLiveManager(ovn.Client, opts...). Inputs that varied per-handler
(DNS server resolution from the external pool) are now passed as functional
options (WithDNSServer).
TopologyHandler still satisfies topology.Manager but its methods delegate
to a cached liveManager. The shim stays until the rest of mulga-siv-129
routes NATS subscribers through topology.Manager directly.
Also exports SubnetGatewayCIDR + FormatDNSServerList from network/topology
so the reconciler in network/reconcile can share helpers with the live
manager (audit pass to follow per plan §6).
Test cases ported one-for-one from services/vpcd/topology_manager_test.go
to network/topology/manager_live_test.go; the vpcd-side test file is
deleted. New WithDNSServer test asserts the option propagates into
DHCPOptions rows.
Refs mulga-siv-129 (Phase 2.6 vpcd subscriber migration, Slice 1 of N).
* refactor(vpcd): collapse SG subscribers onto network/{topology,policy} managers
Phase 2.6 / mulga-siv-129 slice 2. Rewrites the vpc.create-sg /
vpc.update-sg / vpc.delete-sg NATS subscribers in services/vpcd as
thin wrappers over topology.Manager (port group lifecycle) and
policy.SecurityGroupManager (ACL programming). Deletes the ~1000
lines of duplicated ACL builder and provisioning logic — its
authoritative copy lives in network/policy.
Surface additions:
- ovn.ErrPortGroupNotFound sentinel; mock + live GetPortGroup wrap
not-found via fmt.Errorf("%w: ...") so callers can use errors.Is.
- topology.Manager grows EnsureSGPortGroup / DeleteSGPortGroup
(resolves the apply.go TODO from §2.1 of the redesign plan).
liveManager implements them with GetPortGroup-then-Create /
GetPortGroup-then-Clear+Delete idempotency.
Behaviour delta: the old provisionSG rolled back the OVN port group
on AddACLs failure ("all-or-nothing"). The new path lets the port
group survive and relies on network/reconcile to retry EnsureSG on
its next sweep. The all-or-nothing fail-fast test cases are dropped;
ACL emission semantics are covered by network/policy/sg_test.go.
* refactor(vpcd): migrate VPC/Subnet/ENI subscribers to topology.Manager
Phase 2.6 / mulga-siv-129 slice 3. Rewrites vpc.create / vpc.delete /
vpc.create-subnet / vpc.delete-subnet / vpc.create-port /
vpc.delete-port / vpc.update-port-sgs NATS subscribers as thin
wrappers (≤30 lines each) over topology.Manager. Subscriber bodies
now: parse event → translate to spec → delegate to manager.
Deletes:
- vpcd.subnetGateway (and helpers_test.go + topology_test.go's
TestSubnetGateway block); parity coverage lives in
network/topology.TestSubnetGatewayCIDR.
- vpcd.reconcilePortSGs (the live manager already exposes the same
diff-then-transact via reconcilePortSGs on the L2 side).
- vpcd.TopologyHandler.dnsServer; lmDNSServer (passed via
topology.WithDNSServer to the live manager) is the live path.
Behaviour delta: handlers now strict-parse PrivateIpAddress /
MacAddress / CidrBlock at the NATS boundary and reject malformed
payloads instead of degrading to "no DHCP options". The reconciler
guarantees that field validity is upheld upstream of the publish.
* refactor(network/reconcile): drive SG port groups via topology.Manager
applySGs now calls topology.Manager.EnsureSGPortGroup /
DeleteSGPortGroupByName instead of reaching directly into the OVN client.
Reconcile.Config gains a Topology field (required); the production caller
in services/vpcd passes the TopologyHandler shim, the reconcile package
tests construct a fresh topology.NewLiveManager bound to the mock OVN
client.
DeleteSGPortGroupByName is added to the topology.Manager interface for the
reconciler's orphan-removal path, which iterates raw OVN port-group names
without a groupID round-trip. The existing DeleteSGPortGroup(groupID)
delegates to it.
Resolves the TODO at apply.go:137 carried over from slice 2 of
mulga-siv-129.
* feat(network/policy): add FlowsBarrier option to NATManager
NATManager.AddEIP needs to block on `ovn-nbctl --wait=hv sync` after
writing the dnat_and_snat row so the NATS reply only returns once every
chassis has the rewrite flow. The legacy vpc.add-nat handler does this at
the tail of handleAddNAT; the manager has lacked it since slice 2.1.
NewNATManager grows a functional-options tail (Option / WithFlowsBarrier).
Defaults to a no-op for tests; production wiring in services/vpcd passes
waitForFlowsHV. AddEIP now invokes the barrier after AddNAT, logging at
warn level on failure to match the legacy handler's _ = waitForFlowsHV()
swallowing semantics.
Pre-work for slice 4 of mulga-siv-129 — without the barrier the NAT EIP
subscriber migration would lose the post-write flow-install gate.
* refactor(vpcd): migrate NAT GW subscribers to policy.NATManager
handleAddNATGateway / handleDeleteNATGateway now decode the NATS event
and delegate to policy.NATManager.{AddNATGateway,DeleteNATGateway}. The
manager carries identical OVN logic — same snat type, same ExternalIDs
(spinifex:vpc_id + spinifex:nat_gateway_id) — so existing
TestTopologyHandler_AddNATGateway / DeleteNATGateway suites stay green.
Lazy-constructed NATManager added on TopologyHandler (natmOnce). Mode is
derived from the bridge mode (veth → centralized, direct → distributed)
and the FlowsBarrier is bound to waitForFlowsHV for parity with the
legacy EIP path's tail barrier (used by the next slice, vpc.add-nat).
Slice 4 of mulga-siv-129, sub-step b.
* refactor(vpcd): migrate EIP NAT subscribers to policy.NATManager
handleAddNAT / handleDeleteNAT now decode the NATS event and delegate to
policy.NATManager.{AddEIP,DeleteEIP}. The behavioural contract is
preserved end-to-end:
- Idempotency-skip on a matching dnat_and_snat row (same VPC + LogicalIP,
plus MAC + LogicalPort under distributed NAT) moves into AddEIP so
duplicate vpc.add-nat publishes never tear down the rule the previous
invocation just installed (mulga-siv-124).
- Stale-rule cleanup across every router stays in AddEIP.
- Post-write waitForFlowsHV barrier is now driven by the manager via the
FlowsBarrier option wired in services/vpcd (slice 4a).
- DeleteEIP returns nil on ErrNATNotFound, matching the handler's
fire-and-forget "already absent = success" semantic.
The vpcd "errors" import drops out with the inline error-comparison.
Slice 4 of mulga-siv-129, sub-step c.
* refactor(vpcd): migrate IGW subscribers to external.IGWManager (partial)
handleIGWAttach now branches at decode time:
- Centralised NAT + DHCP-sourced pool keeps using attachIGWLegacy because
the gateway-LRP IP comes from vpc.dhcp.acquire, and that DHCP client is
the vpcd-local manager that bead mulga-siv-125.3.3 will retire.
- All other shapes (distributed NAT, static-range pool, no pool)
delegate to external.IGWManager.AttachIGW. The manager mirrors every
step the legacy handler ran — external switch + localnet port +
gateway LRP + switch-side router peer + default route + chassis HA +
waitForFlowsHV tail barrier — so observable behaviour is unchanged.
handleIGWDetach drops its inline OVN walk entirely; both paths exit
through external.IGWManager.DetachIGW followed by releaseGatewayLRPLease.
The lease release stays in vpcd because the upstream client lives in
services/vpcd/dhcp; calling it after every detach is safe because the
upstream server expires unknown leases on its own.
Lazy-constructed IGWManager added on TopologyHandler (igwmOnce). Pool,
allocator (StaticRangeAllocator), chassis, NAT mode, NAT manager, route
manager, and FlowsBarrier are all derived from the existing
TopologyHandler state.
The DHCP-coupled legacy branch retains its full inline OVN logic; it
will disappear in slice-after-125.3.3 follow-on. Slice 4 of
mulga-siv-129, sub-step d.
* feat(ec2/instance): honor AssociatePublicIpAddress per-NIC override
RunInstances --network-interfaces "AssociatePublicIpAddress=true|false"
was silently ignored, only subnet.MapPublicIpOnLaunch gated public IP
assignment. AWS contract is per-NIC override wins.
Extend TestPrepareRunInstances_PublicIPAutoAssigned to table-driven
3 cases: subnet true no-override, subnet false override true, subnet
true override false.
* refactor(config): drop upstream-DHCP config surface (static WAN pool only)
Hard-error in LoadConfig on legacy keys: [network] external_dhcp,
[nodes.<n>.vpcd] dhcp_bind_bridge, and [[network.external_pools]]
source = "dhcp". Add pool range validation (must fit inside the
gateway/prefix_len CIDR; per-cluster non-overlap).
Strip ExternalDHCP from config.NetworkConfig + formation.NetworkConfig
+ admin.ConfigSettings, and DhcpBindBridge from config.VPCDConfig +
admin.ConfigSettings + vpcd.Config. vpcd.ExternalPoolConfig.IsDHCP()
short-circuits to false; the field and downstream DHCP branches in
external_ipam / topology.go remain for Slice C to delete together with
dhcp_manager.
cmd/spinifex/cmd/admin.go: drop useExternalDHCP path and the
detectedDhcpBindBridge helper; require --external-pool when
--external-mode=pool; auto-detect default no longer infers source=dhcp.
service.go's wan_bridge legacy-key rejection now advises removing the
key entirely (vpcd auto-detects br-wan), not renaming to
dhcp_bind_bridge.
Two topology_test cases that exercise the DHCP gateway-LRP path are
skipped — Slice C deletes attachIGWLegacy and these tests together.
* refactor(vpcd): delete dhcp_manager + DHCP gateway-LRP path (Slice C)
Removes upstream-DHCP-client gateway-LRP allocation in favor of static
WAN-pool allocation (StaticRangeAllocator). Part of bead mulga-siv-125.3.3.
- delete services/vpcd/dhcp_manager.go + dhcp/ subpackage (~865 lines)
- delete allocateGatewayLRPIPViaDHCP + releaseGatewayLRPLease paths
- drop ExternalPoolConfig.Source + DhcpBindBridge + IsDHCP() fields
- drop ExternalIPAllocation lease fields (Expiry/ServerID/HWAddr) and
ExternalIPAMRecord.Source
- drop NewExternalIPAM nc *nats.Conn argument; update all 4 call sites
- rename verifyBridgeMode local dhcpBindBridge -> wanBridge; update error
strings and tests
Slice D (delete attachIGWLegacy + dispatcher) intentionally deferred.
* refactor(vpcd): delete attachIGWLegacy + IsDHCP stub (Slice D)
Closes mulga-siv-129 slice 4d. Part of bead mulga-siv-125.3.3.
handleIGWAttach now unconditionally delegates to external.IGWManager.
attachIGWLegacy (~207 lines, ~5 OVN write steps + chassis scheduling)
deleted along with its dispatcher branch in handleIGWAttach. The
ExternalPoolConfig.IsDHCP() dead stub is gone.
No code paths or NATS subjects regress: the static-pool / distributed
branches were already going through external.IGWManager since Slice 4d
(6719dc3).
* feat(ipam): Purpose field on every IPAM record (Slice B)
Part of bead mulga-siv-125.3.3.
Adds a Purpose enum (handlers/ec2/vpc/purpose.go) tagging every IP
allocation with its semantic role (eni-primary, eni-public, eip,
igw-lrp, natgw-external, …) so multi-VPC clusters can reclaim/audit
by (owner, purpose).
External IPAM (spinifex-external-ipam, v1→v2):
- ExternalIPAllocation.Type → .Purpose; legacy values mapped via
LegacyExternalTypeToPurpose (gateway→igw-lrp, auto_assign→eni-public,
elastic_ip→eip). Unknown legacy values warn and become "unknown".
- AllocateIP / AllocateFromPool / allocateFromPool param rename
allocType→purpose. All call sites pass Purpose* constants.
- migrate/002_external_ipam_purpose.go: idempotent CAS-retry migration
with v1/v2-union record shape so partial migrations resume cleanly.
Internal IPAM (spinifex-vpc-ipam, v1→v2):
- IPAMRecord.Allocated []string → []IPEntry{IP, Purpose, OwnerID}.
- AllocateIP gains purpose+ownerID args; ReleaseIP unchanged (still
keyed on IP).
- AllocatedIPs returns []IPEntry.
- eni.go reorders eniId generation before AllocateIP so the owning
resource ID lands on the new entry.
- migrate/003_ipam_purpose.go: cross-bucket backfill that reads the
ENI bucket via the new RunKVWithJetStream path. Orphaned IPs
(no owning ENI) warn and become Purpose=unknown.
Migrate framework:
- KVContext gains an optional JetStream field; new
Registry.RunKVWithJetStream method threads it through. RunKV
signature unchanged — all other callers are unaffected.
Tests:
- ipam_test.go: AllocateIP signature updates; AllocatedIPs now asserts
IP / Purpose / OwnerID on the returned entries.
- external_ipam_test.go: replaces "auto_assign"/"elastic_ip" literals
with PurposeENIPublic/PurposeEIP and asserts alloc.Purpose.
- migrate/002 + 003 tests: schema-detect, idempotent rerun,
no-JetStream fallback, empty bucket.
* refactor(daemon): tombstone obsolete spinifex-dhcp-leases KV bucket
The dhcp_manager removed in Phase 2.3 (bead mulga-siv-125.3.3) owned the
spinifex-dhcp-leases KV bucket. The migrate framework only handles
per-bucket schema version bumps, not bucket deletion, so the obsolete
bucket would linger on upgraded clusters.
Add a one-shot tombstone after initJetStream that deletes the bucket if
present. DeleteKVBucketIfExists treats ErrBucketNotFound and
ErrStreamNotFound as success, so the call is idempotent across restarts.
* feat(config): add network.ipsec_enabled cluster flag (default true)
Add IPSecEnabled to NetworkConfig in both the cluster TOML schema and
the formation handshake payload. Default true so AWS-parity intra-AZ
encryption is on by default. Operators can flip false in
[network] for trusted single-rack lab topologies.
Phase 2.5 (bead mulga-siv-125.3.5) consumes the flag from admin init
(strongSwan provisioning), formation server (propagates to joiners),
and daemon startup (ovs-vsctl ipsec_encapsulation toggle).
* feat(admin): IPsec peer cert generator signed by cluster CA
Add GenerateIPSecPeerCert + IPSecCertPaths in spinifex/admin so admin
init (and the future admin add-node path) can issue per-node IKEv2 peer
credentials signed by the existing cluster CA. The cert carries
id-kp-ipsecIKE (RFC 4945) so strongSwan accepts it as an IKEv2 peer
credential when ovs-monitor-ipsec materialises tunnel configs.
Cluster CA is reused directly — no intermediate strongSwan PKI today;
when intermediate CAs land that becomes a separate workstream.
The cert/key pair lives under {configDir}/ipsec/peer.{pem,key}; the
daemon points OVS Open_vSwitch.other_config (certificate, private_key,
ca_cert) at these paths when network.ipsec_enabled is true.
* feat(admin): wire IPsec peer cert into admin init + join paths
Add --ipsec bool flag (default true) on adminInitCmd. Single-node init
and the multi-node leader path both emit an IPsec peer cert after the
existing CA+server-cert generation. The multi-node formation server
propagates the IPSecEnabled flag to joining nodes via NetworkConfig;
admin join honours it and emits its own peer cert during the same
cert-issuance step that already produces the server cert.
spinifex.toml template gains an unconditional [network] block carrying
ipsec_enabled so the value is visible to operators on every install
regardless of whether external networking is configured. The daemon
reads it via cluster config (NetworkConfig.IPSecEnabled).
Bead mulga-siv-125.3.5.
* build: install openvswitch-ipsec for OVN native IPsec
openvswitch-ipsec provides ovs-monitor-ipsec, which reads the
Open_vSwitch.other_config cert/key/CA pointers and materialises
strongSwan configs for every Geneve tunnel managed by ovn-controller.
Pulls in strongswan + strongswan-pki as transitive deps.
Required by bead mulga-siv-125.3.5.
* feat(daemon): enable OVN native IPsec on intra-AZ Geneve at startup
When network.ipsec_enabled is true (default), the daemon points the
local OVS DB other_config at the per-node IPsec peer cert that admin
init / admin join provisioned (configDir/ipsec/peer.{pem,key} + the
cluster CA at configDir/ca.pem), then sets
other_config:ipsec_encapsulation=true.
ovs-monitor-ipsec (shipped by the openvswitch-ipsec package) consumes
those pointers and materialises strongSwan configs for every Geneve
tunnel ovn-controller programs — including OVN-IC inter-AZ tunnels
when federation lands in Phase 3a.
Missing cert files log a warning and leave Geneve plaintext; operators
upgrading an older cluster reissue certs via spx admin to close the
gap.
Bead mulga-siv-125.3.5.
* test(e2e): multinode IPsec wiring + xfrm SAs + ESP capture
TestMultinodeIPSec asserts:
- OVS DB on every node carries certificate/private_key/ca_cert pointers
and other_config:ipsec_encapsulation=true (what daemon.enableOVNIPSec
writes).
- ip xfrm state on every node has at least one AEAD SA in AES-GCM mode
(rfc4106(gcm(aes)) or gcm(aes)) within 90s.
- tcpdump catches ESP (ip proto 50) on the underlay (best-effort —
logged only; geneve may be idle during the 5s capture window).
Skips cleanly when the cluster was bootstrapped with --ipsec=false
(no ipsec_encapsulation key on first node).
Read-only over SSH; runs in the parallel bucket alongside the other
non-mutating multinode tests.
* fix(igw): write attachment State="attached" for AWS parity
Reconciler intent loader (network/reconcile/intent.go::loadIGWs) filters
IGW records with `State == "attached"`, but AttachInternetGateway was
writing `State = "available"`. The reconciler therefore never picked up
any attached IGW — `intent_igws=0` on every pass — so the OVN external
switch + gateway LRP + WAN bridge wiring built by IGWManager.AttachIGW
never ran for default-VPC IGWs (whose live `vpc.igw-attach` publish at
daemon bootstrap is lost — vpcd subscriber not yet wired).
Net effect on single-node e2e: every SSH probe to 192.168.x public IPs
timed out (TestSSHProbe, TestModifyInstanceAttribute, TestRebootInstance,
TestNATGateway, TestSGToSGDatapath) because SNAT/DNAT rules on the VPC
router had no path to the external network.
Match AWS InternetGatewayAttachment.State semantics:
- Attach: State="attached"
- Detach: State="detached"
Detach value is internal — recordToEC2 gates Attachments on VpcId != "",
so the detached State is never surfaced to API consumers.
Bead: mulga-siv-131
* fix(reconcile): call AttachIGW when IGW topology missing
applyIGWs previously deferred to the NATS handler when an intent IGW
had no external switch in OVN. When the vpc.igw-attach event is lost
(bootstrap-time default-VPC IGW published before vpcd subscriber is
ready), deferral never resolves and VM public-IP traffic dies.
Call IGWManager.AttachIGW directly — it is idempotent (short-circuits
when the external switch already exists), so the reconciler converges
default-VPC IGWs on first pass without duplicating work on subsequent
passes.
Closes mulga-siv-132.
* fix(test): align NewExternalIPAM call with 2-arg signature
Rebase onto dev pulled in daemon_system_instance_test.go which used
the legacy 3-arg NewExternalIPAM(jsNC, js, pools). The feature branch
already narrowed the signature to (js, pools) via the upstream-DHCP
config drop; align the test with the current signature.
* fix(vpcd): EnsureSubnet must EnsureVPC first to avoid NATS race
vpc.create-vpc and vpc.create-subnet are independent NATS events with
no inter-event ordering guarantee. Tenant bootstrap on iam.account.created
publishes both within <1ms, so the subnet handler's
CreateLogicalRouterPort can race the parent VPC's CreateLogicalRouter and
fail with "logical router not found". The cascade fails CreateNetworkInterface
which fails RunInstances which surfaces as InsufficientInstanceCapacity to
the SDK — masking the underlying topology race.
handleSubnetCreate now calls EnsureVPC(VPCID-only) before EnsureSubnet.
EnsureVPC short-circuits on existing LR (idempotent), so the race-winning
vpc.create-vpc handler's full CIDR/VNI ExternalIDs are preserved; if subnet
wins the race the LR is created with bare ExternalIDs (cosmetic) and the
follow-on vpc.create-vpc short-circuits.
Adds TestIntegration_SubnetBeforeVPC regression covering both orderings.
Closes mulga-siv-133.
* feat(ansible): require --external-pool, drop NAT, forward EXTRA_VARS
spx admin init now rejects --external-mode=nat and demands --external-pool
when mode=pool. The ansible init role silently skipped pool flags when the
range was unset, so installs fell over inside the spx validator. Make
targets also swallowed `-e` (make's env-override flag), so operators could
not override pool start/end without editing defaults.yml.
- vars/defaults.yml: rename nat_gateway_ip → external_gateway_ip; add
spinifex_external_no_external opt-out; document the spx flag contract.
- roles/init/tasks/main.yml: drop NAT branch; assert pool range upfront
with a fail_msg pointing at EXTRA_VARS / defaults.yml; emit gateway,
prefix-len, gateway-ip only when explicitly set (let spx auto-detect);
honour --no-external.
- playbooks/dev-reset.yml: rename captured var; coerce legacy
external_mode=nat to pool with a warn; tighten gateway regex with \b.
- Makefile: ansible-dev-* targets now forward EXTRA_VARS="key=val ..."
through --extra-vars so `make` callers can override without editing
defaults.yml.
- scripts/ansible/README.md: document pool requirement, both invocation
styles (direct -e vs make EXTRA_VARS), NAT removal.
* fix(ipsec): guard ipsec_encapsulation on ovs-monitor-ipsec liveness
enableOVNIPSec set OVS cert pointers + ipsec_encapsulation=true without
ensuring ovs-monitor-ipsec.service was running. Without that daemon, OVS
holds the cert pointers unused, strongSwan never gets IKE config, and
tunnel traffic ovn-controller marks for encryption gets silently
dropped. TestMultinodeIPSec fails on `node1 xfrm has no AEAD SAs`.
- systemd: spinifex-daemon Wants=/After= ovs-monitor-ipsec.service
- daemon: ensureOVSMonitorIPSecActive — systemctl enable --now +
is-active poll; refuses to flip ipsec_encapsulation if daemon is dead
- daemon: single-node short-circuit — no peers means no tunnels means
the flag is at best inert and at worst load-bearing-silent-drop
- ci: capture-net-diag.sh + workflow steps to snapshot OVS/OVN/xfrm
state on failure, fan out across all 3 multinode nodes
Refs: mulga-siv-136
* test(e2e): sticky-skip downstream SSH tests when probe fails
When TestSSHProbe times out (3 min, typically because of mulga-siv-134
datapath bug), four downstream tests each re-probe SSH on the same
broken datapath and time out identically — single-node job runs ~24 min
instead of ~12 min, with the same error logged five times.
First waitForSSHReady timeout now sets a package-level atomic flag;
subsequent waitForSSHReady / trySSHReady / requireSSHHealthy calls skip
immediately. Preserves the original red signal on TestSSHProbe and on
any test that genuinely needed SSH (Skip ≠ Pass). Bool variant
(trySSHReady) consults but does not set the flag — its caller already
treats False as advisory warning, not fatal.
TestSGToSGDatapath has its own EventuallyErr SSH wait (not via
waitForSSHReady) and needs an explicit requireSSHHealthy(t) gate at
the top of runSGToSGDatapath. TestNATGateway already calls
waitForSSHReady first so the gate fires naturally.
Refs: mulga-siv-137
* fix(ipsec): use Debian unit name openvswitch-ipsec.service
CI artifact shows `Unit ovs-monitor-ipsec.service could not be found`
on every node. Debian's openvswitch-ipsec package ships the unit as
openvswitch-ipsec.service (the binary inside is ovs-monitor-ipsec, but
systemd's name follows the package). The previous commit guarded on
the wrong unit name, so:
- spinifex-daemon.service Wants=/After= silently no-op'd (missing unit)
- enableOVNIPSec's `systemctl enable --now ovs-monitor-ipsec.service`
failed, daemon logged a warning and continued without flipping
ipsec_encapsulation=true
- TestMultinodeIPSec skipped with `node1 reports ""` because OVS DB
never received the cert pointers
Rename across all 5 spots (daemon, systemd unit, test, CI diag script,
workflow journal capture) to match the Debian package layout.
Note: the package itself still must be present on the CI VM image —
that's tracked in the mulga repo (image-builder provision.sh +
bootstrap-install.sh shim).
Refs: mulga-siv-136
* fix(e2e): authorize default SG tcp/22 + icmp in single-node fixture
admin init leaves the default SG closed (AWS parity); the Go single suite
never ported the Phase-5 SG-ingress block that run-e2e.sh has had since day
one, so every test runner SSH/ping probe hits the OVN port-group ACL drop.
- ec2helpers: add AuthorizeICMPIngress + EnsureDefaultSGOpen helpers.
- single/main_test: open default SG once per process in pkgFixOnce.Do.
- witness: extend ensureDefaultSGSSHIngress to authorize ICMP too.
- run-e2e.sh: append matching ICMP authorize next to tcp/22 block.
mulga-siv-134
* fix(network/reconcile): key DHCP_Options lookup by subnet_id not CIDR
Multi-tenant default VPCs share the same subnet CIDR (172.31.0.0/20) per
account. The existing FindDHCPOptionsByCIDR gate at apply.go:107 found
the first account's DHCP_Options row and skipped creation for every
subsequent subnet with the same CIDR. Those subnets' ports then attached
nothing (FindDHCPOptionsByExternalID misses on subnet_id), VMs got no
DHCPv4 lease, and SSH/ICMP into them silently failed even though SG
ACLs and NAT were correct.
Switch the gate to FindDHCPOptionsByExternalID keyed on spinifex:subnet_id
so each subnet owns its own row, matching the attach lookup at line 203.
Verified: account 000000000001 default VPC RunInstances now produces an
LSP with dhcpv4_options populated, VM cloud-init reports a 172.31.0.5/20
lease + default route via 172.31.0.1, and ping + ssh TCP handshake to
the EIP succeed end-to-end. Stale DHCP_Options row for the prior dead
subnet was wiped manually; reconciler recreated the correct row for the
live subnet on its next pass.
* fix(ipsec): enable openvswitch-ipsec at provision time, drop daemon write
Provisioning now runs `systemctl enable --now openvswitch-ipsec.service`
alongside openvswitch-switch and ovn-controller. The spinifex-daemon
process drops its `enable --now` shell-out and only polls is-active.
Sudoers gains one read-only line:
spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/systemctl is-active openvswitch-ipsec.service
This mirrors the existing vpcd ovn-controller is-active precedent and
removes the daemon's need for systemd-write capability. Aligns with
least-privilege service account pattern: provision enables services,
daemon only reads status.
If the unit is inactive at runtime, the daemon refuses to flip
ipsec_encapsulation=true (silent-drop guard from mulga-siv-136) and
surfaces a clear error pointing at scripts/setup-ovn.sh.
* chore: unblock preflight on feat/network-layer-redesign
Two pre-existing failures blocked preflight on the branch:
- govulncheck flagged golang.org/x/net@v0.54.0 (GO-2026-5026, ASCII Punycode
rejection bug in idna.ToASCII); reached via utils.DownloadFileWithProgress.
Bump to v0.55.0; go mod tidy carries x/sys to v0.45.0.
- TestTopologyHandler_SubnetCreate_RouterPortFails asserted subnet create
fails when the parent VPC router is missing. That contract changed in
aed7b2e (mulga-siv-133), which made handleSubnetCreate call EnsureVPC
idempotently before EnsureSubnet to avoid a NATS publish-order race.
Delete the now-impossible failure case.
* refactor(network/ovn): relocate nbdb model package from services/vpcd
Phase 4 / mulga-siv-125.6 Slice A. Pure import rename, no logic change.
The OVN Northbound DB model (LogicalSwitch, LogicalRouter, DHCPOptions,
etc.) lived under services/vpcd/nbdb/ because that was the original home
of every OVN caller. After Phase 1+2 the L1 client (network/ovn) and all
L3/L5 managers (network/policy, network/external, network/topology,
network/reconcile) became the primary callers — 8 importers in network/*
vs 2 in services/vpcd/. The directory move puts the schema model where
its consumers are, and unblocks deleting services/vpcd/ entirely once
the subscriber/launcher relocations land (Slices B–D).
* refactor(vpcd): delete dead retrofit/reconcileIGW helpers + bridge-mode bleed
Phase 4 / mulga-siv-125.6 Slice B.
Production path no longer touches any of:
- RetrofitAllGatewayPortNetworks / RetrofitAllExternalLocalnetOptions —
the 5-pass startup leftovers that network/reconcile (Phase 2.2) made
redundant. Nothing called them outside tests.
- reconcileIGW + its allocateGatewayLRPIP / gwLrpRange helpers + the
ipv4ToUint32 / uint32ToIPv4 utilities — superseded by
external.IGWManager (Phase 2.4) which carries its own pool logic.
- ensureLocalnetOptions / ensureGatewayPortNetworks /
expectedGatewayPortNetwork — read-before-write retrofit shims for the
same code paths.
- generateMAC — only callers were reconcileIGW + its tests.
useCentralizedNAT (the bridge-mode bleed flagged in plan §2.2) is
replaced by a policy.NATMode value injected via WithNATMode(...). The
mode is resolved once in vpcd.go from host.UplinkMode and threaded into
both the topology-handler lazy managers and the reconciler stack, so
"is this veth?" is no longer a question topology.go can ask. The
TopologyHandler defaults to NATModeDistributed when WithNATMode is
omitted, matching the previous default.
Drops services/vpcd/topology.go from 1220 LOC to 693, deletes ~700 LOC
of obsolete tests (TestGenerateMAC, TestEnsureLocalnetOptions_*,
TestRetrofitAllExternalLocalnetOptions_FixesClearedOptions,
TestReconcileIGW_*, TestGwLrpRange), and frees the nbdb + utils +
net imports in topology.go.
Stale "phase 4 migrates those onto the new managers" comment in vpcd.go
fixed in passing — the subscribers were migrated in Phase 2.6
(mulga-siv-129); only the file-relocation parts of Phase 4 remain.
Stale ensureLocalnetOptions / ensureGatewayPortNetworks references in
network/ovn/{live.go,mock/mock.go} doc comments also cleaned up.
* refactor(network): move NATS VPC subscribers to network/subscribers
Phase 4 Slice C (mulga-siv-125.6). Promotes the NATS event handlers out of
services/vpcd into a new spinifex/network/subscribers package. Subscriber
holds the topology/policy/external managers directly — the lazy *Once
adapter shim (topology_manager.go) is deleted along with vpcd's local
event-struct copies.
launchService now constructs topology.LiveManager eagerly with a DNS
callback derived from external pools (preserving the unscoped-pool lookup
old TopologyHandler.lmDNSServer did), builds every manager up front, then
hands them to subscribers.New + Subscribe. reconcile.New receives
topology.Manager directly instead of through the adapter.
Bridge-mode constants and waitForFlowsHV move into vpcd.go alongside the
launcher; they were left behind as topology.go's last responsibilities.
Handler-layer tests (topology_test.go, security_test.go, integration_test.go)
are dropped: substance is covered by network/topology, network/policy,
network/external manager tests. subscribers/subscribers_test.go adds
focused coverage: missing-dep rejection, topic registration smoke, and
bad-JSON envelope per topic.
services/vpcd is now down to the launcher + the ovn/mock compat aliases
(removed in Slice D when the launcher promotes to cmd/vpcd/).
* refactor(vpcd): relocate services/vpcd → spinifex/vpcd; drop dead aliases
Phase 4 Slice D (mulga-siv-125.6). Final cleanup of the network-redesign
package layout. services/vpcd/ disappears entirely; the launcher remains
an in-process Service impl, just rehomed under spinifex/vpcd/ (no separate
binary — spx vpcd start stays the cobra entrypoint).
- Delete services/vpcd/mock_ovn.go + ovn.go (compat aliases now dead post
Slice C; only callsite was launchService's NewLiveOVNClient, inlined to
ovn.NewLiveClient).
- git-rename services/vpcd/vpcd.go + vpcd_test.go to spinifex/vpcd/.
- Retarget three importers (spinifex/service/service.go +
service_test.go, cmd/spinifex/cmd/service.go) to the new path.
- Fix stale services/vpcd / TopologyHandler comments in network/reconcile/
+ network/external/types.go + spinifex/vpcd/vpcd.go.
- service-interfaces.yaml vpcd path updated.
- Plan doc §13/§14/§16 + §9.2/§19 updated: drop cmd/vpcd/main.go (was
speculative; never built) in favour of in-process Service impl at
spinifex/vpcd/vpcd.go. Plan §16 Created list now lists subscribers/.
Phase 4 plan items 1+2 (remove services/vpcd, entrypoint location)
satisfied. Items 3+4 remain blocked on mulga-siv-125.4 + 125.5 (bundled
join token, plan archive).
* fix(ovn): install openvswitch-ipsec + strongswan explicitly
ovs-monitor-ipsec.service fails to start without an IKE daemon. The
openvswitch-ipsec package only Recommends strongswan, so any apt
config that drops Recommends (or a base image with the recommends
already skipped) leaves the service in a Restart loop with 'IKE
daemon is not installed'. Hoist both packages into setup-ovn.sh so
provisioning is the single source of truth — the bootstrap shim in
mulga is no longer needed.
* test(network): pin ADR-0006 invariants to executable suite
Add static-analysis + behavioural tests that bind every safety and
liveness clause in ADR-0006 (S1-S9, L1-L3) to a named Go test. Failure
output quotes the ADR clause verbatim and points at the offending
file:line so future debugging sessions can't quietly bypass a layer
contract to make a test green.
Static-analysis suite (spinifex/network/invariants/):
- S1 layers_test.go: parses `go list -json`; rejects upward layer
imports and layers pulling from cross-cutter packages
- S2 host_containment_test.go: AST-detects ovs-vsctl / ovn-nbctl /
ovs-ofctl shell-out outside network/host/
- S3 nat_mode_test.go: rejects .UplinkMode() / NATModeFromUplinkMode()
calls outside the init-time resolver locations
- S4 names_test.go: AST-detects OVN object name construction (concat,
fmt.Sprintf, strings.Join) outside topology/names.go
- S5 policy_no_create_test.go: rejects CreateLogical{Switch,Router,...}
and DeleteLogical{...} calls inside network/policy/
- S6 federation_isolation_test.go: rejects TransitSwitch /
TransitRouterPort references and ovn-ic literals outside
network/federation/ (pre-protects the layer before it lands)
- S8 ipsec_test.go: rejects strongswan / xfrm / IKEv2 / libreswan /
openswan imports anywhere in network/
- S9 reconciler_singleton_test.go: rejects Reconcile…Loop and
Reconcile…Pass<n> function names anywhere in network/
- S7 nats_subjects_test.go: scans for `vpc.<verb>` subjects missing
the {azID} segment; calls t.Skip with the gap report so every test
run prints the work-to-do without breaking CI. Flip to t.Fatalf
when the cluster-wide subject rename ships.
Behavioural suite (spinifex/network/reconcile/liveness_test.go):
- L1 reconciler converges: scaled-intent (N=1,3,8) double-Reconcile
property test; asserts second pass is a no-op
- L2 drift bound: structural check that DriftInterval is positive and
≤30 min support window; full fake-clock behavioural test deferred
- L3 federation re-enables: t.Skip stub pointing at the missing
network/federation/ package
Fixes the one production S4 violation the new walker found:
network/external/igw.go was constructing "gw-port-"+vpcID inline.
Adds topology.GatewaySwitchPort(vpcID) helper and retargets both
AttachIGW and DetachIGW callsites through L2.
Plan + per-clause mechanic + phasing in
docs/development/improvements/network-invariants.md.
Refs: mulga-siv-140
* fix(ovn): install strongswan-charon (not strongswan metapackage)
The strongswan metapackage's '\''charon-systemd | strongswan-charon'\''
alternative resolves to charon-systemd by default, which ships
/usr/sbin/charon-systemd but not the legacy /usr/sbin/ipsec helper or
/usr/lib/ipsec/charon binary that ovs-monitor-ipsec --ike-daemon=strongswan
shells out to. Install strongswan-charon explicitly to pull
strongswan-starter (legacy ipsec controller) + the matching charon binary,
matching what openvswitch-ipsec.service expects.
Also reset-failed + restart openvswitch-ipsec.service after enable to
recover from the deb post-install starting the service before its
dependencies finished unpacking.
* chore: add CODEOWNERS — joshsiv-mulga gates network/ + vpcd/
Fall-through reviewer for any change is the maintainer.
network/ and vpcd/ are explicitly listed so the matching branch
protection rule ("Require review from Code Owners") routes those
PRs to joshsiv-mulga. Those trees are governed by ADR-0006
invariant tests (spinifex/network/invariants/); CODEOWNERS adds a
human review gate on top of the mechanical fence.
Refs: mulga-siv-141
* chore: CODEOWNERS fallthrough → @mulgadc/mulgadc-engineers team
Fallthrough reviewer should be the team, not a single maintainer, so
any engineer can approve PRs outside the explicitly-gated paths.
network/ and vpcd/ keep joshsiv-mulga as the required reviewer per
ADR-0006 invariant ownership.
Refs: mulga-siv-141
* feat(ci): add test_run workflow input for -test.run filter
Pass an optional regex through to each suite's '\''go test -test.run'\'' so
dispatches can target a single test (e.g. '\''^TestMultinodeIPSec$'\'') without
running the full multinode suite. Threaded through single, multinode, and
reboot suite invocations; empty input preserves current behaviour.
* fix(daemon): flip NB_Global.ipsec=true on management node
Setting Open_vSwitch.other_config:ipsec_encapsulation=true on each
chassis is necessary but not sufficient for OVN native IPsec — without
NB_Global.ipsec=true, ovn-controller never adds options:remote_name to
the Geneve tunnel ports, so ovs-monitor-ipsec sees no IPsec-enabled
tunnels and the kernel xfrm tables stay empty. After enabling
ipsec_encapsulation locally, also write NB_Global.ipsec=true when the
local ovnnb_db socket is present (management node only); workers skip
silently — one writer is enough for a cluster-wide flag.
Adds a scoped sudoers entry pinning the daemon to that exact ovn-nbctl
invocation so the grant can't be reused for other NB mutations.
* fix(ovn): grant charon AppArmor read on /etc/spinifex/ipsec
The strongswan-charon profile only allows reads from /etc/ipsec.*,
/etc/strongswan.*, and a small fixed allowlist. ovs-monitor-ipsec writes
the per-tunnel strongSwan config with absolute paths to our peer cert +
key under /etc/spinifex/ipsec/, so charon hits 'Permission denied'
loading them (it runs as uid 0 but AppArmor still blocks) and no xfrm
SAs come up. Drop a read grant for /etc/spinifex/ipsec/** into the
profile's local override hook + reload.
* fix(ovn): pin OVS system-id to hostname for IPsec cert identity match
ovs-monitor-ipsec uses chassis-id as the IKEv2 `@<name>` peer identity in
the strongSwan config it generates. Our per-node IPsec peer cert
(admin.GenerateIPSecPeerCert) carries the node name as CN + dnsName SAN.
With chassis-id left at the package-generated UUID, charon validates
`@<UUID>` against a cert with dnsName=<hostname> — mismatch returns
AUTHENTICATION_FAILED and no xfrm SAs are negotiated.
Pin system-id to hostname (which equals the node name on Mulga clusters)
by writing both the on-disk source of truth (/etc/openvswitch/system-id.conf,
read by ovs-ctl on boot) and the running OVS DB value. Writing only one
would drift on the next reboot — the original "do not override" comment
was right about that hazard, just wrong about the resolution.
Cross-link the invariant in admin/ipsec.go: changing the cert identity
without updating setup-ovn.sh (or vice versa) breaks IKE silently.
Refs: mulga-siv-136
* fix(ovn): take --node-name flag so chassis-id matches cluster identity
CI hostnames are bead-themed (env20-casuarina) and don't match the
cluster node name (node1/node2) baked into the IPsec peer cert dnsName
SAN at `spx admin init/join --node NAME`. Pinning system-id to
`hostname -s` produced env20-casuarina; charon then validated
`@node1` against dnsName=env20-casuarina and IKE failed with
AUTHENTICATION_FAILED.
Add a --node-name=NAME flag to setup-ovn.sh, default to hostname for
manual/dev invocations, and update the ansible install role to pass
{{ spinifex_node_name }}. The matching bootstrap-install.sh change
lives in mulga.
Refs: mulga-siv-136
* refactor(daemon): relocate L0 helpers to network/host + widen ADR-0006 S2
Phase 4 Slice E of mulga-siv-125.6. Cleans up spinifex/daemon/network.go
per the audit and widens the S2 invariant to forbid OVS/OVN shell-outs
in spinifex/daemon/ (daemon is an L0 consumer; S2 applies).
Moved to network/host/:
- OVSNetworkPlumber + SetupTap/CleanupTap → host.OVSPlumber (host.NewOVSPlumber)
- GetBridgeIPv4 → host.GetBridgeIPv4
- OVNHealthStatus + CheckOVNHealth → host.OVNHealth + host.HealthStatus
- daemon/ipsec.go OVS/OVN ops → host.SetIPSecCertPaths, host.EnableIPSecEncapsulation, host.SetNBGlobalIPSec
Moved to utils:
- sudoCommand → utils.SudoCommand + utils.SetSudoCommandForTest test seam
(cross-package reassign would trip the reassign linter; helper keeps
the indirection inside utils).
Moved to vm:
- generateMgmtMAC → vm.GenerateMgmtMAC (pairs with vm.GenerateDevMAC)
Deleted (dead):
- SetupComputeNode (replaced by network/host/bridges.go:ensureBridges +
scripts/setup-ovn.sh:572-579)
- EnsureDataRoute
- findInterfaceByIP
- daemon/network.go + dead tests in daemon/network_test.go (duplicates of
vm package tests retained; mgmt MAC test moved to vm/lifecycle_test.go)
S2 invariant (network/invariants/host_containment_test.go) now walks
both spinifex/network/ and spinifex/daemon/, skipping network/host/.
* fix(ovn): broaden AppArmor read grant to /etc/spinifex (covers CA cert)
The earlier override granted reads on /etc/spinifex/ipsec/** only, which
covers the peer cert + key but NOT the cluster CA at /etc/spinifex/ca.pem.
charon then can't load the CA, can't validate the peer cert chain, and
surfaces the failure as `no trusted RSA public key found for '<peer>'`
even though identity matching now succeeds — IKE_AUTH fails with
AUTHENTICATION_FAILED and no xfrm SAs negotiate.
Grant /etc/spinifex/** instead (covers ca.pem + ipsec/peer.{pem,key})
and always rewrite the override so re-runs replace the older narrower
rule.
Refs: mulga-siv-136
* fix(ipsec): symlink cluster CA into charon's default cacerts dir
ovs-monitor-ipsec writes per-conn `cacert=/etc/spinifex/ca.pem` references
in the strongSwan config, but strongSwan 6.0 only loads CAs declared in
standalone `ca` sections or files placed in /etc/ipsec.d/cacerts at
startup. Without this, charon authenticates the local side fine but
rejects every inbound peer cert as `no trusted RSA public key found for
'<peer>'`, AUTH_FAILED, and no xfrm SAs negotiate.
Do the symlink in admin.GenerateIPSecPeerCert (right after writing the
peer cert) — setup-ovn.sh runs before `spx admin init/join` creates
/etc/spinifex/ca.pem, so the wiring can't live there. Use a stage-and-
rename pattern so concurrent test invocations don't race on the symlink.
/etc/ipsec.d/cacerts is on charon's default AppArmor allowlist so no
profile change needed.
Refs: mulga-siv-136
* chore(setup-ovn): drop apt-get install_packages block
OVS/OVN/openvswitch-ipsec/strongswan-charon are baked into the CI gold
image via mulga's image-builder/scripts/provision.sh (commits 116bb05,
a9b3a9c). Keeping a runtime apt-get install path here pulled CI through
apt-cacher-ng on every bootstrap and tripped on intermittent "Message
has been manipulated" InRelease cache corruption (env21/env20 runs).
Drop the install block. Gold-image rebuild is the canonical place to
add new system packages. If a package is missing, downstream ovs/ovn
commands fail loudly during setup-ovn.sh's later steps — clearer signal
than silently re-installing through a flaky proxy.
setup.sh already supports INSTALL_SPINIFEX_SKIP_APT=1 (passed by mulga's
bootstrap-install.sh:237) so this removes the last runtime apt-get call
on the CI provisioning path.
* fix(ovn): gate system-id pin on explicit --node-name
setup-ovn.sh used to fall back to `hostname -s` when --node-name wasn't
passed, and then unconditionally wrote that value to
/etc/openvswitch/system-id.conf + ovs-vsctl external_ids:system-id.
On CI single-node, the gold image already registered a chassis with
name=<UUID>; setup-ovn.sh then rewrote system-id to the bead-themed
hostname (env10-banksia). vpcd's discoverChassis treats any chassis row
whose name != local system-id but hostname == local hostname as a stale
local chassis and skips it, so vpcd reported "no OVN chassis registered
in SBDB" and refused to start, cascading into the e2e + e2e-go failures
on run 26430839508.
Drop the hostname fallback. If --node-name is given, pin both the
on-disk file and the running OVS DB to it (callers that care about
IPsec cert identity matching still get it). If no --node-name is given,
leave the existing system-id alone — that's always safe whether the
value came from a gold-image UUID, bootstrap-install.sh writing nodeN
before this script runs, or a manual ansible value.
* fix(network/reconcile): skip orphan prune on startup; drift owns GC
The vpcd startup reconcile races against the daemon's EnsureDefaultVPC:
the daemon writes SG KV rows + publishes vpc.create-sg on each node in
parallel with vpcd starting up. The leader-gated reconcile can load
intent before any SG row is visible on its local JetStream replica
while subscribers on peer nodes are concurrently creating port groups
from the same vpc.create-sg events. A startup orphan-port-group sweep
then deletes managed sg_* port groups that intent hasn't caught up to.
CI run 26434124790 trace:
node2 reconcile/lock: elected holder=env20-bottlebrush
node2 reconcile: starting intent_sgs=0
node2 reconcile/apply: removed orphan port group pg=sg_161c70ce23e704a2a
node2 reconcile/apply: removed orphan port group pg=sg_a0d078650a3150391
node2 reconcile/apply: removed orphan port group pg=sg_54b564b374e9162bb
Every subsequent RunInstances → vpc.create-port failed with
"port group not found" for ~3 minutes until the 5-minute drift tick
recreated the port groups. Tests give up after 60s; six multinode
suites cascade-fail on InsufficientInstanceCapacity.
Split the reconciler into Reconcile (full prune, drift loop) and
ReconcileApplyOnly (no prune, startup). applySGs takes a pruneOrphans
bool. vpcd startup calls ReconcileApplyOnly; drift loop unchanged.
Legitimate orphans get garbage-collected on the next drift tick — at
worst 5 minutes of lingering rows, no production impact.
Add TestReconcile_ApplyOnlyKeepsOrphanPortGroup to lock the contract.
* fix(ipsec): trigger charon rereadcacerts after CA symlink install
strongSwan scans /etc/ipsec.d/cacerts only at charon startup. The unit
openvswitch-ipsec.service is enabled at provision time and starts before
admin init writes the cluster CA symlink, so charon ends up running with
zero CAs loaded. Every IKE_AUTH then fails with `no trusted RSA public
key found for '<peer>'` and no SAs negotiate — the failure mode exposed
by TestMultinodeIPSec.
Append a stroke rereadcacerts call at the end of
installCAIntoCharonTrustStore so an already-running charon picks up the
freshly-placed CA without a service restart. Failure is swallowed at
debug — a missing/stopped charon at admin-init time is expected and a
later startup will scan cacerts naturally.
* refactor(network/host): move IPSec orchestrator from daemon (ADR-0006 S8)
ADR-0006 S8: "IPSec is OVN-native only. No layer implements custom IKEv2,
XFRM rules, or direct strongSwan management. IPSec SA lifecycle is delegated
entirely to OVN native IPSec and is invisible above L0."
The enableOVNIPSec orchestrator and its ensureOVSMonitorIPSecActive helper
lived in daemon/ipsec.go but composed L0 primitives (SetIPSecCertPaths,
EnableIPSecEncapsulation, SetNBGlobalIPSec) that already lived in
network/host. The orchestrator now lives alongside its callees as
host.EnableOVNIPSec, taking configPath and clusterConfig from the daemon at
the call site. Helper and test-override package vars (systemctlActiveTimeout,
ovnNBSocketPath) move with it; tests are translated to call EnableOVNIPSec
directly rather than via the Daemon struct.
Closes mulga-siv-142
* refactor(daemon): use topology.Port for OVN port names (ADR-0006 S4)
ADR-0006 S4 forbids any layer above L2 from constructing OVN object
names. Three daemon sites formatted "port-" + ENIId inline; route them
through topology.Port instead.
Closes mulga-siv-143
* refactor: use topology.Port for OVN port names in handlers + vm (ADR-0006 S4)
Replace inline "port-"+eniID formatting across five sites (eip, instance x2,
vpc/eni, vm/network_helpers) with topology.Port. OVSIfaceID stays as a thin
wrapper to preserve the OVN binding contract comment and existing call sites.
Closes mulga-siv-144
* test(network/invariants): widen scan roots to daemon/handlers/vm/testutil/utils (ADR-0006)
Widen ADR-0006 invariant scan roots so drift in upstack consumers surfaces
in CI. Backlog of violations in widened roots was drained by
mulga-siv-142/-143/-144; this commit makes the wider scan permanent.
- S2 (host_containment): add vm/ alongside network/ + daemon/
- S3 (nat_mode): add daemon/ alongside network/ + vpcd/
- S4 (names): add daemon/, handlers/, vm/ alongside
network/ + vpcd/; add narrow AWS-ID exemption
(vpc-cidr-assoc-) so AWS resource IDs are not
misclassified as OVN object names
- S6 (federation): add daemon/, handlers/ alongside network/;
convert single-root walk to slice
- S7 (nats_subjects): add daemon/, handlers/, testutil/, utils/ —
stays t.Skip pending cluster-wide subject
rename; gap report now lists 58 subjects
CODEOWNERS: add /spinifex/network/invariants/ rule so widening or
narrowing the scan needs explicit owner review.
Closes mulga-siv-145
Refs: audit-adr-0006.md
* chore(e2e): disable IPsec in pseudo multi-node test
Pseudo multi-node runs three simulated nodes as unprivileged tf-user.
The charon trust-store install in 904b45e writes /etc/ipsec.d/cacerts,
which fails with EACCES under a non-root caller and aborts admin init.
Disable --ipsec for this suite — loopback-only nodes don't exercise
real Geneve encryption anyway. Real-multi-node + single-node e2e
(both run admin init via sudo) continue to cover the IPsec path.
* fix(ovn): atomic Ensure* primitives prevent duplicate logical entities
OVN NB has no unique constraint on Name. The previous
GetLogicalRouter→CreateLogicalRouter sequence in EnsureVPC was a TOCTOU
race: when vpc.create on one node and the defensive vpc.create-subnet
EnsureVPC call on another both observed absence, both inserted, yielding
two routers per VPC. The orphan held DNAT references blocking EIP
detach, and DNAT/flow installation landing on the orphan caused
TestMultinodeGuestSSH SSH timeouts.
Add EnsureLogicalRouter / EnsureLogicalSwitch / EnsurePortGroup to the
libovsdb client. Each builds a single OVSDB transaction combining a
wait-op (Until=equal, Rows=[]) gated on absence of a Name match with the
insert. The server serialises the wait+insert atomically; concurrent
callers see exactly one survivor and the rest fall through to a re-fetch
that returns the canonical row.
Switch all production callers (topology.EnsureVPC / EnsureSubnet /
EnsureSGPortGroup, external.AttachIGW, reconcile.applyVPCs /
applySubnets) to the Ensure* variants. Bare Create* remains available to
the L1 wrapper.
Add invariant test TestS4_OVNNamedCreatesUseEnsure (AST walk) to block
future reintroduction of the bare Create* call sites outside
network/ovn.
Mock implements Ensure* under its existing mutex so the concurrency
tests (50/30/30 goroutines for Router/Switch/PortGroup) prove a single
UUID survives.
Refs: mulga-siv-146
* fix(ovn): wait-op rows must be non-empty; flip to NotEqual predicate
The Ensure* primitives sent a wait-op with `Rows: []ovsdb.Row{}` plus
`Until: ==` (RFC 7047 §5.2.6 absence test). The libovsdb Operation
struct tags Rows as `json:"rows,omitempty"`, so the empty slice was
dropped on the wire and ovsdb-server rejected every transaction with
"Required 'rows' member is missing", breaking reconciler bootstrap and
every downstream create-port lookup ("logical switch X not found").
Flip the predicate to the equivalent `Until: !=` formulation with a
single-row projection containing the Name we want absent:
no row → projection [] != [{name}] → predicate holds
row exists → projection [{name}] == [{name}] → predicate fails
Same atomic guarantee, JSON now well-formed. Mock tests can't catch
this — only a real ovsdb-server validates the envelope.
* chore: remove generateMAC wrapper
* chore: remove all bead / plan references
* fix(network/policy): wait for chassis flows after AddNATGateway
AddEIP fires m.barrier() to block until ovn-northd compiles NB→SB and every
chassis installs the new dnat_and_snat flows; AddNATGateway returned right
after the NB commit. So `aws ec2 create-route … --nat-gateway-id` would
publish vpc.add-nat-gateway, the SNAT row would land in NB, and the API
call would succeed before the gateway chassis had programmed the SNAT flow
for the private subnet.
Phase 8d's 10×5s=50s ping budget races that flow-install latency on slow
CI hosts (last seen run 26455602615 single-node, identical flake on
unmodified dev run 26434711010 — pre-existing, not introduced by Ensure*).
Mirror AddEIP: call m.barrier() after AddNAT returns. Add unit test
TestNATManager_AddNATGateway_FlowsBarrier_Fires covering the new path.
* chore: remove more plan refs
* chore: remove GenerateIPSecPeerCertIfEnabled
* chore: remove dupliate subnetGatewayCIDR function
* chore(network): trim superfluous comments across network/ tree
Collapse multi-paragraph godocs to 1-2 sentences, drop WHAT-restatement
comments, remove section dividers grouping <10 items, and cut prose
narration in tests. ADR clause references, one-line WHY comments for
non-obvious OVN/OVSDB/AWS/NATS semantics, and exported-symbol godoc are
preserved. No behavioural changes.
Packages: invariants, topology, host, external, ovn, reconcile, policy,
subscribers.
* fix(igw): restore attachment State="available" for AWS parity
AWS InternetGatewayAttachment.State is "available" when an IGW is
attached to a VPC, per the AWS SDK doc ("the state is available when
attached to a VPC; otherwise, this value is not returned"). Commit
b7505f8 wrote "attached"/"detached" to align with the reconciler
filter, which inverted AWS parity.
Fix the producer to write "available" again and update the consumer:
- handlers/ec2/igw: AttachInternetGateway and DetachInternetGateway
both persist State="available"; IGWRecord comment narrowed.
- network/reconcile/intent.loadIGWs: filter accepts "available" (was
"attached"); attachment is identified by VpcId != "".
- tests updated: igw service_impl_test, routetable service_impl_test
fixture, reconcile intent_test fixture.
* Update spinifex.toml
* refactor(subscribers): route EIP/NATGW through L5 wrappers (ADR-0006 §S1)
Subscriber previously called policy.NATManager directly for EIP and NAT
Gateway operations, bypassing the L5 wrappers (EIPManager, NATGWManager)
that existed on paper. ADR-0006 §S1 requires every cross-layer call to
pass through the immediate lower neighbor's typed interface; IGW already
followed this pattern.
Wire EIPManager and NATGWManager into the Subscriber struct, construct
them in vpcd alongside IGWManager, and swap the four direct-L3 call
sites (AddEIP, DeleteEIP, AddNATGateway, DeleteNATGateway) onto the
L5 surface (AttachEIP, DetachEIP, AttachNATGateway, DetachNATGateway).
AttachEIP now fires the flows-barrier, giving callers the same
"reachable on return" guarantee IGW already had.
The reconciler still calls policy.NATManager directly; that's a
cross-cutter and a separate concern.
* test(reboot): port 8MB WAL pad workaround from bash run-reboot-e2e.sh
App VMs' user-data writes (unit file + index.html) sit in the on-host
WAL until WriteWALToChunk crosses the 4MB threshold. Host reboot before
that loses them — systemd then refuses to load the zero-byte unit and
the responder never starts. Mirror the bash driver's dd+sync pad so the
WAL flushes pre-reboot. Drop when the upstream WAL fix lands.
* test(e2e): dump OVN/OVS/SG state on Phase 8d NATGW failure
Phase 8d's existing failure path only prints lr-nat-list and
lr-route-list. CI artifacts are not uploaded when run-e2e.sh exits
nonzero, so debugging the NATGW regression requires inline diagnostics
in the journal. Expand the dump to include nb/sb show, chassis bindings,
port_group ACLs, br-int flows filtered to the NATGW IP/private subnet,
host routes, and the private VM's own ip/route/neigh state.
* fix(ovn,reconcile): scope NAT deletes to router; NATGW snat per associated subnet
Two compounding bugs caused Phase 8d NATGW E2E flake (~60% CI failure):
1. ovn.DeleteNAT/DeleteNATByExternalIP searched all NAT rows globally and
only mutated the named router afterwards. With AWS subnet CIDRs repeating
across VPCs, (type, logicalIP/externalIP) is not globally unique — the
delete picked a NAT row owned by a different router, the mutate was a
no-op, and the row delete failed with "remaining reference(s)", leaving
the original router's NAT orphaned. Both calls now resolve the LR first
and filter NAT lookups to lr.NAT UUIDs. Mock client mirrors the scoping.
2. reconcile.loadNATGWs used the NATGW's home (public) subnet CIDR as
SubnetCIDR, producing an snat for the wrong CIDR that collided with the
correct event-published snat. loadNATGWs now walks route tables and
emits one NATGWSpec per (NATGW, associated private subnet) pair,
mirroring publishNatGatewayEventsForAssociation. Intent map keyed by
natgwID|subnetCIDR so multi-subnet NATGWs produce distinct specs.
Tests:
- nat_test.go: cross-router same-logicalIP isolation for DeleteEIP and
DeleteNATGateway
- intent_test.go: NATGW snat uses associated private subnet CIDR, not the
NATGW home subnet; NATGW with no associations produces no spec
---------
Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
3956 lines
156 KiB
Bash
Executable File
3956 lines
156 KiB
Bash
Executable File
#!/bin/bash
|
|
# run-e2e.sh — Single-node E2E test suite (production binary install).
|
|
#
|
|
# Assumes services are already running via systemd (bootstrap-install.sh).
|
|
# Uses production paths: /etc/spinifex, /var/log/spinifex, spx from PATH.
|
|
set -e
|
|
|
|
# Run from the directory containing this script (~/e2e-tests/)
|
|
cd "$(dirname "$0")"
|
|
|
|
# Source helper functions
|
|
source ./lib/multinode-helpers.sh
|
|
|
|
# Dump journald logs on failure
|
|
cleanup() {
|
|
EXIT_CODE=$?
|
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
for svc in spinifex-nats spinifex-predastore spinifex-viperblock \
|
|
spinifex-daemon spinifex-awsgw spinifex-vpcd; do
|
|
echo "=== ${svc} ==="
|
|
sudo journalctl -u "${svc}" --no-pager -n 200 2>/dev/null || true
|
|
done
|
|
fi
|
|
exit $EXIT_CODE
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# Use Spinifex profile (spx admin init sets endpoint_url, ca_bundle, region in ~/.aws/config)
|
|
export AWS_PROFILE=spinifex
|
|
|
|
# Resolve gateway host from config
|
|
GATEWAY_HOST="localhost"
|
|
ENDPOINT_URL=$(aws configure get endpoint_url 2>/dev/null || true)
|
|
if [ -n "$ENDPOINT_URL" ]; then
|
|
DETECTED_GW_HOST=$(echo "$ENDPOINT_URL" | sed 's|https\?://||;s|:.*||')
|
|
if [ -n "$DETECTED_GW_HOST" ]; then
|
|
GATEWAY_HOST="$DETECTED_GW_HOST"
|
|
fi
|
|
fi
|
|
|
|
# Helper: set up an AWS CLI profile with credentials + endpoint/CA config from the spinifex profile
|
|
setup_test_profile() {
|
|
local profile="$1" key_id="$2" secret="$3"
|
|
aws configure set aws_access_key_id "$key_id" --profile "$profile"
|
|
aws configure set aws_secret_access_key "$secret" --profile "$profile"
|
|
aws configure set region "$(aws configure get region)" --profile "$profile"
|
|
aws configure set endpoint_url "$(aws configure get endpoint_url)" --profile "$profile"
|
|
aws configure set ca_bundle "$(aws configure get ca_bundle)" --profile "$profile"
|
|
}
|
|
|
|
# Phase 1: Environment Setup
|
|
echo "Phase 1: Environment Setup"
|
|
|
|
# Check for KVM support
|
|
echo "Checking for KVM support..."
|
|
if [ -e /dev/kvm ]; then
|
|
echo "✅ /dev/kvm exists"
|
|
if [ -w /dev/kvm ]; then
|
|
echo "✅ /dev/kvm is writable"
|
|
else
|
|
echo "❌ /dev/kvm is NOT writable. QEMU will fail."
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "❌ /dev/kvm does NOT exist."
|
|
exit 1
|
|
fi
|
|
|
|
# Wait for health checks on AWS Gateway
|
|
echo "Waiting for AWS Gateway..."
|
|
MAX_RETRIES=30
|
|
COUNT=0
|
|
|
|
until curl -sk "https://${GATEWAY_HOST}:9999" > /dev/null || [ $COUNT -eq $MAX_RETRIES ]; do
|
|
echo "Waiting for gateway... ($COUNT/$MAX_RETRIES)"
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ $COUNT -eq $MAX_RETRIES ]; then
|
|
echo "Gateway failed to start"
|
|
exit 1
|
|
fi
|
|
|
|
# Wait for daemon NATS subscriptions to be active
|
|
wait_for_daemon_ready "https://${GATEWAY_HOST}:9999"
|
|
|
|
# Discover the cluster's availability zone and region dynamically
|
|
SPINIFEX_AZ=$(aws ec2 describe-availability-zones --query 'AvailabilityZones[0].ZoneName' --output text)
|
|
SPINIFEX_REGION=$(aws ec2 describe-availability-zones --query 'AvailabilityZones[0].RegionName' --output text)
|
|
echo "Discovered AZ: $SPINIFEX_AZ, Region: $SPINIFEX_REGION"
|
|
|
|
# No need for AWS_EC2/AWS_IAM vars — endpoint_url and ca_bundle are in the profile config
|
|
|
|
# Phase 1b: Cluster Stats CLI
|
|
echo "Phase 1b: Cluster Stats CLI"
|
|
|
|
# Test spx get nodes — should show node1 as Ready
|
|
echo "Testing spx get nodes..."
|
|
GET_NODES_OUTPUT=$(spx get nodes --timeout 5s 2>/dev/null)
|
|
echo "$GET_NODES_OUTPUT"
|
|
if ! echo "$GET_NODES_OUTPUT" | grep -q "Ready"; then
|
|
echo "spx get nodes did not show any Ready nodes"
|
|
exit 1
|
|
fi
|
|
echo "spx get nodes passed"
|
|
|
|
# Test spx top nodes — should show CPU/MEM stats
|
|
echo "Testing spx top nodes..."
|
|
TOP_NODES_OUTPUT=$(spx top nodes --timeout 5s 2>/dev/null)
|
|
echo "$TOP_NODES_OUTPUT"
|
|
if ! echo "$TOP_NODES_OUTPUT" | grep -q "0/"; then
|
|
echo "spx top nodes did not show resource stats"
|
|
exit 1
|
|
fi
|
|
echo "spx top nodes passed"
|
|
|
|
# Test spx get vms — should show no VMs yet
|
|
echo "Testing spx get vms (empty)..."
|
|
GET_VMS_OUTPUT=$(spx get vms --timeout 5s 2>/dev/null)
|
|
echo "$GET_VMS_OUTPUT"
|
|
if ! echo "$GET_VMS_OUTPUT" | grep -q "No VMs found"; then
|
|
echo "spx get vms should show 'No VMs found' before any launches"
|
|
exit 1
|
|
fi
|
|
echo "spx get vms (empty) passed"
|
|
|
|
# Phase 2: Discovery & Metadata
|
|
echo "Phase 2: Discovery & Metadata"
|
|
# Verify describe-regions (just ensure it returns at least one region)
|
|
aws ec2 describe-regions | jq -e '.Regions | length > 0'
|
|
|
|
# Verify describe-availability-zones
|
|
echo "Verifying describe-availability-zones..."
|
|
AZ_OUTPUT=$(aws ec2 describe-availability-zones)
|
|
echo "$AZ_OUTPUT" | jq -e '.AvailabilityZones | length > 0'
|
|
AZ_NAME=$(echo "$AZ_OUTPUT" | jq -r '.AvailabilityZones[0].ZoneName')
|
|
AZ_STATE=$(echo "$AZ_OUTPUT" | jq -r '.AvailabilityZones[0].State')
|
|
if [ "$AZ_STATE" != "available" ]; then
|
|
echo "Expected AZ state 'available', got '$AZ_STATE'"
|
|
exit 1
|
|
fi
|
|
echo "DescribeAvailabilityZones verified (Zone=$AZ_NAME, State=$AZ_STATE)"
|
|
|
|
# Discover available instance types from Spinifex
|
|
# Spinifex generates these based on the host CPU (e.g., m7i.micro, m8g.small, etc.)
|
|
echo "Discovering available instance types..."
|
|
AVAILABLE_TYPES=$(aws ec2 describe-instance-types --query 'InstanceTypes[*].InstanceType' --output text)
|
|
echo "Available instance types: $AVAILABLE_TYPES"
|
|
|
|
# Pick the nano instance type for minimal resource usage in tests
|
|
INSTANCE_TYPE=$(echo $AVAILABLE_TYPES | tr ' ' '\n' | grep -m1 'nano')
|
|
if [ -z "$INSTANCE_TYPE" ] || [ "$INSTANCE_TYPE" == "None" ]; then
|
|
echo "No instance types found!"
|
|
exit 1
|
|
fi
|
|
echo "Selected instance type for test: $INSTANCE_TYPE"
|
|
|
|
# Get architecture for the selected instance type
|
|
ARCH=$(aws ec2 describe-instance-types --instance-types "$INSTANCE_TYPE" --query 'InstanceTypes[0].ProcessorInfo.SupportedArchitectures[0]' --output text)
|
|
echo "Detected architecture for $INSTANCE_TYPE: $ARCH"
|
|
|
|
# Verify describe-instance-types (ensure the chosen type is available)
|
|
aws ec2 describe-instance-types | jq -e ".InstanceTypes[] | select(.InstanceType==\"$INSTANCE_TYPE\")"
|
|
|
|
# Phase 2b: Serial Console Access Settings
|
|
echo "Phase 2b: Serial Console Access Settings"
|
|
|
|
# Default should be disabled
|
|
SERIAL_DEFAULT=$(aws ec2 get-serial-console-access-status --query 'SerialConsoleAccessEnabled' --output text)
|
|
if [ "$SERIAL_DEFAULT" != "False" ]; then
|
|
echo "Expected serial console access default to be False, got $SERIAL_DEFAULT"
|
|
exit 1
|
|
fi
|
|
echo " Default state: disabled"
|
|
|
|
# Enable
|
|
ENABLE_RESULT=$(aws ec2 enable-serial-console-access --query 'SerialConsoleAccessEnabled' --output text)
|
|
if [ "$ENABLE_RESULT" != "True" ]; then
|
|
echo "Expected enable to return True, got $ENABLE_RESULT"
|
|
exit 1
|
|
fi
|
|
SERIAL_ENABLED=$(aws ec2 get-serial-console-access-status --query 'SerialConsoleAccessEnabled' --output text)
|
|
if [ "$SERIAL_ENABLED" != "True" ]; then
|
|
echo "Expected serial console access to be True after enable, got $SERIAL_ENABLED"
|
|
exit 1
|
|
fi
|
|
echo " Enabled: $SERIAL_ENABLED"
|
|
|
|
# Disable
|
|
DISABLE_RESULT=$(aws ec2 disable-serial-console-access --query 'SerialConsoleAccessEnabled' --output text)
|
|
if [ "$DISABLE_RESULT" != "False" ]; then
|
|
echo "Expected disable to return False, got $DISABLE_RESULT"
|
|
exit 1
|
|
fi
|
|
SERIAL_DISABLED=$(aws ec2 get-serial-console-access-status --query 'SerialConsoleAccessEnabled' --output text)
|
|
if [ "$SERIAL_DISABLED" != "False" ]; then
|
|
echo "Expected serial console access to be False after disable, got $SERIAL_DISABLED"
|
|
exit 1
|
|
fi
|
|
echo " Disabled: $SERIAL_DISABLED"
|
|
echo "Serial console access settings tests passed"
|
|
|
|
# Phase 3: SSH Key Management
|
|
echo "Phase 3: SSH Key Management"
|
|
# Create test-key-1 (create-key-pair) and verify private key material is returned
|
|
KEY_MATERIAL=$(aws ec2 create-key-pair --key-name test-key-1 --query 'KeyMaterial' --output text)
|
|
if [ -z "$KEY_MATERIAL" ] || [ "$KEY_MATERIAL" == "None" ]; then
|
|
echo "Failed to create key pair test-key-1"
|
|
exit 1
|
|
fi
|
|
echo "$KEY_MATERIAL" > test-key-1.pem
|
|
chmod 600 test-key-1.pem
|
|
|
|
# Generate a local RSA key and import it as test-key-2 (import-key-pair)
|
|
ssh-keygen -t rsa -b 2048 -f test-key-2-local -N ""
|
|
aws ec2 import-key-pair --key-name test-key-2 --public-key-material "fileb://test-key-2-local.pub"
|
|
|
|
# Verify both keys are present (describe-key-pairs)
|
|
aws ec2 describe-key-pairs --query 'KeyPairs[*].KeyName' --output text | grep test-key-1
|
|
aws ec2 describe-key-pairs --query 'KeyPairs[*].KeyName' --output text | grep test-key-2
|
|
|
|
# Delete test-key-2 (delete-key-pair) and verify only one remains
|
|
aws ec2 delete-key-pair --key-name test-key-2
|
|
REMAINING_KEYS=$(aws ec2 describe-key-pairs --query 'KeyPairs[*].KeyName' --output text)
|
|
echo "Remaining keys: $REMAINING_KEYS"
|
|
echo "$REMAINING_KEYS" | grep test-key-1
|
|
if echo "$REMAINING_KEYS" | grep -q test-key-2; then
|
|
echo "test-key-2 was not deleted"
|
|
exit 1
|
|
fi
|
|
|
|
# Phase 4: Image Management
|
|
echo "Phase 4: Image Management"
|
|
# Discover whichever Ubuntu AMI bootstrap-install.sh staged. v6+ gold image
|
|
# stages 26.04 (resolute); v3 staged 24.04 (noble). Catalog name follows
|
|
# `ami-ubuntu-<ver>-<arch>`.
|
|
if [ "$ARCH" = "x86_64" ]; then
|
|
ARCH_LABEL="x86_64"
|
|
else
|
|
ARCH_LABEL="arm64"
|
|
fi
|
|
echo "Looking up Ubuntu AMI for arch=${ARCH_LABEL}..."
|
|
AMI_ID=$(aws ec2 describe-images \
|
|
--filters "Name=name,Values=ami-ubuntu-26.04-${ARCH_LABEL},ami-ubuntu-24.04-${ARCH_LABEL}" \
|
|
--query 'Images[0].ImageId' --output text)
|
|
IMAGE_NAME=$(aws ec2 describe-images \
|
|
--filters "Name=name,Values=ami-ubuntu-26.04-${ARCH_LABEL},ami-ubuntu-24.04-${ARCH_LABEL}" \
|
|
--query 'Images[0].Name' --output text | sed 's/^ami-//')
|
|
echo "Using image: $IMAGE_NAME"
|
|
|
|
if [ -z "$AMI_ID" ] || [ "$AMI_ID" = "None" ]; then
|
|
echo "Failed to capture AMI ID — bootstrap-install.sh did not stage ubuntu-{24,26}.04-${ARCH_LABEL}"
|
|
exit 1
|
|
fi
|
|
echo "Captured AMI ID: $AMI_ID"
|
|
|
|
# Verify the AMI exists using its ID (describe-images)
|
|
echo "Verifying AMI availability..."
|
|
aws ec2 describe-images --image-ids "$AMI_ID" | jq -e ".Images[0] | select(.ImageId==\"$AMI_ID\")"
|
|
|
|
# Phase 5: Instance Lifecycle
|
|
echo "Phase 5: Instance Lifecycle"
|
|
|
|
# Authorize SSH on the default VPC's default SG before any run-instances. AWS
|
|
# default SGs allow ingress only from members of the same SG; subsequent SSH
|
|
# probes (Phase 5a-ii, 5a-iv, 7, 8c, 8d) come from the test runner's IP and
|
|
# would be dropped by the OVN port-group ACL otherwise.
|
|
DEFAULT_VPC_PHASE5=$(aws ec2 describe-vpcs \
|
|
--query 'Vpcs[?IsDefault==`true`].VpcId | [0]' --output text)
|
|
DEFAULT_SG_PHASE5=$(aws ec2 describe-security-groups \
|
|
--filters "Name=vpc-id,Values=$DEFAULT_VPC_PHASE5" "Name=group-name,Values=default" \
|
|
--query 'SecurityGroups[0].GroupId' --output text)
|
|
echo "Default VPC: $DEFAULT_VPC_PHASE5; default SG: $DEFAULT_SG_PHASE5"
|
|
# Tolerate InvalidPermission.Duplicate on re-runs (idempotent).
|
|
set +e
|
|
AUTH_OUTPUT=$(aws ec2 authorize-security-group-ingress \
|
|
--group-id "$DEFAULT_SG_PHASE5" \
|
|
--protocol tcp --port 22 --cidr 0.0.0.0/0 2>&1)
|
|
AUTH_EXIT=$?
|
|
set -e
|
|
if [ $AUTH_EXIT -ne 0 ] && ! echo "$AUTH_OUTPUT" | grep -q 'InvalidPermission.Duplicate'; then
|
|
echo "Failed to authorize SSH ingress on default SG: $AUTH_OUTPUT"
|
|
exit 1
|
|
fi
|
|
echo " Default SG ingress: tcp/22 from 0.0.0.0/0"
|
|
|
|
# Phase 5f exercises ICMP egress through OVN; the reply path needs explicit
|
|
# ICMP ingress on the default SG even though SGs are stateful in AWS — keeps
|
|
# parity with the Go single-suite fixture.
|
|
set +e
|
|
ICMP_OUTPUT=$(aws ec2 authorize-security-group-ingress \
|
|
--group-id "$DEFAULT_SG_PHASE5" \
|
|
--protocol icmp --port -1 --cidr 0.0.0.0/0 2>&1)
|
|
ICMP_EXIT=$?
|
|
set -e
|
|
if [ $ICMP_EXIT -ne 0 ] && ! echo "$ICMP_OUTPUT" | grep -q 'InvalidPermission.Duplicate'; then
|
|
echo "Failed to authorize ICMP ingress on default SG: $ICMP_OUTPUT"
|
|
exit 1
|
|
fi
|
|
echo " Default SG ingress: icmp from 0.0.0.0/0"
|
|
|
|
# Launch a VM (run-instances)
|
|
echo "Running: aws ec2 run-instances --image-id $AMI_ID --instance-type $INSTANCE_TYPE --key-name test-key-1"
|
|
# Capture full output for debugging
|
|
set +e # Temporarily disable exit on error to capture output
|
|
RUN_OUTPUT=$(aws ec2 run-instances --image-id "$AMI_ID" --instance-type "$INSTANCE_TYPE" --key-name test-key-1 2>&1)
|
|
RUN_EXIT_CODE=$?
|
|
set -e # Re-enable exit on error
|
|
echo "Run instances exit code: $RUN_EXIT_CODE"
|
|
echo "Run instances output: $RUN_OUTPUT"
|
|
if [ $RUN_EXIT_CODE -ne 0 ]; then
|
|
echo "❌ Failed to launch instance - AWS CLI returned error"
|
|
exit 1
|
|
fi
|
|
INSTANCE_ID=$(echo "$RUN_OUTPUT" | jq -r '.Instances[0].InstanceId')
|
|
if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" == "None" ] || [ "$INSTANCE_ID" == "null" ]; then
|
|
echo "Failed to launch instance - no InstanceId in response"
|
|
exit 1
|
|
fi
|
|
echo "Launched Instance ID: $INSTANCE_ID"
|
|
|
|
# Poll until state is running (describe-instances)
|
|
echo "Polling for instance running state..."
|
|
COUNT=0
|
|
STATE="unknown"
|
|
while [ $COUNT -lt 60 ]; do
|
|
# Capture full output to check if instance even exists in the response
|
|
DESCRIBE_OUTPUT=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID") || {
|
|
echo "⚠️ Gateway request failed, retrying... ($COUNT/60)"
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
continue
|
|
}
|
|
|
|
if [ -z "$DESCRIBE_OUTPUT" ]; then
|
|
echo "⚠️ Gateway returned empty response, retrying..."
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
continue
|
|
fi
|
|
|
|
# Extract state using jq
|
|
STATE=$(echo "$DESCRIBE_OUTPUT" | jq -r '.Reservations[0].Instances[0].State.Name // "not-found"')
|
|
|
|
echo "Instance state: $STATE"
|
|
if [ "$STATE" == "running" ]; then
|
|
break
|
|
fi
|
|
|
|
if [ "$STATE" == "terminated" ]; then
|
|
echo "❌ Instance terminated unexpectedly!"
|
|
exit 1
|
|
fi
|
|
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ "$STATE" != "running" ]; then
|
|
echo "Instance failed to reach running state"
|
|
exit 1
|
|
fi
|
|
|
|
# Phase 5a-pre: Verify spx get vms shows running instance
|
|
echo "Phase 5a-pre: Cluster Stats CLI (with running VM)"
|
|
GET_VMS_OUTPUT=$(spx get vms --timeout 5s 2>/dev/null)
|
|
echo "$GET_VMS_OUTPUT"
|
|
if ! echo "$GET_VMS_OUTPUT" | grep -q "$INSTANCE_ID"; then
|
|
echo "spx get vms did not show running instance $INSTANCE_ID"
|
|
exit 1
|
|
fi
|
|
echo "spx get vms shows running instance"
|
|
|
|
# Phase 5a: Validate instance metadata fields
|
|
echo "Phase 5a: Instance Metadata Validation"
|
|
DESCRIBE_META=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID")
|
|
META_TYPE=$(echo "$DESCRIBE_META" | jq -r '.Reservations[0].Instances[0].InstanceType')
|
|
META_KEY=$(echo "$DESCRIBE_META" | jq -r '.Reservations[0].Instances[0].KeyName')
|
|
META_IMAGE=$(echo "$DESCRIBE_META" | jq -r '.Reservations[0].Instances[0].ImageId')
|
|
META_BDM=$(echo "$DESCRIBE_META" | jq -r '.Reservations[0].Instances[0].BlockDeviceMappings | length')
|
|
|
|
if [ "$META_TYPE" != "$INSTANCE_TYPE" ]; then
|
|
echo "InstanceType mismatch: expected $INSTANCE_TYPE, got $META_TYPE"
|
|
exit 1
|
|
fi
|
|
if [ "$META_KEY" != "test-key-1" ]; then
|
|
echo "KeyName mismatch: expected test-key-1, got $META_KEY"
|
|
exit 1
|
|
fi
|
|
if [ "$META_IMAGE" != "$AMI_ID" ]; then
|
|
echo "ImageId mismatch: expected $AMI_ID, got $META_IMAGE"
|
|
exit 1
|
|
fi
|
|
if [ "$META_BDM" -lt 1 ]; then
|
|
echo "Expected at least 1 BlockDeviceMapping, got $META_BDM"
|
|
exit 1
|
|
fi
|
|
echo "Instance metadata validated (Type=$META_TYPE, Key=$META_KEY, Image=$META_IMAGE, BDMs=$META_BDM)"
|
|
|
|
# Phase 5a-ii: SSH Connectivity & Volume Verification
|
|
echo "Phase 5a-ii: SSH Connectivity & Volume Verification"
|
|
|
|
# Determine SSH connection method:
|
|
# - If instance has a public IP (external networking enabled), SSH via public IP on port 22
|
|
# - Otherwise, fall back to QEMU hostfwd (dev_networking mode)
|
|
INST_PUBLIC_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text 2>/dev/null || echo "None")
|
|
|
|
if [ -n "$INST_PUBLIC_IP" ] && [ "$INST_PUBLIC_IP" != "None" ] && [ "$INST_PUBLIC_IP" != "null" ]; then
|
|
SSH_HOST="$INST_PUBLIC_IP"
|
|
SSH_PORT=22
|
|
echo "SSH via public IP: $SSH_HOST:$SSH_PORT"
|
|
else
|
|
echo "No public IP — using QEMU hostfwd for SSH"
|
|
# set -e would abort on $() returning non-zero, hiding the diagnostic below
|
|
if ! SSH_PORT=$(get_ssh_port "$INSTANCE_ID"); then
|
|
SSH_PORT=""
|
|
fi
|
|
if [ -z "$SSH_PORT" ]; then
|
|
echo "Failed to get SSH port for instance $INSTANCE_ID"
|
|
exit 1
|
|
fi
|
|
SSH_HOST=$(get_ssh_host "$INSTANCE_ID")
|
|
echo "SSH endpoint: $SSH_HOST:$SSH_PORT"
|
|
fi
|
|
|
|
# Wait for SSH to become ready (VM boot + cloud-init)
|
|
wait_for_ssh "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" 60
|
|
|
|
# Test basic SSH connectivity
|
|
test_ssh_connectivity "$SSH_HOST" "$SSH_PORT" "test-key-1.pem"
|
|
|
|
# Check root volume size via lsblk
|
|
echo "Verifying root volume size from inside the VM..."
|
|
ROOT_VOL_SIZE_API=$(aws ec2 describe-volumes --query 'Volumes[0].Size' --output text)
|
|
# Find the disk backing the root filesystem (avoids picking up floppy/cdrom devices)
|
|
# 1. findmnt gets the source device for / (e.g. /dev/vda1)
|
|
# 2. lsblk PKNAME resolves to parent disk name (e.g. vda)
|
|
# 3. lsblk -b -d gets that disk's byte size
|
|
ROOT_DISK_BYTES=$(ssh -o StrictHostKeyChecking=no \
|
|
-o UserKnownHostsFile=/dev/null \
|
|
-o LogLevel=ERROR \
|
|
-o ConnectTimeout=5 \
|
|
-o BatchMode=yes \
|
|
-p "$SSH_PORT" \
|
|
-i "test-key-1.pem" \
|
|
ec2-user@"$SSH_HOST" 'SRC=$(findmnt -n -o SOURCE /); PKN=$(lsblk -n -o PKNAME "$SRC" 2>/dev/null | head -1); DEV=${PKN:-$(basename "$SRC")}; lsblk -b -d -n -o SIZE "/dev/$DEV"' | tr -d '[:space:]')
|
|
if [ -z "$ROOT_DISK_BYTES" ] || [ "$ROOT_DISK_BYTES" = "0" ]; then
|
|
echo "Failed to get root disk size from VM (got: '$ROOT_DISK_BYTES')"
|
|
echo "lsblk debug output:"
|
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
|
-o ConnectTimeout=5 -o BatchMode=yes -p "$SSH_PORT" -i "test-key-1.pem" \
|
|
ec2-user@"$SSH_HOST" 'lsblk -b -d; echo "---"; findmnt -n -o SOURCE /; cat /proc/partitions' || true
|
|
exit 1
|
|
fi
|
|
ROOT_DISK_GIB=$((ROOT_DISK_BYTES / 1073741824))
|
|
echo "Root disk size from VM: ${ROOT_DISK_GIB}GiB (API reports: ${ROOT_VOL_SIZE_API}GiB)"
|
|
if [ "$ROOT_DISK_GIB" -ne "$ROOT_VOL_SIZE_API" ]; then
|
|
echo "Root volume size mismatch: VM reports ${ROOT_DISK_GIB}GiB, API reports ${ROOT_VOL_SIZE_API}GiB"
|
|
exit 1
|
|
fi
|
|
echo "Root volume size verified"
|
|
|
|
# Verify hostname contains instance ID
|
|
echo "Verifying hostname inside the VM..."
|
|
VM_HOSTNAME=$(ssh -o StrictHostKeyChecking=no \
|
|
-o UserKnownHostsFile=/dev/null \
|
|
-o ConnectTimeout=5 \
|
|
-o BatchMode=yes \
|
|
-p "$SSH_PORT" \
|
|
-i "test-key-1.pem" \
|
|
ec2-user@"$SSH_HOST" 'hostname' 2>/dev/null)
|
|
echo "VM hostname: $VM_HOSTNAME"
|
|
# Hostname uses truncated ID: spinifex-vm-<first 8 hex chars of instance ID>
|
|
SHORT_ID=$(echo "$INSTANCE_ID" | sed 's/^i-//' | cut -c1-8)
|
|
if echo "$VM_HOSTNAME" | grep -q "$SHORT_ID"; then
|
|
echo "Hostname contains instance ID prefix ($SHORT_ID)"
|
|
else
|
|
echo "WARNING: Hostname '$VM_HOSTNAME' does not contain instance ID prefix '$SHORT_ID' (non-fatal)"
|
|
fi
|
|
|
|
echo "SSH connectivity and volume verification passed"
|
|
|
|
# Phase 5a-iii: Console Output
|
|
echo "Phase 5a-iii: Console Output"
|
|
|
|
CONSOLE_OUTPUT=$(aws ec2 get-console-output --instance-id "$INSTANCE_ID")
|
|
CONSOLE_INSTANCE=$(echo "$CONSOLE_OUTPUT" | jq -r '.InstanceId')
|
|
CONSOLE_DATA=$(echo "$CONSOLE_OUTPUT" | jq -r '.Output // empty')
|
|
|
|
if [ "$CONSOLE_INSTANCE" != "$INSTANCE_ID" ]; then
|
|
echo "GetConsoleOutput InstanceId mismatch: expected $INSTANCE_ID, got $CONSOLE_INSTANCE"
|
|
exit 1
|
|
fi
|
|
echo " GetConsoleOutput succeeded (InstanceId=$CONSOLE_INSTANCE, has output=$([ -n "$CONSOLE_DATA" ] && echo yes || echo no))"
|
|
|
|
echo "Console output tests passed"
|
|
|
|
# Verify root volume attached to the instance (describe-volumes)
|
|
VOLUME_ID=$(aws ec2 describe-volumes --query 'Volumes[0].VolumeId' --output text)
|
|
if [ -z "$VOLUME_ID" ] || [ "$VOLUME_ID" == "None" ]; then
|
|
echo "Failed to find volume for instance $INSTANCE_ID"
|
|
exit 1
|
|
fi
|
|
echo "Volume ID: $VOLUME_ID"
|
|
|
|
# Phase 5b: Volume Lifecycle (Attach/Detach)
|
|
echo "Phase 5b: Volume Lifecycle (Attach/Detach)"
|
|
echo "Testing volume create -> resize -> attach -> detach -> delete..."
|
|
|
|
# Create a test volume
|
|
echo "Creating 10GB volume in ${SPINIFEX_AZ}..."
|
|
CREATE_OUTPUT=$(aws ec2 create-volume --size 10 --availability-zone "$SPINIFEX_AZ")
|
|
TEST_VOLUME_ID=$(echo "$CREATE_OUTPUT" | jq -r '.VolumeId')
|
|
|
|
if [ -z "$TEST_VOLUME_ID" ] || [ "$TEST_VOLUME_ID" == "null" ]; then
|
|
echo "Failed to create test volume"
|
|
echo "Output: $CREATE_OUTPUT"
|
|
exit 1
|
|
fi
|
|
echo "Created volume: $TEST_VOLUME_ID"
|
|
|
|
# Resize to 20GB
|
|
NEW_SIZE=20
|
|
echo "Modifying volume to ${NEW_SIZE}GB..."
|
|
aws ec2 modify-volume --volume-id "$TEST_VOLUME_ID" --size "$NEW_SIZE"
|
|
|
|
# Verify resize
|
|
echo "Verifying resize..."
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
VOLUME_SIZE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \
|
|
--query 'Volumes[0].Size' --output text)
|
|
|
|
if [ "$VOLUME_SIZE" -eq "$NEW_SIZE" ]; then
|
|
echo "Volume resized successfully to ${NEW_SIZE}GB"
|
|
break
|
|
fi
|
|
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ "$VOLUME_SIZE" -ne "$NEW_SIZE" ]; then
|
|
echo "Volume failed to resize to ${NEW_SIZE}GB (current: ${VOLUME_SIZE}GB)"
|
|
exit 1
|
|
fi
|
|
|
|
# Attach volume to the running instance
|
|
echo "Attaching volume $TEST_VOLUME_ID to instance $INSTANCE_ID..."
|
|
aws ec2 attach-volume --volume-id "$TEST_VOLUME_ID" --instance-id "$INSTANCE_ID" --device /dev/sdf
|
|
|
|
# Verify attachment
|
|
echo "Verifying volume attachment..."
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
ATTACH_STATE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \
|
|
--query 'Volumes[0].Attachments[0].State' --output text)
|
|
ATTACH_INSTANCE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \
|
|
--query 'Volumes[0].Attachments[0].InstanceId' --output text)
|
|
VOL_STATE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \
|
|
--query 'Volumes[0].State' --output text)
|
|
|
|
if [ "$VOL_STATE" == "in-use" ] && [ "$ATTACH_STATE" == "attached" ] && [ "$ATTACH_INSTANCE" == "$INSTANCE_ID" ]; then
|
|
echo "Volume attached successfully (State=$VOL_STATE, AttachState=$ATTACH_STATE, Instance=$ATTACH_INSTANCE)"
|
|
break
|
|
fi
|
|
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ "$ATTACH_STATE" != "attached" ] || [ "$ATTACH_INSTANCE" != "$INSTANCE_ID" ]; then
|
|
echo "Volume attachment verification failed (AttachState=$ATTACH_STATE, Instance=$ATTACH_INSTANCE)"
|
|
exit 1
|
|
fi
|
|
|
|
# Detach volume (without --instance-id to test gateway resolution path)
|
|
echo "Detaching volume $TEST_VOLUME_ID..."
|
|
aws ec2 detach-volume --volume-id "$TEST_VOLUME_ID"
|
|
|
|
# Verify detachment
|
|
echo "Verifying volume detachment..."
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
VOL_STATE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \
|
|
--query 'Volumes[0].State' --output text)
|
|
|
|
if [ "$VOL_STATE" == "available" ]; then
|
|
echo "Volume detached successfully (State=$VOL_STATE)"
|
|
break
|
|
fi
|
|
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ "$VOL_STATE" != "available" ]; then
|
|
echo "Volume detachment verification failed (State=$VOL_STATE)"
|
|
exit 1
|
|
fi
|
|
|
|
# Delete the test volume
|
|
echo "Deleting test volume $TEST_VOLUME_ID..."
|
|
aws ec2 delete-volume --volume-id "$TEST_VOLUME_ID"
|
|
|
|
# Verify deletion
|
|
echo "Verifying volume deletion..."
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
set +e
|
|
VOLUME_CHECK=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \
|
|
--query 'Volumes[0].VolumeId' --output text 2>&1)
|
|
DESCRIBE_EXIT=$?
|
|
set -e
|
|
|
|
if [ $DESCRIBE_EXIT -ne 0 ] || [ "$VOLUME_CHECK" == "None" ] || [ -z "$VOLUME_CHECK" ]; then
|
|
echo "Volume deleted successfully"
|
|
break
|
|
fi
|
|
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ $COUNT -ge 60 ]; then
|
|
echo "Volume deletion verification timed out"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Volume lifecycle test passed (create -> resize -> attach -> detach -> delete)"
|
|
|
|
# Phase 5b-ii: DescribeVolumeStatus
|
|
echo "Phase 5b-ii: DescribeVolumeStatus"
|
|
echo "Testing describe-volume-status on root volume..."
|
|
VOL_STATUS_OUTPUT=$(aws ec2 describe-volume-status --volume-ids "$VOLUME_ID")
|
|
VOL_STATUS_ID=$(echo "$VOL_STATUS_OUTPUT" | jq -r '.VolumeStatuses[0].VolumeId')
|
|
VOL_STATUS_STATE=$(echo "$VOL_STATUS_OUTPUT" | jq -r '.VolumeStatuses[0].VolumeStatus.Status')
|
|
|
|
if [ "$VOL_STATUS_ID" != "$VOLUME_ID" ]; then
|
|
echo "DescribeVolumeStatus VolumeId mismatch: expected $VOLUME_ID, got $VOL_STATUS_ID"
|
|
exit 1
|
|
fi
|
|
echo "DescribeVolumeStatus verified (VolumeId=$VOL_STATUS_ID, Status=$VOL_STATUS_STATE)"
|
|
|
|
# Phase 5c: Snapshot Lifecycle
|
|
echo "Phase 5c: Snapshot Lifecycle"
|
|
echo "Testing snapshot create -> describe -> copy -> delete..."
|
|
|
|
# Use the root volume from Phase 5 — it's already attached and mounted in
|
|
# viperblockd, which is required for create-snapshot (the ebs.snapshot handler
|
|
# needs a live VB instance to flush).
|
|
echo "Using root volume $VOLUME_ID (already attached to $INSTANCE_ID)"
|
|
ROOT_VOL_SIZE=$(aws ec2 describe-volumes --volume-ids "$VOLUME_ID" \
|
|
--query 'Volumes[0].Size' --output text)
|
|
|
|
# Create a snapshot from the attached root volume
|
|
echo "Creating snapshot from volume $VOLUME_ID..."
|
|
SNAP_OUTPUT=$(aws ec2 create-snapshot --volume-id "$VOLUME_ID" --description "e2e-test-snapshot")
|
|
SNAPSHOT_ID=$(echo "$SNAP_OUTPUT" | jq -r '.SnapshotId')
|
|
|
|
if [ -z "$SNAPSHOT_ID" ] || [ "$SNAPSHOT_ID" == "null" ]; then
|
|
echo "Failed to create snapshot"
|
|
echo "Output: $SNAP_OUTPUT"
|
|
exit 1
|
|
fi
|
|
echo "Created snapshot: $SNAPSHOT_ID"
|
|
|
|
# Verify snapshot fields from create response
|
|
SNAP_STATE=$(echo "$SNAP_OUTPUT" | jq -r '.State')
|
|
SNAP_VOL_REF=$(echo "$SNAP_OUTPUT" | jq -r '.VolumeId')
|
|
SNAP_SIZE=$(echo "$SNAP_OUTPUT" | jq -r '.VolumeSize')
|
|
SNAP_PROGRESS=$(echo "$SNAP_OUTPUT" | jq -r '.Progress')
|
|
|
|
if [ "$SNAP_VOL_REF" != "$VOLUME_ID" ]; then
|
|
echo "Snapshot VolumeId mismatch: expected $VOLUME_ID, got $SNAP_VOL_REF"
|
|
exit 1
|
|
fi
|
|
if [ "$SNAP_SIZE" -ne "$ROOT_VOL_SIZE" ]; then
|
|
echo "Snapshot VolumeSize mismatch: expected $ROOT_VOL_SIZE, got $SNAP_SIZE"
|
|
exit 1
|
|
fi
|
|
echo "Snapshot create response verified (State=$SNAP_STATE, VolumeId=$SNAP_VOL_REF, Size=$SNAP_SIZE, Progress=$SNAP_PROGRESS)"
|
|
|
|
# Poll until snapshot is completed (should be immediate in v1, but poll for forward-compat)
|
|
echo "Waiting for snapshot to complete..."
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
SNAP_STATE=$(aws ec2 describe-snapshots --snapshot-ids "$SNAPSHOT_ID" \
|
|
--query 'Snapshots[0].State' --output text)
|
|
|
|
if [ "$SNAP_STATE" == "completed" ]; then
|
|
echo "Snapshot completed"
|
|
break
|
|
fi
|
|
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ "$SNAP_STATE" != "completed" ]; then
|
|
echo "Snapshot failed to reach completed state (State=$SNAP_STATE)"
|
|
exit 1
|
|
fi
|
|
|
|
# Describe snapshot by ID and verify fields
|
|
echo "Verifying snapshot via describe-snapshots..."
|
|
DESCRIBE_SNAP=$(aws ec2 describe-snapshots --snapshot-ids "$SNAPSHOT_ID")
|
|
DESC_VOL_ID=$(echo "$DESCRIBE_SNAP" | jq -r '.Snapshots[0].VolumeId')
|
|
DESC_SIZE=$(echo "$DESCRIBE_SNAP" | jq -r '.Snapshots[0].VolumeSize')
|
|
DESC_DESC=$(echo "$DESCRIBE_SNAP" | jq -r '.Snapshots[0].Description')
|
|
|
|
if [ "$DESC_VOL_ID" != "$VOLUME_ID" ]; then
|
|
echo "Describe snapshot VolumeId mismatch: expected $VOLUME_ID, got $DESC_VOL_ID"
|
|
exit 1
|
|
fi
|
|
if [ "$DESC_SIZE" -ne "$ROOT_VOL_SIZE" ]; then
|
|
echo "Describe snapshot VolumeSize mismatch: expected $ROOT_VOL_SIZE, got $DESC_SIZE"
|
|
exit 1
|
|
fi
|
|
if [ "$DESC_DESC" != "e2e-test-snapshot" ]; then
|
|
echo "Describe snapshot Description mismatch: expected 'e2e-test-snapshot', got '$DESC_DESC'"
|
|
exit 1
|
|
fi
|
|
echo "Describe snapshot verified (VolumeId=$DESC_VOL_ID, Size=$DESC_SIZE, Description=$DESC_DESC)"
|
|
|
|
# Copy the snapshot
|
|
echo "Copying snapshot $SNAPSHOT_ID..."
|
|
COPY_OUTPUT=$(aws ec2 copy-snapshot --source-snapshot-id "$SNAPSHOT_ID" --source-region "$SPINIFEX_REGION" --description "e2e-copy")
|
|
COPY_SNAPSHOT_ID=$(echo "$COPY_OUTPUT" | jq -r '.SnapshotId')
|
|
|
|
if [ -z "$COPY_SNAPSHOT_ID" ] || [ "$COPY_SNAPSHOT_ID" == "null" ]; then
|
|
echo "Failed to copy snapshot"
|
|
echo "Output: $COPY_OUTPUT"
|
|
exit 1
|
|
fi
|
|
echo "Copied snapshot: $COPY_SNAPSHOT_ID"
|
|
|
|
# Verify the copy is a distinct snapshot
|
|
if [ "$COPY_SNAPSHOT_ID" == "$SNAPSHOT_ID" ]; then
|
|
echo "Copy snapshot ID should differ from original"
|
|
exit 1
|
|
fi
|
|
|
|
# Describe all snapshots — should see both original and copy
|
|
TOTAL_SNAPS=$(aws ec2 describe-snapshots \
|
|
--snapshot-ids "$SNAPSHOT_ID" "$COPY_SNAPSHOT_ID" \
|
|
--query 'length(Snapshots)' --output text)
|
|
|
|
if [ "$TOTAL_SNAPS" -ne 2 ]; then
|
|
echo "Expected 2 snapshots, got $TOTAL_SNAPS"
|
|
exit 1
|
|
fi
|
|
echo "Both snapshots visible via describe-snapshots"
|
|
|
|
# Verify copy has correct description
|
|
COPY_DESC=$(aws ec2 describe-snapshots --snapshot-ids "$COPY_SNAPSHOT_ID" \
|
|
--query 'Snapshots[0].Description' --output text)
|
|
if [ "$COPY_DESC" != "e2e-copy" ]; then
|
|
echo "Copy description mismatch: expected 'e2e-copy', got '$COPY_DESC'"
|
|
exit 1
|
|
fi
|
|
|
|
# Delete the original snapshot
|
|
echo "Deleting original snapshot $SNAPSHOT_ID..."
|
|
aws ec2 delete-snapshot --snapshot-id "$SNAPSHOT_ID"
|
|
|
|
# Verify original is gone, copy remains
|
|
echo "Verifying snapshot deletion..."
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
set +e
|
|
SNAP_CHECK=$(aws ec2 describe-snapshots --snapshot-ids "$SNAPSHOT_ID" \
|
|
--query 'Snapshots[0].SnapshotId' --output text 2>&1)
|
|
SNAP_EXIT=$?
|
|
set -e
|
|
|
|
if [ $SNAP_EXIT -ne 0 ] || [ "$SNAP_CHECK" == "None" ] || [ -z "$SNAP_CHECK" ]; then
|
|
echo "Original snapshot deleted successfully"
|
|
break
|
|
fi
|
|
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ $COUNT -ge 60 ]; then
|
|
echo "Snapshot deletion verification timed out"
|
|
exit 1
|
|
fi
|
|
|
|
# Verify copy still exists
|
|
COPY_STATE=$(aws ec2 describe-snapshots --snapshot-ids "$COPY_SNAPSHOT_ID" \
|
|
--query 'Snapshots[0].State' --output text)
|
|
if [ "$COPY_STATE" != "completed" ]; then
|
|
echo "Copy snapshot should still exist (State=$COPY_STATE)"
|
|
exit 1
|
|
fi
|
|
echo "Copy snapshot still intact after original deletion"
|
|
|
|
# Delete the copy
|
|
echo "Deleting copy snapshot $COPY_SNAPSHOT_ID..."
|
|
aws ec2 delete-snapshot --snapshot-id "$COPY_SNAPSHOT_ID"
|
|
|
|
echo "Snapshot lifecycle test passed (create -> describe -> copy -> delete)"
|
|
|
|
# Phase 5d: Verify Snapshot-Backed Instance Launch
|
|
echo "Phase 5d: Verify Snapshot-Backed Instance Launch"
|
|
echo "All run-instances calls go through cloneAMIToVolume() -> OpenFromSnapshot(),"
|
|
echo "so the Phase 5 instance is already snapshot-backed. Successful boot in"
|
|
echo "Phase 5a-iii is itself proof; here we just confirm the AMI carries a"
|
|
echo "snapshot reference via the EC2 API."
|
|
|
|
AMI_SNAP_ID=$(aws ec2 describe-images --image-ids "$AMI_ID" \
|
|
--query 'Images[0].BlockDeviceMappings[0].Ebs.SnapshotId' --output text 2>/dev/null || echo "")
|
|
if [ -z "$AMI_SNAP_ID" ] || [ "$AMI_SNAP_ID" == "None" ]; then
|
|
echo "AMI $AMI_ID has no snapshot reference — launch was NOT snapshot-backed"
|
|
exit 1
|
|
fi
|
|
echo "AMI $AMI_ID is snapshot-backed (SnapshotId=$AMI_SNAP_ID)"
|
|
|
|
echo "Snapshot-backed instance launch verified"
|
|
|
|
# Phase 5e: CreateImage Lifecycle
|
|
echo "Phase 5e: CreateImage Lifecycle"
|
|
echo "Creating custom AMI from running instance $INSTANCE_ID..."
|
|
|
|
CREATE_IMAGE_OUTPUT=$(aws ec2 create-image --instance-id "$INSTANCE_ID" --name "e2e-custom-ami" --description "E2E test custom image")
|
|
CUSTOM_AMI_ID=$(echo "$CREATE_IMAGE_OUTPUT" | jq -r '.ImageId')
|
|
|
|
if [ -z "$CUSTOM_AMI_ID" ] || [ "$CUSTOM_AMI_ID" == "null" ]; then
|
|
echo "Failed to create custom image"
|
|
echo "Output: $CREATE_IMAGE_OUTPUT"
|
|
exit 1
|
|
fi
|
|
echo "Created custom AMI: $CUSTOM_AMI_ID"
|
|
|
|
# Verify the custom AMI exists via describe-images
|
|
echo "Verifying custom AMI via describe-images..."
|
|
CUSTOM_IMAGE=$(aws ec2 describe-images --image-ids "$CUSTOM_AMI_ID")
|
|
CUSTOM_IMAGE_NAME=$(echo "$CUSTOM_IMAGE" | jq -r '.Images[0].Name')
|
|
CUSTOM_IMAGE_STATE=$(echo "$CUSTOM_IMAGE" | jq -r '.Images[0].State')
|
|
|
|
if [ "$CUSTOM_IMAGE_NAME" != "e2e-custom-ami" ]; then
|
|
echo "Custom AMI name mismatch: expected 'e2e-custom-ami', got '$CUSTOM_IMAGE_NAME'"
|
|
exit 1
|
|
fi
|
|
echo "Custom AMI verified (Name=$CUSTOM_IMAGE_NAME, State=$CUSTOM_IMAGE_STATE)"
|
|
|
|
# Extract the backing snapshot ID from the custom AMI via describe-images
|
|
# (needed later to clean up before termination, so DeleteOnTermination can work)
|
|
CUSTOM_AMI_SNAP_ID=$(aws ec2 describe-images --image-ids "$CUSTOM_AMI_ID" \
|
|
--query 'Images[0].BlockDeviceMappings[0].Ebs.SnapshotId' --output text 2>/dev/null || echo "")
|
|
if [ "$CUSTOM_AMI_SNAP_ID" == "None" ]; then
|
|
CUSTOM_AMI_SNAP_ID=""
|
|
fi
|
|
if [ -n "$CUSTOM_AMI_SNAP_ID" ]; then
|
|
echo "Custom AMI backing snapshot: $CUSTOM_AMI_SNAP_ID"
|
|
else
|
|
echo "WARNING: Could not resolve backing snapshot ID from custom AMI"
|
|
fi
|
|
|
|
echo "CreateImage lifecycle test passed"
|
|
|
|
# Phase 5f: Security Group Enforcement (egress)
|
|
# Verifies vpcd programs OVN ACLs that actually drop traffic when an SG
|
|
# rule is revoked. We test EGRESS because in single-node dev_networking
|
|
# mode, INGRESS SSH bypasses OVN via the QEMU hostfwd NIC — the OVN tap
|
|
# only owns the default route inside the VM, so egress is what hits the
|
|
# ACL. The default SG ships with one allow-all egress rule we can flip.
|
|
echo "Phase 5f: Security Group Enforcement (egress ACL)"
|
|
|
|
# Discover the VM's OVN-side gateway from inside the VM (cloud-init
|
|
# suppresses the default route on the dev NIC, so this is always the
|
|
# OVN logical-router port).
|
|
PROBE_GW=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
-o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes \
|
|
-p "$SSH_PORT" -i "test-key-1.pem" \
|
|
ec2-user@"$SSH_HOST" "ip route show default | awk '{print \$3}' | head -1" 2>/dev/null | tr -d '[:space:]')
|
|
if [ -z "$PROBE_GW" ]; then
|
|
echo " ERROR: could not discover default gateway inside VM — skipping Phase 5f"
|
|
else
|
|
echo " Probe target (VM default gateway): $PROBE_GW"
|
|
|
|
probe_egress_icmp() {
|
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
-o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes \
|
|
-p "$SSH_PORT" -i "test-key-1.pem" \
|
|
ec2-user@"$SSH_HOST" "ping -c 3 -W 2 $PROBE_GW" 2>&1
|
|
}
|
|
|
|
# Restore the default egress rule on any failure so later phases stay healthy.
|
|
sg_egress_restore() {
|
|
aws ec2 authorize-security-group-egress \
|
|
--group-id "$DEFAULT_SG_PHASE5" \
|
|
--ip-permissions 'IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]' >/dev/null 2>&1 || true
|
|
}
|
|
|
|
# Test 5f-1: Baseline — allow-all egress is in place.
|
|
# `|| true` so set -e doesn't kill us when the probe legitimately fails
|
|
# (e.g., OVN logical router not responding to ICMP echo in this env);
|
|
# the SKIP branch below needs to be reachable.
|
|
echo "Test 5f-1: Egress baseline (allow-all)"
|
|
BASELINE_OUT=$(probe_egress_icmp || true)
|
|
if echo "$BASELINE_OUT" | grep -qE '[1-3] (packets )?received'; then
|
|
echo " PASS: ICMP egress works with default allow-all"
|
|
SG_ENFORCE_RUN=true
|
|
else
|
|
echo " SKIP: baseline ICMP did not work; cannot verify enforcement"
|
|
echo " (env may block ICMP between VM and gateway regardless of SG)"
|
|
echo " Output: $BASELINE_OUT"
|
|
SG_ENFORCE_RUN=false
|
|
fi
|
|
|
|
if [ "$SG_ENFORCE_RUN" = "true" ]; then
|
|
# Test 5f-2: Revoke egress → ICMP must be dropped.
|
|
echo "Test 5f-2: Revoke egress → expect drop"
|
|
aws ec2 revoke-security-group-egress \
|
|
--group-id "$DEFAULT_SG_PHASE5" \
|
|
--ip-permissions 'IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]' >/dev/null
|
|
sleep 3 # ACL propagation (vpcd handler is synchronous; small buffer for OVN flow install)
|
|
REVOKE_OUT=$(probe_egress_icmp || true)
|
|
if echo "$REVOKE_OUT" | grep -qE '0 (packets )?received|100% packet loss'; then
|
|
echo " PASS: ICMP dropped after revoke"
|
|
else
|
|
echo " FAIL: ICMP still succeeded after revoking allow-all egress"
|
|
echo " Output: $REVOKE_OUT"
|
|
sg_egress_restore
|
|
exit 1
|
|
fi
|
|
|
|
# Test 5f-3: Re-authorize → ICMP works again.
|
|
echo "Test 5f-3: Re-authorize egress → expect allow"
|
|
sg_egress_restore
|
|
sleep 3
|
|
RESTORE_OUT=$(probe_egress_icmp || true)
|
|
if echo "$RESTORE_OUT" | grep -qE '[1-3] (packets )?received'; then
|
|
echo " PASS: ICMP restored after re-authorize"
|
|
else
|
|
echo " FAIL: ICMP still dropped after re-authorize"
|
|
echo " Output: $RESTORE_OUT"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Phase 5f: Security Group enforcement verified (revoke → drop, authorize → allow)"
|
|
fi
|
|
fi
|
|
|
|
# Phase 6: Tag Management
|
|
echo "Phase 6: Tag Management"
|
|
|
|
# 6a: Create tags on the instance
|
|
echo "Creating tags on instance $INSTANCE_ID..."
|
|
aws ec2 create-tags --resources "$INSTANCE_ID" --tags Key=Name,Value=e2e-test Key=Environment,Value=testing Key=DeleteMe,Value=please
|
|
|
|
# 6b: Verify tags with describe-tags (resource-id filter)
|
|
echo "Verifying tags on instance..."
|
|
TAG_COUNT=$(aws ec2 describe-tags --filters "Name=resource-id,Values=$INSTANCE_ID" \
|
|
--query 'length(Tags || `[]`)' --output text)
|
|
if [ "$TAG_COUNT" -ne 3 ]; then
|
|
echo "Expected 3 tags on instance, got $TAG_COUNT"
|
|
exit 1
|
|
fi
|
|
echo "Instance has $TAG_COUNT tags"
|
|
|
|
# 6c: Create tags on the root volume
|
|
echo "Creating tags on volume $VOLUME_ID..."
|
|
aws ec2 create-tags --resources "$VOLUME_ID" --tags Key=Name,Value=e2e-root-vol Key=Environment,Value=testing
|
|
|
|
# 6d: Filter by key
|
|
echo "Testing key filter..."
|
|
ENV_TAGS=$(aws ec2 describe-tags --filters "Name=key,Values=Environment" \
|
|
--query 'length(Tags || `[]`)' --output text)
|
|
if [ "$ENV_TAGS" -ne 2 ]; then
|
|
echo "Expected 2 'Environment' tags across resources, got $ENV_TAGS"
|
|
exit 1
|
|
fi
|
|
echo "Key filter returned $ENV_TAGS tags"
|
|
|
|
# 6e: Filter by resource-type
|
|
echo "Testing resource-type filter..."
|
|
INSTANCE_TAGS=$(aws ec2 describe-tags --filters "Name=resource-type,Values=instance" \
|
|
--query 'length(Tags || `[]`)' --output text)
|
|
if [ "$INSTANCE_TAGS" -ne 3 ]; then
|
|
echo "Expected 3 instance tags, got $INSTANCE_TAGS"
|
|
exit 1
|
|
fi
|
|
echo "Resource-type filter returned $INSTANCE_TAGS instance tags"
|
|
|
|
# 6f: Overwrite a tag value
|
|
echo "Overwriting Name tag on instance..."
|
|
aws ec2 create-tags --resources "$INSTANCE_ID" --tags Key=Name,Value=e2e-test-updated
|
|
UPDATED_NAME=$(aws ec2 describe-tags \
|
|
--filters "Name=resource-id,Values=$INSTANCE_ID" "Name=key,Values=Name" \
|
|
--query 'Tags[0].Value' --output text)
|
|
if [ "$UPDATED_NAME" != "e2e-test-updated" ]; then
|
|
echo "Tag overwrite failed: expected 'e2e-test-updated', got '$UPDATED_NAME'"
|
|
exit 1
|
|
fi
|
|
echo "Tag overwrite verified"
|
|
|
|
# 6g: Delete tag by key (unconditional)
|
|
echo "Deleting DeleteMe tag unconditionally..."
|
|
aws ec2 delete-tags --resources "$INSTANCE_ID" --tags Key=DeleteMe
|
|
REMAINING=$(aws ec2 describe-tags --filters "Name=resource-id,Values=$INSTANCE_ID" \
|
|
--query 'length(Tags || `[]`)' --output text)
|
|
if [ "$REMAINING" -ne 2 ]; then
|
|
echo "Expected 2 tags after unconditional delete, got $REMAINING"
|
|
exit 1
|
|
fi
|
|
echo "Unconditional delete verified ($REMAINING tags remaining)"
|
|
|
|
# 6h: Delete tag with wrong value (should NOT delete)
|
|
echo "Attempting delete with wrong value (should be no-op)..."
|
|
aws ec2 delete-tags --resources "$INSTANCE_ID" --tags Key=Environment,Value=production
|
|
ENV_STILL=$(aws ec2 describe-tags \
|
|
--filters "Name=resource-id,Values=$INSTANCE_ID" "Name=key,Values=Environment" \
|
|
--query 'length(Tags || `[]`)' --output text)
|
|
if [ "$ENV_STILL" -ne 1 ]; then
|
|
echo "Value-conditional delete incorrectly removed tag"
|
|
exit 1
|
|
fi
|
|
echo "Value-conditional mismatch preserved tag"
|
|
|
|
# 6i: Delete tag with correct value
|
|
echo "Deleting Environment tag with correct value..."
|
|
aws ec2 delete-tags --resources "$INSTANCE_ID" --tags Key=Environment,Value=testing
|
|
ENV_GONE=$(aws ec2 describe-tags \
|
|
--filters "Name=resource-id,Values=$INSTANCE_ID" "Name=key,Values=Environment" \
|
|
--query 'length(Tags || `[]`)' --output text)
|
|
if [ "$ENV_GONE" -ne 0 ]; then
|
|
echo "Value-conditional delete failed to remove matching tag"
|
|
exit 1
|
|
fi
|
|
echo "Value-conditional match deleted tag"
|
|
|
|
# 6j: Verify only Name tag remains on instance
|
|
FINAL_COUNT=$(aws ec2 describe-tags --filters "Name=resource-id,Values=$INSTANCE_ID" \
|
|
--query 'length(Tags || `[]`)' --output text)
|
|
if [ "$FINAL_COUNT" -ne 1 ]; then
|
|
echo "Expected 1 tag remaining on instance, got $FINAL_COUNT"
|
|
exit 1
|
|
fi
|
|
echo "Tag management tests passed"
|
|
|
|
# Phase 7: Instance State Transitions
|
|
echo "Phase 7: Instance State Transitions"
|
|
|
|
# Stop instance (stop-instances) and verify transition to stopped (describe-instances)
|
|
echo "Stopping instance..."
|
|
aws ec2 stop-instances --instance-ids "$INSTANCE_ID"
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
STATE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" --query 'Reservations[0].Instances[0].State.Name' --output text)
|
|
echo "Instance state: $STATE"
|
|
if [ "$STATE" == "stopped" ]; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ "$STATE" != "stopped" ]; then
|
|
echo "Instance failed to reach stopped state"
|
|
exit 1
|
|
fi
|
|
|
|
# Reboot stopped instance (should fail)
|
|
echo "Attempting reboot of stopped instance (should fail)..."
|
|
expect_error "IncorrectInstanceState" aws ec2 reboot-instances --instance-ids "$INSTANCE_ID"
|
|
echo "Reboot-stopped correctly rejected"
|
|
|
|
# Phase 7a: Attach volume to stopped instance (should fail)
|
|
echo "Phase 7a: Attach Volume to Stopped Instance (Error Path)"
|
|
echo "Creating a volume to test attach-to-stopped..."
|
|
STOPPED_VOL_OUTPUT=$(aws ec2 create-volume --size 10 --availability-zone "$SPINIFEX_AZ")
|
|
STOPPED_VOL_ID=$(echo "$STOPPED_VOL_OUTPUT" | jq -r '.VolumeId')
|
|
echo "Created volume: $STOPPED_VOL_ID"
|
|
|
|
echo "Attempting attach to stopped instance (should fail)..."
|
|
expect_error "IncorrectInstanceState" aws ec2 attach-volume \
|
|
--volume-id "$STOPPED_VOL_ID" --instance-id "$INSTANCE_ID" --device /dev/sdg
|
|
echo "Attach-to-stopped correctly rejected"
|
|
|
|
# Clean up the test volume
|
|
aws ec2 delete-volume --volume-id "$STOPPED_VOL_ID"
|
|
echo "Cleaned up test volume $STOPPED_VOL_ID"
|
|
|
|
# Phase 7b: ModifyInstanceAttribute (change instance type while stopped, verify via SSH)
|
|
echo "Phase 7b: ModifyInstanceAttribute"
|
|
echo "Instance is stopped — modifying instance type to verify changes take effect on restart"
|
|
|
|
# Derive an upsized type in the same family: nano → small (same vCPUs, more RAM)
|
|
# Note: xlarge needs 16GB RAM which exceeds the 8GB CI VM — use small (2GB) instead
|
|
MODIFY_TYPE="${INSTANCE_TYPE%.nano}.small"
|
|
echo "Changing instance type from $INSTANCE_TYPE to $MODIFY_TYPE..."
|
|
|
|
# Get expected vCPU and memory for the new type
|
|
# Note: --instance-types filter may not be supported; use jq to select the correct type
|
|
TYPES_JSON=$(aws ec2 describe-instance-types)
|
|
EXPECTED_VCPUS=$(echo "$TYPES_JSON" | jq -r ".InstanceTypes[] | select(.InstanceType==\"$MODIFY_TYPE\") | .VCpuInfo.DefaultVCpus")
|
|
EXPECTED_MEM_MIB=$(echo "$TYPES_JSON" | jq -r ".InstanceTypes[] | select(.InstanceType==\"$MODIFY_TYPE\") | .MemoryInfo.SizeInMiB")
|
|
echo "Expected resources after modify: ${EXPECTED_VCPUS} vCPUs, ${EXPECTED_MEM_MIB} MiB RAM"
|
|
|
|
# Modify the instance type
|
|
aws ec2 modify-instance-attribute --instance-id "$INSTANCE_ID" \
|
|
--instance-type "{\"Value\": \"$MODIFY_TYPE\"}"
|
|
|
|
# Verify describe-instances reflects the new type
|
|
MODIFIED_TYPE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].InstanceType' --output text)
|
|
if [ "$MODIFIED_TYPE" != "$MODIFY_TYPE" ]; then
|
|
echo "ModifyInstanceAttribute failed: expected type $MODIFY_TYPE, got $MODIFIED_TYPE"
|
|
exit 1
|
|
fi
|
|
echo "Instance type updated to $MODIFIED_TYPE"
|
|
|
|
# Start instance with the new type
|
|
echo "Starting instance with modified type..."
|
|
aws ec2 start-instances --instance-ids "$INSTANCE_ID"
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
STATE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text)
|
|
echo "Instance state: $STATE"
|
|
if [ "$STATE" == "running" ]; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ "$STATE" != "running" ]; then
|
|
echo "Instance failed to reach running state after type change"
|
|
exit 1
|
|
fi
|
|
|
|
# Re-detect SSH endpoint (may have changed after restart with new QEMU config)
|
|
echo "Detecting SSH endpoint for restarted instance..."
|
|
INST_PUBLIC_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text 2>/dev/null || echo "None")
|
|
if [ -n "$INST_PUBLIC_IP" ] && [ "$INST_PUBLIC_IP" != "None" ] && [ "$INST_PUBLIC_IP" != "null" ]; then
|
|
SSH_HOST="$INST_PUBLIC_IP"
|
|
SSH_PORT=22
|
|
else
|
|
# set -e would abort on $() returning non-zero
|
|
if ! SSH_PORT=$(get_ssh_port "$INSTANCE_ID"); then
|
|
echo "ERROR: Failed to get SSH port for instance $INSTANCE_ID"
|
|
exit 1
|
|
fi
|
|
SSH_HOST=$(get_ssh_host "$INSTANCE_ID")
|
|
fi
|
|
echo "SSH endpoint: $SSH_HOST:$SSH_PORT"
|
|
|
|
# Wait for SSH to become ready
|
|
wait_for_ssh "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" 60
|
|
|
|
# Verify vCPU count matches the new instance type (nproc reports online CPUs)
|
|
echo "Verifying vCPU count inside the VM..."
|
|
VM_VCPUS=$(ssh -o StrictHostKeyChecking=no \
|
|
-o UserKnownHostsFile=/dev/null \
|
|
-o LogLevel=ERROR \
|
|
-o ConnectTimeout=5 \
|
|
-o BatchMode=yes \
|
|
-p "$SSH_PORT" \
|
|
-i "test-key-1.pem" \
|
|
ec2-user@"$SSH_HOST" 'nproc' | tr -d '[:space:]')
|
|
echo "VM reports $VM_VCPUS vCPUs (expected $EXPECTED_VCPUS)"
|
|
if [ "$VM_VCPUS" != "$EXPECTED_VCPUS" ]; then
|
|
echo "vCPU count mismatch after ModifyInstanceAttribute: VM reports $VM_VCPUS, expected $EXPECTED_VCPUS"
|
|
exit 1
|
|
fi
|
|
echo "vCPU count verified"
|
|
|
|
# Verify memory matches the new instance type (MemTotal from /proc/meminfo)
|
|
echo "Verifying memory inside the VM..."
|
|
VM_MEM_KB=$(ssh -o StrictHostKeyChecking=no \
|
|
-o UserKnownHostsFile=/dev/null \
|
|
-o LogLevel=ERROR \
|
|
-o ConnectTimeout=5 \
|
|
-o BatchMode=yes \
|
|
-p "$SSH_PORT" \
|
|
-i "test-key-1.pem" \
|
|
ec2-user@"$SSH_HOST" "awk '/MemTotal/ {print \$2}' /proc/meminfo" | tr -d '[:space:]')
|
|
VM_MEM_MIB=$((VM_MEM_KB / 1024))
|
|
# Allow 15% margin for kernel reserved memory
|
|
EXPECTED_MEM_LOW=$((EXPECTED_MEM_MIB * 85 / 100))
|
|
echo "VM reports ${VM_MEM_MIB} MiB total RAM (expected ~${EXPECTED_MEM_MIB} MiB, threshold ${EXPECTED_MEM_LOW} MiB)"
|
|
if [ "$VM_MEM_MIB" -lt "$EXPECTED_MEM_LOW" ]; then
|
|
echo "Memory too low after ModifyInstanceAttribute: VM reports ${VM_MEM_MIB} MiB, expected at least ${EXPECTED_MEM_LOW} MiB"
|
|
exit 1
|
|
fi
|
|
echo "Memory verified"
|
|
|
|
echo "ModifyInstanceAttribute test passed (type change + vCPU + memory verified via SSH)"
|
|
|
|
# Phase 7c-pre: Reboot Running Instance
|
|
echo "Phase 7c-pre: Reboot Instance"
|
|
|
|
# Capture pre-reboot metadata
|
|
PRE_REBOOT_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
|
|
|
|
echo "Rebooting instance..."
|
|
aws ec2 reboot-instances --instance-ids "$INSTANCE_ID"
|
|
|
|
# Verify state stays running (poll a few times to confirm no transient state change)
|
|
COUNT=0
|
|
while [ $COUNT -lt 10 ]; do
|
|
STATE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text)
|
|
if [ "$STATE" != "running" ]; then
|
|
echo "Instance unexpectedly left running state: $STATE"
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
echo "Instance state remained running during reboot"
|
|
|
|
# Wait for SSH to come back (guest OS restarts)
|
|
INST_PUBLIC_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text 2>/dev/null || echo "None")
|
|
if [ -n "$INST_PUBLIC_IP" ] && [ "$INST_PUBLIC_IP" != "None" ] && [ "$INST_PUBLIC_IP" != "null" ]; then
|
|
SSH_HOST="$INST_PUBLIC_IP"
|
|
SSH_PORT=22
|
|
else
|
|
# set -e would abort on $() returning non-zero
|
|
if ! SSH_PORT=$(get_ssh_port "$INSTANCE_ID"); then
|
|
echo "ERROR: Failed to get SSH port for instance $INSTANCE_ID"
|
|
exit 1
|
|
fi
|
|
SSH_HOST=$(get_ssh_host "$INSTANCE_ID")
|
|
fi
|
|
wait_for_ssh "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" 60
|
|
|
|
# Verify guest actually rebooted (uptime < 120 seconds)
|
|
UPTIME_SECS=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
-o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes \
|
|
-p "$SSH_PORT" -i "test-key-1.pem" \
|
|
ec2-user@"$SSH_HOST" 'cat /proc/uptime | cut -d. -f1' | tr -d '[:space:]')
|
|
echo "Guest uptime after reboot: ${UPTIME_SECS}s"
|
|
if [ "$UPTIME_SECS" -gt 120 ]; then
|
|
echo "Guest uptime too high — reboot may not have occurred"
|
|
exit 1
|
|
fi
|
|
|
|
# Verify metadata unchanged
|
|
POST_REBOOT_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
|
|
if [ "$PRE_REBOOT_IP" != "$POST_REBOOT_IP" ]; then
|
|
echo "IP changed after reboot: $PRE_REBOOT_IP -> $POST_REBOOT_IP"
|
|
exit 1
|
|
fi
|
|
echo "Reboot instance test passed"
|
|
|
|
# Phase 7c: RunInstances with count > 1
|
|
echo "Phase 7c: RunInstances with MinCount/MaxCount > 1"
|
|
echo "Launching 2 instances in a single run-instances call..."
|
|
MULTI_RUN_OUTPUT=$(aws ec2 run-instances \
|
|
--image-id "$AMI_ID" \
|
|
--instance-type "$INSTANCE_TYPE" \
|
|
--key-name test-key-1 \
|
|
--count 2)
|
|
MULTI_COUNT=$(echo "$MULTI_RUN_OUTPUT" | jq '.Instances | length')
|
|
|
|
if [ "$MULTI_COUNT" -ne 2 ]; then
|
|
echo "Expected 2 instances from run-instances --count 2, got $MULTI_COUNT"
|
|
exit 1
|
|
fi
|
|
|
|
MULTI_ID_1=$(echo "$MULTI_RUN_OUTPUT" | jq -r '.Instances[0].InstanceId')
|
|
MULTI_ID_2=$(echo "$MULTI_RUN_OUTPUT" | jq -r '.Instances[1].InstanceId')
|
|
echo "Launched 2 instances: $MULTI_ID_1, $MULTI_ID_2"
|
|
|
|
# Wait for both to reach running state
|
|
for MID in "$MULTI_ID_1" "$MULTI_ID_2"; do
|
|
echo "Waiting for $MID to reach running state..."
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
MSTATE=$(aws ec2 describe-instances --instance-ids "$MID" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text) || {
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
continue
|
|
}
|
|
if [ "$MSTATE" == "running" ]; then
|
|
echo "Instance $MID is running"
|
|
break
|
|
fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
if [ "$MSTATE" != "running" ]; then
|
|
echo "Instance $MID failed to reach running state"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# Terminate the multi-launch instances
|
|
echo "Terminating multi-launch instances..."
|
|
aws ec2 terminate-instances --instance-ids "$MULTI_ID_1" "$MULTI_ID_2"
|
|
for MID in "$MULTI_ID_1" "$MULTI_ID_2"; do
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
MSTATE=$(aws ec2 describe-instances --instance-ids "$MID" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text)
|
|
if [ "$MSTATE" == "terminated" ] || [ "$MSTATE" == "None" ]; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
done
|
|
echo "RunInstances count>1 test passed"
|
|
|
|
# Phase 8: Negative / Error Path Tests
|
|
echo "Phase 8: Negative / Error Path Tests"
|
|
|
|
# 8a: RunInstances with malformed AMI ID (missing ami- prefix)
|
|
echo "8a: RunInstances with malformed AMI ID..."
|
|
expect_error "InvalidAMIID.Malformed" aws ec2 run-instances \
|
|
--image-id notanami --instance-type "$INSTANCE_TYPE" --key-name test-key-1
|
|
|
|
# 8b: RunInstances with invalid instance type
|
|
echo "8b: RunInstances with invalid instance type..."
|
|
expect_error "InvalidInstanceType" aws ec2 run-instances \
|
|
--image-id "$AMI_ID" --instance-type "x99.superlarge" --key-name test-key-1
|
|
|
|
# 8c: Attach an already in-use volume (root volume is attached to running instance)
|
|
echo "8c: Attach already in-use volume..."
|
|
expect_error "VolumeInUse" aws ec2 attach-volume \
|
|
--volume-id "$VOLUME_ID" --instance-id "$INSTANCE_ID" --device /dev/sdg
|
|
|
|
# 8d: Detach boot/root volume (should be rejected)
|
|
echo "8d: Detach boot volume..."
|
|
expect_error "OperationNotPermitted" aws ec2 detach-volume \
|
|
--volume-id "$VOLUME_ID" --instance-id "$INSTANCE_ID"
|
|
|
|
# 8e: Delete a non-existent snapshot
|
|
echo "8e: Delete non-existent snapshot..."
|
|
expect_error "InvalidSnapshot.NotFound" aws ec2 delete-snapshot \
|
|
--snapshot-id snap-nonexistent000000
|
|
|
|
# 8f: Call an unsupported Action (use raw curl to send an invalid Action)
|
|
echo "8f: Unsupported Action..."
|
|
set +e
|
|
UNSUPPORTED_OUTPUT=$(curl -s -k -X POST "https://${GATEWAY_HOST}:9999/" \
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
-d "Action=DescribeFakeThings&Version=2016-11-15" 2>&1)
|
|
set -e
|
|
if echo "$UNSUPPORTED_OUTPUT" | grep -q "InvalidAction\|UnknownAction\|Error"; then
|
|
echo " Got expected error for unsupported action"
|
|
else
|
|
echo " WARNING: Unsupported action did not return expected error (may need auth)"
|
|
fi
|
|
|
|
# 8g: RunInstances with non-existent AMI (valid format but doesn't exist)
|
|
echo "8g: RunInstances with non-existent AMI..."
|
|
expect_error "InvalidAMIID.NotFound" aws ec2 run-instances \
|
|
--image-id ami-0000000000000dead --instance-type "$INSTANCE_TYPE" --key-name test-key-1
|
|
|
|
# 8h: RunInstances with non-existent key pair
|
|
echo "8h: RunInstances with non-existent key pair..."
|
|
expect_error "InvalidKeyPair.NotFound" aws ec2 run-instances \
|
|
--image-id "$AMI_ID" --instance-type "$INSTANCE_TYPE" --key-name nonexistent-key-xyz
|
|
|
|
# 8i: DeleteVolume on non-existent volume
|
|
echo "8i: DeleteVolume non-existent volume..."
|
|
expect_error "InvalidVolume.NotFound" aws ec2 delete-volume \
|
|
--volume-id vol-0000000000000dead
|
|
|
|
# 8j: CreateKeyPair with duplicate name (test-key-1 exists from Phase 3)
|
|
echo "8j: CreateKeyPair duplicate name..."
|
|
expect_error "InvalidKeyPair.Duplicate" aws ec2 create-key-pair \
|
|
--key-name test-key-1
|
|
|
|
# 8k: ImportKeyPair with duplicate name (test-key-1 exists from Phase 3)
|
|
echo "8k: ImportKeyPair duplicate name..."
|
|
expect_error "InvalidKeyPair.Duplicate" aws ec2 import-key-pair \
|
|
--key-name test-key-1 --public-key-material "fileb://test-key-2-local.pub"
|
|
|
|
# 8l: ImportKeyPair with invalid key format
|
|
echo "8l: ImportKeyPair invalid key format..."
|
|
echo "not-a-valid-public-key" > /tmp/bad-key.pub
|
|
expect_error "InvalidKey.Format" aws ec2 import-key-pair \
|
|
--key-name bad-format-key --public-key-material "fileb:///tmp/bad-key.pub"
|
|
|
|
# 8m: DescribeVolumes with non-existent volume ID
|
|
echo "8m: DescribeVolumes non-existent volume..."
|
|
expect_error "InvalidVolume.NotFound" aws ec2 describe-volumes \
|
|
--volume-ids vol-0000000000000dead
|
|
|
|
# 8n: DescribeImages with non-existent AMI ID
|
|
echo "8n: DescribeImages non-existent AMI..."
|
|
expect_error "InvalidAMIID.NotFound" aws ec2 describe-images \
|
|
--image-ids ami-0000000000000dead
|
|
|
|
# 8o: CreateImage with duplicate name (e2e-custom-ami exists from Phase 5e)
|
|
echo "8o: CreateImage duplicate name..."
|
|
expect_error "InvalidAMIName.Duplicate" aws ec2 create-image \
|
|
--instance-id "$INSTANCE_ID" --name "e2e-custom-ami"
|
|
|
|
# 8p: DeleteKeyPair for non-existent key — should succeed (idempotent, matches AWS)
|
|
echo "8p: DeleteKeyPair non-existent key (idempotent)..."
|
|
aws ec2 delete-key-pair --key-name nonexistent-key-99999
|
|
echo " DeleteKeyPair for non-existent key succeeded (idempotent)"
|
|
|
|
# 8q: ModifyInstanceAttribute on running instance (instance not in stopped KV → NotFound)
|
|
echo "8q: ModifyInstanceAttribute on running instance..."
|
|
expect_error "InvalidInstanceID.NotFound" aws ec2 modify-instance-attribute \
|
|
--instance-id "$INSTANCE_ID" --instance-type "{\"Value\": \"$INSTANCE_TYPE\"}"
|
|
|
|
# 8r: Reboot non-existent instance
|
|
echo "8r: Reboot non-existent instance..."
|
|
expect_error "InvalidInstanceID.NotFound" aws ec2 reboot-instances --instance-ids "i-nonexistent"
|
|
|
|
echo "Negative test suite passed"
|
|
|
|
# =============================================================================
|
|
# IAM E2E Tests
|
|
# =============================================================================
|
|
|
|
# IAM Phase 1: User CRUD
|
|
echo ""
|
|
echo "IAM Phase 1: User CRUD"
|
|
|
|
# Root auth — verify list-users works (root user exists)
|
|
echo " Verifying root auth via iam list-users..."
|
|
ROOT_USERS=$(aws iam list-users)
|
|
echo "$ROOT_USERS" | jq -e '.Users | length > 0' > /dev/null
|
|
echo " Root auth verified"
|
|
|
|
# CreateUser — alice
|
|
echo " Creating user alice..."
|
|
ALICE_OUTPUT=$(aws iam create-user --user-name alice)
|
|
echo "$ALICE_OUTPUT" | jq -e '.User.UserName == "alice"' > /dev/null
|
|
ALICE_ARN=$(echo "$ALICE_OUTPUT" | jq -r '.User.Arn')
|
|
echo " Created alice: $ALICE_ARN"
|
|
|
|
# CreateUser — bob with path
|
|
echo " Creating user bob with path /engineering/..."
|
|
BOB_OUTPUT=$(aws iam create-user --user-name bob --path /engineering/)
|
|
echo "$BOB_OUTPUT" | jq -e '.User.Path == "/engineering/"' > /dev/null
|
|
echo " Created bob"
|
|
|
|
# CreateUser — duplicate (expect EntityAlreadyExists)
|
|
echo " Creating duplicate user alice (expect error)..."
|
|
expect_error "EntityAlreadyExists" aws iam create-user --user-name alice
|
|
|
|
# GetUser
|
|
echo " Getting user alice..."
|
|
aws iam get-user --user-name alice | jq -e '.User.UserName == "alice"' > /dev/null
|
|
echo " GetUser alice passed"
|
|
|
|
# GetUser — not found
|
|
echo " Getting nonexistent user (expect error)..."
|
|
expect_error "NoSuchEntity" aws iam get-user --user-name nonexistent
|
|
|
|
# ListUsers — should have root, alice, bob
|
|
echo " Listing users..."
|
|
USER_COUNT=$(aws iam list-users | jq '.Users | length')
|
|
if [ "$USER_COUNT" -lt 3 ]; then
|
|
echo " ERROR: Expected at least 3 users (root, alice, bob), got $USER_COUNT"
|
|
exit 1
|
|
fi
|
|
echo " ListUsers: $USER_COUNT users"
|
|
|
|
# ListUsers with path-prefix
|
|
echo " Listing users with path-prefix /engineering/..."
|
|
ENG_USERS=$(aws iam list-users --path-prefix /engineering/ | jq '.Users | length')
|
|
if [ "$ENG_USERS" -ne 1 ]; then
|
|
echo " ERROR: Expected 1 user with path /engineering/, got $ENG_USERS"
|
|
exit 1
|
|
fi
|
|
echo " Path-prefix filter passed"
|
|
|
|
echo "IAM Phase 1 passed"
|
|
|
|
# IAM Phase 2: Access Key Lifecycle
|
|
echo ""
|
|
echo "IAM Phase 2: Access Key Lifecycle"
|
|
|
|
# CreateAccessKey — alice key 1
|
|
echo " Creating access key 1 for alice..."
|
|
KEY1=$(aws iam create-access-key --user-name alice)
|
|
ALICE_KEY_ID=$(echo "$KEY1" | jq -r '.AccessKey.AccessKeyId')
|
|
ALICE_SECRET=$(echo "$KEY1" | jq -r '.AccessKey.SecretAccessKey')
|
|
echo " Key 1: $ALICE_KEY_ID"
|
|
|
|
if [ -z "$ALICE_KEY_ID" ] || [ "$ALICE_KEY_ID" == "null" ]; then
|
|
echo " ERROR: Failed to create access key for alice"
|
|
exit 1
|
|
fi
|
|
|
|
# CreateAccessKey — alice key 2
|
|
echo " Creating access key 2 for alice..."
|
|
KEY2=$(aws iam create-access-key --user-name alice)
|
|
ALICE_KEY2_ID=$(echo "$KEY2" | jq -r '.AccessKey.AccessKeyId')
|
|
echo " Key 2: $ALICE_KEY2_ID"
|
|
|
|
# CreateAccessKey — alice key 3 (exceed limit)
|
|
echo " Creating access key 3 for alice (expect LimitExceeded)..."
|
|
expect_error "LimitExceeded" aws iam create-access-key --user-name alice
|
|
|
|
# CreateAccessKey — non-existent user
|
|
echo " Creating access key for ghost (expect error)..."
|
|
expect_error "NoSuchEntity" aws iam create-access-key --user-name ghost
|
|
|
|
# ListAccessKeys
|
|
echo " Listing access keys for alice..."
|
|
ALICE_KEY_COUNT=$(aws iam list-access-keys --user-name alice | jq '.AccessKeyMetadata | length')
|
|
if [ "$ALICE_KEY_COUNT" -ne 2 ]; then
|
|
echo " ERROR: Expected 2 keys for alice, got $ALICE_KEY_COUNT"
|
|
exit 1
|
|
fi
|
|
echo " Alice has $ALICE_KEY_COUNT keys"
|
|
|
|
echo " Listing access keys for bob (expect 0)..."
|
|
BOB_KEY_COUNT=$(aws iam list-access-keys --user-name bob | jq '.AccessKeyMetadata // [] | length')
|
|
if [ "${BOB_KEY_COUNT:-0}" -ne 0 ]; then
|
|
echo " ERROR: Expected 0 keys for bob, got $BOB_KEY_COUNT"
|
|
exit 1
|
|
fi
|
|
echo " Bob has 0 keys"
|
|
|
|
# UpdateAccessKey — deactivate
|
|
echo " Deactivating alice key 1..."
|
|
aws iam update-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" --status Inactive
|
|
STATUS=$(aws iam list-access-keys --user-name alice | \
|
|
jq -r ".AccessKeyMetadata[] | select(.AccessKeyId == \"$ALICE_KEY_ID\") | .Status")
|
|
if [ "$STATUS" != "Inactive" ]; then
|
|
echo " ERROR: Expected Inactive, got $STATUS"
|
|
exit 1
|
|
fi
|
|
echo " Key deactivated"
|
|
|
|
# UpdateAccessKey — reactivate
|
|
echo " Reactivating alice key 1..."
|
|
aws iam update-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" --status Active
|
|
echo " Key reactivated"
|
|
|
|
# DeleteAccessKey — key 2
|
|
echo " Deleting alice key 2..."
|
|
aws iam delete-access-key --user-name alice --access-key-id "$ALICE_KEY2_ID"
|
|
ALICE_KEY_COUNT=$(aws iam list-access-keys --user-name alice | jq '.AccessKeyMetadata | length')
|
|
if [ "$ALICE_KEY_COUNT" -ne 1 ]; then
|
|
echo " ERROR: Expected 1 key after delete, got $ALICE_KEY_COUNT"
|
|
exit 1
|
|
fi
|
|
echo " Key 2 deleted, alice has $ALICE_KEY_COUNT key"
|
|
|
|
echo "IAM Phase 2 passed"
|
|
|
|
# IAM Phase 3: User Authentication
|
|
echo ""
|
|
echo "IAM Phase 3: User Authentication"
|
|
|
|
# Configure alice profile
|
|
echo " Configuring spx-alice profile..."
|
|
setup_test_profile spx-alice "$ALICE_KEY_ID" "$ALICE_SECRET"
|
|
|
|
# Deactivate key → auth should fail
|
|
echo " Deactivating alice key, verifying auth rejection..."
|
|
aws iam update-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" --status Inactive
|
|
expect_error "InvalidClientTokenId" \
|
|
aws ec2 describe-instances --profile spx-alice
|
|
echo " Inactive key correctly rejected"
|
|
|
|
# Reactivate key
|
|
echo " Reactivating alice key..."
|
|
aws iam update-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" --status Active
|
|
|
|
# Bad secret → SignatureDoesNotMatch
|
|
echo " Testing bad secret (expect SignatureDoesNotMatch)..."
|
|
setup_test_profile spx-bad "$ALICE_KEY_ID" "WRONG_SECRET_KEY_HERE_12345678901"
|
|
expect_error "SignatureDoesNotMatch" \
|
|
aws ec2 describe-instances --profile spx-bad
|
|
|
|
# Non-existent key ID → InvalidClientTokenId
|
|
echo " Testing fake key ID (expect InvalidClientTokenId)..."
|
|
setup_test_profile spx-fake "AKIAXXXXXXXXXXXXXXXX" "doesntmatter"
|
|
expect_error "InvalidClientTokenId" \
|
|
aws ec2 describe-instances --profile spx-fake
|
|
|
|
# Multi-user auth — create bob key and verify both auth
|
|
echo " Creating access key for bob..."
|
|
BOB_KEY=$(aws iam create-access-key --user-name bob)
|
|
BOB_KEY_ID=$(echo "$BOB_KEY" | jq -r '.AccessKey.AccessKeyId')
|
|
BOB_SECRET=$(echo "$BOB_KEY" | jq -r '.AccessKey.SecretAccessKey')
|
|
setup_test_profile spx-bob "$BOB_KEY_ID" "$BOB_SECRET"
|
|
|
|
# Root still works
|
|
echo " Verifying root auth..."
|
|
aws ec2 describe-instances > /dev/null
|
|
echo " Root auth OK"
|
|
|
|
echo "IAM Phase 3 passed"
|
|
|
|
# IAM Phase 4: Policy CRUD
|
|
echo ""
|
|
echo "IAM Phase 4: Policy CRUD"
|
|
|
|
# Get the admin account ID from the current profile
|
|
POLICY_OUTPUT=$(aws iam create-policy \
|
|
--policy-name EC2ReadOnly \
|
|
--policy-document '{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Effect": "Allow",
|
|
"Action": ["ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeVpcs"],
|
|
"Resource": "*"
|
|
}]
|
|
}')
|
|
ADMIN_ACCOUNT=$(echo "$POLICY_OUTPUT" | jq -r '.Policy.Arn' | cut -d: -f5)
|
|
if [ -z "$ADMIN_ACCOUNT" ]; then
|
|
echo " ERROR: Failed to extract account ID from CreatePolicy response"
|
|
exit 1
|
|
fi
|
|
echo " Created EC2ReadOnly (account=$ADMIN_ACCOUNT)"
|
|
|
|
# CreatePolicy — FullAdmin
|
|
echo " Creating FullAdmin policy..."
|
|
aws iam create-policy \
|
|
--policy-name FullAdmin \
|
|
--path /admin/ \
|
|
--description "Full access to all services" \
|
|
--policy-document '{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Effect": "Allow",
|
|
"Action": "*",
|
|
"Resource": "*"
|
|
}]
|
|
}' > /dev/null
|
|
echo " Created FullAdmin"
|
|
|
|
# CreatePolicy — DenyTerminate (mixed Allow + Deny)
|
|
echo " Creating DenyTerminate policy..."
|
|
aws iam create-policy \
|
|
--policy-name DenyTerminate \
|
|
--policy-document '{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{"Effect": "Allow", "Action": "ec2:*", "Resource": "*"},
|
|
{"Effect": "Deny", "Action": "ec2:TerminateInstances", "Resource": "*"}
|
|
]
|
|
}' > /dev/null
|
|
echo " Created DenyTerminate"
|
|
|
|
# CreatePolicy — IAMReadOnly
|
|
echo " Creating IAMReadOnly policy..."
|
|
aws iam create-policy \
|
|
--policy-name IAMReadOnly \
|
|
--policy-document '{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Effect": "Allow",
|
|
"Action": ["iam:GetUser", "iam:ListUsers", "iam:ListPolicies", "iam:GetPolicy"],
|
|
"Resource": "*"
|
|
}]
|
|
}' > /dev/null
|
|
echo " Created IAMReadOnly"
|
|
|
|
# CreatePolicy — EC2DescribeAll (wildcard)
|
|
echo " Creating EC2DescribeAll policy..."
|
|
aws iam create-policy \
|
|
--policy-name EC2DescribeAll \
|
|
--policy-document '{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Effect": "Allow",
|
|
"Action": "ec2:Describe*",
|
|
"Resource": "*"
|
|
}]
|
|
}' > /dev/null
|
|
echo " Created EC2DescribeAll"
|
|
|
|
# CreatePolicy — duplicate (expect EntityAlreadyExists)
|
|
echo " Creating duplicate EC2ReadOnly (expect error)..."
|
|
expect_error "EntityAlreadyExists" aws iam create-policy --policy-name EC2ReadOnly \
|
|
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}'
|
|
|
|
# CreatePolicy — malformed JSON
|
|
echo " Creating policy with malformed JSON (expect error)..."
|
|
expect_error "MalformedPolicyDocument" aws iam create-policy --policy-name BadPolicy \
|
|
--policy-document '{"not valid"}'
|
|
|
|
# GetPolicy
|
|
echo " Getting EC2ReadOnly policy..."
|
|
aws iam get-policy \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" | \
|
|
jq -e '.Policy.PolicyName == "EC2ReadOnly"' > /dev/null
|
|
echo " GetPolicy passed"
|
|
|
|
# GetPolicy — not found
|
|
echo " Getting non-existent policy (expect error)..."
|
|
expect_error "NoSuchEntity" aws iam get-policy \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/Ghost"
|
|
|
|
# GetPolicyVersion
|
|
echo " Getting EC2ReadOnly policy version v1..."
|
|
aws iam get-policy-version \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" \
|
|
--version-id v1 | jq -e '.PolicyVersion.VersionId == "v1"' > /dev/null
|
|
echo " GetPolicyVersion passed"
|
|
|
|
# ListPolicies
|
|
echo " Listing policies..."
|
|
POLICY_COUNT=$(aws iam list-policies | jq '.Policies | length')
|
|
if [ "$POLICY_COUNT" -lt 5 ]; then
|
|
echo " ERROR: Expected at least 5 policies, got $POLICY_COUNT"
|
|
exit 1
|
|
fi
|
|
echo " ListPolicies: $POLICY_COUNT policies"
|
|
|
|
echo "IAM Phase 4 passed"
|
|
|
|
# IAM Phase 5: Policy Attachment & Enforcement
|
|
echo ""
|
|
echo "IAM Phase 5: Policy Attachment & Enforcement"
|
|
|
|
# Create charlie with key
|
|
echo " Creating user charlie with access key..."
|
|
aws iam create-user --user-name charlie > /dev/null
|
|
CHARLIE_KEY=$(aws iam create-access-key --user-name charlie)
|
|
CHARLIE_KEY_ID=$(echo "$CHARLIE_KEY" | jq -r '.AccessKey.AccessKeyId')
|
|
CHARLIE_SECRET=$(echo "$CHARLIE_KEY" | jq -r '.AccessKey.SecretAccessKey')
|
|
setup_test_profile spx-charlie "$CHARLIE_KEY_ID" "$CHARLIE_SECRET"
|
|
|
|
# Attach policies
|
|
echo " Attaching EC2ReadOnly + IAMReadOnly to alice..."
|
|
aws iam attach-user-policy --user-name alice \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly"
|
|
aws iam attach-user-policy --user-name alice \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly"
|
|
|
|
echo " Attaching DenyTerminate to bob..."
|
|
aws iam attach-user-policy --user-name bob \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate"
|
|
|
|
# ListAttachedUserPolicies
|
|
echo " Verifying alice's attached policies..."
|
|
ALICE_POLICIES=$(aws iam list-attached-user-policies --user-name alice | jq '.AttachedPolicies | length')
|
|
if [ "$ALICE_POLICIES" -ne 2 ]; then
|
|
echo " ERROR: Expected 2 policies for alice, got $ALICE_POLICIES"
|
|
exit 1
|
|
fi
|
|
echo " Alice has $ALICE_POLICIES policies"
|
|
|
|
# Idempotent attach
|
|
echo " Testing idempotent attach..."
|
|
aws iam attach-user-policy --user-name alice \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly"
|
|
ALICE_POLICIES=$(aws iam list-attached-user-policies --user-name alice | jq '.AttachedPolicies | length')
|
|
if [ "$ALICE_POLICIES" -ne 2 ]; then
|
|
echo " ERROR: Expected 2 policies after idempotent attach, got $ALICE_POLICIES"
|
|
exit 1
|
|
fi
|
|
echo " Idempotent attach passed"
|
|
|
|
# Attach edge cases
|
|
echo " Attaching non-existent policy (expect error)..."
|
|
expect_error "NoSuchEntity" aws iam attach-user-policy --user-name alice \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/Ghost"
|
|
|
|
echo " Attaching to non-existent user (expect error)..."
|
|
expect_error "NoSuchEntity" aws iam attach-user-policy --user-name ghost \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly"
|
|
|
|
# --- Enforcement Tests ---
|
|
|
|
# Default Deny — charlie has no policies
|
|
echo " Testing default deny (charlie, no policies)..."
|
|
expect_error "AccessDenied" \
|
|
aws ec2 describe-instances --profile spx-charlie
|
|
expect_error "AccessDenied" \
|
|
aws iam list-users --profile spx-charlie
|
|
echo " Default deny passed"
|
|
|
|
# Explicit Allow — alice has EC2ReadOnly + IAMReadOnly
|
|
echo " Testing explicit allow (alice, EC2ReadOnly + IAMReadOnly)..."
|
|
aws ec2 describe-instances --profile spx-alice > /dev/null
|
|
echo " ec2:DescribeInstances — allowed"
|
|
aws ec2 describe-vpcs --profile spx-alice > /dev/null
|
|
echo " ec2:DescribeVpcs — allowed"
|
|
aws iam list-users --profile spx-alice > /dev/null
|
|
echo " iam:ListUsers — allowed"
|
|
|
|
# Actions NOT in alice's policies → denied
|
|
expect_error "AccessDenied" \
|
|
aws ec2 describe-key-pairs --profile spx-alice
|
|
echo " ec2:DescribeKeyPairs — denied (not in policy)"
|
|
expect_error "AccessDenied" \
|
|
aws iam create-user --user-name hack --profile spx-alice
|
|
echo " iam:CreateUser — denied (not in policy)"
|
|
echo " Explicit allow passed"
|
|
|
|
# Wildcard Allow with Explicit Deny — bob has DenyTerminate (ec2:* Allow + ec2:TerminateInstances Deny)
|
|
echo " Testing deny override (bob, DenyTerminate)..."
|
|
aws ec2 describe-instances --profile spx-bob > /dev/null
|
|
echo " ec2:DescribeInstances — allowed (ec2:* wildcard)"
|
|
aws ec2 describe-key-pairs --profile spx-bob > /dev/null
|
|
echo " ec2:DescribeKeyPairs — allowed (ec2:* wildcard)"
|
|
expect_error "AccessDenied" \
|
|
aws ec2 terminate-instances --instance-ids i-fake --profile spx-bob
|
|
echo " ec2:TerminateInstances — denied (explicit Deny overrides Allow)"
|
|
expect_error "AccessDenied" \
|
|
aws iam list-users --profile spx-bob
|
|
echo " iam:ListUsers — denied (IAM not covered by ec2:*)"
|
|
echo " Deny override passed"
|
|
|
|
# Root user bypass
|
|
echo " Testing root user bypass..."
|
|
aws ec2 describe-instances > /dev/null
|
|
aws iam list-users > /dev/null
|
|
aws iam create-user --user-name temp > /dev/null
|
|
aws iam delete-user --user-name temp > /dev/null
|
|
echo " Root bypass passed"
|
|
|
|
# Prefix wildcard — swap alice to EC2DescribeAll
|
|
echo " Testing prefix wildcard (swap alice to EC2DescribeAll)..."
|
|
aws iam detach-user-policy --user-name alice \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly"
|
|
aws iam detach-user-policy --user-name alice \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly"
|
|
aws iam attach-user-policy --user-name alice \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll"
|
|
|
|
aws ec2 describe-instances --profile spx-alice > /dev/null
|
|
echo " ec2:DescribeInstances — allowed (Describe*)"
|
|
aws ec2 describe-key-pairs --profile spx-alice > /dev/null
|
|
echo " ec2:DescribeKeyPairs — allowed (Describe*)"
|
|
expect_error "AccessDenied" \
|
|
aws ec2 create-key-pair --key-name x --profile spx-alice
|
|
echo " ec2:CreateKeyPair — denied (not Describe*)"
|
|
expect_error "AccessDenied" \
|
|
aws iam list-users --profile spx-alice
|
|
echo " iam:ListUsers — denied (not ec2:Describe*)"
|
|
echo " Prefix wildcard passed"
|
|
|
|
# FullAdmin — give charlie full access
|
|
echo " Testing FullAdmin (charlie)..."
|
|
aws iam attach-user-policy --user-name charlie \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin"
|
|
aws ec2 describe-instances --profile spx-charlie > /dev/null
|
|
echo " ec2:DescribeInstances — allowed (was denied)"
|
|
aws iam list-users --profile spx-charlie > /dev/null
|
|
echo " iam:ListUsers — allowed (was denied)"
|
|
echo " FullAdmin passed"
|
|
|
|
echo "IAM Phase 5 passed"
|
|
|
|
# IAM Phase 6: Policy Lifecycle — Detach & Delete
|
|
echo ""
|
|
echo "IAM Phase 6: Policy Lifecycle — Detach & Delete"
|
|
|
|
# Detach alice's policy → she loses access
|
|
echo " Detaching EC2DescribeAll from alice..."
|
|
aws iam detach-user-policy --user-name alice \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll"
|
|
expect_error "AccessDenied" \
|
|
aws ec2 describe-instances --profile spx-alice
|
|
echo " Alice lost access after detach"
|
|
|
|
# DeletePolicy — conflict (DenyTerminate still attached to bob)
|
|
echo " Deleting DenyTerminate while attached (expect DeleteConflict)..."
|
|
expect_error "DeleteConflict" aws iam delete-policy \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate"
|
|
|
|
# Detach first, then delete
|
|
echo " Detaching DenyTerminate from bob, then deleting..."
|
|
aws iam detach-user-policy --user-name bob \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate"
|
|
aws iam delete-policy \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate"
|
|
|
|
# Verify it's gone
|
|
expect_error "NoSuchEntity" aws iam get-policy \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate"
|
|
echo " DenyTerminate deleted"
|
|
|
|
echo "IAM Phase 6 passed"
|
|
|
|
# IAM Phase 7: Cleanup
|
|
echo ""
|
|
echo "IAM Phase 7: IAM Cleanup"
|
|
|
|
# Delete alice (detach remaining, delete key, delete user)
|
|
echo " Cleaning up alice..."
|
|
aws iam delete-access-key --user-name alice --access-key-id "$ALICE_KEY_ID"
|
|
aws iam delete-user --user-name alice
|
|
|
|
# Delete bob
|
|
echo " Cleaning up bob..."
|
|
aws iam delete-access-key --user-name bob --access-key-id "$BOB_KEY_ID"
|
|
aws iam delete-user --user-name bob
|
|
|
|
# Delete charlie (detach FullAdmin first)
|
|
echo " Cleaning up charlie..."
|
|
aws iam detach-user-policy --user-name charlie \
|
|
--policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin"
|
|
aws iam delete-access-key --user-name charlie --access-key-id "$CHARLIE_KEY_ID"
|
|
aws iam delete-user --user-name charlie
|
|
|
|
# Verify only root remains
|
|
FINAL_USER_COUNT=$(aws iam list-users | jq '.Users | length')
|
|
if [ "$FINAL_USER_COUNT" -ne 1 ]; then
|
|
echo " ERROR: Expected 1 user (root) after cleanup, got $FINAL_USER_COUNT"
|
|
exit 1
|
|
fi
|
|
echo " Users cleaned up (root only)"
|
|
|
|
# Delete remaining policies (including bootstrap AdministratorAccess)
|
|
echo " Cleaning up policies..."
|
|
aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly"
|
|
aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin"
|
|
aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly"
|
|
aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll"
|
|
# AdministratorAccess stays attached to admin — detaching it would revoke
|
|
# admin's own permissions and block the delete (account root bypass not yet implemented).
|
|
|
|
FINAL_POLICY_COUNT=$(aws iam list-policies | jq '.Policies | length')
|
|
if [ "$FINAL_POLICY_COUNT" -ne 1 ]; then
|
|
echo " ERROR: Expected 1 policy (AdministratorAccess) after cleanup, got $FINAL_POLICY_COUNT"
|
|
exit 1
|
|
fi
|
|
echo " Policies cleaned up"
|
|
|
|
echo "IAM Phase 7 passed"
|
|
|
|
# IAM Phase 8: Roles & Instance Profiles (CRUD + lifecycle guards)
|
|
# Runs against the admin root profile after Phase 7 cleanup, so the only
|
|
# pre-existing policy is bootstrap AdministratorAccess.
|
|
echo ""
|
|
echo "IAM Phase 8: Roles & Instance Profiles"
|
|
|
|
ROLE_NAME="app-role"
|
|
PROFILE_NAME="app-profile"
|
|
OTHER_PROFILE_NAME="other-profile"
|
|
ADMIN_POLICY_ARN="arn:aws:iam::${ADMIN_ACCOUNT}:policy/AdministratorAccess"
|
|
ROLE_ARN="arn:aws:iam::${ADMIN_ACCOUNT}:role/${ROLE_NAME}"
|
|
|
|
TRUST_POLICY_DOC='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
|
|
TRUST_POLICY_V2_DOC='{"Version":"2012-10-17","Statement":[{"Sid":"v2","Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
|
|
|
|
# CreateRole — valid trust policy
|
|
echo " Creating role ${ROLE_NAME}..."
|
|
ROLE_OUT=$(aws iam create-role --role-name "${ROLE_NAME}" \
|
|
--assume-role-policy-document "${TRUST_POLICY_DOC}" \
|
|
--description "E2E test role")
|
|
echo "${ROLE_OUT}" | jq -e ".Role.RoleName == \"${ROLE_NAME}\"" > /dev/null
|
|
echo "${ROLE_OUT}" | jq -e ".Role.Arn == \"${ROLE_ARN}\"" > /dev/null
|
|
echo " Created role: ${ROLE_ARN}"
|
|
|
|
# CreateRole — duplicate
|
|
echo " Creating duplicate role (expect EntityAlreadyExists)..."
|
|
expect_error "EntityAlreadyExists" aws iam create-role \
|
|
--role-name "${ROLE_NAME}" --assume-role-policy-document "${TRUST_POLICY_DOC}"
|
|
|
|
# CreateRole — malformed trust policy
|
|
echo " Creating role with malformed trust policy (expect MalformedPolicyDocument)..."
|
|
expect_error "MalformedPolicyDocument" aws iam create-role \
|
|
--role-name "bad-role" --assume-role-policy-document '{not valid json'
|
|
|
|
# GetRole
|
|
echo " Getting role ${ROLE_NAME}..."
|
|
aws iam get-role --role-name "${ROLE_NAME}" \
|
|
| jq -e ".Role.RoleName == \"${ROLE_NAME}\"" > /dev/null
|
|
|
|
# GetRole — missing
|
|
expect_error "NoSuchEntity" aws iam get-role --role-name "ghost-role"
|
|
|
|
# ListRoles (>= 1)
|
|
ROLE_COUNT=$(aws iam list-roles | jq '.Roles | length')
|
|
if [ "${ROLE_COUNT}" -lt 1 ]; then
|
|
echo " ERROR: Expected >= 1 role, got ${ROLE_COUNT}"
|
|
exit 1
|
|
fi
|
|
|
|
# ListRoles with path-prefix
|
|
aws iam list-roles --path-prefix / | jq -e '.Roles | length >= 1' > /dev/null
|
|
|
|
# UpdateRole — description + max-session-duration
|
|
echo " Updating role description and max-session-duration..."
|
|
aws iam update-role --role-name "${ROLE_NAME}" \
|
|
--description "updated" --max-session-duration 7200
|
|
UPDATED_DESC=$(aws iam get-role --role-name "${ROLE_NAME}" | jq -r '.Role.Description')
|
|
if [ "${UPDATED_DESC}" != "updated" ]; then
|
|
echo " ERROR: Expected description 'updated', got '${UPDATED_DESC}'"
|
|
exit 1
|
|
fi
|
|
|
|
# UpdateRole — out-of-range MaxSessionDuration is enforced by the AWS CLI
|
|
# client-side (min 3600) before the request leaves the host. Server-side
|
|
# range validation (900-43200) is exercised in handlers/iam/roles_test.go.
|
|
|
|
# UpdateAssumeRolePolicy
|
|
echo " Updating trust policy..."
|
|
aws iam update-assume-role-policy --role-name "${ROLE_NAME}" \
|
|
--policy-document "${TRUST_POLICY_V2_DOC}"
|
|
|
|
# AttachRolePolicy + ListAttachedRolePolicies
|
|
echo " Attaching AdministratorAccess to ${ROLE_NAME}..."
|
|
aws iam attach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}"
|
|
ATTACHED=$(aws iam list-attached-role-policies --role-name "${ROLE_NAME}" \
|
|
| jq '.AttachedPolicies | length')
|
|
if [ "${ATTACHED}" -ne 1 ]; then
|
|
echo " ERROR: Expected 1 attached policy, got ${ATTACHED}"
|
|
exit 1
|
|
fi
|
|
|
|
# Idempotent re-attach
|
|
aws iam attach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}"
|
|
RE_ATTACHED=$(aws iam list-attached-role-policies --role-name "${ROLE_NAME}" \
|
|
| jq '.AttachedPolicies | length')
|
|
if [ "${RE_ATTACHED}" -ne 1 ]; then
|
|
echo " ERROR: Idempotent re-attach grew count to ${RE_ATTACHED}"
|
|
exit 1
|
|
fi
|
|
|
|
# CreateInstanceProfile — two profiles so Replace has a target in Phase 9
|
|
echo " Creating instance profile ${PROFILE_NAME}..."
|
|
aws iam create-instance-profile --instance-profile-name "${PROFILE_NAME}" \
|
|
| jq -e ".InstanceProfile.InstanceProfileName == \"${PROFILE_NAME}\"" > /dev/null
|
|
|
|
echo " Creating instance profile ${OTHER_PROFILE_NAME}..."
|
|
aws iam create-instance-profile --instance-profile-name "${OTHER_PROFILE_NAME}" > /dev/null
|
|
|
|
# CreateInstanceProfile — duplicate
|
|
expect_error "EntityAlreadyExists" aws iam create-instance-profile \
|
|
--instance-profile-name "${PROFILE_NAME}"
|
|
|
|
# GetInstanceProfile / ListInstanceProfiles
|
|
aws iam get-instance-profile --instance-profile-name "${PROFILE_NAME}" \
|
|
| jq -e ".InstanceProfile.InstanceProfileName == \"${PROFILE_NAME}\"" > /dev/null
|
|
aws iam list-instance-profiles | jq -e '.InstanceProfiles | length >= 2' > /dev/null
|
|
|
|
# AddRoleToInstanceProfile
|
|
echo " Adding ${ROLE_NAME} to ${PROFILE_NAME}..."
|
|
aws iam add-role-to-instance-profile \
|
|
--instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}"
|
|
|
|
# AddRoleToInstanceProfile — second role rejected (one-role limit)
|
|
expect_error "LimitExceeded" aws iam add-role-to-instance-profile \
|
|
--instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}"
|
|
|
|
# ListInstanceProfilesForRole — reverse lookup
|
|
PROFILES_FOR_ROLE=$(aws iam list-instance-profiles-for-role --role-name "${ROLE_NAME}" \
|
|
| jq '.InstanceProfiles | length')
|
|
if [ "${PROFILES_FOR_ROLE}" -ne 1 ]; then
|
|
echo " ERROR: Expected 1 profile for role, got ${PROFILES_FOR_ROLE}"
|
|
exit 1
|
|
fi
|
|
|
|
# DeleteRole — refused while attached policies + still in profile
|
|
expect_error "DeleteConflict" aws iam delete-role --role-name "${ROLE_NAME}"
|
|
|
|
# DeleteInstanceProfile — refused while role attached
|
|
expect_error "DeleteConflict" aws iam delete-instance-profile \
|
|
--instance-profile-name "${PROFILE_NAME}"
|
|
|
|
# Add role to OTHER_PROFILE_NAME so Phase 9 has a Replace target
|
|
aws iam add-role-to-instance-profile \
|
|
--instance-profile-name "${OTHER_PROFILE_NAME}" --role-name "${ROLE_NAME}"
|
|
|
|
echo "IAM Phase 8 passed"
|
|
|
|
# IAM Phase 9: EC2 IAM Instance Profile Association
|
|
# Uses the long-lived
|
|
# singleton $INSTANCE_ID for the Associate/Disassociate/Replace lifecycle,
|
|
# then a dedicated short-lived VM to exercise auto-disassociate on terminate.
|
|
echo ""
|
|
echo "IAM Phase 9: EC2 IAM Instance Profile Association"
|
|
|
|
# Sanity: singleton must still be running.
|
|
SINGLETON_STATE=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text)
|
|
if [ "${SINGLETON_STATE}" != "running" ]; then
|
|
echo " ERROR: Expected singleton ${INSTANCE_ID} running, got ${SINGLETON_STATE}"
|
|
exit 1
|
|
fi
|
|
|
|
# Associate-iam-instance-profile (post-launch)
|
|
echo " Associating ${PROFILE_NAME} -> ${INSTANCE_ID}..."
|
|
ASSOC_OUT=$(aws ec2 associate-iam-instance-profile \
|
|
--instance-id "${INSTANCE_ID}" \
|
|
--iam-instance-profile "Name=${PROFILE_NAME}")
|
|
ASSOC_ID=$(echo "${ASSOC_OUT}" | jq -r '.IamInstanceProfileAssociation.AssociationId')
|
|
if [[ ! "${ASSOC_ID}" =~ ^iip-assoc- ]]; then
|
|
echo " ERROR: AssociationId malformed: ${ASSOC_ID}"
|
|
exit 1
|
|
fi
|
|
echo " Got ${ASSOC_ID}"
|
|
|
|
# Associate again — already associated
|
|
expect_error "IamInstanceProfileAlreadyAssociated" aws ec2 associate-iam-instance-profile \
|
|
--instance-id "${INSTANCE_ID}" --iam-instance-profile "Name=${PROFILE_NAME}"
|
|
|
|
# DescribeInstances surfaces IamInstanceProfile
|
|
PROFILE_ARN_FROM_DESC=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \
|
|
--query 'Reservations[0].Instances[0].IamInstanceProfile.Arn' --output text)
|
|
if [[ "${PROFILE_ARN_FROM_DESC}" != *":instance-profile/${PROFILE_NAME}" ]]; then
|
|
echo " ERROR: describe-instances IamInstanceProfile.Arn = '${PROFILE_ARN_FROM_DESC}'"
|
|
exit 1
|
|
fi
|
|
|
|
# DescribeIamInstanceProfileAssociations
|
|
DESC_OUT=$(aws ec2 describe-iam-instance-profile-associations \
|
|
--association-ids "${ASSOC_ID}")
|
|
echo "${DESC_OUT}" | jq -e ".IamInstanceProfileAssociations[0].InstanceId == \"${INSTANCE_ID}\"" > /dev/null
|
|
|
|
# DeleteInstanceProfile while in-use by a live instance — refused
|
|
expect_error "DeleteConflict" aws iam delete-instance-profile \
|
|
--instance-profile-name "${PROFILE_NAME}"
|
|
|
|
# Replace association → other-profile (yields a fresh association id)
|
|
echo " Replacing association -> ${OTHER_PROFILE_NAME}..."
|
|
REPLACE_OUT=$(aws ec2 replace-iam-instance-profile-association \
|
|
--association-id "${ASSOC_ID}" \
|
|
--iam-instance-profile "Name=${OTHER_PROFILE_NAME}")
|
|
NEW_ASSOC_ID=$(echo "${REPLACE_OUT}" | jq -r '.IamInstanceProfileAssociation.AssociationId')
|
|
if [ "${NEW_ASSOC_ID}" = "${ASSOC_ID}" ] || [[ ! "${NEW_ASSOC_ID}" =~ ^iip-assoc- ]]; then
|
|
echo " ERROR: Replace must mint a new AssociationId (old=${ASSOC_ID}, new=${NEW_ASSOC_ID})"
|
|
exit 1
|
|
fi
|
|
|
|
# Replace with stale id
|
|
expect_error "NoSuchAssociation" aws ec2 replace-iam-instance-profile-association \
|
|
--association-id "${ASSOC_ID}" --iam-instance-profile "Name=${PROFILE_NAME}"
|
|
|
|
# Disassociate via the current (new) id
|
|
echo " Disassociating ${NEW_ASSOC_ID}..."
|
|
aws ec2 disassociate-iam-instance-profile --association-id "${NEW_ASSOC_ID}" > /dev/null
|
|
|
|
# Disassociate again with stale id
|
|
expect_error "NoSuchAssociation" aws ec2 disassociate-iam-instance-profile \
|
|
--association-id "${NEW_ASSOC_ID}"
|
|
|
|
# DescribeInstances no longer carries the profile after Disassociate.
|
|
# AWS CLI --output text prints "None" when the field is null.
|
|
POST_PROFILE=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \
|
|
--query 'Reservations[0].Instances[0].IamInstanceProfile.Arn' --output text)
|
|
if [ "${POST_PROFILE}" != "None" ]; then
|
|
echo " ERROR: IamInstanceProfile.Arn still set after disassociate: '${POST_PROFILE}'"
|
|
exit 1
|
|
fi
|
|
|
|
# RunInstances --iam-instance-profile at launch + auto-disassociate on terminate.
|
|
# Reuse the AMI/type/key/subnet/SG the singleton was launched against in one
|
|
# describe-instances round trip.
|
|
EPH_DESC=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \
|
|
--query 'Reservations[0].Instances[0].[ImageId,InstanceType,KeyName,SubnetId,SecurityGroups[0].GroupId]' \
|
|
--output text)
|
|
EPH_AMI=$(echo "${EPH_DESC}" | awk '{print $1}')
|
|
EPH_TYPE=$(echo "${EPH_DESC}" | awk '{print $2}')
|
|
EPH_KEY=$(echo "${EPH_DESC}" | awk '{print $3}')
|
|
EPH_SUBNET=$(echo "${EPH_DESC}" | awk '{print $4}')
|
|
EPH_SG=$(echo "${EPH_DESC}" | awk '{print $5}')
|
|
|
|
echo " Launching ephemeral VM with --iam-instance-profile ${PROFILE_NAME}..."
|
|
EPH_RUN=$(aws ec2 run-instances \
|
|
--image-id "${EPH_AMI}" --instance-type "${EPH_TYPE}" \
|
|
--key-name "${EPH_KEY}" --subnet-id "${EPH_SUBNET}" \
|
|
--security-group-ids "${EPH_SG}" \
|
|
--iam-instance-profile "Name=${PROFILE_NAME}" \
|
|
--count 1)
|
|
EPH_ID=$(echo "${EPH_RUN}" | jq -r '.Instances[0].InstanceId')
|
|
if [[ ! "${EPH_ID}" =~ ^i- ]]; then
|
|
echo " ERROR: Failed to launch ephemeral VM (got '${EPH_ID}')"
|
|
exit 1
|
|
fi
|
|
echo " Ephemeral VM: ${EPH_ID}"
|
|
|
|
# Poll until running so DescribeInstances surfaces a stable profile field.
|
|
for i in $(seq 1 60); do
|
|
EPH_STATE=$(aws ec2 describe-instances --instance-ids "${EPH_ID}" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text)
|
|
if [ "${EPH_STATE}" = "running" ]; then break; fi
|
|
sleep 2
|
|
done
|
|
if [ "${EPH_STATE}" != "running" ]; then
|
|
echo " ERROR: Ephemeral VM never reached running (last=${EPH_STATE})"
|
|
aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null || true
|
|
exit 1
|
|
fi
|
|
|
|
EPH_ASSOC_ID=$(aws ec2 describe-iam-instance-profile-associations \
|
|
--filters "Name=instance-id,Values=${EPH_ID}" \
|
|
--query 'IamInstanceProfileAssociations[0].AssociationId' --output text)
|
|
if [[ ! "${EPH_ASSOC_ID}" =~ ^iip-assoc- ]]; then
|
|
echo " ERROR: ephemeral VM has no association (got '${EPH_ASSOC_ID}')"
|
|
aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null || true
|
|
exit 1
|
|
fi
|
|
|
|
# Terminate → auto-disassociate
|
|
echo " Terminating ${EPH_ID} (expect auto-disassociate)..."
|
|
aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null
|
|
for i in $(seq 1 60); do
|
|
EPH_STATE=$(aws ec2 describe-instances --instance-ids "${EPH_ID}" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "gone")
|
|
if [ "${EPH_STATE}" = "terminated" ] || [ "${EPH_STATE}" = "gone" ]; then break; fi
|
|
sleep 2
|
|
done
|
|
|
|
# Auto-disassociate is flushed from the daemon's terminate handler — wait
|
|
# until the binding is cleared (up to 30s) so the NoSuchAssociation assertion
|
|
# is deterministic. Matches the Eventually(30s, 1s) loop in the Go port.
|
|
AUTO_DISASSOC_OK=0
|
|
for i in $(seq 1 30); do
|
|
if aws ec2 disassociate-iam-instance-profile --association-id "${EPH_ASSOC_ID}" 2>&1 \
|
|
| grep -q "NoSuchAssociation"; then
|
|
AUTO_DISASSOC_OK=1
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ "${AUTO_DISASSOC_OK}" -ne 1 ]; then
|
|
echo " ERROR: ${EPH_ASSOC_ID} never auto-disassociated after terminate"
|
|
exit 1
|
|
fi
|
|
echo " Auto-disassociated ${EPH_ASSOC_ID}"
|
|
|
|
# Cleanup: profiles + role + policy. Each step is best-effort so a partial
|
|
# failure further up doesn't cascade — matches IAM Phase 7 style.
|
|
aws iam remove-role-from-instance-profile \
|
|
--instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" || true
|
|
aws iam remove-role-from-instance-profile \
|
|
--instance-profile-name "${OTHER_PROFILE_NAME}" --role-name "${ROLE_NAME}" || true
|
|
aws iam delete-instance-profile --instance-profile-name "${PROFILE_NAME}" || true
|
|
aws iam delete-instance-profile --instance-profile-name "${OTHER_PROFILE_NAME}" || true
|
|
aws iam detach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}" || true
|
|
aws iam delete-role --role-name "${ROLE_NAME}" || true
|
|
|
|
# Verify role gone
|
|
expect_error "NoSuchEntity" aws iam get-role --role-name "${ROLE_NAME}"
|
|
|
|
echo "IAM Phase 9 passed"
|
|
echo ""
|
|
echo "IAM E2E Tests Completed Successfully"
|
|
|
|
# =============================================================================
|
|
# Phase 8: EC2 Account Scoping
|
|
# =============================================================================
|
|
# Tests that EC2 resources (instances, volumes, key pairs, snapshots, VPCs,
|
|
# IGWs, EIGWs) are properly isolated between tenant accounts.
|
|
# Based on: docs/development/feature/iam-phase4-e2e-test-guide.md
|
|
# Skips: Section 6 (CreateImage — mulga-612), Tags on instances (mulga-613)
|
|
echo ""
|
|
echo "Phase 8: EC2 Account Scoping"
|
|
echo "========================================"
|
|
|
|
# --- Step 1: Account Setup ---
|
|
echo ""
|
|
echo "Phase 8 Step 1: Account Setup"
|
|
echo "----------------------------------------"
|
|
|
|
echo " Creating Team Alpha account..."
|
|
ALPHA_OUTPUT=$(spx admin account create --name "Team Alpha" 2>&1)
|
|
echo "$ALPHA_OUTPUT"
|
|
ALPHA_ACCOUNT=$(echo "$ALPHA_OUTPUT" | grep "Account ID:" | awk '{print $NF}')
|
|
ALPHA_KEY_ID=$(echo "$ALPHA_OUTPUT" | grep "Access Key ID:" | awk '{print $NF}')
|
|
ALPHA_SECRET=$(echo "$ALPHA_OUTPUT" | grep "Secret Access Key:" | awk '{print $NF}')
|
|
|
|
if [ -z "$ALPHA_ACCOUNT" ] || [ -z "$ALPHA_KEY_ID" ]; then
|
|
echo " ERROR: Failed to parse Team Alpha account output"
|
|
exit 1
|
|
fi
|
|
echo " Team Alpha: account=$ALPHA_ACCOUNT key=$ALPHA_KEY_ID"
|
|
setup_test_profile spx-team-alpha "$ALPHA_KEY_ID" "$ALPHA_SECRET"
|
|
|
|
echo " Creating Team Beta account..."
|
|
BETA_OUTPUT=$(spx admin account create --name "Team Beta" 2>&1)
|
|
echo "$BETA_OUTPUT"
|
|
BETA_ACCOUNT=$(echo "$BETA_OUTPUT" | grep "Account ID:" | awk '{print $NF}')
|
|
BETA_KEY_ID=$(echo "$BETA_OUTPUT" | grep "Access Key ID:" | awk '{print $NF}')
|
|
BETA_SECRET=$(echo "$BETA_OUTPUT" | grep "Secret Access Key:" | awk '{print $NF}')
|
|
|
|
if [ -z "$BETA_ACCOUNT" ] || [ -z "$BETA_KEY_ID" ]; then
|
|
echo " ERROR: Failed to parse Team Beta account output"
|
|
exit 1
|
|
fi
|
|
echo " Team Beta: account=$BETA_ACCOUNT key=$BETA_KEY_ID"
|
|
setup_test_profile spx-team-beta "$BETA_KEY_ID" "$BETA_SECRET"
|
|
|
|
# Verify auth
|
|
aws ec2 describe-instances --profile spx-team-alpha > /dev/null
|
|
echo " Alpha auth OK"
|
|
aws ec2 describe-instances --profile spx-team-beta > /dev/null
|
|
echo " Beta auth OK"
|
|
|
|
echo " Account setup complete"
|
|
|
|
# --- Step 2: Instance Scoping ---
|
|
echo ""
|
|
echo "Phase 8 Step 2: Instance Scoping"
|
|
echo "----------------------------------------"
|
|
|
|
# Create per-account key pairs (key pairs are account-scoped, root's test-key-1 is invisible)
|
|
aws ec2 create-key-pair --key-name alpha-instance-key --profile spx-team-alpha > /dev/null
|
|
aws ec2 create-key-pair --key-name beta-instance-key --profile spx-team-beta > /dev/null
|
|
echo " Created per-account key pairs for instance launches"
|
|
|
|
echo " Alpha launching instance..."
|
|
ALPHA_RUN=$(aws ec2 run-instances \
|
|
--image-id "$AMI_ID" \
|
|
--instance-type "$INSTANCE_TYPE" \
|
|
--key-name alpha-instance-key \
|
|
--profile spx-team-alpha)
|
|
ALPHA_INST=$(echo "$ALPHA_RUN" | jq -r '.Instances[0].InstanceId')
|
|
echo " Alpha instance: $ALPHA_INST"
|
|
|
|
echo " Beta launching instance..."
|
|
BETA_RUN=$(aws ec2 run-instances \
|
|
--image-id "$AMI_ID" \
|
|
--instance-type "$INSTANCE_TYPE" \
|
|
--key-name beta-instance-key \
|
|
--profile spx-team-beta)
|
|
BETA_INST=$(echo "$BETA_RUN" | jq -r '.Instances[0].InstanceId')
|
|
echo " Beta instance: $BETA_INST"
|
|
|
|
# Wait for running
|
|
echo " Waiting for instances to reach running state..."
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
A_STATE=$(aws ec2 describe-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending")
|
|
B_STATE=$(aws ec2 describe-instances --instance-ids "$BETA_INST" --profile spx-team-beta \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending")
|
|
echo " Alpha=$A_STATE, Beta=$B_STATE"
|
|
if [ "$A_STATE" == "running" ] && [ "$B_STATE" == "running" ]; then break; fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
if [ "$A_STATE" != "running" ] || [ "$B_STATE" != "running" ]; then
|
|
echo " ERROR: Instances failed to reach running state"
|
|
exit 1
|
|
fi
|
|
echo " Both instances running"
|
|
|
|
# Describe isolation
|
|
ALPHA_INSTANCES=$(aws ec2 describe-instances --profile spx-team-alpha \
|
|
--query 'Reservations[].Instances[].InstanceId' --output text)
|
|
if echo "$ALPHA_INSTANCES" | grep -q "$BETA_INST"; then
|
|
echo " ERROR: Alpha can see Beta's instance"
|
|
exit 1
|
|
fi
|
|
echo " Alpha sees only own instances"
|
|
|
|
BETA_INSTANCES=$(aws ec2 describe-instances --profile spx-team-beta \
|
|
--query 'Reservations[].Instances[].InstanceId' --output text)
|
|
if echo "$BETA_INSTANCES" | grep -q "$ALPHA_INST"; then
|
|
echo " ERROR: Beta can see Alpha's instance"
|
|
exit 1
|
|
fi
|
|
echo " Beta sees only own instances"
|
|
|
|
# OwnerId verification
|
|
ALPHA_OWNER=$(aws ec2 describe-instances --profile spx-team-alpha \
|
|
--query 'Reservations[0].OwnerId' --output text)
|
|
if [ "$ALPHA_OWNER" != "$ALPHA_ACCOUNT" ]; then
|
|
echo " ERROR: Alpha OwnerId mismatch: expected $ALPHA_ACCOUNT, got $ALPHA_OWNER"
|
|
exit 1
|
|
fi
|
|
echo " Alpha OwnerId correct: $ALPHA_OWNER"
|
|
|
|
# Cross-account operations
|
|
expect_error "InvalidInstanceID.NotFound" \
|
|
aws ec2 stop-instances --instance-ids "$BETA_INST" --profile spx-team-alpha
|
|
echo " Alpha cannot stop Beta's instance"
|
|
|
|
expect_error "InvalidInstanceID.NotFound" \
|
|
aws ec2 terminate-instances --instance-ids "$ALPHA_INST" --profile spx-team-beta
|
|
echo " Beta cannot terminate Alpha's instance"
|
|
|
|
expect_error "InvalidInstanceID.NotFound" \
|
|
aws ec2 reboot-instances --instance-ids "$BETA_INST" --profile spx-team-alpha
|
|
echo " Alpha cannot reboot Beta's instance"
|
|
|
|
# Stop Alpha's instance for cross-account start/modify/console tests
|
|
aws ec2 stop-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha > /dev/null
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
A_STATE=$(aws ec2 describe-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null)
|
|
if [ "$A_STATE" == "stopped" ]; then break; fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
expect_error "InvalidInstanceID.NotFound" \
|
|
aws ec2 start-instances --instance-ids "$ALPHA_INST" --profile spx-team-beta
|
|
echo " Beta cannot start Alpha's stopped instance"
|
|
|
|
expect_error "InvalidInstanceID.NotFound" \
|
|
aws ec2 modify-instance-attribute --instance-id "$ALPHA_INST" \
|
|
--instance-type '{"Value":"t2.small"}' --profile spx-team-beta
|
|
echo " Beta cannot modify Alpha's instance"
|
|
|
|
expect_error "InvalidInstanceID.NotFound" \
|
|
aws ec2 get-console-output --instance-id "$ALPHA_INST" --profile spx-team-beta
|
|
echo " Beta cannot get console output of Alpha's instance"
|
|
|
|
# Restart Alpha's instance for later tests
|
|
aws ec2 start-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha > /dev/null
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
A_STATE=$(aws ec2 describe-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null)
|
|
if [ "$A_STATE" == "running" ]; then break; fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
echo " Instance scoping passed"
|
|
|
|
# --- Step 3: Volume Scoping ---
|
|
echo ""
|
|
echo "Phase 8 Step 3: Volume Scoping"
|
|
echo "----------------------------------------"
|
|
|
|
ALPHA_VOL=$(aws ec2 create-volume --availability-zone "$AZ_NAME" --size 10 \
|
|
--volume-type gp3 --profile spx-team-alpha | jq -r '.VolumeId')
|
|
echo " Alpha volume: $ALPHA_VOL"
|
|
|
|
BETA_VOL=$(aws ec2 create-volume --availability-zone "$AZ_NAME" --size 10 \
|
|
--volume-type gp3 --profile spx-team-beta | jq -r '.VolumeId')
|
|
echo " Beta volume: $BETA_VOL"
|
|
|
|
# Describe isolation
|
|
ALPHA_VOLS=$(aws ec2 describe-volumes --profile spx-team-alpha \
|
|
--query 'Volumes[].VolumeId' --output text)
|
|
if echo "$ALPHA_VOLS" | grep -q "$BETA_VOL"; then
|
|
echo " ERROR: Alpha can see Beta's volume"
|
|
exit 1
|
|
fi
|
|
echo " Alpha sees only own volumes"
|
|
|
|
# Cross-account filter
|
|
expect_error "InvalidVolume.NotFound" \
|
|
aws ec2 describe-volumes --volume-ids "$BETA_VOL" --profile spx-team-alpha
|
|
echo " Alpha cannot describe Beta's volume by ID"
|
|
|
|
# Cross-account delete
|
|
expect_error "InvalidVolume.NotFound" \
|
|
aws ec2 delete-volume --volume-id "$ALPHA_VOL" --profile spx-team-beta
|
|
echo " Beta cannot delete Alpha's volume"
|
|
|
|
# Cross-account attach (other's volume)
|
|
expect_error "InvalidVolume.NotFound" \
|
|
aws ec2 attach-volume --volume-id "$ALPHA_VOL" \
|
|
--instance-id "$BETA_INST" --device /dev/sdf --profile spx-team-beta
|
|
echo " Beta cannot attach Alpha's volume"
|
|
|
|
# Attach Alpha's volume to Alpha's instance, then test cross-account detach
|
|
aws ec2 attach-volume --volume-id "$ALPHA_VOL" \
|
|
--instance-id "$ALPHA_INST" --device /dev/sdf --profile spx-team-alpha > /dev/null
|
|
sleep 1
|
|
|
|
expect_error "InvalidVolume.NotFound" \
|
|
aws ec2 detach-volume --volume-id "$ALPHA_VOL" --profile spx-team-beta
|
|
echo " Beta cannot detach Alpha's volume"
|
|
|
|
# Cross-account modify
|
|
expect_error "InvalidVolume.NotFound" \
|
|
aws ec2 modify-volume --volume-id "$ALPHA_VOL" --size 20 --profile spx-team-beta
|
|
echo " Beta cannot modify Alpha's volume"
|
|
|
|
# Detach for cleanup later
|
|
aws ec2 detach-volume --volume-id "$ALPHA_VOL" --profile spx-team-alpha > /dev/null
|
|
sleep 1
|
|
|
|
echo " Volume scoping passed"
|
|
|
|
# --- Step 4: Key Pair Scoping ---
|
|
echo ""
|
|
echo "Phase 8 Step 4: Key Pair Scoping"
|
|
echo "----------------------------------------"
|
|
|
|
aws ec2 create-key-pair --key-name alpha-key --profile spx-team-alpha > /dev/null
|
|
ALPHA_KEYPAIR_ID=$(aws ec2 describe-key-pairs --key-names alpha-key \
|
|
--profile spx-team-alpha --query 'KeyPairs[0].KeyPairId' --output text)
|
|
echo " Alpha key: alpha-key ($ALPHA_KEYPAIR_ID)"
|
|
|
|
aws ec2 create-key-pair --key-name beta-key --profile spx-team-beta > /dev/null
|
|
echo " Beta key: beta-key"
|
|
|
|
# Describe isolation
|
|
ALPHA_KEYS=$(aws ec2 describe-key-pairs --profile spx-team-alpha \
|
|
--query 'KeyPairs[].KeyName' --output text)
|
|
if echo "$ALPHA_KEYS" | grep -q "beta-key"; then
|
|
echo " ERROR: Alpha can see Beta's key"
|
|
exit 1
|
|
fi
|
|
echo " Alpha sees only own keys"
|
|
|
|
# Same name in both accounts (namespace isolation)
|
|
aws ec2 create-key-pair --key-name shared-name --profile spx-team-alpha > /dev/null
|
|
aws ec2 create-key-pair --key-name shared-name --profile spx-team-beta > /dev/null
|
|
ALPHA_SHARED_ID=$(aws ec2 describe-key-pairs --key-names shared-name \
|
|
--profile spx-team-alpha --query 'KeyPairs[0].KeyPairId' --output text)
|
|
BETA_SHARED_ID=$(aws ec2 describe-key-pairs --key-names shared-name \
|
|
--profile spx-team-beta --query 'KeyPairs[0].KeyPairId' --output text)
|
|
if [ "$ALPHA_SHARED_ID" == "$BETA_SHARED_ID" ]; then
|
|
echo " ERROR: Same KeyPairId for shared-name in both accounts"
|
|
exit 1
|
|
fi
|
|
echo " Namespace isolation: alpha=$ALPHA_SHARED_ID, beta=$BETA_SHARED_ID"
|
|
|
|
# Cross-account delete (idempotent, but shouldn't affect other account)
|
|
aws ec2 delete-key-pair --key-name alpha-key --profile spx-team-beta
|
|
ALPHA_KEY_CHECK=$(aws ec2 describe-key-pairs --key-names alpha-key \
|
|
--profile spx-team-alpha --query 'KeyPairs[0].KeyPairId' --output text)
|
|
if [ "$ALPHA_KEY_CHECK" != "$ALPHA_KEYPAIR_ID" ]; then
|
|
echo " ERROR: Beta's delete affected Alpha's key"
|
|
exit 1
|
|
fi
|
|
echo " Cross-account delete had no effect on Alpha's key"
|
|
|
|
# Import key pair — account scoped
|
|
ssh-keygen -t ed25519 -f /tmp/test-import-key -N "" -q
|
|
aws ec2 import-key-pair --key-name imported-key \
|
|
--public-key-material fileb:///tmp/test-import-key.pub --profile spx-team-alpha > /dev/null
|
|
BETA_IMPORT_CHECK=$(aws ec2 describe-key-pairs --profile spx-team-beta \
|
|
--query 'KeyPairs[].KeyName' --output text)
|
|
if echo "$BETA_IMPORT_CHECK" | grep -q "imported-key"; then
|
|
echo " ERROR: Beta can see Alpha's imported key"
|
|
exit 1
|
|
fi
|
|
echo " Imported key invisible to Beta"
|
|
rm -f /tmp/test-import-key /tmp/test-import-key.pub
|
|
|
|
echo " Key pair scoping passed"
|
|
|
|
# --- Step 5: Snapshot Scoping ---
|
|
echo ""
|
|
echo "Phase 8 Step 5: Snapshot Scoping"
|
|
echo "----------------------------------------"
|
|
|
|
ALPHA_SNAP=$(aws ec2 create-snapshot --volume-id "$ALPHA_VOL" \
|
|
--description "Alpha snapshot" --profile spx-team-alpha | jq -r '.SnapshotId')
|
|
echo " Alpha snapshot: $ALPHA_SNAP"
|
|
|
|
BETA_SNAP=$(aws ec2 create-snapshot --volume-id "$BETA_VOL" \
|
|
--description "Beta snapshot" --profile spx-team-beta | jq -r '.SnapshotId')
|
|
echo " Beta snapshot: $BETA_SNAP"
|
|
|
|
# Describe isolation
|
|
ALPHA_SNAPS=$(aws ec2 describe-snapshots --owner-ids self --profile spx-team-alpha \
|
|
--query 'Snapshots[].SnapshotId' --output text)
|
|
if echo "$ALPHA_SNAPS" | grep -q "$BETA_SNAP"; then
|
|
echo " ERROR: Alpha can see Beta's snapshot"
|
|
exit 1
|
|
fi
|
|
echo " Alpha sees only own snapshots"
|
|
|
|
# OwnerId verification
|
|
ALPHA_SNAP_OWNER=$(aws ec2 describe-snapshots --owner-ids self --profile spx-team-alpha \
|
|
--query 'Snapshots[0].OwnerId' --output text)
|
|
if [ "$ALPHA_SNAP_OWNER" != "$ALPHA_ACCOUNT" ]; then
|
|
echo " ERROR: Snapshot OwnerId mismatch: expected $ALPHA_ACCOUNT, got $ALPHA_SNAP_OWNER"
|
|
exit 1
|
|
fi
|
|
echo " Alpha snapshot OwnerId correct"
|
|
|
|
# Cross-account delete
|
|
expect_error "UnauthorizedOperation" \
|
|
aws ec2 delete-snapshot --snapshot-id "$ALPHA_SNAP" --profile spx-team-beta
|
|
echo " Beta cannot delete Alpha's snapshot"
|
|
|
|
# Cross-account snapshot from other's volume
|
|
expect_error "InvalidVolume.NotFound" \
|
|
aws ec2 create-snapshot --volume-id "$ALPHA_VOL" \
|
|
--description "stolen" --profile spx-team-beta
|
|
echo " Beta cannot snapshot Alpha's volume"
|
|
|
|
echo " Snapshot scoping passed"
|
|
|
|
# --- Step 6: VPC/Subnet Scoping ---
|
|
echo ""
|
|
echo "Phase 8 Step 6: VPC/Subnet Scoping"
|
|
echo "----------------------------------------"
|
|
|
|
ALPHA_VPC=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
|
|
--profile spx-team-alpha --query 'Vpc.VpcId' --output text)
|
|
echo " Alpha VPC: $ALPHA_VPC"
|
|
|
|
BETA_VPC=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
|
|
--profile spx-team-beta --query 'Vpc.VpcId' --output text)
|
|
echo " Beta VPC: $BETA_VPC (same CIDR — no conflict)"
|
|
|
|
# Describe isolation
|
|
ALPHA_VPCS=$(aws ec2 describe-vpcs --profile spx-team-alpha \
|
|
--query 'Vpcs[].VpcId' --output text)
|
|
if echo "$ALPHA_VPCS" | grep -q "$BETA_VPC"; then
|
|
echo " ERROR: Alpha can see Beta's VPC"
|
|
exit 1
|
|
fi
|
|
echo " VPC describe isolation OK"
|
|
|
|
# Cross-account describe by ID
|
|
expect_error "InvalidVpcID.NotFound" \
|
|
aws ec2 describe-vpcs --vpc-ids "$BETA_VPC" --profile spx-team-alpha
|
|
echo " Alpha cannot describe Beta's VPC by ID"
|
|
|
|
# Cross-account delete
|
|
expect_error "InvalidVpcID.NotFound" \
|
|
aws ec2 delete-vpc --vpc-id "$ALPHA_VPC" --profile spx-team-beta
|
|
echo " Beta cannot delete Alpha's VPC"
|
|
|
|
# Create subnets
|
|
ALPHA_SUBNET=$(aws ec2 create-subnet --vpc-id "$ALPHA_VPC" --cidr-block 10.0.1.0/24 \
|
|
--profile spx-team-alpha --query 'Subnet.SubnetId' --output text)
|
|
echo " Alpha subnet: $ALPHA_SUBNET"
|
|
|
|
BETA_SUBNET=$(aws ec2 create-subnet --vpc-id "$BETA_VPC" --cidr-block 10.0.1.0/24 \
|
|
--profile spx-team-beta --query 'Subnet.SubnetId' --output text)
|
|
echo " Beta subnet: $BETA_SUBNET"
|
|
|
|
# Subnet describe isolation
|
|
ALPHA_SUBNETS=$(aws ec2 describe-subnets --profile spx-team-alpha \
|
|
--query 'Subnets[].SubnetId' --output text)
|
|
if echo "$ALPHA_SUBNETS" | grep -q "$BETA_SUBNET"; then
|
|
echo " ERROR: Alpha can see Beta's subnet"
|
|
exit 1
|
|
fi
|
|
echo " Subnet describe isolation OK"
|
|
|
|
# Cross-account create subnet in other's VPC
|
|
expect_error "InvalidVpcID.NotFound" \
|
|
aws ec2 create-subnet --vpc-id "$ALPHA_VPC" --cidr-block 10.0.2.0/24 \
|
|
--profile spx-team-beta
|
|
echo " Beta cannot create subnet in Alpha's VPC"
|
|
|
|
# Cross-account subnet delete
|
|
expect_error "InvalidSubnetID.NotFound" \
|
|
aws ec2 delete-subnet --subnet-id "$ALPHA_SUBNET" --profile spx-team-beta
|
|
echo " Beta cannot delete Alpha's subnet"
|
|
|
|
echo " VPC/Subnet scoping passed"
|
|
|
|
# --- Step 7: IGW + EIGW Scoping ---
|
|
echo ""
|
|
echo "Phase 8 Step 7: IGW + EIGW Scoping"
|
|
echo "----------------------------------------"
|
|
|
|
# IGW
|
|
ALPHA_IGW=$(aws ec2 create-internet-gateway --profile spx-team-alpha \
|
|
--query 'InternetGateway.InternetGatewayId' --output text)
|
|
echo " Alpha IGW: $ALPHA_IGW"
|
|
|
|
BETA_IGW=$(aws ec2 create-internet-gateway --profile spx-team-beta \
|
|
--query 'InternetGateway.InternetGatewayId' --output text)
|
|
echo " Beta IGW: $BETA_IGW"
|
|
|
|
# IGW describe isolation
|
|
ALPHA_IGWS=$(aws ec2 describe-internet-gateways --profile spx-team-alpha \
|
|
--query 'InternetGateways[].InternetGatewayId' --output text)
|
|
if echo "$ALPHA_IGWS" | grep -q "$BETA_IGW"; then
|
|
echo " ERROR: Alpha can see Beta's IGW"
|
|
exit 1
|
|
fi
|
|
echo " IGW describe isolation OK"
|
|
|
|
# Cross-account IGW describe by ID
|
|
expect_error "InvalidInternetGatewayID.NotFound" \
|
|
aws ec2 describe-internet-gateways --internet-gateway-ids "$BETA_IGW" \
|
|
--profile spx-team-alpha
|
|
echo " Alpha cannot describe Beta's IGW by ID"
|
|
|
|
# Cross-account IGW delete
|
|
expect_error "InvalidInternetGatewayID.NotFound" \
|
|
aws ec2 delete-internet-gateway --internet-gateway-id "$ALPHA_IGW" \
|
|
--profile spx-team-beta
|
|
echo " Beta cannot delete Alpha's IGW"
|
|
|
|
# Cross-account attach IGW to other's VPC
|
|
expect_error "InvalidInternetGatewayID.NotFound" \
|
|
aws ec2 attach-internet-gateway --internet-gateway-id "$BETA_IGW" \
|
|
--vpc-id "$ALPHA_VPC" --profile spx-team-alpha
|
|
echo " Alpha cannot attach Beta's IGW to own VPC"
|
|
|
|
# Attach Alpha's IGW, test cross-account detach
|
|
aws ec2 attach-internet-gateway --internet-gateway-id "$ALPHA_IGW" \
|
|
--vpc-id "$ALPHA_VPC" --profile spx-team-alpha > /dev/null
|
|
expect_error "InvalidInternetGatewayID.NotFound" \
|
|
aws ec2 detach-internet-gateway --internet-gateway-id "$ALPHA_IGW" \
|
|
--vpc-id "$ALPHA_VPC" --profile spx-team-beta
|
|
echo " Beta cannot detach Alpha's IGW"
|
|
|
|
# EIGW
|
|
ALPHA_EIGW=$(aws ec2 create-egress-only-internet-gateway --vpc-id "$ALPHA_VPC" \
|
|
--profile spx-team-alpha \
|
|
--query 'EgressOnlyInternetGateway.EgressOnlyInternetGatewayId' --output text)
|
|
echo " Alpha EIGW: $ALPHA_EIGW"
|
|
|
|
BETA_EIGW=$(aws ec2 create-egress-only-internet-gateway --vpc-id "$BETA_VPC" \
|
|
--profile spx-team-beta \
|
|
--query 'EgressOnlyInternetGateway.EgressOnlyInternetGatewayId' --output text)
|
|
echo " Beta EIGW: $BETA_EIGW"
|
|
|
|
# EIGW describe isolation
|
|
ALPHA_EIGWS=$(aws ec2 describe-egress-only-internet-gateways --profile spx-team-alpha \
|
|
--query 'EgressOnlyInternetGateways[].EgressOnlyInternetGatewayId' --output text)
|
|
if echo "$ALPHA_EIGWS" | grep -q "$BETA_EIGW"; then
|
|
echo " ERROR: Alpha can see Beta's EIGW"
|
|
exit 1
|
|
fi
|
|
echo " EIGW describe isolation OK"
|
|
|
|
# Cross-account EIGW delete
|
|
expect_error "" \
|
|
aws ec2 delete-egress-only-internet-gateway \
|
|
--egress-only-internet-gateway-id "$ALPHA_EIGW" --profile spx-team-beta 2>&1 || true
|
|
# Verify Alpha's EIGW still exists
|
|
ALPHA_EIGW_CHECK=$(aws ec2 describe-egress-only-internet-gateways --profile spx-team-alpha \
|
|
--query 'EgressOnlyInternetGateways[].EgressOnlyInternetGatewayId' --output text)
|
|
if ! echo "$ALPHA_EIGW_CHECK" | grep -q "$ALPHA_EIGW"; then
|
|
echo " ERROR: Alpha's EIGW was deleted by Beta"
|
|
exit 1
|
|
fi
|
|
echo " Beta cannot delete Alpha's EIGW"
|
|
|
|
# Cross-account EIGW creation in other's VPC
|
|
expect_error "" \
|
|
aws ec2 create-egress-only-internet-gateway --vpc-id "$ALPHA_VPC" \
|
|
--profile spx-team-beta 2>&1 || true
|
|
echo " Beta cannot create EIGW in Alpha's VPC"
|
|
|
|
echo " IGW + EIGW scoping passed"
|
|
|
|
# --- Step 8: Account Settings ---
|
|
echo ""
|
|
echo "Phase 8 Step 8: Account Settings"
|
|
echo "----------------------------------------"
|
|
|
|
aws ec2 enable-ebs-encryption-by-default --profile spx-team-alpha > /dev/null
|
|
BETA_ENC=$(aws ec2 get-ebs-encryption-by-default --profile spx-team-beta \
|
|
--query 'EbsEncryptionByDefault' --output text)
|
|
if [ "$BETA_ENC" != "False" ]; then
|
|
echo " ERROR: Alpha's encryption setting leaked to Beta (got $BETA_ENC)"
|
|
exit 1
|
|
fi
|
|
echo " Alpha enable did not affect Beta"
|
|
|
|
# Independent toggle
|
|
aws ec2 enable-ebs-encryption-by-default --profile spx-team-beta > /dev/null
|
|
aws ec2 disable-ebs-encryption-by-default --profile spx-team-alpha > /dev/null
|
|
ALPHA_ENC=$(aws ec2 get-ebs-encryption-by-default --profile spx-team-alpha \
|
|
--query 'EbsEncryptionByDefault' --output text)
|
|
BETA_ENC=$(aws ec2 get-ebs-encryption-by-default --profile spx-team-beta \
|
|
--query 'EbsEncryptionByDefault' --output text)
|
|
if [ "$ALPHA_ENC" != "False" ] || [ "$BETA_ENC" != "True" ]; then
|
|
echo " ERROR: Independent settings failed: alpha=$ALPHA_ENC beta=$BETA_ENC"
|
|
exit 1
|
|
fi
|
|
echo " Independent toggle verified: alpha=$ALPHA_ENC, beta=$BETA_ENC"
|
|
|
|
# Reset
|
|
aws ec2 disable-ebs-encryption-by-default --profile spx-team-beta > /dev/null
|
|
|
|
echo " Account settings scoping passed"
|
|
|
|
# --- Step 9: Global Resources ---
|
|
echo ""
|
|
echo "Phase 8 Step 9: Global Resources"
|
|
echo "----------------------------------------"
|
|
|
|
ALPHA_REGIONS=$(aws ec2 describe-regions --profile spx-team-alpha \
|
|
--query 'Regions[].RegionName' --output text)
|
|
BETA_REGIONS=$(aws ec2 describe-regions --profile spx-team-beta \
|
|
--query 'Regions[].RegionName' --output text)
|
|
if [ "$ALPHA_REGIONS" != "$BETA_REGIONS" ]; then
|
|
echo " ERROR: Regions differ between accounts"
|
|
exit 1
|
|
fi
|
|
echo " Regions identical"
|
|
|
|
ALPHA_AZS=$(aws ec2 describe-availability-zones --profile spx-team-alpha \
|
|
--query 'AvailabilityZones[].ZoneName' --output text)
|
|
BETA_AZS=$(aws ec2 describe-availability-zones --profile spx-team-beta \
|
|
--query 'AvailabilityZones[].ZoneName' --output text)
|
|
if [ "$ALPHA_AZS" != "$BETA_AZS" ]; then
|
|
echo " ERROR: AZs differ between accounts"
|
|
exit 1
|
|
fi
|
|
echo " Availability zones identical"
|
|
|
|
ALPHA_TYPES=$(aws ec2 describe-instance-types --profile spx-team-alpha \
|
|
--query 'InstanceTypes[].InstanceType' --output text | tr '\t' '\n' | sort)
|
|
BETA_TYPES=$(aws ec2 describe-instance-types --profile spx-team-beta \
|
|
--query 'InstanceTypes[].InstanceType' --output text | tr '\t' '\n' | sort)
|
|
if [ "$ALPHA_TYPES" != "$BETA_TYPES" ]; then
|
|
echo " ERROR: Instance types differ between accounts"
|
|
exit 1
|
|
fi
|
|
echo " Instance types identical"
|
|
|
|
echo " Global resources passed"
|
|
|
|
# --- Step 10: Edge Cases ---
|
|
echo ""
|
|
echo "Phase 8 Step 10: Edge Cases"
|
|
echo "----------------------------------------"
|
|
|
|
# Empty account (Gamma)
|
|
echo " Creating empty Gamma account..."
|
|
GAMMA_OUTPUT=$(spx admin account create --name "Team Gamma" 2>&1)
|
|
GAMMA_KEY_ID=$(echo "$GAMMA_OUTPUT" | grep "Access Key ID:" | awk '{print $NF}')
|
|
GAMMA_SECRET=$(echo "$GAMMA_OUTPUT" | grep "Secret Access Key:" | awk '{print $NF}')
|
|
setup_test_profile spx-team-gamma "$GAMMA_KEY_ID" "$GAMMA_SECRET"
|
|
|
|
GAMMA_INSTANCES=$(aws ec2 describe-instances --profile spx-team-gamma \
|
|
--query 'Reservations' --output text)
|
|
if [ -n "$GAMMA_INSTANCES" ] && [ "$GAMMA_INSTANCES" != "None" ]; then
|
|
echo " ERROR: Gamma has instances"
|
|
exit 1
|
|
fi
|
|
echo " Gamma: no instances"
|
|
|
|
# Skip volume check: root-account volumes (empty TenantID) are visible to all accounts by design
|
|
echo " Gamma: volumes skipped (root legacy volumes visible to all)"
|
|
|
|
GAMMA_KEYS=$(aws ec2 describe-key-pairs --profile spx-team-gamma \
|
|
--query 'KeyPairs' --output text)
|
|
if [ -n "$GAMMA_KEYS" ] && [ "$GAMMA_KEYS" != "None" ]; then
|
|
echo " ERROR: Gamma has key pairs"
|
|
exit 1
|
|
fi
|
|
echo " Gamma: no key pairs"
|
|
|
|
GAMMA_SNAPS=$(aws ec2 describe-snapshots --owner-ids self --profile spx-team-gamma \
|
|
--query 'Snapshots' --output text)
|
|
if [ -n "$GAMMA_SNAPS" ] && [ "$GAMMA_SNAPS" != "None" ]; then
|
|
echo " ERROR: Gamma has snapshots"
|
|
exit 1
|
|
fi
|
|
echo " Gamma: no snapshots"
|
|
|
|
# Root isolation from tenants
|
|
echo " Verifying root isolation from tenants..."
|
|
aws ec2 create-key-pair --key-name root-scoping-key > /dev/null
|
|
ALPHA_ROOT_CHECK=$(aws ec2 describe-key-pairs --profile spx-team-alpha \
|
|
--query 'KeyPairs[].KeyName' --output text)
|
|
if echo "$ALPHA_ROOT_CHECK" | grep -q "root-scoping-key"; then
|
|
echo " ERROR: Alpha can see root's key pair"
|
|
exit 1
|
|
fi
|
|
echo " Tenants cannot see root's key pairs"
|
|
|
|
ROOT_INSTANCE_CHECK=$(aws ec2 describe-instances \
|
|
--query 'Reservations[].Instances[].InstanceId' --output text)
|
|
if echo "$ROOT_INSTANCE_CHECK" | grep -q "$ALPHA_INST"; then
|
|
echo " ERROR: Root can see Alpha's instance"
|
|
exit 1
|
|
fi
|
|
echo " Root cannot see tenant instances"
|
|
aws ec2 delete-key-pair --key-name root-scoping-key > /dev/null
|
|
|
|
# Non-existent resource IDs — same error as cross-account
|
|
expect_error "InvalidVolume.NotFound" \
|
|
aws ec2 delete-volume --volume-id vol-00000000000000000 --profile spx-team-alpha
|
|
echo " Non-existent volume: same error as cross-account"
|
|
|
|
expect_error "InvalidSnapshot.NotFound" \
|
|
aws ec2 delete-snapshot --snapshot-id snap-00000000000000000 --profile spx-team-alpha
|
|
echo " Non-existent snapshot: same error as cross-account"
|
|
|
|
echo " Edge cases passed"
|
|
|
|
# --- Step 11: EC2 Account Scoping Cleanup ---
|
|
echo ""
|
|
echo "Phase 8 Step 11: EC2 Account Scoping Cleanup"
|
|
echo "----------------------------------------"
|
|
|
|
# Terminate instances
|
|
echo " Terminating Alpha instance..."
|
|
aws ec2 terminate-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha > /dev/null
|
|
echo " Terminating Beta instance..."
|
|
aws ec2 terminate-instances --instance-ids "$BETA_INST" --profile spx-team-beta > /dev/null
|
|
|
|
# Wait for termination
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
A_STATE=$(aws ec2 describe-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated")
|
|
B_STATE=$(aws ec2 describe-instances --instance-ids "$BETA_INST" --profile spx-team-beta \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated")
|
|
if [ "$A_STATE" == "terminated" ] && [ "$B_STATE" == "terminated" ]; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
echo " Instances terminated"
|
|
|
|
# Delete snapshots
|
|
echo " Deleting snapshots..."
|
|
aws ec2 delete-snapshot --snapshot-id "$ALPHA_SNAP" --profile spx-team-alpha 2>/dev/null || true
|
|
aws ec2 delete-snapshot --snapshot-id "$BETA_SNAP" --profile spx-team-beta 2>/dev/null || true
|
|
|
|
# Delete volumes (wait for detach/termination cleanup)
|
|
sleep 3
|
|
echo " Deleting volumes..."
|
|
aws ec2 delete-volume --volume-id "$ALPHA_VOL" --profile spx-team-alpha 2>/dev/null || true
|
|
aws ec2 delete-volume --volume-id "$BETA_VOL" --profile spx-team-beta 2>/dev/null || true
|
|
|
|
# Delete key pairs
|
|
echo " Deleting key pairs..."
|
|
for key in alpha-key alpha-instance-key shared-name imported-key; do
|
|
aws ec2 delete-key-pair --key-name "$key" --profile spx-team-alpha 2>/dev/null || true
|
|
done
|
|
for key in beta-key beta-instance-key shared-name; do
|
|
aws ec2 delete-key-pair --key-name "$key" --profile spx-team-beta 2>/dev/null || true
|
|
done
|
|
|
|
# Delete EIGWs
|
|
echo " Deleting EIGWs..."
|
|
aws ec2 delete-egress-only-internet-gateway \
|
|
--egress-only-internet-gateway-id "$ALPHA_EIGW" --profile spx-team-alpha 2>/dev/null || true
|
|
aws ec2 delete-egress-only-internet-gateway \
|
|
--egress-only-internet-gateway-id "$BETA_EIGW" --profile spx-team-beta 2>/dev/null || true
|
|
|
|
# Detach + delete IGWs
|
|
echo " Deleting IGWs..."
|
|
aws ec2 detach-internet-gateway --internet-gateway-id "$ALPHA_IGW" \
|
|
--vpc-id "$ALPHA_VPC" --profile spx-team-alpha 2>/dev/null || true
|
|
aws ec2 delete-internet-gateway --internet-gateway-id "$ALPHA_IGW" \
|
|
--profile spx-team-alpha 2>/dev/null || true
|
|
aws ec2 delete-internet-gateway --internet-gateway-id "$BETA_IGW" \
|
|
--profile spx-team-beta 2>/dev/null || true
|
|
|
|
# Delete subnets
|
|
echo " Deleting subnets..."
|
|
aws ec2 delete-subnet --subnet-id "$ALPHA_SUBNET" --profile spx-team-alpha 2>/dev/null || true
|
|
aws ec2 delete-subnet --subnet-id "$BETA_SUBNET" --profile spx-team-beta 2>/dev/null || true
|
|
|
|
# Delete VPCs
|
|
echo " Deleting VPCs..."
|
|
aws ec2 delete-vpc --vpc-id "$ALPHA_VPC" --profile spx-team-alpha 2>/dev/null || true
|
|
aws ec2 delete-vpc --vpc-id "$BETA_VPC" --profile spx-team-beta 2>/dev/null || true
|
|
|
|
# Clean up AWS CLI profiles
|
|
for p in spx-team-alpha spx-team-beta spx-team-gamma; do
|
|
aws configure set aws_access_key_id "" --profile $p 2>/dev/null || true
|
|
aws configure set aws_secret_access_key "" --profile $p 2>/dev/null || true
|
|
done
|
|
|
|
echo " EC2 account scoping cleanup complete"
|
|
echo ""
|
|
echo "Phase 8: EC2 Account Scoping PASSED"
|
|
|
|
# Phase 8c: Route Table Validation
|
|
echo ""
|
|
echo "Phase 8c: Route Table Validation"
|
|
echo "========================================"
|
|
|
|
# Step 1: Default VPC has a main route table with local + IGW routes
|
|
echo "Step 1: Verify default VPC main route table"
|
|
RTB_DEFAULT_VPC=$(aws ec2 describe-vpcs --query 'Vpcs[?IsDefault==`true`].VpcId | [0]' --output text)
|
|
if [ -z "$RTB_DEFAULT_VPC" ] || [ "$RTB_DEFAULT_VPC" = "None" ]; then
|
|
echo "FAIL: No default VPC found — route table tests require a default VPC"
|
|
exit 1
|
|
fi
|
|
|
|
MAIN_RTB=$(aws ec2 describe-route-tables \
|
|
--filters "Name=vpc-id,Values=$RTB_DEFAULT_VPC" "Name=association.main,Values=true" \
|
|
--query 'RouteTables[0].RouteTableId' --output text)
|
|
if [ -z "$MAIN_RTB" ] || [ "$MAIN_RTB" = "None" ]; then
|
|
echo "FAIL: No main route table found for default VPC $RTB_DEFAULT_VPC"
|
|
exit 1
|
|
fi
|
|
echo " Main route table: $MAIN_RTB"
|
|
|
|
# Verify local route exists
|
|
LOCAL_ROUTE=$(aws ec2 describe-route-tables --route-table-ids "$MAIN_RTB" \
|
|
--query 'RouteTables[0].Routes[?GatewayId==`local`].DestinationCidrBlock | [0]' --output text)
|
|
if [ -z "$LOCAL_ROUTE" ] || [ "$LOCAL_ROUTE" = "None" ]; then
|
|
echo "FAIL: Main route table missing local route"
|
|
exit 1
|
|
fi
|
|
echo " Local route: $LOCAL_ROUTE → local"
|
|
|
|
# Verify IGW route exists
|
|
IGW_ROUTE=$(aws ec2 describe-route-tables --route-table-ids "$MAIN_RTB" \
|
|
--query 'RouteTables[0].Routes[?DestinationCidrBlock==`0.0.0.0/0`].GatewayId | [0]' --output text)
|
|
if [ -z "$IGW_ROUTE" ] || [ "$IGW_ROUTE" = "None" ]; then
|
|
echo "FAIL: Main route table missing 0.0.0.0/0 → IGW route"
|
|
exit 1
|
|
fi
|
|
echo " Default route: 0.0.0.0/0 → $IGW_ROUTE"
|
|
echo " PASS: Default VPC main route table verified"
|
|
|
|
# Step 2: Custom route table lifecycle
|
|
echo ""
|
|
echo "Step 2: Custom route table CRUD lifecycle"
|
|
RTB_VPC=$RTB_DEFAULT_VPC
|
|
|
|
# Create custom route table
|
|
CUSTOM_RTB=$(aws ec2 create-route-table --vpc-id "$RTB_VPC" \
|
|
--query 'RouteTable.RouteTableId' --output text)
|
|
echo " Created: $CUSTOM_RTB"
|
|
|
|
# Verify local route present
|
|
CUSTOM_LOCAL=$(aws ec2 describe-route-tables --route-table-ids "$CUSTOM_RTB" \
|
|
--query 'RouteTables[0].Routes[?GatewayId==`local`].DestinationCidrBlock | [0]' --output text)
|
|
if [ -z "$CUSTOM_LOCAL" ] || [ "$CUSTOM_LOCAL" = "None" ]; then
|
|
echo "FAIL: Custom route table missing local route"
|
|
exit 1
|
|
fi
|
|
echo " Local route present: $CUSTOM_LOCAL"
|
|
|
|
# Find attached IGW for this VPC
|
|
RTB_IGW=$(aws ec2 describe-internet-gateways \
|
|
--filters "Name=attachment.vpc-id,Values=$RTB_VPC" \
|
|
--query 'InternetGateways[0].InternetGatewayId' --output text)
|
|
|
|
# Add IGW route
|
|
aws ec2 create-route --route-table-id "$CUSTOM_RTB" \
|
|
--destination-cidr-block 0.0.0.0/0 --gateway-id "$RTB_IGW" > /dev/null
|
|
echo " Added route: 0.0.0.0/0 → $RTB_IGW"
|
|
|
|
# Find default subnet
|
|
RTB_SUBNET=$(aws ec2 describe-subnets \
|
|
--filters "Name=vpc-id,Values=$RTB_VPC" "Name=default-for-az,Values=true" \
|
|
--query 'Subnets[0].SubnetId' --output text)
|
|
|
|
# Associate with default subnet
|
|
RTB_ASSOC=$(aws ec2 associate-route-table --route-table-id "$CUSTOM_RTB" \
|
|
--subnet-id "$RTB_SUBNET" --query 'AssociationId' --output text)
|
|
echo " Associated: $RTB_ASSOC ($RTB_SUBNET → $CUSTOM_RTB)"
|
|
|
|
# Disassociate
|
|
aws ec2 disassociate-route-table --association-id "$RTB_ASSOC"
|
|
echo " Disassociated: $RTB_ASSOC"
|
|
|
|
# Delete route, then table
|
|
aws ec2 delete-route --route-table-id "$CUSTOM_RTB" --destination-cidr-block 0.0.0.0/0
|
|
echo " Deleted route: 0.0.0.0/0"
|
|
aws ec2 delete-route-table --route-table-id "$CUSTOM_RTB"
|
|
echo " Deleted route table: $CUSTOM_RTB"
|
|
echo " PASS: Custom route table lifecycle complete"
|
|
|
|
# Step 3: Error paths
|
|
echo ""
|
|
echo "Step 3: Route table error paths"
|
|
|
|
# Create a temporary route table for error testing
|
|
ERR_RTB=$(aws ec2 create-route-table --vpc-id "$RTB_VPC" \
|
|
--query 'RouteTable.RouteTableId' --output text)
|
|
|
|
# Cannot delete local route
|
|
if aws ec2 delete-route --route-table-id "$ERR_RTB" \
|
|
--destination-cidr-block "$CUSTOM_LOCAL" 2>/dev/null; then
|
|
echo "FAIL: Should not be able to delete local route"
|
|
exit 1
|
|
fi
|
|
echo " PASS: Delete local route correctly rejected"
|
|
|
|
# Cannot delete main route table
|
|
if aws ec2 delete-route-table --route-table-id "$MAIN_RTB" 2>/dev/null; then
|
|
echo "FAIL: Should not be able to delete main route table"
|
|
exit 1
|
|
fi
|
|
echo " PASS: Delete main route table correctly rejected"
|
|
|
|
# Cleanup
|
|
aws ec2 delete-route-table --route-table-id "$ERR_RTB"
|
|
echo " Cleanup: deleted $ERR_RTB"
|
|
|
|
echo ""
|
|
echo "Phase 8c: Route Table Validation PASSED"
|
|
|
|
# Phase 8b: VPC Public/Private Subnet E2E
|
|
echo ""
|
|
echo "Phase 8b: VPC Public/Private Subnet E2E"
|
|
echo "========================================"
|
|
|
|
# Check if external networking is configured
|
|
HAS_EXTERNAL=false
|
|
if grep -q 'external_mode = "pool"' /etc/spinifex/spinifex.toml 2>/dev/null; then
|
|
HAS_EXTERNAL=true
|
|
fi
|
|
|
|
HAS_OVN=false
|
|
if command -v ovn-nbctl &>/dev/null; then
|
|
HAS_OVN=true
|
|
fi
|
|
|
|
if [ "$HAS_EXTERNAL" != "true" ]; then
|
|
echo "Skipping Phase 8b: no external networking configured"
|
|
echo "(set SPX_EXTERNAL_POOL=start-end to enable)"
|
|
else
|
|
|
|
echo "Phase 8b Step 1: Discover default VPC infrastructure"
|
|
# The default VPC, subnet, and IGW are created by admin init
|
|
DEFAULT_VPC=$(aws ec2 describe-vpcs --query 'Vpcs[?IsDefault==`true`].VpcId | [0]' --output text)
|
|
if [ -z "$DEFAULT_VPC" ] || [ "$DEFAULT_VPC" = "None" ]; then
|
|
echo "FATAL: No default VPC found — admin init may not have run"
|
|
exit 1
|
|
fi
|
|
DEFAULT_SUBNET=$(aws ec2 describe-subnets --query "Subnets[?VpcId=='${DEFAULT_VPC}' && DefaultForAz==\`true\`].SubnetId | [0]" --output text)
|
|
echo "Default VPC: $DEFAULT_VPC"
|
|
echo "Default subnet: $DEFAULT_SUBNET (MapPublicIpOnLaunch=true)"
|
|
|
|
echo ""
|
|
echo "Phase 8b Step 2: Public subnet instance"
|
|
# Launch instance in default subnet (MapPublicIpOnLaunch=true, IGW attached)
|
|
echo "Launching instance in public subnet..."
|
|
PUB_RUN_OUTPUT=$(aws ec2 run-instances \
|
|
--image-id "$AMI_ID" \
|
|
--instance-type "$INSTANCE_TYPE" \
|
|
--subnet-id "$DEFAULT_SUBNET" \
|
|
--key-name test-key-1 \
|
|
--output json 2>&1)
|
|
PUB_INSTANCE_ID=$(echo "$PUB_RUN_OUTPUT" | jq -r '.Instances[0].InstanceId')
|
|
echo "Public instance: $PUB_INSTANCE_ID"
|
|
|
|
# Wait for running
|
|
echo "Waiting for public instance to reach running state..."
|
|
PUB_COUNT=0
|
|
PUB_STATE="unknown"
|
|
while [ $PUB_COUNT -lt 90 ]; do
|
|
PUB_STATE=$(aws ec2 describe-instances --instance-ids "$PUB_INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending")
|
|
if [ "$PUB_STATE" == "running" ]; then
|
|
echo "Public instance is running"
|
|
break
|
|
fi
|
|
sleep 1
|
|
PUB_COUNT=$((PUB_COUNT + 1))
|
|
done
|
|
if [ "$PUB_STATE" != "running" ]; then
|
|
echo "FAIL: Public instance did not reach running state (got: $PUB_STATE)"
|
|
exit 1
|
|
fi
|
|
|
|
# Verify public IP is assigned
|
|
PUB_IP=$(aws ec2 describe-instances --instance-ids "$PUB_INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
|
|
PUB_PRIVATE_IP=$(aws ec2 describe-instances --instance-ids "$PUB_INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
|
|
echo "Public IP: $PUB_IP, Private IP: $PUB_PRIVATE_IP"
|
|
|
|
if [ -z "$PUB_IP" ] || [ "$PUB_IP" = "None" ]; then
|
|
echo "FAIL: Public subnet instance has no PublicIpAddress"
|
|
exit 1
|
|
fi
|
|
echo "PASS: Public subnet instance has PublicIpAddress=$PUB_IP"
|
|
|
|
# OVN verification: per-VM dnat_and_snat rule exists
|
|
if [ "$HAS_OVN" = true ]; then
|
|
NAT_RULES=$(sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || echo "")
|
|
|
|
# Verify dnat_and_snat exists for this VM's private IP
|
|
if echo "$NAT_RULES" | grep -q "dnat_and_snat.*${PUB_PRIVATE_IP}"; then
|
|
echo "PASS: OVN dnat_and_snat rule exists for $PUB_PRIVATE_IP"
|
|
else
|
|
echo "FAIL: No OVN dnat_and_snat rule for $PUB_PRIVATE_IP"
|
|
echo "NAT rules:"
|
|
echo "$NAT_RULES"
|
|
exit 1
|
|
fi
|
|
|
|
# Verify NO blanket VPC CIDR SNAT (mulga-754 fix)
|
|
if echo "$NAT_RULES" | grep -q "snat.*172.31.0.0/16"; then
|
|
echo "FAIL: Blanket VPC CIDR SNAT exists (should have been removed by mulga-754)"
|
|
echo "NAT rules:"
|
|
echo "$NAT_RULES"
|
|
exit 1
|
|
fi
|
|
echo "PASS: No blanket VPC CIDR SNAT on router (AWS parity)"
|
|
fi
|
|
|
|
# SSH into public instance via its public IP and verify outbound connectivity.
|
|
# This proves the full path: host → public IP → OVN DNAT → VM → OVN SNAT → internet.
|
|
echo "Waiting for SSH via public IP $PUB_IP..."
|
|
PUB_SSH_READY=false
|
|
for attempt in $(seq 1 60); do
|
|
if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
|
-o ConnectTimeout=3 -o BatchMode=yes -i "test-key-1.pem" \
|
|
ec2-user@"$PUB_IP" 'true' 2>/dev/null; then
|
|
PUB_SSH_READY=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ "$PUB_SSH_READY" = true ]; then
|
|
echo "PASS: SSH via public IP $PUB_IP succeeded"
|
|
|
|
# Verify outbound internet from VM (via OVN SNAT, not QEMU user-mode)
|
|
set +e
|
|
OUTBOUND_OK=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
|
-o ConnectTimeout=5 -o BatchMode=yes -i "test-key-1.pem" \
|
|
ec2-user@"$PUB_IP" 'ping -c 2 -W 5 8.8.8.8 >/dev/null 2>&1 && echo yes || echo no' 2>/dev/null)
|
|
set -e
|
|
if [ "$OUTBOUND_OK" = "yes" ]; then
|
|
echo "PASS: Outbound internet from public instance via OVN SNAT"
|
|
else
|
|
echo "WARN: Outbound internet test inconclusive (may depend on WAN gateway)"
|
|
fi
|
|
# Verify cloud-init injected the Spinifex CA into the guest trust store.
|
|
# The guest should be able to curl Spinifex services (awsgw, predastore)
|
|
# WITHOUT --insecure, proving the CA cert was installed via ca_certs module.
|
|
echo ""
|
|
echo "Phase 8b Step 2b: In-guest TLS trust (cloud-init CA injection)"
|
|
|
|
# Test 1: Guest can reach awsgw via HTTPS without --insecure
|
|
set +e
|
|
GUEST_TLS_AWSGW=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
|
-o ConnectTimeout=5 -o BatchMode=yes -i "test-key-1.pem" \
|
|
ec2-user@"$PUB_IP" "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 https://${GATEWAY_HOST}:9999/ 2>/dev/null || echo 000" 2>/dev/null)
|
|
set -e
|
|
|
|
if [ -n "$GUEST_TLS_AWSGW" ] && [ "$GUEST_TLS_AWSGW" != "000" ]; then
|
|
echo "PASS: Guest VM trusts awsgw TLS (HTTP $GUEST_TLS_AWSGW) — cloud-init CA injection works"
|
|
else
|
|
echo "FAIL: Guest VM cannot reach awsgw via HTTPS without --insecure (got: $GUEST_TLS_AWSGW)"
|
|
echo " This means cloud-init did not inject the Spinifex CA into the guest trust store."
|
|
echo " Checking guest user-data for ca_certs block..."
|
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
|
-o BatchMode=yes -i "test-key-1.pem" \
|
|
ec2-user@"$PUB_IP" "cat /var/lib/cloud/instance/user-data.txt 2>/dev/null | head -30" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
|
|
# Test 2: Guest can reach predastore S3 via HTTPS without --insecure
|
|
set +e
|
|
GUEST_TLS_S3=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
|
-o ConnectTimeout=5 -o BatchMode=yes -i "test-key-1.pem" \
|
|
ec2-user@"$PUB_IP" "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 https://${GATEWAY_HOST}:8443/ 2>/dev/null || echo 000" 2>/dev/null)
|
|
set -e
|
|
|
|
if [ -n "$GUEST_TLS_S3" ] && [ "$GUEST_TLS_S3" != "000" ]; then
|
|
echo "PASS: Guest VM trusts predastore S3 TLS (HTTP $GUEST_TLS_S3)"
|
|
else
|
|
echo "WARN: Guest VM cannot reach predastore S3 at https://${GATEWAY_HOST}:8443 (got: $GUEST_TLS_S3)"
|
|
echo " Predastore may use a separate self-signed cert (QUIC) — this is expected for now."
|
|
fi
|
|
|
|
# Test 3: Verify the ca_certs block exists in guest user-data
|
|
set +e
|
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
|
-o BatchMode=yes -i "test-key-1.pem" \
|
|
ec2-user@"$PUB_IP" "sudo grep -q ca_certs /var/lib/cloud/instance/user-data.txt" 2>/dev/null
|
|
GREP_EXIT=$?
|
|
set -e
|
|
|
|
if [ "$GREP_EXIT" -eq 0 ]; then
|
|
echo "PASS: Guest user-data contains ca_certs block"
|
|
else
|
|
echo "FAIL: Guest user-data does NOT contain ca_certs block"
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "WARN: SSH via public IP $PUB_IP not reachable (bridge not ready)"
|
|
echo "Falling back to API-only verification (OVN state already validated above)"
|
|
fi
|
|
|
|
echo ""
|
|
echo "Phase 8b Step 3: Private subnet isolation"
|
|
# Create a second subnet in the default VPC without MapPublicIpOnLaunch
|
|
echo "Creating private subnet 172.31.16.0/20 in default VPC..."
|
|
PRIV_SUBNET_OUTPUT=$(aws ec2 create-subnet \
|
|
--vpc-id "$DEFAULT_VPC" \
|
|
--cidr-block 172.31.16.0/20 \
|
|
--output json 2>&1)
|
|
PRIV_SUBNET_ID=$(echo "$PRIV_SUBNET_OUTPUT" | jq -r '.Subnet.SubnetId')
|
|
echo "Private subnet: $PRIV_SUBNET_ID"
|
|
|
|
# Verify MapPublicIpOnLaunch is false (default)
|
|
MAP_PUB=$(echo "$PRIV_SUBNET_OUTPUT" | jq -r '.Subnet.MapPublicIpOnLaunch')
|
|
if [ "$MAP_PUB" = "false" ]; then
|
|
echo "PASS: Private subnet MapPublicIpOnLaunch=false"
|
|
else
|
|
echo "FAIL: Expected MapPublicIpOnLaunch=false, got $MAP_PUB"
|
|
exit 1
|
|
fi
|
|
|
|
# Launch instance in private subnet
|
|
echo "Launching instance in private subnet..."
|
|
PRIV_RUN_OUTPUT=$(aws ec2 run-instances \
|
|
--image-id "$AMI_ID" \
|
|
--instance-type "$INSTANCE_TYPE" \
|
|
--subnet-id "$PRIV_SUBNET_ID" \
|
|
--key-name test-key-1 \
|
|
--output json 2>&1)
|
|
PRIV_INSTANCE_ID=$(echo "$PRIV_RUN_OUTPUT" | jq -r '.Instances[0].InstanceId')
|
|
echo "Private instance: $PRIV_INSTANCE_ID"
|
|
|
|
# Wait for running
|
|
echo "Waiting for private instance to reach running state..."
|
|
PRIV_COUNT=0
|
|
PRIV_STATE="unknown"
|
|
while [ $PRIV_COUNT -lt 90 ]; do
|
|
PRIV_STATE=$(aws ec2 describe-instances --instance-ids "$PRIV_INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending")
|
|
if [ "$PRIV_STATE" == "running" ]; then
|
|
echo "Private instance is running"
|
|
break
|
|
fi
|
|
sleep 1
|
|
PRIV_COUNT=$((PRIV_COUNT + 1))
|
|
done
|
|
if [ "$PRIV_STATE" != "running" ]; then
|
|
echo "FAIL: Private instance did not reach running state (got: $PRIV_STATE)"
|
|
exit 1
|
|
fi
|
|
|
|
# Verify NO public IP assigned
|
|
PRIV_PUB_IP=$(aws ec2 describe-instances --instance-ids "$PRIV_INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
|
|
PRIV_PRIVATE_IP=$(aws ec2 describe-instances --instance-ids "$PRIV_INSTANCE_ID" \
|
|
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
|
|
echo "Private instance — PublicIpAddress: $PRIV_PUB_IP, PrivateIpAddress: $PRIV_PRIVATE_IP"
|
|
|
|
if [ -z "$PRIV_PUB_IP" ] || [ "$PRIV_PUB_IP" = "None" ] || [ "$PRIV_PUB_IP" = "null" ]; then
|
|
echo "PASS: Private subnet instance has no PublicIpAddress"
|
|
else
|
|
echo "FAIL: Private subnet instance should NOT have PublicIpAddress (got: $PRIV_PUB_IP)"
|
|
exit 1
|
|
fi
|
|
|
|
# OVN verification: NO dnat_and_snat for private instance
|
|
if [ "$HAS_OVN" = true ]; then
|
|
NAT_RULES=$(sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || echo "")
|
|
|
|
if echo "$NAT_RULES" | grep -q "dnat_and_snat.*${PRIV_PRIVATE_IP}"; then
|
|
echo "FAIL: Private instance has dnat_and_snat rule (should not)"
|
|
echo "NAT rules:"
|
|
echo "$NAT_RULES"
|
|
exit 1
|
|
fi
|
|
echo "PASS: No OVN dnat_and_snat rule for private instance $PRIV_PRIVATE_IP"
|
|
|
|
# Confirm still no blanket SNAT
|
|
if echo "$NAT_RULES" | grep -q "snat.*172.31.0.0/16"; then
|
|
echo "FAIL: Blanket VPC CIDR SNAT appeared after private instance launch"
|
|
exit 1
|
|
fi
|
|
echo "PASS: Still no blanket VPC CIDR SNAT (private instances are fully isolated)"
|
|
fi
|
|
|
|
# Phase 8d: NAT Gateway E2E (mulga-763)
|
|
# Reuses PUB_INSTANCE_ID/PUB_IP (bastion) and PRIV_INSTANCE_ID/PRIV_PRIVATE_IP from Phase 8b
|
|
echo ""
|
|
echo "Phase 8d: NAT Gateway E2E"
|
|
echo "========================================"
|
|
|
|
# SSH helper: hop through public VM (bastion) to reach private VM
|
|
BASTION_SSH="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes -i test-key-1.pem ec2-user@$PUB_IP"
|
|
PRIV_SSH_CMD="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 -o BatchMode=yes -i /tmp/key.pem ec2-user@$PRIV_PRIVATE_IP"
|
|
|
|
# Copy SSH key to bastion for hop to private instance
|
|
echo "Setting up bastion SSH key..."
|
|
scp -o StrictHostKeyChecking=no -o LogLevel=ERROR -i test-key-1.pem test-key-1.pem "ec2-user@$PUB_IP:/tmp/key.pem"
|
|
$BASTION_SSH "chmod 600 /tmp/key.pem"
|
|
|
|
# Step 1: Wait for private instance SSH to be ready via bastion hop
|
|
# The private VM needs cloud-init time (~30-40s after "running" state).
|
|
echo ""
|
|
echo "Step 1: Waiting for private instance SSH via bastion (cloud-init boot)..."
|
|
BASTION_SSH_OK=false
|
|
for attempt in $(seq 1 30); do
|
|
if PRIV_HOSTNAME=$($BASTION_SSH "$PRIV_SSH_CMD 'hostname'" 2>/dev/null); then
|
|
BASTION_SSH_OK=true
|
|
break
|
|
fi
|
|
echo " Attempt $attempt/30: private instance SSH not ready yet..."
|
|
sleep 5
|
|
done
|
|
|
|
if [ "$BASTION_SSH_OK" != "true" ]; then
|
|
echo "FAIL: Cannot reach private instance from bastion after 150s"
|
|
echo " Public VM (bastion): $PUB_IP → $PUB_PRIVATE_IP"
|
|
echo " Private VM target: $PRIV_PRIVATE_IP"
|
|
echo " Debugging: checking OVN port bindings..."
|
|
sudo ovn-sbctl --no-leader-only show 2>/dev/null | head -20 || true
|
|
exit 1
|
|
fi
|
|
echo " PASS: Bastion → private instance SSH works (hostname: $PRIV_HOSTNAME)"
|
|
{
|
|
|
|
# Step 2: Baseline — private instance has NO internet
|
|
echo ""
|
|
echo "Step 2: Confirm private instance has no internet (baseline)"
|
|
if $BASTION_SSH "$PRIV_SSH_CMD 'ping -c 1 -W 3 8.8.8.8'" 2>/dev/null; then
|
|
echo "FAIL: Private instance can reach internet WITHOUT NAT GW"
|
|
exit 1
|
|
fi
|
|
echo " PASS: Private instance cannot reach internet (expected)"
|
|
|
|
# Step 3: Create NAT Gateway + route
|
|
echo ""
|
|
echo "Step 3: Create NAT Gateway"
|
|
NAT_EIP_OUTPUT=$(aws ec2 allocate-address --domain vpc --output json)
|
|
NAT_ALLOC_ID=$(echo "$NAT_EIP_OUTPUT" | jq -r '.AllocationId')
|
|
NAT_PUB_IP=$(echo "$NAT_EIP_OUTPUT" | jq -r '.PublicIp')
|
|
echo " Allocated EIP: $NAT_PUB_IP ($NAT_ALLOC_ID)"
|
|
|
|
NAT_GW_OUTPUT=$(aws ec2 create-nat-gateway \
|
|
--subnet-id "$DEFAULT_SUBNET" \
|
|
--allocation-id "$NAT_ALLOC_ID" --output json)
|
|
NAT_GW_ID=$(echo "$NAT_GW_OUTPUT" | jq -r '.NatGateway.NatGatewayId')
|
|
echo " NAT Gateway: $NAT_GW_ID"
|
|
|
|
# Create route table, associate with private subnet, THEN add NAT GW route.
|
|
# Order matters: CreateRoute publishes SNAT events for associated subnets,
|
|
# so the association must exist before the route is added.
|
|
NAT_RTB=$(aws ec2 create-route-table --vpc-id "$DEFAULT_VPC" \
|
|
--query 'RouteTable.RouteTableId' --output text)
|
|
NAT_RTB_ASSOC=$(aws ec2 associate-route-table --route-table-id "$NAT_RTB" \
|
|
--subnet-id "$PRIV_SUBNET_ID" --query 'AssociationId' --output text)
|
|
aws ec2 create-route --route-table-id "$NAT_RTB" \
|
|
--destination-cidr-block 0.0.0.0/0 --nat-gateway-id "$NAT_GW_ID" > /dev/null
|
|
echo " Route: 0.0.0.0/0 → $NAT_GW_ID (rtb: $NAT_RTB, assoc: $NAT_RTB_ASSOC)"
|
|
|
|
# Verify OVN SNAT rule exists (poll — NATS → vpcd → OVN can take several seconds)
|
|
if [ "$HAS_OVN" = true ]; then
|
|
SNAT_FOUND=false
|
|
for attempt in $(seq 1 30); do
|
|
NAT_RULES=$(sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || echo "")
|
|
if echo "$NAT_RULES" | grep -q "snat.*${NAT_PUB_IP}"; then
|
|
SNAT_FOUND=true
|
|
echo " PASS: OVN SNAT rule created for NAT Gateway (after ${attempt}s)"
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ "$SNAT_FOUND" = false ]; then
|
|
echo " WARN: OVN SNAT rule not found after 30s"
|
|
echo " NAT rules: $NAT_RULES"
|
|
fi
|
|
fi
|
|
|
|
# Step 4: Verify private instance CAN now reach internet
|
|
# OVN needs time to install datapath flows after the SNAT rule is created.
|
|
# Retry with increasing backoff rather than a fixed sleep.
|
|
echo ""
|
|
echo "Step 4: Verify outbound connectivity via NAT GW"
|
|
NAT_GW_OK=false
|
|
for attempt in $(seq 1 10); do
|
|
if $BASTION_SSH "$PRIV_SSH_CMD 'ping -c 2 -W 3 8.8.8.8'" 2>/dev/null; then
|
|
NAT_GW_OK=true
|
|
echo " PASS: Private instance can reach internet via NAT Gateway (attempt $attempt)"
|
|
break
|
|
fi
|
|
echo " Attempt $attempt: NAT GW not routing yet, retrying in 5s..."
|
|
sleep 5
|
|
done
|
|
if [ "$NAT_GW_OK" = false ]; then
|
|
echo " FAIL: Private instance cannot reach internet WITH NAT GW after 10 attempts"
|
|
echo ""
|
|
echo "==================== Phase 8d failure diagnostics ===================="
|
|
echo "VPC: vpc-${DEFAULT_VPC}"
|
|
echo "Private subnet: ${PRIV_SUBNET_ID} (private VM IP: ${PRIV_PRIVATE_IP})"
|
|
echo "NATGW: ${NAT_GW_ID} → ${NAT_PUB_IP}"
|
|
echo ""
|
|
echo "--- ovn-nbctl lr-nat-list vpc-${DEFAULT_VPC} ---"
|
|
sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>&1 || true
|
|
echo ""
|
|
echo "--- ovn-nbctl lr-route-list vpc-${DEFAULT_VPC} ---"
|
|
sudo ovn-nbctl --no-leader-only lr-route-list "vpc-${DEFAULT_VPC}" 2>&1 || true
|
|
echo ""
|
|
echo "--- ovn-nbctl show ---"
|
|
sudo ovn-nbctl --no-leader-only show 2>&1 || true
|
|
echo ""
|
|
echo "--- ovn-sbctl show ---"
|
|
sudo ovn-sbctl --no-leader-only show 2>&1 || true
|
|
echo ""
|
|
echo "--- ovn-sbctl list chassis ---"
|
|
sudo ovn-sbctl --no-leader-only list chassis 2>&1 || true
|
|
echo ""
|
|
echo "--- ovn-nbctl find Port_Group (membership + ACLs) ---"
|
|
sudo ovn-nbctl --no-leader-only --columns=name,ports,acls find Port_Group 2>&1 || true
|
|
echo ""
|
|
echo "--- ovn-nbctl list ACL (all VPC ACLs) ---"
|
|
sudo ovn-nbctl --no-leader-only list ACL 2>&1 || true
|
|
echo ""
|
|
echo "--- ovn-nbctl find Logical_Switch_Port name=*${PRIV_PRIVATE_IP//./_}* (private VM LSP) ---"
|
|
sudo ovn-nbctl --no-leader-only find Logical_Switch_Port addresses=\"*${PRIV_PRIVATE_IP}*\" 2>&1 || true
|
|
echo ""
|
|
echo "--- ovs-vsctl show ---"
|
|
sudo ovs-vsctl show 2>&1 || true
|
|
echo ""
|
|
echo "--- ovs-ofctl dump-flows br-int (filtered: NATGW IP/subnet/private IP) ---"
|
|
sudo ovs-ofctl dump-flows br-int 2>&1 \
|
|
| grep -E "${NAT_PUB_IP//./\\.}|172\\.31\\.16\\.|${PRIV_PRIVATE_IP//./\\.}" \
|
|
| head -200 || true
|
|
echo ""
|
|
echo "--- ovs-ofctl dump-flows br-int | wc -l (total flows) ---"
|
|
sudo ovs-ofctl dump-flows br-int 2>&1 | wc -l || true
|
|
echo ""
|
|
echo "--- ip route show ---"
|
|
ip route show 2>&1 || true
|
|
echo ""
|
|
echo "--- ip -br addr show ---"
|
|
ip -br addr show 2>&1 || true
|
|
echo ""
|
|
echo "--- private VM: ip addr / ip route / ip neigh ---"
|
|
$BASTION_SSH "$PRIV_SSH_CMD 'ip -br addr; echo ---; ip route; echo ---; ip neigh'" 2>&1 || true
|
|
echo ""
|
|
echo "--- private VM: ping -c 1 subnet gateway (172.31.16.1) ---"
|
|
$BASTION_SSH "$PRIV_SSH_CMD 'ping -c 1 -W 3 172.31.16.1; echo rc=\$?'" 2>&1 || true
|
|
echo ""
|
|
echo "==================== end Phase 8d diagnostics ===================="
|
|
exit 1
|
|
fi
|
|
|
|
# Step 5: Delete NAT GW and verify internet stops
|
|
echo ""
|
|
echo "Step 5: Delete NAT GW and verify internet stops"
|
|
aws ec2 delete-nat-gateway --nat-gateway-id "$NAT_GW_ID" > /dev/null
|
|
aws ec2 disassociate-route-table --association-id "$NAT_RTB_ASSOC"
|
|
aws ec2 delete-route --route-table-id "$NAT_RTB" --destination-cidr-block 0.0.0.0/0
|
|
aws ec2 delete-route-table --route-table-id "$NAT_RTB"
|
|
aws ec2 release-address --allocation-id "$NAT_ALLOC_ID"
|
|
echo " NAT GW deleted, route table cleaned up, EIP released"
|
|
|
|
# Poll for SNAT removal — OVN datapath may cache the old flow briefly
|
|
SNAT_GONE=false
|
|
for attempt in $(seq 1 15); do
|
|
if ! $BASTION_SSH "$PRIV_SSH_CMD 'ping -c 1 -W 3 8.8.8.8'" 2>/dev/null; then
|
|
SNAT_GONE=true
|
|
echo " PASS: Private instance lost internet after NAT GW deletion (after ${attempt}s)"
|
|
break
|
|
fi
|
|
echo " Attempt $attempt: SNAT still cached, waiting..."
|
|
sleep 2
|
|
done
|
|
if [ "$SNAT_GONE" = false ]; then
|
|
echo " FAIL: Private instance still has internet after NAT GW deletion"
|
|
exit 1
|
|
fi
|
|
|
|
echo ""
|
|
echo "Phase 8d: NAT Gateway E2E PASSED"
|
|
}
|
|
|
|
# Free single-node capacity before Phase 8e by terminating the Phase 8b
|
|
# instances early — nothing between here and Phase 8b's later cleanup uses
|
|
# PUB/PRIV. On nano-only single-node CI the running pool would otherwise be
|
|
# main + PUB + PRIV (3 instances) + Phase 8e's 2 more = 5, which exceeds
|
|
# capacity. The gateway masquerades that as "InvalidInstanceType: None"
|
|
# (RunInstances.go isKnownInstanceType masquerade), masking the real cause.
|
|
echo ""
|
|
echo "Freeing capacity for Phase 8e: terminating Phase 8b instances early..."
|
|
aws ec2 terminate-instances --instance-ids "$PUB_INSTANCE_ID" "$PRIV_INSTANCE_ID" > /dev/null
|
|
for EARLY_ID in "$PUB_INSTANCE_ID" "$PRIV_INSTANCE_ID"; do
|
|
EARLY_COUNT=0
|
|
while [ $EARLY_COUNT -lt 60 ]; do
|
|
EARLY_STATE=$(aws ec2 describe-instances --instance-ids "$EARLY_ID" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated")
|
|
if [ "$EARLY_STATE" = "terminated" ] || [ "$EARLY_STATE" = "None" ]; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
EARLY_COUNT=$((EARLY_COUNT + 1))
|
|
done
|
|
done
|
|
# Mark as already cleaned so the later cleanup block becomes a no-op.
|
|
PHASE8B_INSTANCES_TERMINATED=true
|
|
echo " PUB+PRIV terminated; capacity reclaimed"
|
|
|
|
# Phase 8e: Security Group Enforcement
|
|
# Reuses DEFAULT_VPC, DEFAULT_SUBNET, AMI_ID, INSTANCE_TYPE, test-key-1.pem
|
|
# from earlier phases. Validates the closed loop from RunInstances
|
|
# (--security-group-ids) → ENI → vpcd → OVN port group + ACL → datapath drop.
|
|
echo ""
|
|
echo "Phase 8e: Security Group Enforcement"
|
|
echo "========================================"
|
|
|
|
# Step 1: Create client-sg and target-sg.
|
|
echo "Step 1: Create security groups"
|
|
SGE_CLIENT_SG=$(aws ec2 create-security-group \
|
|
--group-name sge-client \
|
|
--description "Phase 8e client SG (SSH ingress from anywhere)" \
|
|
--vpc-id "$DEFAULT_VPC" \
|
|
--query 'GroupId' --output text)
|
|
echo " client-sg: $SGE_CLIENT_SG"
|
|
|
|
SGE_TARGET_SG=$(aws ec2 create-security-group \
|
|
--group-name sge-target \
|
|
--description "Phase 8e target SG (TCP/8080 ingress from client-sg only)" \
|
|
--vpc-id "$DEFAULT_VPC" \
|
|
--query 'GroupId' --output text)
|
|
echo " target-sg: $SGE_TARGET_SG"
|
|
|
|
aws ec2 authorize-security-group-ingress \
|
|
--group-id "$SGE_CLIENT_SG" \
|
|
--protocol tcp --port 22 --cidr 0.0.0.0/0 > /dev/null
|
|
echo " client-sg ingress: tcp/22 from 0.0.0.0/0"
|
|
|
|
# SG-to-SG via UserIdGroupPair (the VPC-form; --source-group is EC2-Classic only).
|
|
aws ec2 authorize-security-group-ingress \
|
|
--group-id "$SGE_TARGET_SG" \
|
|
--ip-permissions "IpProtocol=tcp,FromPort=8080,ToPort=8080,UserIdGroupPairs=[{GroupId=$SGE_CLIENT_SG}]" > /dev/null
|
|
echo " target-sg ingress: tcp/8080 from $SGE_CLIENT_SG (SG-to-SG)"
|
|
|
|
# Step 2: Launch client-vm and target-vm in the public subnet so both get
|
|
# public IPs (MapPublicIpOnLaunch=true). Target's HTTP server is started via
|
|
# cloud-init user-data — target-sg has no SSH ingress, so we cannot ssh into it.
|
|
echo ""
|
|
echo "Step 2: Launch client-vm and target-vm"
|
|
|
|
SGE_CLIENT_VM=$(aws ec2 run-instances \
|
|
--image-id "$AMI_ID" \
|
|
--instance-type "$INSTANCE_TYPE" \
|
|
--subnet-id "$DEFAULT_SUBNET" \
|
|
--key-name test-key-1 \
|
|
--security-group-ids "$SGE_CLIENT_SG" \
|
|
--query 'Instances[0].InstanceId' --output text)
|
|
echo " client-vm: $SGE_CLIENT_VM"
|
|
|
|
# systemd-run fully detaches the http server from cloud-init's process group;
|
|
# `nohup ... &` alone races with cloud-init exit and the server can get killed.
|
|
# Pass plaintext — AWS CLI base64-encodes --user-data automatically; awsgw
|
|
# decodes once on receipt. Pre-encoding here results in a double-encoded blob
|
|
# landing verbatim in /tmp/cloud-init-startup.sh.
|
|
SGE_TARGET_USERDATA=$(cat <<'EOF'
|
|
#!/bin/bash
|
|
systemd-run --unit=sge-http --description="Phase 8e HTTP server" \
|
|
/usr/bin/python3 -m http.server 8080 --bind 0.0.0.0
|
|
EOF
|
|
)
|
|
|
|
SGE_TARGET_VM=$(aws ec2 run-instances \
|
|
--image-id "$AMI_ID" \
|
|
--instance-type "$INSTANCE_TYPE" \
|
|
--subnet-id "$DEFAULT_SUBNET" \
|
|
--key-name test-key-1 \
|
|
--security-group-ids "$SGE_TARGET_SG" \
|
|
--user-data "$SGE_TARGET_USERDATA" \
|
|
--query 'Instances[0].InstanceId' --output text)
|
|
echo " target-vm: $SGE_TARGET_VM"
|
|
|
|
# Wait both running.
|
|
for SGE_VM in "$SGE_CLIENT_VM" "$SGE_TARGET_VM"; do
|
|
SGE_W=0
|
|
SGE_S="unknown"
|
|
while [ $SGE_W -lt 90 ]; do
|
|
SGE_S=$(aws ec2 describe-instances --instance-ids "$SGE_VM" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending")
|
|
if [ "$SGE_S" = "running" ]; then break; fi
|
|
sleep 1
|
|
SGE_W=$((SGE_W + 1))
|
|
done
|
|
if [ "$SGE_S" != "running" ]; then
|
|
echo "FAIL: $SGE_VM did not reach running (got: $SGE_S)"
|
|
exit 1
|
|
fi
|
|
done
|
|
echo " Both VMs running"
|
|
|
|
SGE_CLIENT_PUB=$(aws ec2 describe-instances --instance-ids "$SGE_CLIENT_VM" \
|
|
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
|
|
SGE_CLIENT_PRIV=$(aws ec2 describe-instances --instance-ids "$SGE_CLIENT_VM" \
|
|
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
|
|
SGE_TARGET_PRIV=$(aws ec2 describe-instances --instance-ids "$SGE_TARGET_VM" \
|
|
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
|
|
SGE_CLIENT_ENI=$(aws ec2 describe-instances --instance-ids "$SGE_CLIENT_VM" \
|
|
--query 'Reservations[0].Instances[0].NetworkInterfaces[0].NetworkInterfaceId' --output text)
|
|
SGE_TARGET_ENI=$(aws ec2 describe-instances --instance-ids "$SGE_TARGET_VM" \
|
|
--query 'Reservations[0].Instances[0].NetworkInterfaces[0].NetworkInterfaceId' --output text)
|
|
echo " client-vm: pub=$SGE_CLIENT_PUB priv=$SGE_CLIENT_PRIV eni=$SGE_CLIENT_ENI"
|
|
echo " target-vm: priv=$SGE_TARGET_PRIV eni=$SGE_TARGET_ENI"
|
|
|
|
# Step 3: Internal correctness via ovn-nbctl — confirm each LSP joined the
|
|
# expected port group. Catches Phase 4 membership regressions independently
|
|
# of packet timing.
|
|
echo ""
|
|
echo "Step 3: ovn-nbctl port_group membership"
|
|
# OVN port group names use [_a-zA-Z0-9]; spinifex maps sg-XXXX → sg_XXXX.
|
|
SGE_CLIENT_PG="${SGE_CLIENT_SG//-/_}"
|
|
SGE_TARGET_PG="${SGE_TARGET_SG//-/_}"
|
|
SGE_CLIENT_LSP="port-${SGE_CLIENT_ENI}"
|
|
SGE_TARGET_LSP="port-${SGE_TARGET_ENI}"
|
|
|
|
SGE_CLIENT_LSP_UUID=$(sudo ovn-nbctl --no-leader-only --bare --columns=_uuid find logical_switch_port name="$SGE_CLIENT_LSP" 2>/dev/null || echo "")
|
|
SGE_TARGET_LSP_UUID=$(sudo ovn-nbctl --no-leader-only --bare --columns=_uuid find logical_switch_port name="$SGE_TARGET_LSP" 2>/dev/null || echo "")
|
|
if [ -z "$SGE_CLIENT_LSP_UUID" ] || [ -z "$SGE_TARGET_LSP_UUID" ]; then
|
|
echo "FAIL: LSP UUIDs not found in OVN NB"
|
|
echo " client-lsp ($SGE_CLIENT_LSP): '$SGE_CLIENT_LSP_UUID'"
|
|
echo " target-lsp ($SGE_TARGET_LSP): '$SGE_TARGET_LSP_UUID'"
|
|
exit 1
|
|
fi
|
|
|
|
SGE_CLIENT_PG_PORTS=$(sudo ovn-nbctl --no-leader-only --bare --columns=ports find port_group name="$SGE_CLIENT_PG" 2>/dev/null || echo "")
|
|
SGE_TARGET_PG_PORTS=$(sudo ovn-nbctl --no-leader-only --bare --columns=ports find port_group name="$SGE_TARGET_PG" 2>/dev/null || echo "")
|
|
|
|
if echo "$SGE_CLIENT_PG_PORTS" | grep -q "$SGE_CLIENT_LSP_UUID"; then
|
|
echo " PASS: client LSP in port_group $SGE_CLIENT_PG"
|
|
else
|
|
echo "FAIL: client LSP $SGE_CLIENT_LSP_UUID not in port_group $SGE_CLIENT_PG"
|
|
echo " ports: $SGE_CLIENT_PG_PORTS"
|
|
exit 1
|
|
fi
|
|
if echo "$SGE_TARGET_PG_PORTS" | grep -q "$SGE_TARGET_LSP_UUID"; then
|
|
echo " PASS: target LSP in port_group $SGE_TARGET_PG"
|
|
else
|
|
echo "FAIL: target LSP $SGE_TARGET_LSP_UUID not in port_group $SGE_TARGET_PG"
|
|
echo " ports: $SGE_TARGET_PG_PORTS"
|
|
exit 1
|
|
fi
|
|
|
|
# Confirm client's private IP made it into the client_pg_ip4 address set so
|
|
# target-sg's SG-to-SG match expression resolves. The <pg>_ip4 / <pg>_ip6
|
|
# sets are auto-derived by ovn-northd from port_group membership and live in
|
|
# SB, not NB (see security.go:73, topology.go:803). northd is async so retry
|
|
# briefly to ride out the post-join propagation window.
|
|
SGE_AS_OK=false
|
|
for SGE_W in $(seq 1 10); do
|
|
SGE_CLIENT_AS_ADDRS=$(sudo ovn-sbctl --no-leader-only --bare --columns=addresses find address_set name="${SGE_CLIENT_PG}_ip4" 2>/dev/null || echo "")
|
|
if echo "$SGE_CLIENT_AS_ADDRS" | grep -qF "$SGE_CLIENT_PRIV"; then
|
|
SGE_AS_OK=true
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ "$SGE_AS_OK" = "true" ]; then
|
|
echo " PASS: client private IP $SGE_CLIENT_PRIV in SB address_set ${SGE_CLIENT_PG}_ip4"
|
|
else
|
|
echo "FAIL: client private IP $SGE_CLIENT_PRIV missing from SB address_set ${SGE_CLIENT_PG}_ip4"
|
|
echo " addresses: $SGE_CLIENT_AS_ADDRS"
|
|
exit 1
|
|
fi
|
|
|
|
# Wait for client SSH to be ready (cloud-init).
|
|
SGE_CLIENT_SSH="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes -i test-key-1.pem ec2-user@$SGE_CLIENT_PUB"
|
|
echo ""
|
|
echo "Waiting for client-vm SSH..."
|
|
SGE_SSH_OK=false
|
|
for SGE_W in $(seq 1 60); do
|
|
if $SGE_CLIENT_SSH 'true' 2>/dev/null; then
|
|
SGE_SSH_OK=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ "$SGE_SSH_OK" != "true" ]; then
|
|
echo "FAIL: client-vm SSH did not become ready"
|
|
exit 1
|
|
fi
|
|
echo " PASS: client-vm SSH ready"
|
|
|
|
# Step 4: Allowed traffic — client → target:8080 must succeed.
|
|
# Retry to give target's cloud-init time to start python3 -m http.server.
|
|
echo ""
|
|
echo "Step 4: Allowed traffic (client → target:8080, SG-to-SG allow rule)"
|
|
SGE_HTTP_OK=false
|
|
for SGE_W in $(seq 1 30); do
|
|
if $SGE_CLIENT_SSH "curl -sS -o /dev/null -m 5 http://${SGE_TARGET_PRIV}:8080/" 2>/dev/null; then
|
|
SGE_HTTP_OK=true
|
|
echo " PASS: client → target:8080 succeeded (after ${SGE_W} attempts)"
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ "$SGE_HTTP_OK" != "true" ]; then
|
|
echo "FAIL: client cannot reach target:8080"
|
|
# Disambiguate: curl -v shows "Connection refused" (server down) vs
|
|
# "Connection timed out" (ACL filtered). Then dump target console for
|
|
# cloud-init / systemd-run / python3 evidence.
|
|
echo " --- curl -v from client to target:8080 ---"
|
|
$SGE_CLIENT_SSH "curl -v --connect-timeout 3 http://${SGE_TARGET_PRIV}:8080/" 2>&1 | tail -25 || true
|
|
echo " --- target console output (tail 80) ---"
|
|
aws ec2 get-console-output --instance-id "$SGE_TARGET_VM" --output text 2>&1 | tail -80 || true
|
|
exit 1
|
|
fi
|
|
|
|
# Step 5: Denied traffic — client → target:22 must fail.
|
|
# target-sg has no SSH ingress; default-deny ACL must drop.
|
|
echo ""
|
|
echo "Step 5: Denied traffic (client → target:22, no SSH ingress on target-sg)"
|
|
if $SGE_CLIENT_SSH "nc -z -w 5 ${SGE_TARGET_PRIV} 22" 2>/dev/null; then
|
|
echo "FAIL: client reached target:22 — default-deny ACL not enforced"
|
|
exit 1
|
|
fi
|
|
echo " PASS: client → target:22 blocked"
|
|
|
|
# Step 6: Revoke target-sg's 8080 rule and retest immediately.
|
|
# The synchronous vpc.update-sg RequestEvent contract (Phase 7) makes
|
|
# propagation to OVN immediate — no sleep needed.
|
|
echo ""
|
|
echo "Step 6: Revoke target-sg ingress, retest (sync RequestEvent contract)"
|
|
aws ec2 revoke-security-group-ingress \
|
|
--group-id "$SGE_TARGET_SG" \
|
|
--ip-permissions "IpProtocol=tcp,FromPort=8080,ToPort=8080,UserIdGroupPairs=[{GroupId=$SGE_CLIENT_SG}]" > /dev/null
|
|
echo " Revoked tcp/8080 from $SGE_CLIENT_SG"
|
|
|
|
# Fresh TCP connection — conntrack does not affect new connections.
|
|
if $SGE_CLIENT_SSH "curl -sS -o /dev/null -m 5 http://${SGE_TARGET_PRIV}:8080/" 2>/dev/null; then
|
|
echo "FAIL: client still reached target:8080 after revoke — propagation not immediate"
|
|
exit 1
|
|
fi
|
|
echo " PASS: client → target:8080 now blocked after revoke"
|
|
|
|
# Step 7: Cleanup — terminate Phase 8e VMs and delete SGs before the shared
|
|
# 8b/8d cleanup runs. Otherwise the SGs would still reference live ENIs.
|
|
echo ""
|
|
echo "Step 7: Cleanup"
|
|
aws ec2 terminate-instances --instance-ids "$SGE_CLIENT_VM" "$SGE_TARGET_VM" > /dev/null
|
|
for SGE_VM in "$SGE_CLIENT_VM" "$SGE_TARGET_VM"; do
|
|
SGE_W=0
|
|
SGE_S="unknown"
|
|
while [ $SGE_W -lt 60 ]; do
|
|
SGE_S=$(aws ec2 describe-instances --instance-ids "$SGE_VM" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated")
|
|
if [ "$SGE_S" = "terminated" ] || [ "$SGE_S" = "None" ]; then break; fi
|
|
sleep 1
|
|
SGE_W=$((SGE_W + 1))
|
|
done
|
|
done
|
|
aws ec2 delete-security-group --group-id "$SGE_TARGET_SG"
|
|
aws ec2 delete-security-group --group-id "$SGE_CLIENT_SG"
|
|
echo " PASS: VMs terminated, SGs deleted"
|
|
|
|
echo ""
|
|
echo "Phase 8e: Security Group Enforcement PASSED"
|
|
|
|
echo ""
|
|
echo "Phase 8b/8d Step: Cleanup"
|
|
if [ "$PHASE8B_INSTANCES_TERMINATED" = "true" ]; then
|
|
echo " PUB+PRIV already terminated before Phase 8e (capacity reclaim)"
|
|
else
|
|
# Terminate both instances
|
|
echo "Terminating public instance $PUB_INSTANCE_ID..."
|
|
aws ec2 terminate-instances --instance-ids "$PUB_INSTANCE_ID"
|
|
echo "Terminating private instance $PRIV_INSTANCE_ID..."
|
|
aws ec2 terminate-instances --instance-ids "$PRIV_INSTANCE_ID"
|
|
|
|
# Wait for both to terminate
|
|
for CLEANUP_ID in "$PUB_INSTANCE_ID" "$PRIV_INSTANCE_ID"; do
|
|
CLEANUP_COUNT=0
|
|
while [ $CLEANUP_COUNT -lt 60 ]; do
|
|
CLEANUP_STATE=$(aws ec2 describe-instances --instance-ids "$CLEANUP_ID" \
|
|
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated")
|
|
if [ "$CLEANUP_STATE" == "terminated" ] || [ "$CLEANUP_STATE" == "None" ]; then
|
|
echo "Instance $CLEANUP_ID terminated"
|
|
break
|
|
fi
|
|
sleep 1
|
|
CLEANUP_COUNT=$((CLEANUP_COUNT + 1))
|
|
done
|
|
done
|
|
fi
|
|
|
|
# Verify public IP NAT rule removed after termination (async via NATS)
|
|
if [ "$HAS_OVN" = true ]; then
|
|
echo "Waiting for NAT rule cleanup..."
|
|
NAT_CLEANED=false
|
|
for nat_wait in $(seq 1 15); do
|
|
NAT_RULES=$(sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || echo "")
|
|
if ! echo "$NAT_RULES" | grep -q "dnat_and_snat.*${PUB_PRIVATE_IP}"; then
|
|
NAT_CLEANED=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ "$NAT_CLEANED" = true ]; then
|
|
echo "PASS: NAT rule cleaned up after public instance termination"
|
|
else
|
|
echo "WARN: dnat_and_snat rule for $PUB_PRIVATE_IP still present after 30s (non-fatal)"
|
|
fi
|
|
fi
|
|
|
|
# Delete private subnet
|
|
echo "Deleting private subnet $PRIV_SUBNET_ID..."
|
|
aws ec2 delete-subnet --subnet-id "$PRIV_SUBNET_ID"
|
|
echo "PASS: Private subnet deleted"
|
|
|
|
echo ""
|
|
echo "Phase 8b: VPC Public/Private Subnet E2E PASSED"
|
|
|
|
fi # end HAS_EXTERNAL check
|
|
|
|
# Phase 9: Terminate and Verify Cleanup
|
|
echo "Phase 9: Terminate and Verify Cleanup"
|
|
|
|
# Save root volume ID before termination for cleanup verification
|
|
ROOT_VOLUME_ID="$VOLUME_ID"
|
|
echo "Root volume to verify cleanup: $ROOT_VOLUME_ID"
|
|
|
|
# Clean up the CreateImage backing snapshot so DeleteOnTermination can delete the root volume.
|
|
# checkVolumeHasNoSnapshots() correctly blocks volume deletion when snapshots reference it.
|
|
if [ -n "$CUSTOM_AMI_SNAP_ID" ]; then
|
|
echo "Deleting CreateImage backing snapshot $CUSTOM_AMI_SNAP_ID before termination..."
|
|
aws ec2 delete-snapshot --snapshot-id "$CUSTOM_AMI_SNAP_ID"
|
|
echo "CreateImage snapshot deleted"
|
|
fi
|
|
|
|
# Deregister the custom AMI so later test suites (e.g. run-lb-e2e.sh) don't
|
|
# discover an AMI whose backing snapshot has been deleted.
|
|
if [ -n "$CUSTOM_AMI_ID" ]; then
|
|
echo "Deregistering custom AMI $CUSTOM_AMI_ID..."
|
|
aws ec2 deregister-image --image-id "$CUSTOM_AMI_ID" 2>/dev/null || true
|
|
echo "Custom AMI deregistered"
|
|
fi
|
|
|
|
# Terminate instance (terminate-instances) and verify termination (describe-instances)
|
|
echo "Terminating instance..."
|
|
aws ec2 terminate-instances --instance-ids "$INSTANCE_ID"
|
|
COUNT=0
|
|
while [ $COUNT -lt 60 ]; do
|
|
STATE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" --query 'Reservations[0].Instances[0].State.Name' --output text)
|
|
echo "Instance state: $STATE"
|
|
if [ "$STATE" == "terminated" ] || [ "$STATE" == "None" ]; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
# Phase 9a: Verify SSH unreachable after termination
|
|
echo "Phase 9a: SSH Unreachable Verification"
|
|
verify_ssh_unreachable "$SSH_HOST" "$SSH_PORT" "test-key-1.pem"
|
|
echo "SSH unreachable verification passed"
|
|
|
|
# Phase 9b: Verify root volume cleanup after termination
|
|
echo "Phase 9b: Volume Cleanup Verification"
|
|
echo "Verifying root volume $ROOT_VOLUME_ID is cleaned up after termination..."
|
|
sleep 5 # Allow time for async volume deletion
|
|
|
|
COUNT=0
|
|
VOLUME_CLEANED=false
|
|
while [ $COUNT -lt 20 ]; do
|
|
set +e
|
|
VOL_CHECK=$(aws ec2 describe-volumes --volume-ids "$ROOT_VOLUME_ID" \
|
|
--query 'Volumes[0].State' --output text 2>&1)
|
|
VOL_EXIT=$?
|
|
set -e
|
|
|
|
if [ $VOL_EXIT -ne 0 ] || [ "$VOL_CHECK" == "None" ] || [ -z "$VOL_CHECK" ]; then
|
|
VOLUME_CLEANED=true
|
|
echo "Root volume $ROOT_VOLUME_ID has been cleaned up (DeleteOnTermination)"
|
|
break
|
|
fi
|
|
|
|
echo "Volume still exists (State=$VOL_CHECK), waiting... ($COUNT/20)"
|
|
sleep 3
|
|
COUNT=$((COUNT + 1))
|
|
done
|
|
|
|
if [ "$VOLUME_CLEANED" != "true" ]; then
|
|
echo "WARNING: Root volume $ROOT_VOLUME_ID was not cleaned up after termination"
|
|
echo "This may indicate a DeleteOnTermination regression"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Volume cleanup verification passed"
|
|
|
|
echo "E2E Test Completed Successfully"
|
|
exit 0
|