UNPKG

ask-cli

Version:

Alexa Skills Kit (ASK) Command Line Interfaces

293 lines (280 loc) 14 kB
const R = require('ramda'); const path = require('path'); const SmapiClient = require('@src/clients/smapi-client'); const ResourcesConfig = require('@src/model/resources-config'); const Manifest = require('@src/model/manifest'); const jsonView = require('@src/view/json-view'); const MultiTasksView = require('@src/view/multi-tasks-view'); const Messenger = require('@src/view/messenger'); const retryUtils = require('@src/utils/retry-utility'); const hashUtils = require('@src/utils/hash-utils'); const stringUtils = require('@src/utils/string-utils'); const CONSTANTS = require('@src/utils/constants'); const DeployDelegate = require('./deploy-delegate'); module.exports = class SkillInfrastructureController { constructor(configuration) { const { profile, doDebug, ignoreHash } = configuration; this.profile = profile; this.doDebug = doDebug; this.ignoreHash = ignoreHash; } /** * Bootstrap a skill project with deploy delegate's custom logic, to initiate the project to be ready for deployment. * @param {String} workspacePath The path to the new skill's workspace * @param {Function} callback (error) */ bootstrapInfrastructures(workspacePath, callback) { const infraType = ResourcesConfig.getInstance().getSkillInfraType(this.profile); if (!stringUtils.isNonBlankString(infraType)) { return process.nextTick(() => { callback('[Error]: Please set the "type" field for your skill infrastructures.'); }); } // 1.Prepare the loading of deploy delegate DeployDelegate.load(infraType, (loadErr, deployDelegate) => { if (loadErr) { return callback(loadErr); } // 2.Call bootstrap method from deploy delegate const bootstrapOptions = { profile: this.profile, userConfig: ResourcesConfig.getInstance().getSkillInfraUserConfig(this.profile), workspacePath }; deployDelegate.bootstrap(bootstrapOptions, (bootsErr, bootstrapResult) => { if (bootsErr) { return callback(bootsErr); } const { userConfig } = bootstrapResult; ResourcesConfig.getInstance().setSkillInfraUserConfig(this.profile, userConfig); callback(); }); }); } /** * Entry method for skill infrastructure deployment based on the deploy delegate type. * @param {Function} callback */ deployInfrastructure(callback) { const infraType = ResourcesConfig.getInstance().getSkillInfraType(this.profile); // 1.Prepare the loading of deploy delegate DeployDelegate.load(infraType, (loadErr, deployDelegate) => { if (loadErr) { return callback(loadErr); } // 2.Trigger regional deployment using deploy delegate this.deployInfraToAllRegions(deployDelegate, (deployErr, deployResult) => { if (deployErr) { return callback(deployErr); } // 3.Post deploy skill manifest update this.updateSkillManifestWithDeployResult(deployResult, (postUpdateErr) => { if (postUpdateErr) { return callback(postUpdateErr); } callback(); }); }); }); } /** * Deploy skill infrastructure to all the Alexa regions. * @param {Object} dd DeployDelegate instance * @param {Function} callback (error, invokeResult) * invokeResult: { $region: { endpoint: { url }, lastDeployHash, deployState } } */ deployInfraToAllRegions(dd, callback) { const skillName = stringUtils.filterNonAlphanumeric(Manifest.getInstance().getSkillName()) || stringUtils.filterNonAlphanumeric(path.basename(process.cwd())); if (!stringUtils.isNonBlankString(skillName)) { return callback('[Error]: Failed to parse the skill name used to decide the CloudFormation stack name. ' + 'Please make sure your skill name or skill project folder basename contains alphanumeric characters.'); } const regionsList = ResourcesConfig.getInstance().getCodeRegions(this.profile); if (!regionsList || regionsList.length === 0) { return callback('[Warn]: Skip the infrastructure deployment, as the "code" field has not been set in the resources config file.'); } // 1.instantiate MultiTasksView const taskConfig = { concurrent: true, exitOnError: false }; const multiTasksView = new MultiTasksView(taskConfig); // 2.register each regional task into MultiTasksView regionsList.forEach((region) => { const taskTitle = `Deploy Alexa skill infrastructure for region "${region}"`; const taskHandle = (reporter, taskCallback) => { this._deployInfraByRegion(reporter, dd, region, skillName, taskCallback); }; multiTasksView.loadTask(taskHandle, taskTitle, region); }); // 3.start multi-tasks and validate task response multiTasksView.start((taskErr, taskResult) => { if (taskErr) { // update partial successful deploy results to resources config if (taskErr.partialResult && !R.isEmpty(R.keys(taskErr.partialResult))) { this._updateResourcesConfig(taskErr.partialResult); } return callback(taskErr.error); } // 4.validate response and update states based on the results try { dd.validateDeployDelegateResponse(taskResult); } catch (responseInvalidErr) { return callback(responseInvalidErr); } this._updateResourcesConfig(taskResult); callback(null, taskResult); }); } /** * Update the skill manifest based on the skill infrastructure deployment result. * @param {Object} rawDeployResult deploy result from invoke: { $region: { endpoint: { url }, lastDeployHash, deployState } } * @param {Function} callback (error) */ updateSkillManifestWithDeployResult(rawDeployResult, callback) { // 1.update local skill.json file: update the "uri" in all the existing "apis" for each region R.keys(Manifest.getInstance().getApis()).forEach((domain) => { R.keys(rawDeployResult).forEach((region) => { Manifest.getInstance().setApisEndpointByDomainRegion(domain, region, rawDeployResult[region].endpoint); }); }); Manifest.getInstance().write(); // 2.compare with current hash result to decide if skill.json file need to be updated // (the only possible change in skillMetaSrc during the infra deployment is the skill.json's uri change) hashUtils.getHash(ResourcesConfig.getInstance().getSkillMetaSrc(this.profile), (hashErr, currentHash) => { if (hashErr) { return callback(hashErr); } if (currentHash === ResourcesConfig.getInstance().getSkillMetaLastDeployHash(this.profile)) { return callback(); } // 3.update skill manifest this._ensureSkillManifestGotUpdated((manifestUpdateErr) => { if (manifestUpdateErr) { return callback(manifestUpdateErr); } ResourcesConfig.getInstance().setSkillMetaLastDeployHash(this.profile, currentHash); Messenger.getInstance().info(' The api endpoints of skill.json have been updated from the skill infrastructure deploy results.'); callback(); }); }); } /** * Deploy skill infrastructure by calling invoke function from deploy delegate. * @param {Object} reporter upstream CLI status reporter * @param {Object} dd injected deploy delegate instance * @param {String} alexaRegion * @param {String} skillName * @param {Function} callback (error, invokeResult) * callback.error can be a String or { message, context } Object which passes back the partial deploy result */ _deployInfraByRegion(reporter, dd, alexaRegion, skillName, callback) { const regionConfig = { profile: this.profile, ignoreHash: this.ignoreHash, alexaRegion, skillId: ResourcesConfig.getInstance().getSkillId(this.profile), skillName, code: { codeBuild: ResourcesConfig.getInstance().getCodeBuildByRegion(this.profile, alexaRegion).file, isCodeModified: null }, userConfig: ResourcesConfig.getInstance().getSkillInfraUserConfig(this.profile), deployState: ResourcesConfig.getInstance().getSkillInfraDeployState(this.profile) }; // 1.calculate the lastDeployHash for current code folder and compare with the one in record const lastDeployHash = ResourcesConfig.getInstance().getCodeLastDeployHashByRegion(this.profile, regionConfig.alexaRegion); hashUtils.getHash(regionConfig.code.codeBuild, (hashErr, currentHash) => { if (hashErr) { return callback(hashErr); } regionConfig.code.isCodeModified = currentHash !== lastDeployHash; // 2.trigger the invoke function from deploy delegate dd.invoke(reporter, regionConfig, (invokeErr, invokeResult) => { if (invokeErr) { return callback(invokeErr); } const { isAllStepSuccess, isCodeDeployed } = invokeResult; // track the current hash if isCodeDeployed if (isCodeDeployed) { invokeResult.lastDeployHash = currentHash; } // pass back result based on if isAllStepSuccess, pass result as error if not all steps succeed callback(isAllStepSuccess ? null : invokeResult, isAllStepSuccess ? invokeResult : undefined); }); }); } /** * Update the the ask resources config and the deploy state. * @param {Object} rawDeployResult deploy result from invoke: { $region: deploy-delegate's response } */ _updateResourcesConfig(rawDeployResult) { const newDeployState = {}; R.keys(rawDeployResult).forEach((alexaRegion) => { newDeployState[alexaRegion] = rawDeployResult[alexaRegion].deployState; ResourcesConfig.getInstance().setCodeLastDeployHashByRegion(this.profile, alexaRegion, rawDeployResult[alexaRegion].lastDeployHash); }); ResourcesConfig.getInstance().setSkillInfraDeployState(this.profile, newDeployState); ResourcesConfig.getInstance().write(); } /** * Make sure the skill manifest is updated successfully by submitting the request to SMAPI and keep polling until complete. * @param {Function} callback */ _ensureSkillManifestGotUpdated(callback) { const smapiClient = new SmapiClient({ profile: this.profile, doDebug: this.doDebug }); const skillId = ResourcesConfig.getInstance().getSkillId(this.profile); // update manifest smapiClient.skill.manifest.updateManifest(skillId, CONSTANTS.SKILL.STAGE.DEVELOPMENT, Manifest.getInstance().content, null, (updateErr, updateResponse) => { if (updateErr) { return callback(updateErr); } if (updateResponse.statusCode >= 300) { return callback(jsonView.toString(updateResponse.body)); } // poll manifest status until finish this._pollSkillStatus(smapiClient, skillId, (pollErr, pollResponse) => { if (pollErr) { return callback(pollErr); } const manifestStatus = R.view(R.lensPath(['body', 'manifest', 'lastUpdateRequest', 'status']), pollResponse); if (!manifestStatus) { return callback(`[Error]: Failed to extract the manifest result from SMAPI's response.\n${pollResponse}`); } if (manifestStatus !== CONSTANTS.SKILL.SKILL_STATUS.SUCCEEDED) { return callback(`[Error]: Updating skill manifest but received non-success message from SMAPI: ${manifestStatus}`); } callback(); }); }); } /** * Poll skill's manifest status until the status is not IN_PROGRESS. * @param {Object} smapiClient * @param {String} skillId * @param {Function} callback */ _pollSkillStatus(smapiClient, skillId, callback) { const retryConfig = { base: 1000, factor: 1.1, maxRetry: 50 }; const retryCall = (loopCallback) => { smapiClient.skill.getSkillStatus(skillId, [CONSTANTS.SKILL.RESOURCES.MANIFEST], (statusErr, statusResponse) => { if (statusErr) { return loopCallback(statusErr); } if (statusResponse.statusCode >= 300) { return loopCallback(jsonView.toString(statusResponse.body)); } loopCallback(null, statusResponse); }); }; const shouldRetryCondition = retryResponse => R.view(R.lensPath(['body', 'manifest', 'lastUpdateRequest', 'status']), retryResponse) === CONSTANTS.SKILL.SKILL_STATUS.IN_PROGRESS; retryUtils.retry(retryConfig, retryCall, shouldRetryCondition, (err, res) => callback(err, err ? null : res)); } };