Merge branch 'tur-63-follower-sync' into 'master'

TUR-63 read-only follower sync

See merge request turniere/turniere-backend!40
This commit is contained in:
Daniel Schädler 2026-04-16 14:45:51 +00:00
commit f75aee8700
43 changed files with 3086 additions and 33 deletions

View File

@ -88,3 +88,90 @@ rails e2e spec:
script: script:
- cd /app - cd /app
- inv blackbox-service - inv blackbox-service
rails follow sync e2e spec:
stage: test
tags:
- docker
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
services:
- name: postgres:16
alias: source-postgres
variables:
POSTGRES_DB: turniere_source_blackbox
POSTGRES_USER: turniere
POSTGRES_PASSWORD: turniere
- name: postgres:16
alias: follower-postgres
variables:
POSTGRES_DB: turniere_follower_blackbox
POSTGRES_USER: turniere
POSTGRES_PASSWORD: turniere
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
alias: source-app
variables:
POSTGRES_URL: postgres://turniere:turniere@source-postgres:5432/turniere_source_blackbox
POSTGRES_USERNAME: turniere
POSTGRES_PASSWORD: turniere
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
MAILGUN_API_KEY: blackbox-test-api-key
MAILGUN_DOMAIN: blackbox.example.com
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3"
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1"
command:
- bundle
- exec
- rails
- s
- -p
- "3000"
- -b
- 0.0.0.0
- name: $CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA
alias: follower-app
variables:
POSTGRES_URL: postgres://turniere:turniere@follower-postgres:5432/turniere_follower_blackbox
POSTGRES_USERNAME: turniere
POSTGRES_PASSWORD: turniere
SECRET_KEY_BASE: turniere-blackbox-secret-key-base-please-change-in-real-prod
MAILGUN_API_KEY: blackbox-test-api-key
MAILGUN_DOMAIN: blackbox.example.com
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: "3"
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1"
command:
- bundle
- exec
- rails
- s
- -p
- "3000"
- -b
- 0.0.0.0
variables:
FF_NETWORK_PER_BUILD: "true"
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
TURNIERE_SOURCE_E2E_BASE_URL: http://source-app:3000
TURNIERE_FOLLOWER_E2E_BASE_URL: http://follower-app:3000
TURNIERE_E2E_EMAIL: e2e@example.com
TURNIERE_E2E_PASSWORD: password123
TURNIERE_E2E_USERNAME: e2e-user
rules:
- if: $SKIP_TEST
when: never
- if: $SKIP_ALL_SPECS
when: never
- if: $SKIP_FOLLOW_E2E_SPEC
when: never
- if: $CI_PIPELINE_SOURCE != "push"
when: never
- when: always
script:
- cd /app
- inv blackbox-follow-service

View File

@ -27,6 +27,10 @@ Running tests works as follows:
bundle exec rspec bundle exec rspec
``` ```
Leader/follower tournament sync docs:
- [doc/leader_follower.md](doc/leader_follower.md)
## Docker ## Docker
[Registry](https://gitlab.com/turniere/turniere-backend/container_registry) [Registry](https://gitlab.com/turniere/turniere-backend/container_registry)

View File

@ -30,6 +30,16 @@ class ApplicationController < ActionController::API
}, status: :forbidden }, status: :forbidden
end end
def render_read_only_error
render json: {
error: 'Tournament is in read only follower mode'
}, status: :locked
end
def require_writable_tournament!(tournament)
render_read_only_error if tournament.read_only_mode?
end
def render_not_found_error(exception) def render_not_found_error(exception)
render json: { error: exception.to_s }, status: :not_found render json: { error: exception.to_s }, status: :not_found
end end

View File

@ -5,6 +5,7 @@ class BeamersController < ApplicationController
before_action :set_beamer, only: %i[show update destroy] before_action :set_beamer, only: %i[show update destroy]
before_action :authenticate_user!, only: %i[create update destroy] before_action :authenticate_user!, only: %i[create update destroy]
before_action -> { require_owner! @tournament.owner }, only: %i[create update destroy] before_action -> { require_owner! @tournament.owner }, only: %i[create update destroy]
before_action -> { require_writable_tournament!(@tournament) }, only: %i[create update destroy]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
# GET /tournaments/:tournament_id/beamers # GET /tournaments/:tournament_id/beamers
@ -32,6 +33,7 @@ class BeamersController < ApplicationController
def create def create
beamer = @tournament.beamers.new(beamer_params) beamer = @tournament.beamers.new(beamer_params)
if beamer.save if beamer.save
push_sync_if_needed!
render json: beamer, status: :created render json: beamer, status: :created
else else
render json: beamer.errors, status: :unprocessable_entity render json: beamer.errors, status: :unprocessable_entity
@ -41,6 +43,7 @@ class BeamersController < ApplicationController
# PATCH /tournaments/:tournament_id/beamers/:id # PATCH /tournaments/:tournament_id/beamers/:id
def update def update
if @beamer.update(beamer_params) if @beamer.update(beamer_params)
push_sync_if_needed!
render json: @beamer render json: @beamer
else else
render json: @beamer.errors, status: :unprocessable_entity render json: @beamer.errors, status: :unprocessable_entity
@ -50,6 +53,7 @@ class BeamersController < ApplicationController
# DELETE /tournaments/:tournament_id/beamers/:id # DELETE /tournaments/:tournament_id/beamers/:id
def destroy def destroy
@beamer.destroy @beamer.destroy
push_sync_if_needed!
head :no_content head :no_content
end end
@ -66,4 +70,8 @@ class BeamersController < ApplicationController
def beamer_params def beamer_params
params.permit(:name, :display_state, :qr_code_url, :logo_url) params.permit(:name, :display_state, :qr_code_url, :logo_url)
end end
def push_sync_if_needed!
TournamentSyncEnqueue.call(@tournament)
end
end end

View File

@ -3,6 +3,7 @@
class BetsController < ApplicationController class BetsController < ApplicationController
before_action :set_match, only: %i[index create] before_action :set_match, only: %i[index create]
before_action :authenticate_user!, only: %i[create] before_action :authenticate_user!, only: %i[create]
before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[create]
rescue_from UserServiceError, with: :handle_user_service_error rescue_from UserServiceError, with: :handle_user_service_error
def index def index

View File

@ -4,6 +4,7 @@ class MatchScoresController < ApplicationController
before_action :set_match_score, only: %i[show update] before_action :set_match_score, only: %i[show update]
before_action :authenticate_user!, only: %i[update] before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @match_score.owner }, only: %i[update] before_action -> { require_owner! @match_score.owner }, only: %i[update]
before_action -> { require_writable_tournament!(@match_score.tournament) }, only: %i[update]
# GET /scores/1 # GET /scores/1
def show def show
@ -14,6 +15,7 @@ class MatchScoresController < ApplicationController
def update def update
if @match_score.update(match_score_params) if @match_score.update(match_score_params)
UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match? UpdateGroupsGroupScoresAndSave.call(group: @match_score.match.group) if @match_score.part_of_group_match?
push_sync_if_needed!(@match_score.tournament)
render json: @match_score render json: @match_score
else else
render json: @match_score.errors, status: :unprocessable_entity render json: @match_score.errors, status: :unprocessable_entity
@ -31,4 +33,8 @@ class MatchScoresController < ApplicationController
def match_score_params def match_score_params
params.slice(:points, :hidden_points).permit! params.slice(:points, :hidden_points).permit!
end end
def push_sync_if_needed!(tournament)
TournamentSyncEnqueue.call(tournament)
end
end end

View File

@ -7,6 +7,8 @@ class MatchesController < ApplicationController
before_action -> { require_owner! @match.owner }, only: %i[update] before_action -> { require_owner! @match.owner }, only: %i[update]
before_action -> { require_owner! @group.owner }, only: %i[create] before_action -> { require_owner! @group.owner }, only: %i[create]
before_action :set_tournament, only: %i[index] before_action :set_tournament, only: %i[index]
before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update]
before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create]
# GET/tournaments/1/matches # GET/tournaments/1/matches
def index def index
@ -54,6 +56,7 @@ class MatchesController < ApplicationController
render json: match.errors, status: :unprocessable_entity render json: match.errors, status: :unprocessable_entity
elsif match.save elsif match.save
UpdateGroupsGroupScoresAndSave.call(group: @group) UpdateGroupsGroupScoresAndSave.call(group: @group)
push_sync_if_needed!(@group.tournament)
render json: match, include: %w[match_scores.team group], status: :created render json: match, include: %w[match_scores.team group], status: :created
else else
render json: match.errors, status: :unprocessable_entity render json: match.errors, status: :unprocessable_entity
@ -63,6 +66,7 @@ class MatchesController < ApplicationController
# PATCH/PUT /matches/1 # PATCH/PUT /matches/1
def update def update
new_state = match_params['state'] new_state = match_params['state']
synced_tournament = nil
Match.transaction do Match.transaction do
if @match.update(match_params) if @match.update(match_params)
@ -75,11 +79,13 @@ class MatchesController < ApplicationController
end end
render json: @match render json: @match
synced_tournament = @match.tournament
else else
render json: @match.errors, status: :unprocessable_entity render json: @match.errors, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
end end
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
end end
private private
@ -156,4 +162,8 @@ class MatchesController < ApplicationController
end end
end end
end end
def push_sync_if_needed!(tournament)
TournamentSyncEnqueue.call(tournament)
end
end end

View File

@ -4,6 +4,7 @@ class StagesController < ApplicationController
before_action :set_stage, only: %i[show update] before_action :set_stage, only: %i[show update]
before_action :authenticate_user!, only: %i[update] before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @stage.owner }, only: %i[update] before_action -> { require_owner! @stage.owner }, only: %i[update]
before_action -> { require_writable_tournament!(@stage.tournament) }, only: %i[update]
# GET /stages/1 # GET /stages/1
def show def show
@ -18,16 +19,19 @@ class StagesController < ApplicationController
return return
end end
synced_tournament = nil
Stage.transaction do Stage.transaction do
if @stage.update(stage_params) if @stage.update(stage_params)
handle_group_stage_end handle_group_stage_end
render json: @stage render json: @stage
synced_tournament = @stage.tournament
else else
render json: @stage.errors, status: :unprocessable_entity render json: @stage.errors, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
end end
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
else else
render json: { render json: {
error: 'The state attribute may only be changed to finished' error: 'The state attribute may only be changed to finished'
@ -75,4 +79,8 @@ class StagesController < ApplicationController
def stage_params def stage_params
params.slice(:state).permit! params.slice(:state).permit!
end end
def push_sync_if_needed!(tournament)
TournamentSyncEnqueue.call(tournament)
end
end end

View File

@ -4,6 +4,7 @@ class TeamsController < ApplicationController
before_action :set_team, only: %i[show update] before_action :set_team, only: %i[show update]
before_action :authenticate_user!, only: %i[update] before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @team.owner }, only: %i[update] before_action -> { require_owner! @team.owner }, only: %i[update]
before_action -> { require_writable_tournament!(@team.tournament) }, only: %i[update]
# GET /teams/1 # GET /teams/1
def show def show
@ -13,6 +14,7 @@ class TeamsController < ApplicationController
# PATCH/PUT /teams/1 # PATCH/PUT /teams/1
def update def update
if @team.update(team_params) if @team.update(team_params)
push_sync_if_needed!(@team.tournament)
render json: @team render json: @team
else else
render json: @team.errors, status: :unprocessable_entity render json: @team.errors, status: :unprocessable_entity
@ -28,4 +30,8 @@ class TeamsController < ApplicationController
def team_params def team_params
params.slice(:name).permit! params.slice(:name).permit!
end end
def push_sync_if_needed!(tournament)
TournamentSyncEnqueue.call(tournament)
end
end end

View File

@ -12,12 +12,16 @@ class TournamentsController < ApplicationController
end end
before_action :set_tournament_for_show, only: %i[show] before_action :set_tournament_for_show, only: %i[show]
before_action :set_tournament, only: %i[update destroy set_timer_end timer_end] before_action :set_tournament, only: %i[update destroy set_timer_end timer_end sync_state]
before_action :authenticate_user!, only: %i[create update destroy set_timer_end] before_action :authenticate_user!, only: %i[create update destroy set_timer_end]
before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end] before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end]
before_action :validate_create_params, only: %i[create] before_action :validate_create_params, only: %i[create]
before_action :validate_update_params, only: %i[update] before_action :validate_update_params, only: %i[update]
before_action :validate_set_timer_end_params, only: %i[set_timer_end] before_action :validate_set_timer_end_params, only: %i[set_timer_end]
before_action :ensure_update_allowed!, only: %i[update]
before_action :ensure_destroy_allowed!, only: %i[destroy]
before_action :ensure_timer_update_allowed!, only: %i[set_timer_end]
before_action :authorize_sync_request!, only: %i[sync_state]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
# GET /tournaments # GET /tournaments
@ -52,11 +56,20 @@ class TournamentsController < ApplicationController
# POST /tournaments # POST /tournaments
def create def create
params = tournament_params params = tournament_params
params.require(:teams)
group_stage = params.delete(:group_stage) group_stage = params.delete(:group_stage)
teams = params.delete('teams') teams = params.delete('teams')
# create tournament tournament = current_user.tournaments.new params.except(:teams, :group_stage)
tournament = current_user.tournaments.new params
if tournament.read_only_mode?
if tournament.save
render json: tournament, status: :created, location: tournament
else
render json: tournament.errors, status: :unprocessable_entity
end
return
end
raise ActionController::ParameterMissing, :teams if teams.nil?
if group_stage if group_stage
params.require(:playoff_teams_amount) params.require(:playoff_teams_amount)
groups = organize_teams_in_groups(teams) groups = organize_teams_in_groups(teams)
@ -87,6 +100,7 @@ class TournamentsController < ApplicationController
# PATCH/PUT /tournaments/1 # PATCH/PUT /tournaments/1
def update def update
synced_tournament = nil
Tournament.transaction do Tournament.transaction do
if only_playoff_teams_amount_changed if only_playoff_teams_amount_changed
@tournament.instant_finalists_amount, @tournament.intermediate_round_participants_amount = @tournament.instant_finalists_amount, @tournament.intermediate_round_participants_amount =
@ -96,12 +110,14 @@ class TournamentsController < ApplicationController
) )
end end
if @tournament.update(tournament_params) if @tournament.update(tournament_params)
synced_tournament = @tournament
render json: @tournament render json: @tournament
else else
render json: @tournament.errors, status: :unprocessable_entity render json: @tournament.errors, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
end end
push_sync_if_needed!(synced_tournament) unless synced_tournament.nil?
end end
# DELETE /tournaments/1 # DELETE /tournaments/1
@ -117,12 +133,20 @@ class TournamentsController < ApplicationController
# PATCH /tournaments/:id/set_timer_end # PATCH /tournaments/:id/set_timer_end
def set_timer_end def set_timer_end
if @tournament.update(timer_end_params) if @tournament.update(timer_end_params)
push_sync_if_needed!(@tournament)
render json: @tournament render json: @tournament
else else
render json: @tournament.errors, status: :unprocessable_entity render json: @tournament.errors, status: :unprocessable_entity
end end
end end
def sync_state
TournamentSnapshotImporter.import!(tournament: @tournament, snapshot: sync_state_params)
render json: @tournament, include: '**'
rescue KeyError, ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private private
@ -194,10 +218,16 @@ class TournamentsController < ApplicationController
end end
def tournament_params def tournament_params
params.slice(:name, :description, :public, :teams, :group_stage, :playoff_teams_amount).permit! params.slice(
:name, :description, :public, :teams, :group_stage, :playoff_teams_amount,
:instant_finalists_amount, :intermediate_round_participants_amount,
:read_only_mode, :sync_target_url, :sync_auth_token
).permit!
end end
def validate_create_params def validate_create_params
return if ActiveModel::Type::Boolean.new.cast(params[:read_only_mode])
teams = params['teams'] teams = params['teams']
return if teams.is_a?(Array) && teams.reject { |t| t.is_a? ActionController::Parameters }.count.zero? return if teams.is_a?(Array) && teams.reject { |t| t.is_a? ActionController::Parameters }.count.zero?
@ -211,12 +241,21 @@ class TournamentsController < ApplicationController
end end
def validate_update_params def validate_update_params
return if sync_config_only_update?
return if only_playoff_teams_amount_changed return if only_playoff_teams_amount_changed
playoff_teams_amount = params['playoff_teams_amount'].to_i || @tournament.playoff_teams_amount playoff_teams_amount = params.key?('playoff_teams_amount') ? params['playoff_teams_amount'].to_i : @tournament.playoff_teams_amount
instant_finalists_amount = params['instant_finalists_amount'].to_i || @tournament.instant_finalists_amount instant_finalists_amount = if params.key?('instant_finalists_amount')
intermediate_round_participants_amount = params['intermediate_round_participants_amount'].to_i || params['instant_finalists_amount'].to_i
else
@tournament.instant_finalists_amount
end
intermediate_round_participants_amount = if params.key?('intermediate_round_participants_amount')
params['intermediate_round_participants_amount'].to_i
else
@tournament.intermediate_round_participants_amount @tournament.intermediate_round_participants_amount
end
return if instant_finalists_amount + (intermediate_round_participants_amount / 2) == return if instant_finalists_amount + (intermediate_round_participants_amount / 2) ==
playoff_teams_amount playoff_teams_amount
@ -225,6 +264,46 @@ class TournamentsController < ApplicationController
error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match' error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match'
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
def sync_state_params
params.require(:snapshot).permit!.to_h
end
def authorize_sync_request!
return render json: { error: 'Tournament is not configured to accept sync pushes' }, status: :unprocessable_entity unless @tournament.sync_accepts_push?
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s
return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
matches = token.bytesize == @tournament.sync_auth_token.to_s.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(token, @tournament.sync_auth_token.to_s)
return if matches
render json: { error: 'Invalid sync token' }, status: :unauthorized
end
def ensure_update_allowed!
return unless @tournament.read_only_mode?
return if sync_config_only_update?
render_read_only_error
end
def ensure_destroy_allowed!
require_writable_tournament!(@tournament)
end
def ensure_timer_update_allowed!
require_writable_tournament!(@tournament)
end
def sync_config_only_update?
allowed = %w[id controller action format tournament read_only_mode sync_target_url sync_auth_token]
(params.keys - allowed).empty?
end
def push_sync_if_needed!(tournament)
TournamentSyncEnqueue.call(tournament)
end
def validate_set_timer_end_params def validate_set_timer_end_params
timer_end = params[:timer_end] timer_end = params[:timer_end]

View File

@ -6,6 +6,7 @@ class Group < ApplicationRecord
has_many :group_scores, dependent: :destroy has_many :group_scores, dependent: :destroy
delegate :owner, to: :stage delegate :owner, to: :stage
delegate :tournament, to: :stage
def teams def teams
matches.map(&:teams).flatten.uniq matches.map(&:teams).flatten.uniq

View File

@ -22,6 +22,10 @@ class Match < ApplicationRecord
stage ? stage.owner : group.owner stage ? stage.owner : group.owner
end end
def tournament
stage ? stage.tournament : group.stage.tournament
end
def current_leading_team def current_leading_team
return nil if match_scores.size < 2 return nil if match_scores.size < 2
return nil if score_value_for(match_scores.first) == score_value_for(match_scores.second) return nil if score_value_for(match_scores.first) == score_value_for(match_scores.second)

View File

@ -5,6 +5,7 @@ class MatchScore < ApplicationRecord
belongs_to :team belongs_to :team
delegate :owner, to: :match delegate :owner, to: :match
delegate :tournament, to: :match
def part_of_group_match? def part_of_group_match?
match.group_match? match.group_match?

View File

@ -10,6 +10,8 @@ class Tournament < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validates :code, presence: true, uniqueness: true validates :code, presence: true, uniqueness: true
validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
validate :sync_configuration_is_complete
validate :playoff_teams_amount_is_positive_power_of_two validate :playoff_teams_amount_is_positive_power_of_two
@ -17,6 +19,7 @@ class Tournament < ApplicationRecord
after_initialize :generate_code after_initialize :generate_code
after_create_commit :ensure_default_beamer! after_create_commit :ensure_default_beamer!
before_validation :clear_follower_sync_token_when_disabling_read_only_mode
def matches def matches
[stages.map(&:matches), stages.map { |s| s.groups.map(&:matches) }].flatten [stages.map(&:matches), stages.map { |s| s.groups.map(&:matches) }].flatten
@ -34,6 +37,14 @@ class Tournament < ApplicationRecord
stages.find { |stage| stage.level == -1 } stages.find { |stage| stage.level == -1 }
end end
def sync_push_enabled?
sync_target_url.present? && sync_auth_token.present? && !read_only_mode?
end
def sync_accepts_push?
read_only_mode? && sync_auth_token.present?
end
private private
def generate_code def generate_code
@ -52,9 +63,32 @@ class Tournament < ApplicationRecord
'playoff_teams_amount needs to be a positive power of two') 'playoff_teams_amount needs to be a positive power of two')
end end
def sync_configuration_is_complete
return if sync_target_url.blank? && sync_auth_token.blank?
return if sync_target_url.present? && sync_auth_token.present?
return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
errors.add(:base, 'sync_target_url and sync_auth_token must be configured together')
end
def ensure_default_beamer! def ensure_default_beamer!
beamers.find_or_create_by!(name: Beamer::DEFAULT_NAME) do |beamer| beamers.find_or_create_by!(name: Beamer::DEFAULT_NAME) do |beamer|
beamer.display_state = 'matches_all' beamer.display_state = 'matches_all'
end end
end end
def sync_configuration_is_complete
return if sync_target_url.blank? && sync_auth_token.blank?
return if sync_target_url.present? && sync_auth_token.present?
return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
errors.add(:base, 'sync_target_url and sync_auth_token must be configured together')
end
def clear_follower_sync_token_when_disabling_read_only_mode
return if read_only_mode?
return if sync_target_url.present?
self.sync_auth_token = nil
end
end end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
class TournamentSyncQueueEntry < ApplicationRecord
LOCK_TTL = 2.minutes
MAX_BACKOFF = 5.minutes
belongs_to :tournament
validates :snapshot_json, presence: true
validates :status, presence: true
scope :due, -> { where(status: 'pending').where('next_attempt_at <= ?', Time.current) }
scope :unlocked, lambda {
where(locked_at: nil).or(where('locked_at < ?', Time.current - LOCK_TTL))
}
def snapshot
JSON.parse(snapshot_json, symbolize_names: true)
end
def schedule_retry!(error_message)
update!(
status: 'pending',
last_error: error_message,
attempts: attempts + 1,
last_attempt_at: Time.current,
next_attempt_at: Time.current + retry_delay,
locked_at: nil,
lock_token: nil
)
end
def mark_synced!
update!(
status: 'synced',
last_error: nil,
last_attempt_at: Time.current,
next_attempt_at: Time.current,
attempts: 0,
locked_at: nil,
lock_token: nil
)
end
def acquire_lock!
token = SecureRandom.hex(8)
updated = self.class
.where(id: id)
.where(locked_at: nil)
.or(self.class.where(id: id).where('locked_at < ?', Time.current - LOCK_TTL))
.update_all(locked_at: Time.current, lock_token: token)
return nil if updated.zero?
reload
token
end
private
def retry_delay
[2**attempts, MAX_BACKOFF].min.seconds
end
end

View File

@ -0,0 +1,88 @@
# frozen_string_literal: true
class TournamentSnapshotBuilder
ROOT_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Tournament).map(&:to_sym).freeze
TEAM_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Team).map(&:to_sym).freeze
BEAMER_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Beamer).map(&:to_sym).freeze
STAGE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Stage).map(&:to_sym).freeze
GROUP_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Group).map(&:to_sym).freeze
MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze
MATCH_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(MatchScore).map(&:to_sym).freeze
GROUP_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(GroupScore).map(&:to_sym).freeze
def self.build(tournament)
new(tournament).build
end
def initialize(tournament)
@tournament = tournament
end
def build
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
payload[:timer_end] = tournament.timer_end&.iso8601
payload[:sync_generated_at] = Time.current.iso8601(6)
payload[:teams] = teams_payload
payload[:beamers] = beamers_payload
payload[:stages] = stages_payload
payload[:groups] = groups_payload
payload[:matches] = matches_payload
payload[:match_scores] = match_scores_payload
payload[:group_scores] = group_scores_payload
payload
end
private
attr_reader :tournament
def teams_payload
teams_for_snapshot.sort_by(&:id).map do |team|
team.attributes.symbolize_keys.slice(*TEAM_ATTRIBUTES)
end
end
def stages_payload
tournament.stages.order(:id).map do |stage|
stage.attributes.symbolize_keys.slice(*STAGE_ATTRIBUTES)
end
end
def beamers_payload
tournament.beamers.order(:id).map do |beamer|
beamer.attributes.symbolize_keys.slice(*BEAMER_ATTRIBUTES)
end
end
def groups_payload
tournament.stages.flat_map(&:groups).sort_by(&:id).map do |group|
group.attributes.symbolize_keys.slice(*GROUP_ATTRIBUTES)
end
end
def matches_payload
tournament.matches.sort_by(&:id).map do |match|
match.attributes.symbolize_keys.slice(*MATCH_ATTRIBUTES)
end
end
def match_scores_payload
tournament.matches.flat_map(&:match_scores).sort_by(&:id).map do |match_score|
match_score.attributes.symbolize_keys.slice(*MATCH_SCORE_ATTRIBUTES)
end
end
def group_scores_payload
tournament.stages.flat_map(&:groups).flat_map(&:group_scores).sort_by(&:id).map do |group_score|
group_score.attributes.symbolize_keys.slice(*GROUP_SCORE_ATTRIBUTES)
end
end
def teams_for_snapshot
(
tournament.teams.to_a +
tournament.matches.flat_map(&:teams) +
tournament.stages.flat_map(&:groups).flat_map(&:group_scores).map(&:team)
).uniq(&:id)
end
end

View File

