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.
- exchange_keyshare/__init__.py +3 -0
- exchange_keyshare/cfn.py +261 -0
- exchange_keyshare/cli.py +43 -0
- exchange_keyshare/commands/__init__.py +1 -0
- exchange_keyshare/commands/config.py +55 -0
- exchange_keyshare/commands/enclave.py +950 -0
- exchange_keyshare/commands/keys.py +476 -0
- exchange_keyshare/commands/setup.py +162 -0
- exchange_keyshare/commands/teardown.py +210 -0
- exchange_keyshare/config.py +175 -0
- exchange_keyshare/enclave.py +110 -0
- exchange_keyshare/keys.py +131 -0
- exchange_keyshare/schema.py +125 -0
- exchange_keyshare/setup.py +89 -0
- exchange_keyshare/templates/credentials-stack.yaml +137 -0
- exchange_keyshare/templates/enclave-stack.yaml +251 -0
- gm_shield-0.2.0.dist-info/METADATA +12 -0
- gm_shield-0.2.0.dist-info/RECORD +20 -0
- gm_shield-0.2.0.dist-info/WHEEL +4 -0
- gm_shield-0.2.0.dist-info/entry_points.txt +2 -0
exchange_keyshare/cfn.py
ADDED
|
@@ -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
|
exchange_keyshare/cli.py
ADDED
|
@@ -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)
|