1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 | 'use strict';
|
14 |
|
15 | const { fetch } = require('@adobe/helix-fetch');
|
16 | const chalk = require('chalk');
|
17 | const ow = require('openwhisk');
|
18 | const glob = require('glob');
|
19 | const path = require('path');
|
20 | const fs = require('fs-extra');
|
21 | const { v4: uuidv4 } = require('uuid');
|
22 | const ProgressBar = require('progress');
|
23 | const { HelixConfig, GitUrl } = require('@adobe/helix-shared');
|
24 | const GitUtils = require('./git-utils');
|
25 | const useragent = require('./user-agent-util');
|
26 | const AbstractCommand = require('./abstract.cmd.js');
|
27 | const PackageCommand = require('./package.cmd.js');
|
28 | const ConfigUtils = require('./config/config-utils.js');
|
29 |
|
30 | function 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 |
|
36 | class 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 |
|
226 | this._default = Object.assign(this._defaultFile(this.directory), this._default);
|
227 |
|
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 |
|
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 |
|
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 |
|
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 |
|
396 | Add a strain definition to your config file:
|
397 | {grey ${newStrain.toYAML()}}
|
398 |
|
399 | Alternatively 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 |
|
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 |
|
440 | .map((script) => {
|
441 |
|
442 | script.actionName = this.actionName(script);
|
443 | return script;
|
444 | });
|
445 |
|
446 | const bar = new ProgressBar('[:bar] :action :etas', {
|
447 | total: (scripts.length * 2)
|
448 | + 1
|
449 | + 2,
|
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 |
|
475 | if (value) {
|
476 |
|
477 | obj[key] = value;
|
478 | }
|
479 | return obj;
|
480 | }, {});
|
481 |
|
482 |
|
483 | const read = scripts
|
484 | .filter((script) => script.zipFile)
|
485 | .map((script) => fs.readFile(script.zipFile)
|
486 | .then((action) => ({ script, action })));
|
487 |
|
488 |
|
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 |
|
528 | if (!this._dryRun) {
|
529 | bindHelixServices = openwhisk.packages.update({
|
530 | package: {
|
531 | binding: {
|
532 | namespace: 'helix',
|
533 | name: 'helix-services',
|
534 | },
|
535 | },
|
536 | name: 'helix-services',
|
537 | }).then(() => {
|
538 | tick('bound helix-services', '');
|
539 | });
|
540 | }
|
541 |
|
542 |
|
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 |
|
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 |
|
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 |
|
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 | }
|
629 | module.exports = DeployCommand;
|