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
|
@@ -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
|
+
]
|