aikido-zen 0.1.0.alpha4-arm64-darwin → 0.1.1-arm64-darwin

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +19 -0
  3. data/CHANGELOG.md +16 -0
  4. data/README.md +136 -23
  5. data/Rakefile +4 -0
  6. data/benchmarks/README.md +27 -0
  7. data/benchmarks/rails7.1_sql_injection.js +74 -0
  8. data/docs/banner.svg +203 -0
  9. data/docs/config.md +123 -0
  10. data/docs/rails.md +70 -0
  11. data/lib/aikido/zen/actor.rb +1 -1
  12. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  13. data/lib/aikido/zen/agent.rb +100 -112
  14. data/lib/aikido/zen/collector/hosts.rb +15 -0
  15. data/lib/aikido/zen/collector/routes.rb +64 -0
  16. data/lib/aikido/zen/{stats → collector}/sink_stats.rb +1 -1
  17. data/lib/aikido/zen/collector/stats.rb +111 -0
  18. data/lib/aikido/zen/{stats → collector}/users.rb +6 -2
  19. data/lib/aikido/zen/collector.rb +117 -0
  20. data/lib/aikido/zen/config.rb +17 -11
  21. data/lib/aikido/zen/context.rb +8 -1
  22. data/lib/aikido/zen/errors.rb +3 -1
  23. data/lib/aikido/zen/event.rb +7 -4
  24. data/lib/aikido/zen/internals.rb +4 -0
  25. data/lib/aikido/zen/{libzen-v0.1.26.aarch64.dylib → libzen-v0.1.31.aarch64.dylib} +0 -0
  26. data/lib/aikido/zen/middleware/set_context.rb +4 -1
  27. data/lib/aikido/zen/rails_engine.rb +27 -18
  28. data/lib/aikido/zen/request/schema/builder.rb +0 -2
  29. data/lib/aikido/zen/request.rb +6 -0
  30. data/lib/aikido/zen/runtime_settings.rb +6 -11
  31. data/lib/aikido/zen/scanners/ssrf_scanner.rb +12 -6
  32. data/lib/aikido/zen/sinks/action_controller.rb +64 -0
  33. data/lib/aikido/zen/sinks/http.rb +1 -1
  34. data/lib/aikido/zen/sinks/pg.rb +13 -12
  35. data/lib/aikido/zen/sinks/typhoeus.rb +1 -1
  36. data/lib/aikido/zen/sinks.rb +1 -0
  37. data/lib/aikido/zen/version.rb +2 -2
  38. data/lib/aikido/zen/worker.rb +82 -0
  39. data/lib/aikido/zen.rb +55 -50
  40. data/tasklib/bench.rake +70 -0
  41. metadata +20 -9
  42. data/CODE_OF_CONDUCT.md +0 -132
  43. data/lib/aikido/zen/stats/routes.rb +0 -53
  44. data/lib/aikido/zen/stats.rb +0 -171
@@ -8,6 +8,13 @@ require_relative "context"
8
8
 
9
9
  module Aikido::Zen
10
10
  class Config
11
+ # @return [Boolean] whether Aikido should be turned completely off (no
12
+ # intercepting calls to protect the app, no agent process running, no
13
+ # middleware installed). Defaults to false (so, enabled). Can be set
14
+ # via the AIKIDO_DISABLED environment variable.
15
+ attr_accessor :disabled
16
+ alias_method :disabled?, :disabled
17
+
11
18
  # @return [Boolean] whether Aikido should only report infractions or block
12
19
  # the request by raising an Exception. Defaults to whether AIKIDO_BLOCKING
13
20
  # is set to a non-empty value in your environment, or +false+ otherwise.
@@ -45,7 +52,7 @@ module Aikido::Zen
45
52
  # into an Object. Defaults to the standard library's JSON.parse method.
46
53
  attr_accessor :json_decoder
47
54
 
48
- # @returns [Logger]
55
+ # @return [Logger]
49
56
  attr_accessor :logger
