From 7ae5179ba623a8e54141d201c9a617fad69c66f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miquel=20Sabat=C3=A9=20Sol=C3=A0?= Date: Tue, 3 May 2016 17:41:35 +0200 Subject: [PATCH 1/4] Added support for removing images/tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft delete support has been supported in Docker Distribution since at least 2.1. This was not enough to implement the removal of images/tags in Portus because there was no support to GC these soft deleted blobs. Since 2.4, it's possible to not just delete the manifest, but also to GC blobs no longer referenced by any image manifest. This means that after being able to track digests, we can now safely provide image/tag removal support. For safety concerns, tags with an empty digest will not be allowed to be removed (this is more likely to be the case of Portus versions that have been running for some time). In previous PRs this has already been addressed, so admins can update their DB filling in the empty gaps (e.g. see PRs #825 or #830). The main downside of this change is that there is no way for a client to detect whether a remote registry supports GC. Because of this, a configuration option has been provided, which is disabled by default. The rationale is that administrators that are really sure about the availability of GC on their private registry will have to enable it explicitly. Fixes #197 Signed-off-by: Miquel Sabaté Solà --- .rubocop.yml | 4 +- app/assets/stylesheets/repositories.scss | 8 ++ app/controllers/concerns/delete_enabled.rb | 14 +++ app/controllers/repositories_controller.rb | 26 +++- app/controllers/tags_controller.rb | 24 ++++ app/helpers/repositories_helper.rb | 50 ++++++-- app/jobs/catalog_job.rb | 5 +- app/models/namespace.rb | 3 +- app/models/repository.rb | 40 +++++- app/models/tag.rb | 82 ++++++++++++- .../namespace/_delete.html.slim | 20 +++ .../repository/_delete.csv.slim | 9 ++ .../repository/_delete.html.slim | 12 ++ app/views/repositories/show.html.slim | 35 +++++- config/config.yml | 11 ++ config/routes.rb | 4 +- ...301_add_marked_to_repositories_and_tags.rb | 6 + db/schema.rb | 10 +- lib/portus/registry_client.rb | 3 +- packaging/suse/portusctl/lib/cli.rb | 5 + .../portusctl/templates/config-local.yml.erb | 11 ++ .../repositories_controller_spec.rb | 54 +++++++- spec/controllers/tags_controller_spec.rb | 55 +++++++++ spec/factories/namespaces.rb | 3 +- spec/factories/repositories.rb | 1 + spec/features/repositories_spec.rb | 2 + spec/helpers/repositories_helper_spec.rb | 46 +++++-- spec/jobs/catalog_job_spec.rb | 4 +- spec/models/namespace_spec.rb | 3 +- spec/models/repository_spec.rb | 51 ++++++++ spec/models/tag_spec.rb | 116 ++++++++++++++++++ 31 files changed, 663 insertions(+), 54 deletions(-) create mode 100644 app/controllers/concerns/delete_enabled.rb create mode 100644 app/controllers/tags_controller.rb create mode 100644 app/views/public_activity/namespace/_delete.html.slim create mode 100644 app/views/public_activity/repository/_delete.csv.slim create mode 100644 app/views/public_activity/repository/_delete.html.slim create mode 100644 db/migrate/20160502140301_add_marked_to_repositories_and_tags.rb create mode 100644 spec/controllers/tags_controller_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 5e6035cca..f8d9d43b1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,9 +2,9 @@ inherit_from: - ./config/rubocop-suse.yml -# TODO: (mssola) only the LDAP class requires this. +# TODO: (mssola) only the LDAP class and portusctl require this. Metrics/ClassLength: - Max: 150 + Max: 160 # TODO: (mssola) Some methods are offending this cop. In the SUSE's style guide # the approach is to use Rubocop's default value. In the near future I will diff --git a/app/assets/stylesheets/repositories.scss b/app/assets/stylesheets/repositories.scss index 882474abf..421dfb3a3 100644 --- a/app/assets/stylesheets/repositories.scss +++ b/app/assets/stylesheets/repositories.scss @@ -1,3 +1,11 @@ .tags .label.label-success { margin: 0px 2px; } + +#remove-repo button { + padding: 0px 10px 0px 0px; +} + +.remove-repo:focus, .remove-repo:hover, .remove-tag:focus, .remove-tag:hover { + text-decoration: none; +} diff --git a/app/controllers/concerns/delete_enabled.rb b/app/controllers/concerns/delete_enabled.rb new file mode 100644 index 000000000..fb06304a0 --- /dev/null +++ b/app/controllers/concerns/delete_enabled.rb @@ -0,0 +1,14 @@ +# DeleteEnabeld redirects the user back if delete support is not enabled. A +# `before_action` will be created for the :destroy method. +module DeleteEnabled + extend ActiveSupport::Concern + + included do + before_action :delete_enabled?, only: [:destroy] + end + + # Returns true if users can delete images/tags. + def delete_enabled? + redirect_to :back, status: :forbidden unless APP_CONFIG.enabled?("delete") + end +end diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb index bb703c852..d5fbb86b2 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -1,5 +1,7 @@ class RepositoriesController < ApplicationController - before_action :set_repository, only: [:show, :toggle_star] + include DeleteEnabled + + before_action :set_repository, only: [:show, :destroy, :toggle_star] # GET /repositories # GET /repositories.json @@ -23,6 +25,28 @@ def toggle_star render template: "repositories/star", locals: { user: current_user } end + # Removes all the tags that belong to this repository, and removes it. + def destroy + # First of all we mark the repo and the tags, so we don't have concurrency + # problems when "delete" events come in. + @repository.tags.update_all(marked: true) + @repository.update_attributes(marked: true) + + # Remove all tags, effectively removing them from the registry too. + @repository.groupped_tags.map { |t| t.first.delete_by_digest!(current_user) } + + # Delete this repository if all tags were successfully deleted. + if @repository.reload.tags.any? + redirect_to repository_path(@repository), alert: "Could not remove all the tags" + else + @repository.delete_and_update!(current_user) + redirect_to namespace_path(@repository.namespace), + notice: "Repository removed with all its tags" + end + end + + protected + def set_repository @repository = Repository.find(params[:id]) end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 000000000..59742bab0 --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -0,0 +1,24 @@ +class TagsController < ApplicationController + include DeleteEnabled + + # Removes all tags the match the digest of the tag with the given ID. + # Moreover, it will also remove the image if it's left empty after removing + # the tags. + def destroy + tag = Tag.find(params[:id]) + + # And now remove the tag by the digest. If the repository containing said + # tags becomes empty after that, remove it too. + repo = tag.repository + if tag.delete_by_digest!(current_user) + if repo.tags.empty? + repo.delete_and_update!(current_user) + redirect_to namespace_path(repo.namespace), notice: "Image removed with all its tags" + else + redirect_to repository_path(tag.repository), notice: "Tag removed successfully" + end + else + redirect_to repository_path(tag.repository), alert: "Tag could not be removed" + end + end +end diff --git a/app/helpers/repositories_helper.rb b/app/helpers/repositories_helper.rb index 42b7319af..bf2cd9992 100644 --- a/app/helpers/repositories_helper.rb +++ b/app/helpers/repositories_helper.rb @@ -2,9 +2,21 @@ # them, dangling repositories that used to contain them. Because of this, this # helper renders the proper HTML for push activities, while being safe at it. module RepositoriesHelper - # Renders a push activity, that is, a repository has been pushed. + # Renders a push activity, that is, a repository/tag has been pushed. def render_push_activity(activity) - owner = content_tag(:strong, "#{fetch_owner(activity)} pushed ") + render_repo_activity(activity, "pushed") + end + + # Renders a delete activity, that is, a repository/tag has been deleted. + def render_delete_activity(activity) + render_repo_activity(activity, "deleted") + end + + protected + + # General method for rendering an activity regarding repositories. + def render_repo_activity(activity, action) + owner = content_tag(:strong, "#{fetch_owner(activity)} #{action} ") namespace = render_namespace(activity) namespace += " / " unless namespace.empty? @@ -12,8 +24,6 @@ def render_push_activity(activity) owner + namespace + render_repository(activity) end - protected - # Fetches the owner of the activity in a safe way. def fetch_owner(activity) activity.owner.nil? ? "Someone" : activity.owner.username @@ -27,22 +37,24 @@ def fetch_owner(activity) def render_namespace(activity) tr = activity.trackable - if tr.nil? + if tr.nil? || tr.is_a?(Namespace) if activity.parameters[:namespace_name].nil? "" else namespace = Namespace.find_by(id: activity.parameters[:namespace_id]) - if namespace.nil? - content_tag(:span, activity.parameters[:namespace_name]) - else - link_to activity.parameters[:namespace_name], namespace - end + tag_or_link(namespace, activity.parameters[:namespace_name]) end else link_to tr.namespace.clean_name, tr.namespace end end + # Returns a link if the namespace is not nil, otherwise just a tag with the + # given name. + def tag_or_link(namespace, name) + namespace.nil? ? content_tag(:span, name) : link_to(name, namespace) + end + # Renders the repository part of the activity in a safe manner. def render_repository(activity) repo, link, tag = get_repo_link_tag(activity) @@ -59,16 +71,17 @@ def get_repo_link_tag(activity) tr = activity.trackable if tr.nil? - if activity.parameters[:repo_name].nil? + if repo_name(activity).nil? ["a repository", nil, ""] else - repo = activity.parameters[:repo_name] + repo = repo_name(activity) ns = Namespace.find_by(id: activity.parameters[:namespace_id]) link = ns.nil? ? nil : namespace_path(ns.id) [repo, link, tag_part(activity)] end else - [tr.name, tr, tag_part(activity)] + name, l = name_and_link(tr, activity) + [name, l, tag_part(activity)] end end @@ -86,4 +99,15 @@ def tag_part(activity) ":#{activity.recipient.name}" end end + + # Fetch the name of the repo from the given activity. + def repo_name(activity) + activity.parameters[:repo_name] || activity.parameters[:repository_name] + end + + # Returns the name and the link to the given tr depending on whether it's a + # Namespace or not. + def name_and_link(tr, activity) + tr.is_a?(Namespace) ? [repo_name(activity), nil] : [tr.name, tr] + end end diff --git a/app/jobs/catalog_job.rb b/app/jobs/catalog_job.rb index d9ece2203..76d460a90 100644 --- a/app/jobs/catalog_job.rb +++ b/app/jobs/catalog_job.rb @@ -38,7 +38,8 @@ def update_registry!(catalog) # At this point, the remaining items in the "repos" array are repos that # exist in the DB but not in the catalog. Remove all of them. - Tag.where(repository_id: dangling_repos).find_each(&:delete_and_update!) - Repository.where(id: dangling_repos).destroy_all + portus = User.find_by(username: "portus") + Tag.where(repository_id: dangling_repos).find_each { |t| t.delete_and_update!(portus) } + Repository.where(id: dangling_repos).find_each { |r| r.delete_and_update!(portus) } end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5c2055abc..782182b0a 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -8,9 +8,8 @@ # updated_at :datetime not null # team_id :integer # public :boolean default("0") -# registry_id :integer not null +# registry_id :integer # global :boolean default("0") -# description :text(65535) # # Indexes # diff --git a/app/models/repository.rb b/app/models/repository.rb index f68454dde..ed2ce5f23 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -7,6 +7,7 @@ # namespace_id :integer # created_at :datetime not null # updated_at :datetime not null +# marked :boolean default("0") # # Indexes # @@ -59,6 +60,33 @@ def groupped_tags end end + # Updates the activities related to this repository and adds a new activity + # regarding the removal of this. + def delete_and_update!(actor) + logger.tagged("catalog") { logger.info "Removed the image '#{name}'." } + + # Take care of current activities. + PublicActivity::Activity.where(trackable: self).update_all( + trackable_type: Namespace, + trackable_id: namespace.id, + recipient_type: nil + ) + + # Add a "delete" activity" + namespace.create_activity( + :delete, + owner: actor, + recipient: self, + parameters: { + repository_name: name, + namespace_id: namespace.id, + namespace_name: namespace.clean_name + } + ) + + destroy + end + # Handle a push event from the registry. def self.handle_push_event(event) registry = Registry.find_from_event(event) @@ -82,11 +110,15 @@ def self.handle_delete_event(event) # Fetch the repo. ns, repo_name, = registry.get_namespace_from_event(event, false) repo = ns.repositories.find_by(name: repo_name) - return if repo.nil? + return if repo.nil? || repo.marked? # Destroy tags and the repository if it's empty now. - repo.tags.where(digest: event["target"]["digest"]).map(&:delete_and_update!) - repo.destroy if !repo.nil? && repo.tags.empty? + user = User.find_from_event(event) + repo.tags.where(digest: event["target"]["digest"], marked: false).map do |t| + t.delete_and_update!(user) + end + repo = repo.reload + repo.delete_and_update!(user) if !repo.nil? && repo.tags.empty? end # Add the repository with the given `repo` name and the given `tag`. The @@ -169,7 +201,7 @@ def self.create_or_update!(repo) end # Finally remove the tags that are left and return the repo. - repository.tags.where(name: to_be_deleted_tags).find_each(&:delete_and_update!) + repository.tags.where(name: to_be_deleted_tags).find_each { |t| t.delete_and_update!(portus) } repository.reload end end diff --git a/app/models/tag.rb b/app/models/tag.rb index 2164971af..0e4584d11 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -10,6 +10,7 @@ # user_id :integer # digest :string(255) # image_id :string(255) default("") +# marked :boolean default("0") # # Indexes # @@ -29,9 +30,74 @@ class Tag < ActiveRecord::Base # and that's guaranteed to have a good format. validates :name, uniqueness: { scope: "repository_id" } + # Delete all the tags that match the given digest. Call this method if you + # want to: + # + # - Safely remove tags (with its re-tags) on the DB. + # - Remove the manifest digest on the registry. + # - Preserve the activities related to the tags that are to be removed. + # + # Returns true on success, false otherwise. + def delete_by_digest!(actor) + dig = fetch_digest + return false if dig.blank? + + Tag.where(digest: dig).update_all(marked: true) + + begin + Registry.get.client.delete(repository.name, dig, "manifests") + rescue StandardError => e + Rails.logger.error "Could not delete tag on the registry: #{e.message}" + return false + end + + Tag.where(digest: dig).map { |t| t.delete_and_update!(actor) } + end + # Delete this tag and update its activity. - def delete_and_update! + def delete_and_update!(actor) logger.tagged("catalog") { logger.info "Removed the tag '#{name}'." } + + # If the tag is no longer there, ignore this call and return early. + unless Tag.find_by(id: id) + logger.tagged("catalog") { logger.info "Ignoring..." } + return + end + + # Delete tag and create the corresponding activities. + destroy + create_delete_activities!(actor) + end + + protected + + # Fetch the digest for this tag. Usually the digest should already be + # initialized since it's provided by the event notification that created this + # tag. However, it might happen that the digest column is left blank (e.g. + # legacy Portus, unknown error, etc). In these cases, this method will fetch + # the manifest from the registry. + # + # Returns a string containing the digest on success. Otherwise it returns + # nil. + def fetch_digest + if digest.blank? + client = Registry.get.client + + begin + _, dig, = client.manifest(repository.name, name) + update_attributes(digest: dig) + dig + rescue StandardError => e + Rails.logger.error "Could not fetch manifest digest: #{e.message}" + nil + end + else + digest + end + end + + # Create/update the activities for a delete operation. + def create_delete_activities!(actor) PublicActivity::Activity.where(recipient: self).update_all( parameters: { namespace_id: repository.namespace.id, @@ -40,6 +106,18 @@ def delete_and_update! tag_name: name } ) - destroy + + # Create the delete activity. + repository.create_activity( + :delete, + owner: actor, + recipient: self, + parameters: { + repository_name: repository.name, + namespace_id: repository.namespace.id, + namespace_name: repository.namespace.clean_name, + tag_name: name + } + ) end end diff --git a/app/views/public_activity/namespace/_delete.html.slim b/app/views/public_activity/namespace/_delete.html.slim new file mode 100644 index 000000000..db8f3c165 --- /dev/null +++ b/app/views/public_activity/namespace/_delete.html.slim @@ -0,0 +1,20 @@ +li + .activitie-container + .activity-type.repo-deleted + i.fa.fa-ship + .user-image + = user_image_tag(activity.owner.email) + .description + h6 + strong + = " #{activity.owner.username} deleted " + - if activity.parameters[:repository_name] + = link_to activity.trackable.clean_name, activity.trackable + = " / #{activity.parameters[:repository_name]}" + - else + = "a repository under the " + = link_to activity.trackable.clean_name, activity.trackable + = " namespace" + small + i.fa.fa-clock-o + = activity_time_tag activity.created_at diff --git a/app/views/public_activity/repository/_delete.csv.slim b/app/views/public_activity/repository/_delete.csv.slim new file mode 100644 index 000000000..4cde49073 --- /dev/null +++ b/app/views/public_activity/repository/_delete.csv.slim @@ -0,0 +1,9 @@ +- unless activity.trackable.nil? + - if activity.recipient.nil? + - if activity.parameters[:tag_name].nil? + = CSV.generate_line(['repository', "#{activity.trackable.namespace.global? ? activity.trackable.namespace.registry.hostname : activity.trackable.namespace.name}/#{activity.trackable.name}", 'delete tag', '-', activity.owner.username, activity.created_at, "-"]) + - else + = CSV.generate_line(['repository', + "#{activity.trackable.namespace.global? ? activity.trackable.namespace.registry.hostname : activity.trackable.namespace.name}/#{activity.trackable.name}:#{activity.parameters[:tag_name]}", 'delete tag', '-', activity.owner.username, activity.created_at, "-"]) + - else + = CSV.generate_line(['repository', "#{activity.trackable.namespace.global? ? activity.trackable.namespace.registry.hostname : activity.trackable.namespace.name}/#{activity.trackable.name}:#{activity.recipient.name}", 'delete tag', '-', activity.owner.username, activity.created_at, "-"]) diff --git a/app/views/public_activity/repository/_delete.html.slim b/app/views/public_activity/repository/_delete.html.slim new file mode 100644 index 000000000..50c86a6fa --- /dev/null +++ b/app/views/public_activity/repository/_delete.html.slim @@ -0,0 +1,12 @@ +li + .activitie-container + .activity-type.repo-deleted + i.fa.fa-forward.fa-fw + .user-image + = user_image_tag(activity.owner.email) + .description + h6 + = render_delete_activity(activity) + small + i.fa.fa-clock-o + = activity_time_tag activity.created_at diff --git a/app/views/repositories/show.html.slim b/app/views/repositories/show.html.slim index 56f01e293..10581fdc7 100644 --- a/app/views/repositories/show.html.slim +++ b/app/views/repositories/show.html.slim @@ -15,19 +15,37 @@ span#star-counter.btn.btn-primary.btn-xs = @repository.stars.count + - if APP_CONFIG.enabled?("delete") + #remove-repo.pull-right + button.remove-repo.fa.fa-lg.fa-trash-o.btn-x.btn-edit-role.btn.btn-link[ + data-placement="left" + data-toggle="popover" + data-title="Please confirm" + data-content='

