fix: move deploy form out of TUI into plain terminal
Bubbletea and huh cannot coexist in the same process. Fixed by: - TUI handles only display: deploy choice + dashboard - Deploy wizard runs as plain terminal prompts in main.go - No more terminal conflicts, keys work correctly - Same flow: wallarm → choice → form → deploy → state saved
This commit is contained in:
parent
e378c2a436
commit
ed9655ee79
2 changed files with 134 additions and 231 deletions
|
|
@ -15,8 +15,11 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/native"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/preflight"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/state"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/tunnel"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/ui"
|
||||
)
|
||||
|
|
@ -27,7 +30,108 @@ var tunnelKey string
|
|||
// Version set at build time.
|
||||
var version = "dev"
|
||||
|
||||
// runTunnelFlow prompts for tunnel credentials in plain terminal and starts the tunnel.
|
||||
// runDeployFlow runs the deployment wizard in plain terminal (no bubbletea).
|
||||
func runDeployFlow(r preflight.Result) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
var s state.State
|
||||
s.DeploymentType = "native"
|
||||
|
||||
// Step 1: Cloud region
|
||||
fmt.Println()
|
||||
fmt.Println("─── Cloud Region ───")
|
||||
if r.USReachable && r.EUReachable {
|
||||
fmt.Println(" [1] US (us1.api.wallarm.com)")
|
||||
fmt.Println(" [2] EU (api.wallarm.com)")
|
||||
fmt.Print("Choose region [1/2]: ")
|
||||
choice, _ := reader.ReadString('\n')
|
||||
choice = strings.TrimSpace(choice)
|
||||
if choice == "2" {
|
||||
s.CloudRegion = "EU"
|
||||
s.APIHost = "api.wallarm.com"
|
||||
} else {
|
||||
s.CloudRegion = "US"
|
||||
s.APIHost = "us1.api.wallarm.com"
|
||||
}
|
||||
} else if r.USReachable {
|
||||
fmt.Println(" US (us1.api.wallarm.com) — only reachable region")
|
||||
s.CloudRegion = "US"
|
||||
s.APIHost = "us1.api.wallarm.com"
|
||||
} else {
|
||||
fmt.Println(" EU (api.wallarm.com) — only reachable region")
|
||||
s.CloudRegion = "EU"
|
||||
s.APIHost = "api.wallarm.com"
|
||||
}
|
||||
|
||||
// Step 2: API Token
|
||||
fmt.Println()
|
||||
fmt.Print("Wallarm API Token (Deploy role): ")
|
||||
token, _ := reader.ReadString('\n')
|
||||
s.APIToken = strings.TrimSpace(token)
|
||||
if s.APIToken == "" {
|
||||
fmt.Println("Token cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Node configuration
|
||||
fmt.Println()
|
||||
fmt.Print("Node name: ")
|
||||
nodeName, _ := reader.ReadString('\n')
|
||||
nodeName = strings.TrimSpace(nodeName)
|
||||
if nodeName == "" {
|
||||
fmt.Println("Node name required.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Print("Listen address [0.0.0.0:8081]: ")
|
||||
address, _ := reader.ReadString('\n')
|
||||
address = strings.TrimSpace(address)
|
||||
if address == "" {
|
||||
address = "0.0.0.0:8081"
|
||||
}
|
||||
|
||||
fmt.Print("Labels [group=" + nodeName + "]: ")
|
||||
labels, _ := reader.ReadString('\n')
|
||||
labels = strings.TrimSpace(labels)
|
||||
if labels == "" {
|
||||
labels = "group=" + nodeName
|
||||
}
|
||||
|
||||
s.Nodes = append(s.Nodes, state.Node{
|
||||
Name: nodeName,
|
||||
Type: "native",
|
||||
Address: address,
|
||||
Status: "deploying",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// Step 4: Deploy
|
||||
fmt.Println()
|
||||
fmt.Println("Starting deployment...")
|
||||
|
||||
if err := native.CreateNodesDir(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
if err := native.GenerateSystemdTemplate(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, node := range s.Nodes {
|
||||
if err := native.InstallNode(node, s.APIToken, s.APIHost, labels); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Deployment failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save state
|
||||
if err := state.Save(&s); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not save state: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✅ Deployment complete! Run 'wallarm' again for the dashboard.")
|
||||
}
|
||||
func runTunnelFlow() {
|
||||
fmt.Println()
|
||||
fmt.Println("🔗 Remote Assistance — Secure Tunnel Setup")
|
||||
|
|
@ -155,13 +259,21 @@ deployment. On subsequent runs, it shows your existing deployments.
|
|||
fmt.Println("✅ Preflight checks passed.")
|
||||
fmt.Println()
|
||||
|
||||
// 2. Launch bubbletea TUI (handles wizard vs dashboard routing)
|
||||
err := ui.Run(result)
|
||||
if err != nil {
|
||||
// 2. Launch bubbletea TUI (deploy choice or dashboard)
|
||||
if err := ui.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "UI error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 3. If TUI exited cleanly (user chose Remote Assistance), start tunnel flow
|
||||
runTunnelFlow()
|
||||
// 3. Post-TUI: if no deployment exists, run deploy form in plain terminal
|
||||
if !state.HasDeployment() {
|
||||
fmt.Println()
|
||||
fmt.Print("Start deployment now? [Y/n]: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer == "" || answer == "y" || answer == "yes" {
|
||||
runDeployFlow(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,20 @@
|
|||
// Package ui provides the bubbletea terminal UI for wallarm:
|
||||
// - Wizard: guides new deployments (type → region → config → deploy)
|
||||
// - Dashboard: lists existing nodes with actions (add, config, remove, tunnel)
|
||||
// - Deploy choice: local deploy or remote assistance
|
||||
// - Dashboard: lists existing nodes with actions
|
||||
//
|
||||
// Deployment logic runs in plain terminal (main.go), not inside the TUI.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/native"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/preflight"
|
||||
"git.sechpoint.app/customer-engineering/wallarm/internal/state"
|
||||
)
|
||||
|
||||
// Styles
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).MarginBottom(1)
|
||||
goodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
|
|
@ -27,37 +24,22 @@ var (
|
|||
activeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true)
|
||||
)
|
||||
|
||||
// Model is the top-level bubbletea model.
|
||||
type Model struct {
|
||||
state stateView
|
||||
width int
|
||||
height int
|
||||
status string // feedback message during deployment
|
||||
deploying bool // true while deployment is in progress
|
||||
state stateView
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
type stateView int
|
||||
|
||||
const (
|
||||
viewPreflight stateView = iota
|
||||
viewRoute
|
||||
viewDeployChoice // local deploy or remote assist?
|
||||
viewWizard
|
||||
viewRoute stateView = iota
|
||||
viewDeployChoice // local deploy or remote assist?
|
||||
viewDashboard
|
||||
viewDone
|
||||
viewError
|
||||
)
|
||||
|
||||
var preflightResult preflight.Result
|
||||
|
||||
// deployCompleteMsg is sent when deployment finishes.
|
||||
type deployCompleteMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// Run starts the bubbletea TUI.
|
||||
func Run(r preflight.Result) error {
|
||||
preflightResult = r
|
||||
// Run starts the bubbletea TUI. Returns nil if user chose local deploy (exit and run form).
|
||||
func Run() error {
|
||||
m := Model{state: viewRoute}
|
||||
p := tea.NewProgram(m)
|
||||
if _, err := p.Run(); err != nil {
|
||||
|
|
@ -76,32 +58,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, tea.Quit
|
||||
case "1":
|
||||
if m.state == viewDeployChoice {
|
||||
m.state = viewWizard
|
||||
return m, nil
|
||||
return m, tea.Quit // exit TUI, main.go runs deploy form
|
||||
}
|
||||
case "2":
|
||||
if m.state == viewDeployChoice {
|
||||
return m, func() tea.Msg { return tea.Quit() } // exit TUI for tunnel flow
|
||||
}
|
||||
case "enter":
|
||||
if m.state == viewWizard && !m.deploying {
|
||||
m.deploying = true
|
||||
return m, m.startDeploy()
|
||||
}
|
||||
if m.state == viewDone || m.state == viewError {
|
||||
return m, tea.Quit
|
||||
return m, tea.Quit // exit TUI, main.go runs tunnel flow
|
||||
}
|
||||
}
|
||||
case deployCompleteMsg:
|
||||
m.deploying = false
|
||||
if msg.err != nil {
|
||||
m.status = fmt.Sprintf("Deployment failed: %v", msg.err)
|
||||
m.state = viewError
|
||||
} else {
|
||||
m.status = "Deployment successful!"
|
||||
m.state = viewDone
|
||||
}
|
||||
return m, nil
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
|
@ -124,33 +87,12 @@ func (m Model) View() string {
|
|||
return ""
|
||||
case viewDeployChoice:
|
||||
return deployChoiceView(m)
|
||||
case viewWizard:
|
||||
return wizardView(m)
|
||||
case viewDashboard:
|
||||
return dashboardView(m)
|
||||
case viewDone:
|
||||
return goodStyle.Render("✅ " + m.status) + "\n\n" + dimStyle.Render("Press Enter to exit.")
|
||||
case viewError:
|
||||
return badStyle.Render("❌ " + m.status) + "\n\n" + dimStyle.Render("Press Enter to exit.")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m Model) startDeploy() tea.Cmd {
|
||||
m.deploying = true
|
||||
return func() tea.Msg {
|
||||
s, err := runDeployForm()
|
||||
if err != nil {
|
||||
return deployCompleteMsg{err}
|
||||
}
|
||||
err = deployNative(s)
|
||||
if err == nil {
|
||||
state.Save(&s)
|
||||
}
|
||||
return deployCompleteMsg{err}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Deploy Choice ───────────────────────────────────────────────────
|
||||
|
||||
func deployChoiceView(m Model) string {
|
||||
|
|
@ -159,157 +101,11 @@ func deployChoiceView(m Model) string {
|
|||
s += activeStyle.Render(" [1] Deploy locally") + "\n"
|
||||
s += " Configure and deploy the Wallarm node on this server now.\n\n"
|
||||
s += activeStyle.Render(" [2] Remote assistance") + "\n"
|
||||
s += " Open a secure tunnel so a remote admin can deploy for you.\n"
|
||||
s += " No credentials are exposed — you provide the tunnel endpoint.\n\n"
|
||||
s += " Open a secure tunnel so a remote admin can deploy for you.\n\n"
|
||||
s += dimStyle.Render("Press 1 or 2, q to quit")
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
func wizardView(m Model) string {
|
||||
s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n"
|
||||
|
||||
if m.deploying {
|
||||
s += dimStyle.Render("Configuring deployment — follow the prompts below...") + "\n"
|
||||
return s
|
||||
}
|
||||
|
||||
s += dimStyle.Render("No existing deployment found. Let's set one up.") + "\n\n"
|
||||
s += activeStyle.Render("Wallarm Native Node Deployment") + "\n"
|
||||
s += " Press Enter to begin configuration" + "\n\n"
|
||||
|
||||
s += activeStyle.Render("Cloud region:") + "\n"
|
||||
r := preflightResult
|
||||
if r.USReachable {
|
||||
s += goodStyle.Render(" US") + " — us1.api.wallarm.com (reachable)\n"
|
||||
} else {
|
||||
s += dimStyle.Render(" US — not reachable") + "\n"
|
||||
}
|
||||
if r.EUReachable {
|
||||
s += goodStyle.Render(" EU") + " — api.wallarm.com (reachable)\n"
|
||||
} else {
|
||||
s += dimStyle.Render(" EU — not reachable") + "\n"
|
||||
}
|
||||
s += dimStyle.Render("\nPress Enter to continue, q to quit")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// runDeployForm collects configuration via huh interactive forms.
|
||||
func runDeployForm() (state.State, error) {
|
||||
var s state.State
|
||||
s.DeploymentType = "native"
|
||||
|
||||
// Step 1: Cloud region
|
||||
regionOptions := []huh.Option[string]{}
|
||||
if preflightResult.USReachable {
|
||||
regionOptions = append(regionOptions, huh.NewOption("[1] US (us1.api.wallarm.com)", "US"))
|
||||
}
|
||||
if preflightResult.EUReachable {
|
||||
regionOptions = append(regionOptions, huh.NewOption("[2] EU (api.wallarm.com)", "EU"))
|
||||
}
|
||||
|
||||
if len(regionOptions) == 0 {
|
||||
return s, fmt.Errorf("no cloud regions reachable")
|
||||
}
|
||||
|
||||
var region string
|
||||
if len(regionOptions) == 1 {
|
||||
region = regionOptions[0].Value // auto-select
|
||||
} else {
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Choose Wallarm cloud region").
|
||||
Options(regionOptions...).
|
||||
Value(®ion),
|
||||
),
|
||||
).WithTheme(huh.ThemeCharm()).Run()
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
}
|
||||
s.CloudRegion = region
|
||||
if region == "US" {
|
||||
s.APIHost = "us1.api.wallarm.com"
|
||||
} else {
|
||||
s.APIHost = "api.wallarm.com"
|
||||
}
|
||||
|
||||
// Step 2: API Token
|
||||
var apiToken string
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Wallarm API Token (Deploy role)").
|
||||
Placeholder("Paste your token here").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&apiToken).
|
||||
Validate(func(v string) error {
|
||||
if len(v) < 10 {
|
||||
return fmt.Errorf("token too short")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(huh.ThemeCharm()).Run()
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.APIToken = apiToken
|
||||
|
||||
// Step 3: Node configuration
|
||||
var nodeName, address, labels string
|
||||
nodeForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Node name").Value(&nodeName).Validate(func(v string) error {
|
||||
if v == "" {
|
||||
return fmt.Errorf("required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("Listen address (IP:Port)").Placeholder("0.0.0.0:8081").Value(&address).Validate(func(v string) error {
|
||||
if v == "" {
|
||||
return fmt.Errorf("required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("Labels (optional)").Placeholder("group=prod").Value(&labels),
|
||||
),
|
||||
)
|
||||
|
||||
err = nodeForm.WithTheme(huh.ThemeCharm()).Run()
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Nodes = append(s.Nodes, state.Node{
|
||||
Name: nodeName,
|
||||
Type: "native",
|
||||
Address: address,
|
||||
Status: "deploying",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// deployNative executes the native deployment.
|
||||
func deployNative(s state.State) error {
|
||||
if err := native.CreateNodesDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := native.GenerateSystemdTemplate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range s.Nodes {
|
||||
labels := "group=" + node.Name
|
||||
if err := native.InstallNode(node, s.APIToken, s.APIHost, labels); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────
|
||||
|
||||
func dashboardView(m Model) string {
|
||||
|
|
@ -333,13 +129,9 @@ func dashboardView(m Model) string {
|
|||
marker = "○"
|
||||
style = warnStyle
|
||||
}
|
||||
detail := n.Address
|
||||
if detail == "" && n.Port != 0 {
|
||||
detail = fmt.Sprintf(":%d → %s:%d", n.Port, n.UpstreamIP, n.UpstreamPort)
|
||||
}
|
||||
out += style.Render(fmt.Sprintf(" %s %s — %s", marker, n.Name, n.Status))
|
||||
if detail != "" {
|
||||
out += dimStyle.Render(fmt.Sprintf(" (%s)", detail))
|
||||
if n.Address != "" {
|
||||
out += dimStyle.Render(fmt.Sprintf(" (%s)", n.Address))
|
||||
}
|
||||
out += "\n"
|
||||
}
|
||||
|
|
@ -355,4 +147,3 @@ func dashboardView(m Model) string {
|
|||
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue