daggerml.contrib.docker_gc

Conservative Docker garbage collection and optional user-systemd scheduling.

View source
  1"""Conservative Docker garbage collection and optional user-systemd scheduling."""
  2
  3import json
  4import logging
  5import os
  6import shutil
  7import subprocess
  8import sys
  9from datetime import datetime, timedelta, timezone
 10from pathlib import Path
 11from typing import Any
 12
 13from daggerml.api import DmlRepoError
 14from daggerml.contrib.executors.docker import DockerExecutor, _image_lock
 15
 16_OWNED_REPOSITORIES = {"daggerml-archive", "daggerml-execution"}
 17logger = logging.getLogger(__name__)
 18
 19
 20def _owned_tag(tag: str) -> bool:
 21    tag = tag.removeprefix("docker.io/library/").removeprefix("index.docker.io/library/")
 22    return tag.partition(":")[0] in _OWNED_REPOSITORIES
 23
 24
 25def _timestamp(value: str) -> datetime:
 26    return datetime.fromisoformat(value.replace("Z", "+00:00"))
 27
 28
 29def _run_if_present(*args: str) -> str | None:
 30    """Ignore resources removed by Docker or other clients since enumeration."""
 31    try:
 32        return DockerExecutor._run_docker(*args)
 33    except DmlRepoError as exc:
 34        if any(marker in str(exc).lower() for marker in (f"no such {args[0]}:", "no such object:")):
 35            return None
 36        raise
 37
 38
 39def collect(*, all_resources: bool = False, older_than_hours: float = 24, dry_run: bool = False) -> dict[str, Any]:
 40    """Collect old stopped containers and unreferenced image tags.
 41
 42    Parameters
 43    ----------
 44    all_resources : bool
 45        Include foreign resources, rather than only DML labels and tag prefixes.
 46    older_than_hours : float
 47        Minimum time since container exit/creation or image creation.
 48    dry_run : bool
 49        Report candidates without deleting them.
 50
 51    Returns
 52    -------
 53    dict
 54        Container IDs and image references selected for removal.
 55    """
 56    if not 0 < older_than_hours < float("inf"):
 57        raise ValueError("older-than-hours must be a positive finite number")
 58    cutoff = datetime.now(timezone.utc) - timedelta(hours=older_than_hours)
 59    run = DockerExecutor._run_docker
 60    removed: dict[str, Any] = {"containers": [], "images": [], "dry_run": dry_run}
 61    with _image_lock():
 62        container_ids = run("container", "ls", "-aq", "--no-trunc").split()
 63        containers = []
 64        for ident in container_ids:
 65            output = _run_if_present("container", "inspect", ident)
 66            if output is not None:
 67                containers.append(json.loads(output)[0])
 68        retained = []
 69        for container in containers:
 70            state = container["State"]
 71            owned = (container.get("Config", {}).get("Labels") or {}).get("daggerml.managed") == "true"
 72            finished = state.get("FinishedAt") or container["Created"]
 73            if finished.startswith("0001-"):
 74                finished = container["Created"]
 75            if (state.get("Status") in {"exited", "dead", "created"}
 76                    and (all_resources or owned)
 77                    and max(_timestamp(finished), _timestamp(container["Created"])) < cutoff):
 78                if not dry_run:
 79                    if _run_if_present("container", "rm", container["Id"]) is None:
 80                        continue
 81                removed["containers"].append(container["Id"])
 82            else:
 83                retained.append(container)
 84        referenced = {container["Image"] for container in retained}
 85        image_ids = set(run("image", "ls", "-aq", "--no-trunc").split())
 86        pending = []
 87        for ident in sorted(image_ids):
 88            output = _run_if_present("image", "inspect", ident)
 89            if output is None:
 90                continue
 91            image = json.loads(output)[0]
 92            if image["Id"] in referenced or _timestamp(image["Created"]) >= cutoff:
 93                continue
 94            tags = image.get("RepoTags") or []
 95            refs = tags if all_resources else [tag for tag in tags if _owned_tag(tag)]
 96            if all_resources and not refs:
 97                refs = [image["Id"]]
 98            pending.extend(refs)
 99        while pending:
