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.
Files changed (52) hide show
  1. envdrift/__init__.py +30 -0
  2. envdrift/_version.py +34 -0
  3. envdrift/api.py +192 -0
  4. envdrift/cli.py +42 -0
  5. envdrift/cli_commands/__init__.py +1 -0
  6. envdrift/cli_commands/diff.py +91 -0
  7. envdrift/cli_commands/encryption.py +630 -0
  8. envdrift/cli_commands/encryption_helpers.py +93 -0
  9. envdrift/cli_commands/hook.py +75 -0
  10. envdrift/cli_commands/init_cmd.py +117 -0
  11. envdrift/cli_commands/partial.py +222 -0
  12. envdrift/cli_commands/sync.py +1140 -0
  13. envdrift/cli_commands/validate.py +109 -0
  14. envdrift/cli_commands/vault.py +376 -0
  15. envdrift/cli_commands/version.py +15 -0
  16. envdrift/config.py +489 -0
  17. envdrift/constants.json +18 -0
  18. envdrift/core/__init__.py +30 -0
  19. envdrift/core/diff.py +233 -0
  20. envdrift/core/encryption.py +400 -0
  21. envdrift/core/parser.py +260 -0
  22. envdrift/core/partial_encryption.py +239 -0
  23. envdrift/core/schema.py +253 -0
  24. envdrift/core/validator.py +312 -0
  25. envdrift/encryption/__init__.py +117 -0
  26. envdrift/encryption/base.py +217 -0
  27. envdrift/encryption/dotenvx.py +236 -0
  28. envdrift/encryption/sops.py +458 -0
  29. envdrift/env_files.py +60 -0
  30. envdrift/integrations/__init__.py +21 -0
  31. envdrift/integrations/dotenvx.py +689 -0
  32. envdrift/integrations/precommit.py +266 -0
  33. envdrift/integrations/sops.py +85 -0
  34. envdrift/output/__init__.py +21 -0
  35. envdrift/output/rich.py +424 -0
  36. envdrift/py.typed +0 -0
  37. envdrift/sync/__init__.py +26 -0
  38. envdrift/sync/config.py +218 -0
  39. envdrift/sync/engine.py +383 -0
  40. envdrift/sync/operations.py +138 -0
  41. envdrift/sync/result.py +99 -0
  42. envdrift/vault/__init__.py +107 -0
  43. envdrift/vault/aws.py +282 -0
  44. envdrift/vault/azure.py +170 -0
  45. envdrift/vault/base.py +150 -0
  46. envdrift/vault/gcp.py +210 -0
  47. envdrift/vault/hashicorp.py +238 -0
  48. envdrift-4.2.1.dist-info/METADATA +160 -0
  49. envdrift-4.2.1.dist-info/RECORD +52 -0
  50. envdrift-4.2.1.dist-info/WHEEL +4 -0
  51. envdrift-4.2.1.dist-info/entry_points.txt +2 -0
  52. envdrift-4.2.1.dist-info/licenses/LICENSE +21 -0
@@ -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]}..."
@@ -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