gitlab-exporter 16.9.0 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77bae663d5dff12133db411a27154cb55c100caf0bec1ecdd4dd4e8102c88994
4
- data.tar.gz: d98c9128dc327d8757d4bb8d021cf863274f59588767ff877ce5ebe4c3274636
3
+ metadata.gz: 3cc0cadf1891c19066f388f384d51f47b9c489e8e0ee7c162565228fb661bada
4
+ data.tar.gz: 7ae7c0ac398b1832c58c2348297139fd88e3ff02e447669043990312160ed0a8
5
5
  SHA512:
6
- metadata.gz: ff4b8dc86a0765ab98cf1e7ac97ee7016e09e1ec1e0a08db4366bdd9a66ae75dc34cb9d41ad5ed28668a6d279f641db780526ac3619b04b114f4c68aa29d8a74
7
- data.tar.gz: a76a114f309436e969865d25cb3a0e7aa4b94d8239cb4d7f775958ef971d3a6747380d002adf8999d3a8be545ad3692980ae980dd5ed6e1a8533cc3f7bc87173
6
+ metadata.gz: 7a358dbc90fe7bed11fc33a179fe2eb726e0d218925a56e7f076cc4c51d14272de4adca57cd5ae084e0a57d51feed0cf331b2dbf3ae67a5c0a2a972c4dba1863
7
+ data.tar.gz: c937591c8d81ad5296955bd5a55f92bcdd6f2ecb0f1fe7cdd36e9f429e88f5bbf2da8bce8375e8897fec29eda18ea0dd60fcd1bbfc5070c49e9b886a0faf3037
data/Gemfile.lock CHANGED
@@ -1,17 +1,16 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab-exporter (16.9.0)
4
+ gitlab-exporter (17.0.0)
5
+ base64 (= 0.2.0)
5
6
  connection_pool (= 2.5.5)
6
7
  deep_merge (~> 1.2.2)
7
8
  faraday (= 2.14.3)
8
9
  pg (= 1.6.3)
9
- puma (= 8.0.1)
10
10
  quantile (= 0.2.1)
11
11
  redis (= 4.8.1)
12
12
  redis-namespace (= 1.11.0)
13
13
  sidekiq (= 6.5.12)
14
- sinatra (~> 3.2.0)
15
14
  webrick (~> 1.9)
16
15
 
17
16
  GEM
@@ -31,23 +30,15 @@ GEM
31
30
  language_server-protocol (3.17.0.5)
32
31
  lint_roller (1.1.0)
33
32
  logger (1.7.0)
34
- mustermann (3.0.3)
35
- ruby2_keywords (~> 0.0.1)
36
- nio4r (2.7.4)
37
33
  parallel (1.27.0)
38
34
  parser (3.3.9.0)
39
35
  ast (~> 2.4.1)
40
36
  racc
41
37
  pg (1.6.3)
42
38
  prism (1.9.0)
43
- puma (8.0.1)
44
- nio4r (~> 2.0)
45
39
  quantile (0.2.1)
46
40
  racc (1.8.1)
47
41
  rack (2.2.15)
48
- rack-protection (3.2.0)
49
- base64 (>= 0.1.0)
50
- rack (~> 2.2, >= 2.2.4)
51
42
  rainbow (3.1.1)
52
43
  redis (4.8.1)
53
44
  redis-namespace (1.11.0)
@@ -81,17 +72,10 @@ GEM
81
72
  parser (>= 3.3.7.2)
82
73
  prism (~> 1.7)
83
74
  ruby-progressbar (1.13.0)
84
- ruby2_keywords (0.0.5)
85
75
  sidekiq (6.5.12)
86
76
  connection_pool (>= 2.2.5, < 3)
87
77
  rack (~> 2.0)
88
78
  redis (>= 4.5.0, < 5)
89
- sinatra (3.2.0)
90
- mustermann (~> 3.0)
91
- rack (~> 2.2, >= 2.2.4)
92
- rack-protection (= 3.2.0)
93
- tilt (~> 2.0)
94
- tilt (2.6.0)
95
79
  unicode-display_width (3.1.4)
96
80
  unicode-emoji (~> 4.0, >= 4.0.4)
97
81
  unicode-emoji (4.0.4)
@@ -6,7 +6,6 @@ db_common: &db_common
6
6
 
7
7
  # Web server config
8
8
  server:
9
- name: webrick # cf. https://github.com/sinatra/sinatra#available-settings
10
9
  listen_address: 0.0.0.0
11
10
  listen_port: 9168
12
11
  # Maximum amount of memory to use in megabytes, after which the process is killed
@@ -22,16 +22,18 @@ Gem::Specification.new do |s|
22
22
 
23
23
  s.required_ruby_version = Gem::Requirement.new(">= 3.0")
24
24
 
25
+ # base64 is required by sidekiq but not declared by it, and is no longer a
26
+ # default gem as of Ruby 3.4. It used to be pulled in transitively via
27
+ # sinatra -> rack-protection; declare it directly now that those are gone.
28
+ s.add_runtime_dependency "base64", "0.2.0"
25
29
  s.add_runtime_dependency "connection_pool", "2.5.5"
26
30
  s.add_runtime_dependency "deep_merge", "~> 1.2.2"
