floe 0.3.1 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -1
- data/Gemfile +3 -0
- data/README.md +43 -2
- data/exe/floe +3 -3
- data/floe.gemspec +0 -4
- data/lib/floe/null_logger.rb +1 -1
- data/lib/floe/version.rb +1 -1
- data/lib/floe/workflow/choice_rule/data.rb +6 -6
- data/lib/floe/workflow/context.rb +48 -0
- data/lib/floe/workflow/runner/docker.rb +84 -15
- data/lib/floe/workflow/runner/kubernetes.rb +46 -8
- data/lib/floe/workflow/runner/podman.rb +81 -11
- data/lib/floe/workflow/runner.rb +21 -1
- data/lib/floe/workflow/state.rb +60 -0
- data/lib/floe/workflow/states/choice.rb +6 -4
- data/lib/floe/workflow/states/fail.rb +8 -4
- data/lib/floe/workflow/states/map.rb +1 -0
- data/lib/floe/workflow/states/parallel.rb +1 -0
- data/lib/floe/workflow/states/pass.rb +6 -4
- data/lib/floe/workflow/states/succeed.rb +6 -4
- data/lib/floe/workflow/states/task.rb +61 -16
- data/lib/floe/workflow/states/wait.rb +13 -6
- data/lib/floe/workflow.rb +60 -36
- metadata +2 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7a1c4e51ef3ec3052e8c3c150070464ed1a8b463a3b1237774b8f1b2ff416dc
|
|
4
|
+
data.tar.gz: 8cd6fd6e8c7bed14635d906699f13dde1f5e666018f0c62709972dbddc1999cc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 73bf61a41d240922786746b0ace87c616311c4bd3f63ac59883096e53b0fce766ef791db3270a29cf257cd5315f5764d69c91bcfd4817efcadfaae72be4c226b
|
|
7
|
+
data.tar.gz: 3cb8f1ad58c7b1b7895363f46871b38b300a66eb40ebb69d2fc8ffd1b5ec303c982c0f49f89f9575b209b5e0671c2570e54670078da4e4916a3718050c9e0b9d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,11 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.0] - 2023-09-26
|
|
8
|
+
### Added
|
|
9
|
+
- Add ability to run workflows asynchronously ([#52](https://github.com/ManageIQ/floe/pull/92))
|
|
10
|
+
- Add Workflow.wait, Workflow#step_nonblock, Workflow#step_nonblock_wait ([#92](https://github.com/ManageIQ/floe/pull/92))
|
|
11
|
+
|
|
7
12
|
## [0.3.1] - 2023-08-29
|
|
8
13
|
### Added
|
|
9
14
|
- Add more global podman runner options ([#90])(https://github.com/ManageIQ/floe/pull/90)
|
|
@@ -58,7 +63,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|
|
58
63
|
### Added
|
|
59
64
|
- Initial release
|
|
60
65
|
|
|
61
|
-
[Unreleased]: https://github.com/ManageIQ/floe/compare/v0.
|
|
66
|
+
[Unreleased]: https://github.com/ManageIQ/floe/compare/v0.4.0...HEAD
|
|
67
|
+
[0.4.0]: https://github.com/ManageIQ/floe/compare/v0.3.1...v0.4.0
|
|
62
68
|
[0.3.1]: https://github.com/ManageIQ/floe/compare/v0.3.0...v0.3.1
|
|
63
69
|
[0.3.0]: https://github.com/ManageIQ/floe/compare/v0.2.3...v0.3.0
|
|
64
70
|
[0.2.3]: https://github.com/ManageIQ/floe/compare/v0.2.2...v0.2.3
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -56,7 +56,7 @@ bundle exec ruby exe/floe --workflow my-workflow.asl --credentials='{"roleArn":
|
|
|
56
56
|
```ruby
|
|
57
57
|
require 'floe'
|
|
58
58
|
|
|
59
|
-
workflow = Floe::Workflow.load(
|
|
59
|
+
workflow = Floe::Workflow.load("workflow.asl")
|
|
60
60
|
workflow.run!
|
|
61
61
|
```
|
|
62
62
|
|
|
@@ -68,10 +68,51 @@ Floe::Workflow::Runner.docker_runner = Floe::Workflow::Runner::Podman.new
|
|
|
68
68
|
# Or
|
|
69
69
|
Floe::Workflow::Runner.docker_runner = Floe::Workflow::Runner::Kubernetes.new("namespace" => "default", "server" => "https://k8s.example.com:6443", "token" => "my-token")
|
|
70
70
|
|
|
71
|
-
workflow = Floe::Workflow.load(
|
|
71
|
+
workflow = Floe::Workflow.load("workflow.asl")
|
|
72
72
|
workflow.run!
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
### Non-Blocking Workflow Execution
|
|
76
|
+
|
|
77
|
+
It is also possible to step through a workflow without blocking, and any state which
|
|
78
|
+
would block will return `Errno::EAGAIN`.
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
require 'floe'
|
|
82
|
+
|
|
83
|
+
workflow = Floe::Workflow.load("workflow.asl")
|
|
84
|
+
|
|
85
|
+
# Step through the workflow while it would not block
|
|
86
|
+
workflow.run_nonblock
|
|
87
|
+
|
|
88
|
+
# Go off and do some other task
|
|
89
|
+
|
|
90
|
+
# Continue stepping until the workflow is finished
|
|
91
|
+
workflow.run_nonblock
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
You can also use the `Floe::Workflow.wait` class method to wait on multiple workflows
|
|
95
|
+
and return all that are ready to be stepped through.
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
require 'floe'
|
|
99
|
+
|
|
100
|
+
workflow1 = Floe::Workflow.load("workflow1.asl")
|
|
101
|
+
workflow2 = Floe::Workflow.load("workflow2.asl")
|
|
102
|
+
|
|
103
|
+
running_workflows = [workflow1, workflow2]
|
|
104
|
+
until running_workflows.empty?
|
|
105
|
+
# Wait for any of the running workflows to be ready (up to the timeout)
|
|
106
|
+
ready_workflows = Floe::Workflow.wait(running_workflows)
|
|
107
|
+
# Step through the ready workflows until they would block
|
|
108
|
+
ready_workflows.each do |workflow|
|
|
109
|
+
loop while workflow.step_nonblock == 0
|
|
110
|
+
end
|
|
111
|
+
# Remove any finished workflows from the list of running_workflows
|
|
112
|
+
running_workflows.reject!(&:end?)
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
75
116
|
### Docker Runner Options
|
|
76
117
|
|
|
77
118
|
#### Docker
|
data/exe/floe
CHANGED
|
@@ -18,9 +18,6 @@ Optimist.die(:docker_runner, "must be one of #{Floe::Workflow::Runner::TYPES.joi
|
|
|
18
18
|
require "logger"
|
|
19
19
|
Floe.logger = Logger.new($stdout)
|
|
20
20
|
|
|
21
|
-
context = Floe::Workflow::Context.new(input: opts[:input])
|
|
22
|
-
workflow = Floe::Workflow.load(opts[:workflow], context, opts[:credentials])
|
|
23
|
-
|
|
24
21
|
runner_klass = case opts[:docker_runner]
|
|
25
22
|
when "docker"
|
|
26
23
|
Floe::Workflow::Runner::Docker
|
|
@@ -34,6 +31,9 @@ runner_options = opts[:docker_runner_options].to_h { |opt| opt.split("=", 2) }
|
|
|
34
31
|
|
|
35
32
|
Floe::Workflow::Runner.docker_runner = runner_klass.new(runner_options)
|
|
36
33
|
|
|
34
|
+
context = Floe::Workflow::Context.new(:input => opts[:input])
|
|
35
|
+
workflow = Floe::Workflow.load(opts[:workflow], context, opts[:credentials])
|
|
36
|
+
|
|
37
37
|
workflow.run!
|
|
38
38
|
|
|
39
39
|
puts workflow.output.inspect
|
data/floe.gemspec
CHANGED
|
@@ -34,8 +34,4 @@ Gem::Specification.new do |spec|
|
|
|
34
34
|
spec.add_dependency "kubeclient", "~>4.7"
|
|
35
35
|
spec.add_dependency "more_core_extensions"
|
|
36
36
|
spec.add_dependency "optimist", "~>3.0"
|
|
37
|
-
|
|
38
|
-
spec.add_development_dependency "manageiq-style"
|
|
39
|
-
spec.add_development_dependency "rspec"
|
|
40
|
-
spec.add_development_dependency "rubocop"
|
|
41
37
|
end
|
data/lib/floe/null_logger.rb
CHANGED
data/lib/floe/version.rb
CHANGED
|
@@ -51,27 +51,27 @@ module Floe
|
|
|
51
51
|
raise "No such variable [#{variable}]" if value.nil? && !%w[IsNull IsPresent].include?(compare_key)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def is_null?(value)
|
|
54
|
+
def is_null?(value) # rubocop:disable Naming/PredicateName
|
|
55
55
|
value.nil?
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def is_present?(value)
|
|
58
|
+
def is_present?(value) # rubocop:disable Naming/PredicateName
|
|
59
59
|
!value.nil?
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
def is_numeric?(value)
|
|
62
|
+
def is_numeric?(value) # rubocop:disable Naming/PredicateName
|
|
63
63
|
value.kind_of?(Integer) || value.kind_of?(Float)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
def is_string?(value)
|
|
66
|
+
def is_string?(value) # rubocop:disable Naming/PredicateName
|
|
67
67
|
value.kind_of?(String)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def is_boolean?(value)
|
|
70
|
+
def is_boolean?(value) # rubocop:disable Naming/PredicateName
|
|
71
71
|
[true, false].include?(value)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def is_timestamp?(value)
|
|
74
|
+
def is_timestamp?(value) # rubocop:disable Naming/PredicateName
|
|
75
75
|
require "date"
|
|
76
76
|
|
|
77
77
|
DateTime.rfc3339(value)
|
|
@@ -21,10 +21,58 @@ module Floe
|
|
|
21
21
|
@context["Execution"]
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
def started?
|
|
25
|
+
execution.key?("StartTime")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def running?
|
|
29
|
+
started? && !ended?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ended?
|
|
33
|
+
execution.key?("EndTime")
|
|
34
|
+
end
|
|
35
|
+
|
|
24
36
|
def state
|
|
25
37
|
@context["State"]
|
|
26
38
|
end
|
|
27
39
|
|
|
40
|
+
def input
|
|
41
|
+
state["Input"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def output
|
|
45
|
+
state["Output"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def output=(val)
|
|
49
|
+
state["Output"] = val
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def state_name
|
|
53
|
+
state["Name"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def next_state
|
|
57
|
+
state["NextState"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def next_state=(val)
|
|
61
|
+
state["NextState"] = val
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def status
|
|
65
|
+
if !started?
|
|
66
|
+
"pending"
|
|
67
|
+
elsif running?
|
|
68
|
+
"running"
|
|
69
|
+
elsif state["Error"]
|
|
70
|
+
"failure"
|
|
71
|
+
else
|
|
72
|
+
"success"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
28
76
|
def state=(val)
|
|
29
77
|
@context["State"] = val
|
|
30
78
|
end
|
|
@@ -18,34 +18,103 @@ module Floe
|
|
|
18
18
|
|
|
19
19
|
image = resource.sub("docker://", "")
|
|
20
20
|
|
|
21
|
-
params = ["run", :rm]
|
|
22
|
-
params += [[:net, "host"]] if network == "host"
|
|
23
|
-
params += env.map { |k, v| [:e, "#{k}=#{v}"] } if env
|
|
24
|
-
|
|
25
21
|
secrets_file = nil
|
|
22
|
+
if secrets && !secrets.empty?
|
|
23
|
+
secrets_file = create_secret(secrets)
|
|
24
|
+
env["_CREDENTIALS"] = "/run/secrets"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
output = run_container(image, env, secrets_file)
|
|
28
|
+
|
|
29
|
+
{"exit_code" => 0, "output" => output}
|
|
30
|
+
ensure
|
|
31
|
+
cleanup({"secrets_ref" => secrets_file})
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run_async!(resource, env = {}, secrets = {})
|
|
35
|
+
raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
|
|
36
|
+
|
|
37
|
+
image = resource.sub("docker://", "")
|
|
38
|
+
|
|
39
|
+
runner_context = {}
|
|
26
40
|
|
|
27
41
|
if secrets && !secrets.empty?
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
runner_context["secrets_ref"] = create_secret(secrets)
|
|
43
|
+
env["_CREDENTIALS"] = "/run/secrets"
|
|
44
|
+
end
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
begin
|
|
47
|
+
runner_context["container_ref"] = run_container(image, env, runner_context["secrets_ref"], :detached => true)
|
|
48
|
+
rescue
|
|
49
|
+
cleanup(runner_context)
|
|
50
|
+
raise
|
|
34
51
|
end
|
|
35
52
|
|
|
36
|
-
|
|
53
|
+
runner_context
|
|
54
|
+
end
|
|
37
55
|
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
def cleanup(runner_context)
|
|
57
|
+
container_id, secrets_file = runner_context.values_at("container_ref", "secrets_ref")
|
|
40
58
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
delete_container(container_id) if container_id
|
|
60
|
+
File.unlink(secrets_file) if secrets_file && File.exist?(secrets_file)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status!(runner_context)
|
|
64
|
+
runner_context["container_state"] = inspect_container(runner_context["container_ref"]).first&.dig("State")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def running?(runner_context)
|
|
68
|
+
runner_context.dig("container_state", "Running")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def success?(runner_context)
|
|
72
|
+
runner_context.dig("container_state", "ExitCode") == 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def output(runner_context)
|
|
76
|
+
output = docker!("logs", runner_context["container_ref"]).output
|
|
77
|
+
runner_context["output"] = output
|
|
44
78
|
end
|
|
45
79
|
|
|
46
80
|
private
|
|
47
81
|
|
|
48
82
|
attr_reader :network
|
|
83
|
+
|
|
84
|
+
def run_container(image, env, secrets_file, detached: false)
|
|
85
|
+
params = ["run"]
|
|
86
|
+
params << (detached ? :detach : :rm)
|
|
87
|
+
params += env.map { |k, v| [:e, "#{k}=#{v}"] }
|
|
88
|
+
params << [:net, "host"] if @network == "host"
|
|
89
|
+
params << [:v, "#{secrets_file}:/run/secrets:z"] if secrets_file
|
|
90
|
+
params << image
|
|
91
|
+
|
|
92
|
+
logger.debug("Running docker: #{AwesomeSpawn.build_command_line("docker", params)}")
|
|
93
|
+
|
|
94
|
+
result = docker!(*params)
|
|
95
|
+
result.output
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def inspect_container(container_id)
|
|
99
|
+
JSON.parse(docker!("inspect", container_id).output)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def delete_container(container_id)
|
|
103
|
+
docker!("rm", container_id)
|
|
104
|
+
rescue
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def create_secret(secrets)
|
|
109
|
+
secrets_file = Tempfile.new
|
|
110
|
+
secrets_file.write(secrets.to_json)
|
|
111
|
+
secrets_file.close
|
|
112
|
+
secrets_file.path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def docker!(*params, **kwargs)
|
|
116
|
+
AwesomeSpawn.run!("docker", :params => params, **kwargs)
|
|
117
|
+
end
|
|
49
118
|
end
|
|
50
119
|
end
|
|
51
120
|
end
|
|
@@ -46,27 +46,65 @@ module Floe
|
|
|
46
46
|
secret = create_secret!(secrets) if secrets && !secrets.empty?
|
|
47
47
|
|
|
48
48
|
begin
|
|
49
|
+
runner_context = {"container_ref" => name}
|
|
50
|
+
|
|
49
51
|
create_pod!(name, image, env, secret)
|
|
50
52
|
loop do
|
|
51
53
|
case pod_info(name).dig("status", "phase")
|
|
52
54
|
when "Pending", "Running"
|
|
53
55
|
sleep(1)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
else # also "Succeeded"
|
|
57
|
+
runner_context["exit_code"] = 0
|
|
58
|
+
output(runner_context)
|
|
59
|
+
break
|
|
58
60
|
end
|
|
59
61
|
end
|
|
62
|
+
|
|
63
|
+
runner_context
|
|
60
64
|
ensure
|
|
61
|
-
cleanup(name, secret)
|
|
65
|
+
cleanup({"container_ref" => name, "secrets_ref" => secret})
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_async!(resource, env = {}, secrets = {})
|
|
70
|
+
raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
|
|
71
|
+
|
|
72
|
+
image = resource.sub("docker://", "")
|
|
73
|
+
name = pod_name(image)
|
|
74
|
+
secret = create_secret!(secrets) if secrets && !secrets.empty?
|
|
75
|
+
|
|
76
|
+
runner_context = {"container_ref" => name, "secrets_ref" => secret}
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
create_pod!(name, image, env, secret)
|
|
80
|
+
rescue
|
|
81
|
+
cleanup(runner_context)
|
|
82
|
+
raise
|
|
62
83
|
end
|
|
84
|
+
|
|
85
|
+
runner_context
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def status!(runner_context)
|
|
89
|
+
runner_context["container_state"] = pod_info(runner_context["container_ref"])["status"]
|
|
63
90
|
end
|
|
64
91
|
|
|
65
|
-
def
|
|
66
|
-
|
|
92
|
+
def running?(runner_context)
|
|
93
|
+
%w[Pending Running].include?(runner_context.dig("container_state", "phase"))
|
|
67
94
|
end
|
|
68
95
|
|
|
69
|
-
def
|
|
96
|
+
def success?(runner_context)
|
|
97
|
+
runner_context.dig("container_state", "phase") == "Succeeded"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def output(runner_context)
|
|
101
|
+
output = kubeclient.get_pod_log(runner_context["container_ref"], namespace).body
|
|
102
|
+
runner_context["output"] = output
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def cleanup(runner_context)
|
|
106
|
+
pod, secret = runner_context.values_at("container_ref", "secrets_ref")
|
|
107
|
+
|
|
70
108
|
delete_pod(pod) if pod
|
|
71
109
|
delete_secret(secret) if secret
|
|
72
110
|
end
|
|
@@ -31,29 +31,99 @@ module Floe
|
|
|
31
31
|
|
|
32
32
|
image = resource.sub("docker://", "")
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
if secrets && !secrets.empty?
|
|
35
|
+
secret = create_secret(secrets)
|
|
36
|
+
env["_CREDENTIALS"] = "/run/secrets/#{secret}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
output = run_container(image, env, secret)
|
|
40
|
+
|
|
41
|
+
{"exit_code" => 0, :output => output}
|
|
42
|
+
ensure
|
|
43
|
+
delete_secret(secret) if secret
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run_async!(resource, env = {}, secrets = {})
|
|
47
|
+
raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
|
|
48
|
+
|
|
49
|
+
image = resource.sub("docker://", "")
|
|
37
50
|
|
|
38
51
|
if secrets && !secrets.empty?
|
|
39
|
-
secret_guid =
|
|
40
|
-
|
|
52
|
+
secret_guid = create_secret(secrets)
|
|
53
|
+
env["_CREDENTIALS"] = "/run/secrets/#{secret_guid}"
|
|
54
|
+
end
|
|
41
55
|
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
begin
|
|
57
|
+
container_id = run_container(image, env, secret_guid, :detached => true)
|
|
58
|
+
rescue
|
|
59
|
+
cleanup({"container_ref" => container_id, "secrets_ref" => secret_guid})
|
|
60
|
+
raise
|
|
44
61
|
end
|
|
45
62
|
|
|
63
|
+
{"container_ref" => container_id, "secrets_ref" => secret_guid}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cleanup(runner_context)
|
|
67
|
+
container_id, secret_guid = runner_context.values_at("container_ref", "secrets_ref")
|
|
68
|
+
|
|
69
|
+
delete_container(container_id) if container_id
|
|
70
|
+
delete_secret(secret_guid) if secret_guid
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def status!(runner_context)
|
|
74
|
+
runner_context["container_state"] = inspect_container(runner_context["container_ref"]).first&.dig("State")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def running?(runner_context)
|
|
78
|
+
runner_context.dig("container_state", "Running")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def success?(runner_context)
|
|
82
|
+
runner_context.dig("container_state", "ExitCode") == 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def output(runner_context)
|
|
86
|
+
output = podman!("logs", runner_context["container_ref"]).output
|
|
87
|
+
runner_context["output"] = output
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def run_container(image, env, secret, detached: false)
|
|
93
|
+
params = ["run"]
|
|
94
|
+
params << (detached ? :detach : :rm)
|
|
95
|
+
params += env.map { |k, v| [:e, "#{k}=#{v}"] }
|
|
96
|
+
params << [:net, "host"] if @network == "host"
|
|
97
|
+
params << [:secret, secret] if secret
|
|
46
98
|
params << image
|
|
47
99
|
|
|
48
100
|
logger.debug("Running podman: #{AwesomeSpawn.build_command_line("podman", params)}")
|
|
101
|
+
|
|
49
102
|
result = podman!(*params)
|
|
103
|
+
result.output
|
|
104
|
+
end
|
|
50
105
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
AwesomeSpawn.run("podman", :params => ["secret", "rm", secret_guid]) if secret_guid
|
|
106
|
+
def inspect_container(container_id)
|
|
107
|
+
JSON.parse(podman!("inspect", container_id).output)
|
|
54
108
|
end
|
|
55
109
|
|
|
56
|
-
|
|
110
|
+
def delete_container(container_id)
|
|
111
|
+
podman!("rm", container_id)
|
|
112
|
+
rescue
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def create_secret(secrets)
|
|
117
|
+
secret_guid = SecureRandom.uuid
|
|
118
|
+
podman!("secret", "create", secret_guid, "-", :in_data => secrets.to_json)
|
|
119
|
+
secret_guid
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def delete_secret(secret_guid)
|
|
123
|
+
podman!("secret", "rm", secret_guid)
|
|
124
|
+
rescue
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
57
127
|
|
|
58
128
|
def podman!(*args, **kwargs)
|
|
59
129
|
params = podman_global_options + args
|
data/lib/floe/workflow/runner.rb
CHANGED
|
@@ -30,7 +30,27 @@ module Floe
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def run!(
|
|
33
|
+
def run!(resource, env = {}, secrets = {})
|
|
34
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_async!(_image, _env = {}, _secrets = {})
|
|
38
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def running?(_ref)
|
|
42
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def success?(_ref)
|
|
46
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def output(_ref)
|
|
50
|
+
raise NotImplementedError, "Must be implemented in a subclass"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cleanup(_ref, _secret)
|
|
34
54
|
raise NotImplementedError, "Must be implemented in a subclass"
|
|
35
55
|
end
|
|
36
56
|
end
|
data/lib/floe/workflow/state.rb
CHANGED
|
@@ -29,9 +29,69 @@ module Floe
|
|
|
29
29
|
@comment = payload["Comment"]
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def run!(_input = nil)
|
|
33
|
+
run_wait until run_nonblock! == 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_wait(timeout: 5)
|
|
37
|
+
start = Time.now.utc
|
|
38
|
+
|
|
39
|
+
loop do
|
|
40
|
+
return 0 if ready?
|
|
41
|
+
return Errno::EAGAIN if timeout.zero? || Time.now.utc - start > timeout
|
|
42
|
+
|
|
43
|
+
sleep(1)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run_nonblock!
|
|
48
|
+
start(context.input) unless started?
|
|
49
|
+
return Errno::EAGAIN unless ready?
|
|
50
|
+
|
|
51
|
+
finish
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start(_input)
|
|
55
|
+
start_time = Time.now.utc.iso8601
|
|
56
|
+
|
|
57
|
+
context.execution["StartTime"] ||= start_time
|
|
58
|
+
context.state["Guid"] = SecureRandom.uuid
|
|
59
|
+
context.state["EnteredTime"] = start_time
|
|
60
|
+
|
|
61
|
+
logger.info("Running state: [#{context.state_name}] with input [#{context.input}]...")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def finish
|
|
65
|
+
finished_time = Time.now.utc
|
|
66
|
+
finished_time_iso = finished_time.iso8601
|
|
67
|
+
entered_time = Time.parse(context.state["EnteredTime"])
|
|
68
|
+
|
|
69
|
+
context.state["FinishedTime"] ||= finished_time_iso
|
|
70
|
+
context.state["Duration"] = finished_time - entered_time
|
|
71
|
+
context.execution["EndTime"] = finished_time_iso if context.next_state.nil?
|
|
72
|
+
|
|
73
|
+
logger.info("Running state: [#{context.state_name}] with input [#{context.input}]...Complete - next state: [#{context.next_state}] output: [#{context.output}]")
|
|
74
|
+
|
|
75
|
+
context.state_history << context.state
|
|
76
|
+
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
32
80
|
def context
|
|
33
81
|
workflow.context
|
|
34
82
|
end
|
|
83
|
+
|
|
84
|
+
def started?
|
|
85
|
+
context.state.key?("EnteredTime")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ready?
|
|
89
|
+
!started? || !running?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def finished?
|
|
93
|
+
context.state.key?("FinishedTime")
|
|
94
|
+
end
|
|
35
95
|
end
|
|
36
96
|
end
|
|
37
97
|
end
|
|
@@ -16,16 +16,18 @@ module Floe
|
|
|
16
16
|
@output_path = Path.new(payload.fetch("OutputPath", "$"))
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def start(input)
|
|
20
|
+
super
|
|
20
21
|
input = input_path.value(context, input)
|
|
21
22
|
next_state = choices.detect { |choice| choice.true?(context, input) }&.next || default
|
|
22
23
|
output = output_path.value(context, input)
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
context.next_state = next_state
|
|
26
|
+
context.output = output
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
+
def running?
|
|
30
|
+
false
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
def end?
|
|
@@ -13,12 +13,16 @@ module Floe
|
|
|
13
13
|
@error = payload["Error"]
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
|
|
16
|
+
def start(input)
|
|
17
|
+
super
|
|
18
|
+
context.state["Error"] = error
|
|
19
|
+
context.state["Cause"] = cause
|
|
20
|
+
context.next_state = nil
|
|
21
|
+
context.output = input
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
def
|
|
21
|
-
|
|
24
|
+
def running?
|
|
25
|
+
false
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def end?
|
|
@@ -19,16 +19,18 @@ module Floe
|
|
|
19
19
|
@result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def start(input)
|
|
23
|
+
super
|
|
23
24
|
output = input_path.value(context, input)
|
|
24
25
|
output = result_path.set(output, result) if result && result_path
|
|
25
26
|
output = output_path.value(context, output)
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
context.next_state = end? ? nil : @next
|
|
29
|
+
context.output = output
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
+
def running?
|
|
33
|
+
false
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def end?
|
|
@@ -15,6 +15,7 @@ module Floe
|
|
|
15
15
|
@next = payload["Next"]
|
|
16
16
|
@end = !!payload["End"]
|
|
17
17
|
@resource = payload["Resource"]
|
|
18
|
+
@runner = Floe::Workflow::Runner.for_resource(@resource)
|
|
18
19
|
@timeout_seconds = payload["TimeoutSeconds"]
|
|
19
20
|
@retry = payload["Retry"].to_a.map { |retrier| Retrier.new(retrier) }
|
|
20
21
|
@catch = payload["Catch"].to_a.map { |catcher| Catcher.new(catcher) }
|
|
@@ -26,27 +27,37 @@ module Floe
|
|
|
26
27
|
@credentials = PayloadTemplate.new(payload["Credentials"]) if payload["Credentials"]
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def
|
|
30
|
+
def start(input)
|
|
31
|
+
super
|
|
30
32
|
input = input_path.value(context, input)
|
|
31
33
|
input = parameters.value(context, input) if parameters
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
runner_context = runner.run_async!(resource, input, credentials&.value({}, workflow.credentials))
|
|
36
|
+
context.state["RunnerContext"] = runner_context
|
|
37
|
+
end
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
def status
|
|
40
|
+
@end ? "success" : "running"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def finish
|
|
44
|
+
results = runner.output(context.state["RunnerContext"])
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
if success?
|
|
47
|
+
context.state["Output"] = process_output!(results)
|
|
48
|
+
context.next_state = next_state
|
|
49
|
+
else
|
|
50
|
+
retry_state!(results) || catch_error!(results)
|
|
51
|
+
end
|
|
44
52
|
|
|
45
|
-
|
|
53
|
+
super
|
|
54
|
+
ensure
|
|
55
|
+
runner.cleanup(context.state["RunnerContext"])
|
|
46
56
|
end
|
|
47
57
|
|
|
48
|
-
def
|
|
49
|
-
|
|
58
|
+
def running?
|
|
59
|
+
runner.status!(context.state["RunnerContext"])
|
|
60
|
+
runner.running?(context.state["RunnerContext"])
|
|
50
61
|
end
|
|
51
62
|
|
|
52
63
|
def end?
|
|
@@ -55,7 +66,22 @@ module Floe
|
|
|
55
66
|
|
|
56
67
|
private
|
|
57
68
|
|
|
58
|
-
|
|
69
|
+
attr_reader :runner
|
|
70
|
+
|
|
71
|
+
def success?
|
|
72
|
+
runner.success?(context.state["RunnerContext"])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def find_retrier(error)
|
|
76
|
+
self.retry.detect { |r| (r.error_equals & [error, "States.ALL"]).any? }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def find_catcher(error)
|
|
80
|
+
self.catch.detect { |c| (c.error_equals & [error, "States.ALL"]).any? }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def retry_state!(error)
|
|
84
|
+
retrier = find_retrier(error)
|
|
59
85
|
return if retrier.nil?
|
|
60
86
|
|
|
61
87
|
# If a different retrier is hit reset the context
|
|
@@ -68,11 +94,26 @@ module Floe
|
|
|
68
94
|
|
|
69
95
|
return if context["State"]["RetryCount"] > retrier.max_attempts
|
|
70
96
|
|
|
71
|
-
Kernel.sleep(retrier.sleep_duration(context["State"]["RetryCount"]))
|
|
97
|
+
# TODO: Kernel.sleep(retrier.sleep_duration(context["State"]["RetryCount"]))
|
|
98
|
+
context.next_state = context.state_name
|
|
72
99
|
true
|
|
73
100
|
end
|
|
74
101
|
|
|
75
|
-
def
|
|
102
|
+
def catch_error!(error)
|
|
103
|
+
catcher = find_catcher(error)
|
|
104
|
+
raise error if catcher.nil?
|
|
105
|
+
|
|
106
|
+
context.next_state = catcher.next
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def process_input(input)
|
|
110
|
+
input = input_path.value(context, input)
|
|
111
|
+
input = parameters.value(context, input) if parameters
|
|
112
|
+
input
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def process_output!(results)
|
|
116
|
+
output = process_input(context.state["Input"])
|
|
76
117
|
return output if results.nil?
|
|
77
118
|
return if output_path.nil?
|
|
78
119
|
|
|
@@ -86,6 +127,10 @@ module Floe
|
|
|
86
127
|
output = result_path.set(output, results)
|
|
87
128
|
output_path.value(context, output)
|
|
88
129
|
end
|
|
130
|
+
|
|
131
|
+
def next_state
|
|
132
|
+
end? ? nil : @next
|
|
133
|
+
end
|
|
89
134
|
end
|
|
90
135
|
end
|
|
91
136
|
end
|
|
@@ -17,15 +17,22 @@ module Floe
|
|
|
17
17
|
@output_path = Path.new(payload.fetch("OutputPath", "$"))
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def
|
|
20
|
+
def start(input)
|
|
21
|
+
super
|
|
21
22
|
input = input_path.value(context, input)
|
|
22
|
-
|
|
23
|
-
output
|
|
24
|
-
|
|
23
|
+
|
|
24
|
+
context.output = output_path.value(context, input)
|
|
25
|
+
context.next_state = end? ? nil : @next
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
def
|
|
28
|
-
|
|
28
|
+
def running?
|
|
29
|
+
now = Time.now.utc
|
|
30
|
+
if now > (Time.parse(context.state["EnteredTime"]) + @seconds)
|
|
31
|
+
context.state["FinishedTime"] = now.iso8601
|
|
32
|
+
false
|
|
33
|
+
else
|
|
34
|
+
true
|
|
35
|
+
end
|
|
29
36
|
end
|
|
30
37
|
|
|
31
38
|
def end?
|
data/lib/floe/workflow.rb
CHANGED
|
@@ -12,9 +12,26 @@ module Floe
|
|
|
12
12
|
payload = path_or_io.respond_to?(:read) ? path_or_io.read : File.read(path_or_io)
|
|
13
13
|
new(payload, context, credentials)
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
def wait(workflows, timeout: 5)
|
|
17
|
+
logger.info("checking #{workflows.count} workflows...")
|
|
18
|
+
|
|
19
|
+
start = Time.now.utc
|
|
20
|
+
ready = []
|
|
21
|
+
|
|
22
|
+
loop do
|
|
23
|
+
ready = workflows.select(&:step_nonblock_ready?)
|
|
24
|
+
break if timeout.zero? || Time.now.utc - start > timeout || !ready.empty?
|
|
25
|
+
|
|
26
|
+
sleep(1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
logger.info("checking #{workflows.count} workflows...Complete - #{ready.count} ready")
|
|
30
|
+
ready
|
|
31
|
+
end
|
|
15
32
|
end
|
|
16
33
|
|
|
17
|
-
attr_reader :context, :credentials, :
|
|
34
|
+
attr_reader :context, :credentials, :payload, :states, :states_by_name, :start_at
|
|
18
35
|
|
|
19
36
|
def initialize(payload, context = nil, credentials = {})
|
|
20
37
|
payload = JSON.parse(payload) if payload.kind_of?(String)
|
|
@@ -24,62 +41,69 @@ module Floe
|
|
|
24
41
|
@payload = payload
|
|
25
42
|
@context = context
|
|
26
43
|
@credentials = credentials
|
|
44
|
+
@start_at = payload["StartAt"]
|
|
27
45
|
|
|
28
46
|
@states = payload["States"].to_a.map { |name, state| State.build!(self, name, state) }
|
|
29
47
|
@states_by_name = @states.each_with_object({}) { |state, result| result[state.name] = state }
|
|
30
|
-
start_at = @payload["StartAt"]
|
|
31
|
-
|
|
32
|
-
context.state["Name"] ||= start_at
|
|
33
|
-
|
|
34
|
-
current_state_name = context.state["Name"]
|
|
35
|
-
@current_state = @states_by_name[current_state_name]
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
unless context.state.key?("Name")
|
|
50
|
+
context.state["Name"] = start_at
|
|
51
|
+
context.state["Input"] = context.execution["Input"].dup
|
|
52
|
+
end
|
|
38
53
|
rescue JSON::ParserError => err
|
|
39
54
|
raise Floe::InvalidWorkflowError, err.message
|
|
40
55
|
end
|
|
41
56
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
context.state["Guid"] = SecureRandom.uuid
|
|
47
|
-
context.state["Input"] ||= context.execution["Input"].dup
|
|
57
|
+
def run!
|
|
58
|
+
step until end?
|
|
59
|
+
self
|
|
60
|
+
end
|
|
48
61
|
|
|
49
|
-
|
|
62
|
+
def step
|
|
63
|
+
step_nonblock_wait until step_nonblock == 0
|
|
64
|
+
self
|
|
65
|
+
end
|
|
50
66
|
|
|
51
|
-
|
|
67
|
+
def run_nonblock
|
|
68
|
+
loop while step_nonblock == 0 && !end?
|
|
69
|
+
self
|
|
70
|
+
end
|
|
52
71
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
tock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
72
|
+
def step_nonblock
|
|
73
|
+
return Errno::EPERM if end?
|
|
56
74
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
step_next
|
|
76
|
+
current_state.run_nonblock!
|
|
77
|
+
end
|
|
60
78
|
|
|
61
|
-
|
|
79
|
+
def step_nonblock_wait(timeout: 5)
|
|
80
|
+
current_state.run_wait(:timeout => timeout)
|
|
81
|
+
end
|
|
62
82
|
|
|
63
|
-
|
|
83
|
+
def step_nonblock_ready?
|
|
84
|
+
current_state.ready?
|
|
85
|
+
end
|
|
64
86
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
def status
|
|
88
|
+
context.status
|
|
89
|
+
end
|
|
68
90
|
|
|
69
|
-
|
|
91
|
+
def output
|
|
92
|
+
context.output if end?
|
|
93
|
+
end
|
|
70
94
|
|
|
71
|
-
|
|
95
|
+
def end?
|
|
96
|
+
context.ended?
|
|
72
97
|
end
|
|
73
98
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
step
|
|
77
|
-
end
|
|
78
|
-
self
|
|
99
|
+
def current_state
|
|
100
|
+
@states_by_name[context.state_name]
|
|
79
101
|
end
|
|
80
102
|
|
|
81
|
-
|
|
82
|
-
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def step_next
|
|
106
|
+
context.state = {"Name" => context.next_state, "Input" => context.output} if context.next_state
|
|
83
107
|
end
|
|
84
108
|
end
|
|
85
109
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: floe
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ManageIQ Developers
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2023-
|
|
11
|
+
date: 2023-09-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: awesome_spawn
|
|
@@ -80,48 +80,6 @@ dependencies:
|
|
|
80
80
|
- - "~>"
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
82
|
version: '3.0'
|
|
83
|
-
- !ruby/object:Gem::Dependency
|
|
84
|
-
name: manageiq-style
|
|
85
|
-
requirement: !ruby/object:Gem::Requirement
|
|
86
|
-
requirements:
|
|
87
|
-
- - ">="
|
|
88
|
-
- !ruby/object:Gem::Version
|
|
89
|
-
version: '0'
|
|
90
|
-
type: :development
|
|
91
|
-
prerelease: false
|
|
92
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
-
requirements:
|
|
94
|
-
- - ">="
|
|
95
|
-
- !ruby/object:Gem::Version
|
|
96
|
-
version: '0'
|
|
97
|
-
- !ruby/object:Gem::Dependency
|
|
98
|
-
name: rspec
|
|
99
|
-
requirement: !ruby/object:Gem::Requirement
|
|
100
|
-
requirements:
|
|
101
|
-
- - ">="
|
|
102
|
-
- !ruby/object:Gem::Version
|
|
103
|
-
version: '0'
|
|
104
|
-
type: :development
|
|
105
|
-
prerelease: false
|
|
106
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
-
requirements:
|
|
108
|
-
- - ">="
|
|
109
|
-
- !ruby/object:Gem::Version
|
|
110
|
-
version: '0'
|
|
111
|
-
- !ruby/object:Gem::Dependency
|
|
112
|
-
name: rubocop
|
|
113
|
-
requirement: !ruby/object:Gem::Requirement
|
|
114
|
-
requirements:
|
|
115
|
-
- - ">="
|
|
116
|
-
- !ruby/object:Gem::Version
|
|
117
|
-
version: '0'
|
|
118
|
-
type: :development
|
|
119
|
-
prerelease: false
|
|
120
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
-
requirements:
|
|
122
|
-
- - ">="
|
|
123
|
-
- !ruby/object:Gem::Version
|
|
124
|
-
version: '0'
|
|
125
83
|
description: Simple Workflow Runner.
|
|
126
84
|
email:
|
|
127
85
|
executables:
|