aws/meta.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

const fs = require('fs');
const logger = require(__dirname + '/../logger');
const app = require(__dirname + '/../app');
const lib = require(__dirname + '/../lib');
const aws = require(__dirname + '/../aws');

/**
 * Read key and secret from the AWS SDK credentials file, if no profile is given in the config or command line only the default peofile
 * will be loaded.
 * @memberof module:aws
 * @method readCredentials
 */
aws.readCredentials = function(profile, callback)
{
    if (typeof profile == "function") callback = profile, profile = null;

    fs.readFile((process.env.HOME || process.env.BKJS_HOME) + "/.aws/credentials", function(err, data) {
        var creds = {};
        if (data && data.length) {
            var state = 0, lines = data.toString().split("\n");
            for (var i = 0; i < lines.length; i++) {
                var x = lines[i].split("=");
                if (state == 0) {
                    if (!profile) profile = "default";
                    if (x[0][0] == '[' && profile == x[0].substr(1, x[0].length - 2)) state = 1;
                } else

                if (state == 1) {
                    if (x[0][0] == '[') break;
                    if (x[0].trim() == "aws_access_key_id" && x[1]) creds.key = x[1].trim();
                    if (x[0].trim() == "aws_secret_access_key" && x[1]) creds.secret = x[1].trim();
                    if (x[0].trim() == "region" && x[1]) creds.region = x[1].trim();
                }
            }
            if (creds.key && creds.secret) creds.profile = profile;
            logger.debug('readCredentials:', creds);
        }
        if (typeof callback == "function") callback(creds);
    });
}

/**
 * Read and apply config from S3 bucket
 * @memberof module:aws
 * @method readConfig
 */
aws.readConfig = function(callback)
{
    var interval = this.confFileInterval > 0 ? this.confFileInterval * 60000 + lib.randomShort() : 0;
    lib.deferInterval(this, interval, "config", this.readConfig.bind(this));
    if (!/^s3:\/\//.test(this.confFile)) return lib.tryCall(callback);
    aws.s3GetFile(this.confFile, { httpTimeout: 1000 }, (err, rc) => {
        logger.debug("readConfig:", this.confFile, "status:", rc.status, "length:", rc.size);
        if (rc.status == 200) {
            app.parseConfig(rc.data, 0, "aws-s3");
        }
        lib.tryCall(callback, rc.status == 200 ? null : { status: rc.status });
    });
}

/**
 * Retrieve instance meta data
 * @memberof module:aws
 * @method getInstanceMeta
 */
aws.getInstanceMeta = function(path, options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    var opts = lib.objExtend({
        noparse: 1,
        httpTimeout: 200,
        quiet: true,
        retryCount: 2,
        retryTimeout: 100,
        errorCount: 0,
        retryOnError: function() { return this.status >= 400 && this.status != 404 && this.status != 529 },
    }, options);

    if (!lib.rxUrl.test(path)) path = `http://${this.metaHost}${path}`;
    if (this.metaToken) opts.headers = { "X-aws-ec2-metadata-token": this.metaToken };

    lib.fetch(path, opts, (err, params) => {
        if ([200, 404, 529].indexOf(params.status) == -1) logger.error('getInstanceMeta:', path, params.status, params.data, err);
        if (typeof callback == "function") callback(err, params.status == 200 ? params.obj || params.data : "");
    });
}

/**
 * @memberof module:aws
 * @method getInstanceMetaToken
 */
aws.getInstanceMetaToken = function(callback)
{
    var opts = {
        method: "PUT",
        headers: { "X-aws-ec2-metadata-token-ttl-seconds": 21600 },
        noparse: 1,
        httpTimeout: 200,
        quiet: true,
        retryCount: 3,
        retryTimeout: 100,
        retryOnError: function() { return this.status >= 400 && this.status != 404 && this.status != 529 },
    }
    lib.fetch(`http://${aws.metaHost}/latest/api/token`, opts, (err, params) => {
        if ([200, 529].indexOf(params.status) == -1) logger.error('getInstanceMetaToken:', params.uri, params.status, params.data, err);
        if (params.status == 200) {
            if (params.data) aws.metaToken = params.data;
        } else {
            aws.metaRetries = lib.toNumber(aws.metaRetries) + 1;
            if (aws.metaRetries > 2) params.status = 0;
        }
        if (params.status == 200 || params.status >= 500) {
            var timeout = params.status == 200 ? 21000000 : 1000 * aws.metaRetries;
            clearTimeout(aws._metaTimer);
            aws._metaTimer = setTimeout(aws.getInstanceMetaToken.bind(aws), timeout);
        }
        if (typeof callback == "function") callback(err, params.status == 200 ? params.data : "");
    });
}

/**
 * Retrieve instance credentials using EC2 instance profile and setup for AWS access
 * @memberof module:aws
 * @method getInstanceCredentials
 */
aws.getInstanceCredentials = function(path, callback)
{
    if (typeof path == "function") callback = path, path = null;

    lib.series([
        function(next) {
            if (path || aws.iamProfile) return next();
            aws.getInstanceMeta("/latest/meta-data/iam/security-credentials/", (err, data) => {
                if (!err && data) aws.iamProfile = data;
                next(err);
            });
        },

        function(next) {
            if (!path) path = "/latest/meta-data/iam/security-credentials/" + aws.iamProfile;
            aws.getInstanceMeta(path, (err, data) => {
                if (!err && data) {
                    var obj = lib.jsonParse(data, { datatype: "obj", logger: "info" });
                    if (obj.AccessKeyId && obj.SecretAccessKey) {
                        aws.key = obj.AccessKeyId;
                        aws.secret = obj.SecretAccessKey;
                        aws.token = obj.Token;
                        aws.tokenExpiration = lib.toDate(obj.Expiration).getTime();
                        logger.debug("getInstanceCredentials:", app.role, aws.key, lib.strftime(aws.tokenExpiration), "interval:", lib.toDuration(aws.tokenExpiration - Date.now()));
                    }
                }
                // Refresh if not set or expire soon
                var timeout = Math.min(aws.tokenExpiration - Date.now(), 3600000);
                timeout = timeout < 300000 ? 30000 : timeout <= 30000 ? 1000 : timeout - 300000;
                clearTimeout(aws._credTimer);
                aws._credTimer = setTimeout(aws.getInstanceCredentials.bind(aws, path), timeout);
                next(err);
            });
        },
    ], callback, true);

}

/**
 * Retrieve instance launch index from the meta data if running on AWS instance
 * @memberof module:aws
 * @method getInstanceInfo
 */
aws.getInstanceInfo = function(options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    lib.series([
        function(next) {
            // ECS containers do not use instance metadata
            var uri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
            if (!uri) return next();

            app.instance.type = "aws";
            aws.metaHost = "169.254.170.2";
            aws.region = app.instance.region = process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION;

            aws.getInstanceCredentials(uri, (err) => {
                aws.getTaskDetails(() => {
                    logger.debug('getInstanceInfo:', aws.name, app.instance, err);
                    return typeof callback == "function" && callback();
                });
            });
        },

        function(next) {
            aws.getInstanceMetaToken(() => { next() });
        },

        function(next) {
            aws.getInstanceMeta("/latest/dynamic/instance-identity/document", (err, data) => {
                if (!err && data) {
                    data = lib.jsonParse(data, { datatype: "obj", logger: "error" });
                    app.instance.type = "aws";
                    app.instance.id = data.instanceId;
                    app.instance.image = data.imageId;
                    app.instance.instance_type = data.instanceType;
                    app.instance.region = data.region;
                    app.instance.zone = data.availabilityZone;
                    aws.accountId = data.accountId;
                    aws.zone = data.availabilityZone;
                    if (!aws.region) aws.region = data.region;
                }
                next(err);
            });
        },

        function(next) {
            if (aws.keyName) return next();
            aws.getInstanceMeta("/latest/meta-data/public-keys/", (err, data) => {
                if (!err && data) aws.keyName = data.substr(2);
                next();
            });
        },

        function(next) {
            // If access key is configured then skip profile meta
            if (aws.key) return next();
            aws.getInstanceCredentials(next);
        },

        function(next) {
            if (app.instance.tag) return next();
            aws.getInstanceMeta("/latest/meta-data/tags/instance/Name", (err, data) => {
                if (!err && data) app.instance.tag = data;
                next(err);
            });
        },

        function(next) {
            if (app.instance.tag || !aws.secret || !app.instance.id) return next();
            aws.getInstanceDetails(next);
        },

    ], (err) => {
        logger.debug('getInstanceInfo:', aws.name, app.instance, 'profile:', aws.iamProfile, 'expire:', aws.tokenExpiration, err);
        if (typeof callback == "function") callback();
    }, true);
}

/**
 * Get the current instance details if not retrieved already in `aws.instance`
 * @memberof module:aws
 * @method getInstanceDetails
 */
aws.getInstanceDetails = function(options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    if (aws.instance?.instanceId == app.instance.id) {
        return lib.tryCall(callback, null, aws.instance);
    }
    aws.ec2DescribeInstances({ instanceId: app.instance.id, retryCount: 3 }, (err, list) => {
        if (!err && list.length) {
            aws.instance = list[0];
            app.instance.tag = aws.instance.name;
        }
        lib.tryCall(callback, err, aws.instance);
    });
}

/**
 * If running inside ECS pulls the task details
 * @memberof module:aws
 * @method getTaskDetails
 */
aws.getTaskDetails = function(options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    var uri = process.env.ECS_CONTAINER_METADATA_URI_V4;
    if (!uri) return lib.tryCall(callback, null, aws.task);

    aws.getInstanceMeta(`${uri}/task`, { noparse: 0 }, (err, rc) => {
        if (!err && rc) {
            aws.task = rc;
            var arn = lib.split(rc.TaskARN, /[:/]/);
            aws.accountId = arn[4];
            app.instance.task = rc.Family;
            app.instance.task_id = arn.at(-1);
            app.instance.service = rc.ServiceName;
            app.instance.zone = aws.zone = rc.AvailabilityZone;

            for (const i in rc.Containers) {
                const c = rc.Containers[i];
                if (c.KnownStatus == "RUNNING") {
                    app.instance.container = c.DockerName;
                    app.instance.container_id = c.DockerId;
                    app.instance.container_image = lib.split(c.Image, "/").pop();
                    for (const j in c.Networks) {
                        if (c.Networks[j].NetworkMode == "awsvpc") {
                            app.instance.ip = String(c.Networks[j].IPv4Addresses);
                            app.instance.netdev = "eth" + c.Networks[j].AttachmentIndex;
                            break;
                        }
                    }
                    break;
                }
            }
        }
        lib.tryCall(callback, err, aws.task);
    });
}