strata-pool 0.7.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.
- strata_pool/__init__.py +31 -0
- strata_pool/api.py +217 -0
- strata_pool/backend.py +56 -0
- strata_pool/backends/__init__.py +6 -0
- strata_pool/backends/docker.py +188 -0
- strata_pool/backends/runpod.py +264 -0
- strata_pool/pool.py +625 -0
- strata_pool/store.py +431 -0
- strata_pool/types.py +215 -0
- strata_pool-0.7.0.dist-info/METADATA +279 -0
- strata_pool-0.7.0.dist-info/RECORD +12 -0
- strata_pool-0.7.0.dist-info/WHEEL +4 -0
strata_pool/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Worker pool for dispatching Strata jobs to ephemeral machines."""
|
|
2
|
+
|
|
3
|
+
from strata_pool.backend import Backend, ProvisionedWorker
|
|
4
|
+
from strata_pool.backends import DockerBackend, RunPodBackend
|
|
5
|
+
from strata_pool.pool import Pool
|
|
6
|
+
from strata_pool.store import PoolStore
|
|
7
|
+
from strata_pool.types import (
|
|
8
|
+
Job,
|
|
9
|
+
JobState,
|
|
10
|
+
MachineType,
|
|
11
|
+
UsageEvent,
|
|
12
|
+
Worker,
|
|
13
|
+
WorkerState,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Backend",
|
|
18
|
+
"DockerBackend",
|
|
19
|
+
"Job",
|
|
20
|
+
"JobState",
|
|
21
|
+
"MachineType",
|
|
22
|
+
"Pool",
|
|
23
|
+
"PoolStore",
|
|
24
|
+
"ProvisionedWorker",
|
|
25
|
+
"RunPodBackend",
|
|
26
|
+
"UsageEvent",
|
|
27
|
+
"Worker",
|
|
28
|
+
"WorkerState",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
strata_pool/api.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""HTTP surface, for running the pool as a service.
|
|
2
|
+
|
|
3
|
+
Needs the `server` extra. The pool is usable as a library without it, and the
|
|
4
|
+
proxy that composes it may well bring its own framework, so a web server is
|
|
5
|
+
not something `import strata_pool` should pull in.
|
|
6
|
+
|
|
7
|
+
Job payloads and results are opaque bytes, so they travel as raw request and
|
|
8
|
+
response bodies rather than being wedged into JSON. Everything else — status,
|
|
9
|
+
fleet, usage — is JSON.
|
|
10
|
+
|
|
11
|
+
The caller is trusted for tenant identity. It presents the pool's API token
|
|
12
|
+
and asserts a tenant in a header; the pool does not authenticate end users and
|
|
13
|
+
has no idea who they are. That is the same trusted-proxy model the Strata
|
|
14
|
+
server uses, and it means the pool must never be reachable from anywhere but
|
|
15
|
+
the proxy.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import asdict
|
|
20
|
+
from typing import Annotated
|
|
21
|
+
|
|
22
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
|
|
23
|
+
from fastapi.responses import JSONResponse
|
|
24
|
+
|
|
25
|
+
from strata_pool.pool import Pool
|
|
26
|
+
from strata_pool.types import Job, JobState, UsageEvent, Worker
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
TENANT_HEADER = "X-Strata-Tenant"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _job_json(job: Job) -> dict:
|
|
34
|
+
"""A job without its payload or result, which are bytes and often large."""
|
|
35
|
+
fields = asdict(job)
|
|
36
|
+
fields.pop("payload")
|
|
37
|
+
fields.pop("result")
|
|
38
|
+
fields["has_result"] = job.result is not None
|
|
39
|
+
return fields
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _worker_json(worker: Worker) -> dict:
|
|
43
|
+
"""A machine without its credential.
|
|
44
|
+
|
|
45
|
+
Built by hand rather than from asdict: `repr=False` keeps the token out of
|
|
46
|
+
logs but not out of a dict, and this response is the one place it would
|
|
47
|
+
otherwise be handed to whoever asked.
|
|
48
|
+
"""
|
|
49
|
+
fields = asdict(worker)
|
|
50
|
+
fields.pop("auth_token")
|
|
51
|
+
return fields
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _usage_json(event: UsageEvent) -> dict:
|
|
55
|
+
return asdict(event)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_app(
|
|
59
|
+
pool: Pool,
|
|
60
|
+
*,
|
|
61
|
+
api_token: str | None = None,
|
|
62
|
+
scaler_interval_seconds: float = 10.0,
|
|
63
|
+
) -> FastAPI:
|
|
64
|
+
"""Build the pool's HTTP app.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
api_token: Bearer token every route except `/health` requires. None
|
|
68
|
+
disables the check, which is a local-development choice: an open
|
|
69
|
+
submit endpoint runs arbitrary payloads on machines you pay for.
|
|
70
|
+
scaler_interval_seconds: How often idle machines are reaped. The app
|
|
71
|
+
starts the scaler itself, so a deployment cannot forget to.
|
|
72
|
+
"""
|
|
73
|
+
if api_token is None:
|
|
74
|
+
logger.warning("pool API starting with no token; anyone who can reach it can run jobs")
|
|
75
|
+
|
|
76
|
+
async def require_token(authorization: Annotated[str | None, Header()] = None) -> None:
|
|
77
|
+
if api_token is None:
|
|
78
|
+
return
|
|
79
|
+
if authorization != f"Bearer {api_token}":
|
|
80
|
+
raise HTTPException(status_code=401, detail="invalid or missing API token")
|
|
81
|
+
|
|
82
|
+
async def tenant(request: Request) -> str:
|
|
83
|
+
value = request.headers.get(TENANT_HEADER)
|
|
84
|
+
if not value:
|
|
85
|
+
raise HTTPException(status_code=400, detail=f"{TENANT_HEADER} is required")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
async def lifespan(app: FastAPI):
|
|
89
|
+
# Reconcile before serving: machines from a previous process are
|
|
90
|
+
# either still reachable or still billing.
|
|
91
|
+
await pool.recover()
|
|
92
|
+
pool.start_scaler(scaler_interval_seconds)
|
|
93
|
+
yield
|
|
94
|
+
await pool.aclose()
|
|
95
|
+
|
|
96
|
+
app = FastAPI(title="strata-pool", lifespan=lifespan)
|
|
97
|
+
guard = [Depends(require_token)]
|
|
98
|
+
|
|
99
|
+
@app.get("/health")
|
|
100
|
+
async def health() -> dict:
|
|
101
|
+
"""Deliberately outside the token check, for load balancers."""
|
|
102
|
+
workers = pool.store.list_workers()
|
|
103
|
+
counts: dict[str, int] = {}
|
|
104
|
+
for worker in workers:
|
|
105
|
+
counts[worker.state.value] = counts.get(worker.state.value, 0) + 1
|
|
106
|
+
return {"status": "ok", "workers": counts, "machine_types": list(pool.machine_types)}
|
|
107
|
+
|
|
108
|
+
@app.post("/v1/jobs", status_code=202, dependencies=guard)
|
|
109
|
+
async def submit_job(
|
|
110
|
+
request: Request,
|
|
111
|
+
machine_type: str,
|
|
112
|
+
tenant_id: Annotated[str, Depends(tenant)],
|
|
113
|
+
priority: int = 0,
|
|
114
|
+
session_id: str | None = None,
|
|
115
|
+
timeout_seconds: float | None = None,
|
|
116
|
+
) -> JSONResponse:
|
|
117
|
+
"""Queue a job. The request body is the payload, verbatim."""
|
|
118
|
+
job = await _submit(
|
|
119
|
+
pool,
|
|
120
|
+
tenant_id=tenant_id,
|
|
121
|
+
machine_type=machine_type,
|
|
122
|
+
payload=await request.body(),
|
|
123
|
+
priority=priority,
|
|
124
|
+
session_id=session_id,
|
|
125
|
+
timeout_seconds=timeout_seconds,
|
|
126
|
+
)
|
|
127
|
+
return JSONResponse(_job_json(job), status_code=202)
|
|
128
|
+
|
|
129
|
+
@app.post("/v1/jobs/sync", dependencies=guard)
|
|
130
|
+
async def submit_and_wait(
|
|
131
|
+
request: Request,
|
|
132
|
+
machine_type: str,
|
|
133
|
+
tenant_id: Annotated[str, Depends(tenant)],
|
|
134
|
+
priority: int = 0,
|
|
135
|
+
session_id: str | None = None,
|
|
136
|
+
timeout_seconds: float | None = None,
|
|
137
|
+
wait_seconds: float = 300.0,
|
|
138
|
+
) -> Response:
|
|
139
|
+
"""Queue a job and block until it finishes.
|
|
140
|
+
|
|
141
|
+
What the notebook's executor protocol wants, since it expects one
|
|
142
|
+
synchronous response. `wait_seconds` bounds how long the caller waits,
|
|
143
|
+
not how long the job may run — a job that outlives it keeps going and
|
|
144
|
+
can be collected by ID.
|
|
145
|
+
"""
|
|
146
|
+
job = await _submit(
|
|
147
|
+
pool,
|
|
148
|
+
tenant_id=tenant_id,
|
|
149
|
+
machine_type=machine_type,
|
|
150
|
+
payload=await request.body(),
|
|
151
|
+
priority=priority,
|
|
152
|
+
session_id=session_id,
|
|
153
|
+
timeout_seconds=timeout_seconds,
|
|
154
|
+
)
|
|
155
|
+
try:
|
|
156
|
+
done = await pool.wait(job.id, timeout=wait_seconds)
|
|
157
|
+
except TimeoutError:
|
|
158
|
+
# 202: it is still running, and the ID is how you find it. Re-read
|
|
159
|
+
# for the freshest state, falling back to the submitted snapshot
|
|
160
|
+
# rather than pretending a row we just wrote could be missing.
|
|
161
|
+
latest = pool.store.get_job(job.id) or job
|
|
162
|
+
return JSONResponse(_job_json(latest), status_code=202)
|
|
163
|
+
return _terminal_response(done)
|
|
164
|
+
|
|
165
|
+
@app.get("/v1/jobs/{job_id}", dependencies=guard)
|
|
166
|
+
async def get_job(job_id: str) -> dict:
|
|
167
|
+
job = pool.store.get_job(job_id)
|
|
168
|
+
if job is None:
|
|
169
|
+
raise HTTPException(status_code=404, detail=f"no such job: {job_id}")
|
|
170
|
+
return _job_json(job)
|
|
171
|
+
|
|
172
|
+
@app.get("/v1/jobs/{job_id}/result", dependencies=guard)
|
|
173
|
+
async def get_job_result(job_id: str) -> Response:
|
|
174
|
+
"""The raw result bytes, once there are any."""
|
|
175
|
+
job = pool.store.get_job(job_id)
|
|
176
|
+
if job is None:
|
|
177
|
+
raise HTTPException(status_code=404, detail=f"no such job: {job_id}")
|
|
178
|
+
if job.state not in (JobState.COMPLETED, JobState.FAILED, JobState.TIMED_OUT):
|
|
179
|
+
raise HTTPException(status_code=409, detail=f"job is {job.state.value}")
|
|
180
|
+
return _terminal_response(job)
|
|
181
|
+
|
|
182
|
+
@app.get("/v1/machine-types", dependencies=guard)
|
|
183
|
+
async def list_machine_types() -> list[dict]:
|
|
184
|
+
"""What a caller may ask for. The catalogue an annotation resolves against."""
|
|
185
|
+
return [asdict(spec) for spec in pool.machine_types.values()]
|
|
186
|
+
|
|
187
|
+
@app.get("/v1/workers", dependencies=guard)
|
|
188
|
+
async def list_workers() -> list[dict]:
|
|
189
|
+
return [_worker_json(worker) for worker in pool.store.list_workers()]
|
|
190
|
+
|
|
191
|
+
@app.get("/v1/usage", dependencies=guard)
|
|
192
|
+
async def list_usage(tenant_id: str | None = Query(default=None)) -> list[dict]:
|
|
193
|
+
"""The billing feed. One event per terminal job, monotonic duration."""
|
|
194
|
+
return [_usage_json(event) for event in pool.store.list_usage(tenant_id)]
|
|
195
|
+
|
|
196
|
+
return app
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def _submit(pool: Pool, **kwargs) -> Job:
|
|
200
|
+
try:
|
|
201
|
+
return await pool.submit(**kwargs)
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
# An unknown machine type is the caller's mistake, not a pool failure.
|
|
204
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _terminal_response(job: Job) -> Response:
|
|
208
|
+
"""Map a finished job onto a status code.
|
|
209
|
+
|
|
210
|
+
A failure on the worker is reported as a failure of the job, not of the
|
|
211
|
+
pool: the caller needs to tell "your code raised" from "we could not run
|
|
212
|
+
it", and a 500 would blur the two.
|
|
213
|
+
"""
|
|
214
|
+
if job.state is JobState.COMPLETED:
|
|
215
|
+
return Response(content=job.result or b"", media_type="application/octet-stream")
|
|
216
|
+
status = 504 if job.state is JobState.TIMED_OUT else 502
|
|
217
|
+
return JSONResponse({"state": job.state.value, "error": job.error}, status_code=status)
|
strata_pool/backend.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The seam between the pool and whatever actually runs machines."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from strata_pool.types import MachineType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ProvisionedWorker:
|
|
11
|
+
"""What a backend hands back from `start()`."""
|
|
12
|
+
|
|
13
|
+
backend_id: str
|
|
14
|
+
"""Cloud-specific resource ID, used for stop()."""
|
|
15
|
+
|
|
16
|
+
endpoint: str
|
|
17
|
+
"""HTTP base URL where the worker will accept jobs.
|
|
18
|
+
|
|
19
|
+
Required at start time. A backend that only learns the address later
|
|
20
|
+
should block until it knows it: the pool has no other way to health-check
|
|
21
|
+
a booting machine, and a worker it cannot reach is a worker it cannot
|
|
22
|
+
stop billing for.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
region: str | None = None
|
|
26
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Backend(Protocol):
|
|
30
|
+
"""A cloud provider or local runtime that can start and stop machines."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
|
|
34
|
+
async def start(
|
|
35
|
+
self,
|
|
36
|
+
spec: MachineType,
|
|
37
|
+
env: dict[str, str] | None = None,
|
|
38
|
+
) -> ProvisionedWorker:
|
|
39
|
+
"""Provision and start a worker.
|
|
40
|
+
|
|
41
|
+
Takes the whole machine type rather than a name and an image: a
|
|
42
|
+
backend needs the resource limits too, and each will grow its own
|
|
43
|
+
provider-specific fields. A backend ignores what it cannot express.
|
|
44
|
+
|
|
45
|
+
Returns once the machine is booting; it does not need to be ready.
|
|
46
|
+
The pool polls `health()` until it is.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
async def stop(self, backend_id: str) -> None:
|
|
51
|
+
"""Stop and deallocate a worker. Must be idempotent."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
async def health(self, endpoint: str) -> bool:
|
|
55
|
+
"""Report whether a worker is ready to accept jobs."""
|
|
56
|
+
...
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Run workers as containers on a local Docker daemon.
|
|
2
|
+
|
|
3
|
+
The first backend, chosen first so that dispatch, boot handling, and the
|
|
4
|
+
metering path are all exercised by CI before any of them produces a number
|
|
5
|
+
someone pays for.
|
|
6
|
+
|
|
7
|
+
It talks to the Docker Engine API over its UNIX socket with httpx rather than
|
|
8
|
+
pulling in the Docker SDK: the pool's dependency list stays at one entry, and
|
|
9
|
+
every request shape is testable against `httpx.MockTransport` without a
|
|
10
|
+
daemon.
|
|
11
|
+
|
|
12
|
+
The backend does not care what runs inside the container. It starts an image,
|
|
13
|
+
finds the host port Docker published, and reports the endpoint; whether that
|
|
14
|
+
image is `strata-worker` or something else is the caller's business.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from strata_pool.backend import ProvisionedWorker
|
|
22
|
+
from strata_pool.types import MachineType
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
DEFAULT_SOCKET = "/var/run/docker.sock"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DockerError(RuntimeError):
|
|
30
|
+
"""The Docker daemon refused a request."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DockerBackend:
|
|
34
|
+
"""Provisions workers as containers on the local Docker daemon."""
|
|
35
|
+
|
|
36
|
+
name = "docker"
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
socket_path: str = DEFAULT_SOCKET,
|
|
42
|
+
worker_port: int = 8080,
|
|
43
|
+
command: list[str] | None = None,
|
|
44
|
+
stop_timeout_seconds: int = 5,
|
|
45
|
+
api: httpx.AsyncClient | None = None,
|
|
46
|
+
probe: httpx.AsyncClient | None = None,
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
Args:
|
|
50
|
+
worker_port: The port the image listens on inside the container.
|
|
51
|
+
Docker publishes it on an arbitrary host port, which is what
|
|
52
|
+
the pool connects to.
|
|
53
|
+
command: Overrides the image's entrypoint. For images that host
|
|
54
|
+
more than one, and for tests.
|
|
55
|
+
api: Client for the Docker Engine API. Defaults to one bound to
|
|
56
|
+
the daemon socket.
|
|
57
|
+
probe: Client for worker health checks, which go over TCP to the
|
|
58
|
+
published port rather than through the daemon.
|
|
59
|
+
"""
|
|
60
|
+
self.worker_port = worker_port
|
|
61
|
+
self.command = command
|
|
62
|
+
self.stop_timeout_seconds = stop_timeout_seconds
|
|
63
|
+
self._owns_clients = api is None and probe is None
|
|
64
|
+
self._api = api if api is not None else _socket_client(socket_path)
|
|
65
|
+
self._probe = probe if probe is not None else httpx.AsyncClient()
|
|
66
|
+
|
|
67
|
+
async def aclose(self) -> None:
|
|
68
|
+
"""Release the HTTP clients. The pool does not own the backend."""
|
|
69
|
+
if self._owns_clients:
|
|
70
|
+
await self._api.aclose()
|
|
71
|
+
await self._probe.aclose()
|
|
72
|
+
|
|
73
|
+
async def start(
|
|
74
|
+
self,
|
|
75
|
+
spec: MachineType,
|
|
76
|
+
env: dict[str, str] | None = None,
|
|
77
|
+
) -> ProvisionedWorker:
|
|
78
|
+
port_key = f"{self.worker_port}/tcp"
|
|
79
|
+
host_config: dict[str, object] = {
|
|
80
|
+
# Empty HostPort means "pick a free one". Binding to loopback
|
|
81
|
+
# keeps a worker off the network: the pool is the only thing that
|
|
82
|
+
# should be able to reach it.
|
|
83
|
+
"PortBindings": {port_key: [{"HostIp": "127.0.0.1", "HostPort": ""}]},
|
|
84
|
+
}
|
|
85
|
+
# Unset means the container may consume the whole host, which is one
|
|
86
|
+
# tenant's job able to starve every other container on the box.
|
|
87
|
+
if spec.cpus is not None:
|
|
88
|
+
host_config["NanoCpus"] = int(spec.cpus * 1_000_000_000)
|
|
89
|
+
if spec.memory_mb is not None:
|
|
90
|
+
host_config["Memory"] = spec.memory_mb * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
create = await self._api.post(
|
|
93
|
+
"/containers/create",
|
|
94
|
+
json={
|
|
95
|
+
"Image": spec.image,
|
|
96
|
+
"Env": [f"{key}={value}" for key, value in (env or {}).items()],
|
|
97
|
+
"Cmd": self.command,
|
|
98
|
+
"ExposedPorts": {port_key: {}},
|
|
99
|
+
"HostConfig": host_config,
|
|
100
|
+
# A machine the pool forgot about can still be found by hand,
|
|
101
|
+
# and gives a later reconcile pass something to match on.
|
|
102
|
+
"Labels": {"strata.pool.machine-type": spec.name},
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
if create.status_code >= 400:
|
|
106
|
+
raise DockerError(f"could not create a {spec.image} container: {_message(create)}")
|
|
107
|
+
container_id = create.json()["Id"]
|
|
108
|
+
|
|
109
|
+
started = await self._api.post(f"/containers/{container_id}/start")
|
|
110
|
+
if started.status_code >= 400:
|
|
111
|
+
# The container exists and would sit there costing disk, so take
|
|
112
|
+
# it back out before reporting the failure.
|
|
113
|
+
await self.stop(container_id)
|
|
114
|
+
raise DockerError(f"could not start {container_id}: {_message(started)}")
|
|
115
|
+
|
|
116
|
+
port = await self._published_port(container_id, port_key)
|
|
117
|
+
return ProvisionedWorker(
|
|
118
|
+
backend_id=container_id,
|
|
119
|
+
endpoint=f"http://127.0.0.1:{port}",
|
|
120
|
+
region="local",
|
|
121
|
+
metadata={"machine_type": spec.name},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def stop(self, backend_id: str) -> None:
|
|
125
|
+
"""Stop and remove a container. Idempotent."""
|
|
126
|
+
stopped = await self._api.post(
|
|
127
|
+
f"/containers/{backend_id}/stop",
|
|
128
|
+
params={"t": self.stop_timeout_seconds},
|
|
129
|
+
)
|
|
130
|
+
# 304 is "already stopped" and 404 is "already gone"; both are the
|
|
131
|
+
# state this method exists to reach.
|
|
132
|
+
if stopped.status_code >= 400 and stopped.status_code != 404:
|
|
133
|
+
logger.warning(
|
|
134
|
+
"docker refused to stop a container; removing it anyway",
|
|
135
|
+
extra={"container_id": backend_id, "detail": _message(stopped)},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
removed = await self._api.delete(f"/containers/{backend_id}", params={"force": "true"})
|
|
139
|
+
if removed.status_code >= 400 and removed.status_code != 404:
|
|
140
|
+
raise DockerError(f"could not remove {backend_id}: {_message(removed)}")
|
|
141
|
+
|
|
142
|
+
async def health(self, endpoint: str) -> bool:
|
|
143
|
+
"""Report whether the worker answers on its published port.
|
|
144
|
+
|
|
145
|
+
Never raises: a refused connection is the normal state of a container
|
|
146
|
+
that is still booting, and the pool polls this in a loop.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
response = await self._probe.get(f"{endpoint}/health", timeout=2.0)
|
|
150
|
+
except httpx.HTTPError:
|
|
151
|
+
return False
|
|
152
|
+
return response.status_code < 400
|
|
153
|
+
|
|
154
|
+
async def _published_port(self, container_id: str, port_key: str) -> int:
|
|
155
|
+
inspect = await self._api.get(f"/containers/{container_id}/json")
|
|
156
|
+
if inspect.status_code >= 400:
|
|
157
|
+
raise DockerError(f"could not inspect {container_id}: {_message(inspect)}")
|
|
158
|
+
|
|
159
|
+
bindings = inspect.json().get("NetworkSettings", {}).get("Ports") or {}
|
|
160
|
+
published = bindings.get(port_key) or []
|
|
161
|
+
if not published:
|
|
162
|
+
raise DockerError(
|
|
163
|
+
f"{container_id} published no host port for {port_key}. The image must "
|
|
164
|
+
f"listen on {self.worker_port}, or the backend needs a different worker_port."
|
|
165
|
+
)
|
|
166
|
+
return int(published[0]["HostPort"])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _socket_client(socket_path: str) -> httpx.AsyncClient:
|
|
170
|
+
"""A client bound to the daemon socket.
|
|
171
|
+
|
|
172
|
+
The host in the URL is ignored for a UNIX-socket transport but httpx still
|
|
173
|
+
requires one, hence the placeholder.
|
|
174
|
+
"""
|
|
175
|
+
return httpx.AsyncClient(
|
|
176
|
+
transport=httpx.AsyncHTTPTransport(uds=socket_path),
|
|
177
|
+
base_url="http://docker",
|
|
178
|
+
timeout=30.0,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _message(response: httpx.Response) -> str:
|
|
183
|
+
"""The daemon's own error text, which is JSON when it is well behaved."""
|
|
184
|
+
try:
|
|
185
|
+
payload = response.json()
|
|
186
|
+
except ValueError:
|
|
187
|
+
return f"HTTP {response.status_code}: {response.text[:200]}"
|
|
188
|
+
return f"HTTP {response.status_code}: {payload.get('message', response.text[:200])}"
|