busybee 0.1.0 → 0.3.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/CHANGELOG.md +71 -7
- data/README.md +70 -42
- data/docs/client/quick_start.md +279 -0
- data/docs/client.md +825 -0
- data/docs/configuration.md +550 -0
- data/docs/grpc.md +50 -25
- data/docs/testing.md +118 -28
- data/docs/workers.md +982 -0
- data/exe/busybee +6 -0
- data/lib/busybee/cli.rb +173 -0
- data/lib/busybee/client/error_handling.rb +37 -0
- data/lib/busybee/client/job_operations.rb +236 -0
- data/lib/busybee/client/message_operations.rb +84 -0
- data/lib/busybee/client/process_operations.rb +108 -0
- data/lib/busybee/client/variable_operations.rb +64 -0
- data/lib/busybee/client.rb +87 -0
- data/lib/busybee/configure.rb +290 -0
- data/lib/busybee/credentials/camunda_cloud.rb +58 -0
- data/lib/busybee/credentials/insecure.rb +24 -0
- data/lib/busybee/credentials/oauth.rb +157 -0
- data/lib/busybee/credentials/tls.rb +43 -0
- data/lib/busybee/credentials.rb +200 -0
- data/lib/busybee/defaults.rb +20 -0
- data/lib/busybee/error.rb +50 -0
- data/lib/busybee/grpc/error.rb +60 -0
- data/lib/busybee/grpc.rb +2 -2
- data/lib/busybee/job.rb +219 -0
- data/lib/busybee/job_stream.rb +85 -0
- data/lib/busybee/logging.rb +61 -0
- data/lib/busybee/railtie.rb +113 -0
- data/lib/busybee/runner/hybrid.rb +64 -0
- data/lib/busybee/runner/multi.rb +101 -0
- data/lib/busybee/runner/polling.rb +54 -0
- data/lib/busybee/runner/streaming.rb +159 -0
- data/lib/busybee/runner.rb +97 -0
- data/lib/busybee/runtime_config.rb +184 -0
- data/lib/busybee/serialization.rb +100 -0
- data/lib/busybee/testing/activated_job.rb +33 -8
- data/lib/busybee/testing/helpers/execution.rb +139 -0
- data/lib/busybee/testing/helpers/support.rb +78 -0
- data/lib/busybee/testing/helpers.rb +56 -66
- data/lib/busybee/testing/matchers/complete_job.rb +55 -0
- data/lib/busybee/testing/matchers/fail_job.rb +75 -0
- data/lib/busybee/testing/matchers/have_activated.rb +1 -1
- data/lib/busybee/testing/matchers/have_available_jobs.rb +44 -0
- data/lib/busybee/testing/matchers/throw_bpmn_error_on.rb +72 -0
- data/lib/busybee/testing.rb +5 -33
- data/lib/busybee/version.rb +1 -1
- data/lib/busybee/worker/configuration.rb +287 -0
- data/lib/busybee/worker/dsl.rb +187 -0
- data/lib/busybee/worker/shutdown.rb +27 -0
- data/lib/busybee/worker.rb +130 -0
- data/lib/busybee.rb +134 -2
- metadata +80 -3
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "busybee/grpc"
|
|
4
|
+
require "busybee/serialization"
|
|
5
|
+
|
|
6
|
+
module Busybee
|
|
7
|
+
class Client
|
|
8
|
+
# Variable and incident operations for managing process instance state.
|
|
9
|
+
module VariableOperations
|
|
10
|
+
# Set variables on a process instance or element instance.
|
|
11
|
+
#
|
|
12
|
+
# @param element_instance_key [Integer, String] The element instance key
|
|
13
|
+
# (process instance key or service task key)
|
|
14
|
+
# @param vars [Hash] Variables to set
|
|
15
|
+
# @param local [Boolean] If true, variables are set only in the local scope
|
|
16
|
+
# (not propagated to parent scopes)
|
|
17
|
+
# @return [Integer] The variable set operation key
|
|
18
|
+
# @raise [ArgumentError] if vars is not a Hash
|
|
19
|
+
# @raise [Busybee::GRPC::Error] if setting variables fails
|
|
20
|
+
#
|
|
21
|
+
# @example Set variables on a process instance
|
|
22
|
+
# key = client.set_variables(process_instance_key, vars: { status: "approved" })
|
|
23
|
+
# # => 12345
|
|
24
|
+
#
|
|
25
|
+
# @example Set local variables on an element
|
|
26
|
+
# client.set_variables(element_key, vars: { tempData: "value" }, local: true)
|
|
27
|
+
#
|
|
28
|
+
def set_variables(element_instance_key, vars: {}, local: false)
|
|
29
|
+
raise ArgumentError, "vars must be a Hash" unless vars.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
request = Busybee::GRPC::SetVariablesRequest.new(
|
|
32
|
+
elementInstanceKey: element_instance_key.to_i,
|
|
33
|
+
variables: Busybee::Serialization.to_json(vars),
|
|
34
|
+
local: local
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
with_retry do
|
|
38
|
+
stub.set_variables(request).key
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resolve an incident.
|
|
43
|
+
#
|
|
44
|
+
# @param incident_key [Integer, String] The incident key to resolve
|
|
45
|
+
# @return [Boolean] true if resolved
|
|
46
|
+
# @raise [Busybee::GRPC::Error] if resolving the incident fails
|
|
47
|
+
#
|
|
48
|
+
# @example Resolve an incident
|
|
49
|
+
# client.resolve_incident(54321)
|
|
50
|
+
# # => true
|
|
51
|
+
#
|
|
52
|
+
def resolve_incident(incident_key)
|
|
53
|
+
request = Busybee::GRPC::ResolveIncidentRequest.new(
|
|
54
|
+
incidentKey: incident_key.to_i
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with_retry do
|
|
58
|
+
stub.resolve_incident(request)
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/duration"
|
|
5
|
+
require "busybee/credentials"
|
|
6
|
+
require "busybee/client/error_handling"
|
|
7
|
+
require "busybee/client/job_operations"
|
|
8
|
+
require "busybee/client/message_operations"
|
|
9
|
+
require "busybee/client/process_operations"
|
|
10
|
+
require "busybee/client/variable_operations"
|
|
11
|
+
|
|
12
|
+
module Busybee
|
|
13
|
+
# Ruby-idiomatic wrapper around Zeebe GRPC API.
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage with local Zeebe
|
|
16
|
+
# client = Busybee::Client.new(insecure: true)
|
|
17
|
+
# client.deploy_process("workflow.bpmn")
|
|
18
|
+
#
|
|
19
|
+
# @example With explicit credentials
|
|
20
|
+
# credentials = Busybee::Credentials::Insecure.new
|
|
21
|
+
# client = Busybee::Client.new(credentials)
|
|
22
|
+
#
|
|
23
|
+
# @example With gem-level configuration
|
|
24
|
+
# Busybee.credential_type = :insecure
|
|
25
|
+
# client = Busybee::Client.new
|
|
26
|
+
#
|
|
27
|
+
class Client
|
|
28
|
+
include ErrorHandling
|
|
29
|
+
include JobOperations
|
|
30
|
+
include MessageOperations
|
|
31
|
+
include ProcessOperations
|
|
32
|
+
include VariableOperations
|
|
33
|
+
|
|
34
|
+
attr_reader :credentials
|
|
35
|
+
|
|
36
|
+
# Create a new client.
|
|
37
|
+
#
|
|
38
|
+
# @param credentials [Credentials, nil] Explicit credentials object (first positional arg)
|
|
39
|
+
# @param params [Hash, nil] Credential kwargs (passed to Credentials.build if no explicit credentials)
|
|
40
|
+
# @raise [ArgumentError] if both credentials object and credential params are provided
|
|
41
|
+
#
|
|
42
|
+
# @example With credential parameters:
|
|
43
|
+
# Client.new(insecure: true, cluster_address: "localhost:26500")
|
|
44
|
+
#
|
|
45
|
+
# @example With explicit credentials:
|
|
46
|
+
# creds = Credentials::Insecure.new(cluster_address: "localhost:26500")
|
|
47
|
+
# Client.new(creds)
|
|
48
|
+
#
|
|
49
|
+
# @example Inferred entirely from gem configuration or env vars:
|
|
50
|
+
# Client.new
|
|
51
|
+
#
|
|
52
|
+
def initialize(credentials = nil, **params)
|
|
53
|
+
if credentials && params.any?
|
|
54
|
+
raise ArgumentError, "cannot pass both explicit credentials and credential parameters"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@credentials = if credentials
|
|
58
|
+
credentials
|
|
59
|
+
elsif params.empty? && Busybee.credentials.is_a?(Busybee::Credentials)
|
|
60
|
+
Busybee.credentials
|
|
61
|
+
else
|
|
62
|
+
Credentials.build(**params) # will attempt to autodetect from env if no params are given
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the cluster address from credentials.
|
|
67
|
+
# @return [String] Cluster address (host:port)
|
|
68
|
+
def cluster_address
|
|
69
|
+
credentials.cluster_address
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Returns the GRPC stub for making API calls.
|
|
75
|
+
# @return [Busybee::GRPC::Gateway::Stub]
|
|
76
|
+
def stub
|
|
77
|
+
credentials.grpc_stub
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Ensures a value is in milliseconds.
|
|
81
|
+
# @param value [Integer, ActiveSupport::Duration] Value to convert
|
|
82
|
+
# @return [Integer] Value in milliseconds
|
|
83
|
+
def milliseconds_from(value)
|
|
84
|
+
value.is_a?(ActiveSupport::Duration) ? value.in_milliseconds.to_i : value.to_i
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Busybee
|
|
4
|
+
# Validated setters for gem-level configuration.
|
|
5
|
+
# Included into Busybee's singleton class; readers live in busybee.rb.
|
|
6
|
+
module Configure # rubocop:disable Metrics/ModuleLength
|
|
7
|
+
# --- String configs ---
|
|
8
|
+
|
|
9
|
+
def cluster_address=(value)
|
|
10
|
+
if value.nil?
|
|
11
|
+
@cluster_address = nil
|
|
12
|
+
return
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
validate_string!(:cluster_address, value)
|
|
16
|
+
@cluster_address = value.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def worker_name=(value)
|
|
20
|
+
if value.nil?
|
|
21
|
+
@worker_name = nil
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
validate_string!(:worker_name, value)
|
|
26
|
+
@worker_name = value.to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# --- Boolean configs ---
|
|
30
|
+
|
|
31
|
+
def default_input_required=(value)
|
|
32
|
+
if value.nil?
|
|
33
|
+
@default_input_required = nil
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
validate_boolean!(:default_input_required, value)
|
|
38
|
+
@default_input_required = value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_max_jobs=(value)
|
|
42
|
+
@default_max_jobs = value.nil? ? nil : validate_positive_integer!(:default_max_jobs, value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def default_output_required=(value)
|
|
46
|
+
if value.nil?
|
|
47
|
+
@default_output_required = nil
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
validate_boolean!(:default_output_required, value)
|
|
52
|
+
@default_output_required = value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_buffer=(value)
|
|
56
|
+
if value.nil?
|
|
57
|
+
@default_buffer = nil
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
validate_boolean!(:default_buffer, value)
|
|
62
|
+
@default_buffer = value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def grpc_retry_enabled=(value)
|
|
66
|
+
if value.nil?
|
|
67
|
+
@grpc_retry_enabled = nil
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
validate_boolean!(:grpc_retry_enabled, value)
|
|
72
|
+
@grpc_retry_enabled = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# --- Duration configs (Integer ms or ActiveSupport::Duration) ---
|
|
76
|
+
|
|
77
|
+
def default_fail_job_backoff=(value)
|
|
78
|
+
@default_fail_job_backoff = value.nil? ? nil : validate_duration!(:default_fail_job_backoff, value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def default_job_lock_timeout=(value)
|
|
82
|
+
@default_job_lock_timeout = value.nil? ? nil : validate_duration!(:default_job_lock_timeout, value)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def default_job_request_timeout=(value)
|
|
86
|
+
@default_job_request_timeout = value.nil? ? nil : validate_duration!(:default_job_request_timeout, value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def default_message_ttl=(value)
|
|
90
|
+
@default_message_ttl = value.nil? ? nil : validate_duration!(:default_message_ttl, value)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def grpc_retry_delay_ms=(value)
|
|
94
|
+
@grpc_retry_delay_ms = value.nil? ? nil : validate_duration!(:grpc_retry_delay_ms, value)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def default_backpressure_delay=(value)
|
|
98
|
+
@default_backpressure_delay = value.nil? ? nil : validate_duration!(:default_backpressure_delay, value)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- Buffer throttle (three-state: false/nil = off, true → 0, Numeric = ms) ---
|
|
102
|
+
|
|
103
|
+
def default_buffer_throttle=(value)
|
|
104
|
+
@default_buffer_throttle = value.nil? ? nil : validate_buffer_throttle!(:default_buffer_throttle, value)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# --- Worker mode ---
|
|
108
|
+
|
|
109
|
+
def default_worker_mode=(value)
|
|
110
|
+
if value.nil?
|
|
111
|
+
@default_worker_mode = nil
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
validate_worker_mode!(:default_worker_mode, value)
|
|
116
|
+
@default_worker_mode = value.to_sym
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# --- Error class list ---
|
|
120
|
+
|
|
121
|
+
def grpc_retry_errors=(value)
|
|
122
|
+
if value.nil?
|
|
123
|
+
@grpc_retry_errors = nil
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
validate_error_classes!(:grpc_retry_errors, value)
|
|
128
|
+
@grpc_retry_errors = value
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- Shutdown errors (Array coercion, validates each is Exception subclass) ---
|
|
132
|
+
|
|
133
|
+
def shutdown_on_errors=(value)
|
|
134
|
+
coerced = Array(value)
|
|
135
|
+
coerced.each do |klass|
|
|
136
|
+
unless klass.is_a?(Class) && klass <= Exception
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"shutdown_on_errors expects exception classes, got #{klass.inspect} (#{klass.class})"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
@shutdown_on_errors = coerced
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- Log format ---
|
|
145
|
+
|
|
146
|
+
def log_format=(value)
|
|
147
|
+
if value.nil?
|
|
148
|
+
@log_format = nil
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
str_value = value.to_s
|
|
153
|
+
if VALID_LOG_FORMATS.include?(str_value)
|
|
154
|
+
@log_format = str_value.to_sym
|
|
155
|
+
else
|
|
156
|
+
Logging.warn("Invalid log_format: #{str_value.inspect}. Valid formats: #{VALID_LOG_FORMATS.join(', ')}")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# --- Credential type ---
|
|
161
|
+
|
|
162
|
+
def credential_type=(value)
|
|
163
|
+
if value.nil?
|
|
164
|
+
@credential_type = nil
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
str_value = value.to_s
|
|
169
|
+
if VALID_CREDENTIAL_TYPES.include?(str_value)
|
|
170
|
+
@credential_type = str_value.to_sym
|
|
171
|
+
else
|
|
172
|
+
Logging.warn("Invalid credential_type: #{str_value.inspect}. Valid types: #{VALID_CREDENTIAL_TYPES.join(', ')}")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# --- Credentials object ---
|
|
177
|
+
|
|
178
|
+
def credentials=(value)
|
|
179
|
+
if value.nil?
|
|
180
|
+
@credentials = nil
|
|
181
|
+
return
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
unless value.is_a?(Busybee::Credentials)
|
|
185
|
+
raise ArgumentError, "credentials must be a Busybee::Credentials object, got #{value.class}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@credentials = value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
# Validates and coerces a duration config value.
|
|
194
|
+
# Returns the (possibly coerced) value to assign.
|
|
195
|
+
def validate_duration!(name, value) # rubocop:disable Metrics/AbcSize
|
|
196
|
+
return value if value.is_a?(Integer)
|
|
197
|
+
return value if defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
|
|
198
|
+
|
|
199
|
+
if value.is_a?(String)
|
|
200
|
+
return value.to_f.to_i if value.match?(/\A\d+(\.\d+)?\z/)
|
|
201
|
+
|
|
202
|
+
raise ArgumentError,
|
|
203
|
+
"#{name} accepts Integer, ActiveSupport::Duration, or numeric String, " \
|
|
204
|
+
"got non-numeric String #{value.inspect}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
if value.is_a?(Numeric)
|
|
208
|
+
Logging.warn("#{name}: coercing #{value.class} #{value.inspect} to Integer #{value.to_i}")
|
|
209
|
+
return value.to_i
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
raise ArgumentError,
|
|
213
|
+
"#{name} accepts Integer, ActiveSupport::Duration, or numeric String, got #{value.class}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def validate_boolean!(name, value)
|
|
217
|
+
return if [true, false].include?(value)
|
|
218
|
+
|
|
219
|
+
raise ArgumentError, "#{name} accepts true or false, got #{value.inspect} (#{value.class})"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_positive_integer!(name, value)
|
|
223
|
+
if value.is_a?(Integer)
|
|
224
|
+
raise ArgumentError, "#{name} must be positive, got #{value}" unless value.positive?
|
|
225
|
+
|
|
226
|
+
return value
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if value.is_a?(String) && value.match?(/\A\d+\z/)
|
|
230
|
+
int_value = value.to_i
|
|
231
|
+
raise ArgumentError, "#{name} must be positive, got #{int_value}" unless int_value.positive?
|
|
232
|
+
|
|
233
|
+
return int_value
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
raise ArgumentError,
|
|
237
|
+
"#{name} accepts a positive Integer or numeric String, got #{value.inspect} (#{value.class})"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_string!(name, value)
|
|
241
|
+
return if value.is_a?(String) || value.is_a?(Symbol)
|
|
242
|
+
|
|
243
|
+
raise ArgumentError, "#{name} accepts String or Symbol, got #{value.inspect} (#{value.class})"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Validates and coerces a buffer throttle value.
|
|
247
|
+
# Returns the (possibly coerced) value to assign.
|
|
248
|
+
def validate_buffer_throttle!(name, value)
|
|
249
|
+
return 0 if value == true
|
|
250
|
+
return false if value == false
|
|
251
|
+
|
|
252
|
+
if value.is_a?(Numeric)
|
|
253
|
+
raise ArgumentError, "#{name} must be non-negative, got #{value.inspect}" if value.negative?
|
|
254
|
+
|
|
255
|
+
return value
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if value.is_a?(String)
|
|
259
|
+
return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)
|
|
260
|
+
|
|
261
|
+
raise ArgumentError,
|
|
262
|
+
"#{name} accepts Numeric, boolean, or numeric String, got non-numeric String #{value.inspect}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
raise ArgumentError, "#{name} accepts Numeric, boolean, or numeric String, got #{value.inspect} (#{value.class})"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def validate_worker_mode!(name, value)
|
|
269
|
+
sym = value.to_sym
|
|
270
|
+
return if VALID_WORKER_MODES.include?(sym)
|
|
271
|
+
|
|
272
|
+
raise ArgumentError,
|
|
273
|
+
"#{name} must be one of #{VALID_WORKER_MODES.map(&:inspect).join(', ')}, got #{value.inspect}"
|
|
274
|
+
rescue NoMethodError
|
|
275
|
+
raise ArgumentError,
|
|
276
|
+
"#{name} accepts Symbol or String, got #{value.inspect} (#{value.class})"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def validate_error_classes!(name, value)
|
|
280
|
+
raise ArgumentError, "#{name} accepts an Array of exception classes, got #{value.class}" unless value.is_a?(Array)
|
|
281
|
+
|
|
282
|
+
value.each do |klass|
|
|
283
|
+
unless klass.is_a?(Class) && klass <= Exception
|
|
284
|
+
raise ArgumentError,
|
|
285
|
+
"#{name} expects exception classes, got #{klass.inspect} (#{klass.class})"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "busybee/credentials/oauth"
|
|
4
|
+
|
|
5
|
+
module Busybee
|
|
6
|
+
class Credentials
|
|
7
|
+
# Camunda Cloud-specific OAuth credentials.
|
|
8
|
+
# Automatically derives the cluster address and OAuth configuration
|
|
9
|
+
# from cluster ID and region.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# credentials = Busybee::Credentials::CamundaCloud.new(
|
|
13
|
+
# client_id: ENV["CAMUNDA_CLIENT_ID"],
|
|
14
|
+
# client_secret: ENV["CAMUNDA_CLIENT_SECRET"],
|
|
15
|
+
# cluster_id: ENV["CAMUNDA_CLUSTER_ID"],
|
|
16
|
+
# region: ENV["CAMUNDA_CLUSTER_REGION"]
|
|
17
|
+
# )
|
|
18
|
+
# stub = credentials.grpc_stub
|
|
19
|
+
#
|
|
20
|
+
# @example With scope for API access control
|
|
21
|
+
# credentials = Busybee::Credentials::CamundaCloud.new(
|
|
22
|
+
# client_id: ENV["CAMUNDA_CLIENT_ID"],
|
|
23
|
+
# client_secret: ENV["CAMUNDA_CLIENT_SECRET"],
|
|
24
|
+
# cluster_id: ENV["CAMUNDA_CLUSTER_ID"],
|
|
25
|
+
# region: ENV["CAMUNDA_CLUSTER_REGION"],
|
|
26
|
+
# scope: "Zeebe Tasklist Operate"
|
|
27
|
+
# )
|
|
28
|
+
#
|
|
29
|
+
class CamundaCloud < OAuth
|
|
30
|
+
CAMUNDA_AUTH_URL = "https://login.cloud.camunda.io/oauth/token"
|
|
31
|
+
CAMUNDA_AUDIENCE = "zeebe.camunda.io"
|
|
32
|
+
|
|
33
|
+
# @param client_id [String] Camunda Cloud client ID
|
|
34
|
+
# @param client_secret [String] Camunda Cloud client secret
|
|
35
|
+
# @param cluster_id [String] Camunda Cloud cluster ID
|
|
36
|
+
# @param region [String] Camunda Cloud region (e.g., "bru-2", "us-east-1")
|
|
37
|
+
# @param scope [String, nil] Optional OAuth2 scope for API access control
|
|
38
|
+
def initialize(client_id:, client_secret:, cluster_id:, region:, scope: nil)
|
|
39
|
+
raise ArgumentError, "client_id is required" if client_id.nil?
|
|
40
|
+
raise ArgumentError, "client_secret is required" if client_secret.nil?
|
|
41
|
+
raise ArgumentError, "cluster_id is required" if cluster_id.nil?
|
|
42
|
+
raise ArgumentError, "region is required" if region.nil?
|
|
43
|
+
|
|
44
|
+
@cluster_id = cluster_id
|
|
45
|
+
@region = region
|
|
46
|
+
|
|
47
|
+
super(
|
|
48
|
+
token_url: CAMUNDA_AUTH_URL,
|
|
49
|
+
client_id: client_id,
|
|
50
|
+
client_secret: client_secret,
|
|
51
|
+
audience: CAMUNDA_AUDIENCE,
|
|
52
|
+
scope: scope,
|
|
53
|
+
cluster_address: "#{cluster_id}.#{region}.zeebe.camunda.io:443"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "busybee/credentials"
|
|
4
|
+
|
|
5
|
+
module Busybee
|
|
6
|
+
class Credentials
|
|
7
|
+
# Insecure credentials for local development, docker-compose, and CI.
|
|
8
|
+
# No TLS, no authentication.
|
|
9
|
+
#
|
|
10
|
+
# @example Connect to local Zeebe
|
|
11
|
+
# credentials = Busybee::Credentials::Insecure.new
|
|
12
|
+
# stub = credentials.grpc_stub
|
|
13
|
+
#
|
|
14
|
+
# @example Connect to custom address
|
|
15
|
+
# credentials = Busybee::Credentials::Insecure.new(cluster_address: "zeebe:26500")
|
|
16
|
+
# stub = credentials.grpc_stub
|
|
17
|
+
#
|
|
18
|
+
class Insecure < Credentials
|
|
19
|
+
def grpc_channel_credentials
|
|
20
|
+
:this_channel_is_insecure
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/cache"
|
|
5
|
+
require "grpc"
|
|
6
|
+
require "json"
|
|
7
|
+
require "net/http"
|
|
8
|
+
|
|
9
|
+
require "busybee/credentials"
|
|
10
|
+
require "busybee/error"
|
|
11
|
+
|
|
12
|
+
module Busybee
|
|
13
|
+
class Credentials
|
|
14
|
+
# OAuth2 credentials with automatic token refresh.
|
|
15
|
+
# Combines TLS channel credentials with OAuth2 call credentials.
|
|
16
|
+
#
|
|
17
|
+
# Token caching uses ActiveSupport::Cache with race_condition_ttl to prevent
|
|
18
|
+
# thundering herd during refresh - multiple threads won't block waiting for
|
|
19
|
+
# a refresh when the token is still valid.
|
|
20
|
+
#
|
|
21
|
+
# @example Basic usage
|
|
22
|
+
# credentials = Busybee::Credentials::OAuth.new(
|
|
23
|
+
# token_url: "https://auth.example.com/oauth/token",
|
|
24
|
+
# client_id: "my-client-id",
|
|
25
|
+
# client_secret: "my-client-secret",
|
|
26
|
+
# audience: "zeebe-api",
|
|
27
|
+
# cluster_address: "zeebe.example.com:443"
|
|
28
|
+
# )
|
|
29
|
+
# stub = credentials.grpc_stub
|
|
30
|
+
#
|
|
31
|
+
# @example With custom CA certificate
|
|
32
|
+
# credentials = Busybee::Credentials::OAuth.new(
|
|
33
|
+
# token_url: "https://auth.example.com/oauth/token",
|
|
34
|
+
# client_id: "my-client-id",
|
|
35
|
+
# client_secret: "my-client-secret",
|
|
36
|
+
# audience: "zeebe-api",
|
|
37
|
+
# cluster_address: "zeebe.example.com:443",
|
|
38
|
+
# certificate_file: "/path/to/ca-cert.pem"
|
|
39
|
+
# )
|
|
40
|
+
#
|
|
41
|
+
class OAuth < Credentials
|
|
42
|
+
# These constants may become configuration options in a future version.
|
|
43
|
+
DEFAULT_EXPIRY_SECONDS = 60 * 50 # 50 minutes
|
|
44
|
+
RACE_CONDITION_TTL_SECONDS = 30
|
|
45
|
+
TOKEN_CACHE_SIZE_BYTES = 4 * 1024 * 1024 # 4MB
|
|
46
|
+
|
|
47
|
+
# @param token_url [String] OAuth2 token endpoint URL
|
|
48
|
+
# @param client_id [String] OAuth2 client ID
|
|
49
|
+
# @param client_secret [String] OAuth2 client secret
|
|
50
|
+
# @param audience [String] OAuth2 audience (API identifier)
|
|
51
|
+
# @param scope [String, nil] Optional OAuth2 scope for API access control
|
|
52
|
+
# @param cluster_address [String, nil] Zeebe cluster address (host:port)
|
|
53
|
+
# @param certificate_file [String, nil] Optional CA certificate file path
|
|
54
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
55
|
+
token_url:,
|
|
56
|
+
client_id:,
|
|
57
|
+
client_secret:,
|
|
58
|
+
audience:,
|
|
59
|
+
scope: nil,
|
|
60
|
+
cluster_address: nil,
|
|
61
|
+
certificate_file: nil
|
|
62
|
+
)
|
|
63
|
+
raise ArgumentError, "token_url is required" if token_url.nil?
|
|
64
|
+
raise ArgumentError, "client_id is required" if client_id.nil?
|
|
65
|
+
raise ArgumentError, "client_secret is required" if client_secret.nil?
|
|
66
|
+
raise ArgumentError, "audience is required" if audience.nil?
|
|
67
|
+
|
|
68
|
+
super(cluster_address: cluster_address)
|
|
69
|
+
@token_uri = URI(token_url)
|
|
70
|
+
@client_id = client_id
|
|
71
|
+
@client_secret = client_secret
|
|
72
|
+
@audience = audience
|
|
73
|
+
@scope = scope
|
|
74
|
+
@certificate_file = certificate_file
|
|
75
|
+
@current_expiry = DEFAULT_EXPIRY_SECONDS - RACE_CONDITION_TTL_SECONDS
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def grpc_channel_credentials
|
|
79
|
+
build_tls_credentials.compose(grpc_call_credentials)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def grpc_call_credentials
|
|
85
|
+
::GRPC::Core::CallCredentials.new(method(:token_updater).to_proc)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_tls_credentials
|
|
89
|
+
if @certificate_file
|
|
90
|
+
::GRPC::Core::ChannelCredentials.new(File.read(@certificate_file))
|
|
91
|
+
else
|
|
92
|
+
::GRPC::Core::ChannelCredentials.new
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def current_token
|
|
97
|
+
# Use race_condition_ttl to prevent thundering herd:
|
|
98
|
+
# - When token is fresh, multiple threads read from cache
|
|
99
|
+
# - 30s before expiry, first thread refreshes while others use stale token
|
|
100
|
+
fetch_options = { expires_in: @current_expiry, race_condition_ttl: RACE_CONDITION_TTL_SECONDS }
|
|
101
|
+
token_cache.fetch(cache_key, fetch_options) do |_key, options = nil|
|
|
102
|
+
token_data = fetch_token_response
|
|
103
|
+
|
|
104
|
+
@current_expiry =
|
|
105
|
+
token_data.fetch("expires_in", DEFAULT_EXPIRY_SECONDS).to_i - RACE_CONDITION_TTL_SECONDS
|
|
106
|
+
|
|
107
|
+
# Set cache expiry dynamically if possible (Rails 7.1+ only):
|
|
108
|
+
options.expires_in = @current_expiry if options&.respond_to?(:expires_in=) # rubocop:disable Lint/RedundantSafeNavigation
|
|
109
|
+
|
|
110
|
+
token_data["access_token"]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fetch_token_response
|
|
115
|
+
response = http_client.request(build_token_request)
|
|
116
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
117
|
+
raise Busybee::OAuthTokenRefreshFailed, "HTTP #{response.code}: #{response.body}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
JSON.parse(response.body)
|
|
121
|
+
rescue JSON::ParserError => e
|
|
122
|
+
raise Busybee::InvalidOAuthResponse, "Invalid JSON response from token endpoint: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def http_client
|
|
126
|
+
Net::HTTP.new(@token_uri.host, @token_uri.port).tap do |client|
|
|
127
|
+
client.use_ssl = (@token_uri.scheme == "https")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_token_request
|
|
132
|
+
Net::HTTP::Post.new(@token_uri.path).tap do |request|
|
|
133
|
+
form_data = {
|
|
134
|
+
"grant_type" => "client_credentials",
|
|
135
|
+
"client_id" => @client_id,
|
|
136
|
+
"client_secret" => @client_secret,
|
|
137
|
+
"audience" => @audience
|
|
138
|
+
}
|
|
139
|
+
form_data["scope"] = @scope if @scope
|
|
140
|
+
request.set_form_data(form_data)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def token_updater(_context)
|
|
145
|
+
{ authorization: "Bearer #{current_token}" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def cache_key
|
|
149
|
+
@cache_key ||= "busybee:oauth_token:#{@audience}:#{@client_id}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def token_cache
|
|
153
|
+
@token_cache ||= ActiveSupport::Cache::MemoryStore.new(size: TOKEN_CACHE_SIZE_BYTES)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|