floe 0.4.0 → 0.5.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: e7a1c4e51ef3ec3052e8c3c150070464ed1a8b463a3b1237774b8f1b2ff416dc
4
- data.tar.gz: 8cd6fd6e8c7bed14635d906699f13dde1f5e666018f0c62709972dbddc1999cc
3
+ metadata.gz: 0aac9121f1d393173b2fb11f9422f9e689693318a97dada576f054d827901024
4
+ data.tar.gz: 8118d4632e54c6fa3beb94384be517d5f3bf494890855901ce36af7ea94ee1c2
5
5
  SHA512:
6
- metadata.gz: 73bf61a41d240922786746b0ace87c616311c4bd3f63ac59883096e53b0fce766ef791db3270a29cf257cd5315f5764d69c91bcfd4817efcadfaae72be4c226b
7
- data.tar.gz: 3cb8f1ad58c7b1b7895363f46871b38b300a66eb40ebb69d2fc8ffd1b5ec303c982c0f49f89f9575b209b5e0671c2570e54670078da4e4916a3718050c9e0b9d
6
+ metadata.gz: 3872e70c2bb41e46570bcba95ef63716cdf3ea93bed77e9654db6614f8225982e13e3a83256b97854b6aa4376799e803f2edd5066dc3e304e75e2970c55d60f2
7
+ data.tar.gz: fac7e8815bcff8d93e443653759a6cdf8371c78f9114e3fef133212c0a1ef49d76bb35a0f491ef5abfeb134a21dc11cbbc00a6694d1f05d0e90f5bdae3471c41
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ This project adheres to [Semantic Versioning](http://semver.org/).
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2023-10-12
8
+ ### Added
9
+ - For task errors, use the json on the last line ([#128](https://github.com/ManageIQ/floe/pull/128))
10
+ - Add ability to pass task service account to kube runner ([#131](https://github.com/ManageIQ/floe/pull/131))
11
+
12
+ ### Fixed
13
+ - Don't put credentials file into input ([#124](https://github.com/ManageIQ/floe/pull/124))
14
+ - exe/floe return success status if the workflow was successful ([#129](https://github.com/ManageIQ/floe/pull/129))
15
+ - For error output, drop trailing newline ([#126](https://github.com/ManageIQ/floe/pull/126))
16
+
17
+ ## [0.4.1] - 2023-10-06
18
+ ### Added
19
+ - Add Fail#CausePath and Fail#ErrorPath ([#110](https://github.com/ManageIQ/floe/pull/110))
20
+ - Add Task#Retrier incremental backoff and Wait#Timestamp ([#100](https://github.com/ManageIQ/floe/pull/100))
21
+
22
+ ### Fixed
23
+ - Combine stdout and stderr for docker and podman runners ([#104](https://github.com/ManageIQ/floe/pull/104))
24
+ - Don't raise an exception on task failure ([#115](https://github.com/ManageIQ/floe/pull/115))
25
+ - Fix task output handling ([#112](https://github.com/ManageIQ/floe/pull/112))
26
+ - Fix Context#input not JSON parsed ([#122](https://github.com/ManageIQ/floe/pull/122))
27
+
7
28
  ## [0.4.0] - 2023-09-26
8
29
  ### Added
9
30
  - Add ability to run workflows asynchronously ([#52](https://github.com/ManageIQ/floe/pull/92))
@@ -63,7 +84,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
63
84
  ### Added
64
85
  - Initial release
65
86
 
66
- [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.4.0...HEAD
87
+ [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.4.1...HEAD
88
+ [0.4.1]: https://github.com/ManageIQ/floe/compare/v0.4.0...v0.4.1
67
89
  [0.4.0]: https://github.com/ManageIQ/floe/compare/v0.3.1...v0.4.0
68
90
  [0.3.1]: https://github.com/ManageIQ/floe/compare/v0.3.0...v0.3.1
69
91
  [0.3.0]: https://github.com/ManageIQ/floe/compare/v0.2.3...v0.3.0
data/Gemfile CHANGED
@@ -12,3 +12,4 @@ gem "manageiq-style"
12
12
  gem "rake", "~> 13.0"
13
13
  gem "rspec"
14
14
  gem "rubocop"
15
+ gem "timecop"
@@ -4,7 +4,7 @@
4
4
  "States": {
5
5
  "FirstState": {
6
6
  "Type": "Task",
7
- "Resource": "docker://agrare/hello-world:latest",
7
+ "Resource": "docker://docker.io/agrare/hello-world:latest",
8
8
  "Credentials": {
9
9
  "mysecret": "dont tell anyone"
10
10
  },
@@ -49,13 +49,13 @@
49
49
 
50
50
  "FirstMatchState": {
51
51
  "Type" : "Task",
52
- "Resource": "docker://agrare/hello-world:latest",
52
+ "Resource": "docker://docker.io/agrare/hello-world:latest",
53
53
  "Next": "PassState"
54
54
  },
55
55
 
56
56
  "SecondMatchState": {
57
57
  "Type" : "Task",
58
- "Resource": "docker://agrare/hello-world:latest",
58
+ "Resource": "docker://docker.io/agrare/hello-world:latest",
59
59
  "Next": "NextState"
60
60
  },
61
61
 
@@ -87,7 +87,7 @@
87
87
 
88
88
  "NextState": {
89
89
  "Type": "Task",
90
- "Resource": "docker://agrare/hello-world:latest",
90
+ "Resource": "docker://docker.io/agrare/hello-world:latest",
91
91
  "Secrets": ["vmdb:aaa-bbb-ccc"],
92
92
  "End": true
93
93
  }
data/exe/floe CHANGED
@@ -37,3 +37,4 @@ workflow = Floe::Workflow.load(opts[:workflow], context, opts[:credentials])
37
37
  workflow.run!
38
38
 
39
39
  puts workflow.output.inspect
40
+ exit workflow.status == "success" ? 0 : 1
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.4.0"
4
+ VERSION = "0.5.0".freeze
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
@@ -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" => {
@@ -13,24 +13,6 @@ module Floe
13
13
  @network = options.fetch("network", "bridge")
14
14
  end
15
15
 
16
- def run!(resource, env = {}, secrets = {})
17
- raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
18
-
19
- image = resource.sub("docker://", "")
20
-
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
16
  def run_async!(resource, env = {}, secrets = {})
35
17
  raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
36
18
 
@@ -40,11 +22,10 @@ module Floe
40
22
 
41
23
  if secrets && !secrets.empty?
42
24
  runner_context["secrets_ref"] = create_secret(secrets)
43
- env["_CREDENTIALS"] = "/run/secrets"
44
25
  end
45
26
 
46
27
  begin
47
- runner_context["container_ref"] = run_container(image, env, runner_context["secrets_ref"], :detached => true)
28
+ runner_context["container_ref"] = run_container(image, env, runner_context["secrets_ref"])
48
29
  rescue
49
30
  cleanup(runner_context)
50
31
  raise
@@ -73,7 +54,7 @@ module Floe
73
54
  end
74
55
 
75
56
  def output(runner_context)
76
- output = docker!("logs", runner_context["container_ref"]).output
57
+ output = docker!("logs", runner_context["container_ref"], :combined_output => true).output
77
58
  runner_context["output"] = output
78
59
  end
79
60
 
@@ -81,10 +62,11 @@ module Floe
81
62
 
82
63
  attr_reader :network
83
64
 
84
- def run_container(image, env, secrets_file, detached: false)
65
+ def run_container(image, env, secrets_file)
85
66
  params = ["run"]
86
- params << (detached ? :detach : :rm)
67
+ params << :detach
87
68
  params += env.map { |k, v| [:e, "#{k}=#{v}"] }
69
+ params << [:e, "_CREDENTIALS=/run/secrets"] if secrets_file
88
70
  params << [:net, "host"] if @network == "host"
89
71
  params << [:v, "#{secrets_file}:/run/secrets:z"] if secrets_file
90
72
  params << image
@@ -35,35 +35,9 @@ module Floe
35
35
 
36
36
  @namespace = options.fetch("namespace", "default")
37
37
 
38
- super
39
- end
40
-
41
- def run!(resource, env = {}, secrets = {})
42
- raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
43
-
44
- image = resource.sub("docker://", "")
45
- name = pod_name(image)
46
- secret = create_secret!(secrets) if secrets && !secrets.empty?
47
-
48
- begin
49
- runner_context = {"container_ref" => name}
38
+ @task_service_account = options["task_service_account"]
50
39
 
51
- create_pod!(name, image, env, secret)
52
- loop do
53
- case pod_info(name).dig("status", "phase")
54
- when "Pending", "Running"
55
- sleep(1)
56
- else # also "Succeeded"
57
- runner_context["exit_code"] = 0
58
- output(runner_context)
59
- break
60
- end
61
- end
62
-
63
- runner_context
64
- ensure
65
- cleanup({"container_ref" => name, "secrets_ref" => secret})
66
- end
40
+ super
67
41
  end
68
42
 
69
43
  def run_async!(resource, env = {}, secrets = {})
@@ -148,6 +122,8 @@ module Floe
148
122
  }
149
123
  }
150
124
 
125
+ spec[:spec][:serviceAccountName] = @task_service_account if @task_service_account
126
+
151
127
  if secret
152
128
  spec[:spec][:volumes] = [
153
129
  {
@@ -26,23 +26,6 @@ module Floe
26
26
  @volumepath = options["volumepath"]
27
27
  end
28
28
 
29
- def run!(resource, env = {}, secrets = {})
30
- raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
31
-
32
- image = resource.sub("docker://", "")
33
-
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
29
  def run_async!(resource, env = {}, secrets = {})
47
30
  raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
48
31
 
@@ -50,11 +33,10 @@ module Floe
50
33
 
51
34
  if secrets && !secrets.empty?
52
35
  secret_guid = create_secret(secrets)
53
- env["_CREDENTIALS"] = "/run/secrets/#{secret_guid}"
54
36
  end
55
37
 
56
38
  begin
57
- container_id = run_container(image, env, secret_guid, :detached => true)
39
+ container_id = run_container(image, env, secret_guid)
58
40
  rescue
59
41
  cleanup({"container_ref" => container_id, "secrets_ref" => secret_guid})
60
42
  raise
@@ -83,16 +65,17 @@ module Floe
83
65
  end
84
66
 
85
67
  def output(runner_context)
86
- output = podman!("logs", runner_context["container_ref"]).output
68
+ output = podman!("logs", runner_context["container_ref"], :combined_output => true).output
87
69
  runner_context["output"] = output
88
70
  end
89
71
 
90
72
  private
91
73
 
92
- def run_container(image, env, secret, detached: false)
74
+ def run_container(image, env, secret)
93
75
  params = ["run"]
94
- params << (detached ? :detach : :rm)
76
+ params << :detach
95
77
  params += env.map { |k, v| [:e, "#{k}=#{v}"] }
78
+ params << [:e, "_CREDENTIALS=/run/secrets/#{secret}"] if secret
96
79
  params << [:net, "host"] if @network == "host"
97
80
  params << [:secret, secret] if secret
98
81
  params << image
@@ -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
@@ -34,23 +35,24 @@ module Floe
34
35
  raise NotImplementedError, "Must be implemented in a subclass"
35
36
  end
36
37
 
38
+ # @return [Hash] runner_context
37
39
  def run_async!(_image, _env = {}, _secrets = {})
38
40
  raise NotImplementedError, "Must be implemented in a subclass"
39
41
  end
40
42
 
41
- def running?(_ref)
43
+ def running?(_runner_context)
42
44
  raise NotImplementedError, "Must be implemented in a subclass"
43
45
  end
44
46
 
45
- def success?(_ref)
47
+ def success?(_runner_context)
46
48
  raise NotImplementedError, "Must be implemented in a subclass"
47
49
  end
48
50
 
49
- def output(_ref)
51
+ def output(_runner_context)
50
52
  raise NotImplementedError, "Must be implemented in a subclass"
51
53
  end
52
54
 
53
- def cleanup(_ref, _secret)
55
+ def cleanup(_runner_context)
54
56
  raise NotImplementedError, "Must be implemented in a subclass"
55
57
  end
56
58
  end
@@ -92,6 +92,23 @@ module Floe
92
92
  def finished?
93
93
  context.state.key?("FinishedTime")
94
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
95
112
  end
96
113
  end
97
114
  end
@@ -9,16 +9,24 @@ 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
18
  def start(input)
17
19
  super
18
- context.state["Error"] = error
19
- context.state["Cause"] = cause
20
- context.next_state = nil
21
- context.output = input
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"]
22
30
  end
23
31
 
24
32
  def running?
@@ -29,10 +29,10 @@ module Floe
29
29
 
30
30
  def start(input)
31
31
  super
32
- input = input_path.value(context, input)
33
- input = parameters.value(context, input) if parameters
34
32
 
33
+ input = process_input(input)
35
34
  runner_context = runner.run_async!(resource, input, credentials&.value({}, workflow.credentials))
35
+
36
36
  context.state["RunnerContext"] = runner_context
37
37
  end
38
38
 
@@ -41,13 +41,15 @@ module Floe
41
41
  end
42
42
 
43
43
  def finish
44
- results = runner.output(context.state["RunnerContext"])
44
+ output = runner.output(context.state["RunnerContext"])
45
45
 
46
46
  if success?
47
- context.state["Output"] = process_output!(results)
47
+ output = parse_output(output)
48
+ context.state["Output"] = process_output!(output)
48
49
  context.next_state = next_state
49
50
  else
50
- retry_state!(results) || catch_error!(results)
51
+ error = parse_error(output)
52
+ retry_state!(error) || catch_error!(error) || fail_workflow!(error)
51
53
  end
52
54
 
53
55
  super
@@ -56,6 +58,8 @@ module Floe
56
58
  end
57
59
 
58
60
  def running?
61
+ return true if waiting?
62
+
59
63
  runner.status!(context.state["RunnerContext"])
60
64
  runner.running?(context.state["RunnerContext"])
61
65
  end
@@ -81,7 +85,7 @@ module Floe
81
85
  end
82
86
 
83
87
  def retry_state!(error)
84
- retrier = find_retrier(error)
88
+ retrier = find_retrier(error["Error"]) if error
85
89
  return if retrier.nil?
86
90
 
87
91
  # If a different retrier is hit reset the context
@@ -94,16 +98,24 @@ module Floe
94
98
 
95
99
  return if context["State"]["RetryCount"] > retrier.max_attempts
96
100
 
97
- # TODO: Kernel.sleep(retrier.sleep_duration(context["State"]["RetryCount"]))
101
+ wait(:seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
98
102
  context.next_state = context.state_name
99
103
  true
100
104
  end
101
105
 
102
106
  def catch_error!(error)
103
- catcher = find_catcher(error)
104
- raise error if catcher.nil?
107
+ catcher = find_catcher(error["Error"]) if error
108
+ return if catcher.nil?
105
109
 
106
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"]
107
119
  end
108
120
 
109
121
  def process_input(input)
@@ -112,17 +124,27 @@ module Floe
112
124
  input
113
125
  end
114
126
 
127
+ def parse_error(output)
128
+ return if output.nil?
129
+
130
+ JSON.parse(output.split("\n").last)
131
+ rescue JSON::ParserError
132
+ {"Error" => output.chomp}
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
+
115
143
  def process_output!(results)
116
- output = process_input(context.state["Input"])
144
+ output = context.input.dup
117
145
  return output if results.nil?
118
146
  return if output_path.nil?
119
147
 
120
- begin
121
- results = JSON.parse(results)
122
- rescue JSON::ParserError
123
- results = {"results" => results}
124
- end
125
-
126
148
  results = result_selector.value(context, results) if result_selector
127
149
  output = result_path.set(output, results)
128
150
  output_path.value(context, output)
@@ -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,9 +11,12 @@ 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", "$"))
@@ -23,21 +28,25 @@ module Floe
23
28
 
24
29
  context.output = output_path.value(context, input)
25
30
  context.next_state = end? ? nil : @next
31
+ please_hold(input)
26
32
  end
27
33
 
28
34
  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
35
+ waiting?
36
36
  end
37
37
 
38
38
  def end?
39
39
  @end
40
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
41
50
  end
42
51
  end
43
52
  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.4.0
4
+ version: 0.5.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-09-26 00:00:00.000000000 Z
11
+ date: 2023-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: awesome_spawn
@@ -152,7 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
152
  - !ruby/object:Gem::Version
153
153
  version: '0'
154
154
  requirements: []
155
- rubygems_version: 3.3.15
155
+ rubygems_version: 3.2.33
156
156
  signing_key:
157
157
  specification_version: 4
158
158
  summary: Simple Workflow Runner.