Add an example of "provisioning"
This new example demonstrates using dynamic providers to approximate
"provisioning". It implements CopyFile and RemoteExec custom resources
that will use SCP and SSH to accomplish post-provision configuration.
The real solution to this is to design and implement
ADD
aws-ts-ec2-provisioners/.gitignore
+rsa
+rsa.pub
+node_modules/

ADD
aws-ts-ec2-provisioners/Pulumi.yaml
+name: aws-ts-ec2-provisioners
+runtime: nodejs
+description: An example of an EC2 webserver instance with post-provision configuration.

ADD
aws-ts-ec2-provisioners/README.md
+# AWS WebServer with Manual Provisioning
+
+This demonstrates using Pulumi dynamic providers to accomplish post-provisioning configuration steps.
+
+Using these building blocks, one can accomplish much of the same as Terraform provisioners.
+
+https://github.com/pulumi/pulumi/issues/1691 tracks designing and developing a complete replacement for provisioners.
+
+## Running the Example
+
+First, create a stack, using `pulumi stack init`.
+
+Next, generate an OpenSSH keypair for use with your server:
+
+```
+$ ssh-keygen -t rsa -f rsa
+```
+
+This will output two files, `rsa` and `rsa.pub`, in the current directory. Be sure not to commit these files!
+
+We then need to configure our stack so that the public key is used by our EC2 instance, and the private key used
+for subsequent SCP and SSH steps that will configure our server after it is stood up.
+
+```
+$ cat rsa.pub | pulumi config set publicKey --
+$ cat rsa | pulumi config set privateKey --secret --
+```
+
+If your key is protected by a passphrase, add that too:
+
+```
+$ pulumi config set privateKeyPassphrase --secret [yourPassphraseHere]
+```
+
+Notice that we've used `--secret` for both `privateKey` and `privateKeyPassphrase`. This ensures their are
+stored in encrypted form in the Pulumi secrets system.
+
+Also set your desired AWS region:
+
+```
+$ pulumi config set aws:region us-west-2
+```
+
+From there, you can run `pulumi up` and all resources will be provisioned and configured.

ADD
aws-ts-ec2-provisioners/index.ts
+// Copyright 2016-2019, Pulumi Corporation. All rights reserved.
+
+import * as aws from "@pulumi/aws";
+import * as pulumi from "@pulumi/pulumi";
+import * as provisioners from "./provisioners";
+
+// Get the config ready to go.
+const config = new pulumi.Config();
+
+// If keyName is provided, an existing KeyPair is used, else if publicKey is provided a new KeyPair
+// derived from the publicKey is created.
+let keyName: pulumi.Input<string> = config.get("keyName");
+const publicKey = config.get("publicKey");
+
+// The privateKey associated with the selected key must be provided (either directly or base64 encoded), along with an optional
+// passphrase if needed.
+const privateKey = config.requireSecret("privateKey").apply(key => {
+ if (key.startsWith("-----BEGIN RSA PRIVATE KEY-----")) {
+ return key;
+ } else {
+ return new Buffer(key, "base64").toString("ascii");
+ }
+});
+const privateKeyPassphrase = config.getSecret("privateKeyPassphrase");
+
+// Create a new security group that permits SSH and web access.
+const secgrp = new aws.ec2.SecurityGroup("secgrp", {
+ description: "Foo",
+ ingress: [
+ { protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] },
+ { protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
+ ],
+});
+
+// Get the AMI
+const amiId = aws.getAmi({
+ owners: ["amazon"],
+ mostRecent: true,
+ filters: [{
+ name: "name",
+ values: ["amzn2-ami-hvm-2.0.????????-x86_64-gp2"],
+ }],
+}, { async: true}).then(ami => ami.id);
+
+// Create an EC2 server that we'll then provision stuff onto.
+const size = "t2.micro";
+if (!keyName) {
+ const key = new aws.ec2.KeyPair("key", { publicKey });
+ keyName = key.keyName;
+}
+const server = new aws.ec2.Instance("server", {
+ instanceType: size,
+ ami: amiId,
+ keyName: keyName,
+ securityGroups: [ secgrp.name ],
+});
+const conn = {
+ host: server.publicIp,
+ username: "ec2-user",
+ privateKey,
+ privateKeyPassphrase,
+};
+
+// Copy a config file to our server.
+const cpConfig = new provisioners.CopyFile("config", {
+ conn,
+ src: "myapp.conf",
+ dest: "myapp.conf",
+}, { dependsOn: server });
+
+// Execute a basic command on our server.
+const catConfig = new provisioners.RemoteExec("cat-config", {
+ conn,
+ command: "cat myapp.conf",
+}, { dependsOn: cpConfig });
+
+export const publicIp = server.publicIp;
+export const publicHostName = server.publicDns;
+export const catConfigStdout = catConfig.result.stdout;

