floe 0.0.1 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|