floe 0.3.1 → 0.4.1

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: b2bab2c07fd71a45bcf896aa219993770c83eedd5c4e3268c318d8b9ba0413ed
4
+ data.tar.gz: 786064609be7fee8a19855cb97fd160984b5a8a7aedc85344eab01302155a988
5
5
  SHA512:
6
- metadata.gz: e4dcf6ccfcf37b0cd187f4dfd876d60deec4f3a1037c20f76e4b759ae61cd394f68afcb23064c3a6c30f76f56e59ff038931e1630a5e7b01393702caf339d3fa
7
- data.tar.gz: 0c588d1eb15f71e4ccd9870df09d72e0e1914f6d31d51b0275f39fb9a7fdd68930eb54f254b4dfa13cc184d0cf9b7babae671f7b2bfa5df1f87d77661b3e5036
6
+ metadata.gz: 45a9fd8a85bb5b1059574c0dc63b0434496b6c75634c567aba9f0008400867b4dbf8c33337b3940084c644606dc3fe6065fc4f79ac5732449ca0d9675d0e2531
7
+ data.tar.gz: f4147cd4e1633d2af02b7cbbd04735a9ffdc733359eb7f349b23a2fb8bada9de18a4f37fad82de543f2eb8060fa6fa52d1864a159f3ab3f780cbdab1824b6ab1
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ This project adheres to [Semantic Versioning](http://semver.org/).
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.1] - 2023-10-06
8
+ ### Added
9
+ - Add Fail#CausePath and Fail#ErrorPath ([#110](https://github.com/ManageIQ/floe/pull/110))
10
+ - Add Task#Retrier incremental backoff and Wait#Timestamp ([#100](https://github.com/ManageIQ/floe/pull/100))
11
+
12
+ ### Fixed
13
+ - Combine stdout and stderr for docker and podman runners ([#104](https://github.com/ManageIQ/floe/pull/104))
14
+ - Don't raise an exception on task failure ([#115](https://github.com/ManageIQ/floe/pull/115))
15
+ - Fix task output handling ([#112](https://github.com/ManageIQ/floe/pull/112))
16
+ - Fix Context#input not JSON parsed ([#122](https://github.com/ManageIQ/floe/pull/122))
17
+
18
+ ## [0.4.0] - 2023-09-26
19
+ ### Added
20
+ - Add ability to run workflows asynchronously ([#52](https://github.com/ManageIQ/floe/pull/92))
21
+ - Add Workflow.wait, Workflow#step_nonblock, Workflow#step_nonblock_wait ([#92](https://github.com/ManageIQ/floe/pull/92))
22
+
7
23
  ## [0.3.1] - 2023-08-29
8
24
  ### Added
9
25
  - Add more global podman runner options ([#90])(https://github.com/ManageIQ/floe/pull/90)
@@ -58,7 +74,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
58
74
  ### Added
59
75
  - Initial release
60
76
 
61
- [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.3.1...HEAD
77
+ [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.4.1...HEAD
78
+ [0.4.1]: https://github.com/ManageIQ/floe/compare/v0.4.0...v0.4.1
79
+ [0.4.0]: https://github.com/ManageIQ/floe/compare/v0.3.1...v0.4.0
62
80
  [0.3.1]: https://github.com/ManageIQ/floe/compare/v0.3.0...v0.3.1
63
81
  [0.3.0]: https://github.com/ManageIQ/floe/compare/v0.2.3...v0.3.0
64
82
  [0.2.3]: https://github.com/ManageIQ/floe/compare/v0.2.2...v0.2.3
data/Gemfile CHANGED
@@ -8,4 +8,8 @@ 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"
15
+ gem "timecop"
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.1"
5
5
  end
@@ -10,7 +10,7 @@ module Floe
10
10
 
11
11
  @error_equals = payload["ErrorEquals"]
12
12
  @next = payload["Next"]
13
- @result_path = payload.fetch("ResultPath", "$")
13
+ @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
14
14
  end
15
15
  end
16
16
  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)
@@ -5,6 +5,7 @@ module Floe
5
5
  class Context
6
6
  def initialize(context = nil, input: {})
7
7
  context = JSON.parse(context) if context.kind_of?(String)
8
+ input = JSON.parse(input) if input.kind_of?(String)
8
9
 
9
10
  @context = context || {
10
11
  "Execution" => {
@@ -21,10 +22,58 @@ module Floe
21
22
  @context["Execution"]
22
23
  end
23
24
 
25
+ def started?
26
+ execution.key?("StartTime")
27
+ end
28
+
29
+ def running?
30
+ started? && !ended?
31
+ end
32
+
33
+ def ended?
34
+ execution.key?("EndTime")
35
+ end
36
+
24
37
  def state
25
38
  @context["State"]
26
39
  end
27
40
 
41
+ def input
42
+ state["Input"]
43
+ end
44
+
45
+ def output
46
+ state["Output"]
47
+ end
48
+
49
+ def output=(val)
50
+ state["Output"] = val
51
+ end
52
+
53
+ def state_name
54
+ state["Name"]
55
+ end
56
+
57
+ def next_state
58
+ state["NextState"]
59
+ end
60
+
61
+ def next_state=(val)
62
+ state["NextState"] = val
63
+ end
64
+
65
+ def status
66
+ if !started?
67
+ "pending"
68
+ elsif running?
69
+ "running"
70
+ elsif state["Error"]
71
+ "failure"
72
+ else
73
+ "success"
74
+ end
75
+ end
76
+
28
77
  def state=(val)
29
78
  @context["State"] = val
30
79
  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"], :combined_output => true).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"], :combined_output => true).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
@@ -5,7 +5,8 @@ module Floe
5
5
  class Runner
6
6
  include Logging
7
7
 
8
- TYPES = %w[docker podman kubernetes].freeze
8
+ TYPES = %w[docker podman kubernetes].freeze
9
+ OUTPUT_MARKER = "__FLOE_OUTPUT__\n"
9
10
 
10
11
  def initialize(_options = {})
11
12
  end
@@ -30,7 +31,28 @@ module Floe
30
31
  end
31
32
  end
32
33
 
33
- def run!(image, env = {}, secrets = {})
34
+ def run!(resource, env = {}, secrets = {})
35
+ raise NotImplementedError, "Must be implemented in a subclass"
36
+ end
37
+
38
+ # @return [Hash] runner_context
39
+ def run_async!(_image, _env = {}, _secrets = {})
40
+ raise NotImplementedError, "Must be implemented in a subclass"
41
+ end
42
+
43
+ def running?(_runner_context)
44
+ raise NotImplementedError, "Must be implemented in a subclass"
45
+ end
46
+
47
+ def success?(_runner_context)
48
+ raise NotImplementedError, "Must be implemented in a subclass"
49
+ end
50
+
51
+ def output(_runner_context)
52
+ raise NotImplementedError, "Must be implemented in a subclass"
53
+ end
54
+
55
+ def cleanup(_runner_context)
34
56
  raise NotImplementedError, "Must be implemented in a subclass"
35
57
  end
36
58
  end
@@ -29,9 +29,86 @@ 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
95
+
96
+ private
97
+
98
+ def wait(seconds: nil, time: nil)
99
+ context.state["WaitUntil"] =
100
+ if seconds
101
+ (Time.parse(context.state["EnteredTime"]) + seconds).iso8601
102
+ elsif time.kind_of?(String)
103
+ time
104
+ else
105
+ time.iso8601
106
+ end
107
+ end
108
+
109
+ def waiting?
110
+ context.state["WaitUntil"] && Time.now.utc <= Time.parse(context.state["WaitUntil"])
111
+ end
35
112
  end
36
113
  end
37
114
  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?
@@ -9,16 +9,28 @@ module Floe
9
9
  def initialize(workflow, name, payload)
10
10
  super
11
11
 
12
- @cause = payload["Cause"]
13
- @error = payload["Error"]
12
+ @cause = payload["Cause"]
13
+ @error = payload["Error"]
14
+ @cause_path = Path.new(payload["CausePath"]) if payload["CausePath"]
15
+ @error_path = Path.new(payload["ErrorPath"]) if payload["ErrorPath"]
14
16
  end
15
17
 
16
- def run!(input)
17
- [nil, input]
18
+ def start(input)
19
+ super
20
+ context.next_state = nil
21
+ # TODO: support intrinsic functions here
22
+ # see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-fail-state.html
23
+ # https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-intrinsic-functions.html#asl-intrsc-func-generic
24
+ context.output = {
25
+ "Error" => @error_path ? @error_path.value(context, input) : error,
26
+ "Cause" => @cause_path ? @cause_path.value(context, input) : cause
27
+ }.compact
28
+ context.state["Error"] = context.output["Error"]
29
+ context.state["Cause"] = context.output["Cause"]
18
30
  end
19
31
 
20
- def status
21
- "errored"
32
+ def running?
33
+ false
22
34
  end
23
35
 
24
36
  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,36 +27,65 @@ module Floe
26
27
  @credentials = PayloadTemplate.new(payload["Credentials"]) if payload["Credentials"]
27
28
  end
28
29
 
29
- def run!(input)
30
- input = input_path.value(context, input)
31
- input = parameters.value(context, input) if parameters
32
-
33
- runner = Floe::Workflow::Runner.for_resource(resource)
34
- _exit_status, results = runner.run!(resource, input, credentials&.value({}, workflow.credentials))
35
-
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)
30
+ def start(input)
31
+ super
41
32
 
42
- catcher = self.catch.detect { |c| (c.error_equals & [err.to_s, "States.ALL"]).any? }
43
- raise if catcher.nil?
33
+ input = process_input(input)
34
+ runner_context = runner.run_async!(resource, input, credentials&.value({}, workflow.credentials))
44
35
 
45
- [catcher.next, output]
36
+ context.state["RunnerContext"] = runner_context
46
37
  end
47
38
 
48
39
  def status
49
40
  @end ? "success" : "running"
50
41
  end
51
42
 
43
+ def finish
44
+ output = runner.output(context.state["RunnerContext"])
45
+
46
+ if success?
47
+ output = parse_output(output)
48
+ context.state["Output"] = process_output!(output)
49
+ context.next_state = next_state
50
+ else
51
+ error = parse_error(output)
52
+ retry_state!(error) || catch_error!(error) || fail_workflow!(error)
53
+ end
54
+
55
+ super
56
+ ensure
57
+ runner.cleanup(context.state["RunnerContext"])
58
+ end
59
+
60
+ def running?
61
+ return true if waiting?
62
+
63
+ runner.status!(context.state["RunnerContext"])
64
+ runner.running?(context.state["RunnerContext"])
65
+ end
66
+
52
67
  def end?
53
68
  @end
54
69
  end
55
70
 
56
71
  private
57
72
 
58
- def retry!(retrier)
73
+ attr_reader :runner
74
+
75
+ def success?
76
+ runner.success?(context.state["RunnerContext"])
77
+ end
78
+
79
+ def find_retrier(error)
80
+ self.retry.detect { |r| (r.error_equals & [error, "States.ALL"]).any? }
81
+ end
82
+
83
+ def find_catcher(error)
84
+ self.catch.detect { |c| (c.error_equals & [error, "States.ALL"]).any? }
85
+ end
86
+
87
+ def retry_state!(error)
88
+ retrier = find_retrier(error["Error"]) if error
59
89
  return if retrier.nil?
60
90
 
61
91
  # If a different retrier is hit reset the context
@@ -68,24 +98,61 @@ module Floe
68
98
 
69
99
  return if context["State"]["RetryCount"] > retrier.max_attempts
70
100
 
71
- Kernel.sleep(retrier.sleep_duration(context["State"]["RetryCount"]))
101
+ wait(:seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
102
+ context.next_state = context.state_name
72
103
  true
73
104
  end
74
105
 
75
- def process_output!(output, results)
106
+ def catch_error!(error)
107
+ catcher = find_catcher(error["Error"]) if error
108
+ return if catcher.nil?
109
+
110
+ context.next_state = catcher.next
111
+ context.output = catcher.result_path.set(context.input, error)
112
+ true
113
+ end
114
+
115
+ def fail_workflow!(error)
116
+ context.next_state = nil
117
+ context.output = {"Error" => error["Error"], "Cause" => error["Cause"]}.compact
118
+ context.state["Error"] = context.output["Error"]
119
+ end
120
+
121
+ def process_input(input)
122
+ input = input_path.value(context, input)
123
+ input = parameters.value(context, input) if parameters
124
+ input
125
+ end
126
+
127
+ def parse_error(output)
128
+ return if output.nil?
129
+
130
+ JSON.parse(output)
131
+ rescue JSON::ParserError
132
+ {"Error" => output}
133
+ end
134
+
135
+ def parse_output(output)
136
+ return if output.nil?
137
+
138
+ JSON.parse(output.split("\n").last)
139
+ rescue JSON::ParserError
140
+ nil
141
+ end
142
+
143
+ def process_output!(results)
144
+ output = context.input.dup
76
145
  return output if results.nil?
77
146
  return if output_path.nil?
78
147
 
79
- begin
80
- results = JSON.parse(results)
81
- rescue JSON::ParserError
82
- results = {"results" => results}
83
- end
84
-
85
148
  results = result_selector.value(context, results) if result_selector
86
149
  output = result_path.set(output, results)
87
150
  output_path.value(context, output)
88
151
  end
152
+
153
+ def next_state
154
+ end? ? nil : @next
155
+ end
89
156
  end
90
157
  end
91
158
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module Floe
4
6
  class Workflow
5
7
  module States
@@ -9,28 +11,42 @@ module Floe
9
11
  def initialize(workflow, name, payload)
10
12
  super
11
13
 
12
- @next = payload["Next"]
13
- @end = !!payload["End"]
14
- @seconds = payload["Seconds"].to_i
14
+ @next = payload["Next"]
15
+ @end = !!payload["End"]
16
+ @seconds = payload["Seconds"]&.to_i
17
+ @timestamp = payload["Timestamp"]
18
+ @timestamp_path = Path.new(payload["TimestampPath"]) if payload.key?("TimestampPath")
19
+ @seconds_path = Path.new(payload["SecondsPath"]) if payload.key?("SecondsPath")
15
20
 
16
21
  @input_path = Path.new(payload.fetch("InputPath", "$"))
17
22
  @output_path = Path.new(payload.fetch("OutputPath", "$"))
18
23
  end
19
24
 
20
- def run!(input)
25
+ def start(input)
26
+ super
21
27
  input = input_path.value(context, input)
22
- sleep(seconds)
23
- output = output_path.value(context, input)
24
- [@end ? nil : @next, output]
28
+
29
+ context.output = output_path.value(context, input)
30
+ context.next_state = end? ? nil : @next
31
+ please_hold(input)
25
32
  end
26
33
 
27
- def status
28
- @end ? "success" : "running"
34
+ def running?
35
+ waiting?
29
36
  end
30
37
 
31
38
  def end?
32
39
  @end
33
40
  end
41
+
42
+ private
43
+
44
+ def please_hold(input)
45
+ wait(
46
+ :seconds => @seconds_path ? @seconds_path.value(context, input).to_i : @seconds,
47
+ :time => @timestamp_path ? @timestamp_path.value(context, input) : @timestamp
48
+ )
49
+ end
34
50
  end
35
51
  end
36
52
  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
data/lib/floe.rb CHANGED
@@ -32,6 +32,7 @@ require_relative "floe/workflow/states/task"
32
32
  require_relative "floe/workflow/states/wait"
33
33
 
34
34
  require "jsonpath"
35
+ require "time"
35
36
 
36
37
  module Floe
37
38
  class Error < StandardError; 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.1
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-10-06 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: