fix(ops): protect sablier services from unsafe prune
This commit is contained in:
@@ -11,6 +11,7 @@ cheerfy_pentest_report.md
|
||||
# Backups y temporales
|
||||
*.bak
|
||||
*.tmp
|
||||
__pycache__/
|
||||
|
||||
# Archivos de auth del CLI de Codex/OpenAI
|
||||
.codex
|
||||
|
||||
@@ -39,5 +39,7 @@ services:
|
||||
networks:
|
||||
changedetection_internal:
|
||||
driver: bridge
|
||||
labels:
|
||||
prune.protect: "true"
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -25,5 +25,7 @@ services:
|
||||
networks:
|
||||
finance_internal:
|
||||
driver: bridge
|
||||
labels:
|
||||
prune.protect: "true"
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -66,5 +66,7 @@ services:
|
||||
networks:
|
||||
guacamole:
|
||||
driver: bridge
|
||||
labels:
|
||||
prune.protect: "true"
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,5 +30,7 @@ services:
|
||||
networks:
|
||||
pdf_internal:
|
||||
driver: bridge
|
||||
labels:
|
||||
prune.protect: "true"
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
Executable
+178
@@ -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())
|
||||
@@ -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" "$@"
|
||||
|
||||
Reference in New Issue
Block a user