From d668dcd1a3605ac6de8b6d0bed98557d228e5302 Mon Sep 17 00:00:00 2001 From: Ian Wahbe Date: Fri, 5 Apr 2024 12:01:12 +0200 Subject: [PATCH 1/2] Support partial resource initialization errors This allows a provider to indicate that it tried to Create, Update or Read a resource but was not able atomically succeed, leaving the resource in a partially initialized state. --- .gitignore | 1 + infer/errors.go | 79 ++++++++++++++++++++++++++++++++++++++++++ infer/resource.go | 86 ++++++++++++++++++++++++++++++++++++++++++---- internal/errors.go | 37 ++++++++++++++++++++ provider.go | 65 +++++++++++++++++++++++++++++++++-- 5 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 infer/errors.go create mode 100644 internal/errors.go diff --git a/.gitignore b/.gitignore index 5eb14448..c14828f7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ **/sdk examples/**/pulumi-resource-* +examples/**/schema-*.json /.vscode diff --git a/infer/errors.go b/infer/errors.go new file mode 100644 index 00000000..84b629db --- /dev/null +++ b/infer/errors.go @@ -0,0 +1,79 @@ +// Copyright 2024, 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. + +package infer + +import ( + "fmt" +) + +// An error indicating that the resource was created but failed to initialize. +// +// This error is treated specially in Create, Update and Read. If the returner error for a +// Create, Update or Read returns true for Errors.Is, state is updated to correspond to +// the accompanying state, the resource will be considered created, and the next call will +// be Update with the new state. +// +// func (*Team) Create( +// ctx p.Context, name string, input TeamInput, preview bool, +// ) (id string, output TeamState, err error) { +// team, err := GetConfig(ctx).Client.CreateTeam(ctx, +// input.OrganizationName, input.Name, +// input.TeamType, input.DisplayName, +// input.Description, input.GithubTeamId) +// if err != nil { +// return "", TeamState{}, fmt.Errorf("error creating team '%s': %w", input.Name, err) +// } +// +// if membersAdded, err := addMembers(team, input.Members); err != nil { +// return TeamState{ +// Input: input, +// Members: membersAdded, +// }, infer.ResourceInitFailedError{Reasons: []string{ +// fmt.Sprintf("Failed to add members: %s", err), +// }} +// } +// +// return TeamState{input, input.Members}, nil +// } +// +// If the the above example errors with [infer.ResourceInitFailedError], the next Update +// will be called with `state` equal to what was returned alongside +// [infer.ResourceInitFailedError]. +type ResourceInitFailedError struct { + Reasons []string +} + +func (err ResourceInitFailedError) Error() string { return "resource failed to initialize" } + +// An error indicating a bug in the provider implementation. +type ProviderError struct { + Inner error +} + +// Create a new [ProviderErrorf]. +func ProviderErrorf(msg string, a ...any) error { + return ProviderError{fmt.Errorf(msg, a...)} +} + +func (err ProviderError) Error() string { + const ( + prefix = "provider error" + suffix = "; please report this to the provider author" + ) + if err.Inner == nil { + return prefix + suffix + } + return prefix + ": " + err.Inner.Error() + suffix +} diff --git a/infer/resource.go b/infer/resource.go index 6527aac0..b5f89a8c 100644 --- a/infer/resource.go +++ b/infer/resource.go @@ -15,6 +15,7 @@ package infer import ( + "errors" "fmt" "reflect" @@ -30,6 +31,7 @@ import ( p "github.com/pulumi/pulumi-go-provider" "github.com/pulumi/pulumi-go-provider/infer/internal/ende" + "github.com/pulumi/pulumi-go-provider/internal" "github.com/pulumi/pulumi-go-provider/internal/introspect" t "github.com/pulumi/pulumi-go-provider/middleware" "github.com/pulumi/pulumi-go-provider/middleware/schema" @@ -894,7 +896,9 @@ func diff[R, I, O any](ctx p.Context, req p.DiffRequest, r *R, forceReplace func }, nil } -func (rc *derivedResourceController[R, I, O]) Create(ctx p.Context, req p.CreateRequest) (p.CreateResponse, error) { +func (rc *derivedResourceController[R, I, O]) Create( + ctx p.Context, req p.CreateRequest, +) (resp p.CreateResponse, retError error) { r := rc.getInstance() var input I @@ -905,11 +909,32 @@ func (rc *derivedResourceController[R, I, O]) Create(ctx p.Context, req p.Create } id, o, err := (*r).Create(ctx, req.Urn.Name(), input, req.Preview) - if err != nil { + if initFailed := (ResourceInitFailedError{}); errors.As(err, &initFailed) { + defer func(createErr error) { + // If there was an error, it indicates a problem with serializing + // the output. + // + // Failing to return full properties here will leak the created + // resource so we should warn users. + if retError != nil { + retError = internal.Errorf("failed to return partial resource: %w;"+ + " %s may be leaked", retError, req.Urn) + } else { + // We don't want to loose information conveyed in the + // error chain returned by the user. + retError = createErr + } + + resp.PartialState = &p.InitializationFailed{ + Reasons: initFailed.Reasons, + } + }(err) + } else if err != nil { return p.CreateResponse{}, err } + if id == "" && !req.Preview { - return p.CreateResponse{}, fmt.Errorf("internal error: '%s' was created without an id", req.Urn) + return p.CreateResponse{}, ProviderErrorf("'%s' was created without an id", req.Urn) } m, err := encoder.AllowUnknown(req.Preview).Encode(o) @@ -926,10 +951,12 @@ func (rc *derivedResourceController[R, I, O]) Create(ctx p.Context, req p.Create return p.CreateResponse{ ID: id, Properties: m, - }, nil + }, err } -func (rc *derivedResourceController[R, I, O]) Read(ctx p.Context, req p.ReadRequest) (p.ReadResponse, error) { +func (rc *derivedResourceController[R, I, O]) Read( + ctx p.Context, req p.ReadRequest, +) (resp p.ReadResponse, retError error) { r := rc.getInstance() var inputs I var state O @@ -955,9 +982,30 @@ func (rc *derivedResourceController[R, I, O]) Read(ctx p.Context, req p.ReadRequ }, nil } id, inputs, state, err := read.Read(ctx, req.ID, inputs, state) - if err != nil { + if initFailed := (ResourceInitFailedError{}); errors.As(err, &initFailed) { + defer func(readErr error) { + // If there was an error, it indicates a problem with serializing + // the output. + // + // Failing to return full properties here will leak the created + // resource so we should warn users. + if retError != nil { + retError = internal.Errorf("failed to return partial resource: %w", + retError) + } else { + // We don't want to loose information conveyed in the + // error chain returned by the user. + retError = readErr + } + + resp.PartialState = &p.InitializationFailed{ + Reasons: initFailed.Reasons, + } + }(err) + } else if err != nil { return p.ReadResponse{}, err } + i, err := inputEncoder.Encode(inputs) if err != nil { return p.ReadResponse{}, err @@ -974,7 +1022,9 @@ func (rc *derivedResourceController[R, I, O]) Read(ctx p.Context, req p.ReadRequ }, nil } -func (rc *derivedResourceController[R, I, O]) Update(ctx p.Context, req p.UpdateRequest) (p.UpdateResponse, error) { +func (rc *derivedResourceController[R, I, O]) Update( + ctx p.Context, req p.UpdateRequest, +) (resp p.UpdateResponse, retError error) { r := rc.getInstance() update, ok := ((interface{})(*r)).(CustomUpdate[I, O]) if !ok { @@ -996,6 +1046,28 @@ func (rc *derivedResourceController[R, I, O]) Update(ctx p.Context, req p.Update return p.UpdateResponse{}, err } o, err := update.Update(ctx, req.ID, olds, news, req.Preview) + if initFailed := (ResourceInitFailedError{}); errors.As(err, &initFailed) { + defer func(updateErr error) { + // If there was an error, it indicates a problem with serializing + // the output. + // + // Failing to return full properties here will leak the created + // resource so we should warn users. + if retError != nil { + retError = internal.Errorf("failed to return partial resource: %w", + retError) + } else { + // We don't want to loose information conveyed in the + // error chain returned by the user. + retError = updateErr + } + + resp.PartialState = &p.InitializationFailed{ + Reasons: initFailed.Reasons, + } + }(err) + err = nil + } if err != nil { return p.UpdateResponse{}, err } diff --git a/internal/errors.go b/internal/errors.go new file mode 100644 index 00000000..66820479 --- /dev/null +++ b/internal/errors.go @@ -0,0 +1,37 @@ +// Copyright 2024, 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. + +package internal + +import "fmt" + +// A error that indicates a bug in the pulumi-go-provider framework. +type Error struct { + Inner error +} + +func Errorf(msg string, a ...any) error { + return Error{fmt.Errorf(msg, a...)} +} + +func (err Error) Error() string { + const ( + prefix = "internal error" + suffix = "; please report this to https://github.com/pulumi/pulumi-go-provider/issues" + ) + if err.Inner == nil { + return prefix + suffix + } + return prefix + ": " + err.Inner.Error() + suffix +} diff --git a/provider.go b/provider.go index ce6618db..7b15bf4d 100644 --- a/provider.go +++ b/provider.go @@ -1,4 +1,4 @@ -// Copyright 2022, Pulumi Corporation. +// Copyright 2022-2024, Pulumi Corporation. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package provider import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -202,6 +203,13 @@ type CreateRequest struct { type CreateResponse struct { ID string // the ID of the created resource. Properties presource.PropertyMap // any properties that were computed during creation. + + // non-nil to indicate that the create failed and left the resource in a partial + // state. + // + // If PartialState is non-nil, then an error will be returned, annotated with + // [pulumirpc.ErrorResourceInitFailed]. + PartialState *InitializationFailed } type ReadRequest struct { @@ -215,6 +223,13 @@ type ReadResponse struct { ID string // the ID of the resource read back (or empty if missing). Properties presource.PropertyMap // the state of the resource read from the live environment. Inputs presource.PropertyMap // the inputs for this resource that would be returned from Check. + + // non-nil to indicate that the read failed and left the resource in a partial + // state. + // + // If PartialState is non-nil, then an error will be returned, annotated with + // [pulumirpc.ErrorResourceInitFailed]. + PartialState *InitializationFailed } type UpdateRequest struct { @@ -228,7 +243,14 @@ type UpdateRequest struct { } type UpdateResponse struct { - Properties presource.PropertyMap // any properties that were computed during updating. + // any properties that were computed during updating. + Properties presource.PropertyMap + // non-nil to indicate that the update failed and left the resource in a partial + // state. + // + // If PartialState is non-nil, then an error will be returned, annotated with + // [pulumirpc.ErrorResourceInitFailed]. + PartialState *InitializationFailed } type DeleteRequest struct { @@ -238,6 +260,13 @@ type DeleteRequest struct { Timeout float64 // the delete request timeout represented in seconds. } +// InitializationFailed indicates that a resource exists but failed to initialize, and is +// thus in a partial state. +type InitializationFailed struct { + // Reasons why the resource did not fully initialize. + Reasons []string +} + // Provide a structured error for missing provider keys. func ConfigMissingKeys(missing map[string]string) error { if len(missing) == 0 { @@ -824,6 +853,16 @@ func (p *provider) Create(ctx context.Context, req *rpc.CreateRequest) (*rpc.Cre Timeout: req.GetTimeout(), Preview: req.GetPreview(), }) + if initFailed := r.PartialState; initFailed != nil { + prop, propErr := p.asStruct(r.Properties) + err = errors.Join(rpcerror.WithDetails( + rpcerror.New(codes.Unknown, err.Error()), + &rpc.ErrorResourceInitFailed{ + Id: r.ID, + Properties: prop, + Reasons: initFailed.Reasons, + }), propErr) + } if err != nil { return nil, err } @@ -854,6 +893,18 @@ func (p *provider) Read(ctx context.Context, req *rpc.ReadRequest) (*rpc.ReadRes Properties: propMap, Inputs: inputMap, }) + if initFailed := r.PartialState; initFailed != nil { + props, propErr := p.asStruct(r.Properties) + inputs, inputsErr := p.asStruct(r.Inputs) + err = errors.Join(rpcerror.WithDetails( + rpcerror.New(codes.Unknown, err.Error()), + &rpc.ErrorResourceInitFailed{ + Id: r.ID, + Inputs: inputs, + Properties: props, + Reasons: initFailed.Reasons, + }), propErr, inputsErr) + } if err != nil { return nil, err } @@ -890,6 +941,16 @@ func (p *provider) Update(ctx context.Context, req *rpc.UpdateRequest) (*rpc.Upd IgnoreChanges: getIgnoreChanges(req.GetIgnoreChanges()), Preview: req.GetPreview(), }) + if initFailed := r.PartialState; initFailed != nil { + prop, propErr := p.asStruct(r.Properties) + err = errors.Join(rpcerror.WithDetails( + rpcerror.New(codes.Unknown, err.Error()), + &rpc.ErrorResourceInitFailed{ + Id: req.GetId(), + Properties: prop, + Reasons: initFailed.Reasons, + }), propErr) + } if err != nil { return nil, err } From c3019cbc3a84d67080e69a31dc15f6547859fa19 Mon Sep 17 00:00:00 2001 From: Ian Wahbe Date: Fri, 5 Apr 2024 13:34:30 +0200 Subject: [PATCH 2/2] Add tests for partial initialization --- tests/go.mod | 8 +- tests/go.sum | 16 ++-- tests/grpc/config_test.go | 2 +- tests/grpc/grpc.go | 2 +- tests/grpc/partial/provider.go | 95 +++++++++++++++++++++ tests/grpc/partial_test.go | 145 +++++++++++++++++++++++++++++++++ 6 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 tests/grpc/partial/provider.go create mode 100644 tests/grpc/partial_test.go diff --git a/tests/go.mod b/tests/go.mod index 978545f0..a8fa52bd 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -8,9 +8,9 @@ replace github.com/pulumi/pulumi-go-provider/integration => ../integration require ( github.com/blang/semver v3.5.1+incompatible + github.com/pulumi/providertest v0.0.11 github.com/pulumi/pulumi-go-provider v0.0.0-00010101000000-000000000000 github.com/pulumi/pulumi-go-provider/integration v0.0.0-00010101000000-000000000000 - github.com/pulumi/pulumi-terraform-bridge/testing v0.0.1 github.com/pulumi/pulumi/pkg/v3 v3.95.0 github.com/pulumi/pulumi/sdk/v3 v3.95.0 github.com/stretchr/testify v1.8.4 @@ -88,13 +88,13 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/zclconf/go-cty v1.13.2 // indirect go.uber.org/atomic v1.10.0 // indirect - golang.org/x/crypto v0.17.0 // indirect + golang.org/x/crypto v0.18.0 // indirect golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.15.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/term v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 // indirect diff --git a/tests/go.sum b/tests/go.sum index 8454c249..ab117d17 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -156,8 +156,8 @@ github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435 github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE= github.com/pulumi/esc v0.6.1-0.20231111193429-44b746a5b3b5 h1:1DJMji9F7XPea46bSuhy4/85n8J4/Mfz8PWLZtjKKiI= github.com/pulumi/esc v0.6.1-0.20231111193429-44b746a5b3b5/go.mod h1:Y6W21yUukvxS2NnS5ae1beMSPhMvj0xNAYcDqDHVj/g= -github.com/pulumi/pulumi-terraform-bridge/testing v0.0.1 h1:SCg1gjfY9N4yn8U8peIUYATifjoDABkyR7H9lmefsfc= -github.com/pulumi/pulumi-terraform-bridge/testing v0.0.1/go.mod h1:7OeUPH8rpt5ipyj9EFcnXpuzQ8SHL0dyqdfa8nOacdk= +github.com/pulumi/providertest v0.0.11 h1:mg8MQ7Cq7+9XlHIkBD+aCqQO4mwAJEISngZgVdnQUe8= +github.com/pulumi/providertest v0.0.11/go.mod h1:HsxjVsytcMIuNj19w1lT2W0QXY0oReXl1+h6eD2JXP8= github.com/pulumi/pulumi/pkg/v3 v3.95.0 h1:FBA0EmjRaqUgzleFMpLSAQUojXH2PyIVERzAm53p63U= github.com/pulumi/pulumi/pkg/v3 v3.95.0/go.mod h1:4mjOPC8lb49ihR/HbGmid0y9GFlpfP9Orumr0wFOGno= github.com/pulumi/pulumi/sdk/v3 v3.95.0 h1:SBpFZYdbVF8DtmiEosut2BRVRjLxPpcQf5bOkyPWosQ= @@ -220,8 +220,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -273,15 +273,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/tests/grpc/config_test.go b/tests/grpc/config_test.go index db6ac04a..3cc26155 100644 --- a/tests/grpc/config_test.go +++ b/tests/grpc/config_test.go @@ -17,7 +17,7 @@ package grpc import ( "testing" - replay "github.com/pulumi/pulumi-terraform-bridge/testing/x" + replay "github.com/pulumi/providertest/replay" "github.com/stretchr/testify/require" p "github.com/pulumi/pulumi-go-provider" diff --git a/tests/grpc/grpc.go b/tests/grpc/grpc.go index c02488c4..a9f73136 100644 --- a/tests/grpc/grpc.go +++ b/tests/grpc/grpc.go @@ -1,4 +1,4 @@ -// Copyright 2023, Pulumi Corporation. +// Copyright 2023-2024, Pulumi Corporation. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/grpc/partial/provider.go b/tests/grpc/partial/provider.go new file mode 100644 index 00000000..67920f43 --- /dev/null +++ b/tests/grpc/partial/provider.go @@ -0,0 +1,95 @@ +// Copyright 2024, 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. + +package partial + +import ( + "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/infer" +) + +func Provider() p.Provider { + return infer.Provider(infer.Options{ + Resources: []infer.InferredResource{infer.Resource[*Partial, Args, State]()}, + ModuleMap: map[tokens.ModuleName]tokens.ModuleName{ + "partial": "index", + }, + }) +} + +var ( + _ infer.CustomResource[Args, State] = (*Partial)(nil) + _ infer.CustomUpdate[Args, State] = (*Partial)(nil) + _ infer.CustomRead[Args, State] = (*Partial)(nil) +) + +type Partial struct{} +type Args struct { + S string `pulumi:"s"` +} +type State struct { + Args + + Out string `pulumi:"out"` +} + +func (*Partial) Create(ctx p.Context, name string, input Args, preview bool) (string, State, error) { + if preview { + return "", State{}, nil + } + contract.Assertf(input.S == "for-create", `expected input.S to be "for-create"`) + return "id", State{ + Args: Args{S: "+for-create"}, + Out: "partial-create", + }, infer.ResourceInitFailedError{ + Reasons: []string{"create: failed to fully init"}, + } +} + +func (*Partial) Update(ctx p.Context, id string, olds State, news Args, preview bool) (State, error) { + if preview { + return State{}, nil + } + contract.Assertf(news.S == "for-update", `expected news.S to be "for-update"`) + contract.Assertf(olds.S == "+for-create", `expected olds.Out to be "partial-create"`) + contract.Assertf(olds.Out == "partial-init", `expected olds.Out to be "partial-create"`) + + return State{ + Args: Args{ + S: "from-update", + }, + Out: "partial-update", + }, infer.ResourceInitFailedError{ + Reasons: []string{"update: failed to continue init"}, + } +} + +func (*Partial) Read(ctx p.Context, id string, inputs Args, state State) ( + canonicalID string, normalizedInputs Args, normalizedState State, err error) { + contract.Assertf(inputs.S == "for-read", `expected inputs.S to be "for-read"`) + contract.Assertf(state.S == "from-update", `expected olds.Out to be "partial-create"`) + contract.Assertf(state.Out == "state-for-read", `expected state.Out to be "state-for-read"`) + + return "from-read-id", Args{ + S: "from-read-input", + }, State{ + Args: Args{"s-state-from-read"}, + Out: "out-state-from-read", + }, infer.ResourceInitFailedError{ + Reasons: []string{"read: failed to finish read"}, + } +} diff --git a/tests/grpc/partial_test.go b/tests/grpc/partial_test.go new file mode 100644 index 00000000..6687efc0 --- /dev/null +++ b/tests/grpc/partial_test.go @@ -0,0 +1,145 @@ +// Copyright 2024, 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. + +package grpc + +import ( + "context" + "testing" + + "github.com/pulumi/pulumi/sdk/v3/go/common/util/rpcutil/rpcerror" + pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/tests/grpc/partial" +) + +// partial_test.go asserts on the errors returned by the gRPC server generated by +// [p.Provider] and [infer.Provider]. It can't use replay, since that doesn't expose +// errors with enough fidelity to assert against. + +// must makes f infallible by requiring that it does not return an error. +func must[I, T any](t *testing.T, f func(I) (T, error), i I) T { + v, err := f(i) + require.NoError(t, err) + return v +} + +func TestPartialCreate(t *testing.T) { + t.Parallel() + s, err := p.RawServer("partial", "1.0.0", partial.Provider())(nil) + require.NoError(t, err) + + _, err = s.Create(context.Background(), &pulumirpc.CreateRequest{ + Urn: "urn:pulumi:dev::dev::partial:index:Partial::t1", + Properties: must(t, structpb.NewStruct, map[string]any{ + "s": "for-create", + }), + }) + + rpcError, ok := rpcerror.FromError(err) + if assert.True(t, ok) { + for _, d := range rpcError.Details() { + initFailed, ok := d.(*pulumirpc.ErrorResourceInitFailed) + if !ok { + continue + } + assert.Equal(t, (&pulumirpc.ErrorResourceInitFailed{ + Id: "id", + Properties: must(t, structpb.NewStruct, map[string]any{ + "s": "+for-create", + "out": "partial-create", + }), + Reasons: []string{"create: failed to fully init"}, + }).String(), initFailed.String()) + } + } +} + +func TestPartialUpdate(t *testing.T) { + t.Parallel() + s, err := p.RawServer("partial", "1.0.0", partial.Provider())(nil) + require.NoError(t, err) + + _, err = s.Update(context.Background(), &pulumirpc.UpdateRequest{ + Id: "update-id", + Urn: "urn:pulumi:dev::dev::partial:index:Partial::t1", + News: must(t, structpb.NewStruct, map[string]any{ + "s": "for-update", + }), + Olds: must(t, structpb.NewStruct, map[string]any{ + "s": "+for-create", + "out": "partial-init", + }), + }) + + rpcError, ok := rpcerror.FromError(err) + if assert.True(t, ok) { + for _, d := range rpcError.Details() { + initFailed, ok := d.(*pulumirpc.ErrorResourceInitFailed) + if !ok { + continue + } + assert.Equal(t, (&pulumirpc.ErrorResourceInitFailed{ + Id: "update-id", + Properties: must(t, structpb.NewStruct, map[string]any{ + "s": "from-update", + "out": "partial-update", + }), + Reasons: []string{"update: failed to continue init"}, + }).String(), initFailed.String()) + } + } +} +func TestPartialRead(t *testing.T) { + t.Parallel() + s, err := p.RawServer("partial", "1.0.0", partial.Provider())(nil) + require.NoError(t, err) + + _, err = s.Read(context.Background(), &pulumirpc.ReadRequest{ + Id: "read-id", + Urn: "urn:pulumi:dev::dev::partial:index:Partial::t1", + Inputs: must(t, structpb.NewStruct, map[string]any{ + "s": "for-read", + }), + Properties: must(t, structpb.NewStruct, map[string]any{ + "s": "from-update", + "out": "state-for-read", + }), + }) + + rpcError, ok := rpcerror.FromError(err) + if assert.True(t, ok) { + for _, d := range rpcError.Details() { + initFailed, ok := d.(*pulumirpc.ErrorResourceInitFailed) + if !ok { + continue + } + assert.Equal(t, (&pulumirpc.ErrorResourceInitFailed{ + Id: "from-read-id", + Inputs: must(t, structpb.NewStruct, map[string]any{ + "s": "from-read-input", + }), + Properties: must(t, structpb.NewStruct, map[string]any{ + "s": "s-state-from-read", + "out": "out-state-from-read", + }), + Reasons: []string{"read: failed to finish read"}, + }).String(), initFailed.String()) + } + } +}