manageiq-floe 0.1.0

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