27
31
  s.add_runtime_dependency "faraday", "2.14.3"
28
32
  s.add_runtime_dependency "pg", "1.6.3"
29
- s.add_runtime_dependency "puma", "8.0.1"
30
33
  s.add_runtime_dependency "quantile", "0.2.1"
31
34
  s.add_runtime_dependency "redis", "4.8.1"
32
35
  s.add_runtime_dependency "redis-namespace", "1.11.0"
33
36
  s.add_runtime_dependency "sidekiq", "6.5.12"
34
- s.add_runtime_dependency "sinatra", "~> 3.2.0"
35
37
  s.add_runtime_dependency "webrick", "~> 1.9"
36
38
 
37
39
  s.add_development_dependency "rspec", "~> 3.12.0"
@@ -1,107 +1,147 @@
1
1
  -- Originally from: https://github.com/ioguix/pgsql-bloat-estimation/blob/master/btree/btree_bloat.sql
2
- -- WARNING: executed with a non-superuser role, the query inspect only index on tables you are granted to read.
2
+ --
3
+ -- Rewritten to use CROSS JOIN LATERAL stages so this query stays in sync with the
4
+ -- `postgres_index_bloat_estimate(p_schema, p_idxname)` function used by the main GitLab
5
+ -- application's reindexing pipeline:
6
+ -- https://gitlab.com/gitlab-org/gitlab/-/blob/master/db/migrate/20260526120000_add_postgres_index_bloat_estimate_function.rb
7
+ --
8
+ -- Expanding the index columns inside a LATERAL join (instead of putting a set-returning
9
+ -- function such as generate_series in the SELECT list) avoids the ProjectSet barrier that
10
+ -- prevents the planner from pushing predicates down. See:
11
+ -- https://gitlab.com/gitlab-org/gitlab/-/work_items/569363
12
+ -- https://gitlab.com/gitlab-org/gitlab/-/work_items/599210
13
+ --
14
+ -- WARNING: executed with a non-superuser role, the query inspects only indexes on tables you are granted to read.
3
15
  -- WARNING: rows with is_na = 't' are known to have bad statistics ("name" type is not supported).
4
- -- This query is compatible with PostgreSQL 12 and after
5
- -- pg_stats is materialized so the planner sees the true row count before planning the join,
6
- -- avoiding a catastrophic cardinality underestimate on PostgreSQL 17.
7
- WITH pg_stats_cached AS MATERIALIZED (
8
- SELECT schemaname, tablename, attname, null_frac, avg_width
9
- FROM pg_catalog.pg_stats
10
- )
11
- SELECT current_database(), nspname AS schemaname, tblname, idxname AS object_name, bs*(relpages)::bigint AS real_size,
12
- bs*(relpages-est_pages)::bigint AS extra_size,
13
- 100 * (relpages-est_pages)::float / relpages AS extra_ratio,
14
- fillfactor,
15
- CASE WHEN relpages > est_pages_ff
16
- THEN bs*(relpages-est_pages_ff)
16
+ -- This query is compatible with PostgreSQL 9.3 and after (LATERAL joins).
17
+ --
18
+ -- Note: unlike the previous ioguix-style query, this form does NOT need to materialize
19
+ -- pg_stats to stay fast on PostgreSQL 17. The old query joined pg_stats once as a single
20
+ -- large join that the PG17 planner badly underestimated (see f18ac4b); here pg_stats is
21
+ -- looked up per index inside the LATERAL, resolving through the pg_statistic indexes, so
22
+ -- there is no single join for the planner to misestimate.
23
+ SELECT
24
+ current_database(),
25
+ n.nspname AS schemaname,
26
+ ct.relname AS tblname,
27
+ ci.relname AS object_name,
28
+ c.bs * ci.relpages::bigint AS real_size,
29
+ c.bs * (ci.relpages - bloat.est_pages)::bigint AS extra_size,
30
+ 100 * (ci.relpages - bloat.est_pages)::float / ci.relpages AS extra_ratio,
31
+ c.fillfactor,
32
+ CASE WHEN ci.relpages > bloat.est_pages_ff
33
+ THEN c.bs * (ci.relpages - bloat.est_pages_ff)
17
34
  ELSE 0
18
35
  END AS bloat_size,
