<?php
class Scheduler
{
const SHUTDOWN = 'SHUTDOWN';
const REBOOT = 'REBOOT';
const WOL = 'WOL';
/**
* @param int $locationid ID of location to delete WOL/shutdown settings for
*/
public static function deleteSchedule(int $locationid)
{
Database::exec("DELETE FROM `reboot_scheduler`
WHERE locationid = :lid", ['lid' => $locationid]);
}
/**
* Calculate next time the given time description is reached
* @param int $now unix timestamp representing now
* @param string $day Name of weekday
* @param string $time Time, fi. 13:45
* @return false|int unix timestamp in the future when we reach the given time
*/
private static function calculateTimestamp(int $now, string $day, string $time)
{
$ts = strtotime("$day $time");
if ($ts < $now) {
$ts = strtotime("next $day $time");
}
if ($ts < $now) {
EventLog::warning("Invalid params to calculateTimestamp(): 'next $day $time'");
$ts = $now + 864000;
}
return $ts;
}
/**
* Take WOL/SD options and opening times schedule, return next event.
* @return array|false array with keys 'time' and 'action' false if no next event
*/
private static function calculateNext(array $options, array $openingTimes)
{
if ((!$options['wol'] && !$options['sd']) || empty($openingTimes))
return false;
$now = time();
$events = [];
foreach ($openingTimes as $row) {
foreach ($row['days'] as $day) {
if ($options['wol']) {
$events[] = ['action' => self::WOL,
'time' => self::calculateTimestamp($now, $day, $row['openingtime'])];
}
if ($options['sd']) {
$events[] = ['action' => self::SHUTDOWN,
'time' => self::calculateTimestamp($now, $day, $row['closingtime'])];
}
}
}
$tmp = ArrayUtil::flattenByKey($events, 'time');
array_multisort($tmp, SORT_NUMERIC | SORT_ASC, $events);
// Only apply offsets now, so we can detect nonsensical overlap
$wolOffset = $options['wol-offset'] * 60;
$sdOffset = $options['sd-offset'] * 60;
$prev = PHP_INT_MAX;
for ($i = count($events) - 1; $i >= 0; --$i) {
$event =& $events[$i];
if ($event['action'] === self::WOL) {
$event['time'] -= $wolOffset;
} elseif ($event['action'] === self::SHUTDOWN) {
$event['time'] += $sdOffset;
} else {
error_log('BUG Unhandled event type ' . $event['action']);
}
if ($event['time'] >= $prev || $event['time'] < $now) {
// Overlap, delete this event
unset($events[$i]);
} else {
$prev = $event['time'];
}
}
unset($event);
// Reset array keys
$events = array_values($events);
// See which is the next suitable event to act upon
$lastEvent = count($events) - 1;
for ($i = 0; $i <= $lastEvent; $i++) {
$event =& $events[$i];
$diff = ($i === $lastEvent ? PHP_INT_MAX : $events[$i + 1]['time'] - $event['time']);
if ($diff < 300 && $event['action'] !== $events[$i + 1]['action']) {
// If difference to next event is < 5 min, ignore.
continue;
}
if ($diff < 900 && $event['action'] === self::SHUTDOWN && $events[$i + 1]['action'] === self::WOL) {
// If difference to next WOL is < 15 min and this is a shutdown, reboot instead.
$res['action'] = self::REBOOT;
$res['time'] = $event['time'];
} else {
// Use first event.
$res = $event;
}
return $res;
}
unset($event);
return false;
}
/**
* Check if any actions have to be taken. To be called periodically by cron.
*/
public static function cron()
{
$now = time();
$res = Database::simpleQuery("SELECT s.locationid, s.action, s.nextexecution, s.options
FROM reboot_scheduler s
WHERE s.nextexecution < :now AND s.nextexecution > 0", ['now' => $now]);
while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
// Calculate next_execution for the event and update DB.
$options = json_decode($row['options'], true);
// Determine proper opening times by waling up tree
$openingTimes = OpeningTimes::forLocation($row['locationid']);
if ($openingTimes !== null) {
self::updateScheduleSingle($row['locationid'], $options, $openingTimes);
}
// Weird clock drift? Server offline for a while? Do nothing.
if ($row['nextexecution'] + 900 < $now)
continue;
self::executeCronForLocation($row['locationid'], $row['action']);
}
}
/**
* Execute the given action for the given location.
*/
private static function executeCronForLocation(int $locationId, string $action)
{
$machines = Database::queryAll("SELECT machineuuid, clientip, macaddr, locationid FROM machine
WHERE locationid = :locid", ['locid' => $locationId]);
if (empty($machines))
return;
if ($action === Scheduler::SHUTDOWN) {
RebootControl::execute($machines, RebootControl::SHUTDOWN, 0);
} elseif ($action === Scheduler::WOL) {
RebootControl::wakeMachines($machines);
} elseif ($action === Scheduler::REBOOT) {
RebootControl::execute($machines, RebootControl::REBOOT, 0);
} else {
EventLog::warning("Invalid action '$action' in schedule for location " . $locationId);
}
}
/**
* Get current settings for given location, or false if none.
* @param int $id
* @return false|array
*/
public static function getLocationOptions(int $id)
{
$res = Database::queryFirst("SELECT options FROM `reboot_scheduler`
WHERE locationid = :id", ['id' => $id]);
if ($res !== false) {
return json_decode($res['options'], true);
}
return false;
}
/**
* Write new WOL/Shutdown options for given location.
* @param int $locationId
* @param bool $wol whether WOL is enabled
* @param bool $sd whether Shutdown is enabled
* @param int $wolOffset how many minutes prior to opening time the WOL should be triggered
* @param int $sdOffset how many minutes after closing time a shutdown should be triggered
*/
public static function setLocationOptions(int $locationId, bool $wol, bool $sd, int $wolOffset, int $sdOffset)
{
$openingTimes = OpeningTimes::forLocation($locationId);
if (!$wol && !$sd) {
self::deleteSchedule($locationId);
} else {
// Sanity checks
if ($wolOffset > 60) {
$wolOffset = 60;
} elseif ($wolOffset < 0) {
$wolOffset = 0;
}
if ($sdOffset > 60) {
$sdOffset = 60;
} elseif ($sdOffset < 0) {
$sdOffset = 0;
}
$options = [
'wol' => $wol,
'sd' => $sd,
'wol-offset' => $wolOffset,
'sd-offset' => $sdOffset,
];
$json_options = json_encode($options);
// Write settings, reset schedule
Database::exec("INSERT INTO `reboot_scheduler` (locationid, action, nextexecution, options)
VALUES (:lid, :act, :next, :opt)
ON DUPLICATE KEY UPDATE
action = VALUES(action), nextexecution = VALUES(nextexecution), options = VALUES(options)", [
'lid' => $locationId,
'act' => 'WOL',
'next' => 0,
'opt' => $json_options,
]);
// Write new timestamps for this location
if ($openingTimes !== null) {
self::updateScheduleSingle($locationId, $options, $openingTimes);
}
}
// In either case, refresh data for children as well
if ($openingTimes !== null) {
self::updateScheduleRecursive($locationId, $openingTimes);
}
}
/**
* Write next WOL/shutdown action to DB, using given options and opening times.
* @param int $locationid Location to store settings for
* @param array $options Options for calculation (WOL/Shutdown enabled, offsets)
* @param array $openingTimes Opening times to use
*/
private static function updateScheduleSingle(int $locationid, array $options, array $openingTimes)
{
if (!$options['wol'] && !$options['sd']) {
self::deleteSchedule($locationid);
return;
}
$nextexec = self::calculateNext($options, $openingTimes);
if ($nextexec === false) {
// Empty opening times, or all intervals seem to be < 5 minutes, disable.
$nextexec = [
'action' => 'WOL',
'time' => 0,
];
}
Database::exec("UPDATE reboot_scheduler
SET action = :act, nextexecution = :next
WHERE locationid = :lid", [
'lid' => $locationid,
'act' => $nextexec['action'],
'next' => $nextexec['time'],
]);
}
/**
* Recurse into all child locations of the given location-id and re-calculate the next
* WOL or shutdown event, based on the given opening times. Recursion stops at locations
* that come with their own opening times.
* @param int $parentId parent location to start recursion from. Not actually processed.
* @param array $openingTimes Opening times to use for calculations
*/
private static function updateScheduleRecursive(int $parentId, array $openingTimes)
{
$list = Location::getLocationsAssoc();
if (!isset($list[$parentId]))
return;
$childIdList = $list[$parentId]['directchildren'];
if (empty($childIdList))
return;
$res = Database::simpleQuery("SELECT l.locationid, l.openingtime, rs.options
FROM location l
LEFT JOIN reboot_scheduler rs USING (locationid)
WHERE l.locationid IN (:list)", ['list' => $childIdList]);
$locationData = [];
while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
$locationData[$row['locationid']] = $row;
}
// Handle all child locations
foreach ($childIdList as $locationId) {
if (!isset($locationData[$locationId]) || $locationData[$locationId]['openingtime'] !== null) {
continue; // Ignore entire sub-tree where new opening times are assigned
}
// This location doesn't have a new openingtimes schedule
// If any options are set for this location, update its schedule
if ($locationData[$locationId]['options'] !== null) {
$options = json_decode($locationData[$locationId]['options'], true);
if (!is_array($options)) {
trigger_error("Invalid options for lid:$locationId", E_USER_WARNING);
} else {
self::updateScheduleSingle($locationId, $options, $openingTimes);
}
}
// Either way, further walk down the tree
self::updateScheduleRecursive($locationId, $openingTimes);
}
}
}