meshagent-cli 0.5.15__py3-none-any.whl → 0.6.0__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.

@@ -0,0 +1,577 @@
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
+ app = async_typer.AsyncTyper(help="Manage containers and images inside a room")
37
+
38
+ # -------------------------
39
+ # Helpers
40
+ # -------------------------
41
+
42
+
43
+ def _parse_keyvals(items: List[str]) -> Dict[str, str]:
44
+ """
45
+ Parse ["KEY=VAL", "FOO=BAR"] -> {"KEY":"VAL", "FOO":"BAR"}
46
+ """
47
+ out: Dict[str, str] = {}
48
+ for s in items or []:
49
+ if "=" not in s:
50
+ raise typer.BadParameter(f"Expected KEY=VALUE, got: {s}")
51
+ k, v = s.split("=", 1)
52
+ out[k] = v
53
+ return out
54
+
55
+
56
+ def _parse_ports(items: List[str]) -> Dict[int, int]:
57
+ """
58
+ Parse ["8080:3000", "9999:9999"] as CONTAINER:HOST -> {8080:3000, 9999:9999}
59
+ (Matches server's expectation: container_port -> host_port.)
60
+ """
61
+ out: Dict[int, int] = {}
62
+ for s in items or []:
63
+ if ":" not in s:
64
+ raise typer.BadParameter(f"Expected CONTAINER:HOST, got: {s}")
65
+ c, h = s.split(":", 1)
66
+ try:
67
+ cp, hp = int(c), int(h)
68
+ except ValueError:
69
+ raise typer.BadParameter(f"Ports must be integers, got: {s}")
70
+ out[cp] = hp
71
+ return out
72
+
73
+
74
+ def _parse_creds(items: List[str]) -> List[DockerSecret]:
75
+ """
76
+ Parse creds given as:
77
+ --cred username,password
78
+ --cred registry,username,password
79
+ """
80
+ creds: List[DockerSecret] = []
81
+ for s in items or []:
82
+ parts = [p.strip() for p in s.split(",")]
83
+ if len(parts) == 2:
84
+ u, p = parts
85
+ creds.append(DockerSecret(username=u, password=p))
86
+ elif len(parts) == 3:
87
+ r, u, p = parts
88
+ creds.append(DockerSecret(registry=r, username=u, password=p))
89
+ else:
90
+ raise typer.BadParameter(
91
+ f"Invalid --cred format: {s}. Use username,password or registry,username,password"
92
+ )
93
+ return creds
94
+
95
+
96
+ class DockerIgnore:
97
+ def __init__(self, dockerignore_path: str):
98
+ """
99
+ Load a .dockerignore file and compile its patterns.
100
+ """
101
+ dockerignore_file = pathlib.Path(dockerignore_path)
102
+ if dockerignore_file.exists():
103
+ with dockerignore_file.open("r") as f:
104
+ patterns = f.read().splitlines()
105
+ else:
106
+ patterns = []
107
+
108
+ # pathspec with gitwildmatch is the same style used by dockerignore
109
+ self._spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
110
+
111
+ def matches(self, path: str) -> bool:
112
+ """
113
+ Return True if the given path matches a pattern in the .dockerignore file.
114
+ Path can be relative or absolute.
115
+ """
116
+ return self._spec.match_file(path)
117
+
118
+
119
+ async def _make_targz_from_dir(path: Path) -> bytes:
120
+ buf = io.BytesIO()
121
+
122
+ docker_ignore = None
123
+
124
+ def _tarfilter_strip_macos(ti: tarfile.TarInfo) -> tarfile.TarInfo | None:
125
+ """
126
+ Filter to make Linux-friendly tarballs:
127
+ - Drop AppleDouble files (._*)
128
+ - Reset uid/gid/uname/gname
129
+ - Clear pax headers
130
+ """
131
+
132
+ if docker_ignore is not None:
133
+ if docker_ignore.matches(ti.path):
134
+ return None
135
+
136
+ base = os.path.basename(ti.name)
137
+ if base.startswith("._"):
138
+ return None
139
+ ti.uid = 0
140
+ ti.gid = 0
141
+ ti.uname = ""
142
+ ti.gname = ""
143
+ ti.pax_headers = {}
144
+ # Preserve mode & type; set a stable-ish mtime
145
+ if ti.mtime is None:
146
+ ti.mtime = int(time.time())
147
+ return ti
148
+
149
+ docker_ignore_path = path.joinpath(".dockerignore")
150
+
151
+ if await aiofiles.ospath.exists(docker_ignore_path):
152
+ docker_ignore = DockerIgnore(docker_ignore_path)
153
+
154
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
155
+ tar.add(path, arcname=".", filter=_tarfilter_strip_macos)
156
+ return buf.getvalue()
157
+
158
+
159
+ async def _drain_stream_plain(stream, *, show_progress: bool = True):
160
+ async def _logs():
161
+ async for line in stream.logs():
162
+ # Server emits plain lines; print as-is
163
+ if line is not None:
164
+ print(line)
165
+
166
+ async def _prog():
167
+ if not show_progress:
168
+ async for _ in stream.progress():
169
+ pass
170
+ return
171
+ async for p in stream.progress():
172
+ if p is None:
173
+ return
174
+ msg = p.message or ""
175
+ # Show progress if we have numbers, else just the message.
176
+ if p.current is not None and p.total:
177
+ print(f"[cyan]{msg} ({p.current}/{p.total})[/cyan]")
178
+ elif msg:
179
+ print(f"[cyan]{msg}[/cyan]")
180
+
181
+ t1 = asyncio.create_task(_logs())
182
+ t2 = asyncio.create_task(_prog())
183
+ try:
184
+ return await stream
185
+ finally:
186
+ await asyncio.gather(t1, t2, return_exceptions=True)
187
+
188
+
189
+ async def _drain_stream_pretty(stream):
190
+ import asyncio
191
+ import math
192
+ from rich.table import Column
193
+ from rich.live import Live
194
+ from rich.panel import Panel
195
+ from rich.console import Group
196
+ from rich.text import Text
197
+ from rich.progress import (
198
+ Progress,
199
+ TextColumn,
200
+ BarColumn,
201
+ TimeElapsedColumn,
202
+ ProgressColumn,
203
+ SpinnerColumn,
204
+ )
205
+
206
+ class MaybeMofN(ProgressColumn):
207
+ def render(self, task):
208
+ import math
209
+ from rich.text import Text
210
+
211
+ def _fmt_bytes(n):
212
+ if n is None:
213
+ return ""
214
+ n = float(n)
215
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
216
+ i = 0
217
+ while n >= 1024 and i < len(units) - 1:
218
+ n /= 1024
219
+ i += 1
220
+ return f"{n:.1f} {units[i]}"
221
+
222
+ if task.total == 0 or math.isinf(task.total):
223
+ return Text("")
224
+ return Text(f"{_fmt_bytes(task.completed)} / {_fmt_bytes(task.total)}")
225
+
226
+ class MaybeBarColumn(BarColumn):
227
+ def __init__(
228
+ self,
229
+ *,
230
+ bar_width: int | None = 28,
231
+ hide_when_unknown: bool = False,
232
+ column_width: int | None = None,
233
+ **kwargs,
234
+ ):
235
+ # bar_width controls the drawn bar size; None = flex
236
+ super().__init__(bar_width=bar_width, **kwargs)
237
+ self.hide_when_unknown = hide_when_unknown
238
+ self.column_width = column_width # fix the table column if set
239
+
240
+ def get_table_column(self) -> Column:
241
+ if self.column_width is None:
242
+ # default behavior (may flex depending on layout)
243
+ return Column(no_wrap=True)
244
+ return Column(
245
+ width=self.column_width,
246
+ min_width=self.column_width,
247
+ max_width=self.column_width,
248
+ no_wrap=True,
249
+ overflow="crop",
250
+ justify="left",
251
+ )
252
+
253
+ def render(self, task):
254
+ if task.total is None or task.total == 0 or math.isinf(task.total):
255
+ return Text("") # hide bar for indeterminate tasks
256
+ return super().render(task)
257
+
258
+ class MaybeETA(ProgressColumn):
259
+ """Show ETA only if total is known."""
260
+
261
+ _elapsed = TimeElapsedColumn()
262
+
263
+ def render(self, task):
264
+ # You can swap this to a TimeRemainingColumn() if you prefer,
265
+ # but hide when total is unknown.
266
+ if task.total == 0 or math.isinf(task.total):
267
+ return Text("")
268
+ return self._elapsed.render(task) # or TimeRemainingColumn().render(task)
269
+
270
+ progress = Progress(
271
+ SpinnerColumn(),
272
+ TextColumn(
273
+ "[bold]{task.description}",
274
+ table_column=Column(ratio=8, no_wrap=True, overflow="ellipsis"),
275
+ ),
276
+ MaybeMofN(table_column=Column(ratio=2, no_wrap=True, overflow="ellipsis")),
277
+ MaybeETA(table_column=Column(ratio=1, no_wrap=True, overflow="ellipsis")),
278
+ MaybeBarColumn(pulse_style="cyan", bar_width=20, hide_when_unknown=True),
279
+ # pulses automatically if total=None
280
+ transient=False, # we’re inside Live; we’ll hide tasks ourselves
281
+ expand=True,
282
+ )
283
+
284
+ logs_tail: list[str] = []
285
+ tasks: dict[str, int] = {} # layer -> task_id
286
+
287
+ def render():
288
+ tail = "\n".join(logs_tail[-12:]) or "waiting…"
289
+ return Group(
290
+ progress,
291
+ Panel(tail, title="logs", border_style="cyan", height=12),
292
+ )
293
+
294
+ async def _logs():
295
+ async for line in stream.logs():
296
+ if line:
297
+ logs_tail.append(line.strip())
298
+
299
+ async def _prog():
300
+ async for p in stream.progress():
301
+ layer = p.layer or "overall"
302
+ if layer not in tasks:
303
+ tasks[layer] = progress.add_task(
304
+ p.message or layer, total=p.total if p.total is not None else 0
305
+ )
306
+ task_id = tasks[layer]
307
+
308
+ updates = {}
309
+ # Keep total=None for pulsing; only set if we get a real number.
310
+ if p.total is not None and not math.isinf(p.total):
311
+ updates["total"] = p.total
312
+ if p.current is not None:
313
+ updates["completed"] = p.current
314
+ if p.message:
315
+ updates["description"] = p.message
316
+ if updates:
317
+ progress.update(task_id, **updates)
318
+
319
+ with Live(render(), refresh_per_second=10) as live:
320
+
321
+ async def _refresh():
322
+ while True:
323
+ live.update(render())
324
+ await asyncio.sleep(0.1)
325
+
326
+ t_logs = asyncio.create_task(_logs())
327
+ t_prog = asyncio.create_task(_prog())
328
+ t_ui = asyncio.create_task(_refresh())
329
+ try:
330
+ result = await stream
331
+ return result
332
+ finally:
333
+ # Hide any still-visible tasks (e.g., indeterminate ones with total=None)
334
+ for tid in list(tasks.values()):
335
+ progress.update(tid, visible=False)
336
+ live.update(render())
337
+
338
+ for t in (t_logs, t_prog):
339
+ await t
340
+
341
+ t_ui.cancel()
342
+
343
+
344
+ async def _with_client(
345
+ *,
346
+ project_id: ProjectIdOption,
347
+ room: RoomOption,
348
+ ):
349
+ account_client = await get_client()
350
+ try:
351
+ project_id = await resolve_project_id(project_id=project_id)
352
+ room = resolve_room(room)
353
+
354
+ connection = await account_client.connect_room(project_id=project_id, room=room)
355
+
356
+ print("[bold green]Connecting to room...[/bold green]", flush=True)
357
+ proto = WebSocketClientProtocol(
358
+ url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
359
+ token=connection.jwt,
360
+ )
361
+ client_cm = RoomClient(protocol=proto)
362
+ await client_cm.__aenter__()
363
+ return account_client, client_cm
364
+ except Exception:
365
+ await account_client.close()
366
+ raise
367
+
368
+
369
+ # -------------------------
370
+ # Top-level: ps / stop / logs / run
371
+ # -------------------------
372
+
373
+
374
+ @app.async_command("ps")
375
+ async def list_containers(
376
+ *,
377
+ project_id: ProjectIdOption = None,
378
+ room: RoomOption,
379
+ output: Annotated[Optional[str], typer.Option(help="json | table")] = "json",
380
+ ):
381
+ account_client, client = await _with_client(
382
+ project_id=project_id,
383
+ room=room,
384
+ )
385
+ try:
386
+ containers = await client.containers.list()
387
+ if output == "table":
388
+ from rich.table import Table
389
+ from rich.console import Console
390
+
391
+ table = Table(title="Containers")
392
+ table.add_column("ID", style="cyan")
393
+ table.add_column("Image")
394
+ table.add_column("Status")
395
+ table.add_column("Name")
396
+ for c in containers:
397
+ table.add_row(c.id, c.image or "", c.status or "", c.name or "")
398
+ Console().print(table)
399
+ else:
400
+ # default json-ish
401
+ print([c.model_dump() for c in containers])
402
+ finally:
403
+ await client.__aexit__(None, None, None)
404
+ await account_client.close()
405
+
406
+
407
+ @app.async_command("stop")
408
+ async def stop_container(
409
+ *,
410
+ project_id: ProjectIdOption = None,
411
+ room: RoomOption,
412
+ id: Annotated[str, typer.Option(..., help="Container ID")],
413
+ ):
414
+ account_client, client = await _with_client(
415
+ project_id=project_id,
416
+ room=room,
417
+ )
418
+ try:
419
+ await client.containers.stop(container_id=id)
420
+ print("[green]Stopped[/green]")
421
+ finally:
422
+ await client.__aexit__(None, None, None)
423
+ await account_client.close()
424
+
425
+
426
+ @app.async_command("logs")
427
+ async def container_logs(
428
+ *,
429
+ project_id: ProjectIdOption = None,
430
+ room: RoomOption,
431
+ id: Annotated[str, typer.Option(..., help="Container ID")],
432
+ follow: Annotated[bool, typer.Option("--follow/--no-follow")] = False,
433
+ ):
434
+ account_client, client = await _with_client(
435
+ project_id=project_id,
436
+ room=room,
437
+ )
438
+ try:
439
+ stream = client.containers.logs(container_id=id, follow=follow)
440
+ await _drain_stream_plain(stream)
441
+ finally:
442
+ await client.__aexit__(None, None, None)
443
+ await account_client.close()
444
+
445
+
446
+ # -------------------------
447
+ # Run (detached) and run-attached
448
+ # -------------------------
449
+
450
+
451
+ @app.async_command("run")
452
+ async def run_container(
453
+ *,
454
+ project_id: ProjectIdOption = None,
455
+ room: RoomOption,
456
+ image: Annotated[str, typer.Option(..., help="Image to run")],
457
+ command: Annotated[Optional[str], typer.Option(...)] = None,
458
+ env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
459
+ port: Annotated[
460
+ List[str], typer.Option("--port", "-p", help="CONTAINER:HOST")
461
+ ] = [],
462
+ var: Annotated[
463
+ List[str],
464
+ typer.Option("--var", help="Template variable KEY=VALUE (optional)"),
465
+ ] = [],
466
+ cred: Annotated[
467
+ List[str],
468
+ typer.Option(
469
+ "--cred",
470
+ help="Docker creds (username,password) or (registry,username,password)",
471
+ ),
472
+ ] = [],
473
+ mount_path: Annotated[Optional[str], typer.Option()] = None,
474
+ mount_subpath: Annotated[Optional[str], typer.Option()] = None,
475
+ participant_name: Annotated[Optional[str], typer.Option()] = None,
476
+ role: Annotated[str, typer.Option(...)] = "user",
477
+ container_name: Annotated[str, typer.Option(...)] = None,
478
+ ):
479
+ account_client, client = await _with_client(
480
+ project_id=project_id,
481
+ room=room,
482
+ )
483
+ try:
484
+ creds = _parse_creds(cred)
485
+ env_map = _parse_keyvals(env)
486
+ ports_map = _parse_ports(port)
487
+ vars_map = _parse_keyvals(var)
488
+
489
+ container_id = await client.containers.run(
490
+ name=container_name,
491
+ image=image,
492
+ command=command,
493
+ env=env_map,
494
+ mount_path=mount_path,
495
+ mount_subpath=mount_subpath,
496
+ role=role,
497
+ participant_name=participant_name,
498
+ ports=ports_map,
499
+ credentials=creds,
500
+ variables=vars_map or None,
501
+ )
502
+
503
+ print(f"Container started: {container_id}")
504
+ finally:
505
+ await client.__aexit__(None, None, None)
506
+ await account_client.close()
507
+
508
+
509
+ # -------------------------
510
+ # Images sub-commands
511
+ # -------------------------
512
+
513
+ images_app = async_typer.AsyncTyper(help="Image operations")
514
+ app.add_typer(images_app, name="images")
515
+
516
+
517
+ @images_app.async_command("list")
518
+ async def images_list(
519
+ *,
520
+ project_id: ProjectIdOption = None,
521
+ room: RoomOption,
522
+ ):
523
+ account_client, client = await _with_client(
524
+ project_id=project_id,
525
+ room=room,
526
+ )
527
+ try:
528
+ imgs = await client.containers.list_images()
529
+ print([i.model_dump() for i in imgs])
530
+ finally:
531
+ await client.__aexit__(None, None, None)
532
+ await account_client.close()
533
+
534
+
535
+ @images_app.async_command("delete")
536
+ async def images_delete(
537
+ *,
538
+ project_id: ProjectIdOption = None,
539
+ room: RoomOption,
540
+ image: Annotated[str, typer.Option(..., help="Image ref/tag to delete")],
541
+ ):
542
+ account_client, client = await _with_client(
543
+ project_id=project_id,
544
+ room=room,
545
+ )
546
+ try:
547
+ await client.containers.delete_image(image=image)
548
+ print("[green]Deleted[/green]")
549
+ finally:
550
+ await client.__aexit__(None, None, None)
551
+ await account_client.close()
552
+
553
+
554
+ @images_app.async_command("pull")
555
+ async def images_pull(
556
+ *,
557
+ project_id: ProjectIdOption = None,
558
+ room: RoomOption,
559
+ tag: Annotated[str, typer.Option(..., help="Image tag/ref to pull")],
560
+ cred: Annotated[
561
+ List[str],
562
+ typer.Option(
563
+ "--cred",
564
+ help="Docker creds (username,password) or (registry,username,password)",
565
+ ),
566
+ ] = [],
567
+ ):
568
+ account_client, client = await _with_client(
569
+ project_id=project_id,
570
+ room=room,
571
+ )
572
+ try:
573
+ await client.containers.pull_image(tag=tag, credentials=_parse_creds(cred))
574
+ print("Image pulled")
575
+ finally:
576
+ await client.__aexit__(None, None, None)
577
+ await account_client.close()
@@ -1,17 +1,17 @@
1
1
  import asyncio
