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 returns the set of byte-range edits the engine should apply to the
// file's current content. Return nil, nil (or a *FixResult with no edits)
// when no fix is applicable; the engine treats either form as a no-op.
//
// Multiple findings against the same file each call Fix independently; the
// engine collects every returned edit and applies them in a single pass.
// See FixResult for the exact ordering, overlap, and whole-file rules.
Fix(ctx *sdk.Context, file *hcl.File) (*sdk.FixResult, error)
}
See TextEdit and FixResult for byte-offset semantics, apply order, and the whole-file exclusivity rule.
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"`
Fixable bool `json:"fixable,omitempty"`
IsDiff bool `json:"is_diff,omitempty"`
}
A finding is auto-fixable when Fixable is true. The engine sets this based on whether the rule implements Fixer; rules must not set it directly. The FixResult.Edits are computed lazily by calling Fixer.Fix(ctx, file) only when needed (in fix or diff mode).
Severity¶
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
SeverityInfo Severity = "info"
)
Go Plugins¶
Prerequisites¶
- Go 1.26.3 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) (*sdk.FixResult, error) {
// return &sdk.FixResult{Edits: []sdk.TextEdit{{
// Start: findingStart,
// End: findingEnd,
// Replacement: []byte("corrected"),
// }}}, 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:
block_types:
- resource
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.block_types | list | No | HCL block types to check (empty = all block types) |
patterns.resource_types | list | No | Resource types to check (empty = all of block type) |
patterns.required_attributes | list | No | Attributes that must be present |
patterns.forbidden_attributes | list | No | Attributes that must NOT be present |
patterns.attribute_patterns | list | No | Regex patterns to validate attribute values |
Block Types¶
The block_types field filters which HCL blocks the rule examines. Valid values:
resource- Terraform resource blocksdata- Data source blocksvariable- Input variable blocksoutput- Output value blockslocals- Local value blocksmodule- Module call blocks
If block_types is not specified, the rule checks all block types.
Check variables have descriptions:
name: require-variable-description
description: Variables must have a description
severity: warning
enabled: true
patterns:
block_types:
- variable
required_attributes:
- description
Check both resources and data sources:
name: require-tags-everywhere
description: Resources and data sources must have tags
severity: warning
enabled: true
patterns:
block_types:
- resource
- data
required_attributes:
- tags
Forbidden Attributes¶
The forbidden_attributes field lists attributes that should NOT be present. This is useful for deprecating old patterns or enforcing security policies.
Forbid deprecated S3 bucket arguments:
name: no-deprecated-s3-args
description: S3 buckets should not use deprecated arguments
severity: error
enabled: true
message: "Use dedicated resources instead of inline arguments"
patterns:
resource_types:
- aws_s3_bucket
forbidden_attributes:
- acl
- website
- cors_rule
- logging
The finding location points to the forbidden attribute itself, not the block.
Attribute Patterns¶
The attribute_patterns field validates attribute values against regex patterns. Each pattern has:
attribute(required): The attribute name to checkpattern(required): A regex pattern the value must matchmessage(optional): Custom message for findings
Validate S3 bucket naming conventions:
name: bucket-naming-convention
description: S3 bucket names must follow naming convention
severity: warning
enabled: true
patterns:
resource_types:
- aws_s3_bucket
attribute_patterns:
- attribute: bucket
pattern: "^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$"
message: "Bucket name must be 3-63 lowercase alphanumeric characters or hyphens"
Notes:
- Patterns are compiled once during rule loading for performance
- If the attribute is missing, the check is skipped (use
required_attributesto enforce presence) - Only simple string literals can be validated; complex expressions are skipped
- Invalid regex patterns cause the rule to fail loading
How YAML Rules Work¶
YAML rules iterate over HCL blocks in the file. For each block that matches the configured block_types (or all blocks if not specified) and resource_types (or all blocks of that type if none specified), the rule:
- Checks that all
required_attributesare present (missing ones generate findings) - Checks that no
forbidden_attributesare present (found ones generate findings) - Validates
attribute_patternsagainst attribute values (non-matching values generate findings)
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