-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add cloud development capability (#66)
- Loading branch information
1 parent
664c6b3
commit eb57b29
Showing
6 changed files
with
2,383 additions
and
2,232 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 "$@" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.