100            deferred = []
101            for ref in pending:
102                try:
103                    # Never force deletion. Docker remains the final reference check.
104                    if not dry_run:
105                        if _run_if_present("image", "rm", "--no-prune", ref) is None:
106                            continue
107                except DmlRepoError as exc:
108                    if "image has dependent child images" not in str(exc).lower():
109                        raise
110                    deferred.append(ref)
111                    continue
112                removed["images"].append(ref)
113            if len(deferred) == len(pending):
114                logger.warning("Retaining images with dependent children: %s", ", ".join(deferred))
115                break
116            pending = deferred
117    return removed
118
119
120def _systemd_quote(value: str, *, exec_argument: bool = True) -> str:
121    if "\n" in value or "\r" in value or "\x00" in value:
122        raise ValueError("systemd values must be single-line strings")
123    value = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
124    if exec_argument:
125        value = value.replace("$", "$$")
126    return '"' + value + '"'
127
128
129def install(*, all_resources: bool = False, older_than_hours: float = 24) -> Path:
130    """Install and enable an hourly systemd user timer for Docker GC.
131
132    Parameters
133    ----------
134    all_resources : bool
135        Persist host-wide collection in the installed service.
136    older_than_hours : float
137        Minimum resource age passed to each collection.
138
139    Returns
140    -------
141    Path
142        Directory containing the installed service and timer.
143    """
144    if not sys.platform.startswith("linux") or not shutil.which("systemctl"):
145        raise DmlRepoError(
146            "Automatic installation requires Linux and systemd; schedule 'dml contrib docker-gc' manually"
147        )
148    if not 0 < older_than_hours < float("inf"):
149        raise ValueError("older-than-hours must be a positive finite number")
150    command = [sys.executable, "-m", "daggerml.contrib.docker_gc", "--older-than-hours", str(older_than_hours)]
151    if all_resources:
152        command.append("--all")
153    # User services need the same Docker executable, cache lock, and daemon
154    # selection as the interactive installer. No shell interpolation is used.
155    environment = {"PATH": os.environ.get("PATH", os.defpath)}
156    unset_environment = []
157    for name in ("XDG_CACHE_HOME", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY",
158                 "DOCKER_CERT_PATH"):
159        if name in os.environ:
160            environment[name] = os.environ[name]
161        else:
162            # User services inherit the manager's environment, which can contain
163            # stale Docker or cache settings absent from this shell.
164            unset_environment.append(name)
165    service = ("[Unit]\nDescription=DaggerML Docker garbage collection\n\n[Service]\nType=oneshot\n"
166               + "".join(f"Environment={_systemd_quote(f'{key}={value}', exec_argument=False)}\n"
167                          for key, value in environment.items())
168               + ("UnsetEnvironment=" + " ".join(unset_environment) + "\n" if unset_environment else "")
169               + "ExecStart=" + " ".join(_systemd_quote(arg) for arg in command) + "\n")
170    timer = ("[Unit]\nDescription=Hourly DaggerML Docker garbage collection\n\n[Timer]\n"
171             "OnCalendar=hourly\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n")
172    directory = Path(os.getenv("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
173    directory.mkdir(parents=True, exist_ok=True)
174    (directory / "daggerml-docker-gc.service").write_text(service, encoding="utf-8")
175    (directory / "daggerml-docker-gc.timer").write_text(timer, encoding="utf-8")
176    subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
177    subprocess.run(["systemctl", "--user", "enable", "--now", "daggerml-docker-gc.timer"], check=True)
178    return directory
179
180
181def main() -> None:
182    """Run the standalone collector used by the installed timer."""
183    from daggerml.contrib.status import cli
184
185    raise SystemExit(cli(["docker-gc", *sys.argv[1:]]))
186
187
188if __name__ == "__main__":
189    main()

logger

logger= <Logger daggerml.contrib.docker_gc (WARNING)>

collect

def collect( *, all_resources: bool = False, older_than_hours: float = 24, dry_run: bool = False) -> dict[str, typing.Any]:
View source
 40def collect(*, all_resources: bool = False, older_than_hours: float = 24, dry_run: bool = False) -> dict[str, Any]:
 41    """Collect old stopped containers and unreferenced image tags.
 42
 43    Parameters
 44    ----------
 45    all_resources : bool
 46        Include foreign resources, rather than only DML labels and tag prefixes.
 47    older_than_hours : float
 48        Minimum time since container exit/creation or image creation.
 49    dry_run : bool
 50        Report candidates without deleting them.
 51
 52    Returns
 53    -------
 54    dict
 55        Container IDs and image references selected for removal.
 56    """
 57    if not 0 < older_than_hours < float("inf"):
 58        raise ValueError("older-than-hours must be a positive finite number")
 59    cutoff = datetime.now(timezone.utc) - timedelta(hours=older_than_hours)
 60    run = DockerExecutor._run_docker
 61    removed: dict[str, Any] = {"containers": [], "images": [], "dry_run": dry_run}
 62    with _image_lock():
 63        container_ids = run("container", "ls", "-aq", "--no-trunc").split()
 64        containers = []
 65        for ident in container_ids:
 66            output = _run_if_present("container", "inspect", ident)
 67            if output is not None:
 68                containers.append(json.loads(output)[0])
 69        retained = []
 70        for container in containers:
 71            state = container["State"]
 72            owned = (container.get("Config", {}).get("Labels") or {}).get("daggerml.managed") == "true"
 73            finished = state.get("FinishedAt") or container["Created"]
 74            if finished.startswith("0001-"):
 75                finished = container["Created"]
 76            if (state.get("Status") in {"exited", "dead", "created"}
 77                    and (all_resources or owned)
 78                    and max(_timestamp(finished), _timestamp(container["Created"])) < cutoff):
 79                if not dry_run:
 80                    if _run_if_present("container", "rm", container["Id"]) is None:
 81                        continue
 82                removed["containers"].append(container["Id"])
 83            else:
 84                retained.append(container)
 85        referenced = {container["Image"] for container in retained}
 86        image_ids = set(run("image", "ls", "-aq", "--no-trunc").split())
 87        pending = []
 88        for ident in sorted(image_ids):
 89            output = _run_if_present("image", "inspect", ident)
 90            if output is None:
 91                continue
 92            image = json.loads(output)[0]
 93            if image["Id"] in referenced or _timestamp(image["Created"]) >= cutoff:
 94                continue
 95            tags = image.get("RepoTags") or []
 96            refs = tags if all_resources else [tag for tag in tags if _owned_tag(tag)]
 97            if all_resources and not refs:
 98                refs = [image["Id"]]
 99            pending.extend(refs)
100        while pending:
101            deferred = []
102            for ref in pending:
103                try:
104                    # Never force deletion. Docker remains the final reference check.
105                    if not dry_run:
106                        if _run_if_present("image", "rm", "--no-prune", ref) is None:
107                            continue
108                except DmlRepoError as exc:
109                    if "image has dependent child images" not in str(exc).lower():
110                        raise
111                    deferred.append(ref)
112                    continue
113                removed["images"].append(ref)
114            if len(deferred) == len(pending):
115                logger.warning("Retaining images with dependent children: %s", ", ".join(deferred))
116                break
117            pending = deferred
118    return removed

Collect old stopped containers and unreferenced image tags.

Parameters
  • all_resources (bool): Include foreign resources, rather than only DML labels and tag prefixes.
  • older_than_hours (float): Minimum time since container exit/creation or image creation.
  • dry_run (bool): Report candidates without deleting them.
Returns
  • dict: Container IDs and image references selected for removal.

install

def install( *, all_resources: bool = False, older_than_hours: float = 24) -> pathlib._local.Path:
View source
130def install(*, all_resources: bool = False, older_than_hours: float = 24) -> Path:
131    """Install and enable an hourly systemd user timer for Docker GC.
132
133    Parameters
134    ----------
135    all_resources : bool
136        Persist host-wide collection in the installed service.
137    older_than_hours : float
138        Minimum resource age passed to each collection.
139
140    Returns
141    -------
142    Path
143        Directory containing the installed service and timer.
144    """
145    if not sys.platform.startswith("linux") or not shutil.which("systemctl"):
146        raise DmlRepoError(
147            "Automatic installation requires Linux and systemd; schedule 'dml contrib docker-gc' manually"
148        )
149    if not 0 < older_than_hours < float("inf"):
150        raise ValueError("older-than-hours must be a positive finite number")
151    command = [sys.executable, "-m", "daggerml.contrib.docker_gc", "--older-than-hours", str(older_than_hours)]
152    if all_resources:
153        command.append("--all")
154    # User services need the same Docker executable, cache lock, and daemon
155    # selection as the interactive installer. No shell interpolation is used.
156    environment = {"PATH": os.environ.get("PATH", os.defpath)}
157    unset_environment = []
158    for name in ("XDG_CACHE_HOME", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY",
159                 "DOCKER_CERT_PATH"):
160        if name in os.environ:
161            environment[name] = os.environ[name]
162        else:
163            # User services inherit the manager's environment, which can contain
164            # stale Docker or cache settings absent from this shell.
165            unset_environment.append(name)
166    service = ("[Unit]\nDescription=DaggerML Docker garbage collection\n\n[Service]\nType=oneshot\n"
167               + "".join(f"Environment={_systemd_quote(f'{key}={value}', exec_argument=False)}\n"
168                          for key, value in environment.items())
169               + ("UnsetEnvironment=" + " ".join(unset_environment) + "\n" if unset_environment else "")
170               + "ExecStart=" + " ".join(_systemd_quote(arg) for arg in command) + "\n")
171    timer = ("[Unit]\nDescription=Hourly DaggerML Docker garbage collection\n\n[Timer]\n"
172             "OnCalendar=hourly\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n")
173    directory = Path(os.getenv("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
174    directory.mkdir(parents=True, exist_ok=True)
175    (directory / "daggerml-docker-gc.service").write_text(service, encoding="utf-8")
176    (directory / "daggerml-docker-gc.timer").write_text(timer, encoding="utf-8")
177    subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
178    subprocess.run(["systemctl", "--user", "enable", "--now", "daggerml-docker-gc.timer"], check=True)
179    return directory

Install and enable an hourly systemd user timer for Docker GC.

Parameters
  • all_resources (bool): Persist host-wide collection in the installed service.
  • older_than_hours (float): Minimum resource age passed to each collection.
Returns
  • Path: Directory containing the installed service and timer.

main

def main() -> None:
View source
182def main() -> None:
183    """Run the standalone collector used by the installed timer."""
184    from daggerml.contrib.status import cli
185
186    raise SystemExit(cli(["docker-gc", *sys.argv[1:]]))

Run the standalone collector used by the installed timer.