From cd72bb6148ad6b531808494628fecb1989282ef0 Mon Sep 17 00:00:00 2001 From: Josh Partlow Date: Tue, 14 Dec 2021 23:06:04 +0000 Subject: [PATCH] (maint) Add a spec case for the provision::abs task The abs.rb is refactored into a class that is executed only if the task file is the executing program. This allows the spec to load the task and manipulate the class while mocking externals. The webmock gem is used to provide HTTP::Net mocking. The spec is not exhaustive, but hopefully covers enough to validate that the refactor didn't break the task, and that the bug I added in #182 is patched. It could be expanded, and could serve as a template for adding specs for other tasks in the module. It does look to expose an edge case bug of calling the provision task with an inventory directory specified that contains a pre-existing inventory file. --- Gemfile | 1 + spec/tasks/abs_spec.rb | 152 ++++++++++++++++++++++ tasks/abs.rb | 282 +++++++++++++++++++++-------------------- 3 files changed, 300 insertions(+), 135 deletions(-) create mode 100644 spec/tasks/abs_spec.rb diff --git a/Gemfile b/Gemfile index 5bd8002..8005692 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ group :development do gem "puppet-module-win-default-r#{minor_version}", require: false, platforms: %i[mswin mingw x64_mingw] gem "puppet-module-win-dev-r#{minor_version}", require: false, platforms: %i[mswin mingw x64_mingw] gem 'github_changelog_generator', require: false if Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new('2.5.0') + gem 'webmock' end # Evaluate Gemfile.local and ~/.gemfile if they exist diff --git a/spec/tasks/abs_spec.rb b/spec/tasks/abs_spec.rb new file mode 100644 index 0000000..556949f --- /dev/null +++ b/spec/tasks/abs_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'webmock/rspec' +require_relative '../../tasks/abs.rb' +require 'yaml' + +RSpec.shared_context('with_tmpdir') do + let(:tmpdir) { @tmpdir } # rubocop:disable RSpec/InstanceVariable + + around(:each) do |example| + Dir.mktmpdir('rspec-provision_test') do |t| + @tmpdir = t + example.run + end + end +end + +describe 'provision::abs' do + let(:abs) { ABSProvision.new } + let(:inventory_dir) { "#{tmpdir}/spec/fixtures" } + let(:inventory_file) { "#{inventory_dir}/litmus_inventory.yaml" } + let(:empty_inventory_yaml) do + <<~YAML + --- + version: 2 + groups: + - name: docker_nodes + targets: [] + - name: ssh_nodes + targets: [] + - name: winrm_nodes + targets: [] + YAML + end + + include_context('with_tmpdir') + + before(:each) do + FileUtils.mkdir_p(inventory_dir) + end + + context '.run' do + it 'handles JSON parameters from stdin' do + json_input = '{"action":"foo","platform":"bar"}' + expect($stdin).to receive(:read).and_return(json_input) + + expect { ABSProvision.run }.to( + raise_error(SystemExit) { |e| + expect(e.status).to eq(0) + }.and( + output("null\n").to_stdout, + ), + ) + end + + it 'raises an error when platform not given for provision' do + expect($stdin).to receive(:read).and_return('{"action":"provision"}') + + expect { ABSProvision.run }.to raise_error(RuntimeError, %r{specify a platform when provisioning}) + end + + it 'raises an error when node_name not given for tear_down' + it 'raises an error if both node_name and platform are given' + end + + context 'provision' do + let(:params) do + { + action: 'provision', + platform: 'redhat-8-x86_64', + inventory: tmpdir, + } + end + let(:response_body) do + [ + { + 'type' => 'redhat-8-x86_64', + 'hostname' => 'foo-bar.test', + }, + ] + end + + it 'provisions the platform' do + stub_request(:post, 'https://abs-prod.k8s.infracore.puppet.net/api/v2/request') + .to_return({ status: 202 }, { status: 200, body: response_body.to_json }) + + expect(abs.task(params)).to eq({ status: 'ok', nodes: 1 }) + + updated_inventory = YAML.load_file(inventory_file) + ssh_targets = updated_inventory['groups'].find { |g| g['name'] == 'ssh_nodes' }['targets'] + expect(ssh_targets.size).to eq(1) + expect(ssh_targets.first.dig('facts', 'platform')).to eq('redhat-8-x86_64') + end + + it 'provision with an existing inventory file' do + pending(<<~EOS) + XXX: (#187) It looks like there's an error hidden here in the way + lib/task_helper.rb get_inventory_hash() attempts to call + PuppetLitmus::InventoryManipulation.inventory_hash_from_inventory_file() + as though it were a class method. + EOS + + stub_request(:post, 'https://abs-prod.k8s.infracore.puppet.net/api/v2/request') + .to_return({ status: 202 }, { status: 200, body: response_body.to_json }) + + File.write(inventory_file, empty_inventory_yaml) + + expect(abs.task(params)).to eq({ status: 'ok', nodes: 1 }) + end + + it 'raises an error if abs returns error response' + end + + context 'teardown' do + let(:params) do + { + action: 'tear_down', + node_name: 'foo-bar.test', + inventory: tmpdir, + } + end + let(:inventory_yaml) do + empty = YAML.safe_load(empty_inventory_yaml) + groups = empty['groups'] + ssh_nodes = groups.find { |g| g['name'] == 'ssh_nodes' } + ssh_nodes['targets'] << { + 'uri' => 'foo-bar.test', + 'facts' => { + 'platform' => 'redhat-8-x86_64', + 'job_id' => 'a-job-id', + } + } + empty.to_yaml + end + + before(:each) do + File.write(inventory_file, inventory_yaml) + end + + it 'tear_down a node' do + expect(abs).to receive(:token_from_fogfile).and_return('fog-token') + stub_request(:post, 'https://abs-prod.k8s.infracore.puppet.net/api/v2/return') + .to_return(status: 200) + + expect(abs.task(params)).to eq({ status: 'ok', removed: [ 'foo-bar.test' ] }) + expect(YAML.load_file(inventory_file)).to eq(YAML.safe_load(empty_inventory_yaml)) + end + + it 'raises an error if abs returns error response' + end +end diff --git a/tasks/abs.rb b/tasks/abs.rb index f3363fd..46deda3 100755 --- a/tasks/abs.rb +++ b/tasks/abs.rb @@ -9,157 +9,169 @@ require 'date' require_relative '../lib/task_helper' -def provision(platform, inventory_location, vars) +# Provision and teardown vms through ABS. +class ABSProvision include PuppetLitmus::InventoryManipulation - uri = URI.parse('https://abs-prod.k8s.infracore.puppet.net/api/v2/request') - jenkins_build_url = if ENV['CI'] == 'true' && ENV['TRAVIS'] == 'true' - ENV['TRAVIS_JOB_WEB_URL'] - elsif ENV['CI'] == 'True' && ENV['APPVEYOR'] == 'True' - "https://ci.appveyor.com/project/#{ENV['APPVEYOR_REPO_NAME']}/build/job/#{ENV['APPVEYOR_JOB_ID']}" - elsif ENV['GITHUB_ACTIONS'] == 'true' - "https://github.com/#{ENV['GITHUB_REPOSITORY']}/actions/runs/#{ENV['GITHUB_RUN_ID']}" - else - 'https://litmus_manual' - end - # Job ID must be unique - job_id = "iac-task-pid-#{Process.pid}-#{DateTime.now.strftime('%Q')}" - - headers = { 'X-AUTH-TOKEN' => token_from_fogfile('abs'), 'Content-Type' => 'application/json' } - priority = (ENV['CI']) ? 1 : 2 - payload = if platform.class == String - { 'resources' => { platform => 1 }, - 'priority' => priority, - 'job' => { 'id' => job_id, - 'tags' => { 'user' => Etc.getlogin, 'jenkins_build_url' => jenkins_build_url } } } - else - { 'resources' => platform, - 'priority' => priority, - 'job' => { 'id' => job_id, - 'tags' => { 'user' => Etc.getlogin, 'jenkins_build_url' => jenkins_build_url } } } - end - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - request = Net::HTTP::Post.new(uri.request_uri, headers) - request.body = payload.to_json - - # Make an initial request - we should receive a 202 response to indicate the request is being processed - reply = http.request(request) - # Use this 'puts' only for debugging purposes - # Do not use this in production mode because puppet_litmus will parse the STDOUT to extract the results - # puts "#{Time.now.strftime('%Y/%m/%d %H:%M:%S')}: Received: #{reply.code} #{reply.message} from ABS" - raise "Error: #{reply}: #{reply.message}" unless reply.is_a?(Net::HTTPAccepted) # should be a 202 - - # We want to then poll the API until we get a 200 response, indicating the VMs have been provisioned - timeout = Time.now.to_i + 600 # Let's poll the API for a max of 10 minutes - sleep_time = 1 - - # Progressively increase the sleep time by 1 second. When we hit 10 seconds, start querying every 30 seconds until we - # exceed the time out. This is an attempt to strike a balance between quick provisioning and not saturating the ABS - # API and network if it's taking longer to provision than usual - while Time.now.to_i < timeout - sleep (sleep_time <= 10) ? sleep_time : 30 # rubocop:disable Lint/ParenthesesAsGroupedExpression + + def provision(platform, inventory_location, vars) + uri = URI.parse('https://abs-prod.k8s.infracore.puppet.net/api/v2/request') + jenkins_build_url = if ENV['CI'] == 'true' && ENV['TRAVIS'] == 'true' + ENV['TRAVIS_JOB_WEB_URL'] + elsif ENV['CI'] == 'True' && ENV['APPVEYOR'] == 'True' + "https://ci.appveyor.com/project/#{ENV['APPVEYOR_REPO_NAME']}/build/job/#{ENV['APPVEYOR_JOB_ID']}" + elsif ENV['GITHUB_ACTIONS'] == 'true' + "https://github.com/#{ENV['GITHUB_REPOSITORY']}/actions/runs/#{ENV['GITHUB_RUN_ID']}" + else + 'https://litmus_manual' + end + # Job ID must be unique + job_id = "iac-task-pid-#{Process.pid}-#{DateTime.now.strftime('%Q')}" + + headers = { 'X-AUTH-TOKEN' => token_from_fogfile('abs'), 'Content-Type' => 'application/json' } + priority = (ENV['CI']) ? 1 : 2 + payload = if platform.class == String + { 'resources' => { platform => 1 }, + 'priority' => priority, + 'job' => { 'id' => job_id, + 'tags' => { 'user' => Etc.getlogin, 'jenkins_build_url' => jenkins_build_url } } } + else + { 'resources' => platform, + 'priority' => priority, + 'job' => { 'id' => job_id, + 'tags' => { 'user' => Etc.getlogin, 'jenkins_build_url' => jenkins_build_url } } } + end + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + request = Net::HTTP::Post.new(uri.request_uri, headers) + request.body = payload.to_json + + # Make an initial request - we should receive a 202 response to indicate the request is being processed reply = http.request(request) # Use this 'puts' only for debugging purposes # Do not use this in production mode because puppet_litmus will parse the STDOUT to extract the results - # puts "#{Time.now.strftime('%Y/%m/%d %H:%M:%S')}: Received #{reply.code} #{reply.message} from ABS" - break if reply.code == '200' # Our host(s) are provisioned - raise 'ABS API Error: Received a HTTP 404 response' if reply.code == '404' # Our host(s) will never be provisioned - sleep_time += 1 - end + # puts "#{Time.now.strftime('%Y/%m/%d %H:%M:%S')}: Received: #{reply.code} #{reply.message} from ABS" + raise "Error: #{reply}: #{reply.message}" unless reply.is_a?(Net::HTTPAccepted) # should be a 202 + + # We want to then poll the API until we get a 200 response, indicating the VMs have been provisioned + timeout = Time.now.to_i + 600 # Let's poll the API for a max of 10 minutes + sleep_time = 1 + + # Progressively increase the sleep time by 1 second. When we hit 10 seconds, start querying every 30 seconds until we + # exceed the time out. This is an attempt to strike a balance between quick provisioning and not saturating the ABS + # API and network if it's taking longer to provision than usual + while Time.now.to_i < timeout + sleep (sleep_time <= 10) ? sleep_time : 30 # rubocop:disable Lint/ParenthesesAsGroupedExpression + reply = http.request(request) + # Use this 'puts' only for debugging purposes + # Do not use this in production mode because puppet_litmus will parse the STDOUT to extract the results + # puts "#{Time.now.strftime('%Y/%m/%d %H:%M:%S')}: Received #{reply.code} #{reply.message} from ABS" + break if reply.code == '200' # Our host(s) are provisioned + raise 'ABS API Error: Received a HTTP 404 response' if reply.code == '404' # Our host(s) will never be provisioned + sleep_time += 1 + end - raise 'Timeout: unable to get a 200 response in 10 minutes' if reply.code != '200' - - inventory_full_path = File.join(inventory_location, '/spec/fixtures/litmus_inventory.yaml') - inventory_hash = get_inventory_hash(inventory_full_path) - data = JSON.parse(reply.body) - data.each do |host| - if platform_uses_ssh(host['type']) - node = { 'uri' => host['hostname'], - 'config' => { 'transport' => 'ssh', 'ssh' => { 'user' => ENV['ABS_USER'], 'host-key-check' => false } }, - 'facts' => { 'provisioner' => 'abs', 'platform' => host['type'], 'job_id' => job_id } } - if !ENV['ABS_SSH_PRIVATE_KEY'].nil? && !ENV['ABS_SSH_PRIVATE_KEY'].empty? - node['config']['ssh']['private-key'] = ENV['ABS_SSH_PRIVATE_KEY'] + raise 'Timeout: unable to get a 200 response in 10 minutes' if reply.code != '200' + + inventory_full_path = File.join(inventory_location, '/spec/fixtures/litmus_inventory.yaml') + inventory_hash = get_inventory_hash(inventory_full_path) + data = JSON.parse(reply.body) + data.each do |host| + if platform_uses_ssh(host['type']) + node = { 'uri' => host['hostname'], + 'config' => { 'transport' => 'ssh', 'ssh' => { 'user' => ENV['ABS_USER'], 'host-key-check' => false } }, + 'facts' => { 'provisioner' => 'abs', 'platform' => host['type'], 'job_id' => job_id } } + if !ENV['ABS_SSH_PRIVATE_KEY'].nil? && !ENV['ABS_SSH_PRIVATE_KEY'].empty? + node['config']['ssh']['private-key'] = ENV['ABS_SSH_PRIVATE_KEY'] + else + node['config']['ssh']['password'] = ENV['ABS_PASSWORD'] + end + group_name = 'ssh_nodes' else - node['config']['ssh']['password'] = ENV['ABS_PASSWORD'] + node = { 'uri' => host['hostname'], + 'config' => { 'transport' => 'winrm', 'winrm' => { 'user' => ENV['ABS_WIN_USER'], 'password' => ENV['ABS_PASSWORD'], 'ssl' => false } }, + 'facts' => { 'provisioner' => 'abs', 'platform' => host['type'], 'job_id' => job_id } } + group_name = 'winrm_nodes' end - group_name = 'ssh_nodes' - else - node = { 'uri' => host['hostname'], - 'config' => { 'transport' => 'winrm', 'winrm' => { 'user' => ENV['ABS_WIN_USER'], 'password' => ENV['ABS_PASSWORD'], 'ssl' => false } }, - 'facts' => { 'provisioner' => 'abs', 'platform' => host['type'], 'job_id' => job_id } } - group_name = 'winrm_nodes' - end - unless vars.nil? - var_hash = YAML.safe_load(vars) - node['vars'] = var_hash + unless vars.nil? + var_hash = YAML.safe_load(vars) + node['vars'] = var_hash + end + add_node_to_group(inventory_hash, node, group_name) end - add_node_to_group(inventory_hash, node, group_name) + + File.open(inventory_full_path, 'w') { |f| f.write inventory_hash.to_yaml } + { status: 'ok', nodes: data.length } end - File.open(inventory_full_path, 'w') { |f| f.write inventory_hash.to_yaml } - { status: 'ok', nodes: data.length } -end + def tear_down(node_name, inventory_location) + inventory_full_path = File.join(inventory_location, '/spec/fixtures/litmus_inventory.yaml') + if File.file?(inventory_full_path) + inventory_hash = inventory_hash_from_inventory_file(inventory_full_path) + facts = facts_from_node(inventory_hash, node_name) + platform = facts['platform'] + job_id = facts['job_id'] + end -def tear_down(node_name, inventory_location) - include PuppetLitmus::InventoryManipulation - inventory_full_path = File.join(inventory_location, '/spec/fixtures/litmus_inventory.yaml') - if File.file?(inventory_full_path) - inventory_hash = inventory_hash_from_inventory_file(inventory_full_path) - facts = facts_from_node(inventory_hash, node_name) - platform = facts['platform'] - job_id = facts['job_id'] - end + targets_to_remove = [] + inventory_hash['groups'].each do |group| + group['targets'].each do |node| + targets_to_remove.push(node['uri']) if node['facts']['job_id'] == job_id + end + end + uri = URI.parse('https://abs-prod.k8s.infracore.puppet.net/api/v2/return') + headers = { 'X-AUTH-TOKEN' => token_from_fogfile('abs'), 'Content-Type' => 'application/json' } + payload = { 'job_id' => job_id, + 'hosts' => [{ 'hostname' => node_name, 'type' => platform, 'engine' => 'vmpooler' }] } + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + request = Net::HTTP::Post.new(uri.request_uri, headers) + request.body = payload.to_json - targets_to_remove = [] - inventory_hash['groups'].each do |group| - group['targets'].each do |node| - targets_to_remove.push(node['uri']) if node['facts']['job_id'] == job_id + reply = http.request(request) + raise "Error: #{reply}: #{reply.message}" unless reply.code == '200' + + targets_to_remove.each do |target| + remove_node(inventory_hash, target) end + File.open(inventory_full_path, 'w') { |f| f.write inventory_hash.to_yaml } + { status: 'ok', removed: targets_to_remove } end - uri = URI.parse('https://abs-prod.k8s.infracore.puppet.net/api/v2/return') - headers = { 'X-AUTH-TOKEN' => token_from_fogfile('abs'), 'Content-Type' => 'application/json' } - payload = { 'job_id' => job_id, - 'hosts' => [{ 'hostname' => node_name, 'type' => platform, 'engine' => 'vmpooler' }] } - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - request = Net::HTTP::Post.new(uri.request_uri, headers) - request.body = payload.to_json - - reply = http.request(request) - raise "Error: #{reply}: #{reply.message}" unless reply.code == '200' - - targets_to_remove.each do |target| - remove_node(inventory_hash, target) + + def task(action:, platform: nil, node_name: nil, inventory: nil, vars: nil, **_kwargs) + inventory_location = sanitise_inventory_location(inventory) + result = provision(platform, inventory_location, vars) if action == 'provision' + result = tear_down(node_name, inventory_location) if action == 'tear_down' + result end - File.open(inventory_full_path, 'w') { |f| f.write inventory_hash.to_yaml } - { status: 'ok', removed: targets_to_remove } -end -params = JSON.parse(STDIN.read) -platform = params['platform'] -action = params['action'] -node_name = params['node_name'] -inventory_location = sanitise_inventory_location(params['inventory']) -vars = params['vars'] -raise 'specify a node_name when tearing down' if action == 'tear_down' && node_name.nil? -raise 'specify a platform when provisioning' if action == 'provision' && platform.nil? -unless node_name.nil? ^ platform.nil? - case action - when 'tear_down' - raise 'specify only a node_name, not platform, when tearing down' - when 'provision' - raise 'specify only a platform, not node_name, when provisioning' - else - raise 'specify only one of: node_name, platform' + def self.run + params = JSON.parse(STDIN.read) + params.transform_keys! { |k| k.to_sym } + action, node_name, platform = params.values_at(:action, :node_name, :platform) + + raise 'specify a node_name when tearing down' if action == 'tear_down' && node_name.nil? + raise 'specify a platform when provisioning' if action == 'provision' && platform.nil? + unless node_name.nil? ^ platform.nil? + case action + when 'tear_down' + raise 'specify only a node_name, not platform, when tearing down' + when 'provision' + raise 'specify only a platform, not node_name, when provisioning' + else + raise 'specify only one of: node_name, platform' + end + end + + begin + runner = new + result = runner.task(**params) + puts result.to_json + exit 0 + rescue => e + puts({ _error: { kind: 'provision/abs_failure', msg: e.message } }.to_json) + exit 1 + end end end -begin - result = provision(platform, inventory_location, vars) if action == 'provision' - result = tear_down(node_name, inventory_location) if action == 'tear_down' - puts result.to_json - exit 0 -rescue => e - puts({ _error: { kind: 'provision/abs_failure', msg: e.message } }.to_json) - exit 1 -end +ABSProvision.run if __FILE__ == $PROGRAM_NAME