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 +4 -4
- data/CHANGELOG.md +18 -1
- data/exe/floe +35 -28
- data/lib/floe/container_runner/docker.rb +225 -0
- data/lib/floe/container_runner/docker_mixin.rb +32 -0
- data/lib/floe/container_runner/kubernetes.rb +329 -0
- data/lib/floe/container_runner/podman.rb +104 -0
- data/lib/floe/container_runner.rb +61 -0
- data/lib/floe/runner.rb +82 -0
- data/lib/floe/version.rb +1 -1
- data/lib/floe/workflow/context.rb +3 -1
- data/lib/floe/workflow/states/task.rb +2 -2
- data/lib/floe.rb +2 -18
- metadata +8 -7
- data/lib/floe/workflow/runner/docker.rb +0 -227
- data/lib/floe/workflow/runner/docker_mixin.rb +0 -32
- data/lib/floe/workflow/runner/kubernetes.rb +0 -331
- data/lib/floe/workflow/runner/podman.rb +0 -106
- data/lib/floe/workflow/runner.rb +0 -77
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e0c91f1170b6abe30e09d32d976efb4644fc3ac5e689274ef49a471bfb53ef8c
|
4
|
+
data.tar.gz: 48b2b6f51342ca80771f498dec85491f3c57ed4943e26cfbbce1ef1d3ade6029
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
-
|
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 (
|
12
|
-
opt :input,
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
opt :
|
18
|
-
|
19
|
-
opt :
|
20
|
-
|
21
|
-
|
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
|
-
#
|
25
|
-
args =
|
26
|
-
|
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
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
34
|
-
|
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
|
-
|
43
|
+
ARGV
|
44
|
+
end
|
37
45
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
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
|