meshagent-cli 0.5.18__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.
- meshagent/cli/agent.py +11 -62
- meshagent/cli/api_keys.py +46 -93
- meshagent/cli/auth_async.py +225 -68
- meshagent/cli/call.py +82 -19
- meshagent/cli/chatbot.py +83 -49
- meshagent/cli/cli.py +26 -70
- meshagent/cli/cli_mcp.py +61 -27
- meshagent/cli/cli_secrets.py +1 -1
- meshagent/cli/common_options.py +2 -10
- meshagent/cli/containers.py +577 -0
- meshagent/cli/developer.py +7 -25
- meshagent/cli/exec.py +162 -76
- meshagent/cli/helper.py +35 -67
- meshagent/cli/helpers.py +131 -0
- meshagent/cli/mailbot.py +31 -26
- meshagent/cli/meeting_transcriber.py +124 -0
- meshagent/cli/messaging.py +12 -51
- meshagent/cli/oauth2.py +189 -0
- meshagent/cli/participant_token.py +32 -21
- meshagent/cli/queue.py +6 -37
- meshagent/cli/services.py +300 -335
- meshagent/cli/storage.py +24 -89
- meshagent/cli/version.py +1 -1
- meshagent/cli/voicebot.py +39 -28
- meshagent/cli/webhook.py +3 -3
- {meshagent_cli-0.5.18.dist-info → meshagent_cli-0.6.0.dist-info}/METADATA +17 -11
- meshagent_cli-0.6.0.dist-info/RECORD +35 -0
- meshagent/cli/otel.py +0 -122
- meshagent_cli-0.5.18.dist-info/RECORD +0 -32
- {meshagent_cli-0.5.18.dist-info → meshagent_cli-0.6.0.dist-info}/WHEEL +0 -0
- {meshagent_cli-0.5.18.dist-info → meshagent_cli-0.6.0.dist-info}/entry_points.txt +0 -0
- {meshagent_cli-0.5.18.dist-info → meshagent_cli-0.6.0.dist-info}/top_level.txt +0 -0
meshagent/cli/services.py
CHANGED
|
@@ -3,93 +3,44 @@
|
|
|
3
3
|
# ---------------------------------------------------------------------------
|
|
4
4
|
import typer
|
|
5
5
|
from rich import print
|
|
6
|
-
from typing import Annotated,
|
|
7
|
-
from meshagent.cli.common_options import ProjectIdOption
|
|
6
|
+
from typing import Annotated, Optional
|
|
7
|
+
from meshagent.cli.common_options import ProjectIdOption
|
|
8
8
|
from aiohttp import ClientResponseError
|
|
9
|
-
|
|
10
|
-
from pydantic import PositiveInt
|
|
11
|
-
import pydantic
|
|
12
|
-
from typing import Literal
|
|
9
|
+
import pathlib
|
|
13
10
|
from meshagent.cli import async_typer
|
|
11
|
+
from meshagent.api.services import well_known_service_path
|
|
14
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
|
|
15
23
|
|
|
16
24
|
|
|
17
25
|
from meshagent.cli.helper import (
|
|
18
26
|
get_client,
|
|
19
27
|
print_json_table,
|
|
20
28
|
resolve_project_id,
|
|
21
|
-
resolve_api_key,
|
|
22
29
|
resolve_room,
|
|
30
|
+
resolve_key,
|
|
23
31
|
)
|
|
24
32
|
from meshagent.api import (
|
|
25
33
|
ParticipantToken,
|
|
26
|
-
|
|
27
|
-
WebSocketClientProtocol,
|
|
28
|
-
websocket_room_url,
|
|
29
|
-
meshagent_base_url,
|
|
34
|
+
ApiScope,
|
|
30
35
|
)
|
|
31
36
|
from meshagent.cli.common_options import OutputFormatOption
|
|
32
37
|
|
|
33
38
|
from pydantic_yaml import parse_yaml_raw_as
|
|
34
39
|
|
|
35
|
-
# Pydantic basemodels
|
|
36
|
-
from meshagent.api.accounts_client import Service, Port, Services
|
|
37
|
-
|
|
38
|
-
app = async_typer.AsyncTyper(help="Manage services for your project")
|
|
39
|
-
|
|
40
|
-
# ---------------------------------------------------------------------------
|
|
41
|
-
# Utilities
|
|
42
|
-
# ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _kv_to_dict(pairs: List[str]) -> Dict[str, str]:
|
|
46
|
-
"""Convert ["A=1","B=2"] → {"A":"1","B":"2"}."""
|
|
47
|
-
out: Dict[str, str] = {}
|
|
48
|
-
for p in pairs:
|
|
49
|
-
if "=" not in p:
|
|
50
|
-
raise typer.BadParameter(f"'{p}' must be KEY=VALUE")
|
|
51
|
-
k, v = p.split("=", 1)
|
|
52
|
-
out[k] = v
|
|
53
|
-
return out
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class PortSpec(pydantic.BaseModel):
|
|
57
|
-
"""
|
|
58
|
-
CLI schema for --port.
|
|
59
|
-
Example:
|
|
60
|
-
--port num=8080 type=webserver liveness=/health path=/agent participant_name=myname
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
num: PositiveInt | Literal["*"]
|
|
64
|
-
type: Literal["mcp.sse", "meshagent.callable", "http", "tcp"]
|
|
65
|
-
liveness: str | None = None
|
|
66
|
-
participant_name: str | None = None
|
|
67
|
-
path: str | None = None
|
|
68
40
|
|
|
41
|
+
from meshagent.cli.call import _make_call
|
|
69
42
|
|
|
70
|
-
|
|
71
|
-
"""
|
|
72
|
-
Convert "num=8080 type=webserver liveness=/health" → PortSpec.
|
|
73
|
-
The user should quote the whole string if it contains spaces.
|
|
74
|
-
"""
|
|
75
|
-
tokens = spec.strip().split()
|
|
76
|
-
kv: Dict[str, str] = {}
|
|
77
|
-
for t in tokens:
|
|
78
|
-
if "=" not in t:
|
|
79
|
-
raise typer.BadParameter(
|
|
80
|
-
f"expected num=PORT_NUMBER type=meshagent.callable|mcp.sse liveness=OPTIONAL_PATH, got '{t}'"
|
|
81
|
-
)
|
|
82
|
-
k, v = t.split("=", 1)
|
|
83
|
-
kv[k] = v
|
|
84
|
-
try:
|
|
85
|
-
return PortSpec(**kv)
|
|
86
|
-
except pydantic.ValidationError as exc:
|
|
87
|
-
raise typer.BadParameter(str(exc))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
# ---------------------------------------------------------------------------
|
|
91
|
-
# Commands
|
|
92
|
-
# ---------------------------------------------------------------------------
|
|
43
|
+
app = async_typer.AsyncTyper(help="Manage services for your project")
|
|
93
44
|
|
|
94
45
|
|
|
95
46
|
@app.async_command("create")
|
|
@@ -97,100 +48,40 @@ async def service_create(
|
|
|
97
48
|
*,
|
|
98
49
|
project_id: ProjectIdOption = None,
|
|
99
50
|
file: Annotated[
|
|
100
|
-
|
|
51
|
+
str,
|
|
101
52
|
typer.Option("--file", "-f", help="File path to a service definition"),
|
|
102
|
-
]
|
|
103
|
-
|
|
104
|
-
image: Annotated[
|
|
105
|
-
Optional[str], typer.Option(help="Container image reference")
|
|
106
|
-
] = None,
|
|
107
|
-
role: Annotated[
|
|
108
|
-
Optional[str], typer.Option(help="Service role (agent|tool)")
|
|
109
|
-
] = None,
|
|
110
|
-
pull_secret: Annotated[
|
|
111
|
-
Optional[str],
|
|
112
|
-
typer.Option("--pull-secret", help="Secret ID for registry"),
|
|
113
|
-
] = None,
|
|
114
|
-
command: Annotated[
|
|
115
|
-
Optional[str],
|
|
116
|
-
typer.Option("--command", help="Override ENTRYPOINT/CMD"),
|
|
117
|
-
] = None,
|
|
118
|
-
env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
|
|
119
|
-
env_secret: Annotated[List[str], typer.Option("--env-secret")] = [],
|
|
120
|
-
runtime_secret: Annotated[List[str], typer.Option("--runtime-secret")] = [],
|
|
121
|
-
room_storage_path: Annotated[
|
|
122
|
-
Optional[str],
|
|
123
|
-
typer.Option("--mount", help="Path inside container to mount room storage"),
|
|
124
|
-
] = None,
|
|
125
|
-
room_storage_subpath: Annotated[
|
|
53
|
+
],
|
|
54
|
+
room: Annotated[
|
|
126
55
|
Optional[str],
|
|
127
56
|
typer.Option(
|
|
128
|
-
"--
|
|
129
|
-
help="Restrict the container's mount to a subpath within the room storage",
|
|
57
|
+
"--room", "-r", help="The name of a room to create the service for"
|
|
130
58
|
),
|
|
131
59
|
] = None,
|
|
132
|
-
port: Annotated[
|
|
133
|
-
List[str],
|
|
134
|
-
typer.Option(
|
|
135
|
-
"--port",
|
|
136
|
-
"-p",
|
|
137
|
-
help=(
|
|
138
|
-
"Repeatable. Example:\n"
|
|
139
|
-
' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
|
|
140
|
-
),
|
|
141
|
-
),
|
|
142
|
-
] = [],
|
|
143
60
|
):
|
|
144
61
|
"""Create a service attached to the project."""
|
|
145
62
|
client = await get_client()
|
|
146
63
|
try:
|
|
147
64
|
project_id = await resolve_project_id(project_id)
|
|
148
65
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
spec = parse_yaml_raw_as(ServiceSpec, f.read())
|
|
152
|
-
if spec.id is not None:
|
|
153
|
-
print("[red]id cannot be set when creating a service[/red]")
|
|
154
|
-
raise typer.Exit(code=1)
|
|
66
|
+
with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
|
|
67
|
+
spec = parse_yaml_raw_as(ServiceSpec, f.read())
|
|
155
68
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# ✅ validate / coerce port specs
|
|
160
|
-
port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
|
|
161
|
-
|
|
162
|
-
ports_dict = {
|
|
163
|
-
ps.num: Port(
|
|
164
|
-
type=ps.type,
|
|
165
|
-
liveness_path=ps.liveness,
|
|
166
|
-
participant_name=ps.participant_name,
|
|
167
|
-
path=ps.path,
|
|
168
|
-
)
|
|
169
|
-
for ps in port_specs
|
|
170
|
-
} or None
|
|
171
|
-
|
|
172
|
-
service_obj = Service(
|
|
173
|
-
created_at=datetime.now(timezone.utc).isoformat(),
|
|
174
|
-
name=name,
|
|
175
|
-
role=role,
|
|
176
|
-
image=image,
|
|
177
|
-
command=command,
|
|
178
|
-
pull_secret=pull_secret,
|
|
179
|
-
room_storage_path=room_storage_path,
|
|
180
|
-
room_storage_subpath=room_storage_subpath,
|
|
181
|
-
environment=_kv_to_dict(env),
|
|
182
|
-
environment_secrets=env_secret or None,
|
|
183
|
-
runtime_secrets=_kv_to_dict(runtime_secret),
|
|
184
|
-
ports=ports_dict,
|
|
185
|
-
)
|
|
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)
|
|
186
72
|
|
|
187
73
|
try:
|
|
188
|
-
|
|
189
|
-
await client.create_service(
|
|
190
|
-
|
|
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
|
+
)
|
|
191
82
|
except ClientResponseError as exc:
|
|
192
83
|
if exc.status == 409:
|
|
193
|
-
print(f"[red]Service name already in use: {
|
|
84
|
+
print(f"[red]Service name already in use: {spec.metadata.name}[/red]")
|
|
194
85
|
raise typer.Exit(code=1)
|
|
195
86
|
raise
|
|
196
87
|
else:
|
|
@@ -206,102 +97,43 @@ async def service_update(
|
|
|
206
97
|
project_id: ProjectIdOption = None,
|
|
207
98
|
id: Optional[str] = None,
|
|
208
99
|
file: Annotated[
|
|
209
|
-
|
|
100
|
+
str,
|
|
210
101
|
typer.Option("--file", "-f", help="File path to a service definition"),
|
|
211
|
-
]
|
|
212
|
-
name: Annotated[Optional[str], typer.Option(help="Friendly service name")] = None,
|
|
213
|
-
image: Annotated[
|
|
214
|
-
Optional[str], typer.Option(help="Container image reference")
|
|
215
|
-
] = None,
|
|
216
|
-
role: Annotated[
|
|
217
|
-
Optional[str], typer.Option(help="Service role (agent|tool)")
|
|
218
|
-
] = None,
|
|
219
|
-
pull_secret: Annotated[
|
|
220
|
-
Optional[str],
|
|
221
|
-
typer.Option("--pull-secret", help="Secret ID for registry"),
|
|
222
|
-
] = None,
|
|
223
|
-
command: Annotated[
|
|
224
|
-
Optional[str],
|
|
225
|
-
typer.Option("--command", help="Override ENTRYPOINT/CMD"),
|
|
226
|
-
] = None,
|
|
227
|
-
env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
|
|
228
|
-
env_secret: Annotated[List[str], typer.Option("--env-secret")] = [],
|
|
229
|
-
runtime_secret: Annotated[List[str], typer.Option("--runtime-secret")] = [],
|
|
230
|
-
room_storage_path: Annotated[
|
|
231
|
-
Optional[str],
|
|
232
|
-
typer.Option("--mount", help="Path inside container to mount room storage"),
|
|
233
|
-
] = None,
|
|
234
|
-
room_storage_subpath: Annotated[
|
|
235
|
-
Optional[str],
|
|
236
|
-
typer.Option(
|
|
237
|
-
"--mount-subpath",
|
|
238
|
-
help="Restrict the container's mount to a subpath within the room storage",
|
|
239
|
-
),
|
|
240
|
-
] = None,
|
|
241
|
-
port: Annotated[
|
|
242
|
-
List[str],
|
|
243
|
-
typer.Option(
|
|
244
|
-
"--port",
|
|
245
|
-
"-p",
|
|
246
|
-
help=(
|
|
247
|
-
"Repeatable. Example:\n"
|
|
248
|
-
' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
|
|
249
|
-
),
|
|
250
|
-
),
|
|
251
|
-
] = [],
|
|
102
|
+
],
|
|
252
103
|
create: Annotated[
|
|
253
104
|
Optional[bool],
|
|
254
105
|
typer.Option(
|
|
255
106
|
help="create the service if it does not exist",
|
|
256
107
|
),
|
|
257
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,
|
|
258
115
|
):
|
|
259
116
|
"""Create a service attached to the project."""
|
|
260
117
|
client = await get_client()
|
|
261
118
|
try:
|
|
262
119
|
project_id = await resolve_project_id(project_id)
|
|
263
120
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
id = spec.id
|
|
269
|
-
service_obj = spec.to_service()
|
|
270
|
-
|
|
271
|
-
else:
|
|
272
|
-
# ✅ validate / coerce port specs
|
|
273
|
-
port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
|
|
274
|
-
|
|
275
|
-
ports_dict = {
|
|
276
|
-
ps.num: Port(
|
|
277
|
-
type=ps.type,
|
|
278
|
-
liveness_path=ps.liveness,
|
|
279
|
-
participant_name=ps.participant_name,
|
|
280
|
-
path=ps.path,
|
|
281
|
-
)
|
|
282
|
-
for ps in port_specs
|
|
283
|
-
} or None
|
|
284
|
-
|
|
285
|
-
service_obj = Service(
|
|
286
|
-
created_at=datetime.now(timezone.utc).isoformat(),
|
|
287
|
-
name=name,
|
|
288
|
-
role=role,
|
|
289
|
-
image=image,
|
|
290
|
-
command=command,
|
|
291
|
-
pull_secret=pull_secret,
|
|
292
|
-
room_storage_path=room_storage_path,
|
|
293
|
-
room_storage_subpath=room_storage_subpath,
|
|
294
|
-
environment=_kv_to_dict(env),
|
|
295
|
-
environment_secrets=env_secret or None,
|
|
296
|
-
runtime_secrets=_kv_to_dict(runtime_secret),
|
|
297
|
-
ports=ports_dict,
|
|
298
|
-
)
|
|
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
|
|
299
125
|
|
|
300
126
|
try:
|
|
301
127
|
if id is None:
|
|
302
|
-
|
|
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
|
+
|
|
303
135
|
for s in services:
|
|
304
|
-
if s.name ==
|
|
136
|
+
if s.metadata.name == spec.metadata.name:
|
|
305
137
|
id = s.id
|
|
306
138
|
|
|
307
139
|
if id is None and not create:
|
|
@@ -309,20 +141,32 @@ async def service_update(
|
|
|
309
141
|
raise typer.Exit(code=1)
|
|
310
142
|
|
|
311
143
|
if id is None:
|
|
312
|
-
|
|
313
|
-
await client.create_service(
|
|
314
|
-
project_id=project_id, service=
|
|
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
|
|
315
151
|
)
|
|
316
|
-
)["id"]
|
|
317
152
|
|
|
318
153
|
else:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
+
)
|
|
322
166
|
|
|
323
167
|
except ClientResponseError as exc:
|
|
324
168
|
if exc.status == 409:
|
|
325
|
-
print(f"[red]Service name already in use: {
|
|
169
|
+
print(f"[red]Service name already in use: {spec.metadata.name}[/red]")
|
|
326
170
|
raise typer.Exit(code=1)
|
|
327
171
|
raise
|
|
328
172
|
else:
|
|
@@ -332,137 +176,142 @@ async def service_update(
|
|
|
332
176
|
await client.close()
|
|
333
177
|
|
|
334
178
|
|
|
335
|
-
@app.async_command("
|
|
336
|
-
async def
|
|
179
|
+
@app.async_command("run")
|
|
180
|
+
async def service_run(
|
|
337
181
|
*,
|
|
338
182
|
project_id: ProjectIdOption = None,
|
|
339
|
-
|
|
340
|
-
file: Annotated[
|
|
341
|
-
Optional[str],
|
|
342
|
-
typer.Option("--file", "-f", help="File path to a service definition"),
|
|
343
|
-
],
|
|
344
|
-
room: Annotated[
|
|
345
|
-
Optional[str],
|
|
346
|
-
typer.Option(
|
|
347
|
-
help="A room name to test the service in (must not be currently running)"
|
|
348
|
-
),
|
|
349
|
-
] = None,
|
|
350
|
-
name: Annotated[Optional[str], typer.Option(help="Friendly service name")] = None,
|
|
351
|
-
role: Annotated[
|
|
352
|
-
Optional[str], typer.Option(help="Service role (agent|tool)")
|
|
353
|
-
] = None,
|
|
354
|
-
image: Annotated[
|
|
355
|
-
Optional[str], typer.Option(help="Container image reference")
|
|
356
|
-
] = None,
|
|
357
|
-
pull_secret: Annotated[
|
|
358
|
-
Optional[str],
|
|
359
|
-
typer.Option("--pull-secret", help="Secret ID for registry"),
|
|
360
|
-
] = None,
|
|
361
|
-
command: Annotated[
|
|
362
|
-
Optional[str],
|
|
363
|
-
typer.Option("--command", help="Override ENTRYPOINT/CMD"),
|
|
364
|
-
] = None,
|
|
365
|
-
env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
|
|
366
|
-
env_secret: Annotated[List[str], typer.Option("--env-secret")] = [],
|
|
367
|
-
runtime_secret: Annotated[List[str], typer.Option("--runtime-secret")] = [],
|
|
368
|
-
room_storage_path: Annotated[
|
|
369
|
-
Optional[str],
|
|
370
|
-
typer.Option("--mount", help="Path inside container to mount room storage"),
|
|
371
|
-
] = None,
|
|
183
|
+
command: str,
|
|
372
184
|
port: Annotated[
|
|
373
|
-
|
|
185
|
+
int,
|
|
374
186
|
typer.Option(
|
|
375
187
|
"--port",
|
|
376
188
|
"-p",
|
|
377
189
|
help=(
|
|
378
|
-
"
|
|
379
|
-
' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
|
|
190
|
+
"a port number to run the agent on (will set MESHAGENT_PORT environment variable when launching the service)"
|
|
380
191
|
),
|
|
381
192
|
),
|
|
382
|
-
] =
|
|
383
|
-
|
|
384
|
-
Optional[
|
|
193
|
+
] = None,
|
|
194
|
+
room: Annotated[
|
|
195
|
+
Optional[str],
|
|
385
196
|
typer.Option(
|
|
386
|
-
|
|
197
|
+
help="A room name to test the service in (must not be currently running)"
|
|
387
198
|
),
|
|
388
199
|
] = None,
|
|
200
|
+
key: Annotated[
|
|
201
|
+
str,
|
|
202
|
+
typer.Option("--key", help="an api key to sign the token with"),
|
|
203
|
+
] = None,
|
|
389
204
|
):
|
|
390
|
-
|
|
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
|
+
|
|
391
218
|
my_client = await get_client()
|
|
392
219
|
try:
|
|
393
220
|
project_id = await resolve_project_id(project_id)
|
|
394
|
-
api_key_id = await resolve_api_key(project_id, api_key_id)
|
|
395
221
|
room = resolve_room(room)
|
|
396
222
|
|
|
397
|
-
if
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
else:
|
|
402
|
-
# ✅ validate / coerce port specs
|
|
403
|
-
port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
|
|
404
|
-
|
|
405
|
-
ports_dict = {
|
|
406
|
-
str(ps.num): Port(
|
|
407
|
-
type=ps.type,
|
|
408
|
-
liveness_path=ps.liveness,
|
|
409
|
-
participant_name=ps.participant_name,
|
|
410
|
-
path=ps.path,
|
|
411
|
-
)
|
|
412
|
-
for ps in port_specs
|
|
413
|
-
} or None
|
|
414
|
-
|
|
415
|
-
service_obj = Service(
|
|
416
|
-
created_at=datetime.now(timezone.utc).isoformat(),
|
|
417
|
-
role=role,
|
|
418
|
-
name=name,
|
|
419
|
-
image=image,
|
|
420
|
-
command=command,
|
|
421
|
-
pull_secret=pull_secret,
|
|
422
|
-
room_storage_path=room_storage_path,
|
|
423
|
-
environment=_kv_to_dict(env),
|
|
424
|
-
environment_secrets=env_secret or None,
|
|
425
|
-
runtime_secrets=_kv_to_dict(runtime_secret),
|
|
426
|
-
ports=ports_dict,
|
|
427
|
-
)
|
|
223
|
+
if room is None:
|
|
224
|
+
print("[bold red]Room was not set[/bold red]")
|
|
225
|
+
raise typer.Exit(1)
|
|
428
226
|
|
|
429
227
|
try:
|
|
228
|
+
parsed_key = parse_api_key(key)
|
|
430
229
|
token = ParticipantToken(
|
|
431
|
-
name=
|
|
230
|
+
name="cli", project_id=project_id, api_key_id=parsed_key.id
|
|
432
231
|
)
|
|
232
|
+
token.add_api_grant(ApiScope.agent_default())
|
|
433
233
|
token.add_role_grant("user")
|
|
434
234
|
token.add_room_grant(room)
|
|
435
|
-
token.extra_payload = {
|
|
436
|
-
"max_runtime_seconds": timeout, # run for 1 hr max
|
|
437
|
-
"meshagent_dev_services": [service_obj.model_dump(mode="json")],
|
|
438
|
-
}
|
|
439
235
|
|
|
440
236
|
print("[bold green]Connecting to room...[/bold green]")
|
|
441
237
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
protocol=WebSocketClientProtocol(
|
|
450
|
-
url=websocket_room_url(
|
|
451
|
-
room_name=room, base_url=meshagent_base_url()
|
|
452
|
-
),
|
|
453
|
-
token=token.to_jwt(token=key),
|
|
454
|
-
)
|
|
455
|
-
) as client:
|
|
456
|
-
print(
|
|
457
|
-
f"[green]Your test room '{client.room_name}' has been started. It will time out after a few minutes if you do not join it.[/green]"
|
|
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)},
|
|
458
245
|
)
|
|
459
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
|
+
|
|
460
305
|
except ClientResponseError as exc:
|
|
461
306
|
if exc.status == 409:
|
|
462
307
|
print(f"[red]Room already in use: {room}[/red]")
|
|
463
308
|
raise typer.Exit(code=1)
|
|
464
309
|
raise
|
|
465
310
|
|
|
311
|
+
except Exception as e:
|
|
312
|
+
print(f"[red]{e}[/red]")
|
|
313
|
+
raise typer.Exit(code=1)
|
|
314
|
+
|
|
466
315
|
finally:
|
|
467
316
|
await my_client.close()
|
|
468
317
|
|
|
@@ -471,7 +320,7 @@ async def service_test(
|
|
|
471
320
|
async def service_show(
|
|
472
321
|
*,
|
|
473
322
|
project_id: ProjectIdOption = None,
|
|
474
|
-
service_id: Annotated[str, typer.Argument(help="ID of the service to
|
|
323
|
+
service_id: Annotated[str, typer.Argument(help="ID of the service to show")],
|
|
475
324
|
):
|
|
476
325
|
"""Show a services for the project."""
|
|
477
326
|
client = await get_client()
|
|
@@ -490,20 +339,44 @@ async def service_list(
|
|
|
490
339
|
*,
|
|
491
340
|
project_id: ProjectIdOption = None,
|
|
492
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,
|
|
493
348
|
):
|
|
494
349
|
"""List all services for the project."""
|
|
495
350
|
client = await get_client()
|
|
496
351
|
try:
|
|
497
352
|
project_id = await resolve_project_id(project_id)
|
|
498
|
-
services: list[
|
|
499
|
-
project_id=project_id
|
|
500
|
-
|
|
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
|
+
)
|
|
501
360
|
|
|
502
361
|
if o == "json":
|
|
503
|
-
print(
|
|
362
|
+
print(
|
|
363
|
+
{"services": [svc.model_dump(mode="json") for svc in services]}
|
|
364
|
+
).model_dump_json(indent=2)
|
|
504
365
|
else:
|
|
505
366
|
print_json_table(
|
|
506
|
-
[
|
|
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",
|
|
507
380
|
)
|
|
508
381
|
finally:
|
|
509
382
|
await client.close()
|
|
@@ -514,12 +387,104 @@ async def service_delete(
|
|
|
514
387
|
*,
|
|
515
388
|
project_id: ProjectIdOption = None,
|
|
516
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,
|
|
517
396
|
):
|
|
518
397
|
"""Delete a service."""
|
|
519
398
|
client = await get_client()
|
|
520
399
|
try:
|
|
521
400
|
project_id = await resolve_project_id(project_id)
|
|
522
|
-
|
|
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
|
+
)
|
|
523
407
|
print(f"[green]Service {service_id} deleted.[/]")
|
|
524
408
|
finally:
|
|
525
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)
|