@backstage/config-loader 0.6.10 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @backstage/config-loader
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1e99c73c75: Update `loadConfig` to return `LoadConfigResult` instead of an array of `AppConfig`.
8
+
9
+ This function is primarily used internally by other config loaders like `loadBackendConfig` which means no changes are required for most users.
10
+
11
+ If you use `loadConfig` directly you will need to update your usage from:
12
+
13
+ ```diff
14
+ - const appConfigs = await loadConfig(options)
15
+ + const { appConfigs } = await loadConfig(options)
16
+ ```
17
+
18
+ ### Patch Changes
19
+
20
+ - 8809b6c0dd: Update the json-schema dependency version.
21
+ - Updated dependencies
22
+ - @backstage/cli-common@0.1.6
23
+
24
+ ## 0.7.2
25
+
26
+ ### Patch Changes
27
+
28
+ - 0611f3b3e2: Reading app config from a remote server
29
+ - 26c5659c97: Bump msw to the same version as the rest
30
+
31
+ ## 0.7.1
32
+
33
+ ### Patch Changes
34
+
35
+ - 10615525f3: Switch to use the json and observable types from `@backstage/types`
36
+ - ea21f7f567: bump `typescript-json-schema` from 0.50.1 to 0.51.0
37
+ - Updated dependencies
38
+ - @backstage/config@0.1.11
39
+ - @backstage/cli-common@0.1.5
40
+ - @backstage/errors@0.1.4
41
+
42
+ ## 0.7.0
43
+
44
+ ### Minor Changes
45
+
46
+ - 7e97d0b8c1: Removed the `EnvFunc` public export. Its only usage was to be passed in to `LoadConfigOptions.experimentalEnvFunc`. If you were using this type, add a definition in your own project instead with the signature `(name: string) => Promise<string | undefined>`.
47
+
48
+ ### Patch Changes
49
+
50
+ - 223e8de6b4: Configuration schema errors are now filtered using the provided visibility option. This means that schema errors due to missing backend configuration will no longer break frontend builds.
51
+ - 7e97d0b8c1: Add public tags and documentation
52
+ - 36e67d2f24: Internal updates to apply more strict checks to throw errors.
53
+ - Updated dependencies
54
+ - @backstage/errors@0.1.3
55
+
3
56
  ## 0.6.10
4
57
 
5
58
  ### Patch Changes
package/dist/index.cjs.js CHANGED
@@ -2,22 +2,27 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
+ var errors = require('@backstage/errors');
5
6
  var yaml = require('yaml');
6
7
  var path = require('path');
7
8
  var Ajv = require('ajv');
8
9
  var mergeAllOf = require('json-schema-merge-allof');
10
+ var traverse = require('json-schema-traverse');
9
11
  var config = require('@backstage/config');
10
12
  var fs = require('fs-extra');
11
13
  var typescriptJsonSchema = require('typescript-json-schema');
12
14
  var chokidar = require('chokidar');
15
+ var fetch = require('node-fetch');
13
16
 
14
17
  function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
15
18
 
16
19
  var yaml__default = /*#__PURE__*/_interopDefaultLegacy(yaml);
17
20
  var Ajv__default = /*#__PURE__*/_interopDefaultLegacy(Ajv);
18
21
  var mergeAllOf__default = /*#__PURE__*/_interopDefaultLegacy(mergeAllOf);
22
+ var traverse__default = /*#__PURE__*/_interopDefaultLegacy(traverse);
19
23
  var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
20
24
  var chokidar__default = /*#__PURE__*/_interopDefaultLegacy(chokidar);
25
+ var fetch__default = /*#__PURE__*/_interopDefaultLegacy(fetch);
21
26
 
22
27
  const ENV_PREFIX = "APP_CONFIG_";
23
28
  const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
@@ -65,6 +70,7 @@ function safeJsonParse(str) {
65
70
  try {
66
71
  return [null, JSON.parse(str)];
67
72
  } catch (err) {
73
+ errors.assertError(err);
68
74
  return [err, str];
69
75
  }
70
76
  }
@@ -95,6 +101,7 @@ async function applyConfigTransforms(initialDir, input, transforms) {
95
101
  break;
96
102
  }
97
103
  } catch (error) {
104
+ errors.assertError(error);
98
105
  throw new Error(`error at ${path}, ${error.message}`);
99
106
  }
100
107
  }
@@ -232,7 +239,7 @@ const CONFIG_VISIBILITIES = ["frontend", "backend", "secret"];
232
239
  const DEFAULT_CONFIG_VISIBILITY = "backend";
233
240
 
