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/sync/engine.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Core sync orchestration engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess # nosec B404
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from envdrift.env_files import detect_env_file
|
|
14
|
+
from envdrift.sync.config import ServiceMapping, SyncConfig
|
|
15
|
+
from envdrift.sync.operations import EnvKeysFile, ensure_directory, preview_value
|
|
16
|
+
from envdrift.sync.result import (
|
|
17
|
+
DecryptionTestResult,
|
|
18
|
+
ServiceSyncResult,
|
|
19
|
+
SyncAction,
|
|
20
|
+
SyncResult,
|
|
21
|
+
)
|
|
22
|
+
from envdrift.vault.base import SecretNotFoundError, VaultError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from envdrift.vault import VaultClient
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SyncMode:
|
|
30
|
+
"""Sync operation mode."""
|
|
31
|
+
|
|
32
|
+
verify_only: bool = False
|
|
33
|
+
force_update: bool = False
|
|
34
|
+
check_decryption: bool = False
|
|
35
|
+
validate_schema: bool = False
|
|
36
|
+
schema_path: str | None = None
|
|
37
|
+
service_dir: Path | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SyncEngine:
|
|
42
|
+
"""Orchestrates vault-to-local key synchronization."""
|
|
43
|
+
|
|
44
|
+
config: SyncConfig
|
|
45
|
+
vault_client: VaultClient
|
|
46
|
+
mode: SyncMode = field(default_factory=SyncMode)
|
|
47
|
+
prompt_callback: Callable[[str], bool] | None = None
|
|
48
|
+
progress_callback: Callable[[str], None] | None = None
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
"""Set default callbacks if not provided."""
|
|
52
|
+
if self.prompt_callback is None:
|
|
53
|
+
self.prompt_callback = self._default_prompt
|
|
54
|
+
if self.progress_callback is None:
|
|
55
|
+
self.progress_callback = lambda _: None
|
|
56
|
+
|
|
57
|
+
def sync_all(self) -> SyncResult:
|
|
58
|
+
"""Sync all services defined in config."""
|
|
59
|
+
result = SyncResult()
|
|
60
|
+
|
|
61
|
+
self.vault_client.ensure_authenticated()
|
|
62
|
+
|
|
63
|
+
for mapping in self.config.mappings:
|
|
64
|
+
self._progress(f"Processing: {mapping.folder_path}")
|
|
65
|
+
service_result = self._sync_service(mapping)
|
|
66
|
+
result.services.append(service_result)
|
|
67
|
+
|
|
68
|
+
# Decryption test if enabled and sync succeeded
|
|
69
|
+
if self.mode.check_decryption and service_result.action != SyncAction.ERROR:
|
|
70
|
+
self._progress(f"Testing decryption: {mapping.folder_path}")
|
|
71
|
+
service_result.decryption_result = self._test_decryption(mapping)
|
|
72
|
+
|
|
73
|
+
# Schema validation if enabled
|
|
74
|
+
if self.mode.validate_schema and service_result.action != SyncAction.ERROR:
|
|
75
|
+
self._progress(f"Validating schema: {mapping.folder_path}")
|
|
76
|
+
service_result.schema_valid = self._validate_schema(mapping)
|
|
77
|
+
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
def _sync_service(self, mapping: ServiceMapping) -> ServiceSyncResult:
|
|
81
|
+
"""Sync a single service."""
|
|
82
|
+
try:
|
|
83
|
+
# Check if corresponding .env.<environment> file exists
|
|
84
|
+
env_file = mapping.folder_path / f".env.{mapping.effective_environment}"
|
|
85
|
+
effective_environment = mapping.effective_environment
|
|
86
|
+
|
|
87
|
+
if not env_file.exists():
|
|
88
|
+
# Try to auto-detect: find any .env.* file or plain .env in the folder
|
|
89
|
+
detected = self._detect_env_file(mapping.folder_path)
|
|
90
|
+
if detected:
|
|
91
|
+
env_file, effective_environment = detected
|
|
92
|
+
else:
|
|
93
|
+
return ServiceSyncResult(
|
|
94
|
+
secret_name=mapping.secret_name,
|
|
95
|
+
folder_path=mapping.folder_path,
|
|
96
|
+
action=SyncAction.SKIPPED,
|
|
97
|
+
message=f"No .env.{mapping.effective_environment} file found - skipping",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Use effective environment for key name
|
|
101
|
+
effective_key_name = f"DOTENV_PRIVATE_KEY_{effective_environment.upper()}"
|
|
102
|
+
|
|
103
|
+
# Fetch secret from vault
|
|
104
|
+
vault_value = self._fetch_vault_secret(mapping)
|
|
105
|
+
vault_preview = preview_value(vault_value)
|
|
106
|
+
|
|
107
|
+
# Ensure folder exists
|
|
108
|
+
if not mapping.folder_path.exists():
|
|
109
|
+
if self.mode.verify_only:
|
|
110
|
+
return ServiceSyncResult(
|
|
111
|
+
secret_name=mapping.secret_name,
|
|
112
|
+
folder_path=mapping.folder_path,
|
|
113
|
+
action=SyncAction.ERROR,
|
|
114
|
+
message="Folder does not exist",
|
|
115
|
+
error=f"Folder does not exist: {mapping.folder_path}",
|
|
116
|
+
)
|
|
117
|
+
ensure_directory(mapping.folder_path)
|
|
118
|
+
|
|
119
|
+
# Read local file
|
|
120
|
+
env_keys_path = mapping.folder_path / self.config.env_keys_filename
|
|
121
|
+
env_keys_file = EnvKeysFile(env_keys_path)
|
|
122
|
+
local_value = env_keys_file.read_key(effective_key_name)
|
|
123
|
+
local_preview = preview_value(local_value) if local_value else None
|
|
124
|
+
|
|
125
|
+
# Compare values
|
|
126
|
+
if local_value is None:
|
|
127
|
+
# Key doesn't exist - create
|
|
128
|
+
if self.mode.verify_only:
|
|
129
|
+
return ServiceSyncResult(
|
|
130
|
+
secret_name=mapping.secret_name,
|
|
131
|
+
folder_path=mapping.folder_path,
|
|
132
|
+
action=SyncAction.ERROR,
|
|
133
|
+
message="Key file does not exist",
|
|
134
|
+
vault_value_preview=vault_preview,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
env_keys_file.write_key(effective_key_name, vault_value, effective_environment)
|
|
138
|
+
return ServiceSyncResult(
|
|
139
|
+
secret_name=mapping.secret_name,
|
|
140
|
+
folder_path=mapping.folder_path,
|
|
141
|
+
action=SyncAction.CREATED,
|
|
142
|
+
message="Created new .env.keys file",
|
|
143
|
+
vault_value_preview=vault_preview,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
elif local_value == vault_value:
|
|
147
|
+
# Values match - skip
|
|
148
|
+
return ServiceSyncResult(
|
|
149
|
+
secret_name=mapping.secret_name,
|
|
150
|
+
folder_path=mapping.folder_path,
|
|
151
|
+
action=SyncAction.SKIPPED,
|
|
152
|
+
message="Values match - no update needed",
|
|
153
|
+
vault_value_preview=vault_preview,
|
|
154
|
+
local_value_preview=local_preview,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
else:
|
|
158
|
+
# Mismatch - update
|
|
159
|
+
if self.mode.verify_only:
|
|
160
|
+
return ServiceSyncResult(
|
|
161
|
+
secret_name=mapping.secret_name,
|
|
162
|
+
folder_path=mapping.folder_path,
|
|
163
|
+
action=SyncAction.ERROR,
|
|
164
|
+
message="Value mismatch detected",
|
|
165
|
+
vault_value_preview=vault_preview,
|
|
166
|
+
local_value_preview=local_preview,
|
|
167
|
+
error="Local value differs from vault",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Check if we should update
|
|
171
|
+
should_update = self.mode.force_update
|
|
172
|
+
if not should_update and self.prompt_callback:
|
|
173
|
+
prompt_msg = (
|
|
174
|
+
f"Value mismatch for {mapping.secret_name}:\n"
|
|
175
|
+
f" Local: {local_preview}\n"
|
|
176
|
+
f" Vault: {vault_preview}\n"
|
|
177
|
+
"Update local file with vault value?"
|
|
178
|
+
)
|
|
179
|
+
should_update = self.prompt_callback(prompt_msg)
|
|
180
|
+
|
|
181
|
+
if should_update:
|
|
182
|
+
# Create backup before updating
|
|
183
|
+
backup_path = env_keys_file.create_backup()
|
|
184
|
+
env_keys_file.write_key(effective_key_name, vault_value, effective_environment)
|
|
185
|
+
return ServiceSyncResult(
|
|
186
|
+
secret_name=mapping.secret_name,
|
|
187
|
+
folder_path=mapping.folder_path,
|
|
188
|
+
action=SyncAction.UPDATED,
|
|
189
|
+
message="Updated with vault value",
|
|
190
|
+
vault_value_preview=vault_preview,
|
|
191
|
+
local_value_preview=local_preview,
|
|
192
|
+
backup_path=backup_path,
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
return ServiceSyncResult(
|
|
196
|
+
secret_name=mapping.secret_name,
|
|
197
|
+
folder_path=mapping.folder_path,
|
|
198
|
+
action=SyncAction.SKIPPED,
|
|
199
|
+
message="Update skipped by user",
|
|
200
|
+
vault_value_preview=vault_preview,
|
|
201
|
+
local_value_preview=local_preview,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
except SecretNotFoundError as e:
|
|
205
|
+
return ServiceSyncResult(
|
|
206
|
+
secret_name=mapping.secret_name,
|
|
207
|
+
folder_path=mapping.folder_path,
|
|
208
|
+
action=SyncAction.ERROR,
|
|
209
|
+
message="Secret not found in vault",
|
|
210
|
+
error=str(e),
|
|
211
|
+
)
|
|
212
|
+
except VaultError as e:
|
|
213
|
+
return ServiceSyncResult(
|
|
214
|
+
secret_name=mapping.secret_name,
|
|
215
|
+
folder_path=mapping.folder_path,
|
|
216
|
+
action=SyncAction.ERROR,
|
|
217
|
+
message="Vault error",
|
|
218
|
+
error=str(e),
|
|
219
|
+
)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
return ServiceSyncResult(
|
|
222
|
+
secret_name=mapping.secret_name,
|
|
223
|
+
folder_path=mapping.folder_path,
|
|
224
|
+
action=SyncAction.ERROR,
|
|
225
|
+
message="Unexpected error",
|
|
226
|
+
error=str(e),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _fetch_vault_secret(self, mapping: ServiceMapping) -> str:
|
|
230
|
+
"""Fetch secret from vault."""
|
|
231
|
+
secret = self.vault_client.get_secret(mapping.secret_name)
|
|
232
|
+
value = secret.value
|
|
233
|
+
|
|
234
|
+
# Handle case where vault stores full line (KEY=value)
|
|
235
|
+
# Strip any DOTENV_PRIVATE_KEY_*= prefix, not just the current environment's
|
|
236
|
+
# Support uppercase, lowercase, digits in environment names (e.g., soak, local, prod)
|
|
237
|
+
pattern = r"^DOTENV_PRIVATE_KEY_[A-Za-z0-9_]+=(.+)$"
|
|
238
|
+
match = re.match(pattern, value)
|
|
239
|
+
if match:
|
|
240
|
+
value = match.group(1)
|
|
241
|
+
|
|
242
|
+
return value
|
|
243
|
+
|
|
244
|
+
def _detect_env_file(self, folder_path: Path) -> tuple[Path, str] | None:
|
|
245
|
+
"""
|
|
246
|
+
Auto-detect .env file in a folder.
|
|
247
|
+
|
|
248
|
+
Checks for:
|
|
249
|
+
1. Plain .env file (returns default environment)
|
|
250
|
+
2. Single .env.* file (returns environment from suffix)
|
|
251
|
+
|
|
252
|
+
Returns (env_file_path, environment_name) or None.
|
|
253
|
+
"""
|
|
254
|
+
detection = detect_env_file(folder_path)
|
|
255
|
+
if (
|
|
256
|
+
detection.status == "found"
|
|
257
|
+
and detection.path is not None
|
|
258
|
+
and detection.environment is not None
|
|
259
|
+
):
|
|
260
|
+
return (detection.path, detection.environment)
|
|
261
|
+
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def _test_decryption(self, mapping: ServiceMapping) -> DecryptionTestResult:
|
|
265
|
+
"""
|
|
266
|
+
Attempt to verify that the synchronized key can decrypt an environment file for the service.
|
|
267
|
+
|
|
268
|
+
The method locates an environment file for the mapping (preferring .env.<environment>, then .env.production, .env.staging, .env.development), checks whether the file appears encrypted, and uses the `dotenvx` utility to decrypt and then re-encrypt the file to confirm the key works. If decryption or re-encryption fails the file is restored to its original state before returning.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
DecryptionTestResult.PASSED if decryption and re-encryption both succeed.
|
|
272
|
+
DecryptionTestResult.FAILED if decryption or re-encryption fails (the original file is restored).
|
|
273
|
+
DecryptionTestResult.SKIPPED if no suitable env file exists, the file does not appear encrypted, or the `dotenvx` utility is not available.
|
|
274
|
+
"""
|
|
275
|
+
# Find .env file to test (prefer .env.<effective_environment>)
|
|
276
|
+
env_files = [
|
|
277
|
+
mapping.folder_path / f".env.{mapping.effective_environment}",
|
|
278
|
+
mapping.folder_path / ".env.production",
|
|
279
|
+
mapping.folder_path / ".env.staging",
|
|
280
|
+
mapping.folder_path / ".env.development",
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
target_file = next((f for f in env_files if f.exists()), None)
|
|
284
|
+
if not target_file:
|
|
285
|
+
return DecryptionTestResult.SKIPPED
|
|
286
|
+
|
|
287
|
+
# Check if file is encrypted (contains dotenvx markers)
|
|
288
|
+
content = target_file.read_text()
|
|
289
|
+
if "encrypted:" not in content.lower():
|
|
290
|
+
return DecryptionTestResult.SKIPPED
|
|
291
|
+
|
|
292
|
+
dotenvx_path = shutil.which("dotenvx")
|
|
293
|
+
if not dotenvx_path:
|
|
294
|
+
return DecryptionTestResult.SKIPPED
|
|
295
|
+
|
|
296
|
+
backup_path = target_file.with_suffix(".backup_decryption_test")
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
shutil.copy2(target_file, backup_path)
|
|
300
|
+
|
|
301
|
+
# Try to decrypt using dotenvx
|
|
302
|
+
result = subprocess.run( # nosec B603
|
|
303
|
+
[dotenvx_path, "decrypt", "-f", str(target_file)],
|
|
304
|
+
cwd=str(mapping.folder_path),
|
|
305
|
+
capture_output=True,
|
|
306
|
+
text=True,
|
|
307
|
+
timeout=30,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if result.returncode != 0:
|
|
311
|
+
# Restore from backup
|
|
312
|
+
shutil.copy2(backup_path, target_file)
|
|
313
|
+
return DecryptionTestResult.FAILED
|
|
314
|
+
|
|
315
|
+
# Re-encrypt to not leave file decrypted
|
|
316
|
+
encrypt_result = subprocess.run( # nosec B603
|
|
317
|
+
[dotenvx_path, "encrypt", "-f", str(target_file)],
|
|
318
|
+
cwd=str(mapping.folder_path),
|
|
319
|
+
capture_output=True,
|
|
320
|
+
text=True,
|
|
321
|
+
timeout=30,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if encrypt_result.returncode != 0:
|
|
325
|
+
shutil.copy2(backup_path, target_file)
|
|
326
|
+
return DecryptionTestResult.FAILED
|
|
327
|
+
|
|
328
|
+
return DecryptionTestResult.PASSED
|
|
329
|
+
|
|
330
|
+
except FileNotFoundError:
|
|
331
|
+
# dotenvx not installed
|
|
332
|
+
return DecryptionTestResult.SKIPPED
|
|
333
|
+
except subprocess.TimeoutExpired:
|
|
334
|
+
shutil.copy2(backup_path, target_file)
|
|
335
|
+
return DecryptionTestResult.FAILED
|
|
336
|
+
except Exception:
|
|
337
|
+
shutil.copy2(backup_path, target_file)
|
|
338
|
+
return DecryptionTestResult.FAILED
|
|
339
|
+
finally:
|
|
340
|
+
# Clean up backup
|
|
341
|
+
backup_path.unlink(missing_ok=True)
|
|
342
|
+
|
|
343
|
+
def _validate_schema(self, mapping: ServiceMapping) -> bool:
|
|
344
|
+
"""Run schema validation for the service."""
|
|
345
|
+
if not self.mode.schema_path:
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
from envdrift.core.schema import SchemaLoader
|
|
350
|
+
from envdrift.core.validator import EnvValidator
|
|
351
|
+
|
|
352
|
+
# Find env file
|
|
353
|
+
env_file = mapping.folder_path / f".env.{mapping.effective_environment}"
|
|
354
|
+
if not env_file.exists():
|
|
355
|
+
env_file = mapping.folder_path / ".env"
|
|
356
|
+
if not env_file.exists():
|
|
357
|
+
return True # No file to validate
|
|
358
|
+
|
|
359
|
+
# Load schema
|
|
360
|
+
service_dir = self.mode.service_dir or mapping.folder_path
|
|
361
|
+
loader = SchemaLoader()
|
|
362
|
+
settings_cls = loader.load(self.mode.schema_path, service_dir=service_dir)
|
|
363
|
+
schema = loader.extract_metadata(settings_cls)
|
|
364
|
+
|
|
365
|
+
# Validate
|
|
366
|
+
validator = EnvValidator(schema)
|
|
367
|
+
result = validator.validate(env_file)
|
|
368
|
+
|
|
369
|
+
return result.valid
|
|
370
|
+
|
|
371
|
+
except Exception:
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
def _progress(self, message: str) -> None:
|
|
375
|
+
"""Report progress."""
|
|
376
|
+
if self.progress_callback:
|
|
377
|
+
self.progress_callback(message)
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _default_prompt(message: str) -> bool:
|
|
381
|
+
"""Default interactive prompt."""
|
|
382
|
+
response = input(f"{message} (y/N): ").strip().lower()
|
|
383
|
+
return response in ("y", "yes")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Atomic file operations for sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# dotenvx header format
|
|
11
|
+
DOTENVX_HEADER = """#/------------------!DOTENV_PRIVATE_KEYS!-------------------\\#
|
|
12
|
+
#/ private decryption keys. DO NOT commit to source control \\#
|
|
13
|
+
#/ [how it works](https://dotenvx.com/encryption) \\#
|
|
14
|
+
#/----------------------------------------------------------\\#"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EnvKeysFile:
|
|
18
|
+
"""Read and write .env.keys files with dotenvx format preservation."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, path: Path):
|
|
21
|
+
"""Initialize with path to .env.keys file."""
|
|
22
|
+
self.path = path
|
|
23
|
+
|
|
24
|
+
def exists(self) -> bool:
|
|
25
|
+
"""Check if the file exists."""
|
|
26
|
+
return self.path.exists()
|
|
27
|
+
|
|
28
|
+
def read_key(self, key_name: str) -> str | None:
|
|
29
|
+
"""
|
|
30
|
+
Read a specific key value from the file.
|
|
31
|
+
|
|
32
|
+
Returns None if file doesn't exist or key not found.
|
|
33
|
+
"""
|
|
34
|
+
if not self.path.exists():
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
content = self.path.read_text()
|
|
38
|
+
pattern = rf"^{re.escape(key_name)}=(.+)$"
|
|
39
|
+
|
|
40
|
+
for line in content.splitlines():
|
|
41
|
+
match = re.match(pattern, line)
|
|
42
|
+
if match:
|
|
43
|
+
value = match.group(1).strip()
|
|
44
|
+
# Remove quotes if present
|
|
45
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
46
|
+
value.startswith("'") and value.endswith("'")
|
|
47
|
+
):
|
|
48
|
+
value = value[1:-1]
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def write_key(self, key_name: str, value: str, environment: str = "production") -> None:
|
|
54
|
+
"""
|
|
55
|
+
Write/update a key, preserving existing content and header.
|
|
56
|
+
|
|
57
|
+
Creates the file with proper header if it doesn't exist.
|
|
58
|
+
"""
|
|
59
|
+
if self.path.exists():
|
|
60
|
+
content = self.path.read_text()
|
|
61
|
+
lines = content.splitlines()
|
|
62
|
+
|
|
63
|
+
# Check if key already exists
|
|
64
|
+
key_pattern = rf"^{re.escape(key_name)}="
|
|
65
|
+
key_found = False
|
|
66
|
+
new_lines = []
|
|
67
|
+
|
|
68
|
+
for line in lines:
|
|
69
|
+
if re.match(key_pattern, line):
|
|
70
|
+
new_lines.append(f"{key_name}={value}")
|
|
71
|
+
key_found = True
|
|
72
|
+
else:
|
|
73
|
+
new_lines.append(line)
|
|
74
|
+
|
|
75
|
+
if not key_found:
|
|
76
|
+
# Add environment comment if not present
|
|
77
|
+
env_comment = f"# .env.{environment}"
|
|
78
|
+
if env_comment not in content:
|
|
79
|
+
new_lines.append(env_comment)
|
|
80
|
+
new_lines.append(f"{key_name}={value}")
|
|
81
|
+
|
|
82
|
+
new_content = "\n".join(new_lines)
|
|
83
|
+
if not new_content.endswith("\n"):
|
|
84
|
+
new_content += "\n"
|
|
85
|
+
|
|
86
|
+
atomic_write(self.path, new_content)
|
|
87
|
+
else:
|
|
88
|
+
# Create new file with header
|
|
89
|
+
content = f"{DOTENVX_HEADER}\n# .env.{environment}\n{key_name}={value}\n"
|
|
90
|
+
atomic_write(self.path, content)
|
|
91
|
+
|
|
92
|
+
def has_dotenvx_header(self) -> bool:
|
|
93
|
+
"""Check if file has the dotenvx header."""
|
|
94
|
+
if not self.path.exists():
|
|
95
|
+
return False
|
|
96
|
+
content = self.path.read_text()
|
|
97
|
+
return "DOTENV_PRIVATE_KEYS" in content
|
|
98
|
+
|
|
99
|
+
def create_backup(self) -> Path:
|
|
100
|
+
"""Create timestamped backup of the file."""
|
|
101
|
+
if not self.path.exists():
|
|
102
|
+
raise FileNotFoundError(f"Cannot backup non-existent file: {self.path}")
|
|
103
|
+
|
|
104
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
105
|
+
backup_path = self.path.parent / f"{self.path.name}.backup.{timestamp}"
|
|
106
|
+
shutil.copy2(self.path, backup_path)
|
|
107
|
+
return backup_path
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def atomic_write(path: Path, content: str, permissions: int = 0o600) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Write file atomically with proper permissions.
|
|
113
|
+
|
|
114
|
+
Uses a temporary file and rename to ensure atomicity.
|
|
115
|
+
"""
|
|
116
|
+
# Ensure parent directory exists
|
|
117
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
tmp_path = path.with_suffix(".tmp")
|
|
120
|
+
try:
|
|
121
|
+
tmp_path.write_text(content)
|
|
122
|
+
tmp_path.chmod(permissions)
|
|
123
|
+
tmp_path.replace(path)
|
|
124
|
+
except Exception:
|
|
125
|
+
tmp_path.unlink(missing_ok=True)
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ensure_directory(path: Path) -> None:
|
|
130
|
+
"""Create directory if it doesn't exist."""
|
|
131
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def preview_value(value: str, length: int = 32) -> str:
|
|
135
|
+
"""Return a preview of the value (first N chars + ...)."""
|
|
136
|
+
if len(value) <= length:
|
|
137
|
+
return value
|
|
138
|
+
return f"{value[:length]}..."
|
envdrift/sync/result.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Sync result models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SyncAction(Enum):
|
|
11
|
+
"""Action taken during sync."""
|
|
12
|
+
|
|
13
|
+
CREATED = "created"
|
|
14
|
+
UPDATED = "updated"
|
|
15
|
+
SKIPPED = "skipped"
|
|
16
|
+
ERROR = "error"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DecryptionTestResult(Enum):
|
|
20
|
+
"""Result of decryption test."""
|
|
21
|
+
|
|
22
|
+
PASSED = "passed"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
SKIPPED = "skipped"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ServiceSyncResult:
|
|
29
|
+
"""Result of syncing a single service."""
|
|
30
|
+
|
|
31
|
+
secret_name: str
|
|
32
|
+
folder_path: Path
|
|
33
|
+
action: SyncAction
|
|
34
|
+
message: str
|
|
35
|
+
vault_value_preview: str | None = None
|
|
36
|
+
local_value_preview: str | None = None
|
|
37
|
+
backup_path: Path | None = None
|
|
38
|
+
decryption_result: DecryptionTestResult | None = None
|
|
39
|
+
schema_valid: bool | None = None
|
|
40
|
+
error: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class SyncResult:
|
|
45
|
+
"""Aggregate sync results."""
|
|
46
|
+
|
|
47
|
+
services: list[ServiceSyncResult] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def total_processed(self) -> int:
|
|
51
|
+
"""Total number of services processed."""
|
|
52
|
+
return len(self.services)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def created_count(self) -> int:
|
|
56
|
+
"""Number of keys created."""
|
|
57
|
+
return sum(1 for s in self.services if s.action == SyncAction.CREATED)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def updated_count(self) -> int:
|
|
61
|
+
"""Number of keys updated."""
|
|
62
|
+
return sum(1 for s in self.services if s.action == SyncAction.UPDATED)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def skipped_count(self) -> int:
|
|
66
|
+
"""Number of keys skipped (no change needed)."""
|
|
67
|
+
return sum(1 for s in self.services if s.action == SyncAction.SKIPPED)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def error_count(self) -> int:
|
|
71
|
+
"""Number of services that failed."""
|
|
72
|
+
return sum(1 for s in self.services if s.action == SyncAction.ERROR)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def decryption_tested(self) -> int:
|
|
76
|
+
"""Number of services where decryption was tested."""
|
|
77
|
+
return sum(1 for s in self.services if s.decryption_result is not None)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def decryption_passed(self) -> int:
|
|
81
|
+
"""Number of services where decryption passed."""
|
|
82
|
+
return sum(1 for s in self.services if s.decryption_result == DecryptionTestResult.PASSED)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def decryption_failed(self) -> int:
|
|
86
|
+
"""Number of services where decryption failed."""
|
|
87
|
+
return sum(1 for s in self.services if s.decryption_result == DecryptionTestResult.FAILED)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def has_errors(self) -> bool:
|
|
91
|
+
"""Check if any errors occurred."""
|
|
92
|
+
return self.error_count > 0 or self.decryption_failed > 0
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def exit_code(self) -> int:
|
|
96
|
+
"""Return appropriate CI exit code."""
|
|
97
|
+
if self.error_count > 0 or self.decryption_failed > 0:
|
|
98
|
+
return 1
|
|
99
|
+
return 0
|