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 +21 -0
- kube_pf-0.1.0/PKG-INFO +127 -0
- kube_pf-0.1.0/README.md +94 -0
- kube_pf-0.1.0/pyproject.toml +55 -0
- kube_pf-0.1.0/src/kubepf/__init__.py +0 -0
- kube_pf-0.1.0/src/kubepf/__main__.py +8 -0
- kube_pf-0.1.0/src/kubepf/cli/__init__.py +0 -0
- kube_pf-0.1.0/src/kubepf/cli/main.py +1 -0
- kube_pf-0.1.0/src/kubepf/cli/port_forward.py +370 -0
- kube_pf-0.1.0/src/kubepf/config.py +62 -0
- kube_pf-0.1.0/src/kubepf/kube.py +166 -0
- kube_pf-0.1.0/src/kubepf/utils.py +29 -0
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
|
+

|
|
39
|
+

|
|
40
|
+
|
|
41
|
+

|
|
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
|
+
|
kube_pf-0.1.0/README.md
ADDED
|
@@ -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
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+

|
|
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
|
|
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()
|