meshagent-cli 0.7.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.
@@ -0,0 +1,490 @@
1
+ # ---------------------------------------------------------------------------
2
+ # Imports
3
+ # ---------------------------------------------------------------------------
4
+ import typer
5
+ from rich import print
6
+ from typing import Annotated, Optional
7
+ from meshagent.cli.common_options import ProjectIdOption
8
+ from aiohttp import ClientResponseError
9
+ import pathlib
10
+ from meshagent.cli import async_typer
11
+ from meshagent.api.services import well_known_service_path
12
+ from meshagent.api.specs.service import ServiceSpec
13
+ from meshagent.api.keys import parse_api_key
14
+
15
+ import asyncio
16
+ import shlex
17
+
18
+ import os
19
+ import signal
20
+ import atexit
21
+ import ctypes
22
+ import sys
23
+
24
+
25
+ from meshagent.cli.helper import (
26
+ get_client,
27
+ print_json_table,
28
+ resolve_project_id,
29
+ resolve_room,
30
+ resolve_key,
31
+ )
32
+ from meshagent.api import (
33
+ ParticipantToken,
34
+ ApiScope,
35
+ )
36
+ from meshagent.cli.common_options import OutputFormatOption
37
+
38
+ from pydantic_yaml import parse_yaml_raw_as
39
+
40
+
41
+ from meshagent.cli.call import _make_call
42
+
43
+ app = async_typer.AsyncTyper(help="Manage services for your project")
44
+
45
+
46
+ @app.async_command("create")
47
+ async def service_create(
48
+ *,
49
+ project_id: ProjectIdOption = None,
50
+ file: Annotated[
51
+ str,
52
+ typer.Option("--file", "-f", help="File path to a service definition"),
53
+ ],
54
+ room: Annotated[
55
+ Optional[str],
56
+ typer.Option(
57
+ "--room", "-r", help="The name of a room to create the service for"
58
+ ),
59
+ ] = None,
60
+ ):
61
+ """Create a service attached to the project."""
62
+ client = await get_client()
63
+ try:
64
+ project_id = await resolve_project_id(project_id)
65
+
66
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
67
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
68
+
69
+ if spec.id is not None:
70
+ print("[red]id cannot be set when creating a service[/red]")
71
+ raise typer.Exit(code=1)
72
+
73
+ try:
74
+ if room is None:
75
+ new_id = await client.create_service(
76
+ project_id=project_id, service=spec
77
+ )
78
+ else:
79
+ new_id = await client.create_room_service(
80
+ project_id=project_id, service=spec, room_name=room
81
+ )
82
+ except ClientResponseError as exc:
83
+ if exc.status == 409:
84
+ print(f"[red]Service name already in use: {spec.metadata.name}[/red]")
85
+ raise typer.Exit(code=1)
86
+ raise
87
+ else:
88
+ print(f"[green]Created service:[/] {new_id}")
89
+
90
+ finally:
91
+ await client.close()
92
+
93
+
94
+ @app.async_command("update")
95
+ async def service_update(
96
+ *,
97
+ project_id: ProjectIdOption = None,
98
+ id: Optional[str] = None,
99
+ file: Annotated[
100
+ str,
101
+ typer.Option("--file", "-f", help="File path to a service definition"),
102
+ ],
103
+ create: Annotated[
104
+ Optional[bool],
105
+ typer.Option(
106
+ help="create the service if it does not exist",
107
+ ),
108
+ ] = False,
109
+ room: Annotated[
110
+ Optional[str],
111
+ typer.Option(
112
+ "--room", "-r", help="The name of a room to update the service for"
113
+ ),
114
+ ] = None,
115
+ ):
116
+ """Create a service attached to the project."""
117
+ client = await get_client()
118
+ try:
119
+ project_id = await resolve_project_id(project_id)
120
+
121
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
122
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
123
+ if spec.id is not None:
124
+ id = spec.id
125
+
126
+ try:
127
+ if id is None:
128
+ if room is None:
129
+ services = await client.list_services(project_id=project_id)
130
+ else:
131
+ services = await client.list_room_services(
132
+ project_id=project_id, room_name=room
133
+ )
134
+
135
+ for s in services:
136
+ if s.metadata.name == spec.metadata.name:
137
+ id = s.id
138
+
139
+ if id is None and not create:
140
+ print("[red]pass a service id or specify --create[/red]")
141
+ raise typer.Exit(code=1)
142
+
143
+ if id is None:
144
+ if room is None:
145
+ id = await client.create_service(
146
+ project_id=project_id, service=spec
147
+ )
148
+ else:
149
+ id = await client.create_room_service(
150
+ project_id=project_id, service=spec, room_name=room
151
+ )
152
+
153
+ else:
154
+ spec.id = id
155
+ if room is None:
156
+ await client.update_service(
157
+ project_id=project_id, service_id=id, service=spec
158
+ )
159
+ else:
160
+ await client.update_room_service(
161
+ project_id=project_id,
162
+ service_id=id,
163
+ service=spec,
164
+ room_name=room,
165
+ )
166
+
167
+ except ClientResponseError as exc:
168
+ if exc.status == 409:
169
+ print(f"[red]Service name already in use: {spec.metadata.name}[/red]")
170
+ raise typer.Exit(code=1)
171
+ raise
172
+ else:
173
+ print(f"[green]Updated service:[/] {id}")
174
+
175
+ finally:
176
+ await client.close()
177
+
178
+
179
+ @app.async_command("run")
180
+ async def service_run(
181
+ *,
182
+ project_id: ProjectIdOption = None,
183
+ command: str,
184
+ port: Annotated[
185
+ int,
186
+ typer.Option(
187
+ "--port",
188
+ "-p",
189
+ help=(
190
+ "a port number to run the agent on (will set MESHAGENT_PORT environment variable when launching the service)"
191
+ ),
192
+ ),
193
+ ] = None,
194
+ room: Annotated[
195
+ Optional[str],
196
+ typer.Option(
197
+ help="A room name to test the service in (must not be currently running)"
198
+ ),
199
+ ] = None,
200
+ key: Annotated[
201
+ str,
202
+ typer.Option("--key", help="an api key to sign the token with"),
203
+ ] = None,
204
+ ):
205
+ key = await resolve_key(project_id=project_id, key=key)
206
+
207
+ if port is None:
208
+ import socket
209
+
210
+ def find_free_port():
211
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
212
+ s.bind(("", 0)) # Bind to a free port provided by the host.
213
+ s.listen(1)
214
+ return s.getsockname()[1]
215
+
216
+ port = find_free_port()
217
+
218
+ my_client = await get_client()
219
+ try:
220
+ project_id = await resolve_project_id(project_id)
221
+ room = resolve_room(room)
222
+
223
+ if room is None:
224
+ print("[bold red]Room was not set[/bold red]")
225
+ raise typer.Exit(1)
226
+
227
+ try:
228
+ parsed_key = parse_api_key(key)
229
+ token = ParticipantToken(
230
+ name="cli", project_id=project_id, api_key_id=parsed_key.id
231
+ )
232
+ token.add_api_grant(ApiScope.agent_default())
233
+ token.add_role_grant("user")
234
+ token.add_room_grant(room)
235
+
236
+ print("[bold green]Connecting to room...[/bold green]")
237
+
238
+ run_tasks = []
239
+
240
+ async def run_service(port: int):
241
+ code, output = await _run_process(
242
+ cmd=shlex.split("python3 " + command),
243
+ log=True,
244
+ env={**os.environ, "MESHAGENT_PORT": str(port)},
245
+ )
246
+
247
+ if code != 0:
248
+ print(f"[red]{output}[/red]")
249
+
250
+ run_tasks.append(asyncio.create_task(run_service(port)))
251
+
252
+ async def get_spec(port: int, attempt=0) -> ServiceSpec:
253
+ import aiohttp
254
+
255
+ max_attempts = 10
256
+
257
+ url = f"http://localhost:{port}{well_known_service_path}"
258
+
259
+ async with aiohttp.ClientSession() as session:
260
+ try:
261
+ res = await session.get(url=url)
262
+ res.raise_for_status()
263
+
264
+ spec_json = await res.json()
265
+
266
+ return ServiceSpec.model_validate(spec_json)
267
+
268
+ except Exception:
269
+ if attempt < max_attempts:
270
+ backoff = 0.1 * pow(2, attempt)
271
+ await asyncio.sleep(backoff)
272
+ return await get_spec(port, attempt + 1)
273
+ else:
274
+ print("[red]unable to read service spec[/red]")
275
+ raise typer.Exit(-1)
276
+
277
+ spec = await get_spec(port)
278
+
279
+ sys.stdout.write("\n")
280
+
281
+ for p in spec.ports:
282
+ print(f"[bold green]Connecting port {p.num}...[/bold green]")
283
+
284
+ for endpoint in p.endpoints:
285
+ print(
286
+ f"[bold green]Connecting endpoint {endpoint.path}...[/bold green]"
287
+ )
288
+
289
+ run_tasks.append(
290
+ asyncio.create_task(
291
+ _make_call(
292
+ room=room,
293
+ project_id=project_id,
294
+ participant_name=endpoint.meshagent.identity,
295
+ url=f"http://localhost:{p.num}{endpoint.path}",
296
+ arguments={},
297
+ key=key,
298
+ permissions=endpoint.meshagent.api,
299
+ )
300
+ )
301
+ )
302
+
303
+ await asyncio.gather(*run_tasks)
304
+
305
+ except ClientResponseError as exc:
306
+ if exc.status == 409:
307
+ print(f"[red]Room already in use: {room}[/red]")
308
+ raise typer.Exit(code=1)
309
+ raise
310
+
311
+ except Exception as e:
312
+ print(f"[red]{e}[/red]")
313
+ raise typer.Exit(code=1)
314
+
315
+ finally:
316
+ await my_client.close()
317
+
318
+
319
+ @app.async_command("show")
320
+ async def service_show(
321
+ *,
322
+ project_id: ProjectIdOption = None,
323
+ service_id: Annotated[str, typer.Argument(help="ID of the service to show")],
324
+ ):
325
+ """Show a services for the project."""
326
+ client = await get_client()
327
+ try:
328
+ project_id = await resolve_project_id(project_id)
329
+ service = await client.get_service(
330
+ project_id=project_id, service_id=service_id
331
+ ) # → List[Service]
332
+ print(service.model_dump(mode="json"))
333
+ finally:
334
+ await client.close()
335
+
336
+
337
+ @app.async_command("list")
338
+ async def service_list(
339
+ *,
340
+ project_id: ProjectIdOption = None,
341
+ o: OutputFormatOption = "table",
342
+ room: Annotated[
343
+ Optional[str],
344
+ typer.Option(
345
+ "--room", "-r", help="The name of a room to list the services for"
346
+ ),
347
+ ] = None,
348
+ ):
349
+ """List all services for the project."""
350
+ client = await get_client()
351
+ try:
352
+ project_id = await resolve_project_id(project_id)
353
+ services: list[ServiceSpec] = (
354
+ (await client.list_services(project_id=project_id))
355
+ if room is None
356
+ else (
357
+ await client.list_room_services(project_id=project_id, room_name=room)
358
+ )
359
+ )
360
+
361
+ if o == "json":
362
+ print(
363
+ {"services": [svc.model_dump(mode="json") for svc in services]}
364
+ ).model_dump_json(indent=2)
365
+ else:
366
+ print_json_table(
367
+ [
368
+ {
369
+ "id": svc.id,
370
+ "name": svc.metadata.name,
371
+ "image": svc.container.image
372
+ if svc.container is not None
373
+ else None,
374
+ }
375
+ for svc in services
376
+ ],
377
+ "id",
378
+ "name",
379
+ "image",
380
+ )
381
+ finally:
382
+ await client.close()
383
+
384
+
385
+ @app.async_command("delete")
386
+ async def service_delete(
387
+ *,
388
+ project_id: ProjectIdOption = None,
389
+ service_id: Annotated[str, typer.Argument(help="ID of the service to delete")],
390
+ room: Annotated[
391
+ Optional[str],
392
+ typer.Option(
393
+ "--room", "-r", help="The name of a room to delete the service for"
394
+ ),
395
+ ] = None,
396
+ ):
397
+ """Delete a service."""
398
+ client = await get_client()
399
+ try:
400
+ project_id = await resolve_project_id(project_id)
401
+ if room is None:
402
+ await client.delete_service(project_id=project_id, service_id=service_id)
403
+ else:
404
+ await client.delete_service(
405
+ project_id=project_id, service_id=service_id, room_name=room
406
+ )
407
+ print(f"[green]Service {service_id} deleted.[/]")
408
+ finally:
409
+ await client.close()
410
+
411
+
412
+ async def _run_process(
413
+ cmd: list[str], cwd=None, env=None, timeout: float | None = None, log: bool = False
414
+ ) -> tuple[int, str]:
415
+ """
416
+ Spawn a process, stream its output line-by-line as it runs, and return its exit code.
417
+ stdout+stderr are merged to preserve ordering.
418
+ """
419
+ proc = await asyncio.create_subprocess_exec(
420
+ *cmd,
421
+ cwd=cwd,
422
+ env=env,
423
+ stdout=asyncio.subprocess.PIPE,
424
+ stderr=asyncio.subprocess.STDOUT,
425
+ preexec_fn=_preexec_fn,
426
+ )
427
+
428
+ _spawned.append(proc)
429
+
430
+ output = []
431
+ try:
432
+ # Stream lines as they appear
433
+ assert proc.stdout is not None
434
+ while True:
435
+ line = (
436
+ await asyncio.wait_for(proc.stdout.readline(), timeout=timeout)
437
+ if timeout
438
+ else await proc.stdout.readline()
439
+ )
440
+ if not line:
441
+ break
442
+ ln = line.decode(errors="replace").rstrip()
443
+ if log:
444
+ print(ln, flush=True)
445
+ output.append(ln) # or send to a logger/queue
446
+
447
+ return await proc.wait(), "".join(output)
448
+ except asyncio.TimeoutError:
449
+ # Graceful shutdown on timeout
450
+ proc.terminate()
451
+ try:
452
+ await asyncio.wait_for(proc.wait(), 5)
453
+ except asyncio.TimeoutError:
454
+ proc.kill()
455
+ await proc.wait()
456
+ raise
457
+
458
+
459
+ # Linux-only: send SIGTERM to child if parent dies
460
+ _PRCTL_AVAILABLE = sys.platform.startswith("linux")
461
+ if _PRCTL_AVAILABLE:
462
+ libc = ctypes.CDLL("libc.so.6", use_errno=True)
463
+ PR_SET_PDEATHSIG = 1
464
+
465
+
466
+ def _preexec_fn():
467
+ # Make child the leader of a new session/process group
468
+ os.setsid()
469
+ # On Linux, ensure child gets SIGTERM if parent dies unexpectedly
470
+ if _PRCTL_AVAILABLE:
471
+ if libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM) != 0:
472
+ err = ctypes.get_errno()
473
+ raise OSError(err, "prctl(PR_SET_PDEATHSIG) failed")
474
+
475
+
476
+ _spawned = []
477
+
478
+
479
+ def _cleanup():
480
+ # Kill each child's process group (created by setsid)
481
+ for p in _spawned:
482
+ try:
483
+ os.killpg(p.pid, signal.SIGTERM)
484
+ except ProcessLookupError:
485
+ pass
486
+ except Exception:
487
+ pass
488
+
489
+
490
+ atexit.register(_cleanup)
@@ -0,0 +1,26 @@
1
+ from meshagent.cli import async_typer
2
+ from meshagent.cli.helper import get_client, print_json_table, resolve_project_id
3
+ from meshagent.cli.common_options import ProjectIdOption
4
+
5
+ app = async_typer.AsyncTyper()
6
+
7
+
8
+ @app.async_command("list")
9
+ async def list(*, project_id: ProjectIdOption = None):
10
+ client = await get_client()
11
+ sessions = await client.list_recent_sessions(
12
+ project_id=await resolve_project_id(project_id=project_id)
13
+ )
14
+ print_json_table(sessions["sessions"])
15
+ await client.close()
16
+
17
+
18
+ @app.async_command("show")
19
+ async def show(*, project_id: ProjectIdOption = None, session_id: str):
20
+ client = await get_client()
21
+ events = await client.list_session_events(
22
+ project_id=await resolve_project_id(project_id=project_id),
23
+ session_id=session_id,
24
+ )
25
+ print_json_table(events["events"], "type", "data")
26
+ await client.close()