envdrift 4.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. envdrift/__init__.py +30 -0
  2. envdrift/_version.py +34 -0
  3. envdrift/api.py +192 -0
  4. envdrift/cli.py +42 -0
  5. envdrift/cli_commands/__init__.py +1 -0
  6. envdrift/cli_commands/diff.py +91 -0
  7. envdrift/cli_commands/encryption.py +630 -0
  8. envdrift/cli_commands/encryption_helpers.py +93 -0
  9. envdrift/cli_commands/hook.py +75 -0
  10. envdrift/cli_commands/init_cmd.py +117 -0
  11. envdrift/cli_commands/partial.py +222 -0
  12. envdrift/cli_commands/sync.py +1140 -0
  13. envdrift/cli_commands/validate.py +109 -0
  14. envdrift/cli_commands/vault.py +376 -0
  15. envdrift/cli_commands/version.py +15 -0
  16. envdrift/config.py +489 -0
  17. envdrift/constants.json +18 -0
  18. envdrift/core/__init__.py +30 -0
  19. envdrift/core/diff.py +233 -0
  20. envdrift/core/encryption.py +400 -0
  21. envdrift/core/parser.py +260 -0
  22. envdrift/core/partial_encryption.py +239 -0
  23. envdrift/core/schema.py +253 -0
  24. envdrift/core/validator.py +312 -0
  25. envdrift/encryption/__init__.py +117 -0
  26. envdrift/encryption/base.py +217 -0
  27. envdrift/encryption/dotenvx.py +236 -0
  28. envdrift/encryption/sops.py +458 -0
  29. envdrift/env_files.py +60 -0
  30. envdrift/integrations/__init__.py +21 -0
  31. envdrift/integrations/dotenvx.py +689 -0
  32. envdrift/integrations/precommit.py +266 -0
  33. envdrift/integrations/sops.py +85 -0
  34. envdrift/output/__init__.py +21 -0
  35. envdrift/output/rich.py +424 -0
  36. envdrift/py.typed +0 -0
  37. envdrift/sync/__init__.py +26 -0
  38. envdrift/sync/config.py +218 -0
  39. envdrift/sync/engine.py +383 -0
  40. envdrift/sync/operations.py +138 -0
  41. envdrift/sync/result.py +99 -0
  42. envdrift/vault/__init__.py +107 -0
  43. envdrift/vault/aws.py +282 -0
  44. envdrift/vault/azure.py +170 -0
  45. envdrift/vault/base.py +150 -0
  46. envdrift/vault/gcp.py +210 -0
  47. envdrift/vault/hashicorp.py +238 -0
  48. envdrift-4.2.1.dist-info/METADATA +160 -0
  49. envdrift-4.2.1.dist-info/RECORD +52 -0
  50. envdrift-4.2.1.dist-info/WHEEL +4 -0
  51. envdrift-4.2.1.dist-info/entry_points.txt +2 -0
  52. envdrift-4.2.1.dist-info/licenses/LICENSE +21 -0
