secured-remote-docker-build/app/models.py
Lukas Schaefer c91f4b9bc2
Fix multi tagged images
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-03 23:25:44 -04:00

251 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 740 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,
}
)