kube-pf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kube_pf-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ohmycoffe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
kube_pf-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: kube-pf
3
+ Version: 0.1.0
4
+ Summary: Interactive kubectl port-forward CLI
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: kubernetes,kubectl,port-forward,cli,devtools
8
+ Author: ohmycoffe
9
+ Author-email: ohmycoffe1@gmail.com
10
+ Requires-Python: >=3.11
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Build Tools
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Classifier: Topic :: Utilities
23
+ Requires-Dist: psutil (>=5.9)
24
+ Requires-Dist: pydantic (>=2.0)
25
+ Requires-Dist: questionary (>=2.0)
26
+ Requires-Dist: rich (>=13.0)
27
+ Requires-Dist: typer (>=0.9)
28
+ Project-URL: Homepage, https://github.com/ohmycoffe/kube-pf
29
+ Project-URL: Issues, https://github.com/ohmycoffe/kube-pf/issues
30
+ Project-URL: Repository, https://github.com/ohmycoffe/kube-pf
31
+ Description-Content-Type: text/markdown
32
+
33
+ # kube-pf
34
+
35
+ > Interactive `kubectl port-forward` — fuzzy-search namespaces and services,
36
+ > forward multiple ports at once, watch a live status table.
37
+
38
+ ![Python](https://img.shields.io/badge/python-3.11%2B-blue)
39
+ ![License](https://img.shields.io/badge/license-MIT-green)
40
+
41
+ ![demo](docs/assets/demo.gif)
42
+
43
+ ---
44
+
45
+ ## Features
46
+
47
+ - Fuzzy-search namespaces and services interactively
48
+ - Forward multiple services simultaneously in one command
49
+ - Live status table with real-time updates when a process dies
50
+ - Pin preferred local ports per service in a TOML config
51
+ - Fallback to a random free port if the preferred port is taken
52
+ - Inform user which services are currently forwarded and on which ports
53
+
54
+ ## Quick start
55
+
56
+ **Install with [pipx](https://pipx.pypa.io/) (recommended)**
57
+
58
+ ```bash
59
+ pipx install kube-pf
60
+ ```
61
+
62
+ Or install from source:
63
+
64
+ ```bash
65
+ pipx install git+https://github.com/ohmycoffe/kube-pf.git
66
+ ```
67
+
68
+ **Requirements:** Python 3.11+, `kubectl` installed and pointing at a cluster.
69
+
70
+ ## Usage
71
+
72
+ ```bash
73
+ kubepf # interactive: pick namespace → pick services
74
+ kubepf -n my-namespace # skip namespace prompt
75
+ kubepf -s auth-service -s cache-api # skip interactive selection, forward specific services
76
+ kubepf -n my-namespace -s auth-service # non-interactive: namespace + services fully specified
77
+ kubepf --config ~/.config/kpf/config.toml # use a config file
78
+ kubepf -v / -vv # INFO / DEBUG logging
79
+ kubepf --help # full option reference
80
+ ```
81
+
82
+ ## Configuration
83
+
84
+ Default path: `~/.config/kpf/config.toml` (or set `KPF_CONFIG`).
85
+
86
+ ```toml
87
+ default_namespace = "kube-public"
88
+
89
+ [[ports]]
90
+ name = "auth-service"
91
+ namespace = "kube-public"
92
+ remote_port = 80
93
+ local_port = 50000
94
+
95
+ [[ports]]
96
+ name = "user-service"
97
+ namespace = "kube-public"
98
+ remote_port = 8080
99
+ local_port = 50001
100
+ ```
101
+
102
+ Option precedence: CLI flag → `KPF_CONFIG` env var → config file → built-in default.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ poetry install # install deps
108
+ poetry run kubepf --help
109
+ poetry run pytest # run unit tests
110
+ ```
111
+
112
+ **Local test cluster with [kind](https://kind.sigs.k8s.io/):**
113
+
114
+ ```bash
115
+ scripts/run-local-cluster.sh # creates cluster + applies test manifests
116
+ ```
117
+
118
+ **Regenerate demo GIF** (requires [VHS](https://github.com/charmbracelet/vhs)):
119
+
120
+ ```bash
121
+ vhs docs/tapes/demo.tape # outputs to docs/assets/demo.gif
122
+ ```
123
+
124
+ ## License
125
+
126
+ MIT — see [LICENSE](LICENSE).
127
+
@@ -0,0 +1,94 @@
1
+ # kube-pf
2
+
3
+ > Interactive `kubectl port-forward` — fuzzy-search namespaces and services,
4
+ > forward multiple ports at once, watch a live status table.
5
+
6
+ ![Python](https://img.shields.io/badge/python-3.11%2B-blue)
7
+ ![License](https://img.shields.io/badge/license-MIT-green)
8
+
9
+ ![demo](docs/assets/demo.gif)
10
+
11
+ ---
12
+
13
+ ## Features
14
+
15
+ - Fuzzy-search namespaces and services interactively
16
+ - Forward multiple services simultaneously in one command
17
+ - Live status table with real-time updates when a process dies
18
+ - Pin preferred local ports per service in a TOML config
19
+ - Fallback to a random free port if the preferred port is taken
20
+ - Inform user which services are currently forwarded and on which ports
21
+
22
+ ## Quick start
23
+
24
+ **Install with [pipx](https://pipx.pypa.io/) (recommended)**
25
+
26
+ ```bash
27
+ pipx install kube-pf
28
+ ```
29
+
30
+ Or install from source:
31
+
32
+ ```bash
33
+ pipx install git+https://github.com/ohmycoffe/kube-pf.git
34
+ ```
35
+
36
+ **Requirements:** Python 3.11+, `kubectl` installed and pointing at a cluster.
37
+
38
+ ## Usage
39
+
40
+ ```bash
41
+ kubepf # interactive: pick namespace → pick services
42
+ kubepf -n my-namespace # skip namespace prompt
43
+ kubepf -s auth-service -s cache-api # skip interactive selection, forward specific services
44
+ kubepf -n my-namespace -s auth-service # non-interactive: namespace + services fully specified
45
+ kubepf --config ~/.config/kpf/config.toml # use a config file
46
+ kubepf -v / -vv # INFO / DEBUG logging
47
+ kubepf --help # full option reference
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ Default path: `~/.config/kpf/config.toml` (or set `KPF_CONFIG`).
53
+
54
+ ```toml
55
+ default_namespace = "kube-public"
56
+
57
+ [[ports]]
58
+ name = "auth-service"
59
+ namespace = "kube-public"
60
+ remote_port = 80
61
+ local_port = 50000
62
+
63
+ [[ports]]
64
+ name = "user-service"
65
+ namespace = "kube-public"
66
+ remote_port = 8080
67
+ local_port = 50001
68
+ ```
69
+
70
+ Option precedence: CLI flag → `KPF_CONFIG` env var → config file → built-in default.
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ poetry install # install deps
76
+ poetry run kubepf --help
77
+ poetry run pytest # run unit tests
78
+ ```
79
+
80
+ **Local test cluster with [kind](https://kind.sigs.k8s.io/):**
81
+
82
+ ```bash
83
+ scripts/run-local-cluster.sh # creates cluster + applies test manifests
84
+ ```
85
+
86
+ **Regenerate demo GIF** (requires [VHS](https://github.com/charmbracelet/vhs)):
87
+
88
+ ```bash
89
+ vhs docs/tapes/demo.tape # outputs to docs/assets/demo.gif
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "kube-pf"
3
+ version = "0.1.0"
4
+ description = "Interactive kubectl port-forward CLI"
5
+ readme = "README.md"
6
+ authors = [
7
+ {name = "ohmycoffe",email = "ohmycoffe1@gmail.com"}
8
+ ]
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11"
12
+ keywords = ["kubernetes", "kubectl", "port-forward", "cli", "devtools"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Build Tools",
24
+ "Topic :: System :: Systems Administration",
25
+ "Topic :: Utilities",
26
+ ]
27
+ dependencies = [
28
+ "typer>=0.9",
29
+ "questionary>=2.0",
30
+ "psutil>=5.9",
31
+ "pydantic>=2.0",
32
+ "rich>=13.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/ohmycoffe/kube-pf"
37
+ Repository = "https://github.com/ohmycoffe/kube-pf"
38
+ Issues = "https://github.com/ohmycoffe/kube-pf/issues"
39
+
40
+ [project.scripts]
41
+ kubepf = "kubepf.__main__:app"
42
+
43
+ [tool.poetry]
44
+ packages = [
45
+ { from = "src", include = "kubepf" },
46
+ ]
47
+
48
+ [build-system]
49
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
50
+ build-backend = "poetry.core.masonry.api"
51
+
52
+ [dependency-groups]
53
+ dev = [
54
+ "pytest (>=9.0.3,<10.0.0)"
55
+ ]
File without changes
@@ -0,0 +1,8 @@
1
+ import logging
2
+
3
+ from kubepf.cli.main import app
4
+
5
+ logging.basicConfig()
6
+
7
+ if __name__ == "__main__":
8
+ app()
File without changes
@@ -0,0 +1 @@
1
+ from kubepf.cli.port_forward import app
@@ -0,0 +1,370 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import signal
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Annotated, Callable
9
+
10
+ import questionary
11
+ from questionary.constants import DEFAULT_QUESTION_PREFIX
12
+ import typer
13
+ from prompt_toolkit.styles import Style
14
+ from rich.console import Console
15
+ from rich.live import Live
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+
19
+ from kubepf.utils import ensure_port
20
+ from kubepf.config import DEFAULT_CONFIG_PATH, ServiceConfig, load_config
21
+ from kubepf.kube import (
22
+ RunningPortForward,
23
+ KubernetesService,
24
+ PortForwardProcess,
25
+ get_available_namespaces,
26
+ find_running_port_forwards,
27
+ get_services,
28
+ start_port_forward,
29
+ get_current_context,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ app = typer.Typer()
35
+ console = Console(stderr=True)
36
+
37
+
38
+ QMARK_COLOR = "#61afef"
39
+ ANSWER_COLOR = "#98c379"
40
+ POINTER_COLOR = "#61afef"
41
+ SELECTED_COLOR = "#98c379"
42
+ SEPARATOR_COLOR = "#4b5263"
43
+ INSTRUCTION_COLOR = "#4b5263"
44
+ DISABLED_COLOR = "#4b5263"
45
+
46
+ STYLE = Style(
47
+ [
48
+ ("qmark", f"fg:{QMARK_COLOR} bold"),
49
+ ("question", "bold"),
50
+ ("answer", f"fg:{ANSWER_COLOR} bold"),
51
+ ("pointer", f"fg:{POINTER_COLOR} bold"),
52
+ ("highlighted", f"fg:{POINTER_COLOR} bold"),
53
+ ("selected", f"fg:{SELECTED_COLOR}"),
54
+ ("separator", f"fg:{SEPARATOR_COLOR}"),
55
+ ("instruction", f"fg:{INSTRUCTION_COLOR} italic"),
56
+ ("text", ""),
57
+ ("disabled", f"fg:{DISABLED_COLOR} italic"),
58
+ ]
59
+ )
60
+
61
+
62
+ def ensure_local_ports(
63
+ services: list[KubernetesService],
64
+ service_configs: list[ServiceConfig],
65
+ namespace: str,
66
+ ) -> list[tuple[KubernetesService, int]]:
67
+ """Map each service to its assigned local port using config preferences, falling back to any free port."""
68
+ preferred = {
69
+ (entry.name, entry.remote_port): entry.local_port
70
+ for entry in service_configs
71
+ if entry.namespace == namespace
72
+ }
73
+ result = []
74
+ for svc in services:
75
+ preferred_port = preferred.get((svc.name, svc.port))
76
+ port = ensure_port(preferred_port)
77
+ if preferred_port and port != preferred_port:
78
+ logger.warning(
79
+ "Preferred port %d for %s:%d is not available. Using %d instead.",
80
+ preferred_port,
81
+ svc.name,
82
+ svc.port,
83
+ port,
84
+ )
85
+ result.append((svc, port))
86
+ return result
87
+
88
+
89
+ async def watch_processes(
90
+ processes: list[PortForwardProcess],
91
+ statuses: dict[str, str],
92
+ stop_event: asyncio.Event,
93
+ on_exit: Callable[[], None] = lambda: None,
94
+ ) -> None:
95
+ """Watch started port-forward processes and update statuses when they exit."""
96
+
97
+ async def _watch(process: PortForwardProcess) -> None:
98
+ await process.process.wait()
99
+ if not stop_event.is_set():
100
+ statuses[f"{process.service_name}:{process.remote_port}"] = (
101
+ f"died (exit {process.process.returncode})"
102
+ )
103
+ on_exit()
104
+
105
+ async with asyncio.TaskGroup() as tg:
106
+ for proc in processes:
107
+ tg.create_task(_watch(proc))
108
+
109
+
110
+ def make_table(
111
+ processes: list[PortForwardProcess],
112
+ statuses: dict[str, str],
113
+ namespace: str,
114
+ context: str | None,
115
+ ) -> Table:
116
+ """Build the Rich status table for the currently running port-forwards."""
117
+ context_str = f" [dim]({context})[/dim]" if context else ""
118
+ table = Table(
119
+ title=f"[bold]Port Forwards[/bold] — [cyan]{namespace}[/cyan]{context_str}",
120
+ caption="[dim]Press [bold]Ctrl+C[/bold] to stop[/dim]",
121
+ border_style="bright_black",
122
+ show_lines=False,
123
+ )
124
+ table.add_column("Service", style="bold", no_wrap=True)
125
+ table.add_column("Remote", style="cyan", justify="right")
126
+ table.add_column("Local", style="cyan", justify="right")
127
+ table.add_column("PID", style="dim", justify="right")
128
+ table.add_column("Status")
129
+ for fwd in processes:
130
+ key = f"{fwd.service_name}:{fwd.remote_port}"
131
+ raw = statuses.get(key, "live")
132
+ if raw == "live":
133
+ status = "[green]● live[/green]"
134
+ elif raw == "stopped":
135
+ status = "[yellow]■ stopped[/yellow]"
136
+ else:
137
+ status = f"[red]✗ {raw}[/red]"
138
+ table.add_row(
139
+ fwd.service_name,
140
+ f":{fwd.remote_port}",
141
+ f"localhost:{fwd.local_port}",
142
+ str(fwd.process.pid),
143
+ status,
144
+ )
145
+ return table
146
+
147
+
148
+ async def run_port_forwards(
149
+ namespace: str,
150
+ services: list[KubernetesService],
151
+ service_configs: list[ServiceConfig],
152
+ context: str | None,
153
+ ) -> None:
154
+ loop = asyncio.get_running_loop()
155
+ stop_event = asyncio.Event()
156
+ processes: list[PortForwardProcess] = []
157
+ statuses: dict[str, str] = {}
158
+
159
+ for service, port in ensure_local_ports(services, service_configs, namespace):
160
+ process = await start_port_forward(namespace, service.name, port, service.port)
161
+ processes.append(process)
162
+ statuses[f"{service.name}:{service.port}"] = "live"
163
+
164
+ if not processes:
165
+ return
166
+
167
+ with Live(
168
+ renderable=make_table(processes, statuses, namespace, context),
169
+ console=console,
170
+ refresh_per_second=1,
171
+ ) as live:
172
+
173
+ def refresh() -> None:
174
+ live.update(make_table(processes, statuses, namespace, context))
175
+
176
+ def cleanup() -> None:
177
+ stop_event.set()
178
+ for proc in processes:
179
+ try:
180
+ proc.process.terminate()
181
+ statuses[f"{proc.service_name}:{proc.remote_port}"] = "stopped"
182
+ except ProcessLookupError:
183
+ pass
184
+ refresh()
185
+
186
+ loop.add_signal_handler(signal.SIGINT, cleanup)
187
+ loop.add_signal_handler(signal.SIGTERM, cleanup)
188
+
189
+ await watch_processes(
190
+ processes=processes,
191
+ statuses=statuses,
192
+ stop_event=stop_event,
193
+ on_exit=refresh,
194
+ )
195
+
196
+
197
+ def __get_title(service: KubernetesService) -> str:
198
+ return f"{service.name} :{service.port} {service.protocol}"
199
+
200
+
201
+ def __get_key(service: KubernetesService) -> tuple[str, int]:
202
+ return (service.name, service.port)
203
+
204
+
205
+ def build_service_choices(
206
+ available_services: list[KubernetesService],
207
+ running_port_forwards: list[RunningPortForward],
208
+ ) -> list[questionary.Choice]:
209
+ """Build the questionary choice list for service selection, marking already-forwarded services as disabled."""
210
+ ports = {(r.name, r.remote_port): r.local_port for r in running_port_forwards}
211
+
212
+ choices = []
213
+
214
+ inactive = [svc for svc in available_services if __get_key(svc) not in ports]
215
+ for svc in inactive:
216
+ title = __get_title(svc)
217
+ choices.append(questionary.Choice(title=title, value=svc))
218
+
219
+ active = [svc for svc in available_services if __get_key(svc) in ports]
220
+ for svc in active:
221
+ title = __get_title(svc)
222
+ disabled = f"already forwarded → localhost:{ports[__get_key(svc)]}"
223
+ choices.append(questionary.Choice(title=title, value=svc, disabled=disabled))
224
+
225
+ return choices
226
+
227
+
228
+ def select_services(
229
+ available_services: list[KubernetesService],
230
+ running_port_forwards: list[RunningPortForward],
231
+ ) -> list[KubernetesService]:
232
+ """Interactively prompt the user to select services to port-forward."""
233
+ choices = build_service_choices(available_services, running_port_forwards)
234
+ selected: list[KubernetesService] = questionary.checkbox(
235
+ "Select services to forward:",
236
+ choices=choices,
237
+ use_search_filter=True,
238
+ use_jk_keys=False,
239
+ style=STYLE,
240
+ ).ask()
241
+ return selected
242
+
243
+
244
+ def select_namespace(default: str | None, available_namespaces: list[str]) -> str:
245
+ default = default if default in available_namespaces else None
246
+
247
+ selected = questionary.select(
248
+ "Select a namespace:",
249
+ choices=available_namespaces,
250
+ use_search_filter=True,
251
+ use_jk_keys=False,
252
+ style=STYLE,
253
+ default=default,
254
+ ).ask()
255
+ if not selected:
256
+ raise typer.Exit(code=0)
257
+ return selected
258
+
259
+
260
+ def __print_error(e: subprocess.CalledProcessError, msg: str) -> None:
261
+ stderr = (e.stderr or "").strip()
262
+ logger.error("Command %s failed [%s]: %s", " ".join(e.cmd), e.returncode, stderr)
263
+ console.print(
264
+ Panel(
265
+ f"[red]{stderr or 'no output'}[/red]",
266
+ title=f"[bold red]{msg}[/bold red]",
267
+ border_style="red",
268
+ expand=False,
269
+ )
270
+ )
271
+
272
+
273
+ def __setup_logging(verbose: int) -> None:
274
+ logging_verbosity = [logging.WARNING, logging.INFO, logging.DEBUG]
275
+ level = logging_verbosity[min(verbose, len(logging_verbosity) - 1)]
276
+ logging.getLogger("kubepf").setLevel(level)
277
+
278
+
279
+ @app.callback(invoke_without_command=True)
280
+ def port_forward(
281
+ config: Annotated[
282
+ Path | None,
283
+ typer.Option(
284
+ "--config",
285
+ "-c",
286
+ envvar="KPF_CONFIG",
287
+ help=f"Path to TOML config file. Defaults to {DEFAULT_CONFIG_PATH}.",
288
+ ),
289
+ ] = None,
290
+ namespace: Annotated[
291
+ str | None,
292
+ typer.Option(
293
+ "--namespace",
294
+ "-n",
295
+ help="Kubernetes namespace to use (interactively selected if not provided).",
296
+ ),
297
+ ] = None,
298
+ service: Annotated[
299
+ list[str] | None,
300
+ typer.Option(
301
+ "--service",
302
+ "-s",
303
+ help="Service to forward. Can be specified multiple times. Skips interactive selection.",
304
+ ),
305
+ ] = None,
306
+ verbose: Annotated[
307
+ int,
308
+ typer.Option(
309
+ "--verbose", "-v", count=True, help="Verbose output. Use -vv for more detail."
310
+ ),
311
+ ] = 0,
312
+ ) -> None:
313
+ """Interactive kubectl port-forward for Kubernetes services."""
314
+
315
+ __setup_logging(verbose)
316
+
317
+ context = get_current_context()
318
+ if context:
319
+ console.print(f"[dim]Context:[/dim] [cyan]{context}[/cyan]")
320
+
321
+ cfg = load_config(config)
322
+
323
+ try:
324
+ with console.status("[bold blue]Fetching namespaces…[/bold blue]"):
325
+ namespaces = get_available_namespaces()
326
+ except subprocess.CalledProcessError as e:
327
+ __print_error(e, "Failed to fetch namespaces using kubectl")
328
+ raise typer.Exit(code=1)
329
+
330
+ if namespace is None:
331
+ namespace = select_namespace(default=cfg.default_namespace, available_namespaces=namespaces)
332
+ elif namespace not in namespaces:
333
+ console.print(f"[red]Namespace [bold]{namespace}[/bold] not found.[/red]")
334
+ raise typer.Exit(code=1)
335
+ else:
336
+ console.print(
337
+ f"[bold {QMARK_COLOR}]{DEFAULT_QUESTION_PREFIX}[/bold {QMARK_COLOR}] [bold]Select a namespace:[/bold] [bold {ANSWER_COLOR}]{namespace}[/bold {ANSWER_COLOR}]"
338
+ )
339
+
340
+ try:
341
+ with console.status(
342
+ f"[bold blue]Fetching services in [cyan]{namespace}[/cyan]…[/bold blue]"
343
+ ):
344
+ available_services = get_services(namespace)
345
+ except subprocess.CalledProcessError as e:
346
+ __print_error(e, f"Failed to fetch services in namespace {namespace} using kubectl")
347
+ raise typer.Exit(code=1)
348
+
349
+ if not available_services:
350
+ console.print(f"[yellow]No services found in namespace [bold]{namespace}[/bold].[/yellow]")
351
+ raise typer.Exit(code=0)
352
+
353
+ if service:
354
+ available_services_map = {s.name: s for s in available_services}
355
+ not_found = {name for name in service if name not in available_services_map}
356
+ if not_found:
357
+ console.print(
358
+ f"[red]Services not found in namespace [bold]{namespace}[/bold]: {', '.join(not_found)}[/red]"
359
+ )
360
+ raise typer.Exit(code=1)
361
+ selected = [available_services_map[name] for name in service]
362
+ else:
363
+ running_services = find_running_port_forwards(available_services)
364
+ selected = select_services(
365
+ available_services=available_services, running_port_forwards=running_services
366
+ )
367
+ if not selected:
368
+ console.print("[yellow]No services selected. Exiting.[/yellow]")
369
+ raise typer.Exit(code=0)
370
+ asyncio.run(run_port_forwards(namespace, selected, cfg.ports, context))
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import tomllib
5
+ from pathlib import Path
6
+ import os
7
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ DEFAULT_CONFIG_PATH = (
12
+ Path(
13
+ os.environ.get(
14
+ "XDG_CONFIG_HOME",
15
+ Path.home() / ".config",
16
+ )
17
+ )
18
+ / "kpf"
19
+ / "config.toml"
20
+ )
21
+
22
+
23
+ class ServiceConfig(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ name: str = Field(min_length=1)
27
+ namespace: str = Field(min_length=1)
28
+ remote_port: int = Field(ge=1, le=65535)
29
+ local_port: int = Field(ge=1, le=65535)
30
+
31
+
32
+ class Config(BaseModel):
33
+ model_config = ConfigDict(extra="forbid")
34
+
35
+ default_namespace: str | None = None
36
+ ports: list[ServiceConfig] = []
37
+
38
+
39
+ def load_config(path: Path | None) -> Config:
40
+ if path is None:
41
+ path = DEFAULT_CONFIG_PATH
42
+
43
+ default_config = Config()
44
+
45
+ if not path.exists():
46
+ logger.debug("No config file found at %s, using defaults", path)
47
+ return default_config
48
+
49
+ with path.open("rb") as f:
50
+ data = tomllib.load(f)
51
+ try:
52
+ config = Config.model_validate(data)
53
+ logger.info("Loaded config from %s", path)
54
+ return config
55
+ except ValidationError as e:
56
+ logger.warning(
57
+ "Failed to parse config file at %s. Using defaults instead. To fix this, ensure your config file is valid TOML and matches the expected schema.",
58
+ path,
59
+ )
60
+ logger.warning("%s", e)
61
+
62
+ return default_config
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import re
7
+ from dataclasses import dataclass
8
+ import subprocess
9
+
10
+ import psutil
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class KubernetesService:
17
+ name: str
18
+ port: int
19
+ protocol: str
20
+
21
+
22
+ @dataclass
23
+ class RunningPortForward:
24
+ name: str
25
+ remote_port: int
26
+ local_port: int
27
+ pid: int
28
+
29
+
30
+ @dataclass
31
+ class PortForwardProcess:
32
+ process: asyncio.subprocess.Process
33
+ local_port: int
34
+ remote_port: int
35
+ service_name: str
36
+
37
+
38
+ def __call_subprocess(cmd: list[str]) -> str:
39
+ logger.debug(" ".join(cmd))
40
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
41
+ return result.stdout
42
+
43
+
44
+ def parse_context(raw: str) -> str:
45
+ """Extract cluster name from a kubectl context string, stripping EKS ARN prefix if present."""
46
+ assert raw is not None, "Context string is empty"
47
+ context = raw.strip()
48
+ if context.startswith("arn:aws:eks:"):
49
+ return context.split("/")[-1]
50
+ return context
51
+
52
+
53
+ def parse_namespaces(raw: str) -> list[str]:
54
+ """Parse namespace names from `kubectl get namespaces -o json` output."""
55
+ data = json.loads(raw)
56
+ return [el["metadata"]["name"] for el in data["items"]]
57
+
58
+
59
+ def parse_services(raw: str) -> list[KubernetesService]:
60
+ """Parse services from `kubectl get services -o json` output, skipping the built-in kubernetes service."""
61
+ data = json.loads(raw)
62
+ services: list[KubernetesService] = []
63
+ for svc in data["items"]:
64
+ name = svc["metadata"]["name"]
65
+ if name == "kubernetes":
66
+ continue
67
+ for port in svc["spec"]["ports"]:
68
+ services.append(
69
+ KubernetesService(
70
+ name=name,
71
+ port=port["port"],
72
+ protocol=port["protocol"],
73
+ )
74
+ )
75
+ return sorted(services, key=lambda x: (x.name, x.port))
76
+
77
+
78
+ def get_current_context() -> str:
79
+ """Get the current kubectl context, extracting cluster name from ARN if present."""
80
+ return parse_context(__call_subprocess(["kubectl", "config", "current-context"]))
81
+
82
+
83
+ def get_available_namespaces() -> list[str]:
84
+ """Get the list of available Kubernetes namespaces using kubectl."""
85
+ return parse_namespaces(__call_subprocess(["kubectl", "get", "namespaces", "-o", "json"]))
86
+
87
+
88
+ def get_services(namespace: str) -> list[KubernetesService]:
89
+ """Get the list of services with their ports in the specified namespace."""
90
+ return parse_services(
91
+ __call_subprocess(["kubectl", "get", "services", "-n", namespace, "-o", "json"])
92
+ )
93
+
94
+
95
+ def find_running_port_forwards(
96
+ services: list[KubernetesService],
97
+ ) -> list[RunningPortForward]:
98
+ """Find running kubectl port-forward processes that match the given services."""
99
+ known_ports = {(svc.name, svc.port) for svc in services}
100
+
101
+ kubectl_procs = [
102
+ proc
103
+ for proc in psutil.process_iter(["pid", "name", "cmdline"])
104
+ if proc.info["name"] == "kubectl"
105
+ ]
106
+
107
+ running: list[RunningPortForward] = []
108
+ for proc in kubectl_procs:
109
+ cmdline = proc.info["cmdline"]
110
+ svc_match = re.search(r"(?:svc|service)/(?P<name>[a-zA-Z0-9-]+)", " ".join(cmdline))
111
+ if not svc_match:
112
+ continue
113
+ service_name = svc_match.group("name")
114
+
115
+ for arg in cmdline:
116
+ port_match = re.fullmatch(r"(?:(?P<local>\d+):)?(?P<remote>\d+)", arg)
117
+ if not port_match:
118
+ continue
119
+ remote_port = int(port_match.group("remote"))
120
+ local_port = (
121
+ int(port_match.group("local")) if port_match.group("local") else remote_port
122
+ )
123
+ if (service_name, remote_port) in known_ports:
124
+ running.append(
125
+ RunningPortForward(
126
+ name=service_name,
127
+ remote_port=remote_port,
128
+ local_port=local_port,
129
+ pid=proc.info["pid"],
130
+ )
131
+ )
132
+
133
+ return running
134
+
135
+
136
+ async def start_port_forward(
137
+ namespace: str, service: str, local_port: int, remote_port: int
138
+ ) -> PortForwardProcess:
139
+ """Start a kubectl port-forward process for the specified service and port."""
140
+ cmd = [
141
+ "kubectl",
142
+ "port-forward",
143
+ f"svc/{service}",
144
+ f"{local_port}:{remote_port}",
145
+ "-n",
146
+ namespace,
147
+ ]
148
+ logger.debug(" ".join(cmd))
149
+ process = await asyncio.create_subprocess_exec(
150
+ *cmd,
151
+ stdout=asyncio.subprocess.DEVNULL,
152
+ stderr=None,
153
+ )
154
+ logger.debug(
155
+ "Started port forward for %s:%d → localhost:%d [PID: %d]",
156
+ service,
157
+ remote_port,
158
+ local_port,
159
+ process.pid,
160
+ )
161
+ return PortForwardProcess(
162
+ process=process,
163
+ local_port=local_port,
164
+ remote_port=remote_port,
165
+ service_name=service,
166
+ )
@@ -0,0 +1,29 @@
1
+ import socket
2
+
3
+
4
+ def is_port_free(port: int) -> bool:
5
+ """Check if a local port is free by trying to bind to it."""
6
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
7
+ try:
8
+ s.bind(("", port))
9
+ return True
10
+ except OSError:
11
+ return False
12
+
13
+
14
+ def find_free_port() -> int:
15
+ """Find a free local port by binding to port 0, which tells the OS to select an available port."""
16
+ # TOCTOU: the port is freed before kubectl binds it, so another process
17
+ # could claim it in the window. Unavoidable without passing a pre-bound
18
+ # socket, which kubectl does not support.
19
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
20
+ s.bind(("", 0))
21
+ return s.getsockname()[1]
22
+
23
+
24
+ def ensure_port(preferred: int | None) -> int:
25
+ """Return the preferred port if it's free, otherwise find a free port."""
26
+ if preferred and is_port_free(preferred):
27
+ return preferred
28
+ else:
29
+ return find_free_port()