botmaro-secrets-manager 0.1.0__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.
- botmaro_secrets_manager-0.1.0.dist-info/METADATA +578 -0
- botmaro_secrets_manager-0.1.0.dist-info/RECORD +11 -0
- botmaro_secrets_manager-0.1.0.dist-info/WHEEL +5 -0
- botmaro_secrets_manager-0.1.0.dist-info/entry_points.txt +2 -0
- botmaro_secrets_manager-0.1.0.dist-info/licenses/LICENSE +21 -0
- botmaro_secrets_manager-0.1.0.dist-info/top_level.txt +1 -0
- secrets_manager/__init__.py +13 -0
- secrets_manager/cli.py +482 -0
- secrets_manager/config.py +83 -0
- secrets_manager/core.py +313 -0
- secrets_manager/gsm.py +180 -0
secrets_manager/cli.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Command-line interface for secrets management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich import print as rprint
|
|
11
|
+
|
|
12
|
+
from .core import SecretsManager
|
|
13
|
+
from .config import SecretsConfig
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="secrets-manager",
|
|
17
|
+
help="Botmaro Secrets Manager - Multi-environment secret management with Google Secret Manager",
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_target(target: str) -> tuple[str, Optional[str], Optional[str]]:
|
|
24
|
+
"""
|
|
25
|
+
Parse target string into (env, project, secret).
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
'staging.myproject' -> ('staging', 'myproject', None)
|
|
29
|
+
'staging' -> ('staging', None, None)
|
|
30
|
+
'staging.myproject.MY_SECRET' -> ('staging', 'myproject', 'MY_SECRET')
|
|
31
|
+
'staging.MY_SECRET' -> ('staging', None, 'MY_SECRET')
|
|
32
|
+
"""
|
|
33
|
+
parts = target.split(".")
|
|
34
|
+
|
|
35
|
+
if len(parts) == 1:
|
|
36
|
+
# Just environment
|
|
37
|
+
return parts[0], None, None
|
|
38
|
+
elif len(parts) == 2:
|
|
39
|
+
# Could be env.project or env.secret
|
|
40
|
+
# Heuristic: if second part is uppercase, it's a secret
|
|
41
|
+
if parts[1].isupper() or "_" in parts[1]:
|
|
42
|
+
return parts[0], None, parts[1]
|
|
43
|
+
else:
|
|
44
|
+
return parts[0], parts[1], None
|
|
45
|
+
elif len(parts) >= 3:
|
|
46
|
+
# env.project.secret
|
|
47
|
+
return parts[0], parts[1], ".".join(parts[2:])
|
|
48
|
+
else:
|
|
49
|
+
raise ValueError(f"Invalid target format: {target}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def bootstrap(
|
|
54
|
+
env: str = typer.Argument(..., help="Environment name (e.g., staging, prod)"),
|
|
55
|
+
project: Optional[str] = typer.Option(
|
|
56
|
+
None, "--project", "-p", help="Project name to scope secrets"
|
|
57
|
+
),
|
|
58
|
+
config: Optional[str] = typer.Option(
|
|
59
|
+
None, "--config", "-c", help="Path to secrets config file"
|
|
60
|
+
),
|
|
61
|
+
export: bool = typer.Option(
|
|
62
|
+
True, "--export/--no-export", help="Export secrets to environment variables"
|
|
63
|
+
),
|
|
64
|
+
runtime_sa: Optional[str] = typer.Option(
|
|
65
|
+
None, "--runtime-sa", help="Runtime service account to grant access"
|
|
66
|
+
),
|
|
67
|
+
deployer_sa: Optional[str] = typer.Option(
|
|
68
|
+
None, "--deployer-sa", help="Deployer service account to grant access"
|
|
69
|
+
),
|
|
70
|
+
output: Optional[str] = typer.Option(
|
|
71
|
+
None, "--output", "-o", help="Output file for .env format"
|
|
72
|
+
),
|
|
73
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
74
|
+
):
|
|
75
|
+
"""
|
|
76
|
+
Bootstrap environment by loading all required secrets.
|
|
77
|
+
|
|
78
|
+
This command loads all secrets defined in the configuration for the specified
|
|
79
|
+
environment and optionally exports them to the current shell environment.
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
\b
|
|
83
|
+
# Bootstrap staging environment
|
|
84
|
+
secrets-manager bootstrap staging
|
|
85
|
+
|
|
86
|
+
\b
|
|
87
|
+
# Bootstrap with project scope
|
|
88
|
+
secrets-manager bootstrap staging --project myapp
|
|
89
|
+
|
|
90
|
+
\b
|
|
91
|
+
# Save to .env file
|
|
92
|
+
secrets-manager bootstrap staging --output .env.staging
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
# Load config
|
|
96
|
+
if config:
|
|
97
|
+
os.environ["SECRETS_CONFIG_PATH"] = config
|
|
98
|
+
|
|
99
|
+
manager = SecretsManager()
|
|
100
|
+
|
|
101
|
+
with console.status(f"[bold green]Loading secrets for {env}..."):
|
|
102
|
+
secrets = manager.bootstrap(
|
|
103
|
+
env=env,
|
|
104
|
+
project=project,
|
|
105
|
+
export_to_env=export,
|
|
106
|
+
runtime_sa=runtime_sa,
|
|
107
|
+
deployer_sa=deployer_sa,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Display results
|
|
111
|
+
if verbose:
|
|
112
|
+
table = Table(title=f"Loaded Secrets - {env}" + (f".{project}" if project else ""))
|
|
113
|
+
table.add_column("Secret", style="cyan")
|
|
114
|
+
table.add_column("Value", style="green")
|
|
115
|
+
|
|
116
|
+
for key, value in secrets.items():
|
|
117
|
+
# Mask value for security
|
|
118
|
+
masked = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***"
|
|
119
|
+
table.add_row(key, masked)
|
|
120
|
+
|
|
121
|
+
console.print(table)
|
|
122
|
+
else:
|
|
123
|
+
console.print(f"[green]✓[/green] Loaded {len(secrets)} secrets for [bold]{env}[/bold]")
|
|
124
|
+
|
|
125
|
+
# Write to output file if specified
|
|
126
|
+
if output:
|
|
127
|
+
output_path = Path(output)
|
|
128
|
+
with open(output_path, "w") as f:
|
|
129
|
+
for key, value in secrets.items():
|
|
130
|
+
f.write(f"{key}={value}\n")
|
|
131
|
+
console.print(f"[green]✓[/green] Secrets written to {output_path}")
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
|
|
135
|
+
raise typer.Exit(code=1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def set(
|
|
140
|
+
target: str = typer.Argument(..., help="Target in format 'env[.project].SECRET_NAME'"),
|
|
141
|
+
value: Optional[str] = typer.Option(None, "--value", "-v", help="Secret value (or use stdin)"),
|
|
142
|
+
config: Optional[str] = typer.Option(
|
|
143
|
+
None, "--config", "-c", help="Path to secrets config file"
|
|
144
|
+
),
|
|
145
|
+
grant: Optional[List[str]] = typer.Option(
|
|
146
|
+
None, "--grant", "-g", help="Service accounts to grant access"
|
|
147
|
+
),
|
|
148
|
+
):
|
|
149
|
+
"""
|
|
150
|
+
Set a secret value (create or update).
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
\b
|
|
154
|
+
# Set an environment-scoped secret
|
|
155
|
+
secrets-manager set staging.API_KEY --value "sk-123456"
|
|
156
|
+
|
|
157
|
+
\b
|
|
158
|
+
# Set a project-scoped secret
|
|
159
|
+
secrets-manager set staging.myapp.DATABASE_URL --value "postgres://..."
|
|
160
|
+
|
|
161
|
+
\b
|
|
162
|
+
# Read value from stdin
|
|
163
|
+
echo "secret-value" | secrets-manager set staging.MY_SECRET
|
|
164
|
+
|
|
165
|
+
\b
|
|
166
|
+
# Grant access to service account
|
|
167
|
+
secrets-manager set staging.API_KEY --value "sk-123" --grant bot@project.iam.gserviceaccount.com
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
# Load config
|
|
171
|
+
if config:
|
|
172
|
+
os.environ["SECRETS_CONFIG_PATH"] = config
|
|
173
|
+
|
|
174
|
+
manager = SecretsManager()
|
|
175
|
+
|
|
176
|
+
# Parse target
|
|
177
|
+
env, project, secret = parse_target(target)
|
|
178
|
+
|
|
179
|
+
if not secret:
|
|
180
|
+
console.print("[red]✗ Error:[/red] Secret name required in target", style="bold red")
|
|
181
|
+
raise typer.Exit(code=1)
|
|
182
|
+
|
|
183
|
+
# Get value from stdin if not provided
|
|
184
|
+
if value is None:
|
|
185
|
+
if not sys.stdin.isatty():
|
|
186
|
+
value = sys.stdin.read().strip()
|
|
187
|
+
else:
|
|
188
|
+
value = typer.prompt("Enter secret value", hide_input=True)
|
|
189
|
+
|
|
190
|
+
# Set the secret
|
|
191
|
+
with console.status(f"[bold green]Setting secret..."):
|
|
192
|
+
result = manager.set_secret(
|
|
193
|
+
env=env,
|
|
194
|
+
secret=secret,
|
|
195
|
+
value=value,
|
|
196
|
+
project=project,
|
|
197
|
+
grant_to=grant,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
target_str = f"{env}.{project}.{secret}" if project else f"{env}.{secret}"
|
|
201
|
+
console.print(f"[green]✓[/green] Secret [bold]{target_str}[/bold] {result['status']}")
|
|
202
|
+
console.print(f" Version: {result['version']}")
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
|
|
206
|
+
raise typer.Exit(code=1)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.command()
|
|
210
|
+
def get(
|
|
211
|
+
target: str = typer.Argument(..., help="Target in format 'env[.project].SECRET_NAME'"),
|
|
212
|
+
version: str = typer.Option("latest", "--version", help="Secret version to retrieve"),
|
|
213
|
+
config: Optional[str] = typer.Option(
|
|
214
|
+
None, "--config", "-c", help="Path to secrets config file"
|
|
215
|
+
),
|
|
216
|
+
reveal: bool = typer.Option(False, "--reveal", help="Show the full secret value"),
|
|
217
|
+
):
|
|
218
|
+
"""
|
|
219
|
+
Get a secret value.
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
\b
|
|
223
|
+
# Get latest version of a secret
|
|
224
|
+
secrets-manager get staging.API_KEY --reveal
|
|
225
|
+
|
|
226
|
+
\b
|
|
227
|
+
# Get specific version
|
|
228
|
+
secrets-manager get staging.API_KEY --version 2
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
# Load config
|
|
232
|
+
if config:
|
|
233
|
+
os.environ["SECRETS_CONFIG_PATH"] = config
|
|
234
|
+
|
|
235
|
+
manager = SecretsManager()
|
|
236
|
+
|
|
237
|
+
# Parse target
|
|
238
|
+
env, project, secret = parse_target(target)
|
|
239
|
+
|
|
240
|
+
if not secret:
|
|
241
|
+
console.print("[red]✗ Error:[/red] Secret name required in target", style="bold red")
|
|
242
|
+
raise typer.Exit(code=1)
|
|
243
|
+
|
|
244
|
+
# Get the secret
|
|
245
|
+
value = manager.get_secret(env=env, secret=secret, project=project, version=version)
|
|
246
|
+
|
|
247
|
+
if value is None:
|
|
248
|
+
console.print(f"[yellow]![/yellow] Secret not found", style="bold yellow")
|
|
249
|
+
raise typer.Exit(code=1)
|
|
250
|
+
|
|
251
|
+
if reveal:
|
|
252
|
+
console.print(value)
|
|
253
|
+
else:
|
|
254
|
+
masked = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***"
|
|
255
|
+
console.print(f"Value: {masked} (use --reveal to show full value)")
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
|
|
259
|
+
raise typer.Exit(code=1)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@app.command()
|
|
263
|
+
def delete(
|
|
264
|
+
target: str = typer.Argument(..., help="Target in format 'env[.project].SECRET_NAME'"),
|
|
265
|
+
config: Optional[str] = typer.Option(
|
|
266
|
+
None, "--config", "-c", help="Path to secrets config file"
|
|
267
|
+
),
|
|
268
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
269
|
+
):
|
|
270
|
+
"""
|
|
271
|
+
Delete a secret.
|
|
272
|
+
|
|
273
|
+
Examples:
|
|
274
|
+
\b
|
|
275
|
+
# Delete a secret
|
|
276
|
+
secrets-manager delete staging.OLD_API_KEY
|
|
277
|
+
|
|
278
|
+
\b
|
|
279
|
+
# Force delete without confirmation
|
|
280
|
+
secrets-manager delete staging.OLD_API_KEY --force
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
# Load config
|
|
284
|
+
if config:
|
|
285
|
+
os.environ["SECRETS_CONFIG_PATH"] = config
|
|
286
|
+
|
|
287
|
+
manager = SecretsManager()
|
|
288
|
+
|
|
289
|
+
# Parse target
|
|
290
|
+
env, project, secret = parse_target(target)
|
|
291
|
+
|
|
292
|
+
if not secret:
|
|
293
|
+
console.print("[red]✗ Error:[/red] Secret name required in target", style="bold red")
|
|
294
|
+
raise typer.Exit(code=1)
|
|
295
|
+
|
|
296
|
+
target_str = f"{env}.{project}.{secret}" if project else f"{env}.{secret}"
|
|
297
|
+
|
|
298
|
+
# Confirm deletion
|
|
299
|
+
if not force:
|
|
300
|
+
confirm = typer.confirm(f"Delete secret '{target_str}'?")
|
|
301
|
+
if not confirm:
|
|
302
|
+
console.print("Cancelled")
|
|
303
|
+
raise typer.Exit(code=0)
|
|
304
|
+
|
|
305
|
+
# Delete the secret
|
|
306
|
+
deleted = manager.delete_secret(env=env, secret=secret, project=project)
|
|
307
|
+
|
|
308
|
+
if deleted:
|
|
309
|
+
console.print(f"[green]✓[/green] Secret [bold]{target_str}[/bold] deleted")
|
|
310
|
+
else:
|
|
311
|
+
console.print(f"[yellow]![/yellow] Secret not found", style="bold yellow")
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
|
|
315
|
+
raise typer.Exit(code=1)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.command()
|
|
319
|
+
def list(
|
|
320
|
+
env: str = typer.Argument(..., help="Environment name"),
|
|
321
|
+
project: Optional[str] = typer.Option(
|
|
322
|
+
None, "--project", "-p", help="Project name to filter by"
|
|
323
|
+
),
|
|
324
|
+
config: Optional[str] = typer.Option(
|
|
325
|
+
None, "--config", "-c", help="Path to secrets config file"
|
|
326
|
+
),
|
|
327
|
+
reveal: bool = typer.Option(False, "--reveal", help="Show secret values"),
|
|
328
|
+
):
|
|
329
|
+
"""
|
|
330
|
+
List all secrets for an environment.
|
|
331
|
+
|
|
332
|
+
Examples:
|
|
333
|
+
\b
|
|
334
|
+
# List all secrets for staging
|
|
335
|
+
secrets-manager list staging
|
|
336
|
+
|
|
337
|
+
\b
|
|
338
|
+
# List secrets for a specific project
|
|
339
|
+
secrets-manager list staging --project myapp
|
|
340
|
+
"""
|
|
341
|
+
try:
|
|
342
|
+
# Load config
|
|
343
|
+
if config:
|
|
344
|
+
os.environ["SECRETS_CONFIG_PATH"] = config
|
|
345
|
+
|
|
346
|
+
manager = SecretsManager()
|
|
347
|
+
|
|
348
|
+
# List secrets
|
|
349
|
+
with console.status(f"[bold green]Loading secrets..."):
|
|
350
|
+
secrets = manager.list_secrets(env=env, project=project)
|
|
351
|
+
|
|
352
|
+
# Display results
|
|
353
|
+
table = Table(title=f"Secrets - {env}" + (f".{project}" if project else ""))
|
|
354
|
+
table.add_column("Secret Name", style="cyan")
|
|
355
|
+
table.add_column("Value", style="green")
|
|
356
|
+
|
|
357
|
+
for name, value in secrets:
|
|
358
|
+
if value and reveal:
|
|
359
|
+
table.add_row(name, value)
|
|
360
|
+
elif value:
|
|
361
|
+
masked = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "***"
|
|
362
|
+
table.add_row(name, masked)
|
|
363
|
+
else:
|
|
364
|
+
table.add_row(name, "[red]<not found>[/red]")
|
|
365
|
+
|
|
366
|
+
console.print(table)
|
|
367
|
+
console.print(f"\nTotal: {len(secrets)} secrets")
|
|
368
|
+
|
|
369
|
+
except Exception as e:
|
|
370
|
+
console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
|
|
371
|
+
raise typer.Exit(code=1)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@app.command()
|
|
375
|
+
def grant_access(
|
|
376
|
+
target: str = typer.Argument(
|
|
377
|
+
..., help="Target in format 'env' or 'env.project' to grant access to all secrets"
|
|
378
|
+
),
|
|
379
|
+
service_account: List[str] = typer.Option(
|
|
380
|
+
..., "--sa", help="Service account email(s) to grant access (can be repeated)"
|
|
381
|
+
),
|
|
382
|
+
config: Optional[str] = typer.Option(
|
|
383
|
+
None, "--config", "-c", help="Path to secrets config file"
|
|
384
|
+
),
|
|
385
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
|
|
386
|
+
):
|
|
387
|
+
"""
|
|
388
|
+
Grant access to all secrets in an environment or project.
|
|
389
|
+
|
|
390
|
+
This grants secretAccessor role to the specified service account(s) for
|
|
391
|
+
all secrets in the given scope.
|
|
392
|
+
|
|
393
|
+
Examples:
|
|
394
|
+
\b
|
|
395
|
+
# Grant access to all staging secrets
|
|
396
|
+
secrets-manager grant-access staging --sa bot@project.iam.gserviceaccount.com
|
|
397
|
+
|
|
398
|
+
\b
|
|
399
|
+
# Grant access to multiple service accounts
|
|
400
|
+
secrets-manager grant-access staging \\
|
|
401
|
+
--sa bot@project.iam.gserviceaccount.com \\
|
|
402
|
+
--sa deployer@project.iam.gserviceaccount.com
|
|
403
|
+
|
|
404
|
+
\b
|
|
405
|
+
# Grant access to project-specific secrets
|
|
406
|
+
secrets-manager grant-access staging.myapp --sa runtime@project.iam.gserviceaccount.com
|
|
407
|
+
"""
|
|
408
|
+
try:
|
|
409
|
+
# Load config
|
|
410
|
+
if config:
|
|
411
|
+
os.environ["SECRETS_CONFIG_PATH"] = config
|
|
412
|
+
|
|
413
|
+
manager = SecretsManager()
|
|
414
|
+
|
|
415
|
+
# Parse target
|
|
416
|
+
env, project, secret = parse_target(target)
|
|
417
|
+
|
|
418
|
+
if secret:
|
|
419
|
+
console.print(
|
|
420
|
+
"[red]✗ Error:[/red] grant-access works on environment or project level, not individual secrets",
|
|
421
|
+
style="bold red",
|
|
422
|
+
)
|
|
423
|
+
console.print(
|
|
424
|
+
"Use 'secrets-manager set <target> --value ... --grant ...' for individual secrets"
|
|
425
|
+
)
|
|
426
|
+
raise typer.Exit(code=1)
|
|
427
|
+
|
|
428
|
+
# Show what will be affected
|
|
429
|
+
target_str = f"{env}.{project}" if project else env
|
|
430
|
+
scope = f"project '{project}' in environment '{env}'" if project else f"environment '{env}'"
|
|
431
|
+
|
|
432
|
+
console.print(f"[bold]Will grant access to all secrets in {scope}[/bold]")
|
|
433
|
+
console.print(f"Service accounts:")
|
|
434
|
+
for sa in service_account:
|
|
435
|
+
console.print(f" - {sa}")
|
|
436
|
+
|
|
437
|
+
# List affected secrets
|
|
438
|
+
with console.status(f"[bold green]Finding secrets..."):
|
|
439
|
+
secrets = manager.list_secrets(env=env, project=project)
|
|
440
|
+
|
|
441
|
+
console.print(f"\nAffected secrets ({len(secrets)}):")
|
|
442
|
+
for name, _ in secrets[:10]: # Show first 10
|
|
443
|
+
console.print(f" - {name}")
|
|
444
|
+
if len(secrets) > 10:
|
|
445
|
+
console.print(f" ... and {len(secrets) - 10} more")
|
|
446
|
+
|
|
447
|
+
# Confirm
|
|
448
|
+
if not force:
|
|
449
|
+
console.print()
|
|
450
|
+
confirm = typer.confirm(
|
|
451
|
+
f"Grant access to {len(service_account)} service account(s) for {len(secrets)} secret(s)?"
|
|
452
|
+
)
|
|
453
|
+
if not confirm:
|
|
454
|
+
console.print("Cancelled")
|
|
455
|
+
raise typer.Exit(code=0)
|
|
456
|
+
|
|
457
|
+
# Grant access
|
|
458
|
+
with console.status(f"[bold green]Granting access..."):
|
|
459
|
+
result = manager.grant_access_bulk(
|
|
460
|
+
env=env, service_accounts=service_account, project=project
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
console.print(
|
|
464
|
+
f"[green]✓[/green] Granted access to {result['secrets_updated']} secrets "
|
|
465
|
+
f"for {result['service_accounts']} service account(s)"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
except Exception as e:
|
|
469
|
+
console.print(f"[red]✗ Error:[/red] {str(e)}", style="bold red")
|
|
470
|
+
raise typer.Exit(code=1)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@app.command()
|
|
474
|
+
def version():
|
|
475
|
+
"""Show version information."""
|
|
476
|
+
from . import __version__
|
|
477
|
+
|
|
478
|
+
console.print(f"Botmaro Secrets Manager v{__version__}")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
if __name__ == "__main__":
|
|
482
|
+
app()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Configuration models and loaders for secrets management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, Union
|
|
6
|
+
import yaml
|
|
7
|
+
import json
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecretConfig(BaseModel):
|
|
12
|
+
"""Configuration for a single secret."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
description: Optional[str] = None
|
|
16
|
+
required: bool = True
|
|
17
|
+
default: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProjectConfig(BaseModel):
|
|
21
|
+
"""Configuration for a project within an environment."""
|
|
22
|
+
|
|
23
|
+
project_id: str
|
|
24
|
+
secrets: List[SecretConfig] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EnvironmentConfig(BaseModel):
|
|
28
|
+
"""Configuration for an environment (staging, prod, etc.)."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
gcp_project: str
|
|
32
|
+
prefix: Optional[str] = None
|
|
33
|
+
projects: Dict[str, ProjectConfig] = Field(default_factory=dict)
|
|
34
|
+
global_secrets: List[SecretConfig] = Field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
@field_validator("prefix", mode="before")
|
|
37
|
+
@classmethod
|
|
38
|
+
def set_prefix(cls, v: Optional[str], info) -> str:
|
|
39
|
+
"""Auto-generate prefix if not provided."""
|
|
40
|
+
if v is None and "name" in info.data:
|
|
41
|
+
return f"botmaro-{info.data['name']}"
|
|
42
|
+
return v or ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SecretsConfig(BaseModel):
|
|
46
|
+
"""Root configuration for all environments and secrets."""
|
|
47
|
+
|
|
48
|
+
version: str = "1.0"
|
|
49
|
+
environments: Dict[str, EnvironmentConfig] = Field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_file(cls, path: Union[str, Path]) -> "SecretsConfig":
|
|
53
|
+
"""Load configuration from YAML or JSON file."""
|
|
54
|
+
path = Path(path)
|
|
55
|
+
if not path.exists():
|
|
56
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
57
|
+
|
|
58
|
+
with open(path, "r") as f:
|
|
59
|
+
if path.suffix in [".yaml", ".yml"]:
|
|
60
|
+
data = yaml.safe_load(f)
|
|
61
|
+
elif path.suffix == ".json":
|
|
62
|
+
data = json.load(f)
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError(f"Unsupported file type: {path.suffix}")
|
|
65
|
+
|
|
66
|
+
return cls(**data)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_env(cls) -> "SecretsConfig":
|
|
70
|
+
"""Load configuration from environment variables."""
|
|
71
|
+
config_path = os.getenv("SECRETS_CONFIG_PATH", "secrets.yml")
|
|
72
|
+
return cls.from_file(config_path)
|
|
73
|
+
|
|
74
|
+
def get_environment(self, env_name: str) -> Optional[EnvironmentConfig]:
|
|
75
|
+
"""Get configuration for a specific environment."""
|
|
76
|
+
return self.environments.get(env_name)
|
|
77
|
+
|
|
78
|
+
def get_project(self, env_name: str, project_name: str) -> Optional[ProjectConfig]:
|
|
79
|
+
"""Get configuration for a specific project in an environment."""
|
|
80
|
+
env = self.get_environment(env_name)
|
|
81
|
+
if env:
|
|
82
|
+
return env.projects.get(project_name)
|
|
83
|
+
return None
|