Skip to content

Commit

Permalink
Merge #11009 #11027
Browse files Browse the repository at this point in the history
11009: Fix update plans with dependent replacements r=Frassle a=Frassle

<!--- 
Thanks so much for your contribution! If this is your first time contributing, please ensure that you have read the [CONTRIBUTING](https://github.com/pulumi/pulumi/blob/master/CONTRIBUTING.md) documentation.
-->

# Description

<!--- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. -->

We weren't correctly handling the case where a resource was marked for deletion due to one of it's dependencies being deleted. We would add an entry to it's "Ops" list, but then overwrite that "Ops" list when we came to generate the recreation step.

Fixes #10924

## Checklist

<!--- Please provide details if the checkbox below is to be left unchecked. -->
- [x] I have added tests that prove my fix is effective or that my feature works
<!--- 
User-facing changes require a CHANGELOG entry.
-->
- [x] I have run `make changelog` and committed the `changelog/pending/<file>` documenting my change
<!--
If the change(s) in this PR is a modification of an existing call to the Pulumi Service,
then the service should honor older versions of the CLI where this change would not exist.
You must then bump the API version in /pkg/backend/httpstate/client/api.go, as well as add
it to the service.
-->
- [ ] Yes, there are changes in this PR that warrants bumping the Pulumi Service API version
  <!-- `@Pulumi` employees: If yes, you must submit corresponding changes in the service repo. -->


11027: Do not execute pending deletes at the start of deployment r=Frassle a=Frassle

<!--- 
Thanks so much for your contribution! If this is your first time contributing, please ensure that you have read the [CONTRIBUTING](https://github.com/pulumi/pulumi/blob/master/CONTRIBUTING.md) documentation.
-->

# Description

<!--- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. -->

This removes all the handling of pending deletes from the start of deployments. Instead we allow resources to just be deleted as they usually would at the end of the deployment.

There's a big comment in TestPendingDeleteOrder that explains the order of operations in a succesful run and how that order differs if we try and do pending deletes up-front.

Fixes #2948

## Checklist

<!--- Please provide details if the checkbox below is to be left unchecked. -->
- [x] I have added tests that prove my fix is effective or that my feature works
<!--- 
User-facing changes require a CHANGELOG entry.
-->
- [ ] I have run `make changelog` and committed the `changelog/pending/<file>` documenting my change
<!--
If the change(s) in this PR is a modification of an existing call to the Pulumi Service,
then the service should honor older versions of the CLI where this change would not exist.
You must then bump the API version in /pkg/backend/httpstate/client/api.go, as well as add
it to the service.
-->
- [ ] Yes, there are changes in this PR that warrants bumping the Pulumi Service API version
  <!-- `@Pulumi` employees: If yes, you must submit corresponding changes in the service repo. -->


Co-authored-by: Fraser Waters <fraser@pulumi.com>
  • Loading branch information
bors[bot] and Frassle committed Nov 1, 2022
3 parents 30aa374 + 1482df9 + a3128e5 commit 1ccfa9f
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 83 deletions.
4 changes: 4 additions & 0 deletions changelog/pending/20221013--engine--update-plan-dbr.yaml
@@ -0,0 +1,4 @@
changes:
- type: fix
scope: engine
description: Fix a bug in update plans handling resources being replaced due to other resources being deleted before replacement.
4 changes: 4 additions & 0 deletions changelog/pending/20221014--engine--pending-deletes.yaml
@@ -0,0 +1,4 @@
changes:
- type: fix
scope: engine
description: Pending deletes are no longer executed before everything else. This correctly handles dependencies for resource graphs that were partially deleted.
160 changes: 160 additions & 0 deletions pkg/engine/lifecycletest/pulumi_test.go
Expand Up @@ -3956,3 +3956,163 @@ func TestDefaultParents(t *testing.T) {
assert.Equal(t, snap.Resources[0].URN, snap.Resources[1].Parent)
assert.Equal(t, snap.Resources[0].URN, snap.Resources[2].Parent)
}

func TestPendingDeleteOrder(t *testing.T) {
// Test for https://github.com/pulumi/pulumi/issues/2948 Ensure that if we have resources A and B, and we
// go to replace A but then fail to replace B that we correctly handle everything in the same order when
// we retry the update.
//
// That is normally for this operation we would do the following:
// 1. Create new A
// 2. Create new B
// 3. Delete old B
// 4. Delete old A
// So if step 2 fails to create the new B we want to see:
// 1. Create new A
// 2. Create new B (fail)
// 1. Create new B
// 2. Delete old B
// 3. Delete old A
// Currently (and what #2948 tracks) is that the engine does the following:
// 1. Create new A
// 2. Create new B (fail)
// 3. Delete old A
// 1. Create new B
// 2. Delete old B
// That delete A fails because the delete B needs to happen first.

t.Parallel()

cloudState := map[resource.ID]resource.PropertyMap{}

failCreationOfTypB := false

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) {

if strings.Contains(string(urn), "typB") && failCreationOfTypB {
return "", nil, resource.StatusOK, fmt.Errorf("Could not create typB")
}

id := resource.ID(fmt.Sprintf("%d", len(cloudState)))
if !preview {
cloudState[id] = news
}
return id, news, resource.StatusOK, nil
},
DeleteF: func(urn resource.URN,
id resource.ID, olds resource.PropertyMap, timeout float64) (resource.Status, error) {
// Fail if anything in cloud state still points to us
for _, res := range cloudState {
for _, v := range res {
if v.IsString() && v.StringValue() == string(id) {
return resource.StatusOK, fmt.Errorf("Can not delete %s", id)
}
}
}

delete(cloudState, id)
return resource.StatusOK, nil
},
DiffF: func(urn resource.URN,
id resource.ID, olds, news resource.PropertyMap, ignoreChanges []string) (plugin.DiffResult, error) {
if strings.Contains(string(urn), "typA") {
if !olds["foo"].DeepEquals(news["foo"]) {
return plugin.DiffResult{
Changes: plugin.DiffSome,
ReplaceKeys: []resource.PropertyKey{"foo"},
DetailedDiff: map[string]plugin.PropertyDiff{
"foo": {
Kind: plugin.DiffUpdateReplace,
InputDiff: true,
},
},
DeleteBeforeReplace: false,
}, nil
}
}
if strings.Contains(string(urn), "typB") {
if !olds["parent"].DeepEquals(news["parent"]) {
return plugin.DiffResult{
Changes: plugin.DiffSome,
ReplaceKeys: []resource.PropertyKey{"parent"},
DetailedDiff: map[string]plugin.PropertyDiff{
"parent": {
Kind: plugin.DiffUpdateReplace,
InputDiff: true,
},
},
DeleteBeforeReplace: false,
}, nil
}
}

return plugin.DiffResult{}, nil
},
UpdateF: func(urn resource.URN,
id resource.ID, olds, news resource.PropertyMap, timeout float64,
ignoreChanges []string, preview bool) (resource.PropertyMap, resource.Status, error) {
assert.Fail(t, "Didn't expect update to be called")
return nil, resource.StatusOK, nil
},
}, nil
}, deploytest.WithoutGrpc),
}

