cloudcost 0.1.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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