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,388 @@
1
+ """Pebble-native subcommands, first-class (not hidden behind a ``pebble`` prefix).
2
+
3
+ These are the operational value-add over a plain shell: ``services``, ``logs``,
4
+ ``plan``, ``checks``, ``notices`` and friends, as thin wrappers over the transport
5
+ (an ``ops.pebble.Client``-shaped object).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import TYPE_CHECKING
12
+
13
+ from ops import pebble
14
+
15
+ from ._args import parse_args
16
+ from .base import Command, Result
17
+
18
+ if TYPE_CHECKING:
19
+ from ..context import ShellContext
20
+
21
+
22
+ def _table(headers: list[str], rows: list[list[str]]) -> str:
23
+ widths = [len(h) for h in headers]
24
+ for row in rows:
25
+ for i, cell in enumerate(row):
26
+ widths[i] = max(widths[i], len(str(cell)))
27
+
28
+ def fmt(row: list[str]) -> str:
29
+ return " ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row))
30
+
31
+ return "\n".join([fmt(headers), *(fmt(r) for r in rows)])
32
+
33
+
34
+ def _enum_value(value: object) -> str:
35
+ return getattr(value, "value", str(value))
36
+
37
+
38
+ # --------------------------------------------------------------------------- #
39
+ # Services
40
+ # --------------------------------------------------------------------------- #
41
+ class Services(Command):
42
+ name = "services"
43
+ summary = "List services and their status"
44
+ usage = "services [name...]"
45
+
46
+ def run(
47
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
48
+ ) -> Result:
49
+ _, _, names = parse_args(args)
50
+ infos = ctx.transport.get_services(names or None)
51
+ if not infos:
52
+ return Result.ok("(no services)")
53
+ rows = [[i.name, _enum_value(i.startup), _enum_value(i.current)] for i in infos]
54
+ return Result.ok(_table(["Service", "Startup", "Current"], rows))
55
+
56
+
57
+ class _ServiceAction(Command):
58
+ verb = ""
59
+ # English past tense — declared per subclass so we don't have to encode
60
+ # consonant-doubling rules ("stop" -> "Stopped", not "Stoped").
61
+ past = ""
62
+
63
+ def run(
64
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
65
+ ) -> Result:
66
+ _, _, names = parse_args(args)
67
+ if not names:
68
+ return Result.fail(f"{self.name}: usage: {self.name} <service...>")
69
+ method = getattr(ctx.transport, f"{self.verb}_services")
70
+ method(names)
71
+ return Result.ok(f"{self.past}: {', '.join(names)}")
72
+
73
+
74
+ class Start(_ServiceAction):
75
+ name = "start"
76
+ verb = "start"
77
+ past = "Started"
78
+ summary = "Start services"
79
+ usage = "start <service...>"
80
+
81
+
82
+ class Stop(_ServiceAction):
83
+ name = "stop"
84
+ verb = "stop"
85
+ past = "Stopped"
86
+ summary = "Stop services"
87
+ usage = "stop <service...>"
88
+
89
+
90
+ class Restart(_ServiceAction):
91
+ name = "restart"
92
+ verb = "restart"
93
+ past = "Restarted"
94
+ summary = "Restart services"
95
+ usage = "restart <service...>"
96
+
97
+
98
+ class Replan(Command):
99
+ name = "replan"
100
+ summary = "Apply the plan: stop/start services as the plan requires"
101
+
102
+ def run(
103
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
104
+ ) -> Result:
105
+ ctx.transport.replan_services()
106
+ return Result.ok("Replanned.")
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # Plan
111
+ # --------------------------------------------------------------------------- #
112
+ class Plan(Command):
113
+ name = "plan"
114
+ summary = "Show the merged Pebble plan (YAML)"
115
+
116
+ def run(
117
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
118
+ ) -> Result:
119
+ plan = ctx.transport.get_plan()
120
+ return Result.ok(plan.to_yaml().rstrip("\n"))
121
+
122
+
123
+ # --------------------------------------------------------------------------- #
124
+ # Logs (CLI-shaped: driven over the relay, not the ops API)
125
+ # --------------------------------------------------------------------------- #
126
+ class Logs(Command):
127
+ name = "logs"
128
+ summary = "Show service logs (-f / --follow to stream)"
129
+ usage = "logs [-f|--follow] [-n N] [service...]"
130
+
131
+ def run(
132
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
133
+ ) -> Result:
134
+ flags, values, services = parse_args(args, valued=("n",))
135
+ follow = "f" in flags or "follow" in flags
136
+ pebble_args = ["logs"]
137
+ if follow:
138
+ pebble_args.append("--follow")
139
+ if "n" in values:
140
+ pebble_args += ["-n", values["n"]]
141
+ pebble_args += services
142
+
143
+ argv, env, runner = self._relay(ctx)
144
+ argv = [*argv, *pebble_args]
145
+ if not follow:
146
+ result = runner.run(argv, env=env, timeout=30.0, check=False)
147
+ return Result(
148
+ output=result.stdout or "",
149
+ error=result.stderr or "",
150
+ code=result.returncode,
151
+ )
152
+ return self._follow(runner, argv, env)
153
+
154
+ @staticmethod
155
+ def _relay(ctx: ShellContext):
156
+ from ...transport.relay import pebble_relay
157
+
158
+ return pebble_relay(ctx.target)
159
+
160
+ @staticmethod
161
+ def _follow(runner, argv: list[str], env: dict[str, str]) -> Result:
162
+ process = runner.popen(
163
+ argv, stdin=None, stdout=sys.stdout, stderr=sys.stdout, text=True, env=env
164
+ )
165
+ try:
166
+ process.wait()
167
+ except KeyboardInterrupt:
168
+ process.terminate()
169
+ sys.stdout.write("\n")
170
+ return Result()
171
+
172
+
173
+ # --------------------------------------------------------------------------- #
174
+ # Checks / health
175
+ # --------------------------------------------------------------------------- #
176
+ class Checks(Command):
177
+ name = "checks"
178
+ summary = "List health checks and their status"
179
+ usage = "checks [name...]"
180
+
181
+ def run(
182
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
183
+ ) -> Result:
184
+ _, _, names = parse_args(args)
185
+ infos = ctx.transport.get_checks(names=names or None)
186
+ if not infos:
187
+ return Result.ok("(no checks)")
188
+ rows = [
189
+ [
190
+ i.name,
191
+ _enum_value(i.level),
192
+ _enum_value(i.status),
193
+ f"{i.failures}/{i.threshold}",
194
+ ]
195
+ for i in infos
196
+ ]
197
+ return Result.ok(_table(["Check", "Level", "Status", "Failures"], rows))
198
+
199
+
200
+ class Health(Command):
201
+ name = "health"
202
+ summary = "Report overall health (all checks up?)"
203
+
204
+ def run(
205
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
206
+ ) -> Result:
207
+ infos = ctx.transport.get_checks()
208
+ failing = [i.name for i in infos if _enum_value(i.status).lower() != "up"]
209
+ if not infos:
210
+ return Result.ok("healthy (no checks configured)")
211
+ if failing:
212
+ return Result(output=f"unhealthy: {', '.join(failing)} not up", code=1)
213
+ return Result.ok("healthy")
214
+
215
+
216
+ # --------------------------------------------------------------------------- #
217
+ # Notices
218
+ # --------------------------------------------------------------------------- #
219
+ class Notices(Command):
220
+ name = "notices"
221
+ summary = "List recent notices"
222
+
223
+ def run(
224
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
225
+ ) -> Result:
226
+ notices = ctx.transport.get_notices()
227
+ if not notices:
228
+ return Result.ok("(no notices)")
229
+ rows = [
230
+ [
231
+ n.id,
232
+ _enum_value(n.type),
233
+ n.key,
234
+ str(n.occurrences),
235
+ n.last_repeated.isoformat() if n.last_repeated else "",
236
+ ]
237
+ for n in notices
238
+ ]
239
+ return Result.ok(_table(["ID", "Type", "Key", "Count", "Last"], rows))
240
+
241
+
242
+ class Notice(Command):
243
+ name = "notice"
244
+ summary = "Show a single notice by ID"
245
+ usage = "notice <id>"
246
+
247
+ def run(
248
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
249
+ ) -> Result:
250
+ if not args:
251
+ return Result.fail("notice: usage: notice <id>")
252
+ notice = ctx.transport.get_notice(args[0])
253
+ lines = [
254
+ f"ID: {notice.id}",
255
+ f"Type: {_enum_value(notice.type)}",
256
+ f"Key: {notice.key}",
257
+ f"Occurrences: {notice.occurrences}",
258
+ f"First: {notice.first_occurred.isoformat() if notice.first_occurred else ''}",
259
+ f"Last: {notice.last_occurred.isoformat() if notice.last_occurred else ''}",
260
+ ]
261
+ if notice.last_data:
262
+ lines.append(f"Data: {notice.last_data}")
263
+ return Result.ok("\n".join(lines))
264
+
265
+
266
+ class Notify(Command):
267
+ name = "notify"
268
+ summary = "Record a custom notice"
269
+ usage = "notify <key> [data-key=value...]"
270
+
271
+ def run(
272
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
273
+ ) -> Result:
274
+ if not args:
275
+ return Result.fail("notify: usage: notify <key> [data-key=value...]")
276
+ key, *rest = args
277
+ data: dict[str, str] = {}
278
+ for item in rest:
279
+ name, _, value = item.partition("=")
280
+ data[name] = value
281
+ notice_id = ctx.transport.notify(
282
+ pebble.NoticeType.CUSTOM, key, data=data or None
283
+ )
284
+ return Result.ok(f"Recorded notice {notice_id}")
285
+
286
+
287
+ # --------------------------------------------------------------------------- #
288
+ # Changes / tasks
289
+ # --------------------------------------------------------------------------- #
290
+ def _all_changes(transport) -> list:
291
+ state = getattr(pebble.ChangeState, "ALL", None)
292
+ return transport.get_changes(select=state) if state else transport.get_changes()
293
+
294
+
295
+ class Changes(Command):
296
+ name = "changes"
297
+ summary = "List recent changes"
298
+
299
+ def run(
300
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
301
+ ) -> Result:
302
+ changes = _all_changes(ctx.transport)
303
+ if not changes:
304
+ return Result.ok("(no changes)")
305
+ rows = [
306
+ [
307
+ c.id,
308
+ _enum_value(c.status),
309
+ "ready" if c.ready else "doing",
310
+ c.summary,
311
+ ]
312
+ for c in changes
313
+ ]
314
+ return Result.ok(_table(["ID", "Status", "State", "Summary"], rows))
315
+
316
+
317
+ class Tasks(Command):
318
+ name = "tasks"
319
+ summary = "Show tasks for a change (defaults to the most recent)"
320
+ usage = "tasks [change-id]"
321
+
322
+ def run(
323
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
324
+ ) -> Result:
325
+ if args:
326
+ change = ctx.transport.get_change(pebble.ChangeID(args[0]))
327
+ else:
328
+ changes = _all_changes(ctx.transport)
329
+ if not changes:
330
+ return Result.ok("(no changes)")
331
+ change = changes[-1]
332
+ rows = [
333
+ [_enum_value(t.status), t.summary] for t in getattr(change, "tasks", [])
334
+ ]
335
+ if not rows:
336
+ return Result.ok(f"Change {change.id}: (no tasks)")
337
+ header = f"Change {change.id}: {change.summary}"
338
+ return Result.ok(header + "\n" + _table(["Status", "Summary"], rows))
339
+
340
+
341
+ # --------------------------------------------------------------------------- #
342
+ # push / pull (explicit transfer, complementing cp/cat)
343
+ # --------------------------------------------------------------------------- #
344
+ class Pull(Command):
345
+ name = "pull"
346
+ summary = "Copy a file from the container to the local host"
347
+ usage = "pull <remote> <local>"
348
+
349
+ def run(
350
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
351
+ ) -> Result:
352
+ _, _, paths = parse_args(args)
353
+ if len(paths) != 2:
354
+ return Result.fail("pull: usage: pull <remote> <local>")
355
+ from .. import pathutils
356
+
357
+ remote = pathutils.resolve(ctx.cwd, paths[0], home=ctx.home)
358
+ try:
359
+ with ctx.transport.pull(remote, encoding=None) as handle:
360
+ data = handle.read()
361
+ with open(paths[1], "wb") as out:
362
+ out.write(data if isinstance(data, bytes) else data.encode("utf-8"))
363
+ except Exception as exc: # noqa: BLE001
364
+ return Result.fail(f"pull: {exc}")
365
+ return Result.ok(f"Pulled {paths[0]} -> {paths[1]}")
366
+
367
+
368
+ class Push(Command):
369
+ name = "push"
370
+ summary = "Copy a local file into the container"
371
+ usage = "push <local> <remote>"
372
+
373
+ def run(
374
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
375
+ ) -> Result:
376
+ _, _, paths = parse_args(args)
377
+ if len(paths) != 2:
378
+ return Result.fail("push: usage: push <local> <remote>")
379
+ from .. import pathutils
380
+
381
+ remote = pathutils.resolve(ctx.cwd, paths[1], home=ctx.home)
382
+ try:
383
+ with open(paths[0], "rb") as handle:
384
+ data = handle.read()
385
+ ctx.transport.push(remote, data, make_dirs=True)
386
+ except Exception as exc: # noqa: BLE001
387
+ return Result.fail(f"push: {exc}")
388
+ return Result.ok(f"Pushed {paths[0]} -> {paths[1]}")
@@ -0,0 +1,77 @@
1
+ """Tab completion: built-in command names, and container-side filesystem paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from collections.abc import Iterable, Iterator
7
+ from typing import TYPE_CHECKING
8
+
9
+ from prompt_toolkit.completion import Completer, Completion
10
+
11
+ from . import pathutils
12
+
13
+ if TYPE_CHECKING:
14
+ from prompt_toolkit.completion import CompleteEvent
15
+ from prompt_toolkit.document import Document
16
+
17
+ from .context import ShellContext
18
+
19
+
20
+ class BorescopeCompleter(Completer):
21
+ """Complete the first token as a command name, later tokens as paths.
22
+
23
+ Path completion lists files inside the container via the transport. It only
24
+ fires on an explicit Tab (the session is created with
25
+ ``complete_while_typing=False``), so the ``juju ssh`` round-trip per request is
26
+ acceptable.
27
+ """
28
+
29
+ def __init__(self, command_names: Iterable[str], ctx: ShellContext):
30
+ self.command_names = sorted(set(command_names))
31
+ self.ctx = ctx
32
+
33
+ def get_completions(
34
+ self, document: Document, complete_event: CompleteEvent
35
+ ) -> Iterator[Completion]:
36
+ text = document.text_before_cursor
37
+ try:
38
+ tokens = shlex.split(text)
39
+ except ValueError:
40
+ tokens = text.split()
41
+
42
+ ends_with_space = text[-1:].isspace()
43
+ if ends_with_space:
44
+ word = ""
45
+ tokens_before = len(tokens)
46
+ else:
47
+ word = tokens[-1] if tokens else ""
48
+ tokens_before = len(tokens) - 1
49
+
50
+ if tokens_before <= 0:
51
+ for name in self.command_names:
52
+ if name.startswith(word):
53
+ yield Completion(name, start_position=-len(word))
54
+ return
55
+
56
+ yield from self._complete_path(word)
57
+
58
+ def _complete_path(self, word: str) -> Iterator[Completion]:
59
+ ctx = self.ctx
60
+ if "/" in word:
61
+ dir_part, _, prefix = word.rpartition("/")
62
+ base = pathutils.resolve(ctx.cwd, dir_part or "/", home=ctx.home)
63
+ else:
64
+ prefix = word
65
+ base = ctx.cwd
66
+ try:
67
+ entries = ctx.transport.list_files(base)
68
+ except Exception: # noqa: BLE001 - completion must never raise
69
+ return
70
+ for info in entries:
71
+ name = info.name
72
+ if not name.startswith(prefix):
73
+ continue
74
+ is_dir = getattr(getattr(info, "type", None), "name", "") == "DIRECTORY"
75
+ yield Completion(
76
+ name + ("/" if is_dir else ""), start_position=-len(prefix)
77
+ )
@@ -0,0 +1,33 @@
1
+ """Per-session shell state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from ..discovery import Target
10
+ from ..transport import Transport
11
+
12
+
13
+ def _default_env() -> dict[str, str]:
14
+ return {"HOME": "/root", "PWD": "/"}
15
+
16
+
17
+ @dataclass
18
+ class ShellContext:
19
+ """The mutable state a command may read or update during a session."""
20
+
21
+ transport: Transport
22
+ target: Target
23
+ cwd: str = "/"
24
+ env: dict[str, str] = field(default_factory=_default_env)
25
+ last_exit: int = 0
26
+
27
+ @property
28
+ def home(self) -> str:
29
+ return self.env.get("HOME", "/root")
30
+
31
+ def chdir(self, path: str) -> None:
32
+ self.cwd = path
33
+ self.env["PWD"] = path
@@ -0,0 +1,29 @@
1
+ """File-backed command history, keyed per controller/model/unit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import pathlib
7
+ from typing import TYPE_CHECKING
8
+
9
+ from prompt_toolkit.history import FileHistory, History, InMemoryHistory
10
+
11
+ if TYPE_CHECKING:
12
+ from ..discovery import Target
13
+
14
+
15
+ def history_path(target: Target) -> pathlib.Path:
16
+ base = os.environ.get("XDG_STATE_HOME") or os.path.join(
17
+ pathlib.Path.home(), ".local", "state"
18
+ )
19
+ return pathlib.Path(base, "borescope", "history", target.history_key)
20
+
21
+
22
+ def history_for(target: Target) -> History:
23
+ """Return a per-target :class:`History`, falling back to in-memory on error."""
24
+ try:
25
+ path = history_path(target)
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ return FileHistory(str(path))
28
+ except OSError: # pragma: no cover - unwritable home dir
29
+ return InMemoryHistory()
@@ -0,0 +1,91 @@
1
+ """Line parsing for the v1 shell.
2
+
3
+ Deliberately tiny: one command at a time, with at most a single ``|`` between two
4
+ stages. No subshells, ``&&``/``||``/``;``, redirection, or background jobs — that
5
+ covers ~95% of debug-shell use at a fraction of the complexity, and the ``exec``
6
+ escape hatch handles the rest by running a real binary in the container.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shlex
13
+ from collections.abc import Mapping
14
+
15
+ from ..errors import BorescopeError
16
+
17
+ _UNSUPPORTED = {
18
+ ";": "sequencing (;)",
19
+ "&": "background jobs (&)",
20
+ "&&": "'&&'",
21
+ "||": "'||'",
22
+ ">": "output redirection (>)",
23
+ ">>": "output redirection (>>)",
24
+ "<": "input redirection (<)",
25
+ "(": "subshells",
26
+ ")": "subshells",
27
+ }
28
+
29
+ _VAR = re.compile(r"\$(\w+)|\$\{(\w+)\}")
30
+
31
+
32
+ class ParseError(BorescopeError):
33
+ """Raised when a line cannot be parsed under the v1 grammar."""
34
+
35
+
36
+ def tokenize(line: str) -> list[str]:
37
+ """Split *line* into tokens, keeping shell operators as their own tokens."""
38
+ lexer = shlex.shlex(line, posix=True, punctuation_chars=True)
39
+ lexer.whitespace_split = True
40
+ try:
41
+ return list(lexer)
42
+ except ValueError as exc: # e.g. unbalanced quotes
43
+ raise ParseError(str(exc)) from exc
44
+
45
+
46
+ def parse_pipeline(line: str) -> list[list[str]]:
47
+ """Parse *line* into a list of stages, each a list of argv tokens.
48
+
49
+ Returns ``[]`` for a blank line. Raises :class:`ParseError` for unsupported
50
+ syntax or more than one pipe.
51
+ """
52
+ tokens = tokenize(line)
53
+ if not tokens:
54
+ return []
55
+
56
+ for tok in tokens:
57
+ if tok in _UNSUPPORTED:
58
+ raise ParseError(
59
+ f"{_UNSUPPORTED[tok]} is not supported in v1. "
60
+ "Use one command at a time (with at most a single '|')."
61
+ )
62
+
63
+ stages: list[list[str]] = []
64
+ current: list[str] = []
65
+ for tok in tokens:
66
+ if tok == "|":
67
+ stages.append(current)
68
+ current = []
69
+ else:
70
+ current.append(tok)
71
+ stages.append(current)
72
+
73
+ if len(stages) > 2:
74
+ raise ParseError("only a single pipe ('cmd1 | cmd2') is supported in v1.")
75
+ if any(not stage for stage in stages):
76
+ raise ParseError("syntax error near '|' (empty pipe stage).")
77
+ return stages
78
+
79
+
80
+ def expand(token: str, env: Mapping[str, str]) -> str:
81
+ """Expand a leading ``~`` and ``$VAR`` / ``${VAR}`` references using *env*."""
82
+ if token == "~":
83
+ token = env.get("HOME", "/root")
84
+ elif token.startswith("~/"):
85
+ token = env.get("HOME", "/root") + token[1:]
86
+
87
+ def _sub(match: re.Match[str]) -> str:
88
+ name = match.group(1) or match.group(2)
89
+ return env.get(name, "")
90
+
91
+ return _VAR.sub(_sub, token)
@@ -0,0 +1,21 @@
1
+ """Container-side path helpers (POSIX semantics, regardless of the host OS)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+
7
+
8
+ def resolve(cwd: str, path: str, *, home: str = "/root") -> str:
9
+ """Resolve *path* (relative to *cwd*) to an absolute, normalised path.
10
+
11
+ Handles ``~`` / ``~/…`` (against *home*), relative paths, and ``.`` / ``..``.
12
+ """
13
+ if not path:
14
+ return cwd
15
+ if path == "~":
16
+ path = home
17
+ elif path.startswith("~/"):
18
+ path = home + path[1:]
19
+ if not path.startswith("/"):
20
+ path = posixpath.join(cwd, path)
21
+ return posixpath.normpath(path)