Services
banshee/services-collection/services/veeam/https/
jobs
The last result, state and age of the last success of each job of a Veeam Backup & Replication 12 or later, by its REST API.
by banshee1 installation2 versions
// The jobs of a Veeam Backup & Replication server, by its REST API (12 and later): for each job, the result of
// its last run, its state, and the age of its last run that succeeded (Success or Warning). A job that fails,
// or that no longer runs, says it.
// Reads POST /api/oauth2/token, then GET /api/v1/jobs/states, then, for each job not disabled, GET
// /api/v1/sessions for its newest session that ended in Success, and in Warning.
// The address of the API is the backup server's, the host's: https://<host:address>:9419/api/.
need('protocol:http');
$address = need('host:address');
$user = need('protocol:http:user');
$password = need('protocol:http:password');
$port = variable('veeam:port', 'integer', array(
'label' => 'API port',
'help' => 'The port of the Veeam Backup & Replication REST API: 9419, unless it was changed at install.',
'default' => 9419,
));
per('job', 'Jobs');
output('result', 'text', array('label' => 'Last result', 'per' => 'job'));
output('state', 'text', array('label' => 'State', 'per' => 'job'));
output('success-age', 'number', array('label' => 'Age of the last success', 'unit' => 'h', 'per' => 'job'));
watch.state('failed', 'Job failed', 'result', array('warning' => array('warning'), 'critical' => array('failed')));
watch.above('old', 'Last success too old', 'success-age', array('warning' => 30, 'critical' => 54));
watch.state('disabled', 'Job disabled', 'state', array('warning' => array('disabled'), 'enabled' => false));
watch.missing('missing', 'Job missing', 'result', array('enabled' => false));
// The revision of the API this collector is written for: 1.1-rev0, that of Veeam Backup & Replication 12.0,
// served by 12.x and deprecated, still served, from 13.0.
function api_headers($token) {
return array('x-api-version' => '1.1-rev0', 'Authorization' => 'Bearer ' . $token);
}
// Log in: the account goes in a form, which makes the answer secret, and its access token with it. The token is
// kept in the cache, encrypted on the poller, ten minutes, under the fifteen it lives.
function login($base, $user, $password) {
$body = url.query(array('grant_type' => 'password', 'username' => $user, 'password' => $password));
$answer = http.post($base . '/api/oauth2/token', $body, headers=array('x-api-version' => '1.1-rev0',
'Content-Type' => 'application/x-www-form-urlencoded'));
if ($answer['status'] == 400 || $answer['status'] == 401 || $answer['status'] == 403) {
fail('misconfigured', 'Veeam refuses the account, code ' . $answer['status']);
}
if ($answer['status'] != 200) {
fail('unreachable', 'Veeam answers the login with code ' . $answer['status'],
'Veeam Backup & Replication serves its REST API from version 12.');
}
$session = json.read($answer['body']);
cache.set('token', $session['access_token'], 600);
return $session['access_token'];
}
// One GET with the token kept in the cache; a token Veeam no longer knows (expired, the service restarted)
// answers 401, and the collector logs in again, once.
function api_get($base, $path, $user, $password) {
if (cache.has('token')) {
$token = cache.get('token');
} else {
$token = login($base, $user, $password);
}
$answer = http.get($base . $path, headers=api_headers($token));
if ($answer['status'] == 401) {
$token = login($base, $user, $password);
$answer = http.get($base . $path, headers=api_headers($token));
}
if ($answer['status'] == 403) {
fail('misconfigured', 'The account may not read ' . $path . ', code 403');
}
if ($answer['status'] != 200) {
fail('unreachable', 'Veeam answers ' . $path . ' with code ' . $answer['status']);
}
return json.read($answer['body']);
}
// 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.1234567+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;
}
$base = 'https://' . $address . ':' . $port;
// The jobs, a page of 200 at a time: {"data": [{"id": "…", "name": "Daily VMs", "type": "Backup", "status":
// "inactive", "lastResult": "Success", "lastRun": "2026-10-26T01:00:03.123+01:00", …}], "pagination":
// {"total": 12, "count": 12, "skip": 0, "limit": 200}}. status is running, inactive or disabled; lastResult
// None (never run), Success, Warning or Failed. Two jobs of one name each keep their own, the second carrying
// its id.
$jobs = array();
$skip = 0;
$total = 1;
while ($skip < $total) {
$page = api_get($base, '/api/v1/jobs/states?' . url.query(array('skip' => $skip, 'limit' => 200)), $user, $password);
foreach ($page['data'] as $job) {
$jobs = list.add($jobs, $job);
}
$total = $page['pagination']['total'];
$skip = $skip + 200;
}
$now = time.now();
$seen = array();
foreach ($jobs as $job) {
$name = $job['name'];
if (list.has($seen, $name)) {
$name = $name . ' #' . $job['id'];
}
$seen[$name] = true;
value('result', text.lower($job['lastResult']), $name);
value('state', $job['status'], $name);
if ($job['status'] == 'disabled') {
continue;
}
// The newest session of the job that ended in Success, then in Warning: the time of the last success is
// the later of their ends.
$last = '';
foreach (array('Success', 'Warning') as $result) {
$query = url.query(array('jobIdFilter' => $job['id'], 'resultFilter' => $result,
'orderColumn' => 'CreationTime', 'orderAsc' => 'false', 'limit' => 1));
$sessions = api_get($base, '/api/v1/sessions?' . $query, $user, $password);
foreach ($sessions['data'] as $session) {
$end = epoch_of(list.get($session, 'endTime', ''));
if ($end != '' && ($last == '' || $end > $last)) {
$last = $end;
}
}
}
if ($last != '') {
value('success-age', number.round(($now - $last) / 3600, 1), $name);
}
}