Skip to content

Commit daa938f

Browse files
authored
Feature/multiple power schedules
1 parent fe5a6fc commit daa938f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1877
-789
lines changed

docker_images/power_schedule/tests/test_worker.py

+59-16
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ def setUp(self) -> None:
3333
'enabled': True,
3434
'start_date': 0,
3535
'end_date': int((datetime.now() + timedelta(days=10)).timestamp()),
36-
'power_on': '22:00',
37-
'power_off': '11:00',
36+
'triggers': [
37+
{'time': '22:00', 'action': 'power_on'},
38+
{'time': '11:00', 'action': 'power_off'},
39+
],
3840
'timezone': 'Asia/Yerevan',
3941
'last_eval': 0,
4042
'last_run': 0,
@@ -86,8 +88,10 @@ def test_schedule_with_zero_end_date(self):
8688
ps = self.valid_ps.copy()
8789
ps['start_date'] = 1
8890
ps['end_date'] = 0
89-
ps['power_on'] = '04:00'
90-
ps['power_off'] = '02:00'
91+
ps['triggers'] = [
92+
{'time': '04:00', 'action': 'power_on'},
93+
{'time': '02:00', 'action': 'power_off'},
94+
]
9195
self.worker.rest_cl.power_schedule_get = MagicMock(
9296
return_value=(200, ps))
9397
ps_tz = pytz.timezone(ps['timezone'])
@@ -148,8 +152,10 @@ def test_excluded_resources(self):
148152
ps_tz = pytz.timezone(ps['timezone'])
149153
now = datetime.now(ps_tz).replace(
150154
hour=3, minute=0, second=0, microsecond=0)
151-
ps['power_on'] = '04:00'
152-
ps['power_off'] = '02:00'
155+
ps['triggers'] = [
156+
{'time': '04:00', 'action': 'power_on'},
157+
{'time': '02:00', 'action': 'power_off'},
158+
]
153159
self.worker.rest_cl.power_schedule_get = MagicMock(
154160
return_value=(200, ps))
155161

@@ -208,8 +214,10 @@ def test_stop_instance(self):
208214
now = base_date.replace(hour=now_h)
209215
power_on = '%s:00' % power_on_h
210216
power_off = '%s:00' % power_off_h
211-
ps['power_on'] = power_on
212-
ps['power_off'] = power_off
217+
ps['triggers'] = [
218+
{'time': power_on, 'action': 'power_on'},
219+
{'time': power_off, 'action': 'power_off'},
220+
]
213221
self.worker.rest_cl.power_schedule_get = MagicMock(
214222
return_value=(200, ps))
215223

@@ -261,8 +269,10 @@ def test_start_instance(self):
261269
now = base_date.replace(hour=now_h)
262270
power_on = '%s:00' % power_on_h
263271
power_off = '%s:00' % power_off_h
264-
ps['power_on'] = power_on
265-
ps['power_off'] = power_off
272+
ps['triggers'] = [
273+
{'time': power_on, 'action': 'power_on'},
274+
{'time': power_off, 'action': 'power_off'},
275+
]
266276
self.worker.rest_cl.power_schedule_get = MagicMock(
267277
return_value=(200, ps))
268278

@@ -317,8 +327,10 @@ def test_no_changes(self):
317327
now = base_date.replace(hour=now_h)
318328
power_on = '%s:00' % power_on_h
319329
power_off = '%s:00' % power_off_h
320-
ps['power_on'] = power_on
321-
ps['power_off'] = power_off
330+
ps['triggers'] = [
331+
{'time': power_on, 'action': 'power_on'},
332+
{'time': power_off, 'action': 'power_off'},
333+
]
322334
self.worker.rest_cl.power_schedule_get = MagicMock(
323335
return_value=(200, ps))
324336

@@ -348,8 +360,10 @@ def test_no_resources(self):
348360
ps_tz = pytz.timezone(ps['timezone'])
349361
now = datetime.now(ps_tz).replace(
350362
hour=3, minute=0, second=0, microsecond=0)
351-
ps['power_on'] = '04:00'
352-
ps['power_off'] = '02:00'
363+
ps['triggers'] = [
364+
{'time': '04:00', 'action': 'power_on'},
365+
{'time': '02:00', 'action': 'power_off'},
366+
]
353367
self.worker.rest_cl.power_schedule_get = MagicMock(
354368
return_value=(200, ps))
355369

