floe 0.0.1 → 0.1.1
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 +7 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +8 -1
- data/README.md +32 -13
- data/Rakefile +3 -0
- data/examples/workflow.asl +95 -0
- data/exe/floe +21 -0
- data/floe.gemspec +34 -18
- data/lib/floe/logging.rb +13 -0
- data/lib/floe/null_logger.rb +13 -0
- data/lib/floe/version.rb +3 -1
- data/lib/floe/workflow/catcher.rb +17 -0
- data/lib/floe/workflow/choice_rule/boolean.rb +19 -0
- data/lib/floe/workflow/choice_rule/data.rb +94 -0
- data/lib/floe/workflow/choice_rule.rb +41 -0
- data/lib/floe/workflow/path.rb +34 -0
- data/lib/floe/workflow/payload_template.rb +37 -0
- data/lib/floe/workflow/reference_path.rb +44 -0
- data/lib/floe/workflow/retrier.rb +22 -0
- data/lib/floe/workflow/runner/docker.rb +45 -0
- data/lib/floe/workflow/runner/kubernetes.rb +118 -0
- data/lib/floe/workflow/runner/podman.rb +42 -0
- data/lib/floe/workflow/runner.rb +33 -0
- data/lib/floe/workflow/state.rb +76 -0
- data/lib/floe/workflow/states/choice.rb +53 -0
- data/lib/floe/workflow/states/fail.rb +37 -0
- data/lib/floe/workflow/states/map.rb +13 -0
- data/lib/floe/workflow/states/parallel.rb +13 -0
- data/lib/floe/workflow/states/pass.rb +31 -0
- data/lib/floe/workflow/states/succeed.rb +26 -0
- data/lib/floe/workflow/states/task.rb +82 -0
- data/lib/floe/workflow/states/wait.rb +25 -0
- data/lib/floe/workflow.rb +82 -0
- data/lib/floe.rb +41 -2
- data/sig/floe.rbs/floe.rbs +4 -0
- metadata +116 -37
- data/.gitignore +0 -17
- data/LICENSE.txt +0 -22
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
class Runner
|
6
|
+
class Docker < Floe::Workflow::Runner
|
7
|
+
def initialize(*)
|
8
|
+
require "awesome_spawn"
|
9
|
+
require "tempfile"
|
10
|
+
|
11
|
+
super
|
12
|
+
end
|
13
|
+
|
14
|
+
def run!(resource, env = {}, secrets = {})
|
15
|
+
raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
|
16
|
+
|
17
|
+
image = resource.sub("docker://", "")
|
18
|
+
|
19
|
+
params = ["run", :rm, [:net, "host"]]
|
20
|
+
params += env.map { |k, v| [:e, "#{k}=#{v}"] } if env
|
21
|
+
|
22
|
+
secrets_file = nil
|
23
|
+
|
24
|
+
if secrets && !secrets.empty?
|
25
|
+
secrets_file = Tempfile.new
|
26
|
+
secrets_file.write(secrets.to_json)
|
27
|
+
secrets_file.flush
|
28
|
+
|
29
|
+
params << [:e, "SECRETS=/run/secrets"]
|
30
|
+
params << [:v, "#{secrets_file.path}:/run/secrets:z"]
|
31
|
+
end
|
32
|
+
|
33
|
+
params << image
|
34
|
+
|
35
|
+
logger.debug("Running docker: #{AwesomeSpawn.build_command_line("docker", params)}")
|
36
|
+
result = AwesomeSpawn.run!("docker", :params => params)
|
37
|
+
|
38
|
+
[result.exit_status, result.output]
|
39
|
+
ensure
|
40
|
+
secrets_file&.close!
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
class Runner
|
6
|
+
class Kubernetes < Floe::Workflow::Runner
|
7
|
+
attr_reader :namespace
|
8
|
+
|
9
|
+
def initialize(*)
|
10
|
+
require "awesome_spawn"
|
11
|
+
require "securerandom"
|
12
|
+
require "base64"
|
13
|
+
require "yaml"
|
14
|
+
|
15
|
+
@namespace = ENV.fetch("DOCKER_RUNNER_NAMESPACE", "default")
|
16
|
+
|
17
|
+
super
|
18
|
+
end
|
19
|
+
|
20
|
+
def run!(resource, env = {}, secrets = {})
|
21
|
+
raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
|
22
|
+
|
23
|
+
image = resource.sub("docker://", "")
|
24
|
+
name = pod_name(image)
|
25
|
+
secret = create_secret!(secrets) unless secrets&.empty?
|
26
|
+
overrides = pod_spec(image, env, secret)
|
27
|
+
|
28
|
+
result = kubectl_run!(image, name, overrides)
|
29
|
+
|
30
|
+
# Kubectl prints that the pod was deleted, strip this from the output
|
31
|
+
output = result.output.gsub(/pod \"#{name}\" deleted/, "")
|
32
|
+
|
33
|
+
[result.exit_status, output]
|
34
|
+
ensure
|
35
|
+
delete_secret!(secret) if secret
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def container_name(image)
|
41
|
+
image.match(%r{^(?<repository>.+\/)?(?<image>.+):(?<tag>.+)$})&.named_captures&.dig("image")
|
42
|
+
end
|
43
|
+
|
44
|
+
def pod_name(image)
|
45
|
+
container_short_name = container_name(image)
|
46
|
+
raise ArgumentError, "Invalid docker image [#{image}]" if container_short_name.nil?
|
47
|
+
|
48
|
+
"#{container_short_name}-#{SecureRandom.uuid}"
|
49
|
+
end
|
50
|
+
|
51
|
+
def pod_spec(image, env, secret = nil)
|
52
|
+
container_spec = {
|
53
|
+
"name" => container_name(image),
|
54
|
+
"image" => image,
|
55
|
+
"env" => env.to_h.map { |k, v| {"name" => k, "value" => v} }
|
56
|
+
}
|
57
|
+
|
58
|
+
spec = {"spec" => {"containers" => [container_spec]}}
|
59
|
+
|
60
|
+
if secret
|
61
|
+
spec["spec"]["volumes"] = [{"name" => "secret-volume", "secret" => {"secretName" => secret}}]
|
62
|
+
container_spec["env"] << {"name" => "SECRETS", "value" => "/run/secrets/#{secret}/secret"}
|
63
|
+
container_spec["volumeMounts"] = [
|
64
|
+
{
|
65
|
+
"name" => "secret-volume",
|
66
|
+
"mountPath" => "/run/secrets/#{secret}",
|
67
|
+
"readOnly" => true
|
68
|
+
}
|
69
|
+
]
|
70
|
+
end
|
71
|
+
|
72
|
+
spec
|
73
|
+
end
|
74
|
+
|
75
|
+
def create_secret!(secrets)
|
76
|
+
secret_name = SecureRandom.uuid
|
77
|
+
|
78
|
+
secret_yaml = {
|
79
|
+
"kind" => "Secret",
|
80
|
+
"apiVersion" => "v1",
|
81
|
+
"metadata" => {
|
82
|
+
"name" => secret_name,
|
83
|
+
"namespace" => namespace
|
84
|
+
},
|
85
|
+
"data" => {
|
86
|
+
"secret" => Base64.urlsafe_encode64(secrets.to_json)
|
87
|
+
},
|
88
|
+
"type" => "Opaque"
|
89
|
+
}.to_yaml
|
90
|
+
|
91
|
+
kubectl!("create", "-f", "-", :in_data => secret_yaml)
|
92
|
+
|
93
|
+
secret_name
|
94
|
+
end
|
95
|
+
|
96
|
+
def delete_secret!(secret_name)
|
97
|
+
kubectl!("delete", "secret", secret_name, [:namespace, namespace])
|
98
|
+
end
|
99
|
+
|
100
|
+
def kubectl!(*params, **kwargs)
|
101
|
+
AwesomeSpawn.run!("kubectl", :params => params, **kwargs)
|
102
|
+
end
|
103
|
+
|
104
|
+
def kubectl_run!(image, name, overrides = nil)
|
105
|
+
params = [
|
106
|
+
"run", :rm, :attach, [:image, image], [:restart, "Never"], [:namespace, namespace], name
|
107
|
+
]
|
108
|
+
|
109
|
+
params << "--overrides=#{overrides.to_json}" if overrides
|
110
|
+
|
111
|
+
logger.debug("Running kubectl: #{AwesomeSpawn.build_command_line("kubectl", params)}")
|
112
|
+
|
113
|
+
kubectl!(*params)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
class Runner
|
6
|
+
class Podman < Floe::Workflow::Runner
|
7
|
+
def initialize(*)
|
8
|
+
require "awesome_spawn"
|
9
|
+
require "securerandom"
|
10
|
+
|
11
|
+
super
|
12
|
+
end
|
13
|
+
|
14
|
+
def run!(resource, env = {}, secrets = {})
|
15
|
+
raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
|
16
|
+
|
17
|
+
image = resource.sub("docker://", "")
|
18
|
+
|
19
|
+
params = ["run", :rm]
|
20
|
+
params += env.map { |k, v| [:e, "#{k}=#{v}"] } if env
|
21
|
+
|
22
|
+
if secrets && !secrets.empty?
|
23
|
+
secret_guid = SecureRandom.uuid
|
24
|
+
AwesomeSpawn.run!("podman", :params => ["secret", "create", secret_guid, "-"], :in_data => secrets.to_json)
|
25
|
+
|
26
|
+
params << [:e, "SECRETS=/run/secrets/#{secret_guid}"]
|
27
|
+
params << [:secret, secret_guid]
|
28
|
+
end
|
29
|
+
|
30
|
+
params << image
|
31
|
+
|
32
|
+
logger.debug("Running podman: #{AwesomeSpawn.build_command_line("podman", params)}")
|
33
|
+
result = AwesomeSpawn.run!("podman", :params => params)
|
34
|
+
|
35
|
+
[result.exit_status, result.output]
|
36
|
+
ensure
|
37
|
+
AwesomeSpawn.run("podman", :params => ["secret", "rm", secret_guid]) if secret_guid
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
class Runner
|
6
|
+
include Logging
|
7
|
+
|
8
|
+
class << self
|
9
|
+
attr_writer :docker_runner_klass
|
10
|
+
|
11
|
+
def docker_runner_klass
|
12
|
+
@docker_runner_klass ||= const_get(ENV.fetch("DOCKER_RUNNER", "docker").capitalize)
|
13
|
+
end
|
14
|
+
|
15
|
+
def for_resource(resource)
|
16
|
+
raise ArgumentError, "resource cannot be nil" if resource.nil?
|
17
|
+
|
18
|
+
scheme = resource.split("://").first
|
19
|
+
case scheme
|
20
|
+
when "docker"
|
21
|
+
docker_runner_klass.new
|
22
|
+
else
|
23
|
+
raise "Invalid resource scheme [#{scheme}]"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def run!(image, env = {}, secrets = {})
|
29
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
class State
|
6
|
+
include Logging
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def build!(workflow, name, payload)
|
10
|
+
state_type = payload["Type"]
|
11
|
+
|
12
|
+
begin
|
13
|
+
klass = Floe::Workflow::States.const_get(state_type)
|
14
|
+
rescue NameError
|
15
|
+
raise Floe::InvalidWorkflowError, "Invalid state type: [#{state_type}]"
|
16
|
+
end
|
17
|
+
|
18
|
+
klass.new(workflow, name, payload)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_reader :workflow, :comment, :name, :type, :payload
|
23
|
+
|
24
|
+
def initialize(workflow, name, payload)
|
25
|
+
@workflow = workflow
|
26
|
+
@name = name
|
27
|
+
@payload = payload
|
28
|
+
@end = !!payload["End"]
|
29
|
+
@type = payload["Type"]
|
30
|
+
@comment = payload["Comment"]
|
31
|
+
end
|
32
|
+
|
33
|
+
def end?
|
34
|
+
@end
|
35
|
+
end
|
36
|
+
|
37
|
+
def context
|
38
|
+
workflow.context
|
39
|
+
end
|
40
|
+
|
41
|
+
def run!(input)
|
42
|
+
logger.info("Running state: [#{name}] with input [#{input}]")
|
43
|
+
|
44
|
+
input = input_path.value(context, input)
|
45
|
+
|
46
|
+
output, next_state = block_given? ? yield(input) : input
|
47
|
+
next_state ||= workflow.states_by_name[payload["Next"]] unless end?
|
48
|
+
|
49
|
+
output ||= input
|
50
|
+
output = output_path&.value(context, output)
|
51
|
+
|
52
|
+
logger.info("Running state: [#{name}] with input [#{input}]...Complete - next state: [#{next_state&.name}] output: [#{output}]")
|
53
|
+
|
54
|
+
[next_state, output]
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_dot
|
58
|
+
String.new.tap do |s|
|
59
|
+
s << " #{name}"
|
60
|
+
|
61
|
+
attributes = to_dot_attributes
|
62
|
+
s << " [ #{attributes.to_a.map { |kv| kv.join("=") }.join(" ")} ]" unless attributes.empty?
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
private def to_dot_attributes
|
67
|
+
end? ? {:style => "bold"} : {}
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_dot_transitions
|
71
|
+
next_state_name = payload["Next"] unless end?
|
72
|
+
Array(next_state_name && " #{name} -> #{next_state_name}")
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
module States
|
6
|
+
class Choice < Floe::Workflow::State
|
7
|
+
attr_reader :choices, :default, :input_path, :output_path
|
8
|
+
|
9
|
+
def initialize(workflow, name, payload)
|
10
|
+
super
|
11
|
+
|
12
|
+
@choices = payload["Choices"].map { |choice| ChoiceRule.build(choice) }
|
13
|
+
@default = payload["Default"]
|
14
|
+
|
15
|
+
@input_path = Path.new(payload.fetch("InputPath", "$"))
|
16
|
+
@output_path = Path.new(payload.fetch("OutputPath", "$"))
|
17
|
+
end
|
18
|
+
|
19
|
+
def run!(*)
|
20
|
+
super do |input|
|
21
|
+
next_state_name = choices.detect { |choice| choice.true?(context, input) }&.next || default
|
22
|
+
next_state = workflow.states_by_name[next_state_name]
|
23
|
+
|
24
|
+
output = input
|
25
|
+
|
26
|
+
[output, next_state]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private def to_dot_attributes
|
31
|
+
super.merge(:shape => "diamond")
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_dot_transitions
|
35
|
+
[].tap do |a|
|
36
|
+
choices.each do |choice|
|
37
|
+
choice_label =
|
38
|
+
if choice.payload["NumericEquals"]
|
39
|
+
"#{choice.variable} == #{choice.payload["NumericEquals"]}"
|
40
|
+
else
|
41
|
+
"Unknown" # TODO
|
42
|
+
end
|
43
|
+
|
44
|
+
a << " #{name} -> #{choice.next} [ label=#{choice_label.inspect} ]"
|
45
|
+
end
|
46
|
+
|
47
|
+
a << " #{name} -> #{default} [ label=\"Default\" ]" if default
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
module States
|
6
|
+
class Fail < Floe::Workflow::State
|
7
|
+
attr_reader :cause, :error
|
8
|
+
|
9
|
+
def initialize(workflow, name, payload)
|
10
|
+
super
|
11
|
+
|
12
|
+
@cause = payload["Cause"]
|
13
|
+
@error = payload["Error"]
|
14
|
+
end
|
15
|
+
|
16
|
+
def run!(input)
|
17
|
+
logger.info("Running state: [#{name}] with input [#{input}]")
|
18
|
+
|
19
|
+
next_state = nil
|
20
|
+
output = input
|
21
|
+
|
22
|
+
logger.info("Running state: [#{name}] with input [#{input}]...Complete - next state: [#{next_state&.name}]")
|
23
|
+
|
24
|
+
[next_state, output]
|
25
|
+
end
|
26
|
+
|
27
|
+
def end?
|
28
|
+
true
|
29
|
+
end
|
30
|
+
|
31
|
+
private def to_dot_attributes
|
32
|
+
super.merge(:color => "red")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
module States
|
6
|
+
class Pass < Floe::Workflow::State
|
7
|
+
attr_reader :end, :next, :result, :parameters, :input_path, :output_path, :result_path
|
8
|
+
|
9
|
+
def initialize(workflow, name, payload)
|
10
|
+
super
|
11
|
+
|
12
|
+
@next = payload["Next"]
|
13
|
+
@result = payload["Result"]
|
14
|
+
|
15
|
+
@parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
|
16
|
+
@input_path = Path.new(payload.fetch("InputPath", "$"))
|
17
|
+
@output_path = Path.new(payload.fetch("OutputPath", "$"))
|
18
|
+
@result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
|
19
|
+
end
|
20
|
+
|
21
|
+
def run!(*)
|
22
|
+
super do |input|
|
23
|
+
output = input
|
24
|
+
output = result_path.set(output, result) if result && result_path
|
25
|
+
output
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
module States
|
6
|
+
class Succeed < Floe::Workflow::State
|
7
|
+
attr_reader :input_path, :output_path
|
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
|
+
|
16
|
+
def end?
|
17
|
+
true # TODO: Handle if this is ending a parallel or map state
|
18
|
+
end
|
19
|
+
|
20
|
+
private def to_dot_attributes
|
21
|
+
super.merge(:color => "green")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
module States
|
6
|
+
class Task < Floe::Workflow::State
|
7
|
+
attr_reader :credentials, :end, :heartbeat_seconds, :next, :parameters,
|
8
|
+
:result_selector, :resource, :timeout_seconds, :retry, :catch,
|
9
|
+
:input_path, :output_path, :result_path
|
10
|
+
|
11
|
+
def initialize(workflow, name, payload)
|
12
|
+
super
|
13
|
+
|
14
|
+
@heartbeat_seconds = payload["HeartbeatSeconds"]
|
15
|
+
@next = payload["Next"]
|
16
|
+
@resource = payload["Resource"]
|
17
|
+
@timeout_seconds = payload["TimeoutSeconds"]
|
18
|
+
@retry = payload["Retry"].to_a.map { |retrier| Retrier.new(retrier) }
|
19
|
+
@catch = payload["Catch"].to_a.map { |catcher| Catcher.new(catcher) }
|
20
|
+
@input_path = Path.new(payload.fetch("InputPath", "$"))
|
21
|
+
@output_path = Path.new(payload.fetch("OutputPath", "$"))
|
22
|
+
@result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
|
23
|
+
@parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
|
24
|
+
@result_selector = PayloadTemplate.new(payload["ResultSelector"]) if payload["ResultSelector"]
|
25
|
+
@credentials = PayloadTemplate.new(payload["Credentials"]) if payload["Credentials"]
|
26
|
+
end
|
27
|
+
|
28
|
+
def run!(*)
|
29
|
+
super do |input|
|
30
|
+
input = parameters.value(context, input) if parameters
|
31
|
+
|
32
|
+
runner = Floe::Workflow::Runner.for_resource(resource)
|
33
|
+
_exit_status, results = runner.run!(resource, input, credentials&.value({}, workflow.credentials))
|
34
|
+
|
35
|
+
output = input
|
36
|
+
process_output!(output, results)
|
37
|
+
rescue => err
|
38
|
+
retrier = self.retry.detect { |r| (r.error_equals & [err.to_s, "States.ALL"]).any? }
|
39
|
+
retry if retry!(retrier)
|
40
|
+
|
41
|
+
catcher = self.catch.detect { |c| (c.error_equals & [err.to_s, "States.ALL"]).any? }
|
42
|
+
raise if catcher.nil?
|
43
|
+
|
44
|
+
[output, workflow.states_by_name[catcher.next]]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def retry!(retrier)
|
51
|
+
return if retrier.nil?
|
52
|
+
|
53
|
+
# If a different retrier is hit reset the context
|
54
|
+
if !context.key?("retrier") || context["retrier"]["error_equals"] != retrier.error_equals
|
55
|
+
context["retrier"] = {"error_equals" => retrier.error_equals, "retry_count" => 0}
|
56
|
+
end
|
57
|
+
|
58
|
+
context["retrier"]["retry_count"] += 1
|
59
|
+
|
60
|
+
return if context["retrier"]["retry_count"] > retrier.max_attempts
|
61
|
+
|
62
|
+
Kernel.sleep(retrier.sleep_duration(context["retrier"]["retry_count"]))
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
def process_output!(output, results)
|
67
|
+
return output if results.nil?
|
68
|
+
return if output_path.nil?
|
69
|
+
|
70
|
+
begin
|
71
|
+
results = JSON.parse(results)
|
72
|
+
rescue JSON::ParserError
|
73
|
+
results = {"results" => results}
|
74
|
+
end
|
75
|
+
|
76
|
+
results = result_selector.value(context, results) if result_selector
|
77
|
+
result_path.set(output, results)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Floe
|
4
|
+
class Workflow
|
5
|
+
module States
|
6
|
+
class Wait < Floe::Workflow::State
|
7
|
+
attr_reader :end, :next, :seconds, :input_path, :output_path
|
8
|
+
|
9
|
+
def initialize(workflow, name, payload)
|
10
|
+
super
|
11
|
+
|
12
|
+
@next = payload["Next"]
|
13
|
+
@seconds = payload["Seconds"].to_i
|
14
|
+
|
15
|
+
@input_path = Path.new(payload.fetch("InputPath", "$"))
|
16
|
+
@output_path = Path.new(payload.fetch("OutputPath", "$"))
|
17
|
+
end
|
18
|
+
|
19
|
+
def run!(*)
|
20
|
+
super { sleep(seconds); nil }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module Floe
|
6
|
+
class Workflow
|
7
|
+
class << self
|
8
|
+
def load(path_or_io, context = {}, credentials = {})
|
9
|
+
payload = path_or_io.respond_to?(:read) ? path_or_io.read : File.read(path_or_io)
|
10
|
+
new(payload, context, credentials)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :context, :credentials, :first_state, :payload, :states, :states_by_name, :start_at
|
15
|
+
|
16
|
+
def initialize(payload, context = {}, credentials = {})
|
17
|
+
payload = JSON.parse(payload) if payload.kind_of?(String)
|
18
|
+
context = JSON.parse(context) if context.kind_of?(String)
|
19
|
+
credentials = JSON.parse(credentials) if credentials.kind_of?(String)
|
20
|
+
|
21
|
+
@payload = payload
|
22
|
+
@context = context
|
23
|
+
@credentials = credentials
|
24
|
+
@states = payload["States"].to_a.map { |name, state| State.build!(self, name, state) }
|
25
|
+
@states_by_name = states.to_h { |state| [state.name, state] }
|
26
|
+
@start_at = @payload["StartAt"]
|
27
|
+
@first_state = @states_by_name[@start_at]
|
28
|
+
rescue JSON::ParserError => err
|
29
|
+
raise Floe::InvalidWorkflowError, err.message
|
30
|
+
end
|
31
|
+
|
32
|
+
def run!
|
33
|
+
state = first_state
|
34
|
+
input = context.dup
|
35
|
+
|
36
|
+
until state.nil?
|
37
|
+
state, output = state.run!(input)
|
38
|
+
input = output
|
39
|
+
end
|
40
|
+
|
41
|
+
output
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_dot
|
45
|
+
String.new.tap do |s|
|
46
|
+
s << "digraph {\n"
|
47
|
+
states.each do |state|
|
48
|
+
s << state.to_dot << "\n"
|
49
|
+
end
|
50
|
+
s << "\n"
|
51
|
+
states.each do |state|
|
52
|
+
Array(state.to_dot_transitions).each do |transition|
|
53
|
+
s << transition << "\n"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
s << "}\n"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def to_svg(path: nil)
|
61
|
+
require "open3"
|
62
|
+
out, err, _status = Open3.capture3("dot -Tsvg", :stdin_data => to_dot)
|
63
|
+
|
64
|
+
raise "Error from graphviz:\n#{err}" if err && !err.empty?
|
65
|
+
|
66
|
+
File.write(path, out) if path
|
67
|
+
|
68
|
+
out
|
69
|
+
end
|
70
|
+
|
71
|
+
def to_ascii(path: nil)
|
72
|
+
require "open3"
|
73
|
+
out, err, _status = Open3.capture3("graph-easy", :stdin_data => to_dot)
|
74
|
+
|
75
|
+
raise "Error from graph-easy:\n#{err}" if err && !err.empty?
|
76
|
+
|
77
|
+
File.write(path, out) if path
|
78
|
+
|
79
|
+
out
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|