plato-sdk-v2 2.7.6__py3-none-any.whl → 2.7.7__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.
- plato/cli/__init__.py +5 -0
- plato/cli/agent.py +1209 -0
- plato/cli/audit_ui.py +316 -0
- plato/cli/chronos.py +817 -0
- plato/cli/main.py +193 -0
- plato/cli/pm.py +1206 -0
- plato/cli/proxy.py +222 -0
- plato/cli/sandbox.py +808 -0
- plato/cli/utils.py +200 -0
- plato/cli/verify.py +690 -0
- plato/cli/world.py +250 -0
- plato/v1/cli/pm.py +4 -1
- plato/v2/__init__.py +2 -0
- plato/v2/models.py +42 -0
- plato/v2/sync/__init__.py +6 -0
- plato/v2/sync/client.py +6 -3
- plato/v2/sync/sandbox.py +1461 -0
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.7.dist-info}/METADATA +1 -1
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.7.dist-info}/RECORD +21 -9
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.7.dist-info}/entry_points.txt +1 -1
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.7.dist-info}/WHEEL +0 -0
plato/cli/sandbox.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
"""Sandbox CLI commands for Plato."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from plato.cli.utils import require_api_key
|
|
16
|
+
from plato.v2.sync.sandbox import SandboxClient
|
|
17
|
+
|
|
18
|
+
# =============================================================================
|
|
19
|
+
# COMMON ARG TYPES
|
|
20
|
+
# =============================================================================
|
|
21
|
+
|
|
22
|
+
WORKING_DIR = Path.cwd()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Panel names for rich help
|
|
26
|
+
STATE_PANEL = "State (Loaded from .plato/state.json if not provided)"
|
|
27
|
+
OUTPUT_PANEL = "General"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SandboxStateError(Exception):
|
|
31
|
+
"""Raised when required state is missing from the client.
|
|
32
|
+
|
|
33
|
+
This typically means either:
|
|
34
|
+
- No state file exists (run `plato sandbox start` first)
|
|
35
|
+
- The required field wasn't saved in state
|
|
36
|
+
- The explicit CLI argument wasn't provided
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, field: str, hint: str | None = None):
|
|
40
|
+
self.field = field
|
|
41
|
+
self.hint = hint or f"Provide --{field.replace('_', '-')} or run `plato sandbox start` first"
|
|
42
|
+
super().__init__(f"Missing required field: {field}. {self.hint}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# State file helpers
|
|
46
|
+
def required_state_field(field: str):
|
|
47
|
+
"""Default factory to pull a field from .plato/state.json in WORKING_DIR."""
|
|
48
|
+
|
|
49
|
+
def _factory():
|
|
50
|
+
path = WORKING_DIR / ".plato" / "state.json"
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return None
|
|
53
|
+
loaded = {}
|
|
54
|
+
try:
|
|
55
|
+
loaded = json.loads(path.read_text())
|
|
56
|
+
except Exception:
|
|
57
|
+
raise Exception("failed to load state.json")
|
|
58
|
+
try:
|
|
59
|
+
return loaded.get(field)
|
|
60
|
+
except Exception:
|
|
61
|
+
raise Exception(f"failed to get field '{field}' from state.json, and no default value provided")
|
|
62
|
+
|
|
63
|
+
return _factory
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Working directory setter for CLI option callback
|
|
67
|
+
def _set_working_dir(value: Path):
|
|
68
|
+
"""Option callback to update global WORKING_DIR based on -w/--working-dir."""
|
|
69
|
+
global WORKING_DIR
|
|
70
|
+
WORKING_DIR = value
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# State args - auto-resolved from .plato/state.json if not provided
|
|
75
|
+
SessionIdArg = Annotated[
|
|
76
|
+
str | None,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--session-id",
|
|
79
|
+
help="Session ID",
|
|
80
|
+
rich_help_panel=STATE_PANEL,
|
|
81
|
+
default_factory=required_state_field("session_id"),
|
|
82
|
+
),
|
|
83
|
+
]
|
|
84
|
+
SimulatorNameArg = Annotated[
|
|
85
|
+
str | None,
|
|
86
|
+
typer.Option(
|
|
87
|
+
"--simulator-name",
|
|
88
|
+
help="Simulator name",
|
|
89
|
+
rich_help_panel=STATE_PANEL,
|
|
90
|
+
default_factory=required_state_field("simulator_name"),
|
|
91
|
+
),
|
|
92
|
+
]
|
|
93
|
+
JobIdArg = Annotated[
|
|
94
|
+
str | None,
|
|
95
|
+
typer.Option(
|
|
96
|
+
"--job-id",
|
|
97
|
+
help="Job ID",
|
|
98
|
+
rich_help_panel=STATE_PANEL,
|
|
99
|
+
default_factory=required_state_field("job_id"),
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
SshConfigArg = Annotated[
|
|
103
|
+
str | None,
|
|
104
|
+
typer.Option(
|
|
105
|
+
"--ssh-config",
|
|
106
|
+
"-c",
|
|
107
|
+
help="SSH config path",
|
|
108
|
+
rich_help_panel=STATE_PANEL,
|
|
109
|
+
default_factory=required_state_field("ssh_config_path"),
|
|
110
|
+
),
|
|
111
|
+
]
|
|
112
|
+
SshHostArg = Annotated[
|
|
113
|
+
str | None,
|
|
114
|
+
typer.Option(
|
|
115
|
+
"--ssh-host",
|
|
116
|
+
"-h",
|
|
117
|
+
help="SSH host alias",
|
|
118
|
+
rich_help_panel=STATE_PANEL,
|
|
119
|
+
default_factory=required_state_field("ssh_host"),
|
|
120
|
+
),
|
|
121
|
+
]
|
|
122
|
+
ModeArg = Annotated[
|
|
123
|
+
str | None,
|
|
124
|
+
typer.Option(
|
|
125
|
+
"--mode",
|
|
126
|
+
"-m",
|
|
127
|
+
help="Mode",
|
|
128
|
+
rich_help_panel=STATE_PANEL,
|
|
129
|
+
default_factory=required_state_field("mode"),
|
|
130
|
+
),
|
|
131
|
+
]
|
|
132
|
+
DatasetArg = Annotated[
|
|
133
|
+
str | None,
|
|
134
|
+
typer.Option(
|
|
135
|
+
"--dataset",
|
|
136
|
+
"-d",
|
|
137
|
+
help="Dataset",
|
|
138
|
+
rich_help_panel=STATE_PANEL,
|
|
139
|
+
default_factory=required_state_field("dataset"),
|
|
140
|
+
),
|
|
141
|
+
]
|
|
142
|
+
PublicUrlArg = Annotated[
|
|
143
|
+
str | None,
|
|
144
|
+
typer.Option(
|
|
145
|
+
"--public-url",
|
|
146
|
+
help="Public URL",
|
|
147
|
+
rich_help_panel=STATE_PANEL,
|
|
148
|
+
default_factory=required_state_field("public_url"),
|
|
149
|
+
),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# Output args
|
|
153
|
+
JsonArg = Annotated[
|
|
154
|
+
bool,
|
|
155
|
+
typer.Option(
|
|
156
|
+
"--json",
|
|
157
|
+
"-j",
|
|
158
|
+
help="Output as JSON",
|
|
159
|
+
rich_help_panel=OUTPUT_PANEL,
|
|
160
|
+
),
|
|
161
|
+
]
|
|
162
|
+
VerboseArg = Annotated[
|
|
163
|
+
bool,
|
|
164
|
+
typer.Option(
|
|
165
|
+
"--verbose",
|
|
166
|
+
"-v",
|
|
167
|
+
help="Verbose output",
|
|
168
|
+
rich_help_panel=OUTPUT_PANEL,
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
WorkingDirArg = Annotated[
|
|
172
|
+
Path,
|
|
173
|
+
typer.Option(
|
|
174
|
+
"--working-dir",
|
|
175
|
+
"-w",
|
|
176
|
+
help="Working directory for .plato/",
|
|
177
|
+
rich_help_panel=OUTPUT_PANEL,
|
|
178
|
+
callback=_set_working_dir,
|
|
179
|
+
default_factory=lambda: Path.cwd(),
|
|
180
|
+
),
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
# UUID pattern for detecting artifact IDs in colon notation
|
|
184
|
+
UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
|
|
185
|
+
|
|
186
|
+
sandbox_app = typer.Typer(
|
|
187
|
+
help="""Manage sandbox VMs for simulator development.
|
|
188
|
+
|
|
189
|
+
State: 'start' writes .plato/state.json, other commands read from it.
|
|
190
|
+
Use --working-dir to change where state is stored/loaded."""
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# =============================================================================
|
|
195
|
+
# OUTPUT HELPERS
|
|
196
|
+
# =============================================================================
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _to_dict(obj) -> dict:
|
|
200
|
+
"""Convert a result object to a dict."""
|
|
201
|
+
if obj is None:
|
|
202
|
+
return {}
|
|
203
|
+
if isinstance(obj, dict):
|
|
204
|
+
return obj
|
|
205
|
+
if hasattr(obj, "model_dump"):
|
|
206
|
+
return obj.model_dump(exclude_none=True)
|
|
207
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
208
|
+
from dataclasses import asdict
|
|
209
|
+
|
|
210
|
+
return {k: v for k, v in asdict(obj).items() if v is not None}
|
|
211
|
+
return {"result": str(obj)}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Output:
|
|
215
|
+
"""Output handler that switches between JSON and pretty-print."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, json_mode: bool = False, verbose: bool = False):
|
|
218
|
+
self.json_mode = json_mode
|
|
219
|
+
self.verbose = verbose
|
|
220
|
+
if json_mode and verbose:
|
|
221
|
+
raise ValueError("Cannot use both --json and --verbose")
|
|
222
|
+
|
|
223
|
+
self.super_console = Console()
|
|
224
|
+
if verbose:
|
|
225
|
+
self.console = Console()
|
|
226
|
+
else:
|
|
227
|
+
self.console = Console(quiet=True)
|
|
228
|
+
|
|
229
|
+
def _format_value(self, value, indent: int = 0) -> str:
|
|
230
|
+
"""Format a value with YAML-like indentation."""
|
|
231
|
+
prefix = " " * indent
|
|
232
|
+
if isinstance(value, dict):
|
|
233
|
+
if not value:
|
|
234
|
+
return "{}"
|
|
235
|
+
lines = []
|
|
236
|
+
for k, v in value.items():
|
|
237
|
+
if v is None:
|
|
238
|
+
continue
|
|
239
|
+
formatted = self._format_value(v, indent + 1)
|
|
240
|
+
if isinstance(v, (dict, list)) and v:
|
|
241
|
+
lines.append(f"{prefix} [dim]{k}:[/dim]\n{formatted}")
|
|
242
|
+
else:
|
|
243
|
+
lines.append(f"{prefix} [dim]{k}:[/dim] {formatted}")
|
|
244
|
+
return "\n".join(lines)
|
|
245
|
+
elif isinstance(value, list):
|
|
246
|
+
if not value:
|
|
247
|
+
return "[]"
|
|
248
|
+
lines = []
|
|
249
|
+
for item in value:
|
|
250
|
+
formatted = self._format_value(item, indent + 1)
|
|
251
|
+
if isinstance(item, dict):
|
|
252
|
+
lines.append(f"{prefix} -\n{formatted}")
|
|
253
|
+
else:
|
|
254
|
+
lines.append(f"{prefix} - {formatted}")
|
|
255
|
+
return "\n".join(lines)
|
|
256
|
+
else:
|
|
257
|
+
return str(value)
|
|
258
|
+
|
|
259
|
+
def success(self, result, title: str | None = None) -> None:
|
|
260
|
+
"""Output a successful result."""
|
|
261
|
+
data = _to_dict(result)
|
|
262
|
+
if self.json_mode:
|
|
263
|
+
self.super_console.print(json.dumps(data, indent=2, default=str))
|
|
264
|
+
else:
|
|
265
|
+
if title:
|
|
266
|
+
self.super_console.print(f"[green]{title}[/green]")
|
|
267
|
+
for key, value in data.items():
|
|
268
|
+
if value is None:
|
|
269
|
+
continue
|
|
270
|
+
formatted = self._format_value(value, 0)
|
|
271
|
+
if isinstance(value, (dict, list)) and value:
|
|
272
|
+
self.super_console.print(f"[cyan]{key}:[/cyan]\n{formatted}")
|
|
273
|
+
else:
|
|
274
|
+
self.super_console.print(f"[cyan]{key}:[/cyan] {formatted}")
|
|
275
|
+
|
|
276
|
+
def error(self, msg: str) -> None:
|
|
277
|
+
"""Output an error."""
|
|
278
|
+
if self.json_mode:
|
|
279
|
+
self.super_console.print(json.dumps({"error": msg}))
|
|
280
|
+
else:
|
|
281
|
+
self.super_console.print(f"[red]{msg}[/red]")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@contextmanager
|
|
285
|
+
def sandbox_context(
|
|
286
|
+
working_dir: Path,
|
|
287
|
+
json_output: bool = False,
|
|
288
|
+
verbose: bool = False,
|
|
289
|
+
console: Console = Console(),
|
|
290
|
+
) -> Generator[tuple[SandboxClient, Output], None, None]:
|
|
291
|
+
"""Context manager for CLI commands with error handling.
|
|
292
|
+
|
|
293
|
+
Yields:
|
|
294
|
+
Tuple of (client, output) for use in the command.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
typer.Exit: On any error, after outputting error message.
|
|
298
|
+
"""
|
|
299
|
+
# Enable HTTP request logging when verbose
|
|
300
|
+
if verbose:
|
|
301
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s: %(message)s")
|
|
302
|
+
logging.getLogger("httpx").setLevel(logging.INFO)
|
|
303
|
+
logging.getLogger("httpcore").setLevel(logging.INFO)
|
|
304
|
+
|
|
305
|
+
out = Output(json_output, verbose)
|
|
306
|
+
client = SandboxClient(
|
|
307
|
+
working_dir=working_dir,
|
|
308
|
+
api_key=require_api_key(),
|
|
309
|
+
console=out.console,
|
|
310
|
+
)
|
|
311
|
+
try:
|
|
312
|
+
yield client, out
|
|
313
|
+
except (SandboxStateError, Exception) as e:
|
|
314
|
+
out.error(str(e))
|
|
315
|
+
raise typer.Exit(1)
|
|
316
|
+
finally:
|
|
317
|
+
client.close()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@sandbox_app.command(name="start")
|
|
321
|
+
def sandbox_start(
|
|
322
|
+
working_dir: WorkingDirArg,
|
|
323
|
+
# modes
|
|
324
|
+
simulator: str = typer.Option(None, "--simulator", "-s", help="Simulator (sim)", rich_help_panel="Simulator Mode"),
|
|
325
|
+
from_config: bool = typer.Option(
|
|
326
|
+
False, "--from-config", "-c", help="Use plato-config.yml", rich_help_panel="Config Mode"
|
|
327
|
+
),
|
|
328
|
+
artifact_id: str = typer.Option(None, "--artifact-id", "-a", help="Artifact UUID", rich_help_panel="Artifact Mode"),
|
|
329
|
+
blank: bool = typer.Option(False, "--blank", "-b", help="Create blank VM", rich_help_panel="Blank Mode"),
|
|
330
|
+
# blank args
|
|
331
|
+
cpus: int = typer.Option(2, "--cpus", help="CPUs (blank VM)", rich_help_panel="Blank Mode"),
|
|
332
|
+
memory: int = typer.Option(1024, "--memory", help="Memory MB (blank VM)", rich_help_panel="Blank Mode"),
|
|
333
|
+
disk: int = typer.Option(10240, "--disk", help="Disk MB (blank VM)", rich_help_panel="Blank Mode"),
|
|
334
|
+
# general args
|
|
335
|
+
dataset: str = typer.Option("base", "--dataset", "-d", help="Dataset we are using"),
|
|
336
|
+
connect_network: bool = typer.Option(True, "--network/--no-network", help="Connect WireGuard to the sandbox"),
|
|
337
|
+
timeout: int = typer.Option(1800, "--timeout", "-t", help="Timeout in seconds for VM to become ready"),
|
|
338
|
+
json_output: JsonArg = False,
|
|
339
|
+
verbose: VerboseArg = False,
|
|
340
|
+
):
|
|
341
|
+
"""Start a new sandbox VM.
|
|
342
|
+
|
|
343
|
+
Creates a sandbox from a simulator, artifact, config file, or blank VM.
|
|
344
|
+
Saves session info to .plato/state.json for use by other commands.
|
|
345
|
+
|
|
346
|
+
Examples:
|
|
347
|
+
plato sandbox start -s espocrm # From simulator
|
|
348
|
+
plato sandbox start -c # From plato-config.yml
|
|
349
|
+
plato sandbox start -a <uuid> # From artifact
|
|
350
|
+
plato sandbox start -b --cpus 4 # Blank VM
|
|
351
|
+
"""
|
|
352
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
353
|
+
out.console.print("Starting sandbox...")
|
|
354
|
+
|
|
355
|
+
if simulator:
|
|
356
|
+
state = client.start(
|
|
357
|
+
mode="simulator",
|
|
358
|
+
simulator_name=simulator,
|
|
359
|
+
dataset=dataset,
|
|
360
|
+
connect_network=connect_network,
|
|
361
|
+
timeout=timeout,
|
|
362
|
+
)
|
|
363
|
+
elif blank:
|
|
364
|
+
state = client.start(
|
|
365
|
+
mode="blank",
|
|
366
|
+
simulator_name=simulator,
|
|
367
|
+
dataset=dataset,
|
|
368
|
+
cpus=cpus,
|
|
369
|
+
memory=memory,
|
|
370
|
+
disk=disk,
|
|
371
|
+
connect_network=connect_network,
|
|
372
|
+
timeout=timeout,
|
|
373
|
+
)
|
|
374
|
+
elif artifact_id:
|
|
375
|
+
state = client.start(
|
|
376
|
+
mode="artifact",
|
|
377
|
+
artifact_id=artifact_id,
|
|
378
|
+
dataset=dataset,
|
|
379
|
+
connect_network=connect_network,
|
|
380
|
+
timeout=timeout,
|
|
381
|
+
)
|
|
382
|
+
elif from_config:
|
|
383
|
+
state = client.start(
|
|
384
|
+
mode="config",
|
|
385
|
+
dataset=dataset,
|
|
386
|
+
connect_network=connect_network,
|
|
387
|
+
timeout=timeout,
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
out.error("Must specify a mode: --blank, --artifact-id, --simulator, or --from-config.")
|
|
391
|
+
raise typer.Exit(1)
|
|
392
|
+
|
|
393
|
+
out.success(state, "Sandbox started")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# CHECKED
|
|
397
|
+
@sandbox_app.command(name="snapshot")
|
|
398
|
+
def sandbox_snapshot(
|
|
399
|
+
working_dir: WorkingDirArg,
|
|
400
|
+
session_id: SessionIdArg,
|
|
401
|
+
mode: ModeArg,
|
|
402
|
+
dataset: DatasetArg,
|
|
403
|
+
json_output: JsonArg = False,
|
|
404
|
+
verbose: VerboseArg = False,
|
|
405
|
+
):
|
|
406
|
+
"""Create a snapshot of the current sandbox state.
|
|
407
|
+
|
|
408
|
+
Captures VM state and database for later restoration.
|
|
409
|
+
|
|
410
|
+
Example:
|
|
411
|
+
plato sandbox snapshot
|
|
412
|
+
"""
|
|
413
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
414
|
+
out.console.print("Creating snapshot...")
|
|
415
|
+
|
|
416
|
+
response = client.snapshot(
|
|
417
|
+
session_id=str(session_id),
|
|
418
|
+
mode=str(mode),
|
|
419
|
+
dataset=str(dataset),
|
|
420
|
+
)
|
|
421
|
+
out.success(response, "Snapshot created")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# CHECKED
|
|
425
|
+
@sandbox_app.command(name="stop")
|
|
426
|
+
def sandbox_stop(
|
|
427
|
+
working_dir: WorkingDirArg,
|
|
428
|
+
session_id: SessionIdArg,
|
|
429
|
+
json_output: JsonArg = False,
|
|
430
|
+
verbose: VerboseArg = False,
|
|
431
|
+
):
|
|
432
|
+
"""Stop and destroy the current sandbox.
|
|
433
|
+
|
|
434
|
+
Terminates the VM and cleans up resources. State file remains for reference.
|
|
435
|
+
|
|
436
|
+
Example:
|
|
437
|
+
plato sandbox stop
|
|
438
|
+
"""
|
|
439
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
440
|
+
out.console.print("Stopping sandbox...")
|
|
441
|
+
client.stop(session_id=str(session_id))
|
|
442
|
+
out.success({"status": "stopped"}, "Sandbox stopped")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# CHECKED
|
|
446
|
+
@sandbox_app.command(name="connect-network")
|
|
447
|
+
def sandbox_connect_network(
|
|
448
|
+
working_dir: WorkingDirArg,
|
|
449
|
+
session_id: SessionIdArg,
|
|
450
|
+
json_output: JsonArg = False,
|
|
451
|
+
verbose: VerboseArg = False,
|
|
452
|
+
):
|
|
453
|
+
"""Connect to the sandbox via WireGuard VPN.
|
|
454
|
+
|
|
455
|
+
Sets up network access to the sandbox VM. Usually done automatically by start.
|
|
456
|
+
|
|
457
|
+
Example:
|
|
458
|
+
plato sandbox connect-network
|
|
459
|
+
"""
|
|
460
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
461
|
+
out.console.print("Connecting to network...")
|
|
462
|
+
result = client.connect_network(session_id=str(session_id))
|
|
463
|
+
out.success(result, "Network connected")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# CHECKED
|
|
467
|
+
@sandbox_app.command(name="status")
|
|
468
|
+
def sandbox_status(
|
|
469
|
+
working_dir: WorkingDirArg,
|
|
470
|
+
session_id: SessionIdArg,
|
|
471
|
+
json_output: JsonArg = False,
|
|
472
|
+
verbose: VerboseArg = False,
|
|
473
|
+
):
|
|
474
|
+
"""Show current sandbox status.
|
|
475
|
+
|
|
476
|
+
Displays local state file and remote session details.
|
|
477
|
+
|
|
478
|
+
Example:
|
|
479
|
+
plato sandbox status
|
|
480
|
+
plato sandbox status --json
|
|
481
|
+
"""
|
|
482
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
483
|
+
out.console.print("Fetching status...")
|
|
484
|
+
local_state = None
|
|
485
|
+
if os.path.exists(working_dir / ".plato" / "state.json"):
|
|
486
|
+
local_state = json.load(open(working_dir / ".plato" / "state.json"))
|
|
487
|
+
|
|
488
|
+
details = client.status(session_id=str(session_id))
|
|
489
|
+
all_details = {"local": local_state, "remote": details}
|
|
490
|
+
out.success(all_details, "Sandbox Status")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# CHECKED
|
|
494
|
+
@sandbox_app.command(name="state")
|
|
495
|
+
def sandbox_state(
|
|
496
|
+
working_dir: WorkingDirArg,
|
|
497
|
+
session_id: SessionIdArg,
|
|
498
|
+
json_output: JsonArg = False,
|
|
499
|
+
verbose: VerboseArg = False,
|
|
500
|
+
):
|
|
501
|
+
"""Get database mutations from the sandbox.
|
|
502
|
+
|
|
503
|
+
Returns changes tracked by the Plato worker (inserts, updates, deletes).
|
|
504
|
+
|
|
505
|
+
Example:
|
|
506
|
+
plato sandbox state
|
|
507
|
+
plato sandbox state --json
|
|
508
|
+
"""
|
|
509
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
510
|
+
out.console.print("Fetching mutations...")
|
|
511
|
+
result = client.state(session_id=str(session_id))
|
|
512
|
+
|
|
513
|
+
out.success(result, f"State: {result.session_id}")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# CHECKED
|
|
517
|
+
@sandbox_app.command(name="start-worker")
|
|
518
|
+
def sandbox_start_worker(
|
|
519
|
+
working_dir: WorkingDirArg,
|
|
520
|
+
job_id: JobIdArg,
|
|
521
|
+
simulator: SimulatorNameArg,
|
|
522
|
+
dataset: DatasetArg,
|
|
523
|
+
wait_timeout: int = typer.Option(240, "--wait-timeout", help="Wait timeout in seconds"),
|
|
524
|
+
json_output: JsonArg = False,
|
|
525
|
+
verbose: VerboseArg = False,
|
|
526
|
+
):
|
|
527
|
+
"""Start the Plato worker in the sandbox.
|
|
528
|
+
|
|
529
|
+
The worker tracks database mutations and enables state capture.
|
|
530
|
+
Waits for worker to be ready (up to --wait-timeout seconds).
|
|
531
|
+
|
|
532
|
+
Example:
|
|
533
|
+
plato sandbox start-worker
|
|
534
|
+
plato sandbox start-worker --wait-timeout 300
|
|
535
|
+
"""
|
|
536
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
537
|
+
out.console.print(f"Starting worker: {simulator}, dataset: {dataset}")
|
|
538
|
+
|
|
539
|
+
client.start_worker(
|
|
540
|
+
job_id=str(job_id),
|
|
541
|
+
simulator=str(simulator),
|
|
542
|
+
dataset=str(dataset),
|
|
543
|
+
wait_timeout=wait_timeout,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
out.success({"status": "started"}, "Worker started")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# CHECKED
|
|
550
|
+
@sandbox_app.command(name="sync")
|
|
551
|
+
def sandbox_sync(
|
|
552
|
+
working_dir: WorkingDirArg,
|
|
553
|
+
session_id: SessionIdArg,
|
|
554
|
+
simulator: SimulatorNameArg,
|
|
555
|
+
timeout: int = typer.Option(120, "--timeout", "-t", help="Timeout in seconds"),
|
|
556
|
+
json_output: JsonArg = False,
|
|
557
|
+
verbose: VerboseArg = False,
|
|
558
|
+
):
|
|
559
|
+
"""Sync local files to the sandbox VM via rsync.
|
|
560
|
+
|
|
561
|
+
Copies working directory to /home/plato/worktree/<simulator> on the VM.
|
|
562
|
+
|
|
563
|
+
Example:
|
|
564
|
+
plato sandbox sync
|
|
565
|
+
plato sandbox sync --timeout 300
|
|
566
|
+
"""
|
|
567
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
568
|
+
out.console.print(f"Syncing {working_dir} -> {f'/home/plato/worktree/{simulator}'}")
|
|
569
|
+
|
|
570
|
+
result = client.sync(
|
|
571
|
+
session_id=str(session_id),
|
|
572
|
+
simulator=str(simulator),
|
|
573
|
+
timeout=timeout,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
out.success(result, "Sync complete")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# CHECKED
|
|
580
|
+
@sandbox_app.command(name="start-services")
|
|
581
|
+
def sandbox_start_services(
|
|
582
|
+
working_dir: WorkingDirArg,
|
|
583
|
+
simulator: SimulatorNameArg,
|
|
584
|
+
ssh_config: SshConfigArg,
|
|
585
|
+
ssh_host: SshHostArg,
|
|
586
|
+
dataset: DatasetArg,
|
|
587
|
+
json_output: JsonArg = False,
|
|
588
|
+
verbose: VerboseArg = False,
|
|
589
|
+
):
|
|
590
|
+
"""Start docker compose services on the sandbox.
|
|
591
|
+
|
|
592
|
+
Deploys containers defined in docker-compose.yml to the VM.
|
|
593
|
+
|
|
594
|
+
Example:
|
|
595
|
+
plato sandbox start-services
|
|
596
|
+
"""
|
|
597
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
598
|
+
# Validate required fields
|
|
599
|
+
if not ssh_config:
|
|
600
|
+
out.error("SSH config path not found. Run 'plato sandbox start' first or provide --ssh-config.")
|
|
601
|
+
raise typer.Exit(1)
|
|
602
|
+
if not ssh_host:
|
|
603
|
+
out.error("SSH host not found. Run 'plato sandbox start' first or provide --ssh-host.")
|
|
604
|
+
raise typer.Exit(1)
|
|
605
|
+
if not simulator:
|
|
606
|
+
out.error("Simulator name not found. Run 'plato sandbox start' first or provide --simulator-name.")
|
|
607
|
+
raise typer.Exit(1)
|
|
608
|
+
if not dataset:
|
|
609
|
+
out.error("Dataset not found. Run 'plato sandbox start' first or provide --dataset.")
|
|
610
|
+
raise typer.Exit(1)
|
|
611
|
+
|
|
612
|
+
out.console.print("Starting services...")
|
|
613
|
+
result = client.start_services(
|
|
614
|
+
ssh_config_path=str(ssh_config),
|
|
615
|
+
ssh_host=str(ssh_host),
|
|
616
|
+
simulator_name=str(simulator),
|
|
617
|
+
dataset=str(dataset),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
out.success(result, "Services started")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@sandbox_app.command(name="flow")
|
|
624
|
+
def sandbox_flow(
|
|
625
|
+
working_dir: WorkingDirArg,
|
|
626
|
+
public_url: PublicUrlArg,
|
|
627
|
+
dataset: DatasetArg,
|
|
628
|
+
job_id: JobIdArg,
|
|
629
|
+
flow_name: str = typer.Option("login", "--flow-name", "-f", help="Flow to execute"),
|
|
630
|
+
api: bool = typer.Option(False, "--api", "-a", help="Fetch flows from API (requires job_id)"),
|
|
631
|
+
json_output: JsonArg = False,
|
|
632
|
+
verbose: VerboseArg = False,
|
|
633
|
+
):
|
|
634
|
+
"""Run a Playwright flow against the sandbox.
|
|
635
|
+
|
|
636
|
+
Executes UI automation flows defined in flows.yml or fetched from API.
|
|
637
|
+
|
|
638
|
+
Examples:
|
|
639
|
+
plato sandbox flow # Run 'login' flow from local config
|
|
640
|
+
plato sandbox flow -f signup # Run 'signup' flow
|
|
641
|
+
plato sandbox flow --api # Fetch flow from API
|
|
642
|
+
"""
|
|
643
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
644
|
+
out.console.print(f"Running flow '{flow_name}' on {str(public_url)}")
|
|
645
|
+
|
|
646
|
+
client.run_flow(
|
|
647
|
+
url=str(public_url),
|
|
648
|
+
flow_name=flow_name,
|
|
649
|
+
dataset=str(dataset),
|
|
650
|
+
use_api=api,
|
|
651
|
+
job_id=str(job_id) if api else None,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
out.success({"flow_name": flow_name, "url": str(public_url)}, "Flow complete")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# @sandbox_app.command(name="clear-audit")
|
|
658
|
+
# def sandbox_clear_audit(
|
|
659
|
+
# working_dir: WorkingDirArg,
|
|
660
|
+
# job_id: JobIdArg,
|
|
661
|
+
# config_path: Path | None = typer.Option(None, "--config-path", help="Path to plato-config.yml"),
|
|
662
|
+
# dataset: str = typer.Option("base", "--dataset", "-d", help="Dataset name"),
|
|
663
|
+
# json_output: JsonArg = False,
|
|
664
|
+
# verbose: VerboseArg = False,
|
|
665
|
+
# ):
|
|
666
|
+
# """Clear the audit_log table(s) in the sandbox database."""
|
|
667
|
+
# with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
668
|
+
# cfg = config_path or Path.cwd() / "plato-config.yml"
|
|
669
|
+
# if not cfg.exists():
|
|
670
|
+
# cfg = Path.cwd() / "plato-config.yaml"
|
|
671
|
+
# if not cfg.exists():
|
|
672
|
+
# raise ValueError("plato-config.yml not found")
|
|
673
|
+
|
|
674
|
+
# with open(cfg) as f:
|
|
675
|
+
# plato_config = yaml.safe_load(f)
|
|
676
|
+
|
|
677
|
+
# datasets = plato_config.get("datasets", {})
|
|
678
|
+
# if dataset not in datasets:
|
|
679
|
+
# raise ValueError(f"Dataset '{dataset}' not found")
|
|
680
|
+
|
|
681
|
+
# listeners = datasets[dataset].get("listeners", {})
|
|
682
|
+
# db_listeners = [
|
|
683
|
+
# (name, lcfg) for name, lcfg in listeners.items() if isinstance(lcfg, dict) and lcfg.get("type") == "db"
|
|
684
|
+
# ]
|
|
685
|
+
|
|
686
|
+
# if not db_listeners:
|
|
687
|
+
# raise ValueError("No database listeners found in config")
|
|
688
|
+
|
|
689
|
+
# out.console.print(f"Clearing {len(db_listeners)} audit log(s)")
|
|
690
|
+
# result = client.clear_audit(
|
|
691
|
+
# job_id=job_id,
|
|
692
|
+
# session_id=client.state.session_id if client.state else None,
|
|
693
|
+
# db_listeners=db_listeners,
|
|
694
|
+
# )
|
|
695
|
+
|
|
696
|
+
# if not result.success:
|
|
697
|
+
# raise Exception(result.error)
|
|
698
|
+
|
|
699
|
+
# out.success(result, "Audit cleared")
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@sandbox_app.command(name="audit-ui")
|
|
703
|
+
def sandbox_audit_ui(
|
|
704
|
+
working_dir: WorkingDirArg,
|
|
705
|
+
job_id: JobIdArg,
|
|
706
|
+
dataset: DatasetArg,
|
|
707
|
+
no_tunnel: bool = typer.Option(False, "--no-tunnel", help="Don't auto-start tunnel"),
|
|
708
|
+
json_output: JsonArg = False,
|
|
709
|
+
verbose: VerboseArg = False,
|
|
710
|
+
):
|
|
711
|
+
"""Launch Streamlit UI for configuring audit ignore rules.
|
|
712
|
+
|
|
713
|
+
Opens a web UI to select tables/columns to ignore during mutation tracking.
|
|
714
|
+
Auto-starts a tunnel to the database if configured in plato-config.yml.
|
|
715
|
+
|
|
716
|
+
Example:
|
|
717
|
+
plato sandbox audit-ui
|
|
718
|
+
plato sandbox audit-ui --no-tunnel
|
|
719
|
+
"""
|
|
720
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
721
|
+
try:
|
|
722
|
+
client.run_audit_ui(
|
|
723
|
+
job_id=job_id,
|
|
724
|
+
dataset=dataset or "base",
|
|
725
|
+
no_tunnel=no_tunnel,
|
|
726
|
+
)
|
|
727
|
+
except ValueError as e:
|
|
728
|
+
out.error(str(e))
|
|
729
|
+
raise typer.Exit(1) from None
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# =============================================================================
|
|
733
|
+
# SSH & TUNNEL COMMANDS
|
|
734
|
+
# =============================================================================
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
@sandbox_app.command(name="ssh", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
|
738
|
+
def sandbox_ssh(
|
|
739
|
+
working_dir: WorkingDirArg,
|
|
740
|
+
ctx: typer.Context,
|
|
741
|
+
ssh_config: SshConfigArg,
|
|
742
|
+
ssh_host: SshHostArg,
|
|
743
|
+
json_output: JsonArg = False,
|
|
744
|
+
verbose: VerboseArg = False,
|
|
745
|
+
):
|
|
746
|
+
"""SSH to the sandbox VM.
|
|
747
|
+
|
|
748
|
+
Uses .plato/ssh_config from 'start'. Extra args after -- are passed to ssh.
|
|
749
|
+
|
|
750
|
+
Examples:
|
|
751
|
+
plato sandbox ssh
|
|
752
|
+
plato sandbox ssh -- -L 8080:localhost:8080
|
|
753
|
+
"""
|
|
754
|
+
import subprocess
|
|
755
|
+
|
|
756
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
757
|
+
if not ssh_config:
|
|
758
|
+
out.error("No SSH config found. Run 'plato sandbox start' first.")
|
|
759
|
+
raise typer.Exit(1)
|
|
760
|
+
|
|
761
|
+
config_path = client.working_dir / ssh_config if not Path(ssh_config).is_absolute() else Path(ssh_config)
|
|
762
|
+
cmd = ["ssh", "-F", str(config_path), ssh_host or "sandbox"] + (ctx.args or [])
|
|
763
|
+
|
|
764
|
+
try:
|
|
765
|
+
raise typer.Exit(subprocess.run(cmd).returncode)
|
|
766
|
+
except KeyboardInterrupt:
|
|
767
|
+
raise typer.Exit(130) from None
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@sandbox_app.command(name="tunnel")
|
|
771
|
+
def sandbox_tunnel(
|
|
772
|
+
working_dir: WorkingDirArg,
|
|
773
|
+
job_id: JobIdArg,
|
|
774
|
+
remote_port: int = typer.Argument(..., help="Remote port on the VM to forward"),
|
|
775
|
+
local_port: int | None = typer.Argument(None, help="Local port to listen on"),
|
|
776
|
+
bind_address: str = typer.Option("127.0.0.1", "--bind", "-b"),
|
|
777
|
+
json_output: JsonArg = False,
|
|
778
|
+
verbose: VerboseArg = False,
|
|
779
|
+
):
|
|
780
|
+
"""Forward a local port to the sandbox VM.
|
|
781
|
+
|
|
782
|
+
Creates a TCP tunnel through the TLS gateway. Useful for database access.
|
|
783
|
+
|
|
784
|
+
Examples:
|
|
785
|
+
plato sandbox tunnel 5432 # Forward PostgreSQL
|
|
786
|
+
plato sandbox tunnel 3306 # Forward MySQL
|
|
787
|
+
plato sandbox tunnel 5432 15432 # VM:5432 -> localhost:15432
|
|
788
|
+
"""
|
|
789
|
+
import time
|
|
790
|
+
|
|
791
|
+
with sandbox_context(working_dir, json_output, verbose) as (client, out):
|
|
792
|
+
if not job_id:
|
|
793
|
+
out.error("No job_id found. Run 'plato sandbox start' first.")
|
|
794
|
+
raise typer.Exit(1)
|
|
795
|
+
|
|
796
|
+
local = local_port or remote_port
|
|
797
|
+
tunnel = client.tunnel(job_id, remote_port, local, bind_address)
|
|
798
|
+
|
|
799
|
+
try:
|
|
800
|
+
tunnel.start()
|
|
801
|
+
out.console.print(f"[green]Tunnel:[/green] {bind_address}:{local} -> VM:{remote_port}")
|
|
802
|
+
out.console.print("[dim]Ctrl+C to stop[/dim]")
|
|
803
|
+
while True:
|
|
804
|
+
time.sleep(1)
|
|
805
|
+
except KeyboardInterrupt:
|
|
806
|
+
out.console.print("\n[yellow]Closed[/yellow]")
|
|
807
|
+
finally:
|
|
808
|
+
tunnel.stop()
|