@@ -391,8 +405,10 @@ def raise_exc(*args):
391405
ps_tz = pytz.timezone(ps['timezone'])
392406
now = datetime.now(ps_tz).replace(
393407
hour=3, minute=0, second=0, microsecond=0)
394-
ps['power_on'] = '04:00'
395-
ps['power_off'] = '02:00'
408+
ps['triggers'] = [
409+
{'time': '04:00', 'action': 'power_on'},
410+
{'time': '02:00', 'action': 'power_off'},
411+
]
396412
self.worker.rest_cl.power_schedule_get = MagicMock(
397413
return_value=(200, ps))
398414

@@ -438,6 +454,33 @@ def raise_exc(*args):
438454
self.worker.rest_cl.power_schedule_update.call_args[0][1][
439455
'last_run_error'], None)
440456

457+
def test_conflicting_triggers(self):
458+
ps = self.valid_ps.copy()
459+
ps['triggers'] = [
460+
{'time': '02:00', 'action': 'power_on'},
461+
{'time': '02:00', 'action': 'power_off'},
462+
]
463+
self.worker.rest_cl.power_schedule_get = MagicMock(
464+
return_value=(200, ps))
465+
self.worker.process_task(
466+
body={'power_schedule_id': self.ps_id},
467+
message=self.message)
468+
469+
result = self.default_result.copy()
470+
result['error'] = 0
471+
result['reason'] = 'Conflicting triggers for time: 02:00'
472+
self.assertEqual(result, self.worker.result)
473+
self.worker.rest_cl.power_schedule_update.assert_called_once()
474+
self.assertIn(
475+
'last_eval',
476+
self.worker.rest_cl.power_schedule_update.call_args[0][1])
477+
self.assertIn(
478+
'last_run',
479+
self.worker.rest_cl.power_schedule_update.call_args[0][1])
480+
self.assertIn(
481+
'last_run_error',
482+
self.worker.rest_cl.power_schedule_update.call_args[0][1])
483+
441484

442485
if __name__ == '__main__':
443486
unittest.main()

docker_images/power_schedule/worker.py

+62-46
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class PowerScheduleReasons:
4747
DISABLED = 'Power schedule is disabled'
4848
OUTDATED = 'Power schedule is outdated'
4949
NO_CHANGES = 'Changing state is not required'
50+
CONFLICT = 'Conflicting triggers'
5051

5152

5253
class PowerScheduleWorker(ConsumerMixin):
@@ -123,61 +124,72 @@ def _intersect(segment_1, segment_2):
123124
first_point = max(a[0])
124125
last_point = min(a[1])
125126
if first_point < last_point:
126-
result = [first_point, last_point]
127+
result = (first_point, last_point)
127128
elif first_point == last_point:
128-
result = [first_point]
129+
result = (first_point,)
129130
else:
130-
result = []
131+
result = ()
131132
return result
132133

