manageiq-floe 0.1.0

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