Are you sure you want to remove this \ + image?

No Yes' + data-html="true" + role="button" + title="Delete image"] + .panel-body .table-responsive.tags table.table.table-stripped.table-hover col.col-40 col.col-20 + col.col-15 col.col-20 - col.col-20 + - if APP_CONFIG.enabled?("delete") + col.col-5 thead tr th Tag th Author th Image th Pushed at + - if APP_CONFIG.enabled?("delete") + th tbody - @tags.each do |tag| - if tag.first.digest.blank? @@ -59,6 +77,21 @@ = tag.first.image_id[0, 12] td= tag.first.created_at + - if APP_CONFIG.enabled?("delete") + td + button.remove-tag.fa.fa-lg.fa-trash-o.btn-x.btn-edit-role.btn.btn-link[ + data-placement="left" + data-toggle="popover" + data-title="Please confirm" + data-content='

Are you sure you want to remove this \ + tag?

No Yes' + data-html="true" + role="button" + title="Delete tag"] + + #write_comment_form.collapse = form_for [@repository, @repository.comments.build], remote: true, html: {id: 'new-comment-form', class: 'form-horizontal', role: 'form'} do |f| .form-group diff --git a/config/config.yml b/config/config.yml index 242ac0e19..81766014b 100644 --- a/config/config.yml +++ b/config/config.yml @@ -24,6 +24,17 @@ email: gravatar: enabled: true +# Allow admins and owners to delete images and tags. This feature should *only* +# be enabled if the version of the running registry is 2.4 or higher since +# it's the first version that supports garbage collection. That being said, +# Portus will only delete the manifests of the tags and administrators are +# supposed to be responsible for garbage collecting unreferenced blobs. This is +# because the registry 2.4 does not garbage collect automatically. For more +# information on garbage collection on the registry, read the documentation: +# https://github.com/docker/distribution/blob/master/docs/garbage-collection.md +delete: + enabled: false + # LDAP support. If enabled, then only users of the specified LDAP server will # be able to use Portus. ldap: diff --git a/config/routes.rb b/config/routes.rb index 570b56b5e..8c79dde8f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,11 +11,13 @@ end get "namespaces/typeahead/:query" => "namespaces#typeahead", :defaults => { format: "json" } - resources :repositories, only: [:index, :show] do + resources :repositories, only: [:index, :show, :destroy] do post :toggle_star, on: :member resources :comments, only: [:create, :destroy] end + resources :tags, only: [:destroy] + resources :application_tokens, only: [:create, :destroy] devise_for :users, controllers: { registrations: "auth/registrations", diff --git a/db/migrate/20160502140301_add_marked_to_repositories_and_tags.rb b/db/migrate/20160502140301_add_marked_to_repositories_and_tags.rb new file mode 100644 index 000000000..1eb7ae97b --- /dev/null +++ b/db/migrate/20160502140301_add_marked_to_repositories_and_tags.rb @@ -0,0 +1,6 @@ +class AddMarkedToRepositoriesAndTags < ActiveRecord::Migration + def change + add_column :repositories, :marked, :boolean, default: false + add_column :tags, :marked, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 85cbddfe8..388fe9d8a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160422075603) do +ActiveRecord::Schema.define(version: 20160502140301) do create_table "activities", force: :cascade do |t| t.integer "trackable_id", limit: 4 @@ -89,10 +89,11 @@ add_index "registries", ["name"], name: "index_registries_on_name", unique: true, using: :btree create_table "repositories", force: :cascade do |t| - t.string "name", limit: 255, default: "", null: false + t.string "name", limit: 255, default: "", null: false t.integer "namespace_id", limit: 4 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "marked", default: false end add_index "repositories", ["name", "namespace_id"], name: "index_repositories_on_name_and_namespace_id", unique: true, using: :btree @@ -117,6 +118,7 @@ t.integer "user_id", limit: 4 t.string "digest", limit: 255 t.string "image_id", limit: 255, default: "" + t.boolean "marked", default: false end add_index "tags", ["name", "repository_id"], name: "index_tags_on_name_and_repository_id", unique: true, using: :btree diff --git a/lib/portus/registry_client.rb b/lib/portus/registry_client.rb index ca99748f1..faa2a9d70 100644 --- a/lib/portus/registry_client.rb +++ b/lib/portus/registry_client.rb @@ -98,6 +98,7 @@ def paged_response(link, field) until link.empty? page, link = get_page(link) + next unless page[field] res += page[field] end res @@ -138,7 +139,7 @@ def add_tags(repositories) repositories.each do |repo| begin ts = tags(repo) - result << { "name" => repo, "tags" => ts } unless ts.nil? + result << { "name" => repo, "tags" => ts } unless ts.blank? rescue StandardError => e Rails.logger.debug "Could not get tags for repo: #{repo}: #{e.message}." end diff --git a/packaging/suse/portusctl/lib/cli.rb b/packaging/suse/portusctl/lib/cli.rb index dcf397d21..c2adec0ef 100644 --- a/packaging/suse/portusctl/lib/cli.rb +++ b/packaging/suse/portusctl/lib/cli.rb @@ -105,6 +105,11 @@ class Cli < Thor type: :boolean, default: true + option "delete-enable", + desc: "Enable delete support. Only do this if your registry is 2.4 or higher", + type: :boolean, + default: false + def setup ensure_root check_setup_flags diff --git a/packaging/suse/portusctl/templates/config-local.yml.erb b/packaging/suse/portusctl/templates/config-local.yml.erb index 446dff3be..39fa4534f 100644 --- a/packaging/suse/portusctl/templates/config-local.yml.erb +++ b/packaging/suse/portusctl/templates/config-local.yml.erb @@ -29,6 +29,17 @@ email: gravatar: enabled: <%= @options["gravatar-enable"] %> +# Allow admins and owners to delete images and tags. This feature should *only* +# be enabled if the version of the running registry is 2.4 or higher since +# it's the first version that supports garbage collection. That being said, +# Portus will only delete the manifests of the tags and administrators are +# supposed to be responsible for garbage collecting unreferenced blobs. This is +# because the registry 2.4 does not garbage collect automatically. For more +# information on garbage collection on the registry, read the documentation: +# https://github.com/docker/distribution/blob/master/docs/garbage-collection.md +delete: + enabled: <%= @options["delete-enable"] %> + # LDAP support. If enabled, then only users of the specified LDAP server will # be able to use Portus. ldap: diff --git a/spec/controllers/repositories_controller_spec.rb b/spec/controllers/repositories_controller_spec.rb index 13257352f..01e800f61 100644 --- a/spec/controllers/repositories_controller_spec.rb +++ b/spec/controllers/repositories_controller_spec.rb @@ -1,7 +1,6 @@ require "rails_helper" -describe RepositoriesController do - +describe RepositoriesController, type: :controller do let(:valid_session) { {} } let(:user) { create(:user) } let!(:public_namespace) { create(:namespace, public: 1, team: create(:team)) } @@ -14,16 +13,13 @@ end describe "GET #index" do - it "assigns all repositories as @repositories" do get :index, {}, valid_session expect(assigns(:repositories)).to eq([visible_repository]) end - end describe "GET #show" do - it "assigns the requested repository as @repository" do get :show, { id: visible_repository.to_param }, valid_session expect(assigns(:repository)).to eq(visible_repository) @@ -47,4 +43,52 @@ expect(response.status).to eq 200 end end + + describe "DELETE #destroy" do + let!(:registry) { create(:registry, hostname: "registry.test.lan") } + let!(:user) { create(:admin) } + let!(:repository) { create(:repository, namespace: registry.global_namespace, name: "repo") } + let!(:repo) { create(:repository, namespace: registry.global_namespace, name: "repo2") } + let!(:tag1) { create(:tag, name: "tag1", repository: repository, digest: "1") } + let!(:tag2) { create(:tag, name: "tag2", repository: repository, digest: "1") } + let!(:tag3) { create(:tag, name: "tag3", repository: repository, digest: "2") } + let!(:tag4) { create(:tag, name: "tag4", repository: repo, digest: "3") } + + before :each do + sign_in user + request.env["HTTP_REFERER"] = "/" + APP_CONFIG["delete"] = { "enabled" => true } + end + + it "removes the repository properly" do + allow_any_instance_of(Tag).to receive(:fetch_digest) { |o| o.digest } + allow_any_instance_of(Portus::RegistryClient).to receive(:delete).and_return(true) + + delete :destroy, { id: repository.id }, valid_session + expect(flash[:notice]).to eq "Repository removed with all its tags" + expect(response.status).to eq 302 + + expect(Repository.find_by(id: repository.id)).to be_nil + end + + it "describes a proper error if tags could not be removed" do + allow_any_instance_of(Tag).to receive(:delete_by_digest!).and_return(true) + + delete :destroy, { id: repository.id }, valid_session + expect(flash[:alert]).to eq "Could not remove all the tags" + expect(response.status).to eq 302 + + # Even if it fails in our tests, all tags should be "marked". + Tag.where(repository: repository).each { |t| expect(t.marked).to be_truthy } + Tag.where(repository: repo).each { |t| expect(t.marked).to be_falsey } + expect(repository.reload.marked).to be_truthy + expect(repo.reload.marked).to be_falsey + end + + it "returns a 403 if deletes are not enabled" do + APP_CONFIG["delete"] = { "enabled" => false } + delete :destroy, { id: -1 }, valid_session + expect(response.status).to eq 403 + end + end end diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb new file mode 100644 index 000000000..f9a6a09ff --- /dev/null +++ b/spec/controllers/tags_controller_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +describe TagsController, type: :controller do + let(:valid_session) { {} } + + describe "DELETE #destroy" do + let!(:registry) { create(:registry, hostname: "registry.test.lan") } + let!(:user) { create(:admin) } + let!(:repository) { create(:repository, namespace: registry.global_namespace, name: "repo") } + let!(:tag) { create(:tag, name: "tag", repository: repository) } + + before :each do + sign_in user + request.env["HTTP_REFERER"] = "/" + APP_CONFIG["delete"] = { "enabled" => true } + end + + it "removes a tag" do + allow_any_instance_of(Tag).to receive(:delete_by_digest!).and_return(true) + delete :destroy, { id: tag.id }, valid_session + expect(flash[:notice]).to eq "Tag removed successfully" + expect(response.status).to eq 302 + end + + it "also removes the repo if there are no more tags" do + allow_any_instance_of(Tag).to receive(:delete_by_digest!) do + Tag.destroy_all + end + + delete :destroy, { id: tag.id }, valid_session + expect(flash[:notice]).to eq "Image removed with all its tags" + expect(response.status).to eq 302 + end + + it "responds accordingly on error" do + allow_any_instance_of(Tag).to receive(:delete_by_digest!).and_return(false) + + delete :destroy, { id: tag.id }, valid_session + expect(flash[:alert]).to eq "Tag could not be removed" + expect(response.status).to eq 302 + end + + it "raises the proper exception when a tag cannot be found" do + expect do + delete :destroy, { id: -1 }, valid_session + end.to raise_error(ActiveRecord::RecordNotFound) + end + + it "returns a 403 if deletes are not enabled" do + APP_CONFIG["delete"] = { "enabled" => false } + delete :destroy, { id: -1 }, valid_session + expect(response.status).to eq 403 + end + end +end diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb index 8f220b0bc..97508e664 100644 --- a/spec/factories/namespaces.rb +++ b/spec/factories/namespaces.rb @@ -8,9 +8,8 @@ # updated_at :datetime not null # team_id :integer # public :boolean default("0") -# registry_id :integer not null +# registry_id :integer # global :boolean default("0") -# description :text(65535) # # Indexes # diff --git a/spec/factories/repositories.rb b/spec/factories/repositories.rb index 2767f2ddd..1a5de31c1 100644 --- a/spec/factories/repositories.rb +++ b/spec/factories/repositories.rb @@ -7,6 +7,7 @@ # namespace_id :integer # created_at :datetime not null # updated_at :datetime not null +# marked :boolean default("0") # # Indexes # diff --git a/spec/features/repositories_spec.rb b/spec/features/repositories_spec.rb index 7778ee6c6..4d10b68ea 100644 --- a/spec/features/repositories_spec.rb +++ b/spec/features/repositories_spec.rb @@ -49,6 +49,8 @@ expectations = [["tag0"], ["tag1"], ["tag2", "tag3"], ["tag4"], ["tag5"]] visit repository_path(repository) + sleep 1 + page.all(".tags tr").each_with_index do |row, idx| expect(row.text.include?("Image")).to be_truthy diff --git a/spec/helpers/repositories_helper_spec.rb b/spec/helpers/repositories_helper_spec.rb index 024ed1392..bad32ac3e 100644 --- a/spec/helpers/repositories_helper_spec.rb +++ b/spec/helpers/repositories_helper_spec.rb @@ -34,15 +34,17 @@ def update_registry!(catalog) nameo = owner.username global = registry.global_namespace.id - # rubocop:disable Metrics/LineLength expectations = [ - "#{nameo} pushed registry:5000 / repo:latest", - "#{nameo} pushed registry:5000 / repo", + "#{nameo} pushed registry:5000 / "\ + "repo:latest", + "#{nameo} pushed registry:5000 / "\ + "repo", "#{nameo} pushed a repository", - "#{nameo} pushed registry:5000 / repo2:0.3", - "#{nameo} pushed namespace / repo3:0.4" + "#{nameo} pushed registry:5000 / "\ + "repo2:0.3", + "#{nameo} pushed namespace "\ + "/ repo3:0.4" ] - # rubocop:enable Metrics/LineLength idx = 0 PublicActivity::Activity.all.order(created_at: :desc).each do |activity| @@ -61,16 +63,38 @@ def update_registry!(catalog) idx = 0 # Changes because of the Catalog job. - # rubocop:disable Metrics/LineLength - expectations[3] = "#{nameo} pushed registry:5000 / repo2:0.3" - expectations[4] = "#{nameo} pushed namespace / repo3:0.4" - # rubocop:enable Metrics/LineLength + expectations[3] = "#{nameo} pushed "\ + "registry:5000 / repo2:0.3" + expectations[4] = "#{nameo} pushed namespace / repo"\ + "3:0.4" - PublicActivity::Activity.all.order(created_at: :desc).each do |activity| + # Push activities + wh = { key: "repository.push" } + PublicActivity::Activity.where(wh).order(created_at: :desc).each do |activity| html = render_push_activity(activity) expect(html).to eq expectations[idx] idx += 1 end + + idx = 0 + expectations = [ + "Someone deleted registry:5000 / "\ + "repo:latest", + "Someone deleted registry:5000 / "\ + "repo2:0.3", + "Someone deleted namespace / repo3:0.4", + "Someone deleted registry:5000 / "\ + "repo2", + "Someone deleted namespace / repo3" + ] + + # Delete Activities + wh = "activities.key='repository.delete' OR activities.key='namespace.delete'" + PublicActivity::Activity.where(wh).order(created_at: :desc).each do |activity| + html = render_delete_activity(activity) + expect(html).to eq expectations[idx] + idx += 1 + end end end end diff --git a/spec/jobs/catalog_job_spec.rb b/spec/jobs/catalog_job_spec.rb index f4abf671b..8dfc0ce07 100644 --- a/spec/jobs/catalog_job_spec.rb +++ b/spec/jobs/catalog_job_spec.rb @@ -151,8 +151,10 @@ def update_registry!(catalog) job = CatalogJobMock.new job.update_registry!([{ "name" => "repo", "tags" => ["0.1"] }]) - expect(PublicActivity::Activity.count).to eq 1 + # Two activities: push and then delete. + expect(PublicActivity::Activity.count).to eq 2 expect(PublicActivity::Activity.first.parameters[:tag_name]).to eq "latest" + expect(PublicActivity::Activity.last.key).to eq "repository.delete" end end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 5a030a443..9f3985a09 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -8,9 +8,8 @@ # updated_at :datetime not null # team_id :integer # public :boolean default("0") -# registry_id :integer not null +# registry_id :integer # global :boolean default("0") -# description :text(65535) # # Indexes # diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 3e64fecff..05a0a20a8 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -7,6 +7,7 @@ # namespace_id :integer # created_at :datetime not null # updated_at :datetime not null +# marked :boolean default("0") # # Indexes # @@ -534,4 +535,54 @@ def get_url(repo, tag) expect(Tag.count).to eq 0 end end + + describe "#delete_and_update!" do + let(:registry) { create(:registry, hostname: "registry.test.lan") } + let(:user) { create(:user) } + let(:repository_name) { "busybox" } + let(:tag_name) { "latest" } + + it "deletes the repository and updates activities accordingly" do + event = { "actor" => { "name" => user.username } } + + # First we create it, and make sure that it creates the activity. + repo = nil + expect do + repo = Repository.add_repo(event, registry.global_namespace, repository_name, tag_name) + end.to change(PublicActivity::Activity, :count).by(1) + + activity = PublicActivity::Activity.first + expect(activity.trackable_type).to eq "Repository" + expect(activity.trackable_id).to eq Repository.first.id + expect(activity.key).to eq "repository.push" + expect(activity.owner_id).to eq user.id + expect(activity.parameters).to be_empty + + # And now delete and see what happens. + expect do + repo.delete_and_update!(user) + end.to change(PublicActivity::Activity, :count).by(1) + + # The original push activity has changed, so it's still trackable. + activity = PublicActivity::Activity.first + expect(activity.trackable_type).to eq "Namespace" + expect(activity.trackable_id).to eq registry.global_namespace.id + expect(activity.key).to eq "repository.push" + expect(activity.owner_id).to eq user.id + expect(activity.parameters).to be_empty + + # There's now a delete activity. + activity = PublicActivity::Activity.last + expect(activity.trackable_type).to eq "Namespace" + expect(activity.trackable_id).to eq registry.global_namespace.id + expect(activity.key).to eq "namespace.delete" + expect(activity.owner_id).to eq user.id + expect(activity.parameters).to eq(repository_name: repository_name, + namespace_id: registry.global_namespace.id, + namespace_name: registry.global_namespace.clean_name) + + # Of course, the repo should be removed + expect(Repository.count).to eq 0 + end + end end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index a9bce8fba..6f66dc5a6 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -10,6 +10,7 @@ # user_id :integer # digest :string(255) # image_id :string(255) default("") +# marked :boolean default("0") # # Indexes # @@ -20,6 +21,121 @@ require "rails_helper" +# Mock class that opens up the `fetch_digest` method as `fetch_digest_test` so +# it can be properly unit tested. +class TagMock < Tag + def fetch_digest_test + fetch_digest + end +end + describe Tag do + let!(:registry) { create(:registry, hostname: "registry.test.lan") } + let!(:user) { create(:admin) } + let!(:repository) { create(:repository, namespace: registry.global_namespace, name: "repo") } + it { should belong_to(:repository) } + it { should belong_to(:author) } + + describe "#delete_by_digest!" do + let!(:tag) { create(:tag, name: "tag1", repository: repository, digest: "1") } + let!(:tag2) { create(:tag, name: "tag2", repository: repository, digest: "2") } + + it "returns false if there is no digest" do + allow_any_instance_of(Tag).to receive(:fetch_digest).and_return(nil) + expect(tag.delete_by_digest!(user)).to be_falsey + end + + it "returns false if the registry client could not delete the tag" do + allow_any_instance_of(Portus::RegistryClient).to receive(:delete) do + raise "I AM ERROR." + end + + # That being said, the tag should be "marked". + expect(tag.delete_by_digest!(user)).to be_falsey + expect(tag.reload.marked?).to be_truthy + end + + it "deletes the tag and updates corresponding activities" do + # Create push activities. This is important so we can test afterwards + # that they will get updated on removal. + repository.create_activity(:push, owner: user, recipient: tag) + repository.create_activity(:push, owner: user, recipient: tag2) + + tag.delete_and_update!(user) + + expect(PublicActivity::Activity.count).to eq 3 + + # The first activity is the first push, which should've changed. + activity = PublicActivity::Activity.first + expect(activity.trackable_type).to eq "Repository" + expect(activity.trackable_id).to eq repository.id + expect(activity.owner_id).to eq user.id + expect(activity.key).to eq "repository.push" + expect(activity.parameters).to eq(namespace_id: registry.global_namespace.id, + namespace_name: registry.global_namespace.clean_name, + repo_name: repository.name, + tag_name: tag.name) + + # The second activity is the other push, which is unaffected by this + # action. + activity = PublicActivity::Activity.all[1] + expect(activity.trackable_type).to eq "Repository" + expect(activity.trackable_id).to eq repository.id + expect(activity.owner_id).to eq user.id + expect(activity.key).to eq "repository.push" + expect(activity.parameters).to be_empty + + # The last activity is the removal of the tag. + activity = PublicActivity::Activity.last + expect(activity.trackable_type).to eq "Repository" + expect(activity.trackable_id).to eq repository.id + expect(activity.owner_id).to eq user.id + expect(activity.key).to eq "repository.delete" + expect(activity.parameters).to eq(namespace_id: registry.global_namespace.id, + namespace_name: registry.global_namespace.clean_name, + repository_name: repository.name, + tag_name: tag.name) + end + end + + # NOTE: lots of cases are being left out on purpose because they are already + # tested in the previous `describe` block. + describe "#delete_and_update!" do + let!(:tag) { create(:tag, name: "tag1", repository: repository, digest: "1") } + + before :each do + tag.destroy + end + + it "does nothing if the tag has already beed removed" do + expect(Rails.logger).to receive(:info).with(/Removed the tag.../) + expect(Rails.logger).to receive(:info).with(/Ignoring.../) + tag.delete_and_update!(user) + end + end + + describe "#fetch_digest" do + it "returns the digest as-is if it's not empty" do + tag = TagMock.create(name: "tag", repository: repository, digest: "1") + expect(tag.fetch_digest_test).to eq tag.digest + end + + it "returns the digest as given by the registry" do + allow_any_instance_of(Portus::RegistryClient).to receive(:manifest).and_return( + ["id", "2", ""]) + + tag = TagMock.create(name: "tag", repository: repository) + expect(tag.fetch_digest_test).to eq "2" + end + + it "returns nil if the client could not fetch the digest" do + allow_any_instance_of(Portus::RegistryClient).to receive(:manifest) do + raise "I AM ERROR." + end + + tag = TagMock.create(name: "tag", repository: repository) + expect(tag.fetch_digest_test).to be_nil + end + end end From 21bfc3a80f4995f6146e6acf594c071749b5d53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miquel=20Sabat=C3=A9=20Sol=C3=A0?= Date: Wed, 4 May 2016 08:56:12 +0200 Subject: [PATCH 2/4] Fixed typos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miquel Sabaté Solà --- app/controllers/concerns/delete_enabled.rb | 2 +- app/controllers/tags_controller.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/delete_enabled.rb b/app/controllers/concerns/delete_enabled.rb index fb06304a0..40beb6f7f 100644 --- a/app/controllers/concerns/delete_enabled.rb +++ b/app/controllers/concerns/delete_enabled.rb @@ -1,4 +1,4 @@ -# DeleteEnabeld redirects the user back if delete support is not enabled. A +# DeleteEnabled redirects the user back if delete support is not enabled. A # `before_action` will be created for the :destroy method. module DeleteEnabled extend ActiveSupport::Concern diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 59742bab0..6e36addce 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,7 +1,7 @@ class TagsController < ApplicationController include DeleteEnabled - # Removes all tags the match the digest of the tag with the given ID. + # Removes all tags that match the digest of the tag with the given ID. # Moreover, it will also remove the image if it's left empty after removing # the tags. def destroy From 84722886a164e82b0464ef818677f09ef38d1d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miquel=20Sabat=C3=A9=20Sol=C3=A0?= Date: Wed, 4 May 2016 09:52:02 +0200 Subject: [PATCH 3/4] DeleteEnabled -> Deletable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miquel Sabaté Solà --- app/controllers/concerns/{delete_enabled.rb => deletable.rb} | 4 ++-- app/controllers/repositories_controller.rb | 2 +- app/controllers/tags_controller.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename app/controllers/concerns/{delete_enabled.rb => deletable.rb} (77%) diff --git a/app/controllers/concerns/delete_enabled.rb b/app/controllers/concerns/deletable.rb similarity index 77% rename from app/controllers/concerns/delete_enabled.rb rename to app/controllers/concerns/deletable.rb index 40beb6f7f..3fade3543 100644 --- a/app/controllers/concerns/delete_enabled.rb +++ b/app/controllers/concerns/deletable.rb @@ -1,6 +1,6 @@ -# DeleteEnabled redirects the user back if delete support is not enabled. A +# Deletable redirects the user back if delete support is not enabled. A # `before_action` will be created for the :destroy method. -module DeleteEnabled +module Deletable extend ActiveSupport::Concern included do diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb index d5fbb86b2..fdcb84088 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -1,5 +1,5 @@ class RepositoriesController < ApplicationController - include DeleteEnabled + include Deletable before_action :set_repository, only: [:show, :destroy, :toggle_star] diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 6e36addce..d3bfe8ad8 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,5 +1,5 @@ class TagsController < ApplicationController - include DeleteEnabled + include Deletable # Removes all tags that match the digest of the tag with the given ID. # Moreover, it will also remove the image if it's left empty after removing From 393ca15c295943a77972be7a172ce1c9d38367ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miquel=20Sabat=C3=A9=20Sol=C3=A0?= Date: Fri, 6 May 2016 17:22:49 +0200 Subject: [PATCH 4/4] Fixed stuff pointed out by Flavio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miquel Sabaté Solà --- app/controllers/repositories_controller.rb | 2 ++ app/models/namespace.rb | 3 ++- spec/factories/namespaces.rb | 3 ++- spec/features/repositories_spec.rb | 1 - spec/models/namespace_spec.rb | 3 ++- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb index fdcb84088..9eaa85ee7 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -37,6 +37,8 @@ def destroy # Delete this repository if all tags were successfully deleted. if @repository.reload.tags.any? + ts = @repository.tags.pluck(:name).join(", ") + logger.error "The following tags could not be removed: #{ts}." redirect_to repository_path(@repository), alert: "Could not remove all the tags" else @repository.delete_and_update!(current_user) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 782182b0a..5c2055abc 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -8,8 +8,9 @@ # updated_at :datetime not null # team_id :integer # public :boolean default("0") -# registry_id :integer +# registry_id :integer not null # global :boolean default("0") +# description :text(65535) # # Indexes # diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb index 97508e664..8f220b0bc 100644 --- a/spec/factories/namespaces.rb +++ b/spec/factories/namespaces.rb @@ -8,8 +8,9 @@ # updated_at :datetime not null # team_id :integer # public :boolean default("0") -# registry_id :integer +# registry_id :integer not null # global :boolean default("0") +# description :text(65535) # # Indexes # diff --git a/spec/features/repositories_spec.rb b/spec/features/repositories_spec.rb index 4d10b68ea..2c46d58e1 100644 --- a/spec/features/repositories_spec.rb +++ b/spec/features/repositories_spec.rb @@ -49,7 +49,6 @@ expectations = [["tag0"], ["tag1"], ["tag2", "tag3"], ["tag4"], ["tag5"]] visit repository_path(repository) - sleep 1 page.all(".tags tr").each_with_index do |row, idx| expect(row.text.include?("Image")).to be_truthy diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 9f3985a09..5a030a443 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -8,8 +8,9 @@ # updated_at :datetime not null # team_id :integer # public :boolean default("0") -# registry_id :integer +# registry_id :integer not null # global :boolean default("0") +# description :text(65535) # # Indexes #