feat: wired TUI form to deployment — wizard actually deploys now

- 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
This commit is contained in:
admin 2026-08-01 13:59:25 +00:00
parent 2db33343a8
commit 1a11e40c66
2 changed files with 232 additions and 163 deletions

View file

@ -16,6 +16,7 @@ type State struct {
DeploymentType string `json:"deployment_type,omitempty"` // docker or native DeploymentType string `json:"deployment_type,omitempty"` // docker or native
CloudRegion string `json:"cloud_region,omitempty"` // US or EU CloudRegion string `json:"cloud_region,omitempty"` // US or EU
APIHost string `json:"api_host,omitempty"` // e.g., api.wallarm.com 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"` Nodes []Node `json:"nodes"`
} }

View file

@ -5,13 +5,15 @@ package ui
import ( import (
"fmt" "fmt"
"os"
"strings" "strings"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "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/preflight"
"git.sechpoint.app/customer-engineering/wallarm/internal/state" "git.sechpoint.app/customer-engineering/wallarm/internal/state"
) )
@ -31,6 +33,8 @@ 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
@ -41,15 +45,20 @@ const (
viewWizard viewWizard
viewDashboard viewDashboard
viewDone viewDone
viewError
) )
var preflightResult preflight.Result var preflightResult preflight.Result
// deployCompleteMsg is sent when deployment finishes.
type deployCompleteMsg struct {
err error
}
// Run starts the bubbletea TUI. // Run starts the bubbletea TUI.
func Run(r preflight.Result) error { func Run(r preflight.Result) error {
preflightResult = r preflightResult = r
m := Model{state: viewPreflight} m := Model{state: viewRoute}
m.state = viewRoute // skip preflight display since it already printed in main
p := tea.NewProgram(m, tea.WithAltScreen()) p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
return err return err
@ -65,7 +74,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "q", "ctrl+c": case "q", "ctrl+c":
return m, tea.Quit 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: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
@ -91,26 +122,58 @@ func (m Model) View() string {
case viewDashboard: case viewDashboard:
return dashboardView(m) return dashboardView(m)
case viewDone: 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 "" 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 ────────────────────────────────────────────────────────── // ─── Wizard ──────────────────────────────────────────────────────────
func wizardView(m Model) string { func wizardView(m Model) string {
s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n" 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 += activeStyle.Render("Step 1: Choose deployment type") + "\n"
s += " [1] Docker — Wallarm node as a container\n" s += " [1] Docker — Wallarm node as a container\n"
s += " [2] Native — Wallarm node directly on this OS\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 r := preflightResult
s += activeStyle.Render("Step 2: Choose cloud region") + "\n"
if r.USReachable { if r.USReachable {
s += goodStyle.Render(" US") + " — us1.api.wallarm.com (reachable)\n" s += goodStyle.Render(" US") + " — us1.api.wallarm.com (reachable)\n"
} else { } else {
@ -121,20 +184,169 @@ func wizardView(m Model) string {
} else { } else {
s += dimStyle.Render(" EU — not reachable") + "\n" s += dimStyle.Render(" EU — not reachable") + "\n"
} }
s += dimStyle.Render("\nPress q to quit")
// 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")
return s 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(&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
}),
),
)
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 ─────────────────────────────────────────────────────── // ─── Dashboard ───────────────────────────────────────────────────────
func dashboardView(m Model) string { func dashboardView(m Model) string {
@ -181,147 +393,3 @@ func dashboardView(m Model) string {
return out 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(&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 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
}