Add new 'pulumi state' command for editing state (#2024)

* Add new 'pulumi state' command for editing state

This commit adds 'pulumi state unprotect' and 'pulumi state delete', two
commands that can be used to unprotect and delete resources from a
stack's state, respectively.

* Simplify LocateResource

* CR: Print yellow 'warning' before editing state

* Lots of CR feedback

* CR: Only delete protected resources when asked with --force
This commit is contained in:
Sean Gillespie 2018-10-15 09:52:55 -07:00 committed by GitHub
parent a71db160e8
commit 730a929c2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 754 additions and 0 deletions

View file

@ -160,6 +160,7 @@ func NewPulumiCmd() *cobra.Command {
// - Advanced Commands:
cmd.AddCommand(newCancelCmd())
cmd.AddCommand(newRefreshCmd())
cmd.AddCommand(newStateCmd())
// - Other Commands:
cmd.AddCommand(newLogsCmd())
cmd.AddCommand(newPluginCmd())

175
cmd/state.go Normal file
View file

@ -0,0 +1,175 @@
// Copyright 2016-2018, 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 cmd
import (
"encoding/json"
"fmt"
"github.com/pulumi/pulumi/pkg/util/contract"
"github.com/pkg/errors"
"github.com/pulumi/pulumi/pkg/apitype"
"github.com/pulumi/pulumi/pkg/backend/display"
"github.com/pulumi/pulumi/pkg/diag/colors"
"github.com/pulumi/pulumi/pkg/resource"
"github.com/pulumi/pulumi/pkg/resource/deploy"
"github.com/pulumi/pulumi/pkg/resource/edit"
"github.com/pulumi/pulumi/pkg/resource/stack"
"github.com/pulumi/pulumi/pkg/util/cmdutil"
"github.com/spf13/cobra"
survey "gopkg.in/AlecAivazis/survey.v1"
surveycore "gopkg.in/AlecAivazis/survey.v1/core"
)
func newStateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "state",
Short: "Edit the current stack's state",
Long: `Edit the current stack's state
Subcommands of this command can be used to surgically edit parts of a stack's state. These can be useful when
troubleshooting a stack or when performing specific edits that otherwise would require editing the state file by hand.`,
Args: cmdutil.NoArgs,
}
cmd.AddCommand(newStateDeleteCommand())
cmd.AddCommand(newStateUnprotectCommand())
return cmd
}
// locateStackResource attempts to find a unique resource associated with the given URN in the given snapshot. If the
// given URN is ambiguous and this is an interactive terminal, it prompts the user to select one of the resources in
// the list of resources with identical URNs to operate upon.
func locateStackResource(opts display.Options, snap *deploy.Snapshot, urn resource.URN) (*resource.State, error) {
candidateResources := edit.LocateResource(snap, urn)
switch {
case len(candidateResources) == 0: // resource was not found
return nil, errors.Errorf("No such resource %q exists in the current state", urn)
case len(candidateResources) == 1: // resource was unambiguously found
return candidateResources[0], nil
}
// If there exist multiple resources that have the requested URN, prompt the user to select one if we're running
// interactively. If we're not, early exit.
if !cmdutil.Interactive() {
errorMsg := "Resource URN ambiguously referred to multiple resources. Did you mean:\n"
for _, res := range candidateResources {
errorMsg += fmt.Sprintf(" %s\n", res.ID)
}
return nil, errors.New(errorMsg)
}
// Note: this is done to adhere to the same color scheme as the `pulumi new` picker, which also does this.
surveycore.DisableColor = true
surveycore.QuestionIcon = ""
surveycore.SelectFocusIcon = opts.Color.Colorize(colors.BrightGreen + ">" + colors.Reset)
prompt := "Multiple resources with the given URN exist, please select the one to edit:"
prompt = opts.Color.Colorize(colors.SpecPrompt + prompt + colors.Reset)
var options []string
optionMap := make(map[string]*resource.State)
for _, ambiguousResource := range candidateResources {
// Prompt the user to select from a list of IDs, since these resources are known to all have the same URN.
message := fmt.Sprintf("%q", ambiguousResource.ID)
if ambiguousResource.Protect {
message += " (Protected)"
}
if ambiguousResource.Delete {
message += " (Pending Deletion)"
}
options = append(options, message)
optionMap[message] = ambiguousResource
}
var option string
if err := survey.AskOne(&survey.Select{
Message: prompt,
Options: options,
PageSize: len(options),
}, &option, nil); err != nil {
return nil, errors.New("no resource selected")
}
return optionMap[option], nil
}
// runStateEdit runs the given state edit function on a resource with the given URN in the current stack.
func runStateEdit(urn resource.URN, operation edit.OperationFunc) error {
return runTotalStateEdit(func(opts display.Options, snap *deploy.Snapshot) error {
res, err := locateStackResource(opts, snap, urn)
if err != nil {
return err
}
return operation(snap, res)
})
}
// runTotalStateEdit runs a snapshot-mutating function on the entirity of the current stack's snapshot. Before mutating
// the snapshot, the user is prompted for confirmation if the current session is interactive.
func runTotalStateEdit(operation func(opts display.Options, snap *deploy.Snapshot) error) error {
opts := display.Options{
Color: cmdutil.GetGlobalColorization(),
}
s, err := requireCurrentStack(true, opts, true /*setCurrent*/)
if err != nil {
return err
}
snap, err := s.Snapshot(commandContext())
if err != nil {
return err
}
if cmdutil.Interactive() {
confirm := false
surveycore.DisableColor = true
surveycore.QuestionIcon = ""
surveycore.SelectFocusIcon = opts.Color.Colorize(colors.BrightGreen + ">" + colors.Reset)
prompt := opts.Color.Colorize(colors.Yellow + "warning" + colors.Reset + ": ")
prompt += "This command will edit your stack's state directly. Confirm?"
if err = survey.AskOne(&survey.Confirm{
Message: prompt,
}, &confirm, nil); err != nil || !confirm {
return errors.New("confirmation declined")
}
}
// The `operation` callback will mutate `snap` in-place. In order to validate the correctness of the transformation
// that we are doing here, we verify the integrity of the snapshot before the mutation. If the snapshot was valid
// before we mutated it, we'll assert that we didn't make it invalid by mutating it.
stackIsAlreadyHosed := snap.VerifyIntegrity() != nil
if err = operation(opts, snap); err != nil {
return err
}
// If the stack is already broken, don't bother verifying the integrity here.
if !stackIsAlreadyHosed {
contract.AssertNoErrorf(snap.VerifyIntegrity(), "state edit produced an invalid snapshot")
}
// Once we've mutated the snapshot, import it back into the backend so that it can be persisted.
bytes, err := json.Marshal(stack.SerializeDeployment(snap))
if err != nil {
return err
}
dep := apitype.UntypedDeployment{
Version: apitype.DeploymentSchemaVersionCurrent,
Deployment: bytes,
}
return s.ImportDeployment(commandContext(), &dep)
}

