Files
computer-playbook/utils/storage/constrained.py
kevinveenbirkenbachandClaude Opus 5 435ff42219 fix(storage): measure the docker root from the caller's namespace
docker info reports DockerRootDir as the DAEMON sees it, and
host_storage_constrained fed that string straight into shutil.disk_usage,
which statvfs()es it in the CALLING process's mount namespace. The two
coincide only when the caller is the daemon's host. In the nested act
container of the workspace jobs they do not, so os.statvfs('/var/lib/docker')
raised FileNotFoundError, matrix.py died, and because 00_one.sh invokes it
bare under set -euo pipefail the whole swarm drill went down with it. All
five workspace jobs of run 30560683608 failed this way, byte-identically,
across five base images and five nested distros.

docker_root_free_bytes() now takes the vantage point as a required keyword:
it measures the daemon's data root when that path resolves here, and the
caller's stated local path when it does not. matrix.py passes "/", which is
the same vantage it already trusts for its ENOSPC abort 270 lines earlier;
overlayfs passes statfs through to the upper filesystem, so a container of a
daemon measures that daemon's data-root filesystem through its own rootfs.

Nothing is caught and nothing is guessed. An unreachable daemon still raises
RuntimeError out of docker_data_root(), which is the guard this module exists
for, and local_vantage carries no default because only the caller knows where
it is running.

The compose-side twin loses its || true and its hardcoded /var/lib/docker
substitution, so an unresolvable root no longer measures a guessed filesystem.
Its compose.exec locality was already correct and is unchanged.

Verified against the pre-fix module under the CI condition: with the data root
stubbed to a path absent from this namespace, the old code raises
FileNotFoundError and the new code measures the local vantage; with the daemon
stubbed unreachable, the new code still raises RuntimeError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:59:09 +02:00

130 lines
4.6 KiB
Python

"""Decide whether a deploy should prefer free storage space over cache.
The decision compares the free space on the filesystem holding Docker's data
root - where images, volumes and build cache grow - against the storage the
deploy actually declares it needs: the sum of ``min_storage`` over the services
of the applications being deployed, transitive dependencies included. No
threshold constant is involved, so the answer follows the declarations rather
than a guess about disk sizes.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from utils import PROJECT_ROOT
from utils.cache.applications import get_variants
from utils.roles.applications.services.registry import (
build_service_registry_from_applications,
load_applications_from_roles_dir,
)
from utils.roles.applications.services.resources import (
aggregate,
collect_role_resources,
)
def required_storage_bytes(
app_ids: list[str], variants: dict[str, int] | None = None
) -> int:
"""Return the summed min_storage of app_ids and their transitive dependencies.
Args:
app_ids: applications the deploy installs; dependencies are walked.
variants: per-app variant index whose `meta/variants.yml` overlay decides
which services are enabled. Without it the base configuration counts,
which over-states a deploy that a variant trims down.
Services that declare no min_storage contribute nothing, so an entirely
undeclared deploy yields 0 and is never treated as constrained.
"""
roles_dir = PROJECT_ROOT / "roles"
applications = load_applications_from_roles_dir(roles_dir)
if variants:
applications = dict(applications)
available = get_variants(roles_dir=str(roles_dir))
for app_id, index in variants.items():
app_variants = available.get(app_id) or []
if 0 <= index < len(app_variants):
applications[app_id] = app_variants[index] or {}
service_registry = build_service_registry_from_applications(applications)
rows: list[dict] = []
visited: set = set()
for app_id in app_ids:
collect_role_resources(
role_name=app_id,
applications=applications,
service_registry=service_registry,
visited=visited,
rows=rows,
warnings=[],
)
return aggregate(rows, ["min_storage"]).get("min_storage_bytes") or 0
def is_constrained(*, free_bytes: int, required_bytes: int) -> bool:
"""Return True when the declared need does not fit into the free space."""
return required_bytes > free_bytes
def docker_data_root() -> str:
"""Return Docker's data root as reported by the local daemon.
Raises:
RuntimeError: the daemon is unreachable or reports no data root. Guessing
a path here would silently measure the wrong filesystem and report a
roomy host, which is the failure this module exists to prevent.
"""
proc = subprocess.run(
["docker", "info", "-f", "{{.DockerRootDir}}"],
capture_output=True,
text=True,
check=False,
)
root = (proc.stdout or "").strip()
if proc.returncode != 0 or not root:
raise RuntimeError(
"cannot determine Docker's data root: "
f"rc={proc.returncode} stderr={(proc.stderr or '').strip()!r}"
)
return root
def docker_root_free_bytes(*, local_vantage: str) -> int:
"""Return the free bytes on the filesystem where Docker's data grows.
Args:
local_vantage: a path in the caller's own mount namespace that sits on the
same filesystem as the daemon's storage. Consulted only when the data
root the daemon reports does not resolve here.
"""
root = docker_data_root()
if Path(root).is_dir():
return shutil.disk_usage(root).free
return shutil.disk_usage(local_vantage).free
def host_storage_constrained(
app_ids: list[str],
variants: dict[str, int] | None = None,
*,
local_vantage: str,
) -> bool:
"""Return True when the deploy's declared storage need exceeds the free space.
Args:
app_ids: applications the deploy installs; dependencies are walked.
variants: per-app variant index, as in required_storage_bytes.
local_vantage: measurement point when the daemon's data root does not
resolve in this mount namespace, as in docker_root_free_bytes.
"""
return is_constrained(
free_bytes=docker_root_free_bytes(local_vantage=local_vantage),
required_bytes=required_storage_bytes(app_ids, variants),
)