diff --git a/.github/workflows/kitchen.yml b/.github/workflows/kitchen.yml index 8ce6a5db..9627931a 100644 --- a/.github/workflows/kitchen.yml +++ b/.github/workflows/kitchen.yml @@ -36,9 +36,9 @@ jobs: - name: Check out cookbook code uses: actions/checkout@master - name: Install Chef Infra Client - uses: actionshub/chef-install@master + uses: actionshub/chef-install@main - name: Dokken - uses: actionshub/kitchen-dokken@master + uses: actionshub/kitchen-dokken@main env: CHEF_LICENSE: accept-no-persist KITCHEN_LOCAL_YAML: kitchen.dokken.yml diff --git a/README.md b/README.md index 0d9fda3e..c7c0d9c0 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,15 @@ The chef_client_scheduled_task resource setups up Chef Infra Client to run as a - `frequency_modifier` Numeric value to go with the scheduled task frequency - default: '30' - `start_time` The start time for the task in HH:mm format (ex: 14:00). If the `frequency` is `minute` default start time will be `Time.now` plus the `frequency_modifier` number of minutes. - `start_date` - The start date for the task in `m:d:Y` format (ex: 12/17/2017). nil by default and isn't necessary if you're running a regular interval. -- `splay` - A random number of seconds between 0 and X to add to interval. default: '300' +- `splay` - A random number of seconds between 0 and X to add to interval. Note splay is applied differently when use_consistent_splay is set to true. default: '300' - `config_directory` - The path to the Chef config directory. default: 'C:/chef' - `log_file_name` - The name of the log file. default: 'client.log' - `log_directory` - The path to the Chef log directory. default: 'CONFIG_DIRECTORY/log' - `chef_binary_path` - The path to the chef-client binary. default: 'C:/opscode/chef/bin/chef-client' - `daemon_options` - An optional array of extra options to pass to the chef-client - `task_name` - The name of the scheduled task. This allows for multiple chef_client_scheduled_task resources when it is used directly like in a wrapper cookbook. default: 'chef-client' +- `use_consistent_splay` - Indicates that the randomly computed splay should remain consistent for a given node, similar to how it functions in cron resource. default: false +- `snap_time_to_frequency` - Indicates that the start day and time for the task should be snapped to start at the next frequency cycle after the previous top of the hour. For example if the current time is 14:07 and the frequency_modifier is 30, the next task start time should be 14:30. Only applicable when frequency = 'minute'. default: false ### chef_client_cron @@ -244,7 +246,7 @@ Use this recipe to run chef-client as a cron job rather than as a service. The c ### task -Use this recipe to run chef-client on Windows nodes as a scheduled task. Without modifying attributes the scheduled task will run 30 minutes after the recipe runs, with each chef run rescheduling the run 30 minutes in the future. By default the job runs as the system user. The time period between runs can be modified with the `default['chef_client']['task']['frequency_modifier']` attribute and the user can be changed with the `default['chef_client']['task']['user']` and `default['chef_client']['task']['password']` attributes. +Use this recipe to run chef-client on Windows nodes as a scheduled task. Without modifying attributes the scheduled task will run 30 minutes after the recipe runs, with each chef run rescheduling the run 30 minutes in the future. By default the job runs as the system user. The time period between runs can be modified with the `default['chef_client']['task']['frequency_modifier']` attribute and the user can be changed with the `default['chef_client']['task']['user']` and `default['chef_client']['task']['password']` attributes. For a scheduled task that behaves more like the chef-client cron job, the snap_time_to_frequency and use_consistent_splay properties can be set to true. ## Usage diff --git a/attributes/default.rb b/attributes/default.rb index 775503f3..dd7f3745 100644 --- a/attributes/default.rb +++ b/attributes/default.rb @@ -53,8 +53,8 @@ 'weekday' => '*', 'path' => nil, 'environment_variables' => nil, - 'log_directory' => nil, - 'log_file' => '/dev/null', + 'log_directory' => '/dev', + 'log_file' => 'null', 'append_log' => false, 'use_cron_d' => false, 'mailto' => nil, @@ -79,6 +79,8 @@ default['chef_client']['task']['start_time'] = nil default['chef_client']['task']['start_date'] = nil default['chef_client']['task']['name'] = 'chef-client' +default['chef_client']['task']['use_consistent_splay'] = false +default['chef_client']['task']['snap_time_to_frequency'] = false default['chef_client']['load_gems'] = {} diff --git a/kitchen.yml b/kitchen.yml index 08db4a96..993d0cb2 100644 --- a/kitchen.yml +++ b/kitchen.yml @@ -118,3 +118,8 @@ suites: run_list: - recipe[test::task] includes: ["windows-2012r2-13", "windows-2012r2-14", "windows-2016", "windows-2019"] + +- name: emulate-cron-task + run_list: + - recipe[test::emulate_cron_task] + includes: ["windows-2012r2-13", "windows-2012r2-14", "windows-2016", "windows-2019"] diff --git a/libraries/helpers.rb b/libraries/helpers.rb index c3bee66c..d23db6a6 100644 --- a/libraries/helpers.rb +++ b/libraries/helpers.rb @@ -24,6 +24,20 @@ module Helpers include Chef::Mixin::Which require 'digest/md5' + # + # Snaps the start time of the scheduled task to the next start of the frequency cycle, where the frequency cycle begins + # at the top of the hour and increments by frequency_modifier in minutes + # + # @param [Integer] frequency_modifier - The frequency modifier in minutes + # @return [Time] The next snap-to time on the frequency cycle computed from top of the previous hour + # + def snap_time(frequency_modifier) + ref_time = Time.now + snap = Time.new(ref_time.year, ref_time.mon, ref_time.day, ref_time.hour, 0, 0) + minutes = (ref_time.min + frequency_modifier) - (ref_time.min % frequency_modifier) + snap + (minutes * 60) + end + def wmi_property_from_query(wmi_property, wmi_query) @wmi = ::WIN32OLE.connect('winmgmts://') result = @wmi.ExecQuery(wmi_query) diff --git a/recipes/task.rb b/recipes/task.rb index 564e6a2c..16bc4606 100644 --- a/recipes/task.rb +++ b/recipes/task.rb @@ -41,6 +41,8 @@ class ::Chef::Recipe chef_binary_path node['chef_client']['bin'] daemon_options node['chef_client']['daemon_options'] task_name node['chef_client']['task']['name'] + use_consistent_splay node['chef_client']['task']['use_consistent_splay'] + snap_time_to_frequency node['chef_client']['task']['snap_time_to_frequency'] end windows_service 'chef-client' do diff --git a/resources/cron.rb b/resources/cron.rb index d250850a..7f3a6732 100644 --- a/resources/cron.rb +++ b/resources/cron.rb @@ -20,6 +20,7 @@ chef_version_for_provides '< 16.0' if respond_to?(:chef_version_for_provides) provides :chef_client_cron +unified_mode true resource_name :chef_client_cron property :job_name, String, default: 'chef-client' @@ -122,6 +123,7 @@ def log_command # @return [String] # def log_path + puts "Log directory='#{new_resource.log_directory}'" return new_resource.log_file_name if new_resource.log_directory.nil? ::File.join(new_resource.log_directory, new_resource.log_file_name) end diff --git a/resources/scheduled_task.rb b/resources/scheduled_task.rb index 6dd40e85..7b76aac7 100644 --- a/resources/scheduled_task.rb +++ b/resources/scheduled_task.rb @@ -20,6 +20,7 @@ chef_version_for_provides '< 16.0' if respond_to?(:chef_version_for_provides) provides :chef_client_scheduled_task +unified_mode true resource_name :chef_client_scheduled_task property :task_name, String, @@ -40,6 +41,9 @@ callbacks: { 'should be a positive number' => proc { |v| v > 0 } }, default: lazy { frequency == 'minute' ? 30 : 1 } +property :snap_time_to_frequency, [true, false], + default: false + property :accept_chef_license, [true, false], default: false @@ -54,6 +58,9 @@ callbacks: { 'should be a positive number' => proc { |v| v > 0 } }, default: 300 +property :use_consistent_splay, [true, false], + default: false + property :run_on_battery, [true, false], default: true @@ -93,7 +100,7 @@ frequency_modifier new_resource.frequency_modifier if frequency_supports_frequency_modifier? start_time start_time_value start_day new_resource.start_date unless new_resource.start_date.nil? - random_delay new_resource.splay if frequency_supports_random_delay? + random_delay new_resource.splay if frequency_supports_random_delay? && !new_resource.use_consistent_splay disallow_start_if_on_batteries new_resource.splay unless new_resource.run_on_battery || Gem::Requirement.new('< 14.4').satisfied_by?(Gem::Version.new(Chef::VERSION)) action [ :create, :enable ] end @@ -115,7 +122,17 @@ def full_command # Fetch path of cmd.exe through environment variable comspec cmd_path = ENV['COMSPEC'] - "#{cmd_path} /c \"#{client_cmd}\"" + "#{cmd_path} /c \"#{consistent_splay_command}#{client_cmd}\"" + end + + # + # The consistent splay sleep time when use_consistent_splay is true + # + # @return [NilClass,String] The prepended sleep command to run prior to executing the full command. + # + def consistent_splay_command + return unless new_resource.use_consistent_splay + "C:/windows/system32/windowspowershell/v1.0/powershell.exe Start-Sleep -s #{splay_sleep_time(new_resource.splay)} && " end # @@ -157,6 +174,10 @@ def frequency_supports_frequency_modifier? def start_time_value if new_resource.start_time new_resource.start_time + elsif new_resource.snap_time_to_frequency && new_resource.frequency == 'minute' + snap_time = snap_time(new_resource.frequency_modifier) + new_resource.start_date = snap_time.strftime('%m/%d/%Y') + snap_time.strftime('%H:%M') elsif Gem::Requirement.new('< 13.7.0').satisfied_by?(Gem::Version.new(Chef::VERSION)) new_resource.frequency == 'minute' ? (Time.now + 60 * new_resource.frequency_modifier.to_f).strftime('%H:%M') : nil end diff --git a/resources/systemd_timer.rb b/resources/systemd_timer.rb index 70b8b0cd..c5378da9 100644 --- a/resources/systemd_timer.rb +++ b/resources/systemd_timer.rb @@ -20,6 +20,7 @@ chef_version_for_provides '< 16.0' if respond_to?(:chef_version_for_provides) provides :chef_client_systemd_timer +unified_mode true resource_name :chef_client_systemd_timer property :job_name, String, default: 'chef-client' diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 773d5579..377e8312 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,4 +5,31 @@ config.color = true # Use color in STDOUT config.formatter = :documentation # Use the specified formatter config.log_level = :error # Avoid deprecation notice SPAM + + config.before do + # Mock chef_version_for_provides to be < 18 for chef_client_scheduled_task so that resource will be evaluated + # by current chef unit testing version + class Chef::Resource + class << self + alias_method :chef_version_for_provides_original, :chef_version_for_provides + + def chef_version_for_provides(constraint) + if ::File.basename(__FILE__) == 'scheduled_task.rb' + '< 18.0' # Bump the constraint to current 17.x versions of Chef + else + chef_version_for_provides_original(constraint) + end + end + end + end + + unless RUBY_PLATFORM =~ /mswin|mingw32|windows/ + # This enables spec testing of windows recipes on nix OSes. This is used + # to address the use of Win32::Service in recipe chef-client::task + Win32 = Module.new unless defined?(Win32) + Win32::Service = Class.new unless defined?(Win32::Service) + allow(::Win32::Service).to receive(:exists?).with(anything).and_return(false) + allow(::Win32::Service).to receive(:exists?).with('chef-client').and_return(true) + end + end end diff --git a/spec/unit/scheduled_task_spec.rb b/spec/unit/scheduled_task_spec.rb index 7deb2e6f..a25eafe9 100644 --- a/spec/unit/scheduled_task_spec.rb +++ b/spec/unit/scheduled_task_spec.rb @@ -1,29 +1,60 @@ # Chefspec and windows aren't the best of friends. Running this on a non-windows # host results in win32ole load errors. -# require 'spec_helper' -# -# describe 'chef-client::task' do -# context 'when given override attributes' do -# let(:chef_run) do -# ChefSpec::ServerRunner.new(platform: 'windows', version: '2012R2', step_into: ['chef_client_scheduled_task']) do |node| -# node.override['chef_client']['task']['start_time'] = 'Tue Sep 13 15:46:33 EDT 2016' -# node.override['chef_client']['task']['user'] = 'system' -# node.override['chef_client']['task']['password'] = 'secret' -# node.override['chef_client']['task']['frequency'] = 'hourly' -# node.override['chef_client']['task']['frequency_modifier'] = 60 -# end.converge(described_recipe) -# end -# -# it 'creates the windows_task resource with desired settings' do -# expect(chef_run).to create_windows_task('chef-client').with( -# command: 'cmd /c "C:/opscode/chef/bin/chef-client -L C:/chef/log/client.log -c C:/chef/client.rb -s 300 ^> NUL 2^>^&1"', -# user: 'system', -# password: 'secret', -# frequency: :hourly, -# frequency_modifier: 60, -# start_time: 'Tue Sep 13 15:46:33 EDT 2016' -# ) -# end -# end -# end +require 'spec_helper' + +describe 'chef-client::task' do + let(:local_system) { instance_double('LocalSystem', account_simple_name: 'system') } + let(:sid_class) { class_double('Chef::ReservedNames::Win32::Security::SID', LocalSystem: local_system, system_user?: true) } + let(:node) { runner.node } + let(:chef_run) { runner.converge(described_recipe) } + let(:runner) { ChefSpec::SoloRunner.new(platform: 'windows', version: '2012R2', step_into: ['chef_client_scheduled_task']) } + + before do + # Mock up the environment to behave like Windows running Chef < 16 + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('COMSPEC').and_return('cmd') + stub_const('Chef::ReservedNames::Win32::Security::SID', sid_class) + node.automatic['chef_client']['bin'] = 'C:/opscode/chef/bin/chef-client' + allow(Chef::Resource).to receive(:respond_to?).and_call_original + allow(Chef::Resource).to receive(:respond_to?).with(:chef_version_for_provides).and_return(true) + allow(Chef::Resource).to receive(:chef_version_for_provides).with('< 16.0').and_return(true) + end + + context 'when given override attributes' do + before do + node.override['chef_client']['task']['start_time'] = '16:10' + node.override['chef_client']['task']['frequency'] = 'hourly' + node.override['chef_client']['task']['frequency_modifier'] = 1 + node.override['chef_client']['daemon_options'] = ['-s', '300', '^>', 'NUL', '2^>^&1'] + end + + it 'creates the windows_task resource with desired settings' do + expect(chef_run).to create_windows_task('chef-client').with( + command: 'cmd /c "C:/opscode/chef/bin/chef-client -L C:/chef/log/client.log -c C:/chef/client.rb -s 300 ^> NUL 2^>^&1"', + user: 'SYSTEM', + frequency: :hourly, + frequency_modifier: 1, + start_time: '16:10' + ) + end + end + + context 'when configured to use a consistent splay and snap frequency time' do + let(:now) { Time.new('2021', '4', '15', '16', '7', '12') } + before do + node.override['chef_client']['task']['use_consistent_splay'] = true + node.override['chef_client']['task']['snap_time_to_frequency'] = true + allow(Time).to receive(:now).and_return(now) + allow_any_instance_of(Chef::Resource::ChefClientScheduledTask).to receive(:splay_sleep_time).and_return(222) + end + + it 'creates the windows_task resource with desired settings' do + expect(chef_run).to create_windows_task('chef-client').with( + command: 'cmd /c "C:/windows/system32/windowspowershell/v1.0/powershell.exe Start-Sleep -s 222 && C:/opscode/chef/bin/chef-client -L C:/chef/log/client.log -c C:/chef/client.rb"', + start_day: '04/15/2021', + start_time: '16:30' + ) + end + end +end diff --git a/test/cookbooks/test/recipes/cron.rb b/test/cookbooks/test/recipes/cron.rb index c5220e27..096fc2b9 100644 --- a/test/cookbooks/test/recipes/cron.rb +++ b/test/cookbooks/test/recipes/cron.rb @@ -1,6 +1,5 @@ apt_update 'update' include_recipe 'test::config' -include_recipe 'cron::default' package 'crontabs' if platform?('fedora') # ensures we actually have the /etc/cron.d dir include_recipe 'chef-client::cron' include_recipe 'chef-client::delete_validation' diff --git a/test/cookbooks/test/recipes/emulate_cron_task.rb b/test/cookbooks/test/recipes/emulate_cron_task.rb new file mode 100644 index 00000000..a14e11e3 --- /dev/null +++ b/test/cookbooks/test/recipes/emulate_cron_task.rb @@ -0,0 +1,19 @@ +node.override['chef_client']['interval'] = 900 +node.override['chef_client']['task']['snap_time_to_frequency'] = true +node.override['chef_client']['task']['use_consistent_splay'] = true + +include_recipe 'test::config' +include_recipe 'chef-client::task' +include_recipe 'chef-client::delete_validation' + +chef_client_scheduled_task 'Chef Client on start' do + user node['chef_client']['task']['user'] + password node['chef_client']['task']['password'] + frequency 'onstart' + config_directory node['chef_client']['conf_dir'] + log_directory node['chef_client']['log_dir'] + log_file_name node['chef_client']['log_file'] + chef_binary_path node['chef_client']['bin'] + daemon_options node['chef_client']['daemon_options'] + task_name "#{node['chef_client']['task']['name']}-onstart" +end diff --git a/test/integration/emulate_cron_task/task_spec.rb b/test/integration/emulate_cron_task/task_spec.rb new file mode 100644 index 00000000..ed8a7be4 --- /dev/null +++ b/test/integration/emulate_cron_task/task_spec.rb @@ -0,0 +1,22 @@ +describe command('C:/opscode/chef/embedded/bin/ohai virtualization -c C:/chef/client.rb') do + its('exit_status') { should eq 0 } +end + +describe file('C:/chef/client.rb') do + its('content') { should match(/ohai.disabled_plugins = \["Mdadm"\]/) } + its('content') { should match(/ohai.optional_plugins = \["Passwd"\]/) } + its('content') { should match(%r{ohai.plugin_path << "/tmp/kitchen/ohai/plugins"}) } +end + +# the inspec resource requires PS 3.0+ and 2k8r2 only has PS 2.0 by default +unless os.release.to_f == 6.1 + describe windows_task('chef-client') do + it { should be_enabled } + its('run_as_user') { should eq 'SYSTEM' } + its('task_to_run') { should match %r{cmd.exe /c C:/windows/system32/windowspowershell/v1.0/powershell.exe Start-Sleep -s ([0-9]|[1-9][0-9]|[1-9][0-9][0-9]) C:/opscode/chef/bin/chef-client -L C:/chef/log/client.log -c C:/chef/client.rb -s 300} } + end + + describe windows_task('chef-client-onstart') do + it { should be_enabled } + end +end