|
# retoor <retoor@molodetz.nl>
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import AsyncIterator, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
|
|
from devplacepy.stealth import stealth_async_client
|
|
|
|
from devplacepy.services.jobs.isslop.acquisition.workspace import directory_size_bytes
|
|
from devplacepy.services.jobs.isslop.config import (
|
|
GIT_CLONE_TIMEOUT_SECONDS,
|
|
GIT_SIZE_LIMIT_BYTES,
|
|
GIT_SIZE_POLL_SECONDS,
|
|
WEBSITE_REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RepositoryTooLargeError(Exception):
|
|
pass
|
|
|
|
|
|
class CloneFailedError(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SizePreflight:
|
|
known: bool
|
|
size_bytes: int
|
|
origin: str
|
|
|
|
|
|
def _owner_repo(url: str) -> Optional[tuple[str, str, str]]:
|
|
match = re.match(r"^git@([\w.-]+):(.+)$", url)
|
|
if match:
|
|
host, path = match.group(1), match.group(2)
|
|
else:
|
|
parsed = urlparse(url)
|
|
host, path = parsed.hostname or "", parsed.path
|
|
parts = [part for part in path.strip("/").removesuffix(".git").split("/") if part]
|
|
if len(parts) < 2 or not host:
|
|
return None
|
|
return host, parts[0], parts[1]
|
|
|
|
|
|
async def preflight_size(url: str) -> SizePreflight:
|
|
located = _owner_repo(url)
|
|
if located is None:
|
|
return SizePreflight(known=False, size_bytes=0, origin="unparseable")
|
|
host, owner, repo = located
|
|
candidates: list[tuple[str, str]] = []
|
|
if host == "github.com":
|
|
candidates.append((f"https://api.github.com/repos/{owner}/{repo}", "github"))
|
|
else:
|
|
candidates.append((f"https://{host}/api/v1/repos/{owner}/{repo}", "gitea"))
|
|
async with stealth_async_client(timeout=WEBSITE_REQUEST_TIMEOUT_SECONDS, follow_redirects=True) as client:
|
|
for api_url, origin in candidates:
|
|
try:
|
|
response = await client.get(api_url, headers={"accept": "application/json"})
|
|
except httpx.HTTPError as error:
|
|
logger.info("Size preflight unavailable via %s: %s", origin, error)
|
|
continue
|
|
if response.status_code != 200:
|
|
logger.info("Size preflight %s returned HTTP %d", origin, response.status_code)
|
|
continue
|
|
try:
|
|
body = response.json()
|
|
except ValueError:
|
|
continue
|
|
size_kb = body.get("size")
|
|
if isinstance(size_kb, (int, float)) and size_kb > 0:
|
|
size_bytes = int(size_kb) * 1024
|
|
logger.info("Preflight size via %s: %d bytes", origin, size_bytes)
|
|
return SizePreflight(known=True, size_bytes=size_bytes, origin=origin)
|
|
return SizePreflight(known=False, size_bytes=0, origin="unknown")
|
|
|
|
|
|
async def clone_repository(url: str, workspace: Path, size_limit: int = GIT_SIZE_LIMIT_BYTES) -> AsyncIterator[str]:
|
|
preflight = await preflight_size(url)
|
|
if preflight.known:
|
|
yield f"Repository size reported by {preflight.origin}: {preflight.size_bytes / (1024 * 1024):.1f} MB"
|
|
if preflight.size_bytes > size_limit:
|
|
raise RepositoryTooLargeError(
|
|
f"Repository is {preflight.size_bytes / (1024 ** 3):.2f} GB which exceeds the 3 GB limit"
|
|
)
|
|
else:
|
|
yield "Repository size not determinable up front, monitoring during clone"
|
|
|
|
argv = [
|
|
"git",
|
|
"clone",
|
|
"--depth",
|
|
"1",
|
|
"--single-branch",
|
|
"--no-tags",
|
|
url,
|
|
str(workspace),
|
|
]
|
|
yield f"Cloning with depth 1: {url}"
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*argv,
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
env={"GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "true", "PATH": "/usr/bin:/bin:/usr/local/bin"},
|
|
)
|
|
|
|
aborted_reason: list[str] = []
|
|
|
|
async def monitor() -> None:
|
|
while proc.returncode is None:
|
|
await asyncio.sleep(GIT_SIZE_POLL_SECONDS)
|
|
if not workspace.exists():
|
|
continue
|
|
size = await asyncio.to_thread(directory_size_bytes, workspace)
|
|
logger.debug("Clone size check: %d bytes", size)
|
|
if size > size_limit:
|
|
aborted_reason.append(f"Clone exceeded 3 GB limit at {size / (1024 ** 3):.2f} GB, cancelled")
|
|
proc.kill()
|
|
return
|
|
|
|
monitor_task = asyncio.create_task(monitor())
|
|
output_lines: list[str] = []
|
|
try:
|
|
assert proc.stdout is not None
|
|
while True:
|
|
try:
|
|
line = await asyncio.wait_for(proc.stdout.readline(), timeout=GIT_CLONE_TIMEOUT_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
raise CloneFailedError("Clone timed out")
|
|
if not line:
|
|
break
|
|
text = line.decode("utf-8", errors="replace").strip()
|
|
if text:
|
|
output_lines.append(text)
|
|
yield f"git: {text}"
|
|
await proc.wait()
|
|
finally:
|
|
monitor_task.cancel()
|
|
try:
|
|
await monitor_task
|
|
except asyncio.CancelledError:
|
|
logger.debug("Clone size monitor stopped")
|
|
|
|
if aborted_reason:
|
|
raise RepositoryTooLargeError(aborted_reason[0])
|
|
if proc.returncode != 0:
|
|
tail = " | ".join(output_lines[-4:])
|
|
raise CloneFailedError(f"git clone failed with code {proc.returncode}: {tail}")
|
|
final_size = await asyncio.to_thread(directory_size_bytes, workspace)
|
|
if final_size > size_limit:
|
|
raise RepositoryTooLargeError(f"Repository is {final_size / (1024 ** 3):.2f} GB which exceeds the 3 GB limit")
|
|
yield f"Clone complete: {final_size / (1024 * 1024):.1f} MB on disk"
|