cloud_financial_officer 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +14 -0
- data/.powrc +1 -0
- data/.yardopts +1 -0
- data/API.md +146 -0
- data/Gemfile +2 -0
- data/Guardfile +10 -0
- data/README.md +141 -0
- data/Rakefile +20 -0
- data/cloud_financial_officer.gemspec +43 -0
- data/config/accounts.example.yml +45 -0
- data/config/database.example.yml +18 -0
- data/config.ru +2 -0
- data/lib/cfo/cfo.rb +44 -0
- data/lib/cfo/record_filter.rb +80 -0
- data/lib/cfo/record_grouper.rb +102 -0
- data/lib/cfo/resources/account.rb +53 -0
- data/lib/cfo/resources/cloud_resource.rb +74 -0
- data/lib/cfo/resources/report.rb +147 -0
- data/lib/cfo/service.rb +148 -0
- data/lib/cfo/version.rb +7 -0
- data/lib/cloud_financial_officer.rb +8 -0
- data/lib/tasks/rspec.rake +6 -0
- data/lib/tasks/service.rake +5 -0
- data/spec/factories.rb +35 -0
- data/spec/lib/cfo/record_filter_spec.rb +49 -0
- data/spec/lib/cfo/record_grouper_spec.rb +110 -0
- data/spec/lib/cfo/resources/account_spec.rb +46 -0
- data/spec/lib/cfo/resources/cloud_resource_spec.rb +90 -0
- data/spec/lib/cfo/resources/report_spec.rb +264 -0
- data/spec/lib/cfo/service_spec.rb +103 -0
- data/spec/spec_helper.rb +37 -0
- data/spec/support/_configure_logging.rb +6 -0
- metadata +204 -0
@@ -0,0 +1,110 @@
|
|
1
|
+
module CFO
|
2
|
+
include CloudCostTracker::Billing
|
3
|
+
describe RecordGrouper do
|
4
|
+
|
5
|
+
before(:all) do
|
6
|
+
BillingRecord.delete_all
|
7
|
+
BillingCode.delete_all
|
8
|
+
# We'll Create 16 BillingRecords so we can perform a tertiary sort,
|
9
|
+
# and still collapse the total Records to 8 by the grouping operation.
|
10
|
+
@groupings = ['account', 'project', 'environment']
|
11
|
+
# First, the BillingCodes must be created
|
12
|
+
BillingCode.create!(:key => 'project', :value => 'balance')
|
13
|
+
BillingCode.create!(:key => 'project', :value => 'imedidata')
|
14
|
+
BillingCode.create!(:key => 'environment', :value => 'sandbox')
|
15
|
+
BillingCode.create!(:key => 'environment', :value => 'validation')
|
16
|
+
BillingCode.create!(:key => 'environment', :value => 'innovate')
|
17
|
+
BillingCode.create!(:key => 'environment', :value => 'production')
|
18
|
+
# Except for the grouping fields, plus one extra field, resource_type,
|
19
|
+
# all records with have the same data...
|
20
|
+
@start, @stop, @per_record_cost = Time.now, (Time.now + 60), 1
|
21
|
+
[ # first, create 8 unique permuations of the @groupings
|
22
|
+
# Account | 'code:project' | 'code:environment' | resource_type (dropped)
|
23
|
+
[ 'dev', 'balance', 'sandbox', 'Server'],
|
24
|
+
[ 'dev', 'balance', 'validation', 'Server'],
|
25
|
+
[ 'dev', 'imedidata', 'sandbox', 'Server'],
|
26
|
+
[ 'dev', 'imedidata', 'validation', 'Server'],
|
27
|
+
[ 'prod', 'balance', 'innovate', 'Server'],
|
28
|
+
[ 'prod', 'balance', 'production', 'Server'],
|
29
|
+
[ 'prod', 'imedidata', 'innovate', 'Server'],
|
30
|
+
[ 'prod', 'imedidata', 'production', 'Server'],
|
31
|
+
# Now, make the same permutations, but with a different resource_type
|
32
|
+
[ 'dev', 'balance', 'sandbox', 'Volume'],
|
33
|
+
[ 'dev', 'balance', 'validation', 'Volume'],
|
34
|
+
[ 'dev', 'imedidata', 'sandbox', 'Volume'],
|
35
|
+
[ 'dev', 'imedidata', 'validation', 'Volume'],
|
36
|
+
[ 'prod', 'balance', 'innovate', 'Volume'],
|
37
|
+
[ 'prod', 'balance', 'production', 'Volume'],
|
38
|
+
[ 'prod', 'imedidata', 'innovate', 'Volume'],
|
39
|
+
[ 'prod', 'imedidata', 'production', 'Volume'],
|
40
|
+
].map do |record_data|
|
41
|
+
BillingRecord.create!(
|
42
|
+
:account => record_data[0], :service => 'Compute', :provider => 'AWS',
|
43
|
+
:resource_type => record_data[3], :resource_id => "id-#{rand 65535}",
|
44
|
+
:start_time => @start, :stop_time => @stop, :cost_per_hour => 60,
|
45
|
+
:total_cost => @per_record_cost, :billing_type => 'whatever',
|
46
|
+
:billing_codes => [
|
47
|
+
BillingCode.where(:key => 'project', :value => record_data[1]).
|
48
|
+
first_or_create!,
|
49
|
+
BillingCode.where(:key => 'environment', :value => record_data[2]).
|
50
|
+
first_or_create!,
|
51
|
+
]
|
52
|
+
)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe '#by' do
|
57
|
+
before(:each) do
|
58
|
+
@results = RecordGrouper.by(@groupings, BillingRecord.all)
|
59
|
+
end
|
60
|
+
|
61
|
+
it "merges all Records with the same unique permutation of groupings" do
|
62
|
+
# make sure there are the right number of records for the above example
|
63
|
+
@results.should have(8).things
|
64
|
+
end
|
65
|
+
it "returns records with a total cost equal to the input's total" do
|
66
|
+
@results.each do |record|
|
67
|
+
record.total_cost.should == 2 * @per_record_cost
|
68
|
+
end
|
69
|
+
total = @results.inject(0) {|t,record| t += record.total_cost}
|
70
|
+
total.should == BillingRecord.count * @per_record_cost
|
71
|
+
end
|
72
|
+
it "returns composite records with shared component record values" do
|
73
|
+
@results.each do |record|
|
74
|
+
record.service.should == 'Compute'
|
75
|
+
record.provider.should == 'AWS'
|
76
|
+
record.billing_type.should == 'whatever'
|
77
|
+
record.resource_type.should == RecordGrouper::MULTIPLE_VALUES_VALUE
|
78
|
+
record.resource_id.should == RecordGrouper::MULTIPLE_VALUES_VALUE
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
context "when invoked with groupings that don't match all input records" do
|
83
|
+
before(:all) do
|
84
|
+
# Create 2 new records that don't match the groupings (in the same way)
|
85
|
+
2.times do
|
86
|
+
BillingRecord.create!(
|
87
|
+
:account => 'other account', :service => 'Compute', :provider => 'RDS',
|
88
|
+
:resource_type => 'Snapshot', :resource_id => "id-#{rand 65535}",
|
89
|
+
:start_time => @start, :stop_time => @stop, :cost_per_hour => 60,
|
90
|
+
:total_cost => @per_record_cost, :billing_type => 'whatever')
|
91
|
+
end
|
92
|
+
@results = RecordGrouper.by(@groupings, BillingRecord.all)
|
93
|
+
end
|
94
|
+
it "treats all matches for a given BillingCode as a single group" do
|
95
|
+
@results.should have(9).things # a new composite record
|
96
|
+
non_matching_record = @results.find {|r| r.account == 'other account'}
|
97
|
+
non_matching_record.billing_codes.should be_empty
|
98
|
+
non_matching_record.total_cost.should == 2 * @per_record_cost
|
99
|
+
end
|
100
|
+
it "returns records with a total cost equal to the input's total" do
|
101
|
+
@results.each do |record|
|
102
|
+
record.total_cost.should == 2 * @per_record_cost
|
103
|
+
end
|
104
|
+
total = @results.inject(0) {|t,record| t += record.total_cost}
|
105
|
+
total.should == BillingRecord.count * @per_record_cost
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module CFO
|
2
|
+
describe Account do
|
3
|
+
|
4
|
+
before(:each) do
|
5
|
+
@account = build(:account,
|
6
|
+
:billing_codes => [['key1', 'val1'], ['key2', 'val2']]
|
7
|
+
)
|
8
|
+
end
|
9
|
+
|
10
|
+
describe '#to_hash' do
|
11
|
+
it "returns a hash of the account's attributes, with name as ID" do
|
12
|
+
account_hash = @account.to_hash
|
13
|
+
account_hash['id'].should == @account.name
|
14
|
+
account_hash['service'].should == @account.service
|
15
|
+
account_hash['provider'].should == @account.provider
|
16
|
+
account_hash['billing_codes'].should ==
|
17
|
+
[['key1', 'val1'], ['key2', 'val2']]
|
18
|
+
end
|
19
|
+
it "returns a URI link to itself" do
|
20
|
+
@account.to_hash['url'].should ==
|
21
|
+
"#{CFO.root}/accounts/#{URI.escape(@account.name)}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe '#uri' do
|
26
|
+
it "returns a URI link to itself" do
|
27
|
+
@account.uri.should == "#{CFO.root}/accounts/#{URI.escape(@account.name)}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '#report_uri' do
|
32
|
+
it "returns a URI for its cost report" do
|
33
|
+
@account.report_uri.should ==
|
34
|
+
"#{CFO.root}/report/for_account/#{URI.escape(@account.name)}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe '#to_json' do
|
39
|
+
it "returns a JSON version of the #to_hash output" do
|
40
|
+
account_hash = @account.to_hash
|
41
|
+
@account.to_json.should == @account.to_hash.to_json
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
module CFO
|
2
|
+
describe CloudResource do
|
3
|
+
|
4
|
+
before(:each) do
|
5
|
+
@resource = build(:resource, {
|
6
|
+
:billing_codes => [['key1', 'val1'], ['key2', 'val2']],
|
7
|
+
:account => build(:account),
|
8
|
+
})
|
9
|
+
end
|
10
|
+
|
11
|
+
describe '#to_hash' do
|
12
|
+
context "when invoked with verbose=false" do
|
13
|
+
it "returns a hash of the resource's attributes" do
|
14
|
+
resource_hash = @resource.to_hash(false)
|
15
|
+
resource_hash['resource_id'].should == @resource.resource_id
|
16
|
+
resource_hash['resource_type'].should == @resource.resource_type
|
17
|
+
end
|
18
|
+
end
|
19
|
+
context "when invoked with verbose=true" do
|
20
|
+
it "returns information on its account" do
|
21
|
+
account_data = @resource.to_hash(true)['account']
|
22
|
+
account_data['id'].should == @resource.account.name
|
23
|
+
account_data['service'].should == @resource.account.service
|
24
|
+
account_data['provider'].should == @resource.account.provider
|
25
|
+
account_data['url'].should ==
|
26
|
+
"#{CFO.root}/accounts/#{URI.escape(@resource.account.name)}"
|
27
|
+
end
|
28
|
+
it "returns a billing codes and the resource list as well" do
|
29
|
+
resource_hash = @resource.to_hash(true)
|
30
|
+
resource_hash['billing_codes'].should ==
|
31
|
+
[['key1', 'val1'], ['key2', 'val2']]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe '#uri' do
|
37
|
+
it "returns a URI link to itself" do
|
38
|
+
@resource.uri.should ==
|
39
|
+
"#{CFO.root}/accounts/#{URI.escape(@resource.account.name)}"+
|
40
|
+
"/#{URI.escape(@resource.resource_type)}"+
|
41
|
+
"/#{URI.escape(@resource.resource_id)}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe '#report_uri' do
|
46
|
+
it "returns a URI link to its cost report" do
|
47
|
+
account = @resource.account
|
48
|
+
@resource.report_uri.should ==
|
49
|
+
"#{CFO.root}/report/for_account/#{URI.escape(account.name)}/for_type/"+
|
50
|
+
"#{URI.escape(@resource.resource_type)}/for_resource/"+
|
51
|
+
"#{URI.escape(@resource.resource_id)}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe '#to_json' do
|
56
|
+
it "returns a JSON version of the #to_hash output" do
|
57
|
+
resource_hash = @resource.to_hash
|
58
|
+
@resource.to_json.should == @resource.to_hash.to_json
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe '#hash_list' do
|
63
|
+
before(:each) do
|
64
|
+
CloudCostTracker::Billing::BillingRecord.delete_all
|
65
|
+
# Create a pair of distinct BillingRecords
|
66
|
+
@resource1 = create(:record,{:resource_id => 'fake-resource-1'})
|
67
|
+
@resource2 = create(:record,{:resource_id => 'fake-resource-2'})
|
68
|
+
end
|
69
|
+
context "when invoked with no conditions" do
|
70
|
+
it "returns an Array of all CloudResource Hash representations" do
|
71
|
+
resource_list = CloudResource.hash_list
|
72
|
+
resource_list.should have(2).things
|
73
|
+
(resource_list.map {|r| r['resource_id']}).should ==
|
74
|
+
[@resource1.resource_id, @resource2.resource_id]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
context "when invoked with conditions" do
|
78
|
+
it "returns an Array of all matching CloudResource Hashes" do
|
79
|
+
resource_list = CloudResource.hash_list(
|
80
|
+
:resource_id => @resource1.resource_id
|
81
|
+
)
|
82
|
+
resource_list.should have(1).thing
|
83
|
+
(resource_list.map {|r| r['resource_id']}).should ==
|
84
|
+
[@resource1.resource_id]
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,264 @@
|
|
1
|
+
module CFO
|
2
|
+
include CloudCostTracker::Billing
|
3
|
+
|
4
|
+
describe Report do
|
5
|
+
|
6
|
+
# Number of BillingRecords to report on
|
7
|
+
NUM_RECORDS = 50 # Each has its own unique provider, service, etc.
|
8
|
+
|
9
|
+
before(:all) do
|
10
|
+
BillingRecord.delete_all
|
11
|
+
@billing_start = DateTime.parse((Time.now - (RECORD_DURATION + 1)).to_s)
|
12
|
+
# Create many BillingRecords, each with its own unique attributes
|
13
|
+
(1..NUM_RECORDS).each do |i|
|
14
|
+
create(:record, {
|
15
|
+
:provider => "#{PROVIDER_NAME}#{i}",
|
16
|
+
:service => "#{SERVICE_NAME}#{i}",
|
17
|
+
:account => "#{ACCOUNT_NAME}#{i}",
|
18
|
+
:resource_id => "#{RESOURCE_ID}#{i}",
|
19
|
+
:resource_type => "#{RESOURCE_TYPE}#{i}",
|
20
|
+
:billing_type => "#{BILLING_TYPE}#{i}",
|
21
|
+
})
|
22
|
+
end
|
23
|
+
@billing_stop = DateTime.parse((Time.now + 1).to_s)
|
24
|
+
end
|
25
|
+
|
26
|
+
after(:all) do
|
27
|
+
BillingRecord.delete_all
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '#to_json' do
|
31
|
+
it "renders a summary of the included billing records" do
|
32
|
+
report_hash = JSON.parse(Report.new.to_json)
|
33
|
+
report_hash['start_time'].should ==
|
34
|
+
report_hash['billing_records'].first['start_time']
|
35
|
+
report_hash['stop_time'].should ==
|
36
|
+
report_hash['billing_records'].last['stop_time']
|
37
|
+
report_hash['total_cost'].should be_a Numeric
|
38
|
+
report_hash['total_cost'].should == NUM_RECORDS * RECORD_TOTAL
|
39
|
+
report_hash['cost_per_hour'].should be_a Numeric
|
40
|
+
report_duration = Time.parse(report_hash['stop_time']) -
|
41
|
+
Time.parse(report_hash['start_time'])
|
42
|
+
report_hash['cost_per_hour'].should be_within(0.01).
|
43
|
+
of(SECONDS_PER_HOUR * report_hash['total_cost'] / report_duration)
|
44
|
+
end
|
45
|
+
|
46
|
+
# CONSTRAIN BY ACCOUNT NAME
|
47
|
+
context "when invoked with a matching account name constraint" do
|
48
|
+
it "returns a report on all the account's BillingRecords" do
|
49
|
+
# now search for a random account
|
50
|
+
account_name = "#{ACCOUNT_NAME}#{rand(NUM_RECORDS) + 1}"
|
51
|
+
constraint = {'for_account' => account_name}
|
52
|
+
response = Report.new([constraint]).to_json
|
53
|
+
billing_records = JSON.parse(response)['billing_records']
|
54
|
+
billing_records.size.should == 1
|
55
|
+
billing_records.first['account'].should == account_name
|
56
|
+
end
|
57
|
+
end
|
58
|
+
context "when invoked with a non-matching account name constraint" do
|
59
|
+
it "returns a report with no BillingRecords" do
|
60
|
+
# now search for a bogus account
|
61
|
+
constraint = {'for_account' => "UN-FINDABLE NAME"}
|
62
|
+
response = Report.new([constraint]).to_json
|
63
|
+
billing_records = JSON.parse(response)['billing_records']
|
64
|
+
billing_records.should be_empty
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# CONSTRAIN BY RESOURCE ID
|
69
|
+
context "when invoked with a matching resource ID constraint" do
|
70
|
+
it "returns a report on all the resource's BillingRecords" do
|
71
|
+
# now search for a resource ID
|
72
|
+
resource_id = "#{RESOURCE_ID}#{rand(NUM_RECORDS) + 1}"
|
73
|
+
constraint = {'for_resource' => resource_id}
|
74
|
+
response = Report.new([constraint]).to_json
|
75
|
+
billing_records = JSON.parse(response)['billing_records']
|
76
|
+
billing_records.size.should == 1
|
77
|
+
billing_records.first['resource_id'].should == resource_id
|
78
|
+
end
|
79
|
+
end
|
80
|
+
context "when invoked with a non-matching resource ID constraint" do
|
81
|
+
it "returns a report with no BillingRecords" do
|
82
|
+
# now search for a bogus resource ID
|
83
|
+
constraint = {'for_resource' => "UN-FINDABLE NAME"}
|
84
|
+
response = Report.new([constraint]).to_json
|
85
|
+
billing_records = JSON.parse(response)['billing_records']
|
86
|
+
billing_records.should be_empty
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# CONSTRAIN BY RESOURCE TYPE
|
91
|
+
context "when invoked with a matching resource type constraint" do
|
92
|
+
it "returns a report on all the resource's BillingRecords" do
|
93
|
+
# now search for a resource type
|
94
|
+
resource_type = "#{RESOURCE_TYPE}#{rand(NUM_RECORDS) + 1}"
|
95
|
+
constraint = {'for_type' => resource_type}
|
96
|
+
response = Report.new([constraint]).to_json
|
97
|
+
billing_records = JSON.parse(response)['billing_records']
|
98
|
+
billing_records.size.should == 1
|
99
|
+
billing_records.first['resource_type'].should == resource_type
|
100
|
+
end
|
101
|
+
end
|
102
|
+
context "when invoked with a non-matching resource type constraint" do
|
103
|
+
it "returns a report with no BillingRecords" do
|
104
|
+
# now search for a bogus resource type
|
105
|
+
constraint = {'for_type' => "UN-FINDABLE NAME"}
|
106
|
+
response = Report.new([constraint]).to_json
|
107
|
+
billing_records = JSON.parse(response)['billing_records']
|
108
|
+
billing_records.should be_empty
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# CONSTRAIN BY PROVIDER
|
113
|
+
context "when invoked with a matching provider constraint" do
|
114
|
+
it "returns a report on all the provider's BillingRecords" do
|
115
|
+
# now search for a provider
|
116
|
+
provider = "#{PROVIDER_NAME}#{rand(NUM_RECORDS) + 1}"
|
117
|
+
constraint = {'for_provider' => provider}
|
118
|
+
response = Report.new([constraint]).to_json
|
119
|
+
billing_records = JSON.parse(response)['billing_records']
|
120
|
+
billing_records.size.should == 1
|
121
|
+
billing_records.first['provider'].should == provider
|
122
|
+
end
|
123
|
+
end
|
124
|
+
context "when invoked with a non-matching provider constraint" do
|
125
|
+
it "returns a report with no BillingRecords" do
|
126
|
+
# now search for a bogus provider
|
127
|
+
constraint = {'for_provider' => "UN-FINDABLE NAME"}
|
128
|
+
response = Report.new([constraint]).to_json
|
129
|
+
billing_records = JSON.parse(response)['billing_records']
|
130
|
+
billing_records.should be_empty
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# CONSTRAIN BY SERVICE
|
135
|
+
context "when invoked with a matching service constraint" do
|
136
|
+
it "returns a report on all the service's BillingRecords" do
|
137
|
+
# now search for a service
|
138
|
+
service = "#{SERVICE_NAME}#{rand(NUM_RECORDS) + 1}"
|
139
|
+
constraint = {'for_service' => service}
|
140
|
+
response = Report.new([constraint]).to_json
|
141
|
+
billing_records = JSON.parse(response)['billing_records']
|
142
|
+
billing_records.size.should == 1
|
143
|
+
billing_records.first['service'].should == service
|
144
|
+
end
|
145
|
+
end
|
146
|
+
context "when invoked with a non-matching service constraint" do
|
147
|
+
it "returns a report with no BillingRecords" do
|
148
|
+
# now search for a bogus service
|
149
|
+
constraint = {'for_service' => "UN-FINDABLE NAME"}
|
150
|
+
response = Report.new([constraint]).to_json
|
151
|
+
billing_records = JSON.parse(response)['billing_records']
|
152
|
+
billing_records.should be_empty
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# CONSTRAIN BY BILLING TYPE
|
157
|
+
context "when invoked with a matching billing type constraint" do
|
158
|
+
it "returns a report on all the BillingRecords of that type" do
|
159
|
+
# now search for a billing type
|
160
|
+
billing_type = "#{BILLING_TYPE}#{rand(NUM_RECORDS) + 1}"
|
161
|
+
constraint = {'for_billing_type' => billing_type}
|
162
|
+
response = Report.new([constraint]).to_json
|
163
|
+
billing_records = JSON.parse(response)['billing_records']
|
164
|
+
billing_records.size.should == 1
|
165
|
+
billing_records.first['billing_type'].should == billing_type
|
166
|
+
end
|
167
|
+
end
|
168
|
+
context "when invoked with a non-matching billing type constraint" do
|
169
|
+
it "returns a report with no BillingRecords" do
|
170
|
+
# now search for a bogus billing type
|
171
|
+
constraint = {'for_billing_type' => "UN-FINDABLE NAME"}
|
172
|
+
response = Report.new([constraint]).to_json
|
173
|
+
billing_records = JSON.parse(response)['billing_records']
|
174
|
+
billing_records.should be_empty
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# CONSTRAIN BY TIME
|
179
|
+
context "when invoked with a time constraint encompassing all records" do
|
180
|
+
it "returns a report on all the BillingRecords" do
|
181
|
+
# now search on a time range encompassing all records
|
182
|
+
constraint = {'for_time' => [@billing_start, Time.now]}
|
183
|
+
response = Report.new([constraint]).to_json
|
184
|
+
billing_records = JSON.parse(response)['billing_records']
|
185
|
+
billing_records.size.should == NUM_RECORDS
|
186
|
+
first_start = DateTime.parse(billing_records.first['start_time'])
|
187
|
+
last_stop = DateTime.parse(billing_records.last['stop_time'])
|
188
|
+
first_start.should >= @billing_start
|
189
|
+
last_stop.should <= @billing_stop
|
190
|
+
end
|
191
|
+
end
|
192
|
+
context "when invoked with a single non-matching time constraint" do
|
193
|
+
it "returns a report with no BillingRecords" do
|
194
|
+
# now search for a zero-second time range
|
195
|
+
constraint = {'for_time' => [Time.now + 60, Time.now + 60]}
|
196
|
+
response = Report.new([constraint]).to_json
|
197
|
+
billing_records = JSON.parse(response)['billing_records']
|
198
|
+
billing_records.should be_empty
|
199
|
+
end
|
200
|
+
end
|
201
|
+
context "with a time constraint that partially overlaps a record" do
|
202
|
+
it "truncates the partially overlapped record" do
|
203
|
+
a_record = BillingRecord.first
|
204
|
+
record_duration = a_record.stop_time - a_record.start_time
|
205
|
+
query_start = a_record.start_time + (record_duration / 2)
|
206
|
+
constraint = {'for_time' => [query_start, Time.now]}
|
207
|
+
response = Report.new([constraint]).to_json
|
208
|
+
billing_records = JSON.parse(response)['billing_records']
|
209
|
+
first_start = Time.parse(billing_records.first['start_time'])
|
210
|
+
first_stop = Time.parse(billing_records.first['stop_time'])
|
211
|
+
first_start.should be_within(1).of query_start
|
212
|
+
first_stop.should be_within(1).of a_record.stop_time
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
# CONSTRAIN BY BILLING CODE
|
217
|
+
context "when coded records exist" do
|
218
|
+
before(:all) do
|
219
|
+
coded_record = BillingRecord.first
|
220
|
+
coded_record.billing_codes =
|
221
|
+
[BillingCode.new(:key => 'codekey', :value => 'codevalue')]
|
222
|
+
coded_record.save!
|
223
|
+
end
|
224
|
+
context "when invoked with a fully-matching code constraint" do
|
225
|
+
it "returns only BillingRecords with matching code" do
|
226
|
+
# now search for the coded record
|
227
|
+
constraint = {'for_code' => ['codekey', 'codevalue']}
|
228
|
+
response = Report.new([constraint]).to_json
|
229
|
+
billing_records = JSON.parse(response)['billing_records']
|
230
|
+
billing_records.size.should == 1
|
231
|
+
billing_records.first['billing_codes'].should ==
|
232
|
+
[[ 'codekey', 'codevalue' ]]
|
233
|
+
end
|
234
|
+
end
|
235
|
+
context "when invoked with a matching wildcard constraint" do
|
236
|
+
it "returns only BillingRecords with matching code" do
|
237
|
+
constraint = {'for_code' => ['codekey', '*']}
|
238
|
+
response = Report.new([constraint]).to_json
|
239
|
+
billing_records = JSON.parse(response)['billing_records']
|
240
|
+
billing_records.size.should == 1
|
241
|
+
billing_records.first['billing_codes'].should ==
|
242
|
+
[[ 'codekey', 'codevalue' ]]
|
243
|
+
constraint = {'for_code' => ['*', 'codevalue']}
|
244
|
+
response = Report.new([constraint]).to_json
|
245
|
+
billing_records = JSON.parse(response)['billing_records']
|
246
|
+
billing_records.size.should == 1
|
247
|
+
billing_records.first['billing_codes'].should ==
|
248
|
+
[[ 'codekey', 'codevalue' ]]
|
249
|
+
end
|
250
|
+
end
|
251
|
+
context "when invoked with only non-matching code constraints" do
|
252
|
+
it "returns a report with no BillingRecords" do
|
253
|
+
# now search for the coded record
|
254
|
+
constraint = {'for_code' => ['XXXXXXXXXX', '*']}
|
255
|
+
response = Report.new([constraint]).to_json
|
256
|
+
billing_records = JSON.parse(response)['billing_records']
|
257
|
+
billing_records.should be_empty
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module CFO
|
2
|
+
describe Service do
|
3
|
+
include Rack::Test::Methods
|
4
|
+
def app ; CFO::Service end
|
5
|
+
|
6
|
+
before(:all) do
|
7
|
+
@accounts = FogTracker.validate_accounts(
|
8
|
+
YAML.load(File.read('./config/accounts.yml')))
|
9
|
+
end
|
10
|
+
|
11
|
+
describe 'GET /' do
|
12
|
+
it "returns a list of the accounts" do
|
13
|
+
get "#{CFO.root}"
|
14
|
+
account_data = JSON.parse(last_response.body)
|
15
|
+
account_data.size.should == @accounts.size
|
16
|
+
account_data.each do |account|
|
17
|
+
@accounts.keys.should include(account['id'])
|
18
|
+
@accounts[account['id']][:service].should == account['service']
|
19
|
+
@accounts[account['id']][:provider].should == account['provider']
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe 'GET /accounts' do
|
25
|
+
it "returns a list of the accounts" do
|
26
|
+
get "#{CFO.root}/accounts"
|
27
|
+
account_data = JSON.parse(last_response.body)
|
28
|
+
account_data.size.should == @accounts.size
|
29
|
+
account_data.each do |account|
|
30
|
+
@accounts.keys.should include(account['id'])
|
31
|
+
@accounts[account['id']][:service].should == account['service']
|
32
|
+
@accounts[account['id']][:provider].should == account['provider']
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe 'GET /resources' do
|
38
|
+
it "returns a list of all known cloud computing resources" do
|
39
|
+
CloudCostTracker::Billing::BillingRecord.delete_all
|
40
|
+
# Create a pair of BillingRecords
|
41
|
+
resource1 = create(:record,{:resource_id => 'fake-resource-1'})
|
42
|
+
resource2 = create(:record,{:resource_id => 'fake-resource-2'})
|
43
|
+
get "#{CFO.root}/resources"
|
44
|
+
resource_data = JSON.parse(last_response.body)
|
45
|
+
(resource_data.map {|r| r['resource_id']}).should ==
|
46
|
+
['fake-resource-1', 'fake-resource-2']
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe 'GET /accounts/:account_name' do
|
51
|
+
context "when passed a valid account name" do
|
52
|
+
it "returns the JSON representation of the account named :account_name" do
|
53
|
+
account = @accounts[@accounts.keys.sort.first]
|
54
|
+
get "#{CFO.root}/accounts/#{URI.escape(@accounts.keys.sort.first)}"
|
55
|
+
account_data = JSON.parse(last_response.body)
|
56
|
+
account_data['id'].should == @accounts.keys.sort.first
|
57
|
+
account_data['service'].should == account[:service]
|
58
|
+
account_data['provider'].should == account[:provider]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
context "when passed an invalid account name" do
|
62
|
+
it "returns a 404 with an error message" do
|
63
|
+
get "#{CFO.root}/accounts/INVALID-ACCOUNT-NAME"
|
64
|
+
last_response.status.should == 404
|
65
|
+
error_data = JSON.parse(last_response.body)
|
66
|
+
error_data.keys.should include('error')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
describe 'GET /accounts/:account_name/:type/:resource_id' do
|
72
|
+
before(:each) do
|
73
|
+
CloudCostTracker::Billing::BillingRecord.delete_all
|
74
|
+
@record = create(:record)
|
75
|
+
@record_url = "#{CFO.root}/accounts/#{URI.escape(@record.account)}"+
|
76
|
+
"/#{URI.escape(@record.resource_type)}"+
|
77
|
+
"/#{URI.escape(@record.resource_id)}"
|
78
|
+
end
|
79
|
+
after(:each) {CloudCostTracker::Billing::BillingRecord.delete_all}
|
80
|
+
|
81
|
+
context "when passed a matching account name, resource type, and ID" do
|
82
|
+
it "returns the JSON representation of the matching resource" do
|
83
|
+
get @record_url
|
84
|
+
resource_data = JSON.parse(last_response.body)
|
85
|
+
resource_data['resource_id'].should == @record.resource_id
|
86
|
+
resource_data['resource_type'].should == @record.resource_type
|
87
|
+
resource_data['account']['id'].should == @record.account
|
88
|
+
resource_data['account']['service'].should == @record.service
|
89
|
+
resource_data['account']['provider'].should == @record.provider
|
90
|
+
end
|
91
|
+
end
|
92
|
+
context "when passed non-matching data" do
|
93
|
+
it "returns a 404 with an error message" do
|
94
|
+
get "#{@record_url}XXXXXXXXXXXXXXXXX"
|
95
|
+
last_response.status.should == 404
|
96
|
+
error_data = JSON.parse(last_response.body)
|
97
|
+
error_data.keys.should include('error')
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
ENV['RACK_ENV'] ||= 'test'
|
3
|
+
|
4
|
+
## Establish ActiveRecord connection and Migrate database
|
5
|
+
# The following db config files are used, in order of preference:
|
6
|
+
# 1. ENV['DB_CONFIG_FILE']
|
7
|
+
# 2. ./config/database.yml
|
8
|
+
# 3. ./config/database.example.yml
|
9
|
+
db_conf_file = './config/database.example.yml'
|
10
|
+
db_conf_file = './config/database.yml' if File.exists? './config/database.yml'
|
11
|
+
if ENV['DB_CONFIG_FILE']
|
12
|
+
db_conf_file = ENV['DB_CONFIG_FILE']
|
13
|
+
else
|
14
|
+
ENV['DB_CONFIG_FILE'] = db_conf_file
|
15
|
+
end
|
16
|
+
puts "Reading DB config file #{db_conf_file}..."
|
17
|
+
db_config = YAML::load(File.open(db_conf_file))[ENV['RACK_ENV'] || 'test']
|
18
|
+
puts "Using DB #{db_config['database']}..."
|
19
|
+
ActiveRecord::Base.establish_connection(db_config)
|
20
|
+
ActiveRecord::Migrator.migrate(
|
21
|
+
File.join(Gem.loaded_specs['cloud_cost_tracker'].full_gem_path,
|
22
|
+
'db', 'migrate'), ENV["VERSION"] ? ENV["VERSION"].to_i : nil
|
23
|
+
)
|
24
|
+
|
25
|
+
require './lib/cloud_financial_officer'
|
26
|
+
require './spec/factories'
|
27
|
+
require 'rack/test'
|
28
|
+
|
29
|
+
# Require RSpec support files. Logging is configured there
|
30
|
+
support_files = Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")]
|
31
|
+
support_files.sort.each {|f| require f}
|
32
|
+
|
33
|
+
# RSpec configuration block
|
34
|
+
RSpec.configure do |config|
|
35
|
+
config.mock_with :rspec # == Mock Framework
|
36
|
+
config.include FactoryGirl::Syntax::Methods
|
37
|
+
end
|