Repository: GoogleCloudPlatform/terraform-validator
Pull request #725 (2022-05-25 21:58:33)

Simplified converter error messages and added resource address
Goals:

- Reduce technical jargon so that errors are easier to understand
- Add resource address to all error messages so that large plans are easier to debug.
- Don't log anything related to non-google resources
- Reduce level of messages that don't impact normal operations
- Add debug-level warnings if there is a beta resource in the plan

b/225383221


Patch:
diff --git a/cmd/convert.go b/cmd/convert.go
index a7b04b39..7ce5c51a 100644
--- a/cmd/convert.go
+++ b/cmd/convert.go
@@ -98,10 +98,7 @@ func (o *convertOptions) run(plan string) error {
 	}
 	assets, err := o.readPlannedAssets(ctx, plan, o.project, ancestryCache, o.offline, false, o.rootOptions.errorLogger)
 	if err != nil {
-		if errors.Cause(err) == tfgcv.ErrParsingProviderProject {
-			return errors.New("unable to parse provider project, please use --project flag")
-		}
-		return errors.Wrap(err, "converting tfplan to CAI assets")
+		return err
 	}
 
 	if len(o.outputPath) > 0 {
diff --git a/cmd/validate.go b/cmd/validate.go
index cb48fb75..0af5b687 100644
--- a/cmd/validate.go
+++ b/cmd/validate.go
@@ -117,10 +117,7 @@ func (o *validateOptions) run(plan string) error {
 		}
 		assets, err = o.readPlannedAssets(ctx, plan, o.project, ancestryCache, o.offline, false, o.rootOptions.errorLogger)
 		if err != nil {
-			if errors.Cause(err) == tfgcv.ErrParsingProviderProject {
-				return errors.New("unable to parse provider project, please use --project flag")
-			}
-			return errors.Wrap(err, "converting tfplan to CAI assets")
+			return err
 		}
 	}
 
