scaltainer 0.6.0 → 0.8.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: 3537d6ea1b1cee2e5a5efce8a5c98e3727473585302d57fc816148d8ffce2a4d
4
- data.tar.gz: 0de1927d3b4dc1a35fa755af035a495f7193b9105b8fc0a2088c2ffa2342192d
3
+ metadata.gz: 8f0d6517b88abd5e66dce4b95a630e923ada8443d07149c0e4feb4835a4743dc
4
+ data.tar.gz: 733ca7fa354630bdf0d7871105e8522e871c08d417c468362976f3916e8a8787
5
5
  SHA512:
6
- metadata.gz: f63915a92b777a563f24f89aaae8045465545c0e19f5adf77741426e20aa9f1186aa56218a2bc0fc8001b07d1616e1a76014cee1eba1c5a961638c4e9531eac8
7
- data.tar.gz: a189a04f2b4b7b04f348b6d9d7438bff6323781b63dd4f1191bbe1b3f4bf9fb47222d265279b4b349c3520d0c3a5d2a129e240800c40c3d479aaa7cbdd63c125
6
+ metadata.gz: d235634c60202ec803146240ed89d7745845dc0a7d932c78e67e429b0664682bc374e587afd62f142aefa4d0d0ff369057a6258bb5574ad0ac369efacb321982
7
+ data.tar.gz: 739f0658e6c043516edf5bf890d719ac52817b49018b28837b57b839fdded24c4ad69e97986b3ed2827884ba25e1469fbf2f25e00b416f16cbd0a240c6aae7f8
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- scaltainer (0.6.0)
4
+ scaltainer (0.8.0)
5
5
  docker-api (~> 2.1)
6
6
  dotenv (~> 2.7)
7
7
  excon (~> 0.47)
@@ -12,51 +12,55 @@ PATH
12
12
  GEM
13
13
  remote: https://rubygems.org/
14
14
  specs:
15
- addressable (2.8.1)
16
- public_suffix (>= 2.0.2, < 6.0)
15
+ addressable (2.9.0)
16
+ public_suffix (>= 2.0.2, < 8.0)
17
17
  coderay (1.1.1)
18
18
  diff-lcs (1.3)
19
19
  docile (1.4.0)
20
- docker-api (2.1.0)
21
- excon (>= 0.47.0)
20
+ docker-api (2.4.0)
21
+ excon (>= 0.64.0)
22
22
  multi_json
23
- domain_name (0.5.20190701)
24
- unf (>= 0.0.5, < 1.0.0)
25
- dotenv (2.7.6)
26
- excon (0.82.0)
27
- ffi (1.15.5)
28
- ffi-compiler (1.0.1)
29
- ffi (>= 1.0.0)
23
+ domain_name (0.6.20240107)
24
+ dotenv (2.8.1)
25
+ excon (0.112.0)
26
+ ffi (1.17.4)
27
+ ffi-compiler (1.4.2)
28
+ ffi (>= 1.15.5)
30
29
  rake
31
- http (4.4.1)
32
- addressable (~> 2.3)
30
+ http (5.3.1)
31
+ addressable (~> 2.8)
33
32
  http-cookie (~> 1.0)
34
33
  http-form_data (~> 2.2)
35
- http-parser (~> 1.2.0)
34
+ llhttp-ffi (~> 0.5.0)
36
35
  http-accept (1.7.0)
37
- http-cookie (1.0.4)
36
+ http-cookie (1.1.6)
38
37
  domain_name (~> 0.5)
39
38
  http-form_data (2.3.0)
40
- http-parser (1.2.3)
41
- ffi-compiler (>= 1.0, < 2.0)
42
39
  json (2.6.3)
43
- jsonpath (1.1.0)
40
+ jsonpath (1.1.5)
44
41
  multi_json
45
- kubeclient (4.9.3)
46
- http (>= 3.0, < 5.0)
42
+ kubeclient (4.13.0)
43
+ http (>= 3.0, < 6.0)
47
44
  jsonpath (~> 1.0)