19
- 100 * (relpages-est_pages_ff)::float / relpages AS bloat_ratio,
20
- is_na
21
- -- , 100-(pst).avg_leaf_density AS pst_avg_bloat, est_pages, index_tuple_hdr_bm, maxalign, pagehdr, nulldatawidth, nulldatahdrwidth, reltuples, relpages -- (DEBUG INFO)
22
- FROM (
23
- SELECT coalesce(1 +
24
- ceil(reltuples/floor((bs-pageopqdata-pagehdr)/(4+nulldatahdrwidth)::float)), 0 -- ItemIdData size + computed avg size of a tuple (nulldatahdrwidth)
25
- ) AS est_pages,
26
- coalesce(1 +
27
- ceil(reltuples/floor((bs-pageopqdata-pagehdr)*fillfactor/(100*(4+nulldatahdrwidth)::float))), 0
28
- ) AS est_pages_ff,
29
- bs, nspname, tblname, idxname, relpages, fillfactor, is_na
30
- -- , pgstatindex(idxoid) AS pst, index_tuple_hdr_bm, maxalign, pagehdr, nulldatawidth, nulldatahdrwidth, reltuples -- (DEBUG INFO)
31
- FROM (
32
- SELECT maxalign, bs, nspname, tblname, idxname, reltuples, relpages, idxoid, fillfactor,
33
- ( index_tuple_hdr_bm +
34
- maxalign - CASE -- Add padding to the index tuple header to align on MAXALIGN
35
- WHEN index_tuple_hdr_bm%maxalign = 0 THEN maxalign
36
- ELSE index_tuple_hdr_bm%maxalign
37
- END
38
- + nulldatawidth + maxalign - CASE -- Add padding to the data to align on MAXALIGN
39
- WHEN nulldatawidth = 0 THEN 0
40
- WHEN nulldatawidth::integer%maxalign = 0 THEN maxalign
41
- ELSE nulldatawidth::integer%maxalign
42
- END
43
- )::numeric AS nulldatahdrwidth, pagehdr, pageopqdata, is_na
44
- -- , index_tuple_hdr_bm, nulldatawidth -- (DEBUG INFO)
45
- FROM (
46
- SELECT n.nspname, i.tblname, i.idxname, i.reltuples, i.relpages,
47
- i.idxoid, i.fillfactor, current_setting('block_size')::numeric AS bs,
48
- CASE -- MAXALIGN: 4 on 32bits, 8 on 64bits (and mingw32 ?)
49
- WHEN version() ~ 'mingw32' OR version() ~ '64-bit|x86_64|ppc64|ia64|amd64' THEN 8
50
- ELSE 4
51
- END AS maxalign,
52
- /* per page header, fixed size: 20 for 7.X, 24 for others */
53
- 24 AS pagehdr,
54
- /* per page btree opaque data */
55
- 16 AS pageopqdata,
56
- /* per tuple header: add IndexAttributeBitMapData if some cols are null-able */
57
- CASE WHEN max(coalesce(s.null_frac,0)) = 0
58
- THEN 2 -- IndexTupleData size
59
- ELSE 2 + (( 32 + 8 - 1 ) / 8) -- IndexTupleData size + IndexAttributeBitMapData size ( max num filed per index + 8 - 1 /8)
60
- END AS index_tuple_hdr_bm,
61
- /* data len: we remove null values save space using it fractionnal part from stats */
62
- sum( (1-coalesce(s.null_frac, 0)) * coalesce(s.avg_width, 1024)) AS nulldatawidth,
63
- max( CASE WHEN i.atttypid = 'pg_catalog.name'::regtype THEN 1 ELSE 0 END ) > 0 AS is_na
64
- FROM (
65
- SELECT ct.relname AS tblname, ct.relnamespace, ic.idxname, ic.attpos, ic.indkey, ic.indkey[ic.attpos], ic.reltuples, ic.relpages, ic.tbloid, ic.idxoid, ic.fillfactor,
66
- coalesce(a1.attnum, a2.attnum) AS attnum, coalesce(a1.attname, a2.attname) AS attname, coalesce(a1.atttypid, a2.atttypid) AS atttypid,
67
- CASE WHEN a1.attnum IS NULL
68
- THEN ic.idxname
69
- ELSE ct.relname
70
- END AS attrelname
71
- FROM (
72
- SELECT idxname, reltuples, relpages, tbloid, idxoid, fillfactor, indkey,
73
- pg_catalog.generate_series(1,indnatts) AS attpos
74
- FROM (
75
- SELECT ci.relname AS idxname, ci.reltuples, ci.relpages, i.indrelid AS tbloid,
76
- i.indexrelid AS idxoid,
77
- coalesce(substring(
78
- array_to_string(ci.reloptions, ' ')
79
- from 'fillfactor=([0-9]+)')::smallint, 90) AS fillfactor,
80
- i.indnatts,
81
- pg_catalog.string_to_array(pg_catalog.textin(
82
- pg_catalog.int2vectorout(i.indkey)),' ')::int[] AS indkey
83
- FROM pg_catalog.pg_index i
84
- JOIN pg_catalog.pg_class ci ON ci.oid = i.indexrelid
85
- WHERE ci.relam=(SELECT oid FROM pg_am WHERE amname = 'btree')
86
- AND ci.relpages > 0
87
- ) AS idx_data
88
- ) AS ic
89
- JOIN pg_catalog.pg_class ct ON ct.oid = ic.tbloid
90
- LEFT JOIN pg_catalog.pg_attribute a1 ON
91
- ic.indkey[ic.attpos] <> 0
92
- AND a1.attrelid = ic.tbloid
93
- AND a1.attnum = ic.indkey[ic.attpos]
94
- LEFT JOIN pg_catalog.pg_attribute a2 ON
95
- ic.indkey[ic.attpos] = 0
96
- AND a2.attrelid = ic.idxoid
97
- AND a2.attnum = ic.attpos
98
- ) i
99
- JOIN pg_catalog.pg_namespace n ON n.oid = i.relnamespace
100
- JOIN pg_stats_cached s ON s.schemaname = n.nspname
101
- AND s.tablename = i.attrelname
102
- AND s.attname = i.attname
103
- GROUP BY 1,2,3,4,5,6,7,8,9,10,11
104
- ) AS rows_data_stats
105
- ) AS rows_hdr_pdg_stats
106
- ) AS relation_stats
107
- ORDER BY nspname, tblname, idxname;
36
+ 100 * (ci.relpages - bloat.est_pages_ff)::float / ci.relpages AS bloat_ratio,
37
+ bloat.is_na
38
+ FROM pg_catalog.pg_class ci
39
+ JOIN pg_catalog.pg_index i ON i.indexrelid = ci.oid
40
+ JOIN pg_catalog.pg_class ct ON ct.oid = i.indrelid
41
+ JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace
42
+
43
+ -- Per-index system constants and fillfactor.
44
+ CROSS JOIN LATERAL (
45
+ SELECT
46
+ current_setting('block_size')::numeric AS bs,
47
+ CASE -- MAXALIGN: 4 on 32bits, 8 on 64bits (and mingw32 ?)
48
+ WHEN version() ~ 'mingw32'::text
49
+ OR version() ~ '64-bit|x86_64|ppc64|ia64|amd64'::text THEN 8
50
+ ELSE 4
51
+ END AS maxalign,
52
+ 24 AS pagehdr, -- per page header, fixed size: 24 for 8.3 and after
53
+ 16 AS pageopqdata, -- per page btree opaque data
54
+ COALESCE(
55
+ substring(array_to_string(ci.reloptions, ' '::text) FROM 'fillfactor=([0-9]+)')::smallint::integer,
56
+ 90
57
+ ) AS fillfactor
58
+ ) c
59
+
60
+ -- Aggregate the per-column statistics for this index, expanding the index columns
61
+ -- through a LATERAL generate_series rather than a SELECT-list set-returning function.
62
+ CROSS JOIN LATERAL (
63
+ SELECT
64
+ max(CASE WHEN COALESCE(a1.atttypid, a2.atttypid) = 'pg_catalog.name'::regtype THEN 1 ELSE 0 END) > 0 AS is_na,
65
+ /* per tuple header: add IndexAttributeBitMapData if some cols are null-able */
66
+ CASE WHEN max(COALESCE(s_t.null_frac, s_x.null_frac, 0::real)) = 0::double precision
67
+ THEN 2 -- IndexTupleData size
68
+ ELSE 2 + (32 + 8 - 1) / 8 -- IndexTupleData size + IndexAttributeBitMapData size
69
+ END AS index_tuple_hdr_bm,
70
+ /* data len: we remove null values, saving space, using their fractional part from stats */
71
+ sum((1::double precision - COALESCE(s_t.null_frac, s_x.null_frac, 0::real))
72
+ * COALESCE(s_t.avg_width, s_x.avg_width, 1024)::double precision) AS nulldatawidth
73
+ FROM generate_series(1, i.indnatts::integer) AS gs(attpos)
74
+ -- Decode the indkey int2vector into an int[] once per index (not once per column).
75
+ CROSS JOIN LATERAL (
76
+ SELECT string_to_array(textin(int2vectorout(i.indkey)), ' '::text)::integer[] AS indkey_arr
77
+ ) ik
78
+ LEFT JOIN pg_catalog.pg_attribute a1
79
+ ON ik.indkey_arr[gs.attpos] <> 0
80
+ AND a1.attrelid = i.indrelid
81
+ AND a1.attnum = ik.indkey_arr[gs.attpos]
82
+ LEFT JOIN pg_catalog.pg_attribute a2
83
+ ON ik.indkey_arr[gs.attpos] = 0
84
+ AND a2.attrelid = ci.oid
85
+ AND a2.attnum = gs.attpos
86
+ LEFT JOIN pg_catalog.pg_stats s_t
87
+ ON s_t.schemaname = n.nspname
88
+ AND s_t.tablename = ct.relname
89
+ AND s_t.attname = a1.attname
90
+ LEFT JOIN pg_catalog.pg_stats s_x
91
+ ON s_x.schemaname = n.nspname
92
+ AND s_x.tablename = ci.relname
93
+ AND s_x.attname = a2.attname
94
+ ) agg
95
+
96
+ -- Index tuple header width, aligned on MAXALIGN.
97
+ CROSS JOIN LATERAL (
98
+ SELECT
99
+ (
100
+ (agg.index_tuple_hdr_bm + c.maxalign
101
+ - CASE -- Add padding to the index tuple header to align on MAXALIGN
102
+ WHEN (agg.index_tuple_hdr_bm % c.maxalign) = 0 THEN c.maxalign
103
+ ELSE agg.index_tuple_hdr_bm % c.maxalign
104
+ END)::double precision
105
+ + agg.nulldatawidth
106
+ + c.maxalign::double precision
107
+ - CASE -- Add padding to the data to align on MAXALIGN
108
+ WHEN agg.nulldatawidth = 0::double precision THEN 0
109
+ WHEN (agg.nulldatawidth::integer % c.maxalign) = 0 THEN c.maxalign
110
+ ELSE agg.nulldatawidth::integer % c.maxalign
111
+ END::double precision
112
+ )::numeric AS nulldatahdrwidth
113
+ ) hdr
114
+
115
+ -- Estimated number of pages, with and without the fillfactor applied.
116
+ CROSS JOIN LATERAL (
117
+ SELECT
118
+ agg.is_na,
119
+ -- NULLIF guards against division by zero: floor(...) reaches 0 once
120
+ -- nulldatahdrwidth exceeds the usable page size (an index whose columns are
121
+ -- entirely unanalyzed and fall back to the 1024-byte default width). Without
122
+ -- it, PostgreSQL raises a hard error that would fail the whole probe; instead
123
+ -- the NULL flows through the outer COALESCE to est_pages = 0.
124
+ COALESCE(
125
+ 1::double precision + ceil(
126
+ ci.reltuples / NULLIF(floor(
127
+ (c.bs - c.pageopqdata::numeric - c.pagehdr::numeric)::double precision
128
+ / (4::numeric + hdr.nulldatahdrwidth)::double precision
129
+ ), 0)
130
+ ),
131
+ 0::double precision
132
+ ) AS est_pages,
133
+ COALESCE(
134
+ 1::double precision + ceil(
135
+ ci.reltuples / NULLIF(floor(
136
+ ((c.bs - c.pageopqdata::numeric - c.pagehdr::numeric) * c.fillfactor::numeric)::double precision
137
+ / (100::double precision * (4::numeric + hdr.nulldatahdrwidth)::double precision)
138
+ ), 0)
139
+ ),
140
+ 0::double precision
141
+ ) AS est_pages_ff
142
+ ) bloat
143
+
144
+ WHERE ci.relkind = 'i'
145
+ AND ci.relam = (SELECT oid FROM pg_am WHERE amname = 'btree'::name)
146
+ AND ci.relpages > 0
147
+ ORDER BY n.nspname, ct.relname, ci.relname;
@@ -1,5 +1,5 @@
1
1
  module GitLab
