Skip to content

Commit

Permalink
Generate cloud-init instead of bash
Browse files Browse the repository at this point in the history
This change retains the cfn-init-style JSON input for initializing an
EC2 instance, but lowers it to cloud-init rather than bash. This is
incomplete, but is sufficient for us to stand up a Ruby on Rails server!
  • Loading branch information
joeduffy committed Aug 1, 2018
1 parent c658a76 commit 4bff87b
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 43 deletions.
1 change: 0 additions & 1 deletion aws-js-webserver/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# AWS Web Server example in JavaScript

Deploy an EC2 instance using `@pulumi/aws`. This example shows how to use multiple infrastructure resources in one program. For a detailed walkthrough, see the tutorial [Infrastructure on AWS](https://pulumi.io/quickstart/aws-ec2.html).

66 changes: 64 additions & 2 deletions aws-ts-ruby-on-rails/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,67 @@
# AWS EC2 Ruby on Rails

This is a conversion of the AWS CloudFormation Application Framework template for a basic Ruby on Rails server.
It creates a single EC2 instance and uses a local MySQL database for storage.
See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/sample-templates-appframeworks-us-west-2.html.
It creates a single EC2 virtual machine instance and uses a local MySQL database for storage. Sourced from
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/sample-templates-appframeworks-us-west-2.html.

## Deploying the App

To deploy your Ruby on Rails application, follow the below steps.

### Prerequisites

1. [Install Pulumi](https://pulumi.io/install)
2. [Configure AWS Credentials](https://pulumi.io/install/aws.html)

### Steps

After cloning this repo, from this working directory, run these commands:

1. Create a new stack, which is an isolated deployment target for this example:

```bash
$ pulumi stack init
```

2. Set the required configuration variables for this program:

```bash
$ pulumi config set aws:region us-east-1
$ pulumi config set dbUser [your-mysql-user-here]
$ pulumi config set dbPassword [your-mysql-password-here] --secret
$ pulumi config set dbRootPassword [your-mysql-root-password-here] --secret

# Optionally, if you have an AWS KMS key to use for SSH access:
$ pulumi config set keyName [your-aws-kms-key-name-here]
```

3. Stand up the VM, which will also install and configure Ruby on Rails and MySQL:

```bash
$ pulumi up
```

4. After a couple minutes, your VM will be ready, and two stack outputs are printed:

```bash
$ pulumi stack output
Current stack outputs (2):
OUTPUT VALUE
vmIP 53.40.227.82
websiteURL http:https://ec2-53-40-227-82.us-west-2.compute.amazonaws.com/notes
```

5. Visit your new website by entering the websiteURL into your browser, or running:

```bash
$ curl curl $(pulumi stack output websiteURL)
```

6. From there, feel free to experiment. Simply making edits and running `pulumi up` will incrementally update your VM.

7. Afterwards, destroy your stack and remove it:

```bash
$ pulumi destroy --yes
$ pulumi stack rm --yes
```
5 changes: 3 additions & 2 deletions aws-ts-ruby-on-rails/files/install_application
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/bash -e
source /etc/profile.d/rvm.sh
source /etc/profile.d/rvm.sh || true
rvm use 2.3.1
export HOME=/home/ec2-user
export PATH=$PATH:/usr/local/bin
Expand All @@ -15,7 +15,8 @@ if pgrep ruby &> /dev/null ; then pkill -TERM ruby ; fi
# Create a new application, with therubyracer javascript library
rails new sample -d mysql --skip-spring --skip-bundle --force
cd /home/ec2-user/sample
sed -i 's/^# \\(.*therubyracer.*$\\)/\\1/' Gemfile
sed -i 's/^# \(.*mini_racer.*$\)/\1/' Gemfile
sed -i 's/^# \(.*therubyracer.*$\)/\1/' Gemfile
bundle install

# Create a sample scoffold
Expand Down
4 changes: 2 additions & 2 deletions aws-ts-ruby-on-rails/files/install_ruby
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/bash
#!/bin/bash -e
curl -sSL https://get.rvm.io | bash
source /etc/profile.d/rvm.sh
source /etc/profile.d/rvm.sh || true
rvm install 2.3.1
rvm --default use 2.3.1
gem install rails
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/bash -e
source /etc/profile.d/rvm.sh
source /etc/profile.d/rvm.sh || true
rvm use 2.3.1
export HOME=/home/ec2-user
export PATH=$PATH:/usr/local/bin
Expand Down
15 changes: 10 additions & 5 deletions aws-ts-ruby-on-rails/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ const webServer = new aws.ec2.Instance("webServer", {
owner: "root",
group: "root",
},
"/home/ec2-user/start-application": {
content: renderConfigFile("./files/start-application", config),
"/home/ec2-user/start_application": {
content: renderConfigFile("./files/start_application", config),
mode: "000500",
owner: "root",
group: "root",
Expand All @@ -96,19 +96,24 @@ const webServer = new aws.ec2.Instance("webServer", {
command: "/tmp/install_application > /var/log/install_application.log",
},
"02_configure_reboot": {
command: "echo /home/ec2-user/start-application >> /etc/rc.local",
command: "echo /home/ec2-user/start_application >> /etc/rc.local",
},
"03_start_application": {
command: "/home/ec2-user/start-application",
command: "/home/ec2-user/start_application > var/log/start_application.log",
},
/*
"04_cleanup": {
command: "rm /tmp/install_application",
},
*/
},
},
},
),
});

// Expor the URL for our newly created Rails application.
// Export the VM IP in case we want to SSH.
export let vmIP = webServer.publicIp;

// Export the URL for our newly created Rails application.
export let websiteURL = webServer.publicDns.apply(url => `http:https://${url}/notes`);
96 changes: 66 additions & 30 deletions aws-ts-ruby-on-rails/init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as fs from "async-file";
import * as mustache from "mustache";

// Init supports CloudFormation cfn-init-like structures for expressing post-provisioning virtual machine
// initialization steps, including installing packages and files, running commands, and managing services.
type Init = {[config: string]: InitConfig};

interface InitConfig {
Expand Down Expand Up @@ -32,65 +34,87 @@ interface InitService {
ensureRunning?: boolean;
}

// createUserData produces a single user data script out of the given init structure.
// createUserData produces a cloud-init payload, suitable for user data, out of the given init structure.
export async function createUserData(configs: string[], init: Init): Promise<string> {
let script = `#!/bin/bash -xe\n\n`;
// We need to encode the user data into multiple MIME parts. This ensures that all of the pieces are processed
// correctly by the cloud-init system, without needing to do map merging, etc.
const boundary = "cd4621d39f783ba4";
let result = `Content-Type: multipart/mixed; ` +
`Merge-Type: list(append)+dict(recurse_array)+str(); ` +
`boundary="${boundary}"\n` +
`MIME-Version: 1.0\r\n\r\n`;

// For each config entry, generate bash script that carries out its wishes.
// For each config entry, generate the cloud-init statements to carry out its wishes.
for (let name of configs) {
let config = init[name];
if (!config) {
throw new Error(`Missing config entry for ${name}`);
}

script += `# ${name}:\n`;
if (config.files) {
// Emit files with the appropriate content and permissions:
for (let path of Object.keys(config.files)) {
let file = config.files[path];
script += `echo "${await file.content}" > ${path}\n`;
script += `chown ${file.owner}:${file.group} ${path}\n`;
script += `chmod ${file.mode} ${path}\n`;
}
}
if (config.packages) {
result += `--${boundary}\nContent-Type: text/cloud-config\r\n\r\n`;
result += `merge_how: list(append)+dict(recurse_array)+str()\n`;

// Process the sections in the same order as cfn-init; from:
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-init.html:
//
// "The cfn-init helper script processes these configuration sections in the following order: packages,
// groups, users, sources, files, commands, and then services. If you require a different order, separate
// your sections into different config keys, and then use a configset that specifies the order in which the
// config keys should be processed."
//
// TODO: support for groups, users, and sources.

if (config.packages && Object.keys(config.packages).length) {
// Install package manager packages:
result += `update_packages: true\n`;
result += `packages:\n`;
for (let manager of Object.keys(config.packages)) {
let packages = config.packages[manager];
switch (manager) {
case "yum":
script += `yum update -y\n`; // TODO: do this earlier.
script += `yum install -y ${packages.join(" ")}\n`;
for (let pkg of packages) {
result += `- ${pkg}\n`;
}
break;
default:
// TODO: support more package managers.
throw new Error(`Unrecognized package manager: ${manager}`);
}
}
}
if (config.commands) {
// Invoke commands as they are encountered:
for (let command of Object.keys(config.commands)) {
let entry = config.commands[command];
script += `${entry.command}\n`;
if (entry.test) {
script += `${entry.test}\n`;
}

if (config.files && Object.keys(config.files).length) {
// Emit files with the appropriate content and permissions:
result += `write_files:\n`;
for (let path of Object.keys(config.files)) {
let file = config.files[path];
result += `- path: ${path}\n`;
result += ` encoding: b64\n`;
result += ` content: ${Buffer.from(await file.content).toString("base64")}\n`;
result += ` owner: ${file.owner}:${file.group}\n`;
result += ` permissions: '${file.mode}'\n`;
}
}
if (config.services) {
// Ensure services are managed appropriately:

let commands = config.commands || {};

if (config.services && Object.keys(config.services).length) {
for (let manager of Object.keys(config.services)) {
// Expand out service management into commands, as there is no built-in cloud-init equivalent:
let services = config.services[manager];
switch (manager) {
case "sysvinit":
for (let service of Object.keys(services)) {
let serviceInfo = services[service];
if (serviceInfo.enabled) {
script += `systemctl enable ${service}\n`;
commands[`${manager}-${service}-enabled`] = {
command: `chkconfig ${service} on`,
};
}
if (serviceInfo.ensureRunning) {
script += `systemctl start ${service}\n`;
commands[`${manager}-${service}-ensureRunning`] = {
command: `service ${service} start`,
};
}
}
break;
Expand All @@ -101,10 +125,22 @@ export async function createUserData(configs: string[], init: Init): Promise<str
}
}

script += `\n`;
if (commands && Object.keys(commands).length) {
// Invoke commands as they are encountered:
result += `runcmd:\n`;
for (let command of Object.keys(commands)) {
let entry = commands[command];
result += `- ${entry.command}\n`;
if (entry.test) {
result += `- ${entry.test}\n`;
}
}
}
}

return script;
result += `--${boundary}--`;

return result;
}

export async function renderConfigFile(path: string, config: any): Promise<string> {
Expand Down

0 comments on commit 4bff87b

Please sign in to comment.