Services
banshee/services-collection/services/restic/ssh/
snapshots
For each restic repository a server backs up to, whether it reads and the age of its newest snapshot, by SSH, without taking a lock.
by banshee1 installation2 versions
// The restic repositories a server backs up to, by SSH and restic on that server: for each repository, whether
// restic reads it, and the age of its newest snapshot. A backup that failed leaves no snapshot: the age grows,
// and says it. Each repository is given by two files on the server, which restic reads itself: the file of its
// location (--repository-file) and the file of its password (--password-file). No secret leaves the server,
// none is written on a command line. restic runs with --no-lock, so that it never writes a lock in the
// repository nor waits for a backup running, and --no-cache, so that it writes no cache on the server.
need('protocol:ssh');
$repositories = variable('restic:repositories', 'multiline', array(
'label' => 'Repositories',
'help' => 'One repository per line: the path, on the server, of the file that holds its location, a space, and the path of the file that holds its password, for instance /etc/restic/nas.repository /etc/restic/nas.password.',
));
per('repository', 'Repositories');
output('status', 'text', array('label' => 'Repository read', 'per' => 'repository'));
output('age', 'number', array('label' => 'Age of the newest snapshot', 'unit' => 'h', 'per' => 'repository'));
watch.expect('unreadable', 'Repository unreadable or empty', 'status', array('critical' => array('ok')));
watch.above('old', 'Newest snapshot too old', 'age', array('warning' => 30, 'critical' => 54));
watch.missing('missing', 'Repository missing', 'status', array('enabled' => false));
// The seconds from 1 January 2000 to a date and time, all in the same local time: two of them subtract into
// a duration, off by an hour at most across a change of summer time.
function local_seconds($year, $month, $day, $hour, $minute, $second) {
$before = array(0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334);
$days = $before[$month - 1] + $day - 1;
if ($month > 2 && $year % 4 == 0 && ($year % 100 != 0 || $year % 400 == 0)) {
$days = $days + 1;
}
for ($y = 2000; $y < $year; $y = $y + 1) {
$days = $days + 365;
if ($y % 4 == 0 && ($y % 100 != 0 || $y % 400 == 0)) {
$days = $days + 1;
}
}
return $days * 86400 + $hour * 3600 + $minute * 60 + $second;
}
// The seconds since 1 January 1970 UTC of a time written 2026-10-26T01:00:03.123456789+01:00 (or Z, or
// +0100); '' when the text is not such a time.
function epoch_of($time) {
$m = text.match($time, '^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(Z|([+-])([0-9]{2}):?([0-9]{2}))$');
if (list.count($m) == 0) {
return '';
}
// 946684800: the seconds from 1970 to 2000. Each part is turned into a number, so that it compares as one.
$seconds = local_seconds($m[1] + 0, $m[2] + 0, $m[3] + 0, $m[4] + 0, $m[5] + 0, $m[6] + 0) + 946684800;
if ($m[8] != 'Z') {
$shift = $m[10] * 3600 + $m[11] * 60;
if ($m[9] == '+') {
$seconds = $seconds - $shift;
} else {
$seconds = $seconds + $shift;
}
}
return $seconds;
}
// A repository is named by the path of the file of its location.
$now = time.now();
$seen = array();
foreach (text.lines($repositories) as $line) {
$files = text.words($line);
if (list.count($files) != 2) {
fail('misconfigured', 'Each line of the repositories must give two paths: the location file and the password file', $line);
}
$name = $files[0];
if (list.has($seen, $name)) {
continue;
}
$seen[$name] = true;
// Every snapshot of the repository: [{"time": "2026-10-26T01:00:03.123456789+01:00", "hostname": "www",
// "paths": ["/srv"], "id": "…"}]. The two paths go as arguments, each quoted apart.
$read = ssh.run('restic snapshots --json --no-lock --no-cache',
arguments=array('--repository-file', $files[0], '--password-file', $files[1]));
// restic 0.17.0 and later exit 10 for a repository that does not exist, 0.17.1 and later 12 for a wrong
// password; the older ones exit 1, and say it.
if ($read['exit'] != 0) {
$status = 'unreadable';
if ($read['exit'] == 12 || text.contains($read['errors'], 'wrong password')) {
$status = 'wrong-password';
} elseif ($read['exit'] == 10 || text.contains($read['errors'], 'does not exist')) {
$status = 'not-found';
}
value('status', $status, $name);
continue;
}
$newest = '';
foreach (json.read(text.trim($read['output'])) as $snapshot) {
$time = epoch_of(list.get($snapshot, 'time', ''));
if ($time != '' && ($newest == '' || $time > $newest)) {
$newest = $time;
}
}
if ($newest == '') {
value('status', 'empty', $name);
continue;
}
value('status', 'ok', $name);
value('age', number.round(($now - $newest) / 3600, 1), $name);
}