From 13df748cc6f938906d663a8c89e4c60141312306 Mon Sep 17 00:00:00 2001 From: Scott Schneider Date: Wed, 22 Apr 2015 16:10:14 -0700 Subject: [PATCH] Add basic auth token functionality ...and rspec tests, hooray! --- lib/vmpooler/api/helpers.rb | 51 +++++--- lib/vmpooler/api/v1.rb | 133 ++++++++++++--------- spec/vmpooler/api/v1_spec.rb | 220 +++++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 67 deletions(-) diff --git a/lib/vmpooler/api/helpers.rb b/lib/vmpooler/api/helpers.rb index a618d15..00e6046 100644 --- a/lib/vmpooler/api/helpers.rb +++ b/lib/vmpooler/api/helpers.rb @@ -4,7 +4,28 @@ module Vmpooler module Helpers - def protected! + def has_token? + request.env['HTTP_X_AUTH_TOKEN'].nil? ? false : true + end + + def valid_token?(backend) + return false unless has_token? + + backend.exists('vmpooler__token__' + request.env['HTTP_X_AUTH_TOKEN']) ? true : false + end + + def validate_token(backend) + return if valid_token?(backend) + + content_type :json + + result = { 'ok' => false } + + headers['WWW-Authenticate'] = 'Basic realm="Authentication required"' + halt 401, JSON.pretty_generate(result) + end + + def validate_auth(backend) return if authorized? content_type :json @@ -75,11 +96,11 @@ module Vmpooler hostname end - def get_task_times(redis, task, date_str) - redis.hvals("vmpooler__#{task}__" + date_str).map(&:to_f) + def get_task_times(backend, task, date_str) + backend.hvals("vmpooler__#{task}__" + date_str).map(&:to_f) end - def get_capacity_metrics(pools, redis) + def get_capacity_metrics(pools, backend) capacity = { current: 0, total: 0, @@ -87,7 +108,7 @@ module Vmpooler } pools.each do |pool| - pool['capacity'] = redis.scard('vmpooler__ready__' + pool['name']).to_i + pool['capacity'] = backend.scard('vmpooler__ready__' + pool['name']).to_i capacity[:current] += pool['capacity'] capacity[:total] += pool['size'].to_i @@ -100,7 +121,7 @@ module Vmpooler capacity end - def get_queue_metrics(pools, redis) + def get_queue_metrics(pools, backend) queue = { pending: 0, cloning: 0, @@ -112,13 +133,13 @@ module Vmpooler } pools.each do |pool| - queue[:pending] += redis.scard('vmpooler__pending__' + pool['name']).to_i - queue[:ready] += redis.scard('vmpooler__ready__' + pool['name']).to_i - queue[:running] += redis.scard('vmpooler__running__' + pool['name']).to_i - queue[:completed] += redis.scard('vmpooler__completed__' + pool['name']).to_i + queue[:pending] += backend.scard('vmpooler__pending__' + pool['name']).to_i + queue[:ready] += backend.scard('vmpooler__ready__' + pool['name']).to_i + queue[:running] += backend.scard('vmpooler__running__' + pool['name']).to_i + queue[:completed] += backend.scard('vmpooler__completed__' + pool['name']).to_i end - queue[:cloning] = redis.get('vmpooler__tasks__clone').to_i + queue[:cloning] = backend.get('vmpooler__tasks__clone').to_i queue[:booting] = queue[:pending].to_i - queue[:cloning].to_i queue[:booting] = 0 if queue[:booting] < 0 queue[:total] = queue[:pending].to_i + queue[:ready].to_i + queue[:running].to_i + queue[:completed].to_i @@ -126,7 +147,7 @@ module Vmpooler queue end - def get_task_metrics(redis, task_str, date_str, opts = {}) + def get_task_metrics(backend, task_str, date_str, opts = {}) opts = {:bypool => false}.merge(opts) task = { @@ -141,7 +162,7 @@ module Vmpooler } } - task[:count][:total] = redis.hlen('vmpooler__' + task_str + '__' + date_str).to_i + task[:count][:total] = backend.hlen('vmpooler__' + task_str + '__' + date_str).to_i if task[:count][:total] > 0 if opts[:bypool] == true @@ -150,7 +171,7 @@ module Vmpooler task[:count][:pool] = {} task[:duration][:pool] = {} - redis.hgetall('vmpooler__' + task_str + '__' + date_str).each do |key, value| + backend.hgetall('vmpooler__' + task_str + '__' + date_str).each do |key, value| pool = 'unknown' hostname = 'unknown' @@ -176,7 +197,7 @@ module Vmpooler end end - task_times = get_task_times(redis, task_str, date_str) + task_times = get_task_times(backend, task_str, date_str) task[:duration][:total] = task_times.reduce(:+).to_f task[:duration][:average] = (task[:duration][:total] / task[:count][:total]).round(1) diff --git a/lib/vmpooler/api/v1.rb b/lib/vmpooler/api/v1.rb index 3ab536f..5ff6019 100644 --- a/lib/vmpooler/api/v1.rb +++ b/lib/vmpooler/api/v1.rb @@ -8,6 +8,30 @@ module Vmpooler include Vmpooler::API::Helpers end + def backend + Vmpooler::API.settings.redis + end + + def config + Vmpooler::API.settings.config[:config] + end + + def pools + Vmpooler::API.settings.config[:pools] + end + + def has_valid_token? + valid_token?(backend) + end + + def need_auth! + validate_auth(backend) + end + + def need_token! + validate_token(backend) + end + get "#{api_prefix}/status/?" do content_type :json @@ -18,14 +42,14 @@ module Vmpooler } } - result[:capacity] = get_capacity_metrics(Vmpooler::API.settings.config[:pools], Vmpooler::API.settings.redis) - result[:queue] = get_queue_metrics(Vmpooler::API.settings.config[:pools], Vmpooler::API.settings.redis) - result[:clone] = get_task_metrics(Vmpooler::API.settings.redis, 'clone', Date.today.to_s) - result[:boot] = get_task_metrics(Vmpooler::API.settings.redis, 'boot', Date.today.to_s) + result[:capacity] = get_capacity_metrics(pools, backend) + result[:queue] = get_queue_metrics(pools, backend) + result[:clone] = get_task_metrics(backend, 'clone', Date.today.to_s) + result[:boot] = get_task_metrics(backend, 'boot', Date.today.to_s) # Check for empty pools - Vmpooler::API.settings.config[:pools].each do |pool| - if Vmpooler::API.settings.redis.scard('vmpooler__ready__' + pool['name']).to_i == 0 + pools.each do |pool| + if backend.scard('vmpooler__ready__' + pool['name']).to_i == 0 result[:status][:empty] ||= [] result[:status][:empty].push(pool['name']) @@ -99,8 +123,8 @@ module Vmpooler (from_date..to_date).each do |date| daily = { date: date.to_s, - boot: get_task_metrics(Vmpooler::API.settings.redis, 'boot', date.to_s, :bypool => true), - clone: get_task_metrics(Vmpooler::API.settings.redis, 'clone', date.to_s, :bypool => true) + boot: get_task_metrics(backend, 'boot', date.to_s, :bypool => true), + clone: get_task_metrics(backend, 'clone', date.to_s, :bypool => true) } result[:daily].push(daily) @@ -182,9 +206,9 @@ module Vmpooler if Vmpooler::API.settings.config[:auth] status 401 - protected! + need_auth! - token = Vmpooler::API.settings.redis.hgetall('vmpooler__token__' + params[:token]) + token = backend.hgetall('vmpooler__token__' + params[:token]) if not token.nil? and not token.empty? status 200 @@ -206,9 +230,9 @@ module Vmpooler if Vmpooler::API.settings.config[:auth] status 401 - protected! + need_auth! - if Vmpooler::API.settings.redis.del('vmpooler__token__' + params[:token]).to_i > 0 + if backend.del('vmpooler__token__' + params[:token]).to_i > 0 status 200 result['ok'] = true end @@ -226,13 +250,13 @@ module Vmpooler if Vmpooler::API.settings.config[:auth] status 401 - protected! + need_auth! o = [('a'..'z'), ('0'..'9')].map(&:to_a).flatten result['token'] = o[rand(25)] + (0...31).map { o[rand(o.length)] }.join - Vmpooler::API.settings.redis.hset('vmpooler__token__' + result['token'], 'user', @auth.username) - Vmpooler::API.settings.redis.hset('vmpooler__token__' + result['token'], 'timestamp', Time.now) + backend.hset('vmpooler__token__' + result['token'], 'user', @auth.username) + backend.hset('vmpooler__token__' + result['token'], 'timestamp', Time.now) status 200 result['ok'] = true @@ -246,7 +270,7 @@ module Vmpooler result = [] - Vmpooler::API.settings.config[:pools].each do |pool| + pools.each do |pool| result.push(pool['name']) end @@ -263,7 +287,7 @@ module Vmpooler jdata = JSON.parse(request.body.read) jdata.each do |key, val| - if Vmpooler::API.settings.redis.scard('vmpooler__ready__' + key) < val.to_i + if backend.scard('vmpooler__ready__' + key).to_i < val.to_i available = 0 end end @@ -277,12 +301,16 @@ module Vmpooler result[key]['ok'] = true ## val.to_i.times do |_i| - vm = Vmpooler::API.settings.redis.spop('vmpooler__ready__' + key) + vm = backend.spop('vmpooler__ready__' + key) unless vm.nil? - Vmpooler::API.settings.redis.sadd('vmpooler__running__' + key, vm) - Vmpooler::API.settings.redis.hset('vmpooler__active__' + key, vm, Time.now) - Vmpooler::API.settings.redis.hset('vmpooler__vm__' + vm, 'checkout', Time.now) + backend.sadd('vmpooler__running__' + key, vm) + backend.hset('vmpooler__active__' + key, vm, Time.now) + backend.hset('vmpooler__vm__' + vm, 'checkout', Time.now) + + if Vmpooler::API.settings.config[:auth] and has_valid_token? and config['vm_lifetime_auth'].to_i > 0 + backend.hset('vmpooler__vm__' + vm, 'lifetime', config['vm_lifetime_auth'].to_i) + end result[key] ||= {} @@ -307,8 +335,8 @@ module Vmpooler result['ok'] = false end - if result['ok'] && Vmpooler::API.settings.config[:config]['domain'] - result['domain'] = Vmpooler::API.settings.config[:config]['domain'] + if result['ok'] && config['domain'] + result['domain'] = config['domain'] end JSON.pretty_generate(result) @@ -328,7 +356,7 @@ module Vmpooler available = 1 request.keys.each do |template| - if Vmpooler::API.settings.redis.scard('vmpooler__ready__' + template) < request[template] + if backend.scard('vmpooler__ready__' + template) < request[template] available = 0 end end @@ -341,12 +369,12 @@ module Vmpooler result[template]['ok'] = true ## - vm = Vmpooler::API.settings.redis.spop('vmpooler__ready__' + template) + vm = backend.spop('vmpooler__ready__' + template) unless vm.nil? - Vmpooler::API.settings.redis.sadd('vmpooler__running__' + template, vm) - Vmpooler::API.settings.redis.hset('vmpooler__active__' + template, vm, Time.now) - Vmpooler::API.settings.redis.hset('vmpooler__vm__' + vm, 'checkout', Time.now) + backend.sadd('vmpooler__running__' + template, vm) + backend.hset('vmpooler__active__' + template, vm, Time.now) + backend.hset('vmpooler__vm__' + vm, 'checkout', Time.now) result[template] ||= {} @@ -368,8 +396,8 @@ module Vmpooler result['ok'] = false end - if result['ok'] && Vmpooler::API.settings.config[:config]['domain'] - result['domain'] = Vmpooler::API.settings.config[:config]['domain'] + if result['ok'] && config['domain'] + result['domain'] = config['domain'] end JSON.pretty_generate(result) @@ -383,18 +411,18 @@ module Vmpooler status 404 result['ok'] = false - params[:hostname] = hostname_shorten(params[:hostname], Vmpooler::API.settings.config[:config]['domain']) + params[:hostname] = hostname_shorten(params[:hostname], config['domain']) - if Vmpooler::API.settings.redis.exists('vmpooler__vm__' + params[:hostname]) + if backend.exists('vmpooler__vm__' + params[:hostname]) status 200 result['ok'] = true - rdata = Vmpooler::API.settings.redis.hgetall('vmpooler__vm__' + params[:hostname]) + rdata = backend.hgetall('vmpooler__vm__' + params[:hostname]) result[params[:hostname]] = {} result[params[:hostname]]['template'] = rdata['template'] - result[params[:hostname]]['lifetime'] = (rdata['lifetime'] || Vmpooler::API.settings.config[:config]['vm_lifetime']).to_i + result[params[:hostname]]['lifetime'] = (rdata['lifetime'] || config['vm_lifetime']).to_i if rdata['destroy'] result[params[:hostname]]['running'] = ((Time.parse(rdata['destroy']) - Time.parse(rdata['checkout'])) / 60 / 60).round(2) @@ -409,8 +437,8 @@ module Vmpooler end end - if Vmpooler::API.settings.config[:config]['domain'] - result[params[:hostname]]['domain'] = Vmpooler::API.settings.config[:config]['domain'] + if config['domain'] + result[params[:hostname]]['domain'] = config['domain'] end end @@ -425,12 +453,12 @@ module Vmpooler status 404 result['ok'] = false - params[:hostname] = hostname_shorten(params[:hostname], Vmpooler::API.settings.config[:config]['domain']) + params[:hostname] = hostname_shorten(params[:hostname], config['domain']) - Vmpooler::API.settings.config[:pools].each do |pool| - if Vmpooler::API.settings.redis.sismember('vmpooler__running__' + pool['name'], params[:hostname]) - Vmpooler::API.settings.redis.srem('vmpooler__running__' + pool['name'], params[:hostname]) - Vmpooler::API.settings.redis.sadd('vmpooler__completed__' + pool['name'], params[:hostname]) + pools.each do |pool| + if backend.sismember('vmpooler__running__' + pool['name'], params[:hostname]) + backend.srem('vmpooler__running__' + pool['name'], params[:hostname]) + backend.sadd('vmpooler__completed__' + pool['name'], params[:hostname]) status 200 result['ok'] = true @@ -443,27 +471,26 @@ module Vmpooler put "#{api_prefix}/vm/:hostname/?" do content_type :json + status 404 + result = { 'ok' => false } + failure = false - result = {} + params[:hostname] = hostname_shorten(params[:hostname], config['domain']) - status 404 - result['ok'] = false - - params[:hostname] = hostname_shorten(params[:hostname], Vmpooler::API.settings.config[:config]['domain']) - - if Vmpooler::API.settings.redis.exists('vmpooler__vm__' + params[:hostname]) + if backend.exists('vmpooler__vm__' + params[:hostname]) begin jdata = JSON.parse(request.body.read) rescue - status 400 - return JSON.pretty_generate(result) + halt 400, JSON.pretty_generate(result) end # Validate data payload jdata.each do |param, arg| case param when 'lifetime' + need_token! if Vmpooler::API.settings.config[:auth] + unless arg.to_i > 0 failure = true end @@ -482,12 +509,14 @@ module Vmpooler jdata.each do |param, arg| case param when 'lifetime' + need_token! if Vmpooler::API.settings.config[:auth] + arg = arg.to_i - Vmpooler::API.settings.redis.hset('vmpooler__vm__' + params[:hostname], param, arg) + backend.hset('vmpooler__vm__' + params[:hostname], param, arg) when 'tags' arg.keys.each do |tag| - Vmpooler::API.settings.redis.hset('vmpooler__vm__' + params[:hostname], 'tag:' + tag, arg[tag]) + backend.hset('vmpooler__vm__' + params[:hostname], 'tag:' + tag, arg[tag]) end end end diff --git a/spec/vmpooler/api/v1_spec.rb b/spec/vmpooler/api/v1_spec.rb index f6d295e..2b7ce03 100644 --- a/spec/vmpooler/api/v1_spec.rb +++ b/spec/vmpooler/api/v1_spec.rb @@ -156,7 +156,227 @@ describe Vmpooler::API::V1 do end end end + end + describe '/vm' do + let(:redis) { double('redis') } + let(:prefix) { '/api/v1' } + let(:config) { { + config: { + 'site_name' => 'test pooler', + 'vm_lifetime_auth' => 2 + }, + pools: [ + {'name' => 'pool1', 'size' => 5}, + {'name' => 'pool2', 'size' => 10} + ] + } } + + before do + app.settings.set :config, config + app.settings.set :redis, redis + + allow(redis).to receive(:exists).and_return '1' + allow(redis).to receive(:hset).and_return '1' + allow(redis).to receive(:sadd).and_return '1' + allow(redis).to receive(:scard).and_return '5' + allow(redis).to receive(:spop).with('vmpooler__ready__pool1').and_return 'abcdefghijklmnop' + allow(redis).to receive(:spop).with('vmpooler__ready__pool2').and_return 'qrstuvwxyz012345' + end + + describe 'POST /vm' do + it 'returns a single VM' do + post "#{prefix}/vm", '{"pool1":"1"}' + + expected = { + ok: true, + pool1: { + ok: true, + hostname: 'abcdefghijklmnop' + } + } + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate(expected)) + expect(last_response.status).to eq(200) + end + + it 'returns multiple VMs' do + post "#{prefix}/vm", '{"pool1":"1","pool2":"1"}' + + expected = { + ok: true, + pool1: { + ok: true, + hostname: 'abcdefghijklmnop' + }, + pool2: { + ok: true, + hostname: 'qrstuvwxyz012345' + } + } + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate(expected)) + expect(last_response.status).to eq(200) + end + + context '(auth not configured)' do + let(:config) { { auth: false } } + + it 'does not extend VM lifetime if auth token is provided' do + expect(redis).not_to receive(:hset).with("vmpooler__vm__abcdefghijklmnop", "lifetime", 2) + + post "#{prefix}/vm", '{"pool1":"1"}', { + 'HTTP_X_AUTH_TOKEN' => 'abcdefghijklmnopqrstuvwxyz012345' + } + + expected = { + ok: true, + pool1: { + ok: true, + hostname: 'abcdefghijklmnop' + } + } + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate(expected)) + expect(last_response.status).to eq(200) + end + end + + context '(auth configured)' do + let(:config) { { auth: true } } + + it 'extends VM lifetime if auth token is provided' do + expect(redis).to receive(:hset).with("vmpooler__vm__abcdefghijklmnop", "lifetime", 2).once + + post "#{prefix}/vm", '{"pool1":"1"}', { + 'HTTP_X_AUTH_TOKEN' => 'abcdefghijklmnopqrstuvwxyz012345' + } + + expected = { + ok: true, + pool1: { + ok: true, + hostname: 'abcdefghijklmnop' + } + } + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate(expected)) + expect(last_response.status).to eq(200) + end + + it 'does not extend VM lifetime if auth token is not provided' do + expect(redis).not_to receive(:hset).with("vmpooler__vm__abcdefghijklmnop", "lifetime", 2) + + post "#{prefix}/vm", '{"pool1":"1"}' + + expected = { + ok: true, + pool1: { + ok: true, + hostname: 'abcdefghijklmnop' + } + } + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate(expected)) + expect(last_response.status).to eq(200) + end + end + end + end + + describe '/vm/:hostname' do + let(:redis) { double('redis') } + let(:prefix) { '/api/v1' } + let(:config) { { + pools: [ + {'name' => 'pool1', 'size' => 5}, + {'name' => 'pool2', 'size' => 10} + ] + } } + + before do + app.settings.set :config, config + app.settings.set :redis, redis + + allow(redis).to receive(:exists).and_return '1' + allow(redis).to receive(:hset).and_return '1' + end + + describe 'PUT /vm/:hostname' do + it 'allows tags to be set' do + put "#{prefix}/vm/testhost", '{"tags":{"tested_by":"rspec"}}' + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => true})) + expect(last_response.status).to eq(200) + end + + it 'does not set tags if request body format is invalid' do + put "#{prefix}/vm/testhost", '{"tags":{"tested"}}' + + expect(last_response).to_not be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(400) + end + + context '(auth not configured)' do + let(:config) { { auth: false } } + + it 'allows VM lifetime to be modified without a token' do + put "#{prefix}/vm/testhost", '{"lifetime":"1"}' + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => true})) + expect(last_response.status).to eq(200) + end + + it 'does not allow a lifetime to be 0' do + put "#{prefix}/vm/testhost", '{"lifetime":"0"}' + + expect(last_response).to_not be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(400) + end + end + + context '(auth configured)' do + let(:config) { { auth: true } } + + it 'allows VM lifetime to be modified with a token' do + put "#{prefix}/vm/testhost", '{"lifetime":"1"}', { + 'HTTP_X_AUTH_TOKEN' => 'abcdefghijklmnopqrstuvwxyz012345' + } + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => true})) + expect(last_response.status).to eq(200) + end + + it 'does not allows VM lifetime to be modified without a token' do + put "#{prefix}/vm/testhost", '{"lifetime":"1"}' + + expect(last_response).to_not be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(401) + end + end + end end end