Pulumi Command Provider 1.0 Release

Posted on

Today, we’re happy to announce the 1.0 release of the Pulumi Command provider. This release marks the provider’s official transition from preview status to general availability (GA). Since its first preview release in late 2021, thousands of Pulumi users incorporated the Command package into their cloud infrastructure projects to help manage local and remote command execution and filesystem operations. In fact, the Command provider is already our ninth-most popular provider, so it’s time to make things official!

The Pulumi Command Provider enables you to execute commands and scripts either locally or remotely as part of the Pulumi resource model, enabling stateful command execution. It also has convenient support for copying assets via SSH. Being able to run arbitrary commands opens up a wide variety of scenarios and integrations with other tools and systems.

Some of the provider’s popular uses include:

  • Running a command locally after creating a resource, to register it with an external service
  • Running a command locally before deleting a resource, to deregister it with an external service
  • Running a command on a remote host immediately after creating it
  • Copying a file to a remote host after creating it (potentially as a script to be executed afterwards)
  • As a simple alternative to some use cases for Dynamic Providers, by running appropriate commands in the different stages of the Pulumi life cycle.

Here’s a simple example of running an arbitrary command when another resource changes: once an AWS Lambda function is deployed, we call it and it responds with a message that is custom to the current Pulumi stack. In a real-world scenario, we could wait for a number of other resources to be deployed as well, and have the Lambda perform registration or initialization of services.

import * as aws from "@pulumi/aws";
import { local } from "@pulumi/command";
import { getStack } from "@pulumi/pulumi";

const f = new aws.lambda.CallbackFunction("f", {
    publish: true,
    callback: async (ev: any) => {
        return `Stack ${ev.stackName} is deployed!`;
    }
});

const invoke = new local.Command("execf", {
    create: `aws lambda invoke --function-name "$FN" --payload '{"stackName": "${getStack()}"}' --cli-binary-format raw-in-base64-out out.txt >/dev/null && cat out.txt | tr -d '"'  && rm out.txt`,
    environment: {
        FN: f.qualifiedArn,
        AWS_REGION: aws.config.region!,
        AWS_PAGER: "",
    },
}, { dependsOn: f })

export const output = invoke.stdout;
import pulumi
import json
import pulumi_aws as aws
import pulumi_command as command

lambda_role = aws.iam.Role("lambdaRole", assume_role_policy=json.dumps({
    "Version": "2012-10-17",
    "Statement": [{
        "Action": "sts:AssumeRole",
        "Effect": "Allow",
        "Principal": {
            "Service": "lambda.amazonaws.com",
        },
    }],
}))

lambda_function = aws.lambda_.Function("lambdaFunction",
    name="f",
    publish=True,
    role=lambda_role.arn,
    handler="index.handler",
    runtime=aws.lambda_.Runtime.NODE_JS20D_X,
    code=pulumi.FileArchive("./handler"))

aws_config = pulumi.Config("aws")
aws_region = aws_config.require("region")

invoke_command = command.local.Command("invokeCommand",
    create=f"aws lambda invoke --function-name \"$FN\" --payload '{{\"stackName\": \"{pulumi.get_stack()}\"}}' --cli-binary-format raw-in-base64-out out.txt >/dev/null && cat out.txt | tr -d '\"'  && rm out.txt",
    environment={
        "FN": lambda_function.arn,
        "AWS_REGION": aws_region,
        "AWS_PAGER": "",
    },
    opts = pulumi.ResourceOptions(depends_on=[lambda_function]))

pulumi.export("output", invoke_command.stdout)
package main

import (
	"encoding/json"
	"fmt"

	"github.com/pulumi/pulumi-aws/sdk/v6/go/aws/iam"
	"github.com/pulumi/pulumi-aws/sdk/v6/go/aws/lambda"
	"github.com/pulumi/pulumi-command/sdk/go/command/local"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)

