# frozen_string_literal: true require 'spec_helper' RSpec.describe Gitlab::Ci::RunnerReleases do subject { described_class.instance } let(:runner_releases_url) { 'the release API URL' } def releases subject.releases end def releases_by_minor subject.releases_by_minor end before do subject.reset_backoff! stub_application_setting(public_runner_releases_url: runner_releases_url) end describe 'caching behavior', :use_clean_rails_memory_store_caching do before do allow(Gitlab::HTTP).to receive(:get).with(runner_releases_url, anything).once { mock_http_response(response) } end shared_examples 'requests that follow cache status' do |validity_period| context "almost #{validity_period.inspect} later" do let(:followup_request_interval) { validity_period - 0.001.seconds } it 'returns cached releases' do releases travel followup_request_interval do expect(Gitlab::HTTP).not_to receive(:get) if expected_releases expected_result_by_minor = expected_releases.group_by(&:without_patch).transform_values(&:max) end expect(releases).to eq(expected_releases) expect(releases_by_minor).to eq(expected_result_by_minor) end end end context "after #{validity_period.inspect}" do let(:followup_request_interval) { validity_period + 1.second } let(:followup_response) { (response || []) + [{ 'name' => 'v14.9.2' }] } it 'checks new releases' do releases travel followup_request_interval do expect(Gitlab::HTTP).to receive(:get) .with(runner_releases_url, anything) .once { mock_http_response(followup_response) } new_releases = (expected_releases || []) + [Gitlab::VersionInfo.new(14, 9, 2)] new_releases_by_minor_version = (expected_releases_by_minor || {}).merge( Gitlab::VersionInfo.new(14, 9, 0) => Gitlab::VersionInfo.new(14, 9, 2) ) expect(releases).to eq(new_releases) expect(releases_by_minor).to eq(new_releases_by_minor_version) end end end end shared_examples 'a service implementing exponential backoff' do |opts| it 'performs exponential backoff on requests', :aggregate_failures do start_time = Time.now.utc.change(usec: 0) http_call_timestamp_offsets = [] allow(Gitlab::HTTP).to receive(:get).with(runner_releases_url, anything) do http_call_timestamp_offsets << Time.now.utc - start_time raise Net::OpenTimeout if opts&.dig(:raise_timeout) mock_http_response(response) end # An initial HTTP request fails travel_to(start_time) subject.reset_backoff! expect(releases).to be_nil expect(releases_by_minor).to be_nil # Successive failed requests result in HTTP requests only after specific backoff periods backoff_periods = [5, 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600].map(&:seconds) backoff_periods.each do |period| travel(period - 1.second) expect(releases).to be_nil expect(releases_by_minor).to be_nil travel 1.second expect(releases).to be_nil expect(releases_by_minor).to be_nil end expect(http_call_timestamp_offsets).to eq([0, 5, 15, 35, 75, 155, 315, 635, 1275, 2555, 5115, 8715]) # Finally a successful HTTP request results in releases being returned allow(Gitlab::HTTP).to receive(:get) .with(runner_releases_url, anything) .once { mock_http_response([{ 'name' => 'v14.9.1-beta1-ee' }]) } travel 1.hour expect(releases).not_to be_nil expect(releases_by_minor).not_to be_nil end end context 'when request results in timeout' do let(:response) { } let(:expected_releases) { nil } let(:expected_releases_by_minor) { nil } it_behaves_like 'requests that follow cache status', 5.seconds it_behaves_like 'a service implementing exponential backoff', raise_timeout: true end context 'when response is nil' do let(:response) { nil } let(:expected_releases) { nil } let(:expected_releases_by_minor) { nil } it_behaves_like 'requests that follow cache status', 5.seconds it_behaves_like 'a service implementing exponential backoff' end context 'when response is not nil' do let(:response) { [{ 'name' => 'v14.9.1-beta1-ee' }, { 'name' => 'v14.9.0' }] } let(:expected_releases) do [ Gitlab::VersionInfo.new(14, 9, 0), Gitlab::VersionInfo.new(14, 9, 1, '-beta1-ee') ] end let(:expected_releases_by_minor) do { Gitlab::VersionInfo.new(14, 9, 0) => Gitlab::VersionInfo.new(14, 9, 1, '-beta1-ee') } end it_behaves_like 'requests that follow cache status', 1.day end end describe '#releases', :use_clean_rails_memory_store_caching do before do allow(Gitlab::HTTP).to receive(:get).with(runner_releases_url, anything).once { mock_http_response(response) } end context 'when response is nil' do let(:response) { nil } let(:expected_result) { nil } it 'returns nil' do expect(releases).to be_nil end end context 'when response is not nil' do let(:response) { [{ 'name' => 'v14.9.1-beta1-ee' }, { 'name' => 'v14.9.0' }] } let(:expected_result) do [ Gitlab::VersionInfo.new(14, 9, 0), Gitlab::VersionInfo.new(14, 9, 1, '-beta1-ee') ] end it 'returns parsed and sorted Gitlab::VersionInfo objects' do expect(releases).to eq(expected_result) end end context 'when response contains unexpected input type' do let(:response) { 'error' } it { expect(releases).to be_nil } end context 'when response contains unexpected input array' do let(:response) { ['error'] } it { expect(releases).to be_nil } end end describe '#releases_by_minor', :use_clean_rails_memory_store_caching do before do allow(Gitlab::HTTP).to receive(:get).with(runner_releases_url, anything).once { mock_http_response(response) } end context 'when response is nil' do let(:response) { nil } let(:expected_result) { nil } it 'returns nil' do expect(releases_by_minor).to be_nil end end context 'when response is not nil' do let(:response) { [{ 'name' => 'v14.9.1-beta1-ee' }, { 'name' => 'v14.9.0' }, { 'name' => 'v14.8.1' }] } let(:expected_result) do { Gitlab::VersionInfo.new(14, 8, 0) => Gitlab::VersionInfo.new(14, 8, 1), Gitlab::VersionInfo.new(14, 9, 0) => Gitlab::VersionInfo.new(14, 9, 1, '-beta1-ee') } end it 'returns parsed and grouped Gitlab::VersionInfo objects' do expect(releases_by_minor).to eq(expected_result) end end context 'when response contains unexpected input type' do let(:response) { 'error' } it { expect(releases_by_minor).to be_nil } end context 'when response contains unexpected input array' do let(:response) { ['error'] } it { expect(releases_by_minor).to be_nil } end end def mock_http_response(response) http_response = instance_double(HTTParty::Response) allow(http_response).to receive(:success?).and_return(!response.nil?) allow(http_response).to receive(:parsed_response).and_return(response) http_response end end