diff --git a/pkg/engine/lifeycletest/pulumi_test.go b/pkg/engine/lifeycletest/pulumi_test.go index 616b86e6e..0e08b63ce 100644 --- a/pkg/engine/lifeycletest/pulumi_test.go +++ b/pkg/engine/lifeycletest/pulumi_test.go @@ -3379,3 +3379,50 @@ func TestPlannedUpdateChangedStack(t *testing.T) { }) assert.Equal(t, expected, snap.Resources[1].Outputs) } + +func TestPlannedOutputChanges(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, + preview bool) (resource.ID, resource.PropertyMap, resource.Status, error) { + return resource.ID("created-id-" + urn.Name()), news, resource.StatusOK, nil + }, + }, nil + }), + } + + outs := resource.NewPropertyMapFromMap(map[string]interface{}{ + "foo": "bar", + "frob": "baz", + }) + program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error { + urn, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, deploytest.ResourceOptions{}) + assert.NoError(t, err) + + monitor.RegisterResourceOutputs(urn, outs) + + return nil + }) + host := deploytest.NewPluginHost(nil, nil, program, loaders...) + + p := &TestPlan{ + Options: UpdateOptions{Host: host}, + } + + project := p.GetProject() + + // Create an initial plan to create resA and the outputs + plan, res := TestOp(Update).Plan(project, p.GetTarget(t, nil), p.Options, p.BackendClient, nil) + assert.NotNil(t, plan) + assert.Nil(t, res) + + // Now change the runtime to not return property "frob", this should error + outs = resource.NewPropertyMapFromMap(map[string]interface{}{ + "foo": "bar", + }) + p.Options.Plan = ClonePlan(t, plan) + snap, res := TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient, nil) + assert.NotNil(t, snap) + assert.NotNil(t, res) +} diff --git a/pkg/resource/deploy/deployment_executor.go b/pkg/resource/deploy/deployment_executor.go index a52df7d4e..195624a76 100644 --- a/pkg/resource/deploy/deployment_executor.go +++ b/pkg/resource/deploy/deployment_executor.go @@ -385,8 +385,7 @@ func (ex *deploymentExecutor) handleSingleEvent(event SourceEvent) result.Result steps, res = ex.stepGen.GenerateReadSteps(e) case RegisterResourceOutputsEvent: logging.V(4).Infof("deploymentExecutor.handleSingleEvent(...): received register resource outputs") - ex.stepExec.ExecuteRegisterResourceOutputs(e) - return nil + return ex.stepExec.ExecuteRegisterResourceOutputs(e) } if res != nil { diff --git a/pkg/resource/deploy/step_executor.go b/pkg/resource/deploy/step_executor.go index 479d67d49..bf0f2b671 100644 --- a/pkg/resource/deploy/step_executor.go +++ b/pkg/resource/deploy/step_executor.go @@ -25,6 +25,7 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/common/resource" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/result" ) const ( @@ -147,7 +148,7 @@ func (se *stepExecutor) ExecuteParallel(antichain antichain) completionToken { } // ExecuteRegisterResourceOutputs services a RegisterResourceOutputsEvent synchronously on the calling goroutine. -func (se *stepExecutor) ExecuteRegisterResourceOutputs(e RegisterResourceOutputsEvent) { +func (se *stepExecutor) ExecuteRegisterResourceOutputs(e RegisterResourceOutputsEvent) result.Result { // Look up the final state in the pending registration list. urn := e.URN() value, has := se.pendingNews.Load(urn) @@ -160,7 +161,27 @@ func (se *stepExecutor) ExecuteRegisterResourceOutputs(e RegisterResourceOutputs outs := e.Outputs() se.log(synchronousWorkerID, "registered resource outputs %s: old=#%d, new=#%d", urn, len(reg.New().Outputs), len(outs)) - reg.New().Outputs = e.Outputs() + reg.New().Outputs = outs + + // If a plan is present check that these outputs match what we recorded before + if se.deployment.plan != nil { + resourcePlan, ok := se.deployment.plan[urn] + if !ok { + return result.FromError(fmt.Errorf("no plan for resource %v", urn)) + } else { + if diffs, has := resourcePlan.Outputs.DiffIncludeUnknowns(outs); has { + return result.FromError(fmt.Errorf("resource violates plan: %v", diffs)) + } + } + } + + // Save these new outputs to the plan + if resourcePlan, ok := se.deployment.newPlans.get(urn); ok { + resourcePlan.Outputs = outs + } else { + return result.FromError(fmt.Errorf("this should already have a plan from when we called register resources")) + } + // If there is an event subscription for finishing the resource, execute them. if e := se.opts.Events; e != nil { if eventerr := e.OnResourceOutputs(reg); eventerr != nil { @@ -176,10 +197,11 @@ func (se *stepExecutor) ExecuteRegisterResourceOutputs(e RegisterResourceOutputs diagMsg := diag.RawMessage(reg.URN(), outErr.Error()) se.deployment.Diag().Errorf(diagMsg) se.cancelDueToError() - return + return nil } } e.Done() + return nil } // Errored returns whether or not this step executor saw a step whose execution ended in failure.