func main() {
	pulumi.Run(func(ctx *pulumi.Context) error {
		awsConfig := config.New(ctx, "aws")
		awsRegion := awsConfig.Require("region")

		tmpJSON0, err := json.Marshal(map[string]interface{}{
			"Version": "2012-10-17",
			"Statement": []map[string]interface{}{
				{
					"Action": "sts:AssumeRole",
					"Effect": "Allow",
					"Principal": map[string]interface{}{
						"Service": "lambda.amazonaws.com",
					},
				},
			},
		})
		if err != nil {
			return err
		}
		json0 := string(tmpJSON0)
		lambdaRole, err := iam.NewRole(ctx, "lambdaRole", &iam.RoleArgs{
			AssumeRolePolicy: pulumi.String(json0),
		})
		if err != nil {
			return err
		}
		lambdaFunction, err := lambda.NewFunction(ctx, "lambdaFunction", &lambda.FunctionArgs{
			Name:    pulumi.String("f"),
			Publish: pulumi.Bool(true),
			Role:    lambdaRole.Arn,
			Handler: pulumi.String("index.handler"),
			Runtime: pulumi.String(lambda.RuntimeNodeJS20dX),
			Code:    pulumi.NewFileArchive("./handler"),
		})
		if err != nil {
			return err
		}
		invokeCommand, err := local.NewCommand(ctx, "invokeCommand", &local.CommandArgs{
			Create: pulumi.String(fmt.Sprintf("aws lambda invoke --function-name \"$FN\" --payload '{\"stackName\": \"%v\"}' --cli-binary-format raw-in-base64-out out.txt >/dev/null && cat out.txt | tr -d '\"'  && rm out.txt", ctx.Stack())),
			Environment: pulumi.StringMap{
				"FN":         lambdaFunction.Arn,
				"AWS_REGION": pulumi.String(awsRegion),
				"AWS_PAGER":  pulumi.String(""),
			},
		}, pulumi.DependsOn([]pulumi.Resource{
			lambdaFunction,
		}))
		if err != nil {
			return err
		}
		ctx.Export("output", invokeCommand.Stdout)
		return nil
	})
}
using System.Collections.Generic;
using System.Text.Json;
using Pulumi;
using Aws = Pulumi.Aws;
using Command = Pulumi.Command;

return await Deployment.RunAsync(() =>
{
    var awsConfig = new Config("aws");

    var lambdaRole = new Aws.Iam.Role("lambdaRole", new()
    {
        AssumeRolePolicy = JsonSerializer.Serialize(new Dictionary<string, object?>
        {
            ["Version"] = "2012-10-17",
            ["Statement"] = new[]
            {
                new Dictionary<string, object?>
                {
                    ["Action"] = "sts:AssumeRole",
                    ["Effect"] = "Allow",
                    ["Principal"] = new Dictionary<string, object?>
                    {
                        ["Service"] = "lambda.amazonaws.com",
                    },
                },
            },
        }),
    });

    var lambdaFunction = new Aws.Lambda.Function("lambdaFunction", new()
    {
        Name = "f",
        Publish = true,
        Role = lambdaRole.Arn,
        Handler = "index.handler",
        Runtime = Aws.Lambda.Runtime.NodeJS20dX,
        Code = new FileArchive("./handler"),
    });

    var invokeCommand = new Command.Local.Command("invokeCommand", new()
    {
        Create = $"aws lambda invoke --function-name \"$FN\" --payload '{{\"stackName\": \"{Deployment.Instance.StackName}\"}}' --cli-binary-format raw-in-base64-out out.txt >/dev/null && cat out.txt | tr -d '\"'  && rm out.txt",
        Environment =
        {
            { "FN", lambdaFunction.Arn },
            { "AWS_REGION", awsConfig.Require("region") },
            { "AWS_PAGER", "" },
        },
    }, new CustomResourceOptions
    {
        DependsOn =
        {
            lambdaFunction,
        },
    });

    return new Dictionary<string, object?>
    {
        ["output"] = invokeCommand.Stdout,
    };
});
package generated_program;

import com.pulumi.Context;
import com.pulumi.Pulumi;
import com.pulumi.aws.iam.Role;
import com.pulumi.aws.iam.RoleArgs;
import com.pulumi.aws.lambda.Function;
import com.pulumi.aws.lambda.FunctionArgs;
import com.pulumi.command.local.Command;
import com.pulumi.command.local.CommandArgs;
import static com.pulumi.codegen.internal.Serialization.*;
import com.pulumi.resources.CustomResourceOptions;
import com.pulumi.asset.FileArchive;
import java.util.Map;

