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 +4 -4
- data/.codeclimate.yml +21 -1
- data/CHANGELOG.md +18 -0
- data/README.md +8 -0
- data/examples/map.asl +46 -0
- data/examples/parallel.asl +32 -0
- data/lib/floe/cli.rb +15 -8
- data/lib/floe/version.rb +1 -1
- data/lib/floe/workflow/branch.rb +8 -0
- data/lib/floe/workflow/choice_rule/data.rb +61 -9
- data/lib/floe/workflow/item_processor.rb +14 -0
- data/lib/floe/workflow/states/child_workflow_mixin.rb +58 -0
- data/lib/floe/workflow/states/choice.rb +5 -6
- data/lib/floe/workflow/states/map.rb +116 -2
- data/lib/floe/workflow/states/parallel.rb +65 -2
- data/lib/floe/workflow/states/retry_catch_mixin.rb +57 -0
- data/lib/floe/workflow/states/task.rb +1 -48
- data/lib/floe/workflow.rb +14 -35
- data/lib/floe/workflow_base.rb +108 -0
- data/lib/floe.rb +9 -2
- data/tools/step_functions +110 -0
- metadata +10 -3
- data/sig/floe.rbs/floe.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75d5be2f5b9cdcfc64b4b3994d32b9e3955486fd99bf4e05c62c2414485188c8
|
|
4
|
+
data.tar.gz: 7257009d5942f157f2ccef6499c77e1f9033e0ac4846a08f3f589943bd4794ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -4,16 +4,20 @@ module Floe
|
|
|
4
4
|
class Workflow
|
|
5
5
|
class ChoiceRule
|
|
6
6
|
class Data < Floe::Workflow::ChoiceRule
|
|
7
|
-
|
|
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, :
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
151
|
+
fetch_path("Variable", variable, context, input)
|
|
124
152
|
end
|
|
125
153
|
|
|
126
|
-
|
|
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
|
-
|
|
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")
|
|
49
|
-
invalid_field_error!("Choices", nil, "must be a non-empty array") unless
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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 :
|
|
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
|
-
|
|
103
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|
-
-
|
|
236
|
+
- tools/step_functions
|
|
230
237
|
homepage: https://github.com/ManageIQ/floe
|
|
231
238
|
licenses:
|
|
232
239
|
- Apache-2.0
|
data/sig/floe.rbs/floe.rbs
DELETED