/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 */

package org.apache.ambari.server.serveraction.kerberos;

import com.google.inject.Inject;
import org.apache.ambari.server.AmbariException;
import org.apache.ambari.server.actionmanager.HostRoleStatus;
import org.apache.ambari.server.agent.CommandReport;
import org.apache.ambari.server.configuration.Configuration;
import org.apache.ambari.server.orm.dao.KerberosPrincipalDAO;
import org.apache.ambari.server.orm.dao.KerberosPrincipalHostDAO;
import org.apache.ambari.server.orm.entities.KerberosPrincipalEntity;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.directory.server.kerberos.shared.keytab.Keytab;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;

import static org.apache.ambari.server.serveraction.kerberos.KerberosActionDataFile.HOSTNAME;
import static org.apache.ambari.server.serveraction.kerberos.KerberosActionDataFile.KEYTAB_FILE_IS_CACHABLE;
import static org.apache.ambari.server.serveraction.kerberos.KerberosActionDataFile.KEYTAB_FILE_PATH;

/**
 * CreateKeytabFilesServerAction is a ServerAction implementation that creates keytab files as
 * instructed.
 * <p/>
 * This class mainly relies on the KerberosServerAction to iterate through metadata identifying
 * the Kerberos keytab files that need to be created. For each identity in the metadata, this
 * implementation's
 * {@link KerberosServerAction#processIdentity(java.util.Map, String, KerberosOperationHandler, java.util.Map)}
 * is invoked attempting the creation of the relevant keytab file.
 */
public class CreateKeytabFilesServerAction extends KerberosServerAction {
  private final static Logger LOG = LoggerFactory.getLogger(CreateKeytabFilesServerAction.class);

  /**
   * KerberosPrincipalDAO used to set and get Kerberos principal details
   */
  @Inject
  private KerberosPrincipalDAO kerberosPrincipalDAO;

  /**
   * KerberosPrincipalHostDAO used to get Kerberos principal details
   */
  @Inject
  private KerberosPrincipalHostDAO kerberosPrincipalHostDAO;

  /**
   * Configuration used to get the configured properties such as the keytab file cache directory
   */
  @Inject
  private Configuration configuration;

  /**
   * A map of data used to track what has been processed in order to optimize the creation of keytabs
   * such as knowing when to create a cached keytab file or use a cached keytab file.
   */
  Map<String, Set<String>> visitedIdentities = new HashMap<String, Set<String>>();

  /**
   * Called to execute this action.  Upon invocation, calls
   * {@link org.apache.ambari.server.serveraction.kerberos.KerberosServerAction#processIdentities(java.util.Map)} )}
   * to iterate through the Kerberos identity metadata and call
   * {@link org.apache.ambari.server.serveraction.kerberos.CreateKeytabFilesServerAction#processIdentities(java.util.Map)}
   * for each identity to process.
   *
   * @param requestSharedDataContext a Map to be used a shared data among all ServerActions related
   *                                 to a given request
   * @return a CommandReport indicating the result of this action
   * @throws AmbariException
   * @throws InterruptedException
   */
  @Override
  public CommandReport execute(ConcurrentMap<String, Object> requestSharedDataContext) throws
      AmbariException, InterruptedException {
    return processIdentities(requestSharedDataContext);
  }