ADD
aws-ts-ec2-provisioners/myapp.conf
+[test]
+x = 42

ADD
aws-ts-ec2-provisioners/package.json
+{
+ "name": "aws-ec2-provisioners",
+ "version": "0.1.0",
+ "main": "index.js",
+ "dependencies": {
+ "@pulumi/aws": "latest",
+ "@pulumi/pulumi": "latest",
+ "@types/ssh2": "^0.5.39",
+ "@types/uuid": "^3.4.4",
+ "scp2": "^0.5.0",
+ "ssh2": "^0.8.5",
+ "uuid": "^3.3.2"
+ }
+}

ADD
aws-ts-ec2-provisioners/provisioners/index.ts
+// Copyright 2016-2019, Pulumi Corporation. All rights reserved.
+
+import * as pulumi from "@pulumi/pulumi";
+import * as scp2 from "scp2";
+import * as ssh2 from "ssh2";
+
+import { Provisioner } from "./provisioner";
+
+// ConnectionArgs tells a provisioner how to access a remote resource. For example, it may need to use
+// SSH or WinRM to connect to the resource in order to run a command or copy a file.
+export interface ConnectionArgs {
+ type?: ConnectionType;
+ host: pulumi.Input<string>;
+ port?: pulumi.Input<number>;
+ username?: pulumi.Input<string>;
+ password?: pulumi.Input<string>;
+ privateKey?: pulumi.Input<string>;
+ privateKeyPassphrase?: pulumi.Input<string>;
+}
+
+// ConnectionType is the set of legal connection mechanisms to use. Default is SSH.
+export type ConnectionType = "ssh" | "winrm";
+
+function connToSsh2(conn: pulumi.Unwrap<ConnectionArgs>): any {
+ return {
+ host: conn.host,
+ port: conn.port,
+ username: conn.username,
+ password: conn.password,
+ privateKey: conn.privateKey,
+ passphrase: conn.privateKeyPassphrase,
+ };
+}
+
+function copyFile(conn: pulumi.Unwrap<ConnectionArgs>, src: string, dest: string): Promise<never> {
+ const connType = conn.type || "ssh";
+ if (connType !== "ssh") {
+ throw new Error("only SSH connection types currently supported");
+ }
+
+ let connectionFailCount = 0;
+ return new Promise((resolve, reject) => {
+ function scp() {
+ scp2.scp(
+ src,
+ { path: dest, ...connToSsh2(conn) },
+ (err) => {
+ if (err) {
+ connectionFailCount++;
+ if (connectionFailCount > 10) {
+ reject(err);
+ } else {
+ setTimeout(scp, connectionFailCount * 500);
+ }
+ return;
+ }
+ resolve();
+ },
+ );
+ }
+ scp();
+ });
+}
+
+// RunCommandResult is the result of running a command.
+export interface RunCommandResult {
+ // The stdout of the command that was executed.
+ stdout: string;
+ // The stderr of the command that was executed.
+ stderr: string;
+ // The exit code of the command that was executed.
+ code: number;
+}
+
+function runCommand(conn: pulumi.Unwrap<ConnectionArgs>, cmd: string): Promise<RunCommandResult> {
+ const connType = conn.type || "ssh";
+ if (connType !== "ssh") {
+ throw new Error("only SSH connection types currently supported");
+ }
+
+ const sshConn = connToSsh2(conn);
+ let connectionFailCount = 0;
+ return new Promise((resolve, reject) => {
+ const conn = new ssh2.Client();
+ function connect() {
+ conn.on("ready", () => {
+ conn.exec(cmd, (err, stream) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ let stdout = "";
+ let stderr = "";
+ stream.on("close", (code, signal) => {
+ conn.end();
+ if (code) {
+ reject(new Error("Command exited with " + code));
+ } else {
+ resolve({ stdout, stderr, code });
+ }
+ }).on("data", (data) => {
+ stdout += data.toString("utf8");
+ }).stderr.on("data", (data) => {
+ stderr += data.toString("utf8");
+ });
+ });
+ }).on("error", (err) => {
+ connectionFailCount++;
+ if (connectionFailCount > 10) {
+ reject(err);
+ } else {
+ setTimeout(connect, connectionFailCount * 500);
+ }
+ }).connect(sshConn);
+ }
+ connect();
+ });
+}
+
+
+// CopyFile is a provisioner step that can copy a file from the machine running Pulumi to the newly created resource.
+export class CopyFile extends pulumi.ComponentResource {
+ private readonly provisioner: Provisioner<ConnectionArgs, never>;
+
+ constructor(name: string, args: CopyFileArgs, opts?: pulumi.ComponentResourceOptions) {
+ super("pulumi:provisioners:CopyFile", name, args, opts);
+
+ this.provisioner = new Provisioner<ConnectionArgs, never>(
+ `${name}-provisioner`,
+ {
+ dep: args.conn,
+ onCreate: (conn) => copyFile(conn, args.src, args.dest),
+ },
+ { parent: this },
+ );
+ }
+}
+
+export interface CopyFileArgs {
+ // conn contains information on how to connect to the destination, in addition to dependency information.
+ conn: pulumi.Input<ConnectionArgs>;
+ // src is the source of the file or directory to copy. It can be specified as relative to the current
+ // working directory or as an absolute path. This cannot be specified if content is set.
+ src: string;
+ // dest is required and specifies the absolute path on the target where the file will be copied to.
+ dest: string;
+}
+
+// RemoteExec runs remote commands and/or invokes scripts. If commands and scripts are specified, they are
+// run in the following order: command, commands, script, and finally then scripts.
+export class RemoteExec extends pulumi.ComponentResource {
+ private readonly provisioner: Provisioner<ConnectionArgs, RunCommandResult[]>;
+ // The results of all commands executed remotely.
+ public readonly results: pulumi.Output<RunCommandResult[]>;
+ // The result of the first command executed remotely.
+ public readonly result: pulumi.Output<RunCommandResult>;
+
+ constructor(name: string, args: RemoteExecArgs, opts?: pulumi.ComponentResourceOptions) {
+ super("pulumi:provisioners:RemoteExec", name, args, opts);
+
+ if (args.command !== undefined && args.commands !== undefined) {
+ throw new Error("Exactly one of 'command' or 'commands' should be provided.");
+ }
+
+ this.provisioner = new Provisioner<ConnectionArgs, RunCommandResult[]>(
+ `${name}-provisioner`,
+ {
+ dep: args.conn,
+ onCreate: async (conn) => {
+ const commands = args.commands || [args.command];
+ const results: RunCommandResult[] = [];
+ for (const cmd of commands) {
+ const result = await runCommand(conn, cmd);
+ results.push(result);
+ }
+ return results;
+ },
+ },
+ { parent: this },
+ );
+
+ this.result = this.provisioner.result[0];
+ this.results = this.provisioner.result;
+ }
+}
+
+export interface RemoteExecArgs {
+ // The connection to use for the remote command execution.
+ conn: ConnectionArgs;
+ // The command to execute. Exactly one of 'command' and 'commands' is required.
+ command?: string;
+ // The commands to execute. Exactly one of 'command' and 'commands' is required.
+ commands?: string[];
+}
+

