README
¶
Terraform Provider: PowerShell
A Terraform provider that manages resources through PowerShell scriptblocks. Define CRUD operations as PowerShell scripts, receive inputs through a bound $InputData parameter, emit a single result object to the PowerShell output stream, and manage any resource that PowerShell can reach.
Architecture
flowchart LR
TF[Terraform] --- GP["PowerShell Provider</br>(Go)"] --- PH["pshost sidecar<br/>(c# subprocess)"]
PH --- PS["embedded PowerShell 7<br/>(stdin/stdout JSON protocol)"]
The provider starts a single, long-lived pshost process during terraform init/plan/apply. pshost is a self-contained executable that embeds PowerShell 7 and the .NET runtime, so no PowerShell or .NET installation is required on the host — the provider runs on any OS/architecture Terraform supports (Linux, macOS, Windows; amd64 and arm64). All resource operations execute sequentially through this process, sharing provider-level state (credentials, connections, modules). A mutex ensures only one resource operation runs at a time.
Requirements
- Terraform >= 1.0
No runtime dependencies: the matching pshost sidecar is bundled in each provider release and embeds PowerShell 7, so PowerShell does not need to be installed separately.
Building from source additionally requires:
- Go >= 1.21
- .NET SDK >= 9.0 (to build the
pshostsidecar) - PowerShell 7+ is optional, only handy for authoring/testing scripts locally
Installation
From Source
git clone https://github.com/markdomansky/terraform-provider-powershell.git
cd terraform-provider-powershell
go build -o terraform-provider-powershell
Place the binary in your Terraform plugin directory or use a dev_overrides block in your Terraform CLI config:
# ~/.terraformrc
provider_installation {
dev_overrides {
"registry.terraform.io/markdomansky/powershell" = "/path/to/binary/directory"
}
direct {}
}
Provider Configuration
provider "powershell" {
# Optional: Script to run once during provider initialization.
# Use to authenticate, load modules, or set shared state in $global:ProviderState.
startup_script = file("${path.module}/scripts/init.ps1")
# Optional: Script to run once when the provider tears down, after all
# resources have finished. Runs in the same process as the startup script and
# every resource, so it can read $global:ProviderState to clean up.
shutdown_script = file("${path.module}/scripts/cleanup.ps1")
# Optional: Default timeout for all script executions (seconds). Default: 3600
timeout = 3600
}
Both startup_script and shutdown_script are shared by every resource: the
startup script runs exactly once before any resource operation, and the shutdown
script runs exactly once after the last one — all within the same persistent
PowerShell process.
Startup Script Example
The startup script authenticates once and stashes shared state in
$global:ProviderState for every resource script to read:
# scripts/init.ps1
$global:ProviderState = @{}
Import-Module Az.Accounts
Connect-AzAccount -Identity
$global:ProviderState["subscription"] = (Get-AzContext).Subscription.Id
The script has no dedicated credential argument — it's just a PowerShell string,
so credentials are passed by interpolating Terraform variables or by reading
$env: values from the provider process. See
Passing credentials to the startup script
in the docs for the full pattern, including how to keep secrets out of state.
Shared Scripts Across Multiple Resources
The startup_script and shutdown_script run once per Terraform run, not
once per resource. Every powershell_script resource shares the same persistent
PowerShell process, so any state the startup script puts in
$global:ProviderState is visible to all of them:
provider "powershell" {
startup_script = file("${path.module}/scripts/init.ps1") # connects once
shutdown_script = file("${path.module}/scripts/cleanup.ps1") # disconnects once
}
resource "powershell_script" "web" {
create_script = <<-PS
$sub = $global:ProviderState["subscription"] # shared from init.ps1
[PSCustomObject]@{ id = "web"; subscription = $sub }
PS
read_script = "..."
update_script = "..."
delete_script = "..."
}
resource "powershell_script" "db" {
create_script = <<-PS
$sub = $global:ProviderState["subscription"] # same shared connection
[PSCustomObject]@{ id = "db"; subscription = $sub }
PS
read_script = "..."
update_script = "..."
delete_script = "..."
}
init.ps1 connects a single time before web and db are created, and
cleanup.ps1 disconnects a single time after the last one is processed. See
Sharing state across multiple resources
in the docs for a complete walkthrough, including isolation guarantees.
Resource: powershell_script
Manages a resource through PowerShell CRUD scriptblocks.
Schema
| Attribute | Type | Required | Description |
|---|---|---|---|
create_script |
String | Yes | Script for resource creation. Must emit exactly one object to the output stream whose id field is a non-empty string. Changes do not replace the resource; use triggers. |
read_script |
String | Yes | Script to read current state. Must emit exactly one object. Emit nothing to signal resource is gone. |
update_script |
String | No | Script for in-place updates, run when input_data/sensitive_input_data change. Must emit exactly one object. When omitted, input changes force a replacement. |
delete_script |
String | Yes | Script for resource deletion. |
input_data |
String (JSON) | No | Input data delivered to scripts through the bound $InputData parameter (a hashtable). Use jsonencode(). |
sensitive_input_data |
String (JSON) | No | Sensitive input, merged into $InputData after input_data (sensitive wins on collision). Redacted from plan output; still stored in state. |
output_data |
String (JSON) | Computed | The single object the script emitted, excluding the reserved sensitive key. Use jsondecode() to extract values. Must include id key/value. |
sensitive_output_data |
String (JSON) | Computed | The value the script emitted under the reserved top-level sensitive key ("{}" when absent). Redacted from plan output. |
triggers |
Map(String) | No | Map of values that force recreation when changed. |
timeout |
Number | No | Per-resource timeout override (seconds). |
A read-only powershell_script data source is also available for scripts that
only fetch data — see Data Source: powershell_script
below.
Script Contract
A CRUD script receives its inputs through a bound $InputData parameter — the host
injects a param($InputData) block automatically, so do not write your own
param(...) block. It returns its result by emitting exactly one object to the
output (success) stream — typically [PSCustomObject]@{ id = '...'; ... }. There is no
$OutputData variable. The following are available inside any CRUD scriptblock:
| Name | Type | Description |
|---|---|---|
$InputData |
Hashtable | Bound parameter parsed from input_data JSON. On read/update/delete, also includes id. |
$global:ProviderState |
Hashtable | Shared state from startup_script. Persists across all operations. |
$Action |
String | Current action: "create", "read", "update", or "delete". |
Emitting output: emit exactly one object to the output stream. A create script's
emitted object must have an id field that is a non-empty string. Emitting nothing
from a read script signals the resource is gone (Terraform removes it from state). If a
script emits more than one object — for example because a cmdlet leaked its result —
the operation fails; pipe stray cmdlet output to Out-Null to suppress it. All other
PowerShell streams (Information/Warning/Verbose/Debug) are captured for diagnostics, and
any record written to the error stream marks the operation as failed, even without a
thrown terminating error.
Isolation: CRUD scripts run in an isolated child scope.
$global:ProviderStateis captured after the script runs, but any other variables a script creates are discarded when it finishes, so they cannot leak into the next resource's script. The only sanctioned channel for state that must survive between operations is$global:ProviderState(which yourstartup_scriptcreates, e.g.$global:ProviderState = @{}, and which lives in the persistent runspace for the whole run). If a script fails, its emitted output is dropped, so a failed operation never returns stale data. Note that$global:ProviderStateis not rolled back on failure — any mutations a failing script made to it persist for later operations.
Basic Example
resource "powershell_script" "example" {
# Scripts use cross-platform PowerShell 7 cmdlets and a portable temp path,
# so this works identically on Linux, macOS, and Windows.
create_script = <<-PS
$path = Join-Path ([System.IO.Path]::GetTempPath()) $InputData.name
New-Item -Path $path -ItemType File -Value "managed" -Force | Out-Null
[PSCustomObject]@{
id = $InputData.name
path = $path
}
PS
read_script = <<-PS
$path = Join-Path ([System.IO.Path]::GetTempPath()) $InputData.id
if (Test-Path $path) {
[PSCustomObject]@{
id = $InputData.id
path = $path
}
}
PS
update_script = <<-PS
$path = Join-Path ([System.IO.Path]::GetTempPath()) $InputData.id
Set-Content -Path $path -Value $InputData.content
[PSCustomObject]@{
id = $InputData.id
content = $InputData.content
}
PS
delete_script = <<-PS
$path = Join-Path ([System.IO.Path]::GetTempPath()) $InputData.id
Remove-Item -Path $path -Force -ErrorAction SilentlyContinue
PS
input_data = jsonencode({
name = "my-resource"
content = "hello world"
})
}
output "resource_path" {
value = jsondecode(powershell_script.example.output_data).path
}
Data Source: powershell_script
Reads data through a read-only PowerShell script — the PowerShell counterpart of
the hashicorp/external data source. Use it to look things up; use the resource
when Terraform should own an object's lifecycle.
The script runs in the same persistent process (and remote session) as every
resource, so it can read $global:ProviderData and any globals seeded by
startup_script.
data "powershell_script" "os_info" {
script = <<-PS
$os = [System.Environment]::OSVersion
[PSCustomObject]@{
platform = "$($os.Platform)"
version = "$($os.Version)"
machine = [System.Environment]::MachineName
}
PS
}
# output_data is JSON in the same shape input_data expects — pass it straight through.
resource "powershell_script" "report" {
create_script = "..."
read_script = "..."
delete_script = "..."
input_data = data.powershell_script.os_info.output_data
}
output "machine" {
value = jsondecode(data.powershell_script.os_info.output_data).machine
}
Schema
| Attribute | Type | Required | Description |
|---|---|---|---|
script |
String | Yes | Read-only script. Must emit at most one object. Runs with $Action = "read". |
input_data |
String (JSON) | No | Input delivered through the bound $InputData parameter. Use jsonencode(). |
sensitive_input_data |
String (JSON) | No | Sensitive input, merged into $InputData after input_data (sensitive wins on collision). Redacted from plan output; still stored in state. |
output_data |
String (JSON) | Computed | The object the script emitted, excluding the reserved sensitive key. "{}" if the script emitted nothing. |
sensitive_output_data |
String (JSON) | Computed | The value emitted under the reserved top-level sensitive key ("{}" when absent). Redacted from plan output. |
timeout |
Number | No | Per-read timeout override (seconds). |
The script contract is the resource's contract with two differences: $Action is
always "read", and emitting nothing is valid (it yields output_data = "{}"
rather than signalling deletion — a data source has no state to remove).
It runs on every plan. The script executes during refresh on every
planandapply, and nothing is persisted between runs. Keep it read-only and cheap. When a resource'sinput_datareferences a data source'soutput_data, a changed lookup result shows up as a proposed update on the next plan — that is how external reality enters your plan.
See docs/data-sources/script.md for secret handling, not-found behaviour, and ordering details, and examples/data-source/ for a runnable config.
Submodule Pattern (Recommended)
The recommended way to use this provider is to create reusable Terraform modules that bundle scripts with typed variables:
modules/
└── managed-file/
├── main.tf # powershell_script resource
├── variables.tf # Typed inputs (file_path, content, etc.)
├── outputs.tf # Extracted outputs
└── scripts/
├── create.ps1
├── read.ps1
├── update.ps1
└── delete.ps1
Module Definition (modules/managed-file/main.tf)
resource "powershell_script" "this" {
create_script = file("${path.module}/scripts/create.ps1")
read_script = file("${path.module}/scripts/read.ps1")
update_script = file("${path.module}/scripts/update.ps1")
delete_script = file("${path.module}/scripts/delete.ps1")
input_data = jsonencode({
file_path = var.file_path
content = var.content
})
triggers = {
content_hash = sha256(var.content)
}
}
Passing variables into script files: Terraform reads
file(...)contents verbatim, so${var.name}inside a.ps1file is not interpolated. Pass values throughinput_dataand read them from the$InputDataparameter (the script above reads$InputData.file_path). See Passing variables to script files for the full pattern, includingtemplatefile()when you genuinely need interpolation.
Usage
module "config" {
source = "./modules/managed-file"
file_path = "/etc/myapp/config.json"
content = jsonencode({ port = 8080 })
}
output "config_id" {
value = module.config.resource_id
}
Benefits
- Reusable: Same module manages any file, just change variables
- Typed: Terraform validates inputs before scripts run
- Testable: Scripts can be tested independently with Pester
- Versionable: Pin module versions, track changes in git
- Encapsulated: Consumers don't see PowerShell details
Targeting two provider aliases from one module
A module can act against two independent targets (e.g. a primary and a replica, or two tenants) by declaring alias slots and letting each resource pick one:
# modules/replicated-record/main.tf
terraform {
required_providers {
powershell = {
source = "markdomansky/powershell"
configuration_aliases = [powershell.primary, powershell.replica]
}
}
}
resource "powershell_script" "primary" {
provider = powershell.primary
# ...create/read/update/delete scripts...
}
resource "powershell_script" "replica" {
provider = powershell.replica
# ...same scripts, routed to the replica configuration...
}
The caller binds those slots to two aliased configurations — each an isolated
sidecar with its own $global:ProviderState — via a providers map:
module "config_record" {
source = "./modules/replicated-record"
providers = {
powershell.primary = powershell.primary
powershell.replica = powershell.replica
}
}
See Using two provider aliases in a submodule
in the docs for the complete example, including per-alias startup_script
connections and how the script reads its alias's state.
Patterns and Practices
Credential Management
Pass credentials through the provider startup script and access them in resource scripts:
provider "powershell" {
startup_script = <<-PS
$global:ProviderState = @{}
# Store credentials in provider state (NOT in Terraform state)
$global:ProviderState["api_key"] = $env:API_KEY
$global:ProviderState["api_url"] = $env:API_URL
PS
}
# In any CRUD script:
$headers = @{ "Authorization" = "Bearer $($global:ProviderState['api_key'])" }
$baseUrl = $global:ProviderState["api_url"]
Idempotent Scripts
Write scripts that are safe to run multiple times:
# create.ps1 - Check before creating
$existing = Get-Something -Name $InputData.name -ErrorAction SilentlyContinue
if ($existing) {
# Already exists - just emit output
[PSCustomObject]@{ id = $existing.Id }
} else {
$new = New-Something -Name $InputData.name
[PSCustomObject]@{ id = $new.Id }
}
Error Handling
Scripts that throw or hit terminating errors will cause the Terraform operation to fail with the error message. Writing any record to the error stream also marks the operation as failed, even without a thrown terminating error:
# Validate inputs
if (-not $InputData.name) {
throw "input_data must include 'name'"
}
# Use -ErrorAction Stop to make cmdlet errors terminating
$result = Invoke-WebRequest -Uri $url -ErrorAction Stop
Read Script for Drift Detection
The read script is called during terraform plan to detect drift. Emit nothing if the resource no longer exists:
# read.ps1
$resource = Get-MyResource -Id $InputData.id -ErrorAction SilentlyContinue
if ($resource) {
[PSCustomObject]@{
id = $resource.Id
status = $resource.Status
}
}
# If $resource is null, the script emits nothing -> Terraform marks as deleted
Triggers for Script Changes
Use triggers to force recreation when scripts change:
resource "powershell_script" "example" {
# ...scripts...
triggers = {
create_hash = sha256(file("${path.module}/scripts/create.ps1"))
read_hash = sha256(file("${path.module}/scripts/read.ps1"))
config_hash = sha256(jsonencode(local.config))
}
}
Testing
Run Go Unit Tests
go test ./scriptprovider/ -v -timeout 120s
Run Pester Tests
Invoke-Pester ./tests/pester/ -Output Detailed
Run Terraform Acceptance Tests
TF_ACC=1 go test ./tests/ -v -timeout 600s
Development
# Build
go build -o terraform-provider-powershell
# Run all tests
go test ./... -timeout 120s
# Run Pester tests
pwsh -Command 'Invoke-Pester ./tests/pester/ -Output Detailed'
How It Works
- Provider Configure: Starts a persistent
pshostsidecar (a self-contained executable with PowerShell 7 embedded) running a responder loop. Optionally executes a startup script. - Command Protocol: Each CRUD operation sends a JSON command to the
pshostprocess via stdin, delimited by###PS_CMD_START###/###PS_CMD_END###markers. - Script Execution: The responder parses the command, binds the
$InputDataparameter and sets$Action(and$global:ProviderState), runs the scriptblock in an isolated child scope (for CRUD actions), and captures the single object the script emits to its output stream. Other streams are captured for diagnostics, and any error-stream record fails the operation. - Response Protocol: The responder writes a JSON response between
###PS_RSP_START###/###PS_RSP_END###markers. - Serial Execution: A Go mutex ensures only one resource operation executes at a time, preventing race conditions in the shared PowerShell runspace.
- Cleanup: When Terraform finishes, the provider runs the optional
shutdown_script(once, in the same process) and then closes stdin, causing the responder loop to exit gracefully.
License
MPL-2.0
Documentation
¶
There is no documentation for this package.