Skip to content

Commit

Permalink
feat: Introduce new config resolution
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Removed `getGlobalConfig`, removed support
for all `rc`-discoverable files, update not always modify only global config
  • Loading branch information
pgrzesik committed Jan 26, 2021
1 parent 3f4cee4 commit 3bff1b4
Show file tree
Hide file tree
Showing 5 changed files with 803 additions and 148 deletions.
208 changes: 130 additions & 78 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,61 @@ const os = require('os');
const fs = require('fs');

const _ = require('lodash');
const rc = require('rc');
const writeFileAtomic = require('write-file-atomic');
const uuid = require('uuid');
const log = require('./log');

let rcFileBase = 'serverless';
let serverlessrcPath = p.join(os.homedir(), `.${rcFileBase}rc`);

let baseFilename = 'serverless';
if (process.env.SERVERLESS_PLATFORM_STAGE && process.env.SERVERLESS_PLATFORM_STAGE !== 'prod') {
rcFileBase = 'serverlessdev';
serverlessrcPath = p.join(os.homedir(), `.${rcFileBase}rc`);
baseFilename = `serverless${process.env.SERVERLESS_PLATFORM_STAGE.toLowerCase()}`;
baseFilename = baseFilename.trim();
}
baseFilename = `.${baseFilename}rc`;

const getLocalConfigPath = () => p.join(process.cwd(), baseFilename);
const getDefaultGlobalConfigPath = () => p.join(os.homedir(), baseFilename);
const getHomeConfigGlobalConfigPath = () => p.join(os.homedir(), '.config', baseFilename);

const getGlobalConfigPath = () => {
const homeConfigGlobalConfigPath = getHomeConfigGlobalConfigPath();
const defaultGlobalConfigPath = getDefaultGlobalConfigPath();
const homeConfigGlobalConfigExists = fs.existsSync(homeConfigGlobalConfigPath);
const defaultGlobalConfigExists = fs.existsSync(defaultGlobalConfigPath);

if (homeConfigGlobalConfigExists && defaultGlobalConfigExists) {
log(`Found two global configuration files. Using: ${defaultGlobalConfigPath}`, {
color: 'orange',
});
return defaultGlobalConfigPath;
}

if (homeConfigGlobalConfigExists) {
return homeConfigGlobalConfigPath;
}

return defaultGlobalConfigPath;
};

