Compare commits

...
16 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub 985220b6e6 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-04 04:21:13 +00:00
lukaszraczylo b940fa7f7a ci(deps): update dependencies with Renovate [skip ci] 2026-10-03 18:44:42 +01:00
lukaszraczyloandGitHub 63c905dead Update go.mod and go.sum (#91) 2026-10-03 04:24:43 +01:00
lukaszraczyloandGitHub 1c419ab1c2 docs: rebuild the landing page and README with comparison, install and reference sections (#90)
The earlier rewrite dropped content the page needs. The landing page
has a hero with an install command, a terminal mock, the real
screenshot, feature rows with snippets from the repository, a dated
comparison with kubectl, k9s, kubefwd and kftray with source links,
install tabs, config and key references, troubleshooting and an FAQ.

The README restores the comparison, advanced features, retry strategy,
kftray migration, signal handling, signature verification and build
from source. Cells that a project does not document show as Not
documented.

It fixes four claims the code contradicts: the maxBodySize default, the
deployment resource type, the file watcher delay and the verbose table
status markers.
2026-10-02 03:25:29 +01:00
lukaszraczyloandGitHub 260a909e3d docs: rewrite the product page and README, update the feature list (#88)
* docs(site): rewrite the product page in plain language

The page lists what kportal does with the flags, keys and config keys
that exist in the code. It drops the CDN assets, gradients and icon
grids, and keeps the light and dark themes.

It now covers tui.columns, typed namespace entry, completion, the log
format flag and the --context flag. The claim about a 100ms file watcher
is removed because the code has no such debounce.

* docs: rewrite the README and update the feature list

The README now has a feature table, install steps, a flag table, a
config reference and troubleshooting. It documents tui.columns, typed
namespace entry, shell completion and the --context flag.

It drops the comparison table with other tools, which the repository
cannot back up, and corrects the supported resource types and the Go
version.
2026-10-02 02:54:50 +01:00
lukaszraczyloandGitHub 554f1e5ae0 Update go.mod and go.sum (#89) 2026-10-02 02:53:05 +01:00
4d263319ab feat: add --context to forward only selected contexts (#85)
* feat: add --context to forward only selected contexts

kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.

--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:

    kportal --context development-team-a
    kportal --context team-a,team-b -headless
    kportal --context team-a --context team-b

An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.

Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.

Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.

Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.

* fix(config): keep the running config when no selected context remains

A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.

LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.

Refs #80

* fix(ui): apply new table columns after a successful SIGHUP reload

The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.

Refs #80

* style: gofmt run_test.go

---------

Co-authored-by: André Cruz <[email protected]>
2026-10-02 02:33:29 +01:00
lukaszraczyloandGitHub 4748bc4be5 feat(ui): type the namespace when the cluster does not list them (#84)
Some users cannot list namespaces on a cluster. The add wizard showed
an error and offered no way forward.

The namespace step now switches to a text prompt when the list call
fails or returns nothing. The prompt shows the reason and checks that
the name is a valid DNS label. Esc from a later step returns to the
prompt.

The j and k keys now type into text fields. They moved the list cursor
before, so a name such as kube-system could not be typed.

Closes #66
2026-10-02 02:28:58 +01:00
f3d3f259a1 Allow configuring forwards-table columns, order, and width (#82)
* Allow configuring forwards-table columns, order, and width

Adds an optional tui.columns section to .kportal.yaml so the visible
columns, their order, and their widths can be customized instead of
always rendering the hardcoded 8-column layout. Omitting the section
keeps the existing default columns/order/widths.

Closes #81

* fix(ui): keep the plain table layout and check column widths

The plain verbose table must look the same when no tui section is
set. It had narrower columns and shorter port headers than before. The
default layout is now identical to the old one, and a golden test
guards it.

A status column that is not last now pads by visible width. Table
rules follow the column widths. The UI applies new columns only after a
reload succeeds, including the SIGHUP path of the verbose table.

The validator rejects a column width above 200. The README describes
the tui.columns setting.

Refs #81

* fix(httplog): replace the deprecated ReverseProxy Director with Rewrite

Staticcheck reports SA1019 because Go 1.26 deprecates Director. The
proxy now sets the target in a Rewrite function. Rewrite drops the
X-Forwarded-For header that Director sent, so the function calls
SetXForwarded to keep that header.

A new test checks the target port, the path and the header.

* fix(release): drop the deprecated cask url verified key

Homebrew warns about the verified parameter of the cask url stanza.
The setting is not needed. GoReleaser check now passes without a
deprecation warning.

The postflight warning remains. GoReleaser generates it from the
post install hook, and the upstream fix is not released yet.

Refs #79

---------

Co-authored-by: Lukasz Raczylo <[email protected]>
2026-10-02 02:24:11 +01:00
lukaszraczyloandGitHub 232c0f1162 Update go.mod and go.sum (#78) 2026-09-02 04:17:26 +01:00
lukaszraczyloandGitHub 6f19ecea6b Update go.mod and go.sum (#77) 2026-08-27 05:54:28 +01:00
lukaszraczyloandGitHub 93b5d6496e Update go.mod and go.sum (#76) 2026-08-26 04:25:59 +01:00
lukaszraczyloandGitHub b3c63bd1fa Update go.mod and go.sum (#75) 2026-08-22 04:20:17 +01:00
lukaszraczyloandGitHub 374163f463 Update go.mod and go.sum (#74) 2026-08-21 04:25:07 +01:00
lukaszraczyloandGitHub 6c695584a4 Update go.mod and go.sum (#73) 2026-08-20 04:22:09 +01:00
lukaszraczyloandGitHub c1dbad858b Update go.mod and go.sum (#72) 2026-08-18 04:21:24 +01:00
38 changed files with 4370 additions and 1657 deletions
+7 -7
View File
@@ -1,4 +1,4 @@
name: Autoupdate go.mod and go.sum name: Update dependencies
on: on:
workflow_dispatch: workflow_dispatch:
@@ -6,15 +6,15 @@ on:
- cron: "0 3 * * *" - cron: "0 3 * * *"
permissions: permissions:
contents: write
actions: write actions: write
contents: write
issues: write
pull-requests: write pull-requests: write
security-events: write
jobs: jobs:
autoupdate: update:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with: with:
go-version: ">=1.24" go-version: ">=1.24"
release-workflow: "release.yml" node: false
secrets: inherit release-workflow: release.yml
-2
View File
@@ -62,8 +62,6 @@ homebrew_casks:
homepage: https://lukaszraczylo.github.io/kportal homepage: https://lukaszraczylo.github.io/kportal
description: "Modern Kubernetes port-forward manager with interactive TUI" description: "Modern Kubernetes port-forward manager with interactive TUI"
license: MIT license: MIT
url:
verified: github.com/lukaszraczylo/kportal
hooks: hooks:
post: post:
install: | install: |
+42
View File
@@ -22,6 +22,48 @@ reliability:
retryOnStale: true # Automatically reconnect when stale connections detected (default: true) retryOnStale: true # Automatically reconnect when stale connections detected (default: true)
watchdogPeriod: "30s" # Goroutine watchdog check interval to detect hung workers (default: 30s) 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: contexts:
# Production context # Production context
- name: production - name: production
+379 -266
View File
@@ -8,51 +8,99 @@
<a href="https://goreportcard.com/report/github.com/lukaszraczylo/kportal"><img src="https://goreportcard.com/badge/github.com/lukaszraczylo/kportal" alt="Go Report Card"></a> <a href="https://goreportcard.com/report/github.com/lukaszraczylo/kportal"><img src="https://goreportcard.com/badge/github.com/lukaszraczylo/kportal" alt="Go Report Card"></a>
</p> </p>
<p align="center"> # kportal
<strong>Kubernetes port-forward manager with interactive terminal UI</strong>
</p>
kportal manages multiple Kubernetes port-forwards with an interactive terminal interface. It provides real-time status updates, automatic reconnection, hot-reload configuration, and mDNS hostname publishing. kportal runs many Kubernetes port-forwards from one YAML file and shows their status in a terminal UI. It reconnects dropped forwards, reloads the file when it changes, and can run without the UI.
![kportal Screenshot](docs/kportal-screenshot.png) ![kportal Screenshot](docs/kportal-screenshot.png)
## ✨ Features Screenshot from kportal v0.2.17. The current key bar also lists `PgUp`/`PgDn`, `b` (Bench) and `l` (Logs).
- **Interactive TUI** - Terminal interface with keyboard navigation Website: <https://lukaszraczylo.github.io/kportal>
- **Live management** - Add, edit, and delete port-forwards without restarting
- **Auto-reconnect** - Exponential backoff retry on connection failures
- **Hot-reload** - Configuration changes applied automatically
- **Health monitoring** - Multiple check methods with stale connection detection
- **Multi-context** - Support for multiple Kubernetes contexts and namespaces
- **Pod restart handling** - Automatic reconnection when pods restart
- **Label selectors** - Dynamic pod targeting using label selectors
- **Port conflict detection** - Validates port availability with PID information
- **mDNS hostnames** - Access forwards via `.local` hostnames
- **HTTP traffic logging** - Real-time HTTP request/response logging for debugging
- **Connection benchmarking** - Built-in HTTP benchmarking with latency statistics
- **Headless mode** - Background operation for scripting and automation
## 🔄 Comparison with Other Tools ## Contents
| Feature | kportal | [k9s](https://k9scli.io/) | [Kube Forwarder](https://kube-forwarder.pixelpoint.io/) | [kftray](https://kftray.app/) | - [Features](#features)
|---------|---------|------|----------------|--------| - [Comparison](#comparison-with-other-tools)
| **Interface** | Terminal TUI | Terminal TUI | Desktop GUI (Electron) | Desktop GUI + TUI | - [Installation](#installation)
| **Persistent Config** | ✅ YAML file | ❌ Session only | ✅ JSON bookmarks | ✅ JSON + Git sync | - [Quick start](#quick-start)
| **Auto-reconnect** | ✅ Exponential backoff | ❌ Manual | ✅ Basic | ✅ Watch API | - [Configuration](#configuration)
| **Hot-reload Config** | ✅ File watch + SIGHUP | ❌ | ❌ | ❌ | - [Usage](#usage)
| **Health Checks** | ✅ TCP + data-transfer | ❌ | ❌ | ❌ | - [Status indicators](#status-indicators)
| **Stale Connection Detection** | ✅ Age + idle tracking | ❌ | ❌ | ❌ | - [Advanced features](#advanced-features)
| **HTTP Traffic Logging** | ✅ Built-in viewer | ❌ | ❌ | ✅ | - [Migration from kftray](#migration-from-kftray)
| **Connection Benchmarking** | ✅ Built-in | ✅ Via Hey | ❌ | ❌ | - [Signal handling](#signal-handling)
| **mDNS Hostnames** | ✅ `.local` domains | ❌ | ❌ | ❌ | - [Troubleshooting](#troubleshooting)
| **Label Selectors** | ✅ | ✅ | ❌ | ✅ | - [Development](#development)
| **Multi-context** | ✅ | ✅ | ✅ | ✅ | - [Telemetry](#telemetry)
| **Headless Mode** | ✅ | ❌ | ❌ | ❌ | - [License](#license)
| **System Tray** | ❌ | ❌ | ❌ | ✅ |
| **UDP Support** | ❌ | ❌ | ❌ | ✅ Proxy relay |
| **Dependencies** | Single binary | Single binary | Electron | Tauri + kubectl |
## 📦 Installation ## Features
| Area | What kportal does |
|------|-------------------|
| Forwards | Forwards to `service/<name>`, `pod/<name>` (exact name or name prefix), or `pod` with a label `selector`. Only TCP is supported. |
| Terminal UI | Add (`n`), edit (`e`), delete (`d`) and toggle (`Space`) forwards without a restart. The add wizard writes the config file. |
| Table columns | `tui.columns` sets which columns the forwards table shows, their order and their width. It applies to the UI and to the `-v` table. |
| Add wizard | If the cluster does not allow listing namespaces, the wizard asks you to type the namespace. |
| Reconnect | Retries with exponential backoff: 1s, 2s, 4s, 8s, then 10s, with 10% jitter. It does not stop retrying. |
| Pod restarts | Forwards by pod prefix or selector connect to a new pod after a restart. |
| Health checks | `tcp-dial` or `data-transfer` check every 3s by default. A connection is stale when it is older than `maxConnectionAge` and idle, or idle longer than `maxIdleTime`. |
| Hot reload | The file watcher and `SIGHUP` reload the config. An invalid config is rejected and the previous one stays active. |
| Contexts | Several Kubernetes contexts and namespaces in one file. `--context` selects which contexts run. |
| Port conflicts | Startup and reload checks report a busy local port and the process that holds it. |
| mDNS | With `mdns.enabled: true`, each forward is reachable as `<alias>.local`. Without an alias, the resource name is used. |
| HTTP log | Per-forward request and response log in the UI, with detail view, filter, search, JSON formatting, gzip/deflate decoding and clipboard copy. |
| Header redaction | With `includeHeaders: true`, values of sensitive headers such as `Authorization` and `Cookie` become `[REDACTED]`. You cannot turn this off. |
| Benchmark | Press `b` to send HTTP requests through a forward. Reports success and failure counts, min/max/avg latency, P50/P95/P99, requests per second and status codes. |
| Generate | `kportal generate` lists services in a cluster and appends the ones you pick to the config with consecutive local ports. |
| Headless | `-headless` runs without the UI and logs to stderr. `-log-format json` switches the log format. |
| Import | `-convert` turns a kftray JSON file into a kportal YAML file. |
| Shell completion | `kportal completion` prints a bash, zsh or fish completion script, or installs it. |
| Verified installer | `install.sh` checks the archive SHA-256 and, if `cosign` is on `PATH`, the keyless signature of the checksums file. |
| Updates | `-update` checks for a newer release. `-version` prints the version. |
## Comparison with other tools
Compared on 2026-10-02 against each project's README and docs. Check their repositories for changes. The kportal column comes from this repository's code. "Not documented" means the project's README and docs do not say.
| Feature | kportal | kubectl port-forward | k9s | kubefwd | kftray |
|---------|---------|----------------------|-----|---------|--------|
| Many forwards in one config file | Yes | No | Partial (annotations configure forwards; no forward list file) | Partial (`example.fwdconf.yml` exists) | Yes |
| Automatic reconnect | Yes | Not documented | Not documented | Yes | Yes |
| Follows pod restarts by label or name prefix | Yes | Not documented | Not documented | Yes (label filter) | Yes |
| Terminal UI of all forwards | Yes | No | Yes (PortForward view, alias `pf`; forwards last for the session) | Yes | Yes |
| GUI | No | No | No | No | Yes |
| Config hot reload | Yes (file watcher, `SIGHUP`) | No | Partial (`benchmarks.yaml` only) | Not documented | Not documented |
| HTTP request and response logging | Yes | No | Not documented | Not documented | Yes |
| Health checks | Yes (`tcp-dial`, `data-transfer`) | Not documented | Not documented | Not documented | Partial (README says it tracks pod health) |
| mDNS or friendly hostnames | Yes (mDNS) | No | No | Partial (edits `/etc/hosts`, not mDNS; needs root) | Not documented |
| Built-in benchmark | Yes | No | Yes (`hey`, key `b`) | Not documented | Not documented |
| Imports another tool's config | Yes (kftray JSON) | No | No | Not documented | Partial (JSON, GitHub repos, service annotations; not other tools' formats) |
| Several contexts in one config | Yes | No | Partial | Partial | Not documented |
| Local port conflict detection | Yes (reports the holding process) | Partial (`:remotePort` picks a free port) | Not documented | Yes | Not documented |
| Platforms | Linux, macOS, Windows | Not documented | macOS, Linux, Windows | macOS, Linux, Windows | macOS, Linux, Windows |
| Licence | MIT | Not documented | Apache-2.0 | Apache-2.0 | GPL-3.0 |
| Language | Go | Not documented | Go | Go | Rust (Tauri, Ratatui) |
| Latest release (2026-10-02) | [Releases](https://github.com/lukaszraczylo/kportal/releases) | Not documented | v0.51.0 (2026-06-06) | v1.25.16 (2026-06-20) | v0.29.2 (2026-09-29) |
When another tool fits better:
- kubectl port-forward for a one-off forward. It needs no config file and ships with kubectl.
- k9s for general cluster browsing and its built-in benchmark.
- kubefwd to forward every service in a namespace with local DNS names.
- kftray for a tray GUI, shared JSON configs and UDP.
Kube Forwarder is a GUI-only forwarder. Its last release was 2019-08-14.
Sources:
- kubectl: [Use Port Forwarding to Access Applications in a Cluster](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/)
- k9s: <https://github.com/derailed/k9s> and <https://k9scli.io/topics/bench/>
- kubefwd: <https://github.com/txn2/kubefwd>
- kftray: <https://github.com/hcavarsan/kftray>
## Installation
### Homebrew (macOS) ### Homebrew (macOS)
@@ -60,29 +108,37 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
brew install --cask lukaszraczylo/taps/kportal brew install --cask lukaszraczylo/taps/kportal
``` ```
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first: If you installed the formula (`brew install lukaszraczylo/taps/kportal`) earlier, run `brew uninstall kportal` first.
> ```bash
> brew uninstall kportal
> ```
### Quick Install ### Quick install
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
``` ```
The installer downloads `kportal-<version>-checksums.txt` from the same release and verifies the archive's SHA-256 before installing. If [`cosign`](https://github.com/sigstore/cosign) is on your `PATH`, the checksums file's keyless cosign signature is also verified against the shared-actions reusable workflow identity. The script downloads `kportal-<version>-checksums.txt` from the same release and verifies the archive SHA-256. If [`cosign`](https://github.com/sigstore/cosign) is on `PATH`, it also verifies the keyless signature of the checksums file.
| Variable | Effect | | Variable | Effect |
|----------|--------| |----------|--------|
| `DRY_RUN=1` | Download and verify only; do not install | | `DRY_RUN=1` | Download and verify only. Do not install. |
| `SKIP_COSIGN=1` | Skip cosign signature verification (SHA-256 is still enforced) | | `SKIP_COSIGN=1` | Skip the cosign check. The SHA-256 check still runs. |
| `INSTALL_DIR` | Target directory. Default `/usr/local/bin`. |
| `KPORTAL_VERSION` | Install a specific version, for example `1.2.3`, instead of the latest. |
| `COSIGN_CERT_IDENTITY_REGEXP` | Certificate identity to accept. Set it if you fork the release pipeline. |
### Manual Download Example:
Download binaries from the [releases page](https://github.com/lukaszraczylo/kportal/releases). ```bash
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | DRY_RUN=1 bash
```
### Build from Source ### Manual download
The [releases page](https://github.com/lukaszraczylo/kportal/releases) has archives for Linux, macOS and Windows on amd64 and arm64. Archives are named `kportal-<version>-<os>-<arch>.tar.gz`, and `.zip` on Windows. Each release also has a `kportal-<version>-checksums.txt` file.
### Build from source
Go 1.26 or later is required (see `go.mod`).
```bash ```bash
git clone https://github.com/lukaszraczylo/kportal.git git clone https://github.com/lukaszraczylo/kportal.git
@@ -90,12 +146,13 @@ cd kportal
make build && make install make build && make install
``` ```
### Verifying Release Signatures `make install` copies the binary to `/opt/homebrew/bin` if that directory exists, otherwise to `~/.local/bin` on macOS and Linux.
All release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify: ### Verifying release signatures
Release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. Download the checksums file and its sigstore bundle from the release, then run:
```bash ```bash
# Download the checksum file and its sigstore bundle from the release
cosign verify-blob \ cosign verify-blob \
--certificate-identity-regexp "^https://github\.com/lukaszraczylo/shared-actions/\.github/workflows/go-release\.yaml@refs/heads/main$" \ --certificate-identity-regexp "^https://github\.com/lukaszraczylo/shared-actions/\.github/workflows/go-release\.yaml@refs/heads/main$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
@@ -103,7 +160,9 @@ cosign verify-blob \
kportal-<version>-checksums.txt kportal-<version>-checksums.txt
``` ```
## 🚀 Quick Start Replace `<version>` with the release version, for example `1.2.3`. Then check the archive against the verified checksums file with `shasum -a 256` or `sha256sum`.
## Quick start
Create `.kportal.yaml`: Create `.kportal.yaml`:
@@ -124,31 +183,40 @@ contexts:
port: 8080 port: 8080
localPort: 8080 localPort: 8080
alias: api alias: api
httpLog: true # Enable HTTP traffic logging httpLog: true
``` ```
Each `name` under `contexts` must exist in your kubeconfig. List them with `kubectl config get-contexts`.
Run: Run:
```bash ```bash
kportal kportal
``` ```
### Keyboard Controls ### Keyboard controls
Forwards list:
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `↑↓` / `j/k` | Navigate | | `Up` `Down` / `j` `k` | Move the selection |
| `Space` / `Enter` | Toggle forward | | `PgUp` `PgDn` / `Ctrl+u` `Ctrl+d` | Move by 10 rows |
| `n` | Add new forward | | `Space` / `Enter` | Toggle the forward |
| `e` | Edit forward | | `n` | Add a forward |
| `d` | Delete forward | | `e` | Edit the forward |
| `b` | Benchmark connection | | `d` | Delete the forward (asks to confirm) |
| `l` | View HTTP logs | | `b` | Benchmark the forward |
| `q` | Quit | | `l` | Open the HTTP log |
| `q` / `Ctrl+c` | Quit |
## 📖 Configuration In the add and edit wizard, `h` on the confirmation step toggles `httpLog`. Advanced `httpLog` keys you set in YAML stay unchanged.
### Basic Structure HTTP log keys are listed under [HTTP traffic logging](#http-traffic-logging).
## Configuration
### Basic structure
```yaml ```yaml
contexts: contexts:
@@ -162,56 +230,56 @@ contexts:
localPort: <local-port> localPort: <local-port>
alias: <display-name> # optional alias: <display-name> # optional
selector: <label-selector> # optional selector: <label-selector> # optional
httpLog: true # optional - enable HTTP logging httpLog: true # optional
``` ```
### Forward Options ### Forward options
| Field | Required | Description | | Field | Required | Description |
|-------|----------|-------------| |-------|----------|-------------|
| `resource` | Yes | Resource type and name (e.g., `service/postgres`, `pod/my-app`) | | `resource` | Yes | `service/<name>`, `pod/<name>`, or `pod` with `selector` |
| `protocol` | Yes | Protocol (`tcp`) | | `protocol` | No | `tcp`. No other value is accepted. |
| `port` | Yes | Remote port | | `port` | Yes | Remote port |
| `localPort` | Yes | Local port | | `localPort` | Yes | Local port |
| `alias` | No | Display name and mDNS hostname | | `alias` | No | Display name and mDNS hostname |
| `selector` | No | Label selector for pod resolution | | `selector` | With bare `pod` | Label selector. Not allowed with `pod/<name>`. |
| `httpLog` | No | Enable HTTP traffic logging (`true`/`false`) | | `httpLog` | No | `true`, or a map of options (see [HTTP traffic logging](#http-traffic-logging)) |
### Resource Formats ### Resource formats
| Format | Description | | Format | Description |
|--------|-------------| |--------|-------------|
| `service/name` | Service forwarding | | `service/name` | Forward to a service |
| `pod/name` | Direct pod by name | | `pod/name` | Forward to a pod by exact name, or by name prefix |
| `pod/prefix` | Pod by prefix (matches `prefix-*`) | | `pod` with `selector` | Forward to a pod picked by label selector |
| `pod` + `selector` | Pod by label selector |
| `deployment/name` | Deployment |
### Health Check Configuration Valid resource types are `pod` and `service`. To reach a deployment, use `pod` with a `selector`.
### Health checks and reliability
```yaml ```yaml
healthCheck: healthCheck:
interval: "3s" # Check frequency interval: "3s"
timeout: "2s" # Check timeout timeout: "2s"
method: "data-transfer" # tcp-dial or data-transfer method: "data-transfer" # or tcp-dial
maxConnectionAge: "25m" # Reconnect before k8s timeout maxConnectionAge: "25m"
maxIdleTime: "10m" # Detect idle connections maxIdleTime: "10m"
reliability: reliability:
tcpKeepalive: "30s" tcpKeepalive: "30s"
dialTimeout: "30s" dialTimeout: "30s"
retryOnStale: true retryOnStale: true
watchdogPeriod: "30s"
``` ```
Health check methods: The values above are the defaults from `.kportal.yaml`.
- `tcp-dial` - Fast TCP connection test
- `data-transfer` - Verifies tunnel functionality by attempting data read
Connection age reconnection only triggers when the connection is also idle, preventing interruption of active transfers like database dumps. - `tcp-dial` opens a TCP connection to the local port.
- `data-transfer` also tries to read data through the tunnel. It is the default.
- A connection past `maxConnectionAge` reconnects only when it is also idle, so a running transfer is not cut.
- `watchdogPeriod` is the interval of the check for hung workers.
### mDNS Hostnames ### mDNS
Enable mDNS to access forwards via `.local` hostnames:
```yaml ```yaml
mdns: mdns:
@@ -225,68 +293,134 @@ contexts:
- resource: service/postgres - resource: service/postgres
port: 5432 port: 5432
localPort: 5432 localPort: 5432
alias: prod-db # Accessible via prod-db.local:5432 alias: prod-db # reachable as prod-db.local:5432
``` ```
- Explicit `alias` becomes `<alias>.local` - `alias: prod-db` publishes `prod-db.local`.
- Without alias, hostname is generated from resource name (`service/redis` → `redis.local`) - Without an alias, `service/redis` publishes `redis.local`.
- Works on macOS (Bonjour) and Linux (avahi-daemon) - A bare `pod` with a selector and no alias is not published.
- Services register as `_kportal._tcp`.
Check registration:
Verify registration:
```bash ```bash
dns-sd -B _kportal._tcp local # macOS dns-sd -B _kportal._tcp local # macOS
avahi-browse -t _kportal._tcp # Linux avahi-browse -t _kportal._tcp # Linux
``` ```
### Table columns
```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`.
- List order is render order. Unlisted columns are hidden.
- `width` is the maximum text width, 1 to 200. Without it, the column keeps its default width.
- Without `tui`, the table keeps its default columns and order.
- The setting applies to the UI and to the `-v` table, and reloads with the file.
## Usage ## Usage
### Interactive Mode ### Command line
| Command or flag | Effect |
|-----------------|--------|
| `kportal` | Start the terminal UI. |
| `-c <file>` | Config file. Default `.kportal.yaml`. |
| `-v` | Verbose logging. In headless mode it sets the log level only. |
| `-headless` | No UI. Logs go to stderr. |
| `-check` | Validate the config and exit. |
| `-context <name>` | Forward only the named contexts. Repeatable and comma-separated. |
| `-log-format text\|json` | Log format. Default `text`. |
| `-convert <file>` | Convert a kftray JSON file to kportal YAML. |
| `-convert-output <file>` | Output of `-convert`. Default `.kportal.yaml`. |
| `-update` | Check for updates and exit. |
| `-version` | Print the version and exit. |
| `kportal generate` | Add forwards from a cluster. |
| `kportal completion` | Print or install a shell completion script. |
Each flag also accepts the double-dash form, for example `--check`.
### Interactive mode
```bash ```bash
kportal kportal
``` ```
### Verbose Mode ### Verbose mode
```bash ```bash
kportal -v kportal -v
``` ```
### Headless Mode Without `-headless`, `-v` prints a plain-text table that redraws. The table uses the same status words as the UI. When stdout is not a terminal, it draws `✓ Active`, `⋯ Starting`, `↻ Reconnecting` and `✗ Error`.
Run without TUI for scripting and automation: ### Headless mode
```bash ```bash
kportal -headless kportal -headless
```
Headless mode emits both structured and standard-library logs to stderr by default
(suitable for redirecting to a log file or systemd journal). The `-v` flag controls
log level (debug vs info), not destination.
Combines well with verbose mode for background operation:
```bash
kportal -headless -v 2>kportal.log & kportal -headless -v 2>kportal.log &
kportal -headless -log-format json
``` ```
### Validate Configuration Headless mode logs to stderr, so you can redirect it to a file or the systemd journal. `-v` sets the log level, not the destination.
### Validate configuration
```bash ```bash
kportal --check kportal -check
``` ```
### Custom Config File ### Custom config file
```bash ```bash
kportal -c /path/to/config.yaml kportal -c /path/to/config.yaml
``` ```
### Generate Forwards from a Cluster ### Select contexts
The `generate` subcommand discovers services in a Kubernetes context and lets you By default kportal forwards every context in the file. `--context` limits the run to the contexts you name:
interactively pick which ones to forward. Selected entries are appended to the
config file with consecutive local ports starting from a value you choose. ```bash
kportal --context development-team-a
kportal --context team-a --context team-b
kportal --context team-a,team-b
```
An unknown name is an error that lists the contexts in the file. Those are the `contexts[].name` values in `.kportal.yaml`, not every context in your kubeconfig.
Local-port conflicts are only reported between forwards that can run together. With `--context`, contexts you never select together can reuse a `localPort`:
```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
```
Without `--context`, or with both contexts named, the duplicate port is an error.
### Generate
```bash ```bash
kportal generate --context=my-cluster kportal generate --context=my-cluster
@@ -296,97 +430,51 @@ kportal generate --context=my-cluster --dry-run
| Flag | Description | | Flag | Description |
|------|-------------| |------|-------------|
| `--context` | (required) Kubernetes context to scan | | `--context` | Required. Kubernetes context to scan. |
| `--config` | Path to kportal config file (default: `.kportal.yaml`) | | `--config` | Config file to append to. Default `.kportal.yaml`. |
| `--dry-run` | Print the planned forwards but do not modify the config | | `--dry-run` | Print the planned forwards. Do not change the config. |
The interactive flow has three steps: Steps:
1. **Namespaces** — multi-select with `space`, toggle-all with `a`, filter with `/`. 1. Namespaces: select with `space`, toggle all with `a`, filter with `/`.
2. **Services** — same controls; rows already present in the config are locked off, and non-TCP ports are skipped (UDP is not supported by kportal's forward layer). 2. Services: same keys. Services already in the config are locked. Non-TCP ports are skipped.
3. **Port assignment** — choose a starting local port (default `10000`, must be ≥ `1024`). Local ports are assigned consecutively in stable order, skipping any already in use. 3. Port assignment: choose a first local port (default `10000`, minimum `1024`). Ports are assigned in order and skip ports in use.
Press `enter` on the final step to save (or to print and exit when `--dry-run` is set), `b` to go back, or `esc` to cancel. Press `enter` on the last step to save (or print, with `--dry-run`), `b` to go back, `esc` to cancel.
## Status Indicators The `--dry-run` output has this form (example values):
| Indicator | Description | ```text
|-----------|-------------| [dry-run] Would add 2 forwards to .kportal.yaml
10000 → my-cluster/default/service/api:8080
10001 → my-cluster/default/service/postgres:5432
```
### Shell completion
```bash
kportal completion --shell zsh # print the script
kportal completion --shell zsh --install # install it
kportal completion --uninstall # remove it
```
Without `--shell`, kportal detects the shell. Supported shells: bash, zsh, fish.
## Status indicators
| Indicator | Meaning |
|-----------|---------|
| `● Active` | Connection healthy | | `● Active` | Connection healthy |
| `○ Starting` | Initial connection (10s grace period) | | `○ Starting` | First connection. Failed health checks show Starting for the first 10s. |
| `◐ Reconnecting` | Reconnecting after failure | | `◐ Reconnecting` | Reconnecting after a failure |
| `✗ Error` | Connection failed | | `✗ Error` | Connection failed |
| `○ Disabled` | Manually disabled | | `○ Disabled` | Turned off in the UI |
## Advanced Features ## Advanced features
### HTTP Traffic Logging ### HTTP traffic logging
Press `l` in the TUI to view real-time HTTP traffic for a selected forward. The log viewer shows: Press `l` on a forward that has `httpLog` set.
| Column | Description |
|--------|-------------|
| TIME | Request timestamp |
| METHOD | HTTP method (GET, POST, etc.) |
| STATUS | Response status code |
| LATENCY | Request duration |
| PATH | Request path |
**List view shortcuts:**
| Key | Action |
|-----|--------|
| `↑/↓` | Navigate entries |
| `Enter` | View request details |
| `g/G` | Jump to top/bottom |
| `a` | Toggle auto-scroll |
| `f` | Cycle filter mode (All → Non-2xx → Errors) |
| `/` | Search by path or method |
| `c` | Clear all filters |
| `q` | Close log viewer |
**Detail view:**
Press `Enter` on any entry to see full request/response details including:
- Request and response headers (alphabetically sorted)
- Request and response bodies
- Timing information and status codes
| Key | Action |
|-----|--------|
| `↑/↓` | Scroll content |
| `PgUp/PgDn` | Scroll by page |
| `g` | Jump to top |
| `c` | Copy response body to clipboard |
| `Esc/q` | Return to list |
**Body display features:**
- **JSON formatting** - JSON bodies are pretty-printed with syntax highlighting
- **Compression handling** - gzip/deflate content is automatically decompressed
- **Binary detection** - Binary content shows a placeholder instead of garbled data
**Filter modes:**
- **All** - Show all entries
- **Non-2xx** - Hide successful (2xx) responses
- **Errors** - Show only 4xx and 5xx responses
**Toggling per-forward logging:**
In the add/edit wizard, press `h` on the confirmation step to toggle `httpLog` on or
off for the current forward. The wizard preserves any advanced `httpLog` keys
(`logFile`, `includeHeaders`, `maxBodySize`, `filterPath`) you set in YAML.
**Header redaction:**
When `httpLog.includeHeaders: true` is set, sensitive header values are
automatically replaced with `[REDACTED]`. The header name is preserved so you can
see that an `Authorization` header was present without exposing its value. Redacted
headers include `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`,
`X-Auth-Token`, `X-Csrf-Token`, `Proxy-Authorization`, `X-Access-Token`, and any
header whose name contains `token`, `secret`, `password`, or `apikey`. This is
always on and cannot be disabled.
**Advanced configuration:**
```yaml ```yaml
forwards: forwards:
@@ -395,43 +483,88 @@ forwards:
localPort: 8080 localPort: 8080
httpLog: httpLog:
enabled: true enabled: true
includeHeaders: true # values of sensitive headers are redacted includeHeaders: true # sensitive values are redacted
maxBodySize: 65536 # bytes; 0 = unlimited maxBodySize: 65536 # bytes
filterPath: "/api/" # only log paths matching this substring filterPath: "/api/" # log only paths containing this text
logFile: "api.log" # append entries to a file in addition to the in-memory ring logFile: "api.log" # also append entries to this file
``` ```
### Connection Benchmarking `maxBodySize` defaults to 1 MB (1048576 bytes) when you omit it or set a value of 0 or less.
Press `b` in the TUI to benchmark a selected forward. Configure: List view columns: TIME, METHOD, STATUS, LATENCY, PATH.
- **URL Path** - Target endpoint (default: `/`) | Key | Action |
- **Method** - HTTP method (GET, POST, etc.) |-----|--------|
- **Concurrency** - Number of parallel workers | `Up` `Down` | Move |
- **Requests** - Total number of requests | `Enter` | Open details |
| `g` / `G` | Top / bottom |
| `a` | Toggle auto-scroll |
| `f` | Cycle filter: All, Non-2xx, Errors |
| `/` | Search path or method |
| `c` | Clear filters |
| `q` | Close |
Results include: The detail view shows sorted request and response headers, bodies and timing. JSON bodies are pretty-printed, gzip and deflate bodies are decoded, and binary bodies show a placeholder.
- Success/failure counts
- Min/Max/Avg latency
- P50/P95/P99 percentiles
- Throughput (requests/sec)
- Status code distribution
### Hot-Reload | Key | Action |
|-----|--------|
| `Up` `Down` / `PgUp` `PgDn` | Scroll |
| `g` | Top |
| `c` | Copy the response body |
| `Esc` / `q` / `Enter` | Back to the list |
Configuration changes are applied automatically. Manual reload: #### Sensitive header redaction
With `includeHeaders: true`, kportal replaces these header values with `[REDACTED]` and keeps the name: `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `X-Csrf-Token`, `Proxy-Authorization`, `X-Access-Token`, and any name containing `token`, `secret`, `password` or `apikey` (any case). You cannot turn this off. Source: `internal/httplog/proxy.go`.
### Benchmarking
Press `b` on a forward. Set the URL path (default `/`), the HTTP method, the concurrency and the request count. The wizard starts at 10 workers and 100 requests.
The results screen has this layout (`x.xx` marks a measured value):
```text
Total Requests: 100
Successful: 100 (100.0%)
Failed: 0
Latency (ms)
Min: x.xx
Max: x.xx
Avg: x.xx
P50: x.xx
P95: x.xx
P99: x.xx
Throughput
Requests/sec: x.xx
Bytes read: n
Status Codes
200: n
```
Only 2xx responses count as successful.
### Hot reload
kportal reloads when the config file changes. To reload by hand:
```bash ```bash
kill -HUP $(pgrep kportal) kill -HUP $(pgrep kportal)
``` ```
### Port Conflict Detection New forwards start, removed forwards stop and unchanged forwards keep running. An invalid config is rejected and the previous one stays active.
kportal validates port availability at startup and during hot-reload, showing which process is using conflicting ports. ### Port conflict detection
### Retry Strategy kportal checks that local ports are free at startup and on reload. The report names the process that holds a busy port. kportal finds it with `lsof` on Linux and macOS and `tasklist` on Windows. A reload that would bind a busy port is rejected and the previous config stays active.
Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue indefinitely until connection succeeds. Conflicts are only reported between forwards that can run together. See [Select contexts](#select-contexts).
### Retry strategy
Exponential backoff with 10% jitter: 1s, 2s, 4s, 8s, then 10s (cap). Retries continue until the connection succeeds.
## Migration from kftray ## Migration from kftray
@@ -439,83 +572,63 @@ Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue ind
kportal --convert configs.json --convert-output .kportal.yaml kportal --convert configs.json --convert-output .kportal.yaml
``` ```
## Signal Handling `-convert-output` defaults to `.kportal.yaml`.
- `Ctrl+C` / `SIGTERM` - Graceful shutdown ## Signal handling
- `SIGHUP` - Reload configuration
## 🐛 Troubleshooting | Signal | Effect |
|--------|--------|
| `Ctrl+C`, `SIGTERM` | Graceful shutdown |
| `SIGHUP` | Reload the configuration |
### Port Already in Use ## Troubleshooting
Port already in use:
```bash ```bash
lsof -i :<port> lsof -i :<port>
kill <pid> kill <pid>
``` ```
### Connection Refused Connection refused:
1. Verify pod is running: `kubectl get pods -n <namespace>` 1. Check the pod: `kubectl get pods -n <namespace>`
2. Verify port is correct: `kubectl describe pod <pod>` 2. Check the port: `kubectl describe pod <pod>`
3. Check service endpoints: `kubectl get endpoints <service>` 3. Check the service endpoints: `kubectl get endpoints <service>`
### Context Not Found Context not found: list your contexts with `kubectl config get-contexts`. kportal reports `context <name> not found in kubeconfig` when a `contexts[].name` value is not in your kubeconfig. Names with `@`, `.`, `:` or `/` (for example `admin@home` or an EKS ARN) are valid. If `--context` reports `unknown context`, the name is missing from `contexts[].name` in the config file.
The add wizard shows a namespace prompt instead of a list: the cluster refused or returned an empty namespace list. Type the namespace name.
## Development
Requires Go 1.26 or later, kubectl and access to a cluster.
```bash ```bash
kubectl config get-contexts make build # build the binary
``` make test # run tests
Context names containing `@`, `.`, `:`, or `/` (e.g. `admin@home`,
`[email protected]`, GKE dotted names, EKS ARNs) are accepted by the
config validator.
## 🔧 Development
### Prerequisites
- Go 1.23+
- Kubernetes cluster access
- kubectl configured
### Build
```bash
make build # Build binary
make test # Run tests
make all # fmt, vet, staticcheck, test make all # fmt, vet, staticcheck, test
make install # Install locally make install # install locally
``` ```
## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Release history is in [CHANGELOG.md](CHANGELOG.md).
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Telemetry ## Telemetry
On startup this binary sends a single anonymous adoption ping — project name, On startup kportal sends one anonymous adoption ping: project name, version and timestamp. It has a 2-second timeout and cannot block startup.
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)** To opt out, set `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1` or `KPORTAL_DISABLE_TELEMETRY=1`. See [oss-telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry) for the wire format and source.
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 ## License
MIT License - see [LICENSE](LICENSE). MIT. See [LICENSE](LICENSE).
## Acknowledgments ## Credits
- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - Terminal UI framework [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Lipgloss](https://github.com/charmbracelet/lipgloss), [client-go](https://github.com/kubernetes/client-go). [kftray](https://github.com/hcavarsan/kftray) was the inspiration.
- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Terminal styling
- [client-go](https://github.com/kubernetes/client-go) - Kubernetes client
- [kftray](https://github.com/hcavarsan/kftray) - Inspiration
## Links ## Links
- [Website](https://lukaszraczylo.github.io/kportal) - [Website](https://lukaszraczylo.github.io/kportal)
- [Issues](https://github.com/lukaszraczylo/kportal/issues) - [Issues](https://github.com/lukaszraczylo/kportal/issues)
- [Releases](https://github.com/lukaszraczylo/kportal/releases) - [Releases](https://github.com/lukaszraczylo/kportal/releases)
- [Changelog](CHANGELOG.md)
+93 -44
View File
@@ -51,6 +51,7 @@ type runOptions struct {
logFormat string logFormat string
convertInput string convertInput string
convertOutput string convertOutput string
contexts []string
verbose bool verbose bool
headless bool headless bool
check bool check bool
@@ -58,6 +59,28 @@ type runOptions struct {
checkUpdate bool 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 // fprintf is a small wrapper that suppresses the io.Writer write error. We
// route output to caller-provided writers (stdout / stderr / io.Discard / // 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 // bytes.Buffer in tests), and a write error on any of these is non-actionable
@@ -166,13 +189,31 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
return code 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() 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)) fprint(stderr, config.FormatValidationErrors(errs))
return 1 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 { if opts.check {
fprintln(stdout, "Configuration is valid") fprintln(stdout, "Configuration is valid")
return 0 return 0
@@ -192,9 +233,9 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
switch { switch {
case opts.headless: case opts.headless:
return runHeadless(ctx, opts, cfg, deps, validator, stderr) return runHeadless(ctx, opts, cfg, deps, stderr)
case opts.verbose: case opts.verbose:
return runVerboseTable(ctx, opts, cfg, deps, validator, stderr) return runVerboseTable(ctx, opts, cfg, deps, stderr)
default: default:
return runInteractive(ctx, opts, cfg, deps, stderr) return runInteractive(ctx, opts, cfg, deps, stderr)
} }
@@ -208,6 +249,8 @@ func parseFlags(args []string, stderr io.Writer) (runOptions, int, bool) {
fs.SetOutput(stderr) fs.SetOutput(stderr)
var opts runOptions 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.StringVar(&opts.configFile, "c", defaultConfigFile, "Path to configuration file")
fs.BoolVar(&opts.verbose, "v", false, "Enable verbose logging") fs.BoolVar(&opts.verbose, "v", false, "Enable verbose logging")
fs.BoolVar(&opts.headless, "headless", false, "Run in headless mode (no UI, for background/daemon use)") fs.BoolVar(&opts.headless, "headless", false, "Run in headless mode (no UI, for background/daemon use)")
@@ -224,6 +267,7 @@ func parseFlags(args []string, stderr io.Writer) (runOptions, int, bool) {
} }
return opts, 2, true return opts, 2, true
} }
opts.contexts = config.NormalizeContextSelection(contexts)
return opts, 0, false return opts, 0, false
} }
@@ -335,6 +379,30 @@ func loadOrCreateConfig(configFile string, stdin io.Reader, stdout, stderr io.Wr
return cfg, true, 0, false 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. // runtimeDeps bundles the long-lived objects shared by all UI modes.
type runtimeDeps struct { type runtimeDeps struct {
manager *forward.Manager manager *forward.Manager
@@ -355,6 +423,7 @@ func buildRuntimeDeps(opts runOptions, cfg *config.Config, stderr io.Writer) (*r
} }
discovery := k8s.NewDiscovery(pool) discovery := k8s.NewDiscovery(pool)
mutator := config.NewMutator(opts.configFile) mutator := config.NewMutator(opts.configFile)
mutator.SetActiveContexts(opts.contexts)
manager, err := forward.NewManager(opts.verbose) manager, err := forward.NewManager(opts.verbose)
if err != nil { if err != nil {
@@ -429,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, // runHeadless runs the daemon-style mode: no UI, signal-driven SIGHUP reloads,
// graceful shutdown on ctx.Done() (which is cancelled by SIGINT/SIGTERM). // 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 { if startErr := deps.manager.Start(cfg); startErr != nil {
fprintf(stderr, "Error starting forwards: %v\n", startErr) fprintf(stderr, "Error starting forwards: %v\n", startErr)
return 1 return 1
@@ -442,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 { watcher, watcherErr := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
return deps.manager.Reload(newCfg) return deps.manager.Reload(newCfg)
}, opts.verbose) }, opts.verbose, config.WithContextSelection(opts.contexts))
watcherStarted := false watcherStarted := false
if watcherErr != nil { if watcherErr != nil {
if opts.verbose { if opts.verbose {
@@ -471,33 +540,15 @@ func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps
if opts.verbose { if opts.verbose {
log.Printf("Received SIGHUP, reloading configuration...") log.Printf("Received SIGHUP, reloading configuration...")
} }
newCfg, loadErr := config.LoadConfig(opts.configFile) reloadFromSignal(opts, deps.manager, opts.verbose, nil)
if loadErr != nil {
if opts.verbose {
log.Printf("Failed to reload config: %v", loadErr)
}
continue
}
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
if opts.verbose {
log.Printf("Config validation failed:")
log.Print(config.FormatValidationErrors(errs))
}
continue
}
if reloadErr := deps.manager.Reload(newCfg); reloadErr != nil {
if opts.verbose {
log.Printf("Failed to reload: %v", reloadErr)
}
}
} }
} }
} }
// runVerboseTable runs the simple table UI with periodic redraws and SIGHUP // runVerboseTable runs the simple table UI with periodic redraws and SIGHUP
// reload, exiting cleanly when ctx is cancelled. // 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 { func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, stderr io.Writer) int {
tableUI := ui.NewTableUI(opts.verbose) tableUI := ui.NewTableUI(opts.verbose, cfg)
deps.manager.SetStatusUI(tableUI) deps.manager.SetStatusUI(tableUI)
// Background update check (best effort). // Background update check (best effort).
@@ -539,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 { watcher, watchErr := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
return deps.manager.Reload(newCfg) if err := deps.manager.Reload(newCfg); err != nil {
}, opts.verbose) return err
}
tableUI.SetColumns(newCfg)
return nil
}, opts.verbose, config.WithContextSelection(opts.contexts))
watcherActive := false watcherActive := false
if watchErr != nil { if watchErr != nil {
log.Printf("Warning: Failed to setup config watcher: %v", watchErr) log.Printf("Warning: Failed to setup config watcher: %v", watchErr)
@@ -564,19 +619,7 @@ func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, d
return shutdownManager(ctx, deps.manager, opts.verbose) return shutdownManager(ctx, deps.manager, opts.verbose)
case <-sigChan: case <-sigChan:
log.Printf("Received SIGHUP, reloading configuration...") log.Printf("Received SIGHUP, reloading configuration...")
newCfg, loadErr := config.LoadConfig(opts.configFile) reloadFromSignal(opts, deps.manager, true, tableUI.SetColumns)
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)
}
} }
} }
} }
@@ -590,7 +633,9 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de
_ = deps.manager.DisableForward(id) _ = deps.manager.DisableForward(id)
} }
}, appVersion) }, appVersion)
bubbleTeaUI.SetColumns(cfg)
bubbleTeaUI.SetWizardDependencies(deps.discovery, deps.mutator, opts.configFile) bubbleTeaUI.SetWizardDependencies(deps.discovery, deps.mutator, opts.configFile)
bubbleTeaUI.SetActiveContexts(opts.contexts)
bubbleTeaUI.SetHTTPLogSubscriber(makeHTTPLogSubscriber(deps.manager)) bubbleTeaUI.SetHTTPLogSubscriber(makeHTTPLogSubscriber(deps.manager))
go func() { go func() {
@@ -611,8 +656,12 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de
var watcher *config.Watcher var watcher *config.Watcher
watcher, err := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error { watcher, err := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
return deps.manager.Reload(newCfg) if err := deps.manager.Reload(newCfg); err != nil {
}, opts.verbose) return err
}
bubbleTeaUI.SetColumns(newCfg)
return nil
}, opts.verbose, config.WithContextSelection(opts.contexts))
if err == nil { if err == nil {
watcher.Start() watcher.Start()
} }
+189
View File
@@ -13,6 +13,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/forward" "github.com/lukaszraczylo/kportal/internal/forward"
"github.com/lukaszraczylo/kportal/internal/ui" "github.com/lukaszraczylo/kportal/internal/ui"
"github.com/lukaszraczylo/kportal/internal/version" "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.verbose)
assert.False(t, opts.headless) assert.False(t, opts.headless)
assert.Equal(t, "text", opts.logFormat) assert.Equal(t, "text", opts.logFormat)
assert.Empty(t, opts.contexts, "no -context means every context is forwarded")
} }
func TestParseFlags_AllSet(t *testing.T) { func TestParseFlags_AllSet(t *testing.T) {
@@ -651,3 +653,190 @@ func TestLoadOrCreateConfig_NotFound_AcceptCreates(t *testing.T) {
require.NotNil(t, cfg) require.NotNil(t, cfg)
assert.FileExists(t, cfgPath) 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)
}
+1175 -1063
View File
File diff suppressed because it is too large Load Diff
+31 -36
View File
@@ -8,12 +8,12 @@ require (
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/go-logr/logr v1.4.4 github.com/go-logr/logr v1.4.4
github.com/grandcat/zeroconf v1.0.0 github.com/grandcat/zeroconf v1.0.0
github.com/lukaszraczylo/oss-telemetry v0.2.3 github.com/lukaszraczylo/oss-telemetry v0.2.5
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.36.3 k8s.io/api v0.37.1
k8s.io/apimachinery v0.36.3 k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.36.3 k8s.io/client-go v0.37.1
k8s.io/klog/v2 v2.140.0 k8s.io/klog/v2 v2.140.0
) )
@@ -29,31 +29,30 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/swag v0.28.0 // indirect github.com/go-openapi/swag v0.29.2 // indirect
github.com/go-openapi/swag/cmdutils v0.28.0 // indirect github.com/go-openapi/swag/cmdutils v0.29.2 // indirect
github.com/go-openapi/swag/conv v0.28.0 // indirect github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/fileutils v0.28.0 // indirect github.com/go-openapi/swag/fileutils v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/mangling v0.28.0 // indirect github.com/go-openapi/swag/mangling v0.29.2 // indirect
github.com/go-openapi/swag/netutils v0.28.0 // indirect github.com/go-openapi/swag/netutils v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/json-iterator/go v1.1.12 // 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.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/miekg/dns v1.1.72 // indirect github.com/miekg/dns v1.1.73 // indirect
github.com/moby/spdystream v0.5.1 // indirect github.com/moby/spdystream v0.5.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -61,27 +60,23 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v1.0.0 // indirect github.com/xo/terminfo v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/mod v0.40.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/term v0.46.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.16.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 // indirect
k8s.io/streaming v0.36.3 // indirect k8s.io/streaming v0.37.1 // indirect
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect
+72 -79
View File
@@ -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/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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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/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 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= 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.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.4/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 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4=
github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4=
github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo=
github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro=
github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw=
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI=
github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0=
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog=
github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8=
github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8=
github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE=
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= 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/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -92,17 +91,17 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= 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/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.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ= github.com/lukaszraczylo/oss-telemetry v0.2.5/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= 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-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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= 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.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= 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 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= 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= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -131,53 +130,47 @@ 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 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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.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.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 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/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= 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/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= 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= 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-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-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.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-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-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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -190,18 +183,18 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= k8s.io/client-go v0.37.1 h1:QTv/5ha4jAHtW9qxxVBkQVFBRDb4jHfFopQqqMdc+wM=
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= k8s.io/client-go v0.37.1/go.mod h1:dnAPtTnCNY38Ho04D2KdY1F4IKausa9UbqaAZKl60SY=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 h1:z5+pcu1jTyKK5mNTe2/+x+U6Uuv9jRVOJQLaBJJMpeI=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w= k8s.io/streaming v0.37.1 h1:TpzVfQeFuVndn2g9mFqxy1UcUYPwDzqjUmwR/IzJCWc=
k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/streaming v0.37.1/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= 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 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+3 -1
View File
@@ -231,7 +231,7 @@ _kportal()
# Top-level options # Top-level options
if [[ "$cur" == -* ]]; then if [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "-c -v --check --headless --log-format --version --update --convert --convert-output" -- "$cur") ) COMPREPLY=( $(compgen -W "-c -v --check --context --headless --log-format --version --update --convert --convert-output" -- "$cur") )
return return
fi fi
@@ -258,6 +258,7 @@ func generateZsh() (string, error) {
// these are literals (single source of truth — keep in sync with the CLI flags). // these are literals (single source of truth — keep in sync with the CLI flags).
flagDescs := []string{ flagDescs := []string{
`'-c[Path to configuration file]:config file:_files -g "*.yaml"'`, `'-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]'`, `'-v[Enable verbose logging]'`,
`'--version[Show version and exit]'`, `'--version[Show version and exit]'`,
`'--update[Check for updates]'`, `'--update[Check for updates]'`,
@@ -355,6 +356,7 @@ complete -c kportal -s v -d 'Enable verbose logging'
complete -c kportal -l version -d 'Show version and exit' complete -c kportal -l version -d 'Show version and exit'
complete -c kportal -l update -d 'Check for updates' complete -c kportal -l update -d 'Check for updates'
complete -c kportal -l check -d 'Validate configuration' 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 headless -d 'Run without UI'
complete -c kportal -l log-format -d 'Log format' -a 'text json' -f 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 -r -f -a '( __fish_complete_suffix .json )' -d 'Convert kftray config'
+1
View File
@@ -251,6 +251,7 @@ func TestAllFlagsPresent(t *testing.T) {
"--version", "--version",
"--update", "--update",
"--check", "--check",
"--context",
"--headless", "--headless",
"--log-format", "--log-format",
"--convert", "--convert",
+29
View File
@@ -60,9 +60,29 @@ type Config struct {
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"` HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"` Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
MDNS *MDNSSpec `yaml:"mdns,omitempty"` MDNS *MDNSSpec `yaml:"mdns,omitempty"`
TUI *TUISpec `yaml:"tui,omitempty"`
Contexts []Context `yaml:"contexts"` 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 // MDNSSpec configures mDNS (multicast DNS) hostname publishing
// When enabled, forwards with aliases can be accessed via <alias>.local hostnames // When enabled, forwards with aliases can be accessed via <alias>.local hostnames
type MDNSSpec struct { type MDNSSpec struct {
@@ -174,6 +194,15 @@ func (c *Config) IsMDNSEnabled() bool {
return c.MDNS != nil && c.MDNS.Enabled 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 // Context represents a Kubernetes context with its namespaces
type Context struct { type Context struct {
Name string `yaml:"name"` Name string `yaml:"name"`
+75 -14
View File
@@ -13,8 +13,9 @@ import (
// All operations use atomic file writes (write to temp, then rename) to prevent // All operations use atomic file writes (write to temp, then rename) to prevent
// corruption and ensure the file watcher picks up changes. // corruption and ensure the file watcher picks up changes.
type Mutator struct { type Mutator struct {
configPath string configPath string
mu sync.Mutex // Ensure only one mutation at a time activeContexts []string
mu sync.Mutex // Ensure only one mutation at a time
} }
// NewMutator creates a new configuration mutator for the given config file path. // 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 // findOrCreateContext finds an existing context or creates a new one
func (m *Mutator) findOrCreateContext(cfg *Config, contextName string) *Context { func (m *Mutator) findOrCreateContext(cfg *Config, contextName string) *Context {
for i := range cfg.Contexts { 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 // Set context/namespace on the forward for validation
fwd.SetContext(contextName, namespaceName) 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() allForwards := cfg.GetAllForwards()
for _, existing := range allForwards { for _, existing := range allForwards {
if existing.LocalPort == fwd.LocalPort { if existing.LocalPort != fwd.LocalPort {
return fmt.Errorf("port %d is already in use by %s", fwd.LocalPort, existing.String()) 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 // Add the forward
targetNamespace.Forwards = append(targetNamespace.Forwards, fwd) 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() 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)) 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 // Validate the new configuration
validator := NewValidator() 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)) 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. // 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 { 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 { return m.RemoveForwards(func(ctx, ns string, fwd Forward) bool {
if contextName != "" && ctx != contextName {
return false
}
return fwd.ID() == id 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 old forward doesn't exist, returns an error.
// If the new forward validation fails, the operation is rolled back (old forward remains). // 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 { 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() m.mu.Lock()
defer m.mu.Unlock() 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 // CRITICAL: Set context/namespace so fwd.ID() generates correct ID
fwd.SetContext(ctx.Name, ns.Name) fwd.SetContext(ctx.Name, ns.Name)
if fwd.ID() == oldID { matchesContext := oldContextName == "" || ctx.Name == oldContextName
if matchesContext && fwd.ID() == oldID {
oldForwardFound = true oldForwardFound = true
// Skip this forward (remove it) // Skip this forward (remove it)
continue continue
@@ -218,20 +272,27 @@ func (m *Mutator) UpdateForward(oldID, newContextName, newNamespaceName string,
// Set context/namespace on the forward for validation // Set context/namespace on the forward for validation
newFwd.SetContext(newContextName, newNamespaceName) 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() allForwards := cfg.GetAllForwards()
for _, existing := range allForwards { for _, existing := range allForwards {
if existing.LocalPort == newFwd.LocalPort && existing.ID() != oldID { if existing.LocalPort != newFwd.LocalPort || existing.ID() == oldID {
return fmt.Errorf("port %d is already in use by %s", newFwd.LocalPort, existing.String()) 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 // Add the new forward
targetNamespace.Forwards = append(targetNamespace.Forwards, newFwd) 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() 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)) return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs))
} }
+235
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -662,3 +663,237 @@ func TestMutator_Concurrent(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, cfg) 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)
}
}
+184
View File
@@ -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
}
+306
View File
@@ -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)
}
+167 -17
View File
@@ -1,8 +1,10 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"regexp" "regexp"
"sort"
"strings" "strings"
"time" "time"
) )
@@ -63,6 +65,20 @@ func NewValidator() *Validator {
return &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. // ValidateConfig validates the entire configuration and returns all errors found.
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError { func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
return v.ValidateConfigWithOptions(cfg, false) 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. // 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. // 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 { 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 var errs []ValidationError
allowEmpty := opts.AllowEmpty
if cfg == nil { if cfg == nil {
return []ValidationError{{ return []ValidationError{{
@@ -85,6 +107,7 @@ func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []Va
if allowEmpty && cfg.IsEmpty() { if allowEmpty && cfg.IsEmpty() {
// Still validate health check and reliability if present (they don't require forwards) // Still validate health check and reliability if present (they don't require forwards)
errs = append(errs, v.validateSpecDurations(cfg)...) errs = append(errs, v.validateSpecDurations(cfg)...)
errs = append(errs, v.validateTUI(cfg)...)
return errs return errs
} }
@@ -101,16 +124,19 @@ func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []Va
} }
// Check for duplicate local ports // Check for duplicate local ports
errs = append(errs, v.validateDuplicatePorts(cfg)...) errs = append(errs, v.validateDuplicatePorts(cfg, opts.ActiveContexts)...)
// Validate mDNS configuration // Validate mDNS configuration
if cfg.IsMDNSEnabled() { if cfg.IsMDNSEnabled() {
errs = append(errs, v.validateMDNS(cfg)...) errs = append(errs, v.validateMDNS(cfg, opts.ActiveContexts)...)
} }
// Validate duration fields in specs // Validate duration fields in specs
errs = append(errs, v.validateSpecDurations(cfg)...) errs = append(errs, v.validateSpecDurations(cfg)...)
// Validate TUI table column configuration
errs = append(errs, v.validateTUI(cfg)...)
return errs return errs
} }
@@ -302,28 +328,58 @@ func (v *Validator) validateResource(fwd *Forward) []ValidationError {
return errs return errs
} }
// validateDuplicatePorts checks for duplicate local ports across all forwards. // conflictScope returns the key that groups forwards which can run at the same
func (v *Validator) validateDuplicatePorts(cfg *Config) []ValidationError { // 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 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 _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces { for _, ns := range ctx.Namespaces {
for _, fwd := range ns.Forwards { 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 // Find duplicates. Sort the keys so the reported errors are deterministic.
for port, forwards := range portMap { 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 { if len(forwards) > 1 {
errs = append(errs, ValidationError{ errs = append(errs, ValidationError{
Field: "localPort", 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{ Context: map[string]string{
"port": fmt.Sprintf("%d", port), "port": fmt.Sprintf("%d", key.port),
"forwards": strings.Join(forwards, ", "), "forwards": strings.Join(forwards, ", "),
}, },
}) })
@@ -462,10 +518,14 @@ func FormatValidationErrors(errs []ValidationError) string {
// validateMDNS validates mDNS configuration when enabled. // validateMDNS validates mDNS configuration when enabled.
// It checks that aliases used for mDNS hostnames are valid and unique. // It checks that aliases used for mDNS hostnames are valid and unique.
// This includes both explicit aliases and auto-generated ones from resource names. // 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 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 _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces { 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) // Check for duplicate aliases among forwards that can run together (would
for alias, forwards := range aliasMap { // 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 { if len(forwards) > 1 {
errs = append(errs, ValidationError{ errs = append(errs, ValidationError{
Field: "alias", 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{ Context: map[string]string{
"alias": alias, "alias": key.alias,
"forwards": strings.Join(forwards, ", "), "forwards": strings.Join(forwards, ", "),
}, },
}) })
@@ -507,6 +581,74 @@ func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
return errs 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. // isValidHostname checks if a string is a valid RFC 1123 hostname.
// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens, // Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
// and be 1-63 characters long. // and be 1-63 characters long.
@@ -625,6 +767,14 @@ func validateDNS1123Label(name, field, entityType string) *ValidationError {
return nil 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). // validateDNS1123Subdomain validates that a name is a valid DNS subdomain name (RFC 1123).
// Used for resource names which can contain dots. // Used for resource names which can contain dots.
func validateDNS1123Subdomain(name, field, entityType string) *ValidationError { func validateDNS1123Subdomain(name, field, entityType string) *ValidationError {
+308 -1
View File
@@ -526,7 +526,7 @@ func TestValidator_CheckDuplicatePorts(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
errs := validator.validateDuplicatePorts(tt.config) errs := validator.validateDuplicatePorts(tt.config, nil)
if tt.expectErrors { if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors") 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
View File
@@ -16,17 +16,30 @@ type ReloadCallback func(*Config) error
// Watcher watches a configuration file for changes and triggers hot-reload. // Watcher watches a configuration file for changes and triggers hot-reload.
type Watcher struct { type Watcher struct {
callback ReloadCallback callback ReloadCallback
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
done chan struct{} done chan struct{}
configPath string configPath string
wg sync.WaitGroup activeContexts []string
stopOnce sync.Once wg sync.WaitGroup
verbose bool 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. // 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() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create file watcher: %w", err) 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 nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
} }
return &Watcher{ w := &Watcher{
configPath: absPath, configPath: absPath,
callback: callback, callback: callback,
watcher: watcher, watcher: watcher,
done: make(chan struct{}), done: make(chan struct{}),
verbose: verbose, verbose: verbose,
}, nil }
for _, opt := range opts {
opt(w)
}
return w, nil
} }
// Start begins watching the configuration file for changes. // 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. // handleReload loads and validates the new configuration, then calls the callback.
func (w *Watcher) handleReload() { func (w *Watcher) handleReload() {
// Load new configuration // Load, validate and apply the --context selection in one step, so the
newCfg, err := LoadConfig(w.configPath) // selection cannot drift from the other reload paths.
newCfg, err := LoadForRuntime(w.configPath, w.activeContexts)
if err != nil { if err != nil {
logger.Error("Failed to load configuration during hot-reload", map[string]interface{}{ logger.Error("Failed to load configuration during hot-reload", map[string]interface{}{
"config_path": w.configPath, "config_path": w.configPath,
@@ -132,17 +150,6 @@ func (w *Watcher) handleReload() {
return 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 // Call reload callback
if err := w.callback(newCfg); err != nil { if err := w.callback(newCfg); err != nil {
logger.Error("Failed to apply new configuration", map[string]interface{}{ logger.Error("Failed to apply new configuration", map[string]interface{}{
+121
View File
@@ -504,3 +504,124 @@ func TestWatcher_StopWithoutStart(t *testing.T) {
t.Fatal("Stop without start timed out") 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")
}
+6 -4
View File
@@ -91,13 +91,15 @@ func (p *Proxy) Start() error {
p.listener = ln p.listener = ln
// Create reverse proxy // Create reverse proxy
director := func(req *http.Request) { rewrite := func(pr *httputil.ProxyRequest) {
req.URL.Scheme = "http" pr.Out.URL.Scheme = "http"
req.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort) 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{ proxy := &httputil.ReverseProxy{
Director: director, Rewrite: rewrite,
Transport: &loggingTransport{ Transport: &loggingTransport{
proxy: p, proxy: p,
transport: http.DefaultTransport, transport: http.DefaultTransport,
+32
View File
@@ -5,7 +5,10 @@ import (
"encoding/json" "encoding/json"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"net/url"
"os" "os"
"strconv"
"testing" "testing"
"github.com/lukaszraczylo/kportal/internal/config" "github.com/lukaszraczylo/kportal/internal/config"
@@ -421,3 +424,32 @@ func TestProxy_LogError(t *testing.T) {
assert.Equal(t, "/test", entry.Path) assert.Equal(t, "/test", entry.Path)
assert.Contains(t, entry.Error, "assert.AnError") 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"))
}
+73 -41
View File
@@ -69,32 +69,35 @@ type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry))
// BubbleTeaUI is a bubbletea-based terminal UI // BubbleTeaUI is a bubbletea-based terminal UI
type BubbleTeaUI struct { type BubbleTeaUI struct {
discovery *k8s.Discovery discovery *k8s.Discovery
program *tea.Program program *tea.Program
forwards map[string]*ForwardStatus forwards map[string]*ForwardStatus
benchmarkState *BenchmarkState benchmarkState *BenchmarkState
httpLogSubscriber HTTPLogSubscriber httpLogSubscriber HTTPLogSubscriber
disabledMap map[string]bool disabledMap map[string]bool
toggleCallback func(id string, enable bool) toggleCallback func(id string, enable bool)
httpLogCleanup func() httpLogCleanup func()
httpLogState *HTTPLogState httpLogState *HTTPLogState
errors map[string]string errors map[string]string
mutator *config.Mutator mutator *config.Mutator
removeWizard *RemoveWizardState removeWizard *RemoveWizardState
addWizard *AddWizardState addWizard *AddWizardState
updateVersion string updateVersion string
updateURL string updateURL string
configPath string configPath string
deleteConfirmID string deleteConfirmID string
deleteConfirmAlias string deleteConfirmContext string
version string deleteConfirmAlias string
forwardOrder []string version string
viewMode ViewMode forwardOrder []string
deleteConfirmCursor int columns []ResolvedColumn
selectedIndex int activeContexts []string
mu sync.RWMutex viewMode ViewMode
deleteConfirming bool deleteConfirmCursor int
updateAvailable bool selectedIndex int
mu sync.RWMutex
deleteConfirming bool
updateAvailable bool
} }
// bubbletea model // bubbletea model
@@ -115,11 +118,20 @@ func NewBubbleTeaUI(toggleCallback func(id string, enable bool), version string)
version: version, version: version,
errors: make(map[string]string), errors: make(map[string]string),
viewMode: ViewModeMain, viewMode: ViewModeMain,
columns: ResolveColumns(nil),
} }
return ui 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 // SetWizardDependencies sets the dependencies needed for the add/remove wizards
func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *config.Mutator, configPath string) { func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *config.Mutator, configPath string) {
ui.mu.Lock() ui.mu.Lock()
@@ -130,6 +142,16 @@ func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *
ui.configPath = configPath 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 // SetHTTPLogSubscriber sets the function to subscribe to HTTP logs
func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) { func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) {
ui.mu.Lock() ui.mu.Lock()
@@ -545,10 +567,15 @@ func (m model) renderForwardsTable(colors mainViewColors) string {
// Build table rows // Build table rows
rows := m.buildTableRows() 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) // Create table with styling (no borders for cleaner look)
t := table.New(). t := table.New().
Border(lipgloss.HiddenBorder()). Border(lipgloss.HiddenBorder()).
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS"). Headers(headers...).
Rows(rows...). Rows(rows...).
StyleFunc(m.createTableStyleFunc(colors)) StyleFunc(m.createTableStyleFunc(colors))
@@ -570,21 +597,23 @@ func (m model) buildTableRows() [][]string {
statusIcon, statusText := m.getStatusIconAndText(id, fwd) statusIcon, statusText := m.getStatusIconAndText(id, fwd)
localPortText := fmt.Sprintf("%d", fwd.LocalPort) row := make([]string, len(m.ui.columns))
if fwd.Status == "Active" && !m.ui.isForwardDisabled(id) { for i, col := range m.ui.columns {
localPortText = hyperlink(fmt.Sprintf("http://127.0.0.1:%d", fwd.LocalPort), fmt.Sprintf("%d→", fwd.LocalPort)) 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{ rows = append(rows, row)
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,
})
} }
return rows return rows
@@ -613,6 +642,8 @@ func (m model) getStatusIconAndText(id string, fwd *ForwardStatus) (icon, text s
// createTableStyleFunc creates the style function for the forwards table // createTableStyleFunc creates the style function for the forwards table
func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) lipgloss.Style { 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 { return func(row, col int) lipgloss.Style {
// Header row // Header row
if row == table.HeaderRow { if row == table.HeaderRow {
@@ -643,7 +674,7 @@ func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) li
} }
// Status column gets colored based on status // Status column gets colored based on status
if col == ColumnStatus && ok { if col == statusCol && ok {
switch fwd.Status { switch fwd.Status {
case "Active": case "Active":
return baseStyle.Foreground(colors.active) return baseStyle.Foreground(colors.active)
@@ -881,6 +912,7 @@ func (ui *BubbleTeaUI) moveSelection(delta int) {
func (ui *BubbleTeaUI) resetDeleteConfirmation() { func (ui *BubbleTeaUI) resetDeleteConfirmation() {
ui.deleteConfirming = false ui.deleteConfirming = false
ui.deleteConfirmID = "" ui.deleteConfirmID = ""
ui.deleteConfirmContext = ""
ui.deleteConfirmAlias = "" ui.deleteConfirmAlias = ""
ui.deleteConfirmCursor = 0 ui.deleteConfirmCursor = 0
} }
+160
View File
@@ -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
}
+107
View File
@@ -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)
}
+77 -4
View File
@@ -162,7 +162,7 @@ func TestCheckPortCmd_PortAvailability(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Test checking a random high port that should be available // Test checking a random high port that should be available
cmd := checkPortCmd(59999, configPath, "") cmd := checkPortCmd(59999, configPath, "", "test-ctx", nil)
msg := cmd() msg := cmd()
portMsg, ok := msg.(PortCheckedMsg) portMsg, ok := msg.(PortCheckedMsg)
@@ -192,7 +192,7 @@ func TestCheckPortCmd_ConfigConflict(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Test checking port that's already in config // Test checking port that's already in config
cmd := checkPortCmd(8080, configPath, "") cmd := checkPortCmd(8080, configPath, "", "test-ctx", nil)
msg := cmd() msg := cmd()
portMsg, ok := msg.(PortCheckedMsg) 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>". // The forward's ID format is "<context>/<namespace>/<resource>:<port>".
excludeID := "test-ctx/default/pod/my-app:8080" excludeID := "test-ctx/default/pod/my-app:8080"
cmd := checkPortCmd(8080, configPath, excludeID) cmd := checkPortCmd(8080, configPath, excludeID, "test-ctx", nil)
msg := cmd() msg := cmd()
portMsg, ok := msg.(PortCheckedMsg) portMsg, ok := msg.(PortCheckedMsg)
@@ -241,7 +241,7 @@ func TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort(t *testing.T) {
// TestCheckPortCmd_InvalidConfig tests behavior with invalid config file // TestCheckPortCmd_InvalidConfig tests behavior with invalid config file
func TestCheckPortCmd_InvalidConfig(t *testing.T) { func TestCheckPortCmd_InvalidConfig(t *testing.T) {
// Use a non-existent config path // 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() msg := cmd()
portMsg, ok := msg.(PortCheckedMsg) portMsg, ok := msg.(PortCheckedMsg)
@@ -420,3 +420,76 @@ func TestHTTPLogSubscriberType(t *testing.T) {
cleanup() cleanup()
assert.Equal(t, 1, mock.CleanupCalls) 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")
}
})
}
}
+6
View File
@@ -36,6 +36,12 @@ func (f *fakeMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.F
return nil return nil
} }
func (f *fakeMutator) RemoveForwardByID(id string) error { 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 { func (f *fakeMutator) UpdateForward(oldID, newCtx, newNS string, newFwd config.Forward) error {
return nil return nil
} }
+2
View File
@@ -24,7 +24,9 @@ type MutatorInterface interface {
AddForward(contextName, namespaceName string, fwd config.Forward) error AddForward(contextName, namespaceName string, fwd config.Forward) error
RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error
RemoveForwardByID(id string) error RemoveForwardByID(id string) error
RemoveForwardByIDInContext(contextName, id string) error
UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) 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 // Compile-time checks to ensure real types implement interfaces
+18
View File
@@ -105,6 +105,8 @@ type MockMutator struct {
LastOldID string LastOldID string
LastNamespaceName string LastNamespaceName string
LastRemovedID string LastRemovedID string
LastRemovedContext string
LastOldContext string
Forwards []struct { Forwards []struct {
Context string Context string
Namespace string Namespace string
@@ -157,6 +159,22 @@ func (m *MockMutator) RemoveForwardByID(id string) error {
return m.RemoveForwardByIDErr 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 { func (m *MockMutator) UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
+83 -31
View File
@@ -3,6 +3,8 @@ package ui
import ( import (
"fmt" "fmt"
"os" "os"
"regexp"
"slices"
"strings" "strings"
"sync" "sync"
"unicode/utf8" "unicode/utf8"
@@ -26,18 +28,29 @@ type ForwardStatus struct {
// TableUI manages the terminal table display // TableUI manages the terminal table display
type TableUI struct { type TableUI struct {
forwards map[string]*ForwardStatus forwards map[string]*ForwardStatus
columns []ResolvedColumn
mu sync.RWMutex mu sync.RWMutex
verbose bool verbose bool
} }
// NewTableUI creates a new table UI manager // NewTableUI creates a new table UI manager. cfg may be nil, in which case
func NewTableUI(verbose bool) *TableUI { // the built-in default column set and order is used.
func NewTableUI(verbose bool, cfg *config.Config) *TableUI {
return &TableUI{ return &TableUI{
forwards: make(map[string]*ForwardStatus), forwards: make(map[string]*ForwardStatus),
verbose: verbose, 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 // AddForward registers a new forward for display
func (t *TableUI) AddForward(id string, fwd *config.Forward) { func (t *TableUI) AddForward(id string, fwd *config.Forward) {
t.mu.Lock() t.mu.Lock()
@@ -94,12 +107,11 @@ func (t *TableUI) Render() {
// Print header // Print header
fmt.Println("kportal - Port Forwarding Status") fmt.Println("kportal - Port Forwarding Status")
fmt.Println(strings.Repeat("=", 130)) fmt.Println(plainRule("=", t.columns))
// Table header // Table header
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n", fmt.Println(renderPlainHeaderRow(t.columns))
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS") fmt.Println(plainRule("-", t.columns))
fmt.Println(strings.Repeat("-", 130))
// Sort forwards by local port for consistent display // Sort forwards by local port for consistent display
type sortEntry struct { type sortEntry struct {
@@ -122,28 +134,10 @@ func (t *TableUI) Render() {
// Print each forward // Print each forward
for _, entry := range entries { for _, entry := range entries {
fwd := entry.fwd fmt.Println(renderPlainDataRow(t.columns, 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(strings.Repeat("=", 130)) fmt.Println(plainRule("=", t.columns))
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards)) fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
// In verbose mode, add a newline to separate from logs // In verbose mode, add a newline to separate from logs
@@ -159,19 +153,18 @@ func (t *TableUI) RenderInitial() {
// Print header // Print header
fmt.Println("\nkportal - Port Forwarding Status") fmt.Println("\nkportal - Port Forwarding Status")
fmt.Println(strings.Repeat("=", 130)) fmt.Println(plainRule("=", t.columns))
// Table header // Table header
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n", fmt.Println(renderPlainHeaderRow(t.columns))
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS") fmt.Println(plainRule("-", t.columns))
fmt.Println(strings.Repeat("-", 130))
// Print message if no forwards yet // Print message if no forwards yet
if len(t.forwards) == 0 { if len(t.forwards) == 0 {
fmt.Println("Initializing port forwards...") fmt.Println("Initializing port forwards...")
} }
fmt.Println(strings.Repeat("=", 130)) fmt.Println(plainRule("=", t.columns))
fmt.Println() fmt.Println()
} }
@@ -189,6 +182,65 @@ func (t *TableUI) Remove(id string) {
delete(t.forwards, id) 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. // hyperlink wraps text in an OSC 8 terminal hyperlink escape sequence.
// Clicking the text opens the URL in terminals that support it (Ghostty, iTerm2, // Clicking the text opens the URL in terminals that support it (Ghostty, iTerm2,
// Windows Terminal, Kitty, WezTerm, etc.). Unsupported terminals show plain text. // Windows Terminal, Kitty, WezTerm, etc.). Unsupported terminals show plain text.
+139
View File
@@ -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])
}
+6 -6
View File
@@ -11,12 +11,12 @@ import (
// TestNewTableUI tests the constructor. // TestNewTableUI tests the constructor.
func TestNewTableUI(t *testing.T) { func TestNewTableUI(t *testing.T) {
tui := NewTableUI(false) tui := NewTableUI(false, nil)
require.NotNil(t, tui) require.NotNil(t, tui)
assert.NotNil(t, tui.forwards) assert.NotNil(t, tui.forwards)
assert.False(t, tui.verbose) assert.False(t, tui.verbose)
tuiVerbose := NewTableUI(true) tuiVerbose := NewTableUI(true, nil)
assert.True(t, tuiVerbose.verbose) assert.True(t, tuiVerbose.verbose)
} }
@@ -58,7 +58,7 @@ func TestTableUI_AddForward(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tui := NewTableUI(false) tui := NewTableUI(false, nil)
fwd := &config.Forward{ fwd := &config.Forward{
Resource: tt.resource, Resource: tt.resource,
Port: 8080, Port: 8080,
@@ -84,7 +84,7 @@ func TestTableUI_AddForward(t *testing.T) {
// TestTableUI_UpdateStatus verifies status mutation. // TestTableUI_UpdateStatus verifies status mutation.
func TestTableUI_UpdateStatus(t *testing.T) { func TestTableUI_UpdateStatus(t *testing.T) {
tui := NewTableUI(false) tui := NewTableUI(false, nil)
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080} fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
tui.AddForward("id-1", fwd) tui.AddForward("id-1", fwd)
@@ -100,7 +100,7 @@ func TestTableUI_UpdateStatus(t *testing.T) {
// TestTableUI_GetForward covers the lookup path. // TestTableUI_GetForward covers the lookup path.
func TestTableUI_GetForward(t *testing.T) { func TestTableUI_GetForward(t *testing.T) {
tui := NewTableUI(false) tui := NewTableUI(false, nil)
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080} fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
tui.AddForward("id-1", fwd) tui.AddForward("id-1", fwd)
@@ -114,7 +114,7 @@ func TestTableUI_GetForward(t *testing.T) {
// TestTableUI_Remove tests deletion. // TestTableUI_Remove tests deletion.
func TestTableUI_Remove(t *testing.T) { func TestTableUI_Remove(t *testing.T) {
tui := NewTableUI(false) tui := NewTableUI(false, nil)
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080} fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
tui.AddForward("id-1", fwd) tui.AddForward("id-1", fwd)
tui.AddForward("id-2", fwd) tui.AddForward("id-2", fwd)
+19 -9
View File
@@ -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 // 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 // in-config conflict scan. Used in edit mode so the wizard does not flag the
// forward being edited as conflicting with itself. // 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 { return func() tea.Msg {
// First check if port is already in the configuration // First check if port is already in the configuration
cfg, err := config.LoadConfig(configPath) cfg, err := config.LoadConfig(configPath)
@@ -163,6 +167,9 @@ func checkPortCmd(port int, configPath, excludeID string) tea.Cmd {
if excludeID != "" && fwd.ID() == excludeID { if excludeID != "" && fwd.ID() == excludeID {
continue continue
} }
if !config.PortsCanConflict(fwd.GetContext(), targetContext, activeContexts) {
continue
}
return PortCheckedMsg{ return PortCheckedMsg{
port: port, port: port,
available: false, 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) // 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 { return func() tea.Msg {
err := mutator.UpdateForward(oldID, contextName, namespace, fwd) err := mutator.UpdateForwardInContext(oldContextName, oldID, contextName, namespace, fwd)
return ForwardSavedMsg{ return ForwardSavedMsg{
success: err == nil, success: err == nil,
err: err, err: err,
@@ -216,15 +223,18 @@ func updateForwardCmd(mutator *config.Mutator, oldID, contextName, namespace str
// removeForwardsCmd removes selected forwards from the configuration file // removeForwardsCmd removes selected forwards from the configuration file
func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea.Cmd { func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Create a map of IDs to remove // Key on context as well as ID: Forward.ID() is only "alias:localPort"
idsToRemove := make(map[string]bool) // 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 { for _, fwd := range forwards {
idsToRemove[fwd.ID] = true idsToRemove[forwardKey{contextName: fwd.Context, id: fwd.ID}] = true
} }
// Remove forwards matching the IDs // Remove forwards matching the IDs
err := mutator.RemoveForwards(func(ctx, ns string, fwd config.Forward) bool { 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{ return ForwardsRemovedMsg{
@@ -236,9 +246,9 @@ func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea
} }
// removeForwardByIDCmd removes a single forward by its ID // 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 { return func() tea.Msg {
err := mutator.RemoveForwardByID(id) err := mutator.RemoveForwardByIDInContext(contextName, id)
return ForwardsRemovedMsg{ return ForwardsRemovedMsg{
success: err == nil, success: err == nil,
count: 1, count: 1,
+50 -8
View File
@@ -14,6 +14,9 @@ import (
"github.com/lukaszraczylo/kportal/internal/k8s" "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 // isFilterableStep returns true if the step supports search/filter
func isFilterableStep(step AddWizardStep) bool { func isFilterableStep(step AddWizardStep) bool {
switch step { 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 // Mark as edit mode and store original ID
m.ui.addWizard.isEditing = true m.ui.addWizard.isEditing = true
m.ui.addWizard.originalID = selectedID m.ui.addWizard.originalID = selectedID
m.ui.addWizard.originalContext = selectedForward.Context
// Start at the remote port step (skip context/namespace/resource selection) // Start at the remote port step (skip context/namespace/resource selection)
m.ui.addWizard.step = StepEnterRemotePort m.ui.addWizard.step = StepEnterRemotePort
@@ -184,6 +188,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Show confirmation dialog // Show confirmation dialog
m.ui.deleteConfirming = true m.ui.deleteConfirming = true
m.ui.deleteConfirmID = selectedID m.ui.deleteConfirmID = selectedID
m.ui.deleteConfirmContext = selectedForward.Context
m.ui.deleteConfirmAlias = selectedForward.Alias m.ui.deleteConfirmAlias = selectedForward.Alias
m.ui.deleteConfirmCursor = 1 // Default to "No" for safety 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') // Confirm deletion (either Enter on Yes or pressing 'y')
if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" { if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" {
id := m.ui.deleteConfirmID id := m.ui.deleteConfirmID
contextName := m.ui.deleteConfirmContext
m.ui.resetDeleteConfirmation() m.ui.resetDeleteConfirmation()
m.ui.mu.Unlock() m.ui.mu.Unlock()
return m, removeForwardByIDCmd(m.ui.mutator, id) return m, removeForwardByIDCmd(m.ui.mutator, contextName, id)
} }
// Enter on No = cancel // Enter on No = cancel
m.ui.resetDeleteConfirmation() m.ui.resetDeleteConfirmation()
@@ -339,7 +345,12 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
switch msg.String() { key := msg.String()
if (key == "j" || key == "k") && wizard.isTypingText() {
key = keyTypedText
}
switch key {
case "ctrl+c": case "ctrl+c":
// Hard cancel // Hard cancel
m.ui.viewMode = ViewModeMain 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 // Reset input mode based on the step we're going back to
switch wizard.step { switch wizard.step {
case StepSelectContext, StepSelectNamespace, StepSelectResourceType: case StepSelectContext, StepSelectResourceType:
wizard.inputMode = InputModeList wizard.inputMode = InputModeList
case StepSelectNamespace:
if wizard.namespaceManual {
wizard.inputMode = InputModeText
} else {
wizard.inputMode = InputModeList
}
case StepEnterResource: case StepEnterResource:
if wizard.selectedResourceType == ResourceTypeService { if wizard.selectedResourceType == ResourceTypeService {
wizard.inputMode = InputModeList wizard.inputMode = InputModeList
@@ -526,10 +543,27 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
wizard.cursor = 0 wizard.cursor = 0
wizard.clearSearchFilter() wizard.clearSearchFilter()
wizard.loading = true wizard.loading = true
wizard.namespaceManual = false
wizard.namespaceListErr = nil
wizard.inputMode = InputModeList
return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext) return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext)
} }
case StepSelectNamespace: 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() filteredNamespaces := wizard.getFilteredNamespaces()
if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) { if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) {
wizard.selectedNamespace = filteredNamespaces[wizard.cursor] wizard.selectedNamespace = filteredNamespaces[wizard.cursor]
@@ -660,7 +694,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
if wizard.isEditing { if wizard.isEditing {
excludeID = wizard.originalID 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: case StepConfirmation:
@@ -718,7 +752,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
// If editing, use atomic update operation // If editing, use atomic update operation
if wizard.isEditing { 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) 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() defer m.ui.mu.Unlock()
if m.ui.addWizard != nil { if m.ui.addWizard != nil {
m.ui.addWizard.loading = false w := m.ui.addWizard
m.ui.addWizard.error = msg.err w.loading = false
w.error = nil
if msg.err == 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 = ""
} }
} }
+1 -1
View File
@@ -1685,7 +1685,7 @@ func TestRemoveForwardByIDCmd_Success(t *testing.T) {
require.NoError(t, mutator.AddForward("ctx", "ns", fwdKeep)) require.NoError(t, mutator.AddForward("ctx", "ns", fwdKeep))
id := "ctx/ns/pod/app:18083" id := "ctx/ns/pod/app:18083"
cmd := removeForwardByIDCmd(mutator, id) cmd := removeForwardByIDCmd(mutator, "", id)
msg := cmd() msg := cmd()
removedMsg, ok := msg.(ForwardsRemovedMsg) removedMsg, ok := msg.(ForwardsRemovedMsg)
require.True(t, ok) require.True(t, ok)
+104
View File
@@ -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)
}
+10
View File
@@ -111,9 +111,11 @@ func (r ResourceType) Description() string {
// AddWizardState maintains the state for the add port forward wizard // AddWizardState maintains the state for the add port forward wizard
type AddWizardState struct { type AddWizardState struct {
error error error error
namespaceListErr error
httpLogOriginal *config.HTTPLogSpec httpLogOriginal *config.HTTPLogSpec
resourceValue string resourceValue string
originalID string originalID string
originalContext string
portCheckMsg string portCheckMsg string
alias string alias string
textInput string textInput string
@@ -137,6 +139,7 @@ type AddWizardState struct {
confirmationFocus ConfirmationFocus confirmationFocus ConfirmationFocus
portAvailable bool portAvailable bool
isEditing bool isEditing bool
namespaceManual bool
loading bool loading bool
httpLog 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 // clearTextInput clears the text input field
func (w *AddWizardState) clearTextInput() { func (w *AddWizardState) clearTextInput() {
w.textInput = "" w.textInput = ""
+30
View File
@@ -142,6 +142,10 @@ func (m model) renderSelectNamespace() string {
b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7))) b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7)))
fmt.Fprintf(&b, "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") b.WriteString("Select Namespace:\n\n")
// Show search input if there's a filter active // Show search input if there's a filter active
@@ -177,6 +181,32 @@ func (m model) renderSelectNamespace() string {
return b.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 { func (m model) renderSelectResourceType() string {
wizard := m.ui.addWizard wizard := m.ui.addWizard
var b strings.Builder var b strings.Builder