Skip to content

Commit

Permalink
Merge #11475
Browse files Browse the repository at this point in the history
11475: Handle replacements of resources marked for deletion 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 required getting state into a bit of unusual failure mode, but clearly people are hitting this (see #11391 and #11464).

If we replace a resource with create before delete semantics, and then fail to delete it we end up with two copies of the resource in state, but one copy will have the "delete" flag set.

If we then manage to trigger another resource to replace with delete before replace semantics that has the first resource as a dependent we would hit an assertion about Delete and PendingReplace both being set the same, if you just removed that asset then state management got confused and left the first resource in the state file even after it had really been deleted.

So now we track if we've seen a pendingDelete for a resource via the state pointer not the URN (so we can handle the multiple state objects) and don't trigger a DeleteReplacement, but a plain Delete if the resource is already flagged to Delete.

Fixes #11391

## 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. -->


Co-authored-by: Fraser Waters <fraser@pulumi.com>
  • Loading branch information
bors[bot] and Frassle committed Dec 6, 2022
2 parents 86e7d56 + 0ed78b8 commit ef4c7e3
Show file tree
Hide file tree
Showing 3 changed files with 182 additions and 2 deletions.
@@ -0,0 +1,4 @@
changes:
- type: fix
scope: engine
description: Fix an assert for resources being replaced but also pending deletion.
170 changes: 170 additions & 0 deletions pkg/engine/lifecycletest/pulumi_test.go
Expand Up @@ -4437,3 +4437,173 @@ func TestDuplicatesDueToAliases(t *testing.T) {
// Because we made the B first that's what should end up in the state file
assert.Equal(t, resource.URN("urn:pulumi:test::test::pkgA:m:typA::resB"), snap.Resources[1].URN)
}

func TestPendingDeleteReplacement(t *testing.T) {
// Test for https://github.com/pulumi/pulumi/issues/11391, check that if we
// try to replace a resource via delete before replace, but fail to delete
// it, then rerun that we don't error.

t.Parallel()

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

failDeletionOfTypB := true

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

id := resource.ID("")
if !preview {
id = resource.ID(fmt.Sprintf("%d", cloudID))
cloudID = cloudID + 1
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)
}
}
}

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

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: true,
}, 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
}
if !olds["frob"].DeepEquals(news["frob"]) {
return plugin.DiffResult{
Changes: plugin.DiffSome,
ReplaceKeys: []resource.PropertyKey{"frob"},
DetailedDiff: map[string]plugin.PropertyDiff{
"frob": {
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),
}

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

_, _, _, err = monitor.RegisterResource("pkgA:m:typB", "resB", true, deploytest.ResourceOptions{
Inputs: resource.NewPropertyMapFromMap(map[string]interface{}{
"parent": idA,
"frob": inB,
}),
PropertyDeps: map[resource.PropertyKey][]resource.URN{
"parent": {urnA},
},
Dependencies: []resource.URN{urnA},
})
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 B but fail to delete it
inB = "inactive"
snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient, nil)
// Assert that this fails, we should have two B's one marked to 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:typB"))
assert.False(t, snap.Resources[2].Delete)
assert.Equal(t, snap.Resources[3].Type, tokens.Type("pkgA:m:typB"))
assert.True(t, snap.Resources[3].Delete)

// Now trigger a replacment of A, which will also trigger B to replace
insA = resource.NewPropertyMapFromMap(map[string]interface{}{
"foo": "baz",
})
failDeletionOfTypB = false
snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient, nil)
// Assert this is ok, we should have just one A and B
assert.Nil(t, res)
assert.NotNil(t, snap)
assert.Len(t, snap.Resources, 3)
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:typB"))
assert.False(t, snap.Resources[2].Delete)
}
10 changes: 8 additions & 2 deletions pkg/resource/deploy/step_generator.go
Expand Up @@ -973,7 +973,7 @@ func (sg *stepGenerator) generateStepsFromDiff(
dependentResource := toReplace[i].res

// If we already deleted this resource due to some other DBR, don't do it again.
if sg.deletes[dependentResource.URN] {
if sg.pendingDeletes[dependentResource] {
continue
}

Expand All @@ -992,10 +992,16 @@ func (sg *stepGenerator) generateStepsFromDiff(
logging.V(7).Infof("Planner decided to delete '%v' due to dependence on condemned resource '%v'",
dependentResource.URN, urn)

steps = append(steps, NewDeleteReplacementStep(sg.deployment, sg.deletes, dependentResource, true))
// This resource might already be pending-delete
if dependentResource.Delete {
steps = append(steps, NewDeleteStep(sg.deployment, sg.deletes, dependentResource))
} else {
steps = append(steps, NewDeleteReplacementStep(sg.deployment, sg.deletes, dependentResource, true))
}
// Mark the condemned resource as deleted. We won't know until later in the deployment whether
// or not we're going to be replacing this resource.
sg.deletes[dependentResource.URN] = true
sg.pendingDeletes[dependentResource] = true
}
}

Expand Down

0 comments on commit ef4c7e3

Please sign in to comment.