remove vm session feature

Cuts the daemon-managed guest-session machinery (start/list/show/
logs/stop/kill/attach/send). The feature shipped aimed at agent-
orchestration workflows (programmatic stdin piping into a long-lived
guest process) that aren't driving any concrete user today, and the
~2.3K LOC of daemon surface area — attach bridge, FIFO keepalive,
controller registry, sessionstream framing, SQLite persistence — was
locking in an API we'd have to keep through v0.1.0.

Anything session-flavoured that people actually need today can be
done with `vm ssh + tmux` or `vm run -- cmd`.

Deleted:
- internal/cli/commands_vm_session.go
- internal/daemon/{guest_sessions,session_lifecycle,session_attach,session_stream,session_controller}.go
- internal/daemon/session/ (guest-session helpers package)
- internal/sessionstream/ (framing package)
- internal/daemon/guest_sessions_test.go
- internal/store/guest_session_test.go
- GuestSession* types from internal/{api,model}
- Store UpsertGuestSession/GetGuestSession/ListGuestSessionsByVM/DeleteGuestSession + scanner helpers
- guest.session.* RPC dispatch entries
- 5 CLI session tests, 2 completion tests, 2 printer tests

Extracted:
- ShellQuote + FormatStepError lifted to internal/daemon/workspace/util.go
  (only non-session consumer); workspace package now self-contained
- internal/daemon/guest_ssh.go keeps guestSSHClient + dialGuest +
  waitForGuestSSH — still used by workspace prepare/export
- internal/daemon/fake_firecracker_test.go preserves the test helper
  that used to live in guest_sessions_test.go

