exchange-keyshare 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.
- exchange_keyshare/__init__.py +3 -0
- exchange_keyshare/cfn.py +14 -0
- exchange_keyshare/cli.py +37 -0
- exchange_keyshare/commands/__init__.py +1 -0
- exchange_keyshare/commands/keys.py +476 -0
- exchange_keyshare/commands/setup.py +170 -0
- exchange_keyshare/config.py +86 -0
- exchange_keyshare/keys.py +106 -0
- exchange_keyshare/schema.py +117 -0
- exchange_keyshare/setup.py +263 -0
- exchange_keyshare/templates/stack.yaml +221 -0
- exchange_keyshare-0.1.0.dist-info/METADATA +10 -0
- exchange_keyshare-0.1.0.dist-info/RECORD +15 -0
- exchange_keyshare-0.1.0.dist-info/WHEEL +4 -0
- exchange_keyshare-0.1.0.dist-info/entry_points.txt +2 -0
exchange_keyshare/cfn.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""CloudFormation operations for exchange-keyshare."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_template_path() -> Path:
|
|
7
|
+
"""Get path to CloudFormation template."""
|
|
8
|
+
return Path(__file__).parent / "templates" / "stack.yaml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_template() -> str:
|
|
12
|
+
"""Load CloudFormation template as string."""
|
|
13
|
+
path = get_template_path()
|
|
14
|
+
return path.read_text()
|
exchange_keyshare/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""CLI entrypoint for exchange-keyshare."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from exchange_keyshare.commands.keys import keys
|
|
8
|
+
from exchange_keyshare.commands.setup import setup
|
|
9
|
+
from exchange_keyshare.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
@click.version_option()
|
|
14
|
+
@click.option(
|
|
15
|
+
"--config",
|
|
16
|
+
"config_path",
|
|
17
|
+
envvar="EXCHANGE_KEYSHARE_CONFIG",
|
|
18
|
+
type=click.Path(),
|
|
19
|
+
help="Path to config file",
|
|
20
|
+
)
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def main(ctx: click.Context, config_path: str | None) -> None:
|
|
23
|
+
"""Exchange Keyshare - Securely share exchange API credentials."""
|
|
24
|
+
ctx.ensure_object(dict)
|
|
25
|
+
config = Config()
|
|
26
|
+
if config_path:
|
|
27
|
+
config.config_path = Path(config_path)
|
|
28
|
+
config.load()
|
|
29
|
+
ctx.obj["config"] = config
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
main.add_command(setup)
|
|
33
|
+
main.add_command(keys)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Keys subcommands for managing exchange credentials."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import questionary
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from exchange_keyshare.config import Config
|
|
10
|
+
from exchange_keyshare.keys import (
|
|
11
|
+
delete_credential,
|
|
12
|
+
get_credential,
|
|
13
|
+
list_credentials,
|
|
14
|
+
upload_credential,
|
|
15
|
+
)
|
|
16
|
+
from exchange_keyshare.schema import (
|
|
17
|
+
PASSPHRASE_REQUIRED_EXCHANGES,
|
|
18
|
+
SUPPORTED_EXCHANGES,
|
|
19
|
+
CredentialSchema,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def require_setup(config: Config) -> tuple[str, str, str]:
|
|
24
|
+
"""Check that setup has been run, exit with error if not.
|
|
25
|
+
|
|
26
|
+
Returns (bucket, region, kms_key_arn) guaranteed to be non-None.
|
|
27
|
+
"""
|
|
28
|
+
if not config.bucket or not config.region or not config.kms_key_arn:
|
|
29
|
+
click.echo(
|
|
30
|
+
"Error: No bucket configured. Run 'exchange-keyshare setup' to create the AWS infrastructure.",
|
|
31
|
+
err=True,
|
|
32
|
+
)
|
|
33
|
+
raise SystemExit(1)
|
|
34
|
+
return config.bucket, config.region, config.kms_key_arn
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_labels(labels: list[dict[str, str]]) -> str:
|
|
38
|
+
"""Format labels as key=value, key=value string."""
|
|
39
|
+
return ", ".join(f"{l['key']}={l['value']}" for l in labels)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_pairs(input_str: str) -> list[str]:
|
|
43
|
+
"""Parse comma-separated pairs input into list."""
|
|
44
|
+
return [p.strip() for p in input_str.split(",") if p.strip()]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_credential_info(cred: CredentialSchema, key: str) -> str:
|
|
48
|
+
"""Format credential info for display."""
|
|
49
|
+
lines = [
|
|
50
|
+
f" Key: {key}",
|
|
51
|
+
f" Exchange: {cred.exchange}",
|
|
52
|
+
]
|
|
53
|
+
if cred.pairs:
|
|
54
|
+
lines.append(f" Pairs: {', '.join(cred.pairs)}")
|
|
55
|
+
if cred.labels:
|
|
56
|
+
lines.append(f" Labels: {format_labels(cred.labels)}")
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@click.group()
|
|
61
|
+
def keys() -> None:
|
|
62
|
+
"""Manage exchange credentials."""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@keys.command("list")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def keys_list(ctx: click.Context) -> None:
|
|
69
|
+
"""List all credentials."""
|
|
70
|
+
config: Config = ctx.obj["config"]
|
|
71
|
+
bucket, region, _kms_key_arn = require_setup(config)
|
|
72
|
+
console = Console()
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
credentials = list_credentials(bucket, region)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
78
|
+
raise SystemExit(1)
|
|
79
|
+
|
|
80
|
+
if not credentials:
|
|
81
|
+
console.print("No credentials found.")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
table = Table(title=f"{len(credentials)} Credential(s)")
|
|
85
|
+
table.add_column("Key", style="cyan")
|
|
86
|
+
table.add_column("Exchange")
|
|
87
|
+
table.add_column("Pairs", style="dim")
|
|
88
|
+
table.add_column("Labels", style="dim")
|
|
89
|
+
|
|
90
|
+
for cred in credentials:
|
|
91
|
+
table.add_row(
|
|
92
|
+
cred.key,
|
|
93
|
+
cred.exchange,
|
|
94
|
+
", ".join(cred.pairs) if cred.pairs else "-",
|
|
95
|
+
format_labels(cred.labels) if cred.labels else "-",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
console.print()
|
|
99
|
+
console.print(table)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _prompt_cancelled() -> None:
|
|
103
|
+
"""Handle user cancellation (Ctrl+C or Escape)."""
|
|
104
|
+
click.echo("\nAborted.", err=True)
|
|
105
|
+
raise SystemExit(1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_required(text: str) -> bool | str:
|
|
109
|
+
"""Validate that a field is not empty."""
|
|
110
|
+
return True if text else "This field is required"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _validate_pairs(text: str) -> bool | str:
|
|
114
|
+
"""Validate trading pairs format (optional, but must be BASE/QUOTE if provided)."""
|
|
115
|
+
if not text.strip():
|
|
116
|
+
return True # Empty is fine, it's optional
|
|
117
|
+
|
|
118
|
+
pairs = [p.strip() for p in text.split(",") if p.strip()]
|
|
119
|
+
invalid: list[str] = []
|
|
120
|
+
for pair in pairs:
|
|
121
|
+
# Must contain exactly one "/" with non-empty base and quote
|
|
122
|
+
parts = pair.split("/")
|
|
123
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
124
|
+
invalid.append(pair)
|
|
125
|
+
|
|
126
|
+
if invalid:
|
|
127
|
+
return f"Invalid format: {', '.join(invalid)}. Use BASE/QUOTE (e.g. BTC/USDT)"
|
|
128
|
+
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@keys.command("create")
|
|
133
|
+
@click.pass_context
|
|
134
|
+
def keys_create(ctx: click.Context) -> None:
|
|
135
|
+
"""Create a new credential (interactive)."""
|
|
136
|
+
config: Config = ctx.obj["config"]
|
|
137
|
+
bucket, region, kms_key_arn = require_setup(config)
|
|
138
|
+
console = Console()
|
|
139
|
+
|
|
140
|
+
console.print()
|
|
141
|
+
console.print(Panel.fit("[bold]Create New Credential[/bold]", border_style="blue"))
|
|
142
|
+
console.print()
|
|
143
|
+
|
|
144
|
+
# Exchange selection (arrow keys)
|
|
145
|
+
exchanges = sorted(SUPPORTED_EXCHANGES)
|
|
146
|
+
exchange = questionary.select(
|
|
147
|
+
"Select exchange:",
|
|
148
|
+
choices=exchanges,
|
|
149
|
+
style=questionary.Style([("highlighted", "bold")]),
|
|
150
|
+
).ask()
|
|
151
|
+
|
|
152
|
+
if exchange is None:
|
|
153
|
+
_prompt_cancelled()
|
|
154
|
+
|
|
155
|
+
console.print()
|
|
156
|
+
|
|
157
|
+
# API credentials (with asterisks)
|
|
158
|
+
api_key = questionary.password("API Key:", validate=_validate_required).ask()
|
|
159
|
+
|
|
160
|
+
if api_key is None:
|
|
161
|
+
_prompt_cancelled()
|
|
162
|
+
|
|
163
|
+
api_secret = questionary.password("API Secret:", validate=_validate_required).ask()
|
|
164
|
+
|
|
165
|
+
if api_secret is None:
|
|
166
|
+
_prompt_cancelled()
|
|
167
|
+
|
|
168
|
+
credential_data: dict[str, str] = {
|
|
169
|
+
"api_key": api_key,
|
|
170
|
+
"api_secret": api_secret,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Passphrase
|
|
174
|
+
if exchange in PASSPHRASE_REQUIRED_EXCHANGES:
|
|
175
|
+
passphrase = questionary.password(
|
|
176
|
+
"Passphrase (required for this exchange):",
|
|
177
|
+
validate=_validate_required,
|
|
178
|
+
).ask()
|
|
179
|
+
|
|
180
|
+
if passphrase is None:
|
|
181
|
+
_prompt_cancelled()
|
|
182
|
+
|
|
183
|
+
credential_data["passphrase"] = passphrase
|
|
184
|
+
else:
|
|
185
|
+
passphrase = questionary.password("Passphrase (optional):").ask()
|
|
186
|
+
|
|
187
|
+
if passphrase is None:
|
|
188
|
+
_prompt_cancelled()
|
|
189
|
+
|
|
190
|
+
if passphrase:
|
|
191
|
+
credential_data["passphrase"] = passphrase
|
|
192
|
+
|
|
193
|
+
console.print()
|
|
194
|
+
|
|
195
|
+
# Trading pairs
|
|
196
|
+
pairs_input = questionary.text(
|
|
197
|
+
"Trading pairs (optional, BASE/QUOTE format, e.g. BTC/USDT, ETH/USDT):",
|
|
198
|
+
validate=_validate_pairs,
|
|
199
|
+
).ask()
|
|
200
|
+
|
|
201
|
+
if pairs_input is None:
|
|
202
|
+
_prompt_cancelled()
|
|
203
|
+
|
|
204
|
+
pairs: list[str] | None = parse_pairs(pairs_input) or None
|
|
205
|
+
|
|
206
|
+
# Labels - always prompt for name first
|
|
207
|
+
labels: list[dict[str, str]] = []
|
|
208
|
+
|
|
209
|
+
name_label = questionary.text("Credential name (optional):").ask()
|
|
210
|
+
|
|
211
|
+
if name_label is None:
|
|
212
|
+
_prompt_cancelled()
|
|
213
|
+
|
|
214
|
+
if name_label:
|
|
215
|
+
labels.append({"key": "name", "value": name_label})
|
|
216
|
+
|
|
217
|
+
# Additional labels
|
|
218
|
+
add_more = questionary.confirm(
|
|
219
|
+
"Add more labels? (key/value tags for your own organization)",
|
|
220
|
+
default=False,
|
|
221
|
+
).ask()
|
|
222
|
+
|
|
223
|
+
if add_more is None:
|
|
224
|
+
_prompt_cancelled()
|
|
225
|
+
|
|
226
|
+
if add_more:
|
|
227
|
+
while True:
|
|
228
|
+
label_key = questionary.text("Label key (Enter to finish):").ask()
|
|
229
|
+
|
|
230
|
+
if label_key is None:
|
|
231
|
+
_prompt_cancelled()
|
|
232
|
+
|
|
233
|
+
if not label_key:
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
label_value = questionary.text(f"Value for '{label_key}':").ask()
|
|
237
|
+
|
|
238
|
+
if label_value is None:
|
|
239
|
+
_prompt_cancelled()
|
|
240
|
+
|
|
241
|
+
labels.append({"key": label_key, "value": label_value})
|
|
242
|
+
|
|
243
|
+
# Build credential
|
|
244
|
+
credential = CredentialSchema(
|
|
245
|
+
version="1",
|
|
246
|
+
exchange=exchange,
|
|
247
|
+
credential=credential_data,
|
|
248
|
+
pairs=pairs,
|
|
249
|
+
labels=labels or None,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Summary
|
|
253
|
+
console.print()
|
|
254
|
+
table = Table(title="Credential Summary", show_header=False, box=None)
|
|
255
|
+
table.add_column("Field", style="dim")
|
|
256
|
+
table.add_column("Value")
|
|
257
|
+
|
|
258
|
+
table.add_row("Exchange", f"[cyan]{exchange}[/cyan]")
|
|
259
|
+
if pairs:
|
|
260
|
+
table.add_row("Pairs", ", ".join(pairs))
|
|
261
|
+
if labels:
|
|
262
|
+
table.add_row("Labels", format_labels(labels))
|
|
263
|
+
|
|
264
|
+
console.print(table)
|
|
265
|
+
console.print()
|
|
266
|
+
|
|
267
|
+
# Confirm upload
|
|
268
|
+
confirm = questionary.confirm("Upload this credential?", default=True).ask()
|
|
269
|
+
|
|
270
|
+
if not confirm:
|
|
271
|
+
click.echo("Aborted.")
|
|
272
|
+
raise SystemExit(0)
|
|
273
|
+
|
|
274
|
+
# Upload
|
|
275
|
+
try:
|
|
276
|
+
s3_key = upload_credential(bucket, region, credential, kms_key_arn)
|
|
277
|
+
console.print()
|
|
278
|
+
console.print("[green]✓[/green] Credential uploaded successfully!")
|
|
279
|
+
console.print(f" Key: [cyan]{s3_key}[/cyan]")
|
|
280
|
+
except Exception as e:
|
|
281
|
+
console.print(f"[red]Error:[/red] {e}", style="red")
|
|
282
|
+
raise SystemExit(1)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@keys.command("delete")
|
|
286
|
+
@click.argument("key")
|
|
287
|
+
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt")
|
|
288
|
+
@click.pass_context
|
|
289
|
+
def keys_delete(ctx: click.Context, key: str, yes: bool) -> None:
|
|
290
|
+
"""Delete a credential by S3 key."""
|
|
291
|
+
config: Config = ctx.obj["config"]
|
|
292
|
+
bucket, region, _kms_key_arn = require_setup(config)
|
|
293
|
+
console = Console()
|
|
294
|
+
|
|
295
|
+
# Get credential info before deletion
|
|
296
|
+
try:
|
|
297
|
+
credential = get_credential(bucket, region, key)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
console.print(f"[red]Error:[/red] Could not find credential: {e}")
|
|
300
|
+
raise SystemExit(1)
|
|
301
|
+
|
|
302
|
+
# Show credential info
|
|
303
|
+
console.print()
|
|
304
|
+
table = Table(title="Credential to Delete", show_header=False, box=None)
|
|
305
|
+
table.add_column("Field", style="dim")
|
|
306
|
+
table.add_column("Value")
|
|
307
|
+
|
|
308
|
+
table.add_row("Key", f"[cyan]{key}[/cyan]")
|
|
309
|
+
table.add_row("Exchange", credential.exchange)
|
|
310
|
+
if credential.pairs:
|
|
311
|
+
table.add_row("Pairs", ", ".join(credential.pairs))
|
|
312
|
+
if credential.labels:
|
|
313
|
+
table.add_row("Labels", format_labels(credential.labels))
|
|
314
|
+
|
|
315
|
+
console.print(table)
|
|
316
|
+
console.print()
|
|
317
|
+
|
|
318
|
+
if not yes:
|
|
319
|
+
confirm = questionary.confirm("Delete this credential?", default=False).ask()
|
|
320
|
+
if not confirm:
|
|
321
|
+
console.print("Aborted.")
|
|
322
|
+
raise SystemExit(0)
|
|
323
|
+
|
|
324
|
+
# Delete
|
|
325
|
+
try:
|
|
326
|
+
delete_credential(bucket, region, key)
|
|
327
|
+
console.print("[green]✓[/green] Credential deleted successfully.")
|
|
328
|
+
except Exception as e:
|
|
329
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
330
|
+
raise SystemExit(1)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@keys.command("update")
|
|
334
|
+
@click.argument("key")
|
|
335
|
+
@click.pass_context
|
|
336
|
+
def keys_update(ctx: click.Context, key: str) -> None:
|
|
337
|
+
"""Update a credential's pairs and labels (interactive)."""
|
|
338
|
+
config: Config = ctx.obj["config"]
|
|
339
|
+
bucket, region, kms_key_arn = require_setup(config)
|
|
340
|
+
console = Console()
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
credential = get_credential(bucket, region, key)
|
|
344
|
+
except Exception as e:
|
|
345
|
+
console.print(f"[red]Error:[/red] Could not find credential: {e}")
|
|
346
|
+
raise SystemExit(1)
|
|
347
|
+
|
|
348
|
+
# Show current credential
|
|
349
|
+
console.print()
|
|
350
|
+
table = Table(title="Current Credential", show_header=False, box=None)
|
|
351
|
+
table.add_column("Field", style="dim")
|
|
352
|
+
table.add_column("Value")
|
|
353
|
+
|
|
354
|
+
table.add_row("Key", f"[cyan]{key}[/cyan]")
|
|
355
|
+
table.add_row("Exchange", credential.exchange)
|
|
356
|
+
if credential.pairs:
|
|
357
|
+
table.add_row("Pairs", ", ".join(credential.pairs))
|
|
358
|
+
if credential.labels:
|
|
359
|
+
table.add_row("Labels", format_labels(credential.labels))
|
|
360
|
+
|
|
361
|
+
console.print(table)
|
|
362
|
+
console.print()
|
|
363
|
+
|
|
364
|
+
# Pairs
|
|
365
|
+
current_pairs = list(credential.pairs) if credential.pairs else []
|
|
366
|
+
current_pairs_str = ", ".join(current_pairs) if current_pairs else ""
|
|
367
|
+
pairs_input = questionary.text(
|
|
368
|
+
"Trading pairs (BASE/QUOTE format, Enter to keep current):",
|
|
369
|
+
default=current_pairs_str,
|
|
370
|
+
validate=_validate_pairs,
|
|
371
|
+
).ask()
|
|
372
|
+
|
|
373
|
+
if pairs_input is None:
|
|
374
|
+
_prompt_cancelled()
|
|
375
|
+
|
|
376
|
+
new_pairs = parse_pairs(pairs_input) if pairs_input else []
|
|
377
|
+
|
|
378
|
+
# Labels
|
|
379
|
+
new_labels: list[dict[str, str]] = list(credential.labels) if credential.labels else []
|
|
380
|
+
|
|
381
|
+
while True:
|
|
382
|
+
# Build choices - "Done" first as default
|
|
383
|
+
choices = ["Done editing labels", "Add label"]
|
|
384
|
+
if new_labels:
|
|
385
|
+
choices.append("Remove label")
|
|
386
|
+
choices.append("Clear all labels")
|
|
387
|
+
|
|
388
|
+
action = questionary.select("Labels:", choices=choices).ask()
|
|
389
|
+
|
|
390
|
+
if action is None:
|
|
391
|
+
_prompt_cancelled()
|
|
392
|
+
|
|
393
|
+
if action == "Done editing labels":
|
|
394
|
+
break
|
|
395
|
+
|
|
396
|
+
if action == "Add label":
|
|
397
|
+
label_key = questionary.text("Label key:").ask()
|
|
398
|
+
if label_key is None:
|
|
399
|
+
_prompt_cancelled()
|
|
400
|
+
if label_key:
|
|
401
|
+
label_value = questionary.text(f"Value for '{label_key}':").ask()
|
|
402
|
+
if label_value is None:
|
|
403
|
+
_prompt_cancelled()
|
|
404
|
+
new_labels = [l for l in new_labels if l["key"] != label_key]
|
|
405
|
+
new_labels.append({"key": label_key, "value": label_value})
|
|
406
|
+
|
|
407
|
+
elif action == "Remove label" and new_labels:
|
|
408
|
+
label_key = questionary.select(
|
|
409
|
+
"Label to remove:",
|
|
410
|
+
choices=[l["key"] for l in new_labels],
|
|
411
|
+
).ask()
|
|
412
|
+
if label_key is None:
|
|
413
|
+
_prompt_cancelled()
|
|
414
|
+
new_labels = [l for l in new_labels if l["key"] != label_key]
|
|
415
|
+
|
|
416
|
+
elif action == "Clear all labels":
|
|
417
|
+
new_labels = []
|
|
418
|
+
|
|
419
|
+
# Show current state after changes
|
|
420
|
+
if action != "Done editing labels":
|
|
421
|
+
if new_labels:
|
|
422
|
+
console.print(f" Labels: {format_labels(new_labels)}")
|
|
423
|
+
else:
|
|
424
|
+
console.print(" Labels: (none)")
|
|
425
|
+
|
|
426
|
+
# Check for changes
|
|
427
|
+
old_pairs_set = set(current_pairs)
|
|
428
|
+
new_pairs_set = set(new_pairs)
|
|
429
|
+
old_labels_dict = {l["key"]: l["value"] for l in (credential.labels or [])}
|
|
430
|
+
new_labels_dict = {l["key"]: l["value"] for l in new_labels}
|
|
431
|
+
|
|
432
|
+
if old_pairs_set == new_pairs_set and old_labels_dict == new_labels_dict:
|
|
433
|
+
console.print("\nNo changes to apply.")
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
# Show diff
|
|
437
|
+
console.print()
|
|
438
|
+
console.print("[bold]Changes:[/bold]")
|
|
439
|
+
|
|
440
|
+
if old_pairs_set != new_pairs_set:
|
|
441
|
+
old_display = ", ".join(sorted(old_pairs_set)) or "(none)"
|
|
442
|
+
new_display = ", ".join(sorted(new_pairs_set)) or "(none)"
|
|
443
|
+
console.print(f" Pairs: {old_display} → {new_display}")
|
|
444
|
+
|
|
445
|
+
if old_labels_dict != new_labels_dict:
|
|
446
|
+
for k, v in new_labels_dict.items():
|
|
447
|
+
if k not in old_labels_dict:
|
|
448
|
+
console.print(f" Labels: [green]+{k}={v}[/green]")
|
|
449
|
+
elif old_labels_dict[k] != v:
|
|
450
|
+
console.print(f" Labels: {k}={old_labels_dict[k]} → {k}={v}")
|
|
451
|
+
for k in old_labels_dict:
|
|
452
|
+
if k not in new_labels_dict:
|
|
453
|
+
console.print(f" Labels: [red]-{k}={old_labels_dict[k]}[/red]")
|
|
454
|
+
|
|
455
|
+
console.print()
|
|
456
|
+
|
|
457
|
+
confirm = questionary.confirm("Apply these changes?", default=True).ask()
|
|
458
|
+
|
|
459
|
+
if not confirm:
|
|
460
|
+
console.print("Aborted.")
|
|
461
|
+
raise SystemExit(0)
|
|
462
|
+
|
|
463
|
+
updated = CredentialSchema(
|
|
464
|
+
version=credential.version,
|
|
465
|
+
exchange=credential.exchange,
|
|
466
|
+
credential=credential.credential,
|
|
467
|
+
pairs=new_pairs or None,
|
|
468
|
+
labels=new_labels or None,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
upload_credential(bucket, region, updated, kms_key_arn, s3_key=key)
|
|
473
|
+
console.print("[green]✓[/green] Credential updated successfully.")
|
|
474
|
+
except Exception as e:
|
|
475
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
476
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Setup command for creating AWS infrastructure."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console, Group, RenderableType
|
|
5
|
+
from rich.live import Live
|
|
6
|
+
from rich.spinner import Spinner
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from exchange_keyshare.config import Config
|
|
11
|
+
from exchange_keyshare.setup import (
|
|
12
|
+
ResourceStatus,
|
|
13
|
+
StackProgress,
|
|
14
|
+
get_default_region,
|
|
15
|
+
get_friendly_type,
|
|
16
|
+
get_stack_outputs,
|
|
17
|
+
poll_stack_progress,
|
|
18
|
+
start_stack_creation,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_status_style(status: str) -> str:
|
|
23
|
+
"""Get rich style for a resource status."""
|
|
24
|
+
if "COMPLETE" in status and "ROLLBACK" not in status:
|
|
25
|
+
return "green"
|
|
26
|
+
elif "IN_PROGRESS" in status:
|
|
27
|
+
return "yellow"
|
|
28
|
+
elif "FAILED" in status or "ROLLBACK" in status:
|
|
29
|
+
return "red"
|
|
30
|
+
return "white"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_status_icon(status: str) -> str:
|
|
34
|
+
"""Get icon for a resource status."""
|
|
35
|
+
if "COMPLETE" in status and "ROLLBACK" not in status:
|
|
36
|
+
return "[green]✓[/green]"
|
|
37
|
+
elif "IN_PROGRESS" in status:
|
|
38
|
+
return "[yellow]⋯[/yellow]"
|
|
39
|
+
elif "FAILED" in status or "ROLLBACK" in status:
|
|
40
|
+
return "[red]✗[/red]"
|
|
41
|
+
return " "
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_progress_display(progress: StackProgress) -> RenderableType:
|
|
45
|
+
"""Build a rich display showing resource creation progress with spinner."""
|
|
46
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
47
|
+
table.add_column("", width=2)
|
|
48
|
+
table.add_column("Resource", style="cyan", min_width=25)
|
|
49
|
+
table.add_column("Type", style="dim")
|
|
50
|
+
table.add_column("Status")
|
|
51
|
+
|
|
52
|
+
# Sort resources: in_progress first, then by name
|
|
53
|
+
def sort_key(item: tuple[str, ResourceStatus]) -> tuple[int, str]:
|
|
54
|
+
_, r = item
|
|
55
|
+
if "IN_PROGRESS" in r.status:
|
|
56
|
+
return (0, r.logical_id)
|
|
57
|
+
elif "COMPLETE" in r.status:
|
|
58
|
+
return (2, r.logical_id)
|
|
59
|
+
else:
|
|
60
|
+
return (1, r.logical_id)
|
|
61
|
+
|
|
62
|
+
sorted_resources = sorted(progress.resources.items(), key=sort_key)
|
|
63
|
+
|
|
64
|
+
for _logical_id, resource in sorted_resources:
|
|
65
|
+
icon = get_status_icon(resource.status)
|
|
66
|
+
style = get_status_style(resource.status)
|
|
67
|
+
friendly_type = get_friendly_type(resource.resource_type)
|
|
68
|
+
|
|
69
|
+
# Simplify status text
|
|
70
|
+
status_text = resource.status.replace("_", " ").title()
|
|
71
|
+
|
|
72
|
+
table.add_row(
|
|
73
|
+
icon,
|
|
74
|
+
resource.logical_id,
|
|
75
|
+
friendly_type,
|
|
76
|
+
f"[{style}]{status_text}[/{style}]",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Add spinner header if still in progress
|
|
80
|
+
if not progress.is_complete and not progress.is_failed:
|
|
81
|
+
spinner = Spinner("dots", text=Text(" Creating infrastructure...", style="bold"))
|
|
82
|
+
return Group(spinner, Text(""), table)
|
|
83
|
+
|
|
84
|
+
return table
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@click.command()
|
|
88
|
+
@click.pass_context
|
|
89
|
+
def setup(ctx: click.Context) -> None:
|
|
90
|
+
"""Create AWS infrastructure for credential storage."""
|
|
91
|
+
config: Config = ctx.obj["config"]
|
|
92
|
+
console = Console()
|
|
93
|
+
|
|
94
|
+
if config.stack_name:
|
|
95
|
+
console.print(f"Already set up with stack: [cyan]{config.stack_name}[/cyan]")
|
|
96
|
+
console.print(f" Bucket: {config.bucket}")
|
|
97
|
+
console.print(f" Region: {config.region}")
|
|
98
|
+
console.print(f" Role ARN: {config.role_arn}")
|
|
99
|
+
console.print(f"\nTo reconfigure, delete {config.config_path}")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
console.print("[bold]Exchange Keyshare Setup[/bold]")
|
|
103
|
+
console.print("=" * 40)
|
|
104
|
+
console.print()
|
|
105
|
+
|
|
106
|
+
principal_arn = click.prompt("Principal ARN (provided by the credential consumer)", type=str)
|
|
107
|
+
external_id = click.prompt("External ID (provided by the credential consumer)", type=str)
|
|
108
|
+
|
|
109
|
+
bucket_name = click.prompt(
|
|
110
|
+
"Bucket name (leave empty for auto-generated)",
|
|
111
|
+
default="",
|
|
112
|
+
show_default=False,
|
|
113
|
+
)
|
|
114
|
+
if not bucket_name:
|
|
115
|
+
bucket_name = None
|
|
116
|
+
|
|
117
|
+
default_region = get_default_region()
|
|
118
|
+
region = click.prompt("AWS region", default=default_region)
|
|
119
|
+
|
|
120
|
+
console.print()
|
|
121
|
+
console.print("[bold]Creating infrastructure...[/bold]")
|
|
122
|
+
console.print()
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
stack_name, region, cfn = start_stack_creation(
|
|
126
|
+
external_id=external_id,
|
|
127
|
+
principal_arn=principal_arn,
|
|
128
|
+
bucket_name=bucket_name,
|
|
129
|
+
region=region,
|
|
130
|
+
)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
133
|
+
raise SystemExit(1)
|
|
134
|
+
|
|
135
|
+
# Poll with live display
|
|
136
|
+
final_progress: StackProgress | None = None
|
|
137
|
+
|
|
138
|
+
with Live(console=console, refresh_per_second=10) as live:
|
|
139
|
+
for progress in poll_stack_progress(stack_name, cfn):
|
|
140
|
+
display = build_progress_display(progress)
|
|
141
|
+
live.update(display)
|
|
142
|
+
final_progress = progress
|
|
143
|
+
|
|
144
|
+
if progress.is_complete or progress.is_failed:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
if final_progress is None or final_progress.is_failed:
|
|
148
|
+
console.print()
|
|
149
|
+
console.print(f"[red]Error: {final_progress.failure_reason if final_progress else 'Unknown error'}[/red]")
|
|
150
|
+
raise SystemExit(1)
|
|
151
|
+
|
|
152
|
+
# Get outputs and save config
|
|
153
|
+
result = get_stack_outputs(stack_name, region)
|
|
154
|
+
|
|
155
|
+
config.bucket = result.bucket
|
|
156
|
+
config.region = result.region
|
|
157
|
+
config.stack_name = result.stack_name
|
|
158
|
+
config.role_arn = result.role_arn
|
|
159
|
+
config.external_id = result.external_id
|
|
160
|
+
config.kms_key_arn = result.kms_key_arn
|
|
161
|
+
config.save()
|
|
162
|
+
|
|
163
|
+
console.print()
|
|
164
|
+
console.print("[green bold]Setup complete![/green bold]")
|
|
165
|
+
console.print()
|
|
166
|
+
console.print("[bold]Connection details (provide to credential consumer):[/bold]")
|
|
167
|
+
console.print(f" Role ARN: [cyan]{result.role_arn}[/cyan]")
|
|
168
|
+
console.print(f" Bucket: [cyan]{result.bucket}[/cyan]")
|
|
169
|
+
console.print(f" External ID: [cyan]{result.external_id}[/cyan]")
|
|
170
|
+
console.print(f" Region: [cyan]{result.region}[/cyan]")
|