48
45
  recursive-open-struct (~> 1.1, >= 1.1.1)
49
46
  rest-client (~> 2.0)
50
- mime-types (3.4.1)
51
- mime-types-data (~> 3.2015)
52
- mime-types-data (3.2022.0105)
47
+ llhttp-ffi (0.5.1)
48
+ ffi-compiler (~> 1.0)
49
+ rake (~> 13.0)
50
+ logger (1.7.0)
51
+ mime-types (3.7.0)
52
+ logger
53
+ mime-types-data (~> 3.2025, >= 3.2025.0507)
54
+ mime-types-data (3.2026.0701)
53
55
  multi_json (1.15.0)
54
56
  netrc (0.11.0)
55
- newrelic_rpm (7.1.0)
57
+ newrelic_rpm (7.2.0)
58
+ ostruct (0.6.3)
56
59
  prometheus-client (2.1.0)
57
- public_suffix (5.0.0)
60
+ public_suffix (5.1.1)
58
61
  rake (13.0.1)
59
- recursive-open-struct (1.1.3)
62
+ recursive-open-struct (1.3.1)
63
+ ostruct
60
64
  rest-client (2.1.0)
61
65
  http-accept (>= 1.7.0, < 2.0)
62
66
  http-cookie (>= 1.0.2, < 2.0)
@@ -81,9 +85,6 @@ GEM
81
85
  simplecov-html (~> 0.10.0)
82
86
  simplecov-html (0.10.2)
83
87
  simplecov-lcov (0.7.0)
84
- unf (0.1.4)
85
- unf_ext
86
- unf_ext (0.0.8.1)
87
88
 
88
89
  PLATFORMS
89
90
  ruby
data/README.md CHANGED
@@ -206,6 +206,47 @@ The configuration file (determined by `-f FILE` command line parameter) should b
206
206
 
