dataplat 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.
Files changed (85) hide show
  1. dataplat/__init__.py +3 -0
  2. dataplat/cli/__init__.py +1 -0
  3. dataplat/cli/_missing.py +108 -0
  4. dataplat/cli/bi/__init__.py +1 -0
  5. dataplat/cli/bi/app.py +14 -0
  6. dataplat/cli/bi/superset.py +558 -0
  7. dataplat/cli/ci/__init__.py +1 -0
  8. dataplat/cli/ci/app.py +14 -0
  9. dataplat/cli/ci/github/__init__.py +1 -0
  10. dataplat/cli/ci/github/app.py +10 -0
  11. dataplat/cli/ci/github/runner.py +399 -0
  12. dataplat/cli/cloud/__init__.py +1 -0
  13. dataplat/cli/cloud/app.py +14 -0
  14. dataplat/cli/cloud/aws/__init__.py +1 -0
  15. dataplat/cli/cloud/aws/_common.py +77 -0
  16. dataplat/cli/cloud/aws/app.py +12 -0
  17. dataplat/cli/cloud/aws/rds.py +686 -0
  18. dataplat/cli/cloud/aws/redshift.py +312 -0
  19. dataplat/cli/cloud/aws/secrets.py +875 -0
  20. dataplat/cli/config.py +492 -0
  21. dataplat/cli/db/__init__.py +342 -0
  22. dataplat/cli/db/_common.py +130 -0
  23. dataplat/cli/db/_report.py +180 -0
  24. dataplat/cli/db/dbt_orphans.py +842 -0
  25. dataplat/cli/db/describe.py +939 -0
  26. dataplat/cli/db/long_queries.py +299 -0
  27. dataplat/cli/db/role.py +434 -0
  28. dataplat/cli/db/role_create.py +452 -0
  29. dataplat/cli/db/role_drop.py +290 -0
  30. dataplat/cli/db/role_list.py +147 -0
  31. dataplat/cli/db/top_tables.py +337 -0
  32. dataplat/cli/ingest/__init__.py +1 -0
  33. dataplat/cli/ingest/airbyte/__init__.py +5 -0
  34. dataplat/cli/ingest/airbyte/_common.py +36 -0
  35. dataplat/cli/ingest/airbyte/_cursor.py +268 -0
  36. dataplat/cli/ingest/airbyte/_resource.py +192 -0
  37. dataplat/cli/ingest/airbyte/app.py +31 -0
  38. dataplat/cli/ingest/airbyte/connections.py +1234 -0
  39. dataplat/cli/ingest/airbyte/definitions.py +122 -0
  40. dataplat/cli/ingest/airbyte/destinations.py +26 -0
  41. dataplat/cli/ingest/airbyte/enums.py +45 -0
  42. dataplat/cli/ingest/airbyte/jobs.py +112 -0
  43. dataplat/cli/ingest/airbyte/sources.py +26 -0
  44. dataplat/cli/ingest/airbyte/tags.py +57 -0
  45. dataplat/cli/ingest/airbyte/templates.py +144 -0
  46. dataplat/cli/ingest/airbyte/tui.py +123 -0
  47. dataplat/cli/ingest/airbyte/workspaces.py +80 -0
  48. dataplat/cli/ingest/app.py +14 -0
  49. dataplat/cli/open.py +117 -0
  50. dataplat/cli/status.py +308 -0
  51. dataplat/core/__init__.py +1 -0
  52. dataplat/core/deps.py +138 -0
  53. dataplat/core/envrc.py +125 -0
  54. dataplat/core/errors.py +23 -0
  55. dataplat/main.py +87 -0
  56. dataplat/services/__init__.py +1 -0
  57. dataplat/services/airbyte/__init__.py +1 -0
  58. dataplat/services/airbyte/_resource.py +113 -0
  59. dataplat/services/airbyte/client.py +280 -0
  60. dataplat/services/airbyte/connections.py +306 -0
  61. dataplat/services/airbyte/definitions.py +64 -0
  62. dataplat/services/airbyte/destinations.py +68 -0
  63. dataplat/services/airbyte/jobs.py +72 -0
  64. dataplat/services/airbyte/sources.py +58 -0
  65. dataplat/services/airbyte/tags.py +120 -0
  66. dataplat/services/airbyte/workspaces.py +47 -0
  67. dataplat/services/aws/__init__.py +1 -0
  68. dataplat/services/aws/auth.py +80 -0
  69. dataplat/services/db/__init__.py +1 -0
  70. dataplat/services/db/connection.py +169 -0
  71. dataplat/services/db/describe.py +1034 -0
  72. dataplat/services/db/long_queries.py +378 -0
  73. dataplat/services/db/orphans.py +429 -0
  74. dataplat/services/db/role.py +812 -0
  75. dataplat/services/db/role_admin.py +458 -0
  76. dataplat/services/db/role_dialects.py +521 -0
  77. dataplat/services/db/targets.py +128 -0
  78. dataplat/services/db/top_tables.py +193 -0
  79. dataplat/services/superset/__init__.py +1 -0
  80. dataplat/services/superset/client.py +319 -0
  81. dataplat-0.1.0.dist-info/METADATA +326 -0
  82. dataplat-0.1.0.dist-info/RECORD +85 -0
  83. dataplat-0.1.0.dist-info/WHEEL +4 -0
  84. dataplat-0.1.0.dist-info/entry_points.txt +2 -0
  85. dataplat-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,875 @@
