mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d263319ab | ||
|
|
4748bc4be5 | ||
|
|
f3d3f259a1 | ||
|
|
232c0f1162 | ||
|
|
6f19ecea6b | ||
|
|
93b5d6496e | ||
|
|
b3c63bd1fa | ||
|
|
374163f463 | ||
|
|
6c695584a4 | ||
|
|
c1dbad858b | ||
|
|
3c1726cdec | ||
|
|
49cc69927a | ||
|
|
4571552f61 | ||
|
|
6b80c9ecc5 | ||
|
|
bcfa2eb035 | ||
|
|
ba475be9de | ||
|
|
ad35ed05c8 | ||
|
|
698ccd510a | ||
|
|
abe90e330d | ||
|
|
add1cd2eb7 | ||
|
|
407ae1842c | ||
|
|
89258e70bb | ||
|
|
dc24fe5d88 | ||
|
|
99485450a6 | ||
|
|
233671730a | ||
|
|
0d1f8560c3 | ||
|
|
5f62ca3ed1 | ||
|
|
c2528f7eec | ||
|
|
a37574371f | ||
|
|
0bab59d97d | ||
|
|
7acac8e885 | ||
|
|
87ce85b07b | ||
|
|
d5125a0d62 | ||
|
|
ab65c2e17b |
@@ -3,3 +3,7 @@ CLAUDE.md
|
||||
DEPLOYMENT_SUMMARY.md
|
||||
HOMEBREW_COMPLIANCE.md
|
||||
RELEASE_SETUP.md
|
||||
|
||||
# Local/live test configs (cluster-specific, never committed)
|
||||
.kportal.test.yaml
|
||||
.kportal.*.local.yaml
|
||||
|
||||
@@ -62,8 +62,6 @@ homebrew_casks:
|
||||
homepage: https://lukaszraczylo.github.io/kportal
|
||||
description: "Modern Kubernetes port-forward manager with interactive TUI"
|
||||
license: MIT
|
||||
url:
|
||||
verified: github.com/lukaszraczylo/kportal
|
||||
hooks:
|
||||
post:
|
||||
install: |
|
||||
|
||||
@@ -22,6 +22,48 @@ reliability:
|
||||
retryOnStale: true # Automatically reconnect when stale connections detected (default: true)
|
||||
watchdogPeriod: "30s" # Goroutine watchdog check interval to detect hung workers (default: 30s)
|
||||
|
||||
# Every context below is forwarded by default. Pass --context to run only some
|
||||
# of them, for example:
|
||||
#
|
||||
# kportal --context staging
|
||||
# kportal --context production,staging # repeatable, or comma-separated
|
||||
#
|
||||
# Local-port conflicts are only reported between forwards that could run at the
|
||||
# same time, so contexts you never select together may reuse the same
|
||||
# localPort - handy when the same service exists in several clusters and you
|
||||
# want it on a fixed local port in each:
|
||||
#
|
||||
# contexts:
|
||||
# - name: development-team-a
|
||||
# namespaces:
|
||||
# - name: rate-service
|
||||
# forwards:
|
||||
# - resource: pod/rpc-server
|
||||
# protocol: tcp
|
||||
# port: 50051
|
||||
# localPort: 3004 # same port as team-b below
|
||||
# - name: development-team-b
|
||||
# namespaces:
|
||||
# - name: rate-service
|
||||
# forwards:
|
||||
# - resource: pod/rpc-server
|
||||
# protocol: tcp
|
||||
# port: 50051
|
||||
# localPort: 3004 # only valid when one of them is selected
|
||||
|
||||
# Optional: TUI table configuration
|
||||
# Controls which columns are visible in the forwards table, in what order,
|
||||
# and how wide each one renders. Omit this section (or leave it empty) to
|
||||
# use the built-in default columns/order/widths.
|
||||
# Valid column names: context, namespace, alias, type, resource, remote, local, status
|
||||
# tui:
|
||||
# columns:
|
||||
# - name: alias
|
||||
# width: 30 # optional; falls back to the built-in default width
|
||||
# - name: resource
|
||||
# - name: local
|
||||
# - name: status
|
||||
|
||||
contexts:
|
||||
# Production context
|
||||
- name: production
|
||||
|
||||
@@ -238,6 +238,26 @@ dns-sd -B _kportal._tcp local # macOS
|
||||
avahi-browse -t _kportal._tcp # Linux
|
||||
```
|
||||
|
||||
### Table Columns
|
||||
|
||||
Choose which columns the forwards table shows, and in what order. The setting applies to the interactive UI and the `-verbose` table:
|
||||
|
||||
```yaml
|
||||
tui:
|
||||
columns:
|
||||
- name: alias
|
||||
width: 30 # optional, 1-200
|
||||
- name: resource
|
||||
- name: local
|
||||
- name: status
|
||||
```
|
||||
|
||||
- Valid names: `context`, `namespace`, `alias`, `type`, `resource`, `remote`, `local`, `status`
|
||||
- The list order is the render order, and unlisted columns are hidden
|
||||
- `width` is the maximum text width, from 1 to 200. If you omit it, the column keeps its default width
|
||||
- If you omit `tui`, the table keeps its default columns and order
|
||||
- Changes apply on hot-reload
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Mode
|
||||
@@ -282,6 +302,65 @@ kportal --check
|
||||
kportal -c /path/to/config.yaml
|
||||
```
|
||||
|
||||
### Select Contexts
|
||||
|
||||
By default every context in the config file is forwarded. `--context` limits the
|
||||
run to the contexts you name, which is useful when the file describes several
|
||||
clusters but you only work against one at a time:
|
||||
|
||||
```bash
|
||||
kportal --context development-team-a
|
||||
```
|
||||
|
||||
The flag is repeatable and accepts comma-separated values, so these are
|
||||
equivalent:
|
||||
|
||||
```bash
|
||||
kportal --context team-a --context team-b
|
||||
kportal --context team-a,team-b
|
||||
```
|
||||
|
||||
It composes with the other modes, and works well as a per-cluster shell alias:
|
||||
|
||||
```bash
|
||||
alias kp-a='kportal --context development-team-a'
|
||||
kportal --context development-team-a -headless
|
||||
```
|
||||
|
||||
An unknown context name is an error that lists the contexts the file defines, so
|
||||
a typo fails loudly instead of silently forwarding nothing.
|
||||
|
||||
#### Reusing local ports across contexts
|
||||
|
||||
Local-port conflicts are only reported between forwards that could run at the
|
||||
same time. Contexts that are never selected together may therefore reuse the
|
||||
same `localPort`, so the same service keeps the same local port in every
|
||||
cluster and the app you are developing never needs reconfiguring:
|
||||
|
||||
```yaml
|
||||
contexts:
|
||||
- name: development-team-a
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- &rate-service
|
||||
resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
alias: rate-service
|
||||
|
||||
- name: development-team-b
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- <<: *rate-service # same local port, no conflict
|
||||
```
|
||||
|
||||
This requires passing `--context`. Without it every context is active, so the
|
||||
duplicate ports above would genuinely collide and are still reported as an
|
||||
error - as is naming both contexts at once (`--context team-a,team-b`).
|
||||
|
||||
### Generate Forwards from a Cluster
|
||||
|
||||
The `generate` subcommand discovers services in a Kubernetes context and lets you
|
||||
@@ -429,6 +508,11 @@ kill -HUP $(pgrep kportal)
|
||||
|
||||
kportal validates port availability at startup and during hot-reload, showing which process is using conflicting ports.
|
||||
|
||||
Conflicts are only reported between forwards that could be running together.
|
||||
With `--context`, forwards in contexts outside the selection are ignored, which
|
||||
is what lets each context reuse the same `localPort` values - see
|
||||
[Reusing local ports across contexts](#reusing-local-ports-across-contexts).
|
||||
|
||||
### Retry Strategy
|
||||
|
||||
Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue indefinitely until connection succeeds.
|
||||
@@ -469,6 +553,11 @@ Context names containing `@`, `.`, `:`, or `/` (e.g. `admin@home`,
|
||||
`[email protected]`, GKE dotted names, EKS ARNs) are accepted by the
|
||||
config validator.
|
||||
|
||||
If `--context` reports `unknown context "..."`, the name does not match any
|
||||
`contexts[].name` in the config file. The error lists the names that are
|
||||
defined - note these are the contexts in your `.kportal.yaml`, which need not be
|
||||
every context in your kubeconfig.
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Prerequisites
|
||||
@@ -490,6 +579,18 @@ make install # Install locally
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## Telemetry
|
||||
|
||||
On startup this binary sends a single anonymous adoption ping — project name,
|
||||
version, timestamp; no identifiers, no command output, no payload contents.
|
||||
Fire-and-forget with a 2-second timeout; cannot block startup or panic.
|
||||
|
||||
See **[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**
|
||||
for the exact wire format, source, and full opt-out documentation.
|
||||
|
||||
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
|
||||
or `KPORTAL_DISABLE_TELEMETRY=1`.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE).
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/complete"
|
||||
)
|
||||
|
||||
// completionCmd handles shell completion generation and installation
|
||||
func completionCmd(args []string) int {
|
||||
fs := flag.NewFlagSet("completion", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
var (
|
||||
installFlag bool
|
||||
shellFlag string
|
||||
uninstall bool
|
||||
)
|
||||
|
||||
fs.BoolVar(&installFlag, "install", false, "Install completions for the shell")
|
||||
fs.BoolVar(&uninstall, "uninstall", false, "Uninstall completions")
|
||||
fs.StringVar(&shellFlag, "shell", "", "Shell type: bash, zsh, or fish (auto-detected if empty)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
printCompletionHelp()
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// Determine shell type
|
||||
var shell complete.Shell
|
||||
if shellFlag != "" {
|
||||
switch shellFlag {
|
||||
case "bash":
|
||||
shell = complete.ShellBash
|
||||
case "zsh":
|
||||
shell = complete.ShellZsh
|
||||
case "fish":
|
||||
shell = complete.ShellFish
|
||||
default:
|
||||
fprintf(os.Stderr, "Error: unknown shell %q (use bash, zsh, or fish)\n", shellFlag)
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
shell = complete.AutoDetectShell()
|
||||
}
|
||||
|
||||
// Handle uninstall
|
||||
if uninstall {
|
||||
installer := complete.NewInstaller(shell)
|
||||
if err := installer.Uninstall(); err != nil {
|
||||
fprintf(os.Stderr, "Error uninstalling completions: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("✅ Completions uninstalled")
|
||||
return 0
|
||||
}
|
||||
|
||||
// Handle install
|
||||
if installFlag {
|
||||
if err := complete.InstallCompletions(shell); err != nil {
|
||||
fprintf(os.Stderr, "Error installing completions: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Print completion script to stdout
|
||||
if err := complete.Print(shell); err != nil {
|
||||
fprintf(os.Stderr, "Error generating completions: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func printCompletionHelp() {
|
||||
fprintf(os.Stdout, `Generate shell completions for kportal.
|
||||
|
||||
Usage:
|
||||
kportal completion [flags]
|
||||
|
||||
Flags:
|
||||
--install Install completions for the current shell
|
||||
--uninstall Remove installed completions
|
||||
--shell <type> Shell type: bash, zsh, or fish (auto-detected)
|
||||
|
||||
Examples:
|
||||
# Generate and source completions (bash)
|
||||
source <(kportal completion)
|
||||
|
||||
# Install completions (requires shell restart)
|
||||
kportal completion --install
|
||||
|
||||
# Install for specific shell
|
||||
kportal completion --install --shell zsh
|
||||
|
||||
# Uninstall completions
|
||||
kportal completion --uninstall
|
||||
|
||||
Shell-specific setup:
|
||||
|
||||
Bash (~/.bashrc):
|
||||
source <(kportal completion)
|
||||
|
||||
Zsh (~/.zshrc):
|
||||
autoload -Uz compinit && compinit
|
||||
source <(kportal completion)
|
||||
|
||||
Fish (~/.config/fish/config.fish):
|
||||
kportal completion --install --shell fish
|
||||
# Or manually:
|
||||
kportal completion --shell fish > ~/.config/fish/completions/kportal.fish
|
||||
`)
|
||||
}
|
||||
+103
-49
@@ -51,6 +51,7 @@ type runOptions struct {
|
||||
logFormat string
|
||||
convertInput string
|
||||
convertOutput string
|
||||
contexts []string
|
||||
verbose bool
|
||||
headless bool
|
||||
check bool
|
||||
@@ -58,6 +59,28 @@ type runOptions struct {
|
||||
checkUpdate bool
|
||||
}
|
||||
|
||||
// contextListFlag collects repeated -context values. Each value may itself be a
|
||||
// comma-separated list, so "-context a -context b" and "-context a,b" are
|
||||
// equivalent.
|
||||
type contextListFlag []string
|
||||
|
||||
func (c *contextListFlag) String() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(*c, ",")
|
||||
}
|
||||
|
||||
func (c *contextListFlag) Set(value string) error {
|
||||
// Reject an empty value rather than silently forwarding every context -
|
||||
// "-context=$UNSET_VAR" in a script should fail loudly.
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("context name cannot be empty")
|
||||
}
|
||||
*c = append(*c, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -117,9 +140,14 @@ func runMain() int {
|
||||
// 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:])
|
||||
// generate and completion have their own FlagSets and must not see kportal's top-level flags.
|
||||
if len(args) >= 1 {
|
||||
switch args[0] {
|
||||
case "generate":
|
||||
return runGenerate(args[1:])
|
||||
case "completion":
|
||||
return completionCmd(args[1:])
|
||||
}
|
||||
}
|
||||
|
||||
opts, code, handled := parseFlags(args, stderr)
|
||||
@@ -161,13 +189,31 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
|
||||
return code
|
||||
}
|
||||
|
||||
// Validate configuration (allow empty for newly created files).
|
||||
// Validate the whole configuration - including contexts that -context
|
||||
// excludes - so problems anywhere in the file are still reported. Only the
|
||||
// checks that depend on what actually runs (local ports, mDNS hostnames)
|
||||
// are scoped to the selection.
|
||||
validator := config.NewValidator()
|
||||
if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 {
|
||||
if errs := validator.ValidateConfigWithOpts(cfg, config.ValidateOptions{
|
||||
AllowEmpty: configIsNew || cfg.IsEmpty(),
|
||||
ActiveContexts: opts.contexts,
|
||||
}); len(errs) > 0 {
|
||||
fprint(stderr, config.FormatValidationErrors(errs))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Narrow the config to the selected contexts for everything downstream.
|
||||
// Skipped for a config with no contexts at all (a file just created above),
|
||||
// where reporting an unknown context would be unhelpful.
|
||||
if len(opts.contexts) > 0 && len(cfg.Contexts) > 0 {
|
||||
selected, err := cfg.SelectContexts(opts.contexts)
|
||||
if err != nil {
|
||||
fprintf(stderr, "Error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
cfg = selected
|
||||
}
|
||||
|
||||
if opts.check {
|
||||
fprintln(stdout, "Configuration is valid")
|
||||
return 0
|
||||
@@ -187,9 +233,9 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
|
||||
|
||||
switch {
|
||||
case opts.headless:
|
||||
return runHeadless(ctx, opts, cfg, deps, validator, stderr)
|
||||
return runHeadless(ctx, opts, cfg, deps, stderr)
|
||||
case opts.verbose:
|
||||
return runVerboseTable(ctx, opts, cfg, deps, validator, stderr)
|
||||
return runVerboseTable(ctx, opts, cfg, deps, stderr)
|
||||
default:
|
||||
return runInteractive(ctx, opts, cfg, deps, stderr)
|
||||
}
|
||||
@@ -203,6 +249,8 @@ func parseFlags(args []string, stderr io.Writer) (runOptions, int, bool) {
|
||||
fs.SetOutput(stderr)
|
||||
|
||||
var opts runOptions
|
||||
var contexts contextListFlag
|
||||
fs.Var(&contexts, "context", "Only forward the named contexts from the config file (repeatable and comma-separated; default: all)")
|
||||
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)")
|
||||
@@ -219,6 +267,7 @@ func parseFlags(args []string, stderr io.Writer) (runOptions, int, bool) {
|
||||
}
|
||||
return opts, 2, true
|
||||
}
|
||||
opts.contexts = config.NormalizeContextSelection(contexts)
|
||||
return opts, 0, false
|
||||
}
|
||||
|
||||
@@ -330,6 +379,30 @@ func loadOrCreateConfig(configFile string, stdin io.Reader, stdout, stderr io.Wr
|
||||
return cfg, true, 0, false
|
||||
}
|
||||
|
||||
// reloadFromSignal re-reads the config after SIGHUP and applies it to the
|
||||
// manager. Load, validation and the -context selection all live in
|
||||
// config.LoadForRuntime, shared with the file watcher, so no reload path can
|
||||
// forget the selection. Failures are logged and the previous config stays
|
||||
// active. onApplied, when non-nil, runs only after a successful Reload.
|
||||
func reloadFromSignal(opts runOptions, manager *forward.Manager, logIt bool, onApplied func(*config.Config)) {
|
||||
newCfg, err := config.LoadForRuntime(opts.configFile, opts.contexts)
|
||||
if err != nil {
|
||||
if logIt {
|
||||
log.Printf("Failed to reload config: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := manager.Reload(newCfg); err != nil {
|
||||
if logIt {
|
||||
log.Printf("Failed to reload: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if onApplied != nil {
|
||||
onApplied(newCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeDeps bundles the long-lived objects shared by all UI modes.
|
||||
type runtimeDeps struct {
|
||||
manager *forward.Manager
|
||||
@@ -350,6 +423,7 @@ func buildRuntimeDeps(opts runOptions, cfg *config.Config, stderr io.Writer) (*r
|
||||
}
|
||||
discovery := k8s.NewDiscovery(pool)
|
||||
mutator := config.NewMutator(opts.configFile)
|
||||
mutator.SetActiveContexts(opts.contexts)
|
||||
|
||||
manager, err := forward.NewManager(opts.verbose)
|
||||
if err != nil {
|
||||
@@ -424,7 +498,7 @@ func runConvert(input, output string, stdout, stderr io.Writer) int {
|
||||
|
||||
// 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 {
|
||||
func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, stderr io.Writer) int {
|
||||
if startErr := deps.manager.Start(cfg); startErr != nil {
|
||||
fprintf(stderr, "Error starting forwards: %v\n", startErr)
|
||||
return 1
|
||||
@@ -437,7 +511,7 @@ func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps
|
||||
|
||||
watcher, watcherErr := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
|
||||
return deps.manager.Reload(newCfg)
|
||||
}, opts.verbose)
|
||||
}, opts.verbose, config.WithContextSelection(opts.contexts))
|
||||
watcherStarted := false
|
||||
if watcherErr != nil {
|
||||
if opts.verbose {
|
||||
@@ -466,39 +540,21 @@ func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps
|
||||
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)
|
||||
}
|
||||
}
|
||||
reloadFromSignal(opts, deps.manager, opts.verbose, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, stderr io.Writer) int {
|
||||
tableUI := ui.NewTableUI(opts.verbose, cfg)
|
||||
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)
|
||||
uctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if update := checker.CheckForUpdate(uctx); update != nil {
|
||||
log.Printf("Update available: v%s (current: v%s) - %s",
|
||||
@@ -534,8 +590,12 @@ func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, d
|
||||
}()
|
||||
|
||||
watcher, watchErr := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
|
||||
return deps.manager.Reload(newCfg)
|
||||
}, opts.verbose)
|
||||
if err := deps.manager.Reload(newCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
tableUI.SetColumns(newCfg)
|
||||
return nil
|
||||
}, opts.verbose, config.WithContextSelection(opts.contexts))
|
||||
watcherActive := false
|
||||
if watchErr != nil {
|
||||
log.Printf("Warning: Failed to setup config watcher: %v", watchErr)
|
||||
@@ -559,19 +619,7 @@ func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, d
|
||||
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)
|
||||
}
|
||||
reloadFromSignal(opts, deps.manager, true, tableUI.SetColumns)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,12 +633,14 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de
|
||||
_ = deps.manager.DisableForward(id)
|
||||
}
|
||||
}, appVersion)
|
||||
bubbleTeaUI.SetColumns(cfg)
|
||||
bubbleTeaUI.SetWizardDependencies(deps.discovery, deps.mutator, opts.configFile)
|
||||
bubbleTeaUI.SetActiveContexts(opts.contexts)
|
||||
bubbleTeaUI.SetHTTPLogSubscriber(makeHTTPLogSubscriber(deps.manager))
|
||||
|
||||
go func() {
|
||||
checker := version.NewChecker(githubOwner, githubRepo, appVersion)
|
||||
uctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
uctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if update := checker.CheckForUpdate(uctx); update != nil {
|
||||
bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL)
|
||||
@@ -606,8 +656,12 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de
|
||||
|
||||
var watcher *config.Watcher
|
||||
watcher, err := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
|
||||
return deps.manager.Reload(newCfg)
|
||||
}, opts.verbose)
|
||||
if err := deps.manager.Reload(newCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
bubbleTeaUI.SetColumns(newCfg)
|
||||
return nil
|
||||
}, opts.verbose, config.WithContextSelection(opts.contexts))
|
||||
if err == nil {
|
||||
watcher.Start()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/forward"
|
||||
"github.com/lukaszraczylo/kportal/internal/ui"
|
||||
"github.com/lukaszraczylo/kportal/internal/version"
|
||||
@@ -545,6 +546,7 @@ func TestParseFlags_Defaults(t *testing.T) {
|
||||
assert.False(t, opts.verbose)
|
||||
assert.False(t, opts.headless)
|
||||
assert.Equal(t, "text", opts.logFormat)
|
||||
assert.Empty(t, opts.contexts, "no -context means every context is forwarded")
|
||||
}
|
||||
|
||||
func TestParseFlags_AllSet(t *testing.T) {
|
||||
@@ -651,3 +653,190 @@ func TestLoadOrCreateConfig_NotFound_AcceptCreates(t *testing.T) {
|
||||
require.NotNil(t, cfg)
|
||||
assert.FileExists(t, cfgPath)
|
||||
}
|
||||
|
||||
// ---- -context selection ----
|
||||
|
||||
func TestParseFlags_ContextSelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{name: "single", args: []string{"-context", "team-a"}, want: []string{"team-a"}},
|
||||
{name: "repeated", args: []string{"-context", "team-a", "-context", "team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "comma separated", args: []string{"-context", "team-a,team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "equals form", args: []string{"-context=team-a,team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "double dash", args: []string{"--context", "team-a"}, want: []string{"team-a"}},
|
||||
{
|
||||
name: "repeated and comma mixed, de-duplicated",
|
||||
args: []string{"-context", "team-a,team-b", "-context", "team-a", "-context", " team-c "},
|
||||
want: []string{"team-a", "team-b", "team-c"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
opts, code, handled := parseFlags(tt.args, &stderr)
|
||||
require.False(t, handled)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Equal(t, tt.want, opts.contexts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFlags_EmptyContextRejected keeps "-context=$UNSET_VAR" in a script
|
||||
// from silently degrading to "forward everything".
|
||||
func TestParseFlags_EmptyContextRejected(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
_, code, handled := parseFlags([]string{"-context="}, &stderr)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 2, code)
|
||||
}
|
||||
|
||||
// sharedPortConfigBody is the layout from issue #80: the same service on the
|
||||
// same local port in two per-team clusters.
|
||||
const sharedPortConfigBody = `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
`
|
||||
|
||||
func writeSharedPortConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(sharedPortConfigBody), 0600))
|
||||
return path
|
||||
}
|
||||
|
||||
// TestRun_CheckWithContextSelection is the end-to-end proof of issue #80:
|
||||
// selecting a single context makes a config that reuses local ports valid.
|
||||
func TestRun_CheckWithContextSelection(t *testing.T) {
|
||||
path := writeSharedPortConfig(t)
|
||||
|
||||
t.Run("without selection the shared port is a conflict", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "Duplicate local port 3004")
|
||||
})
|
||||
|
||||
t.Run("selecting one context is valid", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code, "stderr: %s", stderr.String())
|
||||
assert.Contains(t, stdout.String(), "Configuration is valid")
|
||||
})
|
||||
|
||||
t.Run("selecting both brings the conflict back", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a,team-b"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "Duplicate local port 3004")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRun_CheckUnknownContext verifies the typo guard.
|
||||
func TestRun_CheckUnknownContext(t *testing.T) {
|
||||
path := writeSharedPortConfig(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-z"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), `unknown context "team-z"`)
|
||||
// The message should point at what does exist.
|
||||
assert.Contains(t, stderr.String(), "team-a")
|
||||
assert.Contains(t, stderr.String(), "team-b")
|
||||
}
|
||||
|
||||
// TestRun_CheckContextWithNewlyCreatedConfig makes sure -context does not turn
|
||||
// the create-on-missing flow into an "unknown context" dead end.
|
||||
func TestRun_CheckContextWithNewlyCreatedConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader("y\n"), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code, "stderr: %s", stderr.String())
|
||||
assert.Contains(t, stdout.String(), "Configuration is valid")
|
||||
}
|
||||
|
||||
// TestRun_CheckValidatesNonSelectedContexts proves -check did not get weaker:
|
||||
// a broken forward outside the selection is still reported.
|
||||
func TestRun_CheckValidatesNonSelectedContexts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
body := `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/ok
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 8080
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/bad
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 999999
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "localPort")
|
||||
}
|
||||
|
||||
// ---- reloadFromSignal ----
|
||||
|
||||
func TestReloadFromSignal_OnAppliedRunsAfterSuccessfulReload(t *testing.T) {
|
||||
mgr, err := forward.NewManager(false)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(mgr.Stop)
|
||||
cfgPath := writeYAML(t, "r.yaml", `tui:
|
||||
columns:
|
||||
- name: alias
|
||||
contexts:
|
||||
- name: dev
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/app
|
||||
port: 8080
|
||||
localPort: 38080
|
||||
`)
|
||||
|
||||
var got *config.Config
|
||||
reloadFromSignal(runOptions{configFile: cfgPath}, mgr, false, func(c *config.Config) { got = c })
|
||||
|
||||
require.NotNil(t, got)
|
||||
require.Len(t, got.GetTableColumns(), 1)
|
||||
assert.Equal(t, "alias", got.GetTableColumns()[0].Name)
|
||||
}
|
||||
|
||||
func TestReloadFromSignal_OnAppliedSkippedWhenConfigInvalid(t *testing.T) {
|
||||
mgr, err := forward.NewManager(false)
|
||||
require.NoError(t, err)
|
||||
cfgPath := writeYAML(t, "bad.yaml", "tui:\n columns:\n - name: bogus\ncontexts: []\n")
|
||||
|
||||
called := false
|
||||
reloadFromSignal(runOptions{configFile: cfgPath}, mgr, false, func(*config.Config) { called = true })
|
||||
|
||||
assert.False(t, called)
|
||||
}
|
||||
|
||||
@@ -667,6 +667,14 @@
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Use a custom configuration file instead of .kportal.yaml</p>
|
||||
</div>
|
||||
<div class="glass p-4 sm:p-6 rounded-xl">
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4"><i class="fas fa-layer-group text-teal-500 mr-2"></i>Select Contexts</h3>
|
||||
<div onclick="copyToClipboard('kportal --context development-team-a', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 text-gray-100 p-4 rounded-lg text-sm cursor-pointer group border border-gray-700 hover:border-teal-500 transition-all duration-300 mb-3">
|
||||
<code class="font-mono">kportal --context development-team-a</code>
|
||||
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-teal-400 transition-colors"></i></div>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Forward only the contexts you name instead of all of them. Repeatable and comma-separated. Contexts never selected together can reuse the same local ports.</p>
|
||||
</div>
|
||||
<div class="glass p-4 sm:p-6 rounded-xl">
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4"><i class="fas fa-server text-slate-500 mr-2"></i>Headless Mode</h3>
|
||||
<div onclick="copyToClipboard('kportal -headless -v 2>kportal.log &', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 text-gray-100 p-4 rounded-lg text-sm cursor-pointer group border border-gray-700 hover:border-slate-500 transition-all duration-300 mb-3">
|
||||
|
||||
@@ -6,14 +6,14 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-logr/logr v1.4.3
|
||||
github.com/go-logr/logr v1.4.4
|
||||
github.com/grandcat/zeroconf v1.0.0
|
||||
github.com/lukaszraczylo/oss-telemetry v0.0.0-20260521005811-e02d51419c52
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/api v0.36.0
|
||||
k8s.io/apimachinery v0.36.0
|
||||
k8s.io/client-go v0.36.0
|
||||
k8s.io/api v0.37.0
|
||||
k8s.io/apimachinery v0.37.0
|
||||
k8s.io/client-go v0.37.0
|
||||
k8s.io/klog/v2 v2.140.0
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
@@ -29,31 +29,30 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/swag v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.1 // indirect
|
||||
github.com/go-openapi/swag v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/pools v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
|
||||
github.com/google/gnostic-models v0.7.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.29 // indirect
|
||||
github.com/miekg/dns v1.1.73 // indirect
|
||||
github.com/moby/spdystream v0.5.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
@@ -65,26 +64,23 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260505163821-33341827b392 // indirect
|
||||
k8s.io/streaming v0.36.0 // indirect
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 // indirect
|
||||
k8s.io/streaming v0.37.0 // indirect
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
@@ -12,8 +12,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
@@ -24,7 +24,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -35,44 +34,44 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
|
||||
github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
|
||||
github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU=
|
||||
github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
|
||||
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
|
||||
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
|
||||
github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU=
|
||||
github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
|
||||
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
|
||||
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
|
||||
github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ=
|
||||
github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0=
|
||||
github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c=
|
||||
github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo=
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
|
||||
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
|
||||
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
|
||||
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
|
||||
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
|
||||
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38=
|
||||
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A=
|
||||
github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ=
|
||||
github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA=
|
||||
github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY=
|
||||
github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
|
||||
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA=
|
||||
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM=
|
||||
github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0=
|
||||
github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI=
|
||||
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c=
|
||||
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg=
|
||||
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc=
|
||||
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw=
|
||||
github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I=
|
||||
github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s=
|
||||
github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4=
|
||||
github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno=
|
||||
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg=
|
||||
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc=
|
||||
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0=
|
||||
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc=
|
||||
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M=
|
||||
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE=
|
||||
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk=
|
||||
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4=
|
||||
github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q=
|
||||
github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
||||
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -90,19 +89,19 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.0.0-20260521005811-e02d51419c52 h1:HAm1OV/1uYN3VA/HdDNFjwh8KerTLwl1SoxF+IiNf/M=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.0.0-20260521005811-e02d51419c52/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
|
||||
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
|
||||
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -131,56 +130,52 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -190,25 +185,25 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
|
||||
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
|
||||
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
|
||||
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
|
||||
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
|
||||
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
|
||||
k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4=
|
||||
k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk=
|
||||
k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0=
|
||||
k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE=
|
||||
k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ=
|
||||
k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0=
|
||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
||||
k8s.io/kube-openapi v0.0.0-20260505163821-33341827b392 h1:B7Ylb1OUptHKVX/3kpvXB0i05pDmXU66cGED/4Ta9Bw=
|
||||
k8s.io/kube-openapi v0.0.0-20260505163821-33341827b392/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
|
||||
k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4=
|
||||
k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 h1:z5+pcu1jTyKK5mNTe2/+x+U6Uuv9jRVOJQLaBJJMpeI=
|
||||
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
|
||||
k8s.io/streaming v0.37.0 h1:iPBUZLZiKt5bV+lxJurASMOV07VuBhNpiwJt2//AWrM=
|
||||
k8s.io/streaming v0.37.0/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
// Package complete provides shell completion generation for kportal.
|
||||
// It supports bash, zsh, and fish shells with context-aware completions
|
||||
// for flags, subcommands, config values, and Kubernetes resources.
|
||||
package complete
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Shell represents a supported shell type
|
||||
type Shell string
|
||||
|
||||
const (
|
||||
ShellBash Shell = "bash"
|
||||
ShellZsh Shell = "zsh"
|
||||
ShellFish Shell = "fish"
|
||||
)
|
||||
|
||||
// Installer handles shell completion installation
|
||||
type Installer struct {
|
||||
shell Shell
|
||||
prefixDir string
|
||||
}
|
||||
|
||||
// NewInstaller creates a new completion installer for the specified shell
|
||||
func NewInstaller(shell Shell) *Installer {
|
||||
return &Installer{
|
||||
shell: shell,
|
||||
prefixDir: getDefaultPrefixDir(shell),
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultPrefixDir returns the default completion directory for a shell
|
||||
func getDefaultPrefixDir(shell Shell) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = os.Getenv("HOME")
|
||||
}
|
||||
if home == "" {
|
||||
home = "/tmp"
|
||||
}
|
||||
|
||||
switch shell {
|
||||
case ShellBash:
|
||||
// Check common bash completion directories
|
||||
dirs := []string{
|
||||
"/etc/bash_completion.d",
|
||||
filepath.Join(home, ".local", "share", "bash-completion", "completions"),
|
||||
filepath.Join(home, ".bash_completion.d"),
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if pathExists(dir) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
// Fallback to user dir (best-effort create; Install reports write errors)
|
||||
userDir := filepath.Join(home, ".local", "share", "bash-completion", "completions")
|
||||
ensureDir(userDir)
|
||||
return userDir
|
||||
|
||||
case ShellZsh:
|
||||
dirs := []string{
|
||||
filepath.Join(home, ".zsh", "completions"),
|
||||
filepath.Join(home, ".oh-my-zsh", "completions"),
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if pathExists(dir) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
// Fallback to standard zsh site-functions
|
||||
usrShare := "/usr/local/share/zsh/site-functions"
|
||||
if pathExists(usrShare) {
|
||||
return usrShare
|
||||
}
|
||||
userDir := filepath.Join(home, ".zsh", "completions")
|
||||
ensureDir(userDir)
|
||||
return userDir
|
||||
|
||||
case ShellFish:
|
||||
dir := filepath.Join(home, ".config", "fish", "completions")
|
||||
ensureDir(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Install installs the completion script for the shell
|
||||
func (i *Installer) Install() error {
|
||||
script, err := Generate(i.shell)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate completion script: %w", err)
|
||||
}
|
||||
|
||||
filename := i.getCompletionFilename()
|
||||
filepath := filepath.Join(i.prefixDir, filename)
|
||||
|
||||
// Check if already installed
|
||||
if pathExists(filepath) {
|
||||
return fmt.Errorf("completion file already exists: %s (remove it first to reinstall)", filepath)
|
||||
}
|
||||
|
||||
// #nosec G306 -- completion scripts are non-secret and must be world-readable by the shell
|
||||
if err := os.WriteFile(filepath, []byte(script), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write completion file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uninstall removes the completion script
|
||||
func (i *Installer) Uninstall() error {
|
||||
filename := i.getCompletionFilename()
|
||||
filepath := filepath.Join(i.prefixDir, filename)
|
||||
|
||||
if err := os.Remove(filepath); err != nil {
|
||||
return fmt.Errorf("failed to remove completion file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCompletionFilename returns the filename for the completion script
|
||||
func (i *Installer) getCompletionFilename() string {
|
||||
switch i.shell {
|
||||
case ShellBash:
|
||||
return "_kportal"
|
||||
case ShellZsh:
|
||||
return "_kportal"
|
||||
case ShellFish:
|
||||
return "kportal.fish"
|
||||
}
|
||||
return "_kportal"
|
||||
}
|
||||
|
||||
// Print prints the completion script to stdout
|
||||
func Print(shell Shell) error {
|
||||
script, err := Generate(shell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Print(script)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate generates the completion script for the specified shell
|
||||
func Generate(shell Shell) (string, error) {
|
||||
switch shell {
|
||||
case ShellBash:
|
||||
return generateBash()
|
||||
case ShellZsh:
|
||||
return generateZsh()
|
||||
case ShellFish:
|
||||
return generateFish()
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported shell: %s", shell)
|
||||
}
|
||||
}
|
||||
|
||||
// generateBash generates bash completion script
|
||||
func generateBash() (string, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
bashScript := `# kportal shell completion - bash
|
||||
# Generated by kportal
|
||||
|
||||
# Don't interfere with other completions
|
||||
if [[ -n "${BASH_COMPLETION_VERSINFO:-}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
_kportal()
|
||||
{
|
||||
local cur prev words cword split=false
|
||||
_init_completion -s || return
|
||||
|
||||
# Complete the value expected after the previous flag
|
||||
case "$prev" in
|
||||
-c|--config)
|
||||
_filedir yaml
|
||||
return
|
||||
;;
|
||||
--log-format)
|
||||
COMPREPLY=( $(compgen -W "text json" -- "$cur") )
|
||||
return
|
||||
;;
|
||||
--convert)
|
||||
_filedir json
|
||||
return
|
||||
;;
|
||||
--convert-output)
|
||||
_filedir yaml
|
||||
return
|
||||
;;
|
||||
--context)
|
||||
# Complete from kubectl contexts
|
||||
if command -v kubectl &> /dev/null; then
|
||||
COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") )
|
||||
fi
|
||||
return
|
||||
;;
|
||||
--shell)
|
||||
COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
# Handle option splitting (e.g., -c=value)
|
||||
[[ "$cur" == -*=* ]] && split=true
|
||||
[[ "$split" == true ]] && prev="${cur%%=*}"
|
||||
|
||||
# Subcommand-specific completion
|
||||
case "${words[1]}" in
|
||||
generate)
|
||||
if [[ "$cur" == -* ]]; then
|
||||
COMPREPLY=( $(compgen -W "--context --config --dry-run" -- "$cur") )
|
||||
elif command -v kubectl &> /dev/null; then
|
||||
COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") )
|
||||
fi
|
||||
return
|
||||
;;
|
||||
completion)
|
||||
COMPREPLY=( $(compgen -W "--install --uninstall --shell" -- "$cur") )
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
# Top-level options
|
||||
if [[ "$cur" == -* ]]; then
|
||||
COMPREPLY=( $(compgen -W "-c -v --check --context --headless --log-format --version --update --convert --convert-output" -- "$cur") )
|
||||
return
|
||||
fi
|
||||
|
||||
# Top-level subcommands
|
||||
COMPREPLY=( $(compgen -W "generate completion" -- "$cur") )
|
||||
}
|
||||
|
||||
# Register completion
|
||||
complete -F _kportal kportal
|
||||
|
||||
# Also complete for common aliases
|
||||
complete -F _kportal kp
|
||||
`
|
||||
|
||||
sb.WriteString(bashScript)
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// generateZsh generates zsh completion script
|
||||
func generateZsh() (string, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
// Per-flag zsh _arguments descriptors. Completion scripts are static text, so
|
||||
// these are literals (single source of truth — keep in sync with the CLI flags).
|
||||
flagDescs := []string{
|
||||
`'-c[Path to configuration file]:config file:_files -g "*.yaml"'`,
|
||||
`'*--context[Only forward the named contexts from the config file]:context:($(kubectl config get-contexts -o name 2>/dev/null))'`,
|
||||
`'-v[Enable verbose logging]'`,
|
||||
`'--version[Show version and exit]'`,
|
||||
`'--update[Check for updates]'`,
|
||||
`'--check[Validate configuration]'`,
|
||||
`'--headless[Run without UI]'`,
|
||||
`'--log-format[Log format: text or json]:format:(text json)'`,
|
||||
`'--convert[Convert kftray config]:input file:_files -g "*.json"'`,
|
||||
`'--convert-output[Output file]:output file:_files -g "*.yaml"'`,
|
||||
}
|
||||
|
||||
sb.WriteString(`#compdef kportal
|
||||
|
||||
# kportal shell completion - zsh
|
||||
# Generated by kportal
|
||||
|
||||
_kportal()
|
||||
{
|
||||
local -a commands flags generate_flags completion_flags
|
||||
|
||||
commands=(
|
||||
'generate:Interactively generate forwards from cluster'
|
||||
'completion:Generate shell completion scripts'
|
||||
)
|
||||
|
||||
flags=(
|
||||
`)
|
||||
|
||||
for _, desc := range flagDescs {
|
||||
fmt.Fprintf(&sb, " %s\n", desc)
|
||||
}
|
||||
|
||||
sb.WriteString(` )
|
||||
|
||||
generate_flags=(
|
||||
'--context[Kubernetes context]:context:->ctx'
|
||||
'--config[Config file]:file:_files -g "*.yaml"'
|
||||
'--dry-run[Print without saving]'
|
||||
)
|
||||
|
||||
completion_flags=(
|
||||
'--install[Install completions for the shell]'
|
||||
'--uninstall[Remove installed completions]'
|
||||
'--shell[Shell type]:shell:(bash zsh fish)'
|
||||
)
|
||||
|
||||
_arguments -s $flags '1: :->command' '*:: :->args'
|
||||
|
||||
case $state in
|
||||
command)
|
||||
_describe 'command' commands
|
||||
;;
|
||||
args)
|
||||
case ${words[1]} in
|
||||
generate)
|
||||
_arguments -s $generate_flags
|
||||
if [[ "$words[CURRENT]" == --context ]]; then
|
||||
if command -v kubectl &> /dev/null; then
|
||||
local -a contexts
|
||||
contexts=(${(f)"$(kubectl config get-contexts -o name 2>/dev/null)"})
|
||||
_describe 'context' contexts
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
completion)
|
||||
_arguments -s $completion_flags
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_kportal "$@"
|
||||
`)
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// generateFish generates fish completion script
|
||||
func generateFish() (string, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(`# kportal shell completion - fish
|
||||
# Generated by kportal
|
||||
|
||||
# Main completion
|
||||
complete -c kportal -f
|
||||
|
||||
# Subcommands
|
||||
complete -c kportal -n '__fish_use_subcommand' -a 'generate' -d 'Interactively generate forwards from cluster'
|
||||
complete -c kportal -n '__fish_use_subcommand' -a 'completion' -d 'Generate shell completion scripts'
|
||||
|
||||
# Global flags (main command uses single-dash -c and -v; words accept --)
|
||||
complete -c kportal -s c -r -f -a '( __fish_complete_suffix .yaml )' -d 'Path to configuration file'
|
||||
complete -c kportal -s v -d 'Enable verbose logging'
|
||||
complete -c kportal -l version -d 'Show version and exit'
|
||||
complete -c kportal -l update -d 'Check for updates'
|
||||
complete -c kportal -l check -d 'Validate configuration'
|
||||
complete -c kportal -n 'not __fish_seen_subcommand_from generate completion' -l context -r -d 'Only forward the named contexts' -a '(kubectl config get-contexts -o name 2>/dev/null)' -f
|
||||
complete -c kportal -l headless -d 'Run without UI'
|
||||
complete -c kportal -l log-format -d 'Log format' -a 'text json' -f
|
||||
complete -c kportal -l convert -r -f -a '( __fish_complete_suffix .json )' -d 'Convert kftray config'
|
||||
complete -c kportal -l convert-output -r -f -a '( __fish_complete_suffix .yaml )' -d 'Output file'
|
||||
|
||||
# generate subcommand flags
|
||||
complete -c kportal -n '__fish_seen_subcommand_from generate' -l context -d 'Kubernetes context' -a '(kubectl config get-contexts -o name 2>/dev/null)' -f
|
||||
complete -c kportal -n '__fish_seen_subcommand_from generate' -l config -r -f -a '( __fish_complete_suffix .yaml )' -d 'Config file'
|
||||
complete -c kportal -n '__fish_seen_subcommand_from generate' -l dry-run -d 'Print without saving'
|
||||
|
||||
# completion subcommand flags
|
||||
complete -c kportal -n '__fish_seen_subcommand_from completion' -l install -d 'Install completions for the shell'
|
||||
complete -c kportal -n '__fish_seen_subcommand_from completion' -l uninstall -d 'Remove installed completions'
|
||||
complete -c kportal -n '__fish_seen_subcommand_from completion' -l shell -d 'Shell type' -a 'bash zsh fish' -f
|
||||
|
||||
# Aliases
|
||||
complete -c kp -f
|
||||
`)
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// InstallCompletions installs completions for the specified shell
|
||||
// Prints instructions for manual installation if auto-install fails
|
||||
func InstallCompletions(shell Shell) error {
|
||||
installer := NewInstaller(shell)
|
||||
|
||||
if err := installer.Install(); err != nil {
|
||||
// Check if it's just "already exists" error
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
fmt.Printf("Completion already installed at: %s/%s\n",
|
||||
installer.prefixDir, installer.getCompletionFilename())
|
||||
fmt.Println("Remove it first to reinstall, or source it manually:")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to print the script instead
|
||||
fmt.Println("Could not auto-install completions. To install manually, run:")
|
||||
fmt.Printf(" # %s\n", getInstallInstructions(shell))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Completions installed to: %s/%s\n",
|
||||
installer.prefixDir, installer.getCompletionFilename())
|
||||
fmt.Printf("\nTo enable %s completions, restart your shell or run:\n", shell)
|
||||
fmt.Printf(" source ~/%s\n", getSourceInstruction(shell))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getInstallInstructions(shell Shell) string {
|
||||
return fmt.Sprintf("kportal completion %s > <your-completion-dir>/_kportal", shell)
|
||||
}
|
||||
|
||||
func getSourceInstruction(shell Shell) string {
|
||||
switch shell {
|
||||
case ShellBash:
|
||||
return ".bashrc"
|
||||
case ShellZsh:
|
||||
return ".zshrc"
|
||||
case ShellFish:
|
||||
return ".config/fish/config.fish # completions load automatically"
|
||||
}
|
||||
return "shell config"
|
||||
}
|
||||
|
||||
// AutoDetectShell detects the current shell
|
||||
func AutoDetectShell() Shell {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
return ShellBash // Default
|
||||
}
|
||||
|
||||
if strings.HasSuffix(shell, "/bash") {
|
||||
return ShellBash
|
||||
}
|
||||
if strings.HasSuffix(shell, "/zsh") {
|
||||
return ShellZsh
|
||||
}
|
||||
if strings.HasSuffix(shell, "/fish") {
|
||||
return ShellFish
|
||||
}
|
||||
|
||||
return ShellBash
|
||||
}
|
||||
|
||||
// pathExists reports whether path exists on disk.
|
||||
// #nosec G703 -- callers pass fixed completion paths derived from the user's own
|
||||
// HOME and constant subdirectory names, never external/untrusted input.
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ensureDir best-effort creates a completion directory. Errors are intentionally
|
||||
// ignored: Install() surfaces any later write failure with a clear message.
|
||||
// #nosec G703 -- path is derived from the user's own HOME and constant
|
||||
// subdirectory names; 0o750 is appropriate for user-owned completion dirs.
|
||||
func ensureDir(path string) {
|
||||
_ = os.MkdirAll(path, 0o750)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateBash(t *testing.T) {
|
||||
script, err := Generate(ShellBash)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(ShellBash) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify script contains key elements
|
||||
if !strings.Contains(script, "_kportal()") {
|
||||
t.Error("Bash script missing _kportal function")
|
||||
}
|
||||
if !strings.Contains(script, "complete -F _kportal kportal") {
|
||||
t.Error("Bash script missing completion registration")
|
||||
}
|
||||
if !strings.Contains(script, "--version") {
|
||||
t.Error("Bash script missing --version flag")
|
||||
}
|
||||
if !strings.Contains(script, "generate") {
|
||||
t.Error("Bash script missing generate subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateZsh(t *testing.T) {
|
||||
script, err := Generate(ShellZsh)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(ShellZsh) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify script contains key elements
|
||||
if !strings.Contains(script, "#compdef kportal") {
|
||||
t.Error("Zsh script missing #compdef directive")
|
||||
}
|
||||
if !strings.Contains(script, "_kportal()") {
|
||||
t.Error("Zsh script missing _kportal function")
|
||||
}
|
||||
if !strings.Contains(script, "'generate:") {
|
||||
t.Error("Zsh script missing generate subcommand")
|
||||
}
|
||||
if !strings.Contains(script, "'--context[") {
|
||||
t.Error("Zsh script missing --context flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFish(t *testing.T) {
|
||||
script, err := Generate(ShellFish)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(ShellFish) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify script contains key elements
|
||||
if !strings.Contains(script, "complete -c kportal") {
|
||||
t.Error("Fish script missing complete directive")
|
||||
}
|
||||
if !strings.Contains(script, "-n '__fish_use_subcommand'") {
|
||||
t.Error("Fish script missing subcommand detection")
|
||||
}
|
||||
if !strings.Contains(script, "-l context") {
|
||||
t.Error("Fish script missing --context flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUnsupported(t *testing.T) {
|
||||
_, err := Generate(Shell("unsupported"))
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported shell")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoDetectShell(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shellEnv string
|
||||
expected Shell
|
||||
}{
|
||||
{"bash", "/bin/bash", ShellBash},
|
||||
{"zsh", "/usr/bin/zsh", ShellZsh},
|
||||
{"fish", "/usr/local/bin/fish", ShellFish},
|
||||
{"tcsh", "/bin/tcsh", ShellBash}, // Falls back to bash
|
||||
{"empty", "", ShellBash}, // Falls back to bash
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
detected := autoDetectShellFromEnv(tt.shellEnv)
|
||||
if detected != tt.expected {
|
||||
t.Errorf("AutoDetectShell() = %v, want %v", detected, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// autoDetectShellFromEnv is a test helper that simulates shell detection
|
||||
func autoDetectShellFromEnv(shellEnv string) Shell {
|
||||
if shellEnv == "" {
|
||||
return ShellBash
|
||||
}
|
||||
if strings.HasSuffix(shellEnv, "/bash") {
|
||||
return ShellBash
|
||||
}
|
||||
if strings.HasSuffix(shellEnv, "/zsh") {
|
||||
return ShellZsh
|
||||
}
|
||||
if strings.HasSuffix(shellEnv, "/fish") {
|
||||
return ShellFish
|
||||
}
|
||||
return ShellBash
|
||||
}
|
||||
|
||||
func TestInstaller(t *testing.T) {
|
||||
// Test with bash
|
||||
installer := NewInstaller(ShellBash)
|
||||
if installer.prefixDir == "" {
|
||||
t.Error("Installer should have a prefix directory")
|
||||
}
|
||||
|
||||
// Test filename generation
|
||||
t.Run("filename", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
shell Shell
|
||||
expected string
|
||||
}{
|
||||
{ShellBash, "_kportal"},
|
||||
{ShellZsh, "_kportal"},
|
||||
{ShellFish, "kportal.fish"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
inst := NewInstaller(tt.shell)
|
||||
got := inst.getCompletionFilename()
|
||||
if got != tt.expected {
|
||||
t.Errorf("getCompletionFilename() for %v = %v, want %v", tt.shell, got, tt.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInstallCompletion(t *testing.T) {
|
||||
// Temp directory (auto-removed by the test framework)
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Test installation to temp directory
|
||||
installer := &Installer{
|
||||
shell: ShellBash,
|
||||
prefixDir: tempDir,
|
||||
}
|
||||
|
||||
// Should fail because file doesn't exist (doesn't matter, just test the method exists)
|
||||
_ = installer.Install()
|
||||
}
|
||||
|
||||
func TestPrint(t *testing.T) {
|
||||
// Test that Print doesn't crash and outputs something
|
||||
orig := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := Print(ShellBash)
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = orig
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Print(ShellBash) failed: %v", err)
|
||||
}
|
||||
|
||||
// Read output
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := r.Read(buf)
|
||||
output := string(buf[:n])
|
||||
|
||||
if !strings.Contains(output, "_kportal") {
|
||||
t.Error("Print output should contain _kportal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCompletionFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
shell Shell
|
||||
expected string
|
||||
}{
|
||||
{ShellBash, "_kportal"},
|
||||
{ShellZsh, "_kportal"},
|
||||
{ShellFish, "kportal.fish"},
|
||||
{Shell("unknown"), "_kportal"}, // Falls back
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
inst := &Installer{shell: tt.shell}
|
||||
got := inst.getCompletionFilename()
|
||||
if got != tt.expected {
|
||||
t.Errorf("getCompletionFilename() for %v = %v, want %v", tt.shell, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashCompletionIncludesContextCompletion(t *testing.T) {
|
||||
script, err := Generate(ShellBash)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(ShellBash) failed: %v", err)
|
||||
}
|
||||
|
||||
// Should include kubectl context completion
|
||||
if !strings.Contains(script, "kubectl config get-contexts") {
|
||||
t.Error("Bash completion should include kubectl context completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZshCompletionIncludesContextCompletion(t *testing.T) {
|
||||
script, err := Generate(ShellZsh)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(ShellZsh) failed: %v", err)
|
||||
}
|
||||
|
||||
// Should include kubectl context completion
|
||||
if !strings.Contains(script, "kubectl config get-contexts") {
|
||||
t.Error("Zsh completion should include kubectl context completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFishCompletionIncludesContextCompletion(t *testing.T) {
|
||||
script, err := Generate(ShellFish)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(ShellFish) failed: %v", err)
|
||||
}
|
||||
|
||||
// Should include kubectl context completion
|
||||
if !strings.Contains(script, "kubectl config get-contexts") {
|
||||
t.Error("Fish completion should include kubectl context completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllFlagsPresent(t *testing.T) {
|
||||
script, err := Generate(ShellBash)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(ShellBash) failed: %v", err)
|
||||
}
|
||||
|
||||
// Check all main flags are present. The main command exposes single-dash
|
||||
// -c/-v (no --config/--verbose long forms); word flags use --.
|
||||
flags := []string{
|
||||
"-c",
|
||||
"-v",
|
||||
"--version",
|
||||
"--update",
|
||||
"--check",
|
||||
"--context",
|
||||
"--headless",
|
||||
"--log-format",
|
||||
"--convert",
|
||||
"--convert-output",
|
||||
}
|
||||
|
||||
for _, flag := range flags {
|
||||
if !strings.Contains(script, flag) {
|
||||
t.Errorf("Bash completion missing flag: %s", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubcommandsPresent(t *testing.T) {
|
||||
for _, shell := range []Shell{ShellBash, ShellZsh, ShellFish} {
|
||||
script, err := Generate(shell)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%v) failed: %v", shell, err)
|
||||
}
|
||||
|
||||
for _, sub := range []string{"generate", "completion"} {
|
||||
if !strings.Contains(script, sub) {
|
||||
t.Errorf("%s completion missing %q subcommand", shell, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFlagsPresent(t *testing.T) {
|
||||
script, err := Generate(ShellBash)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(ShellBash) failed: %v", err)
|
||||
}
|
||||
|
||||
generateFlags := []string{
|
||||
"--context",
|
||||
"--config",
|
||||
"--dry-run",
|
||||
}
|
||||
|
||||
for _, flag := range generateFlags {
|
||||
if !strings.Contains(script, flag) {
|
||||
t.Errorf("Bash completion missing generate flag: %s", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionScriptPermissions(t *testing.T) {
|
||||
// Create temp file and verify permissions handling
|
||||
tempDir := t.TempDir()
|
||||
|
||||
installer := &Installer{
|
||||
shell: ShellBash,
|
||||
prefixDir: tempDir,
|
||||
}
|
||||
|
||||
filename := filepath.Join(tempDir, installer.getCompletionFilename())
|
||||
|
||||
// Write a test file
|
||||
err := os.WriteFile(filename, []byte("test"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists and is readable
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != "test" {
|
||||
t.Error("File content mismatch")
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,29 @@ type Config struct {
|
||||
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
|
||||
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
|
||||
MDNS *MDNSSpec `yaml:"mdns,omitempty"`
|
||||
TUI *TUISpec `yaml:"tui,omitempty"`
|
||||
Contexts []Context `yaml:"contexts"`
|
||||
}
|
||||
|
||||
// TUISpec configures the terminal UI table display
|
||||
type TUISpec struct {
|
||||
// Columns lists the forwards-table columns to display, in the order they
|
||||
// should appear. Omitting this field (or leaving it empty) keeps the
|
||||
// built-in default column set and order. Any column not listed here is
|
||||
// hidden.
|
||||
Columns []TableColumn `yaml:"columns,omitempty"`
|
||||
}
|
||||
|
||||
// TableColumn configures a single column of the forwards table.
|
||||
type TableColumn struct {
|
||||
// Name is the column identifier: context, namespace, alias, type,
|
||||
// resource, remote, local, or status.
|
||||
Name string `yaml:"name"`
|
||||
// Width is the maximum display width for the column, in characters.
|
||||
// If zero or omitted, the column's built-in default width is used.
|
||||
Width int `yaml:"width,omitempty"`
|
||||
}
|
||||
|
||||
// MDNSSpec configures mDNS (multicast DNS) hostname publishing
|
||||
// When enabled, forwards with aliases can be accessed via <alias>.local hostnames
|
||||
type MDNSSpec struct {
|
||||
@@ -174,6 +194,15 @@ func (c *Config) IsMDNSEnabled() bool {
|
||||
return c.MDNS != nil && c.MDNS.Enabled
|
||||
}
|
||||
|
||||
// GetTableColumns returns the configured TUI table columns, or nil if the
|
||||
// built-in default column set and order should be used.
|
||||
func (c *Config) GetTableColumns() []TableColumn {
|
||||
if c == nil || c.TUI == nil || len(c.TUI.Columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.TUI.Columns
|
||||
}
|
||||
|
||||
// Context represents a Kubernetes context with its namespaces
|
||||
type Context struct {
|
||||
Name string `yaml:"name"`
|
||||
|
||||
+75
-14
@@ -13,8 +13,9 @@ import (
|
||||
// All operations use atomic file writes (write to temp, then rename) to prevent
|
||||
// corruption and ensure the file watcher picks up changes.
|
||||
type Mutator struct {
|
||||
configPath string
|
||||
mu sync.Mutex // Ensure only one mutation at a time
|
||||
configPath string
|
||||
activeContexts []string
|
||||
mu sync.Mutex // Ensure only one mutation at a time
|
||||
}
|
||||
|
||||
// NewMutator creates a new configuration mutator for the given config file path.
|
||||
@@ -24,6 +25,24 @@ func NewMutator(configPath string) *Mutator {
|
||||
}
|
||||
}
|
||||
|
||||
// SetActiveContexts records which contexts are currently being forwarded, as
|
||||
// selected with --context. Local-port conflicts are then only reported between
|
||||
// forwards that could actually run at the same time, so editing a forward is
|
||||
// not blocked by a port that is only used by a context you are not running.
|
||||
//
|
||||
// Validation still covers the whole file, and the whole file is still written
|
||||
// back, so a config saved here always reloads cleanly.
|
||||
func (m *Mutator) SetActiveContexts(names []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.activeContexts = names
|
||||
}
|
||||
|
||||
// activeContextsLocked returns the active selection. Callers must hold m.mu.
|
||||
func (m *Mutator) activeContextsLocked() []string {
|
||||
return m.activeContexts
|
||||
}
|
||||
|
||||
// findOrCreateContext finds an existing context or creates a new one
|
||||
func (m *Mutator) findOrCreateContext(cfg *Config, contextName string) *Context {
|
||||
for i := range cfg.Contexts {
|
||||
@@ -82,20 +101,28 @@ func (m *Mutator) AddForward(contextName, namespaceName string, fwd Forward) err
|
||||
// Set context/namespace on the forward for validation
|
||||
fwd.SetContext(contextName, namespaceName)
|
||||
|
||||
// Check for duplicate local port
|
||||
// Check for a duplicate local port among forwards that could run alongside
|
||||
// this one. A port used only by a context that is never active at the same
|
||||
// time is free to reuse.
|
||||
active := m.activeContextsLocked()
|
||||
allForwards := cfg.GetAllForwards()
|
||||
for _, existing := range allForwards {
|
||||
if existing.LocalPort == fwd.LocalPort {
|
||||
return fmt.Errorf("port %d is already in use by %s", fwd.LocalPort, existing.String())
|
||||
if existing.LocalPort != fwd.LocalPort {
|
||||
continue
|
||||
}
|
||||
if !PortsCanConflict(existing.GetContext(), contextName, active) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("port %d is already in use by %s", fwd.LocalPort, existing.String())
|
||||
}
|
||||
|
||||
// Add the forward
|
||||
targetNamespace.Forwards = append(targetNamespace.Forwards, fwd)
|
||||
|
||||
// Validate the new configuration
|
||||
// Validate the whole configuration - including contexts that are not
|
||||
// currently active - so nothing invalid is ever written to disk.
|
||||
validator := NewValidator()
|
||||
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
|
||||
if errs := validator.ValidateConfigWithOpts(cfg, ValidateOptions{ActiveContexts: active}); len(errs) > 0 {
|
||||
return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs))
|
||||
}
|
||||
|
||||
@@ -150,7 +177,7 @@ func (m *Mutator) RemoveForwards(predicate func(ctx, ns string, fwd Forward) boo
|
||||
|
||||
// Validate the new configuration
|
||||
validator := NewValidator()
|
||||
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
|
||||
if errs := validator.ValidateConfigWithOpts(cfg, ValidateOptions{ActiveContexts: m.activeContextsLocked()}); len(errs) > 0 {
|
||||
return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs))
|
||||
}
|
||||
|
||||
@@ -159,8 +186,23 @@ func (m *Mutator) RemoveForwards(predicate func(ctx, ns string, fwd Forward) boo
|
||||
}
|
||||
|
||||
// RemoveForwardByID removes a specific forward by its ID.
|
||||
//
|
||||
// Forward.ID() is only "alias:localPort" when an alias is set, which is not
|
||||
// unique across contexts once contexts are allowed to reuse local ports.
|
||||
// Prefer RemoveForwardByIDInContext, which cannot match a forward in a context
|
||||
// the caller did not mean.
|
||||
func (m *Mutator) RemoveForwardByID(id string) error {
|
||||
return m.RemoveForwardByIDInContext("", id)
|
||||
}
|
||||
|
||||
// RemoveForwardByIDInContext removes a forward by ID from a single context.
|
||||
// An empty contextName matches the ID in every context, preserving the
|
||||
// behaviour of RemoveForwardByID.
|
||||
func (m *Mutator) RemoveForwardByIDInContext(contextName, id string) error {
|
||||
return m.RemoveForwards(func(ctx, ns string, fwd Forward) bool {
|
||||
if contextName != "" && ctx != contextName {
|
||||
return false
|
||||
}
|
||||
return fwd.ID() == id
|
||||
})
|
||||
}
|
||||
@@ -170,6 +212,17 @@ func (m *Mutator) RemoveForwardByID(id string) error {
|
||||
// If the old forward doesn't exist, returns an error.
|
||||
// If the new forward validation fails, the operation is rolled back (old forward remains).
|
||||
func (m *Mutator) UpdateForward(oldID, newContextName, newNamespaceName string, newFwd Forward) error {
|
||||
return m.UpdateForwardInContext("", oldID, newContextName, newNamespaceName, newFwd)
|
||||
}
|
||||
|
||||
// UpdateForwardInContext is UpdateForward scoped to the context the edited
|
||||
// forward came from. An empty oldContextName matches oldID in every context,
|
||||
// preserving the behaviour of UpdateForward.
|
||||
//
|
||||
// Scoping matters because Forward.ID() is only "alias:localPort" when an alias
|
||||
// is set: without a context, editing one forward would collapse every
|
||||
// same-alias forward across contexts into a single entry.
|
||||
func (m *Mutator) UpdateForwardInContext(oldContextName, oldID, newContextName, newNamespaceName string, newFwd Forward) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -192,7 +245,8 @@ func (m *Mutator) UpdateForward(oldID, newContextName, newNamespaceName string,
|
||||
// CRITICAL: Set context/namespace so fwd.ID() generates correct ID
|
||||
fwd.SetContext(ctx.Name, ns.Name)
|
||||
|
||||
if fwd.ID() == oldID {
|
||||
matchesContext := oldContextName == "" || ctx.Name == oldContextName
|
||||
if matchesContext && fwd.ID() == oldID {
|
||||
oldForwardFound = true
|
||||
// Skip this forward (remove it)
|
||||
continue
|
||||
@@ -218,20 +272,27 @@ func (m *Mutator) UpdateForward(oldID, newContextName, newNamespaceName string,
|
||||
// Set context/namespace on the forward for validation
|
||||
newFwd.SetContext(newContextName, newNamespaceName)
|
||||
|
||||
// Check for duplicate local port (excluding the one we just removed)
|
||||
// Check for a duplicate local port among forwards that could run alongside
|
||||
// this one (excluding the one we just removed).
|
||||
active := m.activeContextsLocked()
|
||||
allForwards := cfg.GetAllForwards()
|
||||
for _, existing := range allForwards {
|
||||
if existing.LocalPort == newFwd.LocalPort && existing.ID() != oldID {
|
||||
return fmt.Errorf("port %d is already in use by %s", newFwd.LocalPort, existing.String())
|
||||
if existing.LocalPort != newFwd.LocalPort || existing.ID() == oldID {
|
||||
continue
|
||||
}
|
||||
if !PortsCanConflict(existing.GetContext(), newContextName, active) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("port %d is already in use by %s", newFwd.LocalPort, existing.String())
|
||||
}
|
||||
|
||||
// Add the new forward
|
||||
targetNamespace.Forwards = append(targetNamespace.Forwards, newFwd)
|
||||
|
||||
// Validate the new configuration
|
||||
// Validate the whole configuration - including contexts that are not
|
||||
// currently active - so nothing invalid is ever written to disk.
|
||||
validator := NewValidator()
|
||||
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
|
||||
if errs := validator.ValidateConfigWithOpts(cfg, ValidateOptions{ActiveContexts: active}); len(errs) > 0 {
|
||||
return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -662,3 +663,237 @@ func TestMutator_Concurrent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}
|
||||
|
||||
// mutatorWithSharedPorts writes the issue #80 layout - the same alias and local
|
||||
// port in two contexts - and returns a mutator scoped to team-a.
|
||||
func mutatorWithSharedPorts(t *testing.T, active []string) (*Mutator, string) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
body := `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
alias: rate-service
|
||||
- resource: pod/keeper
|
||||
protocol: tcp
|
||||
port: 9000
|
||||
localPort: 3009
|
||||
alias: keeper-a
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
alias: rate-service
|
||||
- resource: pod/keeper
|
||||
protocol: tcp
|
||||
port: 9000
|
||||
localPort: 3009
|
||||
alias: keeper-b
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
m := NewMutator(path)
|
||||
m.SetActiveContexts(active)
|
||||
return m, path
|
||||
}
|
||||
|
||||
func forwardsForContext(t *testing.T, path, contextName string) []Forward {
|
||||
t.Helper()
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload config: %v", err)
|
||||
}
|
||||
var out []Forward
|
||||
for _, fwd := range cfg.GetAllForwards() {
|
||||
if fwd.GetContext() == contextName {
|
||||
out = append(out, fwd)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMutator_RemoveForwardByIDInContext_OnlyTouchesOneContext is the
|
||||
// regression test for the data-loss case that --context makes reachable:
|
||||
// Forward.ID() is just "alias:localPort" when an alias is set, so an unscoped
|
||||
// delete would strip the forward from every context at once.
|
||||
func TestMutator_RemoveForwardByIDInContext_OnlyTouchesOneContext(t *testing.T) {
|
||||
m, path := mutatorWithSharedPorts(t, []string{"team-a"})
|
||||
|
||||
if err := m.RemoveForwardByIDInContext("team-a", "rate-service:3004"); err != nil {
|
||||
t.Fatalf("RemoveForwardByIDInContext() failed: %v", err)
|
||||
}
|
||||
|
||||
// Only the shared-alias forward goes; team-a keeps its other one and
|
||||
// team-b is untouched entirely.
|
||||
teamA := forwardsForContext(t, path, "team-a")
|
||||
if len(teamA) != 1 || teamA[0].Alias != "keeper-a" {
|
||||
t.Errorf("team-a should keep only keeper-a, got %+v", teamA)
|
||||
}
|
||||
if got := forwardsForContext(t, path, "team-b"); len(got) != 2 {
|
||||
t.Errorf("team-b must be untouched, got %d forwards", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMutator_UpdateForwardInContext_OnlyTouchesOneContext guards the same
|
||||
// identity problem on the edit path.
|
||||
func TestMutator_UpdateForwardInContext_OnlyTouchesOneContext(t *testing.T) {
|
||||
m, path := mutatorWithSharedPorts(t, []string{"team-a"})
|
||||
|
||||
updated := Forward{
|
||||
Resource: "pod/rpc-server",
|
||||
Protocol: "tcp",
|
||||
Port: 50052,
|
||||
LocalPort: 3004,
|
||||
Alias: "rate-service",
|
||||
}
|
||||
if err := m.UpdateForwardInContext("team-a", "rate-service:3004", "team-a", "rate-service", updated); err != nil {
|
||||
t.Fatalf("UpdateForwardInContext() failed: %v", err)
|
||||
}
|
||||
|
||||
teamA := forwardsForContext(t, path, "team-a")
|
||||
if len(teamA) != 2 {
|
||||
t.Fatalf("team-a should still have two forwards, got %d", len(teamA))
|
||||
}
|
||||
|
||||
teamB := forwardsForContext(t, path, "team-b")
|
||||
if len(teamB) != 2 {
|
||||
t.Fatalf("team-b must keep both forwards, got %d", len(teamB))
|
||||
}
|
||||
for _, fwd := range teamB {
|
||||
if fwd.Alias == "rate-service" && fwd.Port != 50051 {
|
||||
t.Errorf("team-b remote port = %d, want 50051 (unchanged)", fwd.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMutator_AddForward_PortFreeInNonActiveContext covers the TUI add path for
|
||||
// a config that reuses ports across contexts.
|
||||
func TestMutator_AddForward_PortFreeInNonActiveContext(t *testing.T) {
|
||||
m, path := mutatorWithSharedPorts(t, []string{"team-a"})
|
||||
|
||||
// 3005 is unused anywhere; adding it to the active context must succeed
|
||||
// even though team-b exists with a colliding 3004 entry.
|
||||
fwd := Forward{Resource: "pod/other", Protocol: "tcp", Port: 8080, LocalPort: 3005, Alias: "other"}
|
||||
if err := m.AddForward("team-a", "rate-service", fwd); err != nil {
|
||||
t.Fatalf("AddForward() failed: %v", err)
|
||||
}
|
||||
|
||||
// The file must still hold both contexts and reload cleanly.
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload written config: %v", err)
|
||||
}
|
||||
if len(cfg.Contexts) != 2 {
|
||||
t.Errorf("written config lost a context: got %d, want 2", len(cfg.Contexts))
|
||||
}
|
||||
if got := len(forwardsForContext(t, path, "team-a")); got != 3 {
|
||||
t.Errorf("team-a should now have three forwards, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMutator_AddForward_PortTakenInActiveContext keeps the guard rail: a real
|
||||
// clash inside the running selection is still refused.
|
||||
func TestMutator_AddForward_PortTakenInActiveContext(t *testing.T) {
|
||||
m, _ := mutatorWithSharedPorts(t, []string{"team-a"})
|
||||
|
||||
fwd := Forward{Resource: "pod/other", Protocol: "tcp", Port: 8080, LocalPort: 3004, Alias: "other"}
|
||||
err := m.AddForward("team-a", "rate-service", fwd)
|
||||
if err == nil {
|
||||
t.Fatal("AddForward() should reject a port already used in the active context")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "3004") {
|
||||
t.Errorf("error should name the conflicting port, got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMutator_AddForward_ValidatesNonActiveContexts closes the hole where
|
||||
// validating only the active selection would let an invalid forward reach disk.
|
||||
func TestMutator_AddForward_ValidatesNonActiveContexts(t *testing.T) {
|
||||
m, path := mutatorWithSharedPorts(t, []string{"team-a"})
|
||||
|
||||
// The wizard lets the user pick any kubeconfig context, including one that
|
||||
// is not currently being forwarded. It must still be validated.
|
||||
bad := Forward{Resource: "not-a-valid-resource-format", Protocol: "tcp", Port: 8080, LocalPort: 4000}
|
||||
if err := m.AddForward("team-c", "default", bad); err == nil {
|
||||
t.Fatal("AddForward() must validate forwards added to non-active contexts")
|
||||
}
|
||||
|
||||
// Nothing should have been written.
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload config: %v", err)
|
||||
}
|
||||
if len(cfg.Contexts) != 2 {
|
||||
t.Errorf("rejected add must not modify the file, got %d contexts", len(cfg.Contexts))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMutator_RemoveForwardByID_UnscopedStillMatchesEveryContext documents that
|
||||
// the original entry point keeps its behaviour for callers that have no context.
|
||||
// The fixture uses distinct ports so it is valid with no selection at all.
|
||||
func TestMutator_RemoveForwardByID_UnscopedStillMatchesEveryContext(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
body := `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
alias: shared
|
||||
- resource: pod/keeper
|
||||
protocol: tcp
|
||||
port: 9000
|
||||
localPort: 3009
|
||||
alias: keeper-a
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
alias: shared
|
||||
- resource: pod/keeper
|
||||
protocol: tcp
|
||||
port: 9000
|
||||
localPort: 3010
|
||||
alias: keeper-b
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
m := NewMutator(path)
|
||||
// Scope to team-a so the shared 3004 entries are legal; the point of the
|
||||
// test is that the unscoped remove still matches both of them.
|
||||
m.SetActiveContexts([]string{"team-a"})
|
||||
|
||||
if err := m.RemoveForwardByID("shared:3004"); err != nil {
|
||||
t.Fatalf("RemoveForwardByID() failed: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload config: %v", err)
|
||||
}
|
||||
// Both rate-service entries go; only the two keeper forwards remain.
|
||||
if got := len(cfg.GetAllForwards()); got != 2 {
|
||||
t.Errorf("unscoped remove should match both contexts, %d forwards left", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
)
|
||||
|
||||
// NormalizeContextSelection turns raw --context flag values into a clean
|
||||
// selection: comma-separated entries are split, surrounding whitespace is
|
||||
// trimmed, empty segments are dropped, and duplicates are removed while
|
||||
// preserving first-seen order.
|
||||
//
|
||||
// Duplicates matter beyond tidiness: a repeated name would select the same
|
||||
// context twice, producing duplicate forward IDs and spurious port conflicts.
|
||||
func NormalizeContextSelection(names []string) []string {
|
||||
var (
|
||||
out []string
|
||||
seen = make(map[string]struct{})
|
||||
)
|
||||
|
||||
for _, raw := range names {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
name := strings.TrimSpace(part)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// contextIsActive reports whether name is part of the active selection. An
|
||||
// empty selection means every context is active, which is kportal's default.
|
||||
func contextIsActive(name string, activeContexts []string) bool {
|
||||
if len(activeContexts) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, active := range activeContexts {
|
||||
if active == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PortsCanConflict reports whether forwards living in contextA and contextB can
|
||||
// ever run at the same time and therefore compete for the same local port.
|
||||
//
|
||||
// Two forwards collide when they share a context, or when both of their
|
||||
// contexts are active simultaneously. Forwards in contexts that are never
|
||||
// started together are free to reuse local ports - which is the whole point of
|
||||
// selecting contexts: the same service can keep the same local port in every
|
||||
// cluster.
|
||||
func PortsCanConflict(contextA, contextB string, activeContexts []string) bool {
|
||||
if contextA == contextB {
|
||||
return true
|
||||
}
|
||||
return contextIsActive(contextA, activeContexts) && contextIsActive(contextB, activeContexts)
|
||||
}
|
||||
|
||||
// SelectContexts returns a shallow copy of c containing only the named
|
||||
// contexts, keeping the order they appear in the config file so the UI's row
|
||||
// order stays stable regardless of the order names were passed on the command
|
||||
// line. An empty selection returns c unchanged.
|
||||
//
|
||||
// Unknown names are an error listing the contexts that do exist, so a typo
|
||||
// surfaces immediately instead of silently forwarding nothing.
|
||||
//
|
||||
// The returned config shares backing arrays with c and must never be written
|
||||
// back to disk - use the original config for that.
|
||||
func (c *Config) SelectContexts(names []string) (*Config, error) {
|
||||
selected, missing := c.selectContexts(names)
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("unknown context %s in --context (available: %s)",
|
||||
quoteAll(missing), availableContexts(c))
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
// selectContextsLenient is SelectContexts without the error: unknown names are
|
||||
// dropped and returned to the caller. Reload paths use this so that renaming a
|
||||
// context in the config file does not freeze hot-reload for the rest of the
|
||||
// session.
|
||||
func (c *Config) selectContextsLenient(names []string) (*Config, []string) {
|
||||
return c.selectContexts(names)
|
||||
}
|
||||
|
||||
func (c *Config) selectContexts(names []string) (*Config, []string) {
|
||||
if len(names) == 0 {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
wanted := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
wanted[name] = false
|
||||
}
|
||||
|
||||
// Iterate the config rather than the selection so file order is preserved.
|
||||
filtered := make([]Context, 0, len(names))
|
||||
for _, ctx := range c.Contexts {
|
||||
if _, ok := wanted[ctx.Name]; !ok {
|
||||
continue
|
||||
}
|
||||
wanted[ctx.Name] = true
|
||||
filtered = append(filtered, ctx)
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, name := range names {
|
||||
if !wanted[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
|
||||
selected := *c
|
||||
selected.Contexts = filtered
|
||||
return &selected, missing
|
||||
}
|
||||
|
||||
// availableContexts renders the context names present in the config, for use
|
||||
// in error messages.
|
||||
func availableContexts(c *Config) string {
|
||||
if len(c.Contexts) == 0 {
|
||||
return "none defined in the config file"
|
||||
}
|
||||
names := make([]string, 0, len(c.Contexts))
|
||||
for _, ctx := range c.Contexts {
|
||||
names = append(names, ctx.Name)
|
||||
}
|
||||
return quoteAll(names)
|
||||
}
|
||||
|
||||
func quoteAll(names []string) string {
|
||||
quoted := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", name))
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// LoadForRuntime loads, validates and filters the configuration for the
|
||||
// forward manager. Validation runs against the *whole* file - so a mistake in a
|
||||
// context you are not currently forwarding is still reported - while the
|
||||
// uniqueness checks that only matter for running forwards (local ports, mDNS
|
||||
// hostnames) are scoped to activeContexts.
|
||||
//
|
||||
// Every path that re-reads the config at runtime goes through here, so the
|
||||
// selection cannot be forgotten in one of them.
|
||||
func LoadForRuntime(path string, activeContexts []string) (*Config, error) {
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
validator := NewValidator()
|
||||
if errs := validator.ValidateConfigWithOpts(cfg, ValidateOptions{
|
||||
ActiveContexts: activeContexts,
|
||||
}); len(errs) > 0 {
|
||||
return nil, fmt.Errorf("configuration is invalid:\n%s", FormatValidationErrors(errs))
|
||||
}
|
||||
|
||||
selected, missing := cfg.selectContextsLenient(activeContexts)
|
||||
// Keep the running config when every selected context vanished: an empty
|
||||
// result would stop all forwards after a single rename in the file.
|
||||
if len(selected.Contexts) == 0 && len(missing) > 0 {
|
||||
return nil, fmt.Errorf("none of the selected contexts %s exist in the config file (available: %s)",
|
||||
quoteAll(missing), availableContexts(cfg))
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
logger.Info("Ignoring selected contexts that are no longer in the config file", map[string]interface{}{
|
||||
"contexts": strings.Join(missing, ", "),
|
||||
})
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeContextSelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
want []string
|
||||
}{
|
||||
{name: "nil input", input: nil, want: nil},
|
||||
{name: "single name", input: []string{"team-a"}, want: []string{"team-a"}},
|
||||
{name: "repeated flag", input: []string{"team-a", "team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "comma separated", input: []string{"team-a,team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "repeated and comma mixed", input: []string{"team-a,team-b", "team-c"}, want: []string{"team-a", "team-b", "team-c"}},
|
||||
{name: "whitespace trimmed", input: []string{" team-a , team-b "}, want: []string{"team-a", "team-b"}},
|
||||
{name: "empty segments dropped", input: []string{"team-a,,team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "duplicates removed", input: []string{"team-a,team-a", "team-a"}, want: []string{"team-a"}},
|
||||
{name: "only separators", input: []string{",,"}, want: nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, NormalizeContextSelection(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortsCanConflict(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contextA string
|
||||
contextB string
|
||||
active []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same context always conflicts",
|
||||
contextA: "team-a", contextB: "team-a", active: []string{"team-b"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no selection means everything runs together",
|
||||
contextA: "team-a", contextB: "team-b", active: nil,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "both selected conflict",
|
||||
contextA: "team-a", contextB: "team-b", active: []string{"team-a", "team-b"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "only one selected does not conflict",
|
||||
contextA: "team-a", contextB: "team-b", active: []string{"team-a"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "neither selected does not conflict",
|
||||
contextA: "team-a", contextB: "team-b", active: []string{"team-c"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, PortsCanConflict(tt.contextA, tt.contextB, tt.active))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// threeContextConfig builds a config with three contexts, each holding one
|
||||
// forward on the same local port - the layout this feature exists to enable.
|
||||
func threeContextConfig(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
cfg, err := ParseConfig([]byte(`
|
||||
mdns:
|
||||
enabled: false
|
||||
healthCheck:
|
||||
interval: "5s"
|
||||
contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
alias: rate-service-a
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
alias: rate-service-b
|
||||
- name: team-c
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
alias: rate-service-c
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func contextNames(cfg *Config) []string {
|
||||
names := make([]string, 0, len(cfg.Contexts))
|
||||
for _, ctx := range cfg.Contexts {
|
||||
names = append(names, ctx.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestConfig_SelectContexts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantErr string
|
||||
selection []string
|
||||
want []string
|
||||
}{
|
||||
{name: "empty selection keeps everything", selection: nil, want: []string{"team-a", "team-b", "team-c"}},
|
||||
{name: "single context", selection: []string{"team-b"}, want: []string{"team-b"}},
|
||||
{name: "multiple contexts", selection: []string{"team-a", "team-c"}, want: []string{"team-a", "team-c"}},
|
||||
{
|
||||
name: "config order wins over flag order",
|
||||
selection: []string{"team-c", "team-a"},
|
||||
want: []string{"team-a", "team-c"},
|
||||
},
|
||||
{
|
||||
name: "unknown context errors and lists what exists",
|
||||
selection: []string{"team-z"},
|
||||
wantErr: `unknown context "team-z" in --context (available: "team-a", "team-b", "team-c")`,
|
||||
},
|
||||
{
|
||||
name: "one unknown among known still errors",
|
||||
selection: []string{"team-a", "team-z"},
|
||||
wantErr: `unknown context "team-z"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := threeContextConfig(t)
|
||||
|
||||
got, err := cfg.SelectContexts(tt.selection)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, contextNames(got))
|
||||
// The original config must not be modified.
|
||||
assert.Equal(t, []string{"team-a", "team-b", "team-c"}, contextNames(cfg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_SelectContexts_PreservesTopLevelSettings(t *testing.T) {
|
||||
cfg := threeContextConfig(t)
|
||||
|
||||
selected, err := cfg.SelectContexts([]string{"team-a"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, selected.HealthCheck)
|
||||
assert.Equal(t, "5s", selected.HealthCheck.Interval)
|
||||
assert.Equal(t, cfg.MDNS, selected.MDNS)
|
||||
assert.Len(t, selected.GetAllForwards(), 1)
|
||||
}
|
||||
|
||||
func TestConfig_SelectContexts_EmptyConfig(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
|
||||
// An empty selection is a no-op even with nothing to select.
|
||||
same, err := cfg.SelectContexts(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, cfg, same)
|
||||
|
||||
_, err = cfg.SelectContexts([]string{"team-a"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "none defined in the config file")
|
||||
}
|
||||
|
||||
func TestConfig_SelectContexts_ContextWithNoForwards(t *testing.T) {
|
||||
cfg := &Config{Contexts: []Context{{Name: "empty"}}}
|
||||
|
||||
selected, err := cfg.SelectContexts([]string{"empty"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"empty"}, contextNames(selected))
|
||||
assert.Empty(t, selected.GetAllForwards())
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
|
||||
return path
|
||||
}
|
||||
|
||||
// sharedPortConfig is the reporter's layout from issue #80: the same service on
|
||||
// the same local port in every cluster.
|
||||
const sharedPortConfig = `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
`
|
||||
|
||||
func TestLoadForRuntime_SharedPortsAcrossNonSelectedContexts(t *testing.T) {
|
||||
path := writeConfig(t, sharedPortConfig)
|
||||
|
||||
// Without a selection both contexts run together, so the shared port is a
|
||||
// genuine conflict.
|
||||
_, err := LoadForRuntime(path, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Duplicate local port 3004")
|
||||
|
||||
// Selecting one context makes the layout legal and narrows what runs.
|
||||
cfg, err := LoadForRuntime(path, []string{"team-a"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"team-a"}, contextNames(cfg))
|
||||
require.Len(t, cfg.GetAllForwards(), 1)
|
||||
|
||||
// Selecting both brings the conflict back - they really would collide.
|
||||
_, err = LoadForRuntime(path, []string{"team-a", "team-b"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Duplicate local port 3004")
|
||||
}
|
||||
|
||||
func TestLoadForRuntime_UnknownSelectedContextIsDropped(t *testing.T) {
|
||||
path := writeConfig(t, sharedPortConfig)
|
||||
|
||||
// A context renamed in the file must not freeze hot-reload for the session.
|
||||
cfg, err := LoadForRuntime(path, []string{"team-a", "renamed-away"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"team-a"}, contextNames(cfg))
|
||||
}
|
||||
|
||||
func TestLoadForRuntime_SelectionReducingToNothingIsAnError(t *testing.T) {
|
||||
path := writeConfig(t, sharedPortConfig)
|
||||
|
||||
cfg, err := LoadForRuntime(path, []string{"gone"})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, cfg)
|
||||
assert.Contains(t, err.Error(), `"gone"`)
|
||||
assert.Contains(t, err.Error(), `"team-a"`)
|
||||
}
|
||||
|
||||
func TestLoadForRuntime_ReportsErrorsInNonSelectedContexts(t *testing.T) {
|
||||
// A broken forward in a context we are not forwarding is still a broken
|
||||
// config file, and must be reported.
|
||||
path := writeConfig(t, `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/ok
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 8080
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/bad
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 999999
|
||||
`)
|
||||
|
||||
_, err := LoadForRuntime(path, []string{"team-a"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "localPort")
|
||||
}
|
||||
|
||||
func TestLoadForRuntime_MissingFile(t *testing.T) {
|
||||
_, err := LoadForRuntime(filepath.Join(t.TempDir(), "absent.yaml"), nil)
|
||||
assert.ErrorIs(t, err, ErrConfigNotFound)
|
||||
}
|
||||
+169
-19
@@ -1,8 +1,10 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -63,6 +65,20 @@ func NewValidator() *Validator {
|
||||
return &Validator{}
|
||||
}
|
||||
|
||||
// ValidateOptions tunes how a configuration is validated.
|
||||
type ValidateOptions struct {
|
||||
// ActiveContexts names the contexts that will actually be forwarded, as
|
||||
// selected with --context. Checks for resources that can only clash while
|
||||
// running - local ports and mDNS hostnames - are scoped to these contexts,
|
||||
// so contexts that are never started together may reuse the same values.
|
||||
// An empty slice means every context is active, which is the default.
|
||||
ActiveContexts []string
|
||||
|
||||
// AllowEmpty permits configurations with no contexts or forwards. Used for
|
||||
// newly created config files where the user will add forwards via the TUI.
|
||||
AllowEmpty bool
|
||||
}
|
||||
|
||||
// ValidateConfig validates the entire configuration and returns all errors found.
|
||||
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
||||
return v.ValidateConfigWithOptions(cfg, false)
|
||||
@@ -72,7 +88,13 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
||||
// When allowEmpty is true, empty configurations (no contexts/forwards) are allowed.
|
||||
// This is useful for newly created config files where the user will add forwards via the TUI.
|
||||
func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []ValidationError {
|
||||
return v.ValidateConfigWithOpts(cfg, ValidateOptions{AllowEmpty: allowEmpty})
|
||||
}
|
||||
|
||||
// ValidateConfigWithOpts validates configuration against the given options.
|
||||
func (v *Validator) ValidateConfigWithOpts(cfg *Config, opts ValidateOptions) []ValidationError {
|
||||
var errs []ValidationError
|
||||
allowEmpty := opts.AllowEmpty
|
||||
|
||||
if cfg == nil {
|
||||
return []ValidationError{{
|
||||
@@ -85,6 +107,7 @@ func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []Va
|
||||
if allowEmpty && cfg.IsEmpty() {
|
||||
// Still validate health check and reliability if present (they don't require forwards)
|
||||
errs = append(errs, v.validateSpecDurations(cfg)...)
|
||||
errs = append(errs, v.validateTUI(cfg)...)
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -101,16 +124,19 @@ func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []Va
|
||||
}
|
||||
|
||||
// Check for duplicate local ports
|
||||
errs = append(errs, v.validateDuplicatePorts(cfg)...)
|
||||
errs = append(errs, v.validateDuplicatePorts(cfg, opts.ActiveContexts)...)
|
||||
|
||||
// Validate mDNS configuration
|
||||
if cfg.IsMDNSEnabled() {
|
||||
errs = append(errs, v.validateMDNS(cfg)...)
|
||||
errs = append(errs, v.validateMDNS(cfg, opts.ActiveContexts)...)
|
||||
}
|
||||
|
||||
// Validate duration fields in specs
|
||||
errs = append(errs, v.validateSpecDurations(cfg)...)
|
||||
|
||||
// Validate TUI table column configuration
|
||||
errs = append(errs, v.validateTUI(cfg)...)
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -302,28 +328,58 @@ func (v *Validator) validateResource(fwd *Forward) []ValidationError {
|
||||
return errs
|
||||
}
|
||||
|
||||
// validateDuplicatePorts checks for duplicate local ports across all forwards.
|
||||
func (v *Validator) validateDuplicatePorts(cfg *Config) []ValidationError {
|
||||
// conflictScope returns the key that groups forwards which can run at the same
|
||||
// time. Active contexts all share one scope because they run together; each
|
||||
// inactive context gets its own, so it may reuse the ports and hostnames of
|
||||
// contexts it is never started alongside.
|
||||
func conflictScope(contextName string, activeContexts []string) string {
|
||||
if contextIsActive(contextName, activeContexts) {
|
||||
return "" // the shared "currently running" scope
|
||||
}
|
||||
return contextName
|
||||
}
|
||||
|
||||
// validateDuplicatePorts checks for local ports claimed by more than one
|
||||
// forward that could be running simultaneously. Forwards in contexts that are
|
||||
// never active together are allowed to share a local port.
|
||||
func (v *Validator) validateDuplicatePorts(cfg *Config, activeContexts []string) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
portMap := make(map[int][]string) // port -> list of forward IDs
|
||||
type portKey struct {
|
||||
scope string
|
||||
port int
|
||||
}
|
||||
portMap := make(map[portKey][]string) // port within a scope -> list of forward IDs
|
||||
|
||||
for _, ctx := range cfg.Contexts {
|
||||
for _, ns := range ctx.Namespaces {
|
||||
for _, fwd := range ns.Forwards {
|
||||
portMap[fwd.LocalPort] = append(portMap[fwd.LocalPort], fwd.ID())
|
||||
key := portKey{scope: conflictScope(ctx.Name, activeContexts), port: fwd.LocalPort}
|
||||
portMap[key] = append(portMap[key], fwd.ID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find duplicates
|
||||
for port, forwards := range portMap {
|
||||
// Find duplicates. Sort the keys so the reported errors are deterministic.
|
||||
keys := make([]portKey, 0, len(portMap))
|
||||
for key := range portMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].port != keys[j].port {
|
||||
return keys[i].port < keys[j].port
|
||||
}
|
||||
return keys[i].scope < keys[j].scope
|
||||
})
|
||||
|
||||
for _, key := range keys {
|
||||
forwards := portMap[key]
|
||||
if len(forwards) > 1 {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "localPort",
|
||||
Message: fmt.Sprintf("Duplicate local port %d used by multiple forwards", port),
|
||||
Message: fmt.Sprintf("Duplicate local port %d used by multiple forwards", key.port),
|
||||
Context: map[string]string{
|
||||
"port": fmt.Sprintf("%d", port),
|
||||
"port": fmt.Sprintf("%d", key.port),
|
||||
"forwards": strings.Join(forwards, ", "),
|
||||
},
|
||||
})
|
||||
@@ -447,10 +503,10 @@ func FormatValidationErrors(errs []ValidationError) string {
|
||||
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
|
||||
|
||||
for i, err := range errs {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, err.Message))
|
||||
fmt.Fprintf(&sb, "%d. %s\n", i+1, err.Message)
|
||||
if len(err.Context) > 0 {
|
||||
for k, v := range err.Context {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v))
|
||||
fmt.Fprintf(&sb, " %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
@@ -462,10 +518,14 @@ func FormatValidationErrors(errs []ValidationError) string {
|
||||
// validateMDNS validates mDNS configuration when enabled.
|
||||
// It checks that aliases used for mDNS hostnames are valid and unique.
|
||||
// This includes both explicit aliases and auto-generated ones from resource names.
|
||||
func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
|
||||
func (v *Validator) validateMDNS(cfg *Config, activeContexts []string) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
aliasMap := make(map[string][]string) // alias -> list of forward IDs using it
|
||||
type aliasKey struct {
|
||||
scope string
|
||||
alias string
|
||||
}
|
||||
aliasMap := make(map[aliasKey][]string) // alias within a scope -> list of forward IDs using it
|
||||
|
||||
for _, ctx := range cfg.Contexts {
|
||||
for _, ns := range ctx.Namespaces {
|
||||
@@ -485,19 +545,33 @@ func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
|
||||
})
|
||||
}
|
||||
|
||||
aliasMap[mdnsAlias] = append(aliasMap[mdnsAlias], fwd.ID())
|
||||
key := aliasKey{scope: conflictScope(ctx.Name, activeContexts), alias: mdnsAlias}
|
||||
aliasMap[key] = append(aliasMap[key], fwd.ID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate aliases (would cause mDNS conflicts)
|
||||
for alias, forwards := range aliasMap {
|
||||
// Check for duplicate aliases among forwards that can run together (would
|
||||
// cause mDNS conflicts). Sort the keys so reported errors are deterministic.
|
||||
keys := make([]aliasKey, 0, len(aliasMap))
|
||||
for key := range aliasMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].alias != keys[j].alias {
|
||||
return keys[i].alias < keys[j].alias
|
||||
}
|
||||
return keys[i].scope < keys[j].scope
|
||||
})
|
||||
|
||||
for _, key := range keys {
|
||||
forwards := aliasMap[key]
|
||||
if len(forwards) > 1 {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "alias",
|
||||
Message: fmt.Sprintf("Duplicate mDNS hostname '%s' used by multiple forwards (would cause conflict)", alias),
|
||||
Message: fmt.Sprintf("Duplicate mDNS hostname '%s' used by multiple forwards (would cause conflict)", key.alias),
|
||||
Context: map[string]string{
|
||||
"alias": alias,
|
||||
"alias": key.alias,
|
||||
"forwards": strings.Join(forwards, ", "),
|
||||
},
|
||||
})
|
||||
@@ -507,6 +581,74 @@ func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
|
||||
return errs
|
||||
}
|
||||
|
||||
// maxTableColumnWidth caps tui.columns width so a typo cannot blow up the table.
|
||||
const maxTableColumnWidth = 200
|
||||
|
||||
// validTableColumnNames are the recognized forwards-table column identifiers.
|
||||
var validTableColumnNames = map[string]bool{
|
||||
"context": true,
|
||||
"namespace": true,
|
||||
"alias": true,
|
||||
"type": true,
|
||||
"resource": true,
|
||||
"remote": true,
|
||||
"local": true,
|
||||
"status": true,
|
||||
}
|
||||
|
||||
// validateTUI validates the tui.columns configuration.
|
||||
func (v *Validator) validateTUI(cfg *Config) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
if cfg.TUI == nil {
|
||||
return errs
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for i, col := range cfg.TUI.Columns {
|
||||
name := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
|
||||
if name == "" {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: fmt.Sprintf("tui.columns[%d].name", i),
|
||||
Message: "Column name cannot be empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if !validTableColumnNames[name] {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: fmt.Sprintf("tui.columns[%d].name", i),
|
||||
Message: fmt.Sprintf("Invalid column name '%s' (must be one of: context, namespace, alias, type, resource, remote, local, status)", col.Name),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[name] {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: fmt.Sprintf("tui.columns[%d].name", i),
|
||||
Message: fmt.Sprintf("Duplicate column '%s' in tui.columns", name),
|
||||
})
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
if col.Width < 0 {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: fmt.Sprintf("tui.columns[%d].width", i),
|
||||
Message: fmt.Sprintf("Column '%s' width cannot be negative", name),
|
||||
})
|
||||
} else if col.Width > maxTableColumnWidth {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: fmt.Sprintf("tui.columns[%d].width", i),
|
||||
Message: fmt.Sprintf("Column '%s' width cannot exceed %d", name, maxTableColumnWidth),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// isValidHostname checks if a string is a valid RFC 1123 hostname.
|
||||
// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
|
||||
// and be 1-63 characters long.
|
||||
@@ -625,6 +767,14 @@ func validateDNS1123Label(name, field, entityType string) *ValidationError {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateNamespaceName validates a Kubernetes namespace name (a DNS-1123 label).
|
||||
func ValidateNamespaceName(name string) error {
|
||||
if verr := validateDNS1123Label(name, "namespace", "Namespace"); verr != nil {
|
||||
return errors.New(verr.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDNS1123Subdomain validates that a name is a valid DNS subdomain name (RFC 1123).
|
||||
// Used for resource names which can contain dots.
|
||||
func validateDNS1123Subdomain(name, field, entityType string) *ValidationError {
|
||||
|
||||
@@ -526,7 +526,7 @@ func TestValidator_CheckDuplicatePorts(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
errs := validator.validateDuplicatePorts(tt.config)
|
||||
errs := validator.validateDuplicatePorts(tt.config, nil)
|
||||
|
||||
if tt.expectErrors {
|
||||
assert.NotEmpty(t, errs, "expected validation errors")
|
||||
@@ -2050,3 +2050,310 @@ func TestValidateProtocol(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_ValidateTUI(t *testing.T) {
|
||||
validator := NewValidator()
|
||||
|
||||
baseContexts := []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app", Protocol: "tcp", Port: 8080, LocalPort: 8080, contextName: "dev", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
config *Config
|
||||
name string
|
||||
errorContains string
|
||||
expectErrors bool
|
||||
}{
|
||||
{
|
||||
name: "no tui config - no validation",
|
||||
config: &Config{Contexts: baseContexts},
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "valid columns",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{
|
||||
Columns: []TableColumn{
|
||||
{Name: "alias", Width: 30},
|
||||
{Name: "status"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "unknown column name",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{Columns: []TableColumn{{Name: "bogus"}}},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: "Invalid column name",
|
||||
},
|
||||
{
|
||||
name: "empty column name",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{Columns: []TableColumn{{Name: ""}}},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: "cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate column",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias"}, {Name: "Alias"}}},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: "Duplicate column",
|
||||
},
|
||||
{
|
||||
name: "negative width",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: -1}}},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: "cannot be negative",
|
||||
},
|
||||
{
|
||||
name: "width at cap",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: 200}}},
|
||||
},
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "width above cap",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: 201}}},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: "cannot exceed 200",
|
||||
},
|
||||
{
|
||||
name: "name is case and whitespace insensitive",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{Columns: []TableColumn{{Name: " Alias "}, {Name: "STATUS"}}},
|
||||
},
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "padded duplicate is detected",
|
||||
config: &Config{
|
||||
Contexts: baseContexts,
|
||||
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias"}, {Name: " Alias "}}},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: "Duplicate column",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
errs := validator.ValidateConfig(tt.config)
|
||||
|
||||
if tt.expectErrors {
|
||||
assert.NotEmpty(t, errs)
|
||||
found := false
|
||||
for _, err := range errs {
|
||||
if strings.Contains(err.Message, tt.errorContains) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected error message '%s' not found in errors: %v", tt.errorContains, errs)
|
||||
} else {
|
||||
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidator_DuplicatePortsScopedToActiveContexts covers the rule that makes
|
||||
// --context useful: forwards in contexts that are never running at the same
|
||||
// time may reuse a local port, while forwards that would genuinely collide are
|
||||
// still rejected.
|
||||
func TestValidator_DuplicatePortsScopedToActiveContexts(t *testing.T) {
|
||||
validator := NewValidator()
|
||||
|
||||
buildConfig := func() *Config {
|
||||
cfg, err := ParseConfig([]byte(`contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig() failed: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
active []string
|
||||
expectErrors bool
|
||||
}{
|
||||
{name: "no selection keeps the global check", active: nil, expectErrors: true},
|
||||
{name: "one active context allows the shared port", active: []string{"team-a"}, expectErrors: false},
|
||||
{name: "other active context allows it too", active: []string{"team-b"}, expectErrors: false},
|
||||
{name: "both active reinstates the conflict", active: []string{"team-a", "team-b"}, expectErrors: true},
|
||||
{name: "neither active leaves them isolated", active: []string{"team-c"}, expectErrors: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
errs := validator.ValidateConfigWithOpts(buildConfig(), ValidateOptions{ActiveContexts: tt.active})
|
||||
|
||||
var portErrs []ValidationError
|
||||
for _, err := range errs {
|
||||
if err.Field == "localPort" {
|
||||
portErrs = append(portErrs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.expectErrors {
|
||||
assert.NotEmpty(t, portErrs, "expected a duplicate local port error")
|
||||
} else {
|
||||
assert.Empty(t, portErrs, "shared port should be allowed: %v", portErrs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidator_DuplicatePortsWithinContextAlwaysConflict guards the case a
|
||||
// scope key must never hide: two forwards in the SAME context always collide.
|
||||
func TestValidator_DuplicatePortsWithinContextAlwaysConflict(t *testing.T) {
|
||||
cfg, err := ParseConfig([]byte(`contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/one
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 8080
|
||||
- resource: pod/two
|
||||
protocol: tcp
|
||||
port: 81
|
||||
localPort: 8080
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig() failed: %v", err)
|
||||
}
|
||||
|
||||
for _, active := range [][]string{nil, {"team-a"}, {"team-b"}} {
|
||||
errs := NewValidator().ValidateConfigWithOpts(cfg, ValidateOptions{ActiveContexts: active})
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if e.Field == "localPort" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "same-context duplicate must conflict (active=%v)", active)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidator_StructuralErrorsNotHiddenBySelection proves --check did not get
|
||||
// weaker: a problem in a context that is not being forwarded is still reported.
|
||||
func TestValidator_StructuralErrorsNotHiddenBySelection(t *testing.T) {
|
||||
cfg, err := ParseConfig([]byte(`contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/ok
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 8080
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: ""
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 9090
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig() failed: %v", err)
|
||||
}
|
||||
|
||||
errs := NewValidator().ValidateConfigWithOpts(cfg, ValidateOptions{ActiveContexts: []string{"team-a"}})
|
||||
assert.NotEmpty(t, errs, "an invalid forward in a non-selected context must still be reported")
|
||||
}
|
||||
|
||||
// TestValidator_DuplicateMDNSAliasScopedToActiveContexts mirrors the local-port
|
||||
// rule for mDNS hostnames, which can only clash while forwards are running.
|
||||
func TestValidator_DuplicateMDNSAliasScopedToActiveContexts(t *testing.T) {
|
||||
buildConfig := func() *Config {
|
||||
cfg, err := ParseConfig([]byte(`mdns:
|
||||
enabled: true
|
||||
contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: service/api
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 8080
|
||||
alias: api
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: service/api
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 8080
|
||||
alias: api
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig() failed: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
aliasErrs := func(active []string) []ValidationError {
|
||||
var out []ValidationError
|
||||
for _, e := range NewValidator().ValidateConfigWithOpts(buildConfig(), ValidateOptions{ActiveContexts: active}) {
|
||||
if e.Field == "alias" {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, aliasErrs(nil), "duplicate hostname must conflict when everything runs")
|
||||
assert.Empty(t, aliasErrs([]string{"team-a"}), "duplicate hostname is fine when only one context runs")
|
||||
assert.NotEmpty(t, aliasErrs([]string{"team-a", "team-b"}), "both active means a real hostname clash")
|
||||
}
|
||||
|
||||
+30
-23
@@ -16,17 +16,30 @@ type ReloadCallback func(*Config) error
|
||||
|
||||
// Watcher watches a configuration file for changes and triggers hot-reload.
|
||||
type Watcher struct {
|
||||
callback ReloadCallback
|
||||
watcher *fsnotify.Watcher
|
||||
done chan struct{}
|
||||
configPath string
|
||||
wg sync.WaitGroup
|
||||
stopOnce sync.Once
|
||||
verbose bool
|
||||
callback ReloadCallback
|
||||
watcher *fsnotify.Watcher
|
||||
done chan struct{}
|
||||
configPath string
|
||||
activeContexts []string
|
||||
wg sync.WaitGroup
|
||||
stopOnce sync.Once
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// WatcherOption configures a Watcher at construction time.
|
||||
type WatcherOption func(*Watcher)
|
||||
|
||||
// WithContextSelection restricts hot-reload to the given contexts, matching the
|
||||
// --context selection the process started with. Without it a reload would
|
||||
// resurrect every context in the file.
|
||||
func WithContextSelection(names []string) WatcherOption {
|
||||
return func(w *Watcher) {
|
||||
w.activeContexts = names
|
||||
}
|
||||
}
|
||||
|
||||
// NewWatcher creates a new file watcher for the given config file.
|
||||
func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watcher, error) {
|
||||
func NewWatcher(configPath string, callback ReloadCallback, verbose bool, opts ...WatcherOption) (*Watcher, error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file watcher: %w", err)
|
||||
@@ -46,13 +59,17 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
|
||||
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
return &Watcher{
|
||||
w := &Watcher{
|
||||
configPath: absPath,
|
||||
callback: callback,
|
||||
watcher: watcher,
|
||||
done: make(chan struct{}),
|
||||
verbose: verbose,
|
||||
}, nil
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Start begins watching the configuration file for changes.
|
||||
@@ -121,8 +138,9 @@ func (w *Watcher) watch() {
|
||||
|
||||
// handleReload loads and validates the new configuration, then calls the callback.
|
||||
func (w *Watcher) handleReload() {
|
||||
// Load new configuration
|
||||
newCfg, err := LoadConfig(w.configPath)
|
||||
// Load, validate and apply the --context selection in one step, so the
|
||||
// selection cannot drift from the other reload paths.
|
||||
newCfg, err := LoadForRuntime(w.configPath, w.activeContexts)
|
||||
if err != nil {
|
||||
logger.Error("Failed to load configuration during hot-reload", map[string]interface{}{
|
||||
"config_path": w.configPath,
|
||||
@@ -132,17 +150,6 @@ func (w *Watcher) handleReload() {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate new configuration
|
||||
validator := NewValidator()
|
||||
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
|
||||
logger.Error("Configuration validation failed during hot-reload", map[string]interface{}{
|
||||
"config_path": w.configPath,
|
||||
"validation_errors": len(errs),
|
||||
})
|
||||
logger.Info("Keeping previous configuration active", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Call reload callback
|
||||
if err := w.callback(newCfg); err != nil {
|
||||
logger.Error("Failed to apply new configuration", map[string]interface{}{
|
||||
|
||||
@@ -504,3 +504,124 @@ func TestWatcher_StopWithoutStart(t *testing.T) {
|
||||
t.Fatal("Stop without start timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatcher_WithContextSelection verifies that hot-reload honours the
|
||||
// --context selection: a config whose non-selected contexts reuse local ports
|
||||
// must reload cleanly, and the callback must only receive the selection.
|
||||
func TestWatcher_WithContextSelection(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||
|
||||
initial := `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/app
|
||||
port: 8080
|
||||
localPort: 3004
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/app
|
||||
port: 8080
|
||||
localPort: 3004
|
||||
`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(initial), 0600))
|
||||
|
||||
var mu sync.Mutex
|
||||
var receivedConfig *Config
|
||||
|
||||
callback := func(cfg *Config) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
receivedConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
watcher, err := NewWatcher(configPath, callback, false, WithContextSelection([]string{"team-a"}))
|
||||
require.NoError(t, err)
|
||||
defer watcher.Stop()
|
||||
|
||||
watcher.Start()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
updated := `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/app
|
||||
port: 8080
|
||||
localPort: 3004
|
||||
- resource: pod/extra
|
||||
port: 9090
|
||||
localPort: 3005
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/app
|
||||
port: 8080
|
||||
localPort: 3004
|
||||
`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(updated), 0600))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return receivedConfig != nil
|
||||
}, 3*time.Second, 50*time.Millisecond, "watcher should reload a config with ports shared across non-selected contexts")
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Len(t, receivedConfig.Contexts, 1, "callback should only see the selected context")
|
||||
assert.Equal(t, "team-a", receivedConfig.Contexts[0].Name)
|
||||
assert.Len(t, receivedConfig.GetAllForwards(), 2)
|
||||
}
|
||||
|
||||
// TestWatcher_WithContextSelection_UnknownNameDoesNotFreezeReload covers a
|
||||
// context renamed in the file: reload must keep working for the rest.
|
||||
func TestWatcher_WithContextSelection_UnknownNameDoesNotFreezeReload(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||
|
||||
initial := `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/app
|
||||
port: 8080
|
||||
localPort: 3004
|
||||
`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(initial), 0600))
|
||||
|
||||
var mu sync.Mutex
|
||||
var called bool
|
||||
|
||||
watcher, err := NewWatcher(configPath, func(cfg *Config) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
called = true
|
||||
return nil
|
||||
}, false, WithContextSelection([]string{"team-a", "since-renamed"}))
|
||||
require.NoError(t, err)
|
||||
defer watcher.Stop()
|
||||
|
||||
watcher.Start()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
updated := initial + ` - resource: pod/extra
|
||||
port: 9090
|
||||
localPort: 3005
|
||||
`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(updated), 0600))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return called
|
||||
}, 3*time.Second, 50*time.Millisecond, "an unknown selected context must not freeze hot-reload")
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ func formatProcessList(processes []processInfo) string {
|
||||
|
||||
// getProcessNameByPID retrieves the process name for a given PID on Unix systems
|
||||
func getProcessNameByPID(pid string) string {
|
||||
if !isValidPID(pid) {
|
||||
return ""
|
||||
}
|
||||
// #nosec G204 -- pid is validated by isValidPID() to contain only digits
|
||||
cmd := exec.Command("ps", "-p", pid, "-o", "comm=")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -302,11 +306,11 @@ func FormatConflicts(conflicts []PortConflict) string {
|
||||
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
|
||||
|
||||
for _, conflict := range conflicts {
|
||||
sb.WriteString(fmt.Sprintf("Port %d\n", conflict.Port))
|
||||
fmt.Fprintf(&sb, "Port %d\n", conflict.Port)
|
||||
if conflict.Resource != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Needed for: %s\n", conflict.Resource))
|
||||
fmt.Fprintf(&sb, " Needed for: %s\n", conflict.Resource)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Currently used by: %s\n", conflict.UsedBy))
|
||||
fmt.Fprintf(&sb, " Currently used by: %s\n", conflict.UsedBy)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -264,7 +265,7 @@ func (s *WatchdogTestSuite) TestConcurrentOperations() {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
forwardID := string(rune('a' + id))
|
||||
forwardID := fmt.Sprintf("worker-%d", id)
|
||||
s.watchdog.RegisterWorker(forwardID, nil)
|
||||
for j := 0; j < 10; j++ {
|
||||
s.watchdog.Heartbeat(forwardID)
|
||||
|
||||
@@ -91,13 +91,15 @@ func (p *Proxy) Start() error {
|
||||
p.listener = ln
|
||||
|
||||
// Create reverse proxy
|
||||
director := func(req *http.Request) {
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort)
|
||||
rewrite := func(pr *httputil.ProxyRequest) {
|
||||
pr.Out.URL.Scheme = "http"
|
||||
pr.Out.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort)
|
||||
// Rewrite drops X-Forwarded-For, which Director used to send.
|
||||
pr.SetXForwarded()
|
||||
}
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Director: director,
|
||||
Rewrite: rewrite,
|
||||
Transport: &loggingTransport{
|
||||
proxy: p,
|
||||
transport: http.DefaultTransport,
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
@@ -421,3 +424,32 @@ func TestProxy_LogError(t *testing.T) {
|
||||
assert.Equal(t, "/test", entry.Path)
|
||||
assert.Contains(t, entry.Error, "assert.AnError")
|
||||
}
|
||||
|
||||
func TestProxy_ForwardsToTargetPortAndSendsForwardedFor(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Seen-Path", r.URL.Path)
|
||||
w.Header().Set("X-Seen-Forwarded-For", r.Header.Get("X-Forwarded-For"))
|
||||
}))
|
||||
defer backend.Close()
|
||||
backendURL, err := url.Parse(backend.URL)
|
||||
require.NoError(t, err)
|
||||
targetPort, err := strconv.Atoi(backendURL.Port())
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
proxy := &Proxy{
|
||||
targetPort: targetPort,
|
||||
logger: &Logger{forwardID: "test", maxBodyLen: 1024, output: &buf},
|
||||
forwardID: "test-fwd",
|
||||
}
|
||||
require.NoError(t, proxy.Start())
|
||||
defer func() { _ = proxy.Stop() }()
|
||||
|
||||
resp, err := http.Get("http://" + proxy.listener.Addr().String() + "/hello")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "/hello", resp.Header.Get("X-Seen-Path"))
|
||||
assert.Equal(t, "127.0.0.1", resp.Header.Get("X-Seen-Forwarded-For"))
|
||||
}
|
||||
|
||||
+113
-56
@@ -69,32 +69,35 @@ type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry))
|
||||
|
||||
// BubbleTeaUI is a bubbletea-based terminal UI
|
||||
type BubbleTeaUI struct {
|
||||
discovery *k8s.Discovery
|
||||
program *tea.Program
|
||||
forwards map[string]*ForwardStatus
|
||||
benchmarkState *BenchmarkState
|
||||
httpLogSubscriber HTTPLogSubscriber
|
||||
disabledMap map[string]bool
|
||||
toggleCallback func(id string, enable bool)
|
||||
httpLogCleanup func()
|
||||
httpLogState *HTTPLogState
|
||||
errors map[string]string
|
||||
mutator *config.Mutator
|
||||
removeWizard *RemoveWizardState
|
||||
addWizard *AddWizardState
|
||||
updateVersion string
|
||||
updateURL string
|
||||
configPath string
|
||||
deleteConfirmID string
|
||||
deleteConfirmAlias string
|
||||
version string
|
||||
forwardOrder []string
|
||||
viewMode ViewMode
|
||||
deleteConfirmCursor int
|
||||
selectedIndex int
|
||||
mu sync.RWMutex
|
||||
deleteConfirming bool
|
||||
updateAvailable bool
|
||||
discovery *k8s.Discovery
|
||||
program *tea.Program
|
||||
forwards map[string]*ForwardStatus
|
||||
benchmarkState *BenchmarkState
|
||||
httpLogSubscriber HTTPLogSubscriber
|
||||
disabledMap map[string]bool
|
||||
toggleCallback func(id string, enable bool)
|
||||
httpLogCleanup func()
|
||||
httpLogState *HTTPLogState
|
||||
errors map[string]string
|
||||
mutator *config.Mutator
|
||||
removeWizard *RemoveWizardState
|
||||
addWizard *AddWizardState
|
||||
updateVersion string
|
||||
updateURL string
|
||||
configPath string
|
||||
deleteConfirmID string
|
||||
deleteConfirmContext string
|
||||
deleteConfirmAlias string
|
||||
version string
|
||||
forwardOrder []string
|
||||
columns []ResolvedColumn
|
||||
activeContexts []string
|
||||
viewMode ViewMode
|
||||
deleteConfirmCursor int
|
||||
selectedIndex int
|
||||
mu sync.RWMutex
|
||||
deleteConfirming bool
|
||||
updateAvailable bool
|
||||
}
|
||||
|
||||
// bubbletea model
|
||||
@@ -115,11 +118,20 @@ func NewBubbleTeaUI(toggleCallback func(id string, enable bool), version string)
|
||||
version: version,
|
||||
errors: make(map[string]string),
|
||||
viewMode: ViewModeMain,
|
||||
columns: ResolveColumns(nil),
|
||||
}
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
// SetColumns updates the forwards-table column set and order, e.g. after a
|
||||
// config hot-reload. cfg may be nil to reset to the built-in defaults.
|
||||
func (ui *BubbleTeaUI) SetColumns(cfg *config.Config) {
|
||||
ui.mu.Lock()
|
||||
defer ui.mu.Unlock()
|
||||
ui.columns = ResolveColumns(cfg)
|
||||
}
|
||||
|
||||
// SetWizardDependencies sets the dependencies needed for the add/remove wizards
|
||||
func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *config.Mutator, configPath string) {
|
||||
ui.mu.Lock()
|
||||
@@ -130,6 +142,16 @@ func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *
|
||||
ui.configPath = configPath
|
||||
}
|
||||
|
||||
// SetActiveContexts records the contexts selected with --context so the add
|
||||
// and edit wizards only report local-port conflicts against forwards that
|
||||
// could actually be running. An empty selection means every context is active.
|
||||
func (ui *BubbleTeaUI) SetActiveContexts(names []string) {
|
||||
ui.mu.Lock()
|
||||
defer ui.mu.Unlock()
|
||||
|
||||
ui.activeContexts = names
|
||||
}
|
||||
|
||||
// SetHTTPLogSubscriber sets the function to subscribe to HTTP logs
|
||||
func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) {
|
||||
ui.mu.Lock()
|
||||
@@ -448,6 +470,7 @@ type keyBinding struct {
|
||||
func mainViewKeyBindings() []keyBinding {
|
||||
return []keyBinding{
|
||||
{"↑↓/jk", "Navigate"},
|
||||
{"PgUp/Dn", "Page"},
|
||||
{"Space", "Toggle"},
|
||||
{"n", "New"},
|
||||
{"e", "Edit"},
|
||||
@@ -480,7 +503,7 @@ func (m model) renderMainView() string {
|
||||
|
||||
// Render error section if any errors exist
|
||||
if len(m.ui.errors) > 0 {
|
||||
b.WriteString(m.renderErrorSection())
|
||||
b.WriteString(m.renderErrorSection(termWidth))
|
||||
}
|
||||
|
||||
// Render footer with proper spacing
|
||||
@@ -527,10 +550,14 @@ func (m model) renderTitle(headerColor lipgloss.Color) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderEmptyMessage renders the message shown when no forwards are configured
|
||||
// renderEmptyMessage renders the message shown when no forwards are configured.
|
||||
// It includes an actionable hint so a first-time user knows how to proceed.
|
||||
func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
|
||||
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
|
||||
return disabledStyle.Render("No forwards configured\n")
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(mutedColor)
|
||||
hintStyle := lipgloss.NewStyle().Foreground(highlightColor)
|
||||
return mutedStyle.Render("No forwards configured") + "\n\n" +
|
||||
hintStyle.Render(" Press ") + selectedStyle.Render("n") +
|
||||
hintStyle.Render(" to add your first port forward.") + "\n"
|
||||
}
|
||||
|
||||
// renderForwardsTable renders the forwards table with all styling
|
||||
@@ -540,10 +567,15 @@ func (m model) renderForwardsTable(colors mainViewColors) string {
|
||||
// Build table rows
|
||||
rows := m.buildTableRows()
|
||||
|
||||
headers := make([]string, len(m.ui.columns))
|
||||
for i, col := range m.ui.columns {
|
||||
headers[i] = col.Header
|
||||
}
|
||||
|
||||
// Create table with styling (no borders for cleaner look)
|
||||
t := table.New().
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
|
||||
Headers(headers...).
|
||||
Rows(rows...).
|
||||
StyleFunc(m.createTableStyleFunc(colors))
|
||||
|
||||
@@ -565,21 +597,23 @@ func (m model) buildTableRows() [][]string {
|
||||
|
||||
statusIcon, statusText := m.getStatusIconAndText(id, fwd)
|
||||
|
||||
localPortText := fmt.Sprintf("%d", fwd.LocalPort)
|
||||
if fwd.Status == "Active" && !m.ui.isForwardDisabled(id) {
|
||||
localPortText = hyperlink(fmt.Sprintf("http://127.0.0.1:%d", fwd.LocalPort), fmt.Sprintf("%d→", fwd.LocalPort))
|
||||
row := make([]string, len(m.ui.columns))
|
||||
for i, col := range m.ui.columns {
|
||||
switch col.Key {
|
||||
case ColKeyLocal:
|
||||
localPortText := fmt.Sprintf("%d", fwd.LocalPort)
|
||||
if fwd.Status == "Active" && !m.ui.isForwardDisabled(id) {
|
||||
localPortText = hyperlink(fmt.Sprintf("http://127.0.0.1:%d", fwd.LocalPort), fmt.Sprintf("%d→", fwd.LocalPort))
|
||||
}
|
||||
row[i] = localPortText
|
||||
case ColKeyStatus:
|
||||
row[i] = statusIcon + " " + statusText
|
||||
default:
|
||||
row[i] = columnValue(col, fwd)
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
truncate(fwd.Context, ColumnWidthContext),
|
||||
truncate(fwd.Namespace, ColumnWidthNamespace),
|
||||
truncate(fwd.Alias, ColumnWidthAlias),
|
||||
truncate(fwd.Type, ColumnWidthType),
|
||||
truncate(fwd.Resource, ColumnWidthResource),
|
||||
fmt.Sprintf("%d", fwd.RemotePort),
|
||||
localPortText,
|
||||
statusIcon + " " + statusText,
|
||||
})
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
return rows
|
||||
@@ -608,6 +642,8 @@ func (m model) getStatusIconAndText(id string, fwd *ForwardStatus) (icon, text s
|
||||
|
||||
// createTableStyleFunc creates the style function for the forwards table
|
||||
func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) lipgloss.Style {
|
||||
statusCol := columnIndex(m.ui.columns, ColKeyStatus)
|
||||
|
||||
return func(row, col int) lipgloss.Style {
|
||||
// Header row
|
||||
if row == table.HeaderRow {
|
||||
@@ -638,7 +674,7 @@ func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) li
|
||||
}
|
||||
|
||||
// Status column gets colored based on status
|
||||
if col == ColumnStatus && ok {
|
||||
if col == statusCol && ok {
|
||||
switch fwd.Status {
|
||||
case "Active":
|
||||
return baseStyle.Foreground(colors.active)
|
||||
@@ -655,10 +691,12 @@ func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) li
|
||||
}
|
||||
}
|
||||
|
||||
// renderErrorSection renders the error display section
|
||||
func (m model) renderErrorSection() string {
|
||||
// renderErrorSection renders the error display section, sized to the terminal.
|
||||
func (m model) renderErrorSection(termWidth int) string {
|
||||
var b strings.Builder
|
||||
|
||||
width := errorWidth(termWidth)
|
||||
|
||||
b.WriteString("\n\n")
|
||||
errorHeaderStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
@@ -669,28 +707,44 @@ func (m model) renderErrorSection() string {
|
||||
|
||||
errorLineStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Width(ErrorDisplayWidth).
|
||||
MaxWidth(ErrorDisplayWidth)
|
||||
Width(width).
|
||||
MaxWidth(width)
|
||||
|
||||
for id, errMsg := range m.ui.errors {
|
||||
// Find the forward to display its alias
|
||||
if fwd, ok := m.ui.forwards[id]; ok {
|
||||
b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, errorLineStyle))
|
||||
b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, width, errorLineStyle))
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// errorWidth clamps the error display to the terminal width, falling back to the
|
||||
// default cap so errors never overflow narrow terminals nor sprawl on wide ones.
|
||||
func errorWidth(termWidth int) int {
|
||||
width := ErrorDisplayWidth
|
||||
if termWidth > 0 && termWidth-2 < width {
|
||||
width = termWidth - 2
|
||||
}
|
||||
if width < 20 {
|
||||
width = 20
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// renderErrorLine renders a single error line with proper wrapping
|
||||
func (m model) renderErrorLine(alias, errMsg string, style lipgloss.Style) string {
|
||||
func (m model) renderErrorLine(alias, errMsg string, width int, style lipgloss.Style) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Format: " • alias: error message"
|
||||
prefix := fmt.Sprintf(" • %s: ", alias)
|
||||
|
||||
// Wrap the error message if it's too long
|
||||
maxErrLen := ErrorDisplayWidth - len(prefix)
|
||||
maxErrLen := width - len(prefix)
|
||||
if maxErrLen < 1 {
|
||||
maxErrLen = 1
|
||||
}
|
||||
wrappedMsg := wrapText(errMsg, maxErrLen)
|
||||
|
||||
// Render first line with prefix
|
||||
@@ -750,9 +804,10 @@ func (m model) buildFooterLines(termWidth int) []string {
|
||||
var currentLine strings.Builder
|
||||
currentLineVisualLen := 0
|
||||
|
||||
// Calculate how much space we need for the total count suffix
|
||||
// Calculate how much space we need for the total count suffix.
|
||||
// Use lipgloss.Width for true display width (the "│" glyph is 3 bytes / 1 col).
|
||||
totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder))
|
||||
totalSuffixLen := len(totalSuffix)
|
||||
totalSuffixLen := lipgloss.Width(totalSuffix)
|
||||
|
||||
// Available width (account for some margin)
|
||||
availableWidth := termWidth - 4
|
||||
@@ -761,8 +816,9 @@ func (m model) buildFooterLines(termWidth int) []string {
|
||||
// Build this binding's text
|
||||
keyRendered := keyStyle.Render(binding.key)
|
||||
bindingText := keyRendered + ": " + binding.desc
|
||||
// Visual length without ANSI codes
|
||||
bindingVisualLen := len(binding.key) + 2 + len(binding.desc)
|
||||
// True display width: strips ANSI and counts wide/unicode glyphs (e.g. ↑↓)
|
||||
// correctly, where len() would over-count multibyte runes and wrap early.
|
||||
bindingVisualLen := lipgloss.Width(bindingText)
|
||||
|
||||
// Add separator if not first item on line
|
||||
separator := ""
|
||||
@@ -856,6 +912,7 @@ func (ui *BubbleTeaUI) moveSelection(delta int) {
|
||||
func (ui *BubbleTeaUI) resetDeleteConfirmation() {
|
||||
ui.deleteConfirming = false
|
||||
ui.deleteConfirmID = ""
|
||||
ui.deleteConfirmContext = ""
|
||||
ui.deleteConfirmAlias = ""
|
||||
ui.deleteConfirmCursor = 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
)
|
||||
|
||||
// ColumnKey identifies a forwards-table column.
|
||||
type ColumnKey string
|
||||
|
||||
// Recognized column keys, matching the names accepted in tui.columns.
|
||||
const (
|
||||
ColKeyContext ColumnKey = "context"
|
||||
ColKeyNamespace ColumnKey = "namespace"
|
||||
ColKeyAlias ColumnKey = "alias"
|
||||
ColKeyType ColumnKey = "type"
|
||||
ColKeyResource ColumnKey = "resource"
|
||||
ColKeyRemote ColumnKey = "remote"
|
||||
ColKeyLocal ColumnKey = "local"
|
||||
ColKeyStatus ColumnKey = "status"
|
||||
)
|
||||
|
||||
// ResolvedColumn describes a single column ready for rendering: its header
|
||||
// text, pad width, truncation limit, and identity.
|
||||
type ResolvedColumn struct {
|
||||
Key ColumnKey
|
||||
Header string
|
||||
// Width is the padded width, used by the plain table only.
|
||||
Width int
|
||||
// Max is the truncation limit; zero leaves the value untruncated.
|
||||
Max int
|
||||
}
|
||||
|
||||
// tuiDefaultColumns is the Bubble Tea column set and order, used when the
|
||||
// config does not specify tui.columns.
|
||||
var tuiDefaultColumns = []ResolvedColumn{
|
||||
{Key: ColKeyContext, Header: "CONTEXT", Width: ColumnWidthContext, Max: ColumnWidthContext},
|
||||
{Key: ColKeyNamespace, Header: "NAMESPACE", Width: ColumnWidthNamespace, Max: ColumnWidthNamespace},
|
||||
{Key: ColKeyAlias, Header: "ALIAS", Width: ColumnWidthAlias, Max: ColumnWidthAlias},
|
||||
{Key: ColKeyType, Header: "TYPE", Width: ColumnWidthType, Max: ColumnWidthType},
|
||||
{Key: ColKeyResource, Header: "RESOURCE", Width: ColumnWidthResource, Max: ColumnWidthResource},
|
||||
{Key: ColKeyRemote, Header: "REMOTE"},
|
||||
{Key: ColKeyLocal, Header: "LOCAL"},
|
||||
{Key: ColKeyStatus, Header: "STATUS"},
|
||||
}
|
||||
|
||||
// plainDefaultColumns is the -verbose table column set, widths and headers as
|
||||
// rendered before columns became configurable.
|
||||
var plainDefaultColumns = []ResolvedColumn{
|
||||
{Key: ColKeyContext, Header: "CONTEXT", Width: 15},
|
||||
{Key: ColKeyNamespace, Header: "NAMESPACE", Width: 18},
|
||||
{Key: ColKeyAlias, Header: "ALIAS", Width: 25, Max: 25},
|
||||
{Key: ColKeyType, Header: "TYPE", Width: 10},
|
||||
{Key: ColKeyResource, Header: "RESOURCE", Width: 25, Max: 25},
|
||||
{Key: ColKeyRemote, Header: "REMOTE PORT", Width: 12},
|
||||
{Key: ColKeyLocal, Header: "LOCAL PORT", Width: 12},
|
||||
{Key: ColKeyStatus, Header: "STATUS", Width: 12},
|
||||
}
|
||||
|
||||
// ResolveColumns builds the Bubble Tea columns from the tui.columns
|
||||
// configuration. A nil config, or one with no tui.columns entries, yields the
|
||||
// built-in default column set and order.
|
||||
func ResolveColumns(cfg *config.Config) []ResolvedColumn {
|
||||
return resolveColumns(cfg, tuiDefaultColumns)
|
||||
}
|
||||
|
||||
// plainStatusWidth fits the longest status with indicator ("↻ Reconnecting"),
|
||||
// so a status column that is not last cannot push later columns out of line.
|
||||
const plainStatusWidth = 14
|
||||
|
||||
// ResolvePlainColumns is ResolveColumns for the -verbose table.
|
||||
func ResolvePlainColumns(cfg *config.Config) []ResolvedColumn {
|
||||
defaults := plainDefaultColumns
|
||||
if len(cfg.GetTableColumns()) > 0 {
|
||||
defaults = slices.Clone(plainDefaultColumns)
|
||||
defaults[columnIndex(defaults, ColKeyStatus)].Width = plainStatusWidth
|
||||
}
|
||||
return resolveColumns(cfg, defaults)
|
||||
}
|
||||
|
||||
func resolveColumns(cfg *config.Config, defaults []ResolvedColumn) []ResolvedColumn {
|
||||
var configured []config.TableColumn
|
||||
if cfg != nil {
|
||||
configured = cfg.GetTableColumns()
|
||||
}
|
||||
|
||||
byKey := make(map[ColumnKey]ResolvedColumn, len(defaults))
|
||||
for _, c := range defaults {
|
||||
byKey[c.Key] = c
|
||||
}
|
||||
|
||||
resolved := make([]ResolvedColumn, 0, len(configured))
|
||||
for _, c := range configured {
|
||||
col, ok := byKey[ColumnKey(strings.ToLower(strings.TrimSpace(c.Name)))]
|
||||
if !ok {
|
||||
// Unknown column names are rejected by the validator before this
|
||||
// point is reached; skip defensively rather than render garbage.
|
||||
continue
|
||||
}
|
||||
|
||||
if c.Width > 0 {
|
||||
col.Width = c.Width
|
||||
col.Max = c.Width
|
||||
}
|
||||
|
||||
resolved = append(resolved, col)
|
||||
}
|
||||
|
||||
if len(resolved) == 0 {
|
||||
return slices.Clone(defaults)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// columnValue returns the display value for the given column of a forward,
|
||||
// truncated to the column's Max when set.
|
||||
func columnValue(col ResolvedColumn, fwd *ForwardStatus) string {
|
||||
switch col.Key {
|
||||
case ColKeyContext:
|
||||
return clip(fwd.Context, col.Max)
|
||||
case ColKeyNamespace:
|
||||
return clip(fwd.Namespace, col.Max)
|
||||
case ColKeyAlias:
|
||||
return clip(fwd.Alias, col.Max)
|
||||
case ColKeyType:
|
||||
return clip(fwd.Type, col.Max)
|
||||
case ColKeyResource:
|
||||
return clip(fwd.Resource, col.Max)
|
||||
case ColKeyRemote:
|
||||
return fmt.Sprintf("%d", fwd.RemotePort)
|
||||
case ColKeyLocal:
|
||||
return fmt.Sprintf("%d", fwd.LocalPort)
|
||||
case ColKeyStatus:
|
||||
return fwd.Status
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// clip truncates s to limit runes; a non-positive limit leaves it untouched.
|
||||
func clip(s string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return s
|
||||
}
|
||||
return truncate(s, limit)
|
||||
}
|
||||
|
||||
// columnIndex returns the position of key within cols, or -1 if absent.
|
||||
func columnIndex(cols []ResolvedColumn, key ColumnKey) int {
|
||||
for i, c := range cols {
|
||||
if c.Key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveColumns_Default(t *testing.T) {
|
||||
cols := ResolveColumns(nil)
|
||||
assert.Equal(t, tuiDefaultColumns, cols)
|
||||
|
||||
cols = ResolveColumns(&config.Config{})
|
||||
assert.Equal(t, tuiDefaultColumns, cols)
|
||||
}
|
||||
|
||||
func TestResolveColumns_CustomOrderAndVisibility(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
TUI: &config.TUISpec{
|
||||
Columns: []config.TableColumn{
|
||||
{Name: "status"},
|
||||
{Name: "alias", Width: 40},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cols := ResolveColumns(cfg)
|
||||
|
||||
assert.Len(t, cols, 2)
|
||||
assert.Equal(t, ColKeyStatus, cols[0].Key)
|
||||
assert.Equal(t, ColKeyAlias, cols[1].Key)
|
||||
assert.Equal(t, 40, cols[1].Width)
|
||||
}
|
||||
|
||||
func TestResolveColumns_UsesDefaultWidthWhenUnset(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
TUI: &config.TUISpec{
|
||||
Columns: []config.TableColumn{
|
||||
{Name: "namespace"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cols := ResolveColumns(cfg)
|
||||
|
||||
assert.Len(t, cols, 1)
|
||||
assert.Equal(t, ColumnWidthNamespace, cols[0].Width)
|
||||
}
|
||||
|
||||
func TestResolveColumns_UnknownNamesSkipped(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
TUI: &config.TUISpec{
|
||||
Columns: []config.TableColumn{
|
||||
{Name: "bogus"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// All entries invalid -> fall back to defaults.
|
||||
assert.Equal(t, tuiDefaultColumns, ResolveColumns(cfg))
|
||||
}
|
||||
|
||||
func TestColumnValue(t *testing.T) {
|
||||
fwd := &ForwardStatus{
|
||||
Context: "prod",
|
||||
Namespace: "default",
|
||||
Alias: "api",
|
||||
Type: "service",
|
||||
Resource: "api",
|
||||
RemotePort: 8080,
|
||||
LocalPort: 8081,
|
||||
Status: "Active",
|
||||
}
|
||||
|
||||
assert.Equal(t, "prod", columnValue(ResolvedColumn{Key: ColKeyContext, Max: 10}, fwd))
|
||||
assert.Equal(t, "8080", columnValue(ResolvedColumn{Key: ColKeyRemote}, fwd))
|
||||
assert.Equal(t, "8081", columnValue(ResolvedColumn{Key: ColKeyLocal}, fwd))
|
||||
assert.Equal(t, "Active", columnValue(ResolvedColumn{Key: ColKeyStatus}, fwd))
|
||||
}
|
||||
|
||||
func TestColumnIndex(t *testing.T) {
|
||||
cols := []ResolvedColumn{{Key: ColKeyContext}, {Key: ColKeyStatus}}
|
||||
assert.Equal(t, 1, columnIndex(cols, ColKeyStatus))
|
||||
assert.Equal(t, -1, columnIndex(cols, ColKeyLocal))
|
||||
}
|
||||
|
||||
func TestResolveColumns_DefaultIsCopy(t *testing.T) {
|
||||
cols := ResolveColumns(nil)
|
||||
cols[0].Header = "MUTATED"
|
||||
|
||||
assert.Equal(t, "CONTEXT", ResolveColumns(nil)[0].Header)
|
||||
assert.Equal(t, "CONTEXT", tuiDefaultColumns[0].Header)
|
||||
}
|
||||
|
||||
func TestResolvePlainColumns_LegacyDefaults(t *testing.T) {
|
||||
cols := ResolvePlainColumns(nil)
|
||||
|
||||
assert.Equal(t, plainDefaultColumns, cols)
|
||||
assert.Equal(t, "REMOTE PORT", cols[5].Header)
|
||||
assert.Equal(t, 15, cols[0].Width)
|
||||
|
||||
custom := ResolvePlainColumns(&config.Config{TUI: &config.TUISpec{Columns: []config.TableColumn{{Name: " Status "}, {Name: "alias", Width: 7}}}})
|
||||
assert.Equal(t, plainStatusWidth, custom[0].Width)
|
||||
assert.Equal(t, 7, custom[1].Width)
|
||||
assert.Equal(t, 7, custom[1].Max)
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func TestCheckPortCmd_PortAvailability(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test checking a random high port that should be available
|
||||
cmd := checkPortCmd(59999, configPath, "")
|
||||
cmd := checkPortCmd(59999, configPath, "", "test-ctx", nil)
|
||||
msg := cmd()
|
||||
|
||||
portMsg, ok := msg.(PortCheckedMsg)
|
||||
@@ -192,7 +192,7 @@ func TestCheckPortCmd_ConfigConflict(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test checking port that's already in config
|
||||
cmd := checkPortCmd(8080, configPath, "")
|
||||
cmd := checkPortCmd(8080, configPath, "", "test-ctx", nil)
|
||||
msg := cmd()
|
||||
|
||||
portMsg, ok := msg.(PortCheckedMsg)
|
||||
@@ -224,7 +224,7 @@ func TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort(t *testing.T) {
|
||||
// The forward's ID format is "<context>/<namespace>/<resource>:<port>".
|
||||
excludeID := "test-ctx/default/pod/my-app:8080"
|
||||
|
||||
cmd := checkPortCmd(8080, configPath, excludeID)
|
||||
cmd := checkPortCmd(8080, configPath, excludeID, "test-ctx", nil)
|
||||
msg := cmd()
|
||||
|
||||
portMsg, ok := msg.(PortCheckedMsg)
|
||||
@@ -241,7 +241,7 @@ func TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort(t *testing.T) {
|
||||
// TestCheckPortCmd_InvalidConfig tests behavior with invalid config file
|
||||
func TestCheckPortCmd_InvalidConfig(t *testing.T) {
|
||||
// Use a non-existent config path
|
||||
cmd := checkPortCmd(59998, "/nonexistent/path/.kportal.yaml", "")
|
||||
cmd := checkPortCmd(59998, "/nonexistent/path/.kportal.yaml", "", "test-ctx", nil)
|
||||
msg := cmd()
|
||||
|
||||
portMsg, ok := msg.(PortCheckedMsg)
|
||||
@@ -420,3 +420,76 @@ func TestHTTPLogSubscriberType(t *testing.T) {
|
||||
cleanup()
|
||||
assert.Equal(t, 1, mock.CleanupCalls)
|
||||
}
|
||||
|
||||
// TestCheckPortCmd_ContextScoping verifies the add/edit wizard does not report a
|
||||
// port as taken when the only forward using it lives in a context that is not
|
||||
// being forwarded.
|
||||
func TestCheckPortCmd_ContextScoping(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||
|
||||
configContent := `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/my-app
|
||||
port: 80
|
||||
localPort: 8080
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/other-app
|
||||
port: 80
|
||||
localPort: 9090
|
||||
`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0600))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
targetContext string
|
||||
activeContexts []string
|
||||
port int
|
||||
wantAssignedMsg bool
|
||||
}{
|
||||
{
|
||||
name: "port used only by a non-active context is free",
|
||||
port: 9090, targetContext: "team-a", activeContexts: []string{"team-a"},
|
||||
wantAssignedMsg: false,
|
||||
},
|
||||
{
|
||||
name: "port used by the target context is taken",
|
||||
port: 8080, targetContext: "team-a", activeContexts: []string{"team-a"},
|
||||
wantAssignedMsg: true,
|
||||
},
|
||||
{
|
||||
name: "both contexts active means the port is taken",
|
||||
port: 9090, targetContext: "team-a", activeContexts: []string{"team-a", "team-b"},
|
||||
wantAssignedMsg: true,
|
||||
},
|
||||
{
|
||||
name: "no selection keeps the global check",
|
||||
port: 9090, targetContext: "team-a", activeContexts: nil,
|
||||
wantAssignedMsg: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msg := checkPortCmd(tt.port, configPath, "", tt.targetContext, tt.activeContexts)()
|
||||
|
||||
portMsg, ok := msg.(PortCheckedMsg)
|
||||
require.True(t, ok, "Expected PortCheckedMsg")
|
||||
assert.Equal(t, tt.port, portMsg.port)
|
||||
|
||||
if tt.wantAssignedMsg {
|
||||
assert.Contains(t, portMsg.message, "already assigned")
|
||||
} else {
|
||||
// The OS-level check still runs, so only assert that the
|
||||
// in-config conflict path did not fire.
|
||||
assert.NotContains(t, portMsg.message, "already assigned")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,3 +43,15 @@ const (
|
||||
// MaxPathWidth is the maximum width for displaying file paths
|
||||
MaxPathWidth = 48
|
||||
)
|
||||
|
||||
// HTTP log table layout
|
||||
const (
|
||||
// HTTPLogRowFormat is the column layout shared by the HTTP-log table header
|
||||
// and its rows (TIME, METHOD, STATUS, LATENCY, PATH) so they stay aligned.
|
||||
HTTPLogRowFormat = "%-10s %-7s %-6s %-8s %s"
|
||||
|
||||
// HTTPLogFixedCols is the width consumed by every column except PATH
|
||||
// (prefix + the four fixed columns and their separators), used to size the
|
||||
// remaining space for the path column responsively.
|
||||
HTTPLogFixedCols = 48
|
||||
)
|
||||
|
||||
@@ -36,6 +36,12 @@ func (f *fakeMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.F
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMutator) RemoveForwardByID(id string) error { return nil }
|
||||
func (f *fakeMutator) RemoveForwardByIDInContext(contextName, id string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMutator) UpdateForwardInContext(oldCtx, oldID, newCtx, newNS string, newFwd config.Forward) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMutator) UpdateForward(oldID, newCtx, newNS string, newFwd config.Forward) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ type MutatorInterface interface {
|
||||
AddForward(contextName, namespaceName string, fwd config.Forward) error
|
||||
RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error
|
||||
RemoveForwardByID(id string) error
|
||||
RemoveForwardByIDInContext(contextName, id string) error
|
||||
UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error
|
||||
UpdateForwardInContext(oldContextName, oldID, newContextName, newNamespaceName string, newFwd config.Forward) error
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure real types implement interfaces
|
||||
|
||||
@@ -105,6 +105,8 @@ type MockMutator struct {
|
||||
LastOldID string
|
||||
LastNamespaceName string
|
||||
LastRemovedID string
|
||||
LastRemovedContext string
|
||||
LastOldContext string
|
||||
Forwards []struct {
|
||||
Context string
|
||||
Namespace string
|
||||
@@ -157,6 +159,22 @@ func (m *MockMutator) RemoveForwardByID(id string) error {
|
||||
return m.RemoveForwardByIDErr
|
||||
}
|
||||
|
||||
func (m *MockMutator) RemoveForwardByIDInContext(contextName, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.RemoveForwardByIDCalls++
|
||||
m.LastRemovedContext = contextName
|
||||
m.LastRemovedID = id
|
||||
return m.RemoveForwardByIDErr
|
||||
}
|
||||
|
||||
func (m *MockMutator) UpdateForwardInContext(oldContextName, oldID, newContextName, newNamespaceName string, newFwd config.Forward) error {
|
||||
m.mu.Lock()
|
||||
m.LastOldContext = oldContextName
|
||||
m.mu.Unlock()
|
||||
return m.UpdateForward(oldID, newContextName, newNamespaceName, newFwd)
|
||||
}
|
||||
|
||||
func (m *MockMutator) UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
+94
-35
@@ -3,8 +3,11 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
)
|
||||
@@ -25,18 +28,29 @@ type ForwardStatus struct {
|
||||
// TableUI manages the terminal table display
|
||||
type TableUI struct {
|
||||
forwards map[string]*ForwardStatus
|
||||
columns []ResolvedColumn
|
||||
mu sync.RWMutex
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewTableUI creates a new table UI manager
|
||||
func NewTableUI(verbose bool) *TableUI {
|
||||
// NewTableUI creates a new table UI manager. cfg may be nil, in which case
|
||||
// the built-in default column set and order is used.
|
||||
func NewTableUI(verbose bool, cfg *config.Config) *TableUI {
|
||||
return &TableUI{
|
||||
forwards: make(map[string]*ForwardStatus),
|
||||
verbose: verbose,
|
||||
columns: ResolvePlainColumns(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// SetColumns updates the table's column set and order, e.g. after a config
|
||||
// hot-reload.
|
||||
func (t *TableUI) SetColumns(cfg *config.Config) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.columns = ResolvePlainColumns(cfg)
|
||||
}
|
||||
|
||||
// AddForward registers a new forward for display
|
||||
func (t *TableUI) AddForward(id string, fwd *config.Forward) {
|
||||
t.mu.Lock()
|
||||
@@ -93,12 +107,11 @@ func (t *TableUI) Render() {
|
||||
|
||||
// Print header
|
||||
fmt.Println("kportal - Port Forwarding Status")
|
||||
fmt.Println(strings.Repeat("=", 130))
|
||||
fmt.Println(plainRule("=", t.columns))
|
||||
|
||||
// Table header
|
||||
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n",
|
||||
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS")
|
||||
fmt.Println(strings.Repeat("-", 130))
|
||||
fmt.Println(renderPlainHeaderRow(t.columns))
|
||||
fmt.Println(plainRule("-", t.columns))
|
||||
|
||||
// Sort forwards by local port for consistent display
|
||||
type sortEntry struct {
|
||||
@@ -121,28 +134,10 @@ func (t *TableUI) Render() {
|
||||
|
||||
// Print each forward
|
||||
for _, entry := range entries {
|
||||
fwd := entry.fwd
|
||||
|
||||
// Truncate long names
|
||||
alias := truncate(fwd.Alias, 25)
|
||||
resource := truncate(fwd.Resource, 25)
|
||||
|
||||
// Color code status with indicator
|
||||
statusStr := formatStatusWithIndicator(fwd.Status)
|
||||
|
||||
// Print the row
|
||||
fmt.Printf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s\n",
|
||||
fwd.Context,
|
||||
fwd.Namespace,
|
||||
alias,
|
||||
fwd.Type,
|
||||
resource,
|
||||
fwd.RemotePort,
|
||||
fwd.LocalPort,
|
||||
statusStr)
|
||||
fmt.Println(renderPlainDataRow(t.columns, entry.fwd))
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 130))
|
||||
fmt.Println(plainRule("=", t.columns))
|
||||
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
|
||||
|
||||
// In verbose mode, add a newline to separate from logs
|
||||
@@ -158,19 +153,18 @@ func (t *TableUI) RenderInitial() {
|
||||
|
||||
// Print header
|
||||
fmt.Println("\nkportal - Port Forwarding Status")
|
||||
fmt.Println(strings.Repeat("=", 130))
|
||||
fmt.Println(plainRule("=", t.columns))
|
||||
|
||||
// Table header
|
||||
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n",
|
||||
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS")
|
||||
fmt.Println(strings.Repeat("-", 130))
|
||||
fmt.Println(renderPlainHeaderRow(t.columns))
|
||||
fmt.Println(plainRule("-", t.columns))
|
||||
|
||||
// Print message if no forwards yet
|
||||
if len(t.forwards) == 0 {
|
||||
fmt.Println("Initializing port forwards...")
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 130))
|
||||
fmt.Println(plainRule("=", t.columns))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -188,6 +182,65 @@ func (t *TableUI) Remove(id string) {
|
||||
delete(t.forwards, id)
|
||||
}
|
||||
|
||||
// legacyPlainRuleWidth is the rule width the default plain layout always used.
|
||||
const legacyPlainRuleWidth = 130
|
||||
|
||||
// ansiSeq matches the SGR colour sequences emitted by formatStatusWithIndicator.
|
||||
var ansiSeq = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
||||
|
||||
// visibleWidth returns the number of runes a terminal shows for s.
|
||||
func visibleWidth(s string) int {
|
||||
return utf8.RuneCountInString(ansiSeq.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
// padVisible right-pads s with spaces to width visible runes.
|
||||
func padVisible(s string, width int) string {
|
||||
if n := width - visibleWidth(s); n > 0 {
|
||||
return s + strings.Repeat(" ", n)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// plainRule returns a rule line as wide as the rendered columns: the sum of
|
||||
// their widths plus one separator between each pair.
|
||||
func plainRule(ch string, cols []ResolvedColumn) string {
|
||||
if slices.Equal(cols, plainDefaultColumns) {
|
||||
return strings.Repeat(ch, legacyPlainRuleWidth)
|
||||
}
|
||||
width := len(cols) - 1
|
||||
for _, col := range cols {
|
||||
width += col.Width
|
||||
}
|
||||
return strings.Repeat(ch, max(width, 0))
|
||||
}
|
||||
|
||||
// renderPlainHeaderRow renders the header row for the plain table, padding
|
||||
// every column to its width.
|
||||
func renderPlainHeaderRow(cols []ResolvedColumn) string {
|
||||
cells := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
cells[i] = padVisible(col.Header, col.Width)
|
||||
}
|
||||
return strings.Join(cells, " ")
|
||||
}
|
||||
|
||||
// renderPlainDataRow renders a single forward's data row for the plain table,
|
||||
// padding every column but the last so row cells line up with the header.
|
||||
func renderPlainDataRow(cols []ResolvedColumn, fwd *ForwardStatus) string {
|
||||
cells := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
if col.Key == ColKeyStatus {
|
||||
cells[i] = formatStatusWithIndicator(fwd.Status)
|
||||
} else {
|
||||
cells[i] = columnValue(col, fwd)
|
||||
}
|
||||
if i < len(cols)-1 {
|
||||
cells[i] = padVisible(cells[i], col.Width)
|
||||
}
|
||||
}
|
||||
return " " + strings.Join(cells, " ")
|
||||
}
|
||||
|
||||
// hyperlink wraps text in an OSC 8 terminal hyperlink escape sequence.
|
||||
// Clicking the text opens the URL in terminals that support it (Ghostty, iTerm2,
|
||||
// Windows Terminal, Kitty, WezTerm, etc.). Unsupported terminals show plain text.
|
||||
@@ -195,15 +248,21 @@ func hyperlink(url, text string) string {
|
||||
return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text)
|
||||
}
|
||||
|
||||
// truncate truncates a string to maxLen, adding "..." if needed
|
||||
// truncate truncates a string to maxLen runes, adding "..." if needed.
|
||||
// It counts and slices by rune (not byte) so multibyte aliases/resource names
|
||||
// are never cut mid-rune into mojibake.
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
return string(r[:maxLen])
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
return string(r[:maxLen-3]) + "..."
|
||||
}
|
||||
|
||||
// formatStatusWithIndicator adds color-coded indicator symbols to status
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
orig := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = w
|
||||
defer func() { os.Stdout = orig }()
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
out, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func columnsConfig(cols ...config.TableColumn) *config.Config {
|
||||
return &config.Config{TUI: &config.TUISpec{Columns: cols}}
|
||||
}
|
||||
|
||||
func newPlainTestTable(cfg *config.Config) *TableUI {
|
||||
tbl := NewTableUI(true, cfg)
|
||||
tbl.forwards["a"] = &ForwardStatus{
|
||||
Context: "prod-cluster-with-a-long-name", Namespace: "default", Alias: "api",
|
||||
Type: "service", Resource: "api-server", RemotePort: 8080, LocalPort: 18080, Status: "Active",
|
||||
}
|
||||
tbl.forwards["b"] = &ForwardStatus{
|
||||
Context: "dev", Namespace: "kube-system", Alias: "db", Type: "pod",
|
||||
Resource: "postgres-0", RemotePort: 5432, LocalPort: 15432, Status: "Reconnecting",
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
func TestTableUI_Render_DefaultMatchesLegacyLayout(t *testing.T) {
|
||||
tbl := newPlainTestTable(nil)
|
||||
|
||||
got := captureStdout(t, tbl.Render)
|
||||
|
||||
legacyHeader := fmt.Sprintf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s",
|
||||
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS")
|
||||
legacyRow := func(f *ForwardStatus, status string) string {
|
||||
return fmt.Sprintf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s",
|
||||
f.Context, f.Namespace, truncate(f.Alias, 25), f.Type, truncate(f.Resource, 25), f.RemotePort, f.LocalPort, status)
|
||||
}
|
||||
want := "kportal - Port Forwarding Status\n" +
|
||||
strings.Repeat("=", 130) + "\n" +
|
||||
legacyHeader + "\n" +
|
||||
strings.Repeat("-", 130) + "\n" +
|
||||
legacyRow(tbl.forwards["b"], "↻ Reconnecting") + "\n" +
|
||||
legacyRow(tbl.forwards["a"], "✓ Active") + "\n" +
|
||||
strings.Repeat("=", 130) + "\n" +
|
||||
"Total forwards: 2 | Press Ctrl+C to stop\n\n"
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestPlainRows_CustomOrderStaysAligned(t *testing.T) {
|
||||
cols := ResolvePlainColumns(columnsConfig(
|
||||
config.TableColumn{Name: "alias"},
|
||||
config.TableColumn{Name: "status"},
|
||||
config.TableColumn{Name: "local"},
|
||||
config.TableColumn{Name: "remote"},
|
||||
))
|
||||
rows := []*ForwardStatus{
|
||||
{Alias: "api", Status: "Active", LocalPort: 18080, RemotePort: 80},
|
||||
{Alias: "a-much-longer-alias", Status: "Reconnecting", LocalPort: 1, RemotePort: 5432},
|
||||
{Alias: "db", Status: "Error", LocalPort: 15432, RemotePort: 5432},
|
||||
}
|
||||
|
||||
// Prefix width up to the last column must match the header for every row.
|
||||
lead := visibleWidth(renderPlainHeaderRow(cols[:len(cols)-1])) + 1
|
||||
for _, f := range rows {
|
||||
line := renderPlainDataRow(cols, f)
|
||||
last := strings.LastIndex(line, " ") + 1
|
||||
assert.Equal(t, lead+2, visibleWidth(line[:last]), "row %q misaligned", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadVisible_IgnoresColourCodes(t *testing.T) {
|
||||
coloured := "\033[32m●\033[0m Active"
|
||||
assert.Equal(t, 8, visibleWidth(coloured))
|
||||
assert.Equal(t, 12, visibleWidth(padVisible(coloured, 12)))
|
||||
assert.Equal(t, "toolong", padVisible("toolong", 3))
|
||||
}
|
||||
|
||||
func TestPlainRule_MatchesColumnWidthSum(t *testing.T) {
|
||||
cols := ResolvePlainColumns(columnsConfig(
|
||||
config.TableColumn{Name: "alias", Width: 20},
|
||||
config.TableColumn{Name: "status"},
|
||||
config.TableColumn{Name: "local"},
|
||||
))
|
||||
|
||||
assert.Equal(t, strings.Repeat("=", 20+plainStatusWidth+12+2), plainRule("=", cols))
|
||||
assert.Equal(t, strings.Repeat("-", 130), plainRule("-", ResolvePlainColumns(nil)))
|
||||
}
|
||||
|
||||
func TestTableUI_SetColumns_ChangesRendering(t *testing.T) {
|
||||
tbl := newPlainTestTable(nil)
|
||||
before := captureStdout(t, tbl.Render)
|
||||
|
||||
tbl.SetColumns(columnsConfig(config.TableColumn{Name: "status"}, config.TableColumn{Name: "alias"}))
|
||||
after := captureStdout(t, tbl.Render)
|
||||
|
||||
assert.Contains(t, before, "CONTEXT")
|
||||
assert.NotContains(t, after, "CONTEXT")
|
||||
assert.True(t, strings.Contains(after, "STATUS ALIAS"), after)
|
||||
assert.Contains(t, after, strings.Repeat("=", plainStatusWidth+25+1)+"\n")
|
||||
}
|
||||
|
||||
func TestBubbleTeaUI_SetColumns_ChangesRows(t *testing.T) {
|
||||
m := newTestModelWithForward()
|
||||
m.ui.mu.RLock()
|
||||
before := m.buildTableRows()
|
||||
m.ui.mu.RUnlock()
|
||||
require.Len(t, before, 1)
|
||||
assert.Len(t, before[0], 8)
|
||||
|
||||
m.ui.SetColumns(columnsConfig(config.TableColumn{Name: "alias"}, config.TableColumn{Name: "remote"}))
|
||||
|
||||
m.ui.mu.RLock()
|
||||
after := m.buildTableRows()
|
||||
m.ui.mu.RUnlock()
|
||||
require.Len(t, after, 1)
|
||||
assert.Equal(t, []string{"my-app", "8080"}, after[0])
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
@@ -10,12 +11,12 @@ import (
|
||||
|
||||
// TestNewTableUI tests the constructor.
|
||||
func TestNewTableUI(t *testing.T) {
|
||||
tui := NewTableUI(false)
|
||||
tui := NewTableUI(false, nil)
|
||||
require.NotNil(t, tui)
|
||||
assert.NotNil(t, tui.forwards)
|
||||
assert.False(t, tui.verbose)
|
||||
|
||||
tuiVerbose := NewTableUI(true)
|
||||
tuiVerbose := NewTableUI(true, nil)
|
||||
assert.True(t, tuiVerbose.verbose)
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ func TestTableUI_AddForward(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tui := NewTableUI(false)
|
||||
tui := NewTableUI(false, nil)
|
||||
fwd := &config.Forward{
|
||||
Resource: tt.resource,
|
||||
Port: 8080,
|
||||
@@ -83,7 +84,7 @@ func TestTableUI_AddForward(t *testing.T) {
|
||||
|
||||
// TestTableUI_UpdateStatus verifies status mutation.
|
||||
func TestTableUI_UpdateStatus(t *testing.T) {
|
||||
tui := NewTableUI(false)
|
||||
tui := NewTableUI(false, nil)
|
||||
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
|
||||
tui.AddForward("id-1", fwd)
|
||||
|
||||
@@ -99,7 +100,7 @@ func TestTableUI_UpdateStatus(t *testing.T) {
|
||||
|
||||
// TestTableUI_GetForward covers the lookup path.
|
||||
func TestTableUI_GetForward(t *testing.T) {
|
||||
tui := NewTableUI(false)
|
||||
tui := NewTableUI(false, nil)
|
||||
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
|
||||
tui.AddForward("id-1", fwd)
|
||||
|
||||
@@ -113,7 +114,7 @@ func TestTableUI_GetForward(t *testing.T) {
|
||||
|
||||
// TestTableUI_Remove tests deletion.
|
||||
func TestTableUI_Remove(t *testing.T) {
|
||||
tui := NewTableUI(false)
|
||||
tui := NewTableUI(false, nil)
|
||||
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
|
||||
tui.AddForward("id-1", fwd)
|
||||
tui.AddForward("id-2", fwd)
|
||||
@@ -140,10 +141,14 @@ func TestTruncate(t *testing.T) {
|
||||
{"hi!", "hi", 2}, // maxLen <= 3 branch: no ellipsis
|
||||
{"abcd", "abc", 3}, // maxLen <= 3 branch
|
||||
{"", "", 5},
|
||||
{"café-service", "café-...", 8}, // multibyte: count/slice by rune, no mojibake
|
||||
{"日本語ポッド", "日本...", 5}, // CJK runes truncated cleanly
|
||||
{"naïve", "naïve", 5}, // exactly maxLen runes (6 bytes) — unchanged
|
||||
{"abc", "", 0}, // non-positive maxLen
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input+"_"+string(rune('0'+tt.maxLen)), func(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%s_%d", tt.input, tt.maxLen), func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, truncate(tt.input, tt.maxLen))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,7 +149,11 @@ func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selec
|
||||
// excludeID, when non-empty, is the ID of a forward to ignore during the
|
||||
// in-config conflict scan. Used in edit mode so the wizard does not flag the
|
||||
// forward being edited as conflicting with itself.
|
||||
func checkPortCmd(port int, configPath, excludeID string) tea.Cmd {
|
||||
// targetContext is the context the forward is being added to or edited in, and
|
||||
// activeContexts the --context selection; together they decide which existing
|
||||
// forwards could actually compete for the port. A forward in a context that is
|
||||
// never running alongside targetContext does not conflict.
|
||||
func checkPortCmd(port int, configPath, excludeID, targetContext string, activeContexts []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// First check if port is already in the configuration
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
@@ -163,6 +167,9 @@ func checkPortCmd(port int, configPath, excludeID string) tea.Cmd {
|
||||
if excludeID != "" && fwd.ID() == excludeID {
|
||||
continue
|
||||
}
|
||||
if !config.PortsCanConflict(fwd.GetContext(), targetContext, activeContexts) {
|
||||
continue
|
||||
}
|
||||
return PortCheckedMsg{
|
||||
port: port,
|
||||
available: false,
|
||||
@@ -203,9 +210,9 @@ func saveForwardCmd(mutator *config.Mutator, contextName, namespace string, fwd
|
||||
}
|
||||
|
||||
// updateForwardCmd atomically updates an existing forward (used in edit mode)
|
||||
func updateForwardCmd(mutator *config.Mutator, oldID, contextName, namespace string, fwd config.Forward) tea.Cmd {
|
||||
func updateForwardCmd(mutator *config.Mutator, oldContextName, oldID, contextName, namespace string, fwd config.Forward) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := mutator.UpdateForward(oldID, contextName, namespace, fwd)
|
||||
err := mutator.UpdateForwardInContext(oldContextName, oldID, contextName, namespace, fwd)
|
||||
return ForwardSavedMsg{
|
||||
success: err == nil,
|
||||
err: err,
|
||||
@@ -216,15 +223,18 @@ func updateForwardCmd(mutator *config.Mutator, oldID, contextName, namespace str
|
||||
// removeForwardsCmd removes selected forwards from the configuration file
|
||||
func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Create a map of IDs to remove
|
||||
idsToRemove := make(map[string]bool)
|
||||
// Key on context as well as ID: Forward.ID() is only "alias:localPort"
|
||||
// when an alias is set, which is not unique across contexts once they
|
||||
// are allowed to reuse local ports.
|
||||
type forwardKey struct{ contextName, id string }
|
||||
idsToRemove := make(map[forwardKey]bool)
|
||||
for _, fwd := range forwards {
|
||||
idsToRemove[fwd.ID] = true
|
||||
idsToRemove[forwardKey{contextName: fwd.Context, id: fwd.ID}] = true
|
||||
}
|
||||
|
||||
// Remove forwards matching the IDs
|
||||
err := mutator.RemoveForwards(func(ctx, ns string, fwd config.Forward) bool {
|
||||
return idsToRemove[fwd.ID()]
|
||||
return idsToRemove[forwardKey{contextName: ctx, id: fwd.ID()}]
|
||||
})
|
||||
|
||||
return ForwardsRemovedMsg{
|
||||
@@ -236,9 +246,9 @@ func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea
|
||||
}
|
||||
|
||||
// removeForwardByIDCmd removes a single forward by its ID
|
||||
func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd {
|
||||
func removeForwardByIDCmd(mutator *config.Mutator, contextName, id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := mutator.RemoveForwardByID(id)
|
||||
err := mutator.RemoveForwardByIDInContext(contextName, id)
|
||||
return ForwardsRemovedMsg{
|
||||
success: err == nil,
|
||||
count: 1,
|
||||
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// keyTypedText routes j and k to text input while a text field has focus.
|
||||
const keyTypedText = "<typed-text>"
|
||||
|
||||
// isFilterableStep returns true if the step supports search/filter
|
||||
func isFilterableStep(step AddWizardStep) bool {
|
||||
switch step {
|
||||
@@ -132,6 +135,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Mark as edit mode and store original ID
|
||||
m.ui.addWizard.isEditing = true
|
||||
m.ui.addWizard.originalID = selectedID
|
||||
m.ui.addWizard.originalContext = selectedForward.Context
|
||||
|
||||
// Start at the remote port step (skip context/namespace/resource selection)
|
||||
m.ui.addWizard.step = StepEnterRemotePort
|
||||
@@ -184,6 +188,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Show confirmation dialog
|
||||
m.ui.deleteConfirming = true
|
||||
m.ui.deleteConfirmID = selectedID
|
||||
m.ui.deleteConfirmContext = selectedForward.Context
|
||||
m.ui.deleteConfirmAlias = selectedForward.Alias
|
||||
m.ui.deleteConfirmCursor = 1 // Default to "No" for safety
|
||||
|
||||
@@ -309,9 +314,10 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Confirm deletion (either Enter on Yes or pressing 'y')
|
||||
if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" {
|
||||
id := m.ui.deleteConfirmID
|
||||
contextName := m.ui.deleteConfirmContext
|
||||
m.ui.resetDeleteConfirmation()
|
||||
m.ui.mu.Unlock()
|
||||
return m, removeForwardByIDCmd(m.ui.mutator, id)
|
||||
return m, removeForwardByIDCmd(m.ui.mutator, contextName, id)
|
||||
}
|
||||
// Enter on No = cancel
|
||||
m.ui.resetDeleteConfirmation()
|
||||
@@ -339,7 +345,12 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
key := msg.String()
|
||||
if (key == "j" || key == "k") && wizard.isTypingText() {
|
||||
key = keyTypedText
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "ctrl+c":
|
||||
// Hard cancel
|
||||
m.ui.viewMode = ViewModeMain
|
||||
@@ -373,8 +384,14 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Reset input mode based on the step we're going back to
|
||||
switch wizard.step {
|
||||
case StepSelectContext, StepSelectNamespace, StepSelectResourceType:
|
||||
case StepSelectContext, StepSelectResourceType:
|
||||
wizard.inputMode = InputModeList
|
||||
case StepSelectNamespace:
|
||||
if wizard.namespaceManual {
|
||||
wizard.inputMode = InputModeText
|
||||
} else {
|
||||
wizard.inputMode = InputModeList
|
||||
}
|
||||
case StepEnterResource:
|
||||
if wizard.selectedResourceType == ResourceTypeService {
|
||||
wizard.inputMode = InputModeList
|
||||
@@ -526,10 +543,27 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
wizard.cursor = 0
|
||||
wizard.clearSearchFilter()
|
||||
wizard.loading = true
|
||||
wizard.namespaceManual = false
|
||||
wizard.namespaceListErr = nil
|
||||
wizard.inputMode = InputModeList
|
||||
return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext)
|
||||
}
|
||||
|
||||
case StepSelectNamespace:
|
||||
if wizard.namespaceManual {
|
||||
name := strings.TrimSpace(wizard.textInput)
|
||||
if err := config.ValidateNamespaceName(name); err != nil {
|
||||
wizard.error = err
|
||||
return m, nil
|
||||
}
|
||||
wizard.error = nil
|
||||
wizard.selectedNamespace = name
|
||||
wizard.step = StepSelectResourceType
|
||||
wizard.cursor = 0
|
||||
wizard.clearTextInput()
|
||||
wizard.inputMode = InputModeList
|
||||
return m, nil
|
||||
}
|
||||
filteredNamespaces := wizard.getFilteredNamespaces()
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) {
|
||||
wizard.selectedNamespace = filteredNamespaces[wizard.cursor]
|
||||
@@ -660,7 +694,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
if wizard.isEditing {
|
||||
excludeID = wizard.originalID
|
||||
}
|
||||
return m, checkPortCmd(port, m.ui.configPath, excludeID)
|
||||
return m, checkPortCmd(port, m.ui.configPath, excludeID, wizard.selectedContext, m.ui.activeContexts)
|
||||
}
|
||||
|
||||
case StepConfirmation:
|
||||
@@ -718,7 +752,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
|
||||
// If editing, use atomic update operation
|
||||
if wizard.isEditing {
|
||||
return m, updateForwardCmd(m.ui.mutator, wizard.originalID, wizard.selectedContext, wizard.selectedNamespace, fwd)
|
||||
return m, updateForwardCmd(m.ui.mutator, wizard.originalContext, wizard.originalID, wizard.selectedContext, wizard.selectedNamespace, fwd)
|
||||
}
|
||||
|
||||
return m, saveForwardCmd(m.ui.mutator, wizard.selectedContext, wizard.selectedNamespace, fwd)
|
||||
@@ -863,10 +897,18 @@ func (m model) handleNamespacesLoaded(msg NamespacesLoadedMsg) (tea.Model, tea.C
|
||||
defer m.ui.mu.Unlock()
|
||||
|
||||
if m.ui.addWizard != nil {
|
||||
m.ui.addWizard.loading = false
|
||||
m.ui.addWizard.error = msg.err
|
||||
w := m.ui.addWizard
|
||||
w.loading = false
|
||||
w.error = nil
|
||||
if msg.err == nil {
|
||||
m.ui.addWizard.namespaces = msg.namespaces
|
||||
w.namespaces = msg.namespaces
|
||||
}
|
||||
// Without permission to list namespaces the user types the name instead.
|
||||
if (msg.err != nil || len(msg.namespaces) == 0) && w.step == StepSelectNamespace {
|
||||
w.namespaceManual = true
|
||||
w.namespaceListErr = msg.err
|
||||
w.inputMode = InputModeText
|
||||
w.textInput = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1685,7 +1685,7 @@ func TestRemoveForwardByIDCmd_Success(t *testing.T) {
|
||||
require.NoError(t, mutator.AddForward("ctx", "ns", fwdKeep))
|
||||
|
||||
id := "ctx/ns/pod/app:18083"
|
||||
cmd := removeForwardByIDCmd(mutator, id)
|
||||
cmd := removeForwardByIDCmd(mutator, "", id)
|
||||
msg := cmd()
|
||||
removedMsg, ok := msg.(ForwardsRemovedMsg)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func typeKeys(t *testing.T, m model, text string) {
|
||||
t.Helper()
|
||||
for _, r := range text {
|
||||
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacesLoaded_ListFailureSwitchesToManualEntry(t *testing.T) {
|
||||
m := newModelWithWizard(StepSelectNamespace)
|
||||
m.ui.addWizard.loading = true
|
||||
|
||||
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("namespaces is forbidden")})
|
||||
|
||||
w := m.ui.addWizard
|
||||
assert.True(t, w.namespaceManual)
|
||||
assert.Equal(t, InputModeText, w.inputMode)
|
||||
assert.NoError(t, w.error)
|
||||
assert.False(t, w.loading)
|
||||
assert.Contains(t, m.renderSelectNamespace(), "Enter Namespace")
|
||||
assert.Contains(t, m.renderSelectNamespace(), "forbidden")
|
||||
}
|
||||
|
||||
func TestNamespacesLoaded_EmptyListSwitchesToManualEntry(t *testing.T) {
|
||||
m := newModelWithWizard(StepSelectNamespace)
|
||||
|
||||
m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{}})
|
||||
|
||||
assert.True(t, m.ui.addWizard.namespaceManual)
|
||||
}
|
||||
|
||||
func TestNamespacesLoaded_ListedNamespacesKeepSelectionMode(t *testing.T) {
|
||||
m := newModelWithWizard(StepSelectNamespace)
|
||||
|
||||
m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{"default", "kube-system"}})
|
||||
|
||||
w := m.ui.addWizard
|
||||
assert.False(t, w.namespaceManual)
|
||||
assert.Equal(t, InputModeList, w.inputMode)
|
||||
assert.Equal(t, []string{"default", "kube-system"}, w.namespaces)
|
||||
}
|
||||
|
||||
func TestManualNamespace_TypeAndConfirm(t *testing.T) {
|
||||
m := newModelWithWizard(StepSelectNamespace)
|
||||
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")})
|
||||
|
||||
// k and j must be typed, not treated as navigation
|
||||
typeKeys(t, m, "kube-jack")
|
||||
assert.Equal(t, "kube-jack", m.ui.addWizard.textInput)
|
||||
|
||||
m.handleAddWizardEnter()
|
||||
|
||||
w := m.ui.addWizard
|
||||
assert.Equal(t, "kube-jack", w.selectedNamespace)
|
||||
assert.Equal(t, StepSelectResourceType, w.step)
|
||||
assert.Equal(t, InputModeList, w.inputMode)
|
||||
assert.Empty(t, w.textInput)
|
||||
}
|
||||
|
||||
func TestManualNamespace_RejectsInvalidName(t *testing.T) {
|
||||
m := newModelWithWizard(StepSelectNamespace)
|
||||
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")})
|
||||
m.ui.addWizard.selectedNamespace = ""
|
||||
|
||||
for _, bad := range []string{"", "Has_Upper", "-leading", "trailing-"} {
|
||||
m.ui.addWizard.textInput = bad
|
||||
m.handleAddWizardEnter()
|
||||
|
||||
w := m.ui.addWizard
|
||||
require.Error(t, w.error, "name %q", bad)
|
||||
assert.Equal(t, StepSelectNamespace, w.step, "name %q", bad)
|
||||
assert.Empty(t, w.selectedNamespace, "name %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualNamespace_BackFromLaterStepRestoresTextInput(t *testing.T) {
|
||||
m := newModelWithWizard(StepSelectResourceType)
|
||||
m.ui.addWizard.namespaceManual = true
|
||||
|
||||
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
|
||||
w := m.ui.addWizard
|
||||
assert.Equal(t, StepSelectNamespace, w.step)
|
||||
assert.Equal(t, InputModeText, w.inputMode)
|
||||
}
|
||||
|
||||
func TestKeysJK_StillNavigateInListMode(t *testing.T) {
|
||||
m := newModelWithWizard(StepSelectResourceType)
|
||||
m.ui.addWizard.inputMode = InputModeList
|
||||
|
||||
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
||||
|
||||
assert.Equal(t, 1, m.ui.addWizard.cursor)
|
||||
}
|
||||
@@ -111,9 +111,11 @@ func (r ResourceType) Description() string {
|
||||
// AddWizardState maintains the state for the add port forward wizard
|
||||
type AddWizardState struct {
|
||||
error error
|
||||
namespaceListErr error
|
||||
httpLogOriginal *config.HTTPLogSpec
|
||||
resourceValue string
|
||||
originalID string
|
||||
originalContext string
|
||||
portCheckMsg string
|
||||
alias string
|
||||
textInput string
|
||||
@@ -137,6 +139,7 @@ type AddWizardState struct {
|
||||
confirmationFocus ConfirmationFocus
|
||||
portAvailable bool
|
||||
isEditing bool
|
||||
namespaceManual bool
|
||||
loading bool
|
||||
httpLog bool
|
||||
}
|
||||
@@ -223,6 +226,13 @@ func (w *AddWizardState) handleTextInput(char rune) {
|
||||
}
|
||||
}
|
||||
|
||||
// isTypingText reports whether printable keys must go into a text field,
|
||||
// including keys that otherwise navigate (j, k).
|
||||
func (w *AddWizardState) isTypingText() bool {
|
||||
return w.inputMode == InputModeText ||
|
||||
(w.step == StepConfirmation && w.confirmationFocus == FocusAlias)
|
||||
}
|
||||
|
||||
// clearTextInput clears the text input field
|
||||
func (w *AddWizardState) clearTextInput() {
|
||||
w.textInput = ""
|
||||
|
||||
@@ -139,6 +139,18 @@ func renderBreadcrumb(parts ...string) string {
|
||||
return breadcrumbStyle.Render(strings.Join(parts, " / "))
|
||||
}
|
||||
|
||||
// scrollUpIndicator returns the styled "more items above" hint shown when a
|
||||
// viewport is scrolled down past its first item.
|
||||
func scrollUpIndicator() string {
|
||||
return mutedStyle.Render(" ↑ More above ↑") + "\n"
|
||||
}
|
||||
|
||||
// scrollDownIndicator returns the styled "more items below" hint shown when a
|
||||
// viewport has items beyond its last visible row.
|
||||
func scrollDownIndicator() string {
|
||||
return mutedStyle.Render(" ↓ More below ↓") + "\n"
|
||||
}
|
||||
|
||||
// renderList renders a list of items with cursor selection and viewport scrolling
|
||||
func renderList(items []string, cursor int, prefix string, scrollOffset int) string {
|
||||
var b strings.Builder
|
||||
@@ -147,7 +159,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str
|
||||
|
||||
// Show scroll up indicator if there are items above the viewport
|
||||
if scrollOffset > 0 {
|
||||
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
||||
b.WriteString(scrollUpIndicator())
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
@@ -171,7 +183,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str
|
||||
|
||||
// Show scroll down indicator if there are items below the viewport
|
||||
if end < totalItems {
|
||||
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
||||
b.WriteString(scrollDownIndicator())
|
||||
}
|
||||
|
||||
return b.String()
|
||||
|
||||
+92
-93
@@ -9,8 +9,23 @@ import (
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// formatDetectedPort renders a discovered port for the port-selection list,
|
||||
// e.g. "8080", "80 → 8000" (service target differs), optionally "(http)".
|
||||
func formatDetectedPort(port k8s.PortInfo) string {
|
||||
desc := fmt.Sprintf("%d", port.Port)
|
||||
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||
desc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||
}
|
||||
if port.Name != "" {
|
||||
desc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
// renderAddWizard renders the appropriate step of the add wizard
|
||||
func (m model) renderAddWizard() string {
|
||||
if m.ui.addWizard == nil {
|
||||
@@ -61,24 +76,25 @@ func (m model) renderSelectContext() string {
|
||||
b.WriteString(spinnerStyle.Render("⣾ Loading contexts..."))
|
||||
} else if wizard.error != nil {
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", wizard.error)))
|
||||
b.WriteString(mutedStyle.Render("\n\nCould not read kubeconfig. Press Esc to cancel."))
|
||||
} else if len(wizard.contexts) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No contexts found in kubeconfig"))
|
||||
b.WriteString(mutedStyle.Render("\n\nAdd one with: kubectl config use-context <name>"))
|
||||
} else {
|
||||
filteredContexts := wizard.getFilteredContexts()
|
||||
if len(filteredContexts) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No matching contexts"))
|
||||
} else {
|
||||
const viewportHeight = 20
|
||||
totalItems := len(filteredContexts)
|
||||
|
||||
// Show scroll up indicator if there are items above the viewport
|
||||
if wizard.scrollOffset > 0 {
|
||||
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
||||
b.WriteString(scrollUpIndicator())
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
start := wizard.scrollOffset
|
||||
end := wizard.scrollOffset + viewportHeight
|
||||
end := wizard.scrollOffset + ViewportHeight
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
@@ -103,7 +119,7 @@ func (m model) renderSelectContext() string {
|
||||
|
||||
// Show scroll down indicator if there are items below the viewport
|
||||
if end < totalItems {
|
||||
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
||||
b.WriteString(scrollDownIndicator())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +140,11 @@ func (m model) renderSelectNamespace() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7)))
|
||||
b.WriteString(fmt.Sprintf("Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext)))
|
||||
fmt.Fprintf(&b, "Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext))
|
||||
|
||||
if wizard.namespaceManual {
|
||||
return m.renderManualNamespace(&b)
|
||||
}
|
||||
|
||||
b.WriteString("Select Namespace:\n\n")
|
||||
|
||||
@@ -161,6 +181,32 @@ func (m model) renderSelectNamespace() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderManualNamespace shows a free-text namespace prompt for clusters where
|
||||
// the namespace list is not available to the user.
|
||||
func (m model) renderManualNamespace(b *strings.Builder) string {
|
||||
wizard := m.ui.addWizard
|
||||
|
||||
b.WriteString("Enter Namespace:\n\n")
|
||||
if wizard.namespaceListErr != nil {
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf("Could not list namespaces (%v).", wizard.namespaceListErr)))
|
||||
} else {
|
||||
b.WriteString(mutedStyle.Render("No namespaces found."))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(mutedStyle.Render("Type the namespace name instead."))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(renderTextInput("Namespace: ", wizard.textInput, wizard.textInput != ""))
|
||||
b.WriteString("\n")
|
||||
if wizard.error != nil {
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ %v\n", wizard.error)))
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m model) renderSelectResourceType() string {
|
||||
wizard := m.ui.addWizard
|
||||
var b strings.Builder
|
||||
@@ -342,39 +388,23 @@ func (m model) renderEnterRemotePort() string {
|
||||
b.WriteString("Select remote port:")
|
||||
b.WriteString("\n\n")
|
||||
|
||||
const viewportHeight = 20
|
||||
totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option
|
||||
|
||||
// Show scroll up indicator if there are items above the viewport
|
||||
if wizard.scrollOffset > 0 {
|
||||
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
||||
b.WriteString(scrollUpIndicator())
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
start := wizard.scrollOffset
|
||||
end := wizard.scrollOffset + viewportHeight
|
||||
end := wizard.scrollOffset + ViewportHeight
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
|
||||
// Render detected ports within viewport
|
||||
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
|
||||
port := wizard.detectedPorts[i]
|
||||
// For services, show both service port and target port if they differ
|
||||
var portDesc string
|
||||
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||
// Service with different target port: "80 → 8000 (http)"
|
||||
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
} else {
|
||||
// Pod port or service with same port
|
||||
portDesc = fmt.Sprintf("%d", port.Port)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
}
|
||||
portDesc := formatDetectedPort(wizard.detectedPorts[i])
|
||||
|
||||
prefix := " "
|
||||
if i == wizard.cursor {
|
||||
@@ -402,7 +432,7 @@ func (m model) renderEnterRemotePort() string {
|
||||
|
||||
// Show scroll down indicator if there are items below the viewport
|
||||
if end < totalItems {
|
||||
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
||||
b.WriteString(scrollDownIndicator())
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
@@ -412,19 +442,7 @@ func (m model) renderEnterRemotePort() string {
|
||||
if len(wizard.detectedPorts) > 0 {
|
||||
b.WriteString(mutedStyle.Render("Detected ports:\n"))
|
||||
for _, port := range wizard.detectedPorts {
|
||||
var portDesc string
|
||||
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
} else {
|
||||
portDesc = fmt.Sprintf("%d", port.Port)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
}
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", formatDetectedPort(port))))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
@@ -503,18 +521,18 @@ func (m model) renderConfirmation() string {
|
||||
resourceInfo = fmt.Sprintf("service/%s", wizard.resourceValue)
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf(" Context: %s\n", wizard.selectedContext))
|
||||
b.WriteString(fmt.Sprintf(" Namespace: %s\n", wizard.selectedNamespace))
|
||||
b.WriteString(fmt.Sprintf(" Resource: %s\n", resourceInfo))
|
||||
b.WriteString(fmt.Sprintf(" Remote Port: %d\n", wizard.remotePort))
|
||||
b.WriteString(fmt.Sprintf(" Local Port: %d\n", wizard.localPort))
|
||||
fmt.Fprintf(&b, " Context: %s\n", wizard.selectedContext)
|
||||
fmt.Fprintf(&b, " Namespace: %s\n", wizard.selectedNamespace)
|
||||
fmt.Fprintf(&b, " Resource: %s\n", resourceInfo)
|
||||
fmt.Fprintf(&b, " Remote Port: %d\n", wizard.remotePort)
|
||||
fmt.Fprintf(&b, " Local Port: %d\n", wizard.localPort)
|
||||
b.WriteString(" Protocol: tcp\n")
|
||||
|
||||
httpLogMark := "[ ] disabled"
|
||||
if wizard.httpLog {
|
||||
httpLogMark = "[x] enabled"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" HTTP Log: %s\n", httpLogMark))
|
||||
fmt.Fprintf(&b, " HTTP Log: %s\n", httpLogMark)
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
@@ -648,7 +666,7 @@ func (m model) renderRemoveSelection() string {
|
||||
}
|
||||
|
||||
selectedCount := wizard.getSelectedCount()
|
||||
b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards)))
|
||||
fmt.Fprintf(&b, "%d of %d selected\n\n", selectedCount, len(wizard.forwards))
|
||||
|
||||
b.WriteString(wrapHelpText("Space: Toggle a: All n: None Enter: Remove Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
@@ -663,7 +681,7 @@ func (m model) renderRemoveConfirmation() string {
|
||||
b.WriteString("\n")
|
||||
|
||||
selectedCount := wizard.getSelectedCount()
|
||||
b.WriteString(fmt.Sprintf("Remove %d port forward(s)?\n\n", selectedCount))
|
||||
fmt.Fprintf(&b, "Remove %d port forward(s)?\n\n", selectedCount)
|
||||
|
||||
selectedForwards := wizard.getSelectedForwards()
|
||||
for _, fwd := range selectedForwards {
|
||||
@@ -718,7 +736,7 @@ func (m model) renderBenchmarkConfig() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(renderHeader("HTTP Benchmark", ""))
|
||||
b.WriteString(fmt.Sprintf("Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort))
|
||||
fmt.Fprintf(&b, "Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString("Configure benchmark parameters:")
|
||||
@@ -741,7 +759,7 @@ func (m model) renderBenchmarkConfig() string {
|
||||
b.WriteString(selectedStyle.Render(fmt.Sprintf("%s%-12s", prefix, field.label+":")))
|
||||
b.WriteString(validInputStyle.Render(field.value + "█"))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("%s%-12s %s", prefix, field.label+":", field.value))
|
||||
fmt.Fprintf(&b, "%s%-12s %s", prefix, field.label+":", field.value)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
@@ -759,7 +777,7 @@ func (m model) renderBenchmarkRunning() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(renderHeader("HTTP Benchmark", ""))
|
||||
b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias)))
|
||||
fmt.Fprintf(&b, "Target: %s", breadcrumbStyle.Render(state.forwardAlias))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Progress bar
|
||||
@@ -779,7 +797,7 @@ func (m model) renderBenchmarkRunning() string {
|
||||
b.WriteString(spinnerStyle.Render("Running benchmark..."))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(fmt.Sprintf(" [%s] %d%%", successStyle.Render(bar), percent))
|
||||
fmt.Fprintf(&b, " [%s] %d%%", successStyle.Render(bar), percent)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d / %d requests completed", state.progress, state.total)))
|
||||
b.WriteString("\n\n")
|
||||
@@ -799,7 +817,7 @@ func (m model) renderBenchmarkResults() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(renderHeader("Benchmark Results", ""))
|
||||
b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias)))
|
||||
fmt.Fprintf(&b, "Target: %s", breadcrumbStyle.Render(state.forwardAlias))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if state.error != nil {
|
||||
@@ -824,43 +842,43 @@ func (m model) renderBenchmarkResults() string {
|
||||
successRate = 0
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Total Requests: %d", r.TotalRequests))
|
||||
fmt.Fprintf(&b, "Total Requests: %d", r.TotalRequests)
|
||||
b.WriteString("\n")
|
||||
if r.Failed == 0 {
|
||||
b.WriteString(successStyle.Render(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate))
|
||||
fmt.Fprintf(&b, "Successful: %d (%.1f%%)", r.Successful, successRate)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if r.Failed > 0 {
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf("Failed: %d", r.Failed)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("Failed: %d", r.Failed))
|
||||
fmt.Fprintf(&b, "Failed: %d", r.Failed)
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Latency stats
|
||||
b.WriteString(breadcrumbStyle.Render("Latency (ms)"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" Min: %.2f", r.MinLatency))
|
||||
fmt.Fprintf(&b, " Min: %.2f", r.MinLatency)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" Max: %.2f", r.MaxLatency))
|
||||
fmt.Fprintf(&b, " Max: %.2f", r.MaxLatency)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" Avg: %.2f", r.AvgLatency))
|
||||
fmt.Fprintf(&b, " Avg: %.2f", r.AvgLatency)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" P50: %.2f", r.P50Latency))
|
||||
fmt.Fprintf(&b, " P50: %.2f", r.P50Latency)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" P95: %.2f", r.P95Latency))
|
||||
fmt.Fprintf(&b, " P95: %.2f", r.P95Latency)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" P99: %.2f", r.P99Latency))
|
||||
fmt.Fprintf(&b, " P99: %.2f", r.P99Latency)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Throughput
|
||||
b.WriteString(breadcrumbStyle.Render("Throughput"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" Requests/sec: %.2f", r.Throughput))
|
||||
fmt.Fprintf(&b, " Requests/sec: %.2f", r.Throughput)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" Bytes read: %d", r.BytesRead))
|
||||
fmt.Fprintf(&b, " Bytes read: %d", r.BytesRead)
|
||||
b.WriteString("\n")
|
||||
|
||||
// Status codes if interesting
|
||||
@@ -874,7 +892,7 @@ func (m model) renderBenchmarkResults() string {
|
||||
} else if code >= 400 {
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf(" %d: %d", code, count)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(" %d: %d", code, count))
|
||||
fmt.Fprintf(&b, " %d: %d", code, count)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
@@ -966,7 +984,7 @@ func (m model) renderHTTPLog() string {
|
||||
b.WriteString("\n")
|
||||
|
||||
// Header
|
||||
header := fmt.Sprintf(" %-10s %-7s %-6s %-8s %s",
|
||||
header := " " + fmt.Sprintf(HTTPLogRowFormat,
|
||||
"TIME", "METHOD", "STATUS", "LATENCY", "PATH")
|
||||
b.WriteString(mutedStyle.Render(header))
|
||||
b.WriteString("\n")
|
||||
@@ -1004,8 +1022,8 @@ func (m model) renderHTTPLog() string {
|
||||
end = totalEntries
|
||||
}
|
||||
|
||||
// Calculate max path width
|
||||
maxPathWidth := termWidth - 48
|
||||
// Calculate max path width (remaining space after the fixed columns)
|
||||
maxPathWidth := termWidth - HTTPLogFixedCols
|
||||
if maxPathWidth < 10 {
|
||||
maxPathWidth = 10
|
||||
}
|
||||
@@ -1028,14 +1046,11 @@ func (m model) renderHTTPLog() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate path
|
||||
path := entry.Path
|
||||
if len(path) > maxPathWidth {
|
||||
path = path[:maxPathWidth-3] + "..."
|
||||
}
|
||||
// Truncate path (rune-aware, no mid-rune mojibake)
|
||||
path := truncate(entry.Path, maxPathWidth)
|
||||
|
||||
// Build line
|
||||
line := fmt.Sprintf("%-10s %-7s %-6s %-8s %s",
|
||||
line := fmt.Sprintf(HTTPLogRowFormat,
|
||||
entry.Timestamp,
|
||||
entry.Method,
|
||||
statusStr,
|
||||
@@ -1079,7 +1094,7 @@ func (m model) renderHTTPLog() string {
|
||||
// Footer with entry count
|
||||
b.WriteString("\n")
|
||||
if totalEntries != totalUnfiltered {
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered from %d)", totalEntries, totalEntries, totalUnfiltered)))
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered)", totalEntries, totalUnfiltered)))
|
||||
} else {
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d entries", totalEntries)))
|
||||
}
|
||||
@@ -1121,11 +1136,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
|
||||
}
|
||||
sort.Strings(headerKeys)
|
||||
for _, k := range headerKeys {
|
||||
v := entry.RequestHeaders[k]
|
||||
// Truncate long header values
|
||||
if len(v) > termWidth-20 {
|
||||
v = v[:termWidth-23] + "..."
|
||||
}
|
||||
v := truncate(entry.RequestHeaders[k], termWidth-20)
|
||||
lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
@@ -1146,11 +1157,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
|
||||
reqBody = formatJSONContent(reqBody, entry.RequestHeaders)
|
||||
bodyLines := strings.Split(reqBody, "\n")
|
||||
for _, line := range bodyLines {
|
||||
// Truncate long lines
|
||||
if len(line) > termWidth-6 {
|
||||
line = line[:termWidth-9] + "..."
|
||||
}
|
||||
lines = append(lines, " "+line)
|
||||
lines = append(lines, " "+truncate(line, termWidth-6))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
@@ -1192,11 +1199,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
|
||||
}
|
||||
sort.Strings(headerKeys)
|
||||
for _, k := range headerKeys {
|
||||
v := entry.ResponseHeaders[k]
|
||||
// Truncate long header values
|
||||
if len(v) > termWidth-20 {
|
||||
v = v[:termWidth-23] + "..."
|
||||
}
|
||||
v := truncate(entry.ResponseHeaders[k], termWidth-20)
|
||||
lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
@@ -1217,11 +1220,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
|
||||
respBody = formatJSONContent(respBody, entry.ResponseHeaders)
|
||||
bodyLines := strings.Split(respBody, "\n")
|
||||
for _, line := range bodyLines {
|
||||
// Truncate long lines
|
||||
if len(line) > termWidth-6 {
|
||||
line = line[:termWidth-9] + "..."
|
||||
}
|
||||
lines = append(lines, " "+line)
|
||||
lines = append(lines, " "+truncate(line, termWidth-6))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
|
||||
Reference in New Issue
Block a user