floe 0.0.1 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ module States
6
+ class Map < Floe::Workflow::State
7
+ def initialize(*)
8
+ raise NotImplementedError
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ module States
6
+ class Parallel < Floe::Workflow::State
7
+ def initialize(*)
8
+ raise NotImplementedError
9
+ end
10
+ end
11
+ end
12
+ end
13
+ 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