Add azure provisioner example
ADD
azure-ts-vm-provisioners/.gitignore
+rsa
+rsa.pub
+node_modules/
+yarn.lock
+package-lock.json

ADD
azure-ts-vm-provisioners/Pulumi.yaml
+name: azure-ts-vm-provisioners
+runtime: nodejs
+description: An example of an Azure VM webserver instance with post-provision configuration.

ADD
azure-ts-vm-provisioners/README.md
+# Azure 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`.
+
+Now, we need to ensure that our dependencies are installed:
+
+```
+$ npm install
+```
+
+
+You'll need to log in to the azure cli. You will be prompted to do this during deployment if you forget this step.
+
+```
+$ az login
+```
+
+We'll need to set some config for login credentials, and location information.
+
+```
+pulumi config set azure:location westus
+pulumi config set username <your_username>
+pulumi config set password --secret <your_desired_password>
+
+```
+
+Next, generate an OpenSSH keypair for use with your server - as per the Azure [Requirements][1]
+
+```
+$ ssh-keygen -t rsa -f rsa -m PEM
+```
+
+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 VM, and the private key used
+for subsequent SCP and SSH steps to 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.
+
+
+From there, you can run `pulumi up` and all resources will be provisioned and configured.
+
+[1]: https://docs.microsoft.com/en-us/azure/virtual-machines/linux/ssh-from-windows#create-an-ssh-key-pair

ADD
azure-ts-vm-provisioners/index.ts
+// Copyright 2016-2020, Pulumi Corporation. All rights reserved.
+
+import * as azure from "@pulumi/azure";
+import * as pulumi from "@pulumi/pulumi";
+import * as provisioners from "./provisioners";
+import { getFileHash } from "./util";
+
+// Get the config ready to go.
+const config = new pulumi.Config();
+
+// Set login credentials for the VM
+const username = config.require("username");
+const password = config.requireSecret("password");
+
+// retrieve the ssh publicKey from config
+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 Buffer.from(key, "base64").toString("ascii");
+ }
+});
+const privateKeyPassphrase = config.getSecret("privateKeyPassphrase");
+
+
+// All resources will share a resource group.
+const resourceGroupName = new azure.core.ResourceGroup("server-rg").name;
+
+// Create a network and subnet for all VMs.
+const network = new azure.network.VirtualNetwork("server-network", {
+ resourceGroupName,
+ addressSpaces: ["10.0.0.0/16"],
+ subnets: [{
+ name: "default",
+ addressPrefix: "10.0.1.0/24",
+ }],
+});
+
+// Now allocate a public IP and assign it to our NIC.
+const pubIp = new azure.network.PublicIp("server-ip", {
+ resourceGroupName,
+ allocationMethod: "Dynamic",
+});
+
+const networkInterface = new azure.network.NetworkInterface("server-nic", {
+ resourceGroupName,
+ ipConfigurations: [{
+ name: "webserveripcfg",
+ subnetId: network.subnets[0].id,
+ privateIpAddressAllocation: "Dynamic",
+ publicIpAddressId: pubIp.id,
+ }],
+});
+
+const sg = new azure.network.NetworkSecurityGroup("sg", {
+ resourceGroupName: resourceGroupName,
+ securityRules: [
+ {
+ access: "Allow",
+ protocol: "*",
+ sourceAddressPrefix: "*",
+ destinationAddressPrefix: "*",
+ destinationPortRange: "*",
+ sourcePortRange: "*",
+ direction: "Inbound",
+ name: "in",
+ priority: 100,
+ },
+ {
+ access: "Allow",
+ protocol: "*",
+ sourceAddressPrefix: "*",
+ destinationAddressPrefix: "*",
+ destinationPortRange: "*",
+ sourcePortRange: "*",
+ direction: "Outbound",
+ name: "out",
+ priority: 101,
+ },
+ ],
+
+});
+
+const sga = new azure.network.NetworkInterfaceSecurityGroupAssociation("assoc", {
+ networkInterfaceId: networkInterface.id,
+ networkSecurityGroupId: sg.id,
+});
+
+// Now create the VM, using the resource group and NIC allocated above.
+const vm = new azure.compute.VirtualMachine("server-vm", {
+ resourceGroupName,
+ networkInterfaceIds: [networkInterface.id],
+ vmSize: "Standard_A0",
+ deleteDataDisksOnTermination: true,
+ deleteOsDiskOnTermination: true,
+ osProfile: {
+ computerName: "hostname",
+ adminUsername: username,
+ adminPassword: password,
+ },
+ osProfileLinuxConfig: {
+ disablePasswordAuthentication: false,
+ sshKeys: [
+ {
+ keyData: publicKey,
+ path: `/home/${username}/.ssh/authorized_keys`,
+ },
+ ],
+ },
+ storageOsDisk: {
+ createOption: "FromImage",
+ name: "myosdisk1",
+ },
+ storageImageReference: {
+ publisher: "canonical",
+ offer: "UbuntuServer",
+ sku: "16.04-LTS",
+ version: "latest",
+ },
+});
+
+// The public IP address is not allocated until the VM is running, so wait for that
+// resource to create, and then lookup the IP address again to report its public IP.
+const done = pulumi.all({ _: vm.id, name: pubIp.name, resourceGroupName: pubIp.resourceGroupName });
+
+export const ipAddress = done.apply(d => {
+ return azure.network.getPublicIP({
+ name: d.name,
+ resourceGroupName: d.resourceGroupName,
+ }, { async: true }).then(ip => ip.ipAddress);
+});
+
+const conn: provisioners.ConnectionArgs = {
+ host: ipAddress,
+ username,
+ password,
+ privateKey,
+ privateKeyPassphrase,
+};
+
+const changeToken = getFileHash("myapp.conf");
+// Copy a config file to our server.
+const cpConfig = new provisioners.CopyFile("config", {
+ changeToken,
+ conn,
+ src: "myapp.conf",
+ dest: `/home/${username}/myapp.conf`,
+}, { dependsOn: [vm, pubIp] });
+
+// Execute a basic command on our server.
+const catConfig = new provisioners.RemoteExec("cat-config", {
+ changeToken,
+ conn,
+ command: `cat /home/${username}/myapp.conf`,
+}, { dependsOn: cpConfig });
+
+
+// export the command output
+export const catConfigStdout = catConfig.result.stdout;