234
241
  function compileConfigSchemas(schemas) {
235
- const visibilityByPath = new Map();
242
+ const visibilityByDataPath = new Map();
236
243
  const ajv = new Ajv__default['default']({
237
244
  allErrors: true,
238
245
  allowUnionTypes: true,
@@ -252,7 +259,7 @@ function compileConfigSchemas(schemas) {
252
259
  }
253
260
  if (visibility && visibility !== "backend") {
254
261
  const normalizedPath = context.dataPath.replace(/\['?(.*?)'?\]/g, (_, segment) => `/${segment}`);
255
- visibilityByPath.set(normalizedPath, visibility);
262
+ visibilityByDataPath.set(normalizedPath, visibility);
256
263
  }
257
264
  return true;
258
265
  };
@@ -267,23 +274,27 @@ function compileConfigSchemas(schemas) {
267
274
  }
268
275
  const merged = mergeConfigSchemas(schemas.map((_) => _.value));
269
276
  const validate = ajv.compile(merged);
277
+ const visibilityBySchemaPath = new Map();
278
+ traverse__default['default'](merged, (schema, path) => {
279
+ if (schema.visibility && schema.visibility !== "backend") {
280
+ visibilityBySchemaPath.set(path, schema.visibility);
281
+ }
282
+ });
270
283
  return (configs) => {
271
284
  var _a;
272
285
  const config$1 = config.ConfigReader.fromConfigs(configs).get();
273
- visibilityByPath.clear();
286
+ visibilityByDataPath.clear();
274
287
  const valid = validate(config$1);
275
288
  if (!valid) {
276
- const errors = (_a = validate.errors) != null ? _a : [];
277
289
  return {
278
- errors: errors.map(({dataPath, message, params}) => {
279
- const paramStr = Object.entries(params).map(([name, value]) => `${name}=${value}`).join(" ");
280
- return `Config ${message || ""} { ${paramStr} } at ${dataPath}`;
281
- }),
282
- visibilityByPath: new Map()
290
+ errors: (_a = validate.errors) != null ? _a : [],
291
+ visibilityByDataPath: new Map(visibilityByDataPath),
292
+ visibilityBySchemaPath
283
293
  };
284
294
  }
285
295
  return {
286
- visibilityByPath: new Map(visibilityByPath)
296
+ visibilityByDataPath: new Map(visibilityByDataPath),
297
+ visibilityBySchemaPath
287
298
  };
288
299
  };
289
300
  }
@@ -412,6 +423,7 @@ function compileTsSchemas(paths) {
412
423
  validationKeywords: ["visibility"]
413
424
  }, [path$1.split(path.sep).join("/")]);
