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,102 @@
|
|
1
|
+
module CFO
|
2
|
+
# Encapsulates sevral algorithms for for changing one set of BillingRecords
|
3
|
+
# into another.
|
4
|
+
module RecordGrouper
|
5
|
+
include CloudCostTracker::Billing
|
6
|
+
|
7
|
+
# Maps the grouping Strings to BillingRecord attributes.
|
8
|
+
# Any strings not found as keys in this Hash are interpreted as
|
9
|
+
# BillingCode keys.
|
10
|
+
ATTR_4_GROUPING = {
|
11
|
+
'account' => :account, 'service' => :service, 'provider' => :provider,
|
12
|
+
'type' => :resource_type, 'resource_id' => :resource_id,
|
13
|
+
'billing_type' => :billing_type,
|
14
|
+
}
|
15
|
+
|
16
|
+
# Defines the value put assigned to composite record's attibute when all
|
17
|
+
# its commponent records don't share the same value.
|
18
|
+
MULTIPLE_VALUES_VALUE = '*'
|
19
|
+
|
20
|
+
# Returns a set of "composite" BillingRecords based on an Array of groupings.
|
21
|
+
# If a grouping is one of ['account', 'provider', 'service', 'resource_id',
|
22
|
+
# 'resource_type', 'billing_type'], it is evaluated as a Record's attribute.
|
23
|
+
# Otherwise, its treated as a BillingCode key, and evaluated to it's value.
|
24
|
+
# @param [Array<BillingRecord>] input_records the records to group together
|
25
|
+
# @param [Array<String>] groupings the paramters to group records by
|
26
|
+
# @return [Array<BillingRecord>] the composite BillingRecords
|
27
|
+
def self.by(groupings, input_records)
|
28
|
+
# index the results by a "composite key", made by evaluating the groupings
|
29
|
+
results = Hash.new
|
30
|
+
input_records.each do |record|
|
31
|
+
# create the composite key for this record
|
32
|
+
comp_key = composite_key_for(record, groupings)
|
33
|
+
# if there is already composite record for this key,
|
34
|
+
if comp_record = results[comp_key]
|
35
|
+
# merge this record's data into the composite record
|
36
|
+
results[comp_key] = combine_records(comp_record, record)
|
37
|
+
else # otherwise, create a new composite record
|
38
|
+
# index the record into the results with the composite key
|
39
|
+
results[comp_key] = record
|
40
|
+
end
|
41
|
+
end
|
42
|
+
# Now output the results in composite key order
|
43
|
+
results.keys.sort.map {|comp_key| results[comp_key]}
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# Generates a composite key for a given record based on a list of groupings
|
49
|
+
def self.composite_key_for(record, groupings)
|
50
|
+
groupings.inject("") do |comp_key, grouping|
|
51
|
+
if ATTR_4_GROUPING[grouping] # this grouping represents an attribute
|
52
|
+
comp_key += record.send(ATTR_4_GROUPING[grouping])
|
53
|
+
else # this grouping is a BillingCode key -- search for the BillingCode
|
54
|
+
new_partial_key = '*UNDEFINED_OR_EMPTY*'
|
55
|
+
if code = record.billing_codes.find {|c| c.key == grouping}
|
56
|
+
new_partial_key = code.value if (code.value && (code.value != ''))
|
57
|
+
end
|
58
|
+
comp_key += new_partial_key
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Merges some information from record over a composite record comp_record,
|
64
|
+
# based on the provided groupings.
|
65
|
+
def self.combine_records(comp_record, record)
|
66
|
+
comp_record.total_cost += record.total_cost
|
67
|
+
comp_record.start_time = [comp_record.start_time, record.start_time].min
|
68
|
+
comp_record.stop_time = [comp_record.stop_time, record.stop_time].max
|
69
|
+
# recalculate the hourly rate for the composite record
|
70
|
+
duration = comp_record.stop_time - comp_record.start_time
|
71
|
+
comp_record.cost_per_hour = duration != 0 ?
|
72
|
+
comp_record.total_cost * SECONDS_PER_HOUR / duration : 0
|
73
|
+
comp_record.billing_codes =
|
74
|
+
(comp_record.billing_codes + record.billing_codes).uniq
|
75
|
+
ATTR_4_GROUPING.values.each do |attribute|
|
76
|
+
existing_value = comp_record.send(attribute)
|
77
|
+
new_record_value = record.send(attribute)
|
78
|
+
if existing_value != new_record_value
|
79
|
+
comp_record.send("#{attribute.to_s}=", MULTIPLE_VALUES_VALUE)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
comp_record
|
83
|
+
end
|
84
|
+
|
85
|
+
# Creates a new composite BillingRecord with only the attributes matching the
|
86
|
+
# grouping fields.
|
87
|
+
def self.create_composite_record(record, groupings)
|
88
|
+
record_attribs = groupings.inject({}) do |attrs, grouping|
|
89
|
+
if ATTR_4_GROUPING[grouping] # this grouping String represents an attribute
|
90
|
+
attrs[ATTR_4_GROUPING[grouping]] = record.send(ATTR_4_GROUPING[grouping])
|
91
|
+
end
|
92
|
+
attrs
|
93
|
+
end
|
94
|
+
BillingRecord.new(record_attribs.merge({
|
95
|
+
:total_cost => record.total_cost, :cost_per_hour => record.cost_per_hour,
|
96
|
+
:start_time => record.start_time, :stop_time => record.stop_time,
|
97
|
+
:billing_codes => record.billing_codes
|
98
|
+
}))
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module CFO
|
2
|
+
# Represents an account for a specific service with a cloud provider.
|
3
|
+
class Account
|
4
|
+
# A human-readable ID for the account - best to make this URL-safe.
|
5
|
+
attr_accessor :name
|
6
|
+
|
7
|
+
# The (Fog) name of the cloud computing service provider.
|
8
|
+
attr_accessor :provider
|
9
|
+
|
10
|
+
# The (Fog) name of the cloud computing service.
|
11
|
+
attr_accessor :service
|
12
|
+
|
13
|
+
# An Array of String pairs representing all the BillingCodes associated
|
14
|
+
# with all this Acccount's BillingRecords.
|
15
|
+
attr_accessor :billing_codes
|
16
|
+
|
17
|
+
# Creates a object representing a REST Resource for an account.
|
18
|
+
# @param attributes [Hash] - :name, :provider, :service, or :billing_codes
|
19
|
+
def initialize(attributes = {})
|
20
|
+
attributes.each {|k,v| self.send("#{k}=",v) if self.respond_to?("#{k}=")}
|
21
|
+
end
|
22
|
+
|
23
|
+
# @return [Hash] a RESTful Hash representation of this object.
|
24
|
+
# @param [Booolean] verbose whether to include biling codes and resources.
|
25
|
+
def to_hash(verbose = true)
|
26
|
+
extra_info = {
|
27
|
+
'resources' => CloudResource.hash_list(:account => @name),
|
28
|
+
'billing_codes' => @billing_codes,
|
29
|
+
}
|
30
|
+
{
|
31
|
+
'id' => @name,
|
32
|
+
'service' => @service,
|
33
|
+
'provider' => @provider,
|
34
|
+
'url' => uri,
|
35
|
+
'report_url' => report_uri,
|
36
|
+
}.merge(verbose ? extra_info : {})
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [String] a URI path to this object's JSON representation
|
40
|
+
def uri
|
41
|
+
"#{CFO.root}/accounts/#{URI.escape(@name)}"
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [String] a URI path to this object's cost report.
|
45
|
+
def report_uri
|
46
|
+
"#{CFO.root}/report/for_account/#{URI.escape(@name)}"
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [String] a RESTful JSON representation of this object.
|
50
|
+
def to_json ; to_hash.to_json end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module CFO
|
2
|
+
# Represents a billable asset on a cloud computing service.
|
3
|
+
class CloudResource
|
4
|
+
include CloudCostTracker::Billing
|
5
|
+
|
6
|
+
# A CFO::Account object.
|
7
|
+
attr_accessor :account
|
8
|
+
|
9
|
+
# The resource type, unique this account.
|
10
|
+
attr_accessor :resource_type
|
11
|
+
|
12
|
+
# The resource ID, unique to this account and resource type.
|
13
|
+
attr_accessor :resource_id
|
14
|
+
|
15
|
+
# An Array of String pairs representing all the BillingCodes associated
|
16
|
+
# with all this Resource's BillingRecords.
|
17
|
+
attr_accessor :billing_codes
|
18
|
+
|
19
|
+
# Creates a object representing a REST Resource for a billable resource.
|
20
|
+
# @param attributes [Hash] - :account, :resource_type,
|
21
|
+
# :resource_id, or :billing_codes
|
22
|
+
def initialize(attributes = {})
|
23
|
+
attributes.each {|k,v| self.send("#{k}=",v) if self.respond_to?("#{k}=")}
|
24
|
+
end
|
25
|
+
|
26
|
+
# @return [Hash] a RESTful Hash representation of this object.
|
27
|
+
# @param [Booolean] verbose whether to include a full representation.
|
28
|
+
def to_hash(verbose = true)
|
29
|
+
extra_data = {
|
30
|
+
'account' => {
|
31
|
+
'id' => @account.name,
|
32
|
+
'service' => @account.service,
|
33
|
+
'provider' => @account.provider,
|
34
|
+
'url' => @account.uri,
|
35
|
+
},
|
36
|
+
'report_url' => report_uri,
|
37
|
+
'billing_codes' => @billing_codes,
|
38
|
+
}
|
39
|
+
{ # Return the resource Hash, perhaps with the account merged in
|
40
|
+
'resource_id' => @resource_id,
|
41
|
+
'resource_type' => @resource_type,
|
42
|
+
'url' => uri,
|
43
|
+
}.merge(verbose ? extra_data : {})
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [String] a URI path to this object's JSON representation
|
47
|
+
def uri
|
48
|
+
"#{CFO.root}/accounts/#{URI.escape(@account.name)}/"+
|
49
|
+
"#{URI.escape(@resource_type)}/#{URI.escape(@resource_id)}"
|
50
|
+
end
|
51
|
+
|
52
|
+
# @return [String] a URI path to this object's cost report.
|
53
|
+
def report_uri
|
54
|
+
"#{CFO.root}/report/for_account/#{URI.escape(@account.name)}/for_type/"+
|
55
|
+
"#{URI.escape(@resource_type)}/for_resource/#{URI.escape(@resource_id)}"
|
56
|
+
end
|
57
|
+
|
58
|
+
# @return [String] a RESTful JSON representation of this object.
|
59
|
+
def to_json ; to_hash.to_json end
|
60
|
+
|
61
|
+
# @return [Array<Resource>] a 'links-only' list of matcing Resources,
|
62
|
+
# loaded from the database.
|
63
|
+
def self.hash_list(conditions = {})
|
64
|
+
BillingRecord.select('*').group(:resource_id).where(conditions).
|
65
|
+
collect do |rec|
|
66
|
+
CloudResource.new(
|
67
|
+
rec.attributes.merge('account' =>
|
68
|
+
CFO::Account.new(rec.attributes.merge(:name => rec.account)))
|
69
|
+
).to_hash(false) # Don't include the full account info
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
module CFO
|
2
|
+
# Represents a JSON report, based on an Array of (Hash) constraints.
|
3
|
+
class Report
|
4
|
+
include CloudCostTracker::Billing
|
5
|
+
|
6
|
+
# Creates a object representing a REST Resource for an account.
|
7
|
+
# @param [Array<Hash>] constraint_array a set of Hash constraints on the
|
8
|
+
# BillingRecords to be retrieved.
|
9
|
+
# Each hash key is a type of constraint on the type of BillingRecord
|
10
|
+
# information that is returned. The value is an array of Strings, which
|
11
|
+
# are generally just text to search for in some BillingRecord column, but
|
12
|
+
# are interpreted differently depending on the type of constraint.
|
13
|
+
# @param [Array<String>] groupings a set of Strings for grouping the results.
|
14
|
+
# Strings that match BillingRecord attributes will group all records that
|
15
|
+
# share the same value for that attribute into one record.
|
16
|
+
# Other Strings will can match BillingCode keys, for grouping by Code.
|
17
|
+
# @param [Hash] options optional additional parameters:
|
18
|
+
# - :logger - a Ruby Logger-compatible object
|
19
|
+
def initialize(constraint_array = [], groupings = [], options = {})
|
20
|
+
@constraints = constraint_array
|
21
|
+
@groupings = groupings
|
22
|
+
@log = options[:logger] || FogTracker.default_logger(nil)
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Hash] a Hash representation of the desired cost report.
|
26
|
+
def to_hash
|
27
|
+
update_records # Update @records from the database
|
28
|
+
update_summary_attributes # Update summary attributes
|
29
|
+
{
|
30
|
+
'start_time' => @start_time,
|
31
|
+
'stop_time' => @stop_time,
|
32
|
+
'total_cost' => @total_cost,
|
33
|
+
'cost_per_hour' => @hourly_rate.to_f,
|
34
|
+
'billing_codes' => @bill_codes,
|
35
|
+
'billing_records' => @records.map do |r|
|
36
|
+
r.to_hash.merge(
|
37
|
+
:cost_per_hour => r.cost_per_hour.to_f,
|
38
|
+
:total_cost => r.total_cost.to_f,
|
39
|
+
)
|
40
|
+
end,
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [String] a CSV representation of the desired cost report.
|
45
|
+
def to_csv
|
46
|
+
update_records # Update @records from the database
|
47
|
+
code_count = @records.inject({}) do |count, record|
|
48
|
+
record.billing_codes.each do |code|
|
49
|
+
count[code.key] = count[code.key] ? count[code.key] + 1 : 1
|
50
|
+
end
|
51
|
+
count
|
52
|
+
end
|
53
|
+
sorted_codes = code_count.keys.sort {|a,b| code_count[b] <=> code_count[a]}
|
54
|
+
headers = {
|
55
|
+
:start_time => 'Start Time',
|
56
|
+
:stop_time => 'Stop Time',
|
57
|
+
:account => 'Account Name',
|
58
|
+
:provider => 'Provder',
|
59
|
+
:service => 'Service',
|
60
|
+
:billing_type => 'Billing Type',
|
61
|
+
:total_cost => 'Total Cost',
|
62
|
+
:cost_per_hour => 'Hourly Rate',
|
63
|
+
:resource_id => 'Resource ID',
|
64
|
+
:resource_type => 'Resource Type',
|
65
|
+
}
|
66
|
+
@log.info "Using headers: #{headers.values + sorted_codes}"
|
67
|
+
csv = ((headers.values + sorted_codes).map{|h| "\"#{h}\""}).join(',') +"\n"
|
68
|
+
@records.each do |record|
|
69
|
+
record_data = headers.keys.map {|h| record.send h}
|
70
|
+
record_data += sorted_codes.map do |code|
|
71
|
+
billing_codes = record.billing_codes.select {|c| c.key == code}
|
72
|
+
case billing_codes.count
|
73
|
+
when 0 then ''
|
74
|
+
when 1 then billing_codes.first.value
|
75
|
+
else CFO::RecordGrouper::MULTIPLE_VALUES_VALUE
|
76
|
+
end
|
77
|
+
end
|
78
|
+
csv += (record_data.map{|value| "\"#{value}\""}).join(',') +"\n"
|
79
|
+
end
|
80
|
+
csv
|
81
|
+
end
|
82
|
+
|
83
|
+
# @return [String] a JSON report on the desired costs.
|
84
|
+
def to_json ; to_hash.to_json end
|
85
|
+
|
86
|
+
# Sets a constraint based on resource IDs.
|
87
|
+
# @param [Array<String>] resource_ids the values to match in the records.
|
88
|
+
def for_resource(resource_ids) ; {:resource_id => resource_ids} end
|
89
|
+
|
90
|
+
# Sets a constraint based on resource type.
|
91
|
+
# @param [Array<String>] resource_types the values to match in the records.
|
92
|
+
def for_type(resource_types) ; {:resource_type => resource_types} end
|
93
|
+
|
94
|
+
# Sets a constraint based on account name.
|
95
|
+
# @param [Array<String>] account_names the values to match in the records.
|
96
|
+
def for_account(account_names) ; {:account => account_names} end
|
97
|
+
|
98
|
+
# Sets a constraint based on provider name.
|
99
|
+
# @param [Array<String>] providers the values to match in the records.
|
100
|
+
def for_provider(providers) ; {:provider => providers} end
|
101
|
+
|
102
|
+
# Sets a constraint based on cloud computing service name.
|
103
|
+
# @param [Array<String>] services the values to match in the records.
|
104
|
+
def for_service(services) ; {:service => services} end
|
105
|
+
|
106
|
+
# Sets a constraint based on billing type.
|
107
|
+
# @param [Array<String>] billing_types the values to match in the records
|
108
|
+
def for_billing_type(billing_types) ; {:billing_type => billing_types} end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
# Updates @records from the database.
|
113
|
+
def update_records
|
114
|
+
conditions = Hash.new
|
115
|
+
@constraints.each do |constraint_hash|
|
116
|
+
constraint_hash.each do |constraint_key, values|
|
117
|
+
if self.respond_to? constraint_key
|
118
|
+
conditions.merge!(self.send(constraint_key, values))
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
# Query for BillingRecords, but freeze the resulting records
|
123
|
+
@records = BillingRecord.find(
|
124
|
+
:all, :conditions => conditions, :readonly => true
|
125
|
+
)
|
126
|
+
@log.debug "Got #{@records.count} records for constraints #{conditions}."
|
127
|
+
@records = RecordFilter.by_time(@records, @constraints)
|
128
|
+
@records = RecordFilter.by_code(@records, @constraints)
|
129
|
+
@records = RecordGrouper.by(@groupings, @records) if not @groupings.empty?
|
130
|
+
end
|
131
|
+
|
132
|
+
# Updates summary attributes for time range, cost and billing codes.
|
133
|
+
def update_summary_attributes
|
134
|
+
@start_time = (@records.map{|r| r.start_time}).sort.first
|
135
|
+
@stop_time = (@records.map{|r| r.stop_time}).sort.last
|
136
|
+
@total_cost = @records.inject(0.0) {|total, r| total += r.total_cost}
|
137
|
+
duration = (@start_time && @stop_time) ? (
|
138
|
+
Time.parse(@stop_time.to_s) - Time.parse(@start_time.to_s)
|
139
|
+
) : 0
|
140
|
+
@hourly_rate = duration != 0 ? @total_cost * SECONDS_PER_HOUR / duration : 0
|
141
|
+
@bill_codes = (@records.map {|r| r.billing_codes}).flatten.uniq.map do |c|
|
142
|
+
[c.key, c.value]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
end
|
147
|
+
end
|
data/lib/cfo/service.rb
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require File.join(File.dirname(__FILE__), '..', 'cloud_financial_officer')
|
3
|
+
require 'sinatra/base'
|
4
|
+
CFO.force_timezone # This does nothing by default - modify it if you need it
|
5
|
+
|
6
|
+
module CFO
|
7
|
+
# A RESTful web service for reporting on a CloudCostTracker database.
|
8
|
+
class Service < Sinatra::Base
|
9
|
+
include ::CloudCostTracker::Billing
|
10
|
+
|
11
|
+
# Load account information
|
12
|
+
configure do ; set :accounts, CFO.read_accounts end
|
13
|
+
|
14
|
+
configure :development do
|
15
|
+
require 'sinatra/reloader'
|
16
|
+
register Sinatra::Reloader
|
17
|
+
end
|
18
|
+
configure :production, :development do
|
19
|
+
enable :logging
|
20
|
+
ActiveRecord::Base.logger ||= FogTracker.default_logger(STDOUT)
|
21
|
+
#ActiveRecord::Base.logger.level = ::Logger::DEBUG
|
22
|
+
end
|
23
|
+
before do
|
24
|
+
if not ActiveRecord::Base.connected? # Connect to the database as needed
|
25
|
+
logger.info "Using database #{(CFO.connect_to_database)[:database]}..."
|
26
|
+
end
|
27
|
+
logger.info "Got params #{params}"
|
28
|
+
if params['content-type'] && (params['content-type'] =~ %r{(text/)?csv})
|
29
|
+
content_type 'text/csv' # serve only JSON by default
|
30
|
+
else
|
31
|
+
content_type 'application/json' # serve only JSON by default
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
######## ACCOUNTS ########
|
37
|
+
|
38
|
+
# Returns a list of accounts from / or /accounts.
|
39
|
+
get %r{^#{CFO.root}(/accounts)?/?$} do
|
40
|
+
(settings.accounts.map{|account| account.to_hash(false)}).to_json
|
41
|
+
end
|
42
|
+
|
43
|
+
# Returns an individial account.
|
44
|
+
get "#{CFO.root}/accounts/:account_name" do
|
45
|
+
if account = settings.accounts.find {|a| a.name == params[:account_name]}
|
46
|
+
account.billing_codes = BillingCode.joins(:billing_records).where(
|
47
|
+
:billing_records => {:account => params[:account_name]}
|
48
|
+
).uniq.map {|c| [c.key, c.value]}
|
49
|
+
account.to_json
|
50
|
+
else
|
51
|
+
[404, {'error' => "Account #{params[:account_name]} not found"}.to_json]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
######## RESOURCES ########
|
56
|
+
|
57
|
+
# Returns a list of CloudResources.
|
58
|
+
get %r{^#{CFO.root}/resources/?$} do
|
59
|
+
CFO::CloudResource.hash_list.to_json
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns an individial CloudResource.
|
63
|
+
get "#{CFO.root}/accounts/:account_name/:type/:resource_id" do
|
64
|
+
all_records = BillingRecord.where(
|
65
|
+
:account => params[:account_name],
|
66
|
+
:resource_type => params[:type],
|
67
|
+
:resource_id => params[:resource_id],
|
68
|
+
)
|
69
|
+
if record = all_records.first
|
70
|
+
codes = BillingCode.joins(:billing_records).where(
|
71
|
+
:billing_records => {:resource_id => params[:resource_id]}
|
72
|
+
)
|
73
|
+
CFO::CloudResource.new(
|
74
|
+
:resource_id => record.resource_id,
|
75
|
+
:resource_type => record.resource_type,
|
76
|
+
:account => CFO::Account.new(
|
77
|
+
:name => record.account,
|
78
|
+
:service => record.service,
|
79
|
+
:provider => record.provider,
|
80
|
+
),
|
81
|
+
:billing_codes => codes.map {|c| [c.key, c.value]}
|
82
|
+
).to_json
|
83
|
+
else
|
84
|
+
[404, {'error' => "Resource #{params[:resource_id]}"+
|
85
|
+
" in account #{params[:account_name]} not found"}.to_json
|
86
|
+
]
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
######## REPORTS ########
|
91
|
+
|
92
|
+
# Returns a cost report based on constraints in the request path.
|
93
|
+
get %r{^#{CFO.root}/report.*} do
|
94
|
+
constraints = constraints_from_path(request.path_info)
|
95
|
+
logger.info "Generating report for #{constraints.inspect}."
|
96
|
+
groupings = groupings_from_path(request.path_info)
|
97
|
+
logger.info "Using groupings #{groupings.inspect}."
|
98
|
+
report = CFO::Report.new(constraints, groupings, :logger => logger)
|
99
|
+
if params['content-type'] && (params['content-type'] =~ %r{(text/)?csv})
|
100
|
+
report_file = "report_"+
|
101
|
+
((constraints.map {|c| c.flatten.flatten}).flatten).join('_')+
|
102
|
+
".#{Time.now.strftime('%Y-%m-%d-%H%M%S')}.csv"
|
103
|
+
response['Content-Disposition'] = "attachment; filename=#{report_file}"
|
104
|
+
report.to_csv
|
105
|
+
else
|
106
|
+
report.to_json
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
CONSTRAINT_PATTERN = /(for_\w+)/
|
111
|
+
GROUPING_PATTERN = /by_(\w+)/
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
# Returns a Hash of report constraints (see Report.new) from a path String
|
116
|
+
def constraints_from_path(path)
|
117
|
+
constraints = []
|
118
|
+
tokens = (path.split('/')).drop(2)
|
119
|
+
while not tokens.empty?
|
120
|
+
if key_match = tokens.first.match(CONSTRAINT_PATTERN)
|
121
|
+
constraint_key, constraint_values = key_match[1], Array.new
|
122
|
+
constraint_values = tokens.drop(1).take_while do |t|
|
123
|
+
(t !~ CONSTRAINT_PATTERN) && (t !~ GROUPING_PATTERN)
|
124
|
+
end
|
125
|
+
constraints << {constraint_key => constraint_values}
|
126
|
+
tokens = tokens.drop(constraint_values.size + 1)
|
127
|
+
else
|
128
|
+
tokens = tokens.drop 1
|
129
|
+
end
|
130
|
+
end
|
131
|
+
constraints
|
132
|
+
end
|
133
|
+
|
134
|
+
# Returns a Hash of report constraints (see Report.new) from a path String
|
135
|
+
def groupings_from_path(path)
|
136
|
+
tokens = (path.split('/')).drop(2)
|
137
|
+
tokens.inject([]) do |groupings, token|
|
138
|
+
if not token.scan(GROUPING_PATTERN).empty?
|
139
|
+
groupings << token.scan(GROUPING_PATTERN).first.first
|
140
|
+
end
|
141
|
+
groupings
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# start the server if ruby file executed directly
|
146
|
+
run! if app_file == $0
|
147
|
+
end
|
148
|
+
end
|
data/lib/cfo/version.rb
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
%w{rubygems bundler}.each {|lib| require lib}
|
2
|
+
require File.join(File.dirname(__FILE__), "cfo/cfo")
|
3
|
+
Bundler.require(:default, CFO.env.to_sym)
|
4
|
+
Bundler.require(:development) if CFO.env == 'test'
|
5
|
+
require 'cloud_cost_tracker'
|
6
|
+
|
7
|
+
# Load all ruby files from 'cfo' directory
|
8
|
+
Dir[File.join(File.dirname(__FILE__), "cfo/**/*.rb")].each {|f| require f}
|
data/spec/factories.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
ACCOUNT_NAME = "fake-account-name"
|
2
|
+
SERVICE_NAME = "fake-service"
|
3
|
+
PROVIDER_NAME = "fake-provider"
|
4
|
+
RESOURCE_ID = "fake-resource-ID"
|
5
|
+
RESOURCE_TYPE = "fake-resource-type"
|
6
|
+
BILLING_TYPE = "fake-billing-type"
|
7
|
+
RECORD_TOTAL = 1.0
|
8
|
+
RECORD_HOURLY = 60.0
|
9
|
+
RECORD_DURATION = 60
|
10
|
+
|
11
|
+
FactoryGirl.define do
|
12
|
+
factory :account, :class => CFO::Account do
|
13
|
+
name ACCOUNT_NAME
|
14
|
+
service SERVICE_NAME
|
15
|
+
provider PROVIDER_NAME
|
16
|
+
end
|
17
|
+
|
18
|
+
factory :resource, :class => CFO::CloudResource do
|
19
|
+
resource_id RESOURCE_ID
|
20
|
+
resource_type RESOURCE_TYPE
|
21
|
+
end
|
22
|
+
|
23
|
+
factory :record, :class => CloudCostTracker::Billing::BillingRecord do
|
24
|
+
provider PROVIDER_NAME
|
25
|
+
service SERVICE_NAME
|
26
|
+
account ACCOUNT_NAME
|
27
|
+
resource_id RESOURCE_ID
|
28
|
+
resource_type RESOURCE_TYPE
|
29
|
+
billing_type BILLING_TYPE
|
30
|
+
start_time Time.now - RECORD_DURATION
|
31
|
+
stop_time Time.now
|
32
|
+
cost_per_hour RECORD_HOURLY
|
33
|
+
total_cost RECORD_TOTAL
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module CFO
|
2
|
+
include CloudCostTracker::Billing
|
3
|
+
|
4
|
+
describe RecordFilter 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
|
+
CloudCostTracker::Billing::BillingRecord.delete_all
|
11
|
+
@billing_start = Time.now
|
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 = Time.now + 1
|
24
|
+
end
|
25
|
+
|
26
|
+
after(:all) do
|
27
|
+
BillingRecord.delete_all
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '#truncate_by_time' do
|
31
|
+
it "limits all records' start and stop times to within the given range" do
|
32
|
+
# make sure some records are out-of the start and stop range
|
33
|
+
first_record = BillingRecord.first
|
34
|
+
last_record = BillingRecord.last
|
35
|
+
first_record.start_time = @billing_start - SECONDS_PER_HOUR
|
36
|
+
last_record.stop_time = @billing_stop + SECONDS_PER_HOUR
|
37
|
+
[first_record, last_record].each {|r| r.save!}
|
38
|
+
# Now filter the records by time
|
39
|
+
results = RecordFilter.truncate_by_time(BillingRecord.all,
|
40
|
+
@billing_start, @billing_stop)
|
41
|
+
results.each do |truncated_record|
|
42
|
+
truncated_record.start_time.should >= @billing_start
|
43
|
+
truncated_record.stop_time.should <= @billing_stop
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
end
|