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 counts an op as interrupted when the Vaultik context was cancelled before it returned, rather than when its error wraps context.Canceled, which verify --json does not. 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 reach the branch for 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
|
|
}
|