kubek 0.4.2__py3-none-any.whl
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.
- envx/__init__.py +0 -0
- envx/__main__.py +18 -0
- envx/cli/__init__.py +0 -0
- envx/cli/main.py +159 -0
- envx/console.py +40 -0
- envx/kube.py +168 -0
- envx/style.py +27 -0
- envx/utils.py +51 -0
- kubek/core/__init__.py +2 -0
- kubek/ui/__init__.py +0 -0
- kubek-0.4.2.dist-info/METADATA +117 -0
- kubek-0.4.2.dist-info/RECORD +23 -0
- kubek-0.4.2.dist-info/WHEEL +4 -0
- kubek-0.4.2.dist-info/entry_points.txt +5 -0
- kubek-0.4.2.dist-info/licenses/LICENSE +21 -0
- portfwd/__init__.py +0 -0
- portfwd/__main__.py +8 -0
- portfwd/cli/__init__.py +0 -0
- portfwd/cli/main.py +1 -0
- portfwd/cli/port_forward.py +390 -0
- portfwd/config.py +65 -0
- portfwd/kube.py +176 -0
- portfwd/utils.py +31 -0
envx/__init__.py
ADDED
|
File without changes
|
envx/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from envx.cli.main import app
|
|
4
|
+
from envx.console import console
|
|
5
|
+
from envx.style import COLOR_WARNING
|
|
6
|
+
|
|
7
|
+
logging.basicConfig()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def deprecated_entry() -> None:
|
|
11
|
+
console.print(
|
|
12
|
+
f"[bold {COLOR_WARNING}]Warning:[/] 'envx' has been deprecated, use 'kenvx' instead."
|
|
13
|
+
)
|
|
14
|
+
app()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
app()
|
envx/cli/__init__.py
ADDED
|
File without changes
|
envx/cli/main.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import questionary
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from envx.console import console, print_error
|
|
12
|
+
from envx.kube import (
|
|
13
|
+
get_available_deployments,
|
|
14
|
+
get_available_namespaces,
|
|
15
|
+
get_available_workflowtemplates,
|
|
16
|
+
get_deployment_envs,
|
|
17
|
+
get_workflowtemplate_envs,
|
|
18
|
+
)
|
|
19
|
+
from envx.style import COLOR_MUTED, STYLE
|
|
20
|
+
from envx.utils import export_as_dotenv, resolve_namespace, setup_logging
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResourceKind(str, enum.Enum):
|
|
24
|
+
DEPLOYMENT = "deployment"
|
|
25
|
+
WORKFLOWTEMPLATE = "workflowtemplate"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExportFormat(str, enum.Enum):
|
|
29
|
+
ENV = "env"
|
|
30
|
+
JSON = "json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
app = typer.Typer()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def select_resource_kind() -> ResourceKind:
|
|
37
|
+
selected = questionary.select(
|
|
38
|
+
"Select a kind:",
|
|
39
|
+
choices=[
|
|
40
|
+
questionary.Choice(
|
|
41
|
+
title="Deployment",
|
|
42
|
+
value=ResourceKind.DEPLOYMENT,
|
|
43
|
+
description="(Kubernetes Deployment)",
|
|
44
|
+
),
|
|
45
|
+
questionary.Choice(
|
|
46
|
+
title="WorkflowTemplate",
|
|
47
|
+
value=ResourceKind.WORKFLOWTEMPLATE,
|
|
48
|
+
description="(Argo WorkflowTemplate)",
|
|
49
|
+
),
|
|
50
|
+
],
|
|
51
|
+
use_jk_keys=False,
|
|
52
|
+
style=STYLE,
|
|
53
|
+
).ask()
|
|
54
|
+
if not selected:
|
|
55
|
+
raise typer.Exit(code=0)
|
|
56
|
+
return ResourceKind(selected)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.callback(invoke_without_command=True)
|
|
60
|
+
def get(
|
|
61
|
+
kind: Annotated[
|
|
62
|
+
ResourceKind | None,
|
|
63
|
+
typer.Option(
|
|
64
|
+
help="Kind of resource to get parameters for. If not provided, you will be prompted to select one.",
|
|
65
|
+
),
|
|
66
|
+
] = None,
|
|
67
|
+
namespace: Annotated[
|
|
68
|
+
str | None,
|
|
69
|
+
typer.Option(
|
|
70
|
+
envvar="KENVX_NAMESPACE",
|
|
71
|
+
help="Kubernetes namespace. If not provided, you will be prompted to select one.",
|
|
72
|
+
),
|
|
73
|
+
] = None,
|
|
74
|
+
name: Annotated[
|
|
75
|
+
str | None,
|
|
76
|
+
typer.Option(
|
|
77
|
+
help="Name of the resource. If not provided, you will be prompted to select one.",
|
|
78
|
+
),
|
|
79
|
+
] = None,
|
|
80
|
+
output: ExportFormat = ExportFormat.ENV,
|
|
81
|
+
verbose: Annotated[
|
|
82
|
+
int,
|
|
83
|
+
typer.Option(
|
|
84
|
+
"--verbose", "-v", count=True, help="Verbose output. Use -vv for debug."
|
|
85
|
+
),
|
|
86
|
+
] = 0,
|
|
87
|
+
):
|
|
88
|
+
"""
|
|
89
|
+
Get environment variables for a Kubernetes deployment or Argo WorkflowTemplate.
|
|
90
|
+
"""
|
|
91
|
+
setup_logging(verbose)
|
|
92
|
+
if kind is None:
|
|
93
|
+
kind = select_resource_kind()
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
with console.status(f"[italic {COLOR_MUTED}]Fetching available namespaces…[/]"):
|
|
97
|
+
namespaces = get_available_namespaces()
|
|
98
|
+
except subprocess.CalledProcessError as e:
|
|
99
|
+
print_error(e, "Failed to fetch available namespaces")
|
|
100
|
+
raise typer.Exit(code=1) from None
|
|
101
|
+
|
|
102
|
+
namespace = resolve_namespace(namespace, available_namespaces=namespaces)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with console.status(
|
|
106
|
+
f"[italic {COLOR_MUTED}]Fetching available {kind.value}s in {namespace}…[/]"
|
|
107
|
+
):
|
|
108
|
+
if kind == ResourceKind.DEPLOYMENT:
|
|
109
|
+
resources = get_available_deployments(namespace=namespace)
|
|
110
|
+
else:
|
|
111
|
+
resources = get_available_workflowtemplates(namespace=namespace)
|
|
112
|
+
except subprocess.CalledProcessError as e:
|
|
113
|
+
print_error(
|
|
114
|
+
e, f"Failed to fetch available {kind.value}s in namespace '{namespace}'"
|
|
115
|
+
)
|
|
116
|
+
raise typer.Exit(code=1) from None
|
|
117
|
+
if not resources:
|
|
118
|
+
console.print(f"[red]Error: no {kind.value}s found in namespace '{namespace}'")
|
|
119
|
+
raise typer.Exit(code=1)
|
|
120
|
+
|
|
121
|
+
if not name:
|
|
122
|
+
name = questionary.select(
|
|
123
|
+
f"Select a {kind.value}:",
|
|
124
|
+
choices=resources,
|
|
125
|
+
use_search_filter=True,
|
|
126
|
+
use_jk_keys=False,
|
|
127
|
+
style=STYLE,
|
|
128
|
+
).ask()
|
|
129
|
+
if not name:
|
|
130
|
+
raise typer.Exit(code=0)
|
|
131
|
+
|
|
132
|
+
if name not in resources:
|
|
133
|
+
console.print(
|
|
134
|
+
f"[red]Error:[/red] {kind.value} '{name}' not found in namespace '{namespace}'."
|
|
135
|
+
)
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
with console.status(
|
|
140
|
+
f"[italic {COLOR_MUTED}]Fetching environment variables…[/]"
|
|
141
|
+
):
|
|
142
|
+
if kind == ResourceKind.DEPLOYMENT:
|
|
143
|
+
vals = get_deployment_envs(namespace=namespace, name=name)
|
|
144
|
+
else:
|
|
145
|
+
vals = get_workflowtemplate_envs(namespace=namespace, name=name)
|
|
146
|
+
except subprocess.CalledProcessError as e:
|
|
147
|
+
print_error(e, f"Failed to fetch environment variables for '{name}'")
|
|
148
|
+
raise typer.Exit(code=1) from None
|
|
149
|
+
|
|
150
|
+
if output == ExportFormat.JSON:
|
|
151
|
+
formatted = json.dumps(vals, sort_keys=True)
|
|
152
|
+
elif output == ExportFormat.ENV:
|
|
153
|
+
formatted = export_as_dotenv(vals=vals, name=name)
|
|
154
|
+
print(formatted)
|
|
155
|
+
raise typer.Exit(code=0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
app()
|
envx/console.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from envx.style import COLOR_ERROR
|
|
10
|
+
|
|
11
|
+
console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_error(e: subprocess.CalledProcessError, msg: str) -> None:
|
|
17
|
+
logger.debug("kubectl error", exc_info=e, stack_info=True)
|
|
18
|
+
stderr = (e.stderr or "no output").strip()
|
|
19
|
+
stdout = (e.stdout or "no output").strip()
|
|
20
|
+
cmd = " ".join(e.cmd)
|
|
21
|
+
content = "\n".join(
|
|
22
|
+
[
|
|
23
|
+
"[dim]stderr:[/dim]",
|
|
24
|
+
f"[{COLOR_ERROR}]{stderr}[/]",
|
|
25
|
+
"",
|
|
26
|
+
"[dim]stdout:[/dim]",
|
|
27
|
+
stdout,
|
|
28
|
+
"",
|
|
29
|
+
f"[dim]command:[/dim] {cmd}",
|
|
30
|
+
f"[dim]exit code:[/dim] {e.returncode}",
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
console.print(
|
|
34
|
+
Panel(
|
|
35
|
+
content,
|
|
36
|
+
title=f"[bold {COLOR_ERROR}]{msg}[/]",
|
|
37
|
+
border_style=COLOR_ERROR,
|
|
38
|
+
expand=False,
|
|
39
|
+
)
|
|
40
|
+
)
|
envx/kube.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from envx.utils import decode
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def call_subprocess(cmd: list[str]) -> str:
|
|
17
|
+
logger.debug("%s", " ".join(cmd))
|
|
18
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
19
|
+
return result.stdout
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_available_namespaces() -> list[str]:
|
|
23
|
+
cmd = ["kubectl", "get", "namespaces", "-o", "json"]
|
|
24
|
+
result = call_subprocess(cmd)
|
|
25
|
+
data = json.loads(result)
|
|
26
|
+
return [el["metadata"]["name"] for el in data["items"]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@lru_cache
|
|
30
|
+
def get_secret(namespace: str, name: str) -> dict[str, Any]:
|
|
31
|
+
cmd = ["kubectl", "get", "secret", name, "-n", namespace, "-o", "json"]
|
|
32
|
+
result = call_subprocess(cmd)
|
|
33
|
+
secret = json.loads(result)
|
|
34
|
+
return secret
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@lru_cache
|
|
38
|
+
def get_configmap(namespace: str, name: str) -> dict[str, Any]:
|
|
39
|
+
cmd = ["kubectl", "get", "configmap", name, "-n", namespace, "-o", "json"]
|
|
40
|
+
result = call_subprocess(cmd)
|
|
41
|
+
configmap = json.loads(result)
|
|
42
|
+
return configmap
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _clean_key(key: str) -> str:
|
|
46
|
+
def strip_argo_inputs_param(key: str) -> str:
|
|
47
|
+
match = re.match(r"^\{\{inputs\.parameters\.(?P<param_name>\w+)\}\}$", key)
|
|
48
|
+
return match.group("param_name") if match else key
|
|
49
|
+
|
|
50
|
+
cleanups: list[Callable[[str], str]] = [
|
|
51
|
+
strip_argo_inputs_param,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
for cleanup in cleanups:
|
|
55
|
+
key = cleanup(key)
|
|
56
|
+
return key
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_available_deployments(namespace: str) -> list[str]:
|
|
60
|
+
cmd = ["kubectl", "get", "deployment", "-n", namespace, "-o", "json"]
|
|
61
|
+
result = call_subprocess(cmd)
|
|
62
|
+
data = json.loads(result)
|
|
63
|
+
return [el["metadata"]["name"] for el in data["items"]]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_deployment_envs(namespace: str, name: str) -> dict[str, str]:
|
|
67
|
+
cmd = ["kubectl", "get", "deployment", name, "-n", namespace, "-o", "json"]
|
|
68
|
+
result = call_subprocess(cmd)
|
|
69
|
+
deployment = json.loads(result)
|
|
70
|
+
containers = deployment["spec"]["template"]["spec"]["containers"]
|
|
71
|
+
if len(containers) != 1:
|
|
72
|
+
raise ValueError(f"Expected 1 container, got {len(containers)}")
|
|
73
|
+
return extract_envs_from_container(namespace=namespace, container=containers[0])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_available_workflowtemplates(namespace: str) -> list[str]:
|
|
77
|
+
cmd = ["kubectl", "get", "workflowtemplate", "-n", namespace, "-o", "json"]
|
|
78
|
+
result = call_subprocess(cmd)
|
|
79
|
+
data = json.loads(result)
|
|
80
|
+
return [el["metadata"]["name"] for el in data["items"]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_workflowtemplate_envs(namespace: str, name: str) -> dict[str, str]:
|
|
84
|
+
cmd = ["kubectl", "get", "workflowtemplate", name, "-n", namespace, "-o", "json"]
|
|
85
|
+
result = call_subprocess(cmd)
|
|
86
|
+
workflow = json.loads(result)
|
|
87
|
+
envs = {}
|
|
88
|
+
for template in workflow["spec"]["templates"]:
|
|
89
|
+
if "container" not in template:
|
|
90
|
+
continue
|
|
91
|
+
fallback_keys = {
|
|
92
|
+
p["name"]: p["default"]
|
|
93
|
+
for p in template.get("inputs", {}).get("parameters", [])
|
|
94
|
+
if "default" in p
|
|
95
|
+
}
|
|
96
|
+
envs.update(
|
|
97
|
+
extract_envs_from_container(
|
|
98
|
+
namespace, template["container"], fallback_keys=fallback_keys
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return envs
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def extract_envs_from_container(
|
|
105
|
+
namespace: str,
|
|
106
|
+
container: dict[str, Any],
|
|
107
|
+
fallback_keys: dict[str, str] | None = None,
|
|
108
|
+
) -> dict[str, str]:
|
|
109
|
+
if fallback_keys is None:
|
|
110
|
+
fallback_keys = {}
|
|
111
|
+
result = {}
|
|
112
|
+
if "envFrom" in container:
|
|
113
|
+
for env_from in container["envFrom"]:
|
|
114
|
+
if "configMapRef" in env_from:
|
|
115
|
+
configmap = get_configmap(namespace, env_from["configMapRef"]["name"])
|
|
116
|
+
result.update(configmap["data"])
|
|
117
|
+
elif "secretRef" in env_from:
|
|
118
|
+
secret_name = env_from["secretRef"]["name"]
|
|
119
|
+
secret = get_secret(namespace, secret_name)
|
|
120
|
+
encoded = {k: decode(v) for k, v in secret["data"].items()}
|
|
121
|
+
result.update(encoded)
|
|
122
|
+
else:
|
|
123
|
+
raise ValueError(f"Unknown envFrom format: {env_from}")
|
|
124
|
+
|
|
125
|
+
if "env" in container:
|
|
126
|
+
for env in container["env"]:
|
|
127
|
+
name = env["name"]
|
|
128
|
+
if "value" in env:
|
|
129
|
+
value = env["value"]
|
|
130
|
+
result[name] = value
|
|
131
|
+
elif "valueFrom" in env:
|
|
132
|
+
value_from = env["valueFrom"]
|
|
133
|
+
if "configMapKeyRef" in value_from:
|
|
134
|
+
configmap = get_configmap(
|
|
135
|
+
namespace, value_from["configMapKeyRef"]["name"]
|
|
136
|
+
)
|
|
137
|
+
key = value_from["configMapKeyRef"]["key"]
|
|
138
|
+
if key not in configmap["data"]:
|
|
139
|
+
key = fallback_keys.get(_clean_key(key), key)
|
|
140
|
+
if key not in configmap["data"]:
|
|
141
|
+
logger.warning(
|
|
142
|
+
f"{name} won't be set: key {key} not found in ConfigMap {value_from['configMapKeyRef']['name']}"
|
|
143
|
+
)
|
|
144
|
+
value = ""
|
|
145
|
+
else:
|
|
146
|
+
value = configmap["data"][key]
|
|
147
|
+
result[name] = value
|
|
148
|
+
elif "secretKeyRef" in value_from:
|
|
149
|
+
secret_name = value_from["secretKeyRef"]["name"]
|
|
150
|
+
encoded = get_secret(namespace, secret_name)
|
|
151
|
+
key = value_from["secretKeyRef"]["key"]
|
|
152
|
+
if key not in encoded["data"]:
|
|
153
|
+
key = fallback_keys.get(_clean_key(key), key)
|
|
154
|
+
if key not in encoded["data"]:
|
|
155
|
+
logger.warning(
|
|
156
|
+
f"{name} won't be set: key {key} not found in Secret {secret_name}"
|
|
157
|
+
)
|
|
158
|
+
value = ""
|
|
159
|
+
else:
|
|
160
|
+
value = decode(encoded["data"][key])
|
|
161
|
+
result[name] = value
|
|
162
|
+
else:
|
|
163
|
+
logger.warning(
|
|
164
|
+
f"Unknown valueFrom format: {value_from} for {name} ({env})"
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
logger.warning(f"Unknown env format: {env}")
|
|
168
|
+
return result
|
envx/style.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from questionary import Style
|
|
2
|
+
|
|
3
|
+
# fmt: off
|
|
4
|
+
COLOR_ACCENT = "#e5c07b" # yellow — draws attention (qmark)
|
|
5
|
+
COLOR_SUCCESS = "#98c379" # green — confirmed / selected
|
|
6
|
+
COLOR_ACTIVE = "#61afef" # blue — navigation / pointer
|
|
7
|
+
COLOR_WARNING = "#d19a66" # orange — warnings / cautions
|
|
8
|
+
COLOR_ERROR = "#e06c75" # red — errors / failures
|
|
9
|
+
COLOR_MUTED = "#5c6370" # gray — secondary elements
|
|
10
|
+
COLOR_SUBTLE = "#4b5263" # dark gray — near-invisible hints
|
|
11
|
+
|
|
12
|
+
# fmt: off
|
|
13
|
+
STYLE = Style(
|
|
14
|
+
[
|
|
15
|
+
("qmark", f"fg:{COLOR_ACCENT} bold"),
|
|
16
|
+
("question", "bold"),
|
|
17
|
+
("answer", f"fg:{COLOR_SUCCESS} bold"),
|
|
18
|
+
("pointer", f"fg:{COLOR_ACTIVE} bold"),
|
|
19
|
+
("highlighted", f"fg:{COLOR_ACTIVE} bold"),
|
|
20
|
+
("selected", f"fg:{COLOR_SUCCESS}"),
|
|
21
|
+
("separator", f"fg:{COLOR_MUTED}"),
|
|
22
|
+
("instruction", f"fg:{COLOR_SUBTLE} italic"),
|
|
23
|
+
("text", ""),
|
|
24
|
+
("disabled", f"fg:{COLOR_SUBTLE} italic"),
|
|
25
|
+
]
|
|
26
|
+
)
|
|
27
|
+
# fmt: on
|
envx/utils.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import datetime
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from envx.console import console
|
|
11
|
+
from envx.style import STYLE
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def setup_logging(verbose: int) -> None:
|
|
15
|
+
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
|
|
16
|
+
level = levels[min(verbose, len(levels) - 1)]
|
|
17
|
+
logging.getLogger("kenvx").setLevel(level)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode(val: str) -> str:
|
|
21
|
+
decoded_bytes = base64.b64decode(val)
|
|
22
|
+
return decoded_bytes.decode("utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def export_as_dotenv(vals: dict[str, str], name: str | None = None) -> str:
|
|
26
|
+
sorted_list = sorted(vals.items(), key=lambda x: x[0])
|
|
27
|
+
res = []
|
|
28
|
+
if name:
|
|
29
|
+
now = datetime.datetime.now().isoformat(timespec="seconds")
|
|
30
|
+
res.append(f"# {name} @ {now}")
|
|
31
|
+
for key, value in sorted_list:
|
|
32
|
+
res.append(f"{key}={value}")
|
|
33
|
+
return "\n".join(res)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_namespace(value: str | None, available_namespaces: list[str]) -> str:
|
|
37
|
+
if value:
|
|
38
|
+
if value not in available_namespaces:
|
|
39
|
+
console.print(f"[red]Error:[/red] namespace '{value}' not found.")
|
|
40
|
+
raise typer.Exit(code=1)
|
|
41
|
+
return value
|
|
42
|
+
selected = questionary.select(
|
|
43
|
+
"Select a namespace:",
|
|
44
|
+
choices=available_namespaces,
|
|
45
|
+
use_search_filter=True,
|
|
46
|
+
use_jk_keys=False,
|
|
47
|
+
style=STYLE,
|
|
48
|
+
).ask()
|
|
49
|
+
if not selected:
|
|
50
|
+
raise typer.Exit(code=0)
|
|
51
|
+
return selected
|
kubek/core/__init__.py
ADDED
kubek/ui/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kubek
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: kubectl extensions
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: kubernetes,kubectl,kubectl plugins,cli,devtools,port forwarding,dotenv
|
|
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 (>=7.2.2,<8.0.0)
|
|
24
|
+
Requires-Dist: pydantic (>=2.13.4,<3.0.0)
|
|
25
|
+
Requires-Dist: questionary (>=2.1.1,<3.0.0)
|
|
26
|
+
Requires-Dist: rich (>=15.0.0,<16.0.0)
|
|
27
|
+
Requires-Dist: typer (>=0.25.1,<0.26.0)
|
|
28
|
+
Project-URL: Repository, https://github.com/ohmycoffe/kubek
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
<div align="center">
|
|
32
|
+
|
|
33
|
+
# kubek
|
|
34
|
+
|
|
35
|
+
> `kubectl` plugins for friendlier Kubernetes interactions.
|
|
36
|
+
|
|
37
|
+
**kubek** (**kub**ernetes **e**xtension **k**it) is a collection of CLI tools that plug into `kubectl` and add interactive, developer-friendly shortcuts on top of it.
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Plugins
|
|
48
|
+
|
|
49
|
+
### 🔌 portfwd — Interactive port forwarding
|
|
50
|
+
|
|
51
|
+
Forwarding ports to Kubernetes services usually means running a separate `kubectl port-forward` command for each service, keeping track of which local port maps to what, and restarting them when they die. **portfwd** (**port** **f**or**w**ar**d**) replaces all of that with a single interactive command — pick your services, and it handles the rest.
|
|
52
|
+
|
|
53
|
+
- Fuzzy-search namespaces and services interactively
|
|
54
|
+
- Forward multiple services simultaneously in one command
|
|
55
|
+
- Live status table with real-time updates when a process dies
|
|
56
|
+
- Pin preferred local ports per service in a TOML config
|
|
57
|
+
- Fallback to a random free port if the preferred port is taken
|
|
58
|
+
- Inform user which services are currently forwarded and on which ports
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
→ [Full documentation](kubectl-portfwd/README.md)
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### 📦 envx — Export env vars from Kubernetes manifests
|
|
67
|
+
|
|
68
|
+
Getting credentials out of a running deployment usually means digging through `kubectl get deployment -o yaml`, copying values by hand, and reformatting them into a `.env` file. **envx** (**env**ironment e**x**porter) does it in one command — pick any Deployment or Argo WorkflowTemplate you have access to and get its env vars exported instantly.
|
|
69
|
+
|
|
70
|
+
- Fully interactive (fuzzy-search, arrow key navigation)
|
|
71
|
+
- Output as `.env` or JSON
|
|
72
|
+
- Pipe directly: `kubectl envx ... > .env` to produce a dotenv file
|
|
73
|
+
|
|
74
|
+

|
|
75
|
+
|
|
76
|
+
→ [Full documentation](kubectl-envx/README.md)
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
### Prerequisites
|
|
83
|
+
|
|
84
|
+
- Python 3.11+
|
|
85
|
+
- `kubectl` installed and configured with cluster access
|
|
86
|
+
|
|
87
|
+
### Install
|
|
88
|
+
|
|
89
|
+
The recommended way to install kubek is with [pipx](https://pipx.pypa.io/), which installs it in an isolated environment and automatically makes the plugin executables available on your PATH:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Latest stable release (recommended)
|
|
93
|
+
pipx install kubek
|
|
94
|
+
|
|
95
|
+
# Newest development version
|
|
96
|
+
pipx install git+https://github.com/ohmycoffe/kubek.git
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If you use pip or another package manager, make sure the installation's `bin/` directory is on your PATH — `kubectl` discovers plugins by scanning PATH for executables prefixed with `kubectl-`.
|
|
100
|
+
|
|
101
|
+
### Verify
|
|
102
|
+
|
|
103
|
+
After installing, confirm both plugins are available:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
kubectl portfwd --help
|
|
107
|
+
kubectl envx --help
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
See each plugin's README for full usage details.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT — see [LICENSE](LICENSE).
|
|
117
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
envx/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
envx/__main__.py,sha256=zy4aDa0qqhtusydrj3l7q6tbFknmieBUwowwjvJ2vEQ,341
|
|
3
|
+
envx/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
envx/cli/main.py,sha256=QyQPQvqo8qkE0BigIB8lEHiBg1vLXj-xtAWB7aAkRLU,4791
|
|
5
|
+
envx/console.py,sha256=wydmFCzm8Ds06PMVlDUghRpHh2sLGM9B_I2tjNBNTCk,987
|
|
6
|
+
envx/kube.py,sha256=k9Tw9EOp9DQXwC878gBDgtLm_c5i8ug0S1_tfX6pDas,6173
|
|
7
|
+
envx/style.py,sha256=HUT5EMqfRm6iCgHI_0tboILmiY0ymnItWFBmCUCsZnM,1019
|
|
8
|
+
envx/utils.py,sha256=ChiptJGd6dHCkvGDE3TUPj-VlR8d20lgBQg2D_3EKJw,1411
|
|
9
|
+
portfwd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
portfwd/__main__.py,sha256=wIj7vOZPhMebcsUH75upSLO8mwCSpLIZhsGrhczWPsU,110
|
|
11
|
+
portfwd/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
portfwd/cli/main.py,sha256=LZ9anb4AkDJiPZ6E2Yc5f8A0muNdVsICLt4V7Q_a2uY,55
|
|
13
|
+
portfwd/cli/port_forward.py,sha256=noi-qermp-AqeHJSusnVmuop9x13YBf5Zgo4tTR9yG8,12492
|
|
14
|
+
portfwd/config.py,sha256=jDWh5PQgT7QneJ01CQvAqtVusX-7nQ28vlZLOzZKIwc,1573
|
|
15
|
+
portfwd/kube.py,sha256=TQmJNhV8dufSCivumZ2550Ctarixb5fMFZ5ARUdXmak,4993
|
|
16
|
+
portfwd/utils.py,sha256=-xtml3gowadBYEDCXm6OilMM9Sf9gNh_w5DrQGk3jy0,1004
|
|
17
|
+
kubek/core/__init__.py,sha256=tXbbevUzFUeBNiU8nHd6uAu6l9gzSGfU3DM4N8NYovY,38
|
|
18
|
+
kubek/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
kubek-0.4.2.dist-info/METADATA,sha256=etlvpYNQitGklOPalwO8Sk9a1xzfA_uSTS9zsGgE5RM,4176
|
|
20
|
+
kubek-0.4.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
21
|
+
kubek-0.4.2.dist-info/entry_points.txt,sha256=sf0FMfRSWIMeOG3ML3QqpcoxwZ29T-tSiOPP997vqjM,123
|
|
22
|
+
kubek-0.4.2.dist-info/licenses/LICENSE,sha256=c7i_EtV17QajV6Vbh1u8MkC6s6SuX-hVCklvc-7PA1I,1066
|
|
23
|
+
kubek-0.4.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 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.
|
portfwd/__init__.py
ADDED
|
File without changes
|
portfwd/__main__.py
ADDED
portfwd/cli/__init__.py
ADDED
|
File without changes
|
portfwd/cli/main.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from portfwd.cli.port_forward import app # noqa: F401
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import questionary
|
|
12
|
+
import typer
|
|
13
|
+
from prompt_toolkit.styles import Style
|
|
14
|
+
from questionary.constants import DEFAULT_QUESTION_PREFIX
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from portfwd.config import DEFAULT_CONFIG_PATH, ServiceConfig, load_config
|
|
21
|
+
from portfwd.kube import (
|
|
22
|
+
KubernetesService,
|
|
23
|
+
PortForwardProcess,
|
|
24
|
+
RunningPortForward,
|
|
25
|
+
find_running_port_forwards,
|
|
26
|
+
get_available_namespaces,
|
|
27
|
+
get_current_context,
|
|
28
|
+
get_services,
|
|
29
|
+
start_port_forward,
|
|
30
|
+
)
|
|
31
|
+
from portfwd.utils import ensure_port
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
app = typer.Typer()
|
|
36
|
+
console = Console(stderr=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
QMARK_COLOR = "#61afef"
|
|
40
|
+
ANSWER_COLOR = "#98c379"
|
|
41
|
+
POINTER_COLOR = "#61afef"
|
|
42
|
+
SELECTED_COLOR = "#98c379"
|
|
43
|
+
SEPARATOR_COLOR = "#4b5263"
|
|
44
|
+
INSTRUCTION_COLOR = "#4b5263"
|
|
45
|
+
DISABLED_COLOR = "#4b5263"
|
|
46
|
+
|
|
47
|
+
STYLE = Style(
|
|
48
|
+
[
|
|
49
|
+
("qmark", f"fg:{QMARK_COLOR} bold"),
|
|
50
|
+
("question", "bold"),
|
|
51
|
+
("answer", f"fg:{ANSWER_COLOR} bold"),
|
|
52
|
+
("pointer", f"fg:{POINTER_COLOR} bold"),
|
|
53
|
+
("highlighted", f"fg:{POINTER_COLOR} bold"),
|
|
54
|
+
("selected", f"fg:{SELECTED_COLOR}"),
|
|
55
|
+
("separator", f"fg:{SEPARATOR_COLOR}"),
|
|
56
|
+
("instruction", f"fg:{INSTRUCTION_COLOR} italic"),
|
|
57
|
+
("text", ""),
|
|
58
|
+
("disabled", f"fg:{DISABLED_COLOR} italic"),
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ensure_local_ports(
|
|
64
|
+
services: list[KubernetesService],
|
|
65
|
+
service_configs: list[ServiceConfig],
|
|
66
|
+
namespace: str,
|
|
67
|
+
) -> list[tuple[KubernetesService, int]]:
|
|
68
|
+
"""Map each service to its assigned local port using config preferences,
|
|
69
|
+
falling back to any free port.
|
|
70
|
+
"""
|
|
71
|
+
preferred = {
|
|
72
|
+
(entry.name, entry.remote_port): entry.local_port
|
|
73
|
+
for entry in service_configs
|
|
74
|
+
if entry.namespace == namespace
|
|
75
|
+
}
|
|
76
|
+
result = []
|
|
77
|
+
for svc in services:
|
|
78
|
+
preferred_port = preferred.get((svc.name, svc.port))
|
|
79
|
+
port = ensure_port(preferred_port)
|
|
80
|
+
if preferred_port and port != preferred_port:
|
|
81
|
+
logger.warning(
|
|
82
|
+
"Preferred port %d for %s:%d is not available. Using %d instead.",
|
|
83
|
+
preferred_port,
|
|
84
|
+
svc.name,
|
|
85
|
+
svc.port,
|
|
86
|
+
port,
|
|
87
|
+
)
|
|
88
|
+
result.append((svc, port))
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def watch_processes(
|
|
93
|
+
processes: list[PortForwardProcess],
|
|
94
|
+
statuses: dict[str, str],
|
|
95
|
+
stop_event: asyncio.Event,
|
|
96
|
+
on_exit: Callable[[], None] = lambda: None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Watch started port-forward processes and update statuses when they exit."""
|
|
99
|
+
|
|
100
|
+
async def _watch(process: PortForwardProcess) -> None:
|
|
101
|
+
await process.process.wait()
|
|
102
|
+
if not stop_event.is_set():
|
|
103
|
+
statuses[f"{process.service_name}:{process.remote_port}"] = (
|
|
104
|
+
f"died (exit {process.process.returncode})"
|
|
105
|
+
)
|
|
106
|
+
on_exit()
|
|
107
|
+
|
|
108
|
+
async with asyncio.TaskGroup() as tg:
|
|
109
|
+
for proc in processes:
|
|
110
|
+
tg.create_task(_watch(proc))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def make_table(
|
|
114
|
+
processes: list[PortForwardProcess],
|
|
115
|
+
statuses: dict[str, str],
|
|
116
|
+
namespace: str,
|
|
117
|
+
context: str | None,
|
|
118
|
+
) -> Table:
|
|
119
|
+
"""Build the Rich status table for the currently running port-forwards."""
|
|
120
|
+
context_str = f" [dim]({context})[/dim]" if context else ""
|
|
121
|
+
table = Table(
|
|
122
|
+
title=f"[bold]Port Forwards[/bold] — [cyan]{namespace}[/cyan]{context_str}",
|
|
123
|
+
caption="[dim]Press [bold]Ctrl+C[/bold] to stop[/dim]",
|
|
124
|
+
border_style="bright_black",
|
|
125
|
+
show_lines=False,
|
|
126
|
+
)
|
|
127
|
+
table.add_column("Service", style="bold", no_wrap=True)
|
|
128
|
+
table.add_column("Remote", style="cyan", justify="right")
|
|
129
|
+
table.add_column("Local", style="cyan", justify="right")
|
|
130
|
+
table.add_column("PID", style="dim", justify="right")
|
|
131
|
+
table.add_column("Status")
|
|
132
|
+
for fwd in processes:
|
|
133
|
+
key = f"{fwd.service_name}:{fwd.remote_port}"
|
|
134
|
+
raw = statuses.get(key, "live")
|
|
135
|
+
if raw == "live":
|
|
136
|
+
status = "[green]● live[/green]"
|
|
137
|
+
elif raw == "stopped":
|
|
138
|
+
status = "[yellow]■ stopped[/yellow]"
|
|
139
|
+
else:
|
|
140
|
+
status = f"[red]✗ {raw}[/red]"
|
|
141
|
+
table.add_row(
|
|
142
|
+
fwd.service_name,
|
|
143
|
+
f":{fwd.remote_port}",
|
|
144
|
+
f"localhost:{fwd.local_port}",
|
|
145
|
+
str(fwd.process.pid),
|
|
146
|
+
status,
|
|
147
|
+
)
|
|
148
|
+
return table
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def run_port_forwards(
|
|
152
|
+
namespace: str,
|
|
153
|
+
services: list[KubernetesService],
|
|
154
|
+
service_configs: list[ServiceConfig],
|
|
155
|
+
context: str | None,
|
|
156
|
+
) -> None:
|
|
157
|
+
loop = asyncio.get_running_loop()
|
|
158
|
+
stop_event = asyncio.Event()
|
|
159
|
+
processes: list[PortForwardProcess] = []
|
|
160
|
+
statuses: dict[str, str] = {}
|
|
161
|
+
|
|
162
|
+
for service, port in ensure_local_ports(services, service_configs, namespace):
|
|
163
|
+
process = await start_port_forward(namespace, service.name, port, service.port)
|
|
164
|
+
processes.append(process)
|
|
165
|
+
statuses[f"{service.name}:{service.port}"] = "live"
|
|
166
|
+
|
|
167
|
+
if not processes:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
with Live(
|
|
171
|
+
renderable=make_table(processes, statuses, namespace, context),
|
|
172
|
+
console=console,
|
|
173
|
+
refresh_per_second=1,
|
|
174
|
+
) as live:
|
|
175
|
+
|
|
176
|
+
def refresh() -> None:
|
|
177
|
+
live.update(make_table(processes, statuses, namespace, context))
|
|
178
|
+
|
|
179
|
+
def cleanup() -> None:
|
|
180
|
+
stop_event.set()
|
|
181
|
+
for proc in processes:
|
|
182
|
+
try:
|
|
183
|
+
proc.process.terminate()
|
|
184
|
+
statuses[f"{proc.service_name}:{proc.remote_port}"] = "stopped"
|
|
185
|
+
except ProcessLookupError:
|
|
186
|
+
pass
|
|
187
|
+
refresh()
|
|
188
|
+
|
|
189
|
+
loop.add_signal_handler(signal.SIGINT, cleanup)
|
|
190
|
+
loop.add_signal_handler(signal.SIGTERM, cleanup)
|
|
191
|
+
|
|
192
|
+
await watch_processes(
|
|
193
|
+
processes=processes,
|
|
194
|
+
statuses=statuses,
|
|
195
|
+
stop_event=stop_event,
|
|
196
|
+
on_exit=refresh,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def __get_title(service: KubernetesService) -> str:
|
|
201
|
+
return f"{service.name} :{service.port} {service.protocol}"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def __get_key(service: KubernetesService) -> tuple[str, int]:
|
|
205
|
+
return (service.name, service.port)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_service_choices(
|
|
209
|
+
available_services: list[KubernetesService],
|
|
210
|
+
running_port_forwards: list[RunningPortForward],
|
|
211
|
+
) -> list[questionary.Choice]:
|
|
212
|
+
"""Build the questionary choice list for service selection,
|
|
213
|
+
marking already-forwarded services as disabled.
|
|
214
|
+
"""
|
|
215
|
+
ports = {(r.name, r.remote_port): r.local_port for r in running_port_forwards}
|
|
216
|
+
|
|
217
|
+
choices: list[questionary.Choice] = []
|
|
218
|
+
|
|
219
|
+
inactive = [svc for svc in available_services if __get_key(svc) not in ports]
|
|
220
|
+
for svc in inactive:
|
|
221
|
+
title = __get_title(svc)
|
|
222
|
+
choices.append(questionary.Choice(title=title, value=svc))
|
|
223
|
+
|
|
224
|
+
active = [svc for svc in available_services if __get_key(svc) in ports]
|
|
225
|
+
for svc in active:
|
|
226
|
+
title = __get_title(svc)
|
|
227
|
+
disabled = f"already forwarded → localhost:{ports[__get_key(svc)]}"
|
|
228
|
+
choices.append(questionary.Choice(title=title, value=svc, disabled=disabled))
|
|
229
|
+
|
|
230
|
+
return sorted(choices, key=lambda c: str(c.title))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def select_services(
|
|
234
|
+
available_services: list[KubernetesService],
|
|
235
|
+
running_port_forwards: list[RunningPortForward],
|
|
236
|
+
) -> list[KubernetesService]:
|
|
237
|
+
"""Interactively prompt the user to select services to port-forward."""
|
|
238
|
+
choices = build_service_choices(available_services, running_port_forwards)
|
|
239
|
+
selected: list[KubernetesService] = questionary.checkbox(
|
|
240
|
+
"Select services to forward:",
|
|
241
|
+
choices=choices,
|
|
242
|
+
use_search_filter=True,
|
|
243
|
+
use_jk_keys=False,
|
|
244
|
+
style=STYLE,
|
|
245
|
+
).ask()
|
|
246
|
+
return selected
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def select_namespace(default: str | None, available_namespaces: list[str]) -> str:
|
|
250
|
+
default = default if default in available_namespaces else None
|
|
251
|
+
|
|
252
|
+
selected = questionary.select(
|
|
253
|
+
"Select a namespace:",
|
|
254
|
+
choices=available_namespaces,
|
|
255
|
+
use_search_filter=True,
|
|
256
|
+
use_jk_keys=False,
|
|
257
|
+
style=STYLE,
|
|
258
|
+
default=default,
|
|
259
|
+
).ask()
|
|
260
|
+
if not selected:
|
|
261
|
+
raise typer.Exit(code=0)
|
|
262
|
+
return selected
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def __print_error(e: subprocess.CalledProcessError, msg: str) -> None:
|
|
266
|
+
stderr = (e.stderr or "").strip()
|
|
267
|
+
logger.error("Command %s failed [%s]: %s", " ".join(e.cmd), e.returncode, stderr)
|
|
268
|
+
console.print(
|
|
269
|
+
Panel(
|
|
270
|
+
f"[red]{stderr or 'no output'}[/red]",
|
|
271
|
+
title=f"[bold red]{msg}[/bold red]",
|
|
272
|
+
border_style="red",
|
|
273
|
+
expand=False,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def __setup_logging(verbose: int) -> None:
|
|
279
|
+
logging_verbosity = [logging.WARNING, logging.INFO, logging.DEBUG]
|
|
280
|
+
level = logging_verbosity[min(verbose, len(logging_verbosity) - 1)]
|
|
281
|
+
logging.getLogger("kpf").setLevel(level)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.callback(invoke_without_command=True)
|
|
285
|
+
def port_forward(
|
|
286
|
+
config: Annotated[
|
|
287
|
+
Path | None,
|
|
288
|
+
typer.Option(
|
|
289
|
+
"--config",
|
|
290
|
+
"-c",
|
|
291
|
+
envvar="KPF_CONFIG",
|
|
292
|
+
help=f"Path to TOML config file. Defaults to {DEFAULT_CONFIG_PATH}.",
|
|
293
|
+
),
|
|
294
|
+
] = None,
|
|
295
|
+
namespace: Annotated[
|
|
296
|
+
str | None,
|
|
297
|
+
typer.Option(
|
|
298
|
+
"--namespace",
|
|
299
|
+
"-n",
|
|
300
|
+
help="Kubernetes namespace to use "
|
|
301
|
+
"(interactively selected if not provided).",
|
|
302
|
+
),
|
|
303
|
+
] = None,
|
|
304
|
+
service: Annotated[
|
|
305
|
+
list[str] | None,
|
|
306
|
+
typer.Option(
|
|
307
|
+
"--service",
|
|
308
|
+
"-s",
|
|
309
|
+
help="Service to forward. Can be specified multiple times. "
|
|
310
|
+
"Skips interactive selection.",
|
|
311
|
+
),
|
|
312
|
+
] = None,
|
|
313
|
+
verbose: Annotated[
|
|
314
|
+
int,
|
|
315
|
+
typer.Option(
|
|
316
|
+
"--verbose",
|
|
317
|
+
"-v",
|
|
318
|
+
count=True,
|
|
319
|
+
help="Verbose output. Use -vv for more detail.",
|
|
320
|
+
),
|
|
321
|
+
] = 0,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Interactive kubectl port-forward for Kubernetes services."""
|
|
324
|
+
|
|
325
|
+
__setup_logging(verbose)
|
|
326
|
+
|
|
327
|
+
context = get_current_context()
|
|
328
|
+
if context:
|
|
329
|
+
console.print(f"[dim]Context:[/dim] [cyan]{context}[/cyan]")
|
|
330
|
+
|
|
331
|
+
cfg = load_config(config)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
with console.status("[bold blue]Fetching namespaces…[/bold blue]"):
|
|
335
|
+
namespaces = get_available_namespaces()
|
|
336
|
+
except subprocess.CalledProcessError as e:
|
|
337
|
+
__print_error(e, "Failed to fetch namespaces using kubectl")
|
|
338
|
+
raise typer.Exit(code=1) from None
|
|
339
|
+
|
|
340
|
+
if namespace is None:
|
|
341
|
+
namespace = select_namespace(
|
|
342
|
+
default=cfg.default_namespace, available_namespaces=namespaces
|
|
343
|
+
)
|
|
344
|
+
elif namespace not in namespaces:
|
|
345
|
+
console.print(f"[red]Namespace [bold]{namespace}[/bold] not found.[/red]")
|
|
346
|
+
raise typer.Exit(code=1)
|
|
347
|
+
else:
|
|
348
|
+
console.print(
|
|
349
|
+
f"[bold {QMARK_COLOR}]{DEFAULT_QUESTION_PREFIX}"
|
|
350
|
+
f"[/bold {QMARK_COLOR}] [bold]Select a namespace:[/bold]"
|
|
351
|
+
f" [bold {ANSWER_COLOR}]{namespace}[/bold {ANSWER_COLOR}]"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
with console.status(
|
|
356
|
+
f"[bold blue]Fetching services in [cyan]{namespace}[/cyan]…[/bold blue]"
|
|
357
|
+
):
|
|
358
|
+
available_services = get_services(namespace)
|
|
359
|
+
except subprocess.CalledProcessError as e:
|
|
360
|
+
__print_error(
|
|
361
|
+
e, f"Failed to fetch services in namespace {namespace} using kubectl"
|
|
362
|
+
)
|
|
363
|
+
raise typer.Exit(code=1) from None
|
|
364
|
+
|
|
365
|
+
if not available_services:
|
|
366
|
+
console.print(
|
|
367
|
+
f"[yellow]No services found in namespace [bold]{namespace}[/bold].[/yellow]"
|
|
368
|
+
)
|
|
369
|
+
raise typer.Exit(code=0)
|
|
370
|
+
|
|
371
|
+
if service:
|
|
372
|
+
available_services_map = {s.name: s for s in available_services}
|
|
373
|
+
not_found = {name for name in service if name not in available_services_map}
|
|
374
|
+
if not_found:
|
|
375
|
+
console.print(
|
|
376
|
+
f"[red]Services not found in namespace "
|
|
377
|
+
f"[bold]{namespace}[/bold]: {', '.join(not_found)}[/red]"
|
|
378
|
+
)
|
|
379
|
+
raise typer.Exit(code=1)
|
|
380
|
+
selected = [available_services_map[name] for name in service]
|
|
381
|
+
else:
|
|
382
|
+
running_services = find_running_port_forwards(available_services)
|
|
383
|
+
selected = select_services(
|
|
384
|
+
available_services=available_services,
|
|
385
|
+
running_port_forwards=running_services,
|
|
386
|
+
)
|
|
387
|
+
if not selected:
|
|
388
|
+
console.print("[yellow]No services selected. Exiting.[/yellow]")
|
|
389
|
+
raise typer.Exit(code=0)
|
|
390
|
+
asyncio.run(run_port_forwards(namespace, selected, cfg.ports, context))
|
portfwd/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG_PATH = (
|
|
13
|
+
Path(
|
|
14
|
+
os.environ.get(
|
|
15
|
+
"XDG_CONFIG_HOME",
|
|
16
|
+
Path.home() / ".config",
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
/ "kpf"
|
|
20
|
+
/ "config.toml"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ServiceConfig(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
name: str = Field(min_length=1)
|
|
28
|
+
namespace: str = Field(min_length=1)
|
|
29
|
+
remote_port: int = Field(ge=1, le=65535)
|
|
30
|
+
local_port: int = Field(ge=1, le=65535)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Config(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
|
|
36
|
+
default_namespace: str | None = None
|
|
37
|
+
ports: list[ServiceConfig] = []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(path: Path | None) -> Config:
|
|
41
|
+
if path is None:
|
|
42
|
+
path = DEFAULT_CONFIG_PATH
|
|
43
|
+
|
|
44
|
+
default_config = Config()
|
|
45
|
+
|
|
46
|
+
if not path.exists():
|
|
47
|
+
logger.debug("No config file found at %s, using defaults", path)
|
|
48
|
+
return default_config
|
|
49
|
+
|
|
50
|
+
with path.open("rb") as f:
|
|
51
|
+
data = tomllib.load(f)
|
|
52
|
+
try:
|
|
53
|
+
config = Config.model_validate(data)
|
|
54
|
+
logger.info("Loaded config from %s", path)
|
|
55
|
+
return config
|
|
56
|
+
except ValidationError as e:
|
|
57
|
+
logger.warning(
|
|
58
|
+
"Failed to parse config file at %s. Using defaults instead. "
|
|
59
|
+
"To fix this, ensure your config file is valid TOML "
|
|
60
|
+
"and matches the expected schema.",
|
|
61
|
+
path,
|
|
62
|
+
)
|
|
63
|
+
logger.warning("%s", e)
|
|
64
|
+
|
|
65
|
+
return default_config
|
portfwd/kube.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
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,
|
|
46
|
+
stripping EKS ARN prefix if present.
|
|
47
|
+
"""
|
|
48
|
+
assert raw is not None, "Context string is empty"
|
|
49
|
+
context = raw.strip()
|
|
50
|
+
if context.startswith("arn:aws:eks:"):
|
|
51
|
+
return context.split("/")[-1]
|
|
52
|
+
return context
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_namespaces(raw: str) -> list[str]:
|
|
56
|
+
"""Parse namespace names from `kubectl get namespaces -o json` output."""
|
|
57
|
+
data = json.loads(raw)
|
|
58
|
+
return [el["metadata"]["name"] for el in data["items"]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_services(raw: str) -> list[KubernetesService]:
|
|
62
|
+
"""Parse services from `kubectl get services -o json` output,
|
|
63
|
+
skipping the built-in kubernetes service.
|
|
64
|
+
"""
|
|
65
|
+
data = json.loads(raw)
|
|
66
|
+
services: list[KubernetesService] = []
|
|
67
|
+
for svc in data["items"]:
|
|
68
|
+
name = svc["metadata"]["name"]
|
|
69
|
+
if name == "kubernetes":
|
|
70
|
+
continue
|
|
71
|
+
for port in svc["spec"]["ports"]:
|
|
72
|
+
services.append(
|
|
73
|
+
KubernetesService(
|
|
74
|
+
name=name,
|
|
75
|
+
port=port["port"],
|
|
76
|
+
protocol=port["protocol"],
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return sorted(services, key=lambda x: (x.name, x.port))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_current_context() -> str:
|
|
83
|
+
"""Get the current kubectl context, extracting cluster name from ARN if present."""
|
|
84
|
+
return parse_context(__call_subprocess(["kubectl", "config", "current-context"]))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_available_namespaces() -> list[str]:
|
|
88
|
+
"""Get the list of available Kubernetes namespaces using kubectl."""
|
|
89
|
+
return parse_namespaces(
|
|
90
|
+
__call_subprocess(["kubectl", "get", "namespaces", "-o", "json"])
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_services(namespace: str) -> list[KubernetesService]:
|
|
95
|
+
"""Get the list of services with their ports in the specified namespace."""
|
|
96
|
+
return parse_services(
|
|
97
|
+
__call_subprocess(["kubectl", "get", "services", "-n", namespace, "-o", "json"])
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_running_port_forwards(
|
|
102
|
+
services: list[KubernetesService],
|
|
103
|
+
) -> list[RunningPortForward]:
|
|
104
|
+
"""Find running kubectl port-forward processes that match the given services."""
|
|
105
|
+
known_ports = {(svc.name, svc.port) for svc in services}
|
|
106
|
+
|
|
107
|
+
kubectl_procs = [
|
|
108
|
+
proc
|
|
109
|
+
for proc in psutil.process_iter(["pid", "name", "cmdline"])
|
|
110
|
+
if proc.info["name"] == "kubectl"
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
running: list[RunningPortForward] = []
|
|
114
|
+
for proc in kubectl_procs:
|
|
115
|
+
cmdline = proc.info["cmdline"]
|
|
116
|
+
svc_match = re.search(
|
|
117
|
+
r"(?:svc|service)/(?P<name>[a-zA-Z0-9-]+)", " ".join(cmdline)
|
|
118
|
+
)
|
|
119
|
+
if not svc_match:
|
|
120
|
+
continue
|
|
121
|
+
service_name = svc_match.group("name")
|
|
122
|
+
|
|
123
|
+
for arg in cmdline:
|
|
124
|
+
port_match = re.fullmatch(r"(?:(?P<local>\d+):)?(?P<remote>\d+)", arg)
|
|
125
|
+
if not port_match:
|
|
126
|
+
continue
|
|
127
|
+
remote_port = int(port_match.group("remote"))
|
|
128
|
+
local_port = (
|
|
129
|
+
int(port_match.group("local"))
|
|
130
|
+
if port_match.group("local")
|
|
131
|
+
else remote_port
|
|
132
|
+
)
|
|
133
|
+
if (service_name, remote_port) in known_ports:
|
|
134
|
+
running.append(
|
|
135
|
+
RunningPortForward(
|
|
136
|
+
name=service_name,
|
|
137
|
+
remote_port=remote_port,
|
|
138
|
+
local_port=local_port,
|
|
139
|
+
pid=proc.info["pid"],
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return running
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def start_port_forward(
|
|
147
|
+
namespace: str, service: str, local_port: int, remote_port: int
|
|
148
|
+
) -> PortForwardProcess:
|
|
149
|
+
"""Start a kubectl port-forward process for the specified service and port."""
|
|
150
|
+
cmd = [
|
|
151
|
+
"kubectl",
|
|
152
|
+
"port-forward",
|
|
153
|
+
f"svc/{service}",
|
|
154
|
+
f"{local_port}:{remote_port}",
|
|
155
|
+
"-n",
|
|
156
|
+
namespace,
|
|
157
|
+
]
|
|
158
|
+
logger.debug(" ".join(cmd))
|
|
159
|
+
process = await asyncio.create_subprocess_exec(
|
|
160
|
+
*cmd,
|
|
161
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
162
|
+
stderr=None,
|
|
163
|
+
)
|
|
164
|
+
logger.debug(
|
|
165
|
+
"Started port forward for %s:%d → localhost:%d [PID: %d]",
|
|
166
|
+
service,
|
|
167
|
+
remote_port,
|
|
168
|
+
local_port,
|
|
169
|
+
process.pid,
|
|
170
|
+
)
|
|
171
|
+
return PortForwardProcess(
|
|
172
|
+
process=process,
|
|
173
|
+
local_port=local_port,
|
|
174
|
+
remote_port=remote_port,
|
|
175
|
+
service_name=service,
|
|
176
|
+
)
|
portfwd/utils.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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,
|
|
16
|
+
which tells the OS to select an available port.
|
|
17
|
+
"""
|
|
18
|
+
# TOCTOU: the port is freed before kubectl binds it, so another process
|
|
19
|
+
# could claim it in the window. Unavoidable without passing a pre-bound
|
|
20
|
+
# socket, which kubectl does not support.
|
|
21
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
22
|
+
s.bind(("", 0))
|
|
23
|
+
return s.getsockname()[1]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_port(preferred: int | None) -> int:
|
|
27
|
+
"""Return the preferred port if it's free, otherwise find a free port."""
|
|
28
|
+
if preferred and is_port_free(preferred):
|
|
29
|
+
return preferred
|
|
30
|
+
else:
|
|
31
|
+
return find_free_port()
|