Skip to content

Commit d6963f4

Browse files
authored
Fb/plugin fts config automation (#54)
* initialize mixes runtime config with config from file * initialize mixes runtime config with config from file 2 * config dir should fall back to working dir if it is not explicit * testing for mixing 1 * remove postinstall script * slightly better makeExclusiveReturning implementation * snapshots * finish config precedence test * fix eslint problem * start of consolidateFromFile * wip 1 * wip 2 * wip 3 * wip 4 * wip 5 * wip 6 * wip 7 * wip 8 * wip 9 * use auto configuration in example-cap-server * better naming for example cap server code * isolate fts features always enabled to dedicated user zork * add an explanatory note to _discoverFtsAutoConfig * saner usage of CONFIG_SOURCE enum * better __config source setting * fix for processConfig verror info * simpler and safer total count
1 parent 410192a commit d6963f4

File tree

14 files changed

+317
-62
lines changed

14 files changed

+317
-62
lines changed

cds-plugin.js

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,36 @@
11
// https://cap.cloud.sap/docs/node.js/cds-plugins
22
"use strict";
33

4+
const { promisify } = require("util");
5+
const fs = require("fs");
6+
const pathlib = require("path");
7+
48
const cds = require("@sap/cds");
59
const cdsPackage = require("@sap/cds/package.json");
610
const toggles = require("./src/");
711
const { closeMainClient, closeSubscriberClient } = require("./src/redisWrapper");
812

913
const FEATURE_KEY_REGEX = /\/fts\/([^\s/]+)$/;
14+
const FTS_AUTO_CONFIG = {
15+
type: "boolean",
16+
fallbackValue: false,
17+
};
18+
19+
const readDirAsync = promisify(fs.readdir);
1020

1121
const doEnableHeaderFeatures = cds.env.profiles?.includes("development");
1222
const isBuild = cds.build?.register;
1323

1424
const _overwriteUniqueName = (envFeatureToggles) => {
15-
const uniqueName = envFeatureToggles.uniqueName;
25+
const uniqueName = envFeatureToggles?.uniqueName;
1626
if (!uniqueName) {
1727
return;
1828
}
1929
toggles._reset({ uniqueName });
2030
};
2131

2232
const _overwriteServiceAccessRoles = (envFeatureToggles) => {
23-
if (!Array.isArray(envFeatureToggles.serviceAccessRoles)) {
33+
if (!Array.isArray(envFeatureToggles?.serviceAccessRoles)) {
2434
return;
2535
}
2636
cds.on("loaded", (csn) => {
@@ -77,9 +87,26 @@ const _registerClientCloseOnShutdown = () => {
7787
});
7888
};
7989

90+
const _discoverFtsAutoConfig = async () => {
91+
const root = process.env.ROOT ?? process.cwd();
92+
const ftsRoot = pathlib.join(root, "fts");
93+
let result;
94+
try {
95+
result = (await readDirAsync(ftsRoot, { withFileTypes: true }))
96+
.filter((entry) => entry.isDirectory())
97+
.reduce((acc, curr) => {
98+
const key = `/fts/${curr.name}`; // NOTE: this has to match FEATURE_KEY_REGEX
99+
acc[key] = Object.assign({}, FTS_AUTO_CONFIG);
100+
return acc;
101+
}, {});
102+
} catch (err) {} // eslint-disable-line no-empty
103+
return result;
104+
};
105+
80106
const activate = async () => {
81107
const envFeatureToggles = cds.env.featureToggles;
82-
if (!envFeatureToggles?.config && !envFeatureToggles?.configFile) {
108+
const ftsAutoConfig = await _discoverFtsAutoConfig();
109+
if (!envFeatureToggles?.config && !envFeatureToggles?.configFile && !ftsAutoConfig) {
83110
return;
84111
}
85112
_overwriteUniqueName(envFeatureToggles);
@@ -90,8 +117,9 @@ const activate = async () => {
90117
return;
91118
}
92119
await toggles.initializeFeatures({
93-
config: envFeatureToggles.config,
94-
configFile: envFeatureToggles.configFile,
120+
config: envFeatureToggles?.config,
121+
configFile: envFeatureToggles?.configFile,
122+
configAuto: ftsAutoConfig,
95123
});
96124

97125
_registerFeatureProvider();
@@ -102,8 +130,4 @@ const activate = async () => {
102130
const doExportActivateAsProperty =
103131
cdsPackage.version.localeCompare("7.3.0", undefined, { numeric: true, sensitivity: "base" }) < 0;
104132

105-
module.exports = doExportActivateAsProperty
106-
? {
107-
activate,
108-
}
109-
: activate();
133+
module.exports = doExportActivateAsProperty ? { activate } : activate();

example-cap-server/.cdsrc.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
"bob": {
1818
"tenant": "people",
1919
"password": "bob",
20-
21-
"features": ["*"]
20+
2221
},
2322
"clark": {
2423
"tenant": "pets",
@@ -29,6 +28,12 @@
2928
"tenant": "pets",
3029
"password": "danny",
3130
31+
},
32+
"zork": {
33+
"tenant": "people",
34+
"password": "zork",
35+
36+
"features": ["*"]
3237
}
3338
}
3439
}

example-cap-server/http/check-service.http

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ Authorization: Basic clark clark
1313
### priority | danny
1414
GET {{base_url}}/rest/check/priority
1515
Authorization: Basic danny danny
16+
17+
### priority | zork | features = [*]
18+
GET {{base_url}}/rest/check/priority
19+
Authorization: Basic zork zork

example-cap-server/srv/feature/toggles.yaml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
---
2-
# info: this is a cds feature, identified by the /fts/<feature-name> pattern. it is active if the value is truthy.
3-
/fts/check-service-extension:
4-
type: boolean
5-
fallbackValue: false
6-
validations:
7-
- scopes: [user, tenant]
2+
# NOTE: uncomment in case you want to define custom validation for this fts toggle
3+
# info: this is a cds feature, identified by the /fts/<feature-name> pattern
4+
#/fts/check-service-extension:
5+
# type: boolean
6+
# fallbackValue: false
7+
# validations:
8+
# - scopes: [user, tenant]
89

910
# info: check api priority; 0 means access is disabled
1011
/check/priority:

example-cap-server/srv/service/check-service.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,17 @@ const HIGH_BOUNDARY = 100;
1414

1515
const priorityHandler = async (context) => {
1616
const { "CheckService.priority": priority } = context.model.definitions;
17-
const isToggled = Boolean(priority["@marked"]);
17+
const isFtsToggled = Boolean(priority["@marked"]);
1818
const value = toggles.getFeatureValue(CHECK_API_PRIORITY, { user: context.user.id, tenant: context.tenant });
1919
const messages =
2020
value >= HIGH_BOUNDARY
2121
? HIGH_VALUE_RESPONSES
2222
: value >= MEDIUM_BOUNDARY
2323
? MEDIUM_VALUE_RESPONSES
2424
: LOW_VALUE_RESPONSES;
25-
const message = [value, messages[Math.floor(Math.random() * messages.length)], `isToggled ${isToggled}`].join(" | ");
25+
const message = [value, messages[Math.floor(Math.random() * messages.length)], `isFtsToggled ${isFtsToggled}`].join(
26+
" | "
27+
);
2628
return context.reply(message);
2729
};
2830

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"index.cds"
1010
],
1111
"scripts": {
12-
"postinstall": "npx patch-package",
12+
"patch": "npx patch-package",
1313
"test": "jest",
1414
"test:remove-inline-snapshots": "npx replace '\\.toMatchInlineSnapshot\\(\\s*`[\\s\\S]*?`\\s*\\);' '.toMatchInlineSnapshot();' test -r --include='*.test.js'",
1515
"lint": "npm run prettier && npm run eslint",
@@ -21,7 +21,7 @@
2121
"docs": "cd docs && bundle exec jekyll serve",
2222
"docs:install": "cd docs && npx shx rm -rf vendor Gemfile.lock && bundle install",
2323
"cloc": "npx cloc --vcs=git --read-lang-def=cloc.def src",
24-
"upgrade-lock": "npx shx rm -rf package-lock.json node_modules && npm i --package-lock"
24+
"upgrade-lock": "npx shx rm -rf package-lock.json node_modules && npm i --package-lock && npm run patch"
2525
},
2626
"engines": {
2727
"node": ">=18.0.0"

src/featureToggles.js

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const { REDIS_INTEGRATION_MODE } = redis;
2323
const { Logger } = require("./logger");
2424
const cfEnv = require("./env");
2525
const { HandlerCollection } = require("./shared/handlerCollection");
26-
const { ENV, isObject, tryRequire } = require("./shared/static");
26+
const { ENV, isObject, tryRequire, tryPathReadable } = require("./shared/static");
2727
const { promiseAllDone } = require("./shared/promiseAllDone");
2828
const { LimitedLazyCache } = require("./shared/cache");
2929
const { Semaphore } = require("./shared/semaphore");
@@ -39,9 +39,16 @@ const SCOPE_KEY_INNER_SEPARATOR = "::";
3939
const SCOPE_KEY_OUTER_SEPARATOR = "##";
4040
const SCOPE_ROOT_KEY = "//";
4141

42+
const CONFIG_SOURCE = Object.freeze({
43+
RUNTIME: "RUNTIME",
44+
FILE: "FILE",
45+
AUTO: "AUTO",
46+
});
47+
4248
const CONFIG_KEY = Object.freeze({
4349
TYPE: "TYPE",
4450
ACTIVE: "ACTIVE",
51+
SOURCE: "SOURCE",
4552
APP_URL: "APP_URL",
4653
APP_URL_ACTIVE: "APP_URL_ACTIVE",
4754
VALIDATIONS: "VALIDATIONS",
@@ -52,6 +59,7 @@ const CONFIG_KEY = Object.freeze({
5259
const CONFIG_INFO_KEY = {
5360
[CONFIG_KEY.TYPE]: true,
5461
[CONFIG_KEY.ACTIVE]: true,
62+
[CONFIG_KEY.SOURCE]: true,
5563
[CONFIG_KEY.APP_URL]: true,
5664
[CONFIG_KEY.APP_URL_ACTIVE]: true,
5765
[CONFIG_KEY.VALIDATIONS]: true,
@@ -103,7 +111,7 @@ class FeatureToggles {
103111

104112
_processValidations(featureKey, validations, configFilepath) {
105113
const workingDir = process.cwd();
106-
const configDir = configFilepath ? path.dirname(configFilepath) : __dirname;
114+
const configDir = configFilepath ? path.dirname(configFilepath) : workingDir;
107115

108116
const validationsScopesMap = {};
109117
const validationsRegex = [];
@@ -177,17 +185,41 @@ class FeatureToggles {
177185
}
178186
}
179187

180-
/**
181-
* Populate this.__config.
182-
*/
183-
_processConfig(config, configFilepath) {
188+
_processConfigSource(source, configFromSource, configFilepath) {
189+
let count = 0;
190+
if (!isObject(configFromSource)) {
191+
return count;
192+
}
193+
184194
const { uris: cfAppUris } = cfEnv.cfApp;
185-
const configEntries = Object.entries(config);
186-
for (const [featureKey, { type, active, appUrl, fallbackValue, validations }] of configEntries) {
195+
const entries = Object.entries(configFromSource);
196+
for (const [featureKey, value] of entries) {
197+
if (this.__config[featureKey]) {
198+
continue;
199+
}
200+
count++;
201+
202+
if (!isObject(value)) {
203+
throw new VError(
204+
{
205+
name: VERROR_CLUSTER_NAME,
206+
info: {
207+
featureKey,
208+
source,
209+
},
210+
},
211+
"feature configuration is not an object"
212+
);
213+
}
214+
215+
const { type, active, appUrl, fallbackValue, validations } = value;
216+
187217
this.__featureKeys.push(featureKey);
188218
this.__fallbackValues[featureKey] = fallbackValue;
189219
this.__config[featureKey] = {};
190220

221+
this.__config[featureKey][CONFIG_KEY.SOURCE] = source;
222+
191223
if (type) {
192224
this.__config[featureKey][CONFIG_KEY.TYPE] = type;
193225
}
@@ -210,8 +242,23 @@ class FeatureToggles {
210242
}
211243
}
212244

245+
return count;
246+
}
247+
248+
/**
249+
* Populate this.__config.
250+
*/
251+
_processConfig([configRuntime, configFromFile, configAuto], configFilepath) {
252+
const configRuntimeCount = this._processConfigSource(CONFIG_SOURCE.RUNTIME, configRuntime, configFilepath);
253+
const configFromFileCount = this._processConfigSource(CONFIG_SOURCE.FILE, configFromFile, configFilepath);
254+
const configAutoCount = this._processConfigSource(CONFIG_SOURCE.AUTO, configAuto, configFilepath);
255+
213256
this.__isConfigProcessed = true;
214-
return configEntries.length;
257+
return {
258+
[CONFIG_SOURCE.RUNTIME]: configRuntimeCount,
259+
[CONFIG_SOURCE.FILE]: configFromFileCount,
260+
[CONFIG_SOURCE.AUTO]: configAutoCount,
261+
};
215262
}
216263

217264
_ensureInitialized() {
@@ -641,43 +688,45 @@ class FeatureToggles {
641688
);
642689
}
643690

644-
/**
645-
* Initialize needs to run and finish before other APIs are called. It processes the configuration, sets up
646-
* related internal state, and starts communication with redis.
647-
*/
648-
async _initializeFeatures({ config: configInput, configFile: configFilepath = DEFAULT_CONFIG_FILEPATH } = {}) {
691+
async _initializeFeatures({ config: configRuntime, configFile: configFilepath, configAuto } = {}) {
649692
if (this.__isInitialized) {
650693
return;
651694
}
652695

653-
let config;
696+
let configFromFile;
654697
try {
655-
config = configInput ? configInput : await FeatureToggles.readConfigFromFile(configFilepath);
698+
if (!configFilepath && (await tryPathReadable(DEFAULT_CONFIG_FILEPATH))) {
699+
configFilepath = DEFAULT_CONFIG_FILEPATH;
700+
}
701+
if (configFilepath) {
702+
configFromFile = await FeatureToggles.readConfigFromFile(configFilepath);
703+
}
656704
} catch (err) {
657705
throw new VError(
658706
{
659707
name: VERROR_CLUSTER_NAME,
660708
cause: err,
661709
info: {
662710
configFilepath,
663-
...(configInput && { configBaseInput: JSON.stringify(configInput) }),
664-
...(config && { configBase: JSON.stringify(config) }),
665711
},
666712
},
667-
"initialization aborted, could not resolve configuration"
713+
"initialization aborted, could not read config file"
668714
);
669715
}
670716

671-
let toggleCount;
717+
let toggleCounts;
672718
try {
673-
toggleCount = this._processConfig(config, configFilepath);
719+
toggleCounts = this._processConfig([configRuntime, configFromFile, configAuto], configFilepath);
674720
} catch (err) {
675721
throw new VError(
676722
{
677723
name: VERROR_CLUSTER_NAME,
678724
cause: err,
679725
info: {
680-
...(config && { config: JSON.stringify(config) }),
726+
...(configAuto && { configAuto: JSON.stringify(configAuto) }),
727+
...(configFromFile && { configFromFile: JSON.stringify(configFromFile) }),
728+
...(configRuntime && { configRuntime: JSON.stringify(configRuntime) }),
729+
configFilepath,
681730
},
682731
},
683732
"initialization aborted, could not process configuration"
@@ -751,16 +800,25 @@ class FeatureToggles {
751800
}
752801
}
753802

803+
const totalCount =
804+
toggleCounts[CONFIG_SOURCE.RUNTIME] + toggleCounts[CONFIG_SOURCE.FILE] + toggleCounts[CONFIG_SOURCE.AUTO];
754805
logger.info(
755-
"finished initialization with %i feature toggle%s with %s",
756-
toggleCount,
757-
toggleCount === 1 ? "" : "s",
806+
"finished initialization with %i feature toggle%s (%i runtime, %i file, %i auto) with %s",
807+
totalCount,
808+
totalCount === 1 ? "" : "s",
809+
toggleCounts[CONFIG_SOURCE.RUNTIME],
810+
toggleCounts[CONFIG_SOURCE.FILE],
811+
toggleCounts[CONFIG_SOURCE.AUTO],
758812
redisIntegrationMode
759813
);
760814
this.__isInitialized = true;
761815
return this;
762816
}
763817

818+
/**
819+
* Initialize needs to run and finish before other APIs are called. It processes the configuration, sets up
820+
* related internal state, and starts communication with redis.
821+
*/
764822
async initializeFeatures(options) {
765823
return await Semaphore.makeExclusiveReturning(this._initializeFeatures.bind(this))(options);
766824
}

src/shared/semaphore.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,11 @@ class Semaphore {
7878
let isRunning;
7979
let runningPromise;
8080
return async (...args) => {
81-
if (isRunning) {
82-
return runningPromise;
83-
}
84-
isRunning = true;
8581
try {
86-
runningPromise = cb(...args);
82+
if (!isRunning) {
83+
isRunning = true;
84+
runningPromise = cb(...args);
85+
}
8786
return await runningPromise;
8887
} finally {
8988
isRunning = false;

0 commit comments

Comments
 (0)