When libraries don't exist
You are building a Rust CLI that manages cloud infrastructure. You want to spin up a database, verify the configuration, and apply changes with a single command. You open your Cargo.toml and search for a crate that lets you call Terraform::new().apply(). The search returns nothing useful. There is no native Rust library for Terraform. There is no crate that exposes Pulumi's core engine as a Rust API.
The reason is simple. Terraform is written in Go. Pulumi's engine is written in TypeScript and Python. Rust cannot import these as libraries. The runtimes, memory allocators, and concurrency models clash. Trying to link against Go code from Rust requires fragile FFI bindings and deep knowledge of both runtimes. The community has settled on a better approach. You treat these tools as external processes. You orchestrate them from Rust using subprocesses.
This pattern gives you the full power of the infrastructure engines without fighting language boundaries. You get state management, provider plugins, and error handling for free. Your Rust code stays focused on orchestration, logic, and user experience. You drive the tool instead of rebuilding it.
The subprocess pattern
Rust provides std::process::Command for spawning external programs. You use it to run terraform or pulumi binaries, pass arguments, and capture output. The subprocess runs in isolation. It has its own memory space and lifecycle. Your Rust program waits for it to finish, checks the exit code, and reads the logs.
Think of it like a film production. Rust is the director. Terraform is the camera crew. The director does not build the cameras. The director tells the crew what to shoot and reviews the footage. You send commands to the CLI. You get back status and logs. You make decisions based on the results.
This approach is language-agnostic. It works the same way whether the tool is written in Go, Python, or C. It respects the tool's design. It avoids the nightmare of mixing runtimes. It is the standard way to integrate infrastructure tools into Rust applications.
Minimal example
The most common task is running a command and checking if it succeeded. You use Command::new to create a builder, chain arguments, and call output() to execute. The output() method blocks until the process finishes and captures all output.
use std::process::Command;
/// Runs `terraform apply` and returns a result based on the exit code.
fn apply_infrastructure() -> Result<(), String> {
// Spawn the terraform binary as a child process.
// The builder pattern lets you configure arguments before execution.
let output = Command::new("terraform")
.args(["apply", "-auto-approve"])
.output()
.map_err(|e| format!("Failed to start terraform: {}", e))?;
// Check the exit status. Zero means success.
// Non-zero exit codes indicate errors or warnings.
if output.status.success() {
Ok(())
} else {
// Capture stderr for the error message.
// Use lossy conversion to handle potential encoding issues safely.
let error_msg = String::from_utf8_lossy(&output.stderr);
Err(format!("Terraform failed: {}", error_msg))
}
}
The args method takes a slice of strings. It adds each item as a separate argument to the command. This prevents shell injection issues. You never pass a single string with spaces to args. You always pass a slice or use arg for single items. The output method returns an Output struct containing the exit status, standard output, and standard error as byte slices. You check status.success() to determine if the command worked. If it failed, you parse stderr to understand why.
How the command builder works
When you call Command::new("terraform"), Rust prepares a subprocess builder. It does not run anything yet. The builder stores the program name and configuration. You chain methods to add arguments, environment variables, and working directories. Calling output() triggers the execution.
Rust forks a child process and executes the binary. It waits for the process to terminate. It captures everything written to standard output and standard error. The result is an Output struct. The status field holds the exit code. The stdout and stderr fields hold the captured bytes.
If the binary is not found in the system PATH, output() returns an error. You should handle this case. Users might install Terraform via a package manager that places the binary in a non-standard location. You can check for the binary's existence or provide a clear error message.
Environment variables are often needed. Terraform and Pulumi read configuration from env vars like AWS_ACCESS_KEY_ID or PULUMI_CONFIG_PASSPHRASE. You use the env method to set variables for the subprocess. The child process inherits the parent's environment by default. You can override specific variables or clear the entire environment.
// Set an environment variable for the subprocess.
// This does not affect the parent process.
Command::new("terraform")
.env("TF_VAR_region", "us-east-1")
.args(["plan"])
.output()?;
The community convention is to use String::from_utf8_lossy when reading CLI output. CLI tools usually emit UTF-8, but from_utf8 returns a Result that forces you to handle errors. lossy replaces invalid bytes with the replacement character. It is safer for logging and error messages. It prevents your tool from crashing on a stray byte from the subprocess.
Realistic workflow: Pulumi providers
Pulumi offers a different integration path for advanced use cases. You can write a custom resource provider in Rust. A provider manages specific types of resources. If you need to control a device or service that lacks a Pulumi provider, you can build one in Rust.
You use the pulumi-providers crate to define resources. The crate provides macros and traits to implement the resource lifecycle. You define the schema, implement create, read, update, and delete operations, and compile the provider to a binary. The Pulumi engine calls your binary via gRPC.
This approach lets you write complex resource logic in Rust. You get Rust's performance and safety for operations that require heavy computation or precise control. You still leverage Pulumi's deployment model and state management. The provider runs as a separate process, just like the CLI.
// Conceptual sketch of a Pulumi provider in Rust.
// The pulumi-providers crate generates the gRPC server code.
// You implement the Resource trait for each resource type.
use pulumi::provider::Resource;
/// Defines a custom resource for managing a hypothetical device.
struct MyDeviceResource;
impl Resource for MyDeviceResource {
// Implement the lifecycle methods.
// Pulumi calls these methods via gRPC during stack operations.
async fn create(&self, inputs: serde_json::Value) -> Result<serde_json::Value, String> {
// Perform the actual creation logic here.
// Return the state of the created resource.
Ok(inputs)
}
async fn read(&self, id: String) -> Result<serde_json::Value, String> {
// Fetch the current state of the resource.
Ok(serde_json::json!({ "id": id }))
}
// Implement update and delete similarly.
}
You compile this code to a binary. You register the binary with Pulumi using pulumi plugin install. The engine starts the provider process and communicates with it. Your Rust code handles the resource logic. The engine handles the orchestration. This is the best of both worlds. You use Rust for what it does best. You use Pulumi for infrastructure management.
Pitfalls and edge cases
Subprocess orchestration is robust, but it has traps. The first is blocking. The output() method waits for the process to finish. If you run a long terraform apply, your Rust thread blocks until it completes. This is fine for simple scripts. It is bad for interactive tools. If you need to show progress, use spawn() to start the process and stream the output. You can read from stdout and stderr in real-time.
The second trap is state locking. Terraform locks state files to prevent concurrent modifications. If you run multiple apply commands in parallel, they will fight for the lock. One will succeed. The others will fail. You must serialize state-modifying operations. Use a mutex or a queue in your Rust code to ensure only one command runs at a time.
The third trap is JSON parsing. Terraform and Pulumi can output plans and state as JSON. You can parse this JSON in Rust using serde_json. This lets you inspect resources without applying changes. Be careful with the schema. The JSON structure can change between versions. Pin your tool to a specific version of the CLI or handle schema variations gracefully.
If you pass a single string to .args() instead of a slice, the compiler rejects it with E0308 (mismatched types). The method expects &[&str] or an iterator. Wrap your argument in &[...] or use .arg() for single items. This error catches a common mistake. It prevents shell injection and ensures arguments are separated correctly.
Timeouts are another concern. Command does not have a built-in timeout. If the subprocess hangs, your Rust program hangs. You need to implement a watchdog. You can use tokio::process for async timeouts or spawn a separate thread to kill the process after a duration. Infrastructure commands can take a long time. Set generous timeouts. Provide clear feedback to the user.
Decision matrix
Use std::process::Command when you need to orchestrate Terraform or Pulumi workflows from a Rust CLI or service. This approach gives you full access to the engine's features, handles state management automatically, and keeps your code decoupled from the tool's internals. It is the standard pattern for most use cases.
Use the pulumi-providers crate when you are building a custom resource provider for Pulumi. This allows you to write resource logic in Rust, compile it to a binary, and register it with the Pulumi engine via gRPC. You get Rust's performance and safety for complex resource operations while leveraging Pulumi's deployment model.
Use the tfstate crate when you need to read Terraform state files for analysis or reporting without modifying infrastructure. This crate parses .tfstate JSON files directly in Rust. It is read-only and does not execute changes. It is useful for auditing, dashboards, and integration tests.
Reach for the Pulumi CLI via subprocess when you want to manage Pulumi stacks programmatically without writing a provider. This mirrors the Terraform pattern and works for standard stack operations like up, down, and preview. It is simpler than building a provider and sufficient for most automation tasks.
Trust the subprocess boundary. It isolates failures and keeps your code clean. Drive the tool, don't rebuild it.