207
207
  More details about configuration parameters can be found in [HireFire docs](https://help.hirefire.io/guides).
208
208
 
209
+ ## Error handling
210
+
211
+ Scaltainer keeps going when a problem is likely to pass, and stops when it is not.
212
+ Anything permanent, such as a mistake in your configuration or credentials that are
213
+ refused, exits straight away so you hear about it immediately instead of finding it
214
+ buried in the logs days later. Anything temporary is logged and tried again on the
215
+ next tick.
216
+
217
+ ### Fatal errors
218
+
219
+ Scaltainer exits with a non-zero status. Fix the cause and start it again.
220
+
221
+ | What went wrong | Where |
222
+ | --- | --- |
223
+ | The configuration file is missing, or is not valid YAML | startup |
224
+ | `--enable-newrelic-reporting` is used without `NEW_RELIC_LICENSE_KEY` or `NEW_RELIC_APP_NAME` | startup |
225
+ | `NEW_RELIC_API_KEY` is not set, or New Relic refuses it | web services |
226
+ | A web service has no `newrelic_app_id`, or New Relic does not recognise the one given | web services |
227
+ | A web service is missing `min_response_time` or `max_response_time`, or the two are in the wrong order | web services |
228
+ | A worker service is missing `ratio` | worker services |
229
+ | `endpoint` returns JSON that is not the list of queues scaltainer expects | worker services |
230
+ | Kubernetes refuses the credentials, or the service account may not read or patch the resource | kubernetes |
231
+ | The Docker service is global and cannot be replicated | swarm |
232
+
233
+ ### Retriable errors
234
+
235
+ Scaltainer logs these and carries on. Whatever could not be handled is left alone
236
+ until the next tick.
237
+
238
+ | What went wrong | What is skipped |
239
+ | --- | --- |
240
+ | A configured service is missing from the metrics response | that service |
241
+ | The application is idle, so New Relic has no data for the window | that service |
242
+ | `endpoint` reports a negative or non-integer queue size | that service |
243
+ | The Kubernetes resource is not deployed yet, or changed while it was being scaled | that service |
244
+ | Kubernetes returns a server error | that service |
245
+ | No web or worker services are configured | that type |
246
+ | `endpoint` answers with something that is not JSON | all worker services |
247
+ | New Relic is rate limiting, erroring, or returns a payload that cannot be read | all web services |
248
+ | The Prometheus push gateway is unreachable | nothing, that tick's metrics are dropped |
249
+
209
250
  ## Docker Swarm usage
210
251
 
211
252
  A service definition for scaltainer is typically something like this:
@@ -25,8 +25,8 @@ module Scaltainer
25
25
  opts.on("--enable-newrelic-reporting", "Enable metrics pushing to New Relic") do
26
26
  newrelic_license_key = ENV['NEW_RELIC_LICENSE_KEY']
27
27
  newrelic_app_name = ENV['NEW_RELIC_APP_NAME']
28
- raise 'Must set NEW_RELIC_LICENSE_KEY environment variable if --enable-newrelic-reporting is set' if newrelic_license_key.nil? || newrelic_license_key == ""
29
- raise 'Must set NEW_RELIC_APP_NAME environment variable if --enable-newrelic-reporting is set' if newrelic_app_name.nil? || newrelic_app_name == ""
28
+ raise ConfigurationError.new 'Must set NEW_RELIC_LICENSE_KEY environment variable if --enable-newrelic-reporting is set' if newrelic_license_key.nil? || newrelic_license_key == ""
29
+ raise ConfigurationError.new 'Must set NEW_RELIC_APP_NAME environment variable if --enable-newrelic-reporting is set' if newrelic_app_name.nil? || newrelic_app_name == ""
30
30
  enable_newrelic_reporting = true
31
31
  end
32
32
  opts.on("-v", "--version", "Show version and exit") do
@@ -1,6 +1,11 @@
1
1
  module Scaltainer
2
+ # Recoverable conditions. The runner logs these and skips the affected
3
+ # resource (or resource type) for the current tick, then carries on.
2
4
  class ApplicationError < RuntimeError; end
3
- class ConfigurationError < ApplicationError; end
4
5
  class NetworkError < ApplicationError; end
5
6
  class Warning < ApplicationError; end
6
- end
7
+
8
+ # Permanent misconfiguration. Retrying cannot fix it, so it is left to bubble
9
+ # up and terminate the process instead of being logged on every tick.
10
+ class ConfigurationError < RuntimeError; end
11
+ end
@@ -1,11 +1,20 @@
1
1
  module Newrelic
2
2
  class Metrics
3
+ # New Relic reports failures as an error body alongside an HTTP status.
4
+ # These statuses mean the request can never succeed as configured (unknown
5
+ # application id, rejected API key), so they are permanent. Anything else
6
+ # (rate limiting, 5xx) is transient and worth retrying on the next tick.
7
+ PERMANENT_ERROR_STATUSES = [400, 401, 403, 404].freeze
8
+
3
9
  def initialize(license_key)
4
10
  @headers = {"X-Api-Key" => license_key}
5
11
  @base_url = "https://api.newrelic.com/v2"
6
12
  end
7
13
 
8
14
  # https://docs.newrelic.com/docs/apis/rest-api-v2/application-examples-v2/average-response-time-examples-v2
15
+ # Returns the average response time, or nil when New Relic holds no data for
16
+ # the requested window. Callers must treat nil as "no metric for this
17
+ # resource" rather than as a response time.
9
18
  def get_avg_response_time(app_id, from, to)
10
19
  url = "#{@base_url}/applications/#{app_id}/metrics/data.json"
11
20
  conn = Excon.new(url, persistent: true, tcp_nodelay: true)
@@ -14,11 +23,20 @@ module Newrelic
14
23
  names[]=HttpDispatcher&values[]=average_call_time&values[]=call_count
15
24
  names[]=WebFrontend/QueueTime&values[]=call_count&values[]=average_response_time
16
25
  )
