Skip to content

Commit 015160a

Browse files
author
Tom McKay
committed
fixes #22951 - support docker v2 api
1 parent bf02310 commit 015160a

File tree

8 files changed

+693
-0
lines changed

8 files changed

+693
-0
lines changed
Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
module Katello
2+
class Api::Registry::RegistryProxiesController < Api::V2::ApiController
3+
before_action :disable_strong_params
4+
5+
skip_before_action :authorize, except: [:token]
6+
before_action :registry_authorize, except: [:token]
7+
before_action :authorize_repository_read, only: [:pull_manifest, :tags_list]
8+
before_action :authorize_repository_write, only: [:push_manifest]
9+
skip_before_action :check_content_type, :only => [:start_upload_blob, :upload_blob, :finish_upload_blob,
10+
:chunk_upload_blob, :push_manifest]
11+
skip_after_action :log_response_body, :only => [:pull_blob]
12+
13+
wrap_parameters false
14+
15+
around_action :repackage_message
16+
17+
def repackage_message
18+
yield
19+
ensure
20+
response.headers['Docker-Distribution-API-Version'] = 'registry/2.0'
21+
end
22+
23+
rescue_from RestClient::Exception do |e|
24+
Rails.logger.error pp_exception(e)
25+
if request_from_katello_cli?
26+
render json: { errors: [e.http_body] }, status: e.http_code
27+
else
28+
render plain: e.http_body, status: e.http_code
29+
end
30+
end
31+
32+
def redirect_authorization_headers
33+
response.headers['Docker-Distribution-API-Version'] = 'registry/2.0'
34+
response.headers['Www-Authenticate'] = "Bearer realm=\"#{request_url}/v2/token\"," \
35+
"service=\"#{request.host}\"," \
36+
"scope=\"repository:registry:pull,push\""
37+
end
38+
39+
def registry_authorize
40+
token = request.headers['Authorization']
41+
if token
42+
token_type, token = token.split
43+
if token_type == 'Bearer' && token
44+
personal_token = PersonalAccessToken.find_by_token(token)
45+
if personal_token && !personal_token.expired?
46+
User.current = User.unscoped.find(personal_token.user_id)
47+
return true if User.current
48+
end
49+
elsif token_type == 'Basic' && token
50+
return true if authorize
51+
redirect_authorization_headers
52+
return false
53+
end
54+
end
55+
redirect_authorization_headers
56+
render_error('unauthorized', :status => :unauthorized)
57+
return false
58+
end
59+
60+
def authorize_repository_write
61+
@repository = Repository.syncable.find_by_container_repository_name(params[:repository])
62+
unless @repository
63+
not_found params[:repository]
64+
return false
65+
end
66+
true
67+
end
68+
69+
# Reduce visible repos to include lifecycle env permissions
70+
# http://projects.theforeman.org/issues/22914
71+
def readable_repositories
72+
table_name = Repository.table_name
73+
in_products = Repository.where(:product_id => Katello::Product.authorized(:view_products)).select(:id)
74+
in_environments = Repository.where(:environment_id => Katello::KTEnvironment.authorized(:view_lifecycle_environments)).select(:id)
75+
in_content_views = Repository.joins(:content_view_repositories).where("#{ContentViewRepository.table_name}.content_view_id" => Katello::ContentView.readable).select(:id)
76+
in_versions = Repository.joins(:content_view_version).where("#{Katello::ContentViewVersion.table_name}.content_view_id" => Katello::ContentView.readable).select(:id)
77+
Repository.where("#{table_name}.id in (?) or #{table_name}.id in (?) or #{table_name}.id in (?) or #{table_name}.id in (?)", in_products, in_content_views, in_versions, in_environments)
78+
end
79+
80+
def authorize_repository_read
81+
@repository = readable_repositories.find_by_container_repository_name(params[:repository])
82+
unless @repository
83+
not_found params[:repository]
84+
return false
85+
end
86+
87+
if params[:tag]
88+
if params[:tag][0..6] == 'sha256:'
89+
manifest = Katello::DockerManifestList.where(digest: params[:tag]).first || Katello::DockerManifest.where(digest: params[:tag]).first
90+
not_found params[:tag] unless manifest
91+
else
92+
tag = DockerMetaTag.where(repository_id: @repository.id, name: params[:tag]).first
93+
not_found params[:tag] unless tag
94+
end
95+
end
96+
97+
true
98+
end
99+
100+
def token
101+
personal_token = PersonalAccessToken.where(user_id: User.current.id, name: 'registry').first
102+
if personal_token.nil?
103+
personal_token = PersonalAccessToken.new(user: User.current, name: 'registry', expires_at: 6.minutes.from_now)
104+
personal_token.generate_token
105+
personal_token.save!
106+
else
107+
personal_token.expires_at = 6.minutes.from_now
108+
personal_token.save!
109+
end
110+
response.headers['Docker-Distribution-API-Version'] = 'registry/2.0'
111+
render json: { token: personal_token.token, expires_at: personal_token.expires_at, issued_at: personal_token.created_at }
112+
end
113+
114+
def pull_manifest
115+
headers = {}
116+
env = request.env.select do |key, _value|
117+
key.match("^HTTP.*")
118+
end
119+
env.each do |header|
120+
headers[header[0].split('_')[1..-1].join('-')] = header[1]
121+
end
122+
123+
r = Resources::Registry::Proxy.get(@_request.fullpath, headers)
124+
logger.debug r
125+
render json: r
126+
end
127+
128+
def check_blob
129+
begin
130+
r = Resources::Registry::Proxy.get(@_request.fullpath, 'Accept' => request.headers['Accept'])
131+
response.header['Content-Length'] = "#{r.body.size}"
132+
rescue RestClient::NotFound
133+
tmp_dir = "#{Rails.root}/tmp/registry_upload"
134+
digest_file = "#{tmp_dir}/#{params[:digest][7..-1]}.tar"
135+
raise unless File.exist? digest_file
136+
response.header['Content-Length'] = "#{File.size digest_file}"
137+
end
138+
render json: {}
139+
end
140+
141+
def pull_blob
142+
r = Resources::Registry::Proxy.get(@_request.fullpath, 'Accept' => request.headers['Accept'])
143+
render json: r
144+
end
145+
146+
def push_manifest
147+
repository = params[:repository]
148+
tag = params[:tag]
149+
manifest = request.body.read
150+
File.open(tmp_file('manifest.json'), 'wb', 0600) do |file|
151+
file.write manifest
152+
end
153+
manifest = JSON.parse(manifest)
154+
155+
files = get_manifest_files(manifest)
156+
return if files.nil?
157+
158+
tar_file = create_tar_file(files, repository, tag)
159+
return if tar_file.nil?
160+
161+
digest = upload_manifest(tar_file)
162+
return if digest.nil?
163+
164+
tag = upload_tag(digest, tag)
165+
return if tag.nil?
166+
167+
render json: {}
168+
end
169+
170+
def pulp_content
171+
Katello.pulp_server.resources.content
172+
end
173+
174+
def start_upload_blob
175+
tmp_dir = "#{Rails.root}/tmp/registry_upload"
176+
Dir.mkdir(tmp_dir, mode: 0700) unless Dir.exist? tmp_dir
177+
178+
uuid = SecureRandom.hex(16)
179+
response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/uploads/#{uuid}"
180+
response.header['Docker-Upload-UUID'] = uuid
181+
response.header['Range'] = '0-0'
182+
head 202
183+
end
184+
185+
def status_upload_blob
186+
response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/uploads/#{params[:uuid]}"
187+
response.header['Range'] = "123"
188+
response.header['Docker-Upload-UUID'] = "123"
189+
render plain: '', status: 204
190+
end
191+
192+
def chunk_upload_blob
193+
response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/uploads/#{params[:uuid]}"
194+
render plain: '', status: 202
195+
end
196+
197+
def upload_blob
198+
File.open("#{Rails.root}/tmp/registry_upload/#{params[:uuid]}.tar", 'ab', 0600) do |file|
199+
file.write request.body.read
200+
end
201+
202+
# ???? true chunked data?
203+
if request.headers['Content-Range']
204+
render_error 'unprocessable_entity', :status => :unprocessable_entity
205+
end
206+
207+
response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/uploads/#{params[:uuid]}"
208+
response.header['Range'] = "1-#{request.body.size}"
209+
response.header['Docker-Upload-UUID'] = params[:uuid]
210+
head 204
211+
end
212+
213+
def finish_upload_blob
214+
# error by client if no params[:digest]
215+
216+
tmp_dir = "#{Rails.root}/tmp/registry_upload"
217+
uuid_file = "#{tmp_dir}/#{params[:uuid]}.tar"
218+
digest_file = "#{tmp_dir}/#{params[:digest][7..-1]}.tar"
219+
if File.exist? digest_file
220+
File.unlink(uuid_file)
221+
else
222+
File.rename(uuid_file, digest_file)
223+
end
224+
response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/#{params[:digest]}"
225+
response.header['Docker-Content-Digest'] = params[:digest]
226+
response.header['Content-Range'] = "1-#{File.size(digest_file)}"
227+
response.header['Content-Length'] = "0"
228+
response.header['Docker-Upload-UUID'] = params[:uuid]
229+
head 201
230+
end
231+
232+
def cancel_upload_blob
233+
render plain: '', status: 200
234+
end
235+
236+
def ping
237+
response.headers['Docker-Distribution-API-Version'] = 'registry/2.0'
238+
render json: {}, status: 200
239+
end
240+
241+
def v1_ping
242+
head 200
243+
end
244+
245+
def v1_search
246+
options = {
247+
resource_class: Katello::Repository
248+
}
249+
params[:per_page] = params[:n] || 25
250+
params[:search] = params[:q]
251+
search_results = scoped_search(readable_repositories.where(content_type: 'docker').distinct,
252+
:container_repository_name, :asc, options)
253+
results = {
254+
num_results: search_results[:subtotal],
255+
query: params[:search]
256+
}
257+
results[:results] = search_results[:results].collect do |repository|
258+
{ name: repository[:container_repository_name], description: repository[:description] }
259+
end
260+
render json: results, status: 200
261+
end
262+
263+
def catalog
264+
repositories = readable_repositories.where(content_type: 'docker').collect do |repository|
265+
repository.container_repository_name
266+
end
267+
render json: { repositories: repositories }
268+
end
269+
270+
def tags_list
271+
tags = @repository.docker_tags.collect do |tag|
272+
tag.name
273+
end
274+
tags.uniq!
275+
tags.sort!
276+
render json: {
277+
name: @repository.container_repository_name,
278+
tags: tags
279+
}
280+
end
281+
282+
def get_manifest_files(manifest)
283+
files = ['manifest.json']
284+
if manifest['schemaVersion'] == 1
285+
if manifest['fsLayers']
286+
files += manifest['fsLayers'].collect do |layer|
287+
layerfile = "#{layer['blobSum'][7..-1]}.tar"
288+
force_include_layer(repository, layer['blobSum'], layerfile)
289+
layerfile
290+
end
291+
end
292+
elsif manifest['schemaVersion'] == 2
293+
if manifest['layers']
294+
files += manifest['layers'].collect do |layer|
295+
layerfile = "#{layer['digest'][7..-1]}.tar"
296+
force_include_layer(repository, layer['digest'], layerfile)
297+
layerfile
298+
end
299+
end
300+
files << "#{manifest['config']['digest'][7..-1]}.tar"
301+
else
302+
render_error 'custom_error', :status => :internal_server_error,
303+
:locals => { :message => "Unsupported schema #{manifest['schemaVersion']}" }
304+
return nil
305+
end
306+
files
307+
end
308+
309+
def create_tar_file(files, repository, tag)
310+
tar_file = "#{repository}_#{tag}.tar"
311+
`/usr/bin/tar cf #{tmp_dir}/#{tar_file} -C #{tmp_dir} #{files.join(' ')}`
312+
313+
files.each do |file|
314+
filename = "#{tmp_dir}/#{file}"
315+
File.delete(filename) if File.exist? filename
316+
end
317+
tar_file
318+
end
319+
320+
def upload_manifest(tar_file)
321+
upload_id = pulp_content.create_upload_request['upload_id']
322+
filename = "#{tmp_dir}/#{tar_file}"
323+
File.open(filename, 'rb') do |file|
324+
pulp_content.upload_bits(upload_id, 0, file.read)
325+
326+
file.rewind
327+
content = file.read
328+
unit_keys = [{
329+
name: filename,
330+
size: file.size,
331+
checksum: Digest::SHA256.hexdigest(content)
332+
}]
333+
unit_type_id = 'docker_manifest'
334+
task = sync_task(::Actions::Katello::Repository::ImportUpload,
335+
@repository, [upload_id], :unit_type_id => unit_type_id,
336+
:unit_keys => unit_keys,
337+
:generate_metadata => true, :sync_capsule => true)
338+
digest = task.output['upload_results'][0]['digest']
339+
340+
File.delete(filename)
341+
342+
digest
343+
end
344+
ensure
345+
pulp_content.delete_upload_request(upload_id) if upload_id
346+
end
347+
348+
def upload_tag(digest, tag)
349+
upload_id = pulp_content.create_upload_request['upload_id']
350+
unit_keys = [{
351+
name: tag,
352+
digest: digest
353+
}]
354+
unit_type_id = 'docker_tag'
355+
sync_task(::Actions::Katello::Repository::ImportUpload,
356+
@repository, [upload_id], :unit_type_id => unit_type_id,
357+
:unit_keys => unit_keys,
358+
:generate_metadata => true, :sync_capsule => true)
359+
360+
tag
361+
ensure
362+
pulp_content.delete_upload_request(upload_id) if upload_id
363+
end
364+
365+
def tmp_dir
366+
"#{Rails.root}/tmp/registry_upload"
367+
end
368+
369+
def tmp_file(filename)
370+
File.join(tmp_dir, filename)
371+
end
372+
373+
# TODO: Until pulp supports optional upload of layers, include all layers
374+
# https://pulp.plan.io/issues/3497
375+
def force_include_layer(repository, digest, layer)
376+
unless File.exist? tmp_file(layer)
377+
logger.debug "Getting blob #{digest} to write to #{layer}"
378+
fullpath = "/v2/#{repository}/blobs/#{digest}"
379+
request = Resources::Registry::Proxy.get(fullpath)
380+
File.open(tmp_file(layer), 'wb', 0600) do |file|
381+
file.write request.body
382+
end
383+
logger.debug "Wrote blob #{digest} to #{layer}"
384+
end
385+
end
386+
387+
def disable_strong_params
388+
params.permit!
389+
end
390+
391+
def request_url
392+
request.protocol + request.host_with_port
393+
end
394+
395+
def logger
396+
::Foreman::Logging.logger('katello/registry_proxy')
397+
end
398+
end
399+
end

0 commit comments

Comments
 (0)