File size: 5,407 Bytes
9f72def
 
 
 
 
 
 
 
 
88e4a42
9f72def
 
 
88e4a42
9f72def
 
 
 
 
 
 
 
 
 
 
 
 
 
e3fe0e8
 
 
 
9f72def
 
 
 
5de1095
 
94e680d
5de1095
 
 
 
94e680d
 
 
 
5de1095
9f72def
 
 
 
 
 
 
 
 
 
 
 
d34a83d
e3fe0e8
9f72def
 
 
 
 
 
 
 
88e4a42
9f72def
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94e680d
9f72def
94e680d
9f72def
 
94e680d
9f72def
 
 
 
88e4a42
 
 
 
 
 
 
 
 
 
 
9f72def
88e4a42
 
9f72def
 
 
 
88e4a42
9f72def
 
 
 
 
 
 
 
 
 
d34a83d
 
 
 
 
 
 
 
 
 
9f72def
88e4a42
 
9f72def
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import logging
import os
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel

from . import auth, config, history, prober, state

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("gpu_capacity")

INDEX_HTML = os.path.join(os.path.dirname(__file__), "static", "index.html")


def _sweep(instance_type: str, regions: list[str]) -> dict:
    with ThreadPoolExecutor(max_workers=config.MAX_PROBE_WORKERS) as pool:
        results = dict(
            zip(regions, pool.map(lambda r: prober.probe_region(r, instance_type), regions))
        )
    for region, result in results.items():
        state.update(instance_type, region, result)
    try:
        history.record(instance_type, results)
    except Exception:
        log.exception("history record failed")
    return results


def _auto_refresh_loop():
    # If restored state is still fresh (e.g. restart right after a sweep),
    # don't re-probe everything — wait out the remainder of the period.
    # Types added to the config since the state was written are probed anyway.
    newest = state.newest_checked_at()
    if newest is not None:
        wait = config.AUTO_REFRESH_MINUTES * 60 - (time.time() - newest)
        if wait > 0:
            for instance_type in config.INSTANCE_TYPES:
                if instance_type not in state.get():
                    _sweep(instance_type, config.REGIONS)
            log.info("restored state is fresh, first full sweep in %.0fs", wait)
            time.sleep(wait)
    while True:
        log.info("auto-refresh: probing all configured types")
        started = time.time()
        for instance_type in config.INSTANCE_TYPES:
            _sweep(instance_type, config.REGIONS)
        log.info("auto-refresh: sweep done in %.0fs", time.time() - started)
        time.sleep(max(60, config.AUTO_REFRESH_MINUTES * 60 - (time.time() - started)))


@asynccontextmanager
async def lifespan(app: FastAPI):
    state.load()
    history.load()
    history.start_writer()
    threading.Thread(target=prober.cleanup_leaked, daemon=True).start()
    if config.AUTO_REFRESH_MINUTES > 0:
        log.info("auto-refresh enabled: every %d min", config.AUTO_REFRESH_MINUTES)
        threading.Thread(target=_auto_refresh_loop, daemon=True).start()
    yield


app = FastAPI(title="gpu-capacity", lifespan=lifespan)
app.include_router(auth.router)


class ProbeRequest(BaseModel):
    instance_type: str
    region: str | None = None


TYPE_RE = re.compile(r"[a-z0-9\-]+\.[a-z0-9\-]+")


def _validate(instance_type: str, region: str | None):
    if not TYPE_RE.fullmatch(instance_type):
        raise HTTPException(400, f"invalid instance type {instance_type!r}")
    if region is not None and region not in config.REGIONS:
        raise HTTPException(400, f"unknown region {region!r}")


@app.get("/")
def index():
    return FileResponse(INDEX_HTML)


@app.get("/api/state")
def get_state():
    current = state.get()
    extras = sorted(t for t in current if t not in config.INSTANCE_TYPES)
    types = config.INSTANCE_TYPES + extras
    return {
        "instance_types": types,
        "regions": config.REGIONS,
        "stale_after_seconds": config.STALE_AFTER_SECONDS,
        "gpu_info": prober.gpu_info(types),
        "state": current,
    }


@app.get("/api/me")
def me(request: Request):
    user = auth.current_user(request)
    return {
        "authenticated": user is not None,
        "username": user.get("u") if user else None,
        "can_probe": (not auth.ENABLED) or bool(user and user.get("hf")),
        "auth_enabled": auth.ENABLED,
    }


@app.get("/api/availability/{instance_type}")
def availability(request: Request, instance_type: str,
                 refresh: bool = False, region: str | None = None):
    """Cached availability for one instance type; refresh=true probes first."""
    _validate(instance_type, region)
    regions = [region] if region else config.REGIONS
    if refresh:
        auth.require_probe_rights(request)
        _sweep(instance_type, regions)
    data = state.get().get(instance_type, {})
    data = {r: data[r] for r in regions if r in data}
    return {
        "instance_type": instance_type,
        "available_regions": sorted(r for r, v in data.items() if v["status"] == "available"),
        "regions": data,
    }


@app.get("/api/history/{instance_type}")
def history_series(instance_type: str, region: str, hours: float = 48):
    _validate(instance_type, region)
    return {
        "instance_type": instance_type,
        "region": region,
        "series": history.series(instance_type, region, hours),
    }


@app.post("/api/probe")
def probe(req: ProbeRequest, request: Request):
    auth.require_probe_rights(request)
    _validate(req.instance_type, req.region)
    regions = [req.region] if req.region else config.REGIONS
    log.info("probing %s in %s", req.instance_type, ",".join(regions))
    return {"instance_type": req.instance_type, "results": _sweep(req.instance_type, regions)}


def run():
    uvicorn.run(app, host=os.environ.get("HOST", "127.0.0.1"), port=int(os.environ.get("PORT", "8300")))


if __name__ == "__main__":
    run()