17
- response_array = request(conn, metric_names_array, time_range)
18
- http_call_count, http_average_call_time = response_array[0]["call_count"], response_array[0]["average_call_time"]
19
- webfe_call_count, webfe_average_response_time = response_array[1]["call_count"], response_array[1]["average_response_time"]
26
+ http_values, webfe_values = request(conn, metric_names_array, time_range)
27
+
28
+ # an application that dispatched nothing in the window has no response time
29
+ return nil unless http_values
30
+ http_call_count = numeric_value http_values, "call_count"
31
+ return nil if http_call_count.zero?
32
+ http_average_call_time = numeric_value http_values, "average_call_time"
33
+
34
+ # queue time is an additive correction and is absent for applications that
35
+ # do not sit behind a request queue, in which case it contributes nothing
36
+ webfe_call_count = webfe_values ? numeric_value(webfe_values, "call_count") : 0
37
+ webfe_average_response_time = webfe_values ? numeric_value(webfe_values, "average_response_time") : 0
20
38
 
21
- http_average_call_time + (1.0 * webfe_call_count * webfe_average_response_time / http_call_count) rescue 0.0/0
39
+ http_average_call_time + (1.0 * webfe_call_count * webfe_average_response_time / http_call_count)
22
40
  end
23
41
 
24
42
  private
@@ -26,19 +44,62 @@ module Newrelic
26
44
  def request(conn, metric_names_array, time_range)
27
45
  requests = metric_names_array.map {|metric_names|
28
46
  {
29
- method: :get, headers: @headers,
47
+ method: :get, headers: @headers,
30
48
  query: "#{metric_names}&#{time_range}&summarize=true"
31
49
  }
32
50
  }
33
51
  responses = conn.requests requests
34
- responses.map {|response|
35
- body = JSON.parse(response.body)
36
- if body["error"] && body["error"]["title"]
37
- raise body["error"]["title"]
38
- else
39
- body["metric_data"]["metrics"][0]["timeslices"][0]["values"] rescue {}
40
- end
41
- }
52
+ responses.map {|response| extract_values response }
53
+ end
54
+
55
+ # Returns the values hash of the first timeslice, or nil when New Relic
56
+ # reported no data for the metric over the requested window.
57
+ def extract_values(response)
58
+ body = parse_body response
59
+ error = body["error"]
60
+ raise_api_error response.status, error["title"] if error.is_a?(Hash) && error["title"]
61
+
62
+ metric_data = body["metric_data"]
63
+ metrics = metric_data.is_a?(Hash) ? metric_data["metrics"] : nil
64
+ raise_unexpected_payload response unless metrics.is_a?(Array)
65
+ return nil if metrics.empty?
66
+
67
+ timeslices = metrics.first.is_a?(Hash) ? metrics.first["timeslices"] : nil
68
+ raise_unexpected_payload response unless timeslices.is_a?(Array)
69
+ return nil if timeslices.empty?
70
+
71
+ values = timeslices.first.is_a?(Hash) ? timeslices.first["values"] : nil
72
+ raise_unexpected_payload response unless values.is_a?(Hash)
73
+ values
74
+ end
75
+
76
+ def parse_body(response)
77
+ body = JSON.parse response.body
78
+ raise_unexpected_payload response unless body.is_a?(Hash)
79
+ body
80
+ rescue JSON::ParserError
81
+ raise Scaltainer::NetworkError.new \
82
+ "New Relic API returned a non json response: #{response.body[0..128]}"
83
+ end
84
+
85
+ def numeric_value(values, key)
86
+ value = values[key]
87
+ unless value.is_a?(Numeric)
88
+ raise Scaltainer::NetworkError.new \
89
+ "New Relic API returned a non numeric #{key}: #{value.inspect}"
90
+ end
91
+ value
92
+ end
93
+
94
+ def raise_api_error(status, title)
95
+ message = "New Relic API error (HTTP #{status}): #{title}"
96
+ raise Scaltainer::ConfigurationError.new message if PERMANENT_ERROR_STATUSES.include?(status)
97
+ raise Scaltainer::NetworkError.new message
98
+ end
99
+
100
+ def raise_unexpected_payload(response)
101
+ raise Scaltainer::NetworkError.new \
102
+ "New Relic API returned an unexpected payload: #{response.body[0..128]}"
42
103
  end
