Skip to content

Commit 46bf890

Browse files
Merge pull request #175 from RandomNoun7/MODULES-10893-fix-last-day-of-month
(MODULES-10893) Fix Last Day Of Month Trigger
2 parents 3290187 + 779908b commit 46bf890

File tree

5 files changed

+100
-43
lines changed

5 files changed

+100
-43
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,9 @@ For all triggers:
301301
Each month must be an integer between 1 and 12.
302302
* `on` (Required) — Which days of the month the task should run, as an array.
303303
Each day must be an integer between 1 and 31.
304+
* The string `last` may be used in the array for this property to trigger a
305+
task to run on the last day of each selected month. This feature is only
306+
available for tasks with compatibility level `2` or higher.
304307
* For monthly (by weekday) triggers:
305308
* `months` — Which months the task should run, as an array. Defaults to all months. Each month must be an integer between 1 and 12.
306309
* `day_of_week` (Required) — Which day of the week the task should run, as an array with only one element.

lib/puppet/type/scheduled_task.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def insync?(current)
163163
all months. Each month must be an integer between 1 and 12.
164164
* `on` **(Required)** --- Which days of the month the task should run,
165165
as an array. Each day must be an integer between 1 and 31 or the string `last`.
166+
The string `last` is only supported for tasks with level 2 compatibility or higher.
166167
* For `monthly` (by weekday) triggers:
167168
* `months` --- Which months the task should run, as an array. Defaults to
168169
all months. Each month must be an integer between 1 and 12.

lib/puppet_x/puppetlabs/scheduled_task/trigger.rb

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
# @api private
66
# PuppetX::PuppetLabs::ScheduledTask module
77
module PuppetX::PuppetLabs::ScheduledTask
8-
# This module is used to manage the triggers for the Task Scheduler V2 API
8+
# @api private
9+
# PuppetX::PuppetLabs::ScheduledTask::Trigger module
910
module Trigger
1011
# Gets or sets the amount of time that is allowed to complete the task.
1112
class Duration
@@ -389,42 +390,46 @@ def self.bitmask_to_names(bitmask)
389390

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

395403
# V1 MONTHLYDATE structure
396404
# https://msdn.microsoft.com/en-us/library/windows/desktop/aa381918(v=vs.85).aspx
397405
# V2 IMonthlyTrigger::DaysOfMonth
398406
# https://msdn.microsoft.com/en-us/library/windows/desktop/aa380735(v=vs.85).aspx
399407
def self.indexes_to_bitmask(day_indexes)
400-
day_indexes = [day_indexes].flatten.map do |m|
401-
# The special "day" of 'last' is represented by day "number"
402-
# 32. 'last' has the special meaning of "the last day of the
403-
# month", no matter how many days there are in the month.
404-
# raises if unable to convert
405-
(m.is_a?(String) && m.casecmp('last').zero?) ? 32 : Integer(m)
408+
if day_indexes.nil? || (day_indexes.is_a?(Hash) && day_indexes.empty?)
409+
raise TypeError, 'Day indexes value must not be nil or an empty hash.'
406410
end
407411

408-
invalid_days = day_indexes.reject { |i| i.between?(1, 32) }
412+
integer_days = Array(day_indexes).select { |i| i.is_a?(Integer) }
413+
invalid_days = integer_days.reject { |i| i.between?(1, 31) }
414+
409415
unless invalid_days.empty?
410-
raise ArgumentError, "Day indexes value #{invalid_days.join(', ')} is invalid. Integers must be in the range 1-31, or 'last'"
416+
raise ArgumentError, "Day indexes value #{invalid_days.join(', ')} is invalid. Integers must be in the range 1-31"
411417
end
412-
413-
day_indexes.reduce(0) { |bitmask, day_index| bitmask | 1 << day_index - 1 }
418+
integer_days.reduce(0) { |bitmask, day_index| bitmask | 1 << day_index - 1 }
414419
end
415420

416421
# Converts bitmask to index
417-
def self.bitmask_to_indexes(bitmask)
422+
def self.bitmask_to_indexes(bitmask, run_on_last_day_of_month = nil)
418423
bitmask = Integer(bitmask)
419424
if bitmask.negative? || bitmask > MAX_VALUE
420425
raise ArgumentError, "bitmask must be specified as an integer from 0 to #{MAX_VALUE.to_s(10)}"
421426
end
422427

423-
bit_index(bitmask).map do |bit_index|
424-
# Day 32 has the special meaning of "the last day of the
425-
# month", no matter how many days there are in the month.
426-
(bit_index == 31) ? 'last' : bit_index + 1
427-
end
428+
indexes = bit_index(bitmask).map { |bit_index| bit_index + 1 }
429+
430+
indexes << 'last' if run_on_last_day_of_month
431+
432+
indexes
428433
end
429434

