gm-shield 0.2.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.
@@ -0,0 +1,3 @@
1
+ """Exchange Keyshare - CLI for market makers to securely share exchange credentials."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,261 @@
1
+ """Shared CloudFormation infrastructure — templates, polling, progress."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Generator
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+ from urllib.parse import quote
11
+
12
+ import boto3
13
+ from botocore.exceptions import ClientError
14
+
15
+ if TYPE_CHECKING:
16
+ from mypy_boto3_cloudformation import CloudFormationClient
17
+
18
+
19
+ def load_credentials_template() -> str:
20
+ """Load the creds CloudFormation template as a string."""
21
+ path = Path(__file__).parent / "templates" / "credentials-stack.yaml"
22
+ return path.read_text()
23
+
24
+
25
+ def load_enclave_template() -> str:
26
+ """Load the enclave CloudFormation template as a string."""
27
+ path = Path(__file__).parent / "templates" / "enclave-stack.yaml"
28
+ return path.read_text()
29
+
30
+
31
+ @dataclass
32
+ class ResourceStatus:
33
+ logical_id: str
34
+ resource_type: str
35
+ status: str
36
+ reason: str | None = None
37
+
38
+
39
+ @dataclass
40
+ class StackProgress:
41
+ resources: dict[str, ResourceStatus]
42
+ stack_status: str
43
+ is_complete: bool
44
+ is_failed: bool
45
+ failure_reason: str | None = None
46
+
47
+
48
+ RESOURCE_TYPE_NAMES: dict[str, str] = {
49
+ "AWS::S3::Bucket": "S3 Bucket",
50
+ "AWS::S3::BucketPolicy": "Bucket Policy",
51
+ "AWS::KMS::Key": "KMS Key",
52
+ "AWS::KMS::Alias": "KMS Alias",
53
+ "AWS::IAM::Role": "IAM Role",
54
+ "AWS::IAM::InstanceProfile": "Instance Profile",
55
+ "AWS::EC2::Instance": "EC2 Instance",
56
+ "AWS::EC2::LaunchTemplate": "EC2 Launch Template",
57
+ "AWS::AutoScaling::AutoScalingGroup": "Auto Scaling Group",
58
+ }
59
+
60
+
61
+ def get_friendly_type(resource_type: str) -> str:
62
+ """Get a more readable name for a resource type."""
63
+ return RESOURCE_TYPE_NAMES.get(resource_type, resource_type)
64
+
65
+
66
+ def get_stack_console_url(stack_id: str, region: str) -> str:
67
+ encoded = quote(stack_id, safe="")
68
+ return f"https://{region}.console.aws.amazon.com/cloudformation/home?region={region}#/stacks/stackinfo?stackId={encoded}"
69
+
70
+
71
+ def poll_stack_progress(
72
+ stack_name: str,
73
+ cfn: CloudFormationClient,
74
+ poll_interval: float = 2.0,
75
+ max_attempts: int = 300,
76
+ ) -> Generator[StackProgress, None, None]:
77
+ resources: dict[str, ResourceStatus] = {}
78
+
79
+ for _ in range(max_attempts):
80
+ try:
81
+ stacks_response = cfn.describe_stacks(StackName=stack_name)
82
+ except ClientError:
83
+ yield StackProgress(
84
+ resources=resources,
85
+ stack_status="DELETE_IN_PROGRESS",
86
+ is_complete=False,
87
+ is_failed=True,
88
+ failure_reason="Stack creation failed and is being deleted",
89
+ )
90
+ return
91
+
92
+ stack = stacks_response["Stacks"][0]
93
+ stack_status = stack.get("StackStatus", "UNKNOWN")
94
+
95
+ for event in cfn.describe_stack_events(StackName=stack_name)["StackEvents"]:
96
+ logical_id = event.get("LogicalResourceId", "")
97
+ resource_type = event.get("ResourceType", "")
98
+ status = event.get("ResourceStatus", "")
99
+ reason = event.get("ResourceStatusReason")
100
+
101
+ if resource_type == "AWS::CloudFormation::Stack":
102
+ continue
103
+ is_newer = logical_id not in resources or _is_newer_status(
104
+ status, resources[logical_id].status
105
+ )
106
+ if is_newer:
107
+ resources[logical_id] = ResourceStatus(
108
+ logical_id=logical_id, resource_type=resource_type, status=status, reason=reason
109
+ )
110
+
111
+ is_complete = stack_status == "CREATE_COMPLETE"
112
+ is_failed = stack_status in (
113
+ "CREATE_FAILED",
114
+ "ROLLBACK_COMPLETE",
115
+ "ROLLBACK_IN_PROGRESS",
116
+ "DELETE_IN_PROGRESS",
117
+ )
118
+ failure_reason: str | None = None
119
+ if is_failed:
120
+ failed = [r for r in resources.values() if "FAILED" in r.status]
121
+ reasons = [r.reason for r in failed if r.reason]
122
+ failure_reason = (
123
+ "; ".join(reasons[:3]) if reasons else ("Unknown error" if failed else None)
124
+ )
125
+
126
+ yield StackProgress(
127
+ resources=resources,
128
+ stack_status=stack_status,
129
+ is_complete=is_complete,
130
+ is_failed=is_failed,
131
+ failure_reason=failure_reason,
132
+ )
133
+
134
+ if is_complete or is_failed:
135
+ return
136
+ time.sleep(poll_interval)
137
+
138
+ yield StackProgress(
139
+ resources=resources,
140
+ stack_status="TIMEOUT",
141
+ is_complete=False,
142
+ is_failed=True,
143
+ failure_reason="Stack creation timed out",
144
+ )
145
+
146
+
147
+ def _is_newer_status(new_status: str, old_status: str) -> bool:
148
+ order = [
149
+ "CREATE_IN_PROGRESS",
150
+ "CREATE_COMPLETE",
151
+ "CREATE_FAILED",
152
+ "DELETE_IN_PROGRESS",
153
+ "DELETE_COMPLETE",
154
+ ]
155
+ try:
156
+ return order.index(new_status) > order.index(old_status)
157
+ except ValueError:
158
+ return True
159
+
160
+
161
+ def start_stack_deletion(stack_name: str, region: str):
162
+ cfn = boto3.client("cloudformation", region_name=region) # type: ignore[reportUnknownVariableType]
163
+ try:
164
+ cfn.delete_stack(StackName=stack_name)
165
+ except ClientError as e:
166
+ raise Exception(f"Failed to delete stack: {e}") from e
167
+ return cfn
168
+
169
+
170
+ def poll_stack_deletion(
171
+ stack_name: str,
172
+ cfn: CloudFormationClient,
173
+ poll_interval: float = 2.0,
174
+ max_attempts: int = 300,
175
+ ) -> Generator[StackProgress, None, None]:
176
+ resources: dict[str, ResourceStatus] = {}
177
+
178
+ for _ in range(max_attempts):
179
+ try:
180
+ stacks_response = cfn.describe_stacks(StackName=stack_name)
181
+ except ClientError as e:
182
+ if "does not exist" in str(e):
183
+ yield StackProgress(
184
+ resources=resources,
185
+ stack_status="DELETE_COMPLETE",
186
+ is_complete=True,
187
+ is_failed=False,
188
+ )
189
+ return
190
+ raise
191
+
192
+ stack = stacks_response["Stacks"][0]
193
+ stack_status = stack.get("StackStatus", "UNKNOWN")
194
+
195
+ try:
196
+ for event in cfn.describe_stack_events(StackName=stack_name)["StackEvents"]:
197
+ logical_id = event.get("LogicalResourceId", "")
198
+ resource_type = event.get("ResourceType", "")
199
+ status = event.get("ResourceStatus", "")
200
+ reason = event.get("ResourceStatusReason")
201
+
202
+ if resource_type == "AWS::CloudFormation::Stack":
203
+ continue
204
+ if "DELETE" not in status:
205
+ continue
206
+ is_newer = logical_id not in resources or _is_newer_delete_status(
207
+ status, resources[logical_id].status
208
+ )
209
+ if is_newer:
210
+ resources[logical_id] = ResourceStatus(
211
+ logical_id=logical_id,
212
+ resource_type=resource_type,
213
+ status=status,
214
+ reason=reason,
215
+ )
216
+ except ClientError:
217
+ pass
218
+
219
+ is_complete = stack_status == "DELETE_COMPLETE"
220
+ is_failed = stack_status == "DELETE_FAILED"
221
+ failure_reason = None
222
+ if is_failed:
223
+ failed = [r for r in resources.values() if "FAILED" in r.status]
224
+ reasons = [r.reason for r in failed if r.reason]
225
+ failure_reason = (
226
+ "; ".join(reasons[:3]) if reasons else ("Unknown error" if failed else None)
227
+ )
228
+
229
+ yield StackProgress(
230
+ resources=resources,
231
+ stack_status=stack_status,
232
+ is_complete=is_complete,
233
+ is_failed=is_failed,
234
+ failure_reason=failure_reason,
235
+ )
236
+
237
+ if is_complete or is_failed:
238
+ return
239
+ time.sleep(poll_interval)
240
+
241
+ yield StackProgress(
242
+ resources=resources,
243
+ stack_status="TIMEOUT",
244
+ is_complete=False,
245
+ is_failed=True,
246
+ failure_reason="Stack deletion timed out",
247
+ )
248
+
249
+
250
+ def _is_newer_delete_status(new_status: str, old_status: str) -> bool:
251
+ order = [
252
+ "CREATE_COMPLETE",
253
+ "DELETE_IN_PROGRESS",
254
+ "DELETE_COMPLETE",
255
+ "DELETE_FAILED",
256
+ "DELETE_SKIPPED",
257
+ ]
258
+ try:
259
+ return order.index(new_status) > order.index(old_status)
260
+ except ValueError:
261
+ return True
@@ -0,0 +1,43 @@
1
+ """CLI entrypoint for exchange-keyshare."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from exchange_keyshare.commands.config import config
8
+ from exchange_keyshare.commands.enclave import enclave
9
+ from exchange_keyshare.commands.keys import keys
10
+ from exchange_keyshare.commands.setup import setup
11
+ from exchange_keyshare.commands.teardown import teardown
12
+ from exchange_keyshare.config import Config
13
+
14
+
15
+ @click.group()
16
+ @click.version_option()
17
+ @click.option(
18
+ "--config",
19
+ "config_path",
20
+ envvar="GM_SHIELD_CONFIG",
21
+ type=click.Path(),
22
+ help="Path to config file",
23
+ )
24
+ @click.pass_context
25
+ def main(ctx: click.Context, config_path: str | None) -> None:
26
+ """GM Shield - Securely share exchange API credentials."""
27
+ ctx.ensure_object(dict)
28
+ cfg = Config()
29
+ if config_path:
30
+ cfg.config_path = Path(config_path)
31
+ cfg.load()
32
+ ctx.obj["config"] = cfg
33
+
34
+
35
+ main.add_command(setup)
36
+ main.add_command(config)
37
+ main.add_command(teardown)
38
+ main.add_command(keys)
39
+ main.add_command(enclave)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,55 @@
1
+ """Config command for displaying the 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]gm-shield setup[/cyan] first.")
19
+ return
20
+
21
+ table = Table(show_header=False, box=None)
22
+ table.add_row("[cyan]Keyshare[/cyan]", "")
23
+ table.add_column("Key", style="dim")
24
+ table.add_column("Value", style="cyan")
25
+
26
+ table.add_row("Bucket", cfg.bucket or "")
27
+ table.add_row("Region", cfg.region or "")
28
+ table.add_row("Stack Name", cfg.stack_name or "")
29
+ table.add_row("Stack ID", cfg.stack_id or "")
30
+ table.add_row("KMS Key ARN", cfg.kms_key_arn or "")
31
+ table.add_row("Config Path", str(cfg.config_path))
32
+
33
+ if cfg.enclaves:
34
+ table.add_row("", "")
35
+ table.add_row("[purple]Enclaves[/purple]", "")
36
+ for i, enclave in enumerate(cfg.enclaves, 1):
37
+ labels_str = ", ".join(enclave.labels) if enclave.labels else "-"
38
+ table.add_row(f"{i}. Stack Name", enclave.stack_name)
39
+ table.add_row(" Stack ID", enclave.stack_id)
40
+ table.add_row(" Region", enclave.region)
41
+ table.add_row(" Host Ref", enclave.host_ref)
42
+ table.add_row(" Role ARN", enclave.role_arn)
43
+ table.add_row(" Elastic IP", enclave.eip)
44
+ table.add_row(" Labels", labels_str)
45
+ if i < len(cfg.enclaves):
46
+ table.add_row("", "")
47
+
48
+ if cfg.approved_enclave:
49
+ table.add_row("", "")
50
+ table.add_row("[green]Approved Enclave[/green]", "")
51
+ table.add_row("Role ARN", cfg.approved_enclave.role_arn)
52
+ table.add_row("PCR8", cfg.approved_enclave.pcr8)
53
+ table.add_row("Approved At", cfg.approved_enclave.approved_at)
54
+
55
+ console.print(table)