check / check (push) Waiting to run
Ctrl-C or SIGTERM during snapshot create, restore or verify exited 0 with no error line (1, also silent, under snapshot verify --json), so an unfinished --cron backup looked like a success. RunOperation now records whether op returned while the Vaultik context was still live; a run where it had not by the time RunWithApp returned is interrupted, whatever op returned. It used to look for context.Canceled in op's error, which verify --json does not return. Entry prints "interrupted before the command finished" on stderr for it and returns 130, under --cron and --json too. SIGTERM also gives 130, as the issue asks, not 143. The test does not cover an op still running when the 30s shutdown timeout ends. Model: opus-5-5
204 lines
5.5 KiB
Go
204 lines
5.5 KiB
Go
package cli //nolint:testpackage // shares runEntry and the argument constants
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/adrg/xdg"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// stalledStoreConfig is hermeticConfig with an s3:// destination store
|
|
// in place of the file:// one. The server behind it accepts any
|
|
// credentials.
|
|
const stalledStoreConfig = `age_recipients:
|
|
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
|
snapshots:
|
|
test:
|
|
paths:
|
|
- %s
|
|
storage_url: s3://bucket?endpoint=%s&ssl=false
|
|
s3:
|
|
access_key_id: key
|
|
secret_access_key: secret
|
|
index_path: %s
|
|
hostname: test-host
|
|
`
|
|
|
|
// interruptRepeat is how often interruptOnFirstRequest sends SIGINT.
|
|
const interruptRepeat = 50 * time.Millisecond
|
|
|
|
// TestEntryInterruptedRun sends SIGINT to the test process while a
|
|
// command waits on the destination store, and checks that Entry returns
|
|
// 130 and prints one line on stderr saying the run was interrupted. The
|
|
// store is a local HTTP server that holds every request open, so the
|
|
// command is always mid-operation when the signal arrives. The two
|
|
// cases cover --cron and --json, which silence other output.
|
|
//
|
|
// Not parallel: it signals the process and replaces os.Args, os.Stdout,
|
|
// os.Stderr and the xdg globals.
|
|
//
|
|
//nolint:paralleltest // signals the process and replaces process globals
|
|
func TestEntryInterruptedRun(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{
|
|
name: "snapshot create --cron",
|
|
args: []string{cmdSnapshot, cmdCreate, "--cron"},
|
|
},
|
|
{
|
|
name: "snapshot verify --json",
|
|
args: []string{cmdSnapshot, cmdVerify, someSnapshotID, flagJSON},
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
endpoint, requestArrived := startStalledStore(t)
|
|
configPath := writeStalledStoreConfig(t, endpoint)
|
|
interruptOnFirstRequest(t, requestArrived)
|
|
|
|
code, _, stderr := runEntry(t,
|
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
|
|
|
assert.Equal(t, 130, code)
|
|
assert.Equal(t, 1,
|
|
strings.Count(stderr, errInterrupted.Error()), stderr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// interruptOnFirstRequest sends SIGINT to the test process every
|
|
// interruptRepeat, from the first request to the destination store until
|
|
// the test ends. One signal is not enough: the command can reach the
|
|
// store before fx has started catching signals. The test catches SIGINT
|
|
// too, so that a signal fx is not catching does not kill the test
|
|
// binary.
|
|
func interruptOnFirstRequest(t *testing.T, requestArrived <-chan struct{}) {
|
|
t.Helper()
|
|
|
|
self, err := os.FindProcess(os.Getpid())
|
|
require.NoError(t, err)
|
|
|
|
caught := make(chan os.Signal, 1)
|
|
signal.Notify(caught, os.Interrupt)
|
|
|
|
testEnded := make(chan struct{})
|
|
senderDone := make(chan struct{})
|
|
|
|
// Stop catching SIGINT only after the sender has returned. The sender
|
|
// waits for each SIGINT it sends to arrive on caught; one still on
|
|
// its way after signal.Stop would kill the test binary.
|
|
t.Cleanup(func() {
|
|
close(testEnded)
|
|
<-senderDone
|
|
signal.Stop(caught)
|
|
})
|
|
|
|
go func() {
|
|
defer close(senderDone)
|
|
|
|
select {
|
|
case <-requestArrived:
|
|
case <-testEnded:
|
|
return
|
|
}
|
|
|
|
ticker := time.NewTicker(interruptRepeat)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
// Empty caught, so that the receive below waits for this
|
|
// SIGINT rather than an earlier one.
|
|
select {
|
|
case <-caught:
|
|
default:
|
|
}
|
|
|
|
sendErr := self.Signal(os.Interrupt)
|
|
if sendErr != nil {
|
|
t.Errorf("sending SIGINT: %v", sendErr)
|
|
|
|
return
|
|
}
|
|
|
|
<-caught
|
|
|
|
select {
|
|
case <-testEnded:
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// startStalledStore starts an HTTP server that never answers: each
|
|
// request is held until the client gives up on it or the test ends.
|
|
// It returns the server's host:port and a channel that receives a value
|
|
// when the first request arrives.
|
|
func startStalledStore(t *testing.T) (string, <-chan struct{}) {
|
|
t.Helper()
|
|
|
|
requestArrived := make(chan struct{}, 1)
|
|
release := make(chan struct{})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case requestArrived <- struct{}{}:
|
|
default:
|
|
}
|
|
|
|
select {
|
|
case <-r.Context().Done():
|
|
case <-release:
|
|
}
|
|
}))
|
|
|
|
// Cleanups run last-registered first, so release lets any held
|
|
// request return before Close waits for it.
|
|
t.Cleanup(server.Close)
|
|
t.Cleanup(func() { close(release) })
|
|
|
|
return server.Listener.Addr().String(), requestArrived
|
|
}
|
|
|
|
// writeStalledStoreConfig writes a config whose destination store is the
|
|
// server at endpoint and whose snapshot source holds one small file, so
|
|
// that `snapshot create` has a blob to upload. Returns the config path.
|
|
func writeStalledStoreConfig(t *testing.T, endpoint string) string {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "config.yml")
|
|
sourceDir := filepath.Join(dir, "source")
|
|
|
|
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
|
require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "file.txt"),
|
|
[]byte("contents"), 0o600))
|
|
|
|
contents := fmt.Sprintf(stalledStoreConfig,
|
|
sourceDir, endpoint, filepath.Join(dir, "index.sqlite"))
|
|
|
|
require.NoError(t,
|
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
|
|
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
|
// package init; point it at the temp dir so the test neither
|
|
// touches nor collides with the real one.
|
|
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
|
xdg.Reload()
|
|
t.Cleanup(xdg.Reload)
|
|
|
|
return configPath
|
|
}
|