50
57
 
51
58
  # @return [Integer] maximum number of timing measurements to keep in memory
@@ -83,11 +90,9 @@ module Aikido::Zen
83
90
  # differentiate different clients. By default this uses the request IP.
84
91
  attr_accessor :rate_limiting_discriminator
85
92
 
86
- # @return [Boolean] whether Zen should infer the schema from request bodies
87
- # sent to the app. Defaults to +false+ or the value of the environment
88
- # variable AIKIDO_FEATURE_COLLECT_API_SCHEMA.
89
- attr_accessor :api_schema_collection_enabled
90
- alias_method :api_schema_collection_enabled?, :api_schema_collection_enabled
93
+ # @return [Integer] max number of requests we sample per endpoint when
94
+ # computing the schema.
95
+ attr_accessor :api_schema_max_samples
91
96
 
92
97
  # @api private
93
98
  # @return [Integer] max number of levels deep we want to read a nested
@@ -125,7 +130,8 @@ module Aikido::Zen
125
130
  attr_accessor :imds_allowed_hosts
126
131
 
127
132
  def initialize
128
- self.blocking_mode = !!ENV.fetch("AIKIDO_BLOCKING", false)
133
+ self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
134
+ self.blocking_mode = read_boolean_from_env(ENV.fetch("AIKIDO_BLOCKING", false))
129
135
  self.api_timeouts = 10
130
136
  self.api_base_url = ENV.fetch("AIKIDO_BASE_URL", DEFAULT_API_BASE_URL)
131
137
  self.runtime_api_base_url = ENV.fetch("AIKIDO_RUNTIME_URL", DEFAULT_RUNTIME_BASE_URL)
@@ -146,11 +152,9 @@ module Aikido::Zen
146
152
  self.server_rate_limit_deadline = 1800 # 30 min
147
153
  self.client_rate_limit_period = 3600 # 1 hour
148
154
  self.client_rate_limit_max_events = 100
149
-
150
- self.api_schema_collection_enabled = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_COLLECT_API_SCHEMA", false))
155
+ self.api_schema_max_samples = Integer(ENV.fetch("AIKIDO_MAX_API_DISCOVERY_SAMPLES", 10))
151
156
  self.api_schema_collection_max_depth = 20
152
157
  self.api_schema_collection_max_properties = 20
153
-
154
158
  self.imds_allowed_hosts = ["metadata.google.internal", "metadata.goog"]
155
159
  end
156
160
 
@@ -224,6 +228,8 @@ module Aikido::Zen
224
228
  end
225
229
 
226
230
  # @!visibility private
227
- DEFAULT_RATE_LIMITING_DISCRIMINATOR = ->(request) { request.ip }
231
+ DEFAULT_RATE_LIMITING_DISCRIMINATOR = ->(request) {
232
+ request.actor ? "actor:#{request.actor.id}" : request.ip
233
+ }
228
234
  end
229
235
  end
@@ -7,13 +7,20 @@ require_relative "payload"
7
7
 
8
8
  module Aikido::Zen
9
9
  class Context
10
+ # Build a Context object for the current HTTP request based on the currently
11
+ # configured request builder.
12
+ #
13
+ # @param env [Hash] the Rack env hash.
14
+ # @param config [Aikido::Zen::Config]
15
+ # @return [Aikido::Zen::Context]
10
16
  def self.from_rack_env(env, config = Aikido::Zen.config)
11
17
  config.request_builder.call(env)
12
18
  end
13
19
 
20
+ # @return [Aikido::Zen::Request]
14
21
  attr_reader :request
15
22
 
16
- # @param [Rack::Request] a Request object that implements the
23
+ # @param request [Rack::Request] a Request object that implements the
17
24
  # Rack::Request API, to which we will delegate behavior.
18
25
  # @param settings [Aikido::Zen::RuntimeSettings]
19
26
  #
@@ -3,11 +3,13 @@
3
3
  require "forwardable"
4
4
 
5
5
  module Aikido
