Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support partial resource initialization errors #210

Merged
merged 2 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
**/sdk

examples/**/pulumi-resource-*
examples/**/schema-*.json

/.vscode

Expand Down
79 changes: 79 additions & 0 deletions infer/errors.go
Original file line number Diff line number Diff line change
@@ -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
}
86 changes: 79 additions & 7 deletions infer/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package infer

import (
"errors"
"fmt"
"reflect"

Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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) {
iwahbe marked this conversation as resolved.
Show resolved Hide resolved
// 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)
Expand All @@ -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
iwahbe marked this conversation as resolved.
Show resolved Hide resolved
}

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
Expand All @@ -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) {
iwahbe marked this conversation as resolved.
Show resolved Hide resolved
// 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
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
37 changes: 37 additions & 0 deletions internal/errors.go
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 63 additions & 2 deletions provider.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -17,6 +17,7 @@ package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading