lib/azure.js

/* global sails */

var fs = require('fs');
var path = require('path');
var url = require('url');
var FTPClient = require('ftp');
var c;
    
// Azure dependencies
var AuthenticationContext = require('adal-node').AuthenticationContext;
var common = require('azure-common');
var resourceManagement = require('azure-arm-resource');
var resourceManagementClient;
var webSiteManagement = require('azure-asm-website');
var webSiteManagementClient;
var tokenCreds;
var azurePublishConfig = {};
  
// FTP
var ftpHost;
var ftpUser;
var ftpPass;
var ftpFiles = [];
var ftpDirs = [];


/**
 * Azure site publishing
 * @module azure
 */
module.exports = {
         
  /**
   * Publish to Azure
   * 
   * @param {Object} publishConfig - Azure publishing configuration
   * @param {Function(Error)} done - callback function that takes an optional `Error` object
   */
  publish: function (publishConfig, done) {
    azurePublishConfig = {};

    // Combine hook deafults with publish config generated from successful build
    _.extend(azurePublishConfig, publishConfig, sails.config['federalist-ms'].azure);
    
    // Execute Azure deployment operation in series
    async.series([this._setToken, this._checkResources.bind(this), this._getPublishingCredentials, this._uploadContent.bind(this)], function onResult(err, results) {
      if (err) {
        return done(err);
      }
      done();
    });
  },
  
  /**
   * Retrieve authentication token for Azure
   * 
   * @param {Function(Error, results)} callback - callback function that takes an optional `Error` object and `results` parameter
   */
  _setToken: function (callback) {

    sails.log.verbose("Setting token...");

    var authorityUrl = azurePublishConfig.authorityUrl;
    var service = new AuthenticationContext(authorityUrl);
    var username = azurePublishConfig.username;
    var password = azurePublishConfig.password;
    var clientId = azurePublishConfig.clientId;
    var subscriptionId = azurePublishConfig.subscriptionId;

    if (!username || !password || !clientId || !subscriptionId) {
      return callback("Missing Azure configuration properties. Check to ensure appropriate environment variables have been set");
    }

    service.acquireTokenWithUsernamePassword('https://management.core.windows.net/', username, password, clientId, function onAcquisition(err, tokenResponse) {
      if (err) {
        return callback(err);
      }

      tokenCreds = new common.TokenCloudCredentials({
        subscriptionId: subscriptionId,
        token: tokenResponse.accessToken
      });

      resourceManagementClient = resourceManagement.createResourceManagementClient(tokenCreds);
      webSiteManagementClient = webSiteManagement.createWebSiteManagementClient(tokenCreds);

      sails.log.verbose("Token set");

      return callback(null, 'Token set');
    });
  },
  
  /**
   * Check existence of Azure resources in specified resource group/region
   *  
   * @param {Function(Error, results)} callback - callback function that takes an optional `Error` object and `results` parameter
   */
  _checkResources: function (callback) {
    var service = this;   
    var webSpace = azurePublishConfig.rgName + '-' + azurePublishConfig.region.replace(/ /g, '') + 'webspace';
    var siteName = azurePublishConfig.webAppName;
    
    sails.log.verbose("Determining whether or not Web App '" + siteName + "' already exists");

    function getWebSpace(cb) {
      webSiteManagementClient.webSpaces.get(webSpace, function onWebSpaceGet(err, result) {
        if (err) {
          return cb(err);
        }

        if (result) {
          sails.log.verbose("WebSpace '" + webSpace + "' already exists");
          cb(null, 'Web Space exists');
        }
      });
    }

    function getWebSite(cb) {
      webSiteManagementClient.webSites.get(webSpace, siteName, function onWebSiteGet(err, result) {
        if (err) {
          return cb(err);
        }

        if (result) {
          sails.log.verbose("Web App '" + siteName + "' already exists");
          cb(null, "Web App already exists");
        }
      });
    }

    async.series([getWebSpace, getWebSite], function (err, results) {
      if (err && err.code === 'NotFound') {
        sails.log.verbose("Web App '" + siteName + "' does not exist");
        service._deployResourceGroup(function onResourceGroupDeploy(rgErr, result) {
          if (rgErr) {
            return callback(rgErr);
          }

          callback(null, 'Resource Group deployed');
        });
      } else if (err) {
        return callback(err);
      } else {
        callback(null, 'Resources checked');
      }
    });

  },
  
  /**
   * Get Web App publishing credentials
   * 
   * @param {Function(Error, results)} callback - callback function that takes an optional `Error` object and `results` parameter
   */
  _getPublishingCredentials: function (callback) {
    var webSpace = azurePublishConfig.rgName + '-' + azurePublishConfig.region.replace(/ /g, '') + 'webspace';
    var siteName = azurePublishConfig.webAppName;

    sails.log.verbose("Getting Web App publishing credentials");
    webSiteManagementClient.webSites.getPublishProfile(webSpace, siteName, function onWebSiteGet(err, result) {
      if (err) {
        return callback(err);
      }

      for (var i = 0; i < result.publishProfiles.length; i++) {
        if (result.publishProfiles[i].publishMethod === 'FTP') {
          var profile = result.publishProfiles[i];
          var publishUrl = url.parse(profile.publishUrl);
          ftpHost = publishUrl.hostname;
          ftpUser = profile.userName;
          ftpPass = profile.userPassword;

          sails.log.verbose("FTPS Publish profile retrieved");

          return callback(null, 'FTPS Publish Profile retrieved');
        }
      }
    });
  },
  
  /**
   * Publish site content via FTPS
   * 
   * NOTE: May want to rework for Git publishing instead of FTPS
   * NOTE: Requires Node 0.10.x due to bug in TLS module as
   * described {@link https://github.com/joyent/node/issues/9272|here}
   * 
   * @param {Function(Error, results)} callback - callback function that takes an optional `Error` object and `results` parameter
   */
  _uploadContent: function (callback) {
    ftpDirs.length = 0;
    ftpFiles.length = 0;

    var service = this;
    c = new FTPClient();

    sails.log.verbose("Uploading site content via FTPS");
    
    c.on('ready', function onReady() {
      service._ftpWalk(azurePublishConfig.directory, function onWalk(err) {

        if (err) {
          return callback(err);
        }

        async.series([service._createFTPDirectories, service._uploadFTPFiles], function onResult(err, results) {
          c.end();
          sails.log.verbose("FTPS connection closed");

          if (err) {
            return callback(err);
          }
          callback(null, 'Files uploaded successfully');
        });
      });
    });

    c.connect({
      host: ftpHost,
      secure: true,
      user: ftpUser || process.env.FEDERALIST_AZURE_WEBAPP_DEPLOYMENT_USER,
      password: ftpPass || process.env.FEDERALIST_AZURE_WEBAPP_DEPLOYMENT_PASSWORD
    });
  },
  
  /**
   * Create new Resource Group and execute template deployment
   * 
   * @param {Function(Error)} done - callback function that takes an optional `Error` object
   */
  _deployResourceGroup: function (done) {
    var service = this;

    service._checkRGExistence(function onCheckResourceGroupExistence(err, exists, result) {
      if (err) {
        return done(err);
      }
      
      if (exists) {
        async.series([service._deployTemplate, service._checkDeploymentStatus], function onResult(err, results) {
          if (err) {
            return done(err);
          }
          done(null, results);
        });
      } else {
        async.series([service._createResourceGroup, service._deployTemplate, service._checkDeploymentStatus], function onResult(err, results) {
          if (err) {
            return done(err);
          }
          done(null, results);
        });
      }
    });
  },
  
  /**
   * Check existence of Resource Group
   * 
   * @param {Function(Error)} done - callback function that takes an optional `Error` object
   */
  _checkRGExistence: function (done) {
    var rgName = azurePublishConfig.rgName;
    
    sails.log.verbose("Checking existence of Resource Group '" + rgName + "'");

    resourceManagementClient.resourceGroups.checkExistence(rgName, function onCheckResourceGroupExistence(err, result) {
      // Bug in checkExistence() function that returns an error
      // instead of result.exists = false
      if (err && err.statusCode === 404 && err.code === 'NotFound') {
        sails.log.verbose("Resource Group '" + rgName + "' does not exist");
        return done(null, false, result);
      } else if (err) {
        return done(err);
      } else {
        sails.log.verbose("Resource Group '" + rgName + "' exists");
        done(null, true, result);
      }
    });
  },
  
  /**
   * Create new Resource Group
   * 
   * @param {Function(Error, results)} callback - callback function that takes an optional `Error` object and `results` parameter
   */
  _createResourceGroup: function (callback) {

    var rgName = azurePublishConfig.rgName;
    var params = {
      location: azurePublishConfig.region
    };

    sails.log.verbose("Creating Resource Group '" + rgName + "'");

    resourceManagementClient.resourceGroups.createOrUpdate(rgName, params, function onCreateResourceGroup(err, result) {
      if (err) {
        return callback(err);
      }

      sails.log.verbose("Resource Group '" + rgName + "' created successfully");
      callback(null, result);
    });
  },
  
  /**
   * Deploy generated template to Resource Group
   * 
   * @param {Function(Error, results)} callback - callback function that takes an optional `Error` object and `results` parameter
   */
  _deployTemplate: function (callback) {
  
    // Will replace with hosted template
    var templatePath = azurePublishConfig.rgTemplatePath
    var template;
    var rgName = azurePublishConfig.rgName;
    var deploymentName = azurePublishConfig.rgDeploymentName;

    try {
      template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
    } catch (err) {
      return callback(err);
    }

    // May decide to dynamically populate these parameters based on template JSON
    var params = {
      "properties": {
        "template": template,
        "mode": "Incremental",
        "parameters": {
          "siteName": {
            "value": azurePublishConfig.webAppName
          },
          "hostingPlanName": {
            "value": azurePublishConfig.appHostingPlanName
          },
          "siteLocation": {
            "value": azurePublishConfig.region
          }
        }
      }
    };

    sails.log.verbose("Deploying Template to Resource Group '" + rgName + "'");
    resourceManagementClient.deployments.createOrUpdate(rgName, deploymentName, params, function onResourceGroupDeployment(err, result) {
      if (err) {
        return callback(err);
      }

      sails.log.verbose("Resource Group Template deployment initiated");
      callback(null, result);
    });
  },
  
  /**
   * Check deployment status
   * 
   * @param {Function(Error, results)} callback - callback function that takes an optional `Error` object and `results` parameter
   */
  _checkDeploymentStatus: function (callback) {

    var rgName = azurePublishConfig.rgName;
    var deploymentName = azurePublishConfig.rgDeploymentName;

    sails.log.verbose("Getting status for template deployment '" + deploymentName + "' to Resource Group '" + rgName + "'");

    function checkStatus() {
      var statusInterval = setInterval(function onInterval() {
        resourceManagementClient.deployments.get(rgName, deploymentName, function onDeploymentGet(err, result) {
          if (err) {
            callback(err);
            clearInterval(statusInterval);
          }

          if (result && result.deployment.properties.provisioningState === 'Succeeded') {
            sails.log.verbose("Template deployment succeeded");

            callback(null, 'Template deployment succeeded');
            clearInterval(statusInterval);
          } else if (result && result.deployment.properties.provisioningState === 'Failed') {
            sails.log.verbose("Template deployment failed");
            callback('Template deployment failed');
            clearInterval(statusInterval);
          }
        });

        sails.log.verbose('Template deployment incomplete...waiting 10 seconds for status update');
      }, 10000);
    }

    checkStatus();
  },
  
  /**
   * Walk static site directory structure and copy paths
   * to global arrays
   * 
   * NOTE: may need to adjust path references
   * 
   * @param {string} dir - directory path
   * @param {Function(Error)} done - callback function that takes an optional `Error` object
   */
  _ftpWalk: function (dir, done) {

    var service = this;

    fs.readdir(dir, function onDirRead(err, list) {
      var pending = list.length;

      if (err) {
        return done(err);
      }
      if (!pending) {
        return done();
      }
      list.forEach(function onFile(file) {
        file = path.join(dir, file);
        fs.stat(file, function onStat(err, stat) {
          if (stat && stat.isDirectory()) {
            ftpDirs.push(file);
            service._ftpWalk(file, function onWalk(err, res) {
              if (!--pending) {
                return done();
              }
            });
          } else {
            ftpFiles.push(file);
            if (!--pending) {
              done();
            }
          }
        });
      });
    });
  },
  
  /**
   * Create FTP directories
   * 
   * @param {Function(Error, results)} callback - callback function that takes an optional `Error` object and `results` parameter
   */
  _createFTPDirectories: function (callback) {
    async.each(ftpDirs, createDir, function onResult(err) {
      if (err) {
        return callback(err);
      }

      callback();
    });
    
    /**
     * Create directory
     * 
     * @param {string} dir - directory path
     * @param {Function(Error)} callback - callback function that takes an optional `Error` object
     */
    function createDir(dir, callback) {

      var ftpDir = path.join('/site/wwwroot/', path.relative(azurePublishConfig.directory, dir));
      ftpDir = ftpDir.replace(/\\/g, "/");

      c.mkdir(ftpDir, true, function onFtpMkDir(err) {
        if (err) {
          return callback(err);
        }

        sails.log.verbose("FTP directory '" + ftpDir + "' created");

        return callback();
      });
    }
  },
  
  /**
   * Upload files via FTP
   * 
   * @param {Function(Error)} callback - callback function that takes an optional `Error` object
   */
  _uploadFTPFiles: function (callback) {

    async.each(ftpFiles, uploadFile, function onResult(err) {
      if (err) {
        return callback(err);
      }
      callback();
    });
    
    /**
     * Upload file
     * 
     * @param {string} file - file path
     * @param {Function(Error)} callback - callback function that takes an optional `Error` object
     */
    function uploadFile(file, callback) {
  
      // Relative/absolute path normalization for Azure
      var localFile = path.join(process.cwd(), file);
      var ftpFile = path.join('/site/wwwroot/', path.relative(azurePublishConfig.directory, file));
      ftpFile = ftpFile.replace(/\\/g, "/");

      c.put(localFile, ftpFile, function onFtpPutFile(err) {
        if (err) {
          return callback(err);
        }

        sails.log.verbose("File '" + localFile + "' uploaded successfully to '" + ftpFile + "'");
        callback();
      });
    }
  },
  
  /**
   * Remove Resource Group and cleanup attempted
   * deployment operation
   * 
   * NOTE: Cleanup stubs for Resource Group provisioning failure
   * 
   * @param {Function(Error)} done - callback function that takes an optional `Error` object
   */
  cleanup: function (done) {
  
    // Delete Resource Group
    var rgName = azurePublishConfig.rgName;

    resourceManagementClient.resourceGroups.deleteMethod(rgName, function onResourceGroupDelete(err, result) {
      if (err) {
        return done(err);
      }

      sails.log.verbose("Resource Group '" + rgName + "' successfully purged");
      done(null, result);
    });
  }

};