eip-cloud-services 1.2.5 → 1.3.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/README.md +45 -0
- package/index.js +1 -0
- package/package.json +3 -2
- package/scripts/set-secret.js +80 -0
- package/src/redis.js +25 -2
- package/src/s3.js +4 -2
- package/src/secrets.js +169 -0
package/README.md
CHANGED
|
@@ -81,6 +81,51 @@ module.exports = {
|
|
|
81
81
|
4. [MySQL Module](#mysql-module)
|
|
82
82
|
5. [AWS Lambda Module](#aws-lambda-module)
|
|
83
83
|
6. [AWS SQS Module](#aws-sqs-module)
|
|
84
|
+
7. [AWS Secrets Manager Module](#aws-secrets-manager-module)
|
|
85
|
+
|
|
86
|
+
# AWS Secrets Manager Module
|
|
87
|
+
|
|
88
|
+
## Overview
|
|
89
|
+
|
|
90
|
+
This module provides helpers for reading and creating AWS Secrets Manager secrets. Secret reads use a local tmp-directory cache by default to avoid unnecessary AWS requests. Use `bypassCache` when the caller must force a fresh AWS lookup.
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
```javascript
|
|
95
|
+
const { secrets } = require('eip-cloud-services');
|
|
96
|
+
|
|
97
|
+
const appKey = await secrets.get('my-service/apple-news-app-key');
|
|
98
|
+
const configSecret = await secrets.get('my-service/config', { parseJson: true });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Creating Secrets
|
|
102
|
+
|
|
103
|
+
Existing secrets are never overwritten.
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
await secrets.set('my-service/apple-news-app-key', 'secret-value');
|
|
107
|
+
await secrets.set('my-service/config', { apiKey: 'secret-value' });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
CLI usage:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm run secrets:set -- --name=my-service/apple-news-app-key --value='secret-value'
|
|
114
|
+
npm run secrets:set -- --name=my-service/config --json='{"apiKey":"secret-value"}'
|
|
115
|
+
npm run secrets:set -- --name=my-service/apple-news-app-key --file=./secret.txt
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
If `--name` is omitted, the script uses the current `package.json` name. If no package name is available, it prompts for one.
|
|
119
|
+
|
|
120
|
+
## Cache Options
|
|
121
|
+
|
|
122
|
+
By default `secrets.get()` checks a local tmp cache first and writes AWS results back to that cache. There is no TTL. To force AWS:
|
|
123
|
+
|
|
124
|
+
```javascript
|
|
125
|
+
await secrets.get('my-service/apple-news-app-key', { bypassCache: true });
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Set `EIP_SECRETS_CACHE_DIR` or `secrets.cacheDir` to control cache location.
|
|
84
129
|
|
|
85
130
|
# AWS Lambda Module
|
|
86
131
|
|
package/index.js
CHANGED
|
@@ -4,5 +4,6 @@ exports.s3 = require ( './src/s3' );
|
|
|
4
4
|
exports.cdn = require ( './src/cdn' );
|
|
5
5
|
exports.lambda = require ( './src/lambda' );
|
|
6
6
|
exports.sqs = require ( './src/sqs' );
|
|
7
|
+
exports.secrets = require ( './src/secrets' );
|
|
7
8
|
exports.mysql = require ( './src/mysql' );
|
|
8
9
|
exports.mysql8 = require ( './src/mysql8' );
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eip-cloud-services",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Houses a collection of helpers for connecting with Cloud services.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "mocha"
|
|
7
|
+
"test": "mocha",
|
|
8
|
+
"secrets:set": "node scripts/set-secret.js"
|
|
8
9
|
},
|
|
9
10
|
"author": "Oliver Edgington <oliver@edgington.com>",
|
|
10
11
|
"license": "ISC",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require ( 'fs' );
|
|
4
|
+
const readline = require ( 'readline' );
|
|
5
|
+
const secrets = require ( '../src/secrets' );
|
|
6
|
+
|
|
7
|
+
const parseArgs = ( argv = process.argv.slice ( 2 ) ) => {
|
|
8
|
+
const args = {};
|
|
9
|
+
for ( let i = 0; i < argv.length; i++ ) {
|
|
10
|
+
const arg = argv[ i ];
|
|
11
|
+
if ( !arg.startsWith ( '--' ) ) continue;
|
|
12
|
+
|
|
13
|
+
const [ rawKey, inlineValue ] = arg.slice ( 2 ).split ( '=' );
|
|
14
|
+
const key = rawKey.replace ( /-([a-z])/g, ( _match, char ) => char.toUpperCase () );
|
|
15
|
+
if ( inlineValue !== undefined ) {
|
|
16
|
+
args[ key ] = inlineValue;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if ( argv[ i + 1 ] && !argv[ i + 1 ].startsWith ( '--' ) ) {
|
|
21
|
+
args[ key ] = argv[ i + 1 ];
|
|
22
|
+
i++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
args[ key ] = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return args;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const prompt = question => new Promise ( resolve => {
|
|
33
|
+
const rl = readline.createInterface ( {
|
|
34
|
+
input: process.stdin,
|
|
35
|
+
output: process.stdout
|
|
36
|
+
} );
|
|
37
|
+
rl.question ( question, answer => {
|
|
38
|
+
rl.close ();
|
|
39
|
+
resolve ( answer.trim () );
|
|
40
|
+
} );
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
const resolveSecretValue = args => {
|
|
44
|
+
if ( args.value !== undefined ) return String ( args.value );
|
|
45
|
+
if ( args.json !== undefined ) return JSON.parse ( String ( args.json ) );
|
|
46
|
+
if ( args.file ) return fs.readFileSync ( String ( args.file ), 'utf8' );
|
|
47
|
+
throw new Error ( 'Missing secret value. Use --value, --json, or --file.' );
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const main = async () => {
|
|
51
|
+
const args = parseArgs ();
|
|
52
|
+
const name = secrets.resolveDefaultSecretName ( { name: args.name } ) || await prompt ( 'Secret name: ' );
|
|
53
|
+
if ( !name ) throw new Error ( 'Secret name is required.' );
|
|
54
|
+
|
|
55
|
+
const value = resolveSecretValue ( args );
|
|
56
|
+
const response = await secrets.set ( name, value, {
|
|
57
|
+
region: args.region,
|
|
58
|
+
description: args.description,
|
|
59
|
+
cacheDir: args.cacheDir
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
process.stdout.write ( JSON.stringify ( {
|
|
63
|
+
name,
|
|
64
|
+
arn: response.ARN || response.arn || null,
|
|
65
|
+
versionId: response.VersionId || response.versionId || null
|
|
66
|
+
}, null, 2 ) );
|
|
67
|
+
process.stdout.write ( '\n' );
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if ( require.main === module ) {
|
|
71
|
+
main ().catch ( error => {
|
|
72
|
+
process.stderr.write ( `${ error?.message || error }\n` );
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
} );
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
parseArgs,
|
|
79
|
+
resolveSecretValue
|
|
80
|
+
};
|
package/src/redis.js
CHANGED
|
@@ -53,6 +53,26 @@ const applyPrefix = (value) => {
|
|
|
53
53
|
return value.startsWith(prefix) ? value : `${prefix}${value}`;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
const toPlainObject = ( value ) => {
|
|
57
|
+
if ( value && typeof value === 'object' && !Array.isArray ( value ) ) {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
return {};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const getRedisOptionsForClient = ( clientId, { cluster = false } = {} ) => {
|
|
64
|
+
const baseOptions = toPlainObject ( config?.redis?.options );
|
|
65
|
+
const perClientOptions = toPlainObject ( config?.redis?.clientOptionsById?.[ clientId ] );
|
|
66
|
+
const mergedOptions = { ...baseOptions, ...perClientOptions };
|
|
67
|
+
|
|
68
|
+
// Preserve existing cluster behaviour (TLS by default) unless explicitly overridden.
|
|
69
|
+
if ( cluster && !Object.prototype.hasOwnProperty.call ( mergedOptions, 'tls' ) ) {
|
|
70
|
+
mergedOptions.tls = {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return mergedOptions;
|
|
74
|
+
};
|
|
75
|
+
|
|
56
76
|
/**
|
|
57
77
|
* Creates or retrieves a Redis client instance based on a given client identifier.
|
|
58
78
|
* If the client does not exist, it creates a new one, either connecting to a Redis Cluster
|
|
@@ -93,10 +113,11 @@ const getClient = async ( clientId = 'main' ) => {
|
|
|
93
113
|
host: node.host,
|
|
94
114
|
port: node.port
|
|
95
115
|
} ) );
|
|
116
|
+
const redisOptions = getRedisOptionsForClient ( clientId, { cluster: true } );
|
|
96
117
|
|
|
97
118
|
redisClient = new Redis.Cluster ( clusterNodes, {
|
|
98
119
|
dnsLookup: (address, callback) => callback(null, address),
|
|
99
|
-
redisOptions
|
|
120
|
+
redisOptions
|
|
100
121
|
} );
|
|
101
122
|
|
|
102
123
|
// Await until the cluster is ready
|
|
@@ -113,9 +134,11 @@ const getClient = async ( clientId = 'main' ) => {
|
|
|
113
134
|
throw new Error ( 'Redis Cluster is enabled but there were no cluster nodes defined in config.redis.cluster' );
|
|
114
135
|
}
|
|
115
136
|
else {
|
|
137
|
+
const redisOptions = getRedisOptionsForClient ( clientId );
|
|
116
138
|
redisClient = new Redis ( {
|
|
117
139
|
host: config?.redis?.host,
|
|
118
|
-
port: config?.redis?.port
|
|
140
|
+
port: config?.redis?.port,
|
|
141
|
+
...redisOptions
|
|
119
142
|
} );
|
|
120
143
|
|
|
121
144
|
redisClient.on ( 'error', error => {
|
package/src/s3.js
CHANGED
|
@@ -50,7 +50,9 @@ const zlib = require ( 'zlib' );
|
|
|
50
50
|
const crypto = require ( 'crypto' );
|
|
51
51
|
const { cwd } = require ( 'process' );
|
|
52
52
|
const { log } = config?.s3?.logsFunction ? require ( `${ cwd ()}/${config?.s3?.logsFunction}` ) : console;
|
|
53
|
-
const S3 = new S3Client ( {
|
|
53
|
+
const S3 = new S3Client ( {
|
|
54
|
+
region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || config?.s3?.region || 'eu-west-1'
|
|
55
|
+
} );
|
|
54
56
|
const { pipeline, Writable } = require ( 'stream' );
|
|
55
57
|
const util = require ( 'util' );
|
|
56
58
|
const pipelineAsync = util.promisify ( pipeline );
|
|
@@ -408,4 +410,4 @@ const streamToBuffer = async ( stream ) => {
|
|
|
408
410
|
await pipelineAsync ( stream, collectorStream );
|
|
409
411
|
|
|
410
412
|
return Buffer.concat ( chunks );
|
|
411
|
-
};
|
|
413
|
+
};
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const { SecretsManagerClient, CreateSecretCommand, DescribeSecretCommand, GetSecretValueCommand } = require ( '@aws-sdk/client-secrets-manager' );
|
|
2
|
+
const crypto = require ( 'crypto' );
|
|
3
|
+
const fs = require ( 'fs' );
|
|
4
|
+
const os = require ( 'os' );
|
|
5
|
+
const path = require ( 'path' );
|
|
6
|
+
|
|
7
|
+
let config = {};
|
|
8
|
+
const configDirPath = `${ process.cwd ()}/config`;
|
|
9
|
+
if ( fs.existsSync ( configDirPath ) && fs.statSync ( configDirPath ).isDirectory () ) {
|
|
10
|
+
config = require ( 'config' );
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_REGION = 'eu-west-1';
|
|
14
|
+
const DEFAULT_CACHE_DIR = path.join ( os.tmpdir (), 'eip-cloud-services', 'secrets' );
|
|
15
|
+
|
|
16
|
+
let clientOverride = null;
|
|
17
|
+
|
|
18
|
+
const resolveRegion = options => options.region || config?.secrets?.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || DEFAULT_REGION;
|
|
19
|
+
|
|
20
|
+
const getClient = options => clientOverride || new SecretsManagerClient ( { region: resolveRegion ( options ) } );
|
|
21
|
+
|
|
22
|
+
const safeCacheFileName = ( secretName, region ) => `${ crypto.createHash ( 'sha256' ).update ( `${ region }:${ secretName }` ).digest ( 'hex' ) }.json`;
|
|
23
|
+
|
|
24
|
+
const resolveCacheDir = options => options.cacheDir || config?.secrets?.cacheDir || process.env.EIP_SECRETS_CACHE_DIR || DEFAULT_CACHE_DIR;
|
|
25
|
+
|
|
26
|
+
const resolveCachePath = ( secretName, options = {} ) => path.join ( resolveCacheDir ( options ), safeCacheFileName ( secretName, resolveRegion ( options ) ) );
|
|
27
|
+
|
|
28
|
+
const readCache = ( secretName, options = {} ) => {
|
|
29
|
+
if ( options.bypassCache ) return null;
|
|
30
|
+
|
|
31
|
+
const cachePath = resolveCachePath ( secretName, options );
|
|
32
|
+
if ( !fs.existsSync ( cachePath ) ) return null;
|
|
33
|
+
|
|
34
|
+
const cached = JSON.parse ( fs.readFileSync ( cachePath, 'utf8' ) );
|
|
35
|
+
return cached.value;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const writeCache = ( secretName, value, options = {} ) => {
|
|
39
|
+
if ( options.cache === false ) return;
|
|
40
|
+
|
|
41
|
+
const cachePath = resolveCachePath ( secretName, options );
|
|
42
|
+
fs.mkdirSync ( path.dirname ( cachePath ), { recursive: true, mode: 0o700 } );
|
|
43
|
+
fs.writeFileSync ( cachePath, JSON.stringify ( {
|
|
44
|
+
secretName,
|
|
45
|
+
region: resolveRegion ( options ),
|
|
46
|
+
cachedAt: Date.now (),
|
|
47
|
+
value
|
|
48
|
+
} ), { mode: 0o600 } );
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const parseSecretValue = ( value, options = {} ) => {
|
|
52
|
+
if ( !options.parseJson || typeof value !== 'string' ) return value;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse ( value );
|
|
56
|
+
}
|
|
57
|
+
catch ( _error ) {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const stringifySecretValue = value => typeof value === 'string' ? value : JSON.stringify ( value );
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the default secret name from an explicit value or the current package name.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} [options={}] Options.
|
|
68
|
+
* @param {string} [options.name] Explicit secret name.
|
|
69
|
+
* @param {string} [options.cwd=process.cwd()] Directory containing package.json.
|
|
70
|
+
* @returns {string} The resolved secret name, or an empty string when none can be resolved.
|
|
71
|
+
*/
|
|
72
|
+
exports.resolveDefaultSecretName = ( options = {} ) => {
|
|
73
|
+
if ( options.name ) return String ( options.name ).trim ();
|
|
74
|
+
|
|
75
|
+
const packagePath = path.join ( options.cwd || process.cwd (), 'package.json' );
|
|
76
|
+
if ( !fs.existsSync ( packagePath ) ) return '';
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const packageJson = JSON.parse ( fs.readFileSync ( packagePath, 'utf8' ) );
|
|
80
|
+
return typeof packageJson.name === 'string' ? packageJson.name.trim () : '';
|
|
81
|
+
}
|
|
82
|
+
catch ( _error ) {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check whether a secret exists in AWS Secrets Manager.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} secretName Secret name or ARN.
|
|
91
|
+
* @param {object} [options={}] Options.
|
|
92
|
+
* @param {string} [options.region] AWS region.
|
|
93
|
+
* @returns {Promise<boolean>} True when the secret exists, false when not found.
|
|
94
|
+
*/
|
|
95
|
+
exports.exists = async ( secretName, options = {} ) => {
|
|
96
|
+
try {
|
|
97
|
+
await getClient ( options ).send ( new DescribeSecretCommand ( { SecretId: secretName } ) );
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch ( error ) {
|
|
101
|
+
if ( error?.name === 'ResourceNotFoundException' ) return false;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get a secret value, checking the local tmp cache first unless bypassed.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} secretName Secret name or ARN.
|
|
110
|
+
* @param {object} [options={}] Options.
|
|
111
|
+
* @param {boolean} [options.bypassCache=false] Force AWS lookup instead of tmp cache.
|
|
112
|
+
* @param {boolean} [options.cache=true] Write AWS results to tmp cache.
|
|
113
|
+
* @param {boolean} [options.parseJson=false] Parse JSON string secrets when possible.
|
|
114
|
+
* @param {string} [options.region] AWS region.
|
|
115
|
+
* @param {string} [options.cacheDir] Custom cache directory.
|
|
116
|
+
* @returns {Promise<string|object>} Secret value.
|
|
117
|
+
*/
|
|
118
|
+
exports.get = async ( secretName, options = {} ) => {
|
|
119
|
+
const cached = readCache ( secretName, options );
|
|
120
|
+
if ( cached !== null && cached !== undefined ) return parseSecretValue ( cached, options );
|
|
121
|
+
|
|
122
|
+
const response = await getClient ( options ).send ( new GetSecretValueCommand ( { SecretId: secretName } ) );
|
|
123
|
+
const value = response.SecretString || ( response.SecretBinary ? Buffer.from ( response.SecretBinary ).toString ( 'utf8' ) : '' );
|
|
124
|
+
writeCache ( secretName, value, options );
|
|
125
|
+
|
|
126
|
+
return parseSecretValue ( value, options );
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create a new secret in AWS Secrets Manager. Existing secrets are never overwritten.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} secretName Secret name.
|
|
133
|
+
* @param {string|object} value Secret value.
|
|
134
|
+
* @param {object} [options={}] Options.
|
|
135
|
+
* @param {string} [options.description] Secret description.
|
|
136
|
+
* @param {string} [options.region] AWS region.
|
|
137
|
+
* @param {string} [options.cacheDir] Custom cache directory.
|
|
138
|
+
* @returns {Promise<object>} AWS CreateSecret response.
|
|
139
|
+
*/
|
|
140
|
+
exports.set = async ( secretName, value, options = {} ) => {
|
|
141
|
+
if ( await exports.exists ( secretName, options ) ) {
|
|
142
|
+
throw new Error ( `Secret already exists: ${ secretName }` );
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const secretString = stringifySecretValue ( value );
|
|
146
|
+
const response = await getClient ( options ).send ( new CreateSecretCommand ( {
|
|
147
|
+
Name: secretName,
|
|
148
|
+
SecretString: secretString,
|
|
149
|
+
Description: options.description
|
|
150
|
+
} ) );
|
|
151
|
+
writeCache ( secretName, secretString, options );
|
|
152
|
+
|
|
153
|
+
return response;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
exports.__private = {
|
|
157
|
+
parseSecretValue,
|
|
158
|
+
readCache,
|
|
159
|
+
resolveCachePath,
|
|
160
|
+
safeCacheFileName,
|
|
161
|
+
stringifySecretValue,
|
|
162
|
+
writeCache,
|
|
163
|
+
setClient: client => {
|
|
164
|
+
clientOverride = client;
|
|
165
|
+
},
|
|
166
|
+
clearClient: () => {
|
|
167
|
+
clientOverride = null;
|
|
168
|
+
}
|
|
169
|
+
};
|