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/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/diag/colors"
"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.
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 {
v := updateTextMap[kind]
@ -79,7 +80,7 @@ const (
)
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
// 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,
}
changes, res := apply(ctx, kind, stack, op, opts, eventsChannel)
plan, changes, res := apply(ctx, kind, stack, op, opts, eventsChannel)
if res != nil {
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 op.Opts.AutoApprove || kind == apitype.PreviewUpdate {
close(eventsChannel)
return changes, nil
return plan, changes, nil
}
// Otherwise, ensure the user wants to proceed.
res = confirmBeforeUpdating(kind, stack, events, op.Opts)
close(eventsChannel)
return changes, res
return plan, changes, res
}
// 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.
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 {
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.
@ -208,7 +212,8 @@ func PreviewThenPromptThenExecute(ctx context.Context, kind apitype.UpdateKind,
DryRun: false,
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 {

View file

@ -161,7 +161,7 @@ type Backend interface {
RenameStack(ctx context.Context, stack Stack, newName tokens.QName) (StackReference, error)
// 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(ctx context.Context, stack Stack, op UpdateOperation) (engine.ResourceChanges, result.Result)
// 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,
op backend.UpdateOperation) (engine.ResourceChanges, result.Result) {
op backend.UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
if cmdutil.IsTruthy(os.Getenv(PulumiFilestateLockingEnvVar)) {
err := b.Lock(ctx, stack.Ref())
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer b.Unlock(ctx, stack.Ref())
}
@ -548,7 +548,7 @@ func (b *localBackend) Watch(ctx context.Context, stack backend.Stack,
func (b *localBackend) apply(
ctx context.Context, kind apitype.UpdateKind, stack backend.Stack,
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()
stackName := stackRef.Name()
@ -563,7 +563,7 @@ func (b *localBackend) apply(
// Start the update.
update, err := b.newUpdate(stackName, op)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
// Spawn a display loop to show events on the CLI.
@ -604,19 +604,20 @@ func (b *localBackend) apply(
// Perform the update
start := time.Now().Unix()
var plan *deploy.Plan
var changes engine.ResourceChanges
var updateRes result.Result
switch kind {
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:
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:
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:
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:
changes, updateRes = engine.Destroy(update, engineCtx, op.Opts.Engine, opts.DryRun)
_, changes, updateRes = engine.Destroy(update, engineCtx, op.Opts.Engine, opts.DryRun)
default:
contract.Failf("Unrecognized update kind: %s", kind)
}
@ -660,16 +661,16 @@ func (b *localBackend) apply(
if updateRes != nil {
// We swallow saveErr and backupErr as they are less important than the updateErr.
return changes, updateRes
return plan, changes, updateRes
}
if saveErr != nil {
// 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 {
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.
@ -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.

View file

@ -64,7 +64,10 @@ func (s *localStack) Rename(ctx context.Context, newName tokens.QName) (backend.
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)
}

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,
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.
opts := backend.ApplierOptions{
DryRun: true,
@ -931,7 +931,7 @@ func (b *cloudBackend) createAndStartUpdate(
func (b *cloudBackend) apply(
ctx context.Context, kind apitype.UpdateKind, stack backend.Stack,
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)
@ -945,7 +945,7 @@ func (b *cloudBackend) apply(
update, version, token, err :=
b.createAndStartUpdate(ctx, kind, stack, &op, opts.DryRun)
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 {
@ -985,12 +985,12 @@ func (b *cloudBackend) query(ctx context.Context, op backend.QueryOperation,
func (b *cloudBackend) runEngineAction(
ctx context.Context, kind apitype.UpdateKind, stackRef backend.StackReference,
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")
u, err := b.newUpdate(ctx, stackRef, op, update, token)
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
@ -1039,19 +1039,20 @@ func (b *cloudBackend) runEngineAction(
engineCtx.ParentSpan = parentSpan.Context()
}
var plan *deploy.Plan
var changes engine.ResourceChanges
var res result.Result
switch kind {
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:
changes, res = engine.Update(u, engineCtx, op.Opts.Engine, dryRun)
_, changes, res = engine.Update(u, engineCtx, op.Opts.Engine, dryRun)
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:
changes, res = engine.Refresh(u, engineCtx, op.Opts.Engine, dryRun)
_, changes, res = engine.Refresh(u, engineCtx, op.Opts.Engine, dryRun)
case apitype.DestroyUpdate:
changes, res = engine.Destroy(u, engineCtx, op.Opts.Engine, dryRun)
_, changes, res = engine.Destroy(u, engineCtx, op.Opts.Engine, dryRun)
default:
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)))
}
return changes, res
return plan, changes, res
}
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)
}
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)
}

View file

@ -57,7 +57,7 @@ type MockBackend struct {
LogoutAllF func() error
CurrentUserF func() (string, error)
PreviewF func(context.Context, Stack,
UpdateOperation) (engine.ResourceChanges, result.Result)
UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result)
UpdateF func(context.Context, Stack,
UpdateOperation) (engine.ResourceChanges, result.Result)
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,
op UpdateOperation) (engine.ResourceChanges, result.Result) {
op UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) {
if be.PreviewF != nil {
return be.PreviewF(ctx, stack, op)
@ -335,7 +335,7 @@ type MockStack struct {
ConfigF func() config.Map
SnapshotF func(ctx context.Context) (*deploy.Snapshot, error)
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)
ImportF func(ctx context.Context, op UpdateOperation,
imports []deploy.Import) (engine.ResourceChanges, result.Result)
@ -381,7 +381,10 @@ func (ms *MockStack) Backend() Backend {
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 {
return ms.PreviewF(ctx, op)
}

View file

@ -38,7 +38,7 @@ type Stack interface {
Backend() Backend // the backend this stack belongs to.
// 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(ctx context.Context, op UpdateOperation) (engine.ResourceChanges, result.Result)
// 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.
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)
}
@ -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.
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)
}

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"))
// 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 {
logging.V(5).Infof("watch update failed: %v", res.Error())
if res.Error() == context.Canceled {

View file

@ -38,6 +38,8 @@ func newPreviewCmd() *cobra.Command {
var configArray []string
var configPath bool
var client string
var planFilePath string
var showSecrets bool
// Flags for engine.UpdateOptions.
var jsonDisplay bool
@ -184,7 +186,7 @@ func newPreviewCmd() *cobra.Command {
Display: displayOpts,
}
changes, res := s.Preview(commandContext(), backend.UpdateOperation{
plan, changes, res := s.Preview(commandContext(), backend.UpdateOperation{
Proj: proj,
Root: root,
M: m,
@ -200,6 +202,16 @@ func newPreviewCmd() *cobra.Command {
case expectNop && changes != nil && changes.HasChanges():
return result.FromError(errors.New("error: no changes were expected but changes were proposed"))
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
}
}),
@ -223,6 +235,13 @@ func newPreviewCmd() *cobra.Command {
cmd.PersistentFlags().BoolVar(
&configPath, "config-path", false,
"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(
&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 targetReplaces []string
var targetDependents bool
var planFilePath string
// up implementation used when the source of the Pulumi program is in the current working directory.
upWorkingDirectory := func(opts backend.UpdateOptions) result.Result {
@ -143,6 +144,22 @@ func newUpCmd() *cobra.Command {
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{
Proj: proj,
Root: root,
@ -505,6 +522,14 @@ func newUpCmd() *cobra.Command {
&yes, "yes", "y", false,
"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() {
cmd.PersistentFlags().StringVar(
&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/state"
"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/secrets/passphrase"
"github.com/pulumi/pulumi/pkg/v3/util/cancel"
"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/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/cmdutil"
"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
}
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) {
if strings.Count(stackName, "/") == 2 {
return stackName, nil

View file

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

View file

@ -23,7 +23,12 @@ import (
"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(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)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer info.Close()
emitter, err := makeEventEmitter(ctx.Events, u)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer emitter.Close()

View file

@ -21,7 +21,7 @@ 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(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)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer info.Close()
emitter, err := makeEventEmitter(ctx.Events, u)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
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
// actual resource state.
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)
// Run a second update after fixing the inputs. The import should succeed.
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 {
for _, entry := range entries {
switch urn := entry.Step.URN(); urn {
@ -107,7 +107,7 @@ func TestImportOption(t *testing.T) {
assert.Len(t, snap.Resources, 2)
// 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 {
for _, entry := range entries {
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.
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 {
for _, entry := range entries {
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.
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)
// 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 {
for _, entry := range entries {
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.
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 {
for _, entry := range entries {
switch urn := entry.Step.URN(); urn {
@ -182,7 +182,7 @@ func TestImportOption(t *testing.T) {
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 {
for _, entry := range entries {
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
// a delete-replaced.
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 {
for _, entry := range entries {
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.
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 {
for _, entry := range entries {
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.
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 {
for _, entry := range entries {
switch urn := entry.Step.URN(); urn {
@ -327,7 +327,7 @@ func TestImportWithDifferingImportIdentifierFormat(t *testing.T) {
// Run the initial update. The import should succeed.
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 {
for _, entry := range entries {
switch urn := entry.Step.URN(); urn {
@ -345,7 +345,7 @@ func TestImportWithDifferingImportIdentifierFormat(t *testing.T) {
assert.Len(t, snap.Resources, 2)
// 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 {
for _, entry := range entries {
switch urn := entry.Step.URN(); urn {
@ -483,7 +483,7 @@ func TestImportPlan(t *testing.T) {
// Run the initial update.
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)
// Run an import.
@ -491,7 +491,7 @@ func TestImportPlan(t *testing.T) {
Type: "pkgA:m:typA",
Name: "resB",
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.Len(t, snap.Resources, 4)
@ -552,7 +552,7 @@ func TestImportIgnoreChanges(t *testing.T) {
}
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.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,
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,
_ []Event, res result.Result) result.Result {

View file

@ -39,16 +39,25 @@ func (u *updateInfo) GetTarget() *deploy.Target {
}
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)
})
}
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,
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,
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,
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.
info := &updateInfo{project: project, target: target}
@ -93,21 +111,21 @@ func (op TestOp) RunWithContext(
}()
// Run the step and its validator.
_, res := op(info, ctx, opts, dryRun)
plan, _, res := op(info, ctx, opts, dryRun)
contract.IgnoreClose(journal)
if validate != nil {
res = validate(project, target, journal.Entries(), firedEvents, res)
}
if dryRun {
return nil, res
return plan, nil, res
}
snap := journal.Snap(target.Snapshot)
if res == nil && snap != nil {
res = result.WrapIfNonNil(snap.VerifyIntegrity())
}
return snap, res
return nil, snap, res
}
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()
cfg := p.Config
@ -180,7 +198,10 @@ func (p *TestPlan) GetTarget(snapshot *deploy.Snapshot) deploy.Target {
Name: stack,
Config: cfg,
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)
}
// 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.
func CloneSnapshot(t *testing.T, snap *deploy.Snapshot) *deploy.Snapshot {
t.Helper()
@ -204,12 +237,9 @@ func (p *TestPlan) Run(t *testing.T, snapshot *deploy.Snapshot) *deploy.Snapshot
project := p.GetProject()
snap := snapshot
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 {
previewSnap := CloneSnapshot(t, snap)
previewTarget := p.GetTarget(previewSnap)
previewTarget := p.GetTarget(t, previewSnap)
// Don't run validate on the preview step
_, res := step.Op.Run(project, previewTarget, p.Options, true, p.BackendClient, nil)
if step.ExpectFailure {
@ -221,7 +251,7 @@ func (p *TestPlan) Run(t *testing.T, snapshot *deploy.Snapshot) *deploy.Snapshot
}
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)
if step.ExpectFailure {
assertIsErrorOrBailResult(t, res)

View file

@ -23,7 +23,12 @@ import (
"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(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)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer info.Close()
emitter, err := makeEventEmitter(ctx.Events, u)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer emitter.Close()

View file

@ -146,6 +146,9 @@ type UpdateOptions struct {
// the plugin host to use for this update
Host plugin.Host
// The plan to use for the update, if any.
Plan *deploy.Plan
}
// ResourceChanges contains the aggregate resource changes by operation type.
@ -165,7 +168,9 @@ func (changes ResourceChanges) HasChanges() bool {
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(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)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer info.Close()
emitter, err := makeEventEmitter(ctx.Events, u)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer emitter.Close()
@ -420,7 +425,7 @@ func newUpdateSource(
}
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.
policies := map[string]string{}
@ -445,7 +450,7 @@ func update(ctx *Context, info *deploymentContext, opts deploymentOptions,
deployment, err := newDeployment(ctx, info, opts, preview)
if err != nil {
return nil, result.FromError(err)
return nil, nil, result.FromError(err)
}
defer contract.IgnoreClose(deployment)

View file

@ -28,6 +28,7 @@ import (
"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/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/tokens"
"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 running deployment emits events that indicate its progress. These events must be used to record the new state
// of the deployment target.
@ -153,6 +184,7 @@ type Deployment struct {
target *Target // the deployment target.
prev *Snapshot // the old resource snapshot for comparison.
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.
isImport bool // true if this is an import deployment.
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.
providers *providers.Registry // the provider registry for this 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
@ -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
// 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) {
contract.Assert(ctx != nil)
@ -343,6 +376,7 @@ func NewDeployment(ctx *plugin.Context, target *Target, prev *Snapshot, source S
ctx: ctx,
target: target,
prev: prev,
plan: plan,
olds: olds,
source: source,
localPolicyPackPaths: localPolicyPackPaths,
@ -351,6 +385,7 @@ func NewDeployment(ctx *plugin.Context, target *Target, prev *Snapshot, source S
providers: reg,
goals: newGoals,
news: newResources,
newPlans: newResourcePlan(target.Config),
}, 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.
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}
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
// 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.
// 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.
@ -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.
if opts.Refresh {
if res := ex.refresh(callerCtx, opts, preview); res != nil {
return res
return nil, res
}
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)
destroyTargetsOpt := createTargetMap(opts.DestroyTargets)
if res := ex.checkTargets(opts.ReplaceTargets, OpReplace); res != nil {
return res
return nil, res
}
if res := ex.checkTargets(opts.DestroyTargets, OpDelete); res != nil {
return res
return nil, res
}
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.
src, res := ex.deployment.source.Iterate(callerCtx, opts, ex.deployment)
if res != nil {
return res
return nil, res
}
// 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.
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
@ -236,7 +236,15 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
}
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 {
@ -267,8 +275,38 @@ func (ex *deploymentExecutor) Execute(callerCtx context.Context, opts Options, p
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() {
return res
return nil, res
}
// 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)
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
// we be doing a merge here?
ex.reportExecResult("failed", preview)
return result.Bail()
return nil, result.Bail()
} else if canceled {
ex.reportExecResult("canceled", preview)
return result.Bail()
return nil, result.Bail()
}
return res
return ex.deployment.newPlans.plan(), res
}
func (ex *deploymentExecutor) performDeletes(
@ -375,8 +413,7 @@ func (ex *deploymentExecutor) handleSingleEvent(event SourceEvent) result.Result
steps, res = ex.stepGen.GenerateReadSteps(e)
case RegisterResourceOutputsEvent:
logging.V(4).Infof("deploymentExecutor.handleSingleEvent(...): received register resource outputs")
ex.stepExec.ExecuteRegisterResourceOutputs(e)
return nil
return ex.stepExec.ExecuteRegisterResourceOutputs(e)
}
if res != nil {
@ -433,9 +470,13 @@ func (ex *deploymentExecutor) retirePendingDeletes(callerCtx context.Context, op
}
// 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 {
return nil
return nil, nil
}
// Create an executor for this import.
@ -461,12 +502,12 @@ func (ex *deploymentExecutor) importResources(callerCtx context.Context, opts Op
} else {
ex.reportExecResult("failed", preview)
}
return result.Bail()
return nil, result.Bail()
} else if canceled {
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.

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) {
t.FailNow()
}

View file

@ -97,6 +97,7 @@ func NewImportDeployment(ctx *plugin.Context, target *Target, projectName tokens
source: NewErrorSource(projectName),
preview: preview,
providers: reg,
newPlans: newResourcePlan(target.Config),
}, 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
import (
"crypto/sha256"
"fmt"
"time"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy/providers"
"github.com/pulumi/pulumi/pkg/v3/secrets"
"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/workspace"
)
// 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.
}
// 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.
// This property is not checked; for verification, please refer to the VerifyIntegrity function below.
func NewSnapshot(manifest Manifest, secretsManager secrets.Manager,

View file

@ -1166,6 +1166,28 @@ func (op StepOp) Suffix() string {
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.
func getProvider(s Step) (plugin.Provider, error) {
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/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/result"
)
const (
@ -147,7 +148,7 @@ func (se *stepExecutor) ExecuteParallel(antichain antichain) completionToken {
}
// 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.
urn := e.URN()
value, has := se.pendingNews.Load(urn)
@ -160,7 +161,27 @@ func (se *stepExecutor) ExecuteRegisterResourceOutputs(e RegisterResourceOutputs
outs := e.Outputs()
se.log(synchronousWorkerID,
"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 e := se.opts.Events; e != 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())
se.deployment.Diag().Errorf(diagMsg)
se.cancelDueToError()
return
return nil
}
}
e.Done()
return nil
}
// 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 {
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 {

View file

@ -179,6 +179,39 @@ func (sg *stepGenerator) GenerateSteps(event RegisterResourceEvent) ([]Step, res
contract.Assert(len(steps) == 0)
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() {
return steps, nil
}
@ -272,6 +305,29 @@ func (sg *stepGenerator) generateSteps(event RegisterResourceEvent) ([]Step, res
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
// get serialized into the checkpoint file.
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
}
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
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
// resources that were specified.
allowedResourcesToDelete, res := sg.determineAllowedResourcesToDeleteFromTargets(targetsOpt)

View file

@ -23,8 +23,6 @@ import (
"reflect"
"strings"
"github.com/blang/semver"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/pkg/v3/secrets"
"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/config"
"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"
)
@ -105,23 +102,7 @@ func SerializeDeployment(snap *deploy.Snapshot, sm secrets.Manager, showSecrets
contract.Require(snap != nil, "snap")
// Capture the version information into a manifest.
manifest := apitype.ManifestV1{
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,
})
}
manifest := snap.Manifest.Serialize()
// If a specific secrets manager was not provided, use the one in the snapshot, if present.
if sm == nil {
@ -223,25 +204,9 @@ func DeserializeUntypedDeployment(
// DeserializeDeploymentV3 deserializes a typed DeploymentV3 into a `deploy.Snapshot`.
func DeserializeDeploymentV3(deployment apitype.DeploymentV3, secretsProv SecretsProvider) (*deploy.Snapshot, error) {
// Unpack the versions.
manifest := deploy.Manifest{
Time: deployment.Manifest.Time,
Magic: deployment.Manifest.Magic,
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,
})
manifest, err := deploy.DeserializeManifest(deployment.Manifest)
if err != nil {
return nil, err
}
var secretsManager secrets.Manager
@ -295,7 +260,7 @@ func DeserializeDeploymentV3(deployment apitype.DeploymentV3, secretsProv Secret
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.

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/mitchellh/go-homedir v1.1.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/src-d/gcfg v1.4.0 // 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 {
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 {
Old PropertyValue // the old 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).
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.
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, ignoreKeys ...IgnoreKeyFunc) *ObjectDiff {
func (props PropertyMap) diff(other PropertyMap, ignoreUnknowns bool, ignoreKeys []IgnoreKeyFunc) *ObjectDiff {
adds := make(PropertyMap)
deletes := make(PropertyMap)
sames := make(PropertyMap)
@ -137,7 +137,7 @@ func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *O
// First find any updates or deletes.
for k, old := range props {
if ignore(k) {
if ignore(k) || ignoreUnknowns && (old.IsComputed() || old.IsOutput()) {
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 new.IsOutput() {
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() {
adds[k] = new
} else if !new.HasValue() {
@ -156,7 +156,7 @@ func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *O
} else {
sames[k] = old
}
} else if old.HasValue() {
} else if old.HasValue() && (!ignoreUnknowns || !old.IsComputed()) {
// If there was no new property, it has been deleted.
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.
func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *ValueDiff {
// 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, 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() {
old := v.ArrayValue()
new := other.ArrayValue()
@ -204,7 +208,7 @@ func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *V
sames := make(map[int]PropertyValue)
updates := make(map[int]ValueDiff)
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
} else {
sames[i] = old[i]
@ -228,7 +232,7 @@ func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *V
if v.IsObject() && other.IsObject() {
old := v.ObjectValue()
new := other.ObjectValue()
if diff := old.Diff(new, ignoreKeys...); diff != nil {
if diff := old.diff(new, ignoreUnknowns, ignoreKeys); diff != nil {
return &ValueDiff{
Old: v,
New: other,
@ -237,21 +241,51 @@ func (v PropertyValue) Diff(other PropertyValue, ignoreKeys ...IgnoreKeyFunc) *V
}
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 v.DeepEquals(other) {
if v.DeepEquals(other) || ignoreUnknowns && (v.IsComputed() || v.IsOutput()) {
return nil
}
return &ValueDiff{Old: v, New: other}
}
// 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 {
// Diff returns a diff by comparing a single property value to another; it returns nil if there are no diffs.
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.
for _, k := range props.StableKeys() {
v := props[k]
if p, has := other[k]; has {
if !v.DeepEquals(p) {
if !v.deepEquals(p, includeUnknowns) {
return false
}
} else if v.HasValue() {
@ -269,8 +303,19 @@ func (props PropertyMap) DeepEquals(other PropertyMap) bool {
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) bool {
func (v PropertyValue) deepEquals(other PropertyValue, includeUnknowns bool) 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.
if v.IsArray() {
if !other.IsArray() {
@ -282,7 +327,7 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
return false
}
for i, elem := range va {
if !elem.DeepEquals(oa[i]) {
if !elem.deepEquals(oa[i], includeUnknowns) {
return false
}
}
@ -309,7 +354,7 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
}
vo := v.ObjectValue()
oa := other.ObjectValue()
return vo.DeepEquals(oa)
return vo.deepEquals(oa, includeUnknowns)
}
// Secret are equal if the value they wrap are equal.
@ -320,7 +365,7 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
vs := v.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.
@ -339,7 +384,7 @@ func (v PropertyValue) DeepEquals(other PropertyValue) bool {
if vid.IsComputed() && oid.IsComputed() {
return true
}
return vid.DeepEquals(oid)
return vid.deepEquals(oid, includeUnknowns)
}
// 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.
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
import (
"bytes"
"fmt"
"strconv"
"strings"
@ -291,3 +293,39 @@ func (p PropertyPath) Contains(other PropertyPath) bool {
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 {
path string
parsed PropertyPath
path string
parsed PropertyPath
expected string
}{
{
"root",
PropertyPath{"root"},
"root",
},
{
"root.nested",
PropertyPath{"root", "nested"},
"root.nested",
},
{
`root["nested"]`,
PropertyPath{"root", "nested"},
`root.nested`,
},
{
"root.double.nest",
PropertyPath{"root", "double", "nest"},
"root.double.nest",
},
{
`root["double"].nest`,
PropertyPath{"root", "double", "nest"},
`root.double.nest`,
},
{
`root["double"]["nest"]`,
PropertyPath{"root", "double", "nest"},
`root.double.nest`,
},
{
"root.array[0]",
PropertyPath{"root", "array", 0},
"root.array[0]",
},
{
"root.array[1]",
PropertyPath{"root", "array", 1},
"root.array[1]",
},
{
"root.array[0].nested",
PropertyPath{"root", "array", 0, "nested"},
"root.array[0].nested",
},
{
"root.array2[0][1].nested",
PropertyPath{"root", "array2", 0, 1, "nested"},
"root.array2[0][1].nested",
},
{
"root.nested.array[0].double[1]",
PropertyPath{"root", "nested", "array", 0, "double", 1},
"root.nested.array[0].double[1]",
},
{
`root["key with \"escaped\" quotes"]`,
PropertyPath{"root", `key with "escaped" quotes`},
`root["key with \"escaped\" quotes"]`,
},
{
`root["key with a ."]`,
PropertyPath{"root", "key with a ."},
`root["key with a ."]`,
},
{
`["root key with \"escaped\" quotes"].nested`,
PropertyPath{`root key with "escaped" quotes`, "nested"},
`["root key with \"escaped\" quotes"].nested`,
},
{
`["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)
assert.NoError(t, err)
assert.Equal(t, c.parsed, parsed)
assert.Equal(t, c.expected, parsed.String())
v, ok := parsed.Get(value)
assert.True(t, ok)