  /**
   * For each identity, create a keytab and append to a new or existing keytab file.
   * <p/>
   * It is expected that the {@link org.apache.ambari.server.serveraction.kerberos.CreatePrincipalsServerAction}
   * (or similar) has executed before this action and a set of passwords has been created, map to
   * their relevant (evaluated) principals and stored in the requestSharedDataContext.
   * <p/>
   * If a password exists for the current evaluatedPrincipal, use a
   * {@link org.apache.ambari.server.serveraction.kerberos.KerberosOperationHandler} to generate
   * the keytab file. To help avoid filename collisions and to build a structure that is easy to
   * discover, each keytab file is stored in host-specific
   * ({@link org.apache.ambari.server.serveraction.kerberos.KerberosActionDataFile#HOSTNAME})
   * directory using the SHA1 hash of its destination file path
   * ({@link org.apache.ambari.server.serveraction.kerberos.KerberosActionDataFile#KEYTAB_FILE_PATH})
   * <p/>
   * <pre>
   *   data_directory
   *   |- host1
   *   |  |- 16a054404c8826cd604a27ac970e8cc4b9c7a3fa   (keytab file)
   *   |  |- ...                                        (keytab files)
   *   |  |- a3c09cae73406912e8c55296d1c85b674d24f576   (keytab file)
   *   |- host2
   *   |  |- ...
   * </pre>
   *
   * @param identityRecord           a Map containing the data for the current identity record
   * @param evaluatedPrincipal       a String indicating the relevant principal
   * @param operationHandler         a KerberosOperationHandler used to perform Kerberos-related
   *                                 tasks for specific Kerberos implementations
   *                                 (MIT, Active Directory, etc...)
   * @param requestSharedDataContext a Map to be used a shared data among all ServerActions related
   *                                 to a given request
   * @return a CommandReport, indicating an error condition; or null, indicating a success condition
   * @throws AmbariException if an error occurs while processing the identity record
   */
  @Override
  protected CommandReport processIdentity(Map<String, String> identityRecord, String evaluatedPrincipal,
                                          KerberosOperationHandler operationHandler,
                                          Map<String, Object> requestSharedDataContext)
      throws AmbariException {
    CommandReport commandReport = null;

    if (identityRecord != null) {
      String message;

      if (operationHandler == null) {
        message = String.format("Failed to create keytab file for %s, missing KerberosOperationHandler", evaluatedPrincipal);
        actionLog.writeStdErr(message);
        LOG.error(message);
        commandReport = createCommandReport(1, HostRoleStatus.FAILED, "{}", actionLog.getStdOut(), actionLog.getStdErr());
      } else {
        Map<String, String> principalPasswordMap = getPrincipalPasswordMap(requestSharedDataContext);
        Map<String, Integer> principalKeyNumberMap = getPrincipalKeyNumberMap(requestSharedDataContext);

        String host = identityRecord.get(HOSTNAME);
        String keytabFilePath = identityRecord.get(KEYTAB_FILE_PATH);

        if ((host != null) && !host.isEmpty() && (keytabFilePath != null) && !keytabFilePath.isEmpty()) {
          Set<String> visitedPrincipalKeys = visitedIdentities.get(evaluatedPrincipal);
          String visitationKey = String.format("%s|%s", host, keytabFilePath);

          if ((visitedPrincipalKeys == null) || !visitedPrincipalKeys.contains(visitationKey)) {
            // Look up the current evaluatedPrincipal's password.
            // If found create the keytab file, else try to find it in the cache.
            String password = principalPasswordMap.get(evaluatedPrincipal);

            message = String.format("Creating keytab file for %s on host %s", evaluatedPrincipal, host);
            LOG.info(message);
            actionLog.writeStdOut(message);

            // Determine where to store the keytab file.  It should go into a host-specific
            // directory under the previously determined data directory.
            File hostDirectory = new File(getDataDirectoryPath(), host);

            // Ensure the host directory exists...
            if (!hostDirectory.exists() && hostDirectory.mkdirs()) {
              // Make sure only Ambari has access to this directory.
              ensureAmbariOnlyAccess(hostDirectory);
            }

            if (hostDirectory.exists()) {
              File destinationKeytabFile = new File(hostDirectory, DigestUtils.sha1Hex(keytabFilePath));

              if (password == null) {
                if (kerberosPrincipalHostDAO.exists(evaluatedPrincipal, host)) {
                  // There is nothing to do for this since it must already exist and we don't want to
                  // regenerate the keytab
                  message = String.format("Skipping keytab file for %s, missing password indicates nothing to do", evaluatedPrincipal);
                  LOG.debug(message);
                } else {
                  KerberosPrincipalEntity principalEntity = kerberosPrincipalDAO.find(evaluatedPrincipal);
                  String cachedKeytabPath = (principalEntity == null) ? null : principalEntity.getCachedKeytabPath();

                  if (cachedKeytabPath == null) {
                    message = String.format("Failed to create keytab for %s, missing cached file", evaluatedPrincipal);
                    actionLog.writeStdErr(message);
                    LOG.error(message);
                    commandReport = createCommandReport(1, HostRoleStatus.FAILED, "{}", actionLog.getStdOut(), actionLog.getStdErr());
                  } else {
                    try {
                      operationHandler.createKeytabFile(new File(cachedKeytabPath), destinationKeytabFile);
                    } catch (KerberosOperationException e) {
                      message = String.format("Failed to create keytab file for %s - %s", evaluatedPrincipal, e.getMessage());
                      actionLog.writeStdErr(message);
                      LOG.error(message, e);
                      commandReport = createCommandReport(1, HostRoleStatus.FAILED, "{}", actionLog.getStdOut(), actionLog.getStdErr());
                    }
                  }
                }
              } else {
                Keytab keytab = null;

                // Possibly get the keytab from the cache
                if (visitedPrincipalKeys != null) {
                  // Since we have visited this principal before, attempt to pull the keytab from the
                  // cache...
                  KerberosPrincipalEntity principalEntity = kerberosPrincipalDAO.find(evaluatedPrincipal);
                  String cachedKeytabPath = (principalEntity == null) ? null : principalEntity.getCachedKeytabPath();

                  if (cachedKeytabPath != null) {
                    try {
                      keytab = Keytab.read(new File(cachedKeytabPath));
                    } catch (IOException e) {
                      message = String.format("Failed to read the cached keytab for %s, recreating if possible - %s",
                          evaluatedPrincipal, e.getMessage());

                      if (LOG.isDebugEnabled()) {
                        LOG.warn(message, e);
                      } else {
                        LOG.warn(message, e);
                      }
                    }
                  }
                }

                // If the keytab was not retrieved from the cache... create it.
                if (keytab == null) {
                  Integer keyNumber = principalKeyNumberMap.get(evaluatedPrincipal);

                  try {
                    keytab = operationHandler.createKeytab(evaluatedPrincipal, password, keyNumber);

                    // If the current identity does not represent a service, copy it to a secure location
                    // and store that location so it can be reused rather than recreate it.
                    KerberosPrincipalEntity principalEntity = kerberosPrincipalDAO.find(evaluatedPrincipal);
                    if (principalEntity != null) {
                      if (!principalEntity.isService() && ("true".equalsIgnoreCase(identityRecord.get(KEYTAB_FILE_IS_CACHABLE)))) {
                        File cachedKeytabFile = cacheKeytab(evaluatedPrincipal, keytab);
                        String previousCachedFilePath = principalEntity.getCachedKeytabPath();
                        String cachedKeytabFilePath = ((cachedKeytabFile == null) || !cachedKeytabFile.exists())
                            ? null
                            : cachedKeytabFile.getAbsolutePath();

                        principalEntity.setCachedKeytabPath(cachedKeytabFilePath);
                        kerberosPrincipalDAO.merge(principalEntity);

                        if(previousCachedFilePath != null) {
                          if(!new File(previousCachedFilePath).delete()) {
                            LOG.debug(String.format("Failed to remove orphaned cache file %s", previousCachedFilePath));
                          }
                        }
                      }
                    }
                  } catch (KerberosOperationException e) {
                    message = String.format("Failed to create keytab file for %s - %s", evaluatedPrincipal, e.getMessage());
                    actionLog.writeStdErr(message);
                    LOG.error(message, e);
                    commandReport = createCommandReport(1, HostRoleStatus.FAILED, "{}", actionLog.getStdOut(), actionLog.getStdErr());
                  }
                }

                if (keytab != null) {
                  try {
                    if (operationHandler.createKeytabFile(keytab, destinationKeytabFile)) {
                      ensureAmbariOnlyAccess(destinationKeytabFile);

                      message = String.format("Successfully created keytab file for %s at %s", evaluatedPrincipal, destinationKeytabFile.getAbsolutePath());
                      LOG.debug(message);
                    } else {
                      message = String.format("Failed to create keytab file for %s at %s", evaluatedPrincipal, destinationKeytabFile.getAbsolutePath());
                      actionLog.writeStdErr(message);
                      LOG.error(message);
                      commandReport = createCommandReport(1, HostRoleStatus.FAILED, "{}", actionLog.getStdOut(), actionLog.getStdErr());
                    }
                  } catch (KerberosOperationException e) {
                    message = String.format("Failed to create keytab file for %s - %s", evaluatedPrincipal, e.getMessage());
                    actionLog.writeStdErr(message);
                    LOG.error(message, e);
                    commandReport = createCommandReport(1, HostRoleStatus.FAILED, "{}", actionLog.getStdOut(), actionLog.getStdErr());
                  }
                }
              }
            } else {
              message = String.format("Failed to create keytab file for %s, the container directory does not exist: %s",
                  evaluatedPrincipal, hostDirectory.getAbsolutePath());
              actionLog.writeStdErr(message);
              LOG.error(message);
              commandReport = createCommandReport(1, HostRoleStatus.FAILED, "{}", actionLog.getStdOut(), actionLog.getStdErr());
            }

            if(visitedPrincipalKeys == null) {
              visitedPrincipalKeys = new HashSet<String>();
              visitedIdentities.put(evaluatedPrincipal, visitedPrincipalKeys);
            }

            visitedPrincipalKeys.add(visitationKey);
          }
          else {
            LOG.debug(String.format("Skipping previously processed keytab for %s on host %s", evaluatedPrincipal, host));
          }
        }
      }
    }

    return commandReport;
  }