414
425
  } catch (error) {
426
+ errors.assertError(error);
415
427
  if (error.message !== "type Config not found") {
416
428
  throw error;
417
429
  }
@@ -424,12 +436,12 @@ function compileTsSchemas(paths) {
424
436
  return tsSchemas;
425
437
  }
426
438
 
427
- function filterByVisibility(data, includeVisibilities, visibilityByPath, transformFunc, withFilteredKeys) {
439
+ function filterByVisibility(data, includeVisibilities, visibilityByDataPath, transformFunc, withFilteredKeys) {
428
440
  var _a;
429
441
  const filteredKeys = new Array();
430
442
  function transform(jsonVal, visibilityPath, filterPath) {
431
443
  var _a2;
432
- const visibility = (_a2 = visibilityByPath.get(visibilityPath)) != null ? _a2 : DEFAULT_CONFIG_VISIBILITY;
444
+ const visibility = (_a2 = visibilityByDataPath.get(visibilityPath)) != null ? _a2 : DEFAULT_CONFIG_VISIBILITY;
433
445
  const isVisible = includeVisibilities.includes(visibility);
434
446
  if (typeof jsonVal !== "object") {
435
447
  if (isVisible) {
@@ -479,7 +491,40 @@ function filterByVisibility(data, includeVisibilities, visibilityByPath, transfo
479
491
  data: (_a = transform(data, "", "")) != null ? _a : {}
480
492
  };
481
493
  }
494
+ function filterErrorsByVisibility(errors, includeVisibilities, visibilityByDataPath, visibilityBySchemaPath) {
495
+ if (!errors) {
496
+ return [];
497
+ }
498
+ if (!includeVisibilities) {
499
+ return errors;
500
+ }
501
+ const visibleSchemaPaths = Array.from(visibilityBySchemaPath).filter(([, v]) => includeVisibilities.includes(v)).map(([k]) => k);
502
+ return errors.filter((error) => {
503
+ var _a;
504
+ if (error.keyword === "type" && ["object", "array"].includes(error.params.type)) {
505
+ return true;
506
+ }
507
+ if (error.keyword === "required") {
508
+ const trimmedPath = error.schemaPath.slice(1, -"/required".length);
509
+ const fullPath = `${trimmedPath}/properties/${error.params.missingProperty}`;
510
+ if (visibleSchemaPaths.some((visiblePath) => visiblePath.startsWith(fullPath))) {
511
+ return true;
512
+ }
513
+ }
514
+ const vis = (_a = visibilityByDataPath.get(error.dataPath)) != null ? _a : DEFAULT_CONFIG_VISIBILITY;
515
+ return vis && includeVisibilities.includes(vis);
516
+ });
517
+ }
482
518
 
519
+ function errorsToError(errors) {
520
+ const messages = errors.map(({dataPath, message, params}) => {
521
+ const paramStr = Object.entries(params).map(([name, value]) => `${name}=${value}`).join(" ");
522
+ return `Config ${message || ""} { ${paramStr} } at ${dataPath}`;
523
+ });
524
+ const error = new Error(`Config validation failed, ${messages.join("; ")}`);
525
+ error.messages = messages;
526
+ return error;
527
+ }
483
528
  async function loadConfigSchema(options) {
484
529
  var _a;
485
530
  let schemas;
@@ -496,21 +541,20 @@ async function loadConfigSchema(options) {
496
541
  return {
497
542
  process(configs, {visibility, valueTransform, withFilteredKeys} = {}) {
498
543
  const result = validate(configs);
499
- if (result.errors) {
500
- const error = new Error(`Config validation failed, ${result.errors.join("; ")}`);
501
- error.messages = result.errors;
502
- throw error;
544
+ const visibleErrors = filterErrorsByVisibility(result.errors, visibility, result.visibilityByDataPath, result.visibilityBySchemaPath);
545
+ if (visibleErrors.length > 0) {
546
+ throw errorsToError(visibleErrors);
503
547
  }
504
548
  let processedConfigs = configs;
505
549
  if (visibility) {
506
550
  processedConfigs = processedConfigs.map(({data, context}) => ({
507
551
  context,
508
- ...filterByVisibility(data, visibility, result.visibilityByPath, valueTransform, withFilteredKeys)
552
+ ...filterByVisibility(data, visibility, result.visibilityByDataPath, valueTransform, withFilteredKeys)
509
553
  }));
510
554
  } else if (valueTransform) {
511
555
  processedConfigs = processedConfigs.map(({data, context}) => ({
512
556
  context,
513
- ...filterByVisibility(data, Array.from(CONFIG_VISIBILITIES), result.visibilityByPath, valueTransform, withFilteredKeys)
557
+ ...filterByVisibility(data, Array.from(CONFIG_VISIBILITIES), result.visibilityByDataPath, valueTransform, withFilteredKeys)
514
558
  }));
515
559
  }
516
560
  return processedConfigs;
@@ -524,10 +568,28 @@ async function loadConfigSchema(options) {
524
568
  };
525
569
  }
526
570
 
571
+ function isValidUrl(url) {
572
+ try {
573
+ new URL(url);
574
+ return true;
575
+ } catch {
576
+ return false;
577
+ }
578
+ }
579
+
527
580
  async function loadConfig(options) {
528
- const {configRoot, experimentalEnvFunc: envFunc, watch} = options;
529
- const configPaths = options.configPaths.slice();
530
- if (configPaths.length === 0) {
581
+ const {configRoot, experimentalEnvFunc: envFunc, watch, remote} = options;
582
+ const configPaths = options.configTargets.slice().filter((e) => e.hasOwnProperty("path")).map((configTarget) => configTarget.path);
583
+ options.configPaths.forEach((cp) => {
584
+ if (!configPaths.includes(cp)) {
585
+ configPaths.push(cp);
586
+ }
587
+ });
588
+ const configUrls = options.configTargets.slice().filter((e) => e.hasOwnProperty("url")).map((configTarget) => configTarget.url);
589
+ if (remote === void 0 && configUrls.length > 0) {
590
+ throw new Error(`Remote config detected but this feature is turned off`);
591
+ }
592
+ if (configPaths.length === 0 && configUrls.length === 0) {
531
593
  configPaths.push(path.resolve(configRoot, "app-config.yaml"));
532
594
  const localConfig = path.resolve(configRoot, "app-config.local.yaml");
533
595
  if (await fs__default['default'].pathExists(localConfig)) {
@@ -553,18 +615,53 @@ async function loadConfig(options) {
553
615
  }
554
616
  return configs;
555
617
  };
618
+ const loadRemoteConfigFiles = async () => {
619
+ const configs = [];
620
+ const readConfigFromUrl = async (url) => {
621
+ const response = await fetch__default['default'](url);
622
+ if (!response.ok) {
623
+ throw new Error(`Could not read config file at ${url}`);
624
+ }
625
+ return await response.text();
626
+ };
627
+ for (let i = 0; i < configUrls.length; i++) {
628
+ const configUrl = configUrls[i];
629
+ if (!isValidUrl(configUrl)) {
630
+ throw new Error(`Config load path is not valid: '${configUrl}'`);
631
+ }
632
+ const remoteConfigContent = await readConfigFromUrl(configUrl);
633
+ if (!remoteConfigContent) {
634
+ throw new Error(`Config is not valid`);
635
+ }
636
+ const configYaml = yaml__default['default'].parse(remoteConfigContent);
637
+ const substitutionTransform = createSubstitutionTransform(env);
638
+ const data = await applyConfigTransforms(configRoot, configYaml, [
639
+ substitutionTransform
640
+ ]);
641
+ configs.push({data, context: configUrl});
642
+ }
643
+ return configs;
644
+ };
556
645
  let fileConfigs;
557
646
  try {
558
647
  fileConfigs = await loadConfigFiles();
559
648
  } catch (error) {
560
- throw new Error(`Failed to read static configuration file, ${error.message}`);
649
+ throw new errors.ForwardedError("Failed to read static configuration file", error);
650
+ }
651
+ let remoteConfigs = [];
652
+ if (remote) {
653
+ try {
654
+ remoteConfigs = await loadRemoteConfigFiles();
655
+ } catch (error) {
656
+ throw new errors.ForwardedError(`Failed to read remote configuration file`, error);
657
+ }
561
658
  }
562
659
  const envConfigs = await readEnvConfig(process.env);
563
- if (watch) {
564
- let currentSerializedConfig = JSON.stringify(fileConfigs);
660
+ const watchConfigFile = (watchProp) => {
565
661
  const watcher = chokidar__default['default'].watch(configPaths, {
566
662
  usePolling: process.env.NODE_ENV === "test"
567
663
  });
664
+ let currentSerializedConfig = JSON.stringify(fileConfigs);
568
665
  watcher.on("change", async () => {
569
666
  try {
570
667
  const newConfigs = await loadConfigFiles();
@@ -573,18 +670,55 @@ async function loadConfig(options) {
573
670
  return;
574
671
  }
575
672
  currentSerializedConfig = newSerializedConfig;
576
- watch.onChange([...newConfigs, ...envConfigs]);
673
+ watchProp.onChange([...remoteConfigs, ...newConfigs, ...envConfigs]);
577
674
  } catch (error) {
578
675
  console.error(`Failed to reload configuration files, ${error}`);
579
676
  }
580
677
  });
581
- if (watch.stopSignal) {
582
- watch.stopSignal.then(() => {
678
+ if (watchProp.stopSignal) {
679
+ watchProp.stopSignal.then(() => {
583
680
  watcher.close();
584
681
  });
585
682
  }
683
+ };
684
+ const watchRemoteConfig = (watchProp, remoteProp) => {
685
+ const hasConfigChanged = async (oldRemoteConfigs, newRemoteConfigs) => {
686
+ return JSON.stringify(oldRemoteConfigs) !== JSON.stringify(newRemoteConfigs);
687
+ };
688
+ let handle;
689
+ try {
690
+ handle = setInterval(async () => {
691
+ console.info(`Checking for config update`);
692
+ const newRemoteConfigs = await loadRemoteConfigFiles();
693
+ if (await hasConfigChanged(remoteConfigs, newRemoteConfigs)) {
694
+ remoteConfigs = newRemoteConfigs;
695
+ console.info(`Remote config change, reloading config ...`);
696
+ watchProp.onChange([...remoteConfigs, ...fileConfigs, ...envConfigs]);
697
+ console.info(`Remote config reloaded`);
698
+ }
699
+ }, remoteProp.reloadIntervalSeconds * 1e3);
700
+ } catch (error) {
701
+ console.error(`Failed to reload configuration files, ${error}`);
702
+ }
703
+ if (watchProp.stopSignal) {
704
+ watchProp.stopSignal.then(() => {
705
+ if (handle !== void 0) {
706
+ console.info(`Stopping remote config watch`);
707
+ clearInterval(handle);
708
+ handle = void 0;
709
+ }
710
+ });
711
+ }
712
+ };
713
+ if (watch) {
714
+ watchConfigFile(watch);
586
715
  }
587
- return [...fileConfigs, ...envConfigs];
716
+ if (watch && remote) {
717
+ watchRemoteConfig(watch, remote);
718
+ }
719
+ return {
720
+ appConfigs: remote ? [...remoteConfigs, ...fileConfigs, ...envConfigs] : [...fileConfigs, ...envConfigs]
721
+ };
588
722
  }
589
723
 
590
724
  exports.loadConfig = loadConfig;