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.
@@ -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