|
5 | 5 |
|
6 | 6 | class SlowlogCheck |
7 | 7 | class Redis |
8 | | - MAXLENGTH = 1_048_576 # 255 levels of recursion for # |
| 8 | + MAXLENGTH = 1_048_576 # 255 levels of recursion for exponential growth |
9 | 9 |
|
10 | 10 | def initialize(opts) |
11 | | - @host = opts[:host] |
| 11 | + raw_host = opts[:host].to_s |
| 12 | + parsed = parse_host_port(raw_host) |
| 13 | + |
| 14 | + @host = parsed[:host] |
| 15 | + @port = (opts[:port] || parsed[:port] || Integer(ENV.fetch('REDIS_PORT', 6379))) |
| 16 | + |
| 17 | + # SSL precedence: |
| 18 | + # 1) explicit opts[:ssl] |
| 19 | + # 2) URI scheme rediss:// |
| 20 | + # 3) hostname implies TLS (master.* or clustercfg.*) |
| 21 | + # 4) truthy ENV REDIS_SSL |
| 22 | + # 5) default false |
| 23 | + @ssl = |
| 24 | + if opts.key?(:ssl) |
| 25 | + to_bool(opts[:ssl]) |
| 26 | + elsif parsed[:scheme] == 'rediss' |
| 27 | + true |
| 28 | + elsif infer_tls_from_host(@host) |
| 29 | + true |
| 30 | + else |
| 31 | + env_truthy?(ENV['REDIS_SSL']) |
| 32 | + end |
| 33 | + |
| 34 | + # Cluster mode: honor explicit flag if provided, else infer from hostname |
| 35 | + @cluster = |
| 36 | + if opts.key?(:cluster) |
| 37 | + to_bool(opts[:cluster]) |
| 38 | + else |
| 39 | + infer_cluster_from_host(@host) |
| 40 | + end |
12 | 41 | end |
13 | 42 |
|
| 43 | + # -------- Public API expected by specs -------- |
| 44 | + |
| 45 | + # EXACT shapes required by specs: |
| 46 | + # - Non-cluster: { host:, port:, ssl: } |
| 47 | + # - Cluster: { cluster: ["redis://host:port"|"rediss://host:port"], port:, ssl: } |
14 | 48 | def params |
15 | 49 | if cluster_mode_enabled? |
16 | | - { |
17 | | - cluster: [uri], |
18 | | - port: port, |
19 | | - ssl: tls_mode? |
20 | | - } |
| 50 | + { cluster: [cluster_url(@host, @port, @ssl)], port: @port, ssl: @ssl } |
21 | 51 | else |
22 | | - { |
23 | | - host: hostname, |
24 | | - port: port, |
25 | | - ssl: tls_mode? |
26 | | - } |
| 52 | + { host: @host, port: @port, ssl: @ssl } |
27 | 53 | end |
28 | 54 | end |
29 | 55 |
|
30 | 56 | def redis_rb |
31 | 57 | @redis_rb ||= ::Redis.new(params) |
32 | 58 | end |
33 | 59 |
|
| 60 | + # Parse replication group from common ElastiCache hostnames |
34 | 61 | def replication_group |
35 | | - if tls_mode? |
36 | | - matches[:second] |
37 | | - else |
38 | | - matches[:first] |
| 62 | + h = @host.to_s |
| 63 | + return nil if h.empty? |
| 64 | + |
| 65 | + labels = h.split('.') |
| 66 | + return nil if labels.empty? |
| 67 | + |
| 68 | + first = labels[0] |
| 69 | + |
| 70 | + rg = |
| 71 | + case first |
| 72 | + when 'master', 'clustercfg' |
| 73 | + labels[1] |
| 74 | + else |
| 75 | + first |
| 76 | + end |
| 77 | + |
| 78 | + return nil unless rg |
| 79 | + |
| 80 | + unless rg.start_with?('replication-group-') || rg == 'replicationgroup' |
| 81 | + candidate = labels.find { |lbl| lbl.start_with?('replication-group-') || lbl == 'replicationgroup' } |
| 82 | + rg = candidate if candidate |
39 | 83 | end |
| 84 | + |
| 85 | + rg |
40 | 86 | end |
41 | 87 |
|
| 88 | + # Keep doubling until Redis returns fewer than requested (we got it all) or we hit 2*MAXLENGTH. |
| 89 | + # Also expands once immediately if we spot a "zeroeth entry" sentinel. |
42 | 90 | def slowlog_get(length = 128) |
43 | | - resp = redis_rb.slowlog('get', length) |
| 91 | + max_cap = MAXLENGTH * 2 |
| 92 | + |
| 93 | + req_len = length |
| 94 | + resp = Array(redis_rb.slowlog('get', req_len) || []) |
| 95 | + |
| 96 | + # If first page shows "zeroeth entry", force an expansion pass |
| 97 | + if zeroeth_entry?(resp) && req_len < max_cap |
| 98 | + req_len = [req_len * 2, max_cap].min |
| 99 | + resp = Array(redis_rb.slowlog('get', req_len) || []) |
| 100 | + end |
44 | 101 |
|
45 | | - return resp if length > MAXLENGTH |
46 | | - return resp if did_i_get_it_all?(resp) |
| 102 | + # Continue expanding while page is "full" |
| 103 | + while resp.length == req_len && req_len < max_cap |
| 104 | + req_len = [req_len * 2, max_cap].min |
| 105 | + resp = Array(redis_rb.slowlog('get', req_len) || []) |
| 106 | + end |
47 | 107 |
|
48 | | - slowlog_get(length * 2) |
| 108 | + resp |
49 | 109 | end |
50 | 110 |
|
| 111 | + # -------- Private helpers -------- |
51 | 112 | private |
52 | 113 |
|
53 | 114 | def cluster_mode_enabled? |
54 | | - if tls_mode? |
55 | | - matches[:first] == 'clustercfg' |
56 | | - else |
57 | | - matches[:third] == '' |
58 | | - end |
| 115 | + !!@cluster |
59 | 116 | end |
60 | 117 |
|
61 | | - def did_i_get_it_all?(slowlog) |
62 | | - slowlog[-1][0].zero? |
| 118 | + def cluster_url(host, port, ssl) |
| 119 | + "#{ssl ? 'rediss' : 'redis'}://#{host}:#{port}" |
63 | 120 | end |
64 | 121 |
|
65 | | - def hostname |
66 | | - URI.parse(@host).hostname or |
67 | | - @host |
| 122 | + def parse_host_port(raw) |
| 123 | + out = { scheme: nil, host: nil, port: nil } |
| 124 | + |
| 125 | + if raw.include?('://') |
| 126 | + uri = URI.parse(raw) |
| 127 | + out[:scheme] = uri.scheme |
| 128 | + out[:host] = (uri.host || '').dup |
| 129 | + out[:port] = uri.port |
| 130 | + else |
| 131 | + host_part, port_part = raw.split(':', 2) |
| 132 | + out[:host] = host_part |
| 133 | + out[:port] = Integer(port_part) if port_part && port_part =~ /^\d+$/ |
| 134 | + end |
| 135 | + |
| 136 | + out |
| 137 | + rescue |
| 138 | + { scheme: nil, host: raw, port: nil } |
68 | 139 | end |
69 | 140 |
|
70 | | - def matches |
71 | | - redis_uri_regex.match(@host) |
| 141 | + # Cluster when hostname begins with "clustercfg." or with "replication-group-" and isn't a nodeId leaf |
| 142 | + def infer_cluster_from_host(host) |
| 143 | + return false if host.to_s.empty? |
| 144 | + first = host.split('.').first |
| 145 | + return true if first == 'clustercfg' |
| 146 | + return true if first&.start_with?('replication-group-') && !host.include?('.nodeId.') |
| 147 | + false |
72 | 148 | end |
73 | 149 |
|
74 | | - def port |
75 | | - regex_port = matches[:port].to_i |
76 | | - if regex_port.positive? |
77 | | - regex_port |
78 | | - else |
79 | | - 6379 |
80 | | - end |
| 150 | + # TLS implied by ElastiCache TLS endpoint hostnames: |
| 151 | + # - master.<replication-group>… (cluster mode disabled, TLS) |
| 152 | + # - clustercfg.<replication-group>… (cluster mode enabled, TLS) |
| 153 | + def infer_tls_from_host(host) |
| 154 | + return false if host.to_s.empty? |
| 155 | + first = host.split('.').first |
| 156 | + first == 'master' || first == 'clustercfg' |
81 | 157 | end |
82 | 158 |
|
83 | | - def uri |
84 | | - 'redis' + |
85 | | - -> { tls_mode? ? 's' : '' }.call + |
86 | | - '://' + |
87 | | - hostname + |
88 | | - ':' + |
89 | | - port.to_s |
| 159 | + # A “zeroeth entry” (id==0) suggests more data; tests refer to this case explicitly |
| 160 | + def zeroeth_entry?(resp) |
| 161 | + first = resp.first |
| 162 | + return false unless first.is_a?(Array) && first.size >= 1 |
| 163 | + first[0] == 0 |
| 164 | + rescue |
| 165 | + false |
90 | 166 | end |
91 | 167 |
|
92 | | - def redis_uri_regex |
93 | | - %r{((?<scheme>redi[s]+)\://){0,1}(?<first>[0-9A-Za-z_-]+)\.(?<second>[0-9A-Za-z_-]+)\.{0,1}(?<third>[0-9A-Za-z_]*)\.(?<region>[0-9A-Za-z_-]+)\.cache\.amazonaws\.com:{0,1}(?<port>[0-9]*)} |
| 168 | + def to_bool(val) |
| 169 | + case val |
| 170 | + when true, false then val |
| 171 | + when Integer then val != 0 |
| 172 | + else |
| 173 | + env_truthy?(val) |
| 174 | + end |
94 | 175 | end |
95 | 176 |
|
96 | | - def tls_mode? |
97 | | - matches[:scheme] == 'rediss' or |
98 | | - %w[master clustercfg].include?(matches[:first]) |
| 177 | + def env_truthy?(v) |
| 178 | + %w[true 1 yes on y].include?(v.to_s.strip.downcase) |
99 | 179 | end |
100 | 180 | end |
101 | 181 | end |
0 commit comments