Store schema: CREATE TABLE guest_sessions and its column migrations
removed. Existing dev DBs keep an orphan table (harmless, pre-v0.1.0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-20 12:47:58 -03:00
parent c42fcbe012
commit 2b6437d1b4
No known key found for this signature in database
GPG key ID: 33112E6833C34679
34 changed files with 194 additions and 4031 deletions

View file

@ -1,214 +0,0 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"reflect"
"testing"
"time"
"banger/internal/model"
)
func sampleGuestSession(id, vmID, name string) model.GuestSession {
now := fixedTime()
exit := 7
return model.GuestSession{
ID: id,
VMID: vmID,
Name: name,
Backend: "ssh",
AttachBackend: "vsock",
AttachMode: "rpc",
Command: "pi",
Args: []string{"--mode", "rpc"},
CWD: "/root/repo",
Env: map[string]string{"FOO": "bar"},
StdinMode: model.GuestSessionStdinMode("pipe"),
Status: model.GuestSessionStatus("exited"),
ExitCode: &exit,
GuestPID: 1234,
GuestStateDir: "/tmp/guest-" + id,
StdoutLogPath: "/tmp/" + id + ".stdout",
StderrLogPath: "/tmp/" + id + ".stderr",
Tags: map[string]string{"role": "planner"},
LastError: "",
Attachable: true,
Reattachable: true,
LaunchStage: "started",
LaunchMessage: "ok",
LaunchRawLog: "boot log...",
CreatedAt: now,
StartedAt: now,
UpdatedAt: now,
EndedAt: now.Add(time.Minute),
}
}
// openTestStoreWithVMs opens a fresh store seeded with the given VM IDs so
// guest_sessions FK constraints are satisfied. Each VM gets a minimal
// image it references.
func openTestStoreWithVMs(t *testing.T, vmIDs ...string) *Store {
t.Helper()
ctx := context.Background()
store := openTestStore(t)
image := sampleImage("stub-image")
if err := store.UpsertImage(ctx, image); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
for i, id := range vmIDs {
vm := sampleVM(id, image.ID, fmt.Sprintf("172.16.0.%d", i+2))
vm.ID = id
if err := store.UpsertVM(ctx, vm); err != nil {
t.Fatalf("UpsertVM(%s): %v", id, err)
}
}
return store
}
func TestGuestSessionUpsertAndGetByID(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStoreWithVMs(t, "vm-1")
session := sampleGuestSession("sess-1", "vm-1", "planner")
if err := store.UpsertGuestSession(ctx, session); err != nil {
t.Fatalf("UpsertGuestSession: %v", err)
}
got, err := store.GetGuestSessionByID(ctx, "sess-1")
if err != nil {
t.Fatalf("GetGuestSessionByID: %v", err)
}
if !reflect.DeepEqual(got, session) {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", got, session)
}
}
func TestGuestSessionUpsertIsIdempotent(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStoreWithVMs(t, "vm-1")
session := sampleGuestSession("sess-1", "vm-1", "planner")
if err := store.UpsertGuestSession(ctx, session); err != nil {
t.Fatalf("UpsertGuestSession (first): %v", err)
}
// Mutate + re-upsert → existing row updated.
session.Command = "pi --other"
session.Status = model.GuestSessionStatus("running")
session.ExitCode = nil
if err := store.UpsertGuestSession(ctx, session); err != nil {
t.Fatalf("UpsertGuestSession (second): %v", err)
}
got, err := store.GetGuestSessionByID(ctx, "sess-1")
if err != nil {
t.Fatalf("GetGuestSessionByID: %v", err)
}
if got.Command != "pi --other" {
t.Errorf("command = %q, want 'pi --other'", got.Command)
}
if got.Status != model.GuestSessionStatus("running") {
t.Errorf("status = %q, want running", got.Status)
}
if got.ExitCode != nil {
t.Errorf("ExitCode = %v, want nil after clearing", got.ExitCode)
}
}
func TestGetGuestSessionByIDOrName(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStoreWithVMs(t, "vm-1")
session := sampleGuestSession("sess-1", "vm-1", "planner")
if err := store.UpsertGuestSession(ctx, session); err != nil {
t.Fatalf("UpsertGuestSession: %v", err)
}
byID, err := store.GetGuestSession(ctx, "vm-1", "sess-1")
if err != nil {
t.Fatalf("GetGuestSession by ID: %v", err)
}
if byID.ID != "sess-1" {
t.Errorf("by-ID: got %q, want sess-1", byID.ID)
}
byName, err := store.GetGuestSession(ctx, "vm-1", "planner")
if err != nil {
t.Fatalf("GetGuestSession by name: %v", err)
}
if byName.Name != "planner" {
t.Errorf("by-name: got %q, want planner", byName.Name)
}
// Scoped to the VM.
if _, err := store.GetGuestSession(ctx, "vm-unknown", "sess-1"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("wrong-vm lookup = %v, want sql.ErrNoRows", err)
}
}
func TestListGuestSessionsByVMOrdersByCreatedAt(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStoreWithVMs(t, "vm-1", "vm-2")
base := fixedTime()
first := sampleGuestSession("sess-early", "vm-1", "first")
first.CreatedAt = base
second := sampleGuestSession("sess-late", "vm-1", "second")
second.CreatedAt = base.Add(time.Hour)
other := sampleGuestSession("sess-other", "vm-2", "other")
for _, s := range []model.GuestSession{second, first, other} {
if err := store.UpsertGuestSession(ctx, s); err != nil {
t.Fatalf("UpsertGuestSession: %v", err)
}
}
sessions, err := store.ListGuestSessionsByVM(ctx, "vm-1")
if err != nil {
t.Fatalf("ListGuestSessionsByVM: %v", err)
}
if len(sessions) != 2 {
t.Fatalf("len = %d, want 2 (vm-1 only)", len(sessions))
}
if sessions[0].ID != "sess-early" || sessions[1].ID != "sess-late" {
t.Fatalf("order: got %q, %q; want sess-early, sess-late", sessions[0].ID, sessions[1].ID)
}
empty, err := store.ListGuestSessionsByVM(ctx, "vm-unknown")
if err != nil {
t.Fatalf("ListGuestSessionsByVM (unknown vm): %v", err)
}
if len(empty) != 0 {
t.Fatalf("unknown vm sessions = %+v, want empty", empty)
}
}
func TestDeleteGuestSession(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStoreWithVMs(t, "vm-1")
session := sampleGuestSession("sess-1", "vm-1", "planner")
if err := store.UpsertGuestSession(ctx, session); err != nil {
t.Fatalf("UpsertGuestSession: %v", err)
}
if err := store.DeleteGuestSession(ctx, "sess-1"); err != nil {
t.Fatalf("DeleteGuestSession: %v", err)
}
if _, err := store.GetGuestSessionByID(ctx, "sess-1"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("after delete err = %v, want sql.ErrNoRows", err)
}
// Deleting something that doesn't exist is a no-op (matches SQL DELETE semantics).
if err := store.DeleteGuestSession(ctx, "sess-nope"); err != nil {
t.Fatalf("DeleteGuestSession on missing row: %v", err)
}
}

