feat(e2e-harness): extend harness for single-node Go suite port

Stage A of mulga-siv-89 — add the primitives the single-node run-e2e.sh
port consumes:

- AWSClient gains IAM + STS service handles, plus NewAWSClientWithCreds
  for per-tenant account-scoping in Phase 8.
- awserror.go: ErrorCodeIs, AssertAWSError, ExpectError — Go equivalent
  of the bash expect_error helper used throughout the negative phases.
- poll.go: WaitForInstance/Volume/Snapshot/Image state to any target,
  with per-resource default timeouts tuned from the bash sleep loops.
- spxcli.go: thin shellouts for spx get nodes/vms, spx top nodes,
  spx admin account create with output parsing for tenant-id/access-key.
- rawhttp.go: signed raw POST against the AWS gateway for the Phase 8
  InvalidAction probe (gateway rejects on auth before action parsing).
- ovn.go: OvnNbctl/OvnSbctl wrappers + SkipIfNoOVN for Phase 8b-e
  datapath checks; WaitForPortGroupMember handles propagation polls.
- ec2helpers.go: InstancePublicSSHHost (public-IP-or-qemu-hostfwd),
  LsblkRootGiB, serial-console-access trio, DiscoverDefaultVPC,
  idempotent AuthorizeSSHIngress.
- account.go: AccountCarousel maps tenant name -> scoped AWSClient,
  built atop NewAWSClientWithCreds for the Phase 8 isolation tests.

