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:
parent
2db33343a8
commit
1a11e40c66
2 changed files with 232 additions and 163 deletions
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(®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 ───────────────────────────────────────────────────────
|
// ─── 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(®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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue