floe 0.4.1 → 0.6.0

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