Generate cloud-init instead of bash
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!
MODIFY
aws-js-webserver/README.md
aws-js-webserver/README.md
# 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).
-

MODIFY
aws-ts-ruby-on-rails/README.md
aws-ts-ruby-on-rails/README.md
# 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://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
+ ```

MODIFY
aws-ts-ruby-on-rails/files/install_application
aws-ts-ruby-on-rails/files/install_application
#!/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
@@ -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

MODIFY
aws-ts-ruby-on-rails/files/install_ruby
aws-ts-ruby-on-rails/files/install_ruby
-#!/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

RENAME
aws-ts-ruby-on-rails/files/start-application
aws-ts-ruby-on-rails/files/start_application
#!/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

MODIFY
aws-ts-ruby-on-rails/index.ts
aws-ts-ruby-on-rails/index.ts
@@ -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",
@@ -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://${url}/notes`);

MODIFY
aws-ts-ruby-on-rails/init.ts
aws-ts-ruby-on-rails/init.ts
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 {
@@ -32,35 +34,47 @@ 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.
@@ -68,29 +82,39 @@ export async function createUserData(configs: string[], init: Init): Promise<str
}
}
}
- 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;
@@ -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`;
+ }
+ }
}
+ }
+
+ result += `--${boundary}--`;
- return script;
+ return result;
}
export async function renderConfigFile(path: string, config: any): Promise<string> {

Generate cloud-init instead of bash

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!