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('amcheck')
        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
