Repository: pulumi/pulumi-terraform-bridge
Pull request #376 (2021-08-17 05:56:51)

tfbridge: handle unidirectional conflicts
Some Terraform providers specify ConflictsWith in one direction but not
the other (e.g., pulumi/pulumi-snowflake#11). Teach applyDefaults to
handle this situation too to avoid spurious conflict warnings.

Arguably, specifying ConflictsWith in one direction but not the other is
a bug in the provider schema, but at the very least it's a mistake
that's easy to make. Since Terraform seems to handle it just fine, it
seems worth handling in Pulumi too.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 546b3d25..c2d0627f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@ CHANGELOG
 
 ## HEAD (Unreleased)
 
+- Avoid setting defaults on conflicting fields when the schema specifies
+  `ConflictsWith` in one direction but not the other.
+  [#376](https://github.com/pulumi/pulumi-terraform-bridge/pull/376)
+
 - Add flags to skip docs and examples processing.
   [#365](https://github.com/pulumi/pulumi-terraform-bridge/pull/365)
 
diff --git a/pkg/tfbridge/schema.go b/pkg/tfbridge/schema.go
index 12a70412..1c58b9e1 100644
--- a/pkg/tfbridge/schema.go
+++ b/pkg/tfbridge/schema.go
@@ -355,9 +355,20 @@ func (ctx *conversionContext) applyDefaults(result map[string]interface{}, olds,
 	if tfs != nil {
 		tfs.Range(func(name string, sch shim.Schema) bool {
 			if _, has := result[name]; has {
+				// `name` is present, so mark any names that are declared to
+				// conflict with `name` for exclusion.
 				for _, conflictingName := range sch.ConflictsWith() {
 					conflictsWith[conflictingName] = struct{}{}
 				}
+			} else {
+				// `name` is not present, so mark it for exclusion if any fields
+				// that conflict with `name` are present.
+				for _, conflictingName := range sch.ConflictsWith() {
+					if _, has := result[conflictingName]; has {
+						conflictsWith[name] = struct{}{}
+						break
+					}
+				}
 			}
 			return true
 		})
diff --git a/pkg/tfgen/docs.go b/pkg/tfgen/docs.go
index b463a527..07d83982 100644
--- a/pkg/tfgen/docs.go
+++ b/pkg/tfgen/docs.go
@@ -1005,6 +1005,7 @@ func (g *Generator) convertExamples(docs, name string, stripSubsectionsWithError
 						hcl := strings.Join(subsection[codeBlockStart+1:i], "\n")
 
 						// We've got some code -- assume it's HCL and try to convert it.
+						g.coverageTracker.foundExample(name, hcl)
 						codeBlock, stderr, err := g.convertHCL(hcl, name)
 						if err != nil {
 							skippedExamples = true
@@ -1103,6 +1104,7 @@ func (g *Generator) convertHCL(hcl, path string) (string, string, error) {
 			if v != nil {
 				err = fmt.Errorf("panic to convert HCL for %s to %v: %v", path, languageName, v)
 				g.debug(fmt.Sprintf("panic converting HCL for %s to %v: %v", path, languageName, v))
+				g.coverageTracker.languageConversionPanic(languageName, fmt.Sprintf("%v", v))
 			}
 		}()
 
@@ -1125,6 +1127,7 @@ func (g *Generator) convertHCL(hcl, path string) (string, string, error) {
 			TerraformVersion:         g.terraformVersion,
 		})
 		if err != nil {
+			g.coverageTracker.languageConversionPanic(languageName, err.Error())
 			return fmt.Errorf("failed to convert HCL for %s to %v: %w", path, languageName, err)
 		}
 		if diags.All.HasErrors() {
@@ -1141,6 +1144,7 @@ func (g *Generator) convertHCL(hcl, path string) (string, string, error) {
 			err = diags.NewDiagnosticWriter(&stderr, 0, false).WriteDiagnostics(diags.All)
 			contract.IgnoreError(err)
 
+			g.coverageTracker.languageConversionFailure(languageName, diags.All)
 			// Note that we intentionally avoid returning an error here. The caller will check for an empty code block
 			// before returning and translate that into an error.
 			return nil
@@ -1157,6 +1161,7 @@ func (g *Generator) convertHCL(hcl, path string) (string, string, error) {
 			contract.IgnoreError(err)
 		}
 
+		g.coverageTracker.languageConversionSuccess(languageName)
 		return nil
 	}
 
diff --git a/pkg/tfgen/examples_coverage_exporter.go b/pkg/tfgen/examples_coverage_exporter.go
new file mode 100644
index 00000000..6d504d59
--- /dev/null
+++ b/pkg/tfgen/examples_coverage_exporter.go
@@ -0,0 +1,218 @@
+// Copyright 2016-2021, Pulumi Corporation.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This file implements the methods used by the Coverage Tracker in order
+// to export the data it collected into various JSON formats.
+
+package tfgen
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"sort"
+)
+
+// The export utility's main structure, where it stores the desired output directory
+// and a reference to the CoverageTracker that created it
+type coverageExportUtil struct {
+	Tracker *CoverageTracker // Reference to the Coverage Tracker that wants to turn its data into a file
+}
+
+func newCoverageExportUtil(coverageTracker *CoverageTracker) coverageExportUtil {
+	return coverageExportUtil{coverageTracker}
+}
+
+// The entire export utility interface. Will attempt to export the Coverage Tracker's data into the
+// specified output directory, and will panic if an error is encountered along the way
+func (ce *coverageExportUtil) tryExport(outputDirectory string) error {
+	var err = ce.exportUploadableResults(outputDirectory, "summary.json")
+	if err != nil {
+		return err
+	}
+
+	return ce.exportSummarizedResults(outputDirectory, "concise.json")
+}
+
+// Three different ways to export coverage data:
+// The first mode, using a large provider > example map
+func (ce *coverageExportUtil) exportFullResults(outputDirectory string, fileName string) error {
+
+	// The Coverage Tracker data structure remains identical, the only thing added in the file is the name of the provider
+	providerNameToExamplesMap := map[string]map[string]*GeneralExampleInfo{ce.Tracker.ProviderName: ce.Tracker.EncounteredExamples}
+
+	jsonOutputLocation, err := createJsonOutputLocation(outputDirectory, fileName)
+	if err != nil {
+		return err
+	}
+
+	return marshalAndWriteJson(providerNameToExamplesMap, jsonOutputLocation)
+}
+
+// The second mode, similar to existing Pulumi coverage Json files uploadable to redshift
+func (ce *coverageExportUtil) exportUploadableResults(outputDirectory string, fileName string) error {
+
+	// The Coverage Tracker data structure is flattened down to the example level, and they all
+	// get individually written to the file in order to not have the "{ }" brackets at the start and end
+	type SingleExampleResult struct {
+		ProviderName    string
+		ProviderVersion string
+		ExampleName     string
+		_originalHCL    string
+		IsDuplicated    bool
+		FailedLanguages []LanguageConversionResult
+	}
+
+	jsonOutputLocation, err := createJsonOutputLocation(outputDirectory, fileName)
+	if err != nil {
+		return err
+	}
+
+	// All the examples in the map are iterated by key and marshalled into one large byte array
+	// separated by \n, making the end result look like a bunch of Json files that got concatenated
+	var result []byte
+	for _, exampleInMap := range ce.Tracker.EncounteredExamples {
+		singleExample := SingleExampleResult{
+			ProviderName:    ce.Tracker.ProviderName,
+			ProviderVersion: ce.Tracker.ProviderVersion,
+			ExampleName:     exampleInMap.Name,
+			_originalHCL:    "",
+			FailedLanguages: []LanguageConversionResult{},
+		}
+
+		// The current example's language conversion results are iterated over. If the severity is
+		// anything but zero, then it means some sort of error occured during conversion and
+		// should be logged for future analysis.
+		for _, conversionResult := range exampleInMap.LanguagesConvertedTo {
+			if conversionResult.FailureSeverity != 0 {
+				singleExample._originalHCL = exampleInMap.OriginalHCL
+				singleExample.FailedLanguages = append(singleExample.FailedLanguages, *conversionResult)
+			}
+			singleExample.IsDuplicated = singleExample.IsDuplicated || conversionResult.MultipleTranslations
+		}
+		marshalledExample, err := json.MarshalIndent(singleExample, "", "\t")
+		if err != nil {
+			return err
+		}
+		result = append(append(result, marshalledExample...), uint8('\n'))
+	}
+	return ioutil.WriteFile(jsonOutputLocation, result, 0600)
+}
+
+// The third mode, meant for exporting broad information such as total number of examples,
+// and what percentage of the total each failure severity makes up
+func (ce *coverageExportUtil) exportSummarizedResults(outputDirectory string, fileName string) error {
+
+	// The Coverage Tracker data structure is used to gather general statistics about the examples
+	type NumPct struct {
+		Number int
+		Pct    float64
+	}
+
+	type ErrorMessage struct {
+		Reason string
+		Count  int
+	}
+
+	type LanguageStatistic struct {
+		Successes       NumPct
+		Warnings        NumPct
+		Failures        NumPct
+		Fatals          NumPct
+		Total           int
+		_errorHistogram map[string]int
+		FrequentErrors  []ErrorMessage
+	}
+
+	// Main map for holding all the language conversion statistics
+	var allLanguageStatistics = make(map[string]*LanguageStatistic)
+
+	// All the conversion attempts for each example are iterated by language name and
+	// their results are added to the main map
+	for _, exampleInMap := range ce.Tracker.EncounteredExamples {
+		for _, conversionResult := range exampleInMap.LanguagesConvertedTo {
+			var language *LanguageStatistic
+			if val, ok := allLanguageStatistics[conversionResult.TargetLanguage]; ok {
+
+				// The main map already contains the language entry
+				language = val
+			} else {
+
+				// The main map doesn't yet contain this language, and it needs to be added
+				allLanguageStatistics[conversionResult.TargetLanguage] = &LanguageStatistic{NumPct{0, 0.0}, NumPct{0, 0.0}, NumPct{0, 0.0}, NumPct{0, 0.0}, 0, make(map[string]int), []ErrorMessage{}}
+				language = allLanguageStatistics[conversionResult.TargetLanguage]
+			}
+
+			// The language's entry in the summarized results is updated and any
+			// error messages are saved
+			language.Total += 1
+			if conversionResult.FailureSeverity == 0 {
+				language.Successes.Number += 1
+			} else {
+
+				// A failure occured during conversion so we take the failure info
+				// and add it to the histogram
+				language._errorHistogram[conversionResult.FailureInfo] += 1
+
+				switch conversionResult.FailureSeverity {
+				case Warning:
+					language.Warnings.Number++
+				case Failure:
+					language.Failures.Number++
+				default:
+					language.Fatals.Number++
+				}
+			}
+		}
+	}
+
+	for _, language := range allLanguageStatistics {
+
+		// Calculating error percentages for all languages that were found
+		language.Successes.Pct = float64(language.Successes.Number) / float64(language.Total) * 100.0
+		language.Warnings.Pct = float64(language.Warnings.Number) / float64(language.Total) * 100.0
+		language.Failures.Pct = float64(language.Failures.Number) / float64(language.Total) * 100.0
+		language.Fatals.Pct = float64(language.Fatals.Number) / float64(language.Total) * 100.0
+
+		// Appending and sorting conversion errors by their frequency
+		for reason, count := range language._errorHistogram {
+			language.FrequentErrors = append(language.FrequentErrors, ErrorMessage{reason, count})
+		}
+		sort.Slice(language.FrequentErrors, func(index1, index2 int) bool {
+			return language.FrequentErrors[index1].Count > language.FrequentErrors[index2].Count
+		})
+	}
+
+	jsonOutputLocation, err := createJsonOutputLocation(outputDirectory, fileName)
+	if err != nil {
+		return err
+	}
+	return marshalAndWriteJson(allLanguageStatistics, jsonOutputLocation)
+}
+
+// Minor helper functions to assist with exporting results
+func createJsonOutputLocation(outputDirectory string, fileName string) (string, error) {
+	jsonOutputLocation := filepath.Join(outputDirectory, fileName)
+	err := os.MkdirAll(outputDirectory, 0700)
+	return jsonOutputLocation, err
+}
+
+func marshalAndWriteJson(unmarshalledData interface{}, finalDestination string) error {
+	jsonBytes, err := json.MarshalIndent(unmarshalledData, "", "\t")
+	if err != nil {
+		return err
+	}
+	return ioutil.WriteFile(finalDestination, jsonBytes, 0600)
+}
diff --git a/pkg/tfgen/examples_coverage_tracker.go b/pkg/tfgen/examples_coverage_tracker.go
new file mode 100644
index 00000000..4c4b1585
--- /dev/null
+++ b/pkg/tfgen/examples_coverage_tracker.go
@@ -0,0 +1,170 @@
+// Copyright 2016-2021, Pulumi Corporation.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This file implements a system for collecting data on how many HCL examples were
+// attempted to be converted to Pulumi, and what percentage of such conversions
+// succeeded. Additionally, it allows these diagnostics to be exported in JSON
+// format for uploading and further processing.
+
+package tfgen
+
+import (
+	"fmt"
+
+	"github.com/hashicorp/hcl/v2"
+)
+
+// Main overarching structure for storing coverage data on how many examples were processed,
+// how many failed, and for what reason. At different stages, the code translator notifies
+// the tracker of what is going on. Notifications are treated as an ordered stream of events.
+// INTERFACE:
+// foundExample(), languageConversionSuccess(), languageConversionFailure(), languageConversionPanic().
+type CoverageTracker struct {
+	ProviderName        string                         // Name of the provider
+	ProviderVersion     string                         // Version of the provider
+	CurrentExampleName  string                         // Name of current example that is being processed
+	EncounteredExamples map[string]*GeneralExampleInfo // Mapping example names to their general information
+}
+
+// General information about an example, and how successful it was at being converted to different languages
+type GeneralExampleInfo struct {
+	Name                   string
+	OriginalHCL            string
+	LanguagesConvertedTo   map[string]*LanguageConversionResult // Mapping language names to their conversion diagnostics
+	NameFoundMultipleTimes bool                                 // Current name has already been encountered before
+}
+
+// Individual language information concerning how successfully an example was converted to Pulumi
+type LanguageConversionResult struct {
+	TargetLanguage       string
+	FailureSeverity      int    // [Success, Warning, Failure, Fatal]
+	FailureInfo          string // Additional in-depth information
+	MultipleTranslations bool   // !! Current example name has already been converted for this specific language before. Either the example is duplicated, or a bug is present !!
+}
+
+// Failure severity values
+const (
+	Success = 0
+	Warning = 1
+	Failure = 2
+	Fatal   = 3
+)
+
+func newCoverageTracker(ProviderName string, ProviderVersion string) *CoverageTracker {
+	return &CoverageTracker{ProviderName, ProviderVersion, "", make(map[string]*GeneralExampleInfo)}
+}
+
+// Used when: generator has found a new example with a convertible block of HCL
+func (ct *CoverageTracker) foundExample(exampleName string, hcl string) {
+	if ct == nil {
+		return
+	}
+	ct.CurrentExampleName = exampleName
+	if val, ok := ct.EncounteredExamples[exampleName]; ok {
+		val.NameFoundMultipleTimes = true
+	} else {
+		ct.EncounteredExamples[exampleName] = &GeneralExampleInfo{exampleName, hcl, make(map[string]*LanguageConversionResult), false}
+	}
+}
+
+// Used when: current example has been successfully converted to a certain language
+func (ct *CoverageTracker) languageConversionSuccess(targetLanguage string) {
+	if ct == nil {
+		return
+	}
+	ct.insertLanguageConversionResult(LanguageConversionResult{
+		TargetLanguage:       targetLanguage,
+		FailureSeverity:      0,
+		FailureInfo:          "",
+		MultipleTranslations: false,
+	})
+}
+
+// Used when: generator has successfully converted current example, but threw out some warnings
+func (ct *CoverageTracker) languageConversionWarning(targetLanguage string, warningDiagnostics hcl.Diagnostics) {
+	if ct == nil {
+		return
+	}
+	ct.insertLanguageConversionResult(LanguageConversionResult{
+		TargetLanguage:       targetLanguage,
+		FailureSeverity:      1,
+		FailureInfo:          GetSummaries(warningDiagnostics),
+		MultipleTranslations: false,
+	})
+}
+
+// Used when: generator has failed to convert the current example to a certain language
+func (ct *CoverageTracker) languageConversionFailure(targetLanguage string, failureDiagnostics hcl.Diagnostics) {
+	if ct == nil {
+		return
+	}
+	ct.insertLanguageConversionResult(LanguageConversionResult{
+		TargetLanguage:       targetLanguage,
+		FailureSeverity:      2,
+		FailureInfo:          GetSummaries(failureDiagnostics),
+		MultipleTranslations: false,
+	})
+}
+
+// Used when: generator encountered a fatal internal error when trying to convert the current example to a certain language
+func (ct *CoverageTracker) languageConversionPanic(targetLanguage string, panicInfo string) {
+	if ct == nil {
+		return
+	}
+	ct.insertLanguageConversionResult(LanguageConversionResult{
+		TargetLanguage:       targetLanguage,
+		FailureSeverity:      3,
+		FailureInfo:          panicInfo,
+		MultipleTranslations: false,
+	})
+}
+
+// Adding a language conversion result to the current example. If a conversion result with the same
+// target language already exists, keep the lowest severity one and mark the example as possibly duplicated
+func (ct *CoverageTracker) insertLanguageConversionResult(conversionResult LanguageConversionResult) {
+	if currentExample, ok := ct.EncounteredExamples[ct.CurrentExampleName]; ok {
+		if existingConversionResult, ok := currentExample.LanguagesConvertedTo[conversionResult.TargetLanguage]; ok {
+
+			// If incoming result is of a lower severity, keep it instead of the existing one
+			if conversionResult.FailureSeverity < existingConversionResult.FailureSeverity {
+				currentExample.LanguagesConvertedTo[conversionResult.TargetLanguage] = &conversionResult
+			}
+			existingConversionResult.MultipleTranslations = true
+		} else {
+
+			// A brand new language conversion result is being added for this example
+			currentExample.LanguagesConvertedTo[conversionResult.TargetLanguage] = &conversionResult
+		}
+	} else {
+		fmt.Println("Error: attempted to log the result of a language conversion without first finding an example")
+		fmt.Println(conversionResult)
+		panic("")
+	}
+}
+
+// Turning the hcl.Diagnostics provided during warnings or failures into a brief explanation
+// of why the converter didn't succeed
+func GetSummaries(diagnostics hcl.Diagnostics) string {
+	result := diagnostics[0].Summary
+	for i := 1; i < len(diagnostics); i++ {
+		result += "; " + diagnostics[i].Summary
+	}
+	return result
+}
+
+// Exporting the coverage results
+func (ct *CoverageTracker) exportResults(outputDirectory string) error {
+	coverageExportUtil := newCoverageExportUtil(ct)
+	return (coverageExportUtil.tryExport(outputDirectory))
+}
diff --git a/pkg/tfgen/generate.go b/pkg/tfgen/generate.go
index 567de178..f4182f99 100644
--- a/pkg/tfgen/generate.go
+++ b/pkg/tfgen/generate.go
@@ -68,6 +68,7 @@ type Generator struct {
 	printStats       bool
 	skipDocs         bool
 	skipExamples     bool
+	coverageTracker  *CoverageTracker
 }
 
 type Language string
@@ -614,6 +615,7 @@ type GeneratorOptions struct {
 	Debug              bool
 	SkipDocs           bool
 	SkipExamples       bool
+	CoverageTracker    *CoverageTracker
 }
 
 // NewGenerator returns a code-generator for the given language runtime and package info.
@@ -693,6 +695,7 @@ func NewGenerator(opts GeneratorOptions) (*Generator, error) {
 		printStats:       opts.Debug,
 		skipDocs:         opts.SkipDocs,
 		skipExamples:     opts.SkipExamples,
+		coverageTracker:  opts.CoverageTracker,
 	}, nil
 }
 
diff --git a/pkg/tfgen/main.go b/pkg/tfgen/main.go
index c51ec3c6..0f5b0373 100644
--- a/pkg/tfgen/main.go
+++ b/pkg/tfgen/main.go
@@ -116,16 +116,25 @@ func newTFGenCmd(pkg string, version string, prov tfbridge.ProviderInfo) *cobra.
 				root = afero.NewBasePathFs(afero.NewOsFs(), absOutDir)
 			}
 
+			// Creating an item to keep track of example coverage if the
+			// COVERAGE_OUTPUT_DIR env is set
+			var coverageTracker *CoverageTracker
+			coverageOutputDir, coverageTrackingEnabled := os.LookupEnv("COVERAGE_OUTPUT_DIR")
+			if coverageTrackingEnabled {
+				coverageTracker = newCoverageTracker(prov.Name, prov.Version)
+			}
+
 			// Create a generator with the specified settings.
 			g, err := NewGenerator(GeneratorOptions{
-				Package:      pkg,
-				Version:      version,
-				Language:     Language(args[0]),
-				ProviderInfo: prov,
-				Root:         root,
-				Debug:        debug,
-				SkipDocs:     skipDocs,
-				SkipExamples: skipExamples,
+				Package:         pkg,
+				Version:         version,
+				Language:        Language(args[0]),
+				ProviderInfo:    prov,
+				Root:            root,
+				Debug:           debug,
+				SkipDocs:        skipDocs,
+				SkipExamples:    skipExamples,
+				CoverageTracker: coverageTracker,
 			})
 			if err != nil {
 				return err
@@ -137,7 +146,12 @@ func newTFGenCmd(pkg string, version string, prov tfbridge.ProviderInfo) *cobra.
 				return err
 			}
 
-			return nil
+			// Exporting collected coverage data to the directory specified by COVERAGE_OUTPUT_DIR
+			if coverageTrackingEnabled {
+				err = coverageTracker.exportResults(coverageOutputDir)
+			}
+
+			return err
 		}),
 		PersistentPostRun: func(cmd *cobra.Command, args []string) {
 			glog.Flush()


Test patch:
diff --git a/internal/testprovider/schema.go b/internal/testprovider/schema.go
index 1f366d3f..06263e2a 100644
--- a/internal/testprovider/schema.go
+++ b/internal/testprovider/schema.go
@@ -521,6 +521,17 @@ func ProviderV2() *schemav2.Provider {
 						Optional:      true,
 						ConflictsWith: []string{"conflicting_property"},
 					},
+					// Conflicts with conflicting_property, but not vice-versa,
+					// and has a default. The Terraform docs don't say whether
+					// specifying a `ConflictsWith` in one direction but not the
+					// other is permissible, but providers do it in practice:
+					// https://github.com/pulumi/pulumi-snowflake/issues/11
+					"conflicting_property_unidirectional": {
+						Type:          schemav2.TypeBool,
+						Optional:      true,
+						Default:       false,
+						ConflictsWith: []string{"conflicting_property"},
+					},
 				},
 				SchemaVersion: 1,
 				MigrateState: func(v int, is *terraformv2.InstanceState, p interface{}) (*terraformv2.InstanceState, error) {
@@ -547,6 +558,7 @@ func ProviderV2() *schemav2.Provider {
 					})
 					MustSetIfUnset(data, "set_property_value", []interface{}{"set member 1", "set member 2"})
 					MustSetIfUnset(data, "string_with_bad_interpolation", "some ${interpolated:value} with syntax errors")
+					MustSetIfUnset(data, "conflicting_property_unidirectional", false)
 					return nil
 				},
 				Read: func(data *schemav2.ResourceData, p interface{}) error {
@@ -569,6 +581,7 @@ func ProviderV2() *schemav2.Provider {
 					})
 					MustSetIfUnset(data, "set_property_value", []interface{}{"set member 1", "set member 2"})
 					MustSetIfUnset(data, "string_with_bad_interpolation", "some ${interpolated:value} with syntax errors")
+					MustSetIfUnset(data, "conflicting_property_unidirectional", false)
 					return nil
 				},
 				Update: func(data *schemav2.ResourceData, p interface{}) error {
@@ -591,6 +604,7 @@ func ProviderV2() *schemav2.Provider {
 					})
 					MustSetIfUnset(data, "set_property_value", []interface{}{"set member 1", "set member 2"})
 					MustSetIfUnset(data, "string_with_bad_interpolation", "some ${interpolated:value} with syntax errors")