@ -0,0 +1,205 @@
# frozen_string_literal: true
class TournamentSnapshotImporter
ROOT_ATTRIBUTES = %i[
name
code
description
public
playoff_teams_amount
instant_finalists_amount
intermediate_round_participants_amount
].freeze
def self.import!(tournament:, snapshot:)
new(tournament: tournament, snapshot: snapshot).import!
end
def initialize(tournament:, snapshot:)
@tournament = tournament
@snapshot = snapshot.deep_symbolize_keys
end
def import!
Tournament.transaction do
tournament.lock!
return tournament if stale_snapshot?
existing_teams = tournament.teams.to_a
existing_beamers = tournament.beamers.to_a
existing_stages = tournament.stages.to_a
existing_groups = existing_stages.flat_map(&:groups)
existing_matches = tournament.matches
existing_match_scores = existing_matches.flat_map(&:match_scores)
existing_group_scores = existing_groups.flat_map(&:group_scores)
update_tournament!
teams_by_source_id = sync_teams!
sync_beamers!
stages_by_source_id = sync_stages!
groups_by_source_id = sync_groups!(stages_by_source_id)
matches_by_source_id = sync_matches!(stages_by_source_id, groups_by_source_id)
sync_match_scores!(matches_by_source_id, teams_by_source_id)
sync_group_scores!(groups_by_source_id, teams_by_source_id)
destroy_stale_records(existing_beamers, snapshot.fetch(:beamers, []))
destroy_stale_records(existing_group_scores, snapshot.fetch(:group_scores, []))
destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, []))
destroy_stale_records(existing_matches, snapshot.fetch(:matches, []))
destroy_stale_records(existing_groups, snapshot.fetch(:groups, []))
destroy_stale_records(existing_stages, snapshot.fetch(:stages, []))
destroy_stale_records(existing_teams, snapshot.fetch(:teams, []))
end
tournament
end
private
attr_reader :tournament, :snapshot
def update_tournament!
root_attrs = snapshot.slice(*ROOT_ATTRIBUTES)
root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code])
root_attrs[:timer_end] = parse_time(snapshot[:timer_end])
root_attrs[:sync_source_tournament_id] = snapshot[:id]
root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at
tournament.update!(root_attrs)
end
def sync_teams!
sync_records(
model_class: Team,
existing_records: tournament.teams.to_a,
payload: snapshot.fetch(:teams, [])
) do |team, attrs|
team.tournament = tournament
team.name = attrs.fetch(:name)
end
end
def sync_stages!
sync_records(
model_class: Stage,
existing_records: tournament.stages.to_a,
payload: snapshot.fetch(:stages, [])
) do |stage, attrs|
stage.tournament = tournament
stage.level = attrs.fetch(:level)
stage.state = attrs.fetch(:state)
end
end
def sync_beamers!
existing_by_source_id = tournament.beamers.index_by(&:sync_source_id)
existing_by_name = tournament.beamers.index_by(&:name)
snapshot.fetch(:beamers, []).each_with_object({}) do |attrs, beamers_by_source_id|
source_id = attrs.fetch(:id)
beamer = existing_by_source_id[source_id] || existing_by_name[attrs.fetch(:name)] || Beamer.new
beamer.sync_source_id = source_id
beamer.tournament = tournament
beamer.name = attrs.fetch(:name)
beamer.display_state = attrs.fetch(:display_state)
beamer.is_default = attrs.fetch(:is_default, false)
beamer.qr_code_url = attrs[:qr_code_url]
beamer.logo_url = attrs[:logo_url]
beamer.save!
beamers_by_source_id[source_id] = beamer
end
end
def sync_groups!(stages_by_source_id)
sync_records(
model_class: Group,
existing_records: tournament.stages.flat_map(&:groups),
payload: snapshot.fetch(:groups, [])
) do |group, attrs|
group.stage = stages_by_source_id.fetch(attrs.fetch(:stage_id))
group.number = attrs.fetch(:number)
end
end
def sync_matches!(stages_by_source_id, groups_by_source_id)
sync_records(
model_class: Match,
existing_records: tournament.matches,
payload: snapshot.fetch(:matches, [])
) do |match, attrs|
match.stage = attrs[:stage_id] ? stages_by_source_id.fetch(attrs[:stage_id]) : nil
match.group = attrs[:group_id] ? groups_by_source_id.fetch(attrs[:group_id]) : nil
match.state = attrs.fetch(:state)
match.position = attrs[:position]
match.decider = attrs[:decider]
end
end
def sync_match_scores!(matches_by_source_id, teams_by_source_id)
sync_records(
model_class: MatchScore,
existing_records: tournament.matches.flat_map(&:match_scores),
payload: snapshot.fetch(:match_scores, [])
) do |match_score, attrs|
match_score.match = matches_by_source_id.fetch(attrs.fetch(:match_id))
match_score.team = teams_by_source_id.fetch(attrs.fetch(:team_id))
match_score.points = attrs.fetch(:points)
match_score.hidden_points = attrs.fetch(:hidden_points, 0)
end
end
def sync_group_scores!(groups_by_source_id, teams_by_source_id)
sync_records(
model_class: GroupScore,
existing_records: tournament.stages.flat_map(&:groups).flat_map(&:group_scores),
payload: snapshot.fetch(:group_scores, [])
) do |group_score, attrs|
group_score.group = groups_by_source_id.fetch(attrs.fetch(:group_id))
group_score.team = teams_by_source_id.fetch(attrs.fetch(:team_id))
group_score.group_points = attrs.fetch(:group_points)
group_score.scored_points = attrs.fetch(:scored_points)
group_score.received_points = attrs.fetch(:received_points)
group_score.position = attrs.fetch(:position, 0)
end
end
def sync_records(model_class:, existing_records:, payload:)
existing_by_source_id = existing_records.index_by(&:sync_source_id)
payload.each_with_object({}) do |attrs, records_by_source_id|
source_id = attrs.fetch(:id)
record = existing_by_source_id[source_id] || model_class.new
record.sync_source_id = source_id
yield(record, attrs)
record.save!
records_by_source_id[source_id] = record
end
end
def destroy_stale_records(existing_records, payload)
source_ids = payload.map { |attrs| attrs.fetch(:id) }
stale_records = existing_records.reject { |record| source_ids.include?(record.sync_source_id) }
stale_records.each(&:destroy!)
end
def parse_time(value)
return nil if value.blank?
Time.zone.parse(value)
end
def snapshot_generated_at
parse_time(snapshot[:sync_generated_at])
end
def stale_snapshot?
return false if snapshot_generated_at.nil?
return false if tournament.sync_last_imported_snapshot_at.nil?
snapshot_generated_at <= tournament.sync_last_imported_snapshot_at
end
def code_taken_by_another_tournament?(code)
return false if code.blank?
Tournament.where(code: code).where.not(id: tournament.id).exists?
end
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
class TournamentSyncEnqueue
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
return false unless tournament.sync_push_enabled?
payload = TournamentSnapshotBuilder.build(fresh_tournament)
entry = TournamentSyncQueueEntry.find_or_initialize_by(tournament: tournament)
entry.snapshot_json = JSON.dump(payload)
entry.status = 'pending'
entry.attempts = 0
entry.last_attempt_at = nil
entry.next_attempt_at = Time.current
entry.last_error = nil
entry.locked_at = nil
entry.lock_token = nil
entry.save!
TournamentSyncWorker.start
TournamentSyncWorker.wake
true
end
private
attr_reader :tournament
def fresh_tournament
Tournament.find(tournament.id)
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class TournamentSyncProcessor
def self.process_due!
new.process_due!
end
def process_due!
return unless queue_table_available?
loop do
entry = next_entry
break if entry.nil?
process_entry(entry)
end
end
private
def next_entry
TournamentSyncQueueEntry.due.unlocked.order(:next_attempt_at, :id).first
end
def process_entry(entry)
lock_token = entry.acquire_lock!
return if lock_token.nil?
entry.reload
TournamentSyncPusher.push_snapshot!(entry.tournament, entry.snapshot)
entry.mark_synced!
rescue TournamentSyncPusher::SyncFailed => e
entry.reload.schedule_retry!(e.message)
end
def queue_table_available?
TournamentSyncQueueEntry.connection.data_source_exists?(TournamentSyncQueueEntry.table_name)
rescue ActiveRecord::ActiveRecordError, PG::Error
false
end
end

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'json'
require 'net/http'
require 'uri'
class TournamentSyncPusher
class SyncFailed < StandardError; end
DEFAULT_HTTP_TIMEOUT = 5
def self.push!(tournament)
new(tournament).push!
end
def self.push_snapshot!(tournament, snapshot)
new(tournament).push_snapshot!(snapshot)
end
def initialize(tournament)
@tournament = tournament
end
def push!
return false unless tournament.sync_push_enabled?
push_snapshot!(TournamentSnapshotBuilder.build(tournament))
end
def push_snapshot!(snapshot)
response = perform_request(snapshot: snapshot)
unless response.is_a?(Net::HTTPSuccess)
raise SyncFailed, "sync push failed with status #{response.code}: #{response.body}"
end
tournament.update_columns(sync_last_pushed_at: Time.current, sync_last_push_error: nil)
true
rescue SyncFailed => e
tournament.update_columns(sync_last_push_error: e.message)
raise
rescue StandardError => e
tournament.update_columns(sync_last_push_error: e.message)
raise SyncFailed, e.message
end
private
attr_reader :tournament
def perform_request(snapshot:)
uri = URI.parse(tournament.sync_target_url)
request = Net::HTTP::Patch.new(uri)
request['accept'] = 'application/json'
request['content-type'] = 'application/json'
request['authorization'] = "Bearer #{tournament.sync_auth_token}"
request.body = JSON.dump({ snapshot: snapshot })
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
timeout = http_timeout_seconds
http.open_timeout = timeout
http.read_timeout = timeout
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
http.request(request)
end
end
def http_timeout_seconds
ENV.fetch('TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS', DEFAULT_HTTP_TIMEOUT).to_i
end
end

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
class TournamentSyncSchema
MODEL_COLUMNS = {
Tournament => {
synced: %w[
id
name
code
description
public
playoff_teams_amount
instant_finalists_amount
intermediate_round_participants_amount
timer_end
].freeze,
ignored: %w[
user_id
created_at
updated_at
read_only_mode
sync_target_url
sync_auth_token
sync_source_tournament_id
sync_last_pushed_at
sync_last_push_error
sync_last_imported_snapshot_at
].freeze
}.freeze,
Team => {
synced: %w[id name].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze,
Beamer => {
synced: %w[id name display_state is_default qr_code_url logo_url].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze,
Stage => {
synced: %w[id level state].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze,
Group => {
synced: %w[id number stage_id].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze,
Match => {
synced: %w[id state position stage_id group_id decider].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze,
MatchScore => {
synced: %w[id points match_id team_id hidden_points].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze,
GroupScore => {
synced: %w[id group_points scored_points received_points team_id group_id position].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze
}.freeze
class << self
def synced_columns_for(model_class)
MODEL_COLUMNS.fetch(model_class).fetch(:synced)
end
def ignored_columns_for(model_class)
MODEL_COLUMNS.fetch(model_class).fetch(:ignored)
end
end
end

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
class TournamentSyncWorker
POLL_INTERVAL = 2.seconds
class << self
def start
return if Rails.env.test?
mutex.synchronize do
return if running?
@thread = Thread.new do
Thread.current.name = 'tournament-sync-worker' if Thread.current.respond_to?(:name=)
loop do
TournamentSyncProcessor.process_due!
wait_for_wake_or_timeout
rescue StandardError => e
Rails.logger.warn("Tournament sync worker error: #{e.message}")
wait_for_wake_or_timeout
end
end
end
end
def wake
mutex.synchronize do
condition.broadcast
end
end
def running?
@thread&.alive?
end
private
def mutex
@mutex ||= Mutex.new
end
def condition
@condition ||= ConditionVariable.new
end
def wait_for_wake_or_timeout
mutex.synchronize do
condition.wait(mutex, POLL_INTERVAL)
end
end
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
Rails.application.config.after_initialize do
TournamentSyncWorker.start unless Rails.env.test?
end

View File

@ -25,6 +25,7 @@ Rails.application.routes.draw do
member do member do
get :timer_end get :timer_end
patch :set_timer_end patch :set_timer_end
patch :sync_state
end end
end end
resources :match_scores, only: %i[show update] resources :match_scores, only: %i[show update]

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
class AddTournamentSyncSupport < ActiveRecord::Migration[7.0]
def change
change_table :tournaments, bulk: true do |t|
t.boolean :read_only_mode, null: false, default: false
t.string :sync_target_url
t.string :sync_auth_token
t.integer :sync_source_tournament_id
t.datetime :sync_last_pushed_at
t.string :sync_last_push_error
end
change_table :teams, bulk: true do |t|
t.integer :sync_source_id
end
change_table :stages, bulk: true do |t|
t.integer :sync_source_id
end
change_table :groups, bulk: true do |t|
t.integer :sync_source_id
end
change_table :matches, bulk: true do |t|
t.integer :sync_source_id
end
change_table :match_scores, bulk: true do |t|
t.integer :sync_source_id
end
change_table :group_scores, bulk: true do |t|
t.integer :sync_source_id
end
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class CreateTournamentSyncQueueEntries < ActiveRecord::Migration[7.0]
def change
create_table :tournament_sync_queue_entries do |t|
t.references :tournament, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
t.text :snapshot_json, null: false
t.datetime :next_attempt_at, null: false
t.datetime :last_attempt_at
t.datetime :locked_at
t.string :lock_token
t.integer :attempts, null: false, default: 0
t.string :status, null: false, default: 'pending'
t.string :last_error
t.timestamps
end
add_index :tournament_sync_queue_entries, :next_attempt_at
add_index :tournament_sync_queue_entries, :status
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSyncSnapshotTrackingToTournaments < ActiveRecord::Migration[7.0]
def change
add_column :tournaments, :sync_last_imported_snapshot_at, :datetime
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSyncSourceIdToBeamers < ActiveRecord::Migration[7.0]
def change
add_column :beamers, :sync_source_id, :integer
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class EnsureTournamentSyncColumnsExist < ActiveRecord::Migration[7.0]
def change
add_column_if_missing :tournaments, :read_only_mode, :boolean, null: false, default: false
add_column_if_missing :tournaments, :sync_target_url, :string
add_column_if_missing :tournaments, :sync_auth_token, :string
add_column_if_missing :tournaments, :sync_source_tournament_id, :integer
add_column_if_missing :tournaments, :sync_last_pushed_at, :datetime
add_column_if_missing :tournaments, :sync_last_push_error, :string
add_column_if_missing :teams, :sync_source_id, :integer
add_column_if_missing :stages, :sync_source_id, :integer
add_column_if_missing :groups, :sync_source_id, :integer
add_column_if_missing :matches, :sync_source_id, :integer
add_column_if_missing :match_scores, :sync_source_id, :integer
add_column_if_missing :group_scores, :sync_source_id, :integer
end
private
def add_column_if_missing(table, column, type, **options)
return if column_exists?(table, column)
add_column(table, column, type, **options)
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
class EnsureTournamentSyncQueueEntriesExist < ActiveRecord::Migration[7.0]
def change
return if table_exists?(:tournament_sync_queue_entries)
create_table :tournament_sync_queue_entries do |t|
t.integer :tournament_id, null: false
t.text :snapshot_json, null: false
t.datetime :next_attempt_at, null: false
t.datetime :last_attempt_at
t.datetime :locked_at
t.string :lock_token
t.integer :attempts, default: 0, null: false
t.string :status, default: 'pending', null: false
t.string :last_error
t.timestamps null: false
end
add_index :tournament_sync_queue_entries, :next_attempt_at
add_index :tournament_sync_queue_entries, :status
add_index :tournament_sync_queue_entries, :tournament_id, unique: true
add_foreign_key :tournament_sync_queue_entries, :tournaments, on_delete: :cascade
end
end

228
doc/leader_follower.md Normal file
View File

@ -0,0 +1,228 @@
# Leader/Follower Tournament Sync
Backend supports one-way tournament replication:
- leader tournament stays writable
- follower tournament stays read only
- leader pushes full snapshots asynchronously
- follower never pulls
- follower never needs to reach leader
This fits live topology:
- leader = local laptop / Raspberry Pi / LAN host without public IP
- follower = internet VM with public HTTPS endpoint
- only leader needs outbound access to follower
## Live Setup
### 1. Create follower tournament on remote backend
Create empty tournament with:
- `read_only_mode: true`
- `sync_auth_token: <shared secret>`
Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation.
### 2. Create normal leader tournament on local backend
Create tournament as usual. This remains normal writable tournament.
### 3. Configure leader to push to follower
Update leader tournament with:
- `sync_target_url: https://remote.example/tournaments/:id/sync_state`
- `sync_auth_token: <same shared secret as follower>`
Leader becomes push source. Follower accepts only authenticated snapshot pushes.
### 4. Run tournament normally on leader
These leader-side actions enqueue async snapshot push:
- tournament updates
- timer updates
- team renames
- match state changes
- match score changes
- stage finish / playoff generation
Queue retries with backoff until follower accepts snapshot.
## Local Manual Testing
Backend now has local manual setup target:
```bash
inv docker-blackbox-follow-manual-up
```
This starts:
- leader production image on `http://127.0.0.1:3002`
- follower production image on `http://127.0.0.1:3003`
- separate leader and follower Postgres containers
- bootstrapped owner users on both backends
Teardown:
```bash
inv docker-blackbox-follow-down
```
### Local topology
Compose setup now separates DB traffic:
- `source-postgres` only on `source-db` internal network
- `follower-postgres` only on `follower-db` internal network
- both apps share only `sync` network
This gives:
- separate production app containers
- separate production DB containers
- DB isolation between leader and follower
Important limit:
- Docker Compose does **not** enforce true one-way HTTP reachability here
- local setup still allows follower container to reach leader container over shared `sync` network
- real production trust model still comes from backend design:
- leader pushes
- follower never pulls
- follower has no code path that contacts leader
So local setup is good for frontend/manual testing of behavior, not strict network-policy simulation.
### Manual frontend testing flow
1. Run `inv docker-blackbox-follow-manual-up`
2. Open leader backend at `http://127.0.0.1:3002`
3. Open follower backend at `http://127.0.0.1:3003`
4. Log in with:
- email: `e2e@example.com`
- password: `password123`
5. On follower backend, create empty read-only follower tournament
6. On leader backend, create normal tournament
7. On leader tournament, set:
- `sync_target_url` to follower `PATCH /tournaments/:id/sync_state`
- `sync_auth_token` to same shared token used on follower
8. Point local frontend leader instance to `http://127.0.0.1:3002`
9. Point local frontend follower/remote instance to `http://127.0.0.1:3003`
10. Progress tournament on leader, verify follower mirrors state and stays read only
Frontend team can use this setup to build:
- owner sync configuration UI
- follower read-only UX
- status display for `sync_last_pushed_at`
- status display for `sync_last_push_error`
- takeover flow by disabling `read_only_mode`
## Takeover
If remote must become writable after sync phase:
1. disable `read_only_mode` on follower
2. backend clears follower token when no longer needed
3. follower becomes normal standalone tournament again
After takeover, leader should no longer push to old follower URL.
## API Contract
### Leader-side writable config
Tournament push mode active when:
- `sync_target_url` present
- `sync_auth_token` present
- `read_only_mode` false
### Follower-side push receiver
Tournament accepts push when:
- `read_only_mode` true
- `sync_auth_token` present
Push endpoint:
- `PATCH /tournaments/:id/sync_state`
Auth:
- `Authorization: Bearer <token>`
Payload:
- `{ snapshot: ... }`
Snapshot import replaces tournament graph using `sync_source_id` mapping, not local DB ids.
## Frontend Integration Notes
Frontend does not need separate "follower UI mode" for normal viewers. Remote tournament should look same for non-owner users.
Owner-facing frontend can expose:
- `read_only_mode` toggle
- `sync_target_url` input
- `sync_auth_token` input
- sync health display:
- `sync_last_pushed_at`
- `sync_last_push_error`
Recommended owner UX:
1. create remote follower first
2. copy remote `sync_auth_token`
3. paste remote `sync_state` URL into leader
4. save leader sync config
5. show last push time / error state
6. allow explicit follower takeover by disabling `read_only_mode`
Frontend should block owner edits on follower when `read_only_mode` true. Backend already enforces this with `423 Locked`.
## Future Feature Rule
Any new persisted tournament state must be handled in three places:
1. leader write path
2. snapshot export/import path
3. follower/read-only behavior
Guardrail now exists:
- `TournamentSyncSchema` lists every synced and ignored model column
- spec fails when new DB column appears without explicit sync decision
This does not auto-implement sync for new features. It makes missing sync work fail loudly in tests instead of silently drifting.
For every new tournament feature, add:
1. schema decision in `TournamentSyncSchema`
2. snapshot builder/importer support if state must replicate
3. one follower lifecycle test or roundtrip spec that proves behavior survives sync
## Coverage Today
Current backend coverage includes:
- async push queue and retry behavior
- follower auth and read-only lock
- full follower lifecycle HTTP E2E with unrelated tournaments and mismatched ids
- team renames
- multiple decider matches
- playoff generation blocked before deciders finish
- playoff generation after deciders finish
- follower takeover
- large roundtrip graph coverage for 32 groups, 4 teams each, 64-team playoffs
Still not magic:
- if new feature adds new state and nobody updates sync contract, tests will fail
- if new feature changes behavior but not schema, add behavior test too

View File

@ -0,0 +1,107 @@
services:
source-postgres:
image: ${TURNIERE_BLACKBOX_POSTGRES_IMAGE:-postgres:16}
environment:
POSTGRES_DB: ${TURNIERE_BLACKBOX_SOURCE_POSTGRES_DB:-turniere_source_blackbox}
POSTGRES_USER: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- turniere-blackbox-follow-source-postgres:/var/lib/postgresql/data
networks:
- source-db
follower-postgres:
image: ${TURNIERE_BLACKBOX_POSTGRES_IMAGE:-postgres:16}
environment:
POSTGRES_DB: ${TURNIERE_BLACKBOX_FOLLOWER_POSTGRES_DB:-turniere_follower_blackbox}
POSTGRES_USER: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- turniere-blackbox-follow-follower-postgres:/var/lib/postgresql/data
networks:
- follower-db
source-app:
image: ${TURNIERE_BLACKBOX_APP_IMAGE:-registry.gitlab.com/turniere/turniere-backend/production/commits:local}
depends_on:
source-postgres:
condition: service_healthy
environment:
POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@source-postgres:5432/${TURNIERE_BLACKBOX_SOURCE_POSTGRES_DB:-turniere_source_blackbox}
POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}
SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod}
MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key}
MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com}
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: ${TURNIERE_BLACKBOX_TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS:-3}
RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1"
ports:
- "${TURNIERE_BLACKBOX_SOURCE_HOST_PORT:-3002}:3000"
networks:
- source-db
- sync
follower-app:
image: ${TURNIERE_BLACKBOX_APP_IMAGE:-registry.gitlab.com/turniere/turniere-backend/production/commits:local}
depends_on:
follower-postgres:
condition: service_healthy
environment:
POSTGRES_URL: postgres://${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}:${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}@follower-postgres:5432/${TURNIERE_BLACKBOX_FOLLOWER_POSTGRES_DB:-turniere_follower_blackbox}
POSTGRES_USERNAME: ${TURNIERE_BLACKBOX_POSTGRES_USER:-turniere}
POSTGRES_PASSWORD: ${TURNIERE_BLACKBOX_POSTGRES_PASSWORD:-turniere}
SECRET_KEY_BASE: ${TURNIERE_BLACKBOX_SECRET_KEY_BASE:-turniere-blackbox-secret-key-base-please-change-in-real-prod}
MAILGUN_API_KEY: ${TURNIERE_BLACKBOX_MAILGUN_API_KEY:-blackbox-test-api-key}
MAILGUN_DOMAIN: ${TURNIERE_BLACKBOX_MAILGUN_DOMAIN:-blackbox.example.com}
TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS: ${TURNIERE_BLACKBOX_TOURNAMENT_SYNC_HTTP_TIMEOUT_SECONDS:-3}
RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1"
ports:
- "${TURNIERE_BLACKBOX_FOLLOWER_HOST_PORT:-3003}:3000"
networks:
- follower-db
- sync
e2e:
image: ${TURNIERE_BLACKBOX_RUNNER_IMAGE:-registry.gitlab.com/turniere/turniere-backend/test/commits:local}
depends_on:
source-app:
condition: service_started
follower-app:
condition: service_started
working_dir: /app
environment:
NO_PROXY: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
no_proxy: source-app,follower-app,source-postgres,follower-postgres,localhost,127.0.0.1
TURNIERE_SOURCE_E2E_BASE_URL: ${TURNIERE_SOURCE_E2E_BASE_URL:-http://source-app:3000}
TURNIERE_FOLLOWER_E2E_BASE_URL: ${TURNIERE_FOLLOWER_E2E_BASE_URL:-http://follower-app:3000}
TURNIERE_E2E_EMAIL: ${TURNIERE_E2E_EMAIL:-e2e@example.com}
TURNIERE_E2E_PASSWORD: ${TURNIERE_E2E_PASSWORD:-password123}
TURNIERE_E2E_USERNAME: ${TURNIERE_E2E_USERNAME:-e2e-user}
TURNIERE_E2E_ALT_EMAIL: ${TURNIERE_E2E_ALT_EMAIL:-e2e-alt@example.com}
TURNIERE_E2E_ALT_PASSWORD: ${TURNIERE_E2E_ALT_PASSWORD:-password123}
TURNIERE_E2E_ALT_USERNAME: ${TURNIERE_E2E_ALT_USERNAME:-e2e-alt-user}
networks:
- sync
volumes:
turniere-blackbox-follow-source-postgres:
turniere-blackbox-follow-follower-postgres:
networks:
source-db:
internal: true
follower-db:
internal: true
sync:

View File

@ -21,6 +21,7 @@ module TurniereE2E
def initialize(base_url:) def initialize(base_url:)
@base_url = base_url.sub(%r{/\z}, '') @base_url = base_url.sub(%r{/\z}, '')
@auth_headers = {} @auth_headers = {}
@login_credentials = nil
end end
def register!(email:, password:, username:) def register!(email:, password:, username:)
@ -39,6 +40,8 @@ module TurniereE2E
end end
def login!(email:, password:) def login!(email:, password:)
@login_credentials = { email: email, password: password }
@auth_headers = {}
response = request( response = request(
:post, :post,
'/users/sign_in', '/users/sign_in',
@ -73,7 +76,9 @@ module TurniereE2E
private private
def request(method, path, body: nil, params: nil, headers: {}) def request(method, path, body: nil, params: nil, headers: {}, retry_on_unauthorized: true, refresh_session: true)
login!(**@login_credentials) if refresh_session && should_refresh_session?(method, path)
uri = URI.parse("#{base_url}#{path}") uri = URI.parse("#{base_url}#{path}")
uri.query = URI.encode_www_form(params) if params && !params.empty? uri.query = URI.encode_www_form(params) if params && !params.empty?
@ -95,11 +100,17 @@ module TurniereE2E
end end
parsed_json = parse_json_body(response.body) parsed_json = parse_json_body(response.body)
{ result = {
status: response.code.to_i, status: response.code.to_i,
json: parsed_json, json: parsed_json,
headers: response.each_header.to_h headers: response.each_header.to_h
} }
store_auth_headers!(result)
if retry_on_unauthorized && result[:status] == 401 && can_reauthenticate?(path)
login!(**@login_credentials)
return request(method, path, body: body, params: params, headers: headers, retry_on_unauthorized: false, refresh_session: false)
end
result
end end
def merged_headers(headers) def merged_headers(headers)
@ -118,11 +129,20 @@ module TurniereE2E
end end
def store_auth_headers!(response) def store_auth_headers!(response)
%w[access-token client uid].each_with_object({}) do |key, auth_headers| %w[access-token client uid expiry token-type authorization].each_with_object({}) do |key, auth_headers|
auth_headers[key] = response[:headers][key] if response[:headers].key?(key) value = response[:headers][key]
auth_headers[key] = value if response[:headers].key?(key) && !value.to_s.empty?
end.tap do |headers| end.tap do |headers|
@auth_headers = headers unless headers.empty? @auth_headers = @auth_headers.merge(headers) unless headers.empty?
end end
end
def can_reauthenticate?(path)
!@login_credentials.nil? && path != '/users/sign_in'
end
def should_refresh_session?(method, path)
!@login_credentials.nil? && method != :get && path != '/users/sign_in'
end end
end end
end end

View File

@ -164,6 +164,21 @@ RSpec.describe BeamersController, type: :controller do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
end end
context 'on read only follower tournament' do
before(:each) do
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
apply_authentication_headers_for @owner
end
it 'returns locked' do
patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param,
display_state: 'groups'
}
expect(response).to have_http_status(:locked)
end
end
end end
describe 'DELETE #destroy' do describe 'DELETE #destroy' do

View File

@ -84,11 +84,11 @@ RSpec.describe StagesController, type: :controller do
end end
it 'adds new stages to the tournament' do it 'adds new stages to the tournament' do
expect(running_group_stage.tournament.stages.size).to be > 1 expect(running_group_stage.tournament.reload.stages.size).to be > 1
end end
it 'adds the right teams' do it 'adds the right teams' do
expect(running_group_stage.tournament.stages.max_by(&:level).teams) expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams)
.to match_array(GroupStageService.get_advancing_teams(running_group_stage)) .to match_array(GroupStageService.get_advancing_teams(running_group_stage))
end end
end end

View File

@ -224,6 +224,25 @@ RSpec.describe TournamentsController, type: :controller do
apply_authentication_headers_for @user apply_authentication_headers_for @user
end end
context 'with read_only_mode=true' do
it 'creates an empty follower tournament without teams payload' do
expect do
post :create, params: {
name: 'Follower Tournament',
public: false,
read_only_mode: true,
sync_auth_token: 'shared-secret'
}
end.to change(Tournament, :count).by(1)
expect(response).to have_http_status(:created)
tournament = Tournament.find(deserialize_response(response)[:id])
expect(tournament.read_only_mode?).to eq(true)
expect(tournament.teams).to be_empty
expect(tournament.stages).to be_empty
end
end
context 'with existing teams' do context 'with existing teams' do
it 'creates a new Tournament' do it 'creates a new Tournament' do
expect do expect do
@ -417,6 +436,48 @@ RSpec.describe TournamentsController, type: :controller do
expect(response.media_type).to eq('application/json') expect(response.media_type).to eq('application/json')
end end
it 'allows sync config changes on read only tournaments' do
@tournament.update!(read_only_mode: true, sync_auth_token: 'old-token')
put :update, params: {
id: @tournament.to_param,
read_only_mode: false,
sync_target_url: 'https://remote.example.com/tournaments/1/sync_state',
sync_auth_token: 'new-token'
}
expect(response).to have_http_status(:ok)
@tournament.reload
expect(@tournament.read_only_mode?).to eq(false)
expect(@tournament.sync_target_url).to include('/sync_state')
expect(@tournament.sync_auth_token).to eq('new-token')
end
it 'allows follower takeover when only read_only_mode is disabled' do
@tournament.update!(read_only_mode: true, sync_auth_token: 'old-token')
request.headers['CONTENT_TYPE'] = 'application/json'
put :update, params: {
id: @tournament.to_param,
read_only_mode: false
}, as: :json
expect(response).to have_http_status(:ok)
@tournament.reload
expect(@tournament.read_only_mode?).to eq(false)
expect(@tournament.sync_target_url).to be_blank
expect(@tournament.sync_auth_token).to be_blank
end
it 'blocks normal updates while tournament is read only' do
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
put :update, params: { id: @tournament.to_param, name: 'Should Not Work' }
expect(response).to have_http_status(:locked)
expect(deserialize_response(response)[:error]).to eq('Tournament is in read only follower mode')
end
context 'any variable relevant for group stage to playoff transition changed' do context 'any variable relevant for group stage to playoff transition changed' do
before(:each) do before(:each) do
@filled_tournament = create(:group_stage_tournament) @filled_tournament = create(:group_stage_tournament)
@ -550,6 +611,14 @@ RSpec.describe TournamentsController, type: :controller do
delete :destroy, params: { id: @tournament.to_param } delete :destroy, params: { id: @tournament.to_param }
end.to change(Team, :count).by(-@tournament.teams.size) end.to change(Team, :count).by(-@tournament.teams.size)
end end
it 'blocks destroy while tournament is read only' do
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
delete :destroy, params: { id: @tournament.to_param }
expect(response).to have_http_status(:locked)
end
end end
context 'as another user' do context 'as another user' do
@ -563,4 +632,53 @@ RSpec.describe TournamentsController, type: :controller do
end end
end end
end end
describe 'PATCH #sync_state' do
before do
@tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
end
it 'imports a pushed snapshot' do
request.headers['Authorization'] = 'Bearer shared-secret'
patch :sync_state, params: {
id: @tournament.to_param,
snapshot: {
id: 123,
name: 'Synced Tournament',
code: 'abc123',
description: 'Synced description',
public: true,
playoff_teams_amount: 4,
instant_finalists_amount: 4,
intermediate_round_participants_amount: 0,
timer_end: nil,
teams: [{ id: 7, name: 'Alpha' }],
stages: [],
groups: [],
matches: [],
match_scores: [],
group_scores: []
}
}
expect(response).to have_http_status(:ok)
@tournament.reload
expect(@tournament.sync_source_tournament_id).to eq(123)
expect(@tournament.name).to eq('Synced Tournament')
expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
end
it 'rejects invalid tokens' do
request.headers['Authorization'] = 'Bearer wrong-token'
patch :sync_state, params: {
id: @tournament.to_param,
snapshot: { id: 123 }
}
expect(response).to have_http_status(:unauthorized)
expect(deserialize_response(response)[:error]).to eq('Invalid sync token')
end
end
end end

View File

@ -0,0 +1,435 @@
# frozen_string_literal: true
require 'spec_helper'
require 'securerandom'
require_relative '../../../e2e/lib/api_client'
RSpec.describe 'Tournament follower sync HTTP E2E' do
required_env_vars = %w[
TURNIERE_E2E_EMAIL
TURNIERE_E2E_PASSWORD
TURNIERE_SOURCE_E2E_BASE_URL
TURNIERE_FOLLOWER_E2E_BASE_URL
].freeze
before do
missing_vars = required_env_vars.reject { |name| ENV.key?(name) }
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
end
let(:source_base_url) { ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL') }
let(:follower_base_url) { ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL') }
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
let(:sync_token) { "sync-#{SecureRandom.hex(8)}" }
let(:source_owner_client) { login_client(base_url: source_base_url, email: owner_email, password: owner_password) }
let(:follower_owner_client) { login_client(base_url: follower_base_url, email: owner_email, password: owner_password) }
let(:source_anonymous_client) { TurniereE2E::ApiClient.new(base_url: source_base_url) }
let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) }
it 'pushes full source lifecycle into read only follower tournament' do
log_phase!('seed unrelated tournaments')
source_noise = seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Noise', range: 2..4)
follower_noise = seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Noise', range: 5..7)
log_phase!('create source and follower tournaments')
source = create_group_stage_tournament(client: source_owner_client, public: false, name_prefix: 'Sync Source')
follower = create_follower_tournament(client: follower_owner_client, public: true, name_prefix: 'Sync Follower')
expect(source.fetch(:id)).not_to eq(follower.fetch(:id))
log_phase!('configure sync and verify initial replication')
configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state",
sync_auth_token: sync_token
})
expect(configure_sync[:status]).to eq(200)
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
log_phase!('tighten playoff cutoff and rename teams')
update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
playoff_teams_amount: 4,
instant_finalists_amount: 4,
intermediate_round_participants_amount: 0
})
expect(update_cutoff[:status]).to eq(200)
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
source_group_stage = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
rename_source_tournament_teams!(
source_tournament_id: source.fetch(:id),
team_names: [
group_team_map(source_group_stage.fetch(:groups).first).fetch(1).fetch(:name),
group_team_map(source_group_stage.fetch(:groups).last).fetch(2).fetch(:name)
]
)
source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Midway Noise', range: 2..3))
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Midway Noise', range: 3..4))
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
log_phase!('play group stage with deciders')
play_group_with_decider_lifecycle!(source_id: source.fetch(:id), groups: source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) })
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
log_phase!('verify playoff generation replicated')
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1)
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
follower_group = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }.fetch(:groups).first
follower_team = follower_group.fetch(:matches).first.fetch(:match_scores).first.fetch(:team)
locked_team_update = follower_owner_client.patch("/teams/#{follower_team.fetch(:id)}", body: { name: 'Nope' })
expect(locked_team_update[:status]).to eq(423)
expect(locked_team_update.dig(:json, :error)).to eq('Tournament is in read only follower mode')
source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise', range: 2..4))
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', range: 2..5))
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
log_phase!('finish playoff bracket')
finish_playoff_bracket!(source_id: source.fetch(:id))
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
log_phase!('verify final follower state and takeover')
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
disable_follower = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}", body: { read_only_mode: false })
expect(disable_follower[:status]).to eq(200)
follower_after_disable = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id))
writable_team = follower_after_disable.fetch(:teams).first
takeover_update = follower_owner_client.patch("/teams/#{writable_team.fetch(:id)}", body: { name: "#{writable_team.fetch(:name)} takeover" })
expect(takeover_update[:status]).to eq(200)
end
def log_phase!(message)
puts "phase #{message}"
$stdout.flush
end
def login_client(base_url:, email:, password:)
client = TurniereE2E::ApiClient.new(base_url: base_url)
response = client.login!(email: email, password: password)
expect(response[:status]).to eq(200)
expect(client.authenticated?).to eq(true)
client
end
def create_group_stage_tournament(client:, public:, name_prefix:)
payload = {
name: "#{name_prefix} #{unique_suffix}",
description: 'Follower sync source tournament',
public: public,
group_stage: true,
playoff_teams_amount: 8,
teams: 4.times.flat_map do |group_index|
4.times.map do |seed_index|
{
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
group: group_index
}
end
end
}
response = client.post('/tournaments', body: payload)
expect(response[:status]).to eq(201)
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
end
def create_playoff_tournament(client:, public:, name_prefix:)
payload = {
name: "#{name_prefix} #{unique_suffix}",
description: 'Unrelated playoff tournament',
public: public,
teams: 4.times.map do |index|
{ name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" }
end
}
response = client.post('/tournaments', body: payload)
expect(response[:status]).to eq(201)
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
end
def create_follower_tournament(client:, public:, name_prefix:)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{unique_suffix}",
description: 'Follower tournament',
public: public,
read_only_mode: true,
sync_auth_token: sync_token
})
expect(response[:status]).to eq(201)
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
end
def fetch_tournament(client:, tournament_id:)
response = client.get("/tournaments/#{tournament_id}")
expect(response[:status]).to eq(200)
response.fetch(:json)
end
def seed_unrelated_tournaments!(client:, prefix:, range:)
amount = rand(range)
Array.new(amount) do |index|
if index.even?
create_playoff_tournament(client: client, public: false, name_prefix: "#{prefix} Playoff #{index}")
else
create_group_stage_tournament(client: client, public: true, name_prefix: "#{prefix} Groups #{index}")
end
end
end
def exercise_unrelated_requests!(source_noise:, follower_noise:)
mutate_unrelated_tournament!(client: source_owner_client, tournaments: source_noise, suffix: 'source')
mutate_unrelated_tournament!(client: follower_owner_client, tournaments: follower_noise, suffix: 'follower')
end
def mutate_unrelated_tournament!(client:, tournaments:, suffix:)
tournament = tournaments.sample
updated_name = "#{tournament.fetch(:name)} #{suffix} #{unique_suffix}"
update = client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: updated_name })
expect(update[:status]).to eq(200)
refreshed = fetch_tournament(client: client, tournament_id: tournament.fetch(:id))
team = refreshed.fetch(:teams).first
return if team.nil?
team_update = client.patch("/teams/#{team.fetch(:id)}", body: { name: "#{team.fetch(:name)} #{suffix}" })
expect(team_update[:status]).to eq(200)
end
def rename_source_tournament_teams!(source_tournament_id:, team_names:)
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
team_names.each_with_index do |team_name, index|
team = tournament.fetch(:teams).find { |candidate| candidate.fetch(:name) == team_name }
expect(team).not_to be_nil
response = source_owner_client.patch("/teams/#{team.fetch(:id)}", body: {
name: "#{team.fetch(:name)} Renamed #{index + 1}"
})
expect(response[:status]).to eq(200)
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
end
end
def wait_for_tournament_sync!(source_tournament_id:, follower_tournament_id:, timeout: 20)
deadline = Time.now + timeout
last_progress_at = Time.now
loop do
source = fetch_tournament(client: source_anonymous_client, tournament_id: source_tournament_id)
follower = fetch_tournament(client: follower_anonymous_client, tournament_id: follower_tournament_id)
return follower if tournament_signature(source) == tournament_signature(follower)
if Time.now - last_progress_at >= 2
puts "sync wait source=#{sync_progress_signature(source).inspect} follower=#{sync_progress_signature(follower).inspect}"
$stdout.flush
last_progress_at = Time.now
end
if Time.now >= deadline
raise <<~ERROR
Follower did not catch up within #{timeout}s
source_signature=#{tournament_signature(source).inspect}
follower_signature=#{tournament_signature(follower).inspect}
ERROR
end
sleep 0.5
end
end
def sync_progress_signature(tournament)
{
name: tournament.fetch(:name),
stages: tournament.fetch(:stages).map { |stage| [stage.fetch(:level), stage.fetch(:state)] }
}
end
def synced_team_id_pairs(source:, follower:)
source_by_name = source.fetch(:teams).each_with_object({}) do |team, teams_by_name|
teams_by_name[team.fetch(:name)] = team
end
follower.fetch(:teams).filter_map do |team|
source_team = source_by_name[team.fetch(:name)]
next if source_team.nil?
next if source_team.fetch(:id) == team.fetch(:id)
[source_team.fetch(:id), team.fetch(:id)]
end
end
def play_group_with_decider_lifecycle!(source_id:, groups:)
groups.each_with_index do |group, index|
if index < 2
play_group_matches_for_decider!(group)
else
finish_group_clear_winner!(group)
end
end
group_stage = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
stage_finish = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
expect(stage_finish[:status]).to eq(422)
expect(stage_finish.dig(:json, :error)).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place')
expect(stage_finish.dig(:json, :blocking_ties).size).to be >= 2
stage_finish.fetch(:json).fetch(:blocking_ties).each_with_index do |blocking_tie, index|
decider_create = source_owner_client.post("/groups/#{blocking_tie.fetch(:group_id)}/matches", body: {
team_ids: blocking_tie.fetch(:team_ids)
})
expect(decider_create[:status]).to eq(201)
decider_match = decider_create.fetch(:json)
start_decider = source_owner_client.patch("/matches/#{decider_match.fetch(:id)}", body: { state: 'in_progress' })
expect(start_decider[:status]).to eq(200)
decider_match.fetch(:match_scores).sort_by { |score| score.fetch(:id) }.each_with_index do |score, score_index|
response = source_owner_client.patch("/match_scores/#{score.fetch(:id)}", body: {
hidden_points: score_index.zero? ? 5 + index : 3
})
expect(response[:status]).to eq(200)
end
finish_decider = source_owner_client.patch("/matches/#{decider_match.fetch(:id)}", body: { state: 'finished' })
expect(finish_decider[:status]).to eq(200)
end
finish_stage_after_decider = source_owner_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
expect(finish_stage_after_decider[:status]).to eq(200)
end
def play_group_matches_for_decider!(group)
teams = group_team_map(group)
play_group_match!(group, teams.fetch(1), teams.fetch(2), 1, 1)
play_group_match!(group, teams.fetch(1), teams.fetch(3), 2, 0)
play_group_match!(group, teams.fetch(1), teams.fetch(4), 2, 0)
play_group_match!(group, teams.fetch(2), teams.fetch(3), 2, 0)
play_group_match!(group, teams.fetch(2), teams.fetch(4), 2, 0)
play_group_match!(group, teams.fetch(3), teams.fetch(4), 1, 0)
end
def finish_group_clear_winner!(group)
group.fetch(:matches).each_with_index do |match, index|
play_match!(match_id: match.fetch(:id), first_score: 10 + index, second_score: index)
end
end
def group_team_map(group)
group.fetch(:matches)
.flat_map { |match| match.fetch(:match_scores).map { |score| score.fetch(:team) } }
.uniq { |team| team.fetch(:id) }
.sort_by { |team| team.fetch(:name) }
.each_with_index
.to_h { |team, index| [index + 1, team] }
end
def play_group_match!(group, team_a, team_b, score_a, score_b)
match = group.fetch(:matches).find do |candidate|
team_ids = candidate.fetch(:match_scores).map { |score| score.dig(:team, :id) }.sort
team_ids == [team_a.fetch(:id), team_b.fetch(:id)].sort
end
play_match!(match_id: match.fetch(:id), first_score: score_a, second_score: score_b)
end
def finish_playoff_bracket!(source_id:)
loop do
tournament = fetch_tournament(client: source_anonymous_client, tournament_id: source_id)
playoff_matches = tournament.fetch(:stages)
.select { |stage| stage.fetch(:level) >= 0 }
.flat_map { |stage| stage.fetch(:matches) }
.select { |match| %w[not_started in_progress].include?(match.fetch(:state)) && match.fetch(:match_scores).size == 2 }
.sort_by { |match| [match.dig(:stage, :id) || 0, match.fetch(:position)] }
break if playoff_matches.empty?
playoff_matches.each_with_index do |match, index|
play_match!(match_id: match.fetch(:id), first_score: 20 + index, second_score: index)
end
end
end
def play_match!(match_id:, first_score:, second_score:)
match = source_anonymous_client.get("/matches/#{match_id}")
expect(match[:status]).to eq(200)
unless match.dig(:json, :state) == 'in_progress'
start_response = source_owner_client.patch("/matches/#{match_id}", body: { state: 'in_progress' })
expect(start_response[:status]).to eq(200)
match = source_anonymous_client.get("/matches/#{match_id}")
end
ordered_scores = match.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) }
[first_score, second_score].each_with_index do |points, index|
response = source_owner_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points })
expect(response[:status]).to eq(200)
end
finish_response = source_owner_client.patch("/matches/#{match_id}", body: { state: 'finished' })
expect(finish_response[:status]).to eq(200)
end
def tournament_signature(tournament)
{
name: tournament.fetch(:name),
public: tournament.fetch(:public),
playoff_teams_amount: tournament.fetch(:playoff_teams_amount),
instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
timer_end: tournament[:timer_end],
teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) },
stages: tournament.fetch(:stages).sort_by { |stage| stage.fetch(:level) }.map do |stage|
{
level: stage.fetch(:level),
state: stage.fetch(:state),
groups: stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.map do |group|
{
number: group.fetch(:number),
matches: group.fetch(:matches).sort_by { |match| match.fetch(:position) }.map do |match|
match_signature(match)
end
}
end,
matches: stage.fetch(:matches).sort_by { |match| match.fetch(:position) }.map do |match|
match_signature(match)
end
}
end
}
end
def match_signature(match)
{
state: match.fetch(:state),
position: match.fetch(:position),
decider: match.fetch(:decider),
scores: match.fetch(:match_scores).map do |score|
{
team_name: score.dig(:team, :name),
points: score.fetch(:points),
hidden_points: score.fetch(:hidden_points)
}
end.sort_by { |score| score.fetch(:team_name).to_s }
}
end
def unique_suffix
@unique_suffix_counter ||= 0
@unique_suffix_counter += 1
"#{SecureRandom.hex(3)}-#{@unique_suffix_counter}"
end
end

