systemlink-cli 1.3.1__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.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,24 @@
1
+ """Decorators and helpers for Dynamic Form Fields (DFF) testing and CLI behavior.
2
+
3
+ This module provides lightweight decorator utilities used by the CLI tests and
4
+ command implementations. Keep implementations minimal and well-typed.
5
+ """
6
+
7
+ from typing import Callable, TypeVar
8
+
9
+ F = TypeVar("F", bound=Callable[..., object])
10
+
11
+
12
+ def passthrough_decorator(func: F) -> F:
13
+ """A no-op decorator used in testing to preserve function metadata.
14
+
15
+ Returns the original function unchanged. Useful as a placeholder when a
16
+ decorator is required by test scaffolding but implements no behavior.
17
+
18
+ Args:
19
+ func: The callable to return.
20
+
21
+ Returns:
22
+ The original callable ``func``.
23
+ """
24
+ return func
slcli/example_click.py ADDED
@@ -0,0 +1,404 @@
1
+ """CLI commands for managing example configurations."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import click
8
+
9
+ from .example_loader import ExampleLoader
10
+ from .example_provisioner import ExampleProvisioner, ProvisioningAction, ProvisioningResult
11
+ from .universal_handlers import UniversalResponseHandler, FilteredResponse
12
+ from .utils import ExitCodes, format_success, get_workspace_map, handle_api_error, save_json_file
13
+ from .workspace_utils import get_effective_workspace
14
+
15
+
16
+ def _resolve_workspace_id(workspace: Optional[str]) -> Optional[str]:
17
+ """Resolve workspace name to ID using workspace map.
18
+
19
+ Args:
20
+ workspace: Workspace name or ID provided by the user.
21
+
22
+ Returns:
23
+ Workspace ID if resolved; original value if already an ID; None if not provided.
24
+ """
25
+ if not workspace:
26
+ return None
27
+
28
+ workspace_map = get_workspace_map()
29
+ if not workspace_map:
30
+ return workspace
31
+
32
+ # Direct match on ID
33
+ if workspace in workspace_map:
34
+ return workspace
35
+
36
+ # Match on name (case-insensitive)
37
+ for ws_id, ws_name in workspace_map.items():
38
+ if ws_name and workspace.lower() == str(ws_name).lower():
39
+ return ws_id
40
+
41
+ click.echo(f"✗ Workspace '{workspace}' not found. Provide a valid name or ID.", err=True)
42
+ sys.exit(ExitCodes.INVALID_INPUT)
43
+
44
+
45
+ def _serialize_results(results: List[ProvisioningResult]) -> List[Dict[str, Any]]:
46
+ """Convert provisioning results into serializable dictionaries."""
47
+ serialized: List[Dict[str, Any]] = []
48
+ for res in results:
49
+ action_value = (
50
+ res.action.value if isinstance(res.action, ProvisioningAction) else str(res.action)
51
+ )
52
+ serialized.append(
53
+ {
54
+ "id_reference": res.id_reference,
55
+ "resource_type": res.resource_type,
56
+ "resource_name": res.resource_name,
57
+ "action": action_value,
58
+ "server_id": res.server_id,
59
+ "error": res.error,
60
+ }
61
+ )
62
+ return serialized
63
+
64
+
65
+ def _result_row_formatter(item: Dict[str, Any]) -> List[str]:
66
+ """Formatter for table rows in provisioning output."""
67
+ return [
68
+ item.get("resource_name", ""),
69
+ item.get("resource_type", ""),
70
+ item.get("action", ""),
71
+ item.get("server_id", "") or "-",
72
+ item.get("error", "") or "",
73
+ ]
74
+
75
+
76
+ def _example_row_formatter(item: Dict[str, Any]) -> List[str]:
77
+ """Formatter for table rows in example list output."""
78
+ tags = item.get("tags") or []
79
+ setup = item.get("estimated_setup_time_minutes", 0)
80
+ return [
81
+ item.get("name", ""),
82
+ item.get("title", ""),
83
+ ", ".join(tags),
84
+ str(setup),
85
+ item.get("author", ""),
86
+ ]
87
+
88
+
89
+ def _output_results(results: List[Dict[str, Any]], format_output: str) -> None:
90
+ """Render provisioning results in the requested format."""
91
+ UniversalResponseHandler.handle_list_response(
92
+ resp=FilteredResponse({"resources": results}),
93
+ data_key="resources",
94
+ item_name="resource",
95
+ format_output=format_output,
96
+ formatter_func=_result_row_formatter,
97
+ headers=["Name", "Type", "Action", "Server ID", "Error"],
98
+ column_widths=[45, 12, 10, 38, 30],
99
+ empty_message="No resources processed.",
100
+ enable_pagination=False,
101
+ )
102
+
103
+
104
+ def _write_audit_log(
105
+ results: List[Dict[str, Any]], audit_log: Optional[str], quiet: bool = False
106
+ ) -> None:
107
+ """Persist results to an audit log file if requested."""
108
+ if not audit_log:
109
+ return
110
+ save_json_file(results, audit_log)
111
+ if not quiet:
112
+ click.echo(f"Audit log saved to {audit_log}", err=True)
113
+
114
+
115
+ def register_example_commands(cli: Any) -> None:
116
+ """Register example command group.
117
+
118
+ Args:
119
+ cli: Click CLI group to register commands on.
120
+ """
121
+
122
+ @cli.group()
123
+ def example() -> None:
124
+ """Manage example resource configurations.
125
+
126
+ Examples help you quickly set up demo systems for training,
127
+ testing, or evaluation. Each example includes systems, assets,
128
+ DUTs, templates, and other resources needed for a complete workflow.
129
+
130
+ Workspace: Uses default workspace unless --workspace specified.
131
+ """
132
+ pass
133
+
134
+ @example.command(name="list")
135
+ @click.option(
136
+ "--format",
137
+ "-f",
138
+ type=click.Choice(["table", "json"]),
139
+ default="table",
140
+ help="Output format",
141
+ )
142
+ def list_examples(format: str) -> None:
143
+ """List available example configurations.
144
+
145
+ Shows all examples with descriptions, tags, and estimated setup time.
146
+ """
147
+ try:
148
+ loader = ExampleLoader()
149
+ examples = loader.list_examples()
150
+
151
+ if not examples:
152
+ if format == "json":
153
+ click.echo("[]")
154
+ else:
155
+ click.echo("No examples available.", err=True)
156
+ return
157
+
158
+ if format == "json":
159
+ # JSON: show all at once
160
+ click.echo(json.dumps(examples, indent=2))
161
+ else:
162
+ UniversalResponseHandler.handle_list_response(
163
+ resp=FilteredResponse({"examples": examples}),
164
+ data_key="examples",
165
+ item_name="example",
166
+ format_output=format,
167
+ formatter_func=_example_row_formatter,
168
+ headers=["Name", "Title", "Tags", "Setup (min)", "Author"],
169
+ column_widths=[35, 28, 24, 11, 18],
170
+ enable_pagination=False, # Unlikely to have > 25 examples
171
+ )
172
+
173
+ except Exception as exc:
174
+ handle_api_error(exc)
175
+
176
+ @example.command(name="info")
177
+ @click.argument("example_name")
178
+ @click.option(
179
+ "--format",
180
+ "-f",
181
+ type=click.Choice(["table", "json"]),
182
+ default="table",
183
+ help="Output format",
184
+ )
185
+ def info_example(example_name: str, format: str) -> None:
186
+ """Show detailed information about an example.
187
+
188
+ Displays full config including resources, dependencies, and
189
+ estimated setup time.
190
+
191
+ Example:
192
+ slcli example info demo-test-plans
193
+ """
194
+ try:
195
+ loader = ExampleLoader()
196
+ config = loader.load_config(example_name)
197
+
198
+ if format == "json":
199
+ # JSON: dump full config
200
+ click.echo(json.dumps(config, indent=2))
201
+ else:
202
+ # Table format: show summary and resources
203
+ click.echo(f"\n{'='*70}")
204
+ click.echo(f"Example: {config['title']}")
205
+ click.echo(f"{'='*70}")
206
+ click.echo(f"Name: {config.get('name', 'N/A')}")
207
+ click.echo(f"Author: {config.get('author', 'N/A')}")
208
+ click.echo(f"Setup Time: {config.get('estimated_setup_time_minutes', 0)} minutes")
209
+ click.echo(f"Tags: {', '.join(config.get('tags', []))}")
210
+ click.echo()
211
+ click.echo(f"Description:\n{config.get('description', 'N/A')}")
212
+ click.echo()
213
+
214
+ # Show resources
215
+ resources = config.get("resources", [])
216
+ click.echo(f"\nResources ({len(resources)} total):")
217
+ click.echo("-" * 70)
218
+
219
+ for resource in resources:
220
+ res_type = resource.get("type", "unknown")
221
+ res_name = resource.get("name", "N/A")
222
+ res_ref = resource.get("id_reference", "N/A")
223
+ click.echo(f" {res_type:15} {res_name:30} (${{{res_ref}}})")
224
+
225
+ click.echo(f"{'='*70}\n")
226
+
227
+ except FileNotFoundError as e:
228
+ click.echo(f"✗ Error: {e}", err=True)
229
+ sys.exit(ExitCodes.NOT_FOUND)
230
+ except ValueError as e:
231
+ click.echo(f"✗ Error: {e}", err=True)
232
+ sys.exit(ExitCodes.INVALID_INPUT)
233
+ except Exception as exc:
234
+ handle_api_error(exc)
235
+
236
+ @example.command(name="install")
237
+ @click.argument("example_name")
238
+ @click.option("--workspace", "-w", required=True, help="Workspace name or ID for resources")
239
+ @click.option(
240
+ "--format",
241
+ "-f",
242
+ type=click.Choice(["table", "json"]),
243
+ default="table",
244
+ help="Output format for provisioning results",
245
+ )
246
+ @click.option(
247
+ "--dry-run",
248
+ is_flag=True,
249
+ help="Validate and preview resource creation without calling APIs.",
250
+ )
251
+ @click.option(
252
+ "--audit-log",
253
+ "-a",
254
+ type=click.Path(dir_okay=False, writable=True, resolve_path=True),
255
+ help="Path to write provisioning results as JSON for auditing.",
256
+ )
257
+ def install_example(
258
+ example_name: str,
259
+ workspace: Optional[str],
260
+ format: str,
261
+ dry_run: bool,
262
+ audit_log: Optional[str],
263
+ ) -> None:
264
+ """Provision all resources defined by an example configuration."""
265
+ try:
266
+ loader = ExampleLoader()
267
+ config = loader.load_config(example_name)
268
+
269
+ workspace_id = _resolve_workspace_id(get_effective_workspace(workspace))
270
+ provisioner = ExampleProvisioner(
271
+ workspace_id=workspace_id,
272
+ example_name=example_name,
273
+ dry_run=dry_run,
274
+ )
275
+
276
+ results, err = provisioner.provision(config)
277
+ if err:
278
+ handle_api_error(err)
279
+
280
+ serialized = _serialize_results(results)
281
+ _write_audit_log(serialized, audit_log, quiet=format == "json")
282
+ _output_results(serialized, format)
283
+
284
+ failed = any(r.get("action") == ProvisioningAction.FAILED.value for r in serialized)
285
+ if failed:
286
+ click.echo("✗ One or more resources failed to provision.", err=True)
287
+ sys.exit(ExitCodes.GENERAL_ERROR)
288
+
289
+ if format == "json":
290
+ return
291
+
292
+ created_count = sum(
293
+ 1 for r in serialized if r.get("action") == ProvisioningAction.CREATED.value
294
+ )
295
+ skipped_count = sum(
296
+ 1 for r in serialized if r.get("action") == ProvisioningAction.SKIPPED.value
297
+ )
298
+
299
+ summary_message = "Dry-run completed" if dry_run else "Example install completed"
300
+ format_success(
301
+ summary_message,
302
+ {
303
+ "example": example_name,
304
+ "workspace": workspace_id or "default",
305
+ "created": created_count,
306
+ "skipped": skipped_count,
307
+ },
308
+ )
309
+
310
+ except FileNotFoundError as e:
311
+ click.echo(f"✗ Error: {e}", err=True)
312
+ sys.exit(ExitCodes.NOT_FOUND)
313
+ except ValueError as e:
314
+ click.echo(f"✗ Error: {e}", err=True)
315
+ sys.exit(ExitCodes.INVALID_INPUT)
316
+ except Exception as exc:
317
+ handle_api_error(exc)
318
+
319
+ @example.command(name="delete")
320
+ @click.argument("example_name")
321
+ @click.option("--workspace", "-w", required=True, help="Workspace name or ID for resources")
322
+ @click.option(
323
+ "--format",
324
+ "-f",
325
+ type=click.Choice(["table", "json"]),
326
+ default="table",
327
+ help="Output format for deletion results",
328
+ )
329
+ @click.option(
330
+ "--dry-run",
331
+ is_flag=True,
332
+ help="Preview deletions without calling APIs.",
333
+ )
334
+ @click.option(
335
+ "--audit-log",
336
+ "-a",
337
+ type=click.Path(dir_okay=False, writable=True, resolve_path=True),
338
+ help="Path to write deletion results as JSON for auditing.",
339
+ )
340
+ def delete_example(
341
+ example_name: str,
342
+ workspace: Optional[str],
343
+ format: str,
344
+ dry_run: bool,
345
+ audit_log: Optional[str],
346
+ ) -> None:
347
+ """Delete resources for an example configuration in reverse order."""
348
+ from .utils import check_readonly_mode
349
+
350
+ check_readonly_mode("delete an example")
351
+
352
+ try:
353
+ loader = ExampleLoader()
354
+ config = loader.load_config(example_name)
355
+
356
+ workspace_id = _resolve_workspace_id(get_effective_workspace(workspace))
357
+ provisioner = ExampleProvisioner(
358
+ workspace_id=workspace_id,
359
+ example_name=example_name,
360
+ dry_run=dry_run,
361
+ )
362
+
363
+ results, err = provisioner.delete(config)
364
+ if err:
365
+ handle_api_error(err)
366
+
367
+ serialized = _serialize_results(results)
368
+ _write_audit_log(serialized, audit_log, quiet=format == "json")
369
+ _output_results(serialized, format)
370
+
371
+ failed = any(r.get("action") == ProvisioningAction.FAILED.value for r in serialized)
372
+ if failed:
373
+ click.echo("✗ One or more resources failed to delete.", err=True)
374
+ sys.exit(ExitCodes.GENERAL_ERROR)
375
+
376
+ if format == "json":
377
+ return
378
+
379
+ deleted_count = sum(
380
+ 1 for r in serialized if r.get("action") == ProvisioningAction.DELETED.value
381
+ )
382
+ skipped_count = sum(
383
+ 1 for r in serialized if r.get("action") == ProvisioningAction.SKIPPED.value
384
+ )
385
+
386
+ summary_message = "Dry-run completed" if dry_run else "Example delete completed"
387
+ format_success(
388
+ summary_message,
389
+ {
390
+ "example": example_name,
391
+ "workspace": workspace_id or "default",
392
+ "deleted": deleted_count,
393
+ "skipped": skipped_count,
394
+ },
395
+ )
396
+
397
+ except FileNotFoundError as e:
398
+ click.echo(f"✗ Error: {e}", err=True)
399
+ sys.exit(ExitCodes.NOT_FOUND)
400
+ except ValueError as e:
401
+ click.echo(f"✗ Error: {e}", err=True)
402
+ sys.exit(ExitCodes.INVALID_INPUT)
403
+ except Exception as exc:
404
+ handle_api_error(exc)
@@ -0,0 +1,274 @@
1
+ """Load and validate example configurations."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import yaml # type: ignore
7
+
8
+
9
+ class ExampleLoader:
10
+ """Load and validate example configurations from local examples/ directory."""
11
+
12
+ # Supported schema versions
13
+ SUPPORTED_SCHEMA_VERSIONS = {"1.0"}
14
+
15
+ # Required fields in config
16
+ REQUIRED_FIELDS = {"format_version", "name", "title", "resources"}
17
+
18
+ # Required resource fields
19
+ REQUIRED_RESOURCE_FIELDS = {"type", "name", "properties", "id_reference"}
20
+
21
+ # Supported resource types
22
+ SUPPORTED_RESOURCE_TYPES = {
23
+ "location",
24
+ "product",
25
+ "system",
26
+ "asset",
27
+ "dut",
28
+ "testtemplate",
29
+ "workflow",
30
+ "work_item",
31
+ "work_order",
32
+ "test_result",
33
+ "data_table",
34
+ "file",
35
+ "notebook",
36
+ }
37
+
38
+ def __init__(self, examples_dir: Optional[Path] = None) -> None:
39
+ """Initialize loader with path to examples directory.
40
+
41
+ Args:
42
+ examples_dir: Path to examples directory. Defaults to slcli/examples/.
43
+ """
44
+ self.examples_dir = examples_dir or Path(__file__).parent / "examples"
45
+ self._schema: Optional[Dict[str, Any]] = None
46
+
47
+ def list_examples(self) -> List[Dict[str, Any]]:
48
+ """List all available examples with metadata.
49
+
50
+ Returns:
51
+ List of dicts with keys: name, title, description, tags,
52
+ estimated_setup_time_minutes, author.
53
+ """
54
+ examples = []
55
+ for example_dir in sorted(self.examples_dir.iterdir()):
56
+ # Skip special directories and non-directories
57
+ if not example_dir.is_dir() or example_dir.name.startswith("_"):
58
+ continue
59
+
60
+ config_path = example_dir / "config.yaml"
61
+ if not config_path.exists():
62
+ continue
63
+
64
+ try:
65
+ config = self.load_config(example_dir.name)
66
+ examples.append(
67
+ {
68
+ "name": config["name"],
69
+ "title": config["title"],
70
+ "description": config.get("description", ""),
71
+ "tags": config.get("tags", []),
72
+ "estimated_setup_time_minutes": config.get(
73
+ "estimated_setup_time_minutes", 0
74
+ ),
75
+ "author": config.get("author", "Unknown"),
76
+ }
77
+ )
78
+ except (FileNotFoundError, ValueError):
79
+ # Skip invalid examples
80
+ continue
81
+
82
+ return examples
83
+
84
+ def load_config(self, example_name: str) -> Dict[str, Any]:
85
+ """Load and validate example configuration.
86
+
87
+ Args:
88
+ example_name: Name of example (directory name).
89
+
90
+ Returns:
91
+ Validated config dictionary.
92
+
93
+ Raises:
94
+ FileNotFoundError: If example config not found.
95
+ ValueError: If config fails validation.
96
+ """
97
+ config_path = self.examples_dir / example_name / "config.yaml"
98
+ if not config_path.exists():
99
+ raise FileNotFoundError(
100
+ f"Example '{example_name}' not found. " f"Config path: {config_path}"
101
+ )
102
+
103
+ # Load YAML
104
+ try:
105
+ with open(config_path, "r") as f:
106
+ config = yaml.safe_load(f)
107
+ except yaml.YAMLError as e:
108
+ raise ValueError(f"Invalid YAML in {config_path}: {e}")
109
+
110
+ if not isinstance(config, dict):
111
+ raise ValueError(f"Config must be a dictionary, got {type(config)}")
112
+
113
+ # Validate schema
114
+ errors = self.validate_config(config)
115
+ if errors:
116
+ msg = f"Config validation failed for '{example_name}':\n"
117
+ msg += "\n".join(f" - {e}" for e in errors)
118
+ raise ValueError(msg)
119
+
120
+ # Validate references
121
+ ref_errors = self._validate_references(config)
122
+ if ref_errors:
123
+ msg = f"Reference validation failed for '{example_name}':\n"
124
+ msg += "\n".join(f" - {e}" for e in ref_errors)
125
+ raise ValueError(msg)
126
+
127
+ return config
128
+
129
+ def validate_config(self, config: Dict[str, Any]) -> List[str]:
130
+ """Validate config against basic schema requirements.
131
+
132
+ Args:
133
+ config: Config dictionary to validate.
134
+
135
+ Returns:
136
+ List of validation error messages (empty if valid).
137
+ """
138
+ errors = []
139
+
140
+ # Check all required top-level fields are present
141
+
142
+ missing_fields = self.REQUIRED_FIELDS - set(config.keys())
143
+ if missing_fields:
144
+ errors.append(f"Missing required fields: {', '.join(sorted(missing_fields))}")
145
+
146
+ # Check format_version is supported
147
+ version = config.get("format_version")
148
+ if version and version not in self.SUPPORTED_SCHEMA_VERSIONS:
149
+ errors.append(
150
+ f"Unsupported format_version '{version}'. "
151
+ f"Supported: {self.SUPPORTED_SCHEMA_VERSIONS}"
152
+ )
153
+
154
+ # Validate resources
155
+ resources = config.get("resources", [])
156
+ if not isinstance(resources, list):
157
+ errors.append("resources must be a list")
158
+ else:
159
+ for idx, resource in enumerate(resources):
160
+ if not isinstance(resource, dict):
161
+ errors.append(f"Resource {idx}: must be a dictionary")
162
+ continue
163
+
164
+ # Check required resource fields
165
+ missing = self.REQUIRED_RESOURCE_FIELDS - set(resource.keys())
166
+ if missing:
167
+ errors.append(f"Resource {idx}: missing fields: {', '.join(sorted(missing))}")
168
+
169
+ # Check resource type is supported
170
+ res_type = resource.get("type")
171
+ if res_type and res_type not in self.SUPPORTED_RESOURCE_TYPES:
172
+ errors.append(
173
+ f"Resource {idx}: unsupported type '{res_type}'. "
174
+ f"Supported: {', '.join(sorted(self.SUPPORTED_RESOURCE_TYPES))}"
175
+ )
176
+
177
+ # Validate id_reference format (should be valid identifier)
178
+ id_ref = resource.get("id_reference", "")
179
+ if id_ref and not self._is_valid_identifier(id_ref):
180
+ errors.append(
181
+ f"Resource {idx}: invalid id_reference '{id_ref}'. "
182
+ f"Must start with letter or underscore, contain only "
183
+ f"alphanumeric and underscores."
184
+ )
185
+
186
+ return errors
187
+
188
+ def _is_valid_identifier(self, name: str) -> bool:
189
+ """Check if name is a valid Python identifier."""
190
+ if not name:
191
+ return False
192
+ if not (name[0].isalpha() or name[0] == "_"):
193
+ return False
194
+ return all(c.isalnum() or c == "_" for c in name)
195
+
196
+ def _validate_references(self, config: Dict[str, Any]) -> List[str]:
197
+ """Validate that all ${ref} references are defined.
198
+
199
+ Args:
200
+ config: Config dictionary.
201
+
202
+ Returns:
203
+ List of reference error messages.
204
+ """
205
+ errors = []
206
+
207
+ # Collect all defined id_references
208
+ defined_refs = set()
209
+ resources = config.get("resources", [])
210
+ if not isinstance(resources, list):
211
+ return ["resources must be a list"]
212
+
213
+ for resource in resources:
214
+ if isinstance(resource, dict):
215
+ ref = resource.get("id_reference")
216
+ if ref:
217
+ defined_refs.add(ref)
218
+
219
+ # Check all ${ref} references are defined
220
+ for resource in resources:
221
+ if not isinstance(resource, dict):
222
+ continue
223
+
224
+ # Check in resource properties
225
+ props = resource.get("properties", {})
226
+ if isinstance(props, dict):
227
+ ref_errors = self._collect_undefined_refs(props, defined_refs)
228
+ errors.extend(ref_errors)
229
+
230
+ return errors
231
+
232
+ def _collect_undefined_refs(self, obj: Any, defined_refs: set) -> List[str]:
233
+ """Recursively collect undefined references from an object.
234
+
235
+ Args:
236
+ obj: Object to scan (dict, list, string, etc).
237
+ defined_refs: Set of defined id_references.
238
+
239
+ Returns:
240
+ List of error messages for undefined references.
241
+ """
242
+ errors = []
243
+
244
+ if isinstance(obj, dict):
245
+ for value in obj.values():
246
+ errors.extend(self._collect_undefined_refs(value, defined_refs))
247
+ elif isinstance(obj, list):
248
+ for item in obj:
249
+ errors.extend(self._collect_undefined_refs(item, defined_refs))
250
+ elif isinstance(obj, str):
251
+ # Check for ${ref} pattern
252
+ if obj.startswith("${") and obj.endswith("}"):
253
+ ref = obj[2:-1]
254
+ if ref not in defined_refs:
255
+ errors.append(
256
+ f"Undefined reference: {obj}. "
257
+ f"Defined references: {sorted(defined_refs)}"
258
+ )
259
+
260
+ return errors
261
+
262
+ def get_resource_order(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
263
+ """Get resources in provisioning order (as listed in config).
264
+
265
+ Args:
266
+ config: Config dictionary.
267
+
268
+ Returns:
269
+ List of resource definitions.
270
+ """
271
+ resources = config.get("resources", [])
272
+ if not isinstance(resources, list):
273
+ return []
274
+ return [r for r in resources if isinstance(r, dict)]