Add guest.session.send and vm.workspace.export RPCs
guest.session.send — write to a pipe-mode session's stdin without holding the exclusive attach. The daemon dials a fresh SSH connection, uploads the payload to a temp file, and cats it into the session's named FIFO. Linux atomicity for writes ≤ PIPE_BUF covers all pi RPC JSONL lines. Attach exclusivity is unchanged. vm.workspace.export — pull changes from guest back to host. Runs `git add -A && git diff --cached HEAD --binary` inside the guest via a new RunScriptOutput helper on guest.Client (stdout-only capture, distinct from RunScript which merges stderr). Returns a binary-safe patch and a list of changed files. CLI writes the patch to stdout for `| git apply` or to a file via --output. RunScriptOutput is implemented as a direct SSH session (same pattern as runSession) rather than going through StartCommand/StreamSession to avoid closing the underlying Client, which is required since ExportVMWorkspace calls it twice on the same connection. New files: internal/daemon/workspace_test.go
This commit is contained in:
parent
797a9de1ce
commit
94c353f317
9 changed files with 1074 additions and 1 deletions
254
internal/daemon/workspace_test.go
Normal file
254
internal/daemon/workspace_test.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
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
|
||||
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, _ string) ([]byte, error) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue