From b7a43324e017b019eac32107764ed7abd830cc32 Mon Sep 17 00:00:00 2001 From: aspectrr Date: Sun, 15 Mar 2026 16:48:09 -0400 Subject: [PATCH 01/31] feat: make /allowlist and /redaction pages for cli --- fluid-cli/internal/readonly/validate.go | 15 + fluid-cli/internal/readonly/validate_test.go | 34 ++ fluid-cli/internal/tui/allowlist.go | 334 ++++++++++++ fluid-cli/internal/tui/allowlist_test.go | 166 ++++++ fluid-cli/internal/tui/messages.go | 10 + fluid-cli/internal/tui/model.go | 106 +++- fluid-cli/internal/tui/redaction.go | 534 +++++++++++++++++++ fluid-cli/internal/tui/redaction_test.go | 210 ++++++++ fluid-cli/internal/tui/settings.go | 44 -- shared/readonly/validate.go | 9 + 10 files changed, 1416 insertions(+), 46 deletions(-) create mode 100644 fluid-cli/internal/tui/allowlist.go create mode 100644 fluid-cli/internal/tui/allowlist_test.go create mode 100644 fluid-cli/internal/tui/redaction.go create mode 100644 fluid-cli/internal/tui/redaction_test.go diff --git a/fluid-cli/internal/readonly/validate.go b/fluid-cli/internal/readonly/validate.go index 1b106647..2b582d3b 100644 --- a/fluid-cli/internal/readonly/validate.go +++ b/fluid-cli/internal/readonly/validate.go @@ -4,6 +4,8 @@ package readonly import ( + "sort" + "github.com/aspectrr/fluid.sh/shared/readonly" ) @@ -11,6 +13,19 @@ func AllowedCommandsList() []string { return readonly.AllowedCommandsList() } +func SubcommandRestrictions() map[string][]string { + result := make(map[string][]string, len(readonly.SubcommandRestrictions())) + for cmd, subs := range readonly.SubcommandRestrictions() { + keys := make([]string, 0, len(subs)) + for k := range subs { + keys = append(keys, k) + } + sort.Strings(keys) + result[cmd] = keys + } + return result +} + // ValidateCommand checks that every command in a pipeline is allowed for read-only mode. func ValidateCommand(command string) error { return readonly.ValidateCommand(command) diff --git a/fluid-cli/internal/readonly/validate_test.go b/fluid-cli/internal/readonly/validate_test.go index 93053c52..bb67a1d7 100644 --- a/fluid-cli/internal/readonly/validate_test.go +++ b/fluid-cli/internal/readonly/validate_test.go @@ -385,3 +385,37 @@ func TestAllowedCommandsList(t *testing.T) { } } } + +func TestSubcommandRestrictions(t *testing.T) { + restrs := SubcommandRestrictions() + if len(restrs) == 0 { + t.Error("expected non-empty result") + } + + // Check systemctl has restrictions + subs, ok := restrs["systemctl"] + if !ok { + t.Error("expected systemctl to have subcommand restrictions") + } + if len(subs) == 0 { + t.Error("expected systemctl to have at least one subcommand") + } + + // Verify values are sorted + for cmd, subs := range restrs { + if !sort.StringsAreSorted(subs) { + t.Errorf("expected %q subcommands to be sorted, got %v", cmd, subs) + } + } + + // Spot check systemctl + found := false + for _, s := range subs { + if s == "status" { + found = true + } + } + if !found { + t.Error("expected 'status' in systemctl subcommands") + } +} diff --git a/fluid-cli/internal/tui/allowlist.go b/fluid-cli/internal/tui/allowlist.go new file mode 100644 index 00000000..ebb17e08 --- /dev/null +++ b/fluid-cli/internal/tui/allowlist.go @@ -0,0 +1,334 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/aspectrr/fluid.sh/fluid-cli/internal/config" + "github.com/aspectrr/fluid.sh/fluid-cli/internal/readonly" +) + +type allowlistMode int + +const ( + allowlistModeList allowlistMode = iota + allowlistModeAdd +) + +type allowlistStyles struct { + title lipgloss.Style + help lipgloss.Style + section lipgloss.Style + command lipgloss.Style + userCommand lipgloss.Style + dimmed lipgloss.Style + error lipgloss.Style + success lipgloss.Style + indicator lipgloss.Style +} + +func defaultAllowlistStyles() allowlistStyles { + return allowlistStyles{ + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3B82F6")), + help: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), + section: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#06B6D4")), + command: lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF")), + userCommand: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), + dimmed: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), + error: lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")), + success: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), + indicator: lipgloss.NewStyle().Foreground(lipgloss.Color("#3B82F6")), + } +} + +type AllowlistModel struct { + cfg *config.Config + width int + height int + styles allowlistStyles + builtinCmds []string + subcommandRestrs map[string][]string + userCmds []string + mode allowlistMode + selected int + scrollY int + addInput textinput.Model + addErr string +} + +func NewAllowlistModel(cfg *config.Config) AllowlistModel { + addInput := textinput.New() + addInput.Prompt = "Command: " + addInput.Placeholder = "e.g., custom-tool" + addInput.Focus() + + m := AllowlistModel{ + cfg: cfg, + styles: defaultAllowlistStyles(), + builtinCmds: readonly.AllowedCommandsList(), + subcommandRestrs: readonly.SubcommandRestrictions(), + userCmds: make([]string, len(cfg.ExtraAllowedCommands)), + mode: allowlistModeList, + selected: 0, + scrollY: 0, + addInput: addInput, + } + copy(m.userCmds, cfg.ExtraAllowedCommands) + sort.Strings(m.userCmds) + + return m +} + +func (m AllowlistModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m AllowlistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "esc": + if m.mode == allowlistModeAdd { + m.mode = allowlistModeList + m.addInput.SetValue("") + m.addErr = "" + return m, nil + } + return m, func() tea.Msg { return AllowlistCloseMsg{Saved: false} } + + case "ctrl+s": + return m, func() tea.Msg { return AllowlistCloseMsg{Saved: true} } + + case "up": + if m.mode == allowlistModeList { + if m.selected > 0 { + m.selected-- + m.ensureVisible() + } + } + return m, nil + + case "down": + if m.mode == allowlistModeList { + totalItems := len(m.builtinCmds) + len(m.userCmds) + if m.selected < totalItems-1 { + m.selected++ + m.ensureVisible() + } + } + return m, nil + + case "ctrl+n": + if m.mode == allowlistModeList { + m.mode = allowlistModeAdd + m.addInput.Focus() + } + return m, nil + + case "enter": + if m.mode == allowlistModeAdd { + newCmd := strings.TrimSpace(m.addInput.Value()) + if newCmd == "" { + m.addErr = "command cannot be empty" + return m, nil + } + for _, cmd := range m.builtinCmds { + if cmd == newCmd { + m.addErr = "command already in allowlist" + return m, nil + } + } + for _, cmd := range m.userCmds { + if cmd == newCmd { + m.addErr = "command already in allowlist" + return m, nil + } + } + m.userCmds = append(m.userCmds, newCmd) + sort.Strings(m.userCmds) + m.cfg.ExtraAllowedCommands = m.userCmds + m.mode = allowlistModeList + m.addInput.SetValue("") + m.addErr = "" + m.selected = len(m.builtinCmds) + len(m.userCmds) - 1 + m.ensureVisible() + } + return m, nil + + case "d": + if m.mode == allowlistModeList { + totalBuiltins := len(m.builtinCmds) + if m.selected >= totalBuiltins { + idx := m.selected - totalBuiltins + m.userCmds = append(m.userCmds[:idx], m.userCmds[idx+1:]...) + m.cfg.ExtraAllowedCommands = m.userCmds + if m.selected >= len(m.builtinCmds)+len(m.userCmds) && m.selected > 0 { + m.selected-- + } + m.ensureVisible() + } + } + return m, nil + } + } + + if m.mode == allowlistModeAdd { + var cmd tea.Cmd + m.addInput, cmd = m.addInput.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *AllowlistModel) ensureVisible() { + visibleItems := m.visibleItemCount() + if m.selected < m.scrollY { + m.scrollY = m.selected + } + if m.selected >= m.scrollY+visibleItems { + m.scrollY = m.selected - visibleItems + 1 + } + totalItems := len(m.builtinCmds) + len(m.userCmds) + maxScroll := totalItems - visibleItems + if maxScroll < 0 { + maxScroll = 0 + } + if m.scrollY > maxScroll { + m.scrollY = maxScroll + } +} + +func (m AllowlistModel) visibleItemCount() int { + if m.height <= 0 { + return 10 + } + available := m.height - 10 + if available < 4 { + return 4 + } + return available +} + +func (m AllowlistModel) View() string { + var b strings.Builder + + b.WriteString(m.styles.title.Render("Command Allowlist")) + b.WriteString("\n") + b.WriteString(m.styles.help.Render("Up/down: navigate | Ctrl+N: add | D: delete | Ctrl+S: save | Esc: close")) + b.WriteString("\n") + + if m.mode == allowlistModeAdd { + b.WriteString(m.styles.section.Render("--- Add Command ---")) + b.WriteString("\n") + b.WriteString(m.addInput.View()) + b.WriteString("\n") + if m.addErr != "" { + b.WriteString(m.styles.error.Render(m.addErr)) + b.WriteString("\n") + } + return b.String() + } + + totalItems := len(m.builtinCmds) + len(m.userCmds) + visibleStart := m.scrollY + visibleEnd := m.scrollY + m.visibleItemCount() + if visibleEnd > totalItems { + visibleEnd = totalItems + } + + b.WriteString(m.styles.section.Render("--- Builtin Commands ---")) + b.WriteString("\n") + + for i := visibleStart; i < visibleEnd && i < len(m.builtinCmds); i++ { + b.WriteString(m.renderCommandRow(i, false)) + } + + if len(m.userCmds) > 0 { + b.WriteString("\n") + b.WriteString(m.styles.section.Render("--- User Commands ---")) + b.WriteString("\n") + + userStart := len(m.builtinCmds) + for i := visibleStart; i < visibleEnd; i++ { + if i >= userStart { + userIdx := i - userStart + if userIdx < len(m.userCmds) { + b.WriteString(m.renderCommandRow(i, true)) + } + } + } + } + + totalFields := totalItems + scrollPct := 0 + if totalFields > m.visibleItemCount() { + scrollPct = (m.scrollY * 100) / (totalFields - m.visibleItemCount()) + } + + b.WriteString("\n") + scrollIndicator := fmt.Sprintf("Item %d/%d", m.selected+1, totalFields) + if totalFields > m.visibleItemCount() { + barWidth := 20 + filledWidth := (scrollPct * barWidth) / 100 + if filledWidth < 1 && m.scrollY > 0 { + filledWidth = 1 + } + scrollBar := strings.Repeat("#", filledWidth) + strings.Repeat(".", barWidth-filledWidth) + scrollIndicator += fmt.Sprintf(" [%s] %d%%", scrollBar, scrollPct) + } + b.WriteString(m.styles.help.Render(scrollIndicator)) + + return b.String() +} + +func (m AllowlistModel) renderCommandRow(idx int, isUser bool) string { + prefix := " " + style := m.styles.command + if idx == m.selected { + prefix = m.styles.indicator.Render("> ") + if isUser { + style = m.styles.userCommand.Bold(true) + } else { + style = m.styles.command.Bold(true) + } + } + + cmd := "" + if idx < len(m.builtinCmds) { + cmd = m.builtinCmds[idx] + subs, ok := m.subcommandRestrs[cmd] + if ok { + return fmt.Sprintf("%s%s %s\n", prefix, style.Render(cmd), m.styles.dimmed.Render("("+strings.Join(subs, ", ")+")")) + } + return fmt.Sprintf("%s%s %s\n", prefix, style.Render(cmd), m.styles.dimmed.Render("(all subcommands)")) + } + + userIdx := idx - len(m.builtinCmds) + if userIdx < len(m.userCmds) { + cmd = m.userCmds[userIdx] + delTag := "" + if isUser { + delTag = m.styles.dimmed.Render(" [D to delete]") + } + return fmt.Sprintf("%s%s%s\n", prefix, m.styles.userCommand.Render(cmd), delTag) + } + + return "" +} + +func (m AllowlistModel) GetConfig() *config.Config { + return m.cfg +} diff --git a/fluid-cli/internal/tui/allowlist_test.go b/fluid-cli/internal/tui/allowlist_test.go new file mode 100644 index 00000000..5c9fc855 --- /dev/null +++ b/fluid-cli/internal/tui/allowlist_test.go @@ -0,0 +1,166 @@ +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/aspectrr/fluid.sh/fluid-cli/internal/config" +) + +func TestNewAllowlistModel(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-cmd", "another-cmd"}, + } + m := NewAllowlistModel(cfg) + + if len(m.builtinCmds) == 0 { + t.Error("expected non-empty builtin commands") + } + if len(m.userCmds) != 2 { + t.Errorf("expected 2 user commands, got %d", len(m.userCmds)) + } + if m.mode != allowlistModeList { + t.Error("expected list mode") + } +} + +func TestAllowlistAdd(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{}, + } + m := NewAllowlistModel(cfg) + m.mode = allowlistModeAdd + m.addInput = textinput.New() + m.addInput.SetValue("new-cmd") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.mode != allowlistModeList { + t.Error("expected back to list mode after add") + } + if len(m.userCmds) != 1 { + t.Errorf("expected 1 user command, got %d", len(m.userCmds)) + } + if m.userCmds[0] != "new-cmd" { + t.Errorf("expected new-cmd, got %s", m.userCmds[0]) + } + if m.cfg.ExtraAllowedCommands[0] != "new-cmd" { + t.Errorf("expected config to have new-cmd, got %v", m.cfg.ExtraAllowedCommands) + } +} + +func TestAllowlistAdd_Duplicate(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{}, + } + m := NewAllowlistModel(cfg) + m.mode = allowlistModeAdd + m.addInput = textinput.New() + m.addInput.SetValue("cat") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.addErr == "" { + t.Error("expected error for duplicate command") + } + if len(m.userCmds) != 0 { + t.Error("expected no user commands added") + } +} + +func TestAllowlistAdd_Empty(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{}, + } + m := NewAllowlistModel(cfg) + m.mode = allowlistModeAdd + m.addInput = textinput.New() + m.addInput.SetValue("") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.addErr == "" { + t.Error("expected error for empty command") + } +} + +func TestAllowlistDelete_Builtin(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{}, + } + m := NewAllowlistModel(cfg) + m.selected = 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(AllowlistModel) + + if len(m.userCmds) != 0 { + t.Error("expected no change when deleting builtin") + } +} + +func TestAllowlistDelete_User(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-cmd"}, + } + m := NewAllowlistModel(cfg) + m.selected = len(m.builtinCmds) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(AllowlistModel) + + if len(m.userCmds) != 0 { + t.Error("expected user command to be deleted") + } + if len(m.cfg.ExtraAllowedCommands) != 0 { + t.Error("expected config to have no extra commands") + } +} + +func TestAllowlistClose_NoSave(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-cmd"}, + } + m := NewAllowlistModel(cfg) + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + _ = updated + + if cmd == nil { + t.Error("expected close message") + } + msg := <-func() chan tea.Msg { + ch := make(chan tea.Msg, 1) + go func() { + ch <- cmd() + }() + return ch + }() + + closeMsg, ok := msg.(AllowlistCloseMsg) + if !ok { + t.Error("expected AllowlistCloseMsg") + } + if closeMsg.Saved { + t.Error("expected saved=false") + } +} + +func TestAllowlistClose_Save(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-cmd"}, + } + m := NewAllowlistModel(cfg) + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + _ = updated + + if cmd == nil { + t.Error("expected close message") + } +} diff --git a/fluid-cli/internal/tui/messages.go b/fluid-cli/internal/tui/messages.go index 9347c2e6..576f94a9 100644 --- a/fluid-cli/internal/tui/messages.go +++ b/fluid-cli/internal/tui/messages.go @@ -252,6 +252,16 @@ type ConnectCloseMsg struct { Config config.SandboxHostConfig } +// AllowlistCloseMsg is sent when the allowlist screen is closed +type AllowlistCloseMsg struct { + Saved bool +} + +// RedactionCloseMsg is sent when the redaction screen is closed +type RedactionCloseMsg struct { + Saved bool +} + // SandboxServiceSwapResultMsg is sent when SetSandboxService completes asynchronously. type SandboxServiceSwapResultMsg struct { Svc sandbox.Service diff --git a/fluid-cli/internal/tui/model.go b/fluid-cli/internal/tui/model.go index 5684b496..82c9a6ab 100644 --- a/fluid-cli/internal/tui/model.go +++ b/fluid-cli/internal/tui/model.go @@ -80,6 +80,14 @@ type Model struct { settingsModel SettingsModel inSettings bool + // Allowlist screen + allowlistModel AllowlistModel + inAllowlist bool + + // Redaction screen + redactionModel RedactionModel + inRedaction bool + // Memory approval dialog confirmModel ConfirmModel inMemoryConfirm bool @@ -162,7 +170,8 @@ var allCommands = []commandSuggestion{ {"/context", "Show current context token usage"}, {"/connect", "Connect to a fluid daemon"}, {"/settings", "Open configuration settings"}, - {"/allowlist", "Show the read-only command allowlist"}, + {"/allowlist", "Show and edit read-only command allowlist"}, + {"/redaction", "Show and edit redaction patterns"}, {"/clear", "Clear conversation history"}, {"/help", "Show available commands"}, } @@ -360,6 +369,44 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Handle AllowlistCloseMsg + if closeMsg, ok := msg.(AllowlistCloseMsg); ok { + m.inAllowlist = false + m.state = StateIdle + if closeMsg.Saved { + m.cfg = m.allowlistModel.GetConfig() + if err := m.cfg.Save(m.configPath); err != nil { + m.addSystemMessage(fmt.Sprintf("Failed to save config: %v", err)) + } else { + m.addSystemMessage("Allowlist saved.") + } + } else { + m.addSystemMessage("Allowlist cancelled.") + } + m.updateViewportContent(false) + m.textarea.Focus() + return m, nil + } + + // Handle RedactionCloseMsg + if closeMsg, ok := msg.(RedactionCloseMsg); ok { + m.inRedaction = false + m.state = StateIdle + if closeMsg.Saved { + m.cfg = m.redactionModel.GetConfig() + if err := m.cfg.Save(m.configPath); err != nil { + m.addSystemMessage(fmt.Sprintf("Failed to save config: %v", err)) + } else { + m.addSystemMessage("Redaction patterns saved.") + } + } else { + m.addSystemMessage("Redaction cancelled.") + } + m.updateViewportContent(false) + m.textarea.Focus() + return m, nil + } + // Handle SandboxServiceSwapResultMsg (async result from SetSandboxService) if swapMsg, ok := msg.(SandboxServiceSwapResultMsg); ok { if swapMsg.Err != nil { @@ -387,6 +434,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + // If in allowlist mode, delegate to allowlist model + if m.inAllowlist { + var cmd tea.Cmd + allowlistModel, cmd := m.allowlistModel.Update(msg) + m.allowlistModel = allowlistModel.(AllowlistModel) + return m, cmd + } + + // If in redaction mode, delegate to redaction model + if m.inRedaction { + var cmd tea.Cmd + redactionModel, cmd := m.redactionModel.Update(msg) + m.redactionModel = redactionModel.(RedactionModel) + return m, cmd + } + // If in playbooks mode, delegate to playbooks model if m.inPlaybooks { var cmd tea.Cmd @@ -497,7 +560,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.MouseMsg: if !m.inSettings && !m.inPlaybooks && !m.inConnect && !m.inMemoryConfirm && - !m.inNetworkConfirm && !m.inSourcePrepareConfirm && !m.inCleanup { + !m.inNetworkConfirm && !m.inSourcePrepareConfirm && !m.inCleanup && + !m.inAllowlist && !m.inRedaction { switch msg.Button { case tea.MouseButtonWheelUp: m.viewport.ScrollUp(3) @@ -663,6 +727,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.settingsModel.Init() } + // Handle /allowlist command + if input == "/allowlist" || input == "allowlist" { + m.inAllowlist = true + m.allowlistModel = NewAllowlistModel(m.cfg) + if m.width > 0 && m.height > 0 { + allowlistModel, _ := m.allowlistModel.Update(tea.WindowSizeMsg{ + Width: m.width, + Height: m.height, + }) + m.allowlistModel = allowlistModel.(AllowlistModel) + } + return m, m.allowlistModel.Init() + } + + // Handle /redaction command + if input == "/redaction" || input == "redaction" { + m.inRedaction = true + m.redactionModel = NewRedactionModel(m.cfg) + if m.width > 0 && m.height > 0 { + redactionModel, _ := m.redactionModel.Update(tea.WindowSizeMsg{ + Width: m.width, + Height: m.height, + }) + m.redactionModel = redactionModel.(RedactionModel) + } + return m, m.redactionModel.Init() + } + // Handle /clear command if input == "/clear" || input == "clear" { m.conversation = make([]ConversationEntry, 0) @@ -1280,6 +1372,16 @@ func (m Model) View() string { return m.settingsModel.View() } + // Show allowlist screen if in allowlist mode + if m.inAllowlist { + return m.allowlistModel.View() + } + + // Show redaction screen if in redaction mode + if m.inRedaction { + return m.redactionModel.View() + } + // Show connect wizard if in connect mode if m.inConnect { return m.connectModel.View() diff --git a/fluid-cli/internal/tui/redaction.go b/fluid-cli/internal/tui/redaction.go new file mode 100644 index 00000000..c376b83f --- /dev/null +++ b/fluid-cli/internal/tui/redaction.go @@ -0,0 +1,534 @@ +package tui + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/aspectrr/fluid.sh/fluid-cli/internal/config" +) + +type builtinPatternEntry struct { + name string + category string + pattern string + example string +} + +var builtinPatterns = []builtinPatternEntry{ + {"IPv4 Address", "IP", `\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b`, "Server at 192.168.1.100 is up"}, + {"IPv6 Address", "IP", `(?i)\b(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\b`, "Address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334"}, + {"API Key", "KEY", `\bsk-[a-zA-Z0-9]{20,}\b`, "token=sk-proj-abc123def456ghi789jkl012mno345"}, + {"AWS Access Key", "KEY", `\bAKIA[0-9A-Z]{16}\b`, "AWS_KEY=AKIAIOSFODNN7EXAMPLE"}, + {"SSH Private Key", "KEY", `-----BEGIN [A-Z ]*PRIVATE KEY-----`, "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA..."}, + {"Connection String", "SECRET", `\b(postgres|mysql|mongodb|redis)://[^\s]+`, "db=postgres://user:pass@localhost:5432/mydb"}, +} + +type redactionMode int + +const ( + redactionModeList redactionMode = iota + redactionModeAdd +) + +type redactionStyles struct { + title lipgloss.Style + help lipgloss.Style + section lipgloss.Style + enabled lipgloss.Style + disabled lipgloss.Style + category lipgloss.Style + pattern lipgloss.Style + custom lipgloss.Style + dimmed lipgloss.Style + error lipgloss.Style + success lipgloss.Style + indicator lipgloss.Style + highlight lipgloss.Style + redacted lipgloss.Style + previewBox lipgloss.Style +} + +func defaultRedactionStyles() redactionStyles { + return redactionStyles{ + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3B82F6")), + help: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), + section: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#06B6D4")), + enabled: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), + disabled: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), + category: lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")), + pattern: lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")), + custom: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), + dimmed: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), + error: lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")), + success: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), + indicator: lipgloss.NewStyle().Foreground(lipgloss.Color("#3B82F6")), + highlight: lipgloss.NewStyle().Background(lipgloss.Color("#FEF3C7")).Foreground(lipgloss.Color("#92400E")), + redacted: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), + previewBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#F59E0B")).Padding(0, 1), + } +} + +type RedactionModel struct { + cfg *config.Config + width int + height int + styles redactionStyles + customPatterns []string + mode redactionMode + selected int + scrollY int + addFocused int + exampleInput textinput.Model + regexInput textinput.Model + addErr string + previewBefore string + previewAfter string + previewErr string +} + +func NewRedactionModel(cfg *config.Config) RedactionModel { + exampleInput := textinput.New() + exampleInput.Prompt = "Example: " + exampleInput.Placeholder = "Text with sensitive data..." + exampleInput.Focus() + + regexInput := textinput.New() + regexInput.Prompt = "Regex: " + regexInput.Placeholder = "e.g., \\bsecret-[a-z0-9]+\\b" + + m := RedactionModel{ + cfg: cfg, + styles: defaultRedactionStyles(), + customPatterns: make([]string, len(cfg.Redact.CustomPatterns)), + mode: redactionModeList, + selected: 0, + scrollY: 0, + addFocused: 0, + exampleInput: exampleInput, + regexInput: regexInput, + } + copy(m.customPatterns, cfg.Redact.CustomPatterns) + + return m +} + +func (m RedactionModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m RedactionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "esc": + if m.mode == redactionModeAdd { + m.mode = redactionModeList + m.exampleInput.SetValue("") + m.regexInput.SetValue("") + m.addErr = "" + m.previewBefore = "" + m.previewAfter = "" + m.previewErr = "" + return m, nil + } + return m, func() tea.Msg { return RedactionCloseMsg{Saved: false} } + + case "ctrl+s": + return m, func() tea.Msg { return RedactionCloseMsg{Saved: true} } + + case "up": + if m.mode == redactionModeList { + if m.selected > 0 { + m.selected-- + m.ensureVisible() + } + } + return m, nil + + case "down": + if m.mode == redactionModeList { + totalItems := len(builtinPatterns) + len(m.customPatterns) + 1 + if m.selected < totalItems-1 { + m.selected++ + m.ensureVisible() + } + } + return m, nil + + case " ": + if m.mode == redactionModeList && m.selected == 0 { + m.cfg.Redact.Enabled = !m.cfg.Redact.Enabled + } + return m, nil + + case "e": + if m.mode == redactionModeList && m.selected == 0 { + m.cfg.Redact.Enabled = !m.cfg.Redact.Enabled + } + return m, nil + + case "ctrl+n": + if m.mode == redactionModeList { + m.mode = redactionModeAdd + m.addFocused = 0 + m.exampleInput.Focus() + } + return m, nil + + case "enter": + if m.mode == redactionModeAdd { + regex := strings.TrimSpace(m.regexInput.Value()) + if regex == "" { + m.addErr = "regex cannot be empty" + return m, nil + } + if _, err := regexp.Compile(regex); err != nil { + m.addErr = fmt.Sprintf("invalid regex: %v", err) + return m, nil + } + for _, p := range m.customPatterns { + if p == regex { + m.addErr = "pattern already exists" + return m, nil + } + } + m.customPatterns = append(m.customPatterns, regex) + sort.Strings(m.customPatterns) + m.cfg.Redact.CustomPatterns = m.customPatterns + m.mode = redactionModeList + m.exampleInput.SetValue("") + m.regexInput.SetValue("") + m.addErr = "" + m.previewBefore = "" + m.previewAfter = "" + m.previewErr = "" + m.selected = 1 + len(m.customPatterns) - 1 + m.ensureVisible() + } + return m, nil + + case "tab": + if m.mode == redactionModeAdd { + if m.addFocused == 0 { + m.addFocused = 1 + m.exampleInput.Blur() + m.regexInput.Focus() + } else { + m.addFocused = 0 + m.regexInput.Blur() + m.exampleInput.Focus() + } + } + return m, nil + + case "shift+tab": + if m.mode == redactionModeAdd { + if m.addFocused == 0 { + m.addFocused = 1 + m.exampleInput.Blur() + m.regexInput.Focus() + } else { + m.addFocused = 0 + m.regexInput.Blur() + m.exampleInput.Focus() + } + } + return m, nil + + case "d": + if m.mode == redactionModeList { + totalBuiltins := 1 + len(builtinPatterns) + if m.selected >= totalBuiltins { + idx := m.selected - totalBuiltins + if idx < len(m.customPatterns) { + m.customPatterns = append(m.customPatterns[:idx], m.customPatterns[idx+1:]...) + m.cfg.Redact.CustomPatterns = m.customPatterns + totalItems := len(builtinPatterns) + len(m.customPatterns) + 1 + if m.selected >= totalItems && m.selected > 0 { + m.selected-- + } + m.ensureVisible() + } + } + } + return m, nil + } + } + + if m.mode == redactionModeAdd { + var cmd tea.Cmd + if m.addFocused == 0 { + m.exampleInput, cmd = m.exampleInput.Update(msg) + m.previewBefore = m.exampleInput.Value() + } else { + m.regexInput, cmd = m.regexInput.Update(msg) + } + cmds = append(cmds, cmd) + m.recomputePreview() + } + + return m, tea.Batch(cmds...) +} + +func (m *RedactionModel) recomputePreview() { + regex := m.regexInput.Value() + if regex == "" || m.previewBefore == "" { + m.previewAfter = "" + m.previewErr = "" + return + } + + re, err := regexp.Compile(regex) + if err != nil { + m.previewErr = fmt.Sprintf("invalid regex: %v", err) + m.previewAfter = "" + return + } + + m.previewErr = "" + matches := re.FindAllStringIndex(m.previewBefore, -1) + if len(matches) == 0 { + m.previewAfter = m.previewBefore + return + } + + m.previewAfter = renderReplaced(m.previewBefore, matches, m.styles.highlight) +} + +func (m *RedactionModel) ensureVisible() { + visibleItems := m.visibleItemCount() + totalItems := len(builtinPatterns) + len(m.customPatterns) + 1 + if m.selected < m.scrollY { + m.scrollY = m.selected + } + if m.selected >= m.scrollY+visibleItems { + m.scrollY = m.selected - visibleItems + 1 + } + maxScroll := totalItems - visibleItems + if maxScroll < 0 { + maxScroll = 0 + } + if m.scrollY > maxScroll { + m.scrollY = maxScroll + } +} + +func (m RedactionModel) visibleItemCount() int { + if m.height <= 0 { + return 10 + } + available := m.height - 12 + if available < 4 { + return 4 + } + return available +} + +func (m RedactionModel) View() string { + var b strings.Builder + + b.WriteString(m.styles.title.Render("Redaction Patterns")) + b.WriteString("\n") + b.WriteString(m.styles.help.Render("Up/down: navigate | Space/E: toggle | Ctrl+N: add | D: delete | Ctrl+S: save | Esc: close")) + b.WriteString("\n") + + if m.mode == redactionModeAdd { + b.WriteString(m.styles.section.Render("--- Add Custom Pattern ---")) + b.WriteString("\n") + + if m.addFocused == 0 { + b.WriteString(m.styles.indicator.Render("> ")) + b.WriteString(m.exampleInput.View()) + } else { + b.WriteString(" ") + b.WriteString(m.exampleInput.View()) + } + b.WriteString("\n") + + if m.addFocused == 1 { + b.WriteString(m.styles.indicator.Render("> ")) + b.WriteString(m.regexInput.View()) + } else { + b.WriteString(" ") + b.WriteString(m.regexInput.View()) + } + b.WriteString("\n") + + if m.addErr != "" { + b.WriteString(m.styles.error.Render(m.addErr)) + b.WriteString("\n") + } + + if m.previewBefore != "" && m.previewErr == "" { + b.WriteString("\n") + b.WriteString(m.styles.section.Render("--- Live Preview ---")) + b.WriteString("\n") + + b.WriteString(m.styles.dimmed.Render("Before: ")) + b.WriteString(m.previewBefore) + b.WriteString("\n") + + b.WriteString(m.styles.dimmed.Render("After: ")) + b.WriteString(m.previewAfter) + b.WriteString("\n") + } + + return b.String() + } + + b.WriteString("\n") + enabledStr := "Disabled" + enabledStyle := m.styles.disabled + if m.cfg.Redact.Enabled { + enabledStr = "Enabled" + enabledStyle = m.styles.enabled + } + + prefix := " " + if m.selected == 0 { + prefix = m.styles.indicator.Render("> ") + } + b.WriteString(fmt.Sprintf("%sRedaction: %s (Space/E to toggle)\n", prefix, enabledStyle.Render(enabledStr))) + + totalItems := len(builtinPatterns) + len(m.customPatterns) + 1 + visibleStart := m.scrollY + visibleEnd := m.scrollY + m.visibleItemCount() + if visibleEnd > totalItems { + visibleEnd = totalItems + } + + b.WriteString("\n") + b.WriteString(m.styles.section.Render("--- Builtin Patterns ---")) + b.WriteString("\n") + + builtinStart := 1 + for i := visibleStart; i < visibleEnd && i < builtinStart+len(builtinPatterns); i++ { + if i >= builtinStart { + idx := i - builtinStart + b.WriteString(m.renderBuiltinRow(i, builtinPatterns[idx])) + } + } + + if len(m.customPatterns) > 0 { + b.WriteString("\n") + b.WriteString(m.styles.section.Render("--- Custom Patterns ---")) + b.WriteString("\n") + + customStart := builtinStart + len(builtinPatterns) + for i := visibleStart; i < visibleEnd; i++ { + if i >= customStart { + idx := i - customStart + if idx < len(m.customPatterns) { + b.WriteString(m.renderCustomRow(i, m.customPatterns[idx])) + } + } + } + } + + totalFields := totalItems + scrollPct := 0 + if totalFields > m.visibleItemCount() { + scrollPct = (m.scrollY * 100) / (totalFields - m.visibleItemCount()) + } + + b.WriteString("\n") + scrollIndicator := fmt.Sprintf("Item %d/%d", m.selected+1, totalFields) + if totalFields > m.visibleItemCount() { + barWidth := 20 + filledWidth := (scrollPct * barWidth) / 100 + if filledWidth < 1 && m.scrollY > 0 { + filledWidth = 1 + } + scrollBar := strings.Repeat("#", filledWidth) + strings.Repeat(".", barWidth-filledWidth) + scrollIndicator += fmt.Sprintf(" [%s] %d%%", scrollBar, scrollPct) + } + b.WriteString(m.styles.help.Render(scrollIndicator)) + + return b.String() +} + +func (m RedactionModel) renderBuiltinRow(idx int, p builtinPatternEntry) string { + prefix := " " + style := m.styles.pattern + if idx == m.selected { + prefix = m.styles.indicator.Render("> ") + style = m.styles.pattern.Bold(true) + } + + highlighted := renderHighlighted(p.example, m.styles.highlight) + return fmt.Sprintf("%s%s %s %s\n%s Example: %s\n", + prefix, + m.styles.category.Render("["+p.category+"]"), + style.Render(p.name), + m.styles.dimmed.Render(p.pattern), + prefix, + highlighted, + ) +} + +func (m RedactionModel) renderCustomRow(idx int, pattern string) string { + prefix := " " + style := m.styles.custom + if idx == m.selected { + prefix = m.styles.indicator.Render("> ") + style = m.styles.custom.Bold(true) + } + delTag := m.styles.dimmed.Render(" [D to delete]") + return fmt.Sprintf("%s%s %s%s\n", prefix, style.Render("Custom"), m.styles.dimmed.Render(pattern), delTag) +} + +func renderHighlighted(text string, style lipgloss.Style) string { + re := regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|` + + `\b[A-Za-z0-9+/]{20,}=*\b|` + + `\bAKIA[0-9A-Z]{16}\b|` + + `-----BEGIN [A-Z ]+PRIVATE KEY-----|` + + `\b(postgres|mysql|mongodb|redis)://[^\s]+`) + locs := re.FindAllStringIndex(text, -1) + if len(locs) == 0 { + return text + } + return renderHighlightedLocs(text, locs, style) +} + +func renderHighlightedLocs(text string, locs [][]int, style lipgloss.Style) string { + var b strings.Builder + lastEnd := 0 + for _, loc := range locs { + b.WriteString(text[lastEnd:loc[0]]) + b.WriteString(style.Render(text[loc[0]:loc[1]])) + lastEnd = loc[1] + } + b.WriteString(text[lastEnd:]) + return b.String() +} + +func renderReplaced(text string, locs [][]int, style lipgloss.Style) string { + var b strings.Builder + lastEnd := 0 + redactNum := 1 + for _, loc := range locs { + b.WriteString(text[lastEnd:loc[0]]) + b.WriteString(style.Render(fmt.Sprintf("[REDACTED_%d]", redactNum))) + redactNum++ + lastEnd = loc[1] + } + b.WriteString(text[lastEnd:]) + return b.String() +} + +func (m RedactionModel) GetConfig() *config.Config { + return m.cfg +} diff --git a/fluid-cli/internal/tui/redaction_test.go b/fluid-cli/internal/tui/redaction_test.go new file mode 100644 index 00000000..f6da7158 --- /dev/null +++ b/fluid-cli/internal/tui/redaction_test.go @@ -0,0 +1,210 @@ +package tui + +import ( + "regexp" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/aspectrr/fluid.sh/fluid-cli/internal/config" +) + +func TestNewRedactionModel(t *testing.T) { + cfg := &config.Config{ + Redact: config.RedactConfig{ + Enabled: true, + CustomPatterns: []string{"custom-pattern", "another-pattern"}, + }, + } + m := NewRedactionModel(cfg) + + if !m.cfg.Redact.Enabled { + t.Error("expected redaction enabled") + } + if len(m.customPatterns) != 2 { + t.Errorf("expected 2 custom patterns, got %d", len(m.customPatterns)) + } + if m.mode != redactionModeList { + t.Error("expected list mode") + } +} + +func TestRedactionAdd_ValidRegex(t *testing.T) { + cfg := &config.Config{ + Redact: config.RedactConfig{ + Enabled: true, + CustomPatterns: []string{}, + }, + } + m := NewRedactionModel(cfg) + m.mode = redactionModeAdd + m.regexInput = textinput.New() + m.regexInput.SetValue(`\b\d{3}-\d{4}\b`) + m.exampleInput = textinput.New() + m.exampleInput.SetValue("Call 555-1234 for help") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(RedactionModel) + + if m.mode != redactionModeList { + t.Error("expected back to list mode after add") + } + if len(m.customPatterns) != 1 { + t.Errorf("expected 1 custom pattern, got %d", len(m.customPatterns)) + } + if m.cfg.Redact.CustomPatterns[0] != `\b\d{3}-\d{4}\b` { + t.Errorf("expected custom pattern, got %v", m.cfg.Redact.CustomPatterns) + } +} + +func TestRedactionAdd_InvalidRegex(t *testing.T) { + cfg := &config.Config{ + Redact: config.RedactConfig{ + Enabled: true, + CustomPatterns: []string{}, + }, + } + m := NewRedactionModel(cfg) + m.mode = redactionModeAdd + m.regexInput = textinput.New() + m.regexInput.SetValue(`[invalid`) + m.exampleInput = textinput.New() + m.exampleInput.SetValue("some text") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(RedactionModel) + + if m.addErr == "" { + t.Error("expected error for invalid regex") + } + if len(m.customPatterns) != 0 { + t.Error("expected no patterns added") + } +} + +func TestRedactionDelete_Builtin(t *testing.T) { + cfg := &config.Config{ + Redact: config.RedactConfig{ + Enabled: true, + CustomPatterns: []string{}, + }, + } + m := NewRedactionModel(cfg) + m.selected = 1 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(RedactionModel) + + if len(m.customPatterns) != 0 { + t.Error("expected no change when deleting builtin") + } +} + +func TestRedactionDelete_Custom(t *testing.T) { + cfg := &config.Config{ + Redact: config.RedactConfig{ + Enabled: true, + CustomPatterns: []string{"custom-pattern"}, + }, + } + m := NewRedactionModel(cfg) + m.selected = 1 + len(builtinPatterns) + 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(RedactionModel) + + if len(m.customPatterns) != 0 { + t.Logf("customPatterns: %v", m.customPatterns) + t.Logf("cfg.CustomPatterns: %v", m.cfg.Redact.CustomPatterns) + t.Logf("selected: %d, builtinPatterns: %d", m.selected, len(builtinPatterns)) + t.Error("expected custom pattern to be deleted") + } + if len(m.cfg.Redact.CustomPatterns) != 0 { + t.Error("expected config to have no custom patterns") + } +} + +func TestRedactionToggle(t *testing.T) { + cfg := &config.Config{ + Redact: config.RedactConfig{ + Enabled: false, + CustomPatterns: []string{}, + }, + } + m := NewRedactionModel(cfg) + m.selected = 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace}) + m = updated.(RedactionModel) + + if !m.cfg.Redact.Enabled { + t.Error("expected redaction to be enabled after toggle") + } +} + +func TestRedactionPreview(t *testing.T) { + cfg := &config.Config{ + Redact: config.RedactConfig{ + Enabled: true, + CustomPatterns: []string{}, + }, + } + m := NewRedactionModel(cfg) + m.mode = redactionModeAdd + m.regexInput.SetValue(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`) + m.exampleInput.SetValue("Server at 192.168.1.100 is up") + m.previewBefore = m.exampleInput.Value() + + m.recomputePreview() + + if m.previewAfter == "" { + t.Error("expected preview after redaction") + } + if m.previewErr != "" { + t.Errorf("unexpected preview error: %s", m.previewErr) + } +} + +func TestRedactionClose_NoSave(t *testing.T) { + cfg := &config.Config{ + Redact: config.RedactConfig{ + Enabled: true, + CustomPatterns: []string{"custom-pattern"}, + }, + } + m := NewRedactionModel(cfg) + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + _ = updated + + if cmd == nil { + t.Error("expected close message") + } +} + +func TestRenderHighlighted(t *testing.T) { + text := "Server at 192.168.1.100 is up" + re := regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|` + + `\b[A-Za-z0-9+/]{20,}=*\b|` + + `\bAKIA[0-9A-Z]{16}\b|` + + `-----BEGIN [A-Z ]+PRIVATE KEY-----|` + + `\b(postgres|mysql|mongodb|redis)://[^\s]+`) + locs := re.FindAllStringIndex(text, -1) + if len(locs) != 1 { + t.Fatalf("expected 1 match, got %d", len(locs)) + } + if locs[0][0] != 10 || locs[0][1] != 23 { + t.Errorf("expected match at [10,23], got %v", locs[0]) + } +} + +func TestRenderReplaced(t *testing.T) { + text := "Server at 192.168.1.100 is up" + locs := [][]int{{12, 25}} + result := renderReplaced(text, locs, defaultRedactionStyles().highlight) + + if result == text { + t.Error("expected redaction to change the text") + } +} diff --git a/fluid-cli/internal/tui/settings.go b/fluid-cli/internal/tui/settings.go index f34008ba..50ccf39f 100644 --- a/fluid-cli/internal/tui/settings.go +++ b/fluid-cli/internal/tui/settings.go @@ -49,19 +49,11 @@ const ( // Telemetry FieldTelemetryEnabled - // Redaction - FieldRedactEnabled - FieldRedactCustomPatterns - FieldRedactAllowlist - // Audit FieldAuditEnabled FieldAuditLogPath FieldAuditMaxSizeMB - // Allowlist - FieldExtraAllowedCommands - StaticFieldCount ) @@ -105,12 +97,8 @@ func NewSettingsModel(cfg *config.Config, configPath string) SettingsModel { "Log Level:", "Log Format:", // Telemetry "Enable Anonymous Usage:", - // Redaction - "Redaction Enabled:", "Custom Patterns:", "Allowlist:", // Audit "Audit Enabled:", "Log Path:", "Max Size (MB):", - // Allowlist - "Extra Allowed Commands:", } staticSections := []string{ @@ -125,12 +113,8 @@ func NewSettingsModel(cfg *config.Config, configPath string) SettingsModel { "Logging", "Logging", // Telemetry "Telemetry", - // Redaction - "Redaction", "Redaction", "Redaction", // Audit "Audit", "Audit", "Audit", - // Allowlist - "Allowlist", } for i := range StaticFieldCount { @@ -200,21 +184,12 @@ func (m SettingsModel) getStaticConfigValue(field StaticSettingsField) string { case FieldTelemetryEnabled: return strconv.FormatBool(m.cfg.Telemetry.EnableAnonymousUsage) - case FieldRedactEnabled: - return strconv.FormatBool(m.cfg.Redact.Enabled) - case FieldRedactCustomPatterns: - return strings.Join(m.cfg.Redact.CustomPatterns, ",") - case FieldRedactAllowlist: - return strings.Join(m.cfg.Redact.Allowlist, ",") - case FieldAuditEnabled: return strconv.FormatBool(m.cfg.Audit.Enabled) case FieldAuditLogPath: return m.cfg.Audit.LogPath case FieldAuditMaxSizeMB: return strconv.Itoa(m.cfg.Audit.MaxSizeMB) - case FieldExtraAllowedCommands: - return strings.Join(m.cfg.ExtraAllowedCommands, ",") } return "" } @@ -489,31 +464,12 @@ func (m *SettingsModel) saveConfig() error { // Telemetry m.cfg.Telemetry.EnableAnonymousUsage = getStatic(FieldTelemetryEnabled) == "true" - // Redaction - m.cfg.Redact.Enabled = getStatic(FieldRedactEnabled) == "true" - if patterns := getStatic(FieldRedactCustomPatterns); patterns != "" { - m.cfg.Redact.CustomPatterns = strings.Split(patterns, ",") - } else { - m.cfg.Redact.CustomPatterns = nil - } - if allowlist := getStatic(FieldRedactAllowlist); allowlist != "" { - m.cfg.Redact.Allowlist = strings.Split(allowlist, ",") - } else { - m.cfg.Redact.Allowlist = nil - } - // Audit m.cfg.Audit.Enabled = getStatic(FieldAuditEnabled) == "true" m.cfg.Audit.LogPath = getStatic(FieldAuditLogPath) if v, err := strconv.Atoi(getStatic(FieldAuditMaxSizeMB)); err == nil { m.cfg.Audit.MaxSizeMB = v } - // Allowlist - if cmds := getStatic(FieldExtraAllowedCommands); cmds != "" { - m.cfg.ExtraAllowedCommands = strings.Split(cmds, ",") - } else { - m.cfg.ExtraAllowedCommands = nil - } // Ensure config directory exists configDir := filepath.Dir(m.configPath) diff --git a/shared/readonly/validate.go b/shared/readonly/validate.go index 8e2f5be3..a5c2099b 100644 --- a/shared/readonly/validate.go +++ b/shared/readonly/validate.go @@ -447,6 +447,15 @@ func AllowedCommandsListMap() map[string]bool { return result } +// SubcommandRestrictions returns a map of commands to their allowed subcommands. +func SubcommandRestrictions() map[string]map[string]bool { + result := make(map[string]map[string]bool) + for k, v := range subcommandRestrictions { + result[k] = v + } + return result +} + // ValidateCommandWithExtra checks that every command in a pipeline is allowed, // using both the default allowlist and extra user-configured commands. func ValidateCommandWithExtra(command string, extraAllowed []string) error { From b1d21107ed91fa2816f3437c13fc8264d9d90676 Mon Sep 17 00:00:00 2001 From: aspectrr Date: Sat, 21 Mar 2026 11:58:33 -0400 Subject: [PATCH 02/31] feat: big update, make sandboxing work without kvm access and run_commands make sense --- fluid-cli/cmd/fluid/main.go | 91 ++- fluid-cli/internal/config/config.go | 61 +- fluid-cli/internal/config/config_test.go | 25 + fluid-cli/internal/doctor/checks.go | 2 +- fluid-cli/internal/hostexec/hostexec.go | 2 + fluid-cli/internal/llm/tools.go | 28 +- fluid-cli/internal/llm/tools_test.go | 2 +- fluid-cli/internal/mcp/handlers.go | 63 +- fluid-cli/internal/mcp/handlers_test.go | 33 +- fluid-cli/internal/mcp/server.go | 7 +- fluid-cli/internal/readonly/prepare.go | 77 +++ fluid-cli/internal/readonly/prepare_test.go | 183 +++++ fluid-cli/internal/sandbox/noop.go | 12 + fluid-cli/internal/sandbox/remote.go | 181 +++-- fluid-cli/internal/sandbox/remote_test.go | 258 +++++++ fluid-cli/internal/sandbox/service.go | 3 + fluid-cli/internal/sandbox/types.go | 41 +- fluid-cli/internal/tui/agent.go | 415 +++++++----- fluid-cli/internal/tui/agent_test.go | 287 +++++++- fluid-cli/internal/tui/allowlist.go | 431 +++++++++++- fluid-cli/internal/tui/allowlist_test.go | 238 +++++++ fluid-cli/internal/tui/connect.go | 247 ++++++- fluid-cli/internal/tui/connect_test.go | 84 +++ fluid-cli/internal/tui/messages.go | 50 +- fluid-cli/internal/tui/model.go | 227 ++++++- fluid-cli/internal/tui/model_test.go | 373 +++++++++++ fluid-daemon/cmd/fluid-daemon/main.go | 34 +- fluid-daemon/go.mod | 9 + fluid-daemon/go.sum | 27 + fluid-daemon/internal/agent/client.go | 2 +- fluid-daemon/internal/config/config.go | 31 +- fluid-daemon/internal/config/config_test.go | 4 +- fluid-daemon/internal/daemon/doctor.go | 201 ++++++ fluid-daemon/internal/daemon/doctor_test.go | 190 ++++++ fluid-daemon/internal/daemon/helpers.go | 66 ++ fluid-daemon/internal/daemon/helpers_test.go | 109 +++ fluid-daemon/internal/daemon/readiness.go | 123 ++++ fluid-daemon/internal/daemon/server.go | 481 ++++++++++++- .../daemon/server_create_stream_test.go | 286 ++++++++ fluid-daemon/internal/image/extract.go | 211 +----- fluid-daemon/internal/image/extract_test.go | 150 +---- fluid-daemon/internal/image/store.go | 33 +- fluid-daemon/internal/image/store_test.go | 34 - fluid-daemon/internal/microvm/cloudinit.go | 138 ++++ .../internal/microvm/cloudinit_test.go | 148 ++++ fluid-daemon/internal/microvm/manager.go | 19 +- fluid-daemon/internal/network/bridge.go | 108 +-- fluid-daemon/internal/network/bridge_test.go | 8 +- .../provider/microvm/microvm_provider.go | 313 ++++++++- .../provider/microvm/microvm_provider_test.go | 88 +++ fluid-daemon/internal/snapshotpull/backend.go | 6 +- .../internal/snapshotpull/libvirt_backend.go | 300 ++------- .../snapshotpull/libvirt_backend_test.go | 247 ++----- fluid-daemon/internal/snapshotpull/puller.go | 47 +- .../internal/snapshotpull/puller_test.go | 52 +- fluid-daemon/packaging/fluid-daemon.service | 1 + go.work.sum | 2 +- proto/fluid/v1/daemon.proto | 51 ++ proto/fluid/v1/sandbox.proto | 11 + proto/fluid/v1/source.proto | 1 + proto/gen/go/fluid/v1/daemon.pb.go | 633 +++++++++++++++--- proto/gen/go/fluid/v1/daemon_grpc.pb.go | 156 ++++- proto/gen/go/fluid/v1/sandbox.pb.go | 125 +++- proto/gen/go/fluid/v1/source.pb.go | 13 +- scripts/nginx-cert-typo.sh | 182 +++++ .../components/docs/daemon-setup-steps.tsx | 1 + web/src/routes/docs/source-prepare.tsx | 100 ++- 67 files changed, 6545 insertions(+), 1617 deletions(-) create mode 100644 fluid-cli/internal/sandbox/remote_test.go create mode 100644 fluid-cli/internal/tui/model_test.go create mode 100644 fluid-daemon/internal/daemon/doctor.go create mode 100644 fluid-daemon/internal/daemon/doctor_test.go create mode 100644 fluid-daemon/internal/daemon/helpers_test.go create mode 100644 fluid-daemon/internal/daemon/readiness.go create mode 100644 fluid-daemon/internal/daemon/server_create_stream_test.go create mode 100644 fluid-daemon/internal/microvm/cloudinit.go create mode 100644 fluid-daemon/internal/microvm/cloudinit_test.go create mode 100644 fluid-daemon/internal/provider/microvm/microvm_provider_test.go create mode 100755 scripts/nginx-cert-typo.sh diff --git a/fluid-cli/cmd/fluid/main.go b/fluid-cli/cmd/fluid/main.go index 6fe3b761..571613bb 100644 --- a/fluid-cli/cmd/fluid/main.go +++ b/fluid-cli/cmd/fluid/main.go @@ -19,7 +19,6 @@ import ( "github.com/aspectrr/fluid.sh/fluid-cli/internal/doctor" "github.com/aspectrr/fluid.sh/fluid-cli/internal/hostexec" fluidmcp "github.com/aspectrr/fluid.sh/fluid-cli/internal/mcp" - "github.com/aspectrr/fluid.sh/fluid-cli/internal/netutil" "github.com/aspectrr/fluid.sh/fluid-cli/internal/paths" "github.com/aspectrr/fluid.sh/fluid-cli/internal/readonly" "github.com/aspectrr/fluid.sh/fluid-cli/internal/redact" @@ -208,7 +207,8 @@ var connectCmd = &cobra.Command{ name, _ := cmd.Flags().GetString("name") insecure, _ := cmd.Flags().GetBool("insecure") skipSave, _ := cmd.Flags().GetBool("no-save") - return runConnect(args[0], name, insecure, skipSave) + sshUser, _ := cmd.Flags().GetString("ssh-user") + return runConnect(args[0], name, insecure, skipSave, sshUser) }, } @@ -249,6 +249,7 @@ func init() { connectCmd.Flags().String("name", "", "display name for this daemon (default: hostname from daemon)") connectCmd.Flags().Bool("insecure", false, "skip TLS verification (INSECURE: use only for local/dev daemons)") connectCmd.Flags().Bool("no-save", false, "test connection without saving to config") + connectCmd.Flags().String("ssh-user", "", "SSH user for doctor checks (default: from SSH config)") sourceCmd.AddCommand(sourcePrepareCmd) sourceCmd.AddCommand(sourceListCmd) @@ -359,6 +360,20 @@ func runSourcePrepare(hostname string) error { return fmt.Errorf("saving config after prepare: %w", err) } + // 5. Deploy daemon identity key if available + identityPubKey := config.DaemonIdentityPubKey(loadedCfg.SandboxHosts) + if identityPubKey != "" { + fmt.Printf(" Deploying daemon SSH key to %s...\n", hostname) + deployCtx, deployCancel := context.WithTimeout(context.Background(), 30*time.Second) + deployErr := readonly.DeployDaemonKey(deployCtx, sshRun, identityPubKey, logger) + deployCancel() + if deployErr != nil { + fmt.Printf(" %s Daemon key deploy: %v\n", red("[warning]"), deployErr) + } else { + fmt.Printf(" %s Daemon SSH key deployed\n", green("[ok]")) + } + } + fmt.Println() fmt.Printf(" %s Host %q is ready for read-only access.\n", green("[done]"), hostname) fmt.Printf(" Run `fluid` to start the agent and inspect this host.\n") @@ -366,7 +381,7 @@ func runSourcePrepare(hostname string) error { } // runConnect tests a daemon connection, runs doctor checks, and saves config. -func runConnect(addr, name string, insecure, skipSave bool) error { +func runConnect(addr, name string, insecure, skipSave bool, sshUser string) error { // Append default gRPC port if not specified if _, _, err := net.SplitHostPort(addr); err != nil { addr = net.JoinHostPort(addr, "9091") @@ -398,7 +413,7 @@ func runConnect(addr, name string, insecure, skipSave bool) error { DaemonAddress: addr, DaemonInsecure: insecure, } - svc, err := sandbox.NewRemoteService(addr, cpCfg, loadedCfg.Hosts) + svc, err := sandbox.NewRemoteService(addr, cpCfg) if err != nil { fmt.Printf(" %s Failed to dial: %v\n", red("[error]"), err) return err @@ -434,23 +449,25 @@ func runConnect(addr, name string, insecure, skipSave bool) error { fmt.Printf(" Images: %d available\n", len(info.BaseImages)) fmt.Println() - // 4. Doctor checks via SSH (skip for localhost) - host, _, splitErr := net.SplitHostPort(addr) - if splitErr != nil { - host = addr - } - isLocal := netutil.IsLocalHost(host) - - if !isLocal { - fmt.Printf(" Running doctor checks on %s...\n\n", host) - run := hostexec.NewSSHAlias(host) - doctorCtx, doctorCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer doctorCancel() - results := doctor.RunAll(doctorCtx, run) - doctor.PrintResults(results, os.Stdout, useColor) - fmt.Println() + // 4. Doctor checks via gRPC + fmt.Printf(" Running doctor checks...\n\n") + doctorCtx, doctorCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer doctorCancel() + checkResults, doctorErr := svc.DoctorCheck(doctorCtx) + if doctorErr != nil { + fmt.Printf(" %s Doctor checks failed: %v\n\n", red("[error]"), doctorErr) } else { - fmt.Println(dim(" Doctor checks skipped (localhost)")) + doctorResults := make([]doctor.CheckResult, len(checkResults)) + for i, r := range checkResults { + doctorResults[i] = doctor.CheckResult{ + Name: r.Name, + Category: r.Category, + Passed: r.Passed, + Message: r.Message, + FixCmd: r.FixCmd, + } + } + doctor.PrintResults(doctorResults, os.Stdout, useColor) fmt.Println() } @@ -470,9 +487,11 @@ func runConnect(addr, name string, insecure, skipSave bool) error { } entry := config.SandboxHostConfig{ - Name: name, - DaemonAddress: addr, - Insecure: insecure, + Name: name, + DaemonAddress: addr, + Insecure: insecure, + SSHUser: sshUser, + DaemonIdentityPubKey: info.SSHIdentityPubKey, } loadedCfg.SandboxHosts = config.UpsertSandboxHost(loadedCfg.SandboxHosts, entry) @@ -482,6 +501,28 @@ func runConnect(addr, name string, insecure, skipSave bool) error { return err } fmt.Printf(" %s Saved %q (%s) to config\n", green("[ok]"), name, addr) + + // Deploy daemon identity key to all prepared source hosts + if info.SSHIdentityPubKey != "" { + preparedHosts := loadedCfg.PreparedHosts() + if len(preparedHosts) > 0 { + fmt.Println() + fmt.Println(" Deploying daemon SSH key to prepared hosts...") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + for _, h := range preparedHosts { + sshRunFn := hostexec.NewSSHAlias(h.Name) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := readonly.DeployDaemonKey(ctx, readonly.SSHRunFunc(sshRunFn), info.SSHIdentityPubKey, logger) + cancel() + if err != nil { + fmt.Printf(" %s %s: %v\n", dim("[skip]"), h.Name, err) + } else { + fmt.Printf(" %s %s\n", green("[ok]"), h.Name) + } + } + } + } + fmt.Println() return nil } @@ -695,7 +736,7 @@ func runTUI() error { agent := tui.NewFluidAgent(cfg, core.store, svc, core.source, core.telemetry, core.redactor, core.auditLog, fileLogger) - model := tui.NewModel("fluid", "daemon", "vm-agent", agent, cfg, configPath) + model := tui.NewModel("fluid", "daemon", "vm-agent", agent, cfg, configPath, fileLogger) return tui.Run(model) } @@ -803,7 +844,7 @@ func initSandboxService(loadedCfg *config.Config, logger *slog.Logger) sandbox.S DaemonAddress: sh.DaemonAddress, DaemonInsecure: sh.Insecure, DaemonCAFile: sh.CAFile, - }, loadedCfg.Hosts) + }) if err != nil { logger.Warn("failed to connect to sandbox daemon, falling back to noop", "address", sh.DaemonAddress, "error", err) return sandbox.NewNoopService() diff --git a/fluid-cli/internal/config/config.go b/fluid-cli/internal/config/config.go index b06ed1f1..aa9e8bc1 100644 --- a/fluid-cli/internal/config/config.go +++ b/fluid-cli/internal/config/config.go @@ -14,33 +14,48 @@ import ( // Config is the root configuration for virsh-sandbox API. type Config struct { - Provider string `yaml:"provider"` // "libvirt" (default), "proxmox", or "control-plane" - Libvirt LibvirtConfig `yaml:"libvirt"` - Proxmox ProxmoxConfig `yaml:"proxmox"` - ControlPlane ControlPlaneConfig `yaml:"control_plane"` - VM VMConfig `yaml:"vm"` - SSH SSHConfig `yaml:"ssh"` - Ansible AnsibleConfig `yaml:"ansible"` - Logging LoggingConfig `yaml:"logging"` - Telemetry TelemetryConfig `yaml:"telemetry"` - AIAgent AIAgentConfig `yaml:"ai_agent"` - Hosts []HostConfig `yaml:"hosts"` // Source hosts for read-only SSH access - SandboxHosts []SandboxHostConfig `yaml:"sandbox_hosts"` // Daemon hosts for sandbox operations - Redact RedactConfig `yaml:"redact"` - Audit AuditConfig `yaml:"audit"` - ExtraAllowedCommands []string `yaml:"extra_allowed_commands"` // Additional commands allowed in read-only mode - OnboardingComplete bool `yaml:"onboarding_complete"` // Whether onboarding wizard has been completed - DocsSessionCode string `yaml:"docs_session_code,omitempty"` // Persisted for cross-session docs progress tracking - APIURL string `yaml:"api_url,omitempty"` // Control plane API base URL - WebURL string `yaml:"web_url,omitempty"` // Web dashboard base URL + Provider string `yaml:"provider"` // "libvirt" (default), "proxmox", or "control-plane" + Libvirt LibvirtConfig `yaml:"libvirt"` + Proxmox ProxmoxConfig `yaml:"proxmox"` + ControlPlane ControlPlaneConfig `yaml:"control_plane"` + VM VMConfig `yaml:"vm"` + SSH SSHConfig `yaml:"ssh"` + Ansible AnsibleConfig `yaml:"ansible"` + Logging LoggingConfig `yaml:"logging"` + Telemetry TelemetryConfig `yaml:"telemetry"` + AIAgent AIAgentConfig `yaml:"ai_agent"` + Hosts []HostConfig `yaml:"hosts"` // Source hosts for read-only SSH access + SandboxHosts []SandboxHostConfig `yaml:"sandbox_hosts"` // Daemon hosts for sandbox operations + Redact RedactConfig `yaml:"redact"` + Audit AuditConfig `yaml:"audit"` + ExtraAllowedCommands []string `yaml:"extra_allowed_commands"` // Additional commands allowed in read-only mode + ExtraAllowedSubcommands map[string][]string `yaml:"extra_allowed_subcommands"` // Additional subcommands allowed for specific commands + ExtraAllowedSubcommandsMode map[string]bool `yaml:"extra_allowed_subcommands_mode"` // true = allowlist (block all except), false = blocklist (allow all except) + OnboardingComplete bool `yaml:"onboarding_complete"` // Whether onboarding wizard has been completed + DocsSessionCode string `yaml:"docs_session_code,omitempty"` // Persisted for cross-session docs progress tracking + APIURL string `yaml:"api_url,omitempty"` // Control plane API base URL + WebURL string `yaml:"web_url,omitempty"` // Web dashboard base URL } // SandboxHostConfig configures a remote host running fluid-daemon for sandbox operations. type SandboxHostConfig struct { - Name string `yaml:"name"` - DaemonAddress string `yaml:"daemon_address"` - Insecure bool `yaml:"insecure"` - CAFile string `yaml:"ca_file"` + Name string `yaml:"name"` + DaemonAddress string `yaml:"daemon_address"` + Insecure bool `yaml:"insecure"` + CAFile string `yaml:"ca_file"` + SSHUser string `yaml:"ssh_user"` + DaemonIdentityPubKey string `yaml:"daemon_identity_pub_key,omitempty"` +} + +// DaemonIdentityPubKey returns the first non-empty daemon identity pub key +// from the configured sandbox hosts. +func DaemonIdentityPubKey(hosts []SandboxHostConfig) string { + for _, h := range hosts { + if h.DaemonIdentityPubKey != "" { + return h.DaemonIdentityPubKey + } + } + return "" } // RedactConfig controls PII/sensitive data redaction before LLM calls. diff --git a/fluid-cli/internal/config/config_test.go b/fluid-cli/internal/config/config_test.go index d716debb..8ddd60b1 100644 --- a/fluid-cli/internal/config/config_test.go +++ b/fluid-cli/internal/config/config_test.go @@ -450,6 +450,31 @@ func TestLoadWithEnvOverride_SecureFileNoWarnings(t *testing.T) { assert.Empty(t, warnings) } +func TestDaemonIdentityPubKey(t *testing.T) { + tests := []struct { + name string + hosts []SandboxHostConfig + expect string + }{ + {"nil hosts", nil, ""}, + {"empty hosts", []SandboxHostConfig{}, ""}, + {"no key set", []SandboxHostConfig{{Name: "h1"}}, ""}, + {"one host with key", []SandboxHostConfig{ + {Name: "h1", DaemonIdentityPubKey: "ssh-ed25519 AAAA key@daemon"}, + }, "ssh-ed25519 AAAA key@daemon"}, + {"first non-empty wins", []SandboxHostConfig{ + {Name: "h1"}, + {Name: "h2", DaemonIdentityPubKey: "ssh-ed25519 BBBB key2@daemon"}, + {Name: "h3", DaemonIdentityPubKey: "ssh-ed25519 CCCC key3@daemon"}, + }, "ssh-ed25519 BBBB key2@daemon"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expect, DaemonIdentityPubKey(tt.hosts)) + }) + } +} + func TestLoadWithEnvOverride_InsecureWithSecrets(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("skipping permission test on Windows") diff --git a/fluid-cli/internal/doctor/checks.go b/fluid-cli/internal/doctor/checks.go index b4dce20d..30da1c96 100644 --- a/fluid-cli/internal/doctor/checks.go +++ b/fluid-cli/internal/doctor/checks.go @@ -137,7 +137,7 @@ func checkKVMAvailable(ctx context.Context, run hostexec.RunFunc) CheckResult { Category: "prerequisites", Passed: false, Message: "KVM not available (/dev/kvm missing)", - FixCmd: "sudo modprobe kvm && sudo modprobe kvm_intel || sudo modprobe kvm_amd", + FixCmd: "sudo modprobe kvm && sudo modprobe kvm_intel || sudo modprobe kvm_amd. If this fails, you may be on a virtualized cloud host (e.g., Hetzner Cloud) that doesn't support nested KVM. Use a dedicated server instead.", } } diff --git a/fluid-cli/internal/hostexec/hostexec.go b/fluid-cli/internal/hostexec/hostexec.go index 3ab7461b..f27cbc84 100644 --- a/fluid-cli/internal/hostexec/hostexec.go +++ b/fluid-cli/internal/hostexec/hostexec.go @@ -175,6 +175,8 @@ func NewSSHAlias(hostAlias string, extraArgs ...string) RunFunc { "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=15", "-o", "BatchMode=yes", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=3", } args = append(args, extraArgs...) args = append(args, hostAlias, "--", command) diff --git a/fluid-cli/internal/llm/tools.go b/fluid-cli/internal/llm/tools.go index 344348cc..a93079d8 100644 --- a/fluid-cli/internal/llm/tools.go +++ b/fluid-cli/internal/llm/tools.go @@ -2,9 +2,9 @@ package llm // readOnlyTools is the set of tool names allowed in read-only mode. var readOnlyTools = map[string]bool{ - "list_sandboxes": true, - "get_sandbox": true, - "list_vms": true, + "list_sandboxes": true, + "get_sandbox": true, + // "list_vms": true, // use list_hosts instead "read_file": true, "list_playbooks": true, "get_playbook": true, @@ -202,17 +202,17 @@ func GetTools() []Tool { }, }, }, - { - Type: "function", - Function: Function{ - Name: "list_vms", - Description: "List available host VMs (base images) that can be cloned to create sandboxes. Does not include sandboxes - use list_sandboxes for those.", - Parameters: ParameterSchema{ - Type: "object", - Properties: map[string]Property{}, - }, - }, - }, + // { + // Type: "function", + // Function: Function{ + // Name: "list_vms", + // Description: "List available host VMs (base images) that can be cloned to create sandboxes. Does not include sandboxes - use list_sandboxes for those.", + // Parameters: ParameterSchema{ + // Type: "object", + // Properties: map[string]Property{}, + // }, + // }, + // }, { Type: "function", Function: Function{ diff --git a/fluid-cli/internal/llm/tools_test.go b/fluid-cli/internal/llm/tools_test.go index bf51d43d..f24a2dc7 100644 --- a/fluid-cli/internal/llm/tools_test.go +++ b/fluid-cli/internal/llm/tools_test.go @@ -92,7 +92,7 @@ func TestGetReadOnlyTools(t *testing.T) { "list_hosts", "list_playbooks", "list_sandboxes", - "list_vms", + // "list_vms", // removed - use list_hosts instead "read_file", "read_source_file", "run_source_command", diff --git a/fluid-cli/internal/mcp/handlers.go b/fluid-cli/internal/mcp/handlers.go index bc5ea1e5..c00a860a 100644 --- a/fluid-cli/internal/mcp/handlers.go +++ b/fluid-cli/internal/mcp/handlers.go @@ -271,37 +271,38 @@ func (s *Server) handleGetSandbox(ctx context.Context, request mcp.CallToolReque return jsonResult(result) } -func (s *Server) handleListVMs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.trackToolCall("list_vms") - - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - - vms, err := s.service.ListVMs(ctx) - if err != nil { - s.logger.Error("list_vms failed", "error", err) - return errorResult(map[string]any{"error": fmt.Sprintf("list vms: %s", err)}) - } - - result := make([]map[string]any, 0, len(vms)) - for _, vm := range vms { - item := map[string]any{ - "name": vm.Name, - "state": vm.State, - "prepared": vm.Prepared, - } - if vm.IPAddress != "" { - item["ip"] = vm.IPAddress - } - result = append(result, item) - } - - return jsonResult(map[string]any{ - "vms": result, - "count": len(result), - "total": len(result), - }) -} +// list_vms - use list_hosts instead +// func (s *Server) handleListVMs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// s.trackToolCall("list_vms") +// +// ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) +// defer cancel() +// +// vms, err := s.service.ListVMs(ctx) +// if err != nil { +// s.logger.Error("list_vms failed", "error", err) +// return errorResult(map[string]any{"error": fmt.Sprintf("list vms: %s", err)}) +// } +// +// result := make([]map[string]any, 0, len(vms)) +// for _, vm := range vms { +// item := map[string]any{ +// "name": vm.Name, +// "state": vm.State, +// "prepared": vm.Prepared, +// } +// if vm.IPAddress != "" { +// item["ip"] = vm.IPAddress +// } +// result = append(result, item) +// } +// +// return jsonResult(map[string]any{ +// "vms": result, +// "count": len(result), +// "total": len(result), +// }) +// } func (s *Server) handleCreateSnapshot(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.trackToolCall("create_snapshot") diff --git a/fluid-cli/internal/mcp/handlers_test.go b/fluid-cli/internal/mcp/handlers_test.go index c0181781..e201ee3e 100644 --- a/fluid-cli/internal/mcp/handlers_test.go +++ b/fluid-cli/internal/mcp/handlers_test.go @@ -309,7 +309,18 @@ func (m *mockSandboxService) GetHostInfo(ctx context.Context) (*sandbox.HostInfo } func (m *mockSandboxService) Health(ctx context.Context) error { return nil } -func (m *mockSandboxService) Close() error { return nil } +func (m *mockSandboxService) DoctorCheck(ctx context.Context) ([]sandbox.DoctorCheckResult, error) { + return nil, nil +} + +func (m *mockSandboxService) ScanSourceHostKeys(ctx context.Context) ([]sandbox.ScanSourceHostKeysResult, error) { + return nil, nil +} + +func (m *mockSandboxService) CreateSandboxStream(ctx context.Context, req sandbox.CreateRequest, onProgress func(step string, stepNum, total int)) (*sandbox.SandboxInfo, error) { + return m.CreateSandbox(ctx, req) +} +func (m *mockSandboxService) Close() error { return nil } // --- test server helpers --- @@ -917,16 +928,16 @@ func TestHandleListPlaybooks_NoPlaybooksDir(t *testing.T) { // --- handleListVMs tests --- -func TestHandleListVMs_Empty(t *testing.T) { - srv := testServer() - ctx := context.Background() - - result, err := srv.handleListVMs(ctx, newRequest("list_vms", nil)) - require.NoError(t, err) - - m := parseJSON(t, result) - assert.Equal(t, float64(0), m["count"]) -} +// func TestHandleListVMs_Empty(t *testing.T) { +// srv := testServer() +// ctx := context.Background() +// +// result, err := srv.handleListVMs(ctx, newRequest("list_vms", nil)) +// require.NoError(t, err) +// +// m := parseJSON(t, result) +// assert.Equal(t, float64(0), m["count"]) +// } // --- security tests --- diff --git a/fluid-cli/internal/mcp/server.go b/fluid-cli/internal/mcp/server.go index 653d9417..303747e8 100644 --- a/fluid-cli/internal/mcp/server.go +++ b/fluid-cli/internal/mcp/server.go @@ -97,9 +97,10 @@ func (s *Server) registerTools() { mcp.WithString("sandbox_id", mcp.Required(), mcp.Description("The ID of the sandbox.")), ), s.handleGetSandbox) - s.mcpServer.AddTool(mcp.NewTool("list_vms", - mcp.WithDescription("List available source VMs that can be cloned to create sandboxes."), - ), s.handleListVMs) + // list_vms - use list_hosts instead + // s.mcpServer.AddTool(mcp.NewTool("list_vms", + // mcp.WithDescription("List available source VMs that can be cloned to create sandboxes."), + // ), s.handleListVMs) s.mcpServer.AddTool(mcp.NewTool("create_snapshot", mcp.WithDescription("Create a snapshot of the current sandbox state."), diff --git a/fluid-cli/internal/readonly/prepare.go b/fluid-cli/internal/readonly/prepare.go index 8832c057..6b539736 100644 --- a/fluid-cli/internal/readonly/prepare.go +++ b/fluid-cli/internal/readonly/prepare.go @@ -136,6 +136,83 @@ func PrepareWithKey(ctx context.Context, sshRun SSHRunFunc, pubKey string, onPro return result, nil } +// SetupSourceHost creates the fluid-daemon user (if missing), adds it to the +// libvirt group, and deploys the daemon's SSH identity key. This is the full +// setup needed for the daemon to reach a source host via qemu+ssh. +// All steps are idempotent. Requires sudo on the target host. +func SetupSourceHost(ctx context.Context, sshRun SSHRunFunc, identityPubKey string, logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + key := strings.TrimSpace(identityPubKey) + if key == "" { + return fmt.Errorf("daemon identity pub key is empty") + } + + // Create fluid-daemon user with libvirt access, deploy key, and grant + // passwordless sudo for qemu-img so the daemon can read QEMU-owned snapshot + // files (libvirt-qemu:kvm 0600) when pulling disk images. + cmd := fmt.Sprintf( + "id fluid-daemon >/dev/null 2>&1 || useradd --system --shell /bin/bash -m fluid-daemon && "+ + "usermod -aG libvirt fluid-daemon 2>/dev/null || true && "+ + "mkdir -p ~fluid-daemon/.ssh && chmod 700 ~fluid-daemon/.ssh && "+ + "grep -qF '%s' ~fluid-daemon/.ssh/authorized_keys 2>/dev/null || echo '%s' >> ~fluid-daemon/.ssh/authorized_keys && "+ + "chmod 600 ~fluid-daemon/.ssh/authorized_keys && chown -R fluid-daemon:fluid-daemon ~fluid-daemon/.ssh && "+ + "QEMU_IMG=$(command -v qemu-img); "+ + "echo \"fluid-daemon ALL=(root) NOPASSWD: ${QEMU_IMG}\" > /etc/sudoers.d/fluid-daemon-qemuimg && "+ + "chmod 440 /etc/sudoers.d/fluid-daemon-qemuimg", + key, key, + ) + encoded := base64.StdEncoding.EncodeToString([]byte(cmd)) + wrapped := fmt.Sprintf("echo %s | base64 -d | sudo bash", encoded) + + stdout, stderr, code, err := sshRun(ctx, wrapped) + if err != nil { + return fmt.Errorf("setup source host: %w", err) + } + if code != 0 { + return fmt.Errorf("setup source host: exit=%d stdout=%q stderr=%q", code, stdout, stderr) + } + + logger.Info("source host setup complete: fluid-daemon user + key deployed") + return nil +} + +// DeployDaemonKey deploys the daemon's SSH identity pub key to the fluid-daemon +// user's authorized_keys on a source host, allowing the daemon to SSH in for +// virsh/rsync operations (via qemu+ssh://fluid-daemon@host/system). +// The fluid-daemon user must already exist on the target host. +// The command is idempotent. +func DeployDaemonKey(ctx context.Context, sshRun SSHRunFunc, identityPubKey string, logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + key := strings.TrimSpace(identityPubKey) + if key == "" { + return fmt.Errorf("daemon identity pub key is empty") + } + + cmd := fmt.Sprintf( + "mkdir -p ~fluid-daemon/.ssh && chmod 700 ~fluid-daemon/.ssh && "+ + "grep -qF '%s' ~fluid-daemon/.ssh/authorized_keys 2>/dev/null || echo '%s' >> ~fluid-daemon/.ssh/authorized_keys && "+ + "chmod 600 ~fluid-daemon/.ssh/authorized_keys && chown -R fluid-daemon:fluid-daemon ~fluid-daemon/.ssh", + key, key, + ) + encoded := base64.StdEncoding.EncodeToString([]byte(cmd)) + wrapped := fmt.Sprintf("echo %s | base64 -d | sudo bash", encoded) + + stdout, stderr, code, err := sshRun(ctx, wrapped) + if err != nil { + return fmt.Errorf("deploy daemon key: %w", err) + } + if code != 0 { + return fmt.Errorf("deploy daemon key: exit=%d stdout=%q stderr=%q", code, stdout, stderr) + } + + logger.Info("daemon identity key deployed to fluid-daemon user") + return nil +} + // Prepare configures a golden VM for read-only access via the fluid-readonly user. // All steps are idempotent. The sshRun function is used to execute commands on the VM. // diff --git a/fluid-cli/internal/readonly/prepare_test.go b/fluid-cli/internal/readonly/prepare_test.go index 16fc8ed3..d4335b4e 100644 --- a/fluid-cli/internal/readonly/prepare_test.go +++ b/fluid-cli/internal/readonly/prepare_test.go @@ -646,6 +646,89 @@ func TestPrepare_ShellScriptContent(t *testing.T) { } } +func TestDeployDaemonKey_Success(t *testing.T) { + mock := newMockSSHRun() + key := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaemonKey daemon@host" + + err := DeployDaemonKey(context.Background(), mock.run, key, nil) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + commands := mock.getCommands() + if len(commands) != 1 { + t.Fatalf("expected 1 SSH command, got %d", len(commands)) + } + + // The command should target fluid-daemon user's authorized_keys + cmd := commands[0] + decoded, err := decodeBase64Command(cmd) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if !strings.Contains(decoded, "~fluid-daemon/.ssh/authorized_keys") { + t.Error("command should target ~fluid-daemon/.ssh/authorized_keys") + } + if !strings.Contains(decoded, "grep -qF") { + t.Error("command should use grep -qF for idempotent check") + } + if !strings.Contains(decoded, key) { + t.Error("command should contain the identity pub key") + } + if !strings.Contains(decoded, "chown -R fluid-daemon:fluid-daemon") { + t.Error("command should set ownership to fluid-daemon") + } +} + +func TestDeployDaemonKey_EmptyKey(t *testing.T) { + mock := newMockSSHRun() + + tests := []string{"", " ", "\n", "\t\n "} + for _, key := range tests { + err := DeployDaemonKey(context.Background(), mock.run, key, nil) + if err == nil { + t.Errorf("expected error for empty key %q, got nil", key) + } + if !strings.Contains(err.Error(), "daemon identity pub key is empty") { + t.Errorf("expected 'daemon identity pub key is empty' error, got: %v", err) + } + } + + // No SSH commands should have been issued + if len(mock.getCommands()) != 0 { + t.Errorf("expected no SSH commands for empty key, got %d", len(mock.getCommands())) + } +} + +func TestDeployDaemonKey_SSHFailure(t *testing.T) { + mock := newMockSSHRun() + mock.failAt(0, sshResponse{err: fmt.Errorf("connection refused")}) + key := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaemonKey daemon@host" + + err := DeployDaemonKey(context.Background(), mock.run, key, nil) + if err == nil { + t.Fatal("expected error when SSH connection fails") + } + if !strings.Contains(err.Error(), "deploy daemon key") { + t.Errorf("error should mention deploy daemon key: %v", err) + } +} + +func TestDeployDaemonKey_NonZeroExit(t *testing.T) { + mock := newMockSSHRun() + mock.failAt(0, sshResponse{stderr: "permission denied", exitCode: 1}) + key := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaemonKey daemon@host" + + err := DeployDaemonKey(context.Background(), mock.run, key, nil) + if err == nil { + t.Fatal("expected error when command exits non-zero") + } + if !strings.Contains(err.Error(), "deploy daemon key") { + t.Errorf("error should mention deploy daemon key: %v", err) + } +} + func TestPrepare_IdempotentUserCreation(t *testing.T) { mock := newMockSSHRun() caPubKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey test-ca" @@ -697,3 +780,103 @@ func TestPrepare_SSHDConfigIdempotent(t *testing.T) { } } } + +func TestSetupSourceHost_Success(t *testing.T) { + mock := newMockSSHRun() + key := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaemonKey daemon@host" + + err := SetupSourceHost(context.Background(), mock.run, key, nil) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + commands := mock.getCommands() + if len(commands) != 1 { + t.Fatalf("expected 1 SSH command, got %d", len(commands)) + } + + decoded, err := decodeBase64Command(commands[0]) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + // Should create fluid-daemon user + if !strings.Contains(decoded, "useradd --system --shell /bin/bash -m fluid-daemon") { + t.Error("command should create fluid-daemon user") + } + // Should be idempotent (check if user exists first) + if !strings.Contains(decoded, "id fluid-daemon") { + t.Error("command should check if fluid-daemon user exists") + } + // Should add to libvirt group + if !strings.Contains(decoded, "usermod -aG libvirt fluid-daemon") { + t.Error("command should add fluid-daemon to libvirt group") + } + // Should deploy key to fluid-daemon's authorized_keys + if !strings.Contains(decoded, "~fluid-daemon/.ssh/authorized_keys") { + t.Error("command should target ~fluid-daemon/.ssh/authorized_keys") + } + if !strings.Contains(decoded, key) { + t.Error("command should contain the identity pub key") + } + // Should set ownership + if !strings.Contains(decoded, "chown -R fluid-daemon:fluid-daemon") { + t.Error("command should set ownership to fluid-daemon") + } + // Should use grep for idempotent key append + if !strings.Contains(decoded, "grep -qF") { + t.Error("command should use grep -qF for idempotent key check") + } + // Should set up passwordless sudo for qemu-img so daemon can read QEMU-owned files + if !strings.Contains(decoded, "sudoers.d/fluid-daemon-qemuimg") { + t.Error("command should set up sudoers for qemu-img") + } + if !strings.Contains(decoded, "NOPASSWD") { + t.Error("command should grant NOPASSWD for qemu-img") + } + if !strings.Contains(decoded, "chmod 440") { + t.Error("command should set 440 permissions on sudoers file") + } +} + +func TestSetupSourceHost_EmptyKey(t *testing.T) { + mock := newMockSSHRun() + + for _, key := range []string{"", " ", "\n"} { + err := SetupSourceHost(context.Background(), mock.run, key, nil) + if err == nil { + t.Errorf("expected error for empty key %q, got nil", key) + } + } + if len(mock.getCommands()) != 0 { + t.Errorf("expected no SSH commands for empty key, got %d", len(mock.getCommands())) + } +} + +func TestSetupSourceHost_SSHFailure(t *testing.T) { + mock := newMockSSHRun() + mock.failAt(0, sshResponse{err: fmt.Errorf("connection refused")}) + key := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaemonKey daemon@host" + + err := SetupSourceHost(context.Background(), mock.run, key, nil) + if err == nil { + t.Fatal("expected error when SSH connection fails") + } + if !strings.Contains(err.Error(), "setup source host") { + t.Errorf("error should mention setup source host: %v", err) + } +} + +func TestSetupSourceHost_NonZeroExit(t *testing.T) { + mock := newMockSSHRun() + mock.failAt(0, sshResponse{stderr: "permission denied", exitCode: 1}) + key := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaemonKey daemon@host" + + err := SetupSourceHost(context.Background(), mock.run, key, nil) + if err == nil { + t.Fatal("expected error when command exits non-zero") + } + if !strings.Contains(err.Error(), "setup source host") { + t.Errorf("error should mention setup source host: %v", err) + } +} diff --git a/fluid-cli/internal/sandbox/noop.go b/fluid-cli/internal/sandbox/noop.go index bc93f56f..114a15b9 100644 --- a/fluid-cli/internal/sandbox/noop.go +++ b/fluid-cli/internal/sandbox/noop.go @@ -21,6 +21,10 @@ func (n *NoopService) CreateSandbox(ctx context.Context, req CreateRequest) (*Sa return nil, errors.New(noSandboxMsg) } +func (n *NoopService) CreateSandboxStream(ctx context.Context, req CreateRequest, onProgress func(step string, stepNum, total int)) (*SandboxInfo, error) { + return nil, errors.New(noSandboxMsg) +} + func (n *NoopService) GetSandbox(ctx context.Context, id string) (*SandboxInfo, error) { return nil, errors.New(noSandboxMsg) } @@ -77,6 +81,14 @@ func (n *NoopService) Health(ctx context.Context) error { return errors.New(noSandboxMsg) } +func (n *NoopService) DoctorCheck(ctx context.Context) ([]DoctorCheckResult, error) { + return nil, errors.New(noSandboxMsg) +} + +func (n *NoopService) ScanSourceHostKeys(ctx context.Context) ([]ScanSourceHostKeysResult, error) { + return nil, errors.New(noSandboxMsg) +} + func (n *NoopService) Close() error { return nil } diff --git a/fluid-cli/internal/sandbox/remote.go b/fluid-cli/internal/sandbox/remote.go index 76d43141..1c22556b 100644 --- a/fluid-cli/internal/sandbox/remote.go +++ b/fluid-cli/internal/sandbox/remote.go @@ -11,15 +11,18 @@ import ( "github.com/aspectrr/fluid.sh/fluid-cli/internal/config" fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" ) // RemoteService implements Service by calling the fluid-daemon via gRPC. +// Source VM resolution (which hypervisor host owns which VM) is handled +// by the daemon via its configured source_hosts. The CLI just sends VM names. type RemoteService struct { conn *grpc.ClientConn client fluidv1.DaemonServiceClient - hosts []config.HostConfig } // NewRemoteService dials the daemon gRPC endpoint and returns a Service. @@ -27,7 +30,7 @@ type RemoteService struct { // - If DaemonCAFile is set, use it to verify the daemon's TLS cert // - If DaemonInsecure is false and no CA file, use the system cert pool // - Only use insecure credentials when DaemonInsecure is explicitly true -func NewRemoteService(addr string, cpCfg config.ControlPlaneConfig, hosts []config.HostConfig) (*RemoteService, error) { +func NewRemoteService(addr string, cpCfg config.ControlPlaneConfig) (*RemoteService, error) { var creds credentials.TransportCredentials switch { @@ -66,7 +69,6 @@ func NewRemoteService(addr string, cpCfg config.ControlPlaneConfig, hosts []conf return &RemoteService{ conn: conn, client: fluidv1.NewDaemonServiceClient(conn), - hosts: hosts, }, nil } @@ -77,36 +79,17 @@ func (r *RemoteService) Close() error { return nil } -// sourceHostConn builds a SourceHostConnection from the first configured host. -func (r *RemoteService) sourceHostConn() *fluidv1.SourceHostConnection { - if len(r.hosts) == 0 { - return nil - } - h := r.hosts[0] - user := h.DaemonSSHUser - if user == "" { - user = "fluid-daemon" - } - return &fluidv1.SourceHostConnection{ - Type: "libvirt", - SshHost: h.Address, - SshPort: int32(h.SSHPort), - SshUser: user, - } -} - func (r *RemoteService) CreateSandbox(ctx context.Context, req CreateRequest) (*SandboxInfo, error) { resp, err := r.client.CreateSandbox(ctx, &fluidv1.CreateSandboxCommand{ - BaseImage: req.SourceVM, // derived from source_vm - daemon resolves the actual image - SourceVm: req.SourceVM, - Name: req.Name, - Vcpus: int32(req.VCPUs), - MemoryMb: int32(req.MemoryMB), - TtlSeconds: int32(req.TTLSeconds), - AgentId: req.AgentID, - Network: req.Network, - Live: req.Live, - SourceHostConnection: r.sourceHostConn(), + BaseImage: req.SourceVM, + SourceVm: req.SourceVM, + Name: req.Name, + Vcpus: int32(req.VCPUs), + MemoryMb: int32(req.MemoryMB), + TtlSeconds: int32(req.TTLSeconds), + AgentId: req.AgentID, + Network: req.Network, + Live: req.Live, }) if err != nil { return nil, err @@ -119,6 +102,55 @@ func (r *RemoteService) CreateSandbox(ctx context.Context, req CreateRequest) (* }, nil } +func (r *RemoteService) CreateSandboxStream(ctx context.Context, req CreateRequest, onProgress func(step string, stepNum, total int)) (*SandboxInfo, error) { + stream, err := r.client.CreateSandboxStream(ctx, &fluidv1.CreateSandboxCommand{ + BaseImage: req.SourceVM, + SourceVm: req.SourceVM, + Name: req.Name, + Vcpus: int32(req.VCPUs), + MemoryMb: int32(req.MemoryMB), + TtlSeconds: int32(req.TTLSeconds), + AgentId: req.AgentID, + Network: req.Network, + Live: req.Live, + }) + if err != nil { + // Fall back to unary if streaming is unimplemented (older daemon) + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + if onProgress != nil { + onProgress("Creating sandbox", 1, 9) + } + return r.CreateSandbox(ctx, req) + } + return nil, err + } + + for { + progress, err := stream.Recv() + if err != nil { + return nil, err + } + + if progress.GetError() != "" { + return nil, fmt.Errorf("sandbox creation failed: %s", progress.GetError()) + } + + if progress.GetDone() { + result := progress.GetResult() + return &SandboxInfo{ + ID: result.GetSandboxId(), + Name: result.GetName(), + State: result.GetState(), + IPAddress: result.GetIpAddress(), + }, nil + } + + if onProgress != nil { + onProgress(progress.GetStep(), int(progress.GetStepNum()), int(progress.GetTotalSteps())) + } + } +} + func (r *RemoteService) GetSandbox(ctx context.Context, id string) (*SandboxInfo, error) { resp, err := r.client.GetSandbox(ctx, &fluidv1.GetSandboxRequest{SandboxId: id}) if err != nil { @@ -196,9 +228,7 @@ func (r *RemoteService) CreateSnapshot(ctx context.Context, sandboxID, name stri } func (r *RemoteService) ListVMs(ctx context.Context) ([]*VMInfo, error) { - resp, err := r.client.ListSourceVMs(ctx, &fluidv1.ListSourceVMsCommand{ - SourceHostConnection: r.sourceHostConn(), - }) + resp, err := r.client.ListSourceVMs(ctx, &fluidv1.ListSourceVMsCommand{}) if err != nil { return nil, err } @@ -216,8 +246,7 @@ func (r *RemoteService) ListVMs(ctx context.Context) ([]*VMInfo, error) { func (r *RemoteService) ValidateSourceVM(ctx context.Context, vmName string) (*ValidationInfo, error) { resp, err := r.client.ValidateSourceVM(ctx, &fluidv1.ValidateSourceVMCommand{ - SourceVm: vmName, - SourceHostConnection: r.sourceHostConn(), + SourceVm: vmName, }) if err != nil { return nil, err @@ -236,10 +265,9 @@ func (r *RemoteService) ValidateSourceVM(ctx context.Context, vmName string) (*V func (r *RemoteService) PrepareSourceVM(ctx context.Context, vmName, sshUser, keyPath string) (*PrepareInfo, error) { resp, err := r.client.PrepareSourceVM(ctx, &fluidv1.PrepareSourceVMCommand{ - SourceVm: vmName, - SshUser: sshUser, - SshKeyPath: keyPath, - SourceHostConnection: r.sourceHostConn(), + SourceVm: vmName, + SshUser: sshUser, + SshKeyPath: keyPath, }) if err != nil { return nil, err @@ -259,10 +287,9 @@ func (r *RemoteService) PrepareSourceVM(ctx context.Context, vmName, sshUser, ke func (r *RemoteService) RunSourceCommand(ctx context.Context, vmName, command string, timeoutSec int) (*SourceCommandResult, error) { resp, err := r.client.RunSourceCommand(ctx, &fluidv1.RunSourceCommandCommand{ - SourceVm: vmName, - Command: command, - TimeoutSeconds: int32(timeoutSec), - SourceHostConnection: r.sourceHostConn(), + SourceVm: vmName, + Command: command, + TimeoutSeconds: int32(timeoutSec), }) if err != nil { return nil, err @@ -277,9 +304,8 @@ func (r *RemoteService) RunSourceCommand(ctx context.Context, vmName, command st func (r *RemoteService) ReadSourceFile(ctx context.Context, vmName, path string) (string, error) { resp, err := r.client.ReadSourceFile(ctx, &fluidv1.ReadSourceFileCommand{ - SourceVm: vmName, - Path: path, - SourceHostConnection: r.sourceHostConn(), + SourceVm: vmName, + Path: path, }) if err != nil { return "", err @@ -292,15 +318,26 @@ func (r *RemoteService) GetHostInfo(ctx context.Context) (*HostInfo, error) { if err != nil { return nil, err } + var sourceHosts []SourceHostInfo + for _, sh := range resp.GetSourceHosts() { + sourceHosts = append(sourceHosts, SourceHostInfo{ + Address: sh.GetAddress(), + SSHUser: sh.GetSshUser(), + SSHPort: int(sh.GetSshPort()), + }) + } + return &HostInfo{ - HostID: resp.GetHostId(), - Hostname: resp.GetHostname(), - Version: resp.GetVersion(), - TotalCPUs: int(resp.GetTotalCpus()), - TotalMemoryMB: resp.GetTotalMemoryMb(), - ActiveSandboxes: int(resp.GetActiveSandboxes()), - BaseImages: resp.GetBaseImages(), - SSHCAPubKey: resp.GetSshCaPubKey(), + HostID: resp.GetHostId(), + Hostname: resp.GetHostname(), + Version: resp.GetVersion(), + TotalCPUs: int(resp.GetTotalCpus()), + TotalMemoryMB: resp.GetTotalMemoryMb(), + ActiveSandboxes: int(resp.GetActiveSandboxes()), + BaseImages: resp.GetBaseImages(), + SSHCAPubKey: resp.GetSshCaPubKey(), + SSHIdentityPubKey: resp.GetSshIdentityPubKey(), + SourceHosts: sourceHosts, }, nil } @@ -309,6 +346,40 @@ func (r *RemoteService) Health(ctx context.Context) error { return err } +func (r *RemoteService) DoctorCheck(ctx context.Context) ([]DoctorCheckResult, error) { + resp, err := r.client.DoctorCheck(ctx, &fluidv1.DoctorCheckRequest{}) + if err != nil { + return nil, err + } + results := make([]DoctorCheckResult, len(resp.GetResults())) + for i, res := range resp.GetResults() { + results[i] = DoctorCheckResult{ + Name: res.GetName(), + Category: res.GetCategory(), + Passed: res.GetPassed(), + Message: res.GetMessage(), + FixCmd: res.GetFixCmd(), + } + } + return results, nil +} + +func (r *RemoteService) ScanSourceHostKeys(ctx context.Context) ([]ScanSourceHostKeysResult, error) { + resp, err := r.client.ScanSourceHostKeys(ctx, &fluidv1.ScanSourceHostKeysRequest{}) + if err != nil { + return nil, err + } + results := make([]ScanSourceHostKeysResult, len(resp.GetResults())) + for i, res := range resp.GetResults() { + results[i] = ScanSourceHostKeysResult{ + Address: res.GetAddress(), + Success: res.GetSuccess(), + Error: res.GetError(), + } + } + return results, nil +} + // protoToSandboxInfo converts a proto SandboxInfo to the canonical type. func protoToSandboxInfo(pb *fluidv1.SandboxInfo) *SandboxInfo { var createdAt time.Time diff --git a/fluid-cli/internal/sandbox/remote_test.go b/fluid-cli/internal/sandbox/remote_test.go new file mode 100644 index 00000000..ea2524ec --- /dev/null +++ b/fluid-cli/internal/sandbox/remote_test.go @@ -0,0 +1,258 @@ +package sandbox + +import ( + "context" + "io" + "testing" + + fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// mockDaemonClient implements fluidv1.DaemonServiceClient for testing. +type mockDaemonClient struct { + vms []*fluidv1.SourceVMListEntry + createSandboxResp *fluidv1.SandboxCreated + createSandboxErr error + createStream grpc.ServerStreamingClient[fluidv1.SandboxProgress] + createStreamErr error +} + +func (m *mockDaemonClient) ListSourceVMs(_ context.Context, _ *fluidv1.ListSourceVMsCommand, _ ...grpc.CallOption) (*fluidv1.SourceVMsList, error) { + return &fluidv1.SourceVMsList{Vms: m.vms}, nil +} + +// Stubs for the rest of the interface. + +func (m *mockDaemonClient) CreateSandbox(context.Context, *fluidv1.CreateSandboxCommand, ...grpc.CallOption) (*fluidv1.SandboxCreated, error) { + if m.createSandboxErr != nil { + return nil, m.createSandboxErr + } + if m.createSandboxResp != nil { + return m.createSandboxResp, nil + } + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) GetSandbox(context.Context, *fluidv1.GetSandboxRequest, ...grpc.CallOption) (*fluidv1.SandboxInfo, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) ListSandboxes(context.Context, *fluidv1.ListSandboxesRequest, ...grpc.CallOption) (*fluidv1.ListSandboxesResponse, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) DestroySandbox(context.Context, *fluidv1.DestroySandboxCommand, ...grpc.CallOption) (*fluidv1.SandboxDestroyed, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) StartSandbox(context.Context, *fluidv1.StartSandboxCommand, ...grpc.CallOption) (*fluidv1.SandboxStarted, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) StopSandbox(context.Context, *fluidv1.StopSandboxCommand, ...grpc.CallOption) (*fluidv1.SandboxStopped, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) RunCommand(context.Context, *fluidv1.RunCommandCommand, ...grpc.CallOption) (*fluidv1.CommandResult, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) CreateSnapshot(context.Context, *fluidv1.SnapshotCommand, ...grpc.CallOption) (*fluidv1.SnapshotCreated, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) ValidateSourceVM(context.Context, *fluidv1.ValidateSourceVMCommand, ...grpc.CallOption) (*fluidv1.SourceVMValidation, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) PrepareSourceVM(context.Context, *fluidv1.PrepareSourceVMCommand, ...grpc.CallOption) (*fluidv1.SourceVMPrepared, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) RunSourceCommand(context.Context, *fluidv1.RunSourceCommandCommand, ...grpc.CallOption) (*fluidv1.SourceCommandResult, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) ReadSourceFile(context.Context, *fluidv1.ReadSourceFileCommand, ...grpc.CallOption) (*fluidv1.SourceFileResult, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) GetHostInfo(context.Context, *fluidv1.GetHostInfoRequest, ...grpc.CallOption) (*fluidv1.HostInfoResponse, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) Health(context.Context, *fluidv1.HealthRequest, ...grpc.CallOption) (*fluidv1.HealthResponse, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) DiscoverHosts(context.Context, *fluidv1.DiscoverHostsCommand, ...grpc.CallOption) (*fluidv1.DiscoverHostsResult, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) DoctorCheck(context.Context, *fluidv1.DoctorCheckRequest, ...grpc.CallOption) (*fluidv1.DoctorCheckResponse, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +func (m *mockDaemonClient) CreateSandboxStream(_ context.Context, _ *fluidv1.CreateSandboxCommand, _ ...grpc.CallOption) (grpc.ServerStreamingClient[fluidv1.SandboxProgress], error) { + if m.createStreamErr != nil { + return nil, m.createStreamErr + } + if m.createStream != nil { + return m.createStream, nil + } + return nil, status.Error(codes.Unimplemented, "not implemented") +} + +type fakeSandboxProgressStream struct { + msgs []*fluidv1.SandboxProgress + idx int +} + +func (f *fakeSandboxProgressStream) Header() (metadata.MD, error) { return nil, nil } +func (f *fakeSandboxProgressStream) Trailer() metadata.MD { return nil } +func (f *fakeSandboxProgressStream) CloseSend() error { return nil } +func (f *fakeSandboxProgressStream) Context() context.Context { return context.Background() } +func (f *fakeSandboxProgressStream) SendMsg(any) error { return nil } +func (f *fakeSandboxProgressStream) RecvMsg(any) error { return nil } + +func (f *fakeSandboxProgressStream) Recv() (*fluidv1.SandboxProgress, error) { + if f.idx >= len(f.msgs) { + return nil, io.EOF + } + msg := f.msgs[f.idx] + f.idx++ + return msg, nil +} + +func (m *mockDaemonClient) ScanSourceHostKeys(_ context.Context, _ *fluidv1.ScanSourceHostKeysRequest, _ ...grpc.CallOption) (*fluidv1.ScanSourceHostKeysResponse, error) { + return &fluidv1.ScanSourceHostKeysResponse{ + Results: []*fluidv1.ScanSourceHostKeysResult{ + {Address: "10.0.0.1", Success: true}, + }, + }, nil +} + +func TestListVMs_DelegatesToDaemon(t *testing.T) { + mock := &mockDaemonClient{ + vms: []*fluidv1.SourceVMListEntry{ + {Name: "vm-a", State: "running", Host: "10.0.0.1"}, + {Name: "vm-b", State: "stopped", Host: "10.0.0.2"}, + }, + } + svc := &RemoteService{client: mock} + + vms, err := svc.ListVMs(context.Background()) + if err != nil { + t.Fatalf("ListVMs: %v", err) + } + if len(vms) != 2 { + t.Fatalf("got %d VMs, want 2", len(vms)) + } + if vms[0].Name != "vm-a" { + t.Errorf("got VM[0] %q, want vm-a", vms[0].Name) + } + if vms[1].Name != "vm-b" { + t.Errorf("got VM[1] %q, want vm-b", vms[1].Name) + } +} + +func TestScanSourceHostKeys_DelegatesToDaemon(t *testing.T) { + mock := &mockDaemonClient{} + svc := &RemoteService{client: mock} + + results, err := svc.ScanSourceHostKeys(context.Background()) + if err != nil { + t.Fatalf("ScanSourceHostKeys: %v", err) + } + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].Address != "10.0.0.1" { + t.Errorf("got address %q, want 10.0.0.1", results[0].Address) + } + if !results[0].Success { + t.Error("expected success=true") + } +} + +func TestCreateSandboxStream_DelegatesProgressToCallback(t *testing.T) { + mock := &mockDaemonClient{ + createStream: &fakeSandboxProgressStream{ + msgs: []*fluidv1.SandboxProgress{ + {Step: "Using provided source host", StepNum: 1, TotalSteps: 9}, + {Step: "Pulling fresh snapshot", StepNum: 2, TotalSteps: 9}, + { + Done: true, + Result: &fluidv1.SandboxCreated{ + SandboxId: "sbx-123", + Name: "sandbox", + State: "RUNNING", + IpAddress: "10.0.0.2", + }, + }, + }, + }, + } + svc := &RemoteService{client: mock} + var progressSteps []string + + info, err := svc.CreateSandboxStream(context.Background(), CreateRequest{ + SourceVM: "vm-1", + }, func(step string, stepNum, total int) { + progressSteps = append(progressSteps, step) + if total != 9 { + t.Fatalf("progress total = %d, want 9", total) + } + if stepNum != len(progressSteps) { + t.Fatalf("progress step number = %d, want %d", stepNum, len(progressSteps)) + } + }) + if err != nil { + t.Fatalf("CreateSandboxStream: %v", err) + } + if info.ID != "sbx-123" { + t.Fatalf("sandbox id = %q, want %q", info.ID, "sbx-123") + } + if len(progressSteps) != 2 { + t.Fatalf("progress step count = %d, want 2", len(progressSteps)) + } + if progressSteps[0] != "Using provided source host" || progressSteps[1] != "Pulling fresh snapshot" { + t.Fatalf("progress steps = %v, want provided source host then snapshot pull", progressSteps) + } +} + +func TestCreateSandboxStream_FallsBackToUnaryWithSyntheticProgress(t *testing.T) { + mock := &mockDaemonClient{ + createStreamErr: status.Error(codes.Unimplemented, "not implemented"), + createSandboxResp: &fluidv1.SandboxCreated{ + SandboxId: "sbx-legacy", + Name: "sandbox", + State: "RUNNING", + IpAddress: "10.0.0.9", + }, + } + svc := &RemoteService{client: mock} + var progress [][]any + + info, err := svc.CreateSandboxStream(context.Background(), CreateRequest{ + SourceVM: "vm-1", + }, func(step string, stepNum, total int) { + progress = append(progress, []any{step, stepNum, total}) + }) + if err != nil { + t.Fatalf("CreateSandboxStream: %v", err) + } + if info.ID != "sbx-legacy" { + t.Fatalf("sandbox id = %q, want %q", info.ID, "sbx-legacy") + } + if len(progress) != 1 { + t.Fatalf("progress message count = %d, want 1", len(progress)) + } + if progress[0][0] != "Creating sandbox" || progress[0][1] != 1 || progress[0][2] != 9 { + t.Fatalf("synthetic progress = %v, want [Creating sandbox 1 9]", progress[0]) + } +} diff --git a/fluid-cli/internal/sandbox/service.go b/fluid-cli/internal/sandbox/service.go index 59688f23..89cb49d8 100644 --- a/fluid-cli/internal/sandbox/service.go +++ b/fluid-cli/internal/sandbox/service.go @@ -7,6 +7,7 @@ import "context" type Service interface { // Sandbox lifecycle CreateSandbox(ctx context.Context, req CreateRequest) (*SandboxInfo, error) + CreateSandboxStream(ctx context.Context, req CreateRequest, onProgress func(step string, stepNum, total int)) (*SandboxInfo, error) GetSandbox(ctx context.Context, id string) (*SandboxInfo, error) ListSandboxes(ctx context.Context) ([]*SandboxInfo, error) DestroySandbox(ctx context.Context, id string) error @@ -29,6 +30,8 @@ type Service interface { // Host info GetHostInfo(ctx context.Context) (*HostInfo, error) Health(ctx context.Context) error + DoctorCheck(ctx context.Context) ([]DoctorCheckResult, error) + ScanSourceHostKeys(ctx context.Context) ([]ScanSourceHostKeysResult, error) // Close releases resources (e.g. gRPC connection). Close() error diff --git a/fluid-cli/internal/sandbox/types.go b/fluid-cli/internal/sandbox/types.go index 83f991d6..6a20b858 100644 --- a/fluid-cli/internal/sandbox/types.go +++ b/fluid-cli/internal/sandbox/types.go @@ -87,14 +87,39 @@ type SourceCommandResult struct { Stderr string `json:"stderr"` } +// DoctorCheckResult holds the outcome of a single daemon-side doctor check. +type DoctorCheckResult struct { + Name string + Category string + Passed bool + Message string + FixCmd string +} + +// ScanSourceHostKeysResult holds the outcome of scanning a single source host's SSH key. +type ScanSourceHostKeysResult struct { + Address string + Success bool + Error string +} + +// SourceHostInfo describes a source host the daemon is configured to reach. +type SourceHostInfo struct { + Address string `json:"address"` + SSHUser string `json:"ssh_user"` + SSHPort int `json:"ssh_port"` +} + // HostInfo contains host resource and capability information. type HostInfo struct { - HostID string `json:"host_id"` - Hostname string `json:"hostname"` - Version string `json:"version"` - TotalCPUs int `json:"total_cpus"` - TotalMemoryMB int64 `json:"total_memory_mb"` - ActiveSandboxes int `json:"active_sandboxes"` - BaseImages []string `json:"base_images"` - SSHCAPubKey string `json:"ssh_ca_pub_key,omitempty"` + HostID string `json:"host_id"` + Hostname string `json:"hostname"` + Version string `json:"version"` + TotalCPUs int `json:"total_cpus"` + TotalMemoryMB int64 `json:"total_memory_mb"` + ActiveSandboxes int `json:"active_sandboxes"` + BaseImages []string `json:"base_images"` + SSHCAPubKey string `json:"ssh_ca_pub_key,omitempty"` + SSHIdentityPubKey string `json:"ssh_identity_pub_key,omitempty"` + SourceHosts []SourceHostInfo `json:"source_hosts,omitempty"` } diff --git a/fluid-cli/internal/tui/agent.go b/fluid-cli/internal/tui/agent.go index 72d379fa..9da177fb 100644 --- a/fluid-cli/internal/tui/agent.go +++ b/fluid-cli/internal/tui/agent.go @@ -77,6 +77,10 @@ type FluidAgent struct { currentSourceVM string autoReadOnly bool + // displayReadOnly tracks sticky read-only display state after source VM ops. + // Stays true after withAutoReadOnly exits until a write tool explicitly clears it. + displayReadOnly bool + // Pending approval for network access pendingNetworkApproval *PendingNetworkApproval @@ -187,6 +191,16 @@ func (a *FluidAgent) sendStatus(msg tea.Msg) { } } +// finishRun sends the final TUI-facing status update and returns the only +// direct completion signal for Run(). AgentDoneMsg must not be queued through +// statusCallback, otherwise it can remain buffered and break the next run. +func (a *FluidAgent) finishRun(msg tea.Msg) tea.Msg { + if msg != nil { + a.sendStatus(msg) + } + return AgentDoneMsg{} +} + // sendRedactedMsg sends a SensitiveContentRedactedMsg with dedup by host/path. // Only sends the message the first time per unique key per agent run. func (a *FluidAgent) sendRedactedMsg(host, path string) { @@ -229,6 +243,7 @@ func (a *FluidAgent) withAutoReadOnly(sourceVM string, fn func() (any, error)) ( if !a.readOnly { a.autoReadOnly = true a.readOnly = true + a.displayReadOnly = true enterMsg = &AutoReadOnlyMsg{SourceVM: sourceVM, Enabled: true} } a.mu.Unlock() @@ -238,16 +253,12 @@ func (a *FluidAgent) withAutoReadOnly(sourceVM string, fn func() (any, error)) ( defer func() { a.mu.Lock() a.currentSourceVM = "" - var exitMsg *AutoReadOnlyMsg if a.autoReadOnly && !wasAutoReadOnly { a.autoReadOnly = false a.readOnly = false - exitMsg = &AutoReadOnlyMsg{Enabled: false} + // displayReadOnly stays true - cleared by the next write tool call } a.mu.Unlock() - if exitMsg != nil { - a.sendStatus(*exitMsg) - } }() return fn() } @@ -287,21 +298,19 @@ func (a *FluidAgent) Run(input string) tea.Cmd { if strings.HasPrefix(input, "/prepare ") { hostname := strings.TrimSpace(strings.TrimPrefix(input, "/prepare ")) if hostname == "" { - a.sendStatus(AgentDoneMsg{}) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: "Usage: `/prepare ` - specify an SSH host to prepare", Done: true, - }} + }}) } // Probe if host is already prepared if probeFluidReadonly(hostname, a.cfg.SSH.SourceKeyDir) { if a.lastPrepareWarned != hostname { a.lastPrepareWarned = hostname - a.sendStatus(AgentDoneMsg{}) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: fmt.Sprintf("Host %s is already prepared. Run `/prepare %s` again to re-prepare.", hostname, hostname), Done: true, - }} + }}) } a.lastPrepareWarned = "" } else { @@ -311,22 +320,19 @@ func (a *FluidAgent) Run(input string) tea.Cmd { } switch input { - case "/vms": - a.sendStatus(AgentDoneMsg{}) - result, err := a.listVMs(ctx) - return AgentResponseMsg{Response: AgentResponse{ - Content: a.formatVMsResult(result, err), - Done: true, - }} + // case "/vms": // use /hosts instead + // result, err := a.listVMs(ctx) + // return a.finishRun(AgentResponseMsg{Response: AgentResponse{ + // Content: a.formatVMsResult(result, err), + // Done: true, + // }}) case "/sandboxes": - a.sendStatus(AgentDoneMsg{}) result, err := a.listSandboxes(ctx) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: a.formatSandboxesResult(result, err), Done: true, - }} + }}) case "/hosts": - a.sendStatus(AgentDoneMsg{}) if a.sourceService != nil { hosts := a.sourceService.ListHosts() var lines []string @@ -341,46 +347,39 @@ func (a *FluidAgent) Run(input string) tea.Cmd { if len(hosts) == 0 { content = "No source hosts configured. Run: `fluid source prepare `" } - return AgentResponseMsg{Response: AgentResponse{Content: content, Done: true}} + return a.finishRun(AgentResponseMsg{Response: AgentResponse{Content: content, Done: true}}) } result, err := a.listHostsWithVMs(ctx) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: a.formatHostsResult(result, err), Done: true, - }} + }}) case "/playbooks": - a.sendStatus(AgentDoneMsg{}) result, err := a.listPlaybooks(ctx) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: a.formatPlaybooksResult(result, err), Done: true, - }} + }}) case "/compact": // Manual compaction a.sendStatus(CompactStartMsg{}) result, err := a.Compact(ctx) - a.sendStatus(AgentDoneMsg{}) if err != nil { - return CompactErrorMsg{Err: err} + return a.finishRun(CompactErrorMsg{Err: err}) } - // The Compact function returns a CompactCompleteMsg, - // but here we are in a func returning tea.Msg. - // result is already CompactCompleteMsg. - return result + return a.finishRun(result) case "/context": // Show context usage - a.sendStatus(AgentDoneMsg{}) usage := a.GetContextUsage() tokens := a.EstimateTokens() maxTokens := a.cfg.AIAgent.TotalContextTokens threshold := a.cfg.AIAgent.CompactThreshold - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: fmt.Sprintf("Context usage: %d/%d tokens (%.1f%%)\nAuto-compact threshold: %.0f%%", tokens, maxTokens, usage*100, threshold*100), Done: true, - }} + }}) case "/allowlist": - a.sendStatus(AgentDoneMsg{}) var b strings.Builder b.WriteString("## Read-Only Command Allowlist\n\n") b.WriteString("### Default Commands\n\n") @@ -400,12 +399,11 @@ func (a *FluidAgent) Run(input string) tea.Cmd { b.WriteString("\n") } b.WriteString("Edit extra commands in `/settings` or `config.yaml` under `extra_allowed_commands`.\n") - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: b.String(), Done: true, - }} + }}) case "/help": - a.sendStatus(AgentDoneMsg{}) var b strings.Builder b.WriteString("## Available Commands\n\n") b.WriteString("- **/vms**: List available VMs for cloning\n") @@ -421,16 +419,15 @@ func (a *FluidAgent) Run(input string) tea.Cmd { b.WriteString("- **/help**: Show this help message\n") b.WriteString("\n## Keyboard Shortcuts\n\n") b.WriteString("- **PgUp/PgDn**: Scroll conversation history\n") - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: b.String(), Done: true, - }} + }}) default: - a.sendStatus(AgentDoneMsg{}) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: fmt.Sprintf("Unknown command: %s. Available: /vms, /sandboxes, /hosts, /playbooks, /prepare, /allowlist, /compact, /context, /settings", input), Done: true, - }} + }}) } } @@ -444,8 +441,7 @@ func (a *FluidAgent) Run(input string) tea.Cmd { // LLM client is required if a.llmClient == nil || a.cfg.AIAgent.APIKey == "" { - a.sendStatus(AgentDoneMsg{}) - return AgentErrorMsg{Err: fmt.Errorf("LLM provider not configured. Please set OPENROUTER_API_KEY environment variable or configure it in /settings")} + return a.finishRun(AgentErrorMsg{Err: fmt.Errorf("LLM provider not configured. Please set OPENROUTER_API_KEY environment variable or configure it in /settings")}) } // Check if auto-compaction is needed before making LLM call @@ -465,8 +461,7 @@ func (a *FluidAgent) Run(input string) tea.Cmd { // LLM-driven execution loop for iteration := 0; ; iteration++ { if ctx.Err() != nil { - a.sendStatus(AgentDoneMsg{}) - return AgentCancelledMsg{RunID: currentRunID} + return a.finishRun(AgentCancelledMsg{RunID: currentRunID}) } a.logger.Debug("LLM loop iteration", "iteration", iteration, "history_len", len(a.history)) systemPrompt := a.cfg.AIAgent.DefaultSystem @@ -547,14 +542,12 @@ func (a *FluidAgent) Run(input string) tea.Cmd { resp, err := a.llmClient.Chat(ctx, req) if err != nil { a.logger.Error("LLM chat failed", "error", err) - a.sendStatus(AgentDoneMsg{}) - return AgentErrorMsg{Err: fmt.Errorf("llm chat: %w", err)} + return a.finishRun(AgentErrorMsg{Err: fmt.Errorf("llm chat: %w", err)}) } if len(resp.Choices) == 0 { a.logger.Error("LLM returned no choices") - a.sendStatus(AgentDoneMsg{}) - return AgentErrorMsg{Err: fmt.Errorf("llm returned no choices")} + return a.finishRun(AgentErrorMsg{Err: fmt.Errorf("llm returned no choices")}) } msg := resp.Choices[0].Message @@ -587,8 +580,7 @@ func (a *FluidAgent) Run(input string) tea.Cmd { // Handle tool calls for _, tc := range msg.ToolCalls { if ctx.Err() != nil { - a.sendStatus(AgentDoneMsg{}) - return AgentCancelledMsg{RunID: currentRunID} + return a.finishRun(AgentCancelledMsg{RunID: currentRunID}) } a.logger.Debug("executing tool call", "tool", tc.Function.Name, "call_id", tc.ID) toolStart := time.Now() @@ -644,14 +636,13 @@ func (a *FluidAgent) Run(input string) tea.Cmd { continue } - // No more tool calls, return final response - // Tool results were already sent via ToolCompleteMsg - // Send done message to unblock status listener - a.sendStatus(AgentDoneMsg{}) - return AgentResponseMsg{Response: AgentResponse{ + // No more tool calls. Send the final response through statusCallback so + // ToolCompleteMsg stays ordered ahead of it, then return AgentDoneMsg + // directly as the only completion signal for this run. + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: msg.Content, Done: true, - }} + }}) } } } @@ -680,6 +671,7 @@ func (a *FluidAgent) executeTool(ctx context.Context, tc llm.ToolCall) (any, err case "list_sandboxes": return a.listSandboxes(ctx) case "create_sandbox": + a.clearStickyReadOnly() var args struct { SourceVM string `json:"source_vm"` Host string `json:"host"` @@ -692,6 +684,7 @@ func (a *FluidAgent) executeTool(ctx context.Context, tc llm.ToolCall) (any, err } return a.createSandbox(ctx, args.SourceVM, args.Host, args.CPU, args.MemoryMB, args.Live) case "destroy_sandbox": + a.clearStickyReadOnly() var args struct { SandboxID string `json:"sandbox_id"` } @@ -700,6 +693,7 @@ func (a *FluidAgent) executeTool(ctx context.Context, tc llm.ToolCall) (any, err } return a.destroySandbox(ctx, args.SandboxID) case "run_command": + a.clearStickyReadOnly() var args struct { SandboxID string `json:"sandbox_id"` Command string `json:"command"` @@ -709,6 +703,7 @@ func (a *FluidAgent) executeTool(ctx context.Context, tc llm.ToolCall) (any, err } return a.runCommand(ctx, args.SandboxID, args.Command) case "start_sandbox": + a.clearStickyReadOnly() var args struct { SandboxID string `json:"sandbox_id"` } @@ -717,6 +712,7 @@ func (a *FluidAgent) executeTool(ctx context.Context, tc llm.ToolCall) (any, err } return a.startSandbox(ctx, args.SandboxID) case "stop_sandbox": + a.clearStickyReadOnly() var args struct { SandboxID string `json:"sandbox_id"` } @@ -732,9 +728,10 @@ func (a *FluidAgent) executeTool(ctx context.Context, tc llm.ToolCall) (any, err return nil, err } return a.getSandbox(ctx, args.SandboxID) - case "list_vms": - return a.listVMs(ctx) + // case "list_vms": // use list_hosts instead + // return a.listVMs(ctx) case "create_snapshot": + a.clearStickyReadOnly() var args struct { SandboxID string `json:"sandbox_id"` Name string `json:"name"` @@ -744,12 +741,14 @@ func (a *FluidAgent) executeTool(ctx context.Context, tc llm.ToolCall) (any, err } return a.createSnapshot(ctx, args.SandboxID, args.Name) case "create_playbook": + a.clearStickyReadOnly() var args ansible.CreatePlaybookRequest if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return nil, err } return a.playbookService.CreatePlaybook(ctx, args) case "add_playbook_task": + a.clearStickyReadOnly() var args struct { PlaybookID string `json:"playbook_id"` Name string `json:"name"` @@ -765,6 +764,7 @@ func (a *FluidAgent) executeTool(ctx context.Context, tc llm.ToolCall) (any, err Params: args.Params, }) case "edit_file": + a.clearStickyReadOnly() var args struct { SandboxID string `json:"sandbox_id"` Path string `json:"path"` @@ -1072,20 +1072,47 @@ func (a *FluidAgent) createSandbox(ctx context.Context, sourceVM, hostName strin } a.logger.Info("sandbox creation attempt", "source_vm", sourceVM, "cpu", cpu, "memory_mb", memoryMB, "live", live) + lastStepNum := 0 + lastTotal := 0 - sb, err := a.service.CreateSandbox(ctx, sandbox.CreateRequest{ + sb, err := a.service.CreateSandboxStream(ctx, sandbox.CreateRequest{ SourceVM: sourceVM, AgentID: "tui-agent", VCPUs: cpu, MemoryMB: memoryMB, Live: live, + }, func(step string, stepNum, total int) { + lastStepNum = stepNum + lastTotal = total + a.sendStatus(SandboxCreateProgressMsg{ + SourceVM: sourceVM, + StepName: step, + StepNum: stepNum, + Total: total, + }) }) if err != nil { + a.sendStatus(SandboxCreateProgressMsg{Done: true}) a.logger.Error("sandbox creation failed", "source_vm", sourceVM, "error", err) return nil, err } a.logger.Info("sandbox created", "sandbox_id", sb.ID, "ip", sb.IPAddress) + doneMsg := SandboxCreateProgressMsg{ + SourceVM: sourceVM, + Done: true, + } + if lastTotal > 0 { + doneMsg.StepName = "Ready" + doneMsg.StepNum = lastTotal + doneMsg.Total = lastTotal + if lastStepNum > lastTotal { + doneMsg.StepNum = lastStepNum + doneMsg.Total = lastStepNum + } + } + a.sendStatus(doneMsg) + // Track the created sandbox for cleanup on exit a.createdSandboxes = append(a.createdSandboxes, sb.ID) @@ -1127,59 +1154,70 @@ func (a *FluidAgent) HandleSourcePrepareApprovalResponse(approved bool) { // runPrepareInline runs source host preparation inline in the TUI, sending progress via SourcePrepareProgressMsg. func (a *FluidAgent) runPrepareInline(ctx context.Context, hostname string) tea.Msg { + identityPubKey := config.DaemonIdentityPubKey(a.cfg.SandboxHosts) + totalSteps := 4 + if identityPubKey != "" { + totalSteps = 5 + } + // 1. Resolve SSH config - a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Resolving SSH config", StepNum: 1, Total: 4}) + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Resolving SSH config", StepNum: 1, Total: totalSteps}) resolved, err := sshconfig.Resolve(hostname) if err != nil { - a.sendStatus(AgentDoneMsg{}) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: fmt.Sprintf("Failed to resolve SSH config for %s: %v", hostname, err), Done: true, - }} + }}) } - a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Resolving SSH config", StepNum: 1, Total: 4, Done: true}) + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Resolving SSH config", StepNum: 1, Total: totalSteps, Done: true}) // 2. Ensure SSH key pair - a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Generating SSH key pair", StepNum: 2, Total: 4}) + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Generating SSH key pair", StepNum: 2, Total: totalSteps}) _, pubKey, err := sourcekeys.EnsureKeyPair(a.cfg.SSH.SourceKeyDir) if err != nil { - a.sendStatus(AgentDoneMsg{}) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: fmt.Sprintf("Failed to generate key pair: %v", err), Done: true, - }} + }}) } - a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Generating SSH key pair", StepNum: 2, Total: 4, Done: true}) + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Generating SSH key pair", StepNum: 2, Total: totalSteps, Done: true}) // 3. Prepare host for read-only access - a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Preparing host", StepNum: 3, Total: 4}) + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Preparing host", StepNum: 3, Total: totalSteps}) sshRunFn := hostexec.NewSSHAlias(hostname) sshRun := readonly.SSHRunFunc(sshRunFn) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) _, err = readonly.PrepareWithKey(ctx, sshRun, pubKey, nil, logger) if err != nil { - a.sendStatus(AgentDoneMsg{}) - return AgentResponseMsg{Response: AgentResponse{ + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: fmt.Sprintf("Preparation failed for %s: %v", hostname, err), Done: true, - }} + }}) } - a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Preparing host", StepNum: 3, Total: 4, Done: true}) + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Preparing host", StepNum: 3, Total: totalSteps, Done: true}) - // 4. Update config - a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Saving config", StepNum: 4, Total: 4}) + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Saving config", StepNum: 4, Total: totalSteps}) configPath, _ := paths.ConfigFile() if err := source.SavePreparedHost(a.cfg, configPath, hostname, resolved); err != nil { a.logger.Warn("failed to save config after prepare", "error", err) } - a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Saving config", StepNum: 4, Total: 4, Done: true}) - a.sendStatus(AgentDoneMsg{}) + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Saving config", StepNum: 4, Total: totalSteps, Done: true}) - return AgentResponseMsg{Response: AgentResponse{ + // 5. Deploy daemon identity key if available + if identityPubKey != "" { + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Deploying daemon SSH key", StepNum: 5, Total: totalSteps}) + deployErr := readonly.DeployDaemonKey(ctx, sshRun, identityPubKey, logger) + if deployErr != nil { + a.logger.Warn("daemon key deploy failed (non-fatal)", "host", hostname, "error", deployErr) + } + a.sendStatus(SourcePrepareProgressMsg{SourceVM: hostname, StepName: "Deploying daemon SSH key", StepNum: 5, Total: totalSteps, Done: true}) + } + + return a.finishRun(AgentResponseMsg{Response: AgentResponse{ Content: fmt.Sprintf("Host %s is prepared.", hostname), Done: true, - }} + }}) } func (a *FluidAgent) destroySandbox(ctx context.Context, id string) (map[string]any, error) { @@ -1251,26 +1289,49 @@ func (a *FluidAgent) runCommand(ctx context.Context, sandboxID, command string) } } + a.sendStatus(CommandOutputStartMsg{SandboxID: sandboxID}) + result, err := a.service.RunCommand(ctx, sandboxID, command, 0, nil) if err != nil { a.logger.Error("command execution failed", "sandbox_id", sandboxID, "error", err) + a.sendStatus(CommandOutputDoneMsg{SandboxID: sandboxID}) if result != nil { + stdout, stdoutRedacted := a.redactContent(result.Stdout) + stderr, stderrRedacted := a.redactContent(result.Stderr) + if stdoutRedacted || stderrRedacted { + a.sendRedactedMsg(sandboxID, "") + } return map[string]any{ "sandbox_id": sandboxID, "exit_code": result.ExitCode, - "stdout": result.Stdout, - "stderr": result.Stderr, + "stdout": stdout, + "stderr": stderr, "error": err.Error(), }, nil } return nil, err } + stdout, stdoutRedacted := a.redactContent(result.Stdout) + stderr, stderrRedacted := a.redactContent(result.Stderr) + if stdoutRedacted || stderrRedacted { + a.sendRedactedMsg(sandboxID, "") + } + + // Show output in live output box + if stdout != "" { + a.sendStatus(CommandOutputChunkMsg{SandboxID: sandboxID, Chunk: stdout}) + } + if stderr != "" { + a.sendStatus(CommandOutputChunkMsg{SandboxID: sandboxID, IsStderr: true, Chunk: stderr}) + } + a.sendStatus(CommandOutputDoneMsg{SandboxID: sandboxID}) + return map[string]any{ "sandbox_id": sandboxID, "exit_code": result.ExitCode, - "stdout": result.Stdout, - "stderr": result.Stderr, + "stdout": stdout, + "stderr": stderr, }, nil } @@ -1354,6 +1415,9 @@ func (a *FluidAgent) editFile(ctx context.Context, sandboxID, path, oldStr, newS a.logger.Error("failed to create file", "sandbox_id", sandboxID, "path", path, "stderr", result.Stderr) return nil, fmt.Errorf("failed to create file: %s", result.Stderr) } + a.sendStatus(CommandOutputStartMsg{SandboxID: sandboxID}) + a.sendStatus(CommandOutputChunkMsg{SandboxID: sandboxID, Chunk: fmt.Sprintf("Created %s\n", path)}) + a.sendStatus(CommandOutputDoneMsg{SandboxID: sandboxID}) return map[string]any{ "sandbox_id": sandboxID, "path": path, @@ -1406,6 +1470,10 @@ func (a *FluidAgent) editFile(ctx context.Context, sandboxID, path, oldStr, newS return nil, fmt.Errorf("failed to write file: %s", writeResult.Stderr) } + a.sendStatus(CommandOutputStartMsg{SandboxID: sandboxID}) + a.sendStatus(CommandOutputChunkMsg{SandboxID: sandboxID, Chunk: fmt.Sprintf("Edited %s\n", path)}) + a.sendStatus(CommandOutputDoneMsg{SandboxID: sandboxID}) + return map[string]any{ "sandbox_id": sandboxID, "path": path, @@ -1460,6 +1528,11 @@ func (a *FluidAgent) readFile(ctx context.Context, sandboxID, path string) (map[ a.sendRedactedMsg(sandboxID, path) } + // Show file content in live output box + a.sendStatus(CommandOutputStartMsg{SandboxID: sandboxID}) + a.sendStatus(CommandOutputChunkMsg{SandboxID: sandboxID, Chunk: content + "\n"}) + a.sendStatus(CommandOutputDoneMsg{SandboxID: sandboxID}) + return map[string]any{ "sandbox_id": sandboxID, "path": path, @@ -1572,31 +1645,32 @@ func (a *FluidAgent) getSandbox(ctx context.Context, id string) (map[string]any, return result, nil } -func (a *FluidAgent) listVMs(ctx context.Context) (map[string]any, error) { - vms, err := a.service.ListVMs(ctx) - if err != nil { - a.logger.Error("list VMs failed", "error", err) - return nil, err - } - - result := make([]map[string]any, 0, len(vms)) - for _, v := range vms { - item := map[string]any{ - "name": v.Name, - "state": v.State, - "prepared": v.Prepared, - } - if v.IPAddress != "" { - item["ip"] = v.IPAddress - } - result = append(result, item) - } - - return map[string]any{ - "vms": result, - "count": len(result), - }, nil -} +// list_vms - use list_hosts instead +// func (a *FluidAgent) listVMs(ctx context.Context) (map[string]any, error) { +// vms, err := a.service.ListVMs(ctx) +// if err != nil { +// a.logger.Error("list VMs failed", "error", err) +// return nil, err +// } +// +// result := make([]map[string]any, 0, len(vms)) +// for _, v := range vms { +// item := map[string]any{ +// "name": v.Name, +// "state": v.State, +// "prepared": v.Prepared, +// } +// if v.IPAddress != "" { +// item["ip"] = v.IPAddress +// } +// result = append(result, item) +// } +// +// return map[string]any{ +// "vms": result, +// "count": len(result), +// }, nil +// } func (a *FluidAgent) createSnapshot(ctx context.Context, sandboxID, name string) (map[string]any, error) { if name == "" { @@ -1619,54 +1693,55 @@ func (a *FluidAgent) createSnapshot(ctx context.Context, sandboxID, name string) // Formatting helpers -func (a *FluidAgent) formatVMsResult(result map[string]any, err error) string { - if err != nil { - return fmt.Sprintf("Failed to list VMs: %v", err) - } - - vms, ok := result["vms"].([]map[string]any) - if !ok || len(vms) == 0 { - return "No VMs found." - } - - var b strings.Builder - b.WriteString(fmt.Sprintf("Found %d VM(s) available for cloning:\n\n", len(vms))) - - // Group VMs by host if host information is present - hostVMs := make(map[string][]map[string]any) - for _, vm := range vms { - host := "local" - if h, ok := vm["host"].(string); ok && h != "" { - host = h - } - hostVMs[host] = append(hostVMs[host], vm) - } - - // Display VMs grouped by host - for host, hvms := range hostVMs { - if len(hostVMs) > 1 || host != "local" { - b.WriteString(fmt.Sprintf("### Host: %s\n", host)) - } - for _, vm := range hvms { - state := "unknown" - if s, ok := vm["state"].(string); ok { - state = s - } - b.WriteString(fmt.Sprintf("- **%s** (%s)\n", vm["name"], state)) - } - b.WriteString("\n") - } - - // Display any host errors - if hostErrors, ok := result["host_errors"].([]map[string]any); ok && len(hostErrors) > 0 { - b.WriteString("### Host Errors\n") - for _, he := range hostErrors { - b.WriteString(fmt.Sprintf("- **%s**: %s\n", he["host"], he["error"])) - } - } - - return b.String() -} +// formatVMsResult - use list_hosts instead +// func (a *FluidAgent) formatVMsResult(result map[string]any, err error) string { +// if err != nil { +// return fmt.Sprintf("Failed to list VMs: %v", err) +// } +// +// vms, ok := result["vms"].([]map[string]any) +// if !ok || len(vms) == 0 { +// return "No VMs found." +// } +// +// var b strings.Builder +// b.WriteString(fmt.Sprintf("Found %d VM(s) available for cloning:\n\n", len(vms))) +// +// // Group VMs by host if host information is present +// hostVMs := make(map[string][]map[string]any) +// for _, vm := range vms { +// host := "local" +// if h, ok := vm["host"].(string); ok && h != "" { +// host = h +// } +// hostVMs[host] = append(hostVMs[host], vm) +// } +// +// // Display VMs grouped by host +// for host, hvms := range hostVMs { +// if len(hostVMs) > 1 || host != "local" { +// b.WriteString(fmt.Sprintf("### Host: %s\n", host)) +// } +// for _, vm := range hvms { +// state := "unknown" +// if s, ok := vm["state"].(string); ok { +// state = s +// } +// b.WriteString(fmt.Sprintf("- **%s** (%s)\n", vm["name"], state)) +// } +// b.WriteString("\n") +// } +// +// // Display any host errors +// if hostErrors, ok := result["host_errors"].([]map[string]any); ok && len(hostErrors) > 0 { +// b.WriteString("### Host Errors\n") +// for _, he := range hostErrors { +// b.WriteString(fmt.Sprintf("- **%s**: %s\n", he["host"], he["error"])) +// } +// } +// +// return b.String() +// } func (a *FluidAgent) formatSandboxesResult(result map[string]any, err error) string { if err != nil { @@ -2139,4 +2214,18 @@ func (a *FluidAgent) ClearAutoReadOnly() { a.mu.Lock() defer a.mu.Unlock() a.autoReadOnly = false + a.displayReadOnly = false +} + +// clearStickyReadOnly clears the sticky read-only display state when a write tool executes. +// Sends AutoReadOnlyMsg{Enabled: false} to the model if sticky display was active. +func (a *FluidAgent) clearStickyReadOnly() { + a.mu.Lock() + if !a.displayReadOnly { + a.mu.Unlock() + return + } + a.displayReadOnly = false + a.mu.Unlock() + a.sendStatus(AutoReadOnlyMsg{Enabled: false}) } diff --git a/fluid-cli/internal/tui/agent_test.go b/fluid-cli/internal/tui/agent_test.go index 2086ed3d..92589711 100644 --- a/fluid-cli/internal/tui/agent_test.go +++ b/fluid-cli/internal/tui/agent_test.go @@ -3,16 +3,25 @@ package tui import ( "context" "encoding/base64" + "errors" + "io" + "log/slog" "strings" "testing" + "time" + tea "github.com/charmbracelet/bubbletea" + + "github.com/aspectrr/fluid.sh/fluid-cli/internal/config" "github.com/aspectrr/fluid.sh/fluid-cli/internal/redact" "github.com/aspectrr/fluid.sh/fluid-cli/internal/sandbox" + "github.com/aspectrr/fluid.sh/fluid-cli/internal/telemetry" ) // stubService is a minimal sandbox.Service for testing SetSandboxService. type stubService struct { - closed bool + closed bool + createSandboxStreamFn func(context.Context, sandbox.CreateRequest, func(string, int, int)) (*sandbox.SandboxInfo, error) } func (s *stubService) CreateSandbox(context.Context, sandbox.CreateRequest) (*sandbox.SandboxInfo, error) { @@ -54,8 +63,23 @@ func (s *stubService) RunSourceCommand(context.Context, string, string, int) (*s func (s *stubService) ReadSourceFile(context.Context, string, string) (string, error) { return "", nil } + +func (s *stubService) CreateSandboxStream(_ context.Context, req sandbox.CreateRequest, progress func(string, int, int)) (*sandbox.SandboxInfo, error) { + if s.createSandboxStreamFn != nil { + return s.createSandboxStreamFn(context.Background(), req, progress) + } + return s.CreateSandbox(context.Background(), req) +} func (s *stubService) GetHostInfo(context.Context) (*sandbox.HostInfo, error) { return nil, nil } func (s *stubService) Health(context.Context) error { return nil } +func (s *stubService) DoctorCheck(context.Context) ([]sandbox.DoctorCheckResult, error) { + return nil, nil +} + +func (s *stubService) ScanSourceHostKeys(context.Context) ([]sandbox.ScanSourceHostKeysResult, error) { + return nil, nil +} + func (s *stubService) Close() error { s.closed = true return nil @@ -108,7 +132,7 @@ func TestSetSandboxService_ClosesOldService(t *testing.T) { func TestSetSandboxService_WaitsForDone(t *testing.T) { done := make(chan struct{}) - a := &FluidAgent{done: done} + a := &FluidAgent{done: done, swapTimeout: 2 * time.Second} // Close done channel to simulate goroutine finishing close(done) svc := &stubService{} @@ -130,6 +154,161 @@ func TestSetSandboxService_TimesOut(t *testing.T) { } } +func TestRun_SlashCommandSendsFinalResponseWithoutQueuedDoneStatus(t *testing.T) { + var statuses []tea.Msg + agent := &FluidAgent{ + cfg: &config.Config{}, + telemetry: telemetry.NewNoopService(), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + agent.SetStatusCallback(func(msg tea.Msg) { + statuses = append(statuses, msg) + }) + + result := agent.Run("/help")() + if _, ok := result.(AgentDoneMsg); !ok { + t.Fatalf("Run(/help) returned %T, want AgentDoneMsg", result) + } + if len(statuses) != 1 { + t.Fatalf("status count = %d, want 1", len(statuses)) + } + resp, ok := statuses[0].(AgentResponseMsg) + if !ok { + t.Fatalf("status type = %T, want AgentResponseMsg", statuses[0]) + } + if !resp.Response.Done { + t.Fatal("expected help response to mark the run complete") + } + if !strings.Contains(resp.Response.Content, "Available Commands") { + t.Fatalf("help response missing command list: %q", resp.Response.Content) + } + for _, status := range statuses { + if _, ok := status.(AgentDoneMsg); ok { + t.Fatal("AgentDoneMsg should not be queued through the status callback") + } + } +} + +func TestRun_LLMConfigErrorSendsStatusWithoutQueuedDone(t *testing.T) { + var statuses []tea.Msg + agent := &FluidAgent{ + cfg: &config.Config{}, + telemetry: telemetry.NewNoopService(), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + agent.SetStatusCallback(func(msg tea.Msg) { + statuses = append(statuses, msg) + }) + + result := agent.Run("investigate nginx")() + if _, ok := result.(AgentDoneMsg); !ok { + t.Fatalf("Run(investigate nginx) returned %T, want AgentDoneMsg", result) + } + if len(statuses) != 1 { + t.Fatalf("status count = %d, want 1", len(statuses)) + } + errMsg, ok := statuses[0].(AgentErrorMsg) + if !ok { + t.Fatalf("status type = %T, want AgentErrorMsg", statuses[0]) + } + if !strings.Contains(errMsg.Err.Error(), "LLM provider not configured") { + t.Fatalf("unexpected error: %v", errMsg.Err) + } + for _, status := range statuses { + if _, ok := status.(AgentDoneMsg); ok { + t.Fatal("AgentDoneMsg should not be queued through the status callback") + } + } +} + +func TestCreateSandbox_SendsDoneProgressOnSuccess(t *testing.T) { + var statuses []tea.Msg + svc := &stubService{ + createSandboxStreamFn: func(_ context.Context, _ sandbox.CreateRequest, progress func(string, int, int)) (*sandbox.SandboxInfo, error) { + progress("Discovering IP address", 8, 9) + return &sandbox.SandboxInfo{ + ID: "SBX-123", + Name: "sandbox", + State: "RUNNING", + BaseImage: "ubuntu", + IPAddress: "10.0.0.2", + }, nil + }, + } + agent := &FluidAgent{ + service: svc, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + agent.SetStatusCallback(func(msg tea.Msg) { + statuses = append(statuses, msg) + }) + + result, err := agent.createSandbox(context.Background(), "ubuntu", "", 2, 2048, true) + if err != nil { + t.Fatalf("createSandbox returned error: %v", err) + } + if got := result["sandbox_id"]; got != "SBX-123" { + t.Fatalf("sandbox_id = %v, want %q", got, "SBX-123") + } + + progresses := collectCreateProgressMessages(statuses) + if len(progresses) != 2 { + t.Fatalf("progress message count = %d, want 2", len(progresses)) + } + if progresses[0].Done { + t.Fatal("expected first progress message to be in-flight") + } + if !progresses[1].Done { + t.Fatal("expected final progress message to mark creation done") + } + if progresses[1].StepName != "Ready" || progresses[1].StepNum != 9 || progresses[1].Total != 9 { + t.Fatalf("final done progress = %+v, want Ready [9/9]", progresses[1]) + } +} + +func TestCreateSandbox_SendsDoneProgressOnError(t *testing.T) { + var statuses []tea.Msg + svc := &stubService{ + createSandboxStreamFn: func(_ context.Context, _ sandbox.CreateRequest, progress func(string, int, int)) (*sandbox.SandboxInfo, error) { + progress("Booting microVM", 7, 9) + return nil, errors.New("boom") + }, + } + agent := &FluidAgent{ + service: svc, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + agent.SetStatusCallback(func(msg tea.Msg) { + statuses = append(statuses, msg) + }) + + _, err := agent.createSandbox(context.Background(), "ubuntu", "", 2, 2048, true) + if err == nil { + t.Fatal("expected createSandbox to return error") + } + + progresses := collectCreateProgressMessages(statuses) + if len(progresses) != 2 { + t.Fatalf("progress message count = %d, want 2", len(progresses)) + } + if !progresses[1].Done { + t.Fatal("expected final progress message to close the live create box on error") + } + if progresses[1].StepName != "" || progresses[1].StepNum != 0 || progresses[1].Total != 0 { + t.Fatalf("error done progress = %+v, want empty step details", progresses[1]) + } +} + +func collectCreateProgressMessages(statuses []tea.Msg) []SandboxCreateProgressMsg { + progresses := make([]SandboxCreateProgressMsg, 0, len(statuses)) + for _, status := range statuses { + if progress, ok := status.(SandboxCreateProgressMsg); ok { + progresses = append(progresses, progress) + } + } + return progresses +} + func TestShellEscape(t *testing.T) { tests := []struct { name string @@ -373,6 +552,110 @@ func TestRedactPrivateKeys_CertificateNotRedacted(t *testing.T) { } } +// TestWithAutoReadOnly_StickyDisplay verifies that after a source VM op, model receives +// AutoReadOnlyMsg{Enabled:true} but NOT AutoReadOnlyMsg{Enabled:false} on exit. +func TestWithAutoReadOnly_StickyDisplay(t *testing.T) { + var msgs []AutoReadOnlyMsg + a := &FluidAgent{} + a.SetStatusCallback(func(msg tea.Msg) { + if m, ok := msg.(AutoReadOnlyMsg); ok { + msgs = append(msgs, m) + } + }) + + _, _ = a.withAutoReadOnly("myvm", func() (any, error) { + return nil, nil + }) + + if len(msgs) != 1 { + t.Fatalf("expected 1 AutoReadOnlyMsg, got %d: %v", len(msgs), msgs) + } + if !msgs[0].Enabled { + t.Error("expected Enabled:true enter message") + } + if !a.displayReadOnly { + t.Error("displayReadOnly should remain true after withAutoReadOnly exits") + } + if a.readOnly { + t.Error("readOnly (tool-filtering) should be restored to false") + } +} + +// TestClearStickyReadOnly_SendsExitMsg verifies that clearStickyReadOnly sends the exit message. +func TestClearStickyReadOnly_SendsExitMsg(t *testing.T) { + var msgs []AutoReadOnlyMsg + a := &FluidAgent{displayReadOnly: true} + a.SetStatusCallback(func(msg tea.Msg) { + if m, ok := msg.(AutoReadOnlyMsg); ok { + msgs = append(msgs, m) + } + }) + + a.clearStickyReadOnly() + + if len(msgs) != 1 { + t.Fatalf("expected 1 AutoReadOnlyMsg, got %d", len(msgs)) + } + if msgs[0].Enabled { + t.Error("expected Enabled:false exit message") + } + if a.displayReadOnly { + t.Error("displayReadOnly should be cleared") + } +} + +// TestClearStickyReadOnly_NoOp verifies that clearStickyReadOnly is a no-op when not sticky. +func TestClearStickyReadOnly_NoOp(t *testing.T) { + var msgs []AutoReadOnlyMsg + a := &FluidAgent{displayReadOnly: false} + a.SetStatusCallback(func(msg tea.Msg) { + if m, ok := msg.(AutoReadOnlyMsg); ok { + msgs = append(msgs, m) + } + }) + + a.clearStickyReadOnly() + + if len(msgs) != 0 { + t.Fatalf("expected no messages, got %d", len(msgs)) + } +} + +// TestClearAutoReadOnly_ClearsDisplayReadOnly verifies that Shift+Tab also clears displayReadOnly. +func TestClearAutoReadOnly_ClearsDisplayReadOnly(t *testing.T) { + a := &FluidAgent{autoReadOnly: true, displayReadOnly: true} + a.ClearAutoReadOnly() + if a.displayReadOnly { + t.Error("ClearAutoReadOnly should also clear displayReadOnly") + } + if a.autoReadOnly { + t.Error("ClearAutoReadOnly should clear autoReadOnly") + } +} + +// TestWithAutoReadOnly_AlreadyReadOnly verifies that when agent is already in manual read-only, +// displayReadOnly is not set (user controls the mode). +func TestWithAutoReadOnly_AlreadyReadOnly(t *testing.T) { + var msgs []AutoReadOnlyMsg + a := &FluidAgent{readOnly: true} + a.SetStatusCallback(func(msg tea.Msg) { + if m, ok := msg.(AutoReadOnlyMsg); ok { + msgs = append(msgs, m) + } + }) + + _, _ = a.withAutoReadOnly("myvm", func() (any, error) { + return nil, nil + }) + + if len(msgs) != 0 { + t.Fatalf("expected no AutoReadOnlyMsg when already in read-only, got %d", len(msgs)) + } + if a.displayReadOnly { + t.Error("displayReadOnly should not be set when already manually in read-only") + } +} + // TestShellEscapeInjectionPrevention tests that shellEscape prevents command injection func TestShellEscapeInjectionPrevention(t *testing.T) { maliciousInputs := []string{ diff --git a/fluid-cli/internal/tui/allowlist.go b/fluid-cli/internal/tui/allowlist.go index ebb17e08..ec326de8 100644 --- a/fluid-cli/internal/tui/allowlist.go +++ b/fluid-cli/internal/tui/allowlist.go @@ -18,31 +18,36 @@ type allowlistMode int const ( allowlistModeList allowlistMode = iota allowlistModeAdd + allowlistModeDetail ) type allowlistStyles struct { - title lipgloss.Style - help lipgloss.Style - section lipgloss.Style - command lipgloss.Style - userCommand lipgloss.Style - dimmed lipgloss.Style - error lipgloss.Style - success lipgloss.Style - indicator lipgloss.Style + title lipgloss.Style + help lipgloss.Style + section lipgloss.Style + command lipgloss.Style + userCommand lipgloss.Style + dimmed lipgloss.Style + error lipgloss.Style + success lipgloss.Style + indicator lipgloss.Style + toggleAllowlist lipgloss.Style + toggleBlocklist lipgloss.Style } func defaultAllowlistStyles() allowlistStyles { return allowlistStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3B82F6")), - help: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), - section: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#06B6D4")), - command: lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF")), - userCommand: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), - dimmed: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), - error: lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")), - success: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), - indicator: lipgloss.NewStyle().Foreground(lipgloss.Color("#3B82F6")), + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3B82F6")), + help: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), + section: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#06B6D4")), + command: lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF")), + userCommand: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), + dimmed: lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")), + error: lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")), + success: lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")), + indicator: lipgloss.NewStyle().Foreground(lipgloss.Color("#3B82F6")), + toggleAllowlist: lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED")).Background(lipgloss.Color("#EDE9FE")), + toggleBlocklist: lipgloss.NewStyle().Foreground(lipgloss.Color("#D97706")).Background(lipgloss.Color("#FEF3C7")), } } @@ -59,6 +64,15 @@ type AllowlistModel struct { scrollY int addInput textinput.Model addErr string + + detailCommand string + detailSubcmds []string + detailIsBuiltin map[string]bool + detailSelected int + detailScrollY int + detailAddInput textinput.Model + detailAddErr string + detailModeIsAllowlist bool } func NewAllowlistModel(cfg *config.Config) AllowlistModel { @@ -67,6 +81,13 @@ func NewAllowlistModel(cfg *config.Config) AllowlistModel { addInput.Placeholder = "e.g., custom-tool" addInput.Focus() + detailAddInput := textinput.New() + detailAddInput.Prompt = "Subcommand: " + detailAddInput.Placeholder = "status" + detailAddInput.Width = 30 + detailAddInput.Focus() + detailAddInput.SetValue("") + m := AllowlistModel{ cfg: cfg, styles: defaultAllowlistStyles(), @@ -77,10 +98,18 @@ func NewAllowlistModel(cfg *config.Config) AllowlistModel { selected: 0, scrollY: 0, addInput: addInput, + detailAddInput: detailAddInput, } copy(m.userCmds, cfg.ExtraAllowedCommands) sort.Strings(m.userCmds) + if cfg.ExtraAllowedSubcommands == nil { + cfg.ExtraAllowedSubcommands = make(map[string][]string) + } + if cfg.ExtraAllowedSubcommandsMode == nil { + cfg.ExtraAllowedSubcommandsMode = make(map[string]bool) + } + return m } @@ -106,39 +135,73 @@ func (m AllowlistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.addErr = "" return m, nil } + if m.mode == allowlistModeDetail { + m.mode = allowlistModeList + m.detailCommand = "" + m.detailSubcmds = nil + m.detailIsBuiltin = nil + m.detailSelected = 0 + m.detailScrollY = 0 + m.detailAddInput.SetValue("") + m.detailAddErr = "" + return m, nil + } return m, func() tea.Msg { return AllowlistCloseMsg{Saved: false} } case "ctrl+s": return m, func() tea.Msg { return AllowlistCloseMsg{Saved: true} } case "up": - if m.mode == allowlistModeList { + switch m.mode { + case allowlistModeList: if m.selected > 0 { m.selected-- m.ensureVisible() } + case allowlistModeDetail: + if m.detailSelected > 0 { + m.detailSelected-- + m.ensureDetailVisible() + } } return m, nil case "down": - if m.mode == allowlistModeList { + switch m.mode { + case allowlistModeList: totalItems := len(m.builtinCmds) + len(m.userCmds) if m.selected < totalItems-1 { m.selected++ m.ensureVisible() } + case allowlistModeDetail: + if m.detailSelected < len(m.detailSubcmds)-1 { + m.detailSelected++ + m.ensureDetailVisible() + } } return m, nil case "ctrl+n": - if m.mode == allowlistModeList { + switch m.mode { + case allowlistModeList: m.mode = allowlistModeAdd m.addInput.Focus() + case allowlistModeDetail: + m.detailAddInput.Focus() + } + return m, nil + + case "ctrl+t": + if m.mode == allowlistModeDetail { + m.detailModeIsAllowlist = !m.detailModeIsAllowlist + m.cfg.ExtraAllowedSubcommandsMode[m.detailCommand] = m.detailModeIsAllowlist } return m, nil case "enter": - if m.mode == allowlistModeAdd { + switch m.mode { + case allowlistModeAdd: newCmd := strings.TrimSpace(m.addInput.Value()) if newCmd == "" { m.addErr = "command cannot be empty" @@ -164,21 +227,30 @@ func (m AllowlistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.addErr = "" m.selected = len(m.builtinCmds) + len(m.userCmds) - 1 m.ensureVisible() + case allowlistModeDetail: + m.enterDetailAddSubcommand() + default: + m.enterDetailMode() } return m, nil case "d": - if m.mode == allowlistModeList { + switch m.mode { + case allowlistModeList: totalBuiltins := len(m.builtinCmds) if m.selected >= totalBuiltins { idx := m.selected - totalBuiltins + cmdToDelete := m.userCmds[idx] m.userCmds = append(m.userCmds[:idx], m.userCmds[idx+1:]...) m.cfg.ExtraAllowedCommands = m.userCmds + delete(m.cfg.ExtraAllowedSubcommands, cmdToDelete) if m.selected >= len(m.builtinCmds)+len(m.userCmds) && m.selected > 0 { m.selected-- } m.ensureVisible() } + case allowlistModeDetail: + m.deleteDetailSubcommand() } return m, nil } @@ -190,6 +262,12 @@ func (m AllowlistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + if m.mode == allowlistModeDetail { + var cmd tea.Cmd + m.detailAddInput, cmd = m.detailAddInput.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) } @@ -211,6 +289,24 @@ func (m *AllowlistModel) ensureVisible() { } } +func (m *AllowlistModel) ensureDetailVisible() { + visibleItems := m.detailVisibleItemCount() + if m.detailSelected < m.detailScrollY { + m.detailScrollY = m.detailSelected + } + if m.detailSelected >= m.detailScrollY+visibleItems { + m.detailScrollY = m.detailSelected - visibleItems + 1 + } + totalItems := len(m.detailSubcmds) + maxScroll := totalItems - visibleItems + if maxScroll < 0 { + maxScroll = 0 + } + if m.detailScrollY > maxScroll { + m.detailScrollY = maxScroll + } +} + func (m AllowlistModel) visibleItemCount() int { if m.height <= 0 { return 10 @@ -222,12 +318,156 @@ func (m AllowlistModel) visibleItemCount() int { return available } +func (m AllowlistModel) detailVisibleItemCount() int { + if m.height <= 0 { + return 10 + } + available := m.height - 12 + if available < 4 { + return 4 + } + return available +} + +func (m *AllowlistModel) enterDetailMode() { + cmd := "" + isBuiltin := false + if m.selected < len(m.builtinCmds) { + cmd = m.builtinCmds[m.selected] + isBuiltin = true + } else { + userIdx := m.selected - len(m.builtinCmds) + if userIdx < len(m.userCmds) { + cmd = m.userCmds[userIdx] + } + } + if cmd == "" { + return + } + + m.detailCommand = cmd + m.detailSubcmds = []string{} + m.detailIsBuiltin = make(map[string]bool) + + if builtinSubs, ok := m.subcommandRestrs[cmd]; ok { + for _, sub := range builtinSubs { + m.detailSubcmds = append(m.detailSubcmds, sub) + m.detailIsBuiltin[sub] = true + } + } + + if userSubs, ok := m.cfg.ExtraAllowedSubcommands[cmd]; ok { + for _, sub := range userSubs { + if !m.detailIsBuiltin[sub] { + m.detailSubcmds = append(m.detailSubcmds, sub) + m.detailIsBuiltin[sub] = false + } + } + } + + sort.Strings(m.detailSubcmds) + + if isBuiltin { + m.detailModeIsAllowlist = true + } else { + if len(m.detailSubcmds) > 0 { + if mode, ok := m.cfg.ExtraAllowedSubcommandsMode[cmd]; ok { + m.detailModeIsAllowlist = mode + } else { + m.detailModeIsAllowlist = true + } + } else { + m.detailModeIsAllowlist = true + } + } + + m.detailSelected = 0 + m.detailScrollY = 0 + m.detailAddInput.SetValue("") + m.mode = allowlistModeDetail +} + +func (m *AllowlistModel) enterDetailAddSubcommand() { + newSub := strings.TrimSpace(m.detailAddInput.Value()) + if newSub == "" { + m.detailAddErr = "subcommand cannot be empty" + return + } + for _, sub := range m.detailSubcmds { + if sub == newSub { + m.detailAddErr = "subcommand already exists" + return + } + } + + if !m.detailIsBuiltin[newSub] { + m.cfg.ExtraAllowedSubcommands[m.detailCommand] = append( + m.cfg.ExtraAllowedSubcommands[m.detailCommand], + newSub, + ) + sort.Strings(m.cfg.ExtraAllowedSubcommands[m.detailCommand]) + } + + m.detailSubcmds = append(m.detailSubcmds, newSub) + m.detailIsBuiltin[newSub] = false + sort.Strings(m.detailSubcmds) + + for i, sub := range m.detailSubcmds { + if sub == newSub { + m.detailSelected = i + break + } + } + + m.detailAddInput.SetValue("") + m.detailAddErr = "" + m.ensureDetailVisible() +} + +func (m *AllowlistModel) deleteDetailSubcommand() { + if m.detailSelected >= len(m.detailSubcmds) { + return + } + + sub := m.detailSubcmds[m.detailSelected] + isBuiltin := m.detailIsBuiltin[sub] + + if !isBuiltin { + subs := m.cfg.ExtraAllowedSubcommands[m.detailCommand] + for i, s := range subs { + if s == sub { + m.cfg.ExtraAllowedSubcommands[m.detailCommand] = append(subs[:i], subs[i+1:]...) + break + } + } + } + + m.detailSubcmds = append(m.detailSubcmds[:m.detailSelected], m.detailSubcmds[m.detailSelected+1:]...) + delete(m.detailIsBuiltin, sub) + + if m.detailSelected >= len(m.detailSubcmds) && m.detailSelected > 0 { + m.detailSelected-- + } + + if len(m.detailSubcmds) == 0 && m.detailModeIsAllowlist { + m.detailModeIsAllowlist = false + m.cfg.ExtraAllowedSubcommandsMode[m.detailCommand] = false + } + + m.ensureDetailVisible() +} + func (m AllowlistModel) View() string { var b strings.Builder b.WriteString(m.styles.title.Render("Command Allowlist")) b.WriteString("\n") - b.WriteString(m.styles.help.Render("Up/down: navigate | Ctrl+N: add | D: delete | Ctrl+S: save | Esc: close")) + + if m.mode == allowlistModeDetail { + b.WriteString(m.styles.help.Render("Up/down: navigate | Ctrl+N: add | D: delete | Ctrl+T: toggle mode | Esc: back")) + } else { + b.WriteString(m.styles.help.Render("Up/down: navigate | Enter: details | Ctrl+N: add | D: delete | Ctrl+S: save | Esc: close")) + } b.WriteString("\n") if m.mode == allowlistModeAdd { @@ -242,6 +482,10 @@ func (m AllowlistModel) View() string { return b.String() } + if m.mode == allowlistModeDetail { + return m.renderDetailView(&b) + } + totalItems := len(m.builtinCmds) + len(m.userCmds) visibleStart := m.scrollY visibleEnd := m.scrollY + m.visibleItemCount() @@ -306,24 +550,56 @@ func (m AllowlistModel) renderCommandRow(idx int, isUser bool) string { } } + detailTag := m.styles.dimmed.Render(" [Enter for details]") + cmd := "" if idx < len(m.builtinCmds) { cmd = m.builtinCmds[idx] - subs, ok := m.subcommandRestrs[cmd] - if ok { - return fmt.Sprintf("%s%s %s\n", prefix, style.Render(cmd), m.styles.dimmed.Render("("+strings.Join(subs, ", ")+")")) + + userSubs, hasUserSubs := m.cfg.ExtraAllowedSubcommands[cmd] + subs, hasBuiltinSubs := m.subcommandRestrs[cmd] + + if hasUserSubs && len(userSubs) > 0 { + isAllowlist := m.cfg.ExtraAllowedSubcommandsMode[cmd] + var subInfo string + if isAllowlist { + subInfo = fmt.Sprintf("(only: %s)", strings.Join(userSubs, ", ")) + } else { + subInfo = fmt.Sprintf("(all except: %s)", strings.Join(userSubs, ", ")) + } + return fmt.Sprintf("%s%s %s%s\n", prefix, style.Render(cmd), m.styles.dimmed.Render(subInfo), detailTag) + } + + if hasBuiltinSubs { + return fmt.Sprintf("%s%s %s%s\n", prefix, style.Render(cmd), m.styles.dimmed.Render("("+strings.Join(subs, ", ")+")"), detailTag) } - return fmt.Sprintf("%s%s %s\n", prefix, style.Render(cmd), m.styles.dimmed.Render("(all subcommands)")) + return fmt.Sprintf("%s%s %s%s\n", prefix, style.Render(cmd), m.styles.dimmed.Render("(all subcommands)"), detailTag) } userIdx := idx - len(m.builtinCmds) if userIdx < len(m.userCmds) { cmd = m.userCmds[userIdx] - delTag := "" + + userSubs := m.cfg.ExtraAllowedSubcommands[cmd] + isAllowlist := m.cfg.ExtraAllowedSubcommandsMode[cmd] + + var subInfo string + if len(userSubs) > 0 { + if isAllowlist { + subInfo = fmt.Sprintf("(only: %s)", strings.Join(userSubs, ", ")) + } else { + subInfo = fmt.Sprintf("(all except: %s)", strings.Join(userSubs, ", ")) + } + } else { + subInfo = "(all subcommands)" + } + + tags := "" if isUser { - delTag = m.styles.dimmed.Render(" [D to delete]") + tags = m.styles.dimmed.Render(" [D to delete]") } - return fmt.Sprintf("%s%s%s\n", prefix, m.styles.userCommand.Render(cmd), delTag) + tags += detailTag + return fmt.Sprintf("%s%s %s%s\n", prefix, m.styles.userCommand.Render(cmd), m.styles.dimmed.Render(subInfo), tags) } return "" @@ -332,3 +608,96 @@ func (m AllowlistModel) renderCommandRow(idx int, isUser bool) string { func (m AllowlistModel) GetConfig() *config.Config { return m.cfg } + +func (m AllowlistModel) renderModeToggle() string { + var sb strings.Builder + + sb.WriteString(m.styles.help.Render("Esc: back ")) + + if m.detailModeIsAllowlist { + sb.WriteString(m.styles.toggleAllowlist.Render("[■ Allowlist]")) + sb.WriteString(m.styles.help.Render("|")) + sb.WriteString(m.styles.dimmed.Render("[□ Blocklist]")) + } else { + sb.WriteString(m.styles.dimmed.Render("[□ Allowlist]")) + sb.WriteString(m.styles.help.Render("|")) + sb.WriteString(m.styles.toggleBlocklist.Render("[■ Blocklist]")) + } + + return sb.String() +} + +func (m AllowlistModel) renderDetailView(b *strings.Builder) string { + b.WriteString(m.styles.section.Render("--- " + m.detailCommand + " ---")) + b.WriteString("\n") + + b.WriteString(m.renderModeToggle()) + b.WriteString("\n") + + if len(m.detailSubcmds) == 0 { + if m.detailModeIsAllowlist { + b.WriteString(m.styles.dimmed.Render(" (blocking all except listed)")) + } else { + b.WriteString(m.styles.dimmed.Render(" (allowing all except listed)")) + } + b.WriteString("\n") + } else { + visibleStart := m.detailScrollY + visibleEnd := m.detailScrollY + m.detailVisibleItemCount() + if visibleEnd > len(m.detailSubcmds) { + visibleEnd = len(m.detailSubcmds) + } + + for i := visibleStart; i < visibleEnd; i++ { + sub := m.detailSubcmds[i] + isBuiltin := m.detailIsBuiltin[sub] + + prefix := " " + style := m.styles.command + if i == m.detailSelected { + prefix = m.styles.indicator.Render("> ") + style = m.styles.command.Bold(true) + } + + tag := "" + if isBuiltin { + tag = m.styles.dimmed.Render(" [builtin]") + } else { + tag = m.styles.dimmed.Render(" [user]") + } + + fmt.Fprintf(b, "%s%s%s\n", prefix, style.Render(sub), tag) + } + } + + b.WriteString("\n") + b.WriteString(m.styles.section.Render("--- Add Subcommand ---")) + b.WriteString("\n") + b.WriteString(m.detailAddInput.View()) + b.WriteString("\n") + if m.detailAddErr != "" { + b.WriteString(m.styles.error.Render(m.detailAddErr)) + b.WriteString("\n") + } + + totalFields := len(m.detailSubcmds) + scrollPct := 0 + if totalFields > m.detailVisibleItemCount() { + scrollPct = (m.detailScrollY * 100) / (totalFields - m.detailVisibleItemCount()) + } + + b.WriteString("\n") + scrollIndicator := fmt.Sprintf("Subcommand %d/%d", m.detailSelected+1, totalFields) + if totalFields > m.detailVisibleItemCount() { + barWidth := 20 + filledWidth := (scrollPct * barWidth) / 100 + if filledWidth < 1 && m.detailScrollY > 0 { + filledWidth = 1 + } + scrollBar := strings.Repeat("#", filledWidth) + strings.Repeat(".", barWidth-filledWidth) + scrollIndicator += fmt.Sprintf(" [%s] %d%%", scrollBar, scrollPct) + } + b.WriteString(m.styles.help.Render(scrollIndicator)) + + return b.String() +} diff --git a/fluid-cli/internal/tui/allowlist_test.go b/fluid-cli/internal/tui/allowlist_test.go index 5c9fc855..ce51055c 100644 --- a/fluid-cli/internal/tui/allowlist_test.go +++ b/fluid-cli/internal/tui/allowlist_test.go @@ -164,3 +164,241 @@ func TestAllowlistClose_Save(t *testing.T) { t.Error("expected close message") } } + +func TestAllowlistDetail_Enter(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{}, + } + m := NewAllowlistModel(cfg) + + for i, cmd := range m.builtinCmds { + if cmd == "systemctl" { + m.selected = i + break + } + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.mode != allowlistModeDetail { + t.Error("expected detail mode") + } + if m.detailCommand != "systemctl" { + t.Errorf("expected systemctl, got %s", m.detailCommand) + } + if len(m.detailSubcmds) == 0 { + t.Error("expected subcommands in detail mode") + } +} + +func TestAllowlistDetail_Escape(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{}, + } + m := NewAllowlistModel(cfg) + + for i, cmd := range m.builtinCmds { + if cmd == "systemctl" { + m.selected = i + break + } + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.mode != allowlistModeDetail { + t.Fatal("expected detail mode") + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(AllowlistModel) + + if m.mode != allowlistModeList { + t.Error("expected back to list mode") + } + if m.detailCommand != "" { + t.Error("expected detail command to be cleared") + } +} + +func TestAllowlistDetail_AddSubcommand(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-tool"}, + } + m := NewAllowlistModel(cfg) + + m.selected = len(m.builtinCmds) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.mode != allowlistModeDetail { + t.Fatal("expected detail mode") + } + + m.detailAddInput.SetValue("sub1") + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if len(m.detailSubcmds) != 1 { + t.Errorf("expected 1 subcommand, got %d", len(m.detailSubcmds)) + } + if m.detailSubcmds[0] != "sub1" { + t.Errorf("expected sub1, got %s", m.detailSubcmds[0]) + } + if len(m.cfg.ExtraAllowedSubcommands["custom-tool"]) != 1 { + t.Error("expected subcommand in config") + } +} + +func TestAllowlistDetail_DeleteSubcommand(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-tool"}, + ExtraAllowedSubcommands: map[string][]string{"custom-tool": {"sub1", "sub2"}}, + } + m := NewAllowlistModel(cfg) + + m.selected = len(m.builtinCmds) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.mode != allowlistModeDetail { + t.Fatal("expected detail mode") + } + + m.detailSelected = 0 + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(AllowlistModel) + + if len(m.detailSubcmds) != 1 { + t.Errorf("expected 1 subcommand after delete, got %d", len(m.detailSubcmds)) + } + if len(m.cfg.ExtraAllowedSubcommands["custom-tool"]) != 1 { + t.Error("expected one subcommand in config after delete") + } +} + +func TestAllowlistDetail_AddDuplicateSubcommand(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-tool"}, + ExtraAllowedSubcommands: map[string][]string{"custom-tool": {"sub1"}}, + } + m := NewAllowlistModel(cfg) + + m.selected = len(m.builtinCmds) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + m.detailAddInput.SetValue("sub1") + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.detailAddErr == "" { + t.Error("expected error for duplicate subcommand") + } +} + +func TestAllowlistDetail_NavigateSubcommands(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-tool"}, + ExtraAllowedSubcommands: map[string][]string{"custom-tool": {"aaa", "bbb", "ccc"}}, + } + m := NewAllowlistModel(cfg) + + m.selected = len(m.builtinCmds) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.detailSelected != 0 { + t.Error("expected detailSelected to be 0") + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(AllowlistModel) + + if m.detailSelected != 1 { + t.Error("expected detailSelected to be 1 after down") + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(AllowlistModel) + + if m.detailSelected != 0 { + t.Error("expected detailSelected to be 0 after up") + } +} + +func TestAllowlistDetail_ModeToggle(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-tool"}, + ExtraAllowedSubcommands: map[string][]string{"custom-tool": {"sub1", "sub2"}}, + } + m := NewAllowlistModel(cfg) + + m.selected = len(m.builtinCmds) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.detailModeIsAllowlist != true { + t.Error("expected default allowlist mode for user command with subcommands") + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) + m = updated.(AllowlistModel) + + if m.detailModeIsAllowlist != false { + t.Error("expected blocklist mode after toggle") + } + + if m.cfg.ExtraAllowedSubcommandsMode["custom-tool"] != false { + t.Error("expected mode to be persisted in config") + } +} + +func TestAllowlistDetail_DeleteLastSwitchToBlocklist(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-tool"}, + ExtraAllowedSubcommands: map[string][]string{"custom-tool": {"sub1"}}, + ExtraAllowedSubcommandsMode: map[string]bool{"custom-tool": true}, + } + m := NewAllowlistModel(cfg) + + m.selected = len(m.builtinCmds) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.detailModeIsAllowlist != true { + t.Fatal("expected allowlist mode") + } + + m.detailSelected = 0 + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(AllowlistModel) + + if m.detailModeIsAllowlist != false { + t.Error("expected blocklist mode after deleting last subcommand") + } +} + +func TestAllowlistDetail_DefaultAllowlistForNewCommand(t *testing.T) { + cfg := &config.Config{ + ExtraAllowedCommands: []string{"custom-tool"}, + } + m := NewAllowlistModel(cfg) + + m.selected = len(m.builtinCmds) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(AllowlistModel) + + if m.detailModeIsAllowlist != true { + t.Error("expected default allowlist mode for new command") + } +} diff --git a/fluid-cli/internal/tui/connect.go b/fluid-cli/internal/tui/connect.go index f9bfa1e1..e7692c30 100644 --- a/fluid-cli/internal/tui/connect.go +++ b/fluid-cli/internal/tui/connect.go @@ -3,6 +3,8 @@ package tui import ( "context" "fmt" + "io" + "log/slog" "net" "strings" "time" @@ -15,7 +17,7 @@ import ( "github.com/aspectrr/fluid.sh/fluid-cli/internal/config" "github.com/aspectrr/fluid.sh/fluid-cli/internal/doctor" "github.com/aspectrr/fluid.sh/fluid-cli/internal/hostexec" - "github.com/aspectrr/fluid.sh/fluid-cli/internal/netutil" + "github.com/aspectrr/fluid.sh/fluid-cli/internal/readonly" "github.com/aspectrr/fluid.sh/fluid-cli/internal/sandbox" ) @@ -26,17 +28,18 @@ const ( StepAddress ConnectStep = iota StepConnecting StepDoctor + StepDeployKeys StepDone ) -// connectField indexes into the address-step fields. Only fieldAddress and -// fieldName have backing text inputs; fieldInsecure is a boolean toggle. +// connectField indexes into the address-step fields. Only fieldAddress and fieldName +// have backing text inputs; fieldInsecure is a boolean toggle. type connectField int const ( - fieldAddress connectField = iota - fieldName // last real text input - fieldInsecure // virtual: boolean checkbox, no text input + fieldAddress connectField = iota + fieldName + fieldInsecure // virtual: boolean checkbox, no text input connectFieldCount ) @@ -53,16 +56,17 @@ type ConnectDoctorResultMsg struct { Err error } -// ConnectModel implements the 4-step connect wizard modal. +// ConnectModel implements the connect wizard modal. type ConnectModel struct { step ConnectStep - inputs [fieldInsecure]textinput.Model // only address + name have text inputs + inputs [fieldInsecure]textinput.Model // address, name have text inputs focused connectField insecure bool spinner spinner.Model width int height int styles Styles + logger *slog.Logger // Connection result service sandbox.Service @@ -74,22 +78,32 @@ type ConnectModel struct { doctorResults []doctor.CheckResult doctorErr error - // Config for read-only hosts (passed to NewRemoteService) - hosts []config.HostConfig + // Deploy results + keysDeployed bool + deployResults *DaemonKeyDeployResultMsg + hostDeployStatuses []HostDeployStatus + deployHostIndex int + deployIdentityKey string } // NewConnectModel creates a new connect wizard. -func NewConnectModel(hosts []config.HostConfig) ConnectModel { +func NewConnectModel(logger *slog.Logger) ConnectModel { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + addrInput := textinput.New() addrInput.Placeholder = "localhost:9091" addrInput.Prompt = "" addrInput.CharLimit = 256 + addrInput.Width = 40 addrInput.Focus() nameInput := textinput.New() nameInput.Placeholder = "optional name" nameInput.Prompt = "" nameInput.CharLimit = 128 + nameInput.Width = 40 s := spinner.New() s.Spinner = spinner.Dot @@ -101,7 +115,7 @@ func NewConnectModel(hosts []config.HostConfig) ConnectModel { focused: fieldAddress, spinner: s, styles: DefaultStyles(), - hosts: hosts, + logger: logger, } } @@ -122,18 +136,71 @@ func (m ConnectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { m.connErr = msg.Err m.step = StepConnecting // stay on connecting step to show error + m.logger.Warn("connect failed", "error", msg.Err) return m, nil } m.service = msg.Service m.hostInfo = msg.HostInfo m.connErr = nil m.step = StepDoctor + m.logger.Info("connected", "hostname", msg.HostInfo.Hostname, "version", msg.HostInfo.Version) return m, tea.Batch(m.spinner.Tick, m.runDoctorChecks()) case ConnectDoctorResultMsg: m.doctorResults = msg.Results m.doctorErr = msg.Err m.step = StepDone + if msg.Err != nil { + m.logger.Warn("doctor checks failed", "error", msg.Err) + } else { + passed := 0 + for _, r := range msg.Results { + if r.Passed { + passed++ + } + } + m.logger.Info("doctor checks complete", "passed", passed, "total", len(msg.Results)) + } + return m, nil + + case HostKeyDeployedMsg: + if msg.Index < len(m.hostDeployStatuses) { + if msg.Err != nil { + m.hostDeployStatuses[msg.Index].State = HostDeployFailed + m.hostDeployStatuses[msg.Index].ErrMsg = msg.Err.Error() + m.logger.Warn("host key deploy failed", "host", msg.Host, "error", msg.Err) + } else { + m.hostDeployStatuses[msg.Index].State = HostDeployDone + m.logger.Info("host key deployed", "host", msg.Host) + } + } + // Deploy next host or finish + nextIdx := msg.Index + 1 + if nextIdx < len(m.hostDeployStatuses) { + m.hostDeployStatuses[nextIdx].State = HostDeployDeploying + m.deployHostIndex = nextIdx + return m, deploySourceHostKey(m.hostInfo.SourceHosts[nextIdx], m.deployIdentityKey, nextIdx, m.logger) + } + // All done - scan host keys on daemon side, then rerun doctor checks + m.keysDeployed = true + m.deployResults = m.buildDeployResults() + m.step = StepDoctor + m.doctorResults = nil + m.doctorErr = nil + m.logger.Info("deploy complete, scanning host keys", "deployed", m.deployResults.Deployed, "errors", len(m.deployResults.Errors)) + return m, tea.Batch(m.spinner.Tick, m.scanSourceHostKeys()) + + case ScanKeysCompleteMsg: + m.step = StepDoctor + m.doctorResults = nil + m.doctorErr = nil + m.logger.Info("host key scan complete, re-running doctor checks") + return m, tea.Batch(m.spinner.Tick, m.runDoctorChecks()) + + case DaemonKeyDeployResultMsg: + m.keysDeployed = true + m.deployResults = &msg + m.step = StepDone return m, nil case spinner.TickMsg: @@ -242,17 +309,49 @@ func (m ConnectModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return ConnectCloseMsg{} } } + case StepDeployKeys: + if key == "esc" { + return m, func() tea.Msg { return ConnectCloseMsg{} } + } + case StepDone: switch key { case "enter": + // If daemon has source hosts, identity key available, and not yet deployed: deploy keys + if !m.keysDeployed && m.hostInfo != nil && m.hostInfo.SSHIdentityPubKey != "" && len(m.hostInfo.SourceHosts) > 0 { + m.step = StepDeployKeys + m.deployIdentityKey = m.hostInfo.SSHIdentityPubKey + m.hostDeployStatuses = make([]HostDeployStatus, len(m.hostInfo.SourceHosts)) + for i, sh := range m.hostInfo.SourceHosts { + m.hostDeployStatuses[i] = HostDeployStatus{Name: sh.Address, State: HostDeployPending} + } + m.hostDeployStatuses[0].State = HostDeployDeploying + m.deployHostIndex = 0 + m.logger.Info("starting key deploy", "host_count", len(m.hostInfo.SourceHosts)) + return m, tea.Batch(m.spinner.Tick, deploySourceHostKey(m.hostInfo.SourceHosts[0], m.deployIdentityKey, 0, m.logger)) + } + // Otherwise save & close return m, func() tea.Msg { return ConnectCloseMsg{ Saved: true, Config: m.buildConfig(), } } + case "r": + m.step = StepDoctor + m.doctorResults = nil + m.doctorErr = nil + m.deployResults = nil + m.keysDeployed = false + return m, tea.Batch(m.spinner.Tick, m.runDoctorChecks()) case "esc": - return m, func() tea.Msg { return ConnectCloseMsg{} } + // Save & close (successful connection) + return m, func() tea.Msg { + return ConnectCloseMsg{ + Saved: true, + Config: m.buildConfig(), + } + } } } @@ -274,7 +373,7 @@ func (m ConnectModel) View() string { switch m.step { case StepAddress: // Address and Name text input fields - labels := []string{" Address:", " Name: "} + labels := []string{" Address: ", " Name: "} for i := range fieldInsecure { prefix := " " if connectField(i) == m.focused { @@ -313,16 +412,42 @@ func (m ConnectModel) View() string { case StepDoctor: b.WriteString(fmt.Sprintf(" %s Running doctor checks...", m.spinner.View())) + case StepDeployKeys: + b.WriteString(" Setting up source hosts (user + key)...\n\n") + for _, hs := range m.hostDeployStatuses { + switch hs.State { + case HostDeployDone: + b.WriteString(successStyle.Render(fmt.Sprintf(" v %s", hs.Name))) + case HostDeployFailed: + b.WriteString(errStyle.Render(fmt.Sprintf(" x %s: %s", hs.Name, hs.ErrMsg))) + case HostDeployDeploying: + b.WriteString(fmt.Sprintf(" %s %s", m.spinner.View(), hs.Name)) + default: + b.WriteString(dimStyle.Render(fmt.Sprintf(" - %s", hs.Name))) + } + b.WriteString("\n") + } + case StepDone: m.renderHostInfo(&b, successStyle, dimStyle) b.WriteString("\n") m.renderDoctorResults(&b, successStyle, errStyle) + m.renderDeployResults(&b, successStyle, errStyle) b.WriteString("\n") - b.WriteString(dimStyle.Render(" Enter: save & close Esc: close without saving")) + if !m.keysDeployed && m.hostInfo != nil && m.hostInfo.SSHIdentityPubKey != "" && len(m.hostInfo.SourceHosts) > 0 { + b.WriteString(dimStyle.Render(" Enter: setup source hosts (create user + deploy key) r: retry checks Esc: save & close")) + } else { + b.WriteString(dimStyle.Render(" Enter: save & close r: retry checks Esc: save & close")) + } } b.WriteString("\n") - return b.String() + + content := b.String() + if m.width > 0 && m.height > 0 { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) + } + return content } func (m ConnectModel) renderHostInfo(b *strings.Builder, successStyle, dimStyle lipgloss.Style) { @@ -370,6 +495,21 @@ func (m ConnectModel) renderDoctorResults(b *strings.Builder, successStyle, errS b.WriteString("\n") } +func (m ConnectModel) renderDeployResults(b *strings.Builder, successStyle, errStyle lipgloss.Style) { + if m.deployResults == nil { + return + } + b.WriteString("\n") + if m.deployResults.Deployed > 0 { + b.WriteString(successStyle.Render(fmt.Sprintf(" v Setup complete on %d source host(s)", m.deployResults.Deployed))) + b.WriteString("\n") + } + for _, e := range m.deployResults.Errors { + b.WriteString(errStyle.Render(fmt.Sprintf(" x Source host setup: %s", e))) + b.WriteString("\n") + } +} + // resolveAddress returns the address from input, defaulting to localhost:9091. func (m ConnectModel) resolveAddress() (string, error) { addr := strings.TrimSpace(m.inputs[fieldAddress].Value()) @@ -402,24 +542,28 @@ func (m ConnectModel) buildConfig() config.SandboxHostConfig { name = "default" } - return config.SandboxHostConfig{ + cfg := config.SandboxHostConfig{ Name: name, DaemonAddress: addr, Insecure: m.insecure, } + if m.hostInfo != nil { + cfg.DaemonIdentityPubKey = m.hostInfo.SSHIdentityPubKey + } + return cfg } // attemptConnect dials the daemon and checks health + host info. func (m ConnectModel) attemptConnect(addr string) tea.Cmd { insecure := m.insecure - hosts := m.hosts + m.logger.Info("attempting connect", "address", addr, "insecure", insecure) return func() tea.Msg { cpCfg := config.ControlPlaneConfig{ DaemonAddress: addr, DaemonInsecure: insecure, } - svc, err := sandbox.NewRemoteService(addr, cpCfg, hosts) + svc, err := sandbox.NewRemoteService(addr, cpCfg) if err != nil { return ConnectHealthResultMsg{Err: fmt.Errorf("dial: %w", err)} } @@ -448,24 +592,39 @@ func (m ConnectModel) attemptConnect(addr string) tea.Cmd { } } -// runDoctorChecks runs doctor checks over SSH to the host. +// runDoctorChecks runs doctor checks via the daemon's gRPC DoctorCheck RPC. func (m ConnectModel) runDoctorChecks() tea.Cmd { - addr, _ := m.resolveAddress() + svc := m.service return func() tea.Msg { - host, _, err := net.SplitHostPort(addr) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + results, err := svc.DoctorCheck(ctx) if err != nil { - host = addr + return ConnectDoctorResultMsg{Err: err} } - if netutil.IsLocalHost(host) { - return ConnectDoctorResultMsg{} + doctorResults := make([]doctor.CheckResult, len(results)) + for i, r := range results { + doctorResults[i] = doctor.CheckResult{ + Name: r.Name, + Category: r.Category, + Passed: r.Passed, + Message: r.Message, + FixCmd: r.FixCmd, + } } + return ConnectDoctorResultMsg{Results: doctorResults} + } +} - run := hostexec.NewSSHAlias(host) +// scanSourceHostKeys calls the daemon's ScanSourceHostKeys RPC to add source +// host SSH keys to the daemon's known_hosts file. +func (m ConnectModel) scanSourceHostKeys() tea.Cmd { + svc := m.service + return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - - results := doctor.RunAll(ctx, run) - return ConnectDoctorResultMsg{Results: results} + results, err := svc.ScanSourceHostKeys(ctx) + return ScanKeysCompleteMsg{Results: results, Err: err} } } @@ -473,3 +632,33 @@ func (m ConnectModel) runDoctorChecks() tea.Cmd { func (m ConnectModel) GetService() sandbox.Service { return m.service } + +// deploySourceHostKey sets up a source host for daemon access: creates the +// fluid-daemon user (if missing), adds it to libvirt, and deploys the daemon +// identity key. The CLI user's SSH config is used for authentication (the user +// must have sudo access on the source host). +func deploySourceHostKey(sh sandbox.SourceHostInfo, identityPubKey string, index int, logger *slog.Logger) tea.Cmd { + return func() tea.Msg { + sshRunFn := hostexec.NewSSHAlias(sh.Address) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err := readonly.SetupSourceHost(ctx, readonly.SSHRunFunc(sshRunFn), identityPubKey, logger) + return HostKeyDeployedMsg{Host: sh.Address, Index: index, Err: err} + } +} + +// buildDeployResults aggregates per-host statuses into a DaemonKeyDeployResultMsg. +func (m ConnectModel) buildDeployResults() *DaemonKeyDeployResultMsg { + var deployed, skipped int + var errs []string + for _, hs := range m.hostDeployStatuses { + switch hs.State { + case HostDeployDone: + deployed++ + case HostDeployFailed: + skipped++ + errs = append(errs, fmt.Sprintf("%s: %s", hs.Name, hs.ErrMsg)) + } + } + return &DaemonKeyDeployResultMsg{Deployed: deployed, Skipped: skipped, Errors: errs} +} diff --git a/fluid-cli/internal/tui/connect_test.go b/fluid-cli/internal/tui/connect_test.go index f0d97bde..5cacba52 100644 --- a/fluid-cli/internal/tui/connect_test.go +++ b/fluid-cli/internal/tui/connect_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "testing" "github.com/charmbracelet/bubbles/textinput" @@ -267,3 +268,86 @@ func TestUpdate_InsecureToggle(t *testing.T) { t.Error("insecure should be true after 'y'") } } + +func TestUpdate_PerHostDeploy(t *testing.T) { + m := NewConnectModel(nil) + m.step = StepDone + m.hostInfo = &sandbox.HostInfo{ + Hostname: "daemon1", + SSHIdentityPubKey: "ssh-ed25519 AAAA...", + SourceHosts: []sandbox.SourceHostInfo{ + {Address: "192.168.1.10", SSHUser: "fluid-daemon", SSHPort: 22}, + {Address: "192.168.1.11", SSHUser: "fluid-daemon", SSHPort: 22}, + {Address: "192.168.1.12", SSHUser: "fluid-daemon", SSHPort: 22}, + }, + } + + // Press enter to start deploy + updatedModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updatedModel.(ConnectModel) + + if m.step != StepDeployKeys { + t.Fatalf("expected StepDeployKeys, got %v", m.step) + } + if len(m.hostDeployStatuses) != 3 { + t.Fatalf("expected 3 host statuses, got %d", len(m.hostDeployStatuses)) + } + if m.hostDeployStatuses[0].State != HostDeployDeploying { + t.Errorf("host 0 should be deploying, got %v", m.hostDeployStatuses[0].State) + } + if m.hostDeployStatuses[1].State != HostDeployPending { + t.Errorf("host 1 should be pending, got %v", m.hostDeployStatuses[1].State) + } + if cmd == nil { + t.Fatal("expected non-nil cmd") + } + + // Simulate host 0 success + updatedModel, cmd = m.Update(HostKeyDeployedMsg{Host: "host1", Index: 0, Err: nil}) + m = updatedModel.(ConnectModel) + if m.hostDeployStatuses[0].State != HostDeployDone { + t.Errorf("host 0 should be done, got %v", m.hostDeployStatuses[0].State) + } + if m.hostDeployStatuses[1].State != HostDeployDeploying { + t.Errorf("host 1 should be deploying, got %v", m.hostDeployStatuses[1].State) + } + if cmd == nil { + t.Fatal("expected cmd for next host") + } + + // Simulate host 1 failure + updatedModel, cmd = m.Update(HostKeyDeployedMsg{Host: "host2", Index: 1, Err: fmt.Errorf("connection refused")}) + m = updatedModel.(ConnectModel) + if m.hostDeployStatuses[1].State != HostDeployFailed { + t.Errorf("host 1 should be failed, got %v", m.hostDeployStatuses[1].State) + } + if m.hostDeployStatuses[2].State != HostDeployDeploying { + t.Errorf("host 2 should be deploying, got %v", m.hostDeployStatuses[2].State) + } + + // Simulate host 2 success - should scan host keys, then rerun doctor checks + updatedModel, _ = m.Update(HostKeyDeployedMsg{Host: "host3", Index: 2, Err: nil}) + m = updatedModel.(ConnectModel) + if !m.keysDeployed { + t.Error("keysDeployed should be true after all hosts") + } + if m.step != StepDoctor { + t.Errorf("should be on StepDoctor (scanning keys), got %v", m.step) + } + if m.deployResults == nil { + t.Fatal("deployResults should be set") + } + if m.deployResults.Deployed != 2 { + t.Errorf("expected 2 deployed, got %d", m.deployResults.Deployed) + } + if len(m.deployResults.Errors) != 1 { + t.Errorf("expected 1 error, got %d", len(m.deployResults.Errors)) + } + + // Simulate scan keys complete - should transition to doctor checks + updatedModel, _ = m.Update(ScanKeysCompleteMsg{}) + m = updatedModel.(ConnectModel) + if m.step != StepDoctor { + t.Errorf("should be on StepDoctor (running checks after scan), got %v", m.step) + } +} diff --git a/fluid-cli/internal/tui/messages.go b/fluid-cli/internal/tui/messages.go index 576f94a9..cd17bacb 100644 --- a/fluid-cli/internal/tui/messages.go +++ b/fluid-cli/internal/tui/messages.go @@ -77,8 +77,8 @@ type ToolCompleteMsg struct { Error string } -// AgentDoneMsg is sent through the status channel when the agent finishes -// This unblocks the status listener +// AgentDoneMsg is returned directly by the agent tea.Cmd when a run finishes. +// It must not be queued through the status channel. type AgentDoneMsg struct{} // AgentCancelledMsg is sent when the user cancels the agent via ESC. @@ -228,6 +228,15 @@ type SourcePrepareProgressMsg struct { Done bool } +// SandboxCreateProgressMsg is sent during sandbox creation to show step-by-step progress +type SandboxCreateProgressMsg struct { + SourceVM string + StepName string + StepNum int + Total int + Done bool +} + // AutoReadOnlyMsg is sent when read-only mode is auto-toggled for source VM operations type AutoReadOnlyMsg struct { SourceVM string @@ -267,3 +276,40 @@ type SandboxServiceSwapResultMsg struct { Svc sandbox.Service Err error } + +// DaemonKeyDeployResultMsg carries the result of deploying daemon keys to source hosts. +type DaemonKeyDeployResultMsg struct { + Deployed int + Skipped int + Errors []string +} + +// HostDeployState tracks the deploy status of a single host. +type HostDeployState int + +const ( + HostDeployPending HostDeployState = iota + HostDeployDeploying + HostDeployDone + HostDeployFailed +) + +// HostDeployStatus holds the deploy status for a single host. +type HostDeployStatus struct { + Name string + State HostDeployState + ErrMsg string +} + +// HostKeyDeployedMsg is sent when a single host key deploy completes. +type HostKeyDeployedMsg struct { + Host string + Index int + Err error +} + +// ScanKeysCompleteMsg is sent when the daemon finishes scanning source host SSH keys. +type ScanKeysCompleteMsg struct { + Results []sandbox.ScanSourceHostKeysResult + Err error +} diff --git a/fluid-cli/internal/tui/model.go b/fluid-cli/internal/tui/model.go index 82c9a6ab..32f36873 100644 --- a/fluid-cli/internal/tui/model.go +++ b/fluid-cli/internal/tui/model.go @@ -2,6 +2,8 @@ package tui import ( "fmt" + "io" + "log/slog" "strings" "time" @@ -72,6 +74,7 @@ type Model struct { model string cfg *config.Config configPath string + logger *slog.Logger // Banner state showBanner bool // Show startup banner (until first message) @@ -139,6 +142,12 @@ type Model struct { livePrepareSteps []string // completed/in-progress step descriptions livePrepareIndex int // index in conversation + // Live sandbox create progress + showingLiveCreate bool + liveCreateSourceVM string + liveCreateSteps []string + liveCreateIndex int + // SSH host cache for /prepare autocomplete sshHosts []string sshHostsUpdatedAt time.Time @@ -193,7 +202,11 @@ type AgentRunner interface { } // NewModel creates a new TUI model -func NewModel(title, provider, modelName string, runner AgentRunner, cfg *config.Config, configPath string) Model { +func NewModel(title, provider, modelName string, runner AgentRunner, cfg *config.Config, configPath string, logger *slog.Logger) Model { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + ta := textarea.New() ta.Placeholder = "Type your message... (type /settings to configure)" ta.Focus() @@ -247,6 +260,7 @@ func NewModel(title, provider, modelName string, runner AgentRunner, cfg *config model: modelName, cfg: cfg, configPath: configPath, + logger: logger, agentRunner: runner, mdRenderer: mdRenderer, statusChan: statusChan, @@ -310,6 +324,37 @@ func (m Model) listenForStatus() tea.Cmd { } } +func (m *Model) removeConversationEntry(index int) { + if index < 0 || index >= len(m.conversation) { + return + } + m.conversation = append(m.conversation[:index], m.conversation[index+1:]...) +} + +func (m *Model) clearActiveLiveConversationEntries() { + indexes := make([]int, 0, 3) + if m.showingLiveOutput { + indexes = append(indexes, m.liveOutputIndex) + } + if m.showingLivePrepare { + indexes = append(indexes, m.livePrepareIndex) + } + if m.showingLiveCreate { + indexes = append(indexes, m.liveCreateIndex) + } + + for i := len(indexes) - 1; i >= 0; i-- { + m.removeConversationEntry(indexes[i]) + } +} + +func markProgressStepDone(step string) string { + if strings.HasPrefix(step, " . ") { + step = " v " + strings.TrimPrefix(step, " . ") + } + return strings.TrimSuffix(step, "...") +} + // Update implements tea.Model func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -350,8 +395,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.addSystemMessage(fmt.Sprintf("Connected to %s (%s). Config saved.", closeMsg.Config.Name, closeMsg.Config.DaemonAddress)) } + // Hot-swap sandbox service if available (async to avoid blocking TUI) if svc := m.connectModel.GetService(); svc != nil { + m.updateViewportContent(false) + m.textarea.Focus() return m, func() tea.Msg { err := m.agentRunner.SetSandboxService(svc) return SandboxServiceSwapResultMsg{Svc: svc, Err: err} @@ -418,6 +466,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Drop stale HostKeyDeployedMsg arriving after ESC + if _, ok := msg.(HostKeyDeployedMsg); ok && !m.inConnect { + return m, nil + } + // Close any service arriving after ESC cancellation if healthMsg, ok := msg.(ConnectHealthResultMsg); ok && !m.inConnect { if healthMsg.Service != nil { @@ -673,9 +726,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { sandboxes := agent.GetCreatedSandboxes() if len(sandboxes) > 0 { m.inCleanup = true + m.quitting = false m.cleanupOrder = sandboxes m.cleanupStatuses = make(map[string]CleanupStatus) m.cleanupErrors = make(map[string]string) + m.cleanupDone = false + m.cleanupResult = nil for _, id := range sandboxes { m.cleanupStatuses[id] = CleanupStatusPending } @@ -709,7 +765,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle /connect command if input == "/connect" || input == "connect" { m.inConnect = true - m.connectModel = NewConnectModel(m.cfg.Hosts) + m.connectModel = NewConnectModel(m.logger) if m.width > 0 && m.height > 0 { connectModel, _ := m.connectModel.Update(tea.WindowSizeMsg{ Width: m.width, @@ -817,6 +873,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.agentRunner != nil { m.agentRunner.Cancel() } + m.clearActiveLiveConversationEntries() + drainLoop: + for { + select { + case <-m.statusChan: + default: + break drainLoop + } + } + m.showingLiveOutput = false + m.showingLiveCreate = false + m.showingLivePrepare = false + m.liveOutputLines = nil + m.liveOutputPending = "" + m.liveOutputSandbox = "" + m.liveOutputCommand = "" + m.liveOutputIndex = 0 + m.liveCreateSourceVM = "" + m.liveCreateSteps = nil + m.liveCreateIndex = 0 + m.livePrepareSourceVM = "" + m.livePrepareSteps = nil + m.livePrepareIndex = 0 + m.currentRetry = nil m.state = StateIdle m.thinking = false m.agentStatus = StatusThinking @@ -909,7 +989,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case AgentDoneMsg: - // Agent finished, don't restart the status listener + // AgentDoneMsg is the direct completion signal from Run(); final TUI + // updates should already have arrived through statusChan. return m, nil case ToolStartMsg: @@ -1085,6 +1166,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoBottom() return m, tea.Batch(m.listenForStatus(), m.spinner.Tick) + case SandboxCreateProgressMsg: + if !m.showingLiveCreate && !msg.Done { + m.showingLiveCreate = true + m.liveCreateSourceVM = msg.SourceVM + m.liveCreateSteps = nil + m.liveCreateIndex = len(m.conversation) + m.conversation = append(m.conversation, ConversationEntry{ + Role: "live_create", + Content: "", + }) + } + + if msg.Done { + if len(m.liveCreateSteps) > 0 && msg.StepName != "" && msg.Total > 0 { + m.liveCreateSteps[len(m.liveCreateSteps)-1] = fmt.Sprintf(" v [%d/%d] %s", msg.StepNum, msg.Total, msg.StepName) + } + m.showingLiveCreate = false + } else { + if len(m.liveCreateSteps) > 0 { + m.liveCreateSteps[len(m.liveCreateSteps)-1] = markProgressStepDone(m.liveCreateSteps[len(m.liveCreateSteps)-1]) + } + m.liveCreateSteps = append(m.liveCreateSteps, fmt.Sprintf(" . [%d/%d] %s...", msg.StepNum, msg.Total, msg.StepName)) + } + + if m.liveCreateIndex < len(m.conversation) { + m.conversation[m.liveCreateIndex].Content = strings.Join(m.liveCreateSteps, "\n") + } + m.updateViewportContent(false) + m.viewport.GotoBottom() + return m, tea.Batch(m.listenForStatus(), m.spinner.Tick) + case SensitiveContentRedactedMsg: if msg.Path != "" { m.addSystemMessage(fmt.Sprintf("Sensitive content detected in %s - redacted before sending to LLM", msg.Path)) @@ -1105,7 +1217,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.listenForStatus(), m.spinner.Tick) case AgentResponseMsg: - // Add assistant message (tool results were already sent via ToolCompleteMsg) + // Final assistant/tool UI updates arrive through statusChan. When Done is + // true, stop listening so the next run starts with a clean status stream. + // Tool results were already sent via ToolCompleteMsg. if msg.Response.Content != "" { m.addAssistantMessage(msg.Response.Content) } @@ -1254,6 +1368,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case CleanupStartMsg: + m.inCleanup = true + m.quitting = false + m.cleanupOrder = append([]string(nil), msg.SandboxIDs...) + if m.cleanupStatuses == nil { + m.cleanupStatuses = make(map[string]CleanupStatus) + } + if m.cleanupErrors == nil { + m.cleanupErrors = make(map[string]string) + } + m.cleanupDone = false + m.cleanupResult = nil + for _, id := range m.cleanupOrder { + if _, ok := m.cleanupStatuses[id]; !ok { + m.cleanupStatuses[id] = CleanupStatusPending + } + } + return m, tea.Batch(m.listenForStatus(), m.spinner.Tick) + case CleanupProgressMsg: m.cleanupStatuses[msg.SandboxID] = msg.Status if msg.Error != "" { @@ -1862,6 +1995,32 @@ func (m *Model) updateViewportContent(forceScroll bool) { b.WriteString(style.Render(header + "\n" + content)) b.WriteString("\n") + + case "live_create": + // Styled box for sandbox creation progress + boxWidth := m.width - 6 + if boxWidth < 30 { + boxWidth = 30 + } + + style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#22C55E")). + Padding(0, 1). + Width(boxWidth) + + header := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22C55E")). + Bold(true). + Render(fmt.Sprintf("Creating sandbox from %s:", m.liveCreateSourceVM)) + + content := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#94A3B8")). + Width(boxWidth - 4). + Render(entry.Content) + + b.WriteString(style.Render(header + "\n" + content)) + b.WriteString("\n") } } @@ -2110,29 +2269,29 @@ func (m *Model) formatToolOutput(toolName string, args, result map[string]any) s b.WriteString("\n") } - case "list_vms": - if vms, ok := result["vms"].([]any); ok { - b.WriteString(m.styles.ToolDetails.Render(fmt.Sprintf(" Found %d VM(s)", len(vms)))) - b.WriteString("\n") - for i, vm := range vms { - if i >= 10 { - b.WriteString(m.styles.ToolDetails.Render(fmt.Sprintf(" ... and %d more", len(vms)-10))) - b.WriteString("\n") - break - } - if vmMap, ok := vm.(map[string]any); ok { - name := vmMap["name"] - state := vmMap["state"] - host := vmMap["host"] - line := fmt.Sprintf(" - %v (%v)", name, state) - if host != nil && host != "" { - line += fmt.Sprintf(" on %v", host) - } - b.WriteString(m.styles.ToolDetails.Render(line)) - b.WriteString("\n") - } - } - } + // case "list_vms": + // if vms, ok := result["vms"].([]any); ok { + // b.WriteString(m.styles.ToolDetails.Render(fmt.Sprintf(" Found %d VM(s)", len(vms)))) + // b.WriteString("\n") + // for i, vm := range vms { + // if i >= 10 { + // b.WriteString(m.styles.ToolDetails.Render(fmt.Sprintf(" ... and %d more", len(vms)-10))) + // b.WriteString("\n") + // break + // } + // if vmMap, ok := vm.(map[string]any); ok { + // name := vmMap["name"] + // state := vmMap["state"] + // host := vmMap["host"] + // line := fmt.Sprintf(" - %v (%v)", name, state) + // if host != nil && host != "" { + // line += fmt.Sprintf(" on %v", host) + // } + // b.WriteString(m.styles.ToolDetails.Render(line)) + // b.WriteString("\n") + // } + // } + // } case "create_snapshot": if id, ok := result["snapshot_id"]; ok { @@ -2242,11 +2401,15 @@ func (m Model) startCleanup() tea.Cmd { } } - // Start cleanup in background, progress will come through status channel - go agent.CleanupWithProgress(m.cleanupOrder) - - // Return commands to listen for status updates and keep spinner going - return tea.Batch(m.listenForStatus(), m.spinner.Tick) + cleanupOrder := append([]string(nil), m.cleanupOrder...) + return func() tea.Msg { + go func() { + // Give the cleanup view one render cycle before the destroy loop starts. + time.Sleep(75 * time.Millisecond) + agent.CleanupWithProgress(cleanupOrder) + }() + return CleanupStartMsg{SandboxIDs: cleanupOrder} + } } // renderCleanupView renders the cleanup page showing sandbox destruction progress diff --git a/fluid-cli/internal/tui/model_test.go b/fluid-cli/internal/tui/model_test.go new file mode 100644 index 00000000..13b413d8 --- /dev/null +++ b/fluid-cli/internal/tui/model_test.go @@ -0,0 +1,373 @@ +package tui + +import ( + "io" + "log/slog" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/aspectrr/fluid.sh/fluid-cli/internal/config" + "github.com/aspectrr/fluid.sh/fluid-cli/internal/sandbox" +) + +type stubModelRunner struct { + cancelled bool + runID uint64 +} + +func (s *stubModelRunner) Run(string) tea.Cmd { return nil } +func (s *stubModelRunner) Reset() {} +func (s *stubModelRunner) SetStatusCallback(func(tea.Msg)) {} +func (s *stubModelRunner) SetReadOnly(bool) {} +func (s *stubModelRunner) Cancel() { s.cancelled = true } +func (s *stubModelRunner) RunID() uint64 { return s.runID } +func (s *stubModelRunner) SetSandboxService(sandbox.Service) error { return nil } + +func newTestModel(t *testing.T) (Model, *stubModelRunner) { + t.Helper() + + runner := &stubModelRunner{runID: 1} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + model := NewModel("fluid", "test", "test-model", runner, &config.Config{}, "", logger) + updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + + return updated.(Model), runner +} + +func newTestModelWithAgent(t *testing.T) (Model, *FluidAgent) { + t.Helper() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + agent := &FluidAgent{logger: logger} + model := NewModel("fluid", "test", "test-model", agent, &config.Config{}, "", logger) + updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + + return updated.(Model), agent +} + +func dequeueStatus(t *testing.T, model Model) tea.Msg { + t.Helper() + return model.listenForStatus()() +} + +func TestModelEscapeCancelsAndClearsLiveState(t *testing.T) { + model, runner := newTestModel(t) + + model.state = StateThinking + model.thinking = true + model.agentStatus = StatusWorking + model.currentToolName = "create_sandbox" + model.currentToolArgs = map[string]any{"source_vm": "ubuntu"} + model.currentRetry = &RetryAttemptMsg{SandboxID: "SBX-1", Attempt: 2} + model.conversation = append(model.conversation, + ConversationEntry{Role: "assistant", Content: "working"}, + ConversationEntry{Role: "live_output", Content: "partial output"}, + ConversationEntry{Role: "live_prepare", Content: "partial prepare"}, + ConversationEntry{Role: "live_create", Content: "partial create"}, + ) + model.showingLiveOutput = true + model.liveOutputLines = []string{"line"} + model.liveOutputPending = "pending" + model.liveOutputSandbox = "SBX-1" + model.liveOutputCommand = "uname -a" + model.liveOutputIndex = 2 + model.showingLivePrepare = true + model.livePrepareSourceVM = "ubuntu" + model.livePrepareSteps = []string{"step"} + model.livePrepareIndex = 3 + model.showingLiveCreate = true + model.liveCreateSourceVM = "ubuntu" + model.liveCreateSteps = []string{"step"} + model.liveCreateIndex = 4 + model.statusChan <- ToolStartMsg{ToolName: "create_sandbox"} + model.statusChan <- SandboxCreateProgressMsg{SourceVM: "ubuntu", StepName: "Booting", StepNum: 5, Total: 7} + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = updated.(Model) + + if !runner.cancelled { + t.Fatal("expected Cancel to be called") + } + if model.state != StateIdle { + t.Fatalf("state = %v, want %v", model.state, StateIdle) + } + if model.thinking { + t.Fatal("expected thinking to be false") + } + if model.showingLiveOutput || model.showingLivePrepare || model.showingLiveCreate { + t.Fatal("expected all live views to be cleared") + } + if model.liveOutputLines != nil || model.liveCreateSteps != nil || model.livePrepareSteps != nil { + t.Fatal("expected live buffers to be reset") + } + if model.liveOutputPending != "" { + t.Fatalf("liveOutputPending = %q, want empty", model.liveOutputPending) + } + if model.currentRetry != nil { + t.Fatal("expected retry state to be cleared") + } + if model.currentToolName != "" || model.currentToolArgs != nil { + t.Fatal("expected current tool state to be cleared") + } + if got := len(model.statusChan); got != 0 { + t.Fatalf("status channel length = %d, want 0", got) + } + if len(model.conversation) != 2 { + t.Fatalf("conversation length = %d, want 2", len(model.conversation)) + } + for _, entry := range model.conversation { + if entry.Role == "live_output" || entry.Role == "live_prepare" || entry.Role == "live_create" { + t.Fatalf("unexpected live conversation entry after cancel: %q", entry.Role) + } + } +} + +func TestSandboxCreateProgressDoneClosesActiveBox(t *testing.T) { + model, _ := newTestModel(t) + + updated, _ := model.Update(SandboxCreateProgressMsg{ + SourceVM: "ubuntu", + StepName: "Booting microVM", + StepNum: 5, + Total: 7, + }) + model = updated.(Model) + + if !model.showingLiveCreate { + t.Fatal("expected live create box to be active after progress") + } + + updated, _ = model.Update(SandboxCreateProgressMsg{ + SourceVM: "ubuntu", + StepName: "Ready", + StepNum: 1, + Total: 7, + Done: true, + }) + model = updated.(Model) + + if model.showingLiveCreate { + t.Fatal("expected Done progress to close the live create box") + } + if len(model.liveCreateSteps) != 1 { + t.Fatalf("liveCreateSteps length = %d, want 1", len(model.liveCreateSteps)) + } + if got := model.liveCreateSteps[0]; got != " v [1/7] Ready" { + t.Fatalf("last live create step = %q, want %q", got, " v [1/7] Ready") + } +} + +func TestSandboxCreateProgressMarksPreviousStepDone(t *testing.T) { + model, _ := newTestModel(t) + + updated, _ := model.Update(SandboxCreateProgressMsg{ + SourceVM: "ubuntu", + StepName: "Resolving source host", + StepNum: 1, + Total: 9, + }) + model = updated.(Model) + + updated, _ = model.Update(SandboxCreateProgressMsg{ + SourceVM: "ubuntu", + StepName: "Pulling fresh snapshot", + StepNum: 2, + Total: 9, + }) + model = updated.(Model) + + if len(model.liveCreateSteps) != 2 { + t.Fatalf("liveCreateSteps length = %d, want 2", len(model.liveCreateSteps)) + } + if got := model.liveCreateSteps[0]; got != " v [1/9] Resolving source host" { + t.Fatalf("first live create step = %q, want %q", got, " v [1/9] Resolving source host") + } + if got := model.liveCreateSteps[1]; got != " . [2/9] Pulling fresh snapshot..." { + t.Fatalf("second live create step = %q, want %q", got, " . [2/9] Pulling fresh snapshot...") + } +} + +func TestSandboxCreateProgressDoneWithoutStepsDoesNotCreateBox(t *testing.T) { + model, _ := newTestModel(t) + initialConversationLen := len(model.conversation) + + updated, _ := model.Update(SandboxCreateProgressMsg{ + SourceVM: "ubuntu", + Done: true, + }) + model = updated.(Model) + + if model.showingLiveCreate { + t.Fatal("expected Done-only progress to leave live create inactive") + } + if len(model.liveCreateSteps) != 0 { + t.Fatalf("liveCreateSteps length = %d, want 0", len(model.liveCreateSteps)) + } + if len(model.conversation) != initialConversationLen { + t.Fatalf("conversation length = %d, want %d", len(model.conversation), initialConversationLen) + } +} + +func TestSandboxCreateProgressDoneWithoutDetailsClosesWithoutOverwriting(t *testing.T) { + model, _ := newTestModel(t) + + updated, _ := model.Update(SandboxCreateProgressMsg{ + SourceVM: "ubuntu", + StepName: "Booting microVM", + StepNum: 7, + Total: 9, + }) + model = updated.(Model) + + updated, _ = model.Update(SandboxCreateProgressMsg{Done: true}) + model = updated.(Model) + + if model.showingLiveCreate { + t.Fatal("expected Done-only close to hide the live create box") + } + if len(model.liveCreateSteps) != 1 { + t.Fatalf("liveCreateSteps length = %d, want 1", len(model.liveCreateSteps)) + } + if got := model.liveCreateSteps[0]; got != " . [7/9] Booting microVM..." { + t.Fatalf("live create step = %q, want in-flight step to remain unchanged", got) + } +} + +func TestModelConsecutiveRunsAfterPrepareShowSourceToolResults(t *testing.T) { + model, _ := newTestModel(t) + + model.addUserMessage("/prepare test-vm-1") + model.state = StateThinking + model.thinking = true + model.currentInput = "/prepare test-vm-1" + model.statusChan <- AgentResponseMsg{Response: AgentResponse{ + Content: "Host test-vm-1 is prepared.", + Done: true, + }} + + updated, _ := model.Update(dequeueStatus(t, model)) + model = updated.(Model) + + if model.state != StateIdle { + t.Fatalf("state after prepare = %v, want %v", model.state, StateIdle) + } + if model.thinking { + t.Fatal("expected thinking to stop after prepare completion") + } + if got := len(model.statusChan); got != 0 { + t.Fatalf("status channel length after prepare = %d, want 0", got) + } + + model.addUserMessage("Hey can you investigate the nginx issue on test-vm-1?") + model.state = StateThinking + model.thinking = true + model.currentInput = "Hey can you investigate the nginx issue on test-vm-1?" + model.statusChan <- ToolStartMsg{ + ToolName: "run_source_command", + Args: map[string]any{ + "host": "test-vm-1", + "command": "systemctl status nginx", + }, + } + + updated, _ = model.Update(dequeueStatus(t, model)) + model = updated.(Model) + + if model.currentToolName != "run_source_command" { + t.Fatalf("currentToolName = %q, want run_source_command", model.currentToolName) + } + + model.statusChan <- ToolCompleteMsg{ + ToolName: "run_source_command", + Success: true, + Result: map[string]any{ + "exit_code": 0, + "stdout": "nginx.service - active\n", + "stderr": "", + }, + } + + updated, _ = model.Update(dequeueStatus(t, model)) + model = updated.(Model) + + model.statusChan <- AgentResponseMsg{Response: AgentResponse{ + Content: "nginx is active on test-vm-1.", + Done: true, + }} + + updated, _ = model.Update(dequeueStatus(t, model)) + model = updated.(Model) + + if model.state != StateIdle { + t.Fatalf("state after second run = %v, want %v", model.state, StateIdle) + } + + toolCount := 0 + for _, entry := range model.conversation { + if entry.Role == "tool" { + toolCount++ + } + } + if toolCount != 1 { + t.Fatalf("tool entry count = %d, want 1", toolCount) + } + + view := model.View() + if !strings.Contains(view, "run_source_command") { + t.Fatalf("view missing tool name: %q", view) + } + if !strings.Contains(view, "systemctl status nginx") { + t.Fatalf("view missing source command: %q", view) + } + if !strings.Contains(view, "nginx is active on test-vm-1.") { + t.Fatalf("view missing final assistant response: %q", view) + } +} + +func TestCleanupStartMsgInitializesCleanupView(t *testing.T) { + model, _ := newTestModel(t) + + updated, cmd := model.Update(CleanupStartMsg{SandboxIDs: []string{"sbx-1", "sbx-2"}}) + model = updated.(Model) + + if !model.inCleanup { + t.Fatal("expected cleanup mode to be active") + } + if model.quitting { + t.Fatal("expected quitting to be cleared when cleanup starts") + } + if len(model.cleanupOrder) != 2 { + t.Fatalf("cleanupOrder length = %d, want 2", len(model.cleanupOrder)) + } + if model.cleanupStatuses["sbx-1"] != CleanupStatusPending || model.cleanupStatuses["sbx-2"] != CleanupStatusPending { + t.Fatalf("cleanupStatuses = %v, want both pending", model.cleanupStatuses) + } + if cmd == nil { + t.Fatal("expected CleanupStartMsg to continue status listening") + } + view := model.View() + if !strings.Contains(view, "Cleaning Up Sandboxes") { + t.Fatalf("cleanup view missing header: %q", view) + } +} + +func TestStartCleanupReturnsCleanupStartMsg(t *testing.T) { + model, agent := newTestModelWithAgent(t) + model.cleanupOrder = []string{"sbx-1"} + agent.createdSandboxes = []string{"sbx-1"} + + cmd := model.startCleanup() + if cmd == nil { + t.Fatal("expected cleanup command") + } + msg := cmd() + startMsg, ok := msg.(CleanupStartMsg) + if !ok { + t.Fatalf("cleanup command returned %T, want CleanupStartMsg", msg) + } + if len(startMsg.SandboxIDs) != 1 || startMsg.SandboxIDs[0] != "sbx-1" { + t.Fatalf("CleanupStartMsg = %+v, want sandbox sbx-1", startMsg) + } +} diff --git a/fluid-daemon/cmd/fluid-daemon/main.go b/fluid-daemon/cmd/fluid-daemon/main.go index 73899621..0f8ac74d 100644 --- a/fluid-daemon/cmd/fluid-daemon/main.go +++ b/fluid-daemon/cmd/fluid-daemon/main.go @@ -6,9 +6,11 @@ import ( "fmt" "log/slog" "net" + "net/http" "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -173,9 +175,18 @@ func run(ctx context.Context, logger *slog.Logger) error { } } + // Read daemon identity pub key for sharing with CLI + var identityPubKey string + if pubKeyData, err := os.ReadFile(cfg.SSH.IdentityFile + ".pub"); err == nil { + identityPubKey = strings.TrimSpace(string(pubKeyData)) + logger.Info("loaded SSH identity pub key", "path", cfg.SSH.IdentityFile+".pub") + } else { + logger.Warn("SSH identity pub key not found, daemon key deployment will be unavailable", "path", cfg.SSH.IdentityFile+".pub", "error", err) + } + // Start DaemonService gRPC server (inbound from CLI) if cfg.Daemon.Enabled { - daemonSrv := daemon.NewServer(prov, st, puller, keyMgr, tele, redactor, auditLog, cfg.HostID, version, cfg.SSH.IdentityFile, caPubKey, logger) + daemonSrv := daemon.NewServer(cfg, prov, st, puller, keyMgr, tele, redactor, auditLog, cfg.HostID, version, cfg.SSH.IdentityFile, caPubKey, identityPubKey, logger) grpcServer := grpc.NewServer() fluidv1.RegisterDaemonServiceServer(grpcServer, daemonSrv) @@ -337,7 +348,26 @@ func initMicroVMProvider(ctx context.Context, cfg *config.Config, logger *slog.L } } - return microvmProvider.New(vmMgr, netMgr, imgStore, srcVMMgr, logger), keyMgr, caPubKey, nil + // Discover bridge IP for cloud-init phone_home readiness signaling + bridgeIP, _ := network.GetBridgeIP(cfg.Network.DefaultBridge) + if bridgeIP != "" { + logger.Info("bridge IP discovered for phone_home", "bridge", cfg.Network.DefaultBridge, "ip", bridgeIP) + } + + // Start readiness HTTP server for cloud-init phone_home callbacks + var readiness *daemon.ReadinessServer + if bridgeIP != "" { + readinessAddr := bridgeIP + ":9092" + readiness = daemon.NewReadinessServer(readinessAddr, logger) + go func() { + if err := readiness.Start(); err != nil && err != http.ErrServerClosed { + logger.Warn("readiness server error", "error", err) + } + }() + logger.Info("readiness server started", "addr", readinessAddr) + } + + return microvmProvider.New(vmMgr, netMgr, imgStore, srcVMMgr, keyMgr, cfg.MicroVM.KernelPath, cfg.MicroVM.InitrdPath, cfg.MicroVM.RootDevice, cfg.MicroVM.Accel, caPubKey, bridgeIP, readiness, logger), keyMgr, caPubKey, nil } func initLXCProvider(cfg *config.Config, logger *slog.Logger) (provider.SandboxProvider, error) { diff --git a/fluid-daemon/go.mod b/fluid-daemon/go.mod index 223de4c9..60d4bc01 100644 --- a/fluid-daemon/go.mod +++ b/fluid-daemon/go.mod @@ -16,19 +16,28 @@ require ( ) require ( + github.com/anchore/go-lzo v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/diskfs/go-diskfs v1.7.0 // indirect + github.com/djherbis/times v1.6.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.17.4 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pierrec/lz4/v4 v4.1.17 // indirect + github.com/pkg/xattr v0.4.9 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect + github.com/ulikunitz/xz v0.5.11 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/fluid-daemon/go.sum b/fluid-daemon/go.sum index 8c8e06a3..99e413df 100644 --- a/fluid-daemon/go.sum +++ b/fluid-daemon/go.sum @@ -1,12 +1,22 @@ +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= github.com/aspectrr/fluid.sh/proto/gen/go v0.1.5 h1:6znWyJu5ICUbbCxJRkPGmBRWqF63JxKZ8EWFqv++iDM= github.com/aspectrr/fluid.sh/proto/gen/go v0.1.5/go.mod h1:KWF7CKksKSSwr82ia86QcAG90ciDruqD3HmoeZC1RaY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8= +github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= +github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= @@ -31,6 +41,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -42,7 +54,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= +github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= +github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posthog/posthog-go v1.10.0 h1:wfoy7Jfb4LigCoHYyMZoiJmmEoCLOkSaYfDxM/NtCqY= @@ -52,8 +69,14 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= +github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -72,6 +95,9 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -90,6 +116,7 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= diff --git a/fluid-daemon/internal/agent/client.go b/fluid-daemon/internal/agent/client.go index 4ad91473..f6a9dffc 100644 --- a/fluid-daemon/internal/agent/client.go +++ b/fluid-daemon/internal/agent/client.go @@ -388,7 +388,7 @@ func (c *Client) handleCreateSandbox(ctx context.Context, reqID string, cmd *flu case "libvirt": backend = snapshotpull.NewLibvirtBackend( conn.GetSshHost(), int(conn.GetSshPort()), - conn.GetSshUser(), c.sshIdentityFile, "qemu:///system", c.logger) + conn.GetSshUser(), c.sshIdentityFile, c.logger) case "proxmox": backend = snapshotpull.NewProxmoxBackend( conn.GetProxmoxHost(), conn.GetProxmoxTokenId(), diff --git a/fluid-daemon/internal/config/config.go b/fluid-daemon/internal/config/config.go index 2958a90a..fa6bc228 100644 --- a/fluid-daemon/internal/config/config.go +++ b/fluid-daemon/internal/config/config.go @@ -52,6 +52,19 @@ type Config struct { // Audit configures the audit trail log. Audit AuditConfig `yaml:"audit"` + + // SourceHosts configures remote hypervisor hosts where source VMs live. + // The daemon auto-discovers VMs on these hosts so the CLI only needs + // to send a VM name (no SourceHostConnection required). + SourceHosts []SourceHostConfig `yaml:"source_hosts"` +} + +// SourceHostConfig describes a remote hypervisor host the daemon can reach via SSH. +type SourceHostConfig struct { + Address string `yaml:"address"` + SSHUser string `yaml:"ssh_user"` // default: fluid-daemon + SSHPort int `yaml:"ssh_port"` // default: 22 + Type string `yaml:"type"` // "libvirt" (default) or "proxmox" } // TelemetryConfig controls anonymous telemetry. @@ -117,6 +130,19 @@ type MicroVMConfig struct { // QEMUBinary is the path to qemu-system-x86_64. QEMUBinary string `yaml:"qemu_binary"` + // Accel is the QEMU accelerator: "kvm" (default) or "tcg". + Accel string `yaml:"accel"` + + // KernelPath is the path to a pre-downloaded Linux kernel for microVM boot. + KernelPath string `yaml:"kernel_path"` + + // InitrdPath is the path to the initramfs image matching the kernel. + // Required for distribution kernels that have virtio drivers as modules. + InitrdPath string `yaml:"initrd_path"` + + // RootDevice is the kernel root= device (e.g. /dev/vda1 for partitioned images). + RootDevice string `yaml:"root_device"` + // WorkDir is the directory for sandbox runtime data (overlays, PID files). WorkDir string `yaml:"work_dir"` @@ -215,6 +241,9 @@ func DefaultConfig() Config { }, MicroVM: MicroVMConfig{ QEMUBinary: "qemu-system-x86_64", + KernelPath: "/var/lib/fluid-daemon/vmlinuz", + InitrdPath: "/var/lib/fluid-daemon/initrd.img", + RootDevice: "/dev/vda1", WorkDir: "/var/lib/fluid-daemon/overlays", DefaultVCPUs: 2, DefaultMemoryMB: 2048, @@ -234,7 +263,7 @@ func DefaultConfig() Config { SSH: SSHConfig{ CAKeyPath: filepath.Join(fluidDir, "ssh_ca"), CAPubKeyPath: filepath.Join(fluidDir, "ssh_ca.pub"), - KeyDir: filepath.Join(fluidDir, "keys"), + KeyDir: "/var/lib/fluid-daemon/keys", CertTTL: 30 * time.Minute, DefaultUser: "sandbox", IdentityFile: filepath.Join(fluidDir, "identity"), diff --git a/fluid-daemon/internal/config/config_test.go b/fluid-daemon/internal/config/config_test.go index 71c878bc..66fd3276 100644 --- a/fluid-daemon/internal/config/config_test.go +++ b/fluid-daemon/internal/config/config_test.go @@ -63,8 +63,8 @@ func TestDefaultConfig(t *testing.T) { if cfg.SSH.CAPubKeyPath != filepath.Join(fluidDir, "ssh_ca.pub") { t.Errorf("SSH.CAPubKeyPath = %q, want %q", cfg.SSH.CAPubKeyPath, filepath.Join(fluidDir, "ssh_ca.pub")) } - if cfg.SSH.KeyDir != filepath.Join(fluidDir, "keys") { - t.Errorf("SSH.KeyDir = %q, want %q", cfg.SSH.KeyDir, filepath.Join(fluidDir, "keys")) + if cfg.SSH.KeyDir != "/var/lib/fluid-daemon/keys" { + t.Errorf("SSH.KeyDir = %q, want %q", cfg.SSH.KeyDir, "/var/lib/fluid-daemon/keys") } if cfg.SSH.CertTTL != 30*time.Minute { t.Errorf("SSH.CertTTL = %v, want %v", cfg.SSH.CertTTL, 30*time.Minute) diff --git a/fluid-daemon/internal/daemon/doctor.go b/fluid-daemon/internal/daemon/doctor.go new file mode 100644 index 00000000..c0875ded --- /dev/null +++ b/fluid-daemon/internal/daemon/doctor.go @@ -0,0 +1,201 @@ +package daemon + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "time" + + fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" +) + +func (s *Server) DoctorCheck(ctx context.Context, _ *fluidv1.DoctorCheckRequest) (*fluidv1.DoctorCheckResponse, error) { + var results []*fluidv1.DoctorCheckResult + results = append(results, s.checkQEMUBinary()) + results = append(results, s.checkKVMAvailable()) + results = append(results, s.checkKernelPath()) + results = append(results, s.checkInitrdPath()) + results = append(results, s.checkStorageDirs()...) + results = append(results, s.checkNetworkBridge()) + results = append(results, s.checkSourceHosts(ctx)...) + return &fluidv1.DoctorCheckResponse{Results: results}, nil +} + +func (s *Server) checkQEMUBinary() *fluidv1.DoctorCheckResult { + binary := s.cfg.MicroVM.QEMUBinary + path, err := exec.LookPath(binary) + if err != nil { + return &fluidv1.DoctorCheckResult{ + Name: "qemu-binary", + Category: "binary", + Passed: false, + Message: fmt.Sprintf("QEMU binary %q not found in PATH", binary), + FixCmd: "sudo apt install -y qemu-system-x86", + } + } + return &fluidv1.DoctorCheckResult{ + Name: "qemu-binary", + Category: "binary", + Passed: true, + Message: fmt.Sprintf("QEMU binary found at %s", path), + } +} + +func (s *Server) checkKVMAvailable() *fluidv1.DoctorCheckResult { + if _, err := os.Stat("/dev/kvm"); err != nil { + return &fluidv1.DoctorCheckResult{ + Name: "kvm-available", + Category: "prerequisites", + Passed: false, + Message: "KVM not available (/dev/kvm missing)", + FixCmd: "sudo modprobe kvm && sudo modprobe kvm_intel || sudo modprobe kvm_amd. Or set accel: tcg in daemon config to use software emulation", + } + } + return &fluidv1.DoctorCheckResult{ + Name: "kvm-available", + Category: "prerequisites", + Passed: true, + Message: "KVM available (/dev/kvm)", + } +} + +func (s *Server) checkKernelPath() *fluidv1.DoctorCheckResult { + kp := s.cfg.MicroVM.KernelPath + if _, err := os.Stat(kp); err != nil { + return &fluidv1.DoctorCheckResult{ + Name: "kernel-path", + Category: "prerequisites", + Passed: false, + Message: fmt.Sprintf("kernel not found at %s", kp), + FixCmd: fmt.Sprintf("download a vmlinuz to %s", kp), + } + } + return &fluidv1.DoctorCheckResult{ + Name: "kernel-path", + Category: "prerequisites", + Passed: true, + Message: fmt.Sprintf("kernel found at %s", kp), + } +} + +func (s *Server) checkInitrdPath() *fluidv1.DoctorCheckResult { + ip := s.cfg.MicroVM.InitrdPath + if ip == "" { + return &fluidv1.DoctorCheckResult{ + Name: "initrd-path", + Category: "prerequisites", + Passed: true, + Message: "initrd not configured (direct kernel boot without initramfs)", + } + } + if _, err := os.Stat(ip); err != nil { + return &fluidv1.DoctorCheckResult{ + Name: "initrd-path", + Category: "prerequisites", + Passed: false, + Message: fmt.Sprintf("initrd not found at %s", ip), + FixCmd: fmt.Sprintf("sudo cp /boot/initrd.img-$(uname -r) %s", ip), + } + } + return &fluidv1.DoctorCheckResult{ + Name: "initrd-path", + Category: "prerequisites", + Passed: true, + Message: fmt.Sprintf("initrd found at %s", ip), + } +} + +func (s *Server) checkStorageDirs() []*fluidv1.DoctorCheckResult { + dirs := map[string]string{ + "image-dir": s.cfg.Image.BaseDir, + "work-dir": s.cfg.MicroVM.WorkDir, + } + var results []*fluidv1.DoctorCheckResult + for name, dir := range dirs { + if _, err := os.Stat(dir); err != nil { + results = append(results, &fluidv1.DoctorCheckResult{ + Name: "storage-dirs", + Category: "storage", + Passed: false, + Message: fmt.Sprintf("%s missing: %s", name, dir), + FixCmd: fmt.Sprintf("sudo mkdir -p %s", dir), + }) + } else { + results = append(results, &fluidv1.DoctorCheckResult{ + Name: "storage-dirs", + Category: "storage", + Passed: true, + Message: fmt.Sprintf("%s exists: %s", name, dir), + }) + } + } + return results +} + +func (s *Server) checkNetworkBridge() *fluidv1.DoctorCheckResult { + bridge := s.cfg.Network.DefaultBridge + if _, err := net.InterfaceByName(bridge); err != nil { + return &fluidv1.DoctorCheckResult{ + Name: "network-bridge", + Category: "network", + Passed: false, + Message: fmt.Sprintf("bridge %q not found", bridge), + FixCmd: fmt.Sprintf("sudo ip link add %s type bridge && sudo ip link set %s up", bridge, bridge), + } + } + return &fluidv1.DoctorCheckResult{ + Name: "network-bridge", + Category: "network", + Passed: true, + Message: fmt.Sprintf("bridge %q found", bridge), + } +} + +// checkSourceHosts verifies SSH + libvirt connectivity to each configured source host. +func (s *Server) checkSourceHosts(ctx context.Context) []*fluidv1.DoctorCheckResult { + if len(s.cfg.SourceHosts) == 0 { + return nil + } + + var results []*fluidv1.DoctorCheckResult + for _, conn := range s.sourceHostConns() { + host := conn.SshHost + user := conn.SshUser + name := fmt.Sprintf("source-host-%s", host) + + mgr, err := s.adhocSourceVMManager(conn) + if err != nil { + results = append(results, &fluidv1.DoctorCheckResult{ + Name: name, + Category: "source-hosts", + Passed: false, + Message: fmt.Sprintf("cannot create manager for %s: %v", host, err), + }) + continue + } + + checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + _, err = mgr.ListVMs(checkCtx) + cancel() + + if err != nil { + results = append(results, &fluidv1.DoctorCheckResult{ + Name: name, + Category: "source-hosts", + Passed: false, + Message: fmt.Sprintf("cannot reach %s as %s: %v", host, user, err), + FixCmd: fmt.Sprintf("Run 'fluid connect' and press Enter to setup source hosts, or manually: ssh-keyscan -H %s >> ~fluid-daemon/.ssh/known_hosts", host), + }) + } else { + results = append(results, &fluidv1.DoctorCheckResult{ + Name: name, + Category: "source-hosts", + Passed: true, + Message: fmt.Sprintf("SSH + libvirt OK for %s@%s", user, host), + }) + } + } + return results +} diff --git a/fluid-daemon/internal/daemon/doctor_test.go b/fluid-daemon/internal/daemon/doctor_test.go new file mode 100644 index 00000000..768ee8a0 --- /dev/null +++ b/fluid-daemon/internal/daemon/doctor_test.go @@ -0,0 +1,190 @@ +package daemon + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/config" + fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" +) + +func newTestServer(cfg *config.Config) *Server { + return &Server{cfg: cfg} +} + +func TestDoctorCheck_KVMAvailable(t *testing.T) { + s := newTestServer(&config.Config{}) + result := s.checkKVMAvailable() + + // /dev/kvm may or may not exist in the test environment + if result.Name != "kvm-available" { + t.Errorf("name = %q, want %q", result.Name, "kvm-available") + } + if result.Category != "prerequisites" { + t.Errorf("category = %q, want %q", result.Category, "prerequisites") + } +} + +func TestDoctorCheck_KernelPath(t *testing.T) { + tmp := t.TempDir() + kernelPath := filepath.Join(tmp, "vmlinuz") + + // Missing kernel + s := newTestServer(&config.Config{ + MicroVM: config.MicroVMConfig{KernelPath: kernelPath}, + }) + result := s.checkKernelPath() + if result.Passed { + t.Error("expected kernel-path to fail when file missing") + } + + // Create kernel file + if err := os.WriteFile(kernelPath, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + result = s.checkKernelPath() + if !result.Passed { + t.Error("expected kernel-path to pass when file exists") + } +} + +func TestDoctorCheck_InitrdPath(t *testing.T) { + tmp := t.TempDir() + initrdPath := filepath.Join(tmp, "initrd.img") + + // Configured but missing + s := newTestServer(&config.Config{ + MicroVM: config.MicroVMConfig{InitrdPath: initrdPath}, + }) + result := s.checkInitrdPath() + if result.Passed { + t.Error("expected initrd-path to fail when file missing") + } + if result.Name != "initrd-path" { + t.Errorf("name = %q, want %q", result.Name, "initrd-path") + } + if result.FixCmd == "" { + t.Error("expected fix command when initrd missing") + } + + // Configured and exists + if err := os.WriteFile(initrdPath, []byte("fake-initrd"), 0o644); err != nil { + t.Fatal(err) + } + result = s.checkInitrdPath() + if !result.Passed { + t.Error("expected initrd-path to pass when file exists") + } + + // Not configured (empty path) + s = newTestServer(&config.Config{ + MicroVM: config.MicroVMConfig{InitrdPath: ""}, + }) + result = s.checkInitrdPath() + if !result.Passed { + t.Error("expected initrd-path to pass when not configured") + } + if result.Message != "initrd not configured (direct kernel boot without initramfs)" { + t.Errorf("unexpected message for unconfigured initrd: %s", result.Message) + } +} + +func TestDoctorCheck_StorageDirs(t *testing.T) { + tmp := t.TempDir() + imageDir := filepath.Join(tmp, "images") + workDir := filepath.Join(tmp, "overlays") + + s := newTestServer(&config.Config{ + Image: config.ImageConfig{BaseDir: imageDir}, + MicroVM: config.MicroVMConfig{WorkDir: workDir}, + }) + + // Both missing + results := s.checkStorageDirs() + for _, r := range results { + if r.Passed { + t.Errorf("expected %s to fail when dir missing", r.Message) + } + } + + // Create dirs + if err := os.MkdirAll(imageDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + + results = s.checkStorageDirs() + for _, r := range results { + if !r.Passed { + t.Errorf("expected %s to pass when dir exists", r.Message) + } + } +} + +func TestDoctorCheck_NetworkBridge(t *testing.T) { + s := newTestServer(&config.Config{ + Network: config.NetworkConfig{DefaultBridge: "nonexistent-bridge-xyz"}, + }) + result := s.checkNetworkBridge() + if result.Passed { + t.Error("expected network-bridge to fail for nonexistent bridge") + } + if result.Name != "network-bridge" { + t.Errorf("name = %q, want %q", result.Name, "network-bridge") + } +} + +func TestScanSourceHostKeys_NoSourceHosts(t *testing.T) { + s := newTestServer(&config.Config{}) + resp, err := s.ScanSourceHostKeys(context.Background(), &fluidv1.ScanSourceHostKeysRequest{}) + if err != nil { + t.Fatalf("ScanSourceHostKeys() error: %v", err) + } + if len(resp.Results) != 0 { + t.Errorf("expected 0 results, got %d", len(resp.Results)) + } +} + +func TestDoctorCheck_FullRPC(t *testing.T) { + tmp := t.TempDir() + imageDir := filepath.Join(tmp, "images") + workDir := filepath.Join(tmp, "overlays") + if err := os.MkdirAll(imageDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + + s := newTestServer(&config.Config{ + MicroVM: config.MicroVMConfig{ + QEMUBinary: "nonexistent-qemu-binary-xyz", + KernelPath: filepath.Join(tmp, "vmlinuz"), + WorkDir: workDir, + }, + Image: config.ImageConfig{BaseDir: imageDir}, + Network: config.NetworkConfig{DefaultBridge: "nonexistent-bridge-xyz"}, + }) + + resp, err := s.DoctorCheck(context.Background(), &fluidv1.DoctorCheckRequest{}) + if err != nil { + t.Fatalf("DoctorCheck() error: %v", err) + } + if len(resp.Results) == 0 { + t.Fatal("expected at least one result") + } + + // Verify all results have names and categories + for _, r := range resp.Results { + if r.Name == "" { + t.Error("result has empty name") + } + if r.Category == "" { + t.Error("result has empty category") + } + } +} diff --git a/fluid-daemon/internal/daemon/helpers.go b/fluid-daemon/internal/daemon/helpers.go index b74370b6..d03c4714 100644 --- a/fluid-daemon/internal/daemon/helpers.go +++ b/fluid-daemon/internal/daemon/helpers.go @@ -1,6 +1,7 @@ package daemon import ( + "context" "fmt" "net/url" @@ -48,3 +49,68 @@ func (s *Server) adhocSourceVMManager(conn *fluidv1.SourceHostConnection) (*sour return sourcevm.NewManager(uri, "default", s.keyMgr, "fluid-readonly", proxyJump, s.sshIdentityFile, s.caPubKey, s.logger), nil } + +// sourceHostConns builds SourceHostConnections from the daemon's configured source hosts. +func (s *Server) sourceHostConns() []*fluidv1.SourceHostConnection { + conns := make([]*fluidv1.SourceHostConnection, 0, len(s.cfg.SourceHosts)) + for _, h := range s.cfg.SourceHosts { + user := h.SSHUser + if user == "" { + user = "fluid-daemon" + } + port := h.SSHPort + if port == 0 { + port = 22 + } + typ := h.Type + if typ == "" { + typ = "libvirt" + } + conns = append(conns, &fluidv1.SourceHostConnection{ + Type: typ, + SshHost: h.Address, + SshPort: int32(port), + SshUser: user, + }) + } + return conns +} + +// resolveSourceHost looks up which configured source host owns vmName. +// It checks the cache first, then discovers VMs across all configured hosts. +func (s *Server) resolveSourceHost(ctx context.Context, vmName string) (*fluidv1.SourceHostConnection, error) { + // Check cache + s.vmHostMu.RLock() + if conn, ok := s.vmHostCache[vmName]; ok { + s.vmHostMu.RUnlock() + return conn, nil + } + s.vmHostMu.RUnlock() + + // Discover across all configured source hosts + for _, conn := range s.sourceHostConns() { + mgr, err := s.adhocSourceVMManager(conn) + if err != nil { + s.logger.Warn("failed to create manager for source host", "host", conn.SshHost, "error", err) + continue + } + vms, err := mgr.ListVMs(ctx) + if err != nil { + s.logger.Warn("failed to list VMs on source host", "host", conn.SshHost, "error", err) + continue + } + s.vmHostMu.Lock() + for _, vm := range vms { + s.vmHostCache[vm.Name] = conn + } + s.vmHostMu.Unlock() + + s.vmHostMu.RLock() + if c, ok := s.vmHostCache[vmName]; ok { + s.vmHostMu.RUnlock() + return c, nil + } + s.vmHostMu.RUnlock() + } + return nil, fmt.Errorf("VM %q not found on any configured source host", vmName) +} diff --git a/fluid-daemon/internal/daemon/helpers_test.go b/fluid-daemon/internal/daemon/helpers_test.go new file mode 100644 index 00000000..42958598 --- /dev/null +++ b/fluid-daemon/internal/daemon/helpers_test.go @@ -0,0 +1,109 @@ +package daemon + +import ( + "testing" + + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/config" + fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" +) + +func TestSourceHostConns_Defaults(t *testing.T) { + s := &Server{ + cfg: &config.Config{ + SourceHosts: []config.SourceHostConfig{ + {Address: "10.0.0.1"}, + }, + }, + } + + conns := s.sourceHostConns() + if len(conns) != 1 { + t.Fatalf("got %d conns, want 1", len(conns)) + } + c := conns[0] + if c.SshHost != "10.0.0.1" { + t.Errorf("host: got %q, want 10.0.0.1", c.SshHost) + } + if c.SshUser != "fluid-daemon" { + t.Errorf("user: got %q, want fluid-daemon", c.SshUser) + } + if c.SshPort != 22 { + t.Errorf("port: got %d, want 22", c.SshPort) + } + if c.Type != "libvirt" { + t.Errorf("type: got %q, want libvirt", c.Type) + } +} + +func TestSourceHostConns_CustomValues(t *testing.T) { + s := &Server{ + cfg: &config.Config{ + SourceHosts: []config.SourceHostConfig{ + {Address: "10.0.0.1", SSHUser: "admin", SSHPort: 2222, Type: "proxmox"}, + {Address: "10.0.0.2"}, + }, + }, + } + + conns := s.sourceHostConns() + if len(conns) != 2 { + t.Fatalf("got %d conns, want 2", len(conns)) + } + + // First host: custom values + if conns[0].SshUser != "admin" { + t.Errorf("host0 user: got %q, want admin", conns[0].SshUser) + } + if conns[0].SshPort != 2222 { + t.Errorf("host0 port: got %d, want 2222", conns[0].SshPort) + } + if conns[0].Type != "proxmox" { + t.Errorf("host0 type: got %q, want proxmox", conns[0].Type) + } + + // Second host: defaults + if conns[1].SshUser != "fluid-daemon" { + t.Errorf("host1 user: got %q, want fluid-daemon", conns[1].SshUser) + } + if conns[1].SshPort != 22 { + t.Errorf("host1 port: got %d, want 22", conns[1].SshPort) + } +} + +func TestSourceHostConns_Empty(t *testing.T) { + s := &Server{cfg: &config.Config{}} + conns := s.sourceHostConns() + if len(conns) != 0 { + t.Errorf("got %d conns, want 0", len(conns)) + } +} + +func TestResolveSourceHost_CacheHit(t *testing.T) { + expected := &fluidv1.SourceHostConnection{ + Type: "libvirt", SshHost: "10.0.0.1", SshPort: 22, SshUser: "fluid-daemon", + } + s := &Server{ + cfg: &config.Config{}, + vmHostCache: map[string]*fluidv1.SourceHostConnection{"my-vm": expected}, + } + + conn, err := s.resolveSourceHost(t.Context(), "my-vm") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn.SshHost != "10.0.0.1" { + t.Errorf("got host %q, want 10.0.0.1", conn.SshHost) + } +} + +func TestResolveSourceHost_NotFound_NoHosts(t *testing.T) { + s := &Server{ + cfg: &config.Config{}, + vmHostCache: make(map[string]*fluidv1.SourceHostConnection), + } + + _, err := s.resolveSourceHost(t.Context(), "nonexistent") + if err == nil { + t.Fatal("expected error for VM not found") + } +} diff --git a/fluid-daemon/internal/daemon/readiness.go b/fluid-daemon/internal/daemon/readiness.go new file mode 100644 index 00000000..c999ce7b --- /dev/null +++ b/fluid-daemon/internal/daemon/readiness.go @@ -0,0 +1,123 @@ +package daemon + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// ReadinessServer listens for cloud-init phone_home callbacks from sandboxes. +// When a sandbox's cloud-init finishes (sshd restarted, CA key installed), +// it POSTs to /ready/{sandbox_id}, which unblocks WaitReady. +type ReadinessServer struct { + mu sync.Mutex + waiters map[string]chan struct{} + logger *slog.Logger + server *http.Server +} + +// NewReadinessServer creates a readiness server listening on the given address. +// addr should be "bridgeIP:port" (e.g., "10.0.0.1:9092"). +func NewReadinessServer(addr string, logger *slog.Logger) *ReadinessServer { + rs := &ReadinessServer{ + waiters: make(map[string]chan struct{}), + logger: logger.With("component", "readiness"), + } + + mux := http.NewServeMux() + mux.HandleFunc("/ready/", rs.handleReady) + + rs.server = &http.Server{ + Addr: addr, + Handler: mux, + } + + return rs +} + +// Start begins listening. Blocks until the server is shut down. +func (rs *ReadinessServer) Start() error { + rs.logger.Info("readiness server starting", "addr", rs.server.Addr) + ln, err := net.Listen("tcp", rs.server.Addr) + if err != nil { + return fmt.Errorf("readiness listen: %w", err) + } + return rs.server.Serve(ln) +} + +// Shutdown gracefully stops the server. +func (rs *ReadinessServer) Shutdown(ctx context.Context) error { + return rs.server.Shutdown(ctx) +} + +// Register creates a waiter channel for a sandbox ID. Must be called before +// WaitReady so the channel exists when phone_home arrives. +func (rs *ReadinessServer) Register(sandboxID string) { + rs.mu.Lock() + defer rs.mu.Unlock() + rs.waiters[sandboxID] = make(chan struct{}) +} + +// Unregister removes a sandbox's waiter channel (cleanup). +func (rs *ReadinessServer) Unregister(sandboxID string) { + rs.mu.Lock() + defer rs.mu.Unlock() + delete(rs.waiters, sandboxID) +} + +// WaitReady blocks until the sandbox's phone_home POST arrives or timeout expires. +func (rs *ReadinessServer) WaitReady(sandboxID string, timeout time.Duration) error { + rs.mu.Lock() + ch, ok := rs.waiters[sandboxID] + rs.mu.Unlock() + + if !ok { + return fmt.Errorf("sandbox %s not registered for readiness", sandboxID) + } + + select { + case <-ch: + return nil + case <-time.After(timeout): + return fmt.Errorf("readiness timeout for sandbox %s after %v", sandboxID, timeout) + } +} + +// handleReady handles POST /ready/{sandbox_id} from cloud-init phone_home. +func (rs *ReadinessServer) handleReady(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract sandbox ID from path: /ready/{sandbox_id} + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ready/"), "/") + if len(parts) == 0 || parts[0] == "" { + http.Error(w, "missing sandbox_id", http.StatusBadRequest) + return + } + sandboxID := parts[0] + + rs.logger.Info("phone_home received", "sandbox_id", sandboxID) + + rs.mu.Lock() + ch, ok := rs.waiters[sandboxID] + rs.mu.Unlock() + + if ok { + select { + case <-ch: + // Already signaled + default: + close(ch) + } + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "ok\n") +} diff --git a/fluid-daemon/internal/daemon/server.go b/fluid-daemon/internal/daemon/server.go index 21a6c562..a22b4bde 100644 --- a/fluid-daemon/internal/daemon/server.go +++ b/fluid-daemon/internal/daemon/server.go @@ -6,12 +6,17 @@ import ( "fmt" "log/slog" "os" + "os/exec" + "os/user" + "path/filepath" "strings" + "sync" "time" fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" "github.com/aspectrr/fluid.sh/fluid-daemon/internal/audit" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/config" "github.com/aspectrr/fluid.sh/fluid-daemon/internal/provider" "github.com/aspectrr/fluid.sh/fluid-daemon/internal/redact" "github.com/aspectrr/fluid.sh/fluid-daemon/internal/snapshotpull" @@ -25,13 +30,24 @@ import ( "google.golang.org/grpc/status" ) +const createSandboxStreamTotalSteps = 9 + +type sandboxCreatePuller interface { + Pull(context.Context, snapshotpull.PullRequest, snapshotpull.SnapshotBackend) (*snapshotpull.PullResult, error) +} + +type sandboxCreateProgressProvider interface { + CreateSandboxWithProgress(context.Context, provider.CreateRequest, func(string, int, int)) (*provider.SandboxResult, error) +} + // Server implements the DaemonServiceServer interface. type Server struct { fluidv1.UnimplementedDaemonServiceServer + cfg *config.Config prov provider.SandboxProvider store *state.Store - puller *snapshotpull.Puller + puller sandboxCreatePuller keyMgr sshkeys.KeyProvider telemetry telemetry.Service redactor *redact.Redactor @@ -40,12 +56,17 @@ type Server struct { version string sshIdentityFile string caPubKey string + identityPubKey string logger *slog.Logger + + vmHostMu sync.RWMutex + vmHostCache map[string]*fluidv1.SourceHostConnection // VM name -> host connection } // NewServer creates a new DaemonService server. -func NewServer(prov provider.SandboxProvider, store *state.Store, puller *snapshotpull.Puller, keyMgr sshkeys.KeyProvider, tele telemetry.Service, redactor *redact.Redactor, auditLog *audit.Logger, hostID, version, sshIdentityFile, caPubKey string, logger *slog.Logger) *Server { +func NewServer(cfg *config.Config, prov provider.SandboxProvider, store *state.Store, puller *snapshotpull.Puller, keyMgr sshkeys.KeyProvider, tele telemetry.Service, redactor *redact.Redactor, auditLog *audit.Logger, hostID, version, sshIdentityFile, caPubKey, identityPubKey string, logger *slog.Logger) *Server { return &Server{ + cfg: cfg, prov: prov, store: store, puller: puller, @@ -57,8 +78,30 @@ func NewServer(prov provider.SandboxProvider, store *state.Store, puller *snapsh version: version, sshIdentityFile: sshIdentityFile, caPubKey: caPubKey, + identityPubKey: identityPubKey, logger: logger.With("component", "daemon-service"), + vmHostCache: make(map[string]*fluidv1.SourceHostConnection), + } +} + +func (s *Server) sendSandboxCreateProgress(stream fluidv1.DaemonService_CreateSandboxStreamServer, sandboxID string, stepNum int, step string) error { + return stream.Send(&fluidv1.SandboxProgress{ + SandboxId: sandboxID, + Step: step, + StepNum: int32(stepNum), + TotalSteps: createSandboxStreamTotalSteps, + }) +} + +func (s *Server) sendSandboxCreateError(stream fluidv1.DaemonService_CreateSandboxStreamServer, sandboxID string, err error) { + if err == nil { + return } + _ = stream.Send(&fluidv1.SandboxProgress{ + SandboxId: sandboxID, + Error: err.Error(), + Done: true, + }) } func (s *Server) CreateSandbox(ctx context.Context, req *fluidv1.CreateSandboxCommand) (*fluidv1.SandboxCreated, error) { @@ -84,15 +127,23 @@ func (s *Server) CreateSandbox(ctx context.Context, req *fluidv1.CreateSandboxCo memMB = 2048 } - // If a source host connection is provided, snapshot+pull the image first + // Resolve source host connection: use provided, or resolve from config baseImage := req.GetBaseImage() - if conn := req.GetSourceHostConnection(); conn != nil && req.GetSourceVm() != "" && s.puller != nil { + conn := req.GetSourceHostConnection() + if conn == nil && req.GetSourceVm() != "" && s.puller != nil && len(s.cfg.SourceHosts) > 0 { + resolved, err := s.resolveSourceHost(ctx, req.GetSourceVm()) + if err != nil { + return nil, status.Errorf(codes.NotFound, "resolve source host: %v", err) + } + conn = resolved + } + if conn != nil && req.GetSourceVm() != "" && s.puller != nil { var backend snapshotpull.SnapshotBackend switch conn.GetType() { case "libvirt": backend = snapshotpull.NewLibvirtBackend( conn.GetSshHost(), int(conn.GetSshPort()), - conn.GetSshUser(), s.sshIdentityFile, "qemu:///system", s.logger) + conn.GetSshUser(), s.sshIdentityFile, s.logger) case "proxmox": backend = snapshotpull.NewProxmoxBackend( conn.GetProxmoxHost(), conn.GetProxmoxTokenId(), @@ -174,6 +225,230 @@ func (s *Server) CreateSandbox(ctx context.Context, req *fluidv1.CreateSandboxCo }, nil } +func (s *Server) CreateSandboxStream(req *fluidv1.CreateSandboxCommand, stream fluidv1.DaemonService_CreateSandboxStreamServer) error { + ctx := stream.Context() + start := time.Now() + s.telemetry.Track("daemon_sandbox_created_stream", nil) + s.logger.Info("CreateSandboxStream", "base_image", req.GetBaseImage(), "source_vm", req.GetSourceVm(), "name", req.GetName()) + + sandboxID := req.GetSandboxId() + if sandboxID == "" { + var err error + sandboxID, err = genid.Generate("sbx-") + if err != nil { + return status.Errorf(codes.Internal, "generate sandbox ID: %v", err) + } + } + + vcpus := int(req.GetVcpus()) + if vcpus == 0 { + vcpus = 2 + } + memMB := int(req.GetMemoryMb()) + if memMB == 0 { + memMB = 2048 + } + + // Resolve source host connection: use provided, or resolve from config + baseImage := req.GetBaseImage() + conn := req.GetSourceHostConnection() + switch { + case conn != nil: + if err := s.sendSandboxCreateProgress(stream, sandboxID, 1, "Using provided source host"); err != nil { + return err + } + case req.GetSourceVm() != "" && s.puller != nil && len(s.cfg.SourceHosts) > 0: + if err := s.sendSandboxCreateProgress(stream, sandboxID, 1, "Resolving source host"); err != nil { + return err + } + resolved, err := s.resolveSourceHost(ctx, req.GetSourceVm()) + if err != nil { + s.sendSandboxCreateError(stream, sandboxID, err) + return status.Errorf(codes.NotFound, "resolve source host: %v", err) + } + conn = resolved + default: + if err := s.sendSandboxCreateProgress(stream, sandboxID, 1, "No source host resolution needed"); err != nil { + return err + } + } + + backend := snapshotpull.SnapshotBackend(nil) + if conn != nil && req.GetSourceVm() != "" && s.puller != nil { + switch conn.GetType() { + case "libvirt": + backend = snapshotpull.NewLibvirtBackend( + conn.GetSshHost(), int(conn.GetSshPort()), + conn.GetSshUser(), s.sshIdentityFile, s.logger) + case "proxmox": + backend = snapshotpull.NewProxmoxBackend( + conn.GetProxmoxHost(), conn.GetProxmoxTokenId(), + conn.GetProxmoxSecret(), conn.GetProxmoxNode(), + conn.GetProxmoxVerifySsl(), s.logger) + } + } + + if backend != nil { + stepLabel := "Preparing base image" + mode := "cached" + if req.GetSnapshotMode() == fluidv1.SnapshotMode_SNAPSHOT_MODE_FRESH { + mode = "fresh" + stepLabel = "Pulling fresh snapshot" + } + if err := s.sendSandboxCreateProgress(stream, sandboxID, 2, stepLabel); err != nil { + return err + } + pullResult, err := s.puller.Pull(ctx, snapshotpull.PullRequest{ + SourceHost: conn.GetSshHost(), + VMName: req.GetSourceVm(), + SnapshotMode: mode, + }, backend) + if err != nil { + s.sendSandboxCreateError(stream, sandboxID, err) + return status.Errorf(codes.Internal, "pull snapshot: %v", err) + } + baseImage = pullResult.ImageName + s.logger.Info("snapshot pulled", "image", baseImage, "cached", pullResult.Cached) + } else { + if err := s.sendSandboxCreateProgress(stream, sandboxID, 2, "Using requested base image"); err != nil { + return err + } + } + + // Register for readiness signaling if supported + if rp, ok := s.prov.(sandboxCreateProgressProvider); ok { + // Use streaming provider + result, err := rp.CreateSandboxWithProgress(ctx, provider.CreateRequest{ + SandboxID: sandboxID, + Name: req.GetName(), + BaseImage: baseImage, + SourceVM: req.GetSourceVm(), + Network: req.GetNetwork(), + VCPUs: vcpus, + MemoryMB: memMB, + TTLSeconds: int(req.GetTtlSeconds()), + AgentID: req.GetAgentId(), + SSHPublicKey: req.GetSshPublicKey(), + }, func(step string, stepNum, total int) { + _ = s.sendSandboxCreateProgress(stream, sandboxID, stepNum+2, step) + }) + if err != nil { + s.logger.Error("CreateSandboxStream failed", "error", err) + s.sendSandboxCreateError(stream, sandboxID, err) + return status.Errorf(codes.Internal, "create sandbox: %v", err) + } + + // Persist to state store + now := time.Now().UTC() + sb := &state.Sandbox{ + ID: result.SandboxID, + Name: result.Name, + AgentID: req.GetAgentId(), + BaseImage: baseImage, + Bridge: result.Bridge, + MACAddress: result.MACAddress, + IPAddress: result.IPAddress, + State: result.State, + PID: result.PID, + VCPUs: vcpus, + MemoryMB: memMB, + TTLSeconds: int(req.GetTtlSeconds()), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.CreateSandbox(ctx, sb); err != nil { + s.logger.Warn("failed to persist sandbox state", "sandbox_id", result.SandboxID, "error", err) + } + + s.logAudit(audit.TypeSandboxCreated, map[string]any{ + "sandbox_id": result.SandboxID, + "source_vm": req.GetSourceVm(), + "vcpus": vcpus, + "memory_mb": memMB, + }, nil, time.Since(start).Milliseconds()) + + // Send final done message + return stream.Send(&fluidv1.SandboxProgress{ + SandboxId: sandboxID, + Done: true, + Result: &fluidv1.SandboxCreated{ + SandboxId: result.SandboxID, + Name: result.Name, + State: result.State, + IpAddress: result.IPAddress, + MacAddress: result.MACAddress, + Bridge: result.Bridge, + Pid: int32(result.PID), + }, + }) + } + + // Fallback: provider doesn't support progress, use unary + if err := s.sendSandboxCreateProgress(stream, sandboxID, 3, "Creating sandbox"); err != nil { + return err + } + result, err := s.prov.CreateSandbox(ctx, provider.CreateRequest{ + SandboxID: sandboxID, + Name: req.GetName(), + BaseImage: baseImage, + SourceVM: req.GetSourceVm(), + Network: req.GetNetwork(), + VCPUs: vcpus, + MemoryMB: memMB, + TTLSeconds: int(req.GetTtlSeconds()), + AgentID: req.GetAgentId(), + SSHPublicKey: req.GetSshPublicKey(), + }) + if err != nil { + s.logger.Error("CreateSandboxStream (unary fallback) failed", "error", err) + s.sendSandboxCreateError(stream, sandboxID, err) + return status.Errorf(codes.Internal, "create sandbox: %v", err) + } + + // Persist to state store + now := time.Now().UTC() + sb := &state.Sandbox{ + ID: result.SandboxID, + Name: result.Name, + AgentID: req.GetAgentId(), + BaseImage: baseImage, + Bridge: result.Bridge, + MACAddress: result.MACAddress, + IPAddress: result.IPAddress, + State: result.State, + PID: result.PID, + VCPUs: vcpus, + MemoryMB: memMB, + TTLSeconds: int(req.GetTtlSeconds()), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.CreateSandbox(ctx, sb); err != nil { + s.logger.Warn("failed to persist sandbox state", "sandbox_id", result.SandboxID, "error", err) + } + + s.logAudit(audit.TypeSandboxCreated, map[string]any{ + "sandbox_id": result.SandboxID, + "source_vm": req.GetSourceVm(), + "vcpus": vcpus, + "memory_mb": memMB, + }, nil, time.Since(start).Milliseconds()) + + return stream.Send(&fluidv1.SandboxProgress{ + SandboxId: sandboxID, + Done: true, + Result: &fluidv1.SandboxCreated{ + SandboxId: result.SandboxID, + Name: result.Name, + State: result.State, + IpAddress: result.IPAddress, + MacAddress: result.MACAddress, + Bridge: result.Bridge, + Pid: int32(result.PID), + }, + }) +} + func (s *Server) GetSandbox(ctx context.Context, req *fluidv1.GetSandboxRequest) (*fluidv1.SandboxInfo, error) { id := req.GetSandboxId() if id == "" { @@ -397,11 +672,47 @@ func (s *Server) ListSourceVMs(ctx context.Context, req *fluidv1.ListSourceVMsCo State: vm.State, IpAddress: vm.IPAddress, Prepared: vm.Prepared, + Host: conn.GetSshHost(), }) } return &fluidv1.SourceVMsList{Vms: entries}, nil } + // Query all configured source hosts + if len(s.cfg.SourceHosts) > 0 { + var allEntries []*fluidv1.SourceVMListEntry + var lastErr error + for _, conn := range s.sourceHostConns() { + adhoc, err := s.adhocSourceVMManager(conn) + if err != nil { + lastErr = err + continue + } + vms, err := adhoc.ListVMs(ctx) + if err != nil { + lastErr = err + continue + } + s.vmHostMu.Lock() + for _, vm := range vms { + s.vmHostCache[vm.Name] = conn + allEntries = append(allEntries, &fluidv1.SourceVMListEntry{ + Name: vm.Name, + State: vm.State, + IpAddress: vm.IPAddress, + Prepared: vm.Prepared, + Host: conn.SshHost, + }) + } + s.vmHostMu.Unlock() + } + if len(allEntries) == 0 && lastErr != nil { + return nil, status.Errorf(codes.Internal, "list source VMs: %v", lastErr) + } + return &fluidv1.SourceVMsList{Vms: allEntries}, nil + } + + // Fall back to local provider vms, err := s.prov.ListSourceVMs(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "list source VMs: %v", err) @@ -425,7 +736,16 @@ func (s *Server) ValidateSourceVM(ctx context.Context, req *fluidv1.ValidateSour return nil, status.Error(codes.InvalidArgument, "source_vm is required") } - if conn := req.GetSourceHostConnection(); conn != nil { + conn := req.GetSourceHostConnection() + if conn == nil && len(s.cfg.SourceHosts) > 0 { + resolved, err := s.resolveSourceHost(ctx, req.GetSourceVm()) + if err != nil { + return nil, status.Errorf(codes.NotFound, "resolve source host: %v", err) + } + conn = resolved + } + + if conn != nil { adhoc, err := s.adhocSourceVMManager(conn) if err != nil { return nil, status.Errorf(codes.Internal, "create provider for host: %v", err) @@ -468,7 +788,16 @@ func (s *Server) PrepareSourceVM(ctx context.Context, req *fluidv1.PrepareSource return nil, status.Error(codes.InvalidArgument, "source_vm is required") } - if conn := req.GetSourceHostConnection(); conn != nil { + conn := req.GetSourceHostConnection() + if conn == nil && len(s.cfg.SourceHosts) > 0 { + resolved, err := s.resolveSourceHost(ctx, req.GetSourceVm()) + if err != nil { + return nil, status.Errorf(codes.NotFound, "resolve source host: %v", err) + } + conn = resolved + } + + if conn != nil { adhoc, err := s.adhocSourceVMManager(conn) if err != nil { return nil, status.Errorf(codes.Internal, "create provider for host: %v", err) @@ -522,7 +851,16 @@ func (s *Server) RunSourceCommand(ctx context.Context, req *fluidv1.RunSourceCom timeout = 5 * time.Minute } - if conn := req.GetSourceHostConnection(); conn != nil { + conn := req.GetSourceHostConnection() + if conn == nil && len(s.cfg.SourceHosts) > 0 { + resolved, err := s.resolveSourceHost(ctx, req.GetSourceVm()) + if err != nil { + return nil, status.Errorf(codes.NotFound, "resolve source host: %v", err) + } + conn = resolved + } + + if conn != nil { adhoc, err := s.adhocSourceVMManager(conn) if err != nil { return nil, status.Errorf(codes.Internal, "create provider for host: %v", err) @@ -570,7 +908,16 @@ func (s *Server) ReadSourceFile(ctx context.Context, req *fluidv1.ReadSourceFile return nil, status.Error(codes.InvalidArgument, "path is required") } - if conn := req.GetSourceHostConnection(); conn != nil { + conn := req.GetSourceHostConnection() + if conn == nil && len(s.cfg.SourceHosts) > 0 { + resolved, err := s.resolveSourceHost(ctx, req.GetSourceVm()) + if err != nil { + return nil, status.Errorf(codes.NotFound, "resolve source host: %v", err) + } + conn = resolved + } + + if conn != nil { adhoc, err := s.adhocSourceVMManager(conn) if err != nil { return nil, status.Errorf(codes.Internal, "create provider for host: %v", err) @@ -615,12 +962,31 @@ func (s *Server) GetHostInfo(ctx context.Context, _ *fluidv1.GetHostInfoRequest) s.logger.Warn("failed to get capabilities", "error", err) } + var sourceHosts []*fluidv1.SourceHostInfo + for _, h := range s.cfg.SourceHosts { + port := int32(h.SSHPort) + if port == 0 { + port = 22 + } + user := h.SSHUser + if user == "" { + user = "fluid-daemon" + } + sourceHosts = append(sourceHosts, &fluidv1.SourceHostInfo{ + Address: h.Address, + SshUser: user, + SshPort: port, + }) + } + resp := &fluidv1.HostInfoResponse{ - HostId: s.hostID, - Hostname: hostname, - Version: s.version, - ActiveSandboxes: int32(s.prov.ActiveSandboxCount()), - SshCaPubKey: s.caPubKey, + HostId: s.hostID, + Hostname: hostname, + Version: s.version, + ActiveSandboxes: int32(s.prov.ActiveSandboxCount()), + SshCaPubKey: s.caPubKey, + SshIdentityPubKey: s.identityPubKey, + SourceHosts: sourceHosts, } if caps != nil { @@ -669,6 +1035,93 @@ func (s *Server) DiscoverHosts(ctx context.Context, req *fluidv1.DiscoverHostsCo return &fluidv1.DiscoverHostsResult{Hosts: discovered}, nil } +func (s *Server) ScanSourceHostKeys(ctx context.Context, _ *fluidv1.ScanSourceHostKeysRequest) (*fluidv1.ScanSourceHostKeysResponse, error) { + if len(s.cfg.SourceHosts) == 0 { + return &fluidv1.ScanSourceHostKeysResponse{}, nil + } + + // Resolve fluid-daemon home directory + homeDir := "/home/fluid-daemon" + if u, err := user.Lookup("fluid-daemon"); err == nil { + homeDir = u.HomeDir + } + sshDir := filepath.Join(homeDir, ".ssh") + knownHostsPath := filepath.Join(sshDir, "known_hosts") + + if err := os.MkdirAll(sshDir, 0o700); err != nil { + s.logger.Warn("failed to create .ssh dir for fluid-daemon", "path", sshDir, "error", err) + } + + // Read existing known_hosts to skip duplicates + existing, _ := os.ReadFile(knownHostsPath) + existingStr := string(existing) + + var results []*fluidv1.ScanSourceHostKeysResult + for _, h := range s.cfg.SourceHosts { + addr := h.Address + scanCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + out, err := exec.CommandContext(scanCtx, "ssh-keyscan", "-H", addr).Output() + cancel() + + if err != nil { + s.logger.Warn("ssh-keyscan failed", "address", addr, "error", err) + results = append(results, &fluidv1.ScanSourceHostKeysResult{ + Address: addr, + Success: false, + Error: fmt.Sprintf("ssh-keyscan: %v", err), + }) + continue + } + + // Append new keys (ssh-keyscan -H hashes the hostname so exact dedup + // isn't practical; just skip if we already have content from this scan) + newKeys := strings.TrimSpace(string(out)) + if newKeys == "" { + results = append(results, &fluidv1.ScanSourceHostKeysResult{ + Address: addr, + Success: false, + Error: "ssh-keyscan returned no keys", + }) + continue + } + + // Append to known_hosts + toAppend := newKeys + "\n" + if len(existingStr) > 0 && !strings.HasSuffix(existingStr, "\n") { + toAppend = "\n" + toAppend + } + + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + results = append(results, &fluidv1.ScanSourceHostKeysResult{ + Address: addr, + Success: false, + Error: fmt.Sprintf("open known_hosts: %v", err), + }) + continue + } + _, writeErr := f.WriteString(toAppend) + f.Close() + if writeErr != nil { + results = append(results, &fluidv1.ScanSourceHostKeysResult{ + Address: addr, + Success: false, + Error: fmt.Sprintf("write known_hosts: %v", writeErr), + }) + continue + } + existingStr += toAppend + + s.logger.Info("scanned host keys", "address", addr) + results = append(results, &fluidv1.ScanSourceHostKeysResult{ + Address: addr, + Success: true, + }) + } + + return &fluidv1.ScanSourceHostKeysResponse{Results: results}, nil +} + // logAudit records an operation to the audit log with redaction. func (s *Server) logAudit(opType string, meta map[string]any, err error, durationMs int64) { if s.auditLog == nil { diff --git a/fluid-daemon/internal/daemon/server_create_stream_test.go b/fluid-daemon/internal/daemon/server_create_stream_test.go new file mode 100644 index 00000000..cb6dc5ac --- /dev/null +++ b/fluid-daemon/internal/daemon/server_create_stream_test.go @@ -0,0 +1,286 @@ +package daemon + +import ( + "context" + "errors" + "io" + "log/slog" + "path/filepath" + "testing" + "time" + + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/config" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/provider" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/snapshotpull" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/state" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/telemetry" + fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" + "google.golang.org/grpc/metadata" +) + +type fakeCreateSandboxProvider struct { + createWithProgressFn func(context.Context, provider.CreateRequest, func(string, int, int)) (*provider.SandboxResult, error) +} + +func (f *fakeCreateSandboxProvider) CreateSandbox(_ context.Context, _ provider.CreateRequest) (*provider.SandboxResult, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) DestroySandbox(context.Context, string) error { + return nil +} + +func (f *fakeCreateSandboxProvider) StartSandbox(context.Context, string) (*provider.SandboxResult, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) StopSandbox(context.Context, string, bool) error { + return nil +} + +func (f *fakeCreateSandboxProvider) GetSandboxIP(context.Context, string) (string, error) { + return "", errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) CreateSnapshot(context.Context, string, string) (*provider.SnapshotResult, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) RunCommand(context.Context, string, string, time.Duration) (*provider.CommandResult, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) ListTemplates(context.Context) ([]string, error) { + return nil, nil +} + +func (f *fakeCreateSandboxProvider) ListSourceVMs(context.Context) ([]provider.SourceVMInfo, error) { + return nil, nil +} + +func (f *fakeCreateSandboxProvider) ValidateSourceVM(context.Context, string) (*provider.ValidationResult, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) PrepareSourceVM(context.Context, string, string, string) (*provider.PrepareResult, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) RunSourceCommand(context.Context, string, string, time.Duration) (*provider.CommandResult, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) ReadSourceFile(context.Context, string, string) (string, error) { + return "", errors.New("not implemented") +} + +func (f *fakeCreateSandboxProvider) Capabilities(context.Context) (*provider.HostCapabilities, error) { + return &provider.HostCapabilities{}, nil +} + +func (f *fakeCreateSandboxProvider) ActiveSandboxCount() int { + return 0 +} + +func (f *fakeCreateSandboxProvider) RecoverState(context.Context) error { + return nil +} + +func (f *fakeCreateSandboxProvider) CreateSandboxWithProgress(ctx context.Context, req provider.CreateRequest, progress func(string, int, int)) (*provider.SandboxResult, error) { + if f.createWithProgressFn != nil { + return f.createWithProgressFn(ctx, req, progress) + } + return nil, errors.New("not implemented") +} + +type fakeCreateSandboxPuller struct { + result *snapshotpull.PullResult + err error +} + +func (f *fakeCreateSandboxPuller) Pull(context.Context, snapshotpull.PullRequest, snapshotpull.SnapshotBackend) (*snapshotpull.PullResult, error) { + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +type fakeCreateSandboxStream struct { + ctx context.Context + msgs []*fluidv1.SandboxProgress +} + +func (f *fakeCreateSandboxStream) Send(msg *fluidv1.SandboxProgress) error { + f.msgs = append(f.msgs, msg) + return nil +} + +func (f *fakeCreateSandboxStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeCreateSandboxStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeCreateSandboxStream) SetTrailer(metadata.MD) {} +func (f *fakeCreateSandboxStream) Context() context.Context { + if f.ctx != nil { + return f.ctx + } + return context.Background() +} +func (f *fakeCreateSandboxStream) SendMsg(any) error { return nil } +func (f *fakeCreateSandboxStream) RecvMsg(any) error { return nil } + +func newTestCreateSandboxServer(t *testing.T, prov provider.SandboxProvider, puller sandboxCreatePuller, cfg *config.Config) *Server { + t.Helper() + + if cfg == nil { + cfg = &config.Config{} + } + store, err := state.NewStore(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return &Server{ + cfg: cfg, + prov: prov, + store: store, + puller: puller, + telemetry: telemetry.NewNoopService(), + logger: logger, + vmHostCache: make(map[string]*fluidv1.SourceHostConnection), + } +} + +func TestCreateSandboxStream_EmitsEndToEndProgress(t *testing.T) { + prov := &fakeCreateSandboxProvider{ + createWithProgressFn: func(_ context.Context, req provider.CreateRequest, progress func(string, int, int)) (*provider.SandboxResult, error) { + if req.BaseImage != "snap-host1-vm-1" { + t.Fatalf("BaseImage = %q, want pulled image", req.BaseImage) + } + steps := []string{ + "Resolving network bridge", + "Creating overlay disk", + "Generating cloud-init", + "Setting up network (TAP)", + "Booting microVM", + "Discovering IP address", + "Waiting for cloud-init ready", + } + for i, step := range steps { + progress(step, i+1, len(steps)) + } + return &provider.SandboxResult{ + SandboxID: "sbx-test", + Name: "sandbox", + State: "RUNNING", + IPAddress: "10.0.0.2", + MACAddress: "52:54:00:12:34:56", + Bridge: "br0", + PID: 1234, + }, nil + }, + } + puller := &fakeCreateSandboxPuller{ + result: &snapshotpull.PullResult{ImageName: "snap-host1-vm-1", Cached: false}, + } + server := newTestCreateSandboxServer(t, prov, puller, &config.Config{}) + stream := &fakeCreateSandboxStream{} + + err := server.CreateSandboxStream(&fluidv1.CreateSandboxCommand{ + SourceVm: "vm-1", + SourceHostConnection: &fluidv1.SourceHostConnection{ + Type: "libvirt", + SshHost: "host1", + SshPort: 22, + SshUser: "fluid-daemon", + }, + SnapshotMode: fluidv1.SnapshotMode_SNAPSHOT_MODE_FRESH, + }, stream) + if err != nil { + t.Fatalf("CreateSandboxStream: %v", err) + } + + if len(stream.msgs) != 10 { + t.Fatalf("message count = %d, want 10", len(stream.msgs)) + } + + expectedSteps := []string{ + "Using provided source host", + "Pulling fresh snapshot", + "Resolving network bridge", + "Creating overlay disk", + "Generating cloud-init", + "Setting up network (TAP)", + "Booting microVM", + "Discovering IP address", + "Waiting for cloud-init ready", + } + for i, expected := range expectedSteps { + msg := stream.msgs[i] + if msg.GetStepNum() != int32(i+1) { + t.Fatalf("step %d number = %d, want %d", i, msg.GetStepNum(), i+1) + } + if msg.GetTotalSteps() != createSandboxStreamTotalSteps { + t.Fatalf("step %d total = %d, want %d", i, msg.GetTotalSteps(), createSandboxStreamTotalSteps) + } + if msg.GetStep() != expected { + t.Fatalf("step %d label = %q, want %q", i+1, msg.GetStep(), expected) + } + } + + final := stream.msgs[len(stream.msgs)-1] + if !final.GetDone() { + t.Fatal("expected final message to mark stream done") + } + if final.GetResult().GetSandboxId() != "sbx-test" { + t.Fatalf("final sandbox id = %q, want %q", final.GetResult().GetSandboxId(), "sbx-test") + } +} + +func TestCreateSandboxStream_NoPullPathEmitsNoOpSteps(t *testing.T) { + prov := &fakeCreateSandboxProvider{ + createWithProgressFn: func(_ context.Context, _ provider.CreateRequest, progress func(string, int, int)) (*provider.SandboxResult, error) { + steps := []string{ + "Resolving network bridge", + "Creating overlay disk", + "Generating cloud-init", + "Setting up network (TAP)", + "Booting microVM", + "Discovering IP address", + "Waiting for cloud-init ready", + } + for i, step := range steps { + progress(step, i+1, len(steps)) + } + return &provider.SandboxResult{ + SandboxID: "sbx-direct", + Name: "sandbox", + State: "RUNNING", + }, nil + }, + } + server := newTestCreateSandboxServer(t, prov, nil, &config.Config{}) + stream := &fakeCreateSandboxStream{} + + err := server.CreateSandboxStream(&fluidv1.CreateSandboxCommand{ + BaseImage: "ubuntu-base", + }, stream) + if err != nil { + t.Fatalf("CreateSandboxStream: %v", err) + } + + if len(stream.msgs) < 3 { + t.Fatalf("message count = %d, want at least 3", len(stream.msgs)) + } + if stream.msgs[0].GetStep() != "No source host resolution needed" { + t.Fatalf("step 1 label = %q, want %q", stream.msgs[0].GetStep(), "No source host resolution needed") + } + if stream.msgs[1].GetStep() != "Using requested base image" { + t.Fatalf("step 2 label = %q, want %q", stream.msgs[1].GetStep(), "Using requested base image") + } + if !stream.msgs[len(stream.msgs)-1].GetDone() { + t.Fatal("expected final done message") + } +} diff --git a/fluid-daemon/internal/image/extract.go b/fluid-daemon/internal/image/extract.go index 7b4513ef..3a4ef094 100644 --- a/fluid-daemon/internal/image/extract.go +++ b/fluid-daemon/internal/image/extract.go @@ -1,209 +1,18 @@ +// Package image manages base QCOW2 images. +// +// Kernel extraction has been replaced by a configurable kernel path +// (microvm.kernel_path in daemon config). The functions below are +// retained but disabled so the build stays clean. package image import ( "context" "fmt" - "os" - "os/exec" - "path/filepath" - "strings" ) -// ExtractKernel extracts a vmlinux kernel from a QCOW2 base image. -// The kernel is mounted via NBD, copied out, and saved alongside the image. -// -// Strategy: -// 1. Mount the QCOW2 via qemu-nbd -// 2. Mount the root partition -// 3. Copy /boot/vmlinuz-* or /vmlinuz -// 4. Decompress if needed (extract-vmlinux) -// 5. Save as .vmlinux -func ExtractKernel(ctx context.Context, imagePath string) (string, error) { - baseName := strings.TrimSuffix(filepath.Base(imagePath), ".qcow2") - outputPath := filepath.Join(filepath.Dir(imagePath), baseName+".vmlinux") - - if fileExists(outputPath) { - return outputPath, nil - } - - // Check for virt-ls/virt-cat (libguestfs) - easier approach - if _, err := exec.LookPath("virt-cat"); err == nil { - return extractKernelGuestfs(ctx, imagePath, outputPath) - } - - // Fallback to manual NBD mount - return extractKernelNBD(ctx, imagePath, outputPath) -} - -// extractKernelGuestfs uses libguestfs tools to extract the kernel. -func extractKernelGuestfs(ctx context.Context, imagePath, outputPath string) (string, error) { - // List /boot to find kernel - cmd := exec.CommandContext(ctx, "virt-ls", "-a", imagePath, "/boot/") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("virt-ls /boot/: %w", err) - } - - // Find vmlinuz file - var kernelFile string - for _, line := range strings.Split(string(output), "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "vmlinuz") { - kernelFile = "/boot/" + line - break - } - } - - if kernelFile == "" { - return "", fmt.Errorf("no vmlinuz found in /boot/ of %s", imagePath) - } - - // Extract kernel - kernelCompressed := outputPath + ".compressed" - cmd = exec.CommandContext(ctx, "virt-cat", "-a", imagePath, kernelFile) - kernelData, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("virt-cat %s: %w", kernelFile, err) - } - - if err := os.WriteFile(kernelCompressed, kernelData, 0o644); err != nil { - return "", fmt.Errorf("write compressed kernel: %w", err) - } - defer func() { _ = os.Remove(kernelCompressed) }() - - // Try to decompress (extract-vmlinux script or direct use) - if err := decompressKernel(ctx, kernelCompressed, outputPath); err != nil { - // If decompression fails, try using the compressed kernel directly - // (microvm may support compressed kernels) - if err := os.Rename(kernelCompressed, outputPath); err != nil { - return "", fmt.Errorf("rename kernel: %w", err) - } - } - - return outputPath, nil -} - -// extractKernelNBD uses qemu-nbd to mount and extract the kernel. -func extractKernelNBD(ctx context.Context, imagePath, outputPath string) (string, error) { - // This requires root and available NBD device - nbdDev := "/dev/nbd0" - - // Load nbd module - _ = exec.CommandContext(ctx, "modprobe", "nbd", "max_part=8").Run() - - // Connect image - cmd := exec.CommandContext(ctx, "qemu-nbd", "--connect="+nbdDev, imagePath) - if output, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("qemu-nbd connect: %w: %s", err, string(output)) - } - defer func() { - _ = exec.CommandContext(ctx, "qemu-nbd", "--disconnect", nbdDev).Run() - }() - - // Mount root partition - mountDir, err := os.MkdirTemp("", "fluid-extract-") - if err != nil { - return "", fmt.Errorf("create mount dir: %w", err) - } - defer func() { _ = os.RemoveAll(mountDir) }() - - // Try partition 1 first, then the device itself - mounted := false - for _, dev := range []string{nbdDev + "p1", nbdDev} { - cmd = exec.CommandContext(ctx, "mount", "-o", "ro", dev, mountDir) - if err := cmd.Run(); err == nil { - mounted = true - defer func() { - _ = exec.CommandContext(ctx, "umount", mountDir).Run() - }() - break - } - } - - if !mounted { - return "", fmt.Errorf("could not mount any partition from %s", imagePath) - } - - // Find kernel - bootDir := filepath.Join(mountDir, "boot") - entries, err := os.ReadDir(bootDir) - if err != nil { - return "", fmt.Errorf("read /boot: %w", err) - } - - var kernelPath string - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), "vmlinuz") { - kernelPath = filepath.Join(bootDir, entry.Name()) - break - } - } - - if kernelPath == "" { - return "", fmt.Errorf("no vmlinuz found in /boot/ of %s", imagePath) - } - - // Copy and decompress - kernelData, err := os.ReadFile(kernelPath) - if err != nil { - return "", fmt.Errorf("read kernel: %w", err) - } - - kernelCompressed := outputPath + ".compressed" - if err := os.WriteFile(kernelCompressed, kernelData, 0o644); err != nil { - return "", fmt.Errorf("write compressed kernel: %w", err) - } - defer func() { _ = os.Remove(kernelCompressed) }() - - if err := decompressKernel(ctx, kernelCompressed, outputPath); err != nil { - if err := os.Rename(kernelCompressed, outputPath); err != nil { - return "", fmt.Errorf("rename kernel: %w", err) - } - } - - return outputPath, nil -} - -// decompressKernel attempts to decompress a compressed kernel using extract-vmlinux. -func decompressKernel(ctx context.Context, inputPath, outputPath string) error { - // Try extract-vmlinux script (ships with linux kernel source) - extractScript, err := exec.LookPath("extract-vmlinux") - if err != nil { - // Try common locations - for _, path := range []string{ - "/usr/src/linux-headers-*/scripts/extract-vmlinux", - "/usr/lib/linux-tools-*/extract-vmlinux", - } { - matches, _ := filepath.Glob(path) - if len(matches) > 0 { - extractScript = matches[0] - break - } - } - } - - if extractScript != "" { - cmd := exec.CommandContext(ctx, extractScript, inputPath) - output, err := cmd.Output() - if err == nil && len(output) > 0 { - return os.WriteFile(outputPath, output, 0o644) - } - } - - // Manual decompression attempts - // Try gzip - cmd := exec.CommandContext(ctx, "zcat", inputPath) - output, err := cmd.Output() - if err == nil && len(output) > 0 { - return os.WriteFile(outputPath, output, 0o644) - } - - // Try xz - cmd = exec.CommandContext(ctx, "xzcat", inputPath) - output, err = cmd.Output() - if err == nil && len(output) > 0 { - return os.WriteFile(outputPath, output, 0o644) - } - - return fmt.Errorf("could not decompress kernel (tried extract-vmlinux, gzip, xz)") +// ExtractKernel previously extracted a vmlinux kernel from a QCOW2 image. +// Kernel extraction is no longer used - the daemon now references a +// pre-downloaded kernel via the microvm.kernel_path config field. +func ExtractKernel(_ context.Context, _ string) (string, error) { + return "", fmt.Errorf("kernel extraction is disabled: use microvm.kernel_path config instead") } diff --git a/fluid-daemon/internal/image/extract_test.go b/fluid-daemon/internal/image/extract_test.go index 3a2dcaba..daa89b8f 100644 --- a/fluid-daemon/internal/image/extract_test.go +++ b/fluid-daemon/internal/image/extract_test.go @@ -1,157 +1,13 @@ package image import ( - "compress/gzip" "context" - "os" - "os/exec" - "path/filepath" - "runtime" "testing" ) -func TestExtractKernel_AlreadyExists(t *testing.T) { - dir := t.TempDir() - imagePath := filepath.Join(dir, "test.qcow2") - vmlinuxPath := filepath.Join(dir, "test.vmlinux") - - // Create dummy image and pre-existing kernel - if err := os.WriteFile(imagePath, []byte("fake-qcow2"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(vmlinuxPath, []byte("fake-kernel"), 0o644); err != nil { - t.Fatal(err) - } - - got, err := ExtractKernel(context.Background(), imagePath) - if err != nil { - t.Fatalf("ExtractKernel returned error: %v", err) - } - if got != vmlinuxPath { - t.Fatalf("expected %s, got %s", vmlinuxPath, got) - } - - // Verify content unchanged (no tool was invoked) - data, err := os.ReadFile(got) - if err != nil { - t.Fatal(err) - } - if string(data) != "fake-kernel" { - t.Fatalf("kernel content changed unexpectedly: %s", string(data)) - } -} - -func TestExtractKernel_Guestfs(t *testing.T) { - if _, err := exec.LookPath("virt-cat"); err != nil { - t.Skip("virt-cat not in PATH, skipping guestfs test") - } - if _, err := exec.LookPath("virt-customize"); err != nil { - t.Skip("virt-customize not in PATH, skipping guestfs test") - } - if _, err := exec.LookPath("qemu-img"); err != nil { - t.Skip("qemu-img not in PATH, skipping guestfs test") - } - - dir := t.TempDir() - imagePath := filepath.Join(dir, "test.qcow2") - - // Create a minimal qcow2 image with a filesystem - cmd := exec.Command("qemu-img", "create", "-f", "qcow2", imagePath, "256M") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("qemu-img create failed: %v: %s", err, out) - } - - // Use virt-customize to inject a dummy kernel - dummyKernel := filepath.Join(dir, "vmlinuz-test") - if err := os.WriteFile(dummyKernel, []byte("dummy-kernel-content"), 0o644); err != nil { - t.Fatal(err) - } - - cmd = exec.Command("virt-customize", - "-a", imagePath, - "--mkdir", "/boot", - "--upload", dummyKernel+":/boot/vmlinuz-test", - ) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("virt-customize failed: %v: %s", err, out) - } - - got, err := ExtractKernel(context.Background(), imagePath) - if err != nil { - t.Fatalf("ExtractKernel returned error: %v", err) - } - - info, err := os.Stat(got) - if err != nil { - t.Fatalf("output file not found: %v", err) - } - if info.Size() == 0 { - t.Fatal("output kernel file is empty") - } -} - -func TestExtractKernel_NoKernelInImage(t *testing.T) { - if _, err := exec.LookPath("virt-cat"); err != nil { - t.Skip("virt-cat not in PATH, skipping guestfs test") - } - if _, err := exec.LookPath("qemu-img"); err != nil { - t.Skip("qemu-img not in PATH, skipping test") - } - - dir := t.TempDir() - imagePath := filepath.Join(dir, "empty.qcow2") - - // Create an empty qcow2 (no filesystem, so virt-ls will fail) - cmd := exec.Command("qemu-img", "create", "-f", "qcow2", imagePath, "64M") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("qemu-img create failed: %v: %s", err, out) - } - - _, err := ExtractKernel(context.Background(), imagePath) +func TestExtractKernel_Disabled(t *testing.T) { + _, err := ExtractKernel(context.Background(), "/some/path.qcow2") if err == nil { - t.Fatal("expected error for image with no kernel, got nil") - } -} - -func TestDecompressKernel_Gzip(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("decompressKernel requires Linux zcat, skipping on " + runtime.GOOS) - } - if _, err := exec.LookPath("zcat"); err != nil { - t.Skip("zcat not in PATH, skipping decompression test") - } - - dir := t.TempDir() - inputPath := filepath.Join(dir, "kernel.gz") - outputPath := filepath.Join(dir, "kernel.vmlinux") - - // Create a gzip-compressed file - original := []byte("this-is-a-fake-decompressed-kernel-payload") - f, err := os.Create(inputPath) - if err != nil { - t.Fatal(err) - } - gw := gzip.NewWriter(f) - if _, err := gw.Write(original); err != nil { - t.Fatal(err) - } - if err := gw.Close(); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - err = decompressKernel(context.Background(), inputPath, outputPath) - if err != nil { - t.Fatalf("decompressKernel returned error: %v", err) - } - - data, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("could not read output: %v", err) - } - if string(data) != string(original) { - t.Fatalf("decompressed content mismatch: got %q, want %q", string(data), string(original)) + t.Fatal("expected error from disabled ExtractKernel, got nil") } } diff --git a/fluid-daemon/internal/image/store.go b/fluid-daemon/internal/image/store.go index 76e1992d..46ba30dc 100644 --- a/fluid-daemon/internal/image/store.go +++ b/fluid-daemon/internal/image/store.go @@ -17,10 +17,9 @@ type Store struct { // ImageInfo describes a base image. type ImageInfo struct { - Name string // filename without extension - Path string // full path to QCOW2 file - SizeMB int64 // file size in MB - HasKernel bool // whether a kernel has been extracted + Name string // filename without extension + Path string // full path to QCOW2 file + SizeMB int64 // file size in MB } // NewStore creates an image store for the given base directory. @@ -60,15 +59,10 @@ func (s *Store) List() ([]ImageInfo, error) { name := strings.TrimSuffix(entry.Name(), ".qcow2") fullPath := filepath.Join(s.baseDir, entry.Name()) - // Check for extracted kernel - kernelPath := filepath.Join(s.baseDir, name+".vmlinux") - hasKernel := fileExists(kernelPath) - images = append(images, ImageInfo{ - Name: name, - Path: fullPath, - SizeMB: info.Size() / (1024 * 1024), - HasKernel: hasKernel, + Name: name, + Path: fullPath, + SizeMB: info.Size() / (1024 * 1024), }) } @@ -102,27 +96,12 @@ func (s *Store) GetImagePath(name string) (string, error) { return path, nil } -// GetKernelPath returns the path to the extracted kernel for a base image. -func (s *Store) GetKernelPath(name string) (string, error) { - path := filepath.Join(s.baseDir, name+".vmlinux") - if !fileExists(path) { - return "", fmt.Errorf("kernel for %q not found (run kernel extraction first)", name) - } - return path, nil -} - // HasImage checks if a base image exists. func (s *Store) HasImage(name string) bool { _, err := s.GetImagePath(name) return err == nil } -// HasKernel checks if an extracted kernel exists for a base image. -func (s *Store) HasKernel(name string) bool { - _, err := s.GetKernelPath(name) - return err == nil -} - // BaseDir returns the base image directory. func (s *Store) BaseDir() string { return s.baseDir diff --git a/fluid-daemon/internal/image/store_test.go b/fluid-daemon/internal/image/store_test.go index bf22c09b..bfc8db51 100644 --- a/fluid-daemon/internal/image/store_test.go +++ b/fluid-daemon/internal/image/store_test.go @@ -84,9 +84,6 @@ func TestList_WithImages(t *testing.T) { if !ok { t.Fatal("expected image named 'ubuntu'") } - if !ubuntu.HasKernel { - t.Error("expected ubuntu to have kernel") - } if ubuntu.SizeMB != 2 { t.Errorf("expected ubuntu SizeMB=2, got %d", ubuntu.SizeMB) } @@ -99,9 +96,6 @@ func TestList_WithImages(t *testing.T) { if !ok { t.Fatal("expected image named 'debian'") } - if debian.HasKernel { - t.Error("expected debian to NOT have kernel") - } if debian.SizeMB != 1 { t.Errorf("expected debian SizeMB=1, got %d", debian.SizeMB) } @@ -171,34 +165,6 @@ func TestGetImagePath_Missing(t *testing.T) { } } -func TestGetKernelPath(t *testing.T) { - base := t.TempDir() - - createFile(t, filepath.Join(base, "myimage.qcow2"), 100) - createFile(t, filepath.Join(base, "myimage.vmlinux"), 100) - - s, err := NewStore(base, slog.Default()) - if err != nil { - t.Fatalf("NewStore failed: %v", err) - } - - path, err := s.GetKernelPath("myimage") - if err != nil { - t.Fatalf("GetKernelPath failed: %v", err) - } - - expected := filepath.Join(base, "myimage.vmlinux") - if path != expected { - t.Errorf("expected %s, got %s", expected, path) - } - - // Missing kernel should error. - _, err = s.GetKernelPath("nope") - if err == nil { - t.Fatal("expected error for missing kernel, got nil") - } -} - func TestHasImage(t *testing.T) { base := t.TempDir() diff --git a/fluid-daemon/internal/microvm/cloudinit.go b/fluid-daemon/internal/microvm/cloudinit.go new file mode 100644 index 00000000..dd77ec6b --- /dev/null +++ b/fluid-daemon/internal/microvm/cloudinit.go @@ -0,0 +1,138 @@ +package microvm + +import ( + "fmt" + "os" + "path/filepath" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/diskfs/go-diskfs/disk" + "github.com/diskfs/go-diskfs/filesystem" + "github.com/diskfs/go-diskfs/filesystem/iso9660" +) + +const networkConfig = `network: + version: 2 + ethernets: + all-en: + match: + name: "e*" + dhcp4: true +` + +// generateUserData builds cloud-init user-data YAML with the CA public key +// embedded so the sandbox VM trusts cert-based SSH auth. +// If phoneHomeURL is non-empty, a phone_home module is appended so the +// sandbox signals readiness after runcmd completes. +func generateUserData(caPubKey, phoneHomeURL string) string { + phoneHome := "" + if phoneHomeURL != "" { + phoneHome = fmt.Sprintf(` +phone_home: + url: %s + post: [instance_id] + tries: 3 +`, phoneHomeURL) + } + + return fmt.Sprintf(`#cloud-config +users: + - default + - name: sandbox + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + lock_passwd: true + +write_files: + - path: /etc/ssh/authorized_principals/sandbox + content: | + sandbox + owner: root:root + permissions: '0644' + - path: /etc/ssh/fluid_ca.pub + content: | + %s + owner: root:root + permissions: '0644' + +runcmd: + - grep -q 'TrustedUserCAKeys /etc/ssh/fluid_ca.pub' /etc/ssh/sshd_config || echo 'TrustedUserCAKeys /etc/ssh/fluid_ca.pub' >> /etc/ssh/sshd_config + - grep -q 'AuthorizedPrincipalsFile /etc/ssh/authorized_principals/%%u' /etc/ssh/sshd_config || echo 'AuthorizedPrincipalsFile /etc/ssh/authorized_principals/%%u' >> /etc/ssh/sshd_config + - systemctl restart sshd 2>/dev/null || systemctl restart ssh 2>/dev/null || service sshd restart 2>/dev/null || service ssh restart +%s`, caPubKey, phoneHome) +} + +// GenerateCloudInitISO creates a NoCloud cloud-init ISO containing meta-data, +// network-config, and user-data with the CA public key for SSH cert auth. +// The ISO is written to //cidata.iso and is cleaned up +// automatically by RemoveOverlay. +// +// A unique instance-id per sandbox forces cloud-init to re-run on cloned disks. +// The network-config uses a catch-all match so DHCP works regardless of the +// interface name assigned by the microvm machine type. +// +// If bridgeIP is non-empty, a cloud-init phone_home module is added that POSTs +// to the daemon readiness endpoint when runcmd completes. +func GenerateCloudInitISO(workDir, sandboxID, caPubKey, bridgeIP string) (string, error) { + dir := filepath.Join(workDir, sandboxID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create sandbox dir: %w", err) + } + + isoPath := filepath.Join(dir, "cidata.iso") + + // 2 MB is more than enough for cloud-init metadata + const isoSize int64 = 2 * 1024 * 1024 + + // ISO 9660 requires 2048-byte logical sectors. + d, err := diskfs.Create(isoPath, isoSize, diskfs.SectorSize(2048)) + if err != nil { + return "", fmt.Errorf("create disk image: %w", err) + } + + fspec := disk.FilesystemSpec{ + Partition: 0, + FSType: filesystem.TypeISO9660, + VolumeLabel: "cidata", + } + fs, err := d.CreateFilesystem(fspec) + if err != nil { + return "", fmt.Errorf("create filesystem: %w", err) + } + + metaData := fmt.Sprintf("instance-id: %s\n", sandboxID) + + phoneHomeURL := "" + if bridgeIP != "" { + phoneHomeURL = fmt.Sprintf("http://%s:9092/ready/%s", bridgeIP, sandboxID) + } + + files := map[string]string{ + "/meta-data": metaData, + "/network-config": networkConfig, + "/user-data": generateUserData(caPubKey, phoneHomeURL), + } + + for name, content := range files { + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY) + if err != nil { + return "", fmt.Errorf("open %s: %w", name, err) + } + if _, err := f.Write([]byte(content)); err != nil { + return "", fmt.Errorf("write %s: %w", name, err) + } + } + + iso, ok := fs.(*iso9660.FileSystem) + if !ok { + return "", fmt.Errorf("unexpected filesystem type") + } + if err := iso.Finalize(iso9660.FinalizeOptions{ + RockRidge: true, + VolumeIdentifier: "cidata", + }); err != nil { + return "", fmt.Errorf("finalize ISO: %w", err) + } + + return isoPath, nil +} diff --git a/fluid-daemon/internal/microvm/cloudinit_test.go b/fluid-daemon/internal/microvm/cloudinit_test.go new file mode 100644 index 00000000..e7d8cb0a --- /dev/null +++ b/fluid-daemon/internal/microvm/cloudinit_test.go @@ -0,0 +1,148 @@ +package microvm + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/diskfs/go-diskfs/filesystem/iso9660" +) + +const testCAPubKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestCAKeyForUnitTests fluid-ca@test" + +func TestGenerateCloudInitISO(t *testing.T) { + workDir := t.TempDir() + sandboxID := "SBX-test-1234" + + isoPath, err := GenerateCloudInitISO(workDir, sandboxID, testCAPubKey, "") + if err != nil { + t.Fatalf("GenerateCloudInitISO: %v", err) + } + + // Verify file exists with nonzero size + info, err := os.Stat(isoPath) + if err != nil { + t.Fatalf("stat ISO: %v", err) + } + if info.Size() == 0 { + t.Fatal("ISO file is empty") + } + + // Verify path is under the sandbox directory + expected := filepath.Join(workDir, sandboxID, "cidata.iso") + if isoPath != expected { + t.Errorf("path = %q, want %q", isoPath, expected) + } + + // Open the ISO and verify contents + d, err := diskfs.Open(isoPath) + if err != nil { + t.Fatalf("open ISO: %v", err) + } + + fs, err := d.GetFilesystem(0) + if err != nil { + t.Fatalf("get filesystem: %v", err) + } + + isoFS, ok := fs.(*iso9660.FileSystem) + if !ok { + t.Fatal("filesystem is not ISO 9660") + } + + // Read meta-data + metaFile, err := isoFS.OpenFile("/meta-data", os.O_RDONLY) + if err != nil { + t.Fatalf("open meta-data: %v", err) + } + metaBytes, err := io.ReadAll(metaFile) + if err != nil { + t.Fatalf("read meta-data: %v", err) + } + metaContent := string(metaBytes) + + if !strings.Contains(metaContent, "instance-id: "+sandboxID) { + t.Errorf("meta-data missing instance-id, got: %q", metaContent) + } + + // Read network-config + netFile, err := isoFS.OpenFile("/network-config", os.O_RDONLY) + if err != nil { + t.Fatalf("open network-config: %v", err) + } + netBytes, err := io.ReadAll(netFile) + if err != nil { + t.Fatalf("read network-config: %v", err) + } + netContent := string(netBytes) + + if !strings.Contains(netContent, "dhcp4: true") { + t.Errorf("network-config missing dhcp4, got: %q", netContent) + } + if !strings.Contains(netContent, `name: "e*"`) { + t.Errorf("network-config missing match pattern, got: %q", netContent) + } + + // Read user-data + userFile, err := isoFS.OpenFile("/user-data", os.O_RDONLY) + if err != nil { + t.Fatalf("open user-data: %v", err) + } + userBytes, err := io.ReadAll(userFile) + if err != nil { + t.Fatalf("read user-data: %v", err) + } + userContent := string(userBytes) + + if !strings.Contains(userContent, "name: sandbox") { + t.Errorf("user-data missing sandbox user, got: %q", userContent) + } + if !strings.Contains(userContent, "authorized_principals/sandbox") { + t.Errorf("user-data missing authorized_principals, got: %q", userContent) + } + if !strings.Contains(userContent, "fluid_ca.pub") { + t.Errorf("user-data missing fluid_ca.pub, got: %q", userContent) + } + if !strings.Contains(userContent, testCAPubKey) { + t.Errorf("user-data missing CA public key, got: %q", userContent) + } + if !strings.Contains(userContent, "TrustedUserCAKeys /etc/ssh/fluid_ca.pub") { + t.Errorf("user-data missing TrustedUserCAKeys, got: %q", userContent) + } + if !strings.Contains(userContent, "AuthorizedPrincipalsFile /etc/ssh/authorized_principals/%u") { + t.Errorf("user-data missing AuthorizedPrincipalsFile, got: %q", userContent) + } +} + +func TestGenerateCloudInitISO_DifferentSandboxIDs(t *testing.T) { + workDir := t.TempDir() + + path1, err := GenerateCloudInitISO(workDir, "SBX-aaa", testCAPubKey, "") + if err != nil { + t.Fatalf("first ISO: %v", err) + } + path2, err := GenerateCloudInitISO(workDir, "SBX-bbb", testCAPubKey, "") + if err != nil { + t.Fatalf("second ISO: %v", err) + } + + if path1 == path2 { + t.Error("different sandbox IDs produced same ISO path") + } + + data1, err := os.ReadFile(path1) + if err != nil { + t.Fatalf("read first ISO: %v", err) + } + data2, err := os.ReadFile(path2) + if err != nil { + t.Fatalf("read second ISO: %v", err) + } + + if string(data1) == string(data2) { + t.Error("different sandbox IDs produced identical ISO content") + } +} diff --git a/fluid-daemon/internal/microvm/manager.go b/fluid-daemon/internal/microvm/manager.go index c9175f0c..74c279b3 100644 --- a/fluid-daemon/internal/microvm/manager.go +++ b/fluid-daemon/internal/microvm/manager.go @@ -164,8 +164,10 @@ type LaunchConfig struct { Bridge string VCPUs int MemoryMB int + InitrdPath string // optional initramfs image RootDevice string // kernel root= device, defaults to /dev/vda CloudInitISO string // optional + Accel string // "kvm" (default) or "tcg" } // Launch starts a QEMU microVM process with the given configuration. @@ -204,21 +206,30 @@ func (m *Manager) Launch(ctx context.Context, cfg LaunchConfig) (*SandboxInfo, e } // Build QEMU command args - args := []string{ - "-M", "microvm", "-enable-kvm", "-cpu", "host", + accelArgs := []string{"-enable-kvm", "-cpu", "host"} + if cfg.Accel == "tcg" { + accelArgs = []string{"-accel", "tcg", "-cpu", "max"} + } + args := append([]string{"-M", "microvm"}, accelArgs...) + args = append(args, "-m", strconv.Itoa(cfg.MemoryMB), "-smp", strconv.Itoa(cfg.VCPUs), "-kernel", cfg.KernelPath, + ) + if cfg.InitrdPath != "" { + args = append(args, "-initrd", cfg.InitrdPath) + } + args = append(args, "-append", fmt.Sprintf("console=ttyS0 root=%s rw quiet", rootDev), "-drive", fmt.Sprintf("id=root,file=%s,format=qcow2,if=none", cfg.OverlayPath), "-device", "virtio-blk-device,drive=root", "-netdev", fmt.Sprintf("tap,id=net0,ifname=%s,script=no,downscript=no", cfg.TAPDevice), "-device", fmt.Sprintf("virtio-net-device,netdev=net0,mac=%s", cfg.MACAddress), - "-serial", "stdio", + "-serial", fmt.Sprintf("file:%s", filepath.Join(sandboxDir, "serial.log")), "-nographic", "-nodefaults", "-daemonize", "-pidfile", pidFile, - } + ) // Add cloud-init ISO if provided if cfg.CloudInitISO != "" { diff --git a/fluid-daemon/internal/network/bridge.go b/fluid-daemon/internal/network/bridge.go index ba375a85..15ca6fa1 100644 --- a/fluid-daemon/internal/network/bridge.go +++ b/fluid-daemon/internal/network/bridge.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "log/slog" - "os/exec" + "net" "regexp" "strings" ) @@ -36,8 +36,8 @@ func NewNetworkManager(defaultBridge string, bridgeMap map[string]string, dhcpMo } // ResolveBridge determines which bridge to attach a sandbox's TAP to. -// Priority: explicit request > source VM's network > default bridge. -func (n *NetworkManager) ResolveBridge(ctx context.Context, sourceVMName, requestedNetwork string) (string, error) { +// Priority: explicit request > default bridge. +func (n *NetworkManager) ResolveBridge(ctx context.Context, requestedNetwork string) (string, error) { var bridge string // 1. If explicit network requested, look up in bridge_map @@ -46,33 +46,18 @@ func (n *NetworkManager) ResolveBridge(ctx context.Context, sourceVMName, reques n.logger.Info("resolved bridge from requested network", "network", requestedNetwork, "bridge", b) bridge = b } else if strings.HasPrefix(requestedNetwork, "br") || strings.HasPrefix(requestedNetwork, "virbr") { - // If the requested network looks like a bridge name (not a libvirt network), use it directly bridge = requestedNetwork } else { return "", fmt.Errorf("unknown network %q: not found in bridge_map", requestedNetwork) } } - // 2. If source VM specified, query libvirt for its network - if bridge == "" && sourceVMName != "" { - b, err := n.resolveFromSourceVM(ctx, sourceVMName) - if err == nil && b != "" { - n.logger.Info("resolved bridge from source VM", "source_vm", sourceVMName, "bridge", b) - bridge = b - } - if err != nil { - n.logger.Warn("failed to resolve bridge from source VM, using default", - "source_vm", sourceVMName, "error", err) - } - } - - // 3. Fall back to default bridge + // 2. Fall back to default bridge if bridge == "" { bridge = n.defaultBridge n.logger.Info("using default bridge", "bridge", bridge) } - // Validate bridge name contains only safe characters. if !validBridge.MatchString(bridge) { return "", fmt.Errorf("invalid bridge name %q: must match [a-zA-Z0-9_-]+", bridge) } @@ -80,76 +65,29 @@ func (n *NetworkManager) ResolveBridge(ctx context.Context, sourceVMName, reques return bridge, nil } -// resolveFromSourceVM queries virsh to determine which bridge a source VM is connected to. -func (n *NetworkManager) resolveFromSourceVM(ctx context.Context, sourceVMName string) (string, error) { - // virsh domiflist returns network/bridge info - cmd := exec.CommandContext(ctx, "virsh", "domiflist", sourceVMName) - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("virsh domiflist: %w", err) - } - - // Parse output to find network name or bridge - // Format: Interface Type Source Model MAC - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "---") || strings.HasPrefix(line, "Interface") { - continue - } - - fields := strings.Fields(line) - if len(fields) < 3 { - continue - } - - ifType := fields[1] - source := fields[2] - - // If type is "bridge", source is the bridge name directly - if ifType == "bridge" { - return source, nil - } - - // If type is "network", source is a libvirt network name - if ifType == "network" { - // Check bridge_map first - if bridge, ok := n.bridgeMap[source]; ok { - return bridge, nil - } - - // Resolve via virsh net-info - return n.resolveNetworkToBridge(ctx, source) - } - } - - return "", fmt.Errorf("no network interface found for VM %s", sourceVMName) +// DHCPMode returns the configured DHCP mode. +func (n *NetworkManager) DHCPMode() string { + return n.dhcpMode } -// resolveNetworkToBridge uses virsh net-info to find the bridge for a libvirt network. -func (n *NetworkManager) resolveNetworkToBridge(ctx context.Context, networkName string) (string, error) { - cmd := exec.CommandContext(ctx, "virsh", "net-info", networkName) - output, err := cmd.Output() +// GetBridgeIP returns the first IPv4 address assigned to the named bridge interface. +func GetBridgeIP(bridge string) (string, error) { + iface, err := net.InterfaceByName(bridge) if err != nil { - return "", fmt.Errorf("virsh net-info %s: %w", networkName, err) + return "", fmt.Errorf("interface %s: %w", bridge, err) } - - // Parse "Bridge: virbr0" from output - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "Bridge:") { - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - return strings.TrimSpace(parts[1]), nil - } + addrs, err := iface.Addrs() + if err != nil { + return "", fmt.Errorf("addrs for %s: %w", bridge, err) + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + if ipNet.IP.To4() != nil { + return ipNet.IP.String(), nil } } - - return "", fmt.Errorf("no bridge found for network %s", networkName) -} - -// DHCPMode returns the configured DHCP mode. -func (n *NetworkManager) DHCPMode() string { - return n.dhcpMode + return "", fmt.Errorf("no IPv4 address on bridge %s", bridge) } diff --git a/fluid-daemon/internal/network/bridge_test.go b/fluid-daemon/internal/network/bridge_test.go index f8994ab3..f00df29f 100644 --- a/fluid-daemon/internal/network/bridge_test.go +++ b/fluid-daemon/internal/network/bridge_test.go @@ -9,7 +9,7 @@ import ( func TestNetworkManager_ResolveBridge_Default(t *testing.T) { nm := NewNetworkManager("br0", nil, "dnsmasq", slog.Default()) - bridge, err := nm.ResolveBridge(context.Background(), "", "") + bridge, err := nm.ResolveBridge(context.Background(), "") if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -36,7 +36,7 @@ func TestNetworkManager_ResolveBridge_FromMap(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - bridge, err := nm.ResolveBridge(context.Background(), "", tt.requestedNetwork) + bridge, err := nm.ResolveBridge(context.Background(), tt.requestedNetwork) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -60,7 +60,7 @@ func TestNetworkManager_ResolveBridge_Explicit(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - bridge, err := nm.ResolveBridge(context.Background(), "", tt.requestedNetwork) + bridge, err := nm.ResolveBridge(context.Background(), tt.requestedNetwork) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -77,7 +77,7 @@ func TestNetworkManager_ResolveBridge_UnknownNetwork(t *testing.T) { } nm := NewNetworkManager("br0", bridgeMap, "dnsmasq", slog.Default()) - _, err := nm.ResolveBridge(context.Background(), "", "nonexistent") + _, err := nm.ResolveBridge(context.Background(), "nonexistent") if err == nil { t.Fatal("expected error for unknown network, got nil") } diff --git a/fluid-daemon/internal/provider/microvm/microvm_provider.go b/fluid-daemon/internal/provider/microvm/microvm_provider.go index ae7eba7c..87832f87 100644 --- a/fluid-daemon/internal/provider/microvm/microvm_provider.go +++ b/fluid-daemon/internal/provider/microvm/microvm_provider.go @@ -7,8 +7,11 @@ import ( "context" "fmt" "log/slog" + "os" "os/exec" "runtime" + "strconv" + "strings" "time" "github.com/aspectrr/fluid.sh/fluid-daemon/internal/id" @@ -17,15 +20,31 @@ import ( "github.com/aspectrr/fluid.sh/fluid-daemon/internal/network" "github.com/aspectrr/fluid.sh/fluid-daemon/internal/provider" "github.com/aspectrr/fluid.sh/fluid-daemon/internal/sourcevm" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/sshkeys" ) +// ReadinessWaiter can wait for a sandbox to signal readiness via phone_home. +type ReadinessWaiter interface { + Register(sandboxID string) + Unregister(sandboxID string) + WaitReady(sandboxID string, timeout time.Duration) error +} + // Provider implements provider.SandboxProvider for QEMU microVMs. type Provider struct { - vmMgr *microvm.Manager - netMgr *network.NetworkManager - imgStore *image.Store - srcVMMgr *sourcevm.Manager - logger *slog.Logger + vmMgr *microvm.Manager + netMgr *network.NetworkManager + imgStore *image.Store + srcVMMgr *sourcevm.Manager + keyMgr sshkeys.KeyProvider + kernelPath string + initrdPath string + rootDevice string + accel string + caPubKey string + bridgeIP string + readiness ReadinessWaiter + logger *slog.Logger } // New creates a new microVM provider. @@ -34,17 +53,33 @@ func New( netMgr *network.NetworkManager, imgStore *image.Store, srcVMMgr *sourcevm.Manager, + keyMgr sshkeys.KeyProvider, + kernelPath string, + initrdPath string, + rootDevice string, + accel string, + caPubKey string, + bridgeIP string, + readiness ReadinessWaiter, logger *slog.Logger, ) *Provider { if logger == nil { logger = slog.Default() } return &Provider{ - vmMgr: vmMgr, - netMgr: netMgr, - imgStore: imgStore, - srcVMMgr: srcVMMgr, - logger: logger.With("provider", "microvm"), + vmMgr: vmMgr, + netMgr: netMgr, + imgStore: imgStore, + srcVMMgr: srcVMMgr, + keyMgr: keyMgr, + kernelPath: kernelPath, + initrdPath: initrdPath, + rootDevice: rootDevice, + accel: accel, + caPubKey: caPubKey, + bridgeIP: bridgeIP, + readiness: readiness, + logger: logger.With("provider", "microvm"), } } @@ -54,7 +89,7 @@ func (p *Provider) CreateSandbox(ctx context.Context, req provider.CreateRequest } // Resolve bridge - bridge, err := p.netMgr.ResolveBridge(ctx, req.SourceVM, req.Network) + bridge, err := p.netMgr.ResolveBridge(ctx, req.Network) if err != nil { return nil, fmt.Errorf("resolve bridge: %w", err) } @@ -65,10 +100,20 @@ func (p *Provider) CreateSandbox(ctx context.Context, req provider.CreateRequest return nil, fmt.Errorf("get base image: %w", err) } - // Get kernel path - kernelPath, err := p.imgStore.GetKernelPath(req.BaseImage) - if err != nil { - return nil, fmt.Errorf("get kernel: %w", err) + // Use configured kernel path + kernelPath := p.kernelPath + if kernelPath == "" { + return nil, fmt.Errorf("kernel path not configured") + } + + // Validate initrd exists when configured. Distribution kernels typically + // need an initramfs to load virtio_blk/ext4 modules - booting without one + // causes a kernel panic. Set initrd_path: "" in config if not needed. + initrdPath := p.initrdPath + if initrdPath != "" { + if _, err := os.Stat(initrdPath); err != nil { + return nil, fmt.Errorf("initrd not found at %s (set initrd_path: \"\" in config if not needed): %w", initrdPath, err) + } } // Create overlay disk @@ -77,6 +122,14 @@ func (p *Provider) CreateSandbox(ctx context.Context, req provider.CreateRequest return nil, fmt.Errorf("create overlay: %w", err) } + // Generate cloud-init NoCloud ISO with catch-all DHCP config so the + // sandbox gets an IP regardless of the source VM's interface naming. + cloudInitISO, err := microvm.GenerateCloudInitISO(p.vmMgr.WorkDir(), req.SandboxID, p.caPubKey, p.bridgeIP) + if err != nil { + _ = microvm.RemoveOverlay(p.vmMgr.WorkDir(), req.SandboxID) + return nil, fmt.Errorf("generate cloud-init ISO: %w", err) + } + // Generate MAC address and TAP device mac := microvm.GenerateMACAddress() tapName := network.TAPName(req.SandboxID) @@ -99,15 +152,19 @@ func (p *Provider) CreateSandbox(ctx context.Context, req provider.CreateRequest // Launch microVM info, err := p.vmMgr.Launch(ctx, microvm.LaunchConfig{ - SandboxID: req.SandboxID, - Name: req.Name, - OverlayPath: overlayPath, - KernelPath: kernelPath, - TAPDevice: tapName, - MACAddress: mac, - Bridge: bridge, - VCPUs: vcpus, - MemoryMB: memMB, + SandboxID: req.SandboxID, + Name: req.Name, + OverlayPath: overlayPath, + KernelPath: kernelPath, + InitrdPath: initrdPath, + RootDevice: p.rootDevice, + TAPDevice: tapName, + MACAddress: mac, + Bridge: bridge, + VCPUs: vcpus, + MemoryMB: memMB, + Accel: p.accel, + CloudInitISO: cloudInitISO, }) if err != nil { _ = network.DestroyTAP(ctx, tapName) @@ -132,6 +189,126 @@ func (p *Provider) CreateSandbox(ctx context.Context, req provider.CreateRequest }, nil } +// ProgressFunc is called to report sandbox creation progress. +type ProgressFunc func(step string, stepNum, total int) + +// CreateSandboxWithProgress creates a sandbox while reporting granular progress. +func (p *Provider) CreateSandboxWithProgress(ctx context.Context, req provider.CreateRequest, progress ProgressFunc) (*provider.SandboxResult, error) { + if p.vmMgr == nil { + return nil, fmt.Errorf("microVM manager not available") + } + if p.readiness != nil { + p.readiness.Register(req.SandboxID) + defer p.readiness.Unregister(req.SandboxID) + } + + const totalSteps = 7 + + // Step 1: Resolve bridge + progress("Resolving network bridge", 1, totalSteps) + bridge, err := p.netMgr.ResolveBridge(ctx, req.Network) + if err != nil { + return nil, fmt.Errorf("resolve bridge: %w", err) + } + + imagePath, err := p.imgStore.GetImagePath(req.BaseImage) + if err != nil { + return nil, fmt.Errorf("get base image: %w", err) + } + + kernelPath := p.kernelPath + if kernelPath == "" { + return nil, fmt.Errorf("kernel path not configured") + } + initrdPath := p.initrdPath + if initrdPath != "" { + if _, err := os.Stat(initrdPath); err != nil { + return nil, fmt.Errorf("initrd not found at %s: %w", initrdPath, err) + } + } + + // Step 2: Create overlay disk + progress("Creating overlay disk", 2, totalSteps) + overlayPath, err := microvm.CreateOverlay(ctx, imagePath, p.vmMgr.WorkDir(), req.SandboxID) + if err != nil { + return nil, fmt.Errorf("create overlay: %w", err) + } + + // Step 3: Generate cloud-init + progress("Generating cloud-init", 3, totalSteps) + cloudInitISO, err := microvm.GenerateCloudInitISO(p.vmMgr.WorkDir(), req.SandboxID, p.caPubKey, p.bridgeIP) + if err != nil { + _ = microvm.RemoveOverlay(p.vmMgr.WorkDir(), req.SandboxID) + return nil, fmt.Errorf("generate cloud-init ISO: %w", err) + } + + // Step 4: Set up network (TAP) + progress("Setting up network (TAP)", 4, totalSteps) + mac := microvm.GenerateMACAddress() + tapName := network.TAPName(req.SandboxID) + if err := network.CreateTAP(ctx, tapName, bridge, p.logger); err != nil { + _ = microvm.RemoveOverlay(p.vmMgr.WorkDir(), req.SandboxID) + return nil, fmt.Errorf("create TAP: %w", err) + } + + vcpus := req.VCPUs + if vcpus == 0 { + vcpus = 2 + } + memMB := req.MemoryMB + if memMB == 0 { + memMB = 2048 + } + + // Step 5: Boot microVM + progress("Booting microVM", 5, totalSteps) + info, err := p.vmMgr.Launch(ctx, microvm.LaunchConfig{ + SandboxID: req.SandboxID, + Name: req.Name, + OverlayPath: overlayPath, + KernelPath: kernelPath, + InitrdPath: initrdPath, + RootDevice: p.rootDevice, + TAPDevice: tapName, + MACAddress: mac, + Bridge: bridge, + VCPUs: vcpus, + MemoryMB: memMB, + Accel: p.accel, + CloudInitISO: cloudInitISO, + }) + if err != nil { + _ = network.DestroyTAP(ctx, tapName) + _ = microvm.RemoveOverlay(p.vmMgr.WorkDir(), req.SandboxID) + return nil, fmt.Errorf("launch microVM: %w", err) + } + + // Step 6: Discover IP + progress("Discovering IP address", 6, totalSteps) + ip, err := p.netMgr.DiscoverIP(ctx, mac, bridge, 2*time.Minute) + if err != nil { + p.logger.Warn("IP discovery failed", "sandbox_id", req.SandboxID, "error", err) + } + + // Step 7: Wait for cloud-init ready + progress("Waiting for cloud-init ready", 7, totalSteps) + if p.readiness != nil { + if waitErr := p.readiness.WaitReady(req.SandboxID, 2*time.Minute); waitErr != nil { + p.logger.Warn("readiness wait failed, sandbox may not be fully ready", "sandbox_id", req.SandboxID, "error", waitErr) + } + } + + return &provider.SandboxResult{ + SandboxID: req.SandboxID, + Name: req.Name, + State: "RUNNING", + IPAddress: ip, + MACAddress: mac, + Bridge: bridge, + PID: info.PID, + }, nil +} + func (p *Provider) DestroySandbox(ctx context.Context, sandboxID string) error { if p.vmMgr != nil { info, err := p.vmMgr.Get(sandboxID) @@ -225,14 +402,58 @@ func (p *Provider) RunCommand(ctx context.Context, sandboxID, command string, ti return nil, fmt.Errorf("unable to discover sandbox IP for SSH") } + if p.keyMgr == nil { + return nil, fmt.Errorf("SSH key manager not available - cannot connect to sandbox") + } + creds, err := p.keyMgr.GetCredentials(ctx, sandboxID, "sandbox") + if err != nil { + return nil, fmt.Errorf("get sandbox SSH credentials: %w", err) + } + if timeout == 0 { timeout = 5 * time.Minute } + // Retry loop: sshd may not be ready yet after IP is assigned. + const maxRetries = 6 + const retryDelay = 5 * time.Second + start := time.Now() - stdout, stderr, exitCode, err := runSSHCommand(ctx, ip, command, timeout) - if err != nil { - return nil, fmt.Errorf("run command: %w", err) + var stdout, stderr string + var exitCode int + + for attempt := 0; attempt <= maxRetries; attempt++ { + stdout, stderr, exitCode, err = runSSHCommand(ctx, ip, creds, command, timeout) + if err == nil { + break + } + + // Retry on transient errors: sshd not yet listening, or cert auth + // not yet configured (cloud-init still running). + errMsg := err.Error() + isTransient := strings.Contains(errMsg, "Connection refused") || + strings.Contains(errMsg, "Connection reset") || + strings.Contains(errMsg, "No route to host") || + strings.Contains(errMsg, "connection refused") || + strings.Contains(errMsg, "connection reset") || + strings.Contains(errMsg, "Permission denied") + + if !isTransient || attempt == maxRetries { + return nil, fmt.Errorf("run command: %w", err) + } + + p.logger.Info("SSH connection failed, retrying (sshd may still be starting)", + "sandbox_id", sandboxID, + "attempt", attempt+1, + "max_retries", maxRetries, + "error", err, + ) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryDelay): + } } return &provider.CommandResult{ @@ -349,6 +570,28 @@ func (p *Provider) Capabilities(_ context.Context) (*provider.HostCapabilities, AvailableCPUs: runtime.NumCPU(), } + // Read system memory from /proc/meminfo + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "MemTotal:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + if kb, err := strconv.ParseInt(fields[1], 10, 64); err == nil { + caps.TotalMemoryMB = int(kb / 1024) + } + } + } + if strings.HasPrefix(line, "MemAvailable:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + if kb, err := strconv.ParseInt(fields[1], 10, 64); err == nil { + caps.AvailableMemMB = int(kb / 1024) + } + } + } + } + } + if p.imgStore != nil { names, _ := p.imgStore.ListNames() caps.BaseImages = names @@ -371,16 +614,18 @@ func (p *Provider) RecoverState(ctx context.Context) error { return p.vmMgr.RecoverState(ctx) } -// runSSHCommand executes a command on a sandbox via SSH. -func runSSHCommand(ctx context.Context, ip, command string, timeout time.Duration) (stdout, stderr string, exitCode int, err error) { +// runSSHCommand executes a command on a sandbox via SSH using cert-based auth. +func runSSHCommand(ctx context.Context, ip string, creds *sshkeys.Credentials, command string, timeout time.Duration) (stdout, stderr string, exitCode int, err error) { cmdCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() sshArgs := []string{ + "-i", creds.PrivateKeyPath, + "-o", "CertificateFile=" + creds.CertificatePath, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=10", - fmt.Sprintf("sandbox@%s", ip), + fmt.Sprintf("%s@%s", creds.Username, ip), command, } @@ -392,8 +637,16 @@ func runSSHCommand(ctx context.Context, ip, command string, timeout time.Duratio err = cmd.Run() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 255 { + stderrStr := stderrBuf.String() + return "", stderrStr, 255, fmt.Errorf("ssh failed (exit 255): %s", stderrStr) + } return stdoutBuf.String(), stderrBuf.String(), exitErr.ExitCode(), nil } + // Include stderr in the error for connection diagnostics. + if stderrStr := stderrBuf.String(); stderrStr != "" { + return "", stderrStr, -1, fmt.Errorf("%w: %s", err, stderrStr) + } return "", "", -1, err } diff --git a/fluid-daemon/internal/provider/microvm/microvm_provider_test.go b/fluid-daemon/internal/provider/microvm/microvm_provider_test.go new file mode 100644 index 00000000..a9c1c615 --- /dev/null +++ b/fluid-daemon/internal/provider/microvm/microvm_provider_test.go @@ -0,0 +1,88 @@ +package microvm + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + imagestore "github.com/aspectrr/fluid.sh/fluid-daemon/internal/image" + microvminternal "github.com/aspectrr/fluid.sh/fluid-daemon/internal/microvm" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/network" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/provider" +) + +type stubReadinessWaiter struct { + registered []string + unregistered []string +} + +func (s *stubReadinessWaiter) Register(sandboxID string) { + s.registered = append(s.registered, sandboxID) +} + +func (s *stubReadinessWaiter) Unregister(sandboxID string) { + s.unregistered = append(s.unregistered, sandboxID) +} + +func (s *stubReadinessWaiter) WaitReady(string, time.Duration) error { + return nil +} + +func TestCreateSandboxWithProgress_RegistersReadinessBeforeFailure(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + workDir := t.TempDir() + imageDir := filepath.Join(workDir, "images") + + if err := os.MkdirAll(imageDir, 0o755); err != nil { + t.Fatalf("mkdir image dir: %v", err) + } + if err := os.WriteFile(filepath.Join(imageDir, "ubuntu.qcow2"), []byte("qcow2"), 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + + vmMgr, err := microvminternal.NewManager("true", filepath.Join(workDir, "sandboxes"), logger) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + imgStore, err := imagestore.NewStore(imageDir, logger) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + readiness := &stubReadinessWaiter{} + var progressSteps []string + p := &Provider{ + vmMgr: vmMgr, + netMgr: network.NewNetworkManager("br-test0", nil, "", logger), + imgStore: imgStore, + readiness: readiness, + logger: logger, + } + + _, err = p.CreateSandboxWithProgress(context.Background(), provider.CreateRequest{ + SandboxID: "SBX-123", + BaseImage: "ubuntu", + }, func(step string, _ int, _ int) { + progressSteps = append(progressSteps, step) + }) + if err == nil { + t.Fatal("expected CreateSandboxWithProgress to fail without a configured kernel path") + } + if len(progressSteps) == 0 { + t.Fatal("expected at least one progress step before failure") + } + if progressSteps[0] != "Resolving network bridge" { + t.Fatalf("first progress step = %q, want %q", progressSteps[0], "Resolving network bridge") + } + + if len(readiness.registered) != 1 || readiness.registered[0] != "SBX-123" { + t.Fatalf("registered = %v, want [SBX-123]", readiness.registered) + } + if len(readiness.unregistered) != 1 || readiness.unregistered[0] != "SBX-123" { + t.Fatalf("unregistered = %v, want [SBX-123]", readiness.unregistered) + } +} diff --git a/fluid-daemon/internal/snapshotpull/backend.go b/fluid-daemon/internal/snapshotpull/backend.go index 5ce79a5e..2f5259a1 100644 --- a/fluid-daemon/internal/snapshotpull/backend.go +++ b/fluid-daemon/internal/snapshotpull/backend.go @@ -4,10 +4,8 @@ package snapshotpull import "context" -// SnapshotBackend abstracts the mechanism for snapshotting a VM disk -// on a remote host and pulling it locally. +// SnapshotBackend abstracts the mechanism for pulling a VM disk from a remote host. type SnapshotBackend interface { - // SnapshotAndPull creates a temporary snapshot of vmName's disk, - // transfers the backing image to destPath, then cleans up the snapshot. + // SnapshotAndPull transfers vmName's disk to destPath. SnapshotAndPull(ctx context.Context, vmName string, destPath string) error } diff --git a/fluid-daemon/internal/snapshotpull/libvirt_backend.go b/fluid-daemon/internal/snapshotpull/libvirt_backend.go index de3513fd..6de868b2 100644 --- a/fluid-daemon/internal/snapshotpull/libvirt_backend.go +++ b/fluid-daemon/internal/snapshotpull/libvirt_backend.go @@ -9,13 +9,11 @@ import ( "path/filepath" "strings" "time" - - "github.com/aspectrr/fluid.sh/fluid-daemon/internal/shellutil" ) const fluidSnapPrefix = "fluid-tmp-snap" -// LibvirtBackend snapshots and pulls a VM disk from a remote libvirt host via SSH. +// LibvirtBackend snapshots and pulls a VM disk from a remote libvirt host via virsh over SSH transport. type LibvirtBackend struct { sshHost string sshPort int @@ -25,20 +23,32 @@ type LibvirtBackend struct { logger *slog.Logger } -// NewLibvirtBackend creates a backend that uses SSH + virsh to snapshot and rsync to pull. -func NewLibvirtBackend(host string, port int, user, identityFile, virshURI string, logger *slog.Logger) *LibvirtBackend { +// NewLibvirtBackend creates a backend that uses local virsh with qemu+ssh transport to manage the remote libvirt. +// No SSH prefix needed: virsh handles the connection via its built-in SSH transport. +func NewLibvirtBackend(host string, port int, user, identityFile string, logger *slog.Logger) *LibvirtBackend { if port == 0 { port = 22 } if user == "" { user = "root" } - if virshURI == "" { - virshURI = "qemu:///system" - } if logger == nil { logger = slog.Default() } + + // Build qemu+ssh URI so local virsh connects to remote libvirtd without needing a sudoers entry. + // no_tty=1 prevents TTY prompts in daemon context. + var hostPart string + if port == 22 { + hostPart = fmt.Sprintf("%s@%s", user, host) + } else { + hostPart = fmt.Sprintf("%s@%s:%d", user, host, port) + } + virshURI := fmt.Sprintf("qemu+ssh://%s/system?no_tty=1", hostPart) + if identityFile != "" { + virshURI += "&keyfile=" + identityFile + } + return &LibvirtBackend{ sshHost: host, sshPort: port, @@ -49,50 +59,47 @@ func NewLibvirtBackend(host string, port int, user, identityFile, virshURI strin } } -// virshCmd builds a virsh command string with the connection URI. -func (b *LibvirtBackend) virshCmd(args string) string { - return fmt.Sprintf("virsh -c %s %s", shellutil.Quote(b.virshURI), args) +// runVirshCmd executes a virsh subcommand locally against the remote libvirt via qemu+ssh transport. +func (b *LibvirtBackend) runVirshCmd(ctx context.Context, args ...string) (string, error) { + cmdArgs := append([]string{"-c", b.virshURI}, args...) + cmd := exec.CommandContext(ctx, "virsh", cmdArgs...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("virsh %v: %w: %s", args, err, stderr.String()) + } + return stdout.String(), nil } -// SnapshotAndPull creates a temporary external snapshot, rsyncs the original -// (now read-only) disk to destPath, then blockcommits back and removes the snapshot. +// SnapshotAndPull downloads vmName's disk directly via virsh vol-download. +// No snapshot is created: the live disk is downloaded as-is. This avoids the +// blockcommit write-lock failures that plagued the snapshot-based flow. func (b *LibvirtBackend) SnapshotAndPull(ctx context.Context, vmName string, destPath string) error { - b.logger.Info("starting snapshot-and-pull", "vm", vmName, "dest", destPath) + b.logger.Info("starting disk pull", "vm", vmName, "dest", destPath) - // 1. Clean up any stale snapshot from a previous failed blockcommit, - // then find the (clean) disk path. diskPath, err := b.findCleanDiskPath(ctx, vmName) if err != nil { return fmt.Errorf("find disk path: %w", err) } b.logger.Info("found disk path", "vm", vmName, "disk", diskPath) - // 2. Create external snapshot with unique name (makes original disk read-only) - snapName := fmt.Sprintf("%s-%d", fluidSnapPrefix, time.Now().Unix()) - if err := b.createSnapshot(ctx, vmName, snapName); err != nil { - return fmt.Errorf("create snapshot: %w", err) - } + // Refresh storage pools so libvirt knows about files created outside pool + // management (e.g. overlay files from previous snapshot operations). + b.refreshStoragePools(ctx) - // 3. Always clean up: blockcommit + delete all fluid snapshot metadata - defer func() { - if err := b.blockcommitWithRetry(ctx, vmName); err != nil { - b.logger.Error("blockcommit failed after retries", "vm", vmName, "error", err) - } - b.cleanupAllFluidSnapshots(ctx, vmName) - }() - - // 4. Rsync the now read-only original disk to local destPath - if err := b.rsyncDisk(ctx, diskPath, destPath); err != nil { - return fmt.Errorf("rsync disk: %w", err) + b.logger.Info("downloading disk via vol-download", "vm", vmName, "src", diskPath, "dest", destPath) + if _, err := b.runVirshCmd(ctx, "vol-download", diskPath, destPath); err != nil { + return fmt.Errorf("download disk: %w", err) } - b.logger.Info("snapshot-and-pull complete", "vm", vmName, "dest", destPath) + b.logger.Info("disk pull complete", "vm", vmName, "dest", destPath) return nil } // findDiskPath uses virsh domblklist to find the primary disk path. func (b *LibvirtBackend) findDiskPath(ctx context.Context, vmName string) (string, error) { - out, err := b.sshCommand(ctx, b.virshCmd(fmt.Sprintf("domblklist %s --details", shellutil.Quote(vmName)))) + out, err := b.runVirshCmd(ctx, "domblklist", vmName, "--details") if err != nil { return "", err } @@ -107,101 +114,19 @@ func (b *LibvirtBackend) findDiskPath(ctx context.Context, vmName string) (strin return "", fmt.Errorf("no disk found for VM %s", vmName) } -// createSnapshot creates an external disk-only snapshot. -func (b *LibvirtBackend) createSnapshot(ctx context.Context, vmName, snapName string) error { - _, err := b.sshCommand(ctx, b.virshCmd(fmt.Sprintf( - "snapshot-create-as %s %s --disk-only --atomic", - shellutil.Quote(vmName), shellutil.Quote(snapName), - ))) - return err -} - -// waitForBlockJob polls virsh blockjob until no job is active on the VM's vda disk. -// Handles RUNNING (waits), READY (pivots then re-checks), and no-job (returns). -func (b *LibvirtBackend) waitForBlockJob(ctx context.Context, vmName string) error { - const pollInterval = 2 * time.Second - for { - out, err := b.sshCommand(ctx, b.virshCmd(fmt.Sprintf("blockjob %s vda --info", shellutil.Quote(vmName)))) - if err != nil || strings.TrimSpace(out) == "" { - // No active block job (command errors or empty = no job) - return nil - } - out = strings.TrimSpace(out) - - // "No current block job for vda" means no active job - if strings.Contains(strings.ToLower(out), "no current block job") { - return nil - } - - if strings.Contains(strings.ToLower(out), "ready") { - // Job completed merge phase, needs pivot - b.logger.Info("block job ready, pivoting", "vm", vmName) - _, _ = b.sshCommand(ctx, b.virshCmd(fmt.Sprintf("blockjob %s vda --pivot", shellutil.Quote(vmName)))) - continue // re-check after pivot - } - - // Job still running, wait and re-poll - b.logger.Info("waiting for block job to complete", "vm", vmName, "status", out) - select { - case <-time.After(pollInterval): - case <-ctx.Done(): - return ctx.Err() - } - } -} - -// blockcommit merges the snapshot back into the original disk and pivots. -func (b *LibvirtBackend) blockcommit(ctx context.Context, vmName string) error { - // Wait for any in-flight block job to finish before starting a new one - if err := b.waitForBlockJob(ctx, vmName); err != nil { - return fmt.Errorf("wait for block job: %w", err) - } - _, err := b.sshCommand(ctx, b.virshCmd(fmt.Sprintf( - "blockcommit %s vda --active --pivot --delete", - shellutil.Quote(vmName), - ))) - return err -} - -// blockcommitWithRetry retries blockcommit with exponential backoff. -// QEMU's internal write lock is transient and typically clears within 1-3s. -func (b *LibvirtBackend) blockcommitWithRetry(ctx context.Context, vmName string) error { - const maxAttempts = 5 - for i := 0; i < maxAttempts; i++ { - err := b.blockcommit(ctx, vmName) - if err == nil { - return nil - } - if i < maxAttempts-1 { - delay := time.Duration(1<"), nil } // blockpull pulls backing data into the active overlay, making it standalone. func (b *LibvirtBackend) blockpull(ctx context.Context, vmName string) error { - _, err := b.sshCommand(ctx, b.virshCmd(fmt.Sprintf("blockpull %s vda --wait", shellutil.Quote(vmName)))) + _, err := b.runVirshCmd(ctx, "blockpull", vmName, "vda", "--wait") return err } // cleanupAllFluidSnapshots removes all fluid snapshot metadata from a VM. func (b *LibvirtBackend) cleanupAllFluidSnapshots(ctx context.Context, vmName string) { - out, err := b.sshCommand(ctx, b.virshCmd(fmt.Sprintf("snapshot-list %s --name", shellutil.Quote(vmName)))) + out, err := b.runVirshCmd(ctx, "snapshot-list", vmName, "--name") if err != nil { return } @@ -294,85 +192,23 @@ func (b *LibvirtBackend) cleanupAllFluidSnapshots(ctx context.Context, vmName st } } -// cleanupOrphanOverlayFiles removes any fluid overlay files that are no longer in use. -func (b *LibvirtBackend) cleanupOrphanOverlayFiles(ctx context.Context, vmName, currentDiskPath string) { - dir := currentDiskPath[:strings.LastIndex(currentDiskPath, "/")+1] - base := currentDiskPath[strings.LastIndex(currentDiskPath, "/")+1:] - if dot := strings.LastIndex(base, "."); dot >= 0 { - base = base[:dot] - } - pattern := dir + base + "." + fluidSnapPrefix + "*" - // Pattern is derived from virsh domblklist output (trusted), needs glob expansion so not quoted. - out, err := b.sshCommand(ctx, fmt.Sprintf("ls %s 2>/dev/null", pattern)) - if err != nil || strings.TrimSpace(out) == "" { +// refreshStoragePools refreshes all active storage pools so libvirt discovers +// files created outside pool management (e.g. overlay files from snapshots). +func (b *LibvirtBackend) refreshStoragePools(ctx context.Context) { + out, err := b.runVirshCmd(ctx, "pool-list", "--name") + if err != nil { return } - for _, f := range strings.Split(strings.TrimSpace(out), "\n") { - f = strings.TrimSpace(f) - if f != "" && f != currentDiskPath { - b.logger.Warn("removing orphaned overlay file", "vm", vmName, "path", f) - _, _ = b.sshCommand(ctx, fmt.Sprintf("rm -f %s", shellutil.Quote(f))) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + pool := strings.TrimSpace(line) + if pool != "" { + _, _ = b.runVirshCmd(ctx, "pool-refresh", pool) } } } // deleteSnapshotMetadata removes snapshot metadata from libvirt. func (b *LibvirtBackend) deleteSnapshotMetadata(ctx context.Context, vmName, snapName string) error { - _, err := b.sshCommand(ctx, b.virshCmd(fmt.Sprintf( - "snapshot-delete %s %s --metadata", - shellutil.Quote(vmName), shellutil.Quote(snapName), - ))) + _, err := b.runVirshCmd(ctx, "snapshot-delete", vmName, snapName, "--metadata") return err } - -// rsyncDisk pulls the remote disk to a local path. -func (b *LibvirtBackend) rsyncDisk(ctx context.Context, remotePath, localPath string) error { - sshOpts := b.sshOpts() - src := fmt.Sprintf("%s@%s:%s", b.sshUser, b.sshHost, remotePath) - - args := []string{ - "-avz", "--progress", - "-e", fmt.Sprintf("ssh %s", sshOpts), - src, localPath, - } - - cmd := exec.CommandContext(ctx, "rsync", args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("rsync: %w: %s", err, stderr.String()) - } - return nil -} - -// sshCommand runs a command on the remote host via SSH. -func (b *LibvirtBackend) sshCommand(ctx context.Context, command string) (string, error) { - args := []string{ - "-o", "StrictHostKeyChecking=accept-new", - "-o", "BatchMode=yes", - "-p", fmt.Sprintf("%d", b.sshPort), - } - if b.sshIdentityFile != "" { - args = append(args, "-i", b.sshIdentityFile) - } - args = append(args, fmt.Sprintf("%s@%s", b.sshUser, b.sshHost), command) - - cmd := exec.CommandContext(ctx, "ssh", args...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("ssh command %q: %w: %s", command, err, stderr.String()) - } - return stdout.String(), nil -} - -// sshOpts returns the SSH option string for use with rsync -e. -func (b *LibvirtBackend) sshOpts() string { - opts := fmt.Sprintf("-o StrictHostKeyChecking=accept-new -o BatchMode=yes -p %d", b.sshPort) - if b.sshIdentityFile != "" { - opts += fmt.Sprintf(" -i %s", shellutil.Quote(b.sshIdentityFile)) - } - return opts -} diff --git a/fluid-daemon/internal/snapshotpull/libvirt_backend_test.go b/fluid-daemon/internal/snapshotpull/libvirt_backend_test.go index 4479062a..05a6d95b 100644 --- a/fluid-daemon/internal/snapshotpull/libvirt_backend_test.go +++ b/fluid-daemon/internal/snapshotpull/libvirt_backend_test.go @@ -9,7 +9,7 @@ import ( ) func TestNewLibvirtBackend_Defaults(t *testing.T) { - b := NewLibvirtBackend("host1.example.com", 0, "", "", "", nil) + b := NewLibvirtBackend("host1.example.com", 0, "", "", nil) if b.sshPort != 22 { t.Errorf("expected default port 22, got %d", b.sshPort) @@ -20,13 +20,17 @@ func TestNewLibvirtBackend_Defaults(t *testing.T) { if b.sshHost != "host1.example.com" { t.Errorf("expected host host1.example.com, got %s", b.sshHost) } - if b.virshURI != "qemu:///system" { - t.Errorf("expected default virshURI qemu:///system, got %s", b.virshURI) + // Default port 22 is omitted from URI + if !strings.Contains(b.virshURI, "qemu+ssh://root@host1.example.com/system") { + t.Errorf("expected qemu+ssh URI with host, got %s", b.virshURI) + } + if !strings.Contains(b.virshURI, "no_tty=1") { + t.Errorf("expected no_tty=1 in URI, got %s", b.virshURI) } } func TestNewLibvirtBackend_CustomValues(t *testing.T) { - b := NewLibvirtBackend("10.0.0.1", 2222, "admin", "/home/admin/.ssh/id_rsa", "qemu+tcp://localhost/system", nil) + b := NewLibvirtBackend("10.0.0.1", 2222, "admin", "/home/admin/.ssh/id_rsa", nil) if b.sshPort != 2222 { t.Errorf("expected port 2222, got %d", b.sshPort) @@ -37,129 +41,24 @@ func TestNewLibvirtBackend_CustomValues(t *testing.T) { if b.sshIdentityFile != "/home/admin/.ssh/id_rsa" { t.Errorf("expected identity file /home/admin/.ssh/id_rsa, got %s", b.sshIdentityFile) } - if b.virshURI != "qemu+tcp://localhost/system" { - t.Errorf("expected virshURI qemu+tcp://localhost/system, got %s", b.virshURI) - } -} - -func TestLibvirtBackend_SSHOpts(t *testing.T) { - b := NewLibvirtBackend("host1", 2222, "user", "/path/to/key", "", nil) - opts := b.sshOpts() - - if opts == "" { - t.Fatal("expected non-empty ssh opts") - } - - // Should contain port - if !contains(opts, "-p 2222") { - t.Errorf("expected opts to contain port, got: %s", opts) - } - - // Should contain identity file (shell-quoted) - if !contains(opts, "-i '/path/to/key'") { - t.Errorf("expected opts to contain shell-quoted identity file, got: %s", opts) - } -} - -func TestLibvirtBackend_SSHOpts_NoIdentity(t *testing.T) { - b := NewLibvirtBackend("host1", 22, "root", "", "", nil) - opts := b.sshOpts() - - if contains(opts, "-i") { - t.Errorf("expected no identity file flag, got: %s", opts) - } -} - -func TestLibvirtBackend_VirshCmd(t *testing.T) { - b := NewLibvirtBackend("host1", 22, "root", "", "qemu:///system", nil) - cmd := b.virshCmd("domblklist test-vm --details") - - expected := "virsh -c 'qemu:///system' domblklist test-vm --details" - if cmd != expected { - t.Errorf("expected %q, got %q", expected, cmd) + // Non-standard port is included in URI + if !strings.Contains(b.virshURI, "qemu+ssh://admin@10.0.0.1:2222/system") { + t.Errorf("expected qemu+ssh URI with port, got %s", b.virshURI) } -} - -func TestLibvirtBackend_VirshCmd_CustomURI(t *testing.T) { - b := NewLibvirtBackend("host1", 22, "root", "", "qemu+tcp://localhost/system", nil) - cmd := b.virshCmd("list --all") - - expected := "virsh -c 'qemu+tcp://localhost/system' list --all" - if cmd != expected { - t.Errorf("expected %q, got %q", expected, cmd) + if !strings.Contains(b.virshURI, "keyfile=/home/admin/.ssh/id_rsa") { + t.Errorf("expected keyfile in URI, got %s", b.virshURI) } } -// stubBackend wraps LibvirtBackend and overrides blockcommit via a hook. -// We test blockcommitWithRetry by controlling how many times blockcommit fails. -type stubBackend struct { - *LibvirtBackend - blockcommitFunc func(ctx context.Context, vmName string) error -} - -func TestBlockcommitWithRetry_RespectsTimeout(t *testing.T) { - b := NewLibvirtBackend("host1", 22, "root", "", "", nil) - - // blockcommitWithRetry will fail because there's no SSH host, but it should - // respect context cancellation and not hang - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - err := b.blockcommitWithRetry(ctx, "test-vm") - if err == nil { - t.Log("blockcommitWithRetry returned nil (unexpected but not a test failure for unit)") - } -} - -func TestBlockcommitWithRetry_RespectsContextCancellation(t *testing.T) { - b := NewLibvirtBackend("host1", 22, "root", "", "", nil) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel immediately - - start := time.Now() - err := b.blockcommitWithRetry(ctx, "test-vm") - elapsed := time.Since(start) - - // Should return quickly without going through all retries - if elapsed > 2*time.Second { - t.Errorf("blockcommitWithRetry did not respect context cancellation, took %v", elapsed) - } - // Should return context error or ssh error (ssh itself may fail fast with cancelled ctx) - if err == nil { - t.Log("returned nil with cancelled context") - } -} - -func TestBlockcommitWithRetry_RetryCount(t *testing.T) { - // Verify the retry logic by testing the backoff calculation - // backoff: 1s, 2s, 4s, 8s, 16s (total 31s for 5 attempts) - for i := 0; i < 5; i++ { - delay := time.Duration(1< 2*time.Second { - t.Errorf("waitForBlockJob did not respect context cancellation, took %v", elapsed) - } - // SSH will fail fast with cancelled ctx, returning nil (no active job) - if err != nil { - t.Logf("returned error with cancelled context: %v", err) - } -} - func TestIsFluidOverlay(t *testing.T) { tests := []struct { path string @@ -257,8 +106,8 @@ func TestIsFluidOverlay(t *testing.T) { func TestHasBackingFile_OutputParsing(t *testing.T) { // Test the string parsing logic that hasBackingFile uses. - // We can't call the real method (needs SSH), but we verify the - // parsing logic matches what qemu-img info outputs. + // We can't call the real method (needs virsh), but we verify the + // parsing logic matches what virsh vol-dumpxml outputs. tests := []struct { name string output string @@ -266,22 +115,21 @@ func TestHasBackingFile_OutputParsing(t *testing.T) { }{ { "with backing file", - `image: test-vm.fluid-tmp-snap-123 -file format: qcow2 -virtual size: 20 GiB (21474836480 bytes) -disk size: 196 KiB -cluster_size: 65536 -backing file: /var/lib/libvirt/images/test-vm.qcow2 -backing file format: qcow2`, + ` + test-vm.fluid-tmp-snap-123 + + /var/lib/libvirt/images/test-vm.qcow2 + + +`, true, }, { - "no backing file", - `image: test-vm.qcow2 -file format: qcow2 -virtual size: 20 GiB (21474836480 bytes) -disk size: 1.2 GiB -cluster_size: 65536`, + "no backing file (self-closing)", + ` + test-vm.qcow2 + +`, false, }, { @@ -294,7 +142,7 @@ cluster_size: 65536`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Replicate the parsing logic from hasBackingFile - got := strings.Contains(tt.output, "backing file:") + got := strings.Contains(tt.output, "") if got != tt.expected { t.Errorf("backing file detection for %q = %v, want %v", tt.name, got, tt.expected) } @@ -302,6 +150,20 @@ cluster_size: 65536`, } } +func TestSnapshotAndPull_UnreachableHost(t *testing.T) { + // With an unreachable host, SnapshotAndPull should fail at findCleanDiskPath + // (the first virsh call), not panic or produce confusing errors. + b := NewLibvirtBackend("host1", 22, "root", "", nil) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + err := b.SnapshotAndPull(ctx, "test-vm", "/tmp/test-dest.qcow2") + if err == nil { + t.Fatal("expected error with unreachable host") + } +} + func TestFluidSnapPrefix(t *testing.T) { // Verify the prefix constant matches what we expect if fluidSnapPrefix != "fluid-tmp-snap" { @@ -314,16 +176,3 @@ func TestFluidSnapPrefix(t *testing.T) { t.Errorf("generated snap name %q does not start with prefix %q", snapName, fluidSnapPrefix) } } - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) -} - -func containsHelper(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/fluid-daemon/internal/snapshotpull/puller.go b/fluid-daemon/internal/snapshotpull/puller.go index b793b0ff..f3b8ded3 100644 --- a/fluid-daemon/internal/snapshotpull/puller.go +++ b/fluid-daemon/internal/snapshotpull/puller.go @@ -62,22 +62,37 @@ func NewPuller(imgStore *image.Store, db *gorm.DB, logger *slog.Logger) *Puller // Pull pulls a VM snapshot image, using the cache when appropriate. // Concurrent pulls for the same image are deduplicated. +// +// Fresh mode: try live pull first, fall back to cache on failure. +// Cached mode: try cache first, pull on miss. func (p *Puller) Pull(ctx context.Context, req PullRequest, backend SnapshotBackend) (*PullResult, error) { imageName := cacheKey(req.SourceHost, req.VMName) - // Check cache if mode is "cached" (default) - if req.SnapshotMode != "fresh" { - if result, ok := p.checkCache(ctx, imageName); ok { - p.logger.Info("cache hit", "image", imageName) + if req.SnapshotMode == "fresh" { + result, err := p.pullOrWait(ctx, imageName, req, backend) + if err == nil { return result, nil } + p.logger.Warn("live pull failed, falling back to cache", "image", imageName, "error", err) + if cached, ok := p.checkCache(ctx, imageName); ok { + return cached, nil + } + return nil, err } - // Deduplicate concurrent pulls for the same image + // Cached mode: check cache first, pull on miss + if result, ok := p.checkCache(ctx, imageName); ok { + p.logger.Info("cache hit", "image", imageName) + return result, nil + } + return p.pullOrWait(ctx, imageName, req, backend) +} + +// pullOrWait performs a live pull with inflight deduplication. +func (p *Puller) pullOrWait(ctx context.Context, imageName string, req PullRequest, backend SnapshotBackend) (*PullResult, error) { p.mu.Lock() if entry, ok := p.inflight[imageName]; ok { p.mu.Unlock() - // Wait for the in-flight pull to finish select { case <-entry.done: return entry.result, entry.err @@ -86,19 +101,16 @@ func (p *Puller) Pull(ctx context.Context, req PullRequest, backend SnapshotBack } } - // We're the first - register ourselves entry := &inflightEntry{done: make(chan struct{})} p.inflight[imageName] = entry p.mu.Unlock() result, err := p.doPull(ctx, imageName, req, backend) - // Store result on the entry so all waiters can read it entry.result = result entry.err = err close(entry.done) - // Clean up inflight map p.mu.Lock() delete(p.inflight, imageName) p.mu.Unlock() @@ -112,22 +124,13 @@ func (p *Puller) doPull(ctx context.Context, imageName string, req PullRequest, destPath := p.imgStore.BaseDir() + "/" + imageName + ".qcow2" - // Remove existing file if doing a fresh pull - if req.SnapshotMode == "fresh" { - _ = os.Remove(destPath) - } - // Snapshot and pull if err := backend.SnapshotAndPull(ctx, req.VMName, destPath); err != nil { return nil, fmt.Errorf("snapshot and pull: %w", err) } - // Extract kernel from the pulled image - this is required for microVM boot - _, err := image.ExtractKernel(ctx, destPath) - if err != nil { - _ = os.Remove(destPath) - return nil, fmt.Errorf("extract kernel: %w", err) - } + // Kernel extraction is no longer needed - microVM provider uses a + // pre-downloaded kernel configured via microvm.kernel_path. // Get file size var sizeMB int64 @@ -169,8 +172,8 @@ func (p *Puller) checkCache(ctx context.Context, imageName string) (*PullResult, return nil, false } - // Verify the image and kernel still exist on disk - if !p.imgStore.HasImage(imageName) || !p.imgStore.HasKernel(imageName) { + // Verify the image still exists on disk + if !p.imgStore.HasImage(imageName) { _ = p.db.Delete(&cached).Error return nil, false } diff --git a/fluid-daemon/internal/snapshotpull/puller_test.go b/fluid-daemon/internal/snapshotpull/puller_test.go index a497191c..58fcb1e8 100644 --- a/fluid-daemon/internal/snapshotpull/puller_test.go +++ b/fluid-daemon/internal/snapshotpull/puller_test.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "sync" "sync/atomic" "testing" @@ -35,12 +34,7 @@ func (m *mockBackend) SnapshotAndPull(_ context.Context, vmName string, destPath return m.failErr } // Write a dummy qcow2 file - if err := os.WriteFile(destPath, []byte("fake-qcow2-data"), 0o644); err != nil { - return err - } - // Write a dummy kernel so ExtractKernel short-circuits - kernelPath := strings.TrimSuffix(destPath, ".qcow2") + ".vmlinux" - return os.WriteFile(kernelPath, []byte("fake-kernel"), 0o644) + return os.WriteFile(destPath, []byte("fake-qcow2-data"), 0o644) } func setupTestDB(t *testing.T) *gorm.DB { @@ -247,42 +241,40 @@ func TestPuller_BackendError(t *testing.T) { } } -func TestPuller_CacheMissWhenFileDeleted(t *testing.T) { +func TestPuller_FreshFallsBackToCache(t *testing.T) { db := setupTestDB(t) imgStore := setupTestImageStore(t) puller := NewPuller(imgStore, db, nil) - backend := &mockBackend{} + goodBackend := &mockBackend{} + failBackend := &mockBackend{failErr: fmt.Errorf("connection refused")} + + // Populate cache with a successful cached pull req := PullRequest{ SourceHost: "host1", VMName: "vm1", SnapshotMode: "cached", } - - // First pull - result1, err := puller.Pull(context.Background(), req, backend) + result1, err := puller.Pull(context.Background(), req, goodBackend) if err != nil { - t.Fatalf("first pull error: %v", err) + t.Fatalf("initial pull error: %v", err) } - // Delete the file manually - path := filepath.Join(imgStore.BaseDir(), result1.ImageName+".qcow2") - _ = os.Remove(path) - - // Second pull should be a cache miss - result2, err := puller.Pull(context.Background(), req, backend) + // Fresh pull with a failing backend should fall back to cache + req.SnapshotMode = "fresh" + result2, err := puller.Pull(context.Background(), req, failBackend) if err != nil { - t.Fatalf("second pull error: %v", err) + t.Fatalf("expected fallback to cache, got error: %v", err) } - if result2.Cached { - t.Error("expected cache miss when file deleted") + if !result2.Cached { + t.Error("expected Cached=true from fallback") } - if backend.callCount.Load() != 2 { - t.Errorf("expected 2 backend calls, got %d", backend.callCount.Load()) + if result2.ImageName != result1.ImageName { + t.Errorf("expected same image name, got %q vs %q", result2.ImageName, result1.ImageName) } } -func TestPuller_CacheMissWhenKernelDeleted(t *testing.T) { +func TestPuller_CacheMissWhenFileDeleted(t *testing.T) { db := setupTestDB(t) imgStore := setupTestImageStore(t) puller := NewPuller(imgStore, db, nil) @@ -300,17 +292,17 @@ func TestPuller_CacheMissWhenKernelDeleted(t *testing.T) { t.Fatalf("first pull error: %v", err) } - // Delete only the kernel file - kernelPath := filepath.Join(imgStore.BaseDir(), result1.ImageName+".vmlinux") - _ = os.Remove(kernelPath) + // Delete the file manually + path := filepath.Join(imgStore.BaseDir(), result1.ImageName+".qcow2") + _ = os.Remove(path) - // Second pull should be a cache miss (qcow2 exists but kernel missing) + // Second pull should be a cache miss result2, err := puller.Pull(context.Background(), req, backend) if err != nil { t.Fatalf("second pull error: %v", err) } if result2.Cached { - t.Error("expected cache miss when kernel deleted") + t.Error("expected cache miss when file deleted") } if backend.callCount.Load() != 2 { t.Errorf("expected 2 backend calls, got %d", backend.callCount.Load()) diff --git a/fluid-daemon/packaging/fluid-daemon.service b/fluid-daemon/packaging/fluid-daemon.service index 1d0b4220..ece5c41f 100644 --- a/fluid-daemon/packaging/fluid-daemon.service +++ b/fluid-daemon/packaging/fluid-daemon.service @@ -6,6 +6,7 @@ After=network.target libvirtd.service User=fluid-daemon Group=fluid-daemon ExecStart=/usr/local/bin/fluid-daemon --config /etc/fluid-daemon/daemon.yaml +AmbientCapabilities=CAP_NET_ADMIN Restart=on-failure RestartSec=5 LimitNOFILE=65536 diff --git a/go.work.sum b/go.work.sum index 2c31bd66..8b9d0b13 100644 --- a/go.work.sum +++ b/go.work.sum @@ -11,13 +11,13 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= diff --git a/proto/fluid/v1/daemon.proto b/proto/fluid/v1/daemon.proto index 67369a4d..53df2d8e 100644 --- a/proto/fluid/v1/daemon.proto +++ b/proto/fluid/v1/daemon.proto @@ -14,6 +14,7 @@ import "fluid/v1/host.proto"; service DaemonService { // Sandbox lifecycle rpc CreateSandbox(CreateSandboxCommand) returns (SandboxCreated); + rpc CreateSandboxStream(CreateSandboxCommand) returns (stream SandboxProgress); rpc GetSandbox(GetSandboxRequest) returns (SandboxInfo); rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); rpc DestroySandbox(DestroySandboxCommand) returns (SandboxDestroyed); @@ -39,6 +40,12 @@ service DaemonService { // Host discovery rpc DiscoverHosts(DiscoverHostsCommand) returns (DiscoverHostsResult); + + // Doctor checks + rpc DoctorCheck(DoctorCheckRequest) returns (DoctorCheckResponse); + + // Source host key scanning + rpc ScanSourceHostKeys(ScanSourceHostKeysRequest) returns (ScanSourceHostKeysResponse); } // GetSandboxRequest requests details for a single sandbox. @@ -81,6 +88,17 @@ message HostInfoResponse { int32 active_sandboxes = 6; repeated string base_images = 7; string ssh_ca_pub_key = 8; + string ssh_identity_pub_key = 9; + repeated SourceHostInfo source_hosts = 10; +} + +// SourceHostInfo describes a source host the daemon is configured to use. +// Returned in HostInfoResponse so the CLI can deploy the daemon's identity +// key to these hosts during setup. +message SourceHostInfo { + string address = 1; + string ssh_user = 2; + int32 ssh_port = 3; } // HealthRequest is an empty health check request. @@ -115,3 +133,36 @@ message DiscoveredHost { message DiscoverHostsResult { repeated DiscoveredHost hosts = 1; } + +// DoctorCheckRequest requests daemon-side health checks. +message DoctorCheckRequest {} + +// DoctorCheckResult holds the outcome of a single doctor check. +message DoctorCheckResult { + string name = 1; + string category = 2; + bool passed = 3; + string message = 4; + string fix_cmd = 5; +} + +// DoctorCheckResponse contains the results of all doctor checks. +message DoctorCheckResponse { + repeated DoctorCheckResult results = 1; +} + +// ScanSourceHostKeysRequest requests the daemon to scan and trust SSH host keys +// for all configured source hosts. +message ScanSourceHostKeysRequest {} + +// ScanSourceHostKeysResult holds the outcome of scanning a single source host's key. +message ScanSourceHostKeysResult { + string address = 1; + bool success = 2; + string error = 3; +} + +// ScanSourceHostKeysResponse contains the results of scanning all source host keys. +message ScanSourceHostKeysResponse { + repeated ScanSourceHostKeysResult results = 1; +} diff --git a/proto/fluid/v1/sandbox.proto b/proto/fluid/v1/sandbox.proto index ccb4fdd3..fcfa2504 100644 --- a/proto/fluid/v1/sandbox.proto +++ b/proto/fluid/v1/sandbox.proto @@ -151,3 +151,14 @@ message SnapshotCreated { string snapshot_id = 2; string snapshot_name = 3; } + +// SandboxProgress reports sandbox creation progress during streaming. +message SandboxProgress { + string sandbox_id = 1; + string step = 2; + int32 step_num = 3; + int32 total_steps = 4; + bool done = 5; + SandboxCreated result = 6; + string error = 7; +} diff --git a/proto/fluid/v1/source.proto b/proto/fluid/v1/source.proto index 7ad33cba..c05ab7ef 100644 --- a/proto/fluid/v1/source.proto +++ b/proto/fluid/v1/source.proto @@ -74,6 +74,7 @@ message SourceVMListEntry { string state = 2; string ip_address = 3; bool prepared = 4; + string host = 5; // source host address this VM lives on } // ValidateSourceVMCommand instructs the host to validate a source VM's diff --git a/proto/gen/go/fluid/v1/daemon.pb.go b/proto/gen/go/fluid/v1/daemon.pb.go index 3771e4dc..a10e4550 100644 --- a/proto/gen/go/fluid/v1/daemon.pb.go +++ b/proto/gen/go/fluid/v1/daemon.pb.go @@ -304,17 +304,19 @@ func (*GetHostInfoRequest) Descriptor() ([]byte, []int) { // HostInfoResponse contains host resource and capability information. type HostInfoResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - HostId string `protobuf:"bytes,1,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"` - Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"` - Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` - TotalCpus int32 `protobuf:"varint,4,opt,name=total_cpus,json=totalCpus,proto3" json:"total_cpus,omitempty"` - TotalMemoryMb int64 `protobuf:"varint,5,opt,name=total_memory_mb,json=totalMemoryMb,proto3" json:"total_memory_mb,omitempty"` - ActiveSandboxes int32 `protobuf:"varint,6,opt,name=active_sandboxes,json=activeSandboxes,proto3" json:"active_sandboxes,omitempty"` - BaseImages []string `protobuf:"bytes,7,rep,name=base_images,json=baseImages,proto3" json:"base_images,omitempty"` - SshCaPubKey string `protobuf:"bytes,8,opt,name=ssh_ca_pub_key,json=sshCaPubKey,proto3" json:"ssh_ca_pub_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + HostId string `protobuf:"bytes,1,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"` + Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"` + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + TotalCpus int32 `protobuf:"varint,4,opt,name=total_cpus,json=totalCpus,proto3" json:"total_cpus,omitempty"` + TotalMemoryMb int64 `protobuf:"varint,5,opt,name=total_memory_mb,json=totalMemoryMb,proto3" json:"total_memory_mb,omitempty"` + ActiveSandboxes int32 `protobuf:"varint,6,opt,name=active_sandboxes,json=activeSandboxes,proto3" json:"active_sandboxes,omitempty"` + BaseImages []string `protobuf:"bytes,7,rep,name=base_images,json=baseImages,proto3" json:"base_images,omitempty"` + SshCaPubKey string `protobuf:"bytes,8,opt,name=ssh_ca_pub_key,json=sshCaPubKey,proto3" json:"ssh_ca_pub_key,omitempty"` + SshIdentityPubKey string `protobuf:"bytes,9,opt,name=ssh_identity_pub_key,json=sshIdentityPubKey,proto3" json:"ssh_identity_pub_key,omitempty"` + SourceHosts []*SourceHostInfo `protobuf:"bytes,10,rep,name=source_hosts,json=sourceHosts,proto3" json:"source_hosts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *HostInfoResponse) Reset() { @@ -403,6 +405,83 @@ func (x *HostInfoResponse) GetSshCaPubKey() string { return "" } +func (x *HostInfoResponse) GetSshIdentityPubKey() string { + if x != nil { + return x.SshIdentityPubKey + } + return "" +} + +func (x *HostInfoResponse) GetSourceHosts() []*SourceHostInfo { + if x != nil { + return x.SourceHosts + } + return nil +} + +// SourceHostInfo describes a source host the daemon is configured to use. +// Returned in HostInfoResponse so the CLI can deploy the daemon's identity +// key to these hosts during setup. +type SourceHostInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + SshUser string `protobuf:"bytes,2,opt,name=ssh_user,json=sshUser,proto3" json:"ssh_user,omitempty"` + SshPort int32 `protobuf:"varint,3,opt,name=ssh_port,json=sshPort,proto3" json:"ssh_port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SourceHostInfo) Reset() { + *x = SourceHostInfo{} + mi := &file_fluid_v1_daemon_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SourceHostInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SourceHostInfo) ProtoMessage() {} + +func (x *SourceHostInfo) ProtoReflect() protoreflect.Message { + mi := &file_fluid_v1_daemon_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SourceHostInfo.ProtoReflect.Descriptor instead. +func (*SourceHostInfo) Descriptor() ([]byte, []int) { + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{6} +} + +func (x *SourceHostInfo) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *SourceHostInfo) GetSshUser() string { + if x != nil { + return x.SshUser + } + return "" +} + +func (x *SourceHostInfo) GetSshPort() int32 { + if x != nil { + return x.SshPort + } + return 0 +} + // HealthRequest is an empty health check request. type HealthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -412,7 +491,7 @@ type HealthRequest struct { func (x *HealthRequest) Reset() { *x = HealthRequest{} - mi := &file_fluid_v1_daemon_proto_msgTypes[6] + mi := &file_fluid_v1_daemon_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -424,7 +503,7 @@ func (x *HealthRequest) String() string { func (*HealthRequest) ProtoMessage() {} func (x *HealthRequest) ProtoReflect() protoreflect.Message { - mi := &file_fluid_v1_daemon_proto_msgTypes[6] + mi := &file_fluid_v1_daemon_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -437,7 +516,7 @@ func (x *HealthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. func (*HealthRequest) Descriptor() ([]byte, []int) { - return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{6} + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{7} } // HealthResponse indicates daemon health status. @@ -450,7 +529,7 @@ type HealthResponse struct { func (x *HealthResponse) Reset() { *x = HealthResponse{} - mi := &file_fluid_v1_daemon_proto_msgTypes[7] + mi := &file_fluid_v1_daemon_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -462,7 +541,7 @@ func (x *HealthResponse) String() string { func (*HealthResponse) ProtoMessage() {} func (x *HealthResponse) ProtoReflect() protoreflect.Message { - mi := &file_fluid_v1_daemon_proto_msgTypes[7] + mi := &file_fluid_v1_daemon_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -475,7 +554,7 @@ func (x *HealthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. func (*HealthResponse) Descriptor() ([]byte, []int) { - return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{7} + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{8} } func (x *HealthResponse) GetStatus() string { @@ -496,7 +575,7 @@ type DiscoverHostsCommand struct { func (x *DiscoverHostsCommand) Reset() { *x = DiscoverHostsCommand{} - mi := &file_fluid_v1_daemon_proto_msgTypes[8] + mi := &file_fluid_v1_daemon_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -508,7 +587,7 @@ func (x *DiscoverHostsCommand) String() string { func (*DiscoverHostsCommand) ProtoMessage() {} func (x *DiscoverHostsCommand) ProtoReflect() protoreflect.Message { - mi := &file_fluid_v1_daemon_proto_msgTypes[8] + mi := &file_fluid_v1_daemon_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -521,7 +600,7 @@ func (x *DiscoverHostsCommand) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoverHostsCommand.ProtoReflect.Descriptor instead. func (*DiscoverHostsCommand) Descriptor() ([]byte, []int) { - return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{8} + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{9} } func (x *DiscoverHostsCommand) GetSshConfigContent() string { @@ -550,7 +629,7 @@ type DiscoveredHost struct { func (x *DiscoveredHost) Reset() { *x = DiscoveredHost{} - mi := &file_fluid_v1_daemon_proto_msgTypes[9] + mi := &file_fluid_v1_daemon_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -562,7 +641,7 @@ func (x *DiscoveredHost) String() string { func (*DiscoveredHost) ProtoMessage() {} func (x *DiscoveredHost) ProtoReflect() protoreflect.Message { - mi := &file_fluid_v1_daemon_proto_msgTypes[9] + mi := &file_fluid_v1_daemon_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -575,7 +654,7 @@ func (x *DiscoveredHost) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoveredHost.ProtoReflect.Descriptor instead. func (*DiscoveredHost) Descriptor() ([]byte, []int) { - return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{9} + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{10} } func (x *DiscoveredHost) GetName() string { @@ -658,7 +737,7 @@ type DiscoverHostsResult struct { func (x *DiscoverHostsResult) Reset() { *x = DiscoverHostsResult{} - mi := &file_fluid_v1_daemon_proto_msgTypes[10] + mi := &file_fluid_v1_daemon_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -670,7 +749,7 @@ func (x *DiscoverHostsResult) String() string { func (*DiscoverHostsResult) ProtoMessage() {} func (x *DiscoverHostsResult) ProtoReflect() protoreflect.Message { - mi := &file_fluid_v1_daemon_proto_msgTypes[10] + mi := &file_fluid_v1_daemon_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -683,7 +762,7 @@ func (x *DiscoverHostsResult) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoverHostsResult.ProtoReflect.Descriptor instead. func (*DiscoverHostsResult) Descriptor() ([]byte, []int) { - return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{10} + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{11} } func (x *DiscoverHostsResult) GetHosts() []*DiscoveredHost { @@ -693,6 +772,309 @@ func (x *DiscoverHostsResult) GetHosts() []*DiscoveredHost { return nil } +// DoctorCheckRequest requests daemon-side health checks. +type DoctorCheckRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DoctorCheckRequest) Reset() { + *x = DoctorCheckRequest{} + mi := &file_fluid_v1_daemon_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DoctorCheckRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DoctorCheckRequest) ProtoMessage() {} + +func (x *DoctorCheckRequest) ProtoReflect() protoreflect.Message { + mi := &file_fluid_v1_daemon_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DoctorCheckRequest.ProtoReflect.Descriptor instead. +func (*DoctorCheckRequest) Descriptor() ([]byte, []int) { + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{12} +} + +// DoctorCheckResult holds the outcome of a single doctor check. +type DoctorCheckResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Category string `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"` + Passed bool `protobuf:"varint,3,opt,name=passed,proto3" json:"passed,omitempty"` + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + FixCmd string `protobuf:"bytes,5,opt,name=fix_cmd,json=fixCmd,proto3" json:"fix_cmd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DoctorCheckResult) Reset() { + *x = DoctorCheckResult{} + mi := &file_fluid_v1_daemon_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DoctorCheckResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DoctorCheckResult) ProtoMessage() {} + +func (x *DoctorCheckResult) ProtoReflect() protoreflect.Message { + mi := &file_fluid_v1_daemon_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DoctorCheckResult.ProtoReflect.Descriptor instead. +func (*DoctorCheckResult) Descriptor() ([]byte, []int) { + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{13} +} + +func (x *DoctorCheckResult) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *DoctorCheckResult) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +func (x *DoctorCheckResult) GetPassed() bool { + if x != nil { + return x.Passed + } + return false +} + +func (x *DoctorCheckResult) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *DoctorCheckResult) GetFixCmd() string { + if x != nil { + return x.FixCmd + } + return "" +} + +// DoctorCheckResponse contains the results of all doctor checks. +type DoctorCheckResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*DoctorCheckResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DoctorCheckResponse) Reset() { + *x = DoctorCheckResponse{} + mi := &file_fluid_v1_daemon_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DoctorCheckResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DoctorCheckResponse) ProtoMessage() {} + +func (x *DoctorCheckResponse) ProtoReflect() protoreflect.Message { + mi := &file_fluid_v1_daemon_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DoctorCheckResponse.ProtoReflect.Descriptor instead. +func (*DoctorCheckResponse) Descriptor() ([]byte, []int) { + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{14} +} + +func (x *DoctorCheckResponse) GetResults() []*DoctorCheckResult { + if x != nil { + return x.Results + } + return nil +} + +// ScanSourceHostKeysRequest requests the daemon to scan and trust SSH host keys +// for all configured source hosts. +type ScanSourceHostKeysRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ScanSourceHostKeysRequest) Reset() { + *x = ScanSourceHostKeysRequest{} + mi := &file_fluid_v1_daemon_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ScanSourceHostKeysRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanSourceHostKeysRequest) ProtoMessage() {} + +func (x *ScanSourceHostKeysRequest) ProtoReflect() protoreflect.Message { + mi := &file_fluid_v1_daemon_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanSourceHostKeysRequest.ProtoReflect.Descriptor instead. +func (*ScanSourceHostKeysRequest) Descriptor() ([]byte, []int) { + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{15} +} + +// ScanSourceHostKeysResult holds the outcome of scanning a single source host's key. +type ScanSourceHostKeysResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ScanSourceHostKeysResult) Reset() { + *x = ScanSourceHostKeysResult{} + mi := &file_fluid_v1_daemon_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ScanSourceHostKeysResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanSourceHostKeysResult) ProtoMessage() {} + +func (x *ScanSourceHostKeysResult) ProtoReflect() protoreflect.Message { + mi := &file_fluid_v1_daemon_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanSourceHostKeysResult.ProtoReflect.Descriptor instead. +func (*ScanSourceHostKeysResult) Descriptor() ([]byte, []int) { + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{16} +} + +func (x *ScanSourceHostKeysResult) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *ScanSourceHostKeysResult) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *ScanSourceHostKeysResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// ScanSourceHostKeysResponse contains the results of scanning all source host keys. +type ScanSourceHostKeysResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*ScanSourceHostKeysResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ScanSourceHostKeysResponse) Reset() { + *x = ScanSourceHostKeysResponse{} + mi := &file_fluid_v1_daemon_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ScanSourceHostKeysResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanSourceHostKeysResponse) ProtoMessage() {} + +func (x *ScanSourceHostKeysResponse) ProtoReflect() protoreflect.Message { + mi := &file_fluid_v1_daemon_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanSourceHostKeysResponse.ProtoReflect.Descriptor instead. +func (*ScanSourceHostKeysResponse) Descriptor() ([]byte, []int) { + return file_fluid_v1_daemon_proto_rawDescGZIP(), []int{17} +} + +func (x *ScanSourceHostKeysResponse) GetResults() []*ScanSourceHostKeysResult { + if x != nil { + return x.Results + } + return nil +} + var File_fluid_v1_daemon_proto protoreflect.FileDescriptor const file_fluid_v1_daemon_proto_rawDesc = "" + @@ -719,7 +1101,7 @@ const file_fluid_v1_daemon_proto_rawDesc = "" + "\x15ListSandboxesResponse\x123\n" + "\tsandboxes\x18\x01 \x03(\v2\x15.fluid.v1.SandboxInfoR\tsandboxes\x12\x14\n" + "\x05count\x18\x02 \x01(\x05R\x05count\"\x14\n" + - "\x12GetHostInfoRequest\"\x99\x02\n" + + "\x12GetHostInfoRequest\"\x87\x03\n" + "\x10HostInfoResponse\x12\x17\n" + "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12\x1a\n" + "\bhostname\x18\x02 \x01(\tR\bhostname\x12\x18\n" + @@ -730,7 +1112,14 @@ const file_fluid_v1_daemon_proto_rawDesc = "" + "\x10active_sandboxes\x18\x06 \x01(\x05R\x0factiveSandboxes\x12\x1f\n" + "\vbase_images\x18\a \x03(\tR\n" + "baseImages\x12#\n" + - "\x0essh_ca_pub_key\x18\b \x01(\tR\vsshCaPubKey\"\x0f\n" + + "\x0essh_ca_pub_key\x18\b \x01(\tR\vsshCaPubKey\x12/\n" + + "\x14ssh_identity_pub_key\x18\t \x01(\tR\x11sshIdentityPubKey\x12;\n" + + "\fsource_hosts\x18\n" + + " \x03(\v2\x18.fluid.v1.SourceHostInfoR\vsourceHosts\"`\n" + + "\x0eSourceHostInfo\x12\x18\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x19\n" + + "\bssh_user\x18\x02 \x01(\tR\asshUser\x12\x19\n" + + "\bssh_port\x18\x03 \x01(\x05R\asshPort\"\x0f\n" + "\rHealthRequest\"(\n" + "\x0eHealthResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\"D\n" + @@ -751,9 +1140,26 @@ const file_fluid_v1_daemon_proto_rawDesc = "" + "\x05error\x18\n" + " \x01(\tR\x05error\"E\n" + "\x13DiscoverHostsResult\x12.\n" + - "\x05hosts\x18\x01 \x03(\v2\x18.fluid.v1.DiscoveredHostR\x05hosts2\xc4\t\n" + + "\x05hosts\x18\x01 \x03(\v2\x18.fluid.v1.DiscoveredHostR\x05hosts\"\x14\n" + + "\x12DoctorCheckRequest\"\x8e\x01\n" + + "\x11DoctorCheckResult\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n" + + "\bcategory\x18\x02 \x01(\tR\bcategory\x12\x16\n" + + "\x06passed\x18\x03 \x01(\bR\x06passed\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x12\x17\n" + + "\afix_cmd\x18\x05 \x01(\tR\x06fixCmd\"L\n" + + "\x13DoctorCheckResponse\x125\n" + + "\aresults\x18\x01 \x03(\v2\x1b.fluid.v1.DoctorCheckResultR\aresults\"\x1b\n" + + "\x19ScanSourceHostKeysRequest\"d\n" + + "\x18ScanSourceHostKeysResult\x12\x18\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x18\n" + + "\asuccess\x18\x02 \x01(\bR\asuccess\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"Z\n" + + "\x1aScanSourceHostKeysResponse\x12<\n" + + "\aresults\x18\x01 \x03(\v2\".fluid.v1.ScanSourceHostKeysResultR\aresults2\xc5\v\n" + "\rDaemonService\x12I\n" + - "\rCreateSandbox\x12\x1e.fluid.v1.CreateSandboxCommand\x1a\x18.fluid.v1.SandboxCreated\x12@\n" + + "\rCreateSandbox\x12\x1e.fluid.v1.CreateSandboxCommand\x1a\x18.fluid.v1.SandboxCreated\x12R\n" + + "\x13CreateSandboxStream\x12\x1e.fluid.v1.CreateSandboxCommand\x1a\x19.fluid.v1.SandboxProgress0\x01\x12@\n" + "\n" + "GetSandbox\x12\x1b.fluid.v1.GetSandboxRequest\x1a\x15.fluid.v1.SandboxInfo\x12P\n" + "\rListSandboxes\x12\x1e.fluid.v1.ListSandboxesRequest\x1a\x1f.fluid.v1.ListSandboxesResponse\x12M\n" + @@ -770,7 +1176,9 @@ const file_fluid_v1_daemon_proto_rawDesc = "" + "\x0eReadSourceFile\x12\x1f.fluid.v1.ReadSourceFileCommand\x1a\x1a.fluid.v1.SourceFileResult\x12G\n" + "\vGetHostInfo\x12\x1c.fluid.v1.GetHostInfoRequest\x1a\x1a.fluid.v1.HostInfoResponse\x12;\n" + "\x06Health\x12\x17.fluid.v1.HealthRequest\x1a\x18.fluid.v1.HealthResponse\x12N\n" + - "\rDiscoverHosts\x12\x1e.fluid.v1.DiscoverHostsCommand\x1a\x1d.fluid.v1.DiscoverHostsResultB fluid.v1.SandboxInfo - 9, // 1: fluid.v1.DiscoverHostsResult.hosts:type_name -> fluid.v1.DiscoveredHost - 11, // 2: fluid.v1.DaemonService.CreateSandbox:input_type -> fluid.v1.CreateSandboxCommand - 0, // 3: fluid.v1.DaemonService.GetSandbox:input_type -> fluid.v1.GetSandboxRequest - 2, // 4: fluid.v1.DaemonService.ListSandboxes:input_type -> fluid.v1.ListSandboxesRequest - 12, // 5: fluid.v1.DaemonService.DestroySandbox:input_type -> fluid.v1.DestroySandboxCommand - 13, // 6: fluid.v1.DaemonService.StartSandbox:input_type -> fluid.v1.StartSandboxCommand - 14, // 7: fluid.v1.DaemonService.StopSandbox:input_type -> fluid.v1.StopSandboxCommand - 15, // 8: fluid.v1.DaemonService.RunCommand:input_type -> fluid.v1.RunCommandCommand - 16, // 9: fluid.v1.DaemonService.CreateSnapshot:input_type -> fluid.v1.SnapshotCommand - 17, // 10: fluid.v1.DaemonService.ListSourceVMs:input_type -> fluid.v1.ListSourceVMsCommand - 18, // 11: fluid.v1.DaemonService.ValidateSourceVM:input_type -> fluid.v1.ValidateSourceVMCommand - 19, // 12: fluid.v1.DaemonService.PrepareSourceVM:input_type -> fluid.v1.PrepareSourceVMCommand - 20, // 13: fluid.v1.DaemonService.RunSourceCommand:input_type -> fluid.v1.RunSourceCommandCommand - 21, // 14: fluid.v1.DaemonService.ReadSourceFile:input_type -> fluid.v1.ReadSourceFileCommand - 4, // 15: fluid.v1.DaemonService.GetHostInfo:input_type -> fluid.v1.GetHostInfoRequest - 6, // 16: fluid.v1.DaemonService.Health:input_type -> fluid.v1.HealthRequest - 8, // 17: fluid.v1.DaemonService.DiscoverHosts:input_type -> fluid.v1.DiscoverHostsCommand - 22, // 18: fluid.v1.DaemonService.CreateSandbox:output_type -> fluid.v1.SandboxCreated - 1, // 19: fluid.v1.DaemonService.GetSandbox:output_type -> fluid.v1.SandboxInfo - 3, // 20: fluid.v1.DaemonService.ListSandboxes:output_type -> fluid.v1.ListSandboxesResponse - 23, // 21: fluid.v1.DaemonService.DestroySandbox:output_type -> fluid.v1.SandboxDestroyed - 24, // 22: fluid.v1.DaemonService.StartSandbox:output_type -> fluid.v1.SandboxStarted - 25, // 23: fluid.v1.DaemonService.StopSandbox:output_type -> fluid.v1.SandboxStopped - 26, // 24: fluid.v1.DaemonService.RunCommand:output_type -> fluid.v1.CommandResult - 27, // 25: fluid.v1.DaemonService.CreateSnapshot:output_type -> fluid.v1.SnapshotCreated - 28, // 26: fluid.v1.DaemonService.ListSourceVMs:output_type -> fluid.v1.SourceVMsList - 29, // 27: fluid.v1.DaemonService.ValidateSourceVM:output_type -> fluid.v1.SourceVMValidation - 30, // 28: fluid.v1.DaemonService.PrepareSourceVM:output_type -> fluid.v1.SourceVMPrepared - 31, // 29: fluid.v1.DaemonService.RunSourceCommand:output_type -> fluid.v1.SourceCommandResult - 32, // 30: fluid.v1.DaemonService.ReadSourceFile:output_type -> fluid.v1.SourceFileResult - 5, // 31: fluid.v1.DaemonService.GetHostInfo:output_type -> fluid.v1.HostInfoResponse - 7, // 32: fluid.v1.DaemonService.Health:output_type -> fluid.v1.HealthResponse - 10, // 33: fluid.v1.DaemonService.DiscoverHosts:output_type -> fluid.v1.DiscoverHostsResult - 18, // [18:34] is the sub-list for method output_type - 2, // [2:18] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 6, // 1: fluid.v1.HostInfoResponse.source_hosts:type_name -> fluid.v1.SourceHostInfo + 10, // 2: fluid.v1.DiscoverHostsResult.hosts:type_name -> fluid.v1.DiscoveredHost + 13, // 3: fluid.v1.DoctorCheckResponse.results:type_name -> fluid.v1.DoctorCheckResult + 16, // 4: fluid.v1.ScanSourceHostKeysResponse.results:type_name -> fluid.v1.ScanSourceHostKeysResult + 18, // 5: fluid.v1.DaemonService.CreateSandbox:input_type -> fluid.v1.CreateSandboxCommand + 18, // 6: fluid.v1.DaemonService.CreateSandboxStream:input_type -> fluid.v1.CreateSandboxCommand + 0, // 7: fluid.v1.DaemonService.GetSandbox:input_type -> fluid.v1.GetSandboxRequest + 2, // 8: fluid.v1.DaemonService.ListSandboxes:input_type -> fluid.v1.ListSandboxesRequest + 19, // 9: fluid.v1.DaemonService.DestroySandbox:input_type -> fluid.v1.DestroySandboxCommand + 20, // 10: fluid.v1.DaemonService.StartSandbox:input_type -> fluid.v1.StartSandboxCommand + 21, // 11: fluid.v1.DaemonService.StopSandbox:input_type -> fluid.v1.StopSandboxCommand + 22, // 12: fluid.v1.DaemonService.RunCommand:input_type -> fluid.v1.RunCommandCommand + 23, // 13: fluid.v1.DaemonService.CreateSnapshot:input_type -> fluid.v1.SnapshotCommand + 24, // 14: fluid.v1.DaemonService.ListSourceVMs:input_type -> fluid.v1.ListSourceVMsCommand + 25, // 15: fluid.v1.DaemonService.ValidateSourceVM:input_type -> fluid.v1.ValidateSourceVMCommand + 26, // 16: fluid.v1.DaemonService.PrepareSourceVM:input_type -> fluid.v1.PrepareSourceVMCommand + 27, // 17: fluid.v1.DaemonService.RunSourceCommand:input_type -> fluid.v1.RunSourceCommandCommand + 28, // 18: fluid.v1.DaemonService.ReadSourceFile:input_type -> fluid.v1.ReadSourceFileCommand + 4, // 19: fluid.v1.DaemonService.GetHostInfo:input_type -> fluid.v1.GetHostInfoRequest + 7, // 20: fluid.v1.DaemonService.Health:input_type -> fluid.v1.HealthRequest + 9, // 21: fluid.v1.DaemonService.DiscoverHosts:input_type -> fluid.v1.DiscoverHostsCommand + 12, // 22: fluid.v1.DaemonService.DoctorCheck:input_type -> fluid.v1.DoctorCheckRequest + 15, // 23: fluid.v1.DaemonService.ScanSourceHostKeys:input_type -> fluid.v1.ScanSourceHostKeysRequest + 29, // 24: fluid.v1.DaemonService.CreateSandbox:output_type -> fluid.v1.SandboxCreated + 30, // 25: fluid.v1.DaemonService.CreateSandboxStream:output_type -> fluid.v1.SandboxProgress + 1, // 26: fluid.v1.DaemonService.GetSandbox:output_type -> fluid.v1.SandboxInfo + 3, // 27: fluid.v1.DaemonService.ListSandboxes:output_type -> fluid.v1.ListSandboxesResponse + 31, // 28: fluid.v1.DaemonService.DestroySandbox:output_type -> fluid.v1.SandboxDestroyed + 32, // 29: fluid.v1.DaemonService.StartSandbox:output_type -> fluid.v1.SandboxStarted + 33, // 30: fluid.v1.DaemonService.StopSandbox:output_type -> fluid.v1.SandboxStopped + 34, // 31: fluid.v1.DaemonService.RunCommand:output_type -> fluid.v1.CommandResult + 35, // 32: fluid.v1.DaemonService.CreateSnapshot:output_type -> fluid.v1.SnapshotCreated + 36, // 33: fluid.v1.DaemonService.ListSourceVMs:output_type -> fluid.v1.SourceVMsList + 37, // 34: fluid.v1.DaemonService.ValidateSourceVM:output_type -> fluid.v1.SourceVMValidation + 38, // 35: fluid.v1.DaemonService.PrepareSourceVM:output_type -> fluid.v1.SourceVMPrepared + 39, // 36: fluid.v1.DaemonService.RunSourceCommand:output_type -> fluid.v1.SourceCommandResult + 40, // 37: fluid.v1.DaemonService.ReadSourceFile:output_type -> fluid.v1.SourceFileResult + 5, // 38: fluid.v1.DaemonService.GetHostInfo:output_type -> fluid.v1.HostInfoResponse + 8, // 39: fluid.v1.DaemonService.Health:output_type -> fluid.v1.HealthResponse + 11, // 40: fluid.v1.DaemonService.DiscoverHosts:output_type -> fluid.v1.DiscoverHostsResult + 14, // 41: fluid.v1.DaemonService.DoctorCheck:output_type -> fluid.v1.DoctorCheckResponse + 17, // 42: fluid.v1.DaemonService.ScanSourceHostKeys:output_type -> fluid.v1.ScanSourceHostKeysResponse + 24, // [24:43] is the sub-list for method output_type + 5, // [5:24] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_fluid_v1_daemon_proto_init() } @@ -876,7 +1301,7 @@ func file_fluid_v1_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_fluid_v1_daemon_proto_rawDesc), len(file_fluid_v1_daemon_proto_rawDesc)), NumEnums: 0, - NumMessages: 11, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/gen/go/fluid/v1/daemon_grpc.pb.go b/proto/gen/go/fluid/v1/daemon_grpc.pb.go index afe0a960..67b65a5b 100644 --- a/proto/gen/go/fluid/v1/daemon_grpc.pb.go +++ b/proto/gen/go/fluid/v1/daemon_grpc.pb.go @@ -19,22 +19,25 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - DaemonService_CreateSandbox_FullMethodName = "/fluid.v1.DaemonService/CreateSandbox" - DaemonService_GetSandbox_FullMethodName = "/fluid.v1.DaemonService/GetSandbox" - DaemonService_ListSandboxes_FullMethodName = "/fluid.v1.DaemonService/ListSandboxes" - DaemonService_DestroySandbox_FullMethodName = "/fluid.v1.DaemonService/DestroySandbox" - DaemonService_StartSandbox_FullMethodName = "/fluid.v1.DaemonService/StartSandbox" - DaemonService_StopSandbox_FullMethodName = "/fluid.v1.DaemonService/StopSandbox" - DaemonService_RunCommand_FullMethodName = "/fluid.v1.DaemonService/RunCommand" - DaemonService_CreateSnapshot_FullMethodName = "/fluid.v1.DaemonService/CreateSnapshot" - DaemonService_ListSourceVMs_FullMethodName = "/fluid.v1.DaemonService/ListSourceVMs" - DaemonService_ValidateSourceVM_FullMethodName = "/fluid.v1.DaemonService/ValidateSourceVM" - DaemonService_PrepareSourceVM_FullMethodName = "/fluid.v1.DaemonService/PrepareSourceVM" - DaemonService_RunSourceCommand_FullMethodName = "/fluid.v1.DaemonService/RunSourceCommand" - DaemonService_ReadSourceFile_FullMethodName = "/fluid.v1.DaemonService/ReadSourceFile" - DaemonService_GetHostInfo_FullMethodName = "/fluid.v1.DaemonService/GetHostInfo" - DaemonService_Health_FullMethodName = "/fluid.v1.DaemonService/Health" - DaemonService_DiscoverHosts_FullMethodName = "/fluid.v1.DaemonService/DiscoverHosts" + DaemonService_CreateSandbox_FullMethodName = "/fluid.v1.DaemonService/CreateSandbox" + DaemonService_CreateSandboxStream_FullMethodName = "/fluid.v1.DaemonService/CreateSandboxStream" + DaemonService_GetSandbox_FullMethodName = "/fluid.v1.DaemonService/GetSandbox" + DaemonService_ListSandboxes_FullMethodName = "/fluid.v1.DaemonService/ListSandboxes" + DaemonService_DestroySandbox_FullMethodName = "/fluid.v1.DaemonService/DestroySandbox" + DaemonService_StartSandbox_FullMethodName = "/fluid.v1.DaemonService/StartSandbox" + DaemonService_StopSandbox_FullMethodName = "/fluid.v1.DaemonService/StopSandbox" + DaemonService_RunCommand_FullMethodName = "/fluid.v1.DaemonService/RunCommand" + DaemonService_CreateSnapshot_FullMethodName = "/fluid.v1.DaemonService/CreateSnapshot" + DaemonService_ListSourceVMs_FullMethodName = "/fluid.v1.DaemonService/ListSourceVMs" + DaemonService_ValidateSourceVM_FullMethodName = "/fluid.v1.DaemonService/ValidateSourceVM" + DaemonService_PrepareSourceVM_FullMethodName = "/fluid.v1.DaemonService/PrepareSourceVM" + DaemonService_RunSourceCommand_FullMethodName = "/fluid.v1.DaemonService/RunSourceCommand" + DaemonService_ReadSourceFile_FullMethodName = "/fluid.v1.DaemonService/ReadSourceFile" + DaemonService_GetHostInfo_FullMethodName = "/fluid.v1.DaemonService/GetHostInfo" + DaemonService_Health_FullMethodName = "/fluid.v1.DaemonService/Health" + DaemonService_DiscoverHosts_FullMethodName = "/fluid.v1.DaemonService/DiscoverHosts" + DaemonService_DoctorCheck_FullMethodName = "/fluid.v1.DaemonService/DoctorCheck" + DaemonService_ScanSourceHostKeys_FullMethodName = "/fluid.v1.DaemonService/ScanSourceHostKeys" ) // DaemonServiceClient is the client API for DaemonService service. @@ -47,6 +50,7 @@ const ( type DaemonServiceClient interface { // Sandbox lifecycle CreateSandbox(ctx context.Context, in *CreateSandboxCommand, opts ...grpc.CallOption) (*SandboxCreated, error) + CreateSandboxStream(ctx context.Context, in *CreateSandboxCommand, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxProgress], error) GetSandbox(ctx context.Context, in *GetSandboxRequest, opts ...grpc.CallOption) (*SandboxInfo, error) ListSandboxes(ctx context.Context, in *ListSandboxesRequest, opts ...grpc.CallOption) (*ListSandboxesResponse, error) DestroySandbox(ctx context.Context, in *DestroySandboxCommand, opts ...grpc.CallOption) (*SandboxDestroyed, error) @@ -67,6 +71,10 @@ type DaemonServiceClient interface { Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) // Host discovery DiscoverHosts(ctx context.Context, in *DiscoverHostsCommand, opts ...grpc.CallOption) (*DiscoverHostsResult, error) + // Doctor checks + DoctorCheck(ctx context.Context, in *DoctorCheckRequest, opts ...grpc.CallOption) (*DoctorCheckResponse, error) + // Source host key scanning + ScanSourceHostKeys(ctx context.Context, in *ScanSourceHostKeysRequest, opts ...grpc.CallOption) (*ScanSourceHostKeysResponse, error) } type daemonServiceClient struct { @@ -87,6 +95,25 @@ func (c *daemonServiceClient) CreateSandbox(ctx context.Context, in *CreateSandb return out, nil } +func (c *daemonServiceClient) CreateSandboxStream(ctx context.Context, in *CreateSandboxCommand, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxProgress], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_CreateSandboxStream_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[CreateSandboxCommand, SandboxProgress]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_CreateSandboxStreamClient = grpc.ServerStreamingClient[SandboxProgress] + func (c *daemonServiceClient) GetSandbox(ctx context.Context, in *GetSandboxRequest, opts ...grpc.CallOption) (*SandboxInfo, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SandboxInfo) @@ -237,6 +264,26 @@ func (c *daemonServiceClient) DiscoverHosts(ctx context.Context, in *DiscoverHos return out, nil } +func (c *daemonServiceClient) DoctorCheck(ctx context.Context, in *DoctorCheckRequest, opts ...grpc.CallOption) (*DoctorCheckResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DoctorCheckResponse) + err := c.cc.Invoke(ctx, DaemonService_DoctorCheck_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) ScanSourceHostKeys(ctx context.Context, in *ScanSourceHostKeysRequest, opts ...grpc.CallOption) (*ScanSourceHostKeysResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ScanSourceHostKeysResponse) + err := c.cc.Invoke(ctx, DaemonService_ScanSourceHostKeys_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer // for forward compatibility. @@ -247,6 +294,7 @@ func (c *daemonServiceClient) DiscoverHosts(ctx context.Context, in *DiscoverHos type DaemonServiceServer interface { // Sandbox lifecycle CreateSandbox(context.Context, *CreateSandboxCommand) (*SandboxCreated, error) + CreateSandboxStream(*CreateSandboxCommand, grpc.ServerStreamingServer[SandboxProgress]) error GetSandbox(context.Context, *GetSandboxRequest) (*SandboxInfo, error) ListSandboxes(context.Context, *ListSandboxesRequest) (*ListSandboxesResponse, error) DestroySandbox(context.Context, *DestroySandboxCommand) (*SandboxDestroyed, error) @@ -267,6 +315,10 @@ type DaemonServiceServer interface { Health(context.Context, *HealthRequest) (*HealthResponse, error) // Host discovery DiscoverHosts(context.Context, *DiscoverHostsCommand) (*DiscoverHostsResult, error) + // Doctor checks + DoctorCheck(context.Context, *DoctorCheckRequest) (*DoctorCheckResponse, error) + // Source host key scanning + ScanSourceHostKeys(context.Context, *ScanSourceHostKeysRequest) (*ScanSourceHostKeysResponse, error) mustEmbedUnimplementedDaemonServiceServer() } @@ -280,6 +332,9 @@ type UnimplementedDaemonServiceServer struct{} func (UnimplementedDaemonServiceServer) CreateSandbox(context.Context, *CreateSandboxCommand) (*SandboxCreated, error) { return nil, status.Error(codes.Unimplemented, "method CreateSandbox not implemented") } +func (UnimplementedDaemonServiceServer) CreateSandboxStream(*CreateSandboxCommand, grpc.ServerStreamingServer[SandboxProgress]) error { + return status.Error(codes.Unimplemented, "method CreateSandboxStream not implemented") +} func (UnimplementedDaemonServiceServer) GetSandbox(context.Context, *GetSandboxRequest) (*SandboxInfo, error) { return nil, status.Error(codes.Unimplemented, "method GetSandbox not implemented") } @@ -325,6 +380,12 @@ func (UnimplementedDaemonServiceServer) Health(context.Context, *HealthRequest) func (UnimplementedDaemonServiceServer) DiscoverHosts(context.Context, *DiscoverHostsCommand) (*DiscoverHostsResult, error) { return nil, status.Error(codes.Unimplemented, "method DiscoverHosts not implemented") } +func (UnimplementedDaemonServiceServer) DoctorCheck(context.Context, *DoctorCheckRequest) (*DoctorCheckResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DoctorCheck not implemented") +} +func (UnimplementedDaemonServiceServer) ScanSourceHostKeys(context.Context, *ScanSourceHostKeysRequest) (*ScanSourceHostKeysResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ScanSourceHostKeys not implemented") +} func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {} @@ -364,6 +425,17 @@ func _DaemonService_CreateSandbox_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _DaemonService_CreateSandboxStream_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(CreateSandboxCommand) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DaemonServiceServer).CreateSandboxStream(m, &grpc.GenericServerStream[CreateSandboxCommand, SandboxProgress]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_CreateSandboxStreamServer = grpc.ServerStreamingServer[SandboxProgress] + func _DaemonService_GetSandbox_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetSandboxRequest) if err := dec(in); err != nil { @@ -634,6 +706,42 @@ func _DaemonService_DiscoverHosts_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _DaemonService_DoctorCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DoctorCheckRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).DoctorCheck(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_DoctorCheck_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).DoctorCheck(ctx, req.(*DoctorCheckRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_ScanSourceHostKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ScanSourceHostKeysRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).ScanSourceHostKeys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_ScanSourceHostKeys_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).ScanSourceHostKeys(ctx, req.(*ScanSourceHostKeysRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -705,7 +813,21 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DiscoverHosts", Handler: _DaemonService_DiscoverHosts_Handler, }, + { + MethodName: "DoctorCheck", + Handler: _DaemonService_DoctorCheck_Handler, + }, + { + MethodName: "ScanSourceHostKeys", + Handler: _DaemonService_ScanSourceHostKeys_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "CreateSandboxStream", + Handler: _DaemonService_CreateSandboxStream_Handler, + ServerStreams: true, + }, }, - Streams: []grpc.StreamDesc{}, Metadata: "fluid/v1/daemon.proto", } diff --git a/proto/gen/go/fluid/v1/sandbox.pb.go b/proto/gen/go/fluid/v1/sandbox.pb.go index c78f1a5e..b4fe79c8 100644 --- a/proto/gen/go/fluid/v1/sandbox.pb.go +++ b/proto/gen/go/fluid/v1/sandbox.pb.go @@ -1066,6 +1066,99 @@ func (x *SnapshotCreated) GetSnapshotName() string { return "" } +// SandboxProgress reports sandbox creation progress during streaming. +type SandboxProgress struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Step string `protobuf:"bytes,2,opt,name=step,proto3" json:"step,omitempty"` + StepNum int32 `protobuf:"varint,3,opt,name=step_num,json=stepNum,proto3" json:"step_num,omitempty"` + TotalSteps int32 `protobuf:"varint,4,opt,name=total_steps,json=totalSteps,proto3" json:"total_steps,omitempty"` + Done bool `protobuf:"varint,5,opt,name=done,proto3" json:"done,omitempty"` + Result *SandboxCreated `protobuf:"bytes,6,opt,name=result,proto3" json:"result,omitempty"` + Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxProgress) Reset() { + *x = SandboxProgress{} + mi := &file_fluid_v1_sandbox_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxProgress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxProgress) ProtoMessage() {} + +func (x *SandboxProgress) ProtoReflect() protoreflect.Message { + mi := &file_fluid_v1_sandbox_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxProgress.ProtoReflect.Descriptor instead. +func (*SandboxProgress) Descriptor() ([]byte, []int) { + return file_fluid_v1_sandbox_proto_rawDescGZIP(), []int{14} +} + +func (x *SandboxProgress) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SandboxProgress) GetStep() string { + if x != nil { + return x.Step + } + return "" +} + +func (x *SandboxProgress) GetStepNum() int32 { + if x != nil { + return x.StepNum + } + return 0 +} + +func (x *SandboxProgress) GetTotalSteps() int32 { + if x != nil { + return x.TotalSteps + } + return 0 +} + +func (x *SandboxProgress) GetDone() bool { + if x != nil { + return x.Done + } + return false +} + +func (x *SandboxProgress) GetResult() *SandboxCreated { + if x != nil { + return x.Result + } + return nil +} + +func (x *SandboxProgress) GetError() string { + if x != nil { + return x.Error + } + return "" +} + var File_fluid_v1_sandbox_proto protoreflect.FileDescriptor const file_fluid_v1_sandbox_proto_rawDesc = "" + @@ -1167,7 +1260,17 @@ const file_fluid_v1_sandbox_proto_rawDesc = "" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + "\vsnapshot_id\x18\x02 \x01(\tR\n" + "snapshotId\x12#\n" + - "\rsnapshot_name\x18\x03 \x01(\tR\fsnapshotName*A\n" + + "\rsnapshot_name\x18\x03 \x01(\tR\fsnapshotName\"\xdc\x01\n" + + "\x0fSandboxProgress\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + + "\x04step\x18\x02 \x01(\tR\x04step\x12\x19\n" + + "\bstep_num\x18\x03 \x01(\x05R\astepNum\x12\x1f\n" + + "\vtotal_steps\x18\x04 \x01(\x05R\n" + + "totalSteps\x12\x12\n" + + "\x04done\x18\x05 \x01(\bR\x04done\x120\n" + + "\x06result\x18\x06 \x01(\v2\x18.fluid.v1.SandboxCreatedR\x06result\x12\x14\n" + + "\x05error\x18\a \x01(\tR\x05error*A\n" + "\fSnapshotMode\x12\x18\n" + "\x14SNAPSHOT_MODE_CACHED\x10\x00\x12\x17\n" + "\x13SNAPSHOT_MODE_FRESH\x10\x01B fluid.v1.SnapshotMode 1, // 1: fluid.v1.CreateSandboxCommand.source_host_connection:type_name -> fluid.v1.SourceHostConnection - 15, // 2: fluid.v1.RunCommandCommand.env:type_name -> fluid.v1.RunCommandCommand.EnvEntry - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 16, // 2: fluid.v1.RunCommandCommand.env:type_name -> fluid.v1.RunCommandCommand.EnvEntry + 3, // 3: fluid.v1.SandboxProgress.result:type_name -> fluid.v1.SandboxCreated + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_fluid_v1_sandbox_proto_init() } @@ -1226,7 +1331,7 @@ func file_fluid_v1_sandbox_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_fluid_v1_sandbox_proto_rawDesc), len(file_fluid_v1_sandbox_proto_rawDesc)), NumEnums: 1, - NumMessages: 15, + NumMessages: 16, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/gen/go/fluid/v1/source.pb.go b/proto/gen/go/fluid/v1/source.pb.go index 3de4cab5..10d2ba8e 100644 --- a/proto/gen/go/fluid/v1/source.pb.go +++ b/proto/gen/go/fluid/v1/source.pb.go @@ -557,6 +557,7 @@ type SourceVMListEntry struct { State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` IpAddress string `protobuf:"bytes,3,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` Prepared bool `protobuf:"varint,4,opt,name=prepared,proto3" json:"prepared,omitempty"` + Host string `protobuf:"bytes,5,opt,name=host,proto3" json:"host,omitempty"` // source host address this VM lives on unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -619,6 +620,13 @@ func (x *SourceVMListEntry) GetPrepared() bool { return false } +func (x *SourceVMListEntry) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + // ValidateSourceVMCommand instructs the host to validate a source VM's // readiness for read-only access. type ValidateSourceVMCommand struct { @@ -817,13 +825,14 @@ const file_fluid_v1_source_proto_rawDesc = "" + "\x14ListSourceVMsCommand\x12T\n" + "\x16source_host_connection\x18\x01 \x01(\v2\x1e.fluid.v1.SourceHostConnectionR\x14sourceHostConnection\">\n" + "\rSourceVMsList\x12-\n" + - "\x03vms\x18\x01 \x03(\v2\x1b.fluid.v1.SourceVMListEntryR\x03vms\"x\n" + + "\x03vms\x18\x01 \x03(\v2\x1b.fluid.v1.SourceVMListEntryR\x03vms\"\x8c\x01\n" + "\x11SourceVMListEntry\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05state\x18\x02 \x01(\tR\x05state\x12\x1d\n" + "\n" + "ip_address\x18\x03 \x01(\tR\tipAddress\x12\x1a\n" + - "\bprepared\x18\x04 \x01(\bR\bprepared\"\x8c\x01\n" + + "\bprepared\x18\x04 \x01(\bR\bprepared\x12\x12\n" + + "\x04host\x18\x05 \x01(\tR\x04host\"\x8c\x01\n" + "\x17ValidateSourceVMCommand\x12\x1b\n" + "\tsource_vm\x18\x01 \x01(\tR\bsourceVm\x12T\n" + "\x16source_host_connection\x18\x02 \x01(\v2\x1e.fluid.v1.SourceHostConnectionR\x14sourceHostConnection\"\xf2\x01\n" + diff --git a/scripts/nginx-cert-typo.sh b/scripts/nginx-cert-typo.sh new file mode 100755 index 00000000..5e2c2d57 --- /dev/null +++ b/scripts/nginx-cert-typo.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +# nginx SSL Cert Path Typo Demo +# Sets up nginx with an HTTPS config that references the wrong certificate path. +# The cert lives at /etc/ssl/certs/app-prod.pem but the config says app.pem. +# nginx fails to start because the file doesn't exist at the configured path. +# The root cause is discoverable through logs and a quick ls comparison. +# +# Usage: +# ./scripts/nginx-cert-typo.sh # Setup the broken scenario +# ./scripts/nginx-cert-typo.sh --cleanup # Tear everything down + +usage() { + echo "Usage: $0 [--cleanup]" + echo "" + echo " ssh-host SSH destination (e.g., user@192.168.1.100)" + echo " --cleanup Remove nginx, certs, and all configs" + exit 1 +} + +if [[ $# -lt 1 ]]; then + usage +fi + +SSH_HOST="$1" +CLEANUP="${2:-}" + +# --------------------------------------------------------------------------- +# Cleanup mode +# --------------------------------------------------------------------------- +if [[ "$CLEANUP" == "--cleanup" ]]; then + echo "==> Cleaning up $SSH_HOST..." + ssh "$SSH_HOST" sudo bash -s <<'CLEANUP_EOF' +set -euo pipefail + +echo "[*] Stopping nginx..." +systemctl stop nginx 2>/dev/null || true + +echo "[*] Removing packages..." +apt-get purge -y nginx nginx-common >/dev/null 2>&1 || true +apt-get autoremove -y >/dev/null 2>&1 || true + +echo "[*] Removing leftover files..." +rm -f /etc/ssl/certs/app-prod.pem +rm -f /etc/ssl/private/app.key +rm -f /etc/nginx/sites-enabled/app +rm -f /etc/nginx/sites-available/app + +echo "[+] Cleanup complete." +CLEANUP_EOF + exit 0 +fi + +# --------------------------------------------------------------------------- +# Setup mode +# --------------------------------------------------------------------------- +echo "==> Setting up nginx cert path typo demo on $SSH_HOST..." + +ssh "$SSH_HOST" sudo bash -s <<'SETUP_EOF' +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive + +# -- Install nginx and openssl ------------------------------------------------ +echo "[*] Installing nginx and openssl..." +apt-get update -qq +apt-get install -y -qq nginx openssl >/dev/null + +# -- Generate a self-signed cert (actual file is app-prod.pem) ---------------- +echo "[*] Generating self-signed certificate..." +openssl genrsa -out /etc/ssl/private/app.key 2048 2>/dev/null +openssl req -new -x509 -key /etc/ssl/private/app.key \ + -out /etc/ssl/certs/app-prod.pem -days 365 \ + -subj "/CN=demo.fluid.sh" 2>/dev/null +chmod 640 /etc/ssl/private/app.key +echo "[+] Cert written to /etc/ssl/certs/app-prod.pem" + +# -- Write a working HTTP config and start nginx ------------------------------ +echo "[*] Writing working HTTP config..." +cat > /etc/nginx/sites-available/app <<'NGINX' +server { + listen 80; + server_name _; + location / { + return 200 'Hello from nginx\n'; + add_header Content-Type text/plain; + } +} +NGINX + +rm -f /etc/nginx/sites-enabled/default +ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app + +systemctl restart nginx +echo "[+] nginx running on port 80." + +# -- Generate traffic so logs have entries ------------------------------------ +echo "[*] Generating traffic to populate logs..." +for i in $(seq 1 8); do + curl -s -o /dev/null http://localhost/ || true +done +sleep 2 +echo "[+] Traffic generated, access log should have entries." + +# -- Overwrite with broken HTTPS config (typo: app.pem instead of app-prod.pem) +echo "[*] Overwriting nginx config with broken HTTPS config (cert path typo)..." +cat > /etc/nginx/sites-available/app <<'NGINX' +server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/ssl/certs/app.pem; + ssl_certificate_key /etc/ssl/private/app.key; + + location / { + return 200 'Hello from nginx\n'; + add_header Content-Type text/plain; + } +} +NGINX + +# -- Restart nginx (will fail - cert file not found) -------------------------- +echo "[*] Restarting nginx with broken SSL config..." +systemctl restart nginx 2>&1 || true + +# Give journal a moment to flush +sleep 2 + +# -- Verify the scenario ------------------------------------------------------ +echo "" +echo "=== Verification ===" + +echo -n "[check] nginx process: " +if systemctl is-active --quiet nginx; then + echo "running (unexpected - should have failed)" +else + echo "FAILED to start - as expected" +fi + +echo -n "[check] /etc/ssl/certs/app-prod.pem exists: " +if [[ -f /etc/ssl/certs/app-prod.pem ]]; then + echo "YES (actual cert file)" +else + echo "NO (unexpected)" +fi + +echo -n "[check] /etc/ssl/certs/app.pem exists: " +if [[ -f /etc/ssl/certs/app.pem ]]; then + echo "YES (unexpected - typo would not reproduce)" +else + echo "NO - missing file (typo target)" +fi + +echo -n "[check] journalctl has cert error: " +if journalctl -u nginx --no-pager -n 50 2>/dev/null | grep -qi "app.pem\|cannot load certificate\|no such file\|BIO_new_file\|SSL_CTX_use"; then + echo "YES (cert error in journalctl)" +else + echo "no cert error found in journal yet" +fi + +echo -n "[check] nginx error log has entries from working phase: " +LOG_LINES=$(wc -l < /var/log/nginx/access.log 2>/dev/null || echo 0) +if [[ "$LOG_LINES" -gt 0 ]]; then + echo "YES ($LOG_LINES lines in /var/log/nginx/access.log)" +else + echo "EMPTY (no access log entries found)" +fi + +echo "" +echo "=== Demo ready ===" +echo "The server has a broken nginx setup with a cert path typo." +echo "Config references /etc/ssl/certs/app.pem but the file is app-prod.pem." +echo "/var/log/nginx/access.log has entries from when nginx was working (HTTP phase)." +echo "" +echo "Debugging journey (read-only):" +echo " 1. systemctl status nginx -> failed/inactive, 'cannot load certificate'" +echo " 2. journalctl -u nginx --no-pager -n 50 -> 'open() ... app.pem failed (2: No such file)'" +echo " 3. cat /etc/nginx/sites-enabled/app -> ssl_certificate /etc/ssl/certs/app.pem" +echo " 4. ls /etc/ssl/certs/app*.pem -> only /etc/ssl/certs/app-prod.pem exists" +echo " 5. Root cause: config says app.pem, actual file is app-prod.pem (missing -prod suffix)" +SETUP_EOF diff --git a/web/src/components/docs/daemon-setup-steps.tsx b/web/src/components/docs/daemon-setup-steps.tsx index 12db6fc4..4fd73fe1 100644 --- a/web/src/components/docs/daemon-setup-steps.tsx +++ b/web/src/components/docs/daemon-setup-steps.tsx @@ -313,6 +313,7 @@ After=network.target libvirtd.service User=fluid-daemon Group=fluid-daemon ExecStart=/usr/local/bin/fluid-daemon --config /etc/fluid-daemon/daemon.yaml +AmbientCapabilities=CAP_NET_ADMIN Restart=on-failure RestartSec=5 LimitNOFILE=65536 diff --git a/web/src/routes/docs/source-prepare.tsx b/web/src/routes/docs/source-prepare.tsx index 3684d106..a29e2ae3 100644 --- a/web/src/routes/docs/source-prepare.tsx +++ b/web/src/routes/docs/source-prepare.tsx @@ -320,12 +320,18 @@ function SourcePreparePage() {

6. Restart sshd

-

On Each VM Host

+

On Each Source Host

- Configure the host machine where fluid-daemon runs so it can SSH into source VMs. + The daemon needs SSH access to each source host (hypervisor) to manage VMs via{' '} + qemu+ssh://fluid-daemon@host/system. Source hosts are + configured in the daemon's daemon.yaml under{' '} + source_hosts.

1. Create fluid-daemon system user

+

+ On each source host, create the user that the daemon will SSH in as. +

-

2. Set up SSH directory

+

2. Deploy the daemon SSH key

+

+ The daemon generates an SSH identity key pair on first start. Deploy the public key to each + source host so the daemon can SSH in as fluid-daemon. +

+ +

Option A: Automatic (recommended)

+

+ When you run fluid connect or use the{' '} + /connect TUI command, the CLI fetches the daemon's + source host list and identity key, then offers to deploy the key to each source host + automatically. Your local SSH user must have sudo access on the source hosts for this to + work. +

-

3. Deploy the daemon SSH public key

+

Option B: Manual

+

+ Copy the daemon's public key from the daemon host, then deploy it on each source host. +

+ ' >> /home/fluid-daemon/.ssh/authorized_keys", + command: "echo '' >> ~fluid-daemon/.ssh/authorized_keys", }, + { command: 'chmod 600 ~fluid-daemon/.ssh/authorized_keys' }, + { command: 'chown -R fluid-daemon:fluid-daemon ~fluid-daemon/.ssh' }, + ]} + /> + +

3. Add source host keys to daemon's known_hosts

+

+ The daemon needs the source host's SSH host key in its known_hosts file. The{' '} + fluid connect wizard does this automatically via the + daemon's ScanSourceHostKeys RPC after deploying keys. + For manual setup: +

+ | sudo -u fluid-daemon tee -a ~fluid-daemon/.ssh/known_hosts', }, - { command: 'chmod 600 /home/fluid-daemon/.ssh/authorized_keys' }, ]} /> - - The daemon's SSH public key is at{' '} - /etc/fluid-daemon/identity.pub on the daemon host. + +

Verification

+

+ Run these commands on the daemon host to confirm the daemon can reach the source host. +

+ +

Test SSH connectivity

+ "echo ok"', + }, + ]} + /> + +

Test libvirt connectivity

+ /system?keyfile=/etc/fluid-daemon/identity" list --all', + }, + ]} + /> + + + After connecting, run fluid doctor or press{' '} + r in the connect wizard to re-run doctor checks. The + daemon will verify SSH+libvirt connectivity to each configured source host and report any + failures with fix commands. From 7e261810fa975fc4074aea11d60cad0a060742be Mon Sep 17 00:00:00 2001 From: aspectrr Date: Sat, 21 Mar 2026 12:03:27 -0400 Subject: [PATCH 03/31] fix: satisfy pre-push hook checks --- fluid-cli/internal/tui/connect_test.go | 2 +- fluid-daemon/internal/daemon/readiness.go | 2 +- fluid-daemon/internal/daemon/server.go | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/fluid-cli/internal/tui/connect_test.go b/fluid-cli/internal/tui/connect_test.go index 5cacba52..7acd145d 100644 --- a/fluid-cli/internal/tui/connect_test.go +++ b/fluid-cli/internal/tui/connect_test.go @@ -316,7 +316,7 @@ func TestUpdate_PerHostDeploy(t *testing.T) { } // Simulate host 1 failure - updatedModel, cmd = m.Update(HostKeyDeployedMsg{Host: "host2", Index: 1, Err: fmt.Errorf("connection refused")}) + updatedModel, _ = m.Update(HostKeyDeployedMsg{Host: "host2", Index: 1, Err: fmt.Errorf("connection refused")}) m = updatedModel.(ConnectModel) if m.hostDeployStatuses[1].State != HostDeployFailed { t.Errorf("host 1 should be failed, got %v", m.hostDeployStatuses[1].State) diff --git a/fluid-daemon/internal/daemon/readiness.go b/fluid-daemon/internal/daemon/readiness.go index c999ce7b..f5a7e622 100644 --- a/fluid-daemon/internal/daemon/readiness.go +++ b/fluid-daemon/internal/daemon/readiness.go @@ -119,5 +119,5 @@ func (rs *ReadinessServer) handleReady(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "ok\n") + _, _ = fmt.Fprintf(w, "ok\n") } diff --git a/fluid-daemon/internal/daemon/server.go b/fluid-daemon/internal/daemon/server.go index a22b4bde..cf760bee 100644 --- a/fluid-daemon/internal/daemon/server.go +++ b/fluid-daemon/internal/daemon/server.go @@ -1101,7 +1101,7 @@ func (s *Server) ScanSourceHostKeys(ctx context.Context, _ *fluidv1.ScanSourceHo continue } _, writeErr := f.WriteString(toAppend) - f.Close() + closeErr := f.Close() if writeErr != nil { results = append(results, &fluidv1.ScanSourceHostKeysResult{ Address: addr, @@ -1110,6 +1110,14 @@ func (s *Server) ScanSourceHostKeys(ctx context.Context, _ *fluidv1.ScanSourceHo }) continue } + if closeErr != nil { + results = append(results, &fluidv1.ScanSourceHostKeysResult{ + Address: addr, + Success: false, + Error: fmt.Sprintf("close known_hosts: %v", closeErr), + }) + continue + } existingStr += toAppend s.logger.Info("scanned host keys", "address", addr) From f7080554bc2724f69b1a6bd407f0d524ed112740 Mon Sep 17 00:00:00 2001 From: aspectrr Date: Sun, 5 Apr 2026 11:13:00 -0400 Subject: [PATCH 04/31] chore: add .worktrees/ to .gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index a0caeea0..f8e797a7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ dist/ .venv act.env .claude +.crush +.cache # macOS-specific files .DS_Store + +# Git worktrees +.worktrees/ From abe91f61eba8af6ae3c66925fee80cc4fd2085ee Mon Sep 17 00:00:00 2001 From: aspectrr Date: Mon, 6 Apr 2026 21:47:15 -0400 Subject: [PATCH 05/31] feat: add Kafka/Redpanda demo support and customer logo marquee - Add Kafka capture config and sandbox stub REST handlers with tests - Add kafkastub manager and transport for daemon-side Redpanda simulation - Add daemon Kafka orchestration and agent client integration - Add Redpanda e2e integration test and demo setup/reset scripts - Add customer logo images and LogoMarquee to landing page - Move original product demo to /product route; update scripted demo colors - Fix tickerMockStore to implement new Kafka store methods Co-Authored-By: Claude Sonnet 4.6 --- api/internal/billing/ticker_test.go | 29 + api/internal/rest/kafka_handlers.go | 262 ++++ api/internal/rest/kafka_handlers_test.go | 105 ++ fluid-daemon/internal/agent/kafka.go | 298 +++++ fluid-daemon/internal/daemon/kafka.go | 459 +++++++ fluid-daemon/internal/kafkastub/manager.go | 1143 +++++++++++++++++ .../internal/kafkastub/manager_test.go | 207 +++ fluid-daemon/internal/kafkastub/transport.go | 31 + .../internal/kafkastub/transport_kafka_go.go | 156 +++ .../microvm/redpanda_integration_test.go | 837 ++++++++++++ scripts/reset-kafka-demo.sh | 131 ++ scripts/run-redpanda-e2e-lima-host.sh | 330 +++++ scripts/run-redpanda-e2e-lima-host_test.sh | 81 ++ scripts/run-redpanda-e2e-lima.sh | 239 ++++ scripts/run-redpanda-e2e-lima_test.sh | 42 + scripts/setup-kafka-demo.sh | 867 +++++++++++++ web/public/images/logos/951515-249938965.png | Bin 0 -> 9365 bytes .../Clemson-University-Emblem-2654677447.png | Bin 0 -> 28739 bytes .../Indiana-University-Logo-465862039.png | Bin 0 -> 10252 bytes .../logos/Purdue-Logo-PNG-HD-2002129623.png | Bin 0 -> 62764 bytes .../Santa_Clara_University_Logo-588491151.png | Bin 0 -> 29775 bytes .../logos/UCSD-Seal-Logo-3371647571.png | Bin 0 -> 126125 bytes ...irginia-Tech-Logo-PNG-Photo-2687094516.png | Bin 0 -> 142023 bytes ...et2-logo-png_seeklogo-72798-1654782020.png | Bin 0 -> 5330 bytes ...sticker-ncaa142-5215-4a50fb-2570742203.png | Bin 0 -> 91825 bytes .../images/logos/omnisoc-768w-3770841716.png | Bin 0 -> 13953 bytes .../logos/ucsc-logo-png-4-4148969120.png | Bin 0 -> 370712 bytes web/src/lib/scripted-demo.ts | 244 +--- web/src/routes/_public/index.tsx | 754 +++++------ web/src/routes/_public/product.tsx | 521 ++++++++ 30 files changed, 6081 insertions(+), 655 deletions(-) create mode 100644 api/internal/rest/kafka_handlers.go create mode 100644 api/internal/rest/kafka_handlers_test.go create mode 100644 fluid-daemon/internal/agent/kafka.go create mode 100644 fluid-daemon/internal/daemon/kafka.go create mode 100644 fluid-daemon/internal/kafkastub/manager.go create mode 100644 fluid-daemon/internal/kafkastub/manager_test.go create mode 100644 fluid-daemon/internal/kafkastub/transport.go create mode 100644 fluid-daemon/internal/kafkastub/transport_kafka_go.go create mode 100644 fluid-daemon/internal/provider/microvm/redpanda_integration_test.go create mode 100755 scripts/reset-kafka-demo.sh create mode 100755 scripts/run-redpanda-e2e-lima-host.sh create mode 100755 scripts/run-redpanda-e2e-lima-host_test.sh create mode 100755 scripts/run-redpanda-e2e-lima.sh create mode 100755 scripts/run-redpanda-e2e-lima_test.sh create mode 100755 scripts/setup-kafka-demo.sh create mode 100644 web/public/images/logos/951515-249938965.png create mode 100644 web/public/images/logos/Clemson-University-Emblem-2654677447.png create mode 100644 web/public/images/logos/Indiana-University-Logo-465862039.png create mode 100644 web/public/images/logos/Purdue-Logo-PNG-HD-2002129623.png create mode 100644 web/public/images/logos/Santa_Clara_University_Logo-588491151.png create mode 100644 web/public/images/logos/UCSD-Seal-Logo-3371647571.png create mode 100644 web/public/images/logos/Virginia-Tech-Logo-PNG-Photo-2687094516.png create mode 100644 web/public/images/logos/internet2-logo-png_seeklogo-72798-1654782020.png create mode 100644 web/public/images/logos/lehigh-mountain-hawks-ncaa-logo-sticker-ncaa142-5215-4a50fb-2570742203.png create mode 100644 web/public/images/logos/omnisoc-768w-3770841716.png create mode 100644 web/public/images/logos/ucsc-logo-png-4-4148969120.png create mode 100644 web/src/routes/_public/product.tsx diff --git a/api/internal/billing/ticker_test.go b/api/internal/billing/ticker_test.go index 16b9695f..edf6fecc 100644 --- a/api/internal/billing/ticker_test.go +++ b/api/internal/billing/ticker_test.go @@ -210,6 +210,35 @@ func (m *tickerMockStore) GetSubscriptionByStripeID(context.Context, string) (*s func (m *tickerMockStore) AcquireAdvisoryLock(context.Context, int64) error { return nil } func (m *tickerMockStore) ReleaseAdvisoryLock(context.Context, int64) error { return nil } +func (m *tickerMockStore) CreateKafkaCaptureConfig(context.Context, *store.KafkaCaptureConfig) error { + return nil +} +func (m *tickerMockStore) GetKafkaCaptureConfig(context.Context, string) (*store.KafkaCaptureConfig, error) { + return nil, nil +} +func (m *tickerMockStore) ListKafkaCaptureConfigsByOrg(context.Context, string) ([]*store.KafkaCaptureConfig, error) { + return nil, nil +} +func (m *tickerMockStore) UpdateKafkaCaptureConfig(context.Context, *store.KafkaCaptureConfig) error { + return nil +} +func (m *tickerMockStore) DeleteKafkaCaptureConfig(context.Context, string) error { return nil } +func (m *tickerMockStore) CreateSandboxKafkaStub(context.Context, *store.SandboxKafkaStub) error { + return nil +} +func (m *tickerMockStore) GetSandboxKafkaStub(context.Context, string) (*store.SandboxKafkaStub, error) { + return nil, nil +} +func (m *tickerMockStore) ListSandboxKafkaStubsBySandbox(context.Context, string) ([]*store.SandboxKafkaStub, error) { + return nil, nil +} +func (m *tickerMockStore) UpdateSandboxKafkaStub(context.Context, *store.SandboxKafkaStub) error { + return nil +} +func (m *tickerMockStore) DeleteSandboxKafkaStubsBySandbox(context.Context, string) error { + return nil +} + // --------------------------------------------------------------------------- // mockHostStream - minimal HostStream for registry.Register // --------------------------------------------------------------------------- diff --git a/api/internal/rest/kafka_handlers.go b/api/internal/rest/kafka_handlers.go new file mode 100644 index 00000000..a0c8e524 --- /dev/null +++ b/api/internal/rest/kafka_handlers.go @@ -0,0 +1,262 @@ +package rest + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + serverError "github.com/aspectrr/fluid.sh/api/internal/error" + serverJSON "github.com/aspectrr/fluid.sh/api/internal/json" + "github.com/aspectrr/fluid.sh/api/internal/store" +) + +type kafkaCaptureConfigRequest struct { + SourceHostID string `json:"source_host_id"` + SourceVM string `json:"source_vm"` + Name string `json:"name"` + BootstrapServers []string `json:"bootstrap_servers"` + Topics []string `json:"topics"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + SASLMechanism string `json:"sasl_mechanism,omitempty"` + TLSEnabled bool `json:"tls_enabled"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` + TLSCAPEM string `json:"tls_ca_pem,omitempty"` + Codec string `json:"codec"` + RedactionRules []string `json:"redaction_rules"` + MaxBufferAgeSecs int32 `json:"max_buffer_age_seconds"` + MaxBufferBytes int64 `json:"max_buffer_bytes"` + Enabled bool `json:"enabled"` +} + +func (s *Server) handleCreateKafkaCaptureConfig(w http.ResponseWriter, r *http.Request) { + org, _, ok := s.resolveOrgMembership(w, r) + if !ok { + return + } + + var req kafkaCaptureConfigRequest + if err := serverJSON.DecodeJSON(r.Context(), r, &req); err != nil { + serverError.RespondError(w, http.StatusBadRequest, err) + return + } + if req.SourceHostID == "" || req.SourceVM == "" || len(req.BootstrapServers) == 0 || len(req.Topics) == 0 { + serverError.RespondError(w, http.StatusBadRequest, fmt.Errorf("source_host_id, source_vm, bootstrap_servers, and topics are required")) + return + } + if req.Codec == "" { + req.Codec = "json" + } + if req.SASLMechanism == "" { + req.SASLMechanism = "plain" + } + + cfg := &store.KafkaCaptureConfig{ + ID: uuid.New().String(), + OrgID: org.ID, + SourceHostID: req.SourceHostID, + SourceVM: req.SourceVM, + Name: req.Name, + BootstrapServers: store.StringSlice(req.BootstrapServers), + Topics: store.StringSlice(req.Topics), + Username: req.Username, + Password: req.Password, + SASLMechanism: req.SASLMechanism, + TLSEnabled: req.TLSEnabled, + InsecureSkipVerify: req.InsecureSkipVerify, + TLSCAPEM: req.TLSCAPEM, + Codec: req.Codec, + RedactionRules: store.StringSlice(req.RedactionRules), + MaxBufferAgeSecs: req.MaxBufferAgeSecs, + MaxBufferBytes: req.MaxBufferBytes, + Enabled: req.Enabled, + LastCaptureState: "pending", + } + if err := s.store.CreateKafkaCaptureConfig(r.Context(), cfg); err != nil { + serverError.RespondError(w, http.StatusInternalServerError, fmt.Errorf("failed to create kafka capture config")) + return + } + _ = serverJSON.RespondJSON(w, http.StatusCreated, cfg) +} + +func (s *Server) handleListKafkaCaptureConfigs(w http.ResponseWriter, r *http.Request) { + org, _, ok := s.resolveOrgMembership(w, r) + if !ok { + return + } + configs, err := s.store.ListKafkaCaptureConfigsByOrg(r.Context(), org.ID) + if err != nil { + serverError.RespondError(w, http.StatusInternalServerError, fmt.Errorf("failed to list kafka capture configs")) + return + } + _ = serverJSON.RespondJSON(w, http.StatusOK, map[string]any{ + "kafka_capture_configs": configs, + "count": len(configs), + }) +} + +func (s *Server) handleGetKafkaCaptureConfig(w http.ResponseWriter, r *http.Request) { + org, _, ok := s.resolveOrgMembership(w, r) + if !ok { + return + } + id := chi.URLParam(r, "configID") + cfg, err := s.store.GetKafkaCaptureConfig(r.Context(), id) + if err != nil || cfg.OrgID != org.ID { + serverError.RespondError(w, http.StatusNotFound, fmt.Errorf("kafka capture config not found")) + return + } + _ = serverJSON.RespondJSON(w, http.StatusOK, cfg) +} + +func (s *Server) handleUpdateKafkaCaptureConfig(w http.ResponseWriter, r *http.Request) { + org, _, ok := s.resolveOrgMembership(w, r) + if !ok { + return + } + id := chi.URLParam(r, "configID") + cfg, err := s.store.GetKafkaCaptureConfig(r.Context(), id) + if err != nil || cfg.OrgID != org.ID { + serverError.RespondError(w, http.StatusNotFound, fmt.Errorf("kafka capture config not found")) + return + } + + var req kafkaCaptureConfigRequest + if err := serverJSON.DecodeJSON(r.Context(), r, &req); err != nil { + serverError.RespondError(w, http.StatusBadRequest, err) + return + } + + cfg.SourceHostID = req.SourceHostID + cfg.SourceVM = req.SourceVM + cfg.Name = req.Name + cfg.BootstrapServers = store.StringSlice(req.BootstrapServers) + cfg.Topics = store.StringSlice(req.Topics) + cfg.Username = req.Username + if req.Password != "" { + cfg.Password = req.Password + } + if req.SASLMechanism != "" { + cfg.SASLMechanism = req.SASLMechanism + } + cfg.TLSEnabled = req.TLSEnabled + cfg.InsecureSkipVerify = req.InsecureSkipVerify + if req.TLSCAPEM != "" { + cfg.TLSCAPEM = req.TLSCAPEM + } + if req.Codec != "" { + cfg.Codec = req.Codec + } + cfg.RedactionRules = store.StringSlice(req.RedactionRules) + cfg.MaxBufferAgeSecs = req.MaxBufferAgeSecs + cfg.MaxBufferBytes = req.MaxBufferBytes + cfg.Enabled = req.Enabled + cfg.UpdatedAt = time.Now().UTC() + + if err := s.store.UpdateKafkaCaptureConfig(r.Context(), cfg); err != nil { + serverError.RespondError(w, http.StatusInternalServerError, fmt.Errorf("failed to update kafka capture config")) + return + } + _ = serverJSON.RespondJSON(w, http.StatusOK, cfg) +} + +func (s *Server) handleDeleteKafkaCaptureConfig(w http.ResponseWriter, r *http.Request) { + org, _, ok := s.resolveOrgMembership(w, r) + if !ok { + return + } + id := chi.URLParam(r, "configID") + cfg, err := s.store.GetKafkaCaptureConfig(r.Context(), id) + if err != nil || cfg.OrgID != org.ID { + serverError.RespondError(w, http.StatusNotFound, fmt.Errorf("kafka capture config not found")) + return + } + if err := s.store.DeleteKafkaCaptureConfig(r.Context(), id); err != nil { + serverError.RespondError(w, http.StatusInternalServerError, fmt.Errorf("failed to delete kafka capture config")) + return + } + _ = serverJSON.RespondJSON(w, http.StatusOK, map[string]any{"deleted": true}) +} + +func (s *Server) handleListSandboxKafkaStubs(w http.ResponseWriter, r *http.Request) { + org, _, ok := s.resolveOrgMembership(w, r) + if !ok { + return + } + sandboxID := chi.URLParam(r, "sandboxID") + stubs, err := s.orchestrator.ListSandboxKafkaStubs(r.Context(), org.ID, sandboxID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + serverError.RespondError(w, http.StatusNotFound, fmt.Errorf("sandbox not found")) + return + } + serverError.RespondError(w, http.StatusInternalServerError, fmt.Errorf("failed to list sandbox kafka stubs")) + return + } + _ = serverJSON.RespondJSON(w, http.StatusOK, map[string]any{ + "kafka_stubs": stubs, + "count": len(stubs), + }) +} + +func (s *Server) handleGetSandboxKafkaStub(w http.ResponseWriter, r *http.Request) { + org, _, ok := s.resolveOrgMembership(w, r) + if !ok { + return + } + sandboxID := chi.URLParam(r, "sandboxID") + stubID := chi.URLParam(r, "stubID") + stub, err := s.orchestrator.GetSandboxKafkaStub(r.Context(), org.ID, sandboxID, stubID) + if err != nil { + serverError.RespondError(w, http.StatusNotFound, fmt.Errorf("sandbox kafka stub not found")) + return + } + _ = serverJSON.RespondJSON(w, http.StatusOK, stub) +} + +func (s *Server) handleStartSandboxKafkaStub(w http.ResponseWriter, r *http.Request) { + s.handleTransitionSandboxKafkaStub(w, r, "start") +} + +func (s *Server) handleStopSandboxKafkaStub(w http.ResponseWriter, r *http.Request) { + s.handleTransitionSandboxKafkaStub(w, r, "stop") +} + +func (s *Server) handleRestartSandboxKafkaStub(w http.ResponseWriter, r *http.Request) { + s.handleTransitionSandboxKafkaStub(w, r, "restart") +} + +func (s *Server) handleTransitionSandboxKafkaStub(w http.ResponseWriter, r *http.Request, action string) { + org, _, ok := s.resolveOrgMembership(w, r) + if !ok { + return + } + sandboxID := chi.URLParam(r, "sandboxID") + stubID := chi.URLParam(r, "stubID") + + var ( + stub *store.SandboxKafkaStub + err error + ) + switch action { + case "start": + stub, err = s.orchestrator.StartSandboxKafkaStub(r.Context(), org.ID, sandboxID, stubID) + case "stop": + stub, err = s.orchestrator.StopSandboxKafkaStub(r.Context(), org.ID, sandboxID, stubID) + case "restart": + stub, err = s.orchestrator.RestartSandboxKafkaStub(r.Context(), org.ID, sandboxID, stubID) + } + if err != nil { + if errors.Is(err, store.ErrNotFound) { + serverError.RespondError(w, http.StatusNotFound, fmt.Errorf("sandbox kafka stub not found")) + return + } + serverError.RespondError(w, http.StatusInternalServerError, fmt.Errorf("failed to %s sandbox kafka stub", action)) + return + } + _ = serverJSON.RespondJSON(w, http.StatusOK, stub) +} diff --git a/api/internal/rest/kafka_handlers_test.go b/api/internal/rest/kafka_handlers_test.go new file mode 100644 index 00000000..cd7d3c8f --- /dev/null +++ b/api/internal/rest/kafka_handlers_test.go @@ -0,0 +1,105 @@ +package rest + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" + + "github.com/aspectrr/fluid.sh/api/internal/store" +) + +func TestHandleCreateKafkaCaptureConfig(t *testing.T) { + ms := &mockStore{} + setupOrgMembership(ms) + + var created *store.KafkaCaptureConfig + ms.CreateKafkaCaptureConfigFn = func(_ context.Context, cfg *store.KafkaCaptureConfig) error { + created = cfg + return nil + } + + s := newTestServer(ms, nil) + body := httptest.NewRequest("POST", "/v1/orgs/test-org/kafka-capture-configs", strings.NewReader(`{ + "source_host_id":"sh-1", + "source_vm":"logstash-1", + "name":"Logs", + "bootstrap_servers":["kafka-1:9092"], + "topics":["logs"], + "codec":"json", + "enabled":true + }`)) + body.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + req := authenticatedRequest(ms, "POST", "/v1/orgs/test-org/kafka-capture-configs", body) + s.Router.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + if created == nil { + t.Fatal("expected CreateKafkaCaptureConfig to be called") + } + if created.SourceHostID != "sh-1" || created.SourceVM != "logstash-1" { + t.Fatalf("unexpected created config: %+v", created) + } +} + +func TestHandleListSandboxKafkaStubs(t *testing.T) { + ms := &mockStore{} + setupOrgMembership(ms) + ms.GetSandboxFn = func(_ context.Context, sandboxID string) (*store.Sandbox, error) { + return &store.Sandbox{ID: sandboxID, OrgID: testOrg.ID, HostID: "host-1"}, nil + } + ms.UpdateSandboxKafkaStubFn = func(_ context.Context, _ *store.SandboxKafkaStub) error { + return store.ErrNotFound + } + ms.CreateSandboxKafkaStubFn = func(_ context.Context, _ *store.SandboxKafkaStub) error { + return nil + } + + sender := &mockHostSender{ + SendAndWaitFn: func(_ context.Context, hostID string, msg *fluidv1.ControlMessage, _ time.Duration) (*fluidv1.HostMessage, error) { + if hostID != "host-1" { + t.Fatalf("unexpected hostID %q", hostID) + } + if msg.GetListSandboxKafkaStubs() == nil { + t.Fatalf("expected ListSandboxKafkaStubs command, got %#v", msg.Payload) + } + return &fluidv1.HostMessage{ + RequestId: msg.GetRequestId(), + Payload: &fluidv1.HostMessage_ListSandboxKafkaStubsResponse{ + ListSandboxKafkaStubsResponse: &fluidv1.ListSandboxKafkaStubsResponse{ + Stubs: []*fluidv1.SandboxKafkaStubInfo{{ + StubId: "stub-1", + SandboxId: "SBX-1", + CaptureConfigId: "cfg-1", + BrokerEndpoint: "10.0.0.10:9092", + Topics: []string{"logs"}, + ReplayWindowSeconds: 300, + State: fluidv1.KafkaStubState_KAFKA_STUB_STATE_RUNNING, + }}, + }, + }, + }, nil + }, + } + + s := newTestServerWithSender(ms, sender, nil) + rr := httptest.NewRecorder() + req := authenticatedRequest(ms, "GET", "/v1/orgs/test-org/sandboxes/SBX-1/kafka-stubs", nil) + s.Router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + resp := parseJSONResponse(rr) + if resp["count"] != float64(1) { + t.Fatalf("expected count=1, got %v", resp["count"]) + } +} diff --git a/fluid-daemon/internal/agent/kafka.go b/fluid-daemon/internal/agent/kafka.go new file mode 100644 index 00000000..f41ba10f --- /dev/null +++ b/fluid-daemon/internal/agent/kafka.go @@ -0,0 +1,298 @@ +package agent + +import ( + "context" + "fmt" + "log/slog" + "time" + + fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" + + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/kafkastub" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/provider" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/redact" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/state" +) + +func newKafkaManager(baseDir string, logger *slog.Logger, localStore *state.Store) (*kafkastub.Manager, error) { + manager, err := kafkastub.NewManager(baseDir, redact.New(), logger, + kafkastub.WithTransport(kafkastub.NewKafkaGoTransport()), + kafkastub.WithHooks(kafkastub.Hooks{ + OnCaptureStatus: func(item kafkastub.CaptureStatus) { + if localStore == nil { + return + } + _ = mergeCaptureStatus(context.Background(), localStore, item) + }, + OnSandboxStub: func(stub *kafkastub.SandboxStub) { + if localStore == nil { + return + } + _ = localStore.UpsertSandboxKafkaStub(context.Background(), sandboxKafkaStubToLocal(stub)) + }, + })) + if err != nil { + return nil, err + } + if localStore != nil { + if err := restoreKafkaRuntime(context.Background(), localStore, manager); err != nil { + logger.Warn("failed to restore kafka runtime", "error", err) + } + } + return manager, nil +} + +func restoreKafkaRuntime(ctx context.Context, localStore *state.Store, manager *kafkastub.Manager) error { + configRows, err := localStore.ListKafkaCaptureConfigs(ctx, nil) + if err != nil { + return err + } + configs := make([]kafkastub.CaptureConfig, 0, len(configRows)) + for _, row := range configRows { + configs = append(configs, kafkaCaptureConfigFromLocal(row)) + } + + sandboxes, err := localStore.ListSandboxes(ctx) + if err != nil { + return err + } + var stubs []kafkastub.SandboxStub + for _, sandbox := range sandboxes { + rows, err := localStore.ListSandboxKafkaStubs(ctx, sandbox.ID) + if err != nil { + return err + } + for _, row := range rows { + stubs = append(stubs, sandboxKafkaStubFromLocal(row)) + } + } + return manager.Restore(ctx, configs, stubs) +} + +func kafkaBrokerConfigForRequest(bindings []*fluidv1.KafkaCaptureConfigBinding) *provider.KafkaBrokerConfig { + return kafkaBrokerConfigForDataSources(nil, bindings) +} + +func kafkaBrokerConfigForDataSources(dataSources []*fluidv1.DataSourceAttachment, fallback []*fluidv1.KafkaCaptureConfigBinding) *provider.KafkaBrokerConfig { + if len(kafkaSandboxAttachmentsFromProto(dataSources, fallback)) == 0 { + return nil + } + return &provider.KafkaBrokerConfig{Port: 9092} +} + +func providerDataSourcesFromProto(dataSources []*fluidv1.DataSourceAttachment, fallback []*fluidv1.KafkaCaptureConfigBinding) []provider.DataSourceAttachment { + attachments := kafkaSandboxAttachmentsFromProto(dataSources, fallback) + out := make([]provider.DataSourceAttachment, 0, len(attachments)) + for _, attachment := range attachments { + out = append(out, provider.DataSourceAttachment{ + Type: provider.DataSourceTypeKafka, + ConfigRef: attachment.CaptureConfig.ID, + Kafka: &provider.KafkaDataSourceConfig{ + CaptureConfigID: attachment.CaptureConfig.ID, + Topics: append([]string(nil), attachment.Topics...), + ReplayWindow: attachment.ReplayWindow, + }, + }) + } + return out +} + +func kafkaSandboxAttachmentsFromProto(dataSources []*fluidv1.DataSourceAttachment, fallback []*fluidv1.KafkaCaptureConfigBinding) []kafkastub.SandboxAttachment { + if len(dataSources) > 0 { + attachments := make([]kafkastub.SandboxAttachment, 0, len(dataSources)) + for _, ds := range dataSources { + if ds.GetType() != fluidv1.DataSourceType_DATA_SOURCE_TYPE_KAFKA { + continue + } + kafkaCfg := ds.GetKafka() + if kafkaCfg == nil || kafkaCfg.GetCaptureConfig() == nil { + continue + } + cfg := kafkaCaptureConfigFromProto(kafkaCfg.GetCaptureConfig()) + topics := append([]string(nil), kafkaCfg.GetTopics()...) + if len(topics) == 0 { + topics = append(topics, cfg.Topics...) + } + attachments = append(attachments, kafkastub.SandboxAttachment{ + CaptureConfig: cfg, + Topics: topics, + ReplayWindow: time.Duration(kafkaCfg.GetReplayWindowSeconds()) * time.Second, + }) + } + return attachments + } + + attachments := make([]kafkastub.SandboxAttachment, 0, len(fallback)) + for _, binding := range fallback { + cfg := kafkaCaptureConfigFromProto(binding) + attachments = append(attachments, kafkastub.SandboxAttachment{ + CaptureConfig: cfg, + Topics: append([]string(nil), cfg.Topics...), + }) + } + return attachments +} + +func kafkaCaptureConfigFromProto(binding *fluidv1.KafkaCaptureConfigBinding) kafkastub.CaptureConfig { + return kafkastub.CaptureConfig{ + ID: binding.GetId(), + SourceVM: binding.GetSourceVm(), + BootstrapServers: append([]string(nil), binding.GetBootstrapServers()...), + Topics: append([]string(nil), binding.GetTopics()...), + Username: binding.GetUsername(), + Password: binding.GetPassword(), + SASLMechanism: binding.GetSaslMechanism(), + TLSEnabled: binding.GetTlsEnabled(), + InsecureSkipVerify: binding.GetInsecureSkipVerify(), + TLSCAPEM: binding.GetTlsCaPem(), + Codec: binding.GetCodec(), + RedactionRules: append([]string(nil), binding.GetRedactionRules()...), + MaxBufferAge: time.Duration(binding.GetMaxBufferAgeSeconds()) * time.Second, + MaxBufferBytes: binding.GetMaxBufferBytes(), + Enabled: binding.GetEnabled(), + } +} + +func kafkaCaptureConfigToLocal(cfg kafkastub.CaptureConfig) *state.KafkaCaptureConfig { + return &state.KafkaCaptureConfig{ + ID: cfg.ID, + SourceVM: cfg.SourceVM, + BootstrapServers: append([]string(nil), cfg.BootstrapServers...), + Topics: append([]string(nil), cfg.Topics...), + Username: cfg.Username, + Password: cfg.Password, + SASLMechanism: cfg.SASLMechanism, + TLSEnabled: cfg.TLSEnabled, + InsecureSkipVerify: cfg.InsecureSkipVerify, + TLSCAPEM: cfg.TLSCAPEM, + Codec: cfg.Codec, + RedactionRules: append([]string(nil), cfg.RedactionRules...), + MaxBufferAgeSecs: int(cfg.MaxBufferAge / time.Second), + MaxBufferBytes: cfg.MaxBufferBytes, + Enabled: cfg.Enabled, + UpdatedAt: time.Now().UTC(), + } +} + +func kafkaCaptureConfigFromLocal(row *state.KafkaCaptureConfig) kafkastub.CaptureConfig { + return kafkastub.CaptureConfig{ + ID: row.ID, + SourceVM: row.SourceVM, + BootstrapServers: append([]string(nil), row.BootstrapServers...), + Topics: append([]string(nil), row.Topics...), + Username: row.Username, + Password: row.Password, + SASLMechanism: row.SASLMechanism, + TLSEnabled: row.TLSEnabled, + InsecureSkipVerify: row.InsecureSkipVerify, + TLSCAPEM: row.TLSCAPEM, + Codec: row.Codec, + RedactionRules: append([]string(nil), row.RedactionRules...), + MaxBufferAge: time.Duration(row.MaxBufferAgeSecs) * time.Second, + MaxBufferBytes: row.MaxBufferBytes, + Enabled: row.Enabled, + } +} + +func captureStatusToLocal(item kafkastub.CaptureStatus) *state.KafkaCaptureConfig { + return &state.KafkaCaptureConfig{ + ID: item.CaptureConfigID, + SourceVM: item.SourceVM, + State: item.State, + BufferedBytes: item.BufferedBytes, + SegmentCount: item.SegmentCount, + LastError: item.LastError, + LastResumeCursor: item.LastResumeCursor, + UpdatedAt: item.UpdatedAt, + } +} + +func mergeCaptureStatus(ctx context.Context, localStore *state.Store, item kafkastub.CaptureStatus) error { + rows, err := localStore.ListKafkaCaptureConfigs(ctx, []string{item.CaptureConfigID}) + if err != nil { + return err + } + var row *state.KafkaCaptureConfig + if len(rows) > 0 { + row = rows[0] + } else { + row = &state.KafkaCaptureConfig{ID: item.CaptureConfigID, SourceVM: item.SourceVM} + } + row.SourceVM = item.SourceVM + row.State = item.State + row.BufferedBytes = item.BufferedBytes + row.SegmentCount = item.SegmentCount + row.LastError = item.LastError + row.LastResumeCursor = item.LastResumeCursor + row.UpdatedAt = item.UpdatedAt + return localStore.UpsertKafkaCaptureConfig(ctx, row) +} + +func sandboxKafkaStubToLocal(stub *kafkastub.SandboxStub) *state.SandboxKafkaStub { + return &state.SandboxKafkaStub{ + ID: stub.ID, + SandboxID: stub.SandboxID, + CaptureConfigID: stub.CaptureConfigID, + BrokerEndpoint: stub.BrokerEndpoint, + Topics: append([]string(nil), stub.Topics...), + ReplayWindowSeconds: int(stub.ReplayWindow / time.Second), + State: stub.State, + LastReplayCursor: stub.LastReplayCursor, + LastError: stub.LastError, + AutoStart: stub.AutoStart, + CreatedAt: stub.CreatedAt, + UpdatedAt: stub.UpdatedAt, + } +} + +func sandboxKafkaStubFromLocal(row *state.SandboxKafkaStub) kafkastub.SandboxStub { + return kafkastub.SandboxStub{ + ID: row.ID, + SandboxID: row.SandboxID, + CaptureConfigID: row.CaptureConfigID, + BrokerEndpoint: row.BrokerEndpoint, + Topics: append([]string(nil), row.Topics...), + ReplayWindow: time.Duration(row.ReplayWindowSeconds) * time.Second, + State: row.State, + LastReplayCursor: row.LastReplayCursor, + LastError: row.LastError, + AutoStart: row.AutoStart, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func sandboxKafkaStubToProto(stub *kafkastub.SandboxStub) *fluidv1.SandboxKafkaStubInfo { + return &fluidv1.SandboxKafkaStubInfo{ + StubId: stub.ID, + SandboxId: stub.SandboxID, + CaptureConfigId: stub.CaptureConfigID, + BrokerEndpoint: stub.BrokerEndpoint, + Topics: append([]string(nil), stub.Topics...), + ReplayWindowSeconds: int32(stub.ReplayWindow / time.Second), + State: sandboxKafkaStateToProto(stub.State), + LastReplayCursor: stub.LastReplayCursor, + AutoStart: stub.AutoStart, + LastError: stub.LastError, + } +} + +func sandboxKafkaStateToProto(v string) fluidv1.KafkaStubState { + switch v { + case kafkastub.StateRunning: + return fluidv1.KafkaStubState_KAFKA_STUB_STATE_RUNNING + case kafkastub.StatePaused: + return fluidv1.KafkaStubState_KAFKA_STUB_STATE_PAUSED + case kafkastub.StateError: + return fluidv1.KafkaStubState_KAFKA_STUB_STATE_ERROR + default: + return fluidv1.KafkaStubState_KAFKA_STUB_STATE_STOPPED + } +} + +func sandboxBrokerEndpoint(sandboxIP string) string { + if sandboxIP == "" { + return "127.0.0.1:9092" + } + return fmt.Sprintf("%s:9092", sandboxIP) +} diff --git a/fluid-daemon/internal/daemon/kafka.go b/fluid-daemon/internal/daemon/kafka.go new file mode 100644 index 00000000..190e0787 --- /dev/null +++ b/fluid-daemon/internal/daemon/kafka.go @@ -0,0 +1,459 @@ +package daemon + +import ( + "context" + "fmt" + "log/slog" + "time" + + fluidv1 "github.com/aspectrr/fluid.sh/proto/gen/go/fluid/v1" + + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/kafkastub" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/provider" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/redact" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/state" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func newKafkaManager(baseDir string, redactor *redact.Redactor, logger *slog.Logger, store *state.Store) (*kafkastub.Manager, error) { + manager, err := kafkastub.NewManager(baseDir, redactor, logger, + kafkastub.WithTransport(kafkastub.NewKafkaGoTransport()), + kafkastub.WithHooks(kafkastub.Hooks{ + OnCaptureStatus: func(item kafkastub.CaptureStatus) { + if store == nil { + return + } + _ = mergeCaptureStatus(context.Background(), store, item) + }, + OnSandboxStub: func(stub *kafkastub.SandboxStub) { + if store == nil { + return + } + _ = store.UpsertSandboxKafkaStub(context.Background(), sandboxStubToState(stub)) + }, + })) + if err != nil { + return nil, err + } + if store != nil { + if err := restoreKafkaRuntime(context.Background(), store, manager); err != nil { + logger.Warn("failed to restore kafka runtime", "error", err) + } + } + return manager, nil +} + +func restoreKafkaRuntime(ctx context.Context, store *state.Store, manager *kafkastub.Manager) error { + configRows, err := store.ListKafkaCaptureConfigs(ctx, nil) + if err != nil { + return err + } + configs := make([]kafkastub.CaptureConfig, 0, len(configRows)) + for _, row := range configRows { + configs = append(configs, captureConfigFromState(row)) + } + + sandboxes, err := store.ListSandboxes(ctx) + if err != nil { + return err + } + var stubs []kafkastub.SandboxStub + for _, sandbox := range sandboxes { + rows, err := store.ListSandboxKafkaStubs(ctx, sandbox.ID) + if err != nil { + return err + } + for _, row := range rows { + stubs = append(stubs, sandboxStubFromState(row)) + } + } + return manager.Restore(ctx, configs, stubs) +} + +func (s *Server) attachKafkaStubs(ctx context.Context, sandboxID, sandboxIP string, bindings []*fluidv1.KafkaCaptureConfigBinding) ([]*fluidv1.SandboxKafkaStubInfo, error) { + return s.attachKafkaDataSources(ctx, sandboxID, sandboxIP, nil, bindings) +} + +func (s *Server) attachKafkaDataSources(ctx context.Context, sandboxID, sandboxIP string, dataSources []*fluidv1.DataSourceAttachment, fallback []*fluidv1.KafkaCaptureConfigBinding) ([]*fluidv1.SandboxKafkaStubInfo, error) { + attachments := kafkaSandboxAttachmentsFromProto(dataSources, fallback) + if s.kafkaMgr == nil || len(attachments) == 0 { + return nil, nil + } + + for _, attachment := range attachments { + cfg := attachment.CaptureConfig + if err := s.store.UpsertKafkaCaptureConfig(ctx, captureConfigToState(cfg)); err != nil { + s.logger.Warn("failed to persist kafka capture config", "config_id", cfg.ID, "error", err) + } + } + + stubs, err := s.kafkaMgr.AttachSandbox(ctx, sandboxID, sandboxBrokerEndpoint(sandboxIP), attachments) + if err != nil { + return nil, err + } + + out := make([]*fluidv1.SandboxKafkaStubInfo, 0, len(stubs)) + for _, stub := range stubs { + if err := s.store.UpsertSandboxKafkaStub(ctx, sandboxStubToState(stub)); err != nil { + s.logger.Warn("failed to persist sandbox kafka stub", "stub_id", stub.ID, "error", err) + } + out = append(out, sandboxStubToProto(stub)) + } + return out, nil +} + +func (s *Server) removeKafkaStubs(ctx context.Context, sandboxID string) { + if s.kafkaMgr != nil { + if err := s.kafkaMgr.DetachSandbox(ctx, sandboxID); err != nil { + s.logger.Warn("failed to detach sandbox kafka stubs", "sandbox_id", sandboxID, "error", err) + } + } + if err := s.store.DeleteSandboxKafkaStubs(ctx, sandboxID); err != nil { + s.logger.Warn("failed to delete sandbox kafka stubs from state", "sandbox_id", sandboxID, "error", err) + } +} + +func (s *Server) ListSandboxKafkaStubs(ctx context.Context, req *fluidv1.ListSandboxKafkaStubsCommand) (*fluidv1.ListSandboxKafkaStubsResponse, error) { + if req.GetSandboxId() == "" { + return nil, status.Error(codes.InvalidArgument, "sandbox_id is required") + } + if s.kafkaMgr == nil { + return &fluidv1.ListSandboxKafkaStubsResponse{}, nil + } + stubs, err := s.kafkaMgr.ListSandboxStubs(ctx, req.GetSandboxId()) + if err != nil { + return nil, status.Errorf(codes.Internal, "list sandbox kafka stubs: %v", err) + } + resp := &fluidv1.ListSandboxKafkaStubsResponse{Stubs: make([]*fluidv1.SandboxKafkaStubInfo, 0, len(stubs))} + for _, stub := range stubs { + _ = s.store.UpsertSandboxKafkaStub(ctx, sandboxStubToState(stub)) + resp.Stubs = append(resp.Stubs, sandboxStubToProto(stub)) + } + return resp, nil +} + +func (s *Server) GetSandboxKafkaStub(ctx context.Context, req *fluidv1.GetSandboxKafkaStubCommand) (*fluidv1.SandboxKafkaStubInfo, error) { + if err := requireStubIdentifiers(req.GetSandboxId(), req.GetStubId()); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if s.kafkaMgr == nil { + return nil, status.Error(codes.NotFound, "kafka stub not found") + } + stub, err := s.kafkaMgr.GetSandboxStub(ctx, req.GetSandboxId(), req.GetStubId()) + if err != nil { + if err == kafkastub.ErrNotFound { + return nil, status.Error(codes.NotFound, "kafka stub not found") + } + return nil, status.Errorf(codes.Internal, "get sandbox kafka stub: %v", err) + } + _ = s.store.UpsertSandboxKafkaStub(ctx, sandboxStubToState(stub)) + return sandboxStubToProto(stub), nil +} + +func (s *Server) StartSandboxKafkaStub(ctx context.Context, req *fluidv1.StartSandboxKafkaStubCommand) (*fluidv1.SandboxKafkaStubInfo, error) { + return s.transitionSandboxKafkaStub(ctx, req.GetSandboxId(), req.GetStubId(), "start") +} + +func (s *Server) StopSandboxKafkaStub(ctx context.Context, req *fluidv1.StopSandboxKafkaStubCommand) (*fluidv1.SandboxKafkaStubInfo, error) { + return s.transitionSandboxKafkaStub(ctx, req.GetSandboxId(), req.GetStubId(), "stop") +} + +func (s *Server) RestartSandboxKafkaStub(ctx context.Context, req *fluidv1.RestartSandboxKafkaStubCommand) (*fluidv1.SandboxKafkaStubInfo, error) { + return s.transitionSandboxKafkaStub(ctx, req.GetSandboxId(), req.GetStubId(), "restart") +} + +func (s *Server) transitionSandboxKafkaStub(ctx context.Context, sandboxID, stubID, action string) (*fluidv1.SandboxKafkaStubInfo, error) { + if err := requireStubIdentifiers(sandboxID, stubID); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if s.kafkaMgr == nil { + return nil, status.Error(codes.NotFound, "kafka stub not found") + } + + var ( + stub *kafkastub.SandboxStub + err error + ) + switch action { + case "start": + stub, err = s.kafkaMgr.StartSandboxStub(ctx, sandboxID, stubID) + case "stop": + stub, err = s.kafkaMgr.StopSandboxStub(ctx, sandboxID, stubID) + case "restart": + stub, err = s.kafkaMgr.RestartSandboxStub(ctx, sandboxID, stubID) + default: + return nil, status.Error(codes.InvalidArgument, "unsupported action") + } + if err != nil { + if err == kafkastub.ErrNotFound { + return nil, status.Error(codes.NotFound, "kafka stub not found") + } + return nil, status.Errorf(codes.Internal, "%s sandbox kafka stub: %v", action, err) + } + _ = s.store.UpsertSandboxKafkaStub(ctx, sandboxStubToState(stub)) + return sandboxStubToProto(stub), nil +} + +func (s *Server) GetKafkaCaptureStatus(ctx context.Context, req *fluidv1.KafkaCaptureStatusRequest) (*fluidv1.KafkaCaptureStatusResponse, error) { + if s.kafkaMgr == nil { + return &fluidv1.KafkaCaptureStatusResponse{}, nil + } + statuses, err := s.kafkaMgr.ListCaptureStatuses(ctx, req.GetCaptureConfigIds()) + if err != nil { + return nil, status.Errorf(codes.Internal, "list kafka capture statuses: %v", err) + } + resp := &fluidv1.KafkaCaptureStatusResponse{Statuses: make([]*fluidv1.KafkaCaptureStatus, 0, len(statuses))} + for _, item := range statuses { + _ = mergeCaptureStatus(ctx, s.store, item) + resp.Statuses = append(resp.Statuses, &fluidv1.KafkaCaptureStatus{ + CaptureConfigId: item.CaptureConfigID, + SourceVm: item.SourceVM, + State: item.State, + BufferedBytes: item.BufferedBytes, + SegmentCount: int32(item.SegmentCount), + UpdatedAtUnix: item.UpdatedAt.Unix(), + AttachedSandboxCount: int32(item.AttachedSandboxCount), + LastError: item.LastError, + LastResumeCursor: item.LastResumeCursor, + }) + } + return resp, nil +} + +func kafkaBrokerConfigForRequest(bindings []*fluidv1.KafkaCaptureConfigBinding) *provider.KafkaBrokerConfig { + return kafkaBrokerConfigForDataSources(nil, bindings) +} + +func kafkaBrokerConfigForDataSources(dataSources []*fluidv1.DataSourceAttachment, fallback []*fluidv1.KafkaCaptureConfigBinding) *provider.KafkaBrokerConfig { + if len(kafkaSandboxAttachmentsFromProto(dataSources, fallback)) == 0 { + return nil + } + return &provider.KafkaBrokerConfig{ + Port: 9092, + } +} + +func providerDataSourcesFromProto(dataSources []*fluidv1.DataSourceAttachment, fallback []*fluidv1.KafkaCaptureConfigBinding) []provider.DataSourceAttachment { + attachments := kafkaSandboxAttachmentsFromProto(dataSources, fallback) + out := make([]provider.DataSourceAttachment, 0, len(attachments)) + for _, attachment := range attachments { + out = append(out, provider.DataSourceAttachment{ + Type: provider.DataSourceTypeKafka, + ConfigRef: attachment.CaptureConfig.ID, + Kafka: &provider.KafkaDataSourceConfig{ + CaptureConfigID: attachment.CaptureConfig.ID, + Topics: append([]string(nil), attachment.Topics...), + ReplayWindow: attachment.ReplayWindow, + }, + }) + } + return out +} + +func kafkaSandboxAttachmentsFromProto(dataSources []*fluidv1.DataSourceAttachment, fallback []*fluidv1.KafkaCaptureConfigBinding) []kafkastub.SandboxAttachment { + if len(dataSources) > 0 { + attachments := make([]kafkastub.SandboxAttachment, 0, len(dataSources)) + for _, ds := range dataSources { + if ds.GetType() != fluidv1.DataSourceType_DATA_SOURCE_TYPE_KAFKA { + continue + } + kafkaCfg := ds.GetKafka() + if kafkaCfg == nil || kafkaCfg.GetCaptureConfig() == nil { + continue + } + cfg := captureConfigFromProto(kafkaCfg.GetCaptureConfig()) + topics := append([]string(nil), kafkaCfg.GetTopics()...) + if len(topics) == 0 { + topics = append(topics, cfg.Topics...) + } + attachments = append(attachments, kafkastub.SandboxAttachment{ + CaptureConfig: cfg, + Topics: topics, + ReplayWindow: time.Duration(kafkaCfg.GetReplayWindowSeconds()) * time.Second, + }) + } + return attachments + } + + attachments := make([]kafkastub.SandboxAttachment, 0, len(fallback)) + for _, binding := range fallback { + cfg := captureConfigFromProto(binding) + attachments = append(attachments, kafkastub.SandboxAttachment{ + CaptureConfig: cfg, + Topics: append([]string(nil), cfg.Topics...), + }) + } + return attachments +} + +func sandboxBrokerEndpoint(sandboxIP string) string { + if sandboxIP == "" { + return "127.0.0.1:9092" + } + return fmt.Sprintf("%s:9092", sandboxIP) +} + +func captureConfigFromProto(binding *fluidv1.KafkaCaptureConfigBinding) kafkastub.CaptureConfig { + return kafkastub.CaptureConfig{ + ID: binding.GetId(), + SourceVM: binding.GetSourceVm(), + BootstrapServers: append([]string(nil), binding.GetBootstrapServers()...), + Topics: append([]string(nil), binding.GetTopics()...), + Username: binding.GetUsername(), + Password: binding.GetPassword(), + SASLMechanism: binding.GetSaslMechanism(), + TLSEnabled: binding.GetTlsEnabled(), + InsecureSkipVerify: binding.GetInsecureSkipVerify(), + TLSCAPEM: binding.GetTlsCaPem(), + Codec: binding.GetCodec(), + RedactionRules: append([]string(nil), binding.GetRedactionRules()...), + MaxBufferAge: time.Duration(binding.GetMaxBufferAgeSeconds()) * time.Second, + MaxBufferBytes: binding.GetMaxBufferBytes(), + Enabled: binding.GetEnabled(), + } +} + +func captureConfigFromState(row *state.KafkaCaptureConfig) kafkastub.CaptureConfig { + return kafkastub.CaptureConfig{ + ID: row.ID, + SourceVM: row.SourceVM, + BootstrapServers: append([]string(nil), row.BootstrapServers...), + Topics: append([]string(nil), row.Topics...), + Username: row.Username, + Password: row.Password, + SASLMechanism: row.SASLMechanism, + TLSEnabled: row.TLSEnabled, + InsecureSkipVerify: row.InsecureSkipVerify, + TLSCAPEM: row.TLSCAPEM, + Codec: row.Codec, + RedactionRules: append([]string(nil), row.RedactionRules...), + MaxBufferAge: time.Duration(row.MaxBufferAgeSecs) * time.Second, + MaxBufferBytes: row.MaxBufferBytes, + Enabled: row.Enabled, + } +} + +func captureConfigToState(cfg kafkastub.CaptureConfig) *state.KafkaCaptureConfig { + return &state.KafkaCaptureConfig{ + ID: cfg.ID, + SourceVM: cfg.SourceVM, + BootstrapServers: append([]string(nil), cfg.BootstrapServers...), + Topics: append([]string(nil), cfg.Topics...), + Username: cfg.Username, + Password: cfg.Password, + SASLMechanism: cfg.SASLMechanism, + TLSEnabled: cfg.TLSEnabled, + InsecureSkipVerify: cfg.InsecureSkipVerify, + TLSCAPEM: cfg.TLSCAPEM, + Codec: cfg.Codec, + RedactionRules: append([]string(nil), cfg.RedactionRules...), + MaxBufferAgeSecs: int(cfg.MaxBufferAge / time.Second), + MaxBufferBytes: cfg.MaxBufferBytes, + Enabled: cfg.Enabled, + UpdatedAt: time.Now().UTC(), + } +} + +func captureStatusToState(item kafkastub.CaptureStatus) *state.KafkaCaptureConfig { + return &state.KafkaCaptureConfig{ + ID: item.CaptureConfigID, + SourceVM: item.SourceVM, + State: item.State, + BufferedBytes: item.BufferedBytes, + SegmentCount: item.SegmentCount, + LastError: item.LastError, + LastResumeCursor: item.LastResumeCursor, + UpdatedAt: item.UpdatedAt, + } +} + +func mergeCaptureStatus(ctx context.Context, store *state.Store, item kafkastub.CaptureStatus) error { + rows, err := store.ListKafkaCaptureConfigs(ctx, []string{item.CaptureConfigID}) + if err != nil { + return err + } + var row *state.KafkaCaptureConfig + if len(rows) > 0 { + row = rows[0] + } else { + row = &state.KafkaCaptureConfig{ID: item.CaptureConfigID, SourceVM: item.SourceVM} + } + row.SourceVM = item.SourceVM + row.State = item.State + row.BufferedBytes = item.BufferedBytes + row.SegmentCount = item.SegmentCount + row.LastError = item.LastError + row.LastResumeCursor = item.LastResumeCursor + row.UpdatedAt = item.UpdatedAt + return store.UpsertKafkaCaptureConfig(ctx, row) +} + +func sandboxStubToState(stub *kafkastub.SandboxStub) *state.SandboxKafkaStub { + return &state.SandboxKafkaStub{ + ID: stub.ID, + SandboxID: stub.SandboxID, + CaptureConfigID: stub.CaptureConfigID, + BrokerEndpoint: stub.BrokerEndpoint, + Topics: append([]string(nil), stub.Topics...), + ReplayWindowSeconds: int(stub.ReplayWindow / time.Second), + State: stub.State, + LastReplayCursor: stub.LastReplayCursor, + LastError: stub.LastError, + AutoStart: stub.AutoStart, + CreatedAt: stub.CreatedAt, + UpdatedAt: stub.UpdatedAt, + } +} + +func sandboxStubFromState(row *state.SandboxKafkaStub) kafkastub.SandboxStub { + return kafkastub.SandboxStub{ + ID: row.ID, + SandboxID: row.SandboxID, + CaptureConfigID: row.CaptureConfigID, + BrokerEndpoint: row.BrokerEndpoint, + Topics: append([]string(nil), row.Topics...), + ReplayWindow: time.Duration(row.ReplayWindowSeconds) * time.Second, + State: row.State, + LastReplayCursor: row.LastReplayCursor, + LastError: row.LastError, + AutoStart: row.AutoStart, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func sandboxStubToProto(stub *kafkastub.SandboxStub) *fluidv1.SandboxKafkaStubInfo { + return &fluidv1.SandboxKafkaStubInfo{ + StubId: stub.ID, + SandboxId: stub.SandboxID, + CaptureConfigId: stub.CaptureConfigID, + BrokerEndpoint: stub.BrokerEndpoint, + Topics: append([]string(nil), stub.Topics...), + ReplayWindowSeconds: int32(stub.ReplayWindow / time.Second), + State: stubStateToProto(stub.State), + LastReplayCursor: stub.LastReplayCursor, + AutoStart: stub.AutoStart, + LastError: stub.LastError, + } +} + +func stubStateToProto(v string) fluidv1.KafkaStubState { + switch v { + case kafkastub.StateRunning: + return fluidv1.KafkaStubState_KAFKA_STUB_STATE_RUNNING + case kafkastub.StatePaused: + return fluidv1.KafkaStubState_KAFKA_STUB_STATE_PAUSED + case kafkastub.StateError: + return fluidv1.KafkaStubState_KAFKA_STUB_STATE_ERROR + default: + return fluidv1.KafkaStubState_KAFKA_STUB_STATE_STOPPED + } +} + +func requireStubIdentifiers(sandboxID, stubID string) error { + if sandboxID == "" || stubID == "" { + return fmt.Errorf("sandbox_id and stub_id are required") + } + return nil +} diff --git a/fluid-daemon/internal/kafkastub/manager.go b/fluid-daemon/internal/kafkastub/manager.go new file mode 100644 index 00000000..71bae759 --- /dev/null +++ b/fluid-daemon/internal/kafkastub/manager.go @@ -0,0 +1,1143 @@ +package kafkastub + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/redact" +) + +const ( + StateStopped = "stopped" + StateRunning = "running" + StatePaused = "paused" + StateError = "error" +) + +var ErrNotFound = errors.New("kafkastub: not found") + +type CaptureConfig struct { + ID string + SourceVM string + BootstrapServers []string + Topics []string + Username string + Password string + SASLMechanism string + TLSEnabled bool + InsecureSkipVerify bool + TLSCAPEM string + Codec string + RedactionRules []string + MaxBufferAge time.Duration + MaxBufferBytes int64 + Enabled bool +} + +type SandboxStub struct { + ID string + SandboxID string + CaptureConfigID string + BrokerEndpoint string + Topics []string + ReplayWindow time.Duration + State string + LastReplayCursor string + LastError string + AutoStart bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type SandboxAttachment struct { + CaptureConfig CaptureConfig + Topics []string + ReplayWindow time.Duration +} + +type CaptureStatus struct { + CaptureConfigID string + SourceVM string + State string + BufferedBytes int64 + SegmentCount int + UpdatedAt time.Time + AttachedSandboxCount int + LastError string + LastResumeCursor string +} + +type Header struct { + Key string + Value []byte +} + +type Record struct { + Topic string + Partition int32 + Offset int64 + Key []byte + Headers []Header + Timestamp time.Time + Value []byte +} + +type Hooks struct { + OnCaptureStatus func(CaptureStatus) + OnSandboxStub func(*SandboxStub) +} + +type Option func(*Manager) + +func WithTransport(transport Transport) Option { + return func(m *Manager) { + m.transport = transport + } +} + +func WithHooks(hooks Hooks) Option { + return func(m *Manager) { + m.hooks = hooks + } +} + +func WithSleep(fn func(context.Context, time.Duration) error) Option { + return func(m *Manager) { + m.sleep = fn + } +} + +type segment struct { + path string + sizeBytes int64 + capturedAt time.Time +} + +type captureRuntime struct { + cfg CaptureConfig + status CaptureStatus + segments []segment + attached map[string]int + captureCtx context.Context + captureCancel context.CancelFunc + loaded bool +} + +type replayRuntime struct { + ctx context.Context + cancel context.CancelFunc +} + +type Manager struct { + baseDir string + redactor *redact.Redactor + logger *slog.Logger + transport Transport + hooks Hooks + sleep func(context.Context, time.Duration) error + + mu sync.Mutex + captures map[string]*captureRuntime + stubs map[string]*SandboxStub + replays map[string]*replayRuntime +} + +func NewManager(baseDir string, redactor *redact.Redactor, logger *slog.Logger, opts ...Option) (*Manager, error) { + if logger == nil { + logger = slog.Default() + } + if redactor == nil { + redactor = redact.New() + } + if err := os.MkdirAll(baseDir, 0o755); err != nil { + return nil, fmt.Errorf("create kafka stub base dir: %w", err) + } + m := &Manager{ + baseDir: baseDir, + redactor: redactor, + logger: logger.With("component", "kafkastub"), + transport: noopTransport{}, + hooks: Hooks{}, + sleep: func(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } + }, + captures: make(map[string]*captureRuntime), + stubs: make(map[string]*SandboxStub), + replays: make(map[string]*replayRuntime), + } + for _, opt := range opts { + opt(m) + } + return m, nil +} + +func (m *Manager) Restore(_ context.Context, configs []CaptureConfig, stubs []SandboxStub) error { + for _, cfg := range configs { + m.EnsureCaptureConfig(cfg) + } + + var toStart []string + m.mu.Lock() + defer m.mu.Unlock() + for _, stub := range stubs { + cp := stub + if cp.Topics == nil { + cp.Topics = []string{} + } + if cp.ReplayWindow == 0 { + if runtime, ok := m.captures[cp.CaptureConfigID]; ok { + cp.ReplayWindow = defaultReplayWindow(runtime.cfg.MaxBufferAge) + } else { + cp.ReplayWindow = defaultReplayWindow(0) + } + } + m.stubs[cp.ID] = cloneStub(&cp) + if runtime, ok := m.captures[cp.CaptureConfigID]; ok { + runtime.attached[cp.SandboxID] = 1 + runtime.status.AttachedSandboxCount = len(runtime.attached) + m.touchCaptureLocked(runtime) + } + if cp.State == StateRunning { + toStart = append(toStart, cp.ID) + } + } + go func(ids []string) { + for _, stubID := range ids { + if _, err := m.StartSandboxStub(context.Background(), stubForID(m, stubID), stubID); err != nil { + m.logger.Warn("restore replay worker failed", "stub_id", stubID, "error", err) + } + } + }(append([]string(nil), toStart...)) + return nil +} + +func (m *Manager) EnsureCaptureConfig(cfg CaptureConfig) { + runtime, status := m.ensureAndMaybeStartCapture(cfg) + m.notifyCapture(status) + if runtime == nil { + return + } +} + +func (m *Manager) AttachSandbox(ctx context.Context, sandboxID, brokerEndpoint string, attachments []SandboxAttachment) ([]*SandboxStub, error) { + now := time.Now().UTC() + stubs := make([]*SandboxStub, 0, len(attachments)) + for _, attachment := range attachments { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + cfg := attachment.CaptureConfig + m.EnsureCaptureConfig(cfg) + topics := append([]string(nil), attachment.Topics...) + if len(topics) == 0 { + topics = append(topics, cfg.Topics...) + } + replayWindow := attachment.ReplayWindow + if replayWindow <= 0 { + replayWindow = defaultReplayWindow(cfg.MaxBufferAge) + } + + stub := &SandboxStub{ + ID: sanitizeID(fmt.Sprintf("%s-%s", sandboxID, cfg.ID)), + SandboxID: sandboxID, + CaptureConfigID: cfg.ID, + BrokerEndpoint: normalizeBrokerEndpoint(brokerEndpoint), + Topics: topics, + ReplayWindow: replayWindow, + State: StateStopped, + LastReplayCursor: "head", + AutoStart: true, + CreatedAt: now, + UpdatedAt: now, + } + + m.mu.Lock() + runtime := m.ensureCaptureLocked(cfg) + runtime.attached[sandboxID] = 1 + runtime.status.AttachedSandboxCount = len(runtime.attached) + m.touchCaptureLocked(runtime) + m.stubs[stub.ID] = cloneStub(stub) + m.mu.Unlock() + + m.notifyCapture(runtime.status) + m.notifyStub(stub) + + if stub.AutoStart { + started, err := m.StartSandboxStub(ctx, sandboxID, stub.ID) + if err != nil { + return nil, err + } + stub = started + } + stubs = append(stubs, cloneStub(stub)) + } + return stubs, nil +} + +func (m *Manager) DetachSandbox(_ context.Context, sandboxID string) error { + var captureStatuses []CaptureStatus + + m.mu.Lock() + for id, stub := range m.stubs { + if stub.SandboxID != sandboxID { + continue + } + if replay, ok := m.replays[id]; ok { + replay.cancel() + delete(m.replays, id) + } + if runtime, ok := m.captures[stub.CaptureConfigID]; ok { + delete(runtime.attached, sandboxID) + runtime.status.AttachedSandboxCount = len(runtime.attached) + m.touchCaptureLocked(runtime) + captureStatuses = append(captureStatuses, runtime.status) + } + delete(m.stubs, id) + } + m.mu.Unlock() + + for _, status := range captureStatuses { + m.notifyCapture(status) + } + return nil +} + +func (m *Manager) ListSandboxStubs(_ context.Context, sandboxID string) ([]*SandboxStub, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var stubs []*SandboxStub + for _, stub := range m.stubs { + if stub.SandboxID == sandboxID { + stubs = append(stubs, cloneStub(stub)) + } + } + sort.Slice(stubs, func(i, j int) bool { + return stubs[i].CreatedAt.Before(stubs[j].CreatedAt) + }) + return stubs, nil +} + +func (m *Manager) GetSandboxStub(_ context.Context, sandboxID, stubID string) (*SandboxStub, error) { + m.mu.Lock() + defer m.mu.Unlock() + + stub, ok := m.stubs[stubID] + if !ok || stub.SandboxID != sandboxID { + return nil, ErrNotFound + } + return cloneStub(stub), nil +} + +func (m *Manager) StartSandboxStub(ctx context.Context, sandboxID, stubID string) (*SandboxStub, error) { + return m.transitionStub(ctx, sandboxID, stubID, "start") +} + +func (m *Manager) StopSandboxStub(ctx context.Context, sandboxID, stubID string) (*SandboxStub, error) { + return m.transitionStub(ctx, sandboxID, stubID, "stop") +} + +func (m *Manager) RestartSandboxStub(ctx context.Context, sandboxID, stubID string) (*SandboxStub, error) { + return m.transitionStub(ctx, sandboxID, stubID, "restart") +} + +func (m *Manager) ListCaptureStatuses(_ context.Context, ids []string) ([]CaptureStatus, error) { + m.mu.Lock() + defer m.mu.Unlock() + + allowed := make(map[string]bool, len(ids)) + for _, id := range ids { + allowed[id] = true + } + + var statuses []CaptureStatus + for id, runtime := range m.captures { + if len(allowed) > 0 && !allowed[id] { + continue + } + statuses = append(statuses, runtime.status) + } + sort.Slice(statuses, func(i, j int) bool { + return statuses[i].CaptureConfigID < statuses[j].CaptureConfigID + }) + return statuses, nil +} + +func (m *Manager) RecordCapture(ctx context.Context, configID string, payload string, capturedAt time.Time) (CaptureStatus, error) { + m.mu.Lock() + runtime, ok := m.captures[configID] + if !ok { + m.mu.Unlock() + return CaptureStatus{}, ErrNotFound + } + cfg := runtime.cfg + m.mu.Unlock() + + record := Record{ + Topic: firstTopic(cfg.Topics), + Partition: 0, + Offset: capturedAt.UTC().UnixNano(), + Timestamp: capturedAt.UTC(), + Value: []byte(payload), + } + if err := m.persistRecord(configID, record); err != nil { + return CaptureStatus{}, err + } + statuses, err := m.ListCaptureStatuses(ctx, []string{configID}) + if err != nil { + return CaptureStatus{}, err + } + if len(statuses) == 0 { + return CaptureStatus{}, ErrNotFound + } + return statuses[0], nil +} + +func (m *Manager) ensureAndMaybeStartCapture(cfg CaptureConfig) (*captureRuntime, CaptureStatus) { + m.mu.Lock() + runtime := m.ensureCaptureLocked(cfg) + status := runtime.status + if cfg.Enabled && runtime.captureCancel == nil { + captureCtx, cancel := context.WithCancel(context.Background()) + runtime.captureCtx = captureCtx + runtime.captureCancel = cancel + go m.runCaptureWorker(captureCtx, cfg.ID) + } + if !cfg.Enabled { + if runtime.captureCancel != nil { + runtime.captureCancel() + runtime.captureCancel = nil + runtime.captureCtx = nil + } + runtime.status.State = StateStopped + runtime.status.LastError = "" + m.touchCaptureLocked(runtime) + status = runtime.status + } + m.mu.Unlock() + return runtime, status +} + +func (m *Manager) ensureCaptureLocked(cfg CaptureConfig) *captureRuntime { + runtime, ok := m.captures[cfg.ID] + if ok { + runtime.cfg = cfg + if !runtime.loaded { + m.loadSegmentsLocked(runtime) + } + return runtime + } + runtime = &captureRuntime{ + cfg: cfg, + attached: make(map[string]int), + status: CaptureStatus{ + CaptureConfigID: cfg.ID, + SourceVM: cfg.SourceVM, + State: StateStopped, + UpdatedAt: time.Now().UTC(), + }, + } + m.loadSegmentsLocked(runtime) + m.captures[cfg.ID] = runtime + return runtime +} + +func (m *Manager) loadSegmentsLocked(runtime *captureRuntime) { + runtime.loaded = true + segDir := filepath.Join(m.baseDir, sanitizeID(runtime.cfg.ID)) + entries, err := os.ReadDir(segDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return + } + runtime.status.State = StateError + runtime.status.LastError = err.Error() + m.touchCaptureLocked(runtime) + return + } + + var segments []segment + var lastCursor string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + path := filepath.Join(segDir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var stored persistedRecord + if err := json.Unmarshal(data, &stored); err != nil { + continue + } + ts := time.Unix(0, stored.TimestampUnixNano).UTC() + segments = append(segments, segment{ + path: path, + sizeBytes: int64(len(data)), + capturedAt: ts, + }) + lastCursor = stored.Cursor + } + sort.Slice(segments, func(i, j int) bool { + return segments[i].capturedAt.Before(segments[j].capturedAt) + }) + runtime.segments = segments + runtime.status.BufferedBytes = 0 + for _, seg := range segments { + runtime.status.BufferedBytes += seg.sizeBytes + } + runtime.status.SegmentCount = len(segments) + runtime.status.LastResumeCursor = lastCursor + m.touchCaptureLocked(runtime) +} + +func (m *Manager) runCaptureWorker(ctx context.Context, configID string) { + backoff := time.Second + for { + cfg, ok := m.captureConfigSnapshot(configID) + if !ok { + return + } + + consumer, err := m.transport.NewConsumer(cfg) + if err != nil { + if !m.updateCaptureError(configID, err) { + return + } + if m.sleep(ctx, backoff) != nil { + return + } + continue + } + + m.updateCaptureRunning(configID) + for { + record, err := consumer.ReadMessage(ctx) + if err != nil { + _ = consumer.Close() + if ctx.Err() != nil { + return + } + if !m.updateCaptureError(configID, err) { + return + } + if m.sleep(ctx, backoff) != nil { + return + } + break + } + if err := m.persistRecord(configID, record); err != nil { + if !m.updateCaptureError(configID, err) { + return + } + continue + } + m.updateCaptureCursor(configID, recordCursor(record)) + } + } +} + +func (m *Manager) persistRecord(configID string, record Record) error { + m.mu.Lock() + runtime, ok := m.captures[configID] + if !ok { + m.mu.Unlock() + return ErrNotFound + } + cfg := runtime.cfg + m.mu.Unlock() + + redacted, err := m.redactRecord(cfg, record) + if err != nil { + return err + } + data, err := json.Marshal(redacted) + if err != nil { + return fmt.Errorf("marshal record: %w", err) + } + + segDir := filepath.Join(m.baseDir, sanitizeID(configID)) + if err := os.MkdirAll(segDir, 0o755); err != nil { + return fmt.Errorf("create segment dir: %w", err) + } + segPath := filepath.Join(segDir, segmentFileName(record)) + if err := os.WriteFile(segPath, data, 0o600); err != nil { + return fmt.Errorf("write segment: %w", err) + } + + m.mu.Lock() + defer m.mu.Unlock() + runtime, ok = m.captures[configID] + if !ok { + return ErrNotFound + } + runtime.segments = append(runtime.segments, segment{ + path: segPath, + sizeBytes: int64(len(data)), + capturedAt: record.Timestamp.UTC(), + }) + sort.Slice(runtime.segments, func(i, j int) bool { + return runtime.segments[i].capturedAt.Before(runtime.segments[j].capturedAt) + }) + runtime.status.BufferedBytes += int64(len(data)) + runtime.status.SegmentCount = len(runtime.segments) + runtime.status.State = StateRunning + runtime.status.LastError = "" + m.touchCaptureLocked(runtime) + m.evictLocked(runtime, time.Now().UTC()) + status := runtime.status + go m.notifyCapture(status) + return nil +} + +func (m *Manager) transitionStub(ctx context.Context, sandboxID, stubID, action string) (*SandboxStub, error) { + switch action { + case "stop": + return m.stopReplay(stubID, sandboxID) + case "start": + return m.startReplay(ctx, stubID, sandboxID, false) + case "restart": + return m.startReplay(ctx, stubID, sandboxID, true) + default: + return nil, fmt.Errorf("unsupported action %q", action) + } +} + +func (m *Manager) stopReplay(stubID, sandboxID string) (*SandboxStub, error) { + m.mu.Lock() + defer m.mu.Unlock() + stub, ok := m.stubs[stubID] + if !ok || stub.SandboxID != sandboxID { + return nil, ErrNotFound + } + if replay, ok := m.replays[stubID]; ok { + replay.cancel() + delete(m.replays, stubID) + } + stub.State = StateStopped + stub.LastError = "" + stub.UpdatedAt = time.Now().UTC() + cp := cloneStub(stub) + go m.notifyStub(cp) + return cp, nil +} + +func (m *Manager) startReplay(ctx context.Context, stubID, sandboxID string, reset bool) (*SandboxStub, error) { + m.mu.Lock() + stub, ok := m.stubs[stubID] + if !ok || stub.SandboxID != sandboxID { + m.mu.Unlock() + return nil, ErrNotFound + } + if replay, ok := m.replays[stubID]; ok { + replay.cancel() + delete(m.replays, stubID) + } + if reset { + stub.LastReplayCursor = "head" + } + replayCtx, cancel := context.WithCancel(context.Background()) + m.replays[stubID] = &replayRuntime{ctx: replayCtx, cancel: cancel} + stub.State = StateRunning + stub.LastError = "" + stub.UpdatedAt = time.Now().UTC() + cp := cloneStub(stub) + m.mu.Unlock() + + go m.runReplayWorker(replayCtx, stubID) + m.notifyStub(cp) + return cp, nil +} + +func (m *Manager) runReplayWorker(ctx context.Context, stubID string) { + stub, cfg, records, err := m.snapshotReplay(stubID) + if err != nil { + m.setStubError(stubID, err) + return + } + if len(records) == 0 { + m.setStubError(stubID, fmt.Errorf("no captured kafka records available for replay")) + return + } + + producer, err := m.transport.NewProducer(stub.BrokerEndpoint) + if err != nil { + m.setStubError(stubID, err) + return + } + defer producer.Close() + + started := stub.LastReplayCursor == "" || stub.LastReplayCursor == "head" + lastSentAt := time.Time{} + for _, record := range records { + if !started { + if record.Cursor == stub.LastReplayCursor { + started = true + } + continue + } + if !lastSentAt.IsZero() { + delay := clampDelay(time.Unix(0, record.TimestampUnixNano).UTC().Sub(lastSentAt)) + if delay > 0 { + if err := m.sleep(ctx, delay); err != nil { + return + } + } + } + if err := producer.WriteMessage(ctx, Record{ + Topic: record.Topic, + Partition: record.Partition, + Offset: record.Offset, + Key: record.KeyBytes(), + Headers: record.HeadersList(), + Timestamp: time.Unix(0, record.TimestampUnixNano).UTC(), + Value: record.ValueBytes(), + }); err != nil { + m.setStubError(stubID, err) + return + } + lastSentAt = time.Unix(0, record.TimestampUnixNano).UTC() + m.updateStubCursor(stubID, record.Cursor) + } + m.finishStubReplay(stubID) + + _ = cfg +} + +func (m *Manager) snapshotReplay(stubID string) (*SandboxStub, CaptureConfig, []persistedRecord, error) { + m.mu.Lock() + stub, ok := m.stubs[stubID] + if !ok { + m.mu.Unlock() + return nil, CaptureConfig{}, nil, ErrNotFound + } + runtime, ok := m.captures[stub.CaptureConfigID] + if !ok { + m.mu.Unlock() + return nil, CaptureConfig{}, nil, ErrNotFound + } + cfg := runtime.cfg + stubCopy := cloneStub(stub) + m.mu.Unlock() + + records, err := m.loadRecords(cfg.ID) + if err != nil { + return nil, CaptureConfig{}, nil, err + } + if stubCopy.ReplayWindow > 0 { + cutoff := time.Now().UTC().Add(-stubCopy.ReplayWindow) + filtered := records[:0] + for _, record := range records { + if time.Unix(0, record.TimestampUnixNano).UTC().Before(cutoff) { + continue + } + filtered = append(filtered, record) + } + records = filtered + } + if len(stubCopy.Topics) > 0 { + filtered := records[:0] + for _, record := range records { + if !slices.Contains(stubCopy.Topics, record.Topic) { + continue + } + filtered = append(filtered, record) + } + records = filtered + } + return stubCopy, cfg, records, nil +} + +func (m *Manager) loadRecords(configID string) ([]persistedRecord, error) { + segDir := filepath.Join(m.baseDir, sanitizeID(configID)) + entries, err := os.ReadDir(segDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + records := make([]persistedRecord, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(segDir, entry.Name())) + if err != nil { + return nil, err + } + var record persistedRecord + if err := json.Unmarshal(data, &record); err != nil { + return nil, err + } + records = append(records, record) + } + sort.Slice(records, func(i, j int) bool { + if records[i].TimestampUnixNano != records[j].TimestampUnixNano { + return records[i].TimestampUnixNano < records[j].TimestampUnixNano + } + if records[i].Partition != records[j].Partition { + return records[i].Partition < records[j].Partition + } + return records[i].Offset < records[j].Offset + }) + return records, nil +} + +func (m *Manager) evictLocked(runtime *captureRuntime, now time.Time) { + for len(runtime.segments) > 0 { + head := runtime.segments[0] + remove := false + if runtime.cfg.MaxBufferAge > 0 && now.Sub(head.capturedAt) > runtime.cfg.MaxBufferAge { + remove = true + } + if !remove && runtime.cfg.MaxBufferBytes > 0 && runtime.status.BufferedBytes > runtime.cfg.MaxBufferBytes { + remove = true + } + if !remove { + break + } + _ = os.Remove(head.path) + runtime.status.BufferedBytes -= head.sizeBytes + runtime.segments = runtime.segments[1:] + runtime.status.SegmentCount = len(runtime.segments) + } + m.touchCaptureLocked(runtime) +} + +func (m *Manager) captureConfigSnapshot(configID string) (CaptureConfig, bool) { + m.mu.Lock() + defer m.mu.Unlock() + + runtime, ok := m.captures[configID] + if !ok || !runtime.cfg.Enabled { + return CaptureConfig{}, false + } + return runtime.cfg, true +} + +func (m *Manager) updateCaptureRunning(configID string) { + m.mu.Lock() + runtime, ok := m.captures[configID] + if !ok { + m.mu.Unlock() + return + } + runtime.status.State = StateRunning + runtime.status.LastError = "" + m.touchCaptureLocked(runtime) + status := runtime.status + m.mu.Unlock() + m.notifyCapture(status) +} + +func (m *Manager) updateCaptureError(configID string, err error) bool { + m.mu.Lock() + defer m.mu.Unlock() + runtime, ok := m.captures[configID] + if !ok { + return false + } + runtime.status.State = StateError + runtime.status.LastError = err.Error() + m.touchCaptureLocked(runtime) + go m.notifyCapture(runtime.status) + return runtime.captureCtx != nil +} + +func (m *Manager) updateCaptureCursor(configID, cursor string) { + m.mu.Lock() + runtime, ok := m.captures[configID] + if !ok { + m.mu.Unlock() + return + } + runtime.status.State = StateRunning + runtime.status.LastResumeCursor = cursor + runtime.status.LastError = "" + m.touchCaptureLocked(runtime) + status := runtime.status + m.mu.Unlock() + m.notifyCapture(status) +} + +func (m *Manager) updateStubCursor(stubID, cursor string) { + m.mu.Lock() + stub, ok := m.stubs[stubID] + if !ok { + m.mu.Unlock() + return + } + stub.LastReplayCursor = cursor + stub.State = StateRunning + stub.LastError = "" + stub.UpdatedAt = time.Now().UTC() + cp := cloneStub(stub) + m.mu.Unlock() + m.notifyStub(cp) +} + +func (m *Manager) finishStubReplay(stubID string) { + m.mu.Lock() + stub, ok := m.stubs[stubID] + if !ok { + m.mu.Unlock() + return + } + delete(m.replays, stubID) + stub.State = StateStopped + stub.LastError = "" + stub.UpdatedAt = time.Now().UTC() + cp := cloneStub(stub) + m.mu.Unlock() + m.notifyStub(cp) +} + +func (m *Manager) setStubError(stubID string, err error) { + m.mu.Lock() + stub, ok := m.stubs[stubID] + if !ok { + m.mu.Unlock() + return + } + delete(m.replays, stubID) + stub.State = StateError + stub.LastError = err.Error() + stub.UpdatedAt = time.Now().UTC() + cp := cloneStub(stub) + m.mu.Unlock() + m.notifyStub(cp) +} + +func (m *Manager) touchCaptureLocked(runtime *captureRuntime) { + runtime.status.SourceVM = runtime.cfg.SourceVM + runtime.status.UpdatedAt = time.Now().UTC() +} + +func (m *Manager) redactRecord(cfg CaptureConfig, record Record) (persistedRecord, error) { + if err := validateCodec(cfg.Codec); err != nil { + return persistedRecord{}, err + } + value := record.Value + switch cfg.Codec { + case "", "text": + value = []byte(m.redactor.Redact(string(record.Value))) + case "json": + redacted, err := redactJSON(m.redactor, record.Value, cfg.RedactionRules) + if err != nil { + value = []byte(m.redactor.Redact(string(record.Value))) + } else { + value = redacted + } + } + + key := record.Key + if utf8.Valid(key) { + key = []byte(m.redactor.Redact(string(key))) + } + headers := make([]persistedHeader, 0, len(record.Headers)) + for _, header := range record.Headers { + headerValue := header.Value + if utf8.Valid(headerValue) { + headerValue = []byte(m.redactor.Redact(string(headerValue))) + } + headers = append(headers, persistedHeader{ + Key: header.Key, + Value: base64.StdEncoding.EncodeToString(headerValue), + }) + } + + return persistedRecord{ + Cursor: recordCursor(record), + Topic: record.Topic, + Partition: record.Partition, + Offset: record.Offset, + Key: base64.StdEncoding.EncodeToString(key), + Headers: headers, + TimestampUnixNano: record.Timestamp.UTC().UnixNano(), + Value: base64.StdEncoding.EncodeToString(value), + }, nil +} + +func (m *Manager) notifyCapture(status CaptureStatus) { + if m.hooks.OnCaptureStatus != nil { + m.hooks.OnCaptureStatus(status) + } +} + +func (m *Manager) notifyStub(stub *SandboxStub) { + if m.hooks.OnSandboxStub != nil { + m.hooks.OnSandboxStub(cloneStub(stub)) + } +} + +type persistedHeader struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type persistedRecord struct { + Cursor string `json:"cursor"` + Topic string `json:"topic"` + Partition int32 `json:"partition"` + Offset int64 `json:"offset"` + Key string `json:"key"` + Headers []persistedHeader `json:"headers,omitempty"` + TimestampUnixNano int64 `json:"timestamp_unix_nano"` + Value string `json:"value"` +} + +func (p persistedRecord) KeyBytes() []byte { + data, _ := base64.StdEncoding.DecodeString(p.Key) + return data +} + +func (p persistedRecord) ValueBytes() []byte { + data, _ := base64.StdEncoding.DecodeString(p.Value) + return data +} + +func (p persistedRecord) HeadersList() []Header { + headers := make([]Header, 0, len(p.Headers)) + for _, header := range p.Headers { + value, _ := base64.StdEncoding.DecodeString(header.Value) + headers = append(headers, Header{Key: header.Key, Value: value}) + } + return headers +} + +func redactJSON(redactor *redact.Redactor, payload []byte, rules []string) ([]byte, error) { + var doc any + if err := json.Unmarshal(payload, &doc); err != nil { + return nil, err + } + if len(rules) == 0 { + doc = redactor.RedactAny(doc) + } else { + for _, rule := range rules { + applyJSONRule(redactor, doc, strings.Split(rule, ".")) + } + } + return json.Marshal(doc) +} + +func applyJSONRule(redactor *redact.Redactor, node any, path []string) { + if len(path) == 0 { + return + } + obj, ok := node.(map[string]any) + if !ok { + return + } + value, ok := obj[path[0]] + if !ok { + return + } + if len(path) == 1 { + obj[path[0]] = redactor.RedactAny(value) + return + } + applyJSONRule(redactor, value, path[1:]) +} + +func segmentFileName(record Record) string { + return fmt.Sprintf("%020d-%s-%06d-%020d.json", + record.Timestamp.UTC().UnixNano(), + sanitizeID(record.Topic), + record.Partition, + record.Offset, + ) +} + +func recordCursor(record Record) string { + return fmt.Sprintf("%s/%d/%d", record.Topic, record.Partition, record.Offset) +} + +func defaultReplayWindow(maxAge time.Duration) time.Duration { + if maxAge > 0 && maxAge < 5*time.Minute { + return maxAge + } + return 5 * time.Minute +} + +func clampDelay(delay time.Duration) time.Duration { + if delay <= 0 { + return 0 + } + if delay > 2*time.Second { + return 2 * time.Second + } + return delay +} + +func sanitizeID(v string) string { + v = strings.ToLower(v) + v = strings.ReplaceAll(v, "/", "-") + v = strings.ReplaceAll(v, ":", "-") + v = strings.ReplaceAll(v, "_", "-") + return v +} + +func validateCodec(codec string) error { + switch codec { + case "", "json", "text": + return nil + default: + return fmt.Errorf("unsupported codec %q", codec) + } +} + +func cloneStub(stub *SandboxStub) *SandboxStub { + cp := *stub + cp.Topics = append([]string(nil), stub.Topics...) + return &cp +} + +func stubForID(m *Manager, stubID string) string { + m.mu.Lock() + defer m.mu.Unlock() + if stub, ok := m.stubs[stubID]; ok { + return stub.SandboxID + } + return "" +} + +func firstTopic(topics []string) string { + if len(topics) == 0 { + return "logs" + } + return topics[0] +} + +func normalizeBrokerEndpoint(endpoint string) string { + if endpoint == "" { + return "127.0.0.1:9092" + } + if strings.Contains(endpoint, ":") { + return endpoint + } + return endpoint + ":9092" +} diff --git a/fluid-daemon/internal/kafkastub/manager_test.go b/fluid-daemon/internal/kafkastub/manager_test.go new file mode 100644 index 00000000..c729f7b0 --- /dev/null +++ b/fluid-daemon/internal/kafkastub/manager_test.go @@ -0,0 +1,207 @@ +package kafkastub + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/redact" +) + +func TestRecordCaptureEvictsByMaxBytes(t *testing.T) { + t.Parallel() + + mgr, err := NewManager(t.TempDir(), redact.New(), slog.Default()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + mgr.EnsureCaptureConfig(CaptureConfig{ + ID: "cfg-1", + SourceVM: "logstash-1", + Codec: "text", + MaxBufferBytes: 900, + Enabled: true, + }) + + ctx := context.Background() + if _, err := mgr.RecordCapture(ctx, "cfg-1", strings.Repeat("first-payload-", 30), time.Unix(1, 0)); err != nil { + t.Fatalf("RecordCapture #1: %v", err) + } + status, err := mgr.RecordCapture(ctx, "cfg-1", strings.Repeat("second-payload-", 30), time.Unix(2, 0)) + if err != nil { + t.Fatalf("RecordCapture #2: %v", err) + } + + if status.SegmentCount != 1 { + t.Fatalf("expected 1 segment after eviction, got %d", status.SegmentCount) + } + if status.BufferedBytes > 900 { + t.Fatalf("expected buffered bytes <= 900, got %d", status.BufferedBytes) + } +} + +func TestRecordCaptureRejectsUnsupportedCodec(t *testing.T) { + t.Parallel() + + mgr, err := NewManager(t.TempDir(), redact.New(), slog.Default()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + mgr.EnsureCaptureConfig(CaptureConfig{ + ID: "cfg-unsupported", + SourceVM: "logstash-1", + Codec: "avro", + Enabled: true, + }) + + if _, err := mgr.RecordCapture(context.Background(), "cfg-unsupported", "payload", time.Now()); err == nil { + t.Fatal("expected unsupported codec error") + } +} + +func TestSandboxStubLifecycle(t *testing.T) { + t.Parallel() + + mgr, err := NewManager(t.TempDir(), redact.New(), slog.Default()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + stubs, err := mgr.AttachSandbox(context.Background(), "SBX-1", "10.0.0.10", []SandboxAttachment{{ + CaptureConfig: CaptureConfig{ + ID: "cfg-1", + SourceVM: "logstash-1", + Topics: []string{"logs"}, + Codec: "json", + MaxBufferAge: 10 * time.Minute, + MaxBufferBytes: 1024, + Enabled: true, + }, + }}) + if err != nil { + t.Fatalf("AttachSandbox: %v", err) + } + if len(stubs) != 1 { + t.Fatalf("expected 1 stub, got %d", len(stubs)) + } + if stubs[0].BrokerEndpoint != "10.0.0.10:9092" { + t.Fatalf("unexpected broker endpoint %q", stubs[0].BrokerEndpoint) + } + + stopped, err := mgr.StopSandboxStub(context.Background(), "SBX-1", stubs[0].ID) + if err != nil { + t.Fatalf("StopSandboxStub: %v", err) + } + if stopped.State != StateStopped { + t.Fatalf("expected stopped state, got %q", stopped.State) + } + + restarted, err := mgr.RestartSandboxStub(context.Background(), "SBX-1", stubs[0].ID) + if err != nil { + t.Fatalf("RestartSandboxStub: %v", err) + } + if restarted.State != StateRunning { + t.Fatalf("expected running state, got %q", restarted.State) + } + if restarted.LastReplayCursor != "head" { + t.Fatalf("expected replay cursor reset to head, got %q", restarted.LastReplayCursor) + } + + statuses, err := mgr.ListCaptureStatuses(context.Background(), []string{"cfg-1"}) + if err != nil { + t.Fatalf("ListCaptureStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].AttachedSandboxCount != 1 { + t.Fatalf("expected one attached sandbox, got %+v", statuses) + } + + if err := mgr.DetachSandbox(context.Background(), "SBX-1"); err != nil { + t.Fatalf("DetachSandbox: %v", err) + } + listed, err := mgr.ListSandboxStubs(context.Background(), "SBX-1") + if err != nil { + t.Fatalf("ListSandboxStubs: %v", err) + } + if len(listed) != 0 { + t.Fatalf("expected no stubs after detach, got %d", len(listed)) + } +} + +func TestAttachSandbox_AppliesTopicAndReplayOverrides(t *testing.T) { + t.Parallel() + + mgr, err := NewManager(t.TempDir(), redact.New(), slog.Default()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + stubs, err := mgr.AttachSandbox(context.Background(), "SBX-2", "10.0.0.11", []SandboxAttachment{{ + CaptureConfig: CaptureConfig{ + ID: "cfg-2", + SourceVM: "logstash-2", + Topics: []string{"logs", "metrics"}, + Codec: "json", + MaxBufferAge: 15 * time.Minute, + MaxBufferBytes: 4096, + Enabled: true, + }, + Topics: []string{"logs"}, + ReplayWindow: 2 * time.Minute, + }}) + if err != nil { + t.Fatalf("AttachSandbox: %v", err) + } + if len(stubs) != 1 { + t.Fatalf("expected 1 stub, got %d", len(stubs)) + } + if got := stubs[0].Topics; len(got) != 1 || got[0] != "logs" { + t.Fatalf("stub topics = %v, want [logs]", got) + } + if got := stubs[0].ReplayWindow; got != 2*time.Minute { + t.Fatalf("replay window = %v, want 2m", got) + } +} + +func TestRecordCapturePersistsRedactedPayload(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + mgr, err := NewManager(dir, redact.New(), slog.Default()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + mgr.EnsureCaptureConfig(CaptureConfig{ + ID: "cfg-redact", + SourceVM: "logstash-1", + Codec: "text", + Enabled: true, + }) + + if _, err := mgr.RecordCapture(context.Background(), "cfg-redact", "connect 10.0.0.1", time.Unix(3, 0)); err != nil { + t.Fatalf("RecordCapture: %v", err) + } + + files, err := filepath.Glob(filepath.Join(dir, "cfg-redact", "*.json")) + if err != nil { + t.Fatalf("Glob: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected one persisted segment, got %d", len(files)) + } + data, err := os.ReadFile(files[0]) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var stored persistedRecord + if err := json.Unmarshal(data, &stored); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if string(stored.ValueBytes()) == "connect 10.0.0.1" { + t.Fatal("expected persisted payload to be redacted") + } +} diff --git a/fluid-daemon/internal/kafkastub/transport.go b/fluid-daemon/internal/kafkastub/transport.go new file mode 100644 index 00000000..a25c395a --- /dev/null +++ b/fluid-daemon/internal/kafkastub/transport.go @@ -0,0 +1,31 @@ +package kafkastub + +import ( + "context" + "fmt" +) + +type Consumer interface { + ReadMessage(context.Context) (Record, error) + Close() error +} + +type Producer interface { + WriteMessage(context.Context, Record) error + Close() error +} + +type Transport interface { + NewConsumer(CaptureConfig) (Consumer, error) + NewProducer(string) (Producer, error) +} + +type noopTransport struct{} + +func (noopTransport) NewConsumer(CaptureConfig) (Consumer, error) { + return nil, fmt.Errorf("kafka transport not configured") +} + +func (noopTransport) NewProducer(string) (Producer, error) { + return nil, fmt.Errorf("kafka transport not configured") +} diff --git a/fluid-daemon/internal/kafkastub/transport_kafka_go.go b/fluid-daemon/internal/kafkastub/transport_kafka_go.go new file mode 100644 index 00000000..ad47db54 --- /dev/null +++ b/fluid-daemon/internal/kafkastub/transport_kafka_go.go @@ -0,0 +1,156 @@ +package kafkastub + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "strings" + "time" + + kafka "github.com/segmentio/kafka-go" + "github.com/segmentio/kafka-go/sasl" + "github.com/segmentio/kafka-go/sasl/plain" + "github.com/segmentio/kafka-go/sasl/scram" +) + +func NewKafkaGoTransport() Transport { + return kafkaGoTransport{} +} + +type kafkaGoTransport struct{} + +type kafkaGoConsumer struct { + reader *kafka.Reader +} + +type kafkaGoProducer struct { + writer *kafka.Writer +} + +func (kafkaGoTransport) NewConsumer(cfg CaptureConfig) (Consumer, error) { + dialer, err := kafkaDialer(cfg) + if err != nil { + return nil, err + } + reader := kafka.NewReader(kafka.ReaderConfig{ + Brokers: append([]string(nil), cfg.BootstrapServers...), + GroupID: consumerGroupID(cfg.ID), + GroupTopics: append([]string(nil), cfg.Topics...), + Dialer: dialer, + StartOffset: kafka.LastOffset, + CommitInterval: time.Second, + MinBytes: 1, + MaxBytes: 10e6, + MaxWait: 2 * time.Second, + ReadLagInterval: -1, + }) + return &kafkaGoConsumer{reader: reader}, nil +} + +func (kafkaGoTransport) NewProducer(endpoint string) (Producer, error) { + writer := &kafka.Writer{ + Addr: kafka.TCP(endpoint), + Balancer: &kafka.LeastBytes{}, + RequiredAcks: kafka.RequireOne, + AllowAutoTopicCreation: true, + BatchTimeout: 100 * time.Millisecond, + WriteTimeout: 10 * time.Second, + ReadTimeout: 10 * time.Second, + } + return &kafkaGoProducer{writer: writer}, nil +} + +func (c *kafkaGoConsumer) ReadMessage(ctx context.Context) (Record, error) { + msg, err := c.reader.ReadMessage(ctx) + if err != nil { + return Record{}, err + } + headers := make([]Header, 0, len(msg.Headers)) + for _, header := range msg.Headers { + headers = append(headers, Header{ + Key: header.Key, + Value: append([]byte(nil), header.Value...), + }) + } + return Record{ + Topic: msg.Topic, + Partition: int32(msg.Partition), + Offset: msg.Offset, + Key: append([]byte(nil), msg.Key...), + Headers: headers, + Timestamp: msg.Time.UTC(), + Value: append([]byte(nil), msg.Value...), + }, nil +} + +func (c *kafkaGoConsumer) Close() error { + return c.reader.Close() +} + +func (p *kafkaGoProducer) WriteMessage(ctx context.Context, record Record) error { + headers := make([]kafka.Header, 0, len(record.Headers)) + for _, header := range record.Headers { + headers = append(headers, kafka.Header{ + Key: header.Key, + Value: append([]byte(nil), header.Value...), + }) + } + return p.writer.WriteMessages(ctx, kafka.Message{ + Topic: record.Topic, + Key: append([]byte(nil), record.Key...), + Value: append([]byte(nil), record.Value...), + Headers: headers, + Time: record.Timestamp.UTC(), + }) +} + +func (p *kafkaGoProducer) Close() error { + return p.writer.Close() +} + +func consumerGroupID(configID string) string { + return "fluid-kafkastub-" + sanitizeID(configID) +} + +func kafkaDialer(cfg CaptureConfig) (*kafka.Dialer, error) { + dialer := &kafka.Dialer{ + Timeout: 10 * time.Second, + DualStack: true, + } + if cfg.TLSEnabled { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: cfg.InsecureSkipVerify, + } + if cfg.TLSCAPEM != "" { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(cfg.TLSCAPEM)) { + return nil, fmt.Errorf("invalid kafka TLS CA PEM") + } + tlsConfig.RootCAs = pool + } + dialer.TLS = tlsConfig + } + if cfg.Username != "" || cfg.Password != "" { + mech, err := saslMechanism(cfg) + if err != nil { + return nil, err + } + dialer.SASLMechanism = mech + } + return dialer, nil +} + +func saslMechanism(cfg CaptureConfig) (sasl.Mechanism, error) { + switch strings.ToLower(strings.TrimSpace(cfg.SASLMechanism)) { + case "", "plain": + return plain.Mechanism{Username: cfg.Username, Password: cfg.Password}, nil + case "scram-sha-256": + return scram.Mechanism(scram.SHA256, cfg.Username, cfg.Password) + case "scram-sha-512": + return scram.Mechanism(scram.SHA512, cfg.Username, cfg.Password) + default: + return nil, fmt.Errorf("unsupported SASL mechanism %q", cfg.SASLMechanism) + } +} diff --git a/fluid-daemon/internal/provider/microvm/redpanda_integration_test.go b/fluid-daemon/internal/provider/microvm/redpanda_integration_test.go new file mode 100644 index 00000000..9b3441b3 --- /dev/null +++ b/fluid-daemon/internal/provider/microvm/redpanda_integration_test.go @@ -0,0 +1,837 @@ +package microvm + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/daemon" + imagestore "github.com/aspectrr/fluid.sh/fluid-daemon/internal/image" + microvminternal "github.com/aspectrr/fluid.sh/fluid-daemon/internal/microvm" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/network" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/provider" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/sshca" + "github.com/aspectrr/fluid.sh/fluid-daemon/internal/sshkeys" +) + +type liveE2EHarness struct { + ctx context.Context + cfg redpandaE2EConfig + logger *slog.Logger + workDir string + vmMgr *microvminternal.Manager + readiness *daemon.ReadinessServer + provider *Provider + bridgeIP string +} + +func TestProviderIntegration_RedpandaStartsInGuest(t *testing.T) { + t.Helper() + + cfg := loadRedpandaE2EConfig(t) + h := newLiveE2EHarness(t, cfg) + archiveURL := startArchiveServer(t, h.bridgeIP, ensureRedpandaArchive(t, h.workDir, cfg)) + + req := provider.CreateRequest{ + SandboxID: fmt.Sprintf("sbx-e2e-%d", time.Now().UnixNano()), + Name: "redpanda-e2e", + BaseImage: "base", + Network: cfg.bridge, + VCPUs: 2, + MemoryMB: 2048, + KafkaBroker: &provider.KafkaBrokerConfig{ + ArchiveURL: archiveURL, + Port: 9092, + }, + } + + result := createLiveSandbox(t, h, req) + serialContent := waitForPhoneHomeNotification(t, h, result) + if !strings.Contains(serialContent, "fluid redpanda ready on attempt") { + t.Fatalf("sandbox posted readiness without a successful in-guest Redpanda readiness check\nlast_stage: %s\nserial:\n%s", redpandaSerialStage(serialContent), serialContent) + } + + deadline := time.Now().Add(2 * time.Minute) + var lastErr error + for time.Now().Before(deadline) { + cmdCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + run, err := h.provider.RunCommand(cmdCtx, result.SandboxID, redpandaProbeCommand, 45*time.Second) + cancel() + if err == nil && run.ExitCode == 0 { + return + } + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("probe exit %d stderr=%s stdout=%s", run.ExitCode, run.Stderr, run.Stdout) + } + time.Sleep(10 * time.Second) + } + serialContent = serialLog(h.vmMgr.WorkDir(), result.SandboxID) + t.Fatalf("guest posted readiness and in-guest Redpanda checks passed, but final host-side Kafka probe still failed: %v\nlast_stage: %s\nguest_diagnostics:\n%s\nhost_diagnostics:\n%s\nserial:\n%s", lastErr, redpandaSerialStage(serialContent), guestDiagnostics(h.ctx, h.provider, result.SandboxID), sandboxHostDiagnostics(h.vmMgr.WorkDir(), result.SandboxID, result.PID), serialContent) +} + +func TestProviderIntegration_SandboxStartsWithoutRedpanda(t *testing.T) { + t.Helper() + + cfg := loadRedpandaE2EConfig(t) + h := newLiveE2EHarness(t, cfg) + + req := provider.CreateRequest{ + SandboxID: fmt.Sprintf("sbx-e2e-plain-%d", time.Now().UnixNano()), + Name: "plain-e2e", + BaseImage: "base", + Network: cfg.bridge, + VCPUs: 1, + MemoryMB: 1024, + } + + result := createLiveSandbox(t, h, req) + serialContent := waitForPhoneHomeNotification(t, h, result) + if strings.Contains(serialContent, "fluid redpanda ready on attempt") { + t.Fatalf("plain sandbox should not emit redpanda readiness markers\nlast_stage: %s\nserial:\n%s", redpandaSerialStage(serialContent), serialContent) + } + + cmdCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + run, err := h.provider.RunCommand(cmdCtx, result.SandboxID, plainSandboxProbeCommand, 45*time.Second) + if err != nil { + serialContent = serialLog(h.vmMgr.WorkDir(), result.SandboxID) + t.Fatalf("plain sandbox probe failed: %v\nlast_stage: %s\nguest_diagnostics:\n%s\nhost_diagnostics:\n%s\nserial:\n%s", err, redpandaSerialStage(serialContent), guestDiagnostics(h.ctx, h.provider, result.SandboxID), sandboxHostDiagnostics(h.vmMgr.WorkDir(), result.SandboxID, result.PID), serialContent) + } + if run.ExitCode != 0 { + serialContent = serialLog(h.vmMgr.WorkDir(), result.SandboxID) + t.Fatalf("plain sandbox probe exited with %d\nstdout:\n%s\nstderr:\n%s\nlast_stage: %s\nguest_diagnostics:\n%s\nhost_diagnostics:\n%s\nserial:\n%s", run.ExitCode, run.Stdout, run.Stderr, redpandaSerialStage(serialContent), guestDiagnostics(h.ctx, h.provider, result.SandboxID), sandboxHostDiagnostics(h.vmMgr.WorkDir(), result.SandboxID, result.PID), serialContent) + } +} + +func waitForSerialMarker(serialPath, marker string, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + for { + serialBytes, _ := os.ReadFile(serialPath) + serialContent := string(serialBytes) + if strings.Contains(serialContent, marker) { + return serialContent, nil + } + if time.Now().After(deadline) { + return serialContent, fmt.Errorf("serial marker %q not observed within %v", marker, timeout) + } + time.Sleep(250 * time.Millisecond) + } +} + +func newLiveE2EHarness(t *testing.T, cfg redpandaE2EConfig) *liveE2EHarness { + t.Helper() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ctx := context.Background() + + workDir := e2eWorkDir(t) + imageDir := filepath.Join(workDir, "images") + if err := os.MkdirAll(imageDir, 0o755); err != nil { + t.Fatalf("mkdir image dir: %v", err) + } + if err := os.Symlink(cfg.baseImagePath, filepath.Join(imageDir, "base.qcow2")); err != nil { + t.Fatalf("symlink base image: %v", err) + } + + vmMgr, err := microvminternal.NewManager(cfg.qemuBinary, filepath.Join(workDir, "sandboxes"), logger) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + imgStore, err := imagestore.NewStore(imageDir, logger) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + caKeyPath := filepath.Join(workDir, "sshca", "ca") + if err := sshca.GenerateCA(caKeyPath, "fluid-e2e-ca"); err != nil { + t.Fatalf("GenerateCA: %v", err) + } + ca, err := sshca.NewCA(sshca.Config{ + CAKeyPath: caKeyPath, + CAPubKeyPath: caKeyPath + ".pub", + WorkDir: filepath.Join(workDir, "sshca-work"), + DefaultTTL: 30 * time.Minute, + MaxTTL: 60 * time.Minute, + DefaultPrincipals: []string{"sandbox"}, + EnforceKeyPermissions: true, + }, sshca.WithTimeNow(time.Now)) + if err != nil { + t.Fatalf("NewCA: %v", err) + } + if err := ca.Initialize(ctx); err != nil { + t.Fatalf("Initialize CA: %v", err) + } + caPubKeyBytes, err := os.ReadFile(caKeyPath + ".pub") + if err != nil { + t.Fatalf("read CA pubkey: %v", err) + } + + keyMgr, err := sshkeys.NewKeyManager(ca, sshkeys.Config{ + KeyDir: filepath.Join(workDir, "keys"), + CertificateTTL: 30 * time.Minute, + RefreshMargin: 30 * time.Second, + }, logger) + if err != nil { + t.Fatalf("NewKeyManager: %v", err) + } + t.Cleanup(func() { + _ = keyMgr.Close() + }) + + bridgeIP, err := network.GetBridgeIP(cfg.bridge) + if err != nil { + t.Fatalf("GetBridgeIP(%q): %v", cfg.bridge, err) + } + readiness := startReadinessServer(t, bridgeIP, logger) + + p := New( + vmMgr, + network.NewNetworkManager(cfg.bridge, nil, cfg.dhcpMode, logger), + imgStore, + nil, + keyMgr, + cfg.kernelPath, + cfg.initrdPath, + cfg.rootDevice, + cfg.accel, + 5*time.Minute, + cfg.startupTimeout, + strings.TrimSpace(string(caPubKeyBytes)), + bridgeIP, + readiness, + logger, + ) + + return &liveE2EHarness{ + ctx: ctx, + cfg: cfg, + logger: logger, + workDir: workDir, + vmMgr: vmMgr, + readiness: readiness, + provider: p, + bridgeIP: bridgeIP, + } +} + +func createLiveSandbox(t *testing.T, h *liveE2EHarness, req provider.CreateRequest) *provider.SandboxResult { + t.Helper() + + h.readiness.Register(req.SandboxID) + t.Cleanup(func() { + h.readiness.Unregister(req.SandboxID) + }) + + result, err := h.provider.CreateSandboxWithProgress(h.ctx, req, func(string, int, int) {}) + if err != nil { + serial := serialLog(h.vmMgr.WorkDir(), req.SandboxID) + t.Fatalf("CreateSandboxWithProgress: %v\nlast_stage: %s\nhost_diagnostics:\n%s\nserial:\n%s", err, redpandaSerialStage(serial), sandboxHostDiagnostics(h.vmMgr.WorkDir(), req.SandboxID, 0), serial) + } + if os.Getenv("FLUID_E2E_KEEP_SANDBOX") != "1" { + t.Cleanup(func() { + destroyCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := h.provider.DestroySandbox(destroyCtx, result.SandboxID); err != nil { + t.Logf("DestroySandbox(%s): %v", result.SandboxID, err) + } + }) + } else { + t.Logf("preserving sandbox artifacts for %s under %s", result.SandboxID, h.vmMgr.WorkDir()) + } + + if result.IPAddress == "" { + serial := serialLog(h.vmMgr.WorkDir(), result.SandboxID) + t.Fatalf("CreateSandbox returned empty guest IP\nlast_stage: %s\nready_ip: %s\nhost_diagnostics:\n%s\nserial:\n%s", redpandaSerialStage(serial), h.readiness.ReadyIP(result.SandboxID), sandboxHostDiagnostics(h.vmMgr.WorkDir(), result.SandboxID, result.PID), serial) + } + + return result +} + +func waitForPhoneHomeNotification(t *testing.T, h *liveE2EHarness, result *provider.SandboxResult) string { + t.Helper() + + serialPath := filepath.Join(h.vmMgr.WorkDir(), result.SandboxID, "serial.log") + serialBytes, _ := os.ReadFile(serialPath) + if err := h.readiness.WaitReady(result.SandboxID, h.cfg.startupTimeout); err != nil { + serial := string(serialBytes) + t.Fatalf("sandbox did not post readiness within %v: %v\nlast_stage: %s\nready_ip: %s\nhost_diagnostics:\n%s\nserial:\n%s", h.cfg.startupTimeout, err, redpandaSerialStage(serial), h.readiness.ReadyIP(result.SandboxID), sandboxHostDiagnostics(h.vmMgr.WorkDir(), result.SandboxID, result.PID), serial) + } + serialBytes, _ = os.ReadFile(serialPath) + if !h.readiness.WasReady(result.SandboxID) { + serial := string(serialBytes) + t.Fatalf("sandbox never posted readiness\nlast_stage: %s\nhost_diagnostics:\n%s\nserial:\n%s", redpandaSerialStage(serial), sandboxHostDiagnostics(h.vmMgr.WorkDir(), result.SandboxID, result.PID), serial) + } + + serialContent, err := waitForSerialMarker(serialPath, "fluid notify ready complete", 10*time.Second) + if err != nil { + t.Fatalf("sandbox posted readiness without completing in-guest phone_home notification: %v\nlast_stage: %s\nserial:\n%s", err, redpandaSerialStage(serialContent), serialContent) + } + return serialContent +} + +type redpandaE2EConfig struct { + baseImagePath string + kernelPath string + initrdPath string + archiveURL string + qemuBinary string + bridge string + dhcpMode string + rootDevice string + accel string + startupTimeout time.Duration +} + +func loadRedpandaE2EConfig(t *testing.T) redpandaE2EConfig { + t.Helper() + + if testing.Short() { + t.Skip("skipping live guest integration test in short mode") + } + if os.Getenv("FLUID_E2E_MICROVM") != "1" { + t.Skip("set FLUID_E2E_MICROVM=1 to run live guest microVM integration tests") + } + if os.Geteuid() != 0 { + t.Skip("live guest microVM integration test requires root for TAP/bridge setup") + } + + cfg := redpandaE2EConfig{ + baseImagePath: os.Getenv("FLUID_E2E_BASE_IMAGE"), + kernelPath: os.Getenv("FLUID_E2E_KERNEL"), + initrdPath: os.Getenv("FLUID_E2E_INITRD"), + archiveURL: strings.TrimSpace(os.Getenv("FLUID_E2E_REDPANDA_ARCHIVE_URL")), + qemuBinary: envOrDefault("FLUID_E2E_QEMU_BINARY", defaultQEMUBinary()), + bridge: os.Getenv("FLUID_E2E_BRIDGE"), + dhcpMode: envOrDefault("FLUID_E2E_DHCP_MODE", "arp"), + rootDevice: envOrDefault("FLUID_E2E_ROOT_DEVICE", "/dev/vda1"), + accel: envOrDefault("FLUID_E2E_ACCEL", "tcg"), + startupTimeout: 25 * time.Minute, + } + + if timeoutRaw := os.Getenv("FLUID_E2E_STARTUP_TIMEOUT"); timeoutRaw != "" { + timeout, err := time.ParseDuration(timeoutRaw) + if err != nil { + t.Fatalf("invalid FLUID_E2E_STARTUP_TIMEOUT %q: %v", timeoutRaw, err) + } + cfg.startupTimeout = timeout + } + + missing := make([]string, 0, 3) + if cfg.baseImagePath == "" { + missing = append(missing, "FLUID_E2E_BASE_IMAGE") + } + if cfg.kernelPath == "" { + missing = append(missing, "FLUID_E2E_KERNEL") + } + if cfg.bridge == "" { + missing = append(missing, "FLUID_E2E_BRIDGE") + } + if len(missing) > 0 { + t.Skipf("missing required env vars for live guest integration test: %s", strings.Join(missing, ", ")) + } + + for _, path := range []string{cfg.baseImagePath, cfg.kernelPath} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("required path %q: %v", path, err) + } + } + if cfg.initrdPath != "" { + if _, err := os.Stat(cfg.initrdPath); err != nil { + t.Fatalf("configured initrd %q: %v", cfg.initrdPath, err) + } + } + requiredBins := []string{cfg.qemuBinary, "qemu-img", "ssh-keygen"} + if runtime.GOOS == "darwin" { + requiredBins = append(requiredBins, "ifconfig", "arp") + } else { + requiredBins = append(requiredBins, "ip") + } + for _, bin := range requiredBins { + if _, err := exec.LookPath(bin); err != nil { + t.Fatalf("required binary %q not found: %v", bin, err) + } + } + if cfg.archiveURL == "" { + if _, err := exec.LookPath("dpkg-deb"); err != nil { + t.Fatalf("required binary %q not found: %v", "dpkg-deb", err) + } + } + if _, err := network.GetBridgeIP(cfg.bridge); err != nil { + t.Fatalf("bridge %q is not usable for integration test: %v", cfg.bridge, err) + } + return cfg +} + +func envOrDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func TestWaitForSerialMarker_PollsUntilMarkerAppears(t *testing.T) { + serialPath := filepath.Join(t.TempDir(), "serial.log") + if err := os.WriteFile(serialPath, []byte("fluid phone_home start\n"), 0o644); err != nil { + t.Fatalf("write serial log: %v", err) + } + + done := make(chan struct{}) + go func() { + time.Sleep(100 * time.Millisecond) + _ = os.WriteFile(serialPath, []byte("fluid phone_home start\nfluid notify ready complete\n"), 0o644) + close(done) + }() + + serialContent, err := waitForSerialMarker(serialPath, "fluid notify ready complete", time.Second) + <-done + if err != nil { + t.Fatalf("waitForSerialMarker: %v", err) + } + if !strings.Contains(serialContent, "fluid notify ready complete") { + t.Fatalf("serial content %q missing completion marker", serialContent) + } +} + +func e2eWorkDir(t *testing.T) string { + t.Helper() + + if dir := strings.TrimSpace(os.Getenv("FLUID_E2E_WORKDIR")); dir != "" { + if err := os.RemoveAll(dir); err != nil { + t.Fatalf("remove FLUID_E2E_WORKDIR %q: %v", dir, err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create FLUID_E2E_WORKDIR %q: %v", dir, err) + } + t.Logf("using FLUID_E2E_WORKDIR=%s", dir) + return dir + } + + candidates := []string{"/var/tmp", os.TempDir()} + keepWorkDir := os.Getenv("FLUID_E2E_KEEP_WORKDIR") == "1" + for _, baseDir := range candidates { + dir, err := os.MkdirTemp(baseDir, "fluid-redpanda-e2e-") + if err != nil { + continue + } + if keepWorkDir { + t.Logf("preserving E2E workdir=%s", dir) + } else { + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + } + t.Logf("using E2E workdir=%s", dir) + return dir + } + + t.Fatalf("could not create an E2E workdir in /var/tmp or %s", os.TempDir()) + return "" +} + +func defaultQEMUBinary() string { + if runtime.GOARCH == "arm64" { + return "qemu-system-aarch64" + } + return "qemu-system-x86_64" +} + +func defaultRedpandaDebURLs() []string { + baseURL := "https://dl.redpanda.com/public/redpanda/deb/ubuntu/pool/any-version/main/r/re" + version := "25.3.11-1" + arch := "amd64" + if runtime.GOARCH == "arm64" { + arch = "arm64" + } + pkgs := []string{"redpanda", "redpanda-rpk", "redpanda-tuner"} + urls := make([]string, 0, len(pkgs)) + for _, pkg := range pkgs { + urls = append(urls, fmt.Sprintf("%s/%s_%s_%s.deb", baseURL, pkg, version, arch)) + } + return urls +} + +func startReadinessServer(t *testing.T, bridgeIP string, logger *slog.Logger) *daemon.ReadinessServer { + t.Helper() + + addr := bridgeIP + ":9092" + srv := daemon.NewReadinessServer(addr, logger) + ln, err := net.Listen("tcp", addr) + if err != nil { + t.Fatalf("listen readiness server on %s: %v", addr, err) + } + + done := make(chan error, 1) + go func() { + done <- srv.Serve(ln) + }() + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + t.Logf("shutdown readiness server: %v", err) + } + select { + case err := <-done: + if err != nil && err != http.ErrServerClosed { + t.Logf("readiness server exited with error: %v", err) + } + case <-time.After(5 * time.Second): + t.Log("timed out waiting for readiness server shutdown") + } + }) + + return srv +} + +func ensureRedpandaArchive(t *testing.T, _ string, cfg redpandaE2EConfig) string { + t.Helper() + + archiveDir := redpandaArchiveCacheDir(t) + u, err := url.Parse(cfg.archiveURL) + if cfg.archiveURL == "" { + return buildRedpandaArchiveFromDebs(t, archiveDir, defaultRedpandaDebURLs()) + } + if err != nil { + t.Fatalf("parse archive URL %q: %v", cfg.archiveURL, err) + } + archiveName := filepath.Base(u.Path) + if archiveName == "." || archiveName == "/" || archiveName == "" { + archiveName = "redpanda.tar.gz" + } + return downloadArchiveToCache(t, archiveDir, archiveName, cfg.archiveURL) +} + +func redpandaArchiveCacheDir(t *testing.T) string { + t.Helper() + + candidates := make([]string, 0, 2) + if cacheDir, err := os.UserCacheDir(); err == nil && strings.TrimSpace(cacheDir) != "" { + candidates = append(candidates, filepath.Join(cacheDir, "fluid", "e2e", "redpanda")) + } + candidates = append(candidates, filepath.Join(os.TempDir(), "fluid-redpanda-cache")) + + for _, dir := range candidates { + if err := os.MkdirAll(dir, 0o755); err == nil { + return dir + } + } + + t.Fatalf("could not create a writable redpanda archive cache directory") + return "" +} + +func buildRedpandaArchiveFromDebs(t *testing.T, cacheDir string, urls []string) string { + t.Helper() + + archivePath := filepath.Join(cacheDir, fmt.Sprintf("redpanda-rootfs-%s.tar.gz", runtime.GOARCH)) + _ = os.Remove(archivePath) + + stageDir, err := os.MkdirTemp(cacheDir, "redpanda-rootfs-") + if err != nil { + t.Fatalf("create stage dir: %v", err) + } + defer os.RemoveAll(stageDir) + + for _, pkgURL := range urls { + pkgName := filepath.Base(pkgURL) + pkgPath := downloadArchiveToCache(t, cacheDir, pkgName, pkgURL) + cmd := exec.Command("dpkg-deb", "-x", pkgPath, stageDir) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("extract %s: %v\n%s", pkgName, err, string(output)) + } + } + + payloadDir := filepath.Join(stageDir, "payload") + if err := os.MkdirAll(payloadDir, 0o755); err != nil { + t.Fatalf("create payload dir: %v", err) + } + for _, relPath := range []string{ + "opt/redpanda", + "usr/bin/redpanda", + "usr/bin/rpk", + "usr/bin/iotune-redpanda", + } { + srcPath := filepath.Join(stageDir, relPath) + if _, err := os.Lstat(srcPath); err != nil { + continue + } + dstPath := filepath.Join(payloadDir, relPath) + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + t.Fatalf("mkdir payload parent for %s: %v", relPath, err) + } + cmd := exec.Command("cp", "-a", srcPath, dstPath) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("stage %s into payload: %v\n%s", relPath, err, string(output)) + } + } + + tmpArchive, err := os.CreateTemp(cacheDir, "redpanda-rootfs-*.tar.gz") + if err != nil { + t.Fatalf("create temp archive: %v", err) + } + tmpArchivePath := tmpArchive.Name() + _ = tmpArchive.Close() + defer os.Remove(tmpArchivePath) + + writeTarGzFromDir(t, tmpArchivePath, payloadDir) + if err := os.Rename(tmpArchivePath, archivePath); err != nil { + t.Fatalf("move archive into place: %v", err) + } + return archivePath +} + +func downloadArchiveToCache(t *testing.T, cacheDir, archiveName, sourceURL string) string { + t.Helper() + + archivePath := filepath.Join(cacheDir, archiveName) + if _, err := os.Stat(archivePath); err == nil { + return archivePath + } + + resp, err := http.Get(sourceURL) + if err != nil { + t.Fatalf("download %s: %v", sourceURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("download %s: unexpected status %s", sourceURL, resp.Status) + } + + out, err := os.Create(archivePath) + if err != nil { + t.Fatalf("create archive file: %v", err) + } + defer out.Close() + if _, err := io.Copy(out, resp.Body); err != nil { + t.Fatalf("write archive file: %v", err) + } + return archivePath +} + +func writeTarGzFromDir(t *testing.T, archivePath, root string) { + t.Helper() + + file, err := os.Create(archivePath) + if err != nil { + t.Fatalf("create archive %s: %v", archivePath, err) + } + defer file.Close() + + gz := gzip.NewWriter(file) + defer gz.Close() + + tw := tar.NewWriter(gz) + defer tw.Close() + + if err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == root { + return nil + } + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + relPath = filepath.ToSlash(relPath) + linkTarget := "" + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, err = os.Readlink(path) + if err != nil { + return err + } + } + header, err := tar.FileInfoHeader(info, linkTarget) + if err != nil { + return err + } + header.Name = relPath + if info.IsDir() { + header.Name += "/" + } + if err := tw.WriteHeader(header); err != nil { + return err + } + if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + _, err = io.Copy(tw, in) + return err + }); err != nil { + t.Fatalf("write archive %s: %v", archivePath, err) + } +} + +func startArchiveServer(t *testing.T, bridgeIP, archivePath string) string { + t.Helper() + + addr := bridgeIP + ":9088" + mux := http.NewServeMux() + mux.HandleFunc("/redpanda.tar.gz", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, archivePath) + }) + srv := &http.Server{ + Addr: addr, + Handler: mux, + } + ln, err := net.Listen("tcp", addr) + if err != nil { + t.Fatalf("listen archive server on %s: %v", addr, err) + } + + done := make(chan error, 1) + go func() { + done <- srv.Serve(ln) + }() + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + t.Logf("shutdown archive server: %v", err) + } + select { + case err := <-done: + if err != nil && err != http.ErrServerClosed { + t.Logf("archive server exited with error: %v", err) + } + case <-time.After(5 * time.Second): + t.Log("timed out waiting for archive server shutdown") + } + }) + + return "http://" + addr + "/redpanda.tar.gz" +} + +func serialLog(workDir, sandboxID string) string { + serialBytes, _ := os.ReadFile(filepath.Join(workDir, sandboxID, "serial.log")) + return string(serialBytes) +} + +func redpandaSerialStage(serial string) string { + markers := []string{ + "fluid redpanda install start", + "fluid redpanda archive download complete", + "fluid redpanda extraction complete", + "fluid redpanda binary resolution complete", + "fluid redpanda env file written", + "fluid redpanda install complete", + "fluid redpanda temp cleanup skipped for ephemeral sandbox", + "fluid redpanda enable start", + "fluid redpanda daemon reload complete", + "fluid redpanda service start invoked", + "fluid redpanda systemd enable complete", + "fluid redpanda readiness wait started", + "fluid redpanda readiness pending stage=", + "fluid redpanda readiness wait success", + "fluid redpanda readiness wait timeout", + "fluid redpanda readiness failure", + "fluid redpanda ready on attempt", + "fluid notify ready start", + "fluid notify ready checks complete", + "fluid phone_home start", + "fluid notify ready complete", + "fluid notify ready failure", + } + last := "unknown" + for _, line := range strings.Split(serial, "\n") { + for _, marker := range markers { + if strings.Contains(line, marker) { + last = strings.TrimSpace(line) + } + } + } + return last +} + +func guestDiagnostics(ctx context.Context, p *Provider, sandboxID string) string { + cmdCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + run, err := p.RunCommand(cmdCtx, sandboxID, redpandaDiagnosticsCommand, 90*time.Second) + if err != nil { + return fmt.Sprintf("guest diagnostics unavailable: %v", err) + } + return fmt.Sprintf("exit=%d\nstdout:\n%s\nstderr:\n%s", run.ExitCode, run.Stdout, run.Stderr) +} + +const redpandaProbeCommand = `bash -lc 'set -euo pipefail +listener_ready() { + ss -H -ltn "( sport = :9092 )" | awk '"'"'END { exit(NR==0) }'"'"' +} +systemctl is-active --quiet fluid-redpanda.service +systemctl is-enabled --quiet fluid-redpanda.service +test -x /usr/bin/redpanda +test -s /etc/default/fluid-redpanda +listener_ready +'` + +const plainSandboxProbeCommand = `bash -lc 'set -euo pipefail +systemctl is-active --quiet ssh || systemctl is-active --quiet sshd +test -f /etc/ssh/fluid_ca.pub +test -f /etc/ssh/authorized_principals/sandbox +test ! -f /etc/default/fluid-redpanda +id sandbox >/dev/null +'` + +const redpandaDiagnosticsCommand = `bash -lc 'set +e +echo "--- systemctl status ---" +systemctl status fluid-redpanda.service --no-pager || true +echo "--- cloud-init status ---" +cloud-init status --long || true +echo "--- cloud-final journal ---" +journalctl -u cloud-final --no-pager -n 200 || true +echo "--- kernel journal ---" +journalctl -k --no-pager -n 200 || true +echo "--- redpanda journal ---" +journalctl -u fluid-redpanda.service --no-pager -n 200 || true +echo "--- sockets ---" +ss -ltn || true +echo "--- sockets 9092 ---" +ss -H -ltn "( sport = :9092 )" || true +echo "--- sockets 9092 with pid ---" +ss -H -ltnp "( sport = :9092 )" || true +echo "--- redpanda env ---" +cat /etc/default/fluid-redpanda || true +if [ -f /etc/default/fluid-redpanda ]; then + . /etc/default/fluid-redpanda +fi +if [ -n "${RPK_BIN:-}" ] && [ -x "${RPK_BIN:-}" ]; then + echo "--- rpk cluster info ---" + timeout 10s "${RPK_BIN}" cluster info --brokers 127.0.0.1:9092 || true + echo "--- rpk topic list ---" + timeout 10s "${RPK_BIN}" topic list --brokers 127.0.0.1:9092 || true +fi +echo "--- redpanda config ---" +cat /etc/redpanda/redpanda.yaml || true +echo "--- redpanda tree ---" +find /opt/fluid-redpanda-root -maxdepth 6 -type f | sort || true +echo "--- redpanda logs ---" +find /var/log /var/lib/redpanda -maxdepth 4 -type f \( -iname '*redpanda*.log' -o -iname '*redpanda*' -o -path '/var/log/fluid/*' \) 2>/dev/null | sort | while read -r log_path; do + echo "--- $log_path ---" + cat "$log_path" || true +done +echo "--- os-release ---" +cat /etc/os-release || true +'` diff --git a/scripts/reset-kafka-demo.sh b/scripts/reset-kafka-demo.sh new file mode 100755 index 00000000..67e0da50 --- /dev/null +++ b/scripts/reset-kafka-demo.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# reset-kafka-demo.sh +# +# Destroys kafka-demo-{INDEX} and logstash-demo-{INDEX} VMs and recreates +# them from scratch by running setup-kafka-demo.sh. +# +# Unlike reset-ubuntu.sh, this script only removes the kafka/logstash demo VMs +# and leaves any other VMs on the host untouched. +# +# Usage: sudo ./reset-kafka-demo.sh [VM_INDEX] [--ssh-users-file ] +# +# Options: +# VM_INDEX VM index number (default: 1) +# --ssh-users-file Path to file with SSH users (one per line: ) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +VM_INDEX="" +SSH_USERS_FILE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --ssh-users-file) + SSH_USERS_FILE="$2" + shift 2 + ;; + --help|-h) + echo "Usage: sudo ./reset-kafka-demo.sh [VM_INDEX] [--ssh-users-file ]" + echo "" + echo "Options:" + echo " VM_INDEX VM index number (default: 1)" + echo " --ssh-users-file Path to file with SSH users (one per line: )" + exit 0 + ;; + *) + if [[ -z "$VM_INDEX" ]]; then + VM_INDEX="$1" + else + echo "Unknown argument: $1" >&2 + exit 1 + fi + shift + ;; + esac +done + +VM_INDEX="${VM_INDEX:-1}" + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +if [[ $EUID -ne 0 ]]; then + log_error "This script must be run as root" + exit 1 +fi + +if ! command -v virsh &>/dev/null; then + log_error "virsh not found. Please run setup-ubuntu.sh first." + exit 1 +fi + +KAFKA_VM="kafka-demo-${VM_INDEX}" +LOGSTASH_VM="logstash-demo-${VM_INDEX}" +IMAGE_DIR="/var/lib/libvirt/images" +CLOUD_INIT_DIR="${IMAGE_DIR}/cloud-init" + +log_warn "Destroying ${KAFKA_VM} and ${LOGSTASH_VM} (other VMs will not be touched)." + +# ============================================================================ +# Destroy and undefine the demo VMs +# ============================================================================ +for VM in "$KAFKA_VM" "$LOGSTASH_VM"; do + if virsh dominfo "$VM" &>/dev/null; then + log_info "Destroying VM: ${VM}..." + virsh destroy "$VM" > /dev/null 2>&1 || true + virsh undefine "$VM" --nvram > /dev/null 2>&1 || virsh undefine "$VM" > /dev/null 2>&1 || true + log_success "Removed: ${VM}" + else + log_info "VM '${VM}' does not exist, skipping." + fi +done + +# ============================================================================ +# Clean up disks and cloud-init data +# ============================================================================ +log_info "Cleaning up disks and cloud-init data..." +rm -f "${IMAGE_DIR}/${KAFKA_VM}.qcow2" 2>/dev/null || true +rm -f "${IMAGE_DIR}/${LOGSTASH_VM}.qcow2" 2>/dev/null || true +rm -rf "${CLOUD_INIT_DIR}/${KAFKA_VM}" 2>/dev/null || true +rm -rf "${CLOUD_INIT_DIR}/${LOGSTASH_VM}" 2>/dev/null || true +log_success "Cleanup complete." + +# ============================================================================ +# Flush DHCP leases to prevent IP conflicts on re-creation +# ============================================================================ +log_info "Flushing DHCP leases..." +virsh net-destroy default > /dev/null 2>&1 || true +rm -f /var/lib/libvirt/dnsmasq/virbr0.status \ + /var/lib/libvirt/dnsmasq/virbr0.leases \ + /var/lib/libvirt/dnsmasq/default.leases 2>/dev/null || true +virsh net-start default > /dev/null 2>&1 || true +log_success "DHCP leases flushed." + +# ============================================================================ +# Re-run setup +# ============================================================================ +log_info "Re-running setup-kafka-demo.sh..." +echo "" + +SETUP_SCRIPT="${SCRIPT_DIR}/setup-kafka-demo.sh" +if [[ ! -f "$SETUP_SCRIPT" ]]; then + log_error "setup-kafka-demo.sh not found at: ${SETUP_SCRIPT}" + exit 1 +fi + +SETUP_ARGS=("$VM_INDEX") +if [[ -n "$SSH_USERS_FILE" ]]; then + SETUP_ARGS+=("--ssh-users-file" "$SSH_USERS_FILE") +fi + +exec "$SETUP_SCRIPT" "${SETUP_ARGS[@]}" diff --git a/scripts/run-redpanda-e2e-lima-host.sh b/scripts/run-redpanda-e2e-lima-host.sh new file mode 100755 index 00000000..02d37525 --- /dev/null +++ b/scripts/run-redpanda-e2e-lima-host.sh @@ -0,0 +1,330 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +lima_name="fluid-redpanda-e2e" +lima_template="template://ubuntu" +host_repo_root="${REPO_ROOT}" +guest_repo_root="" +arch="" +bridge="virbr0" +dhcp_mode="libvirt" +accel="tcg" +skip_guest_setup=0 +no_download=0 +guest_workdir="" +keep_workdir=0 +dry_run=0 +lima_name_explicit=0 +required_lima_cpus=4 +required_lima_memory="8GiB" +required_lima_memory_mib=8192 +required_lima_disk="100GiB" + +usage() { + cat <<'EOF' +Usage: ./scripts/run-redpanda-e2e-lima-host.sh [options] + +Create or start a Lima VM from the host, install guest dependencies, and run +the Redpanda live microVM integration test inside the Linux guest. + +Options: + --lima-name Lima VM name. Default: fluid-redpanda-e2e + --template