require "docker"
require "crystar"
require "./logger"
struct Command
image : String
name : String
env : Hash(String, String)
commands : Array(String)
cached : Array(String)
shell : String
workdir : String
artifacts_to_set : Hash(String, String)
artifacts_to_get : Hash(String, String)
def initialize(
@image : String,
@name : String,
@env = {} of String => String,
@commands = [] of String,
@cached = [] of String,
@shell = "sh",
@workdir = ".",
@artifacts_to_set = {} of String => String,
@artifacts_to_get = {} of String => String,
)
log_pipe "Command initialized:\n- image: #{@image}\n- name: #{@name}\n- env: #{@env}\n- commands: #{@commands}\n- cached: #{@cached}\n- shell: #{@shell}\n- workdir: #{@workdir}\n- artifacts_to_set: #{@artifacts_to_set}\n- artifacts_to_get: #{@artifacts_to_get}"
end
def run(
docker : Docker::Client,
project : Project,
pipeline : Pipeline,
stage : Stage,
)
exit_code = 0
logs = ""
binds = [] of String
container = nil
env_array = ["CI=1"] of String
if @env.size > 0
begin
@env.each do |k, v|
val = v
if val.size > 0 && val[0] == '$'
key = val[1..-1]?
val = key ? ENV[key]? || "" : ""
end
env_array << "#{k}=#{val}"
end
log_pipe "Env. vars set up"
rescue ex : Exception
err_pipe "Failure: env_array", ex
exit 1
end
end
begin
workdir_local = @workdir
idx = workdir_local.index(":")
if idx && idx > 0
host_path = File.realpath(workdir_local[0...idx])
workdir_local = workdir_local[(idx + 1)..-1]?
if workdir_local
binds << "#{host_path}:#{workdir_local}"
end
end
log_pipe "Workdir bind set up"
rescue ex : Exception
err_pipe "Failure: workdir.binds", ex
exit 1
end
if @cached.size > 0
begin
log_pipe "Cache volumes parsing ..."
@cached.each do |path|
vid = "cache_volume"
volumes = docker.volumes.list(filters: { "name" => [vid] }) rescue [] of Docker::Api::Models::Volume
if volumes.size == 0
docker.volumes.create(name: vid)
end
log_pipe "Mounted volume: #{vid}:#{path}"
binds << "#{vid}:#{path}"
end
log_pipe "Cache volumes set up"
rescue ex : Exception
err_pipe "Failure: cache volumes", ex
exit 1
end
end
begin
log_pipe "Pulling image #{@image} ..."
docker.images.pull(@image)
log_pipe "Pulled image #{@image}"
rescue ex : Exception
err_pipe "Failure: docker.images.pull: cannot pull #{@image}", ex
exit 1
end
begin
log_pipe "Creating container ..."
container = docker.containers.create(
image: @image,
name: @name,
host_config: {
binds: binds
},
tty: true,
attach_stdout: true,
attach_stderr: true,
working_dir: workdir_local,
cmd: ["/tmp/script.sh"],
env: env_array,
)
log_pipe "Container created: #{container.id}"
rescue ex : Exception
err_pipe "Failure: docker.containers.create", ex
exit 1
end
begin
tar_bytes = create_commands_tar
container.archive.put("/tmp", tar_bytes)
log_pipe "Written pipeline script in container: /tmp/script.sh"
rescue ex : Exception
err_pipe "Failure: container.archive.put", ex
cleanup_container container
exit 1
end
begin
container.reload!
workdir_actual = container.attrs.config.working_dir
log_pipe "Changed workdir to: #{workdir_actual}"
rescue ex : Exception
err_pipe "Failure: container.reload", ex
cleanup_container container
exit 1
end
if @artifacts_to_get.size > 0
begin
log_pipe "Getting artifacts ..."
@artifacts_to_get.each do |name, path|
data = pipeline.get_artifact(name)
if data.nil?
raise "Failure: pipeline.get_artifact: #{name} not found"
end
dest = path
dest = "#{workdir_actual}/#{path}" if path[0]? != '/'
container.archive.put(dest, data)
end
log_pipe "Artifacts retrieved!"
rescue ex : Exception
err_pipe "Failure: container.archive.put", ex
cleanup_container container
exit 1
end
end
begin
log_pipe "Starting container ..."
container.start
log_pipe "Container started: #{container.id}"
rescue ex : Exception
log_pipe "Failed to start container: #{ex.message}"
cleanup_container container
exit 1
end
begin
log_pipe "Waiting for container to exit ..."
exit_code = container.wait
log_pipe "Container exited: #{exit_code} (#{container.id})"
rescue ex : Exception
log_pipe "Failed to wait for container: #{ex.message}"
cleanup_container container
exit 1
end
begin
log_pipe "Fetching container logs ..."
logs = container.logs
log_pipe "Container logs:\n#{logs}"
rescue ex : Exception
log_pipe "Failed to fetch container logs: #{ex.message}"
cleanup_container container
exit 1
end
if exit_code != 0
cleanup_container container
raise Exception.new("Stage #{stage.name} didn't complete.")
end
if @artifacts_to_set.size > 0
begin
@artifacts_to_set.each do |artifact_name, path|
tar_bytes_from_container = container.archive.get(path)
pipeline.set_artifact(
artifact_name,
tar_bytes_from_container
)
end
log_pipe "Artifacts set: #{@artifacts_to_set.size}"
rescue ex : Exception
err_pipe "Failed to set artifacts:", ex
cleanup_container container
exit 1
end
end
cleanup_container container
exit_code
end
def cleanup_container(container : Docker::Container)
begin
container.stop rescue nil
log_pipe "Stopped container: #{container.id}"
ensure
container.remove(v: true, force: true) rescue nil
log_pipe "Removed container: #{container.id}"
end
end
def create_commands_tar : Bytes
buf = IO::Memory.new
Crystar::Writer.open(buf) do |tw|
script = create_script
hdr = Crystar::Header.new(
name: "script.sh",
size: script.size.to_i64,
mode: 0o644 | 0o111,
format: Crystar::Format::USTAR,
)
tw.write_header(hdr)
tw.write(script.to_slice)
end
buf.to_s.to_slice
end
def create_script : Bytes
sh = @shell.size > 0 ? @shell : "sh"
sb = String.build do |io|
io << "#! /bin/#{sh}\n"
io << "set -e\n"
@commands.each do |c|
io << c
io << "\n"
end
end
sb.to_slice
end
def to_s : String
s = "Command:#{@image}:#{@shell}:cmds["
@commands.each_with_index do |c, i|
s << (i > 0 ? "," : "")
s << c
end
s << "]"
unless @env.empty?
s << ":env["
i = 0
@env.each do |k, v|
s << (i > 0 ? "," : "")
i += 1
s << "#{k}=#{v}"
end
s << "]"
end
unless @cached.empty?
s << ":cached["
@cached.each_with_index do |c, i|
s << (i > 0 ? "," : "")
s << c
end
s << "]"
end
s
end
end