floe 0.11.3 → 0.12.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: cb1a786256a191ba7d1b3b08f6e3cd02fa778fcf28ab44a7353f36ae7979039b
4
- data.tar.gz: ca5ab5d0b5ed1949945593bb1f07141e554fbbac4d825cbf6333bb92e5b17b6d
3
+ metadata.gz: 2eb195983f47d7cac44f18e962bf314c3ff4fefbdf3ac5cae32e960bfc2c0fb0
4
+ data.tar.gz: 6a0f8d91337297786c03e99fc96cb118ed8cb76244e52cf8253702d1517a6f5d
5
5
  SHA512:
6
- metadata.gz: e96574a58740f8659f27947144bdc5067fbade1645a0b651e66607494fce5b0a16ef762bbba1638d54a8804425c2de2c3938884b4c18346be3623b4ffbdc821b
7
- data.tar.gz: da673ecc866d1df2f6f75872df183beda957996fd54ff09177ebd84b27bc64fade88122eaaf34f7134e3a92d74b9d9f7a7322648042df444ad889de9a053d298
6
+ metadata.gz: 99f5a3c8e67a9f5c97b43795739c201a49a8f1195d40a1da38f2aa0e16d200266783336e4be9ada5d41b566c545144a4533db258546ebbf04a23c454a489e6a1
7
+ data.tar.gz: a77bfe436b37adf9dbdc2a279b9b1f70f48980062aaaec191a8e1fd57238e8b8b50b02828025e561c7ccb8429cb67fefb3f7ed044ed309d82eaae8dd8d9d407b
data/.yamllint CHANGED
@@ -1,8 +1,6 @@
1
- ---
2
1
  extends: relaxed
3
-
4
2
  rules:
5
3
  indentation:
6
4
  indent-sequences: false
7
5
  line-length:
