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,188 @@
|
|
|
1
|
+
"""`lablink client unregister` — tear down a registered BYO box."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ssl
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from lablink_cli.api import USER_AGENT
|
|
15
|
+
from lablink_cli.docker import Docker, DockerUnavailable, default_docker
|
|
16
|
+
|
|
17
|
+
DEFAULT_ENV_FILE = Path.home() / ".lablink" / "client.env"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_unregister(
|
|
21
|
+
*,
|
|
22
|
+
env_file: Optional[Path],
|
|
23
|
+
insecure: bool,
|
|
24
|
+
yes: bool,
|
|
25
|
+
docker: Docker | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Best-effort allocator notify, then local cleanup. Always exits 0
|
|
28
|
+
once the user has confirmed; partial failures don't block."""
|
|
29
|
+
docker = docker or default_docker()
|
|
30
|
+
console = Console()
|
|
31
|
+
env_file = env_file or DEFAULT_ENV_FILE
|
|
32
|
+
|
|
33
|
+
if not env_file.exists():
|
|
34
|
+
console.print("Nothing to unregister.")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
env = _parse_env_file(env_file)
|
|
38
|
+
required = ("CLIENT_ID", "CLIENT_SECRET", "ALLOCATOR_URL")
|
|
39
|
+
missing = [k for k in required if not env.get(k)]
|
|
40
|
+
if missing:
|
|
41
|
+
console.print(
|
|
42
|
+
f"[red]{env_file} is missing required keys: "
|
|
43
|
+
f"{', '.join(missing)}.[/red]\n"
|
|
44
|
+
f"Delete {env_file} manually and re-run "
|
|
45
|
+
"`lablink client register` to recover."
|
|
46
|
+
)
|
|
47
|
+
raise SystemExit(1)
|
|
48
|
+
|
|
49
|
+
if not yes:
|
|
50
|
+
confirmed = typer.confirm(
|
|
51
|
+
f"Remove lablink-client container and {env_file}?",
|
|
52
|
+
default=False,
|
|
53
|
+
)
|
|
54
|
+
if not confirmed:
|
|
55
|
+
console.print("Aborted.")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Best-effort allocator notify
|
|
59
|
+
notified = _notify_deregister(
|
|
60
|
+
allocator_url=env["ALLOCATOR_URL"],
|
|
61
|
+
client_id=env["CLIENT_ID"],
|
|
62
|
+
client_secret=env["CLIENT_SECRET"],
|
|
63
|
+
insecure=insecure,
|
|
64
|
+
console=console,
|
|
65
|
+
)
|
|
66
|
+
if not notified:
|
|
67
|
+
console.print(
|
|
68
|
+
"[yellow]Allocator notify failed (allocator may already "
|
|
69
|
+
"be torn down). Continuing local cleanup.[/yellow]"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Docker container removal
|
|
73
|
+
try:
|
|
74
|
+
docker.require()
|
|
75
|
+
except DockerUnavailable:
|
|
76
|
+
console.print(
|
|
77
|
+
"[yellow]docker not on PATH — skipping container "
|
|
78
|
+
"removal. Remove `lablink-client` manually if it ever "
|
|
79
|
+
"comes back.[/yellow]"
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
_exec_docker_rm(console, docker)
|
|
83
|
+
|
|
84
|
+
# Env file deletion (terminal step)
|
|
85
|
+
try:
|
|
86
|
+
env_file.unlink()
|
|
87
|
+
except OSError as e:
|
|
88
|
+
console.print(
|
|
89
|
+
f"[red]Failed to delete {env_file}: {e}.[/red] "
|
|
90
|
+
"Remove it manually."
|
|
91
|
+
)
|
|
92
|
+
raise SystemExit(1) from e
|
|
93
|
+
|
|
94
|
+
console.print(
|
|
95
|
+
f"[green]Unregistered.[/green] Removed {env_file} and the "
|
|
96
|
+
"`lablink-client` container (if it was running)."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
101
|
+
"""Parse a simple KEY=VALUE env file. Lines starting with '#' or
|
|
102
|
+
empty are ignored. No quoting/escaping — mirrors what
|
|
103
|
+
`register.py` writes."""
|
|
104
|
+
result: dict[str, str] = {}
|
|
105
|
+
for line in path.read_text().splitlines():
|
|
106
|
+
s = line.strip()
|
|
107
|
+
if not s or s.startswith("#"):
|
|
108
|
+
continue
|
|
109
|
+
if "=" not in s:
|
|
110
|
+
continue
|
|
111
|
+
k, v = s.split("=", 1)
|
|
112
|
+
result[k.strip()] = v.strip()
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _notify_deregister(
|
|
117
|
+
*,
|
|
118
|
+
allocator_url: str,
|
|
119
|
+
client_id: str,
|
|
120
|
+
client_secret: str,
|
|
121
|
+
insecure: bool,
|
|
122
|
+
console: Console,
|
|
123
|
+
) -> bool:
|
|
124
|
+
"""Best-effort DELETE /api/v1/clients/<client_id>.
|
|
125
|
+
|
|
126
|
+
Returns True if the allocator returned 200, False on any other
|
|
127
|
+
outcome (connection refused, timeout, 4xx, 5xx). Never raises —
|
|
128
|
+
the caller continues regardless.
|
|
129
|
+
"""
|
|
130
|
+
url = f"{allocator_url.rstrip('/')}/api/v1/clients/{client_id}"
|
|
131
|
+
req = Request(url, method="DELETE")
|
|
132
|
+
req.add_header("User-Agent", USER_AGENT)
|
|
133
|
+
req.add_header("Authorization", f"Bearer {client_secret}")
|
|
134
|
+
req.add_header("Accept", "application/json")
|
|
135
|
+
|
|
136
|
+
ctx = ssl.create_default_context()
|
|
137
|
+
if insecure:
|
|
138
|
+
ctx.check_hostname = False
|
|
139
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
# S310: allocator URL is operator-supplied by design.
|
|
143
|
+
with urlopen(req, timeout=5, context=ctx) as resp: # noqa: S310
|
|
144
|
+
return 200 <= resp.status < 300
|
|
145
|
+
except HTTPError as e:
|
|
146
|
+
if e.code == 404:
|
|
147
|
+
# Row already gone — idempotent success.
|
|
148
|
+
return True
|
|
149
|
+
console.print(
|
|
150
|
+
f"[yellow]Allocator returned {e.code}.[/yellow]"
|
|
151
|
+
)
|
|
152
|
+
return False
|
|
153
|
+
except URLError as e:
|
|
154
|
+
console.print(
|
|
155
|
+
f"[yellow]Allocator unreachable: {e.reason}.[/yellow]"
|
|
156
|
+
)
|
|
157
|
+
return False
|
|
158
|
+
except (TimeoutError, OSError) as e:
|
|
159
|
+
console.print(
|
|
160
|
+
f"[yellow]Allocator notify failed: {e}.[/yellow]"
|
|
161
|
+
)
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _exec_docker_rm(console: Console, docker: Docker) -> bool:
|
|
166
|
+
"""Run `docker rm -f lablink-client`. Returns True on success or
|
|
167
|
+
when the container is already absent. Never raises.
|
|
168
|
+
|
|
169
|
+
`docker rm -f` exits 0 in both cases — the only non-zero exits
|
|
170
|
+
are for daemon-level failures (docker daemon down, permissions,
|
|
171
|
+
etc.), which we surface as a yellow warning and continue.
|
|
172
|
+
|
|
173
|
+
Deliberately leaves the TAILSCALE_STATE_VOLUME in place. Deleting it
|
|
174
|
+
would NOT remove the node from the tailnet (the coordination server
|
|
175
|
+
keeps its own record, and the offline machine goes on holding its
|
|
176
|
+
MagicDNS name), so the next `register` would mint a fresh node and get a
|
|
177
|
+
suffixed name — the exact lablink#404 failure. Preserving it means
|
|
178
|
+
unregister/register reuses the same node and keeps the unsuffixed name.
|
|
179
|
+
`lablink client reset-overlay` is the opt-in path for discarding it.
|
|
180
|
+
"""
|
|
181
|
+
result = docker.remove_container("lablink-client", force=True)
|
|
182
|
+
if result.ok:
|
|
183
|
+
return True
|
|
184
|
+
console.print(
|
|
185
|
+
f"[yellow]docker rm exited {result.returncode}: "
|
|
186
|
+
f"{result.stderr.strip() or '(no stderr)'}.[/yellow]"
|
|
187
|
+
)
|
|
188
|
+
return False
|