Skip to content

Commit

Permalink
Support partial resource initialization errors
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
iwahbe committed Apr 5, 2024
1 parent a8ea098 commit c1b4da4
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 10 deletions.
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
}
92 changes: 83 additions & 9 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,33 @@ 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.InternalErrorf("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)
err = nil
} 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 +952,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
Expand All @@ -954,10 +982,32 @@ func (rc *derivedResourceController[R, I, O]) Read(ctx p.Context, req p.ReadRequ
Inputs: req.Inputs,
}, nil
}
id, inputs, state, err := read.Read(ctx, req.ID, inputs, state)
if err != nil {
return p.ReadResponse{}, err
id, inputs, state, readErr := read.Read(ctx, req.ID, inputs, state)
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.InternalErrorf("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)
err = nil
} else if readErr != nil {
return p.ReadResponse{}, readErr
}

i, err := inputEncoder.Encode(inputs)
if err != nil {
return p.ReadResponse{}, err
Expand All @@ -974,7 +1024,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 +1048,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.InternalErrorf("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 InternalError struct {
Inner error
}

func InternalErrorf(msg string, a ...any) error {
return InternalError{fmt.Errorf(msg, a...)}
}

func (err InternalError) 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
}
63 changes: 62 additions & 1 deletion provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit c1b4da4

Please sign in to comment.