envdrift/vault/base.py ADDED
@@ -0,0 +1,150 @@
1
+ """Abstract base class for vault clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ class VaultError(Exception):
11
+ """Base exception for vault operations."""
12
+
13
+ pass
14
+
15
+
16
+ class AuthenticationError(VaultError):
17
+ """Authentication to vault failed."""
18
+
19
+ pass
20
+
21
+
22
+ class SecretNotFoundError(VaultError):
23
+ """Secret not found in vault."""
24
+
25
+ pass
26
+
27
+
28
+ @dataclass
29
+ class SecretValue:
30
+ """Value retrieved from vault."""
31
+
32
+ name: str
33
+ value: str
34
+ version: str | None = None
35
+ metadata: dict[str, Any] = field(default_factory=dict)
36
+
37
+ def __str__(self) -> str:
38
+ """
39
+ Produce a string representation of the SecretValue with the secret value masked.
40
+
41
+ Returns:
42
+ str: A string in the form "SecretValue(name=<name>, value=****)" where the actual secret value is redacted.
43
+ """
44
+ return f"SecretValue(name={self.name}, value=****)"
45
+
46
+
47
+ class VaultClient(ABC):
48
+ """Abstract interface for vault backends.
49
+
50
+ Implementations must provide:
51
+ - get_secret: Retrieve a secret by name
52
+ - list_secrets: List available secret names
53
+ - is_authenticated: Check authentication status
54
+ - authenticate: Perform authentication
55
+ """
56
+
57
+ @abstractmethod
58
+ def get_secret(self, name: str) -> SecretValue:
59
+ """
60
+ Retrieve the secret identified by `name` from the vault.
61
+
62
+ Parameters:
63
+ name (str): Secret name or path within the vault.
64
+
65
+ Returns:
66
+ SecretValue: The secret object containing the secret's value and metadata.
67
+
68
+ Raises:
69
+ SecretNotFoundError: If the secret does not exist.
70
+ AuthenticationError: If the client is not authenticated.
71
+ VaultError: For other vault-related errors.
72
+ """
73
+ ...
74
+
75
+ @abstractmethod
76
+ def list_secrets(self, prefix: str = "") -> list[str]:
77
+ """
78
+ List secret names available in the vault, optionally filtered by a prefix.
79
+
80
+ Parameters:
81
+ prefix (str): Optional prefix to filter returned secret names.
82
+
83
+ Returns:
84
+ list[str]: Secret names that match the prefix (or all secret names if prefix is empty).
85
+
86
+ Raises:
87
+ AuthenticationError: If the client is not authenticated.
88
+ VaultError: For other vault-related errors.
89
+ """
90
+ ...
91
+
92
+ @abstractmethod
93
+ def is_authenticated(self) -> bool:
94
+ """
95
+ Determine whether the client is currently authenticated.
96
+
97
+ Returns:
98
+ True if the client is authenticated, False otherwise.
99
+ """
100
+ ...
101
+
102
+ @abstractmethod
103
+ def authenticate(self) -> None:
104
+ """
105
+ Authenticate the client with the vault.
106
+
107
+ Raises:
108
+ AuthenticationError: If authentication fails.
109
+ """
110
+ ...
111
+
112
+ @abstractmethod
113
+ def set_secret(self, name: str, value: str) -> SecretValue:
114
+ """
115
+ Create or update a secret in the vault.
116
+
117
+ Parameters:
118
+ name (str): Secret name or path within the vault.
119
+ value (str): The secret value to store.
120
+
121
+ Returns:
122
+ SecretValue: The stored secret object containing name, value, version, and metadata.
123
+
124
+ Raises:
125
+ AuthenticationError: If the client is not authenticated or lacks write permissions.
126
+ VaultError: For other vault-related errors.
127
+ """
128
+ ...
129
+
130
+ def get_secret_value(self, name: str) -> str:
131
+ """
132
+ Retrieve the value string of a secret identified by name.
133
+
134
+ Parameters:
135
+ name (str): The secret's name or path.
136
+
137
+ Returns:
138
+ The secret's value string.
139
+ """
140
+ return self.get_secret(name).value
141
+
142
+ def ensure_authenticated(self) -> None:
143
+ """
144
+ Authenticate the client if it is not already authenticated.
145
+
146
+ Raises:
147
+ AuthenticationError: If authentication fails.
148
+ """
149
+ if not self.is_authenticated():
150
+ self.authenticate()
envdrift/vault/gcp.py ADDED
@@ -0,0 +1,210 @@
1
+ """GCP Secret Manager client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+
7
+ from envdrift.vault.base import (
8
+ AuthenticationError,
9
+ SecretNotFoundError,
10
+ SecretValue,
11
+ VaultClient,
12
+ VaultError,
13
+ )
14
+
15
+ try:
16
+ from google.api_core import exceptions as google_exceptions
17
+ from google.auth.exceptions import DefaultCredentialsError
18
+ from google.cloud import secretmanager
19
+
20
+ GCP_AVAILABLE = True
21
+ except ImportError:
22
+ GCP_AVAILABLE = False
23
+ secretmanager = None
24
+ google_exceptions = None
25
+ DefaultCredentialsError = Exception
26
+
27
+
28
+ class GCPSecretManagerClient(VaultClient):
29
+ """GCP Secret Manager implementation.
30
+
31
+ Uses Application Default Credentials which support:
32
+ - GOOGLE_APPLICATION_CREDENTIALS env var
33
+ - gcloud auth application-default login
34
+ - Workload Identity / service account bindings
35
+ """
36
+
37
+ def __init__(self, project_id: str):
38
+ """
39
+ Create a GCP Secret Manager client bound to the provided project ID.
40
+
41
+ Parameters:
42
+ project_id (str): GCP project ID (e.g., "my-gcp-project").
43
+
44
+ Raises:
45
+ ImportError: If the GCP SDK is not installed (install with `pip install envdrift[gcp]`).
46
+ """
47
+ if not GCP_AVAILABLE:
48
+ raise ImportError(
49
+ "GCP Secret Manager support requires additional dependencies. "
50
+ "Install with: pip install envdrift[gcp]"
51
+ )
52
+
53
+ self.project_id = project_id
54
+ self._client: secretmanager.SecretManagerServiceClient | None = None
55
+
56
+ def _project_path(self) -> str:
57
+ return f"projects/{self.project_id}"
58
+
59
+ def _secret_id(self, name: str) -> str:
60
+ if name.startswith("projects/"):
61
+ parts = name.split("/")
62
+ if "secrets" in parts:
63
+ idx = parts.index("secrets")
64
+ if idx + 1 < len(parts):
65
+ return parts[idx + 1]
66
+ return name
67
+
68
+ def _secret_path(self, name: str) -> str:
69
+ return f"{self._project_path()}/secrets/{self._secret_id(name)}"
70
+
71
+ def _version_path(self, name: str, version: str = "latest") -> str:
72
+ if name.startswith("projects/") and "/versions/" in name:
73
+ return name
74
+ if name.startswith("projects/") and "/secrets/" in name:
75
+ return f"{name}/versions/{version}"
76
+ return f"{self._secret_path(name)}/versions/{version}"
77
+
78
+ def authenticate(self) -> None:
79
+ """
80
+ Authenticate to GCP Secret Manager and initialize the client.
81
+
82
+ Raises AuthenticationError for credential issues and VaultError for API failures.
83
+ """
84
+ try:
85
+ self._client = secretmanager.SecretManagerServiceClient()
86
+ secrets_iter = self._client.list_secrets(
87
+ request={"parent": self._project_path(), "page_size": 1}
88
+ )
89
+ next(iter(secrets_iter), None)
90
+ except DefaultCredentialsError as e:
91
+ self._client = None
92
+ raise AuthenticationError(f"GCP authentication failed: {e}") from e
93
+ except (
94
+ google_exceptions.PermissionDenied,
95
+ google_exceptions.Unauthenticated,
96
+ ) as e:
97
+ self._client = None
98
+ raise AuthenticationError(f"GCP authentication failed: {e}") from e
99
+ except google_exceptions.GoogleAPICallError as e:
100
+ self._client = None
101
+ raise VaultError(f"GCP Secret Manager error: {e}") from e
102
+
103
+ def is_authenticated(self) -> bool:
104
+ return self._client is not None
105
+
106
+ def get_secret(self, name: str) -> SecretValue:
107
+ """
108
+ Retrieve a secret from GCP Secret Manager.
109
+
110
+ Parameters:
111
+ name (str): Secret name or full resource path.
112
+
113
+ Returns:
114
+ SecretValue: Contains the secret's name, value, version, and metadata.
115
+ """
116
+ self.ensure_authenticated()
117
+
118
+ try:
119
+ version_path = self._version_path(name)
120
+ response = self._client.access_secret_version(request={"name": version_path})
121
+ payload = response.payload.data if response.payload else b""
122
+ try:
123
+ value = payload.decode("utf-8")
124
+ except UnicodeDecodeError:
125
+ import base64
126
+
127
+ value = base64.b64encode(payload).decode("ascii")
128
+ version = response.name.split("/")[-1] if response.name else None
129
+ return SecretValue(
130
+ name=self._secret_id(name),
131
+ value=value,
132
+ version=version,
133
+ metadata={"name": response.name},
134
+ )
135
+ except google_exceptions.NotFound as e:
136
+ raise SecretNotFoundError(f"Secret '{name}' not found") from e
137
+ except (
138
+ google_exceptions.PermissionDenied,
139
+ google_exceptions.Unauthenticated,
140
+ ) as e:
141
+ raise AuthenticationError(f"Access denied to secret '{name}': {e}") from e
142
+ except google_exceptions.GoogleAPICallError as e:
143
+ raise VaultError(f"GCP Secret Manager error: {e}") from e
144
+
145
+ def list_secrets(self, prefix: str = "") -> list[str]:
146
+ """
147
+ List secret names in the project, optionally filtered by a prefix.
148
+
149
+ Parameters:
150
+ prefix (str): Optional prefix to filter secret names.
151
+ """
152
+ self.ensure_authenticated()
153
+
154
+ try:
155
+ secrets = []
156
+ for secret in self._client.list_secrets(request={"parent": self._project_path()}):
157
+ secret_id = secret.name.split("/")[-1] if secret.name else ""
158
+ if secret_id and (not prefix or secret_id.startswith(prefix)):
159
+ secrets.append(secret_id)
160
+ return sorted(secrets)
161
+ except (
162
+ google_exceptions.PermissionDenied,
163
+ google_exceptions.Unauthenticated,
164
+ ) as e:
165
+ raise AuthenticationError(f"Access denied to list secrets: {e}") from e
166
+ except google_exceptions.GoogleAPICallError as e:
167
+ raise VaultError(f"GCP Secret Manager error: {e}") from e
168
+
169
+ def set_secret(self, name: str, value: str) -> SecretValue:
170
+ """
171
+ Create or update a secret in GCP Secret Manager.
172
+
173
+ Returns:
174
+ SecretValue containing the stored secret's name, value, version, and metadata.
175
+ """
176
+ self.ensure_authenticated()
177
+
178
+ secret_id = self._secret_id(name)
179
+ secret_path = self._secret_path(name)
180
+
181
+ try:
182
+ with contextlib.suppress(google_exceptions.AlreadyExists):
183
+ self._client.create_secret(
184
+ request={
185
+ "parent": self._project_path(),
186
+ "secret_id": secret_id,
187
+ "secret": {"replication": {"automatic": {}}},
188
+ }
189
+ )
190
+
191
+ version = self._client.add_secret_version(
192
+ request={
193
+ "parent": secret_path,
194
+ "payload": {"data": value.encode("utf-8")},
195
+ }
196
+ )
197
+ version_id = version.name.split("/")[-1] if version.name else None
198
+ return SecretValue(
199
+ name=secret_id,
200
+ value=value,
201
+ version=version_id,
202
+ metadata={"name": version.name},
203
+ )
204
+ except (
205
+ google_exceptions.PermissionDenied,
206
+ google_exceptions.Unauthenticated,
207
+ ) as e:
208
+ raise AuthenticationError(f"Access denied to write secret '{name}': {e}") from e
209
+ except google_exceptions.GoogleAPICallError as e:
210
+ raise VaultError(f"GCP Secret Manager error: {e}") from e
@@ -0,0 +1,238 @@
1
+ """HashiCorp Vault client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from envdrift.vault.base import (
8
+ AuthenticationError,
9
+ SecretNotFoundError,
10
+ SecretValue,
11
+ VaultClient,
12
+ VaultError,
13
+ )
14
+
15
+ try:
16
+ import hvac
17
+ from hvac.exceptions import Forbidden, InvalidPath, Unauthorized
18
+
19
+ HVAC_AVAILABLE = True
20
+ except ImportError:
21
+ HVAC_AVAILABLE = False
22
+ hvac = None
23
+ InvalidPath = Exception
24
+ Forbidden = Exception
25
+ Unauthorized = Exception
26
+
27
+
28
+ class HashiCorpVaultClient(VaultClient):
29
+ """HashiCorp Vault implementation.
30
+
31
+ Supports KV v2 secrets engine (the default in modern Vault).
32
+
33
+ Authentication methods supported:
34
+ - Token (via token parameter or VAULT_TOKEN env var)
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ url: str,
40
+ token: str | None = None,
41
+ mount_point: str = "secret",
42
+ ):
43
+ """
44
+ Create a HashiCorp Vault client configured to use the KV v2 secrets engine.
45
+
46
+ Parameters:
47
+ url (str): Vault server URL (e.g., "https://vault.example.com:8200").
48
+ token (str | None): Authentication token; if omitted, the VAULT_TOKEN environment variable is used.
49
+ mount_point (str): KV secrets engine mount point (default "secret").
50
+ """
51
+ if not HVAC_AVAILABLE:
52
+ raise ImportError("hvac not installed. Install with: pip install envdrift[hashicorp]")
53
+
54
+ self.url = url
55
+ self.token = token or os.environ.get("VAULT_TOKEN")
56
+ self.mount_point = mount_point
57
+ self._client: hvac.Client | None = None
58
+
59
+ def authenticate(self) -> None:
60
+ """
61
+ Authenticate the client against HashiCorp Vault using the configured token.
62
+
63
+ This initializes and verifies the underlying hvac client and stores it on the instance
64
+ when authentication succeeds.
65
+
66
+ Raises:
67
+ AuthenticationError: If no token was provided, the token is invalid, expired, or Vault
68
+ rejects authentication (including Unauthorized or Forbidden responses).
69
+ VaultError: For other connection or unexpected errors communicating with Vault.
70
+ """
71
+ if not self.token:
72
+ raise AuthenticationError(
73
+ "No Vault token provided. Set VAULT_TOKEN or pass token parameter."
74
+ )
75
+
76
+ try:
77
+ self._client = hvac.Client(url=self.url, token=self.token)
78
+
79
+ if not self._client.is_authenticated():
80
+ raise AuthenticationError("Vault token is invalid or expired")
81
+ except (Unauthorized, Forbidden) as e:
82
+ raise AuthenticationError(f"Vault authentication failed: {e}") from e
83
+ except Exception as e:
84
+ raise VaultError(f"Vault connection error: {e}") from e
85
+
86
+ def is_authenticated(self) -> bool:
87
+ """
88
+ Return whether the stored hvac client is currently authenticated.
89
+
90
+ Returns:
91
+ bool: `True` if an internal hvac client exists and reports it is authenticated, `False` otherwise.
92
+ """
93
+ if self._client is None:
94
+ return False
95
+ try:
96
+ return self._client.is_authenticated()
97
+ except Exception:
98
+ return False
99
+
100
+ def get_secret(self, name: str) -> SecretValue:
101
+ """
102
+ Retrieve a secret from Vault at the given path relative to the client's mount point.
103
+
104
+ If the stored secret data contains only a single key named "value", that value is returned; otherwise the entire secret data dict is JSON-encoded and returned as the value. The returned SecretValue includes the secret's version and metadata (created_time, deletion_time, destroyed, custom_metadata).
105
+
106
+ Parameters:
107
+ name: Secret path relative to the configured mount point.
108
+
109
+ Returns:
110
+ A SecretValue containing the secret's value, version, and metadata.
111
+
112
+ Raises:
113
+ SecretNotFoundError: If the secret path does not exist.
114
+ AuthenticationError: If access to the secret is denied or the client is unauthenticated.
115
+ VaultError: For other Vault-related errors.
116
+ """
117
+ self.ensure_authenticated()
118
+
119
+ try:
120
+ response = self._client.secrets.kv.v2.read_secret_version(
121
+ path=name,
122
+ mount_point=self.mount_point,
123
+ )
124
+
125
+ data = response.get("data", {})
126
+ secret_data = data.get("data", {})
127
+ metadata = data.get("metadata", {})
128
+
129
+ # If there's a single "value" key, return that
130
+ # Otherwise return the JSON string of all data
131
+ if "value" in secret_data and len(secret_data) == 1:
132
+ value = secret_data["value"]
133
+ else:
134
+ import json
135
+
136
+ value = json.dumps(secret_data)
137
+
138
+ return SecretValue(
139
+ name=name,
140
+ value=value,
141
+ version=str(metadata.get("version", "")),
142
+ metadata={
143
+ "created_time": metadata.get("created_time"),
144
+ "deletion_time": metadata.get("deletion_time"),
145
+ "destroyed": metadata.get("destroyed", False),
146
+ "custom_metadata": metadata.get("custom_metadata", {}),
147
+ },
148
+ )
149
+ except InvalidPath as e:
150
+ raise SecretNotFoundError(f"Secret '{name}' not found in Vault") from e
151
+ except (Unauthorized, Forbidden) as e:
152
+ raise AuthenticationError(f"Access denied to secret '{name}': {e}") from e
153
+ except Exception as e:
154
+ raise VaultError(f"Vault error: {e}") from e
155
+
156
+ def list_secrets(self, prefix: str = "") -> list[str]:
157
+ """List secret paths in HashiCorp Vault.
158
+
159
+ Args:
160
+ prefix: Path prefix to list under
161
+
162
+ Returns:
163
+ List of secret paths
164
+ """
165
+ self.ensure_authenticated()
166
+
167
+ try:
168
+ response = self._client.secrets.kv.v2.list_secrets(
169
+ path=prefix,
170
+ mount_point=self.mount_point,
171
+ )
172
+
173
+ keys = response.get("data", {}).get("keys", [])
174
+ return sorted(keys)
175
+ except InvalidPath:
176
+ # Path doesn't exist, return empty list
177
+ return []
178
+ except (Unauthorized, Forbidden) as e:
179
+ raise AuthenticationError(f"Access denied to list secrets: {e}") from e
180
+ except Exception as e:
181
+ raise VaultError(f"Vault error: {e}") from e
182
+
183
+ def create_or_update_secret(self, name: str, data: dict) -> SecretValue:
184
+ """
185
+ Create or update a secret at the given Vault path.
186
+
187
+ Parameters:
188
+ name (str): Secret path in the KV v2 engine.
189
+ data (dict): Dictionary of key-value pairs to store for the secret.
190
+
191
+ Returns:
192
+ SecretValue: The stored secret representation containing the secret `name`, a string `value` (JSON-encoded when multiple keys), the secret `version`, and metadata including `created_time`.
193
+
194
+ Raises:
195
+ AuthenticationError: If the client is not authorized to write the secret.
196
+ VaultError: For other Vault-related errors.
197
+ """
198
+ self.ensure_authenticated()
199
+
200
+ try:
201
+ response = self._client.secrets.kv.v2.create_or_update_secret(
202
+ path=name,
203
+ secret=data,
204
+ mount_point=self.mount_point,
205
+ )
206
+
207
+ metadata = response.get("data", {})
208
+
209
+ # Use same value extraction logic as get_secret for consistency
210
+ import json
211
+
212
+ value = data["value"] if ("value" in data and len(data) == 1) else json.dumps(data)
213
+
214
+ return SecretValue(
215
+ name=name,
216
+ value=value,
217
+ version=str(metadata.get("version", "")),
218
+ metadata={
219
+ "created_time": metadata.get("created_time"),
220
+ },
221
+ )
222
+ except (Unauthorized, Forbidden) as e:
223
+ raise AuthenticationError(f"Access denied to write secret: {e}") from e
224
+ except Exception as e:
225
+ raise VaultError(f"Vault error: {e}") from e
226
+
227
+ def set_secret(self, name: str, value: str) -> SecretValue:
228
+ """
229
+ Set a string secret at the given path.
230
+
231
+ Parameters:
232
+ name (str): Secret path in Vault.
233
+ value (str): Secret string to store.
234
+
235
+ Returns:
236
+ SecretValue: The created or updated secret, including its stored value and metadata.
237
+ """
238
+ return self.create_or_update_secret(name, {"value": value})