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 +4 -4
- data/CHANGELOG.md +27 -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/non_terminal_mixin.rb +3 -1
- data/lib/floe/workflow/states/pass.rb +2 -3
- data/lib/floe/workflow/states/task.rb +2 -7
- 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: ddff07ec60a21f0d90224e992e9e08d51c420a66203daf3d2f9a46257b0df9fa
|
4
|
+
data.tar.gz: bd1dee4d43ea4e62bae056893342aec18fd3acfdcde4b916c1f8cb3f04f9607e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
-
|
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
|