preflight-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
preflight/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Preflight: Infrastructure Change Risk Analysis Engine."""
2
+
3
+ __version__ = "0.1.0"
preflight/cli.py ADDED
@@ -0,0 +1,536 @@
1
+ """Command Line Interface for Preflight."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ # Ensure UTF-8 output on Windows terminals
11
+ if hasattr(sys.stdout, "reconfigure"):
12
+ try:
13
+ sys.stdout.reconfigure(encoding="utf-8")
14
+ except Exception:
15
+ pass
16
+ if hasattr(sys.stderr, "reconfigure"):
17
+ try:
18
+ sys.stderr.reconfigure(encoding="utf-8")
19
+ except Exception:
20
+ pass
21
+
22
+ from preflight import __version__
23
+ from preflight.config import ConfigError, load_config
24
+ from preflight.engine import PreflightEngine
25
+ from preflight.github import GitHubApiError, post_or_update_pr_comment, resolve_github_context
26
+ from preflight.models import Environment, Severity
27
+ from preflight.plan import PlanParseError
28
+ from preflight.policy import PolicyEvaluator
29
+ from preflight.reporting import (
30
+ render_json_report,
31
+ render_markdown_report,
32
+ render_pr_comment,
33
+ render_sarif_report,
34
+ render_terminal_report,
35
+ )
36
+
37
+ err_console = Console(stderr=True)
38
+
39
+ MAIN_HELP = """\
40
+ [bold]Preflight[/bold]: Infrastructure Change Risk Analysis Engine for Terraform plans.
41
+
42
+ Analyzes Terraform execution plans to detect destructive operations, security
43
+ regressions, and architectural risks before changes reach production.
44
+ """
45
+
46
+ MAIN_EPILOG = """\
47
+ [bold green]Terraform Integration Workflow:[/bold green]
48
+
49
+ [bold cyan]Workflow A: Direct Stdin Stream (Zero disk artifacts - Recommended for CI/CD)[/bold cyan]
50
+ $ terraform plan -out=tfplan
51
+ $ terraform show -json tfplan | preflight analyze - --fail-on high
52
+
53
+ [bold cyan]Workflow B: Local Plan File Analysis[/bold cyan]
54
+ $ terraform plan -out=tfplan
55
+ $ terraform show -json tfplan > plan.json
56
+ $ preflight analyze plan.json --environment production
57
+
58
+ [bold cyan]Workflow C: GitHub Pull Request Sticky Comment Gate[/bold cyan]
59
+ $ terraform show -json tfplan | preflight comment - --fail-on high
60
+
61
+ [bold cyan]Workflow D: GitHub Code Scanning & SARIF Upload[/bold cyan]
62
+ $ terraform show -json tfplan | preflight analyze - --format sarif --output preflight.sarif
63
+
64
+ [bold green]Supported Cloud Providers & Detection Rules:[/bold green]
65
+
66
+ [bold cyan]AWS (AWS-*)[/bold cyan]
67
+ • AWS-NET-001: Security group ingress allowing SSH (22), RDP (3389), or all traffic to 0.0.0.0/0
68
+ • AWS-S3-001: S3 Public Access Block removal or disabled settings; public bucket ACLs
69
+ • AWS-IAM-001: Permissive IAM wildcard policies (* on * is Critical; service-level wildcards is High)
70
+ • AWS-DB-001: Deletion or replacement of RDS instances, Aurora clusters, DynamoDB, or ElastiCache
71
+
72
+ [bold cyan]Azure (AZR-*)[/bold cyan]
73
+ • AZR-NET-001: Network Security Group (NSG) rules allowing Internet to ports 22, 3389, or wildcard *
74
+ • AZR-STG-001: Storage account public blob access or missing firewall restrictions
75
+ • AZR-KV-001: Azure Key Vault changes with purge protection disabled or soft delete retention < 7 days
76
+ • AZR-DB-001: Deletion or replacement of Azure SQL, Cosmos DB, or PostgreSQL flexible servers
77
+
78
+ [bold cyan]Google Cloud (GCP-*)[/bold cyan]
79
+ • GCP-FW-001: Ingress compute firewall rules allowing 0.0.0.0/0 to ports 22, 3389, or all protocols
80
+ • GCP-IAM-001: Cloud Storage bucket IAM bindings granting access to allUsers or allAuthenticatedUsers
81
+ • GCP-SQL-001: Cloud SQL instance deletion, replacement, or disabling deletion_protection
82
+
83
+ [bold cyan]Kubernetes & Helm (K8S-*)[/bold cyan]
84
+ • K8S-SEC-001: Workloads deploying with container privileged: true or hostNetwork/hostPID/hostIPC
85
+ • K8S-RBAC-001: RoleBindings and ClusterRoleBindings binding to superuser role cluster-admin
86
+
87
+ [bold cyan]Generic (GEN-*)[/bold cyan]
88
+ • GEN-DEL-001: Permanent resource deletion in Terraform plan
89
+ • GEN-REP-001: Resource replacement (destroy and recreate due to immutable attribute changes)
90
+ • GEN-DST-001: Destructive changes targeting stateful infrastructure (databases, volumes, clusters)
91
+ • GEN-UNK-001: Computed unknown values present in critical configuration fields
92
+ • GEN-ACT-001: Unrecognized or unsupported Terraform lifecycle action sequences
93
+
94
+ [bold green]CI/CD Gate Exit Codes:[/bold green]
95
+
96
+ [bold]0 (Pass)[/bold] Analysis completed successfully. No configured policy conditions were violated.
97
+ [bold]1 (Block)[/bold] Deployment blocked. A configured policy threshold (--fail-on) or blocked rule triggered.
98
+ [bold]2 (Error)[/bold] Execution error. Plan file not found, invalid JSON syntax, or bad configuration.
99
+
100
+ [bold green]Documentation & Repository:[/bold green]
101
+ https://github.com/adityacodes-root/Preflight
102
+ """
103
+
104
+ ANALYZE_EPILOG = """\
105
+ [bold green]Terraform Usage Examples & Patterns:[/bold green]
106
+
107
+ [bold cyan]1. Stream directly via pipe (recommended in CI/CD pipelines):[/bold cyan]
108
+ $ terraform show -json tfplan | preflight analyze -
109
+
110
+ [bold cyan]2. Block CI/CD pipeline on High or Critical severity findings:[/bold cyan]
111
+ $ terraform show -json tfplan | preflight analyze - --fail-on high
112
+
113
+ [bold cyan]3. Set environment criticality (development, staging, production):[/bold cyan]
114
+ $ preflight analyze plan.json --environment production
115
+
116
+ [bold cyan]4. Export as SARIF v2.1.0 for GitHub Code Scanning / Security Tab:[/bold cyan]
117
+ $ terraform show -json tfplan | preflight analyze - --format sarif --output preflight.sarif
118
+
119
+ [bold cyan]5. Export JSON report for downstream scripts or Slack / webhook bots:[/bold cyan]
120
+ $ preflight analyze plan.json --format json --output report.json
121
+
122
+ [bold cyan]6. Export Markdown report for pull requests or documentation:[/bold cyan]
123
+ $ preflight analyze plan.json --format markdown --output report.md
124
+
125
+ [bold cyan]7. Load custom organization policy file (preflight.yaml):[/bold cyan]
126
+ $ preflight analyze plan.json --config .preflight.yaml
127
+
128
+ [bold green]Exit Codes:[/bold green]
129
+ 0 = Pass (Changes safe or below threshold)
130
+ 1 = Block (Severity threshold met or blocked rule violated)
131
+ 2 = Error (Invalid JSON, bad arguments, or file read error)
132
+ """
133
+
134
+ COMMENT_EPILOG = """\
135
+ [bold green]GitHub Actions CI/CD Usage:[/bold green]
136
+
137
+ [bold cyan]1. Automatic Context Resolution (Standard GitHub Actions workflow):[/bold cyan]
138
+ In CI, GITHUB_REPOSITORY, GITHUB_TOKEN, and PR numbers (from GITHUB_REF)
139
+ are detected automatically. Subsequent runs update the comment in place:
140
+
141
+ - name: Preflight PR Risk Gate
142
+ env:
143
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
144
+ run: |
145
+ terraform show -json tfplan | preflight comment - --fail-on high
146
+
147
+ [bold cyan]2. Local Dry-Run (preview markdown without calling GitHub API):[/bold cyan]
148
+ $ preflight comment plan.json --dry-run --fail-on high
149
+
150
+ [bold cyan]3. Explicit Parameters (Custom runners, external PRs, or testing):[/bold cyan]
151
+ $ preflight comment plan.json --repo owner/repo --pr 42 --token $GITHUB_TOKEN
152
+ """
153
+
154
+ app = typer.Typer(
155
+ name="preflight",
156
+ help=MAIN_HELP,
157
+ epilog=MAIN_EPILOG,
158
+ no_args_is_help=True,
159
+ add_completion=False,
160
+ rich_markup_mode="rich",
161
+ )
162
+
163
+
164
+ def version_callback(value: bool) -> None:
165
+ if value:
166
+ typer.echo(f"preflight {__version__}")
167
+ raise typer.Exit(code=0)
168
+
169
+
170
+ @app.callback()
171
+ def main_callback(
172
+ version: Annotated[
173
+ bool,
174
+ typer.Option(
175
+ "--version",
176
+ "-V",
177
+ callback=version_callback,
178
+ is_eager=True,
179
+ help="Show the Preflight version and exit.",
180
+ ),
181
+ ] = False,
182
+ ) -> None:
183
+ pass
184
+
185
+
186
+ @app.command(
187
+ name="analyze",
188
+ help="Analyze a Terraform plan JSON file or stdin stream for infrastructure risk.",
189
+ epilog=ANALYZE_EPILOG,
190
+ )
191
+ def analyze(
192
+ plan_path: Annotated[
193
+ str,
194
+ typer.Argument(
195
+ help="Path to Terraform plan JSON file or '-' to read from stdin pipe.",
196
+ show_default=False,
197
+ ),
198
+ ],
199
+ environment: Annotated[
200
+ str | None,
201
+ typer.Option(
202
+ "--environment",
203
+ "-e",
204
+ help="Target environment (development, staging, production).",
205
+ ),
206
+ ] = None,
207
+ format_type: Annotated[
208
+ str | None,
209
+ typer.Option(
210
+ "--format",
211
+ "-f",
212
+ help="Output format: terminal, json, markdown, or sarif.",
213
+ ),
214
+ ] = None,
215
+ fail_on: Annotated[
216
+ str | None,
217
+ typer.Option(
218
+ "--fail-on",
219
+ help="Block deployment if findings meet or exceed this severity (low, moderate, high, critical).",
220
+ ),
221
+ ] = None,
222
+ output: Annotated[
223
+ Path | None,
224
+ typer.Option(
225
+ "--output",
226
+ "-o",
227
+ help="File path to save the generated report.",
228
+ ),
229
+ ] = None,
230
+ config_path: Annotated[
231
+ Path | None,
232
+ typer.Option(
233
+ "--config",
234
+ "-c",
235
+ help="Path to preflight configuration file (YAML).",
236
+ ),
237
+ ] = None,
238
+ verbose: Annotated[
239
+ bool,
240
+ typer.Option(
241
+ "--verbose",
242
+ "-v",
243
+ help="Enable verbose output.",
244
+ ),
245
+ ] = False,
246
+ ) -> None:
247
+ """Analyze a Terraform plan JSON file and assess deployment risk."""
248
+ # 1. Load configuration
249
+ try:
250
+ config = load_config(config_path)
251
+ except ConfigError as exc:
252
+ err_console.print(f"[bold red]Configuration Error:[/bold red] {exc}")
253
+ sys.exit(2)
254
+
255
+ # 2. Merge CLI overrides with config file values
256
+ effective_env_str = environment or config.environment
257
+ try:
258
+ effective_env = Environment.from_str(effective_env_str)
259
+ except ValueError as exc:
260
+ err_console.print(f"[bold red]Environment Error:[/bold red] {exc}")
261
+ sys.exit(2)
262
+
263
+ effective_format = (format_type or config.format).strip().lower()
264
+ if effective_format not in ("terminal", "json", "markdown", "sarif"):
265
+ err_console.print(
266
+ f"[bold red]Invalid Format:[/bold red] '{effective_format}'. Valid options: terminal, json, markdown, sarif."
267
+ )
268
+ sys.exit(2)
269
+
270
+ effective_fail_on_str = fail_on or config.fail_on
271
+ effective_fail_on: Severity | None = None
272
+ if effective_fail_on_str:
273
+ try:
274
+ effective_fail_on = Severity.from_str(effective_fail_on_str)
275
+ except ValueError as exc:
276
+ err_console.print(f"[bold red]Policy Error:[/bold red] {exc}")
277
+ sys.exit(2)
278
+
279
+ # 3. Configure policy evaluator
280
+ policy_evaluator = PolicyEvaluator(
281
+ fail_on_severity=effective_fail_on,
282
+ blocked_rules=config.blocked_rules,
283
+ allowed_rules=config.allowed_rules,
284
+ max_allowed_score=config.max_score,
285
+ )
286
+
287
+ # 4. Run Analysis
288
+ engine = PreflightEngine()
289
+ try:
290
+ result = engine.analyze_file(
291
+ plan_path=plan_path,
292
+ environment=effective_env,
293
+ policy_evaluator=policy_evaluator,
294
+ )
295
+ except PlanParseError as exc:
296
+ err_console.print(f"[bold red]Plan Parse Error:[/bold red] {exc}")
297
+ sys.exit(2)
298
+ except Exception as exc:
299
+ err_console.print(f"[bold red]Execution Error:[/bold red] {exc}")
300
+ if verbose:
301
+ err_console.print_exception()
302
+ sys.exit(2)
303
+
304
+ # 5. Render Output
305
+ if effective_format == "json":
306
+ json_output = render_json_report(result)
307
+ if output:
308
+ try:
309
+ output.write_text(json_output, encoding="utf-8")
310
+ except OSError as exc:
311
+ err_console.print(
312
+ f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
313
+ )
314
+ sys.exit(2)
315
+ else:
316
+ typer.echo(json_output)
317
+
318
+ elif effective_format == "markdown":
319
+ md_output = render_markdown_report(result)
320
+ if output:
321
+ try:
322
+ output.write_text(md_output, encoding="utf-8")
323
+ except OSError as exc:
324
+ err_console.print(
325
+ f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
326
+ )
327
+ sys.exit(2)
328
+ else:
329
+ typer.echo(md_output)
330
+
331
+ elif effective_format == "sarif":
332
+ sarif_output = render_sarif_report(result)
333
+ if output:
334
+ try:
335
+ output.write_text(sarif_output, encoding="utf-8")
336
+ except OSError as exc:
337
+ err_console.print(
338
+ f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
339
+ )
340
+ sys.exit(2)
341
+ else:
342
+ typer.echo(sarif_output)
343
+
344
+ else: # terminal
345
+ if output:
346
+ try:
347
+ with output.open("w", encoding="utf-8") as f:
348
+ render_terminal_report(result, file=f)
349
+ except OSError as exc:
350
+ err_console.print(
351
+ f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
352
+ )
353
+ sys.exit(2)
354
+ else:
355
+ render_terminal_report(result)
356
+
357
+ # 6. Exit code
358
+ if result.policy_evaluation.blocked:
359
+ sys.exit(1)
360
+ sys.exit(0)
361
+
362
+
363
+ @app.command(
364
+ name="comment",
365
+ help="Analyze a plan and publish or update a sticky GitHub Pull Request comment.",
366
+ epilog=COMMENT_EPILOG,
367
+ )
368
+ def comment(
369
+ plan_path: Annotated[
370
+ str,
371
+ typer.Argument(
372
+ help="Path to Terraform plan JSON file or '-' to read from stdin pipe.",
373
+ show_default=False,
374
+ ),
375
+ ],
376
+ pr: Annotated[
377
+ int | None,
378
+ typer.Option(
379
+ "--pr",
380
+ "-p",
381
+ help="Pull request number (defaults to PR_NUMBER or GITHUB_REF in CI).",
382
+ ),
383
+ ] = None,
384
+ repo: Annotated[
385
+ str | None,
386
+ typer.Option(
387
+ "--repo",
388
+ "-R",
389
+ help="Target repository 'owner/repo' (defaults to GITHUB_REPOSITORY).",
390
+ ),
391
+ ] = None,
392
+ token: Annotated[
393
+ str | None,
394
+ typer.Option(
395
+ "--token",
396
+ "-t",
397
+ help="GitHub API token (defaults to GITHUB_TOKEN or GH_TOKEN).",
398
+ ),
399
+ ] = None,
400
+ environment: Annotated[
401
+ str | None,
402
+ typer.Option(
403
+ "--environment",
404
+ "-e",
405
+ help="Target environment (development, staging, production).",
406
+ ),
407
+ ] = None,
408
+ fail_on: Annotated[
409
+ str | None,
410
+ typer.Option(
411
+ "--fail-on",
412
+ help="Block deployment if findings meet or exceed this severity.",
413
+ ),
414
+ ] = None,
415
+ dry_run: Annotated[
416
+ bool,
417
+ typer.Option(
418
+ "--dry-run",
419
+ help="Render the comment markdown without calling the GitHub API.",
420
+ ),
421
+ ] = False,
422
+ output: Annotated[
423
+ Path | None,
424
+ typer.Option(
425
+ "--output",
426
+ "-o",
427
+ help="Save the rendered comment markdown to a file.",
428
+ ),
429
+ ] = None,
430
+ config_path: Annotated[
431
+ Path | None,
432
+ typer.Option(
433
+ "--config",
434
+ "-c",
435
+ help="Path to preflight configuration file (YAML).",
436
+ ),
437
+ ] = None,
438
+ verbose: Annotated[
439
+ bool,
440
+ typer.Option(
441
+ "--verbose",
442
+ "-v",
443
+ help="Enable verbose output.",
444
+ ),
445
+ ] = False,
446
+ ) -> None:
447
+ """Analyze a plan and publish or update a sticky GitHub Pull Request comment."""
448
+ try:
449
+ config = load_config(config_path)
450
+ except ConfigError as exc:
451
+ err_console.print(f"[bold red]Configuration Error:[/bold red] {exc}")
452
+ sys.exit(2)
453
+
454
+ effective_env_str = environment or config.environment
455
+ try:
456
+ effective_env = Environment.from_str(effective_env_str)
457
+ except ValueError as exc:
458
+ err_console.print(f"[bold red]Environment Error:[/bold red] {exc}")
459
+ sys.exit(2)
460
+
461
+ effective_fail_on_str = fail_on or config.fail_on
462
+ effective_fail_on: Severity | None = None
463
+ if effective_fail_on_str:
464
+ try:
465
+ effective_fail_on = Severity.from_str(effective_fail_on_str)
466
+ except ValueError as exc:
467
+ err_console.print(f"[bold red]Policy Error:[/bold red] {exc}")
468
+ sys.exit(2)
469
+
470
+ policy_evaluator = PolicyEvaluator(
471
+ fail_on_severity=effective_fail_on,
472
+ blocked_rules=config.blocked_rules,
473
+ allowed_rules=config.allowed_rules,
474
+ max_allowed_score=config.max_score,
475
+ )
476
+
477
+ engine = PreflightEngine()
478
+ try:
479
+ result = engine.analyze_file(
480
+ plan_path=plan_path,
481
+ environment=effective_env,
482
+ policy_evaluator=policy_evaluator,
483
+ )
484
+ except PlanParseError as exc:
485
+ err_console.print(f"[bold red]Plan Parse Error:[/bold red] {exc}")
486
+ sys.exit(2)
487
+ except Exception as exc:
488
+ err_console.print(f"[bold red]Execution Error:[/bold red] {exc}")
489
+ if verbose:
490
+ err_console.print_exception()
491
+ sys.exit(2)
492
+
493
+ comment_body = render_pr_comment(result)
494
+
495
+ if output:
496
+ try:
497
+ output.write_text(comment_body, encoding="utf-8")
498
+ except OSError as exc:
499
+ err_console.print(
500
+ f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
501
+ )
502
+ sys.exit(2)
503
+
504
+ if dry_run:
505
+ typer.echo(comment_body)
506
+ else:
507
+ try:
508
+ resolved_repo, resolved_pr, resolved_token = resolve_github_context(
509
+ repo=repo, pr=pr, token=token
510
+ )
511
+ resp = post_or_update_pr_comment(
512
+ repo=resolved_repo,
513
+ pr_number=resolved_pr,
514
+ token=resolved_token,
515
+ comment_body=comment_body,
516
+ )
517
+ action = resp.get("action", "posted")
518
+ url = resp.get("url", "")
519
+ err_console.print(
520
+ f"[bold green]PR Comment {action.capitalize()}:[/bold green] {resolved_repo}#{resolved_pr} {url}".strip()
521
+ )
522
+ except GitHubApiError as exc:
523
+ err_console.print(f"[bold red]GitHub Comment Error:[/bold red] {exc}")
524
+ sys.exit(2)
525
+
526
+ if result.policy_evaluation.blocked:
527
+ sys.exit(1)
528
+ sys.exit(0)
529
+
530
+
531
+ def main() -> None:
532
+ app()
533
+
534
+
535
+ if __name__ == "__main__":
536
+ main()
preflight/config.py ADDED
@@ -0,0 +1,107 @@
1
+ """Configuration management for Preflight."""
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+ from preflight.models import Environment, Severity
9
+
10
+ DEFAULT_CONFIG_FILENAMES = [
11
+ "preflight.yaml",
12
+ "preflight.yml",
13
+ ".preflight.yaml",
14
+ ".preflight.yml",
15
+ ]
16
+
17
+
18
+ class ConfigError(Exception):
19
+ """Raised when configuration cannot be loaded or is invalid."""
20
+
21
+
22
+ class PreflightConfig(BaseModel):
23
+ environment: str = Field(default="production", description="Target environment context")
24
+ fail_on: str | None = Field(default=None, description="Minimum severity to block deployment")
25
+ blocked_rules: list[str] = Field(
26
+ default_factory=list, description="Rules that unconditionally block"
27
+ )
28
+ allowed_rules: list[str] = Field(
29
+ default_factory=list, description="Rules to ignore or suppress"
30
+ )
31
+ max_score: float | None = Field(default=None, description="Maximum allowed plan risk score")
32
+ format: str = Field(default="terminal", description="Output report format")
33
+ verbose: bool = Field(default=False, description="Enable detailed output")
34
+
35
+ @field_validator("environment")
36
+ @classmethod
37
+ def validate_environment(cls, v: str) -> str:
38
+ try:
39
+ Environment.from_str(v)
40
+ return v.lower()
41
+ except ValueError as exc:
42
+ raise ValueError(str(exc)) from exc
43
+
44
+ @field_validator("fail_on")
45
+ @classmethod
46
+ def validate_fail_on(cls, v: str | None) -> str | None:
47
+ if v is None:
48
+ return None
49
+ try:
50
+ Severity.from_str(v)
51
+ return v.lower()
52
+ except ValueError as exc:
53
+ raise ValueError(str(exc)) from exc
54
+
55
+ @field_validator("format")
56
+ @classmethod
57
+ def validate_format(cls, v: str) -> str:
58
+ normalized = v.strip().lower()
59
+ if normalized not in ("terminal", "json", "markdown", "sarif"):
60
+ raise ValueError(
61
+ f"Invalid format '{v}'. Valid options: terminal, json, markdown, sarif."
62
+ )
63
+ return normalized
64
+
65
+ @field_validator("max_score")
66
+ @classmethod
67
+ def validate_max_score(cls, v: float | None) -> float | None:
68
+ if v is not None and (v < 0.0 or v > 100.0):
69
+ raise ValueError("max_score must be between 0.0 and 100.0.")
70
+ return v
71
+
72
+
73
+ def load_config(config_path: str | Path | None = None) -> PreflightConfig:
74
+ """Load and validate configuration from a file or discover the default."""
75
+ target_path: Path | None = None
76
+
77
+ if config_path is not None:
78
+ target_path = Path(config_path)
79
+ if not target_path.exists():
80
+ raise ConfigError(f"Config file not found: '{target_path}'.")
81
+ else:
82
+ for filename in DEFAULT_CONFIG_FILENAMES:
83
+ candidate = Path(filename)
84
+ if candidate.exists() and candidate.is_file():
85
+ target_path = candidate
86
+ break
87
+
88
+ if target_path is None:
89
+ return PreflightConfig()
90
+
91
+ try:
92
+ content = target_path.read_text(encoding="utf-8")
93
+ except OSError as exc:
94
+ raise ConfigError(f"Failed to read config file '{target_path}': {exc}") from exc
95
+
96
+ try:
97
+ raw_data = yaml.safe_load(content) or {}
98
+ except yaml.YAMLError as exc:
99
+ raise ConfigError(f"Invalid YAML syntax in '{target_path}': {exc}") from exc
100
+
101
+ if not isinstance(raw_data, dict):
102
+ raise ConfigError(f"Configuration in '{target_path}' must be a key-value mapping.")
103
+
104
+ try:
105
+ return PreflightConfig(**raw_data)
106
+ except Exception as exc:
107
+ raise ConfigError(f"Configuration validation error in '{target_path}': {exc}") from exc