terraform/configs/configupgrade/upgrade_test.go

305 lines
8.4 KiB
Go
Raw Normal View History

package configupgrade
import (
"bytes"
"io"
"io/ioutil"
"log"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/zclconf/go-cty/cty"
backendinit "github.com/hashicorp/terraform/backend/init"
"github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/helper/logging"
"github.com/hashicorp/terraform/providers"
"github.com/hashicorp/terraform/provisioners"
"github.com/hashicorp/terraform/terraform"
)
func TestUpgradeValid(t *testing.T) {
// This test uses the contents of the test-fixtures/valid directory as
// a table of tests. Every directory there must have both "input" and
// "want" subdirectories, where "input" is the configuration to be
// upgraded and "want" is the expected result.
fixtureDir := "test-fixtures/valid"
testDirs, err := ioutil.ReadDir(fixtureDir)
if err != nil {
t.Fatal(err)
}
for _, entry := range testDirs {
if !entry.IsDir() {
continue
}
t.Run(entry.Name(), func(t *testing.T) {
inputDir := filepath.Join(fixtureDir, entry.Name(), "input")
wantDir := filepath.Join(fixtureDir, entry.Name(), "want")
u := &Upgrader{
Providers: providers.ResolverFixed(testProviders),
Provisioners: testProvisioners,
}
inputSrc, err := LoadModule(inputDir)
if err != nil {
t.Fatal(err)
}
wantSrc, err := LoadModule(wantDir)
if err != nil {
t.Fatal(err)
}
gotSrc, diags := u.Upgrade(inputSrc, inputDir)
if diags.HasErrors() {
t.Error(diags.Err())
}
// Upgrade uses a nil entry as a signal to delete a file, which
// we can't test here because we aren't modifying an existing
// dir in place, so we'll just ignore those and leave that mechanism
// to be tested elsewhere.
for name, got := range gotSrc {
if gotSrc[name] == nil {
delete(gotSrc, name)
continue
}
want, wanted := wantSrc[name]
if !wanted {
t.Errorf("unexpected extra output file %q\n=== GOT ===\n%s", name, got)
continue
}
got = bytes.TrimSpace(got)
want = bytes.TrimSpace(want)
if !bytes.Equal(got, want) {
diff := diffSourceFiles(got, want)
t.Errorf("wrong content in %q\n%s", name, diff)
}
}
for name, want := range wantSrc {
if _, present := gotSrc[name]; !present {
t.Errorf("missing output file %q\n=== WANT ===\n%s", name, want)
}
}
})
}
}
func TestUpgradeRenameJSON(t *testing.T) {
inputDir := filepath.Join("test-fixtures/valid/rename-json/input")
inputSrc, err := LoadModule(inputDir)
if err != nil {
t.Fatal(err)
}
u := &Upgrader{
Providers: providers.ResolverFixed(testProviders),
}
gotSrc, diags := u.Upgrade(inputSrc, inputDir)
if diags.HasErrors() {
t.Error(diags.Err())
}
// This test fixture is also fully covered by TestUpgradeValid, so
// we're just testing that the file was renamed here.
src, exists := gotSrc["misnamed-json.tf"]
if src != nil {
t.Errorf("misnamed-json.tf still has content")
} else if !exists {
t.Errorf("misnamed-json.tf not marked for deletion")
}
src, exists = gotSrc["misnamed-json.tf.json"]
if src == nil || !exists {
t.Errorf("misnamed-json.tf.json was not created")
}
}
func diffSourceFiles(got, want []byte) []byte {
// We'll try to run "diff -u" here to get nice output, but if that fails
// (e.g. because we're running on a machine without diff installed) then
// we'll fall back on just printing out the before and after in full.
gotR, gotW, err := os.Pipe()
if err != nil {
return diffSourceFilesFallback(got, want)
}
defer gotR.Close()
defer gotW.Close()
wantR, wantW, err := os.Pipe()
if err != nil {
return diffSourceFilesFallback(got, want)
}
defer wantR.Close()
defer wantW.Close()
cmd := exec.Command("diff", "-u", "--label=GOT", "--label=WANT", "/dev/fd/3", "/dev/fd/4")
cmd.ExtraFiles = []*os.File{gotR, wantR}
stdout, err := cmd.StdoutPipe()
stderr, err := cmd.StderrPipe()
if err != nil {
return diffSourceFilesFallback(got, want)
}
go func() {
wantW.Write(want)
wantW.Close()
}()
go func() {
gotW.Write(got)
gotW.Close()
}()
err = cmd.Start()
if err != nil {
return diffSourceFilesFallback(got, want)
}
outR := io.MultiReader(stdout, stderr)
out, err := ioutil.ReadAll(outR)
if err != nil {
return diffSourceFilesFallback(got, want)
}
cmd.Wait() // not checking errors here because on failure we'll have stderr captured to return
const noNewline = "\\ No newline at end of file\n"
if bytes.HasSuffix(out, []byte(noNewline)) {
out = out[:len(out)-len(noNewline)]
}
return out
}
func diffSourceFilesFallback(got, want []byte) []byte {
var buf bytes.Buffer
buf.WriteString("=== GOT ===\n")
buf.Write(got)
buf.WriteString("\n=== WANT ===\n")
buf.Write(want)
buf.WriteString("\n")
return buf.Bytes()
}
var testProviders = map[string]providers.Factory{
"test": providers.Factory(func() (providers.Interface, error) {
p := &terraform.MockProvider{}
p.GetSchemaReturn = &terraform.ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"test_instance": {
Attributes: map[string]*configschema.Attribute{
configs/configupgrade: Remove redundant list brackets In early versions of Terraform where the interpolation language didn't have any real list support, list brackets around a single string was the signal to split the string on a special uuid separator to produce a list just in time for processing, giving expressions like this: foo = ["${test_instance.foo.*.id}"] Logically this is weird because it looks like it should produce a list of lists of strings. When we added real list support in Terraform 0.7 we retained support for this behavior by trimming off extra levels of list during evaluation, and inadvertently continued relying on this notation for correct type checking. During the Terraform 0.10 line we fixed the type checker bugs (a few remaining issues notwithstanding) so that it was finally possible to use the more intuitive form: foo = "${test_instance.foo.*.id}" ...but we continued trimming off extra levels of list for backward compatibility. Terraform 0.12 finally removes that compatibility shim, causing redundant list brackets to be interpreted as a list of lists. This upgrade rule attempts to identify situations that are relying on the old compatibility behavior and trim off the redundant extra brackets. It's not possible to do this fully-generally using only static analysis, but we can gather enough information through or partial type inference mechanism here to deal with the most common situations automatically and produce a TF-UPGRADE-TODO comment for more complex scenarios where the user intent isn't decidable with only static analysis. In particular, this handles by far the most common situation of wrapping list brackets around a splat expression like the first example above. After this and the other upgrade rules are applied, the first example above will become: foo = test_instance.foo.*.id
2018-12-06 20:56:43 +01:00
"id": {Type: cty.String, Computed: true},
"type": {Type: cty.String, Optional: true},
"image": {Type: cty.String, Optional: true},
"tags": {Type: cty.Map(cty.String), Optional: true},
"security_groups": {Type: cty.List(cty.String), Optional: true},
configs/configupgrade: Detect and fix element(...) usage with sets Although sets do not have indexed elements, in Terraform 0.11 and earlier element(...) would work with sets because we'd automatically convert them to lists on entry to HIL -- with an arbitrary-but-consistent ordering -- and this return an arbitrary-but-consistent element from the list. The element(...) function in Terraform 0.12 does not allow this because it is not safe in general, but there was an existing pattern relying on this in Terraform 0.11 configs which this upgrade rule is intended to preserve: resource "example" "example" { count = "${length(any_set_attribute)}" foo = "${element(any_set_attribute, count.index}" } The above works because the exact indices assigned in the conversion are irrelevant: we're just asking Terraform to create one resource for each distinct element in the set. This upgrade rule therefore inserts an explicit conversion to list if it is able to successfully provide that the given expression will return a set type: foo = "${element(tolist(any_set_attribute), count.index}" This makes the conversion explicit, allowing users to decide if it is safe and rework the configuration if not. Since our static type analysis functionality focuses mainly on resource type attributes, in practice this rule will only apply when the given expression is a statically-checkable resource reference. Since sets are an SDK-only concept in Terraform 0.11 and earlier anyway, in practice that works out just right: it's not possible for sets to appear anywhere else in older versions anyway.
2019-02-21 01:28:15 +01:00
"subnet_ids": {Type: cty.Set(cty.String), Optional: true},
},
BlockTypes: map[string]*configschema.NestedBlock{
"network": {
Nesting: configschema.NestingSet,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"cidr_block": {Type: cty.String, Optional: true},
"subnet_cidrs": {Type: cty.Map(cty.String), Computed: true},
},
BlockTypes: map[string]*configschema.NestedBlock{
"subnet": {
Nesting: configschema.NestingSet,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"number": {Type: cty.Number, Required: true},
},
},
},
},
},
},
"addresses": {
Nesting: configschema.NestingSingle,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"ipv4": {Type: cty.String, Computed: true},
"ipv6": {Type: cty.String, Computed: true},
},
},
},
},
},
},
}
return p, nil
}),
"terraform": providers.Factory(func() (providers.Interface, error) {
p := &terraform.MockProvider{}
p.GetSchemaReturn = &terraform.ProviderSchema{
DataSources: map[string]*configschema.Block{
"terraform_remote_state": {
// This is just enough an approximation of the remote state
// schema to check out reference upgrade logic. It is
// intentionally not fully-comprehensive.
Attributes: map[string]*configschema.Attribute{
"backend": {Type: cty.String, Optional: true},
},
},
},
}
return p, nil
}),
"aws": providers.Factory(func() (providers.Interface, error) {
// This is here only so we can test the provisioner connection info
// migration behavior, which is resource-type specific. Do not use
// it in any other tests.
p := &terraform.MockProvider{}
p.GetSchemaReturn = &terraform.ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"aws_instance": {},
},
}
return p, nil
}),
}
var testProvisioners = map[string]provisioners.Factory{
"test": provisioners.Factory(func() (provisioners.Interface, error) {
p := &terraform.MockProvisioner{}
p.GetSchemaResponse = provisioners.GetSchemaResponse{
Provisioner: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"commands": {Type: cty.List(cty.String), Optional: true},
"interpreter": {Type: cty.String, Optional: true},
},
},
}
return p, nil
}),
}
func init() {
// Initialize the backends
backendinit.Init(nil)
}
func TestMain(m *testing.M) {
if testing.Verbose() {
// if we're verbose, use the logging requested by TF_LOG
logging.SetOutput()
} else {
// otherwise silence all logs
log.SetOutput(ioutil.Discard)
}
// We have fmt.Stringer implementations on lots of objects that hide
// details that we very often want to see in tests, so we just disable
// spew's use of String methods globally on the assumption that spew
// usage implies an intent to see the raw values and ignore any
// abstractions.
spew.Config.DisableMethods = true
os.Exit(m.Run())
}