Skip to content
Merged
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
202 changes: 202 additions & 0 deletions fixos/cli/_cleanup_docker_volumes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Selective removal of unused (dangling) Docker volumes.

Volumes hold real data, so nothing here is bulk: candidates are listed with
name, Compose project and age, the user picks them, and each one is checked
again right before ``docker volume rm``. ``--yes`` never selects volumes whose
name looks like stored data (databases, git mirrors, backups, ...).
"""

from __future__ import annotations

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

import click

from fixos.cli import _cleanup_space as space
from fixos.cli._cleanup_utils import _format_bytes

# Name tokens that usually mean irreplaceable data rather than a cache.
_DATA_TOKENS = re.compile(
r"(?:^|[_.-])(mirror|git|gitea|onedev|repo|repos|postgres|postgresql|pg|"
r"mysql|mariadb|mongo|mongodb|redis|db|database|backup|backups|minio|s3|"
r"vault|secrets?|keycloak|grafana|prometheus|influx|elastic|opensearch|"
r"nextcloud|uploads|media|webui|openwebui|open-webui|n8n|storage|certs|"
r"letsencrypt|wordpress|mattermost|synapse|chat-history)(?:$|[_.-])",
re.IGNORECASE,
)
_INSPECT_BATCH = 100


def _docker_lines(args: list[str], timeout: int = 30) -> list[str] | None:
try:
result = space._run_cmd(["docker", *args], timeout=timeout)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
return [line for line in result.stdout.splitlines() if line.strip()]


def _is_unused(name: str) -> bool:
"""Re-check right before removal: still dangling and no container (even stopped)."""
dangling = _docker_lines(["volume", "ls", "-q", "-f", "dangling=true", "-f", f"name=^{name}$"])
containers = _docker_lines(["ps", "-aq", "--filter", f"volume={name}"])
return dangling is not None and name in dangling and containers == []


def _age_days(created_at: str, now: datetime) -> float | None:
try:
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
except ValueError:
return None
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
return (now - created).total_seconds() / 86400


def _volume_sizes(mountpoints: list[str]) -> dict[str, int]:
"""Sizes via ``du``; volumes are root-owned, so fall back to ``sudo -n``.