1
+ """AWS Secrets Manager management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ import typer
10
+ from botocore.exceptions import ClientError
11
+ from rich.console import Console
12
+ from rich.syntax import Syntax
13
+ from rich.table import Table
14
+
15
+ from dataplat.cli.cloud.aws._common import default_profile, default_region
16
+ from dataplat.core.errors import AuthError
17
+ from dataplat.services.aws.auth import get_client
18
+
19
+ app = typer.Typer(
20
+ name="secrets",
21
+ help="Manage AWS Secrets Manager secrets",
22
+ no_args_is_help=True,
23
+ )
24
+
25
+ console = Console()
26
+
27
+ # ── environment aliases ─────────────────────────────────────────────────────
28
+ def _profile_aliases() -> dict[str, str]:
29
+ """Short profile aliases from ``DP_AWS_PROFILE_ALIASES``.
30
+
31
+ Format: ``alias=ProfileName,alias2=OtherProfile`` — e.g.
32
+ ``prod=AdminAccess-Prod,qa=AdminAccess-QA``.
33
+ """
34
+ aliases: dict[str, str] = {}
35
+ for chunk in os.getenv("DP_AWS_PROFILE_ALIASES", "").split(","):
36
+ alias, sep, full = chunk.partition("=")
37
+ if sep and alias.strip() and full.strip():
38
+ aliases[alias.strip()] = full.strip()
39
+ return aliases
40
+
41
+
42
+ def _resolve_profile(name: str) -> str:
43
+ """Resolve a short alias to the full AWS profile name."""
44
+ return _profile_aliases().get(name, name)
45
+
46
+
47
+ def _resolve_profiles(profiles: list[str]) -> list[str]:
48
+ """Resolve a list of profile names / aliases, expanding the special 'all' keyword."""
49
+ resolved: list[str] = []
50
+ for p in profiles:
51
+ if p == "all":
52
+ resolved.extend(_profile_aliases().values())
53
+ else:
54
+ resolved.append(_resolve_profile(p))
55
+ # deduplicate while preserving order
56
+ seen: set[str] = set()
57
+ return [p for p in resolved if not (p in seen or seen.add(p))] # type: ignore[func-returns-value]
58
+
59
+
60
+ def _get_client(profile: str | None = None, region: str | None = None):
61
+ """Return a boto3 Secrets Manager client, triggering SSO login if needed."""
62
+ resolved = _resolve_profile(profile) if profile else default_profile()
63
+ try:
64
+ return get_client(
65
+ service_name="secretsmanager",
66
+ profile=resolved,
67
+ region=region or default_region(),
68
+ notify=lambda msg: console.print(f"[yellow]{msg}[/yellow]"),
69
+ )
70
+ except AuthError as exc:
71
+ console.print(f"[red]{exc}[/red]")
72
+ raise typer.Exit(code=1)
73
+
74
+
75
+ def _get_sts_client(profile: str | None = None, region: str | None = None):
76
+ """Return a boto3 STS client, triggering SSO login if needed."""
77
+ resolved = _resolve_profile(profile) if profile else default_profile()
78
+ try:
79
+ return get_client(
80
+ service_name="sts",
81
+ profile=resolved,
82
+ region=region or default_region(),
83
+ notify=lambda msg: console.print(f"[yellow]{msg}[/yellow]"),
84
+ )
85
+ except AuthError as exc:
86
+ console.print(f"[red]{exc}[/red]")
87
+ raise typer.Exit(code=1)
88
+
89
+
90
+ # ── shared options ──────────────────────────────────────────────────────────
91
+ ProfileOption = typer.Option(
92
+ None,
93
+ "--profile",
94
+ "-p",
95
+ help="AWS profile name or alias (see DP_AWS_PROFILE_ALIASES). "
96
+ "Defaults to DP_AWS_PROFILE.",
97
+ )
98
+ RegionOption = typer.Option(
99
+ None,
100
+ "--region",
101
+ "-r",
102
+ help="AWS region. Defaults to DP_AWS_REGION/AWS_REGION or the profile's region.",
103
+ )
104
+ JsonOption = typer.Option(False, "--json", help="Emit JSON instead of a table.")
105
+ YesOption = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt.")
106
+
107
+
108
+ def _alias_for(profile: str) -> str:
109
+ """Return the short alias for a full profile name, or the name itself."""
110
+ return next((a for a, p in _profile_aliases().items() if p == profile), profile)
111
+
112
+
113
+ def _confirm_or_abort(summary: str, yes: bool) -> None:
114
+ """Require confirmation before a write. Non-interactive runs need --yes."""
115
+ if yes:
116
+ return
117
+ console.print(summary)
118
+ if sys.stdin.isatty():
119
+ if typer.confirm("Proceed?", default=False):
120
+ return
121
+ console.print("[yellow]Aborted.[/yellow]")
122
+ raise typer.Exit(code=1)
123
+ console.print(
124
+ "[red]Error: refusing to write without confirmation. "
125
+ "Pass --yes/-y in non-interactive contexts.[/red]"
126
+ )
127
+ raise typer.Exit(code=1)
128
+
129
+
130
+ def _confirm_write(action: str, name: str, profs: list[str], yes: bool) -> None:
131
+ aliases = ", ".join(_alias_for(p) for p in profs)
132
+ _confirm_or_abort(
133
+ f"[bold]{action}[/bold] [cyan]{name}[/cyan] in: [yellow]{aliases}[/yellow]",
134
+ yes,
135
+ )
136
+
137
+
138
+ def _client_error_exit(action: str, exc: ClientError) -> None:
139
+ code = exc.response.get("Error", {}).get("Code", "ClientError")
140
+ msg = exc.response.get("Error", {}).get("Message", str(exc))
141
+ console.print(f"[red]{code} on {action}: {msg}[/red]")
142
+ raise typer.Exit(code=1)
143
+
144
+
145
+ def _read_stdin_value() -> str:
146
+ if sys.stdin.isatty():
147
+ console.print("[red]--value-stdin requires piped input[/red]")
148
+ raise typer.Exit(code=1)
149
+ return sys.stdin.read().rstrip("\n")
150
+
151
+
152
+ @app.command("list")
153
+ def list_secrets(
154
+ prefix: str | None = typer.Option(
155
+ None, "--prefix", help="Filter secrets whose name starts with this prefix"
156
+ ),
157
+ profile: str | None = ProfileOption,
158
+ region: str | None = RegionOption,
159
+ as_json: bool = JsonOption,
160
+ ) -> None:
161
+ """List secrets (optionally filtered by name prefix)."""
162
+ client = _get_client(profile, region)
163
+
164
+ paginator = client.get_paginator("list_secrets")
165
+ filters = []
166
+ if prefix:
167
+ filters.append({"Key": "name", "Values": [prefix]})
168
+
169
+ entries: list[dict] = []
170
+ try:
171
+ for page in (
172
+ paginator.paginate(Filters=filters) if filters else paginator.paginate()
173
+ ):
174
+ for s in page.get("SecretList", []):
175
+ entries.append(
176
+ {
177
+ "name": s["Name"],
178
+ "description": s.get("Description", ""),
179
+ "last_changed": str(
180
+ s.get("LastChangedDate", s.get("CreatedDate", ""))
181
+ ),
182
+ }
183
+ )
184
+ except ClientError as exc:
185
+ _client_error_exit("list_secrets", exc)
186
+
187
+ if as_json:
188
+ typer.echo(json.dumps(entries, indent=2))
189
+ return
190
+
191
+ table = Table(title="Secrets")
192
+ table.add_column("Name", style="cyan")
193
+ table.add_column("Description")
194
+ table.add_column("Last Changed", style="dim")
195
+ for entry in entries:
196
+ table.add_row(entry["name"], entry["description"], entry["last_changed"])
197
+
198
+ console.print(table)
199
+ console.print(f"\n[dim]{len(entries)} secret(s) found[/dim]")
200
+
201
+
202
+ @app.command("get")
203
+ def get_secret(
204
+ name: str = typer.Argument(help="Secret name or ARN"),
205
+ key: str | None = typer.Option(
206
+ None, "--key", "-k", help="Extract a single key from a JSON secret"
207
+ ),
208
+ raw: bool = typer.Option(
209
+ False, "--raw", help="Print the raw value without formatting"
210
+ ),
211
+ profile: str | None = ProfileOption,
212
+ region: str | None = RegionOption,
213
+ ) -> None:
214
+ """Retrieve and display a secret value."""
215
+ client = _get_client(profile, region)
216
+
217
+ try:
218
+ resp = client.get_secret_value(SecretId=name)
219
+ except client.exceptions.ResourceNotFoundException:
220
+ console.print(f"[red]Secret not found: {name}[/red]")
221
+ raise typer.Exit(code=1)
222
+ except ClientError as exc:
223
+ _client_error_exit("get_secret_value", exc)
224
+
225
+ value = resp.get("SecretString", "")
226
+
227
+ if key:
228
+ try:
229
+ data = json.loads(value)
230
+ except json.JSONDecodeError:
231
+ console.print("[red]Secret is not valid JSON — cannot extract key[/red]")
232
+ raise typer.Exit(code=1)
233
+ if key not in data:
234
+ console.print(f"[red]Key '{key}' not found in secret[/red]")
235
+ raise typer.Exit(code=1)
236
+ value = str(data[key])
237
+
238
+ if raw:
239
+ typer.echo(value)
240
+ return
241
+
242
+ # try pretty-printing JSON
243
+ try:
244
+ parsed = json.loads(value)
245
+ formatted = json.dumps(parsed, indent=2, ensure_ascii=False)
246
+ console.print(Syntax(formatted, "json", theme="monokai"))
247
+ except json.JSONDecodeError:
248
+ console.print(value)
249
+
250
+
251
+ @app.command("compare")
252
+ def compare_secret(
253
+ name: str = typer.Argument(help="Secret name or ARN"),
254
+ profiles: list[str] = typer.Option(
255
+ ["all"],
256
+ "--profile",
257
+ "-p",
258
+ help="AWS profiles/aliases to compare (repeatable). 'all' expands "
259
+ "to every alias in DP_AWS_PROFILE_ALIASES.",
260
+ ),
261
+ region: str | None = RegionOption,
262
+ as_json: bool = JsonOption,
263
+ ) -> None:
264
+ """Compare a JSON secret across multiple environments side by side.
265
+
266
+ Example:
267
+
268
+ dp cloud aws secrets compare /my/app/config
269
+ dp cloud aws secrets compare /my/app/config -p prod -p qa
270
+ """
271
+ resolved = _resolve_profiles(profiles)
272
+ env_data: dict[str, dict | None] = {}
273
+
274
+ for prof in resolved:
275
+ alias = _alias_for(prof)
276
+ client = _get_client(prof, region)
277
+ try:
278
+ resp = client.get_secret_value(SecretId=name)
279
+ raw = resp.get("SecretString", "")
280
+ try:
281
+ env_data[alias] = json.loads(raw)
282
+ except json.JSONDecodeError:
283
+ console.print(
284
+ f"[yellow]\\[{alias}] Secret is not JSON — showing raw value[/yellow]"
285
+ )
286
+ env_data[alias] = {"<raw>": raw}
287
+ except client.exceptions.ResourceNotFoundException:
288
+ console.print(f"[yellow]\\[{alias}] Secret not found[/yellow]")
289
+ env_data[alias] = None
290
+ except ClientError as exc:
291
+ _client_error_exit("get_secret_value", exc)
292
+
293
+ if as_json:
294
+ typer.echo(json.dumps({"name": name, "environments": env_data}, indent=2))
295
+ return
296
+
297
+ # collect all keys across all environments
298
+ all_keys: list[str] = []
299
+ seen: set[str] = set()
300
+ for data in env_data.values():
301
+ if data is not None:
302
+ for k in data:
303
+ if k not in seen:
304
+ all_keys.append(k)
305
+ seen.add(k)
306
+
307
+ aliases = list(env_data.keys())
308
+
309
+ table = Table(title=f"Secret: {name}")
310
+ table.add_column("Key", style="cyan bold")
311
+ for alias in aliases:
312
+ table.add_column(alias, overflow="fold")
313
+
314
+ for k in all_keys:
315
+ row_values: list[str] = []
316
+ vals_for_diff: list[str | None] = []
317
+ for alias in aliases:
318
+ data = env_data[alias]
319
+ val = str(data[k]) if data is not None and k in data else None
320
+ vals_for_diff.append(val)
321
+ row_values.append(val if val is not None else "[dim]null[/dim]")
322
+
323
+ # highlight differences across environments
324
+ unique = set(v for v in vals_for_diff if v is not None)
325
+ if len(unique) > 1:
326
+ # values differ — colour them
327
+ row_values = [
328
+ f"[red]{v}[/red]" if v != "[dim]null[/dim]" else v for v in row_values
329
+ ]
330
+
331
+ table.add_row(k, *row_values)
332
+
333
+ console.print(table)
334
+
335
+
336
+ @app.command("set")
337
+ def set_secret(
338
+ name: str = typer.Argument(help="Secret name or ARN"),
339
+ value: str | None = typer.Option(
340
+ None,
341
+ "--value",
342
+ "-v",
343
+ help="Plain-text secret value (prefer --value-stdin: argv is visible in ps).",
344
+ ),
345
+ value_stdin: bool = typer.Option(
346
+ False, "--value-stdin", help="Read the secret value from stdin."
347
+ ),
348
+ from_json: str | None = typer.Option(
349
+ None,
350
+ "--from-json",
351
+ "-j",
352
+ help='Set the secret from a JSON string (e.g. \'{"user":"admin"}\')',
353
+ ),
354
+ from_file: str | None = typer.Option(
355
+ None,
356
+ "--from-file",
357
+ "-f",
358
+ help="Read secret value from a file path",
359
+ ),
360
+ description: str | None = typer.Option(
361
+ None, "--description", help="Secret description"
362
+ ),
363
+ profiles: list[str] = typer.Option(
364
+ ["prod"],
365
+ "--profile",
366
+ "-p",
367
+ help="AWS profile/alias (repeatable). Use 'all' for prod+qa.",
368
+ ),
369
+ region: str | None = RegionOption,
370
+ yes: bool = YesOption,
371
+ ) -> None:
372
+ """Create or update a secret.
373
+
374
+ Provide the value with exactly one of --value, --value-stdin, --from-json,
375
+ or --from-file. If the secret already exists it will be updated; otherwise
376
+ it will be created.
377
+
378
+ Pass --profile multiple times (or 'all') to target several accounts:
379
+
380
+ echo -n "x" | dp cloud aws secrets set my/secret --value-stdin -p prod -p qa
381
+ dp cloud aws secrets set my/secret --from-file value.json -p all
382
+ """
383
+ sources = [value, from_json, from_file, (True if value_stdin else None)]
384
+ provided = sum(1 for s in sources if s is not None)
385
+ if provided != 1:
386
+ console.print(
387
+ "[red]Provide exactly one of --value, --value-stdin, "
388
+ "--from-json, or --from-file[/red]"
389
+ )
390
+ raise typer.Exit(code=1)
391
+
392
+ if value_stdin:
393
+ secret_value = _read_stdin_value()
394
+ elif from_file:
395
+ path = os.path.expanduser(from_file)
396
+ try:
397
+ with open(path) as fh:
398
+ secret_value = fh.read()
399
+ except OSError as exc:
400
+ console.print(f"[red]Cannot read file: {exc}[/red]")
401
+ raise typer.Exit(code=1)
402
+ elif from_json:
403
+ try:
404
+ json.loads(from_json)
405
+ except json.JSONDecodeError:
406
+ console.print("[red]--from-json value is not valid JSON[/red]")
407
+ raise typer.Exit(code=1)
408
+ secret_value = from_json
409
+ else:
410
+ secret_value = value # type: ignore[assignment]
411
+
412
+ resolved = _resolve_profiles(profiles)
413
+ _confirm_write("Write secret", name, resolved, yes)
414
+
415
+ for prof in resolved:
416
+ client = _get_client(prof, region)
417
+ alias = _alias_for(prof)
418
+ try:
419
+ client.put_secret_value(SecretId=name, SecretString=secret_value)
420
+ if description is not None:
421
+ client.update_secret(SecretId=name, Description=description)
422
+ console.print(f"[green]\\[{alias}] Updated secret:[/green] {name}")
423
+ except client.exceptions.ResourceNotFoundException:
424
+ kwargs: dict = {"Name": name, "SecretString": secret_value}
425
+ if description is not None:
426
+ kwargs["Description"] = description
427
+ client.create_secret(**kwargs)
428
+ console.print(f"[green]\\[{alias}] Created secret:[/green] {name}")
429
+ except ClientError as exc:
430
+ code = exc.response.get("Error", {}).get("Code", "ClientError")
431
+ msg = exc.response.get("Error", {}).get("Message", str(exc))
432
+ console.print(f"[red]\\[{alias}] {code} on put_secret_value: {msg}[/red]")
433
+
434
+
435
+ @app.command("edit")
436
+ def edit_secret(
437
+ name: str = typer.Argument(help="Secret name or ARN"),
438
+ key: str | None = typer.Option(
439
+ None, "--key", "-k", help="JSON key to add or update"
440
+ ),
441
+ value: str | None = typer.Option(
442
+ None,
443
+ "--value",
444
+ "-v",
445
+ help="New value for the key (prefer --value-stdin: argv is visible in ps).",
446
+ ),
447
+ value_stdin: bool = typer.Option(
448
+ False, "--value-stdin", help="Read the key's new value from stdin."
449
+ ),
450
+ from_file: str | None = typer.Option(
451
+ None,
452
+ "--from-file",
453
+ "-f",
454
+ help="Path to a JSON file whose keys will be merged into the secret "
455
+ "(new keys are added, existing keys are overwritten, untouched keys preserved).",
456
+ ),
457
+ profiles: list[str] = typer.Option(
458
+ ["prod"],
459
+ "--profile",
460
+ "-p",
461
+ help="AWS profile/alias (repeatable). Use 'all' for prod+qa.",
462
+ ),
463
+ region: str | None = RegionOption,
464
+ yes: bool = YesOption,
465
+ ) -> None:
466
+ """Edit one or more keys inside a JSON secret (read-modify-write).
467
+
468
+ Provide either --key with --value/--value-stdin for a single key, or
469
+ --from-file pointing to a JSON object whose keys are merged into the
470
+ existing secret (shallow merge).
471
+
472
+ Pass --profile multiple times (or 'all') to target several accounts:
473
+
474
+ dp cloud aws secrets edit my/secret -k password --value-stdin -p all < pass.txt
475
+ dp cloud aws secrets edit my/secret --from-file patch.json -p all
476
+ """
477
+ if value_stdin:
478
+ if value is not None:
479
+ console.print("[red]Use either --value or --value-stdin, not both[/red]")
480
+ raise typer.Exit(code=1)
481
+ value = _read_stdin_value()
482
+ using_pair = key is not None or value is not None
483
+ if using_pair and from_file is not None:
484
+ console.print(
485
+ "[red]Use either --key/--value or --from-file, not both[/red]"
486
+ )
487
+ raise typer.Exit(code=1)
488
+ if from_file is None:
489
+ if key is None or value is None:
490
+ console.print(
491
+ "[red]Provide --key and --value, or --from-file with a JSON file[/red]"
492
+ )
493
+ raise typer.Exit(code=1)
494
+ patch: dict = {key: value}
495
+ else:
496
+ path = os.path.expanduser(from_file)
497
+ try:
498
+ with open(path) as fh:
499
+ raw_patch = fh.read()
500
+ except OSError as exc:
501
+ console.print(f"[red]Cannot read file: {exc}[/red]")
502
+ raise typer.Exit(code=1)
503
+ try:
504
+ patch = json.loads(raw_patch)
505
+ except json.JSONDecodeError as exc:
506
+ console.print(f"[red]File is not valid JSON: {exc}[/red]")
507
+ raise typer.Exit(code=1)
508
+ if not isinstance(patch, dict):
509
+ console.print("[red]JSON file must contain an object at the top level[/red]")
510
+ raise typer.Exit(code=1)
511
+
512
+ resolved = _resolve_profiles(profiles)
513
+ _confirm_write(
514
+ f"Edit key(s) {', '.join(patch)} in secret", name, resolved, yes
515
+ )
516
+
517
+ for prof in resolved:
518
+ alias = _alias_for(prof)
519
+ sts = _get_sts_client(prof, region)
520
+ try:
521
+ ident = sts.get_caller_identity()
522
+ console.print(
523
+ f"[dim]\\[{alias}] Authenticated as {ident.get('Arn')} "
524
+ f"(account {ident.get('Account')})[/dim]"
525
+ )
526
+ except ClientError as exc:
527
+ code = exc.response.get("Error", {}).get("Code", "ClientError")
528
+ msg = exc.response.get("Error", {}).get("Message", str(exc))
529
+ console.print(
530
+ f"[red]\\[{alias}] {code} on get_caller_identity: {msg}[/red]"
531
+ )
532
+ continue
533
+ console.print(f"[dim]\\[{alias}] Fetching {name} …[/dim]")
534
+ client = _get_client(prof, region)
535
+
536
+ try:
537
+ resp = client.get_secret_value(SecretId=name)
538
+ except client.exceptions.ResourceNotFoundException:
539
+ console.print(f"[red]\\[{alias}] Secret not found: {name}[/red]")
540
+ continue
541
+ except ClientError as exc:
542
+ code = exc.response.get("Error", {}).get("Code", "ClientError")
543
+ msg = exc.response.get("Error", {}).get("Message", str(exc))
544
+ console.print(
545
+ f"[red]\\[{alias}] {code} on get_secret_value: {msg}[/red]"
546
+ )
547
+ continue
548
+
549
+ raw = resp.get("SecretString", "{}")
550
+ try:
551
+ data = json.loads(raw)
552
+ except json.JSONDecodeError:
553
+ console.print(
554
+ f"[red]\\[{alias}] Existing secret is not valid JSON — cannot edit key[/red]"
555
+ )
556
+ continue
557
+
558
+ _MISSING = object()
559
+ added: list[tuple[str, object]] = []
560
+ updated: list[tuple[str, object, object]] = []
561
+ for k, v in patch.items():
562
+ old = data.get(k, _MISSING)
563
+ if old is _MISSING:
564
+ added.append((k, v))
565
+ elif old != v:
566
+ updated.append((k, old, v))
567
+ data[k] = v
568
+
569
+ if not added and not updated:
570
+ console.print(
571
+ f"[yellow]\\[{alias}] No changes for[/yellow] [cyan]{name}[/cyan]"
572
+ )
573
+ continue
574
+
575
+ try:
576
+ client.put_secret_value(SecretId=name, SecretString=json.dumps(data))
577
+ except ClientError as exc:
578
+ code = exc.response.get("Error", {}).get("Code", "ClientError")
579
+ msg = exc.response.get("Error", {}).get("Message", str(exc))
580
+ console.print(
581
+ f"[red]\\[{alias}] {code} on put_secret_value: {msg}[/red]"
582
+ )
583
+ continue
584
+ console.print(
585
+ f"[green]\\[{alias}] Updated[/green] [cyan]{name}[/cyan] "
586
+ f"({len(added)} added, {len(updated)} changed)"
587
+ )
588
+ # Values are masked: terminal scrollback and CI logs must not hold secrets.
589
+ for k, _v in added:
590
+ console.print(f" + [bold]{k}[/bold]")
591
+ for k, _old, _v in updated:
592
+ console.print(f" ~ [bold]{k}[/bold] (changed)")
593
+
594
+
595
+ @app.command("rename-key")
596
+ def rename_key(
597
+ name: str = typer.Argument(help="Secret name or ARN"),
598
+ old_key: str = typer.Option(..., "--old-key", help="Existing JSON key to rename"),
599
+ new_key: str = typer.Option(..., "--new-key", help="New name for the key"),
600
+ profiles: list[str] = typer.Option(
601
+ ["prod"],
602
+ "--profile",
603
+ "-p",
604
+ help="AWS profile/alias (repeatable). Use 'all' for prod+qa.",
605
+ ),
606
+ region: str | None = RegionOption,
607
+ yes: bool = YesOption,
608
+ ) -> None:
609
+ """Rename a key inside a JSON secret (read-modify-write).
610
+
611
+ Pass --profile multiple times (or 'all') to target several accounts:
612
+
613
+ dp cloud aws secrets rename-key my/secret --old-key user --new-key username -p all
614
+ """
615
+ resolved = _resolve_profiles(profiles)
616
+ _confirm_write(
617
+ f"Rename key '{old_key}' -> '{new_key}' in secret", name, resolved, yes
618
+ )
619
+
620
+ for prof in resolved:
621
+ client = _get_client(prof, region)
622
+ alias = _alias_for(prof)
623
+
624
+ try:
625
+ resp = client.get_secret_value(SecretId=name)
626
+ except client.exceptions.ResourceNotFoundException:
627
+ console.print(f"[red]\\[{alias}] Secret not found: {name}[/red]")
628
+ continue
629
+
630
+ raw = resp.get("SecretString", "{}")
631
+ try:
632
+ data = json.loads(raw)
633
+ except json.JSONDecodeError:
634
+ console.print(
635
+ f"[red]\\[{alias}] Existing secret is not valid JSON — cannot rename key[/red]"
636
+ )
637
+ continue
638
+
639
+ if old_key not in data:
640
+ console.print(f"[red]\\[{alias}] Key '{old_key}' not found in secret[/red]")
641
+ continue
642
+
643
+ if new_key in data:
644
+ console.print(
645
+ f"[red]\\[{alias}] Key '{new_key}' already exists in secret — aborting to avoid data loss[/red]"
646
+ )
647
+ continue
648
+
649
+ # Preserve key order: rebuild dict with the renamed key in the same position
650
+ renamed: dict = {}
651
+ for k, v in data.items():
652
+ renamed[new_key if k == old_key else k] = v
653
+
654
+ client.put_secret_value(SecretId=name, SecretString=json.dumps(renamed))
655
+ console.print(f"[green]\\[{alias}] Renamed key[/green] in [cyan]{name}[/cyan]")
656
+ console.print(f" '{old_key}' → '{new_key}'")
657
+
658
+
659
+ @app.command("delete")
660
+ def delete_secret(
661
+ name: str = typer.Argument(help="Secret name or ARN"),
662
+ force: bool = typer.Option(False, "--force", help="Delete without recovery window"),
663
+ profile: str | None = ProfileOption,
664
+ region: str | None = RegionOption,
665
+ yes: bool = YesOption,
666
+ ) -> None:
667
+ """Schedule a secret for deletion (or force-delete immediately)."""
668
+ resolved_profile = _resolve_profile(profile) if profile else default_profile()
669
+ if force:
670
+ _confirm_or_abort(
671
+ f"[bold red]PERMANENTLY delete[/bold red] [cyan]{name}[/cyan] in "
672
+ f"[yellow]{_alias_for(resolved_profile)}[/yellow] — no recovery window, "
673
+ "cannot be restored.",
674
+ yes,
675
+ )
676
+ else:
677
+ _confirm_write(
678
+ "Schedule deletion (30-day recovery window) of secret",
679
+ name,
680
+ [resolved_profile],
681
+ yes,
682
+ )
683
+
684
+ client = _get_client(profile, region)
685
+
686
+ kwargs: dict = {"SecretId": name}
687
+ if force:
688
+ kwargs["ForceDeleteWithoutRecovery"] = True
689
+ else:
690
+ kwargs["RecoveryWindowInDays"] = 30
691
+
692
+ try:
693
+ client.delete_secret(**kwargs)
694
+ except client.exceptions.ResourceNotFoundException:
695
+ console.print(f"[red]Secret not found: {name}[/red]")
696
+ raise typer.Exit(code=1)
697
+ except ClientError as exc:
698
+ _client_error_exit("delete_secret", exc)
699
+
700
+ if force:
701
+ console.print(f"[yellow]Permanently deleted:[/yellow] {name}")
702
+ else:
703
+ console.print(
704
+ f"[yellow]Scheduled for deletion (30-day recovery window):[/yellow] {name}"
705
+ )
706
+ console.print(
707
+ "[dim]Cancel with: dp cloud aws secrets restore " + name + "[/dim]"
708
+ )
709
+
710
+
711
+ @app.command("restore")
712
+ def restore_secret(
713
+ name: str = typer.Argument(help="Secret name or ARN"),
714
+ profile: str | None = ProfileOption,
715
+ region: str | None = RegionOption,
716
+ ) -> None:
717
+ """Cancel a scheduled deletion, restoring the secret."""
718
+ client = _get_client(profile, region)
719
+ try:
720
+ client.restore_secret(SecretId=name)
721
+ except client.exceptions.ResourceNotFoundException:
722
+ console.print(f"[red]Secret not found: {name}[/red]")
723
+ raise typer.Exit(code=1)
724
+ except ClientError as exc:
725
+ _client_error_exit("restore_secret", exc)
726
+ console.print(f"[green]Restored (deletion cancelled):[/green] {name}")
727
+
728
+
729
+ @app.command("describe")
730
+ def describe_secret(
731
+ name: str = typer.Argument(help="Secret name or ARN"),
732
+ profile: str | None = ProfileOption,
733
+ region: str | None = RegionOption,
734
+ as_json: bool = JsonOption,
735
+ ) -> None:
736
+ """Show a secret's metadata: ARN, rotation, timestamps, tags."""
737
+ client = _get_client(profile, region)
738
+ try:
739
+ resp = client.describe_secret(SecretId=name)
740
+ except client.exceptions.ResourceNotFoundException:
741
+ console.print(f"[red]Secret not found: {name}[/red]")
742
+ raise typer.Exit(code=1)
743
+ except ClientError as exc:
744
+ _client_error_exit("describe_secret", exc)
745
+
746
+ resp.pop("ResponseMetadata", None)
747
+ if as_json:
748
+ typer.echo(json.dumps(resp, indent=2, default=str))
749
+ return
750
+
751
+ table = Table(title=f"Secret: {name}", show_header=False)
752
+ table.add_column("Field", style="cyan", no_wrap=True)
753
+ table.add_column("Value", overflow="fold")
754
+ fields = [
755
+ ("Name", resp.get("Name")),
756
+ ("ARN", resp.get("ARN")),
757
+ ("Description", resp.get("Description")),
758
+ ("KMS key", resp.get("KmsKeyId", "aws/secretsmanager (default)")),
759
+ ("Rotation enabled", resp.get("RotationEnabled", False)),
760
+ ("Rotation lambda", resp.get("RotationLambdaARN")),
761
+ ("Created", resp.get("CreatedDate")),
762
+ ("Last changed", resp.get("LastChangedDate")),
763
+ ("Last accessed", resp.get("LastAccessedDate")),
764
+ ("Deletion date", resp.get("DeletedDate")),
765
+ (
766
+ "Tags",
767
+ ", ".join(
768
+ f"{t.get('Key')}={t.get('Value')}" for t in resp.get("Tags") or []
769
+ ),
770
+ ),
771
+ ]
772
+ for label, val in fields:
773
+ if val not in (None, ""):
774
+ table.add_row(label, str(val))
775
+ console.print(table)
776
+
777
+
778
+ @app.command("versions")
779
+ def list_versions(
780
+ name: str = typer.Argument(help="Secret name or ARN"),
781
+ profile: str | None = ProfileOption,
782
+ region: str | None = RegionOption,
783
+ as_json: bool = JsonOption,
784
+ ) -> None:
785
+ """List a secret's versions with their stages (AWSCURRENT/AWSPREVIOUS)."""
786
+ client = _get_client(profile, region)
787
+ try:
788
+ resp = client.list_secret_version_ids(SecretId=name, IncludeDeprecated=True)
789
+ except client.exceptions.ResourceNotFoundException:
790
+ console.print(f"[red]Secret not found: {name}[/red]")
791
+ raise typer.Exit(code=1)
792
+ except ClientError as exc:
793
+ _client_error_exit("list_secret_version_ids", exc)
794
+
795
+ versions = sorted(
796
+ resp.get("Versions", []),
797
+ key=lambda v: str(v.get("CreatedDate", "")),
798
+ reverse=True,
799
+ )
800
+ if as_json:
801
+ payload = [
802
+ {
803
+ "version_id": v.get("VersionId"),
804
+ "stages": v.get("VersionStages", []),
805
+ "created": str(v.get("CreatedDate", "")),
806
+ }
807
+ for v in versions
808
+ ]
809
+ typer.echo(json.dumps(payload, indent=2))
810
+ return
811
+
812
+ table = Table(title=f"Versions: {name}")
813
+ table.add_column("Version ID", style="cyan")
814
+ table.add_column("Stages")
815
+ table.add_column("Created", style="dim")
816
+ for v in versions:
817
+ table.add_row(
818
+ v.get("VersionId", ""),
819
+ ", ".join(v.get("VersionStages", [])),
820
+ str(v.get("CreatedDate", "")),
821
+ )
822
+ console.print(table)
823
+
824
+
825
+ @app.command("rollback")
826
+ def rollback_secret(
827
+ name: str = typer.Argument(help="Secret name or ARN"),
828
+ profile: str | None = ProfileOption,
829
+ region: str | None = RegionOption,
830
+ yes: bool = YesOption,
831
+ ) -> None:
832
+ """Roll back to the previous version (move AWSCURRENT to AWSPREVIOUS)."""
833
+ client = _get_client(profile, region)
834
+ try:
835
+ resp = client.list_secret_version_ids(SecretId=name)
836
+ except client.exceptions.ResourceNotFoundException:
837
+ console.print(f"[red]Secret not found: {name}[/red]")
838
+ raise typer.Exit(code=1)
839
+ except ClientError as exc:
840
+ _client_error_exit("list_secret_version_ids", exc)
841
+
842
+ current_id: str | None = None
843
+ previous_id: str | None = None
844
+ for v in resp.get("Versions", []):
845
+ stages = v.get("VersionStages", [])
846
+ if "AWSCURRENT" in stages:
847
+ current_id = v.get("VersionId")
848
+ if "AWSPREVIOUS" in stages:
849
+ previous_id = v.get("VersionId")
850
+
851
+ if not current_id or not previous_id:
852
+ console.print(
853
+ "[red]Cannot roll back: need both an AWSCURRENT and an "
854
+ "AWSPREVIOUS version.[/red]"
855
+ )
856
+ raise typer.Exit(code=1)
857
+
858
+ _confirm_or_abort(
859
+ f"Roll back [cyan]{name}[/cyan]: AWSCURRENT "
860
+ f"{current_id[:8]}… -> {previous_id[:8]}…",
861
+ yes,
862
+ )
863
+
864
+ try:
865
+ client.update_secret_version_stage(
866
+ SecretId=name,
867
+ VersionStage="AWSCURRENT",
868
+ MoveToVersionId=previous_id,
869
+ RemoveFromVersionId=current_id,
870
+ )
871
+ except ClientError as exc:
872
+ _client_error_exit("update_secret_version_stage", exc)
873
+ console.print(
874
+ f"[green]Rolled back {name}: AWSCURRENT is now {previous_id}[/green]"
875
+ )