86
cmd/state_delete.go Normal file
View file

@ -0,0 +1,86 @@
// Copyright 2016-2018, 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 cmd
import (
"fmt"
"github.com/pulumi/pulumi/pkg/diag"
"github.com/pulumi/pulumi/pkg/resource"
"github.com/pulumi/pulumi/pkg/resource/deploy"
"github.com/pulumi/pulumi/pkg/resource/edit"
"github.com/pulumi/pulumi/pkg/util/cmdutil"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var force bool // Force deletion of protected resources
func newStateDeleteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: "Deletes a resource from a stack's state",
Long: `Deletes a resource from a stack's state
This command deletes a resource from a stack's state, as long as it is safe to do so. Resources can't be deleted if
there exist other resources that depend on it or are parented to it. Protected resources will not be deleted unless
it is specifically requested using the --force flag.`,
Args: cmdutil.ExactArgs(1),
Run: cmdutil.RunFunc(func(cmd *cobra.Command, args []string) error {
urn := resource.URN(args[0])
err := runStateEdit(urn, doDeletion)
if err != nil {
switch e := err.(type) {
case edit.ResourceHasDependenciesError:
message := "This resource can't be safely deleted because the following resources depend on it:\n"
for _, dependentResource := range e.Dependencies {
depUrn := dependentResource.URN
message += fmt.Sprintf(" * %-15q (%s)\n", depUrn.Name(), depUrn)
}
message += "\nDelete those resources first before deleting this one."
return errors.New(message)
case edit.ResourceProtectedError:
return errors.New(
"This resource can't be safely deleted because it is protected. " +
"Re-run this command with --force to force deletion")
default:
return err
}
}
fmt.Println("Resource deleted successfully")
return nil
}),
}
cmd.Flags().BoolVar(&force, "force", false, "Force deletion of protected resources")
return cmd
}
// doDeletion implements edit.OperationFunc and deletes a resource from the snapshot. If the `force` flag is present,
// doDeletion will unprotect the resource before deleting it.
func doDeletion(snap *deploy.Snapshot, res *resource.State) error {
if !force {
return edit.DeleteResource(snap, res)
}
if res.Protect {
cmdutil.Diag().Warningf(diag.RawMessage("" /*urn*/, "deleting protected resource due to presence of --force"))
res.Protect = false
}
return edit.DeleteResource(snap, res)
}