public class App {
    public static void main(String[] args) {
        Pulumi.run(App::stack);
    }

    public static void stack(Context ctx) {
        var awsConfig = ctx.config("aws");
        var awsRegion = awsConfig.require("region");

        var lambdaRole = new Role("lambdaRole", RoleArgs.builder()
                .assumeRolePolicy(serializeJson(
                        jsonObject(
                                jsonProperty("Version", "2012-10-17"),
                                jsonProperty("Statement", jsonArray(jsonObject(
                                        jsonProperty("Action", "sts:AssumeRole"),
                                        jsonProperty("Effect", "Allow"),
                                        jsonProperty("Principal", jsonObject(
                                                jsonProperty("Service", "lambda.amazonaws.com")))))))))
                .build());

        var lambdaFunction = new Function("lambdaFunction", FunctionArgs.builder()
                .name("f")
                .publish(true)
                .role(lambdaRole.arn())
                .handler("index.handler")
                .runtime("nodejs20.x")
                .code(new FileArchive("./handler"))
                .build());

        var lambdaInvokeOut = lambdaFunction.arn().applyValue(arn -> {
            var invokeCommand = new Command("invokeCommand", CommandArgs.builder()
                    .create(String.format(
                            "aws lambda invoke --function-name \"$FN\" --payload '{\"stackName\": \"%s\"}' --cli-binary-format raw-in-base64-out out.txt >/dev/null && cat out.txt | tr -d '\"'  && rm out.txt",
                            ctx.stackName()))
                    .environment(Map.ofEntries(
                            Map.entry("FN", arn),
                            Map.entry("AWS_REGION", awsRegion),
                            Map.entry("AWS_PAGER", "")))
                    .build(),
                    CustomResourceOptions.builder()
                            .dependsOn(lambdaFunction)
                            .build());

            return invokeCommand.stdout();
        });

        ctx.export("output", lambdaInvokeOut);
    }
}

resources:
  lambdaRole:
    type: aws:iam:Role
    properties:
      assumeRolePolicy:
        fn::toJSON:
          Version: "2012-10-17"
          Statement:
            - Action: sts:AssumeRole
              Effect: Allow
              Principal:
                Service: lambda.amazonaws.com

  lambdaFunction:
    type: aws:lambda:Function
    properties:
      name: f
      publish: true
      role: ${lambdaRole.arn}
      handler: index.handler
      runtime: "nodejs20.x"
      code:
        fn::fileArchive: ./handler

  invokeCommand:
    type: command:local:Command
    properties:
      create: 'aws lambda invoke --function-name "$FN" --payload ''{"stackName": "${pulumi.stack}"}'' --cli-binary-format raw-in-base64-out out.txt >/dev/null && cat out.txt | tr -d ''"''  && rm out.txt'
      environment:
        FN: ${lambdaFunction.arn}
        AWS_REGION: ${aws:region}
        AWS_PAGER: ""
    options:
      dependsOn:
        - ${lambdaFunction}

outputs:
  output: ${invokeCommand.stdout}

What’s new

The 1.0 release of the Command provider marks a stable API for the 1.x series. Rather than simply putting a “v1” label on the existing provider, we also used this opportunity to enhance it with some previously requested capabilities to provide a more complete set of capabilities.

  • The API documentation in the Pulumi registry has examples in all Pulumi languages and is expanded.
  • Capturing stdout and stderr of commands can now be switched off, which is useful when they might contain secrets or are very noisy.
  • Environment handling for remote commands has better error handling and is better documented.
  • The CopyFile resource is superseded by the new CopyToRemote resource. It can copy whole directories in addition to individual files. The source of the copy is now a Pulumi asset or archive which provides full interoperability with the Pulumi ecosystem. The use of assets and archives also makes Pulumi run copy operations only if the source has changed. For an easy transition, the previous CopyFile resource will remain available with a deprecation notice until the next major version.

Here’s an example of copying a directory to a remote host. For brevity, the remote server is assumed to exist, but it could also be provisioned in the same Pulumi program.

