spec/chef/cookbooks/patroni/recipes/patroni_spec.rb (675 lines of code) (raw):

require 'chef_helper' require 'yaml' RSpec.describe 'patroni cookbook' do before do allow(Gitlab).to receive(:[]).and_call_original end let(:chef_run) do ChefSpec::SoloRunner.new(step_into: %w(database_objects)).converge('gitlab-ee::default') end it 'should be disabled by default' do expect(chef_run).to include_recipe('patroni::disable') end context 'when postgres_role is enabled' do before do stub_gitlab_rb(roles: %w(postgres_role)) end it 'should be disabled' do expect(chef_run).to include_recipe('patroni::disable') end end context 'when patroni_role is configured' do before do stub_gitlab_rb(roles: %w(patroni_role)) end it 'should be enabled' do expect(chef_run).to include_recipe('patroni::enable') end end context 'when patroni_role and postgres_role are configured' do before do stub_gitlab_rb(roles: %w(postgres_role patroni_role)) end it 'should be enabled' do expect(chef_run).to include_recipe('patroni::enable') end end context 'when enabled with default config' do before do stub_gitlab_rb( roles: %w(patroni_role), postgresql: { pgbouncer_user_password: '' } ) allow_any_instance_of(OmnibusHelper).to receive(:service_dir_enabled?).and_return(true) allow_any_instance_of(PgHelper).to receive(:is_running?).and_return(true) allow_any_instance_of(PgHelper).to receive(:bootstrapped?).and_return(false) allow_any_instance_of(PgHelper).to receive(:is_replica?).and_return(false) end let(:default_patroni_config_pg13) do { name: 'fauxhai.local', scope: 'postgresql-ha', log: { level: 'INFO' }, consul: { url: 'http://127.0.0.1:8500', service_check_interval: '10s', register_service: true, checks: [], }, postgresql: { bin_dir: '/opt/gitlab/embedded/bin', data_dir: '/var/opt/gitlab/postgresql/data', config_dir: '/var/opt/gitlab/postgresql/data', listen: :'5432', connect_address: "#{Patroni.private_ipv4}:5432", use_unix_socket: true, parameters: { unix_socket_directories: '/var/opt/gitlab/postgresql' }, authentication: { superuser: { username: 'gitlab-psql' }, replication: { username: 'gitlab_replicator' }, }, remove_data_directory_on_diverged_timelines: false, remove_data_directory_on_rewind_failure: false, basebackup: [ 'no-password' ], }, bootstrap: { dcs: { loop_wait: 10, ttl: 30, retry_timeout: 10, maximum_lag_on_failover: 1_048_576, max_timelines_history: 0, master_start_timeout: 300, postgresql: { use_pg_rewind: true, use_slots: true, parameters: { wal_level: 'replica', hot_standby: 'on', wal_keep_size: 160, max_replication_slots: 5, max_connections: 400, max_locks_per_transaction: 128, max_worker_processes: 8, max_wal_senders: 5, checkpoint_timeout: 30, max_prepared_transactions: 0, track_commit_timestamp: 'off', wal_log_hints: 'off' }, }, slots: {}, }, method: 'gitlab_ctl', gitlab_ctl: { command: '/opt/gitlab/bin/gitlab-ctl patroni bootstrap --srcdir=/var/opt/gitlab/patroni/data' } }, restapi: { listen: :'8008', connect_address: "#{Patroni.private_ipv4}:8008", allowlist_include_members: false }, } end let(:default_patroni_config) do { name: 'fauxhai.local', scope: 'postgresql-ha', log: { level: 'INFO' }, consul: { url: 'http://127.0.0.1:8500', service_check_interval: '10s', register_service: true, checks: [], }, postgresql: { bin_dir: '/opt/gitlab/embedded/bin', data_dir: '/var/opt/gitlab/postgresql/data', config_dir: '/var/opt/gitlab/postgresql/data', listen: :'5432', connect_address: "#{Patroni.private_ipv4}:5432", use_unix_socket: true, parameters: { unix_socket_directories: '/var/opt/gitlab/postgresql' }, authentication: { superuser: { username: 'gitlab-psql' }, replication: { username: 'gitlab_replicator' }, }, remove_data_directory_on_diverged_timelines: false, remove_data_directory_on_rewind_failure: false, basebackup: [ 'no-password' ], }, bootstrap: { dcs: { loop_wait: 10, ttl: 30, retry_timeout: 10, maximum_lag_on_failover: 1_048_576, max_timelines_history: 0, master_start_timeout: 300, postgresql: { use_pg_rewind: true, use_slots: true, parameters: { wal_level: 'replica', hot_standby: 'on', wal_keep_segments: 10, max_replication_slots: 5, max_connections: 400, max_locks_per_transaction: 128, max_worker_processes: 8, max_wal_senders: 5, checkpoint_timeout: 30, max_prepared_transactions: 0, track_commit_timestamp: 'off', wal_log_hints: 'off' }, }, slots: {}, }, method: 'gitlab_ctl', gitlab_ctl: { command: '/opt/gitlab/bin/gitlab-ctl patroni bootstrap --srcdir=/var/opt/gitlab/patroni/data' } }, restapi: { listen: :'8008', connect_address: "#{Patroni.private_ipv4}:8008", allowlist_include_members: false }, } end it 'should enable patroni service and disable postgresql runit service' do expect(chef_run).to enable_runit_service('patroni') expect(chef_run).to disable_runit_service('postgresql') end it 'should notify patroni service to hup' do allow_any_instance_of(OmnibusHelper).to receive(:should_notify?).and_call_original allow_any_instance_of(OmnibusHelper).to receive(:should_notify?).with('patroni').and_return(true) expect(chef_run.template('/var/opt/gitlab/patroni/patroni.yaml')).to notify('runit_service[patroni]').to(:hup) end it 'should skip standalone postgresql configuration' do expect(chef_run).to create_postgresql_config('gitlab') expect(chef_run.postgresql_config('gitlab')).not_to notify('execute[start postgresql]').to(:run) expect(chef_run).not_to run_execute(/(start|reload) postgresql/) end it 'should create database objects (roles, databses, extension)' do expect(chef_run).not_to run_execute('/opt/gitlab/embedded/bin/initdb -D /var/opt/gitlab/postgresql/data -E UTF8') expect(chef_run).to create_postgresql_user('gitlab') expect(chef_run).to create_postgresql_user('gitlab_replicator') expect(chef_run).to create_pgbouncer_user('rails:ci') expect(chef_run).to create_pgbouncer_user('rails:main') expect(chef_run).to create_postgresql_database('gitlabhq_production') expect(chef_run).to enable_postgresql_extension('pg_trgm') expect(chef_run).to enable_postgresql_extension('btree_gist') end it 'should create patroni configuration file for PostgreSQL 12' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/patroni.yaml').with_content { |content| expect(YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true)).to eq(default_patroni_config) } end it 'should create patroni configuration file for PostgreSQL 13' do allow_any_instance_of(PgHelper).to receive(:version).and_return(PGVersion.new('13.0')) allow_any_instance_of(PgHelper).to receive(:database_version).and_return(PGVersion.new('13.0')) expect(chef_run).to render_file('/var/opt/gitlab/patroni/patroni.yaml').with_content { |content| expect(YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true)).to eq(default_patroni_config_pg13) } end end context 'when enabled with specific config' do before do stub_gitlab_rb( roles: %w(postgres_role), postgresql: { username: 'test_psql_user', sql_user: 'test_sql_user', sql_user_password: 'dbda601b8d4dc3d1697ef84dbbb8e61b', sql_replication_user: 'test_sql_replication_user', sql_replication_password: '48e84afb4b268128ac14f7c66fc7af42', pgbouncer_user: 'test_pgbouncer_user', pgbouncer_user_password: '2bc94731612abb74aea7805a41dfcb09', connect_port: 15432, }, patroni: { enable: true, scope: 'test-scope', name: 'test-node-name', log_level: 'DEBUG', loop_wait: 20, ttl: 60, master_start_timeout: 600, use_slots: false, use_pg_rewind: true, connect_address: '1.2.3.4', connect_port: 18008, username: 'gitlab', password: 'restapipassword', replication_password: 'fakepassword', allowlist: ['1.2.3.4/32', '127.0.0.1/32'], allowlist_include_members: false, remove_data_directory_on_diverged_timelines: true, remove_data_directory_on_rewind_failure: true, replication_slots: { 'geo_secondary' => { 'type' => 'physical' } }, consul: { service_check_interval: '20s' }, postgresql: { wal_keep_segments: 16, max_wal_senders: 4, max_replication_slots: 4 }, tags: { nofailover: true }, callbacks: { on_role_change: "/patroni/scripts/post-failover-maintenance.sh" }, recovery_conf: { restore_command: "/opt/wal-g/bin/wal-g wal-fetch %f %p" } } ) end it 'should be reflected in patroni configuration file' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/patroni.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg).to include( name: 'test-node-name', scope: 'test-scope', log: { level: 'DEBUG' }, tags: { nofailover: true } ) expect(cfg[:consul][:service_check_interval]).to eq('20s') expect(cfg[:postgresql][:connect_address]).to eq('1.2.3.4:15432') expect(cfg[:postgresql][:authentication]).to eq( superuser: { username: 'test_psql_user' }, replication: { username: 'test_sql_replication_user', password: 'fakepassword' } ) expect(cfg[:postgresql][:callbacks]).to eq( on_role_change: "/patroni/scripts/post-failover-maintenance.sh" ) expect(cfg[:postgresql][:recovery_conf]).to eq( restore_command: "/opt/wal-g/bin/wal-g wal-fetch %f %p" ) expect(cfg[:restapi]).to include( connect_address: '1.2.3.4:18008', authentication: { username: 'gitlab', password: 'restapipassword' }, allowlist: ['1.2.3.4/32', '127.0.0.1/32'], allowlist_include_members: false ) expect(cfg[:bootstrap][:dcs]).to include( loop_wait: 20, ttl: 60, master_start_timeout: 600, slots: { geo_secondary: { type: 'physical' } } ) expect(cfg[:bootstrap][:dcs][:postgresql]).to include( use_slots: false, use_pg_rewind: true ) expect(cfg[:bootstrap][:dcs][:postgresql][:parameters]).to include( wal_keep_segments: 16, max_wal_senders: 4, max_replication_slots: 4 ) expect(cfg[:postgresql][:remove_data_directory_on_rewind_failure]).to be true expect(cfg[:postgresql][:remove_data_directory_on_diverged_timelines]).to be true } end it 'should reflect into dcs config file' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/dcs.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg).to include( loop_wait: 20, ttl: 60, master_start_timeout: 600, slots: { geo_secondary: { type: 'physical' } } ) expect(cfg[:postgresql]).to include( use_slots: false, use_pg_rewind: true ) expect(cfg[:postgresql][:parameters]).to include( wal_keep_segments: 16, max_wal_senders: 4, max_replication_slots: 4 ) } end end context 'when standby cluster is enabled' do before do stub_gitlab_rb( roles: %w(postgres_role), patroni: { enable: true, use_pg_rewind: true, replication_password: 'fakepassword', standby_cluster: { enable: true, host: '1.2.3.4', port: 5432, primary_slot_name: 'geo_secondary' } }, postgresql: { sql_user_password: 'a4125c87ce2572ce271cd77e0de9a0ad', sql_replication_password: 'e64b415e9b9a34ac7ac6e53ae16ccacb', md5_auth_cidr_addresses: '1.2.3.4/32' } ) end it 'should be reflected in patroni configuration file' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/patroni.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg[:postgresql][:authentication]).to include( replication: { username: 'gitlab_replicator', password: 'fakepassword' } ) expect(cfg[:bootstrap][:dcs]).to include( standby_cluster: { host: '1.2.3.4', port: 5432, primary_slot_name: 'geo_secondary' } ) expect(cfg[:bootstrap][:dcs][:postgresql]).to include( use_pg_rewind: true ) } end it 'should reflect into dcs config file' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/dcs.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg).to include( standby_cluster: { host: '1.2.3.4', port: 5432, primary_slot_name: 'geo_secondary' } ) expect(cfg[:postgresql]).to include( use_pg_rewind: true ) } end end context 'when building a cluster' do before do stub_gitlab_rb( roles: %w(postgres_role), patroni: { enable: true } ) end context 'from scratch' do before do allow_any_instance_of(OmnibusHelper).to receive(:service_dir_enabled?).and_return(false) end it 'should enable patroni service and disable postgresql runit service' do expect(chef_run).to enable_runit_service('patroni') expect(chef_run).to disable_runit_service('postgresql') end end context 'converting a standalone instance to a cluster member' do before do allow_any_instance_of(OmnibusHelper).to receive(:service_dir_enabled?).and_return(true) allow_any_instance_of(PatroniHelper).to receive(:node_status).and_return('running') end it 'should signal to node to restart postgresql and disable its runit service' do expect(chef_run).to enable_runit_service('patroni') expect(chef_run).to disable_runit_service('postgresql') expect(chef_run).to run_execute('signal to restart postgresql') end end context 'on a replica' do before do allow_any_instance_of(PgHelper).to receive(:replica?).and_return(true) end it 'should not create database objects' do expect(chef_run).not_to create_postgresql_user('gitlab') expect(chef_run).not_to create_postgresql_user('gitlab_replicator') expect(chef_run).not_to create_pgbouncer_user('patroni') expect(chef_run).not_to run_execute('create gitlabhq_production database') expect(chef_run).not_to enable_postgresql_extension('pg_trgm') expect(chef_run).not_to enable_postgresql_extension('btree_gist') end end end context 'postgresql dynamic configuration' do before do allow(Gitlab).to receive(:[]).and_call_original end context 'with no explicit override' do before do stub_gitlab_rb( roles: %w(postgres_role), patroni: { enable: true } ) end it 'should use default values from postgresql cookbook and handle corner cases' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/dcs.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg[:postgresql][:use_pg_rewind]).to be(true) expect(cfg[:postgresql][:parameters]).to include( max_connections: 400, max_locks_per_transaction: 128, max_worker_processes: 8, max_prepared_transactions: 0, track_commit_timestamp: 'off', wal_log_hints: 'off', max_wal_senders: 5, max_replication_slots: 5, wal_keep_segments: 10, checkpoint_timeout: 30 ) } end end context 'with no explicit override and non-default postgresql settings' do before do stub_gitlab_rb( roles: %w(postgres_role), patroni: { enable: true, }, postgresql: { max_connections: 123, max_locks_per_transaction: 321, max_worker_processes: 12, wal_log_hints: 'foo', max_wal_senders: 11, max_replication_slots: 13, } ) end it 'should use default values from postgresql cookbook' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/dcs.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg[:postgresql][:use_pg_rewind]).to be(true) expect(cfg[:postgresql][:parameters]).to include( max_connections: 123, max_locks_per_transaction: 321, max_worker_processes: 12, max_prepared_transactions: 0, track_commit_timestamp: 'off', wal_log_hints: 'foo', max_wal_senders: 11, max_replication_slots: 13, wal_keep_segments: 10, checkpoint_timeout: 30 ) } end end context 'with explicit override' do before do stub_gitlab_rb( roles: %w(postgres_role), patroni: { enable: true, postgresql: { max_connections: 100, max_locks_per_transaction: 64, max_worker_processes: 4, wal_log_hints: 'on', max_wal_senders: 0, max_replication_slots: 0, checkpoint_timeout: '5min' } } ) end it 'should use default values from postgresql cookbook' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/dcs.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg[:postgresql][:use_pg_rewind]).to be(true) expect(cfg[:postgresql][:parameters]).to include( max_connections: 100, max_locks_per_transaction: 64, max_worker_processes: 4, max_wal_senders: 0, max_replication_slots: 0, wal_log_hints: 'on' ) } end end context 'when pg_rewind is enabled' do before do stub_gitlab_rb( roles: %w(postgres_role), patroni: { enable: true, use_pg_rewind: true, remove_data_directory_on_diverged_timelines: true, remove_data_directory_on_rewind_failure: true } ) end it 'should use default values from postgresql cookbook' do expect(chef_run).to render_file('/var/opt/gitlab/patroni/dcs.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg[:postgresql][:use_pg_rewind]).to be(true) expect(cfg[:postgresql][:parameters]).to include( max_connections: 400, max_locks_per_transaction: 128, max_worker_processes: 8, wal_log_hints: 'on' ) } end end end context 'when patroni is enabled but consul is not' do let(:chef_run) do converge_config('gitlab-ee::default', is_ee: true) end before do stub_gitlab_rb( patroni: { enable: true } ) end it 'expects a warning to be printed' do chef_run expect_logged_warning(/Patroni is enabled but Consul seems to be disabled/) end end context 'when tls is enabled' do it 'should only set the path to the tls certificate and key' do stub_gitlab_rb( roles: %w(postgres_role), patroni: { enable: true, tls_certificate_file: '/path/to/crt.pem', tls_key_file: '/path/to/key.pem' } ) expect(chef_run).to render_file('/var/opt/gitlab/patroni/patroni.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg[:restapi][:certfile]).to eq('/path/to/crt.pem') expect(cfg[:restapi][:keyfile]).to eq('/path/to/key.pem') expect(cfg[:restapi][:keyfile_password]).to be(nil) expect(cfg[:restapi][:cafile]).to be(nil) expect(cfg[:restapi][:ciphers]).to be(nil) expect(cfg[:restapi][:verify_client]).to be(nil) expect(cfg[:ctl]).to be(nil) } end it 'should set all available tls configuration including tls certificate and key paths' do stub_gitlab_rb( roles: %w(postgres_role), patroni: { enable: true, tls_certificate_file: '/path/to/crt.pem', tls_key_file: '/path/to/key.pem', tls_key_password: 'fakepassword', tls_ca_file: '/path/to/ca.pem', tls_ciphers: 'CIPHERS LIST', tls_client_mode: 'optional', tls_client_certificate_file: '/path/to/client.pem', tls_client_key_file: '/path/to/client.key' } ) expect(chef_run).to render_file('/var/opt/gitlab/patroni/patroni.yaml').with_content { |content| cfg = YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true) expect(cfg[:restapi][:certfile]).to eq('/path/to/crt.pem') expect(cfg[:restapi][:keyfile]).to eq('/path/to/key.pem') expect(cfg[:restapi][:keyfile_password]).to eq('fakepassword') expect(cfg[:restapi][:cafile]).to eq('/path/to/ca.pem') expect(cfg[:restapi][:ciphers]).to eq('CIPHERS LIST') expect(cfg[:restapi][:verify_client]).to eq('optional') expect(cfg[:ctl][:insecure]).to eq(false) expect(cfg[:ctl][:certfile]).to eq('/path/to/client.pem') expect(cfg[:ctl][:keyfile]).to eq('/path/to/client.key') } end end end