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,1140 @@
|
|
|
1
|
+
"""Vault sync-related commands for envdrift."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
|
|
12
|
+
from envdrift.env_files import detect_env_file
|
|
13
|
+
from envdrift.output.rich import console, print_error, print_success, print_warning
|
|
14
|
+
from envdrift.vault.base import SecretNotFoundError, VaultError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from envdrift.sync.config import SyncConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_sync_config_and_client(
|
|
21
|
+
config_file: Path | None,
|
|
22
|
+
provider: str | None,
|
|
23
|
+
vault_url: str | None,
|
|
24
|
+
region: str | None,
|
|
25
|
+
project_id: str | None,
|
|
26
|
+
) -> tuple[SyncConfig, Any, str, str | None, str | None, str | None]:
|
|
27
|
+
"""
|
|
28
|
+
Load sync configuration and instantiate a vault client using CLI arguments, discovered project config, or an explicit config file.
|
|
29
|
+
|
|
30
|
+
This resolves effective provider, vault URL, and region by preferring CLI arguments over project defaults (from a provided TOML file, discovered envdrift.toml/pyproject.toml, or an explicit legacy config), constructs a SyncConfig (from a TOML, legacy pair file, or project sync mappings), validates required provider-specific options, and returns the SyncConfig along with a ready-to-use vault client and the resolved provider/URL/region.
|
|
31
|
+
|
|
32
|
+
Parameters:
|
|
33
|
+
config_file (Path | None): Path provided via --config. If a TOML file is given, it is used for defaults and/or as the sync config source; other extensions may be treated as legacy pair files.
|
|
34
|
+
provider (str | None): CLI provider override (e.g., "azure", "aws", "hashicorp", "gcp"). If omitted, the provider from project config is used when available.
|
|
35
|
+
vault_url (str | None): CLI vault URL override for providers that require it (Azure, HashiCorp). If omitted, the value from project config is used when present.
|
|
36
|
+
region (str | None): CLI region override for AWS. If omitted, the value from project config is used when present.
|
|
37
|
+
project_id (str | None): CLI project ID override for GCP Secret Manager. If omitted, the value from project config is used when present.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
tuple[SyncConfig, Any, str, str | None, str | None]: A tuple containing:
|
|
41
|
+
- SyncConfig: the resolved synchronization configuration with mappings.
|
|
42
|
+
- vault_client: an instantiated vault client for the resolved provider.
|
|
43
|
+
- effective_provider: the resolved provider string.
|
|
44
|
+
- effective_vault_url: the resolved vault URL when applicable, otherwise None.
|
|
45
|
+
- effective_region: the resolved region when applicable, otherwise None.
|
|
46
|
+
- effective_project_id: the resolved GCP project ID when applicable, otherwise None.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
typer.Exit: Exits with a non-zero code if no valid sync configuration can be found, required provider options are missing, the config file is invalid or unreadable, or the vault client cannot be created.
|
|
50
|
+
"""
|
|
51
|
+
import tomllib
|
|
52
|
+
|
|
53
|
+
from envdrift.config import ConfigNotFoundError, find_config, load_config
|
|
54
|
+
from envdrift.sync.config import ServiceMapping, SyncConfig, SyncConfigError
|
|
55
|
+
from envdrift.vault import get_vault_client
|
|
56
|
+
|
|
57
|
+
# Determine config source for defaults:
|
|
58
|
+
# 1. If --config points to a TOML file, use it for defaults
|
|
59
|
+
# 2. Otherwise, use auto-discovery (find_config)
|
|
60
|
+
# Note: discovery only runs when --config is not provided. If --config points
|
|
61
|
+
# to a non-TOML file (e.g., pair.txt), we skip discovery to avoid pulling
|
|
62
|
+
# defaults from unrelated projects.
|
|
63
|
+
envdrift_config = None
|
|
64
|
+
config_path = None
|
|
65
|
+
|
|
66
|
+
if config_file is not None and config_file.suffix.lower() == ".toml":
|
|
67
|
+
# Use the explicitly provided TOML file for defaults
|
|
68
|
+
config_path = config_file
|
|
69
|
+
try:
|
|
70
|
+
envdrift_config = load_config(config_path)
|
|
71
|
+
except tomllib.TOMLDecodeError as e:
|
|
72
|
+
print_error(f"TOML syntax error in {config_path}: {e}")
|
|
73
|
+
raise typer.Exit(code=1) from None
|
|
74
|
+
except ConfigNotFoundError:
|
|
75
|
+
pass
|
|
76
|
+
elif config_file is None:
|
|
77
|
+
# Auto-discover config from envdrift.toml or pyproject.toml
|
|
78
|
+
config_path = find_config()
|
|
79
|
+
if config_path:
|
|
80
|
+
try:
|
|
81
|
+
envdrift_config = load_config(config_path)
|
|
82
|
+
except ConfigNotFoundError:
|
|
83
|
+
pass
|
|
84
|
+
except tomllib.TOMLDecodeError as e:
|
|
85
|
+
print_warning(f"TOML syntax error in {config_path}: {e}")
|
|
86
|
+
|
|
87
|
+
vault_config = getattr(envdrift_config, "vault", None)
|
|
88
|
+
|
|
89
|
+
# Determine effective provider (CLI overrides config)
|
|
90
|
+
effective_provider = provider or getattr(vault_config, "provider", None)
|
|
91
|
+
|
|
92
|
+
# Determine effective vault URL (CLI overrides config)
|
|
93
|
+
effective_vault_url = vault_url
|
|
94
|
+
if effective_vault_url is None and vault_config:
|
|
95
|
+
if effective_provider == "azure":
|
|
96
|
+
effective_vault_url = getattr(vault_config, "azure_vault_url", None)
|
|
97
|
+
elif effective_provider == "hashicorp":
|
|
98
|
+
effective_vault_url = getattr(vault_config, "hashicorp_url", None)
|
|
99
|
+
|
|
100
|
+
# Determine effective region (CLI overrides config)
|
|
101
|
+
effective_region = region
|
|
102
|
+
if effective_region is None and vault_config:
|
|
103
|
+
effective_region = getattr(vault_config, "aws_region", None)
|
|
104
|
+
|
|
105
|
+
effective_project_id = project_id
|
|
106
|
+
if effective_project_id is None and vault_config:
|
|
107
|
+
effective_project_id = getattr(vault_config, "gcp_project_id", None)
|
|
108
|
+
|
|
109
|
+
vault_sync = getattr(vault_config, "sync", None)
|
|
110
|
+
|
|
111
|
+
# Load sync config from file or project config
|
|
112
|
+
sync_config: SyncConfig | None = None
|
|
113
|
+
|
|
114
|
+
if config_file is not None:
|
|
115
|
+
# Explicit config file provided
|
|
116
|
+
if not config_file.exists():
|
|
117
|
+
print_error(f"Config file not found: {config_file}")
|
|
118
|
+
raise typer.Exit(code=1)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Detect format by extension
|
|
122
|
+
if config_file.suffix.lower() == ".toml":
|
|
123
|
+
sync_config = SyncConfig.from_toml_file(config_file)
|
|
124
|
+
else:
|
|
125
|
+
# Legacy pair.txt format
|
|
126
|
+
sync_config = SyncConfig.from_file(config_file)
|
|
127
|
+
except SyncConfigError as e:
|
|
128
|
+
print_error(f"Invalid config file: {e}")
|
|
129
|
+
raise typer.Exit(code=1) from None
|
|
130
|
+
elif vault_sync and vault_sync.mappings:
|
|
131
|
+
# Use mappings from project config
|
|
132
|
+
sync_config = SyncConfig(
|
|
133
|
+
mappings=[
|
|
134
|
+
ServiceMapping(
|
|
135
|
+
secret_name=m.secret_name,
|
|
136
|
+
folder_path=Path(m.folder_path),
|
|
137
|
+
vault_name=m.vault_name,
|
|
138
|
+
environment=m.environment,
|
|
139
|
+
profile=m.profile,
|
|
140
|
+
activate_to=Path(m.activate_to) if m.activate_to else None,
|
|
141
|
+
)
|
|
142
|
+
for m in vault_sync.mappings
|
|
143
|
+
],
|
|
144
|
+
default_vault_name=vault_sync.default_vault_name,
|
|
145
|
+
env_keys_filename=vault_sync.env_keys_filename,
|
|
146
|
+
)
|
|
147
|
+
elif config_path and config_path.suffix.lower() == ".toml":
|
|
148
|
+
# Try to load sync config from discovered TOML
|
|
149
|
+
try:
|
|
150
|
+
sync_config = SyncConfig.from_toml_file(config_path)
|
|
151
|
+
except SyncConfigError as e:
|
|
152
|
+
print_warning(f"Could not load sync config from {config_path}: {e}")
|
|
153
|
+
|
|
154
|
+
if sync_config is None or not sync_config.mappings:
|
|
155
|
+
print_error(
|
|
156
|
+
"No sync configuration found. Provide one of:\n"
|
|
157
|
+
" --config <file.toml> TOML config with [vault.sync] section\n"
|
|
158
|
+
" --config <pair.txt> Legacy format: secret=folder\n"
|
|
159
|
+
" [tool.envdrift.vault.sync] section in pyproject.toml"
|
|
160
|
+
)
|
|
161
|
+
raise typer.Exit(code=1)
|
|
162
|
+
|
|
163
|
+
# Validate provider is set
|
|
164
|
+
if effective_provider is None:
|
|
165
|
+
print_error(
|
|
166
|
+
"--provider is required (or set [vault] provider in config). "
|
|
167
|
+
"Options: azure, aws, hashicorp, gcp"
|
|
168
|
+
)
|
|
169
|
+
raise typer.Exit(code=1)
|
|
170
|
+
|
|
171
|
+
# Validate provider-specific options
|
|
172
|
+
if effective_provider == "azure" and not effective_vault_url:
|
|
173
|
+
print_error("Azure provider requires --vault-url (or [vault.azure] vault_url in config)")
|
|
174
|
+
raise typer.Exit(code=1)
|
|
175
|
+
|
|
176
|
+
if effective_provider == "hashicorp" and not effective_vault_url:
|
|
177
|
+
print_error("HashiCorp provider requires --vault-url (or [vault.hashicorp] url in config)")
|
|
178
|
+
raise typer.Exit(code=1)
|
|
179
|
+
|
|
180
|
+
if effective_provider == "gcp" and not effective_project_id:
|
|
181
|
+
print_error("GCP provider requires --project-id (or [vault.gcp] project_id in config)")
|
|
182
|
+
raise typer.Exit(code=1)
|
|
183
|
+
|
|
184
|
+
# Create vault client
|
|
185
|
+
try:
|
|
186
|
+
vault_kwargs: dict = {}
|
|
187
|
+
if effective_provider == "azure":
|
|
188
|
+
vault_kwargs["vault_url"] = effective_vault_url
|
|
189
|
+
elif effective_provider == "aws":
|
|
190
|
+
vault_kwargs["region"] = effective_region or "us-east-1"
|
|
191
|
+
elif effective_provider == "hashicorp":
|
|
192
|
+
vault_kwargs["url"] = effective_vault_url
|
|
193
|
+
elif effective_provider == "gcp":
|
|
194
|
+
vault_kwargs["project_id"] = effective_project_id
|
|
195
|
+
|
|
196
|
+
vault_client = get_vault_client(effective_provider, **vault_kwargs)
|
|
197
|
+
except ImportError as e:
|
|
198
|
+
print_error(str(e))
|
|
199
|
+
raise typer.Exit(code=1) from None
|
|
200
|
+
except ValueError as e:
|
|
201
|
+
print_error(str(e))
|
|
202
|
+
raise typer.Exit(code=1) from None
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
sync_config,
|
|
206
|
+
vault_client,
|
|
207
|
+
effective_provider,
|
|
208
|
+
effective_vault_url,
|
|
209
|
+
effective_region,
|
|
210
|
+
effective_project_id,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def sync(
|
|
215
|
+
config_file: Annotated[
|
|
216
|
+
Path | None,
|
|
217
|
+
typer.Option(
|
|
218
|
+
"--config",
|
|
219
|
+
"-c",
|
|
220
|
+
help="Path to sync config file (TOML or legacy pair.txt format)",
|
|
221
|
+
),
|
|
222
|
+
] = None,
|
|
223
|
+
provider: Annotated[
|
|
224
|
+
str | None,
|
|
225
|
+
typer.Option("--provider", "-p", help="Vault provider: azure, aws, hashicorp, gcp"),
|
|
226
|
+
] = None,
|
|
227
|
+
vault_url: Annotated[
|
|
228
|
+
str | None,
|
|
229
|
+
typer.Option("--vault-url", help="Vault URL (Azure Key Vault or HashiCorp Vault)"),
|
|
230
|
+
] = None,
|
|
231
|
+
region: Annotated[
|
|
232
|
+
str | None,
|
|
233
|
+
typer.Option("--region", help="AWS region (default: us-east-1)"),
|
|
234
|
+
] = None,
|
|
235
|
+
project_id: Annotated[
|
|
236
|
+
str | None,
|
|
237
|
+
typer.Option("--project-id", help="GCP project ID (Secret Manager)"),
|
|
238
|
+
] = None,
|
|
239
|
+
verify: Annotated[
|
|
240
|
+
bool,
|
|
241
|
+
typer.Option("--verify", help="Check only, don't modify files"),
|
|
242
|
+
] = False,
|
|
243
|
+
force: Annotated[
|
|
244
|
+
bool,
|
|
245
|
+
typer.Option("--force", "-f", help="Update all mismatches without prompting"),
|
|
246
|
+
] = False,
|
|
247
|
+
check_decryption: Annotated[
|
|
248
|
+
bool,
|
|
249
|
+
typer.Option("--check-decryption", help="Verify keys can decrypt .env files"),
|
|
250
|
+
] = False,
|
|
251
|
+
validate_schema: Annotated[
|
|
252
|
+
bool,
|
|
253
|
+
typer.Option("--validate-schema", help="Run schema validation after sync"),
|
|
254
|
+
] = False,
|
|
255
|
+
schema: Annotated[
|
|
256
|
+
str | None,
|
|
257
|
+
typer.Option("--schema", "-s", help="Schema path for validation"),
|
|
258
|
+
] = None,
|
|
259
|
+
service_dir: Annotated[
|
|
260
|
+
Path | None,
|
|
261
|
+
typer.Option("--service-dir", "-d", help="Service directory for schema imports"),
|
|
262
|
+
] = None,
|
|
263
|
+
ci: Annotated[
|
|
264
|
+
bool,
|
|
265
|
+
typer.Option("--ci", help="CI mode: exit with code 1 on errors"),
|
|
266
|
+
] = False,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""
|
|
269
|
+
Sync encryption keys from a configured vault to local .env.keys files for each service.
|
|
270
|
+
|
|
271
|
+
Loads sync configuration and a vault client, fetches DOTENV_PRIVATE_KEY_* secrets for configured mappings, and writes/updates local key files; optionally verifies keys, forces updates, checks decryption, and runs schema validation after sync. In interactive mode the command may prompt before updating individual services; --force, --verify, and --ci disable prompts.
|
|
272
|
+
|
|
273
|
+
Exits with code 1 on vault or sync configuration errors, and when run with --ci if any sync errors occurred.
|
|
274
|
+
"""
|
|
275
|
+
from envdrift.output.rich import print_service_sync_status, print_sync_result
|
|
276
|
+
from envdrift.sync.config import SyncConfigError
|
|
277
|
+
|
|
278
|
+
sync_config, vault_client, effective_provider, _, _, _ = load_sync_config_and_client(
|
|
279
|
+
config_file=config_file,
|
|
280
|
+
provider=provider,
|
|
281
|
+
vault_url=vault_url,
|
|
282
|
+
region=region,
|
|
283
|
+
project_id=project_id,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Create sync engine
|
|
287
|
+
from envdrift.sync.engine import SyncEngine, SyncMode
|
|
288
|
+
|
|
289
|
+
mode = SyncMode(
|
|
290
|
+
verify_only=verify,
|
|
291
|
+
force_update=force,
|
|
292
|
+
check_decryption=check_decryption,
|
|
293
|
+
validate_schema=validate_schema,
|
|
294
|
+
schema_path=schema,
|
|
295
|
+
service_dir=service_dir,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Progress callback for non-CI mode
|
|
299
|
+
def progress_callback(msg: str) -> None:
|
|
300
|
+
if not ci:
|
|
301
|
+
console.print(f"[dim]{msg}[/dim]")
|
|
302
|
+
|
|
303
|
+
# Prompt callback (disabled in force/verify/ci modes)
|
|
304
|
+
def prompt_callback(msg: str) -> bool:
|
|
305
|
+
if force or verify or ci:
|
|
306
|
+
return force
|
|
307
|
+
response = console.input(f"{msg} (y/N): ").strip().lower()
|
|
308
|
+
return response in ("y", "yes")
|
|
309
|
+
|
|
310
|
+
engine = SyncEngine(
|
|
311
|
+
config=sync_config,
|
|
312
|
+
vault_client=vault_client,
|
|
313
|
+
mode=mode,
|
|
314
|
+
prompt_callback=prompt_callback,
|
|
315
|
+
progress_callback=progress_callback,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Print header
|
|
319
|
+
console.print()
|
|
320
|
+
mode_str = "VERIFY" if verify else ("FORCE" if force else "Interactive")
|
|
321
|
+
console.print(f"[bold]Vault Sync[/bold] - Mode: {mode_str}")
|
|
322
|
+
console.print(
|
|
323
|
+
f"[dim]Provider: {effective_provider} | Services: {len(sync_config.mappings)}[/dim]"
|
|
324
|
+
)
|
|
325
|
+
console.print()
|
|
326
|
+
|
|
327
|
+
# Run sync
|
|
328
|
+
try:
|
|
329
|
+
result = engine.sync_all()
|
|
330
|
+
except (VaultError, SyncConfigError, SecretNotFoundError) as e:
|
|
331
|
+
print_error(f"Sync failed: {e}")
|
|
332
|
+
raise typer.Exit(code=1) from None
|
|
333
|
+
|
|
334
|
+
# Print results
|
|
335
|
+
for service_result in result.services:
|
|
336
|
+
print_service_sync_status(service_result)
|
|
337
|
+
|
|
338
|
+
print_sync_result(result)
|
|
339
|
+
|
|
340
|
+
# Exit with appropriate code
|
|
341
|
+
if ci and result.has_errors:
|
|
342
|
+
raise typer.Exit(code=1)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def pull(
|
|
346
|
+
config_file: Annotated[
|
|
347
|
+
Path | None,
|
|
348
|
+
typer.Option(
|
|
349
|
+
"--config",
|
|
350
|
+
"-c",
|
|
351
|
+
help="Path to sync config file (TOML or legacy pair.txt format)",
|
|
352
|
+
),
|
|
353
|
+
] = None,
|
|
354
|
+
provider: Annotated[
|
|
355
|
+
str | None,
|
|
356
|
+
typer.Option("--provider", "-p", help="Vault provider: azure, aws, hashicorp, gcp"),
|
|
357
|
+
] = None,
|
|
358
|
+
vault_url: Annotated[
|
|
359
|
+
str | None,
|
|
360
|
+
typer.Option("--vault-url", help="Vault URL (Azure Key Vault or HashiCorp Vault)"),
|
|
361
|
+
] = None,
|
|
362
|
+
region: Annotated[
|
|
363
|
+
str | None,
|
|
364
|
+
typer.Option("--region", help="AWS region (default: us-east-1)"),
|
|
365
|
+
] = None,
|
|
366
|
+
project_id: Annotated[
|
|
367
|
+
str | None,
|
|
368
|
+
typer.Option("--project-id", help="GCP project ID (Secret Manager)"),
|
|
369
|
+
] = None,
|
|
370
|
+
force: Annotated[
|
|
371
|
+
bool,
|
|
372
|
+
typer.Option("--force", "-f", help="Update all mismatches without prompting"),
|
|
373
|
+
] = False,
|
|
374
|
+
profile: Annotated[
|
|
375
|
+
str | None,
|
|
376
|
+
typer.Option("--profile", help="Only process mappings for this profile"),
|
|
377
|
+
] = None,
|
|
378
|
+
skip_sync: Annotated[
|
|
379
|
+
bool,
|
|
380
|
+
typer.Option("--skip-sync", help="Skip syncing keys from vault, only decrypt files"),
|
|
381
|
+
] = False,
|
|
382
|
+
) -> None:
|
|
383
|
+
"""
|
|
384
|
+
Pull keys from vault and decrypt all env files (one-command developer setup).
|
|
385
|
+
|
|
386
|
+
Reads your TOML configuration, fetches encryption keys from your cloud vault,
|
|
387
|
+
writes them to local .env.keys files, and decrypts all corresponding .env files.
|
|
388
|
+
|
|
389
|
+
This is the recommended command for onboarding new developers - just run
|
|
390
|
+
`envdrift pull` and all encrypted environment files are ready to use.
|
|
391
|
+
|
|
392
|
+
Use --profile to filter mappings and activate a specific environment:
|
|
393
|
+
- Without --profile: processes all mappings without a profile tag
|
|
394
|
+
- With --profile: processes regular mappings + the matching profile,
|
|
395
|
+
and copies the decrypted file to the activate_to path if configured
|
|
396
|
+
|
|
397
|
+
Configuration is read from:
|
|
398
|
+
- pyproject.toml [tool.envdrift.vault.sync] section
|
|
399
|
+
- envdrift.toml [vault.sync] section
|
|
400
|
+
- Explicit --config file
|
|
401
|
+
|
|
402
|
+
Examples:
|
|
403
|
+
# Auto-discover config and pull everything (non-profile mappings only)
|
|
404
|
+
envdrift pull
|
|
405
|
+
|
|
406
|
+
# Pull with a specific profile (regular mappings + profile, activates env)
|
|
407
|
+
envdrift pull --profile local
|
|
408
|
+
|
|
409
|
+
# Use explicit config file
|
|
410
|
+
envdrift pull -c envdrift.toml
|
|
411
|
+
|
|
412
|
+
# Override provider settings
|
|
413
|
+
envdrift pull -p azure --vault-url https://myvault.vault.azure.net/
|
|
414
|
+
|
|
415
|
+
# Force update without prompts
|
|
416
|
+
envdrift pull --force
|
|
417
|
+
|
|
418
|
+
# Skip vault sync, only decrypt files (useful when keys are already local)
|
|
419
|
+
envdrift pull --skip-sync
|
|
420
|
+
"""
|
|
421
|
+
from envdrift.output.rich import print_service_sync_status, print_sync_result
|
|
422
|
+
from envdrift.sync.config import SyncConfigError
|
|
423
|
+
|
|
424
|
+
sync_config, vault_client, effective_provider, _, _, _ = load_sync_config_and_client(
|
|
425
|
+
config_file=config_file,
|
|
426
|
+
provider=provider,
|
|
427
|
+
vault_url=vault_url,
|
|
428
|
+
region=region,
|
|
429
|
+
project_id=project_id,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# === FILTER MAPPINGS BY PROFILE ===
|
|
433
|
+
from envdrift.sync.engine import SyncEngine, SyncMode
|
|
434
|
+
|
|
435
|
+
filtered_mappings = sync_config.filter_by_profile(profile)
|
|
436
|
+
|
|
437
|
+
if not filtered_mappings:
|
|
438
|
+
if profile:
|
|
439
|
+
print_error(f"No mappings found for profile '{profile}'")
|
|
440
|
+
else:
|
|
441
|
+
print_warning("No non-profile mappings found. Use --profile to specify one.")
|
|
442
|
+
raise typer.Exit(code=1)
|
|
443
|
+
|
|
444
|
+
# Create a filtered config for the sync engine
|
|
445
|
+
from envdrift.sync.config import SyncConfig as SyncConfigClass
|
|
446
|
+
|
|
447
|
+
filtered_config = SyncConfigClass(
|
|
448
|
+
mappings=filtered_mappings,
|
|
449
|
+
default_vault_name=sync_config.default_vault_name,
|
|
450
|
+
env_keys_filename=sync_config.env_keys_filename,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# === STEP 1: SYNC KEYS FROM VAULT ===
|
|
454
|
+
mode = SyncMode(force_update=force)
|
|
455
|
+
|
|
456
|
+
def progress_callback(msg: str) -> None:
|
|
457
|
+
console.print(f"[dim]{msg}[/dim]")
|
|
458
|
+
|
|
459
|
+
def prompt_callback(msg: str) -> bool:
|
|
460
|
+
if force:
|
|
461
|
+
return True
|
|
462
|
+
response = console.input(f"{msg} (y/N): ").strip().lower()
|
|
463
|
+
return response in ("y", "yes")
|
|
464
|
+
|
|
465
|
+
engine = SyncEngine(
|
|
466
|
+
config=filtered_config,
|
|
467
|
+
vault_client=vault_client,
|
|
468
|
+
mode=mode,
|
|
469
|
+
prompt_callback=prompt_callback,
|
|
470
|
+
progress_callback=progress_callback,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
console.print()
|
|
474
|
+
profile_info = f" (profile: {profile})" if profile else ""
|
|
475
|
+
action = "Decrypting env files" if skip_sync else "Syncing keys and decrypting env files"
|
|
476
|
+
console.print(f"[bold]Pull[/bold] - {action}{profile_info}")
|
|
477
|
+
console.print(f"[dim]Provider: {effective_provider} | Services: {len(filtered_mappings)}[/dim]")
|
|
478
|
+
console.print()
|
|
479
|
+
|
|
480
|
+
# === STEP 1: SYNC KEYS FROM VAULT (unless --skip-sync) ===
|
|
481
|
+
if skip_sync:
|
|
482
|
+
console.print("[dim]Step 1: Skipped (--skip-sync)[/dim]")
|
|
483
|
+
else:
|
|
484
|
+
console.print("[bold cyan]Step 1:[/bold cyan] Syncing keys from vault...")
|
|
485
|
+
console.print()
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
sync_result = engine.sync_all()
|
|
489
|
+
except (VaultError, SyncConfigError, SecretNotFoundError) as e:
|
|
490
|
+
print_error(f"Sync failed: {e}")
|
|
491
|
+
raise typer.Exit(code=1) from None
|
|
492
|
+
|
|
493
|
+
for service_result in sync_result.services:
|
|
494
|
+
print_service_sync_status(service_result)
|
|
495
|
+
|
|
496
|
+
print_sync_result(sync_result)
|
|
497
|
+
|
|
498
|
+
if sync_result.has_errors:
|
|
499
|
+
print_error("Setup incomplete due to sync errors")
|
|
500
|
+
raise typer.Exit(code=1)
|
|
501
|
+
|
|
502
|
+
# === STEP 2: DECRYPT ENV FILES ===
|
|
503
|
+
console.print()
|
|
504
|
+
console.print("[bold cyan]Step 2:[/bold cyan] Decrypting environment files...")
|
|
505
|
+
console.print()
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
from envdrift.cli_commands.encryption_helpers import (
|
|
509
|
+
is_encrypted_content,
|
|
510
|
+
resolve_encryption_backend,
|
|
511
|
+
)
|
|
512
|
+
from envdrift.encryption import (
|
|
513
|
+
EncryptionBackendError,
|
|
514
|
+
EncryptionNotFoundError,
|
|
515
|
+
EncryptionProvider,
|
|
516
|
+
detect_encryption_provider,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
encryption_backend, backend_provider, _ = resolve_encryption_backend(config_file)
|
|
520
|
+
if not encryption_backend.is_installed():
|
|
521
|
+
print_error(f"{encryption_backend.name} is not installed")
|
|
522
|
+
console.print(encryption_backend.install_instructions())
|
|
523
|
+
raise typer.Exit(code=1)
|
|
524
|
+
except ValueError as e:
|
|
525
|
+
print_error(f"Unsupported encryption backend: {e}")
|
|
526
|
+
raise typer.Exit(code=1) from None
|
|
527
|
+
|
|
528
|
+
decrypted_count = 0
|
|
529
|
+
skipped_count = 0
|
|
530
|
+
error_count = 0
|
|
531
|
+
activated_count = 0
|
|
532
|
+
|
|
533
|
+
for mapping in filtered_mappings:
|
|
534
|
+
effective_env = mapping.effective_environment
|
|
535
|
+
env_file = mapping.folder_path / f".env.{effective_env}"
|
|
536
|
+
|
|
537
|
+
if not env_file.exists():
|
|
538
|
+
# Try to auto-detect .env.* file
|
|
539
|
+
detection = detect_env_file(mapping.folder_path)
|
|
540
|
+
if detection.status == "found" and detection.path is not None:
|
|
541
|
+
env_file = detection.path
|
|
542
|
+
elif detection.status == "multiple_found":
|
|
543
|
+
console.print(
|
|
544
|
+
f" [yellow]?[/yellow] {mapping.folder_path} "
|
|
545
|
+
f"[yellow]- skipped (multiple .env.* files, specify environment)[/yellow]"
|
|
546
|
+
)
|
|
547
|
+
skipped_count += 1
|
|
548
|
+
continue
|
|
549
|
+
else:
|
|
550
|
+
console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (not found)[/dim]")
|
|
551
|
+
skipped_count += 1
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
# Check if file is encrypted
|
|
555
|
+
content = env_file.read_text()
|
|
556
|
+
if not is_encrypted_content(backend_provider, encryption_backend, content):
|
|
557
|
+
detected_provider = detect_encryption_provider(env_file)
|
|
558
|
+
if detected_provider and detected_provider != backend_provider:
|
|
559
|
+
if (
|
|
560
|
+
detected_provider == EncryptionProvider.DOTENVX
|
|
561
|
+
and backend_provider != EncryptionProvider.DOTENVX
|
|
562
|
+
):
|
|
563
|
+
console.print(
|
|
564
|
+
f" [red]![/red] {env_file} "
|
|
565
|
+
f"[red]- encrypted with dotenvx, but config uses "
|
|
566
|
+
f"{backend_provider.value}[/red]"
|
|
567
|
+
)
|
|
568
|
+
error_count += 1
|
|
569
|
+
continue
|
|
570
|
+
console.print(
|
|
571
|
+
f" [dim]=[/dim] {env_file} "
|
|
572
|
+
f"[dim]- skipped (encrypted with {detected_provider.value}, "
|
|
573
|
+
f"config uses {backend_provider.value})[/dim]"
|
|
574
|
+
)
|
|
575
|
+
skipped_count += 1
|
|
576
|
+
continue
|
|
577
|
+
console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (not encrypted)[/dim]")
|
|
578
|
+
skipped_count += 1
|
|
579
|
+
continue
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
result = encryption_backend.decrypt(env_file.resolve())
|
|
583
|
+
if not result.success:
|
|
584
|
+
console.print(f" [red]![/red] {env_file} [red]- error: {result.message}[/red]")
|
|
585
|
+
error_count += 1
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
console.print(f" [green]+[/green] {env_file} [dim]- decrypted[/dim]")
|
|
589
|
+
decrypted_count += 1
|
|
590
|
+
|
|
591
|
+
# Activate profile: copy decrypted file to activate_to path if configured
|
|
592
|
+
if profile and mapping.profile == profile and mapping.activate_to:
|
|
593
|
+
activate_path = (mapping.folder_path / mapping.activate_to).resolve()
|
|
594
|
+
# Validate path is within folder_path to prevent directory traversal
|
|
595
|
+
try:
|
|
596
|
+
activate_path.relative_to(mapping.folder_path.resolve())
|
|
597
|
+
except ValueError:
|
|
598
|
+
console.print(
|
|
599
|
+
f" [red]![/red] {mapping.activate_to} [red]- invalid path (escapes folder)[/red]"
|
|
600
|
+
)
|
|
601
|
+
error_count += 1
|
|
602
|
+
continue
|
|
603
|
+
|
|
604
|
+
try:
|
|
605
|
+
shutil.copy2(env_file, activate_path)
|
|
606
|
+
console.print(
|
|
607
|
+
f" [cyan]→[/cyan] {activate_path} [dim]- activated from {env_file.name}[/dim]"
|
|
608
|
+
)
|
|
609
|
+
activated_count += 1
|
|
610
|
+
except OSError as e:
|
|
611
|
+
console.print(
|
|
612
|
+
f" [red]![/red] {activate_path} [red]- activation failed: {e}[/red]"
|
|
613
|
+
)
|
|
614
|
+
error_count += 1
|
|
615
|
+
|
|
616
|
+
except (EncryptionNotFoundError, EncryptionBackendError) as e:
|
|
617
|
+
console.print(f" [red]![/red] {env_file} [red]- error: {e}[/red]")
|
|
618
|
+
error_count += 1
|
|
619
|
+
|
|
620
|
+
# === SUMMARY ===
|
|
621
|
+
console.print()
|
|
622
|
+
summary_lines = [
|
|
623
|
+
f"Decrypted: {decrypted_count}",
|
|
624
|
+
f"Skipped: {skipped_count}",
|
|
625
|
+
f"Errors: {error_count}",
|
|
626
|
+
]
|
|
627
|
+
if activated_count > 0:
|
|
628
|
+
summary_lines.append(f"Activated: {activated_count}")
|
|
629
|
+
console.print(
|
|
630
|
+
Panel(
|
|
631
|
+
"\n".join(summary_lines),
|
|
632
|
+
title="Decrypt Summary",
|
|
633
|
+
expand=False,
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if error_count > 0:
|
|
638
|
+
print_warning("Some files could not be decrypted")
|
|
639
|
+
raise typer.Exit(code=1)
|
|
640
|
+
|
|
641
|
+
console.print()
|
|
642
|
+
print_success("Setup complete! Your environment files are ready to use.")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def lock(
|
|
646
|
+
config_file: Annotated[
|
|
647
|
+
Path | None,
|
|
648
|
+
typer.Option(
|
|
649
|
+
"--config",
|
|
650
|
+
"-c",
|
|
651
|
+
help="Path to sync config file (TOML or legacy pair.txt format)",
|
|
652
|
+
),
|
|
653
|
+
] = None,
|
|
654
|
+
provider: Annotated[
|
|
655
|
+
str | None,
|
|
656
|
+
typer.Option("--provider", "-p", help="Vault provider: azure, aws, hashicorp, gcp"),
|
|
657
|
+
] = None,
|
|
658
|
+
vault_url: Annotated[
|
|
659
|
+
str | None,
|
|
660
|
+
typer.Option("--vault-url", help="Vault URL (Azure Key Vault or HashiCorp Vault)"),
|
|
661
|
+
] = None,
|
|
662
|
+
region: Annotated[
|
|
663
|
+
str | None,
|
|
664
|
+
typer.Option("--region", help="AWS region (default: us-east-1)"),
|
|
665
|
+
] = None,
|
|
666
|
+
project_id: Annotated[
|
|
667
|
+
str | None,
|
|
668
|
+
typer.Option("--project-id", help="GCP project ID (Secret Manager)"),
|
|
669
|
+
] = None,
|
|
670
|
+
force: Annotated[
|
|
671
|
+
bool,
|
|
672
|
+
typer.Option("--force", "-f", help="Force encryption without prompting"),
|
|
673
|
+
] = False,
|
|
674
|
+
profile: Annotated[
|
|
675
|
+
str | None,
|
|
676
|
+
typer.Option("--profile", help="Only process mappings for this profile"),
|
|
677
|
+
] = None,
|
|
678
|
+
verify_vault: Annotated[
|
|
679
|
+
bool,
|
|
680
|
+
typer.Option("--verify-vault", help="Verify local keys match vault before encrypting"),
|
|
681
|
+
] = False,
|
|
682
|
+
sync_keys: Annotated[
|
|
683
|
+
bool,
|
|
684
|
+
typer.Option(
|
|
685
|
+
"--sync-keys", help="Sync keys from vault before encrypting (implies --verify-vault)"
|
|
686
|
+
),
|
|
687
|
+
] = False,
|
|
688
|
+
check_only: Annotated[
|
|
689
|
+
bool,
|
|
690
|
+
typer.Option("--check", help="Only check encryption status, don't encrypt"),
|
|
691
|
+
] = False,
|
|
692
|
+
) -> None:
|
|
693
|
+
"""
|
|
694
|
+
Verify keys and encrypt all env files (opposite of pull - prepares for commit).
|
|
695
|
+
|
|
696
|
+
The lock command ensures your environment files are properly encrypted before
|
|
697
|
+
committing. It can optionally verify that local keys match vault keys to prevent
|
|
698
|
+
key drift, and then encrypts all decrypted .env files.
|
|
699
|
+
|
|
700
|
+
This is the recommended command before committing changes to ensure:
|
|
701
|
+
1. Local encryption keys are in sync with the team's vault keys
|
|
702
|
+
2. All .env files are properly encrypted
|
|
703
|
+
3. No plaintext secrets are accidentally committed
|
|
704
|
+
|
|
705
|
+
Workflow:
|
|
706
|
+
- With --verify-vault: Check if local .env.keys match vault secrets
|
|
707
|
+
- With --sync-keys: Fetch keys from vault to ensure consistency
|
|
708
|
+
- Then: Encrypt all .env files that are currently decrypted
|
|
709
|
+
|
|
710
|
+
Use --profile to filter mappings for a specific environment.
|
|
711
|
+
|
|
712
|
+
Configuration is read from:
|
|
713
|
+
- pyproject.toml [tool.envdrift.vault.sync] section
|
|
714
|
+
- envdrift.toml [vault.sync] section
|
|
715
|
+
- Explicit --config file
|
|
716
|
+
|
|
717
|
+
Examples:
|
|
718
|
+
# Encrypt all env files (basic usage)
|
|
719
|
+
envdrift lock
|
|
720
|
+
|
|
721
|
+
# Verify keys match vault, then encrypt
|
|
722
|
+
envdrift lock --verify-vault
|
|
723
|
+
|
|
724
|
+
# Sync keys from vault first, then encrypt
|
|
725
|
+
envdrift lock --sync-keys
|
|
726
|
+
|
|
727
|
+
# Check encryption status only (dry run)
|
|
728
|
+
envdrift lock --check
|
|
729
|
+
|
|
730
|
+
# Lock with a specific profile
|
|
731
|
+
envdrift lock --profile local
|
|
732
|
+
|
|
733
|
+
# Force encryption without prompts
|
|
734
|
+
envdrift lock --force
|
|
735
|
+
"""
|
|
736
|
+
from envdrift.output.rich import print_service_sync_status, print_sync_result
|
|
737
|
+
from envdrift.sync.config import SyncConfigError
|
|
738
|
+
|
|
739
|
+
# If sync_keys is requested, it implies verify_vault
|
|
740
|
+
if sync_keys:
|
|
741
|
+
verify_vault = True
|
|
742
|
+
|
|
743
|
+
sync_config, vault_client, effective_provider, _, _, _ = load_sync_config_and_client(
|
|
744
|
+
config_file=config_file,
|
|
745
|
+
provider=provider,
|
|
746
|
+
vault_url=vault_url,
|
|
747
|
+
region=region,
|
|
748
|
+
project_id=project_id,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# === FILTER MAPPINGS BY PROFILE ===
|
|
752
|
+
from envdrift.sync.config import SyncConfig as SyncConfigClass
|
|
753
|
+
from envdrift.sync.engine import SyncEngine, SyncMode
|
|
754
|
+
|
|
755
|
+
filtered_mappings = sync_config.filter_by_profile(profile)
|
|
756
|
+
|
|
757
|
+
if not filtered_mappings:
|
|
758
|
+
if profile:
|
|
759
|
+
print_error(f"No mappings found for profile '{profile}'")
|
|
760
|
+
else:
|
|
761
|
+
print_warning("No non-profile mappings found. Use --profile to specify one.")
|
|
762
|
+
raise typer.Exit(code=1)
|
|
763
|
+
|
|
764
|
+
# Create a filtered config for the sync engine
|
|
765
|
+
filtered_config = SyncConfigClass(
|
|
766
|
+
mappings=filtered_mappings,
|
|
767
|
+
default_vault_name=sync_config.default_vault_name,
|
|
768
|
+
env_keys_filename=sync_config.env_keys_filename,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
console.print()
|
|
772
|
+
profile_info = f" (profile: {profile})" if profile else ""
|
|
773
|
+
mode_str = "CHECK" if check_only else ("FORCE" if force else "Interactive")
|
|
774
|
+
console.print(f"[bold]Lock[/bold] - Verifying keys and encrypting env files{profile_info}")
|
|
775
|
+
console.print(
|
|
776
|
+
f"[dim]Provider: {effective_provider} | Mode: {mode_str} | Services: {len(filtered_mappings)}[/dim]"
|
|
777
|
+
)
|
|
778
|
+
console.print()
|
|
779
|
+
|
|
780
|
+
# Tracking for summary
|
|
781
|
+
warnings: list[str] = []
|
|
782
|
+
errors: list[str] = []
|
|
783
|
+
|
|
784
|
+
# === STEP 1: VERIFY/SYNC KEYS (OPTIONAL) ===
|
|
785
|
+
if verify_vault:
|
|
786
|
+
console.print("[bold cyan]Step 1:[/bold cyan] Verifying keys with vault...")
|
|
787
|
+
console.print()
|
|
788
|
+
|
|
789
|
+
if sync_keys:
|
|
790
|
+
# Actually sync keys from vault
|
|
791
|
+
mode = SyncMode(force_update=force)
|
|
792
|
+
|
|
793
|
+
def progress_callback(msg: str) -> None:
|
|
794
|
+
console.print(f"[dim]{msg}[/dim]")
|
|
795
|
+
|
|
796
|
+
def prompt_callback(msg: str) -> bool:
|
|
797
|
+
if force:
|
|
798
|
+
return True
|
|
799
|
+
response = console.input(f"{msg} (y/N): ").strip().lower()
|
|
800
|
+
return response in ("y", "yes")
|
|
801
|
+
|
|
802
|
+
engine = SyncEngine(
|
|
803
|
+
config=filtered_config,
|
|
804
|
+
vault_client=vault_client,
|
|
805
|
+
mode=mode,
|
|
806
|
+
prompt_callback=prompt_callback,
|
|
807
|
+
progress_callback=progress_callback,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
sync_result = engine.sync_all()
|
|
812
|
+
except (VaultError, SyncConfigError, SecretNotFoundError) as e:
|
|
813
|
+
print_error(f"Key sync failed: {e}")
|
|
814
|
+
raise typer.Exit(code=1) from None
|
|
815
|
+
|
|
816
|
+
for service_result in sync_result.services:
|
|
817
|
+
print_service_sync_status(service_result)
|
|
818
|
+
|
|
819
|
+
print_sync_result(sync_result)
|
|
820
|
+
|
|
821
|
+
if sync_result.has_errors:
|
|
822
|
+
errors.append("Key synchronization had errors")
|
|
823
|
+
if not force:
|
|
824
|
+
print_error("Cannot proceed with encryption due to key sync errors")
|
|
825
|
+
raise typer.Exit(code=1)
|
|
826
|
+
else:
|
|
827
|
+
# Just verify (compare local keys with vault)
|
|
828
|
+
from envdrift.sync.operations import EnvKeysFile
|
|
829
|
+
|
|
830
|
+
verification_issues = 0
|
|
831
|
+
|
|
832
|
+
for mapping in filtered_mappings:
|
|
833
|
+
effective_env = mapping.effective_environment
|
|
834
|
+
env_keys_file = mapping.folder_path / (sync_config.env_keys_filename or ".env.keys")
|
|
835
|
+
key_name = f"DOTENV_PRIVATE_KEY_{effective_env.upper()}"
|
|
836
|
+
|
|
837
|
+
# Check if local key exists
|
|
838
|
+
if not env_keys_file.exists():
|
|
839
|
+
console.print(
|
|
840
|
+
f" [yellow]![/yellow] {mapping.folder_path} "
|
|
841
|
+
f"[yellow]- warning: .env.keys not found[/yellow]"
|
|
842
|
+
)
|
|
843
|
+
warnings.append(f"{mapping.folder_path}: .env.keys file missing")
|
|
844
|
+
continue
|
|
845
|
+
|
|
846
|
+
local_keys = EnvKeysFile(env_keys_file)
|
|
847
|
+
local_key = local_keys.read_key(key_name)
|
|
848
|
+
|
|
849
|
+
if not local_key:
|
|
850
|
+
console.print(
|
|
851
|
+
f" [yellow]![/yellow] {mapping.folder_path} "
|
|
852
|
+
f"[yellow]- warning: {key_name} not found in .env.keys[/yellow]"
|
|
853
|
+
)
|
|
854
|
+
warnings.append(f"{mapping.folder_path}: {key_name} missing from .env.keys")
|
|
855
|
+
continue
|
|
856
|
+
|
|
857
|
+
# Fetch key from vault for comparison
|
|
858
|
+
try:
|
|
859
|
+
vault_client.ensure_authenticated()
|
|
860
|
+
vault_secret = vault_client.get_secret(mapping.secret_name)
|
|
861
|
+
|
|
862
|
+
if not vault_secret or not vault_secret.value:
|
|
863
|
+
console.print(
|
|
864
|
+
f" [yellow]![/yellow] {mapping.folder_path} "
|
|
865
|
+
f"[yellow]- warning: vault secret '{mapping.secret_name}' is empty[/yellow]"
|
|
866
|
+
)
|
|
867
|
+
warnings.append(f"{mapping.folder_path}: vault secret is empty")
|
|
868
|
+
continue
|
|
869
|
+
|
|
870
|
+
vault_value = vault_secret.value
|
|
871
|
+
|
|
872
|
+
# Parse vault value (format: KEY_NAME=value)
|
|
873
|
+
if "=" in vault_value and vault_value.startswith("DOTENV_PRIVATE_KEY"):
|
|
874
|
+
vault_key = vault_value.split("=", 1)[1]
|
|
875
|
+
else:
|
|
876
|
+
vault_key = vault_value
|
|
877
|
+
|
|
878
|
+
# Compare keys
|
|
879
|
+
if local_key == vault_key:
|
|
880
|
+
console.print(
|
|
881
|
+
f" [green]✓[/green] {mapping.folder_path} "
|
|
882
|
+
f"[dim]- keys match vault[/dim]"
|
|
883
|
+
)
|
|
884
|
+
else:
|
|
885
|
+
console.print(
|
|
886
|
+
f" [red]✗[/red] {mapping.folder_path} "
|
|
887
|
+
f"[red]- KEY MISMATCH: local key differs from vault![/red]"
|
|
888
|
+
)
|
|
889
|
+
errors.append(
|
|
890
|
+
f"{mapping.folder_path}: local key does not match vault "
|
|
891
|
+
f"(run 'envdrift lock --sync-keys' to fix)"
|
|
892
|
+
)
|
|
893
|
+
verification_issues += 1
|
|
894
|
+
|
|
895
|
+
except SecretNotFoundError:
|
|
896
|
+
console.print(
|
|
897
|
+
f" [yellow]![/yellow] {mapping.folder_path} "
|
|
898
|
+
f"[yellow]- warning: vault secret '{mapping.secret_name}' not found[/yellow]"
|
|
899
|
+
)
|
|
900
|
+
warnings.append(f"{mapping.folder_path}: vault secret not found")
|
|
901
|
+
except VaultError as e:
|
|
902
|
+
console.print(
|
|
903
|
+
f" [red]![/red] {mapping.folder_path} "
|
|
904
|
+
f"[red]- error: vault access failed: {e}[/red]"
|
|
905
|
+
)
|
|
906
|
+
errors.append(f"{mapping.folder_path}: vault error - {e}")
|
|
907
|
+
|
|
908
|
+
console.print()
|
|
909
|
+
|
|
910
|
+
if verification_issues > 0 and not force:
|
|
911
|
+
print_error(
|
|
912
|
+
f"Found {verification_issues} key mismatch(es). "
|
|
913
|
+
"Run with --sync-keys to update local keys, or --force to encrypt anyway."
|
|
914
|
+
)
|
|
915
|
+
raise typer.Exit(code=1)
|
|
916
|
+
|
|
917
|
+
# === STEP 2: ENCRYPT ENV FILES ===
|
|
918
|
+
step_num = "Step 2" if verify_vault else "Step 1"
|
|
919
|
+
console.print(f"[bold cyan]{step_num}:[/bold cyan] Encrypting environment files...")
|
|
920
|
+
console.print()
|
|
921
|
+
|
|
922
|
+
try:
|
|
923
|
+
from envdrift.cli_commands.encryption_helpers import (
|
|
924
|
+
build_sops_encrypt_kwargs,
|
|
925
|
+
is_encrypted_content,
|
|
926
|
+
resolve_encryption_backend,
|
|
927
|
+
)
|
|
928
|
+
from envdrift.encryption import (
|
|
929
|
+
EncryptionBackendError,
|
|
930
|
+
EncryptionNotFoundError,
|
|
931
|
+
EncryptionProvider,
|
|
932
|
+
detect_encryption_provider,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
encryption_backend, backend_provider, encryption_config = resolve_encryption_backend(
|
|
936
|
+
config_file
|
|
937
|
+
)
|
|
938
|
+
if not encryption_backend.is_installed():
|
|
939
|
+
print_error(f"{encryption_backend.name} is not installed")
|
|
940
|
+
console.print(encryption_backend.install_instructions())
|
|
941
|
+
raise typer.Exit(code=1)
|
|
942
|
+
except ValueError as e:
|
|
943
|
+
print_error(f"Unsupported encryption backend: {e}")
|
|
944
|
+
raise typer.Exit(code=1) from None
|
|
945
|
+
|
|
946
|
+
sops_encrypt_kwargs = {}
|
|
947
|
+
if backend_provider == EncryptionProvider.SOPS:
|
|
948
|
+
sops_encrypt_kwargs = build_sops_encrypt_kwargs(encryption_config)
|
|
949
|
+
|
|
950
|
+
encrypted_count = 0
|
|
951
|
+
skipped_count = 0
|
|
952
|
+
error_count = 0
|
|
953
|
+
already_encrypted_count = 0
|
|
954
|
+
|
|
955
|
+
for mapping in filtered_mappings:
|
|
956
|
+
effective_env = mapping.effective_environment
|
|
957
|
+
env_file = mapping.folder_path / f".env.{effective_env}"
|
|
958
|
+
|
|
959
|
+
# Check if env file exists
|
|
960
|
+
if not env_file.exists():
|
|
961
|
+
# Try to auto-detect .env.* file
|
|
962
|
+
detection = detect_env_file(mapping.folder_path)
|
|
963
|
+
if detection.status == "found" and detection.path is not None:
|
|
964
|
+
env_file = detection.path
|
|
965
|
+
elif detection.status == "multiple_found":
|
|
966
|
+
console.print(
|
|
967
|
+
f" [yellow]?[/yellow] {mapping.folder_path} "
|
|
968
|
+
f"[yellow]- skipped (multiple .env.* files, specify environment)[/yellow]"
|
|
969
|
+
)
|
|
970
|
+
warnings.append(f"{mapping.folder_path}: multiple .env files found")
|
|
971
|
+
skipped_count += 1
|
|
972
|
+
continue
|
|
973
|
+
else:
|
|
974
|
+
console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (not found)[/dim]")
|
|
975
|
+
warnings.append(f"{env_file}: file not found")
|
|
976
|
+
skipped_count += 1
|
|
977
|
+
continue
|
|
978
|
+
|
|
979
|
+
# Check if .env.keys file exists (needed for encryption)
|
|
980
|
+
env_keys_file = mapping.folder_path / (sync_config.env_keys_filename or ".env.keys")
|
|
981
|
+
if backend_provider == EncryptionProvider.DOTENVX and not env_keys_file.exists():
|
|
982
|
+
console.print(
|
|
983
|
+
f" [yellow]![/yellow] {env_file} "
|
|
984
|
+
f"[yellow]- warning: no .env.keys file, will generate new key[/yellow]"
|
|
985
|
+
)
|
|
986
|
+
warnings.append(f"{env_file}: no .env.keys file found, new key will be generated")
|
|
987
|
+
|
|
988
|
+
# Check if file is already encrypted
|
|
989
|
+
content = env_file.read_text()
|
|
990
|
+
if not is_encrypted_content(backend_provider, encryption_backend, content):
|
|
991
|
+
detected_provider = detect_encryption_provider(env_file)
|
|
992
|
+
if detected_provider and detected_provider != backend_provider:
|
|
993
|
+
if (
|
|
994
|
+
detected_provider == EncryptionProvider.DOTENVX
|
|
995
|
+
and backend_provider != EncryptionProvider.DOTENVX
|
|
996
|
+
):
|
|
997
|
+
console.print(
|
|
998
|
+
f" [red]![/red] {env_file} "
|
|
999
|
+
f"[red]- encrypted with dotenvx, but config uses "
|
|
1000
|
+
f"{backend_provider.value}[/red]"
|
|
1001
|
+
)
|
|
1002
|
+
errors.append(
|
|
1003
|
+
f"{env_file}: encrypted with dotenvx, but config uses "
|
|
1004
|
+
f"{backend_provider.value}"
|
|
1005
|
+
)
|
|
1006
|
+
error_count += 1
|
|
1007
|
+
continue
|
|
1008
|
+
console.print(
|
|
1009
|
+
f" [dim]=[/dim] {env_file} "
|
|
1010
|
+
f"[dim]- skipped (encrypted with {detected_provider.value}, "
|
|
1011
|
+
f"config uses {backend_provider.value})[/dim]"
|
|
1012
|
+
)
|
|
1013
|
+
warnings.append(
|
|
1014
|
+
f"{env_file}: encrypted with {detected_provider.value}, "
|
|
1015
|
+
f"config uses {backend_provider.value}"
|
|
1016
|
+
)
|
|
1017
|
+
skipped_count += 1
|
|
1018
|
+
continue
|
|
1019
|
+
else:
|
|
1020
|
+
if backend_provider == EncryptionProvider.DOTENVX:
|
|
1021
|
+
# Check encryption ratio
|
|
1022
|
+
encrypted_lines = sum(
|
|
1023
|
+
1 for line in content.splitlines() if "encrypted:" in line.lower()
|
|
1024
|
+
)
|
|
1025
|
+
total_value_lines = sum(
|
|
1026
|
+
1
|
|
1027
|
+
for line in content.splitlines()
|
|
1028
|
+
if line.strip() and not line.strip().startswith("#") and "=" in line
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
if total_value_lines > 0:
|
|
1032
|
+
ratio = encrypted_lines / total_value_lines
|
|
1033
|
+
if ratio >= 0.9: # 90%+ encrypted = fully encrypted
|
|
1034
|
+
console.print(
|
|
1035
|
+
f" [dim]=[/dim] {env_file} [dim]- skipped (already encrypted)[/dim]"
|
|
1036
|
+
)
|
|
1037
|
+
already_encrypted_count += 1
|
|
1038
|
+
continue
|
|
1039
|
+
else:
|
|
1040
|
+
# Partially encrypted - re-encrypt to catch new values
|
|
1041
|
+
console.print(
|
|
1042
|
+
f" [yellow]~[/yellow] {env_file} "
|
|
1043
|
+
f"[dim]- partially encrypted ({int(ratio*100)}%), "
|
|
1044
|
+
"re-encrypting...[/dim]"
|
|
1045
|
+
)
|
|
1046
|
+
warnings.append(f"{env_file}: was only {int(ratio*100)}% encrypted")
|
|
1047
|
+
else:
|
|
1048
|
+
console.print(
|
|
1049
|
+
f" [dim]=[/dim] {env_file} [dim]- skipped (already encrypted)[/dim]"
|
|
1050
|
+
)
|
|
1051
|
+
already_encrypted_count += 1
|
|
1052
|
+
continue
|
|
1053
|
+
else:
|
|
1054
|
+
console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (already encrypted)[/dim]")
|
|
1055
|
+
already_encrypted_count += 1
|
|
1056
|
+
continue
|
|
1057
|
+
|
|
1058
|
+
if check_only:
|
|
1059
|
+
# Just report what would be encrypted
|
|
1060
|
+
console.print(f" [cyan]?[/cyan] {env_file} [dim]- would be encrypted[/dim]")
|
|
1061
|
+
encrypted_count += 1
|
|
1062
|
+
continue
|
|
1063
|
+
|
|
1064
|
+
# Prompt before encrypting (unless force mode)
|
|
1065
|
+
if not force:
|
|
1066
|
+
response = console.input(f" Encrypt {env_file}? (y/N): ").strip().lower()
|
|
1067
|
+
if response not in ("y", "yes"):
|
|
1068
|
+
console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (user declined)[/dim]")
|
|
1069
|
+
skipped_count += 1
|
|
1070
|
+
continue
|
|
1071
|
+
|
|
1072
|
+
# Perform encryption
|
|
1073
|
+
try:
|
|
1074
|
+
result = encryption_backend.encrypt(env_file.resolve(), **sops_encrypt_kwargs)
|
|
1075
|
+
if not result.success:
|
|
1076
|
+
console.print(f" [red]![/red] {env_file} [red]- error: {result.message}[/red]")
|
|
1077
|
+
errors.append(f"{env_file}: encryption failed - {result.message}")
|
|
1078
|
+
error_count += 1
|
|
1079
|
+
continue
|
|
1080
|
+
console.print(f" [green]+[/green] {env_file} [dim]- encrypted[/dim]")
|
|
1081
|
+
encrypted_count += 1
|
|
1082
|
+
|
|
1083
|
+
except (EncryptionNotFoundError, EncryptionBackendError) as e:
|
|
1084
|
+
console.print(f" [red]![/red] {env_file} [red]- error: {e}[/red]")
|
|
1085
|
+
errors.append(f"{env_file}: encryption failed - {e}")
|
|
1086
|
+
error_count += 1
|
|
1087
|
+
|
|
1088
|
+
# === SUMMARY ===
|
|
1089
|
+
console.print()
|
|
1090
|
+
summary_lines = []
|
|
1091
|
+
|
|
1092
|
+
if check_only:
|
|
1093
|
+
summary_lines.append(f"Would encrypt: {encrypted_count}")
|
|
1094
|
+
else:
|
|
1095
|
+
summary_lines.append(f"Encrypted: {encrypted_count}")
|
|
1096
|
+
|
|
1097
|
+
summary_lines.append(f"Already encrypted: {already_encrypted_count}")
|
|
1098
|
+
summary_lines.append(f"Skipped: {skipped_count}")
|
|
1099
|
+
summary_lines.append(f"Errors: {error_count}")
|
|
1100
|
+
|
|
1101
|
+
console.print(
|
|
1102
|
+
Panel(
|
|
1103
|
+
"\n".join(summary_lines),
|
|
1104
|
+
title="Lock Summary",
|
|
1105
|
+
expand=False,
|
|
1106
|
+
)
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
# Print warnings
|
|
1110
|
+
if warnings:
|
|
1111
|
+
console.print()
|
|
1112
|
+
console.print("[bold yellow]Warnings:[/bold yellow]")
|
|
1113
|
+
for warning in warnings:
|
|
1114
|
+
console.print(f" [yellow]•[/yellow] {warning}")
|
|
1115
|
+
|
|
1116
|
+
# Print errors
|
|
1117
|
+
if errors:
|
|
1118
|
+
console.print()
|
|
1119
|
+
console.print("[bold red]Errors:[/bold red]")
|
|
1120
|
+
for error in errors:
|
|
1121
|
+
console.print(f" [red]•[/red] {error}")
|
|
1122
|
+
|
|
1123
|
+
if error_count > 0 or errors:
|
|
1124
|
+
print_warning("Some files could not be encrypted or had issues")
|
|
1125
|
+
raise typer.Exit(code=1)
|
|
1126
|
+
|
|
1127
|
+
console.print()
|
|
1128
|
+
if check_only:
|
|
1129
|
+
if encrypted_count > 0:
|
|
1130
|
+
# In check mode, if files would be encrypted, this is a failure
|
|
1131
|
+
# (useful for CI/pre-commit hooks to ensure all files are encrypted)
|
|
1132
|
+
print_warning(
|
|
1133
|
+
f"Found {encrypted_count} file(s) that need encryption. "
|
|
1134
|
+
"Run 'envdrift lock' to encrypt them."
|
|
1135
|
+
)
|
|
1136
|
+
raise typer.Exit(code=1)
|
|
1137
|
+
else:
|
|
1138
|
+
print_success("Check complete! All files are already encrypted.")
|
|
1139
|
+
else:
|
|
1140
|
+
print_success("Lock complete! Your environment files are encrypted and ready to commit.")
|