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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b158e514e08902a1138c7b2878bb8633ca7c1abb59d4b77b6d9ce568c65710b
4
- data.tar.gz: 32d053bba54e8c35645a636771964692e435ad72ee38e03c8c22b4da5bce25ec
3
+ metadata.gz: e0c91f1170b6abe30e09d32d976efb4644fc3ac5e689274ef49a471bfb53ef8c
4
+ data.tar.gz: 48b2b6f51342ca80771f498dec85491f3c57ed4943e26cfbbce1ef1d3ade6029
5
5
  SHA512:
6
- metadata.gz: e39301eed1de9189b66f07a7ecdda807974bf47495b4906bd57f6f8ed862fab38448668155f79e12c87da8935f4054d14dbcd08e292429b194768d2234fa7c46
7
- data.tar.gz: 5cf0476521a1cd4fc0dcc4edeccdd00c6c72ee88bf7428103b86f5f30097608f934f19e75499a61b5d7e7380f2e1451dccfd82f796eee6bcef041dd6f3db8664
6
+ metadata.gz: 594a89242eb6e27a345b3d5f49b93c6b362d0064d62c7154ce8e554e84f6f02a669b96a815959553c3eb6239453a26cc00a2d181d5cb7b508ee219e6891a156a
7
+ data.tar.gz: 519e65a57c5dd1e639705d280c89cb6868a17e05f750e7d903f8a1ae7aaae38c11839bf04fc2e7d157005fd99ca302773ddfe5cf8612c79d45efafde3de71c39
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ 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
+
7
23
  ## [0.10.0] - 2024-04-05
8
24
  ### Fixed
9
25
  - Fix rubocops ([#164](https://github.com/ManageIQ/floe/pull/164))
@@ -149,7 +165,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
149
165
  ### Added
150
166
  - Initial release
151
167
 
152
- [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.10.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
153
170
  [0.10.0]: https://github.com/ManageIQ/floe/compare/v0.9.0...v0.10.0
154
171
  [0.9.0]: https://github.com/ManageIQ/floe/compare/v0.8.0...v0.9.0
155
172
  [0.8.0]: https://github.com/ManageIQ/floe/compare/v0.7.0...v0.8.0
data/exe/floe CHANGED
@@ -1,45 +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 :context, "JSON payload of the Context", :type => :string
14
- opt :credentials, "JSON payload with credentials", :type => :string
15
- opt :credentials_file, "Path to a file with credentials", :type => :string
16
- opt :docker_runner, "Type of runner for docker images", :type => :string, :short => 'r'
17
- opt :docker_runner_options, "Options to pass to the runner", :type => :strings, :short => 'o'
18
-
19
- opt :docker, "Use docker to run images (short for --docker_runner=docker)", :type => :boolean
20
- opt :podman, "Use podman to run images (short for --docker_runner=podman)", :type => :boolean
21
- opt :kubernetes, "Use kubernetes to run images (short for --docker_runner=kubernetes)", :type => :boolean
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
23
+
24
+ Floe::ContainerRunner.cli_options(self)
25
+
26
+ banner("")
27
+ banner("General options:")
22
28
  end
23
29
 
24
- # legacy support for --workflow
25
- args = ARGV.empty? ? [opts[:workflow], opts[:input]] : ARGV
26
- 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?
27
34
 
28
- # shortcut support
29
- opts[:docker_runner] ||= "docker" if opts[:docker]
30
- opts[:docker_runner] ||= "podman" if opts[:podman]
31
- 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?
32
38
 
33
- require "logger"
34
- 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?)
35
42
 
36
- runner_options = opts[:docker_runner_options].to_h { |opt| opt.split("=", 2) }
43
+ ARGV
44
+ end
37
45
 
38
- begin
39
- Floe.set_runner("docker", opts[:docker_runner], runner_options)
40
- rescue ArgumentError => e
41
- Optimist.die(:docker_runner, e.message)
42
- end
46
+ Floe::ContainerRunner.resolve_cli_options!(opts)
47
+
48
+ require "logger"
49
+ Floe.logger = Logger.new($stdout)
43
50
 
44
51
  credentials =
45
52
  if opts[:credentials_given]
@@ -50,7 +57,7 @@ credentials =
50
57
 
51
58
  workflows =
52
59
  args.each_slice(2).map do |workflow, input|
53
- context = Floe::Workflow::Context.new(opts[:context], :input => input || opts[:input] || "{}")
60
+ context = Floe::Workflow::Context.new(opts[:context], :input => input)
54
61
  Floe::Workflow.load(workflow, context, credentials)
55
62
  end
56
63
 
@@ -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