floe 0.10.0 → 0.11.1

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: 2b158e514e08902a1138c7b2878bb8633ca7c1abb59d4b77b6d9ce568c65710b
4
- data.tar.gz: 32d053bba54e8c35645a636771964692e435ad72ee38e03c8c22b4da5bce25ec
3
+ metadata.gz: ddff07ec60a21f0d90224e992e9e08d51c420a66203daf3d2f9a46257b0df9fa
4
+ data.tar.gz: bd1dee4d43ea4e62bae056893342aec18fd3acfdcde4b916c1f8cb3f04f9607e
5
5
  SHA512:
6
- metadata.gz: e39301eed1de9189b66f07a7ecdda807974bf47495b4906bd57f6f8ed862fab38448668155f79e12c87da8935f4054d14dbcd08e292429b194768d2234fa7c46
7
- data.tar.gz: 5cf0476521a1cd4fc0dcc4edeccdd00c6c72ee88bf7428103b86f5f30097608f934f19e75499a61b5d7e7380f2e1451dccfd82f796eee6bcef041dd6f3db8664
6
+ metadata.gz: 54c7fc79d150ff6801ab36b686fea09543902ee8a76b2fb787a5e038bc2dfef6c55a33f628f4abb888c057db73ab51613aea545a643a64adbaffe6895bc1e8cb
7
+ data.tar.gz: 3b73fadfc840a7f69d923116005f174a07d7e8f9ddd878e852c979563bcd7b63ff1acc7461c9222c48bd598e169fe010a786f970c37029d1225c4fe78118a624
data/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ This project adheres to [Semantic Versioning](http://semver.org/).
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.11.1] - 2024-05-20
8
+ ### Fixed
9
+ - Fix issue where a failed state can leave a workflow in "running" ([#182](https://github.com/ManageIQ/floe/pull/182))
10
+
11
+ ### Changed
12
+ - Drop unused Task#status ([#180](https://github.com/ManageIQ/floe/pull/180))
13
+ - Check task failed? in non_terminal_mixin ([#183](https://github.com/ManageIQ/floe/pull/183))
14
+
15
+ ## [0.11.0] - 2024-05-02
16
+ ### Fixed
17
+ - Ensure the local code is loaded in exe/floe ([#173](https://github.com/ManageIQ/floe/pull/173))
18
+ - Fix issues with exe/floe and various combinations of workflow and input ([#174](https://github.com/ManageIQ/floe/pull/174))
19
+
20
+ ### Added
21
+ - Add support for pluggable schemes ([#169](https://github.com/ManageIQ/floe/pull/169))
22
+
23
+ ### Changed
24
+ - Collapse some namespaces ([#171](https://github.com/ManageIQ/floe/pull/171))
25
+ - Pass workflow context into runner#run_async! ([#177](https://github.com/ManageIQ/floe/pull/177))
26
+
27
+ ### Removed
28
+ - Remove unused run! method ([#176](https://github.com/ManageIQ/floe/pull/176))
29
+
30
+
7
31
  ## [0.10.0] - 2024-04-05
8
32
  ### Fixed
9
33
  - Fix rubocops ([#164](https://github.com/ManageIQ/floe/pull/164))
@@ -149,7 +173,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
149
173
  ### Added
150
174
  - Initial release
151
175
 
152
- [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.10.0...HEAD
176
+ [Unreleased]: https://github.com/ManageIQ/floe/compare/v0.11.1...HEAD
177
+ [0.11.1]: https://github.com/ManageIQ/floe/compare/v0.11.0...v0.11.1
178
+ [0.11.0]: https://github.com/ManageIQ/floe/compare/v0.10.0...v0.11.0
153
179
  [0.10.0]: https://github.com/ManageIQ/floe/compare/v0.9.0...v0.10.0
154
180
  [0.9.0]: https://github.com/ManageIQ/floe/compare/v0.8.0...v0.9.0
155
181
  [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