Compare commits

...

80 commits

Author SHA1 Message Date
Fraser Waters 36bf0a43dd lint 2021-11-24 23:35:26 +00:00
Fraser Waters 19dc1bf865 Fix merge conflict 2021-11-24 23:03:43 +00:00
Fraser Waters 3bad3c4abf Merge remote-tracking branch 'origin/master' into ctpp 2021-11-24 23:02:18 +00:00
Fraser Waters f0ad8cffc7 Fix up tests 2021-11-24 18:05:08 +00:00
Fraser Waters 69d81638f9 Fix unexpected deletes 2021-11-24 17:44:21 +00:00
Fraser Waters 605bc2ecdf Fix unneeded deletes 2021-11-24 17:27:27 +00:00
Fraser Waters 884d460c39 Fix reporting of unseen op errors 2021-11-24 17:10:08 +00:00
Fraser Waters 429c93def9 Add test error text 2021-11-24 16:23:05 +00:00
Fraser Waters a88200c70d Add String and GoString to Result
I got fed up assert errors in tests that looked like:
```
Expected nil, but got: &result.simpleResult{err:(*errors.fundamental)(0xc0002fa5d0)}
```

It was very hard to work out at a glance what had gone wrong and I kept
having to hook a debugger just to look at what the error was.

With GoString these now print something like:
```
Expected nil, but got: &simpleResult{err: Unexpected diag message: <{%reset%}>resource violates plan: properties changed: -zed, -baz, -foo<{%reset%}>
}
```

Which is much more ussful.
2021-11-24 15:22:58 +00:00
Fraser Waters 146680abda Start on more tests 2021-11-24 15:11:04 +00:00
Fraser Waters 6c337ad7f9 Test diag message 2021-11-24 14:22:22 +00:00
Fraser Waters 9242b835f4 Fix clone 2021-11-24 11:53:47 +00:00
Fraser Waters f9bda75d38 Actually check error 2021-11-24 10:44:20 +00:00
Fraser Waters 1cf5e7b763 Asset NoError 2021-11-24 10:37:56 +00:00
Fraser Waters 50232e26ab linting 2021-11-24 10:30:19 +00:00
Fraser Waters dfef30f5a6 Config and manifest serder 2021-11-24 10:05:44 +00:00
Fraser Waters bd7d9b797b Merge remote-tracking branch 'origin/master' into ctpp 2021-11-24 09:39:05 +00:00
Fraser Waters 8f959a03b0 wip config work 2021-11-23 16:43:27 +00:00
Fraser Waters 94bf0840df Add proper Plan type 2021-11-23 16:09:18 +00:00
Fraser Waters 37b22b8d3c Add manifest to plan 2021-11-23 15:35:34 +00:00
Fraser Waters 6022b98134 omitempty 2021-11-23 13:32:10 +00:00
Fraser Waters 202c0f0ae4 Validate outputs don't change 2021-11-23 13:26:11 +00:00
Fraser Waters 76f3e938aa Revert auto-refresh changes 2021-11-22 16:55:05 +00:00
Fraser Waters b692140160 Merge remote-tracking branch 'origin/master' into ctpp 2021-11-22 10:18:53 +00:00
Fraser Waters 5f57c2ab72 Add TestPlannedUpdateChangedStack 2021-11-19 23:23:25 +00:00
Fraser Waters ad6047cd08 Small preview plan test 2021-11-19 21:14:44 +00:00
Fraser Waters b7a5310e07 More copying in tests because I do not trust myself to get mutation correct 2021-11-19 21:12:40 +00:00
Fraser Waters e182cf0ff3 lint 2021-11-19 16:17:32 +00:00
Fraser Waters 1ddd452faa Fix TestExplicitDeleteBeforeReplace 2021-11-19 16:06:21 +00:00
Fraser Waters 7143b728b7 Fix TestGetRefreshOption 2021-11-19 14:43:33 +00:00
Fraser Waters 6d02c4adc8 Auto refresh if using plans 2021-11-19 14:29:30 +00:00
Fraser Waters b3d8cd9870
Update pkg/cmd/pulumi/preview.go
Co-authored-by: Alex Mullans <a.mullans@pulumi.com>
2021-11-19 14:24:47 +00:00
Fraser Waters 9032552d69
Update pkg/cmd/pulumi/up.go
Co-authored-by: Alex Mullans <a.mullans@pulumi.com>
2021-11-19 14:24:33 +00:00
Fraser Waters c1047fb3d9 linting 2021-11-19 14:11:12 +00:00
Fraser Waters 9739ff0eed Fix more tests 2021-11-18 12:27:22 +00:00
Fraser Waters 4fa395b03d Fix same resource test 2021-11-18 11:39:41 +00:00
Fraser Waters 598f2c7213 Resource sames test 2021-11-18 09:59:52 +00:00
Fraser Waters 96af4684db revert stack changes 2021-11-17 16:18:27 +00:00
Fraser Waters 844e8f0c1e Merge remote-tracking branch 'origin/master' into ctpp 2021-11-17 16:16:13 +00:00
Fraser Waters 20eaa27b3d fix test 2021-11-17 15:58:52 +00:00
Fraser Waters de3e95b7ab Check more constraints 2021-11-17 15:49:17 +00:00
Fraser Waters 23e9ec2c54 Fix aliased 2021-11-17 08:59:51 +00:00
Fraser Waters 3b9cf5b648 Adds/Deletes/Updates 2021-11-16 16:26:51 +00:00
Fraser Waters 40400b664b Better constraint diffs 2021-11-16 14:29:04 +00:00
Fraser Waters 878c2bb28a typo 2021-11-15 10:58:07 +00:00
Fraser Waters 83e655e19f Merge remote-tracking branch 'origin/master' into ctpp 2021-11-15 10:02:29 +00:00
Fraser Waters b1a64a65ca Hide behind envvars 2021-11-15 09:58:30 +00:00
Fraser Waters 5b6a03f548 Delete plan cmd, rename arguments to preview and up 2021-11-11 10:40:01 +00:00
Fraser Waters e2c5f12d65 Merge remote-tracking branch 'origin/master' into ctpp 2021-11-11 10:05:00 +00:00
Fraser Waters feaccb1fe1 Merge remote-tracking branch 'origin/master' into ctpp 2021-11-08 13:44:13 +00:00
Fraser Waters 9865fa58e3 Pop op before constraint check 2021-11-08 13:19:35 +00:00
Fraser Waters c41981a84f More tests 2021-11-08 12:40:10 +00:00
Fraser Waters 1db1ab5137 notes 2021-11-05 18:44:10 +00:00
Fraser Waters 82222cc19e property set shrink test 2021-11-05 14:56:49 +00:00
Fraser Waters 775ace8f13 rm Paths() 2021-11-05 08:55:07 +00:00
Fraser Waters 12c0188329 Merge remote-tracking branch 'origin/master' into ctpp 2021-11-04 21:00:51 +00:00
Fraser Waters 3e73c036fb Fix test for missing creates 2021-11-04 19:32:11 +00:00
Fraser Waters f4c28053f8 Test missing create 2021-11-04 16:52:39 +00:00
Fraser Waters d995901fa7 Test expected delete 2021-11-04 15:51:46 +00:00
Fraser Waters 505e4c5d06 Test expected deletes 2021-11-04 15:26:01 +00:00
Fraser Waters 5352638e51 lint 2021-11-04 11:54:04 +00:00
Fraser Waters fdea5bb588 Readd test_plan.go 2021-11-04 11:22:20 +00:00
Fraser Waters b5eb955301 Merge remote-tracking branch 'origin/master' into ctpp 2021-11-04 10:07:10 +00:00
Fraser Waters b1e23a5287 More tests 2021-11-03 15:56:42 +00:00
Fraser Waters 83e2cd7e4a Fix cmdutil.PrintTable to handle non-simple strings 2021-11-03 10:24:42 +00:00
Fraser Waters 8fae881a81 Fix colors 2021-11-02 22:41:17 +00:00
Fraser Waters 1b59952a20 Test and fix PropertyPath.String() 2021-11-02 15:25:36 +00:00
Fraser Waters 155c035e34 Liniting and test fixing 2021-11-02 13:52:21 +00:00
Fraser Waters 0d9043f6a2 Merge remote-tracking branch 'origin/master' into ctpp 2021-11-02 09:19:17 +00:00
Pat Gavlin 90b75141cb fixup 2021-10-29 09:07:02 -07:00
Pat Gavlin 365bfd2ef8 fix diff 2021-10-29 08:44:33 -07:00
Pat Gavlin 954bdc025b WIP: outputs in plans 2021-10-29 08:44:33 -07:00
Pat Gavlin 24a0eb0274 fixes for rebase breaks and diffs 2021-10-29 08:44:33 -07:00
Pat Gavlin 908c3aeb70 Update message 2021-10-29 08:44:31 -07:00
Pat Gavlin 6de090c540 Renames 2021-10-29 08:44:03 -07:00
Pat Gavlin 2818c9888d constraints 2021-10-29 08:43:21 -07:00
Pat Gavlin bbfd8256de plan renderer 2021-10-29 08:43:20 -07:00
Pat Gavlin 13f8d342ce Update wording 2021-10-29 08:43:20 -07:00
Pat Gavlin e101f80839 Plumb plans through the CLI. 2021-10-29 08:43:18 -07:00
Pat Gavlin 33e8980cee Implement resource plans in the engine 2021-10-29 08:42:44 -07:00
39 changed files with 2426 additions and 224 deletions

View file

@ -26,6 +26,7 @@ import (
"github.com/pulumi/pulumi/pkg/v3/backend/display" "github.com/pulumi/pulumi/pkg/v3/backend/display"
"github.com/pulumi/pulumi/pkg/v3/engine" "github.com/pulumi/pulumi/pkg/v3/engine"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype" "github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource" "github.com/pulumi/pulumi/sdk/v3/go/common/resource"
@ -44,7 +45,7 @@ type ApplierOptions struct {
// Applier applies the changes specified by this update operation against the target stack. // Applier applies the changes specified by this update operation against the target stack.
type Applier func(ctx context.Context, kind apitype.UpdateKind, stack Stack, op UpdateOperation, type Applier func(ctx context.Context, kind apitype.UpdateKind, stack Stack, op UpdateOperation,
opts ApplierOptions, events chan<- engine.Event) (engine.ResourceChanges, result.Result) opts ApplierOptions, events chan<- engine.Event) (*deploy.Plan, engine.ResourceChanges, result.Result)
func ActionLabel(kind apitype.UpdateKind, dryRun bool) string { func ActionLabel(kind apitype.UpdateKind, dryRun bool) string {
v := updateTextMap[kind] v := updateTextMap[kind]
@ -79,7 +80,7 @@ const (
) )
func PreviewThenPrompt(ctx context.Context, kind apitype.UpdateKind, stack Stack, func PreviewThenPrompt(ctx context.Context, kind apitype.UpdateKind, stack Stack,
op UpdateOperation, apply Applier) (engine.ResourceChanges, result.Result) { op UpdateOperation, apply Applier) (*deploy.Plan, engine.ResourceChanges, result.Result) {
// create a channel to hear about the update events from the engine. this will be used so that // create a channel to hear about the update events from the engine. this will be used so that
// we can build up the diff display in case the user asks to see the details of the diff // we can build up the diff display in case the user asks to see the details of the diff
@ -111,22 +112,22 @@ func PreviewThenPrompt(ctx context.Context, kind apitype.UpdateKind, stack Stack
ShowLink: true, ShowLink: true,
} }
changes, res := apply(ctx, kind, stack, op, opts, eventsChannel) plan, changes, res := apply(ctx, kind, stack, op, opts, eventsChannel)
if res != nil { if res != nil {
close(eventsChannel) close(eventsChannel)
return changes, res return plan, changes, res
} }
// If there are no changes, or we're auto-approving or just previewing, we can skip the confirmation prompt. // If there are no changes, or we're auto-approving or just previewing, we can skip the confirmation prompt.
if op.Opts.AutoApprove || kind == apitype.PreviewUpdate { if op.Opts.AutoApprove || kind == apitype.PreviewUpdate {
close(eventsChannel) close(eventsChannel)
return changes, nil return plan, changes, nil
} }
// Otherwise, ensure the user wants to proceed. // Otherwise, ensure the user wants to proceed.
res = confirmBeforeUpdating(kind, stack, events, op.Opts) res = confirmBeforeUpdating(kind, stack, events, op.Opts)
close(eventsChannel) close(eventsChannel)
return changes, res return plan, changes, res
} }
// confirmBeforeUpdating asks the user whether to proceed. A nil error means yes. // confirmBeforeUpdating asks the user whether to proceed. A nil error means yes.
@ -196,10 +197,13 @@ func PreviewThenPromptThenExecute(ctx context.Context, kind apitype.UpdateKind,
// Preview the operation to the user and ask them if they want to proceed. // Preview the operation to the user and ask them if they want to proceed.
if !op.Opts.SkipPreview { if !op.Opts.SkipPreview {
changes, res := PreviewThenPrompt(ctx, kind, stack, op, apply) plan, changes, res := PreviewThenPrompt(ctx, kind, stack, op, apply)
if res != nil || kind == apitype.PreviewUpdate { if res != nil || kind == apitype.PreviewUpdate {
return changes, res return changes, res
} }
// TODO(pdg-plan): should this check for an existing plan?
op.Opts.Engine.Plan = plan
} }
// Perform the change (!DryRun) and show the cloud link to the result. // Perform the change (!DryRun) and show the cloud link to the result.
@ -208,7 +212,8 @@ func PreviewThenPromptThenExecute(ctx context.Context, kind apitype.UpdateKind,
DryRun: false, DryRun: false,
ShowLink: true, ShowLink: true,
} }
return apply(ctx, kind, stack, op, opts, nil /*events*/) _, changes, res := apply(ctx, kind, stack, op, opts, nil /*events*/)
return changes, res
} }
func createDiff(updateKind apitype.UpdateKind, events []engine.Event, displayOpts display.Options) string { func createDiff(updateKind apitype.UpdateKind, events []engine.Event, displayOpts display.Options) string {

View file

@ -161,7 +161,7 @@ type Backend interface {
RenameStack(ctx context.Context, stack Stack, newName tokens.QName) (StackReference, error) RenameStack(ctx context.Context, stack Stack, newName tokens.QName) (StackReference, error)
// Preview shows what would be updated given the current workspace's contents. // Preview shows what would be updated given the current workspace's contents.
Preview(ctx context.Context, stack Stack, op UpdateOperation) (engine.ResourceChanges, result.Result) Preview(ctx context.Context, stack Stack, op UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result)
// Update updates the target stack with the current workspace's contents (config and code). // Update updates the target stack with the current workspace's contents (config and code).
Update(ctx context.Context, stack Stack, op UpdateOperation) (engine.ResourceChanges, result.Result) Update(ctx context.Context, stack Stack, op UpdateOperation) (engine.ResourceChanges, result.Result)
// Import imports resources into a stack. // Import imports resources into a stack.

View file

@ -461,12 +461,12 @@ func (b *localBackend) PackPolicies(
} }
func (b *localBackend) Preview(ctx context.Context, stack backend.Stack, func (b *localBackend) Preview(ctx context.Context, stack backend.Stack,
op backend.UpdateOperation) (engine.ResourceChanges, result.Result) { op backend.UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
if cmdutil.IsTruthy(os.Getenv(PulumiFilestateLockingEnvVar)) { if cmdutil.IsTruthy(os.Getenv(PulumiFilestateLockingEnvVar)) {
err := b.Lock(ctx, stack.Ref()) err := b.Lock(ctx, stack.Ref())
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer b.Unlock(ctx, stack.Ref()) defer b.Unlock(ctx, stack.Ref())
} }
@ -548,7 +548,7 @@ func (b *localBackend) Watch(ctx context.Context, stack backend.Stack,
func (b *localBackend) apply( func (b *localBackend) apply(
ctx context.Context, kind apitype.UpdateKind, stack backend.Stack, ctx context.Context, kind apitype.UpdateKind, stack backend.Stack,
op backend.UpdateOperation, opts backend.ApplierOptions, op backend.UpdateOperation, opts backend.ApplierOptions,
events chan<- engine.Event) (engine.ResourceChanges, result.Result) { events chan<- engine.Event) (*deploy.Plan, engine.ResourceChanges, result.Result) {
stackRef := stack.Ref() stackRef := stack.Ref()
stackName := stackRef.Name() stackName := stackRef.Name()
@ -563,7 +563,7 @@ func (b *localBackend) apply(
// Start the update. // Start the update.
update, err := b.newUpdate(stackName, op) update, err := b.newUpdate(stackName, op)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
// Spawn a display loop to show events on the CLI. // Spawn a display loop to show events on the CLI.
@ -604,19 +604,20 @@ func (b *localBackend) apply(
// Perform the update // Perform the update
start := time.Now().Unix() start := time.Now().Unix()
var plan *deploy.Plan
var changes engine.ResourceChanges var changes engine.ResourceChanges
var updateRes result.Result var updateRes result.Result
switch kind { switch kind {
case apitype.PreviewUpdate: case apitype.PreviewUpdate:
changes, updateRes = engine.Update(update, engineCtx, op.Opts.Engine, true) plan, changes, updateRes = engine.Update(update, engineCtx, op.Opts.Engine, true)
case apitype.UpdateUpdate: case apitype.UpdateUpdate:
changes, updateRes = engine.Update(update, engineCtx, op.Opts.Engine, opts.DryRun) _, changes, updateRes = engine.Update(update, engineCtx, op.Opts.Engine, opts.DryRun)
case apitype.ResourceImportUpdate: case apitype.ResourceImportUpdate:
changes, updateRes = engine.Import(update, engineCtx, op.Opts.Engine, op.Imports, opts.DryRun) _, changes, updateRes = engine.Import(update, engineCtx, op.Opts.Engine, op.Imports, opts.DryRun)
case apitype.RefreshUpdate: case apitype.RefreshUpdate:
changes, updateRes = engine.Refresh(update, engineCtx, op.Opts.Engine, opts.DryRun) _, changes, updateRes = engine.Refresh(update, engineCtx, op.Opts.Engine, opts.DryRun)
case apitype.DestroyUpdate: case apitype.DestroyUpdate:
changes, updateRes = engine.Destroy(update, engineCtx, op.Opts.Engine, opts.DryRun) _, changes, updateRes = engine.Destroy(update, engineCtx, op.Opts.Engine, opts.DryRun)
default: default:
contract.Failf("Unrecognized update kind: %s", kind) contract.Failf("Unrecognized update kind: %s", kind)
} }
@ -660,16 +661,16 @@ func (b *localBackend) apply(
if updateRes != nil { if updateRes != nil {
// We swallow saveErr and backupErr as they are less important than the updateErr. // We swallow saveErr and backupErr as they are less important than the updateErr.
return changes, updateRes return plan, changes, updateRes
} }
if saveErr != nil { if saveErr != nil {
// We swallow backupErr as it is less important than the saveErr. // We swallow backupErr as it is less important than the saveErr.
return changes, result.FromError(fmt.Errorf("saving update info: %w", saveErr)) return plan, changes, result.FromError(fmt.Errorf("saving update info: %w", saveErr))
} }
if backupErr != nil { if backupErr != nil {
return changes, result.FromError(fmt.Errorf("saving backup: %w", backupErr)) return plan, changes, result.FromError(fmt.Errorf("saving backup: %w", backupErr))
} }
// Make sure to print a link to the stack's checkpoint before exiting. // Make sure to print a link to the stack's checkpoint before exiting.
@ -705,7 +706,7 @@ func (b *localBackend) apply(
} }
} }
return changes, nil return plan, changes, nil
} }
// query executes a query program against the resource outputs of a locally hosted stack. // query executes a query program against the resource outputs of a locally hosted stack.

View file

@ -64,7 +64,10 @@ func (s *localStack) Rename(ctx context.Context, newName tokens.QName) (backend.
return backend.RenameStack(ctx, s, newName) return backend.RenameStack(ctx, s, newName)
} }
func (s *localStack) Preview(ctx context.Context, op backend.UpdateOperation) (engine.ResourceChanges, result.Result) { func (s *localStack) Preview(
ctx context.Context,
op backend.UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
return backend.PreviewStack(ctx, s, op) return backend.PreviewStack(ctx, s, op)
} }

View file

@ -828,7 +828,7 @@ func (b *cloudBackend) RenameStack(ctx context.Context, stack backend.Stack,
} }
func (b *cloudBackend) Preview(ctx context.Context, stack backend.Stack, func (b *cloudBackend) Preview(ctx context.Context, stack backend.Stack,
op backend.UpdateOperation) (engine.ResourceChanges, result.Result) { op backend.UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
// We can skip PreviewtThenPromptThenExecute, and just go straight to Execute. // We can skip PreviewtThenPromptThenExecute, and just go straight to Execute.
opts := backend.ApplierOptions{ opts := backend.ApplierOptions{
DryRun: true, DryRun: true,
@ -931,7 +931,7 @@ func (b *cloudBackend) createAndStartUpdate(
func (b *cloudBackend) apply( func (b *cloudBackend) apply(
ctx context.Context, kind apitype.UpdateKind, stack backend.Stack, ctx context.Context, kind apitype.UpdateKind, stack backend.Stack,
op backend.UpdateOperation, opts backend.ApplierOptions, op backend.UpdateOperation, opts backend.ApplierOptions,
events chan<- engine.Event) (engine.ResourceChanges, result.Result) { events chan<- engine.Event) (*deploy.Plan, engine.ResourceChanges, result.Result) {
actionLabel := backend.ActionLabel(kind, opts.DryRun) actionLabel := backend.ActionLabel(kind, opts.DryRun)
@ -945,7 +945,7 @@ func (b *cloudBackend) apply(
update, version, token, err := update, version, token, err :=
b.createAndStartUpdate(ctx, kind, stack, &op, opts.DryRun) b.createAndStartUpdate(ctx, kind, stack, &op, opts.DryRun)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
if !op.Opts.Display.SuppressPermalink && opts.ShowLink && !op.Opts.Display.JSONDisplay { if !op.Opts.Display.SuppressPermalink && opts.ShowLink && !op.Opts.Display.JSONDisplay {
@ -985,12 +985,12 @@ func (b *cloudBackend) query(ctx context.Context, op backend.QueryOperation,
func (b *cloudBackend) runEngineAction( func (b *cloudBackend) runEngineAction(
ctx context.Context, kind apitype.UpdateKind, stackRef backend.StackReference, ctx context.Context, kind apitype.UpdateKind, stackRef backend.StackReference,
op backend.UpdateOperation, update client.UpdateIdentifier, token string, op backend.UpdateOperation, update client.UpdateIdentifier, token string,
callerEventsOpt chan<- engine.Event, dryRun bool) (engine.ResourceChanges, result.Result) { callerEventsOpt chan<- engine.Event, dryRun bool) (*deploy.Plan, engine.ResourceChanges, result.Result) {
contract.Assertf(token != "", "persisted actions require a token") contract.Assertf(token != "", "persisted actions require a token")
u, err := b.newUpdate(ctx, stackRef, op, update, token) u, err := b.newUpdate(ctx, stackRef, op, update, token)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
// displayEvents renders the event to the console and Pulumi service. The processor for the // displayEvents renders the event to the console and Pulumi service. The processor for the
@ -1039,19 +1039,20 @@ func (b *cloudBackend) runEngineAction(
engineCtx.ParentSpan = parentSpan.Context() engineCtx.ParentSpan = parentSpan.Context()
} }
var plan *deploy.Plan
var changes engine.ResourceChanges var changes engine.ResourceChanges
var res result.Result var res result.Result
switch kind { switch kind {
case apitype.PreviewUpdate: case apitype.PreviewUpdate:
changes, res = engine.Update(u, engineCtx, op.Opts.Engine, true) plan, changes, res = engine.Update(u, engineCtx, op.Opts.Engine, true)
case apitype.UpdateUpdate: case apitype.UpdateUpdate:
changes, res = engine.Update(u, engineCtx, op.Opts.Engine, dryRun) _, changes, res = engine.Update(u, engineCtx, op.Opts.Engine, dryRun)
case apitype.ResourceImportUpdate: case apitype.ResourceImportUpdate:
changes, res = engine.Import(u, engineCtx, op.Opts.Engine, op.Imports, dryRun) _, changes, res = engine.Import(u, engineCtx, op.Opts.Engine, op.Imports, dryRun)
case apitype.RefreshUpdate: case apitype.RefreshUpdate:
changes, res = engine.Refresh(u, engineCtx, op.Opts.Engine, dryRun) _, changes, res = engine.Refresh(u, engineCtx, op.Opts.Engine, dryRun)
case apitype.DestroyUpdate: case apitype.DestroyUpdate:
changes, res = engine.Destroy(u, engineCtx, op.Opts.Engine, dryRun) _, changes, res = engine.Destroy(u, engineCtx, op.Opts.Engine, dryRun)
default: default:
contract.Failf("Unrecognized update kind: %s", kind) contract.Failf("Unrecognized update kind: %s", kind)
} }
@ -1077,7 +1078,7 @@ func (b *cloudBackend) runEngineAction(
res = result.Merge(res, result.FromError(fmt.Errorf("failed to complete update: %w", completeErr))) res = result.Merge(res, result.FromError(fmt.Errorf("failed to complete update: %w", completeErr)))
} }
return changes, res return plan, changes, res
} }
func (b *cloudBackend) CancelCurrentUpdate(ctx context.Context, stackRef backend.StackReference) error { func (b *cloudBackend) CancelCurrentUpdate(ctx context.Context, stackRef backend.StackReference) error {

View file

@ -140,7 +140,10 @@ func (s *cloudStack) Rename(ctx context.Context, newName tokens.QName) (backend.
return backend.RenameStack(ctx, s, newName) return backend.RenameStack(ctx, s, newName)
} }
func (s *cloudStack) Preview(ctx context.Context, op backend.UpdateOperation) (engine.ResourceChanges, result.Result) { func (s *cloudStack) Preview(
ctx context.Context,
op backend.UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
return backend.PreviewStack(ctx, s, op) return backend.PreviewStack(ctx, s, op)
} }

View file

@ -57,7 +57,7 @@ type MockBackend struct {
LogoutAllF func() error LogoutAllF func() error
CurrentUserF func() (string, error) CurrentUserF func() (string, error)
PreviewF func(context.Context, Stack, PreviewF func(context.Context, Stack,
UpdateOperation) (engine.ResourceChanges, result.Result) UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result)
UpdateF func(context.Context, Stack, UpdateF func(context.Context, Stack,
UpdateOperation) (engine.ResourceChanges, result.Result) UpdateOperation) (engine.ResourceChanges, result.Result)
ImportF func(context.Context, Stack, ImportF func(context.Context, Stack,
@ -180,7 +180,7 @@ func (be *MockBackend) GetStackCrypter(stackRef StackReference) (config.Crypter,
} }
func (be *MockBackend) Preview(ctx context.Context, stack Stack, func (be *MockBackend) Preview(ctx context.Context, stack Stack,
op UpdateOperation) (engine.ResourceChanges, result.Result) { op UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
if be.PreviewF != nil { if be.PreviewF != nil {
return be.PreviewF(ctx, stack, op) return be.PreviewF(ctx, stack, op)
@ -335,7 +335,7 @@ type MockStack struct {
ConfigF func() config.Map ConfigF func() config.Map
SnapshotF func(ctx context.Context) (*deploy.Snapshot, error) SnapshotF func(ctx context.Context) (*deploy.Snapshot, error)
BackendF func() Backend BackendF func() Backend
PreviewF func(ctx context.Context, op UpdateOperation) (engine.ResourceChanges, result.Result) PreviewF func(ctx context.Context, op UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result)
UpdateF func(ctx context.Context, op UpdateOperation) (engine.ResourceChanges, result.Result) UpdateF func(ctx context.Context, op UpdateOperation) (engine.ResourceChanges, result.Result)
ImportF func(ctx context.Context, op UpdateOperation, ImportF func(ctx context.Context, op UpdateOperation,
imports []deploy.Import) (engine.ResourceChanges, result.Result) imports []deploy.Import) (engine.ResourceChanges, result.Result)
@ -381,7 +381,10 @@ func (ms *MockStack) Backend() Backend {
panic("not implemented") panic("not implemented")
} }
func (ms *MockStack) Preview(ctx context.Context, op UpdateOperation) (engine.ResourceChanges, result.Result) { func (ms *MockStack) Preview(
ctx context.Context,
op UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
if ms.PreviewF != nil { if ms.PreviewF != nil {
return ms.PreviewF(ctx, op) return ms.PreviewF(ctx, op)
} }

View file

@ -38,7 +38,7 @@ type Stack interface {
Backend() Backend // the backend this stack belongs to. Backend() Backend // the backend this stack belongs to.
// Preview changes to this stack. // Preview changes to this stack.
Preview(ctx context.Context, op UpdateOperation) (engine.ResourceChanges, result.Result) Preview(ctx context.Context, op UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result)
// Update this stack. // Update this stack.
Update(ctx context.Context, op UpdateOperation) (engine.ResourceChanges, result.Result) Update(ctx context.Context, op UpdateOperation) (engine.ResourceChanges, result.Result)
// Import resources into this stack. // Import resources into this stack.
@ -73,7 +73,11 @@ func RenameStack(ctx context.Context, s Stack, newName tokens.QName) (StackRefer
} }
// PreviewStack previews changes to this stack. // PreviewStack previews changes to this stack.
func PreviewStack(ctx context.Context, s Stack, op UpdateOperation) (engine.ResourceChanges, result.Result) { func PreviewStack(
ctx context.Context,
s Stack,
op UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
return s.Backend().Preview(ctx, s, op) return s.Backend().Preview(ctx, s, op)
} }
@ -117,7 +121,10 @@ func GetStackLogs(ctx context.Context, s Stack, cfg StackConfiguration,
} }
// ExportStackDeployment exports the given stack's deployment as an opaque JSON message. // ExportStackDeployment exports the given stack's deployment as an opaque JSON message.
func ExportStackDeployment(ctx context.Context, s Stack) (*apitype.UntypedDeployment, error) { func ExportStackDeployment(
ctx context.Context,
s Stack) (*apitype.UntypedDeployment, error) {
return s.Backend().ExportDeployment(ctx, s) return s.Backend().ExportDeployment(ctx, s)
} }

View file

@ -96,7 +96,7 @@ func Watch(ctx context.Context, b Backend, stack Stack, op UpdateOperation,
op.Opts.Display.Color.Colorize(colors.SpecImportant+"Updating..."+colors.Reset+"\n")) op.Opts.Display.Color.Colorize(colors.SpecImportant+"Updating..."+colors.Reset+"\n"))
// Perform the update operation // Perform the update operation
_, res := apply(ctx, apitype.UpdateUpdate, stack, op, opts, nil) _, _, res := apply(ctx, apitype.UpdateUpdate, stack, op, opts, nil)
if res != nil { if res != nil {
logging.V(5).Infof("watch update failed: %v", res.Error()) logging.V(5).Infof("watch update failed: %v", res.Error())
if res.Error() == context.Canceled { if res.Error() == context.Canceled {

View file

@ -38,6 +38,8 @@ func newPreviewCmd() *cobra.Command {
var configArray []string var configArray []string
var configPath bool var configPath bool
var client string var client string
var planFilePath string
var showSecrets bool
// Flags for engine.UpdateOptions. // Flags for engine.UpdateOptions.
var jsonDisplay bool var jsonDisplay bool
@ -184,7 +186,7 @@ func newPreviewCmd() *cobra.Command {
Display: displayOpts, Display: displayOpts,
} }
changes, res := s.Preview(commandContext(), backend.UpdateOperation{ plan, changes, res := s.Preview(commandContext(), backend.UpdateOperation{
Proj: proj, Proj: proj,
Root: root, Root: root,
M: m, M: m,
@ -200,6 +202,16 @@ func newPreviewCmd() *cobra.Command {
case expectNop && changes != nil && changes.HasChanges(): case expectNop && changes != nil && changes.HasChanges():
return result.FromError(errors.New("error: no changes were expected but changes were proposed")) return result.FromError(errors.New("error: no changes were expected but changes were proposed"))
default: default:
if planFilePath != "" {
encrypter, err := sm.Encrypter()
if err != nil {
return result.FromError(err)
}
if err = writePlan(planFilePath, plan, encrypter, showSecrets); err != nil {
return result.FromError(err)
}
// TODO(pdg-plan): emit a message about how to apply the plan
}
return nil return nil
} }
}), }),
@ -223,6 +235,13 @@ func newPreviewCmd() *cobra.Command {
cmd.PersistentFlags().BoolVar( cmd.PersistentFlags().BoolVar(
&configPath, "config-path", false, &configPath, "config-path", false,
"Config keys contain a path to a property in a map or list to set") "Config keys contain a path to a property in a map or list to set")
if hasExperimentalCommands() {
cmd.PersistentFlags().StringVar(
&planFilePath, "save-plan", "",
"Save the operations proposed by the preview to a plan file at the given path")
}
cmd.Flags().BoolVarP(
&showSecrets, "show-secrets", "", false, "Emit secrets in plaintext in the plan file. Defaults to `false`")
cmd.PersistentFlags().StringVar( cmd.PersistentFlags().StringVar(
&client, "client", "", "The address of an existing language runtime host to connect to") &client, "client", "", "The address of an existing language runtime host to connect to")

View file

@ -76,6 +76,7 @@ func newUpCmd() *cobra.Command {
var replaces []string var replaces []string
var targetReplaces []string var targetReplaces []string
var targetDependents bool var targetDependents bool
var planFilePath string
// up implementation used when the source of the Pulumi program is in the current working directory. // up implementation used when the source of the Pulumi program is in the current working directory.
upWorkingDirectory := func(opts backend.UpdateOptions) result.Result { upWorkingDirectory := func(opts backend.UpdateOptions) result.Result {
@ -143,6 +144,22 @@ func newUpCmd() *cobra.Command {
TargetDependents: targetDependents, TargetDependents: targetDependents,
} }
if planFilePath != "" {
dec, err := sm.Decrypter()
if err != nil {
return result.FromError(err)
}
enc, err := sm.Encrypter()
if err != nil {
return result.FromError(err)
}
plan, err := readPlan(planFilePath, dec, enc)
if err != nil {
return result.FromError(err)
}
opts.Engine.Plan = plan
}
changes, res := s.Update(commandContext(), backend.UpdateOperation{ changes, res := s.Update(commandContext(), backend.UpdateOperation{
Proj: proj, Proj: proj,
Root: root, Root: root,
@ -505,6 +522,14 @@ func newUpCmd() *cobra.Command {
&yes, "yes", "y", false, &yes, "yes", "y", false,
"Automatically approve and perform the update after previewing it") "Automatically approve and perform the update after previewing it")
if hasExperimentalCommands() {
cmd.PersistentFlags().StringVar(
&planFilePath, "plan", "",
"Path to a plan file to use for the update. The update will not "+
"perform operations that exceed its plan (e.g. replacements instead of updates, or updates instead"+
"of sames).")
}
if hasDebugCommands() { if hasDebugCommands() {
cmd.PersistentFlags().StringVar( cmd.PersistentFlags().StringVar(
&eventLogPath, "event-log", "", &eventLogPath, "event-log", "",

View file

@ -42,12 +42,15 @@ import (
"github.com/pulumi/pulumi/pkg/v3/backend/httpstate" "github.com/pulumi/pulumi/pkg/v3/backend/httpstate"
"github.com/pulumi/pulumi/pkg/v3/backend/state" "github.com/pulumi/pulumi/pkg/v3/backend/state"
"github.com/pulumi/pulumi/pkg/v3/engine" "github.com/pulumi/pulumi/pkg/v3/engine"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/pkg/v3/resource/stack" "github.com/pulumi/pulumi/pkg/v3/resource/stack"
"github.com/pulumi/pulumi/pkg/v3/secrets/passphrase" "github.com/pulumi/pulumi/pkg/v3/secrets/passphrase"
"github.com/pulumi/pulumi/pkg/v3/util/cancel" "github.com/pulumi/pulumi/pkg/v3/util/cancel"
"github.com/pulumi/pulumi/pkg/v3/util/tracing" "github.com/pulumi/pulumi/pkg/v3/util/tracing"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/constant" "github.com/pulumi/pulumi/sdk/v3/go/common/constant"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/ciutil" "github.com/pulumi/pulumi/sdk/v3/go/common/util/ciutil"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
@ -866,6 +869,34 @@ func getRefreshOption(proj *workspace.Project, refresh string) (bool, error) {
return false, nil return false, nil
} }
func writePlan(path string, plan *deploy.Plan, enc config.Encrypter, showSecrets bool) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer contract.IgnoreClose(f)
deploymentPlan, err := stack.SerializePlan(plan, enc, showSecrets)
if err != nil {
return err
}
return json.NewEncoder(f).Encode(deploymentPlan)
}
func readPlan(path string, dec config.Decrypter, enc config.Encrypter) (*deploy.Plan, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer contract.IgnoreClose(f)
var deploymentPlan apitype.DeploymentPlanV1
if err := json.NewDecoder(f).Decode(&deploymentPlan); err != nil {
return nil, err
}
return stack.DeserializePlan(deploymentPlan, dec, enc)
}
func buildStackName(stackName string) (string, error) { func buildStackName(stackName string) (string, error) {
if strings.Count(stackName, "/") == 2 { if strings.Count(stackName, "/") == 2 {
return stackName, nil return stackName, nil

View file

@ -167,7 +167,7 @@ func newDeployment(ctx *Context, info *deploymentContext, opts deploymentOptions
var depl *deploy.Deployment var depl *deploy.Deployment
if !opts.isImport { if !opts.isImport {
depl, err = deploy.NewDeployment( depl, err = deploy.NewDeployment(
plugctx, target, target.Snapshot, source, localPolicyPackPaths, dryRun, ctx.BackendClient) plugctx, target, target.Snapshot, opts.Plan, source, localPolicyPackPaths, dryRun, ctx.BackendClient)
} else { } else {
_, defaultProviderVersions, pluginErr := installPlugins(proj, pwd, main, target, plugctx, _, defaultProviderVersions, pluginErr := installPlugins(proj, pwd, main, target, plugctx,
false /*returnInstallErrors*/) false /*returnInstallErrors*/)
@ -218,12 +218,12 @@ type runActions interface {
// run executes the deployment. It is primarily responsible for handling cancellation. // run executes the deployment. It is primarily responsible for handling cancellation.
func (deployment *deployment) run(cancelCtx *Context, actions runActions, policyPacks map[string]string, func (deployment *deployment) run(cancelCtx *Context, actions runActions, policyPacks map[string]string,
preview bool) (ResourceChanges, result.Result) { preview bool) (*deploy.Plan, ResourceChanges, result.Result) {
// Change into the plugin context's working directory. // Change into the plugin context's working directory.
chdir, err := fsutil.Chdir(deployment.Plugctx.Pwd) chdir, err := fsutil.Chdir(deployment.Plugctx.Pwd)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer chdir() defer chdir()
@ -242,6 +242,7 @@ func (deployment *deployment) run(cancelCtx *Context, actions runActions, policy
start := time.Now() start := time.Now()
done := make(chan bool) done := make(chan bool)
var newPlan *deploy.Plan
var walkResult result.Result var walkResult result.Result
go func() { go func() {
opts := deploy.Options{ opts := deploy.Options{
@ -259,7 +260,7 @@ func (deployment *deployment) run(cancelCtx *Context, actions runActions, policy
DisableResourceReferences: deployment.Options.DisableResourceReferences, DisableResourceReferences: deployment.Options.DisableResourceReferences,
DisableOutputValues: deployment.Options.DisableOutputValues, DisableOutputValues: deployment.Options.DisableOutputValues,
} }
walkResult = deployment.Deployment.Execute(ctx, opts, preview) newPlan, walkResult = deployment.Deployment.Execute(ctx, opts, preview)
close(done) close(done)
}() }()
@ -290,7 +291,7 @@ func (deployment *deployment) run(cancelCtx *Context, actions runActions, policy
// Emit a summary event. // Emit a summary event.
deployment.Options.Events.summaryEvent(preview, actions.MaybeCorrupt(), duration, changes, policyPacks) deployment.Options.Events.summaryEvent(preview, actions.MaybeCorrupt(), duration, changes, policyPacks)
return changes, res return newPlan, changes, res
} }
func (deployment *deployment) Close() error { func (deployment *deployment) Close() error {

View file

@ -23,7 +23,12 @@ import (
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace" "github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
) )
func Destroy(u UpdateInfo, ctx *Context, opts UpdateOptions, dryRun bool) (ResourceChanges, result.Result) { func Destroy(
u UpdateInfo,
ctx *Context,
opts UpdateOptions,
dryRun bool) (*deploy.Plan, ResourceChanges, result.Result) {
contract.Require(u != nil, "u") contract.Require(u != nil, "u")
contract.Require(ctx != nil, "ctx") contract.Require(ctx != nil, "ctx")
@ -31,13 +36,13 @@ func Destroy(u UpdateInfo, ctx *Context, opts UpdateOptions, dryRun bool) (Resou
info, err := newDeploymentContext(u, "destroy", ctx.ParentSpan) info, err := newDeploymentContext(u, "destroy", ctx.ParentSpan)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer info.Close() defer info.Close()
emitter, err := makeEventEmitter(ctx.Events, u) emitter, err := makeEventEmitter(ctx.Events, u)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer emitter.Close() defer emitter.Close()

View file

@ -21,7 +21,7 @@ import (
) )
func Import(u UpdateInfo, ctx *Context, opts UpdateOptions, imports []deploy.Import, func Import(u UpdateInfo, ctx *Context, opts UpdateOptions, imports []deploy.Import,
dryRun bool) (ResourceChanges, result.Result) { dryRun bool) (*deploy.Plan, ResourceChanges, result.Result) {
contract.Require(u != nil, "u") contract.Require(u != nil, "u")
contract.Require(ctx != nil, "ctx") contract.Require(ctx != nil, "ctx")
@ -30,13 +30,13 @@ func Import(u UpdateInfo, ctx *Context, opts UpdateOptions, imports []deploy.Imp
info, err := newDeploymentContext(u, "import", ctx.ParentSpan) info, err := newDeploymentContext(u, "import", ctx.ParentSpan)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer info.Close() defer info.Close()
emitter, err := makeEventEmitter(ctx.Events, u) emitter, err := makeEventEmitter(ctx.Events, u)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer emitter.Close() defer emitter.Close()

View file

@ -84,12 +84,12 @@ func TestImportOption(t *testing.T) {
// Run the initial update. The import should fail due to a mismatch in inputs between the program and the // Run the initial update. The import should fail due to a mismatch in inputs between the program and the
// actual resource state. // actual resource state.
project := p.GetProject() project := p.GetProject()
_, res := TestOp(Update).Run(project, p.GetTarget(nil), p.Options, false, p.BackendClient, nil) _, res := TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient, nil)
assert.NotNil(t, res) assert.NotNil(t, res)
// Run a second update after fixing the inputs. The import should succeed. // Run a second update after fixing the inputs. The import should succeed.
inputs["foo"] = resource.NewStringProperty("bar") inputs["foo"] = resource.NewStringProperty("bar")
snap, res := TestOp(Update).Run(project, p.GetTarget(nil), p.Options, false, p.BackendClient, snap, res := TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -107,7 +107,7 @@ func TestImportOption(t *testing.T) {
assert.Len(t, snap.Resources, 2) assert.Len(t, snap.Resources, 2)
// Now, run another update. The update should succeed and there should be no diffs. // Now, run another update. The update should succeed and there should be no diffs.
snap, res = TestOp(Update).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -123,7 +123,7 @@ func TestImportOption(t *testing.T) {
// Change a property value and run a third update. The update should succeed. // Change a property value and run a third update. The update should succeed.
inputs["foo"] = resource.NewStringProperty("rab") inputs["foo"] = resource.NewStringProperty("rab")
snap, res = TestOp(Update).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -141,11 +141,11 @@ func TestImportOption(t *testing.T) {
// Change the property value s.t. the resource requires replacement. The update should fail. // Change the property value s.t. the resource requires replacement. The update should fail.
inputs["foo"] = resource.NewStringProperty("replace") inputs["foo"] = resource.NewStringProperty("replace")
_, res = TestOp(Update).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, nil) _, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient, nil)
assert.NotNil(t, res) assert.NotNil(t, res)
// Finally, destroy the stack. The `Delete` function should be called. // Finally, destroy the stack. The `Delete` function should be called.
_, res = TestOp(Destroy).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, _, res = TestOp(Destroy).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -161,7 +161,7 @@ func TestImportOption(t *testing.T) {
// Now clear the ID to import and run an initial update to create a resource that we will import-replace. // Now clear the ID to import and run an initial update to create a resource that we will import-replace.
importID, inputs["foo"] = "", resource.NewStringProperty("bar") importID, inputs["foo"] = "", resource.NewStringProperty("bar")
snap, res = TestOp(Update).Run(project, p.GetTarget(nil), p.Options, false, p.BackendClient, snap, res = TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -182,7 +182,7 @@ func TestImportOption(t *testing.T) {
importID = r.ID importID = r.ID
} }
} }
snap, res = TestOp(Update).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -199,7 +199,7 @@ func TestImportOption(t *testing.T) {
// Then set the import ID and run another update. The update should succeed and should show an import-replace and // Then set the import ID and run another update. The update should succeed and should show an import-replace and
// a delete-replaced. // a delete-replaced.
importID = "id" importID = "id"
_, res = TestOp(Update).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, _, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -222,7 +222,7 @@ func TestImportOption(t *testing.T) {
// Change the program to read a resource rather than creating one. // Change the program to read a resource rather than creating one.
readID = "id" readID = "id"
snap, res = TestOp(Update).Run(project, p.GetTarget(nil), p.Options, false, p.BackendClient, snap, res = TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -241,7 +241,7 @@ func TestImportOption(t *testing.T) {
// Now have the program import the resource. We should see an import-replace and a read-discard. // Now have the program import the resource. We should see an import-replace and a read-discard.
readID, importID = "", readID readID, importID = "", readID
_, res = TestOp(Update).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, _, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -327,7 +327,7 @@ func TestImportWithDifferingImportIdentifierFormat(t *testing.T) {
// Run the initial update. The import should succeed. // Run the initial update. The import should succeed.
project := p.GetProject() project := p.GetProject()
snap, res := TestOp(Update).Run(project, p.GetTarget(nil), p.Options, false, p.BackendClient, snap, res := TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -345,7 +345,7 @@ func TestImportWithDifferingImportIdentifierFormat(t *testing.T) {
assert.Len(t, snap.Resources, 2) assert.Len(t, snap.Resources, 2)
// Now, run another update. The update should succeed and there should be no diffs. // Now, run another update. The update should succeed and there should be no diffs.
snap, res = TestOp(Update).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, snap, res = TestOp(Update).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient,
func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result { func(_ workspace.Project, _ deploy.Target, entries JournalEntries, _ []Event, res result.Result) result.Result {
for _, entry := range entries { for _, entry := range entries {
switch urn := entry.Step.URN(); urn { switch urn := entry.Step.URN(); urn {
@ -483,7 +483,7 @@ func TestImportPlan(t *testing.T) {
// Run the initial update. // Run the initial update.
project := p.GetProject() project := p.GetProject()
snap, res := TestOp(Update).Run(project, p.GetTarget(nil), p.Options, false, p.BackendClient, nil) snap, res := TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient, nil)
assert.Nil(t, res) assert.Nil(t, res)
// Run an import. // Run an import.
@ -491,7 +491,7 @@ func TestImportPlan(t *testing.T) {
Type: "pkgA:m:typA", Type: "pkgA:m:typA",
Name: "resB", Name: "resB",
ID: "imported-id", ID: "imported-id",
}}).Run(project, p.GetTarget(snap), p.Options, false, p.BackendClient, nil) }}).Run(project, p.GetTarget(t, snap), p.Options, false, p.BackendClient, nil)
assert.Nil(t, res) assert.Nil(t, res)
assert.Len(t, snap.Resources, 4) assert.Len(t, snap.Resources, 4)
@ -552,7 +552,7 @@ func TestImportIgnoreChanges(t *testing.T) {
} }
project := p.GetProject() project := p.GetProject()
snap, res := TestOp(Update).Run(project, p.GetTarget(nil), p.Options, false, p.BackendClient, nil) snap, res := TestOp(Update).Run(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient, nil)
assert.Nil(t, res) assert.Nil(t, res)
assert.Len(t, snap.Resources, 2) assert.Len(t, snap.Resources, 2)

File diff suppressed because it is too large Load diff

View file

@ -678,7 +678,7 @@ func TestCanceledRefresh(t *testing.T) {
Parallel: 1, Parallel: 1,
Host: deploytest.NewPluginHost(nil, nil, nil, loaders...), Host: deploytest.NewPluginHost(nil, nil, nil, loaders...),
} }
project, target := p.GetProject(), p.GetTarget(old) project, target := p.GetProject(), p.GetTarget(t, old)
validate := func(project workspace.Project, target deploy.Target, entries JournalEntries, validate := func(project workspace.Project, target deploy.Target, entries JournalEntries,
_ []Event, res result.Result) result.Result { _ []Event, res result.Result) result.Result {

View file

@ -39,16 +39,25 @@ func (u *updateInfo) GetTarget() *deploy.Target {
} }
func ImportOp(imports []deploy.Import) TestOp { func ImportOp(imports []deploy.Import) TestOp {
return TestOp(func(info UpdateInfo, ctx *Context, opts UpdateOptions, dryRun bool) (ResourceChanges, result.Result) { return TestOp(func(info UpdateInfo, ctx *Context, opts UpdateOptions,
dryRun bool) (*deploy.Plan, ResourceChanges, result.Result) {
return Import(info, ctx, opts, imports, dryRun) return Import(info, ctx, opts, imports, dryRun)
}) })
} }
type TestOp func(UpdateInfo, *Context, UpdateOptions, bool) (ResourceChanges, result.Result) type TestOp func(UpdateInfo, *Context, UpdateOptions, bool) (*deploy.Plan, ResourceChanges, result.Result)
type ValidateFunc func(project workspace.Project, target deploy.Target, entries JournalEntries, type ValidateFunc func(project workspace.Project, target deploy.Target, entries JournalEntries,
events []Event, res result.Result) result.Result events []Event, res result.Result) result.Result
func (op TestOp) Plan(project workspace.Project, target deploy.Target, opts UpdateOptions,
backendClient deploy.BackendClient, validate ValidateFunc) (*deploy.Plan, result.Result) {
plan, _, res := op.runWithContext(context.Background(), project, target, opts, true, backendClient, validate)
return plan, res
}
func (op TestOp) Run(project workspace.Project, target deploy.Target, opts UpdateOptions, func (op TestOp) Run(project workspace.Project, target deploy.Target, opts UpdateOptions,
dryRun bool, backendClient deploy.BackendClient, validate ValidateFunc) (*deploy.Snapshot, result.Result) { dryRun bool, backendClient deploy.BackendClient, validate ValidateFunc) (*deploy.Snapshot, result.Result) {
@ -60,6 +69,15 @@ func (op TestOp) RunWithContext(
target deploy.Target, opts UpdateOptions, dryRun bool, target deploy.Target, opts UpdateOptions, dryRun bool,
backendClient deploy.BackendClient, validate ValidateFunc) (*deploy.Snapshot, result.Result) { backendClient deploy.BackendClient, validate ValidateFunc) (*deploy.Snapshot, result.Result) {
_, snap, res := op.runWithContext(callerCtx, project, target, opts, dryRun, backendClient, validate)
return snap, res
}
func (op TestOp) runWithContext(
callerCtx context.Context, project workspace.Project,
target deploy.Target, opts UpdateOptions, dryRun bool,
backendClient deploy.BackendClient, validate ValidateFunc) (*deploy.Plan, *deploy.Snapshot, result.Result) {
// Create an appropriate update info and context. // Create an appropriate update info and context.
info := &updateInfo{project: project, target: target} info := &updateInfo{project: project, target: target}
@ -93,21 +111,21 @@ func (op TestOp) RunWithContext(
}() }()
// Run the step and its validator. // Run the step and its validator.
_, res := op(info, ctx, opts, dryRun) plan, _, res := op(info, ctx, opts, dryRun)
contract.IgnoreClose(journal) contract.IgnoreClose(journal)
if validate != nil { if validate != nil {
res = validate(project, target, journal.Entries(), firedEvents, res) res = validate(project, target, journal.Entries(), firedEvents, res)
} }
if dryRun { if dryRun {
return nil, res return plan, nil, res
} }
snap := journal.Snap(target.Snapshot) snap := journal.Snap(target.Snapshot)
if res == nil && snap != nil { if res == nil && snap != nil {
res = result.WrapIfNonNil(snap.VerifyIntegrity()) res = result.WrapIfNonNil(snap.VerifyIntegrity())
} }
return snap, res return nil, snap, res
} }
type TestStep struct { type TestStep struct {
@ -168,7 +186,7 @@ func (p *TestPlan) GetProject() workspace.Project {
} }
} }
func (p *TestPlan) GetTarget(snapshot *deploy.Snapshot) deploy.Target { func (p *TestPlan) GetTarget(t *testing.T, snapshot *deploy.Snapshot) deploy.Target {
stack, _, _ := p.getNames() stack, _, _ := p.getNames()
cfg := p.Config cfg := p.Config
@ -180,7 +198,10 @@ func (p *TestPlan) GetTarget(snapshot *deploy.Snapshot) deploy.Target {
Name: stack, Name: stack,
Config: cfg, Config: cfg,
Decrypter: p.Decrypter, Decrypter: p.Decrypter,
Snapshot: snapshot, // note: it's really important that the preview and update operate on different snapshots. the engine can and
// does mutate the snapshot in-place, even in previews, and sharing a snapshot between preview and update can
// cause state changes from the preview to persist even when doing an update.
Snapshot: CloneSnapshot(t, snapshot),
} }
} }
@ -188,6 +209,18 @@ func assertIsErrorOrBailResult(t *testing.T, res result.Result) {
assert.NotNil(t, res) assert.NotNil(t, res)
} }
// ClonePlan makes a deep copy of the given plan and returns a pointer to the clone.
func ClonePlan(t *testing.T, plan *deploy.Plan) *deploy.Plan {
t.Helper()
if plan != nil {
copiedPlan := copystructure.Must(copystructure.Copy(plan)).(*deploy.Plan)
assert.True(t, reflect.DeepEqual(plan, copiedPlan))
return copiedPlan
}
return plan
}
// CloneSnapshot makes a deep copy of the given snapshot and returns a pointer to the clone. // CloneSnapshot makes a deep copy of the given snapshot and returns a pointer to the clone.
func CloneSnapshot(t *testing.T, snap *deploy.Snapshot) *deploy.Snapshot { func CloneSnapshot(t *testing.T, snap *deploy.Snapshot) *deploy.Snapshot {
t.Helper() t.Helper()
@ -204,12 +237,9 @@ func (p *TestPlan) Run(t *testing.T, snapshot *deploy.Snapshot) *deploy.Snapshot
project := p.GetProject() project := p.GetProject()
snap := snapshot snap := snapshot
for _, step := range p.Steps { for _, step := range p.Steps {
// note: it's really important that the preview and update operate on different snapshots. the engine can and
// does mutate the snapshot in-place, even in previews, and sharing a snapshot between preview and update can
// cause state changes from the preview to persist even when doing an update.
if !step.SkipPreview { if !step.SkipPreview {
previewSnap := CloneSnapshot(t, snap) previewSnap := CloneSnapshot(t, snap)
previewTarget := p.GetTarget(previewSnap) previewTarget := p.GetTarget(t, previewSnap)
// Don't run validate on the preview step // Don't run validate on the preview step
_, res := step.Op.Run(project, previewTarget, p.Options, true, p.BackendClient, nil) _, res := step.Op.Run(project, previewTarget, p.Options, true, p.BackendClient, nil)
if step.ExpectFailure { if step.ExpectFailure {
@ -221,7 +251,7 @@ func (p *TestPlan) Run(t *testing.T, snapshot *deploy.Snapshot) *deploy.Snapshot
} }
var res result.Result var res result.Result
target := p.GetTarget(snap) target := p.GetTarget(t, snap)
snap, res = step.Op.Run(project, target, p.Options, false, p.BackendClient, step.Validate) snap, res = step.Op.Run(project, target, p.Options, false, p.BackendClient, step.Validate)
if step.ExpectFailure { if step.ExpectFailure {
assertIsErrorOrBailResult(t, res) assertIsErrorOrBailResult(t, res)

View file

@ -23,7 +23,12 @@ import (
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace" "github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
) )
func Refresh(u UpdateInfo, ctx *Context, opts UpdateOptions, dryRun bool) (ResourceChanges, result.Result) { func Refresh(
u UpdateInfo,
ctx *Context,
opts UpdateOptions,
dryRun bool) (*deploy.Plan, ResourceChanges, result.Result) {
contract.Require(u != nil, "u") contract.Require(u != nil, "u")
contract.Require(ctx != nil, "ctx") contract.Require(ctx != nil, "ctx")
@ -31,13 +36,13 @@ func Refresh(u UpdateInfo, ctx *Context, opts UpdateOptions, dryRun bool) (Resou
info, err := newDeploymentContext(u, "refresh", ctx.ParentSpan) info, err := newDeploymentContext(u, "refresh", ctx.ParentSpan)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer info.Close() defer info.Close()
emitter, err := makeEventEmitter(ctx.Events, u) emitter, err := makeEventEmitter(ctx.Events, u)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer emitter.Close() defer emitter.Close()

View file

@ -146,6 +146,9 @@ type UpdateOptions struct {
// the plugin host to use for this update // the plugin host to use for this update
Host plugin.Host Host plugin.Host
// The plan to use for the update, if any.
Plan *deploy.Plan
} }
// ResourceChanges contains the aggregate resource changes by operation type. // ResourceChanges contains the aggregate resource changes by operation type.
@ -165,7 +168,9 @@ func (changes ResourceChanges) HasChanges() bool {
return c > 0 return c > 0
} }
func Update(u UpdateInfo, ctx *Context, opts UpdateOptions, dryRun bool) (ResourceChanges, result.Result) { func Update(u UpdateInfo, ctx *Context, opts UpdateOptions, dryRun bool) (
*deploy.Plan, ResourceChanges, result.Result) {
contract.Require(u != nil, "update") contract.Require(u != nil, "update")
contract.Require(ctx != nil, "ctx") contract.Require(ctx != nil, "ctx")
@ -173,13 +178,13 @@ func Update(u UpdateInfo, ctx *Context, opts UpdateOptions, dryRun bool) (Resour
info, err := newDeploymentContext(u, "update", ctx.ParentSpan) info, err := newDeploymentContext(u, "update", ctx.ParentSpan)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer info.Close() defer info.Close()
emitter, err := makeEventEmitter(ctx.Events, u) emitter, err := makeEventEmitter(ctx.Events, u)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer emitter.Close() defer emitter.Close()
@ -420,7 +425,7 @@ func newUpdateSource(
} }
func update(ctx *Context, info *deploymentContext, opts deploymentOptions, func update(ctx *Context, info *deploymentContext, opts deploymentOptions,
preview bool) (ResourceChanges, result.Result) { preview bool) (*deploy.Plan, ResourceChanges, result.Result) {
// Refresh and Import do not execute Policy Packs. // Refresh and Import do not execute Policy Packs.
policies := map[string]string{} policies := map[string]string{}
@ -445,7 +450,7 @@ func update(ctx *Context, info *deploymentContext, opts deploymentOptions,
deployment, err := newDeployment(ctx, info, opts, preview) deployment, err := newDeployment(ctx, info, opts, preview)
if err != nil { if err != nil {
return nil, result.FromError(err) return nil, nil, result.FromError(err)
} }
defer contract.IgnoreClose(deployment) defer contract.IgnoreClose(deployment)

View file

@ -28,6 +28,7 @@ import (
"github.com/pulumi/pulumi/pkg/v3/resource/graph" "github.com/pulumi/pulumi/pkg/v3/resource/graph"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag" "github.com/pulumi/pulumi/sdk/v3/go/common/diag"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource" "github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin" "github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens" "github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
@ -145,6 +146,36 @@ func (m *resourceMap) mapRange(callback func(urn resource.URN, state *resource.S
}) })
} }
type resourcePlans struct {
m sync.RWMutex
plans Plan
}
func newResourcePlan(config config.Map) *resourcePlans {
return &resourcePlans{
plans: NewPlan(config),
}
}
func (m *resourcePlans) set(urn resource.URN, plan *ResourcePlan) {
m.m.Lock()
defer m.m.Unlock()
m.plans.ResourcePlans[urn] = plan
}
func (m *resourcePlans) get(urn resource.URN) (*ResourcePlan, bool) {
m.m.RLock()
defer m.m.RUnlock()
p, ok := m.plans.ResourcePlans[urn]
return p, ok
}
func (m *resourcePlans) plan() *Plan {
return &m.plans
}
// A Deployment manages the iterative computation and execution of a deployment based on a stream of goal states. // A Deployment manages the iterative computation and execution of a deployment based on a stream of goal states.
// A running deployment emits events that indicate its progress. These events must be used to record the new state // A running deployment emits events that indicate its progress. These events must be used to record the new state
// of the deployment target. // of the deployment target.
@ -153,6 +184,7 @@ type Deployment struct {
target *Target // the deployment target. target *Target // the deployment target.
prev *Snapshot // the old resource snapshot for comparison. prev *Snapshot // the old resource snapshot for comparison.
olds map[resource.URN]*resource.State // a map of all old resources. olds map[resource.URN]*resource.State // a map of all old resources.
plan *Plan // a map of all planned resource changes, if any.
imports []Import // resources to import, if this is an import deployment. imports []Import // resources to import, if this is an import deployment.
isImport bool // true if this is an import deployment. isImport bool // true if this is an import deployment.
schemaLoader schema.Loader // the schema cache for this deployment, if any. schemaLoader schema.Loader // the schema cache for this deployment, if any.
@ -162,7 +194,8 @@ type Deployment struct {
depGraph *graph.DependencyGraph // the dependency graph of the old snapshot. depGraph *graph.DependencyGraph // the dependency graph of the old snapshot.
providers *providers.Registry // the provider registry for this deployment. providers *providers.Registry // the provider registry for this deployment.
goals *goalMap // the set of resource goals generated by the deployment. goals *goalMap // the set of resource goals generated by the deployment.
news *resourceMap // the set of new resources generated by the deployment. news *resourceMap // the set of new resources generated by the deployment
newPlans *resourcePlans // the set of new resource plans.
} }
// addDefaultProviders adds any necessary default provider definitions and references to the given snapshot. Version // addDefaultProviders adds any necessary default provider definitions and references to the given snapshot. Version
@ -299,7 +332,7 @@ func buildResourceMap(prev *Snapshot, preview bool) ([]*resource.State, map[reso
// //
// Note that a deployment uses internal concurrency and parallelism in various ways, so it must be closed if for some // Note that a deployment uses internal concurrency and parallelism in various ways, so it must be closed if for some
// reason it isn't carried out to its final conclusion. This will result in cancellation and reclamation of resources. // reason it isn't carried out to its final conclusion. This will result in cancellation and reclamation of resources.
func NewDeployment(ctx *plugin.Context, target *Target, prev *Snapshot, source Source, func NewDeployment(ctx *plugin.Context, target *Target, prev *Snapshot, plan *Plan, source Source,
localPolicyPackPaths []string, preview bool, backendClient BackendClient) (*Deployment, error) { localPolicyPackPaths []string, preview bool, backendClient BackendClient) (*Deployment, error) {
contract.Assert(ctx != nil) contract.Assert(ctx != nil)
@ -343,6 +376,7 @@ func NewDeployment(ctx *plugin.Context, target *Target, prev *Snapshot, source S
ctx: ctx, ctx: ctx,
target: target, target: target,
prev: prev, prev: prev,
plan: plan,
olds: olds, olds: olds,
source: source, source: source,
localPolicyPackPaths: localPolicyPackPaths, localPolicyPackPaths: localPolicyPackPaths,
@ -351,6 +385,7 @@ func NewDeployment(ctx *plugin.Context, target *Target, prev *Snapshot, source S
providers: reg, providers: reg,
goals: newGoals, goals: newGoals,
news: newResources, news: newResources,
newPlans: newResourcePlan(target.Config),
}, nil }, nil
} }
@ -405,7 +440,7 @@ func (d *Deployment) generateEventURN(event SourceEvent) resource.URN {
} }
// Execute executes a deployment to completion, using the given cancellation context and running a preview or update. // Execute executes a deployment to completion, using the given cancellation context and running a preview or update.
func (d *Deployment) Execute(ctx context.Context, opts Options, preview bool) result.Result { func (d *Deployment) Execute(ctx context.Context, opts Options, preview bool) (*Plan, result.Result) {
deploymentExec := &deploymentExecutor{deployment: d} deploymentExec := &deploymentExecutor{deployment: d}
return deploymentExec.Execute(ctx, opts, preview) return deploymentExec.Execute(ctx, opts, preview)
} }

View file

@ -114,7 +114,7 @@ func (ex *deploymentExecutor) reportError(urn resource.URN, err error) {
// Execute executes a deployment to completion, using the given cancellation context and running a preview // Execute executes a deployment to completion, using the given cancellation context and running a preview
// or update. // or update.
func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, preview bool) result.Result { func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, preview bool) (*Plan, result.Result) {
// Set up a goroutine that will signal cancellation to the deployment's plugins if the caller context is cancelled. // Set up a goroutine that will signal cancellation to the deployment's plugins if the caller context is cancelled.
// We do not hang this off of the context we create below because we do not want the failure of a single step to // We do not hang this off of the context we create below because we do not want the failure of a single step to
// cause other steps to fail. // cause other steps to fail.
@ -141,10 +141,10 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
// Before doing anything else, optionally refresh each resource in the base checkpoint. // Before doing anything else, optionally refresh each resource in the base checkpoint.
if opts.Refresh { if opts.Refresh {
if res := ex.refresh(callerCtx, opts, preview); res != nil { if res := ex.refresh(callerCtx, opts, preview); res != nil {
return res return nil, res
} }
if opts.RefreshOnly { if opts.RefreshOnly {
return nil return nil, nil
} }
} }
@ -156,10 +156,10 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
replaceTargetsOpt := createTargetMap(opts.ReplaceTargets) replaceTargetsOpt := createTargetMap(opts.ReplaceTargets)
destroyTargetsOpt := createTargetMap(opts.DestroyTargets) destroyTargetsOpt := createTargetMap(opts.DestroyTargets)
if res := ex.checkTargets(opts.ReplaceTargets, OpReplace); res != nil { if res := ex.checkTargets(opts.ReplaceTargets, OpReplace); res != nil {
return res return nil, res
} }
if res := ex.checkTargets(opts.DestroyTargets, OpDelete); res != nil { if res := ex.checkTargets(opts.DestroyTargets, OpDelete); res != nil {
return res return nil, res
} }
if (updateTargetsOpt != nil || replaceTargetsOpt != nil) && destroyTargetsOpt != nil { if (updateTargetsOpt != nil || replaceTargetsOpt != nil) && destroyTargetsOpt != nil {
@ -169,7 +169,7 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
// Begin iterating the source. // Begin iterating the source.
src, res := ex.deployment.source.Iterate(callerCtx, opts, ex.deployment) src, res := ex.deployment.source.Iterate(callerCtx, opts, ex.deployment)
if res != nil { if res != nil {
return res return nil, res
} }
// Set up a step generator for this deployment. // Set up a step generator for this deployment.
@ -177,7 +177,7 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
// Retire any pending deletes that are currently present in this deployment. // Retire any pending deletes that are currently present in this deployment.
if res := ex.retirePendingDeletes(callerCtx, opts, preview); res != nil { if res := ex.retirePendingDeletes(callerCtx, opts, preview); res != nil {
return res return nil, res
} }
// Derive a cancellable context for this deployment. We will only cancel this context if some piece of the // Derive a cancellable context for this deployment. We will only cancel this context if some piece of the
@ -236,7 +236,15 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
} }
if event.Event == nil { if event.Event == nil {
return false, ex.performDeletes(ctx, updateTargetsOpt, destroyTargetsOpt) res := ex.performDeletes(ctx, updateTargetsOpt, destroyTargetsOpt)
if res != nil {
if resErr := res.Error(); resErr != nil {
logging.V(4).Infof("deploymentExecutor.Execute(...): error performing deletes: %v", resErr)
ex.reportError("", resErr)
return false, result.Bail()
}
}
return false, res
} }
if res := ex.handleSingleEvent(event.Event); res != nil { if res := ex.handleSingleEvent(event.Event); res != nil {
@ -267,8 +275,38 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
res = ex.checkTargets(opts.UpdateTargets, OpUpdate) res = ex.checkTargets(opts.UpdateTargets, OpUpdate)
} }
// Check that we did operations for everything expected in the plan. We mutate ResourcePlan.Ops as we run
// so by the time we get here everything in the map should have an empty ops list (except for unneeded deletes)
if res == nil && ex.deployment.plan != nil {
for urn, resourcePlan := range ex.deployment.plan.ResourcePlans {
if len(resourcePlan.Ops) != 0 {
if len(resourcePlan.Ops) == 1 && resourcePlan.Ops[0] == OpDelete {
// We haven't done a delete for this resource check if it was in the snapshot,
// if it's already gone this wasn't done because it wasn't needed
found := false
for i := range ex.deployment.prev.Resources {
if ex.deployment.prev.Resources[i].URN == urn {
found = true
break
}
}
// Didn't find the resource in the old snapshot so this was just an unneeded delete
if !found {
continue
}
}
err := fmt.Errorf("expected resource operations for %v but none were seen", urn)
logging.V(4).Infof("deploymentExecutor.Execute(...): error handling event: %v", err)
ex.reportError(urn, err)
res = result.Bail()
}
}
}
if res != nil && res.IsBail() { if res != nil && res.IsBail() {
return res return nil, res
} }
// If the step generator and step executor were both successful, then we send all the resources // If the step generator and step executor were both successful, then we send all the resources
@ -280,7 +318,7 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
logging.V(4).Infof("deploymentExecutor.Execute(...): error analyzing resources: %v", resErr) logging.V(4).Infof("deploymentExecutor.Execute(...): error analyzing resources: %v", resErr)
ex.reportError("", resErr) ex.reportError("", resErr)
} }
return result.Bail() return nil, result.Bail()
} }
} }
@ -289,13 +327,13 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
// TODO(cyrusn): We seem to be losing any information about the original 'res's errors. Should // TODO(cyrusn): We seem to be losing any information about the original 'res's errors. Should
// we be doing a merge here? // we be doing a merge here?
ex.reportExecResult("failed", preview) ex.reportExecResult("failed", preview)
return result.Bail() return nil, result.Bail()
} else if canceled { } else if canceled {
ex.reportExecResult("canceled", preview) ex.reportExecResult("canceled", preview)
return result.Bail() return nil, result.Bail()
} }
return res return ex.deployment.newPlans.plan(), res
} }
func (ex *deploymentExecutor) performDeletes( func (ex *deploymentExecutor) performDeletes(
@ -375,8 +413,7 @@ func (ex *deploymentExecutor) handleSingleEvent(event SourceEvent) result.Result
steps, res = ex.stepGen.GenerateReadSteps(e) steps, res = ex.stepGen.GenerateReadSteps(e)
case RegisterResourceOutputsEvent: case RegisterResourceOutputsEvent:
logging.V(4).Infof("deploymentExecutor.handleSingleEvent(...): received register resource outputs") logging.V(4).Infof("deploymentExecutor.handleSingleEvent(...): received register resource outputs")
ex.stepExec.ExecuteRegisterResourceOutputs(e) return ex.stepExec.ExecuteRegisterResourceOutputs(e)
return nil
} }
if res != nil { if res != nil {
@ -433,9 +470,13 @@ func (ex *deploymentExecutor) retirePendingDeletes(callerCtx context.Context, op
} }
// import imports a list of resources into a stack. // import imports a list of resources into a stack.
func (ex *deploymentExecutor) importResources(callerCtx context.Context, opts Options, preview bool) result.Result { func (ex *deploymentExecutor) importResources(
callerCtx context.Context,
opts Options,
preview bool) (*Plan, result.Result) {
if len(ex.deployment.imports) == 0 { if len(ex.deployment.imports) == 0 {
return nil return nil, nil
} }
// Create an executor for this import. // Create an executor for this import.
@ -461,12 +502,12 @@ func (ex *deploymentExecutor) importResources(callerCtx context.Context, opts Op
} else { } else {
ex.reportExecResult("failed", preview) ex.reportExecResult("failed", preview)
} }
return result.Bail() return nil, result.Bail()
} else if canceled { } else if canceled {
ex.reportExecResult("canceled", preview) ex.reportExecResult("canceled", preview)
return result.Bail() return nil, result.Bail()
} }
return nil return ex.deployment.newPlans.plan(), nil
} }
// refresh refreshes the state of the base checkpoint file for the current deployment in memory. // refresh refreshes the state of the base checkpoint file for the current deployment in memory.

View file

@ -42,7 +42,7 @@ func TestPendingOperationsDeployment(t *testing.T) {
}, },
}) })
_, err := NewDeployment(&plugin.Context{}, &Target{}, snap, &fixedSource{}, nil, false, nil) _, err := NewDeployment(&plugin.Context{}, &Target{}, snap, nil, &fixedSource{}, nil, false, nil)
if !assert.Error(t, err) { if !assert.Error(t, err) {
t.FailNow() t.FailNow()
} }

View file

@ -97,6 +97,7 @@ func NewImportDeployment(ctx *plugin.Context, target *Target, projectName tokens
source: NewErrorSource(projectName), source: NewErrorSource(projectName),
preview: preview, preview: preview,
providers: reg, providers: reg,
newPlans: newResourcePlan(target.Config),
}, nil }, nil
} }

View file

@ -0,0 +1,89 @@
// 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 deploy
import (
"crypto/sha256"
"fmt"
"time"
"github.com/blang/semver"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
)
// Manifest captures versions for all binaries used to construct this snapshot.
type Manifest struct {
Time time.Time // the time this snapshot was taken.
Magic string // a magic cookie.
Version string // the pulumi command version.
Plugins []workspace.PluginInfo // the plugin versions also loaded.
}
// Serialize turns a manifest into a data structure suitable for serialization.
func (m Manifest) Serialize() apitype.ManifestV1 {
manifest := apitype.ManifestV1{
Time: m.Time,
Magic: m.Magic,
Version: m.Version,
}
for _, plug := range m.Plugins {
var version string
if plug.Version != nil {
version = plug.Version.String()
}
manifest.Plugins = append(manifest.Plugins, apitype.PluginInfoV1{
Name: plug.Name,
Path: plug.Path,
Type: plug.Kind,
Version: version,
})
}
return manifest
}
// DeserializeManifest deserializes a typed ManifestV1 into a `deploy.Manifest`.
func DeserializeManifest(m apitype.ManifestV1) (*Manifest, error) {
manifest := Manifest{
Time: m.Time,
Magic: m.Magic,
Version: m.Version,
}
for _, plug := range m.Plugins {
var version *semver.Version
if v := plug.Version; v != "" {
sv, err := semver.ParseTolerant(v)
if err != nil {
return nil, err
}
version = &sv
}
manifest.Plugins = append(manifest.Plugins, workspace.PluginInfo{
Name: plug.Name,
Kind: plug.Type,
Version: version,
})
}
return &manifest, nil
}
// NewMagic creates a magic cookie out of a manifest; this can be used to check for tampering. This ignores
// any existing magic value already stored on the manifest.
func (m Manifest) NewMagic() string {
if m.Version == "" {
return ""
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(m.Version)))
}

496
pkg/resource/deploy/plan.go Normal file
View file

@ -0,0 +1,496 @@
package deploy
import (
"fmt"
"sort"
"strings"
"time"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy/providers"
"github.com/pulumi/pulumi/pkg/v3/version"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
)
// A Plan is a mapping from URNs to ResourcePlans. The plan defines an expected set of resources and the expected
// inputs and operations for each. The inputs and operations are treated as constraints, and may allow for inputs or
// operations that do not exactly match those recorded in the plan. In the case of inputs, unknown values in the plan
// accept any value (including no value) as valid. For operations, a same step is allowed in place of an update or
// a replace step, and an update is allowed in place of a replace step. All resource options are required to match
// exactly.
type Plan struct {
ResourcePlans map[resource.URN]*ResourcePlan
Manifest Manifest
// Any environment variables that were set when the plan was created. Values are encrypted.
EnvironmentVariables map[string][]byte
// The configuration in use during the plan.
Config config.Map
}
func NewPlan(config config.Map) Plan {
manifest := Manifest{
Time: time.Now(),
Version: version.Version,
// Plugins: sm.plugins, - Explicitly dropped, since we don't use the plugin list in the manifest anymore.
}
manifest.Magic = manifest.NewMagic()
return Plan{
ResourcePlans: make(map[resource.URN]*ResourcePlan),
Manifest: manifest,
Config: config,
}
}
// Goal is a desired state for a resource object. Normally it represents a subset of the resource's state expressed by
// a program, however if Output is true, it represents a more complete, post-deployment view of the state.
type GoalPlan struct {
// the type of resource.
Type tokens.Type
// the name for the resource's URN.
Name tokens.QName
// true if this resource is custom, managed by a plugin.
Custom bool
// the resource's properties we expect to add.
Adds resource.PropertyMap
// the resource's properties we expect to delete.
Deletes []resource.PropertyKey
// the resource's properties we expect to update.
Updates resource.PropertyMap
// an optional parent URN for this resource.
Parent resource.URN
// true to protect this resource from deletion.
Protect bool
// dependencies of this resource object.
Dependencies []resource.URN
// the provider to use for this resource.
Provider string
// the set of dependencies that affect each property.
PropertyDependencies map[resource.PropertyKey][]resource.URN
// true if this resource should be deleted prior to replacement.
DeleteBeforeReplace *bool
// a list of property names to ignore during changes.
IgnoreChanges []string
// outputs that should always be treated as secrets.
AdditionalSecretOutputs []resource.PropertyKey
// additional URNs that should be aliased to this resource.
Aliases []resource.URN
// the expected ID of the resource, if any.
ID resource.ID
// an optional config object for resource options
CustomTimeouts resource.CustomTimeouts
}
func NewGoalPlan(oldOutputs resource.PropertyMap, goal *resource.Goal) *GoalPlan {
if goal == nil {
return nil
}
var adds resource.PropertyMap
var deletes []resource.PropertyKey
var updates resource.PropertyMap
if diff, hasDiff := oldOutputs.DiffIncludeUnknowns(goal.Properties); hasDiff {
adds = diff.Adds
updates = make(resource.PropertyMap)
for k := range diff.Updates {
updates[k] = diff.Updates[k].New
}
deletes = make([]resource.PropertyKey, len(diff.Deletes))
i := 0
for k := range diff.Deletes {
deletes[i] = k
i = i + 1
}
}
return &GoalPlan{
Type: goal.Type,
Name: goal.Name,
Custom: goal.Custom,
Adds: adds,
Deletes: deletes,
Updates: updates,
Parent: goal.Parent,
Protect: goal.Protect,
Dependencies: goal.Dependencies,
Provider: goal.Provider,
PropertyDependencies: goal.PropertyDependencies,
DeleteBeforeReplace: goal.DeleteBeforeReplace,
IgnoreChanges: goal.IgnoreChanges,
AdditionalSecretOutputs: goal.AdditionalSecretOutputs,
Aliases: goal.Aliases,
ID: goal.ID,
CustomTimeouts: goal.CustomTimeouts,
}
}
// A ResourcePlan represents the planned goal state and resource operations for a single resource. The operations are
// ordered.
type ResourcePlan struct {
Goal *GoalPlan
Ops []StepOp
Outputs resource.PropertyMap
}
func (rp *ResourcePlan) diffURNs(a, b []resource.URN) (message string, changed bool) {
stringsA := make([]string, len(a))
for i, urn := range a {
stringsA[i] = string(urn)
}
stringsB := make([]string, len(a))
for i, urn := range b {
stringsB[i] = string(urn)
}
return rp.diffStrings(stringsA, stringsB)
}
func (rp *ResourcePlan) diffPropertyKeys(a, b []resource.PropertyKey) (message string, changed bool) {
stringsA := make([]string, len(a))
for i, key := range a {
stringsA[i] = string(key)
}
stringsB := make([]string, len(a))
for i, key := range b {
stringsB[i] = string(key)
}
return rp.diffStrings(stringsA, stringsB)
}
func (rp *ResourcePlan) diffStrings(a, b []string) (message string, changed bool) {
setA := map[string]struct{}{}
for _, s := range a {
setA[s] = struct{}{}
}
setB := map[string]struct{}{}
for _, s := range b {
setB[s] = struct{}{}
}
var adds, deletes []string
for s := range setA {
if _, has := setB[s]; !has {
deletes = append(deletes, s)
}
}
for s := range setB {
if _, has := setA[s]; !has {
adds = append(adds, s)
}
}
sort.Strings(adds)
sort.Strings(deletes)
if len(adds) == 0 && len(deletes) == 0 {
return "", false
}
if len(adds) != 0 {
message = fmt.Sprintf("added %v", strings.Join(adds, ", "))
}
if len(deletes) != 0 {
if len(adds) != 0 {
message += "; "
}
message += fmt.Sprintf("deleted %v", strings.Join(deletes, ", "))
}
return message, true
}
func (rp *ResourcePlan) diffPropertyDependencies(a, b map[resource.PropertyKey][]resource.URN) error {
return nil
}
// This is similar to ResourcePlan.checkGoal but for the case we're we don't have a goal saved.
// This simple checks that we're not changing anything.
func checkMissingPlan(
oldState *resource.State,
newInputs resource.PropertyMap,
programGoal *resource.Goal) error {
// We new up a fake ResourcePlan that matches the old state and then simply call checkGoal on it.
goal := &GoalPlan{
Type: oldState.Type,
Name: oldState.URN.Name(),
Custom: oldState.Custom,
Adds: nil,
Deletes: nil,
Updates: nil,
Parent: oldState.Parent,
Protect: oldState.Protect,
Dependencies: oldState.Dependencies,
Provider: oldState.Provider,
PropertyDependencies: oldState.PropertyDependencies,
DeleteBeforeReplace: nil,
IgnoreChanges: nil,
AdditionalSecretOutputs: oldState.AdditionalSecretOutputs,
Aliases: oldState.Aliases,
ID: "",
CustomTimeouts: oldState.CustomTimeouts,
}
rp := ResourcePlan{Goal: goal}
return rp.checkGoal(oldState.Outputs, newInputs, programGoal)
}
func (rp *ResourcePlan) checkGoal(
oldOutputs resource.PropertyMap,
newInputs resource.PropertyMap,
programGoal *resource.Goal) error {
contract.Assert(programGoal != nil)
contract.Assert(newInputs != nil)
// rp.Goal may be nil, but if it isn't Type and Name should match
contract.Assert(rp.Goal == nil || rp.Goal.Type == programGoal.Type)
contract.Assert(rp.Goal == nil || rp.Goal.Name == programGoal.Name)
if rp.Goal == nil {
// If the plan goal is nil it expected a delete
return fmt.Errorf("resource unexpectedly not deleted")
}
// Check that either both resources are custom resources or both are component resources.
if programGoal.Custom != rp.Goal.Custom {
// TODO(pdg-plan): wording?
expected := "custom"
if !rp.Goal.Custom {
expected = "component"
}
return fmt.Errorf("resource kind changed (expected %v)", expected)
}
// Check that the provider is identical.
if rp.Goal.Provider != programGoal.Provider {
// Provider references are a combination of URN and ID, the latter of which may be unknown. Check for that
// case here.
expected, err := providers.ParseReference(rp.Goal.Provider)
if err != nil {
return fmt.Errorf("failed to parse provider reference %v: %w", rp.Goal.Provider, err)
}
actual, err := providers.ParseReference(programGoal.Provider)
if err != nil {
return fmt.Errorf("failed to parse provider reference %v: %w", programGoal.Provider, err)
}
if expected.URN() != actual.URN() || expected.ID() != providers.UnknownID {
return fmt.Errorf("provider changed (expected %v)", rp.Goal.Provider)
}
}
// Check that the parent is identical.
if programGoal.Parent != rp.Goal.Parent {
return fmt.Errorf("parent changed (expected %v)", rp.Goal.Parent)
}
// Check that the protect bit is identical.
if programGoal.Protect != rp.Goal.Protect {
return fmt.Errorf("protect changed (expected %v)", rp.Goal.Protect)
}
// Check that the DBR bit is identical.
switch {
case rp.Goal.DeleteBeforeReplace == nil && programGoal.DeleteBeforeReplace == nil:
// OK
case rp.Goal.DeleteBeforeReplace != nil && programGoal.DeleteBeforeReplace != nil:
if *rp.Goal.DeleteBeforeReplace != *programGoal.DeleteBeforeReplace {
return fmt.Errorf("deleteBeforeReplace changed (expected %v)", *rp.Goal.DeleteBeforeReplace)
}
default:
expected := "no value"
if rp.Goal.DeleteBeforeReplace != nil {
expected = fmt.Sprintf("%v", *rp.Goal.DeleteBeforeReplace)
}
return fmt.Errorf("deleteBeforeReplace changed (expected %v)", expected)
}
// Check that the import ID is identical.
if rp.Goal.ID != programGoal.ID {
return fmt.Errorf("importID changed (expected %v)", rp.Goal.ID)
}
// Check that the timeouts are identical.
switch {
case rp.Goal.CustomTimeouts.Create != programGoal.CustomTimeouts.Create:
return fmt.Errorf("create timeout changed (expected %v)", rp.Goal.CustomTimeouts.Create)
case rp.Goal.CustomTimeouts.Update != programGoal.CustomTimeouts.Update:
return fmt.Errorf("update timeout changed (expected %v)", rp.Goal.CustomTimeouts.Update)
case rp.Goal.CustomTimeouts.Delete != programGoal.CustomTimeouts.Delete:
return fmt.Errorf("delete timeout changed (expected %v)", rp.Goal.CustomTimeouts.Delete)
}
// Check that the ignoreChanges sets are identical.
if message, changed := rp.diffStrings(rp.Goal.IgnoreChanges, programGoal.IgnoreChanges); changed {
return fmt.Errorf("ignoreChanges changed: %v", message)
}
// Check that the additionalSecretOutputs sets are identical.
if message, changed := rp.diffPropertyKeys(
rp.Goal.AdditionalSecretOutputs, programGoal.AdditionalSecretOutputs); changed {
return fmt.Errorf("additionalSecretOutputs changed: %v", message)
}
// Check that the alias sets are identical.
if message, changed := rp.diffURNs(rp.Goal.Aliases, programGoal.Aliases); changed {
return fmt.Errorf("aliases changed: %v", message)
}
// Check that the dependencies match.
if message, changed := rp.diffURNs(rp.Goal.Dependencies, programGoal.Dependencies); changed {
return fmt.Errorf("dependencies changed: %v", message)
}
// Check that the property diffs meet the constraints set in the plan.
changes := []string{}
var diff *resource.ObjectDiff
var hasDiff bool
if diff, hasDiff = oldOutputs.DiffIncludeUnknowns(newInputs); hasDiff {
// Check that any adds are in the goal for adds
for k := range diff.Adds {
if expected, has := rp.Goal.Adds[k]; has {
actual := diff.Adds[k]
if !expected.DeepEqualsIncludeUnknowns(actual) {
// diff wants to add this with value X but constraint wants to add with value Y
changes = append(changes, "+"+string(k))
}
} else {
// diff wants to add this, but not listed as an add in the constraints
changes = append(changes, "+"+string(k))
}
}
// Check that any removes are in the goal for removes
for k := range diff.Deletes {
found := false
for i := range rp.Goal.Deletes {
if rp.Goal.Deletes[i] == k {
found = true
break
}
}
if !found {
// diff wants to delete this, but not listed as a delete in the constraints
changes = append(changes, "-"+string(k))
}
}
// Check that any changes are in the goal for changes or adds
// "or adds" is because if our constraint says to add K=V and someone has already
// added K=W we don't consider it a constraint violation to update K to V.
// This is similar to how if we have a Create resource constraint we don't consider it
// a violation to just update it instead of creating it.
for k := range diff.Updates {
actual := diff.Updates[k].New
if expected, has := rp.Goal.Updates[k]; has {
if !expected.DeepEqualsIncludeUnknowns(actual) {
// diff wants to change this with value X but constraint wants to change with value Y
changes = append(changes, "~"+string(k))
}
} else if expected, has := rp.Goal.Adds[k]; has {
if !expected.DeepEqualsIncludeUnknowns(actual) {
// diff wants to change this with value X but constraint wants to add with value Y
changes = append(changes, "~"+string(k))
}
} else {
// diff wants to update this, but not listed as an update in the constraints
changes = append(changes, "~"+string(k))
}
}
} else {
// No diff, just new up an empty ObjectDiff for checks below
diff = &resource.ObjectDiff{}
}
// Symmetric check, check that the constraints didn't expect things to happen that aren't in the new inputs
for k := range rp.Goal.Adds {
// We expected an add, make sure the value is in the new inputs.
// That means it's either an add, update, or a same, both are ok for an add constraint.
expected := rp.Goal.Adds[k]
// If this is in diff.Adds or diff.Updates we'll of already checked it
_, inAdds := diff.Adds[k]
_, inUpdates := diff.Updates[k]
if !inAdds && !inUpdates {
// It wasn't in the diff as an add or update so check we have a same
if actual, has := newInputs[k]; has {
if !expected.DeepEqualsIncludeUnknowns(actual) {
// diff wants to same this with value X but constraint wants to add with value Y
changes = append(changes, "~"+string(k))
}
} else {
// Not a same, update or an add but constraint wants to add it
changes = append(changes, "-"+string(k))
}
}
}
for k := range rp.Goal.Updates {
// We expected an update, make sure the value is in the new inputs as an update (not an add)
expected := rp.Goal.Updates[k]
// If this is in diff.Updates we'll of already checked it
_, inUpdates := diff.Updates[k]
if !inUpdates {
// Check if this was in adds, it's not ok to have an update constraint but actually do an add
_, inAdds := diff.Adds[k]
if inAdds {
// Constraint wants to update it, but diff wants to add it
changes = append(changes, "+"+string(k))
} else if actual, has := newInputs[k]; has {
// It wasn't in the diff as an add so check we have a same
if !expected.DeepEqualsIncludeUnknowns(actual) {
// diff wants to same this with value X but constraint wants to update with value Y
changes = append(changes, "~"+string(k))
}
} else {
// Not a same or an update but constraint wants to update it
changes = append(changes, "-"+string(k))
}
}
}
for i := range rp.Goal.Deletes {
// We expected a delete, make sure its not present
k := rp.Goal.Deletes[i]
// If this is in diff.Deletes we'll of already checked it
_, inDeletes := diff.Deletes[k]
if !inDeletes {
// See if this is an add, update, or same
if _, has := diff.Adds[k]; has {
// Constraint wants to delete this but diff wants to add it
changes = append(changes, "+"+string(k))
} else if _, has := diff.Updates[k]; has {
// Constraint wants to delete this but diff wants to update it
changes = append(changes, "~"+string(k))
} else if _, has := diff.Sames[k]; has {
// Constraint wants to delete this but diff wants to leave it same
changes = append(changes, "~"+string(k))
}
}
}
if len(changes) > 0 {
// Sort changes, mostly so it's easy to write tests against determinstic strings
sort.Strings(changes)
return fmt.Errorf("properties changed: %v", strings.Join(changes, ", "))
}
// Check that the property dependencies match. Note that because it is legal for a property that is unknown in the
// plan to be unset in the program, we allow the omission of a property from the program's dependency set.
for k, urns := range rp.Goal.PropertyDependencies {
if programDeps, ok := programGoal.PropertyDependencies[k]; ok {
if message, changed := rp.diffURNs(urns, programDeps); changed {
return fmt.Errorf("dependencies for %v changed: %v", k, message)
}
}
}
return nil
}

View file

@ -15,15 +15,12 @@
package deploy package deploy
import ( import (
"crypto/sha256"
"fmt" "fmt"
"time"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy/providers" "github.com/pulumi/pulumi/pkg/v3/resource/deploy/providers"
"github.com/pulumi/pulumi/pkg/v3/secrets" "github.com/pulumi/pulumi/pkg/v3/secrets"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource" "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/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
) )
// Snapshot is a view of a collection of resources in an stack at a point in time. It describes resources; their // Snapshot is a view of a collection of resources in an stack at a point in time. It describes resources; their
@ -36,23 +33,6 @@ type Snapshot struct {
PendingOperations []resource.Operation // all currently pending resource operations. PendingOperations []resource.Operation // all currently pending resource operations.
} }
// Manifest captures versions for all binaries used to construct this snapshot.
type Manifest struct {
Time time.Time // the time this snapshot was taken.
Magic string // a magic cookie.
Version string // the pulumi command version.
Plugins []workspace.PluginInfo // the plugin versions also loaded.
}
// NewMagic creates a magic cookie out of a manifest; this can be used to check for tampering. This ignores
// any existing magic value already stored on the manifest.
func (m Manifest) NewMagic() string {
if m.Version == "" {
return ""
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(m.Version)))
}
// NewSnapshot creates a snapshot from the given arguments. The resources must be in topologically sorted order. // NewSnapshot creates a snapshot from the given arguments. The resources must be in topologically sorted order.
// This property is not checked; for verification, please refer to the VerifyIntegrity function below. // This property is not checked; for verification, please refer to the VerifyIntegrity function below.
func NewSnapshot(manifest Manifest, secretsManager secrets.Manager, func NewSnapshot(manifest Manifest, secretsManager secrets.Manager,

View file

@ -1166,6 +1166,28 @@ func (op StepOp) Suffix() string {
return "" return ""
} }
// ConstrainedTo returns true if this operation is no more impactful than the constraint.
func (op StepOp) ConstrainedTo(constraint StepOp) bool {
var allowed []StepOp
switch constraint {
case OpSame, OpDelete, OpRead, OpReadReplacement, OpRefresh, OpReadDiscard, OpDiscardReplaced,
OpRemovePendingReplace, OpImport, OpImportReplacement:
allowed = []StepOp{constraint}
case OpCreate:
allowed = []StepOp{OpSame, OpCreate}
case OpUpdate:
allowed = []StepOp{OpSame, OpUpdate}
case OpReplace, OpCreateReplacement, OpDeleteReplaced:
allowed = []StepOp{OpSame, OpUpdate, constraint}
}
for _, candidate := range allowed {
if candidate == op {
return true
}
}
return false
}
// getProvider fetches the provider for the given step. // getProvider fetches the provider for the given step.
func getProvider(s Step) (plugin.Provider, error) { func getProvider(s Step) (plugin.Provider, error) {
if providers.IsProviderType(s.Type()) { if providers.IsProviderType(s.Type()) {

View file

@ -25,6 +25,7 @@ import (
"github.com/pulumi/pulumi/sdk/v3/go/common/resource" "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/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/result"
) )
const ( const (
@ -147,7 +148,7 @@ func (se *stepExecutor) ExecuteParallel(antichain antichain) completionToken {
} }
// ExecuteRegisterResourceOutputs services a RegisterResourceOutputsEvent synchronously on the calling goroutine. // 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. // Look up the final state in the pending registration list.
urn := e.URN() urn := e.URN()
value, has := se.pendingNews.Load(urn) value, has := se.pendingNews.Load(urn)
@ -160,7 +161,27 @@ func (se *stepExecutor) ExecuteRegisterResourceOutputs(e RegisterResourceOutputs
outs := e.Outputs() outs := e.Outputs()
se.log(synchronousWorkerID, se.log(synchronousWorkerID,
"registered resource outputs %s: old=#%d, new=#%d", urn, len(reg.New().Outputs), len(outs)) "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.ResourcePlans[urn]
if !ok {
return result.FromError(fmt.Errorf("no plan for resource %v", urn))
}
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 there is an event subscription for finishing the resource, execute them.
if e := se.opts.Events; e != nil { if e := se.opts.Events; e != nil {
if eventerr := e.OnResourceOutputs(reg); eventerr != 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()) diagMsg := diag.RawMessage(reg.URN(), outErr.Error())
se.deployment.Diag().Errorf(diagMsg) se.deployment.Diag().Errorf(diagMsg)
se.cancelDueToError() se.cancelDueToError()
return return nil
} }
} }
e.Done() e.Done()
return nil
} }
// Errored returns whether or not this step executor saw a step whose execution ended in failure. // Errored returns whether or not this step executor saw a step whose execution ended in failure.
@ -295,6 +317,11 @@ func (se *stepExecutor) executeStep(workerID int, step Step) error {
if _, hasGoal := se.deployment.goals.get(newState.URN); hasGoal { if _, hasGoal := se.deployment.goals.get(newState.URN); hasGoal {
se.deployment.news.set(newState.URN, newState) se.deployment.news.set(newState.URN, newState)
} }
// Update the resource's outputs in the generated plan.
if resourcePlan, ok := se.deployment.newPlans.get(newState.URN); ok {
resourcePlan.Outputs = newState.Outputs
}
} }
if events != nil { if events != nil {

View file

@ -179,6 +179,39 @@ func (sg *stepGenerator) GenerateSteps(event RegisterResourceEvent) ([]Step, res
contract.Assert(len(steps) == 0) contract.Assert(len(steps) == 0)
return nil, res return nil, res
} }
// Check each proposed step against the relevant resource plan, if any
for _, s := range steps {
if sg.deployment.plan != nil {
if resourcePlan, ok := sg.deployment.plan.ResourcePlans[s.URN()]; ok {
if len(resourcePlan.Ops) == 0 {
return nil, result.Errorf("%v is not allowed by the plan: no more steps were expected for this resource", s.Op())
}
constraint := resourcePlan.Ops[0]
if !s.Op().ConstrainedTo(constraint) {
return nil, result.Errorf("%v is not allowed by the plan: this resource is constrained to %v", s.Op(), constraint)
}
resourcePlan.Ops = resourcePlan.Ops[1:]
} else {
if !s.Op().ConstrainedTo(OpSame) {
return nil, result.Errorf("%v is not allowed by the plan: no steps were expected for this resource", s.Op())
}
}
}
// Resource plan might be aliased
urn, isAliased := sg.aliased[s.URN()]
if !isAliased {
urn = s.URN()
}
resourcePlan, ok := sg.deployment.newPlans.get(urn)
if !ok {
return nil, result.Errorf("Expected a new resource plan for %v", urn)
}
resourcePlan.Ops = append(resourcePlan.Ops, s.Op())
}
if !sg.isTargetedUpdate() { if !sg.isTargetedUpdate() {
return steps, nil return steps, nil
} }
@ -272,6 +305,29 @@ func (sg *stepGenerator) generateSteps(event RegisterResourceEvent) ([]Step, res
inputs = processedInputs inputs = processedInputs
} }
// Generate the output goal plan
// TODO(pdg-plan): using the program inputs means that non-determinism could sneak in as part of default
// application. However, it is necessary in the face of computed inputs.
newResourcePlan := &ResourcePlan{Goal: NewGoalPlan(oldOutputs, goal)}
sg.deployment.newPlans.set(urn, newResourcePlan)
// If there is a plan for this resource, validate that the program goal conforms to the plan.
// If theres no plan for this resource check that nothing has been changed.
if sg.deployment.plan != nil {
resourcePlan, ok := sg.deployment.plan.ResourcePlans[urn]
if !ok {
if old == nil {
// We could error here, but we'll trigger an error later on anyway that Create isn't valid here
} else if err := checkMissingPlan(old, inputs, goal); err != nil {
return nil, result.FromError(fmt.Errorf("resource violates plan: %w", err))
}
} else {
if err := resourcePlan.checkGoal(oldOutputs, inputs, goal); err != nil {
return nil, result.FromError(fmt.Errorf("resource violates plan: %w", err))
}
}
}
// Produce a new state object that we'll build up as operations are performed. Ultimately, this is what will // Produce a new state object that we'll build up as operations are performed. Ultimately, this is what will
// get serialized into the checkpoint file. // get serialized into the checkpoint file.
new := resource.NewState(goal.Type, urn, goal.Custom, false, "", inputs, nil, goal.Parent, goal.Protect, false, new := resource.NewState(goal.Type, urn, goal.Custom, false, "", inputs, nil, goal.Parent, goal.Protect, false,
@ -646,6 +702,13 @@ func (sg *stepGenerator) generateStepsFromDiff(
continue continue
} }
if _, ok := sg.deployment.newPlans.get(dependentResource.URN); !ok {
// We haven't see this resource before, create a new
// resource plan for it with no goal (because it's going to be a delete)
resourcePlan := &ResourcePlan{}
sg.deployment.newPlans.set(dependentResource.URN, resourcePlan)
}
sg.dependentReplaceKeys[dependentResource.URN] = toReplace[i].keys sg.dependentReplaceKeys[dependentResource.URN] = toReplace[i].keys
logging.V(7).Infof("Planner decided to delete '%v' due to dependence on condemned resource '%v'", logging.V(7).Infof("Planner decided to delete '%v' due to dependence on condemned resource '%v'",
@ -751,6 +814,40 @@ func (sg *stepGenerator) GenerateDeletes(targetsOpt map[resource.URN]bool) ([]St
} }
} }
// Check each proposed delete against the relevant resource plan
for _, s := range dels {
if sg.deployment.plan != nil {
if resourcePlan, ok := sg.deployment.plan.ResourcePlans[s.URN()]; ok {
if len(resourcePlan.Ops) == 0 {
return nil, result.Errorf("%v is not allowed by the plan: no more steps were expected for this resource", s.Op())
}
constraint := resourcePlan.Ops[0]
// We remove the Op from the list before doing the constraint check.
// This is because we look at Ops at the end to see if any expected operations didn't attempt to happen.
// This op has been attempted, it just might fail its constraint.
resourcePlan.Ops = resourcePlan.Ops[1:]
if !s.Op().ConstrainedTo(constraint) {
return nil, result.Errorf("%v is not allowed by the plan: this resource is constrained to %v", s.Op(), constraint)
}
} else {
if !s.Op().ConstrainedTo(OpSame) {
return nil, result.Errorf("%v is not allowed by the plan: no steps were expected for this resource", s.Op())
}
}
}
resourcePlan, ok := sg.deployment.newPlans.get(s.URN())
if !ok {
// TODO(pdg-plan): using the program inputs means that non-determinism could sneak in as part of default
// application. However, it is necessary in the face of computed inputs.
resourcePlan = &ResourcePlan{}
sg.deployment.newPlans.set(s.URN(), resourcePlan)
}
resourcePlan.Ops = append(resourcePlan.Ops, s.Op())
}
// If -target was provided to either `pulumi update` or `pulumi destroy` then only delete // If -target was provided to either `pulumi update` or `pulumi destroy` then only delete
// resources that were specified. // resources that were specified.
allowedResourcesToDelete, res := sg.determineAllowedResourcesToDeleteFromTargets(targetsOpt) allowedResourcesToDelete, res := sg.determineAllowedResourcesToDeleteFromTargets(targetsOpt)

View file

@ -23,8 +23,6 @@ import (
"reflect" "reflect"
"strings" "strings"
"github.com/blang/semver"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy" "github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/pkg/v3/secrets" "github.com/pulumi/pulumi/pkg/v3/secrets"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype" "github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
@ -32,7 +30,6 @@ import (
"github.com/pulumi/pulumi/sdk/v3/go/common/resource" "github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config" "github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
"github.com/santhosh-tekuri/jsonschema/v5" "github.com/santhosh-tekuri/jsonschema/v5"
) )
@ -105,23 +102,7 @@ func SerializeDeployment(snap *deploy.Snapshot, sm secrets.Manager, showSecrets
contract.Require(snap != nil, "snap") contract.Require(snap != nil, "snap")
// Capture the version information into a manifest. // Capture the version information into a manifest.
manifest := apitype.ManifestV1{ manifest := snap.Manifest.Serialize()
Time: snap.Manifest.Time,
Magic: snap.Manifest.Magic,
Version: snap.Manifest.Version,
}
for _, plug := range snap.Manifest.Plugins {
var version string
if plug.Version != nil {
version = plug.Version.String()
}
manifest.Plugins = append(manifest.Plugins, apitype.PluginInfoV1{
Name: plug.Name,
Path: plug.Path,
Type: plug.Kind,
Version: version,
})
}
// If a specific secrets manager was not provided, use the one in the snapshot, if present. // If a specific secrets manager was not provided, use the one in the snapshot, if present.
if sm == nil { if sm == nil {
@ -223,25 +204,9 @@ func DeserializeUntypedDeployment(
// DeserializeDeploymentV3 deserializes a typed DeploymentV3 into a `deploy.Snapshot`. // DeserializeDeploymentV3 deserializes a typed DeploymentV3 into a `deploy.Snapshot`.
func DeserializeDeploymentV3(deployment apitype.DeploymentV3, secretsProv SecretsProvider) (*deploy.Snapshot, error) { func DeserializeDeploymentV3(deployment apitype.DeploymentV3, secretsProv SecretsProvider) (*deploy.Snapshot, error) {
// Unpack the versions. // Unpack the versions.
manifest := deploy.Manifest{ manifest, err := deploy.DeserializeManifest(deployment.Manifest)
Time: deployment.Manifest.Time, if err != nil {
Magic: deployment.Manifest.Magic, return nil, err
Version: deployment.Manifest.Version,
}
for _, plug := range deployment.Manifest.Plugins {
var version *semver.Version
if v := plug.Version; v != "" {
sv, err := semver.ParseTolerant(v)
if err != nil {
return nil, err
}
version = &sv
}
manifest.Plugins = append(manifest.Plugins, workspace.PluginInfo{
Name: plug.Name,
Kind: plug.Type,
Version: version,
})
} }
var secretsManager secrets.Manager var secretsManager secrets.Manager
@ -295,7 +260,7 @@ func DeserializeDeploymentV3(deployment apitype.DeploymentV3, secretsProv Secret
ops = append(ops, desop) ops = append(ops, desop)
} }
return deploy.NewSnapshot(manifest, secretsManager, resources, ops), nil return deploy.NewSnapshot(*manifest, secretsManager, resources, ops), nil
} }
// SerializeResource turns a resource into a structure suitable for serialization. // SerializeResource turns a resource into a structure suitable for serialization.

162
pkg/resource/stack/plan.go Normal file
View file

@ -0,0 +1,162 @@
package stack
import (
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
)
func SerializeResourcePlan(
plan *deploy.ResourcePlan,
enc config.Encrypter,
showSecrets bool) (apitype.ResourcePlanV1, error) {
adds, err := SerializeProperties(plan.Goal.Adds, enc, showSecrets)
if err != nil {
return apitype.ResourcePlanV1{}, err
}
updates, err := SerializeProperties(plan.Goal.Adds, enc, showSecrets)
if err != nil {
return apitype.ResourcePlanV1{}, err
}
deletes := make([]string, len(plan.Goal.Deletes))
for i := range deletes {
deletes[i] = string(plan.Goal.Deletes[i])
}
var outputs map[string]interface{}
if plan.Outputs != nil {
outs, err := SerializeProperties(plan.Outputs, enc, showSecrets)
if err != nil {
return apitype.ResourcePlanV1{}, err
}
outputs = outs
}
goal := apitype.GoalV1{
Type: plan.Goal.Type,
Name: plan.Goal.Name,
Custom: plan.Goal.Custom,
Adds: adds,
Deletes: deletes,
Updates: updates,
Parent: plan.Goal.Parent,
Protect: plan.Goal.Protect,
Dependencies: plan.Goal.Dependencies,
Provider: plan.Goal.Provider,
PropertyDependencies: plan.Goal.PropertyDependencies,
DeleteBeforeReplace: plan.Goal.DeleteBeforeReplace,
IgnoreChanges: plan.Goal.IgnoreChanges,
AdditionalSecretOutputs: plan.Goal.AdditionalSecretOutputs,
Aliases: plan.Goal.Aliases,
ID: plan.Goal.ID,
CustomTimeouts: plan.Goal.CustomTimeouts,
}
steps := make([]apitype.OpType, len(plan.Ops))
for i, op := range plan.Ops {
steps[i] = apitype.OpType(op)
}
return apitype.ResourcePlanV1{
Goal: goal,
Steps: steps,
Outputs: outputs,
}, nil
}
func SerializePlan(plan *deploy.Plan, enc config.Encrypter, showSecrets bool) (apitype.DeploymentPlanV1, error) {
resourcePlans := map[resource.URN]apitype.ResourcePlanV1{}
for urn, plan := range plan.ResourcePlans {
serializedPlan, err := SerializeResourcePlan(plan, enc, showSecrets)
if err != nil {
return apitype.DeploymentPlanV1{}, err
}
resourcePlans[urn] = serializedPlan
}
return apitype.DeploymentPlanV1{
Manifest: plan.Manifest.Serialize(),
ResourcePlans: resourcePlans,
Config: plan.Config,
}, nil
}
func DeserializeResourcePlan(
plan apitype.ResourcePlanV1,
dec config.Decrypter,
enc config.Encrypter) (*deploy.ResourcePlan, error) {
adds, err := DeserializeProperties(plan.Goal.Adds, dec, enc)
if err != nil {
return nil, err
}
updates, err := DeserializeProperties(plan.Goal.Updates, dec, enc)
if err != nil {
return nil, err
}
var outputs resource.PropertyMap
if plan.Outputs != nil {
outs, err := DeserializeProperties(plan.Outputs, dec, enc)
if err != nil {
return nil, err
}
outputs = outs
}
goal := &deploy.GoalPlan{
Type: plan.Goal.Type,
Name: plan.Goal.Name,
Custom: plan.Goal.Custom,
Adds: adds,
Deletes: nil,
Updates: updates,
Parent: plan.Goal.Parent,
Protect: plan.Goal.Protect,
Dependencies: plan.Goal.Dependencies,
Provider: plan.Goal.Provider,
PropertyDependencies: plan.Goal.PropertyDependencies,
DeleteBeforeReplace: plan.Goal.DeleteBeforeReplace,
IgnoreChanges: plan.Goal.IgnoreChanges,
AdditionalSecretOutputs: plan.Goal.AdditionalSecretOutputs,
Aliases: plan.Goal.Aliases,
ID: plan.Goal.ID,
CustomTimeouts: plan.Goal.CustomTimeouts,
}
ops := make([]deploy.StepOp, len(plan.Steps))
for i, op := range plan.Steps {
ops[i] = deploy.StepOp(op)
}
return &deploy.ResourcePlan{
Goal: goal,
Ops: ops,
Outputs: outputs,
}, nil
}
func DeserializePlan(plan apitype.DeploymentPlanV1, dec config.Decrypter, enc config.Encrypter) (*deploy.Plan, error) {
manifest, err := deploy.DeserializeManifest(plan.Manifest)
if err != nil {
return nil, err
}
deserializedPlan := &deploy.Plan{
Config: plan.Config,
Manifest: *manifest,
}
for urn, resourcePlan := range plan.ResourcePlans {
deserializedResourcePlan, err := DeserializeResourcePlan(resourcePlan, dec, enc)
if err != nil {
return nil, err
}
deserializedPlan.ResourcePlans[urn] = deserializedResourcePlan
}
return deserializedPlan, nil
}

View file

@ -62,6 +62,7 @@ require (
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/spf13/pflag v1.0.3 // indirect github.com/spf13/pflag v1.0.3 // indirect
github.com/src-d/gcfg v1.4.0 // indirect github.com/src-d/gcfg v1.4.0 // indirect
github.com/xanzy/ssh-agent v0.2.1 // indirect github.com/xanzy/ssh-agent v0.2.1 // indirect

View file

@ -0,0 +1,79 @@
package apitype
import (
"encoding/json"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
)
// GoalV1 is the serializable version of a resource goal state.
type GoalV1 struct {
// the type of resource.
Type tokens.Type `json:"type"`
// the name for the resource's URN.
Name tokens.QName `json:"name"`
// true if this resource is custom, managed by a plugin.
Custom bool `json:"custom"`
// the resource properties that will be added.
Adds map[string]interface{} `json:"adds,omitempty"`
// the resource properties that will be deleted.
Deletes []string `json:"deletes,omitempty"`
// the resource properties that will be updated.
Updates map[string]interface{} `json:"updates,omitempty"`
// an optional parent URN for this resource.
Parent resource.URN `json:"parent,omitempty"`
// true to protect this resource from deletion.
Protect bool `json:"protect"`
// dependencies of this resource object.
Dependencies []resource.URN `json:"dependencies,omitempty"`
// the provider to use for this resource.
Provider string `json:"provider,omitempty"`
// the set of dependencies that affect each property.
PropertyDependencies map[resource.PropertyKey][]resource.URN `json:"propertyDependencies,omitempty"`
// true if this resource should be deleted prior to replacement.
DeleteBeforeReplace *bool `json:"deleteBeforeReplace,omitempty"`
// a list of property names to ignore during changes.
IgnoreChanges []string `json:"ignoreChanges,omitempty"`
// outputs that should always be treated as secrets.
AdditionalSecretOutputs []resource.PropertyKey `json:"additionalSecretOutputs,omitempty"`
// additional URNs that should be aliased to this resource.
Aliases []resource.URN `json:"aliases,omitempty"`
// the expected ID of the resource, if any.
ID resource.ID `json:"id,omitempty"`
// an optional config object for resource options
CustomTimeouts resource.CustomTimeouts `json:"customTimeouts,omitempty"`
}
// ResourcePlanV1 is the serializable version of a resource plan.
type ResourcePlanV1 struct {
// The goal state for the resource.
Goal GoalV1 `json:"goal"`
// The steps to be performed on the resource.
Steps []OpType `json:"steps,omitempty"`
// The proposed outputs for the resource, if any. Purely advisory.
Outputs map[string]interface{} `json:"state"`
}
// VersionedDeploymentPlan is a version number plus a JSON document. The version number describes what
// version of the DeploymentPlan structure the DeploymentPlan member's JSON document can decode into.
type VersionedDeploymentPlan struct {
Version int `json:"version"`
Plan json.RawMessage `json:"plan"`
}
// DeploymentPlanV1 is the serializable version of a deployment plan.
type DeploymentPlanV1 struct {
// TODO(pdg-plan): should there be a message here?
// Manifest contains metadata about this plan.
Manifest ManifestV1 `json:"manifest" yaml:"manifest"`
// Any environment variables that were set when the plan was created. Values are encrypted.
EnvironmentVariables map[string][]byte `json:"environmentVariables,omitempty"`
// The configuration in use during the plan.
Config config.Map `json:"config,omitempty"`
// The set of resource plans.
ResourcePlans map[resource.URN]ResourcePlanV1 `json:"resourcePlans,omitempty"`
}

View file

@ -646,3 +646,71 @@ const OutputValueSig = "d0e6a833031e9bbcd3f4e8bde6ca49a4"
func IsInternalPropertyKey(key PropertyKey) bool { func IsInternalPropertyKey(key PropertyKey) bool {
return strings.HasPrefix(string(key), "__") return strings.HasPrefix(string(key), "__")
} }
// DeepCopy creates a new copy of this property map.
func (m PropertyMap) DeepCopy() PropertyMap {
copy := PropertyMap{}
for k, v := range m {
copy[k] = v.DeepCopy()
}
return copy
}
// DeepCopy creates a new copy of this property.
func (v PropertyValue) DeepCopy() PropertyValue {
switch {
case v.IsArray():
copy := make([]PropertyValue, len(v.ArrayValue()))
for i, v := range v.ArrayValue() {
copy[i] = v.DeepCopy()
}
return NewArrayProperty(copy)
case v.IsObject():
return NewObjectProperty(v.ObjectValue().DeepCopy())
case v.IsSecret():
return MakeSecret(v.SecretValue().Element.DeepCopy())
default:
return v
}
}
// FindUnknowns returns the set of paths to unknown values nested inside this property map.
func (m PropertyMap) FindUnknowns() []PropertyPath {
var paths []PropertyPath
for k, v := range m {
if v.IsComputed() || v.IsOutput() {
paths = append(paths, PropertyPath{string(k)})
} else {
for _, p := range v.FindUnknowns() {
p = append(PropertyPath{string(k)}, p...)
paths = append(paths, p)
}
}
}
return paths
}
// FindUnknowns returns the set of paths to unknown values nested inside this property map.
func (v PropertyValue) FindUnknowns() []PropertyPath {
switch {
case v.IsArray():
var paths []PropertyPath
for i, v := range v.ArrayValue() {
if v.IsComputed() || v.IsOutput() {
paths = append(paths, PropertyPath{i})
} else {
for _, p := range v.FindUnknowns() {
p = append(PropertyPath{i}, p...)
paths = append(paths, p)
}
}
}
return paths
case v.IsObject():
return v.ObjectValue().FindUnknowns()
case v.IsSecret():
return v.SecretValue().Element.FindUnknowns()
default:
return nil
}
}

View file

@ -78,6 +78,7 @@ func (diff *ObjectDiff) Keys() []PropertyKey {
type ValueDiff struct { type ValueDiff struct {
Old PropertyValue // the old value. Old PropertyValue // the old value.
New PropertyValue // the new value. New PropertyValue // the new value.
Secret *ValueDiff // the secret value diff (only for secrets)
Array *ArrayDiff // the array's detailed diffs (only for arrays). Array *ArrayDiff // the array's detailed diffs (only for arrays).
Object *ObjectDiff // the object's detailed diffs (only for objects). Object *ObjectDiff // the object's detailed diffs (only for objects).
} }
@ -119,8 +120,7 @@ func (diff *ArrayDiff) Len() int {
// IgnoreKeyFunc is the callback type for Diff's ignore option. // IgnoreKeyFunc is the callback type for Diff's ignore option.
type IgnoreKeyFunc func(key PropertyKey) bool type IgnoreKeyFunc func(key PropertyKey) bool
// Diff returns a diffset by comparing the property map to another; it returns nil if there are no diffs. func (props PropertyMap) diff(other PropertyMap, ignoreUnknowns bool, ignoreKeys []IgnoreKeyFunc) *ObjectDiff {
func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *ObjectDiff {
adds := make(PropertyMap) adds := make(PropertyMap)
deletes := make(PropertyMap) deletes := make(PropertyMap)
sames := make(PropertyMap) sames := make(PropertyMap)
@ -137,7 +137,7 @@ func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *O
// First find any updates or deletes. // First find any updates or deletes.
for k, old := range props { for k, old := range props {
if ignore(k) { if ignore(k) || ignoreUnknowns && (old.IsComputed() || old.IsOutput()) {
continue continue
} }
@ -145,7 +145,7 @@ func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *O
// If a new exists, use it; for output properties, however, ignore differences. // If a new exists, use it; for output properties, however, ignore differences.
if new.IsOutput() { if new.IsOutput() {
sames[k] = old sames[k] = old
} else if diff := old.Diff(new, ignoreKeys...); diff != nil { } else if diff := old.diff(new, ignoreUnknowns, ignoreKeys); diff != nil {
if !old.HasValue() { if !old.HasValue() {
adds[k] = new adds[k] = new
} else if !new.HasValue() { } else if !new.HasValue() {
@ -156,7 +156,7 @@ func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *O
} else { } else {
sames[k] = old sames[k] = old
} }
} else if old.HasValue() { } else if old.HasValue() && (!ignoreUnknowns || !old.IsComputed()) {
// If there was no new property, it has been deleted. // If there was no new property, it has been deleted.
deletes[k] = old deletes[k] = old
} }
@ -185,8 +185,12 @@ func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *O
} }
} }
// Diff returns a diff by comparing a single property value to another; it returns nil if there are no diffs. // Diff returns a diffset by comparing the property map to another; it returns nil if there are no diffs.
func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *ValueDiff { func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *ObjectDiff {
return props.diff(other, false, ignoreKeys)
}
func (v PropertyValue) diff(other PropertyValue, ignoreUnknowns bool, ignoreKeys []IgnoreKeyFunc) *ValueDiff {
if v.IsArray() && other.IsArray() { if v.IsArray() && other.IsArray() {
old := v.ArrayValue() old := v.ArrayValue()
new := other.ArrayValue() new := other.ArrayValue()
@ -204,7 +208,7 @@ func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *V
sames := make(map[int]PropertyValue) sames := make(map[int]PropertyValue)
updates := make(map[int]ValueDiff) updates := make(map[int]ValueDiff)
for i := 0; i < len(old) && i < len(new); i++ { for i := 0; i < len(old) && i < len(new); i++ {
if diff := old[i].Diff(new[i]); diff != nil { if diff := old[i].diff(new[i], ignoreUnknowns, ignoreKeys); diff != nil {
updates[i] = *diff updates[i] = *diff
} else { } else {
sames[i] = old[i] sames[i] = old[i]
@ -228,7 +232,7 @@ func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *V
if v.IsObject() && other.IsObject() { if v.IsObject() && other.IsObject() {
old := v.ObjectValue() old := v.ObjectValue()
new := other.ObjectValue() new := other.ObjectValue()
if diff := old.Diff(new, ignoreKeys...); diff != nil { if diff := old.diff(new, ignoreUnknowns, ignoreKeys); diff != nil {
return &ValueDiff{ return &ValueDiff{
Old: v, Old: v,
New: other, New: other,
@ -237,21 +241,51 @@ func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *V
} }
return nil return nil
} }
if v.IsSecret() && other.IsSecret() {
old, new := v.SecretValue().Element, other.SecretValue().Element
diff := old.diff(new, ignoreUnknowns, ignoreKeys)
if diff != nil {
return &ValueDiff{
Old: v,
New: other,
Secret: diff,
}
}
return nil
}
if v.IsResourceReference() && other.IsResourceReference() {
vr := v.ResourceReferenceValue()
or := other.ResourceReferenceValue()
if vr.URN != or.URN {
return &ValueDiff{Old: v, New: other}
}
vid, oid := vr.ID, or.ID
if vid.IsComputed() && ignoreUnknowns || vid.DeepEquals(oid) {
return nil
}
return &ValueDiff{Old: v, New: other}
}
// If we got here, either the values are primitives, or they weren't the same type; do a simple diff. // If we got here, either the values are primitives, or they weren't the same type; do a simple diff.
if v.DeepEquals(other) { if v.DeepEquals(other) || ignoreUnknowns && (v.IsComputed() || v.IsOutput()) {
return nil return nil
} }
return &ValueDiff{Old: v, New: other} return &ValueDiff{Old: v, New: other}
} }
// DeepEquals returns true if this property map is deeply equal to the other property map; and false otherwise. // Diff returns a diff by comparing a single property value to another; it returns nil if there are no diffs.
func (props PropertyMap) DeepEquals(other PropertyMap) bool { func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *ValueDiff {
return v.diff(other, false, ignoreKeys)
}
func (props PropertyMap) deepEquals(other PropertyMap, includeUnknowns bool) bool {
// If any in props either doesn't exist, or is of a different value, return false. // If any in props either doesn't exist, or is of a different value, return false.
for _, k := range props.StableKeys() { for _, k := range props.StableKeys() {
v := props[k] v := props[k]
if p, has := other[k]; has { if p, has := other[k]; has {
if !v.DeepEquals(p) { if !v.deepEquals(p, includeUnknowns) {
return false return false
} }
} else if v.HasValue() { } else if v.HasValue() {
@ -269,8 +303,19 @@ func (props PropertyMap) DeepEquals(other PropertyMap) bool {
return true return true
} }
// DeepEquals returns true if this property map is deeply equal to the other property map; and false otherwise. func (v PropertyValue) deepEquals(other PropertyValue, includeUnknowns bool) bool {
func (v PropertyValue) DeepEquals(other PropertyValue) bool { // Computed values are always equal.
if v.IsComputed() && other.IsComputed() {
return true
}
// If includeUnknowns is true then anything is equal to a computed
if includeUnknowns {
if v.IsComputed() || other.IsComputed() {
return true
}
}
// Arrays are equal if they are both of the same size and elements are deeply equal. // Arrays are equal if they are both of the same size and elements are deeply equal.
if v.IsArray() { if v.IsArray() {
if !other.IsArray() { if !other.IsArray() {
@ -282,7 +327,7 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
return false return false
} }
for i, elem := range va { for i, elem := range va {
if !elem.DeepEquals(oa[i]) { if !elem.deepEquals(oa[i], includeUnknowns) {
return false return false
} }
} }
@ -309,7 +354,7 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
} }
vo := v.ObjectValue() vo := v.ObjectValue()
oa := other.ObjectValue() oa := other.ObjectValue()
return vo.DeepEquals(oa) return vo.deepEquals(oa, includeUnknowns)
} }
// Secret are equal if the value they wrap are equal. // Secret are equal if the value they wrap are equal.
@ -320,7 +365,7 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
vs := v.SecretValue() vs := v.SecretValue()
os := other.SecretValue() os := other.SecretValue()
return vs.Element.DeepEquals(os.Element) return vs.Element.deepEquals(os.Element, includeUnknowns)
} }
// Resource references are equal if they refer to the same resource. The package version is ignored. // Resource references are equal if they refer to the same resource. The package version is ignored.
@ -339,7 +384,7 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
if vid.IsComputed() && oid.IsComputed() { if vid.IsComputed() && oid.IsComputed() {
return true return true
} }
return vid.DeepEquals(oid) return vid.deepEquals(oid, includeUnknowns)
} }
// Outputs are equal if each of their fields is deeply equal. // Outputs are equal if each of their fields is deeply equal.
@ -367,9 +412,28 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
} }
} }
return vo.Element.DeepEquals(oo.Element) return vo.Element.deepEquals(oo.Element, includeUnknowns)
} }
// For all other cases, primitives are equal if their values are equal. // For all other cases, primitives are equal if their values are equal.
return v.V == other.V return v.V == other.V
} }
// DeepEquals returns true if this property map is deeply equal to the other property map; and false otherwise.
func (props PropertyMap) DeepEquals(other PropertyMap) bool {
return props.deepEquals(other, false)
}
// DeepEquals returns true if this property value is deeply equal to the other property value; and false otherwise.
func (v PropertyValue) DeepEquals(other PropertyValue) bool {
return v.deepEquals(other, false)
}
func (props PropertyMap) DiffIncludeUnknowns(other PropertyMap) (*ObjectDiff, bool) {
diff := props.diff(other, true, nil)
return diff, diff != nil
}
func (v PropertyValue) DeepEqualsIncludeUnknowns(other PropertyValue) bool {
return v.deepEquals(other, true)
}

View file

@ -1,6 +1,8 @@
package resource package resource
import ( import (
"bytes"
"fmt"
"strconv" "strconv"
"strings" "strings"
@ -291,3 +293,39 @@ func (p PropertyPath) Contains(other PropertyPath) bool {
return true return true
} }
func requiresQuote(c rune) bool {
return !(c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_')
}
func (p PropertyPath) String() string {
var buf bytes.Buffer
for i, k := range p {
switch k := k.(type) {
case string:
var keyBuf bytes.Buffer
quoted := false
for _, c := range k {
if requiresQuote(c) {
quoted = true
if c == '"' {
keyBuf.WriteByte('\\')
}
}
keyBuf.WriteRune(c)
}
if !quoted {
if i == 0 {
fmt.Fprintf(&buf, "%s", keyBuf.String())
} else {
fmt.Fprintf(&buf, ".%s", keyBuf.String())
}
} else {
fmt.Fprintf(&buf, `["%s"]`, keyBuf.String())
}
case int:
fmt.Fprintf(&buf, "[%d]", k)
}
}
return buf.String()
}

View file

@ -49,68 +49,84 @@ func TestPropertyPath(t *testing.T) {
})) }))
cases := []struct { cases := []struct {
path string path string
parsed PropertyPath parsed PropertyPath
expected string
}{ }{
{ {
"root", "root",
PropertyPath{"root"}, PropertyPath{"root"},
"root",
}, },
{ {
"root.nested", "root.nested",
PropertyPath{"root", "nested"}, PropertyPath{"root", "nested"},
"root.nested",
}, },
{ {
`root["nested"]`, `root["nested"]`,
PropertyPath{"root", "nested"}, PropertyPath{"root", "nested"},
`root.nested`,
}, },
{ {
"root.double.nest", "root.double.nest",
PropertyPath{"root", "double", "nest"}, PropertyPath{"root", "double", "nest"},
"root.double.nest",
}, },
{ {
`root["double"].nest`, `root["double"].nest`,
PropertyPath{"root", "double", "nest"}, PropertyPath{"root", "double", "nest"},
`root.double.nest`,
}, },
{ {
`root["double"]["nest"]`, `root["double"]["nest"]`,
PropertyPath{"root", "double", "nest"}, PropertyPath{"root", "double", "nest"},
`root.double.nest`,
}, },
{ {
"root.array[0]", "root.array[0]",
PropertyPath{"root", "array", 0}, PropertyPath{"root", "array", 0},
"root.array[0]",
}, },
{ {
"root.array[1]", "root.array[1]",
PropertyPath{"root", "array", 1}, PropertyPath{"root", "array", 1},
"root.array[1]",
}, },
{ {
"root.array[0].nested", "root.array[0].nested",
PropertyPath{"root", "array", 0, "nested"}, PropertyPath{"root", "array", 0, "nested"},
"root.array[0].nested",
}, },
{ {
"root.array2[0][1].nested", "root.array2[0][1].nested",
PropertyPath{"root", "array2", 0, 1, "nested"}, PropertyPath{"root", "array2", 0, 1, "nested"},
"root.array2[0][1].nested",
}, },
{ {
"root.nested.array[0].double[1]", "root.nested.array[0].double[1]",
PropertyPath{"root", "nested", "array", 0, "double", 1}, PropertyPath{"root", "nested", "array", 0, "double", 1},
"root.nested.array[0].double[1]",
}, },
{ {
`root["key with \"escaped\" quotes"]`, `root["key with \"escaped\" quotes"]`,
PropertyPath{"root", `key with "escaped" quotes`}, PropertyPath{"root", `key with "escaped" quotes`},
`root["key with \"escaped\" quotes"]`,
}, },
{ {
`root["key with a ."]`, `root["key with a ."]`,
PropertyPath{"root", "key with a ."}, PropertyPath{"root", "key with a ."},
`root["key with a ."]`,
}, },
{ {
`["root key with \"escaped\" quotes"].nested`, `["root key with \"escaped\" quotes"].nested`,
PropertyPath{`root key with "escaped" quotes`, "nested"}, PropertyPath{`root key with "escaped" quotes`, "nested"},
`["root key with \"escaped\" quotes"].nested`,
}, },
{ {
`["root key with a ."][1]`, `["root key with a ."][1]`,
PropertyPath{"root key with a .", 1}, PropertyPath{"root key with a .", 1},
`["root key with a ."][1]`,
}, },
} }
@ -119,6 +135,7 @@ func TestPropertyPath(t *testing.T) {
parsed, err := ParsePropertyPath(c.path) parsed, err := ParsePropertyPath(c.path)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, c.parsed, parsed) assert.Equal(t, c.parsed, parsed)
assert.Equal(t, c.expected, parsed.String())
v, ok := parsed.Get(value) v, ok := parsed.Get(value)
assert.True(t, ok) assert.True(t, ok)