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