/
Variables.js
995 lines (952 loc) · 38.1 KB
/
Variables.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
'use strict';
const _ = require('lodash');
const BbPromise = require('bluebird');
const os = require('os');
const path = require('path');
const replaceall = require('replaceall');
const fse = require('fs-extra');
const logWarning = require('./Error').logWarning;
const PromiseTracker = require('./PromiseTracker');
const resolvedValuesWeak = new WeakSet();
// Used when resolving variables from a JS file
const unsupportedJsTypes = new Set(['undefined', 'symbol', 'function']);
/**
* Maintainer's notes:
*
* This is a tricky class to modify and maintain. A few rules on how it works...
*
* 0. The population of a service consists of pre-population, followed by population. Pre-
* population consists of populating only those settings which are required for variable
* resolution. Current examples include region and stage as they must be resolved to a
* concrete value before they can be used in the provider's `request` method (used by
* `getValueFromCf`, `getValueFromS3`, and `getValueFromSsm`) to resolve the associated values.
* Original issue: #4725
* 1. All variable populations occur in generations. Each of these generations resolves each
* present variable in the given object or property (i.e. terminal string properties and/or
* property parts) once. This is to say that recursive resolutions should not be made. This is
* because cyclic references are allowed [i.e. ${self:} and the like]) and doing so leads to
* dependency and dead-locking issues. This leads to a problem with deep value population (i.e.
* populating ${self:foo.bar} when ${self:foo} has a value of {opt:bar}). To pause that, one must
* pause population, noting the continued depth to traverse. This motivated "deep" variables.
* Original issue #4687
* 2. The resolution of variables can get very confusing if the same object is used multiple times.
* An example of this is the print command which *implicitly/invisibly* populates the
* serverless.yml and then *explicitly/visibly* renders the same file again, without the
* adornments that the framework's components make to the originally loaded service. As a result,
* it is important to reset this object for each use.
* 3. Note that despite some AWS code herein that this class is used in all plugins. Obviously
* users avoid the AWS-specific variable types when targeting other clouds.
*/
class Variables {
constructor(serverless) {
this.serverless = serverless;
this.service = this.serverless.service;
this.tracker = new PromiseTracker();
this.deep = [];
this.deepRefSyntax = RegExp(/(\${)?deep:\d+(\.[^}]+)*()}?/);
this.overwriteSyntax = RegExp(/\s*(?:,\s*)+/g);
this.fileRefSyntax = RegExp(/^file\(([^?%*:|"<>]+?)\)/g);
this.slsRefSyntax = RegExp(/^sls:/g);
this.envRefSyntax = RegExp(/^env:/g);
this.optRefSyntax = RegExp(/^opt:/g);
this.selfRefSyntax = RegExp(/^self:/g);
this.stringRefSyntax = RegExp(/(?:^('|").*?\1$)/g);
this.boolRefSyntax = RegExp(/(?:^(true|false)$)/g);
this.intRefSyntax = RegExp(/(?:^\d+$)/g);
this.s3RefSyntax = RegExp(/^(?:\${)?s3:(.+?)\/(.+)$/);
this.cfRefSyntax = RegExp(/^(?:\${)?cf(?:\.([a-zA-Z0-9-]+))?:(.+?)\.(.+)$/);
this.ssmRefSyntax = RegExp(
/^(?:\${)?ssm(?:\.([a-zA-Z0-9-]+))?:([a-zA-Z0-9_.\-/]+)[~]?(true|false|split)?/
);
this.strToBoolRefSyntax = RegExp(/^(?:\${)?strToBool\(([a-zA-Z0-9_.\-/]+)\)/);
this.variableResolvers = [
{ regex: this.slsRefSyntax, resolver: this.getValueFromSls.bind(this) },
{ regex: this.envRefSyntax, resolver: this.getValueFromEnv.bind(this) },
{ regex: this.optRefSyntax, resolver: this.getValueFromOptions.bind(this) },
{ regex: this.selfRefSyntax, resolver: this.getValueFromSelf.bind(this) },
{ regex: this.fileRefSyntax, resolver: this.getValueFromFile.bind(this) },
{
regex: this.cfRefSyntax,
resolver: this.getValueFromCf.bind(this),
isDisabledAtPrepopulation: true,
serviceName: 'CloudFormation',
},
{
regex: this.s3RefSyntax,
resolver: this.getValueFromS3.bind(this),
isDisabledAtPrepopulation: true,
serviceName: 'S3',
},
{ regex: this.stringRefSyntax, resolver: this.getValueFromString.bind(this) },
{ regex: this.boolRefSyntax, resolver: this.getValueFromBool.bind(this) },
{ regex: this.intRefSyntax, resolver: this.getValueFromInt.bind(this) },
{
regex: this.ssmRefSyntax,
resolver: this.getValueFromSsm.bind(this),
isDisabledAtPrepopulation: true,
serviceName: 'SSM',
},
{
regex: this.strToBoolRefSyntax,
resolver: this.getValueStrToBool.bind(this),
},
{ regex: this.deepRefSyntax, resolver: this.getValueFromDeep.bind(this) },
];
}
loadVariableSyntax() {
this.variableSyntax = RegExp(this.service.provider.variableSyntax, 'g');
}
initialCall(func) {
this.deep = [];
this.tracker.start();
return func().finally(() => {
this.tracker.stop();
this.deep = [];
});
}
// #############
// ## SERVICE ##
// #############
disableDepedentServices(func) {
const dependencyMessage = (configValue, serviceName) =>
`Variable dependency failure: variable '${configValue}' references ${serviceName} but using that service requires a concrete value to be called.`;
// replace and then restore the methods for obtaining values from dependent services. the
// replacement naturally rejects dependencies on these services that occur during pre-population.
// pre-population is, of course, the process of obtaining the required configuration for using
// these services.
for (const resolver of this.variableResolvers) {
if (resolver.isDisabledAtPrepopulation) {
// save original
resolver.original = resolver.resolver;
// knock out
resolver.resolver = variableString =>
BbPromise.reject(dependencyMessage(variableString, resolver.serviceName));
}
}
return func().finally(() => {
// restore
for (const resolver of this.variableResolvers) {
if (resolver.isDisabledAtPrepopulation) {
resolver.resolver = resolver.original;
}
}
});
}
prepopulateService() {
const provider = this.serverless.getProvider('aws');
if (provider) {
const requiredConfigs = [
Object.assign({ name: 'region' }, provider.getRegionSourceValue()),
Object.assign({ name: 'stage' }, provider.getStageSourceValue()),
{
name: 'profile',
value: this.service.provider.profile,
path: 'serverless.service.provider.profile',
},
{
name: 'credentials',
value: this.service.provider.credentials,
path: 'serverless.service.provider.credentials',
},
{
name: 'credentials.accessKeyId',
value: _.get(this, 'service.provider.credentials.accessKeyId'),
path: 'serverless.service.provider.credentials.accessKeyId',
},
{
name: 'credentials.secretAccessKey',
value: _.get(this, 'service.provider.credentials.secretAccessKey'),
path: 'serverless.service.provider.credentials.secretAccessKey',
},
{
name: 'credentials.sessionToken',
value: _.get(this, 'service.provider.credentials.sessionToken'),
path: 'serverless.service.provider.credentials.sessionToken',
},
];
return this.disableDepedentServices(() => {
const prepopulations = requiredConfigs.map(config =>
this.populateValue(config.value, true) // populate
.then(populated => Object.assign(config, { populated }))
);
return this.assignProperties(provider, prepopulations);
});
}
return BbPromise.resolve();
}
/**
* Populate all variables in the service, conveniently remove and restore the service attributes
* that confuse the population methods.
* @param processedOptions An options hive to use for ${opt:...} variables.
* @returns {Promise.<TResult>|*} A promise resolving to the populated service.
*/
populateService(processedOptions) {
// #######################################################################
// ## KEEP SYNCHRONIZED WITH EQUIVALENT IN ~/lib/plugins/print/print.js ##
// #######################################################################
this.options = processedOptions || {};
this.loadVariableSyntax();
// store
const variableSyntaxProperty = this.service.provider.variableSyntax;
// remove
this.service.provider.variableSyntax = undefined; // otherwise matches itself
this.service.serverless = undefined;
return this.initialCall(() =>
this.prepopulateService()
.then(() =>
this.populateObjectImpl(this.service).finally(() => {
// restore
this.service.serverless = this.serverless;
this.service.provider.variableSyntax = variableSyntaxProperty;
})
)
.then(() => this.service)
);
}
// ############
// ## OBJECT ##
// ############
/**
* The declaration of a terminal property. This declaration includes the path and value of the
* property.
* Example Input:
* {
* foo: {
* bar: 'baz'
* }
* }
* Example Result:
* [
* {
* path: ['foo', 'bar']
* value: 'baz
* }
* ]
* @typedef {Object} TerminalProperty
* @property {String[]} path The path to the terminal property
* @property {Date|RegEx|String} The value of the terminal property
*/
/**
* Generate an array of objects noting the terminal properties of the given root object and their
* paths
* @param root The object to generate a terminal property path/value set for
* @param initCurrent The current part of the given root that terminal properties are being sought
* within
* @param [context] An array containing the path to the current object root (intended for internal
* use)
* @param [results] An array of current results (intended for internal use)
* @returns {TerminalProperty[]} The terminal properties of the given root object, with the path
* and value of each
*/
getProperties(root, initAtRoot, initCurrent, initContext, initResults) {
const processedMap = new WeakMap();
return (function self(atRoot, current, context, results) {
if (processedMap.has(current)) return processedMap.get(current);
if (!context) context = [];
if (!results) results = [];
if (_.isObject(current)) processedMap.set(current, results);
const addContext = (value, key) => self(false, value, context.concat(key), results);
if (Array.isArray(current)) {
current.map(addContext);
} else if (
_.isObject(current) &&
!_.isDate(current) &&
!_.isRegExp(current) &&
typeof current !== 'function'
) {
if (atRoot || current !== root) {
_.mapValues(current, addContext);
}
} else {
results.push({ path: context, value: current });
}
return results;
})(initAtRoot, initCurrent, initContext, initResults);
}
/**
* @typedef {TerminalProperty} TerminalPropertyPopulated
* @property {Object} populated The populated value of the value at the path
*/
/**
* Populate the given terminal properties, returning promises to do so
* @param properties The terminal properties to populate
* @returns {Promise<TerminalPropertyPopulated[]>[]} The promises that will resolve to the
* populated values of the given terminal properties
*/
populateVariables(properties) {
const variables = properties.filter(
property => typeof property.value === 'string' && property.value.match(this.variableSyntax)
);
return variables.map(variable =>
this.populateValue(variable.value, false).then(populated =>
Object.assign({}, variable, { populated })
)
);
}
/**
* Assign the populated values back to the target object
* @param target The object to which the given populated terminal properties should be applied
* @param populations The fully populated terminal properties
* @returns {Promise<number>} resolving with the number of changes that were applied to the given
* target
*/
assignProperties(target, populations) {
// eslint-disable-line class-methods-use-this
return BbPromise.all(populations).then(results =>
results.forEach(result => {
if (result.value !== result.populated) {
_.set(target, result.path, result.populated);
}
})
);
}
/**
* Populate the variables in the given object.
* @param objectToPopulate The object to populate variables within.
* @returns {Promise.<TResult>|*} A promise resolving to the in-place populated object.
*/
populateObject(objectToPopulate) {
return this.initialCall(() => this.populateObjectImpl(objectToPopulate));
}
populateObjectImpl(objectToPopulate) {
const leaves = this.getProperties(objectToPopulate, true, objectToPopulate);
const populations = this.populateVariables(leaves);
if (populations.length === 0) {
return BbPromise.resolve(objectToPopulate);
}
return this.assignProperties(objectToPopulate, populations).then(() =>
this.populateObjectImpl(objectToPopulate)
);
}
// ##############
// ## PROPERTY ##
// ##############
/**
* Standard logic for cleaning a variable
* Example: cleanVariable('${opt:foo}') => 'opt:foo'
* @param match The variable match instance variable part
* @returns {string} The cleaned variable match
*/
cleanVariable(match) {
let cleaned = match.replace(this.variableSyntax, (context, contents) => contents.trim());
if (!cleaned.match(/".*"|'.*'/)) {
cleaned = cleaned.replace(/\s/g, '');
}
return cleaned;
}
/**
* @typedef {Object} MatchResult
* @property {String} match The original property value that matched the variable syntax
* @property {String} variable The cleaned variable string that specifies the origin for the
* property value
*/
/**
* Get matches against the configured variable syntax
* @param property The property value to attempt extracting matches from
* @returns {Object|String|MatchResult[]} The given property or the identified matches
*/
getMatches(property) {
if (typeof property !== 'string') {
return property;
}
const matches = property.match(this.variableSyntax);
if (!matches || !matches.length) {
return property;
}
return matches.map(match => ({
match,
variable: this.cleanVariable(match),
}));
}
/**
* Populate the given matches, returning an array of Promises which will resolve to the populated
* values of the given matches
* @param {MatchResult[]} matches The matches to populate
* @returns {Promise[]} Promises for the eventual populated values of the given matches
*/
populateMatches(matches, property) {
return matches.map(match => this.splitAndGet(match, property));
}
/**
* Render the given matches and their associated results to the given value
* @param value The value into which to render the given results
* @param matches The matches on the given value where the results are to be rendered
* @param results The results that are to be rendered to the given value
* @returns {*} The populated value with the given results rendered according to the given matches
*/
renderMatches(value, matches, results) {
let result = value;
for (let i = 0; i < matches.length; i += 1) {
this.warnIfNotFound(matches[i].variable, results[i]);
result = this.populateVariable(result, matches[i].match, results[i]);
}
return result;
}
/**
* Populate the given value, recursively if root is true
* @param valueToPopulate The value to populate variables within
* @param root Whether the caller is the root populator and thereby whether to recursively
* populate
* @returns {PromiseLike<T>} A promise that resolves to the populated value, recursively if root
* is true
*/
populateValue(valueToPopulate, root) {
const property = valueToPopulate;
const matches = this.getMatches(property);
if (!Array.isArray(matches)) {
return BbPromise.resolve(property);
}
const populations = this.populateMatches(matches, valueToPopulate);
return BbPromise.all(populations)
.then(results => this.renderMatches(property, matches, results))
.then(result => {
if (root && matches.length) {
return this.populateValue(result, root);
}
return result;
});
}
/**
* Populate variables in the given property.
* @param propertyToPopulate The property to populate (replace variables with their values).
* @returns {Promise.<TResult>|*} A promise resolving to the populated result.
*/
populateProperty(propertyToPopulate) {
return this.initialCall(() => this.populateValue(propertyToPopulate, true));
}
/**
* Split the cleaned variable string containing one or more comma delimited variables and get a
* final value for the entirety of the string
* @param match The regex match containing both the variable string to split and get a final
* value for as well as the originally matched string
* @param property The original property string the given variable was extracted from
* @returns {Promise} A promise resolving to the final value of the given variable
*/
splitAndGet(match, property) {
const parts = this.splitByComma(match.variable);
if (parts.length > 1) {
return this.overwrite(parts, match.match, property);
}
return this.getValueFromSource(parts[0], property);
}
/**
* Populate a given property, given the matched string to replace and the value to replace the
* matched string with.
* @param propertyParam The property to replace the matched string with the value.
* @param matchedString The string in the given property that was matched and is to be replaced.
* @param valueToPopulate The value to replace the given matched string in the property with.
* @returns {Promise.<TResult>|*} A promise resolving to the property populated with the given
* value for all instances of the given matched string.
*/
populateVariable(propertyParam, matchedString, valueToPopulate) {
let property = propertyParam;
if (property === matchedString) {
// total replacement
property = valueToPopulate;
} else if (typeof valueToPopulate === 'string') {
// partial replacement, string
property = replaceall(matchedString, valueToPopulate, property);
} else if (_.isNumber(valueToPopulate)) {
// partial replacement, number
property = replaceall(matchedString, String(valueToPopulate), property);
} else {
const errorMessage = [
'Trying to populate non string value into',
` a string for variable ${matchedString}.`,
' Please make sure the value of the property is a string.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
return property;
}
// ###############
// ## VARIABLES ##
// ###############
/**
* Split a given string by whitespace padded commas excluding those within single or double quoted
* strings.
* @param string The string to split by comma.
*/
splitByComma(string) {
const quotedWordSyntax = RegExp(/(?:('|").*?\1)/g);
const input = string.trim();
const stringMatches = [];
let match = quotedWordSyntax.exec(input);
while (match) {
stringMatches.push({
start: match.index,
end: quotedWordSyntax.lastIndex,
});
match = quotedWordSyntax.exec(input);
}
const commaReplacements = [];
const contained = (
commaMatch // curry the current commaMatch
) => (
stringMatch // check whether stringMatch containing the commaMatch
) => stringMatch.start < commaMatch.index && this.overwriteSyntax.lastIndex < stringMatch.end;
match = this.overwriteSyntax.exec(input);
while (match) {
const matchContained = contained(match);
const containedBy = stringMatches.find(matchContained);
if (!containedBy) {
// if uncontained, this comma represents a splitting location
commaReplacements.push({
start: match.index,
end: this.overwriteSyntax.lastIndex,
});
}
match = this.overwriteSyntax.exec(input);
}
let prior = 0;
const results = [];
commaReplacements.forEach(replacement => {
results.push(input.slice(prior, replacement.start));
prior = replacement.end;
});
results.push(input.slice(prior));
return results;
}
/**
* Resolve the given variable string that expresses a series of fallback values in case the
* initial values are not valid, resolving each variable and resolving to the first valid value.
* @param variableStrings The overwrite variables to populate and choose from.
* @param variableMatch The original string from which the variables were extracted.
* @returns {Promise.<TResult>|*} A promise resolving to the first validly populating variable
* in the given variable strings string.
*/
overwrite(variableStrings, variableMatch, propertyString) {
// A sentinel to rid rejected Promises, so any of resolved value can be used as fallback.
const FAIL_TOKEN = {};
const variableValues = variableStrings.map(variableString =>
this.getValueFromSource(variableString, propertyString).catch(() => FAIL_TOKEN)
); // eslint-disable-line no-unused-vars
const validValue = value =>
value !== null &&
typeof value !== 'undefined' &&
(Array.isArray(value) || !(typeof value === 'object' && !Object.keys(value).length));
return BbPromise.all(variableValues)
.then(values => values.filter(v => v !== FAIL_TOKEN))
.then(values => {
let deepPropertyString = variableMatch;
let deepProperties = 0;
values.forEach((value, index) => {
if (typeof value === 'string' && value.match(this.variableSyntax)) {
deepProperties += 1;
const deepVariable = this.makeDeepVariable(value);
deepPropertyString = deepPropertyString.replace(
variableStrings[index],
this.cleanVariable(deepVariable)
);
}
});
return deepProperties > 0
? BbPromise.resolve(deepPropertyString) // return deep variable replacement of original
: BbPromise.resolve(values.find(validValue)); // resolve first valid value, else undefined
});
}
/**
* Given any variable string, return the value it should be populated with.
* @param variableString The variable string to retrieve a value for.
* @returns {Promise.<TResult>|*} A promise resolving to the given variables value.
*/
getValueFromSource(variableString, propertyString) {
let ret;
if (this.tracker.contains(variableString)) {
ret = this.tracker.get(variableString, propertyString);
} else {
for (const { regex, resolver } of this.variableResolvers) {
if (variableString.match(regex)) {
ret = resolver(variableString);
break;
}
}
if (!ret) {
const errorMessage = [
`Invalid variable reference syntax for variable ${variableString}.`,
' You can only reference env vars, options, & files.',
' You can check our docs for more info.',
].join('');
ret = BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
ret = this.tracker.add(variableString, ret, propertyString);
}
return ret.then(resolvedValue => {
if (!Array.isArray(resolvedValue) && !_.isPlainObject(resolvedValue)) return resolvedValue;
if (resolvedValuesWeak.has(resolvedValue)) {
try {
return _.cloneDeep(resolvedValue);
} catch (error) {
return resolvedValue;
}
}
resolvedValuesWeak.add(resolvedValue);
return resolvedValue;
});
}
getValueFromSls(variableString) {
let valueToPopulate = {};
const requestedSlsVar = variableString.split(':')[1];
if (requestedSlsVar === 'instanceId') {
valueToPopulate = this.serverless.instanceId;
}
return BbPromise.resolve(valueToPopulate);
}
getValueFromEnv(variableString) {
// eslint-disable-line class-methods-use-this
const requestedEnvVar = variableString.split(':')[1];
let valueToPopulate;
if (requestedEnvVar !== '' || '' in process.env) {
valueToPopulate = process.env[requestedEnvVar];
} else {
valueToPopulate = process.env;
}
return BbPromise.resolve(valueToPopulate);
}
getValueFromString(variableString) {
// eslint-disable-line class-methods-use-this
const valueToPopulate = variableString.replace(/^['"]|['"]$/g, '');
return BbPromise.resolve(valueToPopulate);
}
getValueFromBool(variableString) {
const valueToPopulate = variableString === 'true';
return BbPromise.resolve(valueToPopulate);
}
getValueFromInt(variableString) {
const valueToPopulate = parseInt(variableString, 10);
return BbPromise.resolve(valueToPopulate);
}
getValueFromOptions(variableString) {
const requestedOption = variableString.split(':')[1];
let valueToPopulate;
if (requestedOption !== '' || '' in this.options) {
valueToPopulate = this.options[requestedOption];
} else {
valueToPopulate = this.options;
}
return BbPromise.resolve(valueToPopulate);
}
getValueFromSelf(variableString) {
const selfServiceRex = /self:service\./;
let variable = variableString;
// ###################################################################
// ## KEEP SYNCHRONIZED WITH EQUIVALENT IN ~/lib/classes/Service.js ##
// ## there, see `loadServiceFileParam` ##
// ###################################################################
// The loaded service is altered during load in ~/lib/classes/Service (see loadServiceFileParam)
// Account for these so that user's reference to their file populate properly
if (variable === 'self:service.name') {
variable = 'self:service';
} else if (variable.match(selfServiceRex)) {
variable = variable.replace(selfServiceRex, 'self:serviceObject.');
} else if (variable === 'self:provider') {
variable = 'self:provider.name';
}
const valueToPopulate = this.service;
const deepProperties = variable
.split(':')[1]
.split('.')
.filter(property => property);
return this.getDeeperValue(deepProperties, valueToPopulate);
}
getValueFromFile(variableString) {
const matchedFileRefString = variableString.match(this.fileRefSyntax)[0];
const referencedFileRelativePath = matchedFileRefString
.replace(this.fileRefSyntax, (match, varName) => varName.trim())
.replace('~', os.homedir());
let referencedFileFullPath = path.isAbsolute(referencedFileRelativePath)
? referencedFileRelativePath
: path.join(this.serverless.config.servicePath, referencedFileRelativePath);
// Get real path to handle potential symlinks (but don't fatal error)
referencedFileFullPath = fse.existsSync(referencedFileFullPath)
? fse.realpathSync(referencedFileFullPath)
: referencedFileFullPath;
let fileExtension = referencedFileRelativePath.split('.');
fileExtension = fileExtension[fileExtension.length - 1];
// Validate file exists
if (!this.serverless.utils.fileExistsSync(referencedFileFullPath)) {
return BbPromise.resolve(undefined);
}
let valueToPopulate;
// Process JS files
if (fileExtension === 'js') {
// eslint-disable-next-line global-require, import/no-dynamic-require
const jsFile = require(referencedFileFullPath);
const variableArray = variableString.split(':');
let returnValue;
if (variableArray[1]) {
let jsModule = variableArray[1];
jsModule = jsModule.split('.')[0];
returnValue = jsFile[jsModule];
} else {
returnValue = jsFile;
}
if (typeof returnValue === 'function') {
valueToPopulate = returnValue.call(jsFile, this.serverless);
} else {
valueToPopulate = returnValue;
}
return BbPromise.resolve(valueToPopulate).then(valueToPopulateResolved => {
let deepProperties = variableString.replace(matchedFileRefString, '');
deepProperties = deepProperties.slice(1).split('.');
deepProperties.splice(0, 1);
return this.getDeeperValue(deepProperties, valueToPopulateResolved).then(
deepValueToPopulateResolved => {
if (unsupportedJsTypes.has(typeof deepValueToPopulateResolved)) {
const errorMessage = [
'Invalid variable syntax when referencing',
` file "${referencedFileRelativePath}".`,
' Check if your javascript is returning the correct data.',
].join('');
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
return BbPromise.resolve(deepValueToPopulateResolved);
}
);
});
}
// Process everything except JS
if (fileExtension !== 'js') {
valueToPopulate = this.serverless.utils.readFileSync(referencedFileFullPath);
if (matchedFileRefString !== variableString) {
let deepProperties = variableString.replace(matchedFileRefString, '');
if (deepProperties.substring(0, 1) !== ':') {
const errorMessage = [
'Invalid variable syntax when referencing',
` file "${referencedFileRelativePath}" sub properties`,
' Please use ":" to reference sub properties.',
].join('');
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
deepProperties = deepProperties.slice(1).split('.');
return this.getDeeperValue(deepProperties, valueToPopulate);
}
}
return BbPromise.resolve(valueToPopulate);
}
buildOptions(regionSuffix) {
const options = { useCache: true };
if (regionSuffix) {
options.region = regionSuffix;
}
return options;
}
getValueFromCf(variableString) {
const groups = variableString.match(this.cfRefSyntax);
const regionSuffix = groups[1];
const stackName = groups[2];
const outputLogicalId = groups[3];
return this.serverless
.getProvider('aws')
.request(
'CloudFormation',
'describeStacks',
{ StackName: stackName },
this.buildOptions(regionSuffix)
)
.then(result => {
const outputs = result.Stacks[0].Outputs;
const output = outputs.find(x => x.OutputKey === outputLogicalId);
if (!output) {
const errorMessage = [
'Trying to request a non exported variable from CloudFormation.',
` Stack name: "${stackName}"`,
` Requested variable: "${outputLogicalId}".`,
].join('');
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
}
return BbPromise.resolve(output.OutputValue);
});
}
getValueFromS3(variableString) {
const groups = variableString.match(this.s3RefSyntax);
const bucket = groups[1];
const key = groups[2];
return this.serverless
.getProvider('aws')
.request(
'S3',
'getObject',
{
Bucket: bucket,
Key: key,
},
{ useCache: true }
) // Use request cache
.then(response => BbPromise.resolve(response.Body.toString()))
.catch(err => {
const errorMessage = `Error getting value for ${variableString}. ${err.message}`;
return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
});
}
getValueFromSsm(variableString) {
const groups = variableString.match(this.ssmRefSyntax);
const regionSuffix = groups[1];
const param = groups[2];
const decrypt = groups[3] === 'true';
const split = groups[3] === 'split';
return this.serverless
.getProvider('aws')
.request(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: decrypt,
},
this.buildOptions(regionSuffix)
) // Use request cache
.then(
response => {
const plainText = response.Parameter.Value;
const type = response.Parameter.Type;
// Only if Secrets Manager. Parameter Store does not support JSON.
// We cannot parse StringList types, so don't try
if (type !== 'StringList' && param.startsWith('/aws/reference/secretsmanager')) {
try {
return JSON.parse(plainText);
} catch (err) {
// return as plain text if value is not JSON
}
}
if (split) {
if (type === 'StringList') return plainText.split(',');
logWarning(
`Cannot split SSM parameter '${param}' of type '${type}'. Must be 'StringList'.`
);
}
return plainText;
},
err => {
if (!err.providerError || err.providerError.statusCode !== 400) {
throw new this.serverless.classes.Error(err.message);
}
}
);
}
getValueStrToBool(variableString) {
return BbPromise.try(() => {
if (typeof variableString === 'string') {
const groups = variableString.match(this.strToBoolRefSyntax);
const param = groups[1].trim();
if (/^(true|false|0|1)$/.test(param)) {
if (param === 'false' || param === '0') {
return false;
}
// truthy or non-empty strings
return true;
}
throw new this.serverless.classes.Error(
'Unexpected strToBool input; expected either "true", "false", "0", or "1".'
);
}
// Cast non-string inputs
return Boolean(variableString);
});
}
getDeepIndex(variableString) {
const deepIndexReplace = RegExp(/^deep:|(\.[^}]+)*$/g);
return variableString.replace(deepIndexReplace, '');
}
getVariableFromDeep(variableString) {
const index = this.getDeepIndex(variableString);
return this.deep[index];
}
getValueFromDeep(variableString) {
const deepPrefixReplace = RegExp(/(?:^deep:)\d+\.?/g);
const variable = this.getVariableFromDeep(variableString);
const deepRef = variableString.replace(deepPrefixReplace, '');
let ret = this.populateValue(variable);
if (deepRef.length) {
// if there is a deep reference remaining
ret = ret.then(result => {
if (typeof result === 'string' && result.match(this.variableSyntax)) {
const deepVariable = this.makeDeepVariable(result);
return BbPromise.resolve(this.appendDeepVariable(deepVariable, deepRef));
}
return this.getDeeperValue(deepRef.split('.'), result);
});
}
return ret;
}
makeDeepVariable(variable) {
let index = this.deep.findIndex(item => variable === item);
if (index < 0) {
index = this.deep.push(variable) - 1;
}
const variableContainer = variable.match(this.variableSyntax)[0];
const variableString = this.cleanVariable(variableContainer).replace(/\s/g, '');
return variableContainer.replace(/\s/g, '').replace(variableString, `deep:${index}`);
}
appendDeepVariable(variable, subProperty) {
const variableString = this.cleanVariable(variable);
return variable.replace(variableString, `${variableString}.${subProperty}`);
}
/**
* Get a value that is within the given valueToPopulate. The deepProperties specify what value
* to retrieve from the given valueToPopulate. The trouble is that anywhere along this chain a
* variable can be discovered. If this occurs, to avoid cyclic dependencies, the resolution of
* the deep value from the given valueToPopulate must be halted. The discovered variable is thus
* set aside into a "deep variable" (see makeDeepVariable). The indexing into the given
* valueToPopulate is then resolved with a replacement ${deep:${index}.${remaining.properties}}
* variable (e.g. ${deep:1.foo}). This pauses the population for continuation during the next
* generation of evaluation (see getValueFromDeep)
* @param deepProperties The "path" of properties to follow in obtaining the deeper value
* @param valueToPopulate The value from which to obtain the deeper value
* @returns {Promise} A promise resolving to the deeper value or to a `deep` variable that
* will later resolve to the deeper value
*/
getDeeperValue(deepProperties, valueToPopulate) {
return BbPromise.reduce(
deepProperties,
(reducedValueParam, subProperty) => {
let reducedValue = reducedValueParam;
if (typeof reducedValue === 'string' && reducedValue.match(this.deepRefSyntax)) {
// build mode
reducedValue = this.appendDeepVariable(reducedValue, subProperty);
} else {
// get mode
if (typeof reducedValue === 'undefined') {
reducedValue = {};
} else if (subProperty !== '' || '' in reducedValue) {
reducedValue = reducedValue[subProperty];
}
if (typeof reducedValue === 'string' && reducedValue.match(this.variableSyntax)) {
reducedValue = this.makeDeepVariable(reducedValue);
}
}
return BbPromise.resolve(reducedValue);
},
valueToPopulate
);
}
warnIfNotFound(variableString, valueToPopulate) {
if (
valueToPopulate === null ||
typeof valueToPopulate === 'undefined' ||
(typeof valueToPopulate === 'object' &&
!Array.isArray(valueToPopulate) &&
!Object.keys(valueToPopulate).length)
) {
let varType;
if (variableString.match(this.envRefSyntax)) {
varType = 'environment variable';
} else if (variableString.match(this.optRefSyntax)) {
varType = 'option';
} else if (variableString.match(this.selfRefSyntax)) {
varType = 'service attribute';
} else if (variableString.match(this.fileRefSyntax)) {
varType = 'file';
} else if (variableString.match(this.ssmRefSyntax)) {
varType = 'SSM parameter';
}
if (varType) {
logWarning(
`A valid ${varType} to satisfy the declaration '${variableString}' could not be found.`
);
}
}
return valueToPopulate;
}
}
module.exports = Variables;