#!/bin/sh
#
# zfs-snapshot-clean - sieves ZFS snapshots as per given spec
#
# This command is inspired by Plan9's dumpfs, and pdumpfs/pdumpfs-clean.
#
# Copyright (c) 2009, 2010, 2011, 2014 Akinori MUSHA
#
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.

case "`uname -s`" in
    SunOS)
        type local >/dev/null 2>&1 || exec /usr/xpg4/bin/sh "$0" "$@"
        awk () {
            /usr/xpg4/bin/awk "$@"
        }
        for cmd in /usr/gnu/bin/date /usr/sfw/bin/gdate /usr/local/bin/gdate; do
            if [ -x "$cmd" ]; then
                DATE_CMD="$cmd"
                break
            fi
        done
        : ${DATE_CMD:=gdate}
        date () {
            "$DATE_CMD" "$@"
        }
        ;;
esac

type zfs >/dev/null 2>&1 || PATH="$PATH:/sbin"

VERSION=0.2.0
MYNAME="${0##*/}"
DRYRUN=
VERBOSE=
DEFAULT_DAYSPEC=7D6W6M2Y
DAYSPEC="$DEFAULT_DAYSPEC"
PREFIX=
RECURSIVE=
NDAYS=0
NWEEKS=0
NMONTHS=0
NYEARS=0

version () {
    cat <<EOF
zfs-snapshot-clean version $VERSION - sieves ZFS snapshots as per given spec
EOF
}

usage () {
    version
    cat <<EOF
Usage: $MYNAME [OPTION ...] {VOLUME|MOUNTPOINT} ...
  -k DAYSPEC      Specify how snapshots are kept (Default: "$DEFAULT_DAYSPEC")
  -p PREFIX       Specify the prefix before the date part (Default: none)
  -r              Delete recursively
  -n              Do not actually delete snapshots; Implies -v
  -v              Be verbose
  -V              Show the version number
  -h              Show this help

This command is used to automatically destroy ZFS snapshots that are
not covered by the DAYSPEC, assuming daily snapshots are named in
the format "YYYY-MM-DD".

By default, ZFS snapshots of the most recent 7 days, 6 Sundays, 6
months' first days and 2 years' first days are kept.  Non-daily
snapshots, which do not match "YYYY-MM-DD", are ignored and kept.

Use the -p option if you have a name prefix for the daily snapshots.

EOF
    exit 64
}

main () {
    local option

    while getopts hVnvrk:p: option; do
        case "$option" in
            h)
                usage
                ;;
            V)
                version
                exit 0
                ;;
            n)
                DRYRUN=t
                VERBOSE=t
                ;;
            v)
                VERBOSE=t
                ;;
            p)
                PREFIX="$OPTARG"
                ;;
            k)
                DAYSPEC="$OPTARG"
                ;;
            r)
                RECURSIVE=-r
                ;;
            *)
                usage
                ;;
        esac
    done

    shift "$((OPTIND - 1))"

    if [ $# -eq 0 ]; then
        echo "$MYNAME: no ZFS volume/mountpoint is given"
        usage
    fi

    parse_dayspec "$DAYSPEC"

    local volume ret=0

    for volume; do
        zfs_snapshot_clean "$volume" || ret="$?"
    done

    return "$ret"
}

parse_dayspec () {
    local spec="$1"; shift
    local str="$spec" number

    while [ -n "$str" ]; do
        number="$(expr "$str" : "^\([0-9]*\).*")"
        if [ -z "$str" ]; then
            echo "$MYNAME: specification format error: $spec" >&2
            usage
        fi

        str="${str#"$number"}"

        case "$str" in
            D*)
                NDAYS="$number"
                str="${str#D}"
                ;;
            W*)
                NWEEKS="$number"
                str="${str#W}"
                ;;
            M*)
                NMONTHS="$number"
                str="${str#M}"
                ;;
            Y*)
                NYEARS="$number"
                str="${str#Y}"
                ;;
            *)
                echo "$MYNAME: specification format error: $spec" >&2
                usage
                ;;
        esac
    done

    info "$MYNAME only keeps snapshots of the most recent:"
    [ $NDAYS   -gt 0 ] && info "- $NDAYS days"
    [ $NWEEKS  -gt 0 ] && info "- $NWEEKS Sundays"
    [ $NMONTHS -gt 0 ] && info "- $NMONTHS months' first days"
    [ $NYEARS  -gt 0 ] && info "- $NYEARS years' first days"
}

info () {
    if [ -n "$VERBOSE" ]; then
        echo "$@"
    fi
}

run () {
    info "Running: $@"
    if [ -z "$DRYRUN" ]; then
        "$@"
    fi
}

get_snapshots () {
     zfs list -d 1 -t snapshot -H -o name $1 | sed -e 's/.*@//'
}

zfs_snapshot_clean () {
    local volumename="$1"; shift
    local name date number

    if ! zfs list -o name $volumename >/dev/null 2>&1
    then
	echo "$volumename: not a ZFS volume" >&2
	return 1
    fi

    info "cleaning $volumename"

    {
        # bit 1
        { days; weeks; months; years; } | sort -u
        # bit 2
        get_snapshots $volumename | while read name; do
            case "$name" in
                "$PREFIX"[1-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]) ;;
                *) continue ;;
            esac
            date="${name#"$PREFIX"}"
            echo "$date"
            echo "$date"
        done
    } | sort | uniq -c | while read number date; do
        if [ "$number" -eq 2 ]; then
            run zfs destroy $RECURSIVE "$volumename@$PREFIX$date"
        fi
    done
}

case `date --version 2>/dev/null` in
    *'Free Software Foundation'*)
        date_days_ago () {
            date -d "$1 days ago" +%Y-%m-%d
        }

        date_months_ago () {
            date -d "$(date +%Y-%m-15) -$1 months" +%Y-%m-01
        }

        date_years_ago () {
            date -d "$1 years ago" +%Y-01-01
        }
        ;;
    *)
        date_days_ago () {
            date -v"-${1}d" +%Y-%m-%d
        }

        date_months_ago () {
            date -v"-${1}m" +%Y-%m-01
        }

        date_years_ago () {
            date -v"-${1}y" +%Y-01-01
        }
        ;;
esac

days () {
    local i=0; while [ "$i" -lt "$NDAYS" ]; do
        date_days_ago "$i"
        i="$((i + 1))"
    done
}

weeks () {
    local b="$(date +%u)"    
    local i=0; while [ "$i" -lt "$NWEEKS" ]; do
        date_days_ago "$((b + 7 * i))"
        i="$((i + 1))"
    done
}

months () {
    local b="$(date +%u)"    
    local i=0; while [ "$i" -lt "$NMONTHS" ]; do
        date_months_ago "$i"
        i="$((i + 1))"
    done
}

years () {
    local b="$(date +%u)"    
    local i=0; while [ "$i" -lt "$NYEARS" ]; do
        date_years_ago "$i"
        i="$((i + 1))"
    done
}

main "$@"
