floe 0.9.0 → 0.11.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: 8c7a74a5297258d481fb588ae0fa6eb1b22b7ecf5c049865b77ad23d6fb135cb
4
- data.tar.gz: 82f73726e293e5345d3e7fa55a0049f881f2dce6e6d46570d2352968907c04b9
3
+ metadata.gz: e0c91f1170b6abe30e09d32d976efb4644fc3ac5e689274ef49a471bfb53ef8c
4
+ data.tar.gz: 48b2b6f51342ca80771f498dec85491f3c57ed4943e26cfbbce1ef1d3ade6029
5
5
  SHA512:
6
- metadata.gz: 32d58e28cd76d936f31f9af2c1091d8a7dd930e47a2197b532c02d6d48df2c82feee696072af701aeca5f2af5437040ea3bace5df622c1ad5d0e47e388884ad2
7
- data.tar.gz: 1ee0628fbfde496d00fae67812ac869582b1aca754aca7cf44caa7170edc1b0f6c9100a587770d6f6bb97bc0a948dd8fe0123fd108b9ed037ad71bc62bb8e104
6
+ metadata.gz: 594a89242eb6e27a345b3d5f49b93c6b362d0064d62c7154ce8e554e84f6f02a669b96a815959553c3eb6239453a26cc00a2d181d5cb7b508ee219e6891a156a
7
+ data.tar.gz: 519e65a57c5dd1e639705d280c89cb6868a17e05f750e7d903f8a1ae7aaae38c11839bf04fc2e7d157005fd99ca302773ddfe5cf8612c79d45efafde3de71c39
data/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ This project adheres to [Semantic Versioning](http://semver.org/).
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.11.0] - 2024-05-02
8
+ ### Fixed
9
+ - Ensure the local code is loaded in exe/floe ([#173](https://github.com/ManageIQ/floe/pull/173))
10
+ - Fix issues with exe/floe and various combinations of workflow and input ([#174](https://github.com/ManageIQ/floe/pull/174))
11
+
12
+ ### Added
13
+ - Add support for pluggable schemes ([#169](https://github.com/ManageIQ/floe/pull/169))
14
+
15
+ ### Changed
16
+ - Collapse some namespaces ([#171](https://github.com/ManageIQ/floe/pull/171))
17
+ - Pass workflow context into runner#run_async! ([#177](https://github.com/ManageIQ/floe/pull/177))
18
+
19
+ ### Removed
20
+ - Remove unused run! method ([#176](https://github.com/ManageIQ/floe/pull/176))
21
+
22
+
23
+ ## [0.10.0] - 2024-04-05
24
+ ### Fixed
25
+ - Fix rubocops ([#164](https://github.com/ManageIQ/floe/pull/164))
26
+ - Output should contain errors ([#165](https://github.com/ManageIQ/floe/pull/165))
27
+
28
+ ### Added
29
+ - Add simplecov ([#162](https://github.com/ManageIQ/floe/pull/162))
30
+ - Add ability to pass context on the command line ([#161](https://github.com/ManageIQ/floe/pull/161))
31
+ - Add specs for `Workflow#wait_until`, `#waiting?` ([#166](https://github.com/ManageIQ/floe/pull/166))
32
+
33
+ ### Changed
34
+ - Drop non-standard Error/Cause fields ([#167](https://github.com/ManageIQ/floe/pull/167))
35
+
7
36
  ## [0.9.0] - 2024-02-19
8
37
  ### Changed
9
38
  - Default to wait indefinitely ([#157](https://github.com/ManageIQ/floe/pull/157))
@@ -136,7 +165,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
136
165
  ### Added
137
166
  - Initial release
138
167
 
139
- [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.9.0...HEAD
168
+ [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.11.0...HEAD
169
+ [0.11.0]: https://github.com/ManageIQ/floe/compare/v0.10.0...v0.11.0
170
+ [0.10.0]: https://github.com/ManageIQ/floe/compare/v0.9.0...v0.10.0
140
171
  [0.9.0]: https://github.com/ManageIQ/floe/compare/v0.8.0...v0.9.0
141
172
  [0.8.0]: https://github.com/ManageIQ/floe/compare/v0.7.0...v0.8.0
142
173
  [0.7.0]: https://github.com/ManageIQ/floe/compare/v0.6.1...v0.7.0
data/Gemfile CHANGED
@@ -7,9 +7,3 @@ require File.join(Bundler::Plugin.index.load_paths("bundler-inject")[0], "bundle
7
7
 
8
8
  # Specify your gem's dependencies in floe.gemspec
9
9
  gemspec
10
-
11
- gem "manageiq-style"
12
- gem "rake", "~> 13.0"
13
- gem "rspec"
14
- gem "rubocop"
15
- gem "timecop"
data/exe/floe CHANGED
@@ -1,44 +1,52 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "floe"
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
5
6
  require "optimist"
7
+ require "floe"
8
+ require "floe/container_runner"
6
9
 
7
10
  opts = Optimist.options do
8
11
  version("v#{Floe::VERSION}\n")
9
12
  usage("[options] workflow input [workflow2 input2]")
10
13
 
11
- opt :workflow, "Path to your workflow json (legacy)", :type => :string
12
- opt :input, "JSON payload to input to the workflow (legacy)", :type => :string
13
- opt :credentials, "JSON payload with credentials", :type => :string
14
- opt :credentials_file, "Path to a file with credentials", :type => :string
15
- opt :docker_runner, "Type of runner for docker images", :type => :string, :short => 'r'
16
- opt :docker_runner_options, "Options to pass to the runner", :type => :strings, :short => 'o'
14
+ opt :workflow, "Path to your workflow json file (alternative to passing a bare workflow)", :type => :string
15
+ opt :input, <<~EOMSG, :type => :string
16
+ JSON payload of the Input to the workflow
17
+ If --input is passed and --workflow is not passed, will be used for all bare workflows listed.
18
+ If --input is not passed and --workflow is passed, defaults to '{}'.
19
+ EOMSG
20
+ opt :context, "JSON payload of the Context", :type => :string
21
+ opt :credentials, "JSON payload with Credentials", :type => :string
22
+ opt :credentials_file, "Path to a file with Credentials", :type => :string
17
23
 
18
- opt :docker, "Use docker to run images (short for --docker_runner=docker)", :type => :boolean
19
- opt :podman, "Use podman to run images (short for --docker_runner=podman)", :type => :boolean
20
- opt :kubernetes, "Use kubernetes to run images (short for --docker_runner=kubernetes)", :type => :boolean
24
+ Floe::ContainerRunner.cli_options(self)
25
+
26
+ banner("")
27
+ banner("General options:")
21
28
  end
22
29
 
23
- # legacy support for --workflow
24
- args = ARGV.empty? ? [opts[:workflow], opts[:input]] : ARGV
25
- Optimist.die(:workflow, "must be specified") if args.empty?
30
+ # Create workflow/input pairs from the various combinations of paramaters
31
+ args =
32
+ if opts[:workflow_given]
33
+ Optimist.die("cannot specify both --workflow and bare workflows") if ARGV.any?
26
34
 
27
- # shortcut support
28
- opts[:docker_runner] ||= "docker" if opts[:docker]
29
- opts[:docker_runner] ||= "podman" if opts[:podman]
30
- opts[:docker_runner] ||= "kubernetes" if opts[:kubernetes]
35
+ [opts[:workflow], opts.fetch(:input, "{}")]
36
+ elsif opts[:input_given]
37
+ Optimist.die("workflow(s) must be specified") if ARGV.empty?
31
38
 
32
- require "logger"
33
- Floe.logger = Logger.new($stdout)
39
+ ARGV.flat_map { |w| [w, opts[:input].dup] }
40
+ else
41
+ Optimist.die("workflow/input pairs must be specified") if ARGV.empty? || (ARGV.size > 1 && ARGV.size.odd?)
34
42
 
35
- runner_options = opts[:docker_runner_options].to_h { |opt| opt.split("=", 2) }
43
+ ARGV
44
+ end
36
45
 
37
- begin
38
- Floe.set_runner("docker", opts[:docker_runner], runner_options)
39
- rescue ArgumentError => e
40
- Optimist.die(:docker_runner, e.message)
41
- end
46
+ Floe::ContainerRunner.resolve_cli_options!(opts)
47
+
48
+ require "logger"
49
+ Floe.logger = Logger.new($stdout)
42
50
 
43
51
  credentials =
44
52
  if opts[:credentials_given]
@@ -49,7 +57,7 @@ credentials =
49
57
 
50
58
  workflows =
51
59
  args.each_slice(2).map do |workflow, input|
52
- context = Floe::Workflow::Context.new(:input => input || opts[:input] || "{}")
60
+ context = Floe::Workflow::Context.new(opts[:context], :input => input)
53
61
  Floe::Workflow.load(workflow, context, credentials)
54
62
  end
55
63
 
data/floe.gemspec CHANGED
@@ -34,4 +34,11 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency "jsonpath", "~>1.1"
35
35
  spec.add_dependency "kubeclient", "~>4.7"
36
36
  spec.add_dependency "optimist", "~>3.0"
37
+
38
+ spec.add_development_dependency "manageiq-style"
39
+ spec.add_development_dependency "rake", "~> 13.0"
40
+ spec.add_development_dependency "rspec"
41
+ spec.add_development_dependency "rubocop"
42
+ spec.add_development_dependency "simplecov", ">= 0.21.2"
43
+ spec.add_development_dependency "timecop"
37
44
  end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class ContainerRunner
5
+ class Docker < Floe::Runner
6
+ include Floe::ContainerRunner::DockerMixin
7
+
8
+ DOCKER_COMMAND = "docker"
9
+
10
+ def initialize(options = {})
11
+ require "awesome_spawn"
12
+ require "io/wait"
13
+ require "tempfile"
14
+
15
+ super
16
+
17
+ @network = options.fetch("network", "bridge")
18
+ @pull_policy = options["pull-policy"]
19
+ end
20
+
21
+ def run_async!(resource, env = {}, secrets = {}, _context = {})
22
+ raise ArgumentError, "Invalid resource" unless resource&.start_with?("docker://")
23
+
24
+ image = resource.sub("docker://", "")
25
+
26
+ runner_context = {}
27
+
28
+ if secrets && !secrets.empty?
29
+ runner_context["secrets_ref"] = create_secret(secrets)
30
+ end
31
+
32
+ begin
33
+ runner_context["container_ref"] = run_container(image, env, runner_context["secrets_ref"])
34
+ runner_context
35
+ rescue AwesomeSpawn::CommandResultError => err
36
+ cleanup(runner_context)
37
+ {"Error" => "States.TaskFailed", "Cause" => err.to_s}
38
+ end
39
+ end
40
+
41
+ def cleanup(runner_context)
42
+ container_id, secrets_file = runner_context.values_at("container_ref", "secrets_ref")
43
+
44
+ delete_container(container_id) if container_id
45
+ delete_secret(secrets_file) if secrets_file
46
+ end
47
+
48
+ def wait(timeout: nil, events: %i[create update delete], &block)
49
+ until_timestamp = Time.now.utc + timeout if timeout
50
+
51
+ r, w = IO.pipe
52
+
53
+ pid = AwesomeSpawn.run_detached(
54
+ self.class::DOCKER_COMMAND, :err => :out, :out => w, :params => wait_params(until_timestamp)
55
+ )
56
+
57
+ w.close
58
+
59
+ loop do
60
+ readable_timeout = until_timestamp - Time.now.utc if until_timestamp
61
+
62
+ # Wait for our end of the pipe to be readable and if it didn't timeout
63
+ # get the events from stdout
64
+ next if r.wait_readable(readable_timeout).nil?
65
+
66
+ # Get all events while the pipe is readable
67
+ notices = []
68
+ while r.ready?
69
+ notice = r.gets
70
+
71
+ # If the process has exited `r.gets` returns `nil` and the pipe is
72
+ # always `ready?`
73
+ break if notice.nil?
74
+
75
+ event, runner_context = parse_notice(notice)
76
+ next if event.nil? || !events.include?(event)
77
+
78
+ notices << [event, runner_context]
79
+ end
80
+
81
+ # If we're given a block yield the events otherwise return them
82
+ if block
83
+ notices.each(&block)
84
+ else
85
+ # Terminate the `docker events` process before returning the events
86
+ sigterm(pid)
87
+
88
+ return notices
89
+ end
90
+
91
+ # Check that the `docker events` process is still alive
92
+ Process.kill(0, pid)
93
+ rescue Errno::ESRCH
94
+ # Break out of the loop if the `docker events` process has exited
95
+ break
96
+ end
97
+ ensure
98
+ r.close
99
+ end
100
+
101
+ def status!(runner_context)
102
+ return if runner_context.key?("Error")
103
+
104
+ runner_context["container_state"] = inspect_container(runner_context["container_ref"])&.dig("State")
105
+ end
106
+
107
+ def running?(runner_context)
108
+ !!runner_context.dig("container_state", "Running")
109
+ end
110
+
111
+ def success?(runner_context)
112
+ runner_context.dig("container_state", "ExitCode") == 0
113
+ end
114
+
115
+ def output(runner_context)
116
+ return runner_context.slice("Error", "Cause") if runner_context.key?("Error")
117
+
118
+ output = docker!("logs", runner_context["container_ref"], :combined_output => true).output
119
+ runner_context["output"] = output
120
+ end
121
+
122
+ private
123
+
124
+ attr_reader :network
125
+
126
+ def run_container(image, env, secrets_file)
127
+ params = run_container_params(image, env, secrets_file)
128
+
129
+ logger.debug("Running #{AwesomeSpawn.build_command_line(self.class::DOCKER_COMMAND, params)}")
130
+
131
+ result = docker!(*params)
132
+ result.output
133
+ end
134
+
135
+ def run_container_params(image, env, secrets_file)
136
+ params = ["run"]
137
+ params << :detach
138
+ params += env.map { |k, v| [:e, "#{k}=#{v}"] }
139
+ params << [:e, "_CREDENTIALS=/run/secrets"] if secrets_file
140
+ params << [:pull, @pull_policy] if @pull_policy
141
+ params << [:net, "host"] if @network == "host"
142
+ params << [:v, "#{secrets_file}:/run/secrets:z"] if secrets_file
143
+ params << [:name, container_name(image)]
144
+ params << image
145
+ end
146
+
147
+ def wait_params(until_timestamp)
148
+ params = ["events", [:format, "{{json .}}"], [:filter, "type=container"], [:since, Time.now.utc.to_i]]
149
+ params << [:until, until_timestamp.to_i] if until_timestamp
150
+ params
151
+ end
152
+
153
+ def parse_notice(notice)
154
+ notice = JSON.parse(notice)
155
+
156
+ status = notice["status"]
157
+ event = docker_event_status_to_event(status)
158
+ running = event != :delete
159
+
160
+ name, exit_code = notice.dig("Actor", "Attributes")&.values_at("name", "exitCode")
161
+
162
+ runner_context = {"container_ref" => name, "container_state" => {"Running" => running, "ExitCode" => exit_code.to_i}}
163
+
164
+ [event, runner_context]
165
+ rescue JSON::ParserError
166
+ []
167
+ end
168
+
169
+ def docker_event_status_to_event(status)
170
+ case status
171
+ when "create"
172
+ :create
173
+ when "start"
174
+ :update
175
+ when "die", "destroy"
176
+ :delete
177
+ else
178
+ :unkonwn
179
+ end
180
+ end
181
+
182
+ def inspect_container(container_id)
183
+ JSON.parse(docker!("inspect", container_id).output).first
184
+ rescue
185
+ nil
186
+ end
187
+
188
+ def delete_container(container_id)
189
+ docker!("rm", container_id)
190
+ rescue
191
+ nil
192
+ end
193
+
194
+ def delete_secret(secrets_file)
195
+ return unless File.exist?(secrets_file)
196
+
197
+ File.unlink(secrets_file)
198
+ rescue
199
+ nil
200
+ end
201
+
202
+ def create_secret(secrets)
203
+ secrets_file = Tempfile.new
204
+ secrets_file.write(secrets.to_json)
205
+ secrets_file.close
206
+ secrets_file.path
207
+ end
208
+
209
+ def sigterm(pid)
210
+ Process.kill("TERM", pid)
211
+ rescue Errno::ESRCH
212
+ nil
213
+ end
214
+
215
+ def global_docker_options
216
+ []
217
+ end
218
+
219
+ def docker!(*args, **kwargs)
220
+ params = global_docker_options + args
221
+ AwesomeSpawn.run!(self.class::DOCKER_COMMAND, :params => params, **kwargs)
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class ContainerRunner
5
+ module DockerMixin
6
+ def image_name(image)
7
+ image.match(%r{^(?<repository>.+/)?(?<image>.+):(?<tag>.+)$})&.named_captures&.dig("image")
8
+ end
9
+
10
+ # 63 is the max kubernetes pod name length
11
+ # -5 for the "floe-" prefix
12
+ # -9 for the random hex suffix and leading hyphen
13
+ MAX_CONTAINER_NAME_SIZE = 63 - 5 - 9
14
+
15
+ def container_name(image)
16
+ name = image_name(image)
17
+ raise ArgumentError, "Invalid docker image [#{image}]" if name.nil?
18
+
19
+ # Normalize the image name to be used in the container name.
20
+ # This follows RFC 1123 Label names in Kubernetes as they are the most restrictive
21
+ # See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
22
+ # and https://github.com/kubernetes/kubernetes/blob/952a9cb0/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L178-L184
23
+ #
24
+ # This does not follow the leading and trailing character restriction because we will embed it
25
+ # below with a prefix and suffix that already conform to the RFC.
26
+ normalized_name = name.downcase.gsub(/[^a-z0-9-]/, "-")[0, MAX_CONTAINER_NAME_SIZE]
27
+
28
+ "floe-#{normalized_name}-#{SecureRandom.hex(4)}"
29
+ end
30
+ end
31
+ end
32
+ end