43
104
  end
44
105
  end
@@ -2,6 +2,10 @@ require 'kubeclient'
2
2
 
3
3
  module Scaltainer
4
4
  class KubeResource < ReplicaSetBase
5
+ # Statuses that no later attempt can recover from with the credentials at
6
+ # hand, as opposed to conflicts and outages which are worth retrying.
7
+ PERMANENT_ERROR_STATUSES = [401, 403].freeze
8
+
5
9
  def initialize(name, namespace)
6
10
  @@client ||= self.class.get_client
7
11
  type = ENV['KUBERNETES_CONTROLLER_KIND'] || 'deployment'
@@ -10,6 +14,8 @@ module Scaltainer
10
14
  super(name, type, namespace)
11
15
  @resource = @@client.send("get_#{@type}", normalize_name(@name), @namespace)
12
16
  @id = @resource.metadata.uid
17
+ rescue Kubeclient::HttpError => e
18
+ raise resource_error(e, 'find')
13
19
  end
14
20
 
15
21
  def get_replicas
@@ -18,10 +24,25 @@ module Scaltainer
18
24
 
19
25
  def set_replicas(replicas)
20
26
  @@client.send("patch_#{@type}", normalize_name(@name), {spec: {replicas: replicas}}, @namespace)
27
+ rescue Kubeclient::HttpError => e
28
+ raise resource_error(e, 'scale')
21
29
  end
22
30
 
23
31
  private
24
32
 
33
+ def resource_error(error, action)
34
+ message = "Could not #{action} #{@type} #{@name} in namespace #{@namespace}: #{error.message}"
35
+ case error.error_code
36
+ when *PERMANENT_ERROR_STATUSES
37
+ ConfigurationError.new message
38
+ when 404, 409
39
+ # the resource is absent or was changed since it was read, pick it up next tick
40
+ Scaltainer::Warning.new message
41
+ else
42
+ NetworkError.new message
43
+ end
44
+ end
45
+
25
46
  def self.get_client
26
47
  if ENV['KUBECONFIG']
27
48
  get_client_from_kubeconfig ENV['KUBECONFIG']
@@ -63,12 +63,12 @@ module Scaltainer
63
63
  service_config = @default_service_config.merge service_config
64
64
  @logger.debug "Resource #{service_name} in namespace #{namespace} configuration: #{service_config}"
65
65
  process_service service_name, service_config, service_state, namespace, type, metrics
66
- rescue RuntimeError => e
66
+ rescue ApplicationError => e
67
67
  # skipping service
68
68
  log_exception e
69
69
  end
70
70
  end
71
- rescue RuntimeError => e
71
+ rescue ApplicationError => e
72
72
  # skipping service type
73
73
  log_exception e
74
74
  end
@@ -101,14 +101,10 @@ module Scaltainer
101
101
  end
102
102
 
103
103
  def get_service(service_name, namespace)
104
- begin
105
- service = if @orchestrator == :swarm
106
- DockerService.new service_name, namespace
107
- elsif @orchestrator == :kubernetes
108
- KubeResource.new service_name, namespace
109
- end
110
- rescue => e
111
- raise NetworkError.new "Could not find resource with name #{service_name} in namespace #{namespace}: #{e.message}"
104
+ service = if @orchestrator == :swarm
105
+ DockerService.new service_name, namespace
106
+ elsif @orchestrator == :kubernetes
107
+ KubeResource.new service_name, namespace
112
108
  end
