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,93 @@
|
|
|
1
|
+
"""Helpers for resolving encryption backends from config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from envdrift.config import ConfigNotFoundError, find_config, load_config
|
|
11
|
+
from envdrift.encryption import EncryptionProvider, get_encryption_backend
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from envdrift.config import EncryptionConfig
|
|
15
|
+
from envdrift.encryption.base import EncryptionBackend
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_encryption_backend(
|
|
21
|
+
config_file: Path | None,
|
|
22
|
+
) -> tuple[EncryptionBackend, EncryptionProvider, EncryptionConfig | None]:
|
|
23
|
+
"""
|
|
24
|
+
Resolve the encryption backend using an explicit config file or auto-discovery.
|
|
25
|
+
|
|
26
|
+
Returns the instantiated backend, selected provider, and the encryption config
|
|
27
|
+
(if available).
|
|
28
|
+
"""
|
|
29
|
+
config_path = None
|
|
30
|
+
if config_file is not None and config_file.suffix.lower() == ".toml":
|
|
31
|
+
config_path = config_file
|
|
32
|
+
elif config_file is None:
|
|
33
|
+
config_path = find_config()
|
|
34
|
+
|
|
35
|
+
envdrift_config = None
|
|
36
|
+
if config_path:
|
|
37
|
+
try:
|
|
38
|
+
envdrift_config = load_config(config_path)
|
|
39
|
+
except (ConfigNotFoundError, tomllib.TOMLDecodeError) as exc:
|
|
40
|
+
logger.warning("Failed to load config from %s: %s", config_path, exc)
|
|
41
|
+
envdrift_config = None
|
|
42
|
+
|
|
43
|
+
encryption_config = getattr(envdrift_config, "encryption", None) if envdrift_config else None
|
|
44
|
+
backend_name = encryption_config.backend if encryption_config else "dotenvx"
|
|
45
|
+
provider = EncryptionProvider(backend_name)
|
|
46
|
+
|
|
47
|
+
backend_config: dict[str, object] = {}
|
|
48
|
+
if provider == EncryptionProvider.DOTENVX:
|
|
49
|
+
backend_config["auto_install"] = (
|
|
50
|
+
encryption_config.dotenvx_auto_install if encryption_config else False
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
backend_config["auto_install"] = (
|
|
54
|
+
encryption_config.sops_auto_install if encryption_config else False
|
|
55
|
+
)
|
|
56
|
+
if encryption_config:
|
|
57
|
+
if encryption_config.sops_config_file:
|
|
58
|
+
backend_config["config_file"] = encryption_config.sops_config_file
|
|
59
|
+
if encryption_config.sops_age_key_file:
|
|
60
|
+
backend_config["age_key_file"] = encryption_config.sops_age_key_file
|
|
61
|
+
|
|
62
|
+
backend = get_encryption_backend(provider, **backend_config)
|
|
63
|
+
return backend, provider, encryption_config
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_sops_encrypt_kwargs(encryption_config: EncryptionConfig | None) -> dict[str, str]:
|
|
67
|
+
"""Build SOPS encryption kwargs from config."""
|
|
68
|
+
if not encryption_config:
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
kwargs: dict[str, str] = {}
|
|
72
|
+
if encryption_config.sops_age_recipients:
|
|
73
|
+
kwargs["age_recipients"] = encryption_config.sops_age_recipients
|
|
74
|
+
if encryption_config.sops_kms_arn:
|
|
75
|
+
kwargs["kms_arn"] = encryption_config.sops_kms_arn
|
|
76
|
+
if encryption_config.sops_gcp_kms:
|
|
77
|
+
kwargs["gcp_kms"] = encryption_config.sops_gcp_kms
|
|
78
|
+
if encryption_config.sops_azure_kv:
|
|
79
|
+
kwargs["azure_kv"] = encryption_config.sops_azure_kv
|
|
80
|
+
return kwargs
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_encrypted_content(
|
|
84
|
+
provider: EncryptionProvider,
|
|
85
|
+
backend: EncryptionBackend,
|
|
86
|
+
content: str,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Determine if file content is encrypted for the selected backend."""
|
|
89
|
+
if backend.has_encrypted_header(content):
|
|
90
|
+
return True
|
|
91
|
+
if provider == EncryptionProvider.DOTENVX:
|
|
92
|
+
return "encrypted:" in content.lower()
|
|
93
|
+
return False
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Pre-commit hook command for envdrift."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from envdrift.output.rich import console, print_error, print_success
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def hook(
|
|
13
|
+
install: Annotated[
|
|
14
|
+
bool, typer.Option("--install", "-i", help="Install pre-commit hook")
|
|
15
|
+
] = False,
|
|
16
|
+
show_config: Annotated[
|
|
17
|
+
bool, typer.Option("--config", help="Show pre-commit config snippet")
|
|
18
|
+
] = False,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Manage the pre-commit hook integration by showing a sample config or installing hooks.
|
|
22
|
+
|
|
23
|
+
When invoked with --config or without --install, prints a pre-commit configuration snippet for envdrift hooks.
|
|
24
|
+
When invoked with --install, attempts to install the hooks using the pre-commit integration and prints success on completion.
|
|
25
|
+
|
|
26
|
+
Parameters:
|
|
27
|
+
install (bool): If True, install the pre-commit hooks into the project (--install / -i).
|
|
28
|
+
show_config (bool): If True, print the sample pre-commit configuration snippet (--config).
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
typer.Exit: If installation is requested but the pre-commit integration is unavailable.
|
|
32
|
+
"""
|
|
33
|
+
if show_config or (not install):
|
|
34
|
+
hook_config = """# Add to .pre-commit-config.yaml
|
|
35
|
+
repos:
|
|
36
|
+
- repo: local
|
|
37
|
+
hooks:
|
|
38
|
+
- id: envdrift-validate
|
|
39
|
+
name: Validate env files
|
|
40
|
+
entry: envdrift validate --ci
|
|
41
|
+
language: system
|
|
42
|
+
files: ^\\.env\\.(production|staging|development)$
|
|
43
|
+
pass_filenames: true
|
|
44
|
+
|
|
45
|
+
- id: envdrift-encryption
|
|
46
|
+
name: Check env encryption
|
|
47
|
+
entry: envdrift encrypt --check
|
|
48
|
+
language: system
|
|
49
|
+
files: ^\\.env\\.(production|staging)$
|
|
50
|
+
pass_filenames: true
|
|
51
|
+
|
|
52
|
+
# Optional: Verify encryption keys match vault (prevents key drift)
|
|
53
|
+
# - id: envdrift-vault-verify
|
|
54
|
+
# name: Verify vault key can decrypt
|
|
55
|
+
# entry: envdrift decrypt --verify-vault -p azure --vault-url https://myvault.vault.azure.net --secret myapp-dotenvx-key --ci
|
|
56
|
+
# language: system
|
|
57
|
+
# files: ^\\.env\\.production$
|
|
58
|
+
# pass_filenames: true
|
|
59
|
+
"""
|
|
60
|
+
console.print(hook_config)
|
|
61
|
+
|
|
62
|
+
if not install:
|
|
63
|
+
console.print("[dim]Use --install to add hooks to .pre-commit-config.yaml[/dim]")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
if install:
|
|
67
|
+
try:
|
|
68
|
+
from envdrift.integrations.precommit import install_hooks
|
|
69
|
+
|
|
70
|
+
install_hooks()
|
|
71
|
+
print_success("Pre-commit hooks installed")
|
|
72
|
+
except ImportError:
|
|
73
|
+
print_error("Pre-commit integration not available")
|
|
74
|
+
console.print("Copy the config above to .pre-commit-config.yaml manually")
|
|
75
|
+
raise typer.Exit(code=1) from None
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Schema generation command for envdrift."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from envdrift.core.encryption import EncryptionDetector
|
|
11
|
+
from envdrift.core.parser import EnvParser
|
|
12
|
+
from envdrift.output.rich import console, print_error, print_success
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def init(
|
|
16
|
+
env_file: Annotated[
|
|
17
|
+
Path, typer.Argument(help="Path to .env file to generate schema from")
|
|
18
|
+
] = Path(".env"),
|
|
19
|
+
output: Annotated[
|
|
20
|
+
Path, typer.Option("--output", "-o", help="Output file for Settings class")
|
|
21
|
+
] = Path("settings.py"),
|
|
22
|
+
class_name: Annotated[
|
|
23
|
+
str, typer.Option("--class-name", "-c", help="Name for the Settings class")
|
|
24
|
+
] = "Settings",
|
|
25
|
+
detect_sensitive: Annotated[
|
|
26
|
+
bool, typer.Option("--detect-sensitive", help="Auto-detect sensitive variables")
|
|
27
|
+
] = True,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Generate a Pydantic BaseSettings subclass from variables in an .env file.
|
|
31
|
+
|
|
32
|
+
Writes a Python module containing a Pydantic `BaseSettings` subclass with fields
|
|
33
|
+
inferred from the .env variables. Detected sensitive variables are annotated
|
|
34
|
+
with `json_schema_extra={"sensitive": True}` and fields without a sensible
|
|
35
|
+
default are left required.
|
|
36
|
+
|
|
37
|
+
Parameters:
|
|
38
|
+
env_file (Path): Path to the source .env file.
|
|
39
|
+
output (Path): Path to write the generated Python module (e.g., settings.py).
|
|
40
|
+
class_name (str): Name to use for the generated `BaseSettings` subclass.
|
|
41
|
+
detect_sensitive (bool): If true, attempt to auto-detect sensitive variables
|
|
42
|
+
(by name and value) and mark them in the generated fields.
|
|
43
|
+
"""
|
|
44
|
+
if not env_file.exists():
|
|
45
|
+
print_error(f"ENV file not found: {env_file}")
|
|
46
|
+
raise typer.Exit(code=1)
|
|
47
|
+
|
|
48
|
+
# Parse env file
|
|
49
|
+
parser = EnvParser()
|
|
50
|
+
env = parser.parse(env_file)
|
|
51
|
+
|
|
52
|
+
# Detect sensitive variables if requested
|
|
53
|
+
detector = EncryptionDetector()
|
|
54
|
+
sensitive_vars = set()
|
|
55
|
+
if detect_sensitive:
|
|
56
|
+
for var_name, env_var in env.variables.items():
|
|
57
|
+
is_name_sens = detector.is_name_sensitive(var_name)
|
|
58
|
+
is_val_susp = detector.is_value_suspicious(env_var.value)
|
|
59
|
+
if is_name_sens or is_val_susp:
|
|
60
|
+
sensitive_vars.add(var_name)
|
|
61
|
+
|
|
62
|
+
# Generate settings class
|
|
63
|
+
lines = [
|
|
64
|
+
'"""Auto-generated Pydantic Settings class."""',
|
|
65
|
+
"",
|
|
66
|
+
"from pydantic import Field",
|
|
67
|
+
"from pydantic_settings import BaseSettings, SettingsConfigDict",
|
|
68
|
+
"",
|
|
69
|
+
"",
|
|
70
|
+
f"class {class_name}(BaseSettings):",
|
|
71
|
+
f' """Settings generated from {env_file}."""',
|
|
72
|
+
"",
|
|
73
|
+
" model_config = SettingsConfigDict(",
|
|
74
|
+
f' env_file="{env_file}",',
|
|
75
|
+
' extra="forbid",',
|
|
76
|
+
" )",
|
|
77
|
+
"",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for var_name, env_var in sorted(env.variables.items()):
|
|
81
|
+
is_sensitive = var_name in sensitive_vars
|
|
82
|
+
|
|
83
|
+
# Try to infer type from value
|
|
84
|
+
value = env_var.value
|
|
85
|
+
if value.lower() in ("true", "false"):
|
|
86
|
+
type_hint = "bool"
|
|
87
|
+
default_val = value.lower() == "true"
|
|
88
|
+
elif value.isdigit():
|
|
89
|
+
type_hint = "int"
|
|
90
|
+
default_val = int(value)
|
|
91
|
+
else:
|
|
92
|
+
type_hint = "str"
|
|
93
|
+
default_val = None # Will be required
|
|
94
|
+
|
|
95
|
+
# Build field
|
|
96
|
+
if is_sensitive:
|
|
97
|
+
extra = 'json_schema_extra={"sensitive": True}'
|
|
98
|
+
if default_val is not None:
|
|
99
|
+
lines.append(
|
|
100
|
+
f" {var_name}: {type_hint} = Field(default={default_val!r}, {extra})"
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
lines.append(f" {var_name}: {type_hint} = Field({extra})")
|
|
104
|
+
else:
|
|
105
|
+
if default_val is not None:
|
|
106
|
+
lines.append(f" {var_name}: {type_hint} = {default_val!r}")
|
|
107
|
+
else:
|
|
108
|
+
lines.append(f" {var_name}: {type_hint}")
|
|
109
|
+
|
|
110
|
+
lines.append("")
|
|
111
|
+
|
|
112
|
+
# Write output
|
|
113
|
+
output.write_text("\n".join(lines))
|
|
114
|
+
print_success(f"Generated {output}")
|
|
115
|
+
|
|
116
|
+
if sensitive_vars:
|
|
117
|
+
console.print(f"[dim]Detected {len(sensitive_vars)} sensitive variable(s)[/dim]")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""CLI commands for partial encryption functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from envdrift.config import load_config
|
|
11
|
+
from envdrift.core.partial_encryption import (
|
|
12
|
+
PartialEncryptionError,
|
|
13
|
+
pull_partial_encryption,
|
|
14
|
+
push_partial_encryption,
|
|
15
|
+
)
|
|
16
|
+
from envdrift.output.rich import console, print_error, print_success, print_warning
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def push(
|
|
20
|
+
env: Annotated[
|
|
21
|
+
str | None,
|
|
22
|
+
typer.Option("--env", "-e", help="Environment name (e.g., production, staging)"),
|
|
23
|
+
] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Encrypt secret files and combine with clear files (prepare for commit).
|
|
27
|
+
|
|
28
|
+
This command:
|
|
29
|
+
1. Encrypts .env.{env}.secret files using dotenvx
|
|
30
|
+
2. Combines .env.{env}.clear + encrypted .secret → .env.{env}
|
|
31
|
+
3. Adds warning header to generated file
|
|
32
|
+
|
|
33
|
+
The generated .env.{env} file should be committed to git.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
# Push all environments
|
|
37
|
+
envdrift push
|
|
38
|
+
|
|
39
|
+
# Push specific environment
|
|
40
|
+
envdrift push --env production
|
|
41
|
+
"""
|
|
42
|
+
# Load config
|
|
43
|
+
try:
|
|
44
|
+
config = load_config()
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print_error(f"Failed to load configuration: {e}")
|
|
47
|
+
raise typer.Exit(code=1) from None
|
|
48
|
+
|
|
49
|
+
if not config.partial_encryption.enabled:
|
|
50
|
+
print_error("Partial encryption is not enabled in configuration")
|
|
51
|
+
console.print("\nTo enable partial encryption, add to your envdrift.toml:")
|
|
52
|
+
console.print(
|
|
53
|
+
"[cyan][[partial_encryption.environments]][/cyan]\n"
|
|
54
|
+
'[cyan]name = "production"[/cyan]\n'
|
|
55
|
+
'[cyan]clear_file = ".env.production.clear"[/cyan]\n'
|
|
56
|
+
'[cyan]secret_file = ".env.production.secret"[/cyan]\n'
|
|
57
|
+
'[cyan]combined_file = ".env.production"[/cyan]'
|
|
58
|
+
)
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
|
|
61
|
+
# Filter environments
|
|
62
|
+
envs_to_process = config.partial_encryption.environments
|
|
63
|
+
if env:
|
|
64
|
+
envs_to_process = [e for e in envs_to_process if e.name == env]
|
|
65
|
+
if not envs_to_process:
|
|
66
|
+
print_error(f"No partial encryption configuration found for environment '{env}'")
|
|
67
|
+
raise typer.Exit(code=1)
|
|
68
|
+
|
|
69
|
+
console.print()
|
|
70
|
+
console.print("[bold]Push[/bold] - Encrypting and combining env files")
|
|
71
|
+
console.print(f"[dim]Environments: {len(envs_to_process)}[/dim]")
|
|
72
|
+
console.print()
|
|
73
|
+
|
|
74
|
+
total_encrypted = 0
|
|
75
|
+
total_combined = 0
|
|
76
|
+
errors = []
|
|
77
|
+
|
|
78
|
+
for env_config in envs_to_process:
|
|
79
|
+
console.print(f"[bold cyan]→[/bold cyan] {env_config.name}")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
stats = push_partial_encryption(env_config)
|
|
83
|
+
|
|
84
|
+
console.print(
|
|
85
|
+
f" [green]✓[/green] Generated {env_config.combined_file} "
|
|
86
|
+
f"[dim]({stats['clear_lines']} clear + {stats['secret_vars']} encrypted)[/dim]"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
total_combined += 1
|
|
90
|
+
total_encrypted += stats["secret_vars"]
|
|
91
|
+
|
|
92
|
+
except PartialEncryptionError as e:
|
|
93
|
+
console.print(f" [red]✗[/red] {e}")
|
|
94
|
+
errors.append(f"{env_config.name}: {e}")
|
|
95
|
+
except Exception as e:
|
|
96
|
+
console.print(f" [red]✗[/red] Unexpected error: {e}")
|
|
97
|
+
errors.append(f"{env_config.name}: {e}")
|
|
98
|
+
|
|
99
|
+
# Summary
|
|
100
|
+
console.print()
|
|
101
|
+
summary_lines = [
|
|
102
|
+
f"Combined: {total_combined}/{len(envs_to_process)}",
|
|
103
|
+
f"Total encrypted vars: {total_encrypted}",
|
|
104
|
+
]
|
|
105
|
+
if errors:
|
|
106
|
+
summary_lines.append(f"Errors: {len(errors)}")
|
|
107
|
+
|
|
108
|
+
console.print(Panel("\n".join(summary_lines), title="Push Summary", expand=False))
|
|
109
|
+
|
|
110
|
+
if errors:
|
|
111
|
+
console.print()
|
|
112
|
+
print_warning("Some environments had errors:")
|
|
113
|
+
for error in errors:
|
|
114
|
+
console.print(f" • {error}")
|
|
115
|
+
raise typer.Exit(code=1)
|
|
116
|
+
|
|
117
|
+
console.print()
|
|
118
|
+
print_success("Push complete! Combined files are ready to commit.")
|
|
119
|
+
console.print()
|
|
120
|
+
console.print(
|
|
121
|
+
"[dim]Remember to edit source files (.clear and .secret), not the combined file.[/dim]"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pull_cmd(
|
|
126
|
+
env: Annotated[
|
|
127
|
+
str | None,
|
|
128
|
+
typer.Option("--env", "-e", help="Environment name (e.g., production, staging)"),
|
|
129
|
+
] = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Decrypt secret files for editing (pull operation).
|
|
133
|
+
|
|
134
|
+
This command:
|
|
135
|
+
1. Decrypts .env.{env}.secret files in-place using dotenvx
|
|
136
|
+
2. Makes them available for editing
|
|
137
|
+
|
|
138
|
+
After pulling, you can edit:
|
|
139
|
+
- .env.{env}.clear (non-sensitive variables)
|
|
140
|
+
- .env.{env}.secret (sensitive variables, now decrypted)
|
|
141
|
+
|
|
142
|
+
Run 'envdrift push' before committing to re-encrypt and combine.
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
# Pull all environments
|
|
146
|
+
envdrift pull-partial
|
|
147
|
+
|
|
148
|
+
# Pull specific environment
|
|
149
|
+
envdrift pull-partial --env production
|
|
150
|
+
"""
|
|
151
|
+
# Load config
|
|
152
|
+
try:
|
|
153
|
+
config = load_config()
|
|
154
|
+
except Exception as e:
|
|
155
|
+
print_error(f"Failed to load configuration: {e}")
|
|
156
|
+
raise typer.Exit(code=1) from None
|
|
157
|
+
|
|
158
|
+
if not config.partial_encryption.enabled:
|
|
159
|
+
print_error("Partial encryption is not enabled in configuration")
|
|
160
|
+
raise typer.Exit(code=1)
|
|
161
|
+
|
|
162
|
+
# Filter environments
|
|
163
|
+
envs_to_process = config.partial_encryption.environments
|
|
164
|
+
if env:
|
|
165
|
+
envs_to_process = [e for e in envs_to_process if e.name == env]
|
|
166
|
+
if not envs_to_process:
|
|
167
|
+
print_error(f"No partial encryption configuration found for environment '{env}'")
|
|
168
|
+
raise typer.Exit(code=1)
|
|
169
|
+
|
|
170
|
+
console.print()
|
|
171
|
+
console.print("[bold]Pull[/bold] - Decrypting secret files")
|
|
172
|
+
console.print(f"[dim]Environments: {len(envs_to_process)}[/dim]")
|
|
173
|
+
console.print()
|
|
174
|
+
|
|
175
|
+
decrypted_count = 0
|
|
176
|
+
skipped_count = 0
|
|
177
|
+
errors = []
|
|
178
|
+
|
|
179
|
+
for env_config in envs_to_process:
|
|
180
|
+
console.print(f"[bold cyan]→[/bold cyan] {env_config.name}")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
was_decrypted = pull_partial_encryption(env_config)
|
|
184
|
+
|
|
185
|
+
if was_decrypted:
|
|
186
|
+
console.print(f" [green]✓[/green] Decrypted {env_config.secret_file}")
|
|
187
|
+
decrypted_count += 1
|
|
188
|
+
else:
|
|
189
|
+
console.print(
|
|
190
|
+
f" [dim]=[/dim] {env_config.secret_file} [dim](already decrypted)[/dim]"
|
|
191
|
+
)
|
|
192
|
+
skipped_count += 1
|
|
193
|
+
|
|
194
|
+
except PartialEncryptionError as e:
|
|
195
|
+
console.print(f" [red]✗[/red] {e}")
|
|
196
|
+
errors.append(f"{env_config.name}: {e}")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
console.print(f" [red]✗[/red] Unexpected error: {e}")
|
|
199
|
+
errors.append(f"{env_config.name}: {e}")
|
|
200
|
+
|
|
201
|
+
# Summary
|
|
202
|
+
console.print()
|
|
203
|
+
summary_lines = [
|
|
204
|
+
f"Decrypted: {decrypted_count}",
|
|
205
|
+
f"Skipped: {skipped_count}",
|
|
206
|
+
]
|
|
207
|
+
if errors:
|
|
208
|
+
summary_lines.append(f"Errors: {len(errors)}")
|
|
209
|
+
|
|
210
|
+
console.print(Panel("\n".join(summary_lines), title="Pull Summary", expand=False))
|
|
211
|
+
|
|
212
|
+
if errors:
|
|
213
|
+
console.print()
|
|
214
|
+
print_warning("Some environments had errors:")
|
|
215
|
+
for error in errors:
|
|
216
|
+
console.print(f" • {error}")
|
|
217
|
+
raise typer.Exit(code=1)
|
|
218
|
+
|
|
219
|
+
console.print()
|
|
220
|
+
print_success("Pull complete! Secret files are now decrypted for editing.")
|
|
221
|
+
console.print()
|
|
222
|
+
console.print("[dim]Remember to run 'envdrift push' before committing.[/dim]")
|