kx-cli 0.0.1__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.
- kx/__init__.py +0 -0
- kx/events.py +43 -0
- kx/graph.py +89 -0
- kx/index.py +56 -0
- kx/k8s.py +7 -0
- kx/kubectl.py +16 -0
- kx/main.py +166 -0
- kx/state.py +23 -0
- kx_cli-0.0.1.dist-info/METADATA +7 -0
- kx_cli-0.0.1.dist-info/RECORD +13 -0
- kx_cli-0.0.1.dist-info/WHEEL +5 -0
- kx_cli-0.0.1.dist-info/entry_points.txt +2 -0
- kx_cli-0.0.1.dist-info/top_level.txt +1 -0
kx/__init__.py
ADDED
|
File without changes
|
kx/events.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from kubernetes import client
|
|
2
|
+
from kx.k8s import load_k8s
|
|
3
|
+
|
|
4
|
+
_KIND_MAP = {
|
|
5
|
+
"pod": "Pod", "pods": "Pod",
|
|
6
|
+
"deployment": "Deployment", "deployments": "Deployment", "deploy": "Deployment",
|
|
7
|
+
"replicaset": "ReplicaSet", "replicasets": "ReplicaSet", "rs": "ReplicaSet",
|
|
8
|
+
"statefulset": "StatefulSet", "statefulsets": "StatefulSet", "sts": "StatefulSet",
|
|
9
|
+
"daemonset": "DaemonSet", "daemonsets": "DaemonSet", "ds": "DaemonSet",
|
|
10
|
+
"hpa": "HorizontalPodAutoscaler",
|
|
11
|
+
"horizontalpodautoscaler": "HorizontalPodAutoscaler",
|
|
12
|
+
"horizontalpodautoscalers": "HorizontalPodAutoscaler",
|
|
13
|
+
"service": "Service", "services": "Service", "svc": "Service",
|
|
14
|
+
"ingress": "Ingress", "ingresses": "Ingress",
|
|
15
|
+
"configmap": "ConfigMap", "configmaps": "ConfigMap", "cm": "ConfigMap",
|
|
16
|
+
"secret": "Secret", "secrets": "Secret",
|
|
17
|
+
"job": "Job", "jobs": "Job",
|
|
18
|
+
"cronjob": "CronJob", "cronjobs": "CronJob",
|
|
19
|
+
"pvc": "PersistentVolumeClaim",
|
|
20
|
+
"persistentvolumeclaim": "PersistentVolumeClaim",
|
|
21
|
+
"persistentvolumeclaims": "PersistentVolumeClaim",
|
|
22
|
+
"node": "Node", "nodes": "Node",
|
|
23
|
+
"namespace": "Namespace", "namespaces": "Namespace",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_kind(resource_type: str) -> str:
|
|
28
|
+
return _KIND_MAP.get(resource_type.lower(), resource_type)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_events(namespace: str):
|
|
32
|
+
load_k8s()
|
|
33
|
+
v1 = client.CoreV1Api()
|
|
34
|
+
|
|
35
|
+
return v1.list_namespaced_event(namespace=namespace).items
|
|
36
|
+
|
|
37
|
+
def filter_events(events, name: str, kind: str):
|
|
38
|
+
normalized = normalize_kind(kind)
|
|
39
|
+
return [
|
|
40
|
+
e for e in events
|
|
41
|
+
if e.involved_object.name == name
|
|
42
|
+
and e.involved_object.kind == normalized
|
|
43
|
+
]
|
kx/graph.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from kubernetes import client
|
|
2
|
+
from rich.tree import Tree
|
|
3
|
+
|
|
4
|
+
from kx.k8s import load_k8s
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_tree(kind: str, name: str, namespace: str) -> Tree:
|
|
8
|
+
load_k8s()
|
|
9
|
+
root = Tree(f"[bold]{kind}/{name}[/bold]")
|
|
10
|
+
|
|
11
|
+
apps = client.AppsV1Api()
|
|
12
|
+
core = client.CoreV1Api()
|
|
13
|
+
batch = client.BatchV1Api()
|
|
14
|
+
|
|
15
|
+
pods = core.list_namespaced_pod(namespace).items
|
|
16
|
+
|
|
17
|
+
if kind == "Deployment":
|
|
18
|
+
_tree_deployment(name, namespace, root, apps, pods)
|
|
19
|
+
elif kind == "ReplicaSet":
|
|
20
|
+
_tree_replica_set(name, namespace, root, apps, pods)
|
|
21
|
+
elif kind == "StatefulSet":
|
|
22
|
+
_tree_stateful_set(name, namespace, root, apps, pods)
|
|
23
|
+
elif kind == "DaemonSet":
|
|
24
|
+
_tree_daemon_set(name, namespace, root, apps, pods)
|
|
25
|
+
elif kind == "CronJob":
|
|
26
|
+
_tree_cron_job(name, namespace, root, batch, pods)
|
|
27
|
+
elif kind == "Pod":
|
|
28
|
+
pod = core.read_namespaced_pod(name, namespace)
|
|
29
|
+
_add_containers(pod, root)
|
|
30
|
+
else:
|
|
31
|
+
root.add(f"[dim](no ownership graph for {kind})[/dim]")
|
|
32
|
+
|
|
33
|
+
return root
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _tree_deployment(name, namespace, node, apps, pods):
|
|
37
|
+
deploy = apps.read_namespaced_deployment(name, namespace)
|
|
38
|
+
uid = deploy.metadata.uid
|
|
39
|
+
replica_sets = [
|
|
40
|
+
rs for rs in apps.list_namespaced_replica_set(namespace).items
|
|
41
|
+
if _owned_by(rs, uid)
|
|
42
|
+
]
|
|
43
|
+
for rs in replica_sets:
|
|
44
|
+
rs_node = node.add(f"[green]rs/{rs.metadata.name}[/green]")
|
|
45
|
+
_add_pods_for_owner(rs.metadata.uid, pods, rs_node)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _tree_replica_set(name, namespace, node, apps, pods):
|
|
49
|
+
rs = apps.read_namespaced_replica_set(name, namespace)
|
|
50
|
+
_add_pods_for_owner(rs.metadata.uid, pods, node)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tree_stateful_set(name, namespace, node, apps, pods):
|
|
54
|
+
sts = apps.read_namespaced_stateful_set(name, namespace)
|
|
55
|
+
_add_pods_for_owner(sts.metadata.uid, pods, node)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tree_daemon_set(name, namespace, node, apps, pods):
|
|
59
|
+
ds = apps.read_namespaced_daemon_set(name, namespace)
|
|
60
|
+
_add_pods_for_owner(ds.metadata.uid, pods, node)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _tree_cron_job(name, namespace, node, batch, pods):
|
|
64
|
+
cj = batch.read_namespaced_cron_job(name, namespace)
|
|
65
|
+
uid = cj.metadata.uid
|
|
66
|
+
jobs = [
|
|
67
|
+
j for j in batch.list_namespaced_job(namespace).items
|
|
68
|
+
if _owned_by(j, uid)
|
|
69
|
+
]
|
|
70
|
+
for job in jobs:
|
|
71
|
+
job_node = node.add(f"[green]job/{job.metadata.name}[/green]")
|
|
72
|
+
_add_pods_for_owner(job.metadata.uid, pods, job_node)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _add_pods_for_owner(owner_uid, pods, parent_node):
|
|
76
|
+
owned = [p for p in pods if _owned_by(p, owner_uid)]
|
|
77
|
+
for pod in owned:
|
|
78
|
+
pod_node = parent_node.add(f"[blue]pod/{pod.metadata.name}[/blue]")
|
|
79
|
+
_add_containers(pod, pod_node)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _add_containers(pod, parent_node):
|
|
83
|
+
for container in pod.spec.containers:
|
|
84
|
+
parent_node.add(f"[cyan]container: {container.name}[/cyan]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _owned_by(resource, uid: str) -> bool:
|
|
88
|
+
refs = resource.metadata.owner_references or []
|
|
89
|
+
return any(ref.uid == uid for ref in refs)
|
kx/index.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def resolve_index(state, index: int) -> str:
|
|
5
|
+
try:
|
|
6
|
+
return state.names[index - 1]
|
|
7
|
+
except IndexError:
|
|
8
|
+
typer.echo("Invalid index")
|
|
9
|
+
raise typer.Exit(1)
|
|
10
|
+
|
|
11
|
+
def add_indexes(output: str) -> tuple[str, list[str]]:
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
lines = output.splitlines()
|
|
15
|
+
if not lines:
|
|
16
|
+
return output, []
|
|
17
|
+
|
|
18
|
+
header = lines[0]
|
|
19
|
+
rows = lines[1:]
|
|
20
|
+
|
|
21
|
+
# compute column boundaries from header
|
|
22
|
+
spans = [(m.start(), m.end()) for m in re.finditer(r"\S+\s*", header)]
|
|
23
|
+
|
|
24
|
+
headers = [header[s:e].strip() for s, e in spans]
|
|
25
|
+
name_idx = headers.index("NAME")
|
|
26
|
+
|
|
27
|
+
names = []
|
|
28
|
+
|
|
29
|
+
parsed_rows = []
|
|
30
|
+
for r in rows:
|
|
31
|
+
if not r.strip():
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
cols = [r[s:e].strip() for s, e in spans]
|
|
35
|
+
|
|
36
|
+
if len(cols) <= name_idx:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
names.append(cols[name_idx])
|
|
40
|
+
parsed_rows.append(cols)
|
|
41
|
+
|
|
42
|
+
# add index column (X)
|
|
43
|
+
headers = ["X"] + headers
|
|
44
|
+
parsed_rows = [[str(i + 1)] + r for i, r in enumerate(parsed_rows)]
|
|
45
|
+
|
|
46
|
+
# compute widths
|
|
47
|
+
cols = list(zip(headers, *parsed_rows))
|
|
48
|
+
widths = [max(len(str(cell)) for cell in col) for col in cols]
|
|
49
|
+
|
|
50
|
+
def fmt(row):
|
|
51
|
+
return " ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row))
|
|
52
|
+
|
|
53
|
+
out = [fmt(headers)]
|
|
54
|
+
out += [fmt(r) for r in parsed_rows]
|
|
55
|
+
|
|
56
|
+
return "\n".join(out), names
|
kx/k8s.py
ADDED
kx/kubectl.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def run_kubectl(args: list[str]) -> str:
|
|
5
|
+
result = subprocess.run(
|
|
6
|
+
["kubectl", *args],
|
|
7
|
+
capture_output=True,
|
|
8
|
+
text=True,
|
|
9
|
+
check=True,
|
|
10
|
+
)
|
|
11
|
+
return result.stdout
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_kubectl_interactive(args: list[str]) -> int:
|
|
15
|
+
result = subprocess.run(["kubectl", *args])
|
|
16
|
+
return result.returncode
|
kx/main.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from kx.kubectl import run_kubectl, run_kubectl_interactive
|
|
6
|
+
from kx.index import add_indexes, resolve_index
|
|
7
|
+
from kx.state import KxState, save_state, load_state
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="kx - kubectl extended with indexing")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_current_namespace() -> str:
|
|
13
|
+
result = subprocess.run(
|
|
14
|
+
["kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}"],
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
check=True,
|
|
18
|
+
)
|
|
19
|
+
ns = result.stdout.strip()
|
|
20
|
+
return ns if ns else "default"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _state_fields(index: int) -> tuple[str, str, str]:
|
|
24
|
+
state = load_state()
|
|
25
|
+
name = resolve_index(state, index)
|
|
26
|
+
return name, state.namespace, state.resource_type
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def get(
|
|
31
|
+
resource: str,
|
|
32
|
+
namespace: str = typer.Option(None, "-n", help="Kubernetes namespace"),
|
|
33
|
+
):
|
|
34
|
+
"""List resources and assign index numbers for use with other commands."""
|
|
35
|
+
if namespace is None:
|
|
36
|
+
namespace = _get_current_namespace()
|
|
37
|
+
|
|
38
|
+
output = run_kubectl(["get", resource, "-n", namespace])
|
|
39
|
+
indexed_output, names = add_indexes(output)
|
|
40
|
+
typer.echo(indexed_output)
|
|
41
|
+
save_state(KxState(resource_type=resource, names=names, namespace=namespace))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def describe(
|
|
46
|
+
index: int,
|
|
47
|
+
view: str = typer.Option("full", help="Output view: full or events"),
|
|
48
|
+
):
|
|
49
|
+
"""Show full kubectl describe output for an indexed resource."""
|
|
50
|
+
name, namespace, kind = _state_fields(index)
|
|
51
|
+
|
|
52
|
+
if view == "events":
|
|
53
|
+
from kx.events import get_events, filter_events
|
|
54
|
+
|
|
55
|
+
all_events = get_events(namespace)
|
|
56
|
+
events = filter_events(all_events, name, kind)
|
|
57
|
+
|
|
58
|
+
if not events:
|
|
59
|
+
typer.echo("No events found")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
for e in events:
|
|
63
|
+
obj = e.involved_object
|
|
64
|
+
typer.echo(
|
|
65
|
+
f"{e.type:8} {e.reason:30} "
|
|
66
|
+
f"{obj.kind:10} {e.metadata.creation_timestamp} "
|
|
67
|
+
f"{e.message}"
|
|
68
|
+
)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
subprocess.run(["kubectl", "describe", kind, name, "-n", namespace])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def events(index: int):
|
|
76
|
+
"""Show Kubernetes events for an indexed resource."""
|
|
77
|
+
from kx.events import get_events, filter_events
|
|
78
|
+
|
|
79
|
+
name, namespace, kind = _state_fields(index)
|
|
80
|
+
all_events = get_events(namespace)
|
|
81
|
+
filtered = filter_events(all_events, name, kind)
|
|
82
|
+
|
|
83
|
+
if not filtered:
|
|
84
|
+
typer.echo("No events found")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
for e in filtered:
|
|
88
|
+
obj = e.involved_object
|
|
89
|
+
typer.echo(
|
|
90
|
+
f"{e.type:8} {e.reason:30} "
|
|
91
|
+
f"{obj.kind:10} {e.metadata.creation_timestamp} "
|
|
92
|
+
f"{e.message}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def logs(index: int):
|
|
98
|
+
"""Stream logs for an indexed pod."""
|
|
99
|
+
name, namespace, kind = _state_fields(index)
|
|
100
|
+
|
|
101
|
+
if kind != "pod":
|
|
102
|
+
typer.echo("Logs are only supported on pods.")
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
args = ["logs", name, "-n", namespace]
|
|
106
|
+
typer.echo(run_kubectl(args))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def yaml(index: int):
|
|
111
|
+
"""Print the raw YAML manifest for an indexed resource."""
|
|
112
|
+
name, namespace, kind = _state_fields(index)
|
|
113
|
+
typer.echo(run_kubectl(["get", kind, name, "-n", namespace, "-o", "yaml"]))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def delete(
|
|
118
|
+
index: int,
|
|
119
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
120
|
+
):
|
|
121
|
+
"""Delete an indexed resource (prompts for confirmation unless --yes)."""
|
|
122
|
+
name, namespace, kind = _state_fields(index)
|
|
123
|
+
if not yes:
|
|
124
|
+
typer.confirm(f"Delete {kind}/{name} in {namespace}?", abort=True)
|
|
125
|
+
run_kubectl(["delete", kind, name, "-n", namespace])
|
|
126
|
+
typer.echo(f"Deleted {kind}/{name}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def edit(index: int):
|
|
131
|
+
"""Open an indexed resource in your editor via kubectl edit."""
|
|
132
|
+
name, namespace, kind = _state_fields(index)
|
|
133
|
+
run_kubectl_interactive(["edit", kind, name, "-n", namespace])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command(name="exec")
|
|
137
|
+
def exec_cmd(
|
|
138
|
+
index: int,
|
|
139
|
+
cmd: list[str] = typer.Argument(default=None, help="Command to run (default: bash with sh fallback)"),
|
|
140
|
+
):
|
|
141
|
+
"""Open an interactive shell in an indexed pod (bash, falling back to sh)."""
|
|
142
|
+
name, namespace, kind = _state_fields(index)
|
|
143
|
+
if kind.lower() not in ("pod", "pods"):
|
|
144
|
+
typer.echo("exec is only supported for pods.")
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
if cmd:
|
|
147
|
+
run_kubectl_interactive(["exec", "-it", name, "-n", namespace, "--", *cmd])
|
|
148
|
+
else:
|
|
149
|
+
rc = run_kubectl_interactive(["exec", "-it", name, "-n", namespace, "--", "bash"])
|
|
150
|
+
if rc != 0:
|
|
151
|
+
run_kubectl_interactive(["exec", "-it", name, "-n", namespace, "--", "sh"])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def tree(index: int):
|
|
156
|
+
"""Show the ownership graph for an indexed resource (deployments, statefulsets, etc.)."""
|
|
157
|
+
from kx.events import normalize_kind
|
|
158
|
+
from kx.graph import build_tree
|
|
159
|
+
from rich.console import Console
|
|
160
|
+
|
|
161
|
+
name, namespace, kind = _state_fields(index)
|
|
162
|
+
Console().print(build_tree(normalize_kind(kind), name, namespace))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
app()
|
kx/state.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from dataclasses import dataclass, asdict
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
STATE_FILE = Path.home() / ".kx_state.json"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class KxState:
|
|
10
|
+
resource_type: str
|
|
11
|
+
names: list[str]
|
|
12
|
+
namespace: str = "default"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def save_state(state: KxState) -> None:
|
|
16
|
+
STATE_FILE.write_text(json.dumps(asdict(state)))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_state() -> KxState:
|
|
20
|
+
if not STATE_FILE.exists():
|
|
21
|
+
raise RuntimeError("No state found. Run `kx get <resource>` first.")
|
|
22
|
+
data = json.loads(STATE_FILE.read_text())
|
|
23
|
+
return KxState(**data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
kx/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
kx/events.py,sha256=yUBgELf7NhMTlLK86sRwN3QBPmAhEiumRnqmP0GV-Ag,1627
|
|
3
|
+
kx/graph.py,sha256=jJy84CUY-x5r-1Lrz10I1w7DYRA2L54ycgaeJPo0bW0,2923
|
|
4
|
+
kx/index.py,sha256=OofJB2sW53QmV5VZTSaVZ3p2-WWWj3EddDiOWF16LsU,1344
|
|
5
|
+
kx/k8s.py,sha256=Q1dlnlFk-xRE7ZoViZdtZZEI76UUVHXoT9eARxbJqe0,158
|
|
6
|
+
kx/kubectl.py,sha256=mq09eQgOPNiyqO564byiSAeNiVY0CLTDageqa47taNk,348
|
|
7
|
+
kx/main.py,sha256=rIxN1uqs8fJYX_D4vp6c7vRwQ4vyz0VL9l1DPotXLNM,4998
|
|
8
|
+
kx/state.py,sha256=W9puXcyiQCHSWp5JRhqB3puc6ktqjwTaegJRG-YBOT4,535
|
|
9
|
+
kx_cli-0.0.1.dist-info/METADATA,sha256=g6KwISxwWno01JtEXyQ1khmWlqoIJi7ZaxIUaY6buBU,141
|
|
10
|
+
kx_cli-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
kx_cli-0.0.1.dist-info/entry_points.txt,sha256=7FKIfh_28wCHnIO0TFsW70g2JqsoRsSTjIK161QYQwQ,35
|
|
12
|
+
kx_cli-0.0.1.dist-info/top_level.txt,sha256=HMijR3s083ws0pe6Fso-IU3si7RWxFUEY7BCKg15G5E,3
|
|
13
|
+
kx_cli-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kx
|