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,3 @@
1
+ """Mini-Runbot proof of concept."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from dataclasses import replace
5
+ from pathlib import Path
6
+ from urllib.parse import urlsplit
7
+
8
+ from mini_runbot.config import Settings
9
+ from mini_runbot.domain.errors import GitOperationError, UnsafePathError
10
+ from mini_runbot.domain.models import RepositoryRevision
11
+
12
+ REMOTE_GIT_SCHEMES = {"https", "ssh"}
13
+
14
+
15
+ def is_remote_git_url(value: str) -> bool:
16
+ """Return whether a configured source is an allowed remote Git URL."""
17
+ parsed = urlsplit(value)
18
+ if parsed.scheme.lower() in REMOTE_GIT_SCHEMES:
19
+ return True
20
+ return value.startswith("git@") and ":" in value.partition("@")[2]
21
+
22
+
23
+ def validate_remote_git_url(value: str) -> None:
24
+ parsed = urlsplit(value)
25
+ if parsed.scheme:
26
+ if parsed.scheme.lower() not in REMOTE_GIT_SCHEMES:
27
+ raise GitOperationError(
28
+ "Remote repository URL must use HTTPS or SSH"
29
+ )
30
+ if not parsed.hostname:
31
+ raise GitOperationError("Remote repository URL must include a host")
32
+ if parsed.scheme.lower() == "https" and (parsed.username or parsed.password):
33
+ raise GitOperationError(
34
+ "HTTPS repository URLs must not contain embedded credentials"
35
+ )
36
+ if parsed.password:
37
+ raise GitOperationError(
38
+ "SSH repository URLs must not contain an embedded password"
39
+ )
40
+ return
41
+ if value.startswith("git@") and ":" in value.partition("@")[2]:
42
+ return
43
+ raise GitOperationError("Remote repository URL must use HTTPS or SSH")
44
+
45
+
46
+ class GitCliService:
47
+ def __init__(self, settings: Settings) -> None:
48
+ self.settings = settings
49
+
50
+ def checkout(
51
+ self,
52
+ revision: RepositoryRevision,
53
+ workspace_path: Path,
54
+ log_path: Path,
55
+ ) -> RepositoryRevision:
56
+ config = self.settings.repository(revision.name, revision.requested_ref)
57
+ sources_root = (workspace_path / "sources").resolve()
58
+ target = (sources_root / config.target).resolve()
59
+ if target.parent != sources_root:
60
+ raise UnsafePathError(f"Checkout target escaped sources directory: {target}")
61
+ if target.exists():
62
+ raise GitOperationError(f"Checkout target already exists: {target}")
63
+ target.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ log_path.parent.mkdir(parents=True, exist_ok=True)
66
+ if is_remote_git_url(config.url) or "://" in config.url:
67
+ source = config.url
68
+ sha, output = self._checkout_remote(source, revision.requested_ref, target)
69
+ else:
70
+ source_path = Path(config.url).expanduser().resolve()
71
+ if not source_path.is_dir():
72
+ raise GitOperationError(
73
+ f"Configured repository does not exist: {source_path}"
74
+ )
75
+ source_check = self._run(
76
+ ["git", "-C", str(source_path), "rev-parse", "--is-inside-work-tree"]
77
+ )
78
+ if source_check.strip() != "true":
79
+ raise GitOperationError(
80
+ f"Configured source is not a Git repository: {source_path}"
81
+ )
82
+ sha = self._run(
83
+ [
84
+ "git",
85
+ "-C",
86
+ str(source_path),
87
+ "rev-parse",
88
+ "--verify",
89
+ f"{revision.requested_ref}^{{commit}}",
90
+ ]
91
+ ).strip()
92
+ output = self._run(
93
+ ["git", "clone", "--no-checkout", "--", str(source_path), str(target)]
94
+ )
95
+ output += self._run(
96
+ ["git", "-C", str(target), "checkout", "--detach", sha]
97
+ )
98
+ source = str(source_path)
99
+ with log_path.open("a", encoding="utf-8") as log:
100
+ log.write(f"== {revision.name} @ {revision.requested_ref} ==\n")
101
+ log.write(output)
102
+ if output and not output.endswith("\n"):
103
+ log.write("\n")
104
+ addons_path = (target / config.addons_subpath).resolve()
105
+ if target not in {addons_path, *addons_path.parents} or not addons_path.is_dir():
106
+ raise GitOperationError(
107
+ f"Configured addons_subpath does not exist inside checkout: {config.addons_subpath}"
108
+ )
109
+ return replace(
110
+ revision,
111
+ source=source,
112
+ commit_sha=sha,
113
+ checkout_path=str(addons_path),
114
+ addons_priority=config.addons_priority,
115
+ )
116
+
117
+ def _checkout_remote(
118
+ self, source: str, requested_ref: str, target: Path
119
+ ) -> tuple[str, str]:
120
+ validate_remote_git_url(source)
121
+ target.mkdir()
122
+ output = self._run(["git", "init", str(target)])
123
+ output += self._run(
124
+ ["git", "-C", str(target), "remote", "add", "origin", source]
125
+ )
126
+ output += self._run(
127
+ [
128
+ "git",
129
+ "-C",
130
+ str(target),
131
+ "fetch",
132
+ "--depth=1",
133
+ "--no-tags",
134
+ "origin",
135
+ requested_ref,
136
+ ]
137
+ )
138
+ sha = self._run(
139
+ [
140
+ "git",
141
+ "-C",
142
+ str(target),
143
+ "rev-parse",
144
+ "--verify",
145
+ "FETCH_HEAD^{commit}",
146
+ ]
147
+ ).strip()
148
+ output += self._run(
149
+ ["git", "-C", str(target), "checkout", "--detach", sha]
150
+ )
151
+ return sha, output
152
+
153
+ @staticmethod
154
+ def _run(arguments: list[str]) -> str:
155
+ try:
156
+ result = subprocess.run(
157
+ arguments, capture_output=True, text=True, timeout=120, check=False
158
+ )
159
+ except (OSError, subprocess.TimeoutExpired) as exc:
160
+ raise GitOperationError(f"Git command could not run: {exc}") from exc
161
+ output = f"{result.stdout}{result.stderr}"
162
+ if result.returncode != 0:
163
+ safe_command = " ".join(arguments[:3])
164
+ raise GitOperationError(f"Git command failed ({safe_command}): {output[-500:]}")
165
+ return output
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+
6
+ from sqlalchemy import (
7
+ JSON,
8
+ DateTime,
9
+ Integer,
10
+ String,
11
+ create_engine,
12
+ delete,
13
+ inspect,
14
+ select,
15
+ text,
16
+ update,
17
+ )
18
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
19
+ from sqlalchemy.exc import IntegrityError
20
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
21
+
22
+ from mini_runbot.domain.enums import BuildStatus, StageStatus
23
+ from mini_runbot.domain.errors import ConcurrentUpdateError
24
+ from mini_runbot.domain.models import Build, RepositoryRevision, StageResult
25
+
26
+
27
+ class Base(DeclarativeBase):
28
+ pass
29
+
30
+
31
+ class BuildRow(Base):
32
+ __tablename__ = "builds"
33
+
34
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
35
+ status: Mapped[str] = mapped_column(String(32), nullable=False)
36
+ requested_ref: Mapped[str] = mapped_column(String(200), nullable=False)
37
+ repositories: Mapped[list[dict[str, object]]] = mapped_column(JSON, nullable=False)
38
+ modules: Mapped[list[str]] = mapped_column(JSON, nullable=False)
39
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
40
+ started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
41
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
42
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
43
+ host_port: Mapped[int | None] = mapped_column(Integer)
44
+ database_name: Mapped[str] = mapped_column(String(64), nullable=False)
45
+ compose_project_name: Mapped[str] = mapped_column(String(64), nullable=False)
46
+ workspace_path: Mapped[str] = mapped_column(String(1024), nullable=False)
47
+ preview_url: Mapped[str | None] = mapped_column(String(2048))
48
+ failure_stage: Mapped[str | None] = mapped_column(String(64))
49
+ failure_message: Mapped[str | None] = mapped_column(String(1000))
50
+ stages: Mapped[list[dict[str, object]]] = mapped_column(JSON, nullable=False, default=list)
51
+ version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
52
+
53
+
54
+ class PortLeaseRow(Base):
55
+ __tablename__ = "port_leases"
56
+
57
+ port: Mapped[int] = mapped_column(Integer, primary_key=True)
58
+ build_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
59
+
60
+
61
+ class SqliteBuildRepository:
62
+ def __init__(self, database_url: str) -> None:
63
+ connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
64
+ self.engine = create_engine(database_url, connect_args=connect_args)
65
+ self.sessions = sessionmaker(self.engine, expire_on_commit=False)
66
+
67
+ def create_schema(self) -> None:
68
+ Base.metadata.create_all(self.engine)
69
+ columns = {item["name"] for item in inspect(self.engine).get_columns("builds")}
70
+ if "stages" not in columns:
71
+ with self.engine.begin() as connection:
72
+ connection.execute(
73
+ text("ALTER TABLE builds ADD COLUMN stages JSON NOT NULL DEFAULT '[]'")
74
+ )
75
+ self._seed_port_leases()
76
+
77
+ def add(self, build: Build) -> None:
78
+ with self.sessions.begin() as session:
79
+ session.add(self._to_row(build))
80
+
81
+ def get(self, build_id: str) -> Build | None:
82
+ with self.sessions() as session:
83
+ row = session.get(BuildRow, build_id)
84
+ return self._to_domain(row) if row else None
85
+
86
+ def list(self) -> list[Build]:
87
+ with self.sessions() as session:
88
+ rows = session.scalars(select(BuildRow).order_by(BuildRow.created_at.desc())).all()
89
+ return [self._to_domain(row) for row in rows]
90
+
91
+ def update(self, build: Build) -> None:
92
+ previous_version = build.version
93
+ values = self._values(build)
94
+ values["version"] = previous_version + 1
95
+ with self.sessions.begin() as session:
96
+ result = session.execute(
97
+ update(BuildRow)
98
+ .where(BuildRow.id == build.id, BuildRow.version == previous_version)
99
+ .values(**values)
100
+ )
101
+ if result.rowcount != 1:
102
+ raise ConcurrentUpdateError(f"Build {build.id} changed concurrently")
103
+ build.version += 1
104
+
105
+ def delete(self, build_id: str) -> None:
106
+ with self.sessions.begin() as session:
107
+ session.execute(
108
+ delete(PortLeaseRow).where(PortLeaseRow.build_id == build_id)
109
+ )
110
+ session.execute(delete(BuildRow).where(BuildRow.id == build_id))
111
+
112
+ def try_acquire_port(self, build_id: str, port: int) -> bool:
113
+ try:
114
+ with self.sessions.begin() as session:
115
+ existing = session.scalar(
116
+ select(PortLeaseRow).where(PortLeaseRow.build_id == build_id)
117
+ )
118
+ if existing is not None:
119
+ return existing.port == port
120
+ session.add(PortLeaseRow(port=port, build_id=build_id))
121
+ session.flush()
122
+ except IntegrityError:
123
+ return False
124
+ return True
125
+
126
+ def release_port(self, build_id: str, port: int) -> None:
127
+ with self.sessions.begin() as session:
128
+ session.execute(
129
+ delete(PortLeaseRow).where(
130
+ PortLeaseRow.port == port, PortLeaseRow.build_id == build_id
131
+ )
132
+ )
133
+
134
+ def reconcile_port_leases(self) -> list[int]:
135
+ terminal = {BuildStatus.DESTROYED.value}
136
+ released: list[int] = []
137
+ with self.sessions.begin() as session:
138
+ builds = {row.id: row for row in session.scalars(select(BuildRow)).all()}
139
+ leases = session.scalars(select(PortLeaseRow)).all()
140
+ for lease in leases:
141
+ build = builds.get(lease.build_id)
142
+ if (
143
+ build is None
144
+ or build.status in terminal
145
+ or build.host_port != lease.port
146
+ ):
147
+ released.append(lease.port)
148
+ session.delete(lease)
149
+ self._insert_missing_port_leases(session, builds.values(), terminal)
150
+ return released
151
+
152
+ def _seed_port_leases(self) -> None:
153
+ terminal = {BuildStatus.DESTROYED.value}
154
+ with self.sessions.begin() as session:
155
+ self._insert_missing_port_leases(
156
+ session, session.scalars(select(BuildRow)).all(), terminal
157
+ )
158
+
159
+ @staticmethod
160
+ def _insert_missing_port_leases(session, builds, terminal: set[str]) -> None:
161
+ for build in builds:
162
+ if build.host_port is None or build.status in terminal:
163
+ continue
164
+ statement = (
165
+ sqlite_insert(PortLeaseRow)
166
+ .values(port=build.host_port, build_id=build.id)
167
+ .on_conflict_do_nothing()
168
+ )
169
+ session.execute(statement)
170
+
171
+ @staticmethod
172
+ def _repo_dict(repo: RepositoryRevision) -> dict[str, object]:
173
+ return {
174
+ "name": repo.name,
175
+ "source": repo.source,
176
+ "requested_ref": repo.requested_ref,
177
+ "commit_sha": repo.commit_sha,
178
+ "checkout_path": repo.checkout_path,
179
+ "addons_priority": repo.addons_priority,
180
+ }
181
+
182
+ def _values(self, build: Build) -> dict[str, object]:
183
+ return {
184
+ "status": build.status.value,
185
+ "requested_ref": build.requested_ref,
186
+ "repositories": [self._repo_dict(repo) for repo in build.repositories],
187
+ "modules": build.modules,
188
+ "created_at": build.created_at,
189
+ "started_at": build.started_at,
190
+ "finished_at": build.finished_at,
191
+ "expires_at": build.expires_at,
192
+ "host_port": build.host_port,
193
+ "database_name": build.database_name,
194
+ "compose_project_name": build.compose_project_name,
195
+ "workspace_path": str(build.workspace_path),
196
+ "preview_url": build.preview_url,
197
+ "failure_stage": build.failure_stage,
198
+ "failure_message": build.failure_message,
199
+ "stages": [self._stage_dict(stage) for stage in build.stages],
200
+ }
201
+
202
+ @staticmethod
203
+ def _stage_dict(stage: StageResult) -> dict[str, object]:
204
+ return {
205
+ "name": stage.name,
206
+ "status": stage.status.value,
207
+ "started_at": stage.started_at.isoformat() if stage.started_at else None,
208
+ "finished_at": stage.finished_at.isoformat() if stage.finished_at else None,
209
+ "duration_seconds": stage.duration_seconds,
210
+ "exit_code": stage.exit_code,
211
+ "log_path": stage.log_path,
212
+ "summary": stage.summary,
213
+ "metadata": stage.metadata,
214
+ }
215
+
216
+ def _to_row(self, build: Build) -> BuildRow:
217
+ return BuildRow(id=build.id, version=build.version, **self._values(build))
218
+
219
+ @staticmethod
220
+ def _to_domain(row: BuildRow) -> Build:
221
+ return Build(
222
+ id=row.id,
223
+ status=BuildStatus(row.status),
224
+ requested_ref=row.requested_ref,
225
+ repositories=[RepositoryRevision(**item) for item in row.repositories],
226
+ modules=list(row.modules),
227
+ created_at=_as_utc(row.created_at),
228
+ started_at=_as_utc(row.started_at),
229
+ finished_at=_as_utc(row.finished_at),
230
+ expires_at=_as_utc(row.expires_at),
231
+ host_port=row.host_port,
232
+ database_name=row.database_name,
233
+ compose_project_name=row.compose_project_name,
234
+ workspace_path=Path(row.workspace_path),
235
+ preview_url=row.preview_url,
236
+ failure_stage=row.failure_stage,
237
+ failure_message=row.failure_message,
238
+ stages=[
239
+ StageResult(
240
+ name=item["name"],
241
+ status=StageStatus(item["status"]),
242
+ started_at=(
243
+ _as_utc(datetime.fromisoformat(item["started_at"]))
244
+ if item.get("started_at")
245
+ else None
246
+ ),
247
+ finished_at=(
248
+ _as_utc(datetime.fromisoformat(item["finished_at"]))
249
+ if item.get("finished_at")
250
+ else None
251
+ ),
252
+ duration_seconds=item.get("duration_seconds"),
253
+ exit_code=item.get("exit_code"),
254
+ log_path=item.get("log_path"),
255
+ summary=item.get("summary"),
256
+ metadata=item.get("metadata", {}),
257
+ )
258
+ for item in row.stages
259
+ ],
260
+ version=row.version,
261
+ )
262
+
263
+
264
+ def _as_utc(value: datetime | None) -> datetime | None:
265
+ """Restore the UTC contract that SQLite's datetime adapter cannot retain."""
266
+ if value is None:
267
+ return None
268
+ if value.tzinfo is None:
269
+ return value.replace(tzinfo=UTC)
270
+ return value.astimezone(UTC)