configorama 1.0.2 → 1.1.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 +31 -0
- package/package.json +10 -3
- package/plugins/cloudformation/README.md +79 -0
- package/plugins/cloudformation/credentials.js +231 -0
- package/plugins/cloudformation/index.js +204 -0
- package/plugins/cloudformation/package-lock.json +657 -0
- package/plugins/cloudformation/package.json +24 -0
- package/plugins/onepassword/README.md +175 -0
- package/plugins/onepassword/fields.js +126 -0
- package/plugins/onepassword/index.js +345 -0
- package/plugins/onepassword/normalize.js +145 -0
- package/plugins/onepassword/op-cli.js +117 -0
- package/plugins/onepassword/package.json +15 -0
- package/plugins/onepassword/parser.js +70 -0
- package/plugins/onepassword/sync-factory.js +15 -0
- package/src/index.js +8 -1
- package/src/main.js +6 -3
- package/src/parsers/dotenv.js +13 -0
- package/src/resolvers/valueFromFile.js +2 -1
- package/src/sync.js +27 -0
- package/src/utils/PromiseTracker.js +11 -4
- package/src/utils/PromiseTracker.test.js +48 -0
- package/src/utils/introspection/audit.js +39 -9
- package/src/utils/parsing/parse.js +10 -2
- package/src/utils/paths/fileType.js +27 -0
- package/src/utils/paths/fileType.test.js +41 -0
- package/src/utils/paths/findLineForKey.js +24 -1
- package/src/utils/paths/findLineForKey.test.js +20 -1
package/README.md
CHANGED
|
@@ -141,6 +141,7 @@ npx configorama inspect config.yml
|
|
|
141
141
|
- [Functions (Experimental)](#functions-experimental)
|
|
142
142
|
- [Bundled Plugins](#bundled-plugins)
|
|
143
143
|
- [CloudFormation](#cloudformation)
|
|
144
|
+
- [1Password](#1password)
|
|
144
145
|
- [API Reference](#api-reference)
|
|
145
146
|
- [Async API](#async-api)
|
|
146
147
|
- [Sync API](#sync-api)
|
|
@@ -1550,6 +1551,36 @@ Peer dependency (install separately):
|
|
|
1550
1551
|
npm install @aws-sdk/client-cloudformation @aws-sdk/credential-providers
|
|
1551
1552
|
```
|
|
1552
1553
|
|
|
1554
|
+
### 1Password
|
|
1555
|
+
|
|
1556
|
+
Resolves secret values through the [1Password CLI](https://developer.1password.com/docs/cli/) (`op`). Secrets are fetched at resolution time — never persisted, never logged.
|
|
1557
|
+
|
|
1558
|
+
```yaml
|
|
1559
|
+
npmToken: ${op:npm.NPM_TOKEN}
|
|
1560
|
+
dbPassword: ${op:database}
|
|
1561
|
+
directRef: ${op(op://vault/item/field)}
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
```javascript
|
|
1565
|
+
const configorama = require('configorama')
|
|
1566
|
+
const createOnePasswordResolver = require('configorama/plugins/onepassword')
|
|
1567
|
+
|
|
1568
|
+
const opResolver = createOnePasswordResolver({
|
|
1569
|
+
refs: {
|
|
1570
|
+
npm: 'op://production/npm-automation/notesPlain',
|
|
1571
|
+
database: { item: 'database-prod', vault: 'production', field: 'password' },
|
|
1572
|
+
},
|
|
1573
|
+
})
|
|
1574
|
+
|
|
1575
|
+
const config = await configorama('config.yml', {
|
|
1576
|
+
variableSources: [opResolver]
|
|
1577
|
+
})
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
Full docs: [`plugins/onepassword/README.md`](./plugins/onepassword/README.md). Covers alias refs, private item links, field inference and ambiguity rules, INI/dotenv key paths, `skipResolution`, and sync usage.
|
|
1581
|
+
|
|
1582
|
+
No npm dependencies — requires the `op` binary on `PATH` and a signed-in CLI (or `OP_SERVICE_ACCOUNT_TOKEN`).
|
|
1583
|
+
|
|
1553
1584
|
---
|
|
1554
1585
|
|
|
1555
1586
|
## API Reference
|
package/package.json
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "configorama",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Variable support for configuration files",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
|
-
"./parse-file": "./src/utils/parsing/parse.js"
|
|
9
|
+
"./parse-file": "./src/utils/parsing/parse.js",
|
|
10
|
+
"./plugins/cloudformation": "./plugins/cloudformation/index.js",
|
|
11
|
+
"./plugins/onepassword": "./plugins/onepassword/index.js"
|
|
10
12
|
},
|
|
11
13
|
"files": [
|
|
12
14
|
"cli.js",
|
|
13
15
|
"src",
|
|
16
|
+
"plugins",
|
|
14
17
|
"types",
|
|
15
18
|
"index.d.ts",
|
|
16
19
|
"package.json",
|
|
17
20
|
"package-lock.json",
|
|
18
|
-
"README.md"
|
|
21
|
+
"README.md",
|
|
22
|
+
"!plugins/*/*.test.js",
|
|
23
|
+
"!plugins/*/example",
|
|
24
|
+
"!plugins/*/node_modules"
|
|
19
25
|
],
|
|
20
26
|
"bin": {
|
|
21
27
|
"config": "./cli.js",
|
|
@@ -56,6 +62,7 @@
|
|
|
56
62
|
"@davidwells/box-logger": "^2.0.3",
|
|
57
63
|
"@iarna/toml": "^2.2.5",
|
|
58
64
|
"dot-prop": "^5.3.0",
|
|
65
|
+
"dotenv": "^17.2.2",
|
|
59
66
|
"env-stage-loader": "^1.1.3",
|
|
60
67
|
"find-up": "^3.0.0",
|
|
61
68
|
"git-url-parse": "^14.0.0",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# CloudFormation variable source
|
|
2
|
+
|
|
3
|
+
Resolves CloudFormation stack output values in configorama configs.
|
|
4
|
+
|
|
5
|
+
## Syntax
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
${cf:stackName.outputKey} # default region, default credentials
|
|
9
|
+
${cf(region):stackName.outputKey} # explicit region, default credentials
|
|
10
|
+
${cf(account:region):stackName.outputKey} # explicit account alias + region
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const configorama = require('configorama')
|
|
17
|
+
const createCloudFormationResolver = require('configorama/plugins/cloudformation')
|
|
18
|
+
|
|
19
|
+
const cfResolver = createCloudFormationResolver({
|
|
20
|
+
defaultRegion: 'us-east-1', // optional
|
|
21
|
+
skipResolution: false, // true = collect metadata without calling AWS
|
|
22
|
+
// credentials: { ... } // optional: bypass env-var discovery
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const result = await configorama(configPath, {
|
|
26
|
+
returnMetadata: true,
|
|
27
|
+
variableSources: [cfResolver]
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Multi-account
|
|
32
|
+
|
|
33
|
+
Account aliases map to env-var prefixes. To make `${cf(prod:us-west-2):…}` work,
|
|
34
|
+
set:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export PROD_AWS_ACCESS_KEY_ID=AKIA...
|
|
38
|
+
export PROD_AWS_SECRET_ACCESS_KEY=...
|
|
39
|
+
export PROD_AWS_REGION=us-west-2 # optional; falls back to AWS_REGION
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The alias is the prefix lowercased, so `STAGING_AWS_ACCESS_KEY_ID` becomes
|
|
43
|
+
`staging`. The alias is **not** an AWS 12-digit account number; it's whatever
|
|
44
|
+
string you put before `_AWS_ACCESS_KEY_ID`.
|
|
45
|
+
|
|
46
|
+
### Credential discovery rules
|
|
47
|
+
|
|
48
|
+
1. Scan `process.env` for `{PREFIX}_AWS_ACCESS_KEY_ID` → register `{prefix}` (lowercased).
|
|
49
|
+
2. If `AWS_ACCESS_KEY_ID` is set unprefixed, register it as `default`.
|
|
50
|
+
3. If no `default` exists and there's exactly one prefixed set, use it as
|
|
51
|
+
`default` (and copy into unprefixed env vars so the AWS SDK can find them).
|
|
52
|
+
|
|
53
|
+
### Parallel safety
|
|
54
|
+
|
|
55
|
+
Multiple resolves for the **same** account run concurrently. Resolves for
|
|
56
|
+
**different** accounts are serialized via a refcounted mutex, since
|
|
57
|
+
`process.env` is process-global and two accounts can't be active at once.
|
|
58
|
+
|
|
59
|
+
## Metadata
|
|
60
|
+
|
|
61
|
+
Each resolved variable is recorded in `result.metadata.cfReferences`:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
{
|
|
65
|
+
raw: '${cf(prod:us-west-2):api-service-prod.ApiUrl}',
|
|
66
|
+
resolved: '${cf(prod:us-west-2):api-service-prod.ApiUrl}',
|
|
67
|
+
stackName: 'api-service-prod',
|
|
68
|
+
outputKey: 'ApiUrl',
|
|
69
|
+
region: 'us-west-2',
|
|
70
|
+
account: 'prod', // null when not multi-account
|
|
71
|
+
configPath: 'provider.environment.API_URL',
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Skip resolution
|
|
76
|
+
|
|
77
|
+
`skipResolution: true` collects metadata without calling AWS. Values are
|
|
78
|
+
replaced with placeholders like `[CF:prod:us-west-2:api-service-prod.ApiUrl]`,
|
|
79
|
+
useful in CI for dependency analysis.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* AWS credential discovery and runtime swapping for multi-account deployments */
|
|
2
|
+
|
|
3
|
+
const AWS_CRED_SUFFIXES = [
|
|
4
|
+
'AWS_ACCESS_KEY_ID',
|
|
5
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
6
|
+
'AWS_SESSION_TOKEN',
|
|
7
|
+
'AWS_REGION'
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
/** @type {Map<string, Object>|null} */
|
|
11
|
+
let credentialCache = null
|
|
12
|
+
|
|
13
|
+
// Mutex state for credential swapping (prevents race conditions in parallel deploys)
|
|
14
|
+
/** @type {string|null} */
|
|
15
|
+
let activeAccount = null
|
|
16
|
+
/** @type {number} */
|
|
17
|
+
let activeRefCount = 0
|
|
18
|
+
/** @type {Promise<void>|null} */
|
|
19
|
+
let lockPromise = null
|
|
20
|
+
/** @type {Function|null} */
|
|
21
|
+
let lockResolve = null
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} AwsCredentials
|
|
25
|
+
* @property {string} [AWS_ACCESS_KEY_ID]
|
|
26
|
+
* @property {string} [AWS_SECRET_ACCESS_KEY]
|
|
27
|
+
* @property {string} [AWS_SESSION_TOKEN]
|
|
28
|
+
* @property {string} [AWS_REGION]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Discover all credential sets from env vars matching {PREFIX}_AWS_ACCESS_KEY_ID.
|
|
33
|
+
* E.g., STAGING_AWS_ACCESS_KEY_ID, PROD_AWS_ACCESS_KEY_ID -> "staging", "prod".
|
|
34
|
+
*
|
|
35
|
+
* @returns {Map<string, AwsCredentials>} Map of account alias -> credentials
|
|
36
|
+
*/
|
|
37
|
+
function discoverCredentialSets() {
|
|
38
|
+
if (credentialCache) return credentialCache
|
|
39
|
+
|
|
40
|
+
const sets = new Map()
|
|
41
|
+
const pattern = /^(.+)_AWS_ACCESS_KEY_ID$/
|
|
42
|
+
|
|
43
|
+
for (const key of Object.keys(process.env)) {
|
|
44
|
+
const match = key.match(pattern)
|
|
45
|
+
if (match) {
|
|
46
|
+
const prefix = match[1]
|
|
47
|
+
const name = prefix.toLowerCase()
|
|
48
|
+
|
|
49
|
+
sets.set(name, {
|
|
50
|
+
AWS_ACCESS_KEY_ID: process.env[`${prefix}_AWS_ACCESS_KEY_ID`],
|
|
51
|
+
AWS_SECRET_ACCESS_KEY: process.env[`${prefix}_AWS_SECRET_ACCESS_KEY`],
|
|
52
|
+
AWS_SESSION_TOKEN: process.env[`${prefix}_AWS_SESSION_TOKEN`],
|
|
53
|
+
AWS_REGION: process.env[`${prefix}_AWS_REGION`] || process.env.AWS_REGION,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Capture "default" from unprefixed vars if present and not already discovered
|
|
59
|
+
if (process.env.AWS_ACCESS_KEY_ID && !sets.has('default')) {
|
|
60
|
+
sets.set('default', {
|
|
61
|
+
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
|
62
|
+
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
|
63
|
+
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
|
|
64
|
+
AWS_REGION: process.env.AWS_REGION,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Auto-default: if no unprefixed creds but exactly one prefixed set, use it as default
|
|
69
|
+
if (!sets.has('default') && sets.size === 1) {
|
|
70
|
+
const [, creds] = [...sets.entries()][0]
|
|
71
|
+
sets.set('default', creds)
|
|
72
|
+
for (const [key, value] of Object.entries(creds)) {
|
|
73
|
+
if (value && !process.env[key]) {
|
|
74
|
+
process.env[key] = value
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
credentialCache = sets
|
|
80
|
+
return sets
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get available account aliases.
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
function getAvailableAccounts() {
|
|
88
|
+
return [...discoverCredentialSets().keys()]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if credentials exist for an account alias.
|
|
93
|
+
* @param {string} account
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function hasCredentials(account) {
|
|
97
|
+
return discoverCredentialSets().has(account.toLowerCase())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get credentials for an account alias.
|
|
102
|
+
* @param {string} account
|
|
103
|
+
* @returns {AwsCredentials|null}
|
|
104
|
+
*/
|
|
105
|
+
function getCredentials(account) {
|
|
106
|
+
return discoverCredentialSets().get(account.toLowerCase()) || null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Snapshot current AWS env vars so they can be restored after a swap.
|
|
111
|
+
* @returns {AwsCredentials}
|
|
112
|
+
*/
|
|
113
|
+
function saveCurrentCredentials() {
|
|
114
|
+
const creds = {}
|
|
115
|
+
for (const suffix of AWS_CRED_SUFFIXES) {
|
|
116
|
+
if (process.env[suffix]) {
|
|
117
|
+
creds[suffix] = process.env[suffix]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return creds
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Replace AWS env vars with the supplied credentials.
|
|
125
|
+
* @param {AwsCredentials} creds
|
|
126
|
+
*/
|
|
127
|
+
function applyCredentials(creds) {
|
|
128
|
+
for (const suffix of AWS_CRED_SUFFIXES) {
|
|
129
|
+
delete process.env[suffix]
|
|
130
|
+
}
|
|
131
|
+
for (const [key, value] of Object.entries(creds)) {
|
|
132
|
+
if (value) process.env[key] = value
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Acquire credential lock for an account.
|
|
138
|
+
* Same account can have multiple concurrent holders (refcounted).
|
|
139
|
+
* Different account must wait for current holders to release.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} account
|
|
142
|
+
*/
|
|
143
|
+
async function acquireLock(account) {
|
|
144
|
+
while (activeAccount !== null && activeAccount !== account) {
|
|
145
|
+
if (lockPromise) {
|
|
146
|
+
await lockPromise
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (activeAccount === null) {
|
|
151
|
+
activeAccount = account
|
|
152
|
+
lockPromise = new Promise(resolve => { lockResolve = resolve })
|
|
153
|
+
}
|
|
154
|
+
activeRefCount++
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Release credential lock.
|
|
159
|
+
*/
|
|
160
|
+
function releaseLock() {
|
|
161
|
+
activeRefCount--
|
|
162
|
+
if (activeRefCount === 0) {
|
|
163
|
+
activeAccount = null
|
|
164
|
+
if (lockResolve) {
|
|
165
|
+
lockResolve()
|
|
166
|
+
lockResolve = null
|
|
167
|
+
lockPromise = null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Execute a function with specific account credentials.
|
|
174
|
+
* Uses a refcounted mutex so multiple resolves for the same account run in
|
|
175
|
+
* parallel but different accounts are serialized — env vars are global, so two
|
|
176
|
+
* accounts can't be active at once.
|
|
177
|
+
*
|
|
178
|
+
* @template T
|
|
179
|
+
* @param {string} account - Account alias (matches {ACCOUNT}_AWS_ACCESS_KEY_ID prefix, case-insensitive)
|
|
180
|
+
* @param {() => Promise<T>} fn - Async function to execute with swapped credentials
|
|
181
|
+
* @returns {Promise<T>}
|
|
182
|
+
*/
|
|
183
|
+
async function useCredentials(account, fn) {
|
|
184
|
+
const accountLower = account.toLowerCase()
|
|
185
|
+
const sets = discoverCredentialSets()
|
|
186
|
+
|
|
187
|
+
// If requesting default and we have unprefixed creds already active, no swap needed
|
|
188
|
+
if (accountLower === 'default' && !sets.has('default')) {
|
|
189
|
+
return fn()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const targetCreds = sets.get(accountLower)
|
|
193
|
+
if (!targetCreds) {
|
|
194
|
+
const available = [...sets.keys()].join(', ') || 'none'
|
|
195
|
+
throw new Error(`No credentials found for account "${account}". Available: ${available}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await acquireLock(accountLower)
|
|
199
|
+
const savedCreds = saveCurrentCredentials()
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
applyCredentials(targetCreds)
|
|
203
|
+
return await fn()
|
|
204
|
+
} finally {
|
|
205
|
+
applyCredentials(savedCreds)
|
|
206
|
+
releaseLock()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Clear the credential cache and reset mutex state (useful for testing).
|
|
212
|
+
*/
|
|
213
|
+
function clearCache() {
|
|
214
|
+
credentialCache = null
|
|
215
|
+
activeAccount = null
|
|
216
|
+
activeRefCount = 0
|
|
217
|
+
lockPromise = null
|
|
218
|
+
lockResolve = null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
discoverCredentialSets,
|
|
223
|
+
getAvailableAccounts,
|
|
224
|
+
hasCredentials,
|
|
225
|
+
getCredentials,
|
|
226
|
+
useCredentials,
|
|
227
|
+
clearCache,
|
|
228
|
+
// Low-level (exported for testing)
|
|
229
|
+
saveCurrentCredentials,
|
|
230
|
+
applyCredentials,
|
|
231
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/* CloudFormation stack output variable source */
|
|
2
|
+
const { useCredentials } = require('./credentials')
|
|
3
|
+
|
|
4
|
+
const CF_PREFIX = 'cf'
|
|
5
|
+
// Supports: cf:stack.output, cf(region):stack.output, cf(account:region):stack.output
|
|
6
|
+
const cfVariableSyntax = RegExp(/^cf(\([a-z0-9_-]+(:[a-z0-9_-]+)?\))?:/i)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a CloudFormation variable source resolver.
|
|
10
|
+
*
|
|
11
|
+
* Syntax:
|
|
12
|
+
* ${cf:stackName.outputKey} — default region, default creds
|
|
13
|
+
* ${cf(region):stackName.outputKey} — explicit region, default creds
|
|
14
|
+
* ${cf(account:region):stackName.outputKey} — explicit account alias + region
|
|
15
|
+
*
|
|
16
|
+
* `account` is an env-var-prefix alias matching `{ACCOUNT}_AWS_ACCESS_KEY_ID`
|
|
17
|
+
* (case-insensitive). E.g., with PROD_AWS_ACCESS_KEY_ID set, use `cf(prod:us-west-2):…`.
|
|
18
|
+
* See credentials.js for the discovery rules.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} options - Configuration options
|
|
21
|
+
* @param {object} [options.credentials] - AWS credentials (bypasses env-var discovery)
|
|
22
|
+
* @param {string} [options.defaultRegion] - Default region if not specified in variable
|
|
23
|
+
* @param {boolean} [options.skipResolution] - Skip AWS calls, just collect metadata
|
|
24
|
+
* @param {object} [options.clientOptions] - Additional options passed to CloudFormation client
|
|
25
|
+
* @returns {object} Variable source configuration with resolver and metadata collector
|
|
26
|
+
*/
|
|
27
|
+
function createCloudFormationResolver(options = {}) {
|
|
28
|
+
const {
|
|
29
|
+
credentials,
|
|
30
|
+
defaultRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
|
|
31
|
+
skipResolution = false,
|
|
32
|
+
clientOptions = {}
|
|
33
|
+
} = options
|
|
34
|
+
|
|
35
|
+
const cfReferences = []
|
|
36
|
+
|
|
37
|
+
// Clients are cached per (account, region) — different accounts cannot share a
|
|
38
|
+
// client because the provider chain memoizes credentials on first resolve.
|
|
39
|
+
const clientCache = new Map()
|
|
40
|
+
const outputCache = new Map()
|
|
41
|
+
|
|
42
|
+
async function getClient(region, account) {
|
|
43
|
+
const cacheKey = `${account || 'default'}:${region}`
|
|
44
|
+
if (clientCache.has(cacheKey)) {
|
|
45
|
+
return clientCache.get(cacheKey)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { CloudFormationClient } = await import('@aws-sdk/client-cloudformation')
|
|
49
|
+
const { fromNodeProviderChain } = await import('@aws-sdk/credential-providers')
|
|
50
|
+
|
|
51
|
+
const clientConfig = {
|
|
52
|
+
region,
|
|
53
|
+
credentials: credentials || fromNodeProviderChain(),
|
|
54
|
+
...clientOptions
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const client = new CloudFormationClient(clientConfig)
|
|
58
|
+
clientCache.set(cacheKey, client)
|
|
59
|
+
return client
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getStackOutput(stackName, outputKey, region, account = null) {
|
|
63
|
+
const cacheKey = `${account || 'default'}:${region}:${stackName}`
|
|
64
|
+
|
|
65
|
+
if (outputCache.has(cacheKey)) {
|
|
66
|
+
const outputs = outputCache.get(cacheKey)
|
|
67
|
+
const output = outputs.find(o => o.OutputKey === outputKey)
|
|
68
|
+
return output ? output.OutputValue : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fetchStack = async () => {
|
|
72
|
+
const client = await getClient(region, account)
|
|
73
|
+
const { DescribeStacksCommand } = await import('@aws-sdk/client-cloudformation')
|
|
74
|
+
const command = new DescribeStacksCommand({ StackName: stackName })
|
|
75
|
+
|
|
76
|
+
let response
|
|
77
|
+
try {
|
|
78
|
+
response = await client.send(command)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const code = err.Code || err.name
|
|
81
|
+
const accountInfo = account ? ` for account "${account}"` : ''
|
|
82
|
+
const messages = {
|
|
83
|
+
ExpiredToken: `AWS credentials expired${accountInfo}. Refresh your credentials and try again.`,
|
|
84
|
+
AccessDenied: `Access denied to CloudFormation stack "${stackName}" in ${region}${accountInfo}. Check IAM permissions.`,
|
|
85
|
+
ValidationError: `Stack "${stackName}" not found in ${region}${accountInfo}.`,
|
|
86
|
+
CredentialsProviderError: `No AWS credentials found${accountInfo}. Configure credentials via environment, ~/.aws/credentials, or pass explicitly.`,
|
|
87
|
+
}
|
|
88
|
+
throw new Error(messages[code] || `CloudFormation error: ${err.message}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!response.Stacks || response.Stacks.length === 0) {
|
|
92
|
+
throw new Error(`CloudFormation stack "${stackName}" not found in region "${region}"`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const outputs = response.Stacks[0].Outputs || []
|
|
96
|
+
outputCache.set(cacheKey, outputs)
|
|
97
|
+
|
|
98
|
+
const output = outputs.find(o => o.OutputKey === outputKey)
|
|
99
|
+
return output ? output.OutputValue : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (account) {
|
|
103
|
+
return useCredentials(account, fetchStack)
|
|
104
|
+
}
|
|
105
|
+
return fetchStack()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse cf: variable string.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} varString
|
|
112
|
+
* @returns {{stackName: string, outputKey: string, region: string, account: string|null}}
|
|
113
|
+
*/
|
|
114
|
+
function parseVariable(varString) {
|
|
115
|
+
let region = defaultRegion
|
|
116
|
+
let account = null
|
|
117
|
+
|
|
118
|
+
const paramsMatch = varString.match(/^cf\(([a-z0-9_-]+(?::[a-z0-9_-]+)?)\):/i)
|
|
119
|
+
if (paramsMatch) {
|
|
120
|
+
const params = paramsMatch[1]
|
|
121
|
+
if (params.includes(':')) {
|
|
122
|
+
const [accountPart, regionPart] = params.split(':')
|
|
123
|
+
account = accountPart
|
|
124
|
+
region = regionPart
|
|
125
|
+
} else {
|
|
126
|
+
region = params
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const withoutPrefix = varString.replace(/^cf(\([a-z0-9_-]+(?::[a-z0-9_-]+)?\))?:/i, '')
|
|
131
|
+
|
|
132
|
+
const dotIndex = withoutPrefix.indexOf('.')
|
|
133
|
+
if (dotIndex === -1) {
|
|
134
|
+
throw new Error(`Invalid cf: variable syntax "${varString}". Expected format: cf:stackName.outputKey`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const stackName = withoutPrefix.slice(0, dotIndex)
|
|
138
|
+
const outputKey = withoutPrefix.slice(dotIndex + 1)
|
|
139
|
+
|
|
140
|
+
if (!stackName || !outputKey) {
|
|
141
|
+
throw new Error(`Invalid cf: variable syntax "${varString}". Both stackName and outputKey are required.`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { stackName, outputKey, region, account }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function resolver(varString, opts, currentObject, valueObject) {
|
|
148
|
+
const { stackName, outputKey, region, account } = parseVariable(varString)
|
|
149
|
+
|
|
150
|
+
cfReferences.push({
|
|
151
|
+
raw: valueObject.originalSource,
|
|
152
|
+
resolved: `\${${varString}}`,
|
|
153
|
+
stackName,
|
|
154
|
+
outputKey,
|
|
155
|
+
region,
|
|
156
|
+
account,
|
|
157
|
+
configPath: valueObject.path.join('.')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
if (skipResolution) {
|
|
161
|
+
const accountInfo = account ? `${account}:` : ''
|
|
162
|
+
return `[CF:${accountInfo}${region}:${stackName}.${outputKey}]`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const value = await getStackOutput(stackName, outputKey, region, account)
|
|
166
|
+
|
|
167
|
+
if (value === null) {
|
|
168
|
+
const accountInfo = account ? `, account: ${account}` : ''
|
|
169
|
+
throw new Error(`Output "${outputKey}" not found in CloudFormation stack "${stackName}" (region: ${region}${accountInfo})`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return value
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
type: CF_PREFIX,
|
|
177
|
+
source: 'remote',
|
|
178
|
+
prefix: CF_PREFIX,
|
|
179
|
+
syntax: '${cf:stackName.outputKey}, ${cf(region):stackName.outputKey}, or ${cf(account:region):stackName.outputKey}',
|
|
180
|
+
description: 'Resolves CloudFormation stack output values (supports multi-region and multi-account)',
|
|
181
|
+
match: cfVariableSyntax,
|
|
182
|
+
resolver,
|
|
183
|
+
metadataKey: 'cfReferences',
|
|
184
|
+
collectMetadata: () => cfReferences,
|
|
185
|
+
clearCache: () => {
|
|
186
|
+
outputCache.clear()
|
|
187
|
+
clientCache.clear()
|
|
188
|
+
cfReferences.length = 0
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (require.main === module) {
|
|
194
|
+
const instance = createCloudFormationResolver()
|
|
195
|
+
instance.resolver(
|
|
196
|
+
'cf:rbac-service-v2-dev.RBACTableArn',
|
|
197
|
+
{},
|
|
198
|
+
{},
|
|
199
|
+
{ originalSource: '${cf:rbac-service-v2-dev.RBACTableArn}', path: ['provider', 'rbacTableArn'] }
|
|
200
|
+
).then(console.log).catch(console.error)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = createCloudFormationResolver
|
|
204
|
+
module.exports.cfVariableSyntax = cfVariableSyntax
|