bas 1.7.1 → 1.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: 63490ab5165fd3aa1793a7521272869addb0ebd691d65e7e300b757af770b6fe
4
- data.tar.gz: 834e5ff6ebe43795ddfd94fa19f7d3435cd6ae99fc24902abdec2a8c3215ce3d
3
+ metadata.gz: a27720481a463c1a4a6edaeda8d9c159d009a11546802f559fd4232be721a146
4
+ data.tar.gz: fb829e6b146c295403118870324641d6e8e57ca3231259493aa686c4045d2b80
5
5
  SHA512:
6
- metadata.gz: 97a51249e553a947453084004b2c4701f801bd43a9b14c439721fe1f3e2262813ac541ce0326262997757368eddfc46e08390938f116eef3eb75ecdb80d5e41d
7
- data.tar.gz: 2d0d10c08ae2c5a1810e88c624e42d11dbf16ecacc45184185f0ff55caf27a0819b37baeb645991d3eac1ace9595dd1fedba1c4c7939460d824acfaa0fcc1d86
6
+ metadata.gz: 8a76d3b14aaf04f243a26968e79726025156d491f47ecde2435b5f83c316d3275a61f5cda1de54105459e0d420678c5379194d59f7c7e3d21c8208c10305c730
7
+ data.tar.gz: 34d41b30c0b3520edd259d325973b6b6a87467f7aac874ee17b630a292b68c57155c40157748a8a5c37c593e93be68dd9cee94136b55155d30324a6744a3d2db
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ # 1.8.0 (07.07.2025)
4
+ - [Feat: Implement client to interact with Operaton's API-REST](https://github.com/kommitters/bas/pull/142)
5
+
6
+ # 1.7.2 (04.07.2025)
7
+ - [Improve Elasticsearch mapping creation](https://github.com/kommitters/bas/pull/140)
8
+ - [Fix in Elasticsearch shared storage update_stage method](https://github.com/kommitters/bas/pull/139)
9
+ - [feat: Add Elasticsearch Shared Storage](https://github.com/kommitters/bas/issues/138)
10
+
3
11
  # 1.7.1 (11.02.2025)
4
12
  - [Fix absolute_path in order to execute the scripts](https://github.com/kommitters/bas/pull/133)
5
13
 
data/Gemfile CHANGED
@@ -15,8 +15,12 @@ gem "simplecov-lcov", "~> 0.8.0"
15
15
  gem "vcr"
16
16
  gem "webmock"
17
17
 
18
- gem "httparty"
18
+ gem "faraday", "~> 2.9"
19
+
20
+ gem "json", "~> 2.8"
19
21
 
22
+ gem "elasticsearch", "~> 8.0"
23
+ gem "httparty"
20
24
  gem "pg", "~> 1.5", ">= 1.5.4"
21
25
 
22
26
  group :test do
@@ -29,4 +33,5 @@ group :test do
29
33
  gem "net-smtp", "~> 0.4.0.1"
30
34
  gem "octokit", "~> 8.1.0"
31
35
  gem "openssl", "~> 3.2"
36
+ gem "timecop"
32
37
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "concurrent-ruby"
4
+ require "date"
4
5
 
5
6
  module Bas
6
7
  module Orchestrator
@@ -10,6 +11,7 @@ module Bas
10
11
  # This class initializes a thread pool and processes scheduled scripts based on
11
12
  # time intervals, specific days, or exact times.
12
13
  #
14
+ # rubocop:disable Metrics/ClassLength
13
15
  class Manager
14
16
  def initialize(schedules)
15
17
  @last_executions = Hash.new(0.0)
@@ -30,9 +32,7 @@ module Bas
30
32
  loop do
31
33
  @actual_time = Time.new
32
34
 
33
- execute_interval(script) if interval?(script)
34
- execute_day(script) if day?(script) && time?(script)
35
- execute_time(script) if time?(script) && !day?(script)
35
+ procces_schedule(script)
36
36
 
37
37
  sleep 0.1
38
38
  rescue StandardError => e
@@ -40,6 +40,13 @@ module Bas
40
40
  end
41
41
  end
42
42
 
43
+ def procces_schedule(script)
44
+ execute_interval(script) if interval?(script)
45
+ execute_day(script) if day?(script) && time?(script)
46
+ execute_time(script) if time?(script) && !day?(script)
47
+ execute_custom_rule(script, @actual_time) if custom_rule?(script)
48
+ end
49
+
43
50
  def execute_interval(script)
44
51
  return unless time_in_milliseconds - @last_executions[script[:path]] >= script[:interval]
45
52
 
@@ -59,6 +66,57 @@ module Bas
59
66
  @last_executions[script[:path]] = current_time
60
67
  end
61
68
 
69
+ # rubocop:disable Metrics/MethodLength
70
+ def execute_custom_rule(script, current_moment)
71
+ rule = script[:custom_rule]
72
+ case rule[:type]
73
+ when "last_day_of_week_in_month"
74
+ execute_last_day_of_week_in_month(script, rule, current_moment)
75
+ when "last_day_of_month"
76
+ execute_last_day_of_month(script, rule, current_moment)
77
+ when "last_day_of_week"
78
+ execute_last_day_of_week(script, rule, current_moment)
79
+ when "last_day_of_year"
80
+ execute_last_day_of_year(script, rule, current_moment)
81
+ else
82
+ puts "Unknown custom rule type: #{rule[:type]} for script '#{script[:path]}'"
83
+ end
84
+ end
85
+ # rubocop:enable Metrics/MethodLength
86
+
87
+ def execute_last_day_of_week_in_month(script, rule, current_moment)
88
+ return unless rule[:time]&.include?(current_moment.strftime("%H:%M"))
89
+ return unless today_is_last_day_of_week_in_month?(current_moment, rule[:day_of_week])
90
+
91
+ execute_once_per_time(script, current_moment.strftime("%H:%M"))
92
+ end
93
+
94
+ def execute_last_day_of_month(script, rule, current_moment)
95
+ return unless rule[:time]&.include?(current_moment.strftime("%H:%M"))
96
+ return unless current_moment.to_date == end_of_month(current_moment.to_date)
97
+
98
+ execute_once_per_time(script, current_moment.strftime("%H:%M"))
99
+ end
100
+
101
+ def execute_last_day_of_week(script, rule, current_moment)
102
+ return unless rule[:time]&.include?(current_moment.strftime("%H:%M"))
103
+
104
+ target_wday = Date::DAYNAMES.index { |name| name.casecmp(rule[:day_of_week]).zero? }
105
+ return if target_wday.nil?
106
+
107
+ today = current_moment.to_date
108
+ return unless today.wday == target_wday && last_occurrence_in_week?(today, target_wday)
109
+
110
+ execute_once_per_time(script, current_moment.strftime("%H:%M"))
111
+ end
112
+
113
+ def execute_last_day_of_year(script, rule, current_moment)
114
+ return unless rule[:time]&.include?(current_moment.strftime("%H:%M"))
115
+ return unless current_moment.month == 12 && current_moment.day == 31
116
+
117
+ execute_once_per_time(script, current_moment.strftime("%H:%M"))
118
+ end
119
+
62
120
  def interval?(script)
63
121
  script[:interval]
64
122
  end
@@ -71,6 +129,10 @@ module Bas
71
129
  script[:day]
72
130
  end
73
131
 
132
+ def custom_rule?(script)
133
+ script[:custom_rule] && script[:custom_rule][:type]
134
+ end
135
+
74
136
  def time_in_milliseconds
75
137
  @actual_time.to_f * 1000
76
138
  end
@@ -83,6 +145,41 @@ module Bas
83
145
  @actual_time.strftime("%A")
84
146
  end
85
147
 
148
+ def today_is_last_day_of_week_in_month?(time_obj, target_day_name)
149
+ date = time_obj.to_date
150
+ target_wday = Date::DAYNAMES.index { |name| name.casecmp(target_day_name).zero? }
151
+
152
+ return false if target_wday.nil?
153
+ return false unless date.wday == target_wday
154
+
155
+ last_target_day_of_month?(date)
156
+ end
157
+
158
+ def last_occurrence_in_week?(date, target_wday)
159
+ current_date = date
160
+ next_occurrence = current_date + 7
161
+ next_occurrence.wday == target_wday && last_target_day_of_month?(current_date)
162
+ end
163
+
164
+ def last_target_day_of_month?(date)
165
+ current_date = date
166
+ next_occurrence = current_date + 7
167
+
168
+ next_occurrence.month != current_date.month
169
+ end
170
+
171
+ def end_of_month(date)
172
+ next_month = date.month == 12 ? Date.new(date.year + 1, 1, 1) : Date.new(date.year, date.month + 1, 1)
173
+ next_month - 1
174
+ end
175
+
176
+ def execute_once_per_time(script, execution_time)
177
+ return if @last_executions[script[:path]] == execution_time
178
+
179
+ execute(script)
180
+ @last_executions[script[:path]] = execution_time
181
+ end
182
+
86
183
  def execute(script)
87
184
  puts "Executing #{script[:path]} at #{current_time}"
88
185
  absolute_path = File.expand_path(script[:path], __dir__)
@@ -90,5 +187,6 @@ module Bas
90
187
  system("ruby", absolute_path)
91
188
  end
92
189
  end
190
+ # rubocop:enable Metrics/ClassLength
93
191
  end
94
192
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "types/read"
5
+ require_relative "../utils/elasticsearch/request"
6
+ require_relative "../version"
7
+
8
+ module Bas
9
+ module SharedStorage
10
+ ##
11
+ # The SharedStorage::Elasticsearch class serves as a shared storage implementation to read and write on
12
+ # a shared storage defined as a elasticsearch database
13
+ #
14
+ class Elasticsearch < Bas::SharedStorage::Base
15
+ def read
16
+ params = {
17
+ connection: read_options[:connection], index: read_options[:index],
18
+ query: read_body, method: :search
19
+ }
20
+
21
+ result = Utils::Elasticsearch::Request.execute(params)
22
+ @read_response = build_read_response(result)
23
+ end
24
+
25
+ def write(data)
26
+ params = {
27
+ connection: write_options[:connection], index: write_options[:index],
28
+ body: write_body(data), method: :index
29
+ }
30
+
31
+ create_mapping
32
+ @write_response = Utils::Elasticsearch::Request.execute(params).body
33
+ end
34
+
35
+ def set_in_process
36
+ return if read_options[:avoid_process].eql?(true) || read_response.id.nil?
37
+
38
+ update_stage(read_response.id, "in process")
39
+ end
40
+
41
+ def set_processed
42
+ return if read_options[:avoid_process].eql?(true) || read_response.id.nil?
43
+
44
+ update_stage(read_response.id, "processed")
45
+ end
46
+
47
+ private
48
+
49
+ # rubocop:disable Metrics/MethodLength
50
+ def create_mapping
51
+ short_text_properties = {
52
+ type: "text",
53
+ fields: {
54
+ keyword: { type: "keyword", ignore_above: 256 }
55
+ }
56
+ }
57
+
58
+ params = {
59
+ connection: write_options[:connection],
60
+ index: write_options[:index],
61
+ body: {
62
+ mappings: {
63
+ properties: {
64
+ data: { type: "object" },
65
+ tag: short_text_properties,
66
+ archived: { type: "boolean" },
67
+ inserted_at: { type: "date", format: "yyyy-MM-dd HH:mm:ss Z" },
68
+ stage: short_text_properties,
69
+ status: short_text_properties,
70
+ error_message: { type: "object" },
71
+ version: short_text_properties
72
+ }
73
+ }
74
+ },
75
+ method: :create_mapping
76
+ }
77
+
78
+ Utils::Elasticsearch::Request.execute(params)
79
+ end
80
+
81
+ def read_body
82
+ return read_options[:query] if read_options[:query].is_a?(Hash)
83
+
84
+ {
85
+ query: {
86
+ bool: {
87
+ must: [
88
+ { match: { status: "success" } }, { match: { tag: read_options[:tag] } },
89
+ { match: { archived: false } }, { match: { stage: "unprocessed" } }
90
+ ]
91
+ }
92
+ },
93
+ sort: [{ inserted_at: { order: "asc" } }]
94
+ }
95
+ end
96
+
97
+ def write_body(data)
98
+ if data[:success]
99
+ return {
100
+ data: data[:success], tag: write_options[:tag],
101
+ archived: false, inserted_at: Time.now.strftime("%Y-%m-%d %H:%M:%S %z"),
102
+ stage: "unprocessed", status: "success", error_message: nil, version: Bas::VERSION
103
+ }
104
+ end
105
+
106
+ {
107
+ data: nil, tag: write_options[:tag], archived: false,
108
+ inserted_at: Time.now.strftime("%Y-%m-%d %H:%M:%S %z"), stage: "unprocessed",
109
+ status: "failed", error_message: data[:error], version: Bas::VERSION
110
+ }
111
+ end
112
+ # rubocop:enable Metrics/MethodLength
113
+
114
+ def build_read_response(result)
115
+ first_hit = result["hits"]["hits"].empty? ? {} : result["hits"]["hits"].first
116
+ Bas::SharedStorage::Types::Read.new(
117
+ first_hit["_id"], first_hit["_source"]["data"].to_json, first_hit.dig("_source", "inserted_at")
118
+ )
119
+ end
120
+
121
+ def update_stage(id, stage)
122
+ params = {
123
+ connection: read_options[:connection], index: read_options[:index], method: :update,
124
+ body: {
125
+ query: { ids: { values: [id] } },
126
+ script: { source: "ctx._source.stage = params.new_value", params: { new_value: stage } }
127
+ }
128
+ }
129
+
130
+ response = Utils::Elasticsearch::Request.execute(params)
131
+ return unless response["updated"].zero?
132
+
133
+ raise StandardError, "Document #{id} not found, so it was not updated"
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "elasticsearch"
4
+
5
+ module Utils
6
+ module Elasticsearch
7
+ ##
8
+ # This module is a ElasticsearchDB utility to make requests to a Elasticsearch database.
9
+ #
10
+ module Request
11
+ # Implements the request process logic to the ElasticsearchDB index.
12
+ #
13
+ # <br>
14
+ # <b>Params:</b>
15
+ # * <tt>connection</tt> Connection parameters to the database: `host`, `port`, `index`, `user`, `password`.
16
+ # * <b>query</b>:
17
+ # * <tt>String</tt>: String with the Elasticsearch query to be executed.
18
+ # * <tt>Hash</tt>: Hash with the Elasticsearch query to be executed.
19
+ # * <tt>method</tt>: Method to be executed.
20
+ # Allowed methods are: `:search`, `:index`, `:update`, `:create_mapping`.
21
+ #
22
+ # <br>
23
+ # <b>returns</b> <tt>Elasticsearch::Response</tt>
24
+ #
25
+ class << self
26
+ def execute(params)
27
+ client = ::Elasticsearch::Client.new(
28
+ host: params[:connection][:host],
29
+ port: params[:connection][:port],
30
+ user: params[:connection][:user],
31
+ password: params[:connection][:password],
32
+ api_versioning: false,
33
+ transport_options: build_ssl_options(params[:connection])
34
+ )
35
+
36
+ perform_request(params, client)
37
+ end
38
+
39
+ private
40
+
41
+ def build_ssl_options(connection_params)
42
+ ssl_options = {}
43
+ ssl_options[:ca_file] = connection_params[:ca_file] if connection_params[:ca_file]
44
+ ssl_options[:verify] = connection_params[:ssl_verify] if connection_params.key?(:ssl_verify)
45
+ { ssl: ssl_options }
46
+ end
47
+
48
+ def perform_request(params, client)
49
+ case params[:method]
50
+ when :index
51
+ index_document(params, client)
52
+ when :search
53
+ search(params, client)
54
+ when :update
55
+ update_documents(params, client)
56
+ when :create_mapping
57
+ create_mapping(params, client)
58
+ end
59
+ end
60
+
61
+ def search(params, client)
62
+ search_params = { index: params[:index] }
63
+ search_params[:size] = 1 # return only one document
64
+ if params[:query].is_a?(Hash)
65
+ search_params[:body] = params[:query]
66
+ else
67
+ search_params[:q] = params[:query]
68
+ end
69
+
70
+ client.search(**search_params)
71
+ end
72
+
73
+ def index_document(params, client)
74
+ client.index(index: params[:index], body: params[:body])
75
+ end
76
+
77
+ def update_documents(params, client)
78
+ client.update_by_query(index: params[:index], body: params[:body], wait_for_completion: true, refresh: true)
79
+ end
80
+
81
+ def create_mapping(params, client)
82
+ return if client.indices.exists?(index: params[:index])
83
+
84
+ client.indices.create(index: params[:index], body: params[:body])
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Bas
7
+ module Utils
8
+ module Operaton
9
+ # Client for interacting with Operaton's External Task API
10
+ #
11
+ # This client provides methods to manage external task lifecycle including:
12
+ # - Fetching and locking tasks
13
+ # - Completing tasks with variables
14
+ # - Unlocking tasks
15
+ # - Reporting task failures
16
+ #
17
+ # @example
18
+ # client = ExternalTaskClient.new(base_url: "https://api.operaton.com", worker_id: "worker-123")
19
+ # tasks = client.fetch_and_lock("my-topic")
20
+ class ExternalTaskClient
21
+ def initialize(base_url:, worker_id:)
22
+ raise ArgumentError, "base_url cannot be nil or empty" if base_url.nil? || base_url.empty?
23
+ raise ArgumentError, "worker_id cannot be nil or empty" if worker_id.nil? || worker_id.empty?
24
+
25
+ @base_url = base_url
26
+ @worker_id = worker_id
27
+
28
+ @conn = Faraday.new(url: base_url) do |f|
29
+ f.request :json
30
+ f.response :json, content_type: /\bjson$/
31
+ f.adapter Faraday.default_adapter
32
+ end
33
+ end
34
+
35
+ def fetch_and_lock(topics_str, lock_duration: 10_000, max_tasks: 1, use_priority: true, variables: [])
36
+ post("/external-task/fetchAndLock",
37
+ workerId: @worker_id,
38
+ maxTasks: max_tasks,
39
+ usePriority: use_priority,
40
+ topics: build_topics_payload(topics_str, lock_duration, variables))
41
+ end
42
+
43
+ def complete(task_id, variables = {})
44
+ post("/external-task/#{task_id}/complete", workerId: @worker_id,
45
+ variables: format_variables(variables))
46
+ end
47
+
48
+ def get_variables(task_id)
49
+ get("/external-task/#{task_id}/variables")
50
+ end
51
+
52
+ def unlock(task_id)
53
+ post("/external-task/#{task_id}/unlock")
54
+ end
55
+
56
+ def report_failure(task_id, error_message:, error_details:, retries:, retry_timeout:)
57
+ post("/external-task/#{task_id}/failure",
58
+ workerId: @worker_id,
59
+ errorMessage: error_message,
60
+ errorDetails: error_details,
61
+ retries: retries,
62
+ retryTimeout: retry_timeout)
63
+ end
64
+
65
+ private
66
+
67
+ def build_topics_payload(topics_str, lock_duration, variables)
68
+ topic_names = topics_str.is_a?(Array) ? topics_str : topics_str.to_s.split(",")
69
+ topic_names.map do |name|
70
+ {
71
+ topicName: name.strip,
72
+ lockDuration: lock_duration,
73
+ variables: variables
74
+ }
75
+ end
76
+ end
77
+
78
+ def full_url(path)
79
+ "#{@base_url}#{path}"
80
+ end
81
+
82
+ def post(path, body = {})
83
+ handle_response(@conn.post(full_url(path), body))
84
+ end
85
+
86
+ def get(path, params = {})
87
+ handle_response(@conn.get(full_url(path), params))
88
+ end
89
+
90
+ def handle_response(response)
91
+ raise "Operaton API Error #{response.status}: #{response.body}" unless response.success?
92
+
93
+ response.body
94
+ end
95
+
96
+ def format_variables(vars)
97
+ vars.transform_values do |value|
98
+ {
99
+ value: value,
100
+ type: ruby_type_to_operaton_type(value)
101
+ }
102
+ end
103
+ end
104
+
105
+ def ruby_type_to_operaton_type(value)
106
+ case value
107
+ when String then "String"
108
+ when Integer then "Integer"
109
+ when Float then "Double"
110
+ when TrueClass, FalseClass then "Boolean"
111
+ else "Object"
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
data/lib/bas/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Bas
4
4
  # Gem version
5
- VERSION = "1.7.1"
5
+ VERSION = "1.8.0"
6
6
  end
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bas
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kommitters Open Source
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-02-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: elasticsearch
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: httparty
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -65,11 +79,13 @@ files:
65
79
  - lib/bas/orchestrator/manager.rb
66
80
  - lib/bas/shared_storage/base.rb
67
81
  - lib/bas/shared_storage/default.rb
82
+ - lib/bas/shared_storage/elasticsearch.rb
68
83
  - lib/bas/shared_storage/postgres.rb
69
84
  - lib/bas/shared_storage/types/read.rb
70
85
  - lib/bas/utils/digital_ocean/request.rb
71
86
  - lib/bas/utils/discord/integration.rb
72
87
  - lib/bas/utils/discord/request.rb
88
+ - lib/bas/utils/elasticsearch/request.rb
73
89
  - lib/bas/utils/exceptions/function_not_implemented.rb
74
90
  - lib/bas/utils/exceptions/invalid_process_response.rb
75
91
  - lib/bas/utils/github/octokit_client.rb
@@ -82,6 +98,7 @@ files:
82
98
  - lib/bas/utils/notion/update_db_page.rb
83
99
  - lib/bas/utils/notion/update_db_state.rb
84
100
  - lib/bas/utils/openai/run_assistant.rb
101
+ - lib/bas/utils/operaton/external_task_client.rb
85
102
  - lib/bas/utils/postgres/request.rb
86
103
  - lib/bas/version.rb
87
104
  - renovate.json
@@ -106,7 +123,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
106
123
  - !ruby/object:Gem::Version
107
124
  version: '0'
108
125
  requirements: []
109
- rubygems_version: 3.6.2
126
+ rubygems_version: 3.6.7
110
127
  specification_version: 4
111
128
  summary: BAS - Business automation suite
112
129
  test_files: []