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 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
|
# frozen_string_literal: true
module WebHooks
module AutoDisabling
extend ActiveSupport::Concern
include ::Gitlab::Loggable
ENABLED_HOOK_TYPES = %w[ProjectHook].freeze
MAX_FAILURES = 100
FAILURE_THRESHOLD = 3
EXCEEDED_FAILURE_THRESHOLD = FAILURE_THRESHOLD + 1
INITIAL_BACKOFF = 1.minute.freeze
MAX_BACKOFF = 1.day.freeze
MAX_BACKOFF_COUNT = 11
BACKOFF_GROWTH_FACTOR = 2.0
class_methods do
def auto_disabling_enabled?
enabled_hook_types.include?(name) &&
Gitlab::SafeRequestStore.fetch(:auto_disabling_web_hooks) do
Feature.enabled?(:auto_disabling_web_hooks, type: :ops)
end
end
private
def enabled_hook_types
ENABLED_HOOK_TYPES
end
end
included do
delegate :auto_disabling_enabled?, to: :class, private: true
# A hook is disabled if:
#
# - we are no longer in the grace-perod (recent_failures > ?)
# - and either:
# - disabled_until is nil (i.e. this was set by WebHook#fail!)
# - or disabled_until is in the future (i.e. this was set by WebHook#backoff!)
# - OR silent mode is enabled.
scope :disabled, -> do
return all if Gitlab::SilentMode.enabled?
return none unless auto_disabling_enabled?
where(
'recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)',
FAILURE_THRESHOLD,
Time.current
)
end
# A hook is executable if:
#
# - we are still in the grace-period (recent_failures <= ?)
# - OR we have exceeded the grace period and neither of the following is true:
# - disabled_until is nil (i.e. this was set by WebHook#fail!)
# - disabled_until is in the future (i.e. this was set by WebHook#backoff!)
# - AND silent mode is not enabled.
scope :executable, -> do
return none if Gitlab::SilentMode.enabled?
return all unless auto_disabling_enabled?
where(
'recent_failures <= ? OR (recent_failures > ? AND (disabled_until IS NOT NULL) AND (disabled_until < ?))',
FAILURE_THRESHOLD,
FAILURE_THRESHOLD,
Time.current
)
end
end
def executable?
return true unless auto_disabling_enabled?
!temporarily_disabled? && !permanently_disabled?
end
def temporarily_disabled?
return false unless auto_disabling_enabled?
disabled_until.present? && disabled_until >= Time.current && recent_failures > FAILURE_THRESHOLD
end
def permanently_disabled?
return false unless auto_disabling_enabled?
recent_failures > FAILURE_THRESHOLD && disabled_until.blank?
end
def enable!
return unless auto_disabling_enabled?
return if recent_failures == 0 && disabled_until.nil? && backoff_count == 0
attrs = { recent_failures: 0, disabled_until: nil, backoff_count: 0 }
assign_attributes(attrs)
logger.info(hook_id: id, action: 'enable', **attrs)
save(validate: false)
end
# Don't actually back-off until FAILURE_THRESHOLD failures have been seen
# we mark the grace-period using the recent_failures counter
def backoff!
return unless auto_disabling_enabled?
return if permanently_disabled? || (backoff_count >= MAX_FAILURES && temporarily_disabled?)
attrs = { recent_failures: next_failure_count }
if recent_failures >= FAILURE_THRESHOLD
attrs[:backoff_count] = next_backoff_count
attrs[:disabled_until] = next_backoff.from_now
end
assign_attributes(attrs)
return unless changed?
logger.info(hook_id: id, action: 'backoff', **attrs)
save(validate: false)
end
def failed!
return unless auto_disabling_enabled?
return unless recent_failures < MAX_FAILURES
attrs = { disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count }
assign_attributes(**attrs)
logger.info(hook_id: id, action: 'disable', **attrs)
save(validate: false)
end
def next_backoff
# Optimization to prevent expensive exponentiation and possible overflows
return MAX_BACKOFF if backoff_count >= MAX_BACKOFF_COUNT
(INITIAL_BACKOFF * (BACKOFF_GROWTH_FACTOR**backoff_count))
.clamp(INITIAL_BACKOFF, MAX_BACKOFF)
.seconds
end
def alert_status
return :executable unless auto_disabling_enabled?
if temporarily_disabled?
:temporarily_disabled
elsif permanently_disabled?
:disabled
else
:executable
end
end
private
def logger
@logger ||= Gitlab::WebHooks::Logger.build
end
def next_failure_count
recent_failures.succ.clamp(1, MAX_FAILURES)
end
def next_backoff_count
backoff_count.succ.clamp(1, MAX_FAILURES)
end
end
end
WebHooks::AutoDisabling.prepend_mod
WebHooks::AutoDisabling::ClassMethods.prepend_mod
|