View File

@ -27,5 +27,9 @@ RSpec.describe TournamentsController, type: :routing do
it 'routes to #destroy' do it 'routes to #destroy' do
expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1') expect(delete: '/tournaments/1').to route_to('tournaments#destroy', id: '1')
end end
it 'routes to #sync_state' do
expect(patch: '/tournaments/1/sync_state').to route_to('tournaments#sync_state', id: '1')
end
end end
end end

View File

@ -0,0 +1,225 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TournamentSnapshotImporter do
describe '.import!' do
it 'replaces follower tournament graph using source ids as stable mapping keys' do
source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
source.beamers.create!(name: 'stream-a', display_state: 'during_game', qr_code_url: 'https://example.com/qr')
follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
snapshot = TournamentSnapshotBuilder.build(source)
described_class.import!(tournament: follower, snapshot: snapshot)
follower.reload
expect(follower.sync_source_tournament_id).to eq(source.id)
expect(follower.name).to eq(source.name)
expect(follower.teams.count).to eq(snapshot.fetch(:teams).count)
expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count)
expect(follower.stages.count).to eq(source.stages.count)
expect(follower.matches.count).to eq(source.matches.count)
expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) })
expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map { |beamer| beamer.fetch(:id) })
end
it 'ignores stale snapshots that arrive after a newer one' do
follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
newer_source = create(:tournament, name: 'Newer name')
older_source = create(:tournament, name: 'Older name')
newer_snapshot = TournamentSnapshotBuilder.build(newer_source).merge(sync_generated_at: '2026-04-14T10:30:02Z')
older_snapshot = TournamentSnapshotBuilder.build(older_source).merge(sync_generated_at: '2026-04-14T10:30:01Z')
described_class.import!(tournament: follower, snapshot: newer_snapshot)
described_class.import!(tournament: follower, snapshot: older_snapshot)
follower.reload
expect(follower.name).to eq('Newer name')
expect(follower.sync_last_imported_snapshot_at).to eq(Time.zone.parse('2026-04-14T10:30:02Z'))
end
it 'round-trips large leader tournament graph with mismatched ids, many groups, deciders and 64-team playoffs' do
create_list(:tournament, 3)
source = create_large_source_tournament!
create_list(:tournament, 5)
follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
snapshot = TournamentSnapshotBuilder.build(source)
described_class.import!(tournament: follower, snapshot: snapshot)
follower.reload
expect(follower.id).not_to eq(source.id)
expect(sync_signature(follower)).to eq(sync_signature(source))
expect(follower.teams.count).to eq(128)
expect(follower.stages.where(level: -1).first.groups.count).to eq(32)
expect(follower.stages.where('level >= 0').sum { |stage| stage.matches.count }).to eq(63)
expect(follower.matches.select(&:decider?).count).to eq(source.matches.select(&:decider?).count)
expect(follower.teams.pluck(:sync_source_id)).to match_array(source.teams.pluck(:id))
expect(source.teams.pluck(:id) & follower.teams.pluck(:id)).not_to match_array(source.teams.pluck(:id))
end
end
def create_large_source_tournament!
tournament = create(
:tournament,
teams_count: 0,
name: 'Large Sync Source',
playoff_teams_amount: 64,
instant_finalists_amount: 64,
intermediate_round_participants_amount: 0
)
tournament.beamers.find_by!(name: Beamer::DEFAULT_NAME).update!(
display_state: 'groups',
qr_code_url: 'https://example.com/default-qr',
logo_url: '/logos/default.png'
)
tournament.beamers.create!(
name: 'stream-a',
display_state: 'during_game',
qr_code_url: 'https://example.com/stream-a',
logo_url: '/logos/stream-a.png'
)
tournament.beamers.create!(
name: 'stream-b',
display_state: 'between_games',
qr_code_url: 'https://example.com/stream-b',
logo_url: '/logos/stream-b.png'
)
group_stage = Stage.create!(tournament: tournament, level: -1, state: :finished)
advancing_teams = []
32.times do |group_index|
group = Group.create!(stage: group_stage, number: group_index + 1)
teams = 4.times.map do |seed_index|
create(
:team,
tournament: tournament,
name: "Leader-G#{group_index + 1}-S#{seed_index + 1}"
)
end
teams[0].update!(name: "#{teams[0].name} Renamed") if group_index.zero?
teams[1].update!(name: "#{teams[1].name} Renamed") if group_index == 15
create_group_round_robin!(group: group, teams: teams)
create_group_scores!(group: group, teams: teams)
if group_index < 3
create_decider_match!(group: group, teams: teams.take(2), hidden_points: [8 - group_index, 4 + group_index])
end
advancing_teams.concat(teams.take(2))
end
result = AddPlayoffsToTournamentAndSave.call(tournament: tournament, teams: advancing_teams)
expect(result).to be_a_success
tournament.reload
play_some_playoff_matches!(tournament)
tournament.reload
end
def create_group_round_robin!(group:, teams:)
pairings = [
[0, 1, 2, 1],
[2, 3, 3, 0],
[0, 2, 4, 1],
[1, 3, 2, 0],
[0, 3, 5, 2],
[1, 2, 4, 3]
]
pairings.each_with_index do |(left_index, right_index, left_points, right_points), position|
match = Match.create!(group: group, state: :finished, position: position, decider: false)
MatchScore.create!(match: match, team: teams[left_index], points: left_points, hidden_points: 0)
MatchScore.create!(match: match, team: teams[right_index], points: right_points, hidden_points: 0)
end
end
def create_decider_match!(group:, teams:, hidden_points:)
match = Match.create!(group: group, state: :finished, position: 6, decider: true)
MatchScore.create!(match: match, team: teams.first, points: 0, hidden_points: hidden_points.first)
MatchScore.create!(match: match, team: teams.second, points: 0, hidden_points: hidden_points.last)
end
def create_group_scores!(group:, teams:)
teams.each_with_index do |team, index|
GroupScore.create!(
group: group,
team: team,
group_points: 9 - index,
scored_points: 12 - index,
received_points: index,
position: index + 1
)
end
end
def play_some_playoff_matches!(tournament)
initial_stage = tournament.stages.where('level >= 0').max_by(&:level)
initial_stage.matches.sort_by(&:position).first(4).each_with_index do |match, index|
match.update!(state: :finished)
ordered_scores = match.match_scores.sort_by(&:id)
ordered_scores.first.update!(points: 20 + index)
ordered_scores.second.update!(points: index)
PlayoffStageService.populate_match_below(match).uniq.each(&:save!)
end
end
def sync_signature(tournament)
{
name: tournament.name,
public: tournament.public,
playoff_teams_amount: tournament.playoff_teams_amount,
instant_finalists_amount: tournament.instant_finalists_amount,
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
timer_end: tournament.timer_end&.iso8601,
teams: tournament.teams.order(:name).map { |team| team.name },
beamers: tournament.beamers.order(:name).map do |beamer|
beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys
end,
stages: tournament.stages.order(level: :asc).map do |stage|
{
level: stage.level,
state: stage.state,
groups: stage.groups.order(:number).map do |group|
{
number: group.number,
matches: group.matches.order(:position).map { |match| match_signature(match) },
group_scores: group.group_scores.includes(:team).sort_by(&:position).map do |score|
{
team_name: score.team.name,
position: score.position,
group_points: score.group_points,
scored_points: score.scored_points,
received_points: score.received_points
}
end
}
end,
matches: stage.matches.order(:position).map { |match| match_signature(match) }
}
end
}
end
def match_signature(match)
{
state: match.state,
position: match.position,
decider: match.decider,
scores: match.match_scores.includes(:team).sort_by { |score| score.team.name }.map do |score|
{
team_name: score.team.name,
points: score.points,
hidden_points: score.hidden_points
}
end
}
end
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TournamentSyncEnqueue do
describe '.call' do
it 'resets retry state for newly enqueued snapshots' do
tournament = create(:tournament,
sync_target_url: 'https://remote.example.com/tournaments/1/sync_state',
sync_auth_token: 'shared-secret')
entry = TournamentSyncQueueEntry.create!(
tournament: tournament,
snapshot_json: '{"old":true}',
status: 'pending',
attempts: 4,
last_error: 'network down',
last_attempt_at: 5.minutes.ago,
next_attempt_at: 3.minutes.from_now,
locked_at: 1.minute.ago,
lock_token: 'stale-lock'
)
allow(TournamentSyncWorker).to receive(:start)
allow(TournamentSyncWorker).to receive(:wake)
described_class.call(tournament)
entry.reload
expect(entry.status).to eq('pending')
expect(entry.attempts).to eq(0)
expect(entry.last_error).to be_nil
expect(entry.last_attempt_at).to be_nil
expect(entry.next_attempt_at).to be <= Time.current
expect(entry.locked_at).to be_nil
expect(entry.lock_token).to be_nil
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TournamentSyncProcessor do
describe '.process_due!' do
it 'retries failed pushes asynchronously from queue state' do
tournament = create(:tournament,
sync_target_url: 'https://remote.example.com/tournaments/1/sync_state',
sync_auth_token: 'shared-secret')
TournamentSyncEnqueue.call(tournament)
entry = TournamentSyncQueueEntry.find_by!(tournament: tournament)
allow(TournamentSyncPusher).to receive(:push_snapshot!).and_raise(TournamentSyncPusher::SyncFailed, 'network down')
described_class.process_due!
entry.reload
expect(entry.status).to eq('pending')
expect(entry.last_error).to eq('network down')
expect(entry.attempts).to eq(1)
expect(entry.next_attempt_at).to be > Time.current
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TournamentSyncSchema do
describe 'column coverage' do
described_class::MODEL_COLUMNS.each_key do |model_class|
it "accounts for every #{model_class.name} column in sync contract" do
synced_columns = described_class.synced_columns_for(model_class)
ignored_columns = described_class.ignored_columns_for(model_class)
accounted_columns = synced_columns + ignored_columns
expect(accounted_columns.uniq).to match_array(model_class.column_names)
expect(synced_columns & ignored_columns).to be_empty
end
end
end
end

