diff --git a/.github/workflows/pr_testing_configure.yaml b/.github/workflows/pr_testing_configure.yaml new file mode 100644 index 0000000..8ee36b6 --- /dev/null +++ b/.github/workflows/pr_testing_configure.yaml @@ -0,0 +1,175 @@ +--- +name: 'PR Testing the configure task' + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + # These openvox_bootstrap::configure parameters are used in both + # the agent and server task runs, but puppet_conf will vary. + COMMON_CONFIGURE_PARAMS: |- + "csr_attributes": { + "custom_attributes": { + "1.2.840.113549.1.9.7": "password" + }, + "extension_requests": { + "pp_role": "tomato" + } + }, + "puppet_service_running": true, + "puppet_service_enabled": false + +jobs: + test-configure-task: + strategy: + matrix: + os: + - [almalinux, '9'] + - [ubuntu, '24.04'] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - id: install-bolt + uses: ./.github/actions/bolt + with: + os-codename: jammy + - id: vm-cluster + uses: jpartlow/nested_vms@v1 + with: + os: ${{ matrix.os[0] }} + os-version: ${{ matrix.os[1] }} + os-arch: ${{ matrix.os[2] || 'x86_64' }} + host-root-access: true + ruby-version: '3.3' + install-openvox: false + vms: |- + [ + { + "role": "primary", + "cpus": 4, + "mem_mb": 8192, + "cpu_mode": "host-model" + }, + { + "role": "agent", + "cpus": 2, + "mem_mb": 4096, + "cpu_mode": "host-model" + } + ] + - name: Capture dereferenced inventory for use with openvox_bootstrap + working-directory: kvm_automation_tooling + run: |- + bolt inventory --inventory terraform/instances/inventory.test.yaml show --format json --detail | \ + jq '.inventory | with_entries(select(.key == "targets")) | del(.targets[].groups)' | \ + yq -P > ../inventory.yaml + cat ../inventory.yaml + - name: Install openvox + run: |- + bolt task run openvox_bootstrap::install --inventory inventory.yaml --targets test-primary-1,test-agent-1 + - name: Install openvox-server + run: |- + bolt task run openvox_bootstrap::install --inventory inventory.yaml --targets test-primary-1 package=openvox-server + - name: Disable agents to prevent background service runs + run: |- + bolt command run '/opt/puppetlabs/bin/puppet agent --disable "OpenVox PR testing"' --inventory inventory.yaml --targets test-agent-1,test-primary-1 + - name: Write server configure params + run: |- + cat > server-params.json < sign.sh <<'EOF' + #!/bin/bash + set -e + csr_pem=$(cat) + csr_text=$(openssl req -text <<<"$csr_pem") + password=$(awk -F: -e '/challengePassword/ { print $2 }' <<<"$csr_text") + [[ "${password}" == 'password' ]] + EOF + bolt file upload sign.sh /etc/puppetlabs/puppet/sign.sh --inventory inventory.yaml --targets test-primary-1 + + cat > standup.sh <<'EOF' + #! /bin/bash + set -e + set -x + + chmod 750 /etc/puppetlabs/puppet/sign.sh + chown puppet:puppet /etc/puppetlabs/puppet/sign.sh + + set +e + systemctl start puppetserver + if [ $? -ne 0 ]; then + cat /var/log/puppetlabs/puppetserver/puppetserver.log + exit 1 + fi + EOF + bolt script run standup.sh --inventory inventory.yaml --targets test-primary-1 --stream + - name: Write agent configure params + run: |- + cat > agent-params.json < site.pp <<'EOF' + node default { + notify { "Trusted Facts": + message => $trusted, + } + if $trusted.dig('extensions', 'pp_role') != 'tomato' { + fail("Certificate extension 'pp_role' should be 'tomato'. trusted['extensions'] = ${trusted['extensions']}") + } + } + EOF + bolt file upload site.pp /etc/puppetlabs/code/environments/production/manifests/site.pp --inventory inventory.yaml --targets test-primary-1 + bolt command run '/opt/puppetlabs/bin/puppet agent --agent_disabled_lockfile=/tmp/not_locked.lock --onetime --verbose --no-daemonize' --inventory inventory.yaml --targets test-primary-1,test-agent-1 --stream + - name: Validate service state + run: |- + cat > apply.sh <<'EOF' + set -e + /opt/puppetlabs/bin/puppet apply --test -e 'service { "puppet": ensure => running, enable => false }' + EOF + # Use script rather than bolt apply so that we trip if the + # apply produces changes and returns an exitcode of 2. + bolt script run apply.sh --inventory inventory.yaml --targets test-agent-1,test-primary-1 --stream diff --git a/README.md b/README.md index 01b9790..08be647 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,60 @@ bolt task run openvox_bootstrap::install_build_artifact \ --run-as root ``` +### openvox_bootstrap::configure + +The openvox_bootstrap::configure task can be used to provide very +basic initial configuration for the openvox agent. + +It does not install the agent. Run openvox_bootstrap::install first. +Since the agent service is installed stopped, configuration can +be laid down before the first run begins the certificate request +process. + +It provides the following support: + +* laying down an initial puppet.conf (primary use case being to set + the [server] parameter to point to the openvox-server). +* creating a [csr_attributes.yaml] file for the agent to use when + generating a CSR for use with autosigning scripts and to provide + extension data to the generated certificate. +* ensuring the `puppet` service is in a preferred state. + +NOTE: the csr_attributes.yaml will overwrite any pre-existing files, +but settings for puppet.conf will be merged into an existing file if +present. + +With an example params.json file like this: + +```json +{ + "puppet_conf": { + "main": { + "server": "puppetserver.foo" + } + }, + "csr_attributes": { + "custom_attributes": { + "1.2.840.113549.1.9.7": "password" + }, + "extension_requests": { + "pp_role": "thing1" + } + }, + "puppet_service_running": true, + "puppet_service_enabled": true +} +``` + +You can run the task like this: + +```sh +bolt task run openvox_bootstrap::configure \ + --targets \ + --params @params.json \ + --run-as root +``` + ## Reference See [REFERENCE.md](./REFERENCE.md) for the generated reference doc. @@ -117,3 +171,5 @@ along with this program. If not, see . [puppet_agent::install tasks]: https://github.com/puppetlabs/puppetlabs-puppet_agent/tree/main?tab=readme-ov-file#puppet_agentinstall [apply_prep]: https://www.puppet.com/docs/bolt/latest/plan_functions#apply-prep [puppet_library]: https://www.puppet.com/docs/bolt/latest/using_plugins#puppet-library-plugins +[server]: https://github.com/puppetlabs/puppet/blob/main/references/configuration.md#server +[csr_attributes.yaml]: https://help.puppet.com/core/current/Content/PuppetCore/config_file_csr_attributes.htm diff --git a/REFERENCE.md b/REFERENCE.md index ad74f99..ac0c572 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -4,12 +4,61 @@ ## Table of Contents +### Data types + +* [`Openvox_bootstrap::Cer_short_names`](#Openvox_bootstrap--Cer_short_names): Certificate extension request short names. These are the allowed short names documented for Puppet(TM) extension requests per [csr_attributes +* [`Openvox_bootstrap::Csr_attributes`](#Openvox_bootstrap--Csr_attributes): [csr_attributes.yaml](https://help.puppet.com/core/current/Content/PuppetCore/config_file_csr_attributes.htm) +* [`Openvox_bootstrap::Ini_file`](#Openvox_bootstrap--Ini_file): Simple type for data to be transformed to an INI file format. +* [`Openvox_bootstrap::Oid`](#Openvox_bootstrap--Oid): Object Identifier per https://en.wikipedia.org/wiki/Object_identifier + ### Tasks * [`check`](#check): Check whether a Puppet(tm) implementation is installed. Optionally checks the version. +* [`configure`](#configure): Provides initial configuration for a freshly installed openvox-agent. * [`install`](#install): Installs an openvox package. By default, this will be the latest openvox-agent from the latest collection. * [`install_build_artifact`](#install_build_artifact): Downloads and installs a package directly from the openvox build artifact server. +## Data types + +### `Openvox_bootstrap::Cer_short_names` + +Certificate extension request short names. +These are the allowed short names documented for Puppet(TM) +extension requests per [csr_attributes.yaml](https://help.puppet.com/core/current/Content/PuppetCore/config_file_csr_attributes.htm) + +Alias of `Enum['pp_uuid', 'pp_instance_id', 'pp_image_name', 'pp_preshared_key', 'pp_cost_center', 'pp_product', 'pp_project', 'pp_application', 'pp_service', 'pp_employee', 'pp_created_by', 'pp_environment', 'pp_role', 'pp_software_version', 'pp_department', 'pp_cluster', 'pp_provisioner', 'pp_region', 'pp_datacenter', 'pp_zone', 'pp_network', 'pp_securitypolicy', 'pp_cloudplatform', 'pp_apptier', 'pp_hostname', 'pp_authorization', 'pp_auth_role']` + +### `Openvox_bootstrap::Csr_attributes` + +[csr_attributes.yaml](https://help.puppet.com/core/current/Content/PuppetCore/config_file_csr_attributes.htm) + +Alias of + +```puppet +Struct[{ + Optional['custom_attributes'] => Hash[ + Openvox_bootstrap::Oid, + String + ], + Optional['extension_requests'] => Hash[ + Variant[Openvox_bootstrap::Oid,Openvox_bootstrap::Cer_short_names], + String + ], + }] +``` + +### `Openvox_bootstrap::Ini_file` + +Simple type for data to be transformed to an INI file format. + +Alias of `Hash[String, Hash[String, String]]` + +### `Openvox_bootstrap::Oid` + +Object Identifier per https://en.wikipedia.org/wiki/Object_identifier + +Alias of `Pattern[/\d+(\.\d+)*/]` + ## Tasks ### `check` @@ -32,6 +81,38 @@ Data type: `Enum['eq', 'lt', 'le', 'gt', 'ge']` Version comparison operator. +### `configure` + +Provides initial configuration for a freshly installed openvox-agent. + +**Supports noop?** false + +#### Parameters + +##### `puppet_conf` + +Data type: `Optional[Openvox_bootstrap::Ini_file]` + +Hash of puppet configuration settings to add to the puppet.conf ini file. These will be merged into the existing puppet.conf, if any. + +##### `csr_attributes` + +Data type: `Optional[Openvox_bootstrap::Csr_attributes]` + +Hash of CSR attributes (custom_attributes and extension_requests) to write to the csr_attributes.yaml file. NOTE: This will completely overwrite any pre-existing csr_attributes.yaml. + +##### `puppet_service_running` + +Data type: `Boolean` + +Whether the Puppet service should be running after this task completes. Defaults to true. + +##### `puppet_service_enabled` + +Data type: `Boolean` + +Whether the Puppet service should be enabled to start on boot after this task completes. Defaults to true. + ### `install` Installs an openvox package. By default, this will be the latest openvox-agent from the latest collection. diff --git a/lib/openvox_bootstrap/task.rb b/lib/openvox_bootstrap/task.rb new file mode 100644 index 0000000..88ce7f2 --- /dev/null +++ b/lib/openvox_bootstrap/task.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'json' + +module OpenvoxBootstrap + # Base class for openvox_bootstrap Ruby tasks. + class Task + # Run the task and print the result as JSON. + def self.run + params = JSON.parse($stdin.read) + raise(ArgumentError, <<~ERR) unless params.is_a?(Hash) + Expected a Hash, got #{params.class}: #{params.inspect} + ERR + + params.transform_keys!(&:to_sym) + # Clean out empty params so that task defaults are used. + params.delete_if { |_, v| v.nil? || v == '' } + + result = new.task(**params) + puts JSON.pretty_generate(result) + + result + end + end +end diff --git a/spec/bash_spec_helper.rb b/spec/bash_spec_helper.rb index d53170e..dd3078e 100644 --- a/spec/bash_spec_helper.rb +++ b/spec/bash_spec_helper.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'json' require 'tmpdir' require 'rspec' require 'lib/bash_rspec' diff --git a/spec/lib/contexts.rb b/spec/lib/contexts.rb index c8fcad6..e926d57 100644 --- a/spec/lib/contexts.rb +++ b/spec/lib/contexts.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'json' - # Mirrors the output from puppetlabs-facts. module OBRspecFacts UBUNTU_2404 = { @@ -130,3 +128,24 @@ def mock_facts_task_bash_sh(os) EOF end end + +RSpec.shared_context 'task_run_helpers' do + def validate_task_run_for(subject, input:, expected: {}, code: 0) + old_stdin = $stdin + $stdin = StringIO.new(input.to_json) + old_stdout = $stdout + $stdout = StringIO.new + + begin + subject.run + rescue SystemExit => e + expect(e.status).to eq(code) + end + + output = JSON.parse($stdout.string, symbolize_names: true) + expect(output).to eq(expected) + ensure + $stdin = old_stdin + $stdout = old_stdout + end +end diff --git a/spec/tasks/check_spec.rb b/spec/tasks/check_spec.rb index d7ece34..dc148af 100644 --- a/spec/tasks/check_spec.rb +++ b/spec/tasks/check_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require 'lib/contexts' require_relative '../../tasks/check' describe 'openvox_bootstrap::check' do @@ -9,6 +10,8 @@ end describe '.run' do + include_context 'task_run_helpers' + let(:input) do { version: nil @@ -21,23 +24,8 @@ } end - def validate_task_run(input:, expected: {}, code: 0) - old_stdin = $stdin - $stdin = StringIO.new(input.to_json) - old_stdout = $stdout - $stdout = StringIO.new - - begin - OpenvoxBootstrap::Check.run - rescue SystemExit => e - expect(e.status).to eq(code) - end - - output = JSON.parse($stdout.string, symbolize_names: true) - expect(output).to eq(expected) - ensure - $stdin = old_stdin - $stdout = old_stdout + def validate_task_run(input:, expected:, code: 0) + validate_task_run_for(OpenvoxBootstrap::Check, input: input, expected: expected, code: code) end it 'raises for empty input' do diff --git a/spec/tasks/configure_spec.rb b/spec/tasks/configure_spec.rb new file mode 100644 index 0000000..bc3e9e4 --- /dev/null +++ b/spec/tasks/configure_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../tasks/configure' + +# rubocop:disable RSpec/MessageSpies +# rubocop:disable RSpec/StubbedMock +# rubocop:disable RSpec/MultipleMemoizedHelpers +describe 'openvox_bootstrap::configure' do + let(:tmpdir) { Dir.mktmpdir('openvox_bootstrap-configure-spec') } + let(:task) { OpenvoxBootstrap::Configure.new } + let(:puppet_config_set_calls) { [] } + + around do |example| + example.run + ensure + FileUtils.remove_entry_secure(tmpdir) + end + + before do + allow(task).to receive(:puppet_config_set) do |section, key, value| + puppet_config_set_calls << [section, key, value] + if key == 'oops' + ['error output', instance_double(Process::Status, success?: false)] + else + ['', instance_double(Process::Status, success?: true)] + end + end + end + + describe '#puppet_config_set' do + it 'calls puppet config set with the correct arguments' do + expect(Open3).to receive(:capture2e).with( + '/opt/puppetlabs/bin/puppet', + 'config', + 'set', + '--section', 'main', + 'server', + 'puppet.spec' + ) + t = OpenvoxBootstrap::Configure.new + t.puppet_config_set('main', 'server', 'puppet.spec') + end + end + + describe '#update_puppet_conf' do + let(:puppet_conf) do + { + 'main' => { + 'server' => 'puppet.spec', + 'certname' => 'agent.spec', + }, + 'agent' => { + 'environment' => 'test' + } + } + end + let(:puppet_conf_path) { File.join(tmpdir, 'puppet.conf') } + + it 'call puppet config set' do + expect(task.update_puppet_conf(puppet_conf, tmpdir)).to( + eq( + { + puppet_conf: { + path: puppet_conf_path, + contents: '', + successful: true, + } + } + ) + ) + expect(puppet_config_set_calls).to eq( + [ + ['main', 'server', 'puppet.spec'], + ['main', 'certname', 'agent.spec'], + ['agent', 'environment', 'test'], + ] + ) + end + + it 'does nothing if given an empty config' do + expect(task.update_puppet_conf(nil)).to eq({}) + expect(task.update_puppet_conf({})).to eq({}) + end + + it 'records error output if puppet config set fails' do + puppet_conf['main']['oops'] = 'fail' + expect(task.update_puppet_conf(puppet_conf, tmpdir)).to( + eq( + { + puppet_conf: { + path: puppet_conf_path, + contents: '', + successful: false, + errors: { + '--section=main oops=fail' => 'error output', + }, + } + } + ) + ) + end + end + + def check_returned_id(uid_or_gid) + case uid_or_gid + when Integer + uid_or_gid > 0 + when nil + true # If the user does not exist, it returns nil. + else + false # Should not return anything else. + end + end + + describe '#puppet_uid' do + it 'returns the UID of the puppet user' do + expect(task.puppet_uid).to satisfy do |uid| + check_returned_id(uid) + end + end + end + + describe '#puppet_gid' do + it 'returns the GID of the puppet group' do + expect(task.puppet_gid).to satisfy do |gid| + check_returned_id(gid) + end + end + end + + describe '#write_csr_attributes' do + let(:csr_attributes) do + { + 'custom_attributes' => { + '1.2.840.113549.1.9.7' => 'bar', + }, + 'extension_requests' => { + 'pp_role' => 'spec' + } + } + end + let(:csr_attributes_path) { File.join(tmpdir, 'csr_attributes.yaml') } + let(:csr_attributes_contents) do + <<~YAML + --- + custom_attributes: + 1.2.840.113549.1.9.7: bar + extension_requests: + pp_role: spec + YAML + end + + it 'writes a csr_attributes.yaml file' do + expect(task.write_csr_attributes(csr_attributes, tmpdir)).to( + eq( + { + csr_attributes: { + path: csr_attributes_path, + contents: csr_attributes_contents, + successful: true, + } + } + ) + ) + expect(File.read(csr_attributes_path)).to eq(csr_attributes_contents) + expect(File.stat(csr_attributes_path).mode & 0o777).to eq(0o640) + end + + it 'does nothing if given an empty config' do + expect(task.write_csr_attributes(nil)).to eq({}) + expect(task.write_csr_attributes({})).to eq({}) + end + end + + describe '#manage_puppet_service' do + def status(code = 0) + instance_double(Process::Status, exitstatus: code) + end + + it 'is successful for a 0 exit code' do + command = [ + '/opt/puppetlabs/bin/puppet', + 'apply', + '--detailed-exitcodes', + '-e', + %(service { 'puppet': ensure => running, enable => true, }), + ] + expect(Open3).to receive(:capture2e).with(*command).and_return(['applied', status]) + + expect(task.manage_puppet_service(true, true)).to eq( + { + puppet_service: { + command: command.join(' '), + output: 'applied', + successful: true, + } + } + ) + end + + it 'is successful for a 2 exit code' do + expect(Open3).to receive(:capture2e).and_return(['applied', status(2)]) + + result = task.manage_puppet_service(true, true) + expect(result.dig(:puppet_service, :successful)).to be true + end + + it 'fails for a non 0, 2 exit code' do + expect(Open3).to receive(:capture2e).and_return(['applied', status(1)]) + + result = task.manage_puppet_service(true, true) + expect(result.dig(:puppet_service, :successful)).to be false + end + end + + describe '#task' do + it 'returns a result hash if puppet service is managed successfully' do + expect(task).to receive(:manage_puppet_service).and_return({ puppet_service: { successful: true } }) + + expect(task.task).to eq( + { + puppet_service: { successful: true }, + } + ) + end + + it 'returns a result has if all steps are successful' do + expect(task).to receive(:update_puppet_conf).and_return({ puppet_conf: { successful: true } }) + expect(task).to receive(:write_csr_attributes).and_return({ csr_attributes: { successful: true } }) + expect(task).to receive(:manage_puppet_service).and_return({ puppet_service: { successful: true } }) + + expect(task.task).to eq( + { + csr_attributes: { successful: true }, + puppet_conf: { successful: true }, + puppet_service: { successful: true }, + } + ) + end + + it 'prints results and exits 1 if puppet service fails' do + expect(task).to( + receive(:manage_puppet_service). + and_return( + { + puppet_service: { + successful: false, + output: "apply failed\n", + } + } + ) + ) + + expect { task.task }.to( + raise_error(SystemExit).and( + output(<<~EOM).to_stdout + { + "puppet_service": { + "successful": false, + "output": "apply failed\\n" + } + } + + Failed managing puppet_service: + + apply failed + EOM + ).and(output('').to_stderr) + ) + end + + it 'prints results and exits 1 if any step fails' do + expect(task).to receive(:manage_puppet_service).and_return({ puppet_service: { successful: true } }) + expect(task).to( + receive(:update_puppet_conf). + and_return( + { + puppet_conf: { + successful: false, + errors: { '--section=main server=puppet.spec' => 'error output' } + } + } + ) + ) + + expect { task.task }.to( + raise_error(SystemExit).and( + output(<<~EOM).to_stdout + { + "puppet_conf": { + "successful": false, + "errors": { + "--section=main server=puppet.spec": "error output" + } + }, + "puppet_service": { + "successful": true + } + } + + Failed managing puppet_conf: + + {"--section=main server=puppet.spec"=>"error output"} + EOM + ).and(output('').to_stderr) + ) + end + end +end +# rubocop:enable RSpec/MessageSpies +# rubocop:enable RSpec/StubbedMock +# rubocop:enable RSpec/MultipleMemoizedHelpers diff --git a/spec/unit/openvox_bootstrap/task_spec.rb b/spec/unit/openvox_bootstrap/task_spec.rb new file mode 100644 index 0000000..3180eb1 --- /dev/null +++ b/spec/unit/openvox_bootstrap/task_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'lib/contexts' +require 'openvox_bootstrap/task' + +class OpenvoxBootstrap::TaskTester < OpenvoxBootstrap::Task + def task(foo:, baz: 'default') + { + foo: foo, + baz: baz, + } + end +end + +describe 'openvox_bootstrap::task' do + describe '.run' do + include_context 'task_run_helpers' + + let(:input) { { foo: 'bar', baz: 'dingo' } } + let(:expected_output) do + { + foo: 'bar', + baz: 'dingo', + } + end + let(:tester) { OpenvoxBootstrap::TaskTester } + + it 'raises for empty input' do + expect do + validate_task_run_for(tester, input: nil) + end.to raise_error(ArgumentError) + end + + it 'returns the task result' do + validate_task_run_for(tester, input: input, expected: expected_output) + end + + it 'uses default values for missing params' do + input[:baz] = nil + expected_output[:baz] = 'default' + validate_task_run_for(tester, input: input, expected: expected_output) + end + end +end diff --git a/tasks/check.json b/tasks/check.json index 6449c9f..3ce9072 100644 --- a/tasks/check.json +++ b/tasks/check.json @@ -15,13 +15,18 @@ { "name": "check.rb", "input_method": "stdin", - "requirements": ["puppet-agent"] + "requirements": ["puppet-agent"], + "files": [ + "openvox_bootstrap/lib/openvox_bootstrap/task.rb", + "openvox_bootstrap/tasks/check.rb" + ] }, { "name": "check_linux.sh", "input_method": "environment", "requirements": ["shell"], "files": [ + "openvox_bootstrap/lib/openvox_bootstrap/task.rb", "openvox_bootstrap/tasks/check.rb" ] } diff --git a/tasks/check.rb b/tasks/check.rb index 26e9df9..a3f1376 100755 --- a/tasks/check.rb +++ b/tasks/check.rb @@ -1,10 +1,10 @@ #! /opt/puppetlabs/puppet/bin/ruby # frozen_string_literal: true -require 'json' +require_relative '../lib/openvox_bootstrap/task' module OpenvoxBootstrap - class Check + class Check < Task # Get the Puppet version from the installed Puppet library. # # "require 'puppet/version'" can be fooled by the Ruby environment @@ -22,17 +22,7 @@ def self.puppet_version # Run the task and print the result as JSON. def self.run - params = JSON.parse($stdin.read) - raise(ArgumentError, <<~ERR) unless params.is_a?(Hash) - Expected a Hash, got #{params.class}: #{params.inspect} - ERR - - params.transform_keys!(&:to_sym) - # Clean out empty params so that task defaults are used. - params.delete_if { |_, v| v.nil? || v == '' } - - result = Check.new.task(**params) - puts JSON.pretty_generate(result) + result = super result[:valid] ? exit(0) : exit(1) end diff --git a/tasks/configure.json b/tasks/configure.json new file mode 100644 index 0000000..c38dc59 --- /dev/null +++ b/tasks/configure.json @@ -0,0 +1,27 @@ +{ + "description": "Provides initial configuration for a freshly installed openvox-agent.", + "input_method": "stdin", + "files": [ + "openvox_bootstrap/lib/openvox_bootstrap/task.rb" + ], + "parameters": { + "puppet_conf": { + "description": "Hash of puppet configuration settings to add to the puppet.conf ini file. These will be merged into the existing puppet.conf, if any.", + "type": "Optional[Openvox_bootstrap::Ini_file]" + }, + "csr_attributes": { + "description": "Hash of CSR attributes (custom_attributes and extension_requests) to write to the csr_attributes.yaml file. NOTE: This will completely overwrite any pre-existing csr_attributes.yaml.", + "type": "Optional[Openvox_bootstrap::Csr_attributes]" + }, + "puppet_service_running": { + "description": "Whether the Puppet service should be running after this task completes. Defaults to true.", + "type": "Boolean", + "default": true + }, + "puppet_service_enabled": { + "description": "Whether the Puppet service should be enabled to start on boot after this task completes. Defaults to true.", + "type": "Boolean", + "default": true + } + } +} diff --git a/tasks/configure.rb b/tasks/configure.rb new file mode 100755 index 0000000..577a279 --- /dev/null +++ b/tasks/configure.rb @@ -0,0 +1,176 @@ +#! /opt/puppetlabs/puppet/bin/ruby +# frozen_string_literal: true + +require_relative '../lib/openvox_bootstrap/task' +require 'etc' +require 'open3' +require 'yaml' + +# rubocop:disable Style/NegatedIf +module OpenvoxBootstrap + class Configure < Task + def puppet_uid + Etc.getpwnam('puppet').uid + rescue ArgumentError + nil + end + + def puppet_gid + Etc.getgrnam('puppet').gid + rescue ArgumentError + nil + end + + def puppet_config_set(section, key, value) + command = [ + '/opt/puppetlabs/bin/puppet', + 'config', + 'set', + '--section', section, + key, + value, + ] + Open3.capture2e(*command) + end + + # Add the given settings to the puppet.conf file using + # puppet-config. + # + # Does nothing if given an empty or nil settings hash. + # + # @param settings [Hash>] + # A hash of sections and settings to add to the + # puppet.conf file. + # @return [Hash] + def update_puppet_conf(settings, etc_puppet_path = '/etc/puppetlabs/puppet') + return {} if settings.nil? || settings.empty? + + conf_path = File.join(etc_puppet_path, 'puppet.conf') + success = true + errors = {} + settings.each do |section, section_settings| + section_settings.each do |key, value| + output, status = puppet_config_set(section, key, value) + success &&= status.success? + if !status.success? + err_key = "--section=#{section} #{key}=#{value}" + errors[err_key] = output + end + end + end + + puppet_conf_contents = if File.exist?(conf_path) + File.read(conf_path) + else + '' + end + + result = { + puppet_conf: { + path: conf_path, + contents: puppet_conf_contents, + successful: success, + } + } + result[:puppet_conf][:errors] = errors if !success + result + end + + # Overwrite the csr_attributes.yaml file with the given + # csr_attributes hash. + # + # Does nothing if given an empty or nil csr_attributes. + # + # The file will be mode 640. + # It will either be owned root:root (assuming task is run as root, + # as expected), or puppet:puppet if the puppet user and group + # exist (openvox-server package is installed). + # + # @param csr_attributes [Hash] A hash of custom_attributes + # and extension_requests to write to the csr_attributes.yaml + # file. + # @return [Hash] + def write_csr_attributes(csr_attributes, etc_puppet_path = '/etc/puppetlabs/puppet') + return {} if csr_attributes.nil? || csr_attributes.empty? + + csr_attributes_path = File.join(etc_puppet_path, 'csr_attributes.yaml') + csr_attributes_contents = csr_attributes.to_yaml + File.open(csr_attributes_path, 'w', perm: 0o640) do |f| + f.write(csr_attributes_contents) + end + # nil uid/gid are ignored by FileUtils.chown... + File.chown(puppet_uid, puppet_gid, csr_attributes_path) + + { + csr_attributes: { + path: csr_attributes_path, + contents: csr_attributes_contents, + successful: true, + } + } + end + + # Manage the puppet service using puppet apply. + def manage_puppet_service(running, enabled) + manifest = <<~MANIFEST + service { 'puppet': + ensure => #{running ? 'running' : 'stopped'}, + enable => #{enabled}, + } + MANIFEST + + command = [ + '/opt/puppetlabs/bin/puppet', + 'apply', + '--detailed-exitcodes', + '-e', + manifest.gsub(%r{\n}, ' ').strip, + ] + + output, status = Open3.capture2e(*command) + success = [0, 2].include?(status.exitstatus) + + { + puppet_service: { + command: command.join(' '), + output: output, + successful: success, + } + } + end + + def task( + puppet_conf: {}, + csr_attributes: {}, + puppet_service_running: true, + puppet_service_enabled: true, + **_kwargs + ) + results = {} + results.merge!(update_puppet_conf(puppet_conf)) + results.merge!(write_csr_attributes(csr_attributes)) + results.merge!( + manage_puppet_service(puppet_service_running, puppet_service_enabled) + ) + + success = results.all? { |_, details| details[:successful] } + + if success + results + else + puts JSON.pretty_generate(results) + results.each do |config, details| + next if details[:successful] + + puts "\nFailed managing #{config}:\n\n" + puts details[:output] if details.key?(:output) + pp details[:errors] if details.key?(:errors) + end + exit 1 + end + end + end +end +# rubocop:enable Style/NegatedIf + +OpenvoxBootstrap::Configure.run if __FILE__ == $PROGRAM_NAME diff --git a/types/cer_short_names.pp b/types/cer_short_names.pp new file mode 100644 index 0000000..5478c27 --- /dev/null +++ b/types/cer_short_names.pp @@ -0,0 +1,34 @@ +# Certificate extension request short names. +# These are the allowed short names documented for Puppet(TM) +# extension requests per [csr_attributes.yaml](https://help.puppet.com/core/current/Content/PuppetCore/config_file_csr_attributes.htm) +type Openvox_bootstrap::Cer_short_names = Enum[ + # 1.3.6.1.4.1.34380.1.1 range + 'pp_uuid', + 'pp_instance_id', + 'pp_image_name', + 'pp_preshared_key', + 'pp_cost_center', + 'pp_product', + 'pp_project', + 'pp_application', + 'pp_service', + 'pp_employee', + 'pp_created_by', + 'pp_environment', + 'pp_role', + 'pp_software_version', + 'pp_department', + 'pp_cluster', + 'pp_provisioner', + 'pp_region', + 'pp_datacenter', + 'pp_zone', + 'pp_network', + 'pp_securitypolicy', + 'pp_cloudplatform', + 'pp_apptier', + 'pp_hostname', + # 1.3.6.1.4.1.34380.1.3 range + 'pp_authorization', + 'pp_auth_role', +] diff --git a/types/csr_attributes.pp b/types/csr_attributes.pp new file mode 100644 index 0000000..4ae716e --- /dev/null +++ b/types/csr_attributes.pp @@ -0,0 +1,13 @@ +# [csr_attributes.yaml](https://help.puppet.com/core/current/Content/PuppetCore/config_file_csr_attributes.htm) +type Openvox_bootstrap::Csr_attributes = Struct[ + { + Optional['custom_attributes'] => Hash[ + Openvox_bootstrap::Oid, + String + ], + Optional['extension_requests'] => Hash[ + Variant[Openvox_bootstrap::Oid,Openvox_bootstrap::Cer_short_names], + String + ], + } +] diff --git a/types/ini_file.pp b/types/ini_file.pp new file mode 100644 index 0000000..34fef4a --- /dev/null +++ b/types/ini_file.pp @@ -0,0 +1,5 @@ +# Simple type for data to be transformed to an INI file format. +type Openvox_bootstrap::Ini_file = Hash[ + String, # Section name + Hash[String, String] # Key-value pairs within the section +] diff --git a/types/oid.pp b/types/oid.pp new file mode 100644 index 0000000..6e18438 --- /dev/null +++ b/types/oid.pp @@ -0,0 +1,2 @@ +# Object Identifier per https://en.wikipedia.org/wiki/Object_identifier +type Openvox_bootstrap::Oid = Pattern[/\d+(\.\d+)*/]