summaryrefslogblamecommitdiffstats
path: root/modules-available/rebootcontrol/inc/scheduler.inc.php
blob: 19a01beb4cdb4062a3b7c2c4cbbb87c56bd56254 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12




               






                                                                                                                                            
 



                                                                                    
         
                                                               
                                                                          

         
           
                                                                     





                                                                                       











                                                                                                       




                                                                                         
         



                                                                                                   
                                     
                              
                             

                                                                                         
                                                 
                                                        

                                                                                  
                                                                                                                     
                                 

                                                                                       
                                                                                                                     


                                 

                                                                        
 





                                                                               
                                                                    
                                                             
                                                                               









                                                                                          
                 


                                                
 






                                                                                                             
                                         
                         
                                                                                                                                          
                                                                                                                
                                                                     
                                                              

                                                   
                                              
                         
                                    
                 
                              
                             

         








                                                                                                       
                                        
                                                                                
                                                                                                       







                                                                                                        



























                                                                                                                                     




                                                           



                                                                                                     
           
                                                                                                                   
         

                                                                                                                      
                                                                                      




                                                                                                                      

                                     
                                                             
                                                                                      
                                                              
                                                               
                                                                 






                                                                                                              
                                                   
           
                                                                 
         




                                                                                                                          
                                                                                                            
                 
                                                      



                                                             
                                                                              
           
                                                                                  
         
                                                           
                                                                       
                                                                                                     

                                                          


                                                                   





























                                                                                                                                     
                                                                                                     







































                                                                                                 
                                        













                                                                                                                       
                                                                                   







                                                                                                         




                                                                                                                   
 
<?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;
	}

}