ADD
aws-ts-ec2-provisioners/provisioners/provisioner.ts
+// Copyright 2016-2019, Pulumi Corporation. All rights reserved.
+
+import * as pulumi from "@pulumi/pulumi";
+import * as uuid from "uuid";
+
+// Provisioner lets a custom action run the first time a resource has been created. It takes as input
+// a dependent property. Anytime its value changes, the resource is replaced and will re-run its logic.
+export class Provisioner<T, U> extends pulumi.dynamic.Resource {
+ dep: pulumi.Output<T>;
+ result: pulumi.Output<U>;
+ constructor(name: string, props: ProvisionerProperties<T, U>, opts?: pulumi.CustomResourceOptions) {
+ const provider: pulumi.dynamic.ResourceProvider = {
+ diff: async (id: pulumi.ID, olds: State<T, U>, news: State<T, U>) => {
+ const replace = JSON.stringify(olds.dep) !== JSON.stringify(news.dep);
+ return {
+ changes: replace,
+ replaces: replace ? ["dep"] : undefined,
+ deleteBeforeReplace: true,
+ };
+ },
+ create: async (inputs: State<T, U>) => {
+ const result = await props.onCreate(inputs.dep);
+ if (result !== undefined) {
+ inputs.result = result;
+ }
+ return { id: uuid.v4(), outs: inputs };
+ },
+ };
+ super(provider, name, { dep: props.dep, result: null }, opts);
+ }
+}
+
+export interface ProvisionerProperties<T, U> {
+ dep: pulumi.Input<T>;
+ onCreate: (dep: pulumi.Unwrap<T>) => Promise<pulumi.Unwrap<U>>;
+}
+
+interface State<T, U> {
+ dep: pulumi.Unwrap<T>;
+ result: pulumi.Unwrap<U>;
+}