430435
# Returns bit index
@@ -435,6 +440,14 @@ def self.bit_index(bitmask)
435440
(bitmask & bit_to_check) == bit_to_check
436441
end
437442
end
443+
444+
def self.last_day_of_month?(day_indexes)
445+
invalid_day_names = Array(day_indexes).select { |i| i.is_a?(String) && (i != 'last') }
446+
unless invalid_day_names.empty?
447+
raise ArgumentError, "Only 'last' is allowed as a day name. All other values must be integers between 1 and 31."
448+
end
449+
Array(day_indexes).include? 'last'
450+
end
438451
end
439452

440453
# Gets or sets the months of the year during which the task runs.
@@ -705,16 +718,16 @@ def self.to_manifest_hash(i_trigger)
705718
manifest_hash['schedule'] = 'daily'
706719
manifest_hash['every'] = i_trigger.DaysInterval
707720
when Type::TASK_TRIGGER_WEEKLY
708-
manifest_hash.merge!('schedule' => 'weekly',
721+
manifest_hash.merge!('schedule' => 'weekly',
709722
'every' => i_trigger.WeeksInterval,
710723
'day_of_week' => Day.bitmask_to_names(i_trigger.DaysOfWeek))
711724
when Type::TASK_TRIGGER_MONTHLY
712725
manifest_hash.merge!('schedule' => 'monthly',
713726
'months' => Month.bitmask_to_indexes(i_trigger.MonthsOfYear),
714-
'on' => Days.bitmask_to_indexes(i_trigger.DaysOfMonth))
727+
'on' => Days.bitmask_to_indexes(i_trigger.DaysOfMonth, i_trigger.RunOnLastDayOfMonth))
715728
when Type::TASK_TRIGGER_MONTHLYDOW
716729
occurrences = V2::WeeksOfMonth.bitmask_to_names(i_trigger.WeeksOfMonth)
717-
manifest_hash.merge!('schedule' => 'monthly',
730+
manifest_hash.merge!('schedule' => 'monthly',
718731
'months' => Month.bitmask_to_indexes(i_trigger.MonthsOfYear),
719732
# HACK: choose only the first week selected when converting - this LOSES information
720733
'which_occurrence' => occurrences.first || '',
@@ -783,6 +796,7 @@ def self.append_trigger(definition, manifest_hash)
783796

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

spec/acceptance/should_create_task_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,35 @@
2626
end
2727
end
2828

29+
it 'creates a task that runs on the last day of the month: taskscheduler_api2', tier_high: true do
30+
pp = <<-MANIFEST
31+
scheduled_task {'#{taskname}':
32+
ensure => present,
33+
compatibility => 2,
34+
command => 'c:\\\\windows\\\\system32\\\\notepad.exe',
35+
arguments => "foo bar baz",
36+
working_dir => 'c:\\\\windows',
37+
trigger => {
38+
schedule => 'monthly',
39+
start_time => '12:00',
40+
on => [1, 3, 'last'],
41+
},
42+
provider => 'taskscheduler_api2'
43+
}
44+
MANIFEST
45+
apply_manifest(pp, catch_failures: true)
46+
47+
# Verify the task exists
48+
query_cmd = "schtasks.exe /query /v /fo list /tn #{taskname}"
49+
run_shell(query_cmd) do |result|
50+
# Even though the bit mask value for '32' doesn't actuall work to set
51+
# `last` day of the month as a trigger, schtasks.exe still returns day 32
52+
# if `last` is set as a trigger day. My guess is that this is for backward
53+
# compatability with something in schtasks.exe
54+
expect(result.stdout).to match(%r{Days:.+01, 03, 32})
55+
end
56+
end
57+
2958
it 'creates a task if it does not exist: taskscheduler_api2', tier_high: true do
3059
pp = <<-MANIFEST
3160
scheduled_task {'#{taskname}':

spec/unit/puppet_x/puppetlabs/scheduled_task/trigger_spec.rb

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -760,23 +760,21 @@
760760

761761
# 31 bits for 31 days
762762
ALL_NUMERIC_DAYS_SET = 0b01111111111111111111111111111111
763-
# 32 bits for 31 days + 'last'
764-
ALL_DAYS_SET = 0b11111111111111111111111111111111
765763

766764
EXPECTED_DAYS_CONVERSIONS =
767765
[
768-
{ days: 1, bitmask: 0b00000000000000000000000000000001 },
769-
{ days: [], bitmask: 0 },
770-
{ days: [2], bitmask: 0b00000000000000000000000000000010 },
771-
{ days: [3, 5, 8, 12], bitmask: 0b00000000000000000000100010010100 },
772-
{ days: [3, 5, 8, 12, 'last'], bitmask: 0b10000000000000000000100010010100 },
773-
{ days: (1..31).to_a, bitmask: ALL_NUMERIC_DAYS_SET },
774-
{ days: (1..31).to_a + ['last'], bitmask: ALL_DAYS_SET },
766+
{ days: 1, bitmask: 0b00000000000000000000000000000001, LastDayOfMonth: false },
767+
{ days: [], bitmask: 0, LastDayOfMonth: false },
768+
{ days: [2], bitmask: 0b00000000000000000000000000000010, LastDayOfMonth: false },
769+
{ days: [3, 5, 8, 12], bitmask: 0b00000000000000000000100010010100, LastDayOfMonth: false },
770+
{ days: [3, 5, 8, 12, 'last'], bitmask: 0b00000000000000000000100010010100, LastDayOfMonth: true },
771+
{ days: (1..31).to_a, bitmask: ALL_NUMERIC_DAYS_SET, LastDayOfMonth: false },
772+
{ days: (1..31).to_a + ['last'], bitmask: ALL_NUMERIC_DAYS_SET, LastDayOfMonth: true },
775773
# equivalent representations
776-
{ days: 'last', bitmask: 0b10000000000000000000000000000000 },
777-
{ days: ['last'], bitmask: 1 << (32 - 1) },
778-
{ days: [1, 'last'], bitmask: 0b10000000000000000000000000000001 },
779-
{ days: [1, 30, 31, 'last'], bitmask: 0b11100000000000000000000000000001 },
774+
{ days: 'last', bitmask: 0b00000000000000000000000000000000, LastDayOfMonth: true },
775+
{ days: ['last'], bitmask: 0b00000000000000000000000000000000, LastDayOfMonth: true },
776+
{ days: [1, 'last'], bitmask: 0b00000000000000000000000000000001, LastDayOfMonth: true },
777+
{ days: [1, 30, 31, 'last'], bitmask: 0b01100000000000000000000000000001, LastDayOfMonth: true },
780778
].freeze
781779

782780
describe '#indexes_to_bitmask' do
@@ -792,7 +790,7 @@
792790
end
793791
end
794792

795-
['foo', ['bar'], -1, 0x1000, [33]].each do |value|
793+
[-1, 0x1000, [33]].each do |value|
796794
it "should raise an ArgumentError with value: #{value}" do
797795
expect { days.class.indexes_to_bitmask(value) }.to raise_error(ArgumentError)
798796
end
@@ -802,7 +800,7 @@
802800
describe '#bitmask_to_indexes' do
803801
EXPECTED_DAYS_CONVERSIONS.each do |conversion|
804802
it "should create expected days #{conversion[:days]} from bitmask #{'%32b' % conversion[:bitmask]}" do
805-
expect(days.class.bitmask_to_indexes(conversion[:bitmask])).to eq([conversion[:days]].flatten)
803+
expect(days.class.bitmask_to_indexes(conversion[:bitmask], conversion[:LastDayOfMonth])).to eq([conversion[:days]].flatten)
806804
end
807805
end
808806

@@ -812,9 +810,21 @@
812810
end
813811
end
814812

815-
['foo', -1, ALL_DAYS_SET + 1].each do |value|
816-
it "should raise an ArgumentError with value: #{value}" do
817-
expect { days.class.bitmask_to_indexes(value) }.to raise_error(ArgumentError)
813+
it "should raise an ArgumentError with value: #{ALL_NUMERIC_DAYS_SET + 1}" do
814+
expect { days.class.bitmask_to_indexes(ALL_NUMERIC_DAYS_SET + 1) }.to raise_error(ArgumentError)
815+
end
816+
end
817+
818+
describe '#last_day_of_month?' do
819+
EXPECTED_DAYS_CONVERSIONS.each do |conversion|
820+
it "returns #{conversion[:LastDayOfMonth]} from days #{conversion[:days]}" do
821+
expect(days.class.last_day_of_month?(conversion[:days])).to eq(conversion[:LastDayOfMonth])
822+
end
823+
end
824+
825+
[[1, 3, 'first'], [1, 3, 'last', 'first']].each do |value|
826+
it "returns an ArgumentError for days #{value}" do
827+
expect { days.class.last_day_of_month?(value) }.to raise_error(ArgumentError)
818828
end
819829
end
820830
end
@@ -1048,9 +1058,9 @@
10481058
{
10491059
i_trigger: {
10501060
Type: v2::Type::TASK_TRIGGER_MONTHLY,
1051-
DaysOfMonth: 0b11111111111111111111111111111111,
1061+
DaysOfMonth: 0b01111111111111111111111111111111,
10521062
MonthsOfYear: 1,
1053-
RunOnLastDayOfMonth: true, # ignored
1063+
RunOnLastDayOfMonth: true,
10541064
RandomDelay: 'P2DT5S', # ignored
10551065
},
10561066
expected: {
@@ -1066,7 +1076,7 @@
10661076
# HACK: choose only the last week selected for test conversion, as this LOSES information
10671077
WeeksOfMonth: 0b10000,
10681078
MonthsOfYear: 0b111111111111,
1069-
RunOnLastWeekOfMonth: true, # ignored
1079+
RunOnLastWeekOfMonth: true,
10701080
RandomDelay: 'P2DT5S', # ignored
10711081
},
10721082
expected: {

0 commit comments

Comments
 (0)