okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
c9cb3a0
main
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 = { 31401 :"authentication failed: invalid or expired token" , 32403 :"request forbidden" , 33404 :"resource not found" , 34503 :"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 41def __init__ (self ,message :str ,status :int | None = None )-> None : 42super ().__init__ (message ) 43self .status = status 44 45 46class ColabInstance (Record ): 47"""One Google Colab runtime instance.""" 48 49id :str 50endpoint :str 51name :str | None 52variant :str 53accelerator :str | None 54shape :str 55status :str 56url :str | None 57token :str | None 58token_expires_in :int | None 59raw :dict [str ,Any ] 60 61@property 62def gpu (self )-> str | None : 63"""GPU accelerator name (e.g. 'T4', 'A100') or None.""" 64return self .get ("accelerator" )if self .get ("variant" )== "GPU" else None 65 66@property 67def tpu (self )-> str | None : 68"""TPU accelerator name or None.""" 69return self .get ("accelerator" )if self .get ("variant" )== "TPU" else None 70 71@property 72def mem (self )-> str : 73"""Memory size: 'high' or 'standard'.""" 74return "high" if self .get ("shape" )in ("high" ,"highmem" )else "standard" 75 76async def connect (self ,** options :Any )-> Any : 77"""`colab.connect(self, **options)`.""" 78return await connect (self ,** options ) 79 80async def stop (self )-> Record : 81"""`colab.stop(self)`.""" 82return await stop (self ) 83 84def __str__ (self )-> str : 85parts = [f"ColabInstance(id= { self . get ( 'id' , '' )!r } " ] 86for field in ("name" ,"variant" ,"accelerator" ,"status" ): 87if self .get (field ): 88parts .append (f" { field } = { self [ field ]!r } " ) 89if self .mem != "standard" : 90parts .append (f"mem= { self . mem !r } " ) 91return ", " .join (parts )+ ")" 92 93__repr__ = __str__ 94 95 96class ColabStatus (Record ): 97"""Overall Google Colab status and active instances.""" 98 99authenticated :bool 100str | None 101instances :list [ColabInstance ] 102active :int 103 104def __str__ (self )-> str : 105head = f"Colab Status: { 'authenticated' if self . get ( 'authenticated' ) else 'unauthenticated' } " 106if self .get ("email" ): 107head += f" ( { self [ 'email' ] } )" 108lines = [head ,f"Active instances: { self . get ( 'active' , 0 ) } " ] 109lines += [f" - { inst } " for inst in self .get ("instances" , [])] 110return "\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.""" 121try : 122return await host_call ("colab.account" , {}) 123except HostErrorType as error : 124raise ColabError (str (error ))from error 125 126 127def _send ( 128method :str ,url :str ,headers :dict [str ,str ],body :bytes | None ,timeout :float 129)-> tuple [int ,bytes ]: 130request = urllib .request .Request (url ,data = body ,headers = headers ,method = method ) 131try : 132with urllib .request .urlopen (request ,timeout = timeout )as response : 133return response .status ,response .read () 134except urllib .error .HTTPError as error : 135return error .code ,error .read () 136except urllib .error .URLError as error : 137raise 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.""" 142text = body .decode ("utf-8" ,errors = "replace" ).strip () 143if text .startswith (")]}'" ): 144text = text .partition ("\n" )[2 ].strip () 145return text 146 147 148async def _request ( 149method :str , 150path :str , 151* , 152query :dict [str ,str ]| None = None , 153headers :dict [str ,str ]| None = None , 154timeout :float = API_TIMEOUT , 155)-> Any : 156url = f" { API_BASE } { path } ? { urllib . parse . urlencode ( query or {}) } " 157status ,body = await asyncio .to_thread ( 158_send , 159method , 160url , 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 }, 169None , 170timeout , 171 ) 172text = _plain (body ) 173if status in (200 ,201 ,204 ): 174try : 175return json .loads (text )if text else {} 176except json .JSONDecodeError as error : 177raise ColabError ( 178f"failed to parse Colab JSON response: { text [: 200 ] } " ,status 179 )from error 180reason = REFUSALS .get (status ,f" { method } { path } failed with HTTP { status } " ) 181raise ColabError (f"Colab { reason } ( { status } ): { text [: 200 ] } " ,status ) 182 183 184def _parse_instance (item :dict [str ,Any ])-> ColabInstance : 185endpoint = str (item .get ("endpoint" )or item .get ("id" )or "" ) 186proxy = item .get ("runtimeProxyInfo" ) 187if not isinstance (proxy ,dict ): 188proxy = {} 189return ColabInstance ( 190id = endpoint , 191endpoint = endpoint , 192name = item .get ("name" ), 193variant = item .get ("variant" ,"GPU" ), 194accelerator = item .get ("accelerator" ), 195shape = item .get ("shape" ,"standard" ), 196status = item .get ("status" ,"active" ), 197url = proxy .get ("url" ), 198token = proxy .get ("token" ), 199token_expires_in = proxy .get ("tokenExpiresInSeconds" ), 200raw = item , 201 ) 202 203 204def _find (candidates :list [ColabInstance ],ref :str | ColabInstance )-> ColabInstance : 205target = ref .id if isinstance (ref ,ColabInstance )else str (ref ) 206for inst in candidates : 207if target in (inst .id ,inst .name ): 208return inst 209raise ColabError (f"no Colab instance found matching id { target !r } " ) 210 211 212async def instances ()-> list [ColabInstance ]: 213"""Active Google Colab runtimes.""" 214data = await _request ("GET" ,"/tun/m/assignments" ,query = {"authuser" :"0" }) 215items = data .get ("assignments" )if isinstance (data ,dict )else data 216if not isinstance (items ,list ): 217return [] 218return [_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.""" 223active = await instances () 224if id is not None : 225return _find (active ,id ) 226account = await _account () 227return ColabStatus ( 228authenticated = True , 229= account .get ("email" )or None , 230instances = active , 231active = len (active ), 232 ) 233 234 235def _notebook_hash ()-> str : 236"""A 44-character web-safe Colab notebook hash (nbh).""" 237text = str (uuid .uuid4 ()).replace ("-" ,"_" ) 238return text + "." * (44 - len (text )) 239 240 241async def launch ( 242gpu :str | None = "T4" , 243* , 244mem :str = "standard" , 245shape :str | None = None , 246variant :str | None = None , 247tpu :str | None = None , 248name :str | None = None , 249timeout :float = CONNECT_TIMEOUT , 250)-> ColabInstance : 251"""Launch a Colab runtime, or return the one already assigned.""" 252chosen = shape if shape is not None else mem 253high = chosen in ("high" ,"highmem" ) 254if tpu : 255kind ,accelerator = variant or "TPU" ,tpu 256elif gpu : 257kind ,accelerator = variant or "GPU" ,gpu 258else : 259kind ,accelerator = variant or "CPU" ,None 260 261query = {"authuser" :"0" ,"nbh" :_notebook_hash (),"variant" :kind } 262if accelerator is not None : 263query ["accelerator" ]= accelerator 264if high and accelerator not in ("L4" ,"v5e1" ,"v6e1" ,"V5E1" ,"V6E1" ): 265query ["shape" ]= "hm" 266 267# The first GET returns an existing assignment or the XSRF token that lets 268# the POST provision a VM. 269answer = await _request ("GET" ,"/tun/m/assign" ,query = query ,timeout = timeout ) 270if ( 271isinstance (answer ,dict ) 272and "token" in answer 273and not ("endpoint" in answer or "runtimeProxyInfo" in answer ) 274 ): 275answer = await _request ( 276"POST" , 277"/tun/m/assign" , 278query = query , 279headers = {"X-Goog-Colab-Token" :str (answer ["token" ])}, 280timeout = timeout , 281 ) 282if not isinstance (answer ,dict ): 283raise ColabError (f"unexpected response from Colab assign: { answer } " ) 284 285assignment = answer .get ("assignment" ,answer ) 286assignment .setdefault ("variant" ,kind ) 287if accelerator : 288assignment .setdefault ("accelerator" ,accelerator ) 289assignment .setdefault ("shape" ,chosen ) 290if name : 291assignment .setdefault ("name" ,name ) 292return _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.""" 297endpoint = id .id if isinstance (id ,ColabInstance )else str (id ) 298path = f"/tun/m/unassign/ { urllib . parse . quote ( endpoint ) } " 299albedo_ssh .unalias (_host (endpoint )) 300try : 301# The GET hands out the XSRF token the POST commits with. 302answer = await _request ("GET" ,path ,query = {"authuser" :"0" }) 303xsrf = answer .get ("token" )if isinstance (answer ,dict )else None 304await _request ( 305"POST" , 306path , 307query = {"authuser" :"0" }, 308headers = {"X-Goog-Colab-Token" :str (xsrf )}if xsrf else None , 309 ) 310return Record (id = endpoint ,success = True ,message = "instance stopped" ) 311except ColabError as error : 312if error .status != 404 : 313raise 314return Record ( 315id = endpoint ,success = False ,message = "instance not found or already stopped" 316 ) 317 318 319def _host (endpoint :str )-> str : 320return 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 325key text Colab authorizes for the session.""" 326if path : 327private = Path (path ).expanduser () 328if private .suffix == ".pub" : 329private = private .with_suffix ("" ) 330candidates = [private ] 331else : 332candidates = [ 333Path .home ()/ ".ssh" / name for name in ("id_ed25519" ,"id_ecdsa" ) 334 ] 335for key in candidates : 336public = key .with_name (key .name + ".pub" ) 337if public .is_file (): 338return (str (key )if path else None ),public .read_text ( 339encoding = "utf-8" 340 ).strip () 341raise ColabError ( 342"no ssh public key found; create one with `ssh-keygen -t ed25519` or pass ssh_key=" 343 ) 344 345 346async def connect ( 347instance :str | ColabInstance , 348* , 349remote_cwd :str | None = None , 350python :str | None = None , 351modules :list [str ]| None = None , 352timeout :float = CONNECT_TIMEOUT , 353ssh_key :str | None = None , 354)-> Any : 355"""Connect `remote` to a Colab runtime and return the RemoteConnection. 356 357ssh runs `python -m albedo_plugins.ws` as its ProxyCommand, bridging stdio to the 358runtime'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 360line. Nothing is written to ~/.ssh/config. 361""" 362live = _find (await instances (),instance ) 363if not (live .url and live .token ): 364raise ColabError (f"Colab instance { live . id } has no runtime proxy yet" ) 365private ,public = _ssh_key (ssh_key ) 366url = urllib .parse .urlsplit (live .url ) 367socket_url = urllib .parse .urlunsplit ( 368 ( 369"wss" if url .scheme == "https" else "ws" , 370url .netloc , 371"/colab/ssh" , 372urllib .parse .urlencode ({"colab-runtime-proxy-token" :live .token }), 373"" , 374 ) 375 ) 376proxy = f" { shlex . quote ( sys . executable ) } -m albedo_plugins.ws" .replace ("%" ,"%%" ) 377host = _host (live .id ) 378albedo_ssh .alias ( 379host , 380 [ 381f"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 ) 393return await remote .connect ( 394host , 395remote_cwd = remote_cwd or "/content" , 396python = python or "/usr/bin/python3" , 397modules = modules , 398timeout = timeout , 399 ) 400 401 402class Colab : 403"""Google Colab management interface.""" 404 405instances = staticmethod (instances ) 406status = staticmethod (status ) 407launch = staticmethod (launch ) 408stop = staticmethod (stop ) 409connect = 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.""" 417global host_call ,HostErrorType 418host_call ,HostErrorType = api .host ,api .HostError 419return { 420"colab" :colab , 421"ColabError" :ColabError , 422"ColabInstance" :ColabInstance , 423"ColabStatus" :ColabStatus , 424 }