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,109 @@
1
+ """`lablink client reset-overlay` — discard this box's persisted overlay
2
+ node identity.
3
+
4
+ Separate from `unregister` on purpose. `unregister` keeps the identity so
5
+ that unregister/register lands back on the same tailnet node with the same
6
+ MagicDNS name. Removing it is the opposite intent — "let this box join as a
7
+ brand-new node next time" — and it has a consequence that has to be stated
8
+ out loud rather than buried in a teardown path:
9
+
10
+ Deleting the local state does NOT delete the machine from the tailnet. The
11
+ coordination server keeps its own record, the machine simply goes offline,
12
+ and it *keeps holding its MagicDNS name*. So the next `register` mints a new
13
+ node which cannot claim that name and is handed a suffixed one
14
+ (`...-gpu-1` -> `...-gpu-1-1`). Freeing the name requires deleting the stale
15
+ node in the Tailscale admin console. This command therefore says so.
16
+
17
+ The client reports whatever name it actually got back to the allocator (see
18
+ client/start.sh), so a suffixed name is not broken — just untidy, and
19
+ untidiness here is what made lablink#404 hard to read.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import typer
25
+ from rich.console import Console
26
+
27
+ from lablink_cli.commands.register import TAILSCALE_STATE_VOLUME
28
+ from lablink_cli.docker import Docker, DockerUnavailable, default_docker
29
+ from lablink_cli.log_shipper import CONTAINER_NAME
30
+
31
+ TAILNET_ADMIN_URL = "https://login.tailscale.com/admin/machines"
32
+
33
+
34
+ def run_reset_overlay(*, yes: bool, docker: Docker | None = None) -> None:
35
+ """Remove the persisted tailscaled state volume for the BYO client."""
36
+ docker = docker or default_docker()
37
+ console = Console()
38
+
39
+ try:
40
+ docker.require()
41
+ except DockerUnavailable:
42
+ console.print(
43
+ "[red]docker is not on PATH.[/red] There is nothing for this "
44
+ "command to remove without it."
45
+ )
46
+ raise SystemExit(1)
47
+
48
+ status = docker.container_status(CONTAINER_NAME)
49
+ if status == "daemon_error":
50
+ console.print(
51
+ "[red]Docker daemon is unreachable.[/red] Start Docker and "
52
+ "re-run."
53
+ )
54
+ raise SystemExit(1)
55
+ if status != "missing":
56
+ # Docker refuses to remove a volume that is still attached, and
57
+ # tearing the container down belongs to unregister — don't duplicate
58
+ # it here and half-succeed.
59
+ console.print(
60
+ f"[red]The {CONTAINER_NAME} container still exists "
61
+ f"(status: {status}).[/red]\n"
62
+ "Docker will not remove a volume that is still attached. Run "
63
+ "[bold]lablink client unregister[/bold] first (or "
64
+ f"`docker rm -f {CONTAINER_NAME}`), then re-run this command."
65
+ )
66
+ raise SystemExit(1)
67
+
68
+ if not docker.volume_exists(TAILSCALE_STATE_VOLUME):
69
+ console.print(
70
+ "Nothing to reset — no persisted overlay identity on this box."
71
+ )
72
+ return
73
+
74
+ if not yes:
75
+ confirmed = typer.confirm(
76
+ f"Remove the {TAILSCALE_STATE_VOLUME} volume? The next "
77
+ "`lablink client register` will join the tailnet as a new node.",
78
+ default=False,
79
+ )
80
+ if not confirmed:
81
+ console.print("Aborted.")
82
+ return
83
+
84
+ result = docker.remove_volume(TAILSCALE_STATE_VOLUME)
85
+ if not result.ok:
86
+ console.print(
87
+ f"[red]Could not remove {TAILSCALE_STATE_VOLUME}: "
88
+ f"{result.stderr.strip() or '(no stderr)'}[/red]"
89
+ )
90
+ raise SystemExit(1)
91
+
92
+ console.print(
93
+ f"[green]Removed {TAILSCALE_STATE_VOLUME}.[/green] The next "
94
+ "`lablink client register` will join the tailnet as a new node."
95
+ )
96
+ # Said explicitly because it is the non-obvious half: the operator has
97
+ # just discarded the local identity, but the old machine is still in the
98
+ # tailnet holding the name, so the new node gets a numeric suffix until
99
+ # that machine is deleted. Silence here is what makes the suffix look
100
+ # like a typo rather than a rename (lablink#404).
101
+ console.print(
102
+ "\n[yellow]This does not remove the old machine from your "
103
+ "tailnet.[/yellow] It goes offline but keeps holding its MagicDNS "
104
+ "name, so the new node will be given a numeric suffix (e.g. "
105
+ "[dim]-gpu-1[/dim] -> [dim]-gpu-1-1[/dim]) until you delete the "
106
+ f"stale machine at:\n {TAILNET_ADMIN_URL}\n"
107
+ "The client reports whichever name it actually receives, so a "
108
+ "suffixed name still works."
109
+ )
@@ -0,0 +1,347 @@
1
+ """AWS bootstrapping for LabLink (S3, DynamoDB, Route53).
2
+
3
+ Replaces the template repo's setup.sh with boto3 calls.
4
+ Creates the infrastructure needed before `lablink deploy` can run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
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
+ from rich.table import Table
17
+
18
+ from lablink_allocator_service.conf.structured_config import Config
19
+
20
+ console = Console()
21
+
22
+
23
+ def _get_session(region: str) -> boto3.Session:
24
+ """Create a boto3 session for the given region."""
25
+ return boto3.Session(region_name=region)
26
+
27
+
28
+ # ------------------------------------------------------------------
29
+ # Step 1: Validate AWS credentials
30
+ # ------------------------------------------------------------------
31
+ def check_credentials(session: boto3.Session) -> dict:
32
+ """Validate AWS credentials and return caller identity."""
33
+ sts = session.client("sts")
34
+ try:
35
+ identity = sts.get_caller_identity()
36
+ return {
37
+ "account": identity["Account"],
38
+ "arn": identity["Arn"],
39
+ "user_id": identity["UserId"],
40
+ }
41
+ except (ClientError, Exception) as e:
42
+ console.print(
43
+ "[red]AWS credentials not found or invalid.[/red]"
44
+ )
45
+ console.print()
46
+ console.print(
47
+ " To create access keys, visit:"
48
+ )
49
+ console.print(
50
+ " [link=https://console.aws.amazon.com/iam/"
51
+ "home#/security_credentials]"
52
+ "https://console.aws.amazon.com/iam/"
53
+ "home#/security_credentials"
54
+ "[/link]"
55
+ )
56
+ console.print()
57
+ console.print(
58
+ " Then configure them with one of:"
59
+ )
60
+ console.print(
61
+ ' [dim]1. Run: [bold]aws configure[/bold]'
62
+ "[/dim]"
63
+ )
64
+ console.print(
65
+ " [dim]2. Set environment variables: "
66
+ "[bold]AWS_ACCESS_KEY_ID[/bold] and "
67
+ "[bold]AWS_SECRET_ACCESS_KEY[/bold][/dim]"
68
+ )
69
+ console.print()
70
+ console.print(f" [dim]Error: {e}[/dim]")
71
+ raise SystemExit(1)
72
+
73
+
74
+ # ------------------------------------------------------------------
75
+ # Step 2: Create S3 bucket for OpenTofu state
76
+ # ------------------------------------------------------------------
77
+ def create_s3_bucket(
78
+ session: boto3.Session, bucket_name: str, region: str
79
+ ) -> bool:
80
+ """Create an S3 bucket with versioning. Returns True if created."""
81
+ s3 = session.client("s3")
82
+
83
+ # Check if we already own this bucket
84
+ try:
85
+ s3.head_bucket(Bucket=bucket_name)
86
+ console.print(
87
+ f" [green]exists[/green] S3 bucket: {bucket_name}"
88
+ )
89
+ return False
90
+ except ClientError:
91
+ # head_bucket returns 403 for both "not yours" and
92
+ # "doesn't exist" (S3 anti-enumeration behavior).
93
+ # Try create_bucket and handle specific errors instead.
94
+ pass
95
+
96
+ # Create bucket (us-east-1 doesn't use LocationConstraint)
97
+ create_args: dict = {"Bucket": bucket_name}
98
+ if region != "us-east-1":
99
+ create_args["CreateBucketConfiguration"] = {
100
+ "LocationConstraint": region
101
+ }
102
+
103
+ try:
104
+ s3.create_bucket(**create_args)
105
+ except ClientError as e:
106
+ code = e.response["Error"]["Code"]
107
+ if code == "BucketAlreadyOwnedByYou":
108
+ console.print(
109
+ f" [green]exists[/green] S3 bucket: "
110
+ f"{bucket_name}"
111
+ )
112
+ return False
113
+ if code == "BucketAlreadyExists":
114
+ console.print(
115
+ f" [red]error[/red] S3 bucket "
116
+ f"'{bucket_name}' is taken by another "
117
+ "account — this should not happen with "
118
+ "account-ID-based naming"
119
+ )
120
+ raise SystemExit(1)
121
+ raise
122
+
123
+ # Enable versioning
124
+ s3.put_bucket_versioning(
125
+ Bucket=bucket_name,
126
+ VersioningConfiguration={"Status": "Enabled"},
127
+ )
128
+ console.print(
129
+ f" [green]created[/green] S3 bucket: {bucket_name} "
130
+ "(versioning enabled)"
131
+ )
132
+ return True
133
+
134
+
135
+ # ------------------------------------------------------------------
136
+ # Step 3: Create DynamoDB table for OpenTofu state locking
137
+ # ------------------------------------------------------------------
138
+ def create_dynamodb_table(
139
+ session: boto3.Session, region: str
140
+ ) -> bool:
141
+ """Create the lock-table DynamoDB table. Returns True if created."""
142
+ dynamodb = session.client("dynamodb", region_name=region)
143
+ table_name = "lock-table"
144
+
145
+ try:
146
+ dynamodb.describe_table(TableName=table_name)
147
+ console.print(
148
+ f" [green]exists[/green] DynamoDB table: {table_name}"
149
+ )
150
+ return False
151
+ except dynamodb.exceptions.ResourceNotFoundException:
152
+ pass
153
+
154
+ dynamodb.create_table(
155
+ TableName=table_name,
156
+ KeySchema=[
157
+ {"AttributeName": "LockID", "KeyType": "HASH"},
158
+ ],
159
+ AttributeDefinitions=[
160
+ {"AttributeName": "LockID", "AttributeType": "S"},
161
+ ],
162
+ BillingMode="PAY_PER_REQUEST",
163
+ )
164
+
165
+ # Wait for table to become active
166
+ waiter = dynamodb.get_waiter("table_exists")
167
+ waiter.wait(
168
+ TableName=table_name,
169
+ WaiterConfig={"Delay": 2, "MaxAttempts": 30},
170
+ )
171
+ console.print(
172
+ f" [green]created[/green] DynamoDB table: {table_name}"
173
+ )
174
+ return True
175
+
176
+
177
+ # ------------------------------------------------------------------
178
+ # Step 4: Route53 hosted zone (optional)
179
+ # ------------------------------------------------------------------
180
+ def create_route53_zone(
181
+ session: boto3.Session, domain: str
182
+ ) -> str | None:
183
+ """Create or find a Route53 hosted zone. Returns zone ID or None."""
184
+ route53 = session.client("route53")
185
+
186
+ # Extract root domain (e.g., example.com from test.example.com)
187
+ parts = domain.split(".")
188
+ if len(parts) >= 2:
189
+ zone_name = ".".join(parts[-2:])
190
+ else:
191
+ zone_name = domain
192
+
193
+ # Check for existing zone
194
+ resp = route53.list_hosted_zones_by_name(DNSName=zone_name)
195
+ matching = [
196
+ z
197
+ for z in resp["HostedZones"]
198
+ if z["Name"].rstrip(".") == zone_name
199
+ ]
200
+
201
+ if len(matching) == 1:
202
+ zone_id = matching[0]["Id"].replace("/hostedzone/", "")
203
+ console.print(
204
+ f" [green]exists[/green] Route53 zone: "
205
+ f"{zone_name} ({zone_id})"
206
+ )
207
+ return zone_id
208
+
209
+ if len(matching) > 1:
210
+ console.print(
211
+ f" [yellow]warning[/yellow] Multiple zones found "
212
+ f"for {zone_name} — resolve manually"
213
+ )
214
+ return None
215
+
216
+ # Create new zone
217
+ caller_ref = f"lablink-setup-{int(time.time())}"
218
+ resp = route53.create_hosted_zone(
219
+ Name=zone_name, CallerReference=caller_ref
220
+ )
221
+ zone_id = resp["HostedZone"]["Id"].replace(
222
+ "/hostedzone/", ""
223
+ )
224
+
225
+ # Show nameservers
226
+ zone_info = route53.get_hosted_zone(Id=zone_id)
227
+ nameservers = zone_info["DelegationSet"]["NameServers"]
228
+
229
+ console.print(
230
+ f" [green]created[/green] Route53 zone: "
231
+ f"{zone_name} ({zone_id})"
232
+ )
233
+ console.print()
234
+ console.print(
235
+ Panel(
236
+ "\n".join(f" {ns}" for ns in nameservers),
237
+ title="[yellow]Update your domain registrar "
238
+ "with these nameservers[/yellow]",
239
+ border_style="yellow",
240
+ )
241
+ )
242
+ return zone_id
243
+
244
+
245
+ # ------------------------------------------------------------------
246
+ # Main entry point
247
+ # ------------------------------------------------------------------
248
+ def resolve_bucket_name(account_id: str) -> str:
249
+ """Generate a unique bucket name using the AWS account ID."""
250
+ return f"lablink-tf-state-{account_id}"
251
+
252
+
253
+ def run_setup(cfg: Config, config_path: Path | None = None) -> None:
254
+ """Run the full setup sequence.
255
+
256
+ No-ops for manual provider (no AWS remote-state resources needed).
257
+ """
258
+ if getattr(cfg, "provider", "aws") == "manual":
259
+ console.print(
260
+ "[yellow]Manual provider doesn't use S3/DynamoDB remote "
261
+ "state — skipping setup (no AWS resources will be "
262
+ "created).[/yellow]"
263
+ )
264
+ return
265
+
266
+ region = cfg.app.region
267
+
268
+ console.print()
269
+ console.print(
270
+ Panel(
271
+ "[bold]LabLink Setup — Remote State[/bold]\n"
272
+ "Creates S3 + DynamoDB for OpenTofu state.",
273
+ border_style="cyan",
274
+ )
275
+ )
276
+ console.print()
277
+
278
+ # Step 1: Credentials
279
+ console.print("[bold]Step 1/3:[/bold] Checking AWS credentials")
280
+ session = _get_session(region)
281
+ identity = check_credentials(session)
282
+ console.print(
283
+ f" [green]authenticated[/green] "
284
+ f"Account: {identity['account']}, "
285
+ f"Identity: {identity['arn']}"
286
+ )
287
+ console.print()
288
+
289
+ # Resolve bucket name from account ID
290
+ bucket_name = resolve_bucket_name(identity["account"])
291
+
292
+ # Step 2: S3
293
+ console.print(
294
+ "[bold]Step 2/3:[/bold] S3 bucket for OpenTofu state"
295
+ )
296
+ create_s3_bucket(session, bucket_name, region)
297
+ console.print()
298
+
299
+ # Step 3: DynamoDB
300
+ console.print(
301
+ "[bold]Step 3/3:[/bold] DynamoDB table for state locking"
302
+ )
303
+ create_dynamodb_table(session, region)
304
+
305
+ # Persist bucket_name to user config
306
+ cfg.bucket_name = bucket_name
307
+ from lablink_cli.config.schema import save_config
308
+
309
+ if config_path is None:
310
+ from lablink_cli.app import DEFAULT_CONFIG
311
+
312
+ config_path = DEFAULT_CONFIG
313
+
314
+ save_config(cfg, config_path)
315
+ console.print(
316
+ f" [green]saved[/green] bucket_name → {config_path}"
317
+ )
318
+ console.print()
319
+
320
+ # Optional: Route53
321
+ if cfg.dns.enabled and cfg.dns.terraform_managed:
322
+ console.print(
323
+ "[bold]Optional:[/bold] Route53 hosted zone"
324
+ )
325
+ zone_id = create_route53_zone(session, cfg.dns.domain)
326
+ if zone_id and not cfg.dns.zone_id:
327
+ cfg.dns.zone_id = zone_id
328
+ save_config(cfg, config_path)
329
+ console.print(
330
+ f" [green]saved[/green] dns.zone_id → {config_path}"
331
+ )
332
+ console.print()
333
+
334
+ # Summary
335
+ summary = Table(title="Setup Complete", show_header=False)
336
+ summary.add_column("Resource", style="bold")
337
+ summary.add_column("Value")
338
+ summary.add_row("Region", region)
339
+ summary.add_row("S3 Bucket", bucket_name)
340
+ summary.add_row("DynamoDB Table", "lock-table")
341
+ summary.add_row("AWS Account", identity["account"])
342
+ console.print(summary)
343
+ console.print()
344
+ console.print(
345
+ "[dim]Next step:[/dim] "
346
+ "[bold]lablink deploy[/bold]"
347
+ )
@@ -0,0 +1,133 @@
1
+ """Render a cohort session-metrics summary in the terminal.
2
+
3
+ Numbers come from the allocator's /api/session-metrics/summary endpoint —
4
+ the same view model the admin web UI consumes. This keeps `lablink stats`
5
+ and /admin/session-metrics from ever showing different aggregates for
6
+ the same deployment state.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from urllib.error import HTTPError, URLError
12
+
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from lablink_cli.api import authenticated_json_request
17
+ from lablink_cli.commands.utils import (
18
+ get_allocator_url,
19
+ print_admin_credentials_hint,
20
+ resolve_admin_credentials,
21
+ )
22
+
23
+ console = Console()
24
+
25
+
26
+ def _fetch(cfg) -> dict:
27
+ allocator_url = get_allocator_url(cfg)
28
+ if not allocator_url:
29
+ console.print("[red]Could not determine allocator URL.[/red]")
30
+ raise SystemExit(1)
31
+
32
+ admin_user, admin_pw = resolve_admin_credentials(cfg)
33
+
34
+ try:
35
+ return authenticated_json_request(
36
+ f"{allocator_url}/api/session-metrics/summary",
37
+ admin_user,
38
+ admin_pw,
39
+ ssl_provider=cfg.ssl.provider,
40
+ )
41
+ # HTTPError first: it subclasses URLError, and a rejected login is not
42
+ # an unreachable allocator — we reached it and it said no.
43
+ except HTTPError as e:
44
+ if e.code == 401:
45
+ console.print(
46
+ f"[red]The allocator rejected admin user "
47
+ f"'{admin_user}' (HTTP 401).[/red]"
48
+ )
49
+ print_admin_credentials_hint(cfg)
50
+ else:
51
+ console.print(f"[red]Could not reach allocator: {e}[/red]")
52
+ raise SystemExit(1) from e
53
+ except URLError as e:
54
+ console.print(f"[red]Could not reach allocator: {e}[/red]")
55
+ raise SystemExit(1) from e
56
+
57
+
58
+ def _fmt_hms(seconds: int | float | None) -> str:
59
+ if seconds is None:
60
+ return "—"
61
+ s = int(seconds)
62
+ return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
63
+
64
+
65
+ def run_stats(cfg) -> None:
66
+ body = _fetch(cfg)
67
+
68
+ if not body.get("enabled", False):
69
+ console.print(
70
+ "[yellow]Session metrics collection is disabled for this "
71
+ "deployment. Set monitoring.enabled: true in "
72
+ "~/.lablink/config.yaml to enable.[/yellow]"
73
+ )
74
+ return
75
+
76
+ summary = body.get("summary") or {}
77
+ label = body.get("subject_software_label") or "subject"
78
+ total = summary.get("total_vms", 0)
79
+
80
+ if total == 0:
81
+ console.print(
82
+ "[yellow]No session metrics yet. Either no VMs have "
83
+ "reported, or monitoring just started.[/yellow]"
84
+ )
85
+ return
86
+
87
+ deploy = getattr(cfg, "deployment_name", "lablink")
88
+ console.print(
89
+ f"\n[bold]LabLink session metrics — deploy \"{deploy}\" "
90
+ f"({total} VMs)[/bold]\n"
91
+ )
92
+
93
+ console.print("[bold]Funnel[/bold]")
94
+ funnel = summary.get("funnel", {})
95
+ funnel_total = total or 1
96
+ for stage_key, stage_label in (
97
+ ("started", "Started"),
98
+ ("labeled", "Labeled"),
99
+ ("trained", "Trained"),
100
+ ("tracked", "Tracked"),
101
+ ):
102
+ count = funnel.get(stage_key, 0)
103
+ pct = round(count / funnel_total * 100)
104
+ bar = "█" * (pct // 5)
105
+ console.print(
106
+ f" {stage_label:<8} {count:>3} / {total:<3} {pct:>3}% {bar}"
107
+ )
108
+
109
+ console.print("\n[bold]Summary[/bold]")
110
+ t = Table(show_header=False, box=None)
111
+ t.add_row(
112
+ "% reached training",
113
+ f"{summary.get('pct_reached_training', 0.0):.1f}%",
114
+ )
115
+ t.add_row(
116
+ f"Median time in {label}",
117
+ _fmt_hms(summary.get("median_seconds_in_subject_software")),
118
+ )
119
+ t.add_row(
120
+ "Median time-to-first-train",
121
+ _fmt_hms(summary.get("median_seconds_to_first_train")),
122
+ )
123
+ frames = summary.get("median_labeled_frames")
124
+ t.add_row(
125
+ "Median labeled frames",
126
+ str(frames) if frames is not None else "—",
127
+ )
128
+ epochs = summary.get("median_epochs_completed")
129
+ t.add_row(
130
+ "Median epochs completed",
131
+ str(epochs) if epochs is not None else "—",
132
+ )
133
+ console.print(t)