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'
+ '