manifold-cli 0.0.39__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.
- manifold_cli/__init__.py +3 -0
- manifold_cli/__main__.py +4 -0
- manifold_cli/bridge.py +70 -0
- manifold_cli/cli.py +54 -0
- manifold_cli/clients/__init__.py +0 -0
- manifold_cli/clients/auth_flow.py +169 -0
- manifold_cli/clients/credentials.py +53 -0
- manifold_cli/clients/docker_engine.py +43 -0
- manifold_cli/clients/image.py +130 -0
- manifold_cli/clients/manifold.py +1288 -0
- manifold_cli/clients/runner_home.py +165 -0
- manifold_cli/clients/workos.py +99 -0
- manifold_cli/commands/__init__.py +0 -0
- manifold_cli/commands/auth.py +101 -0
- manifold_cli/commands/benchmark.py +539 -0
- manifold_cli/commands/organization.py +154 -0
- manifold_cli/commands/policy.py +749 -0
- manifold_cli/commands/run.py +337 -0
- manifold_cli/commands/run_log.py +291 -0
- manifold_cli/commands/runner.py +852 -0
- manifold_cli/commands/table.py +101 -0
- manifold_cli/commands/token.py +144 -0
- manifold_cli/dependencies.py +116 -0
- manifold_cli/models/__init__.py +0 -0
- manifold_cli/models/auth.py +49 -0
- manifold_cli/models/benchmark.py +87 -0
- manifold_cli/models/catalog.py +189 -0
- manifold_cli/models/credentials.py +19 -0
- manifold_cli/models/errors.py +121 -0
- manifold_cli/models/log.py +114 -0
- manifold_cli/models/observability.py +121 -0
- manifold_cli/models/organization.py +19 -0
- manifold_cli/models/pagination.py +59 -0
- manifold_cli/models/policy.py +49 -0
- manifold_cli/models/run.py +381 -0
- manifold_cli/models/runner.py +186 -0
- manifold_cli/models/runner_home.py +69 -0
- manifold_cli/models/task_queue.py +40 -0
- manifold_cli/models/token.py +39 -0
- manifold_cli/replay/__init__.py +68 -0
- manifold_cli/replay/log.py +814 -0
- manifold_cli/runner/__init__.py +72 -0
- manifold_cli/runner/api_retry.py +36 -0
- manifold_cli/runner/benchmark/__init__.py +800 -0
- manifold_cli/runner/benchmark/live_view.py +248 -0
- manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
- manifold_cli/runner/benchmark/recording.py +1208 -0
- manifold_cli/runner/benchmark/scheduling.py +256 -0
- manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
- manifold_cli/runner/benchmark/task_server.py +123 -0
- manifold_cli/runner/benchmark/video.py +124 -0
- manifold_cli/runner/benchmark_steps.py +178 -0
- manifold_cli/runner/cancellation.py +22 -0
- manifold_cli/runner/capture.py +282 -0
- manifold_cli/runner/container.py +370 -0
- manifold_cli/runner/current_dispatch.py +25 -0
- manifold_cli/runner/daemon.py +520 -0
- manifold_cli/runner/dispatch.py +38 -0
- manifold_cli/runner/gpu.py +182 -0
- manifold_cli/runner/heartbeat.py +202 -0
- manifold_cli/runner/loopback.py +102 -0
- manifold_cli/runner/loopback_bench.py +183 -0
- manifold_cli/runner/loopback_policy.py +39 -0
- manifold_cli/runner/output.py +218 -0
- manifold_cli/runner/pairing_acceptance.py +57 -0
- manifold_cli/runner/plan.py +369 -0
- manifold_cli/runner/policy.py +350 -0
- manifold_cli/runner/policy_steps.py +164 -0
- manifold_cli/runner/preparation.py +47 -0
- manifold_cli/runner/process.py +219 -0
- manifold_cli/runner/ram.py +17 -0
- manifold_cli/runner/resource_usage.py +265 -0
- manifold_cli/runner/served.py +218 -0
- manifold_cli/runner/service.py +120 -0
- manifold_cli/runner/side_failure.py +68 -0
- manifold_cli/runner/signature.py +98 -0
- manifold_cli/runner/source.py +66 -0
- manifold_cli/runner/stages.py +241 -0
- manifold_cli/runner/step_timing.py +229 -0
- manifold_cli/runner/worker.py +534 -0
- manifold_cli/runner/workload.py +401 -0
- manifold_cli/services/__init__.py +0 -0
- manifold_cli/services/auth.py +89 -0
- manifold_cli/services/benchmark.py +357 -0
- manifold_cli/services/organization.py +22 -0
- manifold_cli/services/policy.py +234 -0
- manifold_cli/services/run.py +304 -0
- manifold_cli/services/runner.py +207 -0
- manifold_cli/services/runner_daemon.py +146 -0
- manifold_cli/services/token.py +52 -0
- manifold_cli/settings.py +30 -0
- manifold_cli/transport/__init__.py +31 -0
- manifold_cli/transport/diagnostics.py +72 -0
- manifold_cli/transport/interfaces.py +69 -0
- manifold_cli/transport/iroh.py +317 -0
- manifold_cli/transport/registry.py +34 -0
- manifold_cli/transport/tcp.py +146 -0
- manifold_cli-0.0.39.dist-info/METADATA +406 -0
- manifold_cli-0.0.39.dist-info/RECORD +102 -0
- manifold_cli-0.0.39.dist-info/WHEEL +4 -0
- manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
- manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
manifold_cli/__init__.py
ADDED
manifold_cli/__main__.py
ADDED
manifold_cli/bridge.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""A byte-identical relay between two `ByteStream`s over any transport.
|
|
2
|
+
|
|
3
|
+
Used to make a policy server on a plain local TCP socket reachable over our
|
|
4
|
+
transport (tcp/iroh) and back, without either end knowing a bridge sits
|
|
5
|
+
in between. No frame decoding: bytes are copied through verbatim.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
|
|
11
|
+
from manifold_cli.transport import ByteStream, Connector, Listener, TransportError
|
|
12
|
+
|
|
13
|
+
RELAY_CHUNK_SIZE = 64 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def pump(src: ByteStream, dst: ByteStream) -> None:
|
|
17
|
+
"""Copy `src` to `dst` until EOF, then close both (unblocks the other pump)."""
|
|
18
|
+
try:
|
|
19
|
+
while True:
|
|
20
|
+
chunk = await src.read(RELAY_CHUNK_SIZE)
|
|
21
|
+
if not chunk:
|
|
22
|
+
break
|
|
23
|
+
try:
|
|
24
|
+
await dst.write(chunk)
|
|
25
|
+
except TransportError:
|
|
26
|
+
# dst was already closed by the other pump's EOF teardown; a
|
|
27
|
+
# failed write there just means this direction is done too.
|
|
28
|
+
break
|
|
29
|
+
finally:
|
|
30
|
+
# First EOF (or error) tears both directions down. A session that ends
|
|
31
|
+
# with a clean close loses nothing; a hard truncation mid-flight could
|
|
32
|
+
# drop unread bytes, which is acceptable here.
|
|
33
|
+
await src.close()
|
|
34
|
+
await dst.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def splice(a: ByteStream, b: ByteStream) -> None:
|
|
38
|
+
"""Pump a->b and b->a concurrently until either direction hits EOF."""
|
|
39
|
+
async with asyncio.TaskGroup() as group:
|
|
40
|
+
group.create_task(pump(a, b))
|
|
41
|
+
group.create_task(pump(b, a))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def run_bridge(listener: Listener, connector: Connector) -> None:
|
|
45
|
+
"""Accept connections on `listener`, dial `connector`, splice each pair.
|
|
46
|
+
|
|
47
|
+
Each accepted stream gets its own spliced pair, so several shards can be
|
|
48
|
+
bridged at once. On cancellation, outstanding splices and the listener are
|
|
49
|
+
torn down. Returns silently; nothing noisy on normal shutdown.
|
|
50
|
+
"""
|
|
51
|
+
tasks: set[asyncio.Task[None]] = set()
|
|
52
|
+
try:
|
|
53
|
+
while True:
|
|
54
|
+
stream = await listener.accept()
|
|
55
|
+
try:
|
|
56
|
+
peer = await connector.connect()
|
|
57
|
+
except BaseException:
|
|
58
|
+
# A failed dial must not leak the accepted stream: close it so
|
|
59
|
+
# the peer sees EOF instead of a connection frozen mid-air.
|
|
60
|
+
await stream.close()
|
|
61
|
+
raise
|
|
62
|
+
task = asyncio.create_task(splice(stream, peer))
|
|
63
|
+
tasks.add(task)
|
|
64
|
+
task.add_done_callback(tasks.discard)
|
|
65
|
+
finally:
|
|
66
|
+
for task in tasks:
|
|
67
|
+
task.cancel()
|
|
68
|
+
with contextlib.suppress(Exception):
|
|
69
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
70
|
+
await listener.close()
|
manifold_cli/cli.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from manifold_cli.commands import auth as auth_commands
|
|
6
|
+
from manifold_cli.commands import benchmark as benchmark_commands
|
|
7
|
+
from manifold_cli.commands import organization as organization_commands
|
|
8
|
+
from manifold_cli.commands import policy as policy_commands
|
|
9
|
+
from manifold_cli.commands import run as run_commands
|
|
10
|
+
from manifold_cli.commands import runner as runner_commands
|
|
11
|
+
from manifold_cli.commands import token as token_commands
|
|
12
|
+
from manifold_cli.dependencies import Dependencies, build_dependencies
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _version_callback(value: bool) -> None:
|
|
16
|
+
if value:
|
|
17
|
+
typer.echo(f"manifold-cli {version('manifold-cli')}")
|
|
18
|
+
raise typer.Exit()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_app(deps: Dependencies) -> typer.Typer:
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="manifold",
|
|
24
|
+
help="Manifold CLI.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@app.callback()
|
|
29
|
+
def _main(
|
|
30
|
+
version: bool = typer.Option(
|
|
31
|
+
False,
|
|
32
|
+
"--version",
|
|
33
|
+
callback=_version_callback,
|
|
34
|
+
is_eager=True,
|
|
35
|
+
help="Show version and exit.",
|
|
36
|
+
),
|
|
37
|
+
) -> None:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
auth_commands.register(app, deps)
|
|
41
|
+
organization_commands.register(app, deps)
|
|
42
|
+
policy_commands.register(app, deps)
|
|
43
|
+
benchmark_commands.register(app, deps)
|
|
44
|
+
run_commands.register(app, deps)
|
|
45
|
+
runner_commands.register(app, deps)
|
|
46
|
+
token_commands.register(app, deps)
|
|
47
|
+
|
|
48
|
+
return app
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
deps = build_dependencies()
|
|
53
|
+
app = build_app(deps)
|
|
54
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Provide httpx authentication flows for Manifold API clients."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator, Generator
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from manifold_cli.models.auth import TokenResponse
|
|
10
|
+
from manifold_cli.models.credentials import Credentials
|
|
11
|
+
from manifold_cli.models.errors import (
|
|
12
|
+
AccessTokenRejectedError,
|
|
13
|
+
InvalidAccessTokenError,
|
|
14
|
+
NoRunnerCredentialError,
|
|
15
|
+
NotLoggedInError,
|
|
16
|
+
RunnerCredentialRejectedError,
|
|
17
|
+
SessionExpiredError,
|
|
18
|
+
WorkOSAuthError,
|
|
19
|
+
)
|
|
20
|
+
from manifold_cli.models.runner_home import RunnerHome
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CredentialStore(Protocol):
|
|
24
|
+
"""Persistent home for the stored credentials WorkOSAuth reads and rotates."""
|
|
25
|
+
|
|
26
|
+
def load(self) -> Credentials | None: ...
|
|
27
|
+
|
|
28
|
+
def save(self, credentials: Credentials) -> None: ...
|
|
29
|
+
|
|
30
|
+
def clear(self) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RunnerHomeStore(Protocol):
|
|
34
|
+
"""The runner home holding the minted token RunnerAuth presents."""
|
|
35
|
+
|
|
36
|
+
def load(self) -> RunnerHome | None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TokenRefresher(Protocol):
|
|
40
|
+
"""Exchanges a refresh token for a new token pair, or raises WorkOSAuthError."""
|
|
41
|
+
|
|
42
|
+
def refresh(self, refresh_token: str) -> TokenResponse: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WorkOSAuth(httpx.Auth):
|
|
46
|
+
def __init__(self, credentials: CredentialStore, refresher: TokenRefresher):
|
|
47
|
+
self._credentials = credentials
|
|
48
|
+
self._refresher = refresher
|
|
49
|
+
|
|
50
|
+
def sync_auth_flow(
|
|
51
|
+
self, request: httpx.Request
|
|
52
|
+
) -> Generator[httpx.Request, httpx.Response]:
|
|
53
|
+
creds = self._credentials.load()
|
|
54
|
+
if creds is None:
|
|
55
|
+
raise NotLoggedInError
|
|
56
|
+
request.headers["Authorization"] = f"Bearer {creds.access_token}"
|
|
57
|
+
response = yield request
|
|
58
|
+
if response.status_code != HTTPStatus.UNAUTHORIZED:
|
|
59
|
+
return
|
|
60
|
+
rotated = self._refresh(creds)
|
|
61
|
+
request.headers["Authorization"] = f"Bearer {rotated.access_token}"
|
|
62
|
+
response = yield request
|
|
63
|
+
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
|
64
|
+
raise SessionExpiredError
|
|
65
|
+
|
|
66
|
+
def _refresh(self, creds: Credentials) -> Credentials:
|
|
67
|
+
"""Rotate and persist the token pair, or fail terminally.
|
|
68
|
+
|
|
69
|
+
Clears the stored credentials and raises SessionExpiredError when the
|
|
70
|
+
refresh itself is rejected: nothing short of that means the session is
|
|
71
|
+
dead. A rotation that succeeds is persisted even if the retried request
|
|
72
|
+
still fails; the caller decides what a lingering 401 means.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
new_tokens = self._refresher.refresh(creds.refresh_token)
|
|
76
|
+
except WorkOSAuthError as exc:
|
|
77
|
+
self._credentials.clear()
|
|
78
|
+
raise SessionExpiredError from exc
|
|
79
|
+
rotated = creds.model_copy(
|
|
80
|
+
update={
|
|
81
|
+
"access_token": new_tokens.access_token,
|
|
82
|
+
"refresh_token": new_tokens.refresh_token,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
self._credentials.save(rotated)
|
|
86
|
+
return rotated
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RunnerAuth(httpx.Auth):
|
|
90
|
+
"""Presents a minted runner token on every request.
|
|
91
|
+
|
|
92
|
+
There is no refresh half: a runner token is long-lived and revoked rather
|
|
93
|
+
than rotated, so a 401 is terminal rather than the cue to rotate that it is
|
|
94
|
+
for `WorkOSAuth`.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, home: RunnerHomeStore):
|
|
98
|
+
self._home = home
|
|
99
|
+
|
|
100
|
+
def sync_auth_flow(
|
|
101
|
+
self, request: httpx.Request
|
|
102
|
+
) -> Generator[httpx.Request, httpx.Response]:
|
|
103
|
+
self._authorise(request)
|
|
104
|
+
response = yield request
|
|
105
|
+
self._raise_for_rejection(response)
|
|
106
|
+
|
|
107
|
+
async def async_auth_flow(
|
|
108
|
+
self, request: httpx.Request
|
|
109
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
110
|
+
self._authorise(request)
|
|
111
|
+
response = yield request
|
|
112
|
+
self._raise_for_rejection(response)
|
|
113
|
+
|
|
114
|
+
def _authorise(self, request: httpx.Request) -> None:
|
|
115
|
+
home = self._home.load()
|
|
116
|
+
if home is None:
|
|
117
|
+
raise NoRunnerCredentialError
|
|
118
|
+
request.headers["Authorization"] = f"Bearer {home.credential.token}"
|
|
119
|
+
|
|
120
|
+
def _raise_for_rejection(self, response: httpx.Response) -> None:
|
|
121
|
+
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
|
122
|
+
raise RunnerCredentialRejectedError
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class InMemoryRunnerTokenAuth(httpx.Auth):
|
|
126
|
+
"""Send a runner token that is kept in memory.
|
|
127
|
+
|
|
128
|
+
`manifold policy serve` uses this. Its token is never written to disk, so
|
|
129
|
+
`RunnerAuth`, which reads the token from a runner home, does not apply.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, token: str):
|
|
133
|
+
self._token = token
|
|
134
|
+
|
|
135
|
+
def sync_auth_flow(
|
|
136
|
+
self, request: httpx.Request
|
|
137
|
+
) -> Generator[httpx.Request, httpx.Response]:
|
|
138
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
139
|
+
response = yield request
|
|
140
|
+
self._raise_for_rejection(response)
|
|
141
|
+
|
|
142
|
+
async def async_auth_flow(
|
|
143
|
+
self, request: httpx.Request
|
|
144
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
145
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
146
|
+
response = yield request
|
|
147
|
+
self._raise_for_rejection(response)
|
|
148
|
+
|
|
149
|
+
def _raise_for_rejection(self, response: httpx.Response) -> None:
|
|
150
|
+
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
|
151
|
+
raise RunnerCredentialRejectedError
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class StaticTokenAuth(httpx.Auth):
|
|
155
|
+
"""Present one access token without loading or refreshing a session."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, token: str):
|
|
158
|
+
# ensure token contains only printable ASCII chars
|
|
159
|
+
if any(ord(character) < 0x20 or ord(character) > 0x7E for character in token):
|
|
160
|
+
raise InvalidAccessTokenError
|
|
161
|
+
self._token = token
|
|
162
|
+
|
|
163
|
+
def sync_auth_flow(
|
|
164
|
+
self, request: httpx.Request
|
|
165
|
+
) -> Generator[httpx.Request, httpx.Response]:
|
|
166
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
167
|
+
response = yield request
|
|
168
|
+
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
|
169
|
+
raise AccessTokenRejectedError
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from manifold_cli.models.credentials import Credentials
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CredentialsClient:
|
|
14
|
+
def __init__(self, path: Path):
|
|
15
|
+
self._path = path
|
|
16
|
+
|
|
17
|
+
def load(self) -> Credentials | None:
|
|
18
|
+
try:
|
|
19
|
+
raw = self._path.read_text()
|
|
20
|
+
except FileNotFoundError:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
return Credentials.model_validate_json(raw)
|
|
24
|
+
except ValidationError:
|
|
25
|
+
logger.warning("credentials file at %s is malformed; ignoring", self._path)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def save(self, credentials: Credentials) -> None:
|
|
29
|
+
write_private_file(self._path, credentials.model_dump_json())
|
|
30
|
+
|
|
31
|
+
def clear(self) -> None:
|
|
32
|
+
self._path.unlink(missing_ok=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_private_file(path: Path, payload: str) -> None:
|
|
36
|
+
"""Replace `path` with `payload`, readable only by its owner.
|
|
37
|
+
|
|
38
|
+
Written to a temporary file in the same directory and renamed, so a reader
|
|
39
|
+
never sees a half-written credential and a failed write leaves the previous
|
|
40
|
+
one intact.
|
|
41
|
+
"""
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
44
|
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
with os.fdopen(fd, "w") as f:
|
|
48
|
+
f.write(payload)
|
|
49
|
+
os.chmod(tmp_name, 0o600)
|
|
50
|
+
os.replace(tmp_name, path)
|
|
51
|
+
except BaseException:
|
|
52
|
+
Path(tmp_name).unlink(missing_ok=True)
|
|
53
|
+
raise
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Read the Docker Engine API over the Docker daemon's Unix socket.
|
|
2
|
+
|
|
3
|
+
The `docker` command prints Docker image sizes rounded for display, and the engine
|
|
4
|
+
API reports them in bytes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
DOCKER_SOCKET_PATH = "/var/run/docker.sock"
|
|
12
|
+
# The Docker daemon computes the Docker image disk usage over every Docker
|
|
13
|
+
# image's layers.
|
|
14
|
+
DOCKER_IMAGE_USAGE_TIMEOUT = timedelta(seconds=30)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DockerEngineClient:
|
|
18
|
+
def __init__(self, socket_path: str):
|
|
19
|
+
self._client = httpx.Client(
|
|
20
|
+
base_url="http://docker",
|
|
21
|
+
transport=httpx.HTTPTransport(uds=socket_path),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def close(self) -> None:
|
|
25
|
+
self._client.close()
|
|
26
|
+
|
|
27
|
+
def get_root_dir(self) -> str:
|
|
28
|
+
"""Return the directory where Docker stores Docker images and containers."""
|
|
29
|
+
response = self._client.get("/info")
|
|
30
|
+
response.raise_for_status()
|
|
31
|
+
return response.json()["DockerRootDir"]
|
|
32
|
+
|
|
33
|
+
def get_docker_image_usage(self) -> tuple[int, int]:
|
|
34
|
+
"""Return the number of local Docker images and the bytes their layers use."""
|
|
35
|
+
response = self._client.get(
|
|
36
|
+
"/v1.52/system/df",
|
|
37
|
+
params={"type": "image"},
|
|
38
|
+
timeout=DOCKER_IMAGE_USAGE_TIMEOUT.total_seconds(),
|
|
39
|
+
)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
image_usage = response.json()["ImageUsage"]
|
|
42
|
+
# The Docker daemon leaves out `TotalCount` and `TotalSize` when they are 0.
|
|
43
|
+
return image_usage.get("TotalCount", 0), image_usage.get("TotalSize", 0)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Read one file out of a container image, using the `docker` command on $PATH.
|
|
2
|
+
|
|
3
|
+
`benchmark init` reads an image's rubric this way. `docker create` makes the
|
|
4
|
+
image's files available without starting the container. Starting the container
|
|
5
|
+
would run the image's entrypoint, and a benchmark entrypoint needs a GPU and a
|
|
6
|
+
connection to a policy. `docker cp` then copies the file out.
|
|
7
|
+
|
|
8
|
+
Only an image already on this machine is read. `docker create` downloads an
|
|
9
|
+
image it cannot find, and a benchmark image is tens of gigabytes. A missing
|
|
10
|
+
image raises ImageInspectError instead. The caller reports that as a file it
|
|
11
|
+
could not read, which is different from an image that does not carry the file.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import tarfile
|
|
18
|
+
from io import BytesIO
|
|
19
|
+
|
|
20
|
+
# Reading files from a local image takes only as long as the disk needs. These
|
|
21
|
+
# limits exist so that a docker daemon that stops responding cannot hold up a
|
|
22
|
+
# registration that has already succeeded.
|
|
23
|
+
INSPECT_TIMEOUT_SECONDS = 30.0
|
|
24
|
+
COPY_TIMEOUT_SECONDS = 120.0
|
|
25
|
+
|
|
26
|
+
# The message docker prints when `docker cp` asks for a path the container does
|
|
27
|
+
# not have.
|
|
28
|
+
_MISSING_PATH = "could not find the file"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ImageInspectError(Exception):
|
|
32
|
+
"""The image's files could not be read.
|
|
33
|
+
|
|
34
|
+
The message says what stopped the read. An image that can be read but does
|
|
35
|
+
not carry the path returns None instead. The caller reports these two cases
|
|
36
|
+
differently: one file could not be checked, the other was checked and is
|
|
37
|
+
not there.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DockerImageClient:
|
|
42
|
+
"""Reads files out of local container images."""
|
|
43
|
+
|
|
44
|
+
def read_file(self, image: str, path: str) -> bytes | None:
|
|
45
|
+
"""Return `path` from `image`, or None when the image does not carry it.
|
|
46
|
+
|
|
47
|
+
Raises ImageInspectError when docker is not installed, when the image
|
|
48
|
+
is not on this machine, or when the copy fails for any reason other
|
|
49
|
+
than the path being absent.
|
|
50
|
+
"""
|
|
51
|
+
if shutil.which("docker") is None:
|
|
52
|
+
raise ImageInspectError("docker is not on $PATH")
|
|
53
|
+
self._require_local(image)
|
|
54
|
+
container = self._create(image)
|
|
55
|
+
try:
|
|
56
|
+
return self._copy(container, path)
|
|
57
|
+
finally:
|
|
58
|
+
self._remove(container)
|
|
59
|
+
|
|
60
|
+
def _require_local(self, image: str) -> None:
|
|
61
|
+
result = self._run(
|
|
62
|
+
["docker", "image", "inspect", image], INSPECT_TIMEOUT_SECONDS
|
|
63
|
+
)
|
|
64
|
+
if result.returncode != 0:
|
|
65
|
+
raise ImageInspectError(f"'{image}' is not on this box")
|
|
66
|
+
|
|
67
|
+
def _create(self, image: str) -> str:
|
|
68
|
+
result = self._run(["docker", "create", image], INSPECT_TIMEOUT_SECONDS)
|
|
69
|
+
if result.returncode != 0:
|
|
70
|
+
raise ImageInspectError(_said(result) or f"docker could not open '{image}'")
|
|
71
|
+
container = result.stdout.decode(errors="replace").strip()
|
|
72
|
+
if not container:
|
|
73
|
+
raise ImageInspectError(f"docker named no container for '{image}'")
|
|
74
|
+
return container
|
|
75
|
+
|
|
76
|
+
def _copy(self, container: str, path: str) -> bytes | None:
|
|
77
|
+
result = self._run(
|
|
78
|
+
["docker", "cp", f"{container}:{path}", "-"], COPY_TIMEOUT_SECONDS
|
|
79
|
+
)
|
|
80
|
+
if result.returncode != 0:
|
|
81
|
+
said = _said(result)
|
|
82
|
+
if _MISSING_PATH in said.lower():
|
|
83
|
+
return None
|
|
84
|
+
raise ImageInspectError(said or f"docker could not read {path}")
|
|
85
|
+
return _only_member(result.stdout, path)
|
|
86
|
+
|
|
87
|
+
def _remove(self, container: str) -> None:
|
|
88
|
+
# This runs in a `finally`, after the read has already happened. A
|
|
89
|
+
# removal that fails or times out must not turn a successful read into
|
|
90
|
+
# an error, so a failure here is ignored. Nothing else in this module
|
|
91
|
+
# removes a container that is left behind.
|
|
92
|
+
with contextlib.suppress(ImageInspectError):
|
|
93
|
+
self._run(["docker", "rm", "--force", container], INSPECT_TIMEOUT_SECONDS)
|
|
94
|
+
|
|
95
|
+
def _run(
|
|
96
|
+
self, argv: list[str], timeout: float
|
|
97
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
98
|
+
try:
|
|
99
|
+
return subprocess.run(argv, capture_output=True, timeout=timeout)
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
raise ImageInspectError(f"could not run {argv[0]}: {exc}") from exc
|
|
102
|
+
except subprocess.TimeoutExpired as exc:
|
|
103
|
+
raise ImageInspectError(
|
|
104
|
+
f"`{' '.join(argv[:2])}` did not finish within {timeout:g} s"
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _said(result: subprocess.CompletedProcess[bytes]) -> str:
|
|
109
|
+
return result.stderr.decode(errors="replace").strip()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _only_member(archive: bytes, path: str) -> bytes:
|
|
113
|
+
"""Return the first file in the archive that `docker cp` produced.
|
|
114
|
+
|
|
115
|
+
An archive holding no file at all raises an error rather than returning
|
|
116
|
+
None, because None would say the image has no rubric and hide a broken
|
|
117
|
+
image. Copying a directory would return the first file inside it, and the
|
|
118
|
+
caller always names a file rather than a directory.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
with tarfile.open(fileobj=BytesIO(archive)) as tar:
|
|
122
|
+
for member in tar:
|
|
123
|
+
if not member.isfile():
|
|
124
|
+
continue
|
|
125
|
+
extracted = tar.extractfile(member)
|
|
126
|
+
if extracted is not None:
|
|
127
|
+
return extracted.read()
|
|
128
|
+
except tarfile.TarError as exc:
|
|
129
|
+
raise ImageInspectError(f"docker streamed no readable archive: {exc}") from exc
|
|
130
|
+
raise ImageInspectError(f"{path} is not a file in the image")
|