terraform-provider-google/google/resource_compute_instance_migrate.go
Paddy 300bae3244 Fix compute_instance migration bug.
`compute_instance`'s StateVersion was set to 2. Then we released a
migration to v3, but never updated the StateVersion to 3, meaning the
migration was never run. When we added the migration for disks, we
bumped to 4, bypassing 3 altogher. In theory, this is fine, and is
expected; after all, some people may have state in version 0 and need to
upgrade all the way to 4, so our schema migration function is supposed
to support this.

Unfortunately, for migrations to v2, v3, and v4 of our schema, the
migration _returned_ after each migration, instead of falling through.
This meant that (in this case), version 2 would see it needs to be
version 4, run the state migration to version 3, then _return_, setting
its StateVersion to _4_, which means the migration from 3->4 got skipped
entirely.

This PR bumps the version to 5, and adds a migration from 4->5 such that
if there are still disks in state after 4, re-run 4. This will fix
things for people that upgraded to 1.0.0 and had their StateVersion
updated without the migration running.

I also updated the tests @danawillow wrote to start from state version 2
instead of state version 3, as the state would never be in version 3.

I also duplicated those tests, but started them from state version 4
(assuming the migration hadn't run) and verifying that the migration
from 4->5 would correct that.
2017-10-02 12:34:09 -07:00

481 lines
15 KiB
Go

package google
import (
"fmt"
"log"
"strconv"
"strings"
compute "google.golang.org/api/compute/v1"
"github.com/hashicorp/terraform/helper/hashcode"
"github.com/hashicorp/terraform/terraform"
)
func resourceComputeInstanceMigrateState(
v int, is *terraform.InstanceState, meta interface{}) (*terraform.InstanceState, error) {
if is.Empty() {
log.Println("[DEBUG] Empty InstanceState; nothing to migrate.")
return is, nil
}
var err error
switch v {
case 0:
log.Println("[INFO] Found Compute Instance State v0; migrating to v1")
is, err = migrateStateV0toV1(is)
if err != nil {
return is, err
}
fallthrough
case 1:
log.Println("[INFO] Found Compute Instance State v1; migrating to v2")
is, err = migrateStateV1toV2(is)
if err != nil {
return is, err
}
fallthrough
case 2:
log.Println("[INFO] Found Compute Instance State v2; migrating to v3")
is, err = migrateStateV2toV3(is)
if err != nil {
return is, err
}
fallthrough
case 3:
log.Println("[INFO] Found Compute Instance State v3; migrating to v4")
is, err = migrateStateV3toV4(is, meta)
if err != nil {
return is, err
}
fallthrough
case 4:
log.Println("[INFO] Found Compute Instance State v4; migrating to v5")
is, err = migrateStateV4toV5(is, meta)
if err != nil {
return is, err
}
// when adding case 5, make sure to turn this into a fallthrough
return is, err
default:
return is, fmt.Errorf("Unexpected schema version: %d", v)
}
}
func migrateStateV0toV1(is *terraform.InstanceState) (*terraform.InstanceState, error) {
log.Printf("[DEBUG] Attributes before migration: %#v", is.Attributes)
// Delete old count
delete(is.Attributes, "metadata.#")
newMetadata := make(map[string]string)
for k, v := range is.Attributes {
if !strings.HasPrefix(k, "metadata.") {
continue
}
// We have a key that looks like "metadata.*" and we know it's not
// metadata.# because we deleted it above, so it must be metadata.<N>.<key>
// from the List of Maps. Just need to convert it to a single Map by
// ditching the '<N>' field.
kParts := strings.SplitN(k, ".", 3)
// Sanity check: all three parts should be there and <N> should be a number
badFormat := false
if len(kParts) != 3 {
badFormat = true
} else if _, err := strconv.Atoi(kParts[1]); err != nil {
badFormat = true
}
if badFormat {
return is, fmt.Errorf(
"migration error: found metadata key in unexpected format: %s", k)
}
// Rejoin as "metadata.<key>"
newK := strings.Join([]string{kParts[0], kParts[2]}, ".")
newMetadata[newK] = v
delete(is.Attributes, k)
}
for k, v := range newMetadata {
is.Attributes[k] = v
}
log.Printf("[DEBUG] Attributes after migration: %#v", is.Attributes)
return is, nil
}
func migrateStateV1toV2(is *terraform.InstanceState) (*terraform.InstanceState, error) {
log.Printf("[DEBUG] Attributes before migration: %#v", is.Attributes)
// Maps service account index to list of scopes for that sccount
newScopesMap := make(map[string][]string)
for k, v := range is.Attributes {
if !strings.HasPrefix(k, "service_account.") {
continue
}
if k == "service_account.#" {
continue
}
if strings.HasSuffix(k, ".scopes.#") {
continue
}
if strings.HasSuffix(k, ".email") {
continue
}
// Key is now of the form service_account.%d.scopes.%d
kParts := strings.Split(k, ".")
// Sanity check: all three parts should be there and <N> should be a number
badFormat := false
if len(kParts) != 4 {
badFormat = true
} else if _, err := strconv.Atoi(kParts[1]); err != nil {
badFormat = true
}
if badFormat {
return is, fmt.Errorf(
"migration error: found scope key in unexpected format: %s", k)
}
newScopesMap[kParts[1]] = append(newScopesMap[kParts[1]], v)
delete(is.Attributes, k)
}
for service_acct_index, newScopes := range newScopesMap {
for _, newScope := range newScopes {
hash := hashcode.String(canonicalizeServiceScope(newScope))
newKey := fmt.Sprintf("service_account.%s.scopes.%d", service_acct_index, hash)
is.Attributes[newKey] = newScope
}
}
log.Printf("[DEBUG] Attributes after migration: %#v", is.Attributes)
return is, nil
}
func migrateStateV2toV3(is *terraform.InstanceState) (*terraform.InstanceState, error) {
log.Printf("[DEBUG] Attributes before migration: %#v", is.Attributes)
is.Attributes["create_timeout"] = "4"
log.Printf("[DEBUG] Attributes after migration: %#v", is.Attributes)
return is, nil
}
func migrateStateV3toV4(is *terraform.InstanceState, meta interface{}) (*terraform.InstanceState, error) {
log.Printf("[DEBUG] Attributes before migration: %#v", is.Attributes)
// Read instance from GCP. Since disks are not necessarily returned from the API in the order they were set,
// we have no other way to know which source belongs to which attached disk.
// Also note that the following code modifies the returned instance- if you need immutability, please change
// this to make a copy of the needed data.
config := meta.(*Config)
instance, err := getInstanceFromInstanceState(config, is)
if err != nil {
return is, fmt.Errorf("migration error: %s", err)
}
diskList, err := getAllDisksFromInstanceState(config, is)
if err != nil {
return is, fmt.Errorf("migration error: %s", err)
}
allDisks := make(map[string]*compute.Disk)
for _, disk := range diskList {
allDisks[disk.Name] = disk
}
hasBootDisk := is.Attributes["boot_disk.#"] == "1"
scratchDisks := 0
if v := is.Attributes["scratch_disk.#"]; v != "" {
scratchDisks, err = strconv.Atoi(v)
if err != nil {
return is, fmt.Errorf("migration error: found scratch_disk.# value in unexpected format: %s", err)
}
}
attachedDisks := 0
if v := is.Attributes["attached_disk.#"]; v != "" {
attachedDisks, err = strconv.Atoi(v)
if err != nil {
return is, fmt.Errorf("migration error: found attached_disk.# value in unexpected format: %s", err)
}
}
disks, err := strconv.Atoi(is.Attributes["disk.#"])
if err != nil {
return is, fmt.Errorf("migration error: found disk.# value in unexpected format: %s", err)
}
for i := 0; i < disks; i++ {
if !hasBootDisk && i == 0 {
is.Attributes["boot_disk.#"] = "1"
// Note: the GCP API does not allow for scratch disks to be boot disks, so this situation
// should never occur.
if is.Attributes["disk.0.scratch_disk"] == "true" {
return is, fmt.Errorf("migration error: found scratch disk at index 0")
}
for _, disk := range instance.Disks {
if disk.Boot {
sourceUrl := strings.Split(disk.Source, "/")
is.Attributes["boot_disk.0.source"] = sourceUrl[len(sourceUrl)-1]
is.Attributes["boot_disk.0.device_name"] = disk.DeviceName
break
}
}
is.Attributes["boot_disk.0.auto_delete"] = is.Attributes["disk.0.auto_delete"]
is.Attributes["boot_disk.0.disk_encryption_key_raw"] = is.Attributes["disk.0.disk_encryption_key_raw"]
is.Attributes["boot_disk.0.disk_encryption_key_sha256"] = is.Attributes["disk.0.disk_encryption_key_sha256"]
// Don't worry about initialize_params, since the disk has already been created.
} else if is.Attributes[fmt.Sprintf("disk.%d.scratch", i)] == "true" {
// Note: the GCP API does not allow for scratch disks without auto_delete, so this situation
// should never occur.
if is.Attributes[fmt.Sprintf("disk.%d.auto_delete", i)] != "true" {
return is, fmt.Errorf("migration error: attempted to migrate scratch disk where auto_delete is not true")
}
is.Attributes[fmt.Sprintf("scratch_disk.%d.interface", scratchDisks)] = "SCSI"
scratchDisks++
} else {
// If disk is neither boot nor scratch, then it is attached.
disk, err := getDiskFromAttributes(config, instance, allDisks, is.Attributes, i)
if err != nil {
return is, fmt.Errorf("migration error: %s", err)
}
is.Attributes[fmt.Sprintf("attached_disk.%d.source", attachedDisks)] = disk.Source
is.Attributes[fmt.Sprintf("attached_disk.%d.device_name", attachedDisks)] = disk.DeviceName
is.Attributes[fmt.Sprintf("attached_disk.%d.disk_encryption_key_raw", attachedDisks)] = is.Attributes[fmt.Sprintf("disk.%d.disk_encryption_key_raw", i)]
is.Attributes[fmt.Sprintf("attached_disk.%d.disk_encryption_key_sha256", attachedDisks)] = is.Attributes[fmt.Sprintf("disk.%d.disk_encryption_key_sha256", i)]
attachedDisks++
}
}
for k, _ := range is.Attributes {
if !strings.HasPrefix(k, "disk.") {
continue
}
delete(is.Attributes, k)
}
if scratchDisks > 0 {
is.Attributes["scratch_disk.#"] = strconv.Itoa(scratchDisks)
}
if attachedDisks > 0 {
is.Attributes["attached_disk.#"] = strconv.Itoa(attachedDisks)
}
log.Printf("[DEBUG] Attributes after migration: %#v", is.Attributes)
return is, nil
}
func migrateStateV4toV5(is *terraform.InstanceState, meta interface{}) (*terraform.InstanceState, error) {
if v := is.Attributes["disk.#"]; v != "" {
return migrateStateV3toV4(is, meta)
}
return is, nil
}
func getInstanceFromInstanceState(config *Config, is *terraform.InstanceState) (*compute.Instance, error) {
project, ok := is.Attributes["project"]
if !ok {
if config.Project == "" {
return nil, fmt.Errorf("could not determine 'project'")
} else {
project = config.Project
}
}
zone, ok := is.Attributes["zone"]
if !ok {
return nil, fmt.Errorf("could not determine 'zone'")
}
instance, err := config.clientCompute.Instances.Get(
project, zone, is.ID).Do()
if err != nil {
return nil, fmt.Errorf("error reading instance: %s", err)
}
return instance, nil
}
func getAllDisksFromInstanceState(config *Config, is *terraform.InstanceState) ([]*compute.Disk, error) {
project, ok := is.Attributes["project"]
if !ok {
if config.Project == "" {
return nil, fmt.Errorf("could not determine 'project'")
} else {
project = config.Project
}
}
zone, ok := is.Attributes["zone"]
if !ok {
return nil, fmt.Errorf("could not determine 'zone'")
}
diskList := []*compute.Disk{}
token := ""
for {
disks, err := config.clientCompute.Disks.List(project, zone).PageToken(token).Do()
if err != nil {
return nil, fmt.Errorf("error reading disks: %s", err)
}
diskList = append(diskList, disks.Items...)
token = disks.NextPageToken
if token == "" {
break
}
}
return diskList, nil
}
func getDiskFromAttributes(config *Config, instance *compute.Instance, allDisks map[string]*compute.Disk, attributes map[string]string, i int) (*compute.AttachedDisk, error) {
if diskSource := attributes[fmt.Sprintf("disk.%d.disk", i)]; diskSource != "" {
return getDiskFromSource(instance, diskSource)
}
if deviceName := attributes[fmt.Sprintf("disk.%d.device_name", i)]; deviceName != "" {
return getDiskFromDeviceName(instance, deviceName)
}
if encryptionKey := attributes[fmt.Sprintf("disk.%d.disk_encryption_key_raw", i)]; encryptionKey != "" {
return getDiskFromEncryptionKey(instance, encryptionKey)
}
autoDelete, err := strconv.ParseBool(attributes[fmt.Sprintf("disk.%d.auto_delete", i)])
if err != nil {
return nil, fmt.Errorf("error parsing auto_delete attribute of disk %d", i)
}
image := attributes[fmt.Sprintf("disk.%d.image", i)]
// We know project and zone are set because we used them to read the instance
project, ok := attributes["project"]
if !ok {
project = config.Project
}
zone := attributes["zone"]
return getDiskFromAutoDeleteAndImage(config, instance, allDisks, autoDelete, image, project, zone)
}
func getDiskFromSource(instance *compute.Instance, source string) (*compute.AttachedDisk, error) {
for _, disk := range instance.Disks {
if disk.Boot == true || disk.Type == "SCRATCH" {
// Ignore boot/scratch disks since this is just for finding attached disks
continue
}
// we can just compare suffixes because terraform only allows setting "disk" by name and uses
// the zone of the instance so we know there can be no duplicate names.
if strings.HasSuffix(disk.Source, "/"+source) {
return disk, nil
}
}
return nil, fmt.Errorf("could not find attached disk with source %q", source)
}
func getDiskFromDeviceName(instance *compute.Instance, deviceName string) (*compute.AttachedDisk, error) {
for _, disk := range instance.Disks {
if disk.Boot == true || disk.Type == "SCRATCH" {
// Ignore boot/scratch disks since this is just for finding attached disks
continue
}
if disk.DeviceName == deviceName {
return disk, nil
}
}
return nil, fmt.Errorf("could not find attached disk with deviceName %q", deviceName)
}
func getDiskFromEncryptionKey(instance *compute.Instance, encryptionKey string) (*compute.AttachedDisk, error) {
encryptionSha, err := hash256(encryptionKey)
if err != nil {
return nil, err
}
for _, disk := range instance.Disks {
if disk.Boot == true || disk.Type == "SCRATCH" {
// Ignore boot/scratch disks since this is just for finding attached disks
continue
}
if disk.DiskEncryptionKey.Sha256 == encryptionSha {
return disk, nil
}
}
return nil, fmt.Errorf("could not find attached disk with encryption hash %q", encryptionSha)
}
func getDiskFromAutoDeleteAndImage(config *Config, instance *compute.Instance, allDisks map[string]*compute.Disk, autoDelete bool, image, project, zone string) (*compute.AttachedDisk, error) {
img, err := resolveImage(config, project, image)
if err != nil {
return nil, err
}
imgParts := strings.Split(img, "/projects/")
canonicalImage := imgParts[len(imgParts)-1]
for i, disk := range instance.Disks {
if disk.Boot == true || disk.Type == "SCRATCH" {
// Ignore boot/scratch disks since this is just for finding attached disks
continue
}
if disk.AutoDelete == autoDelete {
// Read the disk to check if its image matches
sourceUrl := strings.Split(disk.Source, "/")
fullDisk := allDisks[sourceUrl[len(sourceUrl)-1]]
sourceImage, err := getRelativePath(fullDisk.SourceImage)
if err != nil {
return nil, err
}
if canonicalImage == sourceImage {
// Delete this disk because there might be multiple that match
instance.Disks = append(instance.Disks[:i], instance.Disks[i+1:]...)
return disk, nil
}
}
}
// We're not done! It's possible the disk was created with an image family rather than the image itself.
// Now, do the exact same iteration but do some prefix matching to check if the families match.
// This assumes that all disks with a given family have a sourceImage whose name starts with the name of
// the image family.
canonicalImage = strings.Replace(canonicalImage, "/family/", "/", -1)
for i, disk := range instance.Disks {
if disk.Boot == true || disk.Type == "SCRATCH" {
// Ignore boot/scratch disks since this is just for finding attached disks
continue
}
if disk.AutoDelete == autoDelete {
// Read the disk to check if its image matches
sourceUrl := strings.Split(disk.Source, "/")
fullDisk := allDisks[sourceUrl[len(sourceUrl)-1]]
sourceImage, err := getRelativePath(fullDisk.SourceImage)
if err != nil {
return nil, err
}
if strings.Contains(sourceImage, "/"+canonicalImage+"-") {
// Delete this disk because there might be multiple that match
instance.Disks = append(instance.Disks[:i], instance.Disks[i+1:]...)
return disk, nil
}
}
}
return nil, fmt.Errorf("could not find attached disk with image %q", image)
}