docker-cake 0.1
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 +7 -0
- data/.gitignore +4 -0
- data/README.md +29 -0
- data/bin/docker-cake +5 -0
- data/docker-cake.gemspec +20 -0
- data/lib/docker-cake.rb +1 -0
- data/lib/docker_cake.rb +133 -0
- data/lib/docker_cake/cli.rb +61 -0
- data/lib/docker_cake/registry_api_client.rb +459 -0
- metadata +73 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: c65060c27c01fce55231395ee5ad9200948a4be7
|
4
|
+
data.tar.gz: e9fc17acf691b35fbb212bb18fcd74b50d908308
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: da38d0dffb2c6e0eee7de50ec7f2f870e539878f67f65377da781011ad8df84985b96d6ec32e1029740e21dd2b1512985438be6fc8f9cc11b49e1c0986eee33b
|
7
|
+
data.tar.gz: 31857508135384c7a536cb8ce8dc304d26d943916741827d227f81adc0cfdb069703864725230dac4d0b2f33ae7c39752ec0e124834730d5121853850d36e200
|
data/.gitignore
ADDED
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# docker-cake
|
2
|
+
|
3
|
+
Command line tool to compare image layers across tegs, build to optimize storage usage
|
4
|
+
|
5
|
+
### Install:
|
6
|
+
```sh
|
7
|
+
gem install docker-cake
|
8
|
+
```
|
9
|
+
|
10
|
+
### Example:
|
11
|
+
|
12
|
+
```
|
13
|
+
docker-cake library/redmine
|
14
|
+
|
15
|
+
+-----------------+---------------------+------------+--------+--------------+--------------+------------+-----------+
|
16
|
+
| Tag | Date | Size | Layers | Reuse Layers | Extra Layers | Extra Size | Same as |
|
17
|
+
+-----------------+---------------------+------------+--------+--------------+--------------+------------+-----------+
|
18
|
+
| 3.4.1 | 2017-07-13 23:09:32 | 240.249 MB | 34 | 18 | 16 | 240.249 MB | |
|
19
|
+
| 3.4.1-passenger | 2017-07-13 23:09:58 | 258.997 MB | 38 | 36 | 2 | 18.748 MB | |
|
20
|
+
| 3.4 | 2017-08-07 23:17:29 | 240.502 MB | 34 | 19 | 15 | 240.502 MB | |
|
21
|
+
| 3.4.2 | 2017-08-07 23:17:29 | 240.502 MB | 34 | 34 | 0 | 0 Bytes | 3.4 |
|
22
|
+
| 3 | 2017-08-07 23:17:29 | 240.502 MB | 34 | 34 | 0 | 0 Bytes | 3.4 |
|
23
|
+
| latest | 2017-08-07 23:17:29 | 240.502 MB | 34 | 34 | 0 | 0 Bytes | 3.4 |
|
24
|
+
| passenger | 2017-08-07 23:18:15 | 259.285 MB | 38 | 36 | 2 | 18.784 MB | |
|
25
|
+
| 3.4-passenger | 2017-08-07 23:18:15 | 259.285 MB | 38 | 38 | 0 | 0 Bytes | passenger |
|
26
|
+
| 3.4.2-passenger | 2017-08-07 23:18:15 | 259.285 MB | 38 | 38 | 0 | 0 Bytes | passenger |
|
27
|
+
| 3.3 | 2017-08-07 23:22:16 | 251.748 MB | 34 | 22 | 12 | 188.982 MB | |
|
28
|
+
+-----------------+---------------------+------------+--------+--------------+--------------+------------+-----------+
|
29
|
+
```
|
data/bin/docker-cake
ADDED
data/docker-cake.gemspec
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Gem::Specification.new do |gem|
|
2
|
+
gem.specification_version = 3
|
3
|
+
|
4
|
+
gem.name = 'docker-cake'
|
5
|
+
gem.version = "0.1"
|
6
|
+
gem.authors = ["Pavel Evstigneev"]
|
7
|
+
gem.email = ["pavel.evst@gmail.com"]
|
8
|
+
gem.license = 'MIT'
|
9
|
+
gem.date = '2017-08-08'
|
10
|
+
gem.summary = "Command line program to inspect docker images size"
|
11
|
+
gem.homepage = 'https://github.com/paxa/docker-cake'
|
12
|
+
|
13
|
+
gem.files = `git ls-files`.split("\n")
|
14
|
+
gem.require_path = 'lib'
|
15
|
+
gem.executables = ["docker-cake"]
|
16
|
+
|
17
|
+
gem.required_ruby_version = '>= 2.1'
|
18
|
+
|
19
|
+
gem.add_dependency 'terminal-table', ['>= 1.8.0', '<3']
|
20
|
+
end
|
data/lib/docker-cake.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require_relative 'docker_cake'
|
data/lib/docker_cake.rb
ADDED
@@ -0,0 +1,133 @@
|
|
1
|
+
require_relative 'docker_cake/registry_api_client'
|
2
|
+
require 'time'
|
3
|
+
require 'terminal-table'
|
4
|
+
|
5
|
+
class DockerCake
|
6
|
+
|
7
|
+
attr_accessor :registry
|
8
|
+
|
9
|
+
def initialize(url: nil, user: nil, password: nil)
|
10
|
+
@registry ||= RegistryApiClient.new(user: user, password: password)
|
11
|
+
end
|
12
|
+
|
13
|
+
def repo_info(name, tag = 'latest')
|
14
|
+
manifest = registry.manifest_layers(name, tag)
|
15
|
+
puts print_manifest(manifest)
|
16
|
+
end
|
17
|
+
|
18
|
+
def compare_versions(repo_name, filter: /.+/, max: 10)
|
19
|
+
tags = registry.tags(repo_name, false)['tags']
|
20
|
+
tags.select! {|t| t =~ filter}
|
21
|
+
|
22
|
+
selected_tags = tags.last(max)
|
23
|
+
|
24
|
+
manifests = {}
|
25
|
+
procs = selected_tags.map do |tag|
|
26
|
+
lambda { manifests[tag] = registry.manifest_layers(repo_name, tag) }
|
27
|
+
end
|
28
|
+
registry.in_parallel(procs)
|
29
|
+
#selected_tags.each do |tag|
|
30
|
+
# manifests[tag] = registry.manifest_layers(repo_name, tag)
|
31
|
+
# #pp manifests[tag]
|
32
|
+
#end
|
33
|
+
|
34
|
+
manifests = manifests.sort_by do |tag, list|
|
35
|
+
list.map {|l| l['created'] }.max
|
36
|
+
end.to_h
|
37
|
+
|
38
|
+
images_map = {}
|
39
|
+
counted_layers = []
|
40
|
+
result = []
|
41
|
+
|
42
|
+
manifests.each do |tag, layers|
|
43
|
+
stats = {name: tag, size: 0, add_img: 0, reuse_img: 0, add_size: 0, layers: 0, date: Time.at(0).to_s}
|
44
|
+
|
45
|
+
map_key = manifests[tag].map {|l| l['blobSum']}.join(',')
|
46
|
+
if images_map[map_key]
|
47
|
+
stats[:same_as] = images_map[map_key]
|
48
|
+
end
|
49
|
+
images_map[map_key] ||= tag
|
50
|
+
|
51
|
+
manifests[tag].each do |layer|
|
52
|
+
stats[:size] += layer['size'] || 0
|
53
|
+
stats[:layers] += 1
|
54
|
+
stats[:date] = layer['created'] if layer['created'] > stats[:date]
|
55
|
+
if counted_layers.include?(layer['blobSum'])
|
56
|
+
stats[:reuse_img] += 1
|
57
|
+
else
|
58
|
+
stats[:add_img] += 1
|
59
|
+
stats[:add_size] += layer['size'] || 0
|
60
|
+
end
|
61
|
+
|
62
|
+
counted_layers << layer['blobSum']
|
63
|
+
end
|
64
|
+
|
65
|
+
#puts "#{tag} -> #{stats}"
|
66
|
+
result << stats
|
67
|
+
end
|
68
|
+
|
69
|
+
puts print_comparison(result)
|
70
|
+
end
|
71
|
+
|
72
|
+
def print_comparison(result)
|
73
|
+
rows = result.map do |stats|
|
74
|
+
[
|
75
|
+
stats[:name],
|
76
|
+
DateTime.parse(stats[:date]).strftime("%F %T"),
|
77
|
+
size_to_human(stats[:size]),
|
78
|
+
stats[:layers],
|
79
|
+
stats[:reuse_img],
|
80
|
+
stats[:add_img],
|
81
|
+
size_to_human(stats[:add_size]),
|
82
|
+
stats[:same_as]
|
83
|
+
]
|
84
|
+
end
|
85
|
+
|
86
|
+
table = Terminal::Table.new(headings: ['Tag', 'Date', 'Size', 'Layers', 'Reuse Layers', 'Extra Layers', 'Extra Size', 'Same as'], rows: rows)
|
87
|
+
end
|
88
|
+
|
89
|
+
def print_manifest(manifest)
|
90
|
+
rows = manifest.map do |layer|
|
91
|
+
cmd = layer['container_config'] && layer['container_config']['Cmd'] || ['']
|
92
|
+
cmd = cmd.join(" ").gsub('\n', "\n").sub(/#{'/bin/sh -c'}\s+/, '')
|
93
|
+
if cmd.start_with?('#(nop)')
|
94
|
+
cmd.sub!(/#\(nop\)\s+/, '')
|
95
|
+
else
|
96
|
+
cmd = "RUN #{cmd}"
|
97
|
+
end
|
98
|
+
|
99
|
+
cmd.gsub!("\t", " ")
|
100
|
+
|
101
|
+
shorter = []
|
102
|
+
cmd.lines.each do |line|
|
103
|
+
shorter.push(*line.scan(/.{1,90}/))
|
104
|
+
end
|
105
|
+
|
106
|
+
[
|
107
|
+
layer['id'][0...8],
|
108
|
+
DateTime.parse(layer['created']).strftime("%F %T"),
|
109
|
+
shorter.join("\n"),
|
110
|
+
size_to_human(layer['size'])
|
111
|
+
]
|
112
|
+
end
|
113
|
+
|
114
|
+
rows << :separator << [nil, nil, 'TOTAL', size_to_human(manifest.sum {|layer| layer['size'].to_i })]
|
115
|
+
|
116
|
+
table = Terminal::Table.new(headings: ['ID', 'Date', 'CMD', 'Size'], rows: rows)
|
117
|
+
end
|
118
|
+
|
119
|
+
def size_to_human(size)
|
120
|
+
return '0' unless size
|
121
|
+
|
122
|
+
if size > 1_000_000_000
|
123
|
+
"#{(size / 1_000_000_000.0).round(3)} GB"
|
124
|
+
elsif size > 1_000_000
|
125
|
+
"#{(size / 1_000_000.0).round(3)} MB"
|
126
|
+
elsif size > 1_000
|
127
|
+
"#{(size / 1_000.0).round(3)} KB"
|
128
|
+
else
|
129
|
+
"#{size} Bytes"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require_relative '../docker_cake'
|
3
|
+
|
4
|
+
options = {}
|
5
|
+
parser = OptionParser.new do |opts|
|
6
|
+
opts.banner = "Usage: docker-cake [options] REPO"
|
7
|
+
|
8
|
+
opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
|
9
|
+
options[:verbose] = v
|
10
|
+
end
|
11
|
+
|
12
|
+
opts.on("-u", "--user [USER]", "Username, required for private repos") do |v|
|
13
|
+
options[:user] = v
|
14
|
+
end
|
15
|
+
|
16
|
+
opts.on("-p", "--password [PASSWORD]", "Password, required for private repos") do |v|
|
17
|
+
options[:password] = v
|
18
|
+
end
|
19
|
+
|
20
|
+
opts.on("-r", "--registry [REGISTRY URL]", "Docker registry URL, default is https://registry.hub.docker.com") do |v|
|
21
|
+
options[:url] = v
|
22
|
+
end
|
23
|
+
|
24
|
+
opts.on("-l", "--layers", "Print layers of image") do |v|
|
25
|
+
options[:layers] = v
|
26
|
+
end
|
27
|
+
|
28
|
+
opts.on("-n", "--max [NUM TAGS]", "Number of tags to analyse, default is 10") do |v|
|
29
|
+
options[:max] = v
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
parser.parse!
|
35
|
+
|
36
|
+
if options[:verbose]
|
37
|
+
ENV['DEBUG'] = '1'
|
38
|
+
p options
|
39
|
+
end
|
40
|
+
|
41
|
+
repo = ARGV.first
|
42
|
+
tag = ARGV[1]
|
43
|
+
|
44
|
+
if !repo || repo == ''
|
45
|
+
puts "Repository name is required"
|
46
|
+
puts "Example:"
|
47
|
+
puts " docker-cake library/ruby"
|
48
|
+
puts " docker-cake --help"
|
49
|
+
exit(1)
|
50
|
+
end
|
51
|
+
|
52
|
+
connect_options = {user: options[:user], password: options[:password]}
|
53
|
+
connect_options[:url] = options[:url] if options[:url]
|
54
|
+
|
55
|
+
if options[:layers]
|
56
|
+
DockerCake.new(connect_options).repo_info(repo, tag || 'latest')
|
57
|
+
else
|
58
|
+
opts = {}
|
59
|
+
opts[:max] = options[:max].to_i if options[:max]
|
60
|
+
DockerCake.new(connect_options).compare_versions(repo, opts)
|
61
|
+
end
|
@@ -0,0 +1,459 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'uri'
|
3
|
+
require 'socket'
|
4
|
+
require 'net/http'
|
5
|
+
|
6
|
+
class RegistryApiClient
|
7
|
+
|
8
|
+
class RegistryAuthenticationException < Exception
|
9
|
+
end
|
10
|
+
|
11
|
+
class RegistryAuthorizationException < Exception
|
12
|
+
end
|
13
|
+
|
14
|
+
class RegistryUnknownException < Exception
|
15
|
+
end
|
16
|
+
|
17
|
+
class RegistrySSLException < Exception
|
18
|
+
end
|
19
|
+
|
20
|
+
class ReauthenticatedException < Exception
|
21
|
+
end
|
22
|
+
|
23
|
+
class UnknownRegistryException < Exception
|
24
|
+
end
|
25
|
+
|
26
|
+
class InvalidMethod < Exception
|
27
|
+
end
|
28
|
+
|
29
|
+
class Waiter
|
30
|
+
def initialize
|
31
|
+
@queue = Queue.new
|
32
|
+
end
|
33
|
+
|
34
|
+
# Waiting for someone to call #notify
|
35
|
+
def wait
|
36
|
+
@queue.pop(false)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Notify waiting side
|
40
|
+
def notify(data)
|
41
|
+
@queue.push(data)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# It's same as waiter but support multiple subscribers
|
46
|
+
class PubSub
|
47
|
+
def initialize
|
48
|
+
@waiters = []
|
49
|
+
end
|
50
|
+
|
51
|
+
def wait
|
52
|
+
waiter = Waiter.new
|
53
|
+
@waiters << waiter
|
54
|
+
waiter.wait
|
55
|
+
end
|
56
|
+
|
57
|
+
def notify(value)
|
58
|
+
while waiter = @waiters.shift
|
59
|
+
waiter.notify(value)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
DEFAULT_REGISTRY = "https://registry.hub.docker.com"
|
65
|
+
DEFAULT_MANIFEST = "application/vnd.docker.distribution.manifest.v2+json"
|
66
|
+
FAT_MANIFEST = "application/vnd.docker.distribution.manifest.list.v2+json"
|
67
|
+
|
68
|
+
# @param [#to_s] base_uri Docker registry base URI
|
69
|
+
# @param [Hash] options Client options
|
70
|
+
# @option options [#to_s] :user User name for basic authentication
|
71
|
+
# @option options [#to_s] :password Password for basic authentication
|
72
|
+
def initialize(url: DEFAULT_REGISTRY, user: nil, password: nil)
|
73
|
+
@url = url
|
74
|
+
uri = URI.parse(url)
|
75
|
+
@base_uri = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
76
|
+
@user = user
|
77
|
+
@password = password
|
78
|
+
@manifest_format = "application/vnd.docker.distribution.manifest.v2+json"
|
79
|
+
#@manifest_format = "application/vnd.docker.distribution.manifest.list.v2+json"
|
80
|
+
#@manifest_format = "application/vnd.docker.container.image.v1+json"
|
81
|
+
# make a ping connection
|
82
|
+
#ping
|
83
|
+
end
|
84
|
+
|
85
|
+
def http_get(url, manifest: nil, auth: nil, auth_header: nil)
|
86
|
+
http_req("get", url, manifest: manifest, auth: auth, auth_header: auth_header)
|
87
|
+
end
|
88
|
+
|
89
|
+
def http_delete(url)
|
90
|
+
http_req("delete", url)
|
91
|
+
end
|
92
|
+
|
93
|
+
def http_head(url)
|
94
|
+
http_req("head", url)
|
95
|
+
end
|
96
|
+
|
97
|
+
def ping
|
98
|
+
response = http_get('/v2/')
|
99
|
+
end
|
100
|
+
|
101
|
+
def search(query = '')
|
102
|
+
response = http_get "/v2/_catalog"
|
103
|
+
# parse the response
|
104
|
+
repos = JSON.parse(response)["repositories"]
|
105
|
+
if query.strip.length > 0
|
106
|
+
re = Regexp.new query
|
107
|
+
repos = repos.find_all {|e| re =~ e }
|
108
|
+
end
|
109
|
+
return repos
|
110
|
+
end
|
111
|
+
|
112
|
+
def tags(repo, withHashes = false)
|
113
|
+
response = http_get("/v2/#{repo}/tags/list")
|
114
|
+
# parse the response
|
115
|
+
resp = JSON.parse(response)
|
116
|
+
# do we include the hashes?
|
117
|
+
if withHashes then
|
118
|
+
useGet = false
|
119
|
+
resp["hashes"] = {}
|
120
|
+
resp["tags"].each {|tag|
|
121
|
+
if useGet then
|
122
|
+
head = http_get "/v2/#{repo}/manifests/#{tag}"
|
123
|
+
else
|
124
|
+
begin
|
125
|
+
head = http_head("/v2/#{repo}/manifests/#{tag}")
|
126
|
+
rescue InvalidMethod
|
127
|
+
# in case we are in a registry pre-2.3.0, which did not support manifest HEAD
|
128
|
+
useGet = true
|
129
|
+
head = http_get("/v2/#{repo}/manifests/#{tag}")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
resp["hashes"][tag] = head.headers[:docker_content_digest]
|
133
|
+
}
|
134
|
+
end
|
135
|
+
|
136
|
+
return resp
|
137
|
+
end
|
138
|
+
|
139
|
+
# combines small output and fat output to get layer names and sizes
|
140
|
+
def manifest(repo, tag, manifest: nil)
|
141
|
+
if @url == DEFAULT_REGISTRY
|
142
|
+
auth_header = %{Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:#{repo}:pull"}
|
143
|
+
end
|
144
|
+
JSON.parse(http_get("/v2/#{repo}/manifests/#{tag}", manifest: manifest, auth: :bearer, auth_header: auth_header))
|
145
|
+
end
|
146
|
+
|
147
|
+
def manifest_layers(repo, tag)
|
148
|
+
basic_response = nil
|
149
|
+
fat_response = nil
|
150
|
+
|
151
|
+
resp = in_parallel(
|
152
|
+
basic: lambda { manifest(repo, tag) },
|
153
|
+
fat: lambda { manifest(repo, tag, manifest: FAT_MANIFEST) }
|
154
|
+
)
|
155
|
+
|
156
|
+
unless resp[:basic]['layers']
|
157
|
+
puts "Strange response"
|
158
|
+
p resp[:basic]
|
159
|
+
end
|
160
|
+
|
161
|
+
basic = resp[:basic]['layers'] || []
|
162
|
+
fat_response = resp[:fat]
|
163
|
+
|
164
|
+
result = []
|
165
|
+
|
166
|
+
fat_response['history'].each_with_index do |info, index|
|
167
|
+
result[index] = JSON.parse(info['v1Compatibility'])
|
168
|
+
result[index]['blobSum'] = fat_response['fsLayers'][index]['blobSum']
|
169
|
+
result[index]['size'] = basic.detect do |layer|
|
170
|
+
layer['digest'] == result[index]['blobSum']
|
171
|
+
end
|
172
|
+
result[index]['size'] = result[index]['size']['size'] if result[index]['size']
|
173
|
+
end
|
174
|
+
|
175
|
+
# require 'irb'
|
176
|
+
# binding.irb
|
177
|
+
|
178
|
+
result.reverse
|
179
|
+
end
|
180
|
+
|
181
|
+
def in_parallel(procs = {})
|
182
|
+
threads = []
|
183
|
+
result = {}
|
184
|
+
errors = []
|
185
|
+
procs.each do |key, fun|
|
186
|
+
# handle array
|
187
|
+
if fun == nil && key.is_a?(Proc)
|
188
|
+
fun = key
|
189
|
+
key = result.size
|
190
|
+
end
|
191
|
+
result[key] = nil
|
192
|
+
threads << Thread.new do
|
193
|
+
begin
|
194
|
+
result[key] = fun.call
|
195
|
+
rescue => error
|
196
|
+
puts "#{error.class}: #{error.message}"
|
197
|
+
puts error.backtrace
|
198
|
+
errors << error
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
threads.map(&:join)
|
204
|
+
|
205
|
+
if errors.size > 0
|
206
|
+
raise errors.first
|
207
|
+
end
|
208
|
+
|
209
|
+
result
|
210
|
+
end
|
211
|
+
|
212
|
+
# gets the size of a particular blob, given the repo and the content-addressable hash
|
213
|
+
# usually unneeded, since manifest includes it
|
214
|
+
def blob_size(repo, blobSum)
|
215
|
+
response = http_head("/v2/#{repo}/blobs/#{blobSum}")
|
216
|
+
Integer(response.headers[:content_length], 10)
|
217
|
+
end
|
218
|
+
|
219
|
+
def manifest_sum(manifest)
|
220
|
+
size = 0
|
221
|
+
manifest["layers"].each do |layer|
|
222
|
+
size += layer["size"]
|
223
|
+
end
|
224
|
+
size
|
225
|
+
end
|
226
|
+
|
227
|
+
private
|
228
|
+
def http_req(type, url, stream: nil, manifest: nil, auth: nil, auth_header: nil)
|
229
|
+
begin
|
230
|
+
if auth == :bearer && auth_header
|
231
|
+
return do_bearer_req(type, url, auth_header, stream: stream, manifest: manifest)
|
232
|
+
else
|
233
|
+
return req_no_auth(type, url, stream: stream, manifest: manifest)
|
234
|
+
end
|
235
|
+
rescue SocketError => e
|
236
|
+
p e
|
237
|
+
raise RegistryUnknownException
|
238
|
+
rescue HTTP::Unauthorized => e
|
239
|
+
header = e.response.headers[:www_authenticate]
|
240
|
+
method = header.downcase.split(' ')[0]
|
241
|
+
case method
|
242
|
+
when 'basic'
|
243
|
+
response = do_basic_req(type, url, stream: stream, manifest: manifest)
|
244
|
+
when 'bearer'
|
245
|
+
response = do_bearer_req(type, url, header, stream: stream, manifest: manifest)
|
246
|
+
else
|
247
|
+
raise RegistryUnknownException
|
248
|
+
end
|
249
|
+
end
|
250
|
+
return response
|
251
|
+
end
|
252
|
+
|
253
|
+
def req_no_auth(type, url, stream: nil, manifest: nil)
|
254
|
+
block = stream.nil? ? nil : proc do |response|
|
255
|
+
response.read_body do |chunk|
|
256
|
+
stream.write chunk
|
257
|
+
end
|
258
|
+
end
|
259
|
+
response = HTTP.execute(
|
260
|
+
method: type,
|
261
|
+
url: @base_uri + url,
|
262
|
+
headers: {Accept: manifest || @manifest_format},
|
263
|
+
block_response: block
|
264
|
+
)
|
265
|
+
end
|
266
|
+
|
267
|
+
def do_basic_req(type, url, stream: nil, manifest: nil)
|
268
|
+
begin
|
269
|
+
block = stream.nil? ? nil : proc { |response|
|
270
|
+
response.read_body do |chunk|
|
271
|
+
stream.write chunk
|
272
|
+
end
|
273
|
+
}
|
274
|
+
response = HTTP.execute(
|
275
|
+
method: type,
|
276
|
+
url: @base_uri + url,
|
277
|
+
user: @user,
|
278
|
+
password: @password,
|
279
|
+
headers: {Accept: manifest || @manifest_format},
|
280
|
+
block_response: block
|
281
|
+
)
|
282
|
+
rescue SocketError
|
283
|
+
raise RegistryUnknownException
|
284
|
+
rescue HTTP::Unauthorized
|
285
|
+
raise RegistryAuthenticationException
|
286
|
+
rescue MethodNotAllowed
|
287
|
+
raise InvalidMethod
|
288
|
+
end
|
289
|
+
return response
|
290
|
+
end
|
291
|
+
|
292
|
+
def do_bearer_req(type, url, header, stream: false, manifest: nil)
|
293
|
+
token = authenticate_bearer(header)
|
294
|
+
begin
|
295
|
+
block = stream.nil? ? nil : proc { |response|
|
296
|
+
response.read_body do |chunk|
|
297
|
+
stream.write chunk
|
298
|
+
end
|
299
|
+
}
|
300
|
+
response = HTTP.execute(
|
301
|
+
method: type,
|
302
|
+
url: @base_uri + url,
|
303
|
+
headers: {Authorization: 'Bearer ' + token, Accept: manifest || @manifest_format},
|
304
|
+
block_response: block
|
305
|
+
)
|
306
|
+
rescue SocketError
|
307
|
+
raise RegistryUnknownException
|
308
|
+
rescue HTTP::Unauthorized
|
309
|
+
raise RegistryAuthenticationException
|
310
|
+
rescue MethodNotAllowed
|
311
|
+
raise InvalidMethod
|
312
|
+
end
|
313
|
+
|
314
|
+
return response
|
315
|
+
end
|
316
|
+
|
317
|
+
AUTH_CACHE = {}
|
318
|
+
|
319
|
+
def authenticate_bearer(header)
|
320
|
+
# get the parts we need
|
321
|
+
target = split_auth_header(header)
|
322
|
+
scope = target[:params][:scope]
|
323
|
+
|
324
|
+
if AUTH_CACHE[scope].is_a?(String)
|
325
|
+
return AUTH_CACHE[scope]
|
326
|
+
elsif AUTH_CACHE[scope].is_a?(PubSub)
|
327
|
+
return AUTH_CACHE[scope].wait
|
328
|
+
else
|
329
|
+
AUTH_CACHE[scope] = PubSub.new
|
330
|
+
end
|
331
|
+
|
332
|
+
# did we have a username and password?
|
333
|
+
if defined? @user and @user.to_s.strip.length != 0
|
334
|
+
target[:params][:account] = @user
|
335
|
+
end
|
336
|
+
# authenticate against the realm
|
337
|
+
uri = URI.parse(target[:realm])
|
338
|
+
begin
|
339
|
+
response = HTTP.execute(
|
340
|
+
method: :get,
|
341
|
+
url: uri.to_s,
|
342
|
+
query: target[:params],
|
343
|
+
user: @user,
|
344
|
+
password: @password
|
345
|
+
)
|
346
|
+
rescue HTTP::Unauthorized
|
347
|
+
# bad authentication
|
348
|
+
raise RegistryAuthenticationException
|
349
|
+
end
|
350
|
+
# now save the web token
|
351
|
+
token = JSON.parse(response)["token"]
|
352
|
+
AUTH_CACHE[scope].notify(token)
|
353
|
+
AUTH_CACHE[scope] = token
|
354
|
+
return token
|
355
|
+
end
|
356
|
+
|
357
|
+
def split_auth_header(header = '')
|
358
|
+
h = Hash.new
|
359
|
+
h = {params: {}}
|
360
|
+
header.split(/[\s,]+/).each {|entry|
|
361
|
+
p = entry.split('=')
|
362
|
+
case p[0]
|
363
|
+
when 'Bearer'
|
364
|
+
when 'realm'
|
365
|
+
h[:realm] = p[1].gsub(/(^\"|\"$)/,'')
|
366
|
+
else
|
367
|
+
h[:params][p[0]] = p[1].gsub(/(^\"|\"$)/,'')
|
368
|
+
end
|
369
|
+
}
|
370
|
+
h
|
371
|
+
end
|
372
|
+
|
373
|
+
module HTTP
|
374
|
+
extend self
|
375
|
+
|
376
|
+
class Unauthorized < Exception
|
377
|
+
attr_accessor :response
|
378
|
+
def initialize(message, response)
|
379
|
+
super(message)
|
380
|
+
@response = response
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
class MethodNotAllowed < Exception
|
385
|
+
attr_accessor :response
|
386
|
+
def initialize(message, response)
|
387
|
+
super(message)
|
388
|
+
@response = response
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
def execute(method:, url:, headers: {}, user: nil, password: nil, block_response: nil, body: nil, query: nil)
|
393
|
+
if query
|
394
|
+
uri = URI.parse(url)
|
395
|
+
url += (uri.query ? "&" : "?") + URI.encode_www_form(query)
|
396
|
+
end
|
397
|
+
uri = URI.parse(url)
|
398
|
+
response = nil
|
399
|
+
|
400
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
401
|
+
http_klass = case method.to_sym
|
402
|
+
when :get then Net::HTTP::Get
|
403
|
+
when :post then Net::HTTP::Post
|
404
|
+
when :put then Net::HTTP::Put
|
405
|
+
when :patch then Net::HTTP::Patch
|
406
|
+
when :head then Net::HTTP::Head
|
407
|
+
when :delete then Net::HTTP::Delete
|
408
|
+
when :move then Net::HTTP::Move
|
409
|
+
when :copy then Net::HTTP::Copy
|
410
|
+
else Net::HTTP::Post
|
411
|
+
end
|
412
|
+
|
413
|
+
request = http_klass.new(uri)
|
414
|
+
headers.each do |key, value|
|
415
|
+
request[key.to_s] = value
|
416
|
+
end
|
417
|
+
|
418
|
+
if body
|
419
|
+
request.body = body
|
420
|
+
end
|
421
|
+
|
422
|
+
if user != nil || password != nil
|
423
|
+
request.basic_auth(user, password)
|
424
|
+
end
|
425
|
+
|
426
|
+
puts "HTTP req #{method} #{url} #{headers}" if ENV['DEBUG']
|
427
|
+
|
428
|
+
http_resp = http.request(request)
|
429
|
+
|
430
|
+
puts "-> HTTP status: #{http_resp.code} size: #{http_resp.body.size}" if ENV['DEBUG']
|
431
|
+
|
432
|
+
response = Response.new(http_resp)
|
433
|
+
|
434
|
+
if http_resp.code.to_s == "401"
|
435
|
+
raise Unauthorized.new(http_resp.body, response)
|
436
|
+
end
|
437
|
+
|
438
|
+
if http_resp.code.to_s == "405"
|
439
|
+
raise MethodNotAllowed.new(http_resp.body, response)
|
440
|
+
end
|
441
|
+
|
442
|
+
return response
|
443
|
+
end
|
444
|
+
end
|
445
|
+
|
446
|
+
class Response < String
|
447
|
+
attr_accessor :headers
|
448
|
+
|
449
|
+
def initialize(http_response)
|
450
|
+
@http_response = http_response
|
451
|
+
@headers = {}
|
452
|
+
@http_response.to_hash.each do |key, value|
|
453
|
+
@headers[key.gsub('-', '_').to_sym] = value.last
|
454
|
+
end
|
455
|
+
super(http_response.body)
|
456
|
+
end
|
457
|
+
end
|
458
|
+
end
|
459
|
+
end
|
metadata
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: docker-cake
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.1'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Pavel Evstigneev
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-08-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: terminal-table
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.8.0
|
20
|
+
- - "<"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '3'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 1.8.0
|
30
|
+
- - "<"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '3'
|
33
|
+
description:
|
34
|
+
email:
|
35
|
+
- pavel.evst@gmail.com
|
36
|
+
executables:
|
37
|
+
- docker-cake
|
38
|
+
extensions: []
|
39
|
+
extra_rdoc_files: []
|
40
|
+
files:
|
41
|
+
- ".gitignore"
|
42
|
+
- README.md
|
43
|
+
- bin/docker-cake
|
44
|
+
- docker-cake.gemspec
|
45
|
+
- lib/docker-cake.rb
|
46
|
+
- lib/docker_cake.rb
|
47
|
+
- lib/docker_cake/cli.rb
|
48
|
+
- lib/docker_cake/registry_api_client.rb
|
49
|
+
homepage: https://github.com/paxa/docker-cake
|
50
|
+
licenses:
|
51
|
+
- MIT
|
52
|
+
metadata: {}
|
53
|
+
post_install_message:
|
54
|
+
rdoc_options: []
|
55
|
+
require_paths:
|
56
|
+
- lib
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '2.1'
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
requirements:
|
64
|
+
- - ">="
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: '0'
|
67
|
+
requirements: []
|
68
|
+
rubyforge_project:
|
69
|
+
rubygems_version: 2.6.8
|
70
|
+
signing_key:
|
71
|
+
specification_version: 3
|
72
|
+
summary: Command line program to inspect docker images size
|
73
|
+
test_files: []
|