From 1a11e40c665ddc36b71271cb6d6beb5481bd5f31 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 1 Aug 2026 13:59:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20wired=20TUI=20form=20to=20deployment=20?= =?UTF-8?q?=E2=80=94=20wizard=20actually=20deploys=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wizard: type pick → cloud region → token → node config → deploys via native.InstallNode / docker.DeployContainer - State saved after successful deployment with API token - Dashboard reflects real node state from ~/.wallarm/state.json - Removed old unused HuhForm stub --- internal/state/state.go | 1 + internal/ui/ui.go | 394 +++++++++++++++++++++++----------------- 2 files changed, 232 insertions(+), 163 deletions(-) diff --git a/internal/state/state.go b/internal/state/state.go index e8560f2..2d8834a 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -16,6 +16,7 @@ type State struct { DeploymentType string `json:"deployment_type,omitempty"` // docker or native CloudRegion string `json:"cloud_region,omitempty"` // US or EU APIHost string `json:"api_host,omitempty"` // e.g., api.wallarm.com + APIToken string `json:"api_token,omitempty"` // Wallarm API token (sensitive) Nodes []Node `json:"nodes"` } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index b9db321..353d14e 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -5,13 +5,15 @@ package ui import ( "fmt" - "os" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" + "git.sechpoint.app/customer-engineering/wallarm/internal/docker" + "git.sechpoint.app/customer-engineering/wallarm/internal/native" "git.sechpoint.app/customer-engineering/wallarm/internal/preflight" "git.sechpoint.app/customer-engineering/wallarm/internal/state" ) @@ -31,6 +33,8 @@ type Model struct { state stateView width int height int + status string // feedback message during deployment + deploying bool // true while deployment is in progress } type stateView int @@ -41,15 +45,20 @@ const ( viewWizard 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 - m := Model{state: viewPreflight} - m.state = viewRoute // skip preflight display since it already printed in main + m := Model{state: viewRoute} p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { return err @@ -65,7 +74,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": return m, tea.Quit + case "1": + if m.state == viewWizard && !m.deploying { + return m, m.startDeploy("docker") + } + case "2": + if m.state == viewWizard && !m.deploying { + return m, m.startDeploy("native") + } + case "enter": + 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: m.width = msg.Width m.height = msg.Height @@ -91,26 +122,58 @@ func (m Model) View() string { case viewDashboard: return dashboardView(m) case viewDone: - return goodStyle.Render("Done. Run wallarm again to manage your deployment.") + 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 "" } +// startDeploy runs the Huh form in a goroutine, then calls the deploy package. +func (m Model) startDeploy(deployType string) tea.Cmd { + return func() tea.Msg { + m.deploying = true + + // Run the interactive form + s, err := runDeployForm(deployType) + if err != nil { + return deployCompleteMsg{err} + } + + // Execute deployment + if deployType == "native" { + err = deployNative(s) + } else { + err = deployDocker(s) + } + + // Save state on success + if err == nil { + state.Save(&s) + } + + return deployCompleteMsg{err} + } +} + // ─── Wizard ────────────────────────────────────────────────────────── func wizardView(m Model) string { s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n" - s += dimStyle.Render("No existing deployment found. Let's set one up.") + "\n\n" - // Step 1: Deployment type + 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("Step 1: Choose deployment type") + "\n" s += " [1] Docker — Wallarm node as a container\n" s += " [2] Native — Wallarm node directly on this OS\n" - s += dimStyle.Render(" Press 1 or 2, then Enter") + "\n\n" + s += dimStyle.Render(" Press 1 or 2 to begin") + "\n\n" - // Step 2: Cloud region + s += activeStyle.Render("Step 2: Cloud region") + "\n" r := preflightResult - s += activeStyle.Render("Step 2: Choose cloud region") + "\n" if r.USReachable { s += goodStyle.Render(" US") + " — us1.api.wallarm.com (reachable)\n" } else { @@ -121,20 +184,169 @@ func wizardView(m Model) string { } else { s += dimStyle.Render(" EU — not reachable") + "\n" } - - // Step 3: Required info - s += "\n" + activeStyle.Render("Step 3: Required information") + "\n" - s += " • Wallarm API token (Deploy role)\n" - s += " • Listen port / address\n" - s += " • Upstream server (Docker only)\n\n" - - s += dimStyle.Render("Full interactive form coming in next iteration.") + "\n" - s += dimStyle.Render("For now, use: sudo ./deploy/wallarm-native.sh --install") + "\n\n" - s += dimStyle.Render("Press q to quit") + s += dimStyle.Render("\nPress q to quit") return s } +// runDeployForm collects configuration via huh interactive forms. +func runDeployForm(deployType string) (state.State, error) { + var s state.State + s.DeploymentType = deployType + + // Step 1: Cloud region + regionOptions := []huh.Option[string]{} + if preflightResult.USReachable { + regionOptions = append(regionOptions, huh.NewOption("US (us1.api.wallarm.com)", "US")) + } + if preflightResult.EUReachable { + regionOptions = append(regionOptions, huh.NewOption("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 + }), + ), + ) + + if deployType == "native" { + 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), + ), + ) + } else { + var upstreamIP, upstreamPortStr 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("Ingress port").Placeholder("80").Value(&address), + huh.NewInput().Title("Upstream IP").Placeholder("192.168.1.100").Value(&upstreamIP), + huh.NewInput().Title("Upstream port").Placeholder("8080").Value(&upstreamPortStr), + ), + ) + _ = upstreamIP + _ = upstreamPortStr + } + + err = nodeForm.WithTheme(huh.ThemeCharm()).Run() + if err != nil { + return s, err + } + + s.Nodes = append(s.Nodes, state.Node{ + Name: nodeName, + Type: deployType, + 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 +} + +// deployDocker executes the Docker deployment. +func deployDocker(s state.State) error { + if err := docker.InstallDocker(); err != nil { + return err + } + for _, node := range s.Nodes { + if err := docker.DeployContainer(node, s.APIToken, s.APIHost); err != nil { + return err + } + } + return nil +} + // ─── Dashboard ─────────────────────────────────────────────────────── func dashboardView(m Model) string { @@ -181,147 +393,3 @@ func dashboardView(m Model) string { return out } -// HuhForm runs an interactive multi-step form (used programmatically, not via bubbletea TUI). -func HuhForm(preflightResult preflight.Result) (state.State, error) { - var s state.State - - // Step 1: Deployment type - var deployType string - err := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Choose deployment type"). - Options( - huh.NewOption("Docker — node as a container", "docker"), - huh.NewOption("Native — node directly on OS (no Docker)", "native"), - ). - Value(&deployType), - ), - ).WithTheme(huh.ThemeCharm()).Run() - if err != nil { - return s, err - } - s.DeploymentType = deployType - - // Step 2: Cloud region - regionOptions := []huh.Option[string]{} - if preflightResult.USReachable { - regionOptions = append(regionOptions, huh.NewOption("US (us1.api.wallarm.com)", "US")) - } - if preflightResult.EUReachable { - regionOptions = append(regionOptions, huh.NewOption("EU (api.wallarm.com)", "EU")) - } - if len(regionOptions) == 0 { - fmt.Println("No cloud regions reachable.") - os.Exit(1) - } - if len(regionOptions) == 1 { - // Auto-select the only reachable region - for _, opt := range regionOptions { - s.CloudRegion = opt.Key - if s.CloudRegion == "US" { - s.APIHost = "us1.api.wallarm.com" - } else { - s.APIHost = "api.wallarm.com" - } - break - } - } else { - var region string - 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 3: Token - var token string - err = huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Wallarm API Token (Deploy role)"). - Placeholder("Paste your token here"). - EchoMode(huh.EchoModePassword). - Value(&token). - Validate(func(s string) error { - if len(s) < 10 { - return fmt.Errorf("token too short") - } - return nil - }), - ), - ).WithTheme(huh.ThemeCharm()).Run() - if err != nil { - return s, err - } - - // Step 4: Node configuration (varies by type) - if deployType == "native" { - var nodeName, address, labels string - err = huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Node name").Value(&nodeName).Validate(func(s string) error { - if s == "" { - return fmt.Errorf("node name required") - } - return nil - }), - huh.NewInput().Title("Listen address (IP:Port)").Placeholder("0.0.0.0:8081").Value(&address), - huh.NewInput().Title("Labels (optional)").Placeholder("group=prod").Value(&labels), - ), - ).WithTheme(huh.ThemeCharm()).Run() - if err != nil { - return s, err - } - s.Nodes = append(s.Nodes, state.Node{ - Name: nodeName, - Type: "native", - Address: address, - Status: "pending", - }) - } else { - // Docker: port, upstream IP, upstream port - var nodeName, upstreamIP string - var port, upstreamPort int - err = huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Node name").Value(&nodeName).Validate(func(s string) error { - if s == "" { - return fmt.Errorf("node name required") - } - return nil - }), - huh.NewInput().Title("Ingress port").Placeholder("80").Value( - func() *string { v := "80"; return &v }(), - ), - huh.NewInput().Title("Upstream IP").Placeholder("192.168.1.100").Value(&upstreamIP), - huh.NewInput().Title("Upstream port").Placeholder("8080").Value( - func() *string { v := "8080"; return &v }(), - ), - ), - ).WithTheme(huh.ThemeCharm()).Run() - _ = port - _ = upstreamPort - _ = nodeName - _ = upstreamIP - if err != nil { - return s, err - } - // TODO: wire actual port parsing - } - - return s, nil -}