floe 0.10.0 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
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