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/__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
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,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
|