251 lines
8.4 KiB
Python
251 lines
8.4 KiB
Python
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,
|
||
}
|
||
)
|