diff options
Diffstat (limited to 'modules-available/rebootcontrol/inc/scheduler.inc.php')
-rw-r--r-- | modules-available/rebootcontrol/inc/scheduler.inc.php | 330 |
1 files changed, 330 insertions, 0 deletions
diff --git a/modules-available/rebootcontrol/inc/scheduler.inc.php b/modules-available/rebootcontrol/inc/scheduler.inc.php new file mode 100644 index 00000000..19a01beb --- /dev/null +++ b/modules-available/rebootcontrol/inc/scheduler.inc.php @@ -0,0 +1,330 @@ +<?php + +class Scheduler +{ + + const ACTION_SHUTDOWN = 'SHUTDOWN'; + const ACTION_REBOOT = 'REBOOT'; + const ACTION_WOL = 'WOL'; + const RA_NEVER = 'NEVER'; + const RA_SELECTIVE = 'SELECTIVE'; + const RA_ALWAYS = 'ALWAYS'; + const SCHEDULE_OPTIONS_DEFAULT = ['wol' => false, 'sd' => false, 'wol-offset' => 0, 'sd-offset' => 0, 'ra-mode' => self::RA_ALWAYS]; + + /** + * @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 ra-mode is selective, still execute even if wol and shutdown is disabled, + // because we still want to shutdown any sessions in the wrong runmode then + $selectiveRa = ($options['ra-mode'] === self::RA_SELECTIVE); + if ((!$options['wol'] && !$options['sd'] && !$selectiveRa) || empty($openingTimes)) + return false; + $now = time(); + $events = []; + $findWol = $options['wol'] || $options['ra-mode'] === self::RA_SELECTIVE; + $findSd = $options['sd'] || $options['ra-mode'] === self::RA_SELECTIVE; + foreach ($openingTimes as $row) { + foreach ($row['days'] as $day) { + if ($findWol) { + $events[] = ['action' => self::ACTION_WOL, + 'time' => self::calculateTimestamp($now, $day, $row['openingtime'])]; + } + if ($findSd) { + $events[] = ['action' => self::ACTION_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::ACTION_WOL) { + $event['time'] -= $wolOffset; + } elseif ($event['action'] === self::ACTION_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::ACTION_SHUTDOWN && $events[$i + 1]['action'] === self::ACTION_WOL) { + // If difference to next WOL is < 15 min and this is a shutdown, reboot instead. + $res['action'] = self::ACTION_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]); + foreach ($res as $row) { + // Calculate next_execution for the event and update DB. + $options = json_decode($row['options'], true) + self::SCHEDULE_OPTIONS_DEFAULT; + // 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; + $selectiveRa = ($options['ra-mode'] === self::RA_SELECTIVE); + // Now, if selective remote access is active, we might modify the actual event: + if ($selectiveRa) { + // If this is WOL, and WOL is actually enabled, then reboot any running machines + // in remoteaccess mode, in addition to waking the others, so they exit remote access mode. + if ($row['action'] === Scheduler::ACTION_WOL && $options['wol']) { + self::executeCronForLocation($row['locationid'], Scheduler::ACTION_REBOOT, 'remoteaccess'); + self::executeCronForLocation($row['locationid'], Scheduler::ACTION_WOL); + } + // If this is WOL, and WOL is disabled, shut down any running machines, this is so + // anybody walking into this room will not mess with a user's session by yanking the + // power cord etc. + if ($row['action'] === Scheduler::ACTION_WOL && !$options['wol']) { + self::executeCronForLocation($row['locationid'], Scheduler::ACTION_SHUTDOWN, 'remoteaccess'); + } + // If this is SHUTDOWN, and SHUTDOWN is enabled, leave it at that. + if ($row['action'] === Scheduler::ACTION_SHUTDOWN && $options['sd']) { + self::executeCronForLocation($row['locationid'], Scheduler::ACTION_SHUTDOWN); + } + // If this is SHUTDOWN, and SHUTDOWN is disabled, do a reboot, so the machine ends up + // in the proper runmode. + if ($row['action'] === Scheduler::ACTION_SHUTDOWN && !$options['sd']) { + self::executeCronForLocation($row['locationid'], Scheduler::ACTION_REBOOT, ''); + } + } else { + // Regular case, no selective remoteaccess – just do what the cron entry says + self::executeCronForLocation($row['locationid'], $row['action']); + } + } + } + + /** + * Execute the given action for the given location. + * @param int $locationId location + * @param string $action action to perform, Scheduler::* + * @param string|null $onlyRunmode if not null, only process running clients in given runmode + * @return void + */ + private static function executeCronForLocation(int $locationId, string $action, string $onlyRunmode = null) + { + if ($onlyRunmode === null) { + $machines = Database::queryAll("SELECT machineuuid, clientip, macaddr, locationid FROM machine + WHERE locationid = :locid", ['locid' => $locationId]); + } else { + $machines = Database::queryAll("SELECT machineuuid, clientip, macaddr, locationid FROM machine + WHERE locationid = :locid AND currentrunmode = :runmode AND state <> 'OFFLINE'", + ['locid' => $locationId, 'runmode' => $onlyRunmode]); + } + if (empty($machines)) + return; + if ($action === Scheduler::ACTION_SHUTDOWN) { + RebootControl::execute($machines, RebootControl::SHUTDOWN, 0); + } elseif ($action === Scheduler::ACTION_WOL) { + RebootControl::wakeMachines($machines); + } elseif ($action === Scheduler::ACTION_REBOOT) { + RebootControl::execute($machines, RebootControl::REBOOT, 0); + } else { + EventLog::warning("Invalid action '$action' in schedule for location " . $locationId); + } + } + + /** + * Get current settings for given location. + */ + public static function getLocationOptions(int $id): array + { + static $optionList = false; + if ($optionList === false) { + $optionList = Database::queryKeyValueList("SELECT locationid, `options` FROM `reboot_scheduler`"); + } + if (isset($optionList[$id])) { + return (json_decode($optionList[$id], true) ?? []) + self::SCHEDULE_OPTIONS_DEFAULT; + } + return self::SCHEDULE_OPTIONS_DEFAULT; + } + + /** + * Write new WOL/Shutdown options for given location. + * @param array $options 'wol' 'sd' 'wol-offset' 'sd-offset' 'ra-mode' + */ + public static function setLocationOptions(int $locationId, array $options) + { + $options += self::SCHEDULE_OPTIONS_DEFAULT; + $openingTimes = OpeningTimes::forLocation($locationId); + if (!$options['wol'] && !$options['sd'] && $options['ra-mode'] === self::RA_ALWAYS) { + self::deleteSchedule($locationId); + } else { + // Sanitize + Util::clamp($options['wol-offset'], 0, 60); + Util::clamp($options['sd-offset'], 0, 60); + $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'] && $options['ra-mode'] === self::RA_ALWAYS) { + 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 = []; + foreach ($res as $row) { + $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 { + $options += self::SCHEDULE_OPTIONS_DEFAULT; + self::updateScheduleSingle($locationId, $options, $openingTimes); + } + } + // Either way, further walk down the tree + self::updateScheduleRecursive($locationId, $openingTimes); + } + } + + public static function isValidRaMode(string $raMode): bool + { + return $raMode === self::RA_ALWAYS || $raMode === self::RA_NEVER || $raMode === self::RA_SELECTIVE; + } + +}
\ No newline at end of file |