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.
@@ -0,0 +1,133 @@
1
+ """The REPL: one process, one session, one command (or single pipe) at a time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ..errors import BorescopeError
9
+ from . import theme
10
+ from .commands.base import ExitShell, Result, build_registry
11
+ from .parser import ParseError, expand, parse_pipeline
12
+
13
+ if TYPE_CHECKING:
14
+ from .context import ShellContext
15
+
16
+
17
+ class Shell:
18
+ """Drives a Pebble through the command registry."""
19
+
20
+ def __init__(self, ctx: ShellContext):
21
+ self.ctx = ctx
22
+ self.registry = build_registry()
23
+ self._session = None
24
+
25
+ # -- execution ---------------------------------------------------------
26
+ def run_line(self, line: str) -> Result:
27
+ """Parse and run a single input line (may raise :class:`ExitShell`)."""
28
+ try:
29
+ stages = parse_pipeline(line)
30
+ except ParseError as exc:
31
+ return Result.fail(f"borescope: {exc}")
32
+ if not stages:
33
+ return Result()
34
+ return self._execute(stages)
35
+
36
+ def _execute(self, stages: list[list[str]]) -> Result:
37
+ if len(stages) == 1:
38
+ return self._run_stage(stages[0], None)
39
+
40
+ for stage in stages:
41
+ cmd = self.registry.get(stage[0])
42
+ if cmd is not None and cmd.streaming:
43
+ return Result.fail(f"borescope: '{stage[0]}' cannot be used in a pipe.")
44
+
45
+ left = self._run_stage(stages[0], None)
46
+ right = self._run_stage(stages[1], left.output)
47
+ return Result(
48
+ output=right.output, error=left.error + right.error, code=right.code
49
+ )
50
+
51
+ def _run_stage(self, tokens: list[str], stdin: str | None) -> Result:
52
+ tokens = [expand(tok, self.ctx.env) for tok in tokens]
53
+ name, *args = tokens
54
+ cmd = self.registry.get(name)
55
+ if cmd is None:
56
+ return Result.fail(
57
+ f"borescope: command not found: {name}\n"
58
+ f" hint: 'exec {name} ...' runs it inside the container.",
59
+ code=127,
60
+ )
61
+ try:
62
+ return cmd.run(self.ctx, args, stdin)
63
+ except ExitShell:
64
+ raise
65
+ except BorescopeError as exc:
66
+ return Result.fail(f"{name}: {exc}")
67
+ except Exception as exc: # noqa: BLE001 - surface backend errors as output
68
+ return Result.fail(f"{name}: {exc}")
69
+
70
+ def execute_and_emit(self, line: str) -> int:
71
+ """Run one line, print its output, and return the exit code (one-shot)."""
72
+ try:
73
+ result = self.run_line(line)
74
+ except ExitShell as exc:
75
+ return exc.code
76
+ self._emit(result)
77
+ return result.code
78
+
79
+ def _emit(self, result: Result) -> None:
80
+ if result.output:
81
+ sys.stdout.write(result.output)
82
+ if not result.output.endswith("\n"):
83
+ sys.stdout.write("\n")
84
+ sys.stdout.flush()
85
+ if result.error:
86
+ sys.stderr.write(result.error)
87
+ if not result.error.endswith("\n"):
88
+ sys.stderr.write("\n")
89
+ sys.stderr.flush()
90
+ self.ctx.last_exit = result.code
91
+
92
+ # -- interactive loop --------------------------------------------------
93
+ def loop(self) -> int:
94
+ session = self._ensure_session()
95
+ self._print_banner()
96
+ style = theme.style()
97
+ while True:
98
+ try:
99
+ line = session.prompt(theme.prompt_fragments(self.ctx), style=style)
100
+ except KeyboardInterrupt:
101
+ continue
102
+ except EOFError:
103
+ break
104
+ if not line.strip():
105
+ continue
106
+ try:
107
+ result = self.run_line(line)
108
+ except ExitShell as exc:
109
+ self.ctx.last_exit = exc.code
110
+ break
111
+ self._emit(result)
112
+ return self.ctx.last_exit
113
+
114
+ def _ensure_session(self):
115
+ if self._session is None:
116
+ from prompt_toolkit import PromptSession
117
+
118
+ from .completion import BorescopeCompleter
119
+ from .history import history_for
120
+
121
+ self._session = PromptSession(
122
+ history=history_for(self.ctx.target),
123
+ completer=BorescopeCompleter(self.registry.keys(), self.ctx),
124
+ complete_while_typing=False,
125
+ )
126
+ return self._session
127
+
128
+ def _print_banner(self) -> None:
129
+ target = self.ctx.target
130
+ where = target.unit + (f" ({target.container})" if target.container else "")
131
+ print(
132
+ f"borescope — connected to {where}. Type 'help' for commands, 'exit' to quit."
133
+ )
@@ -0,0 +1,35 @@
1
+ """A single small theme module: prompt shape and colours."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from prompt_toolkit.formatted_text import FormattedText
8
+ from prompt_toolkit.styles import Style
9
+
10
+ if TYPE_CHECKING:
11
+ from .context import ShellContext
12
+
13
+ _STYLE = Style.from_dict(
14
+ {
15
+ "prompt.pebble": "ansicyan bold",
16
+ "prompt.path": "ansiblue",
17
+ "prompt.mark": "ansiyellow bold",
18
+ }
19
+ )
20
+
21
+
22
+ def style() -> Style:
23
+ return _STYLE
24
+
25
+
26
+ def prompt_fragments(ctx: ShellContext) -> FormattedText:
27
+ """Render a bash-like prompt: ``pebble:<cwd>#``."""
28
+ return FormattedText(
29
+ [
30
+ ("class:prompt.pebble", "pebble"),
31
+ ("", ":"),
32
+ ("class:prompt.path", ctx.cwd),
33
+ ("class:prompt.mark", "# "),
34
+ ]
35
+ )
borescope/snapshot.py ADDED
@@ -0,0 +1,103 @@
1
+ """``borescope --snapshot`` — dump container state as JSON.
2
+
3
+ Cheap to produce, useful for filing bugs and for feeding tools like
4
+ ``explain-my-model``. The shape is intended to be stable and consumable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from datetime import UTC, datetime
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import yaml
14
+
15
+ from . import __version__
16
+
17
+ if TYPE_CHECKING:
18
+ from .discovery import Target
19
+ from .transport import Transport
20
+
21
+
22
+ def _value(obj: object) -> str:
23
+ return getattr(obj, "value", str(obj))
24
+
25
+
26
+ def build_snapshot(
27
+ transport: Transport, target: Target, *, log_lines: int = 20
28
+ ) -> dict[str, Any]:
29
+ data: dict[str, Any] = {
30
+ "borescope_version": __version__,
31
+ "captured_at": datetime.now(UTC).isoformat(),
32
+ "unit": target.unit,
33
+ "container": target.container,
34
+ "model": target.model,
35
+ "controller": target.controller,
36
+ }
37
+
38
+ try:
39
+ data["system"] = {"version": transport.get_system_info().version}
40
+ except Exception as exc: # noqa: BLE001
41
+ data["system_error"] = str(exc)
42
+
43
+ try:
44
+ data["services"] = [
45
+ {"name": s.name, "startup": _value(s.startup), "current": _value(s.current)}
46
+ for s in transport.get_services()
47
+ ]
48
+ except Exception as exc: # noqa: BLE001
49
+ data["services_error"] = str(exc)
50
+
51
+ try:
52
+ plan = transport.get_plan()
53
+ data["plan"] = (
54
+ plan.to_dict()
55
+ if hasattr(plan, "to_dict")
56
+ else yaml.safe_load(plan.to_yaml())
57
+ )
58
+ except Exception as exc: # noqa: BLE001
59
+ data["plan_error"] = str(exc)
60
+
61
+ try:
62
+ data["checks"] = [
63
+ {
64
+ "name": c.name,
65
+ "level": _value(c.level),
66
+ "status": _value(c.status),
67
+ "failures": c.failures,
68
+ "threshold": c.threshold,
69
+ }
70
+ for c in transport.get_checks()
71
+ ]
72
+ except Exception as exc: # noqa: BLE001
73
+ data["checks_error"] = str(exc)
74
+
75
+ try:
76
+ data["notices"] = [
77
+ {
78
+ "id": n.id,
79
+ "type": _value(n.type),
80
+ "key": n.key,
81
+ "occurrences": n.occurrences,
82
+ "last_repeated": n.last_repeated.isoformat()
83
+ if n.last_repeated
84
+ else None,
85
+ }
86
+ for n in transport.get_notices()
87
+ ]
88
+ except Exception as exc: # noqa: BLE001
89
+ data["notices_error"] = str(exc)
90
+
91
+ try:
92
+ from .transport.relay import run_pebble
93
+
94
+ result = run_pebble(target, ["logs", "-n", str(log_lines)])
95
+ data["recent_logs"] = (result.stdout or "").splitlines()
96
+ except Exception as exc: # noqa: BLE001
97
+ data["logs_error"] = str(exc)
98
+
99
+ return data
100
+
101
+
102
+ def snapshot_json(transport: Transport, target: Target, *, log_lines: int = 20) -> str:
103
+ return json.dumps(build_snapshot(transport, target, log_lines=log_lines), indent=2)
@@ -0,0 +1,162 @@
1
+ """Transport layer (A) — "talk to a Pebble".
2
+
3
+ A narrow interface the shell layer talks to, with two thin backends over an
4
+ ``ops.pebble.Client``-shaped object:
5
+
6
+ - :func:`~borescope.transport.cli_transport.build_cli_transport` (v1 primary) — runs
7
+ the workload container's ``pebble`` via ``juju ssh``.
8
+ - :func:`~borescope.transport.socket_transport.build_socket_transport` (secondary) —
9
+ the real ``ops.pebble.Client`` on a directly-reachable socket.
10
+
11
+ The shell layer only ever sees :class:`Transport`, so the backend choice — and a
12
+ future Go reimplementation of just this layer — stays contained.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterable
18
+ from typing import TYPE_CHECKING, Any, Protocol
19
+
20
+ if TYPE_CHECKING:
21
+ import datetime
22
+ from typing import BinaryIO, TextIO
23
+
24
+ import ops
25
+
26
+
27
+ class Transport(Protocol):
28
+ """The subset of ``ops.pebble.Client`` borescope uses.
29
+
30
+ Both backends (``shimmer.PebbleCliClient`` and ``ops.pebble.Client``) satisfy
31
+ this structurally; it exists to document and contain the only surface that ever
32
+ touches Pebble.
33
+ """
34
+
35
+ # -- system / liveness -------------------------------------------------
36
+ def get_system_info(self) -> ops.pebble.SystemInfo: ...
37
+
38
+ # -- services ----------------------------------------------------------
39
+ def get_services(
40
+ self, names: Iterable[str] | None = None
41
+ ) -> list[ops.pebble.ServiceInfo]: ...
42
+ def start_services(
43
+ self, services: Iterable[str], timeout: float = 30.0, delay: float = 0.1
44
+ ) -> ops.pebble.ChangeID: ...
45
+ def stop_services(
46
+ self, services: Iterable[str], timeout: float = 30.0, delay: float = 0.1
47
+ ) -> ops.pebble.ChangeID: ...
48
+ def restart_services(
49
+ self, services: Iterable[str], timeout: float = 30.0, delay: float = 0.1
50
+ ) -> ops.pebble.ChangeID: ...
51
+ def replan_services(
52
+ self, timeout: float = 30.0, delay: float = 0.1
53
+ ) -> ops.pebble.ChangeID: ...
54
+ def send_signal(self, sig: int | str, services: Iterable[str]) -> None: ...
55
+
56
+ # -- plan / changes ----------------------------------------------------
57
+ def get_plan(self) -> ops.pebble.Plan: ...
58
+ def get_changes(
59
+ self,
60
+ select: ops.pebble.ChangeState = ...,
61
+ service: str | None = None,
62
+ ) -> list[ops.pebble.Change]: ...
63
+ def get_change(self, change_id: ops.pebble.ChangeID) -> ops.pebble.Change: ...
64
+
65
+ # -- checks ------------------------------------------------------------
66
+ def get_checks(
67
+ self,
68
+ level: ops.pebble.CheckLevel | None = None,
69
+ names: Iterable[str] | None = None,
70
+ ) -> list[ops.pebble.CheckInfo]: ...
71
+
72
+ # -- notices -----------------------------------------------------------
73
+ def get_notices(
74
+ self,
75
+ *,
76
+ users: Any = None,
77
+ user_id: int | None = None,
78
+ types: Iterable[Any] | None = None,
79
+ keys: Iterable[str] | None = None,
80
+ ) -> list[ops.pebble.Notice]: ...
81
+ def get_notice(self, id: str) -> ops.pebble.Notice: ...
82
+ def notify(
83
+ self,
84
+ type: ops.pebble.NoticeType,
85
+ key: str,
86
+ *,
87
+ data: dict[str, str] | None = None,
88
+ repeat_after: datetime.timedelta | None = None,
89
+ ) -> str: ...
90
+
91
+ # -- filesystem --------------------------------------------------------
92
+ def pull(
93
+ self, path: str, *, encoding: str | None = "utf-8"
94
+ ) -> BinaryIO | TextIO: ...
95
+ def push(
96
+ self,
97
+ path: str,
98
+ source: Any,
99
+ *,
100
+ encoding: str = "utf-8",
101
+ make_dirs: bool = False,
102
+ permissions: int | None = None,
103
+ user_id: int | None = None,
104
+ user: str | None = None,
105
+ group_id: int | None = None,
106
+ group: str | None = None,
107
+ ) -> None: ...
108
+ def list_files(
109
+ self, path: str, *, pattern: str | None = None, itself: bool = False
110
+ ) -> list[ops.pebble.FileInfo]: ...
111
+ def make_dir(
112
+ self,
113
+ path: str,
114
+ *,
115
+ make_parents: bool = False,
116
+ permissions: int | None = None,
117
+ user_id: int | None = None,
118
+ user: str | None = None,
119
+ group_id: int | None = None,
120
+ group: str | None = None,
121
+ ) -> None: ...
122
+ def remove_path(self, path: str, *, recursive: bool = False) -> None: ...
123
+
124
+ # -- exec --------------------------------------------------------------
125
+ def exec(
126
+ self, command: list[str], **kwargs: Any
127
+ ) -> ops.pebble.ExecProcess[Any]: ...
128
+
129
+
130
+ def open_transport(
131
+ *,
132
+ unit: str,
133
+ container: str | None,
134
+ model: str | None = None,
135
+ juju_binary: str = "juju",
136
+ socket_path: str | None = None,
137
+ via: str = "ssh",
138
+ ) -> Transport:
139
+ """Open the appropriate transport.
140
+
141
+ If *socket_path* is given (a directly-reachable Pebble socket), use the fast
142
+ ``SocketTransport``; otherwise use the ``CliTransport`` relay (via ``juju ssh``
143
+ by default; ``via="exec"`` switches to ``juju exec`` for sites where ssh is
144
+ disabled).
145
+ """
146
+ if socket_path:
147
+ from .socket_transport import build_socket_transport
148
+
149
+ return build_socket_transport(socket_path)
150
+
151
+ from .cli_transport import build_cli_transport
152
+
153
+ return build_cli_transport(
154
+ unit=unit,
155
+ container=container,
156
+ model=model,
157
+ juju_binary=juju_binary,
158
+ via=via,
159
+ )
160
+
161
+
162
+ __all__ = ["Transport", "open_transport"]
@@ -0,0 +1,63 @@
1
+ """CliTransport — the v1 primary backend.
2
+
3
+ Wraps ``shimmer.PebbleCliClient`` (a drop-in ``ops.pebble.Client`` over the Pebble
4
+ CLI) with a :class:`~borescope.transport.runner.JujuSshRunner`. The runner reaches the
5
+ workload's Pebble *through the charm container* (which always has a shell and has the
6
+ workload's socket mounted), so this works even against shell-less rocks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, cast
12
+
13
+ from .runner import JujuExecRunner, JujuSshRunner
14
+
15
+ if TYPE_CHECKING:
16
+ from . import Transport
17
+
18
+ # The charm container's pebble binary (juju-injected; not on $PATH). The per-workload
19
+ # socket path is owned by the runner (``/charm/containers/<name>/pebble.socket``).
20
+ REMOTE_PEBBLE_BINARY = "/charm/bin/pebble"
21
+
22
+ # Default per-call timeout. Higher than shimmer's local default (5 s) because each
23
+ # call pays a ``juju`` round-trip (~0.2 s measured, plus juju client startup).
24
+ DEFAULT_TIMEOUT = 30.0
25
+
26
+ _RUNNERS = {"ssh": JujuSshRunner, "exec": JujuExecRunner}
27
+
28
+
29
+ def build_cli_transport(
30
+ *,
31
+ unit: str,
32
+ container: str | None,
33
+ model: str | None = None,
34
+ juju_binary: str = "juju",
35
+ timeout: float = DEFAULT_TIMEOUT,
36
+ via: str = "ssh",
37
+ ) -> Transport:
38
+ """Build a CLI-relay transport for *unit*'s *container*.
39
+
40
+ *via* picks the Juju relay: ``"ssh"`` (default) uses ``juju ssh`` and works as a
41
+ streaming channel; ``"exec"`` uses ``juju exec`` (request/response) for sites
42
+ where interactive ssh is disabled.
43
+ """
44
+ # Imported lazily: shimmer pulls in ops.pebble, which we keep off the
45
+ # --help / --version cold-start path.
46
+ from shimmer import PebbleCliClient
47
+
48
+ try:
49
+ runner_cls = _RUNNERS[via]
50
+ except KeyError as exc:
51
+ raise ValueError(f"unknown --via {via!r}; choose 'ssh' or 'exec'") from exc
52
+ runner = runner_cls(unit, container, model=model, juju_binary=juju_binary)
53
+ # shimmer's client conforms structurally; cast past the overloaded `exec`
54
+ # signature the type checker can't match against the Transport protocol.
55
+ return cast(
56
+ "Transport",
57
+ PebbleCliClient(
58
+ socket_path=runner.pebble_socket or "",
59
+ pebble_binary=REMOTE_PEBBLE_BINARY,
60
+ runner=runner,
61
+ timeout=timeout,
62
+ ),
63
+ )
@@ -0,0 +1,77 @@
1
+ """Run raw ``pebble`` subcommands over the same relay the transport uses.
2
+
3
+ A few Pebble CLI features (notably ``logs``) aren't part of the
4
+ ``ops.pebble.Client`` API surface, so they're driven by invoking the ``pebble``
5
+ binary directly — remotely via ``juju ssh`` (the CLI relay), or locally against a
6
+ reachable socket. Both the ``logs`` command and ``--snapshot`` share this.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from ..discovery import Target
17
+
18
+
19
+ # Juju injects pebble here in every k8s charm container, but does NOT put it on
20
+ # $PATH. So in --here mode `shutil.which("pebble")` finds nothing — fall through
21
+ # to this absolute location before giving up.
22
+ _JUJU_PEBBLE = "/charm/bin/pebble"
23
+
24
+
25
+ def _local_pebble_binary() -> str:
26
+ """Find the ``pebble`` binary for the local (socket) case.
27
+
28
+ Tries ``$PATH`` first (so a dev with a local Pebble install or snap works),
29
+ then the Juju-injected path inside a charm container.
30
+ """
31
+ found = shutil.which("pebble")
32
+ if found:
33
+ return found
34
+ import os
35
+
36
+ if os.path.exists(_JUJU_PEBBLE):
37
+ return _JUJU_PEBBLE
38
+ # Fall back to the bare name — subprocess will raise FileNotFoundError, which
39
+ # callers wrap into a clear "logs_error" / similar.
40
+ return "pebble"
41
+
42
+
43
+ def pebble_relay(target: Target) -> tuple[list[str], dict[str, str] | None, Any]:
44
+ """Return ``(binary_prefix, env, runner)`` for running ``pebble <args>``.
45
+
46
+ ``runner`` is a shimmer ``Runner`` (``run``/``popen``); ``binary_prefix`` is the
47
+ argv that names the ``pebble`` binary (local ``["pebble"]`` or the remote path).
48
+ """
49
+ if target.socket_path:
50
+ import os
51
+
52
+ from shimmer import LocalSubprocessRunner
53
+
54
+ env = {**os.environ, "PEBBLE_SOCKET": target.socket_path}
55
+ return [_local_pebble_binary()], env, LocalSubprocessRunner()
56
+
57
+ from .cli_transport import _RUNNERS, REMOTE_PEBBLE_BINARY
58
+
59
+ # The runner injects the workload socket env itself (via the charm container),
60
+ # so no env is needed here. Pick the runner that matches the target's
61
+ # --via setting so `logs` / `--snapshot` use the same relay as everything else.
62
+ runner_cls = _RUNNERS.get(target.via, _RUNNERS["ssh"])
63
+ runner = runner_cls(
64
+ target.unit,
65
+ target.container,
66
+ model=target.model,
67
+ juju_binary=target.juju_binary,
68
+ )
69
+ return [REMOTE_PEBBLE_BINARY], None, runner
70
+
71
+
72
+ def run_pebble(
73
+ target: Target, pebble_args: list[str], *, timeout: float = 30.0
74
+ ) -> subprocess.CompletedProcess[Any]:
75
+ """Run ``pebble <pebble_args>`` once and return the completed process."""
76
+ prefix, env, runner = pebble_relay(target)
77
+ return runner.run([*prefix, *pebble_args], env=env, timeout=timeout, check=False)