floe 0.14.0 → 0.15.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: e0e0b86c08d322e2ebc8e115e5193d0493f8257c831f7377b18c32d4284ae6f2
4
- data.tar.gz: 1bf5ed62abafe2e1af6025dbaf47d9faa3b1c93ffec8fd88fbeacd4445efc795
3
+ metadata.gz: 75d5be2f5b9cdcfc64b4b3994d32b9e3955486fd99bf4e05c62c2414485188c8
4
+ data.tar.gz: 7257009d5942f157f2ccef6499c77e1f9033e0ac4846a08f3f589943bd4794ef
5
5
  SHA512:
6
- metadata.gz: 34f64555f2472c121fcea9dda8baac7fbe853cdbc5e949c831d7b7f070878d3ef2f86ed90d907b5a7033539fcad6aeea64a4dee6475a752d7f89e4f679a4b269
7
- data.tar.gz: 378c5bcd562d4bbeb7e3eb13eb55b1d5d8e7aed0ea4749c2b65f951cf3be0e5f13d3e288cdbf63b4a5729e98c5010656d6dca3db60ffbf8463988008980edd83
6
+ metadata.gz: bd9275c7e845841fc472e3e0ec92593354c6293bae3c8dc9d7258acb71fcd5db7abaca2c2aadcc4c7701952b65eae8a14da29c1e8e26d71a7d9df2c6aad9abeb
7
+ data.tar.gz: 8cc089f45244d80428d92feeeb162d8c65fafcea915e00405c54f5340dee4f9106113ad87e36c97a18fd159c3d338bee0f78869e91d200f19b145df494e5edfd
data/.codeclimate.yml CHANGED
@@ -1,3 +1,4 @@
1
+ version: '2'
1
2
  prepare:
2
3
  fetch:
3
4
  - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/.rubocop_base.yml
@@ -8,9 +9,28 @@ prepare:
8
9
  path: styles/base.yml
9
10
  - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/styles/cc_base.yml
10
11
  path: styles/cc_base.yml
12
+ checks:
13
+ argument-count:
14
+ enabled: false
15
+ complex-logic:
16
+ enabled: false
17
+ file-lines:
18
+ enabled: false
19
+ method-complexity:
20
+ config:
21
+ threshold: 11
22
+ method-count:
23
+ enabled: false
24
+ method-lines:
25
+ enabled: false
26
+ nested-control-flow:
27
+ enabled: false
28
+ return-statements:
29
+ enabled: false
11
30
  plugins:
12
31
  rubocop:
13
32
  enabled: true
14
33
  config: ".rubocop_cc.yml"
15
34
  channel: rubocop-1-56-3
