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.
This commit is contained in:
Ben Duncan
2026-09-14 15:39:27 -05:00
committed by GitHub
parent 455c97a2e7
commit ea3e01bb20
19 changed files with 1247 additions and 57 deletions
+1 -1
View File
@@ -130,7 +130,7 @@
"runner": "ci-single",
"extra": "rds",
"suite": "rds",
"timeout_minutes": 100
"timeout_minutes": 120
},
{
"cell": 29,
+3 -2
View File
@@ -77,8 +77,9 @@ e2e_suite_timeout() {
# what the queue costs, not what the tests do: single tests logged 11m, 22m
# and 26m waiting for budget. 17 tests at that concurrency is over an hour
# before anything goes wrong, and the first pass to run deep into the suite
# still had three left at 60m.
rds) echo "90m" ;;
# still had three left at 60m. The loaded graceful-reboot case adds an
# instance that holds its slot through a pgbench load as well as a create.
rds) echo "105m" ;;
# diskperf runs two 16 GiB fio profiles per repetition, each on a volume it
# creates first. One pass is ~20 minutes on the bare-metal cell and a
# baseline capture at SPINIFEX_DISKPERF_REPS=3 is three times that.
+5 -4
View File
@@ -798,13 +798,14 @@ func TestHandleEC2Events_RebootRunningInstance(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, reply)
// vm.Reboot returns a generic "QMP system_reset" error when the
// QMPClient encoder/decoder is nil, which handleRebootInstance maps
// to ServerInternal via its default branch.
// The daemon admits the reboot and runs it in the background, so the reply
// carries no error even though this guest's QMP client cannot be reset. A
// reply that waited for the guest would arrive after the caller gave up.
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, errResp["Code"])
assert.NotContains(t, errResp, "Code", "an admitted reboot must not answer with an error")
t.Cleanup(daemon.vmMgr.WaitForBackgroundWork)
// Instance should remain in running state (reboot doesn't change state)
var status vm.InstanceState
@@ -15,6 +15,12 @@ import (
"github.com/nats-io/nats.go"
)
// rebootInstancesTimeout is the NATS request timeout, overridable in tests so
// the ErrTimeout path can be exercised without a real 5-second wait. The daemon
// admits a reboot and runs it in the background, so this bounds the admission
// and never the guest's shutdown.
var rebootInstancesTimeout = 5 * time.Second
func ValidateRebootInstancesInput(input *ec2.RebootInstancesInput) error {
if input == nil {
return errors.New(awserrors.ErrorInvalidParameterValue)
@@ -59,10 +65,18 @@ func RebootInstances(ctx context.Context, input *ec2.RebootInstancesInput, natsC
reqMsg.Data = jsonData
reqMsg.Header.Set(utils.AccountIDHeader, accountID)
utils.InjectTraceContext(ctx, reqMsg.Header)
msg, err := natsConn.RequestMsg(reqMsg, 5*time.Second)
msg, err := natsConn.RequestMsg(reqMsg, rebootInstancesTimeout)
if err != nil {
slog.ErrorContext(ctx, "RebootInstances: Failed to send command", "instance_id", instanceID, "err", err)
// Only the absence of a subscriber says anything about where the
// instance is. A timeout is a daemon that is slow or wedged, and
// calling that NotFound sends the caller after an entirely
// different fault than the one it hit.
if !errors.Is(err, nats.ErrNoResponders) {
return nil, errors.New(awserrors.ErrorServerInternal)
}
// No daemon subscription: check stopped-KV to return IncorrectInstanceState instead of NotFound.
describeInput := &ec2.DescribeInstancesInput{
InstanceIds: []*string{&instanceID},
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -90,6 +91,31 @@ func TestRebootInstances_NATSRequestFails(t *testing.T) {
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error())
}
// TestRebootInstances_SlowDaemonIsNotAMissingInstance covers a daemon that
// holds the request open. Reporting that as NotFound names a fault the caller
// does not have and hides the one it does: the instance is there, the node
// answering for it is not answering.
func TestRebootInstances_SlowDaemonIsNotAMissingInstance(t *testing.T) {
_, nc := startTestNATSServer(t)
previous := rebootInstancesTimeout
rebootInstancesTimeout = 50 * time.Millisecond
t.Cleanup(func() { rebootInstancesTimeout = previous })
instanceID := "i-slow"
// A live subscriber that never replies forces a real ErrTimeout rather
// than the immediate ErrNoResponders a missing subscriber would give.
_, err := nc.Subscribe("ec2.cmd."+instanceID, func(*nats.Msg) {})
require.NoError(t, err)
input := &ec2.RebootInstancesInput{
InstanceIds: []*string{aws.String(instanceID)},
}
_, err = RebootInstances(context.Background(), input, nc, "123456789012")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestRebootInstances_StoppedInstance(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
@@ -3854,15 +3854,33 @@ func TestRebootInstance_NotFound(t *testing.T) {
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error())
}
func TestRebootInstance_QMPFailureReturnsInternalError(t *testing.T) {
// TestRebootInstance_QMPFailureIsNotTheCallersToHear covers the async contract
// EC2 documents: the answer reports admission, so a reset that fails afterwards
// cannot be returned and must not hold the reply up either. The guest is left
// running and the heartbeat is what notices it never came back.
func TestRebootInstance_QMPFailureIsNotTheCallersToHear(t *testing.T) {
id := "i-qmp-failure"
instance := &vm.VM{ID: id, Status: vm.StateRunning}
mgr := mgrWith(map[string]*vm.VM{id: instance})
t.Cleanup(mgr.WaitForBackgroundWork)
svc := &InstanceServiceImpl{vmMgr: mgr}
require.NoError(t, svc.RebootInstance(context.Background(), instance, spxtypes.EC2InstanceCommand{ID: id}))
assert.Equal(t, vm.StateRunning, mgr.Status(instance))
}
// TestRebootInstance_NotRunning asserts the second of the two failures that can
// still be reported, so a reboot of a stopped guest is refused rather than
// accepted and quietly dropped.
func TestRebootInstance_NotRunning(t *testing.T) {
id := "i-stopped"
instance := &vm.VM{ID: id, Status: vm.StateStopped}
mgr := mgrWith(map[string]*vm.VM{id: instance})
svc := &InstanceServiceImpl{vmMgr: mgr}
err := svc.RebootInstance(context.Background(), instance, spxtypes.EC2InstanceCommand{ID: id})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
assert.Equal(t, awserrors.ErrorIncorrectInstanceState, err.Error())
}
// TestStartInstance_NotFound verifies that a missing instance returns
+1
View File
@@ -16,6 +16,7 @@ var CommandResponseTypes = map[string]unmarshalTarget{
"system_powerdown": &json.RawMessage{},
"system_reset": &json.RawMessage{},
"system_wakeup": &json.RawMessage{},
"quit": &json.RawMessage{},
"query-block": &[]BlockDevice{},
+77 -7
View File
@@ -18,6 +18,7 @@ import (
"time"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/otelsetup"
"github.com/mulgadc/spinifex/spinifex/qmp"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -102,8 +103,15 @@ const (
rebootStatusPollInterval = 100 * time.Millisecond
)
// Reboot resets QEMU and verifies its vCPUs resume. The VM remains in
// StateRunning while firmware re-runs.
// rebootPowerdownTimeout is how long the guest gets to shut itself down before
// it is reset under. Nova's shutdown_timeout, and the same shape as the hard
// reboot AWS documents once a guest will not go quietly.
var rebootPowerdownTimeout = 60 * time.Second
// Reboot admits a reboot and runs it in the background, the way EC2 does: the
// answer says the request was accepted, not that the guest is back. Asking a
// guest to shut down takes as long as the guest takes, which is far longer than
// any caller waits, so only the two admission failures can be reported at all.
func (m *Manager) Reboot(ctx context.Context, id string) error {
instance, ok := m.Get(id)
if !ok {
@@ -114,18 +122,61 @@ func (m *Manager) Reboot(ctx context.Context, id string) error {
ErrInvalidTransition, id, status)
}
// Tell the heartbeat this guest is meant to be stopped for a moment: it
// quits a guest it finds in the shutdown run state, which is the state a
// reboot passes through. Claiming the flag also serialises reboots, so a
// repeated request joins the one in flight instead of racing it.
if !instance.rebooting.CompareAndSwap(false, true) {
slog.InfoContext(ctx, "Reboot already in flight, ignoring the repeat", "instanceId", id)
return nil
}
// Detached rather than derived: the caller is answered immediately, so its
// context is cancelled long before the guest has finished shutting down.
rebootCtx := context.WithoutCancel(ctx)
m.goroutineWg.Go(func() {
defer instance.rebooting.Store(false)
if err := m.rebootNow(rebootCtx, instance); err != nil {
// Nobody is left to return this to. The guest is either back or it
// is not, and the heartbeat is what notices the second case.
slog.ErrorContext(rebootCtx, "Reboot failed", "instanceId", id, "err", err)
}
})
return nil
}
// rebootNow asks the guest to shut itself down, then resets and resumes it in
// the same QEMU process. The VM remains in StateRunning throughout. A guest
// that will not power down inside its budget is hard-reset, and that is logged.
func (m *Manager) rebootNow(ctx context.Context, instance *VM) error {
// A reset alone never reaches the guest kernel, so it syncs nothing and
// every dirty page is lost. Ask first; reset only once asking has failed.
// A guest whose QEMU would exit rather than pause is reset without asking,
// because a powerdown there leaves nothing to reset and the guest down.
switch {
case !qemuPausesOnShutdown(instance):
slog.InfoContext(ctx, "Guest predates the paused-shutdown flag, hard-resetting",
"instanceId", instance.ID)
default:
if err := m.gracefulPowerdown(ctx, instance, rebootPowerdownTimeout); err != nil {
slog.WarnContext(ctx, "Guest did not power down for reboot, hard-resetting",
"instanceId", instance.ID, "budget_ms", otelsetup.Millis(rebootPowerdownTimeout), "err", err)
}
}
rebootCtx, cancel := context.WithTimeout(ctx, rebootRunningTimeout)
defer cancel()
if _, err := sendQMPCommandWithTimeout(rebootCtx, instance.QMPClient,
qmp.QMPCommand{Execute: "system_reset"}, id, contextTimeRemaining(rebootCtx)); err != nil {
qmp.QMPCommand{Execute: "system_reset"}, instance.ID, contextTimeRemaining(rebootCtx)); err != nil {
return fmt.Errorf("QMP system_reset: %w", err)
}
return m.waitForQMPRunning(rebootCtx, instance, rebootStatusPollInterval)
}
// waitForQMPRunning polls until QEMU reports running or ctx expires. Paused
// and prelaunch guests receive cont so a reset cannot leave their vCPUs parked.
// waitForQMPRunning polls until QEMU reports running or ctx expires. Paused,
// prelaunch and shutdown guests receive cont so a reset cannot leave their
// vCPUs parked — a reset from a powered-down guest lands in one of the three.
func (m *Manager) waitForQMPRunning(ctx context.Context, instance *VM, pollInterval time.Duration) error {
var lastStatus qmp.Status
for {
@@ -146,7 +197,8 @@ func (m *Manager) waitForQMPRunning(ctx context.Context, instance *VM, pollInter
return nil
}
if qmpStatus.Status == "paused" || qmpStatus.Status == "prelaunch" {
if qmpStatus.Status == "paused" || qmpStatus.Status == "prelaunch" ||
qmpStatus.Status == qmpStatusShutdown {
if status := m.Status(instance); status != StateRunning {
return fmt.Errorf("%w: cannot resume instance %s in state %s",
ErrInvalidTransition, instance.ID, status)
@@ -944,7 +996,25 @@ func (m *Manager) qmpHeartbeatPoll(instance *VM) bool {
return true
}
if err == nil && !qmpStatus.Running && m.Status(instance) == StateRunning {
// A guest that powered itself off leaves QEMU paused rather than gone,
// because that window is what a reboot needs. Nobody is rebooting this one,
// so close it: quit lets the process exit and recovery proceed as before.
if err == nil && qmpStatus.Status == qmpStatusShutdown &&
m.Status(instance) == StateRunning && !instance.rebooting.Load() {
slog.InfoContext(ctx, "Guest powered itself off, ending QEMU", "instance", instance.ID)
if _, quitErr := sendQMPCommand(ctx, instance.QMPClient,
qmp.QMPCommand{Execute: "quit"}, instance.ID); quitErr != nil {
slog.WarnContext(ctx, "QMP quit failed after guest shutdown",
"instance", instance.ID, "err", quitErr)
}
return true
}
// A reboot in flight is meant to be stopped for a moment, so counting it as
// unresponsive would mark a healthy guest impaired for doing what it was
// asked. Reboot bounds its own window; this only declines to judge it.
if err == nil && !qmpStatus.Running && m.Status(instance) == StateRunning &&
!instance.rebooting.Load() {
err = fmt.Errorf("QMP reported non-running status %q", qmpStatus.Status)
}
if err != nil {
+103 -3
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"log/slog"
"os"
"slices"
"strings"
"sync"
"time"
@@ -19,6 +21,98 @@ import (
// disappear after system_powerdown before resorting to SIGKILL.
const pidFileRemovalTimeout = 20 * time.Second
const (
// powerdownResendInterval re-presses the virtual power button. A guest
// whose acpid has not started yet never sees the first one, so a single
// signal is not a shutdown request — it is a bet on the guest being ready.
powerdownResendInterval = 10 * time.Second
// powerdownPollInterval is how often the guest's run state is sampled
// while it shuts down.
powerdownPollInterval = 500 * time.Millisecond
// qmpStatusShutdown is the run state QEMU reports once a guest has powered
// itself off under -action shutdown=pause: stopped, but not yet gone.
qmpStatusShutdown = "shutdown"
)
// errPowerdownTimedOut reports a guest still running at the end of its budget.
// Callers escalate: Stop to SIGKILL, Reboot to a hard reset.
var errPowerdownTimedOut = errors.New("guest did not power down within its budget")
// qemuPausesOnShutdown reports whether this guest's QEMU was launched with the
// shutdown action that leaves it paused. A guest started before that flag
// exits on powerdown instead, and a reboot would have nothing left to reset.
// Indirected so a test can describe a guest without one running behind it.
var qemuPausesOnShutdown = func(instance *VM) bool {
pid, err := utils.ReadPidFile(instance.ID)
if err != nil || pid <= 0 {
return false
}
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
return false
}
return cmdlineHasPauseAction(cmdline)
}
// cmdlineHasPauseAction looks for the shutdown action in a NUL-separated
// /proc cmdline. Split out from the /proc read so the matching is testable.
func cmdlineHasPauseAction(cmdline []byte) bool {
return slices.Contains(strings.Split(string(cmdline), "\x00"), "shutdown=pause")
}
// gracefulPowerdown presses the guest's power button until it shuts itself
// down, so it syncs and unmounts rather than losing its dirty pages. QEMU is
// left paused in the shutdown run state, not exited — the caller decides.
func (m *Manager) gracefulPowerdown(ctx context.Context, instance *VM, budget time.Duration) error {
return m.powerdownWithTuning(ctx, instance, budget, powerdownResendInterval, powerdownPollInterval)
}
// powerdownWithTuning is gracefulPowerdown with its cadence supplied, so a test
// can drive the re-send and the budget without waiting out the real intervals.
func (m *Manager) powerdownWithTuning(ctx context.Context, instance *VM,
budget, resendInterval, pollInterval time.Duration) error {
if instance.QMPClient == nil {
return errors.New("no QMP client")
}
deadline := time.Now().Add(budget)
var nextSend time.Time
for {
if !time.Now().Before(nextSend) {
if _, err := sendQMPCommand(ctx, instance.QMPClient,
qmp.QMPCommand{Execute: "system_powerdown"}, instance.ID); err != nil {
// A guest already on its way down rejects the second press, so
// this is only worth a debug line until the budget decides.
slog.Debug("QMP system_powerdown failed", "id", instance.ID, "err", err)
}
nextSend = time.Now().Add(resendInterval)
}
status, err := queryQMPStatus(ctx, instance, qmpCommandTimeout)
if err == nil && status.Status == qmpStatusShutdown {
return nil
}
// The process going away is the other clean outcome: a guest can exit
// before the poll sees the run state, and on the fence path QEMU may
// have been reaped underneath us.
if !isInstanceProcessRunning(instance) {
return nil
}
if !time.Now().Before(deadline) {
return fmt.Errorf("%w: %s (last run state %q)",
errPowerdownTimedOut, budget, status.Status)
}
timer := time.NewTimer(pollInterval)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
// Stop transitions a running instance to stopped: graceful QMP shutdown, volume
// unmount, tap teardown, resource deallocation. Migrates to the "stopped" KV
// bucket and fires OnInstanceDown. Returns ErrInstanceNotFound,
@@ -517,9 +611,15 @@ func (m *Manager) shutdownAndUnmount(instance *VM) error {
// unmount would seal this node's stale copy over theirs.
func (m *Manager) shutdownQEMU(instance *VM) {
if instance.QMPClient != nil {
if _, err := sendQMPCommand(context.Background(), instance.QMPClient, qmp.QMPCommand{Execute: "system_powerdown"}, instance.ID); err != nil {
slog.Warn("QMP system_powerdown failed (VM may already be stopped)",
"id", instance.ID, "err", err)
ctx := context.Background()
// The whole graceful budget is the pid-file wait it replaces, so a stop
// takes no longer than it used to. quit closes the paused-on-shutdown
// window a reboot needs and a stop does not.
if err := m.gracefulPowerdown(ctx, instance, pidFileRemovalTimeout); err != nil {
slog.Warn("Guest did not power down gracefully", "id", instance.ID, "err", err)
} else if _, err := sendQMPCommand(ctx, instance.QMPClient,
qmp.QMPCommand{Execute: "quit"}, instance.ID); err != nil {
slog.Warn("QMP quit failed (VM may already be stopped)", "id", instance.ID, "err", err)
}
}
+67 -6
View File
@@ -1366,10 +1366,10 @@ func TestShutdownAndUnmount_NilQMPClient_ProceedsToUnmount(t *testing.T) {
}
// TestShutdownAndUnmount_PowerdownSent_NoForceKill exercises the happy path:
// QMPClient is wired, system_powerdown is dispatched, the PID-file wait
// returns immediately (no PID file present), and the unmount step runs.
// The force-kill branch must not run because WaitForPidFileRemoval returned
// nil.
// QMPClient is wired, the guest is asked to power down and the paused QEMU is
// then quit, the PID-file wait returns immediately (no PID file present), and
// the unmount step runs. The force-kill branch must not run because
// WaitForPidFileRemoval returned nil.
func TestShutdownAndUnmount_PowerdownSent_NoForceKill(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
recorder := &qmpRecorder{}
@@ -1386,8 +1386,8 @@ func TestShutdownAndUnmount_PowerdownSent_NoForceKill(t *testing.T) {
require.NoError(t, m.shutdownAndUnmount(instance))
assert.Equal(t, []string{"system_powerdown"}, recorder.executes(),
"shutdownAndUnmount must dispatch exactly one system_powerdown QMP command")
assert.Equal(t, []string{"system_powerdown", "query-status", "quit"}, recorder.executes(),
"shutdownAndUnmount must ask the guest to power down, then end the paused QEMU")
mounter.mu.Lock()
defer mounter.mu.Unlock()
@@ -1578,3 +1578,64 @@ func TestTerminate_SealFailure_Tolerated(t *testing.T) {
assert.Equal(t, []string{"i-term-seal"}, cleaner.deleteVolumes,
"terminate must still delete the volumes after a failed seal")
}
// TestGracefulPowerdown_ResendReachesALateGuest is the boot-race defect stated
// directly: a guest whose acpid has not started yet ignores the first power
// button. One signal would burn the whole budget and escalate to a kill on a
// guest that was only busy booting, so the button is pressed again.
func TestGracefulPowerdown_ResendReachesALateGuest(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
require.NoError(t, utils.WritePidFile("i-late", os.Getpid()))
t.Cleanup(func() { _ = utils.RemovePidFile("i-late") })
var presses int
recorder := &qmpRecorder{}
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
if cmd.Execute == "system_powerdown" {
presses++
return nil
}
if cmd.Execute != "query-status" {
return nil
}
// The guest only acts on the third press.
if presses < 3 {
return qmpStatusResponse("running", true)
}
return qmpStatusResponse(qmpStatusShutdown, false)
})
defer cancel()
m := NewManager()
instance := &VM{ID: "i-late", Status: StateRunning, QMPClient: qmpClient}
err := m.powerdownWithTuning(t.Context(), instance, 5*time.Second, 5*time.Millisecond, time.Millisecond)
require.NoError(t, err, "a guest that honours a later press must still shut down gracefully")
assert.GreaterOrEqual(t, presses, 3, "the power button must be pressed again while the guest ignores it")
}
// TestGracefulPowerdown_WedgedGuestTimesOut covers the other half: a guest that
// never answers must not be waited on forever. The error is what Stop escalates
// to SIGKILL on and Reboot escalates to a hard reset on.
func TestGracefulPowerdown_WedgedGuestTimesOut(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
require.NoError(t, utils.WritePidFile("i-wedged", os.Getpid()))
t.Cleanup(func() { _ = utils.RemovePidFile("i-wedged") })
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
if cmd.Execute == "query-status" {
return qmpStatusResponse("running", true)
}
return nil
})
defer cancel()
m := NewManager()
instance := &VM{ID: "i-wedged", Status: StateRunning, QMPClient: qmpClient}
err := m.powerdownWithTuning(t.Context(), instance, 30*time.Millisecond, 5*time.Millisecond, time.Millisecond)
require.Error(t, err)
assert.ErrorIs(t, err, errPowerdownTimedOut)
assert.Contains(t, err.Error(), `last run state "running"`)
}
+11
View File
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/aws/aws-sdk-go/aws"
@@ -90,6 +91,11 @@ type VM struct {
// Manager hands out a stable *VM, so the lock is shared across calls.
attachMu sync.Mutex `json:"-"`
// rebooting marks the window in which this guest is expected to power
// itself off and be reset again. The heartbeat quits a guest it finds
// stopped in the shutdown run state, and a reboot passes through it.
rebooting atomic.Bool `json:"-"`
// DesiredState is the state this instance has been asked to be in, as
// opposed to Status, which is the state it is observed to be in.
DesiredState DesiredState `json:"desired_state,omitempty"`
@@ -396,6 +402,11 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-name", fmt.Sprintf("guest=%s,debug-threads=on", cfg.Name))
}
// A guest that powers itself off pauses QEMU instead of ending it. That
// window is what a graceful reboot needs — process, taps and drives all
// survive the guest's clean shutdown. Every other path closes it with quit.
args = append(args, "-action", "shutdown=pause")
if cfg.PIDFile != "" {
args = append(args, "-pidfile", cfg.PIDFile)
}
+1
View File
@@ -58,6 +58,7 @@ func TestExecute(t *testing.T) {
expectedArgs := []string{
"-name", "guest=test-vm,debug-threads=on",
"-action", "shutdown=pause",
"-smp", "2",
"-m", "1024",
"-drive", "file=disk.img,format=qcow2",
+278 -18
View File
@@ -602,6 +602,16 @@ func TestReboot_InstanceNotFound(t *testing.T) {
assert.ErrorIs(t, err, ErrInstanceNotFound)
}
// pausingGuest makes qemuPausesOnShutdown describe this test's guest as one
// whose QEMU pauses on shutdown, which is what every guest launched by the
// current code does. Restores the real /proc check on cleanup.
func pausingGuest(t *testing.T) {
t.Helper()
previous := qemuPausesOnShutdown
qemuPausesOnShutdown = func(*VM) bool { return true }
t.Cleanup(func() { qemuPausesOnShutdown = previous })
}
func TestReboot_NotRunning(t *testing.T) {
m := NewManager()
m.Insert(&VM{ID: "i-1", Status: StateStopped})
@@ -614,9 +624,11 @@ func TestReboot_NotRunning(t *testing.T) {
// TestReboot_DoesNotFireHooks asserts that Manager.Reboot fires neither
// OnInstanceUp nor OnInstanceDown. The hook contract requires
// hooks to fire only on Pending→Running and terminal transitions; reboot keeps
// the VM in StateRunning across system_reset, so firing OnInstanceDown +
// OnInstanceUp would tear down per-instance NATS subs on every reboot.
// the VM in StateRunning across the powerdown and reset, so firing
// OnInstanceDown + OnInstanceUp would tear down per-instance NATS subs on
// every reboot.
func TestReboot_DoesNotFireHooks(t *testing.T) {
pausingGuest(t)
recorder := &qmpRecorder{}
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
@@ -636,17 +648,20 @@ func TestReboot_DoesNotFireHooks(t *testing.T) {
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
require.NoError(t, m.Reboot(t.Context(), "i-1"))
m.WaitForBackgroundWork()
assert.Equal(t, 0, upCalls, "Reboot must not fire OnInstanceUp")
assert.Equal(t, 0, downCalls, "Reboot must not fire OnInstanceDown")
assert.Equal(t, []string{"system_reset", "query-status"}, recorder.executes(),
"Reboot must verify QEMU resumed after system_reset")
assert.Equal(t, []string{"system_powerdown", "query-status", "system_reset", "query-status"},
recorder.executes(),
"Reboot must ask the guest down first, then verify QEMU resumed after system_reset")
}
// TestReboot_DoesNotChangeStatus asserts the VM stays in StateRunning across
// reboot. Pairs with TestReboot_DoesNotFireHooks to lock down the hook
// contract: status doesn't transition, so hooks shouldn't fire.
func TestReboot_DoesNotChangeStatus(t *testing.T) {
pausingGuest(t)
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
if cmd.Execute == "query-status" {
return qmpStatusResponse("running", true)
@@ -659,6 +674,7 @@ func TestReboot_DoesNotChangeStatus(t *testing.T) {
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
require.NoError(t, m.Reboot(t.Context(), "i-1"))
m.WaitForBackgroundWork()
v, ok := m.Get("i-1")
require.True(t, ok)
@@ -666,6 +682,7 @@ func TestReboot_DoesNotChangeStatus(t *testing.T) {
}
func TestReboot_QMPFailureSurfacesError(t *testing.T) {
pausingGuest(t)
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
return map[string]any{
"error": map[string]any{
@@ -679,23 +696,37 @@ func TestReboot_QMPFailureSurfacesError(t *testing.T) {
m := NewManager()
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
err := m.Reboot(t.Context(), "i-1")
instance, ok := m.Get("i-1")
require.True(t, ok)
err := m.rebootNow(t.Context(), instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "QMP system_reset")
}
// TestReboot_ResumesPausedRunstates drives the whole reboot: the guest reaches
// the shutdown runstate on its own, is reset, and lands parked in a runstate
// that needs cont before its vCPUs run again.
func TestReboot_ResumesPausedRunstates(t *testing.T) {
pausingGuest(t)
for _, runstate := range []string{"paused", "prelaunch"} {
t.Run(runstate, func(t *testing.T) {
recorder := &qmpRecorder{}
statusQueries := 0
reset := false
postResetQueries := 0
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
if cmd.Execute == "system_reset" {
reset = true
return nil
}
if cmd.Execute != "query-status" {
return nil
}
statusQueries++
if statusQueries == 1 {
if !reset {
return qmpStatusResponse(qmpStatusShutdown, false)
}
postResetQueries++
if postResetQueries == 1 {
return qmpStatusResponse(runstate, false)
}
return qmpStatusResponse("running", true)
@@ -706,12 +737,17 @@ func TestReboot_ResumesPausedRunstates(t *testing.T) {
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
require.NoError(t, m.Reboot(t.Context(), "i-1"))
assert.Equal(t, []string{"system_reset", "query-status", "cont", "query-status"}, recorder.executes())
m.WaitForBackgroundWork()
assert.Equal(t, []string{
"system_powerdown", "query-status",
"system_reset", "query-status", "cont", "query-status",
}, recorder.executes())
})
}
}
func TestReboot_NonRunningTimesOut(t *testing.T) {
pausingGuest(t)
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
if cmd.Execute == "query-status" {
return qmpStatusResponse("shutdown", false)
@@ -725,13 +761,16 @@ func TestReboot_NonRunningTimesOut(t *testing.T) {
ctx, cancelContext := context.WithTimeout(t.Context(), 25*time.Millisecond)
defer cancelContext()
err := m.Reboot(ctx, "i-1")
instance, ok := m.Get("i-1")
require.True(t, ok)
err := m.rebootNow(ctx, instance)
require.Error(t, err)
assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.Contains(t, err.Error(), `last status "shutdown"`)
}
func TestReboot_MalformedStatusSurfacesError(t *testing.T) {
pausingGuest(t)
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
if cmd.Execute == "query-status" {
return map[string]any{"return": map[string]any{}}
@@ -743,12 +782,15 @@ func TestReboot_MalformedStatusSurfacesError(t *testing.T) {
m := NewManager()
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
err := m.Reboot(t.Context(), "i-1")
instance, ok := m.Get("i-1")
require.True(t, ok)
err := m.rebootNow(t.Context(), instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "missing status")
}
func TestReboot_ContFailureSurfacesError(t *testing.T) {
pausingGuest(t)
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
if cmd.Execute == "query-status" {
return qmpStatusResponse("paused", false)
@@ -765,31 +807,48 @@ func TestReboot_ContFailureSurfacesError(t *testing.T) {
m := NewManager()
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
err := m.Reboot(t.Context(), "i-1")
instance, ok := m.Get("i-1")
require.True(t, ok)
err := m.rebootNow(t.Context(), instance)
require.Error(t, err)
assert.Contains(t, err.Error(), `QMP cont from status "paused"`)
}
func TestReboot_StateChangePreventsCont(t *testing.T) {
pausingGuest(t)
var m *Manager
recorder := &qmpRecorder{}
reset := false
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
if cmd.Execute == "query-status" {
m.UpdateState("i-1", func(v *VM) { v.Status = StateStopping })
return qmpStatusResponse("paused", false)
if cmd.Execute == "system_reset" {
reset = true
return nil
}
return nil
if cmd.Execute != "query-status" {
return nil
}
if !reset {
return qmpStatusResponse(qmpStatusShutdown, false)
}
// A concurrent stop lands between the reset and the resume: cont must
// not run against an instance that is no longer meant to be running.
m.UpdateState("i-1", func(v *VM) { v.Status = StateStopping })
return qmpStatusResponse("paused", false)
})
defer cancel()
m = NewManager()
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
err := m.Reboot(t.Context(), "i-1")
instance, ok := m.Get("i-1")
require.True(t, ok)
err := m.rebootNow(t.Context(), instance)
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidTransition)
assert.Equal(t, []string{"system_reset", "query-status"}, recorder.executes())
assert.Equal(t, []string{
"system_powerdown", "query-status", "system_reset", "query-status",
}, recorder.executes())
}
func TestQMPHeartbeatPoll_UsesRunstate(t *testing.T) {
@@ -1569,3 +1628,204 @@ func TestDetachVolume_BlockdevDelAlreadyRemoved_IdempotentSuccess(t *testing.T)
assert.Equal(t, []string{"vol-1"}, mounter.unmountedOne,
"an already-removed block node must resume through to the unmount seal")
}
// TestReboot_WedgedGuestFallsBackToHardReset asserts the AWS contract: a guest
// that will not shut down cleanly is reset anyway rather than left running, and
// the instance stays in StateRunning throughout.
func TestReboot_WedgedGuestFallsBackToHardReset(t *testing.T) {
pausingGuest(t)
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
require.NoError(t, utils.WritePidFile("i-1", os.Getpid()))
t.Cleanup(func() { _ = utils.RemovePidFile("i-1") })
previous := rebootPowerdownTimeout
rebootPowerdownTimeout = 30 * time.Millisecond
t.Cleanup(func() { rebootPowerdownTimeout = previous })
recorder := &qmpRecorder{}
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
// The guest ignores the power button and never leaves the running run
// state, which is also how it reads coming back from the hard reset.
if cmd.Execute == "query-status" {
return qmpStatusResponse("running", true)
}
return nil
})
defer cancel()
m := NewManager()
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
require.NoError(t, m.Reboot(t.Context(), "i-1"))
m.WaitForBackgroundWork()
assert.Contains(t, recorder.executes(), "system_reset",
"a guest that will not power down must still be reset")
v, ok := m.Get("i-1")
require.True(t, ok)
assert.Equal(t, StateRunning, m.Status(v))
}
// TestQMPHeartbeatPoll_QuitsGuestThatPoweredItselfOff covers the cost of the
// -action shutdown=pause flag: a guest that runs `poweroff` now leaves QEMU
// paused rather than gone, and something has to end it.
func TestQMPHeartbeatPoll_QuitsGuestThatPoweredItselfOff(t *testing.T) {
recorder := &qmpRecorder{}
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
if cmd.Execute == "query-status" {
return qmpStatusResponse(qmpStatusShutdown, false)
}
return nil
})
defer cancel()
m := NewManager()
instance := &VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient}
m.Insert(instance)
assert.True(t, m.qmpHeartbeatPoll(instance))
assert.Equal(t, []string{"query-status", "quit"}, recorder.executes())
}
// TestQMPHeartbeatPoll_LeavesARebootingGuestAlone is the other side of it. A
// reboot passes through the same run state on purpose, so quitting on sight
// would destroy the guest the reboot was about to bring back.
func TestQMPHeartbeatPoll_LeavesARebootingGuestAlone(t *testing.T) {
recorder := &qmpRecorder{}
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
if cmd.Execute == "query-status" {
return qmpStatusResponse(qmpStatusShutdown, false)
}
return nil
})
defer cancel()
m := NewManager()
instance := &VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient}
instance.rebooting.Store(true)
m.Insert(instance)
assert.True(t, m.qmpHeartbeatPoll(instance))
assert.Equal(t, []string{"query-status"}, recorder.executes(),
"a rebooting guest must not be quit, and must not be counted unresponsive")
assert.Zero(t, instance.Health.QMPConsecutiveFailures,
"a reboot in flight is not a failed heartbeat")
}
// TestReboot_PreFlagGuestSkipsPowerdown covers the upgrade window. A guest
// launched before -action shutdown=pause exits on powerdown rather than
// pausing, so asking it to shut down would leave nothing to reset and the
// guest down. Those are reset without asking, exactly as they were before.
func TestReboot_PreFlagGuestSkipsPowerdown(t *testing.T) {
previous := qemuPausesOnShutdown
qemuPausesOnShutdown = func(*VM) bool { return false }
t.Cleanup(func() { qemuPausesOnShutdown = previous })
recorder := &qmpRecorder{}
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
if cmd.Execute == "query-status" {
return qmpStatusResponse("running", true)
}
return nil
})
defer cancel()
m := NewManager()
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
require.NoError(t, m.Reboot(t.Context(), "i-1"))
m.WaitForBackgroundWork()
assert.Equal(t, []string{"system_reset", "query-status"}, recorder.executes(),
"a guest that would exit on powerdown must not be asked to power down")
}
// TestReboot_AnswersBeforeTheGuestIsBack is the contract EC2 documents: the
// reply says the request was accepted. A guest takes tens of seconds to shut
// down and the caller's request times out long before that, so a Reboot that
// waited would report a reboot that had in fact succeeded as a failure.
func TestReboot_AnswersBeforeTheGuestIsBack(t *testing.T) {
pausingGuest(t)
release := make(chan struct{})
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
if cmd.Execute == "query-status" {
// The guest holds the power button down until the test lets go,
// standing in for the seconds a real one spends shutting down.
<-release
return qmpStatusResponse("running", true)
}
return nil
})
defer cancel()
m := NewManager()
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
done := make(chan error, 1)
go func() { done <- m.Reboot(t.Context(), "i-1") }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(5 * time.Second):
close(release)
t.Fatal("Reboot blocked on the guest shutting down; callers time out well inside that")
}
close(release)
m.WaitForBackgroundWork()
}
// TestReboot_RepeatJoinsTheOneInFlight asserts two reboots of one guest do not
// race each other through the powerdown and reset.
func TestReboot_RepeatJoinsTheOneInFlight(t *testing.T) {
pausingGuest(t)
release := make(chan struct{})
recorder := &qmpRecorder{}
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
recorder.record(cmd)
if cmd.Execute == "query-status" {
<-release
return qmpStatusResponse("running", true)
}
return nil
})
defer cancel()
m := NewManager()
m.Insert(&VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient})
require.NoError(t, m.Reboot(t.Context(), "i-1"))
require.NoError(t, m.Reboot(t.Context(), "i-1"))
close(release)
m.WaitForBackgroundWork()
var resets int
for _, execute := range recorder.executes() {
if execute == "system_reset" {
resets++
}
}
assert.Equal(t, 1, resets, "the repeat must join the reboot in flight, not start a second")
}
func TestCmdlineHasPauseAction(t *testing.T) {
for name, tc := range map[string]struct {
cmdline string
want bool
}{
"present": {"qemu-system-x86_64\x00-action\x00shutdown=pause\x00-smp\x002", true},
"absent": {"qemu-system-x86_64\x00-smp\x002", false},
"other action": {"qemu-system-x86_64\x00-action\x00reboot=shutdown", false},
// A substring match would accept this and reboot would then hang
// waiting for a pause that never comes.
"substring only": {"qemu-system-x86_64\x00-name\x00guest=shutdown=paused", false},
"empty": {"", false},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, cmdlineHasPauseAction([]byte(tc.cmdline)))
})
}
}
+32
View File
@@ -60,6 +60,9 @@ 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)
@@ -310,6 +313,35 @@ func psqlCommand(conn PSQLConn, sql string) string {
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
+126
View File
@@ -0,0 +1,126 @@
//go:build e2e
package rds
import (
"fmt"
"strconv"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/tests/e2e/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// The scale factor to build the database at. pgbench costs roughly 16 MiB
// per unit, so this puts the data comfortably past the 256 MiB of shared
// buffers a floor instance gets — the pool is then full of pages the engine
// has to write out on the way down, rather than a dataset that fits.
gracefulRebootScale = 25
// How long the read-write run drives the instance for, to leave the buffer
// pool dirty at the moment the reboot arrives.
gracefulRebootDirtyRun = "45"
// The probe table and how many rows go in it.
gracefulRebootTable = "e2e_graceful_reboot_probe"
gracefulRebootRows = 1000
)
// TestLoadedGracefulReboot reboots a loaded PostgreSQL instance and asserts the
// engine was asked to shut down rather than reset under.
//
// The probe is an UNLOGGED table, and the choice is the whole test. PostgreSQL
// writes no WAL for one, so its contents are durable exactly when the engine
// shuts down cleanly: a clean shutdown checkpoints them, and crash recovery
// truncates every unlogged relation unconditionally. The row count afterwards is
// therefore a direct read of which of the two happened.
//
// The obvious assertions do not work. Rows committed normally survive a hard
// reset too — that is what the WAL is for — so a test that writes, resets and
// finds its data would pass against a reset that lost the guest's entire page
// cache. `synchronous_commit = off` does expose the gap, but only for the few
// hundred milliseconds before the WAL writer flushes, so whether it catches a
// hard reset depends on how fast the reset arrives. An unlogged table has no
// such window.
func TestLoadedGracefulReboot(t *testing.T) {
f := requireRDSFixture(t)
t.Parallel()
reserveDBVMs(t, dbClass)
id := fmt.Sprintf("%s-gracereboot-%d", dbInstancePfx, time.Now().Unix())
harness.Phase(t, "Creating DB instance %q", id)
createDBInstance(t, f, id)
client := rdsClient(t, f)
instance := waitForAvailable(t, f, id)
conn := harness.PSQLConnFor(t, instance, dbMasterUser, dbMasterPassword, dbName)
harness.Phase(t, "Loading %q past its shared buffers", id)
sharedBuffers := queryBytes(t, client, conn,
"SELECT setting::bigint * 8192 FROM pg_settings WHERE name = 'shared_buffers';")
harness.Detail(t, "shared_buffers_bytes", sharedBuffers)
harness.Step(t, "pgbench -i -s %d", gracefulRebootScale)
harness.PGBench(t, client, conn, "-i", "-q", "-s", strconv.Itoa(gracefulRebootScale))
// Assert the load is testing something. A scale factor that quietly built a
// database smaller than the buffer pool leaves nothing to write out on the
// way down, and the test's name stops matching what it does.
dbBytes := queryBytes(t, client, conn, "SELECT pg_database_size(current_database());")
harness.Detail(t, "database_bytes", dbBytes)
require.Greaterf(t, dbBytes, sharedBuffers,
"the load built %d bytes against %d bytes of shared buffers, so the pool was never under pressure "+
"and this run does not exercise a loaded shutdown", dbBytes, sharedBuffers)
harness.Step(t, "pgbench -T %s to dirty the buffer pool", gracefulRebootDirtyRun)
harness.PGBench(t, client, conn, "-T", gracefulRebootDirtyRun, "-c", "4", "-j", "2")
harness.Phase(t, "Writing the unlogged probe")
harness.PSQL(t, client, conn, fmt.Sprintf(
"CREATE UNLOGGED TABLE %s (id int primary key, at timestamptz default now()); "+
"INSERT INTO %s (id) SELECT generate_series(1, %d);",
gracefulRebootTable, gracefulRebootTable, gracefulRebootRows))
require.Equalf(t, gracefulRebootRows, countRows(t, client, conn, gracefulRebootTable),
"the probe table did not hold its rows before the reboot, so nothing after this proves anything")
before := postmasterStartTime(t, client, conn)
harness.Phase(t, "Rebooting %q while its buffer pool is dirty", id)
_, err := f.AWS.RDS.RebootDBInstance(&rds.RebootDBInstanceInput{DBInstanceIdentifier: aws.String(id)})
require.NoError(t, err, "reboot-db-instance")
waitForAvailable(t, f, id)
// Without this the row count proves nothing: an engine that never restarted
// would of course still hold them.
after := postmasterStartTime(t, client, conn)
require.NotEqual(t, before, after,
"pg_postmaster_start_time() did not move, so the engine never restarted and the probe is meaningless")
assert.Equalf(t, gracefulRebootRows, countRows(t, client, conn, gracefulRebootTable),
"the unlogged probe table lost its rows across the reboot, which is what PostgreSQL does to one "+
"on crash recovery — the engine was reset rather than asked to shut down")
}
// queryBytes runs a single-value query and returns it as an integer.
func queryBytes(t *testing.T, tgt harness.SSHTarget, conn harness.PSQLConn, sql string) int64 {
t.Helper()
out := strings.TrimSpace(harness.PSQL(t, tgt, conn, sql))
n, err := strconv.ParseInt(out, 10, 64)
require.NoErrorf(t, err, "parse %q as an integer", out)
return n
}
// countRows returns how many rows table holds. An unlogged table truncated by
// crash recovery still exists, so this reads zero rather than failing.
func countRows(t *testing.T, tgt harness.SSHTarget, conn harness.PSQLConn, table string) int {
t.Helper()
return int(queryBytes(t, tgt, conn, "SELECT count(*) FROM "+table+";"))
}
+364
View File
@@ -0,0 +1,364 @@
//go:build e2e
package reboot
import (
"context"
"encoding/base64"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/tests/e2e/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
guestShutdownKeyName = "guest-shutdown-e2e-key"
guestShutdownSGName = "guest-shutdown-e2e-sg"
guestShutdownVPCCIDR = "10.211.0.0/16"
guestShutdownSubnetCIDR = "10.211.1.0/24"
// The slow guest's shutdown hook writes one fsynced line a second for this
// long. Comfortably inside the budget the daemon gives a guest, and far
// enough past a normal shutdown that a reset rather than a wait is
// unmistakable in the line count that survives.
slowShutdownSeconds = 25
// What the file must hold afterwards. Short of the full run so a slow guest
// under CI load is not failed for losing the last second or two, but far
// enough above zero that a hard reset cannot reach it.
slowShutdownMinLines = 20
// The budget the daemon gives a guest to power down for a reboot. Not
// imported: this is the e2e suite's own statement of the contract, and a
// change to the constant should fail here rather than pass silently.
guestPowerdownBudget = 60 * time.Second
// How long a guest gets to answer on a new boot ID. The wedged variant
// spends its whole budget before the reset and only then boots.
guestRebootBudget = 5 * time.Minute
// The line the daemon logs when it gives up asking and resets the guest.
hardResetLogLine = "Guest did not power down for reboot"
)
// slowShutdownUserData installs a guest that takes its time going down and
// leaves a record of how long it was given. Each line is fsynced, so the file
// that survives is a direct measure of how much of the shutdown actually ran:
// a reset at the first poll leaves none of it.
//
// Ordinary service dependencies on purpose — the unit is stopped early in the
// shutdown transaction, while the root filesystem is still writable.
var slowShutdownUserData = fmt.Sprintf(`#!/bin/bash
set -e
cat > /usr/local/bin/slow-shutdown <<'EOS'
#!/bin/sh
i=0
while [ $i -lt %[1]d ]; do
i=$((i+1))
date +%%s >> %[2]s
sync %[2]s
sleep 1
done
EOS
chmod +x /usr/local/bin/slow-shutdown
cat > /etc/systemd/system/slow-shutdown.service <<'UNIT'
[Unit]
Description=Guest shutdown probe
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=/usr/local/bin/slow-shutdown
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
UNIT
rm -f %[2]s
systemctl daemon-reload
systemctl enable --now slow-shutdown.service
touch /run/guest-shutdown-ready
`, slowShutdownSeconds, slowShutdownProbeFile)
const slowShutdownProbeFile = "/var/lib/slow-shutdown.log"
// wedgedUserData installs a guest that never acknowledges the power button.
// systemd-logind is what turns an ACPI power-button event into a shutdown on
// these images; acpid is disabled too so the answer does not depend on which
// of the two the image happens to ship.
const wedgedUserData = `#!/bin/bash
set -e
mkdir -p /etc/systemd/logind.conf.d
cat > /etc/systemd/logind.conf.d/99-ignore-power.conf <<'EOF'
[Login]
HandlePowerKey=ignore
HandlePowerKeyLongPress=ignore
EOF
systemctl disable --now acpid 2>/dev/null || true
systemctl restart systemd-logind
touch /run/guest-shutdown-ready
`
// TestGuestShutdownBudget proves the two paths a guest reboot can take, against
// real guests rather than a mocked QMP socket.
//
// A reset alone never reaches the guest kernel, so it syncs nothing. The
// primitive therefore asks the guest to shut down and only resets it once
// asking has failed — and the two cases that separates are a guest that is slow
// and must be waited for, and one that will never answer and must be reset
// anyway. Each is asserted on the evidence only it can produce.
func TestGuestShutdownBudget(t *testing.T) {
env := harness.LoadEnv(t)
requireRunnerMode(t, env)
fix := &fixture{
env: env,
artifacts: harness.ArtifactDir(t, env),
ssh: harness.NewPeerSSH(),
preIPs: map[string]string{},
timeouts: timeouts{
instanceRunning: durationEnv("INSTANCE_RUNNING_SECS", 120*time.Second),
},
}
resolveTrust(t, fix)
fix.aws = harness.NewAWSClient(t, env)
t.Cleanup(func() { cleanup(t, fix) })
harness.Phase(t, "Prerequisites")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
require.NoError(t, fix.ssh.Ping(ctx, fix.env.WANHost), "ssh to %s", fix.env.WANHost)
cancel()
fix.instanceType = discoverNanoInstanceType(t, fix.aws)
fix.amiID = discoverAMI(t, fix.aws)
harness.Detail(t, "instance_type", fix.instanceType)
harness.Detail(t, "ami", fix.amiID)
provisionKeyPair(t, fix, guestShutdownKeyName)
harness.Phase(t, "VPC + subnet + security group")
provisionNetwork(t, fix, netNames{
sg: guestShutdownSGName,
sgDesc: "Guest shutdown budget E2E",
vpcCIDR: guestShutdownVPCCIDR,
subnetCIDR: guestShutdownSubnetCIDR,
})
t.Run("SlowGuestIsWaitedForAndKeepsWhatItWrote", func(t *testing.T) {
id, pub := launchProbeGuest(t, fix, slowShutdownUserData, "slow")
before := time.Now()
bootID := rebootAndWaitForNewBootID(t, fix, id, pub)
elapsed := time.Since(before)
harness.Detail(t, "reboot_elapsed_secs", int(elapsed.Seconds()))
harness.Detail(t, "post_reboot_boot_id", bootID)
// The file is the assertion. Every line was fsynced during shutdown, so
// its length is how much of that shutdown the guest was allowed to run.
lines := countProbeLines(t, fix, pub)
harness.Detail(t, "shutdown_probe_lines", lines)
assert.GreaterOrEqualf(t, lines, slowShutdownMinLines,
"the guest wrote %d fsynced lines during shutdown, wanted at least %d — "+
"a guest reset instead of asked never runs its shutdown at all",
lines, slowShutdownMinLines)
// Assert the test tested something: a shutdown that finished in a
// second has not exercised a slow one, whatever the line count says.
assert.GreaterOrEqualf(t, elapsed, time.Duration(slowShutdownMinLines)*time.Second,
"the whole reboot took %s, which is less than the shutdown hook alone should have taken — "+
"the slow guest was not slow and this run proves nothing", elapsed)
// The other half of the contract: a guest that went down on its own
// must not also have been reported as one that would not.
journal := daemonJournalFor(t, fix, id)
assert.NotContainsf(t, journal, hardResetLogLine,
"the daemon logged the hard-reset fallback for %s, but this guest shut down inside its budget", id)
})
t.Run("WedgedGuestIsResetOnceItsBudgetExpires", func(t *testing.T) {
id, pub := launchProbeGuest(t, fix, wedgedUserData, "wedged")
before := time.Now()
bootID := rebootAndWaitForNewBootID(t, fix, id, pub)
elapsed := time.Since(before)
harness.Detail(t, "reboot_elapsed_secs", int(elapsed.Seconds()))
harness.Detail(t, "post_reboot_boot_id", bootID)
// A guest that will not go quietly is still rebooted — that is the AWS
// contract, and it is what stops one wedged guest becoming an outage.
// Reaching a new boot ID above is that assertion.
assert.GreaterOrEqualf(t, elapsed, guestPowerdownBudget,
"the reboot took %s, less than the %s budget, so the guest was reset without being asked first — "+
"the whole point is that asking comes first", elapsed, guestPowerdownBudget)
journal := daemonJournalFor(t, fix, id)
assert.Containsf(t, journal, hardResetLogLine,
"the daemon reset %s without logging why; a guest that never honours ACPI has to be visible, "+
"not silently hard-reset forever", id)
})
}
// launchProbeGuest boots one guest on the given user-data and waits until its
// setup has reported itself done, so a test never measures a shutdown against a
// guest that had not finished being configured.
func launchProbeGuest(t *testing.T, fix *fixture, userData, label string) (id, pub string) {
t.Helper()
harness.Step(t, "launching the %s guest", label)
// e2e:allow-create — the guest is the subject: each variant carries its own
// shutdown behaviour in user-data, so a memoised fixture guest is the wrong
// shape and sharing one between the variants would erase the difference.
out, err := fix.aws.EC2.RunInstances(&ec2.RunInstancesInput{ //nolint:staticcheck // e2e:allow-create
ImageId: aws.String(fix.amiID),
InstanceType: aws.String(fix.instanceType),
KeyName: aws.String(fix.keyPairName),
SubnetId: aws.String(fix.subnetID),
SecurityGroupIds: []*string{aws.String(fix.sgID)},
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(userData))),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
})
require.NoErrorf(t, err, "run-instances %s", label)
require.NotEmpty(t, out.Instances)
id = aws.StringValue(out.Instances[0].InstanceId)
fix.appInstanceIDs = append(fix.appInstanceIDs, id)
harness.Detail(t, label+"_instance", id)
harness.WaitForInstanceRunning(t, fix.aws, id, fix.timeouts.instanceRunning)
pub = describePublicIP(t, fix.aws, id)
require.NotEmptyf(t, pub, "%s has no PublicIpAddress", id)
// The sentinel is written at the end of user-data, so waiting for it waits
// for the shutdown hook to be installed rather than for SSH alone.
harness.Step(t, "waiting for the %s guest's setup to finish", label)
deadline := time.Now().Add(5 * time.Minute)
for {
out, err := runInGuest(fix, pub, "test -e /run/guest-shutdown-ready && echo ready")
if err == nil && strings.Contains(out, "ready") {
return id, pub
}
if time.Now().After(deadline) {
t.Fatalf("%s guest %s never finished its user-data setup (last output %q, err: %v)",
label, id, strings.TrimSpace(out), err)
}
time.Sleep(5 * time.Second)
}
}
// rebootAndWaitForNewBootID reboots the guest and blocks until it answers with a
// boot ID other than the one it held, which is the only positive proof that the
// guest actually rebooted rather than never having gone down.
func rebootAndWaitForNewBootID(t *testing.T, fix *fixture, id, pub string) string {
t.Helper()
previous := readBootID(t, fix, pub)
require.NotEmptyf(t, previous, "%s returned an empty boot ID before the reboot", id)
harness.Detail(t, "pre_reboot_boot_id", previous)
harness.Step(t, "reboot-instances %s", id)
_, err := fix.aws.EC2.RebootInstances(&ec2.RebootInstancesInput{
InstanceIds: []*string{aws.String(id)},
})
require.NoErrorf(t, err, "reboot-instances %s", id)
deadline := time.Now().Add(guestRebootBudget)
var lastID string
var lastErr error
for {
out, err := runInGuest(fix, pub, "cat /proc/sys/kernel/random/boot_id")
lastErr = err
if err == nil {
lastID = lastNonEmptyLine(out)
if lastID != "" && lastID != previous {
return lastID
}
}
if time.Now().After(deadline) {
// "Never came back" and "came back on the same boot" are different
// faults, and one message for both would name neither.
if lastID == previous {
t.Fatalf("%s still holds boot ID %s after %s, so the reboot never reached the guest kernel",
id, previous, guestRebootBudget)
}
t.Fatalf("%s never answered SSH with a boot ID within %s (last error: %v)",
id, guestRebootBudget, lastErr)
}
time.Sleep(3 * time.Second)
}
}
func readBootID(t *testing.T, fix *fixture, pub string) string {
t.Helper()
out, err := runInGuest(fix, pub, "cat /proc/sys/kernel/random/boot_id")
require.NoError(t, err, "read boot ID from the guest")
return lastNonEmptyLine(out)
}
// countProbeLines reads how many fsynced lines the shutdown hook managed to
// write. A missing file means none of it ran, which is the same finding as an
// empty one and is reported as zero rather than as an error.
func countProbeLines(t *testing.T, fix *fixture, pub string) int {
t.Helper()
out, err := runInGuest(fix, pub, "wc -l < "+slowShutdownProbeFile+" 2>/dev/null || echo 0")
require.NoError(t, err, "read the shutdown probe file")
n, err := strconv.Atoi(lastNonEmptyLine(out))
require.NoErrorf(t, err, "parse the shutdown probe line count from %q", out)
return n
}
// daemonJournalFor returns the daemon's log lines mentioning one instance. The
// suite runs on the CI runner, so this hops through the node.
func daemonJournalFor(t *testing.T, fix *fixture, id string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := "sudo journalctl -u spinifex-daemon --since '-15 min' --no-pager 2>/dev/null | grep -F " + id + " || true"
out, err := fix.ssh.Run(ctx, fix.env.WANHost, cmd)
require.NoErrorf(t, err, "read the daemon journal for %s", id)
harness.DumpFile(t, fix.artifacts, fmt.Sprintf("journal-%s.txt", id), out)
return string(out)
}
// runInGuest runs one command inside a guest, hopping through the node: the
// runner has no route to a guest's address and the node does.
func runInGuest(fix *fixture, pub, command string) (string, error) {
keyData, err := os.ReadFile(fix.privateKeyPath)
if err != nil {
return "", fmt.Errorf("read guest key: %w", err)
}
remoteKey := "/tmp/guest-shutdown-e2e.pem"
outer := fmt.Sprintf(`set -e
umask 077 && printf '%%s' '%s' | base64 -d > %s && chmod 600 %s
ssh -i %s -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-o ConnectTimeout=8 -o BatchMode=yes ubuntu@%s bash -s <<'EOFCMD'
%s
EOFCMD
`, base64.StdEncoding.EncodeToString(keyData), remoteKey, remoteKey, remoteKey, pub, command)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
out, err := fix.ssh.Run(ctx, fix.env.WANHost, outer)
return string(out), err
}
// lastNonEmptyLine takes the final line with content, so an SSH banner or a
// warning ahead of the answer does not become the answer.
func lastNonEmptyLine(s string) string {
lines := strings.Split(s, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if trimmed := strings.TrimSpace(lines[i]); trimmed != "" {
return trimmed
}
}
return ""
}
+46 -12
View File
@@ -94,6 +94,7 @@ type fixture struct {
appInstanceIDs []string
preIPs map[string]string
keyPairName string // the pair this fixture created, and the one cleanup removes
privateKeyPath string // local PEM written from CreateKeyPair KeyMaterial
tgArn string
@@ -263,21 +264,52 @@ func phase0Prereqs(t *testing.T, fix *fixture) {
fix.amiID = discoverAMI(t, fix.aws)
harness.Detail(t, "ami", fix.amiID)
// Capture KeyMaterial so post-reboot diagnostics can SSH into guest VMs.
_, _ = fix.aws.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(keyName)})
kpOut, err := fix.aws.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(keyName)})
require.NoErrorf(t, err, "create-key-pair %s", keyName)
provisionKeyPair(t, fix, keyName)
}
// provisionKeyPair creates a key pair and keeps its material, so a test can SSH
// into the guests it launches. Recreated rather than reused: the material is
// returned once, at creation, and a pair left behind by an earlier run cannot
// be read back.
func provisionKeyPair(t *testing.T, fix *fixture, name string) {
t.Helper()
_, _ = fix.aws.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(name)})
kpOut, err := fix.aws.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(name)})
require.NoErrorf(t, err, "create-key-pair %s", name)
material := aws.StringValue(kpOut.KeyMaterial)
require.NotEmptyf(t, material, "create-key-pair %s returned no KeyMaterial", keyName)
fix.privateKeyPath = filepath.Join(fix.artifacts, keyName+".pem")
require.NotEmptyf(t, material, "create-key-pair %s returned no KeyMaterial", name)
fix.keyPairName = name
fix.privateKeyPath = filepath.Join(fix.artifacts, name+".pem")
require.NoError(t, os.WriteFile(fix.privateKeyPath, []byte(material), 0o600), "write private key")
harness.Detail(t, "key_pair", keyName)
harness.Detail(t, "key_pair", name)
}
func phase1Network(t *testing.T, fix *fixture) {
t.Helper()
provisionNetwork(t, fix, netNames{
sg: sgName,
sgDesc: "Reboot E2E shared SG (ALB + app instances)",
vpcCIDR: vpcCIDR,
subnetCIDR: subnetCIDR,
})
}
vpcOut, err := fix.aws.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String(vpcCIDR)})
// netNames is the set of names one test's network is built under. Each test in
// this package brings its own, so tearing one down cannot take the other's VPC.
type netNames struct {
sg string
sgDesc string
vpcCIDR string
subnetCIDR string
}
// provisionNetwork builds a VPC, a public subnet and a security group admitting
// tcp/80 and tcp/22 from anywhere — 22 so the test and its diagnostics can SSH
// into the guests it launches.
func provisionNetwork(t *testing.T, fix *fixture, names netNames) {
t.Helper()
vpcOut, err := fix.aws.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String(names.vpcCIDR)})
require.NoError(t, err, "create-vpc")
fix.vpcID = aws.StringValue(vpcOut.Vpc.VpcId)
harness.Detail(t, "vpc", fix.vpcID)
@@ -294,7 +326,7 @@ func phase1Network(t *testing.T, fix *fixture) {
subnetOut, err := fix.aws.EC2.CreateSubnet(&ec2.CreateSubnetInput{
VpcId: aws.String(fix.vpcID),
CidrBlock: aws.String(subnetCIDR),
CidrBlock: aws.String(names.subnetCIDR),
})
require.NoError(t, err, "create-subnet")
fix.subnetID = aws.StringValue(subnetOut.Subnet.SubnetId)
@@ -305,8 +337,8 @@ func phase1Network(t *testing.T, fix *fixture) {
harness.Detail(t, "subnet", fix.subnetID)
sgOut, err := fix.aws.EC2.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
GroupName: aws.String(sgName),
Description: aws.String("Reboot E2E shared SG (ALB + app instances)"),
GroupName: aws.String(names.sg),
Description: aws.String(names.sgDesc),
VpcId: aws.String(fix.vpcID),
})
require.NoError(t, err, "create-security-group")
@@ -1406,7 +1438,9 @@ func cleanup(t *testing.T, fix *fixture) {
harness.WaitForInstanceTerminated(t, fix.aws, fix.appInstanceIDs, 60*time.Second)
}
_, _ = fix.aws.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(keyName)})
if fix.keyPairName != "" {
_, _ = fix.aws.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(fix.keyPairName)})
}
// SG must come after instances + ALB ENIs are gone.
if fix.sgID != "" {
+50
View File
@@ -531,6 +531,14 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat
host, port := harness.InstancePublicSSHHost(t, inst)
waitForSSHReady(t, host, port, keyPath)
// The boot ID is what proves a reboot happened. Uptime cannot on its
// own: a reboot is asynchronous, so the guest answering shortly after
// the call may still be the one that has not shut down yet.
preBootID := strings.TrimSpace(runSSH(t,
harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath},
"cat /proc/sys/kernel/random/boot_id"))
require.NotEmpty(t, preBootID, "guest returned an empty boot ID before the reboot")
// Capture pre-reboot private IP for the post-reboot identity check.
preDesc, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
InstanceIds: []*string{aws.String(instanceID)},
@@ -573,6 +581,10 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat
waitForSSHReady(t, host, port, keyPath)
tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath}
waitForNewBootID(t, tgt, preBootID)
// Now that the guest is known to be on a new boot, uptime is a second
// reading of the same fact rather than a guess at it.
harness.Step(t, "ssh uptime")
uptimeOut := strings.TrimSpace(runSSH(t, tgt, "cat /proc/uptime | cut -d. -f1"))
uptimeSecs, err := strconv.ParseInt(uptimeOut, 10, 64)
@@ -591,6 +603,44 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat
verifySentinel(t, "Reboot")
}
// rebootBootIDTimeout covers a guest asked to shut down cleanly, the reset it
// gets if it will not, and the boot after either.
const rebootBootIDTimeout = 3 * time.Minute
// waitForNewBootID blocks until the guest answers SSH holding a boot ID other
// than the one it had. "Reachable" and "rebooted" are different facts while a
// reboot is in flight, and only the second one is worth asserting on.
func waitForNewBootID(t *testing.T, tgt harness.SSHTarget, previous string) string {
t.Helper()
harness.Step(t, "waiting for the guest to come back on a new boot ID")
deadline := time.Now().Add(rebootBootIDTimeout)
var lastID string
var lastErr error
for {
out, err := runSSHQuiet(tgt, "cat /proc/sys/kernel/random/boot_id")
lastErr = err
if err == nil {
lastID = strings.TrimSpace(out)
if lastID != "" && lastID != previous {
harness.Detail(t, "post_reboot_boot_id", lastID)
return lastID
}
}
if time.Now().After(deadline) {
// "Never answered" and "answered holding the old boot ID" are
// different faults — a guest that did not come back versus a reset
// that never reached it — and one message for both names neither.
if lastID == previous {
t.Fatalf("guest still holds boot ID %s after %s, so it never rebooted",
previous, rebootBootIDTimeout)
}
t.Fatalf("guest never answered SSH with a boot ID within %s (last error: %v)",
rebootBootIDTimeout, lastErr)
}
time.Sleep(2 * time.Second)
}
}
// describeSingletonInstance returns the current *ec2.Instance for
// instanceID. Any perturbation of the singleton can change its public hostfwd
// endpoint (type change, reboot, stop/start), so callers re-describe rather
+21 -1
View File
@@ -54,19 +54,39 @@ func runEarlyRebootLiveness(t *testing.T, fix *Fixture) {
}
deadline := time.Now().Add(earlyRebootRecoveryTimeout)
// "SSH never answered" and "SSH answered with the boot ID it had before"
// are different faults — a guest that never came back versus a reset that
// never reached it — and one message for both names neither.
var (
reachedOnce bool
lastBootID string
lastSSHErr error
)
for {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
out, sshErr := harness.RunGuestSSH(ctx, tgt, "cat /proc/sys/kernel/random/boot_id")
cancel()
lastSSHErr = sshErr
if sshErr == nil {
reachedOnce = true
newBootID := strings.TrimSpace(string(out))
lastBootID = newBootID
if newBootID != "" && newBootID != bootID {
harness.Detail(t, "reboot_outcome", "resumed", "boot_id", newBootID)
return
}
}
if time.Now().After(deadline) {
t.Fatalf("reboot API succeeded but guest boot ID did not change within %s", earlyRebootRecoveryTimeout)
harness.DumpInstanceConsole(t, fix.AWS, instanceID,
fix.ArtifactDir(t), "early-reboot-console.log")
if !reachedOnce {
t.Fatalf("reboot API succeeded but the guest never answered SSH again within %s "+
"(boot ID before the reboot %s, last SSH error: %v)",
earlyRebootRecoveryTimeout, bootID, lastSSHErr)
}
t.Fatalf("reboot API succeeded but the guest answered SSH still holding boot ID %s after %s "+
"(so the reset never reached the guest kernel; last read %q)",
bootID, earlyRebootRecoveryTimeout, lastBootID)
}
time.Sleep(2 * time.Second)
}