ins := resource.NewPropertyMapFromMap(map[string]interface{}{
"foo": "bar",
})
program := deploytest.NewLanguageRuntime(func(info plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
_, idA, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, deploytest.ResourceOptions{
Inputs: ins,
})
assert.NoError(t, err)

_, _, _, err = monitor.RegisterResource("pkgA:m:typB", "resB", true, deploytest.ResourceOptions{
Inputs: resource.NewPropertyMapFromMap(map[string]interface{}{
"parent": idA,
}),
})
assert.NoError(t, err)

return nil
})
host := deploytest.NewPluginHost(nil, nil, program, loaders...)

p := &TestPlan{
Options: UpdateOptions{Host: host},
}

project := p.GetProject()

// Run an update to create the resources
snap, res := TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient, nil)
assert.Nil(t, res)
assert.NotNil(t, snap)
assert.Len(t, snap.Resources, 3)

// Trigger a replacement of A but fail to create B
failCreationOfTypB = true
ins = resource.NewPropertyMapFromMap(map[string]interface{}{
"foo": "baz",
})
snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient, nil)
// Assert that this fails, we should have two copies of A now, one new one and one old one pending delete
assert.NotNil(t, res)
assert.NotNil(t, snap)
assert.Len(t, snap.Resources, 4)
assert.Equal(t, snap.Resources[1].Type, tokens.Type("pkgA:m:typA"))
assert.False(t, snap.Resources[1].Delete)
assert.Equal(t, snap.Resources[2].Type, tokens.Type("pkgA:m:typA"))
assert.True(t, snap.Resources[2].Delete)

