diff --git a/.fixtures.yml b/.fixtures.yml index 0bbc966f2..0440c131f 100644 --- a/.fixtures.yml +++ b/.fixtures.yml @@ -13,6 +13,7 @@ fixtures: inifile: 'https://github.com/puppetlabs/puppetlabs-inifile.git' puppetdb: 'https://github.com/puppetlabs/puppetlabs-puppetdb.git' redis: 'https://github.com/voxpupuli/puppet-redis.git' + ruby_task_helper: 'https://github.com/puppetlabs/puppetlabs-ruby_task_helper.git' stdlib: 'https://github.com/puppetlabs/puppetlabs-stdlib.git' systemd: 'https://github.com/camptocamp/puppet-systemd.git' yumrepo_core: diff --git a/.gitignore b/.gitignore index d50386507..672de222e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,8 @@ vendor/ .ruby-* ## rspec -spec/fixtures/ +spec/fixtures/modules +spec/fixtures/manifests junit/ ## Puppet module diff --git a/.sync.yml b/.sync.yml index ca841d2d0..3713b720c 100644 --- a/.sync.yml +++ b/.sync.yml @@ -13,3 +13,16 @@ Rakefile: param_docs_pattern: - manifests/init.pp +Gemfile: + extra: + - gem: bolt + version: '~> 1.15' + # TODO: Add something to foreman-installer-modulesync to support `unless ENV['PUPPET_VERSION'] =~ /^5/` + options: + groups: + - 'system_tests' + - gem: beaker-task_helper + version: '~> 1.7' + options: + groups: + - 'system_tests' diff --git a/.travis.yml b/.travis.yml index c04e954fd..1c4b43ff7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,7 @@ matrix: env: - BEAKER_PUPPET_COLLECTION=puppet5 - BEAKER_setfile=centos6-64{hostname=centos6-64.example.com} - script: bundle exec rake beaker + script: bundle exec rake spec_prep beaker services: docker bundler_args: --without development before_install: @@ -27,7 +27,7 @@ matrix: env: - BEAKER_PUPPET_COLLECTION=puppet6 - BEAKER_setfile=centos6-64{hostname=centos6-64.example.com} - script: bundle exec rake beaker + script: bundle exec rake spec_prep beaker services: docker bundler_args: --without development before_install: @@ -38,7 +38,7 @@ matrix: env: - BEAKER_PUPPET_COLLECTION=puppet5 - BEAKER_setfile=debian9-64{hostname=debian9-64.example.com} - script: bundle exec rake beaker + script: bundle exec rake spec_prep beaker services: docker bundler_args: --without development before_install: @@ -49,7 +49,7 @@ matrix: env: - BEAKER_PUPPET_COLLECTION=puppet6 - BEAKER_setfile=debian9-64{hostname=debian9-64.example.com} - script: bundle exec rake beaker + script: bundle exec rake spec_prep beaker services: docker bundler_args: --without development before_install: @@ -60,7 +60,7 @@ matrix: env: - BEAKER_PUPPET_COLLECTION=puppet6 - BEAKER_setfile=debian10-64{hostname=debian10-64.example.com} - script: bundle exec rake beaker + script: bundle exec rake spec_prep beaker services: docker bundler_args: --without development before_install: diff --git a/Gemfile b/Gemfile index e471b9ec4..d74de6a9f 100644 --- a/Gemfile +++ b/Gemfile @@ -36,5 +36,7 @@ gem 'beaker-puppet_install_helper', {"groups"=>["system_tests"]} gem 'metadata-json-lint' gem 'kafo_module_lint' gem 'parallel_tests' +gem 'bolt', '~> 1.15', {"groups"=>["system_tests"]} unless ENV['PUPPET_VERSION'] =~ /^5/ +gem 'beaker-task_helper', '~> 1.7', {"groups"=>["system_tests"]} # vim:ft=ruby diff --git a/spec/acceptance/puppetserver_latest_spec.rb b/spec/acceptance/puppetserver_latest_spec.rb index 0c56af00c..94ff75ff3 100644 --- a/spec/acceptance/puppetserver_latest_spec.rb +++ b/spec/acceptance/puppetserver_latest_spec.rb @@ -1,4 +1,6 @@ require 'spec_helper_acceptance' +require 'beaker-task_helper/inventory' +require 'bolt_spec/run' describe 'Scenario: install puppetserver (latest):' do before(:context) do @@ -22,11 +24,99 @@ class { '::puppet': server_external_nodes => '', # only for install test - don't think to use this in production! # https://docs.puppet.com/puppetserver/latest/tuning_guide.html - server_jvm_max_heap_size => '256m', - server_jvm_min_heap_size => '256m', + server_jvm_max_heap_size => '256m', + server_jvm_min_heap_size => '256m', + server_max_active_instances => 1, } EOS end it_behaves_like 'a idempotent resource' + + describe 'run_agent task' do + include Beaker::TaskHelper::Inventory + include BoltSpec::Run + + def bolt_config + { 'modulepath' => File.join(File.dirname(File.expand_path(__FILE__)), '../', 'fixtures', 'modules') } + end + + def bolt_inventory + hosts_to_inventory + end + + context 'with empty catalog' do + before do + sleep 10 + end + it 'applies and changes nothing' do + results = run_task('puppet::run_agent', 'agent', {}) + expect(results.first).to include('status' => 'success') + expect(results.first['result']['detailed_exitcode']).to eq 0 + expect(results.first['result']['last_run_summary']['changes']['total']).to eq 0 + end + end + + context 'with basic site.pp' do + before do + on default, 'mkdir -p /etc/puppetlabs/code/environments/production/manifests' + on default, 'echo "node default { notify {\'test\':}}" > /etc/puppetlabs/code/environments/production/manifests/site.pp' + end + describe 'running task with --noop' do + it 'changes nothing and reports noop events' do + results = run_task('puppet::run_agent', 'agent', '_noop' => true) + expect(results.first).to include('status' => 'success') + expect(results.first['result']['detailed_exitcode']).to eq 0 + expect(results.first['result']['last_run_summary']['changes']['total']).to eq 0 + expect(results.first['result']['last_run_summary']['events']['noop']).to eq 1 + end + end + describe 'running task without --noop' do + it 'applies changes' do + results = run_task('puppet::run_agent', 'agent', {}) + expect(results.first).to include('status' => 'success') + expect(results.first['result']['detailed_exitcode']).to eq 2 + expect(results.first['result']['last_run_summary']['changes']['total']).to eq 1 + expect(results.first['result']['last_run_summary']['events']['success']).to eq 1 + end + end + end + context 'with invalid puppet_settings' do + it 'returns failure' do + results = run_task('puppet::run_agent', 'agent', 'puppet_settings' => { 'foo' => 'bar' }) + expect(results.first).to include('status' => 'failure') + end + end + context 'with invalid manifest' do + before do + on default, 'echo "NOT A MANIFEST" > /etc/puppetlabs/code/environments/production/manifests/site.pp' + end + it 'returns failure' do + results = run_task('puppet::run_agent', 'agent', {}) + expect(results.first).to include('status' => 'failure') + expect(results.first['result']['_error']['details']['detailed_exitcode']).to eq 1 + end + end + context 'overriding environment' do + before do + on default, 'mkdir -p /etc/puppetlabs/code/environments/test/manifests' + on default, 'echo "node default { file {\'/tmp/overriding_environment_test\': ensure => \'file\'}}" > /etc/puppetlabs/code/environments/test/manifests/site.pp' + end + it 'applies changes' do + results = run_task('puppet::run_agent', 'agent', 'puppet_settings' => { 'environment' => 'test' }) + expect(results.first).to include('status' => 'success') + expect(results.first['result']['detailed_exitcode']).to eq 2 + expect(results.first['result']['last_run_summary']['changes']['total']).to eq 1 + expect(results.first['result']['last_run_summary']['events']['success']).to eq 1 + end + it 'is idempotent' do + results = run_task('puppet::run_agent', 'agent', 'puppet_settings' => { 'environment' => 'test' }) + expect(results.first).to include('status' => 'success') + expect(results.first['result']['detailed_exitcode']).to eq 0 + end + describe file('/tmp/overriding_environment_test') do + it { should exist } + end + end + end end diff --git a/spec/fixtures/agent_disabled.lock b/spec/fixtures/agent_disabled.lock new file mode 100644 index 000000000..89cde570c --- /dev/null +++ b/spec/fixtures/agent_disabled.lock @@ -0,0 +1 @@ +{"disabled_message":"example disable reason"} \ No newline at end of file diff --git a/spec/fixtures/last_run_summary.yaml b/spec/fixtures/last_run_summary.yaml new file mode 100644 index 000000000..d9772820f --- /dev/null +++ b/spec/fixtures/last_run_summary.yaml @@ -0,0 +1,63 @@ +--- +version: + config: puppet6server1-production-f2b1bf4a994 + puppet: 6.9.0 +resources: + changed: 0 + corrective_change: 0 + failed: 0 + failed_to_restart: 0 + out_of_sync: 0 + restarted: 0 + scheduled: 0 + skipped: 0 + total: 3902 +time: + alternatives: 0.148090409 + anchor: 0.007545186 + archive: 0.002333299 + augeas: 0.5816905290000001 + catalog_application: 61.01259471802041 + concat_file: 0.00382828 + concat_fragment: 0.015766492 + config_retrieval: 14.155975302215666 + convert_catalog: 2.1152733135968447 + cron: 0.02888307 + exec: 1.223957106 + fact_generation: 4.117020649369806 + file: 8.819708986000013 + file_line: 0.027194031 + filebucket: 0.000287035 + firewalld_direct_purge: 0.000981873 + firewalld_direct_rule: 0.57764554 + firewalld_service: 1.9242555670000001 + firewalld_zone: 1.274301765 + group: 0.004098342 + host: 0.001780653 + mailalias: 0.000318397 + node_retrieval: 0.8955133240669966 + package: 9.660382613000001 + plugin_sync: 4.877481509000063 + proxy_mysql_galera_hostgroup: 0.001988538 + proxy_mysql_query_rule: 0.003255971 + proxy_mysql_server: 0.005674009999999999 + proxy_mysql_user: 0.010748484 + proxy_scheduler: 0.002541261 + pulp_register: 0.254169852 + pulp_rpmbind: 0.011293752 + selboolean: 0.045746527 + service: 1.4152696620000003 + ssh_authorized_key: 0.001582177 + sysctl: 1.360353065 + total: 87.194980791 + transaction_evaluation: 59.95798896579072 + user: 0.069402451 + xml_fragment: 0.31662629200000003 + yumrepo: 0.0013444239999999999 + last_run: 1571832278 +changes: + total: 0 +events: + failure: 0 + success: 0 + total: 0 diff --git a/spec/spec_helper_acceptance.rb b/spec/spec_helper_acceptance.rb index 2106d846f..9fc6f0cca 100644 --- a/spec/spec_helper_acceptance.rb +++ b/spec/spec_helper_acceptance.rb @@ -5,6 +5,9 @@ ENV['BEAKER_setfile'] ||= 'centos7-64{hostname=centos7-64.example.com}' ENV['BEAKER_HYPERVISOR'] ||= 'docker' +require 'bolt/pal' +Bolt::PAL.load_puppet + require 'beaker-puppet' require 'beaker-rspec' require 'beaker/puppet_install_helper' diff --git a/spec/tasks/run_agent_spec.rb b/spec/tasks/run_agent_spec.rb new file mode 100644 index 000000000..6a11c62f4 --- /dev/null +++ b/spec/tasks/run_agent_spec.rb @@ -0,0 +1,356 @@ +require 'spec_helper' +require_relative '../../tasks/run_agent.rb' + +describe 'RunAgentTask' do + let(:task) { RunAgentTask.new } + + describe '.task' do + let(:mock_sucess_result) do + { + result: 'The run succeeded with no changes or failures; the system was already in the desired state (or --noop was used).', + stdout: 'STDOUT', + stderr: 'STDERR', + detailed_exitcode: 0, + last_run_summary: {} + } + end + + context 'default options' do + let(:default_puppet_opts) do + { + onetime: true, + verbose: true, + daemonize: false, + usecacheonfailure: false, + 'detailed-exitcodes': true, + splay: false, + show_diff: true, + noop: false + } + end + it 'runs the puppet agent' do + expect(task).to receive(:validate_puppet_settings).with({}) + expect(task).to receive(:check_running_as_root) + expect(task).to receive(:check_agent_not_disabled) + expect(task).to receive(:wait_for_puppet_lockfile) + expect(task).to receive(:run_puppet).with(default_puppet_opts).and_return(mock_sucess_result) + expect(task.task).to eq(mock_sucess_result) + end + end + context 'with usecacheonfailure puppet_setting' do + let(:params) { { puppet_settings: { usecacheonfailure: true } } } + let(:expected_puppet_opts) do + { + onetime: true, + verbose: true, + daemonize: false, + usecacheonfailure: true, + 'detailed-exitcodes': true, + splay: false, + show_diff: true, + noop: false + } + end + it 'runs the puppet agent' do + expect(task).to receive(:validate_puppet_settings).with(usecacheonfailure: true) + expect(task).to receive(:check_running_as_root) + expect(task).to receive(:check_agent_not_disabled) + expect(task).to receive(:wait_for_puppet_lockfile) + expect(task).to receive(:run_puppet).with(expected_puppet_opts).and_return(mock_sucess_result) + expect(task.task(params)).to eq(mock_sucess_result) + end + end + end + describe '.validate_puppet_settings' do + let(:mock_all_settings) do + { + environment: 'production', + server: 'puppet.example.com', + onetime: false, + daemonize: true + } + end + + before do + allow(task).to receive(:all_settings).and_return(mock_all_settings) + end + context 'when user tries to set `noop` in `puppet_settings`' do + it do + expect { task.validate_puppet_settings(noop: true) }.to raise_error(TaskHelper::Error, /Don't include `noop` in puppet_settings/) + end + end + context 'when a setting isn\'t a valid puppet configuration setting' do + it do + expect { task.validate_puppet_settings(environment: 'dev', not_a_setting: 'foo') }.to raise_error(TaskHelper::Error, /not_a_setting is not a valid puppet setting/) + end + end + context 'when a user tries to set `daemonize` in `puppet_settings`' do + it do + expect { task.validate_puppet_settings(environment: 'dev', daemonize: true) }.to raise_error(TaskHelper::Error, /Overriding `onetime` or `daemonize` is not supported in this task/) + end + end + context 'when a user tries to set `onetime` in `puppet_settings`' do + it do + expect { task.validate_puppet_settings(environment: 'dev', onetime: false) }.to raise_error(TaskHelper::Error, /Overriding `onetime` or `daemonize` is not supported in this task/) + end + end + end + describe '.wait_for_puppet_lockfile' do + context 'with wait_time = 5' do + context 'when agent isn\'t locked' do + it do + allow(task).to receive(:agent_locked?).and_return(false) + expect(task.wait_for_puppet_lockfile(5)).to eq(0) + end + end + context 'when agent is locked for 2 seconds' do + it 'waits for 2 seconds' do + i = 0 + allow(task).to receive(:agent_locked?) do + if i < 2 + i += 1 + true + else + false + end + end + expect(task.wait_for_puppet_lockfile(5)).to eq(2) + end + end + context 'when agent is locked for more than wait_time' do + it do + allow(task).to receive(:agent_locked?).and_return(true) + expect { task.wait_for_puppet_lockfile(5) }.to raise_error(TaskHelper::Error, /Lockfile still exists after waiting/) + end + end + end + end + describe '.check_agent_not_disabled' do + context 'when agent is disabled' do + it do + allow(task).to receive(:agent_disabled?).and_return(true) + allow(task).to receive(:disabled_message).and_return('Example disabled reason') + expect { task.check_agent_not_disabled }.to raise_error(TaskHelper::Error, /Agent is disabled on this node/) + end + end + context 'when agent isn\'t disabled' do + it do + allow(task).to receive(:agent_disabled?).and_return(false) + expect { task.check_agent_not_disabled }.to_not raise_error + end + end + end + describe '.agent_locked?' do + before do + allow(task).to receive(:agent_config).with(:agent_catalog_run_lockfile).and_return('/path/to/lockfile') + end + context 'when not locked' do + it do + allow(File).to receive(:exist?).with('/path/to/lockfile').and_return(false) + expect(task.agent_locked?).to eq(false) + end + end + context 'when locked' do + it do + allow(File).to receive(:exist?).with('/path/to/lockfile').and_return(true) + expect(task.agent_locked?).to eq(true) + end + end + end + describe '.agent_disabled?' do + before do + allow(task).to receive(:agent_config).with(:agent_disabled_lockfile).and_return('/path/to/lockfile') + end + context 'when not disabled' do + it do + allow(File).to receive(:exist?).with('/path/to/lockfile').and_return(false) + expect(task.agent_disabled?).to eq(false) + end + end + context 'when disabled' do + it do + allow(File).to receive(:exist?).with('/path/to/lockfile').and_return(true) + expect(task.agent_disabled?).to eq(true) + end + end + end + describe '.disabled_message' do + it 'parses agent disabled lockfile' do + allow(task).to receive(:agent_config).with(:agent_disabled_lockfile).and_return(File.join(File.dirname(__FILE__), '../fixtures/agent_disabled.lock')) + expect(task.disabled_message).to eq('example disable reason') + end + end + describe '.agent_config' do + let(:mock_all_settings) do + { + environment: 'production', + server: 'puppet.example.com', + onetime: false, + daemonize: true + } + end + + before do + allow(task).to receive(:all_settings).and_return(mock_all_settings) + end + context 'when setting exists' do + it do + expect(task.agent_config(:environment)).to eq('production') + end + end + context 'when setting doesn\'t exist' do + it do + expect { task.agent_config(:foobar) }.to raise_error(TaskHelper::Error, /Couldn't determine foobar configuration/) + end + end + end + describe '.all_settings' do + context 'when called more than once' do + it 'returns a cached hash of settings' do + task.instance_variable_set(:@all_settings, cached_setting: 'foo') + expect(task.all_settings).to eq(cached_setting: 'foo') + end + end + context 'when calling puppet fails' do + it do + task.instance_variable_set(:@puppet_bin, '/opt/puppetlabs/bin/puppet') + mock_status = double('mock failed status', exitstatus: 1) + allow(Open3).to receive(:capture3).with('/opt/puppetlabs/bin/puppet', 'config', 'print').and_return(['STDOUT', 'STDERR', mock_status]) + expect { task.all_settings }.to raise_error(TaskHelper::Error, /Couldn't determine puppet configuration/) + end + end + context 'when puppet returns settings' do + let(:mock_stdout) do + <<~MOCK_STDOUT + runinterval = 1800 + runtimeout = 3600 + serial = /etc/puppet/ssl/ca/serial + server = puppet + server_datadir = /opt/puppetlabs/puppet/cache/server_data + server_list = + show_diff = false + MOCK_STDOUT + end + let(:expected_all_settings) do + { + runinterval: '1800', + runtimeout: '3600', + serial: '/etc/puppet/ssl/ca/serial', + server: 'puppet', + server_datadir: '/opt/puppetlabs/puppet/cache/server_data', + server_list: nil, + show_diff: 'false' + } + end + it do + task.instance_variable_set(:@puppet_bin, '/opt/puppetlabs/bin/puppet') + mock_status = double('mock status', exitstatus: 0) + allow(Open3).to receive(:capture3).with('/opt/puppetlabs/bin/puppet', 'config', 'print').and_return([mock_stdout, 'STDERR', mock_status]) + expect(task.all_settings).to eq(expected_all_settings) + end + end + end + describe '.check_running_as_root' do + context 'when EUID is zero' do + it do + allow(Process).to receive(:euid).and_return(0) + expect { task.check_running_as_root }.to_not raise_error + end + end + context 'when EUID is non-zero' do + it do + allow(Process).to receive(:euid).and_return(42) + expect { task.check_running_as_root }.to raise_error(TaskHelper::Error, /Puppet agent needs to run as root/) + end + end + end + describe '.last_run_summary' do + it 'parses last_run_summary.yaml' do + allow(task).to receive(:agent_config).with(:lastrunfile).and_return(File.join(File.dirname(__FILE__), '../fixtures/last_run_summary.yaml')) + expect(task.last_run_summary['changes']['total']).to eq(0) + end + end + describe '.puppet_cmd' do + let(:options) do + { + environment: 'test', + onetime: true, + verbose: true, + daemonize: false, + usecacheonfailure: false, + 'detailed-exitcodes': true, + splay: false, + show_diff: true, + noop: false + } + end + before do + task.instance_variable_set(:@puppet_bin, '/opt/puppetlabs/bin/puppet') + end + it 'adds boolean true options to command with `--`' do + expect(task.puppet_cmd(options)).to include('--onetime', '--show_diff') + end + it 'adds boolean false options to command with `--no-` prefix' do + expect(task.puppet_cmd(options)).to include('--no-daemonize', '--no-splay') + end + it 'adds other options to command with values' do + expect(task.puppet_cmd(options)).to start_with('/opt/puppetlabs/bin/puppet', 'agent', '--environment', 'test') + end + end + describe '.run_puppet' do + before do + mock_cmd = double + allow(task).to receive(:puppet_cmd).and_return(mock_cmd) + allow(task).to receive(:last_run_summary).and_return('MOCK SUMMARY') + allow(Open3).to receive(:capture3).with(mock_cmd).and_return(['STDOUT', 'STDERR', mock_status]) + end + context 'successful run with no changes' do + let(:mock_status) do + double('mock status', exitstatus: 0) + end + it do + expect(task.run_puppet({})).to eq( + result: 'The run succeeded with no changes or failures; the system was already in the desired state (or --noop was used).', + stdout: 'STDOUT', + stderr: 'STDERR', + detailed_exitcode: 0, + last_run_summary: 'MOCK SUMMARY' + ) + end + end + context 'successful run with changes' do + let(:mock_status) do + double('mock status', exitstatus: 2) + end + it do + expect(task.run_puppet({})).to eq( + result: 'The run succeeded, and some resources were changed.', + stdout: 'STDOUT', + stderr: 'STDERR', + detailed_exitcode: 2, + last_run_summary: 'MOCK SUMMARY' + ) + end + end + context 'unsuccessful run' do + let(:mock_status) do + double('mock status', exitstatus: 1) + end + it { expect { task.run_puppet({}) }.to raise_error(TaskHelper::Error, /Puppet run failed/) } + end + context 'run succeeded with resources having errors' do + context 'without changes' do + let(:mock_status) do + double('mock status', exitstatus: 4) + end + it { expect { task.run_puppet({}) }.to raise_error(TaskHelper::Error, /Puppet run succeeded, but some resources failed/) } + end + context 'with changes' do + let(:mock_status) do + double('mock status', exitstatus: 6) + end + it { expect { task.run_puppet({}) }.to raise_error(TaskHelper::Error, /Puppet run succeeded, but some resources failed/) } + end + end + end +end diff --git a/tasks/run_agent.json b/tasks/run_agent.json new file mode 100644 index 000000000..53f0541aa --- /dev/null +++ b/tasks/run_agent.json @@ -0,0 +1,20 @@ +{ + "description": "Runs the puppet agent", + "supports_noop": true, + "files": ["ruby_task_helper/files/task_helper.rb"], + "input_method": "stdin", + "parameters": { + "puppet_bin": { + "description": "The full path to the puppet binary. Defaults to `'/opt/puppetlabs/bin/puppet'`.", + "type": "Optional[Stdlib::Absolutepath]" + }, + "wait_time": { + "description": "How many seconds should the task wait for an existing puppet run to finish. Defaults to `300` (5 minutes).", + "type": "Optional[Integer[0]]" + }, + "puppet_settings": { + "description": "An optional hash of puppet configuration settings. These can be used to override puppet's defaults, settings in puppet.conf and default settings included with `--test`.", + "type": "Optional[Hash]" + } + } +} diff --git a/tasks/run_agent.rb b/tasks/run_agent.rb new file mode 100755 index 000000000..ea3a0acee --- /dev/null +++ b/tasks/run_agent.rb @@ -0,0 +1,204 @@ +#!/opt/puppetlabs/puppet/bin/ruby + +require 'open3' +require 'etc' +require 'json' +require 'yaml' + +begin + require_relative '../../ruby_task_helper/files/task_helper.rb' +rescue LoadError + require_relative '../spec/fixtures/modules/ruby_task_helper/files/task_helper.rb' +end + +class RunAgentTask < TaskHelper + def task(puppet_bin: '/opt/puppetlabs/bin/puppet', wait_time: 300, puppet_settings: {}, _noop: false, **_kwargs) + @puppet_bin = puppet_bin + + validate_puppet_settings(puppet_settings) + + puppet_opts = { + onetime: true, + verbose: true, + daemonize: false, + usecacheonfailure: false, + 'detailed-exitcodes': true, + splay: false, + show_diff: true, + noop: _noop + }.merge(puppet_settings) + + check_running_as_root + check_agent_not_disabled + wait_for_puppet_lockfile(wait_time) + run_puppet(puppet_opts) + end + + def validate_puppet_settings(settings) + if settings.key?(:noop) + raise TaskHelper::Error.new( + 'Don\'t include `noop` in puppet_settings. Use bolt\'s dedicated `--noop` option instead', + 'theforeman-puppet/invalid_puppet_setting' + ) + end + settings.each do |setting, _val| + next if all_settings.keys.include?(setting) + + raise TaskHelper::Error.new( + "#{setting} is not a valid puppet setting", + 'theforeman-puppet/invalid_puppet_setting' + ) + end + if %i[onetime daemonize].any? { |x| settings.include?(x) } + raise TaskHelper::Error.new( + 'Overriding `onetime` or `daemonize` is not supported in this task', + 'theforeman-puppet/invalid_puppet_setting' + ) + end + end + + def wait_for_puppet_lockfile(wait_time) + waited = 0 + while agent_locked? && waited < wait_time + sleep 1 + waited += 1 + end + + if agent_locked? + raise TaskHelper::Error.new( + 'Lockfile still exists after waiting', + 'theforeman-puppet/lockfile_timeout_expired' + ) + end + + waited + end + + def check_agent_not_disabled + if agent_disabled? + raise TaskHelper::Error.new( + 'Agent is disabled on this node', + 'theforeman-puppet/agent_disabled', + disabled_message: disabled_message + ) + end + end + + def agent_locked? + File.exist?(agent_config(:agent_catalog_run_lockfile)) + end + + def agent_disabled? + File.exist?(agent_config(:agent_disabled_lockfile)) + end + + def disabled_message + JSON.load(File.read(agent_config(:agent_disabled_lockfile)))['disabled_message'] + end + + def last_run_summary + YAML.load_file(agent_config(:lastrunfile)) + end + + def agent_config(setting) + if all_settings[setting].nil? + raise TaskHelper::Error.new( + "Couldn't determine #{setting} configuration", + 'theforeman-puppet/config_unknown' + ) + end + all_settings[setting] + end + + def all_settings + return @all_settings unless @all_settings.nil? + + cmd = [@puppet_bin, 'config', 'print'] + stdout, stderr, status = Open3.capture3(*cmd) + + unless status.exitstatus.zero? + raise TaskHelper::Error.new( + 'Couldn\'t determine puppet configuration', + 'theforeman-puppet/config_unknown', + stderr: stderr + ) + end + + settings = {} + stdout.split("\n").each do |line| + k, v = line.split(' = ', 2) + v = nil if v == '' + settings[k.to_sym] = v + end + @all_settings = settings + @all_settings + end + + def check_running_as_root + unless Process.euid.zero? + raise TaskHelper::Error.new( + 'Puppet agent needs to run as root', + 'theforeman-puppet/bad_euid', + euid: Process.euid + ) + end + end + + def puppet_cmd(puppet_opts) + cmd = [@puppet_bin, 'agent'] + puppet_opts.each do |option, value| + case value + when true + cmd << "--#{option}" + when false + cmd << "--no-#{option}" + else + cmd << "--#{option}" + cmd << value + end + end + cmd + end + + def run_puppet(puppet_opts) + stdout, stderr, status = Open3.capture3(*puppet_cmd(puppet_opts)) + + case status.exitstatus + when 0 + { + result: 'The run succeeded with no changes or failures; the system was already in the desired state (or --noop was used).', + stdout: stdout, + stderr: stderr, + detailed_exitcode: 0, + last_run_summary: last_run_summary + } + when 1 + raise TaskHelper::Error.new( + 'Puppet run failed', + 'theforeman-puppet/run_failed', + stderr: stderr, + stdout: stdout, + detailed_exitcode: status.exitstatus + ) + when 2 + { + result: 'The run succeeded, and some resources were changed.', + stdout: stdout, + stderr: stderr, + detailed_exitcode: 2, + last_run_summary: last_run_summary + } + when 4, 6 + raise TaskHelper::Error.new( + 'Puppet run succeeded, but some resources failed', + 'theforeman-puppet/failed_resources', + stderr: stderr, + stdout: stdout, + detailed_exitcode: status.exitstatus, + last_run_summary: last_run_summary + ) + end + end +end + +RunAgentTask.run if $PROGRAM_NAME == __FILE__