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
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
"banger/internal/store"
|
||||
)
|
||||
|
||||
type fakeGuestSSHClient struct {
|
||||
|
|
@ -57,6 +58,10 @@ func (f *fakeGuestSSHClient) RunScript(_ context.Context, script string, _ io.Wr
|
|||
}
|
||||
}
|
||||
|
||||
func (f *fakeGuestSSHClient) RunScriptOutput(_ context.Context, _ string) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeGuestSSHClient) UploadFile(_ context.Context, _ string, _ os.FileMode, _ []byte, _ io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -77,6 +82,276 @@ func (f *fakeGuestSSHClient) StreamTarEntries(_ context.Context, _ string, _ []s
|
|||
return fmt.Errorf("unexpected StreamTarEntries command: %s", command)
|
||||
}
|
||||
|
||||
func TestSendToGuestSession_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
|
||||
apiSock := filepath.Join(t.TempDir(), "fc.sock")
|
||||
firecracker := startFakeFirecracker(t, apiSock)
|
||||
|
||||
vm := testVM("sendbox", "image-send", "172.16.0.88")
|
||||
vm.State = model.VMStateRunning
|
||||
vm.Runtime.State = model.VMStateRunning
|
||||
vm.Runtime.PID = firecracker.Process.Pid
|
||||
vm.Runtime.APISockPath = apiSock
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
session := testGuestSession(vm.ID, model.GuestSessionStdinPipe, model.GuestSessionStatusRunning)
|
||||
if err := db.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
|
||||
fake := &recordingGuestSSHClient{}
|
||||
d := newSendTestDaemon(t, db, fake)
|
||||
|
||||
payload := []byte(`{"type":"abort"}` + "\n")
|
||||
result, err := d.SendToGuestSession(ctx, api.GuestSessionSendParams{
|
||||
VMIDOrName: vm.Name,
|
||||
SessionIDOrName: session.Name,
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendToGuestSession: %v", err)
|
||||
}
|
||||
if result.BytesWritten != len(payload) {
|
||||
t.Fatalf("BytesWritten = %d, want %d", result.BytesWritten, len(payload))
|
||||
}
|
||||
if result.Session.ID != session.ID {
|
||||
t.Fatalf("Session.ID = %q, want %q", result.Session.ID, session.ID)
|
||||
}
|
||||
if len(fake.uploadedFiles) != 1 {
|
||||
t.Fatalf("UploadFile call count = %d, want 1", len(fake.uploadedFiles))
|
||||
}
|
||||
for path, data := range fake.uploadedFiles {
|
||||
if !strings.HasPrefix(path, "/tmp/banger-send-") {
|
||||
t.Fatalf("upload path = %q, want /tmp/banger-send-... prefix", path)
|
||||
}
|
||||
if string(data) != string(payload) {
|
||||
t.Fatalf("upload data = %q, want %q", data, payload)
|
||||
}
|
||||
}
|
||||
if len(fake.ranScripts) != 1 {
|
||||
t.Fatalf("RunScript call count = %d, want 1", len(fake.ranScripts))
|
||||
}
|
||||
script := fake.ranScripts[0]
|
||||
pipePath := guestSessionStdinPipePath(session.ID)
|
||||
if !strings.Contains(script, "cat ") {
|
||||
t.Fatalf("send script missing cat command: %q", script)
|
||||
}
|
||||
if !strings.Contains(script, pipePath) {
|
||||
t.Fatalf("send script missing pipe path %q: %q", pipePath, script)
|
||||
}
|
||||
if !strings.Contains(script, "rm -f ") {
|
||||
t.Fatalf("send script missing rm cleanup: %q", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToGuestSession_EmptyPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
|
||||
apiSock := filepath.Join(t.TempDir(), "fc.sock")
|
||||
firecracker := startFakeFirecracker(t, apiSock)
|
||||
|
||||
vm := testVM("sendbox-empty", "image-send", "172.16.0.89")
|
||||
vm.State = model.VMStateRunning
|
||||
vm.Runtime.State = model.VMStateRunning
|
||||
vm.Runtime.PID = firecracker.Process.Pid
|
||||
vm.Runtime.APISockPath = apiSock
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
session := testGuestSession(vm.ID, model.GuestSessionStdinPipe, model.GuestSessionStatusRunning)
|
||||
if err := db.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
|
||||
fake := &recordingGuestSSHClient{}
|
||||
d := newSendTestDaemon(t, db, fake)
|
||||
|
||||
result, err := d.SendToGuestSession(ctx, api.GuestSessionSendParams{
|
||||
VMIDOrName: vm.Name,
|
||||
SessionIDOrName: session.Name,
|
||||
Payload: nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendToGuestSession(empty): %v", err)
|
||||
}
|
||||
if result.BytesWritten != 0 {
|
||||
t.Fatalf("BytesWritten = %d, want 0", result.BytesWritten)
|
||||
}
|
||||
if fake.dialCount != 0 {
|
||||
t.Fatalf("SSH dial count = %d, want 0 for empty payload", fake.dialCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToGuestSession_NotPipeMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
|
||||
vm := testVM("sendbox-closed", "image-send", "172.16.0.90")
|
||||
vm.State = model.VMStateRunning
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
session := testGuestSession(vm.ID, model.GuestSessionStdinClosed, model.GuestSessionStatusRunning)
|
||||
if err := db.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{store: db}
|
||||
_, err := d.SendToGuestSession(ctx, api.GuestSessionSendParams{
|
||||
VMIDOrName: vm.Name,
|
||||
SessionIDOrName: session.Name,
|
||||
Payload: []byte("hello\n"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "stdin pipe") {
|
||||
t.Fatalf("error = %v, want 'stdin pipe' error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToGuestSession_SessionNotRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
|
||||
vm := testVM("sendbox-failed", "image-send", "172.16.0.91")
|
||||
vm.State = model.VMStateRunning
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
session := testGuestSession(vm.ID, model.GuestSessionStdinPipe, model.GuestSessionStatusFailed)
|
||||
if err := db.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{store: db}
|
||||
_, err := d.SendToGuestSession(ctx, api.GuestSessionSendParams{
|
||||
VMIDOrName: vm.Name,
|
||||
SessionIDOrName: session.Name,
|
||||
Payload: []byte("hello\n"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not running") {
|
||||
t.Fatalf("error = %v, want 'not running' error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToGuestSession_VMNotRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
|
||||
vm := testVM("sendbox-stopped", "image-send", "172.16.0.92")
|
||||
vm.State = model.VMStateStopped
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
session := testGuestSession(vm.ID, model.GuestSessionStdinPipe, model.GuestSessionStatusRunning)
|
||||
if err := db.UpsertGuestSession(ctx, session); err != nil {
|
||||
t.Fatalf("UpsertGuestSession: %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{store: db}
|
||||
_, err := d.SendToGuestSession(ctx, api.GuestSessionSendParams{
|
||||
VMIDOrName: vm.Name,
|
||||
SessionIDOrName: session.Name,
|
||||
Payload: []byte("hello\n"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not running") {
|
||||
t.Fatalf("error = %v, want 'not running' error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingGuestSSHClient captures UploadFile and RunScript calls for send tests.
|
||||
type recordingGuestSSHClient struct {
|
||||
dialCount int
|
||||
uploadedFiles map[string][]byte
|
||||
ranScripts []string
|
||||
}
|
||||
|
||||
func (r *recordingGuestSSHClient) Close() error { return nil }
|
||||
|
||||
func (r *recordingGuestSSHClient) UploadFile(_ context.Context, path string, _ os.FileMode, data []byte, _ io.Writer) error {
|
||||
if r.uploadedFiles == nil {
|
||||
r.uploadedFiles = make(map[string][]byte)
|
||||
}
|
||||
copy := make([]byte, len(data))
|
||||
_ = copy[:len(data):len(data)]
|
||||
for i, b := range data {
|
||||
copy[i] = b
|
||||
}
|
||||
r.uploadedFiles[path] = copy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingGuestSSHClient) RunScript(_ context.Context, script string, _ io.Writer) error {
|
||||
r.ranScripts = append(r.ranScripts, script)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingGuestSSHClient) RunScriptOutput(_ context.Context, _ string) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *recordingGuestSSHClient) StreamTar(_ context.Context, _ string, _ string, _ io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingGuestSSHClient) StreamTarEntries(_ context.Context, _ string, _ []string, _ string, _ io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newSendTestDaemon(t *testing.T, db *store.Store, fake *recordingGuestSSHClient) *Daemon {
|
||||
t.Helper()
|
||||
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) {
|
||||
fake.dialCount++
|
||||
return fake, nil
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func testGuestSession(vmID string, stdinMode model.GuestSessionStdinMode, status model.GuestSessionStatus) model.GuestSession {
|
||||
now := model.Now()
|
||||
id := vmID + "-sess-id"
|
||||
return model.GuestSession{
|
||||
ID: id,
|
||||
VMID: vmID,
|
||||
Name: vmID + "-sess",
|
||||
Backend: guestSessionBackendSSH,
|
||||
Command: "pi",
|
||||
Args: []string{"--mode", "rpc"},
|
||||
CWD: "/root/repo",
|
||||
StdinMode: stdinMode,
|
||||
Status: status,
|
||||
GuestStateDir: guestSessionStateDir(id),
|
||||
StdoutLogPath: guestSessionStdoutLogPath(id),
|
||||
StderrLogPath: guestSessionStderrLogPath(id),
|
||||
Attachable: stdinMode == model.GuestSessionStdinPipe && status == model.GuestSessionStatusRunning,
|
||||
Reattachable: stdinMode == model.GuestSessionStdinPipe && status == model.GuestSessionStatusRunning,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func startFakeFirecracker(t *testing.T, apiSock string) *exec.Cmd {
|
||||
t.Helper()
|
||||
cmd := exec.Command("bash", "-lc", fmt.Sprintf("exec -a %q sleep 60", "firecracker --api-sock "+apiSock))
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start fake firecracker: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestGuestSessionPreflightScriptsUseRealNewlines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue