gagarin 0.1.1__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.
- gagarin-0.1.1/.coverage +0 -0
- gagarin-0.1.1/.gitignore +4 -0
- gagarin-0.1.1/PKG-INFO +68 -0
- gagarin-0.1.1/README.md +59 -0
- gagarin-0.1.1/pyproject.toml +18 -0
- gagarin-0.1.1/src/gagarin/__init__.py +58 -0
- gagarin-0.1.1/src/gagarin/account.py +24 -0
- gagarin-0.1.1/src/gagarin/client.py +133 -0
- gagarin-0.1.1/src/gagarin/errors.py +41 -0
- gagarin-0.1.1/src/gagarin/jobs.py +235 -0
- gagarin-0.1.1/src/gagarin/platform.py +29 -0
- gagarin-0.1.1/src/gagarin/projects.py +42 -0
- gagarin-0.1.1/src/gagarin/py.typed +0 -0
- gagarin-0.1.1/src/gagarin/services.py +41 -0
- gagarin-0.1.1/src/gagarin/types.py +184 -0
- gagarin-0.1.1/tests/fake_api.py +151 -0
- gagarin-0.1.1/tests/test_account.py +25 -0
- gagarin-0.1.1/tests/test_client.py +97 -0
- gagarin-0.1.1/tests/test_integration.py +98 -0
- gagarin-0.1.1/tests/test_jobs.py +141 -0
- gagarin-0.1.1/tests/test_platform.py +33 -0
- gagarin-0.1.1/tests/test_projects.py +61 -0
- gagarin-0.1.1/tests/test_services.py +31 -0
gagarin-0.1.1/.coverage
ADDED
|
Binary file
|
gagarin-0.1.1/.gitignore
ADDED
gagarin-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gagarin
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python client for the gagarin API: run jobs from images already in the gagarin registry.
|
|
5
|
+
Project-URL: Repository, https://github.com/gagarin-cloud/sdk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# gagarin — Python
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pip install gagarin
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Python 3.9+, standard library only.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from gagarin import Gagarin, GagarinError
|
|
20
|
+
|
|
21
|
+
gg = Gagarin() # GAGARIN_TOKEN, GAGARIN_API; or Gagarin(token=...)
|
|
22
|
+
|
|
23
|
+
# What `gg run` does: submit, wait, exit with the script's code.
|
|
24
|
+
res = gg.jobs.run_and_wait(
|
|
25
|
+
"shop", "migrate",
|
|
26
|
+
image="migrate:v3", # or registry.gagarin.cloud/<project-id>/migrate:v3
|
|
27
|
+
env={"LOG_LEVEL": "debug"},
|
|
28
|
+
deps=["db"],
|
|
29
|
+
size="m",
|
|
30
|
+
timeout_seconds=900,
|
|
31
|
+
on_update=lambda run: print(run.phase),
|
|
32
|
+
)
|
|
33
|
+
raise SystemExit(res.exit_code)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Fire and forget, then check later:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
sub = gg.jobs.run("shop", "backfill", image="backfill:v1")
|
|
40
|
+
run = gg.jobs.wait("shop", "backfill", sub.revision)
|
|
41
|
+
print(gg.jobs.logs("shop", "backfill", since="30m")["logs"])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A scheduled job (runs nothing now):
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
gg.jobs.run("shop", "nightly", image="report:v2", schedule="0 3 * * *", time_zone="Europe/Berlin")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The basics (answers are plain dicts, typed with `TypedDict`):
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
me = gg.account.whoami() # me["account"], me["can"], me["registry"]
|
|
54
|
+
bill = gg.account.billing() # bill["balance"], bill["runway_hours"]
|
|
55
|
+
projects = gg.projects.list()
|
|
56
|
+
p = gg.projects.create("shop") # raises project_exists if you have one
|
|
57
|
+
st = gg.projects.status("shop") # st["services"], st["notices"], st["usage_today"]
|
|
58
|
+
web = gg.projects.service_status("shop", "web")
|
|
59
|
+
logs = gg.services.logs("shop", "web", since="1h", contains="error")
|
|
60
|
+
history = gg.services.deployments("shop", "web")
|
|
61
|
+
health = gg.platform.health() # an unhealthy platform is an answer, not an exception
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
API refusals raise `GagarinError` with `status`, `code`, `message` and `fix_hint`.
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
68
|
+
```
|
gagarin-0.1.1/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# gagarin — Python
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
pip install gagarin
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Python 3.9+, standard library only.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from gagarin import Gagarin, GagarinError
|
|
11
|
+
|
|
12
|
+
gg = Gagarin() # GAGARIN_TOKEN, GAGARIN_API; or Gagarin(token=...)
|
|
13
|
+
|
|
14
|
+
# What `gg run` does: submit, wait, exit with the script's code.
|
|
15
|
+
res = gg.jobs.run_and_wait(
|
|
16
|
+
"shop", "migrate",
|
|
17
|
+
image="migrate:v3", # or registry.gagarin.cloud/<project-id>/migrate:v3
|
|
18
|
+
env={"LOG_LEVEL": "debug"},
|
|
19
|
+
deps=["db"],
|
|
20
|
+
size="m",
|
|
21
|
+
timeout_seconds=900,
|
|
22
|
+
on_update=lambda run: print(run.phase),
|
|
23
|
+
)
|
|
24
|
+
raise SystemExit(res.exit_code)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Fire and forget, then check later:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
sub = gg.jobs.run("shop", "backfill", image="backfill:v1")
|
|
31
|
+
run = gg.jobs.wait("shop", "backfill", sub.revision)
|
|
32
|
+
print(gg.jobs.logs("shop", "backfill", since="30m")["logs"])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A scheduled job (runs nothing now):
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
gg.jobs.run("shop", "nightly", image="report:v2", schedule="0 3 * * *", time_zone="Europe/Berlin")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The basics (answers are plain dicts, typed with `TypedDict`):
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
me = gg.account.whoami() # me["account"], me["can"], me["registry"]
|
|
45
|
+
bill = gg.account.billing() # bill["balance"], bill["runway_hours"]
|
|
46
|
+
projects = gg.projects.list()
|
|
47
|
+
p = gg.projects.create("shop") # raises project_exists if you have one
|
|
48
|
+
st = gg.projects.status("shop") # st["services"], st["notices"], st["usage_today"]
|
|
49
|
+
web = gg.projects.service_status("shop", "web")
|
|
50
|
+
logs = gg.services.logs("shop", "web", since="1h", contains="error")
|
|
51
|
+
history = gg.services.deployments("shop", "web")
|
|
52
|
+
health = gg.platform.health() # an unhealthy platform is an answer, not an exception
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
API refusals raise `GagarinError` with `status`, `code`, `message` and `fix_hint`.
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
59
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gagarin"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Python client for the gagarin API: run jobs from images already in the gagarin registry."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/gagarin-cloud/sdk"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/gagarin"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""A client for the gagarin API.
|
|
2
|
+
|
|
3
|
+
Its first job is the one ``gg run`` does: start a job from an image that is
|
|
4
|
+
already in gagarin's registry, and optionally wait for it to finish.
|
|
5
|
+
|
|
6
|
+
from gagarin import Gagarin
|
|
7
|
+
|
|
8
|
+
gg = Gagarin() # GAGARIN_TOKEN, GAGARIN_API
|
|
9
|
+
res = gg.jobs.run_and_wait("shop", "migrate", image="migrate:v3", deps=["db"])
|
|
10
|
+
raise SystemExit(res.exit_code)
|
|
11
|
+
|
|
12
|
+
Around it are the basics: ``account``, ``projects`` (with their status),
|
|
13
|
+
``services`` (logs and history) and ``platform`` (gagarin's own health).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .account import Account
|
|
17
|
+
from .client import DEFAULT_BASE_URL, DEFAULT_REGISTRY, Gagarin, Transport
|
|
18
|
+
from .errors import GagarinError, SupersededError, SuspendedError, WaitTimeout
|
|
19
|
+
from .jobs import (
|
|
20
|
+
DONE,
|
|
21
|
+
FAILED,
|
|
22
|
+
MAX_TIMEOUT_SECONDS,
|
|
23
|
+
PENDING,
|
|
24
|
+
RUNNING,
|
|
25
|
+
SUSPENDED,
|
|
26
|
+
Jobs,
|
|
27
|
+
RunResult,
|
|
28
|
+
RunState,
|
|
29
|
+
Submission,
|
|
30
|
+
)
|
|
31
|
+
from .platform import Platform
|
|
32
|
+
from .projects import Projects
|
|
33
|
+
from .services import Services
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"DEFAULT_BASE_URL",
|
|
37
|
+
"DEFAULT_REGISTRY",
|
|
38
|
+
"DONE",
|
|
39
|
+
"FAILED",
|
|
40
|
+
"MAX_TIMEOUT_SECONDS",
|
|
41
|
+
"PENDING",
|
|
42
|
+
"RUNNING",
|
|
43
|
+
"SUSPENDED",
|
|
44
|
+
"Account",
|
|
45
|
+
"Gagarin",
|
|
46
|
+
"GagarinError",
|
|
47
|
+
"Jobs",
|
|
48
|
+
"Platform",
|
|
49
|
+
"Projects",
|
|
50
|
+
"RunResult",
|
|
51
|
+
"RunState",
|
|
52
|
+
"Services",
|
|
53
|
+
"Submission",
|
|
54
|
+
"SupersededError",
|
|
55
|
+
"SuspendedError",
|
|
56
|
+
"Transport",
|
|
57
|
+
"WaitTimeout",
|
|
58
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from .types import Billing, Whoami
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
8
|
+
from .client import Gagarin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Account:
|
|
12
|
+
"""The account the credential belongs to."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: Gagarin):
|
|
15
|
+
self._c = client
|
|
16
|
+
|
|
17
|
+
def whoami(self) -> Whoami:
|
|
18
|
+
"""Which account this credential acts as, and what it may do
|
|
19
|
+
(``can`` holds ``"deploy"`` and/or ``"destroy"``)."""
|
|
20
|
+
return self._c.request("GET", "/v1/whoami")
|
|
21
|
+
|
|
22
|
+
def billing(self) -> Billing:
|
|
23
|
+
"""The account's balance, burn and runway, in micro-USD."""
|
|
24
|
+
return self._c.request("GET", "/v1/billing")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The client, and the one place requests are made."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any, Callable, Mapping, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from .errors import api_error
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.gagarin.cloud"
|
|
15
|
+
DEFAULT_REGISTRY = "registry.gagarin.cloud"
|
|
16
|
+
|
|
17
|
+
#: (method, url, headers, body) -> (status, body). Replaceable to use a
|
|
18
|
+
#: different HTTP stack.
|
|
19
|
+
Transport = Callable[[str, str, Mapping[str, str], Optional[bytes]], Tuple[int, bytes]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _urllib_transport(timeout: float) -> Transport:
|
|
23
|
+
def send(method: str, url: str, headers: Mapping[str, str], body: bytes | None) -> tuple[int, bytes]:
|
|
24
|
+
req = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
|
|
25
|
+
try:
|
|
26
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
27
|
+
return resp.status, resp.read()
|
|
28
|
+
except urllib.error.HTTPError as e:
|
|
29
|
+
return e.code, e.read()
|
|
30
|
+
|
|
31
|
+
return send
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def quote(s: str) -> str:
|
|
35
|
+
return urllib.parse.quote(s, safe="")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def project_path(project: str) -> str:
|
|
39
|
+
return f"/v1/projects/{quote(project)}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def service_path(project: str, name: str) -> str:
|
|
43
|
+
return f"{project_path(project)}/services/{quote(name)}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Gagarin:
|
|
47
|
+
"""A client for the gagarin API.
|
|
48
|
+
|
|
49
|
+
- ``jobs``: submit, wait on and read jobs — what ``gg run`` does.
|
|
50
|
+
- ``account``: who the credential is, and the account's balance.
|
|
51
|
+
- ``projects``: list, create and read projects, and their status.
|
|
52
|
+
- ``services``: any service's logs and deploy history.
|
|
53
|
+
- ``platform``: whether gagarin itself is working.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
token: str | None = None,
|
|
59
|
+
base_url: str | None = None,
|
|
60
|
+
registry: str | None = None,
|
|
61
|
+
*,
|
|
62
|
+
timeout: float = 120.0,
|
|
63
|
+
transport: Transport | None = None,
|
|
64
|
+
):
|
|
65
|
+
"""``token`` defaults to ``$GAGARIN_TOKEN``, ``base_url`` to ``$GAGARIN_API``
|
|
66
|
+
and ``registry`` to ``$GAGARIN_REGISTRY``."""
|
|
67
|
+
from .account import Account
|
|
68
|
+
from .jobs import Jobs
|
|
69
|
+
from .platform import Platform
|
|
70
|
+
from .projects import Projects
|
|
71
|
+
from .services import Services
|
|
72
|
+
|
|
73
|
+
token = token or os.environ.get("GAGARIN_TOKEN")
|
|
74
|
+
if not token:
|
|
75
|
+
raise ValueError("gagarin: no token: pass token= or set GAGARIN_TOKEN")
|
|
76
|
+
self._token = token
|
|
77
|
+
self._base_url = (base_url or os.environ.get("GAGARIN_API") or DEFAULT_BASE_URL).rstrip("/")
|
|
78
|
+
self._registry = (registry or os.environ.get("GAGARIN_REGISTRY") or DEFAULT_REGISTRY).rstrip("/")
|
|
79
|
+
self._send = transport or _urllib_transport(timeout)
|
|
80
|
+
self.jobs = Jobs(self)
|
|
81
|
+
self.account = Account(self)
|
|
82
|
+
self.projects = Projects(self)
|
|
83
|
+
self.services = Services(self)
|
|
84
|
+
self.platform = Platform(self)
|
|
85
|
+
|
|
86
|
+
def send(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
path: str,
|
|
90
|
+
*,
|
|
91
|
+
query: Mapping[str, Any] | None = None,
|
|
92
|
+
body: Any = None,
|
|
93
|
+
) -> tuple[int, bytes]:
|
|
94
|
+
"""Makes one request and hands back the status and body, whatever the status was."""
|
|
95
|
+
url = self._base_url + path
|
|
96
|
+
q = {}
|
|
97
|
+
for k, v in (query or {}).items():
|
|
98
|
+
if v is None or v == "" or v is False:
|
|
99
|
+
continue
|
|
100
|
+
q[k] = "true" if v is True else str(v)
|
|
101
|
+
if q:
|
|
102
|
+
url += "?" + urllib.parse.urlencode(q)
|
|
103
|
+
headers = {
|
|
104
|
+
"Authorization": f"Bearer {self._token}",
|
|
105
|
+
"Accept": "application/json",
|
|
106
|
+
"User-Agent": "gagarin-sdk-python",
|
|
107
|
+
}
|
|
108
|
+
data = None
|
|
109
|
+
if body is not None:
|
|
110
|
+
headers["Content-Type"] = "application/json"
|
|
111
|
+
data = json.dumps(body).encode()
|
|
112
|
+
return self._send(method, url, headers, data)
|
|
113
|
+
|
|
114
|
+
def request(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
path: str,
|
|
118
|
+
*,
|
|
119
|
+
query: Mapping[str, Any] | None = None,
|
|
120
|
+
body: Any = None,
|
|
121
|
+
) -> Any:
|
|
122
|
+
"""Makes one request, raises :class:`GagarinError` on a refusal, and parses the answer."""
|
|
123
|
+
status, raw = self.send(method, path, query=query, body=body)
|
|
124
|
+
if not 200 <= status < 300:
|
|
125
|
+
raise api_error(status, raw)
|
|
126
|
+
return json.loads(raw) if raw else None
|
|
127
|
+
|
|
128
|
+
def image_ref(self, project: str, image: str) -> str:
|
|
129
|
+
"""Expands a bare ``migrate:v3`` into the full reference gagarin runs from.
|
|
130
|
+
A reference that already contains a ``/`` is returned unchanged."""
|
|
131
|
+
if "/" in image:
|
|
132
|
+
return image
|
|
133
|
+
return f"{self._registry}/{self.projects.get(project)['id']}/{image}"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GagarinError(Exception):
|
|
7
|
+
"""What the API answers when it refuses a request. Branch on ``code``."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, status: int, code: str, message: str, fix_hint: str | None = None):
|
|
10
|
+
self.status = status
|
|
11
|
+
self.code = code
|
|
12
|
+
self.message = message
|
|
13
|
+
self.fix_hint = fix_hint
|
|
14
|
+
text = f"[{code}] {message}"
|
|
15
|
+
if fix_hint:
|
|
16
|
+
text += f"\nhint: {fix_hint}"
|
|
17
|
+
super().__init__(text)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SupersededError(GagarinError):
|
|
21
|
+
"""A newer run of the job started before the awaited one was seen to finish."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SuspendedError(GagarinError):
|
|
25
|
+
"""The project was suspended, which stops its runs."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WaitTimeout(GagarinError):
|
|
29
|
+
"""The run had not finished when the wait gave up."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def api_error(status: int, raw: bytes) -> GagarinError:
|
|
33
|
+
"""The error for a refusal, from the API's envelope when there is one."""
|
|
34
|
+
err = None
|
|
35
|
+
try:
|
|
36
|
+
err = json.loads(raw).get("error")
|
|
37
|
+
except (ValueError, AttributeError):
|
|
38
|
+
pass
|
|
39
|
+
if isinstance(err, dict) and err.get("code"):
|
|
40
|
+
return GagarinError(status, err["code"], err.get("message", ""), err.get("fix_hint"))
|
|
41
|
+
return GagarinError(status, f"http_{status}", raw.decode(errors="replace").strip())
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
from .client import service_path
|
|
8
|
+
from .errors import GagarinError, SupersededError, SuspendedError, WaitTimeout
|
|
9
|
+
from .types import Logs, ServiceStatus
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from .client import Gagarin
|
|
13
|
+
|
|
14
|
+
#: The longest a run may take, and what a new job gets.
|
|
15
|
+
MAX_TIMEOUT_SECONDS = 3600
|
|
16
|
+
|
|
17
|
+
PENDING, RUNNING, DONE, FAILED, SUSPENDED = "pending", "running", "done", "failed", "suspended"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RunState:
|
|
22
|
+
"""What a run did."""
|
|
23
|
+
|
|
24
|
+
revision: int
|
|
25
|
+
phase: str
|
|
26
|
+
exit_code: int | None = None
|
|
27
|
+
started_at: str | None = None
|
|
28
|
+
finished_at: str | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def finished(self) -> bool:
|
|
32
|
+
return self.phase in (DONE, FAILED)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def succeeded(self) -> bool:
|
|
36
|
+
return self.phase == DONE and (self.exit_code is None or self.exit_code == 0)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_json(cls, d: Mapping[str, Any]) -> RunState:
|
|
40
|
+
return cls(
|
|
41
|
+
revision=d["revision"],
|
|
42
|
+
phase=d["phase"],
|
|
43
|
+
exit_code=d.get("exit_code"),
|
|
44
|
+
started_at=d.get("started_at"),
|
|
45
|
+
finished_at=d.get("finished_at"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Submission:
|
|
51
|
+
"""The job as stored, and the revision this run is — the run's name."""
|
|
52
|
+
|
|
53
|
+
project: str
|
|
54
|
+
name: str
|
|
55
|
+
revision: int
|
|
56
|
+
image: str
|
|
57
|
+
kind: str = "job"
|
|
58
|
+
size: str | None = None
|
|
59
|
+
digest: str | None = None
|
|
60
|
+
timeout_seconds: int | None = None
|
|
61
|
+
schedule: str | None = None
|
|
62
|
+
time_zone: str | None = None
|
|
63
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_json(cls, d: Mapping[str, Any]) -> Submission:
|
|
67
|
+
return cls(
|
|
68
|
+
project=d["project"],
|
|
69
|
+
name=d["name"],
|
|
70
|
+
revision=d["revision"],
|
|
71
|
+
image=d["image"],
|
|
72
|
+
kind=d.get("kind", "job"),
|
|
73
|
+
size=d.get("size"),
|
|
74
|
+
digest=d.get("digest"),
|
|
75
|
+
timeout_seconds=d.get("timeout_seconds"),
|
|
76
|
+
schedule=d.get("schedule"),
|
|
77
|
+
time_zone=d.get("time_zone"),
|
|
78
|
+
raw=d,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class RunResult:
|
|
84
|
+
"""A submission and how its run ended."""
|
|
85
|
+
|
|
86
|
+
submission: Submission
|
|
87
|
+
run: RunState
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def exit_code(self) -> int:
|
|
91
|
+
"""The script's own exit code, 0 on success, 1 for a run that failed
|
|
92
|
+
without one — what ``gg run`` exits with."""
|
|
93
|
+
if self.run.exit_code is not None:
|
|
94
|
+
return self.run.exit_code
|
|
95
|
+
return 0 if self.run.phase == DONE else 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _retryable(e: Exception) -> bool:
|
|
99
|
+
if isinstance(e, GagarinError):
|
|
100
|
+
return e.status >= 500 or e.status == 429
|
|
101
|
+
return isinstance(e, OSError)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Jobs:
|
|
105
|
+
"""Jobs: images that run to completion and listen on nothing."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, client: Gagarin):
|
|
108
|
+
self._c = client
|
|
109
|
+
|
|
110
|
+
def run(
|
|
111
|
+
self,
|
|
112
|
+
project: str,
|
|
113
|
+
name: str,
|
|
114
|
+
*,
|
|
115
|
+
image: str,
|
|
116
|
+
digest: str | None = None,
|
|
117
|
+
env: Mapping[str, str] | None = None,
|
|
118
|
+
deps: Sequence[str] | None = None,
|
|
119
|
+
size: str | None = None,
|
|
120
|
+
timeout_seconds: int | None = None,
|
|
121
|
+
schedule: str | None = None,
|
|
122
|
+
time_zone: str | None = None,
|
|
123
|
+
) -> Submission:
|
|
124
|
+
"""Submits a run of the named job and returns without waiting.
|
|
125
|
+
|
|
126
|
+
As with ``gg run``, the image must already be in gagarin's registry: a
|
|
127
|
+
full reference, or a bare ``migrate:v3`` expanded against the project.
|
|
128
|
+
Every call is a new run and a new revision.
|
|
129
|
+
|
|
130
|
+
- ``digest``: ``sha256:...`` as docker push reported it, pinning the image.
|
|
131
|
+
- ``env``: the complete environment for this run. Replaces the job's;
|
|
132
|
+
not merged, and ``None`` empties it.
|
|
133
|
+
- ``deps``: resources and services this run may reach, added to what the
|
|
134
|
+
job already declares. A migration must name its database here.
|
|
135
|
+
- ``size``: ``"s"``, ``"m"`` or ``"l"``. ``None`` keeps the current size.
|
|
136
|
+
- ``timeout_seconds``: 1–3600, counted from submission. ``None`` keeps it.
|
|
137
|
+
- ``schedule``: five cron fields or ``@daily``-style. Nothing runs now;
|
|
138
|
+
each firing is a run. Cannot be removed except by destroying the job.
|
|
139
|
+
- ``time_zone``: IANA zone the schedule is read in. ``None`` keeps it.
|
|
140
|
+
"""
|
|
141
|
+
if not image:
|
|
142
|
+
raise ValueError("gagarin: image is required")
|
|
143
|
+
# The environment is replaced on every run, so it is always sent.
|
|
144
|
+
body: dict[str, Any] = {"kind": "job", "image": self._c.image_ref(project, image), "env": dict(env or {})}
|
|
145
|
+
if digest:
|
|
146
|
+
body["digest"] = digest
|
|
147
|
+
if deps:
|
|
148
|
+
body["deps"] = list(deps)
|
|
149
|
+
if size:
|
|
150
|
+
body["size"] = size
|
|
151
|
+
if timeout_seconds:
|
|
152
|
+
body["timeout_seconds"] = timeout_seconds
|
|
153
|
+
if schedule:
|
|
154
|
+
body["schedule"] = schedule
|
|
155
|
+
if time_zone:
|
|
156
|
+
body["time_zone"] = time_zone
|
|
157
|
+
return Submission.from_json(self._c.request("PUT", service_path(project, name), body=body))
|
|
158
|
+
|
|
159
|
+
def get(self, project: str, name: str) -> ServiceStatus:
|
|
160
|
+
"""The job's row from the project's status, with how its latest run went
|
|
161
|
+
in ``actual.run``. A name that is not a job is refused ``not_a_job``."""
|
|
162
|
+
row = self._c.projects.service_status(project, name)
|
|
163
|
+
if row.get("kind") != "job":
|
|
164
|
+
raise GagarinError(409, "not_a_job", f"{name} is a {row.get('kind')} in this project, not a job")
|
|
165
|
+
return row
|
|
166
|
+
|
|
167
|
+
def wait(
|
|
168
|
+
self,
|
|
169
|
+
project: str,
|
|
170
|
+
name: str,
|
|
171
|
+
revision: int,
|
|
172
|
+
*,
|
|
173
|
+
poll_interval: float = 3.0,
|
|
174
|
+
timeout: float = 65 * 60,
|
|
175
|
+
on_update: Callable[[RunState], None] | None = None,
|
|
176
|
+
) -> RunState:
|
|
177
|
+
"""Blocks until the given revision of the job has finished, and returns
|
|
178
|
+
how it ended. A failed run is not an exception: read ``phase`` and
|
|
179
|
+
``exit_code``. The default timeout outlasts the platform's 60 minutes.
|
|
180
|
+
|
|
181
|
+
Raises :class:`SupersededError` if a newer run starts first,
|
|
182
|
+
:class:`SuspendedError` if the project is suspended, and
|
|
183
|
+
:class:`WaitTimeout` when ``timeout`` passes."""
|
|
184
|
+
deadline = time.monotonic() + timeout
|
|
185
|
+
last_phase, transient = None, 0
|
|
186
|
+
while True:
|
|
187
|
+
d = None
|
|
188
|
+
try:
|
|
189
|
+
d = (self.get(project, name).get("actual") or {}).get("run")
|
|
190
|
+
transient = 0
|
|
191
|
+
except Exception as e:
|
|
192
|
+
if not _retryable(e) or transient >= 5:
|
|
193
|
+
raise
|
|
194
|
+
transient += 1
|
|
195
|
+
if d:
|
|
196
|
+
run = RunState.from_json(d)
|
|
197
|
+
if run.revision > revision:
|
|
198
|
+
raise SupersededError(
|
|
199
|
+
409, "superseded", f"run {revision} of {name} was superseded by run {run.revision}"
|
|
200
|
+
)
|
|
201
|
+
if run.revision == revision:
|
|
202
|
+
if run.phase != last_phase and on_update:
|
|
203
|
+
on_update(run)
|
|
204
|
+
last_phase = run.phase
|
|
205
|
+
if run.finished:
|
|
206
|
+
return run
|
|
207
|
+
if run.phase == SUSPENDED:
|
|
208
|
+
raise SuspendedError(
|
|
209
|
+
409, "suspended", f"the project is suspended and run {revision} of {name} was stopped"
|
|
210
|
+
)
|
|
211
|
+
if time.monotonic() + poll_interval > deadline:
|
|
212
|
+
raise WaitTimeout(408, "wait_timeout", f"run {revision} of {name} had not finished in time")
|
|
213
|
+
time.sleep(poll_interval)
|
|
214
|
+
|
|
215
|
+
def run_and_wait(
|
|
216
|
+
self,
|
|
217
|
+
project: str,
|
|
218
|
+
name: str,
|
|
219
|
+
*,
|
|
220
|
+
poll_interval: float = 3.0,
|
|
221
|
+
timeout: float = 65 * 60,
|
|
222
|
+
on_update: Callable[[RunState], None] | None = None,
|
|
223
|
+
**run_options: Any,
|
|
224
|
+
) -> RunResult:
|
|
225
|
+
"""Submits a run and waits for it to finish — what ``gg run`` does.
|
|
226
|
+
Takes the settings :meth:`run` takes, except ``schedule``."""
|
|
227
|
+
if run_options.get("schedule"):
|
|
228
|
+
raise ValueError("gagarin: a scheduled job does not run now; submit it with run()")
|
|
229
|
+
sub = self.run(project, name, **run_options)
|
|
230
|
+
run = self.wait(project, name, sub.revision, poll_interval=poll_interval, timeout=timeout, on_update=on_update)
|
|
231
|
+
return RunResult(submission=sub, run=run)
|
|
232
|
+
|
|
233
|
+
def logs(self, project: str, name: str, **options: Any) -> Logs:
|
|
234
|
+
"""The job's logs; takes the filters :meth:`Services.logs` takes."""
|
|
235
|
+
return self._c.services.logs(project, name, **options)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from .errors import api_error
|
|
7
|
+
from .types import Health
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
10
|
+
from .client import Gagarin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Platform:
|
|
14
|
+
"""Whether gagarin itself is working."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client: Gagarin):
|
|
17
|
+
self._c = client
|
|
18
|
+
|
|
19
|
+
def health(self) -> Health:
|
|
20
|
+
"""The platform's health. An unhealthy platform is an answer, not an
|
|
21
|
+
exception: read ``ok`` and each check's ``detail``. An exception means
|
|
22
|
+
the API could not be asked at all."""
|
|
23
|
+
status, raw = self._c.send("GET", "/healthz/platform")
|
|
24
|
+
if status in (200, 503):
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(raw)
|
|
27
|
+
except ValueError:
|
|
28
|
+
pass
|
|
29
|
+
raise api_error(status, raw)
|