gitlab-test-balancing 0.0.0 → 0.1.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 +4 -4
- data/lib/gitlab/test_balancing/client.rb +148 -0
- data/lib/gitlab/test_balancing/runner/rspec.rb +198 -0
- data/lib/gitlab/test_balancing/version.rb +7 -0
- data/lib/gitlab-test-balancing.rb +9 -1
- metadata +72 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d0847c642c34fc38dbbb8fe6f468c7c906d04a5d756b08d92b0c1fd8f9b715f0
|
|
4
|
+
data.tar.gz: af8f00f37096a128eb0d0cd0ecc7b190ef15915d660ef64eb7c1c4a0825b9ba5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ff4da41f151730ca3d72413bcbce9872c4f3947e0682196b1acdbd1f565d525626287bdd8e055020c6bd9e194b7e5facb0394c918eb9b38faf65437fdfff358
|
|
7
|
+
data.tar.gz: 2fa0983c324d7d90edcbfbf1d809f7de1a1adfbe3ceb92bf6635ea4ffce6196b0c296d1f5fceb5fdfdd82ecfa3ec741f5f0a864adcb98da6a67361306517562f
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module Gitlab
|
|
10
|
+
module TestBalancing
|
|
11
|
+
# HTTP client for the test balancing REST API.
|
|
12
|
+
#
|
|
13
|
+
# Talks to the `/job/test_balancing` endpoints on a GitLab instance,
|
|
14
|
+
# authenticated with a CI/CD job token. A parallel job seeds the job group's
|
|
15
|
+
# shared pending pool with its static test split, then repeatedly requests
|
|
16
|
+
# duration-budgeted batches of test splits until the pool is drained.
|
|
17
|
+
class Client
|
|
18
|
+
Error = Class.new(StandardError)
|
|
19
|
+
|
|
20
|
+
# Raised when the API responds 404, which the server returns when test
|
|
21
|
+
# balancing is unavailable for the project (feature flag off or unlicensed).
|
|
22
|
+
# Callers use this to fall back to a static split.
|
|
23
|
+
FeatureUnavailableError = Class.new(Error)
|
|
24
|
+
|
|
25
|
+
ServerError = Class.new(Error)
|
|
26
|
+
private_constant :ServerError
|
|
27
|
+
|
|
28
|
+
Result = Struct.new(:mode, :test_splits, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
OPEN_TIMEOUT = 10
|
|
31
|
+
READ_TIMEOUT = 30
|
|
32
|
+
MAX_ATTEMPTS = 3
|
|
33
|
+
RETRY_BACKOFF = 2 # seconds, multiplied by the attempt number
|
|
34
|
+
|
|
35
|
+
# Transient errors worth retrying: network failures and 5xx responses.
|
|
36
|
+
# Other HTTP error responses (e.g. 404/422) are not retried.
|
|
37
|
+
RETRIABLE_ERRORS = [
|
|
38
|
+
Net::OpenTimeout,
|
|
39
|
+
Net::ReadTimeout,
|
|
40
|
+
Errno::ECONNRESET,
|
|
41
|
+
Errno::ECONNREFUSED,
|
|
42
|
+
Errno::EHOSTUNREACH,
|
|
43
|
+
Errno::ENETUNREACH,
|
|
44
|
+
SocketError,
|
|
45
|
+
IOError,
|
|
46
|
+
OpenSSL::SSL::SSLError,
|
|
47
|
+
ServerError
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
def initialize(api_url: ENV['CI_API_V4_URL'], job_token: ENV['CI_JOB_TOKEN'], logger: nil)
|
|
51
|
+
@api_url = api_url&.chomp('/')
|
|
52
|
+
@job_token = job_token
|
|
53
|
+
@logger = logger
|
|
54
|
+
@run_id = SecureRandom.uuid
|
|
55
|
+
@seq = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# test_splits: array of { path:, expected_duration: }. expected_duration may
|
|
59
|
+
# be omitted; the server defaults it. Returns a Result with mode ("seed" or
|
|
60
|
+
# "retry") and the test splits the node should run first.
|
|
61
|
+
def initialize_balancing(test_splits)
|
|
62
|
+
response = post('initialize', { test_splits: test_splits })
|
|
63
|
+
|
|
64
|
+
Result.new(mode: response['mode'], test_splits: symbolize_test_splits(response))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns the next batch of test splits (array), empty when the queue is drained.
|
|
68
|
+
#
|
|
69
|
+
# Retries of this call reuse the run_id and seq, so the server returns the
|
|
70
|
+
# same batch if it already claimed one but the response never arrived, and
|
|
71
|
+
# ignores a late attempt from an earlier call.
|
|
72
|
+
def request
|
|
73
|
+
@seq += 1
|
|
74
|
+
|
|
75
|
+
symbolize_test_splits(post('request', { run_id: run_id, seq: @seq }))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
attr_reader :api_url, :job_token, :logger, :run_id
|
|
81
|
+
|
|
82
|
+
def post(action, body)
|
|
83
|
+
uri = URI.parse("#{api_url}/job/test_balancing/#{action}")
|
|
84
|
+
|
|
85
|
+
request = Net::HTTP::Post.new(uri)
|
|
86
|
+
request['Content-Type'] = 'application/json'
|
|
87
|
+
request['JOB-TOKEN'] = job_token
|
|
88
|
+
request.body = JSON.dump(body)
|
|
89
|
+
|
|
90
|
+
response = with_retries(action) do
|
|
91
|
+
net_http_class.start(
|
|
92
|
+
uri.hostname, uri.port,
|
|
93
|
+
use_ssl: uri.scheme == 'https', open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT
|
|
94
|
+
) do |http|
|
|
95
|
+
http.request(request).tap do |res|
|
|
96
|
+
raise ServerError, "#{res.code} #{res.body}" if res.is_a?(Net::HTTPServerError)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
raise FeatureUnavailableError, "test balancing is unavailable (404) for #{action}" if
|
|
102
|
+
response.is_a?(Net::HTTPNotFound)
|
|
103
|
+
|
|
104
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
105
|
+
raise Error, "test balancing #{action} failed: #{response.code} #{response.body}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
JSON.parse(response.body)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Retry the HTTP request on transient errors with linear backoff.
|
|
112
|
+
# Network failures and 5xx responses are retried; other HTTP error
|
|
113
|
+
# responses are handled by the caller and are not retried here.
|
|
114
|
+
def with_retries(action)
|
|
115
|
+
attempt = 0
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
attempt += 1
|
|
119
|
+
yield
|
|
120
|
+
rescue *RETRIABLE_ERRORS => e
|
|
121
|
+
raise Error, "test balancing #{action} failed after #{MAX_ATTEMPTS} attempts: #{e.class}: #{e.message}" if
|
|
122
|
+
attempt >= MAX_ATTEMPTS
|
|
123
|
+
|
|
124
|
+
logger&.info("test balancing #{action} attempt #{attempt} failed (#{e.class}), retrying...")
|
|
125
|
+
sleep(RETRY_BACKOFF * attempt)
|
|
126
|
+
retry
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def symbolize_test_splits(response)
|
|
131
|
+
Array(response['test_splits']).map { |split| split.transform_keys(&:to_sym) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# When WebMock is loaded (e.g. the client runs inside a spec suite that
|
|
135
|
+
# enabled WebMock), use the original, un-stubbed Net::HTTP it saved so our
|
|
136
|
+
# own API calls hit the network without touching WebMock's global config or
|
|
137
|
+
# tripping the suite's request stubs. Falls back to plain Net::HTTP when
|
|
138
|
+
# WebMock is absent.
|
|
139
|
+
def net_http_class
|
|
140
|
+
if defined?(::WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP)
|
|
141
|
+
::WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP
|
|
142
|
+
else
|
|
143
|
+
Net::HTTP
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gitlab/test_balancing/client'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module TestBalancing
|
|
7
|
+
module Runner
|
|
8
|
+
# Drives a dynamic, API-balanced RSpec run for a single parallel node.
|
|
9
|
+
#
|
|
10
|
+
# Seeds this node's static test split into the shared pool via the API, then
|
|
11
|
+
# pulls and runs batches until the queue drains, keeping ONE reporter open
|
|
12
|
+
# for the whole lane so counts, the `Failures:` block and rerun snippets
|
|
13
|
+
# print once at the end.
|
|
14
|
+
#
|
|
15
|
+
# This class owns only the RSpec lifecycle and the queue-drain loop; it is
|
|
16
|
+
# project-agnostic. The caller computes the static split (however it likes)
|
|
17
|
+
# and passes it in. `#run` returns a Result and never calls `exit`, so the
|
|
18
|
+
# caller controls process termination.
|
|
19
|
+
#
|
|
20
|
+
# Each pulled batch is driven through the full `RSpec::Core::Runner#run_specs`
|
|
21
|
+
# lifecycle
|
|
22
|
+
# (https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/runner.rb),
|
|
23
|
+
# loading the batch's files together (the server caps a claim at a small
|
|
24
|
+
# number of files).
|
|
25
|
+
#
|
|
26
|
+
# The only deviation from `run_specs` is keeping ONE outer `reporter.report`
|
|
27
|
+
# across all batches, and `with_suite_hooks` wrapping the whole lane so
|
|
28
|
+
# before(:suite)/after(:suite) run once.
|
|
29
|
+
class Rspec
|
|
30
|
+
# status: process exit status (0 on success).
|
|
31
|
+
# unavailable: true when the feature was unavailable (404)
|
|
32
|
+
Result = Struct.new(:status, :unavailable, keyword_init: true) do
|
|
33
|
+
def passed?
|
|
34
|
+
status.zero?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def unavailable?
|
|
38
|
+
unavailable
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
NULL_LOGGER = Object.new.tap do |logger|
|
|
43
|
+
def logger.info(*); end
|
|
44
|
+
def logger.warn(*); end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# test_splits: array of { path:, expected_duration: } for this node.
|
|
48
|
+
# rspec_args: array of RSpec CLI args;
|
|
49
|
+
# any trailing file list (after `--`) is stripped since files are supplied dynamically per batch.
|
|
50
|
+
def initialize(test_splits:, rspec_args: [], client: nil, logger: NULL_LOGGER)
|
|
51
|
+
@test_splits = test_splits
|
|
52
|
+
@rspec_args = rspec_args
|
|
53
|
+
@logger = logger
|
|
54
|
+
@client = client || Client.new(logger: logger)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run
|
|
58
|
+
first_batch = seed_and_claim_first_batch
|
|
59
|
+
return Result.new(status: 0, unavailable: true) if first_batch.nil?
|
|
60
|
+
|
|
61
|
+
boot_rspec
|
|
62
|
+
all_passed = drain_with_report(first_batch)
|
|
63
|
+
|
|
64
|
+
Result.new(status: exit_code(all_passed), unavailable: false)
|
|
65
|
+
ensure
|
|
66
|
+
# Persist even if the run raised (e.g. a profiler blowing up during
|
|
67
|
+
# teardown), so scripts can still --only-failures retry the specs that
|
|
68
|
+
# actually ran. Guarded by @config so an early return/crash is safe.
|
|
69
|
+
persist_example_statuses if @config
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
attr_reader :test_splits, :rspec_args, :client, :logger
|
|
75
|
+
|
|
76
|
+
def seed_and_claim_first_batch
|
|
77
|
+
logger.info "Seeding #{test_splits.size} test splits into the test balancing pool."
|
|
78
|
+
result = client.initialize_balancing(test_splits)
|
|
79
|
+
logger.info "Test balancing initialized in '#{result.mode}' mode."
|
|
80
|
+
|
|
81
|
+
result.test_splits
|
|
82
|
+
rescue Client::FeatureUnavailableError
|
|
83
|
+
logger.info 'Test balancing is unavailable, falling back to static split.'
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def boot_rspec
|
|
88
|
+
require 'rspec/core'
|
|
89
|
+
|
|
90
|
+
@options = ::RSpec::Core::ConfigurationOptions.new(base_rspec_args)
|
|
91
|
+
::RSpec::Core::Runner.new(@options).configure($stderr, $stdout)
|
|
92
|
+
|
|
93
|
+
@config = ::RSpec.configuration
|
|
94
|
+
@world = ::RSpec.world
|
|
95
|
+
|
|
96
|
+
# We need to alter world.example_groups for each iteration and world.all_examples
|
|
97
|
+
# only ever reflects the current batch. So we keep our own record of executed examples.
|
|
98
|
+
@executed_examples = []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def drain_with_report(first_batch)
|
|
102
|
+
# One report cycle for the entire lane. The true example count is unknown
|
|
103
|
+
# up front (files arrive dynamically), so we pass 0 as the expected count;
|
|
104
|
+
# progress/summary formatters treat this as "unknown total".
|
|
105
|
+
@config.reporter.report(0) do |reporter|
|
|
106
|
+
@reporter = reporter
|
|
107
|
+
@config.with_suite_hooks do # before(:suite)/after(:suite) run once for the lane
|
|
108
|
+
drain_queue(first_batch)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Run the first batch, then keep requesting and running batches until the
|
|
114
|
+
# queue is drained. Returns true when every example across every batch passed.
|
|
115
|
+
#
|
|
116
|
+
# Stops pulling once RSpec sets world.wants_to_quit (fail-fast limit met or
|
|
117
|
+
# an interrupt), mirroring how Runner#run guards run_specs.
|
|
118
|
+
def drain_queue(first_batch)
|
|
119
|
+
all_passed = true
|
|
120
|
+
batch = first_batch
|
|
121
|
+
|
|
122
|
+
loop do
|
|
123
|
+
all_passed = false unless run_batch(batch)
|
|
124
|
+
|
|
125
|
+
break if @world.wants_to_quit
|
|
126
|
+
|
|
127
|
+
batch = client.request
|
|
128
|
+
break if batch.empty?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
all_passed
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Run a whole pulled batch through the full `run_specs` lifecycle against
|
|
135
|
+
# the shared reporter. Returns true if every example in the batch passed.
|
|
136
|
+
def run_batch(batch)
|
|
137
|
+
paths = batch.map { |split| split[:path] }
|
|
138
|
+
expected_duration = batch.sum { |split| split[:expected_duration].to_f }
|
|
139
|
+
logger.info "Pulled #{paths.size} test splits (expected duration: #{expected_duration.round(2)}s) " \
|
|
140
|
+
"from queue:\n#{paths.join("\n")}"
|
|
141
|
+
|
|
142
|
+
@world.reset
|
|
143
|
+
|
|
144
|
+
# Setup a fresh FilterManager for every batch as
|
|
145
|
+
# Rspec's announce_filters may mutate the filter manager when
|
|
146
|
+
# run_all_when_everything_filtered is enabled.
|
|
147
|
+
filter_manager = ::RSpec::Core::FilterManager.new
|
|
148
|
+
@options.configure_filter_manager(filter_manager)
|
|
149
|
+
@config.filter_manager = filter_manager
|
|
150
|
+
|
|
151
|
+
@config.files_or_directories_to_run = paths
|
|
152
|
+
@config.load_spec_files
|
|
153
|
+
@world.announce_filters
|
|
154
|
+
|
|
155
|
+
groups = @world.ordered_example_groups
|
|
156
|
+
@world.example_count(groups) # force filtered_examples computation, as run_specs does
|
|
157
|
+
|
|
158
|
+
passed = groups.map { |group| group.run(@reporter) }.all?
|
|
159
|
+
|
|
160
|
+
# Capture this batch's examples before the next iteration's world.reset
|
|
161
|
+
@executed_examples.concat(@world.all_examples)
|
|
162
|
+
|
|
163
|
+
passed
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Mirrors RSpec::Core::Runner#exit_code: errors raised outside of examples
|
|
167
|
+
# (e.g. a before(:suite) hook, or "errors occurred outside of examples")
|
|
168
|
+
# fail the run even when every example passed.
|
|
169
|
+
def exit_code(examples_passed)
|
|
170
|
+
return @config.error_exit_code || @config.failure_exit_code if @world.non_example_failure
|
|
171
|
+
return @config.failure_exit_code unless examples_passed
|
|
172
|
+
|
|
173
|
+
0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Reproduces RSpec::Core::Runner#persist_example_statuses, which the queue
|
|
177
|
+
# loop bypasses (it never calls Runner#run). Writes the accumulated example
|
|
178
|
+
# statuses to config.example_status_persistence_file_path in RSpec's native
|
|
179
|
+
# format so --only-failures retries work unchanged. ExampleStatusPersister
|
|
180
|
+
# merges with, and file-locks, the existing file, so a single end-of-lane
|
|
181
|
+
# call is safe.
|
|
182
|
+
def persist_example_statuses
|
|
183
|
+
path = @config.example_status_persistence_file_path
|
|
184
|
+
return if path.nil? || @executed_examples.empty?
|
|
185
|
+
|
|
186
|
+
::RSpec::Core::ExampleStatusPersister.persist(@executed_examples, path)
|
|
187
|
+
rescue SystemCallError => e
|
|
188
|
+
logger.warn "Could not write example statuses to #{path}: #{e.inspect}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def base_rspec_args
|
|
192
|
+
idx = rspec_args.index('--')
|
|
193
|
+
idx ? rspec_args[0...idx] : rspec_args
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
# rubocop:disable Naming/FileName -- required to match the gem name for the default require
|
|
2
|
+
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'gitlab/test_balancing/version'
|
|
6
|
+
require_relative 'gitlab/test_balancing/client'
|
|
7
|
+
require_relative 'gitlab/test_balancing/runner/rspec'
|
|
8
|
+
|
|
9
|
+
# rubocop:enable Naming/FileName
|
metadata
CHANGED
|
@@ -1,15 +1,73 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab-test-balancing
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
- GitLab
|
|
7
|
+
- GitLab Engineers
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
11
|
+
date: 2026-11-06 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: gitlab-styles
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 13.0.1
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 13.0.1
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: webmock
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.18'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.18'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: webrick
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.8'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.8'
|
|
69
|
+
description: Distributes tests across the nodes of a parallel CI/CD job using the
|
|
70
|
+
GitLab test balancing API
|
|
13
71
|
email:
|
|
14
72
|
- engineering@gitlab.com
|
|
15
73
|
executables: []
|
|
@@ -17,11 +75,15 @@ extensions: []
|
|
|
17
75
|
extra_rdoc_files: []
|
|
18
76
|
files:
|
|
19
77
|
- lib/gitlab-test-balancing.rb
|
|
20
|
-
|
|
78
|
+
- lib/gitlab/test_balancing/client.rb
|
|
79
|
+
- lib/gitlab/test_balancing/runner/rspec.rb
|
|
80
|
+
- lib/gitlab/test_balancing/version.rb
|
|
81
|
+
homepage: https://gitlab.com/gitlab-org/ruby/gems/gitlab-test-balancing
|
|
21
82
|
licenses:
|
|
22
83
|
- MIT
|
|
23
84
|
metadata:
|
|
24
85
|
rubygems_mfa_required: 'true'
|
|
86
|
+
post_install_message:
|
|
25
87
|
rdoc_options: []
|
|
26
88
|
require_paths:
|
|
27
89
|
- lib
|
|
@@ -29,14 +91,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
29
91
|
requirements:
|
|
30
92
|
- - ">="
|
|
31
93
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '3.
|
|
94
|
+
version: '3.2'
|
|
33
95
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
34
96
|
requirements:
|
|
35
97
|
- - ">="
|
|
36
98
|
- !ruby/object:Gem::Version
|
|
37
99
|
version: '0'
|
|
38
100
|
requirements: []
|
|
39
|
-
rubygems_version:
|
|
101
|
+
rubygems_version: 3.5.22
|
|
102
|
+
signing_key:
|
|
40
103
|
specification_version: 4
|
|
41
|
-
summary:
|
|
104
|
+
summary: Client for the GitLab test balancing API
|
|
42
105
|
test_files: []
|