Skip to content

Commit 6194dee

Browse files
committed
Overhaul uv run secrets CLI group
- plain `set` now has a convenient interactive mode - we use more efficient plural functions under the hood (remove_secret -> remove_secrets, store_secret -> store_secrets)
1 parent 4cbbb62 commit 6194dee

2 files changed

Lines changed: 139 additions & 24 deletions

File tree

deploy/aws.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,24 @@ def get_secrets(secret_id="production-env"):
4141
return secrets
4242

4343

44-
def store_secret(key, value, secret_id="production-env"):
44+
def store_secrets(updates: dict, secret_id="production-env"):
45+
"""Merge `updates` into the secret and put it back. Single atomic write."""
4546
current_values = get_secrets(secret_id)
46-
if key in current_values and not questionary.confirm(f"{key} already in secrets. Override?").unsafe_ask():
47-
raise RuntimeError("Aborting...")
48-
49-
current_values[key] = value
47+
current_values.update(updates)
5048
client = get_boto3_session().client("secretsmanager")
5149
client.put_secret_value(SecretId=secret_id, SecretString=json.dumps(current_values))
5250

5351

54-
def remove_secret(key, secret_id="production-env"):
52+
def remove_secrets(keys, secret_id="production-env") -> list[str]:
53+
"""Remove `keys` from the secret. Returns the keys that were actually removed."""
5554
current_values = get_secrets(secret_id)
56-
if key not in current_values:
57-
echo(f"{key} not found in secrets, nothing to do")
58-
return
59-
60-
del current_values[key]
61-
client = get_boto3_session().client("secretsmanager")
62-
client.put_secret_value(SecretId=secret_id, SecretString=json.dumps(current_values))
55+
removed = [k for k in keys if k in current_values]
56+
for k in removed:
57+
del current_values[k]
58+
if removed:
59+
client = get_boto3_session().client("secretsmanager")
60+
client.put_secret_value(SecretId=secret_id, SecretString=json.dumps(current_values))
61+
return removed
6362

6463

6564
def ecr_login():

deploy/cli/secrets.py

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,53 @@
11
"""AWS Secrets Manager CLI."""
22

33
import json
4+
import secrets as stdlib_secrets
5+
import string
46
import sys
57

68
import click
9+
import questionary
710

811
from deploy import aws
912
from deploy.utils import echo
1013

1114

15+
def _generate_password(length: int) -> str:
16+
"""Letters + digits only — avoids shell/quoting issues across services."""
17+
alphabet = string.ascii_letters + string.digits
18+
return "".join(stdlib_secrets.choice(alphabet) for _ in range(length))
19+
20+
21+
def _prompt_password_length() -> int:
22+
return int(
23+
questionary.text(
24+
"Password length:",
25+
default="32",
26+
validate=lambda x: x.isdigit() and int(x) > 0,
27+
).unsafe_ask()
28+
)
29+
30+
31+
def _parse_dotenv(text: str) -> dict[str, str]:
32+
"""Parse dotenv-style KEY=VALUE lines. Skips blanks and `#` comments."""
33+
updates = {}
34+
for line_num, raw_line in enumerate(text.strip().split("\n"), 1):
35+
line = raw_line.strip()
36+
if not line or line.startswith("#"):
37+
continue
38+
if "=" not in line:
39+
raise ValueError(f"Line {line_num}: missing '=' separator")
40+
key, value = line.split("=", 1)
41+
key = key.strip()
42+
value = value.strip()
43+
if not key:
44+
raise ValueError(f"Line {line_num}: empty key")
45+
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
46+
value = value[1:-1]
47+
updates[key] = value
48+
return updates
49+
50+
1251
@click.group()
1352
def secrets():
1453
"""Manage values in AWS Secrets Manager."""
@@ -28,26 +67,103 @@ def get(secret_id, key):
2867
click.echo(json.dumps(secrets_dict, indent=2, sort_keys=True))
2968

3069

