UNPKG

18.7 kBJavaScriptView Raw
1/*
2 * Copyright 2018 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13'use strict';
14
15const { fetch } = require('@adobe/helix-fetch');
16const chalk = require('chalk');
17const ow = require('openwhisk');
18const glob = require('glob');
19const path = require('path');
20const fs = require('fs-extra');
21const { v4: uuidv4 } = require('uuid');
22const ProgressBar = require('progress');
23const { HelixConfig, GitUrl } = require('@adobe/helix-shared');
24const GitUtils = require('./git-utils');
25const useragent = require('./user-agent-util');
26const AbstractCommand = require('./abstract.cmd.js');
27const PackageCommand = require('./package.cmd.js');
28const ConfigUtils = require('./config/config-utils.js');
29
30function humanFileSize(size) {
31 const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
32 const p2 = 1024 ** i;
33 return `${(size / p2).toFixed(2)} ${['B', 'KiB', 'MiB', 'GiB', 'TiB'][i]}`;
34}
35
36class DeployCommand extends AbstractCommand {
37 constructor(logger) {
38 super(logger);
39 this._enableAuto = true;
40 this._circleciAuth = null;
41 this._wsk_auth = null;
42 this._wsk_namespace = null;
43 this._wsk_host = null;
44 this._fastly_namespace = null;
45 this._fastly_auth = null;
46 this._target = null;
47 this._files = null;
48 this._prefix = null;
49 this._modulePaths = [];
50 this._requiredModules = null;
51 this._default = { };
52 this._defaultFile = () => ({});
53 this._enableDirty = false;
54 this._dryRun = false;
55 this._createPackages = 'auto';
56 this._addStrain = null;
57 this._enableMinify = false;
58 this._resolveGitRefSvc = 'helix-services/resolve-git-ref@v1';
59 this._customPipeline = null;
60 this._epsagonAppName = null;
61 this._epsagonToken = null;
62 this._coralogixAppName = null;
63 this._coralogixToken = null;
64 this._updated = null;
65 this._updatedBy = null;
66 }
67
68 get requireConfigFile() {
69 return this._addStrain === null;
70 }
71
72 withEnableAuto(value) {
73 this._enableAuto = value;
74 return this;
75 }
76
77 withCircleciAuth(value) {
78 this._circleciAuth = value;
79 return this;
80 }
81
82 withFastlyAuth(value) {
83 this._fastly_auth = value;
84 return this;
85 }
86
87 withFastlyNamespace(value) {
88 this._fastly_namespace = value;
89 return this;
90 }
91
92 withWskHost(value) {
93 this._wsk_host = value;
94 return this;
95 }
96
97 withWskAuth(value) {
98 this._wsk_auth = value;
99 return this;
100 }
101
102 withWskNamespace(value) {
103 this._wsk_namespace = value;
104 return this;
105 }
106
107 withWskActionMemory(value) {
108 this._wsk_action_memory = value;
109 return this;
110 }
111
112 withWskActionConcurrency(value) {
113 this._wsk_action_concurrency = value;
114 return this;
115 }
116
117 withTarget(value) {
118 this._target = value;
119 return this;
120 }
121
122 withFiles(value) {
123 this._files = value;
124 return this;
125 }
126
127 withModulePaths(value) {
128 this._modulePaths = value;
129 return this;
130 }
131
132 withRequiredModules(mods) {
133 this._requiredModules = mods;
134 return this;
135 }
136
137 withDefault(value) {
138 this._default = value;
139 return this;
140 }
141
142 withDefaultFile(value) {
143 this._defaultFile = value;
144 return this;
145 }
146
147 withEnableDirty(value) {
148 this._enableDirty = value;
149 return this;
150 }
151
152 withDryRun(value) {
153 this._dryRun = value;
154 return this;
155 }
156
157 withCreatePackages(value) {
158 this._createPackages = value;
159 return this;
160 }
161
162 withAddStrain(value) {
163 this._addStrain = value === undefined ? null : value;
164 return this;
165 }
166
167 withMinify(value) {
168 this._enableMinify = value;
169 return this;
170 }
171
172 withResolveGitRefService(value) {
173 this._resolveGitRefSvc = value;
174 return this;
175 }
176
177 withCustomPipeline(customPipeline) {
178 this._customPipeline = customPipeline;
179 return this;
180 }
181
182 withEpsagonAppName(value) {
183 this._epsagonAppName = value;
184 return this;
185 }
186
187 withEpsagonToken(value) {
188 this._epsagonToken = value;
189 return this;
190 }
191
192 withCoralogixAppName(value) {
193 this._coralogixAppName = value;
194 return this;
195 }
196
197 withCoralogixToken(value) {
198 this._coralogixToken = value;
199 return this;
200 }
201
202 withUpdatedAt(value) {
203 this._updated = value;
204 return this;
205 }
206
207 withUpdatedBy(value) {
208 this._updatedBy = value;
209 return this;
210 }
211
212 actionName(script) {
213 if (script.main.indexOf(path.resolve(__dirname, 'openwhisk')) === 0) {
214 return `hlx--${script.name}`;
215 }
216 if (script.main.match(/cgi-bin/)) {
217 return `${this._prefix}/cgi-bin-${script.name}`;
218 }
219 return `${this._prefix}/${script.name}`;
220 }
221
222 async init() {
223 await super.init();
224 this._target = path.resolve(this.directory, this._target);
225 // init dev default file params
226 this._default = Object.assign(this._defaultFile(this.directory), this._default);
227 // read the package json if present
228 try {
229 this._pkgJson = await fs.readJson(path.resolve(this.directory, 'package.json'));
230 } catch (e) {
231 this._pkgJson = {};
232 }
233 this._updated = this._updated || new Date().getTime();
234 }
235
236 static getBuildVarOptions(name, value, auth, owner, repo) {
237 const body = JSON.stringify({
238 name,
239 value,
240 });
241 const options = {
242 method: 'POST',
243 auth,
244 uri: `https://circleci.com/api/v1.1/project/github/${owner}/${repo}/envvar`,
245 headers: {
246 'Content-Type': 'application/json',
247 Accept: 'application/json',
248 'User-Agent': useragent,
249 },
250 body,
251 };
252 return options;
253 }
254
255 static setBuildVar(name, value, owner, repo, auth) {
256 const options = DeployCommand.getBuildVarOptions(name, value, auth, owner, repo);
257 return fetch(options.uri, options);
258 }
259
260 async autoDeploy() {
261 if (!(fs.existsSync(path.resolve(process.cwd(), '.circleci', 'config.yaml')) || fs.existsSync(path.resolve(process.cwd(), '.circleci', 'config.yml')))) {
262 throw new Error(`Cannot automate deployment without ${path.resolve(process.cwd(), '.circleci', 'config.yaml')}`);
263 }
264
265 const { owner, repo, ref } = await GitUtils.getOriginURL(this.directory);
266
267 const auth = {
268 username: this._circleciAuth,
269 password: '',
270 };
271
272 const followoptions = {
273 method: 'POST',
274 json: true,
275 auth,
276 headers: {
277 'Content-Type': 'application/json',
278 Accept: 'application/json',
279 'User-Agent': useragent,
280 },
281 uri: `https://circleci.com/api/v1.1/project/github/${owner}/${repo}/follow`,
282 };
283
284 this.log.info(`Automating deployment with ${followoptions.uri}`);
285
286 let response = await fetch(followoptions.uri, followoptions);
287 const follow = await response.json();
288
289 const envars = [];
290
291 if (this._fastly_namespace) {
292 envars.push(DeployCommand.setBuildVar('HLX_FASTLY_NAMESPACE', this._fastly_namespace, owner, repo, auth));
293 }
294 if (this._fastly_auth) {
295 envars.push(DeployCommand.setBuildVar('HLX_FASTLY_AUTH', this._fastly_auth, owner, repo, auth));
296 }
297
298 if (this._wsk_auth) {
299 envars.push(DeployCommand.setBuildVar('HLX_WSK_AUTH', this._wsk_auth, owner, repo, auth));
300 }
301
302 if (this._wsk_host) {
303 envars.push(DeployCommand.setBuildVar('HLX_WSK_HOST', this._wsk_host, owner, repo, auth));
304 }
305 if (this._wsk_namespace) {
306 envars.push(DeployCommand.setBuildVar('HLX_WSK_NAMESPACE', this._wsk_namespace, owner, repo, auth));
307 }
308
309 if (this._epsagonAppName) {
310 envars.push(DeployCommand.setBuildVar('HLX_EPSAGON_APP_NAME', this._epsagonAppName, owner, repo, auth));
311 }
312 if (this._epsagonToken) {
313 envars.push(DeployCommand.setBuildVar('HLX_EPSAGON_TOKEN', this._epsagonToken, owner, repo, auth));
314 }
315 if (this._coralogixAppName) {
316 envars.push(DeployCommand.setBuildVar('HLX_CORALOGIX_APP_NAME', this._coralogixAppName, owner, repo, auth));
317 }
318 if (this._coralogixToken) {
319 envars.push(DeployCommand.setBuildVar('HLX_CORALOGIX_TOKEN', this._coralogixToken, owner, repo, auth));
320 }
321
322 await Promise.all(envars);
323
324 if (follow.first_build) {
325 this.log.info('\nAuto-deployment started.');
326 this.log.info('Configuration finished. Go to');
327 this.log.info(`${chalk.grey(`https://circleci.com/gh/${owner}/${repo}/edit`)} for build settings or`);
328 this.log.info(`${chalk.grey(`https://circleci.com/gh/${owner}/${repo}`)} for build status.`);
329 } else {
330 this.log.warn('\nAuto-deployment already set up. Triggering a new build.');
331
332 const triggeroptions = {
333 method: 'POST',
334 json: true,
335 auth,
336 headers: {
337 'Content-Type': 'application/json',
338 Accept: 'application/json',
339 'User-Agent': useragent,
340 },
341 uri: `https://circleci.com/api/v1.1/project/github/${owner}/${repo}/tree/${ref}`,
342 };
343
344 response = await fetch(triggeroptions.uri, triggeroptions);
345 const triggered = await response.json();
346
347 this.log.info(`Go to ${chalk.grey(`${triggered.build_url}`)} for build status.`);
348 }
349 }
350
351 async run() {
352 await this.init();
353 const origin = await GitUtils.getOrigin(this.directory);
354 if (!origin) {
355 throw Error('hlx cannot deploy without a remote git repository. Add one with\n$ git remote add origin <github_repo_url>.git');
356 }
357 const dirty = await GitUtils.isDirty(this.directory);
358 if (dirty && !this._enableDirty) {
359 throw Error('hlx will not deploy a working copy that has uncommitted changes. Re-run with flag --dirty to force.');
360 }
361 if (this._enableAuto) {
362 return this.autoDeploy();
363 }
364
365 // get git coordinates and list affected strains
366 const ref = await GitUtils.getBranch(this.directory);
367 const giturl = new GitUrl(`${origin}#${ref}`);
368 const affected = this.config.strains.filterByCode(giturl);
369 if (affected.length === 0) {
370 let newStrain = this._addStrain ? this.config.strains.get(this._addStrain) : null;
371 if (!newStrain) {
372 newStrain = this.config.strains.get('default');
373 // if default is proxy, fall back to default default
374 if (newStrain.isProxy()) {
375 const hlx = await new HelixConfig()
376 .withSource(await ConfigUtils.createDefaultConfig(this.directory))
377 .init();
378 newStrain = hlx.strains.get('default');
379 }
380 newStrain = newStrain.clone();
381 newStrain.name = this._addStrain || uuidv4();
382 this.config.strains.add(newStrain);
383 }
384
385 newStrain.code = giturl;
386 // also tweak content and static url, if default is still local
387 if (newStrain.content.isLocal) {
388 newStrain.content = giturl;
389 }
390 if (newStrain.static.url.isLocal) {
391 newStrain.static.url = giturl;
392 }
393 if (this._addStrain === null) {
394 this.log.error(chalk`Remote repository {cyan ${giturl}} does not affect any strains.
395
396Add a strain definition to your config file:
397{grey ${newStrain.toYAML()}}
398
399Alternatively you can auto-add one using the {grey --add <name>} option.`);
400 throw Error();
401 }
402
403 affected.push(newStrain);
404 this.config.modified = true;
405 this.log.info(chalk`Updated strain {cyan ${newStrain.name}} in helix-config.yaml`);
406 }
407
408 if (dirty) {
409 this._prefix = `${giturl.host.replace(/[\W]/g, '-')}--${giturl.owner.replace(/[\W]/g, '-')}--${giturl.repo.replace(/[\W]/g, '-')}--${giturl.ref.replace(/[\W]/g, '-')}-dirty`;
410 } else {
411 this._prefix = await GitUtils.getCurrentRevision(this.directory);
412 }
413
414 const owoptions = {
415 apihost: this._wsk_host,
416 api_key: this._wsk_auth,
417 namespace: this._wsk_namespace,
418 };
419 const openwhisk = ow(owoptions);
420
421 if (this._createPackages !== 'ignore') {
422 const pgkCommand = new PackageCommand(this.log)
423 .withTarget(this._target)
424 .withFiles(this._files)
425 .withModulePaths(this._modulePaths)
426 .withRequiredModules(this._requiredModules)
427 .withDirectory(this.directory)
428 .withOnlyModified(this._createPackages === 'auto')
429 .withCustomPipeline(this._customPipeline)
430 .withMinify(this._enableMinify);
431 await pgkCommand.run();
432 }
433
434 // get the list of scripts from the info files
435 const infos = [...glob.sync(`${this._target}/**/*.info.json`)];
436 const scriptInfos = await Promise.all(infos.map((info) => fs.readJSON(info)));
437 const scripts = scriptInfos
438 .filter((script) => script.zipFile)
439 // generate action names
440 .map((script) => {
441 // eslint-disable-next-line no-param-reassign
442 script.actionName = this.actionName(script);
443 return script;
444 });
445
446 const bar = new ProgressBar('[:bar] :action :etas', {
447 total: (scripts.length * 2) // two ticks for each script
448 + 1 // one tick for creating the package
449 + 2, // two ticks for binding static
450 width: 50,
451 renderThrottle: 1,
452 stream: process.stdout,
453 });
454
455 const tick = (message, name) => {
456 bar.tick({
457 action: name ? `deploying ${name}` : '',
458 });
459 if (message) {
460 this.log.infoFields(message, {
461 progress: true,
462 });
463 }
464 };
465
466 const params = Object.entries({
467 EPSAGON_TOKEN: this._epsagonToken,
468 CORALOGIX_API_KEY: this._coralogixToken,
469 CORALOGIX_APPLICATION_NAME: this._coralogixAppName,
470 RESOLVE_GITREF_SERVICE: this._resolveGitRefSvc,
471 EPSAGON_APPLICATION_NAME: this._epsagonAppName,
472 ...this._default,
473 }).reduce((obj, [key, value]) => {
474 // remove all falsy values
475 if (value) {
476 // eslint-disable-next-line no-param-reassign
477 obj[key] = value;
478 }
479 return obj;
480 }, {});
481
482 // read files ...
483 const read = scripts
484 .filter((script) => script.zipFile) // skip empty zip files
485 .map((script) => fs.readFile(script.zipFile)
486 .then((action) => ({ script, action })));
487
488 // create openwhisk package
489 if (!this._dryRun) {
490 const parameters = Object.keys(params).map((key) => {
491 const value = params[key];
492 return { key, value };
493 });
494 const annotations = [
495 {
496 key: 'hlx-code-origin',
497 value: giturl.toString(),
498 },
499 {
500 key: 'updated',
501 value: this._updated,
502 },
503 {
504 key: 'pkgVersion',
505 value: this._pkgJson.version || 'n/a',
506 },
507 {
508 key: 'pkgName',
509 value: this._pkgJson.name || 'n/a',
510 },
511 ];
512 if (this._updatedBy) {
513 annotations.push({ key: 'updatedBy', value: this._updatedBy });
514 }
515 await openwhisk.packages.update({
516 name: this._prefix,
517 package: {
518 publish: true,
519 parameters,
520 annotations,
521 },
522 });
523 tick(`created package ${this._prefix}`, '');
524 }
525
526 let bindHelixServices;
527 // bind helix-services
528 if (!this._dryRun) {
529 bindHelixServices = openwhisk.packages.update({
530 package: {
531 binding: {
532 namespace: 'helix', // namespace to bind from
533 name: 'helix-services', // package to bind from
534 },
535 },
536 name: 'helix-services', // name of the new package
537 }).then(() => {
538 tick('bound helix-services', '');
539 });
540 }
541
542 // ... and deploy
543 const deployed = read.map((p) => p.then(({ script, action }) => {
544 const actionoptions = {
545 name: script.actionName,
546 'User-Agent': useragent,
547 action,
548 kind: 'nodejs:10',
549 annotations: {
550 'web-export': true,
551 pkgVersion: this._pkgJson.version || 'n/a',
552 pkgName: this._pkgJson.name || 'n/a',
553 dependencies: script.dependencies.map((dep) => dep.id).join(','),
554 git: giturl.toString(),
555 updated: this._updated,
556 },
557 };
558 if (this._updatedBy) {
559 actionoptions.annotations.updatedBy = this._updatedBy;
560 }
561 if (this._wsk_action_memory || this._wsk_action_concurrency) {
562 const limits = actionoptions.limits || {};
563 if (this._wsk_action_memory) {
564 limits.memory = this._wsk_action_memory;
565 }
566 if (this._wsk_action_concurrency) {
567 limits.concurrency = this._wsk_action_concurrency;
568 }
569 actionoptions.limits = limits;
570 }
571
572 const baseName = path.basename(script.main);
573 tick(`deploying ${baseName}`, baseName);
574
575 if (this._dryRun) {
576 tick(` deployed ${baseName} (skipped)`);
577 return true;
578 }
579
580 return openwhisk.actions.update(actionoptions).then(() => {
581 tick(` deployed ${baseName} (deployed)`);
582 return true;
583 }).catch((e) => {
584 this.log.error(`❌ Unable to deploy the action ${script.name}: ${e.message}`);
585 // eslint-disable-next-line no-param-reassign
586 script.error = true;
587 tick();
588 return false;
589 });
590 }));
591
592 await Promise.all([...deployed, bindHelixServices]);
593
594 let numErrors = 0;
595 bar.terminate();
596 this.log.info(`✅ deployment of ${scripts.length} action${scripts.length !== 1 ? 's' : ''} completed:`);
597 scripts.forEach((script) => {
598 let status = '';
599 if (script.error) {
600 status = chalk.red(' (failed)');
601 numErrors += 1;
602 }
603 this.log.info(` - ${this._wsk_namespace}/${script.actionName} (${humanFileSize(script.archiveSize)})${status}`);
604 });
605
606 if (numErrors) {
607 this.log.error(`${numErrors} occurred while deploying actions. ${chalk.grey(path.relative(this.directory, this.config.configPath))} not updated.`);
608 throw Error();
609 }
610 // update package in affected strains
611 this.log.info(`Affected strains of ${giturl}:`);
612 const packageProperty = `${this._wsk_namespace}/${this._prefix}`;
613 affected.forEach((strain) => {
614 this.log.info(`- ${strain.name}`);
615 if (strain.package !== packageProperty) {
616 this.config.modified = true;
617 // eslint-disable-next-line no-param-reassign
618 strain.package = packageProperty;
619 }
620 });
621
622 if (!this._dryRun && this.config.modified) {
623 await this.config.saveConfig();
624 this.log.info(`Updated ${path.relative(this.directory, this.config.configPath)}`);
625 }
626 return this;
627 }
628}
629module.exports = DeployCommand;