Home Reference Source Test

functions/transfer/transfer/artifact/artifact.js

import fs from 'fs';
import glob from 'glob';
import uuid from 'uuid/v4';
import Path from 'path';
import extract from 'extract-zip';
import { S3 } from 'aws-sdk';
import wrappedError from 'error/wrapped';
import typedError from 'error/typed';

import { AWS_REGION } from '../../../globals';

import File from './file';

/**
 * An error was encountered when attempting to download, commit and uncompress
 * the remote CodePipeline artifact.
 *
 * @type {Error}
 */
const readyError = wrappedError({
  message: 'Error occurred retrieving artifact',
  type: 'pipeline.artifact',
});

/**
 * The remote artifact could not be retrieved (S3 Error).
 *
 * @type {Error}
 */
const fetchError = wrappedError({
  message: 'Could not retrieve artifact.',
  type: 'pipeline.artifact.file',
});

/**
 * An error was encountered whilst attempting to read a file from inside the
 * artifact.
 *
 * @type {Error}
 */
const readError = wrappedError({
  message: 'Could not read artifact file "{filename}"',
  type: 'pipeline.artifact.file',
});

/**
 * The artifact was successfully downloaded, but an error occurred whilst
 * attempting to decompress it.
 *
 * @type {Error}
 */
const decompressionError = wrappedError({
  message: 'Failed to decompress artifact.',
  type: 'pipeline.artifact.file.decompression',
});

/**
 * The JSON key does not exist in the file.
 *
 * @type {Error}
 */
const keyNotFoundError = typedError({
  message: 'Key "{key}" not found in file "{filename}"',
  type: 'pipeline.artifact.file.json',
});

/**
 * This class handles retrieving CodePipeline artifact files as well as accessing
 * file contents in addition to attributes defined inside JSON files residing
 * within the artifact.
 *
 * As the artifacts passed to the function may reside within remote buckets which
 * this function does not have sufficient permissions to access, credentials are
 * used from the initial CodePipeline event object.
 */
export default class Artifact {
  /**
   * Constructs a new Artifact instance.
   *
   * @param {Object} s3Location - object representing a remote S3 location
   * @param {String} s3Location.bucketName - the name of the S3 bucket.
   * @param {String} s3Location.objectKey - the key of the file inside the S3 bucket.
   */
  constructor({ bucketName, objectKey }, { secretAccessKey, sessionToken, accessKeyId }) {
    /**
     * A local ID for this artifact.
     *
     * @type {String}
     */
    this.id = uuid();
    /**
     * A filename representing the name of the artifact once downloaded and stored
     * locally.
     *
     * @type {String}
     */
    this.filename = `${this.id}.zip`;
    /**
     * A fully resolved Path to the local artifact when downloaded and written
     * to the `/tmp/` directory.
     *
     * @type {String}
     */
    this.filepath = Path.join('/tmp/', this.filename);
    /**
     * A fully resolved path to the directory storing the decompressed contents
     * of the remote artifact in the `/tmp` directory.
     *
     * @type {String}
     */
    this.dir = Path.join('/tmp/', this.id);
    /**
     * The name of the S3 bucket which the remote artifact file resides in.
     *
     * @type {String}
     */
    this.bucketName = bucketName;
    /**
     * The key for the remote artifact file.
     *
     * @type {String}
     */
    this.objectKey = objectKey;
    /**
     * The AWS Secret Access Key which will be used to retrieve the remote artifact.
     *
     * @type {String}
     */
    this.secretAccessKey = secretAccessKey;
    /**
     * The AWS Session Token which will be used to retrieve the remote artifact.
     *
     * @type {String}
     */
    this.sessionToken = sessionToken;
    /**
     * The AWS Access Key ID which will be used to retrieve the remote artifact.
     *
     * @type {String}
     */
    this.accessKeyId = accessKeyId;
    /**
     * Determines if this artifact has been retrieved, decompressed and
     * written to local file storage.
     *
     * @type {Boolean}
     */
    this.isReady = false;
  }

  /**
   * Transform the properties received from CodePipeline into a new instance
   * of this class.
   *
   * This function maps the required properties from the CodePipeline invocation
   * event structure.
   *
   * @return {Artifact} a new artifact file with correctly mapped parameters.
   */
  static toArtifact(artifact, credentials = {}) {
    const {
      location: { s3Location },
    } = artifact;
    return new Artifact(s3Location, credentials);
  }

