Rotación de copias de seguridad abuelo-padre-hijo
Decide qué copias de seguridad conservar y cuáles borrar con la política abuelo-padre-hijo: las 7 diarias más recientes, los domingos de las últimas 4 semanas y el día 1 de los últimos 6 meses. Fechas con date, expresiones regulares con BASH_REMATCH, arrays y ordenación.
- date -d y formatos
- [[ =~ ]] y BASH_REMATCH
- mapfile y sort
- Arrays asociativos
- Aritmética con fechas
- Números con ceros: 10#
Enunciado
Un servidor hace cada noche una copia completa en un fichero backup-AAAA-MM-DD.tar.gz. Si no se borra ninguna, el disco se llena; si se guardan solo las últimas, no hay forma de volver a cómo estaba todo hace dos meses. La solución clásica es la rotación abuelo-padre-hijo (GFS): copias diarias (hijos) para la última semana, semanales (padres) para el último mes y mensuales (abuelos) para el último medio año.
En este servidor la política es: conservar las 7 copias más recientes; además, las hechas en domingo de los últimos 28 días; y además, las hechas el día 1 de mes de los últimos 6 meses (contando el actual). Una copia puede cumplir varias reglas a la vez.
El script recibe la fecha de hoy y el listado de ficheros (lo que daría ls) y escribe qué se conserva y por qué, y qué se borra. Así se puede revisar antes de borrar nada.
Qué tiene que hacer el programa
- La primera línea es
HOY AAAA-MM-DDcon una fecha que exista (si no,La primera línea debe ser HOY AAAA-MM-DDy termina). Cada línea siguiente es el nombre de un fichero; las vacías se ignoran. - Un nombre que no sea exactamente
backup-AAAA-MM-DD.tar.gzcon una fecha que exista escribeIgnorado (nombre no válido): nombre; una copia con fecha posterior a hoy,Ignorado (fecha futura): nombre. Los avisos salen en el orden de la entrada. Si no queda ninguna copia válida,No hay copias. - Las copias válidas se tratan de la más reciente a la más antigua. Motivos para conservar una copia:
diariasi está entre las 7 más recientes;semanalsi es de un domingo y han pasado menos de 28 días desde ella hasta hoy;mensualsi es del día 1 y es del mes de hoy o de uno de los 5 anteriores. - Para cada copia escribe
CONSERVAR nombre (motivos)con los motivos separados por coma y espacio en el orden diaria, semanal, mensual, oBORRAR nombre(BORRAR seguido de cuatro espacios, para que los nombres queden alineados). - Al final,
Se conservan C y se borran B.
Entrada
Línea 1: HOY AAAA-MM-DD. Resto: un nombre de fichero por línea.
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Seis meses de copias
Entrada
HOY 2026-10-04 backup-2026-10-03.tar.gz backup-2026-10-02.tar.gz backup-2026-10-01.tar.gz backup-2026-09-30.tar.gz backup-2026-09-29.tar.gz backup-2026-09-28.tar.gz backup-2026-09-27.tar.gz backup-2026-09-26.tar.gz backup-2026-09-25.tar.gz backup-2026-09-20.tar.gz backup-2026-09-13.tar.gz backup-2026-09-06.tar.gz backup-2026-09-01.tar.gz backup-2026-08-30.tar.gz backup-2026-08-01.tar.gz backup-2026-07-01.tar.gz backup-2026-06-01.tar.gz backup-2026-05-01.tar.gz backup-2026-04-01.tar.gz
Salida por consola
CONSERVAR backup-2026-10-03.tar.gz (diaria) CONSERVAR backup-2026-10-02.tar.gz (diaria) CONSERVAR backup-2026-10-01.tar.gz (diaria, mensual) CONSERVAR backup-2026-09-30.tar.gz (diaria) CONSERVAR backup-2026-09-29.tar.gz (diaria) CONSERVAR backup-2026-09-28.tar.gz (diaria) CONSERVAR backup-2026-09-27.tar.gz (diaria, semanal) BORRAR backup-2026-09-26.tar.gz BORRAR backup-2026-09-25.tar.gz CONSERVAR backup-2026-09-20.tar.gz (semanal) CONSERVAR backup-2026-09-13.tar.gz (semanal) BORRAR backup-2026-09-06.tar.gz CONSERVAR backup-2026-09-01.tar.gz (mensual) BORRAR backup-2026-08-30.tar.gz CONSERVAR backup-2026-08-01.tar.gz (mensual) CONSERVAR backup-2026-07-01.tar.gz (mensual) CONSERVAR backup-2026-06-01.tar.gz (mensual) CONSERVAR backup-2026-05-01.tar.gz (mensual) BORRAR backup-2026-04-01.tar.gz Se conservan 14 y se borran 5
Nombres no válidos y fechas futuras
Entrada
HOY 2026-03-01 backup-2026-02-29.tar.gz backup-2026-02-28.tar.gz copia-2026-02-27.tar.gz backup-2026-03-02.tar.gz backup-2026-03-01.tar.gz backup-2025-11-01.tar.gz backup-2025-09-01.tar.gz
Salida por consola
Ignorado (nombre no válido): backup-2026-02-29.tar.gz Ignorado (nombre no válido): copia-2026-02-27.tar.gz Ignorado (fecha futura): backup-2026-03-02.tar.gz CONSERVAR backup-2026-03-01.tar.gz (diaria, semanal, mensual) CONSERVAR backup-2026-02-28.tar.gz (diaria) CONSERVAR backup-2025-11-01.tar.gz (diaria, mensual) CONSERVAR backup-2025-09-01.tar.gz (diaria) Se conservan 4 y se borran 0
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Validar con una expresión regular
[[ $nombre =~ ^backup-([0-9]{4}-[0-9]{2}-[0-9]{2})\.tar\.gz$ ]] comprueba el formato y guarda la fecha en ${BASH_REMATCH[1]}. Para saber si la fecha existe, pídele a date que la escriba: si no devuelve lo mismo (o falla), no existía.
2. Días entre dos fechas
date -u -d "$f" +%s da los segundos desde 1970 a medianoche UTC de esa fecha; dividido entre 86400, el número de día. Restando los de hoy y los de la copia tienes los días que han pasado, sin preocuparte de meses ni años bisiestos.
dia_hoy=$(( $(date -u -d "$hoy" +%s) / 86400 ))
dias=$(( dia_hoy - $(date -u -d "$f" +%s) / 86400 ))3. Cuidado con los ceros a la izquierda
En la aritmética de Bash, 08 es un número octal no válido. 10#08 obliga a leerlo en base 10. Úsalo con los meses y años que saques de date +%m.
4. Ordenar las fechas
El formato AAAA-MM-DD se ordena bien como texto. mapfile -t ordenadas < <(printf "%s\n" "${fechas[@]}" | sort -ru) deja en un array las fechas de la más reciente a la más antigua, sin repetidas.
5. Acumular motivos
Empieza con motivos="" y ve añadiendo con motivos+="${motivos:+, }semanal": la coma solo aparece si ya había un motivo. Si al final está vacío, la copia se borra.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 3 casos ocultos que buscan los errores típicos.
Ejemplo
HOY 2026-10-04 backup-2026-10-03.tar.gz backup-2026-10-02.tar.gz backup-2026-10-01.tar.gz backup-2026-09-30.tar.gz backup-2026-09-29.tar.gz backup-2026-09-28.tar.gz backup-2026-09-27.tar.gz backup-2026-09-26.tar.gz backup-2026-09-25.tar.gz backup-2026-09-20.tar.gz backup-2026-09-13.tar.gz backup-2026-09-06.tar.gz backup-2026-09-01.tar.gz backup-2026-08-30.tar.gz backup-2026-08-01.tar.gz backup-2026-07-01.tar.gz backup-2026-06-01.tar.gz backup-2026-05-01.tar.gz backup-2026-04-01.tar.gz
CONSERVAR backup-2026-10-03.tar.gz (diaria) CONSERVAR backup-2026-10-02.tar.gz (diaria) CONSERVAR backup-2026-10-01.tar.gz (diaria, mensual) CONSERVAR backup-2026-09-30.tar.gz (diaria) CONSERVAR backup-2026-09-29.tar.gz (diaria) CONSERVAR backup-2026-09-28.tar.gz (diaria) CONSERVAR backup-2026-09-27.tar.gz (diaria, semanal) BORRAR backup-2026-09-26.tar.gz BORRAR backup-2026-09-25.tar.gz CONSERVAR backup-2026-09-20.tar.gz (semanal) CONSERVAR backup-2026-09-13.tar.gz (semanal) BORRAR backup-2026-09-06.tar.gz CONSERVAR backup-2026-09-01.tar.gz (mensual) BORRAR backup-2026-08-30.tar.gz CONSERVAR backup-2026-08-01.tar.gz (mensual) CONSERVAR backup-2026-07-01.tar.gz (mensual) CONSERVAR backup-2026-06-01.tar.gz (mensual) CONSERVAR backup-2026-05-01.tar.gz (mensual) BORRAR backup-2026-04-01.tar.gz Se conservan 14 y se borran 5
Solución explicada
Ver la solución completa
1#!/bin/bash
2# Política abuelo-padre-hijo: 7 diarias, domingos de las últimas 4 semanas y el día 1 de los últimos 6 meses
3export LC_ALL=C
4
5read -r palabra hoy
6if [ "$palabra" != "HOY" ] || ! date -d "$hoy" +%F >/dev/null 2>&1 || [ "$(date -d "$hoy" +%F)" != "$hoy" ]; then
7 echo "La primera línea debe ser HOY AAAA-MM-DD"
8 exit 0
9fi
10dia_hoy=$(( $(date -u -d "$hoy" +%s) / 86400 ))
11mes_hoy=$(( 10#$(date -d "$hoy" +%Y) * 12 + 10#$(date -d "$hoy" +%m) ))
12
13declare -A fichero_de
14fechas=()
15while read -r nombre; do
16 [ -z "$nombre" ] && continue
17 if [[ ! $nombre =~ ^backup-([0-9]{4}-[0-9]{2}-[0-9]{2})\.tar\.gz$ ]] || [ "$(date -d "${BASH_REMATCH[1]}" +%F 2>/dev/null)" != "${BASH_REMATCH[1]}" ]; then
18 echo "Ignorado (nombre no válido): $nombre"
19 continue
20 fi
21 fecha=${BASH_REMATCH[1]}
22 if [[ $fecha > $hoy ]]; then
23 echo "Ignorado (fecha futura): $nombre"
24 continue
25 fi
26 fichero_de[$fecha]=$nombre
27 fechas+=("$fecha")
28done
29
30if [ ${#fechas[@]} -eq 0 ]; then
31 echo "No hay copias"
32 exit 0
33fi
34
35# Las fechas en formato AAAA-MM-DD se ordenan bien como texto: de la más reciente a la más antigua
36mapfile -t ordenadas < <(printf "%s\n" "${fechas[@]}" | sort -ru)
37conservar=0
38borrar=0
39liberado=()
40for i in "${!ordenadas[@]}"; do
41 f=${ordenadas[$i]}
42 motivos=""
43 [ "$i" -lt 7 ] && motivos="diaria"
44 dias=$(( dia_hoy - $(date -u -d "$f" +%s) / 86400 ))
45 if [ "$(date -d "$f" +%u)" -eq 7 ] && [ "$dias" -lt 28 ]; then
46 motivos+="${motivos:+, }semanal"
47 fi
48 meses=$(( mes_hoy - (10#$(date -d "$f" +%Y) * 12 + 10#$(date -d "$f" +%m)) ))
49 if [ "$(date -d "$f" +%d)" = "01" ] && [ "$meses" -lt 6 ]; then
50 motivos+="${motivos:+, }mensual"
51 fi
52 if [ -n "$motivos" ]; then
53 echo "CONSERVAR ${fichero_de[$f]} ($motivos)"
54 conservar=$((conservar + 1))
55 else
56 echo "BORRAR ${fichero_de[$f]}"
57 borrar=$((borrar + 1))
58 fi
59done
60echo "Se conservan $conservar y se borran $borrar"La política se reduce a tres preguntas independientes sobre cada copia (¿está entre las 7 últimas?, ¿es un domingo reciente?, ¿es un día 1 reciente?). Escribirlas por separado y acumular los motivos hace que el informe explique cada decisión, que es lo que permite fiarse de un script que borra.
Toda la aritmética de calendario la resuelve date: el día de la semana con +%u, el número de día con +%s, y la validación de fechas pidiéndole que reescriba la fecha. Contar meses como año × 12 + mes evita casos especiales al cambiar de año.
[[ =~ ]] con grupos extrae la fecha del nombre sin cut ni sed, y mapfile con sustitución de procesos convierte la salida de sort en un array en una sola línea.
El script solo decide: no borra. En la versión real, la salida se filtraría con grep ^BORRAR y se pasaría a rm, o mejor, se movería a una carpeta de cuarentena unos días antes de borrar definitivamente.
Para ir más allá
- Lee el listado real con
ls /backupsy añade la opción--borrarque elimine de verdad las copias marcadas. - Añade copias anuales: el 1 de enero de los últimos 3 años.
- Calcula cuánto espacio se libera sumando el tamaño de los ficheros que se borran (con
stat -c %s).