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
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
"""Register and manage the runners on this box."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from shlex import join
|
|
6
|
+
from typing import Annotated, assert_never
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from manifold_cli.commands.organization import (
|
|
15
|
+
ORGANIZATION_CALL_ERRORS,
|
|
16
|
+
exit_for_call_error,
|
|
17
|
+
)
|
|
18
|
+
from manifold_cli.commands.table import relative_time
|
|
19
|
+
from manifold_cli.dependencies import Dependencies, make_runner_registration_client
|
|
20
|
+
from manifold_cli.models.credentials import RunnerCredential
|
|
21
|
+
from manifold_cli.models.errors import (
|
|
22
|
+
AccessTokenRejectedError,
|
|
23
|
+
AmbiguousRunnerHomeError,
|
|
24
|
+
InvalidAccessTokenError,
|
|
25
|
+
NoRunnerHomeError,
|
|
26
|
+
NotLoggedInError,
|
|
27
|
+
RunnerDeploymentMismatchError,
|
|
28
|
+
)
|
|
29
|
+
from manifold_cli.models.organization import OrganizationNotFoundError
|
|
30
|
+
from manifold_cli.models.runner import (
|
|
31
|
+
GpuModel,
|
|
32
|
+
InstanceOverrideType,
|
|
33
|
+
ListedRunner,
|
|
34
|
+
Runner,
|
|
35
|
+
RunnerNameTakenError,
|
|
36
|
+
RunnerNotFoundError,
|
|
37
|
+
RunnerProvider,
|
|
38
|
+
RunnerStatus,
|
|
39
|
+
)
|
|
40
|
+
from manifold_cli.models.runner_home import BoxFacts, RunnerHome
|
|
41
|
+
from manifold_cli.runner.gpu import gpu_facts, gpu_memory_gb
|
|
42
|
+
from manifold_cli.runner.ram import host_ram_gb
|
|
43
|
+
from manifold_cli.services.runner import RunnerService
|
|
44
|
+
from manifold_cli.services.runner_daemon import RunnerDaemonServiceError
|
|
45
|
+
|
|
46
|
+
ORG_OPTION = typer.Option(
|
|
47
|
+
"--org", help="Organization identifier; defaults to the operator organization."
|
|
48
|
+
)
|
|
49
|
+
_UNTIL_HELP = (
|
|
50
|
+
"End time as an ISO 8601 timestamp, such as 2026-11-04T15:00 or "
|
|
51
|
+
"2026-11-04T15:00+08:00. A timestamp without a time zone is local time."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def register(app: typer.Typer, deps: Dependencies) -> None:
|
|
56
|
+
console = Console()
|
|
57
|
+
runner_app = typer.Typer(
|
|
58
|
+
name="runner",
|
|
59
|
+
help=(
|
|
60
|
+
"Register runners on this box, list an organization's runners, and "
|
|
61
|
+
"set their instance overrides and placement pauses."
|
|
62
|
+
),
|
|
63
|
+
no_args_is_help=True,
|
|
64
|
+
# Only operators run these, so they stay out of a customer's --help.
|
|
65
|
+
hidden=True,
|
|
66
|
+
)
|
|
67
|
+
app.add_typer(runner_app)
|
|
68
|
+
|
|
69
|
+
@runner_app.command("register")
|
|
70
|
+
def register_runner(
|
|
71
|
+
name: Annotated[str, typer.Argument(help="Name for the runner.")],
|
|
72
|
+
gpu_model: Annotated[
|
|
73
|
+
GpuModel,
|
|
74
|
+
typer.Option(
|
|
75
|
+
"--gpu-model",
|
|
76
|
+
help="GPU model of this box. Manifold charges for billed time per "
|
|
77
|
+
"GPU of this model.",
|
|
78
|
+
),
|
|
79
|
+
],
|
|
80
|
+
force: Annotated[
|
|
81
|
+
bool,
|
|
82
|
+
typer.Option(
|
|
83
|
+
"--force",
|
|
84
|
+
help="Replace an existing runner on this box.",
|
|
85
|
+
),
|
|
86
|
+
] = False,
|
|
87
|
+
cache_dir: Annotated[
|
|
88
|
+
Path | None,
|
|
89
|
+
typer.Option("--cache-dir", help="Directory this box's workers cache in."),
|
|
90
|
+
] = None,
|
|
91
|
+
advertise_host: Annotated[
|
|
92
|
+
str | None,
|
|
93
|
+
typer.Option(
|
|
94
|
+
"--advertise-host",
|
|
95
|
+
help="Address this box's policy publishes for its peer to "
|
|
96
|
+
"dial. Defaults to the address the route towards Manifold uses.",
|
|
97
|
+
),
|
|
98
|
+
] = None,
|
|
99
|
+
provider: Annotated[
|
|
100
|
+
RunnerProvider | None,
|
|
101
|
+
typer.Option(
|
|
102
|
+
"--provider",
|
|
103
|
+
help="Cloud provider of the VM this box runs on.",
|
|
104
|
+
),
|
|
105
|
+
] = None,
|
|
106
|
+
is_preemptible: Annotated[
|
|
107
|
+
bool,
|
|
108
|
+
typer.Option(
|
|
109
|
+
"--preemptible",
|
|
110
|
+
help="The provider may stop this box's VM at any time.",
|
|
111
|
+
),
|
|
112
|
+
] = False,
|
|
113
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Register a runner and start its persistent system service.
|
|
116
|
+
|
|
117
|
+
The command stores the runner token in its home. MANIFOLD_TOKEN takes
|
|
118
|
+
precedence over the operator's stored WorkOS login. The persistent
|
|
119
|
+
service runs as the user who registers it. The command returns after
|
|
120
|
+
systemd enables and starts the service.
|
|
121
|
+
"""
|
|
122
|
+
access_token = deps.manifold_token
|
|
123
|
+
runner_service = deps.runner_service
|
|
124
|
+
registration_client = None
|
|
125
|
+
try:
|
|
126
|
+
if access_token is not None:
|
|
127
|
+
registration_client = make_runner_registration_client(
|
|
128
|
+
access_token, deps.manifold_api_url
|
|
129
|
+
)
|
|
130
|
+
runner_service = RunnerService(registration_client)
|
|
131
|
+
_register_runner(
|
|
132
|
+
name=name,
|
|
133
|
+
force=force,
|
|
134
|
+
cache_dir=cache_dir,
|
|
135
|
+
advertise_host=advertise_host,
|
|
136
|
+
gpu_model=gpu_model,
|
|
137
|
+
provider=provider,
|
|
138
|
+
is_preemptible=is_preemptible,
|
|
139
|
+
organization=organization,
|
|
140
|
+
access_token=access_token,
|
|
141
|
+
runner_service=runner_service,
|
|
142
|
+
deps=deps,
|
|
143
|
+
console=console,
|
|
144
|
+
)
|
|
145
|
+
except InvalidAccessTokenError as exc:
|
|
146
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
147
|
+
raise typer.Exit(1) from exc
|
|
148
|
+
finally:
|
|
149
|
+
if registration_client is not None:
|
|
150
|
+
registration_client.close()
|
|
151
|
+
|
|
152
|
+
@runner_app.command()
|
|
153
|
+
def unregister(
|
|
154
|
+
runner: Annotated[
|
|
155
|
+
str | None,
|
|
156
|
+
typer.Option(
|
|
157
|
+
"--runner",
|
|
158
|
+
help="Which home, by name or by id, on a box holding several.",
|
|
159
|
+
),
|
|
160
|
+
] = None,
|
|
161
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Revoke and remove a runner registered on this box."""
|
|
164
|
+
try:
|
|
165
|
+
home = deps.runner_homes.resolve(runner)
|
|
166
|
+
except (NoRunnerHomeError, AmbiguousRunnerHomeError) as exc:
|
|
167
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
168
|
+
raise typer.Exit(1) from exc
|
|
169
|
+
stored = home.load()
|
|
170
|
+
if stored is None:
|
|
171
|
+
console.print(
|
|
172
|
+
f"[red]Could not read runner home at {escape(str(home.path))}.[/red]",
|
|
173
|
+
soft_wrap=True,
|
|
174
|
+
)
|
|
175
|
+
raise typer.Exit(1)
|
|
176
|
+
if stored.credential.api_url.rstrip("/") != deps.manifold_api_url.rstrip("/"):
|
|
177
|
+
mismatch = RunnerDeploymentMismatchError(
|
|
178
|
+
stored.credential.api_url, deps.manifold_api_url
|
|
179
|
+
)
|
|
180
|
+
console.print(f"[red]{escape(str(mismatch))}[/red]")
|
|
181
|
+
command = join(
|
|
182
|
+
(
|
|
183
|
+
"env",
|
|
184
|
+
"-u",
|
|
185
|
+
"MANIFOLD_TOKEN",
|
|
186
|
+
f"MANIFOLD_API_URL={stored.credential.api_url}",
|
|
187
|
+
"manifold",
|
|
188
|
+
"runner",
|
|
189
|
+
"unregister",
|
|
190
|
+
"--runner",
|
|
191
|
+
stored.name,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
console.print(f"Run `{escape(command)}`.", soft_wrap=True)
|
|
195
|
+
raise typer.Exit(1)
|
|
196
|
+
try:
|
|
197
|
+
deps.runner_daemon_service.uninstall(home)
|
|
198
|
+
except RunnerDaemonServiceError as exc:
|
|
199
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
200
|
+
raise typer.Exit(1) from exc
|
|
201
|
+
try:
|
|
202
|
+
revoked = deps.runner_service.revoke(
|
|
203
|
+
stored.runner_id, organization=organization
|
|
204
|
+
)
|
|
205
|
+
except RunnerNotFoundError as exc:
|
|
206
|
+
console.print("[red]Runner service removed, but revoke failed.[/red]")
|
|
207
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
208
|
+
_print_unregister_retry(
|
|
209
|
+
console, name=stored.name, organization=organization
|
|
210
|
+
)
|
|
211
|
+
raise typer.Exit(1) from exc
|
|
212
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
213
|
+
console.print("[red]Runner service removed, but revoke failed.[/red]")
|
|
214
|
+
_print_unregister_retry(
|
|
215
|
+
console, name=stored.name, organization=organization
|
|
216
|
+
)
|
|
217
|
+
raise exit_for_call_error(console, exc) from exc
|
|
218
|
+
home.clear()
|
|
219
|
+
console.print(
|
|
220
|
+
f"[green]✓ Runner unregistered[/green] "
|
|
221
|
+
f"[bold]{revoked.id}[/bold] {escape(revoked.name)}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@runner_app.command("list")
|
|
225
|
+
def list_runners(
|
|
226
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""List the active runners, alphabetically."""
|
|
229
|
+
try:
|
|
230
|
+
runners = deps.runner_service.list(organization=organization)
|
|
231
|
+
except OrganizationNotFoundError as exc:
|
|
232
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
233
|
+
raise typer.Exit(1) from exc
|
|
234
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
235
|
+
raise exit_for_call_error(console, exc) from exc
|
|
236
|
+
if len(runners) == 0:
|
|
237
|
+
register_command = _command(
|
|
238
|
+
"runner",
|
|
239
|
+
"register",
|
|
240
|
+
"NAME",
|
|
241
|
+
"--gpu-model",
|
|
242
|
+
"GPU_MODEL",
|
|
243
|
+
*_org_arguments(organization),
|
|
244
|
+
)
|
|
245
|
+
console.print(
|
|
246
|
+
f"There are no runners yet. Register one with: "
|
|
247
|
+
f"`{escape(register_command)}`"
|
|
248
|
+
)
|
|
249
|
+
return
|
|
250
|
+
active_runners = sorted(
|
|
251
|
+
(runner for runner in runners if runner.revoked_at is None),
|
|
252
|
+
key=lambda runner: runner.name.casefold(),
|
|
253
|
+
)
|
|
254
|
+
if len(active_runners) == 0:
|
|
255
|
+
console.print("There are no active runners.")
|
|
256
|
+
return
|
|
257
|
+
console.print(_runner_summary(active_runners, console.width))
|
|
258
|
+
|
|
259
|
+
@runner_app.command()
|
|
260
|
+
def show(
|
|
261
|
+
name: Annotated[str, typer.Argument(help="Runner name.")],
|
|
262
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Show one active runner's details."""
|
|
265
|
+
runner = _resolve_runner(console, deps.runner_service, name, organization)
|
|
266
|
+
console.print(_runner_details(runner))
|
|
267
|
+
|
|
268
|
+
@runner_app.command("keep-running")
|
|
269
|
+
def keep_running(
|
|
270
|
+
name: Annotated[str, typer.Argument(help="Runner name.")],
|
|
271
|
+
until: Annotated[
|
|
272
|
+
datetime,
|
|
273
|
+
typer.Option(help=_UNTIL_HELP, parser=_parse_until, metavar="TIME"),
|
|
274
|
+
],
|
|
275
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Have the runner instance service start a managed runner's instance and
|
|
278
|
+
keep it running until the given time, for maintenance or to add capacity
|
|
279
|
+
ahead of runs.
|
|
280
|
+
|
|
281
|
+
The runner instance service starts the instance if it is stopped and does
|
|
282
|
+
not stop it before --until, even while the runner is idle or its daemon
|
|
283
|
+
is not responding. Placement still assigns runs to the runner; add
|
|
284
|
+
`pause-placement` to keep runs off it during maintenance.
|
|
285
|
+
"""
|
|
286
|
+
runner = _resolve_runner(console, deps.runner_service, name, organization)
|
|
287
|
+
try:
|
|
288
|
+
updated = deps.runner_service.keep_running(
|
|
289
|
+
runner.id, until=until, organization=organization
|
|
290
|
+
)
|
|
291
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
292
|
+
raise exit_for_call_error(console, exc) from exc
|
|
293
|
+
console.print(_runner_overrides(updated, datetime.now(UTC)))
|
|
294
|
+
|
|
295
|
+
@runner_app.command("keep-stopped")
|
|
296
|
+
def keep_stopped(
|
|
297
|
+
name: Annotated[str, typer.Argument(help="Runner name.")],
|
|
298
|
+
until: Annotated[
|
|
299
|
+
datetime | None,
|
|
300
|
+
typer.Option(
|
|
301
|
+
help=f"{_UNTIL_HELP} Omit it to keep the override in effect until "
|
|
302
|
+
"`clear-overrides`.",
|
|
303
|
+
parser=_parse_until,
|
|
304
|
+
metavar="TIME",
|
|
305
|
+
),
|
|
306
|
+
] = None,
|
|
307
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Stop the runner instance service from starting a managed runner's
|
|
310
|
+
instance, for example to take a broken VM out of the fleet.
|
|
311
|
+
|
|
312
|
+
Setting the override does not stop an instance that is already running,
|
|
313
|
+
but the service still stops it once it is idle or unresponsive. Add
|
|
314
|
+
`pause-placement` to keep runs off the runner as well.
|
|
315
|
+
"""
|
|
316
|
+
runner = _resolve_runner(console, deps.runner_service, name, organization)
|
|
317
|
+
try:
|
|
318
|
+
updated = deps.runner_service.keep_stopped(
|
|
319
|
+
runner.id, until=until, organization=organization
|
|
320
|
+
)
|
|
321
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
322
|
+
raise exit_for_call_error(console, exc) from exc
|
|
323
|
+
console.print(_runner_overrides(updated, datetime.now(UTC)))
|
|
324
|
+
|
|
325
|
+
@runner_app.command("pause-placement")
|
|
326
|
+
def pause_placement(
|
|
327
|
+
name: Annotated[str, typer.Argument(help="Runner name.")],
|
|
328
|
+
until: Annotated[
|
|
329
|
+
datetime,
|
|
330
|
+
typer.Option(help=_UNTIL_HELP, parser=_parse_until, metavar="TIME"),
|
|
331
|
+
],
|
|
332
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Stop placement from assigning runs to a runner until the given time.
|
|
335
|
+
|
|
336
|
+
Works on any runner, including one that Bifrost manages manually. A run
|
|
337
|
+
already on the runner keeps running until it finishes.
|
|
338
|
+
"""
|
|
339
|
+
runner = _resolve_runner(console, deps.runner_service, name, organization)
|
|
340
|
+
try:
|
|
341
|
+
updated = deps.runner_service.pause_placement(
|
|
342
|
+
runner.id, until=until, organization=organization
|
|
343
|
+
)
|
|
344
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
345
|
+
raise exit_for_call_error(console, exc) from exc
|
|
346
|
+
console.print(_runner_overrides(updated, datetime.now(UTC)))
|
|
347
|
+
|
|
348
|
+
@runner_app.command("clear-overrides")
|
|
349
|
+
def clear_overrides(
|
|
350
|
+
name: Annotated[str, typer.Argument(help="Runner name.")],
|
|
351
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Clear a runner's instance override and resume placement on it.
|
|
354
|
+
|
|
355
|
+
After a `keep-running` override is cleared, the runner instance service
|
|
356
|
+
stops the instance once it has been idle for the idle timeout, counted
|
|
357
|
+
from the clear, or once its daemon has not been seen within the liveness
|
|
358
|
+
window.
|
|
359
|
+
"""
|
|
360
|
+
runner = _resolve_runner(console, deps.runner_service, name, organization)
|
|
361
|
+
try:
|
|
362
|
+
updated = deps.runner_service.clear_overrides(
|
|
363
|
+
runner.id, organization=organization
|
|
364
|
+
)
|
|
365
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
366
|
+
raise exit_for_call_error(console, exc) from exc
|
|
367
|
+
console.print(_runner_overrides(updated, datetime.now(UTC)))
|
|
368
|
+
|
|
369
|
+
@runner_app.command()
|
|
370
|
+
def revoke(
|
|
371
|
+
runner_id: Annotated[UUID, typer.Argument(help="Runner id.")],
|
|
372
|
+
organization: Annotated[str | None, ORG_OPTION] = None,
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Retire a runner's credential."""
|
|
375
|
+
try:
|
|
376
|
+
runner = deps.runner_service.revoke(runner_id, organization=organization)
|
|
377
|
+
except RunnerNotFoundError as exc:
|
|
378
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
379
|
+
raise typer.Exit(1) from exc
|
|
380
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
381
|
+
raise exit_for_call_error(console, exc) from exc
|
|
382
|
+
console.print(
|
|
383
|
+
f"[green]✓ Runner revoked[/green] [bold]{runner.id}[/bold] "
|
|
384
|
+
f"{escape(runner.name)}"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _register_runner(
|
|
389
|
+
*,
|
|
390
|
+
name: str,
|
|
391
|
+
force: bool,
|
|
392
|
+
cache_dir: Path | None,
|
|
393
|
+
advertise_host: str | None,
|
|
394
|
+
gpu_model: GpuModel,
|
|
395
|
+
provider: RunnerProvider | None,
|
|
396
|
+
is_preemptible: bool,
|
|
397
|
+
organization: str | None,
|
|
398
|
+
access_token: str | None,
|
|
399
|
+
runner_service: RunnerService,
|
|
400
|
+
deps: Dependencies,
|
|
401
|
+
console: Console,
|
|
402
|
+
) -> None:
|
|
403
|
+
registered = deps.runner_homes.named(name)
|
|
404
|
+
if registered is not None and not force:
|
|
405
|
+
# NOTE: a home path is read to be typed at, and Rich would wrap it
|
|
406
|
+
# on a narrow terminal and put a newline through it. soft_wrap
|
|
407
|
+
# leaves the line whole and lets the terminal reflow it.
|
|
408
|
+
console.print(
|
|
409
|
+
f"[red]A runner is already registered at "
|
|
410
|
+
f"{escape(str(registered.home.path))}.[/red]",
|
|
411
|
+
soft_wrap=True,
|
|
412
|
+
)
|
|
413
|
+
if access_token is not None:
|
|
414
|
+
console.print("The existing registration remains.")
|
|
415
|
+
_print_operator_recovery(
|
|
416
|
+
console,
|
|
417
|
+
name=registered.stored.name,
|
|
418
|
+
gpu_model=gpu_model,
|
|
419
|
+
organization=organization,
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
console.print(
|
|
423
|
+
"Register over it with --force, which revokes the runner it holds."
|
|
424
|
+
)
|
|
425
|
+
raise typer.Exit(1)
|
|
426
|
+
if registered is not None:
|
|
427
|
+
stored = registered.stored.credential.api_url
|
|
428
|
+
if stored != deps.manifold_api_url:
|
|
429
|
+
mismatch = RunnerDeploymentMismatchError(stored, deps.manifold_api_url)
|
|
430
|
+
console.print(f"[red]{escape(str(mismatch))}[/red]")
|
|
431
|
+
unregister = join(
|
|
432
|
+
(
|
|
433
|
+
"env",
|
|
434
|
+
"-u",
|
|
435
|
+
"MANIFOLD_TOKEN",
|
|
436
|
+
f"MANIFOLD_API_URL={stored}",
|
|
437
|
+
"manifold",
|
|
438
|
+
"runner",
|
|
439
|
+
"unregister",
|
|
440
|
+
"--runner",
|
|
441
|
+
registered.stored.name,
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
retry = _command(
|
|
445
|
+
"runner",
|
|
446
|
+
"register",
|
|
447
|
+
name,
|
|
448
|
+
"--gpu-model",
|
|
449
|
+
gpu_model,
|
|
450
|
+
*_org_arguments(organization),
|
|
451
|
+
)
|
|
452
|
+
console.print(
|
|
453
|
+
"Revoking from here would retire a runner on the other deployment. "
|
|
454
|
+
f"Run `{escape(unregister)}`, then retry "
|
|
455
|
+
f"`{escape(retry)}`.",
|
|
456
|
+
soft_wrap=True,
|
|
457
|
+
)
|
|
458
|
+
raise typer.Exit(1)
|
|
459
|
+
if access_token is not None:
|
|
460
|
+
console.print(
|
|
461
|
+
"[red]The manifold runner register command uses MANIFOLD_TOKEN "
|
|
462
|
+
"only for registration and does not route revoke through that "
|
|
463
|
+
"client.[/red]"
|
|
464
|
+
)
|
|
465
|
+
console.print("The existing registration remains.")
|
|
466
|
+
_print_operator_recovery(
|
|
467
|
+
console,
|
|
468
|
+
name=registered.stored.name,
|
|
469
|
+
gpu_model=gpu_model,
|
|
470
|
+
organization=organization,
|
|
471
|
+
)
|
|
472
|
+
raise typer.Exit(1)
|
|
473
|
+
try:
|
|
474
|
+
# Revoke before minting: the server's name uniqueness covers
|
|
475
|
+
# unrevoked runners only, so the name stays taken until the one
|
|
476
|
+
# holding it is retired. The home goes with it, since the runner
|
|
477
|
+
# minted next keys a directory of its own.
|
|
478
|
+
if registered is not None:
|
|
479
|
+
deps.runner_daemon_service.uninstall(registered.home)
|
|
480
|
+
runner_service.revoke(
|
|
481
|
+
registered.stored.runner_id, organization=organization
|
|
482
|
+
)
|
|
483
|
+
registered.home.clear()
|
|
484
|
+
facts = gpu_facts()
|
|
485
|
+
minted = runner_service.mint(
|
|
486
|
+
name,
|
|
487
|
+
ram_gb=host_ram_gb(),
|
|
488
|
+
gpu_memory_gb=gpu_memory_gb(facts.devices),
|
|
489
|
+
gpu_devices=facts.devices,
|
|
490
|
+
gpu_model=gpu_model,
|
|
491
|
+
provider=provider,
|
|
492
|
+
is_preemptible=is_preemptible,
|
|
493
|
+
organization=organization,
|
|
494
|
+
)
|
|
495
|
+
except RunnerNameTakenError as exc:
|
|
496
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
497
|
+
org_arguments = _org_arguments(organization)
|
|
498
|
+
list_command = _command("runner", "list", *org_arguments)
|
|
499
|
+
revoke_command = _command("runner", "revoke", "RUNNER_ID", *org_arguments)
|
|
500
|
+
retry_command = _command(
|
|
501
|
+
"runner", "register", name, "--gpu-model", gpu_model, *org_arguments
|
|
502
|
+
)
|
|
503
|
+
if access_token is not None:
|
|
504
|
+
console.print(
|
|
505
|
+
"The manifold runner register command uses MANIFOLD_TOKEN only "
|
|
506
|
+
"for registration and does not route revoke through that client. "
|
|
507
|
+
f"Ask an operator to run `{escape(list_command)}`, substitute the "
|
|
508
|
+
f"real ID for RUNNER_ID in `{escape(revoke_command)}`, then run "
|
|
509
|
+
f"`{escape(retry_command)}`.",
|
|
510
|
+
soft_wrap=True,
|
|
511
|
+
)
|
|
512
|
+
else:
|
|
513
|
+
console.print(
|
|
514
|
+
"Another box in this or another organization holds that name, or "
|
|
515
|
+
"its home was removed without revoking it. Run "
|
|
516
|
+
f"`{escape(list_command)}`, substitute the real ID "
|
|
517
|
+
f"for RUNNER_ID in `{escape(revoke_command)}`, then run "
|
|
518
|
+
f"`{escape(retry_command)}`.",
|
|
519
|
+
soft_wrap=True,
|
|
520
|
+
)
|
|
521
|
+
raise typer.Exit(1) from exc
|
|
522
|
+
except (RunnerNotFoundError, OrganizationNotFoundError) as exc:
|
|
523
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
524
|
+
raise typer.Exit(1) from exc
|
|
525
|
+
except NotLoggedInError as exc:
|
|
526
|
+
console.print("No registration credential is available.")
|
|
527
|
+
console.print(
|
|
528
|
+
"Export MANIFOLD_TOKEN or run `manifold auth login` to store an "
|
|
529
|
+
"operator credential.",
|
|
530
|
+
soft_wrap=True,
|
|
531
|
+
)
|
|
532
|
+
raise typer.Exit(1) from exc
|
|
533
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
534
|
+
raise exit_for_call_error(console, exc) from exc
|
|
535
|
+
except AccessTokenRejectedError as exc:
|
|
536
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
537
|
+
console.print(
|
|
538
|
+
"Check that MANIFOLD_TOKEN was created by the deployment configured "
|
|
539
|
+
"in MANIFOLD_API_URL.",
|
|
540
|
+
soft_wrap=True,
|
|
541
|
+
)
|
|
542
|
+
raise typer.Exit(1) from exc
|
|
543
|
+
except RunnerDaemonServiceError as exc:
|
|
544
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
545
|
+
raise typer.Exit(1) from exc
|
|
546
|
+
home = deps.runner_homes.for_runner(minted.runner.id)
|
|
547
|
+
home.save(
|
|
548
|
+
RunnerHome(
|
|
549
|
+
name=name,
|
|
550
|
+
runner_id=minted.runner.id,
|
|
551
|
+
credential=RunnerCredential(
|
|
552
|
+
api_url=deps.manifold_api_url, token=minted.token
|
|
553
|
+
),
|
|
554
|
+
box=BoxFacts(
|
|
555
|
+
gpu_devices=[device.index for device in facts.devices],
|
|
556
|
+
cache_dir=cache_dir,
|
|
557
|
+
advertise_host=advertise_host,
|
|
558
|
+
),
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
try:
|
|
562
|
+
unit_name = deps.runner_daemon_service.install(home)
|
|
563
|
+
except RunnerDaemonServiceError as exc:
|
|
564
|
+
if access_token is not None:
|
|
565
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
566
|
+
console.print("The incomplete registration remains.")
|
|
567
|
+
_print_operator_recovery(
|
|
568
|
+
console, name=name, gpu_model=gpu_model, organization=organization
|
|
569
|
+
)
|
|
570
|
+
raise typer.Exit(1) from exc
|
|
571
|
+
try:
|
|
572
|
+
runner_service.revoke(minted.runner.id, organization=organization)
|
|
573
|
+
except (RunnerNotFoundError, *ORGANIZATION_CALL_ERRORS):
|
|
574
|
+
retry_command = _command(
|
|
575
|
+
"runner",
|
|
576
|
+
"register",
|
|
577
|
+
name,
|
|
578
|
+
"--gpu-model",
|
|
579
|
+
gpu_model,
|
|
580
|
+
"--force",
|
|
581
|
+
*_org_arguments(organization),
|
|
582
|
+
)
|
|
583
|
+
console.print(
|
|
584
|
+
"[red]The runner was registered, but its service could not "
|
|
585
|
+
"start and the registration could not be revoked.[/red]"
|
|
586
|
+
)
|
|
587
|
+
console.print(f"Fix the service error, then run `{escape(retry_command)}`.")
|
|
588
|
+
raise typer.Exit(1) from exc
|
|
589
|
+
home.clear()
|
|
590
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
591
|
+
console.print("The incomplete registration was revoked.")
|
|
592
|
+
raise typer.Exit(1) from exc
|
|
593
|
+
console.print(
|
|
594
|
+
f"[green]✓ Runner registered[/green] [bold]{minted.runner.id}[/bold] "
|
|
595
|
+
f"{escape(minted.runner.name)}"
|
|
596
|
+
)
|
|
597
|
+
console.print(f"[dim]home[/dim] {escape(str(home.path))}", soft_wrap=True)
|
|
598
|
+
console.print(f"[dim]service[/dim] {unit_name}")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _print_operator_recovery(
|
|
602
|
+
console: Console, *, name: str, gpu_model: GpuModel, organization: str | None
|
|
603
|
+
) -> None:
|
|
604
|
+
org_arguments = _org_arguments(organization)
|
|
605
|
+
unregister = _command("runner", "unregister", "--runner", name, *org_arguments)
|
|
606
|
+
register = _command(
|
|
607
|
+
"runner", "register", name, "--gpu-model", gpu_model, *org_arguments
|
|
608
|
+
)
|
|
609
|
+
console.print("An operator must unregister this runner and register again:")
|
|
610
|
+
console.print(f"`{escape(unregister)}`")
|
|
611
|
+
console.print(f"`{escape(register)}`")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _print_unregister_retry(
|
|
615
|
+
console: Console, *, name: str, organization: str | None
|
|
616
|
+
) -> None:
|
|
617
|
+
retry = _command(
|
|
618
|
+
"runner", "unregister", "--runner", name, *_org_arguments(organization)
|
|
619
|
+
)
|
|
620
|
+
console.print(f"The runner home remains. Retry with `{escape(retry)}`.")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _command(*arguments: str) -> str:
|
|
624
|
+
return join(("manifold", *arguments))
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _org_arguments(organization: str | None) -> tuple[str, ...]:
|
|
628
|
+
if organization is None:
|
|
629
|
+
return ()
|
|
630
|
+
return ("--org", organization)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _resolve_runner(
|
|
634
|
+
console: Console,
|
|
635
|
+
runner_service: RunnerService,
|
|
636
|
+
name: str,
|
|
637
|
+
organization: str | None,
|
|
638
|
+
) -> ListedRunner:
|
|
639
|
+
"""The active runner with the given name, or exit after reporting why it
|
|
640
|
+
could not be found."""
|
|
641
|
+
try:
|
|
642
|
+
runners = runner_service.list(organization=organization)
|
|
643
|
+
except OrganizationNotFoundError as exc:
|
|
644
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
645
|
+
raise typer.Exit(1) from exc
|
|
646
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
647
|
+
raise exit_for_call_error(console, exc) from exc
|
|
648
|
+
runner = _find_active_runner(runners, name)
|
|
649
|
+
if runner is None:
|
|
650
|
+
console.print(f"[red]Runner '{escape(name)}' not found.[/red]")
|
|
651
|
+
raise typer.Exit(1)
|
|
652
|
+
return runner
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _parse_until(value: str) -> datetime:
|
|
656
|
+
try:
|
|
657
|
+
until = datetime.fromisoformat(value)
|
|
658
|
+
except ValueError as exc:
|
|
659
|
+
raise typer.BadParameter(f"'{value}' is not an ISO 8601 timestamp") from exc
|
|
660
|
+
# `astimezone` converts a naive timestamp from local time.
|
|
661
|
+
return until.astimezone(UTC)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _runner_overrides(runner: Runner, now: datetime) -> Table:
|
|
665
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
666
|
+
table.add_column(style="dim", no_wrap=True)
|
|
667
|
+
table.add_column(overflow="fold")
|
|
668
|
+
table.add_row("name", escape(runner.name))
|
|
669
|
+
table.add_row("instance", _instance_override(runner, now))
|
|
670
|
+
table.add_row("placement", _placement(runner, now))
|
|
671
|
+
return table
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _runner_summary(runners: list[ListedRunner], width: int) -> Table:
|
|
675
|
+
if width < 80:
|
|
676
|
+
return _narrow_runner_summary(runners)
|
|
677
|
+
|
|
678
|
+
table = Table(box=None, pad_edge=False)
|
|
679
|
+
table.add_column("Name", overflow="fold")
|
|
680
|
+
table.add_column("Status", overflow="fold")
|
|
681
|
+
table.add_column("RAM", overflow="fold")
|
|
682
|
+
table.add_column("GPU", overflow="fold")
|
|
683
|
+
table.add_column("Overrides", overflow="fold")
|
|
684
|
+
table.add_column("Preemptible", overflow="fold")
|
|
685
|
+
now = datetime.now(UTC)
|
|
686
|
+
for runner in runners:
|
|
687
|
+
table.add_row(
|
|
688
|
+
f"[bold]{escape(runner.name)}[/bold]",
|
|
689
|
+
_status(runner),
|
|
690
|
+
_ram(runner),
|
|
691
|
+
_gpu_summary(runner),
|
|
692
|
+
_overrides_summary(runner, now),
|
|
693
|
+
_preemptible(runner),
|
|
694
|
+
)
|
|
695
|
+
return table
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _narrow_runner_summary(runners: list[ListedRunner]) -> Table:
|
|
699
|
+
now = datetime.now(UTC)
|
|
700
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
701
|
+
table.add_column(style="dim", no_wrap=True)
|
|
702
|
+
table.add_column(overflow="fold")
|
|
703
|
+
for runner in runners:
|
|
704
|
+
table.add_row("name", f"[bold]{escape(runner.name)}[/bold]")
|
|
705
|
+
table.add_row("status", _status(runner))
|
|
706
|
+
table.add_row("ram", _ram(runner))
|
|
707
|
+
table.add_row("gpu", _gpu_summary(runner))
|
|
708
|
+
table.add_row("overrides", _overrides_summary(runner, now))
|
|
709
|
+
table.add_row("preemptible", _preemptible(runner), end_section=True)
|
|
710
|
+
return table
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _find_active_runner(runners: list[ListedRunner], name: str) -> ListedRunner | None:
|
|
714
|
+
return next(
|
|
715
|
+
(
|
|
716
|
+
runner
|
|
717
|
+
for runner in runners
|
|
718
|
+
if runner.name == name and runner.revoked_at is None
|
|
719
|
+
),
|
|
720
|
+
None,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _runner_details(runner: ListedRunner) -> Table:
|
|
725
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
726
|
+
table.add_column(style="dim", no_wrap=True)
|
|
727
|
+
table.add_column(overflow="fold")
|
|
728
|
+
table.add_row("name", escape(runner.name))
|
|
729
|
+
table.add_row("runner id", str(runner.id))
|
|
730
|
+
table.add_row("created", f"{runner.created_at:%Y-%m-%d %H:%M}")
|
|
731
|
+
table.add_row("status", _status(runner))
|
|
732
|
+
now = datetime.now(UTC)
|
|
733
|
+
table.add_row("last seen", _last_seen_details(runner, now))
|
|
734
|
+
table.add_row("ram", _ram(runner))
|
|
735
|
+
table.add_row("gpus", _gpus(runner))
|
|
736
|
+
table.add_row("preemptible", _preemptible(runner))
|
|
737
|
+
table.add_row("instance", _instance_override(runner, now))
|
|
738
|
+
table.add_row("placement", _placement(runner, now))
|
|
739
|
+
return table
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _status(runner: ListedRunner) -> str:
|
|
743
|
+
if not isinstance(runner.status, RunnerStatus):
|
|
744
|
+
return escape(runner.status)
|
|
745
|
+
if runner.status is RunnerStatus.IDLE:
|
|
746
|
+
return "[green]idle[/]"
|
|
747
|
+
elif runner.status is RunnerStatus.BUSY:
|
|
748
|
+
return "[cyan]busy[/]"
|
|
749
|
+
elif runner.status is RunnerStatus.STARTING:
|
|
750
|
+
return "[cyan]starting[/]"
|
|
751
|
+
elif runner.status is RunnerStatus.NEVER_CONTACTED:
|
|
752
|
+
return "[yellow]never contacted[/]"
|
|
753
|
+
elif runner.status is RunnerStatus.OFFLINE:
|
|
754
|
+
return "[red]offline[/]"
|
|
755
|
+
elif runner.status is RunnerStatus.REVOKED:
|
|
756
|
+
return "[red]revoked[/]"
|
|
757
|
+
else:
|
|
758
|
+
assert_never(runner.status)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _overrides_summary(runner: Runner, now: datetime) -> str:
|
|
762
|
+
"""The overrides in effect, without their end times. `show` prints the end
|
|
763
|
+
times."""
|
|
764
|
+
overrides = []
|
|
765
|
+
instance_override = runner.instance_override
|
|
766
|
+
if instance_override is not None and instance_override.is_in_effect(now):
|
|
767
|
+
overrides.append(_override_type(instance_override.override_type))
|
|
768
|
+
if runner.is_placement_paused(now):
|
|
769
|
+
overrides.append("placement paused")
|
|
770
|
+
if len(overrides) == 0:
|
|
771
|
+
return "none"
|
|
772
|
+
return ", ".join(overrides)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _instance_override(runner: Runner, now: datetime) -> str:
|
|
776
|
+
instance_override = runner.instance_override
|
|
777
|
+
if instance_override is None or not instance_override.is_in_effect(now):
|
|
778
|
+
return "none"
|
|
779
|
+
override_type = _override_type(instance_override.override_type)
|
|
780
|
+
if instance_override.until is None:
|
|
781
|
+
return f"{override_type} until cleared"
|
|
782
|
+
return f"{override_type} until {_local_time(instance_override.until)}"
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _placement(runner: Runner, now: datetime) -> str:
|
|
786
|
+
if runner.placement_paused_until is None or not runner.is_placement_paused(now):
|
|
787
|
+
return "open"
|
|
788
|
+
return f"paused until {_local_time(runner.placement_paused_until)}"
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _override_type(override_type: InstanceOverrideType | str) -> str:
|
|
792
|
+
if not isinstance(override_type, InstanceOverrideType):
|
|
793
|
+
return escape(override_type)
|
|
794
|
+
if override_type is InstanceOverrideType.KEEP_RUNNING:
|
|
795
|
+
return "keep running"
|
|
796
|
+
elif override_type is InstanceOverrideType.KEEP_STOPPED:
|
|
797
|
+
return "keep stopped"
|
|
798
|
+
else:
|
|
799
|
+
assert_never(override_type)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _local_time(instant: datetime) -> str:
|
|
803
|
+
return f"{instant.astimezone():%Y-%m-%d %H:%M %Z}"
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _last_seen_relative(runner: Runner, now: datetime) -> str:
|
|
807
|
+
if runner.last_seen_at is None:
|
|
808
|
+
return "never"
|
|
809
|
+
# A report stamped ahead of this clock is skew, not a sighting yet to come.
|
|
810
|
+
return relative_time(min(runner.last_seen_at, now), now)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _last_seen_details(runner: Runner, now: datetime) -> str:
|
|
814
|
+
if runner.last_seen_at is None:
|
|
815
|
+
return "never"
|
|
816
|
+
relative = _last_seen_relative(runner, now)
|
|
817
|
+
exact = runner.last_seen_at.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
818
|
+
return f"{relative} ({exact})"
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _ram(runner: Runner) -> str:
|
|
822
|
+
if runner.ram_gb is None:
|
|
823
|
+
return "unknown"
|
|
824
|
+
return f"{runner.ram_gb} GB"
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _gpus(runner: Runner) -> str:
|
|
828
|
+
if runner.gpu_device_count == 0:
|
|
829
|
+
return "none"
|
|
830
|
+
return ", ".join(
|
|
831
|
+
f"{device.index}: {escape(device.name)} ({device.memory_gb} GB)"
|
|
832
|
+
for device in runner.gpu_devices
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _gpu_summary(runner: Runner) -> str:
|
|
837
|
+
if runner.gpu_device_count == 0:
|
|
838
|
+
return "none"
|
|
839
|
+
if len(runner.gpu_devices) == 0:
|
|
840
|
+
return str(runner.gpu_device_count)
|
|
841
|
+
first = runner.gpu_devices[0]
|
|
842
|
+
if any(
|
|
843
|
+
device.name != first.name or device.memory_gb != first.memory_gb
|
|
844
|
+
for device in runner.gpu_devices[1:]
|
|
845
|
+
):
|
|
846
|
+
return f"{runner.gpu_device_count} GPUs"
|
|
847
|
+
count = "" if runner.gpu_device_count == 1 else f"{runner.gpu_device_count} x "
|
|
848
|
+
return f"{count}{escape(first.name)} ({first.memory_gb} GB)"
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _preemptible(runner: Runner) -> str:
|
|
852
|
+
return "yes" if runner.is_preemptible else "no"
|