84
cmd/state_unprotect.go Normal file
View file

@ -0,0 +1,84 @@
// Copyright 2016-2018, 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 cmd
import (
"fmt"
"github.com/pulumi/pulumi/pkg/util/contract"
"github.com/pkg/errors"
"github.com/pulumi/pulumi/pkg/backend/display"
"github.com/pulumi/pulumi/pkg/resource/deploy"
"github.com/pulumi/pulumi/pkg/resource"
"github.com/pulumi/pulumi/pkg/resource/edit"
"github.com/pulumi/pulumi/pkg/util/cmdutil"
"github.com/spf13/cobra"
)
func newStateUnprotectCommand() *cobra.Command {
var unprotectAll bool
cmd := &cobra.Command{
Use: "unprotect",
Short: "Unprotect resources in a stack's state",
Long: `Unprotect resource in a stack's state
This command clears the 'protect' bit on one or more resources, allowing those resources to be deleted.`,
Args: cmdutil.MaximumNArgs(1),
Run: cmdutil.RunFunc(func(cmd *cobra.Command, args []string) error {
if unprotectAll {
return unprotectAllResources()
}
if len(args) != 1 {
return errors.New("must provide a URN corresponding to a resource")
}
urn := resource.URN(args[0])
return unprotectResource(urn)
}),
}
cmd.Flags().BoolVar(&unprotectAll, "all", false, "Unprotect all resources in the checkpoint")
return cmd
}
func unprotectAllResources() error {
err := runTotalStateEdit(func(_ display.Options, snap *deploy.Snapshot) error {
for _, res := range snap.Resources {
err := edit.UnprotectResource(snap, res)
contract.AssertNoError(err)
}
return nil
})
if err != nil {
return err
}
fmt.Println("All resources successfully unprotected")
return nil
}
func unprotectResource(urn resource.URN) error {
err := runStateEdit(urn, edit.UnprotectResource)
if err != nil {
return err
}
fmt.Println("Resource successfully unprotected")
return nil
}

17
pkg/resource/edit/doc.go Normal file
View file

@ -0,0 +1,17 @@
// Copyright 2016-2018, 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 edit contains functions suitable for editing a snapshot in-place. It is designed to be used by higher-level
// tools that present a means for users to surgically edit their state.
package edit

View file

@ -0,0 +1,41 @@
// Copyright 2016-2018, 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 edit
import (
"fmt"
"github.com/pulumi/pulumi/pkg/resource"
)
// ResourceHasDependenciesError is returned by DeleteResource if a resource can't be deleted due to the presence of
// resources that depend directly or indirectly upon it.
type ResourceHasDependenciesError struct {
Condemned *resource.State
Dependencies []*resource.State
}
func (r ResourceHasDependenciesError) Error() string {
return fmt.Sprintf("Can't delete resource %q due to dependent resources", r.Condemned.URN)
}
// ResourceProtectedError is returned by DeleteResource if a resource is protected.
type ResourceProtectedError struct {
Condemned *resource.State
}
func (ResourceProtectedError) Error() string {
return "Can't delete protected resource"
}

View file