16
- version: '2'
35
+ exclude_patterns:
36
+ - spec/
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ This project adheres to [Semantic Versioning](http://semver.org/).
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.15.0] - 2024-10-28
8
+ ### Added
9
+ - Add WorkflowBase base class for Workflow ([#279](https://github.com/ManageIQ/floe/pull/279))
10
+ - Add tool for using the aws stepfunctions simulator ([#244](https://github.com/ManageIQ/floe/pull/244))
11
+ - Implement Map state ([#184](https://github.com/ManageIQ/floe/pull/184))
12
+ - Add Map State Tolerated Failure ([#282](https://github.com/ManageIQ/floe/pull/282))
13
+ - Run Map iterations in parallel up to MaxConcurrency ([#283](https://github.com/ManageIQ/floe/pull/283))
14
+ - Implement Parallel State ([#291](https://github.com/ManageIQ/floe/pull/291))
15
+
16
+ ### Changed
17
+ - More granular compare_key and determine path at initialization time ([#274](https://github.com/ManageIQ/floe/pull/274))
18
+ - For Choice validation, use instance variables and not payload ([#277](https://github.com/ManageIQ/floe/pull/277))
19
+ - Return ExceedToleratedFailureThreshold if ToleratedFailureCount/Percentage is present ([#285](https://github.com/ManageIQ/floe/pull/285))
20
+
21
+ ### Fixed
22
+ - Fix case on log messages ([#280](https://github.com/ManageIQ/floe/pull/280))
23
+ - Handle either ToleratedFailureCount or ToleratedFailurePercentage ([#284](https://github.com/ManageIQ/floe/pull/284))
24
+
7
25
  ## [0.14.0] - 2024-08-20
8
26
  ### Added
9
27
  - Implement "IsNumeric": false ([#266](https://github.com/ManageIQ/floe/pull/266))
data/README.md CHANGED
@@ -197,6 +197,14 @@ Options supported by the kubernetes docker runner are:
197
197
  * `ca_file` - Path to a certificate-authority file for the kubernetes API, only valid if server and token are passed. If present `/run/secrets/kubernetes.io/serviceaccount/ca.crt` will be used
198
198
  * `verify_ssl` - Controls if the kubernetes API certificate-authority should be verified, defaults to "true", only vaild if server and token are passed
199
199
 
200
+ ## Features Not Yet Supported
201
+
202
+ The following are not yet supported:
203
+ - Map State Fields:
204
+ - ItemReader
205
+ - ItemSelector/ItemBatcher
206
+ - ResultWriter
207
+
200
208
  ## Development
201
209
 
202
210
  After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/examples/map.asl ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "Comment": "Using Map state in Inline mode",
3
+ "StartAt": "Pass",
4
+ "States": {
5
+ "Pass": {
6
+ "Type": "Pass",
7
+ "Next": "Map demo",
8
+ "Result": {
9
+ "foo": "bar",
10
+ "colors": [
11
+ "red",
12
+ "green",
13
+ "blue",
14
+ "yellow",
15
+ "white"
16
+ ]
17
+ }
18
+ },
19
+ "Map demo": {
20
+ "Type": "Map",
21
+ "ItemsPath": "$.colors",
22
+ "MaxConcurrency": 2,
23
+ "ItemProcessor": {
24
+ "ProcessorConfig": {
25
+ "Mode": "INLINE"
26
+ },
27
+ "StartAt": "Generate UUID",
28
+ "States": {
29
+ "Generate UUID": {
30
+ "Type": "Pass",
31
+ "Next": "Sleep",
32
+ "Parameters": {
33
+ "uuid.$": "States.UUID()"
34
+ }
35
+ },
36
+ "Sleep": {
37
+ "Type": "Task",
38
+ "Resource": "docker://docker.io/agrare/sleep:latest",
39
+ "End": true
40
+ }
41
+ }
42
+ },
43
+ "End": true
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "Comment": "Parallel Example.",
3
+ "StartAt": "FunWithMath",
4
+ "States": {
5
+ "FunWithMath": {
6
+ "Type": "Parallel",
7
+ "End": true,
8
+ "Branches": [
9
+ {
10
+ "StartAt": "Add",
11
+ "States": {
12
+ "Add": {
13
+ "Type": "Task",
14
+ "Resource": "docker://docker.io/agrare/sleep:latest",
15
+ "End": true
16
+ }
17
+ }
18
+ },
19
+ {
20
+ "StartAt": "Subtract",
21
+ "States": {
22
+ "Subtract": {
23
+ "Type": "Task",
24
+ "Resource": "docker://docker.io/agrare/sleep:latest",
25
+ "End": true
26
+ }
27
+ }
28
+ }
29
+ ]
30
+ }
31
+ }
32
+ }
data/lib/floe/cli.rb CHANGED
@@ -13,17 +13,11 @@ module Floe
13
13
  def run(args = ARGV)
14
14
  workflows_inputs, opts = parse_options!(args)
15
15
 
16
- credentials =
17
- if opts[:credentials_given]
18
- opts[:credentials] == "-" ? $stdin.read : opts[:credentials]
19
- elsif opts[:credentials_file_given]
20
- File.read(opts[:credentials_file])
21
- end
16
+ credentials = create_credentials(opts)
22
17
 
23
18
  workflows =
24
19
  workflows_inputs.each_slice(2).map do |workflow, input|
25
- context = Floe::Workflow::Context.new(opts[:context], :input => input, :credentials => credentials)
26
- Floe::Workflow.load(workflow, context)
20
+ create_workflow(workflow, opts[:context], input, credentials)
27
21
  end
28
22
 
29
23
  Floe::Workflow.wait(workflows, &:run_nonblock)
@@ -82,5 +76,18 @@ module Floe
82
76
 
83
77
  return workflows_inputs, opts
84
78
  end
79
+
80
+ def create_credentials(opts)
81
+ if opts[:credentials_given]
82
+ opts[:credentials] == "-" ? $stdin.read : opts[:credentials]
83
+ elsif opts[:credentials_file_given]
84
+ File.read(opts[:credentials_file])
85
+ end
86
+ end
87
+
88
+ def create_workflow(workflow, context_payload, input, credentials)
89
+ context = Floe::Workflow::Context.new(context_payload, :input => input, :credentials => credentials)
90
+ Floe::Workflow.load(workflow, context)
91
+ end
85
92
  end
86
93
  end
data/lib/floe/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Floe
4
- VERSION = "0.14.0"
4
+ VERSION = "0.15.0"
5
5
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class Branch < Floe::WorkflowBase
6
+ end
7
+ end
8
+ end
@@ -4,16 +4,20 @@ module Floe
4
4
  class Workflow
5
5
  class ChoiceRule
6
6
  class Data < Floe::Workflow::ChoiceRule
7
- COMPARE_KEYS = %w[IsNull IsPresent IsNumeric IsString IsBoolean IsTimestamp String Numeric Boolean Timestamp].freeze
7
+ TYPES = ["String", "Numeric", "Boolean", "Timestamp", "Present", "Null"].freeze
8
+ COMPARES = ["Equals", "LessThan", "GreaterThan", "LessThanEquals", "GreaterThanEquals", "Matches"].freeze
9
+ # e.g.: (Is)(String), (Is)(Present)
10
+ TYPE_CHECK = /^(Is)(#{TYPES.join("|")})$/.freeze
11
+ # e.g.: (String)(LessThan)(Path), (Numeric)(GreaterThanEquals)()
12
+ OPERATION = /^(#{(TYPES - %w[Null Present]).join("|")})(#{COMPARES.join("|")})(Path)?$/.freeze
8
13
 
9
- attr_reader :variable, :compare_key, :value, :path
14
+ attr_reader :variable, :compare_key, :type, :compare_predicate, :path
10
15
 
11
16
  def initialize(_workflow, _name, payload)
12
17
  super
13
18
 
14
- @variable = parse_path("Variable", payload)
19
+ @variable = parse_path("Variable")
15
20
  parse_compare_key
16
- @value = path ? parse_path(compare_key, payload) : payload[compare_key]
17
21
  end
18
22
 
19
23
  def true?(context, input)
@@ -108,26 +112,74 @@ module Floe
108
112
  # rubocop:enable Naming/PredicateName
109
113
  # rubocop:enable Style/OptionalBooleanParameter
110
114
 
115
+ # parse the compare key at initialization time
111
116
  def parse_compare_key
112
- @compare_key = payload.keys.detect { |key| key.match?(/^(#{COMPARE_KEYS.join("|")})/) }
117
+ payload.each_key do |key|
118
+ # e.g. (String)(GreaterThan)(Path)
119
+ if (match_values = OPERATION.match(key))
120
+ @compare_key = key
121
+ @type, _operator, @path = match_values.captures
122
+ @compare_predicate = parse_predicate(type)
123
+ break
124
+ end
125
+ # e.g. (Is)(String)
126
+ if TYPE_CHECK.match?(key)
127
+ @compare_key = key
128
+ # type: nil means no runtime type checking.
129
+ @type = @path = nil
130
+ @compare_predicate = parse_predicate("Boolean")
131
+ break
132
+ end
133
+ end
113
134
  parser_error!("requires a compare key") unless compare_key
135
+ end
114
136
 
115
- @path = compare_key.end_with?("Path")
137
+ # parse predicate at initilization time
138
+ # @return the right predicate attached to the compare key
139
+ def parse_predicate(data_type)
140
+ path ? parse_path(compare_key) : parse_field(compare_key, data_type)
116
141
  end
117
142
 
143
+ # @return right hand predicate - input path or static payload value)
118
144
  def compare_value(context, input)
119
- path ? value.value(context, input) : value
145
+ path ? fetch_path(compare_key, compare_predicate, context, input) : compare_predicate
120
146
  end
121
147
 
148
+ # feth the variable value at runtime
149
+ # @return variable value (left hand side )
122
150
  def variable_value(context, input)
123
- variable.value(context, input)
151
+ fetch_path("Variable", variable, context, input)
124
152
  end
125
153
 
126
- def parse_path(field_name, payload)
154
+ # parse path at initilization time
155
+ # helper method to parse a path from the payload
156
+ def parse_path(field_name)
127
157
  value = payload[field_name]
128
158
  missing_field_error!(field_name) unless value
129
159
  wrap_parser_error(field_name, value) { Path.new(value) }
130
160
  end
161
+
162
+ # parse predicate field at initialization time
163
+ def parse_field(field_name, data_type)
164
+ value = payload[field_name]
165
+ return value if correct_type?(value, data_type)
166
+
167
+ invalid_field_error!(field_name, value, "required to be a #{data_type}")
168
+ end
169
+
170
+ # fetch a path at runtime
171
+ def fetch_path(field_name, field_path, context, input)
172
+ value = field_path.value(context, input)
173
+ return value if type.nil? || correct_type?(value, type)
174
+
175
+ runtime_field_error!(field_name, field_path.to_s, "required to point to a #{type}")
176
+ end
177
+
178
+ # if we have runtime checking, check against that type
179
+ # otherwise assume checking a TYPE_CHECK predicate and check against Boolean
180
+ def correct_type?(value, data_type)
181
+ send("is_#{data_type.downcase}?".to_sym, value)
182
+ end
131
183
  end
132
184
  end
133
185
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class ItemProcessor < Floe::WorkflowBase
6
+ attr_reader :processor_config
7
+
8
+ def initialize(payload, name = nil)
9
+ super
10
+ @processor_config = payload.fetch("ProcessorConfig", "INLINE")
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ module States
6
+ module ChildWorkflowMixin
7
+ def run_nonblock!(context)
8
+ start(context) unless context.state_started?
9
+
10
+ step_nonblock!(context) while running?(context)
11
+ return Errno::EAGAIN unless ready?(context)
12
+
13
+ finish(context) if ended?(context)
14
+ end
15
+
16
+ def finish(context)
17
+ if success?(context)
18
+ result = each_child_context(context).map(&:output)
19
+ context.output = process_output(context, result)
20
+ else
21
+ error = parse_error(context)
22
+ retry_state!(context, error) || catch_error!(context, error) || fail_workflow!(context, error)
23
+ end
24
+
25
+ super
26
+ end
27
+
28
+ def ready?(context)
29
+ !context.state_started? || each_child_workflow(context).any? { |wf, ctx| wf.step_nonblock_ready?(ctx) }
30
+ end
31
+
32
+ def wait_until(context)
33
+ each_child_workflow(context).filter_map { |wf, ctx| wf.wait_until(ctx) }.min
34
+ end
35
+
36
+ def waiting?(context)
37
+ each_child_workflow(context).any? { |wf, ctx| wf.waiting?(ctx) }
38
+ end
39
+
40
+ def running?(context)
41
+ !ended?(context)
42
+ end
43
+
44
+ def ended?(context)
45
+ each_child_context(context).all?(&:ended?)
46
+ end
47
+
48
+ def success?(context)
49
+ each_child_context(context).none?(&:failed?)
50
+ end
51
+
52
+ def each_child_context(context)
53
+ context.state[child_context_key].map { |ctx| Context.new(ctx) }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -9,10 +9,9 @@ module Floe
9
9
  def initialize(workflow, name, payload)
10
10
  super
11
11
 
12
- validate_state!(workflow)
13
-
14
- @choices = payload["Choices"].map.with_index { |choice, i| ChoiceRule.build(workflow, name + ["Choices", i.to_s], choice) }
12
+ @choices = payload["Choices"]&.map&.with_index { |choice, i| ChoiceRule.build(workflow, name + ["Choices", i.to_s], choice) }
15
13
  @default = payload["Default"]
14
+ validate_state!(workflow)
16
15
 
17
16
  @input_path = Path.new(payload.fetch("InputPath", "$"))
18
17
  @output_path = Path.new(payload.fetch("OutputPath", "$"))
@@ -45,12 +44,12 @@ module Floe
45
44
  end
46
45
 
47
46
  def validate_state_choices!
48
- missing_field_error!("Choices") unless payload.key?("Choices")
49
- invalid_field_error!("Choices", nil, "must be a non-empty array") unless payload["Choices"].kind_of?(Array) && !payload["Choices"].empty?
47
+ missing_field_error!("Choices") if @choices.nil?
48
+ invalid_field_error!("Choices", nil, "must be a non-empty array") unless @choices.kind_of?(Array) && !@choices.empty?
50
49
  end
51
50
 
52
51
  def validate_state_default!(workflow)
53
- invalid_field_error!("Default", payload["Default"], "is not found in \"States\"") if payload["Default"] && !workflow_state?(payload["Default"], workflow)
52
+ invalid_field_error!("Default", @default, "is not found in \"States\"") if @default && !workflow_state?(@default, workflow)
54
53
  end
55
54
  end
56
55
  end
@@ -4,9 +4,123 @@ module Floe
4
4
  class Workflow
5
5
  module States
6
6
  class Map < Floe::Workflow::State
7
- def initialize(*)
7
+ include ChildWorkflowMixin
8
+ include InputOutputMixin
9
+ include NonTerminalMixin
10
+ include RetryCatchMixin
11
+
12
+ attr_reader :end, :next, :parameters, :input_path, :output_path, :result_path,
13
+ :result_selector, :retry, :catch, :item_processor, :items_path,
14
+ :item_reader, :item_selector, :item_batcher, :result_writer,
15
+ :max_concurrency, :tolerated_failure_percentage, :tolerated_failure_count
16
+
17
+ def initialize(workflow, name, payload)
18
+ super
19
+
20
+ missing_field_error!("InputProcessor") if payload["ItemProcessor"].nil?
21
+
22
+ @next = payload["Next"]
23
+ @end = !!payload["End"]
24
+ @parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
25
+ @input_path = Path.new(payload.fetch("InputPath", "$"))
26
+ @output_path = Path.new(payload.fetch("OutputPath", "$"))
27
+ @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
28
+ @result_selector = PayloadTemplate.new(payload["ResultSelector"]) if payload["ResultSelector"]
29
+ @retry = payload["Retry"].to_a.map { |retrier| Retrier.new(retrier) }
30
+ @catch = payload["Catch"].to_a.map { |catcher| Catcher.new(catcher) }
31
+ @item_processor = ItemProcessor.new(payload["ItemProcessor"], name)
32
+ @items_path = ReferencePath.new(payload.fetch("ItemsPath", "$"))
33
+ @item_reader = payload["ItemReader"]
34
+ @item_selector = payload["ItemSelector"]
35
+ @item_batcher = payload["ItemBatcher"]
36
+ @result_writer = payload["ResultWriter"]
37
+ @max_concurrency = payload["MaxConcurrency"]&.to_i
38
+ @tolerated_failure_percentage = payload["ToleratedFailurePercentage"]&.to_i
39
+ @tolerated_failure_count = payload["ToleratedFailureCount"]&.to_i
40
+
41
+ validate_state!(workflow)
42
+ end
43
+
44
+ def process_input(context)
45
+ input = super
46
+ items_path.value(context, input)
47
+ end
48
+
49
+ def start(context)
8
50
  super
9
- raise NotImplementedError
51
+
52
+ input = process_input(context)
53
+
54
+ context.state["ItemProcessorContext"] = input.map { |item| Context.new({"Execution" => {"Id" => context.execution["Id"]}}, :input => item.to_json).to_h }
55
+ end
56
+
57
+ def end?
58
+ @end
59
+ end
60
+
61
+ def success?(context)
62
+ contexts = each_child_context(context)
63
+ num_failed = contexts.count(&:failed?)
64
+ total = contexts.count
65
+
66
+ return true if num_failed.zero? || total.zero?
67
+ return false if tolerated_failure_count.nil? && tolerated_failure_percentage.nil?
68
+
69
+ # Some have failed, check the tolerated_failure thresholds to see if
70
+ # we should fail the whole state.
71
+ #
72
+ # If either ToleratedFailureCount or ToleratedFailurePercentage are breached
73
+ # then the whole state is considered failed.
74
+ count_tolerated = tolerated_failure_count.nil? || num_failed < tolerated_failure_count
75
+ pct_tolerated = tolerated_failure_percentage.nil? || tolerated_failure_percentage == 100 ||
76
+ ((100 * num_failed / total.to_f) < tolerated_failure_percentage)
77
+
78
+ count_tolerated && pct_tolerated
79
+ end
80
+
81
+ private
82
+
83
+ def step_nonblock!(context)
84
+ each_child_context(context).each do |ctx|
85
+ # If this iteration isn't already running and we can't start any more
86
+ next if !ctx.started? && concurrency_exceeded?(context)
87
+
88
+ item_processor.run_nonblock(ctx) if item_processor.step_nonblock_ready?(ctx)
89
+ end
90
+ end
91
+
92
+ def each_child_workflow(context)
93
+ each_child_context(context).map do |ctx|
94
+ [item_processor, Context.new(ctx)]
95
+ end
96
+ end
97
+
98
+ def concurrency_exceeded?(context)
99
+ max_concurrency && num_running(context) >= max_concurrency
100
+ end
101
+
102
+ def num_running(context)
103
+ each_child_context(context).count(&:running?)
104
+ end
105
+
106
+ def parse_error(context)
107
+ # If ToleratedFailureCount or ToleratedFailurePercentage is present
108
+ # then use States.ExceedToleratedFailureThreshold otherwise
109
+ # take the error from the first failed state
110
+ if tolerated_failure_count || tolerated_failure_percentage
111
+ {"Error" => "States.ExceedToleratedFailureThreshold"}
112
+ else
113
+ each_child_context(context).detect(&:failed?)&.output || {"Error" => "States.Error"}
114
+ end
115
+ end
116
+
117
+ def child_context_key
118
+ "ItemProcessorContext"
119
+ end
120
+
121
+ def validate_state!(workflow)
122
+ validate_state_next!(workflow)
123
+ invalid_field_error!("MaxConcurrency", @max_concurrency, "must be greater than 0") if @max_concurrency && @max_concurrency <= 0
10
124
  end
11
125
  end
12
126
  end
@@ -4,9 +4,72 @@ module Floe
4
4
  class Workflow
5
5
  module States
6
6
  class Parallel < Floe::Workflow::State
7
- def initialize(*)
7
+ include ChildWorkflowMixin
8
+ include InputOutputMixin
9
+ include NonTerminalMixin
10
+ include RetryCatchMixin
11
+
12
+ attr_reader :end, :next, :parameters, :input_path, :output_path, :result_path,
13
+ :result_selector, :retry, :catch, :branches
14
+
15
+ def initialize(workflow, name, payload)
16
+ super
17
+
18
+ missing_field_error!("Branches") if payload["Branches"].nil?
19
+
20
+ @next = payload["Next"]
21
+ @end = !!payload["End"]
22
+ @parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
23
+ @input_path = Path.new(payload.fetch("InputPath", "$"))
24
+ @output_path = Path.new(payload.fetch("OutputPath", "$"))
25
+ @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
26
+ @result_selector = PayloadTemplate.new(payload["ResultSelector"]) if payload["ResultSelector"]
27
+ @retry = payload["Retry"].to_a.map { |retrier| Retrier.new(retrier) }
28
+ @catch = payload["Catch"].to_a.map { |catcher| Catcher.new(catcher) }
29
+ @branches = payload["Branches"].map { |branch| Branch.new(branch) }
30
+
31
+ validate_state!(workflow)
32
+ end
33
+
34
+ def start(context)
8
35
  super
9
- raise NotImplementedError
36
+
37
+ input = process_input(context)
38
+
39
+ context.state["BranchContext"] = branches.map { |_branch| Context.new({"Execution" => {"Id" => context.execution["Id"]}}, :input => input.to_json).to_h }
40
+ end
41
+
42
+ def end?
43
+ @end
44
+ end
45
+
46
+ private
47
+
48
+ def step_nonblock!(context)
49
+ each_child_workflow(context).each do |wf, ctx|
50
+ wf.run_nonblock(ctx) if wf.step_nonblock_ready?(ctx)
51
+ end
52
+ end
53
+
54
+ def each_child_workflow(context)
55
+ branches.filter_map.with_index do |branch, i|
56
+ ctx = context.state.dig("BranchContext", i)
57
+ next if ctx.nil?
58
+
59
+ [branch, Context.new(ctx)]
60
+ end
61
+ end
62
+
63
+ def parse_error(context)
64
+ each_child_context(context).detect(&:failed?)&.output || {"Error" => "States.Error"}
65
+ end
66
+
67
+ def child_context_key
68
+ "BranchContext"
69
+ end
70
+
71
+ def validate_state!(workflow)
72
+ validate_state_next!(workflow)
10
73
  end
11
74
  end
12
75
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ module States
6
+ module RetryCatchMixin
7
+ def find_retrier(error)
8
+ self.retry.detect { |r| r.match_error?(error) }
9
+ end
10
+
11
+ def find_catcher(error)
12
+ self.catch.detect { |c| c.match_error?(error) }
13
+ end
14
+
15
+ def retry_state!(context, error)
16
+ retrier = find_retrier(error["Error"]) if error
17
+ return if retrier.nil?
18
+
19
+ # If a different retrier is hit reset the context
20
+ if !context["State"].key?("RetryCount") || context["State"]["Retrier"] != retrier.error_equals
21
+ context["State"]["RetryCount"] = 0
22
+ context["State"]["Retrier"] = retrier.error_equals
23
+ end
24
+
25
+ context["State"]["RetryCount"] += 1
26
+
27
+ return if context["State"]["RetryCount"] > retrier.max_attempts
28
+
29
+ wait_until!(context, :seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
30
+ context.next_state = context.state_name
31
+ context.output = error
32
+ logger.info("Running state: [#{long_name}] with input [#{context.json_input}] got error[#{context.json_output}]...Retry - delay: #{wait_until(context)}")
33
+ true
34
+ end
35
+
36
+ def catch_error!(context, error)
37
+ catcher = find_catcher(error["Error"]) if error
38
+ return if catcher.nil?
39
+
40
+ context.next_state = catcher.next
41
+ context.output = catcher.result_path.set(context.input, error)
42
+ logger.info("Running state: [#{long_name}] with input [#{context.json_input}]...CatchError - next state: [#{context.next_state}] output: [#{context.json_output}]")
43
+
44
+ true
45
+ end
46
+
47
+ def fail_workflow!(context, error)
48
+ # next_state is nil, and will be set to nil again in super
49
+ # keeping in here for completeness
50
+ context.next_state = nil
51
+ context.output = error
52
+ logger.error("Running state: [#{long_name}] with input [#{context.json_input}]...Complete workflow - output: [#{context.json_output}]")
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -6,6 +6,7 @@ module Floe
6
6
  class Task < Floe::Workflow::State
7
7
  include InputOutputMixin
8
8
  include NonTerminalMixin
9
+ include RetryCatchMixin
9
10
 
10
11
  attr_reader :credentials, :end, :heartbeat_seconds, :next, :parameters,
11
12
  :result_selector, :resource, :timeout_seconds, :retry, :catch,
@@ -82,54 +83,6 @@ module Floe
82
83
  runner.success?(context.state["RunnerContext"])
83
84
  end
84
85
 
85
- def find_retrier(error)
86
- self.retry.detect { |r| r.match_error?(error) }
87
- end
88
-
89
- def find_catcher(error)
90
- self.catch.detect { |c| c.match_error?(error) }
91
- end
92
-
93
- def retry_state!(context, error)
94
- retrier = find_retrier(error["Error"]) if error
95
- return if retrier.nil?
96
-
97
- # If a different retrier is hit reset the context
98
- if !context["State"].key?("RetryCount") || context["State"]["Retrier"] != retrier.error_equals
99
- context["State"]["RetryCount"] = 0
100
- context["State"]["Retrier"] = retrier.error_equals
101
- end
102
-
103
- context["State"]["RetryCount"] += 1
104
-
105
- return if context["State"]["RetryCount"] > retrier.max_attempts
106
-
107
- wait_until!(context, :seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
108
- context.next_state = context.state_name
109
- context.output = error
110
- logger.info("Running state: [#{long_name}] with input [#{context.json_input}] got error[#{context.json_output}]...Retry - delay: #{wait_until(context)}")
111
- true
112
- end
113
-
114
- def catch_error!(context, error)
115
- catcher = find_catcher(error["Error"]) if error
116
- return if catcher.nil?
117
-
118
- context.next_state = catcher.next
119
- context.output = catcher.result_path.set(context.input, error)
120
- logger.info("Running state: [#{long_name}] with input [#{context.json_input}]...CatchError - next state: [#{context.next_state}] output: [#{context.json_output}]")
121
-
122
- true
123
- end
124
-
125
- def fail_workflow!(context, error)
126
- # next_state is nil, and will be set to nil again in super
127
- # keeping in here for completeness
128
- context.next_state = nil
129
- context.output = error
130
- logger.error("Running state: [#{long_name}] with input [#{context.json_input}]...Complete workflow - output: [#{context.json_output}]")
131
- end
132
-
133
86
  def parse_error(output)
134
87
  return if output.nil?
135
88
  return output if output.kind_of?(Hash)
data/lib/floe/workflow.rb CHANGED
@@ -4,9 +4,8 @@ require "securerandom"
4
4
  require "json"
5
5
 
6
6
  module Floe
7
- class Workflow
7
+ class Workflow < Floe::WorkflowBase
8
8
  include Logging
9
- include ValidationMixin
10
9
 
11
10
  class << self
12
11
  def load(path_or_io, context = nil, credentials = {}, name = nil)
@@ -19,7 +18,7 @@ module Floe
19
18
 
20
19
  def wait(workflows, timeout: nil, &block)
21
20
  workflows = [workflows] if workflows.kind_of?(self)
22
- logger.info("checking #{workflows.count} workflows...")
21
+ logger.info("Checking #{workflows.count} workflows...")
23
22
 
24
23
  run_until = Time.now.utc + timeout if timeout.to_i > 0
25
24
  ready = []
@@ -66,29 +65,21 @@ module Floe
66
65
  event, data = queue.pop
67
66
  break if event.nil?
68
67
 
69
- _execution_id, runner_context = data.values_at("execution_id", "runner_context")
70
-
71
- # If the event is for one of our workflows set the updated runner_context
72
- workflows.each do |workflow|
73
- next unless workflow.context.state.dig("RunnerContext", "container_ref") == runner_context["container_ref"]
74
-
75
- workflow.context.state["RunnerContext"] = runner_context
76
- end
77
-
78
- break if queue.empty?
68
+ # break out of the loop if the event is for one of our workflows
69
+ break if queue.empty? || workflows.detect { |wf| wf.execution_id == data["execution_id"] }
79
70
  end
80
71
  ensure
81
72
  sleep_thread&.kill
82
73
  end
83
74
 
84
- logger.info("checking #{workflows.count} workflows...Complete - #{ready.count} ready")
75
+ logger.info("Checking #{workflows.count} workflows...Complete - #{ready.count} ready")
85
76
  ready
86
77
  ensure
87
78
  wait_thread&.kill
88
79
  end
89
80
  end
90
81
 
91
- attr_reader :context, :payload, :states, :states_by_name, :start_at, :name, :comment
82
+ attr_reader :comment, :context
92
83
 
93
84
  def initialize(payload, context = nil, credentials = nil, name = nil)
94
85
  payload = JSON.parse(payload) if payload.kind_of?(String)
@@ -99,20 +90,10 @@ module Floe
99
90
  # caller should really put credentials into context and not pass that variable
100
91
  context.credentials = credentials if credentials
101
92
 
102
- # NOTE: this is a string, and states use an array
103
- @name = name || "State Machine"
104
- @payload = payload
105
- @context = context
106
- @comment = payload["Comment"]
107
- @start_at = payload["StartAt"]
108
-
109
- # NOTE: Everywhere else we include our name (i.e.: parent name) when building the child name.
110
- # When creating the states, we are dropping our name (i.e.: the workflow name)
111
- @states = payload["States"].to_a.map { |state_name, state| State.build!(self, ["States", state_name], state) }
93
+ @context = context
94
+ @comment = payload["Comment"]
112
95
 
113
- validate_workflow
114
-
115
- @states_by_name = @states.each_with_object({}) { |state, result| result[state.short_name] = state }
96
+ super(payload, name)
116
97
  rescue Floe::Error
117
98
  raise
118
99
  rescue => err
@@ -185,7 +166,7 @@ module Floe
185
166
 
186
167
  # NOTE: Expecting the context to be initialized (via start_workflow) before this
187
168
  def current_state
188
- @states_by_name[context.state_name]
169
+ states_by_name[context.state_name]
189
170
  end
190
171
 
191
172
  # backwards compatibility. Caller should access directly from context
@@ -193,14 +174,12 @@ module Floe
193
174
  @context.credentials
194
175
  end
195
176
 
196
- private
197
-
198
- def validate_workflow
199
- missing_field_error!("States") if @states.empty?
200
- missing_field_error!("StartAt") if @start_at.nil?
201
- invalid_field_error!("StartAt", @start_at, "is not found in \"States\"") unless workflow_state?(@start_at, self)
177
+ def execution_id
178
+ @context.execution["Id"]
202
179
  end
203
180
 
181
+ private
182
+
204
183
  def step!
205
184
  next_state = {"Name" => context.next_state, "Guid" => SecureRandom.uuid, "PreviousStateGuid" => context.state["Guid"]}
206
185
 
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class WorkflowBase
5
+ include ValidationMixin
6
+
7
+ attr_reader :name, :payload, :start_at, :states, :states_by_name
8
+
9
+ def initialize(payload, name = nil)
10
+ # NOTE: this is a string, and states use an array
11
+ @name = name || "State Machine"
12
+ @payload = payload
13
+ @start_at = payload["StartAt"]
14
+
15
+ # NOTE: Everywhere else we include our name (i.e.: parent name) when building the child name.
16
+ # When creating the states, we are dropping our name (i.e.: the workflow name)
17
+ @states = payload["States"].to_a.map { |state_name, state| Floe::Workflow::State.build!(self, ["States", state_name], state) }
18
+ @states_by_name = @states.to_h { |state| [state.short_name, state] }
19
+
20
+ validate_workflow!
21
+ end
22
+
23
+ def run(context)
24
+ run_nonblock(context) until context.ended?
25
+ end
26
+
27
+ def run_nonblock(context)
28
+ start_workflow(context)
29
+ loop while step_nonblock(context) == 0 && !context.ended?
30
+ self
31
+ end
32
+
33
+ def step_nonblock(context)
34
+ return Errno::EPERM if context.ended?
35
+
36
+ result = current_state(context).run_nonblock!(context)
37
+ return result if result != 0
38
+
39
+ context.state_history << context.state
40
+ context.next_state ? step!(context) : end_workflow!(context)
41
+
42
+ result
43
+ end
44
+
45
+ def step_nonblock_ready?(context)
46
+ !context.started? || current_state(context).ready?(context)
47
+ end
48
+
49
+ def waiting?(context)
50
+ current_state(context)&.waiting?(context)
51
+ end
52
+
53
+ def wait_until(context)
54
+ current_state(context)&.wait_until(context)
55
+ end
56
+
57
+ def start_workflow(context)
58
+ return if context.state_name
59
+
60
+ context.state["Name"] = start_at
61
+ context.state["Input"] = context.execution["Input"].dup
62
+
63
+ context.execution["StartTime"] = Time.now.utc.iso8601
64
+
65
+ self
66
+ end
67
+
68
+ def current_state(context)
69
+ states_by_name[context.state_name]
70
+ end
71
+
72
+ def end?(context)
73
+ context.ended?
74
+ end
75
+
76
+ def output(context)
77
+ context.output.to_json if end?(context)
78
+ end
79
+
80
+ private
81
+
82
+ def step!(context)
83
+ next_state = {"Name" => context.next_state}
84
+
85
+ # if rerunning due to an error (and we are using Retry)
86
+ if context.state_name == context.next_state && context.failed? && context.state.key?("Retrier")
87
+ next_state.merge!(context.state.slice("RetryCount", "Input", "Retrier"))
88
+ else
89
+ next_state["Input"] = context.output
90
+ end
91
+
92
+ context.state = next_state
93
+ end
94
+
95
+ # Avoiding State#running? because that is potentially expensive.
96
+ # State#run_nonblock! already called running? via State#ready? and
97
+ # called State#finished -- which is what Context#state_finished? is detecting
98
+ def end_workflow!(context)
99
+ context.execution["EndTime"] = context.state["FinishedTime"]
100
+ end
101
+
102
+ def validate_workflow!
103
+ missing_field_error!("States") if @states.empty?
104
+ missing_field_error!("StartAt") if @start_at.nil?
105
+ invalid_field_error!("StartAt", @start_at, "is not found in \"States\"") unless workflow_state?(@start_at, self)
106
+ end
107
+ end
108
+ end
data/lib/floe.rb CHANGED
@@ -8,8 +8,11 @@ require_relative "floe/logging"
8
8
  require_relative "floe/runner"
9
9
 
10
10
  require_relative "floe/validation_mixin"
11
+ require_relative "floe/workflow_base"
11
12
  require_relative "floe/workflow"
13
+ # mixins used by workflow components
12
14
  require_relative "floe/workflow/error_matcher_mixin"
15
+ require_relative "floe/workflow/branch"
13
16
  require_relative "floe/workflow/catcher"
14
17
  require_relative "floe/workflow/choice_rule"
15
18
  require_relative "floe/workflow/choice_rule/not"
@@ -17,6 +20,7 @@ require_relative "floe/workflow/choice_rule/or"
17
20
  require_relative "floe/workflow/choice_rule/and"
18
21
  require_relative "floe/workflow/choice_rule/data"
19
22
  require_relative "floe/workflow/context"
23
+ require_relative "floe/workflow/item_processor"
20
24
  require_relative "floe/workflow/intrinsic_function"
21
25
  require_relative "floe/workflow/intrinsic_function/parser"
22
26
  require_relative "floe/workflow/intrinsic_function/transformer"
@@ -25,11 +29,14 @@ require_relative "floe/workflow/payload_template"
25
29
  require_relative "floe/workflow/reference_path"
26
30
  require_relative "floe/workflow/retrier"
27
31
  require_relative "floe/workflow/state"
32
+ # mixins used by states
33
+ require_relative "floe/workflow/states/child_workflow_mixin"
34
+ require_relative "floe/workflow/states/input_output_mixin"
35
+ require_relative "floe/workflow/states/non_terminal_mixin"
36
+ require_relative "floe/workflow/states/retry_catch_mixin"
28
37
  require_relative "floe/workflow/states/choice"
29
38
  require_relative "floe/workflow/states/fail"
30
- require_relative "floe/workflow/states/input_output_mixin"
31
39
  require_relative "floe/workflow/states/map"
32
- require_relative "floe/workflow/states/non_terminal_mixin"
33
40
  require_relative "floe/workflow/states/parallel"
34
41
  require_relative "floe/workflow/states/pass"
35
42
  require_relative "floe/workflow/states/succeed"
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/inline"
4
+ gemfile do
5
+ source "https://rubygems.org"
6
+ gem "optimist"
7
+ gem "colorize"
8
+ end
9
+ require "pp"
10
+
11
+ SUB_COMMANDS = {
12
+ "execute" => "Execute an .asl file through the stepfunctions simulator.",
13
+ "intrinsic" => "Execute an intrinsic function or JSONPath standalone."
14
+ }.freeze
15
+ Optimist.options do
16
+ banner "Run the aws stepfunctions simulator."
17
+ banner ""
18
+ banner "Notes:"
19
+ banner " This tool requires the stepfunctions simulator to be installed locally and running."
20
+ banner " Installation instructions can be found at https://docs.aws.amazon.com/step-functions/latest/dg/sfn-local.html."
21
+ banner ""
22
+ banner "Commands:"
23
+ SUB_COMMANDS.each { |k, v| banner " #{k.ljust(14)}#{v}" }
24
+ banner ""
25
+ banner " For more help with a specific command use #{$PROGRAM_NAME} <command> --help"
26
+ banner ""
27
+ banner "Global Options:"
28
+ stop_on SUB_COMMANDS.keys
29
+ end
30
+ cmd = ARGV.shift
31
+ Optimist.educate if cmd.nil?
32
+ Optimist.die "unknown subcommand #{cmd.inspect}" unless SUB_COMMANDS.include?(cmd)
33
+
34
+ def aws_stepfunctions(args)
35
+ cmd = "aws stepfunctions --endpoint-url http://localhost:8083 #{args}"
36
+ puts "** #{cmd}".light_black if ENV["DEBUG"]
37
+ output = `#{cmd}`.chomp
38
+ output = output.empty? ? {} : JSON.parse(output)
39
+ puts output.pretty_inspect.light_black if ENV["DEBUG"]
40
+ output
41
+ rescue JSON::ParserError => err
42
+ warn "ERROR: #{err}".light_red if ENV["DEBUG"]
43
+ {}
44
+ end
45
+
46
+ def execute_stepfunction(definition, input)
47
+ require "json"
48
+ require "shellwords"
49
+
50
+ begin
51
+ state_machine_arn = aws_stepfunctions("create-state-machine --definition #{Shellwords.escape(definition)} --name 'StateMachine' --role-arn 'arn:aws:iam::012345678901:role/DummyRole'")["stateMachineArn"]
52
+ exit 1 if state_machine_arn.nil?
53
+
54
+ input = input ? "--input #{Shellwords.escape(input)}" : ""
55
+ execution_arn = aws_stepfunctions("start-execution --state-machine-arn #{state_machine_arn} #{input}")["executionArn"]
56
+ exit 1 if execution_arn.nil?
57
+
58
+ status, output = aws_stepfunctions("describe-execution --execution-arn #{execution_arn}").values_at("status", "output")
59
+ if status == "FAILED"
60
+ warn "ERROR: Execution failed. See simulator for reason.".light_red
61
+ exit 1
62
+ end
63
+ ensure
64
+ aws_stepfunctions("stop-execution --execution-arn #{execution_arn}") if execution_arn
65
+ aws_stepfunctions("delete-state-machine --state-machine-arn #{state_machine_arn}") if state_machine_arn
66
+ end
67
+
68
+ puts output if output
69
+ end
70
+
71
+ def execute
72
+ opts = Optimist.options do
73
+ banner SUB_COMMANDS["execute"]
74
+ banner ""
75
+
76
+ opt :file, "The .asl file to execute", :default => "definition.asl"
77
+ opt :input, "Input to the execution", :type => :string
78
+ end
79
+
80
+ definition = File.read(opts[:file]).chomp
81
+ execute_stepfunction(definition, opts[:input])
82
+ end
83
+
84
+ def intrinsic
85
+ opts = Optimist.options do
86
+ banner SUB_COMMANDS["intrinsic"]
87
+ banner ""
88
+
89
+ opt :function, "The intrinsic function or JSONPath to run", :type => :string, :required => true
90
+ opt :input, "Input to the execution", :type => :string
91
+ end
92
+
93
+ require "json"
94
+
95
+ definition = {
96
+ "StartAt" => "ExecState",
97
+ "States" => {
98
+ "ExecState" => {
99
+ "Type" => "Pass",
100
+ "Parameters" => {"data.$" => opts[:function]},
101
+ "OutputPath" => "$.data",
102
+ "End" => true
103
+ }
104
+ }
105
+ }.to_json
106
+
107
+ execute_stepfunction(definition, opts[:input])
108
+ end
109
+
110
+ send(cmd)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: floe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ManageIQ Developers
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-20 00:00:00.000000000 Z
11
+ date: 2024-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: awesome_spawn
@@ -182,6 +182,8 @@ files:
182
182
  - LICENSE.txt
183
183
  - README.md
184
184
  - Rakefile
185
+ - examples/map.asl
186
+ - examples/parallel.asl
185
187
  - examples/set-credential.asl
186
188
  - examples/workflow.asl
187
189
  - exe/floe
@@ -199,6 +201,7 @@ files:
199
201
  - lib/floe/validation_mixin.rb
200
202
  - lib/floe/version.rb
201
203
  - lib/floe/workflow.rb
204
+ - lib/floe/workflow/branch.rb
202
205
  - lib/floe/workflow/catcher.rb
203
206
  - lib/floe/workflow/choice_rule.rb
204
207
  - lib/floe/workflow/choice_rule/and.rb
@@ -210,11 +213,13 @@ files:
210
213
  - lib/floe/workflow/intrinsic_function.rb
211
214
  - lib/floe/workflow/intrinsic_function/parser.rb
212
215
  - lib/floe/workflow/intrinsic_function/transformer.rb
216
+ - lib/floe/workflow/item_processor.rb
213
217
  - lib/floe/workflow/path.rb
214
218
  - lib/floe/workflow/payload_template.rb
215
219
  - lib/floe/workflow/reference_path.rb
216
220
  - lib/floe/workflow/retrier.rb
217
221
  - lib/floe/workflow/state.rb
222
+ - lib/floe/workflow/states/child_workflow_mixin.rb
218
223
  - lib/floe/workflow/states/choice.rb
219
224
  - lib/floe/workflow/states/fail.rb
220
225
  - lib/floe/workflow/states/input_output_mixin.rb
@@ -222,11 +227,13 @@ files:
222
227
  - lib/floe/workflow/states/non_terminal_mixin.rb
223
228
  - lib/floe/workflow/states/parallel.rb
224
229
  - lib/floe/workflow/states/pass.rb
230
+ - lib/floe/workflow/states/retry_catch_mixin.rb
225
231
  - lib/floe/workflow/states/succeed.rb
226
232
  - lib/floe/workflow/states/task.rb
227
233
  - lib/floe/workflow/states/wait.rb
234
+ - lib/floe/workflow_base.rb
228
235
  - renovate.json
229
- - sig/floe.rbs/floe.rbs
236
+ - tools/step_functions
230
237
  homepage: https://github.com/ManageIQ/floe
231
238
  licenses:
232
239
  - Apache-2.0
@@ -1,4 +0,0 @@
1
- module Floe
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end