837
tasks.py
View File

@ -17,6 +17,10 @@ SERVER_PORT = "3000"
TEST_BASE_URL = f"http://127.0.0.1:{SERVER_PORT}" TEST_BASE_URL = f"http://127.0.0.1:{SERVER_PORT}"
BLACKBOX_BASE_URL = TEST_BASE_URL BLACKBOX_BASE_URL = TEST_BASE_URL
BLACKBOX_INTERNAL_BASE_URL = "http://app:3000" BLACKBOX_INTERNAL_BASE_URL = "http://app:3000"
BLACKBOX_SOURCE_INTERNAL_BASE_URL = "http://source-app:3000"
BLACKBOX_FOLLOWER_INTERNAL_BASE_URL = "http://follower-app:3000"
BLACKBOX_SOURCE_HOST_PORT = "3002"
BLACKBOX_FOLLOWER_HOST_PORT = "3003"
E2E_EMAIL = "e2e@example.com" E2E_EMAIL = "e2e@example.com"
E2E_PASSWORD = "password123" E2E_PASSWORD = "password123"
@ -29,8 +33,12 @@ PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commi
TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local" TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local"
BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml" BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml"
BLACKBOX_PROJECT = "turniere-blackbox" BLACKBOX_PROJECT = "turniere-blackbox"
BLACKBOX_FOLLOW_COMPOSE_FILE = "docker-compose.blackbox-follow.yml"
BLACKBOX_FOLLOW_PROJECT = "turniere-blackbox-follow"
BLACKBOX_POSTGRES_IMAGE = "postgres:16" BLACKBOX_POSTGRES_IMAGE = "postgres:16"
BLACKBOX_DB_NAME = "turniere_blackbox" BLACKBOX_DB_NAME = "turniere_blackbox"
BLACKBOX_SOURCE_DB_NAME = "turniere_source_blackbox"
BLACKBOX_FOLLOWER_DB_NAME = "turniere_follower_blackbox"
BLACKBOX_DB_USER = "turniere" BLACKBOX_DB_USER = "turniere"
BLACKBOX_DB_PASSWORD = "turniere" BLACKBOX_DB_PASSWORD = "turniere"
BLACKBOX_SECRET_KEY_BASE = "turniere-blackbox-secret-key-base-please-change-in-real-prod" BLACKBOX_SECRET_KEY_BASE = "turniere-blackbox-secret-key-base-please-change-in-real-prod"
@ -144,7 +152,7 @@ def _stream_subprocess(command, env=None, check=True):
output_chunks = [] output_chunks = []
assert process.stdout is not None assert process.stdout is not None
for line in process.stdout: for line in process.stdout:
print(line, end="") print(line, end="", flush=True)
output_chunks.append(line) output_chunks.append(line)
process.wait() process.wait()
@ -185,6 +193,37 @@ def _compose_env(
) )
def _follow_compose_env(
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
return _env(
TURNIERE_BLACKBOX_SOURCE_HOST_PORT=source_host_port,
TURNIERE_BLACKBOX_FOLLOWER_HOST_PORT=follower_host_port,
TURNIERE_BLACKBOX_APP_IMAGE=app_image,
TURNIERE_BLACKBOX_RUNNER_IMAGE=runner_image,
TURNIERE_BLACKBOX_POSTGRES_IMAGE=postgres_image,
TURNIERE_BLACKBOX_SOURCE_POSTGRES_DB=BLACKBOX_SOURCE_DB_NAME,
TURNIERE_BLACKBOX_FOLLOWER_POSTGRES_DB=BLACKBOX_FOLLOWER_DB_NAME,
TURNIERE_BLACKBOX_POSTGRES_USER=BLACKBOX_DB_USER,
TURNIERE_BLACKBOX_POSTGRES_PASSWORD=BLACKBOX_DB_PASSWORD,
TURNIERE_BLACKBOX_SECRET_KEY_BASE=BLACKBOX_SECRET_KEY_BASE,
TURNIERE_BLACKBOX_MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY,
TURNIERE_BLACKBOX_MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN,
TURNIERE_SOURCE_E2E_BASE_URL=BLACKBOX_SOURCE_INTERNAL_BASE_URL,
TURNIERE_FOLLOWER_E2E_BASE_URL=BLACKBOX_FOLLOWER_INTERNAL_BASE_URL,
TURNIERE_E2E_EMAIL=E2E_EMAIL,
TURNIERE_E2E_PASSWORD=E2E_PASSWORD,
TURNIERE_E2E_USERNAME=E2E_USERNAME,
TURNIERE_E2E_ALT_EMAIL=E2E_ALT_EMAIL,
TURNIERE_E2E_ALT_PASSWORD=E2E_ALT_PASSWORD,
TURNIERE_E2E_ALT_USERNAME=E2E_ALT_USERNAME,
)
def _format_command(command): def _format_command(command):
return " ".join(shlex.quote(part) for part in command) return " ".join(shlex.quote(part) for part in command)
@ -198,13 +237,55 @@ def _rspec_command(*paths):
return command return command
def _compose_run(compose_args, env, capture_output=False, check=True): def _compose_run(compose_args, env, capture_output=False, check=True, project_name=BLACKBOX_PROJECT, compose_file=BLACKBOX_COMPOSE_FILE):
command = _docker_compose_cmd() + compose_args command = _docker_compose_cmd(project_name=project_name, compose_file=compose_file) + compose_args
return _run_subprocess(command, env=env, capture_output=capture_output, check=check) return _run_subprocess(command, env=env, capture_output=capture_output, check=check)
def _print_header(title): def _print_header(title):
print(f"\n== {title} ==") print(f"\n== {title} ==", flush=True)
def _blackbox_network_smoke(c, target_base_url):
command = [
"ruby",
"-rnet/http",
"-ruri",
"-e",
(
"uri = URI.parse(ARGV.fetch(0) + '/healthz'); "
"res = Net::HTTP.get_response(uri); "
"abort(\"healthcheck #{res.code}\") unless res.is_a?(Net::HTTPSuccess); "
"puts(\"network smoke ok #{uri}\")"
),
target_base_url,
]
c.run(_format_command(command), pty=True)
def _blackbox_service_to_service_smoke(c, env, source_label, target_base_url):
command = [
"bundle",
"exec",
"rails",
"runner",
(
"require 'net/http'; "
"require 'uri'; "
"uri = URI.parse(ENV.fetch('BLACKBOX_SMOKE_TARGET_URL') + '/healthz'); "
"Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 3, read_timeout: 3) do |http| "
"res = http.get(uri.request_uri); "
"abort(\"service smoke #{res.code}\") unless res.is_a?(Net::HTTPSuccess); "
"puts(\"service smoke ok #{ENV.fetch('BLACKBOX_SMOKE_SOURCE_LABEL')} -> #{uri}\"); "
"end"
),
]
smoke_env = env.copy()
smoke_env.update(
BLACKBOX_SMOKE_SOURCE_LABEL=source_label,
BLACKBOX_SMOKE_TARGET_URL=target_base_url,
)
c.run(_format_command(command), env=smoke_env, pty=True)
def _bootstrap_user_command(email, password, username): def _bootstrap_user_command(email, password, username):
@ -305,8 +386,22 @@ def _shared_production_env():
) )
def _production_env_for(db_host, db_name):
return _env(
RAILS_ENV="production",
POSTGRES_URL=f"postgres://{BLACKBOX_DB_USER}:{BLACKBOX_DB_PASSWORD}@{db_host}:5432/{db_name}",
POSTGRES_USERNAME=BLACKBOX_DB_USER,
POSTGRES_PASSWORD=BLACKBOX_DB_PASSWORD,
SECRET_KEY_BASE=BLACKBOX_SECRET_KEY_BASE,
MAILGUN_API_KEY=BLACKBOX_MAILGUN_API_KEY,
MAILGUN_DOMAIN=BLACKBOX_MAILGUN_DOMAIN,
RAILS_LOG_TO_STDOUT="1",
RAILS_SERVE_STATIC_FILES="1",
)
def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_EMAIL, alt_password=E2E_ALT_PASSWORD, def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_EMAIL, alt_password=E2E_ALT_PASSWORD,
alt_username=E2E_ALT_USERNAME): alt_username=E2E_ALT_USERNAME, spec_path="spec/e2e/http", extra_env=None):
env = _env( env = _env(
TURNIERE_E2E_BASE_URL=base_url, TURNIERE_E2E_BASE_URL=base_url,
TURNIERE_E2E_EXPECT_PROFILING="false", TURNIERE_E2E_EXPECT_PROFILING="false",
@ -317,8 +412,10 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
TURNIERE_E2E_ALT_PASSWORD=alt_password, TURNIERE_E2E_ALT_PASSWORD=alt_password,
TURNIERE_E2E_ALT_USERNAME=alt_username, TURNIERE_E2E_ALT_USERNAME=alt_username,
) )
command = _rspec_command("spec/e2e/http") if extra_env:
print(_format_command(command)) env.update(extra_env)
command = _rspec_command(spec_path)
print(_format_command(command), flush=True)
result = _stream_subprocess(command, env=env, check=False) result = _stream_subprocess(command, env=env, check=False)
report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}") report = _parse_rspec_report(f"{result.stdout}\n{result.stderr}")
@ -362,6 +459,19 @@ def _stop_process(process, log_handle):
log_handle.close() log_handle.close()
def _bootstrap_blackbox_users(c, env, email, password, username, alt_email, alt_password, alt_username):
c.run(
_bootstrap_user_command(email, password, username),
env=env,
pty=True,
)
c.run(
_bootstrap_user_command(alt_email, alt_password, alt_username),
env=env,
pty=True,
)
@task @task
def setup(c): def setup(c):
"""Install the pinned Bundler and local backend gem dependencies.""" """Install the pinned Bundler and local backend gem dependencies."""
@ -836,6 +946,653 @@ def docker_blackbox_test(
) )
@task(name="docker-blackbox-follow-up")
def docker_blackbox_follow_up(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
build=True,
):
"""Build Docker images, then boot source/follower Postgres and production apps for follower blackbox testing."""
if build:
docker_build_all(c)
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
_print_header("Starting follower production blackbox stack")
_compose_run(
["down", "-v", "--remove-orphans"],
env=env,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
_compose_run(
["up", "-d", "source-postgres", "follower-postgres", "source-app", "follower-app"],
env=env,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
source_base_url = f"http://127.0.0.1:{source_host_port}"
follower_base_url = f"http://127.0.0.1:{follower_host_port}"
_print_header(f"Waiting for source app healthcheck at {source_base_url}")
_wait_for_http(base_url=source_base_url, timeout=120)
_print_header(f"Waiting for follower app healthcheck at {follower_base_url}")
_wait_for_http(base_url=follower_base_url, timeout=120)
print("Follower production apps ready for blackbox E2E.")
@task(name="docker-blackbox-follow-manual-up")
def docker_blackbox_follow_manual_up(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
build=True,
):
"""Boot local leader/follower production setup for manual testing and print next steps."""
docker_blackbox_follow_up(
c,
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
build=build,
)
source_env = _production_env_for("source-postgres", BLACKBOX_SOURCE_DB_NAME)
follower_env = _production_env_for("follower-postgres", BLACKBOX_FOLLOWER_DB_NAME)
_print_header("Running source production database migrations")
c.run("bundle exec rails db:migrate", env=source_env, pty=True)
_print_header("Running follower production database migrations")
c.run("bundle exec rails db:migrate", env=follower_env, pty=True)
_print_header("Bootstrapping confirmed E2E users on source and follower")
_bootstrap_blackbox_users(c, source_env, E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME, E2E_ALT_EMAIL, E2E_ALT_PASSWORD, E2E_ALT_USERNAME)
_bootstrap_blackbox_users(c, follower_env, E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME, E2E_ALT_EMAIL, E2E_ALT_PASSWORD, E2E_ALT_USERNAME)
source_base_url = f"http://127.0.0.1:{source_host_port}"
follower_base_url = f"http://127.0.0.1:{follower_host_port}"
_print_header("Leader/follower manual setup ready")
print(f"Leader URL: {source_base_url}")
print(f"Follower URL: {follower_base_url}")
print(f"E2E owner email: {E2E_EMAIL}")
print(f"E2E owner password: {E2E_PASSWORD}")
print("Next steps:")
print("1. Create read-only follower tournament on follower backend.")
print("2. Create normal leader tournament on leader backend.")
print("3. Configure leader sync_target_url to follower /sync_state endpoint with shared token.")
print("4. Point frontend local leader view at leader URL and remote view at follower URL.")
print("5. Tear down with: inv docker-blackbox-follow-down")
@task(name="docker-blackbox-follow-down")
def docker_blackbox_follow_down(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
"""Stop and remove follower production blackbox stack."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
_compose_run(
["down", "-v", "--remove-orphans"],
env=env,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
@task(name="docker-blackbox-follow-test")
def docker_blackbox_follow_test(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
"""Run follower-sync HTTP E2E suite against running source/follower production blackbox stack."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
_print_header("Running follower sync HTTP E2E against production images")
command = [
"run",
"--rm",
"e2e",
"inv",
"blackbox-follow-service",
]
full_command = _docker_compose_cmd(project_name=BLACKBOX_FOLLOW_PROJECT, compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE) + command
print(_format_command(full_command), flush=True)
result = _run_subprocess_live(full_command, env=env, check=False)
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
@task(name="docker-blackbox-follow-bootstrap-users")
def docker_blackbox_follow_bootstrap_users(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
"""Bootstrap confirmed E2E users in local source/follower production blackbox DBs."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
for app_service in ("source-app", "follower-app"):
_print_header(f"Bootstrapping confirmed E2E users in {app_service}")
for bootstrap_email, bootstrap_password, bootstrap_username in (
(E2E_EMAIL, E2E_PASSWORD, E2E_USERNAME),
(E2E_ALT_EMAIL, E2E_ALT_PASSWORD, E2E_ALT_USERNAME),
):
bootstrap = _compose_run(
["exec", "-T", app_service, "bash", "-lc", _bootstrap_user_command(bootstrap_email, bootstrap_password, bootstrap_username)],
env=env,
capture_output=True,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
if bootstrap.returncode != 0:
print(bootstrap.stdout or "", end="")
print(bootstrap.stderr or "", end="")
raise subprocess.CalledProcessError(
bootstrap.returncode,
bootstrap.args,
output=bootstrap.stdout,
stderr=bootstrap.stderr,
)
@task(name="docker-blackbox-follow-debug-auth")
def docker_blackbox_follow_debug_auth(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
"""Debug source/follower login and first authenticated create inside local follower blackbox runner."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
script = """
require_relative 'e2e/lib/api_client'
def inspect_client(base_url)
client = TurniereE2E::ApiClient.new(base_url: base_url)
login = client.login!(email: ENV.fetch('TURNIERE_E2E_EMAIL'), password: ENV.fetch('TURNIERE_E2E_PASSWORD'))
puts({ base_url: base_url, login_status: login[:status], login_headers: login[:headers].slice('access-token', 'client', 'uid', 'expiry', 'token-type', 'authorization'), authenticated: client.authenticated? }.inspect)
create = client.post('/tournaments', body: {
name: "Debug #{base_url}",
description: 'Debug tournament',
public: false,
teams: [
{ name: 'One' },
{ name: 'Two' },
{ name: 'Three' },
{ name: 'Four' }
]
})
puts({ base_url: base_url, create_status: create[:status], create_json: create[:json], create_headers: create[:headers].slice('access-token', 'client', 'uid', 'expiry', 'token-type', 'authorization') }.inspect)
end
inspect_client(ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL'))
inspect_client(ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL'))
"""
command = [
"run",
"--rm",
"e2e",
"ruby",
"-e",
script,
]
print(_format_command(_docker_compose_cmd(project_name=BLACKBOX_FOLLOW_PROJECT, compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE) + command))
result = _compose_run(
command,
env=env,
capture_output=True,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
if result.stdout:
print(result.stdout.rstrip())
if result.stderr:
print(result.stderr.rstrip())
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
@task(name="docker-blackbox-follow-debug-seed")
def docker_blackbox_follow_debug_seed(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
"""Debug repeated mixed tournament creation inside local follower blackbox runner."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
script = """
require_relative 'e2e/lib/api_client'
def create_playoff_payload(name)
{
name: name,
description: 'Debug playoff tournament',
public: false,
teams: [
{ name: 'One' },
{ name: 'Two' },
{ name: 'Three' },
{ name: 'Four' }
]
}
end
def create_group_payload(name)
{
name: name,
description: 'Debug group tournament',
public: true,
group_stage: true,
playoff_teams_amount: 8,
teams: 4.times.flat_map do |group_index|
4.times.map do |seed_index|
{ name: "#{name}-G#{group_index + 1}-S#{seed_index + 1}", group: group_index }
end
end
}
end
def inspect_seed(base_url)
client = TurniereE2E::ApiClient.new(base_url: base_url)
login = client.login!(email: ENV.fetch('TURNIERE_E2E_EMAIL'), password: ENV.fetch('TURNIERE_E2E_PASSWORD'))
puts({ base_url: base_url, login_status: login[:status], authenticated: client.authenticated? }.inspect)
[
['playoff-1', create_playoff_payload('Debug Playoff 1')],
['group-1', create_group_payload('Debug Group 1')],
['playoff-2', create_playoff_payload('Debug Playoff 2')],
['group-2', create_group_payload('Debug Group 2')]
].each do |label, payload|
response = client.post('/tournaments', body: payload)
puts({
base_url: base_url,
step: label,
status: response[:status],
json: response[:json],
headers: response[:headers].slice('access-token', 'client', 'uid', 'expiry', 'token-type', 'authorization'),
authenticated: client.authenticated?
}.inspect)
end
end
inspect_seed(ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL'))
inspect_seed(ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL'))
"""
command = [
"run",
"--rm",
"e2e",
"ruby",
"-e",
script,
]
print(_format_command(_docker_compose_cmd(project_name=BLACKBOX_FOLLOW_PROJECT, compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE) + command))
result = _compose_run(
command,
env=env,
capture_output=True,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
if result.stdout:
print(result.stdout.rstrip())
if result.stderr:
print(result.stderr.rstrip())
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
@task(name="docker-blackbox-follow-debug-group-create")
def docker_blackbox_follow_debug_group_create(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
"""Debug single fresh-login group-stage tournament creation inside local follower blackbox runner."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
script = """
require_relative 'e2e/lib/api_client'
def create_group_payload(name)
{
name: name,
description: 'Debug group tournament',
public: true,
group_stage: true,
playoff_teams_amount: 8,
teams: 4.times.flat_map do |group_index|
4.times.map do |seed_index|
{ name: "#{name}-G#{group_index + 1}-S#{seed_index + 1}", group: group_index }
end
end
}
end
def inspect_group_create(base_url)
client = TurniereE2E::ApiClient.new(base_url: base_url)
login = client.login!(email: ENV.fetch('TURNIERE_E2E_EMAIL'), password: ENV.fetch('TURNIERE_E2E_PASSWORD'))
puts({ base_url: base_url, login_status: login[:status], authenticated: client.authenticated? }.inspect)
response = client.post('/tournaments', body: create_group_payload('Fresh Debug Group'))
puts({ base_url: base_url, create_status: response[:status], create_json: response[:json], create_headers: response[:headers].slice('access-token', 'client', 'uid', 'expiry', 'token-type', 'authorization') }.inspect)
end
inspect_group_create(ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL'))
inspect_group_create(ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL'))
"""
command = [
"run",
"--rm",
"e2e",
"ruby",
"-e",
script,
]
print(_format_command(_docker_compose_cmd(project_name=BLACKBOX_FOLLOW_PROJECT, compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE) + command))
result = _compose_run(
command,
env=env,
capture_output=True,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
if result.stdout:
print(result.stdout.rstrip())
if result.stderr:
print(result.stderr.rstrip())
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
@task(name="docker-blackbox-follow-debug-sync")
def docker_blackbox_follow_debug_sync(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
log_tail=120,
):
"""Print local follower blackbox sync queue state and recent app logs."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
script = """
puts '== Source tournaments =='
Tournament.order(:id).pluck(:id, :name, :read_only_mode, :sync_target_url, :sync_last_pushed_at, :sync_last_push_error).each { |row| p row }
puts '== Source sync queue =='
TournamentSyncQueueEntry.order(:id).pluck(:id, :tournament_id, :status, :attempts, :last_error, :next_attempt_at, :last_attempt_at).each { |row| p row }
puts '== Follower tournaments =='
Tournament.order(:id).pluck(:id, :name, :read_only_mode, :sync_target_url, :sync_last_pushed_at, :sync_last_push_error).each { |row| p row }
"""
for service, db_host in (("source-app", "source-postgres"), ("follower-app", "follower-postgres")):
_print_header(f"Sync state from {service}")
db_name = BLACKBOX_SOURCE_DB_NAME if service == "source-app" else BLACKBOX_FOLLOWER_DB_NAME
service_env = _production_env_for(db_host, db_name)
runner = _compose_run(
["run", "--rm", "-e", f"POSTGRES_URL={service_env['POSTGRES_URL']}", "-e", f"POSTGRES_USERNAME={service_env['POSTGRES_USERNAME']}", "-e", f"POSTGRES_PASSWORD={service_env['POSTGRES_PASSWORD']}", "-e", f"SECRET_KEY_BASE={service_env['SECRET_KEY_BASE']}", "-e", f"MAILGUN_API_KEY={service_env['MAILGUN_API_KEY']}", "-e", f"MAILGUN_DOMAIN={service_env['MAILGUN_DOMAIN']}", "-e", f"RAILS_LOG_TO_STDOUT={service_env['RAILS_LOG_TO_STDOUT']}", "-e", f"RAILS_SERVE_STATIC_FILES={service_env['RAILS_SERVE_STATIC_FILES']}", "-e", "RAILS_ENV=production", "e2e", "bundle", "exec", "rails", "runner", script],
env=env,
capture_output=True,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
if runner.stdout:
print(runner.stdout.rstrip())
if runner.stderr:
print(runner.stderr.rstrip())
if runner.returncode != 0:
raise subprocess.CalledProcessError(
runner.returncode,
runner.args,
output=runner.stdout,
stderr=runner.stderr,
)
_print_header(f"Recent logs from {service}")
logs = _compose_run(
["logs", "--tail", str(log_tail), service],
env=env,
capture_output=True,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
output = logs.stdout or logs.stderr or "<no logs>"
print(output.rstrip())
@task(name="docker-blackbox-follow-debug-compare")
def docker_blackbox_follow_debug_compare(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
):
"""Compare latest leader tournament and follower read-only tournament via HTTP API."""
env = _follow_compose_env(
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
script = """
def signature(tournament)
{
id: tournament.id,
name: tournament.name,
public: tournament.public,
playoff_teams_amount: tournament.playoff_teams_amount,
instant_finalists_amount: tournament.instant_finalists_amount,
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
sync_source_tournament_id: tournament.respond_to?(:sync_source_tournament_id) ? tournament.sync_source_tournament_id : nil,
team_names: tournament.teams.order(:id).pluck(:name),
stage_levels: tournament.stages.order(:level).map { |stage| [stage.level, stage.state, stage.groups.count, stage.matches.count] },
total_matches: tournament.matches.count,
decider_matches: tournament.matches.count(&:decider?)
}
end
tournament = if ENV['FOLLOW_COMPARE_MODE'] == 'leader'
Tournament.where.not(sync_target_url: nil).order(:id).last
else
Tournament.where(read_only_mode: true).order(:id).last
end
puts signature(tournament).inspect
"""
for mode, db_host, db_name in (("leader", "source-postgres", BLACKBOX_SOURCE_DB_NAME), ("follower", "follower-postgres", BLACKBOX_FOLLOWER_DB_NAME)):
_print_header(f"Compare {mode} tournament")
service_env = _production_env_for(db_host, db_name)
runner = _compose_run(
["run", "--rm", "-e", f"POSTGRES_URL={service_env['POSTGRES_URL']}", "-e", f"POSTGRES_USERNAME={service_env['POSTGRES_USERNAME']}", "-e", f"POSTGRES_PASSWORD={service_env['POSTGRES_PASSWORD']}", "-e", f"SECRET_KEY_BASE={service_env['SECRET_KEY_BASE']}", "-e", f"MAILGUN_API_KEY={service_env['MAILGUN_API_KEY']}", "-e", f"MAILGUN_DOMAIN={service_env['MAILGUN_DOMAIN']}", "-e", f"RAILS_LOG_TO_STDOUT={service_env['RAILS_LOG_TO_STDOUT']}", "-e", f"RAILS_SERVE_STATIC_FILES={service_env['RAILS_SERVE_STATIC_FILES']}", "-e", "RAILS_ENV=production", "-e", f"FOLLOW_COMPARE_MODE={mode}", "e2e", "bundle", "exec", "rails", "runner", script],
env=env,
capture_output=True,
check=False,
project_name=BLACKBOX_FOLLOW_PROJECT,
compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE,
)
if runner.stdout:
print(runner.stdout.rstrip())
if runner.stderr:
print(runner.stderr.rstrip())
if runner.returncode != 0:
raise subprocess.CalledProcessError(
runner.returncode,
runner.args,
output=runner.stdout,
stderr=runner.stderr,
)
@task(name="docker-blackbox-follow-run")
def docker_blackbox_follow_run(
c,
source_host_port=BLACKBOX_SOURCE_HOST_PORT,
follower_host_port=BLACKBOX_FOLLOWER_HOST_PORT,
app_image=PRODUCTION_TAG,
runner_image=TEST_TAG,
postgres_image=BLACKBOX_POSTGRES_IMAGE,
build=True,
keep_running=False,
):
"""Build, boot, run follower-sync blackbox E2E, then tear down unless keep_running=true."""
exit_error = None
try:
docker_blackbox_follow_up(
c,
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
build=build,
)
docker_blackbox_follow_test(
c,
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
except subprocess.CalledProcessError as error:
exit_error = error
raise
finally:
if keep_running:
print("Keeping follower production blackbox stack running.")
else:
docker_blackbox_follow_down(
c,
source_host_port=source_host_port,
follower_host_port=follower_host_port,
app_image=app_image,
runner_image=runner_image,
postgres_image=postgres_image,
)
if exit_error is not None:
print("Follower blackbox run failed.")
@task(name="blackbox-service") @task(name="blackbox-service")
def blackbox_service( def blackbox_service(
c, c,
@ -855,21 +1612,67 @@ def blackbox_service(
_wait_for_http(base_url=base_url, timeout=120) _wait_for_http(base_url=base_url, timeout=120)
_print_header("Bootstrapping confirmed E2E user") _print_header("Bootstrapping confirmed E2E user")
c.run( _bootstrap_blackbox_users(c, _shared_production_env(), email, password, username, alt_email, alt_password, alt_username)
_bootstrap_user_command(email, password, username),
env=_shared_production_env(),
pty=True,
)
c.run(
_bootstrap_user_command(alt_email, alt_password, alt_username),
env=_shared_production_env(),
pty=True,
)
_print_header("Running HTTP E2E against production image") _print_header("Running HTTP E2E against production image")
_run_blackbox_rspec(base_url, email, password, username, alt_email, alt_password, alt_username) _run_blackbox_rspec(base_url, email, password, username, alt_email, alt_password, alt_username)
@task(name="blackbox-follow-service")
def blackbox_follow_service(
c,
source_base_url=BLACKBOX_SOURCE_INTERNAL_BASE_URL,
follower_base_url=BLACKBOX_FOLLOWER_INTERNAL_BASE_URL,
email=E2E_EMAIL,
password=E2E_PASSWORD,
username=E2E_USERNAME,
alt_email=E2E_ALT_EMAIL,
alt_password=E2E_ALT_PASSWORD,
alt_username=E2E_ALT_USERNAME,
):
"""Run follower-sync HTTP E2E against two independent production app services."""
source_env = _production_env_for("source-postgres", BLACKBOX_SOURCE_DB_NAME)
follower_env = _production_env_for("follower-postgres", BLACKBOX_FOLLOWER_DB_NAME)
_print_header("Running source production database migrations")
c.run("bundle exec rails db:migrate", env=source_env, pty=True)
_print_header("Running follower production database migrations")
c.run("bundle exec rails db:migrate", env=follower_env, pty=True)
_print_header(f"Waiting for source app healthcheck at {source_base_url}")
_wait_for_http(base_url=source_base_url, timeout=120)
_print_header(f"Waiting for follower app healthcheck at {follower_base_url}")
_wait_for_http(base_url=follower_base_url, timeout=120)
_print_header("Running source/follower network smoke")
_blackbox_network_smoke(c, source_base_url)
_blackbox_network_smoke(c, follower_base_url)
_blackbox_service_to_service_smoke(c, source_env, "source-app", follower_base_url)
_blackbox_service_to_service_smoke(c, follower_env, "follower-app", source_base_url)
_print_header("Bootstrapping confirmed E2E users on source and follower")
_bootstrap_blackbox_users(c, source_env, email, password, username, alt_email, alt_password, alt_username)
_bootstrap_blackbox_users(c, follower_env, email, password, username, alt_email, alt_password, alt_username)
_print_header("Running follower sync HTTP E2E against production images")
_run_blackbox_rspec(
source_base_url,
email,
password,
username,
alt_email,
alt_password,
alt_username,
spec_path="spec/e2e/http/tournament_follow_sync_spec.rb",
extra_env={
"TURNIERE_SOURCE_E2E_BASE_URL": source_base_url,
"TURNIERE_FOLLOWER_E2E_BASE_URL": follower_base_url,
},
)
@task(name="blackbox-production") @task(name="blackbox-production")
def blackbox_production( def blackbox_production(
c, c,