floe 0.10.0 → 0.11.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.
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.10.0
4
+ version: 0.11.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: 2024-04-05 00:00:00.000000000 Z
11
+ date: 2024-05-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: awesome_spawn
@@ -185,8 +185,14 @@ files:
185
185
  - exe/floe
186
186
  - floe.gemspec
187
187
  - lib/floe.rb
188
+ - lib/floe/container_runner.rb
189
+ - lib/floe/container_runner/docker.rb
190
+ - lib/floe/container_runner/docker_mixin.rb
191
+ - lib/floe/container_runner/kubernetes.rb
192
+ - lib/floe/container_runner/podman.rb
188
193
  - lib/floe/logging.rb
189
194
  - lib/floe/null_logger.rb
195
+ - lib/floe/runner.rb
190
196
  - lib/floe/version.rb
191
197
  - lib/floe/workflow.rb
192
198
  - lib/floe/workflow/catcher.rb
@@ -200,11 +206,6 @@ files:
200
206
  - lib/floe/workflow/payload_template.rb
201
207
  - lib/floe/workflow/reference_path.rb
202
208
  - lib/floe/workflow/retrier.rb
203
- - lib/floe/workflow/runner.rb
204
- - lib/floe/workflow/runner/docker.rb
205
- - lib/floe/workflow/runner/docker_mixin.rb
206
- - lib/floe/workflow/runner/kubernetes.rb
207
- - lib/floe/workflow/runner/podman.rb
208
209
  - lib/floe/workflow/state.rb
209
210
  - lib/floe/workflow/states/choice.rb
210
211
  - lib/floe/workflow/states/fail.rb