31-
@secrets.command(help="Securely store key-value pair in AWS Secrets Manager")
70+
@secrets.command(help="Set one or more secrets")
3271
@click.option("--secret-id", default="production-env")
33-
@click.argument("key")
34-
@click.argument("value")
35-
def set(key, value, secret_id):
36-
aws.store_secret(key, value, secret_id)
72+
@click.option("--generate", is_flag=True, help="Generate a secure random password")
73+
@click.argument("key", required=False)
74+
@click.argument("value", required=False)
75+
def set(key, value, generate, secret_id):
76+
"""Set secret(s).
77+
78+
\b
79+
Single key:
80+
secrets set KEY VALUE
81+
secrets set KEY --generate
82+
83+
Batch (no args): paste dotenv-style KEY=VALUE lines.
84+
"""
85+
if key is None:
86+
if generate:
87+
echo("--generate requires a KEY")
88+
sys.exit(1)
89+
90+
echo("Enter KEY=VALUE pairs (one per line, Ctrl+D when done):")
91+
text = questionary.text("Paste KEY=VALUE pairs:", multiline=True).unsafe_ask()
92+
if not text:
93+
echo("No input provided.")
94+
sys.exit(1)
3795

96+
try:
97+
updates = _parse_dotenv(text)
98+
except ValueError as e:
99+
echo(f"Error parsing input: {e}")
100+
sys.exit(1)
101+
102+
if not updates:
103+
echo("No valid KEY=VALUE pairs found.")
104+
sys.exit(1)
105+
else:
106+
if generate and value:
107+
echo("Cannot specify both VALUE and --generate")
108+
sys.exit(1)
109+
if not generate and value is None:
110+
echo("Must provide VALUE or use --generate")
111+
sys.exit(1)
112+
if generate:
113+
length = _prompt_password_length()
114+
value = _generate_password(length)
115+
echo(f"Generated password: {value}")
116+
updates = {key: value}
117+
118+
current = aws.get_secrets(secret_id)
119+
overrides = sorted(k for k in updates if k in current)
120+
if overrides:
121+
echo("Will overwrite existing key(s):")
122+
for k in overrides:
123+
echo(f" - {k}")
124+
if not questionary.confirm("Continue?", default=False).unsafe_ask():
125+
echo("Cancelled.")
126+
sys.exit(0)
38127

39-
@secrets.command("set-from-file", help="Securely store value from file in AWS Secrets Manager")
128+
aws.store_secrets(updates, secret_id)
129+
echo(f"Updated {len(updates)} key(s):")
130+
for k in updates:
131+
echo(f" - {k}")
132+
133+
134+
@secrets.command("set-from-file", help="Store the contents of a file as a single secret value")
40135
@click.option("--secret-id", default="production-env")
41136
@click.argument("key")
42137
@click.argument("filename")
43138
def set_from_file(key, filename, secret_id):
44139
with open(filename) as source:
45140
value = source.read()
46-
aws.store_secret(key, value, secret_id)
47141

142+
current = aws.get_secrets(secret_id)
143+
if key in current and not questionary.confirm(f"{key} already in secrets. Overwrite?", default=False).unsafe_ask():
144+
echo("Cancelled.")
145+
sys.exit(0)
146+
147+
aws.store_secrets({key: value}, secret_id)
148+
echo(f"Updated: {key}")
48149

49-
@secrets.command(help="Remove a secret from AWS Secrets Manager")
150+
151+
@secrets.command(help="Remove one or more secrets")
50152
@click.option("--secret-id", default="production-env")
51-
@click.argument("key")
52-
def delete(key, secret_id):
53-
aws.remove_secret(key, secret_id)
153+
@click.argument("keys", nargs=-1, required=True)
154+
def delete(keys, secret_id):
155+
echo("Keys to delete:")
156+
for k in keys:
157+
echo(f" - {k}")
158+
if not questionary.confirm(f"Delete {len(keys)} key(s)?", default=False).unsafe_ask():
159+
echo("Cancelled.")
160+
sys.exit(0)
161+
162+
removed = aws.remove_secrets(keys, secret_id)
163+
missing = [k for k in keys if k not in removed]
164+
if removed:
165+
echo(f"Deleted {len(removed)} key(s):")
166+
for k in removed:
167+
echo(f" - {k}")
168+
for k in missing:
169+
echo(f"Not found, skipped: {k}")

0 commit comments

Comments
 (0)