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 +10 -0
- borescope/__main__.py +10 -0
- borescope/cli.py +144 -0
- borescope/discovery.py +256 -0
- borescope/errors.py +26 -0
- borescope/juju.py +89 -0
- borescope/shell/__init__.py +8 -0
- borescope/shell/commands/__init__.py +13 -0
- borescope/shell/commands/_args.py +54 -0
- borescope/shell/commands/base.py +81 -0
- borescope/shell/commands/basic.py +117 -0
- borescope/shell/commands/execcmd.py +36 -0
- borescope/shell/commands/filesystem.py +494 -0
- borescope/shell/commands/pebble.py +388 -0
- borescope/shell/completion.py +77 -0
- borescope/shell/context.py +33 -0
- borescope/shell/history.py +29 -0
- borescope/shell/parser.py +91 -0
- borescope/shell/pathutils.py +21 -0
- borescope/shell/repl.py +133 -0
- borescope/shell/theme.py +35 -0
- borescope/snapshot.py +103 -0
- borescope/transport/__init__.py +162 -0
- borescope/transport/cli_transport.py +63 -0
- borescope/transport/relay.py +77 -0
- borescope/transport/runner.py +149 -0
- borescope/transport/socket_transport.py +29 -0
- borescope-0.1.0.dev0.dist-info/METADATA +100 -0
- borescope-0.1.0.dev0.dist-info/RECORD +32 -0
- borescope-0.1.0.dev0.dist-info/WHEEL +4 -0
- borescope-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- borescope-0.1.0.dev0.dist-info/licenses/LICENSE +203 -0
borescope/shell/repl.py
ADDED
|
@@ -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
|
+
)
|
borescope/shell/theme.py
ADDED
|
@@ -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)
|