floe 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 379fa04920aa1757f0e189fba83310a25f4a2b2f6faa224016c570bc47a114cd
4
+ data.tar.gz: d9558845724ac2ba5e0758953239fe582d90e99aa14d2e3dbdce9a51dd874c1a
5
+ SHA512:
6
+ metadata.gz: 22f2964e5eab2f3f710dab683b7540e2c9ef59b5dc4045f573ff8857283467be1855a660f80373bd349e83fb94848c16dd114db9bcfeb7e3e45b89f538cac4ae
7
+ data.tar.gz: d4507d552bc4bd4415f7d3efb8620100cac8f70456fe121c014085c82103ece545d2ecb69b720d784749886fca0b16367bcd561884ba7b4740cce23838fc1419
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 CHANGED
@@ -1,4 +1,11 @@
1
- source 'https://rubygems.org'
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
2
7
 
3
8
  # Specify your gem's dependencies in floe.gemspec
4
9
  gemspec
10
+
11
+ gem "rake", "~> 13.0"
data/README.md CHANGED
@@ -1,29 +1,46 @@
1
1
  # Floe
2
2
 
3
- TODO: Write a gem description
3
+ [![CI](https://github.com/ManageIQ/floe/actions/workflows/ci.yaml/badge.svg)](https://github.com/ManageIQ/floe/actions/workflows/ci.yaml)
4
4
 
5
- ## Installation
5
+ ## Overview
6
6
 
7
- Add this line to your application's Gemfile:
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
8
 
9
- gem 'floe'
9
+ ## Installation
10
10
 
11
- And then execute:
11
+ Install the gem and add to the application's Gemfile by executing:
12
12
 
13
- $ bundle
13
+ $ bundle add floe
14
14
 
15
- Or install it yourself as:
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
16
 
17
17
  $ gem install floe
18
18
 
19
19
  ## Usage
20
20
 
21
- TODO: Write usage instructions here
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/floe --workflow examples/workflow.asl --inputs='{"foo": 1}'
27
+ ```
28
+
29
+ ### Ruby Library
30
+
31
+ ```ruby
32
+ require 'floe'
33
+
34
+ workflow = 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).
22
43
 
23
44
  ## Contributing
24
45
 
25
- 1. Fork it
26
- 2. Create your feature branch (`git checkout -b my-new-feature`)
27
- 3. Commit your changes (`git commit -am 'Add some feature'`)
28
- 4. Push to the branch (`git push origin my-new-feature`)
29
- 5. Create new Pull Request
46
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ManageIQ/floe.
data/Rakefile CHANGED
@@ -1 +1,4 @@
1
+ # frozen_string_literal: true
2
+
1
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/floe ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "floe"
5
+ require "optimist"
6
+
7
+ opts = Optimist.options do
8
+ version "v#{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
+ Floe.logger = Logger.new(STDOUT)
16
+
17
+ workflow = Floe::Workflow.load(opts[:workflow], opts[:inputs], opts[:credentials])
18
+
19
+ output = workflow.run!
20
+
21
+ puts output.inspect
data/floe.gemspec CHANGED
@@ -1,23 +1,39 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'floe/version'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/floe/version"
5
4
 
6
5
  Gem::Specification.new do |spec|
7
- spec.name = "floe"
8
- spec.version = Floe::VERSION
9
- spec.authors = ["Kyle Maxwell"]
10
- spec.email = ["kyle@kylemaxwell.com"]
11
- spec.description = %q{The command-line issues client}
12
- spec.summary = %q{A quirky github issues client}
13
- spec.homepage = ""
14
- spec.license = "MIT"
15
-
16
- spec.files = `git ls-files`.split($/)
17
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
6
+ spec.name = "floe"
7
+ spec.version = Floe::VERSION
8
+ spec.authors = ["ManageIQ Developers"]
9
+
10
+ spec.summary = "Simple Workflow Runner."
11
+ spec.description = "Simple Workflow Runner."
12
+ spec.homepage = "https://github.com/ManageIQ/floe"
13
+ spec.required_ruby_version = ">= 2.7.0"
14
+
15
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
26
+ end
27
+ end
28
+ spec.bindir = "exe"
29
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
19
30
  spec.require_paths = ["lib"]
20
31
 
21
- spec.add_development_dependency "bundler", "~> 1.3"
22
- spec.add_development_dependency "rake"
32
+ spec.add_dependency "awesome_spawn", "~>1.0"
33
+ spec.add_dependency "jsonpath", "~>1.1"
34
+ spec.add_dependency "optimist", "~>3.0"
35
+ spec.add_dependency "more_core_extensions"
36
+
37
+ spec.add_development_dependency "rubocop"
38
+ spec.add_development_dependency "rspec"
23
39
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ module Logging
5
+ def self.included(base)
6
+ base.extend(self)
7
+ end
8
+
9
+ def logger
10
+ Floe.logger
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Floe
6
+ class NullLogger < Logger
7
+ def initialize(*_args)
8
+ end
9
+
10
+ def add(*_args, &_block)
11
+ end
12
+ end
13
+ end
data/lib/floe/version.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Floe
2
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
3
5
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class Catcher
6
+ attr_reader :error_equals, :next, :result_path
7
+
8
+ def initialize(payload)
9
+ @payload = payload
10
+
11
+ @error_equals = payload["ErrorEquals"]
12
+ @next = payload["Next"]
13
+ @result_path = payload.fetch("ResultPath", "$")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class ChoiceRule
6
+ class Boolean < Floe::Workflow::ChoiceRule
7
+ def true?(context, input)
8
+ if payload.key?("Not")
9
+ !ChoiceRule.true?(payload["Not"], context, input)
10
+ elsif payload.key?("And")
11
+ payload["And"].all? { |choice| ChoiceRule.true?(choice, context, input) }
12
+ else
13
+ payload["Or"].any? { |choice| ChoiceRule.true?(choice, context, input) }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class ChoiceRule
6
+ class Data < Floe::Workflow::ChoiceRule
7
+ def true?(context, input)
8
+
9
+ lhs = variable_value(context, input)
10
+ rhs = compare_value(context, input)
11
+
12
+ validate!(lhs)
13
+
14
+ case compare_key
15
+ when "IsNull"; is_null?(lhs)
16
+ when "IsPresent"; is_present?(lhs)
17
+ when "IsNumeric"; is_numeric?(lhs)
18
+ when "IsString"; is_string?(lhs)
19
+ when "IsBoolean"; is_boolean?(lhs)
20
+ when "IsTimestamp"; is_timestamp?(lhs)
21
+ when "StringEquals", "StringEqualsPath",
22
+ "NumericEquals", "NumericEqualsPath",
23
+ "BooleanEquals", "BooleanEqualsPath",
24
+ "TimestampEquals", "TimestampEqualsPath"
25
+ lhs == rhs
26
+ when "StringLessThan", "StringLessThanPath",
27
+ "NumericLessThan", "NumericLessThanPath",
28
+ "TimestampLessThan", "TimestampLessThanPath"
29
+ lhs < rhs
30
+ when "StringGreaterThan", "StringGreaterThanPath",
31
+ "NumericGreaterThan", "NumericGreaterThanPath",
32
+ "TimestampGreaterThan", "TimestampGreaterThanPath"
33
+ lhs > rhs
34
+ when "StringLessThanEquals", "StringLessThanEqualsPath",
35
+ "NumericLessThanEquals", "NumericLessThanEqualsPath",
36
+ "TimestampLessThanEquals", "TimestampLessThanEqualsPath"
37
+ lhs <= rhs
38
+ when "StringGreaterThanEquals", "StringGreaterThanEqualsPath",
39
+ "NumericGreaterThanEquals", "NumericGreaterThanEqualsPath",
40
+ "TimestampGreaterThanEquals", "TimestampGreaterThanEqualsPath"
41
+ lhs >= rhs
42
+ when "StringMatches"
43
+ lhs.match?(Regexp.escape(rhs).gsub('\*','.*?'))
44
+ else
45
+ raise Floe::InvalidWorkflowError, "Invalid choice [#{compare_key}]"
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def validate!(value)
52
+ raise RuntimeError, "No such variable [#{variable}]" if value.nil? && !%w[IsNull IsPresent].include?(compare_key)
53
+ end
54
+
55
+ def is_null?(value)
56
+ value.nil?
57
+ end
58
+
59
+ def is_present?(value)
60
+ !value.nil?
61
+ end
62
+
63
+ def is_numeric?(value)
64
+ value.kind_of?(Integer) || value.kind_of?(Float)
65
+ end
66
+
67
+ def is_string?(value)
68
+ value.kind_of?(String)
69
+ end
70
+
71
+ def is_boolean?(value)
72
+ [true, false].include?(value)
73
+ end
74
+
75
+ def is_timestamp?(value)
76
+ require "date"
77
+
78
+ DateTime.rfc3339(value)
79
+ true
80
+ rescue TypeError, Date::Error
81
+ false
82
+ end
83
+
84
+ def compare_key
85
+ @compare_key ||= payload.keys.detect { |key| key.match?(/^(IsNull|IsPresent|IsNumeric|IsString|IsBoolean|IsTimestamp|String|Numeric|Boolean|Timestamp)/) }
86
+ end
87
+
88
+ def compare_value(context, input)
89
+ compare_key.end_with?("Path") ? Path.value(payload[compare_key], context, input) : payload[compare_key]
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class ChoiceRule
6
+ class << self
7
+ def true?(payload, context, input)
8
+ build(payload).true?(context, input)
9
+ end
10
+
11
+ def build(payload)
12
+ data_expression = (payload.keys & %w[And Not Or]).empty?
13
+ if data_expression
14
+ Floe::Workflow::ChoiceRule::Data.new(payload)
15
+ else
16
+ Floe::Workflow::ChoiceRule::Boolean.new(payload)
17
+ end
18
+ end
19
+ end
20
+
21
+ attr_reader :next, :payload, :variable
22
+
23
+ def initialize(payload)
24
+ @payload = payload
25
+
26
+ @next = payload["Next"]
27
+ @variable = payload["Variable"]
28
+ end
29
+
30
+ def true?(*)
31
+ raise NotImplementedError, "Must be implemented in a subclass"
32
+ end
33
+
34
+ private
35
+
36
+ def variable_value(context, input)
37
+ @variable_value ||= Path.value(variable, context, input)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class Path
6
+ class << self
7
+ def value(payload, context, input = {})
8
+ new(payload).value(context, input)
9
+ end
10
+ end
11
+
12
+ def initialize(payload)
13
+ @payload = payload
14
+ end
15
+
16
+ def value(context, input = {})
17
+ obj, path =
18
+ if payload.start_with?("$$")
19
+ [context, payload[1..]]
20
+ else
21
+ [input, payload]
22
+ end
23
+
24
+ results = JsonPath.on(obj, path)
25
+
26
+ results.count < 2 ? results.first : results
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :payload
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class PayloadTemplate
6
+ def initialize(payload)
7
+ @payload = payload
8
+ end
9
+
10
+ def value(context, inputs = {})
11
+ interpolate_value_nested(payload, context, inputs)
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :payload
17
+
18
+ def interpolate_value_nested(value, context, inputs)
19
+ case value
20
+ when Array
21
+ value.map { |val| interpolate_value_nested(val, context, inputs) }
22
+ when Hash
23
+ value.to_h do |key, val|
24
+ val = interpolate_value_nested(val, context, inputs)
25
+ key = key.gsub(/\.\$$/, "") if key.end_with?(".$")
26
+
27
+ [key, val]
28
+ end
29
+ when String
30
+ value.start_with?("$") ? Path.value(value, context, inputs) : value
31
+ else
32
+ value
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class ReferencePath < Path
6
+ class << self
7
+ def set (payload, context, value)
8
+ new(payload).set(context, value)
9
+ end
10
+ end
11
+
12
+ def initialize(*)
13
+ require "more_core_extensions/core_ext/hash/nested"
14
+ require "more_core_extensions/core_ext/array/nested"
15
+
16
+ super
17
+
18
+ raise Floe::InvalidWorkflowError, "Invalid Reference Path" if payload.match?(/@|,|:|\?/)
19
+ end
20
+
21
+ def set(context, value)
22
+ result = context.dup
23
+
24
+ path = JsonPath.new(payload)
25
+ .path[1..]
26
+ .map { |v| v.match(/\[(?<name>.+)\]/)["name"] }
27
+ .map { |v| v[0] == "'" ? v.gsub("'", "") : v.to_i }
28
+ .compact
29
+
30
+ # If the payload is '$' then merge the value into the context
31
+ # otherwise use store path to set the value to a sub-key
32
+ #
33
+ # TODO: how to handle non-hash values, raise error if path=$ and value not a hash?
34
+ if path.empty?
35
+ result.merge!(value)
36
+ else
37
+ result.store_path(path, value)
38
+ end
39
+
40
+ result
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ class Retrier
6
+ attr_reader :error_equals, :interval_seconds, :max_attempts, :backoff_rate
7
+
8
+ def initialize(payload)
9
+ @payload = payload
10
+
11
+ @error_equals = payload["ErrorEquals"]
12
+ @interval_seconds = payload["IntervalSeconds"] || 1.0
13
+ @max_attempts = payload["MaxAttempts"] || 3
14
+ @backoff_rate = payload["BackoffRate"] || 2.0
15
+ end
16
+
17
+ def sleep_duration(attempt)
18
+ interval_seconds * (backoff_rate * attempt)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -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