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