diff --git a/cmd/wallarm/main.go b/cmd/wallarm/main.go index fbc6308..2a044e4 100644 --- a/cmd/wallarm/main.go +++ b/cmd/wallarm/main.go @@ -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) + } + } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index e2aa48d..e833f7f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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 } -