Pure groundwork — no new tests, no production behaviour change. The
cert + lb scenarios still compile against the extended harness.
This commit is contained in:
joshsiv-mulga
2026-05-18 18:28:02 +10:00
parent 2965ff97e1
commit 23143bd0de
8 changed files with 870 additions and 9 deletions
+75
View File
@@ -0,0 +1,75 @@
//go:build e2e
package harness
import (
"sort"
"sync"
"testing"
)
// Profile bundles a tenant account's credentials with a pre-built AWSClient
// scoped to that account. The Client field bypasses AWS_PROFILE shared-config
// lookup — credentials are injected statically via NewAWSClientWithCreds so a
// single test binary can drive many tenants concurrently without mutating
// ~/.aws/credentials.
type Profile struct {
Name string
AccountID string
Client *AWSClient
Info AccountInfo
}
// AccountCarousel manages multiple Profile entries created via
// `spx admin account create`. Used by Phase 8 account-scoping where the test
// repeatedly switches between Alpha/Beta/Gamma to assert isolation.
//
// Concurrent reads via Get/Names are safe; Add must not race with itself.
type AccountCarousel struct {
mu sync.RWMutex
profiles map[string]*Profile
}
// NewAccountCarousel returns an empty carousel.
func NewAccountCarousel() *AccountCarousel {
return &AccountCarousel{profiles: make(map[string]*Profile)}
}
// Add registers a new profile under the given name (e.g. "team-alpha") and
// returns it. Duplicate names overwrite — caller is expected to use unique
// labels per tenant. info.AccessKeyID and info.SecretAccessKey must be set;
// NewAWSClientWithCreds t.Fatals otherwise.
func (a *AccountCarousel) Add(t *testing.T, env *Env, name string, info AccountInfo) *Profile {
t.Helper()
client := NewAWSClientWithCreds(t, env, info.AccessKeyID, info.SecretAccessKey)
p := &Profile{
Name: name,
AccountID: info.AccountID,
Client: client,
Info: info,
}
a.mu.Lock()
a.profiles[name] = p
a.mu.Unlock()
return p
}
// Get returns the named profile or nil if absent.
func (a *AccountCarousel) Get(name string) *Profile {
a.mu.RLock()
defer a.mu.RUnlock()
return a.profiles[name]
}
// Names returns the registered profile names in sorted order — stable for
// snapshot-style logging without depending on map iteration order.
func (a *AccountCarousel) Names() []string {
a.mu.RLock()
defer a.mu.RUnlock()
out := make([]string, 0, len(a.profiles))
for k := range a.profiles {
out = append(out, k)
}
sort.Strings(out)
return out
}
+38 -9
View File
@@ -15,6 +15,8 @@ import (
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
)
// AWSClient bundles the SDK service handles a scenario typically needs against
@@ -23,6 +25,8 @@ import (
type AWSClient struct {
EC2 *ec2.EC2
ELBv2 *elbv2.ELBV2
IAM *iam.IAM
STS *sts.STS
}
// NewAWSClient builds clients pointed at https://<endpoint>:<port>/ using the
@@ -31,6 +35,23 @@ type AWSClient struct {
// SPINIFEX_AWS_ACCESS_KEY_ID / SPINIFEX_AWS_SECRET_ACCESS_KEY for static creds.
func NewAWSClient(t *testing.T, env *Env) *AWSClient {
t.Helper()
id, secret := os.Getenv("SPINIFEX_AWS_ACCESS_KEY_ID"), os.Getenv("SPINIFEX_AWS_SECRET_ACCESS_KEY")
return newAWSClient(t, env, id, secret)
}
// NewAWSClientWithCreds builds an AWSClient with explicit static credentials
// — used by AccountCarousel to scope a client to a tenant account created
// via `spx admin account create`. Bypasses AWS_PROFILE shared-config lookup.
func NewAWSClientWithCreds(t *testing.T, env *Env, accessKey, secretKey string) *AWSClient {
t.Helper()
if accessKey == "" || secretKey == "" {
t.Fatalf("NewAWSClientWithCreds: empty credentials")
}
return newAWSClient(t, env, accessKey, secretKey)
}
func newAWSClient(t *testing.T, env *Env, accessKey, secretKey string) *AWSClient {
t.Helper()
endpoint := os.Getenv("SPINIFEX_AWS_ENDPOINT")
if endpoint == "" {
@@ -62,15 +83,18 @@ func NewAWSClient(t *testing.T, env *Env) *AWSClient {
},
}
if id, secret := os.Getenv("SPINIFEX_AWS_ACCESS_KEY_ID"), os.Getenv("SPINIFEX_AWS_SECRET_ACCESS_KEY"); id != "" && secret != "" {
cfg.Credentials = credentials.NewStaticCredentials(id, secret, "")
opts := session.Options{Config: *cfg}
if accessKey != "" && secretKey != "" {
// Static creds bypass shared-config lookup — required for the
// per-tenant carousel where each Profile holds its own access key.
cfg.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "")
opts.Config = *cfg
} else {
opts.SharedConfigState = session.SharedConfigEnable
opts.Profile = getenv("AWS_PROFILE", "spinifex")
}
sess, err := session.NewSessionWithOptions(session.Options{
Config: *cfg,
SharedConfigState: session.SharedConfigEnable,
Profile: getenv("AWS_PROFILE", "spinifex"),
})
sess, err := session.NewSessionWithOptions(opts)
if err != nil {
t.Fatalf("AWS session: %v", err)
}
@@ -78,6 +102,8 @@ func NewAWSClient(t *testing.T, env *Env) *AWSClient {
return &AWSClient{
EC2: ec2.New(sess),
ELBv2: elbv2.New(sess),
IAM: iam.New(sess),
STS: sts.New(sess),
}
}
@@ -85,7 +111,10 @@ func NewAWSClient(t *testing.T, env *Env) *AWSClient {
// for fault-injection scenarios — the cert scenario already covers trust path.
func (c *AWSClient) IgnoreCertErrors() {
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
c.EC2.Config.HTTPClient = &http.Client{Transport: tr}
c.ELBv2.Config.HTTPClient = &http.Client{Transport: tr}
hc := &http.Client{Transport: tr}
c.EC2.Config.HTTPClient = hc
c.ELBv2.Config.HTTPClient = hc
c.IAM.Config.HTTPClient = hc
c.STS.Config.HTTPClient = hc
_ = (*x509.CertPool)(nil) // silence unused-import on hardened paths
}
+49
View File
@@ -0,0 +1,49 @@
//go:build e2e
package harness
import (
"errors"
"testing"
"github.com/aws/aws-sdk-go/aws/awserr"
)
// ErrorCodeIs reports whether err is an awserr.Error with the given Code.
// Returns false for nil err or non-awserr wrappers.
func ErrorCodeIs(err error, code string) bool {
if err == nil {
return false
}
var aerr awserr.Error
if errors.As(err, &aerr) {
return aerr.Code() == code
}
return false
}
// AssertAWSError fails the test if err is nil or its awserr.Code() != code.
// Logs the full err on mismatch. Returns the matched awserr.Error so callers
// can chain assertions on Message() etc.
func AssertAWSError(t *testing.T, err error, code string) awserr.Error {
t.Helper()
if err == nil {
t.Fatalf("expected AWS error %q, got nil", code)
}
var aerr awserr.Error
if !errors.As(err, &aerr) {
t.Fatalf("expected awserr.Error with code %q, got %T: %v", code, err, err)
}
if aerr.Code() != code {
t.Fatalf("expected AWS error code %q, got %q (full: %v)", code, aerr.Code(), err)
}
return aerr
}
// ExpectError invokes fn and asserts the returned err has the given AWS code.
// Convenience for one-shot negative tests — replaces the bash `expect_error`
// helper used throughout run-e2e.sh.
func ExpectError(t *testing.T, code string, fn func() error) {
t.Helper()
AssertAWSError(t, fn(), code)
}
+236
View File
@@ -0,0 +1,236 @@
//go:build e2e
package harness
import (
"bytes"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/service/ec2"
)
// SSHTarget is the minimal addressing tuple needed by LsblkRootGiB. The
// existing harness.SSH interface requires a Cluster.Node, but the single-host
// e2e suite addresses individual instances by (host, port, key) discovered at
// runtime — those don't fit a Cluster. Defined here rather than in ssh.go to
// keep the production SSH transport untouched.
type SSHTarget struct {
User string
Host string
Port int
KeyPath string
}
// InstancePublicSSHHost returns (host, port) for an SSH connection to inst.
//
// Preference order matches run-e2e.sh:
// 1. Instance's public IP on port 22, when assigned (external networking).
// 2. QEMU hostfwd discovered by scanning `ps -ef` for the qemu process whose
// command line contains the instance ID and a `hostfwd=tcp:<ip>:<port>-:22`
// argument (dev_networking mode).
//
// t.Fatal on neither resolving — both phases need a usable SSH endpoint.
func InstancePublicSSHHost(t *testing.T, inst *ec2.Instance) (string, int) {
t.Helper()
if inst == nil {
t.Fatalf("InstancePublicSSHHost: nil instance")
}
if pub := aws.StringValue(inst.PublicIpAddress); pub != "" && pub != "None" {
return pub, 22
}
id := aws.StringValue(inst.InstanceId)
host, port, err := discoverHostfwd(id)
if err != nil {
t.Fatalf("InstancePublicSSHHost: %v", err)
}
return host, port
}
// hostfwd=tcp:HOST:PORT-:22 — HOST may be empty (qemu defaults to 127.0.0.1).
// Anchor on -:22 so we ignore other hostfwd rules (e.g. metadata, serial).
var hostfwdRE = regexp.MustCompile(`hostfwd=tcp:([^:]*):([0-9]+)-:22`)
func discoverHostfwd(instanceID string) (string, int, error) {
out, err := exec.Command("ps", "-eo", "args=").CombinedOutput()
if err != nil {
return "", 0, fmt.Errorf("ps: %w", err)
}
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, instanceID) || !strings.Contains(line, "qemu") {
continue
}
m := hostfwdRE.FindStringSubmatch(line)
if m == nil {
continue
}
host := m[1]
if host == "" {
host = "127.0.0.1"
}
port, err := strconv.Atoi(m[2])
if err != nil {
return "", 0, fmt.Errorf("parse hostfwd port %q: %w", m[2], err)
}
return host, port, nil
}
return "", 0, fmt.Errorf("no qemu hostfwd found for %s", instanceID)
}
// LsblkRootGiB SSHes into the VM and returns the root disk size in GiB,
// cross-checking the value the API reports against the guest's view.
//
// Equivalent of run-e2e.sh's lsblk pipeline (findmnt → lsblk PKNAME → lsblk
// -b -d). Returns the GiB rounded down (same math as bash: bytes / 1<<30).
func LsblkRootGiB(t *testing.T, tgt SSHTarget) int {
t.Helper()
cmd := `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"`
args := []string{
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-o", "ConnectTimeout=5",
"-o", "BatchMode=yes",
"-p", strconv.Itoa(tgt.Port),
"-i", tgt.KeyPath,
tgt.User + "@" + tgt.Host,
cmd,
}
var stdout, stderr bytes.Buffer
sshCmd := exec.Command("ssh", args...)
sshCmd.Stdout = &stdout
sshCmd.Stderr = &stderr
if err := sshCmd.Run(); err != nil {
t.Fatalf("ssh lsblk %s@%s:%d failed: %v\nstderr: %s", tgt.User, tgt.Host, tgt.Port, err, stderr.String())
}
raw := strings.TrimSpace(stdout.String())
bytesN, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
t.Fatalf("ssh lsblk %s@%s:%d: parse %q: %v", tgt.User, tgt.Host, tgt.Port, raw, err)
}
return int(bytesN / (1 << 30))
}
// GetSerialConsoleAccessEnabled returns the current account attribute value.
// Wraps ec2.GetSerialConsoleAccessStatus so callers don't have to deal with
// the pointer-bool field.
func GetSerialConsoleAccessEnabled(t *testing.T, c *AWSClient) bool {
t.Helper()
out, err := c.EC2.GetSerialConsoleAccessStatus(&ec2.GetSerialConsoleAccessStatusInput{})
if err != nil {
t.Fatalf("GetSerialConsoleAccessStatus: %v", err)
}
return aws.BoolValue(out.SerialConsoleAccessEnabled)
}
// EnableSerialConsoleAccess flips the account attribute on and returns the
// new value (should be true).
func EnableSerialConsoleAccess(t *testing.T, c *AWSClient) bool {
t.Helper()
out, err := c.EC2.EnableSerialConsoleAccess(&ec2.EnableSerialConsoleAccessInput{})
if err != nil {
t.Fatalf("EnableSerialConsoleAccess: %v", err)
}
return aws.BoolValue(out.SerialConsoleAccessEnabled)
}
// DisableSerialConsoleAccess flips the account attribute off and returns the
// new value (should be false).
func DisableSerialConsoleAccess(t *testing.T, c *AWSClient) bool {
t.Helper()
out, err := c.EC2.DisableSerialConsoleAccess(&ec2.DisableSerialConsoleAccessInput{})
if err != nil {
t.Fatalf("DisableSerialConsoleAccess: %v", err)
}
return aws.BoolValue(out.SerialConsoleAccessEnabled)
}
// DiscoverDefaultVPC returns (vpcID, defaultSGID, defaultSubnetID) for the
// account's default VPC. t.Fatal if the default VPC is missing — the suite
// can't run without one, and silently falling back masks the real failure
// (daemon didn't create the default VPC on account bootstrap).
func DiscoverDefaultVPC(t *testing.T, c *AWSClient) (vpcID, sgID, subnetID string) {
t.Helper()
vpcs, err := c.EC2.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{{Name: aws.String("isDefault"), Values: []*string{aws.String("true")}}},
})
if err != nil {
t.Fatalf("describe-vpcs: %v", err)
}
if len(vpcs.Vpcs) == 0 {
t.Fatalf("no default VPC found")
}
vpcID = aws.StringValue(vpcs.Vpcs[0].VpcId)
sgs, err := c.EC2.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpcID)}},
{Name: aws.String("group-name"), Values: []*string{aws.String("default")}},
},
})
if err != nil {
t.Fatalf("describe-security-groups: %v", err)
}
if len(sgs.SecurityGroups) == 0 {
t.Fatalf("default SG missing for VPC %s", vpcID)
}
sgID = aws.StringValue(sgs.SecurityGroups[0].GroupId)
subnets, err := c.EC2.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpcID)}}},
})
if err != nil {
t.Fatalf("describe-subnets: %v", err)
}
for _, s := range subnets.Subnets {
if aws.BoolValue(s.DefaultForAz) {
subnetID = aws.StringValue(s.SubnetId)
break
}
}
// Fall back to the first subnet if none is marked DefaultForAz — pseudo
// multinode skips the marker, and callers just need a usable subnet ID.
if subnetID == "" && len(subnets.Subnets) > 0 {
subnetID = aws.StringValue(subnets.Subnets[0].SubnetId)
}
return vpcID, sgID, subnetID
}
// AuthorizeSSHIngress idempotently authorizes tcp/22 ingress from 0.0.0.0/0
// on sgID. InvalidPermission.Duplicate is treated as success — the rule was
// already in place on a re-run.
func AuthorizeSSHIngress(t *testing.T, c *AWSClient, sgID string) {
t.Helper()
_, err := c.EC2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
GroupId: aws.String(sgID),
IpPermissions: []*ec2.IpPermission{{
IpProtocol: aws.String("tcp"),
FromPort: aws.Int64(22),
ToPort: aws.Int64(22),
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
}},
})
if err == nil {
return
}
var aerr awserr.Error
if asErr(err, &aerr) && aerr.Code() == "InvalidPermission.Duplicate" {
return
}
t.Fatalf("authorize-security-group-ingress tcp/22 on %s: %v", sgID, err)
}
// asErr is a thin errors.As that lets the helper stay testify-free.
func asErr(err error, target *awserr.Error) bool {
if a, ok := err.(awserr.Error); ok {
*target = a
return true
}
return false
}
+78
View File
@@ -0,0 +1,78 @@
//go:build e2e
package harness
import (
"bytes"
"fmt"
"os/exec"
"strings"
"testing"
"time"
)
// SkipIfNoOVN calls t.Skip when ovn-nbctl isn't on PATH or passwordless sudo
// isn't available. Developer laptops don't have OVN; CI VMs have both.
func SkipIfNoOVN(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("ovn-nbctl"); err != nil {
t.Skipf("ovn-nbctl not on PATH (%v); skipping OVN check", err)
}
// `sudo -n true` returns 0 only if a sudoers entry allows passwordless
// for the current user. Anything else (prompt, deny, missing sudo) skips.
if err := exec.Command("sudo", "-n", "true").Run(); err != nil {
t.Skipf("passwordless sudo unavailable (%v); skipping OVN check", err)
}
}
// OvnNbctl runs `sudo -n ovn-nbctl <args...>`. Returns trimmed stdout on
// success; t.Fatal on non-zero exit. Wrap in Eventually for propagation polls.
func OvnNbctl(t *testing.T, args ...string) string {
t.Helper()
return runOvn(t, "ovn-nbctl", args...)
}
// OvnSbctl is the SB-database equivalent of OvnNbctl.
func OvnSbctl(t *testing.T, args ...string) string {
t.Helper()
return runOvn(t, "ovn-sbctl", args...)
}
func runOvn(t *testing.T, tool string, args ...string) string {
t.Helper()
full := append([]string{"-n", tool}, args...)
var stdout, stderr bytes.Buffer
cmd := exec.Command("sudo", full...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("sudo %s %s failed: %v\nstderr: %s", tool, strings.Join(args, " "), err, stderr.String())
}
return strings.TrimSpace(stdout.String())
}
// WaitForPortGroupMember polls until the OVN port_group pg contains the
// logical-switch-port lsp. Default 10s/1s — northd propagation is normally
// sub-second but tests run on busy nodes.
//
// Lookup uses ovn-nbctl --bare --columns=ports find port_group name=<pg>
// (same shape as run-e2e.sh:3311) and checks for the lsp's UUID in the
// returned set.
func WaitForPortGroupMember(t *testing.T, pg, lsp string, opts ...PollOpt) {
t.Helper()
cfg := applyOpts(pollCfg{timeout: 10 * time.Second, interval: 1 * time.Second}, opts...)
EventuallyErr(t, func() error {
lspUUID := OvnNbctl(t, "--no-leader-only", "--bare", "--columns=_uuid",
"find", "logical_switch_port", "name="+lsp)
if lspUUID == "" {
return fmt.Errorf("LSP %s not found in NB", lsp)
}
ports := OvnNbctl(t, "--no-leader-only", "--bare", "--columns=ports",
"find", "port_group", "name="+pg)
if !strings.Contains(ports, lspUUID) {
return fmt.Errorf("LSP %s (%s) not in port_group %s (ports=%s)", lsp, lspUUID, pg, ports)
}
return nil
}, cfg.timeout, cfg.interval)
}
+156
View File
@@ -0,0 +1,156 @@
//go:build e2e
package harness
import (
"fmt"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
)
// PollOpt tunes the default timeout / interval for a Wait* helper. Defaults
// are tuned per-resource based on the bash `sleep N; check` loops in
// run-e2e.sh — instance/volume = 5min, snapshot/image = 10min, all polled
// every 2s.
type PollOpt func(*pollCfg)
type pollCfg struct {
timeout time.Duration
interval time.Duration
}
// WithTimeout overrides the resource-default timeout.
func WithTimeout(d time.Duration) PollOpt { return func(c *pollCfg) { c.timeout = d } }
// WithPoll overrides the resource-default polling interval.
func WithPoll(d time.Duration) PollOpt { return func(c *pollCfg) { c.interval = d } }
func applyOpts(def pollCfg, opts ...PollOpt) pollCfg {
for _, o := range opts {
o(&def)
}
return def
}
// WaitForInstanceState polls DescribeInstances until State.Name == target.
// Returns the latest *ec2.Instance on success; t.Fatal on timeout. Separate
// from lb.go's WaitForInstanceRunning so callers can wait for stopped /
// terminated / pending without one helper per state.
func WaitForInstanceState(t *testing.T, c *AWSClient, id, target string, opts ...PollOpt) *ec2.Instance {
t.Helper()
cfg := applyOpts(pollCfg{timeout: 5 * time.Minute, interval: 2 * time.Second}, opts...)
var last *ec2.Instance
var lastState string
EventuallyErr(t, func() error {
out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
InstanceIds: []*string{aws.String(id)},
})
if err != nil {
return fmt.Errorf("describe %s: %w", id, err)
}
if len(out.Reservations) == 0 || len(out.Reservations[0].Instances) == 0 {
return fmt.Errorf("%s not found", id)
}
last = out.Reservations[0].Instances[0]
lastState = aws.StringValue(last.State.Name)
if lastState == target {
return nil
}
return fmt.Errorf("%s state=%s want=%s", id, lastState, target)
}, cfg.timeout, cfg.interval)
t.Logf("instance %s reached state %s", id, target)
return last
}
// WaitForVolumeState polls DescribeVolumes until State == target. Common
// targets: "available", "in-use", "deleted".
func WaitForVolumeState(t *testing.T, c *AWSClient, id, target string, opts ...PollOpt) *ec2.Volume {
t.Helper()
cfg := applyOpts(pollCfg{timeout: 5 * time.Minute, interval: 2 * time.Second}, opts...)
var last *ec2.Volume
var lastState string
EventuallyErr(t, func() error {
out, err := c.EC2.DescribeVolumes(&ec2.DescribeVolumesInput{
VolumeIds: []*string{aws.String(id)},
})
if err != nil {
return fmt.Errorf("describe-volume %s: %w", id, err)
}
if len(out.Volumes) == 0 {
return fmt.Errorf("%s not found", id)
}
last = out.Volumes[0]
lastState = aws.StringValue(last.State)
if lastState == target {
return nil
}
return fmt.Errorf("%s state=%s want=%s", id, lastState, target)
}, cfg.timeout, cfg.interval)
t.Logf("volume %s reached state %s", id, target)
return last
}
// WaitForSnapshotState polls DescribeSnapshots until State == target. Snapshot
// creation can be slow on busy nodes — default timeout 10min.
func WaitForSnapshotState(t *testing.T, c *AWSClient, id, target string, opts ...PollOpt) *ec2.Snapshot {
t.Helper()
cfg := applyOpts(pollCfg{timeout: 10 * time.Minute, interval: 2 * time.Second}, opts...)
var last *ec2.Snapshot
var lastState string
EventuallyErr(t, func() error {
out, err := c.EC2.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
SnapshotIds: []*string{aws.String(id)},
})
if err != nil {
return fmt.Errorf("describe-snapshot %s: %w", id, err)
}
if len(out.Snapshots) == 0 {
return fmt.Errorf("%s not found", id)
}
last = out.Snapshots[0]
lastState = aws.StringValue(last.State)
if lastState == target {
return nil
}
if lastState == "error" {
return fmt.Errorf("%s entered error state: %s", id, aws.StringValue(last.StateMessage))
}
return fmt.Errorf("%s state=%s want=%s", id, lastState, target)
}, cfg.timeout, cfg.interval)
t.Logf("snapshot %s reached state %s", id, target)
return last
}
// WaitForImageState polls DescribeImages until State == target. AMI creation
// from a snapshot is the slowest path; default timeout 10min.
func WaitForImageState(t *testing.T, c *AWSClient, id, target string, opts ...PollOpt) *ec2.Image {
t.Helper()
cfg := applyOpts(pollCfg{timeout: 10 * time.Minute, interval: 2 * time.Second}, opts...)
var last *ec2.Image
var lastState string
EventuallyErr(t, func() error {
out, err := c.EC2.DescribeImages(&ec2.DescribeImagesInput{
ImageIds: []*string{aws.String(id)},
})
if err != nil {
return fmt.Errorf("describe-image %s: %w", id, err)
}
if len(out.Images) == 0 {
return fmt.Errorf("%s not found", id)
}
last = out.Images[0]
lastState = aws.StringValue(last.State)
if lastState == target {
return nil
}
if lastState == "failed" {
return fmt.Errorf("%s entered failed state: %s", id, aws.StringValue(last.StateReason.Message))
}
return fmt.Errorf("%s state=%s want=%s", id, lastState, target)
}, cfg.timeout, cfg.interval)
t.Logf("image %s reached state %s", id, target)
return last
}
+123
View File
@@ -0,0 +1,123 @@
//go:build e2e
package harness
import (
"bytes"
"crypto/tls"
"encoding/xml"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
)
// PostAWSAction sends a raw application/x-www-form-urlencoded POST with
// Action=<action> + Version=2016-11-15 + caller params, signed with the same
// credentials the AWS SDK is using. Gateway rejects on auth before parsing
// the action, so signing is mandatory even for InvalidAction probes.
//
// Returns the HTTP status, raw body, and the parsed <Code> from the AWS XML
// error envelope (empty for 2xx). t.Fatal on transport / signing failure.
func PostAWSAction(t *testing.T, env *Env, c *AWSClient, action string, params map[string]string) (status int, body []byte, awsCode string) {
t.Helper()
endpoint := c.EC2.Endpoint
if endpoint == "" {
t.Fatalf("PostAWSAction: EC2 endpoint not configured")
}
region := aws.StringValue(c.EC2.Config.Region)
form := url.Values{}
form.Set("Action", action)
if _, ok := params["Version"]; !ok {
// EC2 query API version — same value the SDK injects on every call.
// Hard-coded here because we are bypassing the SDK entirely.
form.Set("Version", "2016-11-15")
}
for k, v := range params {
form.Set(k, v)
}
bodyBytes := []byte(form.Encode())
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(endpoint, "/")+"/", bytes.NewReader(bodyBytes))
if err != nil {
t.Fatalf("PostAWSAction: build request: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
creds := c.EC2.Config.Credentials
if creds == nil {
t.Fatalf("PostAWSAction: no credentials on client")
}
signer := v4.NewSigner(creds)
// SignedReader keeps the body intact for replay; v4.Sign reads-then-resets.
if _, err := signer.Sign(req, bytes.NewReader(bodyBytes), "ec2", region, time.Now()); err != nil {
t.Fatalf("PostAWSAction: sign: %v", err)
}
// Reuse the EC2 client's HTTP transport so TLS trust matches the rest of
// the suite. Fall back to a CA-loaded transport if the embedded client
// is using the SDK default.
httpClient := c.EC2.Config.HTTPClient
if httpClient == nil {
httpClient = &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{}},
Timeout: env.DefaultTimeout,
}
}
resp, err := httpClient.Do(req)
if err != nil {
t.Fatalf("PostAWSAction: %v", err)
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("PostAWSAction: read body: %v", err)
}
status = resp.StatusCode
if status >= 200 && status < 300 {
return status, body, ""
}
awsCode = parseAWSXMLErrorCode(body)
return status, body, awsCode
}
// EC2 returns either <Response><Errors><Error><Code>…</Code> or the SDK-style
// <ErrorResponse><Error><Code>…</Code> envelope. Try both shapes; the
// gateway has shipped one then the other across releases.
type ec2ErrorResponse struct {
XMLName xml.Name `xml:"Response"`
Errors struct {
Error struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
} `xml:"Error"`
} `xml:"Errors"`
}
type sdkErrorResponse struct {
XMLName xml.Name `xml:"ErrorResponse"`
Error struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
} `xml:"Error"`
}
func parseAWSXMLErrorCode(body []byte) string {
var e1 ec2ErrorResponse
if err := xml.Unmarshal(body, &e1); err == nil && e1.Errors.Error.Code != "" {
return e1.Errors.Error.Code
}
var e2 sdkErrorResponse
if err := xml.Unmarshal(body, &e2); err == nil && e2.Error.Code != "" {
return e2.Error.Code
}
return ""
}
+115
View File
@@ -0,0 +1,115 @@
//go:build e2e
package harness
import (
"bytes"
"os"
"os/exec"
"regexp"
"strings"
"testing"
)
// AccountInfo is the parsed result of `spx admin account create`. Fields
// match the lines emitted by cmd/spinifex/cmd/admin.go:runAccountCreate.
type AccountInfo struct {
AccountID string
AccountName string
AccessKeyID string
SecretAccessKey string
Profile string
}
// SpxBin returns the spx binary path (SPX_BIN env var or "spx" on PATH).
func SpxBin() string {
if v := os.Getenv("SPX_BIN"); v != "" {
return v
}
return "spx"
}
// SpxRun runs `spx <args...>` and returns combined stdout+stderr. If wantErr
// is false the test fails on non-zero exit; if true the call returns the
// output and the test continues (caller asserts on output content).
func SpxRun(t *testing.T, wantErr bool, args ...string) string {
t.Helper()
var buf bytes.Buffer
cmd := exec.Command(SpxBin(), args...)
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
out := buf.String()
if err != nil && !wantErr {
t.Fatalf("spx %s failed: %v\noutput:\n%s", strings.Join(args, " "), err, out)
}
if err == nil && wantErr {
t.Fatalf("spx %s expected non-zero exit, got success\noutput:\n%s", strings.Join(args, " "), out)
}
return out
}
// SpxGetNodes runs `spx get nodes`.
func SpxGetNodes(t *testing.T) string {
t.Helper()
return SpxRun(t, false, "get", "nodes")
}
// SpxGetVMs runs `spx get vms`.
func SpxGetVMs(t *testing.T) string {
t.Helper()
return SpxRun(t, false, "get", "vms")
}
// SpxTopNodes runs `spx top nodes`.
func SpxTopNodes(t *testing.T) string {
t.Helper()
return SpxRun(t, false, "top", "nodes")
}
// admin account create output is a fixed two-column key/value list; pin each
// field to a line anchor so a layout change (added/reordered keys) surfaces
// loudly instead of silently matching the wrong value.
var (
spxAcctRE = regexp.MustCompile(`(?m)^\s+Account ID:\s+(\S+)`)
spxNameRE = regexp.MustCompile(`(?m)^\s+Account Name:\s+(.+?)\s*$`)
spxKeyIDRE = regexp.MustCompile(`(?m)^\s+Access Key ID:\s+(\S+)`)
spxSecretRE = regexp.MustCompile(`(?m)^\s+Secret Access Key:\s+(\S+)`)
spxProfRE = regexp.MustCompile(`(?m)^\s+AWS Profile:\s+(\S+)`)
)
// SpxAdminAccountCreate creates a tenant account via the spx CLI and parses
// the printed credentials block. email is forwarded as --email when non-empty;
// `spx admin account create` ignores unknown flags today, but the CLI may
// adopt --email in future and any caller that wants to gate on it (e.g. IAM
// e-mail uniqueness checks) should pass it through unchanged.
func SpxAdminAccountCreate(t *testing.T, name, email string) AccountInfo {
t.Helper()
args := []string{"admin", "account", "create", "--name", name}
if email != "" {
args = append(args, "--email", email)
}
out := SpxRun(t, false, args...)
info := AccountInfo{
AccountID: firstSubmatch(spxAcctRE, out),
AccountName: firstSubmatch(spxNameRE, out),
AccessKeyID: firstSubmatch(spxKeyIDRE, out),
SecretAccessKey: firstSubmatch(spxSecretRE, out),
Profile: firstSubmatch(spxProfRE, out),
}
if info.AccountID == "" || info.AccessKeyID == "" || info.SecretAccessKey == "" {
// Dump raw output to the artifact dir if available — otherwise the
// formatted log block lives only in the JUnit XML.
t.Fatalf("spx admin account create: failed to parse credentials block\n--- raw output ---\n%s\n--- end ---", out)
}
return info
}
func firstSubmatch(re *regexp.Regexp, s string) string {
m := re.FindStringSubmatch(s)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}