mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-13 02:12:35 +00:00
f4adeedb8f
New subcommand that connects to a chosen kube context and walks the
user through three picker steps before writing forwards to the config:
1. Namespace multi-select (no system-namespace exclusion)
2. Service multi-select grouped by namespace, with already-configured
rows greyed out and locked off
3. Starting-port input with a live preview of consecutive local-port
assignments, skipping any port already taken by the existing config
or another row in the batch
Multi-port services emit one forward per port. Non-TCP ports are
skipped with a one-line warning at the end (kportal forward layer is
TCP-only). --dry-run prints the planned forwards without writing.
Subcommand dispatch lives in cmd/kportal/main.go: when os.Args[1] ==
'generate', runGenerate(os.Args[2:]) is invoked before the main
flag.Parse() so the flag.NewFlagSet 'generate' parses its own
'--context', '--config', '--dry-run' flags. Invalid contexts list
the available ones for quick correction.
Tests in internal/ui/generate_test.go cover:
- namespace toggle / toggle-all / filter
- service multi-select with locked already-configured rows and
non-TCP filtering
- port-collision-aware consecutive assignment using a real Mutator
against a tempdir config
- reject + recover for starting-port < 1024
- dry-run does not invoke the mutator
- end-to-end Update() walk through the three steps
- parse-starting-port boundary table
- port-step view rendering
- ServiceCandidate.Key() determinism
README updated with a 'Generate Forwards from a Cluster' section
describing the flow and the three flags.
988 lines
26 KiB
Go
988 lines
26 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/lukaszraczylo/kportal/internal/config"
|
|
)
|
|
|
|
// Generate flow constants
|
|
const (
|
|
// GenerateMinLocalPort is the minimum allowed starting local port for generated forwards.
|
|
// Ports below 1024 are reserved on most systems and require elevated privileges.
|
|
GenerateMinLocalPort = 1024
|
|
|
|
// GenerateMaxLocalPort is the maximum valid TCP port number.
|
|
GenerateMaxLocalPort = 65535
|
|
|
|
// GenerateDefaultStartingPort is the default starting local port.
|
|
GenerateDefaultStartingPort = 10000
|
|
|
|
// GenerateListTimeout is the per-step timeout for k8s list operations.
|
|
GenerateListTimeout = 30 * time.Second
|
|
|
|
// GenerateConcurrency is the maximum number of concurrent ListServices calls.
|
|
GenerateConcurrency = 8
|
|
)
|
|
|
|
// GenerateStep represents the current step in the generate flow.
|
|
type GenerateStep int
|
|
|
|
const (
|
|
GenerateStepNamespaces GenerateStep = iota
|
|
GenerateStepServices
|
|
GenerateStepPortAssign
|
|
GenerateStepDone
|
|
GenerateStepCancelled
|
|
)
|
|
|
|
// generateNamespacesLoadedMsg is fired when namespace listing completes.
|
|
type generateNamespacesLoadedMsg struct {
|
|
err error
|
|
namespaces []string
|
|
}
|
|
|
|
// generateServicesLoadedMsg is fired when concurrent service listing completes.
|
|
type generateServicesLoadedMsg struct {
|
|
err error
|
|
servicesByNS map[string][]ServiceCandidate
|
|
}
|
|
|
|
// generateSavedMsg is fired after AddForward calls complete.
|
|
type generateSavedMsg struct {
|
|
errors []string
|
|
added int
|
|
}
|
|
|
|
// generateTickMsg drives the spinner.
|
|
type generateTickMsg struct{}
|
|
|
|
// ServiceCandidate represents a single service-port row in the generate flow.
|
|
type ServiceCandidate struct {
|
|
Namespace string
|
|
Service string
|
|
Protocol string
|
|
Port int32
|
|
}
|
|
|
|
// Key returns a stable lookup key for collision detection against existing config.
|
|
func (c ServiceCandidate) Key() string {
|
|
return fmt.Sprintf("%s|%s|%s|%d", c.Namespace, "service/"+c.Service, "tcp", c.Port)
|
|
}
|
|
|
|
// GenerateResult is reported by GenerateModel after the program exits.
|
|
type GenerateResult struct {
|
|
Errors []string
|
|
PlannedForwards []config.Forward
|
|
Added int
|
|
SkippedNonTCP int
|
|
Cancelled bool
|
|
UsedDryRun bool
|
|
}
|
|
|
|
// GenerateModel is the bubbletea model driving the generate flow.
|
|
//
|
|
// Field ordering is governed by govet's fieldalignment check: interfaces and
|
|
// other 16-byte values come first, then 8-byte pointers/maps/slices/strings,
|
|
// followed by ints and finally bools.
|
|
type GenerateModel struct {
|
|
// 16-byte interfaces
|
|
discovery DiscoveryInterface
|
|
mutator MutatorInterface
|
|
|
|
// Pointers/maps/slices/strings (8-byte aligned, header sizes vary)
|
|
existingKeys map[string]struct{}
|
|
existingLocalPorts map[int]struct{}
|
|
nsSelected map[string]bool
|
|
servicesByNS map[string][]ServiceCandidate
|
|
svcSelected map[string]bool
|
|
svcLocked map[string]bool
|
|
|
|
namespaces []string
|
|
nsFilteredView []string
|
|
svcOrder []ServiceCandidate
|
|
svcFilteredView []ServiceCandidate
|
|
|
|
contextName string
|
|
configPath string
|
|
loadErr string
|
|
nsFilter string
|
|
svcFilter string
|
|
startingPortStr string
|
|
portError string
|
|
|
|
// Composite result struct
|
|
result GenerateResult
|
|
|
|
// Ints
|
|
step GenerateStep
|
|
spinnerFrame int
|
|
nsCursor int
|
|
nsScroll int
|
|
svcCursor int
|
|
svcScroll int
|
|
termWidth int
|
|
termHeight int
|
|
|
|
// Bools last (smallest alignment)
|
|
dryRun bool
|
|
loading bool
|
|
nsFiltering bool
|
|
svcFiltering bool
|
|
}
|
|
|
|
// NewGenerateModel constructs a fresh generate model.
|
|
// existingForwards is the slice from config.Config.GetAllForwards() and is used
|
|
// for both collision detection and to mark already-configured rows as locked.
|
|
func NewGenerateModel(
|
|
discovery DiscoveryInterface,
|
|
mutator MutatorInterface,
|
|
contextName string,
|
|
configPath string,
|
|
dryRun bool,
|
|
existingForwards []config.Forward,
|
|
) *GenerateModel {
|
|
keys := make(map[string]struct{}, len(existingForwards))
|
|
ports := make(map[int]struct{}, len(existingForwards))
|
|
for _, f := range existingForwards {
|
|
// Only track entries from the same context — collisions across contexts
|
|
// matter for local port assignment but not for "already configured" lock.
|
|
if f.GetContext() == contextName {
|
|
k := fmt.Sprintf("%s|%s|%s|%d", f.GetNamespace(), f.Resource, strings.ToLower(f.Protocol), f.Port)
|
|
keys[k] = struct{}{}
|
|
}
|
|
// Local-port collisions span the whole config file.
|
|
ports[f.LocalPort] = struct{}{}
|
|
}
|
|
|
|
return &GenerateModel{
|
|
discovery: discovery,
|
|
mutator: mutator,
|
|
contextName: contextName,
|
|
configPath: configPath,
|
|
dryRun: dryRun,
|
|
existingKeys: keys,
|
|
existingLocalPorts: ports,
|
|
step: GenerateStepNamespaces,
|
|
loading: true,
|
|
nsSelected: map[string]bool{},
|
|
svcSelected: map[string]bool{},
|
|
svcLocked: map[string]bool{},
|
|
startingPortStr: strconv.Itoa(GenerateDefaultStartingPort),
|
|
termWidth: DefaultTermWidth,
|
|
termHeight: DefaultTermHeight,
|
|
}
|
|
}
|
|
|
|
// Init returns the initial command (load namespaces).
|
|
func (m *GenerateModel) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.loadNamespacesCmd(),
|
|
tickCmd(),
|
|
)
|
|
}
|
|
|
|
// Result exposes the final outcome after the program quits.
|
|
func (m *GenerateModel) Result() GenerateResult { return m.result }
|
|
|
|
func tickCmd() tea.Cmd {
|
|
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return generateTickMsg{} })
|
|
}
|
|
|
|
// ---------- Commands ----------
|
|
|
|
func (m *GenerateModel) loadNamespacesCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), GenerateListTimeout)
|
|
defer cancel()
|
|
ns, err := m.discovery.ListNamespaces(ctx, m.contextName)
|
|
if err == nil {
|
|
sort.Strings(ns)
|
|
}
|
|
return generateNamespacesLoadedMsg{namespaces: ns, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *GenerateModel) loadServicesCmd(namespaces []string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
out := make(map[string][]ServiceCandidate, len(namespaces))
|
|
var (
|
|
mu sync.Mutex
|
|
wg sync.WaitGroup
|
|
sem = make(chan struct{}, GenerateConcurrency)
|
|
errs []string
|
|
)
|
|
for _, ns := range namespaces {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(ns string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
ctx, cancel := context.WithTimeout(context.Background(), GenerateListTimeout)
|
|
defer cancel()
|
|
svcs, err := m.discovery.ListServices(ctx, m.contextName, ns)
|
|
if err != nil {
|
|
mu.Lock()
|
|
errs = append(errs, fmt.Sprintf("%s: %v", ns, err))
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
rows := make([]ServiceCandidate, 0, len(svcs))
|
|
for _, s := range svcs {
|
|
for _, p := range s.Ports {
|
|
proto := strings.ToUpper(p.Protocol)
|
|
if proto == "" {
|
|
proto = "TCP"
|
|
}
|
|
rows = append(rows, ServiceCandidate{
|
|
Namespace: s.Namespace,
|
|
Service: s.Name,
|
|
Port: p.Port,
|
|
Protocol: proto,
|
|
})
|
|
}
|
|
}
|
|
mu.Lock()
|
|
out[ns] = rows
|
|
mu.Unlock()
|
|
}(ns)
|
|
}
|
|
wg.Wait()
|
|
var combinedErr error
|
|
if len(errs) > 0 {
|
|
combinedErr = fmt.Errorf("failed to list services in %d namespaces: %s", len(errs), strings.Join(errs, "; "))
|
|
}
|
|
return generateServicesLoadedMsg{servicesByNS: out, err: combinedErr}
|
|
}
|
|
}
|
|
|
|
func (m *GenerateModel) saveCmd(forwards []config.Forward) tea.Cmd {
|
|
return func() tea.Msg {
|
|
var errs []string
|
|
added := 0
|
|
for _, f := range forwards {
|
|
if err := m.mutator.AddForward(f.GetContext(), f.GetNamespace(), f); err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s/%s/%s:%d: %v", f.GetContext(), f.GetNamespace(), f.Resource, f.Port, err))
|
|
// Continue trying remaining ones — but spec says stop on first error.
|
|
// Spec: "Stop on the first error and report which ones succeeded vs failed".
|
|
return generateSavedMsg{added: added, errors: errs}
|
|
}
|
|
added++
|
|
}
|
|
return generateSavedMsg{added: added, errors: errs}
|
|
}
|
|
}
|
|
|
|
// ---------- Update ----------
|
|
|
|
// Update implements tea.Model.
|
|
func (m *GenerateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.termWidth = msg.Width
|
|
m.termHeight = msg.Height
|
|
return m, nil
|
|
|
|
case generateTickMsg:
|
|
m.spinnerFrame++
|
|
if m.loading {
|
|
return m, tickCmd()
|
|
}
|
|
return m, nil
|
|
|
|
case generateNamespacesLoadedMsg:
|
|
m.loading = false
|
|
if msg.err != nil {
|
|
m.loadErr = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.namespaces = msg.namespaces
|
|
m.recomputeNamespaceFilter()
|
|
return m, nil
|
|
|
|
case generateServicesLoadedMsg:
|
|
m.loading = false
|
|
if msg.err != nil {
|
|
m.loadErr = msg.err.Error()
|
|
}
|
|
m.servicesByNS = msg.servicesByNS
|
|
m.buildServiceOrder()
|
|
m.recomputeServiceFilter()
|
|
return m, nil
|
|
|
|
case generateSavedMsg:
|
|
m.result.Added = msg.added
|
|
m.result.Errors = msg.errors
|
|
m.step = GenerateStepDone
|
|
return m, tea.Quit
|
|
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *GenerateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.loading {
|
|
// Allow only ctrl+c / esc while loading
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
m.step = GenerateStepCancelled
|
|
m.result.Cancelled = true
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch m.step {
|
|
case GenerateStepNamespaces:
|
|
return m.handleNamespaceKey(msg)
|
|
case GenerateStepServices:
|
|
return m.handleServiceKey(msg)
|
|
case GenerateStepPortAssign:
|
|
return m.handlePortKey(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ---------- Namespace step ----------
|
|
|
|
func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.nsFiltering {
|
|
switch msg.Type {
|
|
case tea.KeyEnter, tea.KeyEsc:
|
|
m.nsFiltering = false
|
|
if msg.Type == tea.KeyEsc {
|
|
m.nsFilter = ""
|
|
m.recomputeNamespaceFilter()
|
|
}
|
|
return m, nil
|
|
case tea.KeyBackspace:
|
|
if len(m.nsFilter) > 0 {
|
|
m.nsFilter = m.nsFilter[:len(m.nsFilter)-1]
|
|
m.recomputeNamespaceFilter()
|
|
}
|
|
return m, nil
|
|
case tea.KeyRunes, tea.KeySpace:
|
|
m.nsFilter += string(msg.Runes)
|
|
m.recomputeNamespaceFilter()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
m.step = GenerateStepCancelled
|
|
m.result.Cancelled = true
|
|
return m, tea.Quit
|
|
case "up", "k":
|
|
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), -1)
|
|
case "down", "j":
|
|
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), 1)
|
|
case "pgup":
|
|
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), -10)
|
|
case "pgdown":
|
|
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), 10)
|
|
case " ":
|
|
if len(m.nsFilteredView) > 0 {
|
|
ns := m.nsFilteredView[m.nsCursor]
|
|
m.nsSelected[ns] = !m.nsSelected[ns]
|
|
}
|
|
case "a":
|
|
m.toggleAllNamespaces()
|
|
case "/":
|
|
m.nsFiltering = true
|
|
case "enter":
|
|
selected := m.selectedNamespaces()
|
|
if len(selected) == 0 {
|
|
return m, nil
|
|
}
|
|
m.step = GenerateStepServices
|
|
m.loading = true
|
|
m.loadErr = ""
|
|
return m, tea.Batch(m.loadServicesCmd(selected), tickCmd())
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *GenerateModel) recomputeNamespaceFilter() {
|
|
m.nsFilteredView = filterStrings(m.namespaces, m.nsFilter)
|
|
if m.nsCursor >= len(m.nsFilteredView) {
|
|
m.nsCursor = max(0, len(m.nsFilteredView)-1)
|
|
}
|
|
if m.nsScroll > m.nsCursor {
|
|
m.nsScroll = m.nsCursor
|
|
}
|
|
}
|
|
|
|
func (m *GenerateModel) toggleAllNamespaces() {
|
|
// If everything visible is selected, deselect; otherwise select all visible.
|
|
allSelected := true
|
|
for _, ns := range m.nsFilteredView {
|
|
if !m.nsSelected[ns] {
|
|
allSelected = false
|
|
break
|
|
}
|
|
}
|
|
for _, ns := range m.nsFilteredView {
|
|
m.nsSelected[ns] = !allSelected
|
|
}
|
|
}
|
|
|
|
func (m *GenerateModel) selectedNamespaces() []string {
|
|
out := make([]string, 0, len(m.nsSelected))
|
|
for ns, sel := range m.nsSelected {
|
|
if sel {
|
|
out = append(out, ns)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// ---------- Services step ----------
|
|
|
|
func (m *GenerateModel) buildServiceOrder() {
|
|
rows := make([]ServiceCandidate, 0)
|
|
for _, list := range m.servicesByNS {
|
|
rows = append(rows, list...)
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Namespace != rows[j].Namespace {
|
|
return rows[i].Namespace < rows[j].Namespace
|
|
}
|
|
if rows[i].Service != rows[j].Service {
|
|
return rows[i].Service < rows[j].Service
|
|
}
|
|
return rows[i].Port < rows[j].Port
|
|
})
|
|
m.svcOrder = rows
|
|
m.svcLocked = make(map[string]bool, len(rows))
|
|
for _, r := range rows {
|
|
// Use TCP-canonical key for matching against config (config keeps lowercase tcp).
|
|
canonical := fmt.Sprintf("%s|%s|%s|%d", r.Namespace, "service/"+r.Service, "tcp", r.Port)
|
|
if _, found := m.existingKeys[canonical]; found {
|
|
m.svcLocked[r.Key()] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.svcFiltering {
|
|
switch msg.Type {
|
|
case tea.KeyEnter, tea.KeyEsc:
|
|
m.svcFiltering = false
|
|
if msg.Type == tea.KeyEsc {
|
|
m.svcFilter = ""
|
|
m.recomputeServiceFilter()
|
|
}
|
|
return m, nil
|
|
case tea.KeyBackspace:
|
|
if len(m.svcFilter) > 0 {
|
|
m.svcFilter = m.svcFilter[:len(m.svcFilter)-1]
|
|
m.recomputeServiceFilter()
|
|
}
|
|
return m, nil
|
|
case tea.KeyRunes, tea.KeySpace:
|
|
m.svcFilter += string(msg.Runes)
|
|
m.recomputeServiceFilter()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
m.step = GenerateStepCancelled
|
|
m.result.Cancelled = true
|
|
return m, tea.Quit
|
|
case "b":
|
|
m.step = GenerateStepNamespaces
|
|
m.loadErr = ""
|
|
case "up", "k":
|
|
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), -1)
|
|
case "down", "j":
|
|
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), 1)
|
|
case "pgup":
|
|
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), -10)
|
|
case "pgdown":
|
|
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), 10)
|
|
case " ":
|
|
if len(m.svcFilteredView) > 0 {
|
|
c := m.svcFilteredView[m.svcCursor]
|
|
if !m.svcLocked[c.Key()] && c.Protocol == "TCP" {
|
|
m.svcSelected[c.Key()] = !m.svcSelected[c.Key()]
|
|
}
|
|
}
|
|
case "a":
|
|
m.toggleAllServices()
|
|
case "/":
|
|
m.svcFiltering = true
|
|
case "enter":
|
|
selected := m.selectedCandidates()
|
|
if len(selected) == 0 {
|
|
return m, nil
|
|
}
|
|
m.step = GenerateStepPortAssign
|
|
m.portError = ""
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *GenerateModel) recomputeServiceFilter() {
|
|
if m.svcFilter == "" {
|
|
m.svcFilteredView = m.svcOrder
|
|
} else {
|
|
needle := strings.ToLower(m.svcFilter)
|
|
out := make([]ServiceCandidate, 0, len(m.svcOrder))
|
|
for _, c := range m.svcOrder {
|
|
label := fmt.Sprintf("%s/%s:%d", c.Namespace, c.Service, c.Port)
|
|
if strings.Contains(strings.ToLower(label), needle) {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
m.svcFilteredView = out
|
|
}
|
|
if m.svcCursor >= len(m.svcFilteredView) {
|
|
m.svcCursor = max(0, len(m.svcFilteredView)-1)
|
|
}
|
|
if m.svcScroll > m.svcCursor {
|
|
m.svcScroll = m.svcCursor
|
|
}
|
|
}
|
|
|
|
func (m *GenerateModel) toggleAllServices() {
|
|
allSelected := true
|
|
for _, c := range m.svcFilteredView {
|
|
if m.svcLocked[c.Key()] || c.Protocol != "TCP" {
|
|
continue
|
|
}
|
|
if !m.svcSelected[c.Key()] {
|
|
allSelected = false
|
|
break
|
|
}
|
|
}
|
|
for _, c := range m.svcFilteredView {
|
|
if m.svcLocked[c.Key()] || c.Protocol != "TCP" {
|
|
continue
|
|
}
|
|
m.svcSelected[c.Key()] = !allSelected
|
|
}
|
|
}
|
|
|
|
func (m *GenerateModel) selectedCandidates() []ServiceCandidate {
|
|
out := make([]ServiceCandidate, 0)
|
|
for _, c := range m.svcOrder {
|
|
if m.svcLocked[c.Key()] || c.Protocol != "TCP" {
|
|
continue
|
|
}
|
|
if m.svcSelected[c.Key()] {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------- Port assignment step ----------
|
|
|
|
func (m *GenerateModel) handlePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
m.step = GenerateStepCancelled
|
|
m.result.Cancelled = true
|
|
return m, tea.Quit
|
|
case "esc", "b":
|
|
m.step = GenerateStepServices
|
|
m.portError = ""
|
|
return m, nil
|
|
case "backspace":
|
|
if len(m.startingPortStr) > 0 {
|
|
m.startingPortStr = m.startingPortStr[:len(m.startingPortStr)-1]
|
|
m.portError = ""
|
|
}
|
|
return m, nil
|
|
case "enter":
|
|
start, ok := m.parseStartingPort()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
forwards := m.assignPorts(start)
|
|
m.result.PlannedForwards = forwards
|
|
m.result.SkippedNonTCP = m.countSkippedNonTCP()
|
|
if m.dryRun {
|
|
m.step = GenerateStepDone
|
|
m.result.UsedDryRun = true
|
|
m.result.Added = 0
|
|
return m, tea.Quit
|
|
}
|
|
return m, m.saveCmd(forwards)
|
|
}
|
|
|
|
// Digit-only input
|
|
for _, r := range msg.Runes {
|
|
if r >= '0' && r <= '9' && len(m.startingPortStr) < 5 {
|
|
m.startingPortStr += string(r)
|
|
m.portError = ""
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *GenerateModel) parseStartingPort() (int, bool) {
|
|
if m.startingPortStr == "" {
|
|
m.portError = "Starting port is required"
|
|
return 0, false
|
|
}
|
|
v, err := strconv.Atoi(m.startingPortStr)
|
|
if err != nil {
|
|
m.portError = "Starting port must be a number"
|
|
return 0, false
|
|
}
|
|
if v < GenerateMinLocalPort {
|
|
m.portError = fmt.Sprintf("Starting port must be ≥ %d (privileged ports are not allowed)", GenerateMinLocalPort)
|
|
return 0, false
|
|
}
|
|
if v > GenerateMaxLocalPort {
|
|
m.portError = fmt.Sprintf("Starting port must be ≤ %d", GenerateMaxLocalPort)
|
|
return 0, false
|
|
}
|
|
m.portError = ""
|
|
return v, true
|
|
}
|
|
|
|
// assignPorts computes the planned forwards with collision-free local ports.
|
|
// Stable order: sort by namespace, then service, then port.
|
|
func (m *GenerateModel) assignPorts(start int) []config.Forward {
|
|
candidates := m.selectedCandidates()
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
if candidates[i].Namespace != candidates[j].Namespace {
|
|
return candidates[i].Namespace < candidates[j].Namespace
|
|
}
|
|
if candidates[i].Service != candidates[j].Service {
|
|
return candidates[i].Service < candidates[j].Service
|
|
}
|
|
return candidates[i].Port < candidates[j].Port
|
|
})
|
|
|
|
taken := make(map[int]struct{}, len(m.existingLocalPorts))
|
|
for p := range m.existingLocalPorts {
|
|
taken[p] = struct{}{}
|
|
}
|
|
|
|
out := make([]config.Forward, 0, len(candidates))
|
|
candidate := start
|
|
for _, c := range candidates {
|
|
// Walk forward while the port is taken. Stop if we run out of ports.
|
|
for _, used := taken[candidate]; used && candidate <= GenerateMaxLocalPort; _, used = taken[candidate] {
|
|
candidate++
|
|
}
|
|
if candidate > GenerateMaxLocalPort {
|
|
// Out of ports — bail; the save step will fail with a clear validation error.
|
|
break
|
|
}
|
|
f := config.Forward{
|
|
Resource: "service/" + c.Service,
|
|
Port: int(c.Port),
|
|
LocalPort: candidate,
|
|
Protocol: "tcp",
|
|
Alias: c.Service,
|
|
}
|
|
f.SetContext(m.contextName, c.Namespace)
|
|
out = append(out, f)
|
|
taken[candidate] = struct{}{}
|
|
candidate++
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *GenerateModel) countSkippedNonTCP() int {
|
|
n := 0
|
|
for _, c := range m.svcOrder {
|
|
if c.Protocol != "TCP" {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ---------- View ----------
|
|
|
|
// View implements tea.Model.
|
|
func (m *GenerateModel) View() string {
|
|
var b strings.Builder
|
|
b.WriteString(wizardHeaderStyle.Render(fmt.Sprintf("kportal generate · context: %s", m.contextName)))
|
|
b.WriteString("\n")
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("config: %s", m.configPath)))
|
|
if m.dryRun {
|
|
b.WriteString(" ")
|
|
b.WriteString(warningStyle.Render("[dry-run]"))
|
|
}
|
|
b.WriteString("\n\n")
|
|
|
|
if m.loading {
|
|
b.WriteString(spinnerStyle.Render(spinnerFrame(m.spinnerFrame)))
|
|
b.WriteString(" Loading from cluster…\n")
|
|
b.WriteString("\n")
|
|
b.WriteString(helpStyle.Render("esc: cancel"))
|
|
return b.String()
|
|
}
|
|
|
|
if m.loadErr != "" && m.step == GenerateStepNamespaces {
|
|
b.WriteString(errorStyle.Render("Error: "))
|
|
b.WriteString(m.loadErr)
|
|
b.WriteString("\n\n")
|
|
b.WriteString(helpStyle.Render("esc/ctrl+c: exit"))
|
|
return b.String()
|
|
}
|
|
|
|
switch m.step {
|
|
case GenerateStepNamespaces:
|
|
b.WriteString(m.renderNamespaceStep())
|
|
case GenerateStepServices:
|
|
b.WriteString(m.renderServiceStep())
|
|
case GenerateStepPortAssign:
|
|
b.WriteString(m.renderPortStep())
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m *GenerateModel) renderNamespaceStep() string {
|
|
var b strings.Builder
|
|
b.WriteString(breadcrumbStyle.Render("Step 1 / 3 · Select namespaces"))
|
|
b.WriteString("\n")
|
|
if m.nsFiltering {
|
|
b.WriteString(mutedStyle.Render("filter: "))
|
|
b.WriteString(inputStyle.Render(m.nsFilter + "█"))
|
|
b.WriteString("\n")
|
|
} else if m.nsFilter != "" {
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("filter: %q (press / to edit, esc to clear)", m.nsFilter)))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
if len(m.nsFilteredView) == 0 {
|
|
b.WriteString(mutedStyle.Render("(no namespaces match)\n"))
|
|
} else {
|
|
end := m.nsScroll + ViewportHeight
|
|
if end > len(m.nsFilteredView) {
|
|
end = len(m.nsFilteredView)
|
|
}
|
|
for i := m.nsScroll; i < end; i++ {
|
|
ns := m.nsFilteredView[i]
|
|
cursor := " "
|
|
if i == m.nsCursor {
|
|
cursor = selectedStyle.Render("▸ ")
|
|
}
|
|
box := uncheckedBoxStyle.Render("[ ]")
|
|
if m.nsSelected[ns] {
|
|
box = checkedBoxStyle.Render("[x]")
|
|
}
|
|
line := fmt.Sprintf("%s%s %s", cursor, box, ns)
|
|
b.WriteString(line)
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
selected := m.selectedNamespaces()
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("%d selected", len(selected))))
|
|
b.WriteString("\n")
|
|
help := "↑/↓: move space: toggle a: toggle-all /: filter enter: continue esc: cancel"
|
|
b.WriteString(wrapHelpText(help, wizardHelpWidth(m.termWidth)))
|
|
return b.String()
|
|
}
|
|
|
|
func (m *GenerateModel) renderServiceStep() string {
|
|
var b strings.Builder
|
|
b.WriteString(breadcrumbStyle.Render("Step 2 / 3 · Select services"))
|
|
b.WriteString("\n")
|
|
if m.loadErr != "" {
|
|
b.WriteString(warningStyle.Render("warning: " + m.loadErr))
|
|
b.WriteString("\n")
|
|
}
|
|
if m.svcFiltering {
|
|
b.WriteString(mutedStyle.Render("filter: "))
|
|
b.WriteString(inputStyle.Render(m.svcFilter + "█"))
|
|
b.WriteString("\n")
|
|
} else if m.svcFilter != "" {
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("filter: %q", m.svcFilter)))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
if len(m.svcFilteredView) == 0 {
|
|
b.WriteString(mutedStyle.Render("(no services found)\n"))
|
|
} else {
|
|
end := m.svcScroll + ViewportHeight
|
|
if end > len(m.svcFilteredView) {
|
|
end = len(m.svcFilteredView)
|
|
}
|
|
for i := m.svcScroll; i < end; i++ {
|
|
c := m.svcFilteredView[i]
|
|
cursor := " "
|
|
if i == m.svcCursor {
|
|
cursor = selectedStyle.Render("▸ ")
|
|
}
|
|
locked := m.svcLocked[c.Key()]
|
|
nonTCP := c.Protocol != "TCP"
|
|
box := uncheckedBoxStyle.Render("[ ]")
|
|
switch {
|
|
case locked:
|
|
box = mutedStyle.Render("[~]")
|
|
case nonTCP:
|
|
box = mutedStyle.Render("[!]")
|
|
case m.svcSelected[c.Key()]:
|
|
box = checkedBoxStyle.Render("[x]")
|
|
}
|
|
label := fmt.Sprintf("%s/%s:%d", c.Namespace, c.Service, c.Port)
|
|
if c.Protocol != "TCP" {
|
|
label += fmt.Sprintf(" (%s)", c.Protocol)
|
|
}
|
|
suffix := ""
|
|
if locked {
|
|
suffix = " " + mutedStyle.Render("(already configured)")
|
|
} else if nonTCP {
|
|
suffix = " " + mutedStyle.Render("(non-TCP, skipped)")
|
|
}
|
|
line := fmt.Sprintf("%s%s %s%s", cursor, box, label, suffix)
|
|
if locked || nonTCP {
|
|
line = mutedStyle.Render(fmt.Sprintf("%s%s %s%s", cursor, box, label, suffix))
|
|
}
|
|
b.WriteString(line)
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
sel := m.selectedCandidates()
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("%d selected", len(sel))))
|
|
b.WriteString("\n")
|
|
help := "↑/↓: move space: toggle a: toggle-all /: filter enter: continue b: back esc: cancel"
|
|
b.WriteString(wrapHelpText(help, wizardHelpWidth(m.termWidth)))
|
|
return b.String()
|
|
}
|
|
|
|
func (m *GenerateModel) renderPortStep() string {
|
|
var b strings.Builder
|
|
b.WriteString(breadcrumbStyle.Render("Step 3 / 3 · Assign local ports"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(renderTextInput("Starting local port: ", m.startingPortStr, m.portError == ""))
|
|
b.WriteString("\n")
|
|
if m.portError != "" {
|
|
b.WriteString(errorStyle.Render(m.portError))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
start, ok := m.previewStartingPort()
|
|
if ok {
|
|
preview := m.assignPorts(start)
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("Preview (%d forwards):", len(preview))))
|
|
b.WriteString("\n")
|
|
max := ViewportHeight
|
|
if len(preview) < max {
|
|
max = len(preview)
|
|
}
|
|
for i := 0; i < max; i++ {
|
|
f := preview[i]
|
|
line := fmt.Sprintf(" %d → %s/%s/%s:%d", f.LocalPort, f.GetContext(), f.GetNamespace(), f.Resource, f.Port)
|
|
b.WriteString(line)
|
|
b.WriteString("\n")
|
|
}
|
|
if len(preview) > max {
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" … %d more not shown", len(preview)-max)))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
help := "type digits to set port enter: save esc/b: back ctrl+c: cancel"
|
|
if m.dryRun {
|
|
help = "type digits to set port enter: preview & exit (dry-run) esc/b: back ctrl+c: cancel"
|
|
}
|
|
b.WriteString(wrapHelpText(help, wizardHelpWidth(m.termWidth)))
|
|
return b.String()
|
|
}
|
|
|
|
// previewStartingPort attempts to parse the starting port for preview rendering.
|
|
// Unlike parseStartingPort, it does not mutate model state.
|
|
func (m *GenerateModel) previewStartingPort() (int, bool) {
|
|
if m.startingPortStr == "" {
|
|
return 0, false
|
|
}
|
|
v, err := strconv.Atoi(m.startingPortStr)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
if v < GenerateMinLocalPort || v > GenerateMaxLocalPort {
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// ---------- Helpers ----------
|
|
|
|
func (m *GenerateModel) moveCursor(cursor, scroll *int, total, delta int) {
|
|
if total == 0 {
|
|
*cursor = 0
|
|
*scroll = 0
|
|
return
|
|
}
|
|
*cursor += delta
|
|
if *cursor < 0 {
|
|
*cursor = 0
|
|
}
|
|
if *cursor >= total {
|
|
*cursor = total - 1
|
|
}
|
|
if *cursor < *scroll {
|
|
*scroll = *cursor
|
|
}
|
|
if *cursor >= *scroll+ViewportHeight {
|
|
*scroll = *cursor - ViewportHeight + 1
|
|
}
|
|
}
|
|
|
|
func spinnerFrame(i int) string {
|
|
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
return frames[i%len(frames)]
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// RunGenerate runs the generate flow as a bubbletea program and returns the
|
|
// final result. The discovery and mutator are passed as interfaces so tests
|
|
// can inject fakes.
|
|
func RunGenerate(
|
|
discovery DiscoveryInterface,
|
|
mutator MutatorInterface,
|
|
contextName string,
|
|
configPath string,
|
|
dryRun bool,
|
|
existingForwards []config.Forward,
|
|
) (GenerateResult, error) {
|
|
m := NewGenerateModel(discovery, mutator, contextName, configPath, dryRun, existingForwards)
|
|
prog := tea.NewProgram(m, tea.WithAltScreen())
|
|
finalModel, err := prog.Run()
|
|
if err != nil {
|
|
return GenerateResult{}, err
|
|
}
|
|
if gm, ok := finalModel.(*GenerateModel); ok {
|
|
return gm.Result(), nil
|
|
}
|
|
return m.Result(), nil
|
|
}
|