@adobe/helix-cli
Version:
Project Helix CLI
456 lines (390 loc) • 13.1 kB
JavaScript
/*
* Copyright 2018 Adobe. All rights reserved.
* This file is licensed 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 REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
'use strict';
const request = require('request-promise-native');
const chalk = require('chalk');
const ow = require('openwhisk');
const glob = require('glob');
const path = require('path');
const fs = require('fs-extra');
const uuidv4 = require('uuid/v4');
const ProgressBar = require('progress');
const { GitUrl, GitUtils } = require('@adobe/helix-shared');
const useragent = require('./user-agent-util');
const AbstractCommand = require('./abstract.cmd.js');
const PackageCommand = require('./package.cmd.js');
function humanFileSize(size) {
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
const p2 = 1024 ** i;
return `${(size / p2).toFixed(2)} ${['B', 'KiB', 'MiB', 'GiB', 'TiB'][i]}`;
}
class DeployCommand extends AbstractCommand {
constructor(logger) {
super(logger);
this._enableAuto = true;
this._circleciAuth = null;
this._wsk_auth = null;
this._wsk_namespace = null;
this._wsk_host = null;
this._loggly_host = null;
this._loggly_auth = null;
this._fastly_namespace = null;
this._fastly_auth = null;
this._target = null;
this._docker = null;
this._prefix = null;
this._default = null;
this._enableDirty = false;
this._dryRun = false;
this._createPackages = 'auto';
this._addStrain = null;
}
get requireConfigFile() {
return this._addStrain === null;
}
withEnableAuto(value) {
this._enableAuto = value;
return this;
}
withCircleciAuth(value) {
this._circleciAuth = value;
return this;
}
withFastlyAuth(value) {
this._fastly_auth = value;
return this;
}
withFastlyNamespace(value) {
this._fastly_namespace = value;
return this;
}
withWskHost(value) {
this._wsk_host = value;
return this;
}
withWskAuth(value) {
this._wsk_auth = value;
return this;
}
withWskNamespace(value) {
this._wsk_namespace = value;
return this;
}
withLogglyHost(value) {
this._loggly_host = value;
return this;
}
withLogglyAuth(value) {
this._loggly_auth = value;
return this;
}
withTarget(value) {
this._target = value;
return this;
}
withDocker(value) {
this._docker = value;
return this;
}
withDefault(value) {
this._default = value;
return this;
}
withEnableDirty(value) {
this._enableDirty = value;
return this;
}
withDryRun(value) {
this._dryRun = value;
return this;
}
withCreatePackages(value) {
this._createPackages = value;
return this;
}
withAddStrain(value) {
this._addStrain = value === undefined ? null : value;
return this;
}
actionName(script) {
if (script.main.indexOf(path.resolve(__dirname, 'openwhisk')) === 0) {
return `hlx--${script.name}`;
}
return `${this._prefix}/${script.name}`;
}
async init() {
await super.init();
this._target = path.resolve(this.directory, this._target);
}
static getBuildVarOptions(name, value, auth, owner, repo) {
const body = JSON.stringify({
name,
value,
});
const options = {
method: 'POST',
auth,
uri: `https://circleci.com/api/v1.1/project/github/${owner}/${repo}/envvar`,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': useragent,
},
body,
};
return options;
}
static setBuildVar(name, value, owner, repo, auth) {
const options = DeployCommand.getBuildVarOptions(name, value, auth, owner, repo);
return request(options);
}
async autoDeploy() {
if (!(fs.existsSync(path.resolve(process.cwd(), '.circleci', 'config.yaml')) || fs.existsSync(path.resolve(process.cwd(), '.circleci', 'config.yml')))) {
throw new Error(`Cannot automate deployment without ${path.resolve(process.cwd(), '.circleci', 'config.yaml')}`);
}
const { owner, repo, ref } = GitUtils.getOriginURL();
const auth = {
username: this._circleciAuth,
password: '',
};
const followoptions = {
method: 'POST',
json: true,
auth,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': useragent,
},
uri: `https://circleci.com/api/v1.1/project/github/${owner}/${repo}/follow`,
};
this.log.info(`Automating deployment with ${followoptions.uri}`);
const follow = await request(followoptions);
const envars = [];
if (this._fastly_namespace) {
envars.push(DeployCommand.setBuildVar('HLX_FASTLY_NAMESPACE', this._fastly_namespace, owner, repo, auth));
}
if (this._fastly_auth) {
envars.push(DeployCommand.setBuildVar('HLX_FASTLY_AUTH', this._fastly_auth, owner, repo, auth));
}
if (this._wsk_auth) {
envars.push(DeployCommand.setBuildVar('HLX_WSK_AUTH', this._wsk_auth, owner, repo, auth));
}
if (this._wsk_host) {
envars.push(DeployCommand.setBuildVar('HLX_WSK_HOST', this._wsk_host, owner, repo, auth));
}
if (this._wsk_namespace) {
envars.push(DeployCommand.setBuildVar('HLX_WSK_NAMESPACE', this._wsk_namespace, owner, repo, auth));
}
if (this._loggly_auth) {
envars.push(DeployCommand.setBuildVar('HLX_LOGGLY_AUTH', this._wsk_auth, owner, repo, auth));
}
if (this._loggly_host) {
envars.push(DeployCommand.setBuildVar('HLX_LOGGLY_HOST', this._loggly_host, owner, repo, auth));
}
await Promise.all(envars);
if (follow.first_build) {
this.log.info('\nAuto-deployment started.');
this.log.info('Configuration finished. Go to');
this.log.info(`${chalk.grey(`https://circleci.com/gh/${owner}/${repo}/edit`)} for build settings or`);
this.log.info(`${chalk.grey(`https://circleci.com/gh/${owner}/${repo}`)} for build status.`);
} else {
this.log.warn('\nAuto-deployment already set up. Triggering a new build.');
const triggeroptions = {
method: 'POST',
json: true,
auth,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': useragent,
},
uri: `https://circleci.com/api/v1.1/project/github/${owner}/${repo}/tree/${ref}`,
};
const triggered = await request(triggeroptions);
this.log.info(`Go to ${chalk.grey(`${triggered.build_url}`)} for build status.`);
}
}
async run() {
await this.init();
const origin = GitUtils.getOrigin(this.directory);
if (!origin) {
throw Error('hlx cannot deploy without a remote git repository.');
}
const dirty = GitUtils.isDirty(this.directory);
if (dirty && !this._enableDirty) {
throw Error('hlx will not deploy a working copy that has uncommitted changes. Re-run with flag --dirty to force.');
}
if (this._enableAuto) {
return this.autoDeploy();
}
// get git coordinates and list affected strains
const ref = GitUtils.getBranch(this.directory);
const giturl = new GitUrl(`${origin}#${ref}`);
const affected = this.config.strains.filterByCode(giturl);
if (affected.length === 0) {
let newStrain = this._addStrain ? this.config.strains.get(this._addStrain) : null;
if (!newStrain) {
newStrain = this.config.strains.get('default');
newStrain = newStrain.clone();
newStrain.name = this._addStrain || uuidv4();
this.config.strains.add(newStrain);
}
newStrain.code = giturl;
// also tweak content and static url, if default is still local
if (newStrain.content.isLocal) {
newStrain.content = giturl;
}
if (newStrain.static.url.isLocal) {
newStrain.static.url = giturl;
}
if (this._addStrain === null) {
this.log.error(chalk`Remote repository {cyan ${giturl}} does not affect any strains.
Add a strain definition to your config file:
{grey ${newStrain.toYAML()}}
Alternatively you can auto-add one using the {grey --add <name>} option.`);
throw Error();
}
affected.push(newStrain);
this.config.modified = true;
this.log.info(chalk`Updated strain {cyan ${newStrain.name}} in helix-config.yaml`);
}
this._prefix = GitUtils.getCurrentRevision(this.directory);
if (dirty) {
this._prefix += '-dirty';
}
const owoptions = {
apihost: this._wsk_host,
api_key: this._wsk_auth,
namespace: this._wsk_namespace,
};
const openwhisk = ow(owoptions);
if (this._createPackages !== 'ignore') {
const pgkCommand = new PackageCommand(this.log)
.withTarget(this._target)
.withDirectory(this.directory)
.withOnlyModified(this._createPackages === 'auto');
await pgkCommand.run();
}
// get the list of scripts from the info files
const infos = [...glob.sync(`${this._target}/*.info.json`)];
const scriptInfos = await Promise.all(infos.map(info => fs.readJSON(info)));
const scripts = scriptInfos.filter(script => script.zipFile);
// generate action names
scripts.forEach((script) => {
// eslint-disable-next-line no-param-reassign
script.actionName = this.actionName(script);
});
const bar = new ProgressBar('[:bar] :action :etas', {
total: scripts.length * 2,
width: 50,
renderThrottle: 1,
stream: process.stdout,
});
const tick = (message, name) => {
bar.tick({
action: name ? `deploying ${name}` : '',
});
if (message) {
this.log.log({
progress: true,
level: 'info',
message,
});
}
};
const params = {
...this._default,
LOGGLY_HOST: this._loggly_host,
LOGGLY_KEY: this._loggly_auth,
};
// read files ...
const read = scripts
.filter(script => script.zipFile) // skip empty zip files
.map(script => fs.readFile(script.zipFile)
.then(action => ({ script, action })));
// create openwhisk package
if (!this._dryRun) {
const parameters = Object.keys(params).map((key) => {
const value = params[key];
return { key, value };
});
await openwhisk.packages.update({
name: this._prefix,
package: {
publish: true,
parameters,
annotations: [
{
key: 'hlx-code-origin',
value: giturl.toString(),
},
],
},
});
}
// ... and deploy
const deployed = read.map(p => p.then(({ script, action }) => {
const actionoptions = {
name: script.actionName,
'User-Agent': useragent,
action,
kind: 'nodejs:10-fat',
annotations: { 'web-export': true },
};
if (this._docker) {
this.log.warn(`Using docker image ${this._docker} instead of default nodejs:10-fat container.`);
delete actionoptions.kind;
actionoptions.exec = {
image: this._docker,
main: 'module.exports.main',
};
}
const baseName = path.basename(script.main);
tick(`deploying ${baseName}`, baseName);
if (this._dryRun) {
tick(` deployed ${baseName} (skipped)`);
return true;
}
return openwhisk.actions.update(actionoptions).then(() => {
tick(` deployed ${baseName} (deployed)`);
return true;
}).catch((e) => {
this.log.error(`❌ Unable to deploy the action ${script.name}: ${e.message}`);
tick();
return false;
});
}));
await Promise.all(deployed);
bar.terminate();
this.log.info(`✅ deployment of ${scripts.length} actions completed:`);
scripts.forEach((script) => {
this.log.info(` - ${this._wsk_namespace}/${script.actionName} (${humanFileSize(script.archiveSize)})`);
});
// update package in affected strains
this.log.info(`Affected strains of ${giturl}:`);
affected.forEach((strain) => {
this.log.info(`- ${strain.name}`);
if (strain.package !== this._prefix) {
this.config.modified = true;
// eslint-disable-next-line no-param-reassign
strain.package = this._prefix;
}
});
if (!this._dryRun && this.config.modified) {
this.config.saveConfig();
this.log.info(`Updated ${path.relative(this.directory, this.config.configPath)}`);
}
return this;
}
}
module.exports = DeployCommand;