From f4adeedb8f93d5742895a661d040167e69d95a3f Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Wed, 6 May 2026 13:09:12 +0100 Subject: [PATCH] feat: 'kportal generate' subcommand to bulk-add forwards from a cluster 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. --- README.md | 26 + cmd/kportal/generate.go | 156 ++++++ cmd/kportal/main.go | 7 + internal/ui/generate.go | 987 +++++++++++++++++++++++++++++++++++ internal/ui/generate_test.go | 529 +++++++++++++++++++ 5 files changed, 1705 insertions(+) create mode 100644 cmd/kportal/generate.go create mode 100644 internal/ui/generate.go create mode 100644 internal/ui/generate_test.go diff --git a/README.md b/README.md index ef1f28e..590c684 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,32 @@ kportal --check kportal -c /path/to/config.yaml ``` +### Generate Forwards from a Cluster + +The `generate` subcommand discovers services in a Kubernetes context and lets you +interactively pick which ones to forward. Selected entries are appended to the +config file with consecutive local ports starting from a value you choose. + +```bash +kportal generate --context=my-cluster +kportal generate --context=my-cluster --config=/path/to/.kportal.yaml +kportal generate --context=my-cluster --dry-run +``` + +| Flag | Description | +|------|-------------| +| `--context` | (required) Kubernetes context to scan | +| `--config` | Path to kportal config file (default: `.kportal.yaml`) | +| `--dry-run` | Print the planned forwards but do not modify the config | + +The interactive flow has three steps: + +1. **Namespaces** — multi-select with `space`, toggle-all with `a`, filter with `/`. +2. **Services** — same controls; rows already present in the config are locked off, and non-TCP ports are skipped (UDP is not supported by kportal's forward layer). +3. **Port assignment** — choose a starting local port (default `10000`, must be ≥ `1024`). Local ports are assigned consecutively in stable order, skipping any already in use. + +Press `enter` on the final step to save (or to print and exit when `--dry-run` is set), `b` to go back, or `esc` to cancel. + ## Status Indicators | Indicator | Description | diff --git a/cmd/kportal/generate.go b/cmd/kportal/generate.go new file mode 100644 index 0000000..109eac9 --- /dev/null +++ b/cmd/kportal/generate.go @@ -0,0 +1,156 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/lukaszraczylo/kportal/internal/config" + "github.com/lukaszraczylo/kportal/internal/k8s" + "github.com/lukaszraczylo/kportal/internal/logger" + "github.com/lukaszraczylo/kportal/internal/ui" +) + +// runGenerate parses generate-specific flags, validates them, and runs the +// generate flow. Returns the process exit code. +func runGenerate(args []string) int { + fs := flag.NewFlagSet("generate", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: kportal generate --context=NAME [--config=PATH] [--dry-run]\n\n") + fmt.Fprintf(os.Stderr, "Discover services in the chosen Kubernetes context, pick which ones\n") + fmt.Fprintf(os.Stderr, "to forward, and append them to the kportal config file.\n\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + fs.PrintDefaults() + } + contextFlag := fs.String("context", "", "Kubernetes context to scan (required)") + configFlag := fs.String("config", defaultConfigFile, "Path to kportal configuration file") + dryRunFlag := fs.Bool("dry-run", false, "Print the planned forwards but do not modify the config") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 1 + } + + if *contextFlag == "" { + fmt.Fprintln(os.Stderr, "Error: --context is required") + fs.Usage() + return 1 + } + + // Initialise a discard logger so kubernetes client-go silence is honoured — + // the bubbletea TUI cannot tolerate stderr writes. + logger.Init(logger.LevelError, logger.FormatText, io.Discard) + + // Resolve and sanitise config path the same way main does. + configPath, ok := resolveGenerateConfigPath(*configFlag) + if !ok { + return 1 + } + + // Build kubernetes client pool and verify the requested context exists. + pool, err := k8s.NewClientPool() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to load kubeconfig: %v\n", err) + return 1 + } + contexts, err := pool.ListContexts() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to list kubeconfig contexts: %v\n", err) + return 1 + } + if !contains(contexts, *contextFlag) { + fmt.Fprintf(os.Stderr, "Error: context %q not found in kubeconfig\n", *contextFlag) + fmt.Fprintf(os.Stderr, "Available contexts: %s\n", strings.Join(contexts, ", ")) + return 1 + } + discovery := k8s.NewDiscovery(pool) + mutator := config.NewMutator(configPath) + + // Load existing config (or treat as empty if missing) to gather already-configured forwards. + var existingForwards []config.Forward + cfg, loadErr := config.LoadConfig(configPath) + switch { + case loadErr == nil: + existingForwards = cfg.GetAllForwards() + case errors.Is(loadErr, config.ErrConfigNotFound): + // Config does not exist yet — that's fine; we'll create it on save. + existingForwards = nil + default: + fmt.Fprintf(os.Stderr, "Error: failed to load config: %v\n", loadErr) + return 1 + } + + result, err := ui.RunGenerate(discovery, mutator, *contextFlag, configPath, *dryRunFlag, existingForwards) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + if result.Cancelled { + fmt.Fprintln(os.Stderr, "Cancelled.") + return 1 + } + + if result.UsedDryRun { + fmt.Printf("[dry-run] Would add %d forwards to %s\n", len(result.PlannedForwards), configPath) + for _, f := range result.PlannedForwards { + fmt.Printf(" %d → %s/%s/%s:%d\n", f.LocalPort, f.GetContext(), f.GetNamespace(), f.Resource, f.Port) + } + if result.SkippedNonTCP > 0 { + fmt.Fprintf(os.Stderr, "Warning: skipped %d non-TCP service ports (kportal forward layer is TCP-only)\n", result.SkippedNonTCP) + } + return 0 + } + + if len(result.Errors) > 0 { + fmt.Fprintf(os.Stderr, "Added %d forwards before error; remaining failed:\n", result.Added) + for _, e := range result.Errors { + fmt.Fprintf(os.Stderr, " - %s\n", e) + } + return 1 + } + + fmt.Printf("Added %d forwards to %s\n", result.Added, configPath) + if result.SkippedNonTCP > 0 { + fmt.Fprintf(os.Stderr, "Warning: skipped %d non-TCP service ports (kportal forward layer is TCP-only)\n", result.SkippedNonTCP) + } + return 0 +} + +// resolveGenerateConfigPath mirrors the path validation main applies before +// loading config: absolute, cleaned, and not inside protected system directories. +func resolveGenerateConfigPath(path string) (string, bool) { + if path == "" { + fmt.Fprintln(os.Stderr, "Error: --config cannot be empty") + return "", false + } + abs, err := filepath.Abs(path) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid config path: %v\n", err) + return "", false + } + abs = filepath.Clean(abs) + for _, sysDir := range []string{"/etc", "/sys", "/proc", "/dev"} { + if strings.HasPrefix(abs, sysDir) { + fmt.Fprintf(os.Stderr, "Error: config file cannot be in system directory: %s\n", sysDir) + return "", false + } + } + return abs, true +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} diff --git a/cmd/kportal/main.go b/cmd/kportal/main.go index f1760bd..2b8af94 100644 --- a/cmd/kportal/main.go +++ b/cmd/kportal/main.go @@ -68,6 +68,13 @@ func promptCreateConfig(path string) bool { } func main() { + // Subcommand dispatch. Must run BEFORE flag.Parse() because the global flag + // set is reused from here and we don't want generate-specific flags to be + // rejected as unknown by the main flag set. + if len(os.Args) >= 2 && os.Args[1] == "generate" { + os.Exit(runGenerate(os.Args[2:])) + } + flag.Parse() if *showVersion { diff --git a/internal/ui/generate.go b/internal/ui/generate.go new file mode 100644 index 0000000..c58787d --- /dev/null +++ b/internal/ui/generate.go @@ -0,0 +1,987 @@ +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 +} diff --git a/internal/ui/generate_test.go b/internal/ui/generate_test.go new file mode 100644 index 0000000..1b49e00 --- /dev/null +++ b/internal/ui/generate_test.go @@ -0,0 +1,529 @@ +package ui + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lukaszraczylo/kportal/internal/config" + "github.com/lukaszraczylo/kportal/internal/k8s" +) + +// fakeMutator is a minimal MutatorInterface for tests that don't touch the +// filesystem. It records the order of AddForward calls. +type fakeMutator struct { + addError error + added []config.Forward + mu sync.Mutex +} + +func (f *fakeMutator) AddForward(ctxName, ns string, fwd config.Forward) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.addError != nil { + return f.addError + } + fwd.SetContext(ctxName, ns) + f.added = append(f.added, fwd) + return nil +} + +func (f *fakeMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error { + return nil +} +func (f *fakeMutator) RemoveForwardByID(id string) error { return nil } +func (f *fakeMutator) UpdateForward(oldID, newCtx, newNS string, newFwd config.Forward) error { + return nil +} + +// fakeDiscovery is a minimal DiscoveryInterface for tests. +type fakeDiscovery struct { + servicesByNS map[string][]k8s.ServiceInfo + listNamespacesEr error + listServicesEr error + namespaces []string +} + +func (f *fakeDiscovery) ListContexts() ([]string, error) { return []string{"test"}, nil } +func (f *fakeDiscovery) GetCurrentContext() (string, error) { return "test", nil } +func (f *fakeDiscovery) ListNamespaces(_ context.Context, _ string) ([]string, error) { + return f.namespaces, f.listNamespacesEr +} +func (f *fakeDiscovery) ListPods(_ context.Context, _, _ string) ([]k8s.PodInfo, error) { + return nil, nil +} +func (f *fakeDiscovery) ListPodsWithSelector(_ context.Context, _, _, _ string) ([]k8s.PodInfo, error) { + return nil, nil +} +func (f *fakeDiscovery) ListServices(_ context.Context, _, ns string) ([]k8s.ServiceInfo, error) { + if f.listServicesEr != nil { + return nil, f.listServicesEr + } + return f.servicesByNS[ns], nil +} + +// keyOf builds a tea.KeyMsg the same way bubbletea does for typed runes. +func keyOf(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "space": + return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + } + if len(s) == 1 { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +// drainModel applies a sequence of messages and returns the final model. +func drainModel(t *testing.T, m tea.Model, msgs ...tea.Msg) tea.Model { + t.Helper() + cur := m + for _, msg := range msgs { + next, _ := cur.Update(msg) + cur = next + } + return cur +} + +func TestGenerateModel_NamespaceMultiSelect(t *testing.T) { + disc := &fakeDiscovery{ + namespaces: []string{"alpha", "beta", "gamma"}, + servicesByNS: map[string][]k8s.ServiceInfo{ + "alpha": {{Name: "svc-a", Namespace: "alpha", Ports: []k8s.PortInfo{{Port: 80, Protocol: "TCP"}}}}, + }, + } + mut := &fakeMutator{} + m := NewGenerateModel(disc, mut, "ctx", "/tmp/x.yaml", true, nil) + + // Init (load namespaces) + cmd := m.Init() + if cmd == nil { + t.Fatal("expected Init to return command") + } + // Simulate the namespaces-loaded message. + model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces}) + gm := model.(*GenerateModel) + + if gm.loading { + t.Fatal("expected loading=false after namespaces loaded") + } + if len(gm.nsFilteredView) != 3 { + t.Fatalf("want 3 namespaces, got %d", len(gm.nsFilteredView)) + } + + // Toggle first item with space — cursor starts at 0. + gm2 := drainModel(t, gm, keyOf("space")).(*GenerateModel) + if !gm2.nsSelected["alpha"] { + t.Fatal("expected alpha to be selected") + } + + // 'a' toggles all. Because alpha is selected and the others are not, + // allSelected=false so the press selects everything visible. + gm3 := drainModel(t, gm2, keyOf("a")).(*GenerateModel) + for _, ns := range []string{"alpha", "beta", "gamma"} { + if !gm3.nsSelected[ns] { + t.Fatalf("expected %s to be selected after first toggle-all", ns) + } + } + // Press again — now all are selected, so it should deselect all. + gm4 := drainModel(t, gm3, keyOf("a")).(*GenerateModel) + for _, ns := range []string{"alpha", "beta", "gamma"} { + if gm4.nsSelected[ns] { + t.Fatalf("expected %s to be unselected after second toggle-all", ns) + } + } +} + +func TestGenerateModel_NamespaceFilter(t *testing.T) { + disc := &fakeDiscovery{namespaces: []string{"alpha", "beta", "gamma"}} + m := NewGenerateModel(disc, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil) + model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces}) + gm := model.(*GenerateModel) + + // Enter filter mode + gm = drainModel(t, gm, keyOf("/")).(*GenerateModel) + if !gm.nsFiltering { + t.Fatal("expected to enter filter mode") + } + gm = drainModel(t, gm, keyOf("b")).(*GenerateModel) + if gm.nsFilter != "b" { + t.Fatalf("expected filter=b, got %q", gm.nsFilter) + } + if len(gm.nsFilteredView) != 1 || gm.nsFilteredView[0] != "beta" { + t.Fatalf("expected [beta], got %v", gm.nsFilteredView) + } + // Exit filter + gm = drainModel(t, gm, tea.KeyMsg{Type: tea.KeyEnter}).(*GenerateModel) + if gm.nsFiltering { + t.Fatal("expected filtering to be off after enter") + } +} + +func TestGenerateModel_ServiceMultiSelectAndLock(t *testing.T) { + disc := &fakeDiscovery{ + namespaces: []string{"ns1"}, + servicesByNS: map[string][]k8s.ServiceInfo{ + "ns1": { + {Name: "svc-a", Namespace: "ns1", Ports: []k8s.PortInfo{{Port: 80, Protocol: "TCP"}, {Port: 443, Protocol: "TCP"}}}, + {Name: "svc-udp", Namespace: "ns1", Ports: []k8s.PortInfo{{Port: 53, Protocol: "UDP"}}}, + }, + }, + } + // One forward already configured: svc-a:80 in ns1 + existing := []config.Forward{makeFwd("ctx", "ns1", "service/svc-a", 80, 9000, "tcp")} + + m := NewGenerateModel(disc, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, existing) + // Drive past the namespace step. + model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces}) + gm := model.(*GenerateModel) + gm.nsSelected["ns1"] = true + // Press enter to advance to services step. + model2, _ := gm.Update(keyOf("enter")) + gm2 := model2.(*GenerateModel) + // Provide the loaded services. + model3, _ := gm2.Update(generateServicesLoadedMsg{servicesByNS: map[string][]ServiceCandidate{ + "ns1": { + {Namespace: "ns1", Service: "svc-a", Port: 80, Protocol: "TCP"}, + {Namespace: "ns1", Service: "svc-a", Port: 443, Protocol: "TCP"}, + {Namespace: "ns1", Service: "svc-udp", Port: 53, Protocol: "UDP"}, + }, + }}) + gm3 := model3.(*GenerateModel) + + if len(gm3.svcOrder) != 3 { + t.Fatalf("want 3 candidates, got %d", len(gm3.svcOrder)) + } + if !gm3.svcLocked[(ServiceCandidate{Namespace: "ns1", Service: "svc-a", Port: 80, Protocol: "TCP"}).Key()] { + t.Fatal("svc-a:80 should be locked (already in config)") + } + + // Move to svc-a:443 (cursor index 1) and toggle. + gm4 := drainModel(t, gm3, keyOf("down"), keyOf("space")).(*GenerateModel) + sel := gm4.selectedCandidates() + if len(sel) != 1 || sel[0].Service != "svc-a" || sel[0].Port != 443 { + t.Fatalf("expected [svc-a:443], got %v", sel) + } + + // Try to toggle the locked row (cursor 0) — should remain unselected. + gm5 := drainModel(t, gm4, keyOf("up"), keyOf("space")).(*GenerateModel) + for _, c := range gm5.selectedCandidates() { + if c.Port == 80 { + t.Fatal("locked row was selectable") + } + } + + // Toggle-all should select all selectable (i.e., svc-a:443 only — the others are locked or non-TCP). + gm6 := drainModel(t, gm5, keyOf("a")).(*GenerateModel) + // First press: all eligible already selected (svc-a:443) → deselect. + if len(gm6.selectedCandidates()) != 0 { + t.Fatalf("expected toggle-all to deselect, got %d", len(gm6.selectedCandidates())) + } + gm7 := drainModel(t, gm6, keyOf("a")).(*GenerateModel) + if len(gm7.selectedCandidates()) != 1 { + t.Fatalf("expected 1 selected after second toggle-all, got %d", len(gm7.selectedCandidates())) + } +} + +// readyModel returns a model with loading already cleared so step-level +// behaviour can be tested without injecting load messages first. +func readyModel(disc DiscoveryInterface, mut MutatorInterface, ctx, cfg string, dryRun bool, existing []config.Forward) *GenerateModel { + m := NewGenerateModel(disc, mut, ctx, cfg, dryRun, existing) + m.loading = false + return m +} + +func TestGenerateModel_PortAssignmentWithCollisions(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, ".kportal.yaml") + // Seed an existing config that has localPort 10000 and 10002 already used. + seed := []byte(`contexts: + - name: ctx + namespaces: + - name: existing + forwards: + - resource: service/legacy + port: 8080 + localPort: 10000 + protocol: tcp + - resource: service/legacy2 + port: 8080 + localPort: 10002 + protocol: tcp +`) + if err := os.WriteFile(configPath, seed, 0o600); err != nil { + t.Fatal(err) + } + + // Re-load to grab the existing forwards. + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("load: %v", err) + } + mut := config.NewMutator(configPath) + + disc := &fakeDiscovery{} + m := readyModel(disc, mut, "ctx", configPath, false, cfg.GetAllForwards()) + // Pre-populate svcOrder with three candidates that need ports. + m.svcOrder = []ServiceCandidate{ + {Namespace: "ns1", Service: "alpha", Port: 80, Protocol: "TCP"}, + {Namespace: "ns1", Service: "beta", Port: 80, Protocol: "TCP"}, + {Namespace: "ns1", Service: "gamma", Port: 80, Protocol: "TCP"}, + } + for _, c := range m.svcOrder { + m.svcSelected[c.Key()] = true + } + + planned := m.assignPorts(10000) + if len(planned) != 3 { + t.Fatalf("expected 3 planned forwards, got %d", len(planned)) + } + got := []int{planned[0].LocalPort, planned[1].LocalPort, planned[2].LocalPort} + want := []int{10001, 10003, 10004} // 10000 and 10002 taken + for i, p := range want { + if got[i] != p { + t.Fatalf("planned[%d] localPort: want %d, got %d (full=%v)", i, p, got[i], got) + } + } + + // Now invoke saveCmd through the model and verify mutator side-effects. + m.startingPortStr = "10000" + for _, c := range m.svcOrder { + m.svcSelected[c.Key()] = true + } + m.step = GenerateStepPortAssign + model2, cmd := m.Update(keyOf("enter")) + if cmd == nil { + t.Fatal("expected save command") + } + msg := cmd() + saved, ok := msg.(generateSavedMsg) + if !ok { + t.Fatalf("expected generateSavedMsg, got %T", msg) + } + if saved.added != 3 { + t.Fatalf("expected 3 added, got %d (errors=%v)", saved.added, saved.errors) + } + + // Verify config file now has 5 forwards total. + cfg2, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config: %v", err) + } + if len(cfg2.GetAllForwards()) != 5 { + t.Fatalf("expected 5 forwards after save, got %d", len(cfg2.GetAllForwards())) + } + _ = model2 +} + +func TestGenerateModel_PortBelow1024Rejected(t *testing.T) { + m := readyModel(&fakeDiscovery{}, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil) + m.step = GenerateStepPortAssign + m.svcOrder = []ServiceCandidate{{Namespace: "ns1", Service: "x", Port: 80, Protocol: "TCP"}} + m.svcSelected[m.svcOrder[0].Key()] = true + m.startingPortStr = "80" + + model, cmd := m.Update(keyOf("enter")) + gm := model.(*GenerateModel) + if cmd != nil { + t.Fatal("expected no command (rejected)") + } + if gm.portError == "" { + t.Fatal("expected port error to be set") + } + if gm.step != GenerateStepPortAssign { + t.Fatal("expected to remain on port step after invalid input") + } + + // Backspace + retype a valid value should clear the error and allow continuing. + gm.startingPortStr = "1024" + model2, cmd2 := gm.Update(keyOf("enter")) + gm2 := model2.(*GenerateModel) + if cmd2 == nil { + t.Fatal("expected save command after valid port") + } + if gm2.portError != "" { + t.Fatalf("expected port error cleared, got %q", gm2.portError) + } +} + +func TestGenerateModel_DryRunDoesNotInvokeMutator(t *testing.T) { + mut := &fakeMutator{} + m := readyModel(&fakeDiscovery{}, mut, "ctx", "/tmp/x.yaml", true, nil) + m.step = GenerateStepPortAssign + m.svcOrder = []ServiceCandidate{{Namespace: "ns1", Service: "x", Port: 80, Protocol: "TCP"}} + m.svcSelected[m.svcOrder[0].Key()] = true + m.startingPortStr = "10000" + + model, cmd := m.Update(keyOf("enter")) + gm := model.(*GenerateModel) + if !gm.result.UsedDryRun { + t.Fatal("expected dry-run flag set in result") + } + if len(mut.added) != 0 { + t.Fatalf("expected mutator untouched in dry-run, got %d adds", len(mut.added)) + } + if cmd == nil { + t.Fatal("expected quit command from dry-run path") + } + if msg := cmd(); msg == nil { + // Quit returns a tea.QuitMsg — just ensure it's non-nil. + t.Fatal("expected non-nil quit message") + } +} + +func TestGenerateModel_EndToEnd(t *testing.T) { + disc := &fakeDiscovery{ + namespaces: []string{"ns1"}, + } + mut := &fakeMutator{} + m := NewGenerateModel(disc, mut, "ctx", "/tmp/x.yaml", false, nil) + + // Init returns a Cmd; we don't run it directly. Instead we manually + // inject the messages it would produce. + _ = m.Init() + + // 1. Namespaces load. + model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces}) + gm := model.(*GenerateModel) + + // 2. Toggle ns1 + enter. + gm = drainModel(t, gm, keyOf("space"), keyOf("enter")).(*GenerateModel) + if gm.step != GenerateStepServices { + t.Fatalf("expected services step, got %v", gm.step) + } + + // 3. Provide loaded services. + model2, _ := gm.Update(generateServicesLoadedMsg{servicesByNS: map[string][]ServiceCandidate{ + "ns1": {{Namespace: "ns1", Service: "svc", Port: 8080, Protocol: "TCP"}}, + }}) + gm = model2.(*GenerateModel) + + // 4. Toggle the (only) service + enter. + gm = drainModel(t, gm, keyOf("space"), keyOf("enter")).(*GenerateModel) + if gm.step != GenerateStepPortAssign { + t.Fatalf("expected port-assign step, got %v", gm.step) + } + + // 5. Press enter on the default port (10000). + model3, cmd := gm.Update(keyOf("enter")) + gm = model3.(*GenerateModel) + if cmd == nil { + t.Fatal("expected save command") + } + msg := cmd() + saved := msg.(generateSavedMsg) + if saved.added != 1 { + t.Fatalf("expected 1 added, got %d (errs=%v)", saved.added, saved.errors) + } + + // 6. Process the saved message → step should be Done. + model4, _ := gm.Update(saved) + final := model4.(*GenerateModel) + if final.step != GenerateStepDone { + t.Fatalf("expected Done step, got %v", final.step) + } + if final.result.Added != 1 { + t.Fatalf("expected result.Added=1, got %d", final.result.Added) + } + if len(mut.added) != 1 { + t.Fatalf("expected mutator to record 1 forward, got %d", len(mut.added)) + } + if mut.added[0].Resource != "service/svc" || mut.added[0].LocalPort != 10000 { + t.Fatalf("unexpected forward recorded: %+v", mut.added[0]) + } +} + +// makeFwd is a small helper to build a Forward with context/namespace pre-set. +func makeFwd(ctxName, ns, resource string, port, localPort int, proto string) config.Forward { + f := config.Forward{ + Resource: resource, + Port: port, + LocalPort: localPort, + Protocol: proto, + } + f.SetContext(ctxName, ns) + return f +} + +func TestGenerateModel_ParseStartingPortBoundary(t *testing.T) { + cases := []struct { + name string + input string + wantOK bool + wantVal int + }{ + {"empty", "", false, 0}, + {"non-numeric", "abc", false, 0}, + {"below min", "1023", false, 0}, + {"at min", "1024", true, 1024}, + {"above max", "70000", false, 0}, + {"valid", "10000", true, 10000}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := NewGenerateModel(&fakeDiscovery{}, &fakeMutator{}, "c", "/tmp/x.yaml", true, nil) + m.startingPortStr = tc.input + got, ok := m.parseStartingPort() + if ok != tc.wantOK { + t.Fatalf("ok mismatch: want %v, got %v (err=%q)", tc.wantOK, ok, m.portError) + } + if ok && got != tc.wantVal { + t.Fatalf("val mismatch: want %d, got %d", tc.wantVal, got) + } + }) + } +} + +// TestGenerateModel_PortStepView ensures the port-step view renders without panic. +func TestGenerateModel_PortStepView(t *testing.T) { + m := readyModel(&fakeDiscovery{}, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil) + m.step = GenerateStepPortAssign + m.svcOrder = []ServiceCandidate{{Namespace: "ns", Service: "svc", Port: 80, Protocol: "TCP"}} + m.svcSelected[m.svcOrder[0].Key()] = true + view := m.View() + if !contains(view, "Step 3 / 3") { + t.Fatalf("expected step header in view, got: %s", view) + } + if !contains(view, "10000") { + t.Fatalf("expected default port in view, got: %s", view) + } +} + +// contains is a tiny strings.Contains wrapper that also gives a clearer test failure message. +func contains(s, sub string) bool { + return len(s) >= len(sub) && (sub == "" || stringIndex(s, sub) >= 0) +} + +func stringIndex(s, sub string) int { + if sub == "" { + return 0 + } + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +// Sanity: ensure the model satisfies tea.Model interface — compile-time check. +var _ tea.Model = (*GenerateModel)(nil) + +// Sanity: ensure existing key generation matches a manually-built one. +func TestServiceCandidate_KeyDeterministic(t *testing.T) { + c := ServiceCandidate{Namespace: "ns1", Service: "svc", Port: 80, Protocol: "TCP"} + want := fmt.Sprintf("%s|%s|%s|%d", "ns1", "service/svc", "tcp", 80) + if c.Key() != want { + t.Fatalf("Key() mismatch: want %q, got %q", want, c.Key()) + } +}