MODIFY
misc/test/examples_test.go
misc/test/examples_test.go
package test
import (
+ "encoding/base64"
"fmt"
"io/ioutil"
"net/http"
@@ -12,6 +13,10 @@ import (
"testing"
"time"
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/session"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/pulumi/pulumi/pkg/resource"
"github.com/pulumi/pulumi/pkg/testing/integration"
"github.com/stretchr/testify/assert"
)
@@ -217,6 +222,44 @@ func TestAccAwsTsContainers(t *testing.T) {
integration.ProgramTest(t, &test)
}
+func TestAccAwsTsEc2Provisioners(t *testing.T) {
+ sess, err := session.NewSession(&aws.Config{
+ Region: aws.String(getAwsRegion())},
+ )
+ assert.NoError(t, err)
+ svc := ec2.New(sess)
+ keyName, err := resource.NewUniqueHex("test-keyname", 8, 20)
+ assert.NoError(t, err)
+ t.Logf("Creating keypair %s.\n", keyName)
+ key, err := svc.CreateKeyPair(&ec2.CreateKeyPairInput{
+ KeyName: aws.String(keyName),
+ })
+ assert.NoError(t, err)
+ defer func() {
+ t.Logf("Deleting keypair %s.\n", keyName)
+ _, err := svc.DeleteKeyPair(&ec2.DeleteKeyPairInput{
+ KeyName: aws.String(keyName),
+ })
+ assert.NoError(t, err)
+ }()
+ test := getAWSBase(t).
+ With(integration.ProgramTestOptions{
+ Dir: path.Join(getCwd(t), "..", "..", "aws-ts-ec2-provisioners"),
+ Config: map[string]string{
+ "keyName": aws.StringValue(key.KeyName),
+ },
+ Secrets: map[string]string{
+ "privateKey": base64.StdEncoding.EncodeToString([]byte(aws.StringValue(key.KeyMaterial))),
+ },
+ ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
+ catConfigStdout := stack.Outputs["catConfigStdout"].(string)
+ assert.Equal(t, "[test]\nx = 42\n", catConfigStdout)
+ },
+ })
+
+ integration.ProgramTest(t, &test)
+}
+
func TestAccAwsTsEks(t *testing.T) {
test := getAWSBase(t).
With(integration.ProgramTestOptions{

MODIFY
misc/test/go.mod
misc/test/go.mod
@@ -5,6 +5,7 @@ go 1.12
require (
github.com/Azure/azure-amqp-common-go v1.1.4 // indirect
github.com/apache/thrift v0.12.0 // indirect
+ github.com/aws/aws-sdk-go v1.19.45
github.com/openzipkin/zipkin-go v0.1.6 // indirect
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 // indirect
github.com/pulumi/pulumi v1.6.1-0.20191120205257-a600d1652666

MODIFY
misc/test/go.sum
misc/test/go.sum
@@ -92,6 +92,7 @@ github.com/aws/aws-sdk-go v1.19.16/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi
github.com/aws/aws-sdk-go v1.19.18/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.19.45 h1:jAxmC8qqa7mW531FDgM8Ahbqlb3zmiHgTpJU6fY3vJ0=
github.com/aws/aws-sdk-go v1.19.45/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
+github.com/aws/aws-sdk-go v1.26.0 h1:uddjCeunG1nroOMjcMO57g/vfwY7jJ1zYSEGmdeCeEs=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=

Add an example of "provisioning" (#191)

This new example demonstrates using dynamic providers to approximate
"provisioning". It implements CopyFile and RemoteExec custom resources
that will use SCP and SSH to accomplish post-provision configuration.

The real solution to this is to design and implement
https://github.com/pulumi/pulumi/issues/1691.