#
# Copyright:: Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require_relative 'warnings'
require_relative 'pgversion'

class PostgresqlPreflightValidator < PreflightValidator
  # This check used to verify that the external PG version matches the version
  # we ship. When we bumped the version we ship to 9.6, we haven't yet
  # introduced any changes that _require_ 9.6. So, these constants reflect the
  # actually required PG version.
  REQUIRED_VERSION  = PgVersion.new('9.6')

  # supported PG version
  SUPPORTED_VERSION = PgVersion.new('13')

  def run!
    warn_about_removed_attribute('checkpoint_segments')
    verify_unchanged_external_flag

    # Our additional validations only apply when a database server exists,
    # which - for the time being - means we're only going to run these checks
    # if external DB is configured.
    return unless cs_pg_attr['external']

    external_postgres_config_validation

    # In the future, we should be able to run these
    # on non-backend nodes:
    connectivity_validation
    backend_validation
  end

  def bookshelf_in_sql?
    PrivateChef['bookshelf'] && PrivateChef['bookshelf']['storage_type'].to_s == 'sql'
  end

  def databases_to_check
    d = %w(bifrost opscode_chef oc_id)
    d << 'bookshelf' if bookshelf_in_sql?
    d
  end

  def roles_to_check
    d = %w(opscode-erchef oc_id oc_bifrost)
    d << 'bookshelf' if bookshelf_in_sql?
    d
  end

  def external_postgres_config_validation
    # For now we're only performing these checks for external pgsql mode:
    return unless cs_pg_attr['external']

    # These values must be explictly set in chef-server.rb:
    fail_with err_CSPG004_missing_external_host unless cs_pg_attr.key? 'vip'
    fail_with err_CSPG002_missing_superuser_id unless cs_pg_attr.key? 'db_superuser'
    fail_with err_CSPG003_missing_superuser_password unless has_superuser_password?
  end

  def has_superuser_password?
    # NOTE(ssd): Since these checks run before any secrets are migrated to the store, we need to check both
    # the credentials store and the configuration file.
    PrivateChef.credentials.exist?('postgresql', 'db_superuser_password') || cs_pg_attr['db_superuser_password']
  end

  # We do not support changing from managed to external DB or vice-versa, so the
  # 'external' flag may not be changed in any scenario - it must be set from the first run
  # of chef-server-ctl reconfigure.
  def verify_unchanged_external_flag
    # Given that someone may  move a back-end to a new instance and update the
    # front-ends with the shared chef-server.rb, we may reasonably expect to
    # see this value change on the front end - so let's not call that out as an error.

    # This test isn't valid until we're bootstrapped (which could
    # encompass multiple runs in case of error). Before bootstrap,
    # the default value of 'false' will be in place, and having a
    # differing value is valid.
    if OmnibusHelper.has_been_bootstrapped? && backend? && previous_run
      if (cs_pg_attr.key? 'external') && (cs_pg_attr['external'] != previous_run['postgresql']['external'])
        fail_with err_CSPG001_cannot_change_external_flag
      end
    end
  end

  # When upgrading to 9.6, checkpoint_segments config was removed. The new
  # configurables' defaults are such that they should not need tuning. However,
  # we warn users that have changed the old attribute that their changes don't
  # have any effect anymore.
  def warn_about_removed_attribute(attr)
    return unless cs_pg_attr[attr]

    ChefServer::Warnings.warn err_unused_postgres_configurable(attr)
  end

  def connectivity_validation
    # all nodes are expected to be able to reach the database node
    # and connect to it - let's make a connection intended to fail
    # with an auth-related error just to verify connectivity to the service.
    begin
      connect_as(:invalid_user, 'silent' => true, 'retries' => 0)
    rescue ::PG::ConnectionBad => e
      # A bit messy but PG gem does not expose error codes to us:
      case e.message
      when /.*Connection refused.*/
        fail_with err_CSPG010_postgres_not_available
      when /.*password authentication failed.*/
        # This is what we want to see.
      when /role .* does not exist/
        # This indicates we were successfully able to connect to Postgres
        # AND we authenticated! This is likely because the pg_hba is set
        # to trust our connection. Such an example would be configuring
        # Chef Infra Server to use an "external" Postgres, such as Delivery's,
        # when running on the same host.
      when /.*no pg_hba.conf entry.*/
        # This is also possible, depending on if they've set up pg_hba
        # by host or user or both. This is OK, since it confirms that we're
        # able to connect to the postgres instance.
      when /.*The Username should be in <username@hostname> format.*/
        # This is possible if the external postgres is an Azure Database
        # for PostgreSQL instance. It's also OK, and indicates that the
        # postgres instance can be connected to.
      else
        # This shouldn't be possible, but we still don't want to dump an
        # unhelpful stack trace on the screen. Let's at least catch it and
        # fail with a meaningful error.
        fail_with "CSPG999: #{e.message}"
      end
    end
  end

  def backend_validation
    connect_as(:superuser, 'silent' => true) do |connection|
      backend_verify_database_access(connection)
      backend_verify_postgres_version(connection)
      # The database should only exist if we haven't bootstrapped the previous
      # run, or secrets haven't been copied from previous node.
      if first_run?
        if secrets_exists?
          return
        end
      else
        return
      end
      databases_to_check.each { |db| backend_verify_named_db_not_present(connection, db) }
      backend_verify_cs_roles_not_present(connection)
    end
  rescue ::PG::InsufficientPrivilege => e
    fail_with err_CSPG013_not_superuser
  rescue ::PG::ConnectionBad => e
    case e.message
    when /.*database 'template1', does not exist.*/
      fail_with err_CSPG015_template1_db_required
    when /.*password authentication failed.*/
      fail_with err_CSPG011_invalid_superuser_account
    when /.*no pg_hba.conf entry.*/
      fail_with err_CSPG012_invalid_pg_hba
    else
      # As above - we shouldn't hit this path, but if we do let's at least
      # show a meaningful error instead of a stack trace.
      fail_with "CSPG999: #{e.message}"
    end
  end

  def backend_verify_database_access(connection)
    # Make sure we have the access we need.
    # Note on not escaping username: we already connected with the same attribute,
    # so it's safe at this point.
    r = connection.exec("SELECT rolsuper, rolcreaterole, rolcreatedb FROM pg_roles WHERE rolname='#{cs_pg_attr['db_superuser']}';")
    # a super user may not have createrole/createdb flags set, so check for both cases
    unless (r[0]['rolsuper'] == 't') || ((r[0]['rolcreaterole'] == 't') && (r[0]['rolcreatedb'] == 't'))
      fail_with err_CSPG013_not_superuser
    end
  end

  def backend_verify_postgres_version(connection)
    # Make sure the server is a supported version.
    r = connection.exec('SHOW server_version;')
    v = PgVersion.new /^([0-9\.]+)/.match(r.first['server_version']).to_a.first

    # Note that we're looking for the same major, and using our minor as the minimum version
    # This provides compatibility with external databases that use < 13 before we make use
    # of any features available in > 9.6.

    if v.major == REQUIRED_VERSION || v.major == SUPPORTED_VERSION
      :ok
    elsif v.major < REQUIRED_VERSION || v.major > SUPPORTED_VERSION
      fail_with err_CSPG014_bad_postgres_version(v)
    elsif v.major < SUPPORTED_VERSION
      ChefServer::Warnings.warn err_unsupported_postgres_version(v)
    end
  end

  # Throws CSPG017 if any of the reserved usernames we
  # need to create are already in the datbase.
  def backend_verify_cs_roles_not_present(connection)
    roles_to_check.each do |service|
      %w(sql_user sql_ro_user).each do |key|
        username = service_key_value(service, key)
        fail_with err_CSPG017_role_exists(username) if named_role_exists?(connection, username)
      end
    end
  end

  def backend_verify_named_db_not_present(connection, name)
    fail_with err_CSPG016_database_exists(name) if named_db_exists?(connection, name)
  end

  private

  def err_CSPG001_cannot_change_external_flag
    <<~EOM
      CSPG001: The value of postgresql['external'] must be set prior to the initial
               run of chef-server-ctl reconfigure and cannot be changed.

               See https://docs.chef.io/errors/#cspg001-changed-setting
               for more information on how you can transition an existing chef-server
               to a new instance configured for an external database and vice-versa.
    EOM
  end

  def err_CSPG002_missing_superuser_id
    <<~EOM
      CSPG002: You have not set a database superuser name under
               "postgresql['db_superuser']" in #{ChefUtils::Dist::Server::SERVER}.rb.  This is required
               for external database support - please set it now and
               then re-run 'chef-server-ctl reconfigure'.

               See https://docs.chef.io/server/#postgresql-settings
               for more information.
    EOM
  end

  def err_CSPG003_missing_superuser_password
    <<~EOM
      CSPG003: You have not set a database superuser password using
               chef-server-ctl set-db-superuser-password. This is required
               for external database support - please run this now, then
               re-run 'chef-server-ctl reconfigure'.

               See https://docs.chef.io/server/#postgresql-settings
               for more information.
    EOM
  end

  def err_CSPG004_missing_external_host
    <<~EOM
      CSPG004: Because postgresql['external'] is set to true, you must also set
               postgresql['vip'] to the host or IP of an external postgres database
               in #{ChefUtils::Dist::Server::SERVER}.rb.

               See https://docs.chef.io/server/#postgresql-settings
               for more information.
    EOM
  end

  def err_CSPG010_postgres_not_available
    <<~EOM
      CSPG010: I cannot make a connection to the host #{cs_pg_attr['vip']}.  Please
               verify that the host is online and reachable from this node, and that
               you have configured postgresql['port'] if it's not the standard
               port 5432, then run 'chef-server-ctl reconfigure' again.

               See https://docs.chef.io/errors/#cspg010-cannot-connect
               for more information about postgresql networking requirements.
    EOM
  end

  def err_CSPG011_invalid_superuser_account
    <<EOM
