envdrift 4.2.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.
- envdrift/__init__.py +30 -0
- envdrift/_version.py +34 -0
- envdrift/api.py +192 -0
- envdrift/cli.py +42 -0
- envdrift/cli_commands/__init__.py +1 -0
- envdrift/cli_commands/diff.py +91 -0
- envdrift/cli_commands/encryption.py +630 -0
- envdrift/cli_commands/encryption_helpers.py +93 -0
- envdrift/cli_commands/hook.py +75 -0
- envdrift/cli_commands/init_cmd.py +117 -0
- envdrift/cli_commands/partial.py +222 -0
- envdrift/cli_commands/sync.py +1140 -0
- envdrift/cli_commands/validate.py +109 -0
- envdrift/cli_commands/vault.py +376 -0
- envdrift/cli_commands/version.py +15 -0
- envdrift/config.py +489 -0
- envdrift/constants.json +18 -0
- envdrift/core/__init__.py +30 -0
- envdrift/core/diff.py +233 -0
- envdrift/core/encryption.py +400 -0
- envdrift/core/parser.py +260 -0
- envdrift/core/partial_encryption.py +239 -0
- envdrift/core/schema.py +253 -0
- envdrift/core/validator.py +312 -0
- envdrift/encryption/__init__.py +117 -0
- envdrift/encryption/base.py +217 -0
- envdrift/encryption/dotenvx.py +236 -0
- envdrift/encryption/sops.py +458 -0
- envdrift/env_files.py +60 -0
- envdrift/integrations/__init__.py +21 -0
- envdrift/integrations/dotenvx.py +689 -0
- envdrift/integrations/precommit.py +266 -0
- envdrift/integrations/sops.py +85 -0
- envdrift/output/__init__.py +21 -0
- envdrift/output/rich.py +424 -0
- envdrift/py.typed +0 -0
- envdrift/sync/__init__.py +26 -0
- envdrift/sync/config.py +218 -0
- envdrift/sync/engine.py +383 -0
- envdrift/sync/operations.py +138 -0
- envdrift/sync/result.py +99 -0
- envdrift/vault/__init__.py +107 -0
- envdrift/vault/aws.py +282 -0
- envdrift/vault/azure.py +170 -0
- envdrift/vault/base.py +150 -0
- envdrift/vault/gcp.py +210 -0
- envdrift/vault/hashicorp.py +238 -0
- envdrift-4.2.1.dist-info/METADATA +160 -0
- envdrift-4.2.1.dist-info/RECORD +52 -0
- envdrift-4.2.1.dist-info/WHEEL +4 -0
- envdrift-4.2.1.dist-info/entry_points.txt +2 -0
- envdrift-4.2.1.dist-info/licenses/LICENSE +21 -0
envdrift/output/rich.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Rich console formatting for envdrift output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from envdrift.core.diff import DiffResult, DiffType
|
|
14
|
+
from envdrift.core.encryption import EncryptionReport
|
|
15
|
+
from envdrift.core.schema import SchemaMetadata
|
|
16
|
+
from envdrift.core.validator import ValidationResult
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from envdrift.sync.result import ServiceSyncResult, SyncResult
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_success(message: str) -> None:
|
|
25
|
+
"""Print a success message."""
|
|
26
|
+
console.print(f"[green][OK][/green] {message}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def print_error(message: str) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Print a message prefixed with a red "ERROR" badge to the module console.
|
|
32
|
+
"""
|
|
33
|
+
console.print(f"[red][ERROR][/red] {message}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_warning(message: str) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Display a yellow "WARN" badge followed by the provided message to the shared console.
|
|
39
|
+
"""
|
|
40
|
+
console.print(f"[yellow][WARN][/yellow] {message}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def print_validation_result(
|
|
44
|
+
result: ValidationResult,
|
|
45
|
+
env_path: Path,
|
|
46
|
+
schema: SchemaMetadata,
|
|
47
|
+
verbose: bool = False,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Render a formatted validation report for an environment file against a schema using Rich console output.
|
|
51
|
+
|
|
52
|
+
Prints a header with the environment path and schema, then a PASS or FAIL status. When validation fails, prints any of the following sections as applicable: missing required variables (with schema descriptions when available), extra variables, unencrypted secrets, type errors, warnings, and — when `verbose` is true — missing optional variables (with defaults when available). Finally prints a summary of error and warning counts and a short hint to run with --fix.
|
|
53
|
+
|
|
54
|
+
Parameters:
|
|
55
|
+
result (ValidationResult): Validation outcome containing flags and lists of issues.
|
|
56
|
+
env_path (Path): Filesystem path to the validated environment file.
|
|
57
|
+
schema (SchemaMetadata): Schema metadata used for validation (fields, descriptions, defaults).
|
|
58
|
+
verbose (bool): If true, include missing optional variables and their default information.
|
|
59
|
+
"""
|
|
60
|
+
# Header
|
|
61
|
+
console.print()
|
|
62
|
+
console.print(
|
|
63
|
+
Panel(
|
|
64
|
+
f"[bold]Validating:[/bold] {env_path}\n"
|
|
65
|
+
f"[bold]Schema:[/bold] {schema.module_path}:{schema.class_name}",
|
|
66
|
+
title="envdrift validate",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if result.valid:
|
|
71
|
+
console.print()
|
|
72
|
+
console.print("[bold green]Validation PASSED[/bold green]")
|
|
73
|
+
else:
|
|
74
|
+
console.print()
|
|
75
|
+
console.print("[bold red]Validation FAILED[/bold red]")
|
|
76
|
+
console.print()
|
|
77
|
+
|
|
78
|
+
# Missing required variables
|
|
79
|
+
if result.missing_required:
|
|
80
|
+
console.print("[bold red]MISSING REQUIRED VARIABLES:[/bold red]")
|
|
81
|
+
for var in sorted(result.missing_required):
|
|
82
|
+
field_meta = schema.fields.get(var)
|
|
83
|
+
desc = f" - {field_meta.description}" if field_meta and field_meta.description else ""
|
|
84
|
+
console.print(f" [red]*[/red] {var}{desc}")
|
|
85
|
+
console.print()
|
|
86
|
+
|
|
87
|
+
# Extra variables
|
|
88
|
+
if result.extra_vars:
|
|
89
|
+
console.print("[bold yellow]EXTRA VARIABLES (not in schema):[/bold yellow]")
|
|
90
|
+
for var in sorted(result.extra_vars):
|
|
91
|
+
console.print(f" [yellow]*[/yellow] {var}")
|
|
92
|
+
console.print()
|
|
93
|
+
|
|
94
|
+
# Unencrypted secrets (warning, not error)
|
|
95
|
+
if result.unencrypted_secrets:
|
|
96
|
+
console.print("[bold yellow]UNENCRYPTED SECRETS (warning):[/bold yellow]")
|
|
97
|
+
for var in sorted(result.unencrypted_secrets):
|
|
98
|
+
console.print(f" [yellow]*[/yellow] {var} (marked sensitive but not encrypted)")
|
|
99
|
+
console.print("[dim] Run 'envdrift encrypt --check' for strict enforcement[/dim]")
|
|
100
|
+
console.print()
|
|
101
|
+
|
|
102
|
+
# Type errors
|
|
103
|
+
if result.type_errors:
|
|
104
|
+
console.print("[bold red]TYPE ERRORS:[/bold red]")
|
|
105
|
+
for var, error in sorted(result.type_errors.items()):
|
|
106
|
+
console.print(f" [red]*[/red] {var}: {error}")
|
|
107
|
+
console.print()
|
|
108
|
+
|
|
109
|
+
# Warnings
|
|
110
|
+
if result.warnings:
|
|
111
|
+
console.print("[bold yellow]WARNINGS:[/bold yellow]")
|
|
112
|
+
for warning in result.warnings:
|
|
113
|
+
console.print(f" [yellow]*[/yellow] {warning}")
|
|
114
|
+
console.print()
|
|
115
|
+
|
|
116
|
+
# Missing optional (verbose only)
|
|
117
|
+
if verbose and result.missing_optional:
|
|
118
|
+
console.print("[dim]MISSING OPTIONAL VARIABLES (have defaults):[/dim]")
|
|
119
|
+
for var in sorted(result.missing_optional):
|
|
120
|
+
field_meta = schema.fields.get(var)
|
|
121
|
+
has_default = field_meta and field_meta.default is not None
|
|
122
|
+
default = f" (default: {field_meta.default})" if has_default else ""
|
|
123
|
+
console.print(f" [dim]*[/dim] {var}{default}")
|
|
124
|
+
console.print()
|
|
125
|
+
|
|
126
|
+
# Summary (show if there are any issues)
|
|
127
|
+
err_count = result.error_count
|
|
128
|
+
warn_count = result.warning_count
|
|
129
|
+
if err_count > 0 or warn_count > 0:
|
|
130
|
+
console.print(f"[bold]Summary:[/bold] {err_count} error(s), {warn_count} warning(s)")
|
|
131
|
+
console.print()
|
|
132
|
+
if err_count > 0:
|
|
133
|
+
console.print("[dim]Run with --fix to generate template for missing variables.[/dim]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def print_diff_result(result: DiffResult, show_unchanged: bool = False) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Render a human-readable comparison of two environments to the shared console.
|
|
139
|
+
|
|
140
|
+
Prints a header showing the two environment paths, a table of variable differences (optionally including unchanged entries), and a concise summary of added/removed/changed counts with a drift notice when differences exist.
|
|
141
|
+
|
|
142
|
+
Parameters:
|
|
143
|
+
result (DiffResult): The computed diff between two environments, including paths, per-variable differences, and aggregate counts.
|
|
144
|
+
show_unchanged (bool): If True, include variables that are identical in both environments in the output; otherwise omit them.
|
|
145
|
+
"""
|
|
146
|
+
console.print()
|
|
147
|
+
console.print(
|
|
148
|
+
Panel(
|
|
149
|
+
f"[bold]Comparing:[/bold] {result.env1_path} vs {result.env2_path}",
|
|
150
|
+
title="envdrift diff",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if not result.has_drift:
|
|
155
|
+
console.print()
|
|
156
|
+
console.print("[bold green]No drift detected - environments match[/bold green]")
|
|
157
|
+
console.print()
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Create table
|
|
161
|
+
table = Table(show_header=True, header_style="bold")
|
|
162
|
+
table.add_column("Variable", style="cyan")
|
|
163
|
+
table.add_column(str(result.env1_path.name), style="dim")
|
|
164
|
+
table.add_column(str(result.env2_path.name), style="dim")
|
|
165
|
+
table.add_column("Status", justify="center")
|
|
166
|
+
|
|
167
|
+
for diff in result.differences:
|
|
168
|
+
if diff.diff_type == DiffType.UNCHANGED and not show_unchanged:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# Format status
|
|
172
|
+
if diff.diff_type == DiffType.ADDED:
|
|
173
|
+
status = Text("added", style="green")
|
|
174
|
+
value1 = Text("(missing)", style="dim")
|
|
175
|
+
value2 = Text(str(diff.value2) if diff.value2 else "", style="green")
|
|
176
|
+
elif diff.diff_type == DiffType.REMOVED:
|
|
177
|
+
status = Text("removed", style="red")
|
|
178
|
+
value1 = Text(str(diff.value1) if diff.value1 else "", style="red")
|
|
179
|
+
value2 = Text("(missing)", style="dim")
|
|
180
|
+
elif diff.diff_type == DiffType.CHANGED:
|
|
181
|
+
status = Text("changed", style="yellow")
|
|
182
|
+
value1 = Text(str(diff.value1) if diff.value1 else "", style="yellow")
|
|
183
|
+
value2 = Text(str(diff.value2) if diff.value2 else "", style="yellow")
|
|
184
|
+
else:
|
|
185
|
+
status = Text("unchanged", style="dim")
|
|
186
|
+
value1 = Text(str(diff.value1) if diff.value1 else "", style="dim")
|
|
187
|
+
value2 = Text(str(diff.value2) if diff.value2 else "", style="dim")
|
|
188
|
+
|
|
189
|
+
# Mark sensitive values
|
|
190
|
+
if diff.is_sensitive:
|
|
191
|
+
name_text = Text()
|
|
192
|
+
name_text.append(diff.name)
|
|
193
|
+
name_text.append(" (sensitive)", style="dim")
|
|
194
|
+
else:
|
|
195
|
+
name_text = Text(diff.name)
|
|
196
|
+
|
|
197
|
+
table.add_row(name_text, value1, value2, status)
|
|
198
|
+
|
|
199
|
+
console.print()
|
|
200
|
+
console.print(table)
|
|
201
|
+
console.print()
|
|
202
|
+
|
|
203
|
+
# Summary
|
|
204
|
+
summary_parts = []
|
|
205
|
+
if result.changed_count:
|
|
206
|
+
summary_parts.append(f"[yellow]{result.changed_count} changed[/yellow]")
|
|
207
|
+
if result.added_count:
|
|
208
|
+
summary_parts.append(f"[green]{result.added_count} added[/green]")
|
|
209
|
+
if result.removed_count:
|
|
210
|
+
summary_parts.append(f"[red]{result.removed_count} removed[/red]")
|
|
211
|
+
|
|
212
|
+
console.print(f"[bold]Summary:[/bold] {', '.join(summary_parts)}")
|
|
213
|
+
|
|
214
|
+
if result.has_drift:
|
|
215
|
+
console.print()
|
|
216
|
+
console.print("[yellow]Drift detected between environments[/yellow]")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def print_encryption_report(report: EncryptionReport) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Render a human-readable encryption summary for a file, including overall status, variable counts, detected plaintext secrets, warnings, and a remediation suggestion.
|
|
222
|
+
|
|
223
|
+
Parameters:
|
|
224
|
+
report (EncryptionReport): EncryptionReport for the inspected file (provides path, encryption ratios, lists of encrypted/plaintext/empty variables, plaintext secrets, and warnings).
|
|
225
|
+
"""
|
|
226
|
+
console.print()
|
|
227
|
+
console.print(
|
|
228
|
+
Panel(f"[bold]Encryption Status:[/bold] {report.path}", title="envdrift encrypt --check")
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Overall status
|
|
232
|
+
if report.is_fully_encrypted:
|
|
233
|
+
console.print()
|
|
234
|
+
console.print("[bold green]File is fully encrypted[/bold green]")
|
|
235
|
+
elif report.encrypted_vars and report.plaintext_vars:
|
|
236
|
+
console.print()
|
|
237
|
+
console.print("[bold yellow]File is partially encrypted[/bold yellow]")
|
|
238
|
+
else:
|
|
239
|
+
console.print()
|
|
240
|
+
console.print("[bold red]File is not encrypted[/bold red]")
|
|
241
|
+
|
|
242
|
+
console.print()
|
|
243
|
+
|
|
244
|
+
# Statistics
|
|
245
|
+
console.print("[bold]Variables:[/bold]")
|
|
246
|
+
console.print(f" Encrypted: {len(report.encrypted_vars)}")
|
|
247
|
+
console.print(f" Plaintext: {len(report.plaintext_vars)}")
|
|
248
|
+
console.print(f" Empty: {len(report.empty_vars)}")
|
|
249
|
+
console.print(f" Encryption ratio: {report.encryption_ratio:.0%}")
|
|
250
|
+
console.print()
|
|
251
|
+
|
|
252
|
+
# Plaintext secrets (critical)
|
|
253
|
+
if report.plaintext_secrets:
|
|
254
|
+
console.print("[bold red]PLAINTEXT SECRETS DETECTED:[/bold red]")
|
|
255
|
+
for var in sorted(report.plaintext_secrets):
|
|
256
|
+
console.print(f" [red]*[/red] {var}")
|
|
257
|
+
console.print()
|
|
258
|
+
|
|
259
|
+
# Warnings
|
|
260
|
+
if report.warnings:
|
|
261
|
+
console.print("[bold yellow]WARNINGS:[/bold yellow]")
|
|
262
|
+
for warning in report.warnings:
|
|
263
|
+
console.print(f" [yellow]*[/yellow] {warning}")
|
|
264
|
+
console.print()
|
|
265
|
+
|
|
266
|
+
# Recommendation
|
|
267
|
+
if report.plaintext_secrets:
|
|
268
|
+
console.print("[bold]Recommendation:[/bold]")
|
|
269
|
+
if report.detected_backend == "sops":
|
|
270
|
+
console.print(
|
|
271
|
+
f" Run: [cyan]envdrift encrypt --backend sops[/cyan] [dim]{report.path}[/dim]"
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
console.print(f" Run: [cyan]envdrift encrypt[/cyan] [dim]{report.path}[/dim]")
|
|
275
|
+
console.print()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def print_sync_summary(
|
|
279
|
+
services_processed: int,
|
|
280
|
+
created: int,
|
|
281
|
+
updated: int,
|
|
282
|
+
skipped: int,
|
|
283
|
+
errors: int,
|
|
284
|
+
) -> None:
|
|
285
|
+
"""
|
|
286
|
+
Prints a summary of vault synchronization results.
|
|
287
|
+
|
|
288
|
+
Parameters:
|
|
289
|
+
services_processed (int): Total number of services that were processed.
|
|
290
|
+
created (int): Number of new keys created.
|
|
291
|
+
updated (int): Number of keys updated.
|
|
292
|
+
skipped (int): Number of keys skipped because no change was needed.
|
|
293
|
+
errors (int): Number of services that failed during syncing.
|
|
294
|
+
"""
|
|
295
|
+
console.print()
|
|
296
|
+
console.print(
|
|
297
|
+
Panel(
|
|
298
|
+
f"[bold]Services processed:[/bold] {services_processed}\n"
|
|
299
|
+
f"[green]Created:[/green] {created}\n"
|
|
300
|
+
f"[yellow]Updated:[/yellow] {updated}\n"
|
|
301
|
+
f"[dim]Skipped:[/dim] {skipped}\n"
|
|
302
|
+
f"[red]Errors:[/red] {errors}",
|
|
303
|
+
title="Sync Summary",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if errors == 0:
|
|
308
|
+
console.print("[bold green]All services synced successfully[/bold green]")
|
|
309
|
+
else:
|
|
310
|
+
console.print(f"[bold red]{errors} service(s) failed[/bold red]")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def print_service_sync_status(result: ServiceSyncResult) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Print status for a single service sync operation.
|
|
316
|
+
|
|
317
|
+
Parameters:
|
|
318
|
+
result (ServiceSyncResult): Result of syncing a single service.
|
|
319
|
+
"""
|
|
320
|
+
from envdrift.sync.result import DecryptionTestResult, SyncAction
|
|
321
|
+
|
|
322
|
+
# Determine status icon and color
|
|
323
|
+
if result.action == SyncAction.CREATED:
|
|
324
|
+
icon = "[green]+[/green]"
|
|
325
|
+
status = "[green]created[/green]"
|
|
326
|
+
elif result.action == SyncAction.UPDATED:
|
|
327
|
+
icon = "[yellow]~[/yellow]"
|
|
328
|
+
status = "[yellow]updated[/yellow]"
|
|
329
|
+
elif result.action == SyncAction.SKIPPED:
|
|
330
|
+
icon = "[dim]=[/dim]"
|
|
331
|
+
status = "[dim]skipped[/dim]"
|
|
332
|
+
else: # ERROR
|
|
333
|
+
icon = "[red]x[/red]"
|
|
334
|
+
status = "[red]error[/red]"
|
|
335
|
+
|
|
336
|
+
console.print(f" {icon} {result.folder_path} - {status}")
|
|
337
|
+
|
|
338
|
+
# Show error details
|
|
339
|
+
if result.error:
|
|
340
|
+
console.print(f" [red]Error: {result.error}[/red]")
|
|
341
|
+
|
|
342
|
+
# Show mismatch preview
|
|
343
|
+
if result.local_value_preview and result.vault_value_preview:
|
|
344
|
+
if result.local_value_preview != result.vault_value_preview:
|
|
345
|
+
console.print(f" [dim]Local: {result.local_value_preview}[/dim]")
|
|
346
|
+
console.print(f" [dim]Vault: {result.vault_value_preview}[/dim]")
|
|
347
|
+
|
|
348
|
+
# Show backup path
|
|
349
|
+
if result.backup_path:
|
|
350
|
+
console.print(f" [dim]Backup: {result.backup_path}[/dim]")
|
|
351
|
+
|
|
352
|
+
# Show decryption test result
|
|
353
|
+
if result.decryption_result is not None:
|
|
354
|
+
if result.decryption_result == DecryptionTestResult.PASSED:
|
|
355
|
+
console.print(" [green]Decryption: PASSED[/green]")
|
|
356
|
+
elif result.decryption_result == DecryptionTestResult.FAILED:
|
|
357
|
+
console.print(" [red]Decryption: FAILED[/red]")
|
|
358
|
+
else:
|
|
359
|
+
console.print(" [dim]Decryption: skipped (no encrypted file)[/dim]")
|
|
360
|
+
|
|
361
|
+
# Show schema validation result
|
|
362
|
+
if result.schema_valid is not None:
|
|
363
|
+
if result.schema_valid:
|
|
364
|
+
console.print(" [green]Schema: valid[/green]")
|
|
365
|
+
else:
|
|
366
|
+
console.print(" [red]Schema: invalid[/red]")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def print_sync_result(result: SyncResult) -> None:
|
|
370
|
+
"""
|
|
371
|
+
Print complete sync results with summary.
|
|
372
|
+
|
|
373
|
+
Parameters:
|
|
374
|
+
result (SyncResult): Aggregate sync results.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
console.print()
|
|
378
|
+
|
|
379
|
+
# Build summary panel content
|
|
380
|
+
lines = [
|
|
381
|
+
f"[bold]Services processed:[/bold] {result.total_processed}",
|
|
382
|
+
f"[green]Created:[/green] {result.created_count}",
|
|
383
|
+
f"[yellow]Updated:[/yellow] {result.updated_count}",
|
|
384
|
+
f"[dim]Skipped:[/dim] {result.skipped_count}",
|
|
385
|
+
f"[red]Errors:[/red] {result.error_count}",
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
# Add decryption stats if any were tested
|
|
389
|
+
if result.decryption_tested > 0:
|
|
390
|
+
lines.append("")
|
|
391
|
+
lines.append("[bold]Decryption Tests:[/bold]")
|
|
392
|
+
lines.append(f" [green]Passed:[/green] {result.decryption_passed}")
|
|
393
|
+
lines.append(f" [red]Failed:[/red] {result.decryption_failed}")
|
|
394
|
+
skipped = result.decryption_tested - result.decryption_passed - result.decryption_failed
|
|
395
|
+
if skipped > 0:
|
|
396
|
+
lines.append(f" [dim]Skipped:[/dim] {skipped}")
|
|
397
|
+
|
|
398
|
+
console.print(Panel("\n".join(lines), title="Sync Summary"))
|
|
399
|
+
|
|
400
|
+
# Final status message
|
|
401
|
+
if result.has_errors:
|
|
402
|
+
console.print("[bold red]Sync completed with errors[/bold red]")
|
|
403
|
+
else:
|
|
404
|
+
console.print("[bold green]All services synced successfully[/bold green]")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def print_mismatch_warning(
|
|
408
|
+
service_name: str,
|
|
409
|
+
local_preview: str,
|
|
410
|
+
vault_preview: str,
|
|
411
|
+
) -> None:
|
|
412
|
+
"""
|
|
413
|
+
Print value mismatch warning.
|
|
414
|
+
|
|
415
|
+
Parameters:
|
|
416
|
+
service_name (str): Name of the service with mismatched values.
|
|
417
|
+
local_preview (str): Preview of local value (first N chars).
|
|
418
|
+
vault_preview (str): Preview of vault value (first N chars).
|
|
419
|
+
"""
|
|
420
|
+
console.print()
|
|
421
|
+
console.print(f"[bold yellow]VALUE MISMATCH: {service_name}[/bold yellow]")
|
|
422
|
+
console.print(f" Local: {local_preview}")
|
|
423
|
+
console.print(f" Vault: {vault_preview}")
|
|
424
|
+
console.print()
|
envdrift/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Vault sync module for synchronizing encryption keys from cloud vaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from envdrift.sync.config import ServiceMapping, SyncConfig
|
|
6
|
+
from envdrift.sync.engine import SyncEngine, SyncMode
|
|
7
|
+
from envdrift.sync.operations import EnvKeysFile, atomic_write
|
|
8
|
+
from envdrift.sync.result import (
|
|
9
|
+
DecryptionTestResult,
|
|
10
|
+
ServiceSyncResult,
|
|
11
|
+
SyncAction,
|
|
12
|
+
SyncResult,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DecryptionTestResult",
|
|
17
|
+
"EnvKeysFile",
|
|
18
|
+
"ServiceMapping",
|
|
19
|
+
"ServiceSyncResult",
|
|
20
|
+
"SyncAction",
|
|
21
|
+
"SyncConfig",
|
|
22
|
+
"SyncEngine",
|
|
23
|
+
"SyncMode",
|
|
24
|
+
"SyncResult",
|
|
25
|
+
"atomic_write",
|
|
26
|
+
]
|
envdrift/sync/config.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Sync configuration models and parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SyncConfigError(Exception):
|
|
12
|
+
"""Error loading sync configuration."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ServiceMapping:
|
|
19
|
+
"""Mapping of a vault secret to a local service folder."""
|
|
20
|
+
|
|
21
|
+
secret_name: str
|
|
22
|
+
folder_path: Path
|
|
23
|
+
vault_name: str | None = None
|
|
24
|
+
environment: str | None = None # Defaults to profile if set, else "production"
|
|
25
|
+
profile: str | None = None # Profile name for filtering (e.g., "local", "prod")
|
|
26
|
+
activate_to: Path | None = None # Path to copy decrypted file when profile is activated
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def effective_environment(self) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Return the effective environment.
|
|
32
|
+
|
|
33
|
+
Priority: explicit environment > profile > "production"
|
|
34
|
+
"""
|
|
35
|
+
if self.environment is not None:
|
|
36
|
+
return self.environment
|
|
37
|
+
if self.profile is not None:
|
|
38
|
+
return self.profile
|
|
39
|
+
return "production"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def env_key_name(self) -> str:
|
|
43
|
+
"""Return the environment key name (e.g., DOTENV_PRIVATE_KEY_PRODUCTION)."""
|
|
44
|
+
return f"DOTENV_PRIVATE_KEY_{self.effective_environment.upper()}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SyncConfig:
|
|
49
|
+
"""Complete sync configuration."""
|
|
50
|
+
|
|
51
|
+
mappings: list[ServiceMapping] = field(default_factory=list)
|
|
52
|
+
default_vault_name: str | None = None
|
|
53
|
+
env_keys_filename: str = ".env.keys"
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_file(cls, path: Path) -> SyncConfig:
|
|
57
|
+
"""
|
|
58
|
+
Load sync config from a pair.txt-style file.
|
|
59
|
+
|
|
60
|
+
Format:
|
|
61
|
+
# Comments start with #
|
|
62
|
+
secret-name=folder-path
|
|
63
|
+
vault-name/secret-name=folder-path
|
|
64
|
+
"""
|
|
65
|
+
if not path.exists():
|
|
66
|
+
raise SyncConfigError(f"Config file not found: {path}")
|
|
67
|
+
|
|
68
|
+
mappings: list[ServiceMapping] = []
|
|
69
|
+
|
|
70
|
+
with path.open() as f:
|
|
71
|
+
for line_num, line in enumerate(f, 1):
|
|
72
|
+
line = line.strip()
|
|
73
|
+
|
|
74
|
+
# Skip empty lines and comments
|
|
75
|
+
if not line or line.startswith("#"):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Parse key=value
|
|
79
|
+
if "=" not in line:
|
|
80
|
+
raise SyncConfigError(
|
|
81
|
+
f"Invalid format at line {line_num}: {line!r}. "
|
|
82
|
+
"Expected: secret-name=folder-path"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
secret_part, folder_path = line.split("=", 1)
|
|
86
|
+
secret_part = secret_part.strip()
|
|
87
|
+
folder_path = folder_path.strip()
|
|
88
|
+
|
|
89
|
+
if not secret_part or not folder_path:
|
|
90
|
+
raise SyncConfigError(f"Empty value at line {line_num}: {line!r}")
|
|
91
|
+
|
|
92
|
+
# Check for vault-name/secret-name format
|
|
93
|
+
if "/" in secret_part:
|
|
94
|
+
vault_name, secret_name = secret_part.split("/", 1)
|
|
95
|
+
vault_name = vault_name.strip()
|
|
96
|
+
secret_name = secret_name.strip()
|
|
97
|
+
else:
|
|
98
|
+
vault_name = None
|
|
99
|
+
secret_name = secret_part
|
|
100
|
+
|
|
101
|
+
mappings.append(
|
|
102
|
+
ServiceMapping(
|
|
103
|
+
secret_name=secret_name,
|
|
104
|
+
folder_path=Path(folder_path),
|
|
105
|
+
vault_name=vault_name,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return cls(mappings=mappings)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_toml(cls, data: dict[str, Any]) -> SyncConfig:
|
|
113
|
+
"""
|
|
114
|
+
Load sync config from TOML [vault.sync] section.
|
|
115
|
+
|
|
116
|
+
Format:
|
|
117
|
+
[vault.sync]
|
|
118
|
+
default_vault_name = "my-keyvault"
|
|
119
|
+
env_keys_filename = ".env.keys"
|
|
120
|
+
|
|
121
|
+
[[vault.sync.mappings]]
|
|
122
|
+
secret_name = "myapp-key"
|
|
123
|
+
folder_path = "services/myapp"
|
|
124
|
+
vault_name = "other-vault" # Optional
|
|
125
|
+
environment = "staging" # Optional
|
|
126
|
+
"""
|
|
127
|
+
mappings: list[ServiceMapping] = []
|
|
128
|
+
|
|
129
|
+
for mapping_data in data.get("mappings", []):
|
|
130
|
+
if "secret_name" not in mapping_data:
|
|
131
|
+
raise SyncConfigError("Missing 'secret_name' in mapping")
|
|
132
|
+
if "folder_path" not in mapping_data:
|
|
133
|
+
raise SyncConfigError("Missing 'folder_path' in mapping")
|
|
134
|
+
|
|
135
|
+
activate_to = mapping_data.get("activate_to")
|
|
136
|
+
mappings.append(
|
|
137
|
+
ServiceMapping(
|
|
138
|
+
secret_name=mapping_data["secret_name"],
|
|
139
|
+
folder_path=Path(mapping_data["folder_path"]),
|
|
140
|
+
vault_name=mapping_data.get("vault_name"),
|
|
141
|
+
environment=mapping_data.get("environment"), # None = use effective_environment
|
|
142
|
+
profile=mapping_data.get("profile"),
|
|
143
|
+
activate_to=Path(activate_to) if activate_to else None,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return cls(
|
|
148
|
+
mappings=mappings,
|
|
149
|
+
default_vault_name=data.get("default_vault_name"),
|
|
150
|
+
env_keys_filename=data.get("env_keys_filename", ".env.keys"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def get_effective_vault_name(self, mapping: ServiceMapping) -> str | None:
|
|
154
|
+
"""Get the effective vault name for a mapping (mapping override or default)."""
|
|
155
|
+
return mapping.vault_name or self.default_vault_name
|
|
156
|
+
|
|
157
|
+
def filter_by_profile(self, profile: str | None) -> list[ServiceMapping]:
|
|
158
|
+
"""
|
|
159
|
+
Filter mappings by profile.
|
|
160
|
+
|
|
161
|
+
If profile is None, returns only mappings without a profile (regular mappings).
|
|
162
|
+
If profile is specified, returns:
|
|
163
|
+
- All mappings without a profile (regular mappings)
|
|
164
|
+
- Plus the mapping that matches the specified profile
|
|
165
|
+
"""
|
|
166
|
+
if profile is None:
|
|
167
|
+
# No profile specified: return only non-profile mappings
|
|
168
|
+
return [m for m in self.mappings if m.profile is None]
|
|
169
|
+
|
|
170
|
+
# Profile specified: return non-profile mappings + matching profile
|
|
171
|
+
return [m for m in self.mappings if m.profile is None or m.profile == profile]
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def from_toml_file(cls, path: Path) -> SyncConfig:
|
|
175
|
+
"""
|
|
176
|
+
Load sync config from a TOML file.
|
|
177
|
+
|
|
178
|
+
Supports both standalone TOML files with [vault.sync] section
|
|
179
|
+
and pyproject.toml with [tool.envdrift.vault.sync] section.
|
|
180
|
+
|
|
181
|
+
Format:
|
|
182
|
+
[vault.sync]
|
|
183
|
+
default_vault_name = "my-keyvault"
|
|
184
|
+
env_keys_filename = ".env.keys"
|
|
185
|
+
|
|
186
|
+
[[vault.sync.mappings]]
|
|
187
|
+
secret_name = "myapp-key"
|
|
188
|
+
folder_path = "services/myapp"
|
|
189
|
+
vault_name = "other-vault" # Optional
|
|
190
|
+
environment = "staging" # Optional
|
|
191
|
+
"""
|
|
192
|
+
if not path.exists():
|
|
193
|
+
raise SyncConfigError(f"Config file not found: {path}")
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
with path.open("rb") as f:
|
|
197
|
+
data = tomllib.load(f)
|
|
198
|
+
except tomllib.TOMLDecodeError as e:
|
|
199
|
+
raise SyncConfigError(f"Invalid TOML syntax: {e}") from e
|
|
200
|
+
|
|
201
|
+
# Handle pyproject.toml with [tool.envdrift] structure
|
|
202
|
+
if path.name == "pyproject.toml":
|
|
203
|
+
tool_config = data.get("tool", {}).get("envdrift", {})
|
|
204
|
+
sync_data = tool_config.get("vault", {}).get("sync", {})
|
|
205
|
+
else:
|
|
206
|
+
# Standalone envdrift.toml or sync.toml
|
|
207
|
+
sync_data = data.get("vault", {}).get("sync", {})
|
|
208
|
+
# Also support top-level sync section for dedicated sync config files
|
|
209
|
+
if not sync_data and "mappings" in data:
|
|
210
|
+
sync_data = data
|
|
211
|
+
|
|
212
|
+
if not sync_data:
|
|
213
|
+
raise SyncConfigError(
|
|
214
|
+
f"No sync configuration found in {path}. "
|
|
215
|
+
"Expected [vault.sync] section with [[vault.sync.mappings]]"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return cls.from_toml(sync_data)
|