plato-sdk-v2 2.7.6__py3-none-any.whl → 2.7.8__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/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()