cdf88c6dc3
P2R-13a. restic.Env gains LimitUploadKBps/LimitDownloadKBps which are
emitted as global --limit-upload/--limit-download flags before the
subcommand on every invocation. Agent dispatcher tracks host-wide
caps received via config.update; server pushes them on hello and
after PUT /api/hosts/{id}/bandwidth.
Also extends api.CommandRunPayload with optional per-job overrides
(BandwidthUpKBps/Down + PreHook/PostHook); the override consumers
land in T2/T6.
231 lines
7.0 KiB
Go
231 lines
7.0 KiB
Go
package restic
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// setupScriptBin writes a small shell script to a temp directory,
|
|
// makes it executable, and returns its path. scriptBody is the
|
|
// complete script content (without the shebang line — that's added
|
|
// automatically).
|
|
// Writes to "<path>.tmp" then renames into place — see the matching
|
|
// helper in internal/agent/runner/runner_test.go for the ETXTBSY
|
|
// race rationale. Same fix applied here so this helper doesn't lose
|
|
// the race the next time CI gets unlucky.
|
|
func setupScriptBin(t *testing.T, scriptBody string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
final := filepath.Join(dir, "restic")
|
|
tmp := final + ".tmp"
|
|
content := "#!/bin/sh\n" + scriptBody + "\n"
|
|
if err := os.WriteFile(tmp, []byte(content), 0o755); err != nil {
|
|
t.Fatalf("setupScriptBin: write tmp: %v", err)
|
|
}
|
|
if err := os.Rename(tmp, final); err != nil {
|
|
t.Fatalf("setupScriptBin: rename: %v", err)
|
|
}
|
|
return final
|
|
}
|
|
|
|
// captureLines returns a LineHandler that appends "stream:line" into
|
|
// the returned slice pointer (safe for single-goroutine test use).
|
|
func captureLines() (*[]string, LineHandler) {
|
|
var lines []string
|
|
h := func(stream, line string, _ any) {
|
|
lines = append(lines, fmt.Sprintf("%s:%s", stream, line))
|
|
}
|
|
return &lines, h
|
|
}
|
|
|
|
// --- B1: RunPrune + B2: RunCheck ---
|
|
|
|
func TestRunPruneInvokesPrune(t *testing.T) {
|
|
// Shell script that echoes its args; "prune" should appear in output.
|
|
bin := setupScriptBin(t, `echo "$@"`)
|
|
env := Env{Bin: bin}
|
|
lines, h := captureLines()
|
|
if err := env.RunPrune(context.Background(), h); err != nil {
|
|
t.Fatalf("RunPrune returned error: %v", err)
|
|
}
|
|
for _, l := range *lines {
|
|
if strings.Contains(l, "prune") {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("expected 'prune' in captured output; got: %v", *lines)
|
|
}
|
|
|
|
// --- B2: RunCheck ---
|
|
|
|
func TestRunCheckLockSniff(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
stderrLine string
|
|
wantLocked bool
|
|
}{
|
|
{"stale lock", "Found stale lock from PID 1234", true},
|
|
{"already locked", "repository is already locked exclusively", true},
|
|
{"benign mention", "subdir/locked-file ok", false},
|
|
{"empty", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
// Script emits the line on stderr, then exits 0.
|
|
script := fmt.Sprintf(`printf '%%s\n' %q >&2`, c.stderrLine)
|
|
bin := setupScriptBin(t, script)
|
|
env := Env{Bin: bin}
|
|
res, err := env.RunCheck(context.Background(), 0, nil)
|
|
if err != nil {
|
|
t.Fatalf("RunCheck returned unexpected error: %v", err)
|
|
}
|
|
if res.LockPresent != c.wantLocked {
|
|
t.Fatalf("LockPresent: got %v, want %v (line: %q)", res.LockPresent, c.wantLocked, c.stderrLine)
|
|
}
|
|
if res.ErrorsFound {
|
|
t.Fatal("expected ErrorsFound=false")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunCheckErrorsFoundOnExit1(t *testing.T) {
|
|
bin := setupScriptBin(t, `exit 1`)
|
|
env := Env{Bin: bin}
|
|
res, err := env.RunCheck(context.Background(), 0, nil)
|
|
if err != nil {
|
|
t.Fatalf("RunCheck returned unexpected error (should have absorbed exit 1): %v", err)
|
|
}
|
|
if !res.ErrorsFound {
|
|
t.Fatal("expected ErrorsFound=true for exit 1")
|
|
}
|
|
}
|
|
|
|
func TestRunCheckSubsetArg(t *testing.T) {
|
|
bin := setupScriptBin(t, `echo "$@"`)
|
|
env := Env{Bin: bin}
|
|
lines, h := captureLines()
|
|
if _, err := env.RunCheck(context.Background(), 25, h); err != nil {
|
|
t.Fatalf("RunCheck: %v", err)
|
|
}
|
|
want := "--read-data-subset 25%"
|
|
for _, l := range *lines {
|
|
if strings.Contains(l, want) {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("expected %q in captured output; got: %v", want, *lines)
|
|
}
|
|
|
|
// --- B3: RunUnlock + RunStats ---
|
|
|
|
func TestRunUnlockInvokesUnlock(t *testing.T) {
|
|
bin := setupScriptBin(t, `echo "$@"`)
|
|
env := Env{Bin: bin}
|
|
lines, h := captureLines()
|
|
if err := env.RunUnlock(context.Background(), h); err != nil {
|
|
t.Fatalf("RunUnlock: %v", err)
|
|
}
|
|
for _, l := range *lines {
|
|
if strings.Contains(l, "unlock") {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("expected 'unlock' in captured output; got: %v", *lines)
|
|
}
|
|
|
|
func TestRunStatsParsesJSON(t *testing.T) {
|
|
bin := setupScriptBin(t, `echo '{"total_size":1234,"total_uncompressed_size":5678,"snapshots_count":3,"total_file_count":100,"total_blob_count":50}'`)
|
|
env := Env{Bin: bin}
|
|
stats, err := env.RunStats(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("RunStats: %v", err)
|
|
}
|
|
if stats.TotalSize != 1234 {
|
|
t.Fatalf("TotalSize: got %d, want 1234", stats.TotalSize)
|
|
}
|
|
if stats.TotalUncompressed != 5678 {
|
|
t.Fatalf("TotalUncompressed: got %d, want 5678", stats.TotalUncompressed)
|
|
}
|
|
if stats.SnapshotsCount != 3 {
|
|
t.Fatalf("SnapshotsCount: got %d, want 3", stats.SnapshotsCount)
|
|
}
|
|
if stats.TotalFileCount != 100 {
|
|
t.Fatalf("TotalFileCount: got %d, want 100", stats.TotalFileCount)
|
|
}
|
|
if stats.TotalBlobCount != 50 {
|
|
t.Fatalf("TotalBlobCount: got %d, want 50", stats.TotalBlobCount)
|
|
}
|
|
}
|
|
|
|
func TestRunStatsErrorsWithoutJSON(t *testing.T) {
|
|
bin := setupScriptBin(t, `echo "no json here"`)
|
|
env := Env{Bin: bin}
|
|
_, err := env.RunStats(context.Background(), nil)
|
|
if err == nil {
|
|
t.Fatal("expected error when no JSON in output")
|
|
}
|
|
if !strings.Contains(err.Error(), "no JSON in output") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBandwidthLimitFlagsInjected(t *testing.T) {
|
|
// Script echoes its argv to stdout. Each variant should produce
|
|
// the right --limit-* flags before the subcommand.
|
|
cases := []struct {
|
|
name string
|
|
env Env
|
|
want []string
|
|
}{
|
|
{"both caps", Env{LimitUploadKBps: 1024, LimitDownloadKBps: 512}, []string{"--limit-upload 1024", "--limit-download 512"}},
|
|
{"only upload", Env{LimitUploadKBps: 256}, []string{"--limit-upload 256"}},
|
|
{"zero means omit", Env{LimitUploadKBps: 0, LimitDownloadKBps: 0}, nil},
|
|
{"negative means omit", Env{LimitUploadKBps: -1}, nil},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
bin := setupScriptBin(t, `echo "$@"`)
|
|
env := c.env
|
|
env.Bin = bin
|
|
lines, h := captureLines()
|
|
if err := env.RunUnlock(context.Background(), h); err != nil {
|
|
t.Fatalf("RunUnlock: %v", err)
|
|
}
|
|
joined := strings.Join(*lines, "\n")
|
|
for _, want := range c.want {
|
|
if !strings.Contains(joined, want) {
|
|
t.Fatalf("want %q in argv; got: %s", want, joined)
|
|
}
|
|
}
|
|
if len(c.want) == 0 {
|
|
if strings.Contains(joined, "--limit-upload") || strings.Contains(joined, "--limit-download") {
|
|
t.Fatalf("expected no limit flags; got: %s", joined)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunStatsZeroSnapshots(t *testing.T) {
|
|
// Confirms RunStats succeeds and returns a valid *RepoStats when the
|
|
// repo has no snapshots (snapshots_count=0). A regression that
|
|
// re-added a "SnapshotsCount > 0" guard would return an error here.
|
|
bin := setupScriptBin(t, `echo '{"total_size":0,"total_uncompressed_size":0,"snapshots_count":0,"total_file_count":0,"total_blob_count":0}'`)
|
|
env := Env{Bin: bin}
|
|
stats, err := env.RunStats(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("RunStats with zero snapshots returned unexpected error: %v", err)
|
|
}
|
|
if stats == nil {
|
|
t.Fatal("expected non-nil *RepoStats, got nil")
|
|
}
|
|
if stats.SnapshotsCount != 0 {
|
|
t.Fatalf("SnapshotsCount: got %d, want 0", stats.SnapshotsCount)
|
|
}
|
|
}
|