lablink-cli 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.
- lablink_cli/__init__.py +8 -0
- lablink_cli/api.py +428 -0
- lablink_cli/app.py +938 -0
- lablink_cli/byo_detect.py +112 -0
- lablink_cli/commands/__init__.py +0 -0
- lablink_cli/commands/cleanup.py +647 -0
- lablink_cli/commands/deploy.py +863 -0
- lablink_cli/commands/deploy_compose.py +1203 -0
- lablink_cli/commands/doctor.py +549 -0
- lablink_cli/commands/export_metrics.py +244 -0
- lablink_cli/commands/launch.py +236 -0
- lablink_cli/commands/logs.py +434 -0
- lablink_cli/commands/register.py +839 -0
- lablink_cli/commands/reset_overlay.py +109 -0
- lablink_cli/commands/setup.py +347 -0
- lablink_cli/commands/stats.py +133 -0
- lablink_cli/commands/status.py +934 -0
- lablink_cli/commands/unregister.py +188 -0
- lablink_cli/commands/utils.py +552 -0
- lablink_cli/config/__init__.py +0 -0
- lablink_cli/config/schema.py +212 -0
- lablink_cli/deployment_metrics.py +94 -0
- lablink_cli/docker.py +419 -0
- lablink_cli/log_shipper.py +441 -0
- lablink_cli/templates/docker-compose.tailscale-override.yml +55 -0
- lablink_cli/templates/docker-compose.yml +67 -0
- lablink_cli/tofu_source.py +169 -0
- lablink_cli/tui/__init__.py +0 -0
- lablink_cli/tui/logs_viewer.py +413 -0
- lablink_cli/tui/wizard.py +1814 -0
- lablink_cli-0.1.0.dist-info/METADATA +76 -0
- lablink_cli-0.1.0.dist-info/RECORD +35 -0
- lablink_cli-0.1.0.dist-info/WHEEL +5 -0
- lablink_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lablink_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""Clean up deployment resources and local state.
|
|
2
|
+
|
|
3
|
+
AWS provider: orphaned EC2/IAM/EIP/SG/state resources via boto3.
|
|
4
|
+
Manual provider: the local docker-compose stack and working directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
from botocore.exceptions import ClientError
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
|
|
17
|
+
from lablink_allocator_service.conf.structured_config import Config
|
|
18
|
+
|
|
19
|
+
from lablink_cli.commands.setup import (
|
|
20
|
+
_get_session,
|
|
21
|
+
check_credentials,
|
|
22
|
+
resolve_bucket_name,
|
|
23
|
+
)
|
|
24
|
+
from lablink_cli.commands.utils import (
|
|
25
|
+
get_deploy_dir as _get_deploy_dir,
|
|
26
|
+
)
|
|
27
|
+
from lablink_cli.docker import Docker, default_docker
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
DEFAULT_COMPOSE_DIR = Path.home() / ".lablink" / "compose"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _delete_if_exists(
|
|
35
|
+
action: str, fn, *args, **kwargs
|
|
36
|
+
) -> bool:
|
|
37
|
+
"""Call fn and return True on success, False if not found."""
|
|
38
|
+
try:
|
|
39
|
+
fn(*args, **kwargs)
|
|
40
|
+
console.print(f" [green]deleted[/green] {action}")
|
|
41
|
+
return True
|
|
42
|
+
except ClientError as e:
|
|
43
|
+
code = e.response["Error"]["Code"]
|
|
44
|
+
if code in (
|
|
45
|
+
"NotFoundException",
|
|
46
|
+
"ResourceNotFoundException",
|
|
47
|
+
"NoSuchEntity",
|
|
48
|
+
"InvalidParameterValue",
|
|
49
|
+
"InvalidKeyPair.NotFound",
|
|
50
|
+
"InvalidGroup.NotFound",
|
|
51
|
+
"404",
|
|
52
|
+
):
|
|
53
|
+
console.print(
|
|
54
|
+
f" [dim]not found[/dim] {action}"
|
|
55
|
+
)
|
|
56
|
+
return False
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
# EC2 resources
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
def cleanup_ec2_instances(
|
|
64
|
+
ec2, region: str, deployment_name: str, environment: str, dry_run: bool
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Terminate lablink EC2 instances."""
|
|
67
|
+
console.print("[bold]EC2 Instances[/bold]")
|
|
68
|
+
resp = ec2.describe_instances(
|
|
69
|
+
Filters=[
|
|
70
|
+
{
|
|
71
|
+
"Name": "tag:Name",
|
|
72
|
+
"Values": [
|
|
73
|
+
f"{deployment_name}-allocator-{environment}",
|
|
74
|
+
f"*-lablink-client-{environment}-vm-*",
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"Name": "instance-state-name",
|
|
79
|
+
"Values": [
|
|
80
|
+
"running",
|
|
81
|
+
"stopped",
|
|
82
|
+
"pending",
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
instance_ids = [
|
|
88
|
+
i["InstanceId"]
|
|
89
|
+
for r in resp["Reservations"]
|
|
90
|
+
for i in r["Instances"]
|
|
91
|
+
]
|
|
92
|
+
if not instance_ids:
|
|
93
|
+
console.print(" [dim]none found[/dim]")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
for iid in instance_ids:
|
|
97
|
+
if dry_run:
|
|
98
|
+
console.print(
|
|
99
|
+
f" [yellow]would terminate[/yellow] {iid}"
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
ec2.terminate_instances(InstanceIds=[iid])
|
|
103
|
+
console.print(
|
|
104
|
+
f" [green]terminated[/green] {iid}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if not dry_run and instance_ids:
|
|
108
|
+
console.print(" waiting for termination...")
|
|
109
|
+
waiter = ec2.get_waiter("instance_terminated")
|
|
110
|
+
waiter.wait(InstanceIds=instance_ids)
|
|
111
|
+
console.print(" [green]done[/green]")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cleanup_security_groups(
|
|
115
|
+
ec2, deployment_name: str, environment: str, dry_run: bool
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Delete lablink security groups.
|
|
118
|
+
|
|
119
|
+
Lablink SGs commonly cross-reference each other (e.g., the client SG has
|
|
120
|
+
an ingress rule sourcing the allocator SG). AWS rejects deletion while any
|
|
121
|
+
rule still references the SG, so revoke every matched SG's ingress and
|
|
122
|
+
egress rules first to break the cycle before attempting deletes. A single
|
|
123
|
+
SG with an external dependent (an ENI we don't own, a non-lablink SG) is
|
|
124
|
+
surfaced and skipped instead of aborting the rest of cleanup.
|
|
125
|
+
"""
|
|
126
|
+
console.print("[bold]Security Groups[/bold]")
|
|
127
|
+
matched: list[dict] = []
|
|
128
|
+
for pattern in [
|
|
129
|
+
f"{deployment_name}-allocator-sg-{environment}",
|
|
130
|
+
f"*-lablink-client-{environment}-sg",
|
|
131
|
+
f"{deployment_name}-alb-sg-{environment}",
|
|
132
|
+
]:
|
|
133
|
+
resp = ec2.describe_security_groups(
|
|
134
|
+
Filters=[{"Name": "group-name", "Values": [pattern]}]
|
|
135
|
+
)
|
|
136
|
+
matched.extend(resp["SecurityGroups"])
|
|
137
|
+
|
|
138
|
+
if not matched:
|
|
139
|
+
console.print(" [dim]none found[/dim]")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
if dry_run:
|
|
143
|
+
for sg in matched:
|
|
144
|
+
console.print(
|
|
145
|
+
f" [yellow]would delete[/yellow] "
|
|
146
|
+
f"{sg['GroupName']} ({sg['GroupId']})"
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
for sg in matched:
|
|
151
|
+
gid = sg["GroupId"]
|
|
152
|
+
if sg.get("IpPermissions"):
|
|
153
|
+
try:
|
|
154
|
+
ec2.revoke_security_group_ingress(
|
|
155
|
+
GroupId=gid, IpPermissions=sg["IpPermissions"]
|
|
156
|
+
)
|
|
157
|
+
except ClientError as e:
|
|
158
|
+
console.print(
|
|
159
|
+
f" [dim]revoke ingress failed on {gid}, continuing: "
|
|
160
|
+
f"{e.response['Error']['Code']}[/dim]"
|
|
161
|
+
)
|
|
162
|
+
if sg.get("IpPermissionsEgress"):
|
|
163
|
+
try:
|
|
164
|
+
ec2.revoke_security_group_egress(
|
|
165
|
+
GroupId=gid, IpPermissions=sg["IpPermissionsEgress"]
|
|
166
|
+
)
|
|
167
|
+
except ClientError as e:
|
|
168
|
+
console.print(
|
|
169
|
+
f" [dim]revoke egress failed on {gid}, continuing: "
|
|
170
|
+
f"{e.response['Error']['Code']}[/dim]"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
for sg in matched:
|
|
174
|
+
label = f"{sg['GroupName']} ({sg['GroupId']})"
|
|
175
|
+
try:
|
|
176
|
+
ec2.delete_security_group(GroupId=sg["GroupId"])
|
|
177
|
+
console.print(f" [green]deleted[/green] {label}")
|
|
178
|
+
except ClientError as e:
|
|
179
|
+
code = e.response["Error"]["Code"]
|
|
180
|
+
if code == "DependencyViolation":
|
|
181
|
+
console.print(
|
|
182
|
+
f" [red]could not delete[/red] {label}: still has "
|
|
183
|
+
f"dependents (likely an ENI or non-lablink SG rule); "
|
|
184
|
+
f"investigate manually"
|
|
185
|
+
)
|
|
186
|
+
elif code in (
|
|
187
|
+
"InvalidGroup.NotFound",
|
|
188
|
+
"NotFoundException",
|
|
189
|
+
"404",
|
|
190
|
+
):
|
|
191
|
+
console.print(f" [dim]not found[/dim] {label}")
|
|
192
|
+
else:
|
|
193
|
+
raise
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cleanup_key_pairs(
|
|
197
|
+
ec2, deployment_name: str, environment: str, software: str, dry_run: bool
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Delete lablink key pairs."""
|
|
200
|
+
console.print("[bold]Key Pairs[/bold]")
|
|
201
|
+
found = False
|
|
202
|
+
for name in [
|
|
203
|
+
f"{deployment_name}-keypair-{environment}",
|
|
204
|
+
f"{software}-lablink-client-{environment}-keypair",
|
|
205
|
+
]:
|
|
206
|
+
try:
|
|
207
|
+
ec2.describe_key_pairs(KeyNames=[name])
|
|
208
|
+
found = True
|
|
209
|
+
if dry_run:
|
|
210
|
+
console.print(
|
|
211
|
+
f" [yellow]would delete[/yellow] {name}"
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
_delete_if_exists(
|
|
215
|
+
name,
|
|
216
|
+
ec2.delete_key_pair,
|
|
217
|
+
KeyName=name,
|
|
218
|
+
)
|
|
219
|
+
except ClientError:
|
|
220
|
+
pass
|
|
221
|
+
if not found:
|
|
222
|
+
console.print(" [dim]none found[/dim]")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cleanup_elastic_ips(
|
|
226
|
+
ec2,
|
|
227
|
+
deployment_name: str,
|
|
228
|
+
environment: str,
|
|
229
|
+
eip_strategy: str,
|
|
230
|
+
dry_run: bool,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Release lablink elastic IPs.
|
|
233
|
+
|
|
234
|
+
Never releases when eip_strategy is "persistent" — the whole point
|
|
235
|
+
of that strategy is reuse across deployments (and, commonly, a DNS
|
|
236
|
+
record pointed at it), regardless of dry_run.
|
|
237
|
+
"""
|
|
238
|
+
console.print("[bold]Elastic IPs[/bold]")
|
|
239
|
+
resp = ec2.describe_addresses(
|
|
240
|
+
Filters=[
|
|
241
|
+
{
|
|
242
|
+
"Name": "tag:Name",
|
|
243
|
+
"Values": [f"{deployment_name}-eip-{environment}"],
|
|
244
|
+
}
|
|
245
|
+
]
|
|
246
|
+
)
|
|
247
|
+
if not resp["Addresses"]:
|
|
248
|
+
console.print(" [dim]none found[/dim]")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
if eip_strategy == "persistent":
|
|
252
|
+
for addr in resp["Addresses"]:
|
|
253
|
+
ip = addr.get("PublicIp", "")
|
|
254
|
+
alloc_id = addr["AllocationId"]
|
|
255
|
+
console.print(
|
|
256
|
+
f" [dim]skipping[/dim] {ip} ({alloc_id}) — "
|
|
257
|
+
"eip.strategy is 'persistent', reused across deployments"
|
|
258
|
+
)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
for addr in resp["Addresses"]:
|
|
262
|
+
alloc_id = addr["AllocationId"]
|
|
263
|
+
ip = addr.get("PublicIp", "")
|
|
264
|
+
if dry_run:
|
|
265
|
+
console.print(
|
|
266
|
+
f" [yellow]would release[/yellow] "
|
|
267
|
+
f"{ip} ({alloc_id})"
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
# Disassociate first if attached
|
|
271
|
+
if "AssociationId" in addr:
|
|
272
|
+
ec2.disassociate_address(
|
|
273
|
+
AssociationId=addr["AssociationId"]
|
|
274
|
+
)
|
|
275
|
+
ec2.release_address(AllocationId=alloc_id)
|
|
276
|
+
console.print(
|
|
277
|
+
f" [green]released[/green] "
|
|
278
|
+
f"{ip} ({alloc_id})"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
# IAM resources
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
def _cleanup_instance_profile(
|
|
286
|
+
iam, profile_name: str, dry_run: bool
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Delete an IAM instance profile, detaching roles first."""
|
|
289
|
+
try:
|
|
290
|
+
resp = iam.get_instance_profile(
|
|
291
|
+
InstanceProfileName=profile_name
|
|
292
|
+
)
|
|
293
|
+
if dry_run:
|
|
294
|
+
console.print(
|
|
295
|
+
f" [yellow]would delete[/yellow] "
|
|
296
|
+
f"profile: {profile_name}"
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
for role in resp["InstanceProfile"].get(
|
|
300
|
+
"Roles", []
|
|
301
|
+
):
|
|
302
|
+
iam.remove_role_from_instance_profile(
|
|
303
|
+
InstanceProfileName=profile_name,
|
|
304
|
+
RoleName=role["RoleName"],
|
|
305
|
+
)
|
|
306
|
+
iam.delete_instance_profile(
|
|
307
|
+
InstanceProfileName=profile_name
|
|
308
|
+
)
|
|
309
|
+
console.print(
|
|
310
|
+
f" [green]deleted[/green] "
|
|
311
|
+
f"profile: {profile_name}"
|
|
312
|
+
)
|
|
313
|
+
except ClientError:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _cleanup_role(
|
|
318
|
+
iam, role_name: str, dry_run: bool
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Delete an IAM role, detaching policies first."""
|
|
321
|
+
try:
|
|
322
|
+
resp = iam.list_attached_role_policies(
|
|
323
|
+
RoleName=role_name
|
|
324
|
+
)
|
|
325
|
+
for policy in resp["AttachedPolicies"]:
|
|
326
|
+
if not dry_run:
|
|
327
|
+
iam.detach_role_policy(
|
|
328
|
+
RoleName=role_name,
|
|
329
|
+
PolicyArn=policy["PolicyArn"],
|
|
330
|
+
)
|
|
331
|
+
if dry_run:
|
|
332
|
+
console.print(
|
|
333
|
+
f" [yellow]would delete[/yellow] "
|
|
334
|
+
f"role: {role_name}"
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
iam.delete_role(RoleName=role_name)
|
|
338
|
+
console.print(
|
|
339
|
+
f" [green]deleted[/green] "
|
|
340
|
+
f"role: {role_name}"
|
|
341
|
+
)
|
|
342
|
+
except ClientError:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cleanup_iam(
|
|
347
|
+
session: boto3.Session,
|
|
348
|
+
deployment_name: str,
|
|
349
|
+
environment: str,
|
|
350
|
+
software: str,
|
|
351
|
+
dry_run: bool,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Delete lablink IAM roles, policies, instance profiles."""
|
|
354
|
+
console.print("[bold]IAM Resources[/bold]")
|
|
355
|
+
iam = session.client("iam")
|
|
356
|
+
account_id = (
|
|
357
|
+
session.client("sts").get_caller_identity()["Account"]
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
client_prefix = f"{software}-lablink-client-{environment}"
|
|
361
|
+
|
|
362
|
+
# Instance profiles (allocator + client)
|
|
363
|
+
for profile_name in [
|
|
364
|
+
f"{deployment_name}-allocator-profile-{environment}",
|
|
365
|
+
f"{client_prefix}-instance-profile",
|
|
366
|
+
]:
|
|
367
|
+
_cleanup_instance_profile(iam, profile_name, dry_run)
|
|
368
|
+
|
|
369
|
+
# Roles (allocator + client)
|
|
370
|
+
for role_name in [
|
|
371
|
+
f"{deployment_name}-allocator-role-{environment}",
|
|
372
|
+
f"{client_prefix}-vm-role",
|
|
373
|
+
]:
|
|
374
|
+
_cleanup_role(iam, role_name, dry_run)
|
|
375
|
+
|
|
376
|
+
# Policies
|
|
377
|
+
for policy_name in [
|
|
378
|
+
f"{deployment_name}-s3-backend-policy-{environment}",
|
|
379
|
+
f"{deployment_name}-ec2-mgmt-policy-{environment}",
|
|
380
|
+
]:
|
|
381
|
+
arn = (
|
|
382
|
+
f"arn:aws:iam::{account_id}:policy/{policy_name}"
|
|
383
|
+
)
|
|
384
|
+
if dry_run:
|
|
385
|
+
try:
|
|
386
|
+
iam.get_policy(PolicyArn=arn)
|
|
387
|
+
console.print(
|
|
388
|
+
f" [yellow]would delete[/yellow] "
|
|
389
|
+
f"policy: {policy_name}"
|
|
390
|
+
)
|
|
391
|
+
except ClientError:
|
|
392
|
+
pass
|
|
393
|
+
else:
|
|
394
|
+
_delete_if_exists(
|
|
395
|
+
f"policy: {policy_name}",
|
|
396
|
+
iam.delete_policy,
|
|
397
|
+
PolicyArn=arn,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
# S3 environment state cleanup
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
def cleanup_s3_env_state(
|
|
405
|
+
session: boto3.Session,
|
|
406
|
+
deployment_name: str,
|
|
407
|
+
environment: str,
|
|
408
|
+
bucket_name: str,
|
|
409
|
+
dry_run: bool,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Delete environment-specific OpenTofu state files from S3."""
|
|
412
|
+
console.print("[bold]S3 OpenTofu State[/bold]")
|
|
413
|
+
s3 = session.client("s3")
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
s3.head_bucket(Bucket=bucket_name)
|
|
417
|
+
except ClientError:
|
|
418
|
+
console.print(
|
|
419
|
+
f" [dim]bucket not found:[/dim] {bucket_name}"
|
|
420
|
+
)
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
prefix = f"{deployment_name}/{environment}/"
|
|
424
|
+
try:
|
|
425
|
+
resp = s3.list_object_versions(
|
|
426
|
+
Bucket=bucket_name, Prefix=prefix
|
|
427
|
+
)
|
|
428
|
+
versions = resp.get("Versions", []) + resp.get(
|
|
429
|
+
"DeleteMarkers", []
|
|
430
|
+
)
|
|
431
|
+
if not versions:
|
|
432
|
+
console.print(" [dim]no state files found[/dim]")
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
for v in versions:
|
|
436
|
+
key = v["Key"]
|
|
437
|
+
vid = v["VersionId"]
|
|
438
|
+
if dry_run:
|
|
439
|
+
console.print(
|
|
440
|
+
f" [yellow]would delete[/yellow] "
|
|
441
|
+
f"s3://{bucket_name}/{key} ({vid})"
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
s3.delete_object(
|
|
445
|
+
Bucket=bucket_name,
|
|
446
|
+
Key=key,
|
|
447
|
+
VersionId=vid,
|
|
448
|
+
)
|
|
449
|
+
console.print(
|
|
450
|
+
f" [green]deleted[/green] "
|
|
451
|
+
f"s3://{bucket_name}/{key}"
|
|
452
|
+
)
|
|
453
|
+
except ClientError as e:
|
|
454
|
+
console.print(f" [red]error:[/red] {e}")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
# DynamoDB environment lock entries
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
def cleanup_dynamodb_env_locks(
|
|
461
|
+
session: boto3.Session,
|
|
462
|
+
deployment_name: str,
|
|
463
|
+
environment: str,
|
|
464
|
+
bucket_name: str,
|
|
465
|
+
dry_run: bool,
|
|
466
|
+
) -> None:
|
|
467
|
+
"""Delete environment-specific lock entries from DynamoDB."""
|
|
468
|
+
console.print("[bold]DynamoDB Lock Entries[/bold]")
|
|
469
|
+
dynamodb = session.client("dynamodb")
|
|
470
|
+
table_name = "lock-table"
|
|
471
|
+
|
|
472
|
+
lock_ids = [
|
|
473
|
+
f"{bucket_name}/{deployment_name}/{environment}"
|
|
474
|
+
f"/terraform.tfstate-md5",
|
|
475
|
+
f"{bucket_name}/{deployment_name}/{environment}"
|
|
476
|
+
f"/client/terraform.tfstate-md5",
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
found = False
|
|
480
|
+
for lock_id in lock_ids:
|
|
481
|
+
try:
|
|
482
|
+
resp = dynamodb.get_item(
|
|
483
|
+
TableName=table_name,
|
|
484
|
+
Key={"LockID": {"S": lock_id}},
|
|
485
|
+
)
|
|
486
|
+
if "Item" not in resp:
|
|
487
|
+
continue
|
|
488
|
+
found = True
|
|
489
|
+
if dry_run:
|
|
490
|
+
console.print(
|
|
491
|
+
f" [yellow]would delete[/yellow] {lock_id}"
|
|
492
|
+
)
|
|
493
|
+
else:
|
|
494
|
+
dynamodb.delete_item(
|
|
495
|
+
TableName=table_name,
|
|
496
|
+
Key={"LockID": {"S": lock_id}},
|
|
497
|
+
)
|
|
498
|
+
console.print(
|
|
499
|
+
f" [green]deleted[/green] {lock_id}"
|
|
500
|
+
)
|
|
501
|
+
except ClientError as e:
|
|
502
|
+
code = e.response["Error"]["Code"]
|
|
503
|
+
if code == "ResourceNotFoundException":
|
|
504
|
+
console.print(
|
|
505
|
+
" [dim]lock table not found[/dim]"
|
|
506
|
+
)
|
|
507
|
+
return
|
|
508
|
+
raise
|
|
509
|
+
if not found:
|
|
510
|
+
console.print(" [dim]no lock entries found[/dim]")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ------------------------------------------------------------------
|
|
514
|
+
# Local state
|
|
515
|
+
# ------------------------------------------------------------------
|
|
516
|
+
def cleanup_local(cfg: Config, dry_run: bool) -> None:
|
|
517
|
+
"""Delete local OpenTofu working directory."""
|
|
518
|
+
console.print("[bold]Local State[/bold]")
|
|
519
|
+
deploy_dir = _get_deploy_dir(cfg)
|
|
520
|
+
if deploy_dir.exists():
|
|
521
|
+
if dry_run:
|
|
522
|
+
console.print(
|
|
523
|
+
f" [yellow]would delete[/yellow] {deploy_dir}"
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
shutil.rmtree(deploy_dir)
|
|
527
|
+
console.print(
|
|
528
|
+
f" [green]deleted[/green] {deploy_dir}"
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
console.print(" [dim]not found[/dim]")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ------------------------------------------------------------------
|
|
535
|
+
# Main entry point
|
|
536
|
+
# ------------------------------------------------------------------
|
|
537
|
+
def run_cleanup(
|
|
538
|
+
cfg: Config,
|
|
539
|
+
dry_run: bool = False,
|
|
540
|
+
) -> None:
|
|
541
|
+
"""Clean up deployment resources.
|
|
542
|
+
|
|
543
|
+
Dispatches on cfg.provider: AWS orphaned resources via boto3,
|
|
544
|
+
or the local docker-compose stack for manual.
|
|
545
|
+
"""
|
|
546
|
+
if getattr(cfg, "provider", "aws") == "manual":
|
|
547
|
+
_run_cleanup_manual(cfg, dry_run=dry_run)
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
region = cfg.app.region
|
|
551
|
+
deployment_name = cfg.deployment_name
|
|
552
|
+
environment = cfg.environment
|
|
553
|
+
software = cfg.machine.software
|
|
554
|
+
|
|
555
|
+
console.print()
|
|
556
|
+
mode = "[yellow]DRY RUN[/yellow] " if dry_run else ""
|
|
557
|
+
console.print(
|
|
558
|
+
Panel(
|
|
559
|
+
f"{mode}[bold]LabLink Cleanup[/bold]\n"
|
|
560
|
+
f"Deployment: {deployment_name} | "
|
|
561
|
+
f"Environment: {environment}\n"
|
|
562
|
+
f"Region: {region}",
|
|
563
|
+
border_style="red" if not dry_run else "yellow",
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
console.print()
|
|
567
|
+
|
|
568
|
+
session = _get_session(region)
|
|
569
|
+
check_credentials(session)
|
|
570
|
+
ec2 = session.client("ec2")
|
|
571
|
+
|
|
572
|
+
# AWS resources matching current OpenTofu
|
|
573
|
+
cleanup_ec2_instances(ec2, region, deployment_name, environment, dry_run)
|
|
574
|
+
console.print()
|
|
575
|
+
cleanup_security_groups(ec2, deployment_name, environment, dry_run)
|
|
576
|
+
console.print()
|
|
577
|
+
cleanup_key_pairs(ec2, deployment_name, environment, software, dry_run)
|
|
578
|
+
console.print()
|
|
579
|
+
cleanup_elastic_ips(
|
|
580
|
+
ec2, deployment_name, environment, cfg.eip.strategy, dry_run
|
|
581
|
+
)
|
|
582
|
+
console.print()
|
|
583
|
+
cleanup_iam(session, deployment_name, environment, software, dry_run)
|
|
584
|
+
console.print()
|
|
585
|
+
|
|
586
|
+
# Environment-specific remote state
|
|
587
|
+
account_id = (
|
|
588
|
+
session.client("sts").get_caller_identity()["Account"]
|
|
589
|
+
)
|
|
590
|
+
bucket_name = resolve_bucket_name(account_id)
|
|
591
|
+
cleanup_s3_env_state(
|
|
592
|
+
session, deployment_name, environment, bucket_name, dry_run
|
|
593
|
+
)
|
|
594
|
+
console.print()
|
|
595
|
+
cleanup_dynamodb_env_locks(
|
|
596
|
+
session, deployment_name, environment, bucket_name, dry_run
|
|
597
|
+
)
|
|
598
|
+
console.print()
|
|
599
|
+
|
|
600
|
+
# Local state
|
|
601
|
+
cleanup_local(cfg, dry_run)
|
|
602
|
+
console.print()
|
|
603
|
+
|
|
604
|
+
if dry_run:
|
|
605
|
+
console.print(
|
|
606
|
+
"[yellow]Dry run complete.[/yellow] "
|
|
607
|
+
"Re-run without --dry-run to delete."
|
|
608
|
+
)
|
|
609
|
+
else:
|
|
610
|
+
console.print("[bold]Cleanup complete.[/bold]")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ------------------------------------------------------------------
|
|
614
|
+
# Manual-provider cleanup
|
|
615
|
+
# ------------------------------------------------------------------
|
|
616
|
+
def _run_cleanup_manual(
|
|
617
|
+
cfg: Config, *, dry_run: bool, docker: Docker | None = None
|
|
618
|
+
) -> None:
|
|
619
|
+
"""Tear down a local docker-compose allocator stack.
|
|
620
|
+
|
|
621
|
+
Runs `docker compose down --volumes` in the deployment workdir and
|
|
622
|
+
then removes the workdir itself. No AWS API calls are made.
|
|
623
|
+
"""
|
|
624
|
+
docker = docker or default_docker()
|
|
625
|
+
workdir = DEFAULT_COMPOSE_DIR / (cfg.deployment_name or "lablink")
|
|
626
|
+
|
|
627
|
+
console.print(
|
|
628
|
+
f"[bold]Manual cleanup:[/bold] {cfg.deployment_name or 'lablink'}"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if not workdir.exists():
|
|
632
|
+
console.print(
|
|
633
|
+
f"[dim]Nothing to clean — no compose stack at {workdir}.[/dim]"
|
|
634
|
+
)
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
if dry_run:
|
|
638
|
+
console.print(
|
|
639
|
+
f"[yellow]Would run:[/yellow] docker compose down --volumes "
|
|
640
|
+
f"(in {workdir})"
|
|
641
|
+
)
|
|
642
|
+
console.print(f"[yellow]Would remove:[/yellow] {workdir}")
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
docker.compose(workdir, "down", "--volumes", capture=False)
|
|
646
|
+
shutil.rmtree(workdir)
|
|
647
|
+
console.print(f"[green]Cleaned {workdir}.[/green]")
|