kube-query 0.1.0__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.
- kube_query/__init__.py +3 -0
- kube_query/access.py +85 -0
- kube_query/cleanup.py +115 -0
- kube_query/cli.py +81 -0
- kube_query/cli_kit.py +175 -0
- kube_query/commands/__init__.py +92 -0
- kube_query/commands/_impl.py +147 -0
- kube_query/commands/api_resources.py +66 -0
- kube_query/commands/contexts.py +94 -0
- kube_query/commands/describe.py +168 -0
- kube_query/commands/eso_cmd.py +44 -0
- kube_query/commands/events_cmd.py +67 -0
- kube_query/commands/get.py +52 -0
- kube_query/commands/groups.py +166 -0
- kube_query/commands/helm_cmd.py +109 -0
- kube_query/commands/logs.py +73 -0
- kube_query/commands/overview.py +347 -0
- kube_query/commands/tree_cmd.py +47 -0
- kube_query/commands/version.py +57 -0
- kube_query/config.py +210 -0
- kube_query/discovery.py +191 -0
- kube_query/errors.py +45 -0
- kube_query/eso.py +81 -0
- kube_query/events.py +104 -0
- kube_query/helm.py +260 -0
- kube_query/http.py +101 -0
- kube_query/output.py +215 -0
- kube_query/py.typed +0 -0
- kube_query/summarize.py +391 -0
- kube_query/tree.py +210 -0
- kube_query-0.1.0.dist-info/METADATA +160 -0
- kube_query-0.1.0.dist-info/RECORD +36 -0
- kube_query-0.1.0.dist-info/WHEEL +5 -0
- kube_query-0.1.0.dist-info/entry_points.txt +3 -0
- kube_query-0.1.0.dist-info/licenses/LICENSE +21 -0
- kube_query-0.1.0.dist-info/top_level.txt +1 -0
kube_query/__init__.py
ADDED
kube_query/access.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Object-level access: list/get any resource by its ApiResource, read pod logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from .config import Ctx
|
|
8
|
+
from .discovery import ApiResource
|
|
9
|
+
from .errors import KqApiError, KqError
|
|
10
|
+
from .http import get_json, get_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resource_path(entry: ApiResource, namespace: str | None = None, name: str | None = None) -> str:
|
|
14
|
+
if entry.group:
|
|
15
|
+
base = f"/apis/{quote(entry.api_version, safe='/')}"
|
|
16
|
+
else:
|
|
17
|
+
base = f"/api/{entry.version}"
|
|
18
|
+
if entry.namespaced and namespace:
|
|
19
|
+
base += f"/namespaces/{quote(namespace, safe='')}"
|
|
20
|
+
base += f"/{entry.plural}"
|
|
21
|
+
if name:
|
|
22
|
+
base += f"/{quote(name, safe='')}"
|
|
23
|
+
return base
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_objects(
|
|
27
|
+
ctx: Ctx,
|
|
28
|
+
entry: ApiResource,
|
|
29
|
+
*,
|
|
30
|
+
namespace: str | None = None,
|
|
31
|
+
label_selector: str | None = None,
|
|
32
|
+
field_selector: str | None = None,
|
|
33
|
+
) -> list[dict]:
|
|
34
|
+
"""List objects. ``namespace=None`` on a namespaced resource lists all namespaces."""
|
|
35
|
+
if not entry.namespaced and namespace:
|
|
36
|
+
namespace = None
|
|
37
|
+
params = {"labelSelector": label_selector, "fieldSelector": field_selector}
|
|
38
|
+
data = get_json(ctx.api_client, resource_path(entry, namespace), params)
|
|
39
|
+
items = data.get("items")
|
|
40
|
+
if not isinstance(items, list):
|
|
41
|
+
raise KqError(f"unexpected response while listing {entry.plural}")
|
|
42
|
+
return items
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_object(ctx: Ctx, entry: ApiResource, name: str, *, namespace: str | None = None) -> dict:
|
|
46
|
+
if entry.namespaced and not namespace:
|
|
47
|
+
raise KqError(f"internal: namespace required to fetch a namespaced {entry.kind}")
|
|
48
|
+
try:
|
|
49
|
+
return get_json(ctx.api_client, resource_path(entry, namespace, name))
|
|
50
|
+
except KqApiError as e:
|
|
51
|
+
if e.status == 404:
|
|
52
|
+
where = f" in namespace '{namespace}'" if entry.namespaced else ""
|
|
53
|
+
scope = f" -n {namespace}" if entry.namespaced else ""
|
|
54
|
+
alt = " (or -A for all namespaces)" if entry.namespaced else ""
|
|
55
|
+
raise KqError(
|
|
56
|
+
f"{entry.kind} '{name}' not found{where}.\n"
|
|
57
|
+
f" Hint: list candidates with 'kq get {entry.plural}{scope}'{alt}."
|
|
58
|
+
) from None
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pod_logs(
|
|
63
|
+
ctx: Ctx,
|
|
64
|
+
namespace: str,
|
|
65
|
+
pod: str,
|
|
66
|
+
*,
|
|
67
|
+
container: str | None = None,
|
|
68
|
+
tail: int | None = None,
|
|
69
|
+
previous: bool = False,
|
|
70
|
+
since_seconds: int | None = None,
|
|
71
|
+
timestamps: bool = False,
|
|
72
|
+
) -> str:
|
|
73
|
+
path = f"/api/v1/namespaces/{quote(namespace, safe='')}/pods/{quote(pod, safe='')}/log"
|
|
74
|
+
params = {
|
|
75
|
+
"container": container,
|
|
76
|
+
"tailLines": tail,
|
|
77
|
+
"previous": "true" if previous else None,
|
|
78
|
+
"sinceSeconds": since_seconds,
|
|
79
|
+
"timestamps": "true" if timestamps else None,
|
|
80
|
+
}
|
|
81
|
+
return get_text(ctx.api_client, path, params)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def server_version(ctx: Ctx) -> dict:
|
|
85
|
+
return get_json(ctx.api_client, "/version")
|
kube_query/cleanup.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Output compaction: strip agent-irrelevant noise from Kubernetes objects.
|
|
2
|
+
|
|
3
|
+
Verbosity levels::
|
|
4
|
+
|
|
5
|
+
0 (default) drop managedFields, uid, resourceVersion, generation,
|
|
6
|
+
selfLink, noisy annotations, and all empty values
|
|
7
|
+
1 (-v) drop managedFields and noisy annotations only
|
|
8
|
+
2 (-vv) raw object, nothing is stripped
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
|
|
15
|
+
NOISY_META = ("managedFields", "selfLink", "resourceVersion", "uid", "generation")
|
|
16
|
+
NOISY_ANNOTATIONS = (
|
|
17
|
+
"kubectl.kubernetes.io/last-applied-configuration",
|
|
18
|
+
"deployment.kubernetes.io/revision",
|
|
19
|
+
"deprecated.daemonset.template.generation",
|
|
20
|
+
)
|
|
21
|
+
_EMPTY = ("", {}, [], None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compact(obj: dict | list, verbose: int = 0) -> dict | list:
|
|
25
|
+
if verbose >= 2:
|
|
26
|
+
return obj
|
|
27
|
+
obj = _strip_noisy(obj)
|
|
28
|
+
if verbose == 0:
|
|
29
|
+
obj = _drop_empty(obj)
|
|
30
|
+
return obj
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _strip_noisy(node):
|
|
34
|
+
if isinstance(node, dict):
|
|
35
|
+
out = {}
|
|
36
|
+
for k, v in node.items():
|
|
37
|
+
if k == "metadata" and isinstance(v, dict):
|
|
38
|
+
v = {mk: mv for mk, mv in v.items() if mk != "managedFields"}
|
|
39
|
+
ann = v.get("annotations")
|
|
40
|
+
if isinstance(ann, dict):
|
|
41
|
+
ann = {a: b for a, b in ann.items() if a not in NOISY_ANNOTATIONS}
|
|
42
|
+
if ann:
|
|
43
|
+
v["annotations"] = ann
|
|
44
|
+
else:
|
|
45
|
+
v.pop("annotations", None)
|
|
46
|
+
out[k] = _strip_noisy(v)
|
|
47
|
+
continue
|
|
48
|
+
out[k] = _strip_noisy(v)
|
|
49
|
+
return out
|
|
50
|
+
if isinstance(node, list):
|
|
51
|
+
return [_strip_noisy(x) for x in node]
|
|
52
|
+
return node
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _drop_empty(node):
|
|
56
|
+
if isinstance(node, dict):
|
|
57
|
+
out = {}
|
|
58
|
+
for k, v in node.items():
|
|
59
|
+
if k == "metadata" and isinstance(v, dict):
|
|
60
|
+
v = {mk: mv for mk, mv in v.items() if mk not in NOISY_META}
|
|
61
|
+
v = _drop_empty(v)
|
|
62
|
+
if v in _EMPTY:
|
|
63
|
+
continue
|
|
64
|
+
out[k] = v
|
|
65
|
+
return out
|
|
66
|
+
if isinstance(node, list):
|
|
67
|
+
return [_drop_empty(x) for x in node]
|
|
68
|
+
return node
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -- display helpers for ConfigMaps / Secrets ------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cap_map_values(obj: dict, limit: int = 2000) -> dict:
|
|
75
|
+
"""Cap long string values in data maps (ConfigMap display) in place."""
|
|
76
|
+
for key in ("data", "stringData", "binaryData"):
|
|
77
|
+
m = obj.get(key)
|
|
78
|
+
if not isinstance(m, dict):
|
|
79
|
+
continue
|
|
80
|
+
for k, v in list(m.items()):
|
|
81
|
+
if isinstance(v, str) and len(v) > limit:
|
|
82
|
+
m[k] = v[:limit] + f" ...[truncated {len(v) - limit} of {len(v)} chars; -v to see full]"
|
|
83
|
+
return obj
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def redact_map_values(obj: dict) -> dict:
|
|
87
|
+
"""Hide secret values in place; keys and sizes stay visible."""
|
|
88
|
+
for key in ("data", "stringData", "binaryData"):
|
|
89
|
+
m = obj.get(key)
|
|
90
|
+
if not isinstance(m, dict):
|
|
91
|
+
continue
|
|
92
|
+
obj[key] = {k: (f"<{len(v)} chars hidden -- use --reveal>" if isinstance(v, str) else v) for k, v in m.items()}
|
|
93
|
+
return obj
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reveal_map_values(obj: dict) -> dict:
|
|
97
|
+
"""Base64-decode secret data values in place (binary values stay encoded)."""
|
|
98
|
+
m = obj.get("data")
|
|
99
|
+
if isinstance(m, dict):
|
|
100
|
+
out = {}
|
|
101
|
+
for k, v in m.items():
|
|
102
|
+
if not isinstance(v, str):
|
|
103
|
+
out[k] = v
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
raw = base64.b64decode(v)
|
|
107
|
+
except Exception:
|
|
108
|
+
out[k] = v
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
out[k] = raw.decode("utf-8")
|
|
112
|
+
except UnicodeDecodeError:
|
|
113
|
+
out[k] = f"<{len(raw)} bytes binary; base64: {v[:100]}>"
|
|
114
|
+
obj["data"] = out
|
|
115
|
+
return obj
|
kube_query/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""kq — read-only Kubernetes queries for humans and AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .cli_kit import KqGroup
|
|
9
|
+
from .commands import register
|
|
10
|
+
from .config import Settings
|
|
11
|
+
from .output import FORMATS
|
|
12
|
+
|
|
13
|
+
EPILOG = """\
|
|
14
|
+
\b
|
|
15
|
+
USAGE PATTERNS
|
|
16
|
+
kq overview cluster snapshot: problems first
|
|
17
|
+
kq p l [-A|-n NS] [REGEX] list pods (d=deploy, n=ns, no=node,
|
|
18
|
+
kq p NAME / p desc NAME inspect sec=secret, cm, svc, ev)
|
|
19
|
+
kq logs NAME [-c C] [--tail N] pod logs kq tree deploy/NAME
|
|
20
|
+
kq get RESOURCE[/NAME] any resource incl. CRDs; kq api lists all
|
|
21
|
+
kq helm ls -A / kq eso -A helm releases / ExternalSecret status
|
|
22
|
+
|
|
23
|
+
\b
|
|
24
|
+
OPTIONS
|
|
25
|
+
-o yaml|json|rich|md|tree output (default: compact yaml)
|
|
26
|
+
-n NS | -A | -l SELECTOR | -f RX scope: namespace / all / labels / name regex
|
|
27
|
+
-v (system metadata) -vv (raw) verbosity
|
|
28
|
+
--ctx NAME / --kubeconfig PATH cluster selection
|
|
29
|
+
|
|
30
|
+
\b
|
|
31
|
+
ROLE
|
|
32
|
+
kq CAPTURES cluster state for diagnosis; fixing belongs to other tools
|
|
33
|
+
(kubectl apply, gitops, helm). kq only sends GET requests - it can
|
|
34
|
+
never create, update or delete anything.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group(
|
|
39
|
+
cls=KqGroup,
|
|
40
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
41
|
+
)
|
|
42
|
+
@click.version_option(__version__, "--version", prog_name="kube-query")
|
|
43
|
+
@click.option("--ctx", "--context", "context", default=None, help="Kubeconfig context to use.")
|
|
44
|
+
@click.option("--kubeconfig", default=None, type=click.Path(exists=True), help="Path to a kubeconfig file.")
|
|
45
|
+
@click.option("-n", "--ns", "--namespace", "namespace", default=None, help="Default namespace for this invocation.")
|
|
46
|
+
@click.option(
|
|
47
|
+
"-A", "--all-namespaces", "all_namespaces", is_flag=True, default=False, help="All namespaces (where applicable)."
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"-o",
|
|
51
|
+
"--output",
|
|
52
|
+
"output_fmt",
|
|
53
|
+
type=click.Choice(FORMATS),
|
|
54
|
+
default=None,
|
|
55
|
+
help="Output format: yaml, json, rich, md, tree. [env: KQ_OUTPUT]",
|
|
56
|
+
)
|
|
57
|
+
@click.option("-v", "--verbose", count=True, help="Verbosity: -v keep system metadata, -vv raw object.")
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def main(ctx, context, kubeconfig, namespace, all_namespaces, output_fmt, verbose):
|
|
60
|
+
"""kq - read-only Kubernetes queries for AI agents and humans.
|
|
61
|
+
|
|
62
|
+
Compact yaml by default; rich tables, markdown, json and ownership
|
|
63
|
+
trees on request. Built-ins, shortnames, CRDs, Helm releases and
|
|
64
|
+
External Secrets Operator. Start with: kq overview
|
|
65
|
+
"""
|
|
66
|
+
ctx.obj = Settings(
|
|
67
|
+
context=context,
|
|
68
|
+
kubeconfig=kubeconfig,
|
|
69
|
+
namespace=namespace,
|
|
70
|
+
output=output_fmt,
|
|
71
|
+
verbose=verbose,
|
|
72
|
+
all_namespaces=all_namespaces,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
main.epilog = EPILOG
|
|
77
|
+
register(main)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
kube_query/cli_kit.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""CLI building blocks: alias-hiding groups, resource groups, common options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .output import FORMATS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KqGroup(click.Group):
|
|
11
|
+
"""A click Group that hides registered aliases from help listings."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
14
|
+
super().__init__(*args, **kwargs)
|
|
15
|
+
self._aliases: set[str] = set()
|
|
16
|
+
|
|
17
|
+
def add_command(self, cmd, name=None, *, alias: bool = False) -> None:
|
|
18
|
+
super().add_command(cmd, name)
|
|
19
|
+
if alias:
|
|
20
|
+
self._aliases.add(name or cmd.name)
|
|
21
|
+
|
|
22
|
+
def list_commands(self, ctx) -> list[str]:
|
|
23
|
+
return [n for n in self.commands if n not in self._aliases]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResourceGroup(KqGroup):
|
|
27
|
+
"""A resource group:
|
|
28
|
+
|
|
29
|
+
- ``kq pod`` -> runs the default command (ls)
|
|
30
|
+
- ``kq pod nginx-123`` -> unknown token dispatches to `show` with the name
|
|
31
|
+
- ``kq pod ls`` -> normal subcommand
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
35
|
+
self.default_command = kwargs.pop("default_command", "ls")
|
|
36
|
+
super().__init__(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
def get_command(self, ctx, cmd_name):
|
|
39
|
+
cmd = super().get_command(ctx, cmd_name)
|
|
40
|
+
if cmd is not None or cmd_name.startswith("-"):
|
|
41
|
+
return cmd
|
|
42
|
+
show = super().get_command(ctx, "show")
|
|
43
|
+
if show is None:
|
|
44
|
+
return None
|
|
45
|
+
ctx._implicit_name = cmd_name
|
|
46
|
+
return show
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# -- shared option decorators ---------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def output_option(fn):
|
|
53
|
+
return click.option(
|
|
54
|
+
"-o",
|
|
55
|
+
"--output",
|
|
56
|
+
"output_fmt",
|
|
57
|
+
type=click.Choice(FORMATS),
|
|
58
|
+
default=None,
|
|
59
|
+
help="Output format: yaml (default), json, rich, md, tree. [env: KQ_OUTPUT]",
|
|
60
|
+
)(fn)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def verbose_option(fn):
|
|
64
|
+
return click.option(
|
|
65
|
+
"-v",
|
|
66
|
+
"--verbose",
|
|
67
|
+
count=True,
|
|
68
|
+
default=0,
|
|
69
|
+
help="Verbosity: -v keep system metadata, -vv raw object.",
|
|
70
|
+
)(fn)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def namespace_option(fn):
|
|
74
|
+
return click.option(
|
|
75
|
+
"-n",
|
|
76
|
+
"--ns",
|
|
77
|
+
"--namespace",
|
|
78
|
+
"namespace",
|
|
79
|
+
default=None,
|
|
80
|
+
help="Namespace (default: current context's namespace).",
|
|
81
|
+
)(fn)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def all_namespaces_option(fn):
|
|
85
|
+
return click.option(
|
|
86
|
+
"-A",
|
|
87
|
+
"--all-namespaces",
|
|
88
|
+
"all_namespaces",
|
|
89
|
+
is_flag=True,
|
|
90
|
+
default=False,
|
|
91
|
+
help="All namespaces.",
|
|
92
|
+
)(fn)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def selector_options(fn):
|
|
96
|
+
fn = click.option(
|
|
97
|
+
"-l",
|
|
98
|
+
"--selector",
|
|
99
|
+
"label_selector",
|
|
100
|
+
default=None,
|
|
101
|
+
help="Label selector, e.g. app=web,tier=frontend.",
|
|
102
|
+
)(fn)
|
|
103
|
+
return click.option(
|
|
104
|
+
"--field-selector",
|
|
105
|
+
"field_selector",
|
|
106
|
+
default=None,
|
|
107
|
+
help="Field selector, e.g. status.phase=Running.",
|
|
108
|
+
)(fn)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def filter_option(fn):
|
|
112
|
+
return click.option(
|
|
113
|
+
"-f",
|
|
114
|
+
"--filter",
|
|
115
|
+
"name_filter",
|
|
116
|
+
default=None,
|
|
117
|
+
help="Regex filter on object name.",
|
|
118
|
+
)(fn)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def list_options(include_pattern: bool = True):
|
|
122
|
+
"""Options for `ls`-style commands (order matters for help output)."""
|
|
123
|
+
|
|
124
|
+
def deco(fn):
|
|
125
|
+
if include_pattern:
|
|
126
|
+
fn = click.argument("pattern", required=False)(fn)
|
|
127
|
+
fn = filter_option(fn)
|
|
128
|
+
fn = all_namespaces_option(fn)
|
|
129
|
+
fn = selector_options(fn)
|
|
130
|
+
fn = namespace_option(fn)
|
|
131
|
+
fn = verbose_option(fn)
|
|
132
|
+
return output_option(fn)
|
|
133
|
+
|
|
134
|
+
return deco
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def show_options():
|
|
138
|
+
"""Options for `show`-style commands (NAME may come from group dispatch)."""
|
|
139
|
+
|
|
140
|
+
def deco(fn):
|
|
141
|
+
fn = click.argument("name", required=False)(fn)
|
|
142
|
+
fn = namespace_option(fn)
|
|
143
|
+
fn = verbose_option(fn)
|
|
144
|
+
return output_option(fn)
|
|
145
|
+
|
|
146
|
+
return deco
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def group_options(fn):
|
|
150
|
+
"""Options acceptable directly on a resource group (before the subcommand)."""
|
|
151
|
+
fn = all_namespaces_option(fn)
|
|
152
|
+
fn = namespace_option(fn)
|
|
153
|
+
fn = verbose_option(fn)
|
|
154
|
+
return output_option(fn)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def resolve_show_name(ctx: click.Context, name: str | None) -> str:
|
|
158
|
+
if name:
|
|
159
|
+
return name
|
|
160
|
+
implicit = getattr(getattr(ctx, "parent", None), "_implicit_name", None)
|
|
161
|
+
if implicit:
|
|
162
|
+
return implicit
|
|
163
|
+
raise click.UsageError("Missing NAME.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def merge_group_options(settings, *, namespace=None, output_fmt=None, verbose=0, all_namespaces=False) -> None:
|
|
167
|
+
"""Fold group-level option values into the shared Settings object."""
|
|
168
|
+
if namespace is not None:
|
|
169
|
+
settings.namespace = namespace
|
|
170
|
+
if output_fmt is not None:
|
|
171
|
+
settings.output = output_fmt
|
|
172
|
+
if verbose:
|
|
173
|
+
settings.verbose = max(settings.verbose, verbose)
|
|
174
|
+
if all_namespaces:
|
|
175
|
+
settings.all_namespaces = True
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Command registry for the kq root group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .api_resources import api
|
|
6
|
+
from .contexts import make_contexts_group
|
|
7
|
+
from .describe import describe
|
|
8
|
+
from .eso_cmd import eso
|
|
9
|
+
from .events_cmd import ev
|
|
10
|
+
from .get import get
|
|
11
|
+
from .groups import CONFIGMAPS, DEPLOYMENTS, NAMESPACES, NODES, PODS, SECRETS, SERVICES
|
|
12
|
+
from .helm_cmd import make_helm_group
|
|
13
|
+
from .logs import logs
|
|
14
|
+
from .overview import overview
|
|
15
|
+
from .tree_cmd import tree
|
|
16
|
+
from .version import version
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(main) -> None:
|
|
20
|
+
"""Attach all commands and their aliases to the root group."""
|
|
21
|
+
# triage first
|
|
22
|
+
main.add_command(overview, "overview")
|
|
23
|
+
main.add_command(overview, "ov", alias=True)
|
|
24
|
+
main.add_command(overview, "o", alias=True)
|
|
25
|
+
main.add_command(overview, "health", alias=True)
|
|
26
|
+
|
|
27
|
+
main.add_command(PODS, "pod")
|
|
28
|
+
main.add_command(PODS, "pods", alias=True)
|
|
29
|
+
main.add_command(PODS, "p", alias=True)
|
|
30
|
+
main.add_command(PODS, "po", alias=True)
|
|
31
|
+
|
|
32
|
+
main.add_command(NAMESPACES, "ns")
|
|
33
|
+
main.add_command(NAMESPACES, "namespaces", alias=True)
|
|
34
|
+
main.add_command(NAMESPACES, "namespace", alias=True)
|
|
35
|
+
main.add_command(NAMESPACES, "n", alias=True)
|
|
36
|
+
|
|
37
|
+
main.add_command(DEPLOYMENTS, "deploy")
|
|
38
|
+
main.add_command(DEPLOYMENTS, "deployments", alias=True)
|
|
39
|
+
main.add_command(DEPLOYMENTS, "deployment", alias=True)
|
|
40
|
+
main.add_command(DEPLOYMENTS, "dep", alias=True)
|
|
41
|
+
main.add_command(DEPLOYMENTS, "d", alias=True)
|
|
42
|
+
|
|
43
|
+
main.add_command(SERVICES, "svc")
|
|
44
|
+
main.add_command(SERVICES, "services", alias=True)
|
|
45
|
+
main.add_command(SERVICES, "service", alias=True)
|
|
46
|
+
|
|
47
|
+
main.add_command(SECRETS, "secret")
|
|
48
|
+
main.add_command(SECRETS, "secrets", alias=True)
|
|
49
|
+
main.add_command(SECRETS, "sec", alias=True)
|
|
50
|
+
|
|
51
|
+
main.add_command(CONFIGMAPS, "cm")
|
|
52
|
+
main.add_command(CONFIGMAPS, "configmaps", alias=True)
|
|
53
|
+
main.add_command(CONFIGMAPS, "configmap", alias=True)
|
|
54
|
+
|
|
55
|
+
main.add_command(NODES, "node")
|
|
56
|
+
main.add_command(NODES, "nodes", alias=True)
|
|
57
|
+
main.add_command(NODES, "no", alias=True)
|
|
58
|
+
|
|
59
|
+
main.add_command(ev, "ev")
|
|
60
|
+
main.add_command(ev, "events", alias=True)
|
|
61
|
+
main.add_command(ev, "event", alias=True)
|
|
62
|
+
main.add_command(ev, "e", alias=True)
|
|
63
|
+
|
|
64
|
+
main.add_command(logs, "logs")
|
|
65
|
+
main.add_command(logs, "log", alias=True)
|
|
66
|
+
|
|
67
|
+
main.add_command(make_helm_group(), "helm")
|
|
68
|
+
main.add_command(make_helm_group(), "h", alias=True)
|
|
69
|
+
|
|
70
|
+
main.add_command(eso, "eso")
|
|
71
|
+
main.add_command(eso, "externalsecrets", alias=True)
|
|
72
|
+
|
|
73
|
+
main.add_command(make_contexts_group(), "ctx")
|
|
74
|
+
main.add_command(make_contexts_group(), "contexts", alias=True)
|
|
75
|
+
main.add_command(make_contexts_group(), "context", alias=True)
|
|
76
|
+
main.add_command(make_contexts_group(), "c", alias=True)
|
|
77
|
+
|
|
78
|
+
main.add_command(get, "get")
|
|
79
|
+
main.add_command(get, "g", alias=True)
|
|
80
|
+
|
|
81
|
+
main.add_command(describe, "describe")
|
|
82
|
+
main.add_command(describe, "desc", alias=True)
|
|
83
|
+
|
|
84
|
+
main.add_command(tree, "tree")
|
|
85
|
+
main.add_command(tree, "t", alias=True)
|
|
86
|
+
|
|
87
|
+
main.add_command(api, "api")
|
|
88
|
+
main.add_command(api, "api-resources", alias=True)
|
|
89
|
+
main.add_command(api, "ar", alias=True)
|
|
90
|
+
|
|
91
|
+
main.add_command(version, "version")
|
|
92
|
+
main.add_command(version, "ver", alias=True)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Shared command implementations: list / show rendering used by `get` and the sugar groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ..access import get_object, list_objects
|
|
8
|
+
from ..cleanup import compact
|
|
9
|
+
from ..config import Settings
|
|
10
|
+
from ..discovery import ApiResource
|
|
11
|
+
from ..errors import KqError
|
|
12
|
+
from ..output import console, emit_data, note, print_object, render_rows
|
|
13
|
+
from ..summarize import columns_for, dig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def effective_namespace(settings: Settings, entry: ApiResource, eff, *, name_given: bool) -> str | None:
|
|
17
|
+
"""Namespace for this call (None => all namespaces when listing)."""
|
|
18
|
+
if not entry.namespaced:
|
|
19
|
+
return None
|
|
20
|
+
if name_given:
|
|
21
|
+
return eff.namespace or settings.client.default_namespace()
|
|
22
|
+
if eff.all_namespaces:
|
|
23
|
+
return None
|
|
24
|
+
return eff.namespace or settings.client.default_namespace()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def apply_filter(items: list[dict], patterns: list[str | None]) -> list[dict]:
|
|
28
|
+
pats = [p for p in patterns if p]
|
|
29
|
+
if not pats:
|
|
30
|
+
return items
|
|
31
|
+
regexes = []
|
|
32
|
+
for p in pats:
|
|
33
|
+
try:
|
|
34
|
+
regexes.append(re.compile(p, re.IGNORECASE))
|
|
35
|
+
except re.error as e:
|
|
36
|
+
raise KqError(f"invalid filter regex '{p}': {e}") from None
|
|
37
|
+
return [it for it in items if all(rx.search(dig(it, "metadata", "name", default="") or "") for rx in regexes)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sort_items(items: list[dict]) -> list[dict]:
|
|
41
|
+
return sorted(
|
|
42
|
+
items,
|
|
43
|
+
key=lambda o: (dig(o, "metadata", "namespace", default="") or "", dig(o, "metadata", "name", default="") or ""),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def tabulate(entry: ApiResource, items: list[dict], *, include_ns: bool) -> tuple[list[str], list[dict]]:
|
|
48
|
+
cols = columns_for(entry.kind)
|
|
49
|
+
if include_ns:
|
|
50
|
+
cols = [("NAMESPACE", lambda o: dig(o, "metadata", "namespace", default="") or ""), *cols]
|
|
51
|
+
headers = [h for h, _ in cols]
|
|
52
|
+
rows = []
|
|
53
|
+
for it in items:
|
|
54
|
+
r = {}
|
|
55
|
+
for h, fn in cols:
|
|
56
|
+
try:
|
|
57
|
+
r[h] = fn(it)
|
|
58
|
+
except Exception:
|
|
59
|
+
r[h] = ""
|
|
60
|
+
rows.append(r)
|
|
61
|
+
return headers, rows
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_list(settings: Settings, entry: ApiResource, items: list[dict], eff, *, namespace: str | None) -> None:
|
|
65
|
+
"""Render a list of objects in the requested format."""
|
|
66
|
+
if eff.output in ("yaml", "json"):
|
|
67
|
+
emit_data([compact(it, eff.verbose) for it in items], eff.output)
|
|
68
|
+
if not items:
|
|
69
|
+
where = f" in namespace '{namespace}'" if namespace else ""
|
|
70
|
+
note(f"no {entry.plural} found{where} (0 items)")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if eff.output == "tree":
|
|
74
|
+
from ..tree import forest_for_items, rich_forest
|
|
75
|
+
|
|
76
|
+
forest = forest_for_items(settings.client, entry, items)
|
|
77
|
+
console().print(rich_forest(forest))
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
include_ns = bool(
|
|
81
|
+
items
|
|
82
|
+
and any(dig(o, "metadata", "namespace") for o in items)
|
|
83
|
+
and (namespace is None or len({dig(o, "metadata", "namespace", default="") or "" for o in items}) > 1)
|
|
84
|
+
)
|
|
85
|
+
headers, rows = tabulate(entry, items, include_ns=include_ns)
|
|
86
|
+
where = f" in namespace '{namespace}'" if namespace else " (all namespaces)"
|
|
87
|
+
subtitle = f"{len(items)} {entry.plural}{where}"
|
|
88
|
+
|
|
89
|
+
if not items:
|
|
90
|
+
console().print(f"[dim]no {entry.plural} found{where}[/dim]")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
render_rows(headers, rows, eff.output, title=entry.kind, subtitle=subtitle)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_single(
|
|
97
|
+
settings: Settings,
|
|
98
|
+
entry: ApiResource,
|
|
99
|
+
name: str,
|
|
100
|
+
eff,
|
|
101
|
+
*,
|
|
102
|
+
transform=None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Fetch and render one object (`show`-style)."""
|
|
105
|
+
ns = effective_namespace(settings, entry, eff, name_given=True)
|
|
106
|
+
obj = get_object(settings.client, entry, name, namespace=ns)
|
|
107
|
+
|
|
108
|
+
if eff.output == "tree":
|
|
109
|
+
from ..tree import build_forest, rich_forest
|
|
110
|
+
|
|
111
|
+
console().print(rich_forest(build_forest(settings.client, entry, obj)))
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
data = compact(obj, eff.verbose)
|
|
115
|
+
if transform is not None:
|
|
116
|
+
data = transform(data)
|
|
117
|
+
|
|
118
|
+
if eff.output in ("yaml", "json"):
|
|
119
|
+
emit_data(data, eff.output)
|
|
120
|
+
elif eff.output in ("rich", "md"):
|
|
121
|
+
title = f"{entry.kind} {name}" + (f" -n {ns}" if ns else "")
|
|
122
|
+
print_object(data, eff.output, title=title)
|
|
123
|
+
else:
|
|
124
|
+
raise KqError(f"format '{eff.output}' is not supported here")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fetch_list(
|
|
128
|
+
settings: Settings,
|
|
129
|
+
entry: ApiResource,
|
|
130
|
+
eff,
|
|
131
|
+
*,
|
|
132
|
+
label_selector: str | None = None,
|
|
133
|
+
field_selector: str | None = None,
|
|
134
|
+
pattern: str | None = None,
|
|
135
|
+
name_filter: str | None = None,
|
|
136
|
+
) -> tuple[list[dict], str | None]:
|
|
137
|
+
"""Resolve namespace, list + filter + sort. Returns (items, namespace)."""
|
|
138
|
+
ns = effective_namespace(settings, entry, eff, name_given=False)
|
|
139
|
+
items = list_objects(
|
|
140
|
+
settings.client,
|
|
141
|
+
entry,
|
|
142
|
+
namespace=ns,
|
|
143
|
+
label_selector=label_selector,
|
|
144
|
+
field_selector=field_selector,
|
|
145
|
+
)
|
|
146
|
+
items = sort_items(apply_filter(items, [pattern, name_filter]))
|
|
147
|
+
return items, ns
|