fix(ops): protect sablier services from unsafe prune

This commit is contained in:
Eduardo David Paredes Vara
2026-07-25 13:00:52 +00:00
parent 05444afc54
commit 95a5f11870
10 changed files with 213 additions and 1 deletions
+1
View File
@@ -11,6 +11,7 @@ cheerfy_pentest_report.md
# Backups y temporales
*.bak
*.tmp
__pycache__/
# Archivos de auth del CLI de Codex/OpenAI
.codex
+2
View File
@@ -39,5 +39,7 @@ services:
networks:
changedetection_internal:
driver: bridge
labels:
prune.protect: "true"
proxy:
external: true
+2
View File
@@ -25,5 +25,7 @@ services:
networks:
finance_internal:
driver: bridge
labels:
prune.protect: "true"
proxy:
external: true
+2
View File
@@ -66,5 +66,7 @@ services:
networks:
guacamole:
driver: bridge
labels:
prune.protect: "true"
proxy:
external: true
+8
View File
@@ -0,0 +1,8 @@
ARG IT_TOOLS_BASE_IMAGE=ghcr.io/corentinth/it-tools:latest
FROM ${IT_TOOLS_BASE_IMAGE}
RUN set -eu; \
asset="$(find /usr/share/nginx/html/assets -name 'ascii-text-drawer-*.js' -print -quit)"; \
test -n "$asset"; \
sed -i 's#fontPath:"//unpkg.com/figlet@1.6.0/fonts/"#fontPath:"https://unpkg.com/figlet@1.6.0/fonts"#g' "$asset"; \
grep -q 'fontPath:"https://unpkg.com/figlet@1.6.0/fonts"' "$asset"
+5
View File
@@ -11,6 +11,11 @@ Coleccion de herramientas tecnicas self-hosted.
```bash
docker compose --env-file .env config
docker compose --env-file .env build
docker compose --env-file .env up -d
docker compose --env-file .env logs -f
```
El `Dockerfile` aplica un parche local para el generador de ASCII art: corrige
la URL de fuentes FIGlet que en la imagen upstream termina cargando
`fonts//Standard.flf` y falla en navegador por CORS.
+6
View File
@@ -1,5 +1,9 @@
services:
it-tools:
build:
context: .
args:
IT_TOOLS_BASE_IMAGE: ${IT_TOOLS_BASE_IMAGE:-ghcr.io/corentinth/it-tools:latest}
image: ${IT_TOOLS_IMAGE}
container_name: it-tools
restart: unless-stopped
@@ -21,5 +25,7 @@ services:
networks:
it_internal:
driver: bridge
labels:
prune.protect: "true"
proxy:
external: true
+2
View File
@@ -30,5 +30,7 @@ services:
networks:
pdf_internal:
driver: bridge
labels:
prune.protect: "true"
proxy:
external: true
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Careful Docker pruning for this compose/Sablier host.
The important guardrail is that networks are not pruned by default. Sablier
keeps some containers intentionally stopped, and deleting their compose-created
networks can leave those stopped containers unable to start later.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from dataclasses import dataclass
PROTECT_LABEL = "prune.protect"
PROTECT_VALUE = "true"
NETWORK_NEVER_REMOVE = {"bridge", "host", "none", "proxy", "mail_internal"}
@dataclass
class DockerObject:
id: str
name: str
labels: dict[str, str]
state: str = ""
@property
def protected(self) -> bool:
return self.labels.get(PROTECT_LABEL, "").lower() == PROTECT_VALUE
def docker(args: list[str], *, check: bool = True) -> str:
result = subprocess.run(
["docker", *args],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if check and result.returncode != 0:
sys.stderr.write(result.stderr)
raise SystemExit(result.returncode)
return result.stdout
def parse_labels(raw: str | None) -> dict[str, str]:
labels: dict[str, str] = {}
if not raw:
return labels
for item in raw.split(","):
if not item:
continue
key, sep, value = item.partition("=")
labels[key] = value if sep else ""
return labels
def list_containers() -> list[DockerObject]:
rows = docker(["container", "ls", "-a", "--format", "{{json .}}"]).splitlines()
containers: list[DockerObject] = []
for row in rows:
data = json.loads(row)
containers.append(
DockerObject(
id=data["ID"],
name=data["Names"],
labels=parse_labels(data.get("Labels")),
state=data.get("State", ""),
)
)
return containers
def list_networks() -> list[DockerObject]:
rows = docker(["network", "ls", "--format", "{{json .}}"]).splitlines()
networks: list[DockerObject] = []
for row in rows:
data = json.loads(row)
networks.append(
DockerObject(
id=data["ID"],
name=data["Name"],
labels=parse_labels(data.get("Labels")),
)
)
return networks
def network_has_containers(network_id: str) -> bool:
raw = docker(["network", "inspect", network_id, "--format", "{{json .Containers}}"])
containers = json.loads(raw)
return bool(containers)
def remove_containers(dry_run: bool) -> int:
removable_states = {"created", "exited", "dead"}
candidates = [
c
for c in list_containers()
if c.state.lower() in removable_states and not c.protected
]
if not candidates:
print("containers: none")
return 0
print("containers:")
for container in candidates:
print(f" remove {container.name} ({container.state})")
if not dry_run:
docker(["container", "rm", container.id])
return len(candidates)
def prune_images(dry_run: bool) -> None:
cmd = ["image", "prune", "-af", "--filter", f"label!={PROTECT_LABEL}={PROTECT_VALUE}"]
print("images:")
if dry_run:
print(" would run: docker " + " ".join(cmd))
return
print(docker(cmd).rstrip() or " none")
def prune_build_cache(dry_run: bool) -> None:
cmd = ["builder", "prune", "-af"]
print("builder-cache:")
if dry_run:
print(" would run: docker " + " ".join(cmd))
return
print(docker(cmd).rstrip() or " none")
def prune_networks(dry_run: bool) -> int:
candidates: list[DockerObject] = []
for network in list_networks():
if network.name in NETWORK_NEVER_REMOVE or network.protected:
continue
if network_has_containers(network.id):
continue
candidates.append(network)
if not candidates:
print("networks: none")
return 0
print("networks:")
for network in candidates:
print(f" remove {network.name}")
if not dry_run:
docker(["network", "rm", network.id])
return len(candidates)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="show what would be removed")
parser.add_argument(
"--prune-networks",
action="store_true",
help="also remove unused, unprotected networks",
)
args = parser.parse_args()
print(f"mode: {'dry-run' if args.dry_run else 'execute'}")
removed_containers = remove_containers(args.dry_run)
prune_images(args.dry_run)
prune_build_cache(args.dry_run)
if args.prune_networks:
prune_networks(args.dry_run)
else:
print("networks: skipped")
print(f"summary: removable_containers={removed_containers}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7 -1
View File
@@ -1,4 +1,10 @@
#!/usr/bin/env sh
set -eu
docker system prune -af --filter "label!=prune.protect=true"
SCRIPT_PATH="$0"
if command -v readlink >/dev/null 2>&1; then
SCRIPT_PATH="$(readlink -f "$0")"
fi
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
exec python3 "$SCRIPT_DIR/prune-safe.py" "$@"