  /**
   * Returns an array which can be used to constuct a Map of artifacts.
   *
   * @return {Array[String, Artifact]}
   */
  static toArtifactMapEntry(artifact, credentials) {
    return [artifact.name, Artifact.toArtifact(artifact, credentials)];
  }

  /**
   * A utility function to ensure that this artifact has first retrived and
   * decompressed the remote artifact file, before any attempts are made
   * to retrieve artifact contents.
   *
   * @return {Boolean} true if ready, false otherwise.
   */
  async ready() {
    try {
      if (!this.isReady) {
        const data = await this.fetch();
        await this.write(data);
        await this.unzip();
        this.isReady = true;
      }
      return this.isReady;
    } catch (err) {
      throw readyError(err);
    }
  }

  /**
   * Returns an array of {@link File} instances which represent all matched
   * files from inside the retrieved, decompressed artifact.
   *
   * @param {String} select - a linux-style file glob to match files with.
   * @param {String} path - a relative path used to match files
   *
   * @return {Array[File]} an array of all matched {@link File} instances.
   */
  async match(select, path = '') {
    return new Promise((resolve, reject) => {
      try {
        const { dir } = this;
        const cwd = Path.join(dir, path);
        glob(select, { cwd, nonull: false, nodir: true }, (err, files) => {
          if (err) {
            // TODO: throw error
          } else {
            resolve(
              files.map(file => {
                const { dir: relativeDir, base: filename } = Path.parse(file);
                return new File(relativeDir, filename, Path.join(cwd, file));
              })
            );
          }
        });
      } catch (err) {
        // TODO: catch error
        reject();
      }
    });
  }

  /**
   * Once {@link Artifact#ready} has resolved successfully, this function
   * can be used to retrieve a specific file's contents from within the
   * decompressed artifact.
   *
   * @return {Buffer} a utf-8 buffer of the file's contents.
   */
  get(filename) {
    try {
      const { dir } = this;
      const path = Path.join(dir, filename);
      return fs.readFileSync(path, { encoding: 'utf8' });
    } catch (err) {
      throw readError(err, { filename });
    }
  }

  /**
   * Once {@link Artifact#ready} has resolved successfully, this function
   * can be used to retrieve the value of a specific property key from within
   * a JSON file residing inside the decompressed artifact.
   *
   * @return [String] the value of the key from within a JSON file.
   */
  attribute(filename, key) {
    try {
      const obj = JSON.parse(this.get(filename));
      if (!Object.keys(obj).includes(key)) {
        throw keyNotFoundError({ filename, key });
      } else {
        return obj[key];
      }
    } catch (err) {
      throw readError(err, { filename });
    }
  }

  /**
   * @private
   *
   * Assuming that the remote artifact has been downloaded and written to
   * {@link Artifact#filepath}, attempt to extract the contents of the artifact
   * into {@link Artifact#dir}.
   *
   * @return {Boolean} true once contents have been successfully decompressed.
   */
  unzip() {
    return new Promise((resolve, reject) => {
      const { filepath, dir } = this;
      fs.mkdirSync(dir);
      extract(filepath, { dir }, err => {
        if (err) {
          reject(decompressionError(err));
        } else {
          resolve(true);
        }
      });
    });
  }

  /**
   * @private
   *
   * Write the contents of the compressed, remote artifact to {@link Artifact#filepath}.
   *
   * @return {Boolean} true once the file has been written.
   */
  async write(data) {
    const { filepath } = this;
    fs.writeFileSync(filepath, data, { encoding: 'utf8' });
    return true;
  }

  /**
   * @private
   *
   * Using the S3 client ({@link Artifact#client}) retrieve the artifact from
   * the remote S3 bucket.
   *
   * @return {Buffer} the buffer contents of the remote artifacts retrieved from S3.
   */
  async fetch() {
    try {
      const { bucketName, objectKey } = this;
      const params = { Bucket: bucketName, Key: objectKey };
      const { Body } = await this.client.getObject(params).promise();
      return Body;
    } catch (err) {
      throw fetchError(err);
    }
  }

  /**
   * Returns a new S3 client with credentials pre-configured.
   *
   * @type {S3}
   */
  get client() {
    const { secretAccessKey, sessionToken, accessKeyId } = this;
    return new S3({ region: AWS_REGION, accessKeyId, secretAccessKey, sessionToken });
  }
}