floe 0.18.0 → 0.19.1

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: 40373ebff63056265508d03a1f3d12c3f10c9933b0dbcaffee700140838797cb
4
- data.tar.gz: 51df9c1251e26551c8eb07e3bf16ed17775753b5f62b8407f83b813349063a12
3
+ metadata.gz: adbfaf3b22020809d013feabedb4de1ac729d49758aff9e429bc36d840d5254b
4
+ data.tar.gz: b4a534349166ace022005ad67ece4621e2ba8ae887c8e76538a20c33a91fa1bb
5
5
  SHA512:
6
- metadata.gz: 57201f74d63b63a34c250f615b06013e9355828ed8a8e0007ba229f9b0b49d370428db87f067e0860dbef59bbd349e274a4573b661b34ca69c69ce5d65df1c01
7
- data.tar.gz: e50628793fc80d43fa51fc266b26e1dc8dfeee6c98b9ffbed8c2a0d495ade115c4b77c68d9bcb2d059e1b0f5d573e40315530b837805d8e5b90bbfd9ddef291a
6
+ metadata.gz: 7c5b35efa8f03b994f2a331cd5d84ccb6ab8362c05b956b7e5a2e033b059c756576102cc875022367759a3f3f42a729860133ba7af56b3b8cd7af1b02935038d
7
+ data.tar.gz: e004358bee889a2f0d89de56e596be0299e2d78f219029859de973d72c178951ca7886a6131cca1072ba164feaab18a664e241f86a2829559ca85733879d8164
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.19.1] - 2025-12-17
8
+ ### Fixed
9
+ - Fix `State#ready?` when a task is running with no `WaitUntil` ([#338](https://github.com/ManageIQ/floe/pull/338))
10
+
11
+ ## [0.19.0] - 2025-12-16
12
+ ### Fixed
13
+ - Fix builtin_runner/runner spec file name ([#329](https://github.com/ManageIQ/floe/pull/329))
14
+ - Check WaitUntil before starting a State ([#336](https://github.com/ManageIQ/floe/pull/336))
15
+ - Task TimeoutSeconds and TimeoutSecondsPath not implemented ([#330](https://github.com/ManageIQ/floe/pull/330))
16
+
17
+ ### Added
18
+ - Add floe://log builtin method ([#328](https://github.com/ManageIQ/floe/pull/328))
19
+
20
+ ### Changed
21
+ - Raise `States.Timeout` in `running?` and remove duplicate `timed_out?` check in `finished?` ([#337](https://github.com/ManageIQ/floe/pull/337))
22
+
7
23
  ## [0.18.0] - 2025-10-24
8
24
  ### Added
9
25
  - Declare active support dependency ([#316](https://github.com/ManageIQ/floe/pull/316))
@@ -317,7 +333,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
317
333
  ### Added
318
334
  - Initial release
319
335
 
320
- [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.18.0...HEAD
336
+ [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.19.1...HEAD
337
+ [0.19.1]: https://github.com/ManageIQ/floe/compare/v0.19.0...v0.19.1
338
+ [0.19.0]: https://github.com/ManageIQ/floe/compare/v0.18.0...v0.19.0
321
339
  [0.18.0]: https://github.com/ManageIQ/floe/compare/v0.17.1...v0.18.0
322
340
  [0.17.1]: https://github.com/ManageIQ/floe/compare/v0.17.0...v0.17.1
323
341
  [0.17.0]: https://github.com/ManageIQ/floe/compare/v0.16.0...v0.17.0
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Floe
2
2
 
3
3
  [![CI](https://github.com/ManageIQ/floe/actions/workflows/ci.yaml/badge.svg)](https://github.com/ManageIQ/floe/actions/workflows/ci.yaml)
4
- [![Code Climate](https://codeclimate.com/github/ManageIQ/floe.svg)](https://codeclimate.com/github/ManageIQ/floe)
5
- [![Test Coverage](https://codeclimate.com/github/ManageIQ/floe/badges/coverage.svg)](https://codeclimate.com/github/ManageIQ/floe/coverage)
6
4
 
7
5
  ## Overview
8
6
 
@@ -167,6 +165,31 @@ Task Runner Types:
167
165
 
168
166
  This is the "builtin" runner and exposes methods that are executed internally without having to call out to a docker image.
169
167
 
168
+ ##### Log builtin method
169
+
170
+ `floe://log` allows you to write a message to the logger.
171
+
172
+ Example:
173
+ ```json
174
+ {
175
+ "Comment": "Print log message",
176
+ "StartAt": "Log",
177
+ "States": {
178
+ "Log": {
179
+ "Type": "Task",
180
+ "Resource": "floe://log",
181
+ "Parameters": {
182
+ "Level": "INFO",
183
+ "Message": "Hello, Floe!"
184
+ },
185
+ "End": true
186
+ }
187
+ ```
188
+
189
+ Log Parameters:
190
+ * `Level` (required) - Log level. Permitted values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`, or `UNKNOWN`. Defaults to `INFO`.
191
+ * `Message` (required) - The message to log
192
+
170
193
  ##### HTTP builtin method
171
194
 
172
195
  `floe://http` allows you to execute HTTP method calls.
data/examples/log.asl ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "Comment": "Print log messages",
3
+ "StartAt": "Log Info",
4
+ "States": {
5
+ "Log Info": {
6
+ "Type": "Task",
7
+ "Resource": "floe://log",
8
+ "Parameters": {
9
+ "Level": "INFO",
10
+ "Message": "Hello, Floe!"
11
+ },
12
+ "Next": "Log Debug"
13
+ },
14
+ "Log Debug": {
15
+ "Type": "Task",
16
+ "Resource": "floe://log",
17
+ "Parameters": {
18
+ "Level": "DEBUG",
19
+ "Message": "Hello, Floe!"
20
+ },
21
+ "Next": "Log From Input"
22
+ },
23
+ "Log From Input": {
24
+ "Type": "Task",
25
+ "Resource": "floe://log",
26
+ "Parameters": {
27
+ "Level": "INFO",
28
+ "Message.$": "States.Format('Hello, {}!', $.name)"
29
+ },
30
+ "End": true
31
+ }
32
+ }
33
+ }
@@ -1,6 +1,35 @@
1
+ require "logger"
2
+
1
3
  module Floe
2
4
  module BuiltinRunner
3
5
  class Methods < BasicObject
6
+ def self.log(params, _secrets, context)
7
+ params["Level"] = (params["Level"] ||= "INFO").upcase
8
+
9
+ error = log_verify_params(params)
10
+ return BuiltinRunner.error!({}, :cause => error) if error
11
+
12
+ level, message = params.values_at("Level", "Message")
13
+
14
+ context.logger.add(::Logger::Severity.const_get(level), message)
15
+
16
+ BuiltinRunner.success!({}, :output => context.input)
17
+ end
18
+
19
+ LOG_SEVERITIES = ::Logger::Severity.constants.sort_by { |s| ::Logger::Severity.const_get(s) }.map(&:to_s)
20
+ LOG_SEVERITIES_S = "#{LOG_SEVERITIES[0..-2].join(", ")}, or #{LOG_SEVERITIES[-1]}"
21
+
22
+ private_class_method def self.log_verify_params(params)
23
+ return "Missing Parameter: Message" if params["Message"].nil?
24
+ return "Invalid Parameter: Level: [#{params["Level"]}], must be one of #{LOG_SEVERITIES_S}" unless LOG_SEVERITIES.include?(params["Level"])
25
+
26
+ nil
27
+ end
28
+
29
+ private_class_method def self.log_status!(runner_context)
30
+ runner_context
31
+ end
32
+
4
33
  def self.http(params, _secrets, _context)
5
34
  params["Method"] ||= "GET"
6
35
 
@@ -105,10 +105,14 @@ module Floe
105
105
  end
106
106
 
107
107
  def running?(runner_context)
108
+ return false if runner_context.key?("Error")
109
+
108
110
  !!runner_context.dig("container_state", "Running")
109
111
  end
110
112
 
111
113
  def success?(runner_context)
114
+ return false if runner_context.key?("Error")
115
+
112
116
  runner_context.dig("container_state", "ExitCode") == 0
113
117
  end
114
118
 
@@ -186,8 +190,11 @@ module Floe
186
190
  raise Floe::ExecutionError, "Failed to get status for container #{container_id}: #{err}"
187
191
  end
188
192
 
189
- def delete_container(container_id)
190
- docker!("rm", container_id)
193
+ def delete_container(container_id, force: true)
194
+ params = ["rm", container_id]
195
+ params << "--force" if force
196
+
197
+ docker!(*params)
191
198
  rescue
192
199
  nil
193
200
  end
@@ -70,6 +70,7 @@ module Floe
70
70
  end
71
71
 
72
72
  def running?(runner_context)
73
+ return false if runner_context.key?("Error")
73
74
  return false unless pod_running?(runner_context)
74
75
  # If a pod is Pending and the containers are waiting with a failure
75
76
  # reason such as ImagePullBackOff or CrashLoopBackOff then the pod
@@ -80,6 +81,8 @@ module Floe
80
81
  end
81
82
 
82
83
  def success?(runner_context)
84
+ return false if runner_context.key?("Error")
85
+
83
86
  runner_context.dig("container_state", "phase") == "Succeeded"
84
87
  end
85
88
 
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.18.0"
4
+ VERSION = "0.19.1"
5
5
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Floe
4
6
  class Workflow
5
7
  class Context
@@ -25,6 +27,17 @@ module Floe
25
27
  raise Floe::InvalidExecutionInput, "Invalid State Machine Execution Input: #{err}: was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')"
26
28
  end
27
29
 
30
+ def prepare_start(start_at)
31
+ return if started?
32
+
33
+ state["Name"] = start_at
34
+ state["Input"] = execution["Input"].dup
35
+ state["Guid"] = SecureRandom.uuid
36
+
37
+ execution["Id"] ||= SecureRandom.uuid
38
+ execution["StartTime"] = Time.now.utc.iso8601
39
+ end
40
+
28
41
  def execution
29
42
  @context["Execution"]
30
43
  end
@@ -43,7 +43,9 @@ module Floe
43
43
 
44
44
  # @return for incomplete Errno::EAGAIN, for completed 0
45
45
  def run_nonblock!(context)
46
- start(context) unless context.state_started?
46
+ # Only start the state if it isn't already started and it isn't waiting
47
+ # from a prior Retry.
48
+ start(context) unless started?(context) || waiting?(context)
47
49
  return Errno::EAGAIN unless ready?(context)
48
50
 
49
51
  finish(context)
@@ -89,13 +91,16 @@ module Floe
89
91
  def mark_error(context, exception)
90
92
  # InputPath or OutputPath were bad.
91
93
  context.next_state = nil
92
- context.output = {"Error" => exception.floe_error, "Cause" => exception.message}
94
+ context.output = exception.to_output
93
95
  # Since finish threw an exception, super was never called. Calling that now.
94
96
  mark_finished(context)
95
97
  end
96
98
 
97
99
  def ready?(context)
98
- !started?(context) || !running?(context)
100
+ return false if !started?(context)
101
+ return true if !running?(context)
102
+ return true if wait_until(context) && !waiting?(context)
103
+ false
99
104
  end
100
105
 
101
106
  def running?(context)
@@ -9,29 +9,29 @@ module Floe
9
9
  include RetryCatchMixin
10
10
 
11
11
  attr_reader :credentials, :end, :heartbeat_seconds, :next, :parameters,
12
- :result_selector, :resource, :timeout_seconds, :retry, :catch,
13
- :input_path, :output_path, :result_path
12
+ :result_selector, :resource, :timeout_seconds, :timeout_seconds_path,
13
+ :retry, :catch, :input_path, :output_path, :result_path
14
14
 
15
15
  def initialize(workflow, name, payload)
16
16
  super
17
17
 
18
- @heartbeat_seconds = payload["HeartbeatSeconds"]
19
- @next = payload["Next"]
20
- @end = !!payload["End"]
21
- @resource = payload["Resource"]
22
-
18
+ @resource = payload["Resource"]
23
19
  missing_field_error!("Resource") unless @resource.kind_of?(String)
24
20
  @runner = wrap_parser_error("Resource", @resource) { Floe::Runner.for_resource(@resource) }
25
21
 
26
- @timeout_seconds = payload["TimeoutSeconds"]
27
- @retry = payload["Retry"].to_a.map.with_index { |retrier, i| Retrier.new(workflow, name + ["Retry", i.to_s], retrier) }
28
- @catch = payload["Catch"].to_a.map.with_index { |catcher, i| Catcher.new(workflow, name + ["Catch", i.to_s], catcher) }
29
- @input_path = Path.new(payload.fetch("InputPath", "$"))
30
- @output_path = Path.new(payload.fetch("OutputPath", "$"))
31
- @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
32
- @parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
33
- @result_selector = PayloadTemplate.new(payload["ResultSelector"]) if payload["ResultSelector"]
34
- @credentials = PayloadTemplate.new(payload["Credentials"]) if payload["Credentials"]
22
+ @next = payload["Next"]
23
+ @end = !!payload["End"]
24
+ @timeout_seconds = payload["TimeoutSeconds"]
25
+ @heartbeat_seconds = payload["HeartbeatSeconds"]
26
+ @retry = payload["Retry"].to_a.map.with_index { |retrier, i| Retrier.new(workflow, name + ["Retry", i.to_s], retrier) }
27
+ @catch = payload["Catch"].to_a.map.with_index { |catcher, i| Catcher.new(workflow, name + ["Catch", i.to_s], catcher) }
28
+ @input_path = Path.new(payload.fetch("InputPath", "$"))
29
+ @output_path = Path.new(payload.fetch("OutputPath", "$"))
30
+ @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
31
+ @timeout_seconds_path = ReferencePath.new(payload["TimeoutSecondsPath"]) if payload["TimeoutSecondsPath"]
32
+ @parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
33
+ @result_selector = PayloadTemplate.new(payload["ResultSelector"]) if payload["ResultSelector"]
34
+ @credentials = PayloadTemplate.new(payload["Credentials"]) if payload["Credentials"]
35
35
 
36
36
  validate_state!(workflow)
37
37
  end
@@ -39,6 +39,9 @@ module Floe
39
39
  def start(context)
40
40
  super
41
41
 
42
+ # Wakeup no later than timeout_seconds to check if the Resource has timed out
43
+ wait_until!(context, :seconds => timeout_seconds) if timeout_seconds
44
+
42
45
  input = process_input(context)
43
46
  secrets = credentials&.value(context, context.input)
44
47
  runner_context = runner.run_async!(resource, input, secrets, context)
@@ -48,27 +51,31 @@ module Floe
48
51
 
49
52
  def finish(context)
50
53
  output = runner.output(context.state["RunnerContext"])
54
+ raise Floe::ExecutionError.from_output(parse_error(output)) unless success?(context)
55
+
56
+ output = parse_output(output)
57
+ context.output = process_output(context, output)
51
58
 
52
- if success?(context)
53
- output = parse_output(output)
54
- context.output = process_output(context, output)
55
- else
56
- error = parse_error(output)
57
- retry_state!(context, error) || catch_error!(context, error) || fail_workflow!(context, error)
58
- end
59
59
  super
60
60
  ensure
61
61
  runner.cleanup(context.state["RunnerContext"])
62
62
  end
63
63
 
64
64
  def running?(context)
65
- return true if waiting?(context)
65
+ raise Floe::TimeoutError if timed_out?(context)
66
66
  return false if finished?(context)
67
67
 
68
68
  runner.status!(context.state["RunnerContext"])
69
69
  runner.running?(context.state["RunnerContext"])
70
70
  end
71
71
 
72
+ def mark_error(context, exception)
73
+ error = exception.to_output
74
+
75
+ retry_state!(context, error) || catch_error!(context, error) || fail_workflow!(context, error)
76
+ mark_finished(context)
77
+ end
78
+
72
79
  def end?
73
80
  @end
74
81
  end
@@ -79,12 +86,43 @@ module Floe
79
86
 
80
87
  def validate_state!(workflow)
81
88
  validate_state_next!(workflow)
89
+ validate_state_timeout_seconds!(workflow)
90
+ validate_state_timeout_seconds_path!(workflow)
91
+ end
92
+
93
+ def validate_state_timeout_seconds!(workflow)
94
+ return if @timeout_seconds.nil?
95
+ return if @timeout_seconds.kind_of?(Integer) && @timeout_seconds > 0
96
+
97
+ invalid_field_error!("TimeoutSeconds", @timeout_seconds, "must be positive, non-zero integer")
98
+ end
99
+
100
+ def validate_state_timeout_seconds_path!(workflow)
101
+ return if @timeout_seconds_path.nil? || @timeout_seconds.nil?
102
+
103
+ invalid_field_error!("TimeoutSecondsPath", nil, "cannot specify both \"TimeoutSeconds\" and \"TimeoutSecondsPath\"")
82
104
  end
83
105
 
84
106
  def success?(context)
85
107
  runner.success?(context.state["RunnerContext"])
86
108
  end
87
109
 
110
+ def timed_out?(context)
111
+ return false if timeout_seconds.nil? && timeout_seconds_path.nil?
112
+
113
+ timeout = timeout_seconds || timeout_seconds_path.value(context, context.input)
114
+ entered_time = Time.parse(context.state["EnteredTime"])
115
+
116
+ Time.now.utc > entered_time + timeout
117
+ end
118
+
119
+ def task_timed_out!(context)
120
+ context.state["RunnerContext"]["Error"] = "States.Timeout"
121
+ context.state["RunnerContext"]["Cause"] = "Task timed out"
122
+
123
+ false
124
+ end
125
+
88
126
  def parse_error(output)
89
127
  return if output.nil?
90
128
  return output if output.kind_of?(Hash)
data/lib/floe/workflow.rb CHANGED
@@ -150,15 +150,7 @@ module Floe
150
150
 
151
151
  # setup a workflow
152
152
  def start_workflow
153
- return if context.state_name
154
-
155
- context.state["Name"] = start_at
156
- context.state["Input"] = context.execution["Input"].dup
157
- context.state["Guid"] = SecureRandom.uuid
158
-
159
- context.execution["Id"] ||= SecureRandom.uuid
160
- context.execution["StartTime"] = Time.now.utc.iso8601
161
-
153
+ context.prepare_start(start_at)
162
154
  self
163
155
  end
164
156
 
@@ -183,7 +175,7 @@ module Floe
183
175
 
184
176
  # if rerunning due to an error (and we are using Retry)
185
177
  if context.state_name == context.next_state && context.failed? && context.state.key?("Retrier")
186
- next_state.merge!(context.state.slice("RetryCount", "Input", "Retrier"))
178
+ next_state.merge!(context.state.slice("RetryCount", "Input", "Retrier", "WaitUntil"))
187
179
  else
188
180
  next_state["Input"] = context.output
189
181
  end
data/lib/floe.rb CHANGED
@@ -54,17 +54,36 @@ module Floe
54
54
  class InvalidExecutionInput < Error; end
55
55
 
56
56
  class ExecutionError < Error
57
+ def self.from_output(output)
58
+ raise ArgumentError unless output.kind_of?(Hash) && output.key?("Error")
59
+
60
+ new(output["Cause"], output["Error"])
61
+ end
62
+
57
63
  attr_reader :floe_error
58
64
 
59
65
  def initialize(message, floe_error = "States.Runtime")
60
66
  super(message)
61
67
  @floe_error = floe_error
62
68
  end
69
+
70
+ def to_output
71
+ {"Error" => floe_error}.tap do |output|
72
+ # If there is no "Cause" then ::Exception will use the exception class name
73
+ output["Cause"] = message if message != self.class.name.to_s
74
+ end
75
+ end
63
76
  end
64
77
 
65
78
  class PathError < ExecutionError
66
79
  end
67
80
 
81
+ class TimeoutError < ExecutionError
82
+ def initialize(message = nil)
83
+ super(message, "States.Timeout")
84
+ end
85
+ end
86
+
68
87
  def self.logger
69
88
  @logger ||= NullLogger.new
70
89
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: floe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ManageIQ Developers
@@ -225,10 +225,8 @@ executables:
225
225
  extensions: []
226
226
  extra_rdoc_files: []
227
227
  files:
228
- - ".codeclimate.yml"
229
228
  - ".rspec"
230
229
  - ".rubocop.yml"
231
- - ".rubocop_cc.yml"
232
230
  - ".rubocop_local.yml"
233
231
  - ".yamllint"
234
232
  - CHANGELOG.md
@@ -238,6 +236,7 @@ files:
238
236
  - Rakefile
239
237
  - examples/everything.asl
240
238
  - examples/http.asl
239
+ - examples/log.asl
241
240
  - examples/map.asl
242
241
  - examples/parallel.asl
243
242
  - examples/set-credential.asl
data/.codeclimate.yml DELETED
@@ -1,36 +0,0 @@
1
- version: '2'
2
- prepare:
3
- fetch:
4
- - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/.rubocop_base.yml
5
- path: ".rubocop_base.yml"
6
- - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/.rubocop_cc_base.yml
7
- path: ".rubocop_cc_base.yml"
8
- - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/styles/base.yml
9
- path: styles/base.yml
10
- - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/styles/cc_base.yml
11
- path: styles/cc_base.yml
12
- checks:
13
- argument-count:
14
- enabled: false
15
- complex-logic:
16
- enabled: false
17
- file-lines:
18
- enabled: false
19
- method-complexity:
20
- config:
21
- threshold: 11
22
- method-count:
23
- enabled: false
24
- method-lines:
25
- enabled: false
26
- nested-control-flow:
27
- enabled: false
28
- return-statements:
29
- enabled: false
30
- plugins:
31
- rubocop:
32
- enabled: true
33
- config: ".rubocop_cc.yml"
34
- channel: rubocop-1-56-3
35
- exclude_patterns:
36
- - spec/
data/.rubocop_cc.yml DELETED
@@ -1,4 +0,0 @@
1
- inherit_from:
2
- - ".rubocop_base.yml"
3
- - ".rubocop_cc_base.yml"
4
- - ".rubocop_local.yml"