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 @@
= _('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
......
......@@ -351,6 +351,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :ci do
resource :lint, only: [:show, :create]
end
## EE-specific
resources :feature_flags
## EE-specific
end
draw :legacy_builds
......
......@@ -1943,6 +1943,24 @@ ActiveRecord::Schema.define(version: 20180926140319) do
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 @@ ActiveRecord::Schema.define(version: 20180926140319) do
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
......
# 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
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(
......
# 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
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 @@ module EE
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
......
......@@ -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[
......
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
approvers
vulnerability_feedback
license_management
feature_flag
].freeze
prepended do
......@@ -65,6 +66,11 @@ module EE
@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 EE
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 EE
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
......
- 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
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
......@@ -28,6 +28,15 @@ module EE
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
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:
- vulnerability_feedback
- vulnerability_identifiers
- vulnerability_scanners
- operations_feature_flags
- operations_feature_flags_client
- prometheus_alerts
- prometheus_alert_events
- software_license_policies
......
......@@ -9,4 +9,14 @@ describe Gitlab::Regex do
it { is_expected.to match('foo*Z') }
it { is_expected.not_to match('!!()()') }
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
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
.and_return(ref)
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
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
mount ::EE::API::Boards
mount ::EE::API::GroupBoards
mount ::API::Unleash
mount ::API::EpicIssues
mount ::API::Epics
mount ::API::Geo
......
......@@ -2254,6 +2254,9 @@ msgstr ""
msgid "Copy incoming email address to clipboard"
msgstr ""
msgid "Copy name to clipboard"
msgstr ""
msgid "Copy reference to clipboard"
msgstr ""
......@@ -3177,6 +3180,81 @@ msgstr ""
msgid "Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)"
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"
msgstr ""
......
......@@ -180,10 +180,13 @@
lodash "^4.17.10"
to-fast-properties "^2.0.0"
"@gitlab-org/gitlab-svgs@^1.23.0", "@gitlab-org/gitlab-svgs@^1.29.0":
version "1.29.0"
resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.29.0.tgz#03b65b513f9099bbda6ecf94d673a2952f8c6c70"
integrity sha512-sCl6nP3ph36+8P3nrw9VanAR648rgOUEBlEoLPHkhKm79xB1dUkXGBtI0uaSJVgbJx40M1/Ts8HSdMv+PF3EIg==
"@gitlab-org/gitlab-svgs@^1.29.0":
version "1.30.0"
resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.30.0.tgz#72dca2fb67bafb3c975a322dc6406aaa29aed86c"
"@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":
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