diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b41c2b..4d6566c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install Perl dependencies - run: sudo apt install libmodule-build-perl libapache-session-perl libjson-perl libdbi-perl libdbd-sqlite3-perl libnet-ldap-perl libredis-perl libdbd-mysql-perl + run: sudo apt install libmodule-build-perl libapache-session-perl libjson-perl libdbi-perl libdbd-sqlite3-perl libnet-ldap-perl libredis-perl libdbd-mysql-perl liblwp-protocol-psgi-perl - name: Build and run tests run: | perl Build.PL diff --git a/Build.PL b/Build.PL index a927ea5..845f061 100644 --- a/Build.PL +++ b/Build.PL @@ -14,7 +14,8 @@ Module::Build->new( 'Redis::Fast' => 0, }, test_requires => { DBI => 0, 'DBD::SQLite' => 0, }, - dist_version => '1.3.18', + test_recommends => { 'LWP::Protocol::PSGI' => 0, }, + dist_version => '1.3.19', autosplit => [qw(lib/Apache/Session/Browseable/_common.pm)], configure_requires => { 'Module::Build' => 0, }, meta_merge => { diff --git a/lib/Apache/Session/Browseable.pm b/lib/Apache/Session/Browseable.pm index 89b5654..18c9374 100644 --- a/lib/Apache/Session/Browseable.pm +++ b/lib/Apache/Session/Browseable.pm @@ -1,6 +1,6 @@ package Apache::Session::Browseable; -our $VERSION = '1.3.18'; +our $VERSION = '1.3.19'; print STDERR "Use a sub module of Apache::Session::Browseable such as Apache::Session::Browseable::File"; diff --git a/lib/Apache/Session/Browseable/Patroni.pm b/lib/Apache/Session/Browseable/Patroni.pm index 2bb4f8c..b25fb28 100644 --- a/lib/Apache/Session/Browseable/Patroni.pm +++ b/lib/Apache/Session/Browseable/Patroni.pm @@ -8,7 +8,7 @@ use Apache::Session::Generate::SHA256; use Apache::Session::Serialize::JSON; our @ISA = qw(Apache::Session::Browseable::PgJSON); -our $VERSION = '1.3.17'; +our $VERSION = '1.3.19'; sub populate { my $self = shift; @@ -48,33 +48,99 @@ Optionally, add indexes on some fields. Example for Lemonldap::NG: Use it with Perl: - use Apache::Session::Browseable::Postgres; + use Apache::Session::Browseable::Patroni; my $args = { - DataSource => 'dbi:Pg:sessions', + DataSource => 'dbi:Pg:dbname=sessions', UserName => $db_user, Password => $db_pass, Commit => 1, - # List all Patroni API available (to avoid any haproxy and/or floating IP) - PatroniUrl => 'http://1.2.3.4:8008/cluster http://2.3.4.5:8008/cluster', + # List Patroni API endpoints (comma or space separated) + # Put preferred (local) endpoints first + PatroniUrl => 'http://1.2.3.4:8008/cluster, http://2.3.4.5:8008/cluster', + + # Optional parameters with defaults: + # PatroniTimeout => 3, # API request timeout in seconds + # PatroniCacheTTL => 60, # Leader cache TTL in seconds + # PatroniCircuitBreakerDelay => 30, # Delay before retrying failed API + + # SSL options (verification enabled by default): + # PatroniVerifySSL => 1, # Verify SSL certificates (default: 1) + # PatroniSSLCAFile => '/path/to/ca.pem', # Custom CA file + # PatroniSSLCAPath => '/path/to/certs/', # Custom CA directory }; - # Use it like L + # Use it like L =head1 DESCRIPTION Apache::Session::Browseable provides some class methods to manipulate all sessions and add the capability to index some fields to make research faster. -Apache::Session::Browseable::Patroni implements it for PosqtgreSQL databases +Apache::Session::Browseable::Patroni implements it for PostgreSQL databases using "json" or "jsonb" type to be able to browse sessions and is able to dial directly with Patroni API to find the master node of PostgreSQL cluster in case of error. +=head2 Resilience features + +=over 4 + +=item * B: Avoids hammering the Patroni API when it's failing. +After a failure, the API won't be queried again for C +seconds (default: 30). + +=item * B: The discovered leader is cached for +C seconds (default: 60). This cache is used as fallback when +the API is unavailable. + +=item * B: Refuses to use a cluster that reports +multiple leaders. + +=item * B: Verifies that the leader is in "running" state +before using it. + +=item * B: Each DataSource maintains its own independent +cache, allowing multiple Patroni clusters to be used simultaneously. + +=back + +=head2 SSL/TLS Configuration + +By default, SSL certificate verification is B when connecting to +HTTPS Patroni endpoints. This protects against man-in-the-middle attacks. + +Available SSL options: + +=over 4 + +=item * C (default: 1) + +Set to 0 to disable SSL certificate verification. B: This makes +HTTPS connections vulnerable to MITM attacks. Only use in development or +when you have other network-level protections. + + PatroniVerifySSL => 0, # INSECURE - disable SSL verification + +=item * C + +Path to a custom CA certificate file (PEM format) for verifying the Patroni +API server certificate. + + PatroniSSLCAFile => '/etc/ssl/certs/patroni-ca.pem', + +=item * C + +Path to a directory containing CA certificates for verification. + + PatroniSSLCAPath => '/etc/ssl/certs/', + +=back + =head1 SEE ALSO -L, L +L, L =head1 COPYRIGHT AND LICENSE diff --git a/lib/Apache/Session/Browseable/Store/Patroni.pm b/lib/Apache/Session/Browseable/Store/Patroni.pm index bde225e..fe69ddf 100644 --- a/lib/Apache/Session/Browseable/Store/Patroni.pm +++ b/lib/Apache/Session/Browseable/Store/Patroni.pm @@ -6,9 +6,14 @@ use DBI; use Apache::Session::Store::Postgres; our @ISA = qw(Apache::Session::Store::Postgres); -our $VERSION = '1.3.17'; +our $VERSION = '1.3.19'; -our %knownMappings; +# Cache structure per DataSource: +# { +# leader => { host => '...', port => '...', time => ... }, +# lastFailure => timestamp, +# } +our %patroniCache; sub connection { my $self = shift; @@ -25,11 +30,21 @@ sub connection { return; } - # Use last known Patroni response if available - $session->{args}->{DataSource} = - $knownMappings{ $session->{args}->{DataSource} } - if $session->{args}->{DataSource} - and $knownMappings{ $session->{args}->{DataSource} }; + # Store original DataSource as cache key + my $originalDataSource = $session->{args}->{DataSource}; + $self->{_originalDataSource} = $originalDataSource; + + # Use cached leader if available and not expired + my $cache = $patroniCache{$originalDataSource} || {}; + my $cacheTTL = $session->{args}->{PatroniCacheTTL} || 60; + + if ( $cache->{leader} + and $cache->{leader}->{time} + and ( time() - $cache->{leader}->{time} ) < $cacheTTL ) + { + $session->{args}->{DataSource} = + _buildDataSource( $originalDataSource, $cache->{leader} ); + } foreach ( 0 .. 1 ) { ( @@ -105,16 +120,47 @@ sub remove { sub checkMaster { my ( $self, $args ) = @_; delete $self->{failure}; + + my $originalDataSource = + $self->{_originalDataSource} || $args->{DataSource}; + my $cache = $patroniCache{$originalDataSource} ||= {}; + + # Circuit breaker: avoid hammering Patroni API if it's failing + my $circuitBreakerDelay = $args->{PatroniCircuitBreakerDelay} || 30; + if ( $cache->{lastFailure} + and ( time() - $cache->{lastFailure} ) < $circuitBreakerDelay ) + { + # Circuit breaker active, try cached leader as fallback + return $self->_useCachedLeader( $args, $originalDataSource, + "Circuit breaker active" ); + } + require JSON; require LWP::UserAgent; require IO::Socket::SSL; - my $ua = LWP::UserAgent->new( - env_proxy => 1, - ssl_opts => { + + # SSL verification: secure by default, can be disabled with PatroniVerifySSL => 0 + my $verify_ssl = $args->{PatroniVerifySSL} // 1; + my %ssl_opts; + if ($verify_ssl) { + %ssl_opts = ( + verify_hostname => 1, + SSL_verify_mode => &IO::Socket::SSL::SSL_VERIFY_PEER, + ( $args->{PatroniSSLCAFile} ? ( SSL_ca_file => $args->{PatroniSSLCAFile} ) : () ), + ( $args->{PatroniSSLCAPath} ? ( SSL_ca_path => $args->{PatroniSSLCAPath} ) : () ), + ); + } + else { + %ssl_opts = ( verify_hostname => 0, SSL_verify_mode => &IO::Socket::SSL::SSL_VERIFY_NONE, - }, - timeout => 3, + ); + } + + my $ua = LWP::UserAgent->new( + env_proxy => 1, + ssl_opts => \%ssl_opts, + timeout => $args->{PatroniTimeout} || 3, ); my $res; @@ -125,27 +171,110 @@ sub checkMaster { if ( $resp->is_success ) { my $c = eval { JSON::from_json( $resp->decoded_content ) }; if ( $@ or !$c->{members} or ref( $c->{members} ) ne 'ARRAY' ) { - print STDERR "Bad response from $patroniUrl\n" - . $resp->decoded_content; + print STDERR "Bad response from $patroniUrl: " + . $resp->decoded_content . "\n"; next; } - my ($leader) = grep { $_->{role} eq 'leader' } @{ $c->{members} }; + + my @leaders = grep { $_->{role} eq 'leader' } @{ $c->{members} }; + + # Check for split-brain scenario + if ( @leaders > 1 ) { + my $leadersList = + join( ', ', map { "$_->{host}:$_->{port}" } @leaders ); + print STDERR + "Multiple leaders detected (split-brain) from $patroniUrl" + . " - Leaders: $leadersList\n"; + next; + } + + my ($leader) = @leaders; unless ($leader) { - print STDERR "No leader found from $patroniUrl\n" - . $resp->decoded_content; + print STDERR "No leader found from $patroniUrl: " + . $resp->decoded_content . "\n"; + next; + } + + # Validate leader has required fields + unless ( defined $leader->{host} && defined $leader->{port} ) { + print STDERR "Leader missing host or port from $patroniUrl: " + . $resp->decoded_content . "\n"; + next; + } + + # Check leader health state + if ( $leader->{state} && $leader->{state} ne 'running' ) { + print STDERR + "Leader not in running state (state=$leader->{state})" + . " from $patroniUrl\n"; next; } - my $old = $args->{DataSource}; - $args->{DataSource} =~ s/(?:port|host)=[^;]+;*//g; - $args->{DataSource} =~ s/;$//; - $args->{DataSource} .= ( $args->{DataSource} =~ /:$/ ? '' : ';' ) - . "host=$leader->{host};port=$leader->{port}"; - $knownMappings{$old} = $args->{DataSource}; + + # Cache the leader info + $cache->{leader} = { + host => $leader->{host}, + port => $leader->{port}, + time => time() + }; + + # Reset circuit breaker on success + delete $cache->{lastFailure}; + + $args->{DataSource} = + _buildDataSource( $originalDataSource, $leader ); $res = 1; last; } } + + # If API failed, record for circuit breaker and try cached leader + unless ($res) { + $cache->{lastFailure} = time(); + $res = $self->_useCachedLeader( $args, $originalDataSource, + "Patroni API unavailable" ); + } + return $res; } +# Use cached leader as fallback +sub _useCachedLeader { + my ( $self, $args, $originalDataSource, $reason ) = @_; + + my $cache = $patroniCache{$originalDataSource} || {}; + my $cacheTTL = $args->{PatroniCacheTTL} || 60; + + if ( $cache->{leader} + and $cache->{leader}->{time} + and ( time() - $cache->{leader}->{time} ) < $cacheTTL ) + { + my $age = time() - $cache->{leader}->{time}; + print STDERR "$reason, using cached leader (${age}s old)\n"; + $args->{DataSource} = + _buildDataSource( $originalDataSource, $cache->{leader} ); + return 1; + } + return 0; +} + +# Build DataSource string with new host/port +sub _buildDataSource { + my ( $originalDataSource, $leader ) = @_; + + my $chain = $originalDataSource; + + # Remove existing host/port parameters + $chain =~ s/;\s*host=[^;]+//gi; + $chain =~ s/;\s*port=[^;]+//gi; + $chain =~ s/\s+host=[^\s;]+//gi; + $chain =~ s/\s+port=[^\s;]+//gi; + + # Clean up trailing semicolons + $chain =~ s/;+$//; + + # Add new host and port + my $separator = ( $chain =~ /:$/ ) ? '' : ';'; + return "${chain}${separator}host=$leader->{host};port=$leader->{port}"; +} + 1; diff --git a/t/Apache-Session-Browseable-Store-Patroni.t b/t/Apache-Session-Browseable-Store-Patroni.t new file mode 100644 index 0000000..e4e30d3 --- /dev/null +++ b/t/Apache-Session-Browseable-Store-Patroni.t @@ -0,0 +1,589 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use Test::More; + +plan skip_all => "Optional modules (DBD::Pg, DBI) not installed" + unless eval { + require DBI; + require DBD::Pg; + }; + +plan tests => 34; + +my $package = 'Apache::Session::Browseable::Store::Patroni'; + +use_ok($package); + +my $store = $package->new; +isa_ok( $store, $package ); + +# Test _buildDataSource function +{ + no warnings 'once'; + + # Basic case: append host/port + is( + Apache::Session::Browseable::Store::Patroni::_buildDataSource( + 'dbi:Pg:dbname=sessions', { host => '10.0.0.1', port => 5432 } + ), + 'dbi:Pg:dbname=sessions;host=10.0.0.1;port=5432', + '_buildDataSource: basic append' + ); + + # Replace existing host/port (semicolon separated) + is( + Apache::Session::Browseable::Store::Patroni::_buildDataSource( + 'dbi:Pg:dbname=sessions;host=old.host;port=1234', + { host => '10.0.0.2', port => 5433 } + ), + 'dbi:Pg:dbname=sessions;host=10.0.0.2;port=5433', + '_buildDataSource: replace existing host/port' + ); + + # Handle trailing colon in DSN + is( + Apache::Session::Browseable::Store::Patroni::_buildDataSource( + 'dbi:Pg:', { host => '10.0.0.3', port => 5434 } + ), + 'dbi:Pg:host=10.0.0.3;port=5434', + '_buildDataSource: trailing colon' + ); + + # Complex DSN with other params + is( + Apache::Session::Browseable::Store::Patroni::_buildDataSource( + 'dbi:Pg:dbname=mydb;host=127.0.0.1;port=5432;sslmode=require', + { host => '192.168.1.1', port => 5435 } + ), + 'dbi:Pg:dbname=mydb;sslmode=require;host=192.168.1.1;port=5435', + '_buildDataSource: complex DSN with other params' + ); +} + +# Test cache structure (multi-source support) +{ + no warnings 'once'; + + # Clear cache + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); + + my $ds1 = 'dbi:Pg:dbname=db1'; + my $ds2 = 'dbi:Pg:dbname=db2'; + + # Simulate caching for first datasource + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds1} = { + leader => { + host => '10.0.0.1', + port => 5432, + time => time() + } + }; + + # Simulate caching for second datasource + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds2} = { + leader => { + host => '10.0.0.2', + port => 5433, + time => time() + } + }; + + # Verify they are independent + is( + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds1} + ->{leader}->{host}, + '10.0.0.1', 'Multi-source: first datasource has correct host' + ); + is( + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds2} + ->{leader}->{host}, + '10.0.0.2', 'Multi-source: second datasource has correct host' + ); + + # Verify ports are independent + is( + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds1} + ->{leader}->{port}, + 5432, 'Multi-source: first datasource has correct port' + ); + is( + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds2} + ->{leader}->{port}, + 5433, 'Multi-source: second datasource has correct port' + ); + + # Clear cache + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); +} + +# Test _useCachedLeader +{ + no warnings 'once'; + + my $ds = 'dbi:Pg:dbname=testdb'; + my $store = $package->new; + $store->{_originalDataSource} = $ds; + + # No cache - should return 0 + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); + my $args = { DataSource => $ds, PatroniCacheTTL => 60 }; + + # Capture STDERR + my $stderr = ''; + { + local *STDERR; + open STDERR, '>', \$stderr; + my $result = $store->_useCachedLeader( $args, $ds, "Test reason" ); + is( $result, 0, '_useCachedLeader: returns 0 when no cache' ); + } + + # With valid cache + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds} = { + leader => { + host => '10.0.0.5', + port => 5432, + time => time() - 10 # 10 seconds ago + } + }; + + $stderr = ''; + { + local *STDERR; + open STDERR, '>', \$stderr; + my $result = $store->_useCachedLeader( $args, $ds, "Test reason" ); + is( $result, 1, '_useCachedLeader: returns 1 when cache valid' ); + } + like( $args->{DataSource}, qr/host=10\.0\.0\.5/, + '_useCachedLeader: updates DataSource with cached host' ); + like( $args->{DataSource}, qr/port=5432/, + '_useCachedLeader: updates DataSource with cached port' ); + + # With expired cache + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds} = { + leader => { + host => '10.0.0.6', + port => 5433, + time => time() - 120 # 2 minutes ago, expired + } + }; + $args = { DataSource => $ds, PatroniCacheTTL => 60 }; + + $stderr = ''; + { + local *STDERR; + open STDERR, '>', \$stderr; + my $result = $store->_useCachedLeader( $args, $ds, "Test reason" ); + is( $result, 0, '_useCachedLeader: returns 0 when cache expired' ); + } + + # Clear cache + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); +} + +# Test circuit breaker logic in checkMaster (without actual HTTP calls) +{ + no warnings 'once'; + + my $ds = 'dbi:Pg:dbname=circuitdb'; + my $store = $package->new; + $store->{_originalDataSource} = $ds; + + # Set up circuit breaker as triggered + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds} = { + lastFailure => time() - 10, # Failed 10 seconds ago + leader => { + host => '10.0.0.7', + port => 5432, + time => time() - 5 # Cached 5 seconds ago + } + }; + + my $args = { + DataSource => $ds, + PatroniUrl => 'http://fake.patroni:8008/cluster', + PatroniCircuitBreakerDelay => 30, + PatroniCacheTTL => 60, + }; + + # Circuit breaker should be active (failed 10s ago, delay is 30s) + # It should use cached leader instead of calling API + my $stderr = ''; + { + local *STDERR; + open STDERR, '>', \$stderr; + my $result = $store->checkMaster($args); + is( $result, 1, 'Circuit breaker: uses cached leader when active' ); + } + like( + $stderr, + qr/Circuit breaker active/, + 'Circuit breaker: prints appropriate message' + ); + like( $args->{DataSource}, qr/host=10\.0\.0\.7/, + 'Circuit breaker: uses cached host' ); + + # Clear cache + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); +} + +# Test that circuit breaker expires +{ + no warnings 'once'; + + my $ds = 'dbi:Pg:dbname=expiredcb'; + my $store = $package->new; + $store->{_originalDataSource} = $ds; + + # Circuit breaker triggered long ago (should be expired) + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds} = { + lastFailure => time() - 60, # Failed 60 seconds ago + leader => { + host => '10.0.0.8', + port => 5432, + time => time() - 50 # Still valid cache + } + }; + + my $args = { + DataSource => $ds, + PatroniUrl => 'http://nonexistent.host:8008/cluster', + PatroniCircuitBreakerDelay => 30, # Expired (60 > 30) + PatroniCacheTTL => 120, + }; + + # Circuit breaker should NOT be active (failed 60s ago, delay is 30s) + # It will try to call API (which will fail) then use cache + my $stderr = ''; + { + local *STDERR; + open STDERR, '>', \$stderr; + + # This will try HTTP (fail) then fallback to cache + my $result = $store->checkMaster($args); + is( $result, 1, + 'Circuit breaker expired: uses cache after API failure' ); + } + like( + $stderr, + qr/Patroni API unavailable/, + 'Circuit breaker expired: API was tried' + ); + + # Clear cache + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); +} + +# Test default values +{ + my $store = $package->new; + my $ds = 'dbi:Pg:dbname=defaults'; + $store->{_originalDataSource} = $ds; + + # Test with minimal args + my $args = { + DataSource => $ds, + PatroniUrl => 'http://fake:8008/cluster', + }; + + # Without cache, checkMaster will try HTTP and fail + # This tests that defaults are applied correctly + my $stderr = ''; + { + local *STDERR; + open STDERR, '>', \$stderr; + + # Store the time before calling checkMaster + my $before = time(); + my $result = $store->checkMaster($args); + + # Verify failure was recorded with correct circuit breaker + my $cache = + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds}; + ok( $cache->{lastFailure} >= $before, + 'Default circuit breaker delay: failure recorded' ); + } + + # Clear cache + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); +} + +# Test custom TTL values +{ + no warnings 'once'; + + my $ds = 'dbi:Pg:dbname=customttl'; + + # Set up cache with known age + $Apache::Session::Browseable::Store::Patroni::patroniCache{$ds} = { + leader => { + host => '10.0.0.9', + port => 5432, + time => time() - 45 # 45 seconds ago + } + }; + + my $store = $package->new; + $store->{_originalDataSource} = $ds; + + # With 30s TTL, cache should be expired + my $args1 = { DataSource => $ds, PatroniCacheTTL => 30 }; + my $stderr = ''; + { + local *STDERR; + open STDERR, '>', \$stderr; + my $result = $store->_useCachedLeader( $args1, $ds, "Test" ); + is( $result, 0, 'Custom TTL 30s: cache expired at 45s' ); + } + + # With 60s TTL, cache should be valid + my $args2 = { DataSource => $ds, PatroniCacheTTL => 60 }; + $stderr = ''; + { + local *STDERR; + open STDERR, '>', \$stderr; + my $result = $store->_useCachedLeader( $args2, $ds, "Test" ); + is( $result, 1, 'Custom TTL 60s: cache valid at 45s' ); + } + + # Clear cache + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); +} + +# Test module can be loaded +use_ok('Apache::Session::Browseable::Patroni'); + +# Test JSON parsing and leader validation with mocked HTTP using LWP::Protocol::PSGI +SKIP: { + skip 'LWP::Protocol::PSGI and JSON not available', 10 + unless eval { require LWP::Protocol::PSGI; require JSON; 1 }; + + my $package = 'Apache::Session::Browseable::Store::Patroni'; + my $ds = 'dbi:Pg:dbname=mocktest'; + + # Helper to create PSGI app with given response + my $make_app = sub { + my ($json_data) = @_; + return sub { + my $env = shift; + return [ + 200, + [ 'Content-Type' => 'application/json' ], + [ JSON::to_json($json_data) ] + ]; + }; + }; + + # Test 1: Valid leader response + { + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); + + my $guard = LWP::Protocol::PSGI->register( + $make_app->( + { + members => [ + { + role => 'leader', + host => '10.0.0.100', + port => 5432, + state => 'running' + }, + { + role => 'replica', + host => '10.0.0.101', + port => 5432, + state => 'streaming' + } + ] + } + ) + ); + + my $store = $package->new; + $store->{_originalDataSource} = $ds; + my $args = { + DataSource => $ds, + PatroniUrl => 'http://mock:8008/cluster' + }; + + my $stderr = ''; + my $result; + { + local *STDERR; + open STDERR, '>', \$stderr; + $result = $store->checkMaster($args); + } + is( $result, 1, 'Valid leader: checkMaster returns 1' ); + like( $args->{DataSource}, qr/host=10\.0\.0\.100/, + 'Valid leader: correct host in DataSource' ); + } + + # Test 2: Split-brain detection (multiple leaders) + { + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); + + my $guard = LWP::Protocol::PSGI->register( + $make_app->( + { + members => [ + { + role => 'leader', + host => '10.0.0.100', + port => 5432, + state => 'running' + }, + { + role => 'leader', + host => '10.0.0.101', + port => 5432, + state => 'running' + } + ] + } + ) + ); + + my $store = $package->new; + $store->{_originalDataSource} = $ds; + my $args = { + DataSource => $ds, + PatroniUrl => 'http://mock:8008/cluster' + }; + + my $stderr = ''; + my $result; + { + local *STDERR; + open STDERR, '>', \$stderr; + $result = $store->checkMaster($args); + } + is( $result, 0, 'Split-brain: checkMaster returns 0' ); + like( + $stderr, + qr/Multiple leaders detected/, + 'Split-brain: warning message' + ); + } + + # Test 3: Leader not in running state + { + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); + + my $guard = LWP::Protocol::PSGI->register( + $make_app->( + { + members => [ + { + role => 'leader', + host => '10.0.0.100', + port => 5432, + state => 'starting' + } + ] + } + ) + ); + + my $store = $package->new; + $store->{_originalDataSource} = $ds; + my $args = { + DataSource => $ds, + PatroniUrl => 'http://mock:8008/cluster' + }; + + my $stderr = ''; + my $result; + { + local *STDERR; + open STDERR, '>', \$stderr; + $result = $store->checkMaster($args); + } + is( $result, 0, 'Leader starting: checkMaster returns 0' ); + like( + $stderr, + qr/not in running state/, + 'Leader starting: warning message' + ); + } + + # Test 4: Leader missing host + { + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); + + my $guard = LWP::Protocol::PSGI->register( + $make_app->( + { + members => [ + { + role => 'leader', + port => 5432, + state => 'running' + } + ] + } + ) + ); + + my $store = $package->new; + $store->{_originalDataSource} = $ds; + my $args = { + DataSource => $ds, + PatroniUrl => 'http://mock:8008/cluster' + }; + + my $stderr = ''; + my $result; + { + local *STDERR; + open STDERR, '>', \$stderr; + $result = $store->checkMaster($args); + } + is( $result, 0, 'Leader missing host: checkMaster returns 0' ); + like( + $stderr, + qr/missing host or port/, + 'Leader missing host: warning message' + ); + } + + # Test 5: No leader found + { + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); + + my $guard = LWP::Protocol::PSGI->register( + $make_app->( + { + members => [ + { + role => 'replica', + host => '10.0.0.101', + port => 5432, + state => 'streaming' + } + ] + } + ) + ); + + my $store = $package->new; + $store->{_originalDataSource} = $ds; + my $args = { + DataSource => $ds, + PatroniUrl => 'http://mock:8008/cluster' + }; + + my $stderr = ''; + my $result; + { + local *STDERR; + open STDERR, '>', \$stderr; + $result = $store->checkMaster($args); + } + is( $result, 0, 'No leader: checkMaster returns 0' ); + like( $stderr, qr/No leader found/, 'No leader: warning message' ); + } + + # Clear cache + %Apache::Session::Browseable::Store::Patroni::patroniCache = (); +}