6
+ # @!visibility private
6
7
  # Support rescuing Aikido::Error without forcing a single base class to all
7
8
  # errors (so things that should be e.g. a TypeError, can have the correct
8
9
  # superclass).
9
- module Error; end
10
+ module Error; end # :nodoc:
10
11
 
12
+ # @!visibility private
11
13
  # Generic error for problems with the Agent.
12
14
  class ZenError < RuntimeError
13
15
  include Error
@@ -48,17 +48,20 @@ module Aikido::Zen
48
48
  end
49
49
 
50
50
  class Heartbeat < Event
51
- def initialize(stats:, **opts)
51
+ def initialize(stats:, users:, hosts:, routes:, **opts)
52
52
  super(type: "heartbeat", **opts)
53
53
  @stats = stats
54
+ @users = users
55
+ @hosts = hosts
56
+ @routes = routes
54
57
  end
55
58
 
56
59
  def as_json
57
60
  super.update(
58
61
  stats: @stats.as_json,
59
- routes: @stats.routes.as_json,
60
- hostnames: @stats.outbound_connections.as_json,
61
- users: @stats.users.as_json
62
+ users: @users.as_json,
63
+ routes: @routes.as_json,
64
+ hostnames: @hosts.as_json
62
65
  )
63
66
  end
64
67
  end
@@ -31,6 +31,8 @@ module Aikido::Zen
31
31
  attach_function :detect_sql_injection_native, :detect_sql_injection,
32
32
  [:string, :string, :int], :int
33
33
  rescue LoadError, FFI::NotFoundError => err
34
+ # :nocov:
35
+
34
36
  # Emit an $stderr warning at startup.
35
37
  warn "Zen could not load its binary extension #{libzen_name}: #{err}"
36
38
 
@@ -38,6 +40,8 @@ module Aikido::Zen
38
40
  attempt = format("%p for SQL injection", query)
39
41
  raise InternalsError.new(attempt, "loading", Internals.libzen_name)
40
42
  end
43
+
44
+ # :nocov:
41
45
  else
42
46
  # Analyzes the SQL query to detect if the provided user input is being
43
47
  # passed as-is without escaping.
@@ -3,6 +3,9 @@
3
3
  require_relative "../context"
4
4
 
5
5
  module Aikido::Zen
6
+ # @!visibility private
7
+ ENV_KEY = "aikido.context"
8
+
6
9
  module Middleware
7
10
  # Rack middleware that keeps the current context in a Thread/Fiber-local
8
11
  # variable so that other parts of the agent/firewall can access it.
@@ -14,7 +17,7 @@ module Aikido::Zen
14
17
  def call(env)
15
18
  context = Context.from_rack_env(env)
16
19
 
17
- Aikido::Zen.current_context = context
20
+ Aikido::Zen.current_context = env[ENV_KEY] = context
18
21
  Aikido::Zen.track_request(context.request)
19
22
 
20
23
  @app.call(env)
@@ -5,44 +5,53 @@ require "action_dispatch"
5
5
  module Aikido::Zen
6
6
  class RailsEngine < ::Rails::Engine
7
7
  config.before_configuration do
8
- # Access library configuration at `Rails.application.config.aikido_zen`.
9
- config.aikido_zen = Aikido::Zen.config
8
+ # Access library configuration at `Rails.application.config.zen`.
9
+ config.zen = Aikido::Zen.config
10
10
  end
11
11
 
12
12
  initializer "aikido.add_middleware" do |app|
13
+ next if config.zen.disabled?
14
+
13
15
  app.middleware.use Aikido::Zen::Middleware::SetContext
14
16
  app.middleware.use Aikido::Zen::Middleware::CheckAllowedAddresses