diff --git a/converters/google/convert.go b/converters/google/convert.go
index 9ab20782..333d4706 100644
--- a/converters/google/convert.go
+++ b/converters/google/convert.go
@@ -173,15 +173,25 @@ type Converter struct {
 func (c *Converter) AddResourceChanges(changes []*tfjson.ResourceChange) error {
 	var createOrUpdateOrNoops []*tfjson.ResourceChange
 	for _, rc := range changes {
-		// skip unknown resources
+		// Silently skip non-google resources
+		if !strings.HasPrefix(rc.Type, "google_") {
+			continue
+		}
+
+		// Warn about google-beta resources
+		if rc.ProviderName == "registry.terraform.io/hashicorp/google-beta" {
+			c.errorLogger.Debug(fmt.Sprintf("%s: resource uses the google-beta provider and may not be convertible", rc.Address))
+		}
+
+		// Skip resources not found in the google GA provider's schema
 		if _, ok := c.schema.ResourcesMap[rc.Type]; !ok {
-			c.errorLogger.Info(fmt.Sprintf("unknown resource: %s", rc.Type))
+			c.errorLogger.Debug(fmt.Sprintf("%s: resource type not found in google GA provider: %s.", rc.Address, rc.Type))
 			continue
 		}
 
 		// Skip unsupported resources
 		if _, ok := c.converters[rc.Type]; !ok {
-			c.errorLogger.Info(fmt.Sprintf("unsupported resource: %s", rc.Type))
+			c.errorLogger.Debug(fmt.Sprintf("%s: resource type cannot be converted for CAI-based policies: %s. For details, see https://cloud.google.com/docs/terraform/policy-validation/create-cai-constraints#supported_resources", rc.Address, rc.Type))
 			continue
 		}
 
@@ -189,7 +199,7 @@ func (c *Converter) AddResourceChanges(changes []*tfjson.ResourceChange) error {
 			createOrUpdateOrNoops = append(createOrUpdateOrNoops, rc)
 		} else if tfplan.IsDelete(rc) {
 			if err := c.addDelete(rc); err != nil {
-				return fmt.Errorf("adding resource deletion %w", err)
+				return fmt.Errorf("%s: converting deleted TF resource to CAI: %w", rc.Address, err)
 			}
 		}
 	}
@@ -197,9 +207,9 @@ func (c *Converter) AddResourceChanges(changes []*tfjson.ResourceChange) error {
 	for _, rc := range createOrUpdateOrNoops {
 		if err := c.addCreateOrUpdateOrNoop(rc); err != nil {
 			if errorssyslib.Is(err, ErrDuplicateAsset) {
-				c.errorLogger.Warn(fmt.Sprintf("adding resource change: %v", err))
+				c.errorLogger.Warn(fmt.Sprintf("%s: converting TF resource to CAI: %v", rc.Address, err))
 			} else {
-				return fmt.Errorf("adding resource create/update/no-op %w", err)
+				return fmt.Errorf("%s: converting TF resource to CAI: %w", rc.Address, err)
 			}
 		}
 	}
@@ -228,7 +238,7 @@ func (c *Converter) addDelete(rc *tfjson.ResourceChange) error {
 			if errors.Cause(err) == resources.ErrNoConversion {
 				continue
 			}
-			return errors.Wrap(err, "converting asset")
+			return err
 		}
 
 		for _, converted := range convertedItems {
@@ -240,13 +250,13 @@ func (c *Converter) addDelete(rc *tfjson.ResourceChange) error {
 			} else if !c.offline {
 				asset, err := converter.FetchFullResource(&rd, c.cfg)
 				if errors.Cause(err) == resources.ErrEmptyIdentityField {
-					c.errorLogger.Warn(fmt.Sprintf("%s did not return a value for ID field. Skipping asset fetch.", key))
+					c.errorLogger.Debug(fmt.Sprintf("%s: Unable to fetch and merge remote %s asset due to unset or (known after apply) identity fields on the TF resource.", rc.Address, converted.Type))
 					existingConverterAsset = nil
 				} else if errors.Cause(err) == resources.ErrResourceInaccessible {
-					c.errorLogger.Warn(fmt.Sprintf("%s was not able to be fetched due to not existing or insufficient permission. Skipping asset fetch.", key))
+					c.errorLogger.Warn(fmt.Sprintf("%s: Fetching %s for merge failed due to not existing or insufficient permission.", rc.Address, key))
 					existingConverterAsset = nil
 				} else if err != nil {
-					return errors.Wrap(err, "fetching asset")
+					return fmt.Errorf("fetching remote asset %s: %w", key, err)
 				} else {
 					existingConverterAsset = &asset
 				}
@@ -254,7 +264,7 @@ func (c *Converter) addDelete(rc *tfjson.ResourceChange) error {
 					converted = converter.MergeDelete(*existingConverterAsset, converted)
 					augmented, err := c.augmentAsset(&rd, c.cfg, converted)
 					if err != nil {
-						return errors.Wrap(err, "augmenting asset")
+						return err
 					}
 					c.assets[key] = augmented
 				}
@@ -282,7 +292,7 @@ func (c *Converter) addCreateOrUpdateOrNoop(rc *tfjson.ResourceChange) error {
 			if errors.Cause(err) == resources.ErrNoConversion {
 				continue
 			}
-			return errors.Wrap(err, "converting asset")
+			return err
 		}
 
 		for _, converted := range convertedAssets {
@@ -294,13 +304,13 @@ func (c *Converter) addCreateOrUpdateOrNoop(rc *tfjson.ResourceChange) error {
 			} else if converter.FetchFullResource != nil && !c.offline {
 				asset, err := converter.FetchFullResource(&rd, c.cfg)
 				if errors.Cause(err) == resources.ErrEmptyIdentityField {
-					c.errorLogger.Warn(fmt.Sprintf("%s did not return a value for ID field. Skipping asset fetch.", key))
+					c.errorLogger.Debug(fmt.Sprintf("%s: Unable to fetch and merge remote %s asset due to unset or (known after apply) identity fields on the TF resource.", rc.Address, converted.Type))
 					existingConverterAsset = nil
 				} else if errors.Cause(err) == resources.ErrResourceInaccessible {
-					c.errorLogger.Warn(fmt.Sprintf("%s was not able to be fetched due to not existing or insufficient permission. Skipping asset fetch.", key))
+					c.errorLogger.Warn(fmt.Sprintf("%s: Fetching %s for merge failed due to not existing or insufficient permission.", rc.Address, key))
 					existingConverterAsset = nil
 				} else if err != nil {
-					return errors.Wrap(err, "fetching asset")
+					return fmt.Errorf("fetching remote asset %s: %w", key, err)
 				} else {
 					existingConverterAsset = &asset
 				}
@@ -310,14 +320,14 @@ func (c *Converter) addCreateOrUpdateOrNoop(rc *tfjson.ResourceChange) error {
 				if converter.MergeCreateUpdate == nil {
 					// If a merge function does not exist ignore the asset and return
 					// a checkable error.
-					return fmt.Errorf("asset type %s: asset name %s %w", converted.Type, converted.Name, ErrDuplicateAsset)
+					return fmt.Errorf("%w: type %s: name %s", ErrDuplicateAsset, converted.Type, converted.Name)
 				}
 				converted = converter.MergeCreateUpdate(*existingConverterAsset, converted)
 			}
 
 			augmented, err := c.augmentAsset(&rd, c.cfg, converted)
 			if err != nil {
-				return errors.Wrap(err, "augmenting asset")
+				return err
 			}
 			c.assets[key] = augmented
 		}
diff --git a/go.mod b/go.mod
index b53243ff..89823615 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
 	github.com/hashicorp/go-cleanhttp v0.5.2
 	github.com/hashicorp/terraform-json v0.13.0
 	github.com/hashicorp/terraform-plugin-sdk/v2 v2.10.1
-	github.com/hashicorp/terraform-provider-google v1.20.1-0.20220525180115-2c39a0446a11
+	github.com/hashicorp/terraform-provider-google v1.20.1-0.20220525183030-9f6e3cd58186
 	github.com/kr/pretty v0.3.0 // indirect
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/onsi/gomega v1.17.0 // indirect
diff --git a/go.sum b/go.sum
index 46a7e32a..3ae4f1bc 100644
--- a/go.sum
+++ b/go.sum
@@ -85,8 +85,8 @@ github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3
 github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
 github.com/GoogleCloudPlatform/config-validator v0.0.0-20211122204404-f3fd77c5c355 h1:UQQJmJl5aZNwLISiq/YqEEFXxv042cYY6wD49TOzrco=
 github.com/GoogleCloudPlatform/config-validator v0.0.0-20211122204404-f3fd77c5c355/go.mod h1:MHzWJNkQiBhSRuWAOxdMBBR+acz6g+jXGDtZp5S7vzU=
-github.com/GoogleCloudPlatform/declarative-resource-client-library v1.5.2 h1:SrHcyR/zNEaghHyLZvKKIjKL6RKNuESkyYZIgMHE3ko=
-github.com/GoogleCloudPlatform/declarative-resource-client-library v1.5.2/go.mod h1:UJoDYx6t3+xCOd+dZX8+NrEB+Y/eW1pQlvxh2Gt7y5E=
+github.com/GoogleCloudPlatform/declarative-resource-client-library v1.8.2 h1:V2EzqYSMPUkyKTtSevKTFU0Dw7nK6uVpg40U31Y0x/8=
+github.com/GoogleCloudPlatform/declarative-resource-client-library v1.8.2/go.mod h1:UJoDYx6t3+xCOd+dZX8+NrEB+Y/eW1pQlvxh2Gt7y5E=
 github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
 github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E=
 github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
@@ -703,8 +703,8 @@ github.com/hashicorp/terraform-plugin-log v0.2.0 h1:rjflRuBqCnSk3UHOR25MP1G5BDLK
 github.com/hashicorp/terraform-plugin-log v0.2.0/go.mod h1:E1kJmapEHzqu1x6M++gjvhzM2yMQNXPVWZRCB8sgYjg=
 github.com/hashicorp/terraform-plugin-sdk/v2 v2.10.1 h1:B9AocC+dxrCqcf4vVhztIkSkt3gpRjUkEka8AmZWGlQ=
 github.com/hashicorp/terraform-plugin-sdk/v2 v2.10.1/go.mod h1:FjM9DXWfP0w/AeOtJoSKHBZ01LqmaO6uP4bXhv3fekw=
-github.com/hashicorp/terraform-provider-google v1.20.1-0.20220525180115-2c39a0446a11 h1:zp032ynfPXJmi6vm7jILJc9elQXzctX/hwFPeX91TmU=
-github.com/hashicorp/terraform-provider-google v1.20.1-0.20220525180115-2c39a0446a11/go.mod h1:j3C317ieF2X8MaHzA3jCdIL1DlAQsu9Hw/f09olZ+a4=
+github.com/hashicorp/terraform-provider-google v1.20.1-0.20220525183030-9f6e3cd58186 h1:UzgVkA2ghHUfzm/fQULU3IXKlfzAxtX/E2WySJm6Rvk=
+github.com/hashicorp/terraform-provider-google v1.20.1-0.20220525183030-9f6e3cd58186/go.mod h1:I35tdmI6jM33KOXYHpvcolETDfoEpvHbRA06NKyOe68=
 github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896 h1:1FGtlkJw87UsTMg5s8jrekrHmUPUJaMcu6ELiVhQrNw=
 github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896/go.mod h1:bzBPnUIkI0RxauU8Dqo+2KrZZ28Cf48s8V6IHt3p4co=
 github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 h1:HKLsbzeOsfXmKNpr3GiT18XAblV0BjCbzL8KQAMZGa0=
diff --git a/tfgcv/planned_assets.go b/tfgcv/planned_assets.go
index 99da7d10..bd77886b 100644
--- a/tfgcv/planned_assets.go
+++ b/tfgcv/planned_assets.go
@@ -52,12 +52,12 @@ func ReadPlannedAssets(ctx context.Context, path, project string, ancestry map[s
 
 	changes, err := tfplan.ReadResourceChanges(data)
 	if err != nil {
-		return nil, errors.Wrap(err, "reading resource changes")
+		return nil, err
 	}
 
 	err = converter.AddResourceChanges(changes)
 	if err != nil {
-		return nil, errors.Wrap(err, "adding resource changes to converter")
+		return nil, err
 	}
 
 	return converter.Assets(), nil
@@ -92,5 +92,3 @@ func readTF12Data(path string) ([]byte, error) {
 	}
 	return data, nil
 }
-
-var ErrParsingProviderProject = errors.New("unable to parse provider project")


Test patch:
diff --git a/converters/google/convert_test.go b/converters/google/convert_test.go
index a8f1dd54..da753bef 100644
--- a/converters/google/convert_test.go
+++ b/converters/google/convert_test.go
@@ -15,6 +15,7 @@
 package google
 
 import (
+	"bytes"
 	"context"
 	"sort"
 	"testing"
@@ -26,22 +27,49 @@ import (
 	"github.com/pkg/errors"
 	"github.com/stretchr/testify/assert"
 	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
 )
 
 const testProject = "test-project"
 
-func newTestConverter(convertUnchanged bool) (*Converter, error) {
+type bufferWriteSyncer struct {
+	*bytes.Buffer
+}
+
+func (bws bufferWriteSyncer) Sync() error {
+	return nil
+}
+
+// This returns a logger to allow deterministic testing of the output
+// by omitting the timestamp and calling function.
+func newTestErrorLogger() (*zap.Logger, *bytes.Buffer) {
+	buf := new(bytes.Buffer)
+	syncer := bufferWriteSyncer{Buffer: buf}
+
+	encoderCfg := zapcore.EncoderConfig{
+		MessageKey:     "msg",
+		LevelKey:       "level",
+		NameKey:        "logger",
+		EncodeLevel:    zapcore.LowercaseLevelEncoder,
+		EncodeTime:     zapcore.RFC3339NanoTimeEncoder,
+		EncodeDuration: zapcore.StringDurationEncoder,
+	}
+	core := zapcore.NewCore(zapcore.NewJSONEncoder(encoderCfg), syncer, zap.DebugLevel)
+	return zap.New(core), syncer.Buffer
+}
+
+func newTestConverter(convertUnchanged bool) (*Converter, *bytes.Buffer, error) {
 	ctx := context.Background()
 	project := testProject
 	offline := true
 	cfg, err := resources.GetConfig(ctx, project, offline)
 	if err != nil {
-		return nil, errors.Wrap(err, "constructing configuration")
+		return nil, nil, errors.Wrap(err, "constructing configuration")
 	}
-	errorLogger := zap.NewExample()
+	errorLogger, buf := newTestErrorLogger()
 	c := NewConverter(cfg, &ancestrymanager.NoOpAncestryManager{}, offline, convertUnchanged, errorLogger)
 
-	return c, nil
+	return c, buf, nil
 }
 
 func TestSortByName(t *testing.T) {
@@ -71,6 +99,27 @@ func TestSortByName(t *testing.T) {
 	}
 }
 
+func TestAddResourceChanges_nonGoogleResourceIgnored(t *testing.T) {
+	rc := tfjson.ResourceChange{
+		Address:      "whatever.aws_api_gateway_account.foo",
+		Mode:         "managed",
+		Type:         "aws_api_gateway_account",
+		Name:         "foo",
+		ProviderName: "aws",
+		Change: &tfjson.Change{
+			Actions: tfjson.Actions{"change"},
+			Before:  nil,
+			After:   nil,
+		},
+	}
+	c, buf, err := newTestConverter(false)
+	assert.Nil(t, err)
+	err = c.AddResourceChanges([]*tfjson.ResourceChange{&rc})
+	assert.Nil(t, err)
+	assert.EqualValues(t, map[string]Asset{}, c.assets)
+	assert.Equal(t, "", buf.String())
+}
+
 func TestAddResourceChanges_unknownResourceIgnored(t *testing.T) {
 	rc := tfjson.ResourceChange{
 		Address:      "whatever.google_unknown.foo",
@@ -84,11 +133,13 @@ func TestAddResourceChanges_unknownResourceIgnored(t *testing.T) {
 			After:   nil,
 		},
 	}
-	c, err := newTestConverter(false)
+	c, buf, err := newTestConverter(false)
 	assert.Nil(t, err)
 	err = c.AddResourceChanges([]*tfjson.ResourceChange{&rc})
 	assert.Nil(t, err)
 	assert.EqualValues(t, map[string]Asset{}, c.assets)
+	assert.Contains(t, buf.String(), "resource type not found")
+	assert.Contains(t, buf.String(), rc.Address)
 }
 
 func TestAddResourceChanges_unsupportedResourceIgnored(t *testing.T) {
@@ -104,7 +155,7 @@ func TestAddResourceChanges_unsupportedResourceIgnored(t *testing.T) {
 			After:   nil,
 		},
 	}
-	c, err := newTestConverter(false)
+	c, buf, err := newTestConverter(false)
 	assert.Nil(t, err)
 
 	// fake that this resource is known to the provider; it will never be "supported" by the
@@ -114,6 +165,8 @@ func TestAddResourceChanges_unsupportedResourceIgnored(t *testing.T) {
 	err = c.AddResourceChanges([]*tfjson.ResourceChange{&rc})
 	assert.Nil(t, err)
 	assert.EqualValues(t, map[string]Asset{}, c.assets)
+	assert.Contains(t, buf.String(), "resource type cannot be converted")
+	assert.Contains(t, buf.String(), rc.Address)
 }
 
 func TestAddResourceChanges_noopIgnoredWhenConvertUnchangedFalse(t *testing.T) {
@@ -130,12 +183,13 @@ func TestAddResourceChanges_noopIgnoredWhenConvertUnchangedFalse(t *testing.T) {
 		},
 	}
 	convertUnchanged := false
-	c, err := newTestConverter(convertUnchanged)
+	c, buf, err := newTestConverter(convertUnchanged)
 	assert.Nil(t, err)
 
 	err = c.AddResourceChanges([]*tfjson.ResourceChange{&rc})
 	assert.Nil(t, err)
 	assert.EqualValues(t, map[string]Asset{}, c.assets)
+	assert.Equal(t, "", buf.String())
 }
 
 func TestAddResourceChanges_deleteProcessed(t *testing.T) {
@@ -176,16 +230,53 @@ func TestAddResourceChanges_deleteProcessed(t *testing.T) {
 					After: nil,
 				},
 			}
-			c, err := newTestConverter(tc.convertUnchanged)
+			c, buf, err := newTestConverter(tc.convertUnchanged)
 			assert.Nil(t, err)
 
 			err = c.AddResourceChanges([]*tfjson.ResourceChange{&rc})
 			assert.Nil(t, err)
 			assert.EqualValues(t, map[string]Asset{}, c.assets)
+			assert.Equal(t, "", buf.String())
 		})
 	}
 }
 
+func TestAddResourceChanges_betaResourcesLogged(t *testing.T) {
+	rc := tfjson.ResourceChange{
+		Address:      "whatever.google_compute_disk.foo",
+		Mode:         "managed",
+		Type:         "google_compute_disk",
+		Name:         "foo",
+		ProviderName: "registry.terraform.io/hashicorp/google-beta",
+		Change: &tfjson.Change{
+			Actions: tfjson.Actions{"create"},
+			Before:  nil, // Ignore Before because it's unused
+			After: map[string]interface{}{
+				"project": testProject,
+				"name":    "test-disk",
+				"type":    "pd-ssd",
+				"zone":    "us-central1-a",
+				"image":   "projects/debian-cloud/global/images/debian-8-jessie-v20170523",
+				"labels": map[string]interface{}{
+					"environment": "dev",
+				},
+				"physical_block_size_bytes": 4096,
+			},
+		},
+	}
+	c, buf, err := newTestConverter(false)
+	assert.Nil(t, err)
+
+	err = c.AddResourceChanges([]*tfjson.ResourceChange{&rc})
+	assert.Nil(t, err)
+
+	caiKey := "compute.googleapis.com/Disk//compute.googleapis.com/projects/test-project/zones/us-central1-a/disks/test-disk"
+	assert.Contains(t, c.assets, caiKey)
+
+	assert.Contains(t, buf.String(), "resource uses the google-beta provider")
+	assert.Contains(t, buf.String(), rc.Address)
+}
+
 func TestAddResourceChanges_createOrUpdateOrDeleteCreateOrNoopProcessed(t *testing.T) {
 	cases := []struct {
 		name             string
@@ -252,7 +343,7 @@ func TestAddResourceChanges_createOrUpdateOrDeleteCreateOrNoopProcessed(t *testi
 					},
 				},
 			}
-			c, err := newTestConverter(c.convertUnchanged)
+			c, buf, err := newTestConverter(c.convertUnchanged)
 			assert.Nil(t, err)
 
 			err = c.AddResourceChanges([]*tfjson.ResourceChange{&rc})
@@ -260,6 +351,8 @@ func TestAddResourceChanges_createOrUpdateOrDeleteCreateOrNoopProcessed(t *testi
 
 			caiKey := "compute.googleapis.com/Disk//compute.googleapis.com/projects/test-project/zones/us-central1-a/disks/test-disk"
 			assert.Contains(t, c.assets, caiKey)
+			assert.Equal(t, "", buf.String())
+			assert.NotContains(t, buf.String(), "resource uses the google-beta provider")
 		})
 	}
 }
@@ -383,7 +476,7 @@ func TestAddDuplicatedResources(t *testing.T) {
 			},
 		},
 	}
-	c, err := newTestConverter(false)
+	c, buf, err := newTestConverter(false)
 	assert.Nil(t, err)
 
 	err = c.AddResourceChanges([]*tfjson.ResourceChange{&rcb1, &rcb2, &rcp1, &rcp2})
@@ -394,6 +487,8 @@ func TestAddDuplicatedResources(t *testing.T) {
 
 	caiKeyProject := "cloudresourcemanager.googleapis.com/Project//cloudresourcemanager.googleapis.com/projects/test-project"
 	assert.Contains(t, c.assets, caiKeyProject)
+
+	assert.Contains(t, buf.String(), "duplicate asset")
 }
 
 func TestAddStorageModuleAfterUnknown(t *testing.T) {
@@ -433,7 +528,7 @@ func TestAddStorageModuleAfterUnknown(t *testing.T) {
 			},
 		},
 	}
-	c, err := newTestConverter(false)
+	c, buf, err := newTestConverter(false)
 	assert.Nil(t, err)
 
 	err = c.AddResourceChanges([]*tfjson.ResourceChange{&rc})
@@ -443,6 +538,7 @@ func TestAddStorageModuleAfterUnknown(t *testing.T) {
 		assert.EqualValues(t, c.assets[key].Type, "storage.googleapis.com/Bucket")
 	}
 
