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
7 changes: 7 additions & 0 deletions docs/source/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ reward is zero; the validator never converts missing or null step rewards to zer
Cleanup removes run-owned containers. Built images remain in Docker's local cache
for reuse; the report records their immutable image IDs. Remove an unwanted image
with `docker image rm <image-id>` after its validation runs have finished.
Use `--cleanup-images` to remove the current run's image automatically after all
original and replay containers finish, including failure or interruption. Opt-in
builds receive a unique ownership tag and label; cleanup never forces removal or
prunes parent images or build caches. If another tag refers to the image, that
image is preserved. The artifact bundle's `cleanup.json` records image cleanup
separately, including whether the image itself was removed. This option applies
only to local Docker validation and has no effect when no build is attempted.

Runtime reports use schema version 2 and severity policy v2. Static reports for
v1 manifests retain schema version 1 and policy v1. An explicit v1 policy with a
Expand Down
13 changes: 13 additions & 0 deletions src/openenv/cli/commands/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ def validate(
help="Skip the image build; build-dependent checks are SKIPped with a reason",
),
] = False,
cleanup_images: Annotated[
bool,
typer.Option(
"--cleanup-images",
help="Remove this run's Docker images after validation; preserve shared caches",
),
] = False,
local: Annotated[
bool,
typer.Option(
Expand Down Expand Up @@ -218,6 +225,11 @@ def validate(
runtime_target = target

if runtime_target is not None:
if cleanup_images:
typer.echo(
"Error: --cleanup-images requires local package validation", err=True
)
raise typer.Exit(EXIT_FAIL)
if local:
typer.echo("Error: --local cannot be combined with a running URL", err=True)
raise typer.Exit(EXIT_FAIL)
Expand Down Expand Up @@ -261,6 +273,7 @@ def validate(
package_root,
max_level=_LEVELS[level],
skip_build=skip_build,
cleanup_images=cleanup_images,
policy=load_policy(policy_version) if policy_version else None,
artifacts_dir=(
output.parent / (output.stem + ".artifacts") if output else None
Expand Down
107 changes: 106 additions & 1 deletion src/openenv/validation/providers/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@


_LABEL = "org.openenv.validation.run"
_IMAGE_LABEL = "org.openenv.validation.image-owner"
_MAX_OUTPUT = 65536
_EXCLUDED = {
".git",
Expand Down Expand Up @@ -76,6 +77,31 @@ def _safe_text(value: str, secrets: tuple[str, ...] = ()) -> str:
)


def _image_tag(owner: str) -> str:
if not re.fullmatch(r"validation-[a-f0-9]{32}", owner):
raise ProviderError("Invalid validation image owner")
return f"openenv-validation:{owner}"


def _image_ids(reference: str | None = None, *, owner: str | None = None) -> set[str]:
"""List immutable IDs without inferring absence from engine-specific errors."""
argv = ["docker", "image", "ls", "--all", "--no-trunc", "--quiet"]
if reference is not None:
argv.append(reference)
if owner is not None:
argv.extend(["--filter", f"label={_IMAGE_LABEL}={owner}"])
code, output, _ = _command(argv, 10)
if code or len(output.encode("utf-8")) >= _MAX_OUTPUT:
raise ProviderError("Could not obtain a complete image inventory")
images = set()
for line in output.splitlines():
digest = line.removeprefix("sha256:")
if not re.fullmatch(r"[a-f0-9]{64}", digest):
raise ProviderError("Could not verify image inventory")
images.add("sha256:" + digest)
return images


def _command(
argv: list[str], timeout_s: float, max_bytes: int = _MAX_OUTPUT
) -> tuple[int, str, str]:
Expand Down Expand Up @@ -273,7 +299,23 @@ def __init__(
self.max_context_bytes = max_context_bytes
self.helper_image = helper_image

def build(self, root: Path, execution: ExecutionDeclaration) -> str:
def build(
self,
root: Path,
execution: ExecutionDeclaration,
*,
image_owner: str | None = None,
) -> str:
image_args = (
[
"--tag",
_image_tag(image_owner),
"--label",
f"{_IMAGE_LABEL}={image_owner}",
]
if image_owner is not None
else []
)
deadline = time.monotonic() + self.build_timeout_s
try:
root = root.resolve(strict=True)
Expand Down Expand Up @@ -302,17 +344,80 @@ def build(self, root: Path, execution: ExecutionDeclaration) -> str:
str(iidfile),
"--file",
str(dockerfile),
*image_args,
str(context),
],
deadline - time.monotonic(),
)
if code != 0:
raise StartupError("Image build failed: " + _safe_text(stderr[-4096:]))
image = iidfile.read_text().strip() if iidfile.exists() else ""
if image_owner is not None:
# A tagged BuildKit export's iidfile can differ from the immutable
# image ID addressable by the Docker daemon.
code, image, _ = _command(
[
"docker",
"image",
"inspect",
"--format",
"{{.Id}}",
_image_tag(image_owner),
],
min(10, deadline - time.monotonic()),
)
if code:
raise ProviderError("Docker did not publish the owned image")
image = image.strip()
if not re.fullmatch(r"sha256:[a-f0-9]{64}", image):
raise ProviderError("Docker did not return an immutable image ID")
return image

def cleanup_image(self, owner: str, expected_id: str | None = None) -> dict:
"""Release only this build's tag, preserving other tags and parent images."""
tag = _image_tag(owner)
evidence = {"owner": owner, "tag": tag, "required": True, "completed": False}
code, output, _ = _command(
[
"docker",
"image",
"inspect",
"--format",
f'{{{{.Id}}}} {{{{index .Config.Labels "{_IMAGE_LABEL}"}}}}',
tag,
],
10,
)
if code:
if not _image_ids(tag):
return {**evidence, "completed": True, "reason": "owned tag absent"}
raise ProviderError("Could not verify image ownership for cleanup")
fields = output.split()
if (
len(fields) != 2
or not re.fullmatch(r"sha256:[a-f0-9]{64}", fields[0])
or fields[1] != owner
or expected_id is not None
and fields[0] != expected_id
):
raise ProviderError(
"Refusing cleanup of an image with another identity or owner"
)
image_id = fields[0]
code, _, _ = _command(["docker", "image", "rm", "--no-prune", tag], 10)
if _image_ids(tag):
if code:
raise ProviderError("Could not remove validation image tag")
raise ProviderError("Image tag removal could not be independently verified")
return {
**evidence,
"image_id": image_id,
"completed": True,
# Retagging preserves this immutable label; unrelated caches cannot
# exhaust the inventory budget used to verify this run's image.
"image_removed": image_id not in _image_ids(owner=owner),
}

def start(self, spec: LaunchSpec) -> "DockerRunningSubject":
if spec.network.mode not in self.supported_network_modes:
raise UnsupportedCapability(
Expand Down
48 changes: 44 additions & 4 deletions src/openenv/validation/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,13 @@ def _applicable(check_id, manifest):
return True


def _runtime(subject, graders, *, skip_build, provider):
def _runtime(subject, graders, *, skip_build, provider, cleanup_images=False):
"""Own build/start/collection/stop and retain failure evidence through teardown."""
manifest = subject.manifest
plan = None
evidence = None
running = None
image_owner = image_ref = None
inspection = {}
cleanup = {"required": False, "completed": True}
started = time.monotonic()
Expand All @@ -166,10 +167,15 @@ def _runtime(subject, graders, *, skip_build, provider):
raise UnsupportedCapability(
"credential_delivery is deferred for this release"
)
if provider is None:
if provider is None or cleanup_images:
from .providers.docker import DockerValidationProvider

provider = DockerValidationProvider()
if provider is None:
provider = DockerValidationProvider()
if cleanup_images and not isinstance(provider, DockerValidationProvider):
raise UnsupportedCapability(
"--cleanup-images requires the Docker provider"
)
if manifest.network.mode not in provider.supported_network_modes:
raise UnsupportedCapability(
f"provider cannot enforce network mode {manifest.network.mode}"
Expand All @@ -190,7 +196,16 @@ def _runtime(subject, graders, *, skip_build, provider):
env_vars={"OPENENV_VALIDATION_TOKEN": secrets.token_urlsafe(32)},
)
attempted = True
image_ref = provider.build(subject.root, manifest.execution)
if cleanup_images:
# Register ownership before build: cancellation can occur after Docker
# publishes a tag but before the client returns its immutable ID.
image_owner = spec.run_id
cleanup["required"] = True
image_ref = provider.build(
subject.root, manifest.execution, image_owner=image_owner
)
else:
image_ref = provider.build(subject.root, manifest.execution)
spec = LaunchSpec.model_validate({**spec.model_dump(), "image_ref": image_ref})
running = provider.start(spec)
cleanup = {"required": True, "completed": False}
Expand Down Expand Up @@ -320,6 +335,26 @@ def _runtime(subject, graders, *, skip_build, provider):
result.status = CheckStatus.ERROR
result.evidence.append("subject teardown failed")
result.duration_s = time.monotonic() - started
if image_owner is not None:
try:
cleanup["image"] = provider.cleanup_image(image_owner, image_ref)
except (Exception, KeyboardInterrupt) as exc:
reason = f"image cleanup failed ({type(exc).__name__})"
cleanup["image"] = {
"required": True,
"completed": False,
"owner": image_owner,
"reason": reason,
}
cleanup["completed"] = False
if result is None:
result = _outcome(
"runtime.startup", CheckStatus.ERROR, reason, started=started
)
else:
result.status = CheckStatus.ERROR
result.evidence.append(reason)
result.duration_s = time.monotonic() - started
return [result, *checks], attempted, plan, evidence, inspection, cleanup


Expand All @@ -328,6 +363,7 @@ def run_validation(
*,
max_level: Level = Level.SEMANTIC,
skip_build: bool = False,
cleanup_images: bool = False,
policy: SeverityPolicy | None = None,
provider=None,
artifacts_dir: Path | None = None,
Expand All @@ -349,6 +385,9 @@ def run_validation(
Level ceiling; graders above it are not selected.
skip_build (`bool`, *optional*, defaults to `False`):
Skip the image build; build-dependent checks SKIP with a reason.
cleanup_images (`bool`, *optional*, defaults to `False`):
Remove this run's Docker image after its original and replay containers.
Other image tags, parent images and build caches are preserved.
policy ([`~openenv.validation.policy.SeverityPolicy`], *optional*):
`None` chooses v1 for static and v2 for runtime/semantic ceilings.
provider ([`~openenv.validation.providers.ValidationProvider`], *optional*):
Expand Down Expand Up @@ -438,6 +477,7 @@ def run_validation(
],
skip_build=skip_build,
provider=provider,
cleanup_images=cleanup_images,
)
results.extend(runtime_results)
if attempted:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_cli/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,18 @@ def test_validate_command_rejects_mixed_path_and_url(tmp_path: Path) -> None:
assert "Cannot combine a local path argument with --url" in result.output


def test_cleanup_images_rejects_legacy_url_without_connecting():
with patch(
"openenv.cli.commands.validate.validate_running_environment"
) as validate_url:
result = runner.invoke(
app, ["validate", "--url", "http://localhost:8000", "--cleanup-images"]
)
assert result.exit_code == 1
assert "requires local package" in result.output
validate_url.assert_not_called()


def test_write_report_validation_report_only() -> None:
assert write_report.__annotations__["report"] is ValidationReport
with pytest.raises(AttributeError):
Expand Down
64 changes: 63 additions & 1 deletion tests/test_validation/integration/test_docker_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from pathlib import Path

import pytest
from openenv.validation.manifest import NetworkPolicy, ResourceDeclaration
from openenv.validation.manifest import (
ExecutionDeclaration,
NetworkPolicy,
ResourceDeclaration,
)
from openenv.validation.providers import ProviderError, StartupError
from openenv.validation.providers.docker import DockerValidationProvider
from openenv.validation.runtime.contracts import LaunchSpec
Expand Down Expand Up @@ -175,3 +179,61 @@ def test_docker_exec_timeout_removes_process_tree(spec):
finally:
running.stop()
record_cleanup("exec_timeout", spec.run_id)


@pytest.mark.parametrize("shared", [False, True])
def test_docker_owned_image_cleanup_preserves_shared_images(spec, tmp_path, shared):
"""Only a unique build is disposable, after both image consumers release it."""
owner = "validation-" + uuid.uuid4().hex
sentinel = "openenv-validation-test:" + uuid.uuid4().hex
(tmp_path / "Dockerfile").write_text('FROM scratch\nCMD ["/unused"]\n')
provider = DockerValidationProvider()
image = provider.build(tmp_path, ExecutionDeclaration(), image_owner=owner)
assert image != spec.image_ref
containers = []
try:
for _ in range(2):
created = subprocess.run(
[
"docker",
"create",
"--label",
f"org.openenv.validation.run={owner}",
image,
],
check=True,
capture_output=True,
text=True,
timeout=10,
)
containers.append(created.stdout.strip())
assert containers[0] != containers[1]
with pytest.raises(ProviderError, match="remove validation image"):
provider.cleanup_image(owner, image)
if shared:
subprocess.run(["docker", "tag", image, sentinel], check=True, timeout=10)
finally:
for container in containers:
subprocess.run(
["docker", "rm", container], check=True, capture_output=True, timeout=10
)
cleaned = provider.cleanup_image(owner, image)
try:
assert cleaned["completed"] is True
assert cleaned["image_removed"] is not shared
assert provider.cleanup_image(owner, image)["completed"] is True
for preserved in [spec.image_ref, *([sentinel] if shared else [])]:
subprocess.run(
["docker", "image", "inspect", "--format", "{{.Id}}", preserved],
check=True,
capture_output=True,
timeout=10,
)
finally:
if shared:
subprocess.run(
["docker", "image", "rm", "--no-prune", sentinel],
check=True,
capture_output=True,
timeout=10,
)
Loading
Loading