centurion 1.0.6
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +10 -0
- data/CONTRIBUTORS.md +42 -0
- data/Gemfile +6 -0
- data/LICENSE +19 -0
- data/README.md +239 -0
- data/Rakefile +15 -0
- data/bin/centurion +70 -0
- data/bin/centurionize +60 -0
- data/centurion.gemspec +40 -0
- data/lib/capistrano_dsl.rb +91 -0
- data/lib/centurion.rb +5 -0
- data/lib/centurion/deploy.rb +145 -0
- data/lib/centurion/deploy_dsl.rb +94 -0
- data/lib/centurion/docker_registry.rb +35 -0
- data/lib/centurion/docker_server.rb +58 -0
- data/lib/centurion/docker_server_group.rb +31 -0
- data/lib/centurion/docker_via_api.rb +121 -0
- data/lib/centurion/docker_via_cli.rb +71 -0
- data/lib/centurion/logging.rb +28 -0
- data/lib/centurion/version.rb +3 -0
- data/lib/tasks/deploy.rake +177 -0
- data/lib/tasks/info.rake +24 -0
- data/lib/tasks/list.rake +52 -0
- data/spec/capistrano_dsl_spec.rb +67 -0
- data/spec/deploy_dsl_spec.rb +104 -0
- data/spec/deploy_spec.rb +220 -0
- data/spec/docker_server_group_spec.rb +31 -0
- data/spec/docker_server_spec.rb +43 -0
- data/spec/docker_via_api_spec.rb +111 -0
- data/spec/docker_via_cli_spec.rb +42 -0
- data/spec/logging_spec.rb +41 -0
- data/spec/spec_helper.rb +7 -0
- data/spec/support/matchers/capistrano_dsl_matchers.rb +13 -0
- metadata +243 -0
data/centurion.gemspec
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'centurion/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'centurion'
|
8
|
+
spec.version = Centurion::VERSION
|
9
|
+
spec.authors = [
|
10
|
+
'Nic Benders', 'Karl Matthias', 'Andrew Bloomgarden', 'Aaron Bento',
|
11
|
+
'Paul Showalter', 'David Kerr', 'Jonathan Owens', 'Jon Guymon',
|
12
|
+
'Merlyn Albery-Speyer', 'Amjith Ramanujam', 'David Celis', 'Emily Hyland',
|
13
|
+
'Bryan Stearns']
|
14
|
+
spec.email = [
|
15
|
+
'nic@newrelic.com', 'kmatthias@newrelic.com', 'andrew@newrelic.com',
|
16
|
+
'aaron@newrelic.com', 'poeslacker@gmail.com', 'dkerr@newrelic.com',
|
17
|
+
'jonathan@newrelic.com', 'jon@newrelic.com', 'merlyn@newrelic.com',
|
18
|
+
'amjith@newrelic.com', 'dcelis@newrelic.com', 'ehyland@newrelic.com',
|
19
|
+
'bryan@newrelic.com']
|
20
|
+
spec.summary = %q{Deploy images to a fleet of Docker servers}
|
21
|
+
spec.homepage = 'https://github.com/newrelic/centurion'
|
22
|
+
spec.license = 'MIT'
|
23
|
+
|
24
|
+
spec.files = `git ls-files -z`.split("\x0")
|
25
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
26
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
27
|
+
spec.require_paths = ['lib']
|
28
|
+
|
29
|
+
spec.add_dependency 'trollop'
|
30
|
+
spec.add_dependency 'excon', '~> 0.33'
|
31
|
+
spec.add_dependency 'logger-colors'
|
32
|
+
|
33
|
+
spec.add_development_dependency 'bundler'
|
34
|
+
spec.add_development_dependency 'rake'
|
35
|
+
spec.add_development_dependency 'rspec'
|
36
|
+
spec.add_development_dependency 'pry'
|
37
|
+
spec.add_development_dependency 'simplecov'
|
38
|
+
|
39
|
+
spec.required_ruby_version = '>= 1.9.3'
|
40
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# This file borrows heavily from the Capistrano project, so although much of
|
2
|
+
# the code has been re-written at this point, we include their license here
|
3
|
+
# as a reminder. NOTE that THIS LICENSE ONLY APPLIES TO THIS FILE itself, not
|
4
|
+
# to the rest of the project.
|
5
|
+
#
|
6
|
+
# ORIGINAL CAPISTRANO LICENSE FOLLOWS:
|
7
|
+
#
|
8
|
+
# MIT License (MIT)
|
9
|
+
#
|
10
|
+
# Copyright (c) 2012-2013 Tom Clements, Lee Hambley
|
11
|
+
#
|
12
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
13
|
+
# of this software and associated documentation files (the "Software"), to deal
|
14
|
+
# in the Software without restriction, including without limitation the rights
|
15
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
16
|
+
# copies of the Software, and to permit persons to whom the Software is
|
17
|
+
# furnished to do so, subject to the following conditions:
|
18
|
+
#
|
19
|
+
# The above copyright notice and this permission notice shall be included in
|
20
|
+
# all copies or substantial portions of the Software.
|
21
|
+
#
|
22
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
23
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
24
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
25
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
26
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
27
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
28
|
+
# THE SOFTWARE.
|
29
|
+
|
30
|
+
require 'singleton'
|
31
|
+
|
32
|
+
module Capistrano
|
33
|
+
module DSL
|
34
|
+
module Env
|
35
|
+
class CurrentEnvironmentNotSetError < RuntimeError; end
|
36
|
+
|
37
|
+
class Store < Hash
|
38
|
+
include Singleton
|
39
|
+
end
|
40
|
+
|
41
|
+
def env
|
42
|
+
Store.instance
|
43
|
+
end
|
44
|
+
|
45
|
+
def fetch(key, default=nil, &block)
|
46
|
+
env[current_environment][key] || default
|
47
|
+
end
|
48
|
+
|
49
|
+
def any?(key)
|
50
|
+
value = fetch(key)
|
51
|
+
if value && value.respond_to?(:any?)
|
52
|
+
value.any?
|
53
|
+
else
|
54
|
+
!fetch(key).nil?
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def set(key, value)
|
59
|
+
env[current_environment][key] = value
|
60
|
+
end
|
61
|
+
|
62
|
+
def delete(key)
|
63
|
+
env[current_environment].delete(key)
|
64
|
+
end
|
65
|
+
|
66
|
+
def set_current_environment(environment)
|
67
|
+
env[:current_environment] = environment
|
68
|
+
env[environment] ||= {}
|
69
|
+
end
|
70
|
+
|
71
|
+
def current_environment
|
72
|
+
raise CurrentEnvironmentNotSetError.new('Must set current environment') unless env[:current_environment]
|
73
|
+
env[:current_environment]
|
74
|
+
end
|
75
|
+
|
76
|
+
def clear_env
|
77
|
+
env.clear
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
module Capistrano
|
84
|
+
module DSL
|
85
|
+
include Env
|
86
|
+
|
87
|
+
def invoke(task, *args)
|
88
|
+
Rake::Task[task].invoke(*args)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
data/lib/centurion.rb
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
require 'excon'
|
2
|
+
|
3
|
+
module Centurion; end
|
4
|
+
|
5
|
+
module Centurion::Deploy
|
6
|
+
FAILED_CONTAINER_VALIDATION = 100
|
7
|
+
|
8
|
+
def stop_containers(target_server, port_bindings)
|
9
|
+
public_port = public_port_for(port_bindings)
|
10
|
+
old_containers = target_server.find_containers_by_public_port(public_port)
|
11
|
+
info "Stopping container(s): #{old_containers.inspect}"
|
12
|
+
|
13
|
+
old_containers.each do |old_container|
|
14
|
+
info "Stopping old container #{old_container['Id'][0..7]} (#{old_container['Names'].join(',')})"
|
15
|
+
target_server.stop_container(old_container['Id'])
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def wait_for_http_status_ok(target_server, port, endpoint, image_id, tag, sleep_time=5, retries=12)
|
20
|
+
info 'Waiting for the port to come up'
|
21
|
+
1.upto(retries) do
|
22
|
+
if container_up?(target_server, port) && http_status_ok?(target_server, port, endpoint)
|
23
|
+
info 'Container is up!'
|
24
|
+
break
|
25
|
+
end
|
26
|
+
|
27
|
+
info "Waiting #{sleep_time} seconds to test the #{endpoint} endpoint..."
|
28
|
+
sleep(sleep_time)
|
29
|
+
end
|
30
|
+
|
31
|
+
unless http_status_ok?(target_server, port, endpoint)
|
32
|
+
error "Failed to validate started container on #{target_server}:#{port}"
|
33
|
+
exit(FAILED_CONTAINER_VALIDATION)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def container_up?(target_server, port)
|
38
|
+
# The API returns a record set like this:
|
39
|
+
#[{"Command"=>"script/run ", "Created"=>1394470428, "Id"=>"41a68bda6eb0a5bb78bbde19363e543f9c4f0e845a3eb130a6253972051bffb0", "Image"=>"quay.io/newrelic/rubicon:5f23ac3fad7979cd1efdc9295e0d8c5707d1c806", "Names"=>["/happy_pike"], "Ports"=>[{"IP"=>"0.0.0.0", "PrivatePort"=>80, "PublicPort"=>8484, "Type"=>"tcp"}], "Status"=>"Up 13 seconds"}]
|
40
|
+
|
41
|
+
running_containers = target_server.find_containers_by_public_port(port)
|
42
|
+
container = running_containers.pop
|
43
|
+
|
44
|
+
unless running_containers.empty?
|
45
|
+
# This _should_ never happen, but...
|
46
|
+
error "More than one container is bound to port #{port} on #{target_server}!"
|
47
|
+
return false
|
48
|
+
end
|
49
|
+
|
50
|
+
if container && container['Ports'].any? { |bind| bind['PublicPort'].to_i == port.to_i }
|
51
|
+
info "Found container up for #{Time.now.to_i - container['Created'].to_i} seconds"
|
52
|
+
return true
|
53
|
+
end
|
54
|
+
|
55
|
+
false
|
56
|
+
end
|
57
|
+
|
58
|
+
def http_status_ok?(target_server, port, endpoint)
|
59
|
+
url = "http://#{target_server.hostname}:#{port}#{endpoint}"
|
60
|
+
response = begin
|
61
|
+
Excon.get(url)
|
62
|
+
rescue Excon::Errors::SocketError
|
63
|
+
warn "Failed to connect to #{url}, no socket open."
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
|
67
|
+
return false unless response
|
68
|
+
return true if response.status >= 200 && response.status < 300
|
69
|
+
|
70
|
+
warn "Got HTTP status: #{response.status}"
|
71
|
+
false
|
72
|
+
end
|
73
|
+
|
74
|
+
def wait_for_load_balancer_check_interval
|
75
|
+
sleep(fetch(:rolling_deploy_check_interval, 5))
|
76
|
+
end
|
77
|
+
|
78
|
+
def cleanup_containers(target_server, port_bindings)
|
79
|
+
public_port = public_port_for(port_bindings)
|
80
|
+
old_containers = target_server.old_containers_for_port(public_port)
|
81
|
+
old_containers.shift(2)
|
82
|
+
|
83
|
+
info "Public port #{public_port}"
|
84
|
+
old_containers.each do |old_container|
|
85
|
+
info "Removing old container #{old_container['Id'][0..7]} (#{old_container['Names'].join(',')})"
|
86
|
+
target_server.remove_container(old_container['Id'])
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def container_config_for(target_server, image_id, port_bindings=nil, env_vars=nil)
|
91
|
+
container_config = {
|
92
|
+
'Image' => image_id,
|
93
|
+
'Hostname' => target_server.hostname,
|
94
|
+
}
|
95
|
+
|
96
|
+
if port_bindings
|
97
|
+
container_config['ExposedPorts'] = { port_bindings.keys.first => {} }
|
98
|
+
end
|
99
|
+
|
100
|
+
if env_vars
|
101
|
+
container_config['Env'] = env_vars.map { |k,v| "#{k}=#{v}" }
|
102
|
+
end
|
103
|
+
|
104
|
+
container_config
|
105
|
+
end
|
106
|
+
|
107
|
+
def start_new_container(target_server, image_id, port_bindings, volumes, env_vars=nil)
|
108
|
+
container_config = container_config_for(target_server, image_id, port_bindings, env_vars)
|
109
|
+
start_container_with_config(target_server, volumes, port_bindings, container_config)
|
110
|
+
end
|
111
|
+
|
112
|
+
def launch_console(target_server, image_id, port_bindings, volumes, env_vars=nil)
|
113
|
+
container_config = container_config_for(target_server, image_id, port_bindings, env_vars).merge(
|
114
|
+
'Cmd' => [ '/bin/bash' ],
|
115
|
+
'AttachStdin' => true,
|
116
|
+
'Tty' => true,
|
117
|
+
'OpenStdin' => true,
|
118
|
+
)
|
119
|
+
|
120
|
+
container = start_container_with_config(target_server, volumes, port_bindings, container_config)
|
121
|
+
|
122
|
+
target_server.attach(container['Id'])
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
def start_container_with_config(target_server, volumes, port_bindings, container_config)
|
128
|
+
info "Creating new container for #{container_config['Image'][0..7]}"
|
129
|
+
new_container = target_server.create_container(container_config)
|
130
|
+
|
131
|
+
host_config = {
|
132
|
+
'PortBindings' => port_bindings
|
133
|
+
}
|
134
|
+
# Map some host volumes if needed
|
135
|
+
host_config['Binds'] = volumes if volumes && !volumes.empty?
|
136
|
+
|
137
|
+
info "Starting new container #{new_container['Id'][0..7]}"
|
138
|
+
target_server.start_container(new_container['Id'], host_config)
|
139
|
+
|
140
|
+
info "Inspecting new container #{new_container['Id'][0..7]}:"
|
141
|
+
info target_server.inspect_container(new_container['Id'])
|
142
|
+
|
143
|
+
new_container
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require_relative 'docker_server_group'
|
2
|
+
require 'uri'
|
3
|
+
|
4
|
+
module Centurion::DeployDSL
|
5
|
+
def on_each_docker_host(&block)
|
6
|
+
Centurion::DockerServerGroup.new(fetch(:hosts, []), fetch(:docker_path)).tap do |hosts|
|
7
|
+
hosts.each { |host| block.call(host) }
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def env_vars(new_vars)
|
12
|
+
current = fetch(:env_vars, {})
|
13
|
+
new_vars.each_pair do |new_key, new_value|
|
14
|
+
current[new_key.to_s] = new_value
|
15
|
+
end
|
16
|
+
set(:env_vars, current)
|
17
|
+
end
|
18
|
+
|
19
|
+
def host(hostname)
|
20
|
+
current = fetch(:hosts, [])
|
21
|
+
current << hostname
|
22
|
+
set(:hosts, current)
|
23
|
+
end
|
24
|
+
|
25
|
+
def localhost
|
26
|
+
# DOCKER_HOST is like 'tcp://127.0.0.1:4243'
|
27
|
+
docker_host_uri = URI.parse(ENV['DOCKER_HOST'] || "tcp://127.0.0.1")
|
28
|
+
host_and_port = [docker_host_uri.host, docker_host_uri.port].compact.join(':')
|
29
|
+
host(host_and_port)
|
30
|
+
end
|
31
|
+
|
32
|
+
def host_port(port, options)
|
33
|
+
validate_options_keys(options, [ :host_ip, :container_port, :type ])
|
34
|
+
require_options_keys(options, [ :container_port ])
|
35
|
+
|
36
|
+
add_to_bindings(
|
37
|
+
options[:host_ip] || '0.0.0.0',
|
38
|
+
options[:container_port],
|
39
|
+
port,
|
40
|
+
options[:type] || 'tcp'
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
def public_port_for(port_bindings)
|
45
|
+
# {'80/tcp'=>[{'HostIp'=>'0.0.0.0', 'HostPort'=>'80'}]}
|
46
|
+
first_port_binding = port_bindings.values.first
|
47
|
+
first_port_binding.first['HostPort']
|
48
|
+
end
|
49
|
+
|
50
|
+
def host_volume(volume, options)
|
51
|
+
validate_options_keys(options, [ :container_volume ])
|
52
|
+
require_options_keys(options, [ :container_volume ])
|
53
|
+
|
54
|
+
binds = fetch(:binds, [])
|
55
|
+
container_volume = options[:container_volume]
|
56
|
+
|
57
|
+
binds << "#{volume}:#{container_volume}"
|
58
|
+
set(:binds, binds)
|
59
|
+
end
|
60
|
+
|
61
|
+
def get_current_tags_for(image)
|
62
|
+
hosts = Centurion::DockerServerGroup.new(fetch(:hosts), fetch(:docker_path))
|
63
|
+
hosts.inject([]) do |memo, target_server|
|
64
|
+
tags = target_server.current_tags_for(image)
|
65
|
+
memo += [{ server: target_server.hostname, tags: tags }] if tags
|
66
|
+
memo
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def add_to_bindings(host_ip, container_port, port, type='tcp')
|
73
|
+
set(:port_bindings, fetch(:port_bindings, {}).tap do |bindings|
|
74
|
+
bindings["#{container_port.to_s}/#{type}"] = [
|
75
|
+
{'HostIp' => host_ip, 'HostPort' => port.to_s}
|
76
|
+
]
|
77
|
+
bindings
|
78
|
+
end)
|
79
|
+
end
|
80
|
+
|
81
|
+
def validate_options_keys(options, valid_keys)
|
82
|
+
unless options.keys.all? { |k| valid_keys.include?(k) }
|
83
|
+
raise ArgumentError.new('Options passed with invalid key!')
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def require_options_keys(options, required_keys)
|
88
|
+
missing = required_keys.reject { |k| options.keys.include?(k) }
|
89
|
+
|
90
|
+
unless missing.empty?
|
91
|
+
raise ArgumentError.new("Options must contain #{missing.inspect}")
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'excon'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
module Centurion; end
|
6
|
+
|
7
|
+
class Centurion::DockerRegistry
|
8
|
+
def initialize()
|
9
|
+
# @base_uri = "https://staging-docker-registry.nr-ops.net"
|
10
|
+
@base_uri = 'http://chi-docker-registry.nr-ops.net'
|
11
|
+
end
|
12
|
+
|
13
|
+
def digest_for_tag( repository, tag)
|
14
|
+
path = "/v1/repositories/#{repository}/tags/#{tag}"
|
15
|
+
$stderr.puts "GET: #{path.inspect}"
|
16
|
+
response = Excon.get(
|
17
|
+
@base_uri + path,
|
18
|
+
:headers => { "Content-Type" => "application/json" }
|
19
|
+
)
|
20
|
+
raise response.inspect unless response.status == 200
|
21
|
+
|
22
|
+
# This hack is stupid, and I hate it. But it works around the fact that
|
23
|
+
# the Docker Registry will return a base JSON String, which the Ruby parser
|
24
|
+
# refuses (possibly correctly) to handle
|
25
|
+
JSON.load('[' + response.body + ']').first
|
26
|
+
end
|
27
|
+
|
28
|
+
def respository_tags( respository )
|
29
|
+
path = "/v1/repositories/#{respository}/tags"
|
30
|
+
$stderr.puts "GET: #{path.inspect}"
|
31
|
+
response = Excon.get(@base_uri + path)
|
32
|
+
raise response.inspect unless response.status == 200
|
33
|
+
JSON.load(response.body)
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'pty'
|
2
|
+
require 'forwardable'
|
3
|
+
|
4
|
+
require_relative 'logging'
|
5
|
+
require_relative 'docker_via_api'
|
6
|
+
require_relative 'docker_via_cli'
|
7
|
+
|
8
|
+
module Centurion; end
|
9
|
+
|
10
|
+
class Centurion::DockerServer
|
11
|
+
include Centurion::Logging
|
12
|
+
extend Forwardable
|
13
|
+
|
14
|
+
attr_reader :hostname, :port
|
15
|
+
|
16
|
+
def_delegators :docker_via_api, :create_container, :inspect_container,
|
17
|
+
:inspect_image, :ps, :start_container, :stop_container,
|
18
|
+
:old_containers_for_port, :remove_container
|
19
|
+
def_delegators :docker_via_cli, :pull, :tail, :attach
|
20
|
+
|
21
|
+
def initialize(host, docker_path)
|
22
|
+
@docker_path = docker_path
|
23
|
+
@hostname, @port = host.split(':')
|
24
|
+
@port ||= '4243'
|
25
|
+
end
|
26
|
+
|
27
|
+
def current_tags_for(image)
|
28
|
+
running_containers = ps.select { |c| c['Image'] =~ /#{image}/ }
|
29
|
+
return [] if running_containers.empty?
|
30
|
+
|
31
|
+
parse_image_tags_for(running_containers)
|
32
|
+
end
|
33
|
+
|
34
|
+
def find_containers_by_public_port(public_port, type='tcp')
|
35
|
+
ps.select do |container|
|
36
|
+
if container['Ports']
|
37
|
+
container['Ports'].find do |port|
|
38
|
+
port['PublicPort'] == public_port.to_i && port['Type'] == type
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def docker_via_api
|
47
|
+
@docker_via_api ||= Centurion::DockerViaApi.new(@hostname, @port)
|
48
|
+
end
|
49
|
+
|
50
|
+
def docker_via_cli
|
51
|
+
@docker_via_cli ||= Centurion::DockerViaCli.new(@hostname, @port, @docker_path)
|
52
|
+
end
|
53
|
+
|
54
|
+
def parse_image_tags_for(running_containers)
|
55
|
+
running_container_names = running_containers.map { |c| c['Image'] }
|
56
|
+
running_container_names.map { |name| name.split(/:/).last } # (image, tag)
|
57
|
+
end
|
58
|
+
end
|