@ -0,0 +1,90 @@
// Copyright 2016-2018, 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 edit
import (
"github.com/pulumi/pulumi/pkg/resource"
"github.com/pulumi/pulumi/pkg/resource/deploy"
"github.com/pulumi/pulumi/pkg/resource/graph"
"github.com/pulumi/pulumi/pkg/util/contract"
)
// OperationFunc is the type of functions that edit resources within a snapshot. The edits are made in-place to the
// given snapshot and pertain to the specific passed-in resource.
type OperationFunc func(*deploy.Snapshot, *resource.State) error
// DeleteResource deletes a given resource from the snapshot, if it is possible to do so. A resource can only be deleted
// from a stack if there do not exist any resources that depend on it or descend from it. If such a resource does exist,
// DeleteResource will return an error instance of `ResourceHasDependenciesError`.
func DeleteResource(snapshot *deploy.Snapshot, condemnedRes *resource.State) error {
contract.Require(snapshot != nil, "snapshot")
contract.Require(condemnedRes != nil, "state")
if condemnedRes.Protect {
return ResourceProtectedError{condemnedRes}
}
dg := graph.NewDependencyGraph(snapshot.Resources)
dependencies := dg.DependingOn(condemnedRes)
if len(dependencies) != 0 {
return ResourceHasDependenciesError{Condemned: condemnedRes, Dependencies: dependencies}
}
// If there are no resources that depend on condemnedRes, iterate through the snapshot and keep everything that's
// not condemnedRes.
var newSnapshot []*resource.State
var children []*resource.State
for _, res := range snapshot.Resources {
// While iterating, keep track of the set of resources that are parented to our condemned resource. We'll only
// actually perform the deletion if this set is empty, otherwise it is not legal to delete the resource.
if res.Parent == condemnedRes.URN {
children = append(children, res)
}
if res != condemnedRes {
newSnapshot = append(newSnapshot, res)
}
}
// If there exists a resource that is the child of condemnedRes, we can't delete it.
if len(children) != 0 {
return ResourceHasDependenciesError{Condemned: condemnedRes, Dependencies: children}
}
// Otherwise, we're good to go. Writing the new resource list into the snapshot persists the mutations that we have
// made above.
snapshot.Resources = newSnapshot
return nil
}
// UnprotectResource unprotects a resource.
func UnprotectResource(_ *deploy.Snapshot, res *resource.State) error {
res.Protect = false
return nil
}
// LocateResource returns all resources in the given shapshot that have the given URN.
func LocateResource(snap *deploy.Snapshot, urn resource.URN) []*resource.State {
contract.Require(snap != nil, "snap")
var resources []*resource.State
for _, res := range snap.Resources {
if res.URN == urn {
resources = append(resources, res)
}
}
return resources
}

View file

