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:
admin 2026-08-01 15:12:42 +00:00
parent e378c2a436
commit ed9655ee79
2 changed files with 134 additions and 231 deletions

View file

@ -15,8 +15,11 @@ import (
"fmt" "fmt"
"os" "os"
"strings" "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/preflight"
"git.sechpoint.app/customer-engineering/wallarm/internal/state"
"git.sechpoint.app/customer-engineering/wallarm/internal/tunnel" "git.sechpoint.app/customer-engineering/wallarm/internal/tunnel"
"git.sechpoint.app/customer-engineering/wallarm/internal/ui" "git.sechpoint.app/customer-engineering/wallarm/internal/ui"
) )
@ -27,7 +30,108 @@ var tunnelKey string
// Version set at build time. // Version set at build time.
var version = "dev" 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() { func runTunnelFlow() {
fmt.Println() fmt.Println()
fmt.Println("🔗 Remote Assistance — Secure Tunnel Setup") 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("✅ Preflight checks passed.")
fmt.Println() fmt.Println()
// 2. Launch bubbletea TUI (handles wizard vs dashboard routing) // 2. Launch bubbletea TUI (deploy choice or dashboard)
err := ui.Run(result) if err := ui.Run(); err != nil {
if err != nil {
fmt.Fprintf(os.Stderr, "UI error: %v\n", err) fmt.Fprintf(os.Stderr, "UI error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// 3. If TUI exited cleanly (user chose Remote Assistance), start tunnel flow // 3. Post-TUI: if no deployment exists, run deploy form in plain terminal
runTunnelFlow() 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)
}
}
} }

View file

@ -1,23 +1,20 @@
// Package ui provides the bubbletea terminal UI for wallarm: // Package ui provides the bubbletea terminal UI for wallarm:
// - Wizard: guides new deployments (type → region → config → deploy) // - Deploy choice: local deploy or remote assistance
// - Dashboard: lists existing nodes with actions (add, config, remove, tunnel) // - Dashboard: lists existing nodes with actions
//
// Deployment logic runs in plain terminal (main.go), not inside the TUI.
package ui package ui
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "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" "git.sechpoint.app/customer-engineering/wallarm/internal/state"
) )
// Styles
var ( var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).MarginBottom(1) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).MarginBottom(1)
goodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) goodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
@ -27,37 +24,22 @@ var (
activeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true) activeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true)
) )
// Model is the top-level bubbletea model.
type Model struct { type Model struct {
state stateView state stateView
width int width int
height int height int
status string // feedback message during deployment
deploying bool // true while deployment is in progress
} }
type stateView int type stateView int
const ( const (
viewPreflight stateView = iota viewRoute stateView = iota
viewRoute viewDeployChoice // local deploy or remote assist?
viewDeployChoice // local deploy or remote assist?
viewWizard
viewDashboard viewDashboard
viewDone
viewError
) )
var preflightResult preflight.Result // Run starts the bubbletea TUI. Returns nil if user chose local deploy (exit and run form).
func Run() error {
// deployCompleteMsg is sent when deployment finishes.
type deployCompleteMsg struct {
err error
}
// Run starts the bubbletea TUI.
func Run(r preflight.Result) error {
preflightResult = r
m := Model{state: viewRoute} m := Model{state: viewRoute}
p := tea.NewProgram(m) p := tea.NewProgram(m)
if _, err := p.Run(); err != nil { 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 return m, tea.Quit
case "1": case "1":
if m.state == viewDeployChoice { if m.state == viewDeployChoice {
m.state = viewWizard return m, tea.Quit // exit TUI, main.go runs deploy form
return m, nil
} }
case "2": case "2":
if m.state == viewDeployChoice { if m.state == viewDeployChoice {
return m, func() tea.Msg { return tea.Quit() } // exit TUI for tunnel flow return m, tea.Quit // exit TUI, main.go runs 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
} }
} }
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: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
@ -124,33 +87,12 @@ func (m Model) View() string {
return "" return ""
case viewDeployChoice: case viewDeployChoice:
return deployChoiceView(m) return deployChoiceView(m)
case viewWizard:
return wizardView(m)
case viewDashboard: case viewDashboard:
return dashboardView(m) 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 "" 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 ─────────────────────────────────────────────────── // ─── Deploy Choice ───────────────────────────────────────────────────
func deployChoiceView(m Model) string { func deployChoiceView(m Model) string {
@ -159,157 +101,11 @@ func deployChoiceView(m Model) string {
s += activeStyle.Render(" [1] Deploy locally") + "\n" s += activeStyle.Render(" [1] Deploy locally") + "\n"
s += " Configure and deploy the Wallarm node on this server now.\n\n" s += " Configure and deploy the Wallarm node on this server now.\n\n"
s += activeStyle.Render(" [2] Remote assistance") + "\n" s += activeStyle.Render(" [2] Remote assistance") + "\n"
s += " Open a secure tunnel so a remote admin can deploy for you.\n" s += " Open a secure tunnel so a remote admin can deploy for you.\n\n"
s += " No credentials are exposed — you provide the tunnel endpoint.\n\n"
s += dimStyle.Render("Press 1 or 2, q to quit") s += dimStyle.Render("Press 1 or 2, q to quit")
return s 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(&region),
),
).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 ─────────────────────────────────────────────────────── // ─── Dashboard ───────────────────────────────────────────────────────
func dashboardView(m Model) string { func dashboardView(m Model) string {
@ -333,13 +129,9 @@ func dashboardView(m Model) string {
marker = "○" marker = "○"
style = warnStyle 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)) out += style.Render(fmt.Sprintf(" %s %s — %s", marker, n.Name, n.Status))
if detail != "" { if n.Address != "" {
out += dimStyle.Render(fmt.Sprintf(" (%s)", detail)) out += dimStyle.Render(fmt.Sprintf(" (%s)", n.Address))
} }
out += "\n" out += "\n"
} }
@ -355,4 +147,3 @@ func dashboardView(m Model) string {
return out return out
} }