cloudcost 0.1.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ff5e2d72a7a5655eb528bd4b762c8682e7b388bdbd15f14785ba13b733710e6
4
- data.tar.gz: 045d769db58a4f871a228200500ad3de827d593e562cb6b6ad3976718ca1c859
3
+ metadata.gz: e1f7c30aba80a09bbcd150df43e48d0d7dfa47bd14460634e6997d451957d5f9
4
+ data.tar.gz: f0f628f8ffbc0f7cdcc08e9882758b11392b159e5293671ca0f24cd3a507ec68
5
5
  SHA512:
6
- metadata.gz: 61f67a42c2575cfca9b88f55ed00715a8f32c3a9c1dc4b4f03e3c56a783ef9574c7d63060a3aa6fc5e501ea0cd6f5fcbdf866f0fafd78da4b7f8d202fcafcac9
7
- data.tar.gz: 3d96ff16f9b0015af1f03ffc37ec70c559c42fbcb59403f5bd331c3acec6066f3d4120d9a6a93aaa2d1fe0cc1827f2c6c5c5b342bf3abb11560fa05bd661609b
6
+ metadata.gz: 4bf7307e6baca20b673899a5729106114e6933e7a719022350ba9c2a14724b98b1b625503d1015d052279dfe5e2b029567dbfd7089207565ababe3cdd359ef67
7
+ data.tar.gz: d3abdac8948c4e93a9df63458018b390f1c1ea6d92a2365e1b99d93de973c862989f6dfca797fe3008e1796790a137c98b644bf9809fe1ed5c6f3b2535af8ae6
data/Dockerfile ADDED
@@ -0,0 +1,12 @@
1
+ FROM ruby:3.0-alpine
2
+
3
+ ARG INFLUX_RELEASE=influxdb2-client-2.1.0-linux-amd64.tar.gz
4
+
5
+ RUN apk --no-cache add wget \
6
+ && gem install cloudcost \
7
+ && wget https://dl.influxdata.com/influxdb/releases/$INFLUX_RELEASE \
8
+ && tar xvfz $INFLUX_RELEASE -C /usr/local/bin --strip-components=1 \
9
+ && rm -f $INFLUX_RELEASE
10
+
11
+ ENTRYPOINT [ "cloudcost" ]
12
+ CMD [ "help" ]
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cloudcost (0.0.1)
5
- excon (~> 0.82.0)
4
+ cloudcost (0.4.0)
5
+ excon (~> 0.85.0)
6
6
  parseconfig (~> 1.1.0)
7
7
  terminal-table (~> 3.0.1)
8
8
  thor (~> 1.1.0)
@@ -11,15 +11,15 @@ PATH
11
11
  GEM
12
12
  remote: https://rubygems.org/
13
13
  specs:
14
- excon (0.82.0)
14
+ excon (0.85.0)
15
15
  parseconfig (1.1.0)
16
- terminal-table (3.0.1)
16
+ terminal-table (3.0.2)
17
17
  unicode-display_width (>= 1.1.1, < 3)
18
18
  thor (1.1.0)
19
19
  tty-cursor (0.7.1)
20
20
  tty-spinner (0.9.3)
21
21
  tty-cursor (~> 0.7)
22
- unicode-display_width (2.0.0)
22
+ unicode-display_width (2.1.0)
23
23
 
24
24
  PLATFORMS
25
25
  ruby
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2021 Puzzle ITC
3
+ Copyright (c) 2021 Nik Wolfgramm
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -19,8 +19,7 @@ gem install cloudcost
19
19
 
