diff --git a/internal/agent/TUIconstants.go b/internal/agent/TUIconstants.go new file mode 100644 index 0000000..4cfd81a --- /dev/null +++ b/internal/agent/TUIconstants.go @@ -0,0 +1,64 @@ +package agent + +import ( + "os" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + // Default to true color palette + kairosBg = lipgloss.Color("#03153a") // Deep blue background + kairosHighlight = lipgloss.Color("#e56a44") // Orange highlight + kairosHighlight2 = lipgloss.Color("#d54b11") // Red-orange highlight + kairosAccent = lipgloss.Color("#ee5007") // Accent orange + kairosBorder = lipgloss.Color("#e56a44") // Use highlight for border + kairosText = lipgloss.Color("#ffffff") // White text for contrast + checkMark = "✓" +) + +func init() { + // Fallback colors for terminal environments that do not support true color + term := os.Getenv("TERM") + if strings.Contains(term, "linux") || strings.Contains(term, "-16color") || term == "dumb" { + kairosBg = lipgloss.Color("0") // Black + kairosText = lipgloss.Color("7") // White + kairosHighlight = lipgloss.Color("9") // Bright Red (for title) + kairosHighlight2 = lipgloss.Color("1") // Red (for minor alerts or secondary info) + kairosAccent = lipgloss.Color("5") // Magenta (or "13" if brighter is OK) + kairosBorder = lipgloss.Color("9") // Bright Red (matches highlight) + checkMark = "*" // Use a check mark that works in most terminals + } +} + +const ( + genericNavigationHelp = "↑/k: up • ↓/j: down • enter: select" + StepPrefix = "STEP:" + ErrorPrefix = "ERROR:" +) + +// Installation steps for show +const ( + InstallDefaultStep = "Preparing installation" + InstallPartitionStep = "Partitioning disk" + InstallBeforeInstallStep = "Running before-install" + InstallActiveStep = "Installing Active" + InstallBootloaderStep = "Configuring bootloader" + InstallRecoveryStep = "Creating Recovery" + InstallPassiveStep = "Creating Passive" + InstallAfterInstallStep = "Running after-install" + InstallCompleteStep = "Installation complete!" +) + +// Installation steps to identify installer to UI +const ( + AgentPartitionLog = "Partitioning device" + AgentBeforeInstallLog = "Running stage: before-install" + AgentActiveLog = "Creating file system image" + AgentBootloaderLog = "Installing GRUB" + AgentRecoveryLog = "Copying /run/cos/state/cOS/active.img source to /run/cos/recovery/cOS/recovery.img" + AgentPassiveLog = "Copying /run/cos/state/cOS/active.img source to /run/cos/state/cOS/passive.img" + AgentAfterInstallLog = "Running stage: after-install" + AgentCompleteLog = "Installation complete" // This is not reported by the agent, we should add it. +) diff --git a/internal/agent/TUIcustomizationPage.go b/internal/agent/TUIcustomizationPage.go new file mode 100644 index 0000000..a172112 --- /dev/null +++ b/internal/agent/TUIcustomizationPage.go @@ -0,0 +1,207 @@ +package agent + +import ( + "encoding/json" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kairos-io/kairos-agent/v2/internal/bus" + sdk "github.com/kairos-io/kairos-sdk/bus" + "github.com/mudler/go-pluggable" +) + +// Customization Page + +type YAMLPrompt struct { + YAMLSection string + Bool bool + Prompt string + Default string + AskFirst bool + AskPrompt string + IfEmpty string + PlaceHolder string +} + +type EventPayload struct { + Config string `json:"config"` +} + +// Discover and run plugins for customization +func runCustomizationPlugins() ([]YAMLPrompt, error) { + bus.Manager.Initialize() + var r []YAMLPrompt + + bus.Manager.Response(sdk.EventInteractiveInstall, func(p *pluggable.Plugin, resp *pluggable.EventResponse) { + err := json.Unmarshal([]byte(resp.Data), &r) + if err != nil { + fmt.Println(err) + } + }) + + _, err := bus.Manager.Publish(sdk.EventInteractiveInstall, EventPayload{}) + if err != nil { + return r, err + } + + return r, nil + +} + +func newCustomizationPage() *customizationPage { + return &customizationPage{ + options: []string{ + "User & Password", + "SSH Keys", + }, + + cursor: 0, + cursorWithIds: map[int]string{ + 0: "user_password", + 1: "ssh_keys", + }, + } +} + +func checkPageExists(pageID string, options map[int]string) bool { + for _, opt := range options { + if strings.Contains(opt, pageID) { + return true + } + } + return false +} + +type customizationPage struct { + cursor int + options []string + cursorWithIds map[int]string +} + +func (p *customizationPage) Title() string { + return "Customization" +} + +func (p *customizationPage) Help() string { + return genericNavigationHelp +} + +func (p *customizationPage) Init() tea.Cmd { + mainModel.log.Debugf("Running customization plugins...") + yaML, err := runCustomizationPlugins() + if err != nil { + mainModel.log.Debugf("Error running customization plugins: %v", err) + return nil + } + if len(yaML) > 0 { + startIdx := len(p.options) + for i, prompt := range yaML { + // Check if its already added to the options! + if checkPageExists(idFromSection(prompt), p.cursorWithIds) { + mainModel.log.Debugf("Customization page for %s already exists, skipping", prompt.YAMLSection) + continue + } + optIdx := startIdx + i + if prompt.Bool == false { + mainModel.log.Debugf("Adding customization option for %s", prompt.YAMLSection) + p.options = append(p.options, fmt.Sprintf("Configure %s", prompt.YAMLSection)) + pageID := idFromSection(prompt) + p.cursorWithIds[optIdx] = pageID + newPage := newGenericQuestionPage(prompt) + mainModel.pages = append(mainModel.pages, newPage) + } else { + mainModel.log.Debugf("Adding customization option(bool) for %s", prompt.YAMLSection) + p.options = append(p.options, fmt.Sprintf("Configure %s", prompt.YAMLSection)) + pageID := idFromSection(prompt) + p.cursorWithIds[optIdx] = pageID + newPage := newGenericBoolPage(prompt) + mainModel.pages = append(mainModel.pages, newPage) + } + } + } + + // Now add the finish and install options to the bottom of the list + if !checkPageExists("summary", p.cursorWithIds) { + p.options = append(p.options, "Finish Customization and start Installation") + p.cursorWithIds[len(p.cursorWithIds)] = "summary" + } + + mainModel.log.Debugf("Customization options loaded: %v", p.cursorWithIds) + return nil +} + +func (p *customizationPage) Update(msg tea.Msg) (Page, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if p.cursor > 0 { + p.cursor-- + } + case "down", "j": + if p.cursor < len(p.options)-1 { + p.cursor++ + } + case "enter": + if pageID, ok := p.cursorWithIds[p.cursor]; ok { + return p, func() tea.Msg { return GoToPageMsg{PageID: pageID} } + } + } + } + return p, nil +} + +func (p *customizationPage) View() string { + s := "Customization Options\n\n" + s += "Configure additional settings:\n\n" + + for i, option := range p.options { + cursor := " " + if p.cursor == i { + cursor = lipgloss.NewStyle().Foreground(kairosAccent).Render(">") + } + tick := "" + pageID, ok := p.cursorWithIds[i] + if ok && p.isConfigured(pageID) { + tick = lipgloss.NewStyle().Foreground(kairosAccent).Render(checkMark) + } + s += fmt.Sprintf("%s %s %s\n", cursor, option, tick) + } + + return s +} + +// Helper methods to check configuration +func (p *customizationPage) isUserConfigured() bool { + return mainModel.username != "" && mainModel.password != "" +} + +func (p *customizationPage) isSSHConfigured() bool { + return len(mainModel.sshKeys) > 0 +} + +func (p *customizationPage) ID() string { return "customization" } + +// isConfigured checks if a given pageID is configured, supporting both static and dynamic fields +func (p *customizationPage) isConfigured(pageID string) bool { + // Hardcoded checks for static fields + if pageID == "user_password" { + return p.isUserConfigured() + } + if pageID == "ssh_keys" { + return p.isSSHConfigured() + } + // Try to find a page with this ID and call Configured() if available + for _, page := range mainModel.pages { + if idProvider, ok := page.(interface{ ID() string }); ok && idProvider.ID() == pageID { + // We found the page with the given ID, check if it has a Configured method + if configuredProvider, ok := page.(interface{ Configured() bool }); ok { + // Call the Configured method to check if it's configured + return configuredProvider.Configured() + } + } + } + return false +} diff --git a/internal/agent/TUIdiskPage.go b/internal/agent/TUIdiskPage.go new file mode 100644 index 0000000..001a741 --- /dev/null +++ b/internal/agent/TUIdiskPage.go @@ -0,0 +1,109 @@ +package agent + +import ( + "fmt" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/jaypipes/ghw/pkg/block" + "github.com/jaypipes/ghw/pkg/option" +) + +type diskStruct struct { + id int + name string + size string +} + +// Disk Selection Page +type diskSelectionPage struct { + disks []diskStruct + cursor int +} + +func newDiskSelectionPage() *diskSelectionPage { + bl, err := block.New(option.WithDisableTools(), option.WithNullAlerter()) + if err != nil { + fmt.Printf("Error initializing block device info: %v\n", err) + return nil + } + var disks []diskStruct + + const minDiskSizeBytes = 1 * 1024 * 1024 * 1024 // 1 GiB + excludedDevicePrefixes := []string{"loop", "ram", "sr", "zram"} + + for _, disk := range bl.Disks { + // Check if the device name starts with any excluded prefix + excluded := false + for _, prefix := range excludedDevicePrefixes { + if strings.HasPrefix(disk.Name, prefix) { + excluded = true + break + } + } + if excluded || disk.SizeBytes < minDiskSizeBytes { + continue // Skip excluded devices and disks smaller than the minimum size + } + disks = append(disks, diskStruct{name: filepath.Join("/dev", disk.Name), size: fmt.Sprintf("%.2f GiB", float64(disk.SizeBytes)/float64(1024*1024*1024)), id: len(disks)}) + } + + return &diskSelectionPage{ + disks: disks, + cursor: 0, + } +} + +func (p *diskSelectionPage) Init() tea.Cmd { + return nil +} + +func (p *diskSelectionPage) Update(msg tea.Msg) (Page, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if p.cursor > 0 { + p.cursor-- + } + case "down", "j": + if p.cursor < len(p.disks)-1 { + p.cursor++ + } + case "enter": + // Store selected disk in mainModel + if p.cursor >= 0 && p.cursor < len(p.disks) { + mainModel.disk = p.disks[p.cursor].name + } + // Go to confirmation page + return p, func() tea.Msg { return GoToPageMsg{PageID: "install_options"} } + } + } + return p, nil +} + +func (p *diskSelectionPage) View() string { + s := "Select target disk for installation:\n\n" + s += "WARNING: All data on the selected disk will be DESTROYED!\n\n" + + for i, disk := range p.disks { + cursor := " " + if p.cursor == i { + cursor = lipgloss.NewStyle().Foreground(kairosAccent).Render(">") + } + s += fmt.Sprintf("%s %s (%s)\n", cursor, disk.name, disk.size) + } + + return s +} + +func (p *diskSelectionPage) Title() string { + return "Disk Selection" +} + +func (p *diskSelectionPage) Help() string { + return genericNavigationHelp +} + +func (p *diskSelectionPage) ID() string { return "disk_selection" } diff --git a/internal/agent/TUIgenericPage.go b/internal/agent/TUIgenericPage.go new file mode 100644 index 0000000..14292e3 --- /dev/null +++ b/internal/agent/TUIgenericPage.go @@ -0,0 +1,213 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// genericQuestionPage represents a page that asks a generic question +type genericQuestionPage struct { + genericInput textinput.Model + section YAMLPrompt +} + +func (g genericQuestionPage) Init() tea.Cmd { + return textinput.Blink +} + +func (g genericQuestionPage) Update(msg tea.Msg) (Page, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + if g.genericInput.Value() == "" && g.section.IfEmpty != "" { + // If the input is empty and IfEmpty is set, use IfEmpty value + g.genericInput.SetValue(g.section.IfEmpty) + } + // Now if the input is not empty, we can proceed + if g.genericInput.Value() != "" { + mainModel.log.Info("Setting value", g.genericInput.Value(), "for section:", g.section.YAMLSection) + setValueForSectionInMainModel(g.genericInput.Value(), g.section.YAMLSection) + return g, func() tea.Msg { return GoToPageMsg{PageID: "customization"} } + } + case "esc": + // Go back to customization page + return g, func() tea.Msg { return GoToPageMsg{PageID: "customization"} } + } + } + + g.genericInput, cmd = g.genericInput.Update(msg) + + return g, cmd +} + +func (g genericQuestionPage) View() string { + s := g.section.Prompt + "\n\n" + s += g.genericInput.View() + "\n\n" + + return s +} + +func (g genericQuestionPage) Title() string { + return idFromSection(g.section) +} + +func (g genericQuestionPage) Help() string { + return "Press Enter to submit your answer, or esc to cancel." +} + +func (g genericQuestionPage) ID() string { + return idFromSection(g.section) +} + +func idFromSection(section YAMLPrompt) string { + // Generate a unique ID based on the section's YAMLSection. + // This could be a simple hash or just the section name. + return strings.Replace(section.YAMLSection, ".", "_", -1) +} + +func (g genericQuestionPage) Configured() bool { + // Check if the section has been configured in mainModel.extraFields + _, exists := getValueForSectionInMainModel(g.section.YAMLSection) + return exists +} + +// newGenericQuestionPage initializes a new generic question page with a text input Model. +// Uses the provided section to set up the input Model. +func newGenericQuestionPage(section YAMLPrompt) *genericQuestionPage { + genericInput := textinput.New() + genericInput.Placeholder = section.PlaceHolder + genericInput.Width = 120 + genericInput.Focus() + + return &genericQuestionPage{ + genericInput: genericInput, + section: section, + } +} + +// genericBoolPage represents a page that asks a generic yes/no question +type genericBoolPage struct { + cursor int + options []string + section YAMLPrompt +} + +func newGenericBoolPage(section YAMLPrompt) *genericBoolPage { + return &genericBoolPage{ + options: []string{"Yes", "No"}, + cursor: 1, // Default to "No" + section: section, + } +} + +func (g *genericBoolPage) Title() string { + return idFromSection(g.section) +} + +func (g *genericBoolPage) Help() string { + return genericNavigationHelp +} + +func (g *genericBoolPage) ID() string { + return idFromSection(g.section) +} + +func (g *genericBoolPage) Init() tea.Cmd { + return nil +} + +func (g *genericBoolPage) Update(msg tea.Msg) (Page, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + g.cursor = 0 + case "down", "j": + g.cursor = 1 + case "enter": + // in both cases we just go back to customization + // Save the value to mainModel.extraFields + mainModel.log.Infof("Setting value %s for section %s:", g.options[g.cursor], g.section.YAMLSection) + setValueForSectionInMainModel(g.options[g.cursor], g.section.YAMLSection) + return g, func() tea.Msg { return GoToPageMsg{PageID: "customization"} } + } + } + return g, nil +} + +func (g *genericBoolPage) View() string { + s := g.section.Prompt + "\n\n" + + for i, option := range g.options { + cursor := " " + if g.cursor == i { + cursor = lipgloss.NewStyle().Foreground(kairosAccent).Render(">") + } + s += fmt.Sprintf("%s %s\n", cursor, option) + } + + return s +} + +func (g *genericBoolPage) Configured() bool { + // Check if the section has been configured in mainModel.extraFields + _, exists := getValueForSectionInMainModel(g.section.YAMLSection) + return exists +} + +// setValueForSectionInMainModel sets a value in the mainModel's extraFields map +// for a given section, which is specified as a dot-separated string. +// It creates nested maps as necessary to reach the specified section. +func setValueForSectionInMainModel(value string, section string) { + sections := strings.Split(section, ".") + // Transform "Yes" to "true" and "No" to "false" + if value == "Yes" { + value = "true" + } else if value == "No" { + value = "false" + } + // Ensure mainModel.extraFields is initialized + if mainModel.extraFields == nil { + mainModel.extraFields = make(map[string]interface{}) + } + + currentMap := mainModel.extraFields + for i, key := range sections { + if i == len(sections)-1 { + currentMap[key] = value + } else { + if nextMap, ok := currentMap[key].(map[string]interface{}); ok { + currentMap = nextMap + } else { + newMap := make(map[string]interface{}) + currentMap[key] = newMap + currentMap = newMap + } + } + } +} + +// getValueForSectionInMainModel retrieves a value from the mainModel's extraFields map +// for a given section, which is specified as a dot-separated string. +func getValueForSectionInMainModel(section string) (string, bool) { + sections := strings.Split(section, ".") + currentMap := mainModel.extraFields + + for _, key := range sections { + if nextMap, ok := currentMap[key].(map[string]interface{}); ok { + currentMap = nextMap + } else if value, ok := currentMap[key]; ok { + return fmt.Sprintf("%v", value), true + } else { + return "", false + } + } + return "", false +} diff --git a/internal/agent/TUIinstallOptionsPage.go b/internal/agent/TUIinstallOptionsPage.go new file mode 100644 index 0000000..3157a4a --- /dev/null +++ b/internal/agent/TUIinstallOptionsPage.go @@ -0,0 +1,78 @@ +package agent + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Install Options Page +type installOptionsPage struct { + cursor int + options []string +} + +func newInstallOptionsPage() *installOptionsPage { + return &installOptionsPage{ + options: []string{ + "Start Install", + "Customize Further (User, SSH Keys, etc.)", + }, + cursor: 0, + } +} + +func (p *installOptionsPage) Init() tea.Cmd { + return nil +} + +func (p *installOptionsPage) Update(msg tea.Msg) (Page, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if p.cursor > 0 { + p.cursor-- + } + case "down", "j": + if p.cursor < len(p.options)-1 { + p.cursor++ + } + case "enter": + if p.cursor == 0 { + // Start Install - go to install process + return p, func() tea.Msg { return GoToPageMsg{PageID: "summary"} } + } else { + // Customize Further - go to customization page + return p, func() tea.Msg { return GoToPageMsg{PageID: "customization"} } + } + } + } + return p, nil +} + +func (p *installOptionsPage) View() string { + s := "Installation Options\n\n" + s += "Choose how to proceed:\n\n" + + for i, option := range p.options { + cursor := " " + if p.cursor == i { + cursor = lipgloss.NewStyle().Foreground(kairosAccent).Render(">") + } + s += fmt.Sprintf("%s %s\n", cursor, option) + } + + return s +} + +func (p *installOptionsPage) Title() string { + return "Install Options" +} + +func (p *installOptionsPage) Help() string { + return genericNavigationHelp +} + +func (p *installOptionsPage) ID() string { return "install_options" } diff --git a/internal/agent/TUIinstallProcessPage.go b/internal/agent/TUIinstallProcessPage.go new file mode 100644 index 0000000..ee93cfa --- /dev/null +++ b/internal/agent/TUIinstallProcessPage.go @@ -0,0 +1,245 @@ +package agent + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/kairos-io/kairos-sdk/types" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Install Process Page +type installProcessPage struct { + progress int + step string + steps []string + done chan bool // Channel to signal when installation is complete + output chan string // Channel to receive output from the installer + cmd *exec.Cmd // Reference to the running installer command +} + +func newInstallProcessPage() *installProcessPage { + return &installProcessPage{ + progress: 0, + step: InstallDefaultStep, + steps: []string{ + InstallDefaultStep, + InstallPartitionStep, + InstallBeforeInstallStep, + InstallActiveStep, + InstallBootloaderStep, + InstallRecoveryStep, + InstallPassiveStep, + InstallAfterInstallStep, + InstallCompleteStep, + }, + done: make(chan bool), + output: make(chan string), + } +} + +func (p *installProcessPage) Init() tea.Cmd { + oldLog := mainModel.log + cc := NewInteractiveInstallConfig(&mainModel) + // Create a new logger to track the install process output + // TODO: Maybe do a dual logger or something? So we can still see the output in the old logger decently + logBuffer := bytes.Buffer{} + bufferLog := types.NewBufferLogger(&logBuffer) + cc.Logger = bufferLog + + // Start the installer in a goroutine + go func() { + defer close(p.done) + err := RunInstall(cc) + if err != nil { + return + } + }() + + // Track the log buffer and send mapped steps to p.output + go func() { + lastLen := 0 + for { + time.Sleep(100 * time.Millisecond) + buf := logBuffer.Bytes() + if len(buf) > lastLen { + newLogs := buf[lastLen:] + lines := bytes.Split(newLogs, []byte("\n")) + for _, line := range lines { + strLine := string(line) + if len(strLine) == 0 { + continue + } + + oldLog.Print(strLine) + // Parse log line as JSON and extract the message field + var logEntry map[string]interface{} + msg := strLine + if err := json.Unmarshal([]byte(strLine), &logEntry); err == nil { + // Log the message to the old logger still so we have it there + if m, ok := logEntry["message"].(string); ok { + msg = m + } + } + + if strings.Contains(msg, AgentPartitionLog) { + p.output <- StepPrefix + InstallPartitionStep + } else if strings.Contains(msg, AgentBeforeInstallLog) { + p.output <- StepPrefix + InstallBeforeInstallStep + } else if strings.Contains(msg, AgentActiveLog) { + p.output <- StepPrefix + InstallActiveStep + } else if strings.Contains(msg, AgentBootloaderLog) { + p.output <- StepPrefix + InstallBootloaderStep + } else if strings.Contains(msg, AgentRecoveryLog) { + p.output <- StepPrefix + InstallRecoveryStep + } else if strings.Contains(msg, AgentPassiveLog) { + p.output <- StepPrefix + InstallPassiveStep + } else if strings.Contains(msg, AgentAfterInstallLog) && !strings.Contains(msg, "chroot") { + p.output <- StepPrefix + InstallAfterInstallStep + } else if strings.Contains(msg, AgentCompleteLog) { + p.output <- StepPrefix + InstallCompleteStep + } + } + lastLen = len(buf) + } + select { + case <-p.done: + return + default: + } + } + }() + + // Return a command that will check for output from the installer + return func() tea.Msg { + return CheckInstallerMsg{} + } +} + +// CheckInstallerMsg Message type to check for installer output +type CheckInstallerMsg struct{} + +func (p *installProcessPage) Update(msg tea.Msg) (Page, tea.Cmd) { + switch msg.(type) { + case CheckInstallerMsg: + // Check for new output from the installer + select { + case output, ok := <-p.output: + if !ok { + // Channel closed, installer is done + return p, nil + } + + // Process the output + if strings.HasPrefix(output, StepPrefix) { + // This is a step change notification + stepName := strings.TrimPrefix(output, StepPrefix) + + // Find the index of the step + for i, s := range p.steps { + if s == stepName { + p.progress = i + p.step = stepName + break + } + } + } else if strings.HasPrefix(output, ErrorPrefix) { + // Handle error + errorMsg := strings.TrimPrefix(output, ErrorPrefix) + p.step = "Error: " + errorMsg + return p, nil + } + + // Continue checking for output + return p, func() tea.Msg { return CheckInstallerMsg{} } + + case <-p.done: + // Installer is finished + p.progress = len(p.steps) - 1 + p.step = p.steps[len(p.steps)-1] + return p, nil + + default: + // No new output yet, check again after a short delay + return p, tea.Tick(time.Millisecond*100, func(_ time.Time) tea.Msg { + return CheckInstallerMsg{} + }) + } + } + + return p, nil +} + +func (p *installProcessPage) View() string { + s := "Installation in Progress\n\n" + + // Progress bar + totalSteps := len(p.steps) + progressPercent := (p.progress * 100) / (totalSteps - 1) + barWidth := 40 // Make progress bar wider + filled := barWidth * progressPercent / 100 + progressBar := lipgloss.NewStyle().Foreground(kairosHighlight2).Background(kairosBg).Render(strings.Repeat("█", filled)) + + lipgloss.NewStyle().Foreground(kairosBorder).Background(kairosBg).Render(strings.Repeat("░", barWidth-filled)) + + s += "Progress:" + progressBar + lipgloss.NewStyle().Background(kairosBg).Render(" ") + s += lipgloss.NewStyle().Foreground(kairosText).Background(kairosBg).Bold(true).Render(fmt.Sprintf("%d%%", progressPercent)) + s += "\n\n" + s += fmt.Sprintf("Current step: %s\n\n", p.step) + + // Show completed steps + s += "Completed steps:\n" + tick := lipgloss.NewStyle().Foreground(kairosAccent).Render(checkMark) + for i := 0; i < p.progress; i++ { + s += fmt.Sprintf("%s %s\n", tick, p.steps[i]) + } + + if p.progress < len(p.steps)-1 { + // Make the warning message red + warning := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Bold(true).Render("[!] Do not power off the system during installation!") + s += "\n" + warning + } else { + // Make the completion message green + complete := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Bold(true).Render("Installation completed successfully!\nYou can now reboot your system.") + s += "\n" + complete + } + + return s +} + +func (p *installProcessPage) Title() string { + return "Installing" +} + +func (p *installProcessPage) Help() string { + if p.progress >= len(p.steps)-1 { + return "Press any key to exit" + } + return "Installation in progress - Use ctrl+c to abort" +} + +func (p *installProcessPage) ID() string { return "install_process" } + +// Abort aborts the running installer process and cleans up +func (p *installProcessPage) Abort() { + if p.cmd != nil && p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + mainModel.log.Info("Installer process aborted by user") + } + // Close output channel if not already closed + select { + case <-p.done: + // already closed + default: + close(p.done) + } + // Optionally, send a message to output channel + select { + case p.output <- ErrorPrefix + "Installation aborted by user": + default: + } +} diff --git a/internal/agent/TUImodel.go b/internal/agent/TUImodel.go new file mode 100644 index 0000000..7130730 --- /dev/null +++ b/internal/agent/TUImodel.go @@ -0,0 +1,302 @@ +package agent + +import ( + "fmt" + "github.com/kairos-io/kairos-agent/v2/internal/kairos" + + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kairos-io/kairos-sdk/types" +) + +// Page interface that all pages must implement +type Page interface { + Init() tea.Cmd + Update(tea.Msg) (Page, tea.Cmd) + View() string + Title() string + Help() string + ID() string // Unique identifier for the page +} + +// NextPageMsg is a custom message type for page navigation +type NextPageMsg struct{} + +// GoToPageMsg is a custom message type for navigating to a specific page +// Updated to use PageID instead of PageIndex +type GoToPageMsg struct { + PageID string +} + +// Main application Model +type Model struct { + pages []Page + currentPageID string // Track current page by ID + navigationStack []string // Stack to track navigation history by ID + width int + height int + title string + disk string // Selected disk + username string + sshKeys []string // Store SSH keys + password string + extraFields map[string]any // Dynamic fields for customization + log *types.KairosLogger + source string // cli flags to interactive installer? what?? + + showAbortConfirm bool // Show abort confirmation popup +} + +var mainModel Model + +// InitialModel Initialize the application +func InitialModel(l *types.KairosLogger, source string) Model { + // First create the model with the logger in case any page needs to log something + mainModel = Model{ + navigationStack: []string{}, + title: kairos.DefaultTitleInteractiveInstaller(), + source: source, + log: l, + } + mainModel.pages = []Page{ + newDiskSelectionPage(), + newInstallOptionsPage(), + newCustomizationPage(), + newUserPasswordPage(), + newSSHKeysPage(), + newSummaryPage(), + newInstallProcessPage(), + newUserdataPage(), + } + mainModel.currentPageID = mainModel.pages[0].ID() // Start with the first page + + mainModel.log.Logger.Debug().Msg("Initial model created") + return mainModel +} + +func (m Model) Init() tea.Cmd { + mainModel.log.Debug("Starting Kairos Interactive Installer") + if len(mainModel.pages) > 0 { + for _, p := range mainModel.pages { + if p.ID() == mainModel.currentPageID { + return p.Init() + } + } + } + + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + mainModel.log.Tracef("Received message: %T", msg) + // Deal with window size changes first + switch msg := msg.(type) { + case tea.WindowSizeMsg: + mainModel.log.Tracef("Window size changed: %dx%d", msg.Width, msg.Height) + mainModel.width = msg.Width + mainModel.height = msg.Height + return m, nil + } + // For navigation, access the mainModel so we can modify from anywhere + currentIdx := -1 + for i, p := range mainModel.pages { + if p.ID() == mainModel.currentPageID { + mainModel.log.Tracef("Found current page: %s at index %d", p.ID(), i) + currentIdx = i + break + } + } + if currentIdx == -1 { + mainModel.log.Error("Current page not found in mainModel.pages") + return mainModel, nil + } + + // Hijack all keys if on install process page + if installPage, ok := mainModel.pages[currentIdx].(*installProcessPage); ok { + if mainModel.showAbortConfirm { + // Allow CheckInstallerMsg to update progress even when popup is open + if _, isCheck := msg.(CheckInstallerMsg); isCheck { + updatedPage, cmd := installPage.Update(msg) + mainModel.pages[currentIdx] = updatedPage + return mainModel, cmd + } + // Only handle y/n/esc for popup, block other keys + if keyMsg, isKey := msg.(tea.KeyMsg); isKey { + switch keyMsg.String() { + case "y", "Y": + installPage.Abort() + mainModel.showAbortConfirm = false + fmt.Print("\033[H\033[2J") // Clear the screen before quitting + return mainModel, tea.Quit + case "n", "N", "esc": + mainModel.showAbortConfirm = false + return mainModel, nil + } + } + // Block all other input + return mainModel, nil + } + if keyMsg, isKey := msg.(tea.KeyMsg); isKey { + if keyMsg.Type == tea.KeyCtrlC || keyMsg.String() == "ctrl+c" { + mainModel.showAbortConfirm = true + return mainModel, nil + } + } + if installPage.progress < len(installPage.steps)-1 { + // Ignore all key events during install + if _, isKey := msg.(tea.KeyMsg); isKey { + return mainModel, nil + } + } + if installPage.progress >= len(installPage.steps)-1 { + // After install, any key exits + fmt.Print("\033[H\033[2J") // Clear the screen before quitting + if _, isKey := msg.(tea.KeyMsg); isKey { + return mainModel, tea.Quit + } + } + } + mainModel.log.Tracef("Dealing with message in mainModel.Update") + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + mainModel.log.Debug("User requested to quit the installer") + fmt.Print("\033[H\033[2J") // Clear the screen before quitting + return mainModel, tea.Quit + case "esc": + // Go back to previous page if we have navigation history + if len(mainModel.navigationStack) > 0 { + // Pop the last page from the stack + mainModel.currentPageID = mainModel.navigationStack[len(mainModel.navigationStack)-1] + mainModel.navigationStack = mainModel.navigationStack[:len(mainModel.navigationStack)-1] + return mainModel, mainModel.pages[currentIdx].Init() + } + } + } + + // Handle page navigation + if currentIdx < len(mainModel.pages) { + updatedPage, cmd := mainModel.pages[currentIdx].Update(msg) + mainModel.pages[currentIdx] = updatedPage + + // Check if we need to navigate to next page + if _, ok := msg.(NextPageMsg); ok { + if currentIdx < len(mainModel.pages)-1 { + // Push current page to navigation stack + mainModel.navigationStack = append(mainModel.navigationStack, mainModel.currentPageID) + mainModel.currentPageID = mainModel.pages[currentIdx+1].ID() + return mainModel, tea.Batch(cmd, mainModel.pages[currentIdx+1].Init()) + } + } + + // Check if we need to navigate to a specific page + if goToPageMsg, ok := msg.(GoToPageMsg); ok { + if goToPageMsg.PageID != "" { + for i, p := range mainModel.pages { + if p.ID() == goToPageMsg.PageID { + mainModel.navigationStack = append(mainModel.navigationStack, mainModel.currentPageID) + mainModel.currentPageID = goToPageMsg.PageID + return mainModel, tea.Batch(cmd, mainModel.pages[i].Init()) + } + } + mainModel.log.Tracef("model.Update: pageID=%s not found in mainModel.pages", goToPageMsg.PageID) + } + } + + return mainModel, cmd + } + + return mainModel, nil +} + +func (m Model) View() string { + mainModel.log.Tracef("Rendering view for current page: %s", mainModel.currentPageID) + if mainModel.width == 0 || mainModel.height == 0 { + mainModel.log.Debug("Window size is not set, returning loading message") + return "Loading..." + } + + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(kairosBorder). + Background(kairosBg). + Padding(1). + Width(mainModel.width - 4). + Height(mainModel.height - 4) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(kairosHighlight). + Background(kairosBg). + Padding(0, 0). + Width(mainModel.width - 6). // Set width to match content area + Align(lipgloss.Center) + + // Get current page content by ID + content := "" + help := "" + for _, p := range mainModel.pages { + if p.ID() == mainModel.currentPageID { + content = p.View() + help = p.Help() + break + } + } + + title := titleStyle.Render(mainModel.title) + + helpStyle := lipgloss.NewStyle(). + Foreground(kairosText). + Italic(true) + + var fullHelp string + currentIdx := -1 + for i, p := range mainModel.pages { + if p.ID() == mainModel.currentPageID { + currentIdx = i + break + } + } + if currentIdx != -1 { + if _, ok := mainModel.pages[currentIdx].(*installProcessPage); ok { + fullHelp = help + } else if _, ok := mainModel.pages[currentIdx].(*summaryPage); ok { + fullHelp = help + } else if _, ok := mainModel.pages[currentIdx].(*userdataPage); ok { + fullHelp = help + } else { + fullHelp = help + " • ESC: back • q/ctrl+c: quit" + } + } + + helpText := helpStyle.Render(fullHelp) + + availableHeight := mainModel.height - 8 + contentHeight := availableHeight - 2 + contentLines := strings.Split(content, "\n") + if len(contentLines) > contentHeight { + contentLines = contentLines[:contentHeight] + content = strings.Join(contentLines, "\n") + } + + pageContent := fmt.Sprintf("%s\n\n%s\n\n%s", title, content, helpText) + + if mainModel.showAbortConfirm { + mainModel.log.Debug("Showing abort confirmation popup") + popupStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(kairosAccent). + Background(kairosBg). + Padding(1, 2). + Align(lipgloss.Center) + popupMsg := "Are you sure you want to abort the installation? (y/n)" + popup := popupStyle.Render(popupMsg) + // Overlay the popup in the center + return fmt.Sprintf("%s\n\n%s", borderStyle.Render(pageContent), lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup)) + } + + return borderStyle.Render(pageContent) +} diff --git a/internal/agent/TUIsshKeysPage.go b/internal/agent/TUIsshKeysPage.go new file mode 100644 index 0000000..976d8b9 --- /dev/null +++ b/internal/agent/TUIsshKeysPage.go @@ -0,0 +1,144 @@ +package agent + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SSH Keys Page +type sshKeysPage struct { + mode int // 0 = list view, 1 = add key input + cursor int + sshKeys []string + keyInput textinput.Model +} + +func newSSHKeysPage() *sshKeysPage { + keyInput := textinput.New() + keyInput.Placeholder = "github:USERNAME or gitlab:USERNAME" + keyInput.Width = 60 + + return &sshKeysPage{ + mode: 0, + cursor: 0, + sshKeys: []string{}, + keyInput: keyInput, + } +} + +func (p *sshKeysPage) Init() tea.Cmd { + return nil +} + +func (p *sshKeysPage) Update(msg tea.Msg) (Page, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + if p.mode == 0 { // List view + switch msg.String() { + case "up", "k": + if p.cursor > 0 { + p.cursor-- + } + case "down", "j": + if p.cursor < len(p.sshKeys) { // +1 for "Add new key" option + p.cursor++ + } + case "d": + // Delete selected key + if p.cursor < len(p.sshKeys) { + p.sshKeys = append(p.sshKeys[:p.cursor], p.sshKeys[p.cursor+1:]...) + mainModel.sshKeys = append(mainModel.sshKeys[:p.cursor], mainModel.sshKeys[p.cursor+1:]...) + if p.cursor >= len(p.sshKeys) && p.cursor > 0 { + p.cursor-- + } + } + case "a", "enter": + if p.cursor == len(p.sshKeys) { + // Add new key + p.mode = 1 + p.keyInput.Focus() + return p, textinput.Blink + } + case "esc": + // Go back to customization page + return p, func() tea.Msg { return GoToPageMsg{PageID: "customization"} } + } + } else { // Add key input mode + switch msg.String() { + case "esc": + p.mode = 0 + p.keyInput.Blur() + p.keyInput.SetValue("") + // Go back to customization page + return p, func() tea.Msg { return GoToPageMsg{PageID: "customization"} } + case "enter": + if p.keyInput.Value() != "" { + p.sshKeys = append(p.sshKeys, p.keyInput.Value()) + mainModel.sshKeys = append(mainModel.sshKeys, p.keyInput.Value()) + p.mode = 0 + p.keyInput.Blur() + p.keyInput.SetValue("") + p.cursor = len(p.sshKeys) // Point to "Add new key" option + return p, textinput.Blink + } + } + p.keyInput, cmd = p.keyInput.Update(msg) + } + } + + return p, cmd +} + +func (p *sshKeysPage) View() string { + s := "SSH Keys Management\n\n" + + if p.mode == 0 { + s += "Current SSH Keys:\n\n" + + for i, key := range p.sshKeys { + cursor := " " + if p.cursor == i { + cursor = lipgloss.NewStyle().Foreground(kairosAccent).Render(">") + } + // Truncate long keys for display + displayKey := key + if len(displayKey) > 50 { + displayKey = displayKey[:47] + "..." + } + s += fmt.Sprintf("%s %s\n", cursor, displayKey) + } + + // Add "Add new key" option + cursor := " " + if p.cursor == len(p.sshKeys) { + cursor = lipgloss.NewStyle().Foreground(kairosAccent).Render(">") + } + s += fmt.Sprintf("%s + Add new SSH key\n", cursor) + + s += "\nPress 'd' to delete selected key" + } else { + s += "Add SSH Public Key:\n\n" + s += p.keyInput.View() + "\n\n" + s += "Paste your SSH public key above." + } + + return s +} + +func (p *sshKeysPage) Title() string { + return "SSH Keys" +} + +func (p *sshKeysPage) Help() string { + if p.mode == 0 { + return "↑/k: up • ↓/j: down • enter/a: add key • d: delete • esc: back" + } + return "Type SSH key • enter: add • esc: cancel" +} + +func (p *sshKeysPage) ID() string { return "ssh_keys" } diff --git a/internal/agent/TUIsummaryPage.go b/internal/agent/TUIsummaryPage.go new file mode 100644 index 0000000..785255f --- /dev/null +++ b/internal/agent/TUIsummaryPage.go @@ -0,0 +1,77 @@ +package agent + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v3" +) + +// Summary Page +type summaryPage struct { + cursor int + options []string +} + +func newSummaryPage() *summaryPage { + return &summaryPage{} +} + +func (p *summaryPage) Init() tea.Cmd { + return nil +} + +func (p *summaryPage) Update(msg tea.Msg) (Page, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + return p, func() tea.Msg { return GoToPageMsg{PageID: "install_process"} } + case "v": + return p, func() tea.Msg { return GoToPageMsg{PageID: "userdata"} } + } + } + return p, nil +} + +func (p *summaryPage) View() string { + warningStyle := lipgloss.NewStyle().Foreground(kairosHighlight2) + + s := "Installation Summary\n\n" + s += "Selected Disk: " + mainModel.disk + "\n\n" + s += "Configuration Summary:\n" + if mainModel.username != "" { + s += fmt.Sprintf(" - Username: %s\n", mainModel.username) + } else { + s += " - " + warningStyle.Render("Username: Not set, login to the system wont be possible") + "\n" + } + if len(mainModel.sshKeys) > 0 { + s += fmt.Sprintf(" - SSH Keys: %s\n", mainModel.sshKeys) + } else { + s += " - SSH Keys: Not set\n" + } + + if len(mainModel.extraFields) > 0 { + s += "\nExtra Options:\n" + yamlStr, err := yaml.Marshal(mainModel.extraFields) + if err == nil { + s += "\n" + string(yamlStr) + "\n" + } else { + s += " (error displaying extra options)\n" + } + } else { + s += " - Extra Options: Not set\n" + } + + return s +} + +func (p *summaryPage) Title() string { + return "Installation summary" +} + +func (p *summaryPage) Help() string { + return "Press enter to start the installation process.\nPress v to view the generated userdata." +} + +func (p *summaryPage) ID() string { return "summary" } diff --git a/internal/agent/TUIuserPasswordPage.go b/internal/agent/TUIuserPasswordPage.go new file mode 100644 index 0000000..b9e0590 --- /dev/null +++ b/internal/agent/TUIuserPasswordPage.go @@ -0,0 +1,109 @@ +package agent + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// User Password Page +type userPasswordPage struct { + focusedField int // 0 = username, 1 = password + usernameInput textinput.Model + passwordInput textinput.Model + username string + password string +} + +func newUserPasswordPage() *userPasswordPage { + usernameInput := textinput.New() + usernameInput.Placeholder = "Kairos" + usernameInput.Width = 20 + usernameInput.Focus() + + passwordInput := textinput.New() + passwordInput.Width = 20 + passwordInput.Placeholder = "Kairos" + passwordInput.EchoMode = textinput.EchoPassword + + return &userPasswordPage{ + focusedField: 0, + usernameInput: usernameInput, + passwordInput: passwordInput, + } +} + +func (p *userPasswordPage) Init() tea.Cmd { + return textinput.Blink +} + +func (p *userPasswordPage) Update(msg tea.Msg) (Page, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab": + if p.focusedField == 0 { + p.focusedField = 1 + p.usernameInput.Blur() + p.passwordInput.Focus() + return p, p.passwordInput.Focus() + } else { + p.focusedField = 0 + p.passwordInput.Blur() + p.usernameInput.Focus() + return p, p.usernameInput.Focus() + } + case "enter": + if p.usernameInput.Value() != "" && p.passwordInput.Value() != "" { + p.username = p.usernameInput.Value() + mainModel.username = p.username + p.password = p.passwordInput.Value() + mainModel.password = p.password + // Save and go back to customization + return p, func() tea.Msg { return GoToPageMsg{PageID: "customization"} } + } + case "esc": + // Go back to customization page + return p, func() tea.Msg { return GoToPageMsg{PageID: "customization"} } + } + } + + if p.focusedField == 0 { + p.usernameInput, cmd = p.usernameInput.Update(msg) + } else { + p.passwordInput, cmd = p.passwordInput.Update(msg) + } + + return p, cmd +} + +func (p *userPasswordPage) View() string { + s := "User Account Setup\n\n" + s += "Username:\n" + s += p.usernameInput.View() + "\n\n" + s += "Password:\n" + s += p.passwordInput.View() + "\n\n" + + if p.username != "" { + s += fmt.Sprintf("✓ User configured: %s\n", p.username) + } + + if p.usernameInput.Value() == "" || p.passwordInput.Value() == "" { + s += "\nBoth fields are required to continue." + } + + return s +} + +func (p *userPasswordPage) Title() string { + return "User & Password" +} + +func (p *userPasswordPage) Help() string { + return "tab: switch fields • enter: save and continue" +} + +func (p *userPasswordPage) ID() string { return "user_password" } diff --git a/internal/agent/TUIuserdataPage.go b/internal/agent/TUIuserdataPage.go new file mode 100644 index 0000000..c5112eb --- /dev/null +++ b/internal/agent/TUIuserdataPage.go @@ -0,0 +1,50 @@ +package agent + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Userdata Page + +type userdataPage struct{} + +func (p *userdataPage) Title() string { + return "Userdata Generated" +} + +func (p *userdataPage) Help() string { + return "Press any key to return to the summary page." +} + +func (p *userdataPage) ID() string { + return "userdata" +} + +func newUserdataPage() *userdataPage { + return &userdataPage{} +} + +func (p *userdataPage) Init() tea.Cmd { + return nil +} + +func (p *userdataPage) Update(msg tea.Msg) (Page, tea.Cmd) { + switch msg.(type) { + case tea.KeyMsg: + // Go back to customization page + return p, func() tea.Msg { return GoToPageMsg{PageID: "summary"} } + } + return p, nil +} + +func (p *userdataPage) View() string { + s := "Userdata Generated (plain text):\n" + cc := NewInteractiveInstallConfig(&mainModel) + ccString, err := cc.String() + if err == nil { + s += "\n" + string(ccString) + "\n" + } else { + s += " (error displaying cloud config)\n" + } + return s +} diff --git a/internal/agent/config.go b/internal/agent/config.go index 3568119..c363a49 100644 --- a/internal/agent/config.go +++ b/internal/agent/config.go @@ -1,7 +1,12 @@ package agent import ( + "github.com/kairos-io/kairos-agent/v2/pkg/config" + "github.com/kairos-io/kairos-agent/v2/pkg/constants" + "github.com/kairos-io/kairos-sdk/collector" + "github.com/mudler/yip/pkg/schema" "os" + "strings" "github.com/kairos-io/kairos-agent/v2/internal/kairos" "gopkg.in/yaml.v3" @@ -72,3 +77,75 @@ func LoadConfig(path ...string) (*Config, error) { return cfg, nil } + +type ExtraFields struct { + Extrafields map[string]any `yaml:",inline,omitempty"` +} + +// NewInteractiveInstallConfig creates a new config from model values +func NewInteractiveInstallConfig(m *Model) *config.Config { + // Always set the extra fields + extras := ExtraFields{m.extraFields} + + // This is temporal to generate a valid cc file, no need to properly initialize everything + cc := &config.Config{ + Install: &config.Install{ + Device: m.disk, + }, + } + + if m.source != "" { + cc.Install.Source = m.source + } + + var cloudConfig schema.YipConfig + + // Only add the user stage if we have any users + if m.username != "" { + user := schema.User{ + Name: m.username, + PasswordHash: m.password, + Groups: []string{"admin"}, + SSHAuthorizedKeys: m.sshKeys, + } + + stage := config.NetworkStage.String() + // If we got no ssh keys, we don't need network, do the user as soon as possible + if len(m.sshKeys) == 0 { + stage = config.InitramfsStage.String() + } + + cloudConfig = schema.YipConfig{Name: "Config generated by the installer", + Stages: map[string][]schema.Stage{stage: { + { + Users: map[string]schema.User{ + m.username: user, + }, + }, + }}} + } else { + // If no users, we need to set this option to skip the user validation and confirm that we want a system with no users. + cc.Install.NoUsers = true + } + + // Merge all yamls into one + dat, err := config.MergeYAML(cloudConfig, cc, extras) + if err != nil { + return nil + } + + finalCloudConfig := config.AddHeader("#cloud-config", string(dat)) + + // Read also any userdata in the system + cc, _ = config.ScanNoLogs( + collector.Readers(strings.NewReader(finalCloudConfig)), + collector.Directories(constants.GetUserConfigDirs()...), + collector.MergeBootLine, + ) + + // Generate final config + ccString, _ := cc.String() + m.log.Logger.Debug().Msgf("Generated cloud config: %s", ccString) + + return cc +} diff --git a/internal/agent/interactive_install.go b/internal/agent/interactive_install.go index 9cdb9f2..b5d50e1 100644 --- a/internal/agent/interactive_install.go +++ b/internal/agent/interactive_install.go @@ -1,310 +1,28 @@ package agent import ( - "encoding/json" "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/kairos-io/kairos-sdk/types" "os" - "path/filepath" - "strings" - "github.com/kairos-io/kairos-agent/v2/internal/bus" - "github.com/kairos-io/kairos-agent/v2/internal/cmd" - "github.com/kairos-io/kairos-agent/v2/pkg/config" - events "github.com/kairos-io/kairos-sdk/bus" - "github.com/kairos-io/kairos-sdk/collector" - "github.com/kairos-io/kairos-sdk/ghw" - "github.com/kairos-io/kairos-sdk/unstructured" - - "github.com/erikgeiser/promptkit/textinput" "github.com/kairos-io/kairos-sdk/utils" - "github.com/mudler/go-pluggable" - "github.com/mudler/yip/pkg/schema" - "github.com/pterm/pterm" ) -const ( - canBeEmpty = "Unset" - yesNo = "[y]es/[N]o" -) - -func prompt(prompt, initialValue, placeHolder string, canBeEmpty, hidden bool) (string, error) { - input := textinput.New(prompt) - input.InitialValue = initialValue - input.Placeholder = placeHolder - if canBeEmpty { - input.Validate = func(s string) error { return nil } +// InteractiveInstall starts the interactive installation process. +// The function signature was updated to replace the `debug` parameter with a `logger` parameter (`l types.KairosLogger`). +// - `spawnShell`: If true, spawns a shell after the installation process. +// - `source`: The source of the installation. (Consider reviewing its necessity as noted in the TODO comment.) +// - `l`: A logger instance for logging messages during the installation process. +func InteractiveInstall(spawnShell bool, source string, logger types.KairosLogger) error { + var err error + // Set a default window size + p := tea.NewProgram(InitialModel(&logger, source), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v", err) + os.Exit(1) } - input.Hidden = hidden - - return input.RunPrompt() -} - -func isYes(s string) bool { - i := strings.ToLower(s) - if i == "y" || i == "yes" { - return true - } - return false -} - -const ( - _ = 1 << (10 * iota) - KiB - MiB - GiB - TiB -) - -func promptBool(p events.YAMLPrompt) (string, error) { - def := "n" - if p.Default != "" { - def = p.Default - } - val, err := prompt(p.Prompt, def, yesNo, true, false) - if err != nil { - return "", err - } - if isYes(val) { - val = "true" - } else { - val = "false" - } - - return val, nil -} - -func promptText(p events.YAMLPrompt) (string, error) { - def := "" - if p.Default != "" { - def = p.Default - } - return prompt(p.Prompt, def, p.PlaceHolder, true, false) -} - -func promptToUnstructured(p events.YAMLPrompt, unstructuredYAML map[string]interface{}) (map[string]interface{}, error) { - var res string - if p.AskFirst { - ask, err := prompt(p.AskPrompt, "n", yesNo, true, false) - if err == nil && !isYes(ask) { - return unstructuredYAML, nil - } - } - if p.Bool { - val, err := promptBool(p) - if err != nil { - return unstructuredYAML, err - } - unstructuredYAML[p.YAMLSection] = val - res = val - } else { - val, err := promptText(p) - if err != nil { - return unstructuredYAML, err - } - unstructuredYAML[p.YAMLSection] = val - res = val - } - - if res == "" && p.IfEmpty != "" { - res = p.IfEmpty - unstructuredYAML[p.YAMLSection] = res - } - return unstructuredYAML, nil -} - -func InteractiveInstall(debug, spawnShell bool, sourceImgURL string) error { - var sshUsers []string - bus.Manager.Initialize() - - cmd.PrintBranding(DefaultBanner) - - agentConfig, err := LoadConfig() - if err != nil { - return err - } - - cmd.PrintText(agentConfig.Branding.InteractiveInstall, "Installation") - - disks := []string{} - maxSize := float64(0) - preferedDevice := "/dev/sda" - - for _, disk := range ghw.GetDisks(ghw.NewPaths(""), nil) { - // skip useless devices (/dev/ram, /dev/loop, /dev/sr, /dev/zram) - if strings.HasPrefix(disk.Name, "loop") || strings.HasPrefix(disk.Name, "ram") || strings.HasPrefix(disk.Name, "sr") || strings.HasPrefix(disk.Name, "zram") { - continue - } - size := float64(disk.SizeBytes) / float64(GiB) - if size > maxSize { - maxSize = size - preferedDevice = "/dev/" + disk.Name - } - disks = append(disks, fmt.Sprintf("/dev/%s: (%.2f GiB) ", disk.Name, float64(disk.SizeBytes)/float64(GiB))) - } - - pterm.Info.Println("Available Disks:") - for _, d := range disks { - pterm.Info.Println(" " + d) - } - - device, err := prompt("What's the target install device?", preferedDevice, "Cannot be empty", false, false) - if err != nil { - return err - } - - createUser, err := prompt("Do you want to create any users? If not, system will not be accesible via terminal or ssh", "y", yesNo, true, false) - if err != nil { - return err - } - - var userName, userPassword, sshKeys, makeAdmin string - - if isYes(createUser) { - userName, err = prompt("User to setup", "kairos", canBeEmpty, true, false) - if err != nil { - return err - } - - userPassword, err = prompt("Password", "", canBeEmpty, true, true) - if err != nil { - return err - } - - if userPassword == "" { - userPassword = "!" - } - - sshKeys, err = prompt("SSH access (rsakey, github/gitlab supported, comma-separated)", "github:someuser,github:someuser2", canBeEmpty, true, false) - if err != nil { - return err - } - - makeAdmin, err = prompt("Make the user an admin (with sudo permissions)?", "y", yesNo, true, false) - if err != nil { - return err - } - - // Cleanup the users if we selected the default values as they are not valid users - if sshKeys == "github:someuser,github:someuser2" { - sshKeys = "" - } - if sshKeys != "" { - sshUsers = strings.Split(sshKeys, ",") - } - } - - // Prompt the user by prompts defined by the provider - r := []events.YAMLPrompt{} - - bus.Manager.Response(events.EventInteractiveInstall, func(p *pluggable.Plugin, resp *pluggable.EventResponse) { - err := json.Unmarshal([]byte(resp.Data), &r) - if err != nil { - fmt.Println(err) - } - }) - - _, err = bus.Manager.Publish(events.EventInteractiveInstall, events.EventPayload{}) - if err != nil { - return err - } - - unstructuredYAML := map[string]interface{}{} - for _, p := range r { - unstructuredYAML, err = promptToUnstructured(p, unstructuredYAML) - if err != nil { - return err - } - } - - result, err := unstructured.ToYAMLMap(unstructuredYAML) - if err != nil { - return err - } - - allGood, err := prompt("Are settings ok?", "n", yesNo, true, false) - if err != nil { - return err - } - - if !isYes(allGood) { - return InteractiveInstall(debug, spawnShell, sourceImgURL) - } - - // This is temporal to generate a valid cc file, no need to properly initialize everything - cc := &config.Config{ - Install: &config.Install{ - Device: device, - }, - } - - var cloudConfig schema.YipConfig - - // Only add the user stage if we have any users - if userName != "" { - var isAdmin []string - - if isYes(makeAdmin) { - isAdmin = append(isAdmin, "admin") - } - - user := schema.User{ - Name: userName, - PasswordHash: userPassword, - Groups: isAdmin, - SSHAuthorizedKeys: sshUsers, - } - - stage := config.NetworkStage.String() - // If we got no ssh keys, we don't need network, do the user as soon as possible - if len(sshUsers) == 0 { - stage = config.InitramfsStage.String() - } - - cloudConfig = schema.YipConfig{Name: "Config generated by the installer", - Stages: map[string][]schema.Stage{stage: { - { - Users: map[string]schema.User{ - userName: user, - }, - }, - }}} - } else { - // If no users, we need to set this option to skip the user validation and confirm that we want a system with no users. - cc.Install.NoUsers = true - } - - // Merge all yamls into one - dat, err := config.MergeYAML(cloudConfig, cc, result) - if err != nil { - return err - } - - finalCloudConfig := config.AddHeader("#cloud-config", string(dat)) - - // Store it in a temp file and load it with the collector to have a standard way of loading across all methods - tmpdir, err := os.MkdirTemp("", "kairos-install-") - if err == nil { - err = os.WriteFile(filepath.Join(tmpdir, "kairos-event-install-data.yaml"), []byte(finalCloudConfig), os.ModePerm) - if err != nil { - fmt.Printf("could not write event cloud init: %s\n", err.Error()) - } - - cliConf := generateInstallConfForCLIArgs(sourceImgURL) - cc, _ = config.Scan(collector.Directories(tmpdir), - collector.Readers(strings.NewReader(cliConf)), - collector.MergeBootLine, collector.NoLogs) - } - - pterm.Info.Println("Starting installation") - // Generate final config - ccString, _ := cc.String() - pterm.Info.Println(ccString) - - err = RunInstall(cc) - if err != nil { - pterm.Error.Println(err.Error()) - } - + //TODO: This will always exit and return I think, so the below is useless? Unless we want to hijack the TTY in which case we should do something here for that if spawnShell { return utils.Shell().Run() } diff --git a/internal/kairos/branding.go b/internal/kairos/branding.go index 7614d93..6bd1777 100644 --- a/internal/kairos/branding.go +++ b/internal/kairos/branding.go @@ -1,7 +1,20 @@ package kairos -import "path" +import ( + "os" + "path" +) func BrandingFile(s string) string { return path.Join("/etc", "kairos", "branding", s) } + +func DefaultTitleInteractiveInstaller() string { + // Load it from a text file or something + branding, err := os.ReadFile(BrandingFile("interactive_install_text")) + if err == nil { + return string(branding) + } else { + return "Kairos Interactive Installer" + } +} diff --git a/main.go b/main.go index 6cf9696..18a879b 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + sdkTypes "github.com/kairos-io/kairos-sdk/types" "os" "os/exec" "path/filepath" @@ -503,9 +504,13 @@ This command is meant to be used from the boot GRUB menu, but can be also starte return checkRoot() }, Action: func(c *cli.Context) error { - source := c.String("source") + log := sdkTypes.NewKairosLogger("agent", "info", true) + // Get the viper config in case something in command line or env var has set it and set the level asap + if viper.GetBool("debug") { + log.SetLevel("debug") + } - return agent.InteractiveInstall(c.Bool("debug"), c.Bool("shell"), source) + return agent.InteractiveInstall(c.Bool("shell"), c.String("source"), log) }, }, {