Files
Ben Duncan ea3e01bb20 fix: reboot a guest by asking it, not by pulling its reset line (#1028)
* fix: reboot a guest by asking it, not by pulling its reset line

RebootInstances was QMP system_reset alone. The guest kernel is never told,
so it does not sync, does not unmount and loses every dirty page - which is
how a reboot issued seconds after a write loses that write, and how a guest
rebooted during cloud-init can fail to come back at all.

Guests now launch with -action shutdown=pause, so a clean guest poweroff
leaves QEMU paused rather than gone. Reboot presses the power button, waits
for the guest to reach that state, then resets and resumes the same process:
no volume, tap or state churn, and no window where the guest is missing. A
guest that will not go quietly is hard-reset, which is what AWS documents.

The button is pressed again every 10s while the guest ignores it. One signal
is not a shutdown request on a guest whose acpid has not started, which is
the same boot race that produced the failure.

Two consequences handled here: a guest that powers itself off now leaves
QEMU paused, so the heartbeat quits it; and a guest launched before the flag
exits on powerdown rather than pausing, so it is reset without being asked.

* fix: answer a reboot when it is accepted, not when the guest is back

Asking a guest to shut down takes as long as the guest takes. RebootInstances
gives the daemon five seconds to reply, so a reboot that waited for the guest
reported a reboot that had in fact succeeded as InvalidInstanceID.NotFound.

Manager.Reboot now admits the reboot — the instance exists and is running — and
runs the powerdown and reset in the background, which is the contract EC2
documents. The two admission failures are still reported; nothing after them
can be, so a reset that fails is logged and left to the heartbeat.

The gateway also stops calling a NATS timeout NotFound. Only the absence of a
subscriber says anything about where the instance is, which is how the sibling
start and terminate paths already read it.

* test: prove the churn reboot by its boot ID, not by how fast it happened

The subtest waited ten seconds and then read /proc/uptime. A reboot is
asynchronous, so the guest answering at that point may be the one that has
not shut down yet, and its uptime is whatever it was before — which the test
reads as "reboot may not have occurred".

Waiting for the boot ID to change is positive proof the guest rebooted and
does not depend on how long the guest takes to go down. The uptime assertion
stays, now measured against a boot that is known to be new.

* test: prove the graceful reboot against real guests and a real engine

Two tests the reboot design asked for and the unit tests cannot give.

Cell 18 launches two guests that differ only in how they answer the power
button. One installs a shutdown hook writing an fsynced line a second, so the
file that survives measures how much of its shutdown it was allowed to run —
a guest reset instead of asked leaves none of it. The other ignores the power
key entirely and must be reset once its budget expires, with the daemon saying
so. Each variant asserts the evidence only it can produce, and the slow one
asserts the absence of the line the wedged one requires.

Cell 27 loads a PostgreSQL instance past its shared buffers with pgbench and
reboots it dirty. The probe is an UNLOGGED table: PostgreSQL writes no WAL for
one, so a clean shutdown checkpoints it and crash recovery truncates it
unconditionally. Rows committed normally survive a hard reset too, so the
obvious assertion would pass against the bug.

The rds suite and cell 27 both gain budget for the load phase.

* fix: stop the process-group test misreading a reaped child as alive

The test polled until the child was gone, then checked once more. A task
being reaped shows state X for an instant, which processRunning counted as
running, so that second look failed about 1 run in 50 under -race.

* fix: wait for the recovery retry loop before removing a test's data dir

restoreInstances starts the retry loop untracked, and it rewrites the local
state file when it ends. A write landing during t.TempDir cleanup failed
TestRestoreInstances_* with RemoveAll: directory not empty.
2026-09-14 15:39:27 -05:00

369 lines
14 KiB
Go

//go:build e2e
package harness
import (
"encoding/base64"
"fmt"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/rds"
)
// A DB instance's endpoint only ever has a private address (public access is rejected
// PubliclyAccessible), and nothing on the runner routes into a customer subnet.
// So the client that proves a customer can reach the database has to be a VM in
// that subnet, driven over SSH — not psql on the machine running the test.
//
// The client is an Ubuntu gold image with both engines' clients installed at
// boot, not an engine AMI: those images carry neither sshd nor cloud-init, so a
// VM launched from one would come up unreachable.
const (
rdsClientUser = "ubuntu"
// Where the boot-time install reports itself. The exit-code file is written
// from an EXIT trap so the poller can always read a definitive answer
// instead of inferring one from a missing binary.
rdsClientStatusFile = "/tmp/rds-client-setup.status"
rdsClientExitCodeFile = "/tmp/rds-client-setup.exitcode"
// The cluster CA, pushed into the guest so a verify-full connection has a
// trust root. The engine AMI ships one; an Ubuntu client does not.
RDSClientCACertPath = "/home/ubuntu/spinifex-ca.pem"
// Each engine's assigned port, and the only one an instance of it publishes.
PostgresEnginePort = 5432
MariaDBEnginePort = 3306
rdsClientSSHTimeout = 5 * time.Minute
rdsClientSetupTimeout = 5 * time.Minute
)
// rdsClientUserData installs both engines' clients at first boot. Status and
// exit-code sentinels mirror the storagegrowth suite's protocol; the distinct
// exit codes separate "no apt egress" from "no such package", and one engine's
// client from the other's, which otherwise all surface as a missing binary much
// later.
var rdsClientUserData = fmt.Sprintf(`#!/bin/bash
set -u
trap 'echo $? > %[2]s' EXIT
echo running > %[1]s
export DEBIAN_FRONTEND=noninteractive
apt-get update -y || exit 10
apt-get install -y postgresql-client || exit 11
apt-get install -y mariadb-client || exit 12
# pgbench ships in contrib, not the client package, and a load phase has no
# other way to dirty an engine's buffer pool from outside it.
apt-get install -y postgresql-contrib || exit 13
echo done > %[1]s
`, rdsClientStatusFile, rdsClientExitCodeFile)
// RDSClientPlacement is where a client VM is put: the subnet it sits in and the
// security group it carries. Which subnet is the whole point for a caller
// proving an endpoint is reachable from somewhere other than its own.
type RDSClientPlacement struct {
SubnetID string
SGID string
}
// RDSClientVM returns an SSH target for a postgres client VM in the customer's
// default VPC — the placement every test that only needs *a* client wants.
// Memoized per fixture: the boot, the install and the CA push happen once per
// process.
//
// The default VPC maps public IPs on launch and routes 0.0.0.0/0 at its IGW, so
// the same VM is reachable from the runner for SSH and has the apt egress the
// install needs.
func RDSClientVM(t *testing.T, c *AWSClient, fx *Fixture, env *Env) SSHTarget {
t.Helper()
vpc := EnsureDefaultVPC(t, fx)
// The default SG admits only same-SG members, so the runner's SSH and the
// client's connection to the DB endpoint both need opening explicitly. A
// caller supplying its own placement brings its own groups instead. Both
// engines' ports are opened here: the client VM is shared by every test that
// needs a connection, whichever engine it is connecting to.
EnsureDefaultSGOpen(t, c)
AuthorizeTCPIngress(t, c, vpc.SGID, PostgresEnginePort)
AuthorizeTCPIngress(t, c, vpc.SGID, MariaDBEnginePort)
return RDSClientVMIn(t, c, fx, env, RDSClientPlacement{SubnetID: vpc.SubnetID, SGID: vpc.SGID})
}
// RDSClientVMIn is RDSClientVM in a placement the caller owns, for a topology
// the default VPC cannot express. The subnet must map public IPs on launch and
// reach an IGW, and the group must admit tcp/22 from the runner and let the
// client out to the endpoint: everything past that is the caller's own network.
//
// The memo is keyed through EnsureInstance, whose key already carries the subnet
// and group, so a second placement builds a second VM rather than returning the
// first.
func RDSClientVMIn(t *testing.T, c *AWSClient, fx *Fixture, env *Env, p RDSClientPlacement) SSHTarget {
t.Helper()
instType, arch := DiscoverNanoInstanceType(t, fx)
ami := DiscoverUbuntuAMI(t, fx, arch)
// The suite-level artifact root, not the calling test's subdirectory: the key
// outlives the test that first asked for the VM, and ArtifactDir prunes a
// passing test's directory — taking the PEM the next caller needs with it.
keyName, keyPath := EnsureKeyPair(t, fx)
instanceID := EnsureInstance(t, fx, InstanceSpec{
AMIID: ami,
InstanceType: instType,
KeyName: keyName,
SubnetID: p.SubnetID,
SGID: p.SGID,
UserData: base64.StdEncoding.EncodeToString([]byte(rdsClientUserData)),
})
inst := WaitForInstanceState(t, c, instanceID, "running")
host, port := InstancePublicSSHHost(t, inst)
tgt := SSHTarget{User: rdsClientUser, Host: host, Port: port, KeyPath: keyPath}
// Memoized apart from the instance so a second caller reuses the readied
// guest rather than re-polling the install and re-pushing the CA.
if _, err := fx.ensureOnce(t, "rds-client-vm:ready:"+instanceID, func() (string, func() error, error) {
if !TryGuestSSHReady(host, port, rdsClientUser, keyPath, rdsClientSSHTimeout) {
return "", nil, fmt.Errorf("client VM %s SSH %s:%d not ready after %s", instanceID, host, port, rdsClientSSHTimeout)
}
if err := waitForRDSClientTooling(t, tgt); err != nil {
return "", nil, err
}
if err := pushClusterCA(tgt, env); err != nil {
return "", nil, err
}
return instanceID, nil, nil
}); err != nil {
t.Fatalf("RDSClientVM: %v", err)
}
t.Logf("RDS client VM %s ready at %s@%s:%d", instanceID, rdsClientUser, host, port)
return tgt
}
// waitForRDSClientTooling polls the boot-time install's sentinels until both
// clients are installed. A non-zero exit code is reported with the cloud-init
// log tail, since the interesting failure is always inside apt.
func waitForRDSClientTooling(t *testing.T, tgt SSHTarget) error {
t.Helper()
deadline := time.Now().Add(rdsClientSetupTimeout)
var lastErr error
status := "pending"
for {
// A transient SSH fault mid-install is normal; only its persistence past
// the deadline is a failure, so it is carried, not swallowed — and neither
// sentinel is read at all unless the read that produced it succeeded.
readStatus, code, err := readRDSClientSentinels(tgt)
switch {
case err != nil:
lastErr = err
default:
status = readStatus
if code != "" && code != "0" {
log, _ := GuestExec(tgt, "tail -40 /var/log/cloud-init-output.log 2>/dev/null || true")
return fmt.Errorf("client VM database-client install exited %s (status=%q):\n%s", code, status, log)
}
if code == "0" && status == "done" {
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("client VM database-client install still %q after %s (last ssh error: %v)",
status, rdsClientSetupTimeout, lastErr)
}
time.Sleep(5 * time.Second)
}
}
// Both sentinels, or neither. GuestExec returns the combined output, so ssh's
// own error text on a failed connection is indistinguishable from a sentinel's
// contents — reading one after a failure would take that text for an exit code.
func readRDSClientSentinels(tgt SSHTarget) (status, code string, err error) {
status, err = GuestExec(tgt, "cat "+rdsClientStatusFile+" 2>/dev/null || echo pending")
if err != nil {
return "", "", err
}
code, err = GuestExec(tgt, "cat "+rdsClientExitCodeFile+" 2>/dev/null || true")
if err != nil {
return "", "", err
}
return strings.TrimSpace(status), strings.TrimSpace(code), nil
}
// pushClusterCA copies the cluster CA into the guest so psql can be asked for
// verify-full. Base64 over the SSH command line rather than scp: a PEM is small
// and it needs no second transport.
func pushClusterCA(tgt SSHTarget, env *Env) error {
caPath, err := ResolveCACert(env)
if err != nil {
return fmt.Errorf("locate cluster CA: %w", err)
}
pem, err := os.ReadFile(caPath)
if err != nil {
return fmt.Errorf("read cluster CA %s: %w", caPath, err)
}
cmd := fmt.Sprintf("echo %s | base64 -d > %s && chmod 0600 %s",
base64.StdEncoding.EncodeToString(pem), RDSClientCACertPath, RDSClientCACertPath)
if out, err := GuestExec(tgt, cmd); err != nil {
return fmt.Errorf("push cluster CA into the client guest: %w\n%s", err, out)
}
return nil
}
// ----------------------------------------------------------------------------
// psql in the guest
// ----------------------------------------------------------------------------
// PSQLConn addresses one psql invocation from inside the client guest.
type PSQLConn struct {
Host string
Port int64
User string
Password string
DBName string
// SSLMode is handed to libpq verbatim. Empty leaves libpq's own default,
// which is what an unpinned smoke connection wants; verify-full additionally
// needs SSLRootCert.
SSLMode string
// SSLRootCert is an in-guest path to the trust root, normally
// RDSClientCACertPath.
SSLRootCert string
}
// PSQLConnFor builds a connection from an available DB instance's own reported
// endpoint, so a test cannot accidentally assert against an address the control
// plane never published.
func PSQLConnFor(t *testing.T, instance *rds.DBInstance, user, password, dbName string) PSQLConn {
t.Helper()
if instance == nil || instance.Endpoint == nil {
t.Fatalf("PSQLConnFor: instance publishes no endpoint")
}
return PSQLConn{
Host: aws.StringValue(instance.Endpoint.Address),
Port: aws.Int64Value(instance.Endpoint.Port),
User: user,
Password: password,
DBName: dbName,
}
}
// One psql call, including connect. Generous enough for a cold connection to an
// engine that has just finished starting, tight enough to fail inside a subtest.
const psqlTimeout = 90 * time.Second
// PSQL runs sql through psql in the client guest and returns its output.
// ON_ERROR_STOP turns a failed statement into a non-zero exit rather than a
// warning buried in stdout.
//
// A failure fails the test. There is no skip path on the client leg: a green run
// that never opened a connection is the exact outcome this suite exists to
// prevent.
func PSQL(t *testing.T, tgt SSHTarget, conn PSQLConn, sql string) string {
t.Helper()
out, err := TryPSQL(tgt, conn, sql)
if err != nil {
t.Fatalf("psql %s@%s:%d/%s: %v\n%s", conn.User, conn.Host, conn.Port, conn.DBName, err, out)
}
return out
}
// TryPSQL is PSQL without the t.Fatal, for the cases where the connection is
// supposed to fail: a retired password, a security group that does not admit
// 5432, a certificate that must not verify.
func TryPSQL(tgt SSHTarget, conn PSQLConn, sql string) (string, error) {
return GuestExecTimeout(tgt, psqlCommand(conn, sql), psqlTimeout)
}
// psqlCommand renders the guest-side command line. The password goes through the
// environment, never psql's argv, so it stays out of the guest's process list
// and out of any diagnostic that captures it.
func psqlCommand(conn PSQLConn, sql string) string {
port := conn.Port
if port == 0 {
port = PostgresEnginePort
}
env := []string{
"PGPASSWORD=" + ShellQuote(conn.Password),
"PGCONNECT_TIMEOUT=30",
}
if conn.SSLMode != "" {
env = append(env, "PGSSLMODE="+ShellQuote(conn.SSLMode))
}
if conn.SSLRootCert != "" {
env = append(env, "PGSSLROOTCERT="+ShellQuote(conn.SSLRootCert))
}
args := []string{
"psql", "--no-psqlrc", "--quiet", "--tuples-only", "--no-align",
"--set", "ON_ERROR_STOP=1",
"--host", ShellQuote(conn.Host),
"--port", strconv.FormatInt(port, 10),
"--username", ShellQuote(conn.User),
"--dbname", ShellQuote(conn.DBName),
"--command", ShellQuote(sql),
}
return strings.Join(env, " ") + " " + strings.Join(args, " ")
}
// pgbenchTimeout bounds one load phase. Initialising a scale factor's worth of
// rows and then driving the buffer pool dirty are both minutes of guest I/O on
// these hosts rather than seconds.
const pgbenchTimeout = 20 * time.Minute
// PGBench runs pgbench in the client guest against conn, and returns its output.
// The password goes through the environment for the same reason psql's does.
func PGBench(t *testing.T, tgt SSHTarget, conn PSQLConn, args ...string) string {
t.Helper()
port := conn.Port
if port == 0 {
port = PostgresEnginePort
}
command := strings.Join(append([]string{
"PGPASSWORD=" + ShellQuote(conn.Password),
"PGCONNECT_TIMEOUT=30",
"pgbench",
"--host", ShellQuote(conn.Host),
"--port", strconv.FormatInt(port, 10),
"--username", ShellQuote(conn.User),
}, append(args, ShellQuote(conn.DBName))...), " ")
out, err := GuestExecTimeout(tgt, command, pgbenchTimeout)
if err != nil {
t.Fatalf("pgbench %v against %s:%d/%s: %v\n%s", args, conn.Host, port, conn.DBName, err, out)
}
return out
}
// ResolveInGuest returns the addresses host resolves to inside the guest.
//
// This is the only resolution that proves anything about the guest's DNS path
// The runner's own resolver may point straight at northstar, so a
// host-side lookup can pass on a cluster where no guest can resolve anything. An
// empty result means the name does not resolve, which is the assertion after a
// delete withdraws the record.
func ResolveInGuest(t *testing.T, tgt SSHTarget, host string) []string {
t.Helper()
// getent exits non-zero on NXDOMAIN, so the lookup's own failure is
// swallowed in the guest: an error back here is an SSH fault, not an answer.
out, err := GuestExec(tgt, "getent hosts "+ShellQuote(host)+" || true")
if err != nil {
t.Fatalf("ResolveInGuest %s: %v\n%s", host, err, out)
}
var addrs []string
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
fields := strings.Fields(line)
if len(fields) > 0 && net.ParseIP(fields[0]) != nil {
addrs = append(addrs, fields[0])
}
}
return addrs
}