Skip to content

Commit

Permalink
add jumpbox clone command to dev tool
Browse files Browse the repository at this point in the history
  • Loading branch information
macrael committed Nov 14, 2023
1 parent 56a5632 commit 7ea5dfc
Show file tree
Hide file tree
Showing 9 changed files with 1,857 additions and 26 deletions.
5 changes: 5 additions & 0 deletions dev_tool/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@
]
},
"dependencies": {
"@aws-sdk/client-cloudfront": "^3.450.0",
"@aws-sdk/client-ec2": "^3.441.0",
"@aws-sdk/client-secrets-manager": "^3.441.0",
"node-ssh": "^13.1.0",
"yargs": "^17.2.1"
},
"devDependencies": {
"@types/ssh2": "^1.11.15",
"typescript": "^4.4.4"
}
}
243 changes: 243 additions & 0 deletions dev_tool/src/aws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import {
CloudFrontClient,
ListDistributionsCommand,
} from '@aws-sdk/client-cloudfront'
import {
EC2Client,
DescribeInstancesCommand,
DescribeInstancesCommandInput,
StopInstancesCommand,
StartInstancesCommand,
DescribeSecurityGroupsCommand,
SecurityGroup,
AuthorizeSecurityGroupIngressCommand,
Instance,
} from '@aws-sdk/client-ec2'
import {
SecretsManagerClient,
ListSecretsCommand,
GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager'

// deployment environments -> url mapping
const accountURLs: { [key: string]: string } = {
dev: 'mc-review-dev.onemac.cms.gov', // DO NOT CHECK THIS IN YET
val: 'mc-review-val.onemac.cms.gov',
prod: 'mc-review.onemac.cms.gov',
}

// checkAWSAccess checks that the current creds are valid and for the requested environment
async function checkAWSAccess(envName: string): Promise<undefined | Error> {
// Our environments will always have CloudFront distributions for the UI of the app
// Let's fetch those and make sure we're in the right place.
const client = new CloudFrontClient()
const command = new ListDistributionsCommand({})

try {
const response = await client.send(command)

// One of our cloudfront distributions will have an alias for this environment
const thisDistributionAlias = accountURLs[envName]
const found = response.DistributionList?.Items?.find((dist) =>
dist.Aliases?.Items?.find(
(alias) => alias === thisDistributionAlias
)
)

if (!found) {
console.info(
`You are logged into the wrong account. Load credentials for ${envName} from CloudTamer`
)
return new Error('Wrong account')
}
return undefined
} catch (e) {
if (e.name === 'CredentialsProviderError') {
console.info(
'No AWS credentials found. Load current ones into your environment from CloudTamer'
)
} else if (e.Code === 'ExpiredToken') {
console.info(
'Your AWS credentials have expired. Load current ones into your environment from CloudTamer'
)
} else if (e.Code === 'InvalidClientTokenId') {
console.info(
'Your AWS credentials are wrong. Load current ones into your environment from CloudTamer'
)
} else {
console.error('Unknown error returned by AWS call, update ./dev?')
throw e
}
return e
}
}

// describeSecurityGroup returns the single security group with this ID
async function describeSecurityGroup(
groupID: string
): Promise<SecurityGroup | Error> {
const ec2 = new EC2Client()
const describeSGs = new DescribeSecurityGroupsCommand({
Filters: [{ Name: 'group-id', Values: [groupID] }],
})

try {
const instances = await ec2.send(describeSGs)

if (instances.SecurityGroups?.length != 1) {
return new Error(
`didnt get back the expected secuirty group. found: ${instances.SecurityGroups?.length}`
)
}

const securityGroup = instances.SecurityGroups[0]

return securityGroup
} catch (err) {
return err
}
}

// addWhitelistRuleToGroup adds an SSH whitelist rule to the given groupID
async function addSSHWhitelistRuleToGroup(
groupID: string,
ipAddress: string
): Promise<undefined | Error> {
const ec2 = new EC2Client()
const addWhiteliest = new AuthorizeSecurityGroupIngressCommand({
CidrIp: ipAddress + '/32',
FromPort: 22,
IpProtocol: 'TCP',
ToPort: 22,
GroupId: groupID,
})

try {
await ec2.send(addWhiteliest)
return undefined
} catch (err) {
return err
}
}

// describeInstance returns a single instance that matches the query
async function describeInstance(
input: DescribeInstancesCommandInput = {}
): Promise<Instance | Error> {
const ec2 = new EC2Client()
const describeInstances = new DescribeInstancesCommand(input)

try {
const instances = await ec2.send(describeInstances)

if (
instances.Reservations?.length !== 1 ||
instances.Reservations[0].Instances?.length !== 1
) {
return new Error('Did not find one and only one instance')
}

const instance = instances.Reservations[0].Instances[0]
return instance
} catch (err) {
return err
}
}

async function stopInstance(id: string) {
const ec2 = new EC2Client()
const cmd = new StopInstancesCommand({
InstanceIds: [id],
})

try {
await ec2.send(cmd)
} catch (err) {
return err
}
return undefined
}

async function startInstance(id: string) {
const ec2 = new EC2Client()

const cmd = new StartInstancesCommand({
InstanceIds: [id],
})

try {
await ec2.send(cmd)
} catch (err) {
console.error('Error starting instance', err)
return err
}
}

interface DBConnection {
host: string
user: string
port: number
dbname: string
password: string
}

async function getSecretsForRDS(stage: string): Promise<DBConnection | Error> {
const client = new SecretsManagerClient()
const SMName = `aurora_postgres_${stage}`

const list = new ListSecretsCommand({
Filters: [
{
Key: 'name',
Values: [SMName],
},
],
})
const secrets = await client.send(list)

if (secrets.SecretList?.length !== 1) {
throw new Error('Did not find one and only one secret on ' + stage)
}

const rdsSecret = secrets.SecretList[0]

const getValue = new GetSecretValueCommand({
SecretId: rdsSecret.ARN,
})

try {
const secretValues = await client.send(getValue)

if (!secretValues.SecretString) {
return new Error('AWS didnt return a value for this secret')
}

const parsedSecrets = JSON.parse(secretValues.SecretString)

return {
host: parsedSecrets.host,
user: parsedSecrets.username,
port: parsedSecrets.port,
dbname: parsedSecrets.dbname,
password: parsedSecrets.password,
}
} catch (err) {
if (err.__type === 'AccessDeniedException') {
console.error(
'These creds dont have the ability to get the RDS credentials. Log in with better creds.'
)
return err
}
return err
}
}

export {
checkAWSAccess,
describeInstance,
describeSecurityGroup,
addSSHWhitelistRuleToGroup,
getSecretsForRDS,
stopInstance,
startInstance,
}
50 changes: 34 additions & 16 deletions dev_tool/src/dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn } from 'child_process'
import yargs from 'yargs'
import { parseRunFlags } from './flags.js'
import { cloneDBLocally } from './jumpbox.js'
import {
compileGraphQLTypesOnce,
compileProto,
Expand Down Expand Up @@ -147,7 +148,7 @@ function runPrisma(args: string[]) {
})
}

