Disclaimer: I am working at HashiCorp (now IBM) as part of the Terraform
Core team. The postings on this site are my own and don’t necessarily represent
IBM’s positions, strategies or opinions.
Since I am involved in Terraform my opinions can sometimes be (unconsciously)
biased. I hope you enjoy the post anyway.
Content
What are Terraform Actions?
Terraform Actions are a new block in the Terraform language that allows you to express non-CRUD operations in your configuration. Please see Introduction to Terraform Actions for a detailed introduction.
Prerequisites
If you want to add an Action to your provider you need to do this through the terraform-plugin-framework
package. If your provider still uses SDK/v2
you can take a look at this post on adding mux
ing to add the terraform-plugin-framework
to your provider.
Once you have the plugin framework, we need to update the go.mod
to the following versions:
require (
// These are already final versions
github.com/hashicorp/terraform-plugin-framework v1.16.0
github.com/hashicorp/terraform-plugin-go v0.29.0
github.com/hashicorp/terraform-plugin-mux v0.21.0
// These will be updated to final versions soon
github.com/hashicorp/terraform-plugin-testing v1.14.0-beta.1
github.com/hashicorp/terraform-plugin-docs v0.22.1-0.20250916190709-65411a4e6cbd
)
Then run go mod tidy
to update the dependencies.
To run actions you need the current Terraform version as well: v1.14.0-beta2
is the first version of Terraform to support Actions.
How to add an Action
Let’s take a look at how to add an Action to your provider. As a very simple example, I will use the terraform-provider-bufo
that exposes an action that prints an image of Bufo the frog on your terminal.
It’s very minimal, that’s why it makes a good example to get started.
In your provider.go
file, you need to / should add an assertion to indicate that your provider has actions: var _ provider.ProviderWithActions = &BufoProvider{}
This should give you a compiler error that the Actions method is missing, so let’s add it:
// Source: https://github.com/austinvalle/terraform-provider-bufo/blob/62736f71a1db9d3bf167c4bbda16bbb9044d41dc/internal/provider/provider.go#L33-L37
func (p *BufoProvider) Actions(ctx context.Context) []func() action.Action {
return []func() action.Action{
NewPrintBufo,
}
}
Now let’s take a look at the NewPrintBufo
implementation:
// Source: https://github.com/austinvalle/terraform-provider-bufo/blob/9c39751bc738eb2072f2fbb6a22d11edef3cc1e0/internal/provider/bufo_print.go#L1-L127
package provider
import (
"context"
"fmt"
"image"
"math/rand"
"github.com/hashicorp/terraform-plugin-framework/action"
"github.com/hashicorp/terraform-plugin-framework/action/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/qeesung/image2ascii/convert"
)
var (
_ action.Action = (*printBufo)(nil)
)
func NewPrintBufo() action.Action {
return &printBufo{}
}
type printBufo struct{}
func (a *printBufo) Metadata(ctx context.Context, req action.MetadataRequest, resp *action.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_print"
}
func (a *printBufo) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Prints a bufo image as ASCII art. List of available bufos found at: https://github.com/austinvalle/terraform-provider-bufo/tree/main/internal/provider/bufos",
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "Name of the bufo to print (for example: `bufo-the-builder`), [see the github repo](https://github.com/austinvalle/terraform-provider-bufo/tree/main/internal/provider/bufos) for a list of bufos. " +
"If no name is provided, a random bufo will be selected.",
Optional: true,
Validators: []validator.String{
ValidBufoName(),
},
},
"ratio": schema.Float64Attribute{
Description: "The ratio to scale the width/height of the bufo from the original, defaults to 0.5.",
Optional: true,
},
"color": schema.BoolAttribute{
Description: "Color the printed bufo, defaults to false.",
Optional: true,
},
},
}
}
func (a *printBufo) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
var config printBufoModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
var bufoFileName string
if config.Name.IsNull() {
// Choose a random bufo
bufoEntries, err := bufos.ReadDir("bufos")
if err != nil {
resp.Diagnostics.AddError(
"Failed to select a random bufo",
fmt.Sprintf("There was an issue finding a random bufo: %s", err),
)
return
}
randomBufoIdx := rand.Intn(len(bufoEntries))
bufoFileName = bufoEntries[randomBufoIdx].Name()
} else {
bufoFileName = fmt.Sprintf("%s.png", config.Name.ValueString())
}
bufoLocation := fmt.Sprintf("bufos/%s", bufoFileName)
bufoFile, err := bufos.Open(bufoLocation)
if err != nil {
resp.Diagnostics.AddError(
"Failed to retrieve bufo",
fmt.Sprintf("There was an issue finding bufo at %q: %s", bufoFileName, err),
)
return
}
converter := convert.NewImageConverter()
img, _, err := image.Decode(bufoFile)
if err != nil {
resp.Diagnostics.AddError(
"Failed to decode bufo",
fmt.Sprintf("There was an issue decoding bufo at %q: %s", bufoFileName, err),
)
return
}
ratio := 0.5
if !config.Ratio.IsNull() {
ratio = config.Ratio.ValueFloat64()
}
colored := false
if !config.Color.IsNull() {
colored = config.Color.ValueBool()
}
bufoAscii := converter.Image2ASCIIString(img, &convert.Options{
Ratio: ratio,
FixedWidth: -1,
FixedHeight: -1,
Colored: colored,
})
resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("\n\nBufo: %q\n\n%s", bufoFileName, bufoAscii),
})
}
type printBufoModel struct {
Name types.String `tfsdk:"name"`
Ratio types.Float64 `tfsdk:"ratio"`
Color types.Bool `tfsdk:"color"`
}
As you can see we start out by having a type assertion that the printBufo pointer adheres to the action.Action
interface:
var (
_ action.Action = (*printBufo)(nil)
)
This makes it easier to spot potential issues with the implementation.
Then we have the Metadata
method, which reports the type name of the action back (in this case bufo_print
).
Next, we have a Schema
method which returns the schema for the action. This is fairly similar to how it is done for resources and data sources, but there is one difference. If you take a look at the imports, the schema we define is coming from "github.com/hashicorp/terraform-plugin-framework/action/schema"
, so it’s a specialized subset of the normal schema. This will allow Terraform to evolve this schema independently of the normal provider schema, e.g. limiting or extending its capabilities.
Now let’s take a look at the core of the implementation, the Invoke
method.
The first thing we do is to extract the configuration:
var config printBufoModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
Then we have the core logic of the provider, in this case we load the embedded bufo image and convert it to ASCII art.
Finally, we print the ASCII art to the console by using the resp.SendProgress
method:
resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("\n\nBufo: %q\n\n%s", bufoFileName, bufoAscii),
})
And that wraps up the implementation of the printBufo
action.
Users can now use it like this:
terraform {
required_providers {
bufo = {
source = "austinvalle/bufo"
}
}
}
resource "terraform_data" "test" {
lifecycle {
action_trigger {
events = [after_create]
actions = [action.bufo_print.success]
}
}
}
action "bufo_print" "success" {
config {
# random colorized bufo
color = true
}
}
Next Steps
- Read more about Patterns and Guidelines for Terraform Actions
- Designing a Terraform language feature like Terraform Actions is more abstract and discusses the design decisions behind the feature. It might still be an interesting read.
Please leave a comment
Do you have feedback around actions or do you want to hear about a specific topic around Terraform / Software Development / Language Design / Infrastructure as Code? Please let me know in the comments below.