banger/internal/daemon/workspace_test.go
Thales Maciel 99de42385f
workspace export: stop mutating the guest repo index
Previously `banger vm workspace export` ran `git add -A` against the
guest's real `.git/index`, so the observation step left staged
changes behind that users never asked for. Reconnecting later (ssh,
another export) surfaced them and looked like phantom work.

Route `git add -A` through a throwaway index file instead:

  tmp_idx=$(mktemp ...)
  trap 'rm -f "$tmp_idx"' EXIT
  git read-tree <ref> --index-output="$tmp_idx"
  GIT_INDEX_FILE="$tmp_idx" git add -A
  GIT_INDEX_FILE="$tmp_idx" git diff --cached <ref> --binary|--name-only

The real .git/index, working tree, and refs stay exactly as the user
left them. Same diff content — commits past <ref>, uncommitted edits,
and untracked files (minus .gitignore) all captured.

Regression test locks the invariant: every export script must route
add -A through GIT_INDEX_FILE and clean the temp index on exit. CLI
help text updated to say "non-mutating".
2026-04-19 13:20:56 -03:00

417 lines
12 KiB
Go

package daemon
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"banger/internal/api"
"banger/internal/model"
)
// exportGuestClient is a scriptable fake for RunScriptOutput used in export tests.
// Each call to RunScriptOutput returns the next response from the queue.
type exportGuestClient struct {
responses []exportGuestResponse
scripts []string
callIndex int
}
type exportGuestResponse struct {
output []byte
err error
}
func (e *exportGuestClient) Close() error { return nil }
func (e *exportGuestClient) RunScript(_ context.Context, _ string, _ io.Writer) error {
return nil
}
func (e *exportGuestClient) RunScriptOutput(_ context.Context, script string) ([]byte, error) {
e.scripts = append(e.scripts, script)
if e.callIndex >= len(e.responses) {
return nil, nil
}
r := e.responses[e.callIndex]
e.callIndex++
return r.output, r.err
}
func (e *exportGuestClient) UploadFile(_ context.Context, _ string, _ os.FileMode, _ []byte, _ io.Writer) error {
return nil
}
func (e *exportGuestClient) StreamTar(_ context.Context, _ string, _ string, _ io.Writer) error {
return nil
}
func (e *exportGuestClient) StreamTarEntries(_ context.Context, _ string, _ []string, _ string, _ io.Writer) error {
return nil
}
func newExportTestDaemonStore(t *testing.T, fake *exportGuestClient) *Daemon {
t.Helper()
db := openDaemonStore(t)
d := &Daemon{
store: db,
config: model.DaemonConfig{SSHKeyPath: filepath.Join(t.TempDir(), "id_ed25519")},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
d.guestDial = func(_ context.Context, _ string, _ string) (guestSSHClient, error) {
return fake, nil
}
return d
}
func TestExportVMWorkspace_HappyPath(t *testing.T) {
t.Parallel()
ctx := context.Background()
apiSock := filepath.Join(t.TempDir(), "fc.sock")
firecracker := startFakeFirecracker(t, apiSock)
vm := testVM("exportbox", "image-export", "172.16.0.100")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
patch := []byte("diff --git a/file.go b/file.go\nindex 0000000..1111111 100644\n")
names := []byte("file.go\n")
fake := &exportGuestClient{
responses: []exportGuestResponse{
{output: patch},
{output: names},
},
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
GuestPath: "/root/repo",
})
if err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)
}
if !result.HasChanges {
t.Fatal("HasChanges = false, want true")
}
if string(result.Patch) != string(patch) {
t.Fatalf("Patch = %q, want %q", result.Patch, patch)
}
if result.GuestPath != "/root/repo" {
t.Fatalf("GuestPath = %q, want /root/repo", result.GuestPath)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "file.go" {
t.Fatalf("ChangedFiles = %v, want [file.go]", result.ChangedFiles)
}
if fake.callIndex != 2 {
t.Fatalf("RunScriptOutput call count = %d, want 2", fake.callIndex)
}
// No base_commit provided: diff ref must be HEAD.
for _, script := range fake.scripts {
if !strings.Contains(script, "HEAD") {
t.Fatalf("script missing HEAD ref: %q", script)
}
}
if result.BaseCommit != "HEAD" {
t.Fatalf("BaseCommit = %q, want HEAD", result.BaseCommit)
}
}
func TestExportVMWorkspace_WithBaseCommit(t *testing.T) {
t.Parallel()
ctx := context.Background()
apiSock := filepath.Join(t.TempDir(), "fc.sock")
firecracker := startFakeFirecracker(t, apiSock)
vm := testVM("exportbox-base", "image-export", "172.16.0.105")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
// Simulate: worker committed inside the VM. Without base_commit the diff
// against the new HEAD would be empty. With base_commit we capture
// everything since the original checkout.
patch := []byte("diff --git a/worker.go b/worker.go\nindex 0000000..abcdef 100644\n")
names := []byte("worker.go\n")
fake := &exportGuestClient{
responses: []exportGuestResponse{
{output: patch},
{output: names},
},
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
const prepareCommit = "abc1234deadbeef"
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
BaseCommit: prepareCommit,
})
if err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)
}
if !result.HasChanges {
t.Fatal("HasChanges = false, want true")
}
if result.BaseCommit != prepareCommit {
t.Fatalf("BaseCommit = %q, want %q", result.BaseCommit, prepareCommit)
}
// Both scripts must reference the caller-supplied commit, not HEAD.
for _, script := range fake.scripts {
if strings.Contains(script, " HEAD") {
t.Fatalf("script used HEAD instead of base_commit: %q", script)
}
if !strings.Contains(script, prepareCommit) {
t.Fatalf("script missing base_commit %q: %q", prepareCommit, script)
}
}
}
func TestExportVMWorkspace_BaseCommitFallsBackToHEAD(t *testing.T) {
t.Parallel()
ctx := context.Background()
apiSock := filepath.Join(t.TempDir(), "fc.sock")
firecracker := startFakeFirecracker(t, apiSock)
vm := testVM("exportbox-nobase", "image-export", "172.16.0.106")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
fake := &exportGuestClient{
responses: []exportGuestResponse{
{output: nil},
{output: nil},
},
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
BaseCommit: "", // omitted
})
if err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)
}
if result.BaseCommit != "HEAD" {
t.Fatalf("BaseCommit = %q, want HEAD when not supplied", result.BaseCommit)
}
for _, script := range fake.scripts {
if !strings.Contains(script, "HEAD") {
t.Fatalf("script missing HEAD fallback: %q", script)
}
}
}
func TestExportVMWorkspace_NoChanges(t *testing.T) {
t.Parallel()
ctx := context.Background()
apiSock := filepath.Join(t.TempDir(), "fc.sock")
firecracker := startFakeFirecracker(t, apiSock)
vm := testVM("exportbox-empty", "image-export", "172.16.0.101")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
// Both scripts return empty output (no changes).
fake := &exportGuestClient{
responses: []exportGuestResponse{
{output: nil},
{output: nil},
},
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
})
if err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)
}
if result.HasChanges {
t.Fatal("HasChanges = true, want false")
}
if len(result.Patch) != 0 {
t.Fatalf("Patch = %q, want empty", result.Patch)
}
if len(result.ChangedFiles) != 0 {
t.Fatalf("ChangedFiles = %v, want empty", result.ChangedFiles)
}
}
func TestExportVMWorkspace_DefaultGuestPath(t *testing.T) {
t.Parallel()
ctx := context.Background()
apiSock := filepath.Join(t.TempDir(), "fc.sock")
firecracker := startFakeFirecracker(t, apiSock)
vm := testVM("exportbox-default", "image-export", "172.16.0.102")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
fake := &exportGuestClient{
responses: []exportGuestResponse{
{output: nil},
{output: nil},
},
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
// GuestPath omitted — should default to /root/repo.
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
})
if err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)
}
if result.GuestPath != "/root/repo" {
t.Fatalf("GuestPath = %q, want /root/repo", result.GuestPath)
}
}
func TestExportVMWorkspace_VMNotRunning(t *testing.T) {
t.Parallel()
ctx := context.Background()
vm := testVM("exportbox-stopped", "image-export", "172.16.0.103")
vm.State = model.VMStateStopped
fake := &exportGuestClient{}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
_, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
})
if err == nil || !strings.Contains(err.Error(), "not running") {
t.Fatalf("error = %v, want 'not running' error", err)
}
if fake.callIndex != 0 {
t.Fatal("RunScriptOutput should not be called when VM is not running")
}
}
func TestExportVMWorkspace_MultipleChangedFiles(t *testing.T) {
t.Parallel()
ctx := context.Background()
apiSock := filepath.Join(t.TempDir(), "fc.sock")
firecracker := startFakeFirecracker(t, apiSock)
vm := testVM("exportbox-multi", "image-export", "172.16.0.104")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
patch := []byte("diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n")
names := []byte("a.go\nb.go\nnew/file.go\n")
fake := &exportGuestClient{
responses: []exportGuestResponse{
{output: patch},
{output: names},
},
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
})
if err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)
}
if len(result.ChangedFiles) != 3 {
t.Fatalf("ChangedFiles = %v, want 3 entries", result.ChangedFiles)
}
want := []string{"a.go", "b.go", "new/file.go"}
for i, f := range want {
if result.ChangedFiles[i] != f {
t.Fatalf("ChangedFiles[%d] = %q, want %q", i, result.ChangedFiles[i], f)
}
}
}
// TestExportVMWorkspace_DoesNotMutateRealIndex is a regression guard
// for an earlier design where `git add -A` ran against the guest's
// real `.git/index`, leaving staged changes behind after what the user
// thought was a read-only observation. Every export script must now
// route `git add -A` through a throwaway index selected by
// GIT_INDEX_FILE, and every script must clean that file up.
func TestExportVMWorkspace_DoesNotMutateRealIndex(t *testing.T) {
t.Parallel()
ctx := context.Background()
apiSock := filepath.Join(t.TempDir(), "fc.sock")
firecracker := startFakeFirecracker(t, apiSock)
vm := testVM("exportbox-readonly", "image-export", "172.16.0.107")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
fake := &exportGuestClient{
responses: []exportGuestResponse{
{output: []byte("diff --git a/x b/x\n")},
{output: []byte("x\n")},
},
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
if _, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{IDOrName: vm.Name}); err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)
}
if len(fake.scripts) == 0 {
t.Fatal("expected at least one export script to be sent")
}
for i, script := range fake.scripts {
if !strings.Contains(script, "GIT_INDEX_FILE") {
t.Errorf("script[%d] missing GIT_INDEX_FILE routing:\n%s", i, script)
}
// git add -A must ONLY appear on a line that also sets
// GIT_INDEX_FILE. A bare occurrence would mutate the real
// index.
for _, line := range strings.Split(script, "\n") {
if strings.Contains(line, "git add -A") && !strings.Contains(line, "GIT_INDEX_FILE") {
t.Errorf("script[%d] has unscoped `git add -A`:\n%s", i, script)
break
}
}
if !strings.Contains(script, "git read-tree") {
t.Errorf("script[%d] missing git read-tree (temp index seed):\n%s", i, script)
}
if !strings.Contains(script, "mktemp") {
t.Errorf("script[%d] missing mktemp for temp index:\n%s", i, script)
}
if !strings.Contains(script, "trap") || !strings.Contains(script, "rm") {
t.Errorf("script[%d] missing temp-index cleanup trap:\n%s", i, script)
}
}
}