The existing answers have some problems.
There's one answer written in Python, but you shouldn't write shell scripts in Python (or, if you can help it, anything at all). The python script does shell-like things more awkwardly (and using much more code). There is a natural language to use for these operations, and that is shell. For some things you should avoid shell code, for some things you should not.
The highest-voted answer uses shell, but it does some things that are between stylistic problems and buggy behavior. Here are a few:
- Upper case variable names should not be used so as to avoid colliding with any current or potential future POSIX-standardized variable names. Lower case variable names are explicitly reserved for use by applications such as this.
echo
should not be used because it is not portable; printf
is the canonical, portable alternative.
- The output of
ls
is for human-eyeball-consumption only. The text it emits is not guaranteed to be equal to the real name of any file. In addition, it is not safe if there may be a crafted filename designed to do harm (harm here could be "delete any file on the system"). If you have GNU ls
you can at least use --quoting-style=shell-escape
to mitigate this somewhat. It's still better to simply avoid ls
- Paths with whitespace in them are not handled correctly and won't work.
The other answer written in shell is much better, but still has some problems.
- Uses
/bin/sh
but still not portable due to the use of non-portable shell commands and switches.
- Still breaks on paths with whitespace in them.
- Fails to quote all expansions, which is also vulnerable to crafted filename attacks.
These issues are more minor and could be fixed pretty easily, but I still don't like the approach.
Here is my alternative written in bash
which should be safe and reliable.
#!/bin/bash
dir=${1:-/home/ben/ftp/surveillance/}
threshhold=90
! [[ -d $dir ]] && {
printf '%s: not a directory\n' "$dir" 1>&2
exit 1
}
use_percent=$(df --output=pcent "$dir" | tail -n 1)
if (( ${use_percent%'%'} < threshhold )); then
exit
fi
recent-files () {
[[ -z $1 ]] || [[ $1 == *[!0-9-]* ]] && return 1
local number="$1" i=0 rev=(-r)
shift
if (( number < 0 )); then
((number*=-1))
rev=()
fi
find "${@:-.}" -maxdepth 1 -type f -printf '%T@/%p\0' | \
sort -zn "${rev[@]}" | cut -z -d/ -f2- | \
while IFS= read -rd '' file; do
printf -- '%s\0' "$file"
if (( ++i == number )); then
exit
fi
done
}
oldest=$(recent-files -1 "$dir")
size="$(stat -c %s "$oldest" | numfmt --to=iec-i)"
{
printf 'Running out of space for %s\n' "$dir"
printf 'Removing "%s"; %s freed.\n' "$oldest" "$size"
rm -f -- "$oldest"
} | logger -s -t surveillance-monitor -p local0.warning
I make use of bash-specific features and assume GNU coreutils. A fully sh
compatible version that uses only portable switches is theoretically possible, but it's much more work. Other solutions were in effect already tied to non-portable df
switches, among other things, so little of value is lost. I am explicitly assuming GNU/Linux here, where everyone else was implicitly assuming it. If you have GNU/Linux you can write better scripts, and when you can be better you should be better.
The output messages from this are logged via syslog, so you don't need to check mail from crond if you don't want to. In fact I would suggest this cron job:
*/10 * * * * /path/to/this/script 1>/dev/null 2>&1
This assumes support for vixie cron syntax and will run the script every 10 minutes and discard all output. You can check syslog for the results. (The usual caveat about cron job PATH applies, but the default system PATH ought to include every utility I have referenced).
Shellcheck reports no issues for this script, which is something you should always check before running code you find on the internet.
But how does it work?
I'll explain the parts that I think are least obvious to the average observer, but not everything.
The [ -z $1 ]] || [[ $1 == *[!0-9-]* ]]
check makes sure that the first argument is a positive or negative number. The function requires such a first argument, so this is just a little safety.
The find ... -printf '%T@/%p\0'
part produces a NULL-delimited output, because NULL is the only safe delimiter to use when filenames are involved. Each record produced this way has a prefix that ends in a /
which contains the file's mtime described as the Unix epoch in seconds followed by .
followed by fractional seconds. E.g. 1679796113.043092340
I chose /
as the delimiter arbitrarily; any other delimiter character that is not [0-9\.]
would work just as well. You could, and arguably should, also use \0
here.
Using sort -z
tells sort
to expect input records to have a NULL delimiter and to produce output records with the same delimiter. Using cut -z
is the same thing for cut
; the other cut
switches remove the time portion--once the output is ordered by mtime we stop caring about the actual value.
The while loop is used to limit the number of emitted files to the specified (in this case -1
). A positive value gives the N most recent files, a negative value gives the N least recent files--which is what this situation calls for.
The numfmt
invocation turns the raw number of bytes produced by stat
into a human-readable version (using IEC binary notation). This is unnecessary, but friendly.
The logger
command may not be as widely known as it deserves, but it makes writing to syslog from scripts trivial.
I think everything else is sufficiently self-explanatory.