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,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)
|