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