diff --git a/.nvmrc b/.nvmrc index 1d898f1..6edc5a2 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.19.2 +v22.17.0 diff --git a/src/commands/aws/ecs/ecs.ts b/src/commands/aws/ecs/ecs.ts new file mode 100644 index 0000000..43f52b4 --- /dev/null +++ b/src/commands/aws/ecs/ecs.ts @@ -0,0 +1,12 @@ +import { GluegunMenuToolbox } from '@lenne.tech/gluegun-menu'; +import { defaultMenuSettings } from '../../../globals'; + +module.exports = { + name: 'ecs', + alias: ['e'], + description: 'Working with ECS on AWS (a e)', + hidden: false, + run: async (toolbox: GluegunMenuToolbox) => { + await toolbox.menu.showMenu('aws ecs', defaultMenuSettings); + }, +}; diff --git a/src/commands/aws/ecs/sshtestserver/sshtestserver.ts b/src/commands/aws/ecs/sshtestserver/sshtestserver.ts new file mode 100644 index 0000000..4944300 --- /dev/null +++ b/src/commands/aws/ecs/sshtestserver/sshtestserver.ts @@ -0,0 +1,237 @@ +import { GluegunMenuToolbox } from '@lenne.tech/gluegun-menu'; +import chalk = require('chalk'); +import { defaultMenuSettings } from '../../../../globals'; +import { getTestStacksInfo } from '../../../../common/testservers'; +const { AutoComplete } = require('enquirer'); + +module.exports = { + name: 'ssh-to-test-server', + alias: ['sts'], + description: 'SSH into a test server in ECS (a e sts)', + hidden: false, + run: async (toolbox: GluegunMenuToolbox) => { + const { system, strings, print } = toolbox; + + try { + // Get all test stacks + print.info('Fetching available test servers...'); + const testStacks = await getTestStacksInfo(toolbox); + + if (testStacks.length === 0) { + print.error('No test servers found'); + return; + } + + // Filter only running/complete stacks + const availableStacks = testStacks.filter((stack) => + ['CREATE_COMPLETE', 'UPDATE_COMPLETE'].includes(stack.status), + ); + + if (availableStacks.length === 0) { + print.error('No running test servers found'); + return; + } + + // Create options for server selection + const options = availableStacks.map((stack) => { + const statusColor = + stack.status === 'CREATE_COMPLETE' || + stack.status === 'UPDATE_COMPLETE' + ? chalk.green('✓') + : chalk.yellow('⚠'); + return { + name: `${statusColor} ${stack.name} (${stack.branch})`, + value: stack.name, + }; + }); + + // Let user choose a server + const prompt = new AutoComplete({ + name: 'testserver', + message: 'Choose a test server to SSH into:', + choices: options, + limit: Math.min(options.length, 10), + }); + + const selectedStackName = await prompt.run(); + const selectedStack = availableStacks.find( + (stack) => stack.name === selectedStackName, + ); + + if (!selectedStack) { + print.error('Selected stack not found'); + return; + } + + print.info(`Selected: ${chalk.yellow(selectedStackName)}`); + + // Get the actual ECS cluster name from CloudFormation stack + print.info('Getting ECS cluster name from CloudFormation stack...'); + const stackDetailsResponse = await system.run( + `aws cloudformation describe-stacks --stack-name ${selectedStackName} --output json`, + { trim: true }, + ); + + const stackDetails = JSON.parse(stackDetailsResponse); + + if (!stackDetails.Stacks || stackDetails.Stacks.length === 0) { + print.error('Stack details not found'); + return; + } + + const stack = stackDetails.Stacks[0]; + let clusterName = null; + + // Try to find cluster name from stack outputs first + if (stack.Outputs) { + const clusterOutput = stack.Outputs.find( + (output) => + output.OutputKey.toLowerCase().includes('cluster') || + output.OutputKey.toLowerCase().includes('ecs'), + ); + if (clusterOutput) { + clusterName = clusterOutput.OutputValue; + } + } + + // If not found in outputs, try to get from stack resources + if (!clusterName) { + print.info( + 'Cluster name not found in outputs, checking stack resources...', + ); + const resourcesResponse = await system.run( + `aws cloudformation describe-stack-resources --stack-name ${selectedStackName} --output json`, + { trim: true }, + ); + + const resources = JSON.parse(resourcesResponse); + const clusterResource = resources.StackResources.find( + (resource) => resource.ResourceType === 'AWS::ECS::Cluster', + ); + + if (clusterResource) { + clusterName = clusterResource.PhysicalResourceId; + } + } + + // If still not found, fall back to stack name (original behavior) + if (!clusterName) { + print.warning( + 'Could not find ECS cluster name from stack, using stack name as fallback', + ); + clusterName = selectedStackName; + } + + print.info(`Using ECS cluster: ${chalk.cyan(clusterName)}`); + + // Get running tasks for the cluster + print.info('Fetching ECS tasks...'); + const tasksResponse = await system.run( + `aws ecs list-tasks --cluster ${clusterName} --desired-status RUNNING --output json`, + { trim: true }, + ); + + const tasks = JSON.parse(tasksResponse); + + if (!tasks.taskArns || tasks.taskArns.length === 0) { + print.error(`No running tasks found in cluster ${clusterName}`); + return; + } + + // Get task details to find containers + print.info('Getting task details...'); + const taskDetailsResponse = await system.run( + `aws ecs describe-tasks --cluster ${clusterName} --tasks ${tasks.taskArns.join(' ')} --output json`, + { trim: true }, + ); + + const taskDetails = JSON.parse(taskDetailsResponse); + + if (!taskDetails.tasks || taskDetails.tasks.length === 0) { + print.error('No task details found'); + return; + } + + // Find the first task with containers + let selectedTask = null; + let selectedContainer = null; + + for (const task of taskDetails.tasks) { + if (task.containers && task.containers.length > 0) { + selectedTask = task; + // Try to find a web/app container first, otherwise use the first one + const webContainer = task.containers.find( + (container) => + container.name.toLowerCase().includes('web') || + container.name.toLowerCase().includes('app') || + container.name.toLowerCase().includes('photowall'), + ); + selectedContainer = webContainer || task.containers[0]; + break; + } + } + + if (!selectedTask || !selectedContainer) { + print.error('No suitable task/container found'); + return; + } + + // Extract task ID from ARN + const taskId = selectedTask.taskArn.split('/').pop(); + + print.info(`Task ID: ${chalk.cyan(taskId)}`); + print.info(`Container: ${chalk.cyan(selectedContainer.name)}`); + print.info(`Cluster: ${chalk.cyan(clusterName)}`); + + // Build the SSH command + const sshCommand = `aws ecs execute-command --cluster ${clusterName} --task ${taskId} --container ${selectedContainer.name} --interactive --command "/bin/bash"`; + + print.info('\nSSH command to connect to the container:'); + print.info(chalk.yellow(sshCommand)); + print.info( + '\n' + + chalk.green( + 'You can use this command to connect to the container manually.', + ), + ); + print.info( + chalk.dim( + 'Note: Type "exit" to disconnect from the container when connected.\n', + ), + ); + + const { confirmPaste } = await toolbox.prompt.ask([ + { + type: 'confirm', + name: 'confirmPaste', + message: + 'Would you like to put this command in your clipboard for easy pasting?', + initial: true, + }, + ]); + + if (confirmPaste) { + await toolbox.system.run( + `echo "${sshCommand.replace(/"/g, '\\"')}" | pbcopy`, + ); + print.info(chalk.green('SSH command copied to clipboard!')); + } else { + print.info(chalk.dim('Command not copied to clipboard.')); + } + } catch (error) { + if (error.message && error.message.includes('was cancelled')) { + print.info('Operation cancelled by user'); + } else { + print.error(`Error: ${error.message || error}`); + print.error('Make sure:'); + print.error('1. AWS CLI is configured and you have proper permissions'); + print.error('2. The ECS cluster exists and has running tasks'); + print.error('3. ECS Exec is enabled on the service/task'); + } + } + + if (toolbox.fromMenu()) { + await toolbox.menu.showMenu('aws ecs', defaultMenuSettings); + } + }, +};