You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
terraform/internal/migrate/migration.go

429 lines
11 KiB

// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package migrate
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/hashicorp/terraform/internal/ast"
"github.com/zclconf/go-cty/cty"
)
// Migration represents a single JSON migration file.
type Migration struct {
Name string `json:"name"`
Description string `json:"description"`
Match Match `json:"match"`
Actions []Action `json:"actions"`
}
// Match specifies which blocks to target.
type Match struct {
BlockType string `json:"block_type"`
Label string `json:"label"`
}
// Action describes a single mutation to apply to matched blocks.
type Action struct {
Action string `json:"action"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Name string `json:"name,omitempty"`
Text string `json:"text,omitempty"`
Value string `json:"value,omitempty"`
OldValue string `json:"old_value,omitempty"`
NewValue string `json:"new_value,omitempty"`
BlockName string `json:"block_name,omitempty"`
WireAttribute string `json:"wire_attribute,omitempty"`
WireTraversal string `json:"wire_traversal,omitempty"`
}
// ParseMigration parses a JSON migration file.
func ParseMigration(data []byte) (*Migration, error) {
var m Migration
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parsing migration JSON: %w", err)
}
if err := m.validate(); err != nil {
return nil, err
}
return &m, nil
}
func (m *Migration) validate() error {
if m.Name == "" {
return fmt.Errorf("migration missing required field \"name\"")
}
if m.Match.BlockType == "" {
return fmt.Errorf("migration %q: match missing required field \"block_type\"", m.Name)
}
if len(m.Actions) == 0 {
return fmt.Errorf("migration %q: must have at least one action", m.Name)
}
for i, a := range m.Actions {
if err := a.validate(); err != nil {
return fmt.Errorf("migration %q action %d: %w", m.Name, i, err)
}
}
return nil
}
var validActions = map[string]bool{
"rename_attribute": true,
"remove_attribute": true,
"rename_resource": true,
"add_comment": true,
"set_attribute_value": true,
"add_attribute": true,
"replace_value": true,
"extract_to_resource": true,
"move_attribute_to_block": true,
"flatten_block": true,
"remove_resource": true,
}
func (a *Action) validate() error {
if !validActions[a.Action] {
return fmt.Errorf("unknown action %q", a.Action)
}
switch a.Action {
case "rename_attribute":
if a.From == "" || a.To == "" {
return fmt.Errorf("rename_attribute requires \"from\" and \"to\"")
}
case "remove_attribute":
if a.Name == "" {
return fmt.Errorf("remove_attribute requires \"name\"")
}
case "rename_resource":
if a.To == "" {
return fmt.Errorf("rename_resource requires \"to\"")
}
case "add_comment":
if a.Text == "" {
return fmt.Errorf("add_comment requires \"text\"")
}
case "set_attribute_value":
if a.Name == "" || a.Value == "" {
return fmt.Errorf("set_attribute_value requires \"name\" and \"value\"")
}
case "add_attribute":
if a.Name == "" || a.Value == "" {
return fmt.Errorf("add_attribute requires \"name\" and \"value\"")
}
case "replace_value":
if a.Name == "" || a.OldValue == "" || a.NewValue == "" {
return fmt.Errorf("replace_value requires \"name\", \"old_value\", and \"new_value\"")
}
case "extract_to_resource":
if a.Name == "" || a.To == "" {
return fmt.Errorf("extract_to_resource requires \"name\" (nested block) and \"to\" (new resource type)")
}
case "move_attribute_to_block":
if a.Name == "" || a.BlockName == "" || a.To == "" {
return fmt.Errorf("move_attribute_to_block requires \"name\", \"block_name\", and \"to\"")
}
case "flatten_block":
if a.Name == "" {
return fmt.Errorf("flatten_block requires \"name\"")
}
case "remove_resource":
if a.Text == "" {
return fmt.Errorf("remove_resource requires \"text\" (FIXME comment)")
}
}
return nil
}
// DiscoverMigrations recursively finds all *.json files under dir,
// parses them as migrations, and returns them sorted by name.
func DiscoverMigrations(dir string) ([]*Migration, error) {
var migrations []*Migration
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || filepath.Ext(path) != ".json" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", path, err)
}
m, err := ParseMigration(data)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
migrations = append(migrations, m)
return nil
})
if err != nil {
return nil, err
}
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].Name < migrations[j].Name
})
return migrations, nil
}
// FilterMigrations returns only migrations whose name matches the glob pattern.
// An empty pattern matches all migrations.
func FilterMigrations(migrations []*Migration, pattern string) []*Migration {
if pattern == "" {
return migrations
}
var result []*Migration
for _, m := range migrations {
matched, err := filepath.Match(pattern, m.Name)
if err != nil {
continue
}
if matched {
result = append(result, m)
}
}
return result
}
// Execute applies a migration to all matching blocks in the module.
func Execute(m *Migration, mod *ast.Module) error {
results := mod.FindBlocks(m.Match.BlockType, m.Match.Label)
for _, r := range results {
for _, action := range m.Actions {
if err := executeAction(action, r, mod); err != nil {
return fmt.Errorf("migration %q: %w", m.Name, err)
}
}
}
return nil
}
func executeAction(a Action, r *ast.BlockResult, mod *ast.Module) error {
switch a.Action {
case "rename_attribute":
r.Block.RenameAttribute(a.From, a.To)
case "remove_attribute":
r.Block.RemoveAttribute(a.Name)
case "rename_resource":
oldLabels := r.Block.Labels()
if len(oldLabels) == 0 {
return fmt.Errorf("rename_resource: block has no labels")
}
oldType := oldLabels[0]
newLabels := make([]string, len(oldLabels))
copy(newLabels, oldLabels)
newLabels[0] = a.To
r.Block.SetLabels(newLabels)
// Rename references across entire module
oldTraversal := hcl.Traversal{hcl.TraverseRoot{Name: oldType}}
newTraversal := hcl.Traversal{hcl.TraverseRoot{Name: a.To}}
mod.RenameReferencePrefix(oldTraversal, newTraversal)
case "add_comment":
r.File.AppendComment(a.Text)
case "set_attribute_value":
val, err := parseValue(a.Value)
if err != nil {
return fmt.Errorf("set_attribute_value: %w", err)
}
r.Block.SetAttributeValue(a.Name, val)
case "add_attribute":
if r.Block.HasAttribute(a.Name) {
return nil // already present, skip
}
val, err := parseValue(a.Value)
if err != nil {
return fmt.Errorf("add_attribute: %w", err)
}
r.Block.SetAttributeValue(a.Name, val)
case "replace_value":
expr := r.Block.GetAttributeExpression(a.Name)
if expr == nil {
return nil // attribute not present, skip
}
got := strings.TrimSpace(string(expr.BuildTokens(nil).Bytes()))
if got == a.OldValue {
tokens := hclwrite.Tokens{
{Type: hclsyntax.TokenIdent, Bytes: []byte(a.NewValue)},
}
r.Block.SetAttributeRaw(a.Name, tokens)
}
case "extract_to_resource":
labels := r.Block.Labels()
if len(labels) < 2 {
return fmt.Errorf("extract_to_resource: block needs at least 2 labels")
}
nested := r.Block.NestedBlocks(a.Name)
if len(nested) == 0 {
// Check if it's an attribute instead of a nested block
expr := r.Block.GetAttributeExpression(a.Name)
if expr == nil {
return nil
}
tokens := expr.BuildTokens(nil)
// Remove from parent
r.Block.RemoveAttribute(a.Name)
// Create new resource with same instance name
newBlock := r.File.AddBlock("resource", []string{a.To, labels[1]})
// Add wiring attribute if specified
if a.WireAttribute != "" {
suffix := a.WireTraversal
if suffix == "" {
suffix = "id"
}
newBlock.SetAttributeTraversal(a.WireAttribute, hcl.Traversal{
hcl.TraverseRoot{Name: labels[0]},
hcl.TraverseAttr{Name: labels[1]},
hcl.TraverseAttr{Name: suffix},
})
}
// Set the attribute on the new resource
newBlock.SetAttributeRaw(a.Name, tokens)
return nil
}
// Read attributes from the nested block
attrs := nested[0].Attributes()
// Remove the nested block from the parent
r.Block.RemoveBlock(a.Name)
// Create new resource with same instance name
newBlock := r.File.AddBlock("resource", []string{a.To, labels[1]})
// Add wiring attribute if specified
if a.WireAttribute != "" {
suffix := a.WireTraversal
if suffix == "" {
suffix = "id"
}
newBlock.SetAttributeTraversal(a.WireAttribute, hcl.Traversal{
hcl.TraverseRoot{Name: labels[0]},
hcl.TraverseAttr{Name: labels[1]},
hcl.TraverseAttr{Name: suffix},
})
}
// Copy attributes from the extracted nested block
for name, expr := range attrs {
newBlock.SetAttributeRaw(name, expr.BuildTokens(nil))
}
// Copy nested blocks from the extracted block
copyNestedBlocks(nested[0], newBlock)
case "move_attribute_to_block":
expr := r.Block.GetAttributeExpression(a.Name)
if expr == nil {
return nil
}
tokens := expr.BuildTokens(nil)
// Remove from parent
r.Block.RemoveAttribute(a.Name)
// Find or create the target nested block
existing := r.Block.NestedBlocks(a.BlockName)
var target *ast.Block
if len(existing) > 0 {
target = existing[0]
} else {
target = r.Block.AddBlock(a.BlockName)
}
// Set the attribute with the new name
target.SetAttributeRaw(a.To, tokens)
case "flatten_block":
nested := r.Block.NestedBlocks(a.Name)
if len(nested) == 0 {
return nil
}
// Read all attributes from the nested block
attrs := nested[0].Attributes()
// Remove the nested block
r.Block.RemoveBlock(a.Name)
// Add each attribute to the parent block
for name, expr := range attrs {
r.Block.SetAttributeRaw(name, expr.BuildTokens(nil))
}
case "remove_resource":
labels := r.Block.Labels()
if len(labels) == 0 {
return fmt.Errorf("remove_resource: block has no labels")
}
// Find files that reference this resource and add FIXME comments
prefix := hcl.Traversal{hcl.TraverseRoot{Name: labels[0]}}
warned := make(map[string]bool)
for _, f := range mod.Files() {
if f.ReferencesPrefix(prefix) && !warned[f.Filename()] {
f.AppendComment(a.Text)
warned[f.Filename()] = true
}
}
// Remove the resource block
r.File.RemoveBlock(r.Block.Type(), labels)
default:
return fmt.Errorf("unknown action %q", a.Action)
}
return nil
}
// copyNestedBlocks recursively copies all child blocks from src to dst.
func copyNestedBlocks(src, dst *ast.Block) {
for _, child := range src.AllNestedBlocks() {
newChild := dst.AddBlock(child.Type())
for name, expr := range child.Attributes() {
newChild.SetAttributeRaw(name, expr.BuildTokens(nil))
}
copyNestedBlocks(child, newChild)
}
}
// parseValue converts a string to a cty.Value.
// Supports: "true"/"false" -> cty.BoolVal, integers -> cty.NumberIntVal,
// everything else -> cty.StringVal.
func parseValue(s string) (cty.Value, error) {
if s == "true" {
return cty.True, nil
}
if s == "false" {
return cty.False, nil
}
var n int64
if _, err := fmt.Sscanf(s, "%d", &n); err == nil {
return cty.NumberIntVal(n), nil
}
return cty.StringVal(s), nil
}