2
2
  module Exporter
3
- VERSION = "16.9.0".freeze
3
+ VERSION = "17.0.0".freeze
4
4
  end
5
5
  end
@@ -1,146 +1,138 @@
1
- require "sinatra/base"
1
+ require "webrick"
2
2
  require "English"
3
3
  require "cgi"
4
+ require "stringio"
4
5
 
5
- require_relative "rack_vulndb_255039_patch"
6
6
  require_relative "tls_helper"
7
7
 
8
8
  module GitLab
9
9
  module Exporter
10
- # Metrics web exporter
11
- class WebExporter < Sinatra::Base
12
- # A middleware to kill the process if we exceeded a certain threshold
13
- class MemoryKillerMiddleware
14
- def initialize(app, memory_threshold)
15
- @app = app
16
- @memory_threshold = memory_threshold.to_i * 1024
17
- end
18
-
19
- def call(env)
20
- if memory_usage > @memory_threshold
21
- puts "Memory usage of #{memory_usage} exceeded threshold of #{@memory_threshold}, signalling KILL"
22
- Process.kill("KILL", $PID)
23
- end
24
-
25
- @app.call(env)
26
- end
27
-
28
- private
29
-
30
- def memory_usage
31
- io = IO.popen(%W[ps -o rss= -p #{$PID}])
32
-
33
- mem = io.read
34
- io.close
35
-
36
- return 0 unless $CHILD_STATUS.to_i.zero?
37
-
38
- mem.to_i
39
- end
40
- end
41
-
42
- # Performs a major GC after each request. We found that this helps to free up
43
- # several MB of memory in conjunction with sricter malloc config.
44
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/297241
45
- class RunGC
46
- def initialize(app)
47
- @app = app
48
- end
49
-
50
- def call(env)
51
- @app.call(env).tap do
52
- GC.start
53
- end
54
- end
55
- end
10
+ # Metrics web exporter backed directly by WEBrick (no Sinatra/Rack).
11
+ class WebExporter
12
+ DEFAULT_WEB_SERVER = "webrick".freeze
13
+ DEFAULT_LISTEN_ADDRESS = "0.0.0.0".freeze
14
+ DEFAULT_LISTEN_PORT = 9168
15
+ DEFAULT_MEMORY_THRESHOLD = 1024
16
+
17
+ # Security response headers previously added by Sinatra's Rack::Protection
18
+ # middleware, which is gone now that we serve directly via WEBrick. Kept for
19
+ # parity so scanners and consumers see the same headers as before.
20
+ SECURITY_HEADERS = {
21
+ "X-Content-Type-Options" => "nosniff",
22
+ "X-Frame-Options" => "SAMEORIGIN",
23
+ "X-XSS-Protection" => "1; mode=block"
24
+ }.freeze
56
25
 
57
26
  class << self
58
27
  include TLSHelper
59
28
 
60
- DEFAULT_WEB_SERVER = "webrick".freeze
61
-
62
29
  def setup(config)
63
- setup_server(config[:server])
64
- setup_probes(config[:probes])
30
+ server_config = config[:server] || {}
65
31
 
66
- memory_threshold = (config[:server] && config[:server][:memory_threshold]) || 1024
67
- use MemoryKillerMiddleware, memory_threshold
68
- use Rack::Logger
69
- use RunGC
32
+ memory_threshold = server_config.fetch(:memory_threshold, DEFAULT_MEMORY_THRESHOLD)
33
+ @memory_threshold = memory_threshold.to_i * 1024
34
+
35
+ @server = build_server(server_config)
36
+ mount_probes(config[:probes])
70
37
 
71
38
  # Defrag heap after everything is loaded into memory.
72
39
  GC.compact
73
40
  end
74
41
 
75
- def logger
76
- request.logger
42
+ def run!
43
+ trap_signals
44
+ @server.start
77
45
  end
78
46
 
79
- def setup_server(config)
80
- config ||= {}
47
+ private
81
48
 
82
- set(:server, config.fetch(:name, DEFAULT_WEB_SERVER))
83
- set(:port, config.fetch(:listen_port, 9168))
49
+ def build_server(config)
50
+ warn_deprecated_server(config[:name])
51
+
52
+ options = {
53
+ Port: config.fetch(:listen_port, DEFAULT_LISTEN_PORT),
54
+ BindAddress: config.fetch(:listen_address, DEFAULT_LISTEN_ADDRESS)
55
+ }
84
56
 
85
- # Depending on whether TLS is enabled or not, bind string
86
- # will be different.
87
57
  if config.fetch(:tls_enabled, "false").to_s == "true"
88
- set_tls_config(config)
89
- else
90
- set(:bind, config.fetch(:listen_address, "0.0.0.0"))
58
+ validate_tls_config(config)
59
+ options.merge!(webrick_tls_config(config))
91
60
  end
61
+
62
+ WEBrick::HTTPServer.new(**options)
92
63
  end
93
64
 
94
- def set_tls_config(config) # rubocop:disable Naming/AccessorMethodName
95
- validate_tls_config(config)
65
+ # The web server used to be configurable (e.g. Puma) via the `server.name`
66
+ # setting when the exporter ran on top of Sinatra. It now always uses WEBrick,
67
+ # so the setting is ignored. Warn operators whose config still sets it.
68
+ #
69
+ # TODO: This is step one of a two-step deprecation. In a future major release,
70
+ # raise instead of warning so the ignored `server.name` setting fails loudly.
71
+ def warn_deprecated_server(name)
72
+ return if name.nil? || name.to_s == DEFAULT_WEB_SERVER
73
+
74
+ warn "DEPRECATION: `server.name` (#{name.inspect}) is no longer supported and is ignored; " \
75
+ "the exporter now always uses WEBrick. Please remove `server.name` from your config."
76
+ end
96
77
 
97
- web_server = config.fetch(:name, DEFAULT_WEB_SERVER)
98
- if web_server == "webrick"
99
- set_webrick_tls(config)
100
- elsif web_server == "puma"
101
- set_puma_tls(config)
102
- else
103
- fail "TLS not supported for web server `#{web_server}`."
78
+ def mount_probes(probes)
79
+ (probes || {}).each do |probe_name, params|
80
+ opts =
81
+ if params.delete(:multiple)
82
+ params
83
+ else
84
+ { probe_name => params }
85
+ end
86
+
87
+ @server.mount_proc("/#{probe_name}") do |_request, response|
88
+ handle_probe(response, opts)
89
+ end
104
90
  end
105
91
  end
106
92
 
107
- def set_webrick_tls(config) # rubocop:disable Naming/AccessorMethodName
108
- server_settings = {}
109
- server_settings.merge!(webrick_tls_config(config))
93
+ def handle_probe(response, opts)
94
+ # A memory killer that kills the process if we exceeded a certain threshold.
95
+ check_memory_threshold!
110
96
 
111
- set(:bind, config.fetch(:listen_address, "0.0.0.0"))
112
- set(:server_settings, server_settings)
113
- end
97
+ prober = Prober.new(metrics: PrometheusMetrics.new(include_timestamp: false), logger: @server.logger, **opts)
98
+ prober.probe_all
99
+
100
+ buffer = StringIO.new
101
+ prober.write_to(buffer)
114
102
 
115
- def set_puma_tls(config) # rubocop:disable Naming/AccessorMethodName
116
- listen_address = config.fetch(:listen_address, "0.0.0.0")
117
- listen_port = config.fetch(:listen_port, 8443)
118
- tls_cert_path = CGI.escape(config.fetch(:tls_cert_path))
119
- tls_key_path = CGI.escape(config.fetch(:tls_key_path))
103
+ SECURITY_HEADERS.each do |header, value|
104
+ response[header] = value
105
+ end
106
+ response.content_type = "text/plain; version=0.0.4; charset=utf-8"
107
+ response.body = buffer.string
108
+ ensure
109
+ # Performs a major GC after each request. We found that this helps to free up
110
+ # several MB of memory in conjunction with stricter malloc config.
111
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/297241
112
+ GC.start
113
+ end
120
114
 
121
- bind_string = "ssl://#{listen_address}:#{listen_port}?cert=#{tls_cert_path}&key=#{tls_key_path}"
115
+ def check_memory_threshold!
116
+ usage = memory_usage
117
+ return if usage <= @memory_threshold
122
118
 
123
- set(:bind, bind_string)
119
+ puts "Memory usage of #{usage} exceeded threshold of #{@memory_threshold}, signalling KILL"
120
+ Process.kill("KILL", $PID)
124
121
  end
125
122
 
126
- def setup_probes(config)
127
- (config || {}).each do |probe_name, params|
128
- opts =
129
- if params.delete(:multiple)
130
- params
131
- else
132
- { probe_name => params }
133
- end
123
+ def memory_usage
124
+ # Use the block form so the pipe is closed automatically even if the
125
+ # read raises, avoiding a file-descriptor leak.
126
+ mem = IO.popen(%W[ps -o rss= -p #{$PID}], &:read)
134
127
 
135
- get "/#{probe_name}" do
136
- content_type "text/plain; version=0.0.4; charset=utf-8"
137
- prober = Prober.new(metrics: PrometheusMetrics.new(include_timestamp: false), logger: logger, **opts)
128
+ return 0 unless $CHILD_STATUS.to_i.zero?
138
129
 
139
- prober.probe_all
140
- prober.write_to(response)
130
+ mem.to_i
131
+ end
141
132
 
142
- response
143
- end
133
+ def trap_signals
134
+ %w[INT TERM].each do |signal|
135
+ trap(signal) { @server.shutdown }
144
136
  end
145
137
  end
146
138
  end
@@ -0,0 +1,127 @@
1
+ require "spec_helper"
2
+ require "gitlab_exporter/web_exporter"
3
+ require "net/http"
4
+ require "timeout"
5
+
6
+ describe GitLab::Exporter::WebExporter do
7
+ # Each test builds a fresh server via `.setup`; drop the reference afterwards
8
+ # so class-level state does not leak between examples.
9
+ after do
10
+ described_class.instance_variable_set(:@server, nil)
11
+ end
12
+
13
+ describe ".setup" do
14
+ let(:probes) { {} }
15
+
16
+ it "builds a WEBrick server with the configured port and bind address" do
17
+ options = capture_server_options(server: { listen_address: "127.0.0.1", listen_port: 12_345 })
18
+
19
+ expect(options).to include(Port: 12_345, BindAddress: "127.0.0.1")
20
+ end
21
+
22
+ it "falls back to the default port and bind address" do
23
+ options = capture_server_options({})
24
+
25
+ expect(options).to include(Port: 9168, BindAddress: "0.0.0.0")
26
+ end
27
+
28
+ it "mounts a handler for each configured probe" do
29
+ server = instance_double(WEBrick::HTTPServer)
30
+ allow(WEBrick::HTTPServer).to receive(:new).and_return(server)
31
+
32
+ expect(server).to receive(:mount_proc).with("/ruby")
33
+
34
+ described_class.setup(server: {}, probes: { ruby: { methods: %w[probe_gc], opts: {} } })
35
+ end
36
+
37
+ it "raises when TLS is enabled but no certificate is configured" do
38
+ expect {
39
+ described_class.setup(server: { tls_enabled: true }, probes: probes)
40
+ }.to raise_error(/tls_cert_path not specified/)
41
+ end
42
+
43
+ # Capture the keyword options passed to WEBrick without binding a real socket.
44
+ def capture_server_options(config)
45
+ captured = nil
46
+ allow(WEBrick::HTTPServer).to receive(:new) do |**options|
47
+ captured = options
48
+ instance_double(WEBrick::HTTPServer)
49
+ end
50
+
51
+ described_class.setup(config.merge(probes: {}))
52
+ captured
53
+ end
54
+ end
55
+
56
+ describe "deprecating the server.name setting" do
57
+ it "warns when a non-WEBrick server is requested" do
58
+ expect {
59
+ described_class.send(:warn_deprecated_server, "puma")
60
+ }.to output(/DEPRECATION.*no longer supported/).to_stderr
61
+ end
62
+
63
+ it "does not warn when the server is webrick" do
64
+ expect { described_class.send(:warn_deprecated_server, "webrick") }.not_to output.to_stderr
65
+ end
66
+
67
+ it "does not warn when no server name is set" do
68
+ expect { described_class.send(:warn_deprecated_server, nil) }.not_to output.to_stderr
69
+ end
70
+ end
71
+
72
+ describe "serving probe requests" do
73
+ let(:config) do
74
+ {
75
+ server: { listen_address: "127.0.0.1", listen_port: 0 },
76
+ probes: { ruby: { methods: %w[probe_gc], opts: {} } }
77
+ }
78
+ end
79
+
80
+ let(:server) { described_class.instance_variable_get(:@server) }
81
+ let(:base_uri) { "http://127.0.0.1:#{server.listeners.first.addr[1]}" }
82
+
83
+ before do
84
+ # WEBrick binds its logger and access log to $stderr at construction time
85
+ # and emits a startup banner; silence $stderr around setup so the log
86
+ # device is a throwaway buffer and the test output stays clean.
87
+ original_stderr = $stderr
88
+ $stderr = StringIO.new
89
+ begin
90
+ described_class.setup(config)
91
+ ensure
92
+ $stderr = original_stderr
93
+ end
94
+
95
+ @server_thread = Thread.new { server.start }
96
+ Timeout.timeout(5) { sleep 0.01 until server.status == :Running }
97
+ end
98
+
99
+ after do
100
+ server.shutdown
101
+ @server_thread&.join(5)
102
+ end
103
+
104
+ it "returns Prometheus metrics for a known probe" do
105
+ response = Net::HTTP.get_response(URI("#{base_uri}/ruby"))
106
+
107
+ expect(response.code).to eq("200")
108
+ expect(response.content_type).to eq("text/plain")
109
+ expect(response["Content-Type"]).to include("version=0.0.4")
110
+ expect(response.body).to match(/ruby_gc_stat_count \d+/)
111
+ end
112
+
113
+ it "sets the security headers previously provided by Rack::Protection" do
114
+ response = Net::HTTP.get_response(URI("#{base_uri}/ruby"))
115
+
116
+ expect(response["X-Content-Type-Options"]).to eq("nosniff")
117
+ expect(response["X-Frame-Options"]).to eq("SAMEORIGIN")
118
+ expect(response["X-XSS-Protection"]).to eq("1; mode=block")
119
+ end
120
+
121
+ it "returns 404 for unknown paths" do
122
+ response = Net::HTTP.get_response(URI("#{base_uri}/does-not-exist"))
123
+
124
+ expect(response.code).to eq("404")
125
+ end
126
+ end
127
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-exporter
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.9.0
4
+ version: 17.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pablo Carranza
@@ -10,6 +10,20 @@ bindir: bin
10
10
  cert_chain: []
11
11
  date: 2016-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.0
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: connection_pool
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -66,20 +80,6 @@ dependencies:
66
80
  - - '='
67
81
  - !ruby/object:Gem::Version
68
82
  version: 1.6.3
69
- - !ruby/object:Gem::Dependency
70
- name: puma
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - '='
74
- - !ruby/object:Gem::Version
75
- version: 8.0.1
76
- type: :runtime
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - '='
81
- - !ruby/object:Gem::Version
82
- version: 8.0.1
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: quantile
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -136,20 +136,6 @@ dependencies:
136
136
  - - '='
137
137
  - !ruby/object:Gem::Version
138
138
  version: 6.5.12
139
- - !ruby/object:Gem::Dependency
140
- name: sinatra
141
- requirement: !ruby/object:Gem::Requirement
142
- requirements:
143
- - - "~>"
144
- - !ruby/object:Gem::Version
145
- version: 3.2.0
146
- type: :runtime
147
- prerelease: false
148
- version_requirements: !ruby/object:Gem::Requirement
149
- requirements:
150
- - - "~>"
151
- - !ruby/object:Gem::Version
152
- version: 3.2.0
153
139
  - !ruby/object:Gem::Dependency
154
140
  name: webrick
155
141
  requirement: !ruby/object:Gem::Requirement
@@ -233,7 +219,6 @@ files:
233
219
  - lib/gitlab_exporter/prober.rb
234
220
  - lib/gitlab_exporter/process.rb
235
221
  - lib/gitlab_exporter/prometheus.rb
236
- - lib/gitlab_exporter/rack_vulndb_255039_patch.rb
237
222
  - lib/gitlab_exporter/ruby.rb
238
223
  - lib/gitlab_exporter/sidekiq.rb
239
224
  - lib/gitlab_exporter/tls_helper.rb
@@ -258,6 +243,7 @@ files:
258
243
  - spec/sidekiq_spec.rb
259
244
  - spec/spec_helper.rb
260
245
  - spec/util_spec.rb
246
+ - spec/web_exporter_spec.rb
261
247
  homepage: https://gitlab.com/gitlab-org/ruby/gems/gitlab-exporter
262
248
  licenses:
263
249
  - MIT
@@ -300,3 +286,4 @@ test_files:
300
286
  - spec/sidekiq_spec.rb
301
287
  - spec/spec_helper.rb
302
288
  - spec/util_spec.rb
289
+ - spec/web_exporter_spec.rb
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- if Gem.loaded_specs["rack"].version >= Gem::Version.new("3.0.0")
4
- fail <<~ERR
5
- This patch is unnecessary in Rack versions 3.0.0 or newer.
6
- Please remove this file and the associated spec.
7
-
8
- See https://github.com/rack/rack/blob/main/CHANGELOG.md#security (issue #1733)
9
- ERR
10
- end
11
-
12
- # Patches a cache poisoning attack vector in Rack by not allowing semicolons
13
- # to delimit query parameters.
14
- # See https://github.com/rack/rack/issues/1732.
15
- #
16
- # Solution is taken from the same issue.
17
- #
18
- # The actual patch is due for release in Rack 3.0.0.
19
- module Rack
20
- class Request # rubocop:disable Style/Documentation
21
- Helpers.module_eval do
22
- # rubocop: disable Naming/MethodName
23
- def GET
24
- if get_header(RACK_REQUEST_QUERY_STRING) == query_string
25
- get_header(RACK_REQUEST_QUERY_HASH)
26
- else
27
- query_hash = parse_query(query_string, "&") # only allow ampersand here
28
- set_header(RACK_REQUEST_QUERY_STRING, query_string)
29
- set_header(RACK_REQUEST_QUERY_HASH, query_hash)
30
- end
31
- end
32
- # rubocop: enable Naming/MethodName
33
- end
34
- end
35
- end