summaryrefslogtreecommitdiff
path: root/app/models/operations/feature_flags/strategy.rb
blob: c70e10c72d59275973736424ff588ccd60c54ad0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# frozen_string_literal: true

module Operations
  module FeatureFlags
    class Strategy < ApplicationRecord
      STRATEGY_DEFAULT = 'default'
      STRATEGY_GITLABUSERLIST = 'gitlabUserList'
      STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId'
      STRATEGY_FLEXIBLEROLLOUT = 'flexibleRollout'
      STRATEGY_USERWITHID = 'userWithId'
      STRATEGIES = {
        STRATEGY_DEFAULT => [].freeze,
        STRATEGY_GITLABUSERLIST => [].freeze,
        STRATEGY_GRADUALROLLOUTUSERID => %w[groupId percentage].freeze,
        STRATEGY_FLEXIBLEROLLOUT => %w[groupId rollout stickiness].freeze,
        STRATEGY_USERWITHID => ['userIds'].freeze
      }.freeze
      USERID_MAX_LENGTH = 256
      STICKINESS_SETTINGS = %w[DEFAULT USERID SESSIONID RANDOM].freeze

      self.table_name = 'operations_strategies'

      belongs_to :feature_flag
      has_many :scopes, class_name: 'Operations::FeatureFlags::Scope'
      has_one :strategy_user_list
      has_one :user_list, through: :strategy_user_list

      validates :name,
        inclusion: {
        in: STRATEGIES.keys,
        message: 'strategy name is invalid'
      }

      validate :parameters_validations, if: -> { errors[:name].blank? }
      validates :user_list, presence: true, if: -> { name == STRATEGY_GITLABUSERLIST }
      validates :user_list, absence: true, if: -> { name != STRATEGY_GITLABUSERLIST }
      validate :same_project_validation, if: -> { user_list.present? }

      accepts_nested_attributes_for :scopes, allow_destroy: true

      def user_list_id=(user_list_id)
        self.user_list = ::Operations::FeatureFlags::UserList.find(user_list_id)
      end

      private

      def same_project_validation
        unless user_list.project_id == feature_flag.project_id
          errors.add(:user_list, 'must belong to the same project')
        end
      end

      def parameters_validations
        validate_parameters_type &&
          validate_parameters_keys &&
          validate_parameters_values
      end

      def validate_parameters_type
        parameters.is_a?(Hash) || parameters_error('parameters are invalid')
      end

      def validate_parameters_keys
        actual_keys = parameters.keys.sort
        expected_keys = STRATEGIES[name].sort
        expected_keys == actual_keys || parameters_error('parameters are invalid')
      end

      def validate_parameters_values
        case name
        when STRATEGY_GRADUALROLLOUTUSERID
          gradual_rollout_user_id_parameters_validation
        when STRATEGY_FLEXIBLEROLLOUT
          flexible_rollout_parameters_validation
        when STRATEGY_USERWITHID
          FeatureFlagUserXidsValidator.validate_user_xids(self, :parameters, parameters['userIds'], 'userIds')
        end
      end

      def within_range?(value, min, max)
        return false unless value.is_a?(String)
        return false unless value.match?(/\A\d+\z/)

        value.to_i.between?(min, max)
      end

      def gradual_rollout_user_id_parameters_validation
        percentage = parameters['percentage']
        group_id = parameters['groupId']

        unless within_range?(percentage, 0, 100)
          parameters_error('percentage must be a string between 0 and 100 inclusive')
        end

        unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/)
          parameters_error('groupId parameter is invalid')
        end
      end

      def flexible_rollout_parameters_validation
        stickiness = parameters['stickiness']
        group_id = parameters['groupId']
        rollout = parameters['rollout']

        unless STICKINESS_SETTINGS.include?(stickiness)
          options = STICKINESS_SETTINGS.to_sentence(last_word_connector: ', or ')
          parameters_error("stickiness parameter must be #{options}")
        end

        unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/)
          parameters_error('groupId parameter is invalid')
        end

        unless within_range?(rollout, 0, 100)
          parameters_error('rollout must be a string between 0 and 100 inclusive')
        end
      end

      def parameters_error(message)
        errors.add(:parameters, message)
        false
      end
    end
  end
end