Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions cli/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
- [First-Time Setup](#first-time-setup)
- [Day-to-Day Usage](#day-to-day-usage)
- [CI/CD Comparison](#cicd-comparison)
- [Testing](#testing)
- [Testing Docker Image Management](#testing-docker-image-management)
- [Troubleshooting](#troubleshooting)

---
Expand Down Expand Up @@ -934,6 +936,130 @@ localci run --matrix compiler=clang --matrix version=20

---

## Testing

### Testing Docker Image Management

How to test the Docker image management feature (registry, two-mark matching,
load/build/save, base images).

#### Unit tests (no Docker required)

Most tests mock Docker. From the **cli** directory:

```bash
cd cli
pip install -e ".[dev]" # if not already
python3 -m pytest tests/test_registry.py tests/test_image_manager.py -v
```

**What they cover:**

- **`tests/test_registry.py`**: Two-mark algorithm (essential/extra marks),
`select_image`, tie-breaking, `ImageRegistry` load/save/CRUD, queue builder
with registry (full match vs needs_build).
- **`tests/test_image_manager.py`**: `image_name_from_entry`,
`image_name_base_from_entry`, `ImageManager.prepare_image_for_job` (use
existing, load from tar) with mocked Docker.

Run all related tests (including executor/orchestrator that use cache paths):

```bash
python3 -m pytest tests/test_registry.py tests/test_image_manager.py tests/test_orchestrator.py tests/test_executor.py -v
```

#### CLI checks (list / info)

With an `image-registry.yml` in the project (or use `--registry`), you can
test list and info without Docker:

```bash
# Create a minimal registry so list works
echo 'version: "1.0"
images:
- name: capy-ubuntu-25.04-gcc15
docker_tag: capy-ubuntu-25.04-gcc15:latest
file: images/capy/capy-ubuntu-25.04-gcc15.tar
os: ubuntu:25.04
architecture: x86_64
compilers: [gcc-15]' > image-registry.yml

localci images list
localci images info capy-ubuntu-25.04-gcc15
```

With Docker installed you can pass the registry path explicitly:

```bash
localci images list --registry image-registry.yml
localci images info capy-ubuntu-25.04-gcc15 --registry image-registry.yml
```

#### End-to-end with Docker and a workflow

**Prerequisites:** Docker running, `act` installed, and a workflow that uses a
matrix with `container:` (e.g. capy’s CI).

**Registry present, image in registry**

1. Create `image-registry.yml` in the project root (or where you run `localci`):

```yaml
version: "1.0"
images:
- name: capy-ubuntu-25.04-gcc15
file: images/capy/capy-ubuntu-25.04-gcc15.tar
docker_tag: capy-ubuntu-25.04-gcc15:latest
os: ubuntu:25.04
architecture: x86_64
compilers: [gcc-15]
```

2. Build or import the image and save as `.tar`:

```bash
docker build -t capy-ubuntu-25.04-gcc15:latest -f images/capy/Dockerfile.capy-ubuntu-25.04-gcc15 images/capy
mkdir -p images/capy
docker save -o images/capy/capy-ubuntu-25.04-gcc15.tar capy-ubuntu-25.04-gcc15:latest
```

3. Run one job; the orchestrator should load the image from the registry:

```bash
cd /path/to/capy # or project with .github/workflows/ci.yml
localci run --workflow .github/workflows/ci.yml --job build --matrix compiler=gcc --matrix version=15
```

**No registry:** Without an `image-registry.yml` (or with an empty registry),
the queue uses base-only tags and does not build; `act` uses the default
runner image or you must provide images another way.

**Registry present, no matching image (needs_build):** Use a registry with no
image matching your matrix (e.g. empty `images:` or different OS/compiler).
Run the same `localci run` as above. The queue sets `needs_build=True` and the
orchestrator calls `ImageManager.prepare_image_for_job`, which will try to
build a base image and save it if Docker and a suitable Dockerfile or
generated build path exist.

#### Quick checklist

| Test | Command / action |
|------|-------------------|
| Registry + matching | `pytest tests/test_registry.py -v` |
| Image manager (mocked) | `pytest tests/test_image_manager.py -v` |
| Queue builder + registry | In `test_registry.py`: `TestQueueBuilderWithRegistry` |
| Base-only naming | `pytest tests/test_image_manager.py::TestImageNameBaseFromEntry -v` |
| CLI list/info | `localci images list`, `localci images info <name>` with a valid `image-registry.yml` |
| Load from .tar | Build image, `docker save` to path in registry `file`, run `localci run`; check logs for “Loading image” / “Image ready”. |
| Build when no match | Empty or non-matching registry + `localci run`; confirm build and save. |

**File reference:** Registry and matching: `localci/core/registry.py`. Image
manager (load/build/save): `localci/core/image_manager.py`. Queue image
resolution: `localci/core/queue_builder.py`. CLI: `localci/cli/images.py`
(`localci images list|info|build|clean|import|export`).

---

## Troubleshooting

### "No config file found"
Expand Down
91 changes: 82 additions & 9 deletions cli/localci/cli/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
from __future__ import annotations

import json
import re
import subprocess
from datetime import datetime, timezone, timedelta
from pathlib import Path

import click
import yaml

from localci.core.registry import ImageRegistry
from localci.utils.docker import DockerManager
from localci.utils.output import (
console,
make_table,
Expand Down Expand Up @@ -189,29 +192,99 @@ def images_build(
# ---------------------------------------------------------------------------


def _parse_older_than(s: str) -> timedelta | None:
"""Parse --older-than value: e.g. 7d, 30d, 2w, 1m (m = 30 days)."""
m = re.match(r"^(\d+)(d|w|m)$", s.strip().lower())
if not m:
return None
num = int(m.group(1))
unit = m.group(2)
if unit == "d":
return timedelta(days=num)
if unit == "w":
return timedelta(weeks=num)
if unit == "m":
return timedelta(days=num * 30)
return None


@images.command("clean")
@click.option("--older-than", type=str, default=None, help="Remove images older than (e.g. 30d).")
@click.option("--unused", is_flag=True, help="Remove unused images.")
@click.option("--all", "clean_all", is_flag=True, help="Remove all localci images.")
@click.option("--older-than", type=str, default=None, help="Remove registry images not used since (e.g. 30d, 7d, 2w).")
@click.option("--unused", is_flag=True, help="Remove registry images with usage_count 0.")
@click.option("--all", "clean_all", is_flag=True, help="Remove all localci capy images (Docker + registry).")
@click.option("--dry-run", is_flag=True, help="Preview without removing.")
@click.option("--registry", "-r", "registry_path", type=click.Path(path_type=Path, exists=False), default=None, help="Path to image-registry.yml.")
@click.pass_context
def images_clean(
ctx: click.Context,
older_than: str | None,
unused: bool,
clean_all: bool,
dry_run: bool,
registry_path: Path | None,
) -> None:
"""Clean up Docker images."""
if older_than:
print_warning("--older-than is not yet implemented; ignoring.")
if unused:
print_warning("--unused is not yet implemented; ignoring.")
"""Clean up Docker images and optionally registry / .tar files."""
reg_path = registry_path or REGISTRY_FILE
project_dir = REPO_ROOT

# Disk space management: --older-than and --unused (registry-based)
if older_than or unused:
if not reg_path.exists():
print_error(f"Registry not found: {reg_path}. Cannot use --older-than/--unused.")
ctx.exit(1)
delta = None
if older_than:
delta = _parse_older_than(older_than)
if not delta:
print_error("--older-than must be like 7d, 30d, 2w, 1m")
ctx.exit(1)
registry = ImageRegistry(reg_path)
registry.load()
cutoff = (datetime.now(timezone.utc) - delta) if delta else None
to_remove: list[str] = []
for e in registry.entries:
if older_than and cutoff and e.last_used:
try:
lu = datetime.fromisoformat(e.last_used.replace("Z", "+00:00"))
if lu.tzinfo is None:
lu = lu.replace(tzinfo=timezone.utc)
if lu < cutoff:
to_remove.append(e.name)
except ValueError:
pass
if unused and (e.usage_count or 0) == 0:
to_remove.append(e.name)
to_remove = list(dict.fromkeys(to_remove))
if not to_remove:
print_info("No images match --older-than/--unused.")
return
docker = DockerManager()
for name in to_remove:
entry = registry.find_by_name(name)
if not entry:
continue
tag = entry.docker_tag
if dry_run:
console.print(f" Would remove: {name} (Docker: {tag})")
continue
if docker.image_exists(tag):
docker.remove_image(tag, force=True)
tar_path = project_dir / entry.file if not Path(entry.file).is_absolute() else Path(entry.file)
if tar_path.exists():
tar_path.unlink()
registry.remove(name)
if not dry_run:
registry.save()
print_success(f"Removed {len(to_remove)} image(s) from registry and disk.")
else:
print_info(f"Dry-run: would remove {len(to_remove)} image(s).")
return

if not clean_all:
print_info("Nothing to clean. Use --all to remove localci images.")
print_info("Nothing to clean. Use --all, --older-than, or --unused.")
return

# --all: remove all capy Docker images (and optionally registry entries)
result = _run(["docker", "image", "ls", "--format", "{{.Repository}}:{{.Tag}}"])
if result.returncode != 0:
print_error(result.stderr.strip() or "Failed to list Docker images.")
Expand Down
4 changes: 4 additions & 0 deletions cli/localci/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ def run(
default_secrets={"GITHUB_TOKEN": gh_token},
default_env={},
)
registry_path = project_dir / "image-registry.yml"
images_dir = project_dir / "images" / "capy"
orchestrator = ParallelExecutionManager(
queue=queue,
workflow_file=workflow_path,
Expand All @@ -294,6 +296,8 @@ def run(
cache_config=cfg.cache,
no_cache=no_cache,
cache_dir_override=cache_dir,
registry_path=registry_path,
images_dir=images_dir,
)

status_file = logs_dir / "last-status.json"
Expand Down
4 changes: 4 additions & 0 deletions cli/localci/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
extra_marks,
select_image,
)
from localci.core.image_manager import ( # noqa: F401
ImageManager,
image_name_from_entry,
)
from localci.core.orchestrator import ( # noqa: F401
ExecutionRun,
OrchestratorConfig,
Expand Down
Loading