|
| 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 = [] |
| 156 | + if manifest['schemaVersion'] == 1 |
| 157 | + if manifest['fsLayers'] |
| 158 | + files += manifest['fsLayers'].collect do |layer| |
| 159 | + layerfile = "#{layer['blobSum'][7..-1]}.tar" |
| 160 | + force_include_layer(repository, layer['blobSum'], layerfile) |
| 161 | + layerfile |
| 162 | + end |
| 163 | + end |
| 164 | + elsif manifest['schemaVersion'] == 2 |
| 165 | + if manifest['layers'] |
| 166 | + files += manifest['layers'].collect do |layer| |
| 167 | + layerfile = "#{layer['digest'][7..-1]}.tar" |
| 168 | + force_include_layer(repository, layer['digest'], layerfile) |
| 169 | + layerfile |
| 170 | + end |
| 171 | + end |
| 172 | + files << "#{manifest['config']['digest'][7..-1]}.tar" |
| 173 | + else |
| 174 | + render_error 'custom_error', :status => :internal_server_error, |
| 175 | + :locals => { :message => "Unsupported schema #{manifest['schemaVersion']}" } |
| 176 | + return |
| 177 | + end |
| 178 | + files << "manifest.json" |
| 179 | + |
| 180 | + tar_file = "#{repository}_#{tag}.tar" |
| 181 | + `/usr/bin/tar cf #{tmp_dir}/#{tar_file} -C #{tmp_dir} #{files.join(' ')}` |
| 182 | + |
| 183 | + files.each do |file| |
| 184 | + filename = "#{tmp_dir}/#{file}" |
| 185 | + File.delete(filename) if File.exist? filename |
| 186 | + end |
| 187 | + |
| 188 | + #???? change ::Actions::Katello::Repository::ImportUpload to allow both manifest |
| 189 | + # and tag types (:unit_keys param) so single call |
| 190 | + # MANIFEST |
| 191 | + digest = nil |
| 192 | + begin |
| 193 | + upload_id = pulp_content.create_upload_request['upload_id'] |
| 194 | + filename = "#{tmp_dir}/#{tar_file}" |
| 195 | + File.open(filename, 'rb') do |file| |
| 196 | + pulp_content.upload_bits(upload_id, 0, file.read) |
| 197 | + |
| 198 | + file.rewind |
| 199 | + content = file.read |
| 200 | + unit_keys = [{ |
| 201 | + name: filename, |
| 202 | + size: file.size, |
| 203 | + checksum: Digest::SHA256.hexdigest(content) |
| 204 | + }] |
| 205 | + unit_type_id = 'docker_manifest' |
| 206 | + sync_task(::Actions::Katello::Repository::ImportUpload, |
| 207 | + @repository, [upload_id], :unit_type_id => unit_type_id, |
| 208 | + :unit_keys => unit_keys, |
| 209 | + :generate_metadata => true, :sync_capsule => true) |
| 210 | + digest = task.output['upload_results'][0]['digest'] |
| 211 | + end |
| 212 | + rescue |
| 213 | + render_error 'custom_error', :status => :internal_server_error, |
| 214 | + :locals => { :message => 'Failed to upload manifest' } |
| 215 | + return |
| 216 | + ensure |
| 217 | + pulp_content.delete_upload_request(upload_id) if upload_id |
| 218 | + end |
| 219 | + |
| 220 | + # TAG |
| 221 | + begin |
| 222 | + upload_id = pulp_content.create_upload_request['upload_id'] |
| 223 | + unit_keys = [{ |
| 224 | + name: tag, |
| 225 | + digest: digest |
| 226 | + }] |
| 227 | + unit_type_id = 'docker_tag' |
| 228 | + sync_task(::Actions::Katello::Repository::ImportUpload, |
| 229 | + @repository, [upload_id], :unit_type_id => unit_type_id, |
| 230 | + :unit_keys => unit_keys, |
| 231 | + :generate_metadata => true, :sync_capsule => true) |
| 232 | + rescue |
| 233 | + render_error 'custom_error', :status => :internal_server_error, |
| 234 | + :locals => { :message => 'Failed to upload tag' } |
| 235 | + return |
| 236 | + ensure |
| 237 | + pulp_content.delete_upload_request(upload_id) if upload_id |
| 238 | + end |
| 239 | + |
| 240 | + File.delete("#{tmp_dir}/#{tar_file}") |
| 241 | + |
| 242 | + render json: {} |
| 243 | + end |
| 244 | + |
| 245 | + def pulp_content |
| 246 | + Katello.pulp_server.resources.content |
| 247 | + end |
| 248 | + |
| 249 | + def start_upload_blob |
| 250 | + tmp_dir = "#{Rails.root}/tmp/registry_upload" |
| 251 | + Dir.mkdir(tmp_dir, mode: 0700) unless Dir.exist? tmp_dir |
| 252 | + |
| 253 | + uuid = SecureRandom.hex(16) |
| 254 | + response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/uploads/#{uuid}" |
| 255 | + response.header['Docker-Upload-UUID'] = uuid |
| 256 | + response.header['Range'] = '0-0' |
| 257 | + head 202 |
| 258 | + end |
| 259 | + |
| 260 | + def status_upload_blob |
| 261 | + response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/uploads/#{params[:uuid]}" |
| 262 | + response.header['Range'] = "123" |
| 263 | + response.header['Docker-Upload-UUID'] = "123" |
| 264 | + render plain: '', status: 204 |
| 265 | + end |
| 266 | + |
| 267 | + def chunk_upload_blob |
| 268 | + response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/uploads/#{params[:uuid]}" |
| 269 | + render plain: '', status: 202 |
| 270 | + end |
| 271 | + |
| 272 | + def upload_blob |
| 273 | + File.open("#{Rails.root}/tmp/registry_upload/#{params[:uuid]}.tar", 'ab', 0600) do |file| |
| 274 | + file.write request.body.read |
| 275 | + end |
| 276 | + |
| 277 | + # ???? true chunked data? |
| 278 | + if request.headers['Content-Range'] |
| 279 | + render_error 'unprocessable_entity', :status => :unprocessable_entity |
| 280 | + end |
| 281 | + |
| 282 | + response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/uploads/#{params[:uuid]}" |
| 283 | + response.header['Range'] = "1-#{request.body.size}" |
| 284 | + response.header['Docker-Upload-UUID'] = params[:uuid] |
| 285 | + head 204 |
| 286 | + end |
| 287 | + |
| 288 | + def finish_upload_blob |
| 289 | + # error by client if no params[:digest] |
| 290 | + |
| 291 | + tmp_dir = "#{Rails.root}/tmp/registry_upload" |
| 292 | + uuid_file = "#{tmp_dir}/#{params[:uuid]}.tar" |
| 293 | + digest_file = "#{tmp_dir}/#{params[:digest][7..-1]}.tar" |
| 294 | + if File.exist? digest_file |
| 295 | + File.unlink(uuid_file) |
| 296 | + else |
| 297 | + File.rename(uuid_file, digest_file) |
| 298 | + end |
| 299 | + response.header['Location'] = "#{request_url}/v2/#{params[:repository]}/blobs/#{params[:digest]}" |
| 300 | + response.header['Docker-Content-Digest'] = params[:digest] |
| 301 | + response.header['Content-Range'] = "1-#{File.size(digest_file)}" |
| 302 | + response.header['Content-Length'] = "0" |
| 303 | + response.header['Docker-Upload-UUID'] = params[:uuid] |
| 304 | + head 201 |
| 305 | + end |
| 306 | + |
| 307 | + def cancel_upload_blob |
| 308 | + render plain: '', status: 200 |
| 309 | + end |
| 310 | + |
| 311 | + def ping |
| 312 | + response.headers['Docker-Distribution-API-Version'] = 'registry/2.0' |
| 313 | + render json: {}, status: 200 |
| 314 | + end |
| 315 | + |
| 316 | + def v1_ping |
| 317 | + head 200 |
| 318 | + end |
| 319 | + |
| 320 | + def v1_search |
| 321 | + options = { |
| 322 | + resource_class: Katello::Repository |
| 323 | + } |
| 324 | + params[:per_page] = params[:n] || 25 |
| 325 | + params[:search] = params[:q] |
| 326 | + search_results = scoped_search(readable_repositories.where(content_type: 'docker').distinct, |
| 327 | + :container_repository_name, :asc, options) |
| 328 | + results = { |
| 329 | + num_results: search_results[:subtotal], |
| 330 | + query: params[:search] |
| 331 | + } |
| 332 | + results[:results] = search_results[:results].collect do |repository| |
| 333 | + { name: repository[:container_repository_name], description: repository[:description] } |
| 334 | + end |
| 335 | + render json: results, status: 200 |
| 336 | + end |
| 337 | + |
| 338 | + def catalog |
| 339 | + repositories = readable_repositories.where(content_type: 'docker').collect do |repository| |
| 340 | + repository.container_repository_name |
| 341 | + end |
| 342 | + render json: { repositories: repositories } |
| 343 | + end |
| 344 | + |
| 345 | + def tags_list |
| 346 | + tags = @repository.docker_tags.collect do |tag| |
| 347 | + tag.name |
| 348 | + end |
| 349 | + tags.uniq! |
| 350 | + tags.sort! |
| 351 | + render json: { |
| 352 | + name: @repository.container_repository_name, |
| 353 | + tags: tags |
| 354 | + } |
| 355 | + end |
| 356 | + |
| 357 | + def tmp_dir |
| 358 | + "#{Rails.root}/tmp/registry_upload" |
| 359 | + end |
| 360 | + |
| 361 | + def tmp_file(filename) |
| 362 | + File.join(tmp_dir, filename) |
| 363 | + end |
| 364 | + |
| 365 | + # TODO: Until pulp supports optional upload of layers, include all layers |
| 366 | + # https://pulp.plan.io/issues/3497 |
| 367 | + def force_include_layer(repository, digest, layer) |
| 368 | + unless File.exist? tmp_file(layer) |
| 369 | + logger.debug "Getting blob #{digest} to write to #{layer}" |
| 370 | + fullpath = "/v2/#{repository}/blobs/#{digest}" |
| 371 | + request = Resources::Registry::Proxy.get(fullpath) |
| 372 | + File.open(tmp_file(layer), 'wb', 0600) do |file| |
| 373 | + file.write request.body |
| 374 | + end |
| 375 | + logger.debug "Wrote blob #{digest} to #{layer}" |
| 376 | + end |
| 377 | + end |
| 378 | + |
| 379 | + def disable_strong_params |
| 380 | + params.permit! |
| 381 | + end |
| 382 | + |
| 383 | + def request_url |
| 384 | + request.protocol + request.host_with_port |
| 385 | + end |
| 386 | + |
| 387 | + def logger |
| 388 | + ::Foreman::Logging.logger('katello/registry_proxy') |
| 389 | + end |
| 390 | + end |
| 391 | +end |
0 commit comments