summaryrefslogtreecommitdiff
path: root/qa/qa/runtime/feature.rb
blob: ec28813c1f6c8abfb61c2e6469ce9e409ce23510 (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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# frozen_string_literal: true

require 'active_support/core_ext/object/blank'

module QA
  module Runtime
    class Feature
      SetFeatureError = Class.new(RuntimeError)
      AuthorizationError = Class.new(RuntimeError)
      UnknownScopeError = Class.new(RuntimeError)
      UnknownStateError = Class.new(RuntimeError)

      class << self
        # Documentation: https://docs.gitlab.com/ee/api/features.html

        include Support::API

        def remove(key)
          request = Runtime::API::Request.new(api_client, "/features/#{key}")
          response = delete(request.url)
          unless response.code == QA::Support::API::HTTP_STATUS_NO_CONTENT
            raise SetFeatureError, "Deleting feature flag #{key} failed with `#{response}`."
          end
        end

        def enable(key, **scopes)
          set_and_verify(key, enable: true, **scopes)
        end

        def disable(key, **scopes)
          set_and_verify(key, enable: false, **scopes)
        end

        # Set one or more flags to their specified state.
        #
        # @param [Hash] flags The feature flags and desired values, e.g., { 'flag1' => 'enabled', 'flag2' => "disabled" }
        # @param [Hash] scopes The scope (user, project, group) to apply the feature flag to.
        def set(flags, **scopes)
          flags.each_pair do |flag, state|
            case state
            when 'enabled', 'enable', 'true', 1, true
              enable(flag, **scopes)
            when 'disabled', 'disable', 'false', 0, false
              disable(flag, **scopes)
            else
              raise UnknownStateError, "Unknown feature flag state: #{state}"
            end
          end
        end

        def enabled?(key, **scopes)
          feature = JSON.parse(get_features).find { |flag| flag['name'] == key.to_s }
          feature && (feature['state'] == 'on' || feature['state'] == 'conditional' && scopes.present? && enabled_scope?(feature['gates'], **scopes))
        end

        private

        def api_client
          @api_client ||= Runtime::API::Client.as_admin
        rescue Runtime::API::Client::AuthorizationError => e
          raise AuthorizationError, "Administrator access is required to enable/disable feature flags. #{e.message}"
        end

        def enabled_scope?(gates, **scopes)
          scopes.each do |key, value|
            case key
            when :project, :group, :user
              actors = gates.find { |i| i['key'] == 'actors' }['value']
              return actors.include?("#{key.to_s.capitalize}:#{value.id}")
            when :feature_group
              groups = gates.find { |i| i['key'] == 'groups' }['value']
              return groups.include?(value)
            end
          end

          raise UnknownScopeError, "Unknown scope in: #{scopes}"
        end

        def get_features
          request = Runtime::API::Request.new(api_client, '/features')
          response = get(request.url)
          response.body
        end

        # Change a feature flag and verify that the change was successful
        # Arguments:
        #   key: The feature flag to set (as a string)
        #   enable: `true` to enable the flag, `false` to disable it
        #   scopes: Any scope (user, project, group) to restrict the change to
        def set_and_verify(key, enable:, **scopes)
          msg = "#{enable ? 'En' : 'Dis'}abling feature: #{key}"
          msg += " for scope \"#{scopes_to_s(**scopes)}\"" if scopes.present?
          QA::Runtime::Logger.info(msg)

          Support::Retrier.retry_on_exception(sleep_interval: 2) do
            set_feature(key, enable, **scopes)

            is_enabled = nil

            QA::Support::Waiter.wait_until(sleep_interval: 1) do
              is_enabled = enabled?(key, **scopes)
              is_enabled == enable || !enable && scopes.present?
            end

            if is_enabled == enable
              QA::Runtime::Logger.info("Successfully #{enable ? 'en' : 'dis'}abled and verified feature flag: #{key}")
            else
              raise SetFeatureError, "#{key} was not #{enable ? 'en' : 'dis'}abled!" if enable

              QA::Runtime::Logger.warn("Feature flag scope was removed but the flag is still enabled globally.")
            end
          end
        end

        def set_feature(key, value, **scopes)
          scopes[:project] = scopes[:project].full_path if scopes.key?(:project)
          scopes[:group] = scopes[:group].full_path if scopes.key?(:group)
          scopes[:user] = scopes[:user].username if scopes.key?(:user)
          request = Runtime::API::Request.new(api_client, "/features/#{key}")
          response = post(request.url, scopes.merge({ value: value }))
          unless response.code == QA::Support::API::HTTP_STATUS_CREATED
            raise SetFeatureError, "Setting feature flag #{key} to #{value} failed with `#{response}`."
          end
        end

        def scopes_to_s(**scopes)
          key = scopes.each_key.first
          s = "#{key}: "
          case key
          when :project, :group
            s += scopes[key].full_path
          when :user
            s += scopes[key].username
          when :feature_group
            s += scopes[key]
          else
            raise UnknownScopeError, "Unknown scope: #{key}"
          end

          s
        end
      end
    end
  end
end