function main() {
async function main() {
// check to see if local direnv vars have loaded
if (!process.env.REACT_APP_AUTH_MODE) {
console.info(
Expand All @@ -166,7 +167,7 @@ function main() {
All valid arguments to dev should be enumerated here, this is the entrypoint to the script
*/

yargs(process.argv.slice(2))
await yargs(process.argv.slice(2))
.scriptName('dev')
.command(
'local',
Expand Down Expand Up @@ -485,6 +486,37 @@ function main() {
)
}
)
.command('jumpbox', 'run commands on a jumpbox', (yargs) => {
return yargs
.command(
'clone <env>',
'copy the database in the given aws environment locally',
(yargs) => {
return yargs
.positional('env', {
describe:
'the environment to clone from. You must have AWS credentials configured for this environment.',
demandOption: true,
type: 'string',
choices: ['dev', 'val', 'prod'],
})
.option('stop-after', {
type: 'boolean',
default: true,
})
.example([
[
'$0 jumpbox clone dev',
'clone the db from the dev AWS environment to your local machine',
],
])
},
async (args) => {
await cloneDBLocally(args.env, args.stopAfter)
}
)
.demandCommand(1, 'you must pick a subcommand for jumpbox')
})
.command(
'prisma',
'run the prisma command in app-api. all arguments after -- will be passed directly into the prisma command.',
Expand Down Expand Up @@ -539,20 +571,6 @@ function main() {
runAllGenerate()
}
)
.command(
'hybrid',
'[deprecated use ./dev local web --hybrid instead] run app-web locally connected to the review app deployed for this branch',
(yargs) => {
return yargs.option('stage', {
type: 'string',
describe:
'an alternative Serverless stage in your AWS account to run against',
})
},
(args) => {
runWebAgainstAWS(args.stage)
}
)
.demandCommand(1, '')
.help()
.strict().argv // this prints out the help if you don't call a subcommand
Expand Down
2 changes: 1 addition & 1 deletion dev_tool/src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function parseRunFlags<T extends runFlags>(
// Sadly, `any` lives in here. The types in parseRunFlags's signature are right, but it's not clear to me how
// to make typescript happy with actually mapping one type to another.
// so we do the unsafe thing. Having the types in the signature is still a big win.
let parsedFlags = Object.fromEntries(
const parsedFlags = Object.fromEntries(
Object.entries(inputFlags).map(
([key, value]: [string, boolean | undefined]): [
string,
Expand Down
Loading

0 comments on commit 7ea5dfc

Please sign in to comment.