+	assert.Equal(t, "", buf.String())
 }
 
 func TestTimestampMarshalJSON(t *testing.T) {
diff --git a/testdata/generatedconvert/CloudRunDomainMapping_cloudRunDomainMappingBasicExample_offline.json b/testdata/generatedconvert/CloudRunDomainMapping_cloudRunDomainMappingBasicExample_offline.json
new file mode 100644
index 00000000..d884adf5
--- /dev/null
+++ b/testdata/generatedconvert/CloudRunDomainMapping_cloudRunDomainMappingBasicExample_offline.json
@@ -0,0 +1,55 @@
+[
+  {
+    "name": "//run.googleapis.com/projects/foobar/locations/us-central1/DomainMappings/tf-test-domainmeepmerp.gcp.tfacc.hashicorptest.com",
+    "asset_type": "run.googleapis.com/DomainMapping",
+    "ancestry_path": "organization/12345/folder/67890/project/foobar",
+    "resource": {
+      "version": "v1",
+      "discovery_document_uri": "https://www.googleapis.com/discovery/v1/apis/run/v1/rest",
+      "discovery_name": "DomainMapping",
+      "parent": "//cloudresourcemanager.googleapis.com/projects/foobar",
+      "data": {
+        "apiVersion": "domains.cloudrun.com/v1",
+        "kind": "DomainMapping",
+        "metadata": {
+          "name": "tf-test-domainmeepmerp.gcp.tfacc.hashicorptest.com",
+          "namespace": "foobar"
+        },
+        "spec": {
+          "certificateMode": "AUTOMATIC",
+          "routeName": "tf-test-cloudrun-srvmeepmerp"
+        }
+      }
+    }
+  },
+  {
+    "name": "//run.googleapis.com/projects/foobar/locations/us-central1/services/tf-test-cloudrun-srvmeepmerp",
+    "asset_type": "run.googleapis.com/Service",
+    "ancestry_path": "organization/12345/folder/67890/project/foobar",
+    "resource": {
+      "version": "v1",
+      "discovery_document_uri": "https://www.googleapis.com/discovery/v1/apis/run/v1/rest",
+      "discovery_name": "Service",
+      "parent": "//cloudresourcemanager.googleapis.com/projects/foobar",
+      "data": {
+        "apiVersion": "serving.knative.dev/v1",
+        "kind": "Service",
+        "metadata": {
+          "name": "tf-test-cloudrun-srvmeepmerp",
+          "namespace": "foobar"
+        },
+        "spec": {
+          "template": {
+            "spec": {
+              "containers": [
+                {
+                  "image": "us-docker.pkg.dev/cloudrun/container/hello"
+                }
+              ]
+            }
+          }
+        }
+      }
+    }
+  }
+]
diff --git a/testdata/generatedconvert/CloudRunService_cloudRunServiceBasicExample_offline.json b/testdata/generatedconvert/CloudRunService_cloudRunServiceBasicExample_offline.json
new file mode 100644
index 00000000..df78f812
--- /dev/null
+++ b/testdata/generatedconvert/CloudRunService_cloudRunServiceBasicExample_offline.json
@@ -0,0 +1,37 @@
+[
+  {
+    "name": "//run.googleapis.com/projects/foobar/locations/us-central1/services/tf-test-cloudrun-srvmeepmerp",
+    "asset_type": "run.googleapis.com/Service",
+    "ancestry_path": "organization/12345/folder/67890/project/foobar",
+    "resource": {
+      "version": "v1",
+      "discovery_document_uri": "https://www.googleapis.com/discovery/v1/apis/run/v1/rest",
+      "discovery_name": "Service",
+      "parent": "//cloudresourcemanager.googleapis.com/projects/foobar",
+      "data": {
+        "apiVersion": "serving.knative.dev/v1",
+        "kind": "Service",
+        "metadata": {
+          "name": "tf-test-cloudrun-srvmeepmerp"
+        },
+        "spec": {
+          "template": {
+            "spec": {
+              "containers": [
+                {
+                  "image": "us-docker.pkg.dev/cloudrun/container/hello"
+                }
+              ]
+            }
+          },
+          "traffic": [
+            {
+              "latestRevision": true,
+              "percent": 100
+            }
+          ]
+        }
+      }
+    }
+  }
+]
diff --git a/testdata/generatedconvert/CloudRunService_cloudRunServiceMultipleEnvironmentVariablesExample_offline.json b/testdata/generatedconvert/CloudRunService_cloudRunServiceMultipleEnvironmentVariablesExample_offline.json
new file mode 100644
index 00000000..69a4d5b3
--- /dev/null
+++ b/testdata/generatedconvert/CloudRunService_cloudRunServiceMultipleEnvironmentVariablesExample_offline.json
@@ -0,0 +1,50 @@
+[
+  {
+    "name": "//run.googleapis.com/projects/foobar/locations/us-central1/services/tf-test-cloudrun-srvmeepmerp",
+    "asset_type": "run.googleapis.com/Service",
+    "ancestry_path": "organization/12345/folder/67890/project/foobar",
+    "resource": {
+      "version": "v1",
+      "discovery_document_uri": "https://www.googleapis.com/discovery/v1/apis/run/v1/rest",
+      "discovery_name": "Service",
+      "parent": "//cloudresourcemanager.googleapis.com/projects/foobar",
+      "data": {
+        "apiVersion": "serving.knative.dev/v1",
+        "kind": "Service",
+        "metadata": {
+          "annotations": {
+            "generated-by": "magic-modules"
+          },
+          "name": "tf-test-cloudrun-srvmeepmerp"
+        },
+        "spec": {
+          "template": {
+            "spec": {
+              "containers": [
+                {
+                  "env": [
+                    {
+                      "name": "SOURCE",
+                      "value": "remote"
+                    },
+                    {
+                      "name": "TARGET",
+                      "value": "home"
+                    }
+                  ],
+                  "image": "us-docker.pkg.dev/cloudrun/container/hello"
+                }
+              ]
+            }
+          },
+          "traffic": [
+            {
+              "latestRevision": true,
+              "percent": 100
+            }
+          ]
+        }
+      }
+    }
+  }
+]
diff --git a/testdata/generatedconvert/CloudRunService_cloudRunServiceSqlExample_offline.json b/testdata/generatedconvert/CloudRunService_cloudRunServiceSqlExample_offline.json
new file mode 100644
index 00000000..6120dcdc
--- /dev/null
+++ b/testdata/generatedconvert/CloudRunService_cloudRunServiceSqlExample_offline.json
@@ -0,0 +1,56 @@
+[
+  {
+    "name": "//cloudsql.googleapis.com/projects/foobar/instances/tf-test-cloudrun-sqlmeepmerp",
+    "asset_type": "sqladmin.googleapis.com/Instance",
+    "ancestry_path": "organization/12345/folder/67890/project/foobar",
+    "resource": {
+      "version": "v1beta4",
+      "discovery_document_uri": "https://www.googleapis.com/discovery/v1/apis/sqladmin/v1beta4/rest",
+      "discovery_name": "DatabaseInstance",
+      "parent": "//cloudresourcemanager.googleapis.com/projects/foobar",
+      "data": {
+        "databaseVersion": "MYSQL_5_7",
+        "name": "tf-test-cloudrun-sqlmeepmerp",
+        "project": "foobar",
+        "region": "us-east1",
+        "settings": {
+          "activationPolicy": "ALWAYS",
+          "availabilityType": "ZONAL",
+          "dataDiskType": "PD_SSD",
+          "pricingPlan": "PER_USE",
+          "storageAutoResize": true,
+          "tier": "db-f1-micro"
+        }
+      }
+    }
+  },
+  {
+    "name": "//run.googleapis.com/projects/foobar/locations/us-central1/services/tf-test-cloudrun-srvmeepmerp",
+    "asset_type": "run.googleapis.com/Service",
+    "ancestry_path": "organization/12345/folder/67890/project/foobar",
+    "resource": {
+      "version": "v1",
+      "discovery_document_uri": "https://www.googleapis.com/discovery/v1/apis/run/v1/rest",
+      "discovery_name": "Service",
+      "parent": "//cloudresourcemanager.googleapis.com/projects/foobar",
+      "data": {
+        "apiVersion": "serving.knative.dev/v1",
+        "kind": "Service",
+        "metadata": {
+          "name": "tf-test-cloudrun-srvmeepmerp"
+        },
+        "spec": {
+          "template": {
+            "spec": {
+              "containers": [
+                {
+                  "image": "us-docker.pkg.dev/cloudrun/container/hello"
+                }
+              ]
+            }
+          }
+        }
+      }
+    }
+  }
+]