diff --git a/provider/provider_yaml_test.go b/provider/provider_yaml_test.go index 571fec4aa22..df3c57235ff 100644 --- a/provider/provider_yaml_test.go +++ b/provider/provider_yaml_test.go @@ -16,14 +16,19 @@ import ( "runtime" "strings" "testing" + "time" "github.com/aws/aws-sdk-go-v2/config" s3sdk "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/pulumi/providertest/pulumitest" "github.com/pulumi/providertest/pulumitest/assertpreview" "github.com/pulumi/providertest/pulumitest/opttest" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optpreview" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optup" "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -382,6 +387,119 @@ resources: }) } +// TestDefaultTagsImport tests the scenario where `tagsAll` and `tags` both +// exist and the user is importing a resource. +func TestDefaultTagsImport(t *testing.T) { + bucketName := fmt.Sprintf("mybucket-%d", time.Now().UnixNano()) + file1 := ` +name: default-tags +runtime: yaml +resources: + awsProvider: + type: pulumi:providers:aws + properties: + defaultTags: + tags: + Project: x + Environment: dev + myBucket: + type: aws:s3:BucketV2 + properties: + bucket: %s + tags: + CreatedBy: pulumi-aws + options: + provider: ${awsProvider} + retainOnDelete: %s +%s +outputs: + bucketName: ${myBucket.id} +` + workdir := t.TempDir() + cwd, err := os.Getwd() + assert.NoError(t, err) + + first := fmt.Sprintf(file1, bucketName, "true", "") + err = os.WriteFile(filepath.Join(workdir, "Pulumi.yaml"), []byte(first), 0o600) + require.NoError(t, err) + + ptest := pulumitest.NewPulumiTest(t, workdir, + opttest.SkipInstall(), + opttest.LocalProviderPath("aws", filepath.Join(cwd, "..", "bin")), + opttest.TestInPlace(), + ) + ptest.Up() + tags := getBucketTagging(ptest.Context(), bucketName) + assert.Subset(t, tags, []types.Tag{ + { + Key: pulumi.StringRef("Project"), + Value: pulumi.StringRef("x"), + }, + { + Key: pulumi.StringRef("Environment"), + Value: pulumi.StringRef("dev"), + }, + { + Key: pulumi.StringRef("CreatedBy"), + Value: pulumi.StringRef("pulumi-aws"), + }, + }) + + // destroy with retainOnDelete: true + ptest.Destroy() + + err = os.WriteFile( + filepath.Join(workdir, "Pulumi.yaml"), + []byte(fmt.Sprintf(file1, bucketName, "false", fmt.Sprintf(" import: %s", bucketName))), + 0o600, + ) + require.NoError(t, err) + + // up with an import + importResult := ptest.Up(optup.Diff()) + + changes := *importResult.Summary.ResourceChanges + assert.Equal(t, 1, changes["import"]) + assert.Equal(t, importResult.Summary.Result, "succeeded") +} + +func TestRegress4080(t *testing.T) { + file1 := ` +name: test-aws-1655-pf +runtime: yaml +description: | + Initial deployment without tags +resources: + app: + type: aws:appconfig:Application + aws-provider: + type: pulumi:providers:aws + res: + options: + provider: ${aws-provider} + properties: + applicationId: ${app.id} + type: aws:appconfig:Environment +` + workdir := t.TempDir() + cwd, err := os.Getwd() + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(workdir, "Pulumi.yaml"), []byte(file1), 0o600) + require.NoError(t, err) + + ptest := pulumitest.NewPulumiTest(t, workdir, + opttest.SkipInstall(), + opttest.LocalProviderPath("aws", filepath.Join(cwd, "..", "bin")), + opttest.TestInPlace(), + ) + ptest.Up() + + res := ptest.Preview(optpreview.Refresh(), optpreview.Diff()) + fmt.Printf("stdout: %s", res.StdOut) + fmt.Printf("stderr: %s", res.StdErr) +} + // Make sure that legacy Bucket supports deleting tags out of band and detecting drift. func TestRegress3674(t *testing.T) { ptest := pulumiTest(t, filepath.Join("test-programs", "regress-3674"), opttest.SkipInstall()) @@ -472,6 +590,15 @@ func configureS3() *s3sdk.Client { return s3sdk.NewFromConfig(cfg) } +func getBucketTagging(ctx context.Context, awsBucket string) []types.Tag { + s3 := configureS3() + tagging, err := s3.GetBucketTagging(ctx, &s3sdk.GetBucketTaggingInput{ + Bucket: &awsBucket, + }) + contract.AssertNoErrorf(err, "failed to get bucket tagging") + return tagging.TagSet +} + func deleteBucketTagging(ctx context.Context, awsBucket string) { s3 := configureS3() _, err := s3.DeleteBucketTagging(ctx, &s3sdk.DeleteBucketTaggingInput{ diff --git a/provider/resources.go b/provider/resources.go index 16662a73317..22a59cc154b 100644 --- a/provider/resources.go +++ b/provider/resources.go @@ -5945,6 +5945,19 @@ compatibility shim in favor of the new "name" field.`) prov.Resources[key].PreCheckCallback = applyTags } + // also override read so that it works during import + if transform := prov.Resources[key].TransformOutputs; transform != nil { + prov.Resources[key].TransformOutputs = func(ctx context.Context, pm resource.PropertyMap) (resource.PropertyMap, error) { + config, err := transform(ctx, pm) + if err != nil { + return nil, err + } + return applyTagsOutputs(ctx, config) + } + } else { + prov.Resources[key].TransformOutputs = applyTagsOutputs + } + if prov.Resources[key].GetFields() == nil { prov.Resources[key].Fields = map[string]*tfbridge.SchemaInfo{} } diff --git a/provider/tags.go b/provider/tags.go index 40b1ef8307a..9925af8807e 100644 --- a/provider/tags.go +++ b/provider/tags.go @@ -57,6 +57,40 @@ func applyTags( } if allTags.IsNull() { delete(ret, "tags") + delete(ret, "tagsAll") + return ret, nil + } + ret["tags"] = allTags + ret["tagsAll"] = allTags + + return ret, nil +} + +// similar to applyTags, but applied to the `TransformOutputs` method that is run +// during Read +func applyTagsOutputs( + ctx context.Context, config resource.PropertyMap, +) (resource.PropertyMap, error) { + ret := config.Copy() + configTags := resource.NewObjectProperty(resource.PropertyMap{}) + if t, ok := config["tags"]; ok { + configTags = t + } + + meta := resource.PropertyMap{} + if at, ok := config["tagsAll"]; ok { + meta["defaultTags"] = resource.NewObjectProperty(resource.PropertyMap{ + "tags": at, + }) + } + + allTags, err := mergeTags(ctx, configTags, meta) + if err != nil { + return nil, err + } + if allTags.IsNull() { + delete(ret, "tags") + delete(ret, "tagsAll") return ret, nil } ret["tags"] = allTags diff --git a/provider/tags_test.go b/provider/tags_test.go index 6612883ba1f..8912740f8b8 100644 --- a/provider/tags_test.go +++ b/provider/tags_test.go @@ -220,6 +220,187 @@ func TestApplyTags(t *testing.T) { } } +func TestApplyTagsOutputs(t *testing.T) { + ctx := context.Background() + + type gen = *rapid.Generator[resource.PropertyValue] + type pk = resource.PropertyKey + type pv = resource.PropertyValue + type pm = resource.PropertyMap + + maybeNullOrUnknown := func(x gen) gen { + return rapid.OneOf( + rapid.Just(resource.NewNullProperty()), + x, + rapid.Map(x, resource.MakeComputed), + ) + } + + str := maybeNullOrUnknown(rapid.OneOf( + rapid.Just(resource.NewStringProperty("")), + rapid.Just(resource.NewStringProperty("foo")), + rapid.Just(resource.NewStringProperty("bar")), + )) + + keys := rapid.Map(rapid.OneOf[string]( + rapid.Just(""), + rapid.Just("a"), + rapid.Just("b"), + ), func(s string) pk { + return resource.PropertyKey(s) + }) + + makeObj := func(m map[pk]resource.PropertyValue) resource.PropertyValue { + return resource.NewObjectProperty(resource.PropertyMap(m)) + } + + keyValueTags := maybeNullOrUnknown( + rapid.Map(rapid.MapOfN[pk, pv](keys, str, 0, 3), makeObj)) + + config := rapid.Map(keyValueTags, func(tags pv) pm { + return resource.PropertyMap{ + "tags": tags, + } + }) + + defaultConfig := maybeNullOrUnknown(rapid.Map(config, + resource.NewObjectProperty)) + + ignoreConfig := rapid.Custom[pv](func(t *rapid.T) pv { + keys := keyValueTags.Draw(t, "keys") + keyPrefixes := keyValueTags.Draw(t, "keyPrefixes") + m := resource.PropertyMap{} + if !keys.IsNull() { + m["keys"] = keys + } + if !keyPrefixes.IsNull() { + m["keyPrefixes"] = keyPrefixes + } + return resource.NewObjectProperty(m) + }) + + meta := rapid.Custom[pm](func(t *rapid.T) pm { + i := ignoreConfig.Draw(t, "ignoreConfig") + d := defaultConfig.Draw(t, "defaultConfig") + m := resource.PropertyMap{} + if !i.IsNull() { + m["ignoreConfig"] = i + } + if !d.IsNull() { + m["defaultConfig"] = d + } + return m + }) + + type args struct { + meta resource.PropertyMap + config resource.PropertyMap + } + + argsGen := rapid.Custom[args](func(t *rapid.T) args { + m := meta.Draw(t, "meta") + c := config.Draw(t, "config") + return args{meta: m, config: c} + }) + + t.Run("no panics", func(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + args := argsGen.Draw(t, "args") + _, err := applyTags(ctx, args.config, args.meta) + require.NoError(t, err) + }) + }) + + type testCase struct { + name string + config resource.PropertyMap + expect resource.PropertyMap + } + + testCases := []testCase{ + { + name: "resource tags propagate", + config: resource.PropertyMap{ + "tags": resource.NewObjectProperty(resource.PropertyMap{ + "tag1": resource.NewStringProperty("tag1v"), + }), + }, + expect: resource.PropertyMap{ + "tags": resource.NewObjectProperty(resource.PropertyMap{ + "tag1": resource.NewStringProperty("tag1v"), + }), + }, + }, + { + name: "provier sets a tag", + config: resource.PropertyMap{ + "tagsAll": resource.NewObjectProperty(resource.PropertyMap{ + "tag2": resource.NewStringProperty("tag2v"), + }), + }, + expect: resource.PropertyMap{ + "tags": resource.NewObjectProperty(resource.PropertyMap{ + "tag2": resource.NewStringProperty("tag2v"), + }), + }, + }, + { + name: "provider adds a tag to resource tags", + config: resource.PropertyMap{ + "tags": resource.NewObjectProperty(resource.PropertyMap{ + "tag1": resource.NewStringProperty("tag1v"), + }), + "tagsAll": resource.NewObjectProperty(resource.PropertyMap{ + "tag2": resource.NewStringProperty("tag2v"), + }), + }, + expect: resource.PropertyMap{ + "tags": resource.NewObjectProperty(resource.PropertyMap{ + "tag1": resource.NewStringProperty("tag1v"), + "tag2": resource.NewStringProperty("tag2v"), + }), + }, + }, + { + name: "provider cannot change a resource tag", + config: resource.PropertyMap{ + "tags": resource.NewObjectProperty(resource.PropertyMap{ + "tag1": resource.NewStringProperty("tag1v"), + }), + "tagsAll": resource.NewObjectProperty(resource.PropertyMap{ + "tag1": resource.NewStringProperty("tag2v"), + }), + }, + expect: resource.PropertyMap{ + "tags": resource.NewObjectProperty(resource.PropertyMap{ + "tag1": resource.NewStringProperty("tag1v"), + }), + }, + }, + { + name: "unknowns mark the entire computation unknown", + config: resource.PropertyMap{ + "tags": resource.NewObjectProperty(resource.PropertyMap{ + "tag1": resource.MakeComputed(resource.PropertyValue{}), + }), + }, + expect: resource.PropertyMap{ + "tags": resource.NewOutputProperty(resource.Output{Known: false}), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r, err := applyTagsOutputs(ctx, tc.config) + require.NoError(t, err) + // Expect tagsAll to be copied from tags. + tc.expect["tagsAll"] = tc.expect["tags"] + require.Equal(t, tc.expect, r) + }) + } +} + func TestAddingEmptyTagProducesChangeDiff(t *testing.T) { replayEvent := ` [