15
- app.middleware.use Aikido::Zen::Middleware::Throttler
16
-
17
- # Due to how Rails sets up its middleware chain, the routing is evaluated
18
- # (and the Request object constructed) in the app that terminates the
19
- # chain, so no amount of middleware will be able to access it.
20
- #
21
- # This way, we overwrite the Request object as early as we can in the
22
- # request handling, so that by the time we start evaluating inputs, we
23
- # have assigned the request correctly.
17
+
24
18
  ActiveSupport.on_load(:action_controller) do
19
+ # Due to how Rails sets up its middleware chain, the routing is evaluated
20
+ # (and the Request object constructed) in the app that terminates the
21
+ # chain, so no amount of middleware will be able to access it.
22
+ #
23
+ # This way, we overwrite the Request object as early as we can in the
24
+ # request handling, so that by the time we start evaluating inputs, we
25
+ # have assigned the request correctly.
25
26
  before_action { Aikido::Zen.current_context.update_request(request) }
26
27
  end
27
28
  end
28
29
 
29
30
  initializer "aikido.configuration" do |app|
30
- app.config.aikido_zen.logger = ::Rails.logger.tagged("aikido")
31
- app.config.aikido_zen.request_builder = Aikido::Zen::Context::RAILS_REQUEST_BUILDER
31
+ # Allow the logger to be configured before checking if disabled? so we can
32
+ # let the user know that the agent is disabled.
33
+ app.config.zen.logger = ::Rails.logger.tagged("aikido")
34
+
35
+ next if config.zen.disabled?
36
+
37
+ app.config.zen.request_builder = Aikido::Zen::Context::RAILS_REQUEST_BUILDER
32
38
 
33
39
  # Plug Rails' JSON encoder/decoder, but only if the user hasn't changed
34
40
  # them for something else.
35
- if app.config.aikido_zen.json_encoder == Aikido::Zen::Config::DEFAULT_JSON_ENCODER
36
- app.config.aikido_zen.json_encoder = ActiveSupport::JSON.method(:encode)
41
+ if app.config.zen.json_encoder == Aikido::Zen::Config::DEFAULT_JSON_ENCODER
42
+ app.config.zen.json_encoder = ActiveSupport::JSON.method(:encode)
37
43
  end
38
44
 
39
- if app.config.aikido_zen.json_decoder == Aikido::Zen::Config::DEFAULT_JSON_DECODER
40
- app.config.aikido_zen.json_decoder = ActiveSupport::JSON.method(:decode)
45
+ if app.config.zen.json_decoder == Aikido::Zen::Config::DEFAULT_JSON_DECODER
46
+ app.config.zen.json_decoder = ActiveSupport::JSON.method(:decode)
41
47
  end
42
48
  end
43
49
 
44
50
  config.after_initialize do
45
- Aikido::Zen.initialize!
51
+ if config.zen.disabled?
52
+ config.zen.logger.warn("Zen has been disabled and will not run.")
53
+ next
54
+ end
46
55
 
47
56
  # Make sure this is run at the end of the initialization process, so
48
57
  # that any gems required after aikido-zen are detected and patched
@@ -16,8 +16,6 @@ module Aikido::Zen
16
16
  end
17
17
 
18
18
  def schema
