summaryrefslogtreecommitdiff
path: root/qa/qa/runtime/feature.rb
blob: 7011f46542b034aaf73569bf40dc887216c09a40 (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
# frozen_string_literal: true

require 'active_support/core_ext/object/blank'

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

        include Support::Api

        SetFeatureError = Class.new(RuntimeError)
        AuthorizationError = Class.new(RuntimeError)
        UnknownScopeError = Class.new(RuntimeError)

        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

        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.filter { |i| i['key'] == 'actors' }.first['value']
              break actors.include?("#{key.to_s.capitalize}:#{value.id}")
            when :feature_group
              groups = gates.filter { |i| i['key'] == 'groups' }.first['value']
              break groups.include?(value)
            else
              raise UnknownScopeError, "Unknown scope: #{key}"
            end
          end
        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