2
2
  import json
3
- import typer
4
3
  from rich import print
5
- from typing import Annotated
6
- from meshagent.cli.common_options import ProjectIdOption, ApiKeyIdOption, RoomOption
4
+ from meshagent.cli.common_options import ProjectIdOption, RoomOption
7
5
  from meshagent.cli import async_typer
8
6
  from meshagent.cli.helper import (
9
7
  get_client,
10
8
  resolve_project_id,
11
- resolve_api_key,
12
9
  resolve_room,
13
10
  )
14
- from meshagent.api import RoomClient, ParticipantToken, WebSocketClientProtocol
11
+ from meshagent.api import (
12
+ RoomClient,
13
+ WebSocketClientProtocol,
14
+ )
15
15
  from meshagent.api.helpers import meshagent_base_url, websocket_room_url
16
16
 
17
17
  app = async_typer.AsyncTyper()
@@ -22,11 +22,6 @@ async def watch_logs(
22
22
  *,
23
23
  project_id: ProjectIdOption = None,
24
24
  room: RoomOption,
25
- api_key_id: ApiKeyIdOption = None,
26
- name: Annotated[str, typer.Option(..., help="Participant name")] = "cli",
27
- role: Annotated[
28
- str, typer.Option(..., help="Role to assign to this participant")
29
- ] = "user",
30
25
  ):
31
26
  """
32
27
  Watch logs from the developer feed in the specified room.
@@ -36,28 +31,15 @@ async def watch_logs(
36
31
  try:
37
32
  # Resolve project ID (or fetch from the active project if not provided)
38
33
  project_id = await resolve_project_id(project_id=project_id)
39
- api_key_id = await resolve_api_key(project_id, api_key_id)
40
34
  room = resolve_room(room)
41
35
 
42
- # Decrypt the project's API key
43
- key = (
44
- await account_client.decrypt_project_api_key(
45
- project_id=project_id, id=api_key_id
46
- )
47
- )["token"]
48
-
49
- # Build a participant token
50
- token = ParticipantToken(
51
- name=name, project_id=project_id, api_key_id=api_key_id
52
- )
53
- token.add_role_grant(role=role)
54
- token.add_room_grant(room)
36
+ connection = await account_client.connect_room(project_id=project_id, room=room)
55
37
 
56
38
  print("[bold green]Connecting to room...[/bold green]")
57
39
  async with RoomClient(
58
40
  protocol=WebSocketClientProtocol(
59
41
  url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
60
- token=token.to_jwt(token=key),
42
+ token=connection.jwt,
61
43
  )
62
44
  ) as client:
63
45
  # Create a developer client from the room client