diff --git a/cmd/pulumi.go b/cmd/pulumi.go index 66d7099a0..012e225a3 100644 --- a/cmd/pulumi.go +++ b/cmd/pulumi.go @@ -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()) diff --git a/cmd/state.go b/cmd/state.go new file mode 100644 index 000000000..34a29cef6 --- /dev/null +++ b/cmd/state.go @@ -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) +} diff --git a/cmd/state_delete.go b/cmd/state_delete.go new file mode 100644 index 000000000..35217e408 --- /dev/null +++ b/cmd/state_delete.go @@ -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) +} diff --git a/cmd/state_unprotect.go b/cmd/state_unprotect.go new file mode 100644 index 000000000..b595bda66 --- /dev/null +++ b/cmd/state_unprotect.go @@ -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 +} diff --git a/pkg/resource/edit/doc.go b/pkg/resource/edit/doc.go new file mode 100644 index 000000000..9b2145285 --- /dev/null +++ b/pkg/resource/edit/doc.go @@ -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 diff --git a/pkg/resource/edit/errors.go b/pkg/resource/edit/errors.go new file mode 100644 index 000000000..42d5a0fa1 --- /dev/null +++ b/pkg/resource/edit/errors.go @@ -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" +} diff --git a/pkg/resource/edit/operations.go b/pkg/resource/edit/operations.go new file mode 100644 index 000000000..eec51a224 --- /dev/null +++ b/pkg/resource/edit/operations.go @@ -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 +} diff --git a/pkg/resource/edit/operations_test.go b/pkg/resource/edit/operations_test.go new file mode 100644 index 000000000..d8a963530 --- /dev/null +++ b/pkg/resource/edit/operations_test.go @@ -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) +}