floe 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae01d21cee834a23c32b89a8a1418d5c02e1a7ec825305cce2ea783c9be6fc40
4
- data.tar.gz: df5f35ef07d199e929e7cf3bb0a73620a6ee5def9b555abf1409a9f74c6f7b9a
3
+ metadata.gz: e7a1c4e51ef3ec3052e8c3c150070464ed1a8b463a3b1237774b8f1b2ff416dc
4
+ data.tar.gz: 8cd6fd6e8c7bed14635d906699f13dde1f5e666018f0c62709972dbddc1999cc
5
5
  SHA512:
6
- metadata.gz: e4dcf6ccfcf37b0cd187f4dfd876d60deec4f3a1037c20f76e4b759ae61cd394f68afcb23064c3a6c30f76f56e59ff038931e1630a5e7b01393702caf339d3fa
7
- data.tar.gz: 0c588d1eb15f71e4ccd9870df09d72e0e1914f6d31d51b0275f39fb9a7fdd68930eb54f254b4dfa13cc184d0cf9b7babae671f7b2bfa5df1f87d77661b3e5036
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.3.1...HEAD
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
@@ -8,4 +8,7 @@ require File.join(Bundler::Plugin.index.load_paths("bundler-inject")[0], "bundle
8
8
  # Specify your gem's dependencies in floe.gemspec
9
9
  gemspec
10
10
 
11
+ gem "manageiq-style"
11
12
  gem "rake", "~> 13.0"
13
+ gem "rspec"
14
+ gem "rubocop"
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(File.read("workflow.asl"))
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(File.read("workflow.asl"))
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
@@ -4,7 +4,7 @@ require 'logger'
4
4
 
5
5
  module Floe
6
6
  class NullLogger < Logger
7
- def initialize(*)
7
+ def initialize(*) # rubocop:disable Lint/MissingSuper
8
8
  end
9
9
 
10
10
  def add(*_args, &_block)
data/lib/floe/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Floe
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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
- secrets_file = Tempfile.new
29
- secrets_file.write(secrets.to_json)
30
- secrets_file.flush
42
+ runner_context["secrets_ref"] = create_secret(secrets)
43
+ env["_CREDENTIALS"] = "/run/secrets"
44
+ end
31
45
 
32
- params << [:e, "_CREDENTIALS=/run/secrets"]
33
- params << [:v, "#{secrets_file.path}:/run/secrets:z"]
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
- params << image
53
+ runner_context
54
+ end
37
55
 
38
- logger.debug("Running docker: #{AwesomeSpawn.build_command_line("docker", params)}")
39
- result = AwesomeSpawn.run!("docker", :params => params)
56
+ def cleanup(runner_context)
57
+ container_id, secrets_file = runner_context.values_at("container_ref", "secrets_ref")
40
58
 
41
- [result.exit_status, result.output]
42
- ensure
43
- secrets_file&.close!
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
- when "Succeeded"
55
- return [0, output(name)]
56
- else
57
- return [1, output(name)]
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 output(pod)
66
- kubeclient.get_pod_log(pod, namespace).body
92
+ def running?(runner_context)
93
+ %w[Pending Running].include?(runner_context.dig("container_state", "phase"))
67
94
  end
68
95
 
69
- def cleanup(pod, secret)
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
- params = ["run", :rm]
35
- params += [[:net, "host"]] if @network == "host"
36
- params += env.map { |k, v| [:e, "#{k}=#{v}"] } if env
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 = SecureRandom.uuid
40
- podman!("secret", "create", secret_guid, "-", :in_data => secrets.to_json)
52
+ secret_guid = create_secret(secrets)
53
+ env["_CREDENTIALS"] = "/run/secrets/#{secret_guid}"
54
+ end
41
55
 
42
- params << [:e, "_CREDENTIALS=/run/secrets/#{secret_guid}"]
43
- params << [:secret, secret_guid]
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
- [result.exit_status, result.output]
52
- ensure
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
- private
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
@@ -30,7 +30,27 @@ module Floe
30
30
  end
31
31
  end
32
32
 
33
- def run!(image, env = {}, secrets = {})
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
@@ -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 run!(input)
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
- [next_state, output]
25
+ context.next_state = next_state
26
+ context.output = output
25
27
  end
26
28
 
27
- def status
28
- "running"
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 run!(input)
17
- [nil, input]
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 status
21
- "errored"
24
+ def running?
25
+ false
22
26
  end
23
27
 
24
28
  def end?
@@ -5,6 +5,7 @@ module Floe
5
5
  module States
6
6
  class Map < Floe::Workflow::State
7
7
  def initialize(*)
8
+ super
8
9
  raise NotImplementedError
9
10
  end
10
11
  end
@@ -5,6 +5,7 @@ module Floe
5
5
  module States
6
6
  class Parallel < Floe::Workflow::State
7
7
  def initialize(*)
8
+ super
8
9
  raise NotImplementedError
9
10
  end
10
11
  end
@@ -19,16 +19,18 @@ module Floe
19
19
  @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
20
20
  end
21
21
 
22
- def run!(input)
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
- [@end ? nil : @next, output]
28
+ context.next_state = end? ? nil : @next
29
+ context.output = output
28
30
  end
29
31
 
30
- def status
31
- @end ? "success" : "running"
32
+ def running?
33
+ false
32
34
  end
33
35
 
34
36
  def end?
@@ -10,12 +10,14 @@ module Floe
10
10
  super
11
11
  end
12
12
 
13
- def run!(input)
14
- [nil, input]
13
+ def start(input)
14
+ super
15
+ context.next_state = nil
16
+ context.output = input
15
17
  end
16
18
 
17
- def status
18
- "success"
19
+ def running?
20
+ false
19
21
  end
20
22
 
21
23
  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 run!(input)
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
- runner = Floe::Workflow::Runner.for_resource(resource)
34
- _exit_status, results = runner.run!(resource, input, credentials&.value({}, workflow.credentials))
35
+ runner_context = runner.run_async!(resource, input, credentials&.value({}, workflow.credentials))
36
+ context.state["RunnerContext"] = runner_context
37
+ end
35
38
 
36
- output = process_output!(input, results)
37
- [@end ? nil : @next, output]
38
- rescue => err
39
- retrier = self.retry.detect { |r| (r.error_equals & [err.to_s, "States.ALL"]).any? }
40
- retry if retry!(retrier)
39
+ def status
40
+ @end ? "success" : "running"
41
+ end
42
+
43
+ def finish
44
+ results = runner.output(context.state["RunnerContext"])
41
45
 
42
- catcher = self.catch.detect { |c| (c.error_equals & [err.to_s, "States.ALL"]).any? }
43
- raise if catcher.nil?
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
- [catcher.next, output]
53
+ super
54
+ ensure
55
+ runner.cleanup(context.state["RunnerContext"])
46
56
  end
47
57
 
48
- def status
49
- @end ? "success" : "running"
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
- def retry!(retrier)
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 process_output!(output, results)
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 run!(input)
20
+ def start(input)
21
+ super
21
22
  input = input_path.value(context, input)
22
- sleep(seconds)
23
- output = output_path.value(context, input)
24
- [@end ? nil : @next, output]
23
+
24
+ context.output = output_path.value(context, input)
25
+ context.next_state = end? ? nil : @next
25
26
  end
26
27
 
27
- def status
28
- @end ? "success" : "running"
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, :output, :payload, :states, :states_by_name, :current_state, :status
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
- @status = current_state_name == start_at ? "pending" : current_state.status
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 step
43
- @status = "running" if @status == "pending"
44
- context.execution["StartTime"] ||= Time.now.utc
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
- logger.info("Running state: [#{current_state.name}] with input [#{context.state["Input"]}]...")
62
+ def step
63
+ step_nonblock_wait until step_nonblock == 0
64
+ self
65
+ end
50
66
 
51
- context.state["EnteredTime"] = Time.now.utc
67
+ def run_nonblock
68
+ loop while step_nonblock == 0 && !end?
69
+ self
70
+ end
52
71
 
53
- tick = Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
- next_state, output = current_state.run!(context.state["Input"])
55
- tock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ def step_nonblock
73
+ return Errno::EPERM if end?
56
74
 
57
- context.state["FinishedTime"] = Time.now.utc
58
- context.state["Duration"] = tock - tick
59
- context.state["Output"] = output
75
+ step_next
76
+ current_state.run_nonblock!
77
+ end
60
78
 
61
- logger.info("Running state: [#{current_state.name}] with input [#{context["Input"]}]...Complete - next state: [#{next_state}] output: [#{output}]")
79
+ def step_nonblock_wait(timeout: 5)
80
+ current_state.run_wait(:timeout => timeout)
81
+ end
62
82
 
63
- context.state_history << context.state
83
+ def step_nonblock_ready?
84
+ current_state.ready?
85
+ end
64
86
 
65
- @status = current_state.status
66
- @current_state = next_state && @states_by_name[next_state]
67
- @output = output if end?
87
+ def status
88
+ context.status
89
+ end
68
90
 
69
- context.state = {"Name" => next_state, "Input" => output} unless end?
91
+ def output
92
+ context.output if end?
93
+ end
70
94
 
71
- self
95
+ def end?
96
+ context.ended?
72
97
  end
73
98
 
74
- def run!
75
- until end?
76
- step
77
- end
78
- self
99
+ def current_state
100
+ @states_by_name[context.state_name]
79
101
  end
80
102
 
81
- def end?
82
- current_state.nil?
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.3.1
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-08-29 00:00:00.000000000 Z
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: