terraform/states/statefile/version3_upgrade.go

446 lines
16 KiB
Go

package statefile
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/configs"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/hashicorp/terraform/states"
)
func upgradeStateV3ToV4(old *stateV3) (*stateV4, error) {
if old.Serial < 0 {
// The new format is using uint64 here, which should be fine for any
// real state (we only used positive integers in practice) but we'll
// catch this explicitly here to avoid weird behavior if a state file
// has been tampered with in some way.
return nil, fmt.Errorf("state has serial less than zero, which is invalid")
}
new := &stateV4{
TerraformVersion: old.TFVersion,
Serial: uint64(old.Serial),
Lineage: old.Lineage,
RootOutputs: map[string]outputStateV4{},
Resources: []resourceStateV4{},
}
if new.TerraformVersion == "" {
// Older formats considered this to be optional, but now it's required
// and so we'll stub it out with something that's definitely older
// than the version that really created this state.
new.TerraformVersion = "0.0.0"
}
for _, msOld := range old.Modules {
if len(msOld.Path) < 1 || msOld.Path[0] != "root" {
return nil, fmt.Errorf("state contains invalid module path %#v", msOld.Path)
}
// Convert legacy-style module address into our newer address type.
// Since these old formats are only generated by versions of Terraform
// that don't support count and for_each on modules, we can just assume
// all of the modules are unkeyed.
moduleAddr := make(addrs.ModuleInstance, len(msOld.Path)-1)
for i, name := range msOld.Path[1:] {
if !hclsyntax.ValidIdentifier(name) {
// If we don't fail here then we'll produce an invalid state
// version 4 which subsequent operations will reject, so we'll
// fail early here for safety to make sure we can never
// inadvertently commit an invalid snapshot to a backend.
return nil, fmt.Errorf("state contains invalid module path %#v: %q is not a valid identifier; rename it in Terraform 0.11 before upgrading to Terraform 0.12", msOld.Path, name)
}
moduleAddr[i] = addrs.ModuleInstanceStep{
Name: name,
InstanceKey: addrs.NoKey,
}
}
// In a v3 state file, a "resource state" is actually an instance
// state, so we need to fill in a missing level of hierarchy here
// by lazily creating resource states as we encounter them.
// We'll track them in here, keyed on the string representation of
// the resource address.
resourceStates := map[string]*resourceStateV4{}
for legacyAddr, rsOld := range msOld.Resources {
instAddr, err := parseLegacyResourceAddress(legacyAddr)
if err != nil {
return nil, err
}
resAddr := instAddr.Resource
rs, exists := resourceStates[resAddr.String()]
if !exists {
var modeStr string
switch resAddr.Mode {
case addrs.ManagedResourceMode:
modeStr = "managed"
case addrs.DataResourceMode:
modeStr = "data"
default:
return nil, fmt.Errorf("state contains resource %s with an unsupported resource mode %#v", resAddr, resAddr.Mode)
}
// In state versions prior to 4 we allowed each instance of a
// resource to have its own provider configuration address,
// which makes no real sense in practice because providers
// are associated with resources in the configuration. We
// elevate that to the resource level during this upgrade,
// implicitly taking the provider address of the first instance
// we encounter for each resource. While this is lossy in
// theory, in practice there is no reason for these values to
// differ between instances.
var providerAddr addrs.AbsProviderConfig
oldProviderAddr := rsOld.Provider
if strings.Contains(oldProviderAddr, "provider.") {
// Smells like a new-style provider address, but we'll test it.
var diags tfdiags.Diagnostics
providerAddr, diags = addrs.ParseLegacyAbsProviderConfigStr(oldProviderAddr)
if diags.HasErrors() {
if strings.Contains(oldProviderAddr, "${") {
// There seems to be a common misconception that
// interpolation was valid in provider aliases
// in 0.11, so we'll use a specialized error
// message for that case.
return nil, fmt.Errorf("invalid provider config reference %q for %s: this alias seems to contain a template interpolation sequence, which was not supported but also not error-checked in Terraform 0.11. To proceed, rename the associated provider alias to a valid identifier and apply the change with Terraform 0.11 before upgrading to Terraform 0.12", oldProviderAddr, instAddr)
}
return nil, fmt.Errorf("invalid provider config reference %q for %s: %s", oldProviderAddr, instAddr, diags.Err())
}
} else {
// Smells like an old-style module-local provider address,
// which we'll need to migrate. We'll assume it's referring
// to the same module the resource is in, which might be
// incorrect but it'll get fixed up next time any updates
// are made to an instance.
if oldProviderAddr != "" {
localAddr, diags := configs.ParseProviderConfigCompactStr(oldProviderAddr)
if diags.HasErrors() {
if strings.Contains(oldProviderAddr, "${") {
// There seems to be a common misconception that
// interpolation was valid in provider aliases
// in 0.11, so we'll use a specialized error
// message for that case.
return nil, fmt.Errorf("invalid legacy provider config reference %q for %s: this alias seems to contain a template interpolation sequence, which was not supported but also not error-checked in Terraform 0.11. To proceed, rename the associated provider alias to a valid identifier and apply the change with Terraform 0.11 before upgrading to Terraform 0.12", oldProviderAddr, instAddr)
}
return nil, fmt.Errorf("invalid legacy provider config reference %q for %s: %s", oldProviderAddr, instAddr, diags.Err())
}
providerAddr = addrs.AbsProviderConfig{
Module: moduleAddr.Module(),
// We use NewLegacyProvider here so we can use
// LegacyString() below to get the appropriate
// legacy-style provider string.
Provider: addrs.NewLegacyProvider(localAddr.LocalName),
Alias: localAddr.Alias,
}
} else {
providerAddr = addrs.AbsProviderConfig{
Module: moduleAddr.Module(),
// We use NewLegacyProvider here so we can use
// LegacyString() below to get the appropriate
// legacy-style provider string.
Provider: addrs.NewLegacyProvider(resAddr.ImpliedProvider()),
}
}
}
rs = &resourceStateV4{
Module: moduleAddr.String(),
Mode: modeStr,
Type: resAddr.Type,
Name: resAddr.Name,
Instances: []instanceObjectStateV4{},
ProviderConfig: providerAddr.LegacyString(),
}
resourceStates[resAddr.String()] = rs
}
// Now we'll deal with the instance itself, which may either be
// the first instance in a resource we just created or an additional
// instance for a resource added on a prior loop.
instKey := instAddr.Key
if isOld := rsOld.Primary; isOld != nil {
isNew, err := upgradeInstanceObjectV3ToV4(rsOld, isOld, instKey, states.NotDeposed)
if err != nil {
return nil, fmt.Errorf("failed to migrate primary generation of %s: %s", instAddr, err)
}
rs.Instances = append(rs.Instances, *isNew)
}
for i, isOld := range rsOld.Deposed {
// When we migrate old instances we'll use sequential deposed
// keys just so that the upgrade result is deterministic. New
// deposed keys allocated moving forward will be pseudorandomly
// selected, but we check for collisions and so these
// non-random ones won't hurt.
deposedKey := states.DeposedKey(fmt.Sprintf("%08x", i+1))
isNew, err := upgradeInstanceObjectV3ToV4(rsOld, isOld, instKey, deposedKey)
if err != nil {
return nil, fmt.Errorf("failed to migrate deposed generation index %d of %s: %s", i, instAddr, err)
}
rs.Instances = append(rs.Instances, *isNew)
}
if instKey != addrs.NoKey && rs.EachMode == "" {
rs.EachMode = "list"
}
}
for _, rs := range resourceStates {
new.Resources = append(new.Resources, *rs)
}
if len(msOld.Path) == 1 && msOld.Path[0] == "root" {
// We'll migrate the outputs for this module too, then.
for name, oldOS := range msOld.Outputs {
newOS := outputStateV4{
Sensitive: oldOS.Sensitive,
}
valRaw := oldOS.Value
valSrc, err := json.Marshal(valRaw)
if err != nil {
// Should never happen, because this value came from JSON
// in the first place and so we're just round-tripping here.
return nil, fmt.Errorf("failed to serialize output %q value as JSON: %s", name, err)
}
// The "type" field in state V2 wasn't really that useful
// since it was only able to capture string vs. list vs. map.
// For this reason, during upgrade we'll just discard it
// altogether and use cty's idea of the implied type of
// turning our old value into JSON.
ty, err := ctyjson.ImpliedType(valSrc)
if err != nil {
// REALLY should never happen, because we literally just
// encoded this as JSON above!
return nil, fmt.Errorf("failed to parse output %q value from JSON: %s", name, err)
}
// ImpliedType tends to produce structural types, but since older
// version of Terraform didn't support those a collection type
// is probably what was intended, so we'll see if we can
// interpret our value as one.
ty = simplifyImpliedValueType(ty)
tySrc, err := ctyjson.MarshalType(ty)
if err != nil {
return nil, fmt.Errorf("failed to serialize output %q type as JSON: %s", name, err)
}
newOS.ValueRaw = json.RawMessage(valSrc)
newOS.ValueTypeRaw = json.RawMessage(tySrc)
new.RootOutputs[name] = newOS
}
}
}
new.normalize()
return new, nil
}
func upgradeInstanceObjectV3ToV4(rsOld *resourceStateV2, isOld *instanceStateV2, instKey addrs.InstanceKey, deposedKey states.DeposedKey) (*instanceObjectStateV4, error) {
// Schema versions were, in prior formats, a private concern of the provider
// SDK, and not a first-class concept in the state format. Here we're
// sniffing for the pre-0.12 SDK's way of representing schema versions
// and promoting it to our first-class field if we find it. We'll ignore
// it if it doesn't look like what the SDK would've written. If this
// sniffing fails then we'll assume schema version 0.
var schemaVersion uint64
migratedSchemaVersion := false
if raw, exists := isOld.Meta["schema_version"]; exists {
switch tv := raw.(type) {
case string:
v, err := strconv.ParseUint(tv, 10, 64)
if err == nil {
schemaVersion = v
migratedSchemaVersion = true
}
case int:
schemaVersion = uint64(tv)
migratedSchemaVersion = true
case float64:
schemaVersion = uint64(tv)
migratedSchemaVersion = true
}
}
private := map[string]interface{}{}
for k, v := range isOld.Meta {
if k == "schema_version" && migratedSchemaVersion {
// We're gonna promote this into our first-class schema version field
continue
}
private[k] = v
}
var privateJSON []byte
if len(private) != 0 {
var err error
privateJSON, err = json.Marshal(private)
if err != nil {
// This shouldn't happen, because the Meta values all came from JSON
// originally anyway.
return nil, fmt.Errorf("cannot serialize private instance object data: %s", err)
}
}
var status string
if isOld.Tainted {
status = "tainted"
}
var instKeyRaw interface{}
switch tk := instKey.(type) {
case addrs.IntKey:
instKeyRaw = int(tk)
case addrs.StringKey:
instKeyRaw = string(tk)
default:
if instKeyRaw != nil {
return nil, fmt.Errorf("unsupported instance key: %#v", instKey)
}
}
var attributes map[string]string
if isOld.Attributes != nil {
attributes = make(map[string]string, len(isOld.Attributes))
for k, v := range isOld.Attributes {
attributes[k] = v
}
}
if isOld.ID != "" {
// As a special case, if we don't already have an "id" attribute and
// yet there's a non-empty first-class ID on the old object then we'll
// create a synthetic id attribute to avoid losing that first-class id.
// In practice this generally arises only in tests where state literals
// are hand-written in a non-standard way; real code prior to 0.12
// would always force the first-class ID to be copied into the
// id attribute before storing.
if attributes == nil {
attributes = make(map[string]string, len(isOld.Attributes))
}
if idVal := attributes["id"]; idVal == "" {
attributes["id"] = isOld.ID
}
}
return &instanceObjectStateV4{
IndexKey: instKeyRaw,
Status: status,
Deposed: string(deposedKey),
AttributesFlat: attributes,
SchemaVersion: schemaVersion,
PrivateRaw: privateJSON,
}, nil
}
// parseLegacyResourceAddress parses the different identifier format used
// state formats before version 4, like "instance.name.0".
func parseLegacyResourceAddress(s string) (addrs.ResourceInstance, error) {
var ret addrs.ResourceInstance
// Split based on ".". Every resource address should have at least two
// elements (type and name).
parts := strings.Split(s, ".")
if len(parts) < 2 || len(parts) > 4 {
return ret, fmt.Errorf("invalid internal resource address format: %s", s)
}
// Data resource if we have at least 3 parts and the first one is data
ret.Resource.Mode = addrs.ManagedResourceMode
if len(parts) > 2 && parts[0] == "data" {
ret.Resource.Mode = addrs.DataResourceMode
parts = parts[1:]
}
// If we're not a data resource and we have more than 3, then it is an error
if len(parts) > 3 && ret.Resource.Mode != addrs.DataResourceMode {
return ret, fmt.Errorf("invalid internal resource address format: %s", s)
}
// Build the parts of the resource address that are guaranteed to exist
ret.Resource.Type = parts[0]
ret.Resource.Name = parts[1]
ret.Key = addrs.NoKey
// If we have more parts, then we have an index. Parse that.
if len(parts) > 2 {
idx, err := strconv.ParseInt(parts[2], 0, 0)
if err != nil {
return ret, fmt.Errorf("error parsing resource address %q: %s", s, err)
}
ret.Key = addrs.IntKey(idx)
}
return ret, nil
}
// simplifyImpliedValueType attempts to heuristically simplify a value type
// derived from a legacy stored output value into something simpler that
// is closer to what would've fitted into the pre-v0.12 value type system.
func simplifyImpliedValueType(ty cty.Type) cty.Type {
switch {
case ty.IsTupleType():
// If all of the element types are the same then we'll make this
// a list instead. This is very likely to be true, since prior versions
// of Terraform did not officially support mixed-type collections.
if ty.Equals(cty.EmptyTuple) {
// Don't know what the element type would be, then.
return ty
}
etys := ty.TupleElementTypes()
ety := etys[0]
for _, other := range etys[1:] {
if !other.Equals(ety) {
// inconsistent types
return ty
}
}
ety = simplifyImpliedValueType(ety)
return cty.List(ety)
case ty.IsObjectType():
// If all of the attribute types are the same then we'll make this
// a map instead. This is very likely to be true, since prior versions
// of Terraform did not officially support mixed-type collections.
if ty.Equals(cty.EmptyObject) {
// Don't know what the element type would be, then.
return ty
}
atys := ty.AttributeTypes()
var ety cty.Type
for _, other := range atys {
if ety == cty.NilType {
ety = other
continue
}
if !other.Equals(ety) {
// inconsistent types
return ty
}
}
ety = simplifyImpliedValueType(ety)
return cty.Map(ety)
default:
// No other normalizations are possible
return ty
}
}