mirror of
https://github.com/TronoSfera/backupy-agent.git
synced 2026-05-18 10:03:30 +03:00
Source ports from the TronoSfera/backupy-cloud monorepo:
- apps/agent/ — Go agent (WSS client, persistent queue, Docker
discovery, 5 DB drivers: PG/MySQL/Mongo/Redis/SQLite,
pre/post hooks, Prometheus metrics)
- apps/backupy-decrypt/ — standalone CLI for client-side decryption
- packages/proto/ — protobuf wire format (generated .pb.go committed
so the repo builds without protoc)
- docs/ — agent spec + wire-protocol contract
Apache-2.0 license. Image published to ghcr.io/tronosfera/backupy-agent
on every v* tag via .github/workflows/release.yml (multi-arch amd64+arm64).
202 lines
5.4 KiB
Go
202 lines
5.4 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRunHook_Success(t *testing.T) {
|
|
res, err := RunHook(context.Background(), "echo hi", nil, time.Second)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.ExitCode != 0 {
|
|
t.Errorf("ExitCode=%d want 0", res.ExitCode)
|
|
}
|
|
if res.Stdout != "hi\n" {
|
|
t.Errorf("Stdout=%q want %q", res.Stdout, "hi\n")
|
|
}
|
|
if res.TimedOut {
|
|
t.Errorf("TimedOut=true; should be false on success")
|
|
}
|
|
if res.Duration <= 0 {
|
|
t.Errorf("Duration=%v should be > 0", res.Duration)
|
|
}
|
|
}
|
|
|
|
func TestRunHook_NonZeroExit(t *testing.T) {
|
|
res, err := RunHook(context.Background(), "false", nil, time.Second)
|
|
if err == nil {
|
|
t.Fatalf("expected error for non-zero exit")
|
|
}
|
|
if res.ExitCode != 1 {
|
|
t.Errorf("ExitCode=%d want 1", res.ExitCode)
|
|
}
|
|
if res.TimedOut {
|
|
t.Errorf("TimedOut should be false for plain non-zero exit")
|
|
}
|
|
}
|
|
|
|
func TestRunHook_Timeout(t *testing.T) {
|
|
start := time.Now()
|
|
res, err := RunHook(context.Background(), "sleep 10", nil, 100*time.Millisecond)
|
|
elapsed := time.Since(start)
|
|
if err == nil {
|
|
t.Fatalf("expected timeout error")
|
|
}
|
|
if !res.TimedOut {
|
|
t.Errorf("TimedOut=false; want true")
|
|
}
|
|
if res.ExitCode != -1 {
|
|
t.Errorf("ExitCode=%d want -1 on timeout", res.ExitCode)
|
|
}
|
|
// Killed within reasonable slack of the timeout.
|
|
if elapsed > 5*time.Second {
|
|
t.Errorf("hook took %v, should die well under 5s", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestRunHook_ContextCancel(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
var res HookResult
|
|
var err error
|
|
start := time.Now()
|
|
go func() {
|
|
res, err = RunHook(ctx, "sleep 30", nil, 30*time.Second)
|
|
close(done)
|
|
}()
|
|
// Let the process actually start before we cancel.
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("hook did not exit within 2s of cancel")
|
|
}
|
|
if err == nil {
|
|
t.Errorf("expected error on cancel")
|
|
}
|
|
if res.TimedOut {
|
|
t.Errorf("cancel should NOT be reported as TimedOut")
|
|
}
|
|
if res.ExitCode != -1 {
|
|
t.Errorf("ExitCode=%d want -1 on cancel", res.ExitCode)
|
|
}
|
|
// Sanity: we cancelled fast.
|
|
if time.Since(start) > 5*time.Second {
|
|
t.Errorf("cancel took too long: %v", time.Since(start))
|
|
}
|
|
}
|
|
|
|
func TestRunHook_StdoutTruncation(t *testing.T) {
|
|
// Produce ~100 KB to stdout; ring buffer keeps last 8 KB.
|
|
// `yes "X" | head -c 102400` is portable across BSD + GNU userland.
|
|
cmd := `yes X | head -c 102400`
|
|
res, err := RunHook(context.Background(), cmd, nil, 10*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(res.Stdout) != HookOutputBufBytes {
|
|
t.Errorf("stdout length=%d want %d (ring buffer cap)", len(res.Stdout), HookOutputBufBytes)
|
|
}
|
|
// Every byte in the buffer should be a captured 'X' or '\n' (from yes).
|
|
for i, c := range res.Stdout {
|
|
if c != 'X' && c != '\n' {
|
|
t.Errorf("unexpected byte at offset %d: %q", i, c)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunHook_EnvPassedThrough(t *testing.T) {
|
|
res, err := RunHook(context.Background(), `echo "$BACKUPY_TEST_VAR"`, []string{"BACKUPY_TEST_VAR=hello"}, time.Second)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if strings.TrimSpace(res.Stdout) != "hello" {
|
|
t.Errorf("stdout=%q want %q", res.Stdout, "hello")
|
|
}
|
|
}
|
|
|
|
func TestRunHook_EmptyCommandRejected(t *testing.T) {
|
|
if _, err := RunHook(context.Background(), "", nil, time.Second); err == nil {
|
|
t.Error("empty command must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestRunHook_StderrCaptured(t *testing.T) {
|
|
res, err := RunHook(context.Background(), `echo "oops" 1>&2; exit 1`, nil, time.Second)
|
|
if err == nil {
|
|
t.Fatalf("expected non-zero exit error")
|
|
}
|
|
if res.ExitCode != 1 {
|
|
t.Errorf("ExitCode=%d want 1", res.ExitCode)
|
|
}
|
|
if strings.TrimSpace(res.Stderr) != "oops" {
|
|
t.Errorf("stderr=%q want %q", res.Stderr, "oops")
|
|
}
|
|
}
|
|
|
|
func TestHookSet_BudgetExhaustion(t *testing.T) {
|
|
hs := NewHookSet()
|
|
// Pre-charge past the budget so the next Run is immediately denied.
|
|
hs.consumed = HooksTotalBudget + time.Second
|
|
_, err := hs.Run(context.Background(), "true", nil, time.Second)
|
|
if err == nil {
|
|
t.Error("expected ErrHooksBudgetExceeded after budget consumed")
|
|
}
|
|
}
|
|
|
|
func TestHookSet_NormalRunCharges(t *testing.T) {
|
|
hs := NewHookSet()
|
|
res, err := hs.Run(context.Background(), "true", nil, time.Second)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.ExitCode != 0 {
|
|
t.Errorf("ExitCode=%d want 0", res.ExitCode)
|
|
}
|
|
if hs.consumed <= 0 {
|
|
t.Errorf("consumed=%v should be > 0 after a run", hs.consumed)
|
|
}
|
|
}
|
|
|
|
func TestHookRingBuffer_Truncation(t *testing.T) {
|
|
buf := newHookRingBuffer(8)
|
|
// Write more than capacity in chunks.
|
|
for _, chunk := range []string{"abcd", "efgh", "ijkl"} {
|
|
if _, err := buf.Write([]byte(chunk)); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
}
|
|
got := buf.String()
|
|
if got != "efghijkl" {
|
|
t.Errorf("ring buffer kept %q, want %q", got, "efghijkl")
|
|
}
|
|
}
|
|
|
|
func TestHookRingBuffer_SingleHugeWrite(t *testing.T) {
|
|
buf := newHookRingBuffer(8)
|
|
// Single write much larger than capacity.
|
|
src := []byte("0123456789ABCDEF")
|
|
if _, err := buf.Write(src); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
got := buf.String()
|
|
if got != "89ABCDEF" {
|
|
t.Errorf("ring buffer kept %q, want %q", got, "89ABCDEF")
|
|
}
|
|
}
|
|
|
|
func TestHookRingBuffer_SmallWriteUnderCap(t *testing.T) {
|
|
buf := newHookRingBuffer(16)
|
|
if _, err := buf.Write([]byte("hello")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := buf.String(); got != "hello" {
|
|
t.Errorf("got %q want %q", got, "hello")
|
|
}
|
|
}
|