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.
@@ -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]")