function storeConfig(config, configPath) {
config.meta = config.meta || {};
config.meta.updated_at = Math.round(Date.now() / 1000);

const jsonConfig = JSON.stringify(config, null, 2);

function storeConfig(config) {
try {
writeFileAtomic.sync(serverlessrcPath, JSON.stringify(config, null, 2));
writeFileAtomic.sync(configPath, jsonConfig);
} catch (error) {
if (process.env.SLS_DEBUG) {
log(error.stack, { color: 'red' });
log(`Unable to store serverless config due to ${error.code} error`, { color: 'red' });
}
try {
return JSON.parse(fs.readFileSync(serverlessrcPath));
} catch (readError) {
// Ignore
log(`Unable to store serverless config: ${configPath} due to ${error.code} error`, {
color: 'red',
});
}
return {};
}
return config;
}

function createConfig() {
// set default config options
const config = {
function createDefaultGlobalConfig() {
const defaultConfig = {
userId: null, // currentUserId
frameworkId: uuid.v1(),
trackingDisabled: false,
Expand All @@ -48,112 +69,143 @@ function createConfig() {
updated_at: null, // config file updated date
},
};

// save new config
return storeConfig(config);
}

// check for global .serverlessrc file
function hasConfigFile() {
const stats = (() => {
try {
return fs.lstatSync(serverlessrcPath);
} catch (error) {
if (error.code === 'ENOENT') return null;
if (process.env.SLS_DEBUG) {
log(error.stack, { color: 'red' });
log(`Unable to read config due to ${error.code} error`, { color: 'red' });
}
return null;
}
})();
if (!stats) return false;
return stats.isFile();
storeConfig(defaultConfig, getDefaultGlobalConfigPath());
return defaultConfig;
}

// get global + local .serverlessrc config
// 'rc' module merges local config over global
function getConfig() {
if (!hasConfigFile()) {
// create config first
createConfig();
}
// then return config merged via rc module
function getLocalConfig() {
const localConfigPath = getLocalConfigPath();
try {
return rc(rcFileBase, null, /* Ensure to not read options from CLI */ {});
} catch (rcError) {
log(`User Configuration warning: Cannot resolve config file: ${rcError.message}`, {
return JSON.parse(fs.readFileSync(localConfigPath));
} catch (error) {
if (error.code === 'ENOENT') return {};
log(`User Configuration warning: Cannot resolve local config file.\nError: ${error.message}`, {
color: 'orange',
});
return getGlobalConfig();
try {
// try/catch to account for very unlikely race condition where file existed
// during readFileSync but no longer exists during rename
const backupServerlessrcPath = `${localConfigPath}.bak`;
fs.renameSync(localConfigPath, backupServerlessrcPath);
log(`Your previous local config was renamed to ${backupServerlessrcPath} for debugging.`, {
color: 'orange',
});
} catch {
// Ignore
}
}

return {};
}

function getGlobalConfig() {
if (hasConfigFile()) {
try {
return JSON.parse(fs.readFileSync(serverlessrcPath));
} catch (err) {
log(`User Configuration warning: Cannot resolve global config file: ${err.message}`, {
color: 'orange',
});
const globalConfigPath = getGlobalConfigPath();
try {
return JSON.parse(fs.readFileSync(globalConfigPath));
} catch (error) {
// If the file does not exist, we want to recreate default global configuration
if (error.code !== 'ENOENT') {
log(
`User Configuration warning: Cannot resolve global config file: ${globalConfigPath} \nError: ${error.message}`,
{
color: 'orange',
}
);
try {
// try/catch to account for very unlikely race condition where file existed
// during hasConfigFile check but no longer exists during rename
const backupServerlessrcPath = `${serverlessrcPath}.bak`;
fs.renameSync(serverlessrcPath, backupServerlessrcPath);
// during readFileSync but no longer exists during rename
const backupServerlessrcPath = `${globalConfigPath}.bak`;
fs.renameSync(globalConfigPath, backupServerlessrcPath);
log(
`Your previous config was renamed to ${backupServerlessrcPath} for debugging. Default global config will be recreated under ${serverlessrcPath}.`,
`Your previous global config was renamed to ${backupServerlessrcPath} for debugging. Default global config will be recreated under ${getDefaultGlobalConfigPath()}.`,
{ color: 'orange' }
);
} catch {
// Ignore
}
}
}
// else create and return it
return createConfig();

return createDefaultGlobalConfig();
}

function getConfig() {
const localConfig = getLocalConfig();
const globalConfig = getGlobalConfig();
return _.merge(globalConfig, localConfig);
}

function getConfigForUpdate() {
const localConfigPath = getLocalConfigPath();
const localConfigExists = fs.existsSync(localConfigPath);
if (localConfigExists) {
return {
config: getLocalConfig(),
configPath: localConfigPath,
};
}

return {
config: getGlobalConfig(),
configPath: getGlobalConfigPath(),
};
}

// set global .serverlessrc config value.
function set(key, value) {
let config = getGlobalConfig();
const configForUpdate = getConfigForUpdate();
let { config } = configForUpdate;
const { configPath } = configForUpdate;
if (key && typeof key === 'string' && typeof value !== 'undefined') {
config = _.set(config, key, value);
} else if (_.isObject(key)) {
config = _.merge(config, key);
} else if (typeof value !== 'undefined') {
config = _.merge(config, value);
}
// update config meta
config.meta = config.meta || {};
config.meta.updated_at = Math.round(Date.now() / 1000);
// write to .serverlessrc file
return storeConfig(config);
storeConfig(config, configPath);
return getConfig();
}

function deleteValue(key) {
let config = getGlobalConfig();
const configForUpdate = getConfigForUpdate();
let { config } = configForUpdate;
const { configPath } = configForUpdate;
if (key && typeof key === 'string') {
config = _.omit(config, [key]);
} else if (key && Array.isArray(key)) {
config = _.omit(config, key);
}
// write to .serverlessrc file
return storeConfig(config);
storeConfig(config, configPath);
return getConfig();
}

/* Get config value with object path */
function get(path) {
const config = getConfig();
return _.get(config, path);
}

function getLoggedInUser() {
const config = getConfig();
if (!config.userId) {
return null;
}
const user = _.get(config, ['users', config.userId, 'dashboard']);
if (!user || !user.username) {
return null; // user is logged out
}
return {
userId: config.userId,
username: user.username,
accessKeys: user.accessKeys,
idToken: user.idToken,
};
}

module.exports = {
set,
get,
delete: deleteValue,
getConfig,
getGlobalConfig,
CONFIG_FILE_PATH: serverlessrcPath,
getLoggedInUser,
CONFIG_FILE_NAME: baseFilename,
};
39 changes: 30 additions & 9 deletions docs/config.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# config

## Handler of user config files (stored in `.sererlessrc`)
## Handler of user config files (stored in `.serverlessrc`)

By default config is stored in home directory, but user may host it in any other location as supported by [rc search rules](https://github.com/dominictarr/rc#standards)
By default, global config is stored in home directory, but user can also store it in `~/.config`. In addition, local config is recognized if it's present in current directory. On resolution, values from both global and local configs will be merged, with local config values overriding global ones.

Ensures no exception on eventual access issues (altough errors are logged with `SLS_DEBUG` env var on).
When using `delete` or `set`, only one underlying config file will be modified. If local config file is present, it will be modified. Otherwise, global config will be modified.

Ensures no exception on eventual access issues (altough errors are logged with `SLS_DEBUG` env var on). If malformed config will be encountered, it will be renamed to `.serverlessrc.bak` and in case of global configuration, it will be recreated with default values under `~/.serverlessrc`. If local config is malformed, it won't be recreated.

```javascript
const config = require('@serverless/utils/config');
Expand All @@ -14,20 +16,39 @@ Exposes following _sync_ access methods:

### `get(propertyPath)`

Retrieve stored property
Retrieve stored property. It supports nested paths as well.

### `set(propertyPath, value)`

Store given property (can be any JSON value)
Store given property (can be any JSON value).

### `set(object)`

Merge provided `object` with existing config.

### `delete(propertyPath)`

Delete given property
Delete given property. It supports nested paths as well.

### `delete(arrayOfPropertyPaths)`

Delete all properties in provided `arrayOfPropertyPaths`. It supports nested paths as well.

### `getConfig()`

Returns whole structure of config file, if it encounters an error while trying to read config with `rc`, it falls back to `getGlobalConfig()`
Returns whole config structure (merged if both local and global configs found)

### `getGlobalConfig()`
### `getLoggedInUser()`

Returns whole structure of global `~/.serverlessrc` config. If it encounters an issue when trying to parse the global config, it renames it to `~/.serverlessrc.bak` and recreates default config under `~/.serverlessrc`
Returns details about currently logged in user (based on `userId` value in config).

Example result:

```javascript
{
idToken: 'user-id-token',
accessKeys: ['first-access-key', 'second-access-key'],
username: 'exampleUser',
userId: 'example-user-id',
}
```
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"js-yaml": "^4.0.0",
"lodash": "^4.17.20",
"ncjsm": "^4.1.0",
"rc": "^1.2.8",
"type": "^2.1.0",
"uuid": "^8.3.2",
"write-file-atomic": "^3.0.3"
Expand All @@ -21,6 +20,7 @@
"@serverless/eslint-config": "^3.0.0",
"@serverless/test": "^7.0.0",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1",
"eslint": "^7.17.0",
"eslint-plugin-import": "^2.22.1",
"git-list-updated": "^1.2.1",
Expand All @@ -32,7 +32,8 @@
"prettier": "^2.2.1",
"process-utils": "^4.0.0",
"sinon": "^9.2.2",
"standard-version": "^9.1.0"
"standard-version": "^9.1.0",
"timers-ext": "^0.1.7"
},
"eslintConfig": {
"extends": "@serverless/eslint-config/node",
Expand Down

0 comments on commit 3bff1b4

Please sign in to comment.