// Now allow B to create and try again
failCreationOfTypB = false
snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient, nil)
assert.Nil(t, res)
assert.NotNil(t, snap)
assert.Len(t, snap.Resources, 3)
}
96 changes: 96 additions & 0 deletions pkg/engine/lifecycletest/update_plan_test.go
Expand Up @@ -17,6 +17,7 @@ package lifecycletest

import (
"regexp"
"strings"
"testing"

"github.com/blang/semver"
Expand Down Expand Up @@ -1507,3 +1508,98 @@ func TestProviderDeterministicPreview(t *testing.T) {
assert.NotEqual(t, expectedName, snap.Resources[1].Inputs["name"])
assert.NotEqual(t, expectedName, snap.Resources[1].Outputs["name"])
}

func TestPlannedUpdateWithDependentDelete(t *testing.T) {
t.Parallel()

var diffResult *plugin.DiffResult

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
},
UpdateF: func(urn resource.URN, id resource.ID, olds, news resource.PropertyMap, timeout float64,
ignoreChanges []string, preview bool) (resource.PropertyMap, resource.Status, error) {
return news, resource.StatusOK, nil
},
CheckF: func(urn resource.URN,
olds, news resource.PropertyMap, _ []byte) (resource.PropertyMap, []plugin.CheckFailure, error) {
return news, nil, nil
},
DiffF: func(urn resource.URN,
id resource.ID, olds, news resource.PropertyMap, ignoreChanges []string) (plugin.DiffResult, error) {
if strings.Contains(string(urn), "resA") || strings.Contains(string(urn), "resB") {
assert.NotNil(t, diffResult, "Diff was called but diffResult wasn't set")
return *diffResult, nil
}
return plugin.DiffResult{}, nil
},
}, nil
}),
}

var ins resource.PropertyMap
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
resA, _, outs, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, deploytest.ResourceOptions{
Inputs: ins,
})
assert.NoError(t, err)

_, _, _, err = monitor.RegisterResource("pkgA:m:typB", "resB", true, deploytest.ResourceOptions{
Inputs: outs,
Dependencies: []resource.URN{resA},
})
assert.NoError(t, err)

return nil
})
host := deploytest.NewPluginHost(nil, nil, program, loaders...)

p := &TestPlan{
Options: UpdateOptions{Host: host, GeneratePlan: true},
}

project := p.GetProject()

// Create an initial ResA and resB
ins = resource.NewPropertyMapFromMap(map[string]interface{}{
"foo": "bar",
"zed": "baz",
})
snap, res := TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient, nil)
assert.NotNil(t, snap)
assert.Nil(t, res)

// Update the input and mark it as a replace, check that both A and B are marked as replacements
ins = resource.NewPropertyMapFromMap(map[string]interface{}{
"foo": "frob",
"zed": "baz",
})
diffResult = &plugin.DiffResult{
Changes: plugin.DiffSome,
ReplaceKeys: []resource.PropertyKey{"foo"},
StableKeys: []resource.PropertyKey{"zed"},
DetailedDiff: map[string]plugin.PropertyDiff{
"foo": {
Kind: plugin.DiffUpdateReplace,
InputDiff: true,
},
},
DeleteBeforeReplace: true,
}
plan, res := TestOp(Update).Plan(project, p.GetTarget(t, snap), p.Options, p.BackendClient, nil)
assert.NotNil(t, plan)
assert.Nil(t, res)

