Commit a6c0e0c9 authored by Markus Koller's avatar Markus Koller

Merge branch '349741-registry-client-changes' into 'master'

Add support for the gitlab container registry API

See merge request gitlab-org/gitlab!78452
parents 6e7d0fe0 f4504543
......@@ -23,7 +23,7 @@ class ContainerRepository < ApplicationRecord
enum expiration_policy_cleanup_status: { cleanup_unscheduled: 0, cleanup_scheduled: 1, cleanup_unfinished: 2, cleanup_ongoing: 3 }
enum migration_skipped_reason: { not_in_plan: 0, too_many_retries: 1, too_many_tags: 2, root_namespace_in_deny_list: 3 }
delegate :client, to: :registry
delegate :client, :gitlab_api_client, to: :registry
scope :ordered, -> { order(:name) }
scope :with_api_entity_associations, -> { preload(project: [:route, { namespace: :route }]) }
......@@ -150,6 +150,18 @@ class ContainerRepository < ApplicationRecord
migration_state == 'importing'
end
def migration_pre_import
return :error unless gitlab_api_client.supports_gitlab_api?
gitlab_api_client.pre_import_repository(self.path)
end
def migration_import
return :error unless gitlab_api_client.supports_gitlab_api?
gitlab_api_client.import_repository(self.path)
end
def self.build_from_path(path)
self.new(project: path.repository_project,
name: path.repository_name)
......
......@@ -15,6 +15,12 @@ class UpdateContainerRegistryInfoService
client = ContainerRegistry::Client.new(registry_config.api_url, token: token)
info = client.registry_info
gitlab_api_client = ContainerRegistry::GitlabApiClient.new(registry_config.api_url, token: token)
if gitlab_api_client.supports_gitlab_api?
info[:features] ||= []
info[:features] << ContainerRegistry::GitlabApiClient::REGISTRY_GITLAB_V1_API_FEATURE
end
Gitlab::CurrentSettings.update!(
container_registry_vendor: info[:vendor] || '',
container_registry_version: info[:version] || '',
......
# frozen_string_literal: true
require 'faraday'
require 'faraday_middleware'
require 'digest'
module ContainerRegistry
class BaseClient
DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE = 'application/vnd.docker.distribution.manifest.v2+json'
DOCKER_DISTRIBUTION_MANIFEST_LIST_V2_TYPE = 'application/vnd.docker.distribution.manifest.list.v2+json'
OCI_MANIFEST_V1_TYPE = 'application/vnd.oci.image.manifest.v1+json'
CONTAINER_IMAGE_V1_TYPE = 'application/vnd.docker.container.image.v1+json'
ACCEPTED_TYPES = [DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE, OCI_MANIFEST_V1_TYPE].freeze
ACCEPTED_TYPES_RAW = [DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE, OCI_MANIFEST_V1_TYPE, DOCKER_DISTRIBUTION_MANIFEST_LIST_V2_TYPE].freeze
RETRY_EXCEPTIONS = [Faraday::Request::Retry::DEFAULT_EXCEPTIONS, Faraday::ConnectionFailed].flatten.freeze
RETRY_OPTIONS = {
max: 1,
interval: 5,
exceptions: RETRY_EXCEPTIONS
}.freeze
ERROR_CALLBACK_OPTIONS = {
callback: -> (env, exception) do
Gitlab::ErrorTracking.log_exception(
exception,
class: name,
url: env[:url]
)
end
}.freeze
# Taken from: FaradayMiddleware::FollowRedirects
REDIRECT_CODES = Set.new [301, 302, 303, 307]
class << self
private
def with_dummy_client(return_value_if_disabled: nil)
registry_config = Gitlab.config.registry
unless registry_config.enabled && registry_config.api_url.present?
return return_value_if_disabled
end
token = Auth::ContainerRegistryAuthenticationService.access_token([], [])
yield new(registry_config.api_url, token: token)
end
end
def initialize(base_uri, options = {})
@base_uri = base_uri
@options = options
end
private
def faraday(timeout_enabled: true)
@faraday ||= faraday_base(timeout_enabled: timeout_enabled) do |conn|
initialize_connection(conn, @options, &method(:accept_manifest))
end
end
def faraday_base(timeout_enabled: true, &block)
request_options = timeout_enabled ? Gitlab::HTTP::DEFAULT_TIMEOUT_OPTIONS : nil
Faraday.new(
@base_uri,
headers: { user_agent: "GitLab/#{Gitlab::VERSION}" },
request: request_options,
&block
)
end
def initialize_connection(conn, options)
conn.request :json
if options[:user] && options[:password]
conn.request(:basic_auth, options[:user].to_s, options[:password].to_s)
elsif options[:token]
conn.request(:authorization, :bearer, options[:token].to_s)
end
yield(conn) if block_given?
conn.request(:retry, RETRY_OPTIONS)
conn.request(:gitlab_error_callback, ERROR_CALLBACK_OPTIONS)
conn.adapter :net_http
end
def response_body(response, allow_redirect: false)
if allow_redirect && REDIRECT_CODES.include?(response.status)
response = redirect_response(response.headers['location'])
end
response.body if response && response.success?
end
def redirect_response(location)
return unless location
uri = URI(@base_uri).merge(location)
raise ArgumentError, "Invalid scheme for #{location}" unless %w[http https].include?(uri.scheme)
faraday_redirect.get(uri)
end
def accept_manifest(conn)
conn.headers['Accept'] = ACCEPTED_TYPES
conn.response :json, content_type: 'application/json'
conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+prettyjws'
conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+json'
conn.response :json, content_type: DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE
conn.response :json, content_type: OCI_MANIFEST_V1_TYPE
end
# Create a new request to make sure the Authorization header is not inserted
# via the Faraday middleware
def faraday_redirect
@faraday_redirect ||= faraday_base do |conn|
conn.request :json
conn.request(:retry, RETRY_OPTIONS)
conn.request(:gitlab_error_callback, ERROR_CALLBACK_OPTIONS)
conn.adapter :net_http
end
end
def delete_if_exists(path)
result = faraday.delete(path)
result.success? || result.status == 404
end
end
end
# frozen_string_literal: true
require 'faraday'
require 'faraday_middleware'
require 'digest'
module ContainerRegistry
class Client
class Client < BaseClient
include Gitlab::Utils::StrongMemoize
attr_accessor :uri
DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE = 'application/vnd.docker.distribution.manifest.v2+json'
DOCKER_DISTRIBUTION_MANIFEST_LIST_V2_TYPE = 'application/vnd.docker.distribution.manifest.list.v2+json'
OCI_MANIFEST_V1_TYPE = 'application/vnd.oci.image.manifest.v1+json'
CONTAINER_IMAGE_V1_TYPE = 'application/vnd.docker.container.image.v1+json'
REGISTRY_VERSION_HEADER = 'gitlab-container-registry-version'
REGISTRY_FEATURES_HEADER = 'gitlab-container-registry-features'
REGISTRY_TAG_DELETE_FEATURE = 'tag_delete'
ACCEPTED_TYPES = [DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE, OCI_MANIFEST_V1_TYPE].freeze
ACCEPTED_TYPES_RAW = [DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE, OCI_MANIFEST_V1_TYPE, DOCKER_DISTRIBUTION_MANIFEST_LIST_V2_TYPE].freeze
# Taken from: FaradayMiddleware::FollowRedirects
REDIRECT_CODES = Set.new [301, 302, 303, 307]
RETRY_EXCEPTIONS = [Faraday::Request::Retry::DEFAULT_EXCEPTIONS, Faraday::ConnectionFailed].flatten.freeze
RETRY_OPTIONS = {
max: 1,
interval: 5,
exceptions: RETRY_EXCEPTIONS
}.freeze
ERROR_CALLBACK_OPTIONS = {
callback: -> (env, exception) do
Gitlab::ErrorTracking.log_exception(
exception,
class: name,
url: env[:url]
)
end
}.freeze
def self.supports_tag_delete?
registry_config = Gitlab.config.registry
return false unless registry_config.enabled && registry_config.api_url.present?
token = Auth::ContainerRegistryAuthenticationService.access_token([], [])
client = new(registry_config.api_url, token: token)
client.supports_tag_delete?
with_dummy_client(return_value_if_disabled: false) do |client|
client.supports_tag_delete?
end
end
def self.registry_info
registry_config = Gitlab.config.registry
return unless registry_config.enabled && registry_config.api_url.present?
token = Auth::ContainerRegistryAuthenticationService.access_token([], [])
client = new(registry_config.api_url, token: token)
client.registry_info
end
def initialize(base_uri, options = {})
@base_uri = base_uri
@options = options
with_dummy_client do |client|
client.registry_info
end
end
def registry_info
......@@ -176,89 +133,11 @@ module ContainerRegistry
private
def initialize_connection(conn, options)
conn.request :json
if options[:user] && options[:password]
conn.request(:basic_auth, options[:user].to_s, options[:password].to_s)
elsif options[:token]
conn.request(:authorization, :bearer, options[:token].to_s)
end
yield(conn) if block_given?
conn.request(:retry, RETRY_OPTIONS)
conn.request(:gitlab_error_callback, ERROR_CALLBACK_OPTIONS)
conn.adapter :net_http
end
def accept_manifest(conn)
conn.headers['Accept'] = ACCEPTED_TYPES
conn.response :json, content_type: 'application/json'
conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+prettyjws'
conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+json'
conn.response :json, content_type: DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE
conn.response :json, content_type: OCI_MANIFEST_V1_TYPE
end
def response_body(response, allow_redirect: false)
if allow_redirect && REDIRECT_CODES.include?(response.status)
response = redirect_response(response.headers['location'])
end
response.body if response && response.success?
end
def redirect_response(location)
return unless location
uri = URI(@base_uri).merge(location)
raise ArgumentError, "Invalid scheme for #{location}" unless %w[http https].include?(uri.scheme)
faraday_redirect.get(uri)
end
def faraday(timeout_enabled: true)
@faraday ||= faraday_base(timeout_enabled: timeout_enabled) do |conn|
initialize_connection(conn, @options, &method(:accept_manifest))
end
end
def faraday_blob
@faraday_blob ||= faraday_base do |conn|
initialize_connection(conn, @options)
end
end
# Create a new request to make sure the Authorization header is not inserted
# via the Faraday middleware
def faraday_redirect
@faraday_redirect ||= faraday_base do |conn|
conn.request :json
conn.request(:retry, RETRY_OPTIONS)
conn.request(:gitlab_error_callback, ERROR_CALLBACK_OPTIONS)
conn.adapter :net_http
end
end
def faraday_base(timeout_enabled: true, &block)
request_options = timeout_enabled ? Gitlab::HTTP::DEFAULT_TIMEOUT_OPTIONS : nil
Faraday.new(
@base_uri,
headers: { user_agent: "GitLab/#{Gitlab::VERSION}" },
request: request_options,
&block
)
end
def delete_if_exists(path)
result = faraday.delete(path)
result.success? || result.status == 404
end
end
end
......
# frozen_string_literal: true
module ContainerRegistry
class GitlabApiClient < BaseClient
include Gitlab::Utils::StrongMemoize
IMPORT_RESPONSES = {
200 => :already_imported,
202 => :ok,
401 => :unauthorized,
404 => :not_found,
409 => :already_being_imported,
424 => :pre_import_failed,
425 => :already_being_imported,
429 => :too_many_imports
}.freeze
REGISTRY_GITLAB_V1_API_FEATURE = 'gitlab_v1_api'
def self.supports_gitlab_api?
with_dummy_client(return_value_if_disabled: false) do |client|
client.supports_gitlab_api?
end
end
def supports_gitlab_api?
strong_memoize(:supports_gitlab_api) do
registry_features = Gitlab::CurrentSettings.container_registry_features || []
next true if ::Gitlab.com? && registry_features.include?(REGISTRY_GITLAB_V1_API_FEATURE)
response = faraday.get('/gitlab/v1/')
response.success? || response.status == 401
end
end
def pre_import_repository(path)
response = start_import_for(path, pre: true)
IMPORT_RESPONSES.fetch(response.status, :error)
end
def import_repository(path)
response = start_import_for(path, pre: false)
IMPORT_RESPONSES.fetch(response.status, :error)
end
private
def start_import_for(path, pre:)
faraday.put("/gitlab/v1/import/#{path}") do |req|
req.params['pre'] = pre.to_s
end
end
end
end
......@@ -2,12 +2,21 @@
module ContainerRegistry
class Registry
include Gitlab::Utils::StrongMemoize
attr_reader :uri, :client, :path
def initialize(uri, options = {})
@uri = uri
@path = options[:path] || default_path
@client = ContainerRegistry::Client.new(uri, options)
@options = options
@path = @options[:path] || default_path
@client = ContainerRegistry::Client.new(@uri, @options)
end
def gitlab_api_client
strong_memoize(:gitlab_api_client) do
ContainerRegistry::GitlabApiClient.new(@uri, @options)
end
end
private
......
This diff is collapsed.
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ContainerRegistry::GitlabApiClient do
using RSpec::Parameterized::TableSyntax
include_context 'container registry client'
describe '#supports_gitlab_api?' do
subject { client.supports_gitlab_api? }
where(:registry_gitlab_api_enabled, :is_on_dot_com, :container_registry_features, :expect_registry_to_be_pinged, :expected_result) do
false | true | [described_class::REGISTRY_GITLAB_V1_API_FEATURE] | false | true
true | false | [described_class::REGISTRY_GITLAB_V1_API_FEATURE] | true | true
true | true | [] | true | true
true | false | [] | true | true
false | true | [described_class::REGISTRY_GITLAB_V1_API_FEATURE] | false | true
false | false | [described_class::REGISTRY_GITLAB_V1_API_FEATURE] | true | false
false | true | [] | true | false
false | false | [] | true | false
end
with_them do
before do
allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
stub_registry_gitlab_api_support(registry_gitlab_api_enabled)
stub_application_setting(container_registry_features: container_registry_features)
end
it 'returns the expected result' do
if expect_registry_to_be_pinged
expect_next_instance_of(Faraday::Connection) do |connection|
expect(connection).to receive(:run_request).and_call_original
end
else
expect(Faraday::Connection).not_to receive(:new)
end
expect(subject).to be expected_result
end
end
context 'with 401 response' do
before do
allow(::Gitlab).to receive(:com?).and_return(false)
stub_application_setting(container_registry_features: [])
stub_request(:get, "#{registry_api_url}/gitlab/v1/")
.to_return(status: 401, body: '')
end
it { is_expected.to be_truthy }
end
end
describe '#pre_import_repository' do
let(:path) { 'namespace/path/to/repository' }
subject { client.pre_import_repository('namespace/path/to/repository') }
where(:status_code, :expected_result) do
200 | :already_imported
202 | :ok
401 | :unauthorized
404 | :not_found
409 | :already_being_imported
418 | :error
424 | :pre_import_failed
425 | :already_being_imported
429 | :too_many_imports
end
with_them do
before do
stub_pre_import(path, status_code, pre: true)
end
it { is_expected.to eq(expected_result) }
end
end
describe '#pre_import_repository' do
let(:path) { 'namespace/path/to/repository' }
subject { client.import_repository('namespace/path/to/repository') }
where(:status_code, :expected_result) do
200 | :already_imported
202 | :ok
401 | :unauthorized
404 | :not_found
409 | :already_being_imported
418 | :error
424 | :pre_import_failed
425 | :already_being_imported
429 | :too_many_imports
end
with_them do
before do
stub_pre_import(path, status_code, pre: false)
end
it { is_expected.to eq(expected_result) }
end
end
describe '.supports_gitlab_api?' do
subject { described_class.supports_gitlab_api? }
where(:registry_gitlab_api_enabled, :is_on_dot_com, :container_registry_features, :expect_registry_to_be_pinged, :expected_result) do
true | true | [described_class::REGISTRY_GITLAB_V1_API_FEATURE] | false | true
true | false | [described_class::REGISTRY_GITLAB_V1_API_FEATURE] | true | true
false | true | [described_class::REGISTRY_GITLAB_V1_API_FEATURE] | false | true
false | false | [described_class::REGISTRY_GITLAB_V1_API_FEATURE] | true | false
true | true | [] | true | true
true | false | [] | true | true
false | true | [] | true | false
false | false | [] | true | false
end
with_them do
before do
allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
stub_container_registry_config(enabled: true, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
stub_registry_gitlab_api_support(registry_gitlab_api_enabled)
stub_application_setting(container_registry_features: container_registry_features)
end
it 'returns the expected result' do
if expect_registry_to_be_pinged
expect_next_instance_of(Faraday::Connection) do |connection|
expect(connection).to receive(:run_request).and_call_original
end
else
expect(Faraday::Connection).not_to receive(:new)
end
expect(subject).to be expected_result
end
end
context 'with the registry disabled' do
before do
stub_container_registry_config(enabled: false, api_url: 'http://sandbox.local', key: 'spec/fixtures/x509_certificate_pk.key')
end
it 'returns false' do
expect(Faraday::Connection).not_to receive(:new)
expect(subject).to be_falsey
end
end
context 'with a blank registry url' do
before do
stub_container_registry_config(enabled: true, api_url: '', key: 'spec/fixtures/x509_certificate_pk.key')
end
it 'returns false' do
expect(Faraday::Connection).not_to receive(:new)
expect(subject).to be_falsey
end
end
end
def stub_pre_import(path, status_code, pre:)
stub_request(:put, "#{registry_api_url}/gitlab/v1/import/#{path}?pre=#{pre}")
.to_return(status: status_code, body: '')
end
def stub_registry_gitlab_api_support(supported = true)
status_code = supported ? 200 : 404
stub_request(:get, "#{registry_api_url}/gitlab/v1/")
.to_return(status: status_code, body: '')
end
end
......@@ -27,4 +27,10 @@ RSpec.describe ContainerRegistry::Registry do
it { is_expected.to eq(path) }
end
end
describe '#gitlab_api_client' do
subject { registry.gitlab_api_client }
it { is_expected.to be_instance_of(ContainerRegistry::GitlabApiClient) }
end
end
......@@ -209,6 +209,46 @@ RSpec.describe ContainerRepository do
end
end
context 'registry migration' do
shared_examples 'handling the migration step' do |step|
let(:client_response) { :foobar }
before do
allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(true)
end
it 'returns the same response as the client' do
expect(repository.gitlab_api_client)
.to receive(step).with(repository.path).and_return(client_response)
expect(subject).to eq(client_response)
end
context 'when the gitlab_api feature is not supported' do
before do
allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(false)
end
it 'returns :error' do
expect(repository.gitlab_api_client).not_to receive(step)
expect(subject).to eq(:error)
end
end
end
describe '#migration_pre_import' do
subject { repository.migration_pre_import }
it_behaves_like 'handling the migration step', :pre_import_repository
end
describe '#migration_import' do
subject { repository.migration_import }
it_behaves_like 'handling the migration step', :import_repository
end
end
describe '.build_from_path' do
let(:registry_path) do
ContainerRegistry::Path.new(project.full_path + '/some/image')
......
......@@ -48,6 +48,7 @@ RSpec.describe UpdateContainerRegistryInfoService do
before do
stub_registry_info({})
stub_supports_gitlab_api(false)
end
it 'uses a token with no access permissions' do
......@@ -63,6 +64,7 @@ RSpec.describe UpdateContainerRegistryInfoService do
context 'when unabled to detect the container registry type' do
it 'sets the application settings to their defaults' do
stub_registry_info({})
stub_supports_gitlab_api(false)
subject
......@@ -76,20 +78,23 @@ RSpec.describe UpdateContainerRegistryInfoService do
context 'when able to detect the container registry type' do
context 'when using the GitLab container registry' do
it 'updates application settings accordingly' do
stub_registry_info(vendor: 'gitlab', version: '2.9.1-gitlab', features: %w[a,b,c])
stub_registry_info(vendor: 'gitlab', version: '2.9.1-gitlab', features: %w[a b c])
stub_supports_gitlab_api(true)
subject
application_settings.reload
expect(application_settings.container_registry_vendor).to eq('gitlab')
expect(application_settings.container_registry_version).to eq('2.9.1-gitlab')
expect(application_settings.container_registry_features).to eq(%w[a,b,c])
expect(application_settings.container_registry_features)
.to match_array(%W[a b c #{ContainerRegistry::GitlabApiClient::REGISTRY_GITLAB_V1_API_FEATURE}])
end
end
context 'when using a third-party container registry' do
it 'updates application settings accordingly' do
stub_registry_info(vendor: 'other', version: nil, features: nil)
stub_supports_gitlab_api(false)
subject
......@@ -112,4 +117,10 @@ RSpec.describe UpdateContainerRegistryInfoService do
allow(client).to receive(:registry_info).and_return(output)
end
end
def stub_supports_gitlab_api(output)
allow_next_instance_of(ContainerRegistry::GitlabApiClient) do |client|
allow(client).to receive(:supports_gitlab_api?).and_return(output)
end
end
end
# frozen_string_literal: true
RSpec.shared_context 'container registry client' do
let(:token) { '12345' }
let(:options) { { token: token } }
let(:registry_api_url) { 'http://container-registry' }
let(:client) { described_class.new(registry_api_url, options) }
let(:push_blob_headers) do
{
'Accept' => 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json',
'Authorization' => "bearer #{token}",
'Content-Type' => 'application/octet-stream',
'User-Agent' => "GitLab/#{Gitlab::VERSION}"
}
end
let(:headers_with_accept_types) do
{
'Accept' => 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json',
'Authorization' => "bearer #{token}",
'User-Agent' => "GitLab/#{Gitlab::VERSION}"
}
end
let(:expected_faraday_headers) { { user_agent: "GitLab/#{Gitlab::VERSION}" } }
let(:expected_faraday_request_options) { Gitlab::HTTP::DEFAULT_TIMEOUT_OPTIONS }
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment