Support transformations in Go (#3978)

* started transformations for go sdk

* added first basic test

* added second test with child

* added RegisterStackTransformation

* added a couple tests to lifecycle_test

* update CHANGELOG and test

* included TODO for #3846
This commit is contained in:
Tasia Halim 2020-03-02 13:59:11 -08:00 committed by GitHub
parent dbb800834d
commit c96271b7a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 345 additions and 26 deletions

View file

@ -24,6 +24,9 @@ CHANGELOG
- Add support for secrets in the Go SDK.
[3938](https://github.com/pulumi/pulumi/pull/3938)
- Add support for transformations in the Go SDK.
[3978](https://github.com/pulumi/pulumi/pull/3938)
## 1.11.0 (2020-02-19)
- Allow oversize protocol buffers for Python SDK.
[#3895](https://github.com/pulumi/pulumi/pull/3895)

View file

@ -5216,6 +5216,202 @@ func TestSingleResourceDefaultProviderGolangLifecycle(t *testing.T) {
p.Run(t, nil)
}
// Inspired by transformations_test.go.
func TestSingleResourceDefaultProviderGolangTransformations(t *testing.T) {
loaders := []*deploytest.ProviderLoader{
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
return &deploytest.Provider{
CreateF: func(urn resource.URN,
news resource.PropertyMap, timeout float64) (resource.ID, resource.PropertyMap, resource.Status, error) {
return "created-id", news, resource.StatusOK, nil
},
ReadF: func(urn resource.URN, id resource.ID,
inputs, state resource.PropertyMap) (plugin.ReadResult, resource.Status, error) {
return plugin.ReadResult{Inputs: inputs, Outputs: state}, resource.StatusOK, nil
},
}, nil
}),
}
newResource := func(ctx *pulumi.Context, name string, opts ...pulumi.ResourceOption) error {
var res testResource
return ctx.RegisterResource("pkgA:m:typA", name, &testResourceInputs{
Foo: pulumi.String("bar"),
}, &res, opts...)
}
newComponent := func(ctx *pulumi.Context, name string, opts ...pulumi.ResourceOption) error {
var res testResource
err := ctx.RegisterComponentResource("pkgA:m:typA", name, &res, opts...)
if err != nil {
return err
}
var resChild testResource
return ctx.RegisterResource("pkgA:m:typA", name+"Child", &testResourceInputs{
Foo: pulumi.String("bar"),
}, &resChild, pulumi.Parent(&res))
}
program := deploytest.NewLanguageRuntime(func(info plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
ctx, err := pulumi.NewContext(context.Background(), pulumi.RunInfo{
Project: info.Project,
Stack: info.Stack,
Parallel: info.Parallel,
DryRun: info.DryRun,
MonitorAddr: info.MonitorAddress,
})
assert.NoError(t, err)
return pulumi.RunWithContext(ctx, func(ctx *pulumi.Context) error {
// Scenario #1 - apply a transformation to a CustomResource
res1Transformation := func(args *pulumi.ResourceTransformationArgs) *pulumi.ResourceTransformationResult {
// TODO[pulumi/pulumi#3846] We should use a mergeOptions-style API here.
return &pulumi.ResourceTransformationResult{
Props: args.Props,
Opts: append(args.Opts, pulumi.AdditionalSecretOutputs([]string{"output"})),
}
}
assert.NoError(t, newResource(ctx, "res1",
pulumi.Transformations([]pulumi.ResourceTransformation{res1Transformation})))
// Scenario #2 - apply a transformation to a Component to transform its children
res2Transformation := func(args *pulumi.ResourceTransformationArgs) *pulumi.ResourceTransformationResult {
if args.Name == "res2Child" {
// TODO[pulumi/pulumi#3846] We should use a mergeOptions-style API here.
return &pulumi.ResourceTransformationResult{
Props: args.Props,
Opts: append(args.Opts, pulumi.AdditionalSecretOutputs([]string{"output", "output2"})),
}
}
return nil
}
assert.NoError(t, newComponent(ctx, "res2",
pulumi.Transformations([]pulumi.ResourceTransformation{res2Transformation})))
// Scenario #3 - apply a transformation to the Stack to transform all (future) resources in the stack
res3Transformation := func(args *pulumi.ResourceTransformationArgs) *pulumi.ResourceTransformationResult {
// Props might be nil.
var props *testResourceInputs
if args.Props == nil {
props = &testResourceInputs{}
} else {
props = args.Props.(*testResourceInputs)
}
props.Foo = pulumi.String("baz")
return &pulumi.ResourceTransformationResult{
Props: props,
Opts: args.Opts,
}
}
assert.NoError(t, ctx.RegisterStackTransformation(res3Transformation))
assert.NoError(t, newResource(ctx, "res3"))
// Scenario #4 - transformations are applied in order of decreasing specificity
// 1. (not in this example) Child transformation
// 2. First parent transformation
// 3. Second parent transformation
// 4. Stack transformation
res4Transformation1 := func(args *pulumi.ResourceTransformationArgs) *pulumi.ResourceTransformationResult {
if args.Name == "res4Child" {
props := args.Props.(*testResourceInputs)
props.Foo = pulumi.String("baz1")
return &pulumi.ResourceTransformationResult{
Props: props,
Opts: args.Opts,
}
}
return nil
}
res4Transformation2 := func(args *pulumi.ResourceTransformationArgs) *pulumi.ResourceTransformationResult {
if args.Name == "res4Child" {
props := args.Props.(*testResourceInputs)
props.Foo = pulumi.String("baz2")
return &pulumi.ResourceTransformationResult{
Props: props,
Opts: args.Opts,
}
}
return nil
}
assert.NoError(t, newComponent(ctx, "res4",
pulumi.Transformations([]pulumi.ResourceTransformation{res4Transformation1, res4Transformation2})))
return nil
})
})
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
p := &TestPlan{
Options: UpdateOptions{host: host},
}
p.Steps = []TestStep{{
Op: Update,
Validate: func(project workspace.Project, target deploy.Target, j *Journal,
_ []Event, res result.Result) result.Result {
foundRes1 := false
foundRes2 := false
foundRes2Child := false
foundRes3 := false
foundRes4Child := false
// foundRes5Child1 := false
for _, res := range j.Snap(target.Snapshot).Resources {
// "res1" has a transformation which adds additionalSecretOutputs
if res.URN.Name() == "res1" {
foundRes1 = true
assert.Equal(t, res.Type, tokens.Type("pkgA:m:typA"))
assert.Contains(t, res.AdditionalSecretOutputs, resource.PropertyKey("output"))
}
// "res2" has a transformation which adds additionalSecretOutputs to it's "child"
if res.URN.Name() == "res2" {
foundRes2 = true
assert.Equal(t, res.Type, tokens.Type("pkgA:m:typA"))
assert.NotContains(t, res.AdditionalSecretOutputs, resource.PropertyKey("output"))
}
if res.URN.Name() == "res2Child" {
foundRes2Child = true
assert.Equal(t, res.Parent.Name(), tokens.QName("res2"))
assert.Equal(t, res.Type, tokens.Type("pkgA:m:typA"))
assert.Contains(t, res.AdditionalSecretOutputs, resource.PropertyKey("output"))
assert.Contains(t, res.AdditionalSecretOutputs, resource.PropertyKey("output2"))
}
// "res3" is impacted by a global stack transformation which sets
// Foo to "baz"
if res.URN.Name() == "res3" {
foundRes3 = true
assert.Equal(t, "baz", res.Inputs["foo"].StringValue())
assert.Len(t, res.Aliases, 0)
}
// "res4" is impacted by two component parent transformations which set
// Foo to "baz1" and then "baz2" and also a global stack
// transformation which sets optionalDefault to "baz". The end
// result should be "baz".
if res.URN.Name() == "res4Child" {
foundRes4Child = true
assert.Equal(t, res.Parent.Name(), tokens.QName("res4"))
assert.Equal(t, "baz", res.Inputs["foo"].StringValue())
}
}
assert.True(t, foundRes1)
assert.True(t, foundRes2)
assert.True(t, foundRes2Child)
assert.True(t, foundRes3)
assert.True(t, foundRes4Child)
return res
},
}}
p.Run(t, nil)
}
// This test validates the wiring of the IgnoreChanges prop in the go SDK.
// It doesn't attempt to validate underlying behavior.
func TestIgnoreChangesGolangLifecycle(t *testing.T) {
@ -5345,8 +5541,8 @@ func TestExplicitDeleteBeforeReplaceGoSDK(t *testing.T) {
assert.NoError(t, err)
return pulumi.RunWithContext(ctx, func(ctx *pulumi.Context) error {
var provider pulumi.ProviderResourceState
err := ctx.RegisterResource(string(providers.MakeProviderType("pkgA")), "provA", nil, &provider)
provider := &pulumi.ProviderResourceState{}
err := ctx.RegisterResource(string(providers.MakeProviderType("pkgA")), "provA", nil, provider)
assert.NoError(t, err)
var res pulumi.CustomResourceState
@ -5557,34 +5753,34 @@ func TestProviderInheritanceGolangLifecycle(t *testing.T) {
}, &providerBOverride)
assert.NoError(t, err)
parentProviders := make(map[string]pulumi.ProviderResource)
parentProviders["pkgA"] = providerA
parentProviders["pkgB"] = providerB
parentProviders["pkgA"] = &providerA
parentProviders["pkgB"] = &providerB
// create a parent resource that uses provider map
var parentResource pulumi.CustomResourceState
err = ctx.RegisterResource("pkgA:m:typA", "resA", nil, &parentResource, pulumi.ProviderMap(parentProviders))
assert.NoError(t, err)
// parent uses specified provider from map
parentResultProvider := parentResource.GetProvider("pkgA:m:typA")
assert.Equal(t, providerA, parentResultProvider)
assert.Equal(t, &providerA, parentResultProvider)
// create a child resource
var childResource pulumi.CustomResourceState
err = ctx.RegisterResource("pkgB:m:typB", "resBChild", nil, &childResource, pulumi.Parent(parentResource))
err = ctx.RegisterResource("pkgB:m:typB", "resBChild", nil, &childResource, pulumi.Parent(&parentResource))
assert.NoError(t, err)
// child uses provider value from parent
childResultProvider := childResource.GetProvider("pkgB:m:typB")
assert.Equal(t, providerB, childResultProvider)
assert.Equal(t, &providerB, childResultProvider)
// create a child with a provider specified
var childWithOverride pulumi.CustomResourceState
err = ctx.RegisterResource("pkgB:m:typB", "resBChildOverride", nil, &childWithOverride,
pulumi.Parent(parentResource), pulumi.Provider(providerBOverride))
pulumi.Parent(&parentResource), pulumi.Provider(&providerBOverride))
assert.NoError(t, err)
// child uses the specified provider, and not the provider from the parent
childWithOverrideProvider := childWithOverride.GetProvider("pkgB:m:typB")
assert.Equal(t, providerBOverride, childWithOverrideProvider)
assert.Equal(t, &providerBOverride, childWithOverrideProvider)
// pass in a fake ID
testID := pulumi.ID("testID")
@ -5594,42 +5790,42 @@ func TestProviderInheritanceGolangLifecycle(t *testing.T) {
assert.NoError(t, err)
// parent uses specified provider from map
parentResultProvider = parentResource.GetProvider("pkgA:m:typA")
assert.Equal(t, providerA, parentResultProvider)
assert.Equal(t, &providerA, parentResultProvider)
// read a child resource
err = ctx.ReadResource("pkgB:m:typB", "readResBChild", testID, nil, &childResource, pulumi.Parent(parentResource))
err = ctx.ReadResource("pkgB:m:typB", "readResBChild", testID, nil, &childResource, pulumi.Parent(&parentResource))
assert.NoError(t, err)
// child uses provider value from parent
childResultProvider = childResource.GetProvider("pkgB:m:typB")
assert.Equal(t, providerB, childResultProvider)
assert.Equal(t, &providerB, childResultProvider)
// read a child with a provider specified
err = ctx.ReadResource("pkgB:m:typB", "readResBChildOverride", testID, nil, &childWithOverride,
pulumi.Parent(parentResource), pulumi.Provider(providerBOverride))
pulumi.Parent(&parentResource), pulumi.Provider(&providerBOverride))
assert.NoError(t, err)
// child uses the specified provider, and not the provider from the parent
childWithOverrideProvider = childWithOverride.GetProvider("pkgB:m:typB")
assert.Equal(t, providerBOverride, childWithOverrideProvider)
assert.Equal(t, &providerBOverride, childWithOverrideProvider)
// invoke with specific provider
var invokeResult struct{}
err = ctx.Invoke("pkgB:do:something", invokeArgs{
Bang: "3",
}, &invokeResult, pulumi.Provider(providerBOverride))
}, &invokeResult, pulumi.Provider(&providerBOverride))
assert.NoError(t, err)
// invoke with parent
err = ctx.Invoke("pkgB:do:something", invokeArgs{
Bar: "2",
}, &invokeResult, pulumi.Parent(parentResource))
}, &invokeResult, pulumi.Parent(&parentResource))
assert.NoError(t, err)
// invoke with parent and provider
err = ctx.Invoke("pkgB:do:something", invokeArgs{
Bang: "3",
}, &invokeResult, pulumi.Parent(parentResource), pulumi.Provider(providerBOverride))
}, &invokeResult, pulumi.Parent(&parentResource), pulumi.Provider(&providerBOverride))
assert.NoError(t, err)
return nil

View file

@ -277,6 +277,13 @@ func (ctx *Context) ReadResource(
options.Parent = ctx.stack
}
// Before anything else, if there are transformations registered, give them a chance to run to modify the
// user-provided properties and options assigned to this resource.
props, options, transformations, err := applyTransformations(t, name, props, resource, opts, options)
if err != nil {
return err
}
// Collapse aliases to URNs.
aliasURNs, err := ctx.collapseAliases(options.Aliases, t, name, options.Parent)
if err != nil {
@ -292,7 +299,7 @@ func (ctx *Context) ReadResource(
providers := mergeProviders(t, options.Parent, options.Provider, options.Providers)
// Create resolvers for the resource's outputs.
res := makeResourceState(t, name, resource, providers, aliasURNs)
res := makeResourceState(t, name, resource, providers, aliasURNs, transformations)
// Kick off the resource read operation. This will happen asynchronously and resolve the above properties.
go func() {
@ -399,6 +406,13 @@ func (ctx *Context) RegisterResource(
options.Parent = ctx.stack
}
// Before anything else, if there are transformations registered, give them a chance to run to modify the
// user-provided properties and options assigned to this resource.
props, options, transformations, err := applyTransformations(t, name, props, resource, opts, options)
if err != nil {
return err
}
// Collapse aliases to URNs.
aliasURNs, err := ctx.collapseAliases(options.Aliases, t, name, options.Parent)
if err != nil {
@ -414,7 +428,7 @@ func (ctx *Context) RegisterResource(
providers := mergeProviders(t, options.Parent, options.Provider, options.Providers)
// Create resolvers for the resource's outputs.
res := makeResourceState(t, name, resource, providers, aliasURNs)
res := makeResourceState(t, name, resource, providers, aliasURNs, transformations)
// Kick off the resource registration. If we are actually performing a deployment, the resulting properties
// will be resolved asynchronously as the RPC operation completes. If we're just planning, values won't resolve.
@ -476,10 +490,47 @@ func (ctx *Context) RegisterComponentResource(
// resourceState contains the results of a resource registration operation.
type resourceState struct {
outputs map[string]Output
providers map[string]ProviderResource
aliases []URNOutput
name string
outputs map[string]Output
providers map[string]ProviderResource
aliases []URNOutput
name string
transformations []ResourceTransformation
}
// Apply transformations and return the transformations themselves, as well as the transformed props and opts.
func applyTransformations(t, name string, props Input, resource Resource, opts []ResourceOption,
options *resourceOptions) (Input, *resourceOptions, []ResourceTransformation, error) {
transformations := options.Transformations
if options.Parent != nil {
transformations = append(transformations, options.Parent.getTransformations()...)
}
for _, transformation := range transformations {
args := &ResourceTransformationArgs{
Resource: resource,
Type: t,
Name: name,
Props: props,
Opts: opts,
}
res := transformation(args)
if res != nil {
resOptions := &resourceOptions{}
for _, o := range res.Opts {
o.applyResourceOption(resOptions)
}
if resOptions.Parent != nil && resOptions.Parent.URN() != options.Parent.URN() {
return nil, nil, nil, errors.New("transformations cannot currently be used to change the `parent` of a resource")
}
props = res.Props
options = resOptions
}
}
return props, options, transformations, nil
}
// checks all possible sources of providers and merges them with preference given to the most specific
@ -543,7 +594,7 @@ func (ctx *Context) collapseAliases(aliases []Alias, t, name string, parent Reso
// makeResourceState creates a set of resolvers that we'll use to finalize state, for URNs, IDs, and output
// properties.
func makeResourceState(t, name string, resourceV Resource, providers map[string]ProviderResource,
aliases []URNOutput) *resourceState {
aliases []URNOutput, transformations []ResourceTransformation) *resourceState {
// Ensure that the input resource is a pointer to a struct. Note that we don't fail if it is not, and we probably
// ought to.
@ -620,6 +671,8 @@ func makeResourceState(t, name string, resourceV Resource, providers map[string]
rs.name = name
state.aliases = aliases
rs.aliases = aliases
state.transformations = transformations
rs.transformations = transformations
return state
}
@ -955,3 +1008,10 @@ func (ctx *Context) RegisterResourceOutputs(resource Resource, outs Map) error {
func (ctx *Context) Export(name string, value Input) {
ctx.exports[name] = value
}
// RegisterStackTransformation adds a transformation to all future resources constructed in this Pulumi stack.
func (ctx *Context) RegisterStackTransformation(t ResourceTransformation) error {
ctx.stack.addTransformation(t)
return nil
}

View file

@ -36,6 +36,8 @@ type ResourceState struct {
aliases []URNOutput
name string
transformations []ResourceTransformation
}
func (s ResourceState) URN() URNOutput {
@ -58,6 +60,14 @@ func (s ResourceState) getName() string {
return s.name
}
func (s ResourceState) getTransformations() []ResourceTransformation {
return s.transformations
}
func (s *ResourceState) addTransformation(t ResourceTransformation) {
s.transformations = append(s.transformations, t)
}
func (ResourceState) isResource() {}
type CustomResourceState struct {
@ -98,6 +108,12 @@ type Resource interface {
// isResource() is a marker method used to ensure that all Resource types embed a ResourceState.
isResource()
// getTransformations returns the transformations for the resource.
getTransformations() []ResourceTransformation
// addTransformation adds a single transformation to the resource.
addTransformation(t ResourceTransformation)
}
// CustomResource is a cloud resource whose create, read, update, and delete (CRUD) operations are managed by performing
@ -159,6 +175,10 @@ type resourceOptions struct {
Aliases []Alias
// AdditionalSecretOutputs is an optional list of output properties to mark as secret.
AdditionalSecretOutputs []string
// Transformations is an optional list of transformations to apply to this resource during construction.
// The transformations are applied in order, and are applied prior to transformation and to parents
// walking from the resource up to the stack.
Transformations []ResourceTransformation
}
type invokeOptions struct {
@ -302,3 +322,10 @@ func AdditionalSecretOutputs(o []string) ResourceOption {
ro.AdditionalSecretOutputs = o
})
}
// Transformations is an optional list of transformations to be applied to the resource.
func Transformations(o []ResourceTransformation) ResourceOption {
return resourceOption(func(ro *resourceOptions) {
ro.Transformations = o
})
}

View file

@ -264,7 +264,7 @@ type testResource struct {
func TestResourceState(t *testing.T) {
var theResource testResource
state := makeResourceState("", "", &theResource, nil, nil)
state := makeResourceState("", "", &theResource, nil, nil, nil)
resolved, _, _, _ := marshalInputs(&testResourceInputs{
Any: String("foo"),

View file

@ -84,7 +84,7 @@ func RunWithContext(ctx *Context, body RunFunc) error {
if err != nil {
return err
}
ctx.stack = stack
ctx.stack = &stack
// Execute the body.
var result error

View file

@ -0,0 +1,33 @@
package pulumi
// ResourceTransformationArgs is the argument bag passed to a resource transformation.
type ResourceTransformationArgs struct {
// The resource instance that is being transformed.
Resource Resource
// The type of the resource.
Type string
// The name of the resource.
Name string
// The original properties passed to the resource constructor.
Props Input
// The original resource options passed to the resource constructor.
Opts []ResourceOption
}
// ResourceTransformationResult is the result that must be returned by a resource transformation
// callback. It includes new values to use for the `props` and `opts` of the `Resource` in place of
// the originally provided values.
type ResourceTransformationResult struct {
// The new properties to use in place of the original `props`.
Props Input
// The new resource options to use in place of the original `opts`.
Opts []ResourceOption
}
// ResourceTransformation is the callback signature for the `transformations` resource option. A
// transformation is passed the same set of inputs provided to the `Resource` constructor, and can
// optionally return back alternate values for the `props` and/or `opts` prior to the resource
// actually being created. The effect will be as though those props and opts were passed in place
// of the original call to the `Resource` constructor. If the transformation returns nil,
// this indicates that the resource will not be transformed.
type ResourceTransformation func(*ResourceTransformationArgs) *ResourceTransformationResult