UNPKG

@adobe/helix-cli

Version:
801 lines (718 loc) 24.4 kB
/* * 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. */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ const fs = require('fs-extra'); const request = require('request-promise-native'); const chalk = require('chalk'); const path = require('path'); const glob = require('glob-to-regexp'); const { toBase64 } = require('request/lib/helpers'); const ProgressBar = require('progress'); const { GitUtils } = require('@adobe/helix-shared'); const strainconfig = require('./strain-config-utils'); const include = require('./include-util'); const useragent = require('./user-agent-util'); const cli = require('./cli-util'); const AbstractCommand = require('./abstract.cmd.js'); const { conditions, resolve, reset } = require('./fastly/vcl-utils'); const HELIX_VCL_DEFAULT_FILE = path.resolve(__dirname, '../layouts/fastly/helix.vcl'); class PublishCommand extends AbstractCommand { constructor(logger) { super(logger); this._wsk_auth = null; this._wsk_namespace = null; this._wsk_host = null; this._fastly_namespace = null; this._fastly_auth = null; this._dryRun = false; this._strains = []; this._vclFile = null; this._service = null; this._options = { headers: { 'User-Agent': useragent, Accept: 'application/json', }, json: true, }; this._version = null; this._dictionaries = { secrets: null, strain_action_roots: null, strain_owners: null, strain_refs: null, strain_repos: null, strain_root_paths: null, strain_github_static_repos: null, strain_github_static_owners: null, strain_github_static_refs: null, strain_github_static_magic: null, strain_github_static_root: null, strain_index_files: null, strain_allow: null, strain_deny: null, }; this._backends = { GitHub: { hostname: 'raw.githubusercontent.com', error_threshold: 0, first_byte_timeout: 15000, weight: 100, address: 'raw.githubusercontent.com', connect_timeout: 1000, name: 'GitHub', port: 443, between_bytes_timeout: 10000, shield: 'iad-va-us', ssl_cert_hostname: 'githubusercontent.com', max_conn: 200, use_ssl: true, }, AdobeRuntime: { hostname: 'adobeioruntime.net', error_threshold: 0, first_byte_timeout: 60000, weight: 100, address: 'adobeioruntime.net', connect_timeout: 1000, name: 'AdobeRuntime', port: 443, between_bytes_timeout: 10000, shield: 'iad-va-us', ssl_cert_hostname: 'adobeioruntime.net', max_conn: 200, use_ssl: true, }, }; } tick(ticks = 1, message, name) { if (name === true) { // eslint-disable-next-line no-param-reassign name = message; } this.progressBar().tick(ticks, { action: name || '', }); if (message) { this.log.log({ progress: true, level: 'info', message, }); } } progressBar() { if (this._bar) { return this._bar; } // number of backends const backends = Object.keys(this._backends).length; // number of dictionaries const dictionaries = Object.keys(this._dictionaries).length; // number of non-strain-specific dictionaries const staticdictionaries = 1; const strains = this._strainsToPublish.length; const vclfiles = 4; const extrarequests = 4; const ticks = backends + dictionaries + (strains * (dictionaries - staticdictionaries)) + vclfiles + extrarequests; this._bar = new ProgressBar('Publishing [:bar] :action :etas', { total: ticks, width: 50, renderThrottle: 1, stream: process.stdout, }); return this._bar; } withWskHost(value) { this._wsk_host = value; return this; } withWskAuth(value) { this._wsk_auth = value; return this; } withWskNamespace(value) { this._wsk_namespace = value; return this; } withFastlyNamespace(value) { this._fastly_namespace = value; return this; } withFastlyAuth(value) { this._fastly_auth = value; this._options.headers['Fastly-Key'] = value; return this; } withDryRun(value) { this._dryRun = value; return this; } withVclFile(value) { this._vclFile = value; return this; } /** * Prepares a request to the Fastly API for the current service, using a given path extension * @param {String} pathext the path extension */ options(pathext) { return Object.assign({ uri: `https://api.fastly.com/service/${this._fastly_namespace}${pathext}` }, this._options); } /** * Prepares a request to the Fastly APU for the current version of the current service, * using a given path extension * @param {String} pathext the path extension */ async version(pathext) { const ver = await this.getCurrentVersion(); return this.options(`/version/${ver}${pathext}`); } /** * Prepares a PUT request to the Fastly API, setting a given value for a given path extension */ putOpts(pathext, value) { const ver = this.options(pathext); return Object.assign({ method: 'PUT', form: { item_value: value, }, }, ver); } /** * Prepares a DELETE request to the Fastly API, removing a given value for a given path extension */ deleteOpts(pathext) { const ver = this.options(pathext); return Object.assign({ method: 'DELETE', }, ver); } /** * Prepares a GET request to the Fastly API, removing a given value for a given path extension */ getOpts(pathext) { const ver = this.options(pathext); return Object.assign({ method: 'GET', }, ver); } /** * Prepares a PUT request to the Fastly API at the latest service version */ async putVersionOpts(pathext) { const ver = await this.version(pathext); return Object.assign({ method: 'PUT' }, ver); } /** * Pulls the service configuration from the Fastly API * @param {boolean} refresh */ async getService(refresh) { if (!this._service || refresh) { try { this._service = await request(this.options('')); } catch (e) { this.log.error('Unable to get service', e); throw e; } } return this._service; } /** * Determines the latest version of the service */ async getCurrentVersion() { if (this._version) { return this._version; } const service = await this.getService(); return [...service.versions].pop().number; } /** * Refreshes the map of edge dictionaries defined in the service */ async getDictionaries() { // if there are undefined dictionaries, we have to reload them if (Object.values(this._dictionaries).some(e => e == null)) { const opts = await this.version('/dictionary'); const dicts = await request(opts); Object.values(dicts).map((dict) => { if (!dict.deleted_at) { this._dictionaries[dict.name] = dict.id; } return dict.id; }); } return this._dictionaries; } /** * Refreshes the map of backends defined in the service */ async getBackends() { // if there are backends without an created_at record, they haven't // been fetched from the service yet if (Object.values(this._backends).some(e => e.created_at == null)) { const opts = await this.version('/backend'); const backs = await request(opts); Object.values(backs).map((back) => { if (!back.deleted_at) { this._backends[back.name] = back; } return back.name; }); } return this._backends; } /** * Creates edge dictionaries in the service config */ async initDictionaries() { const dictionaries = await this.getDictionaries(); const missingdicts = Object.entries(dictionaries) .filter(([_key, value]) => value === null) .map(([key, _value]) => key); const existing = Object.entries(dictionaries).length - missingdicts.length; this.tick(existing, `Skipping ${existing} existing dictionaries`); if (missingdicts.length > 0) { const baseopts = await this.version('/dictionary'); const fixdicts = missingdicts.map((dict) => { const opts = Object.assign({ method: 'POST', form: { name: dict, write_only: true, }, }, baseopts); return request(opts).then((r) => { this.tick(1, `Dictionary ${dict} created`, dict); this._dictionaries[r.name] = r.id; return r; }) .catch((e) => { const message = `Dictionary ${dict} could not be created`; this.log.error(message, e); throw new Error(message, e); }); }); // wait for all dictionaries to be created await Promise.all(fixdicts); await this.getDictionaries(); } } /** * Creates backends in the service config */ async initBackends() { const backends = await this.getBackends(); const missingbackends = Object.entries(backends) .filter(([_key, value]) => value.created_at === undefined) .map(([_key, value]) => value); const existing = Object.entries(backends).length - missingbackends.length; this.tick(existing, `Skipping ${existing} existing backends`); if (missingbackends.length > 0) { const baseopts = await this.version('/backend'); return Promise.all(missingbackends.map(async (backend) => { const opts = Object.assign({ method: 'POST', form: backend, }, baseopts); try { this.tick(0, `Creating backend ${backend.name}`, true); const r = await request(opts); this.tick(1, `Created backend ${backend.name}`, true); return r; } catch (e) { const message = `Backend ${backend.name} could not be created`; this.log.error(`${message}`, e); throw new Error(message, e); } })); } return Promise.resolve(); } /** * Clones an existing configuration version. Returns a promise. */ async cloneVersion() { const cloneOpts = await this.putVersionOpts('/clone'); this.tick(0, 'Cloning Service Config version', 'cloning version'); return request(cloneOpts).then((r) => { this._version = r.number; this.tick(1, `Cloned Service Config Version ${r.number}`, `cloning version ${r.number}`); return Promise.resolve(this); }) .catch((e) => { const message = 'Unable to create new service version'; this.log.error(message, e); throw new Error(message, e); }); } /** * Publishes the latest (cloned) service version. Returns a promise. */ async publishVersion() { const actOpts = await this.putVersionOpts('/activate'); this.tick(0, 'Activating version', 'activating version'); return request(actOpts).then((r) => { this.tick(1, `Activated version ${r.number}`, `activated version ${r.number}`); this._version = r.number; return Promise.resolve(this); }) .catch((e) => { const message = `Unable to activate new configuration: ${ e.error && e.error.msg ? e.error.msg : ''} ${ e.error && e.error.detail ? e.error.detail : ''}`; throw new Error(message, e); }); } /** * Sets a value in a named edge dictionary * @param {String} dict dictionary name * @param {String} key * @param {String} value */ async putDict(dict, key, value) { this.log.silly(`update dict ${dict} ${key}=${value}`); await this.getDictionaries(); const mydict = this._dictionaries[dict]; if (!mydict) { this.log.error(`Dictionary ${dict} does not exist. Try ${Object.keys(this._dictionaries).join(', ')}`); return null; } if (value) { const opts = await this.putOpts(`/dictionary/${mydict}/item/${key}`, value); return request(opts); } try { const opts = await this.deleteOpts(`/dictionary/${mydict}/item/${key}`); await request(opts); } catch (e) { this.log.error(`Unknown error when deleting key ${key} from dictionary ${mydict}`, e); } return Promise.resolve(); } /** * Creates a condition expression in VCL language that maps requests to strains. * @param {Strain} strain the strain to generate a condition expression for */ static vclConditions(strain) { return conditions(strain); } /** * Generates VCL for strain resolution from a list of strains */ static getStrainResolutionVCL(strains) { return resolve(strains); } /** * Turns a list of parameter names into a regular expression string. * @param {Array<String>} params a list of parameter names */ static makeFilter(params) { return `^(${[...params, 'hlx_.*'].join('|')})$`; } static makeParamWhitelist(params, indent = '') { return `set req.http.X-Old-Url = req.url; set req.url = querystring.regfilter_except(req.url, "${PublishCommand.makeFilter(params)}"); set req.http.X-Encoded-Params = urlencode(req.url.qs); set req.url = req.http.X-Old-Url;` .split('\n') .map(line => indent + line) .join('\n'); } /** * Generates VCL for strain resolution from a list of strains */ static getStrainParametersVCL(strains) { let retvcl = ''; let defvcl = '# This file handles the URL parameter whitelist\n\n'; strains.forEach(({ name, params }) => { if (params.length === 0) { return; } if (name === 'default') { defvcl += '# default parameters, can be overridden per strain\n'; defvcl += PublishCommand.makeParamWhitelist(params); defvcl += '\n'; } else { retvcl += ` if (req.http.X-Strain == "${name}") { ${PublishCommand.makeParamWhitelist(params, ' ')} }\n`; } }); return defvcl + retvcl; } static getXVersionExtensionVCL(configVersion, cliVersion, revision) { let retvcl = '# This section handles the strain resolution\n'; const version = `; src=${configVersion}; cli=${cliVersion}; rev=${revision}`; retvcl += `set req.http.X-Version = req.http.X-Version + "${version}";\n`; return retvcl; } async setStrainsVCL() { const vcl = PublishCommand.getStrainResolutionVCL(this._strains); return this.transferVCL(vcl, 'strains.vcl'); } async setParametersVCL() { const vcl = PublishCommand.getStrainParametersVCL(this._strains); return this.transferVCL(vcl, 'params.vcl'); } async getVersionVCLSection() { const configVersion = await this.getCurrentVersion(); const cliVersion = cli.getVersion(); const revision = GitUtils.getCurrentRevision(); return PublishCommand.getXVersionExtensionVCL(configVersion, cliVersion, revision); } async setDynamicVCL() { const vcl = await this.getVersionVCLSection(); return this.transferVCL(vcl, 'dynamic.vcl'); } async setResetVCL() { const vcl = reset(this._backends); return this.transferVCL(vcl, 'reset.vcl'); } async vclopts(name, vcl) { const baseopts = await this.version(`/vcl/${name}`); const postopts = await this.version('/vcl'); // making a get request to probe if the VCL already exists return request.get(baseopts).then(() => // eslint-disable-next-line implicit-arrow-linebreak Object.assign({ method: 'PUT', form: { name, content: vcl, }, }, baseopts)).catch((e) => { if (e.response && e.response.statusCode === 404) { // create new return Object.assign({ method: 'POST', form: { name, content: vcl, }, }, postopts); } return e; }); } /** * Creates or updates a VCL file in the service with the given VCL code * @param {String} vcl code * @param {String} name name of the file * @param {boolean} isMain this the main VCL */ async transferVCL(vcl, name, isMain = false) { const opts = await this.vclopts(name, vcl); return request(opts) .then(async (r) => { this.tick(1, `Uploading VCL ${name}`, true); if (isMain) { const mainbaseopts = await this.version(`/vcl/${name}/main`); const mainopts = Object.assign({ method: 'PUT' }, mainbaseopts); return request(mainopts).then(() => { this.tick(1, `Uploaded VCL ${name}`, true); }); } return r; }) .catch((e) => { const message = `Unable to update VCL ${name}`; this.log.error(message, e); throw new Error(message, e); }); } /** * Purges the entire Fastly cache for the given service version. */ async purgeAll() { const baseopts = this.options('/purge_all'); const opts = Object.assign({ method: 'POST', }, baseopts); return request(opts).then((r) => { this.tick(1, 'Purging entire cache'); return r; }) .catch((e) => { const message = 'Cache could not be purged'; this.log.error(message, e); throw new Error(message, e); }); } async initFastly() { this._backends = strainconfig.addbackends(this._strains, this._backends); return this.initBackends(); } async setHelixVCL() { try { const content = include(this._vclFile); return this.transferVCL(content, 'helix.vcl', true); } catch (e) { this.log.error(`❌ Unable to set ${this._vclFile}`); throw e; } } showNextStep() { const strains = this._strains; const urls = strains.filter(strain => strain.url).map(strain => strain.url); this.progressBar().terminate(); this.log.info(`✅ The following strains have been published and version ${this._version} is now online:`); this._strains.forEach((strain) => { if (strain.package) { const url = strain.url ? ` -> ${strain.url}` : ''; this.log.info(`- ${strain.name}${url}`); } }); if (urls.length) { this.log.info('\nYou now access your site using:'); this.log.info(chalk.grey(`$ curl ${urls[0]}`)); } } /** * Turns a list of globs into a regular expression string. * @param {Array<String>} globs a list of globs */ static makeRegexp(globs) { return globs.map(glob).map(re => re.toString().replace(/^\/|\/$/g, '')).join('|'); } async _updateFastly() { this.progressBar(); await this.cloneVersion(); await this.initDictionaries(); await this.initFastly(); const dictJobs = []; const makeDictJob = (dictname, strainname, strainvalue, message, shortMsg) => { this._logger.debug(`Setting ${strainname} ${dictname}=${strainvalue || ''}`); const job = this.putDict(dictname, strainname, strainvalue || '') .then(() => { this.tick(1, message, shortMsg); }) .catch((e) => { const msg = 'Error setting edge dictionary value'; this.log.error(message, e); throw new Error(msg, e); }); dictJobs.push(job); }; const owsecret = `Basic ${toBase64(`${this._wsk_auth}`)}`; makeDictJob('secrets', 'OPENWHISK_AUTH', owsecret, 'Set OpenWhisk Authentication', 'openwhisk authentication'); makeDictJob('secrets', 'OPENWHISK_NAMESPACE', this._wsk_namespace, 'Set OpenWhisk namespace', 'openwhisk namespace'); const [secretp, ownsp] = dictJobs.splice(0, 2); this._strainsToPublish.forEach((strain) => { const actionPrefix = `/${this._wsk_namespace}/${strain.package}/`; // content repo makeDictJob('strain_owners', strain.name, strain.content.owner, '- Set content owner', 'content owner'); makeDictJob('strain_repos', strain.name, strain.content.repo, '- Set content repo', 'content repo'); makeDictJob('strain_refs', strain.name, strain.content.ref, '- Set content ref', 'content ref'); makeDictJob('strain_action_roots', strain.name, actionPrefix, '- Set action root', 'action root'); // optional makeDictJob('strain_index_files', strain.name, strain.directoryIndex, '- Set directory index', 'directory index'); makeDictJob('strain_root_paths', strain.name, strain.content.path, '- Set content root', 'content root'); // static // TODO: replace ref with sha for better performance and lower risk of hitting rate limits makeDictJob('strain_github_static_repos', strain.name, strain.static.repo, '- Set static repo', 'static repo'); makeDictJob('strain_github_static_owners', strain.name, strain.static.owner, '- Set static owner', 'static owner'); makeDictJob('strain_github_static_refs', strain.name, strain.static.ref, '- Set static ref', 'static ref'); if (strain.static.path) { makeDictJob('strain_github_static_root', strain.name, strain.static.path, '- Set static root', 'static root'); } else { // skipping: no message here this.tick(); } if (strain.static && strain.static.magic) { makeDictJob( 'strain_github_static_magic', strain.name, strain.static.magic ? 'true' : 'false', strain.static.magic ? '- Enable magic' : '- Disable magic', 'static magic', ); } else { makeDictJob('strain_github_static_magic', strain.name, 'false', '- Disable magic', 'static magic'); } if (strain.static && strain.static.allow) { const allow = PublishCommand.makeRegexp(strain.static.allow); makeDictJob('strain_allow', strain.name, allow, '- Set whitelist', 'whitelist'); } else { // skipping: no message here this.tick(); } if (strain.static && strain.static.deny) { const deny = PublishCommand.makeRegexp(strain.static.deny); makeDictJob('strain_deny', strain.name, deny, '- Set blacklist', 'blacklist'); } else { // skipping: no message here this.tick(); } }); // do everything at once await Promise.all([ ...dictJobs, // todo: those below act on this._strains which include all strains, not just the // todo: ones handled in dictJobs. is this correct? this.setStrainsVCL(), this.setDynamicVCL(), this.setParametersVCL(), this.setResetVCL(), this.setHelixVCL(), secretp, ownsp, ]); } async init() { await super.init(); this._strains = Array.from(this.config.strains.values()); if (!this._vclFile) { this._vclFile = path.resolve(this.directory, '.hlx', 'helix.vcl'); } if (!await fs.pathExists(this._vclFile)) { this._vclFile = HELIX_VCL_DEFAULT_FILE; } this._strainsToPublish = this.config.strains.getByFilter((strain) => { if (strain.isProxy()) { this.log.debug(`ignoring proxy strain ${strain.name}`); return false; } // skip the strains where we can't determine the action name if (!strain.package) { this.log.debug(`ignoring unaffected strain ${strain.name}`); return false; } return true; }); if (this._strainsToPublish.length === 0) { this.log.warn(chalk`None of the strains contains {cyan package} information.`); } } async run() { await this.init(); try { await this._updateFastly(); this.tick(0); await this.publishVersion(); await this.purgeAll(); this.showNextStep(); } catch (e) { const message = 'Error while running the Publish command'; this.log.error(`${message}: ${e.stack}`, e); throw new Error(message, e); } } } module.exports = PublishCommand;