Add basic auth token functionality

...and rspec tests, hooray!
This commit is contained in:
Scott Schneider 2015-04-22 16:10:14 -07:00
parent 8cd49d215b
commit 13df748cc6
3 changed files with 337 additions and 67 deletions

View file

@ -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)

View file

@ -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

View file

@ -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