File: trigger.rb

package info (click to toggle)
puppet-agent 8.10.0-5
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 27,392 kB
  • sloc: ruby: 286,820; sh: 492; xml: 116; makefile: 88; cs: 68
file content (823 lines) | stat: -rw-r--r-- 34,394 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
# frozen_string_literal: true

require 'time'

# module PuppetX
module PuppetX; end

# module PuppetX::PuppetLabs
module PuppetX::PuppetLabs; end

# @api private
# PuppetX::PuppetLabs::ScheduledTask module
module PuppetX::PuppetLabs::ScheduledTask
  # @api private
  # PuppetX::PuppetLabs::ScheduledTask::Trigger module
  module Trigger
    # Gets or sets the amount of time that is allowed to complete the task.
    class Duration
      # From https://msdn.microsoft.com/en-us/library/windows/desktop/aa381850(v=vs.85).aspx
      # https://en.wikipedia.org/wiki/ISO_8601#Durations
      #
      # The format for this string is PnYnMnDTnHnMnS, where nY is the number of years, nM is the number of months,
      # nD is the number of days, 'T' is the date/time separator, nH is the number of hours, nM is the number of minutes,
      # and nS is the number of seconds (for example, PT5M specifies 5 minutes and P1M4DT2H5M specifies one month,
      # four days, two hours, and five minutes)
      def self.to_hash(duration)
        regex = %r{^P((?'year'\d+)Y)?((?'month'\d+)M)?((?'day'\d+)D)?(T((?'hour'\d+)H)?((?'minute'\d+)M)?((?'second'\d+)S)?)?$}

        matches = regex.match(duration)
        return nil if matches.nil?

        {
          year: matches['year'],
          month: matches['month'],
          day: matches['day'],
          minute: matches['minute'],
          hour: matches['hour'],
          second: matches['second'],
        }
      end

      # Converts a hash in a time format to seconds
      def self.hash_to_seconds(value)
        return 0 if value.nil?
        time = 0
        # Note - the Year and Month calculations are approximate
        time += value[:year].to_i   * (365.2422 * 24 * 60**2)      unless value[:year].nil?
        time += value[:month].to_i  * (365.2422 * 2 * 60**2)       unless value[:month].nil?
        time += value[:day].to_i    * 24 * 60**2                   unless value[:day].nil?
        time += value[:hour].to_i   * 60**2                        unless value[:hour].nil?
        time += value[:minute].to_i * 60                           unless value[:minute].nil?
        time += value[:second].to_i                                unless value[:second].nil?

        time.to_i
      end

      # Converts a hash in a time format to minutes
      def self.to_minutes(value)
        return 0 if value.nil?
        return 0 unless value.is_a?(String)
        return 0 if value.empty?

        duration = hash_to_seconds(to_hash(value))

        duration / 60
      end
    end

    # Converts a datetime to local time with no timezone
    def iso8601_datetime_to_local(value)
      return nil if value.nil?
      raise ArgumentError, 'value must be a String' unless value.is_a?(String)
      return nil if value.empty?

      # defaults to parsing as local with no timezone passed
      Time.parse(value).getlocal
    end
    module_function :iso8601_datetime_to_local

    # Scheduled Task manifest
    class Manifest
      # Valid Keys
      ValidKeys = [ # rubocop:disable Naming/ConstantName
        'index',
        'enabled',
        'schedule',
        'start_date',
        'start_time',
        'every',
        'months',
        'on',
        'which_occurrence',
        'day_of_week',
        'minutes_interval',
        'minutes_duration',
        'user_id',
        'disable_time_zone_synchronization',
      ].freeze

      # Valid Schedule Keys
      ValidScheduleKeys = [ # rubocop:disable Naming/ConstantName
        'once',
        'daily',
        'weekly',
        'monthly',
        'boot',
        'logon',
      ].freeze

      # https://msdn.microsoft.com/en-us/library/system.datetime.fromoadate(v=vs.110).aspx
      # d must be a value between -657435.0 (1/1/1753) through 2958465.99999999 (12/31/9999 11:59:59)
      MINIMUM_TRIGGER_DATE = Time.local(1753, 1, 1)

      # Formats time in a %Y-%-m-%-d format
      def self.format_date(time)
        time.strftime('%Y-%-m-%-d')
      end

      # Formats time to the ISO8601 %H:%M format
      def self.format_time(time)
        # equivalent to the ISO8601 %H:%M
        time.strftime('%R')
      end

      # Returns the default trigger setting for a specified schedule
      def self.default_trigger_settings_for(schedule = 'once')
        case schedule
        when 'once'
          {
            'schedule' => 'once',
          }
        when 'daily'
          {
            'schedule' => 'daily',
            'every'    => 1,
          }
        when 'weekly'
          {
            'schedule'     => 'weekly',
            'days_of_week' => V2::Day.names,
            'every'        => 1,
          }
        when 'monthly'
          {
            'schedule' => 'monthly',
            'months'   => V2::Month.indexes,
            'days'     => 0,
          }
        end
      end

      # Returns the default trigger for a specified schedule
      def self.default_trigger_for(schedule = 'once')
        now = Time.now
        {
          'enabled'             => true,
          'minutes_interval'    => 0,
          'minutes_duration'    => 0,
          'start_date'          => format_date(now),
          'start_time'          => format_time(now),
        }.merge(default_trigger_settings_for(schedule))
      end

      # Checks if a given string is in a valid time format
      def self.time_valid?(time)
        Time.parse("2016-5-1 #{time}")
        true
      rescue
        false
      end

      # canonicalize given manifest hash
      # throws errors if hash structure is invalid
      # does not throw errors when invalid types are specified
      # returns original object with downcased keys
      def self.canonicalize_and_validate(manifest_hash)
        raise TypeError unless manifest_hash.is_a?(Hash)
        manifest_hash = downcase_keys(manifest_hash)

        # check for valid key usage
        invalid_keys = manifest_hash.keys - ValidKeys
        raise ArgumentError, "Unknown trigger option(s): #{Puppet::Parameter.format_value_for_display(invalid_keys)}" unless invalid_keys.empty?

        unless ValidScheduleKeys.include?(manifest_hash['schedule'])
          raise ArgumentError, "Unknown schedule type: #{manifest_hash['schedule'].inspect}"
        end

        required = V2::EVENT_BASED_TRIGGER_MAP.value?(manifest_hash['schedule']) ? [] : ['start_time']

        required.each do |field|
          next if manifest_hash.key?(field)
          raise ArgumentError, "Must specify '#{field}' when defining a trigger"
        end

        start_time_valid = time_valid?(manifest_hash['start_time'])
        raise ArgumentError, "Invalid start_time value: #{manifest_hash['start_time']}" unless start_time_valid
        # The start_time must be canonicalized to match the format that the rest of the code expects
        manifest_hash['start_time'] = format_time(Time.parse(manifest_hash['start_time'])) unless manifest_hash['start_time'].nil?

        # specific setting rules for schedule types
        case manifest_hash['schedule']
        when 'monthly'
          if manifest_hash.key?('on')
            if manifest_hash.key?('day_of_week') || manifest_hash.key?('which_occurrence')
              raise ArgumentError, "Neither 'day_of_week' nor 'which_occurrence' can be specified when creating a monthly date-based trigger"
            end
          elsif manifest_hash.key?('which_occurrence') || manifest_hash.key?('day_of_week')
            raise ArgumentError, 'which_occurrence cannot be specified as an array' if manifest_hash['which_occurrence'].is_a?(Array)

            ['day_of_week', 'which_occurrence'].each do |field|
              next if manifest_hash.key?(field)
              raise ArgumentError, "#{field} must be specified when creating a monthly day-of-week based trigger"
            end
          else
            raise ArgumentError, "Don't know how to create a 'monthly' schedule with the options: #{manifest_hash.keys.sort.join(', ')}"
          end
        when 'once'
          raise ArgumentError, "Must specify 'start_date' when defining a one-time trigger" unless manifest_hash['start_date']
        end

        if manifest_hash.key?('every')
          every = begin
                    Integer(manifest_hash['every'])
                  rescue
                    nil
                  end
          raise ArgumentError, "Invalid every value: #{manifest_hash['every']}" if every.nil?
          manifest_hash['every'] = every
        end

        # day of week uses valid names (for weekly / monthly schedules)
        if manifest_hash.key?('day_of_week')
          manifest_hash['day_of_week'] = [manifest_hash['day_of_week']].flatten
          invalid_days = manifest_hash['day_of_week'] - V2::Day.names
          raise ArgumentError, "Unknown day_of_week values(s): #{invalid_days}" unless invalid_days.empty?
        end

        if manifest_hash.key?('months')
          manifest_hash['months'] = [manifest_hash['months']].flatten
          invalid_months = manifest_hash['months'] - V2::Month.indexes
          raise ArgumentError, "Unknown months values(s): #{invalid_months}" unless invalid_months.empty?
        end

        # monthly
        if manifest_hash.key?('on')
          manifest_hash['on'] = [manifest_hash['on']].flatten
          invalid_on = manifest_hash['on'] - ((1..31).to_a + ['last'])
          raise ArgumentError, "Unknown on values(s): #{invalid_on}" unless invalid_on.empty?
        end

        # monthly day of week
        if manifest_hash.key?('which_occurrence')
          # NOTE: cannot canonicalize to an array here (yet!) because more code changes required
          invalid_which_occurrence = [manifest_hash['which_occurrence']].flatten - V2::WeeksOfMonth::WEEK_OF_MONTH_CONST_MAP.keys
          raise ArgumentError, "Unknown which_occurrence values(s): #{invalid_which_occurrence}" unless invalid_which_occurrence.empty?
        end

        # duration set with / without interval
        if manifest_hash['minutes_duration']
          duration = Integer(manifest_hash['minutes_duration'])
          # defaults to -1 when unspecified
          interval = Integer(manifest_hash['minutes_interval'] || -1)
          if duration != 0 && duration <= interval
            raise ArgumentError, 'minutes_duration must be an integer greater than minutes_interval and equal to or greater than 0'
          end
        end

        # interval set with / without duration
        if manifest_hash['minutes_interval']
          interval = Integer(manifest_hash['minutes_interval'])
          # interval < 0
          if interval.negative?
            raise ArgumentError, 'minutes_interval must be an integer greater or equal to 0'
          end

          # defaults to a day when unspecified
          duration = Integer(manifest_hash['minutes_duration'] || 1440)

          if interval.positive? && interval >= duration
            raise ArgumentError, 'minutes_interval cannot be set without minutes_duration also being set to a number greater than 0'
          end
        end
        manifest_hash['minutes_interval'] = interval if interval
        manifest_hash['minutes_duration'] = duration if duration

        if manifest_hash['start_date']
          start_date = Time.parse(manifest_hash['start_date'] + ' 00:00')
          raise ArgumentError, "start_date must be on or after #{format_date(MINIMUM_TRIGGER_DATE)}" unless start_date >= MINIMUM_TRIGGER_DATE
          manifest_hash['start_date'] = format_date(start_date)
        end

        if manifest_hash['user_id']
          raise 'user_id can only be verified on a Windows Operating System' unless Puppet.features.microsoft_windows?
          # If the user specifies undef in the manifest, coerce that into an empty string;
          # This is what scheduled tasks expects to receive for 'all users'
          user_id = (manifest_hash['user_id'] == :undef) ? '' : manifest_hash['user_id']
          # If the user cannot be resolved, the task will fail to save with a vague error
          raise ArgumentError, "Invalid user, specified user must exist: #{user_id}" unless Puppet::Util::Windows::SID.name_to_sid(user_id)
          # To keep the internal comparison consistent but human readable, convert from
          # the user id specified in the manifest to the canonical representation of that
          # account's SID on the system. If the specified user_id is null/empty, leave it
          # that way so the task runs whenever _any_ user logs on.
          user_id = Puppet::Util::Windows::SID.sid_to_name(Puppet::Util::Windows::SID.name_to_sid(user_id)) unless user_id == ''
          manifest_hash['user_id'] = user_id
        end

        manifest_hash
      end

      # converts all keys to lowercase
      def self.downcase_keys(hash)
        rekeyed = hash.map do |k, v|
          [k.is_a?(String) ? k.downcase : k, v.is_a?(Hash) ? downcase_keys(v) : v]
        end
        Hash[rekeyed]
      end

      private_class_method :downcase_keys
    end

    # Task Scheduler API V2
    class V2
      # Gets or sets the days of the week in which the task runs.
      class Day
        # V1 WEEKLY structure
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa384014(v=vs.85).aspx
        # V2 IWeeklyTrigger::DaysOfWeek / IMonthlyDOWTrigger::DaysOfWeek
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa381905(v=vs.85).aspx

        # The task will run on Sunday.
        TASK_SUNDAY       = 0x1

        # The task will run on Monday.
        TASK_MONDAY       = 0x2

        # The task will run on Tuesday.
        TASK_TUESDAY      = 0x4

        # The task will run on Wednesday.
        TASK_WEDNESDAY    = 0x8

        # The task will run on Thursday.
        TASK_THURSDAY     = 0x10

        # The task will run on Friday.
        TASK_FRIDAY       = 0x20

        # The task will run on Saturday.
        TASK_SATURDAY     = 0x40

        # 7 bits for 7 possible days to set
        MAX_VALUE = 0b1111111

        # Day name to HEX map
        DAY_CONST_MAP = {
          'sun'   => TASK_SUNDAY,
          'mon'   => TASK_MONDAY,
          'tues'  => TASK_TUESDAY,
          'wed'   => TASK_WEDNESDAY,
          'thurs' => TASK_THURSDAY,
          'fri'   => TASK_FRIDAY,
          'sat'   => TASK_SATURDAY,
        }.freeze

        # Returns day names
        def self.names
          @names ||= DAY_CONST_MAP.keys.freeze
        end

        # Returns day task values
        def self.values
          @values ||= DAY_CONST_MAP.values.freeze
        end

        # Converts day names to bitmask
        def self.names_to_bitmask(day_names)
          day_names = [day_names].flatten
          invalid_days = day_names - DAY_CONST_MAP.keys
          raise ArgumentError, "Days_of_week value #{invalid_days.join(', ')} is invalid. Expected sun, mon, tue, wed, thu, fri or sat." unless invalid_days.empty?

          day_names.reduce(0) { |bitmask, day| bitmask | DAY_CONST_MAP[day] }
        end

        # Converts bitmask to day names
        def self.bitmask_to_names(bitmask)
          bitmask = Integer(bitmask)
          if bitmask.negative? || bitmask > MAX_VALUE
            raise ArgumentError, "bitmask must be specified as an integer from 0 to #{MAX_VALUE.to_s(10)}"
          end

          DAY_CONST_MAP.values.each_with_object([]) do |day, names|
            names << DAY_CONST_MAP.key(day) if bitmask & day != 0
          end
        end
      end

      # Defines the day of the month the task will run.
      class Days
        # 32 bit mask, but only 31 days can be set.
        # This is contrary to the V2 IMonthlyTrigger::DaysOfMonth documentation
        # referenced below, but in testing, setting the last bit of the bit
        # mask does not set 'last' day of month as that documentation suggests.
        # Instead it results in an error. That feature will be handled instead
        # by the RunOnLastDayOfMonth property of the trigger object.
        # V2 IMonthlyTrigger::RunOnLastDayOfMonth
        # https://docs.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-imonthlytrigger-put_runonlastdayofmonth
        MAX_VALUE = 0b01111111111111111111111111111111

        # V1 MONTHLYDATE structure
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa381918(v=vs.85).aspx
        # V2 IMonthlyTrigger::DaysOfMonth
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa380735(v=vs.85).aspx
        def self.indexes_to_bitmask(day_indexes)
          if day_indexes.nil? || (day_indexes.is_a?(Hash) && day_indexes.empty?)
            raise TypeError, 'Day indexes value must not be nil or an empty hash.'
          end

          integer_days = Array(day_indexes).select { |i| i.is_a?(Integer) }
          invalid_days = integer_days.reject { |i| i.between?(1, 31) }

          unless invalid_days.empty?
            raise ArgumentError, "Day indexes value #{invalid_days.join(', ')} is invalid. Integers must be in the range 1-31"
          end
          integer_days.reduce(0) { |bitmask, day_index| bitmask | 1 << day_index - 1 }
        end

        # Converts bitmask to index
        def self.bitmask_to_indexes(bitmask, run_on_last_day_of_month = nil)
          bitmask = Integer(bitmask)
          if bitmask.negative? || bitmask > MAX_VALUE
            raise ArgumentError, "bitmask must be specified as an integer from 0 to #{MAX_VALUE.to_s(10)}"
          end

          indexes = bit_index(bitmask).map { |bit_index| bit_index + 1 }

          indexes << 'last' if run_on_last_day_of_month

          indexes
        end

        # Returns bit index
        def self.bit_index(bitmask)
          (0..31).select do |bit_index|
            bit_to_check = 1 << bit_index
            # given position is set in the bitmask
            (bitmask & bit_to_check) == bit_to_check
          end
        end

        def self.last_day_of_month?(day_indexes)
          invalid_day_names = Array(day_indexes).select { |i| i.is_a?(String) && (i != 'last') }
          unless invalid_day_names.empty?
            raise ArgumentError, "Only 'last' is allowed as a day name. All other values must be integers between 1 and 31."
          end
          Array(day_indexes).include? 'last'
        end
      end

      # Gets or sets the months of the year during which the task runs.
      class Month
        # V1 MONTHLYDATE structure
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa381918(v=vs.85).aspx
        # V2 IMonthlyTrigger::MonthsOfYear / IMonthlyDOWTrigger::MonthsOfYear
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa380736(v=vs.85).aspx

        # The task will run in January.
        TASK_JANUARY      = 0x1

        # The task will run in February.
        TASK_FEBRUARY     = 0x2

        # The task will run in March.
        TASK_MARCH        = 0x4

        # The task will run in April.
        TASK_APRIL        = 0x8

        # The task will run in May.
        TASK_MAY          = 0x10

        # The task will run in June.
        TASK_JUNE         = 0x20

        # The task will run in July.
        TASK_JULY         = 0x40

        # The task will run in August.
        TASK_AUGUST       = 0x80

        # The task will run in September.
        TASK_SEPTEMBER    = 0x100

        # The task will run in October.
        TASK_OCTOBER      = 0x200

        # The task will run in November.
        TASK_NOVEMBER     = 0x400

        # The task will run in December.
        TASK_DECEMBER     = 0x800

        # 12 bits for 12 possible months to set
        MAX_VALUE = 0b111111111111

        # Month number to HEX map
        MONTHNUM_CONST_MAP = {
          1  => TASK_JANUARY,
          2  => TASK_FEBRUARY,
          3  => TASK_MARCH,
          4  => TASK_APRIL,
          5  => TASK_MAY,
          6  => TASK_JUNE,
          7  => TASK_JULY,
          8  => TASK_AUGUST,
          9  => TASK_SEPTEMBER,
          10 => TASK_OCTOBER,
          11 => TASK_NOVEMBER,
          12 => TASK_DECEMBER,
        }.freeze

        # Returns month indexes
        def self.indexes
          @indexes ||= MONTHNUM_CONST_MAP.keys.freeze
        end

        # Converts indexes to bitmask
        def self.indexes_to_bitmask(month_indexes)
          month_indexes = [month_indexes].flatten.map do |m|
            Integer(m)
          rescue
            m
          end
          invalid_months = month_indexes - MONTHNUM_CONST_MAP.keys
          raise ArgumentError, 'Month must be specified as an integer in the range 1-12' unless invalid_months.empty?

          month_indexes.reduce(0) { |bitmask, month_index| bitmask | MONTHNUM_CONST_MAP[month_index] }
        end

        # Converts bitmask to indexes
        def self.bitmask_to_indexes(bitmask)
          bitmask = Integer(bitmask)
          if bitmask.negative? || bitmask > MAX_VALUE
            raise ArgumentError, "bitmask must be specified as an integer from 0 to #{MAX_VALUE.to_s(10)}"
          end

          MONTHNUM_CONST_MAP.values.each_with_object([]) do |day, indexes|
            indexes << MONTHNUM_CONST_MAP.key(day) if bitmask & day != 0
          end
        end
      end

      # Gets or sets the weeks of the month during which the task runs.
      class WeeksOfMonth
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa380733(v=vs.85).aspx
        # First week of the month to HEX
        FIRST = 0x01

        # Second week of the month to HEX
        SECOND  = 0x02

        # Third week of the month to HEX
        THIRD   = 0x04

        # Forth week of the month to HEX
        FOURTH  = 0x08

        # Last week of the month to HEX
        LAST    = 0x10

        # 5 bits for 5 possible weeks to set
        MAX_VALUE = 0b11111

        # Week of the month to HEX map
        WEEK_OF_MONTH_CONST_MAP = {
          'first'  => FIRST,
          'second' => SECOND,
          'third'  => THIRD,
          'fourth' => FOURTH,
          'last'   => LAST,
        }.freeze

        # Converts names to bitmask
        def self.names_to_bitmask(week_names)
          week_names = [week_names].flatten
          invalid_weeks = week_names - WEEK_OF_MONTH_CONST_MAP.keys
          raise ArgumentError, "week_names value #{invalid_weeks.join(', ')} is invalid. Expected first, second, third, fourth or last." unless invalid_weeks.empty?

          week_names.reduce(0) { |bitmask, day| bitmask | WEEK_OF_MONTH_CONST_MAP[day] }
        end

        # Converts bitmask to names
        def self.bitmask_to_names(bitmask)
          bitmask = Integer(bitmask)
          if bitmask.negative? || bitmask > MAX_VALUE
            raise ArgumentError, "bitmask must be specified as an integer from 0 to #{MAX_VALUE.to_s(10)}"
          end

          WEEK_OF_MONTH_CONST_MAP.values.each_with_object([]) do |week, names|
            names << WEEK_OF_MONTH_CONST_MAP.key(week) if bitmask & week != 0
          end
        end
      end

      # Defines the type of triggers that can be used by tasks.
      class Type
        # https://docs.microsoft.com/en-us/windows/win32/api/taskschd/ne-taskschd-task_trigger_type2
        # Triggers the task when a specific event occurs.
        TASK_TRIGGER_EVENT                 = 0

        # Triggers the task at a specific time of day.
        TASK_TRIGGER_TIME                  = 1

        # Triggers the task on a daily schedule. For example, the task starts at a specific time every day, every-other day, every third day, and so on.
        TASK_TRIGGER_DAILY                 = 2

        # Triggers the task on a weekly schedule. For example, the task starts at 8:00 AM on a specific day every week or other week.
        TASK_TRIGGER_WEEKLY                = 3

        # Triggers the task on a monthly schedule. For example, the task starts on specific days of specific months.
        TASK_TRIGGER_MONTHLY               = 4

        # Triggers the task on a monthly day-of-week schedule. For example, the task starts on a specific days of the week, weeks of the month, and months of the year.
        TASK_TRIGGER_MONTHLYDOW            = 5

        # Triggers the task when the computer goes into an idle state.
        TASK_TRIGGER_IDLE                  = 6

        # Triggers the task when the task is registered.
        TASK_TRIGGER_REGISTRATION          = 7

        # Triggers the task when the computer boots.
        TASK_TRIGGER_BOOT                  = 8

        # Triggers the task when a specific user logs on.
        TASK_TRIGGER_LOGON                 = 9

        # Triggers the task when a specific session state changes.
        TASK_TRIGGER_SESSION_STATE_CHANGE  = 11

        # Custom trigger
        TASK_TRIGGER_CUSTOM_TRIGGER_01     = 12
      end

      # https://docs.microsoft.com/en-us/windows/win32/api/taskschd/ne-taskschd-task_session_state_change_type
      class SessionStateChangeType
        # Terminal Server console connection state change. For example, when you connect to a user session on the local computer by switching users on the computer.
        TASK_CONSOLE_CONNECT      = 1

        # Terminal Server console disconnection state change. For example, when you disconnect to a user session on the local computer by switching users on the computer.
        TASK_CONSOLE_DISCONNECT   = 2

        # Terminal Server remote connection state change. For example, when a user connects to a user session by using the Remote Desktop Connection program from a remote computer.
        TASK_REMOTE_CONNECT       = 3

        # Terminal Server remote disconnection state change. For example, when a user disconnects from a user session while using the Remote Desktop Connection program from a remote computer.
        TASK_REMOTE_DISCONNECT    = 4

        # Terminal Server session locked state change. For example, this state change causes the task to run when the computer is locked.
        TASK_SESSION_LOCK         = 7

        # Terminal Server session unlocked state change. For example, this state change causes the task to run when the computer is unlocked.
        TASK_SESSION_UNLOCK       = 8
      end

      # Trigger type to day map
      SCHEDULE_BASED_TRIGGER_MAP = {
        Type::TASK_TRIGGER_DAILY      => 'daily',
        Type::TASK_TRIGGER_WEEKLY     => 'weekly',
        # NOTE: monthly uses context to determine MONTHLY or MONTHLYDOW
        Type::TASK_TRIGGER_MONTHLY    => 'monthly',
        Type::TASK_TRIGGER_MONTHLYDOW => 'monthly',
        Type::TASK_TRIGGER_TIME       => 'once',
      }.freeze

      # Event based trigger map
      EVENT_BASED_TRIGGER_MAP = {
        Type::TASK_TRIGGER_BOOT                 => 'boot',
        Type::TASK_TRIGGER_LOGON                => 'logon',
        # The triggers below are not yet supported.
        # Type::TASK_TRIGGER_EVENT                => 'event',
        # Type::TASK_TRIGGER_IDLE                 => 'idle',
        # Type::TASK_TRIGGER_REGISTRATION         => 'task_registered',
        # Type::TASK_TRIGGER_SESSION_STATE_CHANGE => 'session_state_change',
      }.freeze

      # Type manifest map
      TYPE_MANIFEST_MAP = SCHEDULE_BASED_TRIGGER_MAP.merge(EVENT_BASED_TRIGGER_MAP).freeze

      # Returns a type based on a manifest hash
      def self.type_from_manifest_hash(manifest_hash)
        # monthly schedule defaults to TASK_TRIGGER_MONTHLY unless...
        if manifest_hash['schedule'] == 'monthly' &&
           (manifest_hash.key?('which_occurrence') || manifest_hash.key?('day_of_week'))
          return Type::TASK_TRIGGER_MONTHLYDOW
        end

        TYPE_MANIFEST_MAP.key(manifest_hash['schedule'])
      end

      # Converts trigger to manifest hash
      def self.to_manifest_hash(i_trigger)
        if TYPE_MANIFEST_MAP[i_trigger.Type].nil?
          raise ArgumentError, _('Unknown trigger type %{type}') % { type: i_trigger.ole_type.to_s }
        end

        # StartBoundary and EndBoundary may be empty strings per V2 API
        start_boundary = Trigger.iso8601_datetime_to_local(i_trigger.StartBoundary)
        _end_boundary = Trigger.iso8601_datetime_to_local(i_trigger.EndBoundary)

        manifest_hash = {
          'start_date'                        => start_boundary ? Manifest.format_date(start_boundary) : '',
          'start_time'                        => start_boundary ? Manifest.format_time(start_boundary) : '',
          'enabled'                           => i_trigger.Enabled,
          'minutes_interval'                  => Duration.to_minutes(i_trigger.Repetition.Interval) || 0,
          'minutes_duration'                  => Duration.to_minutes(i_trigger.Repetition.Duration) || 0,
          'disable_time_zone_synchronization' => start_boundary ? !%r{(Z|[+-]\d\d:\d\d)$}.match?(i_trigger.StartBoundary) : false,
        }

        case i_trigger.Type
        when Type::TASK_TRIGGER_TIME
          manifest_hash['schedule'] = 'once'
        when Type::TASK_TRIGGER_DAILY
          manifest_hash['schedule'] = 'daily'
          manifest_hash['every'] = i_trigger.DaysInterval
        when Type::TASK_TRIGGER_WEEKLY
          manifest_hash.merge!('schedule'    => 'weekly',
                               'every'       => i_trigger.WeeksInterval,
                               'day_of_week' => Day.bitmask_to_names(i_trigger.DaysOfWeek))
        when Type::TASK_TRIGGER_MONTHLY
          manifest_hash.merge!('schedule' => 'monthly',
                               'months'   => Month.bitmask_to_indexes(i_trigger.MonthsOfYear),
                               'on'       => Days.bitmask_to_indexes(i_trigger.DaysOfMonth, i_trigger.RunOnLastDayOfMonth))
        when Type::TASK_TRIGGER_MONTHLYDOW
          occurrences = V2::WeeksOfMonth.bitmask_to_names(i_trigger.WeeksOfMonth)
          manifest_hash.merge!('schedule'         => 'monthly',
                               'months'           => Month.bitmask_to_indexes(i_trigger.MonthsOfYear),
                               # HACK: choose only the first week selected when converting - this LOSES information
                               'which_occurrence' => occurrences.first || '',
                               'day_of_week'      => Day.bitmask_to_names(i_trigger.DaysOfWeek))
          # MODULES-10101: We will need to evaluate whether the value 'last' has been applied to the WeekOfMonth
          # parameter by inspecting the value of Trigger::RunOnLastWeekOfMonth. See JIRA ticket for more details.
          manifest_hash['which_occurrence'] = 'last' if i_trigger.RunOnLastWeekOfMonth
        when Type::TASK_TRIGGER_BOOT
          manifest_hash['schedule'] = 'boot'
        when Type::TASK_TRIGGER_LOGON
          # Resolve the UserID unless it is an empty string, which represents all users.
          user_id = (i_trigger.UserId == '') ? '' : Puppet::Util::Windows::SID.sid_to_name(Puppet::Util::Windows::SID.name_to_sid(i_trigger.UserId))
          manifest_hash['schedule'] = 'logon'
          manifest_hash['user_id'] = user_id
        end

        manifest_hash
      end

      # Adds trigger to definition
      def self.append_trigger(definition, manifest_hash)
        manifest_hash = Trigger::Manifest.canonicalize_and_validate(manifest_hash)
        # create appropriate i_trigger based on 'schedule'
        i_trigger = definition.Triggers.Create(type_from_manifest_hash(manifest_hash))

        # Values for all Trigger Types
        if manifest_hash['minutes_interval']
          minutes_interval = manifest_hash['minutes_interval']
          if minutes_interval.positive?
            i_trigger.Repetition.Interval = "PT#{minutes_interval}M"
            # one day in minutes
            i_trigger.Repetition.Duration = 'PT1440M' unless manifest_hash.key?('minutes_duration')
          end
        end

        if manifest_hash['minutes_duration']
          minutes_duration = manifest_hash['minutes_duration']
          i_trigger.Repetition.Duration = "PT#{minutes_duration}M" unless minutes_duration.zero?
        end

        # manifests specify datetime in the local timezone, ITrigger accepts ISO8601
        # when start_date is null or missing, Time.parse returns today
        datetime_string = "#{manifest_hash['start_date']} #{manifest_hash['start_time']}"
        # Time.parse always assumes local time
        # If `disable_time_zone_synchronization` has been set to true then the timezone is removed from the start time
        unless datetime_string.strip.empty?
          start = if manifest_hash['disable_time_zone_synchronization'] && manifest_hash['disable_time_zone_synchronization'] == true
                    Time.parse(datetime_string).iso8601.gsub(%r{Z|(\+..\:..$)|(\-..\:..$)}, '')
                  else
                    Time.parse(datetime_string).iso8601
                  end
          i_trigger.StartBoundary = start
        end

        # ITrigger specific settings
        case i_trigger.Type
        when Type::TASK_TRIGGER_DAILY
          # https://msdn.microsoft.com/en-us/library/windows/desktop/aa446858(v=vs.85).aspx
          i_trigger.DaysInterval = Integer(manifest_hash['every'] || 1)

        when Type::TASK_TRIGGER_WEEKLY
          days_of_week = manifest_hash['day_of_week'] || Day.names
          # https://msdn.microsoft.com/en-us/library/windows/desktop/aa384019(v=vs.85).aspx
          i_trigger.DaysOfWeek = Day.names_to_bitmask(days_of_week)
          i_trigger.WeeksInterval = Integer(manifest_hash['every'] || 1)

        when Type::TASK_TRIGGER_MONTHLY
          # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382062(v=vs.85).aspx
          i_trigger.RunOnLastDayOfMonth = Days.last_day_of_month?(manifest_hash['on'])
          i_trigger.DaysOfMonth = Days.indexes_to_bitmask(manifest_hash['on'])
          i_trigger.MonthsOfYear = Month.indexes_to_bitmask(manifest_hash['months'] || Month.indexes)

        when Type::TASK_TRIGGER_MONTHLYDOW
          # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382055(v=vs.85).aspx
          i_trigger.DaysOfWeek = Day.names_to_bitmask(manifest_hash['day_of_week'])
          i_trigger.MonthsOfYear = Month.indexes_to_bitmask(manifest_hash['months'] || Month.indexes)
          # HACK: convert V1 week value to names, then back to V2 bitmask
          i_trigger.WeeksOfMonth = WeeksOfMonth.names_to_bitmask(manifest_hash['which_occurrence'])

        when Type::TASK_TRIGGER_LOGON
          i_trigger.UserId = manifest_hash['user_id']
        end

        nil
      end
    end
  end
end