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,458 @@
1
+ """SOPS encryption backend implementation.
2
+
3
+ Mozilla SOPS (Secrets OPerationS) is a tool for encrypting values within files
4
+ while keeping the structure visible. It supports various key management systems
5
+ including AWS KMS, GCP KMS, Azure Key Vault, age, and PGP.
6
+
7
+ For .env files, SOPS encrypts the values while keeping the key names visible.
8
+ Encrypted values have the format: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import shutil
15
+ import subprocess # nosec B404
16
+ from pathlib import Path
17
+
18
+ from envdrift.encryption.base import (
19
+ EncryptionBackend,
20
+ EncryptionBackendError,
21
+ EncryptionNotFoundError,
22
+ EncryptionResult,
23
+ EncryptionStatus,
24
+ )
25
+
26
+
27
+ class SOPSEncryptionBackend(EncryptionBackend):
28
+ """Encryption backend using Mozilla SOPS.
29
+
30
+ SOPS encrypts values in-place while preserving file structure.
31
+ It supports multiple key management systems:
32
+ - AWS KMS
33
+ - GCP KMS
34
+ - Azure Key Vault
35
+ - age (modern, simple encryption)
36
+ - PGP
37
+
38
+ Configuration is typically stored in .sops.yaml in the project root.
39
+ """
40
+
41
+ # Pattern to match SOPS encrypted values
42
+ # Format: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
43
+ ENCRYPTED_PATTERN = re.compile(r"^ENC\[AES256_GCM,")
44
+
45
+ # Alternative SOPS patterns (for YAML/JSON metadata)
46
+ SOPS_METADATA_MARKERS = [
47
+ "sops:", # YAML metadata section
48
+ '"sops":', # JSON metadata section
49
+ "sops_version:", # Version marker in metadata
50
+ ]
51
+
52
+ def __init__(
53
+ self,
54
+ config_file: Path | str | None = None,
55
+ age_key: str | None = None,
56
+ age_key_file: Path | str | None = None,
57
+ auto_install: bool = False,
58
+ ):
59
+ """
60
+ Initialize the SOPS encryption backend.
61
+
62
+ Parameters:
63
+ config_file (Path | str | None): Path to .sops.yaml configuration file.
64
+ age_key (str | None): Age private key for decryption (can also be set via
65
+ SOPS_AGE_KEY environment variable).
66
+ age_key_file (Path | str | None): Path to age key file (SOPS_AGE_KEY_FILE).
67
+ auto_install (bool): If True, attempt to auto-install SOPS when missing.
68
+ """
69
+ self._config_file = Path(config_file) if config_file else None
70
+ self._age_key = age_key
71
+ self._age_key_file = Path(age_key_file) if age_key_file else None
72
+ self._auto_install = auto_install
73
+ self._binary_path: Path | None = None
74
+
75
+ @property
76
+ def name(self) -> str:
77
+ """Return backend name."""
78
+ return "sops"
79
+
80
+ @property
81
+ def encrypted_value_prefix(self) -> str:
82
+ """Return the prefix used to identify encrypted values."""
83
+ return "ENC["
84
+
85
+ def _find_binary(self) -> Path | None:
86
+ """Find the SOPS binary in PATH."""
87
+ if self._binary_path and self._binary_path.exists():
88
+ return self._binary_path
89
+
90
+ try:
91
+ from envdrift.integrations.sops import get_sops_path
92
+
93
+ venv_path = get_sops_path()
94
+ if venv_path.exists():
95
+ self._binary_path = venv_path
96
+ return self._binary_path
97
+ except RuntimeError:
98
+ pass
99
+
100
+ # Check system PATH
101
+ sops_path = shutil.which("sops")
102
+ if sops_path:
103
+ self._binary_path = Path(sops_path)
104
+ return self._binary_path
105
+
106
+ if self._auto_install:
107
+ from envdrift.integrations.sops import SopsInstaller, SopsInstallError
108
+
109
+ try:
110
+ installer = SopsInstaller()
111
+ self._binary_path = installer.install()
112
+ return self._binary_path
113
+ except SopsInstallError:
114
+ return None
115
+
116
+ return None
117
+
118
+ def is_installed(self) -> bool:
119
+ """Check if SOPS is installed."""
120
+ return self._find_binary() is not None
121
+
122
+ def get_version(self) -> str | None:
123
+ """Get the installed SOPS version."""
124
+ binary = self._find_binary()
125
+ if not binary:
126
+ return None
127
+
128
+ try:
129
+ result = subprocess.run( # nosec B603
130
+ [str(binary), "--version"],
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=10,
134
+ )
135
+ if result.returncode == 0:
136
+ # Output is like "sops 3.8.1 (latest)"
137
+ return result.stdout.strip().split()[1] if result.stdout else None
138
+ except Exception: # nosec B110
139
+ # Intentionally return None for any error during version check
140
+ return None
141
+ return None
142
+
143
+ def _build_env(self, env: dict[str, str] | None = None) -> dict[str, str]:
144
+ """Build environment dict with SOPS-specific variables."""
145
+ import os
146
+
147
+ result = dict(os.environ)
148
+ if env:
149
+ result.update(env)
150
+
151
+ # Add age key if configured
152
+ if self._age_key and "SOPS_AGE_KEY" not in result:
153
+ result["SOPS_AGE_KEY"] = self._age_key
154
+ if self._age_key_file and "SOPS_AGE_KEY_FILE" not in result:
155
+ result["SOPS_AGE_KEY_FILE"] = str(self._age_key_file)
156
+
157
+ return result
158
+
159
+ def _run(
160
+ self,
161
+ args: list[str],
162
+ env: dict[str, str] | None = None,
163
+ cwd: Path | str | None = None,
164
+ ) -> subprocess.CompletedProcess:
165
+ """Run SOPS command."""
166
+ binary = self._find_binary()
167
+ if not binary:
168
+ raise EncryptionNotFoundError(f"SOPS is not installed.\n{self.install_instructions()}")
169
+
170
+ cmd = [str(binary)]
171
+
172
+ # Add config file before positional args; SOPS treats late flags as extra paths.
173
+ if self._config_file and self._config_file.exists():
174
+ cmd.extend(["--config", str(self._config_file)])
175
+
176
+ cmd.extend(args)
177
+
178
+ try:
179
+ result = subprocess.run( # nosec B603
180
+ cmd,
181
+ capture_output=True,
182
+ text=True,
183
+ timeout=120,
184
+ env=self._build_env(env),
185
+ cwd=str(cwd) if cwd else None,
186
+ )
187
+ return result
188
+ except subprocess.TimeoutExpired as e:
189
+ raise EncryptionBackendError("SOPS command timed out") from e
190
+ except FileNotFoundError as e:
191
+ raise EncryptionNotFoundError(f"SOPS binary not found: {e}") from e
192
+
193
+ def encrypt(
194
+ self,
195
+ env_file: Path | str,
196
+ keys_file: Path | str | None = None,
197
+ **kwargs,
198
+ ) -> EncryptionResult:
199
+ """
200
+ Encrypt a .env file using SOPS.
201
+
202
+ Parameters:
203
+ env_file (Path | str): Path to the .env file to encrypt.
204
+ keys_file (Path | str | None): Not used for SOPS (keys come from .sops.yaml
205
+ or environment).
206
+ **kwargs: Additional options:
207
+ - env (dict): Environment variables to pass to subprocess.
208
+ - cwd (Path | str): Working directory for subprocess.
209
+ - in_place (bool): Encrypt in-place (default True).
210
+ - age_recipients (str): Age public keys for encryption.
211
+ - kms_arn (str): AWS KMS key ARN.
212
+ - gcp_kms (str): GCP KMS resource ID.
213
+ - azure_kv (str): Azure Key Vault key URL.
214
+
215
+ Returns:
216
+ EncryptionResult: Result of the encryption operation.
217
+ """
218
+ env_file = Path(env_file)
219
+
220
+ if not env_file.exists():
221
+ return EncryptionResult(
222
+ success=False,
223
+ message=f"File not found: {env_file}",
224
+ file_path=env_file,
225
+ )
226
+
227
+ if not self.is_installed():
228
+ raise EncryptionNotFoundError(f"SOPS is not installed.\n{self.install_instructions()}")
229
+
230
+ # Build SOPS arguments
231
+ args = ["--encrypt"]
232
+
233
+ # Add encryption key options if provided
234
+ if kwargs.get("age_recipients"):
235
+ args.extend(["--age", kwargs["age_recipients"]])
236
+ if kwargs.get("kms_arn"):
237
+ args.extend(["--kms", kwargs["kms_arn"]])
238
+ if kwargs.get("gcp_kms"):
239
+ args.extend(["--gcp-kms", kwargs["gcp_kms"]])
240
+ if kwargs.get("azure_kv"):
241
+ args.extend(["--azure-kv", kwargs["azure_kv"]])
242
+
243
+ # In-place encryption by default
244
+ in_place = kwargs.get("in_place", True)
245
+ if in_place:
246
+ args.append("--in-place")
247
+
248
+ # Specify input type for .env files
249
+ args.extend(["--input-type", "dotenv", "--output-type", "dotenv"])
250
+
251
+ args.append(str(env_file))
252
+
253
+ result = self._run(
254
+ args,
255
+ env=kwargs.get("env"),
256
+ cwd=kwargs.get("cwd"),
257
+ )
258
+
259
+ if result.returncode != 0:
260
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
261
+ raise EncryptionBackendError(f"SOPS encryption failed: {error_msg}")
262
+
263
+ return EncryptionResult(
264
+ success=True,
265
+ message=f"Encrypted {env_file}",
266
+ file_path=env_file,
267
+ )
268
+
269
+ def decrypt(
270
+ self,
271
+ env_file: Path | str,
272
+ keys_file: Path | str | None = None,
273
+ **kwargs,
274
+ ) -> EncryptionResult:
275
+ """
276
+ Decrypt a .env file using SOPS.
277
+
278
+ Parameters:
279
+ env_file (Path | str): Path to the .env file to decrypt.
280
+ keys_file (Path | str | None): Not used for SOPS.
281
+ **kwargs: Additional options:
282
+ - env (dict): Environment variables to pass to subprocess.
283
+ - cwd (Path | str): Working directory for subprocess.
284
+ - in_place (bool): Decrypt in-place (default True).
285
+ - output_file (Path | str): Write output to different file.
286
+
287
+ Returns:
288
+ EncryptionResult: Result of the decryption operation.
289
+ """
290
+ env_file = Path(env_file)
291
+
292
+ if not env_file.exists():
293
+ return EncryptionResult(
294
+ success=False,
295
+ message=f"File not found: {env_file}",
296
+ file_path=env_file,
297
+ )
298
+
299
+ if not self.is_installed():
300
+ raise EncryptionNotFoundError(f"SOPS is not installed.\n{self.install_instructions()}")
301
+
302
+ # Build SOPS arguments
303
+ args = ["--decrypt"]
304
+
305
+ # In-place decryption by default
306
+ in_place = kwargs.get("in_place", True)
307
+ output_file = kwargs.get("output_file")
308
+
309
+ if output_file:
310
+ args.extend(["--output", str(output_file)])
311
+ elif in_place:
312
+ args.append("--in-place")
313
+
314
+ # Specify input type for .env files
315
+ args.extend(["--input-type", "dotenv", "--output-type", "dotenv"])
316
+
317
+ args.append(str(env_file))
318
+
319
+ result = self._run(
320
+ args,
321
+ env=kwargs.get("env"),
322
+ cwd=kwargs.get("cwd"),
323
+ )
324
+
325
+ if result.returncode != 0:
326
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
327
+ raise EncryptionBackendError(f"SOPS decryption failed: {error_msg}")
328
+
329
+ output_path = Path(output_file) if output_file else env_file
330
+ return EncryptionResult(
331
+ success=True,
332
+ message=f"Decrypted {output_path}",
333
+ file_path=output_path,
334
+ )
335
+
336
+ def detect_encryption_status(self, value: str) -> EncryptionStatus:
337
+ """
338
+ Detect the encryption status of a value.
339
+
340
+ Parameters:
341
+ value (str): The unquoted value string to classify.
342
+
343
+ Returns:
344
+ EncryptionStatus: EMPTY if value is empty, ENCRYPTED if it matches
345
+ SOPS encrypted pattern (ENC[...), PLAINTEXT otherwise.
346
+ """
347
+ if not value:
348
+ return EncryptionStatus.EMPTY
349
+
350
+ if self.ENCRYPTED_PATTERN.match(value):
351
+ return EncryptionStatus.ENCRYPTED
352
+
353
+ return EncryptionStatus.PLAINTEXT
354
+
355
+ def has_encrypted_header(self, content: str) -> bool:
356
+ """
357
+ Check if file content contains SOPS encryption markers.
358
+
359
+ Parameters:
360
+ content (str): Raw file content to inspect.
361
+
362
+ Returns:
363
+ bool: True if SOPS encryption markers are present.
364
+ """
365
+ # Check for ENC[] encrypted values anywhere in content
366
+ # The pattern is re-created without anchoring to search anywhere
367
+ if "ENC[AES256_GCM," in content:
368
+ return True
369
+
370
+ # Check for SOPS metadata markers (in YAML/JSON files)
371
+ for marker in self.SOPS_METADATA_MARKERS:
372
+ if marker in content:
373
+ return True
374
+
375
+ return False
376
+
377
+ def install_instructions(self) -> str:
378
+ """Return installation instructions for SOPS."""
379
+ from envdrift.integrations.sops import SOPS_VERSION
380
+
381
+ return f"""
382
+ SOPS is not installed.
383
+
384
+ Installation options:
385
+
386
+ macOS (Homebrew):
387
+ brew install sops
388
+
389
+ Linux (apt):
390
+ # Download latest release from https://github.com/getsops/sops/releases
391
+ wget https://github.com/getsops/sops/releases/download/v{SOPS_VERSION}/sops-v{SOPS_VERSION}.linux.amd64
392
+ chmod +x sops-v{SOPS_VERSION}.linux.amd64
393
+ sudo mv sops-v{SOPS_VERSION}.linux.amd64 /usr/local/bin/sops
394
+
395
+ Windows (Chocolatey):
396
+ choco install sops
397
+
398
+ Optional auto-install:
399
+ Set [encryption.sops] auto_install = true in envdrift.toml
400
+
401
+ After installing SOPS, you'll also need to set up encryption keys.
402
+ Common options:
403
+ - age: Simple, modern encryption (recommended for local dev)
404
+ Install: brew install age # or download from https://github.com/FiloSottile/age
405
+ Generate key: age-keygen -o key.txt
406
+ Set env: export SOPS_AGE_KEY_FILE=key.txt
407
+
408
+ - AWS KMS: For AWS-based workflows
409
+ - GCP KMS: For Google Cloud workflows
410
+ - Azure Key Vault: For Azure workflows
411
+
412
+ See https://github.com/getsops/sops for full documentation.
413
+ """
414
+
415
+ def exec_env(
416
+ self,
417
+ env_file: Path | str,
418
+ command: list[str],
419
+ **kwargs,
420
+ ) -> subprocess.CompletedProcess:
421
+ """
422
+ Execute a command with decrypted environment variables.
423
+
424
+ SOPS supports running commands with decrypted secrets injected
425
+ as environment variables without writing them to disk.
426
+
427
+ Parameters:
428
+ env_file (Path | str): Path to the encrypted .env file.
429
+ command (list[str]): Command and arguments to execute.
430
+ **kwargs: Additional options:
431
+ - env (dict): Additional environment variables.
432
+ - cwd (Path | str): Working directory.
433
+
434
+ Returns:
435
+ subprocess.CompletedProcess: Result of the command execution.
436
+ """
437
+ env_file = Path(env_file)
438
+
439
+ if not env_file.exists():
440
+ raise EncryptionBackendError(f"File not found: {env_file}")
441
+
442
+ if not self.is_installed():
443
+ raise EncryptionNotFoundError(f"SOPS is not installed.\n{self.install_instructions()}")
444
+
445
+ # SOPS exec-env runs a command with secrets as environment variables
446
+ args = [
447
+ "exec-env",
448
+ "--input-type",
449
+ "dotenv",
450
+ str(env_file),
451
+ "--",
452
+ ] + command
453
+
454
+ return self._run(
455
+ args,
456
+ env=kwargs.get("env"),
457
+ cwd=kwargs.get("cwd"),
458
+ )
envdrift/env_files.py ADDED
@@ -0,0 +1,60 @@
1
+ """Helpers for detecting environment files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ EnvFileStatus = Literal["found", "folder_not_found", "multiple_found", "not_found"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class EnvFileDetection:
14
+ """Result of auto-detecting an env file in a folder."""
15
+
16
+ path: Path | None
17
+ environment: str | None
18
+ status: EnvFileStatus
19
+
20
+
21
+ def detect_env_file(folder_path: Path, default_environment: str = "production") -> EnvFileDetection:
22
+ """
23
+ Auto-detect .env file in a folder.
24
+
25
+ Checks for:
26
+ 1. Plain .env file (returns default environment)
27
+ 2. Single .env.* file (returns environment from suffix)
28
+
29
+ Returns an EnvFileDetection with status:
30
+ - "found": env file found
31
+ - "folder_not_found": folder doesn't exist
32
+ - "multiple_found": multiple .env.* files exist (ambiguous)
33
+ - "not_found": no env files found
34
+ """
35
+ if not folder_path.exists():
36
+ return EnvFileDetection(None, None, "folder_not_found")
37
+
38
+ # First, check for plain .env file
39
+ plain_env = folder_path / ".env"
40
+ if plain_env.exists() and plain_env.is_file():
41
+ return EnvFileDetection(plain_env, default_environment, "found")
42
+
43
+ # Find all .env.* files, excluding special files
44
+ exclude_patterns = {".env.keys", ".env.example", ".env.sample", ".env.template"}
45
+ env_files = []
46
+
47
+ for f in folder_path.iterdir():
48
+ if f.is_file() and f.name.startswith(".env.") and f.name not in exclude_patterns:
49
+ env_files.append(f)
50
+
51
+ if len(env_files) == 1:
52
+ env_file = env_files[0]
53
+ # Extract environment from filename: .env.soak -> soak
54
+ environment = env_file.name[5:] # Remove ".env." prefix
55
+ return EnvFileDetection(env_file, environment, "found")
56
+
57
+ if len(env_files) > 1:
58
+ return EnvFileDetection(None, None, "multiple_found")
59
+
60
+ return EnvFileDetection(None, None, "not_found")
@@ -0,0 +1,21 @@
1
+ """Integration modules for external tools."""
2
+
3
+ from envdrift.integrations.dotenvx import (
4
+ DotenvxError,
5
+ DotenvxInstaller,
6
+ DotenvxNotFoundError,
7
+ DotenvxWrapper,
8
+ )
9
+ from envdrift.integrations.precommit import get_hook_config, install_hooks
10
+ from envdrift.integrations.sops import SopsInstaller, SopsInstallError
11
+
12
+ __all__ = [
13
+ "DotenvxError",
14
+ "DotenvxInstaller",
15
+ "DotenvxNotFoundError",
16
+ "DotenvxWrapper",
17
+ "SopsInstallError",
18
+ "SopsInstaller",
19
+ "get_hook_config",
20
+ "install_hooks",
21
+ ]