View file

@ -99,32 +99,6 @@ func (s *Store) migrate() error {
stats_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
);`,
`CREATE TABLE IF NOT EXISTS guest_sessions (
id TEXT PRIMARY KEY,
vm_id TEXT NOT NULL,
name TEXT NOT NULL,
backend TEXT NOT NULL,
command TEXT NOT NULL,
args_json TEXT NOT NULL DEFAULT '[]',
cwd TEXT,
env_json TEXT NOT NULL DEFAULT '{}',
stdin_mode TEXT NOT NULL,
status TEXT NOT NULL,
exit_code INTEGER,
guest_pid INTEGER NOT NULL DEFAULT 0,
guest_state_dir TEXT,
stdout_log_path TEXT,
stderr_log_path TEXT,
tags_json TEXT NOT NULL DEFAULT '{}',
last_error TEXT,
attachable INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
started_at TEXT,
updated_at TEXT NOT NULL,
ended_at TEXT,
UNIQUE(vm_id, name),
FOREIGN KEY(vm_id) REFERENCES vms(id) ON DELETE CASCADE
);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
@ -137,18 +111,6 @@ func (s *Store) migrate() error {
if err := ensureColumnExists(s.db, "images", "seeded_ssh_public_key_fingerprint", "TEXT"); err != nil {
return err
}
for _, spec := range []struct{ table, column, typ string }{
{"guest_sessions", "attach_backend", "TEXT"},
{"guest_sessions", "attach_mode", "TEXT"},
{"guest_sessions", "reattachable", "INTEGER NOT NULL DEFAULT 0"},
{"guest_sessions", "launch_stage", "TEXT"},
{"guest_sessions", "launch_message", "TEXT"},
{"guest_sessions", "launch_raw_log", "TEXT"},
} {
if err := ensureColumnExists(s.db, spec.table, spec.column, spec.typ); err != nil {
return err
}
}
return nil
}
@ -336,122 +298,6 @@ func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.
return vms, rows.Err()
}
func (s *Store) UpsertGuestSession(ctx context.Context, session model.GuestSession) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
argsJSON, err := json.Marshal(session.Args)
if err != nil {
return err
}
envJSON, err := json.Marshal(session.Env)
if err != nil {
return err
}
tagsJSON, err := json.Marshal(session.Tags)
if err != nil {
return err
}
const query = `
INSERT INTO guest_sessions (
id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
created_at, started_at, updated_at, ended_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
vm_id=excluded.vm_id,
name=excluded.name,
backend=excluded.backend,
attach_backend=excluded.attach_backend,
attach_mode=excluded.attach_mode,
command=excluded.command,
args_json=excluded.args_json,
cwd=excluded.cwd,
env_json=excluded.env_json,
stdin_mode=excluded.stdin_mode,
status=excluded.status,
exit_code=excluded.exit_code,
guest_pid=excluded.guest_pid,
guest_state_dir=excluded.guest_state_dir,
stdout_log_path=excluded.stdout_log_path,
stderr_log_path=excluded.stderr_log_path,
tags_json=excluded.tags_json,
last_error=excluded.last_error,
attachable=excluded.attachable,
reattachable=excluded.reattachable,
launch_stage=excluded.launch_stage,
launch_message=excluded.launch_message,
launch_raw_log=excluded.launch_raw_log,
started_at=excluded.started_at,
updated_at=excluded.updated_at,
ended_at=excluded.ended_at`
_, err = s.db.ExecContext(ctx, query,
session.ID,
session.VMID,
session.Name,
session.Backend,
session.AttachBackend,
session.AttachMode,
session.Command,
string(argsJSON),
session.CWD,
string(envJSON),
string(session.StdinMode),
string(session.Status),
nullableInt(session.ExitCode),
session.GuestPID,
session.GuestStateDir,
session.StdoutLogPath,
session.StderrLogPath,
string(tagsJSON),
session.LastError,
boolToInt(session.Attachable),
boolToInt(session.Reattachable),
session.LaunchStage,
session.LaunchMessage,
session.LaunchRawLog,
session.CreatedAt.Format(time.RFC3339),
nullableTimeString(session.StartedAt),
session.UpdatedAt.Format(time.RFC3339),
nullableTimeString(session.EndedAt),
)
return err
}
func (s *Store) GetGuestSessionByID(ctx context.Context, id string) (model.GuestSession, error) {
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE id = ?", id)
return scanGuestSessionRow(row)
}
func (s *Store) GetGuestSession(ctx context.Context, vmID, idOrName string) (model.GuestSession, error) {
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? AND (id = ? OR name = ?)", vmID, idOrName, idOrName)
return scanGuestSessionRow(row)
}
func (s *Store) ListGuestSessionsByVM(ctx context.Context, vmID string) ([]model.GuestSession, error) {
rows, err := s.db.QueryContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? ORDER BY created_at ASC", vmID)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []model.GuestSession
for rows.Next() {
session, err := scanGuestSession(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, session)
}
return sessions, rows.Err()
}
func (s *Store) DeleteGuestSession(ctx context.Context, id string) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err := s.db.ExecContext(ctx, "DELETE FROM guest_sessions WHERE id = ?", id)
return err
}
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
used := map[string]struct{}{}
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
@ -622,113 +468,6 @@ func boolToInt(value bool) int {
return 0
}
const guestSessionSelectSQL = `
SELECT id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
created_at, started_at, updated_at, ended_at
FROM guest_sessions`
func scanGuestSession(rows scanner) (model.GuestSession, error) {
return scanGuestSessionRow(rows)
}
func scanGuestSessionRow(row scanner) (model.GuestSession, error) {
var session model.GuestSession
var (
argsJSON string
envJSON string
tagsJSON string
stdinMode string
status string
exitCode sql.NullInt64
startedAt sql.NullString
endedAt sql.NullString
attachable int
reattachable int
createdRaw string
updatedRaw string
)
err := row.Scan(
&session.ID,
&session.VMID,
&session.Name,
&session.Backend,
&session.AttachBackend,
&session.AttachMode,
&session.Command,
&argsJSON,
&session.CWD,
&envJSON,
&stdinMode,
&status,
&exitCode,
&session.GuestPID,
&session.GuestStateDir,
&session.StdoutLogPath,
&session.StderrLogPath,
&tagsJSON,
&session.LastError,
&attachable,
&reattachable,
&session.LaunchStage,
&session.LaunchMessage,
&session.LaunchRawLog,
&createdRaw,
&startedAt,
&updatedRaw,
&endedAt,
)
if err != nil {
return session, err
}
session.StdinMode = model.GuestSessionStdinMode(stdinMode)
session.Status = model.GuestSessionStatus(status)
session.Attachable = attachable == 1
session.Reattachable = reattachable == 1
if argsJSON != "" {
if err := json.Unmarshal([]byte(argsJSON), &session.Args); err != nil {
return session, err
}
}
if envJSON != "" {
if err := json.Unmarshal([]byte(envJSON), &session.Env); err != nil {
return session, err
}
}
if tagsJSON != "" {
if err := json.Unmarshal([]byte(tagsJSON), &session.Tags); err != nil {
return session, err
}
}
if exitCode.Valid {
value := int(exitCode.Int64)
session.ExitCode = &value
}
var parseErr error
session.CreatedAt, parseErr = time.Parse(time.RFC3339, createdRaw)
if parseErr != nil {
return session, parseErr
}
session.UpdatedAt, parseErr = time.Parse(time.RFC3339, updatedRaw)
if parseErr != nil {
return session, parseErr
}
if startedAt.Valid && startedAt.String != "" {
session.StartedAt, parseErr = time.Parse(time.RFC3339, startedAt.String)
if parseErr != nil {
return session, parseErr
}
}
if endedAt.Valid && endedAt.String != "" {
session.EndedAt, parseErr = time.Parse(time.RFC3339, endedAt.String)
if parseErr != nil {
return session, parseErr
}
}
return session, nil
}
func nullableTimeString(value time.Time) any {
if value.IsZero() {
return nil