""" Docker Prune Executor Scheduled, non-interactive reclaim of Docker disk usage. The host equivalent is system-admin-toj's scripts/disk/prune-docker.sh, which prompts per stage; a cron task cannot prompt, so the destructive stages are opt-in instead. Runs the docker CLI against the socket already mounted into this container. Config schema: { "build_cache": true, # safe: cache is rebuilt on demand "dangling_images": true, # safe: untagged layers nothing references "unused_images": false, # re-pull on next deploy; costs bandwidth "volumes": false, # DESTRUCTIVE - see below "build_cache_until_hours": 168, "dry_run": false } `volumes` is off by default and should stay off unless you have checked what is actually unattached. `docker volume prune` removes every volume not bound to a *running* container, which includes the data volume of anything merely stopped. On this host that is a plausible way to lose a database. Defaults are the two stages that only ever discard regenerable data. """ import asyncio import logging from typing import Any, Dict, List, Tuple from src.config import Settings logger = logging.getLogger(__name__) COMMAND_TIMEOUT = 900 async def _run(args: List[str]) -> Tuple[int, str, str]: proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=COMMAND_TIMEOUT) except asyncio.TimeoutError: proc.kill() await proc.wait() raise Exception(f"timed out after {COMMAND_TIMEOUT}s: {' '.join(args)}") return proc.returncode, stdout.decode().strip(), stderr.decode().strip() def _reclaimed(output: str) -> str: """Pull the 'Total reclaimed space: X' line out of docker's prune output.""" for line in output.splitlines(): if "reclaimed space" in line.lower(): return line.split(":", 1)[1].strip() return "0B" async def execute(config: Dict[str, Any], settings: Settings) -> str: dry_run = bool(config.get("dry_run", False)) until_hours = int(config.get("build_cache_until_hours", 168)) stages: List[Tuple[str, List[str]]] = [] if config.get("build_cache", True): stages.append( ("build cache", ["docker", "builder", "prune", "-f", "--filter", f"until={until_hours}h"]) ) if config.get("dangling_images", True): stages.append(("dangling images", ["docker", "image", "prune", "-f"])) if config.get("unused_images", False): stages.append(("unused images", ["docker", "image", "prune", "-a", "-f"])) if config.get("volumes", False): logger.warning( "volume pruning is enabled; this removes volumes belonging to stopped " "containers, not just orphaned ones" ) stages.append(("volumes", ["docker", "volume", "prune", "-f"])) if not stages: return "no prune stages enabled; nothing to do" rc, out, err = await _run(["docker", "system", "df"]) if rc != 0: raise Exception(f"docker unavailable: {err or out}") before = out if dry_run: planned = ", ".join(name for name, _ in stages) logger.info("dry run; would prune: %s", planned) return f"dry run - would prune: {planned}\n{before}" results = [] for name, args in stages: rc, out, err = await _run(args) if rc != 0: # Report rather than abort: a later stage may still reclaim space, and # a partial reclaim is more useful than none. logger.error("prune stage %r failed: %s", name, err or out) results.append(f"{name}: FAILED ({(err or out).splitlines()[0] if (err or out) else 'unknown'})") continue results.append(f"{name}: {_reclaimed(out)}") logger.info("pruned %s -> %s", name, _reclaimed(out)) summary = "; ".join(results) if any("FAILED" in r for r in results): raise Exception(f"one or more prune stages failed: {summary}") return f"reclaimed - {summary}"