import json import re from fastapi import HTTPException, Request, status from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator from starlette.datastructures import UploadFile # Registry domain.com/org/name with optional tag SAFE_IMAGE_REF = re.compile(r"^([a-z0-9.]+\/)?[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+:?[a-zA-Z0-9_-]+$") SAFE_COMMIT = re.compile(r"^[0-9a-fA-F]{7,40}$") MAX_IMAGE_REF_LENGTH = 255 MAX_TAGS = 16 DEFAULT_REMOTE = "https://index.docker.io/v1/" READ_CHUNK_SIZE = 1024 * 64 class BuildRequest(BaseModel): token: str = Field( ..., min_length=1, description="JWT with repo (owner/name), dockerfile, and dockerfile_sha256", ) commit: str | None = Field( default=None, min_length=7, max_length=40, description="Git commit SHA to build (7-40 hex chars); clone is pinned to this revision", ) tags: list[str] = Field( ..., min_length=1, max_length=MAX_TAGS, description="Image tags to build and push", ) remote: str = Field( default=DEFAULT_REMOTE, min_length=1, description="Registry host for BuildKit auth (defaults to Docker Hub)", ) username: str = Field(..., min_length=1) password: str = Field(..., min_length=1) repo_username: str | None = Field( default=None, description="Optional git username for private repo clone (not in JWT)", ) repo_password: str | None = Field( default=None, description="Optional git password/token for private repo clone (not in JWT)", ) uploads: dict[str, bytes] | None = Field( default=None, exclude=True, description="Uploaded files keyed by relative path (multipart only)", ) @field_validator("commit") @classmethod def commit_must_be_hex_sha(cls, value: str | None) -> str | None: if value is None: return None commit = value.strip().lower() if not SAFE_COMMIT.match(commit): raise ValueError("commit must be a 7–40 character hex git SHA") return commit @field_validator("repo_username", "repo_password", mode="before") @classmethod def empty_repo_auth_to_none(cls, value: object) -> object: if isinstance(value, str) and not value.strip(): return None return value @model_validator(mode="after") def repo_auth_both_or_neither(self) -> "BuildRequest": has_user = self.repo_username is not None has_password = self.repo_password is not None if has_user != has_password: raise ValueError( "repo_username and repo_password must both be set or both omitted" ) return self @field_validator("tags") @classmethod def tags_must_be_safe_image_refs(cls, value: list[str]) -> list[str]: cleaned = [ tag.strip() for tag in value if isinstance(tag, str) and tag.strip() ] if not cleaned: raise ValueError("at least one non-empty tag is required") if len(cleaned) > MAX_TAGS: raise ValueError(f"at most {MAX_TAGS} tags are allowed") for tag in cleaned: if len(tag) > MAX_IMAGE_REF_LENGTH: raise ValueError(f"tag exceeds {MAX_IMAGE_REF_LENGTH} characters: {tag!r}") if not SAFE_IMAGE_REF.match(tag): raise ValueError(f"invalid or unsafe image tag: {tag!r}") return cleaned def _validate_build_request(data: dict) -> BuildRequest: try: return BuildRequest.model_validate(data) except ValidationError as exc: messages = [] for error in exc.errors(): loc = ".".join(str(part) for part in error["loc"]) messages.append(f"{loc}: {error['msg']}" if loc else error["msg"]) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(messages), ) from exc async def read_upload_parts( uploads: list[UploadFile], ) -> dict[str, bytes]: from app.config import settings from app.paths import normalize_manifest_path collected: dict[str, bytes] = {} total_bytes = 0 for upload in uploads: filename = upload.filename if not filename or not filename.strip(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="uploaded file parts must include a filename", ) try: path = normalize_manifest_path(filename) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) from exc if path in collected: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"duplicate upload path: {path}", ) chunks: list[bytes] = [] file_bytes = 0 while True: chunk = await upload.read(READ_CHUNK_SIZE) if not chunk: break file_bytes += len(chunk) if file_bytes > settings.max_file_bytes: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"uploaded file exceeds {settings.max_file_bytes} bytes: {path}", ) total_bytes += len(chunk) if total_bytes > settings.max_upload_bytes: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"total upload exceeds {settings.max_upload_bytes} bytes", ) chunks.append(chunk) collected[path] = b"".join(chunks) return collected async def parse_json_build(request: Request) -> BuildRequest: body = await request.json() if not isinstance(body, dict): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="JSON body must be an object", ) build_request = _validate_build_request(body) if build_request.commit is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="commit is required for JSON requests", ) return build_request def _parse_multipart_tags(raw_tags: object) -> list[str]: if not isinstance(raw_tags, str): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="tags must be a JSON array string", ) try: parsed = json.loads(raw_tags) except json.JSONDecodeError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="tags must be a JSON array string", ) from exc if not isinstance(parsed, list): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="tags must be a JSON array", ) return parsed async def parse_multipart_build(request: Request) -> BuildRequest: form = await request.form() upload_files = [ value for key, value in form.multi_items() if key == "files" and isinstance(value, UploadFile) ] uploads = await read_upload_parts(upload_files) if upload_files else None if not uploads: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="at least one upload is required when using multipart/form-data", ) raw_commit = form.get("commit") commit = raw_commit if isinstance(raw_commit, str) and raw_commit.strip() else None raw_repo_username = form.get("repo_username") raw_repo_password = form.get("repo_password") repo_username = ( raw_repo_username if isinstance(raw_repo_username, str) and raw_repo_username.strip() else None ) repo_password = ( raw_repo_password if isinstance(raw_repo_password, str) and raw_repo_password.strip() else None ) return _validate_build_request( { "token": form.get("token"), "commit": commit, "tags": _parse_multipart_tags(form.get("tags")), "remote": form.get("remote") or DEFAULT_REMOTE, "username": form.get("username"), "password": form.get("password"), "repo_username": repo_username, "repo_password": repo_password, "uploads": uploads, } )