scaltainer 0.7.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 +4 -4
- data/Gemfile.lock +7 -7
- data/README.md +41 -0
- data/lib/scaltainer/command.rb +2 -2
- data/lib/scaltainer/exceptions.rb +7 -2
- data/lib/scaltainer/newrelic/metrics.rb +74 -13
- data/lib/scaltainer/orchestrators/kubernetes.rb +21 -0
- data/lib/scaltainer/runner.rb +7 -15
- data/lib/scaltainer/service_types/web.rb +1 -5
- data/lib/scaltainer/service_types/worker.rb +4 -4
- data/lib/scaltainer/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f0d6517b88abd5e66dce4b95a630e923ada8443d07149c0e4feb4835a4743dc
|
|
4
|
+
data.tar.gz: 733ca7fa354630bdf0d7871105e8522e871c08d417c468362976f3916e8a8787
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
4
|
+
scaltainer (0.8.0)
|
|
5
5
|
docker-api (~> 2.1)
|
|
6
6
|
dotenv (~> 2.7)
|
|
7
7
|
excon (~> 0.47)
|
|
@@ -12,8 +12,8 @@ PATH
|
|
|
12
12
|
GEM
|
|
13
13
|
remote: https://rubygems.org/
|
|
14
14
|
specs:
|
|
15
|
-
addressable (2.
|
|
16
|
-
public_suffix (>= 2.0.2, <
|
|
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)
|
|
@@ -23,8 +23,8 @@ GEM
|
|
|
23
23
|
domain_name (0.6.20240107)
|
|
24
24
|
dotenv (2.8.1)
|
|
25
25
|
excon (0.112.0)
|
|
26
|
-
ffi (1.17.
|
|
27
|
-
ffi-compiler (1.
|
|
26
|
+
ffi (1.17.4)
|
|
27
|
+
ffi-compiler (1.4.2)
|
|
28
28
|
ffi (>= 1.15.5)
|
|
29
29
|
rake
|
|
30
30
|
http (5.3.1)
|
|
@@ -33,7 +33,7 @@ GEM
|
|
|
33
33
|
http-form_data (~> 2.2)
|
|
34
34
|
llhttp-ffi (~> 0.5.0)
|
|
35
35
|
http-accept (1.7.0)
|
|
36
|
-
http-cookie (1.1.
|
|
36
|
+
http-cookie (1.1.6)
|
|
37
37
|
domain_name (~> 0.5)
|
|
38
38
|
http-form_data (2.3.0)
|
|
39
39
|
json (2.6.3)
|
|
@@ -51,7 +51,7 @@ GEM
|
|
|
51
51
|
mime-types (3.7.0)
|
|
52
52
|
logger
|
|
53
53
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
|
54
|
-
mime-types-data (3.
|
|
54
|
+
mime-types-data (3.2026.0701)
|
|
55
55
|
multi_json (1.15.0)
|
|
56
56
|
netrc (0.11.0)
|
|
57
57
|
newrelic_rpm (7.2.0)
|
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:
|
data/lib/scaltainer/command.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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)
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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']
|
data/lib/scaltainer/runner.rb
CHANGED
|
@@ -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
|
|
66
|
+
rescue ApplicationError => e
|
|
67
67
|
# skipping service
|
|
68
68
|
log_exception e
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
|
-
rescue
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -11,11 +11,10 @@ module Scaltainer
|
|
|
11
11
|
m = JSON.parse(response.body)
|
|
12
12
|
m.reduce({}){|hash, item| hash.merge!({item["name"] => (item["quantity"] || item["value"])})}
|
|
13
13
|
rescue JSON::ParserError => e
|
|
14
|
-
|
|
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
|
-
|
|
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
|
data/lib/scaltainer/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hossam Hammady
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-07-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|