diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 23095d2cb10ab8b1eb532ec817a3a4838b10667d..c904798883ce8db57c8f8c90d5528b8ea61a2426 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -195,7 +195,7 @@ = _('Charts') - if project_nav_tab? :operations - = nav_link(controller: [:environments, :clusters, :user, :gcp]) do + = nav_link(controller: [:environments, :clusters, :user, :gcp, :feature_flags]) do = link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do .nav-icon-container = sprite_icon('cloud-gear') @@ -203,7 +203,7 @@ = _('Operations') %ul.sidebar-sub-level-items - = nav_link(controller: [:environments, :clusters, :user, :gcp], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:environments, :clusters, :user, :gcp, :feature_flags], html_options: { class: "fly-out-top-item" } ) do = link_to metrics_project_environments_path(@project) do %strong.fly-out-top-item-name = _('Operations') @@ -249,6 +249,12 @@ %span= _("Got it!") = sprite_icon('thumb-up') + - if project_nav_tab? :feature_flags + = nav_link(controller: :feature_flags) do + = link_to project_feature_flags_path(@project), title: _('Feature Flags'), class: 'shortcuts-feature-flags' do + %span + = _('Feature Flags') + - if project_nav_tab? :container_registry = nav_link(controller: %w[projects/registry/repositories]) do = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do diff --git a/config/routes/project.rb b/config/routes/project.rb index c0c1775f8d37d57bdae1eab35c502f364274d8c8..7eff9401e5c2000bafd70a5653a09f9aa156ca3f 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -351,6 +351,10 @@ namespace :ci do resource :lint, only: [:show, :create] end + + ## EE-specific + resources :feature_flags + ## EE-specific end draw :legacy_builds diff --git a/db/schema.rb b/db/schema.rb index 3516fcf53c5d5a491bc18be33dde090d1273df08..b4ca69f5448279f2be1debcf1d3e0ce325a822fc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1943,6 +1943,24 @@ t.string "nonce", null: false end + create_table "operations_feature_flags", id: :bigserial, force: :cascade do |t| + t.integer "project_id", null: false + t.boolean "active", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.string "name", null: false + t.text "description" + end + + add_index "operations_feature_flags", ["project_id", "name"], name: "index_operations_feature_flags_on_project_id_and_name", unique: true, using: :btree + + create_table "operations_feature_flags_clients", id: :bigserial, force: :cascade do |t| + t.integer "project_id", null: false + t.string "token", null: false + end + + add_index "operations_feature_flags_clients", ["project_id", "token"], name: "index_operations_feature_flags_clients_on_project_id_and_token", unique: true, using: :btree + create_table "packages_maven_metadata", id: :bigserial, force: :cascade do |t| t.integer "package_id", limit: 8, null: false t.datetime_with_timezone "created_at", null: false @@ -3233,6 +3251,8 @@ add_foreign_key "notes", "projects", name: "fk_99e097b079", on_delete: :cascade add_foreign_key "notification_settings", "users", name: "fk_0c95e91db7", on_delete: :cascade add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", name: "fk_oauth_openid_requests_oauth_access_grants_access_grant_id" + add_foreign_key "operations_feature_flags", "projects", on_delete: :cascade + add_foreign_key "operations_feature_flags_clients", "projects", on_delete: :cascade add_foreign_key "packages_maven_metadata", "packages_packages", column: "package_id", name: "fk_be88aed360", on_delete: :cascade add_foreign_key "packages_package_files", "packages_packages", column: "package_id", name: "fk_86f0f182f8", on_delete: :cascade add_foreign_key "packages_packages", "projects", on_delete: :cascade diff --git a/doc/user/project/operations/feature_flags.md b/doc/user/project/operations/feature_flags.md new file mode 100644 index 0000000000000000000000000000000000000000..1dbf485fea8af6ddac8f62090927059056a92650 --- /dev/null +++ b/doc/user/project/operations/feature_flags.md @@ -0,0 +1,3 @@ +# Feature Flags + +## Client libraries diff --git a/ee/app/controllers/projects/feature_flags_controller.rb b/ee/app/controllers/projects/feature_flags_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..e12e513af14d6751f353361be2ccf9afe246c40b --- /dev/null +++ b/ee/app/controllers/projects/feature_flags_controller.rb @@ -0,0 +1,64 @@ +class Projects::FeatureFlagsController < Projects::ApplicationController + respond_to :html + + before_action :authorize_read_feature_flag! + before_action :authorize_update_feature_flag!, only: [:edit, :update] + before_action :authorize_destroy_feature_flag!, only: [:destroy] + + before_action :feature_flag, only: [:edit, :update, :destroy] + + def index + @feature_flags = project.operations_feature_flags + .ordered + .page(params[:page]).per(30) + end + + def new + @feature_flag = project.operations_feature_flags.new + end + + def create + @feature_flag = project.operations_feature_flags.create(create_params) + + if @feature_flag.persisted? + redirect_to project_feature_flags_path(@project), status: 302, notice: 'Feature flag was successfully created.' + else + render :new + end + end + + def edit + end + + def update + if feature_flag.update(update_params) + redirect_to project_feature_flags_path(@project), status: 302, notice: 'Feature flag was successfully updated.' + else + render :edit + end + end + + def destroy + if feature_flag.destroy + redirect_to project_feature_flags_path(@project), status: 302, notice: 'Feature flag was successfully removed.' + else + redirect_to project_feature_flags_path(@project), status: 302, alert: 'Feature flag was not removed.' + end + end + + protected + + def feature_flag + @feature_flag ||= project.operations_feature_flags.find(params[:id]) + end + + def create_params + params.require(:operations_feature_flag) + .permit(:name, :description, :active) + end + + def update_params + params.require(:operations_feature_flag) + .permit(:name, :description, :active) + end +end diff --git a/ee/app/helpers/ee/projects_helper.rb b/ee/app/helpers/ee/projects_helper.rb index 3986cd93ec1d4dec13e0b9d1a6db76a5d264e685..634f3b7db71f801a8a7312e66c84f7c703456806 100644 --- a/ee/app/helpers/ee/projects_helper.rb +++ b/ee/app/helpers/ee/projects_helper.rb @@ -25,9 +25,20 @@ def get_project_nav_tabs(project, current_user) nav_tabs << :packages end + if can?(current_user, :read_feature_flag, project) && !nav_tabs.include?(:operations) + nav_tabs << :operations + end + nav_tabs end + override :tab_ability_map + def tab_ability_map + tab_ability_map = super + tab_ability_map[:feature_flags] = :read_feature_flag + tab_ability_map + end + override :project_permissions_settings def project_permissions_settings(project) super.merge( diff --git a/ee/app/helpers/feature_flags_helper.rb b/ee/app/helpers/feature_flags_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..fa21ecf684fd681dd63a298b8fc5b96b15094eaf --- /dev/null +++ b/ee/app/helpers/feature_flags_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module FeatureFlagsHelper + include ::API::Helpers::RelatedResourcesHelpers + + def unleash_api_url(project) + expose_url(api_v4_feature_flags_unleash_path(project_id: project.id)) + end + + def unleash_api_instance_id(project) + project.feature_flags_client_token + end +end diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index 394cd732fbbfacdcf67a99531f309a10e41ef249..1a3dacda658ff423a54c63c191e6c627b27d26e5 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -50,6 +50,9 @@ module Project has_many :prometheus_alerts, inverse_of: :project has_many :prometheus_alert_events, inverse_of: :project + has_many :operations_feature_flags, class_name: 'Operations::FeatureFlag' + has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient' + scope :with_shared_runners_limit_enabled, -> { with_shared_runners.non_public_only } scope :mirror, -> { where(mirror: true) } @@ -559,6 +562,11 @@ def update_root_ref(remote_name) change_head(root_ref) if root_ref.present? && root_ref != default_branch end + def feature_flags_client_token + instance = operations_feature_flags_client || create_operations_feature_flags_client! + instance.token + end + private def set_override_pull_mirror_available diff --git a/ee/app/models/license.rb b/ee/app/models/license.rb index 78aa3b0b340a0ec0b79b4a923cf19963c4a238bd..ca349a535f90df9862cabde4fc2b409d9d272290 100644 --- a/ee/app/models/license.rb +++ b/ee/app/models/license.rb @@ -67,6 +67,7 @@ class License < ActiveRecord::Base custom_project_templates packages code_owner_as_approver_suggestion + feature_flags ].freeze EEU_FEATURES = EEP_FEATURES + %i[ diff --git a/ee/app/models/operations/feature_flag.rb b/ee/app/models/operations/feature_flag.rb new file mode 100644 index 0000000000000000000000000000000000000000..9980dee548041d01d5d286843f48863c28fbdefd --- /dev/null +++ b/ee/app/models/operations/feature_flag.rb @@ -0,0 +1,20 @@ +module Operations + class FeatureFlag < ActiveRecord::Base + self.table_name = 'operations_feature_flags' + + belongs_to :project + + validates :project, presence: true + validates :name, + presence: true, + length: 2..63, + format: { + with: Gitlab::Regex.feature_flag_regex, + message: Gitlab::Regex.feature_flag_regex_message + } + validates :name, uniqueness: { scope: :project_id } + validates :description, allow_blank: true, length: 0..255 + + scope :ordered, -> { order(:name) } + end +end diff --git a/ee/app/models/operations/feature_flags_client.rb b/ee/app/models/operations/feature_flags_client.rb new file mode 100644 index 0000000000000000000000000000000000000000..75da25daafd75ba13189c483ff9a27632cfddb09 --- /dev/null +++ b/ee/app/models/operations/feature_flags_client.rb @@ -0,0 +1,23 @@ +module Operations + class FeatureFlagsClient < ActiveRecord::Base + include TokenAuthenticatable + + self.table_name = 'operations_feature_flags_clients' + + belongs_to :project + + validates :project, presence: true + validates :token, presence: true + + add_authentication_token_field :token + + before_validation :ensure_token! + + def self.find_for_project_and_token(project, token) + return unless project + return unless token + + find_by(token: token, project: project) + end + end +end diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index 1a0040876332b44b6c82c29213205c28b9c85fe1..d00084ca27688c2fcb28ef23d0b1d574dd48e880 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -8,6 +8,7 @@ module ProjectPolicy approvers vulnerability_feedback license_management + feature_flag ].freeze prepended do @@ -65,6 +66,11 @@ module ProjectPolicy @subject.feature_available?(:license_management) end + with_scope :subject + condition(:feature_flags_disabled) do + !@subject.feature_available?(:feature_flags) + end + rule { admin }.enable :change_repository_storage rule { support_bot }.enable :guest_access @@ -99,6 +105,11 @@ module ProjectPolicy enable :admin_board enable :admin_vulnerability_feedback enable :create_package + enable :read_feature_flag + enable :create_feature_flag + enable :update_feature_flag + enable :destroy_feature_flag + enable :admin_feature_flag end rule { can?(:public_access) }.enable :read_package @@ -117,6 +128,10 @@ module ProjectPolicy prevent(*create_read_update_admin_destroy(:package)) end + rule { feature_flags_disabled }.policy do + prevent(*create_read_update_admin_destroy(:feature_flag)) + end + rule { can?(:maintainer_access) }.policy do enable :push_code_to_protected_branches enable :admin_path_locks diff --git a/ee/app/views/projects/feature_flags/_configure_feature_flags_button.html.haml b/ee/app/views/projects/feature_flags/_configure_feature_flags_button.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..dde8aca6383800d985f7a1de4bd190a50b8a850b --- /dev/null +++ b/ee/app/views/projects/feature_flags/_configure_feature_flags_button.html.haml @@ -0,0 +1,3 @@ +- if can?(current_user, :admin_feature_flag, @project) + %button.btn.btn-primary.btn-inverted.append-right-8{ type: 'button', data: { toggle: 'modal', target: '#configure-feature-flags-modal' } }> + = s_('FeatureFlags|Configure') diff --git a/ee/app/views/projects/feature_flags/_configure_feature_flags_modal.html.haml b/ee/app/views/projects/feature_flags/_configure_feature_flags_modal.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..0ba79fc8e56e1b83db7d077feb73a8593296c4df --- /dev/null +++ b/ee/app/views/projects/feature_flags/_configure_feature_flags_modal.html.haml @@ -0,0 +1,58 @@ +- if can?(current_user, :admin_feature_flag, @project) + #configure-feature-flags-modal.modal{ tabindex: -1, + role: 'dialog' } + .modal-dialog{ role: 'document' } + .modal-content + .modal-header + %h4.modal-title + = s_('FeatureFlags|Configure feature flags') + %button.close{ type: 'button', data: { dismiss: 'modal' }, aria: { label: _('Close') } } + %span{ "aria-hidden": true } × + .modal-body + %p + - client_libraries_url = help_page_path("user/project/operations/feature_flags", anchor: "client-libraries") + = s_('FeatureFlags|Install a %{docs_link_start}compatible client library%{docs_link_end} and specify the API URL, application name, and instance ID during the configuration setup.').html_safe % { docs_link_start: %Q{}.html_safe, + docs_link_end: ''.html_safe } + = link_to s_('FeatureFlags|More information'), help_page_path("user/project/operations/feature_flags") + + .form-group + = label_tag :api_url, s_('FeatureFlags|API URL'), class: 'label-bold' + .input-group + = text_field_tag :api_url, + unleash_api_url(@project), + readonly: true, + class: "form-control js-select-on-focus" + %span.input-group-append + = clipboard_button(target: '#api_url', + title: _("Copy URL to clipboard"), + placement: "left", + container: '#configure-feature-flags-modal', + class: "input-group-text btn btn-default") + + .form-group + = label_tag :instance_id, s_('FeatureFlags|Instance ID'), class: 'label-bold' + .input-group + = text_field_tag :instance_id, + unleash_api_instance_id(@project), + readonly: true, + class: "form-control js-select-on-focus" + %span.input-group-append + = clipboard_button(target: '#instance_id', + title: _("Copy ID to clipboard"), + placement: "left", + container: '#configure-feature-flags-modal', + class: "input-group-text btn btn-default") + + .form-group + = label_tag :application_name, s_('FeatureFlags|Application name'), class: 'label-bold' + .input-group + = text_field_tag :application_name, + "production", + readonly: true, + class: "form-control js-select-on-focus" + %span.input-group-append + = clipboard_button(target: '#application_name', + title: _("Copy name to clipboard"), + placement: "left", + container: '#configure-feature-flags-modal', + class: "input-group-text btn btn-default") diff --git a/ee/app/views/projects/feature_flags/_delete_feature_flag_modal.html.haml b/ee/app/views/projects/feature_flags/_delete_feature_flag_modal.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..5920570d52a4563deb91473f31e4669b018702aa --- /dev/null +++ b/ee/app/views/projects/feature_flags/_delete_feature_flag_modal.html.haml @@ -0,0 +1,26 @@ +- if can?(current_user, :destroy_feature_flag, @project) + .modal{ id: "delete-feature-flag-modal-#{feature_flag.id}", + tabindex: -1, + role: 'dialog' } + .modal-dialog{ role: 'document' } + .modal-content + .modal-header + %h4.modal-title.d-flex.mw-100 + - truncated_feature_flag_name = capture do + %span.text-truncate.prepend-left-4.append-right-4= feature_flag.name + = s_('FeatureFlags|Delete %{feature_flag_name}?').html_safe % { feature_flag_name: truncated_feature_flag_name } + %button.close{ type: 'button', data: { dismiss: 'modal' }, aria: { label: _('Close') } } + %span{ "aria-hidden": true } × + .modal-body + %p + - monospace_feature_flag_name = capture do + %span.text-monospace= feature_flag.name + = s_('FeatureFlags|Feature flag %{feature_flag_name} will be removed. Are you sure?').html_safe % { feature_flag_name: monospace_feature_flag_name } + .modal-footer + %button{ type: 'button', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel + + = button_to 'Delete', + project_feature_flag_path(@project, feature_flag), + title: 'Delete', + method: :delete, + class: 'btn btn-remove' diff --git a/ee/app/views/projects/feature_flags/_empty_state.html.haml b/ee/app/views/projects/feature_flags/_empty_state.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..ed8adf09cddba86aefe892f8b2659ec6fd7287a1 --- /dev/null +++ b/ee/app/views/projects/feature_flags/_empty_state.html.haml @@ -0,0 +1,15 @@ +.border-top + .row.empty-state + .col-12 + .svg-content + = image_tag 'illustrations/feature_flag.svg' + + .col-12 + .text-content + %h4.text-center= s_('FeatureFlags|Get started with feature flags') + %p + = s_('FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.') + = link_to 'More information', help_page_path("user/project/operations/feature_flags") + + = render 'new_feature_flag_button' + = render 'configure_feature_flags_button' diff --git a/ee/app/views/projects/feature_flags/_errors.html.haml b/ee/app/views/projects/feature_flags/_errors.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..a32245640be2c21c59ad9e3e79f622e410d6ed04 --- /dev/null +++ b/ee/app/views/projects/feature_flags/_errors.html.haml @@ -0,0 +1,4 @@ +#error_explanation + .alert.alert-danger + - @feature_flag.errors.full_messages.each do |message| + %p= message diff --git a/ee/app/views/projects/feature_flags/_form.html.haml b/ee/app/views/projects/feature_flags/_form.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..497ae478e08f9a1b8493ee1497b758d7a225d32b --- /dev/null +++ b/ee/app/views/projects/feature_flags/_form.html.haml @@ -0,0 +1,29 @@ +- if @feature_flag.errors.any? + = render 'errors' + +%fieldset + .row + .form-group.col-md-4 + = f.label :name, class: 'label-bold', for: 'feature_flag_name' do + = s_('FeatureFlags|Name') + = f.text_field :name, class: "form-control", id: "feature_flag_name" + .row + .form-group.col-md-4 + = f.label :description, class: 'label-bold', for: 'feature_flag_description' do + = s_('FeatureFlags|Description') + = f.text_area :description, class: "form-control", id: "feature_flag_description", rows: 4 + .row + .form-group.col-md-1 + = f.label :active, class: 'label-bold' do + = s_('FeatureFlags|Status') + .form-check + = f.check_box :active, id: 'feature_flag_status', class: 'form-check-input' + = f.label :active, for: 'feature_flag_status', class: 'form-check-label' do + = s_('FeatureFlags|Active') +.form-actions + - if @feature_flag.persisted? + = f.submit s_('FeatureFlags|Save changes'), class: "btn btn-success" + - else + = f.submit s_('FeatureFlags|Create feature flag'), class: "btn btn-success" + .float-right + = link_to _('Cancel'), project_feature_flags_path(@project), class: 'btn btn-cancel' diff --git a/ee/app/views/projects/feature_flags/_new_feature_flag_button.html.haml b/ee/app/views/projects/feature_flags/_new_feature_flag_button.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..fe7e8f044768f2df24b8b6b644e4dba0f1c277c6 --- /dev/null +++ b/ee/app/views/projects/feature_flags/_new_feature_flag_button.html.haml @@ -0,0 +1,3 @@ +- if can?(current_user, :create_feature_flag, @project) + = link_to new_project_feature_flag_path(@project), class: 'btn btn-success' do + = s_('FeatureFlags|New Feature Flag') diff --git a/ee/app/views/projects/feature_flags/_table.html.haml b/ee/app/views/projects/feature_flags/_table.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..ccd7ac967b8086205b766da6968e5fdc623a0ce4 --- /dev/null +++ b/ee/app/views/projects/feature_flags/_table.html.haml @@ -0,0 +1,41 @@ +.table-holder.border-top + .gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-10{ role: 'columnheader' }= s_('FeatureFlags|Status') + .table-section.section-50{ role: 'columnheader' }= s_('FeatureFlags|Feature flag') + - @feature_flags.each do |feature_flag| + = render 'delete_feature_flag_modal', { feature_flag: feature_flag } + + .gl-responsive-table-row{ role: 'row' } + .table-section.section-10{ role: 'gridcell' } + .table-mobile-header{ role: "rowheader" }= s_('FeatureFlags|Status') + .table-mobile-content + - if feature_flag.active? + %span.badge.badge-success + = s_('FeatureFlags|Active') + - else + %span.badge.badge-danger + = s_('FeatureFlags|Inactive') + + .table-section.section-50{ role: 'gridcell' } + .table-mobile-header{ role: "rowheader" }= s_('FeatureFlags|Feature Flag') + .table-mobile-content.d-flex.flex-column + .text-monospace.text-truncate= feature_flag.name + .text-secondary.text-truncate= feature_flag.description + + .table-section.section-40.table-button-footer{ role: 'gridcell' } + .table-action-buttons.btn-group + - if can?(current_user, :update_feature_flag, @project) + = link_to edit_project_feature_flag_path(@project, feature_flag), + class: 'btn btn-default has-tooltip', + type: 'button', + title: _('Edit') do + = sprite_icon('pencil', size: 16) + + - if can?(current_user, :destroy_feature_flag, @project) + %button.btn.btn-danger.has-tooltip{ type: 'button', + data: { toggle: 'modal', + target: "#delete-feature-flag-modal-#{feature_flag.id}" }, + title: _('Delete') } + = sprite_icon('remove', size: 16) + += paginate @feature_flags, theme: "gitlab" diff --git a/ee/app/views/projects/feature_flags/edit.html.haml b/ee/app/views/projects/feature_flags/edit.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..166f59bb92010c8a940b1e53075a4e7b6315eaaf --- /dev/null +++ b/ee/app/views/projects/feature_flags/edit.html.haml @@ -0,0 +1,9 @@ +- add_to_breadcrumbs "Feature Flags", project_feature_flags_path(@project) +- breadcrumb_title @feature_flag.name +- page_title s_('FeatureFlags|Edit Feature Flag') +%h3.page-title + = s_('FeatureFlags|Edit %{feature_flag_name}') % { feature_flag_name: @feature_flag.name } +%hr.clearfix +%div + = form_for [@project, @feature_flag], url: project_feature_flag_path(@project, @feature_flag), html: { class: 'fieldset-form' } do |f| + = render 'form', { f: f } diff --git a/ee/app/views/projects/feature_flags/index.html.haml b/ee/app/views/projects/feature_flags/index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..bcdc6ac57fa6cf1c05a86359611ecb31c3ebf71d --- /dev/null +++ b/ee/app/views/projects/feature_flags/index.html.haml @@ -0,0 +1,15 @@ +- page_title _('Feature Flags') + += render 'configure_feature_flags_modal' + +- if @feature_flags.empty? + = render 'empty_state' +- else + %h3.page-title.with-button + = _('Feature Flags') + + .pull-right + = render 'configure_feature_flags_button' + = render 'new_feature_flag_button' + + = render 'table' diff --git a/ee/app/views/projects/feature_flags/new.html.haml b/ee/app/views/projects/feature_flags/new.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..7ca3340073db8268eb1c06d962e42cf358e00860 --- /dev/null +++ b/ee/app/views/projects/feature_flags/new.html.haml @@ -0,0 +1,10 @@ +- @breadcrumb_link = new_project_feature_flag_path(@project) +- add_to_breadcrumbs "Feature Flags", project_feature_flags_path(@project) +- breadcrumb_title s_('FeatureFlags|New') +- page_title s_('FeatureFlags|New Feature Flag') +%h3.page-title + = s_('FeatureFlags|New Feature Flag') +%hr.clearfix +%div + = form_for [@project, @feature_flag], url: project_feature_flags_path(@project), html: { class: 'fieldset-form' } do |f| + = render 'form', { f: f } diff --git a/ee/changelogs/unreleased/feature-flags-mvc-ee.yml b/ee/changelogs/unreleased/feature-flags-mvc-ee.yml new file mode 100644 index 0000000000000000000000000000000000000000..9e675bf50d41c46872288446808cc52600514c33 --- /dev/null +++ b/ee/changelogs/unreleased/feature-flags-mvc-ee.yml @@ -0,0 +1,5 @@ +--- +title: Add Feature Flags MVC +merge_request: 7433 +author: +type: added diff --git a/ee/db/migrate/20180626171125_add_feature_flags_to_projects.rb b/ee/db/migrate/20180626171125_add_feature_flags_to_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..c2ade6cc41311743119e478cf3ee1c9d160f9df9 --- /dev/null +++ b/ee/db/migrate/20180626171125_add_feature_flags_to_projects.rb @@ -0,0 +1,32 @@ +class AddFeatureFlagsToProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + create_table :operations_feature_flags, id: :bigserial do |t| + t.integer :project_id, null: false + t.boolean :active, null: false + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + + t.string :name, null: false + t.text :description + + t.foreign_key :projects, column: :project_id, on_delete: :cascade + + t.index [:project_id, :name], unique: true + end + + create_table :operations_feature_flags_clients, id: :bigserial do |t| + t.integer :project_id, null: false + t.string :token, null: false + + t.index [:project_id, :token], unique: true + + t.foreign_key :projects, column: :project_id, on_delete: :cascade + end + end +end diff --git a/ee/lib/api/unleash.rb b/ee/lib/api/unleash.rb new file mode 100644 index 0000000000000000000000000000000000000000..b70e3a75738a795f9fb1b3a2cc64a1c08bc6c7c4 --- /dev/null +++ b/ee/lib/api/unleash.rb @@ -0,0 +1,58 @@ +module API + class Unleash < Grape::API + include PaginationParams + + namespace :feature_flags do + resource :unleash, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + params do + requires :project_id, type: String, desc: 'The ID of a project' + optional :instance_id, type: String, desc: 'The Instance ID of Unleash Client' + end + route_param :project_id do + before do + authorize_by_unleash_instance_id! + authorize_feature_flags_feature! + end + + get do + # not supported yet + status :ok + end + + get 'features' do + present project, with: ::EE::API::Entities::UnleashFeatures + end + + post 'client/register' do + # not supported yet + status :ok + end + + post 'client/metrics' do + # not supported yet + status :ok + end + end + end + end + + helpers do + def project + @project ||= find_project(params[:project_id]) + end + + def unleash_instance_id + params[:instance_id] || env['HTTP_UNLEASH_INSTANCEID'] + end + + def authorize_by_unleash_instance_id! + unauthorized! unless Operations::FeatureFlagsClient + .find_for_project_and_token(project, unleash_instance_id) + end + + def authorize_feature_flags_feature! + forbidden! unless project.feature_available?(:feature_flags) + end + end + end +end diff --git a/ee/lib/ee/api/entities.rb b/ee/lib/ee/api/entities.rb index 4606dc40e1b0d7cef4f674add18c7abd4ca2aa6c..917d90544f9e193db03ff9032cfcbbeb74571157 100644 --- a/ee/lib/ee/api/entities.rb +++ b/ee/lib/ee/api/entities.rb @@ -427,6 +427,27 @@ def missing_oauth_application object.geo_node.missing_oauth_application? end end + + class UnleashFeature < Grape::Entity + expose :name + expose :description, unless: ->(feature) { feature.description.nil? } + expose :active, as: :enabled + end + + class UnleashFeatures < Grape::Entity + expose :version + expose :features, with: UnleashFeature + + private + + def version + 1 + end + + def features + object.operations_feature_flags.ordered + end + end end end end diff --git a/ee/lib/ee/gitlab/regex.rb b/ee/lib/ee/gitlab/regex.rb index 82e081bfac44d0cede5648fa456e055264c9f7aa..37c3e8aaa9b256a5f456f3f92a3424611af13ec6 100644 --- a/ee/lib/ee/gitlab/regex.rb +++ b/ee/lib/ee/gitlab/regex.rb @@ -28,6 +28,15 @@ def maven_app_name_regex def maven_app_group_regex maven_app_name_regex end + + def feature_flag_regex + /\A[a-z]([-_a-z0-9]*[a-z0-9])?\z/ + end + + def feature_flag_regex_message + "can contain only lowercase letters, digits, '_' and '-'. " \ + "Must start with a letter, and cannot end with '-' or '_'" + end end end end diff --git a/ee/spec/controllers/projects/feature_flags_controller_spec.rb b/ee/spec/controllers/projects/feature_flags_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8944f83594cd7a1d9fc6221fc44a72d53df84f35 --- /dev/null +++ b/ee/spec/controllers/projects/feature_flags_controller_spec.rb @@ -0,0 +1,153 @@ +require 'spec_helper' + +describe Projects::FeatureFlagsController do + include Gitlab::Routing + + set(:user) { create(:user) } + set(:project) { create(:project) } + let(:feature_enabled) { true } + + before do + project.add_developer(user) + + sign_in(user) + stub_licensed_features(feature_flags: feature_enabled) + end + + describe 'GET index' do + render_views + + subject { get(:index, view_params) } + + context 'when there is no feature flags' do + before do + subject + end + + it 'shows an empty state with buttons' do + expect(response).to be_ok + expect(response).to render_template('_empty_state') + expect(response).to render_template('_configure_feature_flags_button') + expect(response).to render_template('_new_feature_flag_button') + end + end + + context 'for a list of feature flags' do + let!(:feature_flags) { create_list(:operations_feature_flag, 50, project: project) } + + before do + subject + end + + it 'shows an list of feature flags with buttons' do + expect(response).to be_ok + expect(response).to render_template('_table') + expect(response).to render_template('_configure_feature_flags_button') + expect(response).to render_template('_new_feature_flag_button') + end + end + + context 'when feature is not available' do + let(:feature_enabled) { false } + + before do + subject + end + + it 'shows not found' do + expect(subject).to have_gitlab_http_status(404) + end + end + end + + describe 'GET new' do + render_views + + subject { get(:new, view_params) } + + it 'renders the form' do + subject + + expect(response).to be_ok + expect(response).to render_template('new') + expect(response).to render_template('_form') + end + end + + describe 'POST create' do + render_views + + subject { post(:create, params) } + + context 'when creating a new feature flag' do + let(:params) do + view_params.merge(operations_feature_flag: { name: 'my_feature_flag', active: true }) + end + + it 'creates and redirects to list' do + subject + + expect(response).to redirect_to(project_feature_flags_path(project)) + end + end + + context 'when a feature flag already exists' do + let!(:feature_flag) { create(:operations_feature_flag, project: project, name: 'my_feature_flag') } + + let(:params) do + view_params.merge(operations_feature_flag: { name: 'my_feature_flag', active: true }) + end + + it 'shows an error' do + subject + + expect(response).to render_template('new') + expect(response).to render_template('_errors') + end + end + end + + describe 'PUT update' do + let!(:feature_flag) { create(:operations_feature_flag, project: project, name: 'my_feature_flag') } + + render_views + + subject { post(:create, params) } + + context 'when updating an existing feature flag' do + let(:params) do + view_params.merge( + id: feature_flag.id, + operations_feature_flag: { name: 'my_feature_flag_v2', active: true } + ) + end + + it 'updates and redirects to list' do + subject + + expect(response).to redirect_to(project_feature_flags_path(project)) + end + end + + context 'when using existing name of the feature flag' do + let!(:other_feature_flag) { create(:operations_feature_flag, project: project, name: 'other_feature_flag') } + + let(:params) do + view_params.merge(operations_feature_flag: { name: 'other_feature_flag', active: true }) + end + + it 'shows an error' do + subject + + expect(response).to render_template('new') + expect(response).to render_template('_errors') + end + end + end + + private + + def view_params + { namespace_id: project.namespace, project_id: project } + end +end diff --git a/ee/spec/factories/operations/feature_flags.rb b/ee/spec/factories/operations/feature_flags.rb new file mode 100644 index 0000000000000000000000000000000000000000..7f5d722df37369e953711f8c6de8129984f99f18 --- /dev/null +++ b/ee/spec/factories/operations/feature_flags.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :operations_feature_flag, class: Operations::FeatureFlag do + sequence(:name) { |n| "feature_flag_#{n}" } + project + active true + end +end diff --git a/ee/spec/factories/operations/feature_flags_clients.rb b/ee/spec/factories/operations/feature_flags_clients.rb new file mode 100644 index 0000000000000000000000000000000000000000..46d2a074133e4ecf8b8326e9430570b655061542 --- /dev/null +++ b/ee/spec/factories/operations/feature_flags_clients.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :operations_feature_flags_client, class: Operations::FeatureFlagsClient do + project + end +end diff --git a/ee/spec/features/projects/feature_flags_spec.rb b/ee/spec/features/projects/feature_flags_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f261a0bf819e73eda6dd289d9e14bf6c93a2944c --- /dev/null +++ b/ee/spec/features/projects/feature_flags_spec.rb @@ -0,0 +1,184 @@ +require 'spec_helper' + +describe 'Feature Flags', :js do + using RSpec::Parameterized::TableSyntax + + invalid_input_table = proc do + 'with space' | '' | 'Name can contain only' + '