Skip to content

Commit

Permalink
add cloud development capability (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeffomatic authored Dec 13, 2020
1 parent 664c6b3 commit eb57b29
Show file tree
Hide file tree
Showing 6 changed files with 2,383 additions and 2,232 deletions.
3 changes: 3 additions & 0 deletions bin/cloud-dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh

npx ts-node --transpile-only src/cli/cloud-dev/main.ts "$@"
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@types/ws": "^7.2.6",
"@typescript-eslint/eslint-plugin": "^3.2.0",
"@typescript-eslint/parser": "^3.2.0",
"aws-sdk": "^2.809.0",
"eslint": "^7.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.21.1",
Expand Down
95 changes: 95 additions & 0 deletions src/cli/cloud-dev/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Cloud development server

## Local dev

### Setup

#### AWS credentials

Get an IAM access key for your user account via the AWS console. Make sure the user account has the `jeffanddom-cloud-dev-policy-1` attached to it. This will allow you to use local tools that make AWS API calls.

#### SSH

- Get the `jeffanddom-cloud-dev-1.pem` SSH key from 1Password. This key is used to provide SSH authentication to the EC2 development server.
- From your local terminal, `ssh-add` the following SSH keys:
- `jeffanddom-cloud-dev-1.pem`
- The key you use for Github access, usually `~/.ssh/id_rsa`

#### VSCode

Add the [Remote SSH extension](https://code.visualstudio.com/docs/remote/ssh).

### Usage

#### Provisioning the instance

From your local manchester repo, run `bin/cloud-dev`.

This will provision a new EC2 instance, and update your local SSH config so you can access the instance using the `jeffanddom-cloud-dev` SSH alias. Example:

```
% bin/cloud-dev
launching new instance of template jeffanddom-cloud-dev-template-1...
instance i-0a8b08a6ea27ca78d created, waiting for public hostname...
public hostname is ec2-3-101-81-255.us-west-1.compute.amazonaws.com
updating /Users/jeff/.ssh/config...
Happy hacking! Press CTRL+C to terminate.
```

Keep this program running! It will terminate the EC2 instance when closed.

#### Connecting with VSCode

Next, open the VSCode command palette and choose `Remote-SSH: Connect to Host...`. It should show `jeffanddom-cloud-dev` as an option. Once connected, choose "Open folder". Changes you make will update the cloud server's manchester repository, not the local one.

The manchester repo will likely be out-of-date, so you'll want to fetch latest changes (see [Git](#Git) below for instructions) and re-run `yarn`.

To run the game server, use the VSCode terminal and run `yarn dev`. You can access the the server via the web using the hostname emitted by `bin/cloud-dev`.

#### Git

SSH agent forwarding seems to be broken in VSCode, so fetch/push actions in the cloud dev server require a separate SSH session from a new terminal. To connect to the server, run:

```
ssh -A jeffanddom-cloud-dev
```

Use this terminal session to perform fetches and pushes.

#### TODO

- Wait for upstream to [fix SSH agent forwarding](https://github.com/microsoft/vscode-remote-release/issues/4183), so we can do git fetch/push on the cloud dev's repo via VSCode, rather than having to open a separate terminal.
- When closing `bin/cloud-dev`, stop, rather than terminate, the EC2 instance. This will prevent changes in the cloud dev repo from getting lost. We may need to introduce a user-based tagging system so that multiple people can have their own dev servers.
- Use the VSCode SSH session to forward HTTP traffic from a local port to the cloud server. This way, we can avoid worrying about the cloud dev server's hostname unless we want to do multiplayer.

## Ops stuff

### Building the base launch image

Launch an EC2 instance with the following:

- AMI: Ubuntu 20.04 HVM LTS (`ami-00831fc7c1e3ddc60`)
- Instance type: t3.xlarge (4 vCPUs, bursty)
- Security group: `jeffanddom-cloud-dev-sg-2`, which opens ingress ports 22, 43, 80, and 3000
- SSH key: `jeffanddom-cloud-dev-1`

Note that most of our AWS objects follow the namescheme `jeffanddom-cloud-dev-{object type}-{version number}`.

Next, perform the following on-host config:

- `sudo apt install yarn npm`
- Clone manchester, then run `yarn` to install deps

The `jeffanddom-cloud-dev-template-1` launch template includes an AMI that includes all of the above.

### IAM policies for local development

`bin/cloud-dev` needs to be able to:

- list EC2 instances
- launch EC2 instances via a launch template
- create EC2 tags
- terminate EC2 instances
- decode STS encoded error messages (just to debug EC2 error messages)

These policies are currently stored in `jeffanddom-cloud-dev-policy-1`.
217 changes: 217 additions & 0 deletions src/cli/cloud-dev/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* Launches a development host in EC2. The program will terminate the instance
* when closed.
*
* AVOID calling this from a package.json script, i.e. "yarn cloud-dev". If this
* script is executed via a yarn/npm parent, CTRL+C will cause the parent to
* surrender the TTY back to the shell before this script has finished its
* shutdown cleanup.
*/

import * as fs from 'fs'
import * as os from 'os'

import * as AWS from 'aws-sdk'

import * as util from '../util'

/**
* Launches a new instance based on the provided launch template, identified by
* name.
*/
async function launch(
ec2: AWS.EC2,
templateName: string,
): Promise<AWS.EC2.Instance> {
const res = await ec2
.runInstances({
MinCount: 1,
MaxCount: 1,
LaunchTemplate: { LaunchTemplateName: templateName },
})
.promise()

if (!res.Instances || res.Instances.length == 0) {
throw new Error('no instances returned')
}

return res.Instances[0]
}

/**
* It takes a little while for PublicDnsName to become available for newly-
* launched EC2 instances. This function will poll with backoff until the
* hostname is available, or until reaching the max number of attempts.
*/
async function waitForPublicDnsName(
ec2: AWS.EC2,
instanceId: string,
): Promise<string> {
const maxAttempts = 5
let wait = 1500
for (let i = 0; i < maxAttempts; i++) {
const res = await ec2
.describeInstances({ InstanceIds: [instanceId] })
.promise()

const reservations = res.Reservations
if (!reservations || reservations.length == 0) {
throw new Error(`could not find instance ${instanceId}`)
}

const instances = reservations[0].Instances
if (!instances || instances.length == 0) {
throw new Error(`could not find instance ${instanceId}`)
}

const instance = instances[0]
if (
instance.PublicDnsName !== undefined &&
instance.PublicDnsName.length > 0
) {
return instance.PublicDnsName
}

await util.sleep(wait)
wait *= 2
}

throw new Error(
`unable to read public hostname of ${instanceId} after ${maxAttempts} attempts`,
)
}

async function terminate(ec2: AWS.EC2, instanceId: string): Promise<void> {
await ec2.terminateInstances({ InstanceIds: [instanceId] }).promise()
}

/**
* Removes a host definition block from an SSH config. The block is chosen by
* matching the localHost parameter against the "Host" declaration. If there is
* a trailing newline after the block, it will be removed.
*/
function removeSshHostConfig(sshConfig: string, localHost: string): string {
const lines = sshConfig.split(os.EOL)
const res = []
let skip = false

for (let i = 0; i < lines.length; i++) {
// Stop skipping if we reach a new Host or Match line
if (skip && lines[i].match(/^s*(Host|Match)/)) {
skip = false
}

// Start skipping if we see a Host line matching the localHost arg value
if (lines[i].match(new RegExp(`^\\s*Host\\s+${localHost}`))) {
skip = true
}

if (skip) {
continue
}

res.push(lines[i])
}

return res.join(os.EOL)
}

function sshConfigTemplate(opts: {
localHostAlias: string
remoteHost: string
remoteUser: string
}): string {
return `Host ${opts.localHostAlias}
HostName ${opts.remoteHost}
User ${opts.remoteUser}
`
}

/**
* Updates the given SSH config file. A Host directive matching the
* localHostAlias argument will be replaced with a new one using the provided
* remoteHost value. If no such directive exists, one will be created.
*
* The old version of the SSH config file will be preserved in a backup file.
* The backup file will have a `.cloud-dev-backup` suffix.
*/
async function updateSshConfig(opts: {
sshConfigPath: string
localHostAlias: string
remoteHost: string
remoteUser: string
}): Promise<void> {
const srcConfig = (await fs.promises.readFile(opts.sshConfigPath)).toString()

// write a backup
const backupPath = opts.sshConfigPath + '.cloud-dev-backup'
await fs.promises.writeFile(backupPath, srcConfig)

let newConfig = removeSshHostConfig(srcConfig, opts.localHostAlias)

// add a trailing gap if necessary
if (!newConfig.endsWith(os.EOL + os.EOL)) {
newConfig += os.EOL + os.EOL
}

// append the new SSH config
newConfig += sshConfigTemplate(opts)
await fs.promises.writeFile(opts.sshConfigPath, newConfig)
}

async function main(): Promise<void> {
const ec2 = new AWS.EC2({ region: 'us-west-1' })
const launchTemplateName = 'jeffanddom-cloud-dev-template-1'
const localHostAlias = 'jeffanddom-cloud-dev'
const remoteUser = 'ubuntu'
const sshConfigPath = process.env['HOME'] + '/.ssh/config'

console.log(`launching new instance of template ${launchTemplateName}...`)
const instance = await launch(ec2, launchTemplateName)
const instanceId = instance.InstanceId
if (instanceId === undefined) {
throw new Error('no instance ID returned')
}

// Ensure that the instance is terminated when this program quits.
const quit = () => {
console.log(`terminating ${instanceId}...`)

// We not really supposed to use async functions as event handler callbacks,
// so let's handle the termination results as promises.
terminate(ec2, instanceId)
.then(() => process.exit())
.catch((e) => {
console.error(e)
process.exit(1)
})
}
process.on('SIGINT', quit) // CTRL+C from TTY
process.on('SIGTERM', quit) // `kill <this pid>`
process.on('SIGHUP', quit) // Window/tab close

// Fetch and print the public hostname.
console.log(
`instance ${instance.InstanceId} created, waiting for public hostname...`,
)
const remoteHost = await waitForPublicDnsName(ec2, instanceId)
console.log(`public hostname is ${remoteHost}`)

// Update SSH config with new remote host
console.log(`updating ${sshConfigPath}...`)
await updateSshConfig({
sshConfigPath,
localHostAlias,
remoteHost,
remoteUser,
})

// Prevent the program from quitting when main() returns. We'll wait for an
// OS signal instead.
console.log('Happy hacking! Press CTRL+C to terminate.')
util.preventDefaultTermination()
}

main().catch((e) => {
throw e
})
17 changes: 17 additions & 0 deletions src/cli/util/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* An awaitable sleep().
*/
export async function sleep(ms: number): Promise<void> {
await new Promise((resolve, _) => setTimeout(resolve, ms))
}

/**
* Generates a no-op background loop that prevents the NodeJS process from
* terminating when no more real I/O is scheduled. Use this when you want
* OS signals to trigger termination.
*/
export function preventDefaultTermination(): void {
setInterval(() => {
// do nothing
}, 1_000_000_000)
}
Loading

0 comments on commit eb57b29

Please sign in to comment.