8
- max: 120
6
+ max: 1000
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ This project adheres to [Semantic Versioning](http://semver.org/).
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.12.0] - 2024-07-31
8
+ ### Added
9
+ - Set Floe.logger.level if DEBUG env var set ([#234](https://github.com/ManageIQ/floe/pull/234))
10
+ - Add InstrinsicFunction foundation + States.Array, States.UUID ([#194](https://github.com/ManageIQ/floe/pull/194))
11
+ - Evaluate IntrinsicFunctions from PayloadTemplate ([#236](https://github.com/ManageIQ/floe/pull/236))
12
+ - Add more intrinsic functions ([#242](https://github.com/ManageIQ/floe/pull/242))
13
+ - Add State#cause_path, error_path ([#249](https://github.com/ManageIQ/floe/pull/249))
14
+ - Validate Catcher Next ([#250](https://github.com/ManageIQ/floe/pull/250))
15
+ - Add spec/supports ([#248](https://github.com/ManageIQ/floe/pull/248))
16
+
17
+ ### Fixed
18
+ - Handle non-hash input/output values ([#214](https://github.com/ManageIQ/floe/pull/214))
19
+ - Fix Input/Output handling for Pass, Choice, Succeed states ([#225](https://github.com/ManageIQ/floe/pull/225))
20
+ - Wrap Parslet::ParseFailed errors as Floe::InvalidWorkflowError ([#235](https://github.com/ManageIQ/floe/pull/235))
21
+ - Fix edge cases with States.Array ([#237](https://github.com/ManageIQ/floe/pull/237))
22
+ - Fix sporadic test failure with Wait state ([#243](https://github.com/ManageIQ/floe/pull/243))
23
+ - Fix invalid container names after normalizing ([#252](https://github.com/ManageIQ/floe/pull/252))
24
+
25
+ ### Changed
26
+ - Extract ErrorMatcherMixin from Catch and Retry ([#186](https://github.com/ManageIQ/floe/pull/186))
27
+ - Output should be JSON ([#230](https://github.com/ManageIQ/floe/pull/230))
28
+ - Normalize functions to all take arguments ([#238](https://github.com/ManageIQ/floe/pull/238))
29
+ - Pass full path name to State.new for better errors ([#229](https://github.com/ManageIQ/floe/pull/229))
30
+ - Validate that state machine input is valid JSON ([#227](https://github.com/ManageIQ/floe/pull/227))
31
+ - Move the Parslet parse into initialize so that invalid function definition will fail on workflow load ([#245](https://github.com/ManageIQ/floe/pull/245))
32
+
7
33
  ## [0.11.3] - 2024-06-20
8
34
  ### Fixed
9
35
  - ResultPath=$ replaces complete output ([#199](https://github.com/ManageIQ/floe/pull/199))
@@ -199,7 +225,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
199
225
  ### Added
200
226
  - Initial release
201
227
 
202
- [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.11.3...HEAD
228
+ [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.12.0...HEAD
229
+ [0.12.0]: https://github.com/ManageIQ/floe/compare/v0.11.3...v0.12.0
203
230
  [0.11.3]: https://github.com/ManageIQ/floe/compare/v0.11.2...v0.11.3
204
231
  [0.11.2]: https://github.com/ManageIQ/floe/compare/v0.11.1...v0.11.2
205
232
  [0.11.1]: https://github.com/ManageIQ/floe/compare/v0.11.0...v0.11.1
data/floe.gemspec CHANGED
@@ -36,11 +36,11 @@ Gem::Specification.new do |spec|
36
36
  spec.add_dependency "jsonpath", "~>1.1"
37
37
  spec.add_dependency "kubeclient", "~>4.7"
38
38
  spec.add_dependency "optimist", "~>3.0"
39
+ spec.add_dependency "parslet", "~>2.0"
39
40
 
40
- spec.add_development_dependency "manageiq-style"
41
+ spec.add_development_dependency "manageiq-style", ">= 1.5.2"
41
42
  spec.add_development_dependency "rake", "~> 13.0"
42
43
  spec.add_development_dependency "rspec"
43
- spec.add_development_dependency "rubocop"
44
44
  spec.add_development_dependency "simplecov", ">= 0.21.2"
45
45
  spec.add_development_dependency "timecop"
46
46
  end
data/lib/floe/cli.rb CHANGED
@@ -7,6 +7,7 @@ module Floe
7
7
  require "logger"
8
8
 
9
9
  Floe.logger = Logger.new($stdout)
10
+ Floe.logger.level = 0 if ENV["DEBUG"]
10
11
  end
11
12
 
12
13
  def run(args = ARGV)
@@ -30,10 +31,12 @@ module Floe
30
31
  # Display status
31
32
  workflows.each do |workflow|
32
33
  puts "", "#{workflow.name}#{" (#{workflow.status})" unless workflow.context.success?}", "===" if workflows.size > 1
33
- puts workflow.output.inspect
34
+ puts workflow.output
34
35
  end
35
36
 
36
37
  workflows.all? { |workflow| workflow.context.success? }
38
+ rescue Floe::Error => err
39
+ abort(err.message)
37
40
  end
38
41
 
39
42
  private
@@ -24,6 +24,9 @@ module Floe
24
24
  # This does not follow the leading and trailing character restriction because we will embed it
25
25
  # below with a prefix and suffix that already conform to the RFC.
26
26
  normalized_name = name.downcase.gsub(/[^a-z0-9-]/, "-")[0, MAX_CONTAINER_NAME_SIZE]
27
+ # Ensure that the normalized_name doesn't end in any invalid characters after we
28
+ # limited the length to the MAX_CONTAINER_NAME_SIZE.
29
+ normalized_name.gsub!(/[^a-z0-9]+$/, "")
27
30
 
28
31
  "floe-#{normalized_name}-#{SecureRandom.hex(4)}"
29
32
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ module ValidationMixin
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ def parser_error!(comment)
10
+ self.class.parser_error!(name, comment)
11
+ end
12
+
13
+ def missing_field_error!(field_name)
14
+ self.class.missing_field_error!(name, field_name)
15
+ end
16
+
17
+ def invalid_field_error!(field_name, field_value = nil, comment = nil)
18
+ self.class.invalid_field_error!(name, field_name, field_value, comment)
19
+ end
20
+
21
+ def workflow_state?(field_value, workflow)
22
+ workflow.payload["States"] ? workflow.payload["States"].include?(field_value) : true
23
+ end
24
+
25
+ def wrap_parser_error(field_name, field_value)
26
+ yield
27
+ rescue ArgumentError, InvalidWorkflowError => error
28
+ invalid_field_error!(field_name, field_value, error.message)
29
+ end
30
+
31
+ module ClassMethods
32
+ def parser_error!(name, comment)
33
+ name = name.join(".") if name.kind_of?(Array)
34
+ raise Floe::InvalidWorkflowError, "#{name} #{comment}"
35
+ end
36
+
37
+ def missing_field_error!(name, field_name)
38
+ parser_error!(name, "does not have required field \"#{field_name}\"")
39
+ end
40
+
41
+ def invalid_field_error!(name, field_name, field_value, comment)
42
+ # instead of displaying a large hash or array, just displaying the word Hash or Array
43
+ field_value = field_value.class if field_value.kind_of?(Hash) || field_value.kind_of?(Array)
44
+
45
+ parser_error!(name, "field \"#{field_name}\"#{" value \"#{field_value}\"" unless field_value.nil?} #{comment}")
46
+ end
47
+ end
48
+ end
49
+ 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.11.3"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -3,14 +3,28 @@
3
3
  module Floe
4
4
  class Workflow
5
5
  class Catcher
6
- attr_reader :error_equals, :next, :result_path
6
+ include ErrorMatcherMixin
7
+ include ValidationMixin
7
8
 
8
- def initialize(payload)
9
- @payload = payload
9
+ attr_reader :error_equals, :next, :result_path, :name
10
+
11
+ def initialize(workflow, name, payload)
12
+ @name = name
13
+ @payload = payload
10
14
 
11
15
  @error_equals = payload["ErrorEquals"]
12
16
  @next = payload["Next"]
13
17
  @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
18
+
19
+ missing_field_error!("ErrorEquals") if !@error_equals.kind_of?(Array) || @error_equals.empty?
20
+ validate_state_next!(workflow)
21
+ end
22
+
23
+ private
24
+
25
+ def validate_state_next!(workflow)
26
+ missing_field_error!("Next") if @next.nil?
27
+ invalid_field_error!("Next", @next, "is not found in \"States\"") if @next && !workflow_state?(@next, workflow)
14
28
  end
15
29
  end
16
30
  end
@@ -4,26 +4,31 @@ module Floe
4
4
  class Workflow
5
5
  class ChoiceRule
6
6
  class << self
7
- def build(payload)
7
+ def build(workflow, name, payload)
8
8
  if (sub_payloads = payload["Not"])
9
- Floe::Workflow::ChoiceRule::Not.new(payload, build_children([sub_payloads]))
9
+ name += ["Not"]
10
+ Floe::Workflow::ChoiceRule::Not.new(workflow, name, payload, build_children(workflow, name, [sub_payloads]))
10
11
  elsif (sub_payloads = payload["And"])
11
- Floe::Workflow::ChoiceRule::And.new(payload, build_children(sub_payloads))
12
+ name += ["And"]
13
+ Floe::Workflow::ChoiceRule::And.new(workflow, name, payload, build_children(workflow, name, sub_payloads))
12
14
  elsif (sub_payloads = payload["Or"])
13
- Floe::Workflow::ChoiceRule::Or.new(payload, build_children(sub_payloads))
15
+ name += ["Or"]
16
+ Floe::Workflow::ChoiceRule::Or.new(workflow, name, payload, build_children(workflow, name, sub_payloads))
14
17
  else
15
- Floe::Workflow::ChoiceRule::Data.new(payload)
18
+ name += ["Data"]
19
+ Floe::Workflow::ChoiceRule::Data.new(workflow, name, payload)
16
20
  end
17
21
  end
18
22
 
19
- def build_children(sub_payloads)
20
- sub_payloads.map { |payload| build(payload) }
23
+ def build_children(workflow, name, sub_payloads)
24
+ sub_payloads.map.with_index { |payload, i| build(workflow, name + [i.to_s], payload) }
21
25
  end
22
26
  end
23
27
 
24
- attr_reader :next, :payload, :variable, :children
28
+ attr_reader :next, :payload, :variable, :children, :name
25
29
 
26
- def initialize(payload, children = nil)
30
+ def initialize(_workflow, name, payload, children = nil)
31
+ @name = name
27
32
  @payload = payload
28
33
  @children = children
29
34
 
@@ -9,9 +9,7 @@ module Floe
9
9
  # @param input [Hash] (default: {})
10
10
  def initialize(context = nil, input: nil, credentials: {})
11
11
  context = JSON.parse(context) if context.kind_of?(String)
12
-
13
- input ||= {}
14
- input = JSON.parse(input) if input.kind_of?(String)
12
+ input = JSON.parse(input || "{}")
15
13
 
16
14
  @context = context || {}
17
15
  self["Execution"] ||= {}
@@ -23,7 +21,7 @@ module Floe
23
21
 
24
22
  @credentials = credentials || {}
25
23
  rescue JSON::ParserError => err
26
- raise Floe::InvalidWorkflowError, err.message
24
+ raise Floe::InvalidExecutionInput, "Invalid State Machine Execution Input: #{err}: was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')"
27
25
  end
28
26
 
29
27
  def execution
@@ -39,7 +37,7 @@ module Floe
39
37
  end
40
38
 
41
39
  def failed?
42
- output&.key?("Error") || false
40
+ (output.kind_of?(Hash) && output.key?("Error")) || false
43
41
  end
44
42
 
45
43
  def ended?
@@ -54,10 +52,18 @@ module Floe
54
52
  state["Input"]
55
53
  end
56
54
 
55
+ def json_input
56
+ input.to_json
57
+ end
58
+
57
59
  def output
58
60
  state["Output"]
59
61
  end
60
62
 
63
+ def json_output
64
+ output.to_json
65
+ end
66
+
61
67
  def output=(val)
62
68
  state["Output"] = val
63
69
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ # Methods for common error handling
6
+ module ErrorMatcherMixin
7
+ # @param [String] error the error thrown
8
+ def match_error?(error)
9
+ return false if error == "States.Runtime"
10
+ return true if error_equals.include?("States.ALL")
11
+ return true if error_equals.include?("States.Timeout") && error == "States.HeartbeatTimeout"
12
+
13
+ error_equals.include?(error)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,100 @@
1
+ require "parslet"
2
+
3
+ module Floe
4
+ class Workflow
5
+ class IntrinsicFunction
6
+ class Parser < Parslet::Parser
7
+ rule(:spaces) { str(' ').repeat(1) }
8
+ rule(:spaces?) { spaces.maybe }
9
+ rule(:digit) { match('[0-9]') }
10
+ rule(:quote) { str('\'') }
11
+
12
+ rule(:comma_sep) { str(',') >> spaces? }
13
+
14
+ rule(:true_literal) { str('true').as(:true_literal) }
15
+ rule(:false_literal) { str('false').as(:false_literal) }
16
+ rule(:null_literal) { str('null').as(:null_literal) }
17
+
18
+ rule(:number) do
19
+ (
20
+ str('-').maybe >> (
21
+ str('0') | (match('[1-9]') >> digit.repeat)
22
+ ) >> (
23
+ str('.') >> digit.repeat(1)
24
+ ).maybe >> (
25
+ match('[eE]') >> (str('+') | str('-')).maybe >> digit.repeat(1)
26
+ ).maybe
27
+ ).as(:number)
28
+ end
29
+
30
+ rule(:string) do
31
+ (
32
+ quote >> (
33
+ (str('\\') >> any) | (quote.absent? >> any)
34
+ ).repeat >> quote
35
+ ).as(:string)
36
+ end
37
+
38
+ rule(:jsonpath) do
39
+ (
40
+ str('$') >> match('[^,)]').repeat(0)
41
+ ).as(:jsonpath)
42
+ end
43
+
44
+ rule(:arg) do
45
+ (
46
+ string | number | jsonpath | true_literal | false_literal | null_literal | expression
47
+ ).as(:arg)
48
+ end
49
+
50
+ rule(:args) do
51
+ (
52
+ arg >> (comma_sep >> arg).repeat
53
+ ).maybe.as(:args)
54
+ end
55
+
56
+ [
57
+ :states_array, "States.Array",
58
+ :states_array_partition, "States.ArrayPartition",
59
+ :states_array_contains, "States.ArrayContains",
60
+ :states_array_range, "States.ArrayRange",
61
+ :states_array_get_item, "States.ArrayGetItem",
62
+ :states_array_length, "States.ArrayLength",
63
+ :states_array_unique, "States.ArrayUnique",
64
+ :states_base64_encode, "States.Base64Encode",
65
+ :states_base64_decode, "States.Base64Decode",
66
+ :states_hash, "States.Hash",
67
+ :states_math_random, "States.MathRandom",
68
+ :states_math_add, "States.MathAdd",
69
+ :states_string_split, "States.StringSplit",
70
+ :states_uuid, "States.UUID",
71
+ ].each_slice(2) do |function_symbol, function_name|
72
+ rule(function_symbol) do
73
+ (
74
+ str(function_name) >> str('(') >> args >> str(')')
75
+ ).as(function_symbol)
76
+ end
77
+ end
78
+
79
+ rule(:expression) do
80
+ states_array |
81
+ states_array_partition |
82
+ states_array_contains |
83
+ states_array_range |
84
+ states_array_get_item |
85
+ states_array_length |
86
+ states_array_unique |
87
+ states_base64_encode |
88
+ states_base64_decode |
89
+ states_hash |
90
+ states_math_random |
91
+ states_math_add |
92
+ states_string_split |
93
+ states_uuid
94
+ end
95
+
96
+ root(:expression)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,196 @@
1
+ # Disable rubocops against the `match` method, since this is a Parslet specific
2
+ # match method and not the typical `Object#match`.
3
+ # rubocop:disable Performance/RegexpMatch, Performance/RedundantMatch
4
+
5
+ require "parslet"
6
+
7
+ module Floe
8
+ class Workflow
9
+ class IntrinsicFunction
10
+ class Transformer < Parslet::Transform
11
+ OptionalArg = Struct.new(:type)
12
+
13
+ class << self
14
+ def process_args(args, function, signature = nil)
15
+ args = resolve_args(args)
16
+ if signature
17
+ check_arity(args, function, signature)
18
+ check_types(args, function, signature)
19
+ end
20
+ args
21
+ end
22
+
23
+ private
24
+
25
+ def resolve_args(args)
26
+ if args.nil?
27
+ # 0 args
28
+ []
29
+ elsif args.kind_of?(Array)
30
+ # >1 arg
31
+ args.map { |a| a[:arg] }
32
+ else
33
+ # 1 arg
34
+ [args[:arg]]
35
+ end
36
+ end
37
+
38
+ def check_arity(args, function, signature)
39
+ if signature.any?(OptionalArg)
40
+ signature_without_optional = signature.reject { |a| a.kind_of?(OptionalArg) }
41
+ signature_size = (signature_without_optional.size..signature.size)
42
+
43
+ raise ArgumentError, "wrong number of arguments to #{function} (given #{args.size}, expected #{signature_size})" unless signature_size.include?(args.size)
44
+ else
45
+ raise ArgumentError, "wrong number of arguments to #{function} (given #{args.size}, expected #{signature.size})" unless signature.size == args.size
46
+ end
47
+ end
48
+
49
+ def check_types(args, function, signature)
50
+ args.zip(signature).each_with_index do |(arg, type), index|
51
+ type = type.type if type.kind_of?(OptionalArg)
52
+
53
+ raise ArgumentError, "wrong type for argument #{index + 1} to #{function} (given #{arg.class}, expected #{type})" unless arg.kind_of?(type)
54
+ end
55
+ end
56
+ end
57
+
58
+ rule(:null_literal => simple(:v)) { nil }
59
+ rule(:true_literal => simple(:v)) { true }
60
+ rule(:false_literal => simple(:v)) { false }
61
+
62
+ rule(:string => simple(:v)) { v.to_s[1..-2] }
63
+ rule(:number => simple(:v)) { v.match(/[eE.]/) ? Float(v) : Integer(v) }
64
+ rule(:jsonpath => simple(:v)) { Floe::Workflow::Path.value(v.to_s, context, input) }
65
+
66
+ rule(:states_array => {:args => subtree(:args)}) do
67
+ Transformer.process_args(args, "States.Array")
68
+ end
69
+
70
+ rule(:states_array_partition => {:args => subtree(:args)}) do
71
+ args = Transformer.process_args(args(), "States.ArrayPartition", [Array, Integer])
72
+ array, chunk = *args
73
+ raise ArgumentError, "invalid value for argument 2 to States.ArrayPartition (given #{chunk}, expected a positive Integer)" unless chunk.positive?
74
+
75
+ array.each_slice(chunk).to_a
76
+ end
77
+
78
+ rule(:states_array_contains => {:args => subtree(:args)}) do
79
+ args = Transformer.process_args(args(), "States.ArrayContains", [Array, Object])
80
+ array, target = *args
81
+
82
+ array.include?(target)
83
+ end
84
+
85
+ rule(:states_array_range => {:args => subtree(:args)}) do
86
+ args = Transformer.process_args(args(), "States.ArrayRange", [Integer, Integer, Integer])
87
+ range_begin, range_end, increment = *args
88
+ raise ArgumentError, "invalid value for argument 3 to States.ArrayRange (given #{increment}, expected a non-zero Integer)" if increment.zero?
89
+
90
+ (range_begin..range_end).step(increment).to_a
91
+ end
92
+
93
+ rule(:states_array_get_item => {:args => subtree(:args)}) do
94
+ args = Transformer.process_args(args(), "States.ArrayGetItem", [Array, Integer])
95
+ array, index = *args
96
+ raise ArgumentError, "invalid value for argument 2 to States.ArrayGetItem (given #{index}, expected 0 or a positive Integer)" unless index >= 0
97
+
98
+ array[index]
99
+ end
100
+
101
+ rule(:states_array_length => {:args => subtree(:args)}) do
102
+ args = Transformer.process_args(args(), "States.ArrayLength", [Array])
103
+ array = args.first
104
+
105
+ array.size
106
+ end
107
+
108
+ rule(:states_array_unique => {:args => subtree(:args)}) do
109
+ args = Transformer.process_args(args(), "States.ArrayUnique", [Array])
110
+ array = args.first
111
+
112
+ array.uniq
113
+ end
114
+
115
+ rule(:states_base64_encode => {:args => subtree(:args)}) do
116
+ args = Transformer.process_args(args(), "States.Base64Encode", [String])
117
+ str = args.first
118
+
119
+ require "base64"
120
+ Base64.strict_encode64(str).force_encoding("UTF-8")
121
+ end
122
+
123
+ rule(:states_base64_decode => {:args => subtree(:args)}) do
124
+ args = Transformer.process_args(args(), "States.Base64Decode", [String])
125
+ str = args.first
126
+
127
+ require "base64"
128
+ begin
129
+ Base64.strict_decode64(str)
130
+ rescue ArgumentError => err
131
+ raise ArgumentError, "invalid value for argument 1 to States.Base64Decode (#{err})"
132
+ end
133
+ end
134
+
135
+ rule(:states_hash => {:args => subtree(:args)}) do
136
+ args = Transformer.process_args(args(), "States.Hash", [Object, String])
137
+ data, algorithm = *args
138
+ raise NotImplementedError if data.kind_of?(Hash)
139
+ if data.nil?
140
+ raise ArgumentError, "invalid value for argument 1 to States.Hash (given null, expected non-null)"
141
+ end
142
+
143
+ algorithms = %w[MD5 SHA-1 SHA-256 SHA-384 SHA-512]
144
+ unless algorithms.include?(algorithm)
145
+ raise ArgumentError, "invalid value for argument 2 to States.Hash (given #{algorithm.inspect}, expected one of #{algorithms.map(&:inspect).join(", ")})"
146
+ end
147
+
148
+ require "openssl"
149
+ algorithm = algorithm.sub("-", "")
150
+ data = data.to_json unless data.kind_of?(String)
151
+ OpenSSL::Digest.hexdigest(algorithm, data)
152
+ end
153
+
154
+ rule(:states_math_random => {:args => subtree(:args)}) do
155
+ args = Transformer.process_args(args(), "States.MathRandom", [Integer, Integer, OptionalArg[Integer]])
156
+ range_start, range_end, seed = *args
157
+ unless range_start < range_end
158
+ raise ArgumentError, "invalid values for arguments to States.MathRandom (start must be less than end)"
159
+ end
160
+
161
+ seed ||= Random.new_seed
162
+ Random.new(seed).rand(range_start..range_end)
163
+ end
164
+
165
+ rule(:states_math_add => {:args => subtree(:args)}) do
166
+ args = Transformer.process_args(args(), "States.MathAdd", [Integer, Integer])
167
+
168
+ args.sum
169
+ end
170
+
171
+ rule(:states_string_split => {:args => subtree(:args)}) do
172
+ args = Transformer.process_args(args(), "States.StringSplit", [String, String])
173
+ str, delimeter = *args
174
+
175
+ case delimeter.size
176
+ when 0
177
+ str.empty? ? [] : [str]
178
+ when 1
179
+ str.split(delimeter)
180
+ else
181
+ str.split(/[#{Regexp.escape(delimeter)}]+/)
182
+ end
183
+ end
184
+
185
+ rule(:states_uuid => {:args => subtree(:args)}) do
186
+ Transformer.process_args(args, "States.UUID", [])
187
+
188
+ require "securerandom"
189
+ SecureRandom.uuid
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ # rubocop:enable Performance/RegexpMatch, Performance/RedundantMatch
@@ -0,0 +1,34 @@
1
+ require "parslet"
2
+
3
+ module Floe
4
+ class Workflow
5
+ class IntrinsicFunction
6
+ def self.value(payload, context = {}, input = {})
7
+ new(payload).value(context, input)
8
+ end
9
+
10
+ def self.intrinsic_function?(payload)
11
+ payload.start_with?("States.")
12
+ end
13
+
14
+ attr_reader :payload
15
+
16
+ def initialize(payload)
17
+ @payload = payload
18
+ @tree = Parser.new.parse(payload)
19
+
20
+ Floe.logger.debug { "Parsed intrinsic function: #{payload.inspect} => #{tree.inspect}" }
21
+ rescue Parslet::ParseFailed => err
22
+ raise Floe::InvalidWorkflowError, err.message
23
+ end
24
+
25
+ def value(context = {}, input = {})
26
+ Transformer.new.apply(tree, :context => context, :input => input)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :tree
32
+ end
33
+ end
34
+ end
@@ -4,6 +4,10 @@ module Floe
4
4
  class Workflow
5
5
  class Path
6
6
  class << self
7
+ def path?(payload)
8
+ payload.start_with?("$")
9
+ end
10
+
7
11
  def value(payload, context, input = {})
8
12
  new(payload).value(context, input)
9
13
  end
@@ -26,8 +30,10 @@ module Floe
26
30
  [input, payload]
27
31
  end
28
32
 
29
- results = JsonPath.on(obj, path)
33
+ # If path is $ then just return the entire input
34
+ return obj if path == "$"
30
35
 
36
+ results = JsonPath.on(obj, path)
31
37
  results.count < 2 ? results.first : results
32
38
  end
33
39
  end
@@ -42,14 +42,17 @@ module Floe
42
42
  end
43
43
 
44
44
  def parse_payload_string(value)
45
- value.start_with?("$") ? Path.new(value) : value
45
+ return Path.new(value) if Path.path?(value)
46
+ return IntrinsicFunction.new(value) if IntrinsicFunction.intrinsic_function?(value)
47
+
48
+ value
46
49
  end
47
50
 
48
51
  def interpolate_value(value, context, inputs)
49
52
  case value
50
- when Array then interpolate_value_array(value, context, inputs)
51
- when Hash then interpolate_value_hash(value, context, inputs)
52
- when Path then value.value(context, inputs)
53
+ when Array then interpolate_value_array(value, context, inputs)
54
+ when Hash then interpolate_value_hash(value, context, inputs)
55
+ when Path, IntrinsicFunction then value.value(context, inputs)
53
56
  else
54
57
  value
55
58
  end
@@ -3,15 +3,21 @@
3
3
  module Floe
4
4
  class Workflow
5
5
  class Retrier
6
- attr_reader :error_equals, :interval_seconds, :max_attempts, :backoff_rate
6
+ include ErrorMatcherMixin
7
+ include ValidationMixin
7
8
 
8
- def initialize(payload)
9
- @payload = payload
9
+ attr_reader :error_equals, :interval_seconds, :max_attempts, :backoff_rate, :name
10
+
11
+ def initialize(_workflow, name, payload)
12
+ @name = name
13
+ @payload = payload
10
14
 
11
15
  @error_equals = payload["ErrorEquals"]
12
16
  @interval_seconds = payload["IntervalSeconds"] || 1.0
13
17
  @max_attempts = payload["MaxAttempts"] || 3
14
18
  @backoff_rate = payload["BackoffRate"] || 2.0
19
+
20
+ missing_field_error!("ErrorEquals") if !@error_equals.kind_of?(Array) || @error_equals.empty?
15
21
  end
16
22
 
17
23
  # @param [Integer] attempt 1 for the first attempt
@@ -4,16 +4,18 @@ module Floe
4
4
  class Workflow
5
5
  class State
6
6
  include Logging
7
+ include ValidationMixin
7
8
 
8
9
  class << self
9
10
  def build!(workflow, name, payload)
10
11
  state_type = payload["Type"]
11
- raise Floe::InvalidWorkflowError, "Missing \"Type\" field in state [#{name}]" if payload["Type"].nil?
12
+ missing_field_error!(name, "Type") if payload["Type"].nil?
13
+ invalid_field_error!(name[0..-2], "Name", name.last, "must be less than or equal to 80 characters") if name.last.length > 80
12
14
 
13
15
  begin
14
16
  klass = Floe::Workflow::States.const_get(state_type)
15
17
  rescue NameError
16
- raise Floe::InvalidWorkflowError, "Invalid state type: [#{state_type}]"
18
+ invalid_field_error!(name, "Type", state_type, "is not valid")
17
19
  end
18
20
 
19
21
  klass.new(workflow, name, payload)
@@ -22,14 +24,11 @@ module Floe
22
24
 
23
25
  attr_reader :comment, :name, :type, :payload
24
26
 
25
- def initialize(workflow, name, payload)
27
+ def initialize(_workflow, name, payload)
26
28
  @name = name
27
29
  @payload = payload
28
30
  @type = payload["Type"]
29
31
  @comment = payload["Comment"]
30
-
31
- raise Floe::InvalidWorkflowError, "Missing \"Type\" field in state [#{name}]" if payload["Type"].nil?
32
- raise Floe::InvalidWorkflowError, "State name [#{name[..79]}...] must be less than or equal to 80 characters" if name.length > 80
33
32
  end
34
33
 
35
34
  def wait(context, timeout: nil)
@@ -54,7 +53,7 @@ module Floe
54
53
  def start(context)
55
54
  context.state["EnteredTime"] = Time.now.utc.iso8601
56
55
 
57
- logger.info("Running state: [#{long_name}] with input [#{context.input}]...")
56
+ logger.info("Running state: [#{long_name}] with input [#{context.json_input}]...")
58
57
  end
59
58
 
60
59
  def finish(context)
@@ -64,8 +63,8 @@ module Floe
64
63
  context.state["FinishedTime"] ||= finished_time.iso8601
65
64
  context.state["Duration"] = finished_time - entered_time
66
65
 
67
- level = context.output&.[]("Error") ? :error : :info
68
- logger.public_send(level, "Running state: [#{long_name}] with input [#{context.input}]...Complete #{context.next_state ? "- next state [#{context.next_state}]" : "workflow -"} output: [#{context.output}]")
66
+ level = context.failed? ? :error : :info
67
+ logger.public_send(level, "Running state: [#{long_name}] with input [#{context.json_input}]...Complete #{context.next_state ? "- next state [#{context.next_state}]" : "workflow -"} output: [#{context.json_output}]")
69
68
 
70
69
  0
71
70
  end
@@ -86,8 +85,12 @@ module Floe
86
85
  context.state["WaitUntil"] && Time.parse(context.state["WaitUntil"])
87
86
  end
88
87
 
88
+ def short_name
89
+ name.last
90
+ end
91
+
89
92
  def long_name
90
- "#{@type}:#{name}"
93
+ "#{type}:#{short_name}"
91
94
  end
92
95
 
93
96
  private
@@ -95,7 +98,7 @@ module Floe
95
98
  def wait_until!(context, seconds: nil, time: nil)
96
99
  context.state["WaitUntil"] =
97
100
  if seconds
98
- (Time.parse(context.state["EnteredTime"]) + seconds).iso8601
101
+ (Time.now + seconds).iso8601
99
102
  elsif time.kind_of?(String)
100
103
  time
101
104
  else
@@ -11,7 +11,7 @@ module Floe
11
11
 
12
12
  validate_state!(workflow)
13
13
 
14
- @choices = payload["Choices"].map { |choice| ChoiceRule.build(choice) }
14
+ @choices = payload["Choices"].map.with_index { |choice, i| ChoiceRule.build(workflow, name + ["Choices", i.to_s], choice) }
15
15
  @default = payload["Default"]
16
16
 
17
17
  @input_path = Path.new(payload.fetch("InputPath", "$"))
@@ -19,7 +19,8 @@ module Floe
19
19
  end
20
20
 
21
21
  def finish(context)
22
- output = output_path.value(context, context.input)
22
+ input = input_path.value(context, context.input)
23
+ output = output_path.value(context, input)
23
24
  next_state = choices.detect { |choice| choice.true?(context, output) }&.next || default
24
25
 
25
26
  context.next_state = next_state
@@ -43,12 +44,12 @@ module Floe
43
44
  end
44
45
 
45
46
  def validate_state_choices!
46
- raise Floe::InvalidWorkflowError, "Choice state must have \"Choices\"" unless payload.key?("Choices")
47
- raise Floe::InvalidWorkflowError, "\"Choices\" must be a non-empty array" unless payload["Choices"].kind_of?(Array) && !payload["Choices"].empty?
47
+ missing_field_error!("Choices") unless payload.key?("Choices")
48
+ invalid_field_error!("Choices", nil, "must be a non-empty array") unless payload["Choices"].kind_of?(Array) && !payload["Choices"].empty?
48
49
  end
49
50
 
50
51
  def validate_state_default!(workflow)
51
- raise Floe::InvalidWorkflowError, "\"Default\" not in \"States\"" unless workflow.payload["States"].include?(payload["Default"])
52
+ invalid_field_error!("Default", payload["Default"], "is not found in \"States\"") if payload["Default"] && !workflow_state?(payload["Default"], workflow)
52
53
  end
53
54
  end
54
55
  end
@@ -4,7 +4,7 @@ module Floe
4
4
  class Workflow
5
5
  module States
6
6
  class Fail < Floe::Workflow::State
7
- attr_reader :cause, :error
7
+ attr_reader :cause, :error, :cause_path, :error_path
8
8
 
9
9
  def initialize(workflow, name, payload)
10
10
  super
@@ -12,8 +12,8 @@ module Floe
12
12
  end
13
13
 
14
14
  def validate_state_next!(workflow)
15
- raise Floe::InvalidWorkflowError, "Missing \"Next\" field in state [#{name}]" if @next.nil? && !@end
16
- raise Floe::InvalidWorkflowError, "\"Next\" [#{@next}] not in \"States\" for state [#{name}]" if @next && !workflow.payload["States"].key?(@next)
15
+ missing_field_error!("Next") if @next.nil? && !@end
16
+ invalid_field_error!("Next", @next, "is not found in \"States\"") if @next && !workflow_state?(@next, workflow)
17
17
  end
18
18
  end
19
19
  end
@@ -25,7 +25,8 @@ module Floe
25
25
  end
26
26
 
27
27
  def finish(context)
28
- context.output = process_output(context, result)
28
+ input = result.nil? ? process_input(context) : result
29
+ context.output = process_output(context, input)
29
30
  super
30
31
  end
31
32
 
@@ -6,9 +6,18 @@ module Floe
6
6
  class Succeed < Floe::Workflow::State
7
7
  attr_reader :input_path, :output_path
8
8
 
9
+ def initialize(workflow, name, payload)
10
+ super
11
+
12
+ @input_path = Path.new(payload.fetch("InputPath", "$"))
13
+ @output_path = Path.new(payload.fetch("OutputPath", "$"))
14
+ end
15
+
9
16
  def finish(context)
17
+ input = input_path.value(context, context.input)
18
+ context.output = output_path.value(context, input)
10
19
  context.next_state = nil
11
- context.output = context.input
20
+
12
21
  super
13
22
  end
14
23
 
@@ -18,10 +18,13 @@ module Floe
18
18
  @next = payload["Next"]
19
19
  @end = !!payload["End"]
20
20
  @resource = payload["Resource"]
21
- @runner = Floe::Runner.for_resource(@resource)
21
+
22
+ missing_field_error!("Resource") unless @resource.kind_of?(String)
23
+ @runner = wrap_parser_error("Resource", @resource) { Floe::Runner.for_resource(@resource) }
24
+
22
25
  @timeout_seconds = payload["TimeoutSeconds"]
23
- @retry = payload["Retry"].to_a.map { |retrier| Retrier.new(retrier) }
24
- @catch = payload["Catch"].to_a.map { |catcher| Catcher.new(catcher) }
26
+ @retry = payload["Retry"].to_a.map.with_index { |retrier, i| Retrier.new(workflow, name + ["Retry", i.to_s], retrier) }
27
+ @catch = payload["Catch"].to_a.map.with_index { |catcher, i| Catcher.new(workflow, name + ["Catch", i.to_s], catcher) }
25
28
  @input_path = Path.new(payload.fetch("InputPath", "$"))
26
29
  @output_path = Path.new(payload.fetch("OutputPath", "$"))
27
30
  @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
@@ -30,8 +33,6 @@ module Floe
30
33
  @credentials = PayloadTemplate.new(payload["Credentials"]) if payload["Credentials"]
31
34
 
32
35
  validate_state!(workflow)
33
- rescue ArgumentError => err
34
- raise Floe::InvalidWorkflowError, err.message
35
36
  end
36
37
 
37
38
  def start(context)
@@ -82,11 +83,11 @@ module Floe
82
83
  end
83
84
 
84
85
  def find_retrier(error)
85
- self.retry.detect { |r| (r.error_equals & [error, "States.ALL"]).any? }
86
+ self.retry.detect { |r| r.match_error?(error) }
86
87
  end
87
88
 
88
89
  def find_catcher(error)
89
- self.catch.detect { |c| (c.error_equals & [error, "States.ALL"]).any? }
90
+ self.catch.detect { |c| c.match_error?(error) }
90
91
  end
91
92
 
92
93
  def retry_state!(context, error)
@@ -106,7 +107,7 @@ module Floe
106
107
  wait_until!(context, :seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
107
108
  context.next_state = context.state_name
108
109
  context.output = error
109
- logger.info("Running state: [#{long_name}] with input [#{context.input}] got error[#{context.output}]...Retry - delay: #{wait_until(context)}")
110
+ logger.info("Running state: [#{long_name}] with input [#{context.json_input}] got error[#{context.json_output}]...Retry - delay: #{wait_until(context)}")
110
111
  true
111
112
  end
112
113
 
@@ -116,7 +117,7 @@ module Floe
116
117
 
117
118
  context.next_state = catcher.next
118
119
  context.output = catcher.result_path.set(context.input, error)
119
- logger.info("Running state: [#{long_name}] with input [#{context.input}]...CatchError - next state: [#{context.next_state}] output: [#{context.output}]")
120
+ logger.info("Running state: [#{long_name}] with input [#{context.json_input}]...CatchError - next state: [#{context.next_state}] output: [#{context.json_output}]")
120
121
 
121
122
  true
122
123
  end
@@ -126,7 +127,7 @@ module Floe
126
127
  # keeping in here for completeness
127
128
  context.next_state = nil
128
129
  context.output = error
129
- logger.error("Running state: [#{long_name}] with input [#{context.input}]...Complete workflow - output: [#{context.output}]")
130
+ logger.error("Running state: [#{long_name}] with input [#{context.json_input}]...Complete workflow - output: [#{context.json_output}]")
130
131
  end
131
132
 
132
133
  def parse_error(output)
data/lib/floe/workflow.rb CHANGED
@@ -6,6 +6,7 @@ require "json"
6
6
  module Floe
7
7
  class Workflow
8
8
  include Logging
9
+ include ValidationMixin
9
10
 
10
11
  class << self
11
12
  def load(path_or_io, context = nil, credentials = {}, name = nil)
@@ -96,19 +97,21 @@ module Floe
96
97
  # caller should really put credentials into context and not pass that variable
97
98
  context.credentials = credentials if credentials
98
99
 
99
- raise Floe::InvalidWorkflowError, "Missing field \"States\"" if payload["States"].nil?
100
- raise Floe::InvalidWorkflowError, "Missing field \"StartAt\"" if payload["StartAt"].nil?
101
- raise Floe::InvalidWorkflowError, "\"StartAt\" not in the \"States\" field" unless payload["States"].key?(payload["StartAt"])
102
-
103
- @name = name
100
+ # NOTE: this is a string, and states use an array
101
+ @name = name || "State Machine"
104
102
  @payload = payload
105
103
  @context = context
106
104
  @comment = payload["Comment"]
107
105
  @start_at = payload["StartAt"]
108
106
 
109
- @states = payload["States"].to_a.map { |state_name, state| State.build!(self, state_name, state) }
110
- @states_by_name = @states.each_with_object({}) { |state, result| result[state.name] = state }
111
- rescue Floe::InvalidWorkflowError
107
+ # NOTE: Everywhere else we include our name (i.e.: parent name) when building the child name.
108
+ # When creating the states, we are dropping our name (i.e.: the workflow name)
109
+ @states = payload["States"].to_a.map { |state_name, state| State.build!(self, ["States", state_name], state) }
110
+
111
+ validate_workflow
112
+
113
+ @states_by_name = @states.each_with_object({}) { |state, result| result[state.short_name] = state }
114
+ rescue Floe::Error
112
115
  raise
113
116
  rescue => err
114
117
  raise Floe::InvalidWorkflowError, err.message
@@ -157,7 +160,7 @@ module Floe
157
160
  end
158
161
 
159
162
  def output
160
- context.output if end?
163
+ context.json_output if end?
161
164
  end
162
165
 
163
166
  def end?
@@ -187,8 +190,15 @@ module Floe
187
190
  def credentials
188
191
  @context.credentials
189
192
  end
193
+
190
194
  private
191
195
 
196
+ def validate_workflow
197
+ missing_field_error!("States") if @states.empty?
198
+ missing_field_error!("StartAt") if @start_at.nil?
199
+ invalid_field_error!("StartAt", @start_at, "is not found in \"States\"") unless workflow_state?(@start_at, self)
200
+ end
201
+
192
202
  def step!
193
203
  next_state = {"Name" => context.next_state, "Guid" => SecureRandom.uuid, "PreviousStateGuid" => context.state["Guid"]}
194
204
 
data/lib/floe.rb CHANGED
@@ -7,7 +7,9 @@ require_relative "floe/logging"
7
7
 
8
8
  require_relative "floe/runner"
9
9
 
10
+ require_relative "floe/validation_mixin"
10
11
  require_relative "floe/workflow"
12
+ require_relative "floe/workflow/error_matcher_mixin"
11
13
  require_relative "floe/workflow/catcher"
12
14
  require_relative "floe/workflow/choice_rule"
13
15
  require_relative "floe/workflow/choice_rule/not"
@@ -15,6 +17,9 @@ require_relative "floe/workflow/choice_rule/or"
15
17
  require_relative "floe/workflow/choice_rule/and"
16
18
  require_relative "floe/workflow/choice_rule/data"
17
19
  require_relative "floe/workflow/context"
20
+ require_relative "floe/workflow/intrinsic_function"
21
+ require_relative "floe/workflow/intrinsic_function/parser"
22
+ require_relative "floe/workflow/intrinsic_function/transformer"
18
23
  require_relative "floe/workflow/path"
19
24
  require_relative "floe/workflow/payload_template"
20
25
  require_relative "floe/workflow/reference_path"
@@ -37,6 +42,7 @@ require "time"
37
42
  module Floe
38
43
  class Error < StandardError; end
39
44
  class InvalidWorkflowError < Error; end
45
+ class InvalidExecutionInput < Error; end
40
46
 
41
47
  def self.logger
42
48
  @logger ||= NullLogger.new
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.11.3
4
+ version: 0.12.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-06-20 00:00:00.000000000 Z
11
+ date: 2024-08-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: awesome_spawn
@@ -80,20 +80,34 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: parslet
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: manageiq-style
85
99
  requirement: !ruby/object:Gem::Requirement
86
100
  requirements:
87
101
  - - ">="
88
102
  - !ruby/object:Gem::Version
89
- version: '0'
103
+ version: 1.5.2
90
104
  type: :development
91
105
  prerelease: false
92
106
  version_requirements: !ruby/object:Gem::Requirement
93
107
  requirements:
94
108
  - - ">="
95
109
  - !ruby/object:Gem::Version
96
- version: '0'
110
+ version: 1.5.2
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: rake
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -122,20 +136,6 @@ dependencies:
122
136
  - - ">="
123
137
  - !ruby/object:Gem::Version
124
138
  version: '0'
125
- - !ruby/object:Gem::Dependency
126
- name: rubocop
127
- requirement: !ruby/object:Gem::Requirement
128
- requirements:
129
- - - ">="
130
- - !ruby/object:Gem::Version
131
- version: '0'
132
- type: :development
133
- prerelease: false
134
- version_requirements: !ruby/object:Gem::Requirement
135
- requirements:
136
- - - ">="
137
- - !ruby/object:Gem::Version
138
- version: '0'
139
139
  - !ruby/object:Gem::Dependency
140
140
  name: simplecov
141
141
  requirement: !ruby/object:Gem::Requirement
@@ -196,6 +196,7 @@ files:
196
196
  - lib/floe/logging.rb
197
197
  - lib/floe/null_logger.rb
198
198
  - lib/floe/runner.rb
199
+ - lib/floe/validation_mixin.rb
199
200
  - lib/floe/version.rb
200
201
  - lib/floe/workflow.rb
201
202
  - lib/floe/workflow/catcher.rb
@@ -205,6 +206,10 @@ files:
205
206
  - lib/floe/workflow/choice_rule/not.rb
206
207
  - lib/floe/workflow/choice_rule/or.rb
207
208
  - lib/floe/workflow/context.rb
209
+ - lib/floe/workflow/error_matcher_mixin.rb
210
+ - lib/floe/workflow/intrinsic_function.rb
211
+ - lib/floe/workflow/intrinsic_function/parser.rb
212
+ - lib/floe/workflow/intrinsic_function/transformer.rb
208
213
  - lib/floe/workflow/path.rb
209
214
  - lib/floe/workflow/payload_template.rb
210
215
  - lib/floe/workflow/reference_path.rb