Public Access
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / vikunja (push) Successful in 18s
Post-merge / release (push) Successful in 36s
Post-merge / publish (push) Successful in 20s
Post-merge / badges (push) Successful in 35s
334 lines
9.8 KiB
Python
334 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Build and push Docker images to a Gitea container registry.
|
|
|
|
Replaces raw ``docker build`` / ``docker push`` shell commands with a
|
|
tested Python tool. Supports:
|
|
|
|
- Building from any Dockerfile with a configurable context directory
|
|
- Tagging with multiple tags (e.g. ``latest`` + version)
|
|
- Optional push to a Gitea registry (with login)
|
|
- Dry-run mode (prints commands without executing)
|
|
|
|
Usage::
|
|
|
|
# Build a single image
|
|
python3 -m devx.tools.build_image \\
|
|
--dockerfile docker/ci-base/Dockerfile \\
|
|
--tag ci-base:latest \\
|
|
--tag ci-base:0.19.3
|
|
|
|
# Build and push to registry
|
|
python3 -m devx.tools.build_image \\
|
|
--dockerfile docker/ci-base/Dockerfile \\
|
|
--tag ci-base:latest \\
|
|
--tag ci-base:0.19.3 \\
|
|
--registry git.oblachno.oblachno.fyi \\
|
|
--push
|
|
|
|
# Build multiple images (from a manifest file)
|
|
python3 -m devx.tools.build_image --manifest docker/images.json --push
|
|
|
|
The manifest file is a JSON list of dicts, each with:
|
|
- ``name``: image name (e.g. ``ci-base``)
|
|
- ``dockerfile``: path to Dockerfile (relative to repo root)
|
|
- ``context``: build context directory (optional, defaults to repo root)
|
|
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
|
|
|
|
Registry authentication uses ``CI_GITEA_API_TOKEN`` (or legacy ``CI_GITEA_TOKEN``)
|
|
and ``CI_GITEA_USERNAME`` environment variables, matching the existing CI workflow patterns.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess # nosec B404
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
from devx.i18n import _
|
|
from devx.tokens import get_developer_token
|
|
|
|
|
|
@dataclass
|
|
class ImageSpec:
|
|
"""Specification for a single Docker image to build."""
|
|
|
|
name: str
|
|
dockerfile: str
|
|
context: str = "."
|
|
tags: list[str] = field(default_factory=lambda: ["latest"])
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, object]) -> ImageSpec:
|
|
"""Create an ImageSpec from a dict (e.g. from a JSON manifest)."""
|
|
name = str(data.get("name", ""))
|
|
if not name:
|
|
raise ValueError(_("Image manifest entry missing 'name'"))
|
|
dockerfile = str(data.get("dockerfile", ""))
|
|
if not dockerfile:
|
|
raise ValueError(_("Image manifest entry missing 'dockerfile'"))
|
|
context = str(data.get("context", "."))
|
|
tags_raw = data.get("tags", ["latest"])
|
|
if not isinstance(tags_raw, list):
|
|
raise ValueError(_("Image 'tags' must be a list"))
|
|
tags = [str(t) for t in tags_raw] if tags_raw else ["latest"]
|
|
return cls(name=name, dockerfile=dockerfile, context=context, tags=tags)
|
|
|
|
|
|
def load_manifest(path: str | Path) -> list[ImageSpec]:
|
|
"""Load a JSON manifest file describing images to build.
|
|
|
|
The file must contain a JSON list of dicts with at least ``name`` and
|
|
``dockerfile`` keys. ``context`` and ``tags`` are optional.
|
|
|
|
Returns a list of :class:`ImageSpec` instances.
|
|
"""
|
|
p = Path(path)
|
|
if not p.is_file():
|
|
raise click.ClickException(_("Manifest file not found: {path}", path=p))
|
|
with p.open(encoding="utf-8") as f: # noqa: PTH123
|
|
data = json.load(f)
|
|
if not isinstance(data, list):
|
|
raise click.ClickException(_("Manifest must be a JSON list"))
|
|
return [ImageSpec.from_dict(entry) for entry in data]
|
|
|
|
|
|
def build_full_tag(registry: str | None, name: str, tag: str) -> str:
|
|
"""Build a full image tag, optionally prefixed with a registry.
|
|
|
|
>>> build_full_tag(None, "ci-base", "latest")
|
|
'ci-base:latest'
|
|
>>> build_full_tag("git.example.com", "ci-base", "0.1.0")
|
|
'git.example.com/ci-base:0.1.0'
|
|
"""
|
|
if registry:
|
|
return f"{registry}/{name}:{tag}"
|
|
return f"{name}:{tag}"
|
|
|
|
|
|
def registry_login(
|
|
registry: str,
|
|
username: str,
|
|
token: str,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> bool:
|
|
"""Log in to a Docker registry.
|
|
|
|
Returns True on success, False on failure.
|
|
In dry-run mode, prints the command without executing.
|
|
"""
|
|
cmd = ["docker", "login", registry, "-u", username, "--password-stdin"]
|
|
if dry_run:
|
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
|
return True
|
|
result = subprocess.run( # nosec B603
|
|
cmd,
|
|
input=token,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
click.echo(
|
|
_("Registry login failed: {error}", error=result.stderr.strip()),
|
|
err=True,
|
|
)
|
|
return False
|
|
click.echo(f"Logged in to {registry}")
|
|
return True
|
|
|
|
|
|
def build_image(
|
|
spec: ImageSpec,
|
|
registry: str | None = None,
|
|
*,
|
|
dry_run: bool = False,
|
|
pull: bool = False,
|
|
) -> bool:
|
|
"""Build a Docker image from a Dockerfile.
|
|
|
|
Tags the image with all specified tags, optionally prefixed with the
|
|
registry. Returns True on success, False on failure.
|
|
"""
|
|
if not Path(spec.dockerfile).is_file():
|
|
click.echo(
|
|
_("Dockerfile not found: {path}", path=spec.dockerfile),
|
|
err=True,
|
|
)
|
|
return False
|
|
|
|
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
|
cmd = ["docker", "build"]
|
|
if pull:
|
|
cmd.append("--pull")
|
|
for ft in full_tags:
|
|
cmd.extend(["-t", ft])
|
|
cmd.extend(["-f", spec.dockerfile, spec.context])
|
|
|
|
if dry_run:
|
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
|
return True
|
|
|
|
click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...")
|
|
result = subprocess.run( # nosec B603
|
|
cmd,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
click.echo(_("Build failed for {name}", name=spec.name), err=True)
|
|
return False
|
|
click.echo(f"Built {spec.name}")
|
|
return True
|
|
|
|
|
|
def push_image(
|
|
spec: ImageSpec,
|
|
registry: str,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> bool:
|
|
"""Push all tags of a Docker image to the registry.
|
|
|
|
Returns True if all pushes succeed, False if any fail.
|
|
"""
|
|
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
|
all_ok = True
|
|
for ft in full_tags:
|
|
cmd = ["docker", "push", ft]
|
|
if dry_run:
|
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
|
continue
|
|
click.echo(f"Pushing {ft}...")
|
|
result = subprocess.run( # nosec B603
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
click.echo(
|
|
_("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()),
|
|
err=True,
|
|
)
|
|
all_ok = False
|
|
else:
|
|
click.echo(f"Pushed {ft}")
|
|
return all_ok
|
|
|
|
|
|
def _get_registry_creds() -> tuple[str, str]:
|
|
"""Get registry credentials from environment variables."""
|
|
try:
|
|
token = get_developer_token()
|
|
except click.ClickException:
|
|
token = None
|
|
username = os.environ.get("CI_GITEA_USERNAME", "")
|
|
return username, token or ""
|
|
|
|
|
|
@click.command()
|
|
@click.option(
|
|
"--dockerfile",
|
|
"dockerfile",
|
|
default=None,
|
|
help="Path to Dockerfile (for single-image build).",
|
|
)
|
|
@click.option(
|
|
"--context",
|
|
"context",
|
|
default=".",
|
|
help="Build context directory (for single-image build).",
|
|
)
|
|
@click.option(
|
|
"--name",
|
|
"name",
|
|
default=None,
|
|
help="Image name (for single-image build).",
|
|
)
|
|
@click.option(
|
|
"--tag",
|
|
"tags",
|
|
multiple=True,
|
|
help="Tag(s) for the image. Can be repeated. Defaults to 'latest'.",
|
|
)
|
|
@click.option(
|
|
"--manifest",
|
|
"manifest",
|
|
default=None,
|
|
help="Path to JSON manifest file listing images to build.",
|
|
)
|
|
@click.option(
|
|
"--registry",
|
|
"registry",
|
|
default=None,
|
|
help="Registry URL (e.g. git.example.com). If set with --push, images are tagged and pushed there.",
|
|
)
|
|
@click.option(
|
|
"--push",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Push images to the registry after building.",
|
|
)
|
|
@click.option(
|
|
"--dry-run",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Print commands without executing.",
|
|
)
|
|
@click.option(
|
|
"--pull",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Pass --pull to docker build (always fetch latest base image).",
|
|
)
|
|
def main(
|
|
dockerfile: str | None,
|
|
context: str,
|
|
name: str | None,
|
|
tags: tuple[str, ...],
|
|
manifest: str | None,
|
|
registry: str | None,
|
|
push: bool,
|
|
dry_run: bool,
|
|
pull: bool,
|
|
) -> None:
|
|
"""Build and optionally push Docker images to a Gitea registry."""
|
|
if manifest:
|
|
specs = load_manifest(manifest)
|
|
elif dockerfile and name:
|
|
tag_list = list(tags) if tags else ["latest"]
|
|
specs = [ImageSpec(name=name, dockerfile=dockerfile, context=context, tags=tag_list)]
|
|
else:
|
|
raise click.ClickException(_("Provide --manifest or both --dockerfile and --name"))
|
|
|
|
if push:
|
|
if not registry:
|
|
raise click.ClickException(_("--push requires --registry"))
|
|
username, token = _get_registry_creds()
|
|
if not token or not username:
|
|
raise click.ClickException(
|
|
_("Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars")
|
|
)
|
|
if not registry_login(registry, username, token, dry_run=dry_run):
|
|
raise click.ClickException(_("Registry login failed"))
|
|
|
|
failed: list[str] = []
|
|
for spec in specs:
|
|
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
|
|
failed.append(spec.name)
|
|
continue
|
|
if push and not push_image(spec, registry, dry_run=dry_run): # type: ignore[arg-type]
|
|
failed.append(spec.name)
|
|
|
|
if failed:
|
|
raise click.ClickException(_("Failed images: {names}", names=", ".join(failed)))
|
|
click.echo(f"\nDone. {len(specs)} image(s) processed.")
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
main() # pragma: no cover
|