@ -0,0 +1,260 @@
// Copyright 2016-2018, 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 edit
import (
"testing"
"time"
"github.com/pulumi/pulumi/pkg/resource"
"github.com/pulumi/pulumi/pkg/resource/deploy"
"github.com/pulumi/pulumi/pkg/resource/deploy/providers"
"github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/version"
"github.com/stretchr/testify/assert"
)
func NewResource(name string, provider *resource.State, deps ...resource.URN) *resource.State {
prov := ""
if provider != nil {
p, err := providers.NewReference(provider.URN, provider.ID)
if err != nil {
panic(err)
}
prov = p.String()
}
t := tokens.Type("a:b:c")
return &resource.State{
Type: t,
URN: resource.NewURN("test", "test", "", t, tokens.QName(name)),
Inputs: resource.PropertyMap{},
Outputs: resource.PropertyMap{},
Dependencies: deps,
Provider: prov,
}
}
func NewProviderResource(pkg, name, id string, deps ...resource.URN) *resource.State {
t := providers.MakeProviderType(tokens.Package(pkg))
return &resource.State{
Type: t,
URN: resource.NewURN("test", "test", "", t, tokens.QName(name)),
ID: resource.ID(id),
Inputs: resource.PropertyMap{},
Outputs: resource.PropertyMap{},
Dependencies: deps,
}
}
func NewSnapshot(resources []*resource.State) *deploy.Snapshot {
return deploy.NewSnapshot(deploy.Manifest{
Time: time.Now(),
Version: version.Version,
Plugins: nil,
}, resources, nil)
}
func TestDeletion(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
b := NewResource("b", pA)
c := NewResource("c", pA)
snap := NewSnapshot([]*resource.State{
pA,
a,
b,
c,
})
err := DeleteResource(snap, b)
assert.NoError(t, err)
assert.Len(t, snap.Resources, 3)
assert.Equal(t, []*resource.State{pA, a, c}, snap.Resources)
}
func TestFailedDeletionProviderDependency(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
b := NewResource("b", pA)
c := NewResource("c", pA)
snap := NewSnapshot([]*resource.State{
pA,
a,
b,
c,
})
err := DeleteResource(snap, pA)
assert.Error(t, err)
depErr, ok := err.(ResourceHasDependenciesError)
if !assert.True(t, ok) {
t.FailNow()
}
assert.Contains(t, depErr.Dependencies, a)
assert.Contains(t, depErr.Dependencies, b)
assert.Contains(t, depErr.Dependencies, c)
assert.Len(t, snap.Resources, 4)
assert.Equal(t, []*resource.State{pA, a, b, c}, snap.Resources)
}
func TestFailedDeletionRegularDependency(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
b := NewResource("b", pA, a.URN)
c := NewResource("c", pA)
snap := NewSnapshot([]*resource.State{
pA,
a,
b,
c,
})
err := DeleteResource(snap, a)
assert.Error(t, err)
depErr, ok := err.(ResourceHasDependenciesError)
if !assert.True(t, ok) {
t.FailNow()
}
assert.NotContains(t, depErr.Dependencies, pA)
assert.NotContains(t, depErr.Dependencies, a)
assert.Contains(t, depErr.Dependencies, b)
assert.NotContains(t, depErr.Dependencies, c)
assert.Len(t, snap.Resources, 4)
assert.Equal(t, []*resource.State{pA, a, b, c}, snap.Resources)
}
func TestFailedDeletionProtected(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
a.Protect = true
snap := NewSnapshot([]*resource.State{
pA,
a,
})
err := DeleteResource(snap, a)
assert.Error(t, err)
_, ok := err.(ResourceProtectedError)
assert.True(t, ok)
}
func TestFailedDeletionParentDependency(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
b := NewResource("b", pA)
b.Parent = a.URN
c := NewResource("c", pA)
c.Parent = a.URN
snap := NewSnapshot([]*resource.State{
pA,
a,
b,
c,
})
err := DeleteResource(snap, a)
assert.Error(t, err)
depErr, ok := err.(ResourceHasDependenciesError)
if !assert.True(t, ok) {
t.FailNow()
}
assert.NotContains(t, depErr.Dependencies, pA)
assert.NotContains(t, depErr.Dependencies, a)
assert.Contains(t, depErr.Dependencies, b)
assert.Contains(t, depErr.Dependencies, c)
assert.Len(t, snap.Resources, 4)
assert.Equal(t, []*resource.State{pA, a, b, c}, snap.Resources)
}
func TestUnprotectResource(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
a.Protect = true
b := NewResource("b", pA)
c := NewResource("c", pA)
snap := NewSnapshot([]*resource.State{
pA,
a,
b,
c,
})
err := UnprotectResource(snap, a)
assert.NoError(t, err)
assert.Len(t, snap.Resources, 4)
assert.Equal(t, []*resource.State{pA, a, b, c}, snap.Resources)
assert.False(t, a.Protect)
}
func TestLocateResourceNotFound(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
b := NewResource("b", pA)
c := NewResource("c", pA)
snap := NewSnapshot([]*resource.State{
pA,
a,
b,
c,
})
ty := tokens.Type("a:b:c")
urn := resource.NewURN("test", "test", "", ty, "not-present")
resList := LocateResource(snap, urn)
assert.Nil(t, resList)
}
func TestLocateResourceAmbiguous(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
b := NewResource("b", pA)
aPending := NewResource("a", pA)
aPending.Delete = true
snap := NewSnapshot([]*resource.State{
pA,
a,
b,
aPending,
})
resList := LocateResource(snap, a.URN)
assert.Len(t, resList, 2)
assert.Contains(t, resList, a)
assert.Contains(t, resList, aPending)
assert.NotContains(t, resList, pA)
assert.NotContains(t, resList, b)
}
func TestLocateResourceExact(t *testing.T) {
pA := NewProviderResource("a", "p1", "0")
a := NewResource("a", pA)
b := NewResource("b", pA)
c := NewResource("c", pA)
snap := NewSnapshot([]*resource.State{
pA,
a,
b,
c,
})
resList := LocateResource(snap, a.URN)
assert.Len(t, resList, 1)
assert.Contains(t, resList, a)
}