Commit 76da065b authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch 'feature-flags-mvc-ee' into 'master'

Feature flags MVC

See merge request gitlab-org/gitlab-ee!7433
parents 45e25fb9 3f58a218
...@@ -195,7 +195,7 @@ ...@@ -195,7 +195,7 @@
= _('Charts') = _('Charts')
- if project_nav_tab? :operations - 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 = link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do
.nav-icon-container .nav-icon-container
= sprite_icon('cloud-gear') = sprite_icon('cloud-gear')
...@@ -203,7 +203,7 @@ ...@@ -203,7 +203,7 @@
= _('Operations') = _('Operations')
%ul.sidebar-sub-level-items %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 = link_to metrics_project_environments_path(@project) do
%strong.fly-out-top-item-name %strong.fly-out-top-item-name
= _('Operations') = _('Operations')
...@@ -249,6 +249,12 @@ ...@@ -249,6 +249,12 @@
%span= _("Got it!") %span= _("Got it!")
= sprite_icon('thumb-up') = 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 - if project_nav_tab? :container_registry
= nav_link(controller: %w[projects/registry/repositories]) do = nav_link(controller: %w[projects/registry/repositories]) do
= link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do
......
...@@ -351,6 +351,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -351,6 +351,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :ci do namespace :ci do
resource :lint, only: [:show, :create] resource :lint, only: [:show, :create]
end end
## EE-specific
resources :feature_flags
## EE-specific
end end
draw :legacy_builds draw :legacy_builds
......
...@@ -1943,6 +1943,24 @@ ActiveRecord::Schema.define(version: 20180926140319) do ...@@ -1943,6 +1943,24 @@ ActiveRecord::Schema.define(version: 20180926140319) do
t.string "nonce", null: false t.string "nonce", null: false
end 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| create_table "packages_maven_metadata", id: :bigserial, force: :cascade do |t|
t.integer "package_id", limit: 8, null: false t.integer "package_id", limit: 8, null: false
t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "created_at", null: false
...@@ -3233,6 +3251,8 @@ ActiveRecord::Schema.define(version: 20180926140319) do ...@@ -3233,6 +3251,8 @@ ActiveRecord::Schema.define(version: 20180926140319) do
add_foreign_key "notes", "projects", name: "fk_99e097b079", on_delete: :cascade 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 "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 "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_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_package_files", "packages_packages", column: "package_id", name: "fk_86f0f182f8", on_delete: :cascade
add_foreign_key "packages_packages", "projects", on_delete: :cascade add_foreign_key "packages_packages", "projects", on_delete: :cascade
......
# Feature Flags
## Client libraries
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
...@@ -25,9 +25,20 @@ module EE ...@@ -25,9 +25,20 @@ module EE
nav_tabs << :packages nav_tabs << :packages
end end
if can?(current_user, :read_feature_flag, project) && !nav_tabs.include?(:operations)
nav_tabs << :operations
end
nav_tabs nav_tabs
end 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 override :project_permissions_settings
def project_permissions_settings(project) def project_permissions_settings(project)
super.merge( super.merge(
......
# 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
...@@ -50,6 +50,9 @@ module EE ...@@ -50,6 +50,9 @@ module EE
has_many :prometheus_alerts, inverse_of: :project has_many :prometheus_alerts, inverse_of: :project
has_many :prometheus_alert_events, 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 :with_shared_runners_limit_enabled, -> { with_shared_runners.non_public_only }
scope :mirror, -> { where(mirror: true) } scope :mirror, -> { where(mirror: true) }
...@@ -559,6 +562,11 @@ module EE ...@@ -559,6 +562,11 @@ module EE
change_head(root_ref) if root_ref.present? && root_ref != default_branch change_head(root_ref) if root_ref.present? && root_ref != default_branch
end end
def feature_flags_client_token
instance = operations_feature_flags_client || create_operations_feature_flags_client!
instance.token
end
private private
def set_override_pull_mirror_available def set_override_pull_mirror_available
......
...@@ -67,6 +67,7 @@ class License < ActiveRecord::Base ...@@ -67,6 +67,7 @@ class License < ActiveRecord::Base
custom_project_templates custom_project_templates
packages packages
code_owner_as_approver_suggestion code_owner_as_approver_suggestion
feature_flags
].freeze ].freeze
EEU_FEATURES = EEP_FEATURES + %i[ EEU_FEATURES = EEP_FEATURES + %i[
......
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
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
...@@ -8,6 +8,7 @@ module EE ...@@ -8,6 +8,7 @@ module EE
approvers approvers
vulnerability_feedback vulnerability_feedback
license_management license_management
feature_flag
].freeze ].freeze
prepended do prepended do
...@@ -65,6 +66,11 @@ module EE ...@@ -65,6 +66,11 @@ module EE
@subject.feature_available?(:license_management) @subject.feature_available?(:license_management)
end end
with_scope :subject
condition(:feature_flags_disabled) do
!@subject.feature_available?(:feature_flags)
end
rule { admin }.enable :change_repository_storage rule { admin }.enable :change_repository_storage
rule { support_bot }.enable :guest_access rule { support_bot }.enable :guest_access
...@@ -99,6 +105,11 @@ module EE ...@@ -99,6 +105,11 @@ module EE
enable :admin_board enable :admin_board
enable :admin_vulnerability_feedback enable :admin_vulnerability_feedback
enable :create_package enable :create_package
enable :read_feature_flag
enable :create_feature_flag
enable :update_feature_flag
enable :destroy_feature_flag
enable :admin_feature_flag
end end
rule { can?(:public_access) }.enable :read_package rule { can?(:public_access) }.enable :read_package
...@@ -117,6 +128,10 @@ module EE ...@@ -117,6 +128,10 @@ module EE
prevent(*create_read_update_admin_destroy(:package)) prevent(*create_read_update_admin_destroy(:package))
end end
rule { feature_flags_disabled }.policy do
prevent(*create_read_update_admin_destroy(:feature_flag))
end
rule { can?(:maintainer_access) }.policy do rule { can?(:maintainer_access) }.policy do
enable :push_code_to_protected_branches enable :push_code_to_protected_branches
enable :admin_path_locks enable :admin_path_locks
......
- 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')
- 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 } &times;
.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{<a href="#{client_libraries_url}">}.html_safe,
docs_link_end: '</a>'.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")
- 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 } &times;
.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'
.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'
#error_explanation
.alert.alert-danger
- @feature_flag.errors.full_messages.each do |message|
%p= message
- 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'
- 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')
.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"
- 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 }
- 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'
- @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 }
---
title: Add Feature Flags MVC
merge_request: 7433
author:
type: added
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
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
...@@ -427,6 +427,27 @@ module EE ...@@ -427,6 +427,27 @@ module EE
object.geo_node.missing_oauth_application? object.geo_node.missing_oauth_application?
end end
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 end
end end
...@@ -28,6 +28,15 @@ module EE ...@@ -28,6 +28,15 @@ module EE
def maven_app_group_regex def maven_app_group_regex
maven_app_name_regex maven_app_name_regex
end 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 end
end end
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
FactoryBot.define do
factory :operations_feature_flag, class: Operations::FeatureFlag do
sequence(:name) { |n| "feature_flag_#{n}" }
project
active true
end
end
FactoryBot.define do
factory :operations_feature_flags_client, class: Operations::FeatureFlagsClient do
project
end
end
require 'spec_helper'
describe 'Feature Flags', :js do
using RSpec::Parameterized::TableSyntax
invalid_input_table = proc do
'with space' | '' | 'Name can contain only'
'<script>' | '' | 'Name can contain only'
'x' * 100 | '' | 'Name is too long'
'some-name' | 'y' * 1001 | 'Description is too long'
end
let(:user) {create(:user)}
let(:project) {create(:project, namespace: user.namespace)}
before do
stub_licensed_features(feature_flags: true)
sign_in(user)
end
it 'shows empty state' do
visit(project_feature_flags_path(project))
expect_empty_state
end
context 'when creating a new feature flag' do
context 'and input is valid' do
where(:name, :description, :status) do
'my-active-flag' | 'a new flag' | true
'my-inactive-flag' | '' | false
end
with_them do
it 'adds the feature flag to the table' do
add_feature_flag(name, description, status)
expect_feature_flag(name, description, status)
expect(page).to have_selector '.flash-container', text: 'successfully created'
end
end
end
context 'and input is invalid' do
where(:name, :description, :error_message, &invalid_input_table)
with_them do
it 'displays an error message' do
add_feature_flag(name, description, false)
expect(page).to have_selector '.alert-danger', text: error_message
end
end
end
end
context 'when editing a feature flag' do
before do
add_feature_flag('feature-flag-to-edit', 'with some description', false)
end
context 'and input is valid' do
it 'updates the feature flag' do
name = 'new-name'
description = 'new description'
edit_feature_flag('feature-flag-to-edit', name, description, true)
expect_feature_flag(name, description, true)
expect(page).to have_selector '.flash-container', text: 'successfully updated'
end
end
context 'and input is invalid' do
where(:name, :description, :error_message, &invalid_input_table)
with_them do
it 'displays an error message' do
edit_feature_flag('feature-flag-to-edit', name, description, false)
expect(page).to have_selector '.alert-danger', text: error_message
end
end
end
end
context 'when deleting a feature flag' do
before do
add_feature_flag('feature-flag-to-delete', 'with some description', false)
end
context 'and no feature flags are left' do
it 'shows empty state' do
visit(project_feature_flags_path(project))
delete_feature_flag('feature-flag-to-delete')
expect_empty_state
end
end
context 'and there is a feature flag left' do
before do
add_feature_flag('another-feature-flag', '', true)
end
it 'shows feature flag table without deleted feature flag' do
visit(project_feature_flags_path(project))
delete_feature_flag('feature-flag-to-delete')
expect_feature_flag('another-feature-flag', '', true)
end
end
it 'does not delete if modal is cancelled' do
visit(project_feature_flags_path(project))
delete_feature_flag('feature-flag-to-delete', false)
expect_feature_flag('feature-flag-to-delete', 'with some description', false)
end
end
private
def add_feature_flag(name, description, status)
visit(new_project_feature_flag_path(project))
fill_in 'Name', with: name
fill_in 'Description', with: description
if status
check('Active')
else
uncheck('Active')
end
click_button 'Create feature flag'
end
def delete_feature_flag(name, confirm = true)
delete_button = find('.gl-responsive-table-row', text: name).find('.btn-danger[title="Delete"]')
delete_button.click
within '.modal' do
if confirm
click_button 'Delete'
else
click_button 'Cancel'
end
end
end
def edit_feature_flag(old_name, new_name, new_description, new_status)
visit(project_feature_flags_path(project))
edit_button = find('.gl-responsive-table-row', text: old_name).find('.btn-default[title="Edit"]')
edit_button.click
fill_in 'Name', with: new_name
fill_in 'Description', with: new_description
if new_status
check('Active')
else
uncheck('Active')
end
click_button 'Save changes'
end
def expect_empty_state
expect(page).to have_text 'Get started with feature flags'
expect(page).to have_selector('.btn-success', text: 'New Feature Flag')
expect(page).to have_selector('.btn-primary.btn-inverted', text: 'Configure')
end
def expect_feature_flag(name, description, status)
expect(current_path).to eq project_feature_flags_path(project)
expect(page).to have_selector '.table-section .badge', text: status ? 'Active' : 'Inactive'
expect(page).to have_selector '.table-section', text: name
expect(page).to have_selector '.table-section', text: description
end
end
{
"additionalProperties": false,
"properties": {
"features": {
"items": {
"$ref": "unleash_feature.json"
},
"minItems": 0,
"type": "array"
},
"version": {
"type": "integer"
}
},
"required": [
"version",
"features"
],
"type": "object"
}
{
"type": "object",
"additionalProperties": false,
"required": [
"name",
"enabled"
],
"properties": {
"name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"description": {
"type": "string"
}
}
}
require 'spec_helper'
describe FeatureFlagsHelper do
set(:project) { create(:project) }
context '#unleash_api_url' do
subject { helper.unleash_api_url(project) }
it { is_expected.to end_with("/api/v4/feature_flags/unleash/#{project.id}") }
end
context '#unleash_api_instance_id' do
subject { helper.unleash_api_instance_id(project) }
it { is_expected.not_to be_empty }
end
end
...@@ -56,6 +56,8 @@ project: ...@@ -56,6 +56,8 @@ project:
- vulnerability_feedback - vulnerability_feedback
- vulnerability_identifiers - vulnerability_identifiers
- vulnerability_scanners - vulnerability_scanners
- operations_feature_flags
- operations_feature_flags_client
- prometheus_alerts - prometheus_alerts
- prometheus_alert_events - prometheus_alert_events
- software_license_policies - software_license_policies
......
...@@ -9,4 +9,14 @@ describe Gitlab::Regex do ...@@ -9,4 +9,14 @@ describe Gitlab::Regex do
it { is_expected.to match('foo*Z') } it { is_expected.to match('foo*Z') }
it { is_expected.not_to match('!!()()') } it { is_expected.not_to match('!!()()') }
end end
describe '.feature_flag_regex' do
subject { described_class.feature_flag_regex }
it { is_expected.to match('foo') }
it { is_expected.to match('f_feature_flag') }
it { is_expected.not_to match('MY_FEATURE_FLAG') }
it { is_expected.not_to match('my feature flag') }
it { is_expected.not_to match('!!()()') }
end
end end
require 'spec_helper'
describe Operations::FeatureFlag do
subject { create(:operations_feature_flag) }
describe 'associations' do
it { is_expected.to belong_to(:project) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
end
end
require 'spec_helper'
describe Operations::FeatureFlagsClient do
subject { create(:operations_feature_flags_client) }
describe 'associations' do
it { is_expected.to belong_to(:project) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:project) }
end
describe '#token' do
it "ensures that token is always set" do
expect(subject.token).not_to be_empty
end
end
end
...@@ -1767,4 +1767,24 @@ describe Project do ...@@ -1767,4 +1767,24 @@ describe Project do
.and_return(ref) .and_return(ref)
end end
end end
describe '#feature_flags_client_token' do
let(:project) { create(:project) }
subject { project.feature_flags_client_token }
context 'when there is no access token' do
it "creates a new one" do
is_expected.not_to be_empty
end
end
context 'when there is access token' do
let!(:instance) { create(:operations_feature_flags_client, project: project, token: 'token') }
it "provides an existing one" do
is_expected.to eq('token')
end
end
end
end end
require 'spec_helper'
describe API::Unleash do
set(:project) { create(:project) }
let(:project_id) { project.id }
let(:feature_enabled) { true }
let(:params) { }
let(:headers) { }
before do
stub_licensed_features(feature_flags: feature_enabled)
end
shared_examples 'authenticated request' do
context 'when using instance id' do
let(:client) { create(:operations_feature_flags_client, project: project) }
let(:params) { { instance_id: client.token } }
it 'responds with OK' do
subject
expect(response).to have_gitlab_http_status(200)
end
context 'when feature is not available' do
let(:feature_enabled) { false }
it 'responds with forbidden' do
subject
expect(response).to have_gitlab_http_status(403)
end
end
end
context 'when using header' do
let(:client) { create(:operations_feature_flags_client, project: project) }
let(:headers) { { "UNLEASH-INSTANCEID" => client.token }}
it 'responds with OK' do
subject
expect(response).to have_gitlab_http_status(200)
end
end
context 'when using bogus instance id' do
let(:params) { { instance_id: 'token' } }
it 'responds with unauthorized' do
subject
expect(response).to have_gitlab_http_status(401)
end
end
context 'when using not existing project' do
let(:project_id) { -5000 }
let(:params) { { instance_id: 'token' } }
it 'responds with unauthorized' do
subject
expect(response).to have_gitlab_http_status(401)
end
end
end
describe 'GET /feature_flags/unleash/:project_id/features' do
subject { get api("/feature_flags/unleash/#{project_id}/features"), params, headers }
it_behaves_like 'authenticated request'
context 'with a list of feature flag' do
let(:client) { create(:operations_feature_flags_client, project: project) }
let(:headers) { { "UNLEASH-INSTANCEID" => client.token }}
let!(:enable_feature_flag) { create(:operations_feature_flag, project: project, name: 'feature1', active: true) }
let!(:disabled_feature_flag) { create(:operations_feature_flag, project: project, name: 'feature2', active: false) }
it 'responds with a list' do
subject
expect(response).to have_gitlab_http_status(200)
expect(json_response['version']).to eq(1)
expect(json_response['features']).not_to be_empty
expect(json_response['features'].first['name']).to eq('feature1')
end
it 'matches json schema' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('unleash/unleash', dir: 'ee')
end
end
end
describe 'POST /feature_flags/unleash/:project_id/client/register' do
subject { post api("/feature_flags/unleash/#{project_id}/client/register"), params, headers }
it_behaves_like 'authenticated request'
end
describe 'POST /feature_flags/unleash/:project_id/client/metrics' do
subject { post api("/feature_flags/unleash/#{project_id}/client/metrics"), params, headers }
it_behaves_like 'authenticated request'
end
end
...@@ -167,6 +167,7 @@ module API ...@@ -167,6 +167,7 @@ module API
mount ::EE::API::Boards mount ::EE::API::Boards
mount ::EE::API::GroupBoards mount ::EE::API::GroupBoards
mount ::API::Unleash
mount ::API::EpicIssues mount ::API::EpicIssues
mount ::API::Epics mount ::API::Epics
mount ::API::Geo mount ::API::Geo
......
...@@ -2254,6 +2254,9 @@ msgstr "" ...@@ -2254,6 +2254,9 @@ msgstr ""
msgid "Copy incoming email address to clipboard" msgid "Copy incoming email address to clipboard"
msgstr "" msgstr ""
msgid "Copy name to clipboard"
msgstr ""
msgid "Copy reference to clipboard" msgid "Copy reference to clipboard"
msgstr "" msgstr ""
...@@ -3177,6 +3180,81 @@ msgstr "" ...@@ -3177,6 +3180,81 @@ msgstr ""
msgid "Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)" msgid "Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)"
msgstr "" msgstr ""
msgid "Feature Flags"
msgstr ""
msgid "FeatureFlags|API URL"
msgstr ""
msgid "FeatureFlags|Active"
msgstr ""
msgid "FeatureFlags|Application name"
msgstr ""
msgid "FeatureFlags|Configure"
msgstr ""
msgid "FeatureFlags|Configure feature flags"
msgstr ""
msgid "FeatureFlags|Create feature flag"
msgstr ""
msgid "FeatureFlags|Delete %{feature_flag_name}?"
msgstr ""
msgid "FeatureFlags|Description"
msgstr ""
msgid "FeatureFlags|Edit %{feature_flag_name}"
msgstr ""
msgid "FeatureFlags|Edit Feature Flag"
msgstr ""
msgid "FeatureFlags|Feature Flag"
msgstr ""
msgid "FeatureFlags|Feature flag"
msgstr ""
msgid "FeatureFlags|Feature flag %{feature_flag_name} will be removed. Are you sure?"
msgstr ""
msgid "FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality."
msgstr ""
msgid "FeatureFlags|Get started with feature flags"
msgstr ""
msgid "FeatureFlags|Inactive"
msgstr ""
msgid "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."
msgstr ""
msgid "FeatureFlags|Instance ID"
msgstr ""
msgid "FeatureFlags|More information"
msgstr ""
msgid "FeatureFlags|Name"
msgstr ""
msgid "FeatureFlags|New"
msgstr ""
msgid "FeatureFlags|New Feature Flag"
msgstr ""
msgid "FeatureFlags|Save changes"
msgstr ""
msgid "FeatureFlags|Status"
msgstr ""
msgid "Feb" msgid "Feb"
msgstr "" msgstr ""
......
...@@ -180,10 +180,13 @@ ...@@ -180,10 +180,13 @@
lodash "^4.17.10" lodash "^4.17.10"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@gitlab-org/gitlab-svgs@^1.23.0", "@gitlab-org/gitlab-svgs@^1.29.0": "@gitlab-org/gitlab-svgs@^1.29.0":
version "1.29.0" version "1.30.0"
resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.29.0.tgz#03b65b513f9099bbda6ecf94d673a2952f8c6c70" resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.30.0.tgz#72dca2fb67bafb3c975a322dc6406aaa29aed86c"
integrity sha512-sCl6nP3ph36+8P3nrw9VanAR648rgOUEBlEoLPHkhKm79xB1dUkXGBtI0uaSJVgbJx40M1/Ts8HSdMv+PF3EIg==
"@gitlab-org/gitlab-svgs@^1.30.0":
version "1.31.0"
resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.31.0.tgz#495b074669f93af40e34f9978ce887773dea470a"
"@gitlab-org/gitlab-ui@^1.7.1": "@gitlab-org/gitlab-ui@^1.7.1":
version "1.7.1" version "1.7.1"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment