mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3471502b7a | ||
|
|
17f9248588 | ||
|
|
2751b02428 | ||
|
|
7e08fd7758 | ||
|
|
4d263319ab | ||
|
|
4748bc4be5 | ||
|
|
f3d3f259a1 |
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func TestAllFlagsPresent(t *testing.T) {
|
|||||||
"--version",
|
"--version",
|
||||||
"--update",
|
"--update",
|
||||||
"--check",
|
"--check",
|
||||||
|
"--context",
|
||||||
"--headless",
|
"--headless",
|
||||||
"--log-format",
|
"--log-format",
|
||||||
"--convert",
|
"--convert",
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeContextSelection turns raw --context flag values into a clean
|
||||||
|
// selection: comma-separated entries are split, surrounding whitespace is
|
||||||
|
// trimmed, empty segments are dropped, and duplicates are removed while
|
||||||
|
// preserving first-seen order.
|
||||||
|
//
|
||||||
|
// Duplicates matter beyond tidiness: a repeated name would select the same
|
||||||
|
// context twice, producing duplicate forward IDs and spurious port conflicts.
|
||||||
|
func NormalizeContextSelection(names []string) []string {
|
||||||
|
var (
|
||||||
|
out []string
|
||||||
|
seen = make(map[string]struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, raw := range names {
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
name := strings.TrimSpace(part)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[name]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextIsActive reports whether name is part of the active selection. An
|
||||||
|
// empty selection means every context is active, which is kportal's default.
|
||||||
|
func contextIsActive(name string, activeContexts []string) bool {
|
||||||
|
if len(activeContexts) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, active := range activeContexts {
|
||||||
|
if active == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortsCanConflict reports whether forwards living in contextA and contextB can
|
||||||
|
// ever run at the same time and therefore compete for the same local port.
|
||||||
|
//
|
||||||
|
// Two forwards collide when they share a context, or when both of their
|
||||||
|
// contexts are active simultaneously. Forwards in contexts that are never
|
||||||
|
// started together are free to reuse local ports - which is the whole point of
|
||||||
|
// selecting contexts: the same service can keep the same local port in every
|
||||||
|
// cluster.
|
||||||
|
func PortsCanConflict(contextA, contextB string, activeContexts []string) bool {
|
||||||
|
if contextA == contextB {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return contextIsActive(contextA, activeContexts) && contextIsActive(contextB, activeContexts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectContexts returns a shallow copy of c containing only the named
|
||||||
|
// contexts, keeping the order they appear in the config file so the UI's row
|
||||||
|
// order stays stable regardless of the order names were passed on the command
|
||||||
|
// line. An empty selection returns c unchanged.
|
||||||
|
//
|
||||||
|
// Unknown names are an error listing the contexts that do exist, so a typo
|
||||||
|
// surfaces immediately instead of silently forwarding nothing.
|
||||||
|
//
|
||||||
|
// The returned config shares backing arrays with c and must never be written
|
||||||
|
// back to disk - use the original config for that.
|
||||||
|
func (c *Config) SelectContexts(names []string) (*Config, error) {
|
||||||
|
selected, missing := c.selectContexts(names)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return nil, fmt.Errorf("unknown context %s in --context (available: %s)",
|
||||||
|
quoteAll(missing), availableContexts(c))
|
||||||
|
}
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectContextsLenient is SelectContexts without the error: unknown names are
|
||||||
|
// dropped and returned to the caller. Reload paths use this so that renaming a
|
||||||
|
// context in the config file does not freeze hot-reload for the rest of the
|
||||||
|
// session.
|
||||||
|
func (c *Config) selectContextsLenient(names []string) (*Config, []string) {
|
||||||
|
return c.selectContexts(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) selectContexts(names []string) (*Config, []string) {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wanted := make(map[string]bool, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
wanted[name] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate the config rather than the selection so file order is preserved.
|
||||||
|
filtered := make([]Context, 0, len(names))
|
||||||
|
for _, ctx := range c.Contexts {
|
||||||
|
if _, ok := wanted[ctx.Name]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wanted[ctx.Name] = true
|
||||||
|
filtered = append(filtered, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
for _, name := range names {
|
||||||
|
if !wanted[name] {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := *c
|
||||||
|
selected.Contexts = filtered
|
||||||
|
return &selected, missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// availableContexts renders the context names present in the config, for use
|
||||||
|
// in error messages.
|
||||||
|
func availableContexts(c *Config) string {
|
||||||
|
if len(c.Contexts) == 0 {
|
||||||
|
return "none defined in the config file"
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(c.Contexts))
|
||||||
|
for _, ctx := range c.Contexts {
|
||||||
|
names = append(names, ctx.Name)
|
||||||
|
}
|
||||||
|
return quoteAll(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteAll(names []string) string {
|
||||||
|
quoted := make([]string, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
quoted = append(quoted, fmt.Sprintf("%q", name))
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadForRuntime loads, validates and filters the configuration for the
|
||||||
|
// forward manager. Validation runs against the *whole* file - so a mistake in a
|
||||||
|
// context you are not currently forwarding is still reported - while the
|
||||||
|
// uniqueness checks that only matter for running forwards (local ports, mDNS
|
||||||
|
// hostnames) are scoped to activeContexts.
|
||||||
|
//
|
||||||
|
// Every path that re-reads the config at runtime goes through here, so the
|
||||||
|
// selection cannot be forgotten in one of them.
|
||||||
|
func LoadForRuntime(path string, activeContexts []string) (*Config, error) {
|
||||||
|
cfg, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
validator := NewValidator()
|
||||||
|
if errs := validator.ValidateConfigWithOpts(cfg, ValidateOptions{
|
||||||
|
ActiveContexts: activeContexts,
|
||||||
|
}); len(errs) > 0 {
|
||||||
|
return nil, fmt.Errorf("configuration is invalid:\n%s", FormatValidationErrors(errs))
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, missing := cfg.selectContextsLenient(activeContexts)
|
||||||
|
// Keep the running config when every selected context vanished: an empty
|
||||||
|
// result would stop all forwards after a single rename in the file.
|
||||||
|
if len(selected.Contexts) == 0 && len(missing) > 0 {
|
||||||
|
return nil, fmt.Errorf("none of the selected contexts %s exist in the config file (available: %s)",
|
||||||
|
quoteAll(missing), availableContexts(cfg))
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
logger.Info("Ignoring selected contexts that are no longer in the config file", map[string]interface{}{
|
||||||
|
"contexts": strings.Join(missing, ", "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeContextSelection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "nil input", input: nil, want: nil},
|
||||||
|
{name: "single name", input: []string{"team-a"}, want: []string{"team-a"}},
|
||||||
|
{name: "repeated flag", input: []string{"team-a", "team-b"}, want: []string{"team-a", "team-b"}},
|
||||||
|
{name: "comma separated", input: []string{"team-a,team-b"}, want: []string{"team-a", "team-b"}},
|
||||||
|
{name: "repeated and comma mixed", input: []string{"team-a,team-b", "team-c"}, want: []string{"team-a", "team-b", "team-c"}},
|
||||||
|
{name: "whitespace trimmed", input: []string{" team-a , team-b "}, want: []string{"team-a", "team-b"}},
|
||||||
|
{name: "empty segments dropped", input: []string{"team-a,,team-b"}, want: []string{"team-a", "team-b"}},
|
||||||
|
{name: "duplicates removed", input: []string{"team-a,team-a", "team-a"}, want: []string{"team-a"}},
|
||||||
|
{name: "only separators", input: []string{",,"}, want: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, NormalizeContextSelection(tt.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortsCanConflict(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contextA string
|
||||||
|
contextB string
|
||||||
|
active []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "same context always conflicts",
|
||||||
|
contextA: "team-a", contextB: "team-a", active: []string{"team-b"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no selection means everything runs together",
|
||||||
|
contextA: "team-a", contextB: "team-b", active: nil,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both selected conflict",
|
||||||
|
contextA: "team-a", contextB: "team-b", active: []string{"team-a", "team-b"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only one selected does not conflict",
|
||||||
|
contextA: "team-a", contextB: "team-b", active: []string{"team-a"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "neither selected does not conflict",
|
||||||
|
contextA: "team-a", contextB: "team-b", active: []string{"team-c"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, PortsCanConflict(tt.contextA, tt.contextB, tt.active))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// threeContextConfig builds a config with three contexts, each holding one
|
||||||
|
// forward on the same local port - the layout this feature exists to enable.
|
||||||
|
func threeContextConfig(t *testing.T) *Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := ParseConfig([]byte(`
|
||||||
|
mdns:
|
||||||
|
enabled: false
|
||||||
|
healthCheck:
|
||||||
|
interval: "5s"
|
||||||
|
contexts:
|
||||||
|
- name: team-a
|
||||||
|
namespaces:
|
||||||
|
- name: rate-service
|
||||||
|
forwards:
|
||||||
|
- resource: pod/rpc-server
|
||||||
|
protocol: tcp
|
||||||
|
port: 50051
|
||||||
|
localPort: 3004
|
||||||
|
alias: rate-service-a
|
||||||
|
- name: team-b
|
||||||
|
namespaces:
|
||||||
|
- name: rate-service
|
||||||
|
forwards:
|
||||||
|
- resource: pod/rpc-server
|
||||||
|
protocol: tcp
|
||||||
|
port: 50051
|
||||||
|
localPort: 3004
|
||||||
|
alias: rate-service-b
|
||||||
|
- name: team-c
|
||||||
|
namespaces:
|
||||||
|
- name: rate-service
|
||||||
|
forwards:
|
||||||
|
- resource: pod/rpc-server
|
||||||
|
protocol: tcp
|
||||||
|
port: 50051
|
||||||
|
localPort: 3004
|
||||||
|
alias: rate-service-c
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextNames(cfg *Config) []string {
|
||||||
|
names := make([]string, 0, len(cfg.Contexts))
|
||||||
|
for _, ctx := range cfg.Contexts {
|
||||||
|
names = append(names, ctx.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_SelectContexts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wantErr string
|
||||||
|
selection []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "empty selection keeps everything", selection: nil, want: []string{"team-a", "team-b", "team-c"}},
|
||||||
|
{name: "single context", selection: []string{"team-b"}, want: []string{"team-b"}},
|
||||||
|
{name: "multiple contexts", selection: []string{"team-a", "team-c"}, want: []string{"team-a", "team-c"}},
|
||||||
|
{
|
||||||
|
name: "config order wins over flag order",
|
||||||
|
selection: []string{"team-c", "team-a"},
|
||||||
|
want: []string{"team-a", "team-c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown context errors and lists what exists",
|
||||||
|
selection: []string{"team-z"},
|
||||||
|
wantErr: `unknown context "team-z" in --context (available: "team-a", "team-b", "team-c")`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one unknown among known still errors",
|
||||||
|
selection: []string{"team-a", "team-z"},
|
||||||
|
wantErr: `unknown context "team-z"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := threeContextConfig(t)
|
||||||
|
|
||||||
|
got, err := cfg.SelectContexts(tt.selection)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, contextNames(got))
|
||||||
|
// The original config must not be modified.
|
||||||
|
assert.Equal(t, []string{"team-a", "team-b", "team-c"}, contextNames(cfg))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_SelectContexts_PreservesTopLevelSettings(t *testing.T) {
|
||||||
|
cfg := threeContextConfig(t)
|
||||||
|
|
||||||
|
selected, err := cfg.SelectContexts([]string{"team-a"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotNil(t, selected.HealthCheck)
|
||||||
|
assert.Equal(t, "5s", selected.HealthCheck.Interval)
|
||||||
|
assert.Equal(t, cfg.MDNS, selected.MDNS)
|
||||||
|
assert.Len(t, selected.GetAllForwards(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_SelectContexts_EmptyConfig(t *testing.T) {
|
||||||
|
cfg := &Config{}
|
||||||
|
|
||||||
|
// An empty selection is a no-op even with nothing to select.
|
||||||
|
same, err := cfg.SelectContexts(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Same(t, cfg, same)
|
||||||
|
|
||||||
|
_, err = cfg.SelectContexts([]string{"team-a"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "none defined in the config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_SelectContexts_ContextWithNoForwards(t *testing.T) {
|
||||||
|
cfg := &Config{Contexts: []Context{{Name: "empty"}}}
|
||||||
|
|
||||||
|
selected, err := cfg.SelectContexts([]string{"empty"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"empty"}, contextNames(selected))
|
||||||
|
assert.Empty(t, selected.GetAllForwards())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedPortConfig is the reporter's layout from issue #80: the same service on
|
||||||
|
// the same local port in every cluster.
|
||||||
|
const sharedPortConfig = `contexts:
|
||||||
|
- name: team-a
|
||||||
|
namespaces:
|
||||||
|
- name: rate-service
|
||||||
|
forwards:
|
||||||
|
- resource: pod/rpc-server
|
||||||
|
protocol: tcp
|
||||||
|
port: 50051
|
||||||
|
localPort: 3004
|
||||||
|
- name: team-b
|
||||||
|
namespaces:
|
||||||
|
- name: rate-service
|
||||||
|
forwards:
|
||||||
|
- resource: pod/rpc-server
|
||||||
|
protocol: tcp
|
||||||
|
port: 50051
|
||||||
|
localPort: 3004
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestLoadForRuntime_SharedPortsAcrossNonSelectedContexts(t *testing.T) {
|
||||||
|
path := writeConfig(t, sharedPortConfig)
|
||||||
|
|
||||||
|
// Without a selection both contexts run together, so the shared port is a
|
||||||
|
// genuine conflict.
|
||||||
|
_, err := LoadForRuntime(path, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "Duplicate local port 3004")
|
||||||
|
|
||||||
|
// Selecting one context makes the layout legal and narrows what runs.
|
||||||
|
cfg, err := LoadForRuntime(path, []string{"team-a"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"team-a"}, contextNames(cfg))
|
||||||
|
require.Len(t, cfg.GetAllForwards(), 1)
|
||||||
|
|
||||||
|
// Selecting both brings the conflict back - they really would collide.
|
||||||
|
_, err = LoadForRuntime(path, []string{"team-a", "team-b"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "Duplicate local port 3004")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadForRuntime_UnknownSelectedContextIsDropped(t *testing.T) {
|
||||||
|
path := writeConfig(t, sharedPortConfig)
|
||||||
|
|
||||||
|
// A context renamed in the file must not freeze hot-reload for the session.
|
||||||
|
cfg, err := LoadForRuntime(path, []string{"team-a", "renamed-away"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"team-a"}, contextNames(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadForRuntime_SelectionReducingToNothingIsAnError(t *testing.T) {
|
||||||
|
path := writeConfig(t, sharedPortConfig)
|
||||||
|
|
||||||
|
cfg, err := LoadForRuntime(path, []string{"gone"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, cfg)
|
||||||
|
assert.Contains(t, err.Error(), `"gone"`)
|
||||||
|
assert.Contains(t, err.Error(), `"team-a"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadForRuntime_ReportsErrorsInNonSelectedContexts(t *testing.T) {
|
||||||
|
// A broken forward in a context we are not forwarding is still a broken
|
||||||
|
// config file, and must be reported.
|
||||||
|
path := writeConfig(t, `contexts:
|
||||||
|
- name: team-a
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/ok
|
||||||
|
protocol: tcp
|
||||||
|
port: 80
|
||||||
|
localPort: 8080
|
||||||
|
- name: team-b
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/bad
|
||||||
|
protocol: tcp
|
||||||
|
port: 80
|
||||||
|
localPort: 999999
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := LoadForRuntime(path, []string{"team-a"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "localPort")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadForRuntime_MissingFile(t *testing.T) {
|
||||||
|
_, err := LoadForRuntime(filepath.Join(t.TempDir(), "absent.yaml"), nil)
|
||||||
|
assert.ErrorIs(t, err, ErrConfigNotFound)
|
||||||
|
}
|
||||||
+167
-17
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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{}{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/kportal/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColumnKey identifies a forwards-table column.
|
||||||
|
type ColumnKey string
|
||||||
|
|
||||||
|
// Recognized column keys, matching the names accepted in tui.columns.
|
||||||
|
const (
|
||||||
|
ColKeyContext ColumnKey = "context"
|
||||||
|
ColKeyNamespace ColumnKey = "namespace"
|
||||||
|
ColKeyAlias ColumnKey = "alias"
|
||||||
|
ColKeyType ColumnKey = "type"
|
||||||
|
ColKeyResource ColumnKey = "resource"
|
||||||
|
ColKeyRemote ColumnKey = "remote"
|
||||||
|
ColKeyLocal ColumnKey = "local"
|
||||||
|
ColKeyStatus ColumnKey = "status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolvedColumn describes a single column ready for rendering: its header
|
||||||
|
// text, pad width, truncation limit, and identity.
|
||||||
|
type ResolvedColumn struct {
|
||||||
|
Key ColumnKey
|
||||||
|
Header string
|
||||||
|
// Width is the padded width, used by the plain table only.
|
||||||
|
Width int
|
||||||
|
// Max is the truncation limit; zero leaves the value untruncated.
|
||||||
|
Max int
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuiDefaultColumns is the Bubble Tea column set and order, used when the
|
||||||
|
// config does not specify tui.columns.
|
||||||
|
var tuiDefaultColumns = []ResolvedColumn{
|
||||||
|
{Key: ColKeyContext, Header: "CONTEXT", Width: ColumnWidthContext, Max: ColumnWidthContext},
|
||||||
|
{Key: ColKeyNamespace, Header: "NAMESPACE", Width: ColumnWidthNamespace, Max: ColumnWidthNamespace},
|
||||||
|
{Key: ColKeyAlias, Header: "ALIAS", Width: ColumnWidthAlias, Max: ColumnWidthAlias},
|
||||||
|
{Key: ColKeyType, Header: "TYPE", Width: ColumnWidthType, Max: ColumnWidthType},
|
||||||
|
{Key: ColKeyResource, Header: "RESOURCE", Width: ColumnWidthResource, Max: ColumnWidthResource},
|
||||||
|
{Key: ColKeyRemote, Header: "REMOTE"},
|
||||||
|
{Key: ColKeyLocal, Header: "LOCAL"},
|
||||||
|
{Key: ColKeyStatus, Header: "STATUS"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainDefaultColumns is the -verbose table column set, widths and headers as
|
||||||
|
// rendered before columns became configurable.
|
||||||
|
var plainDefaultColumns = []ResolvedColumn{
|
||||||
|
{Key: ColKeyContext, Header: "CONTEXT", Width: 15},
|
||||||
|
{Key: ColKeyNamespace, Header: "NAMESPACE", Width: 18},
|
||||||
|
{Key: ColKeyAlias, Header: "ALIAS", Width: 25, Max: 25},
|
||||||
|
{Key: ColKeyType, Header: "TYPE", Width: 10},
|
||||||
|
{Key: ColKeyResource, Header: "RESOURCE", Width: 25, Max: 25},
|
||||||
|
{Key: ColKeyRemote, Header: "REMOTE PORT", Width: 12},
|
||||||
|
{Key: ColKeyLocal, Header: "LOCAL PORT", Width: 12},
|
||||||
|
{Key: ColKeyStatus, Header: "STATUS", Width: 12},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveColumns builds the Bubble Tea columns from the tui.columns
|
||||||
|
// configuration. A nil config, or one with no tui.columns entries, yields the
|
||||||
|
// built-in default column set and order.
|
||||||
|
func ResolveColumns(cfg *config.Config) []ResolvedColumn {
|
||||||
|
return resolveColumns(cfg, tuiDefaultColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainStatusWidth fits the longest status with indicator ("↻ Reconnecting"),
|
||||||
|
// so a status column that is not last cannot push later columns out of line.
|
||||||
|
const plainStatusWidth = 14
|
||||||
|
|
||||||
|
// ResolvePlainColumns is ResolveColumns for the -verbose table.
|
||||||
|
func ResolvePlainColumns(cfg *config.Config) []ResolvedColumn {
|
||||||
|
defaults := plainDefaultColumns
|
||||||
|
if len(cfg.GetTableColumns()) > 0 {
|
||||||
|
defaults = slices.Clone(plainDefaultColumns)
|
||||||
|
defaults[columnIndex(defaults, ColKeyStatus)].Width = plainStatusWidth
|
||||||
|
}
|
||||||
|
return resolveColumns(cfg, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveColumns(cfg *config.Config, defaults []ResolvedColumn) []ResolvedColumn {
|
||||||
|
var configured []config.TableColumn
|
||||||
|
if cfg != nil {
|
||||||
|
configured = cfg.GetTableColumns()
|
||||||
|
}
|
||||||
|
|
||||||
|
byKey := make(map[ColumnKey]ResolvedColumn, len(defaults))
|
||||||
|
for _, c := range defaults {
|
||||||
|
byKey[c.Key] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := make([]ResolvedColumn, 0, len(configured))
|
||||||
|
for _, c := range configured {
|
||||||
|
col, ok := byKey[ColumnKey(strings.ToLower(strings.TrimSpace(c.Name)))]
|
||||||
|
if !ok {
|
||||||
|
// Unknown column names are rejected by the validator before this
|
||||||
|
// point is reached; skip defensively rather than render garbage.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Width > 0 {
|
||||||
|
col.Width = c.Width
|
||||||
|
col.Max = c.Width
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved = append(resolved, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resolved) == 0 {
|
||||||
|
return slices.Clone(defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// columnValue returns the display value for the given column of a forward,
|
||||||
|
// truncated to the column's Max when set.
|
||||||
|
func columnValue(col ResolvedColumn, fwd *ForwardStatus) string {
|
||||||
|
switch col.Key {
|
||||||
|
case ColKeyContext:
|
||||||
|
return clip(fwd.Context, col.Max)
|
||||||
|
case ColKeyNamespace:
|
||||||
|
return clip(fwd.Namespace, col.Max)
|
||||||
|
case ColKeyAlias:
|
||||||
|
return clip(fwd.Alias, col.Max)
|
||||||
|
case ColKeyType:
|
||||||
|
return clip(fwd.Type, col.Max)
|
||||||
|
case ColKeyResource:
|
||||||
|
return clip(fwd.Resource, col.Max)
|
||||||
|
case ColKeyRemote:
|
||||||
|
return fmt.Sprintf("%d", fwd.RemotePort)
|
||||||
|
case ColKeyLocal:
|
||||||
|
return fmt.Sprintf("%d", fwd.LocalPort)
|
||||||
|
case ColKeyStatus:
|
||||||
|
return fwd.Status
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clip truncates s to limit runes; a non-positive limit leaves it untouched.
|
||||||
|
func clip(s string, limit int) string {
|
||||||
|
if limit <= 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return truncate(s, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// columnIndex returns the position of key within cols, or -1 if absent.
|
||||||
|
func columnIndex(cols []ResolvedColumn, key ColumnKey) int {
|
||||||
|
for i, c := range cols {
|
||||||
|
if c.Key == key {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/kportal/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveColumns_Default(t *testing.T) {
|
||||||
|
cols := ResolveColumns(nil)
|
||||||
|
assert.Equal(t, tuiDefaultColumns, cols)
|
||||||
|
|
||||||
|
cols = ResolveColumns(&config.Config{})
|
||||||
|
assert.Equal(t, tuiDefaultColumns, cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveColumns_CustomOrderAndVisibility(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
TUI: &config.TUISpec{
|
||||||
|
Columns: []config.TableColumn{
|
||||||
|
{Name: "status"},
|
||||||
|
{Name: "alias", Width: 40},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := ResolveColumns(cfg)
|
||||||
|
|
||||||
|
assert.Len(t, cols, 2)
|
||||||
|
assert.Equal(t, ColKeyStatus, cols[0].Key)
|
||||||
|
assert.Equal(t, ColKeyAlias, cols[1].Key)
|
||||||
|
assert.Equal(t, 40, cols[1].Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveColumns_UsesDefaultWidthWhenUnset(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
TUI: &config.TUISpec{
|
||||||
|
Columns: []config.TableColumn{
|
||||||
|
{Name: "namespace"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := ResolveColumns(cfg)
|
||||||
|
|
||||||
|
assert.Len(t, cols, 1)
|
||||||
|
assert.Equal(t, ColumnWidthNamespace, cols[0].Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveColumns_UnknownNamesSkipped(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
TUI: &config.TUISpec{
|
||||||
|
Columns: []config.TableColumn{
|
||||||
|
{Name: "bogus"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// All entries invalid -> fall back to defaults.
|
||||||
|
assert.Equal(t, tuiDefaultColumns, ResolveColumns(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColumnValue(t *testing.T) {
|
||||||
|
fwd := &ForwardStatus{
|
||||||
|
Context: "prod",
|
||||||
|
Namespace: "default",
|
||||||
|
Alias: "api",
|
||||||
|
Type: "service",
|
||||||
|
Resource: "api",
|
||||||
|
RemotePort: 8080,
|
||||||
|
LocalPort: 8081,
|
||||||
|
Status: "Active",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "prod", columnValue(ResolvedColumn{Key: ColKeyContext, Max: 10}, fwd))
|
||||||
|
assert.Equal(t, "8080", columnValue(ResolvedColumn{Key: ColKeyRemote}, fwd))
|
||||||
|
assert.Equal(t, "8081", columnValue(ResolvedColumn{Key: ColKeyLocal}, fwd))
|
||||||
|
assert.Equal(t, "Active", columnValue(ResolvedColumn{Key: ColKeyStatus}, fwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColumnIndex(t *testing.T) {
|
||||||
|
cols := []ResolvedColumn{{Key: ColKeyContext}, {Key: ColKeyStatus}}
|
||||||
|
assert.Equal(t, 1, columnIndex(cols, ColKeyStatus))
|
||||||
|
assert.Equal(t, -1, columnIndex(cols, ColKeyLocal))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveColumns_DefaultIsCopy(t *testing.T) {
|
||||||
|
cols := ResolveColumns(nil)
|
||||||
|
cols[0].Header = "MUTATED"
|
||||||
|
|
||||||
|
assert.Equal(t, "CONTEXT", ResolveColumns(nil)[0].Header)
|
||||||
|
assert.Equal(t, "CONTEXT", tuiDefaultColumns[0].Header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePlainColumns_LegacyDefaults(t *testing.T) {
|
||||||
|
cols := ResolvePlainColumns(nil)
|
||||||
|
|
||||||
|
assert.Equal(t, plainDefaultColumns, cols)
|
||||||
|
assert.Equal(t, "REMOTE PORT", cols[5].Header)
|
||||||
|
assert.Equal(t, 15, cols[0].Width)
|
||||||
|
|
||||||
|
custom := ResolvePlainColumns(&config.Config{TUI: &config.TUISpec{Columns: []config.TableColumn{{Name: " Status "}, {Name: "alias", Width: 7}}}})
|
||||||
|
assert.Equal(t, plainStatusWidth, custom[0].Width)
|
||||||
|
assert.Equal(t, 7, custom[1].Width)
|
||||||
|
assert.Equal(t, 7, custom[1].Max)
|
||||||
|
}
|
||||||
@@ -162,7 +162,7 @@ func TestCheckPortCmd_PortAvailability(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func typeKeys(t *testing.T, m model, text string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range text {
|
||||||
|
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacesLoaded_ListFailureSwitchesToManualEntry(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
m.ui.addWizard.loading = true
|
||||||
|
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("namespaces is forbidden")})
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
assert.True(t, w.namespaceManual)
|
||||||
|
assert.Equal(t, InputModeText, w.inputMode)
|
||||||
|
assert.NoError(t, w.error)
|
||||||
|
assert.False(t, w.loading)
|
||||||
|
assert.Contains(t, m.renderSelectNamespace(), "Enter Namespace")
|
||||||
|
assert.Contains(t, m.renderSelectNamespace(), "forbidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacesLoaded_EmptyListSwitchesToManualEntry(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{}})
|
||||||
|
|
||||||
|
assert.True(t, m.ui.addWizard.namespaceManual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacesLoaded_ListedNamespacesKeepSelectionMode(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{"default", "kube-system"}})
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
assert.False(t, w.namespaceManual)
|
||||||
|
assert.Equal(t, InputModeList, w.inputMode)
|
||||||
|
assert.Equal(t, []string{"default", "kube-system"}, w.namespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualNamespace_TypeAndConfirm(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")})
|
||||||
|
|
||||||
|
// k and j must be typed, not treated as navigation
|
||||||
|
typeKeys(t, m, "kube-jack")
|
||||||
|
assert.Equal(t, "kube-jack", m.ui.addWizard.textInput)
|
||||||
|
|
||||||
|
m.handleAddWizardEnter()
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
assert.Equal(t, "kube-jack", w.selectedNamespace)
|
||||||
|
assert.Equal(t, StepSelectResourceType, w.step)
|
||||||
|
assert.Equal(t, InputModeList, w.inputMode)
|
||||||
|
assert.Empty(t, w.textInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualNamespace_RejectsInvalidName(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")})
|
||||||
|
m.ui.addWizard.selectedNamespace = ""
|
||||||
|
|
||||||
|
for _, bad := range []string{"", "Has_Upper", "-leading", "trailing-"} {
|
||||||
|
m.ui.addWizard.textInput = bad
|
||||||
|
m.handleAddWizardEnter()
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
require.Error(t, w.error, "name %q", bad)
|
||||||
|
assert.Equal(t, StepSelectNamespace, w.step, "name %q", bad)
|
||||||
|
assert.Empty(t, w.selectedNamespace, "name %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualNamespace_BackFromLaterStepRestoresTextInput(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectResourceType)
|
||||||
|
m.ui.addWizard.namespaceManual = true
|
||||||
|
|
||||||
|
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
assert.Equal(t, StepSelectNamespace, w.step)
|
||||||
|
assert.Equal(t, InputModeText, w.inputMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeysJK_StillNavigateInListMode(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectResourceType)
|
||||||
|
m.ui.addWizard.inputMode = InputModeList
|
||||||
|
|
||||||
|
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
||||||
|
|
||||||
|
assert.Equal(t, 1, m.ui.addWizard.cursor)
|
||||||
|
}
|
||||||
@@ -111,9 +111,11 @@ func (r ResourceType) Description() string {
|
|||||||
// AddWizardState maintains the state for the add port forward wizard
|
// 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 = ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user