  /**
   * Cache a keytab given its relative principal name and the keytab data.
   * <p/>
   * The specified keytab is stored in a file in a location derived using the configured keytab
   * cache directory and the seeded hash of the principal name - this is to add a slight level
   * of obscurity so that it cannot be determined what keytab data is in the file based on its name.
   * The file is the set readable by only the Ambari server process owner.
   *
   * @param principal the principal name related to the keytab data
   * @param keytab    the keytab data to cache
   * @return a File pointing to the cached keytab file
   * @throws AmbariException if a failure occurs while creating the cache file containing the the keytab data
   */
  private File cacheKeytab(String principal, Keytab keytab) throws AmbariException {
    File cacheDirectory = configuration.getKerberosKeytabCacheDir();

    if (cacheDirectory == null) {
      String message = "The Kerberos keytab cache directory is not configured in the Ambari properties";
      LOG.error(message);
      throw new AmbariException(message);
    }

    if (!cacheDirectory.exists()) {
      // If the cache directory does not exist, create it and ensure only Ambari has access to it
      if (cacheDirectory.mkdirs()) {
        ensureAmbariOnlyAccess(cacheDirectory);

        if (!cacheDirectory.exists()) {
          String message = String.format("Failed to create the keytab cache directory %s",
              cacheDirectory.getAbsolutePath());
          LOG.error(message);
          throw new AmbariException(message);
        }
      }
    }

    File cachedKeytabFile = new File(cacheDirectory, DigestUtils.sha1Hex(principal + String.valueOf(System.currentTimeMillis())));

    try {
      keytab.write(cachedKeytabFile);
      ensureAmbariOnlyAccess(cachedKeytabFile);
    } catch (IOException e) {
      String message = String.format("Failed to write the keytab for %s to the cache location (%s)",
          principal, cachedKeytabFile.getAbsolutePath());
      LOG.error(message, e);
      throw new AmbariException(message, e);
    }

    return cachedKeytabFile;
  }

  /**
   * Ensures that the owner of the Ambari server process is the only local user account able to
   * read and write to the specified file or read, write to, and execute the specified directory.
   *
   * @param file the file or directory for which to modify access
   */
  private void ensureAmbariOnlyAccess(File file) {
    if (file.exists()) {
      if (!file.setReadable(false, false) || !file.setReadable(true, true)) {
        LOG.warn(String.format("Failed to set %s readable only by Ambari", file.getAbsolutePath()));
      }

      if (!file.setWritable(false, false) || !file.setWritable(true, true)) {
        LOG.warn(String.format("Failed to set %s writable only by Ambari", file.getAbsolutePath()));
      }

      if (file.isDirectory()) {
        if (!file.setExecutable(false, false) && !file.setExecutable(true, true)) {
          LOG.warn(String.format("Failed to set %s executable by Ambari", file.getAbsolutePath()));
        }
      } else {
        if (!file.setExecutable(false, false)) {
          LOG.warn(String.format("Failed to set %s not executable", file.getAbsolutePath()));
        }
      }
    }
  }
}
