exchange-keyshare 0.1.0__tar.gz → 0.1.1__tar.gz
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-0.1.0 → exchange_keyshare-0.1.1}/.claude/settings.local.json +6 -1
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/CLAUDE.md +6 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/PKG-INFO +2 -1
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/pyproject.toml +2 -1
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/cli.py +4 -0
- exchange_keyshare-0.1.1/src/exchange_keyshare/commands/config.py +34 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/commands/setup.py +1 -0
- exchange_keyshare-0.1.1/src/exchange_keyshare/commands/teardown.py +208 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/config.py +4 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/setup.py +129 -1
- exchange_keyshare-0.1.1/tests/test_cli.py +257 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/tests/test_config.py +17 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/.github/workflows/ci.yaml +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/.github/workflows/publish.yml +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/.gitignore +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/README.md +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/__init__.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/cfn.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/commands/__init__.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/commands/keys.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/keys.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/schema.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/templates/stack.yaml +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/templates +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/tests/__init__.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/tests/test_cfn.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/tests/test_keys.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/tests/test_schema.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/tests/test_setup.py +0 -0
- {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/uv.lock +0 -0
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nFix S3 upload AccessDenied by specifying KMS encryption\n\nThe bucket policy requires explicit KMS encryption on uploads, but\nupload_credential wasn''t passing ServerSideEncryption or SSEKMSKeyId\nparameters. Also, kms_key_arn was retrieved from CloudFormation but\nnever saved to the config file.\n\n- Add kms_key_arn field to Config class\n- Save kms_key_arn during setup\n- Pass kms_key_arn to all upload_credential calls\n- Remove outdated placeholder ARN test\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
|
7
7
|
"Bash(git push)",
|
|
8
8
|
"Bash(uv sync:*)",
|
|
9
|
-
"Bash(git commit:*)"
|
|
9
|
+
"Bash(git commit:*)",
|
|
10
|
+
"Bash(uv pip install:*)",
|
|
11
|
+
"WebFetch(domain:github.com)",
|
|
12
|
+
"WebSearch",
|
|
13
|
+
"WebFetch(domain:raw.githubusercontent.com)",
|
|
14
|
+
"WebFetch(domain:pypi.org)"
|
|
10
15
|
]
|
|
11
16
|
}
|
|
12
17
|
}
|
|
@@ -26,6 +26,8 @@ src/exchange_keyshare/
|
|
|
26
26
|
│ └── stack.yaml # CloudFormation template
|
|
27
27
|
└── commands/
|
|
28
28
|
├── setup.py # `exchange-keyshare setup` command
|
|
29
|
+
├── config.py # `exchange-keyshare config` command
|
|
30
|
+
├── teardown.py # `exchange-keyshare teardown` command
|
|
29
31
|
└── keys.py # `exchange-keyshare keys` subcommands
|
|
30
32
|
```
|
|
31
33
|
|
|
@@ -33,6 +35,8 @@ src/exchange_keyshare/
|
|
|
33
35
|
|
|
34
36
|
```bash
|
|
35
37
|
exchange-keyshare setup # Create AWS infrastructure
|
|
38
|
+
exchange-keyshare config # Display current configuration
|
|
39
|
+
exchange-keyshare teardown # Delete AWS infrastructure (revoke access)
|
|
36
40
|
exchange-keyshare keys list # List credentials
|
|
37
41
|
exchange-keyshare keys create # Create credential (interactive)
|
|
38
42
|
exchange-keyshare keys delete # Delete credential
|
|
@@ -47,8 +51,10 @@ Created by `setup` command with:
|
|
|
47
51
|
- `bucket`: S3 bucket name
|
|
48
52
|
- `region`: AWS region
|
|
49
53
|
- `stack_name`: CloudFormation stack name
|
|
54
|
+
- `stack_id`: CloudFormation stack ID (ARN)
|
|
50
55
|
- `role_arn`: IAM role ARN
|
|
51
56
|
- `external_id`: External ID for role assumption
|
|
57
|
+
- `kms_key_arn`: KMS key ARN
|
|
52
58
|
|
|
53
59
|
## Environment Variables
|
|
54
60
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: exchange-keyshare
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: CLI for market makers to securely share exchange API credentials
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: boto3>=1.34.0
|
|
7
|
+
Requires-Dist: botocore[crt]>=1.34.0
|
|
7
8
|
Requires-Dist: click>=8.1.0
|
|
8
9
|
Requires-Dist: pyyaml>=6.0
|
|
9
10
|
Requires-Dist: questionary>=2.0.0
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "exchange-keyshare"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.1"
|
|
4
4
|
description = "CLI for market makers to securely share exchange API credentials"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"click>=8.1.0",
|
|
8
8
|
"boto3>=1.34.0",
|
|
9
|
+
"botocore[crt]>=1.34.0",
|
|
9
10
|
"pyyaml>=6.0",
|
|
10
11
|
"rich>=13.0.0",
|
|
11
12
|
"questionary>=2.0.0",
|
|
@@ -4,8 +4,10 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
|
+
from exchange_keyshare.commands.config import config
|
|
7
8
|
from exchange_keyshare.commands.keys import keys
|
|
8
9
|
from exchange_keyshare.commands.setup import setup
|
|
10
|
+
from exchange_keyshare.commands.teardown import teardown
|
|
9
11
|
from exchange_keyshare.config import Config
|
|
10
12
|
|
|
11
13
|
|
|
@@ -30,6 +32,8 @@ def main(ctx: click.Context, config_path: str | None) -> None:
|
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
main.add_command(setup)
|
|
35
|
+
main.add_command(config)
|
|
36
|
+
main.add_command(teardown)
|
|
33
37
|
main.add_command(keys)
|
|
34
38
|
|
|
35
39
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Config command for displaying current configuration."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from exchange_keyshare.config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.pass_context
|
|
12
|
+
def config(ctx: click.Context) -> None:
|
|
13
|
+
"""Display current configuration."""
|
|
14
|
+
cfg: Config = ctx.obj["config"]
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
if not cfg.stack_name:
|
|
18
|
+
console.print("Not configured. Run [cyan]exchange-keyshare setup[/cyan] first.")
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
table = Table(show_header=False, box=None)
|
|
22
|
+
table.add_column("Key", style="dim")
|
|
23
|
+
table.add_column("Value", style="cyan")
|
|
24
|
+
|
|
25
|
+
table.add_row("Bucket", cfg.bucket or "")
|
|
26
|
+
table.add_row("Region", cfg.region or "")
|
|
27
|
+
table.add_row("Stack Name", cfg.stack_name or "")
|
|
28
|
+
table.add_row("Stack ID", cfg.stack_id or "")
|
|
29
|
+
table.add_row("Role ARN", cfg.role_arn or "")
|
|
30
|
+
table.add_row("External ID", cfg.external_id or "")
|
|
31
|
+
table.add_row("KMS Key ARN", cfg.kms_key_arn or "")
|
|
32
|
+
table.add_row("Config Path", str(cfg.config_path))
|
|
33
|
+
|
|
34
|
+
console.print(table)
|
|
@@ -155,6 +155,7 @@ def setup(ctx: click.Context) -> None:
|
|
|
155
155
|
config.bucket = result.bucket
|
|
156
156
|
config.region = result.region
|
|
157
157
|
config.stack_name = result.stack_name
|
|
158
|
+
config.stack_id = result.stack_id
|
|
158
159
|
config.role_arn = result.role_arn
|
|
159
160
|
config.external_id = result.external_id
|
|
160
161
|
config.kms_key_arn = result.kms_key_arn
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Teardown command for deleting AWS infrastructure."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console, Group, RenderableType
|
|
5
|
+
from rich.live import Live
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.spinner import Spinner
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from exchange_keyshare.config import Config
|
|
12
|
+
from exchange_keyshare.keys import CredentialInfo, list_credentials
|
|
13
|
+
from exchange_keyshare.setup import (
|
|
14
|
+
ResourceStatus,
|
|
15
|
+
StackProgress,
|
|
16
|
+
get_friendly_type,
|
|
17
|
+
get_stack_console_url,
|
|
18
|
+
poll_stack_deletion,
|
|
19
|
+
start_stack_deletion,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_delete_status_style(status: str) -> str:
|
|
24
|
+
"""Get rich style for a deletion status."""
|
|
25
|
+
if "DELETE_COMPLETE" in status or "DELETE_SKIPPED" in status:
|
|
26
|
+
return "green"
|
|
27
|
+
elif "IN_PROGRESS" in status:
|
|
28
|
+
return "yellow"
|
|
29
|
+
elif "FAILED" in status:
|
|
30
|
+
return "red"
|
|
31
|
+
return "white"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_delete_status_icon(status: str) -> str:
|
|
35
|
+
"""Get icon for a deletion status."""
|
|
36
|
+
if "DELETE_COMPLETE" in status or "DELETE_SKIPPED" in status:
|
|
37
|
+
return "[green]✓[/green]"
|
|
38
|
+
elif "IN_PROGRESS" in status:
|
|
39
|
+
return "[yellow]⋯[/yellow]"
|
|
40
|
+
elif "FAILED" in status:
|
|
41
|
+
return "[red]✗[/red]"
|
|
42
|
+
return " "
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_deletion_progress_display(progress: StackProgress) -> RenderableType:
|
|
46
|
+
"""Build a rich display showing resource deletion progress with spinner."""
|
|
47
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
48
|
+
table.add_column("", width=2)
|
|
49
|
+
table.add_column("Resource", style="cyan", min_width=25)
|
|
50
|
+
table.add_column("Type", style="dim")
|
|
51
|
+
table.add_column("Status")
|
|
52
|
+
|
|
53
|
+
# Sort resources: in_progress first, then by name
|
|
54
|
+
def sort_key(item: tuple[str, ResourceStatus]) -> tuple[int, str]:
|
|
55
|
+
_, r = item
|
|
56
|
+
if "IN_PROGRESS" in r.status:
|
|
57
|
+
return (0, r.logical_id)
|
|
58
|
+
elif "COMPLETE" in r.status or "SKIPPED" in r.status:
|
|
59
|
+
return (2, r.logical_id)
|
|
60
|
+
else:
|
|
61
|
+
return (1, r.logical_id)
|
|
62
|
+
|
|
63
|
+
sorted_resources = sorted(progress.resources.items(), key=sort_key)
|
|
64
|
+
|
|
65
|
+
for _logical_id, resource in sorted_resources:
|
|
66
|
+
icon = get_delete_status_icon(resource.status)
|
|
67
|
+
style = get_delete_status_style(resource.status)
|
|
68
|
+
friendly_type = get_friendly_type(resource.resource_type)
|
|
69
|
+
|
|
70
|
+
# Simplify status text
|
|
71
|
+
status_text = resource.status.replace("_", " ").title()
|
|
72
|
+
|
|
73
|
+
table.add_row(
|
|
74
|
+
icon,
|
|
75
|
+
resource.logical_id,
|
|
76
|
+
friendly_type,
|
|
77
|
+
f"[{style}]{status_text}[/{style}]",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Add spinner header if still in progress
|
|
81
|
+
if not progress.is_complete and not progress.is_failed:
|
|
82
|
+
spinner = Spinner("dots", text=Text(" Deleting infrastructure...", style="bold"))
|
|
83
|
+
return Group(spinner, Text(""), table)
|
|
84
|
+
|
|
85
|
+
return table
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@click.command()
|
|
89
|
+
@click.pass_context
|
|
90
|
+
def teardown(ctx: click.Context) -> None:
|
|
91
|
+
"""Delete AWS infrastructure and revoke access.
|
|
92
|
+
|
|
93
|
+
This permanently deletes all infrastructure created by setup.
|
|
94
|
+
The S3 bucket and KMS key are retained but access is revoked.
|
|
95
|
+
"""
|
|
96
|
+
cfg: Config = ctx.obj["config"]
|
|
97
|
+
console = Console()
|
|
98
|
+
|
|
99
|
+
if not cfg.stack_name:
|
|
100
|
+
console.print("Nothing to teardown. Run [cyan]exchange-keyshare setup[/cyan] first.")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
region = cfg.region or "us-east-1"
|
|
104
|
+
|
|
105
|
+
# Try to list existing credentials (gracefully handle failure)
|
|
106
|
+
credentials: list[CredentialInfo] = []
|
|
107
|
+
if cfg.bucket and cfg.region:
|
|
108
|
+
try:
|
|
109
|
+
credentials = list_credentials(cfg.bucket, cfg.region)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass # Gracefully ignore - permissions may already be revoked
|
|
112
|
+
|
|
113
|
+
# Build credential list for warning
|
|
114
|
+
cred_text = ""
|
|
115
|
+
if credentials:
|
|
116
|
+
cred_text = "\n[bold]Credentials that will become inaccessible:[/bold]\n"
|
|
117
|
+
for cred in credentials:
|
|
118
|
+
cred_text += f" - [cyan]{cred.exchange}[/cyan]\n"
|
|
119
|
+
if cred.pairs:
|
|
120
|
+
cred_text += f" Pairs: {', '.join(cred.pairs)}\n"
|
|
121
|
+
if cred.labels:
|
|
122
|
+
labels_str = ", ".join(f"{lbl['key']}={lbl['value']}" for lbl in cred.labels)
|
|
123
|
+
cred_text += f" Labels: {labels_str}\n"
|
|
124
|
+
cred_text += "\n"
|
|
125
|
+
|
|
126
|
+
# Build console URL if we have a stack ID
|
|
127
|
+
console_url = ""
|
|
128
|
+
if cfg.stack_id:
|
|
129
|
+
console_url = get_stack_console_url(cfg.stack_id, region)
|
|
130
|
+
|
|
131
|
+
# Show scary warning with specific resources
|
|
132
|
+
manual_cmd = f"aws cloudformation delete-stack --stack-name {cfg.stack_name} --region {region}"
|
|
133
|
+
|
|
134
|
+
warning = Panel(
|
|
135
|
+
"[bold red]WARNING: This action cannot be undone![/bold red]\n\n"
|
|
136
|
+
"[bold]Resources that will be DELETED:[/bold]\n"
|
|
137
|
+
f" - IAM Role: [cyan]{cfg.role_arn}[/cyan]\n"
|
|
138
|
+
f" - KMS Key Alias: [cyan]alias/{cfg.bucket}[/cyan]\n"
|
|
139
|
+
" - S3 Bucket Policies\n\n"
|
|
140
|
+
"[bold]Resources that will be RETAINED (but inaccessible):[/bold]\n"
|
|
141
|
+
f" - S3 Bucket: [cyan]{cfg.bucket}[/cyan]\n"
|
|
142
|
+
f" - KMS Key: [cyan]{cfg.kms_key_arn}[/cyan]\n"
|
|
143
|
+
f"{cred_text}\n"
|
|
144
|
+
f"Stack: [cyan]{cfg.stack_name}[/cyan]\n"
|
|
145
|
+
f"Region: [cyan]{region}[/cyan]\n\n"
|
|
146
|
+
f"[dim]Manual command: {manual_cmd}[/dim]",
|
|
147
|
+
title="[bold red]Teardown Infrastructure[/bold red]",
|
|
148
|
+
border_style="red",
|
|
149
|
+
)
|
|
150
|
+
console.print(warning)
|
|
151
|
+
console.print()
|
|
152
|
+
|
|
153
|
+
# Require typing the stack name to confirm
|
|
154
|
+
console.print(f"To confirm, type the stack name: [bold]{cfg.stack_name}[/bold]")
|
|
155
|
+
confirmation = click.prompt("", default="", show_default=False)
|
|
156
|
+
|
|
157
|
+
if confirmation != cfg.stack_name:
|
|
158
|
+
console.print("[yellow]Aborted.[/yellow] Stack name did not match.")
|
|
159
|
+
raise SystemExit(1)
|
|
160
|
+
|
|
161
|
+
console.print()
|
|
162
|
+
|
|
163
|
+
# Show console URL before starting deletion
|
|
164
|
+
if console_url:
|
|
165
|
+
console.print(f"[dim]Monitor progress in AWS Console:[/dim]")
|
|
166
|
+
console.print(f"[link={console_url}]{console_url}[/link]")
|
|
167
|
+
console.print()
|
|
168
|
+
console.print("[dim]You can safely cancel this command (Ctrl+C) - deletion will continue in AWS.[/dim]")
|
|
169
|
+
console.print()
|
|
170
|
+
|
|
171
|
+
# Start deletion and poll with live display
|
|
172
|
+
try:
|
|
173
|
+
cfn = start_stack_deletion(cfg.stack_name, region)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
console.print(f"[red]Error starting deletion: {e}[/red]")
|
|
176
|
+
raise SystemExit(1)
|
|
177
|
+
|
|
178
|
+
final_progress: StackProgress | None = None
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
with Live(console=console, refresh_per_second=10) as live:
|
|
182
|
+
for progress in poll_stack_deletion(cfg.stack_name, cfn):
|
|
183
|
+
display = build_deletion_progress_display(progress)
|
|
184
|
+
live.update(display)
|
|
185
|
+
final_progress = progress
|
|
186
|
+
|
|
187
|
+
if progress.is_complete or progress.is_failed:
|
|
188
|
+
break
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
console.print()
|
|
191
|
+
console.print("[yellow]Interrupted.[/yellow] Deletion continues in AWS.")
|
|
192
|
+
if console_url:
|
|
193
|
+
console.print(f"[dim]Monitor progress:[/dim] {console_url}")
|
|
194
|
+
raise SystemExit(0)
|
|
195
|
+
|
|
196
|
+
if final_progress is None or final_progress.is_failed:
|
|
197
|
+
console.print()
|
|
198
|
+
console.print(f"[red]Error: {final_progress.failure_reason if final_progress else 'Unknown error'}[/red]")
|
|
199
|
+
if console_url:
|
|
200
|
+
console.print(f"[dim]Check AWS Console:[/dim] {console_url}")
|
|
201
|
+
raise SystemExit(1)
|
|
202
|
+
|
|
203
|
+
# Delete config file
|
|
204
|
+
if cfg.config_path.exists():
|
|
205
|
+
cfg.config_path.unlink()
|
|
206
|
+
|
|
207
|
+
console.print()
|
|
208
|
+
console.print("[green]Teardown complete.[/green] All access has been revoked.")
|
|
@@ -26,6 +26,7 @@ class Config:
|
|
|
26
26
|
bucket: str | None = None
|
|
27
27
|
region: str | None = None
|
|
28
28
|
stack_name: str | None = None
|
|
29
|
+
stack_id: str | None = None
|
|
29
30
|
role_arn: str | None = None
|
|
30
31
|
external_id: str | None = None
|
|
31
32
|
kms_key_arn: str | None = None
|
|
@@ -36,6 +37,7 @@ class Config:
|
|
|
36
37
|
self.bucket = data.get("bucket")
|
|
37
38
|
self.region = data.get("region")
|
|
38
39
|
self.stack_name = data.get("stack_name")
|
|
40
|
+
self.stack_id = data.get("stack_id")
|
|
39
41
|
self.role_arn = data.get("role_arn")
|
|
40
42
|
self.external_id = data.get("external_id")
|
|
41
43
|
self.kms_key_arn = data.get("kms_key_arn")
|
|
@@ -49,6 +51,8 @@ class Config:
|
|
|
49
51
|
data["region"] = self.region
|
|
50
52
|
if self.stack_name:
|
|
51
53
|
data["stack_name"] = self.stack_name
|
|
54
|
+
if self.stack_id:
|
|
55
|
+
data["stack_id"] = self.stack_id
|
|
52
56
|
if self.role_arn:
|
|
53
57
|
data["role_arn"] = self.role_arn
|
|
54
58
|
if self.external_id:
|
|
@@ -40,6 +40,7 @@ class SetupResult:
|
|
|
40
40
|
role_arn: str
|
|
41
41
|
external_id: str
|
|
42
42
|
stack_name: str
|
|
43
|
+
stack_id: str
|
|
43
44
|
kms_key_arn: str
|
|
44
45
|
|
|
45
46
|
|
|
@@ -216,12 +217,138 @@ def _is_newer_status(new_status: str, old_status: str) -> bool:
|
|
|
216
217
|
return True
|
|
217
218
|
|
|
218
219
|
|
|
220
|
+
def get_stack_console_url(stack_id: str, region: str) -> str:
|
|
221
|
+
"""Get AWS Console URL for a CloudFormation stack."""
|
|
222
|
+
# URL-encode the stack ID (it contains special characters)
|
|
223
|
+
from urllib.parse import quote
|
|
224
|
+
encoded_stack_id = quote(stack_id, safe="")
|
|
225
|
+
return f"https://{region}.console.aws.amazon.com/cloudformation/home?region={region}#/stacks/stackinfo?stackId={encoded_stack_id}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def start_stack_deletion(stack_name: str, region: str) -> "CloudFormationClient":
|
|
229
|
+
"""Start CloudFormation stack deletion. Returns cfn_client for polling."""
|
|
230
|
+
cfn = cast("CloudFormationClient", boto3.client("cloudformation", region_name=region)) # pyright: ignore[reportUnknownMemberType]
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
cfn.delete_stack(StackName=stack_name) # pyright: ignore[reportUnknownMemberType]
|
|
234
|
+
except ClientError as e:
|
|
235
|
+
raise Exception(f"Failed to delete stack: {e}") from e
|
|
236
|
+
|
|
237
|
+
return cfn
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def poll_stack_deletion(
|
|
241
|
+
stack_name: str,
|
|
242
|
+
cfn: "CloudFormationClient",
|
|
243
|
+
poll_interval: float = 2.0,
|
|
244
|
+
max_attempts: int = 300,
|
|
245
|
+
) -> Generator[StackProgress, None, None]:
|
|
246
|
+
"""Poll stack deletion progress and yield updates."""
|
|
247
|
+
resources: dict[str, ResourceStatus] = {}
|
|
248
|
+
|
|
249
|
+
for _ in range(max_attempts):
|
|
250
|
+
# Get current stack status
|
|
251
|
+
try:
|
|
252
|
+
stacks_response = cfn.describe_stacks(StackName=stack_name)
|
|
253
|
+
except ClientError as e:
|
|
254
|
+
# Stack not found means deletion complete
|
|
255
|
+
if "does not exist" in str(e):
|
|
256
|
+
yield StackProgress(
|
|
257
|
+
resources=resources,
|
|
258
|
+
stack_status="DELETE_COMPLETE",
|
|
259
|
+
is_complete=True,
|
|
260
|
+
is_failed=False,
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
raise
|
|
264
|
+
|
|
265
|
+
stack = stacks_response["Stacks"][0]
|
|
266
|
+
stack_status = stack.get("StackStatus", "UNKNOWN")
|
|
267
|
+
|
|
268
|
+
# Get resource events
|
|
269
|
+
try:
|
|
270
|
+
events_response = cfn.describe_stack_events(StackName=stack_name)
|
|
271
|
+
for event in events_response["StackEvents"]:
|
|
272
|
+
logical_id = event.get("LogicalResourceId", "")
|
|
273
|
+
resource_type = event.get("ResourceType", "")
|
|
274
|
+
status = event.get("ResourceStatus", "")
|
|
275
|
+
reason = event.get("ResourceStatusReason")
|
|
276
|
+
|
|
277
|
+
# Skip the stack itself
|
|
278
|
+
if resource_type == "AWS::CloudFormation::Stack":
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
# Update if this is a newer status for this resource
|
|
282
|
+
if logical_id not in resources or _is_newer_delete_status(status, resources[logical_id].status):
|
|
283
|
+
resources[logical_id] = ResourceStatus(
|
|
284
|
+
logical_id=logical_id,
|
|
285
|
+
resource_type=resource_type,
|
|
286
|
+
status=status,
|
|
287
|
+
reason=reason,
|
|
288
|
+
)
|
|
289
|
+
except ClientError:
|
|
290
|
+
pass # Stack events may not be available
|
|
291
|
+
|
|
292
|
+
# Check for completion
|
|
293
|
+
is_complete = stack_status == "DELETE_COMPLETE"
|
|
294
|
+
is_failed = stack_status == "DELETE_FAILED"
|
|
295
|
+
|
|
296
|
+
failure_reason: str | None = None
|
|
297
|
+
if is_failed:
|
|
298
|
+
failed_resources = [r for r in resources.values() if "FAILED" in r.status]
|
|
299
|
+
if failed_resources:
|
|
300
|
+
reasons = [r.reason for r in failed_resources if r.reason]
|
|
301
|
+
failure_reason = "; ".join(reasons[:3]) if reasons else "Unknown error"
|
|
302
|
+
|
|
303
|
+
yield StackProgress(
|
|
304
|
+
resources=resources,
|
|
305
|
+
stack_status=stack_status,
|
|
306
|
+
is_complete=is_complete,
|
|
307
|
+
is_failed=is_failed,
|
|
308
|
+
failure_reason=failure_reason,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if is_complete or is_failed:
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
time.sleep(poll_interval)
|
|
315
|
+
|
|
316
|
+
# Timeout
|
|
317
|
+
yield StackProgress(
|
|
318
|
+
resources=resources,
|
|
319
|
+
stack_status="TIMEOUT",
|
|
320
|
+
is_complete=False,
|
|
321
|
+
is_failed=True,
|
|
322
|
+
failure_reason="Stack deletion timed out",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _is_newer_delete_status(new_status: str, old_status: str) -> bool:
|
|
327
|
+
"""Check if new_status is more recent than old_status for deletion."""
|
|
328
|
+
status_order = [
|
|
329
|
+
"CREATE_COMPLETE",
|
|
330
|
+
"DELETE_IN_PROGRESS",
|
|
331
|
+
"DELETE_COMPLETE",
|
|
332
|
+
"DELETE_FAILED",
|
|
333
|
+
"DELETE_SKIPPED",
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
new_idx = status_order.index(new_status)
|
|
338
|
+
old_idx = status_order.index(old_status)
|
|
339
|
+
return new_idx > old_idx
|
|
340
|
+
except ValueError:
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
|
|
219
344
|
def get_stack_outputs(stack_name: str, region: str) -> SetupResult:
|
|
220
345
|
"""Get outputs from a completed stack."""
|
|
221
346
|
cfn = cast("CloudFormationClient", boto3.client("cloudformation", region_name=region)) # pyright: ignore[reportUnknownMemberType]
|
|
222
347
|
|
|
223
348
|
response = cfn.describe_stacks(StackName=stack_name)
|
|
224
|
-
|
|
349
|
+
stack = response["Stacks"][0]
|
|
350
|
+
stack_id = stack.get("StackId", "")
|
|
351
|
+
stack_outputs = stack.get("Outputs", [])
|
|
225
352
|
outputs: dict[str, str] = {
|
|
226
353
|
o["OutputKey"]: o["OutputValue"]
|
|
227
354
|
for o in stack_outputs
|
|
@@ -234,6 +361,7 @@ def get_stack_outputs(stack_name: str, region: str) -> SetupResult:
|
|
|
234
361
|
role_arn=outputs["RoleArn"],
|
|
235
362
|
external_id=outputs["ExternalId"],
|
|
236
363
|
stack_name=stack_name,
|
|
364
|
+
stack_id=stack_id,
|
|
237
365
|
kms_key_arn=outputs["KmsKeyArn"],
|
|
238
366
|
)
|
|
239
367
|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Tests for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from click.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from exchange_keyshare.cli import main
|
|
9
|
+
from exchange_keyshare.config import save_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_config_command_displays_configuration(tmp_path: Path) -> None:
|
|
13
|
+
"""Config command displays all configuration fields."""
|
|
14
|
+
config_path = tmp_path / "config.yaml"
|
|
15
|
+
|
|
16
|
+
save_config(
|
|
17
|
+
config_path,
|
|
18
|
+
{
|
|
19
|
+
"bucket": "test-bucket",
|
|
20
|
+
"region": "us-east-1",
|
|
21
|
+
"stack_name": "test-stack",
|
|
22
|
+
"stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
|
|
23
|
+
"role_arn": "arn:aws:iam::123456789:role/test-role",
|
|
24
|
+
"external_id": "test-external-id",
|
|
25
|
+
"kms_key_arn": "arn:aws:kms:us-east-1:123456789:key/abc123",
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
runner = CliRunner()
|
|
30
|
+
result = runner.invoke(main, ["--config", str(config_path), "config"])
|
|
31
|
+
|
|
32
|
+
assert result.exit_code == 0
|
|
33
|
+
assert "test-bucket" in result.output
|
|
34
|
+
assert "us-east-1" in result.output
|
|
35
|
+
assert "test-stack" in result.output
|
|
36
|
+
# Stack ID may be truncated by Rich table rendering, just check it starts showing
|
|
37
|
+
assert "arn:aws:cloudformation" in result.output
|
|
38
|
+
assert "arn:aws:iam" in result.output
|
|
39
|
+
assert "test-external-id" in result.output
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_config_command_shows_message_when_not_configured(tmp_path: Path) -> None:
|
|
43
|
+
"""Config command shows helpful message when no config exists."""
|
|
44
|
+
config_path = tmp_path / "config.yaml"
|
|
45
|
+
|
|
46
|
+
runner = CliRunner()
|
|
47
|
+
result = runner.invoke(main, ["--config", str(config_path), "config"])
|
|
48
|
+
|
|
49
|
+
assert result.exit_code == 0
|
|
50
|
+
assert "not configured" in result.output.lower() or "setup" in result.output.lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_teardown_requires_confirmation(tmp_path: Path) -> None:
|
|
54
|
+
"""Teardown command requires typing confirmation to proceed."""
|
|
55
|
+
config_path = tmp_path / "config.yaml"
|
|
56
|
+
save_config(
|
|
57
|
+
config_path,
|
|
58
|
+
{
|
|
59
|
+
"bucket": "test-bucket",
|
|
60
|
+
"region": "us-east-1",
|
|
61
|
+
"stack_name": "test-stack",
|
|
62
|
+
"stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
runner = CliRunner()
|
|
67
|
+
# User types wrong confirmation
|
|
68
|
+
result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="wrong\n")
|
|
69
|
+
|
|
70
|
+
assert result.exit_code == 1
|
|
71
|
+
assert "aborted" in result.output.lower() or "cancelled" in result.output.lower()
|
|
72
|
+
# Config file should still exist
|
|
73
|
+
assert config_path.exists()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_teardown_shows_scary_warning(tmp_path: Path) -> None:
|
|
77
|
+
"""Teardown command shows a scary warning about irreversibility."""
|
|
78
|
+
config_path = tmp_path / "config.yaml"
|
|
79
|
+
save_config(
|
|
80
|
+
config_path,
|
|
81
|
+
{
|
|
82
|
+
"bucket": "test-bucket",
|
|
83
|
+
"region": "us-east-1",
|
|
84
|
+
"stack_name": "test-stack",
|
|
85
|
+
"stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
runner = CliRunner()
|
|
90
|
+
result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="\n")
|
|
91
|
+
|
|
92
|
+
# Should show warning text about irreversibility
|
|
93
|
+
output_lower = result.output.lower()
|
|
94
|
+
assert "warning" in output_lower or "cannot be undone" in output_lower or "permanent" in output_lower
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@patch("exchange_keyshare.commands.teardown.poll_stack_deletion")
|
|
98
|
+
@patch("exchange_keyshare.commands.teardown.start_stack_deletion")
|
|
99
|
+
def test_teardown_deletes_stack_and_config_on_confirmation(
|
|
100
|
+
mock_start_deletion: MagicMock,
|
|
101
|
+
mock_poll_deletion: MagicMock,
|
|
102
|
+
tmp_path: Path,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Teardown deletes CloudFormation stack and config file when confirmed."""
|
|
105
|
+
from exchange_keyshare.setup import StackProgress
|
|
106
|
+
|
|
107
|
+
# Mock successful deletion
|
|
108
|
+
mock_start_deletion.return_value = MagicMock()
|
|
109
|
+
mock_poll_deletion.return_value = iter([
|
|
110
|
+
StackProgress(
|
|
111
|
+
resources={},
|
|
112
|
+
stack_status="DELETE_COMPLETE",
|
|
113
|
+
is_complete=True,
|
|
114
|
+
is_failed=False,
|
|
115
|
+
)
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
config_path = tmp_path / "config.yaml"
|
|
119
|
+
save_config(
|
|
120
|
+
config_path,
|
|
121
|
+
{
|
|
122
|
+
"bucket": "test-bucket",
|
|
123
|
+
"region": "us-east-1",
|
|
124
|
+
"stack_name": "test-stack",
|
|
125
|
+
"stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
runner = CliRunner()
|
|
130
|
+
# User types correct confirmation (the stack name)
|
|
131
|
+
result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="test-stack\n")
|
|
132
|
+
|
|
133
|
+
assert result.exit_code == 0
|
|
134
|
+
mock_start_deletion.assert_called_once_with("test-stack", "us-east-1")
|
|
135
|
+
# Config file should be deleted
|
|
136
|
+
assert not config_path.exists()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_teardown_shows_message_when_not_configured(tmp_path: Path) -> None:
|
|
140
|
+
"""Teardown shows helpful message when no config exists."""
|
|
141
|
+
config_path = tmp_path / "config.yaml"
|
|
142
|
+
|
|
143
|
+
runner = CliRunner()
|
|
144
|
+
result = runner.invoke(main, ["--config", str(config_path), "teardown"])
|
|
145
|
+
|
|
146
|
+
assert result.exit_code == 0
|
|
147
|
+
assert "not configured" in result.output.lower() or "nothing to teardown" in result.output.lower()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@patch("exchange_keyshare.commands.teardown.list_credentials")
|
|
151
|
+
def test_teardown_shows_existing_credentials_with_pairs_and_labels(
|
|
152
|
+
mock_list_credentials: MagicMock, tmp_path: Path
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Teardown shows existing credentials with pairs and labels in the warning."""
|
|
155
|
+
from exchange_keyshare.keys import CredentialInfo
|
|
156
|
+
|
|
157
|
+
mock_list_credentials.return_value = [
|
|
158
|
+
CredentialInfo(
|
|
159
|
+
key="exchange-credentials/binance-abc123.yaml",
|
|
160
|
+
exchange="binance",
|
|
161
|
+
pairs=["BTC/USDT", "ETH/USDT"],
|
|
162
|
+
labels=[{"key": "environment", "value": "production"}],
|
|
163
|
+
),
|
|
164
|
+
CredentialInfo(
|
|
165
|
+
key="exchange-credentials/coinbase-def456.yaml",
|
|
166
|
+
exchange="coinbase",
|
|
167
|
+
pairs=None,
|
|
168
|
+
labels=None,
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
config_path = tmp_path / "config.yaml"
|
|
173
|
+
save_config(
|
|
174
|
+
config_path,
|
|
175
|
+
{
|
|
176
|
+
"bucket": "test-bucket",
|
|
177
|
+
"region": "us-east-1",
|
|
178
|
+
"stack_name": "test-stack",
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
runner = CliRunner()
|
|
183
|
+
result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="\n")
|
|
184
|
+
|
|
185
|
+
# Should show credentials in output
|
|
186
|
+
assert "binance" in result.output.lower()
|
|
187
|
+
assert "coinbase" in result.output.lower()
|
|
188
|
+
# Should show pairs
|
|
189
|
+
assert "BTC/USDT" in result.output
|
|
190
|
+
assert "ETH/USDT" in result.output
|
|
191
|
+
# Should show labels
|
|
192
|
+
assert "environment=production" in result.output
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@patch("exchange_keyshare.commands.teardown.list_credentials")
|
|
196
|
+
def test_teardown_gracefully_handles_list_credentials_failure(
|
|
197
|
+
mock_list_credentials: MagicMock, tmp_path: Path
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Teardown continues gracefully if listing credentials fails."""
|
|
200
|
+
mock_list_credentials.side_effect = Exception("Access denied")
|
|
201
|
+
|
|
202
|
+
config_path = tmp_path / "config.yaml"
|
|
203
|
+
save_config(
|
|
204
|
+
config_path,
|
|
205
|
+
{
|
|
206
|
+
"bucket": "test-bucket",
|
|
207
|
+
"region": "us-east-1",
|
|
208
|
+
"stack_name": "test-stack",
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
runner = CliRunner()
|
|
213
|
+
result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="\n")
|
|
214
|
+
|
|
215
|
+
# Should still show the warning and prompt (not crash)
|
|
216
|
+
assert "warning" in result.output.lower() or "cannot be undone" in result.output.lower()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@patch("exchange_keyshare.commands.teardown.poll_stack_deletion")
|
|
220
|
+
@patch("exchange_keyshare.commands.teardown.start_stack_deletion")
|
|
221
|
+
def test_teardown_shows_console_url_and_cancel_message(
|
|
222
|
+
mock_start_deletion: MagicMock,
|
|
223
|
+
mock_poll_deletion: MagicMock,
|
|
224
|
+
tmp_path: Path,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Teardown shows AWS Console URL and message about safe cancellation."""
|
|
227
|
+
from exchange_keyshare.setup import StackProgress
|
|
228
|
+
|
|
229
|
+
mock_start_deletion.return_value = MagicMock()
|
|
230
|
+
mock_poll_deletion.return_value = iter([
|
|
231
|
+
StackProgress(
|
|
232
|
+
resources={},
|
|
233
|
+
stack_status="DELETE_COMPLETE",
|
|
234
|
+
is_complete=True,
|
|
235
|
+
is_failed=False,
|
|
236
|
+
)
|
|
237
|
+
])
|
|
238
|
+
|
|
239
|
+
config_path = tmp_path / "config.yaml"
|
|
240
|
+
save_config(
|
|
241
|
+
config_path,
|
|
242
|
+
{
|
|
243
|
+
"bucket": "test-bucket",
|
|
244
|
+
"region": "us-east-1",
|
|
245
|
+
"stack_name": "test-stack",
|
|
246
|
+
"stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
runner = CliRunner()
|
|
251
|
+
result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="test-stack\n")
|
|
252
|
+
|
|
253
|
+
assert result.exit_code == 0
|
|
254
|
+
# Should show console URL
|
|
255
|
+
assert "console.aws.amazon.com" in result.output
|
|
256
|
+
# Should show message about safe cancellation
|
|
257
|
+
assert "ctrl+c" in result.output.lower() or "cancel" in result.output.lower()
|
|
@@ -69,3 +69,20 @@ def test_save_config_sets_restrictive_permissions(tmp_path: Path) -> None:
|
|
|
69
69
|
assert mode & stat.S_IRWXU == stat.S_IRUSR | stat.S_IWUSR # owner rw
|
|
70
70
|
assert mode & stat.S_IRWXG == 0 # no group permissions
|
|
71
71
|
assert mode & stat.S_IRWXO == 0 # no other permissions
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_config_saves_and_loads_stack_id(tmp_path: Path) -> None:
|
|
75
|
+
"""Config can save and load stack_id field."""
|
|
76
|
+
config_path = tmp_path / "config.yaml"
|
|
77
|
+
|
|
78
|
+
config = Config(config_path=config_path)
|
|
79
|
+
config.bucket = "test-bucket"
|
|
80
|
+
config.region = "us-east-1"
|
|
81
|
+
config.stack_name = "test-stack"
|
|
82
|
+
config.stack_id = "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123"
|
|
83
|
+
config.save()
|
|
84
|
+
|
|
85
|
+
loaded = Config(config_path=config_path)
|
|
86
|
+
loaded.load()
|
|
87
|
+
|
|
88
|
+
assert loaded.stack_id == "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{exchange_keyshare-0.1.0 → exchange_keyshare-0.1.1}/src/exchange_keyshare/templates/stack.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|