19
- return unless @config.api_schema_collection_enabled?
20
-
21
19
  Request::Schema.new(
22
20
  content_type: body_data_type,
23
21
  body_schema: body_schema,
@@ -11,6 +11,12 @@ module Aikido::Zen
11
11
  # @return [Aikido::Zen::Router]
12
12
  attr_reader :router
13
13
 
14
+ # The current user, if set by the host app.
15
+ #
16
+ # @return [Aikido::Zen::Actor, nil]
17
+ # @see Aikido::Zen.track_user
18
+ attr_accessor :actor
19
+
14
20
  def initialize(delegate, framework:, router:)
15
21
  super(delegate)
16
22
  @framework = framework
@@ -11,16 +11,11 @@ module Aikido::Zen
11
11
  #
12
12
  # You can subscribe to changes with +#add_observer(object, func_name)+, which
13
13
  # will call the function passing the settings as an argument.
14
- class RuntimeSettings < Concurrent::MutableStruct.new(
15
- :updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :skip_protection_for_ips, :received_any_stats
16
- )
17
- include Concurrent::Concern::Observable
18
-
14
+ RuntimeSettings = Struct.new(:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :skip_protection_for_ips, :received_any_stats) do
19
15
  def initialize(*)
20
- self.observers = Concurrent::Collection::CopyOnWriteObserverSet.new
21
16
  super
22
- self.endpoints ||= Endpoints.new
23
- self.skip_protection_for_ips ||= IPSet.new
17
+ self.endpoints ||= RuntimeSettings::Endpoints.new
18
+ self.skip_protection_for_ips ||= RuntimeSettings::IPSet.new
24
19
  end
25
20
 
26
21
  # @!attribute [rw] updated_at
@@ -56,12 +51,12 @@ module Aikido::Zen
56
51
 
57
52
  self.updated_at = Time.at(data["configUpdatedAt"].to_i / 1000)
58
53
  self.heartbeat_interval = (data["heartbeatIntervalInMS"].to_i / 1000)
59
- self.endpoints = Endpoints.from_json(data["endpoints"])
54
+ self.endpoints = RuntimeSettings::Endpoints.from_json(data["endpoints"])
60
55
  self.blocked_user_ids = data["blockedUserIds"]
61
- self.skip_protection_for_ips = IPSet.from_json(data["allowedIPAddresses"])
56
+ self.skip_protection_for_ips = RuntimeSettings::IPSet.from_json(data["allowedIPAddresses"])
62
57
  self.received_any_stats = data["receivedAnyStats"]
63
58
 
64
- observers.notify_observers(self) if updated_at != last_updated_at
59
+ Aikido::Zen.agent.updated_settings! if updated_at != last_updated_at
65
60
  end
66
61
  end
67
62
  end
@@ -112,7 +112,8 @@ module Aikido::Zen
112
112
  is_port_relevant = input_uri.port != input_uri.default_port
113
113
  return false if is_port_relevant && input_uri.port != conn_uri.port
114
114
 
115
- conn_uri.hostname == input_uri.hostname
115
+ conn_uri.hostname == input_uri.hostname &&
116
+ conn_uri.port == input_uri.port
116
117
  end
117
118
 
118
119
  def private_ip?(hostname)
@@ -128,8 +129,11 @@ module Aikido::Zen
128
129
  # * The input itself, if it already looks like a URI.
129
130
  # * The input prefixed with http://
130
131
  # * The input prefixed with https://
132
+ # * The input prefixed with the scheme of the request's URI, to consider
133
+ # things like an FTP request (to "ftp://localhost") with a plain host
134
+ # as a user-input ("localhost").
131
135
  #
132
- # @return [Set<URI>]
136
+ # @return [Array<URI>] a list of unique URIs based on the above criteria.
133
137
  def uris_from_input
134
138
  input = @input.to_s
135
139
 
@@ -138,10 +142,12 @@ module Aikido::Zen
138
142
  # valid hostname. We should do the same for the input.
139
143
  input = format("[%s]", input) if unescaped_ipv6?(input)
140
144
 
141
- [input, "http://#{input}", "https://#{input}"]
142
- .map { |candidate| as_uri(candidate) }
143
- .compact
144
- .uniq
145
+ [
146
+ input,
147
+ "http://#{input}",
148
+ "https://#{input}",
149
+ "#{@request_uri.scheme}://#{input}"
150
+ ].map { |candidate| as_uri(candidate) }.compact.uniq
145
151
  end
146
152
 
147
153
  def as_uri(string)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Sinks
5
+ module ActionController
6
+ # Implements the "middleware" for rate limiting in Rails apps, where we
7
+ # need to check at the end of the `before_action` chain, rather than in
8
+ # an actual Rack middleware, to allow for calls to Zen.track_user being
9
+ # made from before_actions in the host app, thus allowing rate-limiting
10
+ # by user ID rather than solely by IP.
11
+ class Throttler
12
+ def initialize(
13
+ config: Aikido::Zen.config,
14
+ settings: Aikido::Zen.runtime_settings,
15
+ rate_limiter: Aikido::Zen::RateLimiter.new
16
+ )
17
+ @config = config
18
+ @settings = settings
19
+ @rate_limiter = rate_limiter
20
+ end
21
+
22
+ def throttle(controller)
23
+ context = controller.request.env[Aikido::Zen::ENV_KEY]
24
+ request = context.request
25
+
26
+ if should_throttle?(request)
27
+ status, headers, body = @config.rate_limited_responder.call(request)
28
+ controller.headers.update(headers)
29
+ controller.render plain: Array(body).join, status: status
30
+
31
+ return true
32
+ end
33
+
34
+ false
35
+ end
36
+
37
+ private def should_throttle?(request)
38
+ return false if @settings.skip_protection_for_ips.include?(request.ip)
39
+
40
+ @rate_limiter.throttle?(request)
41
+ end
42
+ end
43
+
44
+ def self.throttler
45
+ @throttler ||= Aikido::Zen::Sinks::ActionController::Throttler.new
46
+ end
47
+
48
+ module Extensions
49
+ def run_callbacks(kind, *)
50
+ return super unless kind == :process_action
51
+
52
+ super do
53
+ rate_limiter = Aikido::Zen::Sinks::ActionController.throttler
54
+ throttled = rate_limiter.throttle(self)
55
+
56
+ yield if block_given? && !throttled
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ ::AbstractController::Callbacks.prepend(Aikido::Zen::Sinks::ActionController::Extensions)
@@ -66,7 +66,7 @@ module Aikido::Zen
66
66
 
67
67
  response
68
68
  ensure
69
- context["ssrf.request"] = prev_request
69
+ context["ssrf.request"] = prev_request if context
70
70
  end
71
71
  end
72
72
  end
@@ -7,6 +7,17 @@ module Aikido::Zen
7
7
  module PG
8
8
  SINK = Sinks.add("pg", scanners: [Scanners::SQLInjectionScanner])
9
9
 
10
+ # For some reason, the ActiveRecord pg adapter does not wrap exceptions in
11
+ # StatementInvalid, which leads to inconsistent handling. This guarantees
12
+ # that all Zen errors are wrapped in a StatementInvalid, so documentation
13
+ # can be consistent.
14
+ WRAP_EXCEPTIONS = if defined?(ActiveRecord::StatementInvalid)
15
+ <<~RUBY
16
+ rescue Aikido::Zen::SQLInjectionError
17
+ raise ActiveRecord::StatementInvalid
18
+ RUBY
19
+ end
20
+
10
21
  module Extensions
11
22
  %i[
12
23
  send_query exec sync_exec async_exec
@@ -16,12 +27,7 @@ module Aikido::Zen
16
27
  def #{method}(query, *)
17
28
  SINK.scan(query: query, dialect: :postgresql, operation: :#{method})
18
29
  super
19
- rescue Aikido::Zen::SQLInjectionError
20
- # The pg adapter does not wrap exceptions in StatementInvalid, which
21
- # leads to inconsistent handling. This guarantees that all Aikido
22
- # errors are wrapped in a StatementInvalid, so documentation can be
23
- # consistent.
24
- raise ActiveRecord::StatementInvalid
30
+ #{WRAP_EXCEPTIONS}
25
31
  end
26
32
  RUBY
27
33
  end
@@ -33,12 +39,7 @@ module Aikido::Zen
33
39
  def #{method}(_, query, *)
34
40
  SINK.scan(query: query, dialect: :postgresql, operation: :#{method})
35
41
  super
36
- rescue Aikido::Zen::SQLInjectionError
37
- # The pg adapter does not wrap exceptions in StatementInvalid, which
38
- # leads to inconsistent handling. This guarantees that all Aikido
39
- # errors are wrapped in a StatementInvalid, so documentation can be
40
- # consistent.
41
- raise ActiveRecord::StatementInvalid
42
+ #{WRAP_EXCEPTIONS}
42
43
  end
43
44
  RUBY
44
45
  end
@@ -66,7 +66,7 @@ module Aikido::Zen
66
66
  operation: "request"
67
67
  )
68
68
  ensure
69
- context["ssrf.request"] = nil
69
+ context["ssrf.request"] = nil if context
70
70
  end
71
71
 
72
72
  true
@@ -4,6 +4,7 @@ require_relative "sink"
4
4
 
5
5
  require_relative "sinks/socket"
6
6
 
7
+ require_relative "sinks/action_controller" if defined?(::ActionController)
7
8
  require_relative "sinks/resolv" if defined?(::Resolv)
8
9
  require_relative "sinks/net_http" if defined?(::Net::HTTP)
9
10
  require_relative "sinks/http" if defined?(::HTTP)
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "0.1.0.alpha4"
5
+ VERSION = "0.1.1"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
- LIBZEN_VERSION = "0.1.26"
8
+ LIBZEN_VERSION = "0.1.31"
9
9
  end
10
10
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Aikido::Zen
6
+ # @api private
7
+ #
8
+ # The worker manages the background thread in which Zen communicates with the
9
+ # Aikido server.
10
+ class Worker
11
+ # @return [Concurrent::ExecutorService]
12
+ attr_reader :executor
13
+
14
+ # @!visibility private
15
+ attr_reader :timers, :deferrals
16
+
17
+ def initialize(config: Aikido::Zen.config)
18
+ @config = config
19
+ @timers = []
20
+ @deferrals = []
21
+ @executor = Concurrent::SingleThreadExecutor.new
22
+ end
23
+
24
+ # Queue a block to be run asynchronously in the background thread.
25
+ #
26
+ # @return [void]
27
+ def perform(&block)
28
+ executor.post do
29
+ yield
30
+ rescue Exception => err # rubocop:disable Lint/RescueException
31
+ @config.logger.error "Error in background worker: #{err.inspect}"
32
+ end
33
+ end
34
+
35
+ # Queue a block to be run asynchronously after a delay.
36
+ #
37
+ # @param interval [Integer] amount of seconds to wait.
38
+ # @return [void]
39
+ def delay(interval, &task)
40
+ Concurrent::ScheduledTask
41
+ .execute(interval, executor: executor) { perform(&task) }
42
+ .tap { |deferral| @deferrals << deferral }
43
+ end
44
+
45
+ # Queue a block to run repeatedly on a timer on the background thread. The
46
+ # timer will consider how long the block takes to run to schedule the next
47
+ # run. For example, if you schedule a block to run every 10 seconds, and the
48
+ # block itself takes 2 seconds, the second iteration will be run 8 seconds
49
+ # after the first one.
50
+ #
51
+ # If the block takes longer than the given interval, the second iteration
52
+ # will be run immediately.
53
+ #
54
+ # @param interval [Integer] amount of seconds to wait between runs.
55
+ # @param run_now [Boolean] whether to run the block immediately, or wait for
56
+ # +interval+ seconds before the first run. Defaults to +true+.
57
+ # @return [void]
58
+ def every(interval, run_now: true, &task)
59
+ Concurrent::TimerTask
60
+ .execute(
61
+ run_now: run_now,
62
+ executor: executor,
63
+ interval_type: :fixed_rate,
64
+ execution_interval: interval
65
+ ) {
66
+ perform(&task)
67
+ }
68
+ .tap { |timer| @timers << timer }
69
+ end
70
+
71
+ # Safely clean up and kill the thread, giving time to kill any ongoing tasks
72
+ # on the queue.
73
+ #
74
+ # @return [void]
75
+ def shutdown
76
+ @deferrals.each { |task| task.cancel if task.pending? }
77
+ @timers.each { |task| task.shutdown }
78
+ @executor.shutdown
79
+ @executor.wait_for_termination(30)
80
+ end
81
+ end
82
+ end