ADD
azure-ts-vm-provisioners/myapp.conf
+[test]
+x = 42

ADD
azure-ts-vm-provisioners/package.json
+{
+ "name": "azure-vm-provisioners",
+ "version": "0.1.0",
+ "main": "index.js",
+ "dependencies": {
+ "@pulumi/azure": "^3.8.0",
+ "@pulumi/pulumi": "^2.0.0",
+ "@pulumi/tls": "^2.1.2",
+ "@types/ssh2": "^0.5.39",
+ "@types/uuid": "^3.4.4",
+ "scp2": "^0.5.0",
+ "ssh2": "^0.8.5",
+ "uuid": "^3.3.2"
+ }
+}

ADD
azure-ts-vm-provisioners/provisioners/index.ts
+// Copyright 2016-2020, 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,
+ changeToken: args.changeToken,
+ onCreate: (conn) => copyFile(conn, args.src, args.dest),
+ },
+ { parent: this },
+ );
+ }
+}
+
+interface ChangeTrackingProvisioner {
+ // changeToken allows you to specify a value that controls the replacement of the dependent dynamic resources.
+ // Specifing a value that changes with the content of the file(s) allows the provisioner resources to be
+ // replaced every time the content(s) change.
+ changeToken?: string;
+}
+
+export interface CopyFileArgs extends ChangeTrackingProvisioner {
+ // 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;
+ },
+ changeToken: args.changeToken,
+ },
+ { parent: this },
+ );
+
+ this.result = this.provisioner.result[0];
+ this.results = this.provisioner.result;
+ }
+}
+
+export interface RemoteExecArgs extends ChangeTrackingProvisioner {
+ // 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
azure-ts-vm-provisioners/provisioners/provisioner.ts
+// Copyright 2016-2020, 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>) => {
+ let replace = false;
+ let replacementProperties = [];
+ if (JSON.stringify(olds.dep) !== JSON.stringify(news.dep)) {
+ replace = true;
+ replacementProperties.push("dep");
+ }
+ // Only trigger replacement due to the `changeToken` property, IFF
+ // the changeToken still has a value in the new inputs, and it doesn't
+ // match with the old value. If, say, the user decides to no longer specify
+ // the changeToken in the new inputs, then we don't trigger a replacement.
+ if (news.changeToken && olds.changeToken !== news.changeToken) {
+ replace = true;
+ replacementProperties.push("changeToken");
+ }
+ return {
+ changes: replace,
+ replaces: replace ? replacementProperties : 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>;
+ changeToken: pulumi.Input<string>;
+ onCreate: (dep: pulumi.Unwrap<T>) => Promise<pulumi.Unwrap<U>>;
+}
+
+interface State<T, U> {
+ dep: pulumi.Unwrap<T>;
+ changeToken: pulumi.Unwrap<string>;
+ result: pulumi.Unwrap<U>;
+}

ADD
azure-ts-vm-provisioners/util.ts
+// Copyright 2016-2020, Pulumi Corporation. All rights reserved.
+
+import * as crypto from "crypto";
+import * as fs from "fs";
+import * as path from "path";
+
+// getFileHash calculates a hash for all of the files under the scripts directory.
+export function getFileHash(filename: string): string {
+ const data = fs.readFileSync(path.join(__dirname, filename), {encoding: "utf8"});
+ const hash = crypto.createHash("md5").update(data, "utf8");
+ return hash.digest("hex");
+}

Add azure provisioner example (#704)