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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a6a8fe345f9e54f72394a96fab04b30c611df88d75c37312c0c1ca4fabb19b94
4
+ data.tar.gz: e912605c61ee9cfb3e299d730aa2612ca152013095635d6e43bb7ba70f95d2e9
5
+ SHA512:
6
+ metadata.gz: 85afb6b3c99ec3c21500a51c04eacd4023231bc89fafb81ad9648077f84dcb37d65fc0545affe66db93209ee935fed81060b51aa34a92171d68ef215bda94a7e
7
+ data.tar.gz: a31a038245135c3c2e16647ab26209e3c16a40ca91c3d3491e6c6e167a4824acd3e1e16471779cc82009fd2eaec2a2466cb048171583360318e88e6e5bdf93c0
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Change Log
2
+ All notable changes to this project will be documented in this file.
3
+ This project adheres to [Semantic Versioning](http://semver.org/).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2023-03-13
8
+ ### Added
9
+ - Initial release
10
+
11
+ [Unreleased]: https://github.com/ManageIQ/manageiq-floe/compare/v0.1.0...HEAD
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ plugin "bundler-inject", "~> 2.0"
6
+ require File.join(Bundler::Plugin.index.load_paths("bundler-inject")[0], "bundler-inject") rescue nil
7
+
8
+ # Specify your gem's dependencies in manageiq-floe.gemspec
9
+ gemspec
10
+
11
+ gem "rake", "~> 13.0"
data/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # ManageIQ::Floe
2
+
3
+ [![CI](https://github.com/ManageIQ/manageiq-floe/actions/workflows/ci.yaml/badge.svg)](https://github.com/ManageIQ/manageiq-floe/actions/workflows/ci.yaml)
4
+
5
+ ## Overview
6
+
7
+ Floe is a runner for [Amazon States Language](https://states-language.net/) workflows with support for Docker resources and running on Docker, Podman, or Kubernetes.
8
+
9
+ ## Installation
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ $ bundle add manageiq-floe
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ $ gem install manageiq-floe
18
+
19
+ ## Usage
20
+
21
+ Floe can be run as a command-line utility or as a ruby class.
22
+
23
+ ### Command Line
24
+
25
+ ```
26
+ bundle exec ruby exe/manageiq-floe --workflow examples/workflow.json --inputs='{"foo": 1}'
27
+ ```
28
+
29
+ ### Ruby Library
30
+
31
+ ```ruby
32
+ require 'manageiq-floe'
33
+
34
+ workflow = ManageIQ::Floe::Workflow.load(File.read("workflow.json"))
35
+ workflow.run!
36
+ ```
37
+
38
+ ## Development
39
+
40
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
41
+
42
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
43
+
44
+ ## Contributing
45
+
46
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ManageIQ/manageiq-floe.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,89 @@
1
+ {
2
+ "Comment": "An example of the Amazon States Language using a choice state.",
3
+ "StartAt": "FirstState",
4
+ "States": {
5
+ "FirstState": {
6
+ "Type": "Task",
7
+ "Resource": "docker://agrare/hello-world:latest",
8
+ "Credentials": {
9
+ "mysecret": "dont tell anyone"
10
+ },
11
+ "Retry": [
12
+ {
13
+ "ErrorEquals": [ "States.Timeout" ],
14
+ "IntervalSeconds": 3,
15
+ "MaxAttempts": 2,
16
+ "BackoffRate": 1.5
17
+ }
18
+ ],
19
+ "Catch": [
20
+ {
21
+ "ErrorEquals": [ "States.ALL" ],
22
+ "Next": "FailState"
23
+ }
24
+ ],
25
+ "Next": "ChoiceState"
26
+ },
27
+
28
+ "ChoiceState": {
29
+ "Type" : "Choice",
30
+ "Choices": [
31
+ {
32
+ "Variable": "$.foo",
33
+ "NumericEquals": 1,
34
+ "Next": "FirstMatchState"
35
+ },
36
+ {
37
+ "Variable": "$.foo",
38
+ "NumericEquals": 2,
39
+ "Next": "SecondMatchState"
40
+ },
41
+ {
42
+ "Variable": "$.foo",
43
+ "NumericEquals": 3,
44
+ "Next": "SuccessState"
45
+ }
46
+ ],
47
+ "Default": "FailState"
48
+ },
49
+
50
+ "FirstMatchState": {
51
+ "Type" : "Task",
52
+ "Resource": "docker://agrare/hello-world:latest",
53
+ "Next": "PassState"
54
+ },
55
+
56
+ "SecondMatchState": {
57
+ "Type" : "Task",
58
+ "Resource": "docker://agrare/hello-world:latest",
59
+ "Next": "NextState"
60
+ },
61
+
62
+ "PassState": {
63
+ "Type": "Pass",
64
+ "Result": {
65
+ "foo": "bar",
66
+ "bar": "baz"
67
+ },
68
+ "ResultPath": "$.result",
69
+ "Next": "NextState"
70
+ },
71
+
72
+ "FailState": {
73
+ "Type": "Fail",
74
+ "Error": "FailStateError",
75
+ "Cause": "No Matches!"
76
+ },
77
+
78
+ "SuccessState": {
79
+ "Type": "Succeed"
80
+ },
81
+
82
+ "NextState": {
83
+ "Type": "Task",
84
+ "Resource": "docker://agrare/hello-world:latest",
85
+ "Secrets": ["vmdb:aaa-bbb-ccc"],
86
+ "End": true
87
+ }
88
+ }
89
+ }
data/exe/manageiq-floe ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "manageiq-floe"
5
+ require "optimist"
6
+
7
+ opts = Optimist.options do
8
+ version "v#{ManageIQ::Floe::VERSION}\n"
9
+ opt :workflow, "Path to your workflow json", :type => :string, :required => true
10
+ opt :inputs, "JSON payload to input to the workflow", :type => :string, :default => '{}'
11
+ opt :credentials, "JSON payload with credentials", :type => :string, :default => '{}'
12
+ end
13
+
14
+ require "logger"
15
+ ManageIQ::Floe.logger = Logger.new(STDOUT)
16
+
17
+ workflow = ManageIQ::Floe::Workflow.load(opts[:workflow], opts[:inputs], opts[:credentials])
18
+
19
+ output = workflow.run!
20
+
21
+ puts output.inspect
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ module Logging
6
+ def self.included(base)
7
+ base.extend(self)
8
+ end
9
+
10
+ def logger
11
+ ManageIQ::Floe.logger
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module ManageIQ
6
+ module Floe
7
+ class NullLogger < Logger
8
+ def initialize(*_args)
9
+ end
10
+
11
+ def add(*_args, &_block)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ class Workflow
6
+ class Catcher
7
+ attr_reader :error_equals, :next, :result_path
8
+
9
+ def initialize(payload)
10
+ @payload = payload
11
+
12
+ @error_equals = payload["ErrorEquals"]
13
+ @next = payload["Next"]
14
+ @result_path = payload.fetch("ResultPath", "$")
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ class Workflow
6
+ class ChoiceRule
7
+ class Boolean < ManageIQ::Floe::Workflow::ChoiceRule
8
+ def true?(context, input)
9
+ if payload.key?("Not")
10
+ !ChoiceRule.true?(payload["Not"], context, input)
11
+ elsif payload.key?("And")
12
+ payload["And"].all? { |choice| ChoiceRule.true?(choice, context, input) }
13
+ else
14
+ payload["Or"].any? { |choice| ChoiceRule.true?(choice, context, input) }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ class Workflow
6
+ class ChoiceRule
7
+ class Data < ManageIQ::Floe::Workflow::ChoiceRule
8
+ def true?(context, input)
9
+
10
+ lhs = variable_value(context, input)
11
+ rhs = compare_value(context, input)
12
+
13
+ validate!(lhs)
14
+
15
+ case compare_key
16
+ when "IsNull"; is_null?(lhs)
17
+ when "IsPresent"; is_present?(lhs)
18
+ when "IsNumeric"; is_numeric?(lhs)
19
+ when "IsString"; is_string?(lhs)
20
+ when "IsBoolean"; is_boolean?(lhs)
21
+ when "IsTimestamp"; is_timestamp?(lhs)
22
+ when "StringEquals", "StringEqualsPath",
23
+ "NumericEquals", "NumericEqualsPath",
24
+ "BooleanEquals", "BooleanEqualsPath",
25
+ "TimestampEquals", "TimestampEqualsPath"
26
+ lhs == rhs
27
+ when "StringLessThan", "StringLessThanPath",
28
+ "NumericLessThan", "NumericLessThanPath",
29
+ "TimestampLessThan", "TimestampLessThanPath"
30
+ lhs < rhs
31
+ when "StringGreaterThan", "StringGreaterThanPath",
32
+ "NumericGreaterThan", "NumericGreaterThanPath",
33
+ "TimestampGreaterThan", "TimestampGreaterThanPath"
34
+ lhs > rhs
35
+ when "StringLessThanEquals", "StringLessThanEqualsPath",
36
+ "NumericLessThanEquals", "NumericLessThanEqualsPath",
37
+ "TimestampLessThanEquals", "TimestampLessThanEqualsPath"
38
+ lhs <= rhs
39
+ when "StringGreaterThanEquals", "StringGreaterThanEqualsPath",
40
+ "NumericGreaterThanEquals", "NumericGreaterThanEqualsPath",
41
+ "TimestampGreaterThanEquals", "TimestampGreaterThanEqualsPath"
42
+ lhs >= rhs
43
+ when "StringMatches"
44
+ lhs.match?(Regexp.escape(rhs).gsub('\*','.*?'))
45
+ else
46
+ raise ManageIQ::Floe::InvalidWorkflowError, "Invalid choice [#{compare_key}]"
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def validate!(value)
53
+ raise RuntimeError, "No such variable [#{variable}]" if value.nil? && !%w[IsNull IsPresent].include?(compare_key)
54
+ end
55
+
56
+ def is_null?(value)
57
+ value.nil?
58
+ end
59
+
60
+ def is_present?(value)
61
+ !value.nil?
62
+ end
63
+
64
+ def is_numeric?(value)
65
+ value.kind_of?(Integer) || value.kind_of?(Float)
66
+ end
67
+
68
+ def is_string?(value)
69
+ value.kind_of?(String)
70
+ end
71
+
72
+ def is_boolean?(value)
73
+ [true, false].include?(value)
74
+ end
75
+
76
+ def is_timestamp?(value)
77
+ require "date"
78
+
79
+ DateTime.rfc3339(value)
80
+ true
81
+ rescue TypeError, Date::Error
82
+ false
83
+ end
84
+
85
+ def compare_key
86
+ @compare_key ||= payload.keys.detect { |key| key.match?(/^(IsNull|IsPresent|IsNumeric|IsString|IsBoolean|IsTimestamp|String|Numeric|Boolean|Timestamp)/) }
87
+ end
88
+
89
+ def compare_value(context, input)
90
+ compare_key.end_with?("Path") ? Path.value(payload[compare_key], context, input) : payload[compare_key]
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ class Workflow
6
+ class ChoiceRule
7
+ class << self
8
+ def true?(payload, context, input)
9
+ build(payload).true?(context, input)
10
+ end
11
+
12
+ def build(payload)
13
+ data_expression = (payload.keys & %w[And Not Or]).empty?
14
+ if data_expression
15
+ ManageIQ::Floe::Workflow::ChoiceRule::Data.new(payload)
16
+ else
17
+ ManageIQ::Floe::Workflow::ChoiceRule::Boolean.new(payload)
18
+ end
19
+ end
20
+ end
21
+
22
+ attr_reader :next, :payload, :variable
23
+
24
+ def initialize(payload)
25
+ @payload = payload
26
+
27
+ @next = payload["Next"]
28
+ @variable = payload["Variable"]
29
+ end
30
+
31
+ def true?(*)
32
+ raise NotImplementedError, "Must be implemented in a subclass"
33
+ end
34
+
35
+ private
36
+
37
+ def variable_value(context, input)
38
+ @variable_value ||= Path.value(variable, context, input)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ class Workflow
6
+ class Path
7
+ class << self
8
+ def value(payload, context, input = {})
9
+ new(payload).value(context, input)
10
+ end
11
+ end
12
+
13
+ def initialize(payload)
14
+ @payload = payload
15
+ end
16
+
17
+ def value(context, input = {})
18
+ obj, path =
19
+ if payload.start_with?("$$")
20
+ [context, payload[1..]]
21
+ else
22
+ [input, payload]
23
+ end
24
+
25
+ results = JsonPath.on(obj, path)
26
+
27
+ results.count < 2 ? results.first : results
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :payload
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ class Workflow
6
+ class PayloadTemplate
7
+ def initialize(payload)
8
+ @payload = payload
9
+ end
10
+
11
+ def value(context, inputs = {})
12
+ interpolate_value_nested(payload, context, inputs)
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :payload
18
+
19
+ def interpolate_value_nested(value, context, inputs)
20
+ case value
21
+ when Array
22
+ value.map { |val| interpolate_value_nested(val, context, inputs) }
23
+ when Hash
24
+ value.to_h do |key, val|
25
+ val = interpolate_value_nested(val, context, inputs)
26
+ key = key.gsub(/\.\$$/, "") if key.end_with?(".$")
27
+
28
+ [key, val]
29
+ end
30
+ when String
31
+ value.start_with?("$") ? Path.value(value, context, inputs) : value
32
+ else
33
+ value
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ class Workflow
6
+ class ReferencePath < Path
7
+ class << self
8
+ def set (payload, context, value)
9
+ new(payload).set(context, value)
10
+ end
11
+ end
12
+
13
+ def initialize(*)
14
+ require "more_core_extensions/core_ext/hash/nested"
15
+ require "more_core_extensions/core_ext/array/nested"
16
+
17
+ super
18
+
19
+ raise ManageIQ::Floe::InvalidWorkflowError, "Invalid Reference Path" if payload.match?(/@|,|:|\?/)
20
+ end
21
+
22
+ def set(context, value)
23
+ result = context.dup
24
+
25
+ path = JsonPath.new(payload)
26
+ .path[1..]
27
+ .map { |v| v.match(/\[(?<name>.+)\]/)["name"] }
28
+ .map { |v| v[0] == "'" ? v.gsub("'", "") : v.to_i }
29
+ .compact
30
+
31
+ # If the payload is '$' then merge the value into the context
32
+ # otherwise use store path to set the value to a sub-key
33
+ #
34
+ # TODO: how to handle non-hash values, raise error if path=$ and value not a hash?
35
+ if path.empty?
36
+ result.merge!(value)
37
+ else
38
+ result.store_path(path, value)
39
+ end
40
+
41
+ result
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ManageIQ
4
+ module Floe
5
+ class Workflow
6
+ class Retrier
7
+ attr_reader :error_equals, :interval_seconds, :max_attempts, :backoff_rate
8
+
9
+ def initialize(payload)
10
+ @payload = payload
11
+
12
+ @error_equals = payload["ErrorEquals"]
13
+ @interval_seconds = payload["IntervalSeconds"] || 1.0
14
+ @max_attempts = payload["MaxAttempts"] || 3
15
+ @backoff_rate = payload["BackoffRate"] || 2.0
16
+ end
17
+
18
+ def sleep_duration(attempt)
19
+ interval_seconds * (backoff_rate * attempt)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,45 @@
1
+ module ManageIQ
2
+ module Floe
3
+ class Workflow
4
+ class Runner
5
+ class Docker < ManageIQ::Floe::Workflow::Runner
6
+ def initialize(*)
7
+ require "awesome_spawn"
8
+ require "tempfile"
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, [:net, "host"]]
19
+ params += env.map { |k, v| [:e, "#{k}=#{v}"] } if env
20
+
21
+ secrets_file = nil
22
+
23
+ if secrets && !secrets.empty?
24
+ secrets_file = Tempfile.new
25
+ secrets_file.write(secrets.to_json)
26
+ secrets_file.flush
27
+
28
+ params << [:e, "SECRETS=/run/secrets"]
29
+ params << [:v, "#{secrets_file.path}:/run/secrets:z"]
30
+ end
31
+
32
+ params << image
33
+
34
+ logger.debug("Running docker: #{AwesomeSpawn.build_command_line("docker", params)}")
35
+ result = AwesomeSpawn.run!("docker", :params => params)
36
+
37
+ [result.exit_status, result.output]
38
+ ensure
39
+ secrets_file&.close!
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end