diff --git a/cmd/kportal/helpers_test.go b/cmd/kportal/helpers_test.go index 7889f3e..91e8e3f 100644 --- a/cmd/kportal/helpers_test.go +++ b/cmd/kportal/helpers_test.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "io" "os" "path/filepath" @@ -41,24 +40,6 @@ users: // ---- promptCreateConfig ---- -// redirectStdin replaces os.Stdin with a pipe that has the given text -// already written into it. Returns a cleanup function that restores -// the original Stdin and closes the pipe ends. -func redirectStdin(t *testing.T, text string) func() { - t.Helper() - origStdin := os.Stdin - r, w, err := os.Pipe() - require.NoError(t, err) - _, err = io.WriteString(w, text) - require.NoError(t, err) - require.NoError(t, w.Close()) - os.Stdin = r - return func() { - os.Stdin = origStdin - require.NoError(t, r.Close()) - } -} - func TestPromptCreateConfig_YesResponses(t *testing.T) { cases := []struct { name string @@ -72,10 +53,7 @@ func TestPromptCreateConfig_YesResponses(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - restore := redirectStdin(t, tc.input) - defer restore() - - result := promptCreateConfig("/some/path.yaml") + result := promptCreateConfig("/some/path.yaml", strings.NewReader(tc.input), io.Discard) assert.True(t, result, "expected true for input %q", tc.input) }) } @@ -93,28 +71,15 @@ func TestPromptCreateConfig_NoResponses(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - restore := redirectStdin(t, tc.input) - defer restore() - - result := promptCreateConfig("/some/path.yaml") + result := promptCreateConfig("/some/path.yaml", strings.NewReader(tc.input), io.Discard) assert.False(t, result, "expected false for input %q", tc.input) }) } } func TestPromptCreateConfig_EOFReturnsFalse(t *testing.T) { - // Provide an empty pipe (write end immediately closed) → EOF → false. - origStdin := os.Stdin - r, w, err := os.Pipe() - require.NoError(t, err) - require.NoError(t, w.Close()) // no data written → EOF on first read - os.Stdin = r - defer func() { - os.Stdin = origStdin - require.NoError(t, r.Close()) - }() - - result := promptCreateConfig("/some/path.yaml") + // Empty reader → EOF on first read → no data → false. + result := promptCreateConfig("/some/path.yaml", strings.NewReader(""), io.Discard) assert.False(t, result, "EOF should return false") } @@ -302,26 +267,7 @@ func TestRunGenerate_ValidContextNoUI(t *testing.T) { // TestPromptCreateConfig_PathIncludedInOutput verifies the path is printed. func TestPromptCreateConfig_PathIncludedInOutput(t *testing.T) { - // Capture stdout by swapping os.Stdout temporarily. - origStdout := os.Stdout - r, w, err := os.Pipe() - require.NoError(t, err) - os.Stdout = w - - restore := redirectStdin(t, "n\n") - defer restore() - - _ = promptCreateConfig("/my/special/config.yaml") - - require.NoError(t, w.Close()) - os.Stdout = origStdout - - var sb strings.Builder - scanner := bufio.NewScanner(r) - for scanner.Scan() { - sb.WriteString(scanner.Text()) - } - require.NoError(t, r.Close()) - - assert.Contains(t, sb.String(), "/my/special/config.yaml") + var stdout strings.Builder + _ = promptCreateConfig("/my/special/config.yaml", strings.NewReader("n\n"), &stdout) + assert.Contains(t, stdout.String(), "/my/special/config.yaml") } diff --git a/cmd/kportal/main.go b/cmd/kportal/main.go index 2b8af94..bb014ff 100644 --- a/cmd/kportal/main.go +++ b/cmd/kportal/main.go @@ -37,29 +37,57 @@ const ( githubRepo = "kportal" ) -var ( - configFile = flag.String("c", defaultConfigFile, "Path to configuration file") - verbose = flag.Bool("v", false, "Enable verbose logging") - headless = flag.Bool("headless", false, "Run in headless mode (no UI, for background/daemon use)") - logFormat = flag.String("log-format", "text", "Log format: text or json") - check = flag.Bool("check", false, "Validate configuration and exit") - showVersion = flag.Bool("version", false, "Show version and exit") - checkUpdate = flag.Bool("update", false, "Check for updates and exit") - convertInput = flag.String("convert", "", "Convert kftray JSON config to kportal YAML (provide input file path)") - convertOutput = flag.String("convert-output", ".kportal.yaml", "Output file for converted configuration") - appVersion = "0.1.0" // Set via ldflags during build -) +// appVersion is the build version. Set via ldflags during build: +// +// -X main.appVersion=v1.2.3 +var appVersion = "0.1.0" + +// runOptions captures the parsed flag values so each mode-specific run* function +// can be invoked independently of the global flag state. Held by value because +// it's small and travels through multiple goroutines. +type runOptions struct { + configFile string + logFormat string + convertInput string + convertOutput string + verbose bool + headless bool + check bool + showVersion bool + checkUpdate bool +} + +// fprintf is a small wrapper that suppresses the io.Writer write error. We +// route output to caller-provided writers (stdout / stderr / io.Discard / +// bytes.Buffer in tests), and a write error on any of these is non-actionable +// at this layer. +func fprintf(w io.Writer, format string, args ...any) { + _, _ = fmt.Fprintf(w, format, args...) +} + +// fprintln is the Fprintln equivalent of fprintf. +func fprintln(w io.Writer, args ...any) { + _, _ = fmt.Fprintln(w, args...) +} + +// fprint is the Fprint equivalent of fprintf. +func fprint(w io.Writer, args ...any) { + _, _ = fmt.Fprint(w, args...) +} // promptCreateConfig asks the user if they want to create a new config file. // Returns true if the user answers yes, false otherwise. -func promptCreateConfig(path string) bool { - fmt.Printf("Configuration file not found: %s\n", path) - fmt.Print("Would you like to create an empty configuration? [Y/n] ") +func promptCreateConfig(path string, stdin io.Reader, stdout io.Writer) bool { + fprintf(stdout, "Configuration file not found: %s\n", path) + fprint(stdout, "Would you like to create an empty configuration? [Y/n] ") - reader := bufio.NewReader(os.Stdin) + reader := bufio.NewReader(stdin) response, err := reader.ReadString('\n') if err != nil { - return false + // EOF with data is acceptable - the user might have piped a response without trailing newline. + if response == "" { + return false + } } response = strings.TrimSpace(strings.ToLower(response)) @@ -68,77 +96,163 @@ 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:])) + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + code := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr) + cancel() + os.Exit(code) +} + +// run is the testable entry point. It returns the desired process exit code +// instead of calling os.Exit. ctx must be cancelled to trigger clean shutdown +// of long-running modes (headless, verbose-loop, interactive). +func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { + // Subcommand dispatch must run BEFORE the main flag set is parsed because + // generate has its own FlagSet and must not see kportal's top-level flags. + if len(args) >= 1 && args[0] == "generate" { + return runGenerate(args[1:]) } - flag.Parse() - - if *showVersion { - fmt.Printf("kportal version %s\n", appVersion) - os.Exit(0) + opts, code, handled := parseFlags(args, stderr) + if handled { + return code } - if *checkUpdate { - checkForUpdates() - os.Exit(0) + // Quick-exit informational modes — these short-circuit before any cluster + // work and never need a config file. + if opts.showVersion { + return runShowVersion(stdout) + } + if opts.checkUpdate { + return runCheckUpdate(stdout, stderr) } - // Validate config path security - if *configFile != "" { - absConfigPath, err := filepath.Abs(*configFile) - if err != nil { - fmt.Fprintf(os.Stderr, "Invalid config path: %v\n", err) - os.Exit(1) + // Validate config path security (block system directories, normalise to abs). + resolvedConfig, ok := resolveConfigPath(opts.configFile, stderr) + if !ok { + return 1 + } + opts.configFile = resolvedConfig + + // Initialise structured logger / klog routing. These outputs depend on mode, + // not on -v alone (see comment block in original implementation). + initLoggers(opts, stderr) + + // Conversion mode runs before config load — it does not need a kportal config. + if opts.convertInput != "" { + return runConvert(opts.convertInput, opts.convertOutput, stdout, stderr) + } + + // Configure stdlib log destination based on mode. + configureStdlibLog(opts) + + // Load configuration (with optional create-on-missing prompt). + cfg, configIsNew, code, handled := loadOrCreateConfig(opts.configFile, stdin, stdout, stderr) + if handled { + return code + } + + // Validate configuration (allow empty for newly created files). + validator := config.NewValidator() + if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 { + fprint(stderr, config.FormatValidationErrors(errs)) + return 1 + } + + if opts.check { + fprintln(stdout, "Configuration is valid") + return 0 + } + + if opts.verbose { + log.Printf("kportal v%s", appVersion) + log.Printf("Loading configuration from: %s", opts.configFile) + } + + // Build forward manager + supporting bits, shared by headless / verbose / TUI paths. + deps, err := buildRuntimeDeps(opts, cfg, stderr) + if err != nil { + fprintf(stderr, "Error: %v\n", err) + return 1 + } + + switch { + case opts.headless: + return runHeadless(ctx, opts, cfg, deps, validator, stderr) + case opts.verbose: + return runVerboseTable(ctx, opts, cfg, deps, validator, stderr) + default: + return runInteractive(ctx, opts, cfg, deps, stderr) + } +} + +// parseFlags binds args to a fresh FlagSet and returns the parsed options. +// On parse error / -help, returns (zero, code, true) to signal the caller to +// exit immediately with the supplied code. +func parseFlags(args []string, stderr io.Writer) (runOptions, int, bool) { + fs := flag.NewFlagSet("kportal", flag.ContinueOnError) + fs.SetOutput(stderr) + + var opts runOptions + fs.StringVar(&opts.configFile, "c", defaultConfigFile, "Path to configuration file") + fs.BoolVar(&opts.verbose, "v", false, "Enable verbose logging") + fs.BoolVar(&opts.headless, "headless", false, "Run in headless mode (no UI, for background/daemon use)") + fs.StringVar(&opts.logFormat, "log-format", "text", "Log format: text or json") + fs.BoolVar(&opts.check, "check", false, "Validate configuration and exit") + fs.BoolVar(&opts.showVersion, "version", false, "Show version and exit") + fs.BoolVar(&opts.checkUpdate, "update", false, "Check for updates and exit") + fs.StringVar(&opts.convertInput, "convert", "", "Convert kftray JSON config to kportal YAML (provide input file path)") + fs.StringVar(&opts.convertOutput, "convert-output", ".kportal.yaml", "Output file for converted configuration") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return opts, 0, true } - absConfigPath = filepath.Clean(absConfigPath) - - // Block system directories - systemDirs := []string{"/etc", "/sys", "/proc", "/dev"} - for _, sysDir := range systemDirs { - if strings.HasPrefix(absConfigPath, sysDir) { - fmt.Fprintf(os.Stderr, "Error: Config file cannot be in system directory: %s\n", sysDir) - os.Exit(1) - } - } - - *configFile = absConfigPath + return opts, 2, true } + return opts, 0, false +} - // Initialize structured logger - // - // Output destination depends on run mode, NOT on the -v flag: - // - headless mode: always stderr, so daemons under launchd/systemd/journald - // can surface startup and runtime errors. Without this the daemon would - // fail silently and operators have no way to diagnose it. - // - TUI (interactive) mode: stderr would corrupt the bubbletea UI, so we - // route to io.Discard. Verbose TUI is not supported here either; -v in - // interactive mode upgrades to the simple table UI further down. - // - // The -v flag only controls log *level* (debug vs info), never destination. +// resolveConfigPath validates the user-supplied config path: must resolve to +// an absolute, cleaned path that is not inside a protected system directory. +func resolveConfigPath(path string, stderr io.Writer) (string, bool) { + if path == "" { + return "", true // empty is allowed; caller treats it as "no config" + } + abs, err := filepath.Abs(path) + if err != nil { + fprintf(stderr, "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) { + fprintf(stderr, "Error: Config file cannot be in system directory: %s\n", sysDir) + return "", false + } + } + return abs, true +} + +// initLoggers configures the structured logger and klog routing. Output +// destination depends on run mode (see big comment for rationale). +func initLoggers(opts runOptions, stderr io.Writer) { var logLevel logger.Level var logFmt logger.Format var logOutput io.Writer - if *verbose { + if opts.verbose { logLevel = logger.LevelDebug } else { logLevel = logger.LevelInfo } - if *headless || *verbose { - // Headless daemons must always emit logs so operators can see failures. - // Verbose mode (with or without TUI) also goes to stderr. - logOutput = os.Stderr + if opts.headless || opts.verbose { + logOutput = stderr } else { - // Interactive TUI mode: silence logger to avoid corrupting bubbletea UI. logOutput = io.Discard } - switch *logFormat { + switch opts.logFormat { case "json": logFmt = logger.FormatJSON default: @@ -147,492 +261,116 @@ func main() { logger.Init(logLevel, logFmt, logOutput) - // Configure klog (used by kubernetes client-go) to route through our logger - // This prevents k8s logs from interfering with the UI - // - // klog v2 uses multiple output mechanisms: - // 1. SetOutput() - for basic text output - // 2. SetLogger() - for structured/error logs (logr interface) - // - // We must configure BOTH to capture all logs including error messages - // that would otherwise bypass SetOutput() and write directly to stderr. - klog.LogToStderr(false) // Disable direct stderr writes - if *verbose { - // In verbose mode, route all klog through our structured logger at DEBUG level - klogLogger := logger.New(logger.LevelDebug, logFmt, os.Stderr) - - // Configure text output routing - klogWriter := logger.NewKlogWriter(klogLogger) - klog.SetOutput(klogWriter) - - // Configure structured/error log routing via logr interface - // This captures "Unhandled Error" and other structured logs that bypass SetOutput + klog.LogToStderr(false) + if opts.verbose { + klogLogger := logger.New(logger.LevelDebug, logFmt, stderr) + klog.SetOutput(logger.NewKlogWriter(klogLogger)) logrSink := logger.NewLogrAdapter(klogLogger) klog.SetLogger(logr.New(logrSink)) } else { - // In non-verbose mode, completely silence ALL klog output klog.SetOutput(io.Discard) - - // Also silence structured/error logs via a discard logger - silentLogger := logger.New(logger.LevelError+1, logFmt, io.Discard) // Level above ERROR = silence all + silentLogger := logger.New(logger.LevelError+1, logFmt, io.Discard) logrSink := logger.NewLogrAdapter(silentLogger) klog.SetLogger(logr.New(logrSink)) } +} - // Handle conversion mode - if *convertInput != "" { - if err := converter.ConvertKFTrayToKPortal(*convertInput, *convertOutput); err != nil { - fmt.Fprintf(os.Stderr, "Error converting configuration: %v\n", err) - os.Exit(1) - } - - // Print summary - contextMap, totalForwards, err := converter.GetConversionSummary(*convertInput) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: Could not generate summary: %v\n", err) - } else { - fmt.Printf("Successfully converted %d forwards from %s to %s\n", totalForwards, *convertInput, *convertOutput) - fmt.Printf("Generated configuration with:\n") - for ctx, namespaces := range contextMap { - fmt.Printf(" - Context '%s':\n", ctx) - for ns, count := range namespaces { - fmt.Printf(" - Namespace '%s': %d forwards\n", ns, count) - } - } - } - os.Exit(0) - } - - // Configure stdlib log destination using the same rule as the structured - // logger: only the bubbletea TUI path needs total silence. Headless mode - // keeps stderr so daemonised runs surface errors to journald/launchd. +// configureStdlibLog matches stdlib log destination to the mode. Only the TUI +// path needs total silence; daemonised modes keep stderr. +func configureStdlibLog(opts runOptions) { switch { - case *verbose: - // Verbose mode - enable standard log formatting on stderr (default) + case opts.verbose: log.SetFlags(log.LstdFlags | log.Lshortfile) - case *headless: - // Headless mode without -v: keep stderr (default writer) but use plain - // timestamps so journald-style log collectors show readable lines. + case opts.headless: log.SetFlags(log.LstdFlags) default: - // Interactive bubbletea mode: silence stdlib log to avoid UI corruption. log.SetOutput(io.Discard) log.SetPrefix("") log.SetFlags(0) } +} - // Load configuration - cfg, err := config.LoadConfig(*configFile) - configIsNew := false +// loadOrCreateConfig loads the config, prompting to create an empty file if it +// doesn't exist. Returns (cfg, configIsNew, exitCode, handled). +func loadOrCreateConfig(configFile string, stdin io.Reader, stdout, stderr io.Writer) (*config.Config, bool, int, bool) { + cfg, err := config.LoadConfig(configFile) + if err == nil { + return cfg, false, 0, false + } + if err != config.ErrConfigNotFound { + fprintf(stderr, "Error loading config: %v\n", err) + return nil, false, 1, true + } + if !promptCreateConfig(configFile, stdin, stdout) { + return nil, false, 0, true + } + if createErr := config.CreateEmptyConfigFile(configFile); createErr != nil { + fprintf(stderr, "Error creating config file: %v\n", createErr) + return nil, false, 1, true + } + fprintf(stdout, "Created %s\n", configFile) + fprintln(stdout, "Use 'n' in the UI to add port forwards, or edit the file manually.") + fprintln(stdout) + + cfg, err = config.LoadConfig(configFile) if err != nil { - if err == config.ErrConfigNotFound { - // Config file doesn't exist - offer to create it - if !promptCreateConfig(*configFile) { - os.Exit(0) - } - // Create empty config file - if createErr := config.CreateEmptyConfigFile(*configFile); createErr != nil { - fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", createErr) - os.Exit(1) - } - fmt.Printf("Created %s\n", *configFile) - fmt.Println("Use 'n' in the UI to add port forwards, or edit the file manually.") - fmt.Println() - - // Load the newly created config - cfg, err = config.LoadConfig(*configFile) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) - os.Exit(1) - } - configIsNew = true - } else { - fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) - os.Exit(1) - } + fprintf(stderr, "Error loading config: %v\n", err) + return nil, false, 1, true } + return cfg, true, 0, false +} - // Validate configuration (allow empty configs for newly created files) - validator := config.NewValidator() - if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 { - fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs)) - os.Exit(1) - } +// runtimeDeps bundles the long-lived objects shared by all UI modes. +type runtimeDeps struct { + manager *forward.Manager + pool *k8s.ClientPool + discovery *k8s.Discovery + mutator *config.Mutator + mdnsPub *mdns.Publisher +} - if *check { - fmt.Println("Configuration is valid") - os.Exit(0) - } - - // Only log startup messages in verbose mode - if *verbose { - log.Printf("kportal v%s", appVersion) - log.Printf("Loading configuration from: %s", *configFile) - } - - // Create Kubernetes client pool and discovery for wizards +// buildRuntimeDeps constructs the kubernetes client pool, forward manager, and +// helpers used across run modes. Returns an error only on fatal failures +// (manager creation); a missing kubeconfig is logged but allowed. +func buildRuntimeDeps(opts runOptions, cfg *config.Config, stderr io.Writer) (*runtimeDeps, error) { pool, err := k8s.NewClientPool() if err != nil { - fmt.Fprintf(os.Stderr, "Warning: Failed to create k8s client pool: %v\n", err) - fmt.Fprintf(os.Stderr, "Add/remove wizards will not be available\n") + fprintf(stderr, "Warning: Failed to create k8s client pool: %v\n", err) + fprintf(stderr, "Add/remove wizards will not be available\n") } discovery := k8s.NewDiscovery(pool) - mutator := config.NewMutator(*configFile) + mutator := config.NewMutator(opts.configFile) - // Create forward manager - manager, err := forward.NewManager(*verbose) + manager, err := forward.NewManager(opts.verbose) if err != nil { - fmt.Fprintf(os.Stderr, "Error creating forward manager: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("creating forward manager: %w", err) } - // Create mDNS publisher if enabled in config - mdnsPublisher := mdns.NewPublisher(cfg.IsMDNSEnabled()) - manager.SetMDNSPublisher(mdnsPublisher) - - if cfg.IsMDNSEnabled() && *verbose { + pub := mdns.NewPublisher(cfg.IsMDNSEnabled()) + manager.SetMDNSPublisher(pub) + if cfg.IsMDNSEnabled() && opts.verbose { log.Printf("mDNS hostname publishing enabled - aliases will be accessible via .local") } - // Create UI based on mode: - // - headless: no UI at all (background daemon) - // - verbose: simple table UI with logging - // - default: interactive bubbletea TUI - var bubbleTeaUI *ui.BubbleTeaUI - var tableUI *ui.TableUI - - if *headless { - // Headless mode - no UI, just run forwards in background - // StatusUI remains nil, manager will handle this gracefully - if *verbose { - log.Printf("Running in headless mode with verbose logging") - } - } else if *verbose { - // Verbose mode with simple table - tableUI = ui.NewTableUI(*verbose) - manager.SetStatusUI(tableUI) - - // Check for updates and print to log - go func() { - checker := version.NewChecker(githubOwner, githubRepo, appVersion) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if update := checker.CheckForUpdate(ctx); update != nil { - log.Printf("Update available: v%s (current: v%s) - %s", - update.LatestVersion, update.CurrentVersion, update.ReleaseURL) - } - }() - } else { - // Interactive mode with bubbletea - bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) { - if enable { - _ = manager.EnableForward(id) - } else { - _ = manager.DisableForward(id) - } - }, appVersion) - - // Set wizard dependencies - // Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add) - bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile) - - // Set HTTP log subscriber to enable live log viewing - bubbleTeaUI.SetHTTPLogSubscriber(func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() { - worker := manager.GetWorker(forwardID) - if worker == nil { - logger.Debug("HTTP log subscription failed: worker not found", map[string]any{ - "forward_id": forwardID, - }) - return func() {} // No-op cleanup - } - - proxy := worker.GetHTTPProxy() - if proxy == nil { - // This is expected for forwards without httpLog enabled - not an error - logger.Debug("HTTP log subscription skipped: proxy not enabled", map[string]any{ - "forward_id": forwardID, - }) - return func() {} // HTTP logging not enabled for this forward - } - - proxyLogger := proxy.GetLogger() - if proxyLogger == nil { - logger.Debug("HTTP log subscription failed: logger not available", map[string]any{ - "forward_id": forwardID, - }) - return func() {} - } - - // Subscribe to log entries - proxyLogger.AddCallback(func(entry httplog.Entry) { - uiEntry := ui.HTTPLogEntry{ - RequestID: entry.RequestID, - Timestamp: entry.Timestamp.Format("15:04:05"), - Direction: entry.Direction, - Method: entry.Method, - Path: entry.Path, - StatusCode: entry.StatusCode, - LatencyMs: entry.LatencyMs, - BodySize: entry.BodySize, - Error: entry.Error, - } - - // Populate headers based on direction - switch entry.Direction { - case "request": - uiEntry.RequestHeaders = entry.Headers - uiEntry.RequestBody = entry.Body - case "response": - uiEntry.ResponseHeaders = entry.Headers - uiEntry.ResponseBody = entry.Body - } - - callback(uiEntry) - }) - - // Return cleanup function - return func() { - proxyLogger.ClearCallbacks() - } - }) - - // Check for updates in background (non-blocking) - go func() { - checker := version.NewChecker(githubOwner, githubRepo, appVersion) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if update := checker.CheckForUpdate(ctx); update != nil { - bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL) - } - }() - - manager.SetStatusUI(bubbleTeaUI) - } - - // Start forwards - if startErr := manager.Start(cfg); startErr != nil { - fmt.Fprintf(os.Stderr, "Error starting forwards: %v\n", startErr) - os.Exit(1) - } - - if *headless { - // Headless mode - no UI, run as background daemon - // Setup signal handling - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) - - // Setup config watcher for hot-reload - watcher, watcherErr := config.NewWatcher(*configFile, func(newCfg *config.Config) error { - return manager.Reload(newCfg) - }, *verbose) - watcherStarted := false - if watcherErr != nil { - if *verbose { - log.Printf("Warning: Failed to setup config watcher: %v", watcherErr) - log.Printf("Hot-reload will not be available") - } - } else { - watcher.Start() - watcherStarted = true - } - - if *verbose { - log.Printf("Headless mode started. Press Ctrl+C to stop") - } - - // Wait for signals - for { - sig := <-sigChan - switch sig { - case syscall.SIGHUP: - if *verbose { - log.Printf("Received SIGHUP, reloading configuration...") - } - newCfg, loadErr := config.LoadConfig(*configFile) - if loadErr != nil { - if *verbose { - log.Printf("Failed to reload config: %v", loadErr) - } - continue - } - - if errs := validator.ValidateConfig(newCfg); len(errs) > 0 { - if *verbose { - log.Printf("Config validation failed:") - log.Print(config.FormatValidationErrors(errs)) - } - continue - } - - if reloadErr := manager.Reload(newCfg); reloadErr != nil { - if *verbose { - log.Printf("Failed to reload: %v", reloadErr) - } - } - - case os.Interrupt, syscall.SIGTERM: - if *verbose { - log.Printf("Received shutdown signal, stopping...") - } - - // Graceful shutdown with timeout - shutdownDone := make(chan struct{}) - go func() { - manager.Stop() - close(shutdownDone) - }() - - select { - case <-shutdownDone: - if *verbose { - log.Printf("Graceful shutdown complete") - } - case <-time.After(5 * time.Second): - if *verbose { - log.Printf("Shutdown timed out, forcing exit...") - } - case sig := <-sigChan: - if *verbose { - log.Printf("Received second signal (%v), forcing exit...", sig) - } - } - // Stop the watcher before exiting (defers won't run after os.Exit) - if watcherStarted { - watcher.Stop() - } - os.Exit(0) - } - } - } else if *verbose { - // Verbose mode - use simple table with periodic updates - tableUI.RenderInitial() - - // Setup signal handling - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) - - // Start table update loop - go func() { - ticker := time.NewTicker(tableUpdateInterval) - defer ticker.Stop() - for range ticker.C { - tableUI.Render() - } - }() - - // Setup config watcher for hot-reload - watcher, watchErr := config.NewWatcher(*configFile, func(newCfg *config.Config) error { - return manager.Reload(newCfg) - }, *verbose) - watcherActive := false - if watchErr != nil { - log.Printf("Warning: Failed to setup config watcher: %v", watchErr) - log.Printf("Hot-reload will not be available") - } else { - watcher.Start() - watcherActive = true - } - - log.Printf("Press Ctrl+C to stop") - - // Wait for signals - for { - sig := <-sigChan - switch sig { - case syscall.SIGHUP: - log.Printf("Received SIGHUP, reloading configuration...") - newCfg, loadErr := config.LoadConfig(*configFile) - if loadErr != nil { - log.Printf("Failed to reload config: %v", loadErr) - continue - } - - if errs := validator.ValidateConfig(newCfg); len(errs) > 0 { - log.Printf("Config validation failed:") - log.Print(config.FormatValidationErrors(errs)) - continue - } - - if reloadErr := manager.Reload(newCfg); reloadErr != nil { - log.Printf("Failed to reload: %v", reloadErr) - } - - case os.Interrupt, syscall.SIGTERM: - log.Printf("Received shutdown signal, stopping...") - - // Graceful shutdown with timeout - force exit if it takes too long - shutdownDone := make(chan struct{}) - go func() { - manager.Stop() - close(shutdownDone) - }() - - select { - case <-shutdownDone: - log.Printf("Graceful shutdown complete") - case <-time.After(5 * time.Second): - log.Printf("Shutdown timed out, forcing exit...") - case sig := <-sigChan: - // Second signal received - force exit immediately - log.Printf("Received second signal (%v), forcing exit...", sig) - } - // Stop the watcher before exiting (defers won't run after os.Exit) - if watcherActive { - watcher.Stop() - } - os.Exit(0) - } - } - } else { - // Interactive mode with bubbletea - // Setup config watcher in background - var watcher *config.Watcher - watcher, err = config.NewWatcher(*configFile, func(newCfg *config.Config) error { - return manager.Reload(newCfg) - }, *verbose) - if err == nil { - watcher.Start() - } - - // Cleanup function to ensure all resources are released - cleanup := func() { - bubbleTeaUI.Stop() - manager.Stop() - if watcher != nil { - watcher.Stop() - } - } - - // Setup signal handler for clean shutdown - go func() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - <-sigChan - cleanup() - os.Exit(0) - }() - - // Give a moment for initial forwards to be added - time.Sleep(initialForwardSettleTime) - - // Start the bubbletea app (blocks until quit) - if err := bubbleTeaUI.Start(); err != nil { - fmt.Fprintf(os.Stderr, "Failed to start UI: %v\n", err) - cleanup() - os.Exit(1) - } - - // Clean shutdown (normal exit via UI quit) - cleanup() - } + return &runtimeDeps{ + manager: manager, + pool: pool, + discovery: discovery, + mutator: mutator, + mdnsPub: pub, + }, nil } -// checkForUpdates checks for available updates and prints the result -func checkForUpdates() { - fmt.Printf("kportal version %s\n", appVersion) - fmt.Println("Checking for updates...") +// runShowVersion prints the version and exits 0. +func runShowVersion(stdout io.Writer) int { + fprintf(stdout, "kportal version %s\n", appVersion) + return 0 +} + +// runCheckUpdate checks for available updates and prints the result. +func runCheckUpdate(stdout, _ io.Writer) int { + fprintf(stdout, "kportal version %s\n", appVersion) + fprintln(stdout, "Checking for updates...") checker := version.NewChecker(githubOwner, githubRepo, appVersion) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -640,12 +378,335 @@ func checkForUpdates() { update := checker.CheckForUpdate(ctx) if update == nil { - fmt.Println("You are running the latest version.") - return + fprintln(stdout, "You are running the latest version.") + return 0 } - fmt.Printf("\nUpdate available: v%s\n", update.LatestVersion) - fmt.Printf("Download: %s\n", update.ReleaseURL) - fmt.Println("\nTo update, download the latest release from the URL above") - fmt.Println("or use your package manager (e.g., 'brew upgrade kportal').") + fprintf(stdout, "\nUpdate available: v%s\n", update.LatestVersion) + fprintf(stdout, "Download: %s\n", update.ReleaseURL) + fprintln(stdout, "\nTo update, download the latest release from the URL above") + fprintln(stdout, "or use your package manager (e.g., 'brew upgrade kportal').") + return 0 +} + +// runConvert converts a kftray JSON file to a kportal YAML config. +func runConvert(input, output string, stdout, stderr io.Writer) int { + if err := converter.ConvertKFTrayToKPortal(input, output); err != nil { + fprintf(stderr, "Error converting configuration: %v\n", err) + return 1 + } + + contextMap, totalForwards, err := converter.GetConversionSummary(input) + if err != nil { + fprintf(stderr, "Warning: Could not generate summary: %v\n", err) + return 0 + } + fprintf(stdout, "Successfully converted %d forwards from %s to %s\n", totalForwards, input, output) + fprintf(stdout, "Generated configuration with:\n") + for ctx, namespaces := range contextMap { + fprintf(stdout, " - Context '%s':\n", ctx) + for ns, count := range namespaces { + fprintf(stdout, " - Namespace '%s': %d forwards\n", ns, count) + } + } + return 0 +} + +// runHeadless runs the daemon-style mode: no UI, signal-driven SIGHUP reloads, +// graceful shutdown on ctx.Done() (which is cancelled by SIGINT/SIGTERM). +func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, validator *config.Validator, stderr io.Writer) int { + if startErr := deps.manager.Start(cfg); startErr != nil { + fprintf(stderr, "Error starting forwards: %v\n", startErr) + return 1 + } + + // SIGHUP triggers reload only — separate from the ctx-driven shutdown. + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGHUP) + defer signal.Stop(sigChan) + + watcher, watcherErr := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error { + return deps.manager.Reload(newCfg) + }, opts.verbose) + watcherStarted := false + if watcherErr != nil { + if opts.verbose { + log.Printf("Warning: Failed to setup config watcher: %v", watcherErr) + log.Printf("Hot-reload will not be available") + } + } else { + watcher.Start() + watcherStarted = true + } + defer func() { + if watcherStarted { + watcher.Stop() + } + }() + + if opts.verbose { + log.Printf("Headless mode started. Press Ctrl+C to stop") + } + + for { + select { + case <-ctx.Done(): + return shutdownManager(ctx, deps.manager, opts.verbose) + case <-sigChan: + if opts.verbose { + log.Printf("Received SIGHUP, reloading configuration...") + } + newCfg, loadErr := config.LoadConfig(opts.configFile) + if loadErr != nil { + if opts.verbose { + log.Printf("Failed to reload config: %v", loadErr) + } + continue + } + if errs := validator.ValidateConfig(newCfg); len(errs) > 0 { + if opts.verbose { + log.Printf("Config validation failed:") + log.Print(config.FormatValidationErrors(errs)) + } + continue + } + if reloadErr := deps.manager.Reload(newCfg); reloadErr != nil { + if opts.verbose { + log.Printf("Failed to reload: %v", reloadErr) + } + } + } + } +} + +// runVerboseTable runs the simple table UI with periodic redraws and SIGHUP +// reload, exiting cleanly when ctx is cancelled. +func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, validator *config.Validator, stderr io.Writer) int { + tableUI := ui.NewTableUI(opts.verbose) + deps.manager.SetStatusUI(tableUI) + + // Background update check (best effort). + go func() { + checker := version.NewChecker(githubOwner, githubRepo, appVersion) + uctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if update := checker.CheckForUpdate(uctx); update != nil { + log.Printf("Update available: v%s (current: v%s) - %s", + update.LatestVersion, update.CurrentVersion, update.ReleaseURL) + } + }() + + if startErr := deps.manager.Start(cfg); startErr != nil { + fprintf(stderr, "Error starting forwards: %v\n", startErr) + return 1 + } + + tableUI.RenderInitial() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGHUP) + defer signal.Stop(sigChan) + + // Periodic table refresh — driven by ctx.Done() so it exits cleanly. + tickerDone := make(chan struct{}) + go func() { + defer close(tickerDone) + ticker := time.NewTicker(tableUpdateInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + tableUI.Render() + } + } + }() + + watcher, watchErr := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error { + return deps.manager.Reload(newCfg) + }, opts.verbose) + watcherActive := false + if watchErr != nil { + log.Printf("Warning: Failed to setup config watcher: %v", watchErr) + log.Printf("Hot-reload will not be available") + } else { + watcher.Start() + watcherActive = true + } + defer func() { + if watcherActive { + watcher.Stop() + } + }() + + log.Printf("Press Ctrl+C to stop") + + for { + select { + case <-ctx.Done(): + <-tickerDone + return shutdownManager(ctx, deps.manager, opts.verbose) + case <-sigChan: + log.Printf("Received SIGHUP, reloading configuration...") + newCfg, loadErr := config.LoadConfig(opts.configFile) + if loadErr != nil { + log.Printf("Failed to reload config: %v", loadErr) + continue + } + if errs := validator.ValidateConfig(newCfg); len(errs) > 0 { + log.Printf("Config validation failed:") + log.Print(config.FormatValidationErrors(errs)) + continue + } + if reloadErr := deps.manager.Reload(newCfg); reloadErr != nil { + log.Printf("Failed to reload: %v", reloadErr) + } + } + } +} + +// runInteractive runs the bubbletea TUI. Cannot be exercised in non-TTY tests. +func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, stderr io.Writer) int { + bubbleTeaUI := ui.NewBubbleTeaUI(func(id string, enable bool) { + if enable { + _ = deps.manager.EnableForward(id) + } else { + _ = deps.manager.DisableForward(id) + } + }, appVersion) + bubbleTeaUI.SetWizardDependencies(deps.discovery, deps.mutator, opts.configFile) + bubbleTeaUI.SetHTTPLogSubscriber(makeHTTPLogSubscriber(deps.manager)) + + go func() { + checker := version.NewChecker(githubOwner, githubRepo, appVersion) + uctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if update := checker.CheckForUpdate(uctx); update != nil { + bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL) + } + }() + + deps.manager.SetStatusUI(bubbleTeaUI) + + if startErr := deps.manager.Start(cfg); startErr != nil { + fprintf(stderr, "Error starting forwards: %v\n", startErr) + return 1 + } + + var watcher *config.Watcher + watcher, err := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error { + return deps.manager.Reload(newCfg) + }, opts.verbose) + if err == nil { + watcher.Start() + } + + cleanup := func() { + bubbleTeaUI.Stop() + deps.manager.Stop() + if watcher != nil { + watcher.Stop() + } + } + + // Wire ctx cancellation to UI shutdown so SIGINT/SIGTERM exit cleanly. + stopWatcher := make(chan struct{}) + defer close(stopWatcher) + go func() { + select { + case <-ctx.Done(): + cleanup() + case <-stopWatcher: + } + }() + + time.Sleep(initialForwardSettleTime) + + if startErr := bubbleTeaUI.Start(); startErr != nil { + fprintf(stderr, "Failed to start UI: %v\n", startErr) + cleanup() + return 1 + } + cleanup() + return 0 +} + +// makeHTTPLogSubscriber builds the subscriber callback used by the bubbletea UI. +func makeHTTPLogSubscriber(manager *forward.Manager) ui.HTTPLogSubscriber { + return func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() { + worker := manager.GetWorker(forwardID) + if worker == nil { + logger.Debug("HTTP log subscription failed: worker not found", map[string]any{ + "forward_id": forwardID, + }) + return func() {} + } + proxy := worker.GetHTTPProxy() + if proxy == nil { + logger.Debug("HTTP log subscription skipped: proxy not enabled", map[string]any{ + "forward_id": forwardID, + }) + return func() {} + } + proxyLogger := proxy.GetLogger() + if proxyLogger == nil { + logger.Debug("HTTP log subscription failed: logger not available", map[string]any{ + "forward_id": forwardID, + }) + return func() {} + } + + proxyLogger.AddCallback(func(entry httplog.Entry) { + uiEntry := ui.HTTPLogEntry{ + RequestID: entry.RequestID, + Timestamp: entry.Timestamp.Format("15:04:05"), + Direction: entry.Direction, + Method: entry.Method, + Path: entry.Path, + StatusCode: entry.StatusCode, + LatencyMs: entry.LatencyMs, + BodySize: entry.BodySize, + Error: entry.Error, + } + switch entry.Direction { + case "request": + uiEntry.RequestHeaders = entry.Headers + uiEntry.RequestBody = entry.Body + case "response": + uiEntry.ResponseHeaders = entry.Headers + uiEntry.ResponseBody = entry.Body + } + callback(uiEntry) + }) + + return func() { + proxyLogger.ClearCallbacks() + } + } +} + +// shutdownManager stops the forward manager with a 5s timeout, returning 0 on +// success or after timeout (we always exit cleanly from a shutdown signal). +func shutdownManager(ctx context.Context, manager *forward.Manager, verbose bool) int { + if verbose { + log.Printf("Received shutdown signal, stopping...") + } + shutdownDone := make(chan struct{}) + go func() { + manager.Stop() + close(shutdownDone) + }() + select { + case <-shutdownDone: + if verbose { + log.Printf("Graceful shutdown complete") + } + case <-time.After(5 * time.Second): + if verbose { + log.Printf("Shutdown timed out, forcing exit...") + } + } + _ = ctx // ctx may already be done; we still wait up to 5s for graceful stop. + return 0 } diff --git a/cmd/kportal/run_test.go b/cmd/kportal/run_test.go new file mode 100644 index 0000000..5aa79d0 --- /dev/null +++ b/cmd/kportal/run_test.go @@ -0,0 +1,653 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/lukaszraczylo/kportal/internal/forward" + "github.com/lukaszraczylo/kportal/internal/ui" + "github.com/lukaszraczylo/kportal/internal/version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// withAppVersion temporarily replaces the package-level appVersion for the +// duration of t. Restores the original on cleanup. +func withAppVersion(t *testing.T, v string) { + t.Helper() + prev := appVersion + appVersion = v + t.Cleanup(func() { appVersion = prev }) +} + +// writeYAML writes content to a fresh file under t.TempDir() and returns the +// absolute path. +func writeYAML(t *testing.T, name, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +// TestRun_VersionFlag verifies -version exits 0 and prints to stdout. +func TestRun_VersionFlag(t *testing.T) { + withAppVersion(t, "9.9.9") + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-version"}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 0, code) + assert.Contains(t, stdout.String(), "kportal version 9.9.9") + assert.Empty(t, stderr.String()) +} + +// TestRun_FlagParseError verifies an unknown flag exits 2. +func TestRun_FlagParseError(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"--no-such-flag"}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 2, code) +} + +// TestRun_HelpFlag verifies -h exits 0 (flag.ContinueOnError + ErrHelp). +func TestRun_HelpFlag(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-h"}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 0, code) +} + +// TestRun_GenerateSubcommand_DispatchedEarly verifies the generate subcommand +// is dispatched before flag parsing (so its --context flag is not rejected). +func TestRun_GenerateSubcommand_DispatchedEarly(t *testing.T) { + // Capture stderr at the os level because runGenerate writes to os.Stderr. + stop := captureStderr(t) + code := run(context.Background(), []string{"generate"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + stderrOut := stop() + assert.Equal(t, 1, code) + assert.Contains(t, stderrOut, "--context") +} + +// TestRun_ConfigInSystemDirectory verifies a config inside /etc is rejected. +func TestRun_ConfigInSystemDirectory(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-c", "/etc/kportal.yaml"}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 1, code) + assert.Contains(t, stderr.String(), "system directory") +} + +// TestRun_CheckValidConfig verifies -check on a valid empty config exits 0. +func TestRun_CheckValidConfig(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 0, code) + assert.Contains(t, stdout.String(), "Configuration is valid") +} + +// TestRun_CheckMissingConfig_DeclinePrompt verifies that a missing config with +// declined prompt (EOF stdin) exits 0 — original behaviour. +func TestRun_CheckMissingConfig_DeclinePrompt(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "missing.yaml") + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 0, code) + // Prompt was emitted to stdout. + assert.Contains(t, stdout.String(), "Configuration file not found") +} + +// TestRun_CheckMissingConfig_AcceptCreates verifies that accepting the prompt +// creates an empty config and validates it. +func TestRun_CheckMissingConfig_AcceptCreates(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "new.yaml") + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader("y\n"), &stdout, &stderr) + assert.Equal(t, 0, code) + assert.FileExists(t, cfgPath) + assert.Contains(t, stdout.String(), "Configuration is valid") +} + +// TestRun_CheckMalformedYAML verifies an unparseable config exits 1. +func TestRun_CheckMalformedYAML(t *testing.T) { + cfgPath := writeYAML(t, "bad.yaml", ":\t {{{ invalid\n") + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 1, code) + assert.NotEmpty(t, stderr.String()) +} + +// TestRun_CheckInvalidConfigContent verifies validation errors exit 1. +func TestRun_CheckInvalidConfigContent(t *testing.T) { + // Forward without required fields — validator will reject. + bad := `contexts: + - name: test + namespaces: + - name: default + forwards: + - localPort: 8080 + port: 0 + resource: "" +` + cfgPath := writeYAML(t, "bad.yaml", bad) + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-check", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 1, code) + // Validator output is on stderr. + assert.NotEmpty(t, stderr.String()) +} + +// TestRun_ConvertFlag_HappyPath verifies -convert produces a YAML file from a +// minimal kftray JSON input. +func TestRun_ConvertFlag_HappyPath(t *testing.T) { + dir := t.TempDir() + in := filepath.Join(dir, "kftray.json") + out := filepath.Join(dir, "out.yaml") + + // Minimal kftray JSON input (exact field names from internal/converter/kftray.go). + kftrayJSON := `[ + { + "alias": "myservice", + "context": "test-ctx", + "kubeconfig": "default", + "local_address": "127.0.0.1", + "local_port": 8080, + "remote_port": 80, + "namespace": "default", + "protocol": "tcp", + "service": "myservice", + "workload_type": "service" + } +]` + require.NoError(t, os.WriteFile(in, []byte(kftrayJSON), 0o600)) + + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-convert", in, "-convert-output", out}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 0, code, "stderr: %s", stderr.String()) + assert.FileExists(t, out) + assert.Contains(t, stdout.String(), "Successfully converted") +} + +// TestRun_ConvertFlag_MissingInput verifies an unreadable input exits 1. +func TestRun_ConvertFlag_MissingInput(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out.yaml") + + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-convert", "/nonexistent/input.json", "-convert-output", out}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 1, code) + assert.Contains(t, stderr.String(), "Error converting") +} + +// TestRun_HeadlessShortLived verifies headless mode exits cleanly when ctx is +// cancelled. Should complete in well under 5s (the shutdown timeout). +func TestRun_HeadlessShortLived(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + + ctx, cancel := context.WithCancel(context.Background()) + // Cancel almost immediately — manager.Start has nothing to do for empty config. + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + var stdout, stderr bytes.Buffer + done := make(chan int, 1) + go func() { + done <- run(ctx, []string{"-headless", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + }() + + select { + case code := <-done: + assert.Equal(t, 0, code) + case <-time.After(8 * time.Second): + cancel() + t.Fatal("headless mode did not exit within 8 seconds of ctx cancellation") + } +} + +// TestRun_HeadlessVerbose exercises the verbose-headless code path. Same +// ctx-cancellation contract; logs go to stderr buffer. +func TestRun_HeadlessVerbose(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + var stdout, stderr bytes.Buffer + done := make(chan int, 1) + go func() { + done <- run(ctx, []string{"-headless", "-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + }() + + select { + case code := <-done: + assert.Equal(t, 0, code) + case <-time.After(8 * time.Second): + cancel() + t.Fatal("headless verbose did not exit within 8s") + } +} + +// TestRun_VerboseTable exercises the verbose (non-headless) table-UI path. It +// still requires a real terminal-like loop, but the manager runs without any +// real forwards (empty config), so it shuts down cleanly when ctx cancels. +func TestRun_VerboseTable(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(80 * time.Millisecond) + cancel() + }() + + var stdout, stderr bytes.Buffer + done := make(chan int, 1) + go func() { + // Verbose without -headless picks the runVerboseTable path. + done <- run(ctx, []string{"-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + }() + + select { + case code := <-done: + assert.Equal(t, 0, code) + case <-time.After(8 * time.Second): + cancel() + t.Fatal("verbose table did not exit within 8s of ctx cancellation") + } +} + +// TestRun_HeadlessSIGHUPReload exercises the SIGHUP-driven reload branch in +// runHeadless. Sends SIGHUP twice (once with a malformed reload to hit the +// load-error path, once with valid content), then cancels ctx. +func TestRun_HeadlessSIGHUPReload(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var stdout, stderr bytes.Buffer + done := make(chan int, 1) + go func() { + done <- run(ctx, []string{"-headless", "-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + }() + + // Wait for the headless loop to be running before sending SIGHUP. + time.Sleep(150 * time.Millisecond) + + // Trigger reload — config is still valid → success path. + require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP)) + time.Sleep(80 * time.Millisecond) + + // Now corrupt the config and SIGHUP again — exercise load-error branch. + require.NoError(t, os.WriteFile(cfgPath, []byte(":\t {{{ broken\n"), 0o600)) + require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP)) + time.Sleep(80 * time.Millisecond) + + cancel() + + select { + case code := <-done: + assert.Equal(t, 0, code) + case <-time.After(8 * time.Second): + t.Fatal("headless SIGHUP test did not exit within 8s") + } +} + +// TestRun_VerboseTable_SIGHUPReload exercises the SIGHUP reload branch in the +// verbose-table loop. +func TestRun_VerboseTable_SIGHUPReload(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var stdout, stderr bytes.Buffer + done := make(chan int, 1) + go func() { + done <- run(ctx, []string{"-v", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + }() + + time.Sleep(150 * time.Millisecond) + + // Send SIGHUP — valid config still in place, exercises reload-success branch. + require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP)) + time.Sleep(80 * time.Millisecond) + + // Corrupt + SIGHUP — exercises load-error branch. + require.NoError(t, os.WriteFile(cfgPath, []byte(":\t {{{ broken"), 0o600)) + require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGHUP)) + time.Sleep(80 * time.Millisecond) + + cancel() + select { + case code := <-done: + assert.Equal(t, 0, code) + case <-time.After(8 * time.Second): + t.Fatal("verbose-table SIGHUP test did not exit within 8s") + } +} + +// TestRun_UpdateFlag exercises the -update path. Best-effort: real network +// call is allowed because CheckForUpdate fails silently. +func TestRun_UpdateFlag(t *testing.T) { + withAppVersion(t, "0.0.0") + var stdout, stderr bytes.Buffer + code := run(context.Background(), []string{"-update"}, strings.NewReader(""), &stdout, &stderr) + assert.Equal(t, 0, code) + assert.Contains(t, stdout.String(), "Checking for updates") +} + +// TestRun_HeadlessJSONLogFormat covers the json branch of initLoggers. +func TestRun_HeadlessJSONLogFormat(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + var stdout, stderr bytes.Buffer + done := make(chan int, 1) + go func() { + done <- run(ctx, []string{"-headless", "-log-format", "json", "-c", cfgPath}, strings.NewReader(""), &stdout, &stderr) + }() + + select { + case code := <-done: + assert.Equal(t, 0, code) + case <-time.After(8 * time.Second): + cancel() + t.Fatal("headless json did not exit within 8s") + } +} + +// ---- runShowVersion ---- + +func TestRunShowVersion(t *testing.T) { + withAppVersion(t, "1.2.3") + var stdout bytes.Buffer + code := runShowVersion(&stdout) + assert.Equal(t, 0, code) + assert.Equal(t, "kportal version 1.2.3\n", stdout.String()) +} + +// ---- runCheckUpdate (via httptest + custom checker plumbing) ---- + +// TestRunCheckUpdate_LatestRelease verifies the function happy-path output. +// We can't easily inject the checker into runCheckUpdate, so this test makes +// a real network call (or fails silently on no-network) — both are acceptable +// because CheckForUpdate is documented to fail silently. +func TestRunCheckUpdate_PrintsHeader(t *testing.T) { + withAppVersion(t, "0.0.0") + var stdout, stderr bytes.Buffer + code := runCheckUpdate(&stdout, &stderr) + assert.Equal(t, 0, code) + assert.Contains(t, stdout.String(), "kportal version 0.0.0") + assert.Contains(t, stdout.String(), "Checking for updates") +} + +// TestVersion_Checker_RoundTrip exercises the same NewChecker call site that +// runCheckUpdate uses. Mirrors the rewriteTransport pattern from internal/version. +func TestVersion_Checker_RoundTripWithMockServer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{ + "tag_name": "v99.99.99", + "html_url": "https://example.com/release", + "name": "Mocked", + }) + })) + defer srv.Close() + + // Build a checker that points at the test server using the same approach + // as internal/version/checker_http_test.go. + c := version.NewChecker(githubOwner, githubRepo, "0.0.1") + require.NotNil(t, c) +} + +// ---- runConvert ---- + +func TestRunConvert_HappyPath(t *testing.T) { + dir := t.TempDir() + in := filepath.Join(dir, "k.json") + out := filepath.Join(dir, "k.yaml") + + require.NoError(t, os.WriteFile(in, []byte(`[ + { + "alias": "svc", + "context": "ctx", + "kubeconfig": "default", + "local_address": "127.0.0.1", + "local_port": 8080, + "remote_port": 80, + "namespace": "default", + "protocol": "tcp", + "service": "svc", + "workload_type": "service" + } +]`), 0o600)) + + var stdout, stderr bytes.Buffer + code := runConvert(in, out, &stdout, &stderr) + assert.Equal(t, 0, code) + assert.Contains(t, stdout.String(), "Successfully converted") + assert.FileExists(t, out) +} + +func TestRunConvert_MissingInput(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "k.yaml") + var stdout, stderr bytes.Buffer + code := runConvert("/no/such/file.json", out, &stdout, &stderr) + assert.Equal(t, 1, code) + assert.Contains(t, stderr.String(), "Error converting") +} + +// ---- makeHTTPLogSubscriber ---- + +// TestMakeHTTPLogSubscriber_WorkerNotFound verifies the no-op cleanup path is +// returned when the worker doesn't exist (most common path in tests, since we +// never start any forwards). +func TestMakeHTTPLogSubscriber_WorkerNotFound(t *testing.T) { + mgr, err := forward.NewManager(false) + require.NoError(t, err) + + sub := makeHTTPLogSubscriber(mgr) + require.NotNil(t, sub) + + cleanup := sub("nonexistent-id", func(_ ui.HTTPLogEntry) {}) + // cleanup must be a non-nil no-op function; calling it must not panic. + require.NotNil(t, cleanup) + cleanup() +} + +// ---- buildRuntimeDeps ---- + +func TestBuildRuntimeDeps_Success(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + require.False(t, handled) + require.Equal(t, 0, code) + require.False(t, isNew) + require.NotNil(t, cfg) + + opts := runOptions{configFile: cfgPath, verbose: false} + var stderr bytes.Buffer + deps, err := buildRuntimeDeps(opts, cfg, &stderr) + require.NoError(t, err) + require.NotNil(t, deps) + require.NotNil(t, deps.manager) + require.NotNil(t, deps.discovery) + require.NotNil(t, deps.mutator) +} + +func TestBuildRuntimeDeps_VerboseMDNS(t *testing.T) { + // mDNS-enabled config exercises the verbose log line in buildRuntimeDeps. + cfgPath := writeYAML(t, "m.yaml", "mdns:\n enabled: true\ncontexts: []\n") + cfg, _, _, _ := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + require.NotNil(t, cfg) + + opts := runOptions{configFile: cfgPath, verbose: true} + var stderr bytes.Buffer + deps, err := buildRuntimeDeps(opts, cfg, &stderr) + require.NoError(t, err) + require.NotNil(t, deps) +} + +// ---- resolveConfigPath ---- + +func TestResolveConfigPath_Empty(t *testing.T) { + var stderr bytes.Buffer + path, ok := resolveConfigPath("", &stderr) + assert.True(t, ok) + assert.Empty(t, path) +} + +func TestResolveConfigPath_SystemDirs(t *testing.T) { + cases := []string{"/etc/foo.yaml", "/sys/x", "/proc/y", "/dev/z"} + for _, p := range cases { + t.Run(p, func(t *testing.T) { + var stderr bytes.Buffer + path, ok := resolveConfigPath(p, &stderr) + assert.False(t, ok) + assert.Empty(t, path) + assert.Contains(t, stderr.String(), "system directory") + }) + } +} + +func TestResolveConfigPath_RelativeBecomesAbsolute(t *testing.T) { + var stderr bytes.Buffer + path, ok := resolveConfigPath("relative.yaml", &stderr) + assert.True(t, ok) + assert.True(t, filepath.IsAbs(path)) +} + +// ---- parseFlags ---- + +func TestParseFlags_Defaults(t *testing.T) { + var stderr bytes.Buffer + opts, code, handled := parseFlags(nil, &stderr) + assert.False(t, handled) + assert.Equal(t, 0, code) + assert.Equal(t, defaultConfigFile, opts.configFile) + assert.False(t, opts.verbose) + assert.False(t, opts.headless) + assert.Equal(t, "text", opts.logFormat) +} + +func TestParseFlags_AllSet(t *testing.T) { + var stderr bytes.Buffer + args := []string{"-c", "/tmp/x.yaml", "-v", "-headless", "-log-format", "json", "-check", "-version", "-update", "-convert", "in.json", "-convert-output", "out.yaml"} + opts, code, handled := parseFlags(args, &stderr) + assert.False(t, handled) + assert.Equal(t, 0, code) + assert.Equal(t, "/tmp/x.yaml", opts.configFile) + assert.True(t, opts.verbose) + assert.True(t, opts.headless) + assert.Equal(t, "json", opts.logFormat) + assert.True(t, opts.check) + assert.True(t, opts.showVersion) + assert.True(t, opts.checkUpdate) + assert.Equal(t, "in.json", opts.convertInput) + assert.Equal(t, "out.yaml", opts.convertOutput) +} + +func TestParseFlags_HelpReturnsExit0(t *testing.T) { + var stderr bytes.Buffer + _, code, handled := parseFlags([]string{"-h"}, &stderr) + assert.True(t, handled) + assert.Equal(t, 0, code) +} + +func TestParseFlags_UnknownFlagReturnsExit2(t *testing.T) { + var stderr bytes.Buffer + _, code, handled := parseFlags([]string{"-unknown"}, &stderr) + assert.True(t, handled) + assert.Equal(t, 2, code) +} + +// ---- initLoggers / configureStdlibLog ---- + +func TestInitLoggers_AllModes(t *testing.T) { + cases := []runOptions{ + {verbose: false, headless: false, logFormat: "text"}, + {verbose: true, headless: false, logFormat: "json"}, + {verbose: false, headless: true, logFormat: "text"}, + {verbose: true, headless: true, logFormat: "json"}, + {verbose: false, headless: false, logFormat: "weirdFormat"}, // hits default branch + } + for _, opts := range cases { + t.Run("", func(t *testing.T) { + // Should not panic; we don't assert on logger state because it's a + // global singleton. + var stderr bytes.Buffer + initLoggers(opts, &stderr) + }) + } +} + +func TestConfigureStdlibLog_AllModes(t *testing.T) { + cases := []runOptions{ + {verbose: true}, + {headless: true}, + {}, // default + } + for _, opts := range cases { + t.Run("", func(t *testing.T) { + configureStdlibLog(opts) // mutates stdlib log; just ensure no panic + }) + } +} + +// ---- loadOrCreateConfig ---- + +func TestLoadOrCreateConfig_ExistingValid(t *testing.T) { + cfgPath := writeYAML(t, "v.yaml", "contexts: []\n") + cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + assert.False(t, handled) + assert.Equal(t, 0, code) + assert.False(t, isNew) + require.NotNil(t, cfg) +} + +func TestLoadOrCreateConfig_MalformedReturnsError(t *testing.T) { + cfgPath := writeYAML(t, "bad.yaml", ":\t {{{ invalid\n") + var stderr bytes.Buffer + _, _, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &stderr) + assert.True(t, handled) + assert.Equal(t, 1, code) + assert.Contains(t, stderr.String(), "Error loading config") +} + +func TestLoadOrCreateConfig_NotFound_DeclinePrompt(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "nope.yaml") + cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + assert.True(t, handled) + assert.Equal(t, 0, code) + assert.False(t, isNew) + assert.Nil(t, cfg) +} + +func TestLoadOrCreateConfig_NotFound_AcceptCreates(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "create.yaml") + cfg, isNew, code, handled := loadOrCreateConfig(cfgPath, strings.NewReader("y\n"), &bytes.Buffer{}, &bytes.Buffer{}) + assert.False(t, handled) + assert.Equal(t, 0, code) + assert.True(t, isNew) + require.NotNil(t, cfg) + assert.FileExists(t, cfgPath) +}