Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/c7n/filters/offhours.py: 62%
242 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-08 06:51 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-08 06:51 +0000
1# Copyright The Cloud Custodian Authors.
2# SPDX-License-Identifier: Apache-2.0
3"""
4Resource Scheduling Offhours
5============================
7Custodian provides for time based filters, that allow for taking periodic
8action on a resource, with resource schedule customization based on tag values.
9A common use is offhours scheduling for asgs and instances.
11Features
12========
14- Flexible offhours scheduling with opt-in, opt-out selection, and timezone
15 support.
16- Resume during offhours support.
17- Can be combined with other filters to get a particular set (
18 resources with tag, vpc, etc).
19- Can be combined with arbitrary actions
20- Can omit a set of dates such as public holidays.
22Policy Configuration
23====================
25We provide an `onhour` and `offhour` time filter, each should be used in a
26different policy, they support the same configuration options:
28 - **weekends**: default true, whether to leave resources off for the weekend
29 - **weekends-only**: default false, whether to turn the resource off only on
30 the weekend
31 - **default_tz**: which timezone to utilize when evaluating time **(REQUIRED)**
32 - **fallback-schedule**: If a resource doesn't support tagging or doesn't provide
33 a tag you can supply a default schedule that will be used. When the tag is provided
34 this will be ignored. See :ref:`ScheduleParser Time Specifications <scheduleparser-time-spec>`.
35 - **tag**: which resource tag name to use for per-resource configuration
36 (schedule and timezone overrides and opt-in/opt-out); default is
37 ``maid_offhours``.
38 - **opt-out**: Determines the behavior for resources which do not have a tag
39 matching the one specified for **tag**. Values can be either ``false`` (the
40 default) where the policy operates on an opt-in basis and resources must have
41 the tag in order to be acted on by the policy, or ``true`` where the policy
42 operates on an opt-out basis, and resources without the tag are acted on by
43 the policy.
44 - **onhour**: the default time to start/run resources, specified as 0-23
45 - **offhour**: the default time to stop/suspend resources, specified as 0-23
46 - **skip-days**: a list of dates to skip. Dates must use format YYYY-MM-DD
47 - **skip-days-from**: a list of dates to skip stored at a url. **expr**,
48 **format**, and **url** must be passed as parameters. Same syntax as
49 ``value_from``. Can not specify both **skip-days-from** and **skip-days**.
51This example policy overrides most of the defaults for an offhour policy:
53.. code-block:: yaml
55 policies:
56 - name: offhours-stop
57 resource: ec2
58 filters:
59 - type: offhour
60 weekends: false
61 default_tz: pt
62 tag: downtime
63 opt-out: true
64 onhour: 8
65 offhour: 20
68Tag Based Configuration
69=======================
71Resources can use a special tag to override the default configuration on a
72per-resource basis. Note that the name of the tag is configurable via the
73``tag`` option in the policy; the examples below use the default tag name,
74``maid_offhours``.
76The value of the tag must be one of the following:
78- **(empty)** or **on** - An empty tag value or a value of "on" implies night
79 and weekend offhours using the default time zone configured in the policy
80 (tz=est if unspecified) and the default onhour and offhour values configured
81 in the policy.
82- **off** - If offhours is configured to run in opt-out mode, this tag can be
83 specified to disable offhours on a given instance. If offhours is configured
84 to run in opt-in mode, this tag will have no effect (the resource will still
85 be opted out).
86- a semicolon-separated string composed of one or more of the following
87 components, which override the defaults specified in the policy:
89 * ``tz=<timezone>`` to evaluate with a resource-specific timezone, where
90 ``<timezone>`` is either one of the supported timezone aliases defined in
91 :py:attr:`c7n.filters.offhours.Time.TZ_ALIASES` (such as ``pt``) or the name
92 of a geographic timezone identifier in
93 [IANA's tzinfo database](https://www.iana.org/time-zones), such as
94 ``Americas/Los_Angeles``. *(Note all timezone aliases are
95 referenced to a locality to ensure taking into account local daylight
96 savings time, if applicable.)*
97 * ``off=(time spec)`` and/or ``on=(time spec)`` matching time specifications
98 supported by :py:class:`c7n.filters.offhours.ScheduleParser` as described
99 in the next section.
102.. _scheduleparser-time-spec:
104ScheduleParser Time Specifications
105----------------------------------
107Each time specification follows the format ``(days,hours)``. Multiple time
108specifications can be combined in square-bracketed lists, i.e.
109``[(days,hours),(days,hours),(days,hours)]``.
111**Examples**::
113 # up mon-fri from 7am-7pm; eastern time
114 off=(M-F,19);on=(M-F,7)
115 # up mon-fri from 6am-9pm; up sun from 10am-6pm; pacific time
116 off=[(M-F,21),(U,18)];on=[(M-F,6),(U,10)];tz=pt
118**Possible values**:
120 +------------+----------------------+
121 | field | values |
122 +============+======================+
123 | days | M, T, W, H, F, S, U |
124 +------------+----------------------+
125 | hours | 0, 1, 2, ..., 22, 23 |
126 +------------+----------------------+
128 Days can be specified in a range (ex. M-F).
130Policy examples
131===============
133Turn ec2 instances on and off
135.. code-block:: yaml
137 policies:
138 - name: offhours-stop
139 resource: ec2
140 filters:
141 - type: offhour
142 actions:
143 - stop
145 - name: offhours-start
146 resource: ec2
147 filters:
148 - type: onhour
149 actions:
150 - start
152Here's doing the same with auto scale groups
154.. code-block:: yaml
156 policies:
157 - name: asg-offhours-stop
158 resource: asg
159 filters:
160 - offhour
161 actions:
162 - suspend
163 - name: asg-onhours-start
164 resource: asg
165 filters:
166 - onhour
167 actions:
168 - resume
170Additional policy examples and resource-type-specific information can be seen in
171the :ref:`EC2 Offhours <ec2offhours>` and :ref:`ASG Offhours <asgoffhours>`
172use cases.
174Resume During Offhours
175======================
177These policies are evaluated hourly; during each run (once an hour),
178cloud-custodian will act on **only** the resources tagged for that **exact**
179hour. In other words, if a resource has an offhours policy of
180stopping/suspending at 23:00 Eastern daily and starting/resuming at 06:00
181Eastern daily, and you run cloud-custodian once an hour via Lambda, that
182resource will only be stopped once a day sometime between 23:00 and 23:59, and
183will only be started once a day sometime between 06:00 and 06:59. If the current
184hour does not *exactly* match the hour specified in the policy, nothing will be
185done at all.
187As a result of this, if custodian stops an instance or suspends an ASG and you
188need to start/resume it, you can safely do so manually and custodian won't touch
189it again until the next day.
191ElasticBeanstalk, EFS and Other Services with Tag Value Restrictions
192====================================================================
194A number of AWS services have restrictions on the characters that can be used
195in tag values, such as `ElasticBeanstalk <http://docs.aws.amazon.com/elasticbean
196stalk/latest/dg/using-features.tagging.html>`_ and `EFS <http://docs.aws.amazon.
197com/efs/latest/ug/API_Tag.html>`_. In particular, these services do not allow
198parenthesis, square brackets, commas, or semicolons, or empty tag values. This
199proves to be problematic with the tag-based schedule configuration described
200above. The best current workaround is to define a separate policy with a unique
201``tag`` name for each unique schedule that you want to use, and then tag
202resources with that tag name and a value of ``on``. Note that this can only be
203used in opt-in mode, not opt-out.
205Another option is to escape the tag value with the following mapping, generated
206with the char's unicode number `"u" + hex(ord(the_char))[2:]`.
207This works for GCP resources as well.
209- ( and ) as u28 and u29
210- [ and ] as u5b and u5d
211- , as u2c
212- ; as u3b
213- = as u3d
214- / as u2f
215- - as u2d
217**Examples**::
219 # off=(M-F,18);tz=Australia/Sydney
220 offu3du28M-Fu2c18u29u3btzu3dAustraliau2fSydney
221 # off=[(M-F,18),(S,13)]
222 off=u5bu28M-Fu2c18u29u2cu28Su2c13u29u5d
224Public Holidays
225===============
227In order to properly implement support for public holidays, make sure to include
228either **skip-days** or **skip-days-from** with your policy. This list
229should contain all of the public holidays you wish to address and must use
230YYYY-MM-DD syntax for its dates. If the date the policy is being run on matches
231any one of those dates, the policy will not return any resources. These dates
232include year as many holidays vary from year to year so year is required to prevent
233errors. A sample policy that would not start stopped instances on a public holiday
234might look like:
236.. code-block:: yaml
238 policies:
239 - name: onhour-morning-start-skip-holidays
240 resource: ec2
241 filters:
242 - type: onhour
243 tag: custodian_downtime
244 default_tz: et
245 onhour: 6
246 skip-days: ['2017-12-25']
247 actions:
248 - start
250"""
251# note we have to module import for our testing mocks
252import datetime
253import logging
254from os.path import join
256from dateutil import zoneinfo, tz as tzutil
258from c7n.exceptions import PolicyValidationError
259from c7n.filters import Filter
260from c7n.utils import type_schema, dumps
261from c7n.resolver import ValuesFrom
263log = logging.getLogger('custodian.offhours')
266def brackets_removed(u):
267 return u.translate({ord('['): None, ord(']'): None})
270def parens_removed(u):
271 return u.translate({ord('('): None, ord(')'): None})
274class Time(Filter):
275 """
276 Schedule offhours for resources see :ref:`offhours <offhours>`
277 for features and configuration.
278 """
279 schema = {
280 'type': 'object',
281 'properties': {
282 'tag': {'type': 'string'},
283 'default_tz': {'type': 'string'},
284 'fallback-schedule': {'type': 'string'},
285 'fallback_schedule': {'type': 'string'},
286 'weekends': {'type': 'boolean'},
287 'weekends-only': {'type': 'boolean'},
288 'opt-out': {'type': 'boolean'},
289 'skip-days': {'type': 'array', 'items':
290 {'type': 'string', 'pattern': '^[0-9]{4}-[0-9]{2}-[0-9]{2}'}},
291 'skip-days-from': ValuesFrom.schema,
292 }
293 }
294 schema_alias = True
295 time_type = None
297 # Defaults and constants
298 DEFAULT_TAG = "maid_offhours"
299 DEFAULT_TZ = 'et'
301 TZ_ALIASES = {
302 'pdt': 'America/Los_Angeles',
303 'pt': 'America/Los_Angeles',
304 'pst': 'America/Los_Angeles',
305 'ast': 'America/Phoenix',
306 'at': 'America/Phoenix',
307 'est': 'America/New_York',
308 'edt': 'America/New_York',
309 'et': 'America/New_York',
310 'cst': 'America/Chicago',
311 'cdt': 'America/Chicago',
312 'ct': 'America/Chicago',
313 'mst': 'America/Denver',
314 'mdt': 'America/Denver',
315 'mt': 'America/Denver',
316 'gmt': 'Etc/GMT',
317 'gt': 'Etc/GMT',
318 'bst': 'Europe/London',
319 'ist': 'Europe/Dublin',
320 'cet': 'Europe/Berlin',
321 # Technically IST (Indian Standard Time), but that's the same as Ireland
322 'it': 'Asia/Kolkata',
323 'jst': 'Asia/Tokyo',
324 'kst': 'Asia/Seoul',
325 'sgt': 'Asia/Singapore',
326 'aet': 'Australia/Sydney',
327 'brt': 'America/Sao_Paulo',
328 'nzst': 'Pacific/Auckland',
329 'utc': 'Etc/UTC',
330 }
331 TAG_RESTRICTIONS = ["(", ")", "[", "]", ",", ";", "=", "/", "-"]
332 # mapping to ['u28', 'u29', 'u5b', 'u5d', 'u2c', 'u3b', 'u3d', 'u2f', "u2d"]
333 TAG_RESTRICTIONS_ESCAPE = ["u" + hex(ord(c))[2:] for c in TAG_RESTRICTIONS]
335 z_names = list(zoneinfo.get_zonefile_instance().zones)
336 non_title_case_zones = (
337 lambda aliases=TZ_ALIASES.keys(), z_names=z_names:
338 {z.lower(): z for z in z_names
339 if z.title() != z and z.lower() not in aliases})()
340 TZ_ALIASES.update(non_title_case_zones)
342 def __init__(self, data, manager=None):
343 super(Time, self).__init__(data, manager)
344 self.default_tz = self.data.get('default_tz', self.DEFAULT_TZ)
345 self.weekends = self.data.get('weekends', True)
346 self.weekends_only = self.data.get('weekends-only', False)
347 self.opt_out = self.data.get('opt-out', False)
348 self.tag_key = self.data.get('tag', self.DEFAULT_TAG).lower()
349 # we originally had fallback_schedule, but the code was looking for
350 # fallback-schedule, we want to deprecate the underscore form.
351 self.fallback_schedule = (
352 self.data.get('fallback-schedule') or
353 self.data.get('fallback_schedule')
354 )
355 self.default_schedule = self.get_default_schedule()
356 self.parser = ScheduleParser(self.default_schedule)
358 self.id_key = None
360 self.opted_out = []
361 self.parse_errors = []
362 self.enabled_count = 0
364 def validate(self):
365 if self.get_tz(self.default_tz) is None:
366 raise PolicyValidationError(
367 "Invalid timezone specified %s" % (
368 self.default_tz))
369 hour = self.data.get("%shour" % self.time_type, self.DEFAULT_HR)
370 if hour not in self.parser.VALID_HOURS:
371 raise PolicyValidationError(
372 "Invalid hour specified %s" % (hour,))
373 if 'skip-days' in self.data and 'skip-days-from' in self.data:
374 raise PolicyValidationError(
375 "Cannot specify two sets of skip days %s" % (
376 self.data,))
377 return self
379 def process(self, resources, event=None):
380 resources = super(Time, self).process(resources)
381 if self.parse_errors and self.manager and self.manager.ctx.log_dir:
382 self.log.warning("parse errors %d", len(self.parse_errors))
383 with open(join(
384 self.manager.ctx.log_dir, 'parse_errors.json'), 'w') as fh:
385 dumps(self.parse_errors, fh=fh)
386 self.parse_errors = []
387 if self.opted_out and self.manager and self.manager.ctx.log_dir:
388 self.log.debug("disabled count %d", len(self.opted_out))
389 with open(join(
390 self.manager.ctx.log_dir, 'opted_out.json'), 'w') as fh:
391 dumps(self.opted_out, fh=fh)
392 self.opted_out = []
393 return resources
395 def __call__(self, i):
396 value = self.get_tag_value(i)
397 # Sigh delayed init, due to circle dep, process/init would be better
398 # but unit testing is calling this direct.
399 if self.id_key is None:
400 self.id_key = (
401 self.manager is None and 'InstanceId' or self.manager.get_model().id)
403 # The resource tag is not present, if we're not running in an opt-out
404 # mode, we're done.
405 if value is False:
406 if not self.opt_out:
407 return False
408 value = "" # take the defaults
410 # Resource opt out, track and record
411 if 'off' == value:
412 self.opted_out.append(i)
413 return False
414 else:
415 self.enabled_count += 1
417 try:
418 return self.process_resource_schedule(i, value, self.time_type)
419 except Exception:
420 log.exception(
421 "%s failed to process resource:%s value:%s",
422 self.__class__.__name__, i[self.id_key], value)
423 return False
425 def process_resource_schedule(self, i, value, time_type):
426 """Does the resource tag schedule and policy match the current time."""
427 rid = i[self.id_key]
428 # this is to normalize trailing semicolons which when done allows
429 # dateutil.parser.parse to process: value='off=(m-f,1);' properly.
430 # before this normalization, some cases would silently fail.
431 value = ';'.join(filter(None, value.split(';')))
432 if self.parser.has_resource_schedule(value, time_type):
433 schedule = self.parser.parse(value)
434 elif self.parser.keys_are_valid(value):
435 # respect timezone from tag
436 raw_data = self.parser.raw_data(value)
437 if 'tz' in raw_data:
438 schedule = dict(self.default_schedule)
439 schedule['tz'] = raw_data['tz']
440 else:
441 schedule = self.default_schedule
442 else:
443 schedule = None
444 if schedule is None:
445 log.warning(
446 "Invalid schedule on resource:%s value:%s", rid, value)
447 self.parse_errors.append((rid, value))
448 return False
449 tz = self.get_tz(schedule['tz'])
450 if not tz:
451 log.warning(
452 "Could not resolve tz on resource:%s value:%s", rid, value)
453 self.parse_errors.append((rid, value))
454 return False
455 now = datetime.datetime.now(tz).replace(
456 minute=0, second=0, microsecond=0)
457 now_str = now.strftime("%Y-%m-%d")
458 if 'skip-days-from' in self.data:
459 values = ValuesFrom(self.data['skip-days-from'], self.manager)
460 self.skip_days = values.get_values()
461 else:
462 self.skip_days = self.data.get('skip-days', [])
463 if now_str in self.skip_days:
464 return False
465 return self.match(now, schedule)
467 def match(self, now, schedule):
468 time = schedule.get(self.time_type, ())
469 for item in time:
470 days, hour = item.get("days"), item.get('hour')
471 if now.weekday() in days and now.hour == hour:
472 return True
473 return False
475 def get_tag_value(self, i):
476 """Get the resource's tag value specifying its schedule."""
477 # Look for the tag, Normalize tag key and tag value
478 found = self.fallback_schedule
479 for t in i.get('Tags', ()):
480 if t['Key'].lower() == self.tag_key:
481 found = t['Value']
482 break
483 # NOTE for GCP resources, eg sql-instance
484 if found == self.fallback_schedule and 'labels' in i:
485 found = i.get('labels', {}).get(self.tag_key) or found
486 if found in (False, None):
487 return False
488 # enforce utf8, or do translate tables via unicode ord mapping
489 value = found.lower().encode('utf8').decode('utf8')
490 value = self.unescape_tag_restrictions(value)
491 # Some folks seem to be interpreting the docs quote marks as
492 # literal for values.
493 value = value.strip("'").strip('"')
494 return value
496 @classmethod
497 def unescape_tag_restrictions(cls, value: str):
498 for i, c in enumerate(cls.TAG_RESTRICTIONS_ESCAPE):
499 value = value.replace(c, cls.TAG_RESTRICTIONS[i])
500 return value
502 @classmethod
503 def get_tz(cls, tz):
504 found = cls.TZ_ALIASES.get(tz)
505 if found:
506 return tzutil.gettz(found)
507 return tzutil.gettz(tz.title())
509 def get_default_schedule(self):
510 raise NotImplementedError("use subclass")
513class OffHour(Time):
515 schema = type_schema(
516 'offhour', rinherit=Time.schema, required=['offhour', 'default_tz'],
517 offhour={'type': 'integer', 'minimum': 0, 'maximum': 23})
518 time_type = "off"
520 DEFAULT_HR = 19
522 def get_default_schedule(self):
523 default = {'tz': self.default_tz, self.time_type: [
524 {'hour': self.data.get(
525 "%shour" % self.time_type, self.DEFAULT_HR)}]}
526 if self.weekends_only:
527 default[self.time_type][0]['days'] = [4]
528 elif self.weekends:
529 default[self.time_type][0]['days'] = tuple(range(5))
530 else:
531 default[self.time_type][0]['days'] = tuple(range(7))
532 return default
535class OnHour(Time):
537 schema = type_schema(
538 'onhour', rinherit=Time.schema, required=['onhour', 'default_tz'],
539 onhour={'type': 'integer', 'minimum': 0, 'maximum': 23})
540 time_type = "on"
542 DEFAULT_HR = 7
544 def get_default_schedule(self):
545 default = {'tz': self.default_tz, self.time_type: [
546 {'hour': self.data.get(
547 "%shour" % self.time_type, self.DEFAULT_HR)}]}
548 if self.weekends_only:
549 # turn on monday
550 default[self.time_type][0]['days'] = [0]
551 elif self.weekends:
552 default[self.time_type][0]['days'] = tuple(range(5))
553 else:
554 default[self.time_type][0]['days'] = tuple(range(7))
555 return default
558class ScheduleParser:
559 """Parses tag values for custom on/off hours schedules.
561 At the minimum the ``on`` and ``off`` values are required. Each of
562 these must be seperated by a ``;`` in the format described below.
564 **Schedule format**::
566 # up mon-fri from 7am-7pm; eastern time
567 off=(M-F,19);on=(M-F,7)
568 # up mon-fri from 6am-9pm; up sun from 10am-6pm; pacific time
569 off=[(M-F,21),(U,18)];on=[(M-F,6),(U,10)];tz=pt
571 **Possible values**:
573 +------------+----------------------+
574 | field | values |
575 +============+======================+
576 | days | M, T, W, H, F, S, U |
577 +------------+----------------------+
578 | hours | 0, 1, 2, ..., 22, 23 |
579 +------------+----------------------+
581 Days can be specified in a range (ex. M-F).
583 If the timezone is not supplied, it is assumed ET (eastern time), but this
584 default can be configurable.
586 **Parser output**:
588 The schedule parser will return a ``dict`` or ``None`` (if the schedule is
589 invalid)::
591 # off=[(M-F,21),(U,18)];on=[(M-F,6),(U,10)];tz=pt
592 {
593 off: [
594 { days: "M-F", hour: 21 },
595 { days: "U", hour: 18 }
596 ],
597 on: [
598 { days: "M-F", hour: 6 },
599 { days: "U", hour: 10 }
600 ],
601 tz: "pt"
602 }
604 """
606 DAY_MAP = {'m': 0, 't': 1, 'w': 2, 'h': 3, 'f': 4, 's': 5, 'u': 6}
607 VALID_HOURS = tuple(range(24))
609 def __init__(self, default_schedule):
610 self.default_schedule = default_schedule
611 self.cache = {}
613 @staticmethod
614 def raw_data(tag_value):
615 """convert the tag to a dictionary, taking values as is
617 This method name and purpose are opaque... and not true.
618 """
619 data = {}
620 pieces = []
621 for p in tag_value.split(' '):
622 pieces.extend(p.split(';'))
623 # parse components
624 for piece in pieces:
625 kv = piece.split('=')
626 # components must by key=value
627 if not len(kv) == 2:
628 continue
629 key, value = kv
630 data[key] = value
631 return data
633 def keys_are_valid(self, tag_value):
634 """test that provided tag keys are valid"""
635 for key in ScheduleParser.raw_data(tag_value):
636 if key not in ('on', 'off', 'tz'):
637 return False
638 return True
640 def parse(self, tag_value):
641 # check the cache
642 if tag_value in self.cache:
643 return self.cache[tag_value]
645 schedule = {}
647 if not self.keys_are_valid(tag_value):
648 return None
649 # parse schedule components
650 pieces = tag_value.split(';')
651 for piece in pieces:
652 kv = piece.split('=')
653 # components must by key=value
654 if not len(kv) == 2:
655 return None
656 key, value = kv
657 if key != 'tz':
658 value = self.parse_resource_schedule(value)
659 if value is None:
660 return None
661 schedule[key] = value
663 # add default timezone, if none supplied or blank
664 if not schedule.get('tz'):
665 schedule['tz'] = self.default_schedule['tz']
667 # cache
668 self.cache[tag_value] = schedule
669 return schedule
671 @staticmethod
672 def has_resource_schedule(tag_value, time_type):
673 raw_data = ScheduleParser.raw_data(tag_value)
674 # note time_type is set to 'on' or 'off' and raw_data is a dict
675 return time_type in raw_data
677 def parse_resource_schedule(self, lexeme):
678 parsed = []
679 exprs = brackets_removed(lexeme).split(',(')
680 for e in exprs:
681 tokens = parens_removed(e).split(',')
682 # custom hours must have two parts: (<days>, <hour>)
683 if not len(tokens) == 2:
684 return None
685 if not tokens[1].isdigit():
686 return None
687 hour = int(tokens[1])
688 if hour not in self.VALID_HOURS:
689 return None
690 days = self.expand_day_range(tokens[0])
691 if not days:
692 return None
693 parsed.append({'days': days, 'hour': hour})
694 return parsed
696 def expand_day_range(self, days):
697 # single day specified
698 if days in self.DAY_MAP:
699 return [self.DAY_MAP[days]]
700 day_range = [d for d in map(self.DAY_MAP.get, days.split('-'))
701 if d is not None]
702 if not len(day_range) == 2:
703 return None
704 # support wrap around days aka friday-monday = 4,5,6,0
705 if day_range[0] > day_range[1]:
706 return list(range(day_range[0], 7)) + list(range(day_range[1] + 1))
707 return list(range(min(day_range), max(day_range) + 1))