DEVX-68: feat: add pre-built Docker runner images and tested image build/push tools

This commit is contained in:
emil
2026-06-27 01:34:05 +00:00
parent d4ddbd7e7a
commit c0238e75df
14 changed files with 2034 additions and 389 deletions
+20
View File
@@ -0,0 +1,20 @@
.venv/
.git/
.gitea/
tests/
docs/
*.egg-info/
__pycache__/
htmlcov/
.coverage
dist/
build/
*.md
!README.md
.env
.env.example
activate.sh
activate.fish
activate.zsh
hooks/
.devin/
+131
View File
@@ -0,0 +1,131 @@
name: Build Images
# Builds and pushes pre-built Docker runner images to the Gitea registry.
# These images eliminate the 40-120s setup tax on every CI job by baking
# devx and all dependencies into the image.
#
# Triggers:
# - On push to master (after post-merge release completes)
# - Manually via workflow_dispatch
#
# The workflow builds 3 tier images in sequence:
# ci-base → ci-quality → ci-full
#
# Each tier builds FROM the previous one, so they must be built in order.
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
on:
push:
branches: [master]
paths:
- docker/**
- pyproject.toml
- src/devx/**
workflow_dispatch:
jobs:
detect-type:
runs-on: docker
timeout-minutes: 5
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
build-and-push:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Docker registry login
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
run: |
. .venv/bin/activate
echo "$REPO_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$REGISTRY_USERNAME" --password-stdin
- name: Build and push tier images
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
# Build ci-base first (it's the base for ci-quality and ci-full)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-base/Dockerfile \
--name oblachno-oss/runner-images/ci-base \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push --pull
# Build ci-quality (FROM ci-base-latest)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-quality/Dockerfile \
--name oblachno-oss/runner-images/ci-quality \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
# Build ci-full (FROM ci-quality-latest)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-full/Dockerfile \
--name oblachno-oss/runner-images/ci-full \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "build-images/build-and-push" \
--commit "${{ github.sha }}"
cleanup:
needs: [build-and-push]
if: always() && needs.build-and-push.result == 'success'
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
- name: Clean up old image versions
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.clean_images \
--owner oblachno-oss \
--name oblachno-oss/runner-images/ci-base \
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
+46
View File
@@ -74,6 +74,9 @@ src/devx/
├── tools/ # Developer tooling modules (run locally or by CI) ├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks) │ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea │ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
│ ├── build_image.py # Build and push Docker images to Gitea registry
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
│ ├── check_test_speed.py # Measure unit test execution time │ ├── check_test_speed.py # Measure unit test execution time
│ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs) │ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs)
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments │ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
@@ -424,6 +427,11 @@ projects.
| `devx-check-test-speed` | Verify test suite timing | | `devx-check-test-speed` | Verify test suite timing |
| `devx-pre-push` | Run lint + tests before push | | `devx-pre-push` | Run lint + tests before push |
| `devx-clean` | Remove caches, build artifacts, coverage data | | `devx-clean` | Remove caches, build artifacts, coverage data |
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
| `devx-build-images` | Build Docker images from manifest (no push) |
| `devx-push-images` | Build and push Docker images to Gitea registry |
| `devx-build-images-dry-run` | Show what would be built/pushed |
| `devx-clean-images` | Delete old image versions (keep last 2 + latest) |
**Variables** (set BEFORE including devx.mak): **Variables** (set BEFORE including devx.mak):
- `DEVX_PYTHON` — Python executable (default: `python3`) - `DEVX_PYTHON` — Python executable (default: `python3`)
@@ -433,6 +441,44 @@ projects.
- `DEVX_COV_PKG` — coverage package (default: `src/devx`) - `DEVX_COV_PKG` — coverage package (default: `src/devx`)
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`) - `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
- `DEVX_PR_BASE` — PR base branch (default: `master`) - `DEVX_PR_BASE` — PR base branch (default: `master`)
- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
## Pre-built Docker Runner Images
devx builds and publishes three tier images to the Gitea container registry
to eliminate the 40-120s setup tax on every CI job:
| Image | Contains | Used by jobs |
|-------|----------|-------------|
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake | quality, badges |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
**Build process** (in `build-images.yml` workflow):
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest`
2. `ci-quality` builds FROM `ci-base-latest`
3. `ci-full` builds FROM `ci-quality-latest`
Each image is tagged `latest` and pushed to
`git.oblachno.oblachno.fyi/oblachno-oss/runner-images:<tier>-latest`.
**Using images in workflows**:
```yaml
jobs:
quality:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-quality-latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-image # links /opt/venv, installs project (no-deps)
```
**Image build/push tools** (tested Python modules):
- `devx.tools.build_image` — Build and push Docker images from Dockerfile or manifest
- `devx.tools.clean_images` — Delete old image versions via Gitea API (keep last N + latest)
**Usage in project Makefile**: **Usage in project Makefile**:
```makefile ```makefile
+20 -1
View File
@@ -1,4 +1,4 @@
.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed .PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
PYTHON := python3 PYTHON := python3
VENV := .venv VENV := .venv
@@ -30,6 +30,11 @@ setup-release: $(VENV)/bin/activate .env
export PATH="$(HOME)/.local/bin:$$PATH"; \ export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit $(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
.env: .env:
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi @if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
@@ -105,3 +110,17 @@ pre-push: lint-all pytest-cov
clean: devx-clean clean: devx-clean
@echo "[clean] Done." @echo "[clean] Done."
# ── Docker image management ──────────────────────────────────────────────────
build-images: devx-build-images
@echo "[build-images] Done."
push-images: devx-push-images
@echo "[push-images] Done."
build-images-dry-run: devx-build-images-dry-run
@echo "[build-images-dry-run] Done."
clean-images: devx-clean-images
@echo "[clean-images] Done."
+26
View File
@@ -0,0 +1,26 @@
# ci-base — lightweight image for CI jobs that only need devx core + tea.
#
# Used by: detect-type, detect-changes, validate-commit-msg, pr-review,
# auto-merge, sync-wiki, vikunja, configure-repo, discover-runners,
# molecule-report, discover-integration-runners
#
# Jobs using this image: setup is instant (ln -s /opt/venv .venv)
# No pip install needed — devx and all deps are pre-installed.
FROM gitea/runner-images:ubuntu-latest
# Create a virtual environment with all deps pre-installed
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:/root/.local/bin:$PATH"
# Install devx from local source (build context = devx repo root)
COPY . /tmp/devx
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
&& pip install --no-cache-dir /tmp/devx[ci] \
&& rm -rf /tmp/devx
# Install tea CLI (for Gitea API operations in CI)
RUN python3 -m devx.tools.install_tools --tool tea
# Workspace directory (actions/checkout mounts repo here)
WORKDIR /workspace
+24
View File
@@ -0,0 +1,24 @@
# ci-full — heaviest image, includes everything for release, molecule, deploy.
#
# Used by: release, publish, release-dry-run, molecule-tests,
# provision-infra, deploy-observability, provision-zitadel,
# deploy-customer, integration-tests
#
# Layers on top of ci-quality: adds release tools, molecule, deploy deps,
# git-cliff, and OpenTofu.
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-quality-latest
# Install devx[release,molecule,deploy] from local source
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
&& rm -rf /tmp/devx
# Install git-cliff (changelog generator for release job)
RUN python3 -m devx.tools.install_tools --tool git-cliff
# Install OpenTofu (for infra deploy jobs)
RUN ARCH=$(uname -m | sed 's/x86_64/amd64') \
&& VERSION=1.12.3 \
&& curl -fsSL "https://github.com/opentofu/opentofu/releases/download/v${VERSION}/tofu_${VERSION}_$(uname -s | tr '[:upper:]' '[:lower:]')_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin tofu
+17
View File
@@ -0,0 +1,17 @@
# ci-quality — image for lint, type-checking, badge generation.
#
# Used by: quality (lint-all + pytest-cov + checks), badges (generate_badges
# runs ruff/pyright/bandit to produce quality badge)
#
# Layers on top of ci-base: adds lint tools + actionlint + checkmake.
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-base-latest
# Install devx[lint] from local source (adds ruff, pyright, bandit, etc.)
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[lint] \
&& rm -rf /tmp/devx
# Install CI/CD binary tools
RUN python3 -m devx.tools.install_tools --tool actionlint \
&& python3 -m devx.tools.install_checkmake
+20
View File
@@ -0,0 +1,20 @@
[
{
"name": "oblachno-oss/runner-images/ci-base",
"dockerfile": "docker/ci-base/Dockerfile",
"context": ".",
"tags": ["latest"]
},
{
"name": "oblachno-oss/runner-images/ci-quality",
"dockerfile": "docker/ci-quality/Dockerfile",
"context": ".",
"tags": ["latest"]
},
{
"name": "oblachno-oss/runner-images/ci-full",
"dockerfile": "docker/ci-full/Dockerfile",
"context": ".",
"tags": ["latest"]
}
]
+20 -7
View File
@@ -26,14 +26,13 @@ devx = "devx.cli:cli"
version = {attr = "devx.__version__"} version = {attr = "devx.__version__"}
[project.optional-dependencies] [project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv/requests # Test runners (pytest + coverage + parallel execution)
ci = [ ci = [
"pytest>=9.1.0", "pytest>=9.1.0",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"build>=1.5.0", "pytest-xdist>=3.8",
"twine>=6.2.0",
] ]
# Lint and type-checking tools (quality job) # Lint and type-checking tools (quality job, badge generation)
lint = [ lint = [
"ruff>=0.15.17", "ruff>=0.15.17",
"pyright>=1.1.410", "pyright>=1.1.410",
@@ -41,16 +40,30 @@ lint = [
"pip-audit>=2.10", "pip-audit>=2.10",
"pre-commit>=4.6.0", "pre-commit>=4.6.0",
] ]
# Molecule testing (optional — for projects with Ansible roles) # Release tools (build + publish to PyPI/Gitea registry)
release = [
"build>=1.5.0",
"twine>=6.2.0",
]
# Molecule testing (for projects with Ansible roles)
molecule = [ molecule = [
"molecule>=26.4.0", "molecule>=26.4.0",
"molecule-docker>=2.1.0", "molecule-docker>=2.1.0",
"ansible-lint>=26.4.0", "ansible-lint>=26.4.0",
"ansible>=14.0.0", "ansible-core>=2.15,<2.17",
]
# Deploy tools (for infra staging/production deployments)
deploy = [
"ansible-core>=2.15,<2.17",
"boto3>=1.34",
"docker>=7.0",
"jinja2>=3.1",
"pyyaml>=6.0",
"cryptography>=41.0",
] ]
# Full dev environment (local development) # Full dev environment (local development)
dev = [ dev = [
"devx[ci,lint]", "devx[ci,lint,release,molecule]",
"build>=1.3.0", "build>=1.3.0",
"twine>=6.2.0", "twine>=6.2.0",
] ]
+55
View File
@@ -249,3 +249,58 @@ devx-clean:
@find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true @find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
@find . -type f -name "*.pyc" -delete 2>/dev/null || true @find . -type f -name "*.pyc" -delete 2>/dev/null || true
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true @rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
# ── Pre-built image setup ─────────────────────────────────────────────────────
#
# When running inside a pre-built Docker runner image (ci-base, ci-quality,
# ci-full), all deps are already installed in /opt/venv. This target links
# the venv and installs the project itself (no-deps, fast).
# Falls back to devx-setup-ci if /opt/venv is not present (local dev).
devx-setup-image:
@if [ -d /opt/venv ]; then \
ln -sf /opt/venv $(DEVX_VENV); \
. $(DEVX_BIN)/activate && pip install -e . --no-deps 2>/dev/null; \
echo "[devx-setup-image] Linked /opt/venv and installed project (no-deps)."; \
else \
echo "[devx-setup-image] /opt/venv not found — falling back to devx-setup-ci"; \
$(MAKE) devx-setup-ci; \
fi
# ── Docker image build / push / cleanup ───────────────────────────────────────
#
# Variables:
# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.oblachno.fyi)
# DEVX_IMAGE_MANIFEST — path to JSON manifest (default: docker/images.json)
# DEVX_IMAGE_OWNER — package owner for cleanup (default: oblachno-oss)
DEVX_GITEA_REGISTRY ?= git.oblachno.oblachno.fyi
DEVX_IMAGE_MANIFEST ?= docker/images.json
DEVX_IMAGE_OWNER ?= oblachno-oss
# Build all images from manifest (no push)
devx-build-images:
@$(DEVX_PYTHON) -m devx.tools.build_image --manifest $(DEVX_IMAGE_MANIFEST) --pull
# Build and push all images to the Gitea registry
devx-push-images:
@$(DEVX_PYTHON) -m devx.tools.build_image \
--manifest $(DEVX_IMAGE_MANIFEST) \
--registry $(DEVX_GITEA_REGISTRY) \
--push --pull
# Dry-run: show what would be built/pushed
devx-build-images-dry-run:
@$(DEVX_PYTHON) -m devx.tools.build_image \
--manifest $(DEVX_IMAGE_MANIFEST) \
--registry $(DEVX_GITEA_REGISTRY) \
--push --dry-run
# Clean up old image versions (keep last 2 + latest)
devx-clean-images:
@$(DEVX_PYTHON) -m devx.tools.clean_images \
--owner $(DEVX_IMAGE_OWNER) \
--name oblachno-oss/runner-images/ci-base \
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
+334
View File
@@ -0,0 +1,334 @@
#!/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 ``REPO_TOKEN`` (or ``GITEA_REGISTRY_TOKEN``)
and ``REGISTRY_USERNAME`` (or ``GITEA_REGISTRY_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 _
@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() 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.
Supports both REPO_TOKEN/GITEA_REGISTRY_TOKEN and
REGISTRY_USERNAME/GITEA_REGISTRY_USERNAME patterns.
"""
token = os.environ.get("REPO_TOKEN") or os.environ.get("GITEA_REGISTRY_TOKEN", "")
username = os.environ.get("REGISTRY_USERNAME") or os.environ.get("GITEA_REGISTRY_USERNAME", "")
return username, token
@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 REPO_TOKEN and REGISTRY_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
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Clean up old Docker images from a Gitea container registry.
Queries the Gitea API for all versions of a package (container type) and
deletes all but the most recent N versions. The ``latest`` tag is always
preserved if present.
Usage::
# Clean up ci-base images, keep last 2 versions
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2
# Clean up multiple images
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--name ci-quality \\
--name ci-full \\
--keep 2
# Dry run (list what would be deleted)
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2 \\
--dry-run
Authentication uses ``REPO_TOKEN`` environment variable.
"""
from __future__ import annotations
import os
from typing import Any
import click
import requests
from devx.config import GITEA_API_URL
from devx.i18n import _
def list_package_versions(
api_url: str,
owner: str,
name: str,
token: str,
*,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""List all versions of a container package from the Gitea API.
Returns a list of version dicts, each containing at least ``version``
and ``created_at`` fields.
"""
url = f"{api_url}/packages/{owner}?type=container&name={name}"
headers = {"Authorization": f"token {token}"}
all_versions: list[dict[str, Any]] = []
page = 1
while True:
resp = requests.get(
f"{url}&page={page}&limit=50",
headers=headers,
timeout=timeout,
)
resp.raise_for_status()
data = resp.json()
if not data:
break
all_versions.extend(data)
if len(data) < 50:
break
page += 1
return all_versions
def delete_package_version(
api_url: str,
owner: str,
name: str,
version: str,
token: str,
*,
timeout: int = 30,
) -> bool:
"""Delete a specific version of a container package.
Returns True on success, False on failure.
"""
url = f"{api_url}/packages/{owner}/{name}/{version}"
headers = {"Authorization": f"token {token}"}
resp = requests.delete(url, headers=headers, timeout=timeout)
return resp.status_code in (204, 200)
def sort_versions_by_date(
versions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Sort package versions by creation date, newest first.
Falls back to version string comparison if created_at is missing.
"""
def _sort_key(v: dict[str, Any]) -> str:
return str(v.get("created_at", v.get("version", "")))
return sorted(versions, key=_sort_key, reverse=True)
def select_for_deletion(
versions: list[dict[str, Any]],
keep: int,
) -> list[dict[str, Any]]:
"""Select versions to delete, keeping the most recent ``keep`` versions.
Versions named ``latest`` are always preserved.
"""
sorted_versions = sort_versions_by_date(versions)
to_delete = sorted_versions[keep:]
# Always preserve 'latest' tag
to_delete = [v for v in to_delete if v.get("version") != "latest"]
return to_delete
@click.command()
@click.option(
"--owner",
required=True,
help="Package owner (user or org).",
)
@click.option(
"--name",
"names",
multiple=True,
required=True,
help="Package name(s). Can be repeated.",
)
@click.option(
"--keep",
default=2,
type=int,
show_default=True,
help="Number of recent versions to keep (excluding 'latest').",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="List versions that would be deleted without actually deleting.",
)
@click.option(
"--api-url",
default=None,
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
)
def main(
owner: str,
names: tuple[str, ...],
keep: int,
dry_run: bool,
api_url: str | None,
) -> None:
"""Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("REPO_TOKEN environment variable required"))
base_url = api_url or GITEA_API_URL
total_deleted = 0
total_kept = 0
for name in names:
click.echo(f"\n{'=' * 60}")
click.echo(f"Package: {owner}/{name}")
click.echo(f"{'=' * 60}")
try:
versions = list_package_versions(base_url, owner, name, token)
except requests.RequestException as exc:
click.echo(
_("Failed to list versions for {name}: {error}", name=name, error=exc),
err=True,
)
continue
if not versions:
click.echo(_("No versions found."))
continue
click.echo(f"Found {len(versions)} version(s):")
for v in sort_versions_by_date(versions):
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})")
to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete)
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}")
if dry_run:
for v in to_delete:
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}")
total_kept += kept_count
continue
deleted_count = 0
for v in to_delete:
version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token):
click.echo(f" Deleted: {version}")
deleted_count += 1
else:
click.echo(f" FAILED to delete: {version}", err=True)
total_deleted += deleted_count
total_kept += kept_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+517 -381
View File
File diff suppressed because it is too large Load Diff
+583
View File
@@ -0,0 +1,583 @@
"""Unit tests for devx.tools.build_image."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click import ClickException
from click.testing import CliRunner
import devx.tools.build_image as build_image
from devx.tools.build_image import (
ImageSpec,
build_full_tag,
load_manifest,
push_image,
registry_login,
)
from devx.tools.build_image import (
build_image as do_build,
)
from devx.tools.clean_images import select_for_deletion, sort_versions_by_date
class TestImageSpec:
def test_from_dict_minimal(self) -> None:
spec = ImageSpec.from_dict({"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"})
assert spec.name == "ci-base"
assert spec.dockerfile == "docker/ci-base/Dockerfile"
assert spec.context == "."
assert spec.tags == ["latest"]
def test_from_dict_full(self) -> None:
spec = ImageSpec.from_dict(
{
"name": "ci-quality",
"dockerfile": "docker/ci-quality/Dockerfile",
"context": ".",
"tags": ["latest", "0.19.3"],
}
)
assert spec.name == "ci-quality"
assert spec.dockerfile == "docker/ci-quality/Dockerfile"
assert spec.context == "."
assert spec.tags == ["latest", "0.19.3"]
def test_from_dict_missing_name(self) -> None:
with pytest.raises(ValueError, match="missing 'name'"):
ImageSpec.from_dict({"dockerfile": "Dockerfile"})
def test_from_dict_missing_dockerfile(self) -> None:
with pytest.raises(ValueError, match="missing 'dockerfile'"):
ImageSpec.from_dict({"name": "ci-base"})
def test_from_dict_tags_not_list(self) -> None:
with pytest.raises(ValueError, match="tags.*must be a list"):
ImageSpec.from_dict(
{
"name": "ci-base",
"dockerfile": "Dockerfile",
"tags": "latest",
}
)
def test_from_dict_empty_tags_defaults_to_latest(self) -> None:
spec = ImageSpec.from_dict(
{
"name": "ci-base",
"dockerfile": "Dockerfile",
"tags": [],
}
)
assert spec.tags == ["latest"]
class TestBuildFullTag:
def test_no_registry(self) -> None:
assert build_full_tag(None, "ci-base", "latest") == "ci-base:latest"
def test_with_registry(self) -> None:
assert build_full_tag("git.example.com", "ci-base", "0.1.0") == "git.example.com/ci-base:0.1.0"
def test_with_registry_and_path(self) -> None:
assert (
build_full_tag("git.example.com", "oblachno/ci-base", "latest") == "git.example.com/oblachno/ci-base:latest"
)
class TestLoadManifest:
def test_load_valid_manifest(self, tmp_path: Path) -> None:
manifest = tmp_path / "images.json"
manifest.write_text(
json.dumps(
[
{"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"},
{"name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "tags": ["latest", "1.0"]},
]
)
)
specs = load_manifest(manifest)
assert len(specs) == 2
assert specs[0].name == "ci-base"
assert specs[1].tags == ["latest", "1.0"]
def test_load_missing_file(self, tmp_path: Path) -> None:
with pytest.raises(ClickException, match="not found"):
load_manifest(tmp_path / "nonexistent.json")
def test_load_not_a_list(self, tmp_path: Path) -> None:
manifest = tmp_path / "images.json"
manifest.write_text(json.dumps({"name": "ci-base"}))
with pytest.raises(ClickException, match="must be a JSON list"):
load_manifest(manifest)
class TestRegistryLogin:
def test_success(self) -> None:
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert registry_login("git.example.com", "user", "token") is True
assert mock_run.call_args.args[0] == [
"docker",
"login",
"git.example.com",
"-u",
"user",
"--password-stdin",
]
assert mock_run.call_args.kwargs["input"] == "token"
def test_failure(self) -> None:
mock_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert registry_login("git.example.com", "user", "bad") is False
def test_dry_run(self) -> None:
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert registry_login("git.example.com", "user", "token", dry_run=True) is True
mock_run.assert_not_called()
class TestBuildImage:
def test_success(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), context=".", tags=["latest"])
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert do_build(spec) is True
def test_dockerfile_not_found(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="nonexistent/Dockerfile")
assert do_build(spec) is False
def test_build_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
mock_result = MagicMock(returncode=1)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert do_build(spec) is False
def test_dry_run(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest", "1.0"])
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert do_build(spec, dry_run=True) is True
mock_run.assert_not_called()
def test_with_registry(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest"])
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert do_build(spec, registry="git.example.com") is True
cmd = mock_run.call_args.args[0]
assert "-t" in cmd
idx = cmd.index("-t")
assert cmd[idx + 1] == "git.example.com/ci-base:latest"
def test_pull_flag(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert do_build(spec, pull=True) is True
cmd = mock_run.call_args.args[0]
assert "--pull" in cmd
class TestPushImage:
def test_success(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert push_image(spec, "git.example.com") is True
assert mock_run.call_count == 2
def test_partial_failure(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
results = [
MagicMock(returncode=0, stderr="", stdout=""),
MagicMock(returncode=1, stderr="push failed", stdout=""),
]
with patch("devx.tools.build_image.subprocess.run", side_effect=results):
assert push_image(spec, "git.example.com") is False
def test_dry_run(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert push_image(spec, "git.example.com", dry_run=True) is True
mock_run.assert_not_called()
class TestSortVersions:
def test_sort_by_created_at_desc(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01T00:00:00Z"},
{"version": "0.3.0", "created_at": "2025-03-01T00:00:00Z"},
{"version": "0.2.0", "created_at": "2025-02-01T00:00:00Z"},
]
result = sort_versions_by_date(versions)
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
def test_sort_fallback_to_version(self) -> None:
versions = [
{"version": "0.1.0"},
{"version": "0.3.0"},
{"version": "0.2.0"},
]
result = sort_versions_by_date(versions)
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
class TestSelectForDeletion:
def test_keep_2(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert len(to_delete) == 2
assert {v["version"] for v in to_delete} == {"0.1.0", "0.2.0"}
def test_preserve_latest_tag(self) -> None:
versions = [
{"version": "latest", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2)
deleted_versions = {v["version"] for v in to_delete}
assert "latest" not in deleted_versions
# latest is oldest by date but still preserved
assert "0.2.0" in deleted_versions
def test_keep_all(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert len(to_delete) == 0
def test_keep_more_than_available(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
]
to_delete = select_for_deletion(versions, keep=5)
assert len(to_delete) == 0
class TestCleanImagesAPI:
"""Tests for the clean_images module's API functions."""
def test_list_package_versions(self) -> None:
from devx.tools.clean_images import list_package_versions
mock_resp = MagicMock()
mock_resp.json.return_value = [{"version": "0.1.0"}]
mock_resp.raise_for_status = MagicMock()
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp) as mock_get:
versions = list_package_versions(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"token",
)
assert versions == [{"version": "0.1.0"}]
assert "page=1" in mock_get.call_args.args[0]
def test_list_package_versions_pagination(self) -> None:
from devx.tools.clean_images import list_package_versions
# First page: 50 items, second page: 3 items, third page: empty
page1 = [{"version": f"0.{i}.0"} for i in range(50)]
page2 = [{"version": f"1.{i}.0"} for i in range(3)]
responses = [
MagicMock(json=MagicMock(return_value=page1), raise_for_status=MagicMock()),
MagicMock(json=MagicMock(return_value=page2), raise_for_status=MagicMock()),
MagicMock(json=MagicMock(return_value=[]), raise_for_status=MagicMock()),
]
with patch("devx.tools.clean_images.requests.get", side_effect=responses):
versions = list_package_versions(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"token",
)
assert len(versions) == 53
def test_delete_package_version_success(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=204)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is True
)
def test_delete_package_version_failure(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is False
)
class TestCLIBuildImage:
def test_single_image_build(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--tag", "latest"],
)
assert result.exit_code == 0
def test_manifest_build(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
manifest = tmp_path / "images.json"
manifest.write_text(
json.dumps(
[
{"name": "ci-base", "dockerfile": str(dockerfile)},
]
)
)
runner = CliRunner()
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--manifest", str(manifest)],
)
assert result.exit_code == 0
def test_missing_dockerfile_and_manifest(self) -> None:
runner = CliRunner()
result = runner.invoke(build_image.main, [])
assert result.exit_code != 0
assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower()
def test_push_without_registry(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"],
)
assert result.exit_code != 0
assert "registry" in result.output.lower()
def test_push_without_credentials(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
assert "credential" in result.output.lower() or "token" in result.output.lower()
def test_dry_run(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
with patch("devx.tools.build_image.subprocess.run") as mock_run:
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
mock_run.assert_not_called()
assert "dry-run" in result.output
def test_build_failure_exits_with_error(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
mock_result = MagicMock(returncode=1)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base"],
)
assert result.exit_code != 0
def test_push_login_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
login_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}):
with patch("devx.tools.build_image.subprocess.run", return_value=login_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
assert "login" in result.output.lower()
def test_push_image_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
build_result = MagicMock(returncode=0)
login_result = MagicMock(returncode=0, stderr="", stdout="")
push_result = MagicMock(returncode=1, stderr="push failed", stdout="")
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}):
with patch(
"devx.tools.build_image.subprocess.run",
side_effect=[login_result, build_result, push_result],
):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
class TestCLICleanImages:
def test_dry_run(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "1", "--dry-run"],
)
assert result.exit_code == 0
assert "dry-run" in result.output
assert "0.1.0" in result.output
def test_no_token(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base"],
)
assert result.exit_code != 0
assert "token" in result.output.lower()
def test_no_versions_found(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = []
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "No versions" in result.output
def test_actual_delete(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
list_resp = MagicMock()
list_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=204)
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "Deleted" in result.output
def test_list_request_exception(self) -> None:
import requests as req
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch(
"devx.tools.clean_images.requests.get",
side_effect=req.ConnectionError("network down"),
):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "Failed to list" in result.output
def test_delete_failure_in_cli(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
list_resp = MagicMock()
list_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=500)
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "FAILED" in result.output