import * as pulumi from "@pulumi/pulumi";
import { remote, types } from "@pulumi/command";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

export = async () => {
    const config = new pulumi.Config();

    // Get the private key to connect to the server. If a key is
    // provided, use it, otherwise default to the standard id_rsa SSH key.
    const privateKeyBase64 = config.get("privateKeyBase64");
    const privateKey = privateKeyBase64 ?
        Buffer.from(privateKeyBase64, 'base64').toString('ascii') :
        fs.readFileSync(path.join(os.homedir(), ".ssh", "id_rsa")).toString("utf8");

    const serverPublicIp = config.require("serverPublicIp");
    const userName = config.require("userName");

    // The configuration of our SSH connection to the instance.
    const connection: types.input.remote.ConnectionArgs = {
        host: serverPublicIp,
        user: userName,
        privateKey: privateKey,
    };

    // Set up source and target of the remote copy.
    const from = config.require("payload")!;
    const archive = new pulumi.asset.FileArchive(from);
    const to = config.require("destDir")!;

    // Copy the files to the remote.
    const copy = new remote.CopyToRemote("copy", {
        connection,
        source: archive,
        remotePath: to,
    });

    // Verify that the expected files were copied to the remote.
    // We want to run this after each copy, i.e., when something changed,
    // so we use the asset to be copied as a trigger.
    const find = new remote.Command("ls", {
        connection,
        create: `find ${to}/${from} | sort`,
        triggers: [archive],
    }, { dependsOn: copy });

    return {
        remoteContents: find.stdout
    }
}
import pulumi
import pulumi_command as command

config = pulumi.Config()

server_public_ip = config.require("serverPublicIp")
user_name = config.require("userName")
private_key = config.require("privateKey")
payload = config.require("payload")
dest_dir = config.require("destDir")

archive = pulumi.FileArchive(payload)

# The configuration of our SSH connection to the instance.
conn = command.remote.ConnectionArgs(
    host = server_public_ip,
    user = user_name,
    privateKey = private_key,
)

# Copy the files to the remote.
copy = command.remote.CopyToRemote("copy",
    connection=conn,
    source=archive,
    destination=dest_dir)

# Verify that the expected files were copied to the remote.
# We want to run this after each copy, i.e., when something changed,
# so we use the asset to be copied as a trigger.
find = command.remote.Command("find",
    connection=conn,
    create=f"find {dest_dir}/{payload} | sort",
    triggers=[archive],
    opts = pulumi.ResourceOptions(depends_on=[copy]))

pulumi.export("remoteContents", find.stdout)
package main

import (
	"fmt"

	"github.com/pulumi/pulumi-command/sdk/go/command/remote"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)

func main() {
	pulumi.Run(func(ctx *pulumi.Context) error {
		cfg := config.New(ctx, "")
		serverPublicIp := cfg.Require("serverPublicIp")
		userName := cfg.Require("userName")
		privateKey := cfg.Require("privateKey")
		payload := cfg.Require("payload")
		destDir := cfg.Require("destDir")

		archive := pulumi.NewFileArchive(payload)

		conn := remote.ConnectionArgs{
			Host:       pulumi.String(serverPublicIp),
			User:       pulumi.String(userName),
			PrivateKey: pulumi.String(privateKey),
		}

		copy, err := remote.NewCopyToRemote(ctx, "copy", &remote.CopyToRemoteArgs{
			Connection: conn,
			Source:     archive,
		})
		if err != nil {
			return err
		}

		find, err := remote.NewCommand(ctx, "find", &remote.CommandArgs{
			Connection: conn,
			Create:     pulumi.String(fmt.Sprintf("find %v/%v | sort", destDir, payload)),
			Triggers: pulumi.Array{
				archive,
			},
		}, pulumi.DependsOn([]pulumi.Resource{
			copy,
		}))
		if err != nil {
			return err
		}

		ctx.Export("remoteContents", find.Stdout)
		return nil
	})
}
using System.Collections.Generic;
using Pulumi;
using Command = Pulumi.Command;

