floe 0.4.1 → 0.6.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: b2bab2c07fd71a45bcf896aa219993770c83eedd5c4e3268c318d8b9ba0413ed
4
- data.tar.gz: 786064609be7fee8a19855cb97fd160984b5a8a7aedc85344eab01302155a988
3
+ metadata.gz: 58ace49051c911efbe352b2b882cc663aea3be231e6c8dcf138864e340ae2de6
4
+ data.tar.gz: d422be1ce106663dd1dc40b7ddb52024f9eb9b0e85f807572d66f31fcdf16e51
5
5
  SHA512:
6
- metadata.gz: 45a9fd8a85bb5b1059574c0dc63b0434496b6c75634c567aba9f0008400867b4dbf8c33337b3940084c644606dc3fe6065fc4f79ac5732449ca0d9675d0e2531
7
- data.tar.gz: f4147cd4e1633d2af02b7cbbd04735a9ffdc733359eb7f349b23a2fb8bada9de18a4f37fad82de543f2eb8060fa6fa52d1864a159f3ab3f780cbdab1824b6ab1
6
+ metadata.gz: e5eab9d1763c723d8bb913e31bb736d0ba3c3e6d6d63585031e30adc5cbd3049105c756b89eee6999df2d6a0dcfd35e6a1ae4a39c10ce949387e91aae30acf32
7
+ data.tar.gz: 3249cce513b7195ce5ed791c300d9df62159febeb522c45db36dda38244c76f9290af3157ac744fa0757417ce327f95eea80c704375310ed3181e05426208e3b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ This project adheres to [Semantic Versioning](http://semver.org/).
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2023-11-09
8
+ ### Added
9
+ - Prefix pod names with 'floe-' ([#132](https://github.com/ManageIQ/floe/pull/132))
10
+ - Validate that the workflow payload is correct ([#136](https://github.com/ManageIQ/floe/pull/136))
11
+
12
+ ### Fixed
13
+ - Fix issue where certain docker image names cannot be pod names ([#134](https://github.com/ManageIQ/floe/pull/134))
14
+ - Fix uninitialized constant RSpec::Support::Differ in tests ([#137](https://github.com/ManageIQ/floe/pull/137))
15
+ - Handle ImagePullErr/ImagePullBackOff as errors ([#135](https://github.com/ManageIQ/floe/pull/135))
16
+
17
+ ### Changed
18
+ - Add task spec helper ([#123](https://github.com/ManageIQ/floe/pull/123))
19
+ - Rename State#run_wait to just #wait ([#139](https://github.com/ManageIQ/floe/pull/139))
20
+ - Refactor the Podman runner to be a Docker subclass ([#140](https://github.com/ManageIQ/floe/pull/140))
21
+
22
+ ## [0.5.0] - 2023-10-12
23
+ ### Added
24
+ - For task errors, use the json on the last line ([#128](https://github.com/ManageIQ/floe/pull/128))
25
+ - Add ability to pass task service account to kube runner ([#131](https://github.com/ManageIQ/floe/pull/131))
26
+
27
+ ### Fixed
28
+ - Don't put credentials file into input ([#124](https://github.com/ManageIQ/floe/pull/124))
29
+ - exe/floe return success status if the workflow was successful ([#129](https://github.com/ManageIQ/floe/pull/129))
30
+ - For error output, drop trailing newline ([#126](https://github.com/ManageIQ/floe/pull/126))
31
+
7
32
  ## [0.4.1] - 2023-10-06
8
33
  ### Added
9
34
  - Add Fail#CausePath and Fail#ErrorPath ([#110](https://github.com/ManageIQ/floe/pull/110))
@@ -22,19 +47,19 @@ This project adheres to [Semantic Versioning](http://semver.org/).
22
47
 
23
48
  ## [0.3.1] - 2023-08-29
24
49
  ### Added
25
- - Add more global podman runner options ([#90])(https://github.com/ManageIQ/floe/pull/90)
50
+ - Add more global podman runner options ([#90](https://github.com/ManageIQ/floe/pull/90))
26
51
 
27
52
  ## [0.3.0] - 2023-08-07
28
53
  ### Added
29
- - Add --network=host option to Docker/Podman runners ([#81])(https://github.com/ManageIQ/floe/pull/81)
54
+ - Add --network=host option to Docker/Podman runners ([#81](https://github.com/ManageIQ/floe/pull/81))
30
55
 
31
56
  ### Fixed
32
- - Fix PayloadTemplate value transformation rules ([#78])(https://github.com/ManageIQ/floe/pull/78)
33
- - Move end out of the root state node ([#80])(https://github.com/ManageIQ/floe/pull/80)
57
+ - Fix PayloadTemplate value transformation rules ([#78](https://github.com/ManageIQ/floe/pull/78))
58
+ - Move end out of the root state node ([#80](https://github.com/ManageIQ/floe/pull/80))
34
59
 
35
60
  ## [0.2.3] - 2023-07-28
36
61
  ### Fixed
37
- - Fix storing next_state in Context ([#76])(https://github.com/ManageIQ/floe/pull/76)
62
+ - Fix storing next_state in Context ([#76](https://github.com/ManageIQ/floe/pull/76))
38
63
 
39
64
  ## [0.2.2] - 2023-07-24
40
65
  ### Fixed
@@ -74,7 +99,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
74
99
  ### Added
75
100
  - Initial release
76
101
 
77
- [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.4.1...HEAD
102
+ [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.6.0...HEAD
103
+ [0.6.0]: https://github.com/ManageIQ/floe/compare/v0.5.0...v0.6.0
104
+ [0.5.0]: https://github.com/ManageIQ/floe/compare/v0.4.1...v0.5.0
78
105
  [0.4.1]: https://github.com/ManageIQ/floe/compare/v0.4.0...v0.4.1
79
106
  [0.4.0]: https://github.com/ManageIQ/floe/compare/v0.3.1...v0.4.0
80
107
  [0.3.1]: https://github.com/ManageIQ/floe/compare/v0.3.0...v0.3.1
@@ -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.1"
4
+ VERSION = "0.6.0".freeze
5
5
  end
@@ -11,6 +11,9 @@ module Floe
11
11
 
12
12
  def initialize(payload)
13
13
  @payload = payload
14
+
15
+ raise Floe::InvalidWorkflowError, "Path [#{payload}] must be a string" if payload.nil? || !payload.kind_of?(String)
16
+ raise Floe::InvalidWorkflowError, "Path [#{payload}] must start with \"$\"" if payload[0] != "$"
14
17
  end
15
18
 
16
19
  def value(context, input = {})
@@ -4,6 +4,10 @@ module Floe
4
4
  class Workflow
5
5
  class Runner
6
6
  class Docker < Floe::Workflow::Runner
7
+ include DockerMixin
8
+
9
+ DOCKER_COMMAND = "docker"
10
+
7
11
  def initialize(options = {})
8
12
  require "awesome_spawn"
9
13
  require "tempfile"
@@ -13,24 +17,6 @@ module Floe
13
17
  @network = options.fetch("network", "bridge")
14
18
  end
15
19
 
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
20
  def run_async!(resource, env = {}, secrets = {})
35
21
  raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
36
22
 
@@ -40,11 +26,10 @@ module Floe
40
26
 
41
27
  if secrets && !secrets.empty?
42
28
  runner_context["secrets_ref"] = create_secret(secrets)
43
- env["_CREDENTIALS"] = "/run/secrets"
44
29
  end
45
30
 
46
31
  begin
47
- runner_context["container_ref"] = run_container(image, env, runner_context["secrets_ref"], :detached => true)
32
+ runner_context["container_ref"] = run_container(image, env, runner_context["secrets_ref"])
48
33
  rescue
49
34
  cleanup(runner_context)
50
35
  raise
@@ -57,7 +42,7 @@ module Floe
57
42
  container_id, secrets_file = runner_context.values_at("container_ref", "secrets_ref")
58
43
 
59
44
  delete_container(container_id) if container_id
60
- File.unlink(secrets_file) if secrets_file && File.exist?(secrets_file)
45
+ delete_secret(secrets_file) if secrets_file
61
46
  end
62
47
 
63
48
  def status!(runner_context)
@@ -81,12 +66,14 @@ module Floe
81
66
 
82
67
  attr_reader :network
83
68
 
84
- def run_container(image, env, secrets_file, detached: false)
69
+ def run_container(image, env, secrets_file)
85
70
  params = ["run"]
86
- params << (detached ? :detach : :rm)
71
+ params << :detach
87
72
  params += env.map { |k, v| [:e, "#{k}=#{v}"] }
73
+ params << [:e, "_CREDENTIALS=/run/secrets"] if secrets_file
88
74
  params << [:net, "host"] if @network == "host"
89
75
  params << [:v, "#{secrets_file}:/run/secrets:z"] if secrets_file
76
+ params << [:name, container_name(image)]
90
77
  params << image
91
78
 
92
79
  logger.debug("Running docker: #{AwesomeSpawn.build_command_line("docker", params)}")
@@ -105,6 +92,14 @@ module Floe
105
92
  nil
106
93
  end
107
94
 
95
+ def delete_secret(secrets_file)
96
+ return unless File.exist?(secrets_file)
97
+
98
+ File.unlink(secrets_file)
99
+ rescue
100
+ nil
101
+ end
102
+
108
103
  def create_secret(secrets)
109
104
  secrets_file = Tempfile.new
110
105
  secrets_file.write(secrets.to_json)
@@ -112,8 +107,13 @@ module Floe
112
107
  secrets_file.path
113
108
  end
114
109
 
115
- def docker!(*params, **kwargs)
116
- AwesomeSpawn.run!("docker", :params => params, **kwargs)
110
+ def global_docker_options
111
+ []
112
+ end
113
+
114
+ def docker!(*args, **kwargs)
115
+ params = global_docker_options + args
116
+ AwesomeSpawn.run!(self.class::DOCKER_COMMAND, :params => params, **kwargs)
117
117
  end
118
118
  end
119
119
  end
@@ -0,0 +1,31 @@
1
+ module Floe
2
+ class Workflow
3
+ class Runner
4
+ module DockerMixin
5
+ def image_name(image)
6
+ image.match(%r{^(?<repository>.+/)?(?<image>.+):(?<tag>.+)$})&.named_captures&.dig("image")
7
+ end
8
+
9
+ MAX_CONTAINER_NAME_SIZE = 63 - 5 - 9 # 63 is the max kubernetes pod name length
10
+ # -5 for the "floe-" prefix
11
+ # -9 for the random hex suffix and leading hyphen
12
+
13
+ def container_name(image)
14
+ name = image_name(image)
15
+ raise ArgumentError, "Invalid docker image [#{image}]" if name.nil?
16
+
17
+ # Normalize the image name to be used in the container name.
18
+ # This follows RFC 1123 Label names in Kubernetes as they are the most restrictive
19
+ # See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
20
+ # and https://github.com/kubernetes/kubernetes/blob/952a9cb0/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L178-L184
21
+ #
22
+ # This does not follow the leading and trailing character restriction because we will embed it
23
+ # below with a prefix and suffix that already conform to the RFC.
24
+ normalized_name = name.downcase.gsub(/[^a-z0-9-]/, "-")[0, MAX_CONTAINER_NAME_SIZE]
25
+
26
+ "floe-#{normalized_name}-#{SecureRandom.hex(4)}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -4,10 +4,15 @@ module Floe
4
4
  class Workflow
5
5
  class Runner
6
6
  class Kubernetes < Floe::Workflow::Runner
7
- TOKEN_FILE = "/run/secrets/kubernetes.io/serviceaccount/token"
8
- CA_CERT_FILE = "/run/secrets/kubernetes.io/serviceaccount/ca.crt"
7
+ include DockerMixin
8
+
9
+ TOKEN_FILE = "/run/secrets/kubernetes.io/serviceaccount/token"
10
+ CA_CERT_FILE = "/run/secrets/kubernetes.io/serviceaccount/ca.crt"
11
+ RUNNING_PHASES = %w[Pending Running].freeze
12
+ FAILURE_REASONS = %w[CrashLoopBackOff ImagePullBackOff ErrImagePull].freeze
9
13
 
10
14
  def initialize(options = {})
15
+ require "active_support/core_ext/hash/keys"
11
16
  require "awesome_spawn"
12
17
  require "securerandom"
13
18
  require "base64"
@@ -35,42 +40,16 @@ module Floe
35
40
 
36
41
  @namespace = options.fetch("namespace", "default")
37
42
 
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}
43
+ @task_service_account = options["task_service_account"]
50
44
 
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
45
+ super
67
46
  end
68
47
 
69
48
  def run_async!(resource, env = {}, secrets = {})
70
49
  raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
71
50
 
72
51
  image = resource.sub("docker://", "")
73
- name = pod_name(image)
52
+ name = container_name(image)
74
53
  secret = create_secret!(secrets) if secrets && !secrets.empty?
75
54
 
76
55
  runner_context = {"container_ref" => name, "secrets_ref" => secret}
@@ -86,11 +65,17 @@ module Floe
86
65
  end
87
66
 
88
67
  def status!(runner_context)
89
- runner_context["container_state"] = pod_info(runner_context["container_ref"])["status"]
68
+ runner_context["container_state"] = pod_info(runner_context["container_ref"]).to_h.deep_stringify_keys["status"]
90
69
  end
91
70
 
92
71
  def running?(runner_context)
93
- %w[Pending Running].include?(runner_context.dig("container_state", "phase"))
72
+ return false unless pod_running?(runner_context)
73
+ # If a pod is Pending and the containers are waiting with a failure
74
+ # reason such as ImagePullBackOff or CrashLoopBackOff then the pod
75
+ # will never be run.
76
+ return false if container_failed?(runner_context)
77
+
78
+ true
94
79
  end
95
80
 
96
81
  def success?(runner_context)
@@ -98,8 +83,13 @@ module Floe
98
83
  end
99
84
 
100
85
  def output(runner_context)
101
- output = kubeclient.get_pod_log(runner_context["container_ref"], namespace).body
102
- runner_context["output"] = output
86
+ runner_context["output"] =
87
+ if container_failed?(runner_context)
88
+ failed_state = failed_container_states(runner_context).first
89
+ {"Error" => failed_state["reason"], "Cause" => failed_state["message"]}
90
+ else
91
+ kubeclient.get_pod_log(runner_context["container_ref"], namespace).body
92
+ end
103
93
  end
104
94
 
105
95
  def cleanup(runner_context)
@@ -117,15 +107,18 @@ module Floe
117
107
  kubeclient.get_pod(pod_name, namespace)
118
108
  end
119
109
 
120
- def container_name(image)
121
- image.match(%r{^(?<repository>.+/)?(?<image>.+):(?<tag>.+)$})&.named_captures&.dig("image")
110
+ def pod_running?(context)
111
+ RUNNING_PHASES.include?(context.dig("container_state", "phase"))
122
112
  end
123
113
 
124
- def pod_name(image)
125
- container_short_name = container_name(image)
126
- raise ArgumentError, "Invalid docker image [#{image}]" if container_short_name.nil?
114
+ def failed_container_states(context)
115
+ container_statuses = context.dig("container_state", "containerStatuses") || []
116
+ container_statuses.map { |status| status["state"]&.values&.first }.compact
117
+ .select { |state| FAILURE_REASONS.include?(state["reason"]) }
118
+ end
127
119
 
128
- "#{container_short_name}-#{SecureRandom.uuid}"
120
+ def container_failed?(context)
121
+ failed_container_states(context).any?
129
122
  end
130
123
 
131
124
  def pod_spec(name, image, env, secret = nil)
@@ -139,7 +132,7 @@ module Floe
139
132
  :spec => {
140
133
  :containers => [
141
134
  {
142
- :name => container_name(image),
135
+ :name => name[0...-9], # remove the random suffix and its leading hyphen
143
136
  :image => image,
144
137
  :env => env.map { |k, v| {:name => k, :value => v.to_s} }
145
138
  }
@@ -148,6 +141,8 @@ module Floe
148
141
  }
149
142
  }
150
143
 
144
+ spec[:spec][:serviceAccountName] = @task_service_account if @task_service_account
145
+
151
146
  if secret
152
147
  spec[:spec][:volumes] = [
153
148
  {
@@ -3,7 +3,9 @@
3
3
  module Floe
4
4
  class Workflow
5
5
  class Runner
6
- class Podman < Floe::Workflow::Runner
6
+ class Podman < Floe::Workflow::Runner::Docker
7
+ DOCKER_COMMAND = "podman"
8
+
7
9
  def initialize(options = {})
8
10
  require "awesome_spawn"
9
11
  require "securerandom"
@@ -26,75 +28,16 @@ module Floe
26
28
  @volumepath = options["volumepath"]
27
29
  end
28
30
 
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
- def run_async!(resource, env = {}, secrets = {})
47
- raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
48
-
49
- image = resource.sub("docker://", "")
50
-
51
- if secrets && !secrets.empty?
52
- secret_guid = create_secret(secrets)
53
- env["_CREDENTIALS"] = "/run/secrets/#{secret_guid}"
54
- end
55
-
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
61
- end
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
31
  private
91
32
 
92
- def run_container(image, env, secret, detached: false)
33
+ def run_container(image, env, secret)
93
34
  params = ["run"]
94
- params << (detached ? :detach : :rm)
35
+ params << :detach
95
36
  params += env.map { |k, v| [:e, "#{k}=#{v}"] }
37
+ params << [:e, "_CREDENTIALS=/run/secrets/#{secret}"] if secret
96
38
  params << [:net, "host"] if @network == "host"
97
39
  params << [:secret, secret] if secret
40
+ params << [:name, container_name(image)]
98
41
  params << image
99
42
 
100
43
  logger.debug("Running podman: #{AwesomeSpawn.build_command_line("podman", params)}")
@@ -103,16 +46,6 @@ module Floe
103
46
  result.output
104
47
  end
105
48
 
106
- def inspect_container(container_id)
107
- JSON.parse(podman!("inspect", container_id).output)
108
- end
109
-
110
- def delete_container(container_id)
111
- podman!("rm", container_id)
112
- rescue
113
- nil
114
- end
115
-
116
49
  def create_secret(secrets)
117
50
  secret_guid = SecureRandom.uuid
118
51
  podman!("secret", "create", secret_guid, "-", :in_data => secrets.to_json)
@@ -125,13 +58,9 @@ module Floe
125
58
  nil
126
59
  end
127
60
 
128
- def podman!(*args, **kwargs)
129
- params = podman_global_options + args
130
-
131
- AwesomeSpawn.run!("podman", :params => params, **kwargs)
132
- end
61
+ alias podman! docker!
133
62
 
134
- def podman_global_options
63
+ def global_docker_options
135
64
  options = []
136
65
  options << [:identity, @identity] if @identity
137
66
  options << [:"log-level", @log_level] if @log_level
@@ -8,6 +8,7 @@ module Floe
8
8
  class << self
9
9
  def build!(workflow, name, payload)
10
10
  state_type = payload["Type"]
11
+ raise Floe::InvalidWorkflowError, "Missing \"Type\" field in state [#{name}]" if payload["Type"].nil?
11
12
 
12
13
  begin
13
14
  klass = Floe::Workflow::States.const_get(state_type)
@@ -27,13 +28,16 @@ module Floe
27
28
  @payload = payload
28
29
  @type = payload["Type"]
29
30
  @comment = payload["Comment"]
31
+
32
+ raise Floe::InvalidWorkflowError, "Missing \"Type\" field in state [#{name}]" if payload["Type"].nil?
33
+ raise Floe::InvalidWorkflowError, "State name [#{name}] must be less than or equal to 80 characters" if name.length > 80
30
34
  end
31
35
 
32
36
  def run!(_input = nil)
33
- run_wait until run_nonblock! == 0
37
+ wait until run_nonblock! == 0
34
38
  end
35
39
 
36
- def run_wait(timeout: 5)
40
+ def wait(timeout: 5)
37
41
  start = Time.now.utc
38
42
 
39
43
  loop do
@@ -95,7 +99,7 @@ module Floe
95
99
 
96
100
  private
97
101
 
98
- def wait(seconds: nil, time: nil)
102
+ def wait_until!(seconds: nil, time: nil)
99
103
  context.state["WaitUntil"] =
100
104
  if seconds
101
105
  (Time.parse(context.state["EnteredTime"]) + seconds).iso8601
@@ -9,6 +9,8 @@ module Floe
9
9
  def initialize(workflow, name, payload)
10
10
  super
11
11
 
12
+ validate_state!
13
+
12
14
  @choices = payload["Choices"].map { |choice| ChoiceRule.build(choice) }
13
15
  @default = payload["Default"]
14
16
 
@@ -33,6 +35,22 @@ module Floe
33
35
  def end?
34
36
  false
35
37
  end
38
+
39
+ private
40
+
41
+ def validate_state!
42
+ validate_state_choices!
43
+ validate_state_default!
44
+ end
45
+
46
+ def validate_state_choices!
47
+ raise Floe::InvalidWorkflowError, "Choice state must have \"Choices\"" unless payload.key?("Choices")
48
+ raise Floe::InvalidWorkflowError, "\"Choices\" must be a non-empty array" unless payload["Choices"].kind_of?(Array) && !payload["Choices"].empty?
49
+ end
50
+
51
+ def validate_state_default!
52
+ raise Floe::InvalidWorkflowError, "\"Default\" not in \"States\"" unless workflow.payload["States"].include?(payload["Default"])
53
+ end
36
54
  end
37
55
  end
38
56
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ module States
6
+ module NonTerminalMixin
7
+ def validate_state_next!
8
+ raise Floe::InvalidWorkflowError, "Missing \"Next\" field in state [#{name}]" if @next.nil? && !@end
9
+ raise Floe::InvalidWorkflowError, "\"Next\" [#{@next}] not in \"States\" for state [#{name}]" if @next && !workflow.payload["States"].key?(@next)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -4,6 +4,8 @@ module Floe
4
4
  class Workflow
5
5
  module States
6
6
  class Pass < Floe::Workflow::State
7
+ include NonTerminalMixin
8
+
7
9
  attr_reader :end, :next, :result, :parameters, :input_path, :output_path, :result_path
8
10
 
9
11
  def initialize(workflow, name, payload)
@@ -17,6 +19,8 @@ module Floe
17
19
  @input_path = Path.new(payload.fetch("InputPath", "$"))
18
20
  @output_path = Path.new(payload.fetch("OutputPath", "$"))
19
21
  @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
22
+
23
+ validate_state!
20
24
  end
21
25
 
22
26
  def start(input)
@@ -36,6 +40,12 @@ module Floe
36
40
  def end?
37
41
  @end
38
42
  end
43
+
44
+ private
45
+
46
+ def validate_state!
47
+ validate_state_next!
48
+ end
39
49
  end
40
50
  end
41
51
  end
@@ -4,6 +4,8 @@ module Floe
4
4
  class Workflow
5
5
  module States
6
6
  class Task < Floe::Workflow::State
7
+ include NonTerminalMixin
8
+
7
9
  attr_reader :credentials, :end, :heartbeat_seconds, :next, :parameters,
8
10
  :result_selector, :resource, :timeout_seconds, :retry, :catch,
9
11
  :input_path, :output_path, :result_path
@@ -25,6 +27,8 @@ module Floe
25
27
  @parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
26
28
  @result_selector = PayloadTemplate.new(payload["ResultSelector"]) if payload["ResultSelector"]
27
29
  @credentials = PayloadTemplate.new(payload["Credentials"]) if payload["Credentials"]
30
+
31
+ validate_state!
28
32
  end
29
33
 
30
34
  def start(input)
@@ -72,6 +76,10 @@ module Floe
72
76
 
73
77
  attr_reader :runner
74
78
 
79
+ def validate_state!
80
+ validate_state_next!
81
+ end
82
+
75
83
  def success?
76
84
  runner.success?(context.state["RunnerContext"])
77
85
  end
@@ -98,7 +106,7 @@ module Floe
98
106
 
99
107
  return if context["State"]["RetryCount"] > retrier.max_attempts
100
108
 
101
- wait(:seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
109
+ wait_until!(:seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
102
110
  context.next_state = context.state_name
103
111
  true
104
112
  end
@@ -126,14 +134,16 @@ module Floe
126
134
 
127
135
  def parse_error(output)
128
136
  return if output.nil?
137
+ return output if output.kind_of?(Hash)
129
138
 
130
- JSON.parse(output)
139
+ JSON.parse(output.split("\n").last)
131
140
  rescue JSON::ParserError
132
- {"Error" => output}
141
+ {"Error" => output.chomp}
133
142
  end
134
143
 
135
144
  def parse_output(output)
136
145
  return if output.nil?
146
+ return output if output.kind_of?(Hash)
137
147
 
138
148
  JSON.parse(output.split("\n").last)
139
149
  rescue JSON::ParserError
@@ -6,7 +6,9 @@ module Floe
6
6
  class Workflow
7
7
  module States
8
8
  class Wait < Floe::Workflow::State
9
- attr_reader :end, :next, :seconds, :input_path, :output_path
9
+ include NonTerminalMixin
10
+
11
+ attr_reader :end, :input_path, :next, :seconds, :seconds_path, :timestamp, :timestamp_path, :output_path
10
12
 
11
13
  def initialize(workflow, name, payload)
12
14
  super
@@ -20,6 +22,8 @@ module Floe
20
22
 
21
23
  @input_path = Path.new(payload.fetch("InputPath", "$"))
22
24
  @output_path = Path.new(payload.fetch("OutputPath", "$"))
25
+
26
+ validate_state!
23
27
  end
24
28
 
25
29
  def start(input)
@@ -28,7 +32,11 @@ module Floe
28
32
 
29
33
  context.output = output_path.value(context, input)
30
34
  context.next_state = end? ? nil : @next
31
- please_hold(input)
35
+
36
+ wait_until!(
37
+ :seconds => seconds_path ? seconds_path.value(context, input).to_i : seconds,
38
+ :time => timestamp_path ? timestamp_path.value(context, input) : timestamp
39
+ )
32
40
  end
33
41
 
34
42
  def running?
@@ -41,11 +49,8 @@ module Floe
41
49
 
42
50
  private
43
51
 
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
- )
52
+ def validate_state!
53
+ validate_state_next!
49
54
  end
50
55
  end
51
56
  end
data/lib/floe/workflow.rb CHANGED
@@ -38,6 +38,10 @@ module Floe
38
38
  credentials = JSON.parse(credentials) if credentials.kind_of?(String)
39
39
  context = Context.new(context) unless context.kind_of?(Context)
40
40
 
41
+ raise Floe::InvalidWorkflowError, "Missing field \"States\"" if payload["States"].nil?
42
+ raise Floe::InvalidWorkflowError, "Missing field \"StartAt\"" if payload["StartAt"].nil?
43
+ raise Floe::InvalidWorkflowError, "\"StartAt\" not in the \"States\" field" unless payload["States"].key?(payload["StartAt"])
44
+
41
45
  @payload = payload
42
46
  @context = context
43
47
  @credentials = credentials
@@ -77,7 +81,7 @@ module Floe
77
81
  end
78
82
 
79
83
  def step_nonblock_wait(timeout: 5)
80
- current_state.run_wait(:timeout => timeout)
84
+ current_state.wait(:timeout => timeout)
81
85
  end
82
86
 
83
87
  def step_nonblock_ready?
data/lib/floe.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "floe/workflow/payload_template"
18
18
  require_relative "floe/workflow/reference_path"
19
19
  require_relative "floe/workflow/retrier"
20
20
  require_relative "floe/workflow/runner"
21
+ require_relative "floe/workflow/runner/docker_mixin"
21
22
  require_relative "floe/workflow/runner/docker"
22
23
  require_relative "floe/workflow/runner/kubernetes"
23
24
  require_relative "floe/workflow/runner/podman"
@@ -25,6 +26,7 @@ require_relative "floe/workflow/state"
25
26
  require_relative "floe/workflow/states/choice"
26
27
  require_relative "floe/workflow/states/fail"
27
28
  require_relative "floe/workflow/states/map"
29
+ require_relative "floe/workflow/states/non_terminal_mixin"
28
30
  require_relative "floe/workflow/states/parallel"
29
31
  require_relative "floe/workflow/states/pass"
30
32
  require_relative "floe/workflow/states/succeed"
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.1
4
+ version: 0.6.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-10-06 00:00:00.000000000 Z
11
+ date: 2023-11-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: awesome_spawn
@@ -117,12 +117,14 @@ files:
117
117
  - lib/floe/workflow/retrier.rb
118
118
  - lib/floe/workflow/runner.rb
119
119
  - lib/floe/workflow/runner/docker.rb
120
+ - lib/floe/workflow/runner/docker_mixin.rb
120
121
  - lib/floe/workflow/runner/kubernetes.rb
121
122
  - lib/floe/workflow/runner/podman.rb
122
123
  - lib/floe/workflow/state.rb
123
124
  - lib/floe/workflow/states/choice.rb
124
125
  - lib/floe/workflow/states/fail.rb
125
126
  - lib/floe/workflow/states/map.rb
127
+ - lib/floe/workflow/states/non_terminal_mixin.rb
126
128
  - lib/floe/workflow/states/parallel.rb
127
129
  - lib/floe/workflow/states/pass.rb
128
130
  - lib/floe/workflow/states/succeed.rb
@@ -152,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
154
  - !ruby/object:Gem::Version
153
155
  version: '0'
154
156
  requirements: []
155
- rubygems_version: 3.3.15
157
+ rubygems_version: 3.4.20
156
158
  signing_key:
157
159
  specification_version: 4
158
160
  summary: Simple Workflow Runner.