diff options
89 files changed, 2457 insertions, 511 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index cc97bd7d197..042664f7338 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -bfcc916ab8ef38ee53755e2d71fc45d28f26c7bf +53fd83a9c21e89bf1bfb9b7f918b9bcfa3ef776a diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql index 470de060ee3..ad861a60d15 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql @@ -1,8 +1,13 @@ +#import "./work_item.fragment.graphql" + mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) { workItemUpdate: workItemUpdateTask(input: $input) { workItem { id descriptionHtml } + task { + ...WorkItem + } } } diff --git a/app/controllers/concerns/harbor/access.rb b/app/controllers/concerns/harbor/access.rb new file mode 100644 index 00000000000..70de72f15fc --- /dev/null +++ b/app/controllers/concerns/harbor/access.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Harbor + module Access + extend ActiveSupport::Concern + + included do + before_action :harbor_registry_enabled! + before_action :authorize_read_harbor_registry! + before_action do + push_frontend_feature_flag(:harbor_registry_integration) + end + + feature_category :integrations + end + + private + + def harbor_registry_enabled! + render_404 unless Feature.enabled?(:harbor_registry_integration) + end + + def authorize_read_harbor_registry! + raise NotImplementedError + end + end +end diff --git a/app/controllers/concerns/harbor/artifact.rb b/app/controllers/concerns/harbor/artifact.rb new file mode 100644 index 00000000000..c9d7d26fbb9 --- /dev/null +++ b/app/controllers/concerns/harbor/artifact.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Harbor + module Artifact + def index + respond_to do |format| + format.json do + artifacts + end + end + end + + private + + def query_params + params.permit(:repository_id, :search, :sort, :page, :limit) + end + + def query + Gitlab::Harbor::Query.new(container.harbor_integration, query_params) + end + + def artifacts + unless query.valid? + return render( + json: { message: 'Invalid parameters', errors: query.errors }, + status: :unprocessable_entity + ) + end + + artifacts_json = ::Integrations::HarborSerializers::ArtifactSerializer.new + .with_pagination(request, response) + .represent(query.artifacts) + render json: artifacts_json + end + + def container + raise NotImplementedError + end + end +end diff --git a/app/controllers/concerns/harbor/repository.rb b/app/controllers/concerns/harbor/repository.rb new file mode 100644 index 00000000000..0e541e2172e --- /dev/null +++ b/app/controllers/concerns/harbor/repository.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Harbor + module Repository + def index + respond_to do |format| + format.html + format.json do + repositories + end + end + end + + # The show action renders index to allow frontend routing to work on page refresh + def show + render :index + end + + private + + def query_params + params.permit(:search, :sort, :page, :limit) + end + + def query + Gitlab::Harbor::Query.new(container.harbor_integration, query_params) + end + + def repositories + unless query.valid? + return render( + json: { message: 'Invalid parameters', errors: query.errors }, + status: :unprocessable_entity + ) + end + + repositories_json = ::Integrations::HarborSerializers::RepositorySerializer.new + .with_pagination(request, response) + .represent( + query.repositories, + url: container.harbor_integration.url, + project_name: container.harbor_integration.project_name + ) + render json: repositories_json + end + + def container + raise NotImplementedError + end + end +end diff --git a/app/controllers/concerns/harbor/tag.rb b/app/controllers/concerns/harbor/tag.rb new file mode 100644 index 00000000000..e0c00d1155a --- /dev/null +++ b/app/controllers/concerns/harbor/tag.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Harbor + module Tag + def index + respond_to do |format| + format.json do + tags + end + end + end + + private + + def query_params + params.permit(:repository_id, :artifact_id, :sort, :page, :limit) + end + + def query + Gitlab::Harbor::Query.new(container.harbor_integration, query_params) + end + + def tags + unless query.valid? + return render( + json: { message: 'Invalid parameters', errors: query.errors }, + status: :unprocessable_entity + ) + end + + tags_json = ::Integrations::HarborSerializers::TagSerializer.new + .with_pagination(request, response) + .represent(query.tags) + render json: tags_json + end + + def container + raise NotImplementedError + end + end +end diff --git a/app/controllers/groups/harbor/application_controller.rb b/app/controllers/groups/harbor/application_controller.rb new file mode 100644 index 00000000000..cff767c8efd --- /dev/null +++ b/app/controllers/groups/harbor/application_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Groups + module Harbor + class ApplicationController < Groups::ApplicationController + layout 'group' + include ::Harbor::Access + + private + + def authorize_read_harbor_registry! + render_404 unless can?(current_user, :read_harbor_registry, @group) + end + end + end +end diff --git a/app/controllers/groups/harbor/artifacts_controller.rb b/app/controllers/groups/harbor/artifacts_controller.rb new file mode 100644 index 00000000000..b7570b44a2c --- /dev/null +++ b/app/controllers/groups/harbor/artifacts_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Groups + module Harbor + class ArtifactsController < ::Groups::Harbor::ApplicationController + include ::Harbor::Artifact + + private + + def container + @group + end + end + end +end diff --git a/app/controllers/groups/harbor/repositories_controller.rb b/app/controllers/groups/harbor/repositories_controller.rb index 364607f9b20..1ad38bd7103 100644 --- a/app/controllers/groups/harbor/repositories_controller.rb +++ b/app/controllers/groups/harbor/repositories_controller.rb @@ -2,22 +2,13 @@ module Groups module Harbor - class RepositoriesController < Groups::ApplicationController - feature_category :integrations - - before_action :harbor_registry_enabled! - before_action do - push_frontend_feature_flag(:harbor_registry_integration) - end - - def show - render :index - end + class RepositoriesController < ::Groups::Harbor::ApplicationController + include ::Harbor::Repository private - def harbor_registry_enabled! - render_404 unless Feature.enabled?(:harbor_registry_integration) + def container + @group end end end diff --git a/app/controllers/groups/harbor/tags_controller.rb b/app/controllers/groups/harbor/tags_controller.rb new file mode 100644 index 00000000000..da43cb3f64c --- /dev/null +++ b/app/controllers/groups/harbor/tags_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Groups + module Harbor + class TagsController < ::Groups::Harbor::ApplicationController + include ::Harbor::Tag + + private + + def container + @group + end + end + end +end diff --git a/app/controllers/projects/harbor/application_controller.rb b/app/controllers/projects/harbor/application_controller.rb index e6e694783fa..9271ec560dc 100644 --- a/app/controllers/projects/harbor/application_controller.rb +++ b/app/controllers/projects/harbor/application_controller.rb @@ -4,18 +4,12 @@ module Projects module Harbor class ApplicationController < Projects::ApplicationController layout 'project' - - before_action :harbor_registry_enabled! - before_action do - push_frontend_feature_flag(:harbor_registry_integration) - end - - feature_category :integrations + include ::Harbor::Access private - def harbor_registry_enabled! - render_404 unless Feature.enabled?(:harbor_registry_integration) + def authorize_read_harbor_registry! + render_404 unless can?(current_user, :read_harbor_registry, @project) end end end diff --git a/app/controllers/projects/harbor/artifacts_controller.rb b/app/controllers/projects/harbor/artifacts_controller.rb new file mode 100644 index 00000000000..ce36f181b42 --- /dev/null +++ b/app/controllers/projects/harbor/artifacts_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects + module Harbor + class ArtifactsController < ::Projects::Harbor::ApplicationController + include ::Harbor::Artifact + + private + + def container + @project + end + end + end +end diff --git a/app/controllers/projects/harbor/repositories_controller.rb b/app/controllers/projects/harbor/repositories_controller.rb index dd3e3dc1978..4db13331bf0 100644 --- a/app/controllers/projects/harbor/repositories_controller.rb +++ b/app/controllers/projects/harbor/repositories_controller.rb @@ -3,8 +3,12 @@ module Projects module Harbor class RepositoriesController < ::Projects::Harbor::ApplicationController - def show - render :index + include ::Harbor::Repository + + private + + def container + @project end end end diff --git a/app/controllers/projects/harbor/tags_controller.rb b/app/controllers/projects/harbor/tags_controller.rb new file mode 100644 index 00000000000..f49c5ac7768 --- /dev/null +++ b/app/controllers/projects/harbor/tags_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects + module Harbor + class TagsController < ::Projects::Harbor::ApplicationController + include ::Harbor::Tag + + private + + def container + @project + end + end + end +end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 0af5533613f..e11edbda6dc 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -19,5 +19,9 @@ module Ci scope :unprotected, -> { where(protected: false) } scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } scope :for_groups, ->(group_ids) { where(group_id: group_ids) } + + def audit_details + key + end end end diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 1e91f248fc4..c80c2ebe69a 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -18,5 +18,9 @@ module Ci scope :unprotected, -> { where(protected: false) } scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } + + def audit_details + key + end end end diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb index b1def38d019..57f8e21c5a6 100644 --- a/app/models/concerns/integrations/has_issue_tracker_fields.rb +++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb @@ -5,26 +5,32 @@ module Integrations extend ActiveSupport::Concern included do + self.field_storage = :data_fields + field :project_url, required: true, - storage: :data_fields, title: -> { _('Project URL') }, - help: -> { s_('IssueTracker|The URL to the project in the external issue tracker.') } + help: -> do + s_('IssueTracker|The URL to the project in the external issue tracker.') + end field :issues_url, required: true, - storage: :data_fields, title: -> { s_('IssueTracker|Issue URL') }, help: -> do - format s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.'), + ERB::Util.html_escape( + s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') + ) % { colon_id: '<code>:id</code>'.html_safe + } end field :new_issue_url, required: true, - storage: :data_fields, title: -> { s_('IssueTracker|New issue URL') }, - help: -> { s_('IssueTracker|The URL to create an issue in the external issue tracker.') } + help: -> do + s_('IssueTracker|The URL to create an issue in the external issue tracker.') + end end end end diff --git a/app/models/group.rb b/app/models/group.rb index 5919b1e71bf..6d8f8bd7613 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -112,6 +112,8 @@ class Group < Namespace has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob' has_many :dependency_proxy_manifests, class_name: 'DependencyProxy::Manifest' + has_one :harbor_integration, class_name: 'Integrations::Harbor' + # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index 7889cd8f9a9..bf1358ac0f6 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -2,9 +2,34 @@ module Integrations class Campfire < Integration - prop_accessor :token, :subdomain, :room validates :token, presence: true, if: :activated? + field :token, + type: 'password', + title: -> { _('Campfire token') }, + help: -> { s_('CampfireService|API authentication token from Campfire.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '', + required: true + + field :subdomain, + title: -> { _('Campfire subdomain (optional)') }, + placeholder: '', + help: -> do + ERB::Util.html_escape( + s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') + ) % { + code_open: '<code>'.html_safe, + code_close: '</code>'.html_safe + } + end + + field :room, + title: -> { _('Campfire room ID (optional)') }, + placeholder: '123456', + help: -> { s_('CampfireService|From the end of the room URL.') } + def title 'Campfire' end @@ -15,42 +40,18 @@ module Integrations def help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer' - s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + + ERB::Util.html_escape( + s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}') + ) % { + docs_link: docs_link.html_safe + } end def self.to_param 'campfire' end - def fields - [ - { - type: 'password', - name: 'token', - title: _('Campfire token'), - help: s_('CampfireService|API authentication token from Campfire.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'subdomain', - title: _('Campfire subdomain (optional)'), - placeholder: '', - help: s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - }, - { - type: 'text', - name: 'room', - title: _('Campfire room ID (optional)'), - placeholder: '123456', - help: s_('CampfireService|From the end of the room URL.') - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index bb0fb6b9079..97e586c0662 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -15,7 +15,75 @@ module Integrations TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze - prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env, :datadog_tags + field :datadog_site, + placeholder: DEFAULT_DOMAIN, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe + } + end + + field :api_url, + title: -> { s_('DatadogIntegration|API URL') }, + help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') } + + field :api_key, + type: 'password', + title: -> { _('API key') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') }, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') + ) % { + linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, + linkClose: '</a>'.html_safe + } + end, + required: true + + field :archive_trace_events, + type: 'checkbox', + title: -> { s_('Logs') }, + checkbox_label: -> { s_('Enable logs collection') }, + help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') } + + field :datadog_service, + title: -> { s_('DatadogIntegration|Service') }, + placeholder: 'gitlab-ci', + help: -> { s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') } + + field :datadog_env, + title: -> { s_('DatadogIntegration|Environment') }, + placeholder: 'ci', + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } + end + + field :datadog_tags, + type: 'textarea', + title: -> { s_('DatadogIntegration|Tags') }, + placeholder: "tag:value\nanother_tag:value", + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } + end before_validation :strip_properties @@ -77,92 +145,11 @@ module Integrations end def fields - f = [ - { - type: 'text', - name: 'datadog_site', - placeholder: DEFAULT_DOMAIN, - help: ERB::Util.html_escape( - s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe - }, - required: false - }, - { - type: 'text', - name: 'api_url', - title: s_('DatadogIntegration|API URL'), - help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'), - required: false - }, - { - type: 'password', - name: 'api_key', - title: _('API key'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), - help: ERB::Util.html_escape( - s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') - ) % { - linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, - linkClose: '</a>'.html_safe - }, - required: true - } - ] - if Feature.enabled?(:datadog_integration_logs_collection, parent) - f.append({ - type: 'checkbox', - name: 'archive_trace_events', - title: s_('Logs'), - checkbox_label: s_('Enable logs collection'), - help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'), - required: false - }) + super + else + super.reject { _1.name == 'archive_trace_events' } end - - f += [ - { - type: 'text', - name: 'datadog_service', - title: s_('DatadogIntegration|Service'), - placeholder: 'gitlab-ci', - help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') - }, - { - type: 'text', - name: 'datadog_env', - title: s_('DatadogIntegration|Environment'), - placeholder: 'ci', - help: ERB::Util.html_escape( - s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe, - linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, - linkClose: '</a>'.html_safe - } - }, - { - type: 'textarea', - name: 'datadog_tags', - title: s_('DatadogIntegration|Tags'), - placeholder: "tag:value\nanother_tag:value", - help: ERB::Util.html_escape( - s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe, - linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, - linkClose: '</a>'.html_safe - } - } - ] - - f end override :hook_url diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 44813795fc0..82981493822 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -4,7 +4,7 @@ module Integrations class Harbor < Integration prop_accessor :url, :project_name, :username, :password - validates :url, public_url: true, presence: true, if: :activated? + validates :url, public_url: true, presence: true, addressable_url: { allow_localhost: false, allow_local_network: false }, if: :activated? validates :project_name, presence: true, if: :activated? validates :username, presence: true, if: :activated? validates :password, format: { with: ::Ci::Maskable::REGEX }, if: :activated? diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb index 0ec750a4b77..3f3e321f45e 100644 --- a/app/models/integrations/irker.rb +++ b/app/models/integrations/irker.rb @@ -4,13 +4,55 @@ require 'uri' module Integrations class Irker < Integration - prop_accessor :server_host, :server_port, :default_irc_uri - prop_accessor :recipients, :channels - boolean_accessor :colorize_messages validates :recipients, presence: true, if: :validate_recipients? - before_validation :get_channels + field :server_host, + placeholder: 'localhost', + title: -> { s_('IrkerService|Server host (optional)') }, + help: -> { s_('IrkerService|irker daemon hostname (defaults to localhost).') } + + field :server_port, + placeholder: 6659, + title: -> { s_('IrkerService|Server port (optional)') }, + help: -> { s_('IrkerService|irker daemon port (defaults to 6659).') } + + field :default_irc_uri, + title: -> { s_('IrkerService|Default IRC URI (optional)') }, + help: -> { s_('IrkerService|URI to add before each recipient.') }, + placeholder: 'irc://irc.network.net:6697/' + + field :recipients, + type: 'textarea', + title: -> { s_('IrkerService|Recipients') }, + placeholder: 'irc[s]://irc.network.net[:port]/#channel', + required: true, + help: -> do + recipients_docs_link = ActionController::Base.helpers.link_to( + s_('IrkerService|How to enter channels or users?'), + Rails.application.routes.url_helpers.help_page_url( + 'user/project/integrations/irker', + anchor: 'enter-irker-recipients' + ), + target: '_blank', rel: 'noopener noreferrer' + ) + + ERB::Util.html_escape( + s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}') + ) % { + recipients_docs_link: recipients_docs_link.html_safe + } + end + + field :colorize_messages, + type: 'checkbox', + title: -> { _('Colorize messages') } + + # NOTE: This field is only used internally to store the parsed + # channels from the `recipients` field, it should not be exposed + # in the UI or API. + prop_accessor :channels + def title s_('IrkerService|irker (IRC gateway)') end @@ -43,34 +85,6 @@ module Integrations } end - def fields - recipients_docs_link = ActionController::Base.helpers.link_to( - s_('IrkerService|How to enter channels or users?'), - Rails.application.routes.url_helpers.help_page_url( - 'user/project/integrations/irker', - anchor: 'enter-irker-recipients' - ), - target: '_blank', rel: 'noopener noreferrer' - ) - - [ - { type: 'text', name: 'server_host', placeholder: 'localhost', title: s_('IrkerService|Server host (optional)'), - help: s_('IrkerService|irker daemon hostname (defaults to localhost).') }, - { type: 'text', name: 'server_port', placeholder: 6659, title: s_('IrkerService|Server port (optional)'), - help: s_('IrkerService|irker daemon port (defaults to 6659).') }, - { type: 'text', name: 'default_irc_uri', title: s_('IrkerService|Default IRC URI (optional)'), - help: s_('IrkerService|URI to add before each recipient.'), - placeholder: 'irc://irc.network.net:6697/' }, - { type: 'textarea', name: 'recipients', title: s_('IrkerService|Recipients'), - placeholder: 'irc[s]://irc.network.net[:port]/#channel', required: true, - help: format( - s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}').html_safe, - recipients_docs_link: recipients_docs_link.html_safe - ) }, - { type: 'checkbox', name: 'colorize_messages', title: _('Colorize messages') } - ] - end - def help docs_link = ActionController::Base.helpers.link_to( _('Learn more.'), diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index 758c9e4761b..05ee919892d 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -5,7 +5,25 @@ module Integrations include HasWebHook extend Gitlab::Utils::Override - prop_accessor :username, :token, :server + field :username, + title: -> { _('Username') }, + help: -> { s_('Enter your Packagist username.') }, + placeholder: '', + required: true + + field :token, + type: 'password', + title: -> { _('Token') }, + help: -> { s_('Enter your Packagist token.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + placeholder: '', + required: true + + field :server, + title: -> { _('Server (optional)') }, + help: -> { s_('Enter your Packagist server. Defaults to https://packagist.org.') }, + placeholder: 'https://packagist.org' validates :username, presence: true, if: :activated? validates :token, presence: true, if: :activated? @@ -22,37 +40,6 @@ module Integrations 'packagist' end - def fields - [ - { - type: 'text', - name: 'username', - title: _('Username'), - help: s_('Enter your Packagist username.'), - placeholder: '', - required: true - }, - { - type: 'password', - name: 'token', - title: _('Token'), - help: s_('Enter your Packagist token.'), - non_empty_password_title: s_('ProjectService|Enter new token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'server', - title: _('Server (optional)'), - help: s_('Enter your Packagist server. Defaults to https://packagist.org.'), - placeholder: 'https://packagist.org', - required: false - } - ] - end - def self.supported_events %w(push merge_request tag_push) end diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb index c08d1fe7f4a..77cbba25f2c 100644 --- a/app/models/integrations/pipelines_email.rb +++ b/app/models/integrations/pipelines_email.rb @@ -6,11 +6,26 @@ module Integrations RECIPIENTS_LIMIT = 30 - prop_accessor :recipients, :branches_to_be_notified - boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :recipients, presence: true, if: :validate_recipients? validate :number_of_recipients_within_limit, if: :validate_recipients? + field :recipients, + type: 'textarea', + help: -> { _('Comma-separated list of email addresses.') }, + required: true + + field :notify_only_broken_pipelines, + type: 'checkbox' + + field :notify_only_default_branch, + type: 'checkbox', + api_only: true + + field :branches_to_be_notified, + type: 'select', + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: branch_choices + def initialize_properties super @@ -65,21 +80,6 @@ module Integrations project&.ci_pipelines&.any? end - def fields - [ - { type: 'textarea', - name: 'recipients', - help: _('Comma-separated list of email addresses.'), - required: true }, - { type: 'checkbox', - name: 'notify_only_broken_pipelines' }, - { type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: self.class.branch_choices } - ] - end - def test(data) result = execute(data, force: true) diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index 7fd5efa8765..791e27c5db7 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -4,9 +4,73 @@ module Integrations class Pushover < Integration BASE_URI = 'https://api.pushover.net/1' - prop_accessor :api_key, :user_key, :device, :priority, :sound validates :api_key, :user_key, :priority, presence: true, if: :activated? + field :api_key, + type: 'password', + title: -> { _('API key') }, + help: -> { s_('PushoverService|Enter your application key.') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') }, + placeholder: '', + required: true + + field :user_key, + type: 'password', + title: -> { _('User key') }, + help: -> { s_('PushoverService|Enter your user key.') }, + non_empty_password_title: -> { s_('PushoverService|Enter new user key') }, + non_empty_password_help: -> { s_('PushoverService|Leave blank to use your current user key.') }, + placeholder: '', + required: true + + field :device, + title: -> { _('Devices (optional)') }, + help: -> { s_('PushoverService|Leave blank for all active devices.') }, + placeholder: '' + + field :priority, + type: 'select', + required: true, + choices: -> do + [ + [s_('PushoverService|Lowest priority'), -2], + [s_('PushoverService|Low priority'), -1], + [s_('PushoverService|Normal priority'), 0], + [s_('PushoverService|High priority'), 1] + ] + end + + field :sound, + type: 'select', + choices: -> do + [ + ['Device default sound', nil], + ['Pushover (default)', 'pushover'], + %w(Bike bike), + %w(Bugle bugle), + ['Cash Register', 'cashregister'], + %w(Classical classical), + %w(Cosmic cosmic), + %w(Falling falling), + %w(Gamelan gamelan), + %w(Incoming incoming), + %w(Intermission intermission), + %w(Magic magic), + %w(Mechanical mechanical), + ['Piano Bar', 'pianobar'], + %w(Siren siren), + ['Space Alarm', 'spacealarm'], + ['Tug Boat', 'tugboat'], + ['Alien Alarm (long)', 'alien'], + ['Climb (long)', 'climb'], + ['Persistent (long)', 'persistent'], + ['Pushover Echo (long)', 'echo'], + ['Up Down (long)', 'updown'], + ['None (silent)', 'none'] + ] + end + def title 'Pushover' end @@ -19,81 +83,6 @@ module Integrations 'pushover' end - def fields - [ - { - type: 'password', - name: 'api_key', - title: _('API key'), - help: s_('PushoverService|Enter your application key.'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key.'), - placeholder: '', - required: true - }, - { - type: 'password', - name: 'user_key', - title: _('User key'), - help: s_('PushoverService|Enter your user key.'), - non_empty_password_title: s_('PushoverService|Enter new user key'), - non_empty_password_help: s_('PushoverService|Leave blank to use your current user key.'), - placeholder: '', - required: true - }, - { - type: 'text', - name: 'device', - title: _('Devices (optional)'), - help: s_('PushoverService|Leave blank for all active devices.'), - placeholder: '' - }, - { - type: 'select', - name: 'priority', - required: true, - choices: - [ - [s_('PushoverService|Lowest priority'), -2], - [s_('PushoverService|Low priority'), -1], - [s_('PushoverService|Normal priority'), 0], - [s_('PushoverService|High priority'), 1] - ], - default_choice: 0 - }, - { - type: 'select', - name: 'sound', - choices: - [ - ['Device default sound', nil], - ['Pushover (default)', 'pushover'], - %w(Bike bike), - %w(Bugle bugle), - ['Cash Register', 'cashregister'], - %w(Classical classical), - %w(Cosmic cosmic), - %w(Falling falling), - %w(Gamelan gamelan), - %w(Incoming incoming), - %w(Intermission intermission), - %w(Magic magic), - %w(Mechanical mechanical), - ['Piano Bar', 'pianobar'], - %w(Siren siren), - ['Space Alarm', 'spacealarm'], - ['Tug Boat', 'tugboat'], - ['Alien Alarm (long)', 'alien'], - ['Climb (long)', 'climb'], - ['Persistent (long)', 'persistent'], - ['Pushover Echo (long)', 'echo'], - ['Up Down (long)', 'updown'], - ['None (silent)', 'none'] - ] - } - ] - end - def self.supported_events %w(push) end diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index 350ee61ad11..8bc296e0320 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -2,9 +2,12 @@ module Integrations class Shimo < BaseThirdPartyWiki - prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? + field :external_wiki_url, + title: -> { s_('Shimo|Shimo Workspace URL') }, + required: true + def render? return false unless Feature.enabled?(:shimo_integration, project) @@ -30,16 +33,5 @@ module Integrations rescue StandardError nil end - - def fields - [ - { - type: 'text', - name: 'external_wiki_url', - title: s_('Shimo|Shimo Workspace URL'), - required: true - } - ] - end end end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index ab6e1da27f8..fa719f925ed 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -33,10 +33,7 @@ module Integrations end def fields - [ - { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true } - ] + super.select { _1.name.in?(%w[project_url issues_url]) } end end end diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb index c33df465fde..11db469f7ee 100644 --- a/app/models/integrations/zentao.rb +++ b/app/models/integrations/zentao.rb @@ -4,7 +4,28 @@ module Integrations class Zentao < Integration include Gitlab::Routing - data_field :url, :api_url, :api_token, :zentao_product_xid + self.field_storage = :data_fields + + field :url, + title: -> { s_('ZentaoIntegration|ZenTao Web URL') }, + placeholder: 'https://www.zentao.net', + help: -> { s_('ZentaoIntegration|Base URL of the ZenTao instance.') }, + required: true + + field :api_url, + title: -> { s_('ZentaoIntegration|ZenTao API URL (optional)') }, + help: -> { s_('ZentaoIntegration|If different from Web URL.') } + + field :api_token, + type: 'password', + title: -> { s_('ZentaoIntegration|ZenTao API token') }, + non_empty_password_title: -> { s_('ZentaoIntegration|Enter new ZenTao API token') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + required: true + + field :zentao_product_xid, + title: -> { s_('ZentaoIntegration|ZenTao Product ID') }, + required: true validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true @@ -47,39 +68,6 @@ module Integrations %w() end - def fields - [ - { - type: 'text', - name: 'url', - title: s_('ZentaoIntegration|ZenTao Web URL'), - placeholder: 'https://www.zentao.net', - help: s_('ZentaoIntegration|Base URL of the ZenTao instance.'), - required: true - }, - { - type: 'text', - name: 'api_url', - title: s_('ZentaoIntegration|ZenTao API URL (optional)'), - help: s_('ZentaoIntegration|If different from Web URL.') - }, - { - type: 'password', - name: 'api_token', - title: s_('ZentaoIntegration|ZenTao API token'), - non_empty_password_title: s_('ZentaoIntegration|Enter new ZenTao API token'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), - required: true - }, - { - type: 'text', - name: 'zentao_product_xid', - title: s_('ZentaoIntegration|ZenTao Product ID'), - required: true - } - ] - end - private def client diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 59dd74c1363..50b6f4bbe15 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -154,6 +154,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy rule { reporter }.policy do enable :reporter_access enable :read_container_image + enable :read_harbor_registry enable :admin_issue_board enable :admin_label enable :admin_milestone diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index e9266f1cb20..c54dbefc1ae 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -318,6 +318,7 @@ class ProjectPolicy < BasePolicy enable :read_commit_status enable :read_build enable :read_container_image + enable :read_harbor_registry enable :read_deploy_board enable :read_pipeline enable :read_pipeline_schedule diff --git a/app/serializers/integrations/harbor_serializers/artifact_entity.rb b/app/serializers/integrations/harbor_serializers/artifact_entity.rb new file mode 100644 index 00000000000..010380561eb --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/artifact_entity.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class ArtifactEntity < Grape::Entity + include ActionView::Helpers::SanitizeHelper + + expose :harbor_id do |item| + item['id'] + end + + expose :digest do |item| + strip_tags(item['digest']) + end + + expose :size do |item| + item['size'] + end + + expose :push_time do |item| + item['push_time']&.to_datetime&.utc + end + + expose :tags do |item| + item['tags'].map { |tag| strip_tags(tag['name']) } + end + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/artifact_serializer.rb b/app/serializers/integrations/harbor_serializers/artifact_serializer.rb new file mode 100644 index 00000000000..aaf78a72330 --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/artifact_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class ArtifactSerializer < BaseSerializer + include WithPagination + + entity ::Integrations::HarborSerializers::ArtifactEntity + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/repository_entity.rb b/app/serializers/integrations/harbor_serializers/repository_entity.rb new file mode 100644 index 00000000000..f03465fe8e2 --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/repository_entity.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class RepositoryEntity < Grape::Entity + include ActionView::Helpers::SanitizeHelper + + expose :harbor_id do |item| + item['id'] + end + + expose :name do |item| + strip_tags(item['name']) + end + + expose :artifact_count do |item| + item['artifact_count'] + end + + expose :creation_time do |item| + item['creation_time']&.to_datetime&.utc + end + + expose :update_time do |item| + item['update_time']&.to_datetime&.utc + end + + expose :harbor_project_id do |item| + item['project_id'] + end + + expose :pull_count do |item| + item['pull_count'] + end + + expose :location do |item| + path = [ + 'harbor/projects', + item['project_id'].to_s, + 'repositories', + item['name'].remove("#{options[:project_name]}/") + ].join('/') + path = validate_path(path) + strip_tags(Gitlab::Utils.append_path(options[:url], path)) + end + + private + + def validate_path(path) + Gitlab::Utils.check_path_traversal!(path) + rescue ::Gitlab::Utils::PathTraversalAttackError + Gitlab::AppLogger.error("Path traversal attack detected #{path}") + '' + end + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/repository_serializer.rb b/app/serializers/integrations/harbor_serializers/repository_serializer.rb new file mode 100644 index 00000000000..9b9e089eab8 --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/repository_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class RepositorySerializer < BaseSerializer + include WithPagination + + entity ::Integrations::HarborSerializers::RepositoryEntity + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/tag_entity.rb b/app/serializers/integrations/harbor_serializers/tag_entity.rb new file mode 100644 index 00000000000..8c26bc1ecbd --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/tag_entity.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class TagEntity < Grape::Entity + include ActionView::Helpers::SanitizeHelper + + expose :harbor_repository_id do |item| + item['repository_id'] + end + + expose :harbor_artifact_id do |item| + item['artifact_id'] + end + + expose :harbor_id do |item| + item['id'] + end + + expose :name do |item| + strip_tags(item['name']) + end + + expose :pull_time do |item| + item['pull_time']&.to_datetime&.utc + end + + expose :push_time do |item| + item['push_time']&.to_datetime&.utc + end + + expose :signed do |item| + item['signed'] + end + + expose :immutable do |item| + item['immutable'] + end + end + end +end diff --git a/app/serializers/integrations/harbor_serializers/tag_serializer.rb b/app/serializers/integrations/harbor_serializers/tag_serializer.rb new file mode 100644 index 00000000000..7111e65e3e6 --- /dev/null +++ b/app/serializers/integrations/harbor_serializers/tag_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module HarborSerializers + class TagSerializer < BaseSerializer + include WithPagination + + entity ::Integrations::HarborSerializers::TagEntity + end + end +end diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml index 6a1e66520b5..a8a52b2aba7 100644 --- a/app/views/groups/harbor/repositories/index.html.haml +++ b/app/views/groups/harbor/repositories/index.html.haml @@ -1,7 +1,7 @@ - page_title _("Harbor Registry") - @content_class = "limit-container-width" unless fluid_layout -#js-harbor-registry-list-group{ data: { endpoint: group_harbor_registries_path(@group), +#js-harbor-registry-list-group{ data: { endpoint: group_harbor_repositories_path(@group), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index b33d1443706..33fcda6129c 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -3,6 +3,8 @@ - page_title _("Merge requests") - if issuables_count_for_state(:merge_requests, :all) == 0 + = render 'shared/issuable/search_bar', type: :merge_requests + = render 'shared/empty_states/merge_requests', project_select_button: true - else .top-area diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml index 270cbf3facd..0fce3b7f8aa 100644 --- a/app/views/projects/harbor/repositories/index.html.haml +++ b/app/views/projects/harbor/repositories/index.html.haml @@ -1,7 +1,7 @@ - page_title _("Harbor Registry") - @content_class = "limit-container-width" unless fluid_layout -#js-harbor-registry-list-project{ data: { endpoint: project_harbor_registry_index_path(@project), +#js-harbor-registry-list-project{ data: { endpoint: project_harbor_repositories_path(@project), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', diff --git a/config/routes/group.rb b/config/routes/group.rb index bf6094ff2f1..2a5931207b0 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -118,10 +118,17 @@ constraints(::Constraints::GroupUrlConstrainer.new) do end resources :container_registries, only: [:index, :show], controller: 'registry/repositories' - resources :harbor_registries, only: [:index, :show], controller: 'harbor/repositories' resource :dependency_proxy, only: [:show, :update] resources :email_campaigns, only: :index + namespace :harbor do + resources :repositories, only: [:index] do + resources :artifacts, only: [:index] do + resources :tags, only: [:index] + end + end + end + resources :autocomplete_sources, only: [] do collection do get 'members' diff --git a/config/routes/project.rb b/config/routes/project.rb index f996cd1945f..ba4dba47f4c 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -457,6 +457,14 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end end + + namespace :harbor do + resources :repositories, only: [:index, :show] do + resources :artifacts, only: [:index] do + resources :tags, only: [:index] + end + end + end end # End of the /-/ scope. @@ -523,9 +531,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resources :container_registry, only: [:index, :destroy, :show], # rubocop: disable Cop/PutProjectRoutesUnderScope controller: 'registry/repositories' - resources :harbor_registry, only: [:index, :show], # rubocop: disable Cop/PutProjectRoutesUnderScope - controller: 'harbor/repositories' - namespace :registry do resources :repository, only: [] do # rubocop: disable Cop/PutProjectRoutesUnderScope # We default to JSON format in the controller to avoid ambiguity. diff --git a/db/post_migrate/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences.rb b/db/post_migrate/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences.rb new file mode 100644 index 00000000000..1207b51f190 --- /dev/null +++ b/db/post_migrate/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddTmpIndexForPotentiallyMisassociatedVulnerabilityOccurrences < Gitlab::Database::Migration[2.0] + INDEX_NAME = "tmp_index_vulnerability_occurrences_on_id_and_scanner_id" + REPORT_TYPES = { cluster_image_scanning: 7, generic: 99 }.freeze + CLAUSE = "report_type IN (#{REPORT_TYPES.values.join(',')})" + + disable_ddl_transaction! + + def up + prepare_async_index :vulnerability_occurrences, + [:id, :scanner_id], + where: CLAUSE, + name: INDEX_NAME + end + + def down + unprepare_async_index_by_name :vulnerability_occurrences, INDEX_NAME + end +end diff --git a/db/post_migrate/20220630085003_drop_project_successfull_pages_deploy_index_from_ci_builds.rb b/db/post_migrate/20220630085003_drop_project_successfull_pages_deploy_index_from_ci_builds.rb new file mode 100644 index 00000000000..0810419a4e8 --- /dev/null +++ b/db/post_migrate/20220630085003_drop_project_successfull_pages_deploy_index_from_ci_builds.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class DropProjectSuccessfullPagesDeployIndexFromCiBuilds < Gitlab::Database::Migration[2.0] + disable_ddl_transaction! + + INDEX_NAME = 'index_ci_builds_on_project_id_for_successfull_pages_deploy' + + def up + remove_concurrent_index_by_name :ci_builds, INDEX_NAME + end + + # rubocop:disable Migration/PreventIndexCreation + def down + add_concurrent_index :ci_builds, + :project_id, + where: "(((type)::text = 'GenericCommitStatus'::text) AND ((stage)::text = 'deploy'::text) AND " \ + "((name)::text = 'pages:deploy'::text) AND ((status)::text = 'success'::text))", + name: INDEX_NAME + end + # rubocop:enable Migration/PreventIndexCreation +end diff --git a/db/schema_migrations/20220606082910 b/db/schema_migrations/20220606082910 new file mode 100644 index 00000000000..5917ba95971 --- /dev/null +++ b/db/schema_migrations/20220606082910 @@ -0,0 +1 @@ +ecab80f469d2aea061b5c8371a243e4b6686d637c3df284f23e575606ef8c1a6
\ No newline at end of file diff --git a/db/schema_migrations/20220630085003 b/db/schema_migrations/20220630085003 new file mode 100644 index 00000000000..9e020afbe84 --- /dev/null +++ b/db/schema_migrations/20220630085003 @@ -0,0 +1 @@ +c1fb356eb437f9511c0af324f9f4a173245a427d20e2bbda0557dfaff28911c3
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 69a5285d4a1..f4496eebb35 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -27492,8 +27492,6 @@ CREATE INDEX index_ci_builds_on_project_id_and_id ON ci_builds USING btree (proj CREATE INDEX index_ci_builds_on_project_id_and_name_and_ref ON ci_builds USING btree (project_id, name, ref) WHERE (((type)::text = 'Ci::Build'::text) AND ((status)::text = 'success'::text) AND ((retried = false) OR (retried IS NULL))); -CREATE INDEX index_ci_builds_on_project_id_for_successfull_pages_deploy ON ci_builds USING btree (project_id) WHERE (((type)::text = 'GenericCommitStatus'::text) AND ((stage)::text = 'deploy'::text) AND ((name)::text = 'pages:deploy'::text) AND ((status)::text = 'success'::text)); - CREATE INDEX index_ci_builds_on_queued_at ON ci_builds USING btree (queued_at); CREATE INDEX index_ci_builds_on_resource_group_and_status_and_commit_id ON ci_builds USING btree (resource_group_id, status, commit_id) WHERE (resource_group_id IS NOT NULL); diff --git a/doc/administration/geo/replication/object_storage.md b/doc/administration/geo/replication/object_storage.md index d75193b1309..d2e10678f8c 100644 --- a/doc/administration/geo/replication/object_storage.md +++ b/doc/administration/geo/replication/object_storage.md @@ -34,8 +34,7 @@ See [Object storage replication tests](geo_validation_tests.md#object-storage-re ## Enabling GitLab-managed object storage replication -> The beta feature was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/10586) in GitLab 12.4. -> The feature was made [generally available] in GitLab 15.1. +> The feature was made [generally available](https://gitlab.com/groups/gitlab-org/-/epics/5551) in GitLab 15.1. **Secondary** sites can replicate files stored on the **primary** site regardless of whether they are stored on the local file system or in object storage. diff --git a/doc/api/settings.md b/doc/api/settings.md index 7b7ceb1e986..e33f3990ddd 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -381,7 +381,7 @@ listed in the descriptions of the relevant settings. | `metrics_method_call_threshold` | integer | no | A method call is only tracked when it takes longer than the given amount of milliseconds. | | `max_number_of_repository_downloads` **(ULTIMATE SELF)** | integer | no | Maximum number of unique repositories a user can download in the specified time period before they are banned. Default: 0, Maximum: 10,000 repositories. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87980) in GitLab 15.1. | | `max_number_of_repository_downloads_within_time_period` **(ULTIMATE SELF)** | integer | no | Reporting time period (in seconds). Default: 0, Maximum: 864000 seconds (10 days). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87980) in GitLab 15.1. | -| `git_rate_limit_users_allowlist` **(ULTIMATE SELF)** | array of strings | no | List of usernames excluded from Git anti-abuse rate limits. Default: [], Maximum: 100 usernames. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90815) in GitLab 15.2. | +| `git_rate_limit_users_allowlist` **(ULTIMATE SELF)** | array of strings | no | List of usernames excluded from Git anti-abuse rate limits. Default: `[]`, Maximum: 100 usernames. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90815) in GitLab 15.2. | | `mirror_available` | boolean | no | Allow repository mirroring to configured by project Maintainers. If disabled, only Administrators can configure repository mirroring. | | `mirror_capacity_threshold` **(PREMIUM)** | integer | no | Minimum capacity to be available before scheduling more mirrors preemptively. | | `mirror_max_capacity` **(PREMIUM)** | integer | no | Maximum number of mirrors that can be synchronizing at the same time. | diff --git a/doc/user/infrastructure/iac/terraform_state.md b/doc/user/infrastructure/iac/terraform_state.md index e29228c7a4c..24203e8d922 100644 --- a/doc/user/infrastructure/iac/terraform_state.md +++ b/doc/user/infrastructure/iac/terraform_state.md @@ -22,6 +22,16 @@ In GitLab, you can: - Lock and unlock states. - Remotely execute `terraform plan` and `terraform apply` commands. +WARNING: +**Disaster recovery planning** +Terraform state files are encrypted with the lockbox Ruby gem when they are at rest on disk and in object storage. +[To decrypt a state file, GitLab must be available](https://gitlab.com/gitlab-org/gitlab/-/issues/335739). +If it is offline, and you use GitLab to deploy infrastructure that GitLab requires (like virtual machines, +Kubernetes clusters, or network components), you cannot access the state file easily or decrypt it. +Additionally, if GitLab serves up Terraform modules or other dependencies that are required to bootstrap GitLab, +these will be inaccessible. To work around this issue, make other arrangements to host or back up these dependencies, +or consider using a separate GitLab instance with no shared points of failure. + ## Prerequisites For self-managed GitLab, before you can use GitLab for your Terraform state files: diff --git a/lib/api/helpers/integrations_helpers.rb b/lib/api/helpers/integrations_helpers.rb index 0fbd0e6be44..0b0100c7d7f 100644 --- a/lib/api/helpers/integrations_helpers.rb +++ b/lib/api/helpers/integrations_helpers.rb @@ -633,6 +633,12 @@ module API }, { required: false, + name: :notify_only_default_branch, + type: Boolean, + desc: 'Send notifications only for the default branch' + }, + { + required: false, name: :branches_to_be_notified, type: String, desc: 'Branches for which notifications are to be sent' diff --git a/lib/gitlab/harbor/client.rb b/lib/gitlab/harbor/client.rb index 06142ae2b40..ee40725ba95 100644 --- a/lib/gitlab/harbor/client.rb +++ b/lib/gitlab/harbor/client.rb @@ -21,14 +21,44 @@ module Gitlab { success: response.success? } end + def get_repositories(params) + get(url("projects/#{integration.project_name}/repositories"), params) + end + + def get_artifacts(params) + repository_name = params.delete(:repository_name) + get(url("projects/#{integration.project_name}/repositories/#{repository_name}/artifacts"), params) + end + + def get_tags(params) + repository_name = params.delete(:repository_name) + artifact_name = params.delete(:artifact_name) + get( + url("projects/#{integration.project_name}/repositories/#{repository_name}/artifacts/#{artifact_name}/tags"), + params + ) + end + private - def url(path) - Gitlab::Utils.append_path(base_url, path) + def get(path, params = {}) + options = { headers: headers, query: params } + response = Gitlab::HTTP.get(path, options) + + raise Gitlab::Harbor::Client::Error, 'request error' unless response.success? + + { + body: Gitlab::Json.parse(response.body), + total_count: response.headers['x-total-count'].to_i + } + rescue JSON::ParserError + raise Gitlab::Harbor::Client::Error, 'invalid response format' end - def base_url - Gitlab::Utils.append_path(integration.url, '/api/v2.0/') + # url must be used within get method otherwise this would avoid validation by GitLab::HTTP + def url(path) + base_url = Gitlab::Utils.append_path(integration.url, '/api/v2.0/') + Gitlab::Utils.append_path(base_url, path) end def headers diff --git a/lib/gitlab/harbor/query.rb b/lib/gitlab/harbor/query.rb new file mode 100644 index 00000000000..c120810ecf1 --- /dev/null +++ b/lib/gitlab/harbor/query.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Gitlab + module Harbor + class Query + include ActiveModel::Validations + + attr_reader :client, :repository_id, :artifact_id, :search, :limit, :sort, :page + + DEFAULT_LIMIT = 10 + SORT_REGEX = %r{\A(creation_time|update_time|name) (asc|desc)\z}.freeze + + validates :page, numericality: { greater_than: 0, integer: true }, allow_blank: true + validates :limit, numericality: { greater_than: 0, less_than_or_equal_to: 25, integer: true }, allow_blank: true + validates :repository_id, format: { + with: /\A[a-zA-Z0-9\_\.\-$]+\z/, + message: 'Id invalid' + }, allow_blank: true + validates :artifact_id, format: { + with: /\A[a-zA-Z0-9\_\.\-$]+\z/, + message: 'Id invalid' + }, allow_blank: true + validates :sort, format: { + with: SORT_REGEX, + message: 'params invalid' + }, allow_blank: true + validates :search, format: { + with: /\A([a-z\_]*=[a-zA-Z0-9\- :]*,*)*\z/, + message: 'params invalid' + }, allow_blank: true + + def initialize(integration, params) + @client = Client.new(integration) + @repository_id = params[:repository_id] + @artifact_id = params[:artifact_id] + @search = params[:search] + @limit = params[:limit] + @sort = params[:sort] + @page = params[:page] + validate + end + + def repositories + result = @client.get_repositories(query_options) + return [] if result[:total_count] == 0 + + Kaminari.paginate_array( + result[:body], + limit: query_page_size, + total_count: result[:total_count] + ) + end + + def artifacts + result = @client.get_artifacts(query_artifacts_options) + return [] if result[:total_count] == 0 + + Kaminari.paginate_array( + result[:body], + limit: query_page_size, + total_count: result[:total_count] + ) + end + + def tags + result = @client.get_tags(query_tags_options) + return [] if result[:total_count] == 0 + + Kaminari.paginate_array( + result[:body], + limit: query_page_size, + total_count: result[:total_count] + ) + end + + private + + def query_artifacts_options + options = query_options + options[:repository_name] = repository_id + options[:with_tag] = true + + options + end + + def query_options + options = { + page: query_page, + page_size: query_page_size + } + + options[:q] = query_search if search.present? + options[:sort] = query_sort if sort.present? + + options + end + + def query_tags_options + options = query_options + options[:repository_name] = repository_id + options[:artifact_name] = artifact_id + + options + end + + def query_page + page.presence || 1 + end + + def query_page_size + (limit.presence || DEFAULT_LIMIT).to_i + end + + def query_search + search.gsub('=', '=~') + end + + def query_sort + match = sort.match(SORT_REGEX) + order = (match[2] == 'asc' ? '' : '-') + + "#{order}#{match[1]}" + end + end + end +end diff --git a/lib/sidebars/groups/menus/packages_registries_menu.rb b/lib/sidebars/groups/menus/packages_registries_menu.rb index 4c21845ef18..fda90406e0a 100644 --- a/lib/sidebars/groups/menus/packages_registries_menu.rb +++ b/lib/sidebars/groups/menus/packages_registries_menu.rb @@ -54,7 +54,7 @@ module Sidebars ::Sidebars::MenuItem.new( title: _('Harbor Registry'), - link: group_harbor_registries_path(context.group), + link: group_harbor_repositories_path(context.group), active_routes: { controller: 'groups/harbor/repositories' }, item_id: :harbor_registry ) diff --git a/lib/sidebars/projects/menus/packages_registries_menu.rb b/lib/sidebars/projects/menus/packages_registries_menu.rb index d82a02a342f..914368e6fec 100644 --- a/lib/sidebars/projects/menus/packages_registries_menu.rb +++ b/lib/sidebars/projects/menus/packages_registries_menu.rb @@ -70,8 +70,8 @@ module Sidebars ::Sidebars::MenuItem.new( title: _('Harbor Registry'), - link: project_harbor_registry_index_path(context.project), - active_routes: { controller: 'projects/harbor/repositories' }, + link: project_harbor_repositories_path(context.project), + active_routes: { controller: :harbor_registry }, item_id: :harbor_registry ) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index abba06393da..6f1034113a0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21698,15 +21698,9 @@ msgstr "" msgid "IssueTracker|The URL to create an issue in the external issue tracker." msgstr "" -msgid "IssueTracker|The URL to the project in YouTrack." -msgstr "" - msgid "IssueTracker|The URL to the project in the external issue tracker." msgstr "" -msgid "IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}." -msgstr "" - msgid "IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}." msgstr "" @@ -30221,9 +30215,6 @@ msgstr "" msgid "ProjectService|Enter new token" msgstr "" -msgid "ProjectService|Issue URL" -msgstr "" - msgid "ProjectService|Jenkins server URL" msgstr "" diff --git a/spec/controllers/concerns/harbor/artifact_spec.rb b/spec/controllers/concerns/harbor/artifact_spec.rb new file mode 100644 index 00000000000..6716d615a3b --- /dev/null +++ b/spec/controllers/concerns/harbor/artifact_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Harbor::Artifact do + controller(ActionController::Base) do + include ::Harbor::Artifact + end + it_behaves_like 'raises NotImplementedError when calling #container' +end diff --git a/spec/controllers/concerns/harbor/repository_spec.rb b/spec/controllers/concerns/harbor/repository_spec.rb new file mode 100644 index 00000000000..cae038ceed2 --- /dev/null +++ b/spec/controllers/concerns/harbor/repository_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Harbor::Repository do + controller(ActionController::Base) do + include ::Harbor::Repository + end + it_behaves_like 'raises NotImplementedError when calling #container' +end diff --git a/spec/controllers/concerns/harbor/tag_spec.rb b/spec/controllers/concerns/harbor/tag_spec.rb new file mode 100644 index 00000000000..0d72ef303b0 --- /dev/null +++ b/spec/controllers/concerns/harbor/tag_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Harbor::Tag do + controller(ActionController::Base) do + include ::Harbor::Tag + end + it_behaves_like 'raises NotImplementedError when calling #container' +end diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index 3945637c2c3..5ac26b7a260 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -233,7 +233,7 @@ FactoryBot.define do factory :harbor_integration, class: 'Integrations::Harbor' do project active { true } - type { 'HarborService' } + type { 'Integrations::Harbor' } url { 'https://demo.goharbor.io' } project_name { 'testproject' } diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb index 7541e54f014..be1db970e9d 100644 --- a/spec/features/groups/merge_requests_spec.rb +++ b/spec/features/groups/merge_requests_spec.rb @@ -86,7 +86,7 @@ RSpec.describe 'Group merge requests page' do expect(page).to have_selector('.empty-state') expect(page).to have_link('Select project to create merge request') - expect(page).not_to have_selector('.issues-filters') + expect(page).to have_selector('.issues-filters') end context 'with no open merge requests' do diff --git a/spec/lib/gitlab/harbor/client_spec.rb b/spec/lib/gitlab/harbor/client_spec.rb index bc5b593370a..4e80b8b53e3 100644 --- a/spec/lib/gitlab/harbor/client_spec.rb +++ b/spec/lib/gitlab/harbor/client_spec.rb @@ -3,12 +3,277 @@ require 'spec_helper' RSpec.describe Gitlab::Harbor::Client do - let(:harbor_integration) { build(:harbor_integration) } + let_it_be(:harbor_integration) { create(:harbor_integration) } subject(:client) { described_class.new(harbor_integration) } + describe '#initialize' do + context 'if integration is nil' do + let(:harbor_integration) { nil } + + it 'raises ConfigError' do + expect { client }.to raise_error(described_class::ConfigError) + end + end + + context 'integration is provided' do + it 'is initialized successfully' do + expect { client }.not_to raise_error + end + end + end + + describe '#get_repositories' do + context 'with valid params' do + let(:mock_response) do + [ + { + "artifact_count": 1, + "creation_time": "2022-03-13T09:36:43.240Z", + "id": 1, + "name": "jihuprivate/busybox", + "project_id": 4, + "pull_count": 0, + "update_time": "2022-03-13T09:36:43.240Z" + } + ] + end + + let(:mock_repositories) do + { + body: mock_response, + total_count: 2 + } + end + + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 200, body: mock_response.to_json, headers: { "x-total-count": 2 }) + end + + it 'get repositories' do + expect(client.get_repositories({}).deep_stringify_keys).to eq(mock_repositories.deep_stringify_keys) + end + end + + context 'when harbor project does not exist' do + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 404, body: {}.to_json) + end + + it 'raises Gitlab::Harbor::Client::Error' do + expect do + client.get_repositories({}) + end.to raise_error(Gitlab::Harbor::Client::Error, 'request error') + end + end + + context 'with invalid response' do + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 200, body: '[not json}') + end + + it 'raises Gitlab::Harbor::Client::Error' do + expect do + client.get_repositories({}) + end.to raise_error(Gitlab::Harbor::Client::Error, 'invalid response format') + end + end + end + + describe '#get_artifacts' do + context 'with valid params' do + let(:mock_response) do + [ + { + "digest": "sha256:661e8e44e5d7290fbd42d0495ab4ff6fdf1ad251a9f358969b3264a22107c14d", + "icon": "sha256:0048162a053eef4d4ce3fe7518615bef084403614f8bca43b40ae2e762e11e06", + "id": 1, + "project_id": 1, + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-04-23T08:04:08.901Z", + "repository_id": 1, + "size": 126745886, + "tags": [ + { + "artifact_id": 1, + "id": 1, + "immutable": false, + "name": "2", + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-04-23T08:04:08.920Z", + "repository_id": 1, + "signed": false + } + ], + "type": "IMAGE" + } + ] + end + + let(:mock_artifacts) do + { + body: mock_response, + total_count: 1 + } + end + + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories/test/artifacts") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 200, body: mock_response.to_json, headers: { "x-total-count": 1 }) + end + + it 'get artifacts' do + expect(client.get_artifacts({ repository_name: 'test' }) + .deep_stringify_keys).to eq(mock_artifacts.deep_stringify_keys) + end + end + + context 'when harbor repository does not exist' do + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories/test/artifacts") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 404, body: {}.to_json) + end + + it 'raises Gitlab::Harbor::Client::Error' do + expect do + client.get_artifacts({ repository_name: 'test' }) + end.to raise_error(Gitlab::Harbor::Client::Error, 'request error') + end + end + + context 'with invalid response' do + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories/test/artifacts") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 200, body: '[not json}') + end + + it 'raises Gitlab::Harbor::Client::Error' do + expect do + client.get_artifacts({ repository_name: 'test' }) + end.to raise_error(Gitlab::Harbor::Client::Error, 'invalid response format') + end + end + end + + describe '#get_tags' do + context 'with valid params' do + let(:mock_response) do + [ + { + "artifact_id": 1, + "id": 1, + "immutable": false, + "name": "2", + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-04-23T08:04:08.920Z", + "repository_id": 1, + "signed": false + } + ] + end + + let(:mock_tags) do + { + body: mock_response, + total_count: 1 + } + end + + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories/test/artifacts/1/tags") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 200, body: mock_response.to_json, headers: { "x-total-count": 1 }) + end + + it 'get tags' do + expect(client.get_tags({ repository_name: 'test', artifact_name: '1' }) + .deep_stringify_keys).to eq(mock_tags.deep_stringify_keys) + end + end + + context 'when harbor artifact does not exist' do + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories/test/artifacts/1/tags") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 404, body: {}.to_json) + end + + it 'raises Gitlab::Harbor::Client::Error' do + expect do + client.get_tags({ repository_name: 'test', artifact_name: '1' }) + end.to raise_error(Gitlab::Harbor::Client::Error, 'request error') + end + end + + context 'with invalid response' do + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories/test/artifacts/1/tags") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + .to_return(status: 200, body: '[not json}') + end + + it 'raises Gitlab::Harbor::Client::Error' do + expect do + client.get_tags({ repository_name: 'test', artifact_name: '1' }) + end.to raise_error(Gitlab::Harbor::Client::Error, 'invalid response format') + end + end + end + describe '#ping' do - let!(:harbor_ping_request) { stub_harbor_request("https://demo.goharbor.io/api/v2.0/ping") } + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/ping") + .with( + headers: { + 'Content-Type': 'application/json' + }) + .to_return(status: 200, body: 'pong') + end it "calls api/v2.0/ping successfully" do expect(client.ping).to eq(success: true) diff --git a/spec/lib/gitlab/harbor/query_spec.rb b/spec/lib/gitlab/harbor/query_spec.rb new file mode 100644 index 00000000000..dcb9a16b27b --- /dev/null +++ b/spec/lib/gitlab/harbor/query_spec.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Harbor::Query do + let_it_be(:harbor_integration) { create(:harbor_integration) } + + let(:params) { {} } + + subject(:query) { described_class.new(harbor_integration, ActionController::Parameters.new(params)) } + + describe 'Validations' do + context 'page' do + context 'with valid page' do + let(:params) { { page: 1 } } + + it 'initialize successfully' do + expect(query.valid?).to eq(true) + end + end + + context 'with invalid page' do + let(:params) { { page: -1 } } + + it 'initialize failed' do + expect(query.valid?).to eq(false) + end + end + end + + context 'limit' do + context 'with valid limit' do + let(:params) { { limit: 1 } } + + it 'initialize successfully' do + expect(query.valid?).to eq(true) + end + end + + context 'with invalid limit' do + context 'with limit less than 0' do + let(:params) { { limit: -1 } } + + it 'initialize failed' do + expect(query.valid?).to eq(false) + end + end + + context 'with limit greater than 25' do + let(:params) { { limit: 26 } } + + it 'initialize failed' do + expect(query.valid?).to eq(false) + end + end + end + end + + context 'repository_id' do + context 'with valid repository_id' do + let(:params) { { repository_id: 'test' } } + + it 'initialize successfully' do + expect(query.valid?).to eq(true) + end + end + + context 'with invalid repository_id' do + let(:params) { { repository_id: 'test@@' } } + + it 'initialize failed' do + expect(query.valid?).to eq(false) + end + end + end + + context 'artifact_id' do + context 'with valid artifact_id' do + let(:params) { { artifact_id: 'test' } } + + it 'initialize successfully' do + expect(query.valid?).to eq(true) + end + end + + context 'with invalid artifact_id' do + let(:params) { { artifact_id: 'test@@' } } + + it 'initialize failed' do + expect(query.valid?).to eq(false) + end + end + end + + context 'sort' do + context 'with valid sort' do + let(:params) { { sort: 'creation_time desc' } } + + it 'initialize successfully' do + expect(query.valid?).to eq(true) + end + end + + context 'with invalid sort' do + let(:params) { { sort: 'blabla desc' } } + + it 'initialize failed' do + expect(query.valid?).to eq(false) + end + end + end + + context 'search' do + context 'with valid search' do + let(:params) { { search: 'name=desc' } } + + it 'initialize successfully' do + expect(query.valid?).to eq(true) + end + end + + context 'with invalid search' do + let(:params) { { search: 'blabla' } } + + it 'initialize failed' do + expect(query.valid?).to eq(false) + end + end + end + end + + describe '#repositories' do + let(:response) { { total_count: 0, repositories: [] } } + + def expect_query_option_include(expected_params) + expect_next_instance_of(Gitlab::Harbor::Client) do |client| + expect(client).to receive(:get_repositories) + .with(hash_including(expected_params)) + .and_return(response) + end + + query.repositories + end + + context 'when params is {}' do + it 'fills default params' do + expect_query_option_include(page_size: 10, page: 1) + end + end + + context 'when params contains options' do + let(:params) { { search: 'name=bu', sort: 'creation_time desc', limit: 20, page: 3 } } + + it 'fills params with standard of Harbor' do + expect_query_option_include(q: 'name=~bu', sort: '-creation_time', page_size: 20, page: 3) + end + end + + context 'when params contains invalid sort option' do + let(:params) { { search: 'name=bu', sort: 'blabla desc', limit: 20, page: 3 } } + + it 'ignores invalid sort params' do + expect(query.valid?).to eq(false) + end + end + + context 'when client.get_repositories returns data' do + let(:response_with_data) do + { + total_count: 1, + body: + [ + { + "id": 3, + "name": "testproject/thirdbusybox", + "artifact_count": 1, + "creation_time": "2022-03-15T07:12:14.479Z", + "update_time": "2022-03-15T07:12:14.479Z", + "project_id": 3, + "pull_count": 0 + }.with_indifferent_access + ] + } + end + + it 'returns the right repositories data' do + expect_next_instance_of(Gitlab::Harbor::Client) do |client| + expect(client).to receive(:get_repositories) + .with(hash_including(page_size: 10, page: 1)) + .and_return(response_with_data) + end + + expect(query.repositories.first).to include( + "name": "testproject/thirdbusybox", + "artifact_count": 1 + ) + end + end + end + + describe '#artifacts' do + let(:response) { { total_count: 0, artifacts: [] } } + + def expect_query_option_include(expected_params) + expect_next_instance_of(Gitlab::Harbor::Client) do |client| + expect(client).to receive(:get_artifacts) + .with(hash_including(expected_params)) + .and_return(response) + end + + query.artifacts + end + + context 'when params is {}' do + it 'fills default params' do + expect_query_option_include(page_size: 10, page: 1) + end + end + + context 'when params contains options' do + let(:params) do + { search: 'tags=1', repository_id: 'jihuprivate', sort: 'creation_time desc', limit: 20, page: 3 } + end + + it 'fills params with standard of Harbor' do + expect_query_option_include(q: 'tags=~1', sort: '-creation_time', page_size: 20, page: 3) + end + end + + context 'when params contains invalid sort option' do + let(:params) { { search: 'tags=1', repository_id: 'jihuprivate', sort: 'blabla desc', limit: 20, page: 3 } } + + it 'ignores invalid sort params' do + expect(query.valid?).to eq(false) + end + end + + context 'when client.get_artifacts returns data' do + let(:response_with_data) do + { + total_count: 1, + body: + [ + { + "digest": "sha256:14d4f50961544fdb669075c442509f194bdc4c0e344bde06e35dbd55af842a38", + "icon": "sha256:0048162a053eef4d4ce3fe7518615bef084403614f8bca43b40ae2e762e11e06", + "id": 5, + "project_id": 14, + "push_time": "2022-03-22T09:04:56.170Z", + "repository_id": 5, + "size": 774790, + "tags": [ + { + "artifact_id": 5, + "id": 7, + "immutable": false, + "name": "2", + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-03-22T09:05:04.844Z", + "repository_id": 5 + }, + { + "artifact_id": 5, + "id": 6, + "immutable": false, + "name": "1", + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-03-22T09:04:56.186Z", + "repository_id": 5 + } + ], + "type": "IMAGE" + }.with_indifferent_access + ] + } + end + + it 'returns the right artifacts data' do + expect_next_instance_of(Gitlab::Harbor::Client) do |client| + expect(client).to receive(:get_artifacts) + .with(hash_including(page_size: 10, page: 1)) + .and_return(response_with_data) + end + + artifact = query.artifacts.first + + expect(artifact).to include( + "digest": "sha256:14d4f50961544fdb669075c442509f194bdc4c0e344bde06e35dbd55af842a38", + "push_time": "2022-03-22T09:04:56.170Z" + ) + expect(artifact["tags"].size).to eq(2) + end + end + end + + describe '#tags' do + let(:response) { { total_count: 0, tags: [] } } + + def expect_query_option_include(expected_params) + expect_next_instance_of(Gitlab::Harbor::Client) do |client| + expect(client).to receive(:get_tags) + .with(hash_including(expected_params)) + .and_return(response) + end + + query.tags + end + + context 'when params is {}' do + it 'fills default params' do + expect_query_option_include(page_size: 10, page: 1) + end + end + + context 'when params contains options' do + let(:params) { { repository_id: 'jihuprivate', sort: 'creation_time desc', limit: 20, page: 3 } } + + it 'fills params with standard of Harbor' do + expect_query_option_include(sort: '-creation_time', page_size: 20, page: 3) + end + end + + context 'when params contains invalid sort option' do + let(:params) { { repository_id: 'jihuprivate', artifact_id: 'test', sort: 'blabla desc', limit: 20, page: 3 } } + + it 'ignores invalid sort params' do + expect(query.valid?).to eq(false) + end + end + + context 'when client.get_tags returns data' do + let(:response_with_data) do + { + total_count: 2, + body: + [ + { + "artifact_id": 5, + "id": 7, + "immutable": false, + "name": "2", + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-03-22T09:05:04.844Z", + "repository_id": 5 + }, + { + "artifact_id": 5, + "id": 6, + "immutable": false, + "name": "1", + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-03-22T09:04:56.186Z", + "repository_id": 5 + }.with_indifferent_access + ] + } + end + + it 'returns the right tags data' do + expect_next_instance_of(Gitlab::Harbor::Client) do |client| + expect(client).to receive(:get_tags) + .with(hash_including(page_size: 10, page: 1)) + .and_return(response_with_data) + end + + tag = query.tags.first + + expect(tag).to include( + "immutable": false, + "push_time": "2022-03-22T09:05:04.844Z" + ) + end + end + end +end diff --git a/spec/migrations/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb b/spec/migrations/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb new file mode 100644 index 00000000000..1450811b3b9 --- /dev/null +++ b/spec/migrations/20220606082910_add_tmp_index_for_potentially_misassociated_vulnerability_occurrences_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "spec_helper" + +require_migration! + +RSpec.describe AddTmpIndexForPotentiallyMisassociatedVulnerabilityOccurrences do + let(:async_index) { Gitlab::Database::AsyncIndexes::PostgresAsyncIndex } + let(:index_name) { described_class::INDEX_NAME } + + it "schedules the index" do + reversible_migration do |migration| + migration.before -> do + expect(async_index.where(name: index_name).count).to be(0) + end + + migration.after -> do + expect(async_index.where(name: index_name).count).to be(1) + end + end + end +end diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb index 3a4b836e453..fc5a9c879f6 100644 --- a/spec/models/ci/group_variable_spec.rb +++ b/spec/models/ci/group_variable_spec.rb @@ -56,4 +56,10 @@ RSpec.describe Ci::GroupVariable do let!(:parent) { model.group } end + + describe '#audit_details' do + it "equals to the group variable's key" do + expect(subject.audit_details).to eq(subject.key) + end + end end diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index 29ca088ee04..f0af229ff2c 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -51,4 +51,10 @@ RSpec.describe Ci::Variable do let!(:model) { create(:ci_variable, project: parent) } end end + + describe '#audit_details' do + it "equals to the variable's key" do + expect(subject.audit_details).to eq(subject.key) + end + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 91ba12a16d5..e8e805b2678 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -42,6 +42,7 @@ RSpec.describe Group do it { is_expected.to have_many(:organizations).class_name('CustomerRelations::Organization') } it { is_expected.to have_one(:crm_settings) } it { is_expected.to have_one(:group_feature) } + it { is_expected.to have_one(:harbor_integration) } describe '#members & #requesters' do let(:requester) { create(:user) } diff --git a/spec/models/integrations/datadog_spec.rb b/spec/models/integrations/datadog_spec.rb index cfc44b22a84..47f916e8457 100644 --- a/spec/models/integrations/datadog_spec.rb +++ b/spec/models/integrations/datadog_spec.rb @@ -240,4 +240,20 @@ RSpec.describe Integrations::Datadog do end end end + + describe '#fields' do + it 'includes the archive_trace_events field' do + expect(instance.fields).to include(have_attributes(name: 'archive_trace_events')) + end + + context 'when the FF :datadog_integration_logs_collection is disabled' do + before do + stub_feature_flags(datadog_integration_logs_collection: false) + end + + it 'does not include the archive_trace_events field' do + expect(instance.fields).not_to include(have_attributes(name: 'archive_trace_events')) + end + end + end end diff --git a/spec/models/integrations/harbor_spec.rb b/spec/models/integrations/harbor_spec.rb index 9e3d4b524a6..5d8597969a1 100644 --- a/spec/models/integrations/harbor_spec.rb +++ b/spec/models/integrations/harbor_spec.rb @@ -19,6 +19,14 @@ RSpec.describe Integrations::Harbor do it { is_expected.to allow_value('helloworld').for(:password) } end + describe 'url' do + subject { build(:harbor_integration) } + + it { is_expected.not_to allow_value('https://192.168.1.1').for(:url) } + it { is_expected.not_to allow_value('https://127.0.0.1').for(:url) } + it { is_expected.to allow_value('https://demo.goharbor.io').for(:url)} + end + describe '#fields' do it 'returns custom fields' do expect(harbor_integration.fields.pluck(:name)).to eq(%w[url project_name username password]) diff --git a/spec/models/integrations/youtrack_spec.rb b/spec/models/integrations/youtrack_spec.rb index f6a9dd8ef37..618ebcbb76a 100644 --- a/spec/models/integrations/youtrack_spec.rb +++ b/spec/models/integrations/youtrack_spec.rb @@ -37,4 +37,10 @@ RSpec.describe Integrations::Youtrack do expect(described_class.reference_pattern.match('yt-123')[:issue]).to eq('yt-123') end end + + describe '#fields' do + it 'only returns the project_url and issues_url fields' do + expect(subject.fields.pluck(:name)).to eq(%w[project_url issues_url]) + end + end end diff --git a/spec/requests/groups/harbor/artifacts_controller_spec.rb b/spec/requests/groups/harbor/artifacts_controller_spec.rb new file mode 100644 index 00000000000..ea9529119a6 --- /dev/null +++ b/spec/requests/groups/harbor/artifacts_controller_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::Harbor::ArtifactsController do + it_behaves_like 'a harbor artifacts controller', anonymous_status_code: '404' do + let_it_be(:container) { create(:group) } + let_it_be(:harbor_integration) { create(:harbor_integration, group: container, project: nil) } + end +end diff --git a/spec/requests/groups/harbor/repositories_controller_spec.rb b/spec/requests/groups/harbor/repositories_controller_spec.rb index 3e475dc410e..b4022561f54 100644 --- a/spec/requests/groups/harbor/repositories_controller_spec.rb +++ b/spec/requests/groups/harbor/repositories_controller_spec.rb @@ -3,67 +3,8 @@ require 'spec_helper' RSpec.describe Groups::Harbor::RepositoriesController do - let_it_be(:group, reload: true) { create(:group) } - let_it_be(:user) { create(:user) } - - shared_examples 'responds with 404 status' do - it 'returns 404' do - subject - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - shared_examples 'responds with 200 status' do - it 'renders the index template' do - subject - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template(:index) - end - end - - before do - stub_feature_flags(harbor_registry_integration: true) - group.add_reporter(user) - login_as(user) - end - - describe 'GET #index' do - subject do - get group_harbor_registries_path(group) - response - end - - context 'with harbor registry feature flag enabled' do - it_behaves_like 'responds with 200 status' - end - - context 'with harbor registry feature flag disabled' do - before do - stub_feature_flags(harbor_registry_integration: false) - end - - it_behaves_like 'responds with 404 status' - end - end - - describe 'GET #show' do - subject do - get group_harbor_registry_path(group, 1) - response - end - - context 'with harbor registry feature flag enabled' do - it_behaves_like 'responds with 200 status' - end - - context 'with harbor registry feature flag disabled' do - before do - stub_feature_flags(harbor_registry_integration: false) - end - - it_behaves_like 'responds with 404 status' - end + it_behaves_like 'a harbor repositories controller', anonymous_status_code: '404' do + let_it_be(:container, reload: true) { create(:group) } + let_it_be(:harbor_integration) { create(:harbor_integration, group: container, project: nil) } end end diff --git a/spec/requests/groups/harbor/tags_controller_spec.rb b/spec/requests/groups/harbor/tags_controller_spec.rb new file mode 100644 index 00000000000..257d4366837 --- /dev/null +++ b/spec/requests/groups/harbor/tags_controller_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::Harbor::TagsController do + it_behaves_like 'a harbor tags controller', anonymous_status_code: '404' do + let_it_be(:container) { create(:group) } + let_it_be(:harbor_integration) { create(:harbor_integration, group: container, project: nil) } + end +end diff --git a/spec/requests/projects/harbor/artifacts_controller_spec.rb b/spec/requests/projects/harbor/artifacts_controller_spec.rb new file mode 100644 index 00000000000..310fbcf0a0f --- /dev/null +++ b/spec/requests/projects/harbor/artifacts_controller_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Harbor::ArtifactsController do + it_behaves_like 'a harbor artifacts controller', anonymous_status_code: '302' do + let_it_be(:container) { create(:project) } + let_it_be(:harbor_integration) { create(:harbor_integration, project: container) } + end +end diff --git a/spec/requests/projects/harbor/repositories_controller_spec.rb b/spec/requests/projects/harbor/repositories_controller_spec.rb index cdb5a696d7e..751becaa20a 100644 --- a/spec/requests/projects/harbor/repositories_controller_spec.rb +++ b/spec/requests/projects/harbor/repositories_controller_spec.rb @@ -3,67 +3,8 @@ require 'spec_helper' RSpec.describe Projects::Harbor::RepositoriesController do - let_it_be(:project, reload: true) { create(:project) } - let_it_be(:user) { create(:user) } - - shared_examples 'responds with 404 status' do - it 'returns 404' do - subject - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - shared_examples 'responds with 200 status' do - it 'renders the index template' do - subject - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template(:index) - end - end - - before do - stub_feature_flags(harbor_registry_integration: true) - project.add_developer(user) - sign_in(user) - end - - describe 'GET #index' do - subject do - get project_harbor_registry_index_path(project) - response - end - - context 'with harbor registry feature flag enabled' do - it_behaves_like 'responds with 200 status' - end - - context 'with harbor registry feature flag disabled' do - before do - stub_feature_flags(harbor_registry_integration: false) - end - - it_behaves_like 'responds with 404 status' - end - end - - describe 'GET #show' do - subject do - get project_harbor_registry_path(project, 1) - response - end - - context 'with harbor registry feature flag enabled' do - it_behaves_like 'responds with 200 status' - end - - context 'with harbor registry feature flag disabled' do - before do - stub_feature_flags(harbor_registry_integration: false) - end - - it_behaves_like 'responds with 404 status' - end + it_behaves_like 'a harbor repositories controller', anonymous_status_code: '302' do + let_it_be(:container, reload: true) { create(:project) } + let_it_be(:harbor_integration) { create(:harbor_integration, project: container) } end end diff --git a/spec/requests/projects/harbor/tags_controller_spec.rb b/spec/requests/projects/harbor/tags_controller_spec.rb new file mode 100644 index 00000000000..119d1c746ac --- /dev/null +++ b/spec/requests/projects/harbor/tags_controller_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Harbor::TagsController do + it_behaves_like 'a harbor tags controller', anonymous_status_code: '302' do + let_it_be(:container) { create(:project) } + let_it_be(:harbor_integration) { create(:harbor_integration, project: container) } + end +end diff --git a/spec/routing/group_routing_spec.rb b/spec/routing/group_routing_spec.rb index 5c2ef62683e..9f5f821cc61 100644 --- a/spec/routing/group_routing_spec.rb +++ b/spec/routing/group_routing_spec.rb @@ -59,6 +59,18 @@ RSpec.shared_examples 'groups routing' do expect(get('/groups/gitlabhq/-/boards')).to route_to('groups/boards#index', group_id: 'gitlabhq') end + + it 'routes to the harbor repositories controller' do + expect(get("groups/#{group_path}/-/harbor/repositories")).to route_to('groups/harbor/repositories#index', group_id: group_path) + end + + it 'routes to the harbor artifacts controller' do + expect(get("groups/#{group_path}/-/harbor/repositories/test/artifacts")).to route_to('groups/harbor/artifacts#index', group_id: group_path, repository_id: 'test') + end + + it 'routes to the harbor tags controller' do + expect(get("groups/#{group_path}/-/harbor/repositories/test/artifacts/test/tags")).to route_to('groups/harbor/tags#index', group_id: group_path, repository_id: 'test', artifact_id: 'test') + end end RSpec.describe "Groups", "routing" do diff --git a/spec/serializers/integrations/harbor_serializers/artifact_entity_spec.rb b/spec/serializers/integrations/harbor_serializers/artifact_entity_spec.rb new file mode 100644 index 00000000000..c9a95c02e19 --- /dev/null +++ b/spec/serializers/integrations/harbor_serializers/artifact_entity_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::HarborSerializers::ArtifactEntity do + let_it_be(:harbor_integration) { create(:harbor_integration) } + + let(:artifact) do + { + "digest": "sha256:14d4f50961544fdb669075c442509f194bdc4c0e344bde06e35dbd55af842a38", + "id": 5, + "project_id": 14, + "push_time": "2022-03-22T09:04:56.170Z", + "repository_id": 5, + "size": 774790, + "tags": [ + { + "artifact_id": 5, + "id": 7, + "immutable": false, + "name": "2", + "push_time": "2022-03-22T09:05:04.844Z", + "repository_id": 5, + "signed": false + }, + { + "artifact_id": 5, + "id": 6, + "immutable": false, + "name": "1", + "push_time": "2022-03-22T09:04:56.186Z", + "repository_id": 5, + "signed": false + } + ], + "type": "IMAGE" + }.deep_stringify_keys + end + + subject { described_class.new(artifact).as_json } + + it 'returns the Harbor artifact' do + expect(subject).to include({ + harbor_id: 5, + size: 774790, + push_time: "2022-03-22T09:04:56.170Z".to_datetime, + digest: "sha256:14d4f50961544fdb669075c442509f194bdc4c0e344bde06e35dbd55af842a38", + tags: %w[2 1] + }) + end +end diff --git a/spec/serializers/integrations/harbor_serializers/artifact_serializer_spec.rb b/spec/serializers/integrations/harbor_serializers/artifact_serializer_spec.rb new file mode 100644 index 00000000000..9879c0a6434 --- /dev/null +++ b/spec/serializers/integrations/harbor_serializers/artifact_serializer_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::HarborSerializers::ArtifactSerializer do + it 'represents Integrations::HarborSerializers::ArtifactEntity entities' do + expect(described_class.entity_class).to eq(Integrations::HarborSerializers::ArtifactEntity) + end +end diff --git a/spec/serializers/integrations/harbor_serializers/repository_entity_spec.rb b/spec/serializers/integrations/harbor_serializers/repository_entity_spec.rb new file mode 100644 index 00000000000..29708bd0416 --- /dev/null +++ b/spec/serializers/integrations/harbor_serializers/repository_entity_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::HarborSerializers::RepositoryEntity do + let_it_be(:harbor_integration) { create(:harbor_integration) } + + let(:repo) do + { + "artifact_count" => 1, + "creation_time" => "2022-03-13T09:36:43.240Z", + "id" => 1, + "name" => "jihuprivate/busybox", + "project_id" => 4, + "pull_count" => 0, + "update_time" => "2022-03-13T09:36:43.240Z" + }.deep_stringify_keys + end + + subject { described_class.new(repo, url: "https://demo.goharbor.io", project_name: "jihuprivate").as_json } + + context 'with normal repository data' do + it 'returns the Harbor repository' do + expect(subject).to include({ + artifact_count: 1, + creation_time: "2022-03-13T09:36:43.240Z".to_datetime, + harbor_id: 1, + name: "jihuprivate/busybox", + harbor_project_id: 4, + pull_count: 0, + update_time: "2022-03-13T09:36:43.240Z".to_datetime, + location: "https://demo.goharbor.io/harbor/projects/4/repositories/busybox" + }) + end + end + + context 'with data that may contain path traversal attacks' do + before do + repo["project_id"] = './../../../../../etc/hosts' + end + + it 'returns empty location' do + expect(subject).to include({ + artifact_count: 1, + creation_time: "2022-03-13T09:36:43.240Z".to_datetime, + harbor_id: 1, + name: "jihuprivate/busybox", + harbor_project_id: './../../../../../etc/hosts', + pull_count: 0, + update_time: "2022-03-13T09:36:43.240Z".to_datetime, + location: "https://demo.goharbor.io/" + }) + end + end +end diff --git a/spec/serializers/integrations/harbor_serializers/repository_serializer_spec.rb b/spec/serializers/integrations/harbor_serializers/repository_serializer_spec.rb new file mode 100644 index 00000000000..1a4235bea1e --- /dev/null +++ b/spec/serializers/integrations/harbor_serializers/repository_serializer_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::HarborSerializers::RepositorySerializer do + it 'represents Integrations::HarborSerializers::RepositoryEntity entities' do + expect(described_class.entity_class).to eq(Integrations::HarborSerializers::RepositoryEntity) + end +end diff --git a/spec/serializers/integrations/harbor_serializers/tag_entity_spec.rb b/spec/serializers/integrations/harbor_serializers/tag_entity_spec.rb new file mode 100644 index 00000000000..f4bc5f71d5b --- /dev/null +++ b/spec/serializers/integrations/harbor_serializers/tag_entity_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::HarborSerializers::TagEntity do + let_it_be(:harbor_integration) { create(:harbor_integration) } + + let(:push_time) { "2022-03-22T09:04:56.186Z" } + let(:pull_time) { "2022-03-23T09:04:56.186Z" } + + let(:tag) do + { + "artifact_id": 5, + "id": 6, + "immutable": false, + "name": "1", + "push_time": push_time, + "pull_time": pull_time, + "repository_id": 5, + "signed": false + }.deep_stringify_keys + end + + subject { described_class.new(tag).as_json } + + it 'returns the Harbor artifact' do + expect(subject).to include({ + harbor_repository_id: 5, + harbor_artifact_id: 5, + harbor_id: 6, + name: "1", + pull_time: pull_time.to_datetime.utc, + push_time: push_time.to_datetime.utc, + signed: false, + immutable: false + }) + end +end diff --git a/spec/serializers/integrations/harbor_serializers/tag_serializer_spec.rb b/spec/serializers/integrations/harbor_serializers/tag_serializer_spec.rb new file mode 100644 index 00000000000..45fee0b9b17 --- /dev/null +++ b/spec/serializers/integrations/harbor_serializers/tag_serializer_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::HarborSerializers::TagSerializer do + it 'represents Integrations::HarborSerializers::TagEntity entities' do + expect(described_class.entity_class).to eq(Integrations::HarborSerializers::TagEntity) + end +end diff --git a/spec/support/helpers/harbor_helper.rb b/spec/support/helpers/harbor_helper.rb new file mode 100644 index 00000000000..3f13710ede6 --- /dev/null +++ b/spec/support/helpers/harbor_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module HarborHelper + def harbor_repository_url(container, *args) + if container.is_a?(Project) + project_harbor_repositories_path(container, *args) + else + group_harbor_repositories_path(container, *args) + end + end + + def harbor_artifact_url(container, *args) + if container.is_a?(Project) + project_harbor_repository_artifacts_path(container, *args) + else + group_harbor_repository_artifacts_path(container, *args) + end + end + + def harbor_tag_url(container, *args) + if container.is_a?(Project) + project_harbor_repository_artifact_tags_path(container, *args) + else + group_harbor_repository_artifact_tags_path(container, *args) + end + end +end diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index 483bca07ba6..eec6e92c5fe 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -31,6 +31,7 @@ RSpec.shared_context 'GroupPolicy context' do admin_milestone admin_issue_board read_container_image + read_harbor_registry read_metrics_dashboard_annotation read_prometheus read_crm_contact diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index 3bb05d0b6a6..789b385c435 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -30,7 +30,7 @@ RSpec.shared_context 'ProjectPolicy context' do create_snippet create_incident daily_statistics create_merge_request_in download_code download_wiki_code fork_project metrics_dashboard read_build read_commit_status read_confidential_issues read_container_image - read_deployment read_environment read_merge_request + read_harbor_registry read_deployment read_environment read_merge_request read_metrics_dashboard_annotation read_pipeline read_prometheus read_sentry_issue update_issue create_merge_request_in ] diff --git a/spec/support/shared_examples/harbor/artifacts_controller_shared_examples.rb b/spec/support/shared_examples/harbor/artifacts_controller_shared_examples.rb new file mode 100644 index 00000000000..85fcd426e3d --- /dev/null +++ b/spec/support/shared_examples/harbor/artifacts_controller_shared_examples.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a harbor artifacts controller' do |args| + include HarborHelper + let_it_be(:user) { create(:user) } + let_it_be(:unauthorized_user) { create(:user) } + let_it_be(:json_header) { { accept: 'application/json' } } + + let(:mock_artifacts) do + [ + { + "digest": "sha256:661e8e44e5d7290fbd42d0495ab4ff6fdf1ad251a9f358969b3264a22107c14d", + "icon": "sha256:0048162a053eef4d4ce3fe7518615bef084403614f8bca43b40ae2e762e11e06", + "id": 1, + "project_id": 1, + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-04-23T08:04:08.901Z", + "repository_id": 1, + "size": 126745886, + "tags": [ + { + "artifact_id": 1, + "id": 1, + "immutable": false, + "name": "2", + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-04-23T08:04:08.920Z", + "repository_id": 1, + "signed": false + } + ], + "type": "IMAGE" + } + ] + end + + let(:repository_id) { 'test' } + + shared_examples 'responds with 404 status' do + it 'returns 404' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + shared_examples 'responds with 200 status with json' do + it 'renders the index template' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).not_to render_template(:index) + end + end + + shared_examples 'responds with 302 status' do + it 'returns 302' do + subject + + expect(response).to redirect_to(new_user_session_path) + end + end + + shared_examples 'responds with 422 status with json' do + it 'returns 422' do + subject + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + end + end + + before do + stub_request(:get, + "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories/test/artifacts"\ + "?page=1&page_size=10&with_tag=true") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }).to_return(status: 200, body: mock_artifacts.to_json, headers: { "x-total-count": 2 }) + container.add_reporter(user) + sign_in(user) + end + + describe 'GET #index.json' do + subject do + get harbor_artifact_url(container, repository_id), headers: json_header + end + + context 'with harbor registry feature flag enabled' do + it_behaves_like 'responds with 200 status with json' + end + + context 'with harbor registry feature flag disabled' do + before do + stub_feature_flags(harbor_registry_integration: false) + end + + it_behaves_like 'responds with 404 status' + end + + context 'with anonymous user' do + before do + sign_out(user) + end + + it_behaves_like "responds with #{args[:anonymous_status_code]} status" + end + + context 'with unauthorized user' do + before do + sign_in(unauthorized_user) + end + + it_behaves_like 'responds with 404 status' + end + + context 'with valid params' do + context 'with valid repository' do + subject do + get harbor_artifact_url(container, repository_id), headers: json_header + end + + it_behaves_like 'responds with 200 status with json' + end + + context 'with valid page' do + subject do + get harbor_artifact_url(container, repository_id, page: '1'), headers: json_header + end + + it_behaves_like 'responds with 200 status with json' + end + + context 'with valid limit' do + subject do + get harbor_artifact_url(container, repository_id, limit: '10'), headers: json_header + end + + it_behaves_like 'responds with 200 status with json' + end + end + + context 'with invalid params' do + context 'with invalid page' do + subject do + get harbor_artifact_url(container, repository_id, page: 'aaa'), headers: json_header + end + + it_behaves_like 'responds with 422 status with json' + end + + context 'with invalid limit' do + subject do + get harbor_artifact_url(container, repository_id, limit: 'aaa'), headers: json_header + end + + it_behaves_like 'responds with 422 status with json' + end + end + end +end diff --git a/spec/support/shared_examples/harbor/container_shared_examples.rb b/spec/support/shared_examples/harbor/container_shared_examples.rb new file mode 100644 index 00000000000..57274e0b457 --- /dev/null +++ b/spec/support/shared_examples/harbor/container_shared_examples.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'raises NotImplementedError when calling #container' do + describe '#container' do + it 'raises NotImplementedError' do + expect { controller.send(:container) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/support/shared_examples/harbor/repositories_controller_shared_examples.rb b/spec/support/shared_examples/harbor/repositories_controller_shared_examples.rb new file mode 100644 index 00000000000..b35595a10b2 --- /dev/null +++ b/spec/support/shared_examples/harbor/repositories_controller_shared_examples.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a harbor repositories controller' do |args| + include HarborHelper + let_it_be(:user) { create(:user) } + let_it_be(:unauthorized_user) { create(:user) } + let_it_be(:json_header) { { accept: 'application/json' } } + + let(:mock_repositories) do + [ + { + "artifact_count": 6, + "creation_time": "2022-04-24T10:59:02.719Z", + "id": 33, + "name": "test/photon", + "project_id": 3, + "pull_count": 12, + "update_time": "2022-04-24T11:06:27.678Z" + }, + { + "artifact_count": 1, + "creation_time": "2022-04-23T08:04:08.880Z", + "id": 1, + "name": "test/gemnasium", + "project_id": 3, + "pull_count": 0, + "update_time": "2022-04-23T08:04:08.880Z" + } + ] + end + + shared_examples 'responds with 404 status' do + it 'returns 404' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + shared_examples 'responds with 200 status with html' do + it 'renders the index template' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:index) + end + end + + shared_examples 'responds with 302 status' do + it 'returns 302' do + subject + + expect(response).to redirect_to(new_user_session_path) + end + end + + shared_examples 'responds with 200 status with json' do + it 'renders the index template' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).not_to render_template(:index) + end + end + + shared_examples 'responds with 422 status with json' do + it 'returns 422' do + subject + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + end + end + + before do + stub_request(:get, "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories?page=1&page_size=10") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }).to_return(status: 200, body: mock_repositories.to_json, headers: { "x-total-count": 2 }) + container.add_reporter(user) + sign_in(user) + end + + describe 'GET #index.html' do + subject do + get harbor_repository_url(container) + end + + context 'with harbor registry feature flag enabled' do + it_behaves_like 'responds with 200 status with html' + end + + context 'with harbor registry feature flag disabled' do + before do + stub_feature_flags(harbor_registry_integration: false) + end + + it_behaves_like 'responds with 404 status' + end + + context 'with anonymous user' do + before do + sign_out(user) + end + + it_behaves_like "responds with #{args[:anonymous_status_code]} status" + end + + context 'with unauthorized user' do + before do + sign_in(unauthorized_user) + end + + it_behaves_like 'responds with 404 status' + end + end + + describe 'GET #index.json' do + subject do + get harbor_repository_url(container), headers: json_header + end + + context 'with harbor registry feature flag enabled' do + it_behaves_like 'responds with 200 status with json' + end + + context 'with harbor registry feature flag disabled' do + before do + stub_feature_flags(harbor_registry_integration: false) + end + + it_behaves_like 'responds with 404 status' + end + + context 'with valid params' do + context 'with valid page params' do + subject do + get harbor_repository_url(container, page: '1'), headers: json_header + end + + it_behaves_like 'responds with 200 status with json' + end + + context 'with valid limit params' do + subject do + get harbor_repository_url(container, limit: '10'), headers: json_header + end + + it_behaves_like 'responds with 200 status with json' + end + end + + context 'with invalid params' do + context 'with invalid page params' do + subject do + get harbor_repository_url(container, page: 'aaa'), headers: json_header + end + + it_behaves_like 'responds with 422 status with json' + end + + context 'with invalid limit params' do + subject do + get harbor_repository_url(container, limit: 'aaa'), headers: json_header + end + + it_behaves_like 'responds with 422 status with json' + end + end + end +end diff --git a/spec/support/shared_examples/harbor/tags_controller_shared_examples.rb b/spec/support/shared_examples/harbor/tags_controller_shared_examples.rb new file mode 100644 index 00000000000..46fea7fdff6 --- /dev/null +++ b/spec/support/shared_examples/harbor/tags_controller_shared_examples.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a harbor tags controller' do |args| + include HarborHelper + let_it_be(:user) { create(:user) } + let_it_be(:unauthorized_user) { create(:user) } + let_it_be(:json_header) { { accept: 'application/json' } } + + let(:mock_artifacts) do + [ + { + "artifact_id": 1, + "id": 1, + "immutable": false, + "name": "2", + "pull_time": "0001-01-01T00:00:00.000Z", + "push_time": "2022-04-23T08:04:08.920Z", + "repository_id": 1, + "signed": false + } + ] + end + + let(:repository_id) { 'test' } + let(:artifact_id) { '1' } + + shared_examples 'responds with 404 status' do + it 'returns 404' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + shared_examples 'responds with 200 status with json' do + it 'renders the index template' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).not_to render_template(:index) + end + end + + shared_examples 'responds with 302 status' do + it 'returns 302' do + subject + + expect(response).to redirect_to(new_user_session_path) + end + end + + shared_examples 'responds with 422 status with json' do + it 'returns 422' do + subject + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + end + end + + before do + stub_request(:get, + "https://demo.goharbor.io/api/v2.0/projects/testproject/repositories/test/artifacts/1/tags"\ + "?page=1&page_size=10") + .with( + headers: { + 'Authorization': 'Basic aGFyYm9ydXNlcm5hbWU6aGFyYm9ycGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }).to_return(status: 200, body: mock_artifacts.to_json, headers: { "x-total-count": 2 }) + container.add_reporter(user) + sign_in(user) + end + + describe 'GET #index.json' do + subject do + get(harbor_tag_url(container, repository_id, artifact_id), + headers: json_header) + end + + context 'with harbor registry feature flag enabled' do + it_behaves_like 'responds with 200 status with json' + end + + context 'with harbor registry feature flag disabled' do + before do + stub_feature_flags(harbor_registry_integration: false) + end + + it_behaves_like 'responds with 404 status' + end + + context 'with anonymous user' do + before do + sign_out(user) + end + + it_behaves_like "responds with #{args[:anonymous_status_code]} status" + end + + context 'with unauthorized user' do + before do + sign_in(unauthorized_user) + end + + it_behaves_like 'responds with 404 status' + end + + context 'with valid params' do + context 'with valid repository' do + subject do + get harbor_tag_url(container, repository_id, artifact_id), headers: json_header + end + + it_behaves_like 'responds with 200 status with json' + end + + context 'with valid page' do + subject do + get(harbor_tag_url(container, repository_id, artifact_id, page: '1'), + headers: json_header) + end + + it_behaves_like 'responds with 200 status with json' + end + + context 'with valid limit' do + subject do + get(harbor_tag_url(container, repository_id, artifact_id, limit: '10'), + headers: json_header) + end + + it_behaves_like 'responds with 200 status with json' + end + end + + context 'with invalid params' do + context 'with invalid page' do + subject do + get(harbor_tag_url(container, repository_id, artifact_id, page: 'aaa'), + headers: json_header) + end + + it_behaves_like 'responds with 422 status with json' + end + + context 'with invalid limit' do + subject do + get(harbor_tag_url(container, repository_id, artifact_id, limit: 'aaa'), + headers: json_header) + end + + it_behaves_like 'responds with 422 status with json' + end + end + end +end |