Plugin Development¶
Guide to developing custom plugins for TerraTidy.
Overview¶
TerraTidy supports three types of custom plugins:
- Go plugins (
.sofiles) - compiled Go plugins using the plugin system - YAML rules (
.yaml/.ymlfiles) - declarative pattern-based rules - Bash rules (
.shfiles) - shell scripts that output JSON findings
All plugin types are loaded automatically from configured plugin directories.
Plugin Directories¶
Place plugins in one of these directories:
.terratidy/plugins/(project-local)~/.terratidy/plugins/(user-global)
Configure directories in .terratidy.yaml:
SDK Reference¶
Rule Interface¶
Every rule (Go, Rego, YAML, or Bash) implements the sdk.Rule interface:
type Rule interface {
// Name returns the rule identifier
Name() string
// Description returns a human-readable description
Description() string
// Check evaluates the rule against a parsed HCL file
Check(ctx *sdk.Context, file *hcl.File) ([]sdk.Finding, error)
}
Rules that support auto-fixing also implement sdk.Fixer:
type Fixer interface {
// Fix applies an automatic fix and returns the corrected file bytes
Fix(ctx *sdk.Context, file *hcl.File) ([]byte, error)
}
Context¶
The sdk.Context provides runtime information to rules:
type Context struct {
context.Context // Embedded for cancellation/deadline support
Options map[string]any // Rule-specific config from .terratidy.yaml
WorkDir string // Directory TerraTidy was invoked from
File string // Absolute path to file being checked
AllFiles map[string][]byte // All files being processed (for cross-file rules)
}
Finding¶
type Finding struct {
Rule string `json:"rule"`
Message string `json:"message"`
File string `json:"file"`
Location Location `json:"location"`
Severity Severity `json:"severity"`
Fix *FixResult `json:"fix,omitempty"`
}
FixResult¶
Holds the result of an auto-fix operation:
A finding is auto-fixable when Fix != nil.
Severity¶
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
SeverityInfo Severity = "info"
)
Go Plugins¶
Prerequisites¶
- Go 1.26.1 or later
- TerraTidy SDK (
pkg/sdk) - TerraTidy plugin types (
internal/plugins)
Plugin Structure¶
Plugin Exports¶
Go plugins must export two symbols:
PluginMetadata- a*plugins.PluginMetadatavariable with plugin infoNew- a constructor function returning the plugin instance
// PluginMetadata contains information about a plugin
type PluginMetadata struct {
Name string
Version string
Description string
Author string
Type PluginType // "rule", "engine", or "formatter"
Path string // set automatically on load
}
Plugin Interfaces¶
Depending on the plugin type, New() must return one of:
| Type | Return Type | Interface |
|---|---|---|
rule | plugins.RulePlugin | GetRules() []sdk.Rule |
engine | plugins.EnginePlugin | Name() string, Run(ctx, files) ([]sdk.Finding, error) |
formatter | plugins.FormatterPlugin | Name() string, Format(findings []sdk.Finding, w io.Writer) error |
Complete Go Plugin Example¶
This example checks that all resource blocks have a tags attribute:
package main
import (
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/santosr2/TerraTidy/internal/plugins"
"github.com/santosr2/TerraTidy/pkg/sdk"
)
// PluginMetadata provides information about this plugin.
var PluginMetadata = &plugins.PluginMetadata{
Name: "require-tags",
Version: "1.0.0",
Description: "Checks that resources have a tags attribute",
Author: "Your Name",
Type: plugins.PluginTypeRule,
}
// Plugin implements the RulePlugin interface.
type Plugin struct {
rules []sdk.Rule
}
// New creates a new instance of the plugin.
func New() plugins.RulePlugin {
return &Plugin{rules: []sdk.Rule{&RequireTagsRule{}}}
}
// GetRules returns all rules provided by this plugin.
func (p *Plugin) GetRules() []sdk.Rule { return p.rules }
// RequireTagsRule checks that resource blocks include a tags attribute.
type RequireTagsRule struct{}
func (r *RequireTagsRule) Name() string { return "require-tags" }
func (r *RequireTagsRule) Description() string { return "Resources must have a tags attribute" }
func (r *RequireTagsRule) Check(ctx *sdk.Context, file *hcl.File) ([]sdk.Finding, error) {
body, ok := file.Body.(*hclsyntax.Body)
if !ok {
return nil, nil
}
var findings []sdk.Finding
for _, block := range body.Blocks {
if block.Type != "resource" {
continue
}
hasTags := false
for _, attr := range block.Body.Attributes {
if attr.Name == "tags" {
hasTags = true
break
}
}
if !hasTags {
findings = append(findings, sdk.Finding{
Rule: "require-tags",
Message: fmt.Sprintf("Resource %q is missing a tags attribute", block.Labels[0]),
File: ctx.File,
Location: sdk.LocationFromRange(block.DefRange()),
Severity: sdk.SeverityWarning,
})
}
}
return findings, nil
}
// Optional: implement sdk.Fixer for auto-fix support
// func (r *RequireTagsRule) Fix(_ *sdk.Context, _ *hcl.File) ([]byte, error) {
// return fixedContent, nil
// }
Building Go Plugins¶
Then copy the .so file to a plugin directory:
YAML Rules¶
YAML rules provide a declarative way to define pattern-based checks without writing Go code.
YAML Rule Structure¶
name: require-description
description: All resources must have a description attribute
severity: warning
enabled: true
message: "Resource is missing a 'description' attribute"
tags:
- documentation
- best-practice
patterns:
resource_types:
- aws_instance
- aws_s3_bucket
required_attributes:
- description
Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Rule identifier |
description | string | No | Human-readable description |
severity | string | No | error, warning, or info (default: warning) |
enabled | bool | No | Whether the rule is active (default: false) |
message | string | No | Custom message for findings |
tags | list | No | Tags for categorization |
patterns.resource_types | list | No | Resource types to check (empty = all resources) |
patterns.required_attributes | list | No | Attributes that must be present |
How YAML Rules Work¶
YAML rules iterate over HCL blocks in the file. For each resource block that matches the configured resource_types (or all resource blocks if none are specified), the rule checks that all required_attributes are present. Missing attributes generate a finding.
Installation¶
Place .yaml or .yml files in a plugin directory:
Bash Rules¶
Bash rules execute a shell script that analyzes Terraform files and outputs findings as JSON.
Bash Rule Contract¶
- The script receives the file path as the first argument (
$1) - It must output JSON to stdout in this format:
- Exit code 0 means success (with or without findings)
- Exit code 1 with JSON output is treated as findings (not a script error)
- The script has a 30-second timeout
- The script must be executable (
chmod +x)
Output Format¶
{
"findings": [
{
"file": "main.tf",
"line": 5,
"column": 1,
"message": "Issue description",
"severity": "warning",
"rule": "optional-rule-name"
}
]
}
If rule is omitted, the script filename (without extension) is used as the rule name.
Example Bash Rule¶
#!/usr/bin/env bash
set -euo pipefail
FILE="$1"
# Match 12-digit AWS account IDs (standalone, not inside a variable reference)
PATTERN='[^a-zA-Z_$][0-9]{12}[^0-9]'
findings="[]"
while IFS= read -r match; do
line=$(echo "$match" | cut -d: -f1)
findings=$(echo "$findings" | jq --arg file "$FILE" --arg line "$line" \
'. + [{"file": $file, "line": ($line | tonumber), "message": "Hardcoded AWS account ID detected; use a variable or data source", "severity": "warning"}]')
done < <(grep -nE "$PATTERN" "$FILE" 2>/dev/null || true)
echo "{\"findings\": $findings}"
Installation¶
Place executable .sh files in a plugin directory:
cp no-hardcoded-account-id.sh .terratidy/plugins/
chmod +x .terratidy/plugins/no-hardcoded-account-id.sh
Note: Bash rules are not supported on Windows.
Scaffolding Rules¶
Use terratidy init-rule to scaffold new rules:
terratidy init-rule --name my-rule --type go # Go plugin with test file
terratidy init-rule --name my-rule --type rego # OPA/Rego policy
terratidy init-rule --name my-rule --type yaml # YAML rule config
Note: Bash rules cannot be scaffolded via init-rule. Create them manually since the format is simple (executable script outputting JSON).
Plugin Management¶
# List all loaded plugins
terratidy plugins list
# Show details for a specific plugin
terratidy plugins info require-tags
# Create a new Go plugin project
terratidy plugins init my-plugin
Distribution¶
Plugins are distributed as files. There is no built-in registry or package manager.
For teams/organizations:
- Store plugins in a shared Git repository
- Copy plugin files to
~/.terratidy/plugins/or a project-local directory - Use your CI/CD pipeline to install plugins before running TerraTidy
# Example: install plugins from a shared repo
git clone git@github.com:my-org/terratidy-plugins.git /tmp/plugins
cp /tmp/plugins/*.yaml .terratidy/plugins/
cp /tmp/plugins/*.sh .terratidy/plugins/
chmod +x .terratidy/plugins/*.sh
Plugin metadata includes a Version field for tracking, but there is no automatic version resolution or update mechanism.
Testing Plugins¶
Testing Go Plugins¶
func TestRequireTagsRule(t *testing.T) {
rule := &RequireTagsRule{}
src := []byte(`
resource "aws_instance" "example" {
ami = "ami-12345"
instance_type = "t2.micro"
}
`)
file, diags := hclsyntax.ParseConfig(src, "test.tf", hcl.Pos{Line: 1, Column: 1})
require.False(t, diags.HasErrors())
ctx := &sdk.Context{File: "test.tf"}
findings, err := rule.Check(ctx, file)
require.NoError(t, err)
assert.Len(t, findings, 1)
assert.Equal(t, "require-tags", findings[0].Rule)
}
Testing Bash Rules¶
Run the script directly and check the JSON output:
# Should produce findings
./no-hardcoded-account-id.sh test-fixtures/hardcoded.tf | jq '.findings | length'
# Should produce no findings
./no-hardcoded-account-id.sh test-fixtures/clean.tf | jq '.findings | length'
Best Practices¶
- Clear naming: Use kebab-case descriptive rule names (
require-tags,no-public-s3-bucket) - Good messages: Provide actionable error messages that tell the user what to fix
- Appropriate severity: Match severity to impact (error for security, warning for best practices, info for suggestions)
- Provide fixes: Implement the
Fixmethod when an automatic correction is possible - Test thoroughly: Write unit tests for Go plugins, script tests for Bash rules
- Use YAML for simple checks: If you only need to verify attribute presence, a YAML rule is simpler than Go