rds_ssm_connect 1.3.9 → 1.5.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/.github/workflows/npm-publish.yml +1 -0
- package/CLAUDE.md +105 -0
- package/connect.js +212 -25
- package/package.json +10 -7
- package/test/connect.test.js +185 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
**rds_ssm_connect** is a Node.js CLI tool that enables secure connections to AWS RDS databases through AWS Systems Manager (SSM) port forwarding via bastion hosts. The tool reads AWS profiles from the user's config, retrieves database credentials from AWS Secrets Manager, and automatically sets up port forwarding through a bastion instance.
|
|
8
|
+
|
|
9
|
+
## Key Architecture
|
|
10
|
+
|
|
11
|
+
### Entry Point
|
|
12
|
+
- `connect.js` - Main executable (shebang: `#!/usr/bin/env node`)
|
|
13
|
+
- Reads AWS profiles from `~/.aws/config`
|
|
14
|
+
- Uses `inquirer` for interactive environment selection
|
|
15
|
+
- Executes AWS commands via `aws-vault` wrapper
|
|
16
|
+
- Establishes SSM port forwarding session
|
|
17
|
+
|
|
18
|
+
### Configuration
|
|
19
|
+
- `envPortMapping.js` - Environment-to-port mapping configuration
|
|
20
|
+
- Maps environment suffixes (dev, stage, prod, team1-5, qa1-5, etc.) to local port numbers (5433-5452)
|
|
21
|
+
- Defines default database name: `emr`
|
|
22
|
+
- Defines AWS region: `us-east-2`
|
|
23
|
+
- **Critical**: When adding new environments, update this mapping to assign unique ports
|
|
24
|
+
|
|
25
|
+
### Core Flow
|
|
26
|
+
1. Read AWS profiles from `~/.aws/config`
|
|
27
|
+
2. Prompt user to select environment (AWS profile)
|
|
28
|
+
3. Query Secrets Manager for RDS credentials (secrets starting with `rds!cluster`)
|
|
29
|
+
4. Find running bastion instance (tagged with `Name=*bastion*`)
|
|
30
|
+
5. Get RDS Aurora cluster endpoint (cluster IDs ending with `-rds-aurora`)
|
|
31
|
+
6. Start SSM port forwarding session
|
|
32
|
+
7. Display connection details for database client
|
|
33
|
+
|
|
34
|
+
## Development Commands
|
|
35
|
+
|
|
36
|
+
### Installation
|
|
37
|
+
```bash
|
|
38
|
+
npm install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Testing
|
|
42
|
+
```bash
|
|
43
|
+
npm test
|
|
44
|
+
```
|
|
45
|
+
Tests cover:
|
|
46
|
+
- AWS config parsing
|
|
47
|
+
- Port mapping logic
|
|
48
|
+
- Credentials validation
|
|
49
|
+
- Retry configuration validation
|
|
50
|
+
|
|
51
|
+
### Local Testing
|
|
52
|
+
```bash
|
|
53
|
+
node connect.js
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Global Installation (for testing as installed package)
|
|
57
|
+
```bash
|
|
58
|
+
npm install -g .
|
|
59
|
+
rds_ssm_connect
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Publishing
|
|
63
|
+
Package is published to npm via GitHub Actions workflow (`.github/workflows/npm-publish.yml`) when a release is created.
|
|
64
|
+
|
|
65
|
+
## Prerequisites for Running
|
|
66
|
+
|
|
67
|
+
- `aws-vault` - Required for AWS credential management
|
|
68
|
+
- AWS CLI - Required for AWS API calls
|
|
69
|
+
- Node.js (ES modules enabled via `"type": "module"` in package.json)
|
|
70
|
+
- Properly configured `~/.aws/config` with named profiles
|
|
71
|
+
|
|
72
|
+
## AWS Resource Naming Conventions
|
|
73
|
+
|
|
74
|
+
The application relies on specific AWS resource naming patterns:
|
|
75
|
+
- **Secrets**: Must start with `rds!cluster`
|
|
76
|
+
- **Bastion instances**: Must be tagged with `Name=*bastion*` and in `running` state
|
|
77
|
+
- **RDS clusters**: DBClusterIdentifier must end with `-rds-aurora` and be in `available` state
|
|
78
|
+
|
|
79
|
+
## Important Notes
|
|
80
|
+
|
|
81
|
+
- Port assignment is based on longest-matching environment suffix (e.g., `my-team-dev` matches `dev` → port 5433)
|
|
82
|
+
- The tool keeps the SSM session running until Ctrl+C is pressed
|
|
83
|
+
- Default port is 5432 if no environment suffix matches
|
|
84
|
+
- All AWS operations use the `us-east-2` region by default
|
|
85
|
+
|
|
86
|
+
## Error Handling & Recovery
|
|
87
|
+
|
|
88
|
+
### TargetNotConnected Recovery
|
|
89
|
+
The application automatically handles the race condition where a bastion instance appears running but SSM agent is not connected:
|
|
90
|
+
|
|
91
|
+
1. Detects `TargetNotConnected` error (exit code 254)
|
|
92
|
+
2. Terminates the disconnected bastion instance
|
|
93
|
+
3. Waits for ASG to spin up a new instance (max 20 retries @ 15s intervals)
|
|
94
|
+
4. Verifies SSM agent is online using `describe-instance-information`
|
|
95
|
+
5. Retries port forwarding with new instance (max 2 retries)
|
|
96
|
+
|
|
97
|
+
### Configuration Constants
|
|
98
|
+
All retry/timeout values are configurable in `RETRY_CONFIG`:
|
|
99
|
+
- `BASTION_WAIT_MAX_RETRIES`: 20 (time to wait for new instance)
|
|
100
|
+
- `BASTION_WAIT_RETRY_DELAY_MS`: 15000 (delay between instance checks)
|
|
101
|
+
- `PORT_FORWARDING_MAX_RETRIES`: 2 (connection retry attempts)
|
|
102
|
+
- `SSM_AGENT_READY_WAIT_MS`: 10000 (stabilization time after agent online)
|
|
103
|
+
|
|
104
|
+
### Process Cleanup
|
|
105
|
+
The application registers handlers for `SIGINT`, `SIGTERM`, and `exit` events to properly clean up child processes (SSM sessions) to prevent zombie processes.
|
package/connect.js
CHANGED
|
@@ -10,6 +10,34 @@ import { envPortMapping, REGION, TABLE_NAME } from './envPortMapping.js'
|
|
|
10
10
|
|
|
11
11
|
const execAsync = promisify(exec)
|
|
12
12
|
|
|
13
|
+
// Configuration constants
|
|
14
|
+
const RETRY_CONFIG = {
|
|
15
|
+
BASTION_WAIT_MAX_RETRIES: 20,
|
|
16
|
+
BASTION_WAIT_RETRY_DELAY_MS: 15000,
|
|
17
|
+
PORT_FORWARDING_MAX_RETRIES: 2,
|
|
18
|
+
SSM_AGENT_READY_WAIT_MS: 10000
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Store active child processes for cleanup
|
|
22
|
+
let activeChildProcesses = []
|
|
23
|
+
|
|
24
|
+
// Handle graceful shutdown
|
|
25
|
+
function setupProcessCleanup () {
|
|
26
|
+
const cleanup = () => {
|
|
27
|
+
console.log('\nCleaning up active connections...')
|
|
28
|
+
activeChildProcesses.forEach(child => {
|
|
29
|
+
if (child && !child.killed) {
|
|
30
|
+
child.kill('SIGTERM')
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
process.exit(0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
process.on('SIGINT', cleanup)
|
|
37
|
+
process.on('SIGTERM', cleanup)
|
|
38
|
+
process.on('exit', cleanup)
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
async function readAwsConfig () {
|
|
14
42
|
const awsConfigPath = path.join(os.homedir(), '.aws', 'config')
|
|
15
43
|
try {
|
|
@@ -36,7 +64,171 @@ async function runCommand (command) {
|
|
|
36
64
|
}
|
|
37
65
|
}
|
|
38
66
|
|
|
67
|
+
async function sleep (ms) {
|
|
68
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function terminateBastionInstance (ENV, instanceId) {
|
|
72
|
+
console.log(`Terminating disconnected bastion instance: ${instanceId}`)
|
|
73
|
+
const terminateCommand = `aws-vault exec ${ENV} -- aws ec2 terminate-instances --region ${REGION} --instance-ids ${instanceId}`
|
|
74
|
+
await runCommand(terminateCommand)
|
|
75
|
+
console.log('Bastion instance terminated. ASG will spin up a new instance...')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function waitForNewBastionInstance (ENV, oldInstanceId, maxRetries = RETRY_CONFIG.BASTION_WAIT_MAX_RETRIES, retryDelay = RETRY_CONFIG.BASTION_WAIT_RETRY_DELAY_MS) {
|
|
79
|
+
console.log('Waiting for new bastion instance to be ready...')
|
|
80
|
+
|
|
81
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
82
|
+
console.log(`Checking for new bastion instance (attempt ${attempt}/${maxRetries})...`)
|
|
83
|
+
|
|
84
|
+
const instanceIdCommand = `aws-vault exec ${ENV} -- aws ec2 describe-instances --region ${REGION} --filters "Name=tag:Name,Values='*bastion*'" "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId] | [0][0]" --output text`
|
|
85
|
+
const newInstanceId = await runCommand(instanceIdCommand)
|
|
86
|
+
|
|
87
|
+
if (newInstanceId && newInstanceId !== oldInstanceId && newInstanceId !== 'None') {
|
|
88
|
+
console.log(`New bastion instance found: ${newInstanceId}`)
|
|
89
|
+
|
|
90
|
+
// Verify SSM agent is ready
|
|
91
|
+
const isReady = await waitForSSMAgentReady(ENV, newInstanceId)
|
|
92
|
+
if (isReady) {
|
|
93
|
+
return newInstanceId
|
|
94
|
+
} else {
|
|
95
|
+
console.log('SSM agent not ready yet, will retry...')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (attempt < maxRetries) {
|
|
100
|
+
await sleep(retryDelay)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function waitForSSMAgentReady (ENV, instanceId, maxRetries = 10, retryDelay = 3000) {
|
|
108
|
+
console.log('Verifying SSM agent status...')
|
|
109
|
+
|
|
110
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
111
|
+
const ssmStatusCommand = `aws-vault exec ${ENV} -- aws ssm describe-instance-information --region ${REGION} --filters "Key=InstanceIds,Values=${instanceId}" --query "InstanceInformationList[0].PingStatus" --output text`
|
|
112
|
+
const status = await runCommand(ssmStatusCommand)
|
|
113
|
+
|
|
114
|
+
if (status === 'Online') {
|
|
115
|
+
console.log('SSM agent is online and ready.')
|
|
116
|
+
// Additional wait for agent to stabilize
|
|
117
|
+
await sleep(RETRY_CONFIG.SSM_AGENT_READY_WAIT_MS)
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(`SSM agent status: ${status || 'Unknown'} (attempt ${attempt}/${maxRetries})`)
|
|
122
|
+
|
|
123
|
+
if (attempt < maxRetries) {
|
|
124
|
+
await sleep(retryDelay)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log('Warning: SSM agent did not report Online status, but proceeding anyway.')
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function monitorPortForwardingSession (child) {
|
|
133
|
+
const state = {
|
|
134
|
+
stderrOutput: '',
|
|
135
|
+
targetNotConnectedError: false,
|
|
136
|
+
sessionEstablished: false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
child.stdout.on('data', (data) => {
|
|
140
|
+
const output = data.toString()
|
|
141
|
+
|
|
142
|
+
// Detect when session is actually established
|
|
143
|
+
if (output.includes('Starting session with SessionId:')) {
|
|
144
|
+
state.sessionEstablished = true
|
|
145
|
+
console.log('Port forwarding session established.')
|
|
146
|
+
console.log('Press Ctrl+C to end the session.')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!output.includes('Starting session with SessionId:') && !output.includes('Port 5433 opened for sessionId')) {
|
|
150
|
+
console.log(output)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
child.stderr.on('data', (data) => {
|
|
155
|
+
const errorOutput = data.toString()
|
|
156
|
+
state.stderrOutput += errorOutput
|
|
157
|
+
|
|
158
|
+
// Check for TargetNotConnected error
|
|
159
|
+
if (errorOutput.includes('TargetNotConnected') || errorOutput.includes('is not connected')) {
|
|
160
|
+
state.targetNotConnectedError = true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.error(errorOutput)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
return state
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function handleTargetNotConnectedError (ENV, instanceId, rdsEndpoint, portNumber, retryCount, maxRetries) {
|
|
170
|
+
console.log(`\nDetected TargetNotConnected error. Attempting recovery (retry ${retryCount + 1}/${maxRetries})...`)
|
|
171
|
+
|
|
172
|
+
// Terminate the disconnected instance
|
|
173
|
+
await terminateBastionInstance(ENV, instanceId)
|
|
174
|
+
|
|
175
|
+
// Wait for new instance to be ready
|
|
176
|
+
const newInstanceId = await waitForNewBastionInstance(ENV, instanceId)
|
|
177
|
+
|
|
178
|
+
if (!newInstanceId) {
|
|
179
|
+
throw new Error('Failed to find new bastion instance after waiting.')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Retry with new instance
|
|
183
|
+
console.log('Retrying port forwarding with new bastion instance...')
|
|
184
|
+
return await startPortForwarding(ENV, newInstanceId, rdsEndpoint, portNumber, retryCount + 1, maxRetries)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function executePortForwardingCommand (ENV, instanceId, rdsEndpoint, portNumber) {
|
|
188
|
+
const portForwardingCommand = `aws-vault exec ${ENV} -- aws ssm start-session --target ${instanceId} --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters "host=${rdsEndpoint},portNumber='5432',localPortNumber='${portNumber}'" --cli-connect-timeout 0`
|
|
189
|
+
|
|
190
|
+
console.log('Starting port forwarding session...')
|
|
191
|
+
const child = exec(portForwardingCommand)
|
|
192
|
+
|
|
193
|
+
// Register child process for cleanup
|
|
194
|
+
activeChildProcesses.push(child)
|
|
195
|
+
|
|
196
|
+
return child
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function startPortForwarding (ENV, instanceId, rdsEndpoint, portNumber, retryCount = 0, maxRetries = RETRY_CONFIG.PORT_FORWARDING_MAX_RETRIES) {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const child = executePortForwardingCommand(ENV, instanceId, rdsEndpoint, portNumber)
|
|
202
|
+
const sessionState = monitorPortForwardingSession(child)
|
|
203
|
+
|
|
204
|
+
child.on('close', async (code) => {
|
|
205
|
+
// Remove from active processes
|
|
206
|
+
activeChildProcesses = activeChildProcesses.filter(p => p !== child)
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
// Handle TargetNotConnected error with retry
|
|
210
|
+
if (code === 254 && sessionState.targetNotConnectedError && retryCount < maxRetries) {
|
|
211
|
+
await handleTargetNotConnectedError(ENV, instanceId, rdsEndpoint, portNumber, retryCount, maxRetries)
|
|
212
|
+
resolve()
|
|
213
|
+
} else if (code !== 0) {
|
|
214
|
+
console.log(`Port forwarding session ended with code ${code}`)
|
|
215
|
+
reject(new Error(`Port forwarding failed with code ${code}`))
|
|
216
|
+
} else {
|
|
217
|
+
console.log(`Port forwarding session ended with code ${code}`)
|
|
218
|
+
resolve()
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error('Error during recovery:', error)
|
|
222
|
+
reject(error)
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
39
228
|
async function main () {
|
|
229
|
+
// Setup process cleanup handlers
|
|
230
|
+
setupProcessCleanup()
|
|
231
|
+
|
|
40
232
|
try {
|
|
41
233
|
const ENVS = await readAwsConfig()
|
|
42
234
|
|
|
@@ -69,7 +261,23 @@ async function main () {
|
|
|
69
261
|
|
|
70
262
|
const secretsGetCommand = `aws-vault exec ${ENV} -- aws secretsmanager get-secret-value --region ${REGION} --secret-id "${SECRET_NAME}" --query SecretString --output text`
|
|
71
263
|
const secretString = await runCommand(secretsGetCommand)
|
|
72
|
-
|
|
264
|
+
|
|
265
|
+
if (!secretString) {
|
|
266
|
+
console.error('Failed to retrieve secret value from Secrets Manager.')
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let CREDENTIALS
|
|
271
|
+
try {
|
|
272
|
+
CREDENTIALS = JSON.parse(secretString)
|
|
273
|
+
if (!CREDENTIALS.username || !CREDENTIALS.password) {
|
|
274
|
+
throw new Error('Missing username or password in credentials')
|
|
275
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
console.error('Failed to parse credentials from Secrets Manager:', error.message)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
73
281
|
const USERNAME = CREDENTIALS.username
|
|
74
282
|
const PASSWORD = CREDENTIALS.password
|
|
75
283
|
|
|
@@ -83,7 +291,7 @@ async function main () {
|
|
|
83
291
|
const instanceIdCommand = `aws-vault exec ${ENV} -- aws ec2 describe-instances --region ${REGION} --filters "Name=tag:Name,Values='*bastion*'" "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId] | [0][0]" --output text`
|
|
84
292
|
const INSTANCE_ID = await runCommand(instanceIdCommand)
|
|
85
293
|
|
|
86
|
-
if (!INSTANCE_ID) {
|
|
294
|
+
if (!INSTANCE_ID || INSTANCE_ID === 'None') {
|
|
87
295
|
console.error('Failed to find a running instance with tag Name=*bastion*.')
|
|
88
296
|
return
|
|
89
297
|
}
|
|
@@ -91,33 +299,12 @@ async function main () {
|
|
|
91
299
|
const rdsEndpointCommand = `aws-vault exec ${ENV} -- aws rds describe-db-clusters --region ${REGION} --query "DBClusters[?Status=='available' && ends_with(DBClusterIdentifier, '-rds-aurora')].Endpoint | [0]" --output text`
|
|
92
300
|
const RDS_ENDPOINT = await runCommand(rdsEndpointCommand)
|
|
93
301
|
|
|
94
|
-
if (!RDS_ENDPOINT) {
|
|
302
|
+
if (!RDS_ENDPOINT || RDS_ENDPOINT === 'None') {
|
|
95
303
|
console.error('Failed to find the RDS endpoint.')
|
|
96
304
|
return
|
|
97
305
|
}
|
|
98
306
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
console.log('Starting port forwarding session...')
|
|
102
|
-
const child = exec(portForwardingCommand)
|
|
103
|
-
|
|
104
|
-
child.stdout.on('data', (data) => {
|
|
105
|
-
const output = data.toString()
|
|
106
|
-
if (!output.includes('Starting session with SessionId:') && !output.includes('Port 5433 opened for sessionId')) {
|
|
107
|
-
console.log(output)
|
|
108
|
-
}
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
child.stderr.on('data', (data) => {
|
|
112
|
-
console.error(data.toString())
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
child.on('close', (code) => {
|
|
116
|
-
console.log(`Port forwarding session ended with code ${code}`)
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
console.log('Port forwarding session established.')
|
|
120
|
-
console.log('Press Ctrl+C to end the session.')
|
|
307
|
+
await startPortForwarding(ENV, INSTANCE_ID, RDS_ENDPOINT, portNumber)
|
|
121
308
|
} catch (error) {
|
|
122
309
|
console.error(`Error: ${error.message}`)
|
|
123
310
|
console.error('Exiting due to unhandled error')
|
package/package.json
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rds_ssm_connect",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@aws-sdk/client-ec2": "^3.
|
|
7
|
-
"@aws-sdk/client-rds": "^3.
|
|
8
|
-
"@aws-sdk/client-ssm": "^3.
|
|
9
|
-
"glob": "^
|
|
10
|
-
"inquirer": "^
|
|
11
|
-
"rimraf": "^6.
|
|
6
|
+
"@aws-sdk/client-ec2": "^3.943.0",
|
|
7
|
+
"@aws-sdk/client-rds": "^3.943.0",
|
|
8
|
+
"@aws-sdk/client-ssm": "^3.943.0",
|
|
9
|
+
"glob": "^13.0.0",
|
|
10
|
+
"inquirer": "^13.0.2",
|
|
11
|
+
"rimraf": "^6.1.2"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
14
|
"rds_ssm_connect": "./connect.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/**/*.test.js"
|
|
15
18
|
}
|
|
16
19
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFile } from 'fs/promises'
|
|
4
|
+
import { tmpdir } from 'os'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { writeFile, mkdir, rm } from 'fs/promises'
|
|
7
|
+
|
|
8
|
+
// Test AWS config parsing
|
|
9
|
+
describe('AWS Config Parsing', () => {
|
|
10
|
+
let testConfigDir
|
|
11
|
+
|
|
12
|
+
before(async () => {
|
|
13
|
+
testConfigDir = join(tmpdir(), `test-aws-config-${Date.now()}`)
|
|
14
|
+
await mkdir(testConfigDir, { recursive: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
after(async () => {
|
|
18
|
+
await rm(testConfigDir, { recursive: true, force: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should parse AWS config profiles correctly', async () => {
|
|
22
|
+
const configContent = `
|
|
23
|
+
[profile dev]
|
|
24
|
+
region = us-east-2
|
|
25
|
+
|
|
26
|
+
[profile stage]
|
|
27
|
+
region = us-east-2
|
|
28
|
+
|
|
29
|
+
[profile prod]
|
|
30
|
+
region = us-east-1
|
|
31
|
+
`
|
|
32
|
+
const configPath = join(testConfigDir, 'config')
|
|
33
|
+
await writeFile(configPath, configContent)
|
|
34
|
+
|
|
35
|
+
const content = await readFile(configPath, { encoding: 'utf-8' })
|
|
36
|
+
const profiles = content
|
|
37
|
+
.split(/\r?\n/)
|
|
38
|
+
.filter(line => line.startsWith('[') && line.endsWith(']'))
|
|
39
|
+
.map(line => line.slice(1, -1))
|
|
40
|
+
.map(line => line.replace('profile ', '').trim())
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(profiles, ['dev', 'stage', 'prod'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should handle empty config file', async () => {
|
|
46
|
+
const configPath = join(testConfigDir, 'empty-config')
|
|
47
|
+
await writeFile(configPath, '')
|
|
48
|
+
|
|
49
|
+
const content = await readFile(configPath, { encoding: 'utf-8' })
|
|
50
|
+
const profiles = content
|
|
51
|
+
.split(/\r?\n/)
|
|
52
|
+
.filter(line => line.startsWith('[') && line.endsWith(']'))
|
|
53
|
+
.map(line => line.slice(1, -1))
|
|
54
|
+
.map(line => line.replace('profile ', '').trim())
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(profiles, [])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should handle malformed config entries', async () => {
|
|
60
|
+
const configContent = `
|
|
61
|
+
[profile valid-env]
|
|
62
|
+
region = us-east-2
|
|
63
|
+
|
|
64
|
+
this is not a profile
|
|
65
|
+
[another-invalid
|
|
66
|
+
|
|
67
|
+
[profile another-valid]
|
|
68
|
+
`
|
|
69
|
+
const configPath = join(testConfigDir, 'malformed-config')
|
|
70
|
+
await writeFile(configPath, configContent)
|
|
71
|
+
|
|
72
|
+
const content = await readFile(configPath, { encoding: 'utf-8' })
|
|
73
|
+
const profiles = content
|
|
74
|
+
.split(/\r?\n/)
|
|
75
|
+
.filter(line => line.startsWith('[') && line.endsWith(']'))
|
|
76
|
+
.map(line => line.slice(1, -1))
|
|
77
|
+
.map(line => line.replace('profile ', '').trim())
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(profiles, ['valid-env', 'another-valid'])
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Test port mapping logic
|
|
84
|
+
describe('Port Mapping', () => {
|
|
85
|
+
it('should map environment suffix to correct port', () => {
|
|
86
|
+
const envPortMapping = {
|
|
87
|
+
dev: '5433',
|
|
88
|
+
stage: '5434',
|
|
89
|
+
'pre-prod': '5435',
|
|
90
|
+
prod: '5436',
|
|
91
|
+
team1: '5442'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const testCases = [
|
|
95
|
+
{ env: 'my-project-dev', expected: '5433' },
|
|
96
|
+
{ env: 'my-project-stage', expected: '5434' },
|
|
97
|
+
{ env: 'my-project-prod', expected: '5436' },
|
|
98
|
+
{ env: 'my-project-team1', expected: '5442' },
|
|
99
|
+
{ env: 'unknown-env', expected: '5432' } // default
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
testCases.forEach(({ env, expected }) => {
|
|
103
|
+
const allEnvSuffixes = Object.keys(envPortMapping).sort((a, b) => b.length - a.length)
|
|
104
|
+
const matchedSuffix = allEnvSuffixes.find(suffix => env.endsWith(suffix))
|
|
105
|
+
const portNumber = envPortMapping[matchedSuffix] || '5432'
|
|
106
|
+
|
|
107
|
+
assert.equal(portNumber, expected, `Failed for env: ${env}`)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should handle longest matching suffix', () => {
|
|
112
|
+
const envPortMapping = {
|
|
113
|
+
dev: '5433',
|
|
114
|
+
'perf-dev': '5440'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const env = 'my-project-perf-dev'
|
|
118
|
+
const allEnvSuffixes = Object.keys(envPortMapping).sort((a, b) => b.length - a.length)
|
|
119
|
+
const matchedSuffix = allEnvSuffixes.find(suffix => env.endsWith(suffix))
|
|
120
|
+
const portNumber = envPortMapping[matchedSuffix] || '5432'
|
|
121
|
+
|
|
122
|
+
// Should match 'perf-dev' not 'dev'
|
|
123
|
+
assert.equal(portNumber, '5440')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Test credentials parsing
|
|
128
|
+
describe('Credentials Parsing', () => {
|
|
129
|
+
it('should parse valid credentials JSON', () => {
|
|
130
|
+
const secretString = JSON.stringify({
|
|
131
|
+
username: 'testuser',
|
|
132
|
+
password: 'testpass123'
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const credentials = JSON.parse(secretString)
|
|
136
|
+
|
|
137
|
+
assert.equal(credentials.username, 'testuser')
|
|
138
|
+
assert.equal(credentials.password, 'testpass123')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('should detect missing username', () => {
|
|
142
|
+
const secretString = JSON.stringify({
|
|
143
|
+
password: 'testpass123'
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const credentials = JSON.parse(secretString)
|
|
147
|
+
|
|
148
|
+
assert.ok(!credentials.username, 'Username should be missing')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should detect missing password', () => {
|
|
152
|
+
const secretString = JSON.stringify({
|
|
153
|
+
username: 'testuser'
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const credentials = JSON.parse(secretString)
|
|
157
|
+
|
|
158
|
+
assert.ok(!credentials.password, 'Password should be missing')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should throw on malformed JSON', () => {
|
|
162
|
+
const secretString = '{ invalid json }'
|
|
163
|
+
|
|
164
|
+
assert.throws(() => {
|
|
165
|
+
JSON.parse(secretString)
|
|
166
|
+
}, SyntaxError)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// Test retry configuration
|
|
171
|
+
describe('Retry Configuration', () => {
|
|
172
|
+
it('should have valid retry values', () => {
|
|
173
|
+
const RETRY_CONFIG = {
|
|
174
|
+
BASTION_WAIT_MAX_RETRIES: 20,
|
|
175
|
+
BASTION_WAIT_RETRY_DELAY_MS: 15000,
|
|
176
|
+
PORT_FORWARDING_MAX_RETRIES: 2,
|
|
177
|
+
SSM_AGENT_READY_WAIT_MS: 10000
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
assert.ok(RETRY_CONFIG.BASTION_WAIT_MAX_RETRIES > 0, 'Max retries should be positive')
|
|
181
|
+
assert.ok(RETRY_CONFIG.BASTION_WAIT_RETRY_DELAY_MS > 0, 'Retry delay should be positive')
|
|
182
|
+
assert.ok(RETRY_CONFIG.PORT_FORWARDING_MAX_RETRIES >= 0, 'Max retries should be non-negative')
|
|
183
|
+
assert.ok(RETRY_CONFIG.SSM_AGENT_READY_WAIT_MS > 0, 'SSM wait should be positive')
|
|
184
|
+
})
|
|
185
|
+
})
|