@@ -1,227 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Floe
4
- class Workflow
5
- class Runner
6
- class Docker < Floe::Workflow::Runner
7
- include DockerMixin
8
-
9
- DOCKER_COMMAND = "docker"
10
-
11
- def initialize(options = {})
12
- require "awesome_spawn"
13
- require "io/wait"
14
- require "tempfile"
15
-
16
- super
17
-
18
- @network = options.fetch("network", "bridge")
19
- @pull_policy = options["pull-policy"]
20
- end
21
-
22
- def run_async!(resource, env = {}, secrets = {})
23
- raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
24
-
25
- image = resource.sub("docker://", "")
26
-
27
- runner_context = {}
28
-
29
- if secrets && !secrets.empty?
30
- runner_context["secrets_ref"] = create_secret(secrets)
31
- end
32
-
33
- begin
34
- runner_context["container_ref"] = run_container(image, env, runner_context["secrets_ref"])
35
- runner_context
36
- rescue AwesomeSpawn::CommandResultError => err
37
- cleanup(runner_context)
38
- {"Error" => "States.TaskFailed", "Cause" => err.to_s}
39
- end
40
- end
41
-
42
- def cleanup(runner_context)
43
- container_id, secrets_file = runner_context.values_at("container_ref", "secrets_ref")
44
-
45
- delete_container(container_id) if container_id
46
- delete_secret(secrets_file) if secrets_file
47
- end
48
-
49
- def wait(timeout: nil, events: %i[create update delete], &block)
50
- until_timestamp = Time.now.utc + timeout if timeout
51
-
52
- r, w = IO.pipe
53
-
54
- pid = AwesomeSpawn.run_detached(
55
- self.class::DOCKER_COMMAND, :err => :out, :out => w, :params => wait_params(until_timestamp)
56
- )
57
-
58
- w.close
59
-
60
- loop do
61
- readable_timeout = until_timestamp - Time.now.utc if until_timestamp
62
-
63
- # Wait for our end of the pipe to be readable and if it didn't timeout
64
- # get the events from stdout
65
- next if r.wait_readable(readable_timeout).nil?
66
-
67
- # Get all events while the pipe is readable
68
- notices = []
69
- while r.ready?
70
- notice = r.gets
71
-
72
- # If the process has exited `r.gets` returns `nil` and the pipe is
73
- # always `ready?`
74
- break if notice.nil?
75
-
76
- event, runner_context = parse_notice(notice)
77
- next if event.nil? || !events.include?(event)
78
-
79
- notices << [event, runner_context]
80
- end
81
-
82
- # If we're given a block yield the events otherwise return them
83
- if block
84
- notices.each(&block)
85
- else
86
- # Terminate the `docker events` process before returning the events
87
- sigterm(pid)
88
-
89
- return notices
90
- end
91
-
92
- # Check that the `docker events` process is still alive
93
- Process.kill(0, pid)
94
- rescue Errno::ESRCH
95
- # Break out of the loop if the `docker events` process has exited
96
- break
97
- end
98
- ensure
99
- r.close
100
- end
101
-
102
- def status!(runner_context)
103
- return if runner_context.key?("Error")
104
-
105
- runner_context["container_state"] = inspect_container(runner_context["container_ref"])&.dig("State")
106
- end
107
-
108
- def running?(runner_context)
109
- !!runner_context.dig("container_state", "Running")
110
- end
111
-
112
- def success?(runner_context)
113
- runner_context.dig("container_state", "ExitCode") == 0
114
- end
115
-
116
- def output(runner_context)
117
- return runner_context.slice("Error", "Cause") if runner_context.key?("Error")
118
-
119
- output = docker!("logs", runner_context["container_ref"], :combined_output => true).output
120
- runner_context["output"] = output
121
- end
122
-
123
- private
124
-
125
- attr_reader :network
126
-
127
- def run_container(image, env, secrets_file)
128
- params = run_container_params(image, env, secrets_file)
129
-
130
- logger.debug("Running #{AwesomeSpawn.build_command_line(self.class::DOCKER_COMMAND, params)}")
131
-
132
- result = docker!(*params)
133
- result.output
134
- end
135
-
136
- def run_container_params(image, env, secrets_file)
137
- params = ["run"]
138
- params << :detach
139
- params += env.map { |k, v| [:e, "#{k}=#{v}"] }
140
- params << [:e, "_CREDENTIALS=/run/secrets"] if secrets_file
141
- params << [:pull, @pull_policy] if @pull_policy
142
- params << [:net, "host"] if @network == "host"
143
- params << [:v, "#{secrets_file}:/run/secrets:z"] if secrets_file
144
- params << [:name, container_name(image)]
145
- params << image
146
- end
147
-
148
- def wait_params(until_timestamp)
149
- params = ["events", [:format, "{{json .}}"], [:filter, "type=container"], [:since, Time.now.utc.to_i]]
150
- params << [:until, until_timestamp.to_i] if until_timestamp
151
- params
152
- end
153
-
154
- def parse_notice(notice)
155
- notice = JSON.parse(notice)
156
-
157
- status = notice["status"]
158
- event = docker_event_status_to_event(status)
159
- running = event != :delete
160
-
161
- name, exit_code = notice.dig("Actor", "Attributes")&.values_at("name", "exitCode")
162
-
163
- runner_context = {"container_ref" => name, "container_state" => {"Running" => running, "ExitCode" => exit_code.to_i}}
164
-
165
- [event, runner_context]
166
- rescue JSON::ParserError
167
- []
168
- end
169
-
170
- def docker_event_status_to_event(status)
171
- case status
172
- when "create"
173
- :create
174
- when "start"
175
- :update
176
- when "die", "destroy"
177
- :delete
178
- else
179
- :unkonwn
180
- end
181
- end
182
-
183
- def inspect_container(container_id)
184
- JSON.parse(docker!("inspect", container_id).output).first
185
- rescue
186
- nil
187
- end
188
-
189
- def delete_container(container_id)
190
- docker!("rm", container_id)
191
- rescue
192
- nil
193
- end
194
-
195
- def delete_secret(secrets_file)
196
- return unless File.exist?(secrets_file)
197
-
198
- File.unlink(secrets_file)
199
- rescue
200
- nil
201
- end
202
-
203
- def create_secret(secrets)
204
- secrets_file = Tempfile.new
205
- secrets_file.write(secrets.to_json)
206
- secrets_file.close
207
- secrets_file.path
208
- end
209
-
210
- def sigterm(pid)
211
- Process.kill("TERM", pid)
212
- rescue Errno::ESRCH
213
- nil
214
- end
215
-
216
- def global_docker_options
217
- []
218
- end
219
-
220
- def docker!(*args, **kwargs)
221
- params = global_docker_options + args
222
- AwesomeSpawn.run!(self.class::DOCKER_COMMAND, :params => params, **kwargs)
223
- end
224
- end
225
- end
226
- end
227
- end
@@ -1,32 +0,0 @@
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
- # 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
- MAX_CONTAINER_NAME_SIZE = 63 - 5 - 9
13
-
14
- def container_name(image)
15
- name = image_name(image)
16
- raise ArgumentError, "Invalid docker image [#{image}]" if name.nil?
17
-
18
- # Normalize the image name to be used in the container name.
19
- # This follows RFC 1123 Label names in Kubernetes as they are the most restrictive
20
- # See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
21
- # and https://github.com/kubernetes/kubernetes/blob/952a9cb0/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L178-L184
22
- #
23
- # This does not follow the leading and trailing character restriction because we will embed it
24
- # below with a prefix and suffix that already conform to the RFC.
25
- normalized_name = name.downcase.gsub(/[^a-z0-9-]/, "-")[0, MAX_CONTAINER_NAME_SIZE]
26
-
27
- "floe-#{normalized_name}-#{SecureRandom.hex(4)}"
28
- end
29
- end
30
- end
31
- end
32
- end
@@ -1,331 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Floe
4
- class Workflow
5
- class Runner
6
- class Kubernetes < Floe::Workflow::Runner
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
13
-
14
- def initialize(options = {})
15
- require "active_support/core_ext/hash/keys"
16
- require "awesome_spawn"
17
- require "securerandom"
18
- require "base64"
19
- require "kubeclient"
20
- require "yaml"
21
-
22
- @kubeconfig_file = ENV.fetch("KUBECONFIG", nil) || options.fetch("kubeconfig", File.join(Dir.home, ".kube", "config"))
23
- @kubeconfig_context = options["kubeconfig_context"]
24
-
25
- @token = options["token"]
26
- @token ||= File.read(options["token_file"]) if options.key?("token_file")
27
- @token ||= File.read(TOKEN_FILE) if File.exist?(TOKEN_FILE)
28
-
29
- @server = options["server"]
30
- @server ||= URI::HTTPS.build(:host => ENV.fetch("KUBERNETES_SERVICE_HOST"), :port => ENV.fetch("KUBERNETES_SERVICE_PORT", 6443)) if ENV.key?("KUBERNETES_SERVICE_HOST")
31
-
32
- @ca_file = options["ca_file"]
33
- @ca_file ||= CA_CERT_FILE if File.exist?(CA_CERT_FILE)
34
-
35
- @verify_ssl = options["verify_ssl"] == "false" ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
36
-
37
- if server.nil? && token.nil? && !File.exist?(kubeconfig_file)
38
- raise ArgumentError, "Missing connections options, provide a kubeconfig file or pass server and token via --docker-runner-options"
39
- end
40
-
41
- @namespace = options.fetch("namespace", "default")
42
-
43
- @pull_policy = options["pull-policy"]
44
- @task_service_account = options["task_service_account"]
45
-
46
- super
47
- end
48
-
49
- def run_async!(resource, env = {}, secrets = {})
50
- raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
51
-
52
- image = resource.sub("docker://", "")
53
- name = container_name(image)
54
- secret = create_secret!(secrets) if secrets && !secrets.empty?
55
-
56
- runner_context = {"container_ref" => name, "container_state" => {"phase" => "Pending"}, "secrets_ref" => secret}
57
-
58
- begin
59
- create_pod!(name, image, env, secret)
60
- runner_context
61
- rescue Kubeclient::HttpError => err
62
- cleanup(runner_context)
63
- {"Error" => "States.TaskFailed", "Cause" => err.to_s}
64
- end
65
- end
66
-
67
- def status!(runner_context)
68
- return if runner_context.key?("Error")
69
-
70
- runner_context["container_state"] = pod_info(runner_context["container_ref"]).to_h.deep_stringify_keys["status"]
71
- end
72
-
73
- def running?(runner_context)
74
- return false unless pod_running?(runner_context)
75
- # If a pod is Pending and the containers are waiting with a failure
76
- # reason such as ImagePullBackOff or CrashLoopBackOff then the pod
77
- # will never be run.
78
- return false if container_failed?(runner_context)
79
-
80
- true
81
- end
82
-
83
- def success?(runner_context)
84
- runner_context.dig("container_state", "phase") == "Succeeded"
85
- end
86
-
87
- def output(runner_context)
88
- if runner_context.key?("Error")
89
- runner_context.slice("Error", "Cause")
90
- elsif container_failed?(runner_context)
91
- failed_state = failed_container_states(runner_context).first
92
- {"Error" => failed_state["reason"], "Cause" => failed_state["message"]}
93
- else
94
- runner_context["output"] = kubeclient.get_pod_log(runner_context["container_ref"], namespace).body
95
- end
96
- end
97
-
98
- def cleanup(runner_context)
99
- pod, secret = runner_context.values_at("container_ref", "secrets_ref")
100
-
101
- delete_pod(pod) if pod
102
- delete_secret(secret) if secret
103
- end
104
-
105
- def wait(timeout: nil, events: %i[create update delete])
106
- retry_connection = true
107
-
108
- begin
109
- watcher = kubeclient.watch_pods(:namespace => namespace)
110
-
111
- retry_connection = true
112
-
113
- if timeout.to_i > 0
114
- timeout_thread = Thread.new do
115
- sleep(timeout)
116
- watcher.finish
117
- end
118
- end
119
-
120
- watcher.each do |notice|
121
- break if error_notice?(notice)
122
-
123
- event = kube_notice_type_to_event(notice.type)
124
- next unless events.include?(event)
125
-
126
- runner_context = parse_notice(notice)
127
- next if runner_context.nil?
128
-
129
- if block_given?
130
- yield [event, runner_context]
131
- else
132
- timeout_thread&.kill # If we break out before the timeout, kill the timeout thread
133
- return [[event, runner_context]]
134
- end
135
- end
136
- rescue Kubeclient::HttpError => err
137
- raise unless err.error_code == 401 && retry_connection
138
-
139
- @kubeclient = nil
140
- retry_connection = false
141
- retry
142
- ensure
143
- begin
144
- watch&.finish
145
- rescue
146
- nil
147
- end
148
-
149
- timeout_thread&.join(0)
150
- end
151
- end
152
-
153
- private
154
-
155
- attr_reader :ca_file, :kubeconfig_file, :kubeconfig_context, :namespace, :server, :token, :verify_ssl
156
-
157
- def pod_info(pod_name)
158
- kubeclient.get_pod(pod_name, namespace)
159
- end
160
-
161
- def pod_running?(context)
162
- RUNNING_PHASES.include?(context.dig("container_state", "phase"))
163
- end
164
-
165
- def failed_container_states(context)
166
- container_statuses = context.dig("container_state", "containerStatuses") || []
167
- container_statuses.filter_map { |status| status["state"]&.values&.first }
168
- .select { |state| FAILURE_REASONS.include?(state["reason"]) }
169
- end
170
-
171
- def container_failed?(context)
172
- failed_container_states(context).any?
173
- end
174
-
175
- def pod_spec(name, image, env, secret = nil)
176
- spec = {
177
- :kind => "Pod",
178
- :apiVersion => "v1",
179
- :metadata => {
180
- :name => name,
181
- :namespace => namespace
182
- },
183
- :spec => {
184
- :containers => [
185
- {
186
- :name => name[0...-9], # remove the random suffix and its leading hyphen
187
- :image => image,
188
- :env => env.map { |k, v| {:name => k, :value => v.to_s} }
189
- }
190
- ],
191
- :restartPolicy => "Never"
192
- }
193
- }
194
-
195
- spec[:spec][:imagePullPolicy] = @pull_policy if @pull_policy
196
- spec[:spec][:serviceAccountName] = @task_service_account if @task_service_account
197
-
198
- if secret
199
- spec[:spec][:volumes] = [
200
- {
201
- :name => "secret-volume",
202
- :secret => {:secretName => secret}
203
- }
204
- ]
205
-
206
- spec[:spec][:containers][0][:env] << {
207
- :name => "_CREDENTIALS",
208
- :value => "/run/secrets/#{secret}/secret"
209
- }
210
-
211
- spec[:spec][:containers][0][:volumeMounts] = [
212
- {
213
- :name => "secret-volume",
214
- :mountPath => "/run/secrets/#{secret}",
215
- :readOnly => true
216
- }
217
- ]
218
- end
219
-
220
- spec
221
- end
222
-
223
- def create_pod!(name, image, env, secret = nil)
224
- kubeclient.create_pod(pod_spec(name, image, env, secret))
225
- end
226
-
227
- def delete_pod!(name)
228
- kubeclient.delete_pod(name, namespace)
229
- end
230
-
231
- def delete_pod(name)
232
- delete_pod!(name)
233
- rescue
234
- nil
235
- end
236
-
237
- def create_secret!(secrets)
238
- secret_name = SecureRandom.uuid
239
-
240
- secret_config = {
241
- :kind => "Secret",
242
- :apiVersion => "v1",
243
- :metadata => {
244
- :name => secret_name,
245
- :namespace => namespace
246
- },
247
- :data => {
248
- :secret => Base64.urlsafe_encode64(secrets.to_json)
249
- },
250
- :type => "Opaque"
251
- }
252
-
253
- kubeclient.create_secret(secret_config)
254
-
255
- secret_name
256
- end
257
-
258
- def delete_secret!(secret_name)
259
- kubeclient.delete_secret(secret_name, namespace)
260
- end
261
-
262
- def delete_secret(name)
263
- delete_secret!(name)
264
- rescue
265
- nil
266
- end
267
-
268
- def kube_notice_type_to_event(type)
269
- case type
270
- when "ADDED"
271
- :create
272
- when "MODIFIED"
273
- :update
274
- when "DELETED"
275
- :delete
276
- else
277
- :unknown
278
- end
279
- end
280
-
281
- def error_notice?(notice)
282
- return false unless notice.type == "ERROR"
283
-
284
- message = notice.object&.message
285
- code = notice.object&.code
286
- reason = notice.object&.reason
287
-
288
- logger.warn("Received [#{code} #{reason}], [#{message}]")
289
-
290
- true
291
- end
292
-
293
- def parse_notice(notice)
294
- return if notice.object.nil?
295
-
296
- pod = notice.object
297
- container_ref = pod.metadata.name
298
- container_state = pod.to_h[:status].deep_stringify_keys
299
-
300
- {"container_ref" => container_ref, "container_state" => container_state}
301
- end
302
-
303
- def kubeclient
304
- return @kubeclient unless @kubeclient.nil?
305
-
306
- if server && token
307
- api_endpoint = server
308
- auth_options = {:bearer_token => token}
309
- ssl_options = {:verify_ssl => verify_ssl}
310
- ssl_options[:ca_file] = ca_file if ca_file
311
- else
312
- context = kubeconfig&.context(kubeconfig_context)
313
- raise ArgumentError, "Missing connections options, provide a kubeconfig file or pass server and token via --docker-runner-options" if context.nil?
314
-
315
- api_endpoint = context.api_endpoint
316
- auth_options = context.auth_options
317
- ssl_options = context.ssl_options
318
- end
319
-
320
- @kubeclient = Kubeclient::Client.new(api_endpoint, "v1", :ssl_options => ssl_options, :auth_options => auth_options).tap(&:discover)
321
- end
322
-
323
- def kubeconfig
324
- return if kubeconfig_file.nil? || !File.exist?(kubeconfig_file)
325
-
326
- Kubeclient::Config.read(kubeconfig_file)
327
- end
328
- end
329
- end
330
- end
331
- end