okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petcolab: refresh tokens in the daemon, share the websocket and google oauth code, drop the ssh config editsc9cb3a0

main
13.5 KiB424 linesraw
1"""Google Colab GPU runtimes: list, launch and stop them, and reach one over `remote`.
2
3The account is the one `/login colab` saved. The daemon owns its tokens: the
4kernel asks the `colab.account` host route for a fresh access token and never
5reads creds.json. A runtime is reached through the ssh-over-websocket proxy
6Colab exposes, run as ssh's ProxyCommand by `albedo_plugins.ws` (see `connect`).
7"""
8
9from __future__ import annotations
10
11import asyncio
12import json
13import shlex
14import sys
15import urllib.error
16import urllib.parse
17import urllib.request
18import uuid
19from pathlib import Path
20from typing import Any
21
22import albedo_ssh
23from albedo_api import PythonApi, Record
24from albedo_plugins.remote import remote
25
26API_BASE = "https://colab.research.google.com"
27CONNECT_TIMEOUT = 60.0
28API_TIMEOUT = 30.0
29PRIV = Path(__file__).resolve().parent.parent
30REFUSALS = {
31    401: "authentication failed: invalid or expired token",
32    403: "request forbidden",
33    404: "resource not found",
34    503: "GPU capacity exhausted",
35}
36
37
38class ColabError(RuntimeError):
39    """A Google Colab operation failed; `status` is the HTTP status, if there was one."""
40
41    def __init__(self, message: str, status: int | None = None) -> None:
42        super().__init__(message)
43        self.status = status
44
45
46class ColabInstance(Record):
47    """One Google Colab runtime instance."""
48
49    id: str
50    endpoint: str
51    name: str | None
52    variant: str
53    accelerator: str | None
54    shape: str
55    status: str
56    url: str | None
57    token: str | None
58    token_expires_in: int | None
59    raw: dict[str, Any]
60
61    @property
62    def gpu(self) -> str | None:
63        """GPU accelerator name (e.g. 'T4', 'A100') or None."""
64        return self.get("accelerator") if self.get("variant") == "GPU" else None
65
66    @property
67    def tpu(self) -> str | None:
68        """TPU accelerator name or None."""
69        return self.get("accelerator") if self.get("variant") == "TPU" else None
70
71    @property
72    def mem(self) -> str:
73        """Memory size: 'high' or 'standard'."""
74        return "high" if self.get("shape") in ("high", "highmem") else "standard"
75
76    async def connect(self, **options: Any) -> Any:
77        """`colab.connect(self, **options)`."""
78        return await connect(self, **options)
79
80    async def stop(self) -> Record:
81        """`colab.stop(self)`."""
82        return await stop(self)
83
84    def __str__(self) -> str:
85        parts = [f"ColabInstance(id={self.get('id', '')!r}"]
86        for field in ("name", "variant", "accelerator", "status"):
87            if self.get(field):
88                parts.append(f"{field}={self[field]!r}")
89        if self.mem != "standard":
90            parts.append(f"mem={self.mem!r}")
91        return ", ".join(parts) + ")"
92
93    __repr__ = __str__
94
95
96class ColabStatus(Record):
97    """Overall Google Colab status and active instances."""
98
99    authenticated: bool
100    email: str | None
101    instances: list[ColabInstance]
102    active: int
103
104    def __str__(self) -> str:
105        head = f"Colab Status: {'authenticated' if self.get('authenticated') else 'unauthenticated'}"
106        if self.get("email"):
107            head += f" ({self['email']})"
108        lines = [head, f"Active instances: {self.get('active', 0)}"]
109        lines += [f"  - {inst}" for inst in self.get("instances", [])]
110        return "\n".join(lines)
111
112    __repr__ = __str__
113
114
115host_call: Any = None
116HostErrorType: type[Exception] = Exception
117
118
119async def _account() -> dict[str, str]:
120    """The signed-in account: `access` token and `email`, refreshed by the daemon."""
121    try:
122        return await host_call("colab.account", {})
123    except HostErrorType as error:
124        raise ColabError(str(error)) from error
125
126
127def _send(
128    method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: float
129) -> tuple[int, bytes]:
130    request = urllib.request.Request(url, data=body, headers=headers, method=method)
131    try:
132        with urllib.request.urlopen(request, timeout=timeout) as response:
133            return response.status, response.read()
134    except urllib.error.HTTPError as error:
135        return error.code, error.read()
136    except urllib.error.URLError as error:
137        raise ColabError(f"network error connecting to Colab at {url}: {error.reason}")
138
139
140def _plain(body: bytes) -> str:
141    """The body as text, without the `)]}'` line Google puts before JSON."""
142    text = body.decode("utf-8", errors="replace").strip()
143    if text.startswith(")]}'"):
144        text = text.partition("\n")[2].strip()
145    return text
146
147
148async def _request(
149    method: str,
150    path: str,
151    *,
152    query: dict[str, str] | None = None,
153    headers: dict[str, str] | None = None,
154    timeout: float = API_TIMEOUT,
155) -> Any:
156    url = f"{API_BASE}{path}?{urllib.parse.urlencode(query or {})}"
157    status, body = await asyncio.to_thread(
158        _send,
159        method,
160        url,
161        {
162            "Authorization": f"Bearer {(await _account())['access']}",
163            "X-Colab-Tunnel": "Google",
164            "Accept": "application/json",
165            "X-Colab-Client-Agent": "colab-cli",
166            "User-Agent": "albedo-colab-plugin/1.0",
167            **(headers or {}),
168        },
169        None,
170        timeout,
171    )
172    text = _plain(body)
173    if status in (200, 201, 204):
174        try:
175            return json.loads(text) if text else {}
176        except json.JSONDecodeError as error:
177            raise ColabError(
178                f"failed to parse Colab JSON response: {text[:200]}", status
179            ) from error
180    reason = REFUSALS.get(status, f"{method} {path} failed with HTTP {status}")
181    raise ColabError(f"Colab {reason} ({status}): {text[:200]}", status)
182
183
184def _parse_instance(item: dict[str, Any]) -> ColabInstance:
185    endpoint = str(item.get("endpoint") or item.get("id") or "")
186    proxy = item.get("runtimeProxyInfo")
187    if not isinstance(proxy, dict):
188        proxy = {}
189    return ColabInstance(
190        id=endpoint,
191        endpoint=endpoint,
192        name=item.get("name"),
193        variant=item.get("variant", "GPU"),
194        accelerator=item.get("accelerator"),
195        shape=item.get("shape", "standard"),
196        status=item.get("status", "active"),
197        url=proxy.get("url"),
198        token=proxy.get("token"),
199        token_expires_in=proxy.get("tokenExpiresInSeconds"),
200        raw=item,
201    )
202
203
204def _find(candidates: list[ColabInstance], ref: str | ColabInstance) -> ColabInstance:
205    target = ref.id if isinstance(ref, ColabInstance) else str(ref)
206    for inst in candidates:
207        if target in (inst.id, inst.name):
208            return inst
209    raise ColabError(f"no Colab instance found matching id {target!r}")
210
211
212async def instances() -> list[ColabInstance]:
213    """Active Google Colab runtimes."""
214    data = await _request("GET", "/tun/m/assignments", query={"authuser": "0"})
215    items = data.get("assignments") if isinstance(data, dict) else data
216    if not isinstance(items, list):
217        return []
218    return [_parse_instance(item) for item in items if isinstance(item, dict)]
219
220
221async def status(id: str | None = None) -> ColabStatus | ColabInstance:
222    """Active runtimes and the signed-in account, or one runtime by id or name."""
223    active = await instances()
224    if id is not None:
225        return _find(active, id)
226    account = await _account()
227    return ColabStatus(
228        authenticated=True,
229        email=account.get("email") or None,
230        instances=active,
231        active=len(active),
232    )
233
234
235def _notebook_hash() -> str:
236    """A 44-character web-safe Colab notebook hash (nbh)."""
237    text = str(uuid.uuid4()).replace("-", "_")
238    return text + "." * (44 - len(text))
239
240
241async def launch(
242    gpu: str | None = "T4",
243    *,
244    mem: str = "standard",
245    shape: str | None = None,
246    variant: str | None = None,
247    tpu: str | None = None,
248    name: str | None = None,
249    timeout: float = CONNECT_TIMEOUT,
250) -> ColabInstance:
251    """Launch a Colab runtime, or return the one already assigned."""
252    chosen = shape if shape is not None else mem
253    high = chosen in ("high", "highmem")
254    if tpu:
255        kind, accelerator = variant or "TPU", tpu
256    elif gpu:
257        kind, accelerator = variant or "GPU", gpu
258    else:
259        kind, accelerator = variant or "CPU", None
260
261    query = {"authuser": "0", "nbh": _notebook_hash(), "variant": kind}
262    if accelerator is not None:
263        query["accelerator"] = accelerator
264    if high and accelerator not in ("L4", "v5e1", "v6e1", "V5E1", "V6E1"):
265        query["shape"] = "hm"
266
267    # The first GET returns an existing assignment or the XSRF token that lets
268    # the POST provision a VM.
269    answer = await _request("GET", "/tun/m/assign", query=query, timeout=timeout)
270    if (
271        isinstance(answer, dict)
272        and "token" in answer
273        and not ("endpoint" in answer or "runtimeProxyInfo" in answer)
274    ):
275        answer = await _request(
276            "POST",
277            "/tun/m/assign",
278            query=query,
279            headers={"X-Goog-Colab-Token": str(answer["token"])},
280            timeout=timeout,
281        )
282    if not isinstance(answer, dict):
283        raise ColabError(f"unexpected response from Colab assign: {answer}")
284
285    assignment = answer.get("assignment", answer)
286    assignment.setdefault("variant", kind)
287    if accelerator:
288        assignment.setdefault("accelerator", accelerator)
289    assignment.setdefault("shape", chosen)
290    if name:
291        assignment.setdefault("name", name)
292    return _parse_instance(assignment)
293
294
295async def stop(id: str | ColabInstance) -> Record:
296    """Stop an active Colab runtime, and forget the ssh name `connect` gave it."""
297    endpoint = id.id if isinstance(id, ColabInstance) else str(id)
298    path = f"/tun/m/unassign/{urllib.parse.quote(endpoint)}"
299    albedo_ssh.unalias(_host(endpoint))
300    try:
301        # The GET hands out the XSRF token the POST commits with.
302        answer = await _request("GET", path, query={"authuser": "0"})
303        xsrf = answer.get("token") if isinstance(answer, dict) else None
304        await _request(
305            "POST",
306            path,
307            query={"authuser": "0"},
308            headers={"X-Goog-Colab-Token": str(xsrf)} if xsrf else None,
309        )
310        return Record(id=endpoint, success=True, message="instance stopped")
311    except ColabError as error:
312        if error.status != 404:
313            raise
314        return Record(
315            id=endpoint, success=False, message="instance not found or already stopped"
316        )
317
318
319def _host(endpoint: str) -> str:
320    return f"colab-{endpoint}"
321
322
323def _ssh_key(path: str | None) -> tuple[str | None, str]:
324    """The private key ssh should offer (None: its own defaults) and the public
325    key text Colab authorizes for the session."""
326    if path:
327        private = Path(path).expanduser()
328        if private.suffix == ".pub":
329            private = private.with_suffix("")
330        candidates = [private]
331    else:
332        candidates = [
333            Path.home() / ".ssh" / name for name in ("id_ed25519", "id_ecdsa")
334        ]
335    for key in candidates:
336        public = key.with_name(key.name + ".pub")
337        if public.is_file():
338            return (str(key) if path else None), public.read_text(
339                encoding="utf-8"
340            ).strip()
341    raise ColabError(
342        "no ssh public key found; create one with `ssh-keygen -t ed25519` or pass ssh_key="
343    )
344
345
346async def connect(
347    instance: str | ColabInstance,
348    *,
349    remote_cwd: str | None = None,
350    python: str | None = None,
351    modules: list[str] | None = None,
352    timeout: float = CONNECT_TIMEOUT,
353    ssh_key: str | None = None,
354) -> Any:
355    """Connect `remote` to a Colab runtime and return the RemoteConnection.
356
357    ssh runs `python -m albedo_plugins.ws` as its ProxyCommand, bridging stdio to the
358    runtime's websocket proxy. The proxy url and token come from a fresh listing
359    (they expire) and reach the bridge through its environment, never a command
360    line. Nothing is written to ~/.ssh/config.
361    """
362    live = _find(await instances(), instance)
363    if not (live.url and live.token):
364        raise ColabError(f"Colab instance {live.id} has no runtime proxy yet")
365    private, public = _ssh_key(ssh_key)
366    url = urllib.parse.urlsplit(live.url)
367    socket_url = urllib.parse.urlunsplit(
368        (
369            "wss" if url.scheme == "https" else "ws",
370            url.netloc,
371            "/colab/ssh",
372            urllib.parse.urlencode({"colab-runtime-proxy-token": live.token}),
373            "",
374        )
375    )
376    proxy = f"{shlex.quote(sys.executable)} -m albedo_plugins.ws".replace("%", "%%")
377    host = _host(live.id)
378    albedo_ssh.alias(
379        host,
380        [
381            f"ProxyCommand={proxy}",
382            "User=root",
383            "StrictHostKeyChecking=no",
384            "UserKnownHostsFile=/dev/null",
385            *([f"IdentityFile={private}", "IdentitiesOnly=yes"] if private else []),
386        ],
387        {
388            "PYTHONPATH": str(PRIV),
389            "ALBEDO_WS_URL": socket_url,
390            "ALBEDO_WS_HEADERS": json.dumps({"X-Colab-Ssh-Pubkey": public}),
391        },
392    )
393    return await remote.connect(
394        host,
395        remote_cwd=remote_cwd or "/content",
396        python=python or "/usr/bin/python3",
397        modules=modules,
398        timeout=timeout,
399    )
400
401
402class Colab:
403    """Google Colab management interface."""
404
405    instances = staticmethod(instances)
406    status = staticmethod(status)
407    launch = staticmethod(launch)
408    stop = staticmethod(stop)
409    connect = staticmethod(connect)
410
411
412colab = Colab()
413
414
415def setup(api: PythonApi) -> dict[str, object]:
416    """Expose the Colab plugin in the Python kernel environment."""
417    global host_call, HostErrorType
418    host_call, HostErrorType = api.host, api.HostError
419    return {
420        "colab": colab,
421        "ColabError": ColabError,
422        "ColabInstance": ColabInstance,
423        "ColabStatus": ColabStatus,
424    }