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.
@@ -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