20
20
  cloudcost does support the same auth configuration options as [cloudscale-cli](https://cloudscale-ch.github.io/cloudscale-cli/).
21
21
 
22
- You can manage multiple profiles using `cloudscale.ini` files ([see here](https://cloudscale-ch.github.io/cloudscale-cli/auth/) for instructions).
23
-
22
+ You can manage multiple profiles using `cloudscale.ini` files ([see here](https://cloudscale-ch.github.io/cloudscale-cli/auth/) for instructions).
24
23
 
25
24
  Otherwise you can export a `CLOUDSCALE_API_TOKEN` in your environment:
26
25
 
@@ -66,7 +65,7 @@ cloudcost servers --summary
66
65
 
67
66
  #### Group and summarize by tag
68
67
 
69
- By using the `--tag-by` option, you can summarize usage by tag:
68
+ By using the `--group-by` option, you can summarize usage by tag:
70
69
 
71
70
  ```sh
72
71
  cloudcost servers --group-by budget-group
@@ -87,6 +86,19 @@ cloudcost servers --group-by budget-group --profile prod | \
87
86
  influx write --bucket my-bucket --org my-org --token my-super-secret-auth-token
88
87
  ```
89
88
 
89
+ Example Flux-Query for loading data from InfluxDB:
90
+
91
+ ```sh
92
+ influx query --org my-org --token my-super-secret-auth-token \
93
+ 'from(bucket:"my-bucket")
94
+ |> range(start: -1d)
95
+ |> filter(fn: (r) =>
96
+ r._measurement == "cloudscaleServerCosts" and
97
+ r._field == "cost_per_day") and
98
+ r.profile == "prod" and
99
+ r.group == "my-budget-group"'
100
+ ```
101
+
90
102
  #### CSV Output
91
103
 
92
104
  Output in CSV format instead of a table:
@@ -154,3 +166,37 @@ cloudcost server-tags --name ldap --set-tags owner=sys budget-group=base-infrast
154
166
  ```sh
155
167
  cloudcost server-tags --name ldap --remove-tags owner budget-group
156
168
  ```
169
+
170
+ ### Volumes
171
+
172
+ List all volumes:
173
+
174
+ ```sh
175
+ cloudcost volumes
176
+ ```
177
+
178
+ Only list volumes of type `bulk`
179
+
180
+ ```sh
181
+ cloudcost volumes --type bulk
182
+ ```
183
+
184
+ List volumes which are not attached to a server:
185
+
186
+ ```sh
187
+ cloudcost volumes --no-attached
188
+ ```
189
+
190
+ Filter volumes by names:
191
+
192
+ ```sh
193
+ cloudcost volumes --name "pvc"
194
+ ```
195
+
196
+ Output as InfluxDB Line Protocol:
197
+
198
+ ```sh
199
+ cloudcost volumes --output influx --profile prod --no-attached
200
+ ```
201
+
202
+ NOTE: The Line Protocol output includes a tag `state` which is either "attached", "unattached" or "all".
data/Rakefile CHANGED
@@ -6,3 +6,17 @@ require "rubocop/rake_task"
6
6
  RuboCop::RakeTask.new
7
7
 
8
8
  task default: %i[rubocop]
9
+
10
+ DOCKER_REGISTRY = "registry.puzzle.ch/puzzle/cloudcost"
11
+
12
+ desc "Build the docker image and tag it with the current version."
13
+ task :docker_build do
14
+ puts command = "docker build -t #{DOCKER_REGISTRY}:#{Cloudcost::VERSION} ."
15
+ puts `#{command}`
16
+ end
17
+
18
+ desc "Push the newest docker image."
19
+ task :docker_push do
20
+ puts command = "docker push #{DOCKER_REGISTRY}:#{Cloudcost::VERSION}"
21
+ puts `#{command}`
22
+ end
data/cloudcost.gemspec CHANGED
@@ -10,8 +10,7 @@ Gem::Specification.new do |s|
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
12
  s.authors = ["Nik Wolfgramm"]
13
- s.description = "Calculate cloudscale.ch server costs from your actual deployment"
14
- s.email = "wolfgramm@puzzle.ch"
13
+ s.description = "Calculate cloudscale.ch server costs from the current deployment"
15
14
  s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
16
15
  s.require_paths = ["lib"]
17
16
  s.required_ruby_version = ">= 2.7"
@@ -20,7 +19,7 @@ Gem::Specification.new do |s|
20
19
  s.test_files = s.files.grep(%r{^(test|spec|features)/})
21
20
  s.license = "MIT"
22
21
 
23
- s.add_dependency("excon", "~> 0.82.0")
22
+ s.add_dependency("excon", "~> 0.85.0")
24
23
  s.add_dependency("parseconfig", "~> 1.1.0")
25
24
  s.add_dependency("terminal-table", "~> 3.0.1")
26
25
  s.add_dependency("thor", "~> 1.1.0")
@@ -4,6 +4,7 @@ require "excon"
4
4
  require "json"
5
5
 
6
6
  module Cloudcost
7
+ # Connecting to and accessing the cloudscale.ch API
7
8
  class ApiConnection
8
9
  API_URL = "https://api.cloudscale.ch"
9
10
 
@@ -38,6 +39,19 @@ module Cloudcost
38
39
  )
39
40
  end
40
41
 
42
+ def get_volumes(options = {})
43
+ volumes = get_resource("volumes", options)
44
+ volumes = volumes.reject { |volume| volume[:tags].key?(options[:missing_tag].to_sym) } if options[:missing_tag]
45
+ volumes = volumes.select { |volume| /#{options[:name]}/.match? volume[:name] } if options[:name]
46
+ volumes = volumes.select { |volume| /#{options[:type]}/.match? volume[:type] } if options[:type]
47
+ unless options[:attached].nil?
48
+ volumes = volumes.select do |volume|
49
+ (volume[:servers].size.positive?) == options[:attached]
50
+ end
51
+ end
52
+ volumes
53
+ end
54
+
41
55
  private
42
56
 
43
57
  def new_connection
@@ -3,9 +3,9 @@
3
3
  require "parseconfig"
4
4
  require "excon"
5
5
  require "json"
6
- require "cloudcost/error"
7
6
 
8
7
  module Cloudcost
8
+ # Support for cloudscale-cli style API-tokens and profiles
9
9
  class ApiToken
10
10
  attr_accessor :profile, :token
11
11
 
data/lib/cloudcost/cli.rb CHANGED
@@ -4,6 +4,7 @@ require "thor"
4
4
  require "tty-spinner"
5
5
 
6
6
  module Cloudcost
7
+ # Implementaion of CLI functionality
7
8
  class CLI < Thor
8
9
  # Error raised by this runner
9
10
  Error = Class.new(StandardError)
@@ -38,22 +39,18 @@ module Cloudcost
38
39
  spinner = TTY::Spinner.new("[:spinner] Calculating costs...", clear: options[:csv])
39
40
  spinner.auto_spin
40
41
  end
41
- output(servers, options) do |result|
42
- spinner.success("(done)") if spinner
42
+ output_servers(servers, options) do |result|
43
+ spinner&.success("(done)")
43
44
  puts result
44
45
  end
45
46
  rescue Excon::Error, TokenError, ProfileError, PricingError => e
46
47
  error_message = "ERROR: #{e.message}"
47
- if spinner
48
- spinner.error("(#{error_message})")
49
- else
50
- puts error_message
51
- end
48
+ spinner ? spinner.error(error_message) : puts(error_message)
52
49
  end
53
50
 
54
51
  desc "server-tags", "show and assign tags of servers"
55
52
  option :name, desc: "filter name by regex", aliases: %w[-n]
56
- option :tag, desc: "filter servers by tag", aliases: %w[-t]
53
+ option :tag, desc: "filter by tag", aliases: %w[-t]
57
54
  option :set_tags,
58
55
  desc: "set tags",
59
56
  aliases: %w[-T],
@@ -79,19 +76,40 @@ module Cloudcost
79
76
  (options[:remove_tags] || []).each do |tag|
80
77
  tags.reject! { |k| k == tag.to_sym }
81
78
  end
82
- api_connection(options).set_server_tags(server.uuid, tags)
83
- spinner.success
79
+ begin
80
+ api_connection(options).set_server_tags(server.uuid, tags)
81
+ spinner.success
82
+ rescue Excon::Error => e
83
+ spinner.error "ERROR: #{e.message}"
84
+ end
84
85
  end
85
86
  end
86
87
  spinners.auto_spin
87
88
  end
88
- rescue Excon::Error, TokenError, ProfileError => e
89
- error_message = "ERROR: #{e.message}"
90
- if defined?(spinner)
91
- spinner.error("(#{error_message})")
92
- else
93
- puts error_message
89
+ rescue Cloudcost::TokenError, Cloudcost::ProfileError => e
90
+ puts "ERROR: #{e.message}"
91
+ end
92
+
93
+ desc "volumes", "explore volumes"
94
+ option :name, desc: "filter name by regex", aliases: %w[-n]
95
+ option :tag, desc: "filter by tag", aliases: %w[-t]
96
+ option :summary, desc: "display totals only", type: :boolean, aliases: %w[-S]
97
+ option :type, enum: %w[ssd bulk], desc: "volume type"
98
+ option :attached, type: :boolean, desc: "volume attached to servers"
99
+ option :output, default: "table", enum: %w[table csv influx], desc: "output format", aliases: %w[-o]
100
+ def volumes
101
+ volumes = load_volumes(options)
102
+ if options[:output] == "table"
103
+ spinner = TTY::Spinner.new("[:spinner] Calculating costs...", clear: options[:csv])
104
+ spinner.auto_spin
105
+ end
106
+ output_volumes(volumes, options) do |result|
107
+ spinner&.success("(done)")
108
+ puts result
94
109
  end
110
+ rescue Excon::Error, Cloudcost::TokenError, Cloudcost::ProfileError, Cloudcost::PricingError => e
111
+ error_message = "ERROR: #{e.message}"
112
+ spinner ? spinner.error(error_message) : puts(error_message)
95
113
  end
96
114
 
97
115
  no_tasks do
@@ -114,21 +132,55 @@ module Cloudcost
114
132
  spinner = TTY::Spinner.new("[:spinner] Loading servers...", clear: options[:csv])
115
133
  spinner.auto_spin
116
134
  end
117
- servers = api_connection(options).get_servers(options).map { |server| Server.new(server) }
118
- spinner.success "(#{servers.size} found)" if spinner
135
+ servers = api_connection(options).get_servers(options).map { |server| Cloudcost::Server.new(server) }
136
+ spinner&.success "(#{servers.size} found)"
119
137
  servers
138
+ rescue Excon::Error => e
139
+ spinner&.error "ERROR: #{e.message}"
140
+ []
141
+ end
142
+
143
+ def load_volumes(options)
144
+ if options[:output] == "table"
145
+ spinner = TTY::Spinner.new("[:spinner] Loading volumes...", clear: options[:csv])
146
+ spinner.auto_spin
147
+ end
148
+ volumes = api_connection(options).get_volumes(options).map { |volume| Cloudcost::Volume.new(volume) }
149
+ spinner&.success "(#{volumes.size} found)"
150
+ volumes
151
+ rescue Excon::Error => e
152
+ spinner&.error "\ERROR: #{e.message}"
153
+ []
120
154
  end
121
155
 
122
- def output(servers, options)
123
- if options[:group_by]
124
- yield Cloudcost::ServerList.new(servers, options).grouped_cost_table
156
+ def output_servers(servers, options)
157
+ if servers.empty?
158
+ yield "WARNING: No servers found."
159
+ elsif options[:group_by]
160
+ yield Cloudcost::ServerList.new(servers, options).grouped_costs
125
161
  elsif options[:output] == "csv"
126
162
  yield Cloudcost::ServerList.new(servers, options).to_csv
127
163
  else
164
+ if options[:output] == "influx"
165
+ puts "ERROR: group-by option required for influx output"
166
+ exit 1
167
+ end
128
168
  yield Cloudcost::ServerList.new(servers, options).cost_table
129
169
  end
130
170
  end
131
171
 
172
+ def output_volumes(volumes, options)
173
+ if volumes.empty?
174
+ yield "WARNING: No volumes found."
175
+ elsif options[:output] == "csv"
176
+ yield Cloudcost::VolumeList.new(volumes, options).to_csv
177
+ elsif options[:output] == "influx"
178
+ yield Cloudcost::VolumeList.new(volumes, options).totals_influx_line_protocol
179
+ else
180
+ yield Cloudcost::VolumeList.new(volumes, options).cost_table
181
+ end
182
+ end
183
+
132
184
  def tag_option_to_s(options)
133
185
  messages = []
134
186
  messages << "set tags \"#{options[:set_tags].join(", ")}\"" if options[:set_tags]
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudcost
4
+ # generic CSV output methods
5
+ module CsvOutput
6
+ def groups_to_csv(group_rows)
7
+ CSV.generate do |csv|
8
+ csv << headings
9
+ group_rows.each { |row| csv << row }
10
+ end
11
+ end
12
+
13
+ def to_csv
14
+ CSV.generate do |csv|
15
+ csv << headings
16
+ if @options[:summary]
17
+ csv << totals
18
+ else
19
+ rows.each { |row| csv << row }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cloudcost/pricing"
4
-
5
3
  module Cloudcost
6
4
  def self.tags_to_s(tag_hash = [])
7
5
  tag_hash.map { |k, v| "#{k}=#{v}" }.join(" ")
8
6
  end
9
7
 
8
+ # Representation of cloudscale.ch server object
10
9
  class Server
11
10
  attr_accessor :data
12
11
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudcost
4
+ # InfluxDB output methods for the ServerList class
5
+ module ServerInfluxdbOutput
6
+ def grouped_influx_line_protocol(group_rows)
7
+ lines = []
8
+ group_rows.each do |row|
9
+ [
10
+ { field: "server_count", position: 1, unit: "i" },
11
+ { field: "vcpus", position: 2, unit: "i" },
12
+ { field: "memory_gb", position: 3, unit: "i" },
13
+ { field: "ssd_gb", position: 4, unit: "i" },
14
+ { field: "bulk_gb", position: 5, unit: "i" },
15
+ { field: "chf_per_day", position: 6, unit: "" }
16
+ ].each do |field|
17
+ lines << %(
18
+ cloudscaleServerCosts,group=#{row[0]},profile=#{@options[:profile] || "?"}
19
+ #{field[:field]}=#{row[field[:position]]}#{field[:unit]}
20
+ ).gsub(/\s+/, " ").strip
21
+ end
22
+ end
23
+ lines.join("\n")
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudcost
4
+ # ServerList represents a list of servers and integrates several output methods
5
+ class ServerList
6
+ include Cloudcost::ServerTabularOutput
7
+ include Cloudcost::ServerInfluxdbOutput
8
+ include Cloudcost::CsvOutput
9
+
10
+ def initialize(servers, options = {})
11
+ @servers = servers
12
+ @options = options
13
+ end
14
+
15
+ def calculate_totals(servers = @servers)
16
+ totals = { vcpu: 0, memory: 0, ssd: 0, bulk: 0, cost: 0.0 }
17
+ servers.each do |server|
18
+ totals[:vcpu] += server.vcpu_count
19
+ totals[:memory] += server.memory_gb
20
+ totals[:ssd] += server.storage_size(:ssd)
21
+ totals[:bulk] += server.storage_size(:bulk)
22
+ totals[:cost] += server.total_costs_per_day
23
+ end
24
+ totals
25
+ end
26
+
27
+ def totals(servers = @servers)
28
+ totals = calculate_totals(servers)
29
+ total_row = @options[:summary] ? %w[Total] : ["Total", "", "", ""]
30
+ total_row.concat [
31
+ totals[:vcpu],
32
+ totals[:memory],
33
+ totals[:ssd],
34
+ totals[:bulk],
35
+ format("%.2f", totals[:cost].round(2)),
36
+ format("%.2f", (totals[:cost] * 30).round(2))
37
+ ]
38
+ end
39
+
40
+ def grouped_costs
41
+ no_tag = "<no-tag>"
42
+ group_rows = @servers.group_by { |s| s.tags[@options[:group_by].to_sym] || no_tag }.map do |name, servers|
43
+ server_groups_data(name, servers).values.flatten
44
+ end
45
+ group_rows.sort! { |a, b| a[0] == no_tag ? 1 : a[0] <=> b[0] }
46
+ case @options[:output]
47
+ when "csv"
48
+ groups_to_csv(group_rows)
49
+ when "influx"
50
+ grouped_influx_line_protocol(group_rows)
51
+ else
52
+ grouped_cost_table(group_rows)
53
+ end
54
+ end
55
+
56
+ def server_groups_data(name, servers)
57
+ data = { name: name, count: 0, vcpu: 0, memory: 0, ssd: 0, bulk: 0, costs_daily: 0 }
58
+ servers.each do |server|
59
+ data[:count] += 1
60
+ data[:vcpu] += server.vcpu_count
61
+ data[:memory] += server.memory_gb
62
+ data[:ssd] += server.storage_size(:ssd)
63
+ data[:bulk] += server.storage_size(:bulk)
64
+ data[:costs_daily] += server.total_costs_per_day
65
+ end
66
+ data[:costs_monthly] = (data[:costs_daily] * 30).round(2)
67
+ data[:costs_daily] = data[:costs_daily].round(2)
68
+ data
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "terminal-table"
4
+
5
+ module Cloudcost
6
+ # Tabular output methods for the ServerList class
7
+ module ServerTabularOutput
8
+ def headings
9
+ headings = if @options[:summary]
10
+ [""]
11
+ elsif @options[:group_by]
12
+ %w[Group Servers]
13
+ else
14
+ %w[Name UUID Flavor Tags]
15
+ end
16
+ headings.concat ["vCPU's", "Memory [GB]", "SSD [GB]", "Bulk [GB]", "CHF/day", "CHF/30-days"]
17
+ end
18
+
19
+ def rows
20
+ rows = []
21
+ @servers.sort_by(&:name).map do |server|
22
+ rows << [
23
+ server.name,
24
+ server.uuid,
25
+ server.flavor,
26
+ server.tags_to_s,
27
+ server.vcpu_count,
28
+ server.memory_gb,
29
+ server.storage_size(:ssd),
30
+ server.storage_size(:bulk),
31
+ format("%.2f", server.total_costs_per_day.round(2)),
32
+ format("%.2f", (server.total_costs_per_day * 30).round(2))
33
+ ]
34
+ end
35
+ rows
36
+ end
37
+
38
+ def totals(servers = @servers)
39
+ totals = calculate_totals(servers)
40
+ total_row = @options[:summary] ? %w[Total] : ["Total", "", "", ""]
41
+ total_row.concat [
42
+ totals[:vcpu],
43
+ totals[:memory],
44
+ totals[:ssd],
45
+ totals[:bulk],
46
+ format("%.2f", totals[:cost].round(2)),
47
+ format("%.2f", (totals[:cost] * 30).round(2))
48
+ ]
49
+ end
50
+
51
+ def tags_table
52
+ Terminal::Table.new do |t|
53
+ t.title = "cloudscale.ch server tags"
54
+ t.title += " (#{@options[:profile]})" if @options[:profile]
55
+ t.headings = %w[Name UUID Tags]
56
+ t.rows = @servers.sort_by(&:name).map do |server|
57
+ [
58
+ server.name,
59
+ server.uuid,
60
+ server.tags_to_s
61
+ ]
62
+ end
63
+ end
64
+ end
65
+
66
+ def cost_table
67
+ table = Terminal::Table.new do |t|
68
+ t.title = "cloudscale.ch server costs"
69
+ t.title += " (#{@options[:profile]})" if @options[:profile]
70
+ t.headings = headings
71
+ t.rows = rows unless @options[:summary]
72
+ end
73
+
74
+ table.add_separator unless @options[:summary]
75
+ table.add_row totals
76
+ first_number_row = @options[:summary] ? 1 : 2
77
+ (first_number_row..table.columns.size).each { |column| table.align_column(column, :right) }
78
+ table
79
+ end
80
+
81
+ def grouped_cost_table(group_rows)
82
+ table = Terminal::Table.new do |t|
83
+ t.title = "cloudscale.ch server costs grouped by tag \"#{@options[:group_by]}\""
84
+ t.title += " (#{@options[:profile]})" if @options[:profile]
85
+ t.headings = headings
86
+ end
87
+ table.rows = group_rows
88
+ (1..table.columns.size).each { |column| table.align_column(column, :right) }
89
+ table
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server/server_tabular_output"
4
+ require_relative "server/server_influxdb_output"
5
+ require_relative "csv_output"
6
+ require_relative "server/server"
7
+ require_relative "server/server_list"
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudcost
4
+ # Representation of cloudscale.ch volume object
5
+ class Volume
6
+ attr_accessor :data
7
+
8
+ def initialize(data)
9
+ @data = data
10
+ end
11
+
12
+ def name
13
+ @data[:name]
14
+ end
15
+
16
+ def uuid
17
+ @data[:uuid]
18
+ end
19
+
20
+ def type
21
+ @data[:type]
22
+ end
23
+
24
+ def servers
25
+ @data[:servers]
26
+ end
27
+
28
+ def server_name
29
+ servers.size.positive? ? servers.first[:name] : ""
30
+ end
31
+
32
+ def attached?
33
+ servers.size.positive?
34
+ end
35
+
36
+ def server_uuids
37
+ @data[:server_uuids]
38
+ end
39
+
40
+ def tags
41
+ @data[:tags]
42
+ end
43
+
44
+ def size_gb
45
+ @data[:size_gb]
46
+ end
47
+
48
+ def tags_to_s
49
+ Cloudcost.tags_to_s(tags)
50
+ end
51
+
52
+ def costs_per_day
53
+ Pricing.storage_costs_per_day(type, size_gb)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudcost
4
+ # InfluxDB output methods for the ServerList class
5
+ module VolumeInfluxdbOutput
6
+ def totals_influx_line_protocol
7
+ lines = []
8
+ tag_set = [
9
+ "profile=#{@options[:profile] || "?"}",
10
+ "state=#{volumes_attached_state}"
11
+ ]
12
+ metrics = calculate_totals
13
+ [
14
+ { field: "ssd_gb", key: :size_ssd, unit: "i" },
15
+ { field: "bulk_gb", key: :size_bulk, unit: "i" },
16
+ { field: "chf_per_day", key: :size, unit: "" }
17
+ ].each do |field|
18
+ lines << %(
19
+ cloudscaleVolumeCosts,#{tag_set.join(",")}
20
+ #{field[:field]}=#{metrics[field[:key]]}#{field[:unit]}
21
+ ).gsub(/\s+/, " ").strip
22
+ end
23
+ lines.join("\n")
24
+ end
25
+
26
+ def volumes_attached_state
27
+ case @options[:attached]
28
+ when true
29
+ "attached"
30
+ when false
31
+ "unattached"
32
+ else
33
+ "all"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "terminal-table"
4
+
5
+ module Cloudcost
6
+ # volumeList represents a list of volumes and integrates several output methods
7
+ class VolumeList
8
+ include Cloudcost::CsvOutput
9
+ include Cloudcost::VolumeInfluxdbOutput
10
+
11
+ def initialize(volumes, options = {})
12
+ @volumes = volumes
13
+ @options = options
14
+ end
15
+
16
+ def calculate_totals(volumes = @volumes)
17
+ total = { size: 0, size_ssd: 0, size_bulk: 0, cost: 0.0 }
18
+ volumes.each do |volume|
19
+ total[:size] += volume.size_gb
20
+ total["size_#{volume.type}".to_sym] += volume.size_gb if %w[ssd bulk].include? volume.type
21
+ total[:cost] += volume.costs_per_day
22
+ end
23
+ total
24
+ end
25
+
26
+ def totals(volumes = @volumes)
27
+ total = calculate_totals(volumes)
28
+ total_row = @options[:summary] ? %w[Total] : ["Total", "", "", "", ""]
29
+ if @options[:summary]
30
+ total_row.concat [
31
+ total[:size_ssd],
32
+ total[:size_bulk],
33
+ total[:size]
34
+ ]
35
+ else
36
+ total_row.concat [total[:size]]
37
+ end
38
+ total_row.concat [
39
+ format("%.2f", total[:cost].round(2)),
40
+ format("%.2f", (total[:cost] * 30).round(2))
41
+ ]
42
+ end
43
+
44
+ def headings
45
+ headings = if @options[:summary]
46
+ ["", "SSD [GB]", "Bulk [GB]", "Total [GB]"]
47
+ else
48
+ ["Name", "UUID", "Type", "Servers", "Tags", "Size [GB]"]
49
+ end
50
+ headings.concat ["CHF/day", "CHF/30-days"]
51
+ end
52
+
53
+ def rows
54
+ rows = []
55
+ @volumes.sort_by(&:name).map do |volume|
56
+ rows << [
57
+ volume.name,
58
+ volume.uuid,
59
+ volume.type,
60
+ volume.server_name,
61
+ volume.tags_to_s,
62
+ volume.size_gb,
63
+ format("%.2f", volume.costs_per_day.round(2)),
64
+ format("%.2f", (volume.costs_per_day * 30).round(2))
65
+ ]
66
+ end
67
+ rows
68
+ end
69
+
70
+ def cost_table
71
+ table = Terminal::Table.new do |t|
72
+ t.title = "cloudscale.ch volume costs"
73
+ t.title += " (#{@options[:profile]})" if @options[:profile]
74
+ t.headings = headings
75
+ t.rows = rows unless @options[:summary]
76
+ end
77
+
78
+ table.add_separator unless @options[:summary]
79
+ table.add_row totals
80
+ first_number_row = @options[:summary] ? 1 : 2
81
+ (first_number_row..table.columns.size).each { |column| table.align_column(column, :right) }
82
+ table
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "csv_output"
4
+ require_relative "volume/volume"
5
+ require_relative "volume/volume_influxdb_output"
6
+ require_relative "volume/volume_list"
@@ -6,4 +6,6 @@ module Cloudcost
6
6
  class ProfileError < Error; end
7
7
 
8
8
  class TokenError < Error; end
9
+
10
+ class PricingError < StandardError; end
9
11
  end
@@ -2,14 +2,12 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
- PRICING = YAML.load_file( File.join(
6
- File.expand_path("../..", __dir__), "data/pricing.yml")
7
- )
5
+ PRICING = YAML.load_file(File.join(
6
+ File.expand_path("../..", __dir__), "data/pricing.yml"
7
+ ))
8
8
 
9
9
  module Cloudcost
10
- class PricingError < StandardError
11
- end
12
-
10
+ # pricing class which implements cost calculation methods for cloudscale.ch resources
13
11
  module Pricing
14
12
  def self.server_costs_per_day(flavor)
15
13
  PRICING["servers"][flavor] || raise(PricingError, "#{flavor} flavor not found in pricing.yml")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cloudcost
4
- VERSION = "0.1.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/cloudcost.rb CHANGED
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "terminal-table"
4
3
  require "csv"
5
4
 
6
5
  require "cloudcost/version"
6
+ require "cloudcost/error"
7
7
  require "cloudcost/api_token"
8
8
  require "cloudcost/api_connection"
9
- require "cloudcost/server"
10
- require "cloudcost/server_list"
9
+ require "cloudcost/pricing"
10
+ require "cloudcost/commands/server"
11
+ require "cloudcost/commands/volume"
11
12
  require "cloudcost/cli"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cloudcost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nik Wolfgramm
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-17 00:00:00.000000000 Z
11
+ date: 2021-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: excon
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.82.0
19
+ version: 0.85.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.82.0
26
+ version: 0.85.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: parseconfig
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -80,8 +80,8 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: 0.9.3
83
- description: Calculate cloudscale.ch server costs from your actual deployment
84
- email: wolfgramm@puzzle.ch
83
+ description: Calculate cloudscale.ch server costs from the current deployment
84
+ email:
85
85
  executables:
86
86
  - cloudcost
87
87
  extensions: []
@@ -89,6 +89,7 @@ extra_rdoc_files: []
89
89
  files:
90
90
  - ".gitignore"
91
91
  - ".rubocop.yml"
92
+ - Dockerfile
92
93
  - Gemfile
93
94
  - Gemfile.lock
94
95
  - LICENSE
@@ -101,10 +102,18 @@ files:
101
102
  - lib/cloudcost/api_connection.rb
102
103
  - lib/cloudcost/api_token.rb
103
104
  - lib/cloudcost/cli.rb
105
+ - lib/cloudcost/commands/csv_output.rb
106
+ - lib/cloudcost/commands/server.rb
107
+ - lib/cloudcost/commands/server/server.rb
108
+ - lib/cloudcost/commands/server/server_influxdb_output.rb
109
+ - lib/cloudcost/commands/server/server_list.rb
110
+ - lib/cloudcost/commands/server/server_tabular_output.rb
111
+ - lib/cloudcost/commands/volume.rb
112
+ - lib/cloudcost/commands/volume/volume.rb
113
+ - lib/cloudcost/commands/volume/volume_influxdb_output.rb
114
+ - lib/cloudcost/commands/volume/volume_list.rb
104
115
  - lib/cloudcost/error.rb
105
116
  - lib/cloudcost/pricing.rb
106
- - lib/cloudcost/server.rb
107
- - lib/cloudcost/server_list.rb
108
117
  - lib/cloudcost/version.rb
109
118
  homepage: https://gitlab.puzzle.ch/nwolfgramm/cloudcost
110
119
  licenses:
@@ -1,159 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cloudcost
4
- class ServerList
5
- def initialize(servers, options = {})
6
- @servers = servers
7
- @options = options
8
- end
9
-
10
- def calculate_totals(servers = @servers)
11
- totals = { vcpu: 0, memory: 0, ssd: 0, bulk: 0, cost: 0.0 }
12
- servers.each do |server|
13
- totals[:vcpu] += server.vcpu_count
14
- totals[:memory] += server.memory_gb
15
- totals[:ssd] += server.storage_size(:ssd)
16
- totals[:bulk] += server.storage_size(:bulk)
17
- totals[:cost] += server.total_costs_per_day
18
- end
19
- totals
20
- end
21
-
22
- def headings
23
- headings = if @options[:summary]
24
- [""]
25
- elsif @options[:group_by]
26
- ["Group", "Servers"]
27
- else
28
- %w[Name UUID Flavor Tags]
29
- end
30
- headings.concat ["vCPU's", "Memory [GB]", "SSD [GB]", "Bulk [GB]", "CHF/day", "CHF/30-days"]
31
- end
32
-
33
- def rows
34
- rows = []
35
- @servers.sort_by(&:name).map do |server|
36
- rows << [
37
- server.name,
38
- server.uuid,
39
- server.flavor,
40
- server.tags_to_s,
41
- server.vcpu_count,
42
- server.memory_gb,
43
- server.storage_size(:ssd),
44
- server.storage_size(:bulk),
45
- format("%.2f", server.total_costs_per_day.round(2)),
46
- format("%.2f", (server.total_costs_per_day * 30).round(2))
47
- ]
48
- end
49
- rows
50
- end
51
-
52
- def totals(servers = @servers)
53
- totals = calculate_totals(servers)
54
- total_row = @options[:summary] ? %w[Total] : ["Total", "", "", ""]
55
- total_row.concat [
56
- totals[:vcpu],
57
- totals[:memory],
58
- totals[:ssd],
59
- totals[:bulk],
60
- format("%.2f", totals[:cost].round(2)),
61
- format("%.2f", (totals[:cost] * 30).round(2))
62
- ]
63
- end
64
-
65
- def tags_table
66
- Terminal::Table.new do |t|
67
- t.title = "cloudscale.ch server tags"
68
- t.title += " (#{@options[:profile]})" if @options[:profile]
69
- t.headings = %w[Name UUID Tags]
70
- t.rows = @servers.sort_by(&:name).map do |server|
71
- [
72
- server.name,
73
- server.uuid,
74
- server.tags_to_s
75
- ]
76
- end
77
- end
78
- end
79
-
80
- def cost_table
81
- table = Terminal::Table.new do |t|
82
- t.title = "cloudscale.ch server costs"
83
- t.title += " (#{@options[:profile]})" if @options[:profile]
84
- t.headings = headings
85
- t.rows = rows unless @options[:summary]
86
- end
87
-
88
- table.add_separator unless @options[:summary]
89
- table.add_row totals
90
- first_number_row = @options[:summary] ? 1 : 2
91
- (first_number_row..table.columns.size).each { |column| table.align_column(column, :right) }
92
- table
93
- end
94
-
95
- def grouped_cost_table
96
- no_tag = "<no-tag>"
97
- group_rows = @servers.group_by {|s| s.tags[@options[:group_by].to_sym] || no_tag }.map do |name, servers|
98
- server_groups_data(name, servers).values.flatten
99
- end.sort {|a, b| a[0] == no_tag ? 1 : a[0] <=> b[0] }
100
- if @options[:output] == "csv"
101
- CSV.generate do |csv|
102
- csv << headings
103
- group_rows.each { |row| csv << row }
104
- end
105
- elsif @options[:output] == "influx"
106
- lines = []
107
- group_rows.each do |row|
108
- [
109
- { field: "server_count", position: 1, unit: "i" },
110
- { field: "vcpus", position: 2, unit: "i" },
111
- { field: "memory_gb", position: 3, unit: "i" },
112
- { field: "ssd_gb", position: 4, unit: "i" },
113
- { field: "bulk_gb", position: 5, unit: "i" },
114
- { field: "cost_per_day", position: 6, unit: "" },
115
- ].each do |field|
116
- lines << "cloudscaleServerCosts,grouped_by=#{@options[:group_by]},group=#{row[0]},environment=#{@options[:profile] || "default"},currency=CHF #{field[:field]}=#{row[field[:position]]}#{field[:unit]}"
117
- end
118
- end
119
- lines.join("\n")
120
- else
121
- table = Terminal::Table.new do |t|
122
- t.title = "cloudscale.ch server costs grouped by tag \"#{@options[:group_by]}\""
123
- t.title += " (#{@options[:profile]})" if @options[:profile]
124
- t.headings = headings
125
- end
126
- table.rows = group_rows
127
- (1..table.columns.size).each { |column| table.align_column(column, :right) }
128
- table
129
- end
130
- end
131
-
132
- def server_groups_data(name, servers)
133
- data = { name: name, count: 0, vcpu: 0, memory: 0, ssd: 0, bulk: 0, costs_daily: 0 }
134
- servers.each do |server|
135
- data[:count] += 1
136
- data[:vcpu] += server.vcpu_count
137
- data[:memory] += server.memory_gb
138
- data[:ssd] += server.storage_size(:ssd)
139
- data[:bulk] += server.storage_size(:bulk)
140
- data[:costs_daily] += server.total_costs_per_day
141
- end
142
- data[:costs_monthly] = (data[:costs_daily] * 30).round(2)
143
- data[:costs_daily] = data[:costs_daily].round(2)
144
- data
145
- end
146
-
147
- def to_csv
148
- CSV.generate do |csv|
149
- csv << headings
150
- if @options[:summary]
151
- csv << totals
152
- else
153
- rows.each { |row| csv << row }
154
- end
155
- end
156
- end
157
-
158
- end
159
- end