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.
- dataplat/__init__.py +3 -0
- dataplat/cli/__init__.py +1 -0
- dataplat/cli/_missing.py +108 -0
- dataplat/cli/bi/__init__.py +1 -0
- dataplat/cli/bi/app.py +14 -0
- dataplat/cli/bi/superset.py +558 -0
- dataplat/cli/ci/__init__.py +1 -0
- dataplat/cli/ci/app.py +14 -0
- dataplat/cli/ci/github/__init__.py +1 -0
- dataplat/cli/ci/github/app.py +10 -0
- dataplat/cli/ci/github/runner.py +399 -0
- dataplat/cli/cloud/__init__.py +1 -0
- dataplat/cli/cloud/app.py +14 -0
- dataplat/cli/cloud/aws/__init__.py +1 -0
- dataplat/cli/cloud/aws/_common.py +77 -0
- dataplat/cli/cloud/aws/app.py +12 -0
- dataplat/cli/cloud/aws/rds.py +686 -0
- dataplat/cli/cloud/aws/redshift.py +312 -0
- dataplat/cli/cloud/aws/secrets.py +875 -0
- dataplat/cli/config.py +492 -0
- dataplat/cli/db/__init__.py +342 -0
- dataplat/cli/db/_common.py +130 -0
- dataplat/cli/db/_report.py +180 -0
- dataplat/cli/db/dbt_orphans.py +842 -0
- dataplat/cli/db/describe.py +939 -0
- dataplat/cli/db/long_queries.py +299 -0
- dataplat/cli/db/role.py +434 -0
- dataplat/cli/db/role_create.py +452 -0
- dataplat/cli/db/role_drop.py +290 -0
- dataplat/cli/db/role_list.py +147 -0
- dataplat/cli/db/top_tables.py +337 -0
- dataplat/cli/ingest/__init__.py +1 -0
- dataplat/cli/ingest/airbyte/__init__.py +5 -0
- dataplat/cli/ingest/airbyte/_common.py +36 -0
- dataplat/cli/ingest/airbyte/_cursor.py +268 -0
- dataplat/cli/ingest/airbyte/_resource.py +192 -0
- dataplat/cli/ingest/airbyte/app.py +31 -0
- dataplat/cli/ingest/airbyte/connections.py +1234 -0
- dataplat/cli/ingest/airbyte/definitions.py +122 -0
- dataplat/cli/ingest/airbyte/destinations.py +26 -0
- dataplat/cli/ingest/airbyte/enums.py +45 -0
- dataplat/cli/ingest/airbyte/jobs.py +112 -0
- dataplat/cli/ingest/airbyte/sources.py +26 -0
- dataplat/cli/ingest/airbyte/tags.py +57 -0
- dataplat/cli/ingest/airbyte/templates.py +144 -0
- dataplat/cli/ingest/airbyte/tui.py +123 -0
- dataplat/cli/ingest/airbyte/workspaces.py +80 -0
- dataplat/cli/ingest/app.py +14 -0
- dataplat/cli/open.py +117 -0
- dataplat/cli/status.py +308 -0
- dataplat/core/__init__.py +1 -0
- dataplat/core/deps.py +138 -0
- dataplat/core/envrc.py +125 -0
- dataplat/core/errors.py +23 -0
- dataplat/main.py +87 -0
- dataplat/services/__init__.py +1 -0
- dataplat/services/airbyte/__init__.py +1 -0
- dataplat/services/airbyte/_resource.py +113 -0
- dataplat/services/airbyte/client.py +280 -0
- dataplat/services/airbyte/connections.py +306 -0
- dataplat/services/airbyte/definitions.py +64 -0
- dataplat/services/airbyte/destinations.py +68 -0
- dataplat/services/airbyte/jobs.py +72 -0
- dataplat/services/airbyte/sources.py +58 -0
- dataplat/services/airbyte/tags.py +120 -0
- dataplat/services/airbyte/workspaces.py +47 -0
- dataplat/services/aws/__init__.py +1 -0
- dataplat/services/aws/auth.py +80 -0
- dataplat/services/db/__init__.py +1 -0
- dataplat/services/db/connection.py +169 -0
- dataplat/services/db/describe.py +1034 -0
- dataplat/services/db/long_queries.py +378 -0
- dataplat/services/db/orphans.py +429 -0
- dataplat/services/db/role.py +812 -0
- dataplat/services/db/role_admin.py +458 -0
- dataplat/services/db/role_dialects.py +521 -0
- dataplat/services/db/targets.py +128 -0
- dataplat/services/db/top_tables.py +193 -0
- dataplat/services/superset/__init__.py +1 -0
- dataplat/services/superset/client.py +319 -0
- dataplat-0.1.0.dist-info/METADATA +326 -0
- dataplat-0.1.0.dist-info/RECORD +85 -0
- dataplat-0.1.0.dist-info/WHEEL +4 -0
- dataplat-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|