assert.Equal(t, 3, len(plan.ResourcePlans["urn:pulumi:test::test::pkgA:m:typA::resA"].Ops))
assert.Equal(t, 3, len(plan.ResourcePlans["urn:pulumi:test::test::pkgA:m:typB::resB"].Ops))

// Now try and run with the plan
p.Options.Plan = plan.Clone()
snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient, nil)
assert.NotNil(t, snap)
assert.Nil(t, res)
}
4 changes: 4 additions & 0 deletions pkg/resource/deploy/deployment.go
Expand Up @@ -249,6 +249,10 @@ func (m *resourcePlans) set(urn resource.URN, plan *ResourcePlan) {
m.m.Lock()
defer m.m.Unlock()

if _, ok := m.plans.ResourcePlans[urn]; ok {
panic(fmt.Sprintf("tried to set resource plan for %s but it's already been set", urn))
}

m.plans.ResourcePlans[urn] = plan
}

Expand Down
50 changes: 0 additions & 50 deletions pkg/resource/deploy/deployment_executor.go
Expand Up @@ -186,11 +186,6 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
// Set up a step generator for this deployment.
ex.stepGen = newStepGenerator(ex.deployment, opts, updateTargetsOpt, replaceTargetsOpt)

// Retire any pending deletes that are currently present in this deployment.
if res := ex.retirePendingDeletes(callerCtx, opts, preview); res != nil {
return nil, res
}

// Derive a cancellable context for this deployment. We will only cancel this context if some piece of the
// deployment's execution fails.
ctx, cancel := context.WithCancel(callerCtx)
Expand Down Expand Up @@ -438,51 +433,6 @@ func (ex *deploymentExecutor) handleSingleEvent(event SourceEvent) result.Result
return nil
}

// retirePendingDeletes deletes all resources that are pending deletion. Run before the start of a deployment, this pass
// ensures that the engine never sees any resources that are pending deletion from a previous deployment.
//
// retirePendingDeletes re-uses the deployment executor's step generator but uses its own step executor.
func (ex *deploymentExecutor) retirePendingDeletes(callerCtx context.Context, opts Options,
preview bool) result.Result {

contract.Require(ex.stepGen != nil, "ex.stepGen != nil")
steps := ex.stepGen.GeneratePendingDeletes()
if len(steps) == 0 {
logging.V(4).Infoln("deploymentExecutor.retirePendingDeletes(...): no pending deletions")
return nil
}

logging.V(4).Infof("deploymentExecutor.retirePendingDeletes(...): executing %d steps", len(steps))
ctx, cancel := context.WithCancel(callerCtx)

stepExec := newStepExecutor(ctx, cancel, ex.deployment, opts, preview, false)
antichains := ex.stepGen.ScheduleDeletes(steps)
// Submit the deletes for execution and wait for them all to retire.
for _, antichain := range antichains {
for _, step := range antichain {
ex.deployment.Ctx().StatusDiag.Infof(diag.RawMessage(step.URN(), "completing deletion from previous update"))
}

tok := stepExec.ExecuteParallel(antichain)
tok.Wait(ctx)
}

stepExec.SignalCompletion()
stepExec.WaitForCompletion()

// Like Refresh, we use the presence of an error in the caller's context to detect whether or not we have been
// cancelled.
canceled := callerCtx.Err() != nil
if stepExec.Errored() {
ex.reportExecResult("failed", preview)
return result.Bail()
} else if canceled {
ex.reportExecResult("canceled", preview)
return result.Bail()
}
return nil
}

// import imports a list of resources into a stack.
func (ex *deploymentExecutor) importResources(
callerCtx context.Context,
Expand Down

0 comments on commit 1ccfa9f

Please sign in to comment.