mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-11 00:09:31 +00:00
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.
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user