return await Deployment.RunAsync(() =>
{
    var config = new Config();
    var serverPublicIp = config.Require("serverPublicIp");
    var userName = config.Require("userName");
    var privateKey = config.Require("privateKey");
    var payload = config.Require("payload");
    var destDir = config.Require("destDir");

    var archive = new FileArchive(payload);

    // The configuration of our SSH connection to the instance.
    var conn = new Command.Remote.Inputs.ConnectionArgs
    {
        Host = serverPublicIp,
        User = userName,
        PrivateKey = privateKey,
    };

    // Copy the files to the remote.
    var copy = new Command.Remote.CopyToRemote("copy", new()
    {
        Connection = conn,
        Source = archive,
    });

    // Verify that the expected files were copied to the remote.
    // We want to run this after each copy, i.e., when something changed,
    // so we use the asset to be copied as a trigger.
    var find = new Command.Remote.Command("find", new()
    {
        Connection = conn,
        Create = $"find {destDir}/{payload} | sort",
        Triggers = new[]
        {
            archive,
        },
    }, new CustomResourceOptions
    {
        DependsOn =
        {
            copy,
        },
    });

    return new Dictionary<string, object?>
    {
        ["remoteContents"] = find.Stdout,
    };
});
package generated_program;

import com.pulumi.Context;
import com.pulumi.Pulumi;
import com.pulumi.core.Output;
import com.pulumi.command.remote.Command;
import com.pulumi.command.remote.CommandArgs;
import com.pulumi.command.remote.CopyToRemote;
import com.pulumi.command.remote.inputs.*;
import com.pulumi.resources.CustomResourceOptions;
import com.pulumi.asset.FileArchive;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;

public class App {
    public static void main(String[] args) {
        Pulumi.run(App::stack);
    }

    public static void stack(Context ctx) {
        final var config = ctx.config();
        final var serverPublicIp = config.require("serverPublicIp");
        final var userName = config.require("userName");
        final var privateKey = config.require("privateKey");
        final var payload = config.require("payload");
        final var destDir = config.require("destDir");

        final var archive = new FileArchive(payload);

        // The configuration of our SSH connection to the instance.
        final var conn = ConnectionArgs.builder()
            .host(serverPublicIp)
            .user(userName)
            .privateKey(privateKey)
            .build();

        // Copy the files to the remote.
        var copy = new CopyToRemote("copy", CopyToRemoteArgs.builder()
            .connection(conn)
            .source(archive)
            .destination(destDir)
            .build());

        // Verify that the expected files were copied to the remote.
        // We want to run this after each copy, i.e., when something changed,
        // so we use the asset to be copied as a trigger.
        var find = new Command("find", CommandArgs.builder()
            .connection(conn)
            .create(String.format("find %s/%s | sort", destDir,payload))
            .triggers(archive)
            .build(), CustomResourceOptions.builder()
                .dependsOn(copy)
                .build());

        ctx.export("remoteContents", find.stdout());
    }
}

resources:
  # Copy the files to the remote.
  copy:
    type: command:remote:CopyToRemote
    properties:
      connection: ${conn}
      source: ${archive}
      remotePath: ${destDir}

  # Verify that the expected files were copied to the remote.
  # We want to run this after each copy, i.e., when something changed,
  # so we use the asset to be copied as a trigger.
  find:
    type: command:remote:Command
    properties:
      connection: ${conn}
      create: find ${destDir}/${payload} | sort
      triggers:
        - ${archive}
    options:
      dependsOn:
        - ${copy}

config:
  serverPublicIp:
    type: string
  userName:
    type: string
  privateKey:
    type: string
  payload:
    type: string
  destDir:
    type: string

variables:
  # The source directory or archive to copy.
  archive:
    fn::fileArchive: ${payload}
  # The configuration of our SSH connection to the instance.
  conn:
    host: ${serverPublicIp}
    user: ${userName}
    privateKey: ${privateKey}

outputs:
  remoteContents: ${find.stdout}

Getting started

If you’re currently using one of the v0 pre-release versions of the Command provider, you don’t need to make any changes to your existing code. However, the enhanced functionality of copying assets and archives is only available in the new CopyToRemote resource. If you’re using CopyFile, you should consider migrating to the new resource after the upgrade to 1.0.

Enjoy the new features, and we’re looking forward to seeing what you build with the Command provider!