meshagent-cli 0.22.2__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.
Potentially problematic release.
This version of meshagent-cli might be problematic. Click here for more details.
- meshagent/cli/__init__.py +3 -0
- meshagent/cli/agent.py +273 -0
- meshagent/cli/api_keys.py +102 -0
- meshagent/cli/async_typer.py +79 -0
- meshagent/cli/auth.py +30 -0
- meshagent/cli/auth_async.py +295 -0
- meshagent/cli/call.py +215 -0
- meshagent/cli/chatbot.py +1983 -0
- meshagent/cli/cli.py +187 -0
- meshagent/cli/cli_mcp.py +408 -0
- meshagent/cli/cli_secrets.py +414 -0
- meshagent/cli/common_options.py +47 -0
- meshagent/cli/containers.py +725 -0
- meshagent/cli/database.py +997 -0
- meshagent/cli/developer.py +70 -0
- meshagent/cli/exec.py +397 -0
- meshagent/cli/helper.py +236 -0
- meshagent/cli/helpers.py +185 -0
- meshagent/cli/host.py +41 -0
- meshagent/cli/mailbot.py +1295 -0
- meshagent/cli/mailboxes.py +223 -0
- meshagent/cli/meeting_transcriber.py +138 -0
- meshagent/cli/messaging.py +157 -0
- meshagent/cli/multi.py +357 -0
- meshagent/cli/oauth2.py +341 -0
- meshagent/cli/participant_token.py +63 -0
- meshagent/cli/port.py +70 -0
- meshagent/cli/projects.py +105 -0
- meshagent/cli/queue.py +91 -0
- meshagent/cli/room.py +26 -0
- meshagent/cli/rooms.py +214 -0
- meshagent/cli/services.py +722 -0
- meshagent/cli/sessions.py +26 -0
- meshagent/cli/storage.py +813 -0
- meshagent/cli/sync.py +434 -0
- meshagent/cli/task_runner.py +1317 -0
- meshagent/cli/version.py +1 -0
- meshagent/cli/voicebot.py +624 -0
- meshagent/cli/webhook.py +100 -0
- meshagent/cli/worker.py +1403 -0
- meshagent_cli-0.22.2.dist-info/METADATA +49 -0
- meshagent_cli-0.22.2.dist-info/RECORD +45 -0
- meshagent_cli-0.22.2.dist-info/WHEEL +5 -0
- meshagent_cli-0.22.2.dist-info/entry_points.txt +2 -0
- meshagent_cli-0.22.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
# meshagent/cli/containers.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import tarfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pathlib
|
|
12
|
+
import pathspec
|
|
13
|
+
|
|
14
|
+
import aiofiles
|
|
15
|
+
import aiofiles.ospath
|
|
16
|
+
import typer
|
|
17
|
+
from rich import print
|
|
18
|
+
from typing import Annotated, Optional, List, Dict
|
|
19
|
+
|
|
20
|
+
from meshagent.cli import async_typer
|
|
21
|
+
from meshagent.cli.common_options import ProjectIdOption, RoomOption
|
|
22
|
+
from meshagent.cli.helper import (
|
|
23
|
+
get_client,
|
|
24
|
+
resolve_project_id,
|
|
25
|
+
resolve_room,
|
|
26
|
+
)
|
|
27
|
+
from meshagent.api import (
|
|
28
|
+
RoomClient,
|
|
29
|
+
WebSocketClientProtocol,
|
|
30
|
+
)
|
|
31
|
+
from meshagent.api.helpers import meshagent_base_url, websocket_room_url
|
|
32
|
+
from meshagent.api.room_server_client import (
|
|
33
|
+
DockerSecret,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
app = async_typer.AsyncTyper(help="Manage containers and images inside a room")
|
|
39
|
+
|
|
40
|
+
# -------------------------
|
|
41
|
+
# Helpers
|
|
42
|
+
# -------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_keyvals(items: List[str]) -> Dict[str, str]:
|
|
46
|
+
"""
|
|
47
|
+
Parse ["KEY=VAL", "FOO=BAR"] -> {"KEY":"VAL", "FOO":"BAR"}
|
|
48
|
+
"""
|
|
49
|
+
out: Dict[str, str] = {}
|
|
50
|
+
for s in items or []:
|
|
51
|
+
if "=" not in s:
|
|
52
|
+
raise typer.BadParameter(f"Expected KEY=VALUE, got: {s}")
|
|
53
|
+
k, v = s.split("=", 1)
|
|
54
|
+
out[k] = v
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_ports(items: List[str]) -> Dict[int, int]:
|
|
59
|
+
"""
|
|
60
|
+
Parse ["8080:3000", "9999:9999"] as CONTAINER:HOST -> {8080:3000, 9999:9999}
|
|
61
|
+
(Matches server's expectation: container_port -> host_port.)
|
|
62
|
+
"""
|
|
63
|
+
out: Dict[int, int] = {}
|
|
64
|
+
for s in items or []:
|
|
65
|
+
if ":" not in s:
|
|
66
|
+
raise typer.BadParameter(f"Expected CONTAINER:HOST, got: {s}")
|
|
67
|
+
c, h = s.split(":", 1)
|
|
68
|
+
try:
|
|
69
|
+
cp, hp = int(c), int(h)
|
|
70
|
+
except ValueError:
|
|
71
|
+
raise typer.BadParameter(f"Ports must be integers, got: {s}")
|
|
72
|
+
out[cp] = hp
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_creds(items: List[str]) -> List[DockerSecret]:
|
|
77
|
+
"""
|
|
78
|
+
Parse creds given as:
|
|
79
|
+
--cred username,password
|
|
80
|
+
--cred registry,username,password
|
|
81
|
+
"""
|
|
82
|
+
creds: List[DockerSecret] = []
|
|
83
|
+
for s in items or []:
|
|
84
|
+
parts = [p.strip() for p in s.split(",")]
|
|
85
|
+
if len(parts) == 2:
|
|
86
|
+
u, p = parts
|
|
87
|
+
creds.append(DockerSecret(username=u, password=p))
|
|
88
|
+
elif len(parts) == 3:
|
|
89
|
+
r, u, p = parts
|
|
90
|
+
creds.append(DockerSecret(registry=r, username=u, password=p))
|
|
91
|
+
else:
|
|
92
|
+
raise typer.BadParameter(
|
|
93
|
+
f"Invalid --cred format: {s}. Use username,password or registry,username,password"
|
|
94
|
+
)
|
|
95
|
+
return creds
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DockerIgnore:
|
|
99
|
+
def __init__(self, dockerignore_path: str):
|
|
100
|
+
"""
|
|
101
|
+
Load a .dockerignore file and compile its patterns.
|
|
102
|
+
"""
|
|
103
|
+
dockerignore_file = pathlib.Path(dockerignore_path)
|
|
104
|
+
if dockerignore_file.exists():
|
|
105
|
+
with dockerignore_file.open("r") as f:
|
|
106
|
+
patterns = f.read().splitlines()
|
|
107
|
+
else:
|
|
108
|
+
patterns = []
|
|
109
|
+
|
|
110
|
+
# pathspec with gitwildmatch is the same style used by dockerignore
|
|
111
|
+
self._spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
112
|
+
|
|
113
|
+
def matches(self, path: str) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Return True if the given path matches a pattern in the .dockerignore file.
|
|
116
|
+
Path can be relative or absolute.
|
|
117
|
+
"""
|
|
118
|
+
return self._spec.match_file(path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _make_targz_from_dir(path: Path) -> bytes:
|
|
122
|
+
buf = io.BytesIO()
|
|
123
|
+
|
|
124
|
+
docker_ignore = None
|
|
125
|
+
|
|
126
|
+
def _tarfilter_strip_macos(ti: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
|
127
|
+
"""
|
|
128
|
+
Filter to make Linux-friendly tarballs:
|
|
129
|
+
- Drop AppleDouble files (._*)
|
|
130
|
+
- Reset uid/gid/uname/gname
|
|
131
|
+
- Clear pax headers
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
if docker_ignore is not None:
|
|
135
|
+
if docker_ignore.matches(ti.path):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
base = os.path.basename(ti.name)
|
|
139
|
+
if base.startswith("._"):
|
|
140
|
+
return None
|
|
141
|
+
ti.uid = 0
|
|
142
|
+
ti.gid = 0
|
|
143
|
+
ti.uname = ""
|
|
144
|
+
ti.gname = ""
|
|
145
|
+
ti.pax_headers = {}
|
|
146
|
+
# Preserve mode & type; set a stable-ish mtime
|
|
147
|
+
if ti.mtime is None:
|
|
148
|
+
ti.mtime = int(time.time())
|
|
149
|
+
return ti
|
|
150
|
+
|
|
151
|
+
docker_ignore_path = path.joinpath(".dockerignore")
|
|
152
|
+
|
|
153
|
+
if await aiofiles.ospath.exists(docker_ignore_path):
|
|
154
|
+
docker_ignore = DockerIgnore(docker_ignore_path)
|
|
155
|
+
|
|
156
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
157
|
+
tar.add(path, arcname=".", filter=_tarfilter_strip_macos)
|
|
158
|
+
return buf.getvalue()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _drain_stream_plain(stream, *, show_progress: bool = True):
|
|
162
|
+
async def _logs():
|
|
163
|
+
async for line in stream.logs():
|
|
164
|
+
# Server emits plain lines; print as-is
|
|
165
|
+
if line is not None:
|
|
166
|
+
print(line)
|
|
167
|
+
|
|
168
|
+
async def _prog():
|
|
169
|
+
if not show_progress:
|
|
170
|
+
async for _ in stream.progress():
|
|
171
|
+
pass
|
|
172
|
+
return
|
|
173
|
+
async for p in stream.progress():
|
|
174
|
+
if p is None:
|
|
175
|
+
return
|
|
176
|
+
msg = p.message or ""
|
|
177
|
+
# Show progress if we have numbers, else just the message.
|
|
178
|
+
if p.current is not None and p.total:
|
|
179
|
+
print(f"[cyan]{msg} ({p.current}/{p.total})[/cyan]")
|
|
180
|
+
elif msg:
|
|
181
|
+
print(f"[cyan]{msg}[/cyan]")
|
|
182
|
+
|
|
183
|
+
t1 = asyncio.create_task(_logs())
|
|
184
|
+
t2 = asyncio.create_task(_prog())
|
|
185
|
+
try:
|
|
186
|
+
return await stream
|
|
187
|
+
finally:
|
|
188
|
+
await asyncio.gather(t1, t2, return_exceptions=True)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def _drain_stream_pretty(stream):
|
|
192
|
+
import asyncio
|
|
193
|
+
import math
|
|
194
|
+
from rich.table import Column
|
|
195
|
+
from rich.live import Live
|
|
196
|
+
from rich.panel import Panel
|
|
197
|
+
from rich.console import Group
|
|
198
|
+
from rich.text import Text
|
|
199
|
+
from rich.progress import (
|
|
200
|
+
Progress,
|
|
201
|
+
TextColumn,
|
|
202
|
+
BarColumn,
|
|
203
|
+
TimeElapsedColumn,
|
|
204
|
+
ProgressColumn,
|
|
205
|
+
SpinnerColumn,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
class MaybeMofN(ProgressColumn):
|
|
209
|
+
def render(self, task):
|
|
210
|
+
import math
|
|
211
|
+
from rich.text import Text
|
|
212
|
+
|
|
213
|
+
def _fmt_bytes(n):
|
|
214
|
+
if n is None:
|
|
215
|
+
return ""
|
|
216
|
+
n = float(n)
|
|
217
|
+
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
|
218
|
+
i = 0
|
|
219
|
+
while n >= 1024 and i < len(units) - 1:
|
|
220
|
+
n /= 1024
|
|
221
|
+
i += 1
|
|
222
|
+
return f"{n:.1f} {units[i]}"
|
|
223
|
+
|
|
224
|
+
if task.total == 0 or math.isinf(task.total):
|
|
225
|
+
return Text("")
|
|
226
|
+
return Text(f"{_fmt_bytes(task.completed)} / {_fmt_bytes(task.total)}")
|
|
227
|
+
|
|
228
|
+
class MaybeBarColumn(BarColumn):
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
bar_width: int | None = 28,
|
|
233
|
+
hide_when_unknown: bool = False,
|
|
234
|
+
column_width: int | None = None,
|
|
235
|
+
**kwargs,
|
|
236
|
+
):
|
|
237
|
+
# bar_width controls the drawn bar size; None = flex
|
|
238
|
+
super().__init__(bar_width=bar_width, **kwargs)
|
|
239
|
+
self.hide_when_unknown = hide_when_unknown
|
|
240
|
+
self.column_width = column_width # fix the table column if set
|
|
241
|
+
|
|
242
|
+
def get_table_column(self) -> Column:
|
|
243
|
+
if self.column_width is None:
|
|
244
|
+
# default behavior (may flex depending on layout)
|
|
245
|
+
return Column(no_wrap=True)
|
|
246
|
+
return Column(
|
|
247
|
+
width=self.column_width,
|
|
248
|
+
min_width=self.column_width,
|
|
249
|
+
max_width=self.column_width,
|
|
250
|
+
no_wrap=True,
|
|
251
|
+
overflow="crop",
|
|
252
|
+
justify="left",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def render(self, task):
|
|
256
|
+
if task.total is None or task.total == 0 or math.isinf(task.total):
|
|
257
|
+
return Text("") # hide bar for indeterminate tasks
|
|
258
|
+
return super().render(task)
|
|
259
|
+
|
|
260
|
+
class MaybeETA(ProgressColumn):
|
|
261
|
+
"""Show ETA only if total is known."""
|
|
262
|
+
|
|
263
|
+
_elapsed = TimeElapsedColumn()
|
|
264
|
+
|
|
265
|
+
def render(self, task):
|
|
266
|
+
# You can swap this to a TimeRemainingColumn() if you prefer,
|
|
267
|
+
# but hide when total is unknown.
|
|
268
|
+
if task.total == 0 or math.isinf(task.total):
|
|
269
|
+
return Text("")
|
|
270
|
+
return self._elapsed.render(task) # or TimeRemainingColumn().render(task)
|
|
271
|
+
|
|
272
|
+
progress = Progress(
|
|
273
|
+
SpinnerColumn(),
|
|
274
|
+
TextColumn(
|
|
275
|
+
"[bold]{task.description}",
|
|
276
|
+
table_column=Column(ratio=8, no_wrap=True, overflow="ellipsis"),
|
|
277
|
+
),
|
|
278
|
+
MaybeMofN(table_column=Column(ratio=2, no_wrap=True, overflow="ellipsis")),
|
|
279
|
+
MaybeETA(table_column=Column(ratio=1, no_wrap=True, overflow="ellipsis")),
|
|
280
|
+
MaybeBarColumn(pulse_style="cyan", bar_width=20, hide_when_unknown=True),
|
|
281
|
+
# pulses automatically if total=None
|
|
282
|
+
transient=False, # we’re inside Live; we’ll hide tasks ourselves
|
|
283
|
+
expand=True,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
logs_tail: list[str] = []
|
|
287
|
+
tasks: dict[str, int] = {} # layer -> task_id
|
|
288
|
+
|
|
289
|
+
def render():
|
|
290
|
+
tail = "\n".join(logs_tail[-12:]) or "waiting…"
|
|
291
|
+
return Group(
|
|
292
|
+
progress,
|
|
293
|
+
Panel(tail, title="logs", border_style="cyan", height=12),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def _logs():
|
|
297
|
+
async for line in stream.logs():
|
|
298
|
+
if line:
|
|
299
|
+
logs_tail.append(line.strip())
|
|
300
|
+
|
|
301
|
+
async def _prog():
|
|
302
|
+
async for p in stream.progress():
|
|
303
|
+
layer = p.layer or "overall"
|
|
304
|
+
if layer not in tasks:
|
|
305
|
+
tasks[layer] = progress.add_task(
|
|
306
|
+
p.message or layer, total=p.total if p.total is not None else 0
|
|
307
|
+
)
|
|
308
|
+
task_id = tasks[layer]
|
|
309
|
+
|
|
310
|
+
updates = {}
|
|
311
|
+
# Keep total=None for pulsing; only set if we get a real number.
|
|
312
|
+
if p.total is not None and not math.isinf(p.total):
|
|
313
|
+
updates["total"] = p.total
|
|
314
|
+
if p.current is not None:
|
|
315
|
+
updates["completed"] = p.current
|
|
316
|
+
if p.message:
|
|
317
|
+
updates["description"] = p.message
|
|
318
|
+
if updates:
|
|
319
|
+
progress.update(task_id, **updates)
|
|
320
|
+
|
|
321
|
+
with Live(render(), refresh_per_second=10) as live:
|
|
322
|
+
|
|
323
|
+
async def _refresh():
|
|
324
|
+
while True:
|
|
325
|
+
live.update(render())
|
|
326
|
+
await asyncio.sleep(0.1)
|
|
327
|
+
|
|
328
|
+
t_logs = asyncio.create_task(_logs())
|
|
329
|
+
t_prog = asyncio.create_task(_prog())
|
|
330
|
+
t_ui = asyncio.create_task(_refresh())
|
|
331
|
+
try:
|
|
332
|
+
result = await stream
|
|
333
|
+
return result
|
|
334
|
+
finally:
|
|
335
|
+
# Hide any still-visible tasks (e.g., indeterminate ones with total=None)
|
|
336
|
+
for tid in list(tasks.values()):
|
|
337
|
+
progress.update(tid, visible=False)
|
|
338
|
+
live.update(render())
|
|
339
|
+
|
|
340
|
+
for t in (t_logs, t_prog):
|
|
341
|
+
await t
|
|
342
|
+
|
|
343
|
+
t_ui.cancel()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
async def _with_client(
|
|
347
|
+
*,
|
|
348
|
+
project_id: ProjectIdOption,
|
|
349
|
+
room: RoomOption,
|
|
350
|
+
):
|
|
351
|
+
account_client = await get_client()
|
|
352
|
+
try:
|
|
353
|
+
project_id = await resolve_project_id(project_id=project_id)
|
|
354
|
+
room = resolve_room(room)
|
|
355
|
+
|
|
356
|
+
connection = await account_client.connect_room(project_id=project_id, room=room)
|
|
357
|
+
|
|
358
|
+
proto = WebSocketClientProtocol(
|
|
359
|
+
url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
|
|
360
|
+
token=connection.jwt,
|
|
361
|
+
)
|
|
362
|
+
client_cm = RoomClient(protocol=proto)
|
|
363
|
+
await client_cm.__aenter__()
|
|
364
|
+
return account_client, client_cm
|
|
365
|
+
except Exception:
|
|
366
|
+
await account_client.close()
|
|
367
|
+
raise
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# -------------------------
|
|
371
|
+
# Top-level: ps / stop / logs / run
|
|
372
|
+
# -------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@app.async_command("ps")
|
|
376
|
+
async def list_containers(
|
|
377
|
+
*,
|
|
378
|
+
project_id: ProjectIdOption,
|
|
379
|
+
room: RoomOption,
|
|
380
|
+
output: Annotated[Optional[str], typer.Option(help="json | table")] = "json",
|
|
381
|
+
):
|
|
382
|
+
account_client, client = await _with_client(
|
|
383
|
+
project_id=project_id,
|
|
384
|
+
room=room,
|
|
385
|
+
)
|
|
386
|
+
try:
|
|
387
|
+
containers = await client.containers.list()
|
|
388
|
+
if output == "table":
|
|
389
|
+
from rich.table import Table
|
|
390
|
+
from rich.console import Console
|
|
391
|
+
|
|
392
|
+
table = Table(title="Containers")
|
|
393
|
+
table.add_column("ID", style="cyan")
|
|
394
|
+
table.add_column("Image")
|
|
395
|
+
table.add_column("Status")
|
|
396
|
+
table.add_column("Name")
|
|
397
|
+
for c in containers:
|
|
398
|
+
table.add_row(c.id, c.image or "", c.status or "", c.name or "")
|
|
399
|
+
Console().print(table)
|
|
400
|
+
else:
|
|
401
|
+
# default json-ish
|
|
402
|
+
print([c.model_dump() for c in containers])
|
|
403
|
+
finally:
|
|
404
|
+
await client.__aexit__(None, None, None)
|
|
405
|
+
await account_client.close()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@app.async_command("stop")
|
|
409
|
+
async def stop_container(
|
|
410
|
+
*,
|
|
411
|
+
project_id: ProjectIdOption,
|
|
412
|
+
room: RoomOption,
|
|
413
|
+
id: Annotated[str, typer.Option(..., help="Container ID")],
|
|
414
|
+
):
|
|
415
|
+
account_client, client = await _with_client(
|
|
416
|
+
project_id=project_id,
|
|
417
|
+
room=room,
|
|
418
|
+
)
|
|
419
|
+
try:
|
|
420
|
+
await client.containers.stop(container_id=id)
|
|
421
|
+
print("[green]Stopped[/green]")
|
|
422
|
+
finally:
|
|
423
|
+
await client.__aexit__(None, None, None)
|
|
424
|
+
await account_client.close()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@app.async_command("logs")
|
|
428
|
+
async def container_logs(
|
|
429
|
+
*,
|
|
430
|
+
project_id: ProjectIdOption,
|
|
431
|
+
room: RoomOption,
|
|
432
|
+
id: Annotated[str, typer.Option(..., help="Container ID")],
|
|
433
|
+
follow: Annotated[
|
|
434
|
+
bool, typer.Option("--follow/--no-follow", help="Stream logs")
|
|
435
|
+
] = False,
|
|
436
|
+
):
|
|
437
|
+
account_client, client = await _with_client(
|
|
438
|
+
project_id=project_id,
|
|
439
|
+
room=room,
|
|
440
|
+
)
|
|
441
|
+
try:
|
|
442
|
+
stream = client.containers.logs(container_id=id, follow=follow)
|
|
443
|
+
await _drain_stream_plain(stream)
|
|
444
|
+
finally:
|
|
445
|
+
await client.__aexit__(None, None, None)
|
|
446
|
+
await account_client.close()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@app.async_command("exec")
|
|
450
|
+
async def exec_container(
|
|
451
|
+
*,
|
|
452
|
+
project_id: ProjectIdOption,
|
|
453
|
+
room: RoomOption,
|
|
454
|
+
container_id: Annotated[str, typer.Option(..., help="container id")],
|
|
455
|
+
command: Annotated[
|
|
456
|
+
Optional[str],
|
|
457
|
+
typer.Option(..., help="Command to execute in the container (quoted string)"),
|
|
458
|
+
] = None,
|
|
459
|
+
tty: Annotated[bool, typer.Option(..., help="Allocate a TTY")] = False,
|
|
460
|
+
):
|
|
461
|
+
account_client, client = await _with_client(
|
|
462
|
+
project_id=project_id,
|
|
463
|
+
room=room,
|
|
464
|
+
)
|
|
465
|
+
result = 1
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
import termios
|
|
469
|
+
|
|
470
|
+
from contextlib import contextmanager
|
|
471
|
+
|
|
472
|
+
container = await client.containers.exec(
|
|
473
|
+
container_id=container_id,
|
|
474
|
+
command=command,
|
|
475
|
+
tty=tty,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
async def write_all(fd, data: bytes) -> None:
|
|
479
|
+
loop = asyncio.get_running_loop()
|
|
480
|
+
mv = memoryview(data)
|
|
481
|
+
|
|
482
|
+
while mv:
|
|
483
|
+
try:
|
|
484
|
+
n = os.write(fd, mv)
|
|
485
|
+
mv = mv[n:]
|
|
486
|
+
except BlockingIOError:
|
|
487
|
+
fut = loop.create_future()
|
|
488
|
+
loop.add_writer(fd, fut.set_result, None)
|
|
489
|
+
try:
|
|
490
|
+
await fut
|
|
491
|
+
finally:
|
|
492
|
+
loop.remove_writer(fd)
|
|
493
|
+
|
|
494
|
+
async def read_stderr():
|
|
495
|
+
async for output in container.stderr():
|
|
496
|
+
await write_all(sys.stderr.fileno(), output)
|
|
497
|
+
|
|
498
|
+
async def read_stdout():
|
|
499
|
+
async for output in container.stdout():
|
|
500
|
+
await write_all(sys.stdout.fileno(), output)
|
|
501
|
+
|
|
502
|
+
@contextmanager
|
|
503
|
+
def raw_mode(fd: int):
|
|
504
|
+
import tty
|
|
505
|
+
|
|
506
|
+
old = termios.tcgetattr(fd)
|
|
507
|
+
try:
|
|
508
|
+
tty.setraw(fd) # immediate bytes
|
|
509
|
+
yield
|
|
510
|
+
finally:
|
|
511
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
512
|
+
|
|
513
|
+
async def read_piped_stdin(bufsize: int = 1024):
|
|
514
|
+
while True:
|
|
515
|
+
chunk = await asyncio.to_thread(sys.stdin.buffer.read, bufsize)
|
|
516
|
+
|
|
517
|
+
if not chunk or len(chunk) == 0:
|
|
518
|
+
await container.close_stdin()
|
|
519
|
+
break
|
|
520
|
+
|
|
521
|
+
await container.write(chunk)
|
|
522
|
+
|
|
523
|
+
async def read_stdin(bufsize: int = 1024):
|
|
524
|
+
# If stdin is piped, just read normally (blocking is fine; no TTY semantics)
|
|
525
|
+
if not sys.stdin.isatty():
|
|
526
|
+
while True:
|
|
527
|
+
chunk = sys.stdin.buffer.read(bufsize)
|
|
528
|
+
if not chunk:
|
|
529
|
+
return
|
|
530
|
+
await container.write(chunk)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
fd = sys.stdin.fileno()
|
|
534
|
+
|
|
535
|
+
# Make reads non-blocking so we never hang shutdown
|
|
536
|
+
prev_blocking = os.get_blocking(fd)
|
|
537
|
+
os.set_blocking(fd, False)
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
with raw_mode(fd):
|
|
541
|
+
while True:
|
|
542
|
+
try:
|
|
543
|
+
chunk = os.read(fd, bufsize)
|
|
544
|
+
except BlockingIOError:
|
|
545
|
+
# nothing typed yet
|
|
546
|
+
await asyncio.sleep(0.01)
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
if chunk == b"":
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
# optional: allow Ctrl-C to exit
|
|
553
|
+
if chunk == b"\x03":
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
await container.write(chunk)
|
|
557
|
+
finally:
|
|
558
|
+
os.set_blocking(fd, prev_blocking)
|
|
559
|
+
|
|
560
|
+
if not tty and not sys.stdin.isatty():
|
|
561
|
+
await asyncio.gather(read_stdout(), read_stderr(), read_piped_stdin())
|
|
562
|
+
else:
|
|
563
|
+
if not sys.stdin.isatty():
|
|
564
|
+
print("[red]TTY requested but not a TTY[/red]")
|
|
565
|
+
raise typer.Exit(-1)
|
|
566
|
+
|
|
567
|
+
reader = asyncio.create_task(read_stdin())
|
|
568
|
+
await asyncio.gather(read_stdout(), read_stderr())
|
|
569
|
+
reader.cancel()
|
|
570
|
+
|
|
571
|
+
result = await container.result
|
|
572
|
+
finally:
|
|
573
|
+
await client.__aexit__(None, None, None)
|
|
574
|
+
await account_client.close()
|
|
575
|
+
|
|
576
|
+
sys.exit(result)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# -------------------------
|
|
580
|
+
# Run (detached)
|
|
581
|
+
# -------------------------
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@app.async_command("run")
|
|
585
|
+
async def run_container(
|
|
586
|
+
*,
|
|
587
|
+
project_id: ProjectIdOption,
|
|
588
|
+
room: RoomOption,
|
|
589
|
+
image: Annotated[str, typer.Option(..., help="Image to run")],
|
|
590
|
+
command: Annotated[
|
|
591
|
+
Optional[str],
|
|
592
|
+
typer.Option(..., help="Command to execute in the container (quoted string)"),
|
|
593
|
+
] = None,
|
|
594
|
+
env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
|
|
595
|
+
port: Annotated[
|
|
596
|
+
List[str], typer.Option("--port", "-p", help="CONTAINER:HOST")
|
|
597
|
+
] = [],
|
|
598
|
+
var: Annotated[
|
|
599
|
+
List[str],
|
|
600
|
+
typer.Option("--var", help="Template variable KEY=VALUE (optional)"),
|
|
601
|
+
] = [],
|
|
602
|
+
cred: Annotated[
|
|
603
|
+
List[str],
|
|
604
|
+
typer.Option(
|
|
605
|
+
"--cred",
|
|
606
|
+
help="Docker creds (username,password) or (registry,username,password)",
|
|
607
|
+
),
|
|
608
|
+
] = [],
|
|
609
|
+
mount_path: Annotated[
|
|
610
|
+
Optional[str],
|
|
611
|
+
typer.Option(help="Room storage path to mount into the container"),
|
|
612
|
+
] = None,
|
|
613
|
+
mount_subpath: Annotated[
|
|
614
|
+
Optional[str],
|
|
615
|
+
typer.Option(help="Subpath within `--mount-path` to mount"),
|
|
616
|
+
] = None,
|
|
617
|
+
participant_name: Annotated[
|
|
618
|
+
Optional[str], typer.Option(help="Participant name to associate with the run")
|
|
619
|
+
] = None,
|
|
620
|
+
role: Annotated[
|
|
621
|
+
str, typer.Option(..., help="Role to run the container as")
|
|
622
|
+
] = "user",
|
|
623
|
+
container_name: Annotated[
|
|
624
|
+
str, typer.Option(..., help="Optional container name")
|
|
625
|
+
] = None,
|
|
626
|
+
):
|
|
627
|
+
account_client, client = await _with_client(
|
|
628
|
+
project_id=project_id,
|
|
629
|
+
room=room,
|
|
630
|
+
)
|
|
631
|
+
try:
|
|
632
|
+
creds = _parse_creds(cred)
|
|
633
|
+
env_map = _parse_keyvals(env)
|
|
634
|
+
ports_map = _parse_ports(port)
|
|
635
|
+
vars_map = _parse_keyvals(var)
|
|
636
|
+
|
|
637
|
+
container_id = await client.containers.run(
|
|
638
|
+
name=container_name,
|
|
639
|
+
image=image,
|
|
640
|
+
command=command,
|
|
641
|
+
env=env_map,
|
|
642
|
+
mount_path=mount_path,
|
|
643
|
+
mount_subpath=mount_subpath,
|
|
644
|
+
role=role,
|
|
645
|
+
participant_name=participant_name,
|
|
646
|
+
ports=ports_map,
|
|
647
|
+
credentials=creds,
|
|
648
|
+
variables=vars_map or None,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
print(f"Container started: {container_id}")
|
|
652
|
+
finally:
|
|
653
|
+
await client.__aexit__(None, None, None)
|
|
654
|
+
await account_client.close()
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# -------------------------
|
|
658
|
+
# Images sub-commands
|
|
659
|
+
# -------------------------
|
|
660
|
+
|
|
661
|
+
images_app = async_typer.AsyncTyper(help="Image operations")
|
|
662
|
+
app.add_typer(images_app, name="images")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@images_app.async_command("list")
|
|
666
|
+
async def images_list(
|
|
667
|
+
*,
|
|
668
|
+
project_id: ProjectIdOption,
|
|
669
|
+
room: RoomOption,
|
|
670
|
+
):
|
|
671
|
+
account_client, client = await _with_client(
|
|
672
|
+
project_id=project_id,
|
|
673
|
+
room=room,
|
|
674
|
+
)
|
|
675
|
+
try:
|
|
676
|
+
imgs = await client.containers.list_images()
|
|
677
|
+
print([i.model_dump() for i in imgs])
|
|
678
|
+
finally:
|
|
679
|
+
await client.__aexit__(None, None, None)
|
|
680
|
+
await account_client.close()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@images_app.async_command("delete")
|
|
684
|
+
async def images_delete(
|
|
685
|
+
*,
|
|
686
|
+
project_id: ProjectIdOption,
|
|
687
|
+
room: RoomOption,
|
|
688
|
+
image: Annotated[str, typer.Option(..., help="Image ref/tag to delete")],
|
|
689
|
+
):
|
|
690
|
+
account_client, client = await _with_client(
|
|
691
|
+
project_id=project_id,
|
|
692
|
+
room=room,
|
|
693
|
+
)
|
|
694
|
+
try:
|
|
695
|
+
await client.containers.delete_image(image=image)
|
|
696
|
+
print("[green]Deleted[/green]")
|
|
697
|
+
finally:
|
|
698
|
+
await client.__aexit__(None, None, None)
|
|
699
|
+
await account_client.close()
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@images_app.async_command("pull")
|
|
703
|
+
async def images_pull(
|
|
704
|
+
*,
|
|
705
|
+
project_id: ProjectIdOption,
|
|
706
|
+
room: RoomOption,
|
|
707
|
+
tag: Annotated[str, typer.Option(..., help="Image tag/ref to pull")],
|
|
708
|
+
cred: Annotated[
|
|
709
|
+
List[str],
|
|
710
|
+
typer.Option(
|
|
711
|
+
"--cred",
|
|
712
|
+
help="Docker creds (username,password) or (registry,username,password)",
|
|
713
|
+
),
|
|
714
|
+
] = [],
|
|
715
|
+
):
|
|
716
|
+
account_client, client = await _with_client(
|
|
717
|
+
project_id=project_id,
|
|
718
|
+
room=room,
|
|
719
|
+
)
|
|
720
|
+
try:
|
|
721
|
+
await client.containers.pull_image(tag=tag, credentials=_parse_creds(cred))
|
|
722
|
+
print("Image pulled")
|
|
723
|
+
finally:
|
|
724
|
+
await client.__aexit__(None, None, None)
|
|
725
|
+
await account_client.close()
|