CSPG011: I could not authenticate to #{cs_pg_attr['vip']} as
         #{cs_pg_attr['db_connection_superuser'] || cs_pg_attr['db_superuser']} using the password provided.
         Please make sure that the the password you provided in
         #{ChefUtils::Dist::Server::SERVER}.rb under "postgresql['db_superuser_password'] is correct
         for this user.

         See https://docs.chef.io/errors/#cspg011-cannot-authenticate
         for more information.
EOM
  end

  def err_CSPG012_invalid_pg_hba
    <<EOM
CSPG012: There is a missing or incorrect pg_hba.conf entry for the
         user '#{cs_pg_attr['db_connection_superuser'] || cs_pg_attr['db_superuser']}' and/or this originating host.
         Please ensure that pg_hba.conf entries exist to allow the superuser
         account to connect from the #{ChefUtils::Dist::Server::PRODUCT} backend nodes, and to
         allow the application accounts to connect from all #{ChefUtils::Dist::Server::PRODUCT}
         nodes.

         See https://docs.chef.io/errors/#cspg012-incorrect-rules
         for more information.
EOM
  end

  def err_CSPG013_not_superuser
    <<~EOM
      CSPG013: The superuser account '#{cs_pg_attr['db_superuser']}' does not have
               superuser access to the to the database specified.  At minimum, this
               user must be granted CREATE DATABASE and CREATE ROLE privileges.

               See https://docs.chef.io/errors/#cspg013-incorrect-permissions
               for more information.
    EOM
  end

  def err_CSPG014_bad_postgres_version(ver)
    <<~EOM
      CSPG014: #{ChefUtils::Dist::Server::PRODUCT} currently requires PostgreSQL version #{REQUIRED_VERSION} or greater.
               The database you have provided is running version #{ver}.

               See https://docs.chef.io/errors/#cspg014-incorrect-version
               for more information.
    EOM
  end

  def err_CSPG015_template1_db_required
    <<~EOM
      CSPG015: The database server you provided does not have the default database
               template1 available.  Please create the template1 database before
               proceeding.

               See https://docs.chef.io/errors/#cspg015-missing-database
               for more information.
    EOM
  end

  def err_CSPG016_database_exists(dbname)
    <<~EOM
      CSPG016: The #{ChefUtils::Dist::Server::PRODUCT} database named '#{dbname}' already exists on the
               PostgreSQL server. Please remove it before proceeding.

               See https://docs.chef.io/errors/#cspg016-database-exists
               for more information.
    EOM
  end

  def err_CSPG017_role_exists(username)
    <<~EOM
      CSPG017: #{ChefUtils::Dist::Server::PRODUCT} database role/user named '#{username}' already exists
               on the PostgreSQL server. If possible, please remove this user
               via 'DROP ROLE "#{username}"' before proceeding, or reference the
               troubleshooting link below for information about configuring
               #{ChefUtils::Dist::Server::PRODUCT} to use an alternative user name.

               See https://docs.chef.io/errors/#cspg017-user-exists
               for more information.
    EOM
  end

  def err_unused_postgres_configurable(setting)
    <<~EOM
      The setting

          postgresql['#{setting}']

      is no longer supported by the version of PostgreSQL included in Chef
      Server. Please check the release notes at https://docs.chef.io/release_notes_server for details.
    EOM
  end

  def err_unsupported_postgres_version(ver)
    <<~EOM
      Chef Server currently supports PostgreSQL version #{SUPPORTED_VERSION}.
      The database you have provided is running version #{ver}.

      Please check the release notes at https://docs.chef.io/release_notes_server for details.
    EOM
  end
end
