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
|
@@ -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)
|