Bring over the TUI to interactive installer (#845)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Itxaka
2025-07-23 10:54:27 +02:00
committed by GitHub
parent 6a573eafbb
commit 6d74cdc4b6
15 changed files with 1711 additions and 300 deletions

View File

@@ -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.
)

View File

@@ -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
}

View File

@@ -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" }

View File

@@ -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
}

View File

@@ -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" }

View File

@@ -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:
}
}

302
internal/agent/TUImodel.go Normal file
View File

@@ -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)
}

View File

@@ -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" }

View File

@@ -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" }

View File

@@ -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" }

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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"
}
}

View File

@@ -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)
},
},
{