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/verify.py ADDED
@@ -0,0 +1,690 @@
1
+ # """Verification CLI commands for Plato simulator creation pipeline.
2
+
3
+ # All verification commands follow the convention:
4
+ # - Exit 0 = verification passed
5
+ # - Exit 1 = verification failed
6
+ # - Stderr = actionable error message for agents
7
+
8
+ # Usage:
9
+ # plato sandbox verify <check>
10
+ # plato pm verify <check>
11
+ # """
12
+
13
+ # from __future__ import annotations
14
+
15
+ # import os
16
+ # import subprocess
17
+ # import sys
18
+ # from collections import defaultdict
19
+ # from pathlib import Path
20
+ # from typing import NoReturn
21
+
22
+ # import typer
23
+ # import yaml
24
+
25
+ # from plato.cli.utils import (
26
+ # STATE_FILE,
27
+ # get_http_client,
28
+ # get_sandbox_state,
29
+ # require_api_key,
30
+ # )
31
+
32
+
33
+ # def _error(msg: str) -> None:
34
+ # """Write error to stderr."""
35
+ # sys.stderr.write(f"{msg}\n")
36
+
37
+
38
+ # def _fail(msg: str) -> NoReturn:
39
+ # """Write error to stderr and exit 1."""
40
+ # _error(msg)
41
+ # raise typer.Exit(1)
42
+
43
+
44
+ # # =============================================================================
45
+ # # SANDBOX VERIFY COMMANDS
46
+ # # =============================================================================
47
+
48
+ # sandbox_verify_app = typer.Typer(help="Verify sandbox setup and state")
49
+
50
+
51
+ # @sandbox_verify_app.callback(invoke_without_command=True)
52
+ # def sandbox_verify_default(
53
+ # ctx: typer.Context,
54
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
55
+ # ):
56
+ # """Verify sandbox is properly configured.
57
+
58
+ # Checks that .plato/state.yaml exists and contains all required fields.
59
+
60
+ # Options:
61
+ # -w, --working-dir: Working directory containing .plato/
62
+ # """
63
+ # if ctx.invoked_subcommand is not None:
64
+ # return
65
+
66
+ # state = get_sandbox_state(working_dir)
67
+ # if not state:
68
+ # _fail(f"File not found: {STATE_FILE}")
69
+
70
+ # # Core required fields
71
+ # required_fields = ["job_id", "session_id", "public_url", "plato_config_path", "service"]
72
+ # missing = [f for f in required_fields if f not in state or not state[f]]
73
+
74
+ # # Check plato_config_path exists
75
+ # plato_config = state.get("plato_config_path")
76
+ # if plato_config:
77
+ # # Convert container path to relative path for checking
78
+ # if plato_config.startswith("/workspace/"):
79
+ # check_path = Path(plato_config[len("/workspace/") :])
80
+ # else:
81
+ # check_path = Path(plato_config)
82
+
83
+ # if not check_path.exists():
84
+ # missing.append(f"plato_config_path (file): File not found: {plato_config}")
85
+
86
+ # if missing:
87
+ # _fail(f"Missing fields in {STATE_FILE}: {missing}")
88
+
89
+ # # Success - exit 0
90
+
91
+
92
+ # @sandbox_verify_app.command(name="services")
93
+ # def verify_services(
94
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
95
+ # ):
96
+ # """Verify containers are running and public URL returns 200.
97
+
98
+ # Options:
99
+ # -w, --working-dir: Working directory containing .plato/
100
+ # """
101
+ # state = get_sandbox_state(working_dir)
102
+ # if not state:
103
+ # _fail(f"File not found: {STATE_FILE}")
104
+
105
+ # ssh_config = state.get("ssh_config_path")
106
+ # ssh_host = state.get("ssh_host", "sandbox")
107
+ # public_url = state.get("public_url")
108
+
109
+ # if not ssh_config:
110
+ # _fail("No ssh_config_path in state")
111
+
112
+ # # Check containers via SSH
113
+ # try:
114
+ # result = subprocess.run(
115
+ # [
116
+ # "ssh",
117
+ # "-F",
118
+ # os.path.expanduser(ssh_config),
119
+ # ssh_host,
120
+ # "docker ps -a --format '{{.Names}}\t{{.Status}}'",
121
+ # ],
122
+ # capture_output=True,
123
+ # text=True,
124
+ # timeout=30,
125
+ # )
126
+
127
+ # if result.returncode != 0:
128
+ # _fail(f"Failed to check containers via SSH: {result.stderr.strip()}")
129
+
130
+ # unhealthy = []
131
+ # for line in result.stdout.strip().split("\n"):
132
+ # if not line:
133
+ # continue
134
+ # parts = line.split("\t")
135
+ # if len(parts) >= 2:
136
+ # name, status = parts[0], parts[1]
137
+ # if "unhealthy" in status.lower() or "exited" in status.lower() or "dead" in status.lower():
138
+ # unhealthy.append(f"{name}: {status}")
139
+
140
+ # if unhealthy:
141
+ # _fail(f"Unhealthy containers: {unhealthy}")
142
+
143
+ # except subprocess.TimeoutExpired:
144
+ # _fail("SSH connection timed out")
145
+ # except FileNotFoundError:
146
+ # _fail("SSH not found")
147
+
148
+ # # Check public URL
149
+ # if public_url:
150
+ # try:
151
+ # import urllib.error
152
+ # import urllib.request
153
+
154
+ # req = urllib.request.Request(public_url, method="HEAD")
155
+ # req.add_header("User-Agent", "plato-verify/1.0")
156
+
157
+ # try:
158
+ # with urllib.request.urlopen(req, timeout=10) as response:
159
+ # if response.getcode() != 200:
160
+ # _fail(f"HTTP {response.getcode()} from {public_url}")
161
+ # except urllib.error.HTTPError as e:
162
+ # if e.code == 502:
163
+ # _fail("HTTP 502 Bad Gateway - check app_port in plato-config.yml and nginx config")
164
+ # else:
165
+ # _fail(f"HTTP {e.code} from {public_url}")
166
+
167
+ # except Exception as e:
168
+ # _fail(f"Failed to check public URL: {e}")
169
+
170
+ # # Success - exit 0
171
+
172
+
173
+ # @sandbox_verify_app.command(name="login")
174
+ # def verify_login(
175
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
176
+ # ):
177
+ # """Verify login page is accessible.
178
+
179
+ # Options:
180
+ # -w, --working-dir: Working directory containing .plato/
181
+ # """
182
+ # state = get_sandbox_state(working_dir)
183
+ # if not state:
184
+ # _fail(f"File not found: {STATE_FILE}")
185
+
186
+ # public_url = state.get("public_url")
187
+ # if not public_url:
188
+ # _fail("No public_url in .sandbox.yaml")
189
+
190
+ # try:
191
+ # import urllib.error
192
+ # import urllib.request
193
+
194
+ # req = urllib.request.Request(public_url, method="GET")
195
+ # req.add_header("User-Agent", "plato-verify/1.0")
196
+
197
+ # with urllib.request.urlopen(req, timeout=10) as response:
198
+ # if response.getcode() != 200:
199
+ # _fail(f"HTTP {response.getcode()} from {public_url}")
200
+ # except urllib.error.HTTPError as e:
201
+ # _fail(f"HTTP {e.code} from {public_url}")
202
+ # except Exception as e:
203
+ # _fail(f"Failed to check login page: {e}")
204
+
205
+ # # Success - exit 0
206
+
207
+
208
+ # @sandbox_verify_app.command(name="worker")
209
+ # def verify_worker(
210
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
211
+ # ):
212
+ # """Verify Plato worker is connected and audit triggers installed.
213
+
214
+ # Options:
215
+ # -w, --working-dir: Working directory containing .plato/
216
+ # """
217
+ # state = get_sandbox_state(working_dir)
218
+ # if not state:
219
+ # _fail(f"File not found: {STATE_FILE}")
220
+
221
+ # session_id = state.get("session_id")
222
+ # if not session_id:
223
+ # _fail("No session_id in state")
224
+
225
+ # api_key = require_api_key()
226
+
227
+ # try:
228
+ # from plato._generated.api.v2.sessions import state as sessions_state
229
+
230
+ # with get_http_client() as client:
231
+ # state_response = sessions_state.sync(
232
+ # session_id=session_id,
233
+ # client=client,
234
+ # x_api_key=api_key,
235
+ # )
236
+
237
+ # if state_response is None:
238
+ # _fail("State API returned no data")
239
+
240
+ # if not state_response.results:
241
+ # _fail("State API returned empty results")
242
+
243
+ # for job_id, result in state_response.results.items():
244
+ # if hasattr(result, "error") and result.error:
245
+ # _fail(f"Worker error: {result.error}")
246
+
247
+ # state_data = result.state if hasattr(result, "state") and result.state else {}
248
+ # if isinstance(state_data, dict):
249
+ # if "error" in state_data:
250
+ # _fail(f"Worker error: {state_data['error']}")
251
+
252
+ # if "db" in state_data:
253
+ # db_state = state_data["db"]
254
+ # if not db_state.get("is_connected", False):
255
+ # _fail("Worker not connected to database")
256
+ # # Success - worker connected
257
+ # return
258
+ # else:
259
+ # _fail("Worker not initialized (no db state)")
260
+
261
+ # _fail("No worker state found")
262
+
263
+ # except typer.Exit:
264
+ # raise
265
+ # except Exception as e:
266
+ # if "502" in str(e):
267
+ # _fail("Worker not ready (502)")
268
+ # _fail(f"Failed to check worker: {e}")
269
+
270
+
271
+ # @sandbox_verify_app.command(name="audit-clear")
272
+ # def verify_audit_clear(
273
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
274
+ # ):
275
+ # """Verify audit log is cleared (0 mutations).
276
+
277
+ # Options:
278
+ # -w, --working-dir: Working directory containing .plato/
279
+ # """
280
+ # state = get_sandbox_state(working_dir)
281
+ # if not state:
282
+ # _fail(f"File not found: {STATE_FILE}")
283
+
284
+ # session_id = state.get("session_id")
285
+ # api_key = require_api_key()
286
+
287
+ # try:
288
+ # from plato._generated.api.v2.sessions import state as sessions_state
289
+
290
+ # with get_http_client() as client:
291
+ # state_response = sessions_state.sync(
292
+ # session_id=session_id,
293
+ # client=client,
294
+ # x_api_key=api_key,
295
+ # )
296
+
297
+ # if state_response is None:
298
+ # _fail("State API returned no data")
299
+
300
+ # audit_count = 0
301
+ # if state_response.results:
302
+ # for job_id, result in state_response.results.items():
303
+ # state_data = result.state if hasattr(result, "state") and result.state else {}
304
+ # if isinstance(state_data, dict) and "db" in state_data:
305
+ # audit_count = state_data["db"].get("audit_log_count", 0)
306
+ # break
307
+
308
+ # if audit_count != 0:
309
+ # _fail(f"Audit log not clear: {audit_count} mutations")
310
+
311
+ # # Success - exit 0
312
+
313
+ # except typer.Exit:
314
+ # raise
315
+ # except Exception as e:
316
+ # _fail(f"Failed to check audit: {e}")
317
+
318
+
319
+ # @sandbox_verify_app.command(name="flow")
320
+ # def verify_flow():
321
+ # """Verify login flow exists and is valid.
322
+
323
+ # Checks that flows.yml (or base/flows.yml) exists and contains a valid 'login'
324
+ # flow definition with required fields (name, steps, etc.).
325
+
326
+ # Exit code 0 = valid flow found, exit code 1 = missing or invalid.
327
+ # """
328
+ # flow_paths = ["flows.yml", "base/flows.yml", "login-flow.yml"]
329
+ # flow_file = None
330
+
331
+ # for path in flow_paths:
332
+ # if Path(path).exists():
333
+ # flow_file = path
334
+ # break
335
+
336
+ # if not flow_file:
337
+ # _fail(f"No flows.yml found. Searched: {flow_paths}")
338
+
339
+ # assert flow_file is not None # for type checker
340
+
341
+ # try:
342
+ # with open(flow_file) as f:
343
+ # flows = yaml.safe_load(f)
344
+
345
+ # if not flows:
346
+ # _fail(f"Flows file is empty: {flow_file}")
347
+
348
+ # if "login" not in flows:
349
+ # _fail(f"No 'login' flow defined in {flow_file}")
350
+
351
+ # # Success - exit 0
352
+
353
+ # except yaml.YAMLError as e:
354
+ # _fail(f"Invalid YAML in {flow_file}: {e}")
355
+
356
+
357
+ # @sandbox_verify_app.command(name="mutations")
358
+ # def verify_mutations(
359
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
360
+ # ):
361
+ # """Verify no mutations after login flow.
362
+
363
+ # Options:
364
+ # -w, --working-dir: Working directory containing .plato/
365
+ # """
366
+ # state = get_sandbox_state(working_dir)
367
+ # if not state:
368
+ # _fail(f"File not found: {STATE_FILE}")
369
+
370
+ # session_id = state.get("session_id")
371
+ # api_key = require_api_key()
372
+
373
+ # try:
374
+ # from plato._generated.api.v2.sessions import state as sessions_state
375
+
376
+ # with get_http_client() as client:
377
+ # state_response = sessions_state.sync(
378
+ # session_id=session_id,
379
+ # client=client,
380
+ # x_api_key=api_key,
381
+ # )
382
+
383
+ # if state_response is None:
384
+ # _fail("State API returned no data")
385
+
386
+ # mutations = []
387
+ # audit_count = 0
388
+ # if state_response.results:
389
+ # for job_id, result in state_response.results.items():
390
+ # state_data = result.state if hasattr(result, "state") and result.state else {}
391
+ # if isinstance(state_data, dict) and "db" in state_data:
392
+ # audit_count = state_data["db"].get("audit_log_count", 0)
393
+ # mutations = state_data["db"].get("mutations", [])
394
+ # break
395
+
396
+ # if audit_count == 0:
397
+ # # Success - exit 0
398
+ # return
399
+
400
+ # # Build table breakdown
401
+ # table_ops: dict[str, dict[str, int]] = defaultdict(lambda: {"INSERT": 0, "UPDATE": 0, "DELETE": 0})
402
+ # for mutation in mutations:
403
+ # table = mutation.get("table", "unknown")
404
+ # op = mutation.get("operation", "UNKNOWN").upper()
405
+ # if op in table_ops[table]:
406
+ # table_ops[table][op] += 1
407
+
408
+ # # Format error message
409
+ # table_summary = {t: dict(ops) for t, ops in table_ops.items()}
410
+ # _fail(f"Found {audit_count} mutations: {table_summary}")
411
+
412
+ # except typer.Exit:
413
+ # raise
414
+ # except Exception as e:
415
+ # _fail(f"Failed to check mutations: {e}")
416
+
417
+
418
+ # @sandbox_verify_app.command(name="audit-active")
419
+ # def verify_audit_active():
420
+ # """Verify audit system is tracking changes.
421
+
422
+ # This is a manual verification step that requires making a change in the app
423
+ # and confirming mutations appear. Always exits 0 - actual verification is manual.
424
+ # """
425
+ # # This step requires manual verification - just pass
426
+ # pass
427
+
428
+
429
+ # @sandbox_verify_app.command(name="snapshot")
430
+ # def verify_snapshot(
431
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
432
+ # ):
433
+ # """Verify snapshot was created.
434
+
435
+ # Options:
436
+ # -w, --working-dir: Working directory containing .plato/
437
+ # """
438
+ # state = get_sandbox_state(working_dir)
439
+ # if not state:
440
+ # _fail(f"File not found: {STATE_FILE}")
441
+
442
+ # artifact_id = state.get("artifact_id")
443
+
444
+ # if not artifact_id:
445
+ # _fail("No artifact_id - run 'plato sandbox snapshot' first")
446
+
447
+ # # Validate UUID format
448
+ # import re
449
+
450
+ # 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)
451
+
452
+ # if not uuid_pattern.match(artifact_id):
453
+ # _fail(f"Invalid artifact_id format: {artifact_id}")
454
+
455
+ # # Success - exit 0
456
+
457
+
458
+ # # =============================================================================
459
+ # # PM VERIFY COMMANDS
460
+ # # =============================================================================
461
+
462
+ # pm_verify_app = typer.Typer(help="Verify review and submit steps")
463
+
464
+
465
+ # @pm_verify_app.command(name="review")
466
+ # def verify_review(
467
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
468
+ # ):
469
+ # """Verify review prerequisites.
470
+
471
+ # Options:
472
+ # -w, --working-dir: Working directory containing .plato/
473
+ # """
474
+ # issues = []
475
+
476
+ # # Check API key
477
+ # if not os.environ.get("PLATO_API_KEY"):
478
+ # issues.append("PLATO_API_KEY not set")
479
+
480
+ # # Check state
481
+ # state = get_sandbox_state(working_dir)
482
+ # if not state:
483
+ # issues.append(f"{STATE_FILE} not found")
484
+ # else:
485
+ # if not state.get("artifact_id"):
486
+ # issues.append("No artifact_id - run 'plato sandbox snapshot' first")
487
+ # if not state.get("service"):
488
+ # issues.append("No service name in state")
489
+
490
+ # # Check plato-config.yml
491
+ # if not Path("plato-config.yml").exists() and not Path("plato-config.yaml").exists():
492
+ # issues.append("plato-config.yml not found")
493
+
494
+ # if issues:
495
+ # _fail(f"Review prerequisites not met: {issues}")
496
+
497
+ # # Success - exit 0
498
+
499
+
500
+ # @pm_verify_app.command(name="submit")
501
+ # def verify_submit(
502
+ # working_dir: Path | None = typer.Option(None, "-w", "--working-dir", help="Working directory with .plato/"),
503
+ # ):
504
+ # """Verify submit prerequisites.
505
+
506
+ # Options:
507
+ # -w, --working-dir: Working directory containing .plato/
508
+ # """
509
+ # issues = []
510
+
511
+ # if not os.environ.get("PLATO_API_KEY"):
512
+ # issues.append("PLATO_API_KEY not set")
513
+
514
+ # state = get_sandbox_state(working_dir)
515
+ # if not state:
516
+ # issues.append(f"{STATE_FILE} not found")
517
+ # else:
518
+ # required = ["artifact_id", "service", "plato_config_path"]
519
+ # for field in required:
520
+ # if not state.get(field):
521
+ # issues.append(f"Missing {field} in state")
522
+
523
+ # if issues:
524
+ # _fail(f"Submit prerequisites not met: {issues}")
525
+
526
+ # # Success - exit 0
527
+
528
+
529
+ # # =============================================================================
530
+ # # RESEARCH/VALIDATION/CONFIG VERIFY COMMANDS
531
+ # # =============================================================================
532
+
533
+
534
+ # @sandbox_verify_app.command(name="research")
535
+ # def verify_research(
536
+ # report_path: str = typer.Option("research-report.yml", "--report", "-r"),
537
+ # ):
538
+ # """Verify research report is complete.
539
+
540
+ # Checks that the research report YAML file contains all required fields:
541
+ # app_name, source, database.type, docker, and credentials.
542
+
543
+ # Options:
544
+ # -r, --report: Path to research report file (default: research-report.yml)
545
+
546
+ # Exit code 0 = complete, exit code 1 = missing required fields.
547
+ # """
548
+ # if not Path(report_path).exists():
549
+ # _fail(f"Research report not found: {report_path}")
550
+
551
+ # try:
552
+ # with open(report_path) as f:
553
+ # report = yaml.safe_load(f)
554
+ # except yaml.YAMLError as e:
555
+ # _fail(f"Invalid YAML in {report_path}: {e}")
556
+
557
+ # if not report:
558
+ # _fail(f"Research report is empty: {report_path}")
559
+
560
+ # required_fields = ["db_type", "docker_image", "docker_tag", "credentials", "github_url"]
561
+ # missing = [f for f in required_fields if f not in report or not report[f]]
562
+
563
+ # # Check credentials sub-fields
564
+ # if "credentials" in report and report["credentials"]:
565
+ # creds = report["credentials"]
566
+ # if not creds.get("username"):
567
+ # missing.append("credentials.username")
568
+ # if not creds.get("password"):
569
+ # missing.append("credentials.password")
570
+
571
+ # # Check db_type is valid
572
+ # valid_db_types = ["postgresql", "mysql", "mariadb"]
573
+ # if report.get("db_type") and report["db_type"].lower() not in valid_db_types:
574
+ # _fail(f"Invalid db_type: {report['db_type']}. Valid: {valid_db_types}")
575
+
576
+ # if missing:
577
+ # _fail(f"Missing fields in research report: {missing}")
578
+
579
+ # # Success - exit 0
580
+
581
+
582
+ # @sandbox_verify_app.command(name="validation")
583
+ # def verify_validation(
584
+ # report_path: str = typer.Option("research-report.yml", "--report", "-r"),
585
+ # ):
586
+ # """Verify app can become a simulator.
587
+
588
+ # Checks that the research report indicates a supported database type
589
+ # (postgresql, mysql, mariadb, sqlite) and has no blocking issues.
590
+
591
+ # Options:
592
+ # -r, --report: Path to research report file (default: research-report.yml)
593
+
594
+ # Exit code 0 = can become simulator, exit code 1 = has blockers.
595
+ # """
596
+ # if not Path(report_path).exists():
597
+ # _fail(f"Research report not found: {report_path}")
598
+
599
+ # with open(report_path) as f:
600
+ # report = yaml.safe_load(f)
601
+
602
+ # # Check database type
603
+ # db_type = report.get("db_type", "").lower()
604
+ # supported_dbs = ["postgresql", "mysql", "mariadb"]
605
+
606
+ # if db_type == "sqlite":
607
+ # _fail("SQLite not supported. Plato requires PostgreSQL, MySQL, or MariaDB")
608
+
609
+ # if db_type not in supported_dbs:
610
+ # _fail(f"Unknown database type: {db_type}. Supported: {supported_dbs}")
611
+
612
+ # # Check for blockers
613
+ # blockers = report.get("blockers", [])
614
+ # if blockers:
615
+ # _fail(f"Blockers found: {blockers}")
616
+
617
+ # # Success - exit 0
618
+
619
+
620
+ # @sandbox_verify_app.command(name="config")
621
+ # def verify_config(
622
+ # config_path: str = typer.Option("plato-config.yml", "--config", "-c"),
623
+ # compose_path: str = typer.Option("base/docker-compose.yml", "--compose"),
624
+ # ):
625
+ # """Verify configuration files are valid.
626
+
627
+ # Checks that plato-config.yml and docker-compose.yml exist and contain valid
628
+ # YAML. Validates required fields in plato-config.yml (service, datasets, etc.).
629
+
630
+ # Options:
631
+ # -c, --config: Path to plato-config.yml (default: plato-config.yml)
632
+ # --compose: Path to docker-compose.yml (default: base/docker-compose.yml)
633
+
634
+ # Exit code 0 = valid, exit code 1 = issues found.
635
+ # """
636
+ # issues = []
637
+
638
+ # # Check plato-config.yml
639
+ # if not Path(config_path).exists():
640
+ # _fail(f"File not found: {config_path}")
641
+
642
+ # try:
643
+ # with open(config_path) as f:
644
+ # config = yaml.safe_load(f)
645
+ # except yaml.YAMLError as e:
646
+ # _fail(f"Invalid YAML in {config_path}: {e}")
647
+
648
+ # required_config_fields = ["service", "datasets"]
649
+ # for field in required_config_fields:
650
+ # if field not in config:
651
+ # issues.append(f"{config_path}: Missing '{field}'")
652
+
653
+ # # Check datasets.base structure
654
+ # if "datasets" in config and "base" in config.get("datasets", {}):
655
+ # base = config["datasets"]["base"]
656
+
657
+ # if "metadata" not in base:
658
+ # issues.append(f"{config_path}: Missing datasets.base.metadata")
659
+
660
+ # if "listeners" not in base:
661
+ # issues.append(f"{config_path}: Missing datasets.base.listeners")
662
+ # elif "db" not in base.get("listeners", {}):
663
+ # issues.append(f"{config_path}: Missing listeners.db")
664
+
665
+ # # Check docker-compose.yml
666
+ # if not Path(compose_path).exists():
667
+ # _fail(f"File not found: {compose_path}")
668
+
669
+ # try:
670
+ # with open(compose_path) as f:
671
+ # compose = yaml.safe_load(f)
672
+ # except yaml.YAMLError as e:
673
+ # _fail(f"Invalid YAML in {compose_path}: {e}")
674
+
675
+ # services = compose.get("services", {})
676
+ # standard_db_images = ["postgres:", "mysql:", "mariadb:"]
677
+
678
+ # for svc_name, svc_config in services.items():
679
+ # if svc_config.get("network_mode") != "host":
680
+ # issues.append(f"{compose_path}: '{svc_name}' missing 'network_mode: host'")
681
+
682
+ # image = svc_config.get("image", "")
683
+ # for std_img in standard_db_images:
684
+ # if image.startswith(std_img):
685
+ # issues.append(f"{compose_path}: '{svc_name}' uses standard DB image '{image}' - use Plato DB image")
686
+
687
+ # if issues:
688
+ # _fail(f"Config issues: {issues}")
689
+
690
+ # # Success - exit 0