133134
def get_action(self, schedule):
134135
local_tz = pytz.timezone(schedule['timezone'])
135136
now_dt = datetime.now(tz=pytz.utc)
136137
last_eval_dt = datetime.fromtimestamp(
137138
schedule['last_eval'], tz=pytz.utc)
138-
power_off_today = self._local_time_to_utc(
139-
schedule['power_off'], local_tz)
140-
power_on_today = self._local_time_to_utc(
141-
schedule['power_on'], local_tz)
142-
if power_off_today == power_on_today:
143-
self.result['reason'] = PowerScheduleReasons.NO_CHANGES
144-
return None
145-
power_off_tomorrow = power_off_today + timedelta(hours=24)
146-
power_on_tomorrow = power_on_today + timedelta(hours=24)
147-
power_off_segment = [power_off_today, power_off_tomorrow]
148-
power_on_segment = [power_on_today, power_on_tomorrow]
149-
run_dt = [last_eval_dt, now_dt]
150-
151-
action = None
152-
schedule_time = None
153-
# list of state changes already come
154-
action_time = [x for x in power_off_segment + power_on_segment
155-
if x < now_dt]
156-
if action_time:
157-
# get nearest past action time
158-
last_action_time = min(action_time, key=lambda x: abs(x - now_dt))
159-
if last_action_time in power_on_segment:
160-
action = 'start_instance'
161-
schedule_time = power_on_segment
162-
elif last_action_time in power_off_segment:
163-
action = 'stop_instance'
164-
schedule_time = power_off_segment
165-
else:
166-
# no action changes required yet
167-
self.result['reason'] = PowerScheduleReasons.NO_CHANGES
168-
return None
169-
170-
cross = None
171-
# intersect power on or power off schedule with check times to check if
172-
# the last action has already been applied
173-
if schedule_time:
174-
cross = self._intersect(schedule_time, run_dt)
175-
if cross == run_dt or cross is None:
139+
times_today = []
140+
time_action_map = {}
141+
for trigger in schedule['triggers']:
142+
action = trigger['action']
143+
time = self._local_time_to_utc(trigger['time'], local_tz)
144+
if time in time_action_map:
145+
raise PowerScheduleException(
146+
'Conflicting triggers for time: {}'.format(
147+
trigger['time']))
148+
times_today.append(time)
149+
time_action_map[time] = action
150+
# collect power on/off segments during day
151+
times_today = sorted(times_today)
152+
time_periods = zip(times_today[:-1], times_today[1:])
153+
154+
run_dt = (last_eval_dt, now_dt)
155+
if len(times_today) == 1:
156+
time = times_today[0]
157+
if last_eval_dt <= time <= now_dt:
158+
action = time_action_map[times_today[0]]
159+
LOG.info('Action required: %s', action)
160+
return action
161+
162+
candidate = None
163+
for period in time_periods:
164+
cross = self._intersect(period, run_dt)
165+
if cross and cross == run_dt:
166+
# trigger's time hasn't come
167+
# (period_start <= last_eval <= now <= period_end)
168+
self.result['reason'] = PowerScheduleReasons.NO_CHANGES
169+
return None
170+
elif cross and cross != run_dt and cross != period:
171+
# found nearest trigger in the past
172+
# (last_eval <= period_start <= now <= period_end) OR
173+
# (period_start <= last_eval <= period_end <= now)
174+
time = max([x for x in period if x <= now_dt])
175+
action = time_action_map[time]
176+
LOG.info('Action required: %s', action)
177+
return action
178+
elif cross and cross == period:
179+
# too much time passed between runs, continue iterating between
180+
# periods to find the nearest trigger
181+
# (last_eval <= period_start <= period_end <= now)
182+
if not candidate:
183+
candidate = period[1]
184+
else:
185+
candidate = max(candidate, period[1])
186+
if not candidate:
187+
# triggers' times hasn't come
176188
self.result['reason'] = PowerScheduleReasons.NO_CHANGES
177189
return None
178-
else:
179-
LOG.info('Action required: %s', action)
180-
return action
190+
action = time_action_map[candidate]
191+
LOG.info('Action required: %s', action)
192+
return action
181193

182194
@staticmethod
183195
def get_resource_data(resource, cloud_type):
@@ -279,9 +291,14 @@ def process_schedule(self, power_schedule_id):
279291
return
280292
required_action = self.get_action(schedule)
281293
if required_action:
282-
self.process_resources(schedule, required_action)
294+
action_func_map = {
295+
'power_on': 'start_instance',
296+
'power_off': 'stop_instance',
297+
}
298+
self.process_resources(schedule, action_func_map[required_action])
283299

284300
def process_task(self, body, message):
301+
now_ts = int(datetime.now(tz=pytz.utc).timestamp())
285302
self.result = self.default_result().copy()
286303
error = None
287304
power_schedule_id = body.get('power_schedule_id')
@@ -296,7 +313,6 @@ def process_task(self, body, message):
296313
LOG.error('Task failed: %s', error)
297314
LOG.info('Power schedule %s results:\n%s',
298315
power_schedule_id, self.result)
299-
now_ts = int(datetime.now(tz=pytz.utc).timestamp())
300316
updates = {
301317
'last_eval': now_ts,
302318
}

0 commit comments

Comments
 (0)