mirror of
https://github.com/kairos-io/kairos-agent.git
synced 2025-09-19 09:12:37 +00:00
Bring over the TUI to interactive installer (#845)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
64
internal/agent/TUIconstants.go
Normal file
64
internal/agent/TUIconstants.go
Normal 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.
|
||||
)
|
207
internal/agent/TUIcustomizationPage.go
Normal file
207
internal/agent/TUIcustomizationPage.go
Normal 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
|
||||
}
|
109
internal/agent/TUIdiskPage.go
Normal file
109
internal/agent/TUIdiskPage.go
Normal 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" }
|
213
internal/agent/TUIgenericPage.go
Normal file
213
internal/agent/TUIgenericPage.go
Normal 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
|
||||
}
|
78
internal/agent/TUIinstallOptionsPage.go
Normal file
78
internal/agent/TUIinstallOptionsPage.go
Normal 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" }
|
245
internal/agent/TUIinstallProcessPage.go
Normal file
245
internal/agent/TUIinstallProcessPage.go
Normal 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
302
internal/agent/TUImodel.go
Normal 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)
|
||||
}
|
144
internal/agent/TUIsshKeysPage.go
Normal file
144
internal/agent/TUIsshKeysPage.go
Normal 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" }
|
77
internal/agent/TUIsummaryPage.go
Normal file
77
internal/agent/TUIsummaryPage.go
Normal 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" }
|
109
internal/agent/TUIuserPasswordPage.go
Normal file
109
internal/agent/TUIuserPasswordPage.go
Normal 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" }
|
50
internal/agent/TUIuserdataPage.go
Normal file
50
internal/agent/TUIuserdataPage.go
Normal 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
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
|
9
main.go
9
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
Reference in New Issue
Block a user