diff --git a/cmd/installer/branding/branding.go b/cmd/installer/branding/branding.go new file mode 100644 index 000000000..6c0353ed4 --- /dev/null +++ b/cmd/installer/branding/branding.go @@ -0,0 +1,56 @@ +/* +Copyright © 2026 Mulga Defense Corporation + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package branding centralises Mulga/Spinifex visual identity constants used +// by the installer TUI. +// +// TODO: Replace placeholder values with official Mulga/Spinifex assets once +// the brand guide and logo files are located. +package branding + +import "github.com/charmbracelet/lipgloss" + +// Logo is the ASCII art rendered on the installer welcome screen. +// TODO: Replace with official Spinifex/Mulga ASCII logo. +const Logo = ` + ███████╗██████╗ ██╗███╗ ██╗██╗███████╗███████╗██╗ ██╗ + ██╔════╝██╔══██╗██║████╗ ██║██║██╔════╝██╔════╝╚██╗██╔╝ + ███████╗██████╔╝██║██╔██╗ ██║██║█████╗ █████╗ ╚███╔╝ + ╚════██║██╔═══╝ ██║██║╚██╗██║██║██╔══╝ ██╔══╝ ██╔██╗ + ███████║██║ ██║██║ ╚████║██║██║ ███████╗██╔╝ ██╗ + ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝` + +// Subtitle is displayed beneath the logo on the welcome screen. +const Subtitle = "Bare-Metal Node Provisioner" + +// Publisher is shown in the welcome screen footer. +const Publisher = "Mulga Defense Corporation" + +// Mulga color palette. +// TODO: Replace with official hex values from the Mulga brand guide. +const ( + ColorPrimary = lipgloss.Color("#00A3E0") // Mulga blue (placeholder) + ColorAccent = lipgloss.Color("#FF6B00") // Mulga orange (placeholder) + ColorBackground = lipgloss.Color("#0D1117") // Dark background + ColorSurface = lipgloss.Color("#161B22") // Slightly lighter surface + ColorText = lipgloss.Color("#E6EDF3") // Light text + ColorMuted = lipgloss.Color("#8B949E") // Muted / secondary text + ColorSuccess = lipgloss.Color("#3FB950") // Green + ColorWarning = lipgloss.Color("#D29922") // Amber + ColorError = lipgloss.Color("#F85149") // Red + ColorBorder = lipgloss.Color("#30363D") // Border +) diff --git a/cmd/installer/main.go b/cmd/installer/main.go index 761880de6..a755c03d4 100644 --- a/cmd/installer/main.go +++ b/cmd/installer/main.go @@ -19,13 +19,16 @@ package main import ( "fmt" "os" + "strings" "github.com/mulgadc/spinifex/cmd/installer/install" "github.com/mulgadc/spinifex/cmd/installer/ui" ) func main() { - cfg, err := ui.Run() + ttyPath := detectTTY() + + cfg, err := ui.Run(ttyPath) if err != nil { fmt.Fprintf(os.Stderr, "installer: %v\n", err) os.Exit(1) @@ -36,3 +39,22 @@ func main() { os.Exit(1) } } + +// detectTTY returns the TTY device path the installer should attach to. +// It reads SPINIFEX_CONSOLE from the kernel cmdline (/proc/cmdline) so that +// spinifex-init can direct the installer to the correct console (ttyS0 for +// serial, tty1 for VGA). Falls back to tty1. +func detectTTY() string { + data, err := os.ReadFile("/proc/cmdline") + if err == nil { + for param := range strings.FieldsSeq(string(data)) { + if after, ok := strings.CutPrefix(param, "SPINIFEX_CONSOLE="); ok { + val := after + if val != "" && val != "auto" { + return "/dev/" + val + } + } + } + } + return "/dev/tty1" +} diff --git a/cmd/installer/ui/ui.go b/cmd/installer/ui/ui.go index 6d8992ada..0b3db6bf4 100644 --- a/cmd/installer/ui/ui.go +++ b/cmd/installer/ui/ui.go @@ -15,7 +15,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ -// Package ui presents the interactive installer forms using huh. +// Package ui presents the interactive installer TUI using bubbletea and lipgloss. package ui import ( @@ -25,15 +25,80 @@ import ( "os" "strings" - "github.com/charmbracelet/huh" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mulgadc/spinifex/cmd/installer/branding" "github.com/mulgadc/spinifex/cmd/installer/install" ) -// Run walks the operator through all installer forms and returns a completed -// Config ready to hand to install.Run. -func Run() (*install.Config, error) { - cfg := &install.Config{} +// screen represents which step of the wizard is active. +type screen int +const ( + screenWelcome screen = iota + screenDisk + screenDiskConfirm + screenNetwork + screenIdentity + screenJoinConfig + screenCACertPrompt + screenCACert + screenConfirm + screenDone // signals completion; program exits +) + +// networkField tracks which text input is focused on the network screen. +type networkField int + +const ( + fieldIP networkField = iota + fieldMask + fieldGateway + fieldNIC + fieldNetworkCount +) + +// model is the top-level bubbletea model for the installer wizard. +type model struct { + screen screen + width int + height int + + // Disk selection + disks []diskInfo + diskCursor int + eraseInput textinput.Model + + // Network + netInputs [3]textinput.Model // IP, mask, gateway + netFocus networkField + nics []string + nicCursor int + + // Identity + hostnameInput textinput.Model + clusterRole int // 0 = init, 1 = join + + // Join config + joinIPInput textinput.Model + joinPortInput textinput.Model + + // CA cert + hasCACert int // 0 = no, 1 = yes + caCertInput textinput.Model + + // Accumulated validation error shown on current screen + validationErr string + + // Final result — set when screenDone is reached + result *install.Config + err error +} + +// Run launches the bubbletea program connected to ttyPath and returns the +// completed Config when the user finishes the wizard. +func Run(ttyPath string) (*install.Config, error) { disks, err := availableDisks() if err != nil { return nil, fmt.Errorf("listing disks: %w", err) @@ -47,198 +112,777 @@ func Run() (*install.Config, error) { return nil, fmt.Errorf("listing network interfaces: %w", err) } - var joinHost string - var joinPort string - var caCert string + m := newModel(disks, nics) - form := huh.NewForm( - // Step 1: Welcome - huh.NewGroup( - huh.NewNote(). - Title("Spinifex Installer"). - Description("This will install Spinifex onto this machine.\n\nAll data on the selected disk will be erased.\nPress Enter to continue."), - ), + var opts []tea.ProgramOption + opts = append(opts, tea.WithAltScreen()) - // Step 2: Disk selection - huh.NewGroup( - huh.NewSelect[string](). - Title("Select installation disk"). - Description("All data on the selected disk will be permanently erased."). - Options(diskOptions(disks)...). - Value(&cfg.Disk), - huh.NewConfirm(). - Title("Confirm disk erasure"). - Description("Are you sure? This cannot be undone."). - Affirmative("Yes, erase and install"). - Negative("Cancel"). - Validate(func(v bool) error { - if !v { - return errors.New("installation cancelled") - } - return nil - }). - Value(new(bool)), - ), + if ttyPath != "" { + tty, err := os.OpenFile(ttyPath, os.O_RDWR, 0) + if err == nil { + opts = append(opts, tea.WithInput(tty), tea.WithOutput(tty)) + } + } - // Step 3: Network configuration - huh.NewGroup( - huh.NewInput(). - Title("Management IP address"). - Description("Static IP address for this node (e.g. 192.168.1.10)"). - Validate(validateIP). - Value(&cfg.ManagementIP), - huh.NewInput(). - Title("Subnet mask"). - Placeholder("255.255.255.0"). - Validate(validateIP). - Value(&cfg.SubnetMask), - huh.NewInput(). - Title("Default gateway"). - Validate(validateIP). - Value(&cfg.Gateway), - huh.NewSelect[string](). - Title("OVN network interface"). - Description("Physical NIC to use for VM networking"). - Options(nicOptions(nics)...). - Value(&cfg.OVNInterface), - ), - - // Step 4: Node configuration - huh.NewGroup( - huh.NewInput(). - Title("Hostname"). - Description("Name for this node (e.g. node1)"). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return errors.New("hostname is required") - } - return nil - }). - Value(&cfg.Hostname), - huh.NewSelect[string](). - Title("Cluster role"). - Options( - huh.NewOption("Initialize new cluster (single or first node)", "init"), - huh.NewOption("Join existing cluster", "join"), - ). - Value(&cfg.ClusterRole), - ), - - // Step 5: Join configuration (shown when joining) - huh.NewGroup( - huh.NewInput(). - Title("Primary node IP"). - Description("IP address of the node running spx admin init"). - Validate(func(s string) error { - if cfg.ClusterRole != "join" { - return nil - } - return validateIP(s) - }). - Value(&joinHost), - huh.NewInput(). - Title("Formation port"). - Placeholder("4432"). - Value(&joinPort), - ).WithHideFunc(func() bool { - return cfg.ClusterRole != "join" - }), - - // Step 6: Optional CA certificate - huh.NewGroup( - huh.NewConfirm(). - Title("Install a custom CA certificate?"). - Description("Required for air-gapped deployments with a private certificate authority."). - Affirmative("Yes"). - Negative("No"). - Value(&cfg.HasCACert), - ), - huh.NewGroup( - huh.NewText(). - Title("CA certificate (PEM format)"). - Description("Paste the full PEM certificate block."). - Validate(func(s string) error { - if !cfg.HasCACert { - return nil - } - if !strings.Contains(s, "BEGIN CERTIFICATE") { - return errors.New("does not look like a PEM certificate") - } - return nil - }). - Value(&caCert), - ).WithHideFunc(func() bool { - return !cfg.HasCACert - }), - - // Step 7: Final confirmation - huh.NewGroup( - huh.NewConfirm(). - Title("Begin installation?"). - Description("Review your choices above. Installation will begin immediately."). - Affirmative("Install"). - Negative("Cancel"). - Validate(func(v bool) error { - if !v { - return errors.New("installation cancelled") - } - return nil - }). - Value(new(bool)), - ), - ) - - if err := form.Run(); err != nil { + p := tea.NewProgram(m, opts...) + final, err := p.Run() + if err != nil { return nil, err } + fm := final.(model) + if fm.err != nil { + return nil, fm.err + } + return fm.result, nil +} + +func newModel(disks []diskInfo, nics []string) model { + eraseIn := textinput.New() + eraseIn.Placeholder = "yes" + eraseIn.CharLimit = 3 + + ipIn := textinput.New() + ipIn.Placeholder = "192.168.1.10" + + maskIn := textinput.New() + maskIn.Placeholder = "255.255.255.0" + + gwIn := textinput.New() + gwIn.Placeholder = "192.168.1.1" + ipIn.Focus() + + hostnameIn := textinput.New() + hostnameIn.Placeholder = "node1" + hostnameIn.CharLimit = 64 + + joinIPIn := textinput.New() + joinIPIn.Placeholder = "192.168.1.10" + + joinPortIn := textinput.New() + joinPortIn.Placeholder = "4432" + joinPortIn.CharLimit = 5 + + caCertIn := textinput.New() + caCertIn.Placeholder = "-----BEGIN CERTIFICATE-----" + caCertIn.CharLimit = 0 + + return model{ + screen: screenWelcome, + disks: disks, + nics: nics, + eraseInput: eraseIn, + netInputs: [3]textinput.Model{ipIn, maskIn, gwIn}, + hostnameInput: hostnameIn, + joinIPInput: joinIPIn, + joinPortInput: joinPortIn, + caCertInput: caCertIn, + } +} + +// ── Styles ──────────────────────────────────────────────────────────────────── + +var ( + styleLogo = lipgloss.NewStyle(). + Foreground(branding.ColorPrimary). + Bold(true) + + styleTitle = lipgloss.NewStyle(). + Foreground(branding.ColorPrimary). + Bold(true). + MarginBottom(1) + + styleSubtitle = lipgloss.NewStyle(). + Foreground(branding.ColorMuted) + + styleBox = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(branding.ColorBorder). + Padding(1, 2) + + styleSelected = lipgloss.NewStyle(). + Foreground(branding.ColorBackground). + Background(branding.ColorPrimary). + Bold(true) + + styleWarning = lipgloss.NewStyle(). + Foreground(branding.ColorWarning). + Bold(true) + + styleError = lipgloss.NewStyle(). + Foreground(branding.ColorError) + + styleMuted = lipgloss.NewStyle(). + Foreground(branding.ColorMuted) + + styleSuccess = lipgloss.NewStyle(). + Foreground(branding.ColorSuccess) + + styleLabel = lipgloss.NewStyle(). + Foreground(branding.ColorAccent). + Bold(true) + + styleHelp = lipgloss.NewStyle(). + Foreground(branding.ColorMuted). + MarginTop(1) +) + +// ── Init / Update / View ────────────────────────────────────────────────────── + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + m.validationErr = "" + switch msg.String() { + case "ctrl+c": + m.err = errors.New("installation cancelled") + return m, tea.Quit + } + return m.handleKey(msg) + } + + // Forward to active input + return m.updateActiveInput(msg) +} + +func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch m.screen { + case screenWelcome: + if key == "enter" || key == " " { + m.screen = screenDisk + } + + case screenDisk: + switch key { + case "up", "k": + if m.diskCursor > 0 { + m.diskCursor-- + } + case "down", "j": + if m.diskCursor < len(m.disks)-1 { + m.diskCursor++ + } + case "enter": + m.screen = screenDiskConfirm + m.eraseInput.Focus() + m.eraseInput.SetValue("") + } + + case screenDiskConfirm: + switch key { + case "enter": + if strings.ToLower(strings.TrimSpace(m.eraseInput.Value())) != "yes" { + m.validationErr = "Type 'yes' to confirm disk erasure" + return m, nil + } + m.screen = screenNetwork + m.netFocus = fieldIP + m.netInputs[0].Focus() + m.netInputs[1].Blur() + m.netInputs[2].Blur() + case "esc": + m.screen = screenDisk + return m, nil + default: + var cmd tea.Cmd + m.eraseInput, cmd = m.eraseInput.Update(msg) + return m, cmd + } + + case screenNetwork: + switch key { + case "tab", "down": + m.netFocus = (m.netFocus + 1) % fieldNetworkCount + m = m.withFocusedNetworkField() + case "shift+tab", "up": + m.netFocus = (m.netFocus - 1 + fieldNetworkCount) % fieldNetworkCount + m = m.withFocusedNetworkField() + case "enter": + if m.netFocus < fieldNIC { + m.netFocus++ + m = m.withFocusedNetworkField() + return m, nil + } + // Validate all fields + ip := strings.TrimSpace(m.netInputs[fieldIP].Value()) + mask := strings.TrimSpace(m.netInputs[fieldMask].Value()) + gw := strings.TrimSpace(m.netInputs[fieldGateway].Value()) + if net.ParseIP(ip) == nil { + m.validationErr = "Invalid management IP address" + return m, nil + } + if net.ParseIP(mask) == nil { + m.validationErr = "Invalid subnet mask" + return m, nil + } + if net.ParseIP(gw) == nil { + m.validationErr = "Invalid gateway address" + return m, nil + } + if len(m.nics) == 0 { + m.validationErr = "No network interfaces available" + return m, nil + } + m.screen = screenIdentity + m.hostnameInput.Focus() + case "left", "h": + if m.netFocus == fieldNIC && m.nicCursor > 0 { + m.nicCursor-- + } + case "right", "l": + if m.netFocus == fieldNIC && m.nicCursor < len(m.nics)-1 { + m.nicCursor++ + } + default: + if m.netFocus < fieldNIC { + var cmd tea.Cmd + m.netInputs[m.netFocus], cmd = m.netInputs[m.netFocus].Update(msg) + return m, cmd + } + } + + case screenIdentity: + switch key { + case "tab", "down": + if m.hostnameInput.Focused() { + m.hostnameInput.Blur() + } else { + m.hostnameInput.Focus() + } + case "left", "h", "right", "l": + if !m.hostnameInput.Focused() { + if key == "left" || key == "h" { + m.clusterRole = 0 + } else { + m.clusterRole = 1 + } + } + case "enter": + if m.hostnameInput.Focused() { + if strings.TrimSpace(m.hostnameInput.Value()) == "" { + m.validationErr = "Hostname is required" + return m, nil + } + m.hostnameInput.Blur() + return m, nil + } + if m.clusterRole == 1 { + m.screen = screenJoinConfig + m.joinIPInput.Focus() + } else { + m.screen = screenCACertPrompt + } + default: + if m.hostnameInput.Focused() { + var cmd tea.Cmd + m.hostnameInput, cmd = m.hostnameInput.Update(msg) + return m, cmd + } + } + + case screenJoinConfig: + switch key { + case "tab", "down": + if m.joinIPInput.Focused() { + m.joinIPInput.Blur() + m.joinPortInput.Focus() + } else { + m.joinPortInput.Blur() + m.joinIPInput.Focus() + } + case "enter": + if m.joinIPInput.Focused() { + m.joinIPInput.Blur() + m.joinPortInput.Focus() + return m, nil + } + joinIP := strings.TrimSpace(m.joinIPInput.Value()) + if net.ParseIP(joinIP) == nil { + m.validationErr = "Invalid primary node IP" + return m, nil + } + m.screen = screenCACertPrompt + case "esc": + m.screen = screenIdentity + default: + var cmd tea.Cmd + if m.joinIPInput.Focused() { + m.joinIPInput, cmd = m.joinIPInput.Update(msg) + } else { + m.joinPortInput, cmd = m.joinPortInput.Update(msg) + } + return m, cmd + } + + case screenCACertPrompt: + switch key { + case "left", "h", "right", "l": + if m.hasCACert == 0 { + m.hasCACert = 1 + } else { + m.hasCACert = 0 + } + case "enter": + if m.hasCACert == 1 { + m.screen = screenCACert + m.caCertInput.Focus() + } else { + m.screen = screenConfirm + } + } + + case screenCACert: + switch key { + case "enter": + cert := strings.TrimSpace(m.caCertInput.Value()) + if !strings.Contains(cert, "BEGIN CERTIFICATE") { + m.validationErr = "Does not look like a PEM certificate" + return m, nil + } + m.screen = screenConfirm + case "esc": + m.screen = screenCACertPrompt + default: + var cmd tea.Cmd + m.caCertInput, cmd = m.caCertInput.Update(msg) + return m, cmd + } + + case screenConfirm: + switch key { + case "enter", "y", "Y": + m.result = m.buildConfig() + m.screen = screenDone + return m, tea.Quit + case "n", "N", "esc": + m.err = errors.New("installation cancelled") + return m, tea.Quit + } + } + + return m, nil +} + +func (m model) withFocusedNetworkField() model { + for i := range m.netInputs { + if networkField(i) == m.netFocus { + m.netInputs[i].Focus() + } else { + m.netInputs[i].Blur() + } + } + return m +} + +func (m model) updateActiveInput(msg tea.Msg) (tea.Model, tea.Cmd) { + // Delegate to the currently-focused text input for cursor blink, etc. + switch m.screen { + case screenDiskConfirm: + var cmd tea.Cmd + m.eraseInput, cmd = m.eraseInput.Update(msg) + return m, cmd + case screenNetwork: + if m.netFocus < fieldNIC { + var cmd tea.Cmd + m.netInputs[m.netFocus], cmd = m.netInputs[m.netFocus].Update(msg) + return m, cmd + } + case screenIdentity: + if m.hostnameInput.Focused() { + var cmd tea.Cmd + m.hostnameInput, cmd = m.hostnameInput.Update(msg) + return m, cmd + } + } + return m, nil +} + +// ── View ────────────────────────────────────────────────────────────────────── + +func (m model) View() string { + w := m.width + if w == 0 { + w = 80 + } + + var content string + switch m.screen { + case screenWelcome: + content = m.viewWelcome(w) + case screenDisk: + content = m.viewDisk(w) + case screenDiskConfirm: + content = m.viewDiskConfirm(w) + case screenNetwork: + content = m.viewNetwork(w) + case screenIdentity: + content = m.viewIdentity(w) + case screenJoinConfig: + content = m.viewJoinConfig(w) + case screenCACertPrompt: + content = m.viewCACertPrompt(w) + case screenCACert: + content = m.viewCACert(w) + case screenConfirm: + content = m.viewConfirm(w) + case screenDone: + content = m.viewDone(w) + } + + return content +} + +func (m model) viewWelcome(w int) string { + logo := styleLogo.Render(branding.Logo) + subtitle := styleSubtitle.Render(branding.Subtitle) + publisher := styleMuted.Render(branding.Publisher) + + warning := styleWarning.Render("WARNING: Installation will erase the selected disk entirely.") + help := styleHelp.Render("Press Enter to begin") + + body := lipgloss.JoinVertical(lipgloss.Center, + logo, + subtitle, + publisher, + "", + warning, + "", + help, + ) + + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 72)).Render(body), + ) +} + +func (m model) viewDisk(w int) string { + title := styleTitle.Render("Select Installation Disk") + subtitle := styleMuted.Render("All data on the selected disk will be permanently erased.") + + var rows []string + for i, d := range m.disks { + line := fmt.Sprintf(" %-20s %-8s %s", d.Path, d.Size, d.Model) + if i == m.diskCursor { + line = styleSelected.Render("> " + line[2:]) + } else { + line = styleMuted.Render(line) + } + rows = append(rows, line) + } + + help := styleHelp.Render("↑/↓ to select • Enter to confirm") + body := lipgloss.JoinVertical(lipgloss.Left, append([]string{title, subtitle, ""}, append(rows, "", help)...)...) + + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 72)).Render(body), + ) +} + +func (m model) viewDiskConfirm(w int) string { + title := styleTitle.Render("Confirm Disk Erasure") + disk := styleLabel.Render(m.disks[m.diskCursor].Path) + msg := fmt.Sprintf("All data on %s will be permanently erased.\nType 'yes' to confirm:", disk) + + var lines []string + lines = append(lines, title, msg, "", m.eraseInput.View()) + if m.validationErr != "" { + lines = append(lines, "", styleError.Render(m.validationErr)) + } + lines = append(lines, styleHelp.Render("Enter to confirm • Esc to go back")) + + body := lipgloss.JoinVertical(lipgloss.Left, lines...) + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 64)).Render(body), + ) +} + +func (m model) viewNetwork(w int) string { + title := styleTitle.Render("Network Configuration") + + labels := []string{"Management IP", "Subnet mask", "Default gateway"} + var rows []string + for i, inp := range m.netInputs { + label := styleLabel.Render(labels[i]) + if networkField(i) == m.netFocus { + rows = append(rows, label, inp.View(), "") + } else { + rows = append(rows, label, styleMuted.Render(inp.Value()), "") + } + } + + // NIC selector + nicLabel := styleLabel.Render("OVN network interface") + var nicLine string + if len(m.nics) == 0 { + nicLine = styleError.Render("no interfaces found") + } else { + var parts []string + for i, nic := range m.nics { + if i == m.nicCursor { + if m.netFocus == fieldNIC { + parts = append(parts, styleSelected.Render(" "+nic+" ")) + } else { + parts = append(parts, styleLabel.Render("["+nic+"]")) + } + } else { + parts = append(parts, styleMuted.Render(nic)) + } + } + nicLine = strings.Join(parts, " ") + } + rows = append(rows, nicLabel, nicLine) + + var errLine []string + if m.validationErr != "" { + errLine = []string{"", styleError.Render(m.validationErr)} + } + + help := styleHelp.Render("Tab/↑↓ to move • ←/→ to select NIC • Enter to proceed") + all := append([]string{title, ""}, append(rows, append(errLine, "", help)...)...) + body := lipgloss.JoinVertical(lipgloss.Left, all...) + + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 64)).Render(body), + ) +} + +func (m model) viewIdentity(w int) string { + title := styleTitle.Render("Node Identity") + + hostnameLabel := styleLabel.Render("Hostname") + + roleLabel := styleLabel.Render("Cluster role") + roles := []string{"Initialize new cluster", "Join existing cluster"} + var roleParts []string + for i, r := range roles { + if i == m.clusterRole && !m.hostnameInput.Focused() { + roleParts = append(roleParts, styleSelected.Render(" "+r+" ")) + } else if i == m.clusterRole { + roleParts = append(roleParts, styleLabel.Render("["+r+"]")) + } else { + roleParts = append(roleParts, styleMuted.Render(r)) + } + } + + var lines []string + lines = append(lines, title, "", hostnameLabel, m.hostnameInput.View(), "", roleLabel, strings.Join(roleParts, " ")) + if m.validationErr != "" { + lines = append(lines, "", styleError.Render(m.validationErr)) + } + lines = append(lines, styleHelp.Render("Tab to toggle focus • ←/→ to select role • Enter to proceed")) + + body := lipgloss.JoinVertical(lipgloss.Left, lines...) + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 64)).Render(body), + ) +} + +func (m model) viewJoinConfig(w int) string { + title := styleTitle.Render("Join Existing Cluster") + ipLabel := styleLabel.Render("Primary node IP") + portLabel := styleLabel.Render("Formation port") + + var lines []string + lines = append(lines, title, "", ipLabel, m.joinIPInput.View(), "", portLabel, m.joinPortInput.View()) + if m.validationErr != "" { + lines = append(lines, "", styleError.Render(m.validationErr)) + } + lines = append(lines, styleHelp.Render("Tab to move • Enter to proceed • Esc to go back")) + + body := lipgloss.JoinVertical(lipgloss.Left, lines...) + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 64)).Render(body), + ) +} + +func (m model) viewCACertPrompt(w int) string { + title := styleTitle.Render("Custom CA Certificate") + subtitle := styleMuted.Render("Required for air-gapped deployments with a private CA.") + + options := []string{"No", "Yes"} + var parts []string + for i, o := range options { + if i == m.hasCACert { + parts = append(parts, styleSelected.Render(" "+o+" ")) + } else { + parts = append(parts, styleMuted.Render(o)) + } + } + + body := lipgloss.JoinVertical(lipgloss.Left, + title, subtitle, "", styleLabel.Render("Install a custom CA certificate?"), + "", strings.Join(parts, " "), + "", styleHelp.Render("←/→ to select • Enter to proceed"), + ) + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 64)).Render(body), + ) +} + +func (m model) viewCACert(w int) string { + title := styleTitle.Render("CA Certificate (PEM)") + subtitle := styleMuted.Render("Paste the full PEM certificate block.") + + var lines []string + lines = append(lines, title, subtitle, "", m.caCertInput.View()) + if m.validationErr != "" { + lines = append(lines, "", styleError.Render(m.validationErr)) + } + lines = append(lines, styleHelp.Render("Enter to confirm • Esc to go back")) + + body := lipgloss.JoinVertical(lipgloss.Left, lines...) + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 64)).Render(body), + ) +} + +func (m model) viewConfirm(w int) string { + title := styleTitle.Render("Confirm Installation") + + cfg := m.buildConfig() + role := "Initialize new cluster" if cfg.ClusterRole == "join" { - port := strings.TrimSpace(joinPort) + role = fmt.Sprintf("Join cluster at %s", cfg.JoinAddr) + } + + summary := []struct{ k, v string }{ + {"Disk", cfg.Disk}, + {"Management IP", cfg.ManagementIP}, + {"Subnet mask", cfg.SubnetMask}, + {"Gateway", cfg.Gateway}, + {"OVN interface", cfg.OVNInterface}, + {"Hostname", cfg.Hostname}, + {"Cluster role", role}, + } + if cfg.HasCACert { + summary = append(summary, struct{ k, v string }{"CA certificate", "provided"}) + } + + var rows []string + for _, s := range summary { + rows = append(rows, fmt.Sprintf(" %s%-20s%s %s", + styleLabel.Render(""), styleLabel.Render(s.k), "", s.v)) + } + + warning := styleWarning.Render("This will erase " + cfg.Disk + " and begin installation.") + + body := lipgloss.JoinVertical(lipgloss.Left, + title, "", + strings.Join(rows, "\n"), "", + warning, "", + styleHelp.Render("Enter/Y to install • N/Esc to cancel"), + ) + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 72)).Render(body), + ) +} + +func (m model) viewDone(w int) string { + body := lipgloss.JoinVertical(lipgloss.Center, + styleSuccess.Render("Installation complete."), + "", + styleMuted.Render("The system will reboot shortly."), + ) + return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center, + styleBox.Width(min(w-4, 48)).Render(body), + ) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func (m model) buildConfig() *install.Config { + cfg := &install.Config{} + if len(m.disks) > m.diskCursor { + cfg.Disk = m.disks[m.diskCursor].Path + } + cfg.ManagementIP = strings.TrimSpace(m.netInputs[fieldIP].Value()) + cfg.SubnetMask = strings.TrimSpace(m.netInputs[fieldMask].Value()) + cfg.Gateway = strings.TrimSpace(m.netInputs[fieldGateway].Value()) + if len(m.nics) > m.nicCursor { + cfg.OVNInterface = m.nics[m.nicCursor] + } + cfg.Hostname = strings.TrimSpace(m.hostnameInput.Value()) + if m.clusterRole == 0 { + cfg.ClusterRole = "init" + } else { + cfg.ClusterRole = "join" + port := strings.TrimSpace(m.joinPortInput.Value()) if port == "" { port = "4432" } - cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(joinHost), port) + cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(m.joinIPInput.Value()), port) } - - cfg.CACert = strings.TrimSpace(caCert) - - return cfg, nil + cfg.HasCACert = m.hasCACert == 1 + cfg.CACert = strings.TrimSpace(m.caCertInput.Value()) + return cfg } -func validateIP(s string) error { - if net.ParseIP(strings.TrimSpace(s)) == nil { - return errors.New("invalid IP address") - } - return nil +// diskInfo holds display info for a block device. +type diskInfo struct { + Path string + Size string + Model string } -// availableDisks returns block device paths from /sys/block, excluding loop -// and RAM devices. -func availableDisks() ([]string, error) { +func availableDisks() ([]diskInfo, error) { entries, err := os.ReadDir("/sys/block") if err != nil { return nil, err } - var disks []string + var disks []diskInfo for _, e := range entries { name := e.Name() if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") { continue } - disks = append(disks, "/dev/"+name) + d := diskInfo{Path: "/dev/" + name} + d.Size = readSysBlockFile(name, "size") + if d.Size != "" { + // Convert 512-byte sectors to human-readable + d.Size = formatSectors(d.Size) + } + d.Model = strings.TrimSpace(readSysBlockFile(name, "device/model")) + disks = append(disks, d) } return disks, nil } -func diskOptions(disks []string) []huh.Option[string] { - opts := make([]huh.Option[string], len(disks)) - for i, d := range disks { - opts[i] = huh.NewOption(d, d) +func readSysBlockFile(dev, file string) string { + data, err := os.ReadFile("/sys/block/" + dev + "/" + file) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func formatSectors(sectors string) string { + var n int64 + if _, err := fmt.Sscan(sectors, &n); err != nil { + return "" + } + bytes := n * 512 + switch { + case bytes >= 1<<40: + return fmt.Sprintf("%.1fT", float64(bytes)/(1<<40)) + case bytes >= 1<<30: + return fmt.Sprintf("%.1fG", float64(bytes)/(1<<30)) + case bytes >= 1<<20: + return fmt.Sprintf("%.1fM", float64(bytes)/(1<<20)) + default: + return fmt.Sprintf("%dB", bytes) } - return opts } -// availableNICs returns non-loopback network interface names. func availableNICs() ([]string, error) { ifaces, err := net.Interfaces() if err != nil { @@ -253,11 +897,3 @@ func availableNICs() ([]string, error) { } return nics, nil } - -func nicOptions(nics []string) []huh.Option[string] { - opts := make([]huh.Option[string], len(nics)) - for i, n := range nics { - opts[i] = huh.NewOption(n, n) - } - return opts -} diff --git a/go.mod b/go.mod index 156ead98d..00e75c161 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.26.1 require ( github.com/aws/aws-sdk-go v1.55.8 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 github.com/kdomanski/iso9660 v0.4.0 @@ -32,20 +35,16 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/boltdb/bolt v1.3.1 // indirect github.com/buraksezer/consistent v0.10.0 // indirect - github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/hub v1.0.2 // indirect github.com/cenkalti/rpc2 v1.0.5 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/huh v1.0.0 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/console v1.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -79,14 +78,13 @@ require ( github.com/klauspost/reedsolomon v1.13.3 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/go.sum b/go.sum index 48bef4402..571682caa 100644 --- a/go.sum +++ b/go.sum @@ -79,8 +79,6 @@ github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/buraksezer/consistent v0.10.0 h1:hqBgz1PvNLC5rkWcEBVAL9dFMBWz6I0VgUCW25rrZlU= github.com/buraksezer/consistent v0.10.0/go.mod h1:6BrVajWq7wbKZlTOUPs/XVfR8c0maujuPowduSpZqmw= -github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= -github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/hub v1.0.2 h1:Nqv9TNaA9boeO2wQFW8o87BY3zKthtnzXmWGmJqhAV8= @@ -90,26 +88,26 @@ github.com/cenkalti/rpc2 v1.0.5/go.mod h1:2yfU5b86vOr16+iY1jN3MvT6Kxc9Nf8j5iZWwU github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= -github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= @@ -261,8 +259,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -281,8 +279,6 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=