Apuntes DAM
Volver al inicio

Rotación de copias de seguridad abuelo-padre-hijo

Ejercicio de BashDifícilUnos 70 minutos

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

  1. La primera línea es HOY AAAA-MM-DD con una fecha que exista (si no, La primera línea debe ser HOY AAAA-MM-DD y termina). Cada línea siguiente es el nombre de un fichero; las vacías se ignoran.
  2. Un nombre que no sea exactamente backup-AAAA-MM-DD.tar.gz con una fecha que exista escribe Ignorado (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.
  3. Las copias válidas se tratan de la más reciente a la más antigua. Motivos para conservar una copia: diaria si está entre las 7 más recientes; semanal si es de un domingo y han pasado menos de 28 días desde ella hasta hoy; mensual si es del día 1 y es del mes de hoy o de uno de los 5 anteriores.
  4. Para cada copia escribe CONSERVAR nombre (motivos) con los motivos separados por coma y espacio en el orden diaria, semanal, mensual, o BORRAR nombre (BORRAR seguido de cuatro espacios, para que los nombres queden alineados).
  5. 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.

bash
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.

🖥️BashRotación de copias de seguridad abuelo-padre-hijoDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
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
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
bash
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 /backups y añade la opción --borrar que 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).

Dónde se explica