$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); } } }