mini-runbot 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mini_runbot/__init__.py +3 -0
- mini_runbot/adapters/git/cli.py +165 -0
- mini_runbot/adapters/persistence/sqlite.py +270 -0
- mini_runbot/adapters/runtime/compose.py +323 -0
- mini_runbot/adapters/runtime/local.py +30 -0
- mini_runbot/adapters/runtime/ports.py +49 -0
- mini_runbot/api/app.py +147 -0
- mini_runbot/api/schemas.py +73 -0
- mini_runbot/application/build_manager.py +390 -0
- mini_runbot/application/executor.py +41 -0
- mini_runbot/application/scheduler.py +60 -0
- mini_runbot/bootstrap.py +28 -0
- mini_runbot/cli/init_config.py +228 -0
- mini_runbot/cli/main.py +346 -0
- mini_runbot/cli/presentation.py +167 -0
- mini_runbot/config.py +129 -0
- mini_runbot/domain/enums.py +24 -0
- mini_runbot/domain/errors.py +45 -0
- mini_runbot/domain/models.py +72 -0
- mini_runbot/domain/transitions.py +43 -0
- mini_runbot/domain/validation.py +88 -0
- mini_runbot/ports/repositories.py +23 -0
- mini_runbot/ports/services.py +62 -0
- mini_runbot/resources/config.example.yaml +22 -0
- mini_runbot/templates/compose.yaml.j2 +49 -0
- mini_runbot/web/app.js +247 -0
- mini_runbot/web/index.html +94 -0
- mini_runbot/web/styles.css +148 -0
- mini_runbot-0.2.0.dist-info/METADATA +146 -0
- mini_runbot-0.2.0.dist-info/RECORD +33 -0
- mini_runbot-0.2.0.dist-info/WHEEL +5 -0
- mini_runbot-0.2.0.dist-info/entry_points.txt +2 -0
- mini_runbot-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from jinja2 import (
|
|
11
|
+
Environment,
|
|
12
|
+
FileSystemLoader,
|
|
13
|
+
PackageLoader,
|
|
14
|
+
StrictUndefined,
|
|
15
|
+
select_autoescape,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from mini_runbot.config import Settings
|
|
19
|
+
from mini_runbot.domain.errors import RuntimeOperationError
|
|
20
|
+
from mini_runbot.domain.models import Build
|
|
21
|
+
from mini_runbot.ports.services import CommandResult, RuntimeInspection
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DockerComposeRuntimeService:
|
|
25
|
+
def __init__(self, settings: Settings, template_root: Path | None = None) -> None:
|
|
26
|
+
self.settings = settings
|
|
27
|
+
loader = (
|
|
28
|
+
FileSystemLoader(template_root)
|
|
29
|
+
if template_root is not None
|
|
30
|
+
else PackageLoader("mini_runbot", "templates")
|
|
31
|
+
)
|
|
32
|
+
self.environment = Environment(
|
|
33
|
+
loader=loader,
|
|
34
|
+
undefined=StrictUndefined,
|
|
35
|
+
autoescape=select_autoescape(default=False),
|
|
36
|
+
keep_trailing_newline=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def prepare(self, build_id: str, workspace_path: Path) -> None:
|
|
40
|
+
self._validated_workspace(build_id, workspace_path)
|
|
41
|
+
(workspace_path / "logs").mkdir(parents=True, exist_ok=True)
|
|
42
|
+
(workspace_path / "runtime").mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
def render(self, build: Build) -> CommandResult:
|
|
45
|
+
started = time.monotonic()
|
|
46
|
+
if build.host_port is None:
|
|
47
|
+
raise RuntimeOperationError("Build has no allocated host port")
|
|
48
|
+
if any(not repository.checkout_path for repository in build.repositories):
|
|
49
|
+
raise RuntimeOperationError("All repositories must be checked out before rendering")
|
|
50
|
+
repositories = sorted(
|
|
51
|
+
build.repositories, key=lambda item: (item.addons_priority, item.name)
|
|
52
|
+
)
|
|
53
|
+
addons_path = ",".join(
|
|
54
|
+
["/usr/lib/python3/dist-packages/odoo/addons"]
|
|
55
|
+
+ [f"/mnt/addons/{item.name}" for item in repositories]
|
|
56
|
+
)
|
|
57
|
+
rendered = self.environment.get_template("compose.yaml.j2").render(
|
|
58
|
+
compose_project_name=build.compose_project_name,
|
|
59
|
+
database_name=build.database_name,
|
|
60
|
+
host_port=build.host_port,
|
|
61
|
+
odoo_image=self.settings.odoo_image,
|
|
62
|
+
postgres_image=self.settings.postgres_image,
|
|
63
|
+
repositories=repositories,
|
|
64
|
+
addons_path=addons_path,
|
|
65
|
+
)
|
|
66
|
+
path = self._compose_path(build)
|
|
67
|
+
path.write_text(rendered, encoding="utf-8")
|
|
68
|
+
log_path = build.workspace_path / "logs" / "render.log"
|
|
69
|
+
log_path.write_text(f"Rendered {path.name}\n", encoding="utf-8")
|
|
70
|
+
return self._result(started, log_path, "Compose configuration rendered")
|
|
71
|
+
|
|
72
|
+
def validate(self, build: Build) -> CommandResult:
|
|
73
|
+
return self._run(build, "compose_validate", ["config", "--quiet"])
|
|
74
|
+
|
|
75
|
+
def start_database(self, build: Build) -> CommandResult:
|
|
76
|
+
return self._run(build, "database", ["up", "-d", "--wait", "db"])
|
|
77
|
+
|
|
78
|
+
def install_modules(self, build: Build) -> CommandResult:
|
|
79
|
+
arguments = [
|
|
80
|
+
"run",
|
|
81
|
+
"--rm",
|
|
82
|
+
"odoo",
|
|
83
|
+
"odoo",
|
|
84
|
+
"-d",
|
|
85
|
+
build.database_name,
|
|
86
|
+
"-i",
|
|
87
|
+
",".join(build.modules),
|
|
88
|
+
]
|
|
89
|
+
if not self.settings.load_demo_data:
|
|
90
|
+
arguments.append("--without-demo=all")
|
|
91
|
+
arguments.extend(
|
|
92
|
+
[
|
|
93
|
+
"--stop-after-init",
|
|
94
|
+
f"--addons-path={self._addons_path(build)}",
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
return self._run(
|
|
98
|
+
build,
|
|
99
|
+
"install",
|
|
100
|
+
arguments,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def test_modules(self, build: Build) -> CommandResult:
|
|
104
|
+
tags = ",".join(f"/{module}" for module in build.modules)
|
|
105
|
+
return self._run(
|
|
106
|
+
build,
|
|
107
|
+
"test",
|
|
108
|
+
[
|
|
109
|
+
"run",
|
|
110
|
+
"--rm",
|
|
111
|
+
"odoo",
|
|
112
|
+
"odoo",
|
|
113
|
+
"-d",
|
|
114
|
+
build.database_name,
|
|
115
|
+
"--test-enable",
|
|
116
|
+
f"--test-tags={tags}",
|
|
117
|
+
"--stop-after-init",
|
|
118
|
+
f"--addons-path={self._addons_path(build)}",
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def start_server(self, build: Build) -> CommandResult:
|
|
123
|
+
return self._run(build, "start", ["up", "-d", "odoo"])
|
|
124
|
+
|
|
125
|
+
def wait_healthy(self, build: Build) -> CommandResult:
|
|
126
|
+
if not build.preview_url:
|
|
127
|
+
raise RuntimeOperationError("Build has no preview URL")
|
|
128
|
+
started = time.monotonic()
|
|
129
|
+
deadline = started + self.settings.health_timeout_seconds
|
|
130
|
+
last_error = "no response"
|
|
131
|
+
while time.monotonic() < deadline:
|
|
132
|
+
try:
|
|
133
|
+
with urllib.request.urlopen(build.preview_url, timeout=5) as response:
|
|
134
|
+
if response.status < 500:
|
|
135
|
+
log_path = build.workspace_path / "logs" / "healthcheck.log"
|
|
136
|
+
log_path.write_text(
|
|
137
|
+
f"HTTP {response.status} from {build.preview_url}\n", encoding="utf-8"
|
|
138
|
+
)
|
|
139
|
+
return self._result(started, log_path, "Odoo HTTP healthcheck passed")
|
|
140
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
141
|
+
last_error = str(exc)
|
|
142
|
+
time.sleep(1)
|
|
143
|
+
raise RuntimeOperationError(f"Odoo healthcheck timed out: {last_error}")
|
|
144
|
+
|
|
145
|
+
def inspect(self, build: Build) -> RuntimeInspection:
|
|
146
|
+
compose_path = self._compose_path(build)
|
|
147
|
+
if not compose_path.is_file():
|
|
148
|
+
return RuntimeInspection(False, False, (), "Compose configuration is absent")
|
|
149
|
+
command = [
|
|
150
|
+
"docker",
|
|
151
|
+
"compose",
|
|
152
|
+
"--project-name",
|
|
153
|
+
build.compose_project_name,
|
|
154
|
+
"--file",
|
|
155
|
+
str(compose_path),
|
|
156
|
+
"ps",
|
|
157
|
+
"--all",
|
|
158
|
+
"--format",
|
|
159
|
+
"json",
|
|
160
|
+
]
|
|
161
|
+
try:
|
|
162
|
+
result = subprocess.run(
|
|
163
|
+
command,
|
|
164
|
+
cwd=build.workspace_path,
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
timeout=self.settings.command_timeout_seconds,
|
|
168
|
+
check=False,
|
|
169
|
+
)
|
|
170
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
171
|
+
raise RuntimeOperationError(
|
|
172
|
+
f"Docker runtime inspection could not complete: {exc}"
|
|
173
|
+
) from exc
|
|
174
|
+
log_path = build.workspace_path / "logs" / "runtime_inspect.log"
|
|
175
|
+
log_path.write_text(f"{result.stdout}{result.stderr}"[-1_000_000:], encoding="utf-8")
|
|
176
|
+
if result.returncode != 0:
|
|
177
|
+
raise RuntimeOperationError(f"Docker runtime inspection failed; see {log_path}")
|
|
178
|
+
entries = self._parse_compose_ps(result.stdout)
|
|
179
|
+
services = tuple(
|
|
180
|
+
sorted(
|
|
181
|
+
str(item.get("Service"))
|
|
182
|
+
for item in entries
|
|
183
|
+
if item.get("Service")
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
running_services = {
|
|
187
|
+
str(item.get("Service"))
|
|
188
|
+
for item in entries
|
|
189
|
+
if str(item.get("State", "")).lower() == "running"
|
|
190
|
+
}
|
|
191
|
+
db_entries = [item for item in entries if item.get("Service") == "db"]
|
|
192
|
+
database_healthy = all(
|
|
193
|
+
str(item.get("Health", "")).lower() in {"", "healthy"}
|
|
194
|
+
for item in db_entries
|
|
195
|
+
)
|
|
196
|
+
expected = {"db", "odoo"}
|
|
197
|
+
running = expected.issubset(running_services) and database_healthy
|
|
198
|
+
summary = (
|
|
199
|
+
"Docker runtime is running"
|
|
200
|
+
if running
|
|
201
|
+
else f"Docker runtime incomplete; services={','.join(services) or 'none'}"
|
|
202
|
+
)
|
|
203
|
+
return RuntimeInspection(bool(entries), running, services, summary)
|
|
204
|
+
|
|
205
|
+
def destroy(self, build_id: str, workspace_path: Path) -> None:
|
|
206
|
+
workspace = self._validated_workspace(build_id, workspace_path)
|
|
207
|
+
compose_path = workspace / "runtime" / "compose.yaml"
|
|
208
|
+
if compose_path.exists():
|
|
209
|
+
result = subprocess.run(
|
|
210
|
+
[
|
|
211
|
+
"docker",
|
|
212
|
+
"compose",
|
|
213
|
+
"--project-name",
|
|
214
|
+
build_id.replace("-", "_"),
|
|
215
|
+
"--file",
|
|
216
|
+
str(compose_path),
|
|
217
|
+
"down",
|
|
218
|
+
"--volumes",
|
|
219
|
+
"--remove-orphans",
|
|
220
|
+
],
|
|
221
|
+
cwd=workspace,
|
|
222
|
+
capture_output=True,
|
|
223
|
+
text=True,
|
|
224
|
+
timeout=self.settings.command_timeout_seconds,
|
|
225
|
+
check=False,
|
|
226
|
+
)
|
|
227
|
+
log_path = workspace / "logs" / "destroy.log"
|
|
228
|
+
log_path.write_text(f"{result.stdout}{result.stderr}"[-1_000_000:], encoding="utf-8")
|
|
229
|
+
if result.returncode != 0:
|
|
230
|
+
raise RuntimeOperationError(f"Compose destruction failed; see {log_path}")
|
|
231
|
+
|
|
232
|
+
def _run(self, build: Build, stage: str, arguments: list[str]) -> CommandResult:
|
|
233
|
+
compose_path = self._compose_path(build)
|
|
234
|
+
command = [
|
|
235
|
+
"docker",
|
|
236
|
+
"compose",
|
|
237
|
+
"--project-name",
|
|
238
|
+
build.compose_project_name,
|
|
239
|
+
"--file",
|
|
240
|
+
str(compose_path),
|
|
241
|
+
*arguments,
|
|
242
|
+
]
|
|
243
|
+
started = time.monotonic()
|
|
244
|
+
try:
|
|
245
|
+
result = subprocess.run(
|
|
246
|
+
command,
|
|
247
|
+
cwd=build.workspace_path,
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
timeout=self.settings.command_timeout_seconds,
|
|
251
|
+
check=False,
|
|
252
|
+
)
|
|
253
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
254
|
+
raise RuntimeOperationError(f"{stage} command could not complete: {exc}") from exc
|
|
255
|
+
log_path = build.workspace_path / "logs" / f"{stage}.log"
|
|
256
|
+
log_path.write_text(f"{result.stdout}{result.stderr}"[-1_000_000:], encoding="utf-8")
|
|
257
|
+
if result.returncode != 0:
|
|
258
|
+
raise RuntimeOperationError(
|
|
259
|
+
f"{stage} failed with exit code {result.returncode}",
|
|
260
|
+
exit_code=result.returncode,
|
|
261
|
+
log_path=str(log_path),
|
|
262
|
+
)
|
|
263
|
+
return CommandResult(
|
|
264
|
+
exit_code=result.returncode,
|
|
265
|
+
duration_seconds=time.monotonic() - started,
|
|
266
|
+
log_path=str(log_path),
|
|
267
|
+
summary=f"{stage} completed",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _compose_path(self, build: Build) -> Path:
|
|
271
|
+
self._validated_workspace(build.id, build.workspace_path)
|
|
272
|
+
return build.workspace_path / "runtime" / "compose.yaml"
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _addons_path(build: Build) -> str:
|
|
276
|
+
repositories = sorted(
|
|
277
|
+
build.repositories, key=lambda item: (item.addons_priority, item.name)
|
|
278
|
+
)
|
|
279
|
+
return ",".join(
|
|
280
|
+
["/usr/lib/python3/dist-packages/odoo/addons"]
|
|
281
|
+
+ [f"/mnt/addons/{item.name}" for item in repositories]
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _validated_workspace(self, build_id: str, workspace_path: Path) -> Path:
|
|
285
|
+
root = self.settings.builds_root.resolve()
|
|
286
|
+
actual = workspace_path.resolve()
|
|
287
|
+
if actual != (root / build_id).resolve() or actual.parent != root:
|
|
288
|
+
raise RuntimeOperationError(f"Workspace is outside configured root: {actual}")
|
|
289
|
+
return actual
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _result(started: float, log_path: Path, summary: str) -> CommandResult:
|
|
293
|
+
return CommandResult(
|
|
294
|
+
exit_code=0,
|
|
295
|
+
duration_seconds=time.monotonic() - started,
|
|
296
|
+
log_path=str(log_path),
|
|
297
|
+
summary=summary,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _parse_compose_ps(output: str) -> list[dict[str, object]]:
|
|
302
|
+
content = output.strip()
|
|
303
|
+
if not content:
|
|
304
|
+
return []
|
|
305
|
+
try:
|
|
306
|
+
parsed = json.loads(content)
|
|
307
|
+
if isinstance(parsed, list):
|
|
308
|
+
return [item for item in parsed if isinstance(item, dict)]
|
|
309
|
+
if isinstance(parsed, dict):
|
|
310
|
+
return [parsed]
|
|
311
|
+
except json.JSONDecodeError:
|
|
312
|
+
entries: list[dict[str, object]] = []
|
|
313
|
+
for line in content.splitlines():
|
|
314
|
+
try:
|
|
315
|
+
item = json.loads(line)
|
|
316
|
+
except json.JSONDecodeError as exc:
|
|
317
|
+
raise RuntimeOperationError(
|
|
318
|
+
"Docker runtime inspection returned invalid JSON"
|
|
319
|
+
) from exc
|
|
320
|
+
if isinstance(item, dict):
|
|
321
|
+
entries.append(item)
|
|
322
|
+
return entries
|
|
323
|
+
raise RuntimeOperationError("Docker runtime inspection returned invalid JSON")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from mini_runbot.domain.errors import UnsafePathError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LocalRuntimeService:
|
|
8
|
+
"""Controlled Phase 0 runtime that owns only a per-build runtime directory."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, builds_root: Path) -> None:
|
|
11
|
+
self.builds_root = builds_root.resolve()
|
|
12
|
+
|
|
13
|
+
def _validate_workspace(self, build_id: str, workspace_path: Path) -> Path:
|
|
14
|
+
expected = (self.builds_root / build_id).resolve()
|
|
15
|
+
actual = workspace_path.resolve()
|
|
16
|
+
if actual != expected or actual.parent != self.builds_root:
|
|
17
|
+
raise UnsafePathError(f"Workspace is outside the configured build root: {actual}")
|
|
18
|
+
return actual
|
|
19
|
+
|
|
20
|
+
def prepare(self, build_id: str, workspace_path: Path) -> None:
|
|
21
|
+
workspace = self._validate_workspace(build_id, workspace_path)
|
|
22
|
+
(workspace / "logs").mkdir(parents=True, exist_ok=True)
|
|
23
|
+
(workspace / "runtime").mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
def destroy(self, build_id: str, workspace_path: Path) -> None:
|
|
26
|
+
workspace = self._validate_workspace(build_id, workspace_path)
|
|
27
|
+
runtime_path = workspace / "runtime"
|
|
28
|
+
if runtime_path.exists():
|
|
29
|
+
shutil.rmtree(runtime_path)
|
|
30
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from threading import Lock
|
|
3
|
+
|
|
4
|
+
from mini_runbot.domain.errors import RuntimeOperationError
|
|
5
|
+
from mini_runbot.ports.repositories import PortLeaseStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SocketPortAllocator:
|
|
9
|
+
def __init__(
|
|
10
|
+
self, start: int, end: int, lease_store: PortLeaseStore | None = None
|
|
11
|
+
) -> None:
|
|
12
|
+
if not 1024 <= start <= end <= 65535:
|
|
13
|
+
raise ValueError("Port range must be between 1024 and 65535")
|
|
14
|
+
self.start = start
|
|
15
|
+
self.end = end
|
|
16
|
+
self.lease_store = lease_store
|
|
17
|
+
self._allocated: dict[int, str] = {}
|
|
18
|
+
self._lock = Lock()
|
|
19
|
+
|
|
20
|
+
def allocate(self, build_id: str, excluded: set[int] | None = None) -> int:
|
|
21
|
+
excluded = excluded or set()
|
|
22
|
+
with self._lock:
|
|
23
|
+
for port in range(self.start, self.end + 1):
|
|
24
|
+
if port in self._allocated or port in excluded:
|
|
25
|
+
continue
|
|
26
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as candidate:
|
|
27
|
+
try:
|
|
28
|
+
candidate.bind(("127.0.0.1", port))
|
|
29
|
+
except OSError:
|
|
30
|
+
continue
|
|
31
|
+
if self.lease_store and not self.lease_store.try_acquire_port(
|
|
32
|
+
build_id, port
|
|
33
|
+
):
|
|
34
|
+
continue
|
|
35
|
+
self._allocated[port] = build_id
|
|
36
|
+
return port
|
|
37
|
+
raise RuntimeOperationError("No preview ports are available in the configured range")
|
|
38
|
+
|
|
39
|
+
def release(self, build_id: str, port: int) -> None:
|
|
40
|
+
with self._lock:
|
|
41
|
+
if self._allocated.get(port) == build_id:
|
|
42
|
+
self._allocated.pop(port)
|
|
43
|
+
if self.lease_store:
|
|
44
|
+
self.lease_store.release_port(build_id, port)
|
|
45
|
+
|
|
46
|
+
def reconcile(self) -> list[int]:
|
|
47
|
+
if not self.lease_store:
|
|
48
|
+
return []
|
|
49
|
+
return self.lease_store.reconcile_port_leases()
|
mini_runbot/api/app.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, status
|
|
7
|
+
from fastapi.responses import FileResponse
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
|
|
10
|
+
from mini_runbot.api.schemas import BuildLogsResponse, BuildResponse, PublicConfigResponse
|
|
11
|
+
from mini_runbot.application.build_manager import BuildManager
|
|
12
|
+
from mini_runbot.application.executor import LocalBuildExecutor
|
|
13
|
+
from mini_runbot.application.scheduler import CleanupScheduler
|
|
14
|
+
from mini_runbot.bootstrap import create_manager
|
|
15
|
+
from mini_runbot.config import Settings
|
|
16
|
+
from mini_runbot.domain.errors import BuildNotFoundError, MiniRunbotError
|
|
17
|
+
from mini_runbot.domain.validation import CreateBuildRequest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@asynccontextmanager
|
|
21
|
+
async def lifespan(application: FastAPI):
|
|
22
|
+
settings = Settings.from_environment()
|
|
23
|
+
scheduler: CleanupScheduler | None = None
|
|
24
|
+
if settings.cleanup_interval_seconds > 0:
|
|
25
|
+
scheduler = CleanupScheduler(get_manager(), settings.cleanup_interval_seconds)
|
|
26
|
+
scheduler.start()
|
|
27
|
+
application.state.cleanup_scheduler = scheduler
|
|
28
|
+
try:
|
|
29
|
+
yield
|
|
30
|
+
finally:
|
|
31
|
+
if scheduler is not None:
|
|
32
|
+
scheduler.stop()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
app = FastAPI(title="Mini-Runbot", version="0.2.0", lifespan=lifespan)
|
|
36
|
+
web_root = Path(__file__).resolve().parents[1] / "web"
|
|
37
|
+
app.mount("/assets", StaticFiles(directory=web_root), name="assets")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@lru_cache(maxsize=1)
|
|
41
|
+
def get_manager() -> BuildManager:
|
|
42
|
+
return create_manager(docker=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ManagerDependency = Annotated[BuildManager, Depends(get_manager)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=1)
|
|
49
|
+
def get_executor() -> LocalBuildExecutor:
|
|
50
|
+
settings = Settings.from_environment()
|
|
51
|
+
return LocalBuildExecutor(get_manager(), max_workers=settings.max_concurrent_builds)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
ExecutorDependency = Annotated[LocalBuildExecutor, Depends(get_executor)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.get("/", include_in_schema=False)
|
|
58
|
+
def dashboard() -> FileResponse:
|
|
59
|
+
return FileResponse(web_root / "index.html")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/app-config", response_model=PublicConfigResponse)
|
|
63
|
+
def public_config() -> PublicConfigResponse:
|
|
64
|
+
settings = Settings.from_environment()
|
|
65
|
+
return PublicConfigResponse(
|
|
66
|
+
repositories=[
|
|
67
|
+
{
|
|
68
|
+
"alias": alias,
|
|
69
|
+
"default_ref": repository.default_ref,
|
|
70
|
+
"allow_request_ref": repository.allow_request_ref,
|
|
71
|
+
"addons_priority": repository.addons_priority,
|
|
72
|
+
}
|
|
73
|
+
for alias, repository in sorted(settings.repositories.items())
|
|
74
|
+
],
|
|
75
|
+
max_concurrent_builds=settings.max_concurrent_builds,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.post("/builds", response_model=BuildResponse, status_code=status.HTTP_202_ACCEPTED)
|
|
80
|
+
def create_build(
|
|
81
|
+
request: CreateBuildRequest,
|
|
82
|
+
manager: ManagerDependency,
|
|
83
|
+
executor: ExecutorDependency,
|
|
84
|
+
) -> BuildResponse:
|
|
85
|
+
if isinstance(executor, LocalBuildExecutor) and not manager.execution_ready:
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=503,
|
|
88
|
+
detail="No repositories are configured for build execution",
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
build = manager.create(request)
|
|
92
|
+
except MiniRunbotError as exc:
|
|
93
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
94
|
+
executor.submit(build.id)
|
|
95
|
+
return BuildResponse.from_domain(build)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.get("/builds", response_model=list[BuildResponse])
|
|
99
|
+
def list_builds(manager: ManagerDependency) -> list[BuildResponse]:
|
|
100
|
+
return [BuildResponse.from_domain(build) for build in manager.list()]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.get("/builds/{build_id}", response_model=BuildResponse)
|
|
104
|
+
def get_build(build_id: str, manager: ManagerDependency) -> BuildResponse:
|
|
105
|
+
try:
|
|
106
|
+
return BuildResponse.from_domain(manager.get(build_id))
|
|
107
|
+
except BuildNotFoundError as exc:
|
|
108
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.delete("/builds/{build_id}", response_model=BuildResponse)
|
|
112
|
+
def destroy_build(
|
|
113
|
+
build_id: str,
|
|
114
|
+
manager: ManagerDependency,
|
|
115
|
+
executor: ExecutorDependency,
|
|
116
|
+
) -> BuildResponse:
|
|
117
|
+
try:
|
|
118
|
+
manager.get(build_id)
|
|
119
|
+
if executor.is_active(build_id):
|
|
120
|
+
raise HTTPException(
|
|
121
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
122
|
+
detail=(
|
|
123
|
+
"Build execution is still active; wait for it to finish before destroying it"
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
return BuildResponse.from_domain(manager.destroy(build_id))
|
|
127
|
+
except BuildNotFoundError as exc:
|
|
128
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.get("/builds/{build_id}/logs", response_model=BuildLogsResponse)
|
|
132
|
+
def get_build_logs(
|
|
133
|
+
build_id: str,
|
|
134
|
+
manager: ManagerDependency,
|
|
135
|
+
stage_name: Annotated[str | None, Query(alias="stage")] = None,
|
|
136
|
+
) -> BuildLogsResponse:
|
|
137
|
+
limit = 100_000
|
|
138
|
+
try:
|
|
139
|
+
content = manager.read_logs(build_id, stage_name, max_bytes=limit)
|
|
140
|
+
except BuildNotFoundError as exc:
|
|
141
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
142
|
+
return BuildLogsResponse(
|
|
143
|
+
build_id=build_id,
|
|
144
|
+
stage=stage_name,
|
|
145
|
+
content=content,
|
|
146
|
+
truncated_to_bytes=limit,
|
|
147
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from mini_runbot.domain.enums import BuildStatus, StageStatus
|
|
6
|
+
from mini_runbot.domain.models import Build
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RepositoryResponse(BaseModel):
|
|
10
|
+
model_config = ConfigDict(from_attributes=True)
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
requested_ref: str
|
|
14
|
+
commit_sha: str | None
|
|
15
|
+
checkout_path: str | None
|
|
16
|
+
addons_priority: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StageResponse(BaseModel):
|
|
20
|
+
model_config = ConfigDict(from_attributes=True)
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
status: StageStatus
|
|
24
|
+
started_at: datetime | None
|
|
25
|
+
finished_at: datetime | None
|
|
26
|
+
duration_seconds: float | None
|
|
27
|
+
exit_code: int | None
|
|
28
|
+
log_path: str | None
|
|
29
|
+
summary: str | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BuildResponse(BaseModel):
|
|
33
|
+
model_config = ConfigDict(from_attributes=True)
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
status: BuildStatus
|
|
37
|
+
requested_ref: str
|
|
38
|
+
repositories: list[RepositoryResponse]
|
|
39
|
+
modules: list[str]
|
|
40
|
+
created_at: datetime
|
|
41
|
+
started_at: datetime | None
|
|
42
|
+
finished_at: datetime | None
|
|
43
|
+
expires_at: datetime
|
|
44
|
+
host_port: int | None
|
|
45
|
+
preview_url: str | None
|
|
46
|
+
failure_stage: str | None
|
|
47
|
+
failure_message: str | None
|
|
48
|
+
stages: list[StageResponse]
|
|
49
|
+
version: int
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_domain(cls, build: Build) -> "BuildResponse":
|
|
53
|
+
return cls.model_validate(build)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BuildLogsResponse(BaseModel):
|
|
57
|
+
build_id: str
|
|
58
|
+
stage: str | None
|
|
59
|
+
content: str
|
|
60
|
+
truncated_to_bytes: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RepositoryOptionResponse(BaseModel):
|
|
64
|
+
alias: str
|
|
65
|
+
default_ref: str
|
|
66
|
+
allow_request_ref: bool
|
|
67
|
+
addons_priority: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PublicConfigResponse(BaseModel):
|
|
71
|
+
repositories: list[RepositoryOptionResponse]
|
|
72
|
+
default_ttl_seconds: int = 14_400
|
|
73
|
+
max_concurrent_builds: int
|