``sudo -n`` never prompts: without passwordless sudo the size stays unknown.
"""
sizes = space._dir_sizes([Path(m) for m in mountpoints])
missing = [m for m in mountpoints if m not in sizes]
if missing:
try:
result = space._run_cmd(["sudo", "-n", "du", "-sbl0", "--", *missing], timeout=300)
for record in result.stdout.split("\0"):
size, sep, path = record.partition("\t")
if sep and size.isdigit():
sizes[path] = int(size)
except (OSError, subprocess.TimeoutExpired):
pass
return sizes


def _unused_volumes(
days: float | None, match: list[str], now: datetime | None = None
) -> list[dict] | None:
names = _docker_lines(["volume", "ls", "-q", "-f", "dangling=true"])
if names is None:
return None
now = now or datetime.now(timezone.utc)
records: list[dict] = []
for start in range(0, len(names), _INSPECT_BATCH):
raw = _docker_lines(["volume", "inspect", *names[start : start + _INSPECT_BATCH]], timeout=60)
if raw is None:
return None
records.extend(json.loads("\n".join(raw)))
candidates = []
for record in records:
name = record.get("Name", "")
if match and not any(token.lower() in name.lower() for token in match):
continue
age = _age_days(record.get("CreatedAt", ""), now)
if days is not None and (age is None or age < days):
continue
labels = record.get("Labels") or {}
project = labels.get("com.docker.compose.project") or "(bez projektu)"
protected = bool(_DATA_TOKENS.search(name))
candidates.append(
{
"name": name,
"description": name,
"project": project,
"age_days": age,
"mountpoint": record.get("Mountpoint", ""),
"protected": protected,
"size_bytes": 0,
"size_known": False,
}
)
sizes = _volume_sizes([c["mountpoint"] for c in candidates if c["mountpoint"]])
for candidate in candidates:
if candidate["mountpoint"] in sizes:
candidate["size_bytes"] = sizes[candidate["mountpoint"]]
candidate["size_known"] = True
age = candidate["age_days"]
size = _format_bytes(candidate["size_bytes"]) if candidate["size_known"] else "rozmiar nieznany"
candidate["note"] = (
f"{candidate['project']}, {age:.0f} dni" if age is not None else candidate["project"]
) + (" — wygląda na DANE, tylko ręczny wybór" if candidate["protected"] else "")
candidate["size_label"] = size
candidates.sort(key=lambda c: (c["size_known"], c["size_bytes"]), reverse=True)
return candidates


def _cleanup_docker_volumes(
json_output: bool,
dry_run: bool,
list_only: bool,
yes: bool,
days: float | None = None,
match: list[str] | None = None,
) -> None:
"""List unused Docker volumes and remove the selected ones."""
candidates = _unused_volumes(days, match or [])
if json_output:
space._echo_json(
{
"error": None if candidates is not None else "docker volume ls failed",
"candidates": [
{k: c[k] for k in ("name", "project", "age_days", "size_bytes", "size_known", "protected")}
for c in candidates or []
],
}
)
return
if candidates is None:
click.echo(click.style("Błąd: nie udało się odczytać wolumenów Dockera.", fg="red"))
return
if not candidates:
click.echo(click.style("Brak nieużywanych wolumenów Dockera dla podanych filtrów.", fg="green"))
return
known = sum(c["size_bytes"] for c in candidates if c["size_known"])
click.echo(
f"\n{click.style('DOCKER — nieużywane wolumeny (dane!):', fg='magenta', bold=True)} "
f"{len(candidates)}, znany rozmiar {_format_bytes(known)}"
)
for index, item in enumerate(candidates, 1):
color = "red" if item["protected"] else "yellow"
click.echo(
f" [{index:3d}] {click.style(item['name'], fg=color)}: {item['size_label']} — {item['note']}"
)
if list_only:
return
if yes:
selected = [i for i, c in enumerate(candidates) if not c["protected"]]
skipped = len(candidates) - len(selected)
if skipped:
click.echo(f" --yes pomija {skipped} wolumenów wyglądających na dane; wybierz je ręcznie.")
else:
selected = space._choose(candidates, "Numery wolumenów do usunięcia (np. 1,2, all)")
if not selected:
click.echo("Pominięto.")
return
if dry_run:
space._show_dry_run_commands([["docker", "volume", "rm", candidates[i]["name"]] for i in selected])
return
if not yes and not space._confirm_remove():
click.echo("Anulowano.")
return
freed = 0
for index in selected:
name = candidates[index]["name"]
if not _is_unused(name):
click.echo(f" ⏭️ {name}: jest już używany albo nie istnieje — pominięto")
continue
try:
result = space._run_cmd(["docker", "volume", "rm", name], timeout=120)
except (OSError, subprocess.TimeoutExpired) as exc:
click.echo(click.style(f" ❌ {name}: {exc}", fg="red"))
continue
if result.returncode == 0:
freed += candidates[index]["size_bytes"]
click.echo(click.style(f" ✅ {name}", fg="green"))
else:
click.echo(click.style(f" ❌ {name}: {result.stderr.strip()[:120]}", fg="red"))
click.echo(f"Usunięto wolumeny o znanym rozmiarze łącznie {_format_bytes(freed)}.")
40 changes: 38 additions & 2 deletions fixos/cli/cleanup_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

import click

from fixos.cli._cleanup_docker_volumes import _cleanup_docker_volumes

# Re-export public symbols used by fixos.cli (backward-compat)
from fixos.cli._cleanup_flatpak import _cleanup_flatpak_detailed
from fixos.cli._cleanup_space import (
Expand Down Expand Up @@ -1611,7 +1613,7 @@ def _execute_yes_cleanup(plan: dict, scanner, dry_run: bool = False) -> None:
"Wyczyść konkretną usługę "
"(docker, docker-all, docker-old, docker-containers, docker-networks, "
"docker-stale-services, orphaned-projects, ollama-old, snap-old, "
"journal, user-cache, jetbrains, libvirt, gitive, docker-buildcache, tmp, venvs-old, "
"journal, user-cache, jetbrains, libvirt, gitive, docker-buildcache, docker-volumes, tmp, venvs-old, "
"npm, ...)"
),
)
Expand Down Expand Up @@ -1719,6 +1721,23 @@ def _execute_yes_cleanup(plan: dict, scanner, dry_run: bool = False) -> None:
f"(domyślnie {DEFAULT_OLLAMA_OLD_UNUSED_DAYS}); pomija modele aktualnie uruchomione"
),
)
@click.option(
"--docker-volumes",
"docker_volumes",
is_flag=True,
default=False,
help=(
"Wybierz i usuń nieużywane wolumeny Dockera (nazwa, projekt Compose, wiek, "
"rozmiar przez du/sudo -n); filtry --days i --match; --yes pomija "
"wolumeny wyglądające na dane"
),
)
@click.option(
"--match",
"match",
default=None,
help="Dla --docker-volumes: fragmenty nazw rozdzielone przecinkami, np. ollama,huggingface",
)
@click.option(
"--snap-old",
"snap_old",
Expand Down Expand Up @@ -1893,6 +1912,8 @@ def cleanup_services(
list_orphan_pins,
process_hours,
ollama_old,
docker_volumes,
match,
snap_old,
journal_cleanup,
user_cache,
Expand Down Expand Up @@ -1951,6 +1972,8 @@ def cleanup_services(
fixos cleanup --unpin-orphan-project /absolute/missing/project
# usuwa ochronę, ale nie zmienia Dockera ani procesów
fixos cleanup --ollama-old --days 90 --dry-run
fixos cleanup --docker-volumes --list # nieużywane wolumeny: nazwa, projekt, wiek, rozmiar
fixos cleanup --docker-volumes --match ollama,huggingface --days 90
# modele Ollama niezmieniane od 90+ dni
fixos cleanup -c ollama-old
fixos cleanup --snap-old # wyłączone rewizje snapów
Expand Down Expand Up @@ -2004,6 +2027,7 @@ def cleanup_services(
list_orphan_pins,
ollama_old,
snap_old,
docker_volumes,
journal_cleanup,
user_cache,
jetbrains,
Expand Down Expand Up @@ -2053,6 +2077,7 @@ def cleanup_services(
ollama_old,
docker_buildcache,
snap_old,
docker_volumes,
journal_cleanup,
user_cache,
jetbrains,
Expand Down Expand Up @@ -2106,6 +2131,17 @@ def cleanup_services(
"libvirt": (libvirt, _cleanup_libvirt),
"gitive": (gitive, _cleanup_gitive),
"docker-buildcache": (docker_buildcache, _cleanup_docker_buildcache),
"docker-volumes": (
docker_volumes,
lambda j, d, lst, y: _cleanup_docker_volumes(
j,
d,
lst,
y,
days=effective_days,
match=[t.strip() for t in (match or "").split(",") if t.strip()],
),
),
"tmp": (
tmp_cleanup,
lambda j, d, lst, y: _cleanup_tmp(
Expand Down Expand Up @@ -2168,7 +2204,7 @@ def cleanup_services(
click.echo(
click.style(
"Akcje odzyskiwania miejsca (snap/journal/cache/JetBrains/"
"libvirt/gitive/docker-buildcache/tmp/trash/thumbnails) uruchom jako osobną, pojedynczą akcję.",
"libvirt/gitive/docker-buildcache/docker-volumes/tmp/trash/thumbnails) uruchom jako osobną, pojedynczą akcję.",
fg="red",
)
)
Expand Down
12 changes: 8 additions & 4 deletions fixos/diagnostics/_storage_container_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
from __future__ import annotations

import os
from typing import ClassVar

from fixos.diagnostics.storage_analyzer import StorageItem
from fixos.constants import MIN_DOCKER_DANGLING_MB, MIN_PODMAN_MB
from fixos.diagnostics.storage_analyzer import StorageItem


class _ContainerAnalyzerMixin:
"""Mixin providing container _analyze_* methods for StorageAnalyzer."""

_DOCKER_DF_SECTIONS = {
_DOCKER_DF_SECTIONS: ClassVar[dict[str, str]] = {
"Images space usage:": "images",
"Containers space usage:": "containers",
"Local Volumes space usage:": "volumes",
Expand Down Expand Up @@ -119,11 +120,14 @@ def _add_docker_total_item(self, total_images: int, dangling_images: int) -> Non
size_bytes=total_docker,
category="containers",
risk="medium",
cleanup_command="docker system prune -a --volumes",
# Never a bulk volume prune: volumes are data and are
# chosen one by one with `fixos cleanup --docker-volumes`.
cleanup_command="docker builder prune --force --filter until=168h",
description=(
f"Docker łącznie: {StorageItem._format_size(total_docker)}. "
f"Obrazy: {total_images}, dangling: {dangling_images}. "
"Uwaga: usunie wszystkie nieużywane zasoby."
"Czyści cache buildów starszy niż 7 dni; wolumeny wybierz "
"ręcznie przez `fixos cleanup --docker-volumes`."
),
)
)
Expand Down
42 changes: 42 additions & 0 deletions project/ticket-081/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Ticket 081: fixos cleanup --docker-volumes: select and remove unused Docker volumes safely

- **ID**: ticket-081
- **Owner**: claude
- **Status**: IN_PROGRESS
- **Workflow state**: EDIT
- **Created**: 2026-10-13

## Goal and scope

On 2026-10-13 the host had 397 unused Docker volumes (89 GB). fixos only
counted them; removing the 18 model volumes (55.5 GB) needed manual
`docker volume rm`. The only volume path in fixos was `cleanup --full`
"Docker Total" → `docker system prune -a --volumes`, which deletes every
unused volume at once, including data such as git mirrors.

`fixos cleanup --docker-volumes` (also `-c docker-volumes`):

- lists unused volumes with name, Compose project, age and size (`du`, then
non-interactive `sudo -n du`; otherwise "rozmiar nieznany"), largest first;
- `--match ollama,huggingface` and `--days N` filters; `--list`, `--dry-run`,
`--json`;
- removes only selected volumes, re-checking each is still dangling and not
referenced by any container right before `docker volume rm`;
- `--yes` never selects names that look like data (mirror, git, db, postgres,
backup, Open WebUI, n8n, storage, ...); those need an explicit choice. A live
dry-run showed `--yes` would otherwise have removed three Open WebUI volumes.

`--full` "Docker Total" now runs the bounded build-cache prune and points to
`--docker-volumes` for volumes.

SESSION_EXECUTION_AUTHORIZATION: user answered "tak" to adding this command to fixos.

## Acceptance criteria

- [x] AC-01: tests cover listing/sorting, filters, `--yes` protection, removal-time re-check, dry-run/list and the `--full` command.
- [x] AC-02: full suite, ruff on changed files and `./project/governance-check.sh` pass.

## Tracking boundary

This directory contains the minimal reviewed intent. Optional participant prose
and raw command logs are not required delivery output.
Loading
Loading