borescope 0.1.0.dev0__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.
borescope/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """borescope - a natural shell for debugging Juju Kubernetes workload containers."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("borescope")
7
+ except PackageNotFoundError: # pragma: no cover - running from an uninstalled tree
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ __all__ = ["__version__"]
borescope/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """``python -m borescope`` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
borescope/cli.py ADDED
@@ -0,0 +1,144 @@
1
+ """Command-line entry point for borescope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="borescope",
14
+ description=(
15
+ "A natural shell for debugging Juju Kubernetes workload containers."
16
+ ),
17
+ )
18
+ parser.add_argument("unit", nargs="?", help="unit reference, e.g. 'myapp/0'")
19
+ parser.add_argument(
20
+ "--container", help="workload container name (default: first declared)"
21
+ )
22
+ parser.add_argument("-m", "--model", help="Juju model (default: current)")
23
+ parser.add_argument(
24
+ "-c",
25
+ "--command",
26
+ help="run a single command and exit (no REPL)",
27
+ )
28
+ parser.add_argument(
29
+ "--snapshot",
30
+ action="store_true",
31
+ help="dump container state as JSON and exit",
32
+ )
33
+ parser.add_argument(
34
+ "--socket",
35
+ help="talk directly to a Pebble unix socket (skip juju)",
36
+ )
37
+ parser.add_argument(
38
+ "--here",
39
+ action="store_true",
40
+ help=(
41
+ "run inside the charm container: auto-detect a workload's mounted "
42
+ "Pebble socket (use --container to pick when there are several)"
43
+ ),
44
+ )
45
+ parser.add_argument(
46
+ "--juju", default="juju", help="juju binary to invoke (default: juju)"
47
+ )
48
+ parser.add_argument(
49
+ "--via",
50
+ choices=("ssh", "exec"),
51
+ default="ssh",
52
+ help=(
53
+ "Juju relay for Mode B: 'ssh' (default, streaming) or 'exec' "
54
+ "(request/response — for sites where ssh is disabled)"
55
+ ),
56
+ )
57
+ parser.add_argument(
58
+ "--version", action="version", version=f"borescope {__version__}"
59
+ )
60
+ return parser
61
+
62
+
63
+ def _build_target(args: argparse.Namespace):
64
+ from .discovery import Target, resolve_local_target, resolve_target
65
+
66
+ if args.here:
67
+ return resolve_local_target(container=args.container)
68
+ if args.socket:
69
+ unit = args.unit or "local"
70
+ app = unit.split("/")[0]
71
+ return Target(
72
+ unit=unit,
73
+ app=app,
74
+ container=args.container,
75
+ model=args.model,
76
+ juju_binary=args.juju,
77
+ socket_path=args.socket,
78
+ )
79
+ return resolve_target(
80
+ args.unit,
81
+ container=args.container,
82
+ model=args.model,
83
+ juju_binary=args.juju,
84
+ via=args.via,
85
+ )
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ args = build_parser().parse_args(argv)
90
+ if not args.unit and not args.socket and not args.here:
91
+ print(
92
+ "borescope: a unit reference is required (e.g. 'borescope myapp/0'), "
93
+ "or use --here when running inside a charm container.",
94
+ file=sys.stderr,
95
+ )
96
+ return 2
97
+
98
+ # Heavy imports happen only past argument parsing, keeping --help/--version fast.
99
+ from .errors import BorescopeError
100
+ from .shell import ShellContext
101
+ from .transport import open_transport
102
+
103
+ try:
104
+ target = _build_target(args)
105
+ transport = open_transport(
106
+ unit=target.unit,
107
+ container=target.container,
108
+ model=target.model,
109
+ juju_binary=target.juju_binary,
110
+ socket_path=target.socket_path,
111
+ via=target.via,
112
+ )
113
+ if args.snapshot:
114
+ from .snapshot import snapshot_json
115
+
116
+ print(snapshot_json(transport, target))
117
+ return 0
118
+
119
+ from .discovery import sanity_check
120
+
121
+ sanity_check(transport, target)
122
+ except BorescopeError as exc:
123
+ print(f"borescope: {exc}", file=sys.stderr)
124
+ return 1
125
+
126
+ from .shell import Shell
127
+
128
+ shell = Shell(ShellContext(transport=transport, target=target))
129
+
130
+ if args.command is not None:
131
+ return shell.execute_and_emit(args.command)
132
+
133
+ if not sys.stdin.isatty():
134
+ code = 0
135
+ for line in sys.stdin:
136
+ if line.strip():
137
+ code = shell.execute_and_emit(line.rstrip("\n"))
138
+ return code
139
+
140
+ return shell.loop()
141
+
142
+
143
+ if __name__ == "__main__": # pragma: no cover
144
+ sys.exit(main())
borescope/discovery.py ADDED
@@ -0,0 +1,256 @@
1
+ """Discovery layer (B) — "find the right Pebble".
2
+
3
+ Turns a unit reference (``myapp/0``) plus optional ``--container`` / ``--model``
4
+ into a :class:`Target` describing exactly which workload container's Pebble to talk
5
+ to. Everything here uses only the user's Juju model access (``juju status``,
6
+ ``juju ssh`` to read the charm's ``metadata.yaml``) — never ``kubectl`` or
7
+ cluster-admin — so borescope inherits Juju's authority for free.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING
16
+
17
+ import yaml
18
+
19
+ from . import juju
20
+ from .errors import DiscoveryError, JujuError
21
+
22
+ if TYPE_CHECKING:
23
+ from .transport import Transport
24
+
25
+ _UNIT_RE = re.compile(r"^(?P<app>[a-z0-9][a-z0-9-]*)/(?P<num>\d+)$")
26
+
27
+ # Charm agent metadata lives at a deterministic path inside the *charm* container
28
+ # (which, unlike the workload rock, has a normal filesystem and `cat`).
29
+ _META_FILES = ("metadata.yaml", "charmcraft.yaml")
30
+
31
+ # Inside a Juju k8s charm container, each declared workload's Pebble socket is
32
+ # mounted here, one subdirectory per container. This is what makes Mode A
33
+ # (``--here``) possible without any Juju round-trip.
34
+ _LOCAL_SOCKET_DIR = "/charm/containers"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Target:
39
+ """A fully-resolved Pebble target."""
40
+
41
+ unit: str
42
+ app: str
43
+ container: str | None
44
+ model: str | None
45
+ controller: str | None = None
46
+ juju_binary: str = "juju"
47
+ socket_path: str | None = None
48
+ # CLI-relay variant for Mode B: "ssh" (default) or "exec". Ignored when
49
+ # socket_path is set (Mode A uses the socket directly).
50
+ via: str = "ssh"
51
+
52
+ @property
53
+ def history_key(self) -> str:
54
+ """Stable per-controller/model/unit key for history files."""
55
+ parts = [self.controller or "_", self.model or "_", self.unit]
56
+ return "/".join(parts).replace("/", "_")
57
+
58
+
59
+ def parse_unit_ref(ref: str) -> tuple[str, str]:
60
+ """Split ``app/n`` into ``(app, n)``; raise :class:`DiscoveryError` if malformed."""
61
+ match = _UNIT_RE.match(ref.strip())
62
+ if not match:
63
+ raise DiscoveryError(
64
+ f"'{ref}' is not a valid unit reference (expected e.g. 'myapp/0')."
65
+ )
66
+ return match.group("app"), match.group("num")
67
+
68
+
69
+ def _agent_dir(app: str, num: str) -> str:
70
+ return f"unit-{app}-{num}"
71
+
72
+
73
+ def discover_containers(
74
+ unit: str, app: str, num: str, *, model: str | None, juju_binary: str
75
+ ) -> list[str]:
76
+ """Return the workload container names declared in the charm's metadata.
77
+
78
+ Reads the charm's ``metadata.yaml`` (falling back to ``charmcraft.yaml``) from
79
+ the charm container — ``juju status`` does not list workload container names.
80
+ """
81
+ base = f"/var/lib/juju/agents/{_agent_dir(app, num)}/charm"
82
+ last_error: JujuError | None = None
83
+ for name in _META_FILES:
84
+ try:
85
+ raw = juju.run_juju(
86
+ # No ``--`` separator: juju's k8s ssh leaks it into the remote sh.
87
+ ["ssh", unit, "cat", f"{base}/{name}"],
88
+ model=model,
89
+ juju_binary=juju_binary,
90
+ )
91
+ except JujuError as exc:
92
+ last_error = exc
93
+ continue
94
+ try:
95
+ meta = yaml.safe_load(raw) or {}
96
+ except yaml.YAMLError:
97
+ continue
98
+ containers = meta.get("containers")
99
+ if isinstance(containers, dict):
100
+ return [str(name) for name in containers]
101
+ if last_error is not None:
102
+ raise DiscoveryError(
103
+ f"could not read charm metadata for {unit}: {last_error}"
104
+ ) from last_error
105
+ return []
106
+
107
+
108
+ def resolve_target(
109
+ unit_ref: str,
110
+ *,
111
+ container: str | None = None,
112
+ model: str | None = None,
113
+ juju_binary: str = "juju",
114
+ via: str = "ssh",
115
+ ) -> Target:
116
+ """Resolve *unit_ref* to a :class:`Target`, confirming it exists and is on k8s."""
117
+ app, num = parse_unit_ref(unit_ref)
118
+ controller, current_model = juju.current_controller_model(juju_binary)
119
+ effective_model = model or current_model
120
+
121
+ status = juju.status_json(model=model, juju_binary=juju_binary)
122
+
123
+ model_type = (status.get("model") or {}).get("type")
124
+ if model_type == "iaas":
125
+ raise DiscoveryError(
126
+ f"{unit_ref} is on a machine (IAAS) model. borescope only supports "
127
+ "Kubernetes charms, which run Pebble; machine charms already have a "
128
+ "real shell. See the project scope."
129
+ )
130
+
131
+ apps = status.get("applications") or {}
132
+ if app not in apps:
133
+ raise DiscoveryError(
134
+ f"application '{app}' not found in model "
135
+ f"'{effective_model or '<current>'}'."
136
+ )
137
+ units = apps[app].get("units") or {}
138
+ if unit_ref not in units:
139
+ available = ", ".join(sorted(units)) or "none"
140
+ raise DiscoveryError(
141
+ f"unit '{unit_ref}' not found. Units of '{app}': {available}."
142
+ )
143
+
144
+ chosen = container
145
+ if chosen is None:
146
+ containers = discover_containers(
147
+ unit_ref, app, num, model=model, juju_binary=juju_binary
148
+ )
149
+ if not containers:
150
+ raise DiscoveryError(
151
+ f"no workload containers declared by '{app}'. Is this a "
152
+ "Kubernetes (sidecar) charm?"
153
+ )
154
+ # Default to the first declared workload container (open question #5).
155
+ chosen = containers[0]
156
+
157
+ return Target(
158
+ unit=unit_ref,
159
+ app=app,
160
+ container=chosen,
161
+ model=effective_model,
162
+ controller=controller,
163
+ juju_binary=juju_binary,
164
+ via=via,
165
+ )
166
+
167
+
168
+ def discover_local_sockets(base: str = _LOCAL_SOCKET_DIR) -> dict[str, str]:
169
+ """Map workload container name → mounted Pebble socket path.
170
+
171
+ As seen from *inside the charm container*, where Juju mounts every workload's
172
+ socket at ``/charm/containers/<name>/pebble.socket``. Returns an empty mapping
173
+ if *base* isn't present (i.e. we're not in a charm container).
174
+ """
175
+ sockets: dict[str, str] = {}
176
+ try:
177
+ names = os.listdir(base)
178
+ except OSError:
179
+ return sockets
180
+ for name in sorted(names):
181
+ path = f"{base}/{name}/pebble.socket"
182
+ if os.path.exists(path):
183
+ sockets[name] = path
184
+ return sockets
185
+
186
+
187
+ def resolve_local_target(
188
+ *, container: str | None = None, base: str = _LOCAL_SOCKET_DIR
189
+ ) -> Target:
190
+ """Resolve a :class:`Target` for borescope running *inside the charm container*.
191
+
192
+ Talks directly to a workload's mounted Pebble socket (Mode A) — no Juju, no
193
+ unit reference. Picks the socket named by *container*, or the sole one if the
194
+ charm declares just a single workload.
195
+ """
196
+ sockets = discover_local_sockets(base)
197
+ if not sockets:
198
+ raise DiscoveryError(
199
+ f"no Pebble sockets found under {base}. '--here' only works from inside "
200
+ "a Juju Kubernetes charm container, which mounts each workload's socket "
201
+ "there. From your workstation, run 'borescope <unit>' instead."
202
+ )
203
+ if container is not None:
204
+ socket = sockets.get(container)
205
+ if socket is None:
206
+ available = ", ".join(sockets)
207
+ raise DiscoveryError(
208
+ f"no Pebble socket for container '{container}' under {base}. "
209
+ f"Available: {available}."
210
+ )
211
+ chosen = container
212
+ elif len(sockets) == 1:
213
+ chosen, socket = next(iter(sockets.items()))
214
+ else:
215
+ available = ", ".join(sockets)
216
+ raise DiscoveryError(
217
+ f"this charm has multiple workload containers ({available}); "
218
+ "choose one with --container."
219
+ )
220
+ return Target(
221
+ unit="local",
222
+ app=chosen,
223
+ container=chosen,
224
+ model=None,
225
+ socket_path=socket,
226
+ )
227
+
228
+
229
+ def sanity_check(transport: Transport, target: Target) -> None:
230
+ """Confirm the container's Pebble answers, and is new enough, before prompting."""
231
+ try:
232
+ info = transport.get_system_info()
233
+ except Exception as exc: # noqa: BLE001 - surface any backend failure uniformly
234
+ raise DiscoveryError(
235
+ f"could not reach Pebble in {target.unit} "
236
+ f"container '{target.container}': {exc}"
237
+ ) from exc
238
+
239
+ # borescope v1 relies on Pebble's `--format json` output (via shimmer). Older
240
+ # Pebbles (e.g. v1.26, still shipped by current stable charms) lack it. Probe
241
+ # one structured read so we fail fast with a clear message rather than letting
242
+ # every read command die with a cryptic "unknown flag `format'".
243
+ try:
244
+ transport.get_services()
245
+ except Exception as exc: # noqa: BLE001
246
+ message = str(exc).lower()
247
+ if "unknown flag" in message and "format" in message:
248
+ version = getattr(info, "version", "unknown")
249
+ raise DiscoveryError(
250
+ f"the Pebble in {target.unit} container '{target.container}' "
251
+ f"(version {version}) is too old for borescope: it lacks the "
252
+ "`--format json` output borescope relies on. borescope v1 needs a "
253
+ "newer Pebble (support for older Pebble may come later)."
254
+ ) from exc
255
+ # Any other failure here isn't necessarily fatal; reachability is already
256
+ # proven, so let the session start and let individual commands report.
borescope/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ """borescope's error hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class BorescopeError(Exception):
7
+ """Base class for all borescope errors."""
8
+
9
+
10
+ class DiscoveryError(BorescopeError):
11
+ """Raised when a unit/model/container cannot be resolved or reached."""
12
+
13
+
14
+ class TransportError(BorescopeError):
15
+ """Raised when the chosen transport cannot talk to a Pebble."""
16
+
17
+
18
+ class JujuError(BorescopeError):
19
+ """Raised when an underlying ``juju`` invocation fails."""
20
+
21
+ def __init__(
22
+ self, message: str, *, returncode: int | None = None, stderr: str = ""
23
+ ):
24
+ super().__init__(message)
25
+ self.returncode = returncode
26
+ self.stderr = stderr
borescope/juju.py ADDED
@@ -0,0 +1,89 @@
1
+ """Thin wrappers around the ``juju`` CLI.
2
+
3
+ borescope never links Juju's Go/Python API libraries; it shells out to the user's
4
+ ``juju`` client. That keeps borescope inside the user's existing Juju authority — if
5
+ they can run ``juju ssh`` to a unit, borescope works; if they can't, it fails the
6
+ same way — and needs no ``kubectl`` / cluster-admin access.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ from typing import Any
14
+
15
+ from .errors import JujuError
16
+
17
+ DEFAULT_TIMEOUT = 30.0
18
+
19
+
20
+ def run_juju(
21
+ args: list[str],
22
+ *,
23
+ model: str | None = None,
24
+ juju_binary: str = "juju",
25
+ input: str | None = None,
26
+ timeout: float = DEFAULT_TIMEOUT,
27
+ ) -> str:
28
+ """Run ``juju <args>`` and return stdout, raising :class:`JujuError` on failure.
29
+
30
+ *model*, when given, is inserted as ``-m <model>`` immediately after the
31
+ subcommand (``args[0]``), matching how ``juju`` expects the flag.
32
+ """
33
+ cmd = [juju_binary, args[0]]
34
+ if model:
35
+ cmd += ["-m", model]
36
+ cmd += args[1:]
37
+ try:
38
+ result = subprocess.run(
39
+ cmd,
40
+ input=input,
41
+ # Detach stdin unless we're sending input: `juju ssh` forwards our stdin
42
+ # to the remote and would otherwise drain borescope's own piped-batch
43
+ # command stream during discovery, leaving the REPL loop nothing to read.
44
+ stdin=subprocess.DEVNULL if input is None else None,
45
+ capture_output=True,
46
+ text=True,
47
+ timeout=timeout,
48
+ check=True,
49
+ )
50
+ except FileNotFoundError as exc:
51
+ raise JujuError(
52
+ f"'{juju_binary}' not found on PATH. Install Juju and try again."
53
+ ) from exc
54
+ except subprocess.TimeoutExpired as exc:
55
+ raise JujuError(f"'{' '.join(cmd)}' timed out after {timeout}s") from exc
56
+ except subprocess.CalledProcessError as exc:
57
+ stderr = (exc.stderr or "").strip()
58
+ raise JujuError(
59
+ f"'{' '.join(cmd)}' failed: {stderr or 'unknown error'}",
60
+ returncode=exc.returncode,
61
+ stderr=stderr,
62
+ ) from exc
63
+ return result.stdout
64
+
65
+
66
+ def current_controller_model(
67
+ juju_binary: str = "juju",
68
+ ) -> tuple[str | None, str | None]:
69
+ """Return ``(controller, model)`` from ``juju switch`` (either may be None)."""
70
+ try:
71
+ out = run_juju(["switch"], juju_binary=juju_binary).strip()
72
+ except JujuError:
73
+ return None, None
74
+ if not out:
75
+ return None, None
76
+ # `juju switch` prints "<controller>:<user>/<model>" (or "<controller>:<model>").
77
+ controller, _, model = out.partition(":")
78
+ return controller or None, (model or None)
79
+
80
+
81
+ def status_json(
82
+ *, model: str | None = None, juju_binary: str = "juju"
83
+ ) -> dict[str, Any]:
84
+ """Return parsed ``juju status --format=json`` for *model*."""
85
+ out = run_juju(["status", "--format=json"], model=model, juju_binary=juju_binary)
86
+ try:
87
+ return json.loads(out)
88
+ except json.JSONDecodeError as exc:
89
+ raise JujuError("could not parse `juju status --format=json` output") from exc
@@ -0,0 +1,8 @@
1
+ """Shell layer (C) — "drive the Pebble"."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .context import ShellContext
6
+ from .repl import Shell
7
+
8
+ __all__ = ["Shell", "ShellContext"]
@@ -0,0 +1,13 @@
1
+ """Built-in commands and the registry that discovers them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Command, ExitShell, Result, build_registry
6
+
7
+
8
+ def import_all() -> None:
9
+ """Import every command module so its ``Command`` subclasses register."""
10
+ from . import basic, execcmd, filesystem, pebble # noqa: F401
11
+
12
+
13
+ __all__ = ["Command", "ExitShell", "Result", "build_registry", "import_all"]
@@ -0,0 +1,54 @@
1
+ """A tiny getopt-ish argument splitter shared by the built-in commands.
2
+
3
+ Not a full argparse — these are debug-shell commands, so we want forgiving,
4
+ familiar behaviour (``-la``, ``-n10``, ``-n 10``, ``--follow``) without per-command
5
+ boilerplate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def parse_args(
12
+ args: list[str], valued: tuple[str, ...] = ()
13
+ ) -> tuple[set[str], dict[str, str], list[str]]:
14
+ """Split *args* into ``(flags, values, positionals)``.
15
+
16
+ *valued* names flags that take a value (e.g. ``("n",)`` for ``-n N`` /
17
+ ``("name",)`` for ``--name X``). Everything after ``--`` is positional.
18
+ """
19
+ flags: set[str] = set()
20
+ values: dict[str, str] = {}
21
+ positionals: list[str] = []
22
+
23
+ i = 0
24
+ while i < len(args):
25
+ arg = args[i]
26
+ if arg == "--":
27
+ positionals.extend(args[i + 1 :])
28
+ break
29
+ if arg.startswith("--"):
30
+ name = arg[2:]
31
+ if name in valued:
32
+ i += 1
33
+ values[name] = args[i] if i < len(args) else ""
34
+ else:
35
+ flags.add(name)
36
+ elif len(arg) > 1 and arg[0] == "-" and not arg[1].isdigit():
37
+ body = arg[1:]
38
+ j = 0
39
+ while j < len(body):
40
+ ch = body[j]
41
+ if ch in valued:
42
+ rest = body[j + 1 :]
43
+ if rest:
44
+ values[ch] = rest
45
+ else:
46
+ i += 1
47
+ values[ch] = args[i] if i < len(args) else ""
48
+ break
49
+ flags.add(ch)
50
+ j += 1
51
+ else:
52
+ positionals.append(arg)
53
+ i += 1
54
+ return flags, values, positionals