113
109
  raise ConfigurationError.new "Unknown resource: #{service_name} in namespace #{namespace}" unless service
114
110
  service
@@ -118,11 +114,7 @@ module Scaltainer
118
114
  return if current_replicas == desired_replicas
119
115
  # send scale command to orchestrator
120
116
  @logger.info "Scaling #{service.type} #{service.name} from #{current_replicas} to #{desired_replicas}"
121
- begin
122
- service.set_replicas desired_replicas
123
- rescue => e
124
- raise NetworkError.new "Could not scale #{service.type} #{service.name} due to error: #{e.message}"
125
- end
117
+ service.set_replicas desired_replicas
126
118
  end
127
119
 
128
120
  def register_pushgateway(pushgateway)
@@ -16,11 +16,7 @@ module Scaltainer
16
16
  app_id = service_config["newrelic_app_id"]
17
17
  raise ConfigurationError.new "Resource #{service_name} does not have a corresponding newrelic_app_id" unless app_id
18
18
 
19
- begin
20
- metric = nr.get_avg_response_time app_id, from, to
21
- rescue => e
22
- raise NetworkError.new "Could not retrieve metrics from New Relic API for #{service_name}: #{e.message}"
23
- end
19
+ metric = nr.get_avg_response_time app_id, from, to
24
20
 
25
21
  hash.merge!(service_name => metric)
26
22
  end
@@ -9,13 +9,12 @@ module Scaltainer
9
9
  begin
10
10
  response = Excon.get(@app_endpoint)
11
11
  m = JSON.parse(response.body)
12
- m.reduce({}){|hash, item| hash.merge!({item["name"] => item["quantity"]})}
12
+ m.reduce({}){|hash, item| hash.merge!({item["name"] => (item["quantity"] || item["value"])})}
13
13
  rescue JSON::ParserError => e
14
- raise ConfigurationError.new "app_endpoint returned non json response: #{response.body[0..128]}"
14
+ # a non json body is typically a transient upstream error page, retry next tick
15
+ raise NetworkError.new "app_endpoint returned non json response: #{response.body[0..128]}"
15
16
  rescue TypeError => e
16
17
  raise ConfigurationError.new "app_endpoint returned unexpected json response: #{response.body[0..128]}"
17
- rescue => e
18
- raise NetworkError.new "Could not retrieve metrics from application endpoint: #{@app_endpoint}.\n#{e.message}"
19
18
  end
20
19
  end
21
20
 
@@ -23,7 +22,8 @@ module Scaltainer
23
22
  super
24
23
  raise ConfigurationError.new "Missing ratio in worker resource configuration" unless service_config["ratio"]
25
24
  if !metric.is_a?(Integer) || metric < 0
26
- raise ConfigurationError.new "#{metric} is an invalid metric value, must be a non-negative number"
25
+ # the metric comes from the endpoint at runtime, not from configuration
26
+ raise Scaltainer::Warning.new "#{metric} is an invalid metric value, must be a non-negative number"
27
27
  end
28
28
  desired_replicas = (metric * 1.0 / service_config["ratio"]).ceil
29
29
  end
@@ -1,3 +1,3 @@
1
1
  module Scaltainer
2
- VERSION = "0.6.0"
2
+ VERSION = "0.8.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scaltainer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hossam Hammady
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-09-15 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -224,7 +224,7 @@ homepage: https://github.com/hammady/scaltainer
224
224
  licenses:
225
225
  - MIT
226
226
  metadata: {}
227
- post_install_message:
227
+ post_install_message:
228
228
  rdoc_options: []
229
229
  require_paths:
230
230
  - lib
@@ -240,7 +240,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
240
240
  version: '0'
241
241
  requirements: []
242
242
  rubygems_version: 3.1.6
243
- signing_key:
243
+ signing_key:
244
244
  specification_version: 4
245
245
  summary: Autoscale kubernetes controllers and docker services based on application
246
246
  metrics and more