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