omnirun 0.2.1__tar.gz → 0.2.3__tar.gz
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.
- {omnirun-0.2.1 → omnirun-0.2.3}/.github/workflows/publish.yml +3 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/PKG-INFO +5 -2
- {omnirun-0.2.1 → omnirun-0.2.3}/README.md +4 -1
- {omnirun-0.2.1 → omnirun-0.2.3}/flake.nix +3 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/pyproject.toml +1 -1
- omnirun-0.2.3/scripts/check-version.sh +32 -0
- omnirun-0.2.3/scripts/pre-push +39 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/__init__.py +1 -1
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/base.py +18 -1
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/colab.py +10 -3
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/kaggle.py +10 -3
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/local.py +11 -3
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/marketplace.py +63 -13
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/slurm.py +11 -4
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/ssh.py +8 -3
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/vast.py +15 -9
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/cli.py +18 -8
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/config.py +13 -2
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/daemon.py +16 -9
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/repo.py +62 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_cli.py +77 -3
- omnirun-0.2.3/tests/test_config.py +37 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_marketplaces.py +114 -1
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_queue.py +7 -2
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_repo.py +52 -1
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_slurm.py +26 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/uv.lock +1 -1
- {omnirun-0.2.1 → omnirun-0.2.3}/.envrc +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/.github/workflows/checks.yml +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/.github/workflows/ci.yml +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/.gitignore +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/.python-version +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/AGENTS.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/CLAUDE.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/DESIGN.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/TESTING.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/flake.lock +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/main.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/research/colab-kaggle.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/research/gpu-marketplaces.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/research/launcher-landscape.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/research/ml-job-queues.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/research/slurm-ssh.md +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/__init__.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/jobdir.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/runpod.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/tarsafe.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/backends/thunder.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/bootstrap.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/chooser.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/execlayer/__init__.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/execlayer/base.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/execlayer/local.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/execlayer/ssh.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/models.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/queue.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/src/omnirun/store.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/__init__.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/conftest.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_bootstrap.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_chooser.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_colab.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_kaggle.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_local_backend.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_ssh_backend.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_ssh_exec.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_store.py +0 -0
- {omnirun-0.2.1 → omnirun-0.2.3}/tests/test_tarsafe.py +0 -0
|
@@ -22,6 +22,9 @@ jobs:
|
|
|
22
22
|
- name: Checkout
|
|
23
23
|
uses: actions/checkout@v7
|
|
24
24
|
|
|
25
|
+
- name: Check tag matches package version
|
|
26
|
+
run: ./scripts/check-version.sh "${GITHUB_REF_NAME}"
|
|
27
|
+
|
|
25
28
|
- name: Install uv
|
|
26
29
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
27
30
|
with:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: omnirun
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Run jobs from your repo anywhere: Slurm over SSH, any SSH box, Kaggle, Colab, or marketplace GPUs
|
|
5
5
|
Author-email: Dmitrii Mukhutdinov <flyingleafe@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -140,7 +140,9 @@ section name is yours (you can have several of the same type). All backends
|
|
|
140
140
|
accept `enabled = false` to keep the config without probing it. Every backend
|
|
141
141
|
also accepts `project_root` (point it at an existing checkout to reuse its
|
|
142
142
|
`.git`/`.venv`) and `max_parallel` (queue-daemon cap on concurrent jobs, default
|
|
143
|
-
`1`).
|
|
143
|
+
`1`). `project_root` may be a single path (applied to every repo) or a table
|
|
144
|
+
mapping repo slug -> path, with an optional `default` key as fallback, so one
|
|
145
|
+
backend can serve several repos (each getting the right checkout).
|
|
144
146
|
|
|
145
147
|
```toml
|
|
146
148
|
[policy]
|
|
@@ -159,6 +161,7 @@ poll_interval_s = 10.0 # scheduler tick: refresh running + place pending
|
|
|
159
161
|
type = "local"
|
|
160
162
|
# root = "$HOME/.omnirun" # OMNIRUN_ROOT: where jobs live
|
|
161
163
|
# project_root = "$HOME/proj" # reuse an existing checkout's .git and .venv
|
|
164
|
+
# project_root = { my-repo = "$HOME/proj" } # or per-repo (by slug), else default path
|
|
162
165
|
# max_parallel = 2 # queue daemon: run up to 2 jobs here at once
|
|
163
166
|
|
|
164
167
|
# ---- any machine you can ssh into ----
|
|
@@ -120,7 +120,9 @@ section name is yours (you can have several of the same type). All backends
|
|
|
120
120
|
accept `enabled = false` to keep the config without probing it. Every backend
|
|
121
121
|
also accepts `project_root` (point it at an existing checkout to reuse its
|
|
122
122
|
`.git`/`.venv`) and `max_parallel` (queue-daemon cap on concurrent jobs, default
|
|
123
|
-
`1`).
|
|
123
|
+
`1`). `project_root` may be a single path (applied to every repo) or a table
|
|
124
|
+
mapping repo slug -> path, with an optional `default` key as fallback, so one
|
|
125
|
+
backend can serve several repos (each getting the right checkout).
|
|
124
126
|
|
|
125
127
|
```toml
|
|
126
128
|
[policy]
|
|
@@ -139,6 +141,7 @@ poll_interval_s = 10.0 # scheduler tick: refresh running + place pending
|
|
|
139
141
|
type = "local"
|
|
140
142
|
# root = "$HOME/.omnirun" # OMNIRUN_ROOT: where jobs live
|
|
141
143
|
# project_root = "$HOME/proj" # reuse an existing checkout's .git and .venv
|
|
144
|
+
# project_root = { my-repo = "$HOME/proj" } # or per-repo (by slug), else default path
|
|
142
145
|
# max_parallel = 2 # queue daemon: run up to 2 jobs here at once
|
|
143
146
|
|
|
144
147
|
# ---- any machine you can ssh into ----
|
|
@@ -71,6 +71,9 @@
|
|
|
71
71
|
|
|
72
72
|
shellHook = ''
|
|
73
73
|
${pre-commit-check.shellHook}
|
|
74
|
+
# Guard against tagging a release without bumping the version.
|
|
75
|
+
# pre-commit owns only the pre-commit hook, so pre-push is ours.
|
|
76
|
+
ln -sf "$PWD/scripts/pre-push" "$(git rev-parse --git-path hooks)/pre-push"
|
|
74
77
|
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.zlib ]}:$LD_LIBRARY_PATH
|
|
75
78
|
export UV_PYTHON=${python}/bin/python
|
|
76
79
|
uv sync --quiet
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Verify the package version is internally consistent, and optionally that it
|
|
3
|
+
# matches an expected version (e.g. a release tag).
|
|
4
|
+
#
|
|
5
|
+
# scripts/check-version.sh # pyproject.toml == src/omnirun/__init__.py
|
|
6
|
+
# scripts/check-version.sh v0.2.2 # ...and both equal 0.2.2
|
|
7
|
+
#
|
|
8
|
+
# Used by both the publish workflow and the pre-push hook so the rule lives in
|
|
9
|
+
# one place.
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
root="$(cd "$(dirname "$0")/.." && pwd)"
|
|
13
|
+
|
|
14
|
+
extract() { grep -m1 -E "$2" "$root/$1" | sed -E 's/.*"([^"]+)".*/\1/'; }
|
|
15
|
+
|
|
16
|
+
pyproject="$(extract pyproject.toml '^version *= *"')"
|
|
17
|
+
init="$(extract src/omnirun/__init__.py '^__version__ *= *"')"
|
|
18
|
+
|
|
19
|
+
if [ "$pyproject" != "$init" ]; then
|
|
20
|
+
echo "version mismatch: pyproject.toml=$pyproject src/omnirun/__init__.py=$init" >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
if [ "$#" -ge 1 ]; then
|
|
25
|
+
expected="${1#v}"
|
|
26
|
+
if [ "$pyproject" != "$expected" ]; then
|
|
27
|
+
echo "version mismatch: expected $expected but package version is $pyproject" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
echo "version OK: $pyproject"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Refuse to push a release tag whose commit does not declare a matching version.
|
|
3
|
+
#
|
|
4
|
+
# Installed as .git/hooks/pre-push by the devShell (see flake.nix). It inspects
|
|
5
|
+
# the *pushed commit's* content (via `git show <sha>:path`), so it catches a
|
|
6
|
+
# stale worktree too. CI (publish.yml) enforces the same rule server-side.
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
version_at() { git show "$1:$2" | grep -m1 -E "$3" | sed -E 's/.*"([^"]+)".*/\1/'; }
|
|
10
|
+
|
|
11
|
+
status=0
|
|
12
|
+
while read -r _local_ref local_sha remote_ref _remote_sha; do
|
|
13
|
+
case "$remote_ref" in
|
|
14
|
+
refs/tags/v[0-9]*) ;;
|
|
15
|
+
*) continue ;;
|
|
16
|
+
esac
|
|
17
|
+
# Deleting a tag pushes the zero sha; nothing to check.
|
|
18
|
+
case "$local_sha" in
|
|
19
|
+
0000000000000000000000000000000000000000) continue ;;
|
|
20
|
+
esac
|
|
21
|
+
|
|
22
|
+
tag="${remote_ref#refs/tags/}"
|
|
23
|
+
pyproject="$(version_at "$local_sha" pyproject.toml '^version *= *"')"
|
|
24
|
+
init="$(version_at "$local_sha" src/omnirun/__init__.py '^__version__ *= *"')"
|
|
25
|
+
|
|
26
|
+
if [ "$tag" != "v$pyproject" ]; then
|
|
27
|
+
echo "pre-push: tag $tag != pyproject version $pyproject (commit ${local_sha:0:12})" >&2
|
|
28
|
+
status=1
|
|
29
|
+
fi
|
|
30
|
+
if [ "$pyproject" != "$init" ]; then
|
|
31
|
+
echo "pre-push: pyproject $pyproject != __init__ $init (commit ${local_sha:0:12})" >&2
|
|
32
|
+
status=1
|
|
33
|
+
fi
|
|
34
|
+
done
|
|
35
|
+
|
|
36
|
+
if [ "$status" -ne 0 ]; then
|
|
37
|
+
echo "pre-push: bump the version before tagging (see scripts/check-version.sh)" >&2
|
|
38
|
+
fi
|
|
39
|
+
exit "$status"
|
|
@@ -22,6 +22,14 @@ class BackendError(RuntimeError):
|
|
|
22
22
|
"""Raised for backend-level failures with a user-actionable message."""
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
#: Optional hook submit() calls the instant it creates a *billable* resource,
|
|
26
|
+
#: before the (possibly minutes-long) wait for it to become usable. It hands the
|
|
27
|
+
#: client a partial JobHandle so a recovery stub can be persisted immediately —
|
|
28
|
+
#: an interrupted submit then stays visible to `ps`/`gc` instead of orphaning a
|
|
29
|
+
#: running instance with no local record.
|
|
30
|
+
ProvisioningSink = Callable[[JobHandle], None]
|
|
31
|
+
|
|
32
|
+
|
|
25
33
|
class Backend(ABC):
|
|
26
34
|
"""One configured execution target (a cluster, a machine, a provider account).
|
|
27
35
|
|
|
@@ -46,7 +54,16 @@ class Backend(ABC):
|
|
|
46
54
|
def probe(self, res: ResourceSpec) -> list[Offer]: ...
|
|
47
55
|
|
|
48
56
|
@abstractmethod
|
|
49
|
-
def submit(
|
|
57
|
+
def submit(
|
|
58
|
+
self,
|
|
59
|
+
spec: JobSpec,
|
|
60
|
+
offer: Offer,
|
|
61
|
+
on_provisioning: ProvisioningSink | None = None,
|
|
62
|
+
) -> JobHandle:
|
|
63
|
+
"""Run the job and return a handle. If a billable resource is created
|
|
64
|
+
before the handle is ready, call on_provisioning with a partial handle
|
|
65
|
+
first so the client can persist a recovery stub (see ProvisioningSink)."""
|
|
66
|
+
...
|
|
50
67
|
|
|
51
68
|
@abstractmethod
|
|
52
69
|
def status(self, handle: JobHandle) -> StatusReport: ...
|
|
@@ -33,7 +33,7 @@ from pathlib import Path
|
|
|
33
33
|
from typing import Any
|
|
34
34
|
|
|
35
35
|
from omnirun.backends import jobdir, tarsafe
|
|
36
|
-
from omnirun.backends.base import Backend, BackendError, register
|
|
36
|
+
from omnirun.backends.base import Backend, BackendError, ProvisioningSink, register
|
|
37
37
|
from omnirun.bootstrap import (
|
|
38
38
|
HEARTBEAT_STALE_S,
|
|
39
39
|
BootstrapParams,
|
|
@@ -329,7 +329,12 @@ class ColabBackend(Backend):
|
|
|
329
329
|
|
|
330
330
|
# ---- submit -----------------------------------------------------------------
|
|
331
331
|
|
|
332
|
-
def submit(
|
|
332
|
+
def submit(
|
|
333
|
+
self,
|
|
334
|
+
spec: JobSpec,
|
|
335
|
+
offer: Offer,
|
|
336
|
+
on_provisioning: ProvisioningSink | None = None,
|
|
337
|
+
) -> JobHandle:
|
|
333
338
|
session = self._session(spec.job_id)
|
|
334
339
|
job_dir = f"{COLAB_ROOT}/jobs/{spec.job_id}"
|
|
335
340
|
|
|
@@ -360,7 +365,9 @@ class ColabBackend(Backend):
|
|
|
360
365
|
BootstrapParams(
|
|
361
366
|
omnirun_root=COLAB_ROOT,
|
|
362
367
|
project_root=jobdir.project_root_of(
|
|
363
|
-
COLAB_ROOT,
|
|
368
|
+
COLAB_ROOT,
|
|
369
|
+
spec.repo.slug,
|
|
370
|
+
self.config.project_root_for(spec.repo.slug),
|
|
364
371
|
),
|
|
365
372
|
code=code,
|
|
366
373
|
),
|
|
@@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
from typing import Any
|
|
35
35
|
|
|
36
|
-
from omnirun.backends.base import Backend, BackendError, register
|
|
36
|
+
from omnirun.backends.base import Backend, BackendError, ProvisioningSink, register
|
|
37
37
|
from omnirun.backends import jobdir, tarsafe
|
|
38
38
|
from omnirun.bootstrap import (
|
|
39
39
|
BootstrapParams,
|
|
@@ -393,7 +393,12 @@ class KaggleBackend(Backend):
|
|
|
393
393
|
"the backend's `max_source_bytes` if Kaggle accepts more"
|
|
394
394
|
)
|
|
395
395
|
|
|
396
|
-
def submit(
|
|
396
|
+
def submit(
|
|
397
|
+
self,
|
|
398
|
+
spec: JobSpec,
|
|
399
|
+
offer: Offer,
|
|
400
|
+
on_provisioning: ProvisioningSink | None = None,
|
|
401
|
+
) -> JobHandle:
|
|
397
402
|
api = self._api()
|
|
398
403
|
user = self._username(api)
|
|
399
404
|
job_id = spec.job_id
|
|
@@ -435,7 +440,9 @@ class KaggleBackend(Backend):
|
|
|
435
440
|
BootstrapParams(
|
|
436
441
|
omnirun_root=KAGGLE_ROOT,
|
|
437
442
|
project_root=jobdir.project_root_of(
|
|
438
|
-
KAGGLE_ROOT,
|
|
443
|
+
KAGGLE_ROOT,
|
|
444
|
+
spec.repo.slug,
|
|
445
|
+
self.config.project_root_for(spec.repo.slug),
|
|
439
446
|
),
|
|
440
447
|
code=code,
|
|
441
448
|
),
|
|
@@ -13,7 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
from typing import TYPE_CHECKING
|
|
14
14
|
|
|
15
15
|
from omnirun.backends import jobdir
|
|
16
|
-
from omnirun.backends.base import Backend, register
|
|
16
|
+
from omnirun.backends.base import Backend, ProvisioningSink, register
|
|
17
17
|
from omnirun.bootstrap import BootstrapParams
|
|
18
18
|
from omnirun.execlayer.base import shell_quote
|
|
19
19
|
from omnirun.execlayer.local import LocalExec
|
|
@@ -110,10 +110,18 @@ class LocalBackend(Backend):
|
|
|
110
110
|
|
|
111
111
|
# --- lifecycle ---------------------------------------------------------
|
|
112
112
|
|
|
113
|
-
def submit(
|
|
113
|
+
def submit(
|
|
114
|
+
self,
|
|
115
|
+
spec: JobSpec,
|
|
116
|
+
offer: Offer,
|
|
117
|
+
on_provisioning: ProvisioningSink | None = None,
|
|
118
|
+
) -> JobHandle:
|
|
114
119
|
root = jobdir.remote_root(self.exec, self.config.root)
|
|
115
120
|
project_root = jobdir.resolve_project_root(
|
|
116
|
-
self.exec,
|
|
121
|
+
self.exec,
|
|
122
|
+
root,
|
|
123
|
+
spec.repo.slug,
|
|
124
|
+
self.config.project_root_for(spec.repo.slug),
|
|
117
125
|
)
|
|
118
126
|
params = BootstrapParams(
|
|
119
127
|
omnirun_root=root,
|
|
@@ -7,6 +7,13 @@ the job (client-side git push + bootstrap.sh) -> detached launch (setsid+nohup,
|
|
|
7
7
|
pidfile), exactly like the plain ssh backend.
|
|
8
8
|
|
|
9
9
|
Termination policy (billable resources must not leak):
|
|
10
|
+
- **Provisioning stub** (issue #7): the instant ``submit`` rents an instance —
|
|
11
|
+
before the minutes-long provisioning wait — it calls the ``on_provisioning``
|
|
12
|
+
hook with a partial handle carrying the instance id, so the client persists a
|
|
13
|
+
recovery record *first*. A submit killed mid-wait then stays visible to
|
|
14
|
+
``ps``/``status`` (reported as PROVISIONING with a billing warning) and
|
|
15
|
+
reclaimable by ``omnirun gc --all``, instead of orphaning a running instance
|
|
16
|
+
with no local trace. The final handle overwrites the stub on success.
|
|
10
17
|
- **Client-side auto-terminate** (``auto_terminate``, default true): the instance
|
|
11
18
|
is destroyed in ``pull_outputs`` (after a successful pull), in ``cancel``, and
|
|
12
19
|
in ``gc``. ``status`` never terminates — when the job is terminal it appends a
|
|
@@ -52,7 +59,7 @@ import httpx
|
|
|
52
59
|
from pydantic import BaseModel, Field
|
|
53
60
|
|
|
54
61
|
from omnirun.backends import jobdir
|
|
55
|
-
from omnirun.backends.base import Backend, BackendError
|
|
62
|
+
from omnirun.backends.base import Backend, BackendError, ProvisioningSink
|
|
56
63
|
from omnirun.bootstrap import BootstrapParams
|
|
57
64
|
from omnirun.execlayer.base import Exec, shell_quote
|
|
58
65
|
from omnirun.repo import local_root_of
|
|
@@ -270,9 +277,26 @@ class MarketplaceBackend(Backend, ABC):
|
|
|
270
277
|
unfit_reasons=[reason],
|
|
271
278
|
)
|
|
272
279
|
|
|
273
|
-
def submit(
|
|
280
|
+
def submit(
|
|
281
|
+
self,
|
|
282
|
+
spec: JobSpec,
|
|
283
|
+
offer: Offer,
|
|
284
|
+
on_provisioning: ProvisioningSink | None = None,
|
|
285
|
+
) -> JobHandle:
|
|
274
286
|
inst = self._create_instance(spec, offer)
|
|
275
287
|
instance_id = inst.instance_id
|
|
288
|
+
# The instance is now billing but the handle isn't ready yet. Hand the
|
|
289
|
+
# client a stub carrying the instance id *before* the provisioning wait,
|
|
290
|
+
# so an interrupted submit (a kill mid-wait) stays visible to ps/gc
|
|
291
|
+
# instead of orphaning a running instance with no local record (#7).
|
|
292
|
+
if on_provisioning is not None:
|
|
293
|
+
on_provisioning(
|
|
294
|
+
JobHandle(
|
|
295
|
+
backend=self.name,
|
|
296
|
+
job_id=spec.job_id,
|
|
297
|
+
data={"instance_id": instance_id, "provisioning": True},
|
|
298
|
+
)
|
|
299
|
+
)
|
|
276
300
|
try:
|
|
277
301
|
inst = self._wait_provisioned(instance_id)
|
|
278
302
|
assert inst.ssh_target is not None
|
|
@@ -286,7 +310,7 @@ class MarketplaceBackend(Backend, ABC):
|
|
|
286
310
|
self._wait_ssh(ex)
|
|
287
311
|
root = jobdir.remote_root(ex, self.config.root)
|
|
288
312
|
project_root = jobdir.resolve_project_root(
|
|
289
|
-
ex, root, spec.repo.slug, self.config.
|
|
313
|
+
ex, root, spec.repo.slug, self.config.project_root_for(spec.repo.slug)
|
|
290
314
|
)
|
|
291
315
|
params = BootstrapParams(
|
|
292
316
|
omnirun_root=root,
|
|
@@ -379,6 +403,31 @@ class MarketplaceBackend(Backend, ABC):
|
|
|
379
403
|
|
|
380
404
|
def status(self, handle: JobHandle) -> StatusReport:
|
|
381
405
|
instance_id = handle.data["instance_id"]
|
|
406
|
+
if not handle.data.get("job_dir"):
|
|
407
|
+
# A provisioning stub: submit was interrupted before it staged the
|
|
408
|
+
# job, so only the instance id was persisted. Report whether the
|
|
409
|
+
# rented instance is still billing so the user knows to reclaim it.
|
|
410
|
+
try:
|
|
411
|
+
inst = self._get_instance(instance_id)
|
|
412
|
+
except BackendError as e:
|
|
413
|
+
return StatusReport(
|
|
414
|
+
status=JobStatus.PROVISIONING,
|
|
415
|
+
detail=f"submit was interrupted while provisioning instance "
|
|
416
|
+
f"{instance_id}; could not reach the {self.name} API to check "
|
|
417
|
+
f"it ({e}) — verify in the provider console, run `omnirun gc`",
|
|
418
|
+
)
|
|
419
|
+
if inst is None:
|
|
420
|
+
return StatusReport(
|
|
421
|
+
status=JobStatus.LOST,
|
|
422
|
+
detail=f"instance {instance_id} never finished provisioning and "
|
|
423
|
+
"no longer exists",
|
|
424
|
+
)
|
|
425
|
+
return StatusReport(
|
|
426
|
+
status=JobStatus.PROVISIONING,
|
|
427
|
+
detail=f"submit was interrupted after renting instance {instance_id} "
|
|
428
|
+
f"(now {inst.status or 'unknown'}) — it is still billing; run "
|
|
429
|
+
f"`omnirun gc --all` (or cancel {handle.job_id}) to destroy it",
|
|
430
|
+
)
|
|
382
431
|
inst: Instance | None = None
|
|
383
432
|
api_error: str | None = None
|
|
384
433
|
try:
|
|
@@ -436,16 +485,17 @@ class MarketplaceBackend(Backend, ABC):
|
|
|
436
485
|
return paths
|
|
437
486
|
|
|
438
487
|
def cancel(self, handle: JobHandle) -> None:
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
488
|
+
if handle.data.get("job_dir"): # a provisioning stub has nothing to kill
|
|
489
|
+
try: # best-effort remote kill; instance dies right after anyway
|
|
490
|
+
ex = self._exec_from_handle(handle)
|
|
491
|
+
q = shell_quote(handle.data["job_dir"])
|
|
492
|
+
ex.run(
|
|
493
|
+
f"if [ -f {q}/pid ]; then p=$(cat {q}/pid); "
|
|
494
|
+
f'kill -TERM -- "-$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null; fi; true',
|
|
495
|
+
timeout=30,
|
|
496
|
+
)
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
449
499
|
instance_id = handle.data["instance_id"]
|
|
450
500
|
if self._get_instance(instance_id) is not None:
|
|
451
501
|
self._terminate(instance_id)
|
|
@@ -17,7 +17,7 @@ from datetime import datetime, timedelta
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
from omnirun.backends import jobdir
|
|
20
|
-
from omnirun.backends.base import Backend, BackendError, register
|
|
20
|
+
from omnirun.backends.base import Backend, BackendError, ProvisioningSink, register
|
|
21
21
|
from omnirun.bootstrap import BootstrapParams, generate_bootstrap
|
|
22
22
|
from omnirun.config import BackendConfig
|
|
23
23
|
from omnirun.execlayer.base import Exec, ExecError, shell_quote
|
|
@@ -181,6 +181,8 @@ class SlurmBackend(Backend):
|
|
|
181
181
|
|
|
182
182
|
reasons: list[str] = []
|
|
183
183
|
notes: list[str] = []
|
|
184
|
+
if res.wants_gpu() and self.config.extra("has_gpus", True) is False:
|
|
185
|
+
reasons.append(f"backend {self.name} is CPU-only (has_gpus = false)")
|
|
184
186
|
if res.wants_gpu() and res.gpu_type and self.config.gpu_map:
|
|
185
187
|
if _gpu_map_lookup(self.config.gpu_map, res.gpu_type) is None:
|
|
186
188
|
reasons.append(
|
|
@@ -258,7 +260,7 @@ class SlurmBackend(Backend):
|
|
|
258
260
|
root = self.config.root
|
|
259
261
|
job_dir = jobdir.job_dir_of(root, spec.job_id)
|
|
260
262
|
project_root = jobdir.project_root_of(
|
|
261
|
-
root, spec.repo.slug, self.config.
|
|
263
|
+
root, spec.repo.slug, self.config.project_root_for(spec.repo.slug)
|
|
262
264
|
)
|
|
263
265
|
sbatch = render_sbatch(spec, self.config, job_dir, root)
|
|
264
266
|
bootstrap = generate_bootstrap(
|
|
@@ -277,11 +279,16 @@ class SlurmBackend(Backend):
|
|
|
277
279
|
)
|
|
278
280
|
return f"{sbatch}{sep}{bootstrap}"
|
|
279
281
|
|
|
280
|
-
def submit(
|
|
282
|
+
def submit(
|
|
283
|
+
self,
|
|
284
|
+
spec: JobSpec,
|
|
285
|
+
offer: Offer | None = None,
|
|
286
|
+
on_provisioning: ProvisioningSink | None = None,
|
|
287
|
+
) -> JobHandle:
|
|
281
288
|
ex = self.exec_
|
|
282
289
|
root = jobdir.remote_root(ex, self.config.root)
|
|
283
290
|
project_root = jobdir.resolve_project_root(
|
|
284
|
-
ex, root, spec.repo.slug, self.config.
|
|
291
|
+
ex, root, spec.repo.slug, self.config.project_root_for(spec.repo.slug)
|
|
285
292
|
)
|
|
286
293
|
params = BootstrapParams(
|
|
287
294
|
omnirun_root=root,
|
|
@@ -10,7 +10,7 @@ from collections.abc import Iterator
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from omnirun.backends import jobdir
|
|
13
|
-
from omnirun.backends.base import Backend, BackendError, register
|
|
13
|
+
from omnirun.backends.base import Backend, BackendError, ProvisioningSink, register
|
|
14
14
|
from omnirun.bootstrap import BootstrapParams
|
|
15
15
|
from omnirun.execlayer.base import Exec, ExecError, shell_quote
|
|
16
16
|
from omnirun.execlayer.ssh import RECONNECT_HINT, SSHExec
|
|
@@ -213,11 +213,16 @@ class SshBackend(Backend):
|
|
|
213
213
|
|
|
214
214
|
# --- submit / lifecycle ---------------------------------------------------
|
|
215
215
|
|
|
216
|
-
def submit(
|
|
216
|
+
def submit(
|
|
217
|
+
self,
|
|
218
|
+
spec: JobSpec,
|
|
219
|
+
offer: Offer | None = None,
|
|
220
|
+
on_provisioning: ProvisioningSink | None = None,
|
|
221
|
+
) -> JobHandle:
|
|
217
222
|
ex = self.exec_
|
|
218
223
|
root = jobdir.remote_root(ex, self.config.root)
|
|
219
224
|
project_root = jobdir.resolve_project_root(
|
|
220
|
-
ex, root, spec.repo.slug, self.config.
|
|
225
|
+
ex, root, spec.repo.slug, self.config.project_root_for(spec.repo.slug)
|
|
221
226
|
)
|
|
222
227
|
params = BootstrapParams(
|
|
223
228
|
omnirun_root=root,
|
|
@@ -37,23 +37,25 @@ OFFER_LIMIT = 5
|
|
|
37
37
|
MIN_RELIABILITY = 0.95
|
|
38
38
|
|
|
39
39
|
# normalized name -> vast gpu_name values (their catalog naming)
|
|
40
|
+
# The REST /bundles/ endpoint matches gpu_name with spaces ("A100 SXM4");
|
|
41
|
+
# underscore forms match nothing (the vast CLI translates them, the API doesn't).
|
|
40
42
|
VAST_GPU_NAMES: dict[str, list[str]] = {
|
|
41
43
|
"H200": ["H200"],
|
|
42
|
-
"H100": ["
|
|
43
|
-
"A100": ["
|
|
44
|
-
"A100-80": ["
|
|
45
|
-
"A6000": ["
|
|
44
|
+
"H100": ["H100 SXM", "H100 PCIE", "H100 NVL"],
|
|
45
|
+
"A100": ["A100 SXM4", "A100 PCIE", "A100X"],
|
|
46
|
+
"A100-80": ["A100 SXM4", "A100 PCIE"],
|
|
47
|
+
"A6000": ["RTX A6000"],
|
|
46
48
|
"L40": ["L40", "L40S"],
|
|
47
49
|
"L4": ["L4"],
|
|
48
|
-
"V100": ["
|
|
49
|
-
"4090": ["
|
|
50
|
-
"3090": ["
|
|
51
|
-
"5090": ["
|
|
50
|
+
"V100": ["Tesla V100"],
|
|
51
|
+
"4090": ["RTX 4090"],
|
|
52
|
+
"3090": ["RTX 3090"],
|
|
53
|
+
"5090": ["RTX 5090"],
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
|
|
55
57
|
def vast_gpu_names(gpu_type: str) -> list[str]:
|
|
56
|
-
return VAST_GPU_NAMES.get(gpu_type, [gpu_type.replace("
|
|
58
|
+
return VAST_GPU_NAMES.get(gpu_type, [gpu_type.replace("_", " ").replace("-", " ")])
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
def normalize_vast_gpu(name: str, gpu_ram_mb: float | None = None) -> str:
|
|
@@ -84,6 +86,10 @@ class VastBackend(MarketplaceBackend):
|
|
|
84
86
|
}
|
|
85
87
|
if res.gpu_type is not None:
|
|
86
88
|
filt["gpu_name"] = {"in": vast_gpu_names(res.gpu_type)}
|
|
89
|
+
if res.gpu_type == "A100-80":
|
|
90
|
+
# cheapest-first + OFFER_LIMIT would otherwise fill the page
|
|
91
|
+
# with 40GB A100s that normalization then discards
|
|
92
|
+
filt["gpu_ram"] = {"gte": 70_000}
|
|
87
93
|
elif res.min_vram_gb is not None:
|
|
88
94
|
# vast reports gpu_ram in MB (verify live)
|
|
89
95
|
filt["gpu_ram"] = {"gte": res.min_vram_gb * 1024}
|
|
@@ -27,6 +27,7 @@ from omnirun.config import (
|
|
|
27
27
|
)
|
|
28
28
|
from omnirun.models import (
|
|
29
29
|
EnvSpec,
|
|
30
|
+
JobHandle,
|
|
30
31
|
JobRecord,
|
|
31
32
|
JobSpec,
|
|
32
33
|
JobStatus,
|
|
@@ -451,15 +452,24 @@ def submit(
|
|
|
451
452
|
_render_payload(backend_obj, spec, picked.offer)
|
|
452
453
|
return
|
|
453
454
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
455
|
+
store = JobStore()
|
|
456
|
+
picked_offer = picked.offer
|
|
457
|
+
|
|
458
|
+
def _persist(h: JobHandle) -> None:
|
|
459
|
+
# Called once with a provisioning stub (if the backend rents something
|
|
460
|
+
# before the handle is ready) and again with the final handle, so an
|
|
461
|
+
# interrupted submit still leaves a reclaimable record (#7).
|
|
462
|
+
store.save(
|
|
463
|
+
JobRecord(
|
|
464
|
+
spec=spec,
|
|
465
|
+
handle=h,
|
|
466
|
+
offer=picked_offer,
|
|
467
|
+
submitted_at=datetime.now(timezone.utc),
|
|
468
|
+
)
|
|
461
469
|
)
|
|
462
|
-
|
|
470
|
+
|
|
471
|
+
handle = backend_obj.submit(spec, picked_offer, on_provisioning=_persist)
|
|
472
|
+
_persist(handle)
|
|
463
473
|
console.print(f"[green]submitted[/green] {spec.job_id} -> {picked.offer.label}")
|
|
464
474
|
console.print(f"follow logs with: omnirun logs -f {spec.job_id}")
|
|
465
475
|
|
|
@@ -48,8 +48,10 @@ class BackendConfig(BaseModel, extra="allow"):
|
|
|
48
48
|
env_setup: list[str] = Field(default_factory=list) # module loads, exports
|
|
49
49
|
# Where a project's shared checkout + .venv live on the worker. Default
|
|
50
50
|
# "$OMNIRUN_ROOT/projects/<slug>"; point at an existing clone to reuse it
|
|
51
|
-
# (its .git becomes the object store, its .venv the shared env).
|
|
52
|
-
|
|
51
|
+
# (its .git becomes the object store, its .venv the shared env). A str
|
|
52
|
+
# applies to every repo; a dict maps repo slug -> path (with an optional
|
|
53
|
+
# "default" key as fallback) so one backend can serve several repos.
|
|
54
|
+
project_root: str | dict[str, str] | None = None
|
|
53
55
|
|
|
54
56
|
# static capability declaration (ssh backend; probe checks live via nvidia-smi)
|
|
55
57
|
gpus: list[GpuDecl] = Field(default_factory=list)
|
|
@@ -74,6 +76,15 @@ class BackendConfig(BaseModel, extra="allow"):
|
|
|
74
76
|
def extra(self, key: str, default: Any = None) -> Any:
|
|
75
77
|
return (self.model_extra or {}).get(key, default)
|
|
76
78
|
|
|
79
|
+
def project_root_for(self, slug: str) -> str | None:
|
|
80
|
+
"""Configured project root for a repo. A str applies to every repo; a
|
|
81
|
+
dict is keyed by slug, falling back to a "default" key, then to None
|
|
82
|
+
(the built-in "$OMNIRUN_ROOT/projects/<slug>" path)."""
|
|
83
|
+
pr = self.project_root
|
|
84
|
+
if isinstance(pr, dict):
|
|
85
|
+
return pr.get(slug) or pr.get("default")
|
|
86
|
+
return pr
|
|
87
|
+
|
|
77
88
|
|
|
78
89
|
class DaemonConfig(BaseModel):
|
|
79
90
|
host: str = "127.0.0.1" # localhost socket; bind 0.0.0.0 + auth to go remote
|
|
@@ -26,6 +26,7 @@ from omnirun import chooser
|
|
|
26
26
|
from omnirun.backends.base import Backend, make_backend
|
|
27
27
|
from omnirun.config import Config
|
|
28
28
|
from omnirun.models import (
|
|
29
|
+
JobHandle,
|
|
29
30
|
JobRecord,
|
|
30
31
|
JobSpec,
|
|
31
32
|
JobStatus,
|
|
@@ -433,19 +434,25 @@ class Daemon:
|
|
|
433
434
|
if be is None:
|
|
434
435
|
self._fail_submit(qid, f"backend {backend_name!r} unavailable")
|
|
435
436
|
return
|
|
437
|
+
|
|
438
|
+
def _persist(h: JobHandle) -> None:
|
|
439
|
+
# Persist a stub the instant a billable resource is created, then
|
|
440
|
+
# the final handle — an interrupted submit stays reclaimable (#7).
|
|
441
|
+
self._jobstore.save(
|
|
442
|
+
JobRecord(
|
|
443
|
+
spec=spec,
|
|
444
|
+
handle=h,
|
|
445
|
+
offer=offer,
|
|
446
|
+
submitted_at=datetime.now(timezone.utc),
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
|
|
436
450
|
try:
|
|
437
|
-
handle = be.submit(spec, offer)
|
|
451
|
+
handle = be.submit(spec, offer, on_provisioning=_persist)
|
|
438
452
|
except Exception as e:
|
|
439
453
|
self._retry_or_fail(qid, str(e))
|
|
440
454
|
return
|
|
441
|
-
|
|
442
|
-
JobRecord(
|
|
443
|
-
spec=spec,
|
|
444
|
-
handle=handle,
|
|
445
|
-
offer=offer,
|
|
446
|
-
submitted_at=datetime.now(timezone.utc),
|
|
447
|
-
)
|
|
448
|
-
)
|
|
455
|
+
_persist(handle)
|
|
449
456
|
with self._lock:
|
|
450
457
|
entry = self._store.get(qid)
|
|
451
458
|
if entry is None:
|