+					MustSetIfUnset(data, "conflicting_property_unidirectional", false)
 					return nil
 				},
 				Delete: func(data *schemav2.ResourceData, p interface{}) error {
diff --git a/pkg/tfshim/tfplugin5/provider_test.go b/pkg/tfshim/tfplugin5/provider_test.go
index 14e45079..19129dd8 100644
--- a/pkg/tfshim/tfplugin5/provider_test.go
+++ b/pkg/tfshim/tfplugin5/provider_test.go
@@ -375,10 +375,11 @@ func TestProviderResourcesMap(t *testing.T) {
 				"nested_resources": cty.List(cty.Object(map[string]cty.Type{
 					"configuration": cty.Map(cty.String),
 				})),
-				"set_property_value":            cty.Set(cty.String),
-				"string_with_bad_interpolation": cty.String,
-				"conflicting_property":          cty.String,
-				"conflicting_property2":         cty.String,
+				"set_property_value":                  cty.Set(cty.String),
+				"string_with_bad_interpolation":       cty.String,
+				"conflicting_property":                cty.String,
+				"conflicting_property2":               cty.String,
+				"conflicting_property_unidirectional": cty.Bool,
 			}),
 			schema: schema.SchemaMap{
 				"id": &attributeSchema{
@@ -507,6 +508,11 @@ func TestProviderResourcesMap(t *testing.T) {
 					valueType: shim.TypeString,
 					optional:  true,
 				},
+				"conflicting_property_unidirectional": &attributeSchema{
+					ctyType:   cty.Bool,
+					valueType: shim.TypeBool,
+					optional:  true,
+				},
 			},
 		},
 	}