Merge branch 'feature/multi-level-container-registry-images' into 'master'
Multi-level container registry images Closes #17801 See merge request !10109
This commit is contained in:
commit
d6caa9d734
|
@ -30,6 +30,7 @@ eslint-report.html
|
|||
/config/unicorn.rb
|
||||
/config/secrets.yml
|
||||
/config/sidekiq.yml
|
||||
/config/registry.key
|
||||
/coverage/*
|
||||
/coverage-javascript/
|
||||
/db/*.sqlite3
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Container Registry
|
||||
*/
|
||||
|
||||
.container-image {
|
||||
border-bottom: 1px solid $white-normal;
|
||||
}
|
||||
|
||||
.container-image-head {
|
||||
padding: 0 16px;
|
||||
line-height: 4em;
|
||||
}
|
||||
|
||||
.table.tags {
|
||||
margin-bottom: 0;
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
class Projects::ContainerRegistryController < Projects::ApplicationController
|
||||
before_action :verify_registry_enabled
|
||||
before_action :authorize_read_container_image!
|
||||
before_action :authorize_update_container_image!, only: [:destroy]
|
||||
layout 'project'
|
||||
|
||||
def index
|
||||
@tags = container_registry_repository.tags
|
||||
end
|
||||
|
||||
def destroy
|
||||
url = namespace_project_container_registry_index_path(project.namespace, project)
|
||||
|
||||
if tag.delete
|
||||
redirect_to url
|
||||
else
|
||||
redirect_to url, alert: 'Failed to remove tag'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_registry_enabled
|
||||
render_404 unless Gitlab.config.registry.enabled
|
||||
end
|
||||
|
||||
def container_registry_repository
|
||||
@container_registry_repository ||= project.container_registry_repository
|
||||
end
|
||||
|
||||
def tag
|
||||
@tag ||= container_registry_repository.tag(params[:id])
|
||||
end
|
||||
end
|
|
@ -0,0 +1,16 @@
|
|||
module Projects
|
||||
module Registry
|
||||
class ApplicationController < Projects::ApplicationController
|
||||
layout 'project'
|
||||
|
||||
before_action :verify_registry_enabled!
|
||||
before_action :authorize_read_container_image!
|
||||
|
||||
private
|
||||
|
||||
def verify_registry_enabled!
|
||||
render_404 unless Gitlab.config.registry.enabled
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,43 @@
|
|||
module Projects
|
||||
module Registry
|
||||
class RepositoriesController < ::Projects::Registry::ApplicationController
|
||||
before_action :authorize_update_container_image!, only: [:destroy]
|
||||
before_action :ensure_root_container_repository!, only: [:index]
|
||||
|
||||
def index
|
||||
@images = project.container_repositories
|
||||
end
|
||||
|
||||
def destroy
|
||||
if image.destroy
|
||||
redirect_to project_container_registry_path(@project),
|
||||
notice: 'Image repository has been removed successfully!'
|
||||
else
|
||||
redirect_to project_container_registry_path(@project),
|
||||
alert: 'Failed to remove image repository!'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def image
|
||||
@image ||= project.container_repositories.find(params[:id])
|
||||
end
|
||||
|
||||
##
|
||||
# Container repository object for root project path.
|
||||
#
|
||||
# Needed to maintain a backwards compatibility.
|
||||
#
|
||||
def ensure_root_container_repository!
|
||||
ContainerRegistry::Path.new(@project.full_path).tap do |path|
|
||||
break if path.has_repository?
|
||||
|
||||
ContainerRepository.build_from_path(path).tap do |repository|
|
||||
repository.save! if repository.has_tags?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,28 @@
|
|||
module Projects
|
||||
module Registry
|
||||
class TagsController < ::Projects::Registry::ApplicationController
|
||||
before_action :authorize_update_container_image!, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
if tag.delete
|
||||
redirect_to project_container_registry_path(@project),
|
||||
notice: 'Registry tag has been removed successfully!'
|
||||
else
|
||||
redirect_to project_container_registry_path(@project),
|
||||
alert: 'Failed to remove registry tag!'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def image
|
||||
@image ||= project.container_repositories
|
||||
.find(params[:repository_id])
|
||||
end
|
||||
|
||||
def tag
|
||||
@tag ||= image.tag(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,77 @@
|
|||
class ContainerRepository < ActiveRecord::Base
|
||||
belongs_to :project
|
||||
|
||||
validates :name, length: { minimum: 0, allow_nil: false }
|
||||
validates :name, uniqueness: { scope: :project_id }
|
||||
|
||||
delegate :client, to: :registry
|
||||
|
||||
before_destroy :delete_tags!
|
||||
|
||||
def registry
|
||||
@registry ||= begin
|
||||
token = Auth::ContainerRegistryAuthenticationService.full_access_token(path)
|
||||
|
||||
url = Gitlab.config.registry.api_url
|
||||
host_port = Gitlab.config.registry.host_port
|
||||
|
||||
ContainerRegistry::Registry.new(url, token: token, path: host_port)
|
||||
end
|
||||
end
|
||||
|
||||
def path
|
||||
@path ||= [project.full_path, name].select(&:present?).join('/')
|
||||
end
|
||||
|
||||
def tag(tag)
|
||||
ContainerRegistry::Tag.new(self, tag)
|
||||
end
|
||||
|
||||
def manifest
|
||||
@manifest ||= client.repository_tags(path)
|
||||
end
|
||||
|
||||
def tags
|
||||
return @tags if defined?(@tags)
|
||||
return [] unless manifest && manifest['tags']
|
||||
|
||||
@tags = manifest['tags'].map do |tag|
|
||||
ContainerRegistry::Tag.new(self, tag)
|
||||
end
|
||||
end
|
||||
|
||||
def blob(config)
|
||||
ContainerRegistry::Blob.new(self, config)
|
||||
end
|
||||
|
||||
def has_tags?
|
||||
tags.any?
|
||||
end
|
||||
|
||||
def root_repository?
|
||||
name.empty?
|
||||
end
|
||||
|
||||
def delete_tags!
|
||||
return unless has_tags?
|
||||
|
||||
digests = tags.map { |tag| tag.digest }.to_set
|
||||
|
||||
digests.all? do |digest|
|
||||
client.delete_repository_tag(self.path, digest)
|
||||
end
|
||||
end
|
||||
|
||||
def self.build_from_path(path)
|
||||
self.new(project: path.repository_project,
|
||||
name: path.repository_name)
|
||||
end
|
||||
|
||||
def self.create_from_path!(path)
|
||||
build_from_path(path).tap(&:save!)
|
||||
end
|
||||
|
||||
def self.build_root_repository(project)
|
||||
self.new(project: project, name: '')
|
||||
end
|
||||
end
|
|
@ -159,6 +159,7 @@ class Project < ActiveRecord::Base
|
|||
has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
|
||||
has_one :project_feature, dependent: :destroy
|
||||
has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
|
||||
has_many :container_repositories, dependent: :destroy
|
||||
|
||||
has_many :commit_statuses, dependent: :destroy
|
||||
has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline'
|
||||
|
@ -406,32 +407,15 @@ class Project < ActiveRecord::Base
|
|||
@repository ||= Repository.new(path_with_namespace, self)
|
||||
end
|
||||
|
||||
def container_registry_path_with_namespace
|
||||
path_with_namespace.downcase
|
||||
end
|
||||
|
||||
def container_registry_repository
|
||||
return unless Gitlab.config.registry.enabled
|
||||
|
||||
@container_registry_repository ||= begin
|
||||
token = Auth::ContainerRegistryAuthenticationService.full_access_token(container_registry_path_with_namespace)
|
||||
url = Gitlab.config.registry.api_url
|
||||
host_port = Gitlab.config.registry.host_port
|
||||
registry = ContainerRegistry::Registry.new(url, token: token, path: host_port)
|
||||
registry.repository(container_registry_path_with_namespace)
|
||||
end
|
||||
end
|
||||
|
||||
def container_registry_repository_url
|
||||
def container_registry_url
|
||||
if Gitlab.config.registry.enabled
|
||||
"#{Gitlab.config.registry.host_port}/#{container_registry_path_with_namespace}"
|
||||
"#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}"
|
||||
end
|
||||
end
|
||||
|
||||
def has_container_registry_tags?
|
||||
return unless container_registry_repository
|
||||
|
||||
container_registry_repository.tags.any?
|
||||
container_repositories.to_a.any?(&:has_tags?) ||
|
||||
has_root_container_repository_tags?
|
||||
end
|
||||
|
||||
def commit(ref = 'HEAD')
|
||||
|
@ -922,10 +906,10 @@ class Project < ActiveRecord::Base
|
|||
expire_caches_before_rename(old_path_with_namespace)
|
||||
|
||||
if has_container_registry_tags?
|
||||
Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present"
|
||||
Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!"
|
||||
|
||||
# we currently doesn't support renaming repository if it contains tags in container registry
|
||||
raise StandardError.new('Project cannot be renamed, because tags are present in its container registry')
|
||||
# we currently doesn't support renaming repository if it contains images in container registry
|
||||
raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
|
||||
end
|
||||
|
||||
if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
|
||||
|
@ -1272,7 +1256,7 @@ class Project < ActiveRecord::Base
|
|||
]
|
||||
|
||||
if container_registry_enabled?
|
||||
variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_repository_url, public: true }
|
||||
variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true }
|
||||
end
|
||||
|
||||
variables
|
||||
|
@ -1405,4 +1389,15 @@ class Project < ActiveRecord::Base
|
|||
|
||||
Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace)
|
||||
end
|
||||
|
||||
##
|
||||
# This method is here because of support for legacy container repository
|
||||
# which has exactly the same path like project does, but which might not be
|
||||
# persisted in `container_repositories` table.
|
||||
#
|
||||
def has_root_container_repository_tags?
|
||||
return false unless Gitlab.config.registry.enabled
|
||||
|
||||
ContainerRepository.build_root_repository(self).has_tags?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,6 +17,7 @@ module Auth
|
|||
end
|
||||
|
||||
def self.full_access_token(*names)
|
||||
names = names.flatten
|
||||
registry = Gitlab.config.registry
|
||||
token = JSONWebToken::RSAToken.new(registry.key)
|
||||
token.issuer = registry.issuer
|
||||
|
@ -37,13 +38,13 @@ module Auth
|
|||
private
|
||||
|
||||
def authorized_token(*accesses)
|
||||
token = JSONWebToken::RSAToken.new(registry.key)
|
||||
token.issuer = registry.issuer
|
||||
token.audience = params[:service]
|
||||
token.subject = current_user.try(:username)
|
||||
token.expire_time = self.class.token_expire_at
|
||||
token[:access] = accesses.compact
|
||||
token
|
||||
JSONWebToken::RSAToken.new(registry.key).tap do |token|
|
||||
token.issuer = registry.issuer
|
||||
token.audience = params[:service]
|
||||
token.subject = current_user.try(:username)
|
||||
token.expire_time = self.class.token_expire_at
|
||||
token[:access] = accesses.compact
|
||||
end
|
||||
end
|
||||
|
||||
def scope
|
||||
|
@ -55,20 +56,43 @@ module Auth
|
|||
def process_scope(scope)
|
||||
type, name, actions = scope.split(':', 3)
|
||||
actions = actions.split(',')
|
||||
path = ContainerRegistry::Path.new(name)
|
||||
|
||||
return unless type == 'repository'
|
||||
|
||||
process_repository_access(type, name, actions)
|
||||
process_repository_access(type, path, actions)
|
||||
end
|
||||
|
||||
def process_repository_access(type, name, actions)
|
||||
requested_project = Project.find_by_full_path(name)
|
||||
def process_repository_access(type, path, actions)
|
||||
return unless path.valid?
|
||||
|
||||
requested_project = path.repository_project
|
||||
|
||||
return unless requested_project
|
||||
|
||||
actions = actions.select do |action|
|
||||
can_access?(requested_project, action)
|
||||
end
|
||||
|
||||
{ type: type, name: name, actions: actions } if actions.present?
|
||||
return unless actions.present?
|
||||
|
||||
# At this point user/build is already authenticated.
|
||||
#
|
||||
ensure_container_repository!(path, actions)
|
||||
|
||||
{ type: type, name: path.to_s, actions: actions }
|
||||
end
|
||||
|
||||
##
|
||||
# Because we do not have two way communication with registry yet,
|
||||
# we create a container repository image resource when push to the
|
||||
# registry is successfuly authorized.
|
||||
#
|
||||
def ensure_container_repository!(path, actions)
|
||||
return if path.has_repository?
|
||||
return unless actions.include?('push')
|
||||
|
||||
ContainerRepository.create_from_path!(path)
|
||||
end
|
||||
|
||||
def can_access?(requested_project, requested_action)
|
||||
|
@ -101,6 +125,11 @@ module Auth
|
|||
can?(current_user, :read_container_image, requested_project)
|
||||
end
|
||||
|
||||
##
|
||||
# We still support legacy pipeline triggers which do not have associated
|
||||
# actor. New permissions model and new triggers are always associated with
|
||||
# an actor, so this should be improved in 10.0 version of GitLab.
|
||||
#
|
||||
def build_can_push?(requested_project)
|
||||
# Build can push only to the project from which it originates
|
||||
has_authentication_ability?(:build_create_container_image) &&
|
||||
|
@ -113,14 +142,11 @@ module Auth
|
|||
end
|
||||
|
||||
def error(code, status:, message: '')
|
||||
{
|
||||
errors: [{ code: code, message: message }],
|
||||
http_status: status
|
||||
}
|
||||
{ errors: [{ code: code, message: message }], http_status: status }
|
||||
end
|
||||
|
||||
def has_authentication_ability?(capability)
|
||||
(@authentication_abilities || []).include?(capability)
|
||||
@authentication_abilities.to_a.include?(capability)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -31,16 +31,16 @@ module Projects
|
|||
project.team.truncate
|
||||
project.destroy!
|
||||
|
||||
unless remove_registry_tags
|
||||
raise_error('Failed to remove project container registry. Please try again or contact administrator')
|
||||
unless remove_legacy_registry_tags
|
||||
raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
|
||||
end
|
||||
|
||||
unless remove_repository(repo_path)
|
||||
raise_error('Failed to remove project repository. Please try again or contact administrator')
|
||||
raise_error('Failed to remove project repository. Please try again or contact administrator.')
|
||||
end
|
||||
|
||||
unless remove_repository(wiki_path)
|
||||
raise_error('Failed to remove wiki repository. Please try again or contact administrator')
|
||||
raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -68,10 +68,16 @@ module Projects
|
|||
end
|
||||
end
|
||||
|
||||
def remove_registry_tags
|
||||
##
|
||||
# This method makes sure that we correctly remove registry tags
|
||||
# for legacy image repository (when repository path equals project path).
|
||||
#
|
||||
def remove_legacy_registry_tags
|
||||
return true unless Gitlab.config.registry.enabled
|
||||
|
||||
project.container_registry_repository.delete_tags
|
||||
ContainerRepository.build_root_repository(project).tap do |repository|
|
||||
return repository.has_tags? ? repository.delete_tags! : true
|
||||
end
|
||||
end
|
||||
|
||||
def raise_error(message)
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
.container-image.js-toggle-container
|
||||
.container-image-head
|
||||
= link_to "#", class: "js-toggle-button" do
|
||||
= icon('chevron-down', 'aria-hidden': 'true')
|
||||
= escape_once(image.path)
|
||||
|
||||
= clipboard_button(clipboard_text: "docker pull #{image.path}")
|
||||
|
||||
.controls.hidden-xs.pull-right
|
||||
= link_to namespace_project_container_registry_path(@project.namespace, @project, image),
|
||||
class: 'btn btn-remove has-tooltip',
|
||||
title: 'Remove repository',
|
||||
data: { confirm: 'Are you sure?' },
|
||||
method: :delete do
|
||||
= icon('trash cred', 'aria-hidden': 'true')
|
||||
|
||||
.container-image-tags.js-toggle-content.hide
|
||||
- if image.has_tags?
|
||||
.table-holder
|
||||
%table.table.tags
|
||||
%thead
|
||||
%tr
|
||||
%th Tag
|
||||
%th Tag ID
|
||||
%th Size
|
||||
%th Created
|
||||
- if can?(current_user, :update_container_image, @project)
|
||||
%th
|
||||
= render partial: 'tag', collection: image.tags
|
||||
- else
|
||||
.nothing-here-block No tags in Container Registry for this container image.
|
||||
|
|
@ -25,5 +25,9 @@
|
|||
- if can?(current_user, :update_container_image, @project)
|
||||
%td.content
|
||||
.controls.hidden-xs.pull-right
|
||||
= link_to namespace_project_container_registry_path(@project.namespace, @project, tag.name), class: 'btn btn-remove has-tooltip', title: "Remove", data: { confirm: "Are you sure?" }, method: :delete do
|
||||
= icon("trash cred")
|
||||
= link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name),
|
||||
method: :delete,
|
||||
class: 'btn btn-remove has-tooltip',
|
||||
title: 'Remove tag',
|
||||
data: { confirm: 'Are you sure you want to delete this tag?' } do
|
||||
= icon('trash cred')
|
|
@ -15,25 +15,12 @@
|
|||
%br
|
||||
Then you are free to create and upload a container image with build and push commands:
|
||||
%pre
|
||||
docker build -t #{escape_once(@project.container_registry_repository_url)} .
|
||||
docker build -t #{escape_once(@project.container_registry_url)}/image .
|
||||
%br
|
||||
docker push #{escape_once(@project.container_registry_repository_url)}
|
||||
docker push #{escape_once(@project.container_registry_url)}/image
|
||||
|
||||
- if @tags.blank?
|
||||
%li
|
||||
.nothing-here-block No images in Container Registry for this project.
|
||||
- if @images.blank?
|
||||
.nothing-here-block No container image repositories in Container Registry for this project.
|
||||
|
||||
- else
|
||||
.table-holder
|
||||
%table.table.tags
|
||||
%thead
|
||||
%tr
|
||||
%th Name
|
||||
%th Image ID
|
||||
%th Size
|
||||
%th Created
|
||||
- if can?(current_user, :update_container_image, @project)
|
||||
%th
|
||||
|
||||
- @tags.each do |tag|
|
||||
= render 'tag', tag: tag
|
||||
= render partial: 'image', collection: @images
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Add support for multi-level container image repository names
|
||||
merge_request: 10109
|
||||
author: André Guede
|
|
@ -221,7 +221,15 @@ constraints(ProjectUrlConstrainer.new) do
|
|||
end
|
||||
end
|
||||
|
||||
resources :container_registry, only: [:index, :destroy], constraints: { id: Gitlab::Regex.container_registry_reference_regex }
|
||||
resources :container_registry, only: [:index, :destroy],
|
||||
controller: 'registry/repositories'
|
||||
|
||||
namespace :registry do
|
||||
resources :repository, only: [] do
|
||||
resources :tags, only: [:destroy],
|
||||
constraints: { id: Gitlab::Regex.container_registry_reference_regex }
|
||||
end
|
||||
end
|
||||
|
||||
resources :milestones, constraints: { id: /\d+/ } do
|
||||
member do
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
class CreateContainerRepository < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def change
|
||||
create_table :container_repositories do |t|
|
||||
t.references :project, foreign_key: true, index: true, null: false
|
||||
t.string :name, null: false
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
|
||||
add_index :container_repositories, [:project_id, :name], unique: true
|
||||
end
|
||||
end
|
11
db/schema.rb
11
db/schema.rb
|
@ -323,6 +323,16 @@ ActiveRecord::Schema.define(version: 20170405080720) do
|
|||
|
||||
add_index "ci_variables", ["project_id"], name: "index_ci_variables_on_project_id", using: :btree
|
||||
|
||||
create_table "container_repositories", force: :cascade do |t|
|
||||
t.integer "project_id", null: false
|
||||
t.string "name", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
add_index "container_repositories", ["project_id", "name"], name: "index_container_repositories_on_project_id_and_name", unique: true, using: :btree
|
||||
add_index "container_repositories", ["project_id"], name: "index_container_repositories_on_project_id", using: :btree
|
||||
|
||||
create_table "deploy_keys_projects", force: :cascade do |t|
|
||||
t.integer "deploy_key_id", null: false
|
||||
t.integer "project_id", null: false
|
||||
|
@ -1304,6 +1314,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
|
|||
add_foreign_key "boards", "projects"
|
||||
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
|
||||
add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade
|
||||
add_foreign_key "container_repositories", "projects"
|
||||
add_foreign_key "issue_metrics", "issues", on_delete: :cascade
|
||||
add_foreign_key "label_priorities", "labels", on_delete: :cascade
|
||||
add_foreign_key "label_priorities", "projects", on_delete: :cascade
|
||||
|
|
|
@ -299,8 +299,8 @@ could look like:
|
|||
stage: build
|
||||
script:
|
||||
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.example.com
|
||||
- docker build -t registry.example.com/group/project:latest .
|
||||
- docker push registry.example.com/group/project:latest
|
||||
- docker build -t registry.example.com/group/project/image:latest .
|
||||
- docker push registry.example.com/group/project/image:latest
|
||||
```
|
||||
|
||||
You have to use the special `gitlab-ci-token` user created for you in order to
|
||||
|
@ -350,8 +350,8 @@ stages:
|
|||
- deploy
|
||||
|
||||
variables:
|
||||
CONTAINER_TEST_IMAGE: registry.example.com/my-group/my-project:$CI_COMMIT_REF_NAME
|
||||
CONTAINER_RELEASE_IMAGE: registry.example.com/my-group/my-project:latest
|
||||
CONTAINER_TEST_IMAGE: registry.example.com/my-group/my-project/my-image:$CI_COMMIT_REF_NAME
|
||||
CONTAINER_RELEASE_IMAGE: registry.example.com/my-group/my-project/my-image:latest
|
||||
|
||||
before_script:
|
||||
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.example.com
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
- Starting from GitLab 8.12, if you have 2FA enabled in your account, you need
|
||||
to pass a personal access token instead of your password in order to login to
|
||||
GitLab's Container Registry.
|
||||
- Multiple level image names support was added in GitLab 9.1
|
||||
|
||||
With the Docker Container Registry integrated into GitLab, every project can
|
||||
have its own space to store its Docker images.
|
||||
|
@ -54,18 +55,25 @@ sure that you are using the Registry URL with the namespace and project name
|
|||
that is hosted on GitLab:
|
||||
|
||||
```
|
||||
docker build -t registry.example.com/group/project .
|
||||
docker push registry.example.com/group/project
|
||||
docker build -t registry.example.com/group/project/image .
|
||||
docker push registry.example.com/group/project/image
|
||||
```
|
||||
|
||||
Your image will be named after the following scheme:
|
||||
|
||||
```
|
||||
<registry URL>/<namespace>/<project>
|
||||
<registry URL>/<namespace>/<project>/<image>
|
||||
```
|
||||
|
||||
As such, the name of the image is unique, but you can differentiate the images
|
||||
using tags.
|
||||
GitLab supports up to three levels of image repository names.
|
||||
|
||||
Following examples of image tags are valid:
|
||||
|
||||
```
|
||||
registry.example.com/group/project:some-tag
|
||||
registry.example.com/group/project/image:latest
|
||||
registry.example.com/group/project/my/image:rc1
|
||||
```
|
||||
|
||||
## Use images from GitLab Container Registry
|
||||
|
||||
|
@ -73,7 +81,7 @@ To download and run a container from images hosted in GitLab Container Registry,
|
|||
use `docker run`:
|
||||
|
||||
```
|
||||
docker run [options] registry.example.com/group/project [arguments]
|
||||
docker run [options] registry.example.com/group/project/image [arguments]
|
||||
```
|
||||
|
||||
For more information on running Docker containers, visit the
|
||||
|
@ -136,7 +144,7 @@ A user attempted to enable an S3-backed Registry. The `docker login` step went
|
|||
fine. However, when pushing an image, the output showed:
|
||||
|
||||
```
|
||||
The push refers to a repository [s3-testing.myregistry.com:4567/root/docker-test]
|
||||
The push refers to a repository [s3-testing.myregistry.com:4567/root/docker-test/docker-image]
|
||||
dc5e59c14160: Pushing [==================================================>] 14.85 kB
|
||||
03c20c1a019a: Pushing [==================================================>] 2.048 kB
|
||||
a08f14ef632e: Pushing [==================================================>] 2.048 kB
|
||||
|
@ -229,7 +237,7 @@ a container image. You may need to run as root to do this. For example:
|
|||
|
||||
```sh
|
||||
docker login s3-testing.myregistry.com:4567
|
||||
docker push s3-testing.myregistry.com:4567/root/docker-test
|
||||
docker push s3-testing.myregistry.com:4567/root/docker-test/docker-image
|
||||
```
|
||||
|
||||
In the example above, we see the following trace on the mitmproxy window:
|
||||
|
|
|
@ -38,11 +38,11 @@ module ContainerRegistry
|
|||
end
|
||||
|
||||
def delete
|
||||
client.delete_blob(repository.name, digest)
|
||||
client.delete_blob(repository.path, digest)
|
||||
end
|
||||
|
||||
def data
|
||||
@data ||= client.blob(repository.name, digest, type)
|
||||
@data ||= client.blob(repository.path, digest, type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
module ContainerRegistry
|
||||
##
|
||||
# Class responsible for extracting project and repository name from
|
||||
# image repository path provided by a containers registry API response.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# some/group/my_project/my/image ->
|
||||
# project: some/group/my_project
|
||||
# repository: my/image
|
||||
#
|
||||
class Path
|
||||
InvalidRegistryPathError = Class.new(StandardError)
|
||||
|
||||
LEVELS_SUPPORTED = 3
|
||||
|
||||
def initialize(path)
|
||||
@path = path
|
||||
end
|
||||
|
||||
def valid?
|
||||
@path =~ Gitlab::Regex.container_repository_name_regex &&
|
||||
components.size > 1 &&
|
||||
components.size < Namespace::NUMBER_OF_ANCESTORS_ALLOWED
|
||||
end
|
||||
|
||||
def components
|
||||
@components ||= @path.to_s.split('/')
|
||||
end
|
||||
|
||||
def nodes
|
||||
raise InvalidRegistryPathError unless valid?
|
||||
|
||||
@nodes ||= components.size.downto(2).map do |length|
|
||||
components.take(length).join('/')
|
||||
end
|
||||
end
|
||||
|
||||
def has_project?
|
||||
repository_project.present?
|
||||
end
|
||||
|
||||
def has_repository?
|
||||
return false unless has_project?
|
||||
|
||||
repository_project.container_repositories
|
||||
.where(name: repository_name).any?
|
||||
end
|
||||
|
||||
def root_repository?
|
||||
@path == repository_project.full_path
|
||||
end
|
||||
|
||||
def repository_project
|
||||
@project ||= Project
|
||||
.where_full_path_in(nodes.first(LEVELS_SUPPORTED))
|
||||
.first
|
||||
end
|
||||
|
||||
def repository_name
|
||||
return unless has_project?
|
||||
|
||||
@path.remove(%r(^#{Regexp.escape(repository_project.full_path)}/?))
|
||||
end
|
||||
|
||||
def to_s
|
||||
@path
|
||||
end
|
||||
end
|
||||
end
|
|
@ -8,10 +8,6 @@ module ContainerRegistry
|
|||
@client = ContainerRegistry::Client.new(uri, options)
|
||||
end
|
||||
|
||||
def repository(name)
|
||||
ContainerRegistry::Repository.new(self, name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_path
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
module ContainerRegistry
|
||||
class Repository
|
||||
attr_reader :registry, :name
|
||||
|
||||
delegate :client, to: :registry
|
||||
|
||||
def initialize(registry, name)
|
||||
@registry, @name = registry, name
|
||||
end
|
||||
|
||||
def path
|
||||
[registry.path, name].compact.join('/')
|
||||
end
|
||||
|
||||
def tag(tag)
|
||||
ContainerRegistry::Tag.new(self, tag)
|
||||
end
|
||||
|
||||
def manifest
|
||||
return @manifest if defined?(@manifest)
|
||||
|
||||
@manifest = client.repository_tags(name)
|
||||
end
|
||||
|
||||
def valid?
|
||||
manifest.present?
|
||||
end
|
||||
|
||||
def tags
|
||||
return @tags if defined?(@tags)
|
||||
return [] unless manifest && manifest['tags']
|
||||
|
||||
@tags = manifest['tags'].map do |tag|
|
||||
ContainerRegistry::Tag.new(self, tag)
|
||||
end
|
||||
end
|
||||
|
||||
def blob(config)
|
||||
ContainerRegistry::Blob.new(self, config)
|
||||
end
|
||||
|
||||
def delete_tags
|
||||
return unless tags
|
||||
|
||||
tags.all?(&:delete)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -22,9 +22,7 @@ module ContainerRegistry
|
|||
end
|
||||
|
||||
def manifest
|
||||
return @manifest if defined?(@manifest)
|
||||
|
||||
@manifest = client.repository_manifest(repository.name, name)
|
||||
@manifest ||= client.repository_manifest(repository.path, name)
|
||||
end
|
||||
|
||||
def path
|
||||
|
@ -38,9 +36,7 @@ module ContainerRegistry
|
|||
end
|
||||
|
||||
def digest
|
||||
return @digest if defined?(@digest)
|
||||
|
||||
@digest = client.repository_tag_digest(repository.name, name)
|
||||
@digest ||= client.repository_tag_digest(repository.path, name)
|
||||
end
|
||||
|
||||
def config_blob
|
||||
|
@ -82,7 +78,7 @@ module ContainerRegistry
|
|||
def delete
|
||||
return unless digest
|
||||
|
||||
client.delete_repository_tag(repository.name, digest)
|
||||
client.delete_repository_tag(repository.path, digest)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -121,6 +121,13 @@ module Gitlab
|
|||
git_reference_regex
|
||||
end
|
||||
|
||||
##
|
||||
# Docker Distribution Registry 2.4.1 repository name rules
|
||||
#
|
||||
def container_repository_name_regex
|
||||
@container_repository_regex ||= %r{\A[a-z0-9]+(?:[-._/][a-z0-9]+)*\Z}
|
||||
end
|
||||
|
||||
def environment_name_regex
|
||||
@environment_name_regex ||= /\A[a-zA-Z0-9_\\\/\${}. -]+\z/.freeze
|
||||
end
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Projects::Registry::RepositoriesController do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:empty_project, :private) }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
stub_container_registry_config(enabled: true)
|
||||
end
|
||||
|
||||
context 'when user has access to registry' do
|
||||
before do
|
||||
project.add_developer(user)
|
||||
end
|
||||
|
||||
describe 'GET index' do
|
||||
context 'when root container repository exists' do
|
||||
before do
|
||||
create(:container_repository, :root, project: project)
|
||||
end
|
||||
|
||||
it 'does not create root container repository' do
|
||||
expect { go_to_index }.not_to change { ContainerRepository.all.count }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when root container repository is not created' do
|
||||
context 'when there are tags for this repository' do
|
||||
before do
|
||||
stub_container_registry_tags(repository: project.full_path,
|
||||
tags: %w[rc1 latest])
|
||||
end
|
||||
|
||||
it 'successfully renders container repositories' do
|
||||
go_to_index
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'creates a root container repository' do
|
||||
expect { go_to_index }.to change { ContainerRepository.all.count }.by(1)
|
||||
expect(ContainerRepository.first).to be_root_repository
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are no tags for this repository' do
|
||||
before do
|
||||
stub_container_registry_tags(repository: :any, tags: [])
|
||||
end
|
||||
|
||||
it 'successfully renders container repositories' do
|
||||
go_to_index
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'does not ensure root container repository' do
|
||||
expect { go_to_index }.not_to change { ContainerRepository.all.count }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user does not have access to registry' do
|
||||
describe 'GET index' do
|
||||
it 'responds with 404' do
|
||||
go_to_index
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not ensure root container repository' do
|
||||
expect { go_to_index }.not_to change { ContainerRepository.all.count }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def go_to_index
|
||||
get :index, namespace_id: project.namespace,
|
||||
project_id: project
|
||||
end
|
||||
end
|
|
@ -0,0 +1,33 @@
|
|||
FactoryGirl.define do
|
||||
factory :container_repository do
|
||||
name 'test_container_image'
|
||||
project
|
||||
|
||||
transient do
|
||||
tags []
|
||||
end
|
||||
|
||||
trait :root do
|
||||
name ''
|
||||
end
|
||||
|
||||
after(:build) do |repository, evaluator|
|
||||
next if evaluator.tags.to_a.none?
|
||||
|
||||
allow(repository.client)
|
||||
.to receive(:repository_tags)
|
||||
.and_return({
|
||||
'name' => repository.path,
|
||||
'tags' => evaluator.tags
|
||||
})
|
||||
|
||||
evaluator.tags.each do |tag|
|
||||
allow(repository.client)
|
||||
.to receive(:repository_tag_digest)
|
||||
.with(repository.path, tag)
|
||||
.and_return('sha256:4c8e63ca4cb663ce6c688cb06f1c3' \
|
||||
'72b088dac5b6d7ad7d49cd620d85cf72a15')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,45 +1,61 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe "Container Registry" do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:empty_project) }
|
||||
let(:repository) { project.container_registry_repository }
|
||||
let(:tag_name) { 'latest' }
|
||||
let(:tags) { [tag_name] }
|
||||
|
||||
let(:container_repository) do
|
||||
create(:container_repository, name: 'my/image')
|
||||
end
|
||||
|
||||
before do
|
||||
login_as(:user)
|
||||
project.team << [@user, :developer]
|
||||
stub_container_registry_tags(*tags)
|
||||
login_as(user)
|
||||
project.add_developer(user)
|
||||
stub_container_registry_config(enabled: true)
|
||||
allow(Auth::ContainerRegistryAuthenticationService).to receive(:full_access_token).and_return('token')
|
||||
stub_container_registry_tags(repository: :any, tags: [])
|
||||
end
|
||||
|
||||
describe 'GET /:project/container_registry' do
|
||||
before do
|
||||
visit namespace_project_container_registry_index_path(project.namespace, project)
|
||||
end
|
||||
context 'when there are no image repositories' do
|
||||
scenario 'user visits container registry main page' do
|
||||
visit_container_registry
|
||||
|
||||
context 'when no tags' do
|
||||
let(:tags) { [] }
|
||||
|
||||
it { expect(page).to have_content('No images in Container Registry for this project') }
|
||||
end
|
||||
|
||||
context 'when there are tags' do
|
||||
it { expect(page).to have_content(tag_name) }
|
||||
it { expect(page).to have_content('d7a513a66') }
|
||||
expect(page).to have_content 'No container image repositories'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /:project/container_registry/tag' do
|
||||
context 'when there are image repositories' do
|
||||
before do
|
||||
visit namespace_project_container_registry_index_path(project.namespace, project)
|
||||
stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest])
|
||||
project.container_repositories << container_repository
|
||||
end
|
||||
|
||||
it do
|
||||
expect_any_instance_of(::ContainerRegistry::Tag).to receive(:delete).and_return(true)
|
||||
scenario 'user wants to see multi-level container repository' do
|
||||
visit_container_registry
|
||||
|
||||
click_on 'Remove'
|
||||
expect(page).to have_content('my/image')
|
||||
end
|
||||
|
||||
scenario 'user removes entire container repository' do
|
||||
visit_container_registry
|
||||
|
||||
expect_any_instance_of(ContainerRepository)
|
||||
.to receive(:delete_tags!).and_return(true)
|
||||
|
||||
click_on 'Remove repository'
|
||||
end
|
||||
|
||||
scenario 'user removes a specific tag from container repository' do
|
||||
visit_container_registry
|
||||
|
||||
expect_any_instance_of(ContainerRegistry::Tag)
|
||||
.to receive(:delete).and_return(true)
|
||||
|
||||
click_on 'Remove tag'
|
||||
end
|
||||
end
|
||||
|
||||
def visit_container_registry
|
||||
visit namespace_project_container_registry_index_path(
|
||||
project.namespace, project)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -443,9 +443,12 @@ describe "Internal Project Access", feature: true do
|
|||
end
|
||||
|
||||
describe "GET /:project_path/container_registry" do
|
||||
let(:container_repository) { create(:container_repository) }
|
||||
|
||||
before do
|
||||
stub_container_registry_tags('latest')
|
||||
stub_container_registry_tags(repository: :any, tags: ['latest'])
|
||||
stub_container_registry_config(enabled: true)
|
||||
project.container_repositories << container_repository
|
||||
end
|
||||
|
||||
subject { namespace_project_container_registry_index_path(project.namespace, project) }
|
||||
|
|
|
@ -432,9 +432,12 @@ describe "Private Project Access", feature: true do
|
|||
end
|
||||
|
||||
describe "GET /:project_path/container_registry" do
|
||||
let(:container_repository) { create(:container_repository) }
|
||||
|
||||
before do
|
||||
stub_container_registry_tags('latest')
|
||||
stub_container_registry_tags(repository: :any, tags: ['latest'])
|
||||
stub_container_registry_config(enabled: true)
|
||||
project.container_repositories << container_repository
|
||||
end
|
||||
|
||||
subject { namespace_project_container_registry_index_path(project.namespace, project) }
|
||||
|
|
|
@ -443,9 +443,12 @@ describe "Public Project Access", feature: true do
|
|||
end
|
||||
|
||||
describe "GET /:project_path/container_registry" do
|
||||
let(:container_repository) { create(:container_repository) }
|
||||
|
||||
before do
|
||||
stub_container_registry_tags('latest')
|
||||
stub_container_registry_tags(repository: :any, tags: ['latest'])
|
||||
stub_container_registry_config(enabled: true)
|
||||
project.container_repositories << container_repository
|
||||
end
|
||||
|
||||
subject { namespace_project_container_registry_index_path(project.namespace, project) }
|
||||
|
|
|
@ -1,110 +1,121 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe ContainerRegistry::Blob do
|
||||
let(:digest) { 'sha256:0123456789012345' }
|
||||
let(:config) do
|
||||
{
|
||||
'digest' => digest,
|
||||
'mediaType' => 'binary',
|
||||
'size' => 1000
|
||||
}
|
||||
let(:group) { create(:group, name: 'group') }
|
||||
let(:project) { create(:empty_project, path: 'test', group: group) }
|
||||
|
||||
let(:repository) do
|
||||
create(:container_repository, name: 'image',
|
||||
tags: %w[latest rc1],
|
||||
project: project)
|
||||
end
|
||||
|
||||
let(:config) do
|
||||
{ 'digest' => 'sha256:0123456789012345',
|
||||
'mediaType' => 'binary',
|
||||
'size' => 1000 }
|
||||
end
|
||||
|
||||
let(:blob) { described_class.new(repository, config) }
|
||||
|
||||
before do
|
||||
stub_container_registry_config(enabled: true,
|
||||
api_url: 'http://registry.gitlab',
|
||||
host_port: 'registry.gitlab')
|
||||
end
|
||||
let(:token) { 'authorization-token' }
|
||||
|
||||
let(:registry) { ContainerRegistry::Registry.new('http://example.com', token: token) }
|
||||
let(:repository) { registry.repository('group/test') }
|
||||
let(:blob) { repository.blob(config) }
|
||||
|
||||
it { expect(blob).to respond_to(:repository) }
|
||||
it { expect(blob).to delegate_method(:registry).to(:repository) }
|
||||
it { expect(blob).to delegate_method(:client).to(:repository) }
|
||||
|
||||
context '#path' do
|
||||
subject { blob.path }
|
||||
|
||||
it { is_expected.to eq('example.com/group/test@sha256:0123456789012345') }
|
||||
describe '#path' do
|
||||
it 'returns a valid path to the blob' do
|
||||
expect(blob.path).to eq('group/test/image@sha256:0123456789012345')
|
||||
end
|
||||
end
|
||||
|
||||
context '#digest' do
|
||||
subject { blob.digest }
|
||||
|
||||
it { is_expected.to eq(digest) }
|
||||
describe '#digest' do
|
||||
it 'return correct digest value' do
|
||||
expect(blob.digest).to eq 'sha256:0123456789012345'
|
||||
end
|
||||
end
|
||||
|
||||
context '#type' do
|
||||
subject { blob.type }
|
||||
|
||||
it { is_expected.to eq('binary') }
|
||||
describe '#type' do
|
||||
it 'returns a correct type' do
|
||||
expect(blob.type).to eq 'binary'
|
||||
end
|
||||
end
|
||||
|
||||
context '#revision' do
|
||||
subject { blob.revision }
|
||||
|
||||
it { is_expected.to eq('0123456789012345') }
|
||||
describe '#revision' do
|
||||
it 'returns a correct blob SHA' do
|
||||
expect(blob.revision).to eq '0123456789012345'
|
||||
end
|
||||
end
|
||||
|
||||
context '#short_revision' do
|
||||
subject { blob.short_revision }
|
||||
|
||||
it { is_expected.to eq('012345678') }
|
||||
describe '#short_revision' do
|
||||
it 'return a short SHA' do
|
||||
expect(blob.short_revision).to eq '012345678'
|
||||
end
|
||||
end
|
||||
|
||||
context '#delete' do
|
||||
describe '#delete' do
|
||||
before do
|
||||
stub_request(:delete, 'http://example.com/v2/group/test/blobs/sha256:0123456789012345').
|
||||
to_return(status: 200)
|
||||
stub_request(:delete, 'http://registry.gitlab/v2/group/test/image/blobs/sha256:0123456789012345')
|
||||
.to_return(status: 200)
|
||||
end
|
||||
|
||||
subject { blob.delete }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
it 'returns true when blob has been successfuly deleted' do
|
||||
expect(blob.delete).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context '#data' do
|
||||
let(:data) { '{"key":"value"}' }
|
||||
|
||||
subject { blob.data }
|
||||
|
||||
describe '#data' do
|
||||
context 'when locally stored' do
|
||||
before do
|
||||
stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:0123456789012345').
|
||||
stub_request(:get, 'http://registry.gitlab/v2/group/test/image/blobs/sha256:0123456789012345').
|
||||
to_return(
|
||||
status: 200,
|
||||
headers: { 'Content-Type' => 'application/json' },
|
||||
body: data)
|
||||
body: '{"key":"value"}')
|
||||
end
|
||||
|
||||
it { is_expected.to eq(data) }
|
||||
it 'returns a correct blob data' do
|
||||
expect(blob.data).to eq '{"key":"value"}'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when externally stored' do
|
||||
let(:location) { 'http://external.com/blob/file' }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:0123456789012345').
|
||||
with(headers: { 'Authorization' => "bearer #{token}" }).
|
||||
to_return(
|
||||
stub_request(:get, 'http://registry.gitlab/v2/group/test/image/blobs/sha256:0123456789012345')
|
||||
.with(headers: { 'Authorization' => 'bearer token' })
|
||||
.to_return(
|
||||
status: 307,
|
||||
headers: { 'Location' => location })
|
||||
end
|
||||
|
||||
context 'for a valid address' do
|
||||
let(:location) { 'http://external.com/blob/file' }
|
||||
|
||||
before do
|
||||
stub_request(:get, location).
|
||||
with(headers: { 'Authorization' => nil }).
|
||||
to_return(
|
||||
status: 200,
|
||||
headers: { 'Content-Type' => 'application/json' },
|
||||
body: data)
|
||||
body: '{"key":"value"}')
|
||||
end
|
||||
|
||||
it { is_expected.to eq(data) }
|
||||
it 'returns correct data' do
|
||||
expect(blob.data).to eq '{"key":"value"}'
|
||||
end
|
||||
end
|
||||
|
||||
context 'for invalid file' do
|
||||
let(:location) { 'file:///etc/passwd' }
|
||||
|
||||
it { expect{ subject }.to raise_error(ArgumentError, 'invalid address') }
|
||||
it 'raises an error' do
|
||||
expect { blob.data }.to raise_error(ArgumentError, 'invalid address')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,212 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe ContainerRegistry::Path do
|
||||
subject { described_class.new(path) }
|
||||
|
||||
describe '#components' do
|
||||
let(:path) { 'path/to/some/project' }
|
||||
|
||||
it 'splits components by a forward slash' do
|
||||
expect(subject.components).to eq %w[path to some project]
|
||||
end
|
||||
end
|
||||
|
||||
describe '#nodes' do
|
||||
context 'when repository path is valid' do
|
||||
let(:path) { 'path/to/some/project' }
|
||||
|
||||
it 'return all project path like node in reverse order' do
|
||||
expect(subject.nodes).to eq %w[path/to/some/project
|
||||
path/to/some
|
||||
path/to]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when repository path is invalid' do
|
||||
let(:path) { '' }
|
||||
|
||||
it 'rasises en error' do
|
||||
expect { subject.nodes }
|
||||
.to raise_error described_class::InvalidRegistryPathError
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_s' do
|
||||
let(:path) { 'some/image' }
|
||||
|
||||
it 'return a string with a repository path' do
|
||||
expect(subject.to_s).to eq path
|
||||
end
|
||||
end
|
||||
|
||||
describe '#valid?' do
|
||||
context 'when path has less than two components' do
|
||||
let(:path) { 'something/' }
|
||||
|
||||
it { is_expected.not_to be_valid }
|
||||
end
|
||||
|
||||
context 'when path has more than allowed number of components' do
|
||||
let(:path) { 'a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/r/s/t/u/w/y/z' }
|
||||
|
||||
it { is_expected.not_to be_valid }
|
||||
end
|
||||
|
||||
context 'when path has invalid characters' do
|
||||
let(:path) { 'some\path' }
|
||||
|
||||
it { is_expected.not_to be_valid }
|
||||
end
|
||||
|
||||
context 'when path has two or more components' do
|
||||
let(:path) { 'some/path' }
|
||||
|
||||
it { is_expected.to be_valid }
|
||||
end
|
||||
|
||||
context 'when path is related to multi-level image' do
|
||||
let(:path) { 'some/path/my/image' }
|
||||
|
||||
it { is_expected.to be_valid }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#has_repository?' do
|
||||
context 'when project exists' do
|
||||
let(:project) { create(:empty_project) }
|
||||
let(:path) { "#{project.full_path}/my/image" }
|
||||
|
||||
context 'when path already has matching repository' do
|
||||
before do
|
||||
create(:container_repository, project: project, name: 'my/image')
|
||||
end
|
||||
|
||||
it { is_expected.to have_repository }
|
||||
it { is_expected.to have_project }
|
||||
end
|
||||
|
||||
context 'when path does not have matching repository' do
|
||||
it { is_expected.not_to have_repository }
|
||||
it { is_expected.to have_project }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when project does not exist' do
|
||||
let(:path) { 'some/project/my/image' }
|
||||
|
||||
it { is_expected.not_to have_repository }
|
||||
it { is_expected.not_to have_project }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#repository_project' do
|
||||
let(:group) { create(:group, path: 'some_group') }
|
||||
|
||||
context 'when project for given path exists' do
|
||||
let(:path) { 'some_group/some_project' }
|
||||
|
||||
before do
|
||||
create(:empty_project, group: group, name: 'some_project')
|
||||
create(:empty_project, name: 'some_project')
|
||||
end
|
||||
|
||||
it 'returns a correct project' do
|
||||
expect(subject.repository_project.group).to eq group
|
||||
end
|
||||
end
|
||||
|
||||
context 'when project for given path does not exist' do
|
||||
let(:path) { 'not/matching' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(subject.repository_project).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when matching multi-level path' do
|
||||
let(:project) do
|
||||
create(:empty_project, group: group, name: 'some_project')
|
||||
end
|
||||
|
||||
context 'when using the zero-level path' do
|
||||
let(:path) { project.full_path }
|
||||
|
||||
it 'supports zero-level path' do
|
||||
expect(subject.repository_project).to eq project
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using first-level path' do
|
||||
let(:path) { "#{project.full_path}/repository" }
|
||||
|
||||
it 'supports first-level path' do
|
||||
expect(subject.repository_project).to eq project
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using second-level path' do
|
||||
let(:path) { "#{project.full_path}/repository/name" }
|
||||
|
||||
it 'supports second-level path' do
|
||||
expect(subject.repository_project).to eq project
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using too deep nesting in the path' do
|
||||
let(:path) { "#{project.full_path}/repository/name/invalid" }
|
||||
|
||||
it 'does not support three-levels of nesting' do
|
||||
expect(subject.repository_project).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#repository_name' do
|
||||
context 'when project does not exist' do
|
||||
let(:path) { 'some/name' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(subject.repository_name).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when project exists' do
|
||||
let(:group) { create(:group, path: 'some_group') }
|
||||
|
||||
let(:project) do
|
||||
create(:empty_project, group: group, name: 'some_project')
|
||||
end
|
||||
|
||||
before do
|
||||
allow(path).to receive(:repository_project)
|
||||
.and_return(project)
|
||||
end
|
||||
|
||||
context 'when project path equal repository path' do
|
||||
let(:path) { 'some_group/some_project' }
|
||||
|
||||
it 'returns an empty string' do
|
||||
expect(subject.repository_name).to eq ''
|
||||
end
|
||||
end
|
||||
|
||||
context 'when repository path has one additional level' do
|
||||
let(:path) { 'some_group/some_project/repository' }
|
||||
|
||||
it 'returns a correct repository name' do
|
||||
expect(subject.repository_name).to eq 'repository'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when repository path has two additional levels' do
|
||||
let(:path) { 'some_group/some_project/repository/image' }
|
||||
|
||||
it 'returns a correct repository name' do
|
||||
expect(subject.repository_name).to eq 'repository/image'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,7 +10,7 @@ describe ContainerRegistry::Registry do
|
|||
it { is_expected.to respond_to(:uri) }
|
||||
it { is_expected.to respond_to(:path) }
|
||||
|
||||
it { expect(subject.repository('test')).not_to be_nil }
|
||||
it { expect(subject).not_to be_nil }
|
||||
|
||||
context '#path' do
|
||||
subject { registry.path }
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe ContainerRegistry::Repository do
|
||||
let(:registry) { ContainerRegistry::Registry.new('http://example.com') }
|
||||
let(:repository) { registry.repository('group/test') }
|
||||
|
||||
it { expect(repository).to respond_to(:registry) }
|
||||
it { expect(repository).to delegate_method(:client).to(:registry) }
|
||||
it { expect(repository.tag('test')).not_to be_nil }
|
||||
|
||||
context '#path' do
|
||||
subject { repository.path }
|
||||
|
||||
it { is_expected.to eq('example.com/group/test') }
|
||||
end
|
||||
|
||||
context 'manifest processing' do
|
||||
before do
|
||||
stub_request(:get, 'http://example.com/v2/group/test/tags/list').
|
||||
with(headers: { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json' }).
|
||||
to_return(
|
||||
status: 200,
|
||||
body: JSON.dump(tags: ['test']),
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
context '#manifest' do
|
||||
subject { repository.manifest }
|
||||
|
||||
it { is_expected.not_to be_nil }
|
||||
end
|
||||
|
||||
context '#valid?' do
|
||||
subject { repository.valid? }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context '#tags' do
|
||||
subject { repository.tags }
|
||||
|
||||
it { is_expected.not_to be_empty }
|
||||
end
|
||||
end
|
||||
|
||||
context '#delete_tags' do
|
||||
let(:tag) { ContainerRegistry::Tag.new(repository, 'tag') }
|
||||
|
||||
before { expect(repository).to receive(:tags).twice.and_return([tag]) }
|
||||
|
||||
subject { repository.delete_tags }
|
||||
|
||||
context 'succeeds' do
|
||||
before { expect(tag).to receive(:delete).and_return(true) }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context 'any fails' do
|
||||
before { expect(tag).to receive(:delete).and_return(false) }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,25 +1,59 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe ContainerRegistry::Tag do
|
||||
let(:registry) { ContainerRegistry::Registry.new('http://example.com') }
|
||||
let(:repository) { registry.repository('group/test') }
|
||||
let(:tag) { repository.tag('tag') }
|
||||
let(:headers) { { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json' } }
|
||||
let(:group) { create(:group, name: 'group') }
|
||||
let(:project) { create(:project, path: 'test', group: group) }
|
||||
|
||||
let(:repository) do
|
||||
create(:container_repository, name: '', project: project)
|
||||
end
|
||||
|
||||
let(:headers) do
|
||||
{ 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json' }
|
||||
end
|
||||
|
||||
let(:tag) { described_class.new(repository, 'tag') }
|
||||
|
||||
before do
|
||||
stub_container_registry_config(enabled: true,
|
||||
api_url: 'http://registry.gitlab',
|
||||
host_port: 'registry.gitlab')
|
||||
end
|
||||
|
||||
it { expect(tag).to respond_to(:repository) }
|
||||
it { expect(tag).to delegate_method(:registry).to(:repository) }
|
||||
it { expect(tag).to delegate_method(:client).to(:repository) }
|
||||
|
||||
context '#path' do
|
||||
subject { tag.path }
|
||||
describe '#path' do
|
||||
context 'when tag belongs to zero-level repository' do
|
||||
let(:repository) do
|
||||
create(:container_repository, name: '',
|
||||
tags: %w[rc1],
|
||||
project: project)
|
||||
end
|
||||
|
||||
it { is_expected.to eq('example.com/group/test:tag') }
|
||||
it 'returns path to the image' do
|
||||
expect(tag.path).to eq('group/test:tag')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tag belongs to first-level repository' do
|
||||
let(:repository) do
|
||||
create(:container_repository, name: 'my_image',
|
||||
tags: %w[tag],
|
||||
project: project)
|
||||
end
|
||||
|
||||
it 'returns path to the image' do
|
||||
expect(tag.path).to eq('group/test/my_image:tag')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'manifest processing' do
|
||||
context 'schema v1' do
|
||||
before do
|
||||
stub_request(:get, 'http://example.com/v2/group/test/manifests/tag').
|
||||
stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag').
|
||||
with(headers: headers).
|
||||
to_return(
|
||||
status: 200,
|
||||
|
@ -56,7 +90,7 @@ describe ContainerRegistry::Tag do
|
|||
|
||||
context 'schema v2' do
|
||||
before do
|
||||
stub_request(:get, 'http://example.com/v2/group/test/manifests/tag').
|
||||
stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag').
|
||||
with(headers: headers).
|
||||
to_return(
|
||||
status: 200,
|
||||
|
@ -93,7 +127,7 @@ describe ContainerRegistry::Tag do
|
|||
|
||||
context 'when locally stored' do
|
||||
before do
|
||||
stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac').
|
||||
stub_request(:get, 'http://registry.gitlab/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac').
|
||||
with(headers: { 'Accept' => 'application/octet-stream' }).
|
||||
to_return(
|
||||
status: 200,
|
||||
|
@ -105,7 +139,7 @@ describe ContainerRegistry::Tag do
|
|||
|
||||
context 'when externally stored' do
|
||||
before do
|
||||
stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac').
|
||||
stub_request(:get, 'http://registry.gitlab/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac').
|
||||
with(headers: { 'Accept' => 'application/octet-stream' }).
|
||||
to_return(
|
||||
status: 307,
|
||||
|
@ -123,29 +157,29 @@ describe ContainerRegistry::Tag do
|
|||
end
|
||||
end
|
||||
|
||||
context 'manifest digest' do
|
||||
context 'with stubbed digest' do
|
||||
before do
|
||||
stub_request(:head, 'http://example.com/v2/group/test/manifests/tag').
|
||||
with(headers: headers).
|
||||
to_return(status: 200, headers: { 'Docker-Content-Digest' => 'sha256:digest' })
|
||||
stub_request(:head, 'http://registry.gitlab/v2/group/test/manifests/tag')
|
||||
.with(headers: headers)
|
||||
.to_return(status: 200, headers: { 'Docker-Content-Digest' => 'sha256:digest' })
|
||||
end
|
||||
|
||||
context '#digest' do
|
||||
subject { tag.digest }
|
||||
|
||||
it { is_expected.to eq('sha256:digest') }
|
||||
describe '#digest' do
|
||||
it 'returns a correct tag digest' do
|
||||
expect(tag.digest).to eq 'sha256:digest'
|
||||
end
|
||||
end
|
||||
|
||||
context '#delete' do
|
||||
describe '#delete' do
|
||||
before do
|
||||
stub_request(:delete, 'http://example.com/v2/group/test/manifests/sha256:digest').
|
||||
with(headers: headers).
|
||||
to_return(status: 200)
|
||||
stub_request(:delete, 'http://registry.gitlab/v2/group/test/manifests/sha256:digest')
|
||||
.with(headers: headers)
|
||||
.to_return(status: 200)
|
||||
end
|
||||
|
||||
subject { tag.delete }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
it 'correctly deletes the tag' do
|
||||
expect(tag.delete).to be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -116,6 +116,9 @@ merge_access_levels:
|
|||
- protected_branch
|
||||
push_access_levels:
|
||||
- protected_branch
|
||||
container_repositories:
|
||||
- project
|
||||
- name
|
||||
project:
|
||||
- taggings
|
||||
- base_tags
|
||||
|
@ -202,6 +205,7 @@ project:
|
|||
- project_authorizations
|
||||
- route
|
||||
- statistics
|
||||
- container_repositories
|
||||
- uploads
|
||||
award_emoji:
|
||||
- awardable
|
||||
|
|
|
@ -1348,7 +1348,7 @@ describe Ci::Build, :models do
|
|||
{ key: 'CI_REGISTRY', value: 'registry.example.com', public: true }
|
||||
end
|
||||
let(:ci_registry_image) do
|
||||
{ key: 'CI_REGISTRY_IMAGE', value: project.container_registry_repository_url, public: true }
|
||||
{ key: 'CI_REGISTRY_IMAGE', value: project.container_registry_url, public: true }
|
||||
end
|
||||
|
||||
context 'and is disabled for project' do
|
||||
|
|
|
@ -0,0 +1,209 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe ContainerRepository do
|
||||
let(:group) { create(:group, name: 'group') }
|
||||
let(:project) { create(:project, path: 'test', group: group) }
|
||||
|
||||
let(:container_repository) do
|
||||
create(:container_repository, name: 'my_image', project: project)
|
||||
end
|
||||
|
||||
before do
|
||||
stub_container_registry_config(enabled: true,
|
||||
api_url: 'http://registry.gitlab',
|
||||
host_port: 'registry.gitlab')
|
||||
|
||||
stub_request(:get, 'http://registry.gitlab/v2/group/test/my_image/tags/list')
|
||||
.with(headers: { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json' })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: JSON.dump(tags: ['test_tag']),
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
describe 'associations' do
|
||||
it 'belongs to the project' do
|
||||
expect(container_repository).to belong_to(:project)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#tag' do
|
||||
it 'has a test tag' do
|
||||
expect(container_repository.tag('test')).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#path' do
|
||||
it 'returns a full path to the repository' do
|
||||
expect(container_repository.path).to eq('group/test/my_image')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#manifest' do
|
||||
subject { container_repository.manifest }
|
||||
|
||||
it { is_expected.not_to be_nil }
|
||||
end
|
||||
|
||||
describe '#valid?' do
|
||||
subject { container_repository.valid? }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
describe '#tags' do
|
||||
subject { container_repository.tags }
|
||||
|
||||
it { is_expected.not_to be_empty }
|
||||
end
|
||||
|
||||
describe '#has_tags?' do
|
||||
it 'has tags' do
|
||||
expect(container_repository).to have_tags
|
||||
end
|
||||
end
|
||||
|
||||
describe '#delete_tags!' do
|
||||
let(:container_repository) do
|
||||
create(:container_repository, name: 'my_image',
|
||||
tags: %w[latest rc1],
|
||||
project: project)
|
||||
end
|
||||
|
||||
context 'when action succeeds' do
|
||||
it 'returns status that indicates success' do
|
||||
expect(container_repository.client)
|
||||
.to receive(:delete_repository_tag)
|
||||
.and_return(true)
|
||||
|
||||
expect(container_repository.delete_tags!).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context 'when action fails' do
|
||||
it 'returns status that indicates failure' do
|
||||
expect(container_repository.client)
|
||||
.to receive(:delete_repository_tag)
|
||||
.and_return(false)
|
||||
|
||||
expect(container_repository.delete_tags!).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#root_repository?' do
|
||||
context 'when repository is a root repository' do
|
||||
let(:repository) { create(:container_repository, :root) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(repository).to be_root_repository
|
||||
end
|
||||
end
|
||||
|
||||
context 'when repository is not a root repository' do
|
||||
it 'returns false' do
|
||||
expect(container_repository).not_to be_root_repository
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.build_from_path' do
|
||||
let(:registry_path) do
|
||||
ContainerRegistry::Path.new(project.full_path + '/some/image')
|
||||
end
|
||||
|
||||
let(:repository) do
|
||||
described_class.build_from_path(registry_path)
|
||||
end
|
||||
|
||||
it 'fabricates repository assigned to a correct project' do
|
||||
expect(repository.project).to eq project
|
||||
end
|
||||
|
||||
it 'fabricates repository with a correct name' do
|
||||
expect(repository.name).to eq 'some/image'
|
||||
end
|
||||
|
||||
it 'is not persisted' do
|
||||
expect(repository).not_to be_persisted
|
||||
end
|
||||
end
|
||||
|
||||
describe '.create_from_path!' do
|
||||
let(:repository) do
|
||||
described_class.create_from_path!(ContainerRegistry::Path.new(path))
|
||||
end
|
||||
|
||||
let(:repository_path) { ContainerRegistry::Path.new(path) }
|
||||
|
||||
context 'when received multi-level repository path' do
|
||||
let(:path) { project.full_path + '/some/image' }
|
||||
|
||||
it 'fabricates repository assigned to a correct project' do
|
||||
expect(repository.project).to eq project
|
||||
end
|
||||
|
||||
it 'fabricates repository with a correct name' do
|
||||
expect(repository.name).to eq 'some/image'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when path is too long' do
|
||||
let(:path) do
|
||||
project.full_path + '/a/b/c/d/e/f/g/h/i/j/k/l/n/o/p/s/t/u/x/y/z'
|
||||
end
|
||||
|
||||
it 'does not create repository and raises error' do
|
||||
expect { repository }.to raise_error(
|
||||
ContainerRegistry::Path::InvalidRegistryPathError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when received multi-level repository with nested groups' do
|
||||
let(:group) { create(:group, :nested, name: 'nested') }
|
||||
let(:path) { project.full_path + '/some/image' }
|
||||
|
||||
it 'fabricates repository assigned to a correct project' do
|
||||
expect(repository.project).to eq project
|
||||
end
|
||||
|
||||
it 'fabricates repository with a correct name' do
|
||||
expect(repository.name).to eq 'some/image'
|
||||
end
|
||||
|
||||
it 'has path including a nested group' do
|
||||
expect(repository.path).to include 'nested/test/some/image'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when received root repository path' do
|
||||
let(:path) { project.full_path }
|
||||
|
||||
it 'fabricates repository assigned to a correct project' do
|
||||
expect(repository.project).to eq project
|
||||
end
|
||||
|
||||
it 'fabricates repository with an empty name' do
|
||||
expect(repository.name).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.build_root_repository' do
|
||||
let(:repository) do
|
||||
described_class.build_root_repository(project)
|
||||
end
|
||||
|
||||
it 'fabricates a root repository object' do
|
||||
expect(repository).to be_root_repository
|
||||
end
|
||||
|
||||
it 'assignes it to the correct project' do
|
||||
expect(repository.project).to eq project
|
||||
end
|
||||
|
||||
it 'does not persist it' do
|
||||
expect(repository).not_to be_persisted
|
||||
end
|
||||
end
|
||||
end
|
|
@ -148,18 +148,22 @@ describe Namespace, models: true do
|
|||
expect(@namespace.move_dir).to be_truthy
|
||||
end
|
||||
|
||||
context "when any project has container tags" do
|
||||
context "when any project has container images" do
|
||||
let(:container_repository) { create(:container_repository) }
|
||||
|
||||
before do
|
||||
stub_container_registry_config(enabled: true)
|
||||
stub_container_registry_tags('tag')
|
||||
stub_container_registry_tags(repository: :any, tags: ['tag'])
|
||||
|
||||
create(:empty_project, namespace: @namespace)
|
||||
create(:empty_project, namespace: @namespace, container_repositories: [container_repository])
|
||||
|
||||
allow(@namespace).to receive(:path_was).and_return(@namespace.path)
|
||||
allow(@namespace).to receive(:path).and_return('new_path')
|
||||
end
|
||||
|
||||
it { expect { @namespace.move_dir }.to raise_error('Namespace cannot be moved, because at least one project has tags in container registry') }
|
||||
it 'raises an error about not movable project' do
|
||||
expect { @namespace.move_dir }.to raise_error(/Namespace cannot be moved/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with subgroups' do
|
||||
|
|
|
@ -1157,11 +1157,12 @@ describe Project, models: true do
|
|||
# Project#gitlab_shell returns a new instance of Gitlab::Shell on every
|
||||
# call. This makes testing a bit easier.
|
||||
allow(project).to receive(:gitlab_shell).and_return(gitlab_shell)
|
||||
|
||||
allow(project).to receive(:previous_changes).and_return('path' => ['foo'])
|
||||
end
|
||||
|
||||
it 'renames a repository' do
|
||||
stub_container_registry_config(enabled: false)
|
||||
|
||||
expect(gitlab_shell).to receive(:mv_repository).
|
||||
ordered.
|
||||
with(project.repository_storage_path, "#{project.namespace.full_path}/foo", "#{project.full_path}").
|
||||
|
@ -1185,10 +1186,13 @@ describe Project, models: true do
|
|||
project.rename_repo
|
||||
end
|
||||
|
||||
context 'container registry with tags' do
|
||||
context 'container registry with images' do
|
||||
let(:container_repository) { create(:container_repository) }
|
||||
|
||||
before do
|
||||
stub_container_registry_config(enabled: true)
|
||||
stub_container_registry_tags('tag')
|
||||
stub_container_registry_tags(repository: :any, tags: ['tag'])
|
||||
project.container_repositories << container_repository
|
||||
end
|
||||
|
||||
subject { project.rename_repo }
|
||||
|
@ -1386,38 +1390,17 @@ describe Project, models: true do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#container_registry_path_with_namespace' do
|
||||
let(:project) { create(:empty_project, path: 'PROJECT') }
|
||||
|
||||
subject { project.container_registry_path_with_namespace }
|
||||
|
||||
it { is_expected.not_to eq(project.path_with_namespace) }
|
||||
it { is_expected.to eq(project.path_with_namespace.downcase) }
|
||||
end
|
||||
|
||||
describe '#container_registry_repository' do
|
||||
describe '#container_registry_url' do
|
||||
let(:project) { create(:empty_project) }
|
||||
|
||||
before { stub_container_registry_config(enabled: true) }
|
||||
|
||||
subject { project.container_registry_repository }
|
||||
|
||||
it { is_expected.not_to be_nil }
|
||||
end
|
||||
|
||||
describe '#container_registry_repository_url' do
|
||||
let(:project) { create(:empty_project) }
|
||||
|
||||
subject { project.container_registry_repository_url }
|
||||
subject { project.container_registry_url }
|
||||
|
||||
before { stub_container_registry_config(**registry_settings) }
|
||||
|
||||
context 'for enabled registry' do
|
||||
let(:registry_settings) do
|
||||
{
|
||||
enabled: true,
|
||||
host_port: 'example.com',
|
||||
}
|
||||
{ enabled: true,
|
||||
host_port: 'example.com' }
|
||||
end
|
||||
|
||||
it { is_expected.not_to be_nil }
|
||||
|
@ -1425,9 +1408,7 @@ describe Project, models: true do
|
|||
|
||||
context 'for disabled registry' do
|
||||
let(:registry_settings) do
|
||||
{
|
||||
enabled: false
|
||||
}
|
||||
{ enabled: false }
|
||||
end
|
||||
|
||||
it { is_expected.to be_nil }
|
||||
|
@ -1437,28 +1418,60 @@ describe Project, models: true do
|
|||
describe '#has_container_registry_tags?' do
|
||||
let(:project) { create(:empty_project) }
|
||||
|
||||
subject { project.has_container_registry_tags? }
|
||||
|
||||
context 'for enabled registry' do
|
||||
context 'when container registry is enabled' do
|
||||
before { stub_container_registry_config(enabled: true) }
|
||||
|
||||
context 'with tags' do
|
||||
before { stub_container_registry_tags('test', 'test2') }
|
||||
context 'when tags are present for multi-level registries' do
|
||||
before do
|
||||
create(:container_repository, project: project, name: 'image')
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
stub_container_registry_tags(repository: /image/,
|
||||
tags: %w[latest rc1])
|
||||
end
|
||||
|
||||
it 'should have image tags' do
|
||||
expect(project).to have_container_registry_tags
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no tags' do
|
||||
before { stub_container_registry_tags }
|
||||
context 'when tags are present for root repository' do
|
||||
before do
|
||||
stub_container_registry_tags(repository: project.full_path,
|
||||
tags: %w[latest rc1 pre1])
|
||||
end
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
it 'should have image tags' do
|
||||
expect(project).to have_container_registry_tags
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are no tags at all' do
|
||||
before do
|
||||
stub_container_registry_tags(repository: :any, tags: [])
|
||||
end
|
||||
|
||||
it 'should not have image tags' do
|
||||
expect(project).not_to have_container_registry_tags
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for disabled registry' do
|
||||
context 'when container registry is disabled' do
|
||||
before { stub_container_registry_config(enabled: false) }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
it 'should not have image tags' do
|
||||
expect(project).not_to have_container_registry_tags
|
||||
end
|
||||
|
||||
it 'should not check root repository tags' do
|
||||
expect(project).not_to receive(:full_path)
|
||||
expect(project).not_to have_container_registry_tags
|
||||
end
|
||||
|
||||
it 'should iterate through container repositories' do
|
||||
expect(project).to receive(:container_repositories)
|
||||
expect(project).not_to have_container_registry_tags
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -6,14 +6,15 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
let(:current_params) { {} }
|
||||
let(:rsa_key) { OpenSSL::PKey::RSA.generate(512) }
|
||||
let(:payload) { JWT.decode(subject[:token], rsa_key).first }
|
||||
|
||||
let(:authentication_abilities) do
|
||||
[
|
||||
:read_container_image,
|
||||
:create_container_image
|
||||
]
|
||||
[:read_container_image, :create_container_image]
|
||||
end
|
||||
|
||||
subject { described_class.new(current_project, current_user, current_params).execute(authentication_abilities: authentication_abilities) }
|
||||
subject do
|
||||
described_class.new(current_project, current_user, current_params)
|
||||
.execute(authentication_abilities: authentication_abilities)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Gitlab.config.registry).to receive_messages(enabled: true, issuer: 'rspec', key: nil)
|
||||
|
@ -40,13 +41,11 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
end
|
||||
|
||||
shared_examples 'a accessible' do
|
||||
shared_examples 'an accessible' do
|
||||
let(:access) do
|
||||
[{
|
||||
'type' => 'repository',
|
||||
[{ 'type' => 'repository',
|
||||
'name' => project.path_with_namespace,
|
||||
'actions' => actions,
|
||||
}]
|
||||
'actions' => actions }]
|
||||
end
|
||||
|
||||
it_behaves_like 'a valid token'
|
||||
|
@ -59,19 +58,19 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
shared_examples 'a pullable' do
|
||||
it_behaves_like 'a accessible' do
|
||||
it_behaves_like 'an accessible' do
|
||||
let(:actions) { ['pull'] }
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'a pushable' do
|
||||
it_behaves_like 'a accessible' do
|
||||
it_behaves_like 'an accessible' do
|
||||
let(:actions) { ['push'] }
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'a pullable and pushable' do
|
||||
it_behaves_like 'a accessible' do
|
||||
it_behaves_like 'an accessible' do
|
||||
let(:actions) { %w(pull push) }
|
||||
end
|
||||
end
|
||||
|
@ -81,15 +80,30 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
it { is_expected.not_to include(:token) }
|
||||
end
|
||||
|
||||
shared_examples 'container repository factory' do
|
||||
it 'creates a new container repository resource' do
|
||||
expect { subject }
|
||||
.to change { project.container_repositories.count }.by(1)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'not a container repository factory' do
|
||||
it 'does not create a new container repository resource' do
|
||||
expect { subject }.not_to change { ContainerRepository.count }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#full_access_token' do
|
||||
let(:project) { create(:empty_project) }
|
||||
let(:token) { described_class.full_access_token(project.path_with_namespace) }
|
||||
|
||||
subject { { token: token } }
|
||||
|
||||
it_behaves_like 'a accessible' do
|
||||
it_behaves_like 'an accessible' do
|
||||
let(:actions) { ['*'] }
|
||||
end
|
||||
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'user authorization' do
|
||||
|
@ -110,16 +124,20 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a pushable'
|
||||
it_behaves_like 'container repository factory'
|
||||
end
|
||||
|
||||
context 'allow reporter to pull images' do
|
||||
before { project.team << [current_user, :reporter] }
|
||||
|
||||
let(:current_params) do
|
||||
{ scope: "repository:#{project.path_with_namespace}:pull" }
|
||||
end
|
||||
context 'when pulling from root level repository' do
|
||||
let(:current_params) do
|
||||
{ scope: "repository:#{project.path_with_namespace}:pull" }
|
||||
end
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
|
||||
context 'return a least of privileges' do
|
||||
|
@ -130,6 +148,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'disallow guest to pull or push images' do
|
||||
|
@ -140,6 +159,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -152,6 +172,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'disallow anyone to push images' do
|
||||
|
@ -160,6 +181,16 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'when repository name is invalid' do
|
||||
let(:current_params) do
|
||||
{ scope: 'repository:invalid:push' }
|
||||
end
|
||||
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -173,6 +204,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'disallow anyone to push images' do
|
||||
|
@ -181,6 +213,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -191,6 +224,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -198,11 +232,9 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
context 'build authorized as user' do
|
||||
let(:current_project) { create(:empty_project) }
|
||||
let(:current_user) { create(:user) }
|
||||
|
||||
let(:authentication_abilities) do
|
||||
[
|
||||
:build_read_container_image,
|
||||
:build_create_container_image
|
||||
]
|
||||
[:build_read_container_image, :build_create_container_image]
|
||||
end
|
||||
|
||||
before do
|
||||
|
@ -219,6 +251,10 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
it_behaves_like 'a pullable and pushable' do
|
||||
let(:project) { current_project }
|
||||
end
|
||||
|
||||
it_behaves_like 'container repository factory' do
|
||||
let(:project) { current_project }
|
||||
end
|
||||
end
|
||||
|
||||
context 'for other projects' do
|
||||
|
@ -231,11 +267,13 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
let(:project) { create(:empty_project, :public) }
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
shared_examples 'pullable for being team member' do
|
||||
context 'when you are not member' do
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'when you are member' do
|
||||
|
@ -244,12 +282,14 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'when you are owner' do
|
||||
let(:project) { create(:empty_project, namespace: current_user.namespace) }
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -263,6 +303,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
|
||||
context 'when you are not member' do
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'when you are member' do
|
||||
|
@ -271,12 +312,14 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'when you are owner' do
|
||||
let(:project) { create(:empty_project, namespace: current_user.namespace) }
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -296,12 +339,14 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'when you are owner' do
|
||||
let(:project) { create(:empty_project, :public, namespace: current_user.namespace) }
|
||||
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -318,6 +363,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'an inaccessible'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -325,6 +371,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
context 'unauthorized' do
|
||||
context 'disallow to use scope-less authentication' do
|
||||
it_behaves_like 'a forbidden'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'for invalid scope' do
|
||||
|
@ -333,6 +380,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a forbidden'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'for private project' do
|
||||
|
@ -354,6 +402,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a pullable'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
|
||||
context 'when pushing' do
|
||||
|
@ -362,6 +411,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do
|
|||
end
|
||||
|
||||
it_behaves_like 'a forbidden'
|
||||
it_behaves_like 'not a container repository factory'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,6 +7,11 @@ describe Projects::DestroyService, services: true do
|
|||
let!(:remove_path) { path.sub(/\.git\Z/, "+#{project.id}+deleted.git") }
|
||||
let!(:async) { false } # execute or async_execute
|
||||
|
||||
before do
|
||||
stub_container_registry_config(enabled: true)
|
||||
stub_container_registry_tags(repository: :any, tags: [])
|
||||
end
|
||||
|
||||
shared_examples 'deleting the project' do
|
||||
it 'deletes the project' do
|
||||
expect(Project.unscoped.all).not_to include(project)
|
||||
|
@ -89,30 +94,64 @@ describe Projects::DestroyService, services: true do
|
|||
it_behaves_like 'deleting the project with pipeline and build'
|
||||
end
|
||||
|
||||
context 'container registry' do
|
||||
before do
|
||||
stub_container_registry_config(enabled: true)
|
||||
stub_container_registry_tags('tag')
|
||||
end
|
||||
describe 'container registry' do
|
||||
context 'when there are regular container repositories' do
|
||||
let(:container_repository) { create(:container_repository) }
|
||||
|
||||
context 'tags deletion succeeds' do
|
||||
it do
|
||||
expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete).and_return(true)
|
||||
before do
|
||||
stub_container_registry_tags(repository: project.full_path + '/image',
|
||||
tags: ['tag'])
|
||||
project.container_repositories << container_repository
|
||||
end
|
||||
|
||||
destroy_project(project, user, {})
|
||||
context 'when image repository deletion succeeds' do
|
||||
it 'removes tags' do
|
||||
expect_any_instance_of(ContainerRepository)
|
||||
.to receive(:delete_tags!).and_return(true)
|
||||
|
||||
destroy_project(project, user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when image repository deletion fails' do
|
||||
it 'raises an exception' do
|
||||
expect_any_instance_of(ContainerRepository)
|
||||
.to receive(:delete_tags!).and_return(false)
|
||||
|
||||
expect{ destroy_project(project, user) }
|
||||
.to raise_error(ActiveRecord::RecordNotDestroyed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'tags deletion fails' do
|
||||
before { expect_any_instance_of(ContainerRegistry::Tag).to receive(:delete).and_return(false) }
|
||||
context 'when there are tags for legacy root repository' do
|
||||
before do
|
||||
stub_container_registry_tags(repository: project.full_path,
|
||||
tags: ['tag'])
|
||||
end
|
||||
|
||||
subject { destroy_project(project, user, {}) }
|
||||
context 'when image repository tags deletion succeeds' do
|
||||
it 'removes tags' do
|
||||
expect_any_instance_of(ContainerRepository)
|
||||
.to receive(:delete_tags!).and_return(true)
|
||||
|
||||
it { expect{subject}.to raise_error(Projects::DestroyService::DestroyError) }
|
||||
destroy_project(project, user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when image repository tags deletion fails' do
|
||||
it 'raises an exception' do
|
||||
expect_any_instance_of(ContainerRepository)
|
||||
.to receive(:delete_tags!).and_return(false)
|
||||
|
||||
expect { destroy_project(project, user) }
|
||||
.to raise_error(Projects::DestroyService::DestroyError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy_project(project, user, params)
|
||||
def destroy_project(project, user, params = {})
|
||||
if async
|
||||
Projects::DestroyService.new(project, user, params).async_execute
|
||||
else
|
||||
|
|
|
@ -29,9 +29,12 @@ describe Projects::TransferService, services: true do
|
|||
end
|
||||
|
||||
context 'disallow transfering of project with tags' do
|
||||
let(:container_repository) { create(:container_repository) }
|
||||
|
||||
before do
|
||||
stub_container_registry_config(enabled: true)
|
||||
stub_container_registry_tags('tag')
|
||||
stub_container_registry_tags(repository: :any, tags: ['tag'])
|
||||
project.container_repositories << container_repository
|
||||
end
|
||||
|
||||
subject { transfer_project(project, user, group) }
|
||||
|
|
|
@ -27,23 +27,40 @@ module StubGitlabCalls
|
|||
|
||||
def stub_container_registry_config(registry_settings)
|
||||
allow(Gitlab.config.registry).to receive_messages(registry_settings)
|
||||
allow(Auth::ContainerRegistryAuthenticationService).to receive(:full_access_token).and_return('token')
|
||||
allow(Auth::ContainerRegistryAuthenticationService)
|
||||
.to receive(:full_access_token).and_return('token')
|
||||
end
|
||||
|
||||
def stub_container_registry_tags(*tags)
|
||||
allow_any_instance_of(ContainerRegistry::Client).to receive(:repository_tags).and_return(
|
||||
{ "tags" => tags }
|
||||
)
|
||||
allow_any_instance_of(ContainerRegistry::Client).to receive(:repository_manifest).and_return(
|
||||
JSON.parse(File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json'))
|
||||
)
|
||||
allow_any_instance_of(ContainerRegistry::Client).to receive(:blob).and_return(
|
||||
File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json')
|
||||
)
|
||||
def stub_container_registry_tags(repository: :any, tags:)
|
||||
repository = any_args if repository == :any
|
||||
|
||||
allow_any_instance_of(ContainerRegistry::Client)
|
||||
.to receive(:repository_tags).with(repository)
|
||||
.and_return({ 'tags' => tags })
|
||||
|
||||
allow_any_instance_of(ContainerRegistry::Client)
|
||||
.to receive(:repository_manifest).with(repository)
|
||||
.and_return(stub_container_registry_tag_manifest)
|
||||
|
||||
allow_any_instance_of(ContainerRegistry::Client)
|
||||
.to receive(:blob).with(repository)
|
||||
.and_return(stub_container_registry_blob)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_container_registry_tag_manifest
|
||||
fixture_path = 'spec/fixtures/container_registry/tag_manifest.json'
|
||||
|
||||
JSON.parse(File.read(Rails.root + fixture_path))
|
||||
end
|
||||
|
||||
def stub_container_registry_blob
|
||||
fixture_path = 'spec/fixtures/container_registry/config_blob.json'
|
||||
|
||||
File.read(Rails.root + fixture_path)
|
||||
end
|
||||
|
||||
def gitlab_url
|
||||
Gitlab.config.gitlab.url
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue