diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 9e319810..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @splitio/sdk diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index f7bac809..00000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,9 +0,0 @@ -# Ruby SDK - -## What did you accomplish? - - -## How to test new changes? - - -## Extra Notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 347d1b34..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: ci -on: - pull_request: - branches: - - development - - master - push: - branches: - - development - - master - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - test: - name: Test - runs-on: ubuntu-latest - services: - redis: - image: redis - ports: - - 6379:6379 - strategy: - fail-fast: false - matrix: - version: - - '2.5.0' - - '3.2.2' - - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Set up Java - uses: actions/setup-java@v2 - with: - java-version: 17 - distribution: "temurin" - - - name: Setup Ruby ${{ matrix.version }} - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.version }} - - - name: Install dependencies - run: bundle install - - - name: Run tests - run: bundle exec rake - - - name: Fix code coverage paths - working-directory: coverage - run: | - sed -i 's@'$GITHUB_WORKSPACE'@/github/workspace/@g' .resultset.json - ruby -rjson -e 'sqube = JSON.load(File.read(".resultset.json"))["RSpec"]["coverage"].transform_values {|lines| lines["lines"]}; total = { "RSpec" => { "coverage" => sqube, "timestamp" => Time.now.to_i }}; puts JSON.dump(total)' > .resultset.sonarqube.json - - - name: Set VERSION env - run: echo "VERSION=$(cat lib/splitclient-rb/version.rb | grep VERSION | awk -F "'" '{print $2}')" >> $GITHUB_ENV - - - name: SonarQube Scan (Push) - if: matrix.version == '3.2.2' && github.event_name == 'push' - uses: SonarSource/sonarcloud-github-action@v5.0.0 - env: - SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - projectBaseDir: . - args: > - -Dsonar.host.url=${{ secrets.SONARQUBE_HOST }} - -Dsonar.projectVersion=${{ env.VERSION }} - - - name: SonarQube Scan (Pull Request) - if: matrix.version == '3.2.2' && github.event_name == 'pull_request' - uses: SonarSource/sonarcloud-github-action@v5.0.0 - env: - SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - projectBaseDir: . - args: > - -Dsonar.java.source=17 - -Dsonar.host.url=${{ secrets.SONARQUBE_HOST }} - -Dsonar.projectVersion=${{ env.VERSION }} - -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} - -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} - -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} diff --git a/.github/workflows/update-license-year.yml b/.github/workflows/update-license-year.yml deleted file mode 100644 index 9e582543..00000000 --- a/.github/workflows/update-license-year.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Update License Year - -on: - schedule: - - cron: "0 3 1 1 *" # 03:00 AM on January 1 - -permissions: - contents: write - pull-requests: write - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Set Current year - run: "echo CURRENT=$(date +%Y) >> $GITHUB_ENV" - - - name: Set Previous Year - run: "echo PREVIOUS=$(($CURRENT-1)) >> $GITHUB_ENV" - - - name: Update LICENSE - uses: jacobtomlinson/gha-find-replace@v2 - with: - find: ${{ env.PREVIOUS }} - replace: ${{ env.CURRENT }} - include: "LICENSE" - regex: false - - - name: Commit files - run: | - git config user.name 'github-actions[bot]' - git config user.email 'github-actions[bot]@users.noreply.github.com' - git commit -m "Updated License Year" -a - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - title: Update License Year - branch: update-license diff --git a/.gitignore b/.gitignore index dede6f6e..7785f3f1 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ dump.rdb release.sh .scannerwork +docs +examples \ No newline at end of file diff --git a/.harness/orgs/PROD/projects/Harness_Split/pipelines/rubyclientrepo/input_sets/rubyclient.yaml b/.harness/orgs/PROD/projects/Harness_Split/pipelines/rubyclientrepo/input_sets/rubyclient.yaml new file mode 100644 index 00000000..2412fee9 --- /dev/null +++ b/.harness/orgs/PROD/projects/Harness_Split/pipelines/rubyclientrepo/input_sets/rubyclient.yaml @@ -0,0 +1,14 @@ +inputSet: + name: ruby-client + identifier: rubyclient + orgIdentifier: PROD + projectIdentifier: Harness_Split + pipeline: + identifier: rubyclientrepo + properties: + ci: + codebase: + build: + type: PR + spec: + number: <+trigger.prNumber> diff --git a/.harness/pipeline.yaml b/.harness/pipeline.yaml new file mode 100644 index 00000000..1b1d6f0e --- /dev/null +++ b/.harness/pipeline.yaml @@ -0,0 +1,182 @@ +pipeline: + name: ruby-client-repo + identifier: rubyclientrepo + projectIdentifier: Harness_Split + orgIdentifier: PROD + tags: {} + properties: + ci: + codebase: + connectorRef: fmegithubharnessgitops + repoName: ruby-client + build: + type: branch + spec: + branch: <+input> + stages: + - stage: + name: Build and Test + identifier: Build_and_Test + description: "" + type: CI + spec: + cloneCodebase: true + caching: + enabled: true + override: true + platform: + os: Linux + arch: Amd64 + runtime: + type: Cloud + spec: + size: flex + execution: + steps: + - step: + type: Run + name: Install Ruby + identifier: Install_Ruby + spec: + shell: Bash + command: |- + curl https://mise.run | sh + export PATH="$HOME/.local/bin:$PATH" + mise use --global ruby@<+matrix.rubyVersion> + ruby --version + gem --version + - step: + type: Run + name: Start Redis + identifier: Start_Redis + spec: + shell: Sh + command: |- + apt-get install -y redis-server -qq + redis-server --daemonize yes --port 6379 + redis-cli ping + - step: + type: Run + name: Wait for Redis + identifier: Wait_for_Redis + spec: + shell: Sh + command: |- + for i in $(seq 1 15); do + redis-cli ping && break + echo "Waiting for Redis... attempt $i" + sleep 1 + done + - step: + type: Run + name: Install Dependencies + identifier: Install_Dependencies + spec: + shell: Bash + command: |- + export PATH="$HOME/.local/bin:$PATH" + eval "$(mise activate bash)" + gem install bundler:2.4.19 --no-document + bundle install --jobs 4 --retry 3 + - step: + type: Run + name: Run Tests + identifier: Run_Tests + spec: + shell: Bash + command: |- + export PATH="$HOME/.local/bin:$PATH" + eval "$(mise activate bash)" + bundle exec rake + - step: + type: Run + name: Fix Coverage Paths + identifier: Fix_Coverage_Paths + when: + stageStatus: Success + condition: <+matrix.rubyVersion> == "3.2.2" + spec: + shell: Bash + command: |- + export PATH="$HOME/.local/bin:$PATH" + eval "$(mise activate bash)" + cd coverage + sed -i "s@${HARNESS_WORKSPACE}@/harness/workspace/@g" .resultset.json + ruby -rjson -e ' + sqube = JSON.load(File.read(".resultset.json"))["RSpec"]["coverage"].transform_values {|lines| lines["lines"]} + total = { "RSpec" => { "coverage" => sqube, "timestamp" => Time.now.to_i }} + puts JSON.dump(total) + ' > .resultset.sonarqube.json + - step: + type: Run + name: SonarQube Scan + identifier: SonarQube_Scan + when: + stageStatus: Success + condition: <+matrix.rubyVersion> == "3.2.2" + spec: + shell: Sh + command: |- + export SONAR_SCANNER_VERSION=5.0.1.3006 + curl -sSLo sonar-scanner.zip \ + "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-linux.zip" + unzip -q sonar-scanner.zip + export PATH="$PWD/sonar-scanner-${SONAR_SCANNER_VERSION}-linux/bin:$PATH" + apt-get install -y openjdk-17-jdk -qq + sonar-scanner \ + -Dsonar.projectKey=ruby-client \ + -Dsonar.sources=lib \ + -Dsonar.tests=spec \ + -Dsonar.host.url=https://sonar.harness.io \ + -Dsonar.token=<+secrets.getValue("sonarqube-token")> \ + -Dsonar.ruby.coverage.reportPaths=coverage/.resultset.sonarqube.json \ + -Dsonar.coverage.exclusions="spec/**,**/vendor/**" \ + -Dsonar.exclusions="**/vendor/**,**/.git/**" + - step: + type: Run + name: Post Quality Gate + identifier: Post_Quality_Gate + when: + stageStatus: Success + condition: <+matrix.rubyVersion> == "3.2.2" + spec: + shell: Bash + command: |- + #!/bin/bash + set -e + + SONAR_TOKEN="<+secrets.getValue("sonarqube-token")>" + SONAR_URL="https://sonar.harness.io" + SONAR_PROJECT_KEY="ruby-client" + GITHUB_TOKEN="<+secrets.getValue("github-devops-token")>" + GITHUB_REPO="ruby-client" + COMMIT_SHA="<+codebase.commitSha>" + + STATUS_JSON=$(curl -sf \ + -H "Authorization: Bearer ${SONAR_TOKEN}" \ + "${SONAR_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}") + + QG_STATUS=$(echo "$STATUS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['projectStatus']['status'])") + + case "$QG_STATUS" in + OK) GH_STATE="success"; DESCRIPTION="Quality gate passed" ;; + ERROR) GH_STATE="failure"; DESCRIPTION="Quality gate failed" ;; + WARN) GH_STATE="success"; DESCRIPTION="Quality gate passed with warnings" ;; + *) GH_STATE="pending"; DESCRIPTION="Quality gate status unknown" ;; + esac + + TARGET_URL="${SONAR_URL}/dashboard?id=${SONAR_PROJECT_KEY}" + + curl -sf -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + -H "Content-Type: application/json" \ + -d "{\"state\":\"${GH_STATE}\",\"description\":\"${DESCRIPTION}\",\"context\":\"sonarqube/quality-gate\",\"target_url\":\"${TARGET_URL}\"}" \ + "https://api.github.com/repos/splitio/${GITHUB_REPO}/statuses/${COMMIT_SHA}" + + echo "Posted SonarQube quality gate status: ${GH_STATE}" + strategy: + matrix: + rubyVersion: + - "2.7.8" + - "3.2.2" diff --git a/spec/engine/sync_manager_spec.rb b/spec/engine/sync_manager_spec.rb index c3771f3b..c4fa9139 100644 --- a/spec/engine/sync_manager_spec.rb +++ b/spec/engine/sync_manager_spec.rb @@ -95,24 +95,27 @@ it 'start sync manager with wrong sse host url and non connect to server, must start polling.' do ENV['SPLITCLIENT_ENV'] = "prod" - mock_server do |server| - server.setup_response('/') do |_, res| - send_content(res, 'content') - end + begin + mock_server do |server| + server.setup_response('/') do |_, res| + send_content(res, 'content') + end - config.streaming_service_url = 'https://fake-sse.io' - config.connection_timeout = 1 + config.streaming_service_url = 'https://fake-sse.io' + config.connection_timeout = 1 - sync_manager = subject.new(config, synchronizer, telemetry_runtime_producer, telemetry_synchronizer, status_manager, sse_handler, push_manager, push_status_queue) - sync_manager.start + sync_manager = subject.new(config, synchronizer, telemetry_runtime_producer, telemetry_synchronizer, status_manager, sse_handler, push_manager, push_status_queue) + sync_manager.start - sleep(2) - expect(a_request(:get, 'https://sdk.split.io/api/splitChanges?s=1.3&since=-1&rbSince=-1')).to have_been_made.once + sleep(2) + expect(a_request(:get, 'https://sdk.split.io/api/splitChanges?s=1.3&since=-1&rbSince=-1')).to have_been_made.once - expect(config.threads.size).to eq(9) - config.threads.values.each { |thread| Thread.kill(thread) } + expect(config.threads.size).to eq(9) + config.threads.values.each { |thread| Thread.kill(thread) } + end + ensure + ENV['SPLITCLIENT_ENV'] = "test" end - ENV['SPLITCLIENT_ENV'] = "test" end it 'start sync manager receiving control message, must switch to polling' do diff --git a/spec/integrations/push_client_spec.rb b/spec/integrations/push_client_spec.rb index ca3a317f..099d7e2e 100644 --- a/spec/integrations/push_client_spec.rb +++ b/spec/integrations/push_client_spec.rb @@ -180,9 +180,11 @@ end it 'processing split iff update event' do + no_change_response = '{"ff":{"d":[],"s":1585948850110,"t":1585948850110},"rbs":{"d":[],"s":-1,"t":-1}}' mock_splits_request(splits, -1) mock_splits_request(splits2, '1585948850109') - mock_splits_request(splits3, '1585948850110') + stub_request(:get, "https://sdk.split.io/api/splitChanges?s=1.3&since=1585948850110&rbSince=-1") + .to_return(status: 200, body: no_change_response) mock_segment_changes('segment3', segment3, '-1') stub_request(:post, 'https://telemetry.split.io/api/v1/metrics/config').to_return(status: 200, body: '') stub_request(:get, "https://sdk.split.io/api/splitChanges?s=1.3&since=1585948850111&rbSince=-1").to_return(status: 200, body: '') diff --git a/spec/integrations/redis_client_spec.rb b/spec/integrations/redis_client_spec.rb index 5197fde4..8cce586b 100644 --- a/spec/integrations/redis_client_spec.rb +++ b/spec/integrations/redis_client_spec.rb @@ -600,23 +600,24 @@ expect(impressions.size).to eq 3 - expect(impressions[0][:matching_key]).to eq('nico_test') - expect(impressions[0][:split_name]).to eq(:FACUNDO_TEST) - expect(impressions[0][:treatment][:treatment]).to eq('on') - expect(impressions[0][:treatment][:label]).to eq('whitelisted') - expect(impressions[0][:treatment][:change_number]).to eq(1_506_703_262_916) - - expect(impressions[2][:matching_key]).to eq('nico_test') - expect(impressions[2][:split_name]).to eq(:testing) - expect(impressions[2][:treatment][:treatment]).to eq('off') - expect(impressions[2][:treatment][:label]).to eq('in split test_definition_as_of treatment [off]') - expect(impressions[2][:treatment][:change_number]).to eq(1_506_440_189_077) - - expect(impressions[1][:matching_key]).to eq('nico_test') - expect(impressions[1][:split_name]).to eq(:testing222) - expect(impressions[1][:treatment][:treatment]).to eq('off') - expect(impressions[1][:treatment][:label]).to eq('in segment all') - expect(impressions[1][:treatment][:change_number]).to eq(1_505_162_627_437) + imp_facundo = impressions.find { |i| i[:split_name] == :FACUNDO_TEST } + imp_testing = impressions.find { |i| i[:split_name] == :testing } + imp_testing222 = impressions.find { |i| i[:split_name] == :testing222 } + + expect(imp_facundo[:matching_key]).to eq('nico_test') + expect(imp_facundo[:treatment][:treatment]).to eq('on') + expect(imp_facundo[:treatment][:label]).to eq('whitelisted') + expect(imp_facundo[:treatment][:change_number]).to eq(1_506_703_262_916) + + expect(imp_testing[:matching_key]).to eq('nico_test') + expect(imp_testing[:treatment][:treatment]).to eq('off') + expect(imp_testing[:treatment][:label]).to eq('in split test_definition_as_of treatment [off]') + expect(imp_testing[:treatment][:change_number]).to eq(1_506_440_189_077) + + expect(imp_testing222[:matching_key]).to eq('nico_test') + expect(imp_testing222[:treatment][:treatment]).to eq('off') + expect(imp_testing222[:treatment][:label]).to eq('in segment all') + expect(imp_testing222[:treatment][:change_number]).to eq(1_505_162_627_437) end it 'returns treatments with input validation' do @@ -694,23 +695,24 @@ expect(impressions.size).to eq 3 - expect(impressions[2][:matching_key]).to eq('nico_test') - expect(impressions[2][:split_name]).to eq(:FACUNDO_TEST) - expect(impressions[2][:treatment][:treatment]).to eq('on') - expect(impressions[2][:treatment][:label]).to eq('whitelisted') - expect(impressions[2][:treatment][:change_number]).to eq(1_506_703_262_916) + imp_facundo = impressions.find { |i| i[:split_name] == :FACUNDO_TEST } + imp_testing = impressions.find { |i| i[:split_name] == :testing } + imp_testing222 = impressions.find { |i| i[:split_name] == :testing222 } - expect(impressions[1][:matching_key]).to eq('nico_test') - expect(impressions[1][:split_name]).to eq(:testing) - expect(impressions[1][:treatment][:treatment]).to eq('off') - expect(impressions[1][:treatment][:label]).to eq('in split test_definition_as_of treatment [off]') - expect(impressions[1][:treatment][:change_number]).to eq(1_506_440_189_077) + expect(imp_facundo[:matching_key]).to eq('nico_test') + expect(imp_facundo[:treatment][:treatment]).to eq('on') + expect(imp_facundo[:treatment][:label]).to eq('whitelisted') + expect(imp_facundo[:treatment][:change_number]).to eq(1_506_703_262_916) - expect(impressions[0][:matching_key]).to eq('nico_test') - expect(impressions[0][:split_name]).to eq(:testing222) - expect(impressions[0][:treatment][:treatment]).to eq('off') - expect(impressions[0][:treatment][:label]).to eq('in segment all') - expect(impressions[0][:treatment][:change_number]).to eq(1_505_162_627_437) + expect(imp_testing[:matching_key]).to eq('nico_test') + expect(imp_testing[:treatment][:treatment]).to eq('off') + expect(imp_testing[:treatment][:label]).to eq('in split test_definition_as_of treatment [off]') + expect(imp_testing[:treatment][:change_number]).to eq(1_506_440_189_077) + + expect(imp_testing222[:matching_key]).to eq('nico_test') + expect(imp_testing222[:treatment][:treatment]).to eq('off') + expect(imp_testing222[:treatment][:label]).to eq('in segment all') + expect(imp_testing222[:treatment][:change_number]).to eq(1_505_162_627_437) end it 'returns treatments with input validation' do @@ -786,23 +788,24 @@ expect(impressions.size).to eq 3 - expect(impressions[0][:matching_key]).to eq('nico_test') - expect(impressions[0][:split_name]).to eq(:FACUNDO_TEST) - expect(impressions[0][:treatment][:treatment]).to eq('on') - expect(impressions[0][:treatment][:label]).to eq('whitelisted') - expect(impressions[0][:treatment][:change_number]).to eq(1_506_703_262_916) + imp_facundo = impressions.find { |i| i[:split_name] == :FACUNDO_TEST } + imp_testing = impressions.find { |i| i[:split_name] == :testing } + imp_testing222 = impressions.find { |i| i[:split_name] == :testing222 } - expect(impressions[2][:matching_key]).to eq('nico_test') - expect(impressions[2][:split_name]).to eq(:testing) - expect(impressions[2][:treatment][:treatment]).to eq('off') - expect(impressions[2][:treatment][:label]).to eq('in split test_definition_as_of treatment [off]') - expect(impressions[2][:treatment][:change_number]).to eq(1_506_440_189_077) + expect(imp_facundo[:matching_key]).to eq('nico_test') + expect(imp_facundo[:treatment][:treatment]).to eq('on') + expect(imp_facundo[:treatment][:label]).to eq('whitelisted') + expect(imp_facundo[:treatment][:change_number]).to eq(1_506_703_262_916) - expect(impressions[1][:matching_key]).to eq('nico_test') - expect(impressions[1][:split_name]).to eq(:testing222) - expect(impressions[1][:treatment][:treatment]).to eq('off') - expect(impressions[1][:treatment][:label]).to eq('in segment all') - expect(impressions[1][:treatment][:change_number]).to eq(1_505_162_627_437) + expect(imp_testing[:matching_key]).to eq('nico_test') + expect(imp_testing[:treatment][:treatment]).to eq('off') + expect(imp_testing[:treatment][:label]).to eq('in split test_definition_as_of treatment [off]') + expect(imp_testing[:treatment][:change_number]).to eq(1_506_440_189_077) + + expect(imp_testing222[:matching_key]).to eq('nico_test') + expect(imp_testing222[:treatment][:treatment]).to eq('off') + expect(imp_testing222[:treatment][:label]).to eq('in segment all') + expect(imp_testing222[:treatment][:change_number]).to eq(1_505_162_627_437) end it 'returns treatments with input validation' do @@ -880,23 +883,24 @@ expect(impressions.size).to eq 3 - expect(impressions[2][:matching_key]).to eq('nico_test') - expect(impressions[2][:split_name]).to eq(:FACUNDO_TEST) - expect(impressions[2][:treatment][:treatment]).to eq('on') - expect(impressions[2][:treatment][:label]).to eq('whitelisted') - expect(impressions[2][:treatment][:change_number]).to eq(1_506_703_262_916) + imp_facundo = impressions.find { |i| i[:split_name] == :FACUNDO_TEST } + imp_testing = impressions.find { |i| i[:split_name] == :testing } + imp_testing222 = impressions.find { |i| i[:split_name] == :testing222 } - expect(impressions[1][:matching_key]).to eq('nico_test') - expect(impressions[1][:split_name]).to eq(:testing) - expect(impressions[1][:treatment][:treatment]).to eq('off') - expect(impressions[1][:treatment][:label]).to eq('in split test_definition_as_of treatment [off]') - expect(impressions[1][:treatment][:change_number]).to eq(1_506_440_189_077) + expect(imp_facundo[:matching_key]).to eq('nico_test') + expect(imp_facundo[:treatment][:treatment]).to eq('on') + expect(imp_facundo[:treatment][:label]).to eq('whitelisted') + expect(imp_facundo[:treatment][:change_number]).to eq(1_506_703_262_916) - expect(impressions[0][:matching_key]).to eq('nico_test') - expect(impressions[0][:split_name]).to eq(:testing222) - expect(impressions[0][:treatment][:treatment]).to eq('off') - expect(impressions[0][:treatment][:label]).to eq('in segment all') - expect(impressions[0][:treatment][:change_number]).to eq(1_505_162_627_437) + expect(imp_testing[:matching_key]).to eq('nico_test') + expect(imp_testing[:treatment][:treatment]).to eq('off') + expect(imp_testing[:treatment][:label]).to eq('in split test_definition_as_of treatment [off]') + expect(imp_testing[:treatment][:change_number]).to eq(1_506_440_189_077) + + expect(imp_testing222[:matching_key]).to eq('nico_test') + expect(imp_testing222[:treatment][:treatment]).to eq('off') + expect(imp_testing222[:treatment][:label]).to eq('in segment all') + expect(imp_testing222[:treatment][:change_number]).to eq(1_505_162_627_437) end it 'returns treatments with input validation' do diff --git a/spec/splitclient/split_manager_spec.rb b/spec/splitclient/split_manager_spec.rb index 0f9bdd0e..abc2dd87 100644 --- a/spec/splitclient/split_manager_spec.rb +++ b/spec/splitclient/split_manager_spec.rb @@ -85,7 +85,7 @@ context '#splits' do it 'returns empty array and logs error when not ready' do - stub_request(:get, "https://sdk.split.io/api/splitChanges?s=1.3&since=-1") + stub_request(:get, "https://sdk.split.io/api/splitChanges?s=1.3&since=-1&rbSince=-1") .to_return(status: 200, body: "", headers: {}) allow(subject).to receive(:ready?).and_return(false) @@ -140,7 +140,7 @@ stub_request(:get, 'https://sdk.split.io/api/splitChanges?s=1.3&since=-1&rbSince=-1') .to_return(status: 200, body: splits4) - stub_request(:get, "https://sdk.split.io/api/splitChanges?s=1.3&since") + stub_request(:get, "https://sdk.split.io/api/splitChanges?s=1.3&since&rbSince=-1") .to_return(status: 200, body: "", headers: {}) end diff --git a/spec/sse_debug_server.rb b/spec/sse_debug_server.rb new file mode 100644 index 00000000..57798fcc --- /dev/null +++ b/spec/sse_debug_server.rb @@ -0,0 +1,365 @@ +# frozen_string_literal: true + +# +# Split Ruby SDK - Local Mock Server Example +# +# Stands up a local WEBrick server that mocks every endpoint the SDK talks to +# (auth, splitChanges, segmentChanges, events, telemetry, and the SSE stream), +# then boots the SDK against it. This lets us deterministically reproduce +# streaming edge cases WITHOUT a real backend. +# +# The SSE endpoint behaviour is driven by the SSE_MODE env var: +# +# SSE_MODE=ok -> normal 200 stream, sends a keepalive then stays open (default) +# SSE_MODE=eof -> 200, sends the OK first-event, then closes the socket (clean EOF) +# SSE_MODE=400 -> responds to the stream request with HTTP 400 +# SSE_MODE=401 -> responds to the stream request with HTTP 401 +# SSE_MODE=500 -> responds to the stream request with HTTP 500 +# SSE_MODE=scenario -> connect -> SPLIT_UPDATE -> Ably error event -> expects a +# reconnect. Each /sse hit is logged with its attempt number +# so you can see the second connection land. +# SSE_MODE=graceful_shutdown -> connect -> SPLIT_UPDATE -> server closes the TCP +# connection cleanly (no error event, no 4xx/5xx — just a +# FIN), same as an LB/server recycling a healthy connection. +# Expects the SDK to hit EOFError and reconnect. +# SSE_MODE=ungraceful -> connect -> SPLIT_UPDATE -> the stream is cut abruptly +# (simulated pod crash), and the NEXT couple of reconnect +# attempts get 504 Gateway Timeout / 500 Internal Server +# Error (simulating the pod restarting / gateway with no +# healthy backend yet). Expects the SDK to keep retrying +# with backoff and eventually reconnect once the "pod" is +# back (4th attempt). +# AUTH_MODE=401 -> make the AUTH service return 401 (before SSE is ever attempted) +# +# Usage: +# SSE_MODE=401 ruby spec/sse_debug_server.rb +# AUTH_MODE=401 ruby spec/sse_debug_server.rb +# SSE_MODE=scenario ruby spec/sse_debug_server.rb +# SSE_MODE=graceful_shutdown ruby spec/sse_debug_server.rb +# SSE_MODE=ungraceful ruby spec/sse_debug_server.rb +# + +# Load the local library code (this repo) instead of an installed gem. +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) + +require 'splitclient-rb' +require 'webrick' +require 'json' + +SSE_MODE = ENV.fetch('SSE_MODE', 'ok') +AUTH_MODE = ENV.fetch('AUTH_MODE', 'ok') +PORT = Integer(ENV.fetch('PORT', '8123')) + +# A valid-looking Ably token + channels payload. The token is only parsed for +# channels/exp; its signature is never verified by the SDK. +AUTH_BODY = { + pushEnabled: true, + token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ4LWFibHktY2FwYWJpbGl0eSI6IntcInh4eHhfeHh4eF9zZWdtZW50c1wiOltcInN1YnNjcmliZVwiXSxcInh4eHhfeHh4eF9zcGxpdHNcIjpbXCJzdWJzY3JpYmVcIl0sXCJjb250cm9sX3ByaVwiOltcInN1YnNjcmliZVwiLFwiY2hhbm5lbC1tZXRhZGF0YTpwdWJsaXNoZXJzXCJdLFwiY29udHJvbF9zZWNcIjpbXCJzdWJzY3JpYmVcIixcImNoYW5uZWwtbWV0YWRhdGE6cHVibGlzaGVyc1wiXX0iLCJ4LWFibHktY2xpZW50SWQiOiJjbGllbnRJZCIsImV4cCI6MTk4OTgyMTc1NSwiaWF0IjoxNTg5ODE4MTU1fQ' +}.freeze + +EMPTY_SPLITS = { ff: { d: [], s: -1, t: 4_000_000 }, rbs: { d: [], s: -1, t: 4_000_000 } }.freeze + +# Base64 (no compression) encoded split definition used by SSE_MODE=scenario's +# SPLIT_UPDATE event. +SCENARIO_CHANGE_NUMBER = 4_000_000 +SCENARIO_SPLIT_B64 = 'eyJ0cmFmZmljVHlwZU5hbWUiOiJ1c2VyIiwibmFtZSI6Im1vY2tfc2NlbmFyaW9fZmxhZyIsInRyYWZmaWNBbGxvY2F0aW9uIjoxMDAsInRyYWZmaWNBbGxvY2F0aW9uU2VlZCI6MSwic2VlZCI6MSwic3RhdHVzIjoiQUNUSVZFIiwia2lsbGVkIjpmYWxzZSwiZGVmYXVsdFRyZWF0bWVudCI6Im9mZiIsImNoYW5nZU51bWJlciI6MTcwMDAwMDAwMDAwMCwiYWxnbyI6MiwiY29uZmlndXJhdGlvbnMiOnt9LCJjb25kaXRpb25zIjpbeyJjb25kaXRpb25UeXBlIjoiUk9MTE9VVCIsIm1hdGNoZXJHcm91cCI6eyJjb21iaW5lciI6IkFORCIsIm1hdGNoZXJzIjpbeyJtYXRjaGVyVHlwZSI6IkFMTF9LRVlTIiwibmVnYXRlIjpmYWxzZX1dfSwicGFydGl0aW9ucyI6W3sidHJlYXRtZW50Ijoib24iLCJzaXplIjoxMDB9LHsidHJlYXRtZW50Ijoib2ZmIiwic2l6ZSI6MH1dLCJsYWJlbCI6ImRlZmF1bHQgcnVsZSJ9XX0=' + +def scenario_split_update_event + data = { + type: 'SPLIT_UPDATE', + changeNumber: SCENARIO_CHANGE_NUMBER, + pcn: -1, + c: 0, + d: SCENARIO_SPLIT_B64 + }.to_json + payload = { + id: '1', clientId: 'mockClientId', timestamp: Time.now.to_i, encoding: 'json', + channel: 'mock_channel', data: data, name: 'message' + }.to_json + content = "id: 1\nevent: message\ndata: #{payload}\n\n" + "#{content.bytesize.to_s(16)}\r\n#{content}\r\n" +end + +def control_pri_occupancy_event(publishers: 2) + data = { metrics: { publishers: publishers } }.to_json + payload = { + id: '1', timestamp: Time.now.to_i, encoding: 'json', + channel: '[?occupancy=metrics.publishers]control_pri', data: data, name: '[meta]occupancy' + }.to_json + content = "event: message\ndata: #{payload}\n\n" + "#{content.bytesize.to_s(16)}\r\n#{content}\r\n" +end + +def scenario_error_event + # code 40142 falls in Ably's 40140-40149 "reconnect" range (see + # Client#dispatch_error), so the SDK treats this as PUSH_RETRYABLE_ERROR. + payload = { message: 'Token expired', code: 40_142, statusCode: 401, href: 'https://help.ably.io/error/40142' }.to_json + content = "event: error\ndata: #{payload}\n\n" + "#{content.bytesize.to_s(16)}\r\n#{content}\r\n" +end + +$sse_attempt = 0 + +server = WEBrick::HTTPServer.new( + BindAddress: '127.0.0.1', + Port: PORT, + Logger: WEBrick::Log.new($stdout, WEBrick::Log::INFO), + AccessLog: [] +) + +# --- Auth service ----------------------------------------------------------- +server.mount_proc('/api/v2/auth') do |_req, res| + if AUTH_MODE == '401' + res.status = 401 + res.body = '' + puts '>> AUTH: responding 401' + else + res.status = 200 + res.content_type = 'application/json' + res.body = AUTH_BODY.to_json + puts '>> AUTH: responding 200 (pushEnabled=true)' + end +end + +# --- SDK splitChanges / segmentChanges (polling fallback) ------------------- +server.mount_proc('/api/splitChanges') do |_req, res| + res.status = 200 + res.content_type = 'application/json' + res.body = EMPTY_SPLITS.to_json +end + +server.mount_proc('/api/segmentChanges') do |_req, res| + res.status = 200 + res.content_type = 'application/json' + res.body = { name: 'seg', added: [], removed: [], since: -1, till: -1 }.to_json +end + +# --- Events / Telemetry (fire-and-forget) ----------------------------------- +%w[/api/events/bulk /api/testImpressions/bulk /api/testImpressions/count + /api/keys/ss /api/metrics/usage /api/metrics/config /api/v1/metrics/usage + /api/v1/metrics/config /api/v1/keys/ss].each do |path| + server.mount_proc(path) do |_req, res| + res.status = 200 + res.body = 'ok' + end +end + +# --- SSE stream ------------------------------------------------------------- +server.mount_proc('/sse') do |_req, res| + $sse_attempt += 1 + attempt = $sse_attempt + puts ">> SSE: connection attempt ##{attempt}" + + case SSE_MODE + when 'scenario' + res.status = 200 + res.content_type = 'text/event-stream' + res.keep_alive = false + res['Connection'] = 'close' + res.chunked = true + rd, wr = IO.pipe + res.body = rd + + Thread.new do + # 1. First event establishes the connection (first_event code check == 200) + # and reports 2 publishers available on control_pri, same as a real + # Ably occupancy notification. + wr.write(control_pri_occupancy_event(publishers: 2)) + sleep 0.3 + + if attempt == 1 + # 2. Deliver a SPLIT_UPDATE the SDK should process successfully. + puts '>> SSE: sending SPLIT_UPDATE' + wr.write(scenario_split_update_event) + sleep 0.3 + + # 3. Simulate a server-side error event -> the client should close and + # the SyncManager should reconnect (PUSH_RETRYABLE_ERROR). + puts '>> SSE: sending error event (simulated server error)' + wr.write(scenario_error_event) + sleep 0.3 + else + puts ">> SSE: attempt ##{attempt} reconnected successfully, staying up" + loop do + sleep 20 + begin + wr.write("c\r\n:keepalive\n\n\r\n") + rescue StandardError + break + end + end + end + ensure + begin + wr.close + rescue StandardError + nil + end + end + when 'graceful_shutdown' + res.status = 200 + res.content_type = 'text/event-stream' + res.keep_alive = false + res['Connection'] = 'close' + res.chunked = true + rd, wr = IO.pipe + res.body = rd + + Thread.new do + # 1. First event establishes the connection. + wr.write(control_pri_occupancy_event(publishers: 2)) + sleep 0.3 + + if attempt == 1 + # 2. Deliver a SPLIT_UPDATE the SDK should process successfully. + puts '>> SSE: sending SPLIT_UPDATE' + wr.write(scenario_split_update_event) + sleep 0.3 + + # 3. Close the connection cleanly (FIN, no error event) -- simulates the + # server/LB gracefully shutting down a healthy connection. The client's + # readpartial should hit EOFError and the SDK should reconnect. + puts ">> SSE: attempt ##{attempt} gracefully closing the connection (no error event)" + else + # 4. On reconnect, stay up so we can confirm the SDK settles into a + # stable connected state instead of looping forever. + puts ">> SSE: attempt ##{attempt} reconnected successfully, staying up" + loop do + sleep 20 + begin + wr.write("c\r\n:keepalive\n\n\r\n") + rescue StandardError + break + end + end + end + ensure + begin + wr.close + rescue StandardError + nil + end + end + when 'ungraceful' + # Attempt 1: connect normally and process an update, then the "pod" crashes + # (the TCP connection is torn down abruptly, no clean FIN/close). Attempts + # 2-3: the gateway/LB has no healthy backend yet and answers with 504/500, + # same as a Kubernetes readiness gap after a pod restart. Attempt 4+: the + # backend is back, respond normally and stay connected. + if attempt == 1 + res.status = 200 + res.content_type = 'text/event-stream' + res.chunked = true + rd, wr = IO.pipe + res.body = rd + + Thread.new do + wr.write(control_pri_occupancy_event(publishers: 2)) + sleep 0.3 + + puts '>> SSE: sending SPLIT_UPDATE' + wr.write(scenario_split_update_event) + sleep 0.3 + + puts ">> SSE: attempt ##{attempt} simulating pod crash (abrupt socket close, no FIN)" + # Closing only the write end without flushing/finishing the chunked + # response leaves the client mid-read with no clean EOF marker -- + # closer to a killed pod than a graceful shutdown. + wr.close + end + elsif attempt.between?(2, 3) + status = attempt == 2 ? 504 : 500 + res.status = status + res.content_type = 'text/event-stream' + res.body = '' + puts ">> SSE: attempt ##{attempt} responding HTTP #{status} (gateway/backend still recovering)" + else + res.status = 200 + res.content_type = 'text/event-stream' + res.chunked = true + rd, wr = IO.pipe + res.body = rd + wr.write(control_pri_occupancy_event(publishers: 2)) + + Thread.new do + puts ">> SSE: attempt ##{attempt} reconnected successfully, staying up" + loop do + sleep 20 + begin + wr.write("c\r\n:keepalive\n\n\r\n") + rescue StandardError + break + end + end + end + end + when '400', '401', '500' + res.status = Integer(SSE_MODE) + res.content_type = 'text/event-stream' + res.body = '' + puts ">> SSE: responding HTTP #{SSE_MODE}" + when 'eof' + # Send the OK first event so the client marks itself connected, then close + # the TCP connection -> the client's readpartial hits EOFError. + # Connection: close disables keep-alive so WEBrick actually FINs the socket + # once the body pipe drains. + res.status = 200 + res.content_type = 'text/event-stream' + res.keep_alive = false + res['Connection'] = 'close' + res.chunked = true + rd, wr = IO.pipe + wr.write(control_pri_occupancy_event(publishers: 2)) + wr.close + res.body = rd + puts '>> SSE: responding 200 then closing (EOF)' + else # 'ok' + res.status = 200 + res.content_type = 'text/event-stream' + res.chunked = true + rd, wr = IO.pipe + wr.write("c\r\n:keepalive\n\n\r\n") + # Keep the pipe open so the connection stays alive. + Thread.new do + loop do + sleep 20 + begin + wr.write("c\r\n:keepalive\n\n\r\n") + rescue StandardError + break + end + end + end + res.body = rd + puts '>> SSE: responding 200, streaming keepalives' + end +end + +Thread.new { server.start } +sleep 0.5 + +puts "Mock server on http://127.0.0.1:#{PORT} (SSE_MODE=#{SSE_MODE}, AUTH_MODE=#{AUTH_MODE})" + +base = "http://127.0.0.1:#{PORT}" +options = { + base_uri: "#{base}/api", + events_uri: "#{base}/api", + streaming_service_url: "#{base}/sse", + auth_service_url: "#{base}/api/v2/auth", + telemetry_service_url: "#{base}/api/v1", + streaming_enabled: true, + debug_enabled: true +} + +factory = SplitIoClient::SplitFactory.new('localhost-mock-key', options) +client = factory.client + +begin + client.block_until_ready(10) + puts 'SDK is ready!' +rescue SplitIoClient::SDKBlockerTimeoutExpiredException + puts 'SDK timed out waiting to be ready.' +end + +# Keep running so we can watch the streaming lifecycle in the logs. +loop { sleep 3 }