These changes add a new flag to the various `ResourceOptions` types that indicates that a resource should be deleted before it is replaced, even if the provider does not require this behavior. The usual delete-before-replace cascade semantics apply. Fixes #1620.
2818 lines
85 KiB
Go
2818 lines
85 KiB
Go
// 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 engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/blang/semver"
|
|
"github.com/mitchellh/copystructure"
|
|
"github.com/pkg/errors"
|
|
"github.com/stretchr/testify/assert"
|
|
"google.golang.org/grpc/codes"
|
|
|
|
"github.com/pulumi/pulumi/pkg/diag"
|
|
"github.com/pulumi/pulumi/pkg/diag/colors"
|
|
"github.com/pulumi/pulumi/pkg/resource"
|
|
"github.com/pulumi/pulumi/pkg/resource/config"
|
|
"github.com/pulumi/pulumi/pkg/resource/deploy"
|
|
"github.com/pulumi/pulumi/pkg/resource/deploy/deploytest"
|
|
"github.com/pulumi/pulumi/pkg/resource/deploy/providers"
|
|
"github.com/pulumi/pulumi/pkg/resource/plugin"
|
|
"github.com/pulumi/pulumi/pkg/tokens"
|
|
"github.com/pulumi/pulumi/pkg/util/cancel"
|
|
"github.com/pulumi/pulumi/pkg/util/contract"
|
|
"github.com/pulumi/pulumi/pkg/util/logging"
|
|
"github.com/pulumi/pulumi/pkg/util/rpcutil/rpcerror"
|
|
"github.com/pulumi/pulumi/pkg/workspace"
|
|
)
|
|
|
|
type JournalEntryKind int
|
|
|
|
const (
|
|
JournalEntryBegin JournalEntryKind = 0
|
|
JournalEntrySuccess JournalEntryKind = 1
|
|
JournalEntryFailure JournalEntryKind = 2
|
|
JournalEntryOutputs JournalEntryKind = 4
|
|
)
|
|
|
|
type JournalEntry struct {
|
|
Kind JournalEntryKind
|
|
Step deploy.Step
|
|
Resource *resource.State
|
|
}
|
|
|
|
type Journal struct {
|
|
Entries []JournalEntry
|
|
events chan JournalEntry
|
|
cancel chan bool
|
|
done chan bool
|
|
}
|
|
|
|
func (j *Journal) Close() error {
|
|
close(j.cancel)
|
|
<-j.done
|
|
|
|
return nil
|
|
}
|
|
|
|
func (j *Journal) BeginMutation(step deploy.Step) (SnapshotMutation, error) {
|
|
select {
|
|
case j.events <- JournalEntry{Kind: JournalEntryBegin, Step: step}:
|
|
return j, nil
|
|
case <-j.cancel:
|
|
return nil, errors.New("journal closed")
|
|
}
|
|
}
|
|
|
|
func (j *Journal) End(step deploy.Step, success bool) error {
|
|
kind := JournalEntryFailure
|
|
if success {
|
|
kind = JournalEntrySuccess
|
|
}
|
|
select {
|
|
case j.events <- JournalEntry{Kind: kind, Step: step}:
|
|
return nil
|
|
case <-j.cancel:
|
|
return errors.New("journal closed")
|
|
}
|
|
}
|
|
|
|
func (j *Journal) RegisterResourceOutputs(step deploy.Step) error {
|
|
select {
|
|
case j.events <- JournalEntry{Kind: JournalEntryOutputs, Step: step}:
|
|
return nil
|
|
case <-j.cancel:
|
|
return errors.New("journal closed")
|
|
}
|
|
}
|
|
|
|
func (j *Journal) RecordPlugin(plugin workspace.PluginInfo) error {
|
|
return nil
|
|
}
|
|
|
|
func (j *Journal) Snap(base *deploy.Snapshot) *deploy.Snapshot {
|
|
// Build up a list of current resources by replaying the journal.
|
|
resources, dones := []*resource.State{}, make(map[*resource.State]bool)
|
|
ops, doneOps := []resource.Operation{}, make(map[*resource.State]bool)
|
|
for _, e := range j.Entries {
|
|
logging.V(7).Infof("%v %v (%v)", e.Step.Op(), e.Step.URN(), e.Kind)
|
|
|
|
// Begin journal entries add pending operations to the snapshot. As we see success or failure
|
|
// entries, we'll record them in doneOps.
|
|
switch e.Kind {
|
|
case JournalEntryBegin:
|
|
switch e.Step.Op() {
|
|
case deploy.OpCreate, deploy.OpCreateReplacement:
|
|
ops = append(ops, resource.NewOperation(e.Step.New(), resource.OperationTypeCreating))
|
|
case deploy.OpDelete, deploy.OpDeleteReplaced, deploy.OpReadDiscard, deploy.OpDiscardReplaced:
|
|
ops = append(ops, resource.NewOperation(e.Step.Old(), resource.OperationTypeDeleting))
|
|
case deploy.OpRead, deploy.OpReadReplacement:
|
|
ops = append(ops, resource.NewOperation(e.Step.New(), resource.OperationTypeReading))
|
|
case deploy.OpUpdate:
|
|
ops = append(ops, resource.NewOperation(e.Step.New(), resource.OperationTypeUpdating))
|
|
}
|
|
case JournalEntryFailure, JournalEntrySuccess:
|
|
switch e.Step.Op() {
|
|
// nolint: lll
|
|
case deploy.OpCreate, deploy.OpCreateReplacement, deploy.OpRead, deploy.OpReadReplacement, deploy.OpUpdate:
|
|
doneOps[e.Step.New()] = true
|
|
case deploy.OpDelete, deploy.OpDeleteReplaced, deploy.OpReadDiscard, deploy.OpDiscardReplaced:
|
|
doneOps[e.Step.Old()] = true
|
|
}
|
|
}
|
|
|
|
// Now mark resources done as necessary.
|
|
if e.Kind == JournalEntrySuccess {
|
|
switch e.Step.Op() {
|
|
case deploy.OpSame, deploy.OpUpdate:
|
|
resources = append(resources, e.Step.New())
|
|
dones[e.Step.Old()] = true
|
|
case deploy.OpCreate, deploy.OpCreateReplacement:
|
|
resources = append(resources, e.Step.New())
|
|
if old := e.Step.Old(); old != nil && old.PendingReplacement {
|
|
dones[old] = true
|
|
}
|
|
case deploy.OpDelete, deploy.OpDeleteReplaced, deploy.OpReadDiscard, deploy.OpDiscardReplaced:
|
|
if old := e.Step.Old(); !old.PendingReplacement {
|
|
dones[old] = true
|
|
}
|
|
case deploy.OpReplace:
|
|
// do nothing.
|
|
case deploy.OpRead, deploy.OpReadReplacement:
|
|
resources = append(resources, e.Step.New())
|
|
if e.Step.Old() != nil {
|
|
dones[e.Step.Old()] = true
|
|
}
|
|
case deploy.OpRemovePendingReplace:
|
|
dones[e.Resource] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Append any resources from the base snapshot that were not produced by the current snapshot.
|
|
// See backend.SnapshotManager.snap for why this works.
|
|
if base != nil {
|
|
for _, res := range base.Resources {
|
|
if !dones[res] {
|
|
resources = append(resources, res)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Append any pending operations.
|
|
var operations []resource.Operation
|
|
for _, op := range ops {
|
|
if !doneOps[op.Resource] {
|
|
operations = append(operations, op)
|
|
}
|
|
}
|
|
|
|
manifest := deploy.Manifest{}
|
|
manifest.Magic = manifest.NewMagic()
|
|
return deploy.NewSnapshot(manifest, resources, operations)
|
|
}
|
|
|
|
func (j *Journal) SuccessfulSteps() []deploy.Step {
|
|
var steps []deploy.Step
|
|
for _, entry := range j.Entries {
|
|
if entry.Kind == JournalEntrySuccess {
|
|
steps = append(steps, entry.Step)
|
|
}
|
|
}
|
|
return steps
|
|
}
|
|
|
|
type StepSummary struct {
|
|
Op deploy.StepOp
|
|
URN resource.URN
|
|
}
|
|
|
|
func AssertSameSteps(t *testing.T, expected []StepSummary, actual []deploy.Step) bool {
|
|
assert.Equal(t, len(expected), len(actual))
|
|
for _, exp := range expected {
|
|
act := actual[0]
|
|
actual = actual[1:]
|
|
|
|
if !assert.Equal(t, exp.Op, act.Op()) || !assert.Equal(t, exp.URN, act.URN()) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func newJournal() *Journal {
|
|
j := &Journal{
|
|
events: make(chan JournalEntry),
|
|
cancel: make(chan bool),
|
|
done: make(chan bool),
|
|
}
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-j.cancel:
|
|
close(j.done)
|
|
return
|
|
case e := <-j.events:
|
|
j.Entries = append(j.Entries, e)
|
|
}
|
|
}
|
|
}()
|
|
return j
|
|
}
|
|
|
|
type updateInfo struct {
|
|
project workspace.Project
|
|
target deploy.Target
|
|
}
|
|
|
|
func (u *updateInfo) GetRoot() string {
|
|
return ""
|
|
}
|
|
|
|
func (u *updateInfo) GetProject() *workspace.Project {
|
|
return &u.project
|
|
}
|
|
|
|
func (u *updateInfo) GetTarget() *deploy.Target {
|
|
return &u.target
|
|
}
|
|
|
|
type TestOp func(UpdateInfo, *Context, UpdateOptions, bool) (ResourceChanges, error)
|
|
type ValidateFunc func(project workspace.Project, target deploy.Target, j *Journal, events []Event, err error) error
|
|
|
|
func (op TestOp) Run(project workspace.Project, target deploy.Target, opts UpdateOptions,
|
|
dryRun bool, backendClient deploy.BackendClient, validate ValidateFunc) (*deploy.Snapshot, error) {
|
|
|
|
return op.RunWithContext(context.Background(), project, target, opts, dryRun, backendClient, validate)
|
|
}
|
|
|
|
func (op TestOp) RunWithContext(callerCtx context.Context, project workspace.Project, target deploy.Target,
|
|
opts UpdateOptions, dryRun bool, backendClient deploy.BackendClient, validate ValidateFunc) (*deploy.Snapshot, error) {
|
|
|
|
// Create an appropriate update info and context.
|
|
info := &updateInfo{project: project, target: target}
|
|
|
|
cancelCtx, cancelSrc := cancel.NewContext(context.Background())
|
|
done := make(chan bool)
|
|
defer close(done)
|
|
go func() {
|
|
select {
|
|
case <-callerCtx.Done():
|
|
cancelSrc.Cancel()
|
|
case <-done:
|
|
}
|
|
}()
|
|
|
|
events := make(chan Event)
|
|
journal := newJournal()
|
|
|
|
ctx := &Context{
|
|
Cancel: cancelCtx,
|
|
Events: events,
|
|
SnapshotManager: journal,
|
|
BackendClient: backendClient,
|
|
}
|
|
|
|
// Begin draining events.
|
|
var firedEvents []Event
|
|
go func() {
|
|
for e := range events {
|
|
firedEvents = append(firedEvents, e)
|
|
}
|
|
}()
|
|
|
|
// Run the step and its validator.
|
|
_, err := op(info, ctx, opts, dryRun)
|
|
contract.IgnoreClose(journal)
|
|
|
|
if dryRun {
|
|
return nil, err
|
|
}
|
|
if validate != nil {
|
|
err = validate(project, target, journal, firedEvents, err)
|
|
}
|
|
|
|
snap := journal.Snap(target.Snapshot)
|
|
if err == nil && snap != nil {
|
|
err = snap.VerifyIntegrity()
|
|
}
|
|
return snap, err
|
|
|
|
}
|
|
|
|
type TestStep struct {
|
|
Op TestOp
|
|
ExpectFailure bool
|
|
SkipPreview bool
|
|
Validate ValidateFunc
|
|
}
|
|
|
|
type TestPlan struct {
|
|
Project string
|
|
Stack string
|
|
Runtime string
|
|
Config config.Map
|
|
Decrypter config.Decrypter
|
|
BackendClient deploy.BackendClient
|
|
Options UpdateOptions
|
|
Steps []TestStep
|
|
}
|
|
|
|
//nolint: goconst
|
|
func (p *TestPlan) getNames() (stack tokens.QName, project tokens.PackageName, runtime string) {
|
|
project = tokens.PackageName(p.Project)
|
|
if project == "" {
|
|
project = "test"
|
|
}
|
|
runtime = p.Runtime
|
|
if runtime == "" {
|
|
runtime = "test"
|
|
}
|
|
stack = tokens.QName(p.Stack)
|
|
if stack == "" {
|
|
stack = "test"
|
|
}
|
|
return stack, project, runtime
|
|
}
|
|
|
|
func (p *TestPlan) NewURN(typ tokens.Type, name string, parent resource.URN) resource.URN {
|
|
stack, project, _ := p.getNames()
|
|
var pt tokens.Type
|
|
if parent != "" {
|
|
pt = parent.Type()
|
|
}
|
|
return resource.NewURN(stack, project, pt, typ, tokens.QName(name))
|
|
}
|
|
|
|
func (p *TestPlan) NewProviderURN(pkg tokens.Package, name string, parent resource.URN) resource.URN {
|
|
return p.NewURN(providers.MakeProviderType(pkg), name, parent)
|
|
}
|
|
|
|
func (p *TestPlan) GetProject() workspace.Project {
|
|
_, projectName, runtime := p.getNames()
|
|
|
|
return workspace.Project{
|
|
Name: projectName,
|
|
Runtime: workspace.NewProjectRuntimeInfo(runtime, nil),
|
|
}
|
|
}
|
|
|
|
func (p *TestPlan) GetTarget(snapshot *deploy.Snapshot) deploy.Target {
|
|
stack, _, _ := p.getNames()
|
|
|
|
cfg := p.Config
|
|
if cfg == nil {
|
|
cfg = config.Map{}
|
|
}
|
|
|
|
return deploy.Target{
|
|
Name: stack,
|
|
Config: cfg,
|
|
Decrypter: p.Decrypter,
|
|
Snapshot: snapshot,
|
|
}
|
|
}
|
|
|
|
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)
|
|
_, err := step.Op.Run(project, previewTarget, p.Options, true, p.BackendClient, step.Validate)
|
|
if step.ExpectFailure {
|
|
assert.Error(t, err)
|
|
continue
|
|
}
|
|
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
var err error
|
|
target := p.GetTarget(snap)
|
|
snap, err = step.Op.Run(project, target, p.Options, false, p.BackendClient, step.Validate)
|
|
if step.ExpectFailure {
|
|
assert.Error(t, err)
|
|
continue
|
|
}
|
|
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
return snap
|
|
}
|
|
|
|
// 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()
|
|
if snap != nil {
|
|
copiedSnap := copystructure.Must(copystructure.Copy(*snap)).(deploy.Snapshot)
|
|
assert.True(t, reflect.DeepEqual(*snap, copiedSnap))
|
|
return &copiedSnap
|
|
}
|
|
|
|
return snap
|
|
}
|
|
|
|
func MakeBasicLifecycleSteps(t *testing.T, resCount int) []TestStep {
|
|
return []TestStep{
|
|
// Initial update
|
|
{
|
|
Op: Update,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Should see only creates.
|
|
for _, entry := range j.Entries {
|
|
assert.Equal(t, deploy.OpCreate, entry.Step.Op())
|
|
}
|
|
assert.Len(t, j.Snap(target.Snapshot).Resources, resCount)
|
|
return err
|
|
},
|
|
},
|
|
// No-op refresh
|
|
{
|
|
Op: Refresh,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Should see only refresh-sames.
|
|
for _, entry := range j.Entries {
|
|
assert.Equal(t, deploy.OpRefresh, entry.Step.Op())
|
|
assert.Equal(t, deploy.OpSame, entry.Step.(*deploy.RefreshStep).ResultOp())
|
|
}
|
|
assert.Len(t, j.Snap(target.Snapshot).Resources, resCount)
|
|
return err
|
|
},
|
|
},
|
|
// No-op update
|
|
{
|
|
Op: Update,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Should see only sames.
|
|
for _, entry := range j.Entries {
|
|
assert.Equal(t, deploy.OpSame, entry.Step.Op())
|
|
}
|
|
assert.Len(t, j.Snap(target.Snapshot).Resources, resCount)
|
|
return err
|
|
},
|
|
},
|
|
// No-op refresh
|
|
{
|
|
Op: Refresh,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Should see only referesh-sames.
|
|
for _, entry := range j.Entries {
|
|
assert.Equal(t, deploy.OpRefresh, entry.Step.Op())
|
|
assert.Equal(t, deploy.OpSame, entry.Step.(*deploy.RefreshStep).ResultOp())
|
|
}
|
|
assert.Len(t, j.Snap(target.Snapshot).Resources, resCount)
|
|
return err
|
|
},
|
|
},
|
|
// Destroy
|
|
{
|
|
Op: Destroy,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Should see only deletes.
|
|
for _, entry := range j.Entries {
|
|
switch entry.Step.Op() {
|
|
case deploy.OpDelete, deploy.OpReadDiscard:
|
|
// ok
|
|
default:
|
|
assert.Fail(t, "expected OpDelete or OpReadDiscard")
|
|
}
|
|
}
|
|
assert.Len(t, j.Snap(target.Snapshot).Resources, 0)
|
|
return err
|
|
},
|
|
},
|
|
// No-op refresh
|
|
{
|
|
Op: Refresh,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
assert.Len(t, j.Entries, 0)
|
|
assert.Len(t, j.Snap(target.Snapshot).Resources, 0)
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestEmptyProgramLifecycle(t *testing.T) {
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, _ *deploytest.ResourceMonitor) error {
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: MakeBasicLifecycleSteps(t, 0),
|
|
}
|
|
p.Run(t, nil)
|
|
}
|
|
|
|
func TestSingleResourceDefaultProviderLifecycle(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: MakeBasicLifecycleSteps(t, 2),
|
|
}
|
|
p.Run(t, nil)
|
|
}
|
|
|
|
func TestSingleResourceExplicitProviderLifecycle(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
provURN, provID, _, err := monitor.RegisterResource(providers.MakeProviderType("pkgA"), "provA", true, "",
|
|
false, nil, "", resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
if provID == "" {
|
|
provID = providers.UnknownID
|
|
}
|
|
|
|
provRef, err := providers.NewReference(provURN, provID)
|
|
assert.NoError(t, err)
|
|
|
|
_, _, _, err = monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, provRef.String(),
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: MakeBasicLifecycleSteps(t, 2),
|
|
}
|
|
p.Run(t, nil)
|
|
}
|
|
|
|
func TestSingleResourceDefaultProviderUpgrade(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
}
|
|
|
|
provURN := p.NewProviderURN("pkgA", "default", "")
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
|
|
// Create an old snapshot with an existing copy of the single resource and no providers.
|
|
old := &deploy.Snapshot{
|
|
Resources: []*resource.State{{
|
|
Type: resURN.Type(),
|
|
URN: resURN,
|
|
Custom: true,
|
|
ID: "0",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
}},
|
|
}
|
|
|
|
isRefresh := false
|
|
validate := func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Should see only sames: the default provider should be injected into the old state before the update
|
|
// runs.
|
|
for _, entry := range j.Entries {
|
|
switch urn := entry.Step.URN(); urn {
|
|
case provURN, resURN:
|
|
expect := deploy.OpSame
|
|
if isRefresh {
|
|
expect = deploy.OpRefresh
|
|
}
|
|
assert.Equal(t, expect, entry.Step.Op())
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
}
|
|
assert.Len(t, j.Snap(target.Snapshot).Resources, 2)
|
|
return err
|
|
}
|
|
|
|
// Run a single update step using the base snapshot.
|
|
p.Steps = []TestStep{{Op: Update, Validate: validate}}
|
|
p.Run(t, old)
|
|
|
|
// Run a single refresh step using the base snapshot.
|
|
isRefresh = true
|
|
p.Steps = []TestStep{{Op: Refresh, Validate: validate}}
|
|
p.Run(t, old)
|
|
|
|
// Run a single destroy step using the base snapshot.
|
|
isRefresh = false
|
|
p.Steps = []TestStep{{
|
|
Op: Destroy,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Should see two deletes: the default provider should be injected into the old state before the update
|
|
// runs.
|
|
deleted := make(map[resource.URN]bool)
|
|
for _, entry := range j.Entries {
|
|
switch urn := entry.Step.URN(); urn {
|
|
case provURN, resURN:
|
|
deleted[urn] = true
|
|
assert.Equal(t, deploy.OpDelete, entry.Step.Op())
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
}
|
|
assert.Len(t, deleted, 2)
|
|
assert.Len(t, j.Snap(target.Snapshot).Resources, 0)
|
|
return err
|
|
},
|
|
}}
|
|
p.Run(t, old)
|
|
|
|
// Run a partial lifecycle using the base snapshot, skipping the initial update step.
|
|
p.Steps = MakeBasicLifecycleSteps(t, 2)[1:]
|
|
p.Run(t, old)
|
|
}
|
|
|
|
func TestSingleResourceDefaultProviderReplace(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
DiffConfigF: func(olds, news resource.PropertyMap) (plugin.DiffResult, error) {
|
|
// Always require replacement.
|
|
keys := []resource.PropertyKey{}
|
|
for k := range news {
|
|
keys = append(keys, k)
|
|
}
|
|
return plugin.DiffResult{ReplaceKeys: keys}, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Config: config.Map{
|
|
config.MustMakeKey("pkgA", "foo"): config.NewValue("bar"),
|
|
},
|
|
}
|
|
|
|
// Build a basic lifecycle.
|
|
steps := MakeBasicLifecycleSteps(t, 2)
|
|
|
|
// Run the lifecycle through its no-op update+refresh.
|
|
p.Steps = steps[:4]
|
|
snap := p.Run(t, nil)
|
|
|
|
// Change the config and run an update. We expect everything to require replacement.
|
|
p.Config[config.MustMakeKey("pkgA", "foo")] = config.NewValue("baz")
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
provURN := p.NewProviderURN("pkgA", "default", "")
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
|
|
// Look for replace steps on the provider and the resource.
|
|
replacedProvider, replacedResource := false, false
|
|
for _, entry := range j.Entries {
|
|
if entry.Kind != JournalEntrySuccess || entry.Step.Op() != deploy.OpDeleteReplaced {
|
|
continue
|
|
}
|
|
|
|
switch urn := entry.Step.URN(); urn {
|
|
case provURN:
|
|
replacedProvider = true
|
|
case resURN:
|
|
replacedResource = true
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
}
|
|
assert.True(t, replacedProvider)
|
|
assert.True(t, replacedResource)
|
|
|
|
return err
|
|
},
|
|
}}
|
|
|
|
snap = p.Run(t, snap)
|
|
|
|
// Resume the lifecycle with another no-op update.
|
|
p.Steps = steps[2:]
|
|
p.Run(t, snap)
|
|
}
|
|
|
|
func TestSingleResourceExplicitProviderReplace(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
DiffConfigF: func(olds, news resource.PropertyMap) (plugin.DiffResult, error) {
|
|
// Always require replacement.
|
|
keys := []resource.PropertyKey{}
|
|
for k := range news {
|
|
keys = append(keys, k)
|
|
}
|
|
return plugin.DiffResult{ReplaceKeys: keys}, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
providerInputs := resource.PropertyMap{
|
|
resource.PropertyKey("foo"): resource.NewStringProperty("bar"),
|
|
}
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
provURN, provID, _, err := monitor.RegisterResource(providers.MakeProviderType("pkgA"), "provA", true, "",
|
|
false, nil, "", providerInputs, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
if provID == "" {
|
|
provID = providers.UnknownID
|
|
}
|
|
|
|
provRef, err := providers.NewReference(provURN, provID)
|
|
assert.NoError(t, err)
|
|
|
|
_, _, _, err = monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, provRef.String(),
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
}
|
|
|
|
// Build a basic lifecycle.
|
|
steps := MakeBasicLifecycleSteps(t, 2)
|
|
|
|
// Run the lifecycle through its no-op update+refresh.
|
|
p.Steps = steps[:4]
|
|
snap := p.Run(t, nil)
|
|
|
|
// Change the config and run an update. We expect everything to require replacement.
|
|
providerInputs[resource.PropertyKey("foo")] = resource.NewStringProperty("baz")
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
provURN := p.NewProviderURN("pkgA", "provA", "")
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
|
|
// Look for replace steps on the provider and the resource.
|
|
replacedProvider, replacedResource := false, false
|
|
for _, entry := range j.Entries {
|
|
if entry.Kind != JournalEntrySuccess || entry.Step.Op() != deploy.OpDeleteReplaced {
|
|
continue
|
|
}
|
|
|
|
switch urn := entry.Step.URN(); urn {
|
|
case provURN:
|
|
replacedProvider = true
|
|
case resURN:
|
|
replacedResource = true
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
}
|
|
assert.True(t, replacedProvider)
|
|
assert.True(t, replacedResource)
|
|
|
|
return err
|
|
},
|
|
}}
|
|
snap = p.Run(t, snap)
|
|
|
|
// Resume the lifecycle with another no-op update.
|
|
p.Steps = steps[2:]
|
|
p.Run(t, snap)
|
|
}
|
|
|
|
func TestSingleResourceExplicitProviderDeleteBeforeReplace(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
DiffConfigF: func(olds, news resource.PropertyMap) (plugin.DiffResult, error) {
|
|
// Always require replacement.
|
|
keys := []resource.PropertyKey{}
|
|
for k := range news {
|
|
keys = append(keys, k)
|
|
}
|
|
return plugin.DiffResult{ReplaceKeys: keys, DeleteBeforeReplace: true}, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
providerInputs := resource.PropertyMap{
|
|
resource.PropertyKey("foo"): resource.NewStringProperty("bar"),
|
|
}
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
provURN, provID, _, err := monitor.RegisterResource(providers.MakeProviderType("pkgA"), "provA", true, "",
|
|
false, nil, "", providerInputs, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
if provID == "" {
|
|
provID = providers.UnknownID
|
|
}
|
|
|
|
provRef, err := providers.NewReference(provURN, provID)
|
|
assert.NoError(t, err)
|
|
|
|
_, _, _, err = monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, provRef.String(),
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
}
|
|
|
|
// Build a basic lifecycle.
|
|
steps := MakeBasicLifecycleSteps(t, 2)
|
|
|
|
// Run the lifecycle through its no-op update+refresh.
|
|
p.Steps = steps[:4]
|
|
snap := p.Run(t, nil)
|
|
|
|
// Change the config and run an update. We expect everything to require replacement.
|
|
providerInputs[resource.PropertyKey("foo")] = resource.NewStringProperty("baz")
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
provURN := p.NewProviderURN("pkgA", "provA", "")
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
|
|
// Look for replace steps on the provider and the resource.
|
|
createdProvider, createdResource := false, false
|
|
deletedProvider, deletedResource := false, false
|
|
for _, entry := range j.Entries {
|
|
if entry.Kind != JournalEntrySuccess {
|
|
continue
|
|
}
|
|
|
|
switch urn := entry.Step.URN(); urn {
|
|
case provURN:
|
|
if entry.Step.Op() == deploy.OpDeleteReplaced {
|
|
assert.False(t, createdProvider)
|
|
assert.False(t, createdResource)
|
|
assert.True(t, deletedResource)
|
|
deletedProvider = true
|
|
} else if entry.Step.Op() == deploy.OpCreateReplacement {
|
|
assert.True(t, deletedProvider)
|
|
assert.True(t, deletedResource)
|
|
assert.False(t, createdResource)
|
|
createdProvider = true
|
|
}
|
|
case resURN:
|
|
if entry.Step.Op() == deploy.OpDeleteReplaced {
|
|
assert.False(t, deletedProvider)
|
|
assert.False(t, deletedResource)
|
|
deletedResource = true
|
|
} else if entry.Step.Op() == deploy.OpCreateReplacement {
|
|
assert.True(t, deletedProvider)
|
|
assert.True(t, deletedResource)
|
|
assert.True(t, createdProvider)
|
|
createdResource = true
|
|
}
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
}
|
|
assert.True(t, deletedProvider)
|
|
assert.True(t, deletedResource)
|
|
|
|
return err
|
|
},
|
|
}}
|
|
snap = p.Run(t, snap)
|
|
|
|
// Resume the lifecycle with another no-op update.
|
|
p.Steps = steps[2:]
|
|
p.Run(t, snap)
|
|
}
|
|
|
|
func TestSingleResourceDiffUnavailable(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
DiffF: func(urn resource.URN, id resource.ID,
|
|
olds, news resource.PropertyMap) (plugin.DiffResult, error) {
|
|
|
|
return plugin.DiffResult{}, plugin.DiffUnavailable("diff unavailable")
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
inputs := resource.PropertyMap{}
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "", inputs, nil, false)
|
|
assert.NoError(t, err)
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
}
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
|
|
// Run the initial update.
|
|
project := p.GetProject()
|
|
snap, err := TestOp(Update).Run(project, p.GetTarget(nil), p.Options, false, p.BackendClient, nil)
|
|
assert.NoError(t, err)
|
|
|
|
// Now change the inputs to our resource and run a preview.
|
|
inputs = resource.PropertyMap{"foo": resource.NewStringProperty("bar")}
|
|
_, err = TestOp(Update).Run(project, p.GetTarget(snap), p.Options, true, p.BackendClient,
|
|
func(_ workspace.Project, _ deploy.Target, _ *Journal, events []Event, err error) error {
|
|
found := false
|
|
for _, e := range events {
|
|
if e.Type == DiagEvent {
|
|
p := e.Payload.(DiagEventPayload)
|
|
if p.URN == resURN && p.Severity == diag.Warning && p.Message == "diff unavailable" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
assert.True(t, found)
|
|
return err
|
|
})
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestDestroyWithPendingDelete(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, _ *deploytest.ResourceMonitor) error {
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
}
|
|
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
|
|
// Create an old snapshot with two copies of a resource that share a URN: one that is pending deletion and one
|
|
// that is not.
|
|
old := &deploy.Snapshot{
|
|
Resources: []*resource.State{
|
|
{
|
|
Type: resURN.Type(),
|
|
URN: resURN,
|
|
Custom: true,
|
|
ID: "1",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
},
|
|
{
|
|
Type: resURN.Type(),
|
|
URN: resURN,
|
|
Custom: true,
|
|
ID: "0",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
Delete: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
Validate: func(_ workspace.Project, _ deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Verify that we see a DeleteReplacement for the resource with ID 0 and a Delete for the resource with
|
|
// ID 1.
|
|
deletedID0, deletedID1 := false, false
|
|
for _, entry := range j.Entries {
|
|
// Ignore non-terminal steps and steps that affect the injected default provider.
|
|
if entry.Kind != JournalEntrySuccess || entry.Step.URN() != resURN ||
|
|
(entry.Step.Op() != deploy.OpDelete && entry.Step.Op() != deploy.OpDeleteReplaced) {
|
|
continue
|
|
}
|
|
|
|
switch id := entry.Step.Old().ID; id {
|
|
case "0":
|
|
assert.False(t, deletedID0)
|
|
deletedID0 = true
|
|
case "1":
|
|
assert.False(t, deletedID1)
|
|
deletedID1 = true
|
|
default:
|
|
assert.Fail(t, "unexpected resource ID %v", string(id))
|
|
}
|
|
}
|
|
assert.True(t, deletedID0)
|
|
assert.True(t, deletedID1)
|
|
|
|
return err
|
|
},
|
|
}}
|
|
p.Run(t, old)
|
|
}
|
|
|
|
func TestUpdateWithPendingDelete(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
host := deploytest.NewPluginHost(nil, nil, nil, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
}
|
|
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
|
|
// Create an old snapshot with two copies of a resource that share a URN: one that is pending deletion and one
|
|
// that is not.
|
|
old := &deploy.Snapshot{
|
|
Resources: []*resource.State{
|
|
{
|
|
Type: resURN.Type(),
|
|
URN: resURN,
|
|
Custom: true,
|
|
ID: "1",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
},
|
|
{
|
|
Type: resURN.Type(),
|
|
URN: resURN,
|
|
Custom: true,
|
|
ID: "0",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
Delete: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
p.Steps = []TestStep{{
|
|
Op: Destroy,
|
|
Validate: func(_ workspace.Project, _ deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Verify that we see a DeleteReplacement for the resource with ID 0 and a Delete for the resource with
|
|
// ID 1.
|
|
deletedID0, deletedID1 := false, false
|
|
for _, entry := range j.Entries {
|
|
// Ignore non-terminal steps and steps that affect the injected default provider.
|
|
if entry.Kind != JournalEntrySuccess || entry.Step.URN() != resURN ||
|
|
(entry.Step.Op() != deploy.OpDelete && entry.Step.Op() != deploy.OpDeleteReplaced) {
|
|
continue
|
|
}
|
|
|
|
switch id := entry.Step.Old().ID; id {
|
|
case "0":
|
|
assert.False(t, deletedID0)
|
|
deletedID0 = true
|
|
case "1":
|
|
assert.False(t, deletedID1)
|
|
deletedID1 = true
|
|
default:
|
|
assert.Fail(t, "unexpected resource ID %v", string(id))
|
|
}
|
|
}
|
|
assert.True(t, deletedID0)
|
|
assert.True(t, deletedID1)
|
|
|
|
return err
|
|
},
|
|
}}
|
|
p.Run(t, old)
|
|
}
|
|
|
|
func TestParallelRefresh(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
// Create a program that registers four resources, each of which depends on the resource that immediately precedes
|
|
// it.
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
resA, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
resB, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resB", true, "", false, []resource.URN{resA}, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
resC, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resC", true, "", false, []resource.URN{resB}, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
_, _, _, err = monitor.RegisterResource("pkgA:m:typA", "resD", true, "", false, []resource.URN{resC}, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{Parallel: 4, host: host},
|
|
}
|
|
|
|
p.Steps = []TestStep{{Op: Update}}
|
|
snap := p.Run(t, nil)
|
|
|
|
assert.Len(t, snap.Resources, 5)
|
|
assert.Equal(t, string(snap.Resources[0].URN.Name()), "default") // provider
|
|
assert.Equal(t, string(snap.Resources[1].URN.Name()), "resA")
|
|
assert.Equal(t, string(snap.Resources[2].URN.Name()), "resB")
|
|
assert.Equal(t, string(snap.Resources[3].URN.Name()), "resC")
|
|
assert.Equal(t, string(snap.Resources[4].URN.Name()), "resD")
|
|
|
|
p.Steps = []TestStep{{Op: Refresh}}
|
|
snap = p.Run(t, snap)
|
|
|
|
assert.Len(t, snap.Resources, 5)
|
|
assert.Equal(t, string(snap.Resources[0].URN.Name()), "default") // provider
|
|
assert.Equal(t, string(snap.Resources[1].URN.Name()), "resA")
|
|
assert.Equal(t, string(snap.Resources[2].URN.Name()), "resB")
|
|
assert.Equal(t, string(snap.Resources[3].URN.Name()), "resC")
|
|
assert.Equal(t, string(snap.Resources[4].URN.Name()), "resD")
|
|
}
|
|
|
|
func TestExternalRefresh(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
// Our program reads a resource and exits.
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, err := monitor.ReadResource("pkgA:m:typA", "resA", "resA-some-id", "", resource.PropertyMap{}, "")
|
|
if !assert.NoError(t, err) {
|
|
t.FailNow()
|
|
}
|
|
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: []TestStep{{Op: Update}},
|
|
}
|
|
|
|
// The read should place "resA" in the snapshot with the "External" bit set.
|
|
snap := p.Run(t, nil)
|
|
assert.Len(t, snap.Resources, 2)
|
|
assert.Equal(t, string(snap.Resources[0].URN.Name()), "default") // provider
|
|
assert.Equal(t, string(snap.Resources[1].URN.Name()), "resA")
|
|
assert.True(t, snap.Resources[1].External)
|
|
|
|
p = &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: []TestStep{{Op: Refresh}},
|
|
}
|
|
|
|
snap = p.Run(t, snap)
|
|
// A refresh should leave "resA" as it is in the snapshot. The External bit should still be set.
|
|
assert.Len(t, snap.Resources, 2)
|
|
assert.Equal(t, string(snap.Resources[0].URN.Name()), "default") // provider
|
|
assert.Equal(t, string(snap.Resources[1].URN.Name()), "resA")
|
|
assert.True(t, snap.Resources[1].External)
|
|
}
|
|
|
|
func TestRefreshInitFailure(t *testing.T) {
|
|
p := &TestPlan{}
|
|
|
|
provURN := p.NewProviderURN("pkgA", "default", "")
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
res2URN := p.NewURN("pkgA:m:typA", "resB", "")
|
|
|
|
res2Outputs := resource.PropertyMap{"foo": resource.NewStringProperty("bar")}
|
|
|
|
//
|
|
// Refresh will persist any initialization errors that are returned by `Read`. This provider
|
|
// will error out or not based on the value of `refreshShouldFail`.
|
|
//
|
|
refreshShouldFail := false
|
|
|
|
//
|
|
// Set up test environment to use `readFailProvider` as the underlying resource provider.
|
|
//
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
ReadF: func(
|
|
urn resource.URN, id resource.ID, props resource.PropertyMap,
|
|
) (resource.PropertyMap, resource.Status, error) {
|
|
if refreshShouldFail && urn == resURN {
|
|
err := &plugin.InitError{
|
|
Reasons: []string{"Refresh reports continued to fail to initialize"},
|
|
}
|
|
return resource.PropertyMap{}, resource.StatusPartialFailure, err
|
|
} else if urn == res2URN {
|
|
return res2Outputs, resource.StatusOK, nil
|
|
}
|
|
return resource.PropertyMap{}, resource.StatusOK, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p.Options.host = host
|
|
|
|
//
|
|
// Create an old snapshot with a single initialization failure.
|
|
//
|
|
old := &deploy.Snapshot{
|
|
Resources: []*resource.State{
|
|
{
|
|
Type: resURN.Type(),
|
|
URN: resURN,
|
|
Custom: true,
|
|
ID: "0",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
InitErrors: []string{"Resource failed to initialize"},
|
|
},
|
|
{
|
|
Type: res2URN.Type(),
|
|
URN: res2URN,
|
|
Custom: true,
|
|
ID: "1",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
},
|
|
},
|
|
}
|
|
|
|
//
|
|
// Refresh DOES NOT fail, causing the initialization error to disappear.
|
|
//
|
|
p.Steps = []TestStep{{Op: Refresh}}
|
|
snap := p.Run(t, old)
|
|
|
|
for _, resource := range snap.Resources {
|
|
switch urn := resource.URN; urn {
|
|
case provURN:
|
|
// break
|
|
case resURN:
|
|
assert.Empty(t, resource.InitErrors)
|
|
case res2URN:
|
|
assert.Equal(t, res2Outputs, resource.Outputs)
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
}
|
|
|
|
//
|
|
// Refresh DOES fail, causing the new initialization error to appear.
|
|
//
|
|
refreshShouldFail = true
|
|
p.Steps = []TestStep{{Op: Refresh, SkipPreview: true, ExpectFailure: true}}
|
|
snap = p.Run(t, old)
|
|
for _, resource := range snap.Resources {
|
|
switch urn := resource.URN; urn {
|
|
case provURN:
|
|
// break
|
|
case resURN:
|
|
assert.Equal(t, []string{"Refresh reports continued to fail to initialize"}, resource.InitErrors)
|
|
case res2URN:
|
|
assert.Equal(t, res2Outputs, resource.Outputs)
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test that ensures that we log diagnostics for resources that receive an error from Check. (Note that this
|
|
// is distinct from receiving non-error failures from Check.)
|
|
func TestCheckFailureRecord(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
CheckF: func(urn resource.URN,
|
|
olds, news resource.PropertyMap) (resource.PropertyMap, []plugin.CheckFailure, error) {
|
|
return nil, nil, errors.New("oh no, check had an error")
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "", nil, nil, false)
|
|
assert.Error(t, err)
|
|
return err
|
|
})
|
|
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: []TestStep{{
|
|
Op: Update,
|
|
ExpectFailure: true,
|
|
SkipPreview: true,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
sawFailure := false
|
|
for _, evt := range evts {
|
|
if evt.Type == DiagEvent {
|
|
e := evt.Payload.(DiagEventPayload)
|
|
msg := colors.Never.Colorize(e.Message)
|
|
sawFailure = msg == "oh no, check had an error\n" && e.Severity == diag.Error
|
|
}
|
|
}
|
|
|
|
assert.True(t, sawFailure)
|
|
return err
|
|
},
|
|
}},
|
|
}
|
|
|
|
p.Run(t, nil)
|
|
}
|
|
|
|
// Test that checks that we emit diagnostics for properties that check says are invalid.
|
|
func TestCheckFailureInvalidPropertyRecord(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
CheckF: func(urn resource.URN,
|
|
olds, news resource.PropertyMap) (resource.PropertyMap, []plugin.CheckFailure, error) {
|
|
return nil, []plugin.CheckFailure{{
|
|
Property: "someprop",
|
|
Reason: "field is not valid",
|
|
}}, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "", nil, nil, false)
|
|
assert.Error(t, err)
|
|
return err
|
|
})
|
|
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: []TestStep{{
|
|
Op: Update,
|
|
ExpectFailure: true,
|
|
SkipPreview: true,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
sawFailure := false
|
|
for _, evt := range evts {
|
|
if evt.Type == DiagEvent {
|
|
e := evt.Payload.(DiagEventPayload)
|
|
msg := colors.Never.Colorize(e.Message)
|
|
sawFailure = strings.Contains(msg, "field is not valid") && e.Severity == diag.Error
|
|
if sawFailure {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.True(t, sawFailure)
|
|
return err
|
|
},
|
|
}},
|
|
}
|
|
|
|
p.Run(t, nil)
|
|
|
|
}
|
|
|
|
// Test that tests that Refresh can detect that resources have been deleted and removes them
|
|
// from the snapshot.
|
|
func TestRefreshWithDelete(t *testing.T) {
|
|
for _, parallelFactor := range []int{1, 4} {
|
|
t.Run(fmt.Sprintf("parallel-%d", parallelFactor), func(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
ReadF: func(
|
|
urn resource.URN, id resource.ID, props resource.PropertyMap,
|
|
) (resource.PropertyMap, resource.Status, error) {
|
|
// This thing doesn't exist. Returning nil from Read should trigger
|
|
// the engine to delete it from the snapshot.
|
|
return nil, resource.StatusOK, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "", nil, nil, false)
|
|
assert.NoError(t, err)
|
|
return err
|
|
})
|
|
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p := &TestPlan{Options: UpdateOptions{host: host, Parallel: parallelFactor}}
|
|
|
|
p.Steps = []TestStep{{Op: Update}}
|
|
snap := p.Run(t, nil)
|
|
|
|
p.Steps = []TestStep{{Op: Refresh}}
|
|
snap = p.Run(t, snap)
|
|
|
|
// Refresh succeeds and records that the resource in the snapshot doesn't exist anymore
|
|
provURN := p.NewProviderURN("pkgA", "default", "")
|
|
assert.Len(t, snap.Resources, 1)
|
|
assert.Equal(t, provURN, snap.Resources[0].URN)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Tests that dependencies are correctly rewritten when refresh removes deleted resources.
|
|
func TestRefreshDeleteDependencies(t *testing.T) {
|
|
p := &TestPlan{}
|
|
|
|
const resType = "pkgA:m:typA"
|
|
|
|
urnA := p.NewURN(resType, "resA", "")
|
|
urnB := p.NewURN(resType, "resB", "")
|
|
urnC := p.NewURN(resType, "resC", "")
|
|
|
|
newResource := func(urn resource.URN, id resource.ID, delete bool, dependencies ...resource.URN) *resource.State {
|
|
return &resource.State{
|
|
Type: urn.Type(),
|
|
URN: urn,
|
|
Custom: true,
|
|
Delete: delete,
|
|
ID: id,
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
Dependencies: dependencies,
|
|
}
|
|
}
|
|
|
|
oldResources := []*resource.State{
|
|
newResource(urnA, "0", false),
|
|
newResource(urnB, "1", false, urnA),
|
|
newResource(urnC, "2", false, urnA, urnB),
|
|
newResource(urnA, "3", true),
|
|
newResource(urnA, "4", true),
|
|
newResource(urnC, "5", true, urnA, urnB),
|
|
}
|
|
|
|
old := &deploy.Snapshot{
|
|
Resources: oldResources,
|
|
}
|
|
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
ReadF: func(urn resource.URN, id resource.ID,
|
|
state resource.PropertyMap) (resource.PropertyMap, resource.Status, error) {
|
|
|
|
switch id {
|
|
case "0", "4":
|
|
// We want to delete resources A::0 and A::4.
|
|
return nil, resource.StatusOK, nil
|
|
default:
|
|
return state, resource.StatusOK, nil
|
|
}
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
p.Options.host = deploytest.NewPluginHost(nil, nil, nil, loaders...)
|
|
|
|
p.Steps = []TestStep{{Op: Refresh}}
|
|
snap := p.Run(t, old)
|
|
|
|
provURN := p.NewProviderURN("pkgA", "default", "")
|
|
|
|
for _, r := range snap.Resources {
|
|
switch urn := r.URN; urn {
|
|
case provURN:
|
|
continue
|
|
case urnA, urnB, urnC:
|
|
// break
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
|
|
switch r.ID {
|
|
case "1":
|
|
// A::0 was deleted, so B's dependency list should be empty.
|
|
assert.Equal(t, urnB, r.URN)
|
|
assert.Empty(t, r.Dependencies)
|
|
case "2":
|
|
// A::0 was deleted, so C's dependency list should only contain B.
|
|
assert.Equal(t, urnC, r.URN)
|
|
assert.Equal(t, []resource.URN{urnB}, r.Dependencies)
|
|
case "3":
|
|
// A::3 should not have changed.
|
|
assert.Equal(t, oldResources[3], r)
|
|
case "5":
|
|
// A::4 was deleted but A::3 was still refernceable by C, so C should not have changed.
|
|
assert.Equal(t, oldResources[5], r)
|
|
default:
|
|
t.Fatalf("unexepcted resource %v::%v", r.URN, r.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tests basic refresh functionality.
|
|
func TestRefreshBasics(t *testing.T) {
|
|
p := &TestPlan{}
|
|
|
|
const resType = "pkgA:m:typA"
|
|
|
|
urnA := p.NewURN(resType, "resA", "")
|
|
urnB := p.NewURN(resType, "resB", "")
|
|
urnC := p.NewURN(resType, "resC", "")
|
|
|
|
newResource := func(urn resource.URN, id resource.ID, delete bool, dependencies ...resource.URN) *resource.State {
|
|
return &resource.State{
|
|
Type: urn.Type(),
|
|
URN: urn,
|
|
Custom: true,
|
|
Delete: delete,
|
|
ID: id,
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
Dependencies: dependencies,
|
|
}
|
|
}
|
|
|
|
oldResources := []*resource.State{
|
|
newResource(urnA, "0", false),
|
|
newResource(urnB, "1", false, urnA),
|
|
newResource(urnC, "2", false, urnA, urnB),
|
|
newResource(urnA, "3", true),
|
|
newResource(urnA, "4", true),
|
|
newResource(urnC, "5", true, urnA, urnB),
|
|
}
|
|
|
|
newStates := map[resource.ID]resource.PropertyMap{
|
|
// A::0 and A::3 will have no changes.
|
|
"0": {},
|
|
"3": {},
|
|
|
|
// B::1 and A::4 will have changes.
|
|
"1": {"foo": resource.NewStringProperty("bar")},
|
|
"4": {"baz": resource.NewStringProperty("qux")},
|
|
|
|
// C::2 and C::5 will be deleted.
|
|
"2": nil,
|
|
"5": nil,
|
|
}
|
|
|
|
old := &deploy.Snapshot{
|
|
Resources: oldResources,
|
|
}
|
|
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
ReadF: func(urn resource.URN, id resource.ID,
|
|
state resource.PropertyMap) (resource.PropertyMap, resource.Status, error) {
|
|
|
|
new, hasNewState := newStates[id]
|
|
assert.True(t, hasNewState)
|
|
return new, resource.StatusOK, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
p.Options.host = deploytest.NewPluginHost(nil, nil, nil, loaders...)
|
|
|
|
p.Steps = []TestStep{{
|
|
Op: Refresh,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
// Should see only refreshes.
|
|
for _, entry := range j.Entries {
|
|
assert.Equal(t, deploy.OpRefresh, entry.Step.Op())
|
|
resultOp := entry.Step.(*deploy.RefreshStep).ResultOp()
|
|
|
|
old := entry.Step.Old()
|
|
if !old.Custom || providers.IsProviderType(old.Type) {
|
|
// Component and provider resources should never change.
|
|
assert.Equal(t, deploy.OpSame, resultOp)
|
|
continue
|
|
}
|
|
|
|
expected, new := newStates[old.ID], entry.Step.New()
|
|
if expected == nil {
|
|
// If the resource was deleted, we want the result op to be an OpDelete.
|
|
assert.Nil(t, new)
|
|
assert.Equal(t, deploy.OpDelete, resultOp)
|
|
} else {
|
|
// If there were changes to the outputs, we want the result op to be an OpUpdate. Otherwise we want
|
|
// an OpSame.
|
|
if reflect.DeepEqual(old.Outputs, expected) {
|
|
assert.Equal(t, deploy.OpSame, resultOp)
|
|
} else {
|
|
assert.Equal(t, deploy.OpUpdate, resultOp)
|
|
}
|
|
|
|
// Only the outputs should have changed (if anything changed).
|
|
old.Outputs = expected
|
|
assert.Equal(t, old, new)
|
|
}
|
|
}
|
|
return err
|
|
},
|
|
}}
|
|
snap := p.Run(t, old)
|
|
|
|
provURN := p.NewProviderURN("pkgA", "default", "")
|
|
|
|
for _, r := range snap.Resources {
|
|
switch urn := r.URN; urn {
|
|
case provURN:
|
|
continue
|
|
case urnA, urnB, urnC:
|
|
// break
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
|
|
// The only resources left in the checkpoint should be those that were not deleted by the refresh.
|
|
expected := newStates[r.ID]
|
|
assert.NotNil(t, expected)
|
|
|
|
idx, err := strconv.ParseInt(string(r.ID), 0, 0)
|
|
assert.NoError(t, err)
|
|
|
|
// The new resources should be equal to the old resources + the new outputs.
|
|
old := oldResources[int(idx)]
|
|
old.Outputs = expected
|
|
assert.Equal(t, old, r)
|
|
}
|
|
}
|
|
|
|
// Tests that an interrupted refresh leaves behind an expected state.
|
|
func TestCanceledRefresh(t *testing.T) {
|
|
p := &TestPlan{}
|
|
|
|
const resType = "pkgA:m:typA"
|
|
|
|
urnA := p.NewURN(resType, "resA", "")
|
|
urnB := p.NewURN(resType, "resB", "")
|
|
urnC := p.NewURN(resType, "resC", "")
|
|
|
|
newResource := func(urn resource.URN, id resource.ID, delete bool, dependencies ...resource.URN) *resource.State {
|
|
return &resource.State{
|
|
Type: urn.Type(),
|
|
URN: urn,
|
|
Custom: true,
|
|
Delete: delete,
|
|
ID: id,
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
Dependencies: dependencies,
|
|
}
|
|
}
|
|
|
|
oldResources := []*resource.State{
|
|
newResource(urnA, "0", false),
|
|
newResource(urnB, "1", false),
|
|
newResource(urnC, "2", false),
|
|
}
|
|
|
|
newStates := map[resource.ID]resource.PropertyMap{
|
|
// A::0 and B::1 will have changes; D::3 will be deleted.
|
|
"0": {"foo": resource.NewStringProperty("bar")},
|
|
"1": {"baz": resource.NewStringProperty("qux")},
|
|
"2": nil,
|
|
}
|
|
|
|
old := &deploy.Snapshot{
|
|
Resources: oldResources,
|
|
}
|
|
|
|
// Set up a cancelable context for the refresh operation.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Serialize all refreshes s.t. we can cancel after the first is issued.
|
|
refreshes, cancelled := make(chan resource.ID), make(chan bool)
|
|
go func() {
|
|
<-refreshes
|
|
cancel()
|
|
}()
|
|
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
ReadF: func(urn resource.URN, id resource.ID,
|
|
state resource.PropertyMap) (resource.PropertyMap, resource.Status, error) {
|
|
|
|
refreshes <- id
|
|
<-cancelled
|
|
|
|
new, hasNewState := newStates[id]
|
|
assert.True(t, hasNewState)
|
|
return new, resource.StatusOK, nil
|
|
},
|
|
CancelF: func() error {
|
|
close(cancelled)
|
|
return nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
refreshed := make(map[resource.ID]bool)
|
|
op := TestOp(Refresh)
|
|
options := UpdateOptions{
|
|
Parallel: 1,
|
|
host: deploytest.NewPluginHost(nil, nil, nil, loaders...),
|
|
}
|
|
project, target := p.GetProject(), p.GetTarget(old)
|
|
validate := func(project workspace.Project, target deploy.Target, j *Journal, _ []Event, err error) error {
|
|
for _, entry := range j.Entries {
|
|
assert.Equal(t, deploy.OpRefresh, entry.Step.Op())
|
|
resultOp := entry.Step.(*deploy.RefreshStep).ResultOp()
|
|
|
|
old := entry.Step.Old()
|
|
if !old.Custom || providers.IsProviderType(old.Type) {
|
|
// Component and provider resources should never change.
|
|
assert.Equal(t, deploy.OpSame, resultOp)
|
|
continue
|
|
}
|
|
|
|
refreshed[old.ID] = true
|
|
|
|
expected, new := newStates[old.ID], entry.Step.New()
|
|
if expected == nil {
|
|
// If the resource was deleted, we want the result op to be an OpDelete.
|
|
assert.Nil(t, new)
|
|
assert.Equal(t, deploy.OpDelete, resultOp)
|
|
} else {
|
|
// If there were changes to the outputs, we want the result op to be an OpUpdate. Otherwise we want
|
|
// an OpSame.
|
|
if reflect.DeepEqual(old.Outputs, expected) {
|
|
assert.Equal(t, deploy.OpSame, resultOp)
|
|
} else {
|
|
assert.Equal(t, deploy.OpUpdate, resultOp)
|
|
}
|
|
|
|
// Only the outputs should have changed (if anything changed).
|
|
old.Outputs = expected
|
|
assert.Equal(t, old, new)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
snap, err := op.RunWithContext(ctx, project, target, options, false, nil, validate)
|
|
assert.Error(t, err)
|
|
assert.Equal(t, 1, len(refreshed))
|
|
|
|
provURN := p.NewProviderURN("pkgA", "default", "")
|
|
|
|
for _, r := range snap.Resources {
|
|
switch urn := r.URN; urn {
|
|
case provURN:
|
|
continue
|
|
case urnA, urnB, urnC:
|
|
// break
|
|
default:
|
|
t.Fatalf("unexpected resource %v", urn)
|
|
}
|
|
|
|
idx, err := strconv.ParseInt(string(r.ID), 0, 0)
|
|
assert.NoError(t, err)
|
|
|
|
if refreshed[r.ID] {
|
|
// The refreshed resource should have its new state.
|
|
expected := newStates[r.ID]
|
|
if expected == nil {
|
|
assert.Fail(t, "refreshed resource was not deleted")
|
|
} else {
|
|
old := oldResources[int(idx)]
|
|
old.Outputs = expected
|
|
assert.Equal(t, old, r)
|
|
}
|
|
} else {
|
|
// Any resources that were not refreshed should retain their original state.
|
|
old := oldResources[int(idx)]
|
|
assert.Equal(t, old, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tests that errors returned directly from the language host get logged by the engine.
|
|
func TestLanguageHostDiagnostics(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
errorText := "oh no"
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, _ *deploytest.ResourceMonitor) error {
|
|
// Exiting immediately with an error simulates a language exiting immediately with a non-zero exit code.
|
|
return errors.New(errorText)
|
|
})
|
|
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: []TestStep{{
|
|
Op: Update,
|
|
ExpectFailure: true,
|
|
SkipPreview: true,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.Error(t, err)
|
|
sawExitCode := false
|
|
for _, evt := range evts {
|
|
if evt.Type == DiagEvent {
|
|
e := evt.Payload.(DiagEventPayload)
|
|
msg := colors.Never.Colorize(e.Message)
|
|
sawExitCode = strings.Contains(msg, errorText) && e.Severity == diag.Error
|
|
if sawExitCode {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.True(t, sawExitCode)
|
|
return err
|
|
},
|
|
}},
|
|
}
|
|
|
|
p.Run(t, nil)
|
|
}
|
|
|
|
type brokenDecrypter struct {
|
|
ErrorMessage string
|
|
}
|
|
|
|
func (b brokenDecrypter) DecryptValue(ciphertext string) (string, error) {
|
|
return "", fmt.Errorf(b.ErrorMessage)
|
|
}
|
|
|
|
// Tests that the engine presents a reasonable error message when a decrypter fails to decrypt a config value.
|
|
func TestBrokenDecrypter(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, _ *deploytest.ResourceMonitor) error {
|
|
return nil
|
|
})
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
key := config.MustMakeKey("foo", "bar")
|
|
msg := "decryption failed"
|
|
configMap := make(config.Map)
|
|
configMap[key] = config.NewSecureValue("hunter2")
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Decrypter: brokenDecrypter{ErrorMessage: msg},
|
|
Config: configMap,
|
|
Steps: []TestStep{{
|
|
Op: Update,
|
|
ExpectFailure: true,
|
|
SkipPreview: true,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.Error(t, err)
|
|
decryptErr := err.(DecryptError)
|
|
assert.Equal(t, key, decryptErr.Key)
|
|
assert.Contains(t, decryptErr.Err.Error(), msg)
|
|
return err
|
|
},
|
|
}},
|
|
}
|
|
|
|
p.Run(t, nil)
|
|
}
|
|
|
|
func TestBadResourceType(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, mon *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := mon.RegisterResource(
|
|
"very:bad", "resA", true, "", false, nil, "", resource.PropertyMap{}, nil, false)
|
|
assert.Error(t, err)
|
|
rpcerr, ok := rpcerror.FromError(err)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, codes.InvalidArgument, rpcerr.Code())
|
|
assert.Contains(t, rpcerr.Message(), "Type 'very:bad' is not a valid type token")
|
|
|
|
_, _, err = mon.ReadResource("very:bad", "someResource", "someId", "", resource.PropertyMap{}, "")
|
|
assert.Error(t, err)
|
|
rpcerr, ok = rpcerror.FromError(err)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, codes.InvalidArgument, rpcerr.Code())
|
|
assert.Contains(t, rpcerr.Message(), "Type 'very:bad' is not a valid type token")
|
|
|
|
// Component resources may have any format type.
|
|
_, _, _, noErr := mon.RegisterResource(
|
|
"a:component", "resB", false /* custom */, "", false, nil, "", resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, noErr)
|
|
|
|
_, _, _, noErr = mon.RegisterResource(
|
|
"singlename", "resC", false /* custom */, "", false, nil, "", resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, noErr)
|
|
|
|
return err
|
|
})
|
|
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: []TestStep{{
|
|
Op: Update,
|
|
ExpectFailure: true,
|
|
SkipPreview: true,
|
|
}},
|
|
}
|
|
|
|
p.Run(t, nil)
|
|
}
|
|
|
|
// Tests that provider cancellation occurs as expected.
|
|
func TestProviderCancellation(t *testing.T) {
|
|
const resourceCount = 4
|
|
|
|
// Set up a cancelable context for the refresh operation.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Wait for our resource ops, then cancel.
|
|
var ops sync.WaitGroup
|
|
ops.Add(resourceCount)
|
|
go func() {
|
|
ops.Wait()
|
|
cancel()
|
|
}()
|
|
|
|
// Set up an independent cancelable context for the provider's operations.
|
|
provCtx, provCancel := context.WithCancel(context.Background())
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
CreateF: func(urn resource.URN,
|
|
inputs resource.PropertyMap) (resource.ID, resource.PropertyMap, resource.Status, error) {
|
|
|
|
// Inform the waiter that we've entered a provider op and wait for cancellation.
|
|
ops.Done()
|
|
<-provCtx.Done()
|
|
|
|
return resource.ID(urn.Name()), resource.PropertyMap{}, resource.StatusOK, nil
|
|
},
|
|
CancelF: func() error {
|
|
provCancel()
|
|
return nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
done := make(chan bool)
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
errors := make([]error, resourceCount)
|
|
var resources sync.WaitGroup
|
|
resources.Add(resourceCount)
|
|
for i := 0; i < resourceCount; i++ {
|
|
go func(idx int) {
|
|
_, _, _, errors[idx] = monitor.RegisterResource("pkgA:m:typA", fmt.Sprintf("res%d", idx), true, "",
|
|
false, nil, "", resource.PropertyMap{}, nil, false)
|
|
resources.Done()
|
|
}(i)
|
|
}
|
|
resources.Wait()
|
|
for _, err := range errors {
|
|
assert.NoError(t, err)
|
|
}
|
|
close(done)
|
|
return nil
|
|
})
|
|
|
|
p := &TestPlan{}
|
|
op := TestOp(Update)
|
|
options := UpdateOptions{
|
|
Parallel: resourceCount,
|
|
host: deploytest.NewPluginHost(nil, nil, program, loaders...),
|
|
}
|
|
project, target := p.GetProject(), p.GetTarget(nil)
|
|
|
|
_, err := op.RunWithContext(ctx, project, target, options, false, nil, nil)
|
|
assert.Error(t, err)
|
|
|
|
// Wait for the program to finish.
|
|
<-done
|
|
}
|
|
|
|
// Tests that a preview works for a stack with pending operations.
|
|
func TestPreviewWithPendingOperations(t *testing.T) {
|
|
p := &TestPlan{}
|
|
|
|
const resType = "pkgA:m:typA"
|
|
urnA := p.NewURN(resType, "resA", "")
|
|
|
|
newResource := func(urn resource.URN, id resource.ID, delete bool, dependencies ...resource.URN) *resource.State {
|
|
return &resource.State{
|
|
Type: urn.Type(),
|
|
URN: urn,
|
|
Custom: true,
|
|
Delete: delete,
|
|
ID: id,
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
Dependencies: dependencies,
|
|
}
|
|
}
|
|
|
|
old := &deploy.Snapshot{
|
|
PendingOperations: []resource.Operation{{
|
|
Resource: newResource(urnA, "0", false),
|
|
Type: resource.OperationTypeUpdating,
|
|
}},
|
|
Resources: []*resource.State{
|
|
newResource(urnA, "0", false),
|
|
},
|
|
}
|
|
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "",
|
|
resource.PropertyMap{}, nil, false)
|
|
assert.NoError(t, err)
|
|
return nil
|
|
})
|
|
|
|
op := TestOp(Update)
|
|
options := UpdateOptions{host: deploytest.NewPluginHost(nil, nil, program, loaders...)}
|
|
project, target := p.GetProject(), p.GetTarget(old)
|
|
|
|
// A preview should succeed despite the pending operations.
|
|
_, err := op.Run(project, target, options, true, nil, nil)
|
|
assert.NoError(t, err)
|
|
|
|
// But an update should fail.
|
|
_, err = op.Run(project, target, options, false, nil, nil)
|
|
assert.EqualError(t, err, deploy.PlanPendingOperationsError{}.Error())
|
|
}
|
|
|
|
// Tests that a failed partial update causes the engine to persist the resource's old inputs and new outputs.
|
|
func TestUpdatePartialFailure(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
DiffF: func(urn resource.URN, id resource.ID, olds, news resource.PropertyMap) (plugin.DiffResult, error) {
|
|
return plugin.DiffResult{
|
|
Changes: plugin.DiffSome,
|
|
}, nil
|
|
},
|
|
|
|
UpdateF: func(urn resource.URN, id resource.ID, olds,
|
|
news resource.PropertyMap) (resource.PropertyMap, resource.Status, error) {
|
|
outputs := resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"output_prop": 42,
|
|
})
|
|
|
|
return outputs, resource.StatusPartialFailure, errors.New("update failed to apply")
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, mon *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := mon.RegisterResource("pkgA:m:typA", "resA", true, "", false, nil, "",
|
|
resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"input_prop": "new inputs",
|
|
}), nil, false)
|
|
|
|
return err
|
|
})
|
|
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p := &TestPlan{Options: UpdateOptions{host: host}}
|
|
|
|
resURN := p.NewURN("pkgA:m:typA", "resA", "")
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
ExpectFailure: true,
|
|
SkipPreview: true,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.Error(t, err)
|
|
for _, entry := range j.Entries {
|
|
switch urn := entry.Step.URN(); urn {
|
|
case resURN:
|
|
assert.Equal(t, deploy.OpUpdate, entry.Step.Op())
|
|
switch entry.Kind {
|
|
case JournalEntryBegin:
|
|
continue
|
|
case JournalEntrySuccess:
|
|
inputs := entry.Step.New().Inputs
|
|
outputs := entry.Step.New().Outputs
|
|
assert.Len(t, inputs, 1)
|
|
assert.Len(t, outputs, 1)
|
|
assert.Equal(t,
|
|
resource.NewStringProperty("old inputs"), inputs[resource.PropertyKey("input_prop")])
|
|
assert.Equal(t,
|
|
resource.NewNumberProperty(42), outputs[resource.PropertyKey("output_prop")])
|
|
default:
|
|
t.Fatalf("unexpected journal operation: %d", entry.Kind)
|
|
}
|
|
}
|
|
}
|
|
|
|
return err
|
|
},
|
|
}}
|
|
|
|
old := &deploy.Snapshot{
|
|
Resources: []*resource.State{
|
|
{
|
|
Type: resURN.Type(),
|
|
URN: resURN,
|
|
Custom: true,
|
|
ID: "1",
|
|
Inputs: resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"input_prop": "old inputs",
|
|
}),
|
|
Outputs: resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"output_prop": 1,
|
|
}),
|
|
},
|
|
},
|
|
}
|
|
|
|
p.Run(t, old)
|
|
}
|
|
|
|
// Tests that the StackReference resource works as intended,
|
|
func TestStackReference(t *testing.T) {
|
|
loaders := []*deploytest.ProviderLoader{}
|
|
|
|
// Test that the normal lifecycle works correctly.
|
|
program := deploytest.NewLanguageRuntime(func(info plugin.RunInfo, mon *deploytest.ResourceMonitor) error {
|
|
_, _, state, err := mon.RegisterResource("pulumi:pulumi:StackReference", "other", true, "", false, nil, "",
|
|
resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"name": "other",
|
|
}), nil, false)
|
|
assert.NoError(t, err)
|
|
if !info.DryRun {
|
|
assert.Equal(t, "bar", state["outputs"].ObjectValue()["foo"].StringValue())
|
|
}
|
|
return nil
|
|
})
|
|
p := &TestPlan{
|
|
BackendClient: &deploytest.BackendClient{
|
|
GetStackOutputsF: func(ctx context.Context, name string) (resource.PropertyMap, error) {
|
|
switch name {
|
|
case "other":
|
|
return resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"foo": "bar",
|
|
}), nil
|
|
default:
|
|
return nil, errors.Errorf("unknown stack \"%s\"", name)
|
|
}
|
|
},
|
|
},
|
|
Options: UpdateOptions{host: deploytest.NewPluginHost(nil, nil, program, loaders...)},
|
|
Steps: MakeBasicLifecycleSteps(t, 2),
|
|
}
|
|
p.Run(t, nil)
|
|
|
|
// Test that changes to `name` cause replacement.
|
|
resURN := p.NewURN("pulumi:pulumi:StackReference", "other", "")
|
|
old := &deploy.Snapshot{
|
|
Resources: []*resource.State{
|
|
{
|
|
Type: resURN.Type(),
|
|
URN: resURN,
|
|
Custom: true,
|
|
ID: "1",
|
|
Inputs: resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"name": "other2",
|
|
}),
|
|
Outputs: resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"name": "other2",
|
|
"outputs": resource.PropertyMap{},
|
|
}),
|
|
},
|
|
},
|
|
}
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
SkipPreview: true,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.NoError(t, err)
|
|
for _, entry := range j.Entries {
|
|
switch urn := entry.Step.URN(); urn {
|
|
case resURN:
|
|
switch entry.Step.Op() {
|
|
case deploy.OpCreateReplacement, deploy.OpDeleteReplaced, deploy.OpReplace:
|
|
// OK
|
|
default:
|
|
t.Fatalf("unexpected journal operation: %v", entry.Step.Op())
|
|
}
|
|
}
|
|
}
|
|
|
|
return err
|
|
},
|
|
}}
|
|
p.Run(t, old)
|
|
|
|
// Test that unknown stacks are handled appropriately.
|
|
program = deploytest.NewLanguageRuntime(func(info plugin.RunInfo, mon *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := mon.RegisterResource("pulumi:pulumi:StackReference", "other", true, "", false, nil, "",
|
|
resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"name": "rehto",
|
|
}), nil, false)
|
|
assert.Error(t, err)
|
|
return err
|
|
})
|
|
p.Options = UpdateOptions{host: deploytest.NewPluginHost(nil, nil, program, loaders...)}
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
ExpectFailure: true,
|
|
SkipPreview: true,
|
|
}}
|
|
p.Run(t, nil)
|
|
|
|
// Test that unknown properties cause errors.
|
|
program = deploytest.NewLanguageRuntime(func(info plugin.RunInfo, mon *deploytest.ResourceMonitor) error {
|
|
_, _, _, err := mon.RegisterResource("pulumi:pulumi:StackReference", "other", true, "", false, nil, "",
|
|
resource.NewPropertyMapFromMap(map[string]interface{}{
|
|
"name": "other",
|
|
"foo": "bar",
|
|
}), nil, false)
|
|
assert.Error(t, err)
|
|
return err
|
|
})
|
|
p.Options = UpdateOptions{host: deploytest.NewPluginHost(nil, nil, program, loaders...)}
|
|
p.Run(t, nil)
|
|
}
|
|
|
|
type channelWriter struct {
|
|
channel chan []byte
|
|
}
|
|
|
|
func (cw *channelWriter) Write(d []byte) (int, error) {
|
|
cw.channel <- d
|
|
return len(d), nil
|
|
}
|
|
|
|
// Tests that a failed plugin load correctly shuts down the host.
|
|
func TestLoadFailureShutdown(t *testing.T) {
|
|
|
|
// Note that the setup here is a bit baroque, and is intended to replicate the CLI architecture that lead to
|
|
// issue #2170. That issue--a panic on a closed channel--was caused by the intersection of several design choices:
|
|
//
|
|
// - The provider registry loads and configures the set of providers necessary for the resources currently in the
|
|
// checkpoint it is processing at plan creation time. Registry creation fails promptly if a provider plugin
|
|
// fails to load (e.g. because is binary is missing).
|
|
// - Provider configuration in the CLI's host happens asynchronously. This is meant to allow the engine to remain
|
|
// responsive while plugins configure.
|
|
// - Providers may call back into the CLI's host for logging. Callbacks are processed as long as the CLI's plugin
|
|
// context is open.
|
|
// - Log events from the CLI's host are delivered to the CLI's diagnostic streams via channels. The CLI closes
|
|
// these channels once the engine operation it initiated completes.
|
|
//
|
|
// These choices gave rise to the following situation:
|
|
// 1. The provider registry loads a provider for package A and kicks off its configuration.
|
|
// 2. The provider registry attempts to load a provider for package B. The load fails, and the provider registry
|
|
// creation call fails promptly.
|
|
// 3. The engine operation requested by the CLI fails promptly because provider registry creation failed.
|
|
// 4. The CLI shuts down its diagnostic channels.
|
|
// 5. The provider for package A calls back in to the host to log a message. The host then attempts to deliver
|
|
// the message to the CLI's diagnostic channels, causing a panic.
|
|
//
|
|
// The fix was to properly close the plugin host during step (3) s.t. the host was no longer accepting callbacks
|
|
// and would not attempt to send messages to the CLI's diagnostic channels.
|
|
//
|
|
// As such, this test attempts to replicate the CLI architecture by using one provider that configures
|
|
// asynchronously and attempts to call back into the engine and a second provider that fails to load.
|
|
|
|
release, done := make(chan bool), make(chan bool)
|
|
sinkWriter := &channelWriter{channel: make(chan []byte)}
|
|
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoaderWithHost("pkgA", semver.MustParse("1.0.0"),
|
|
func(host plugin.Host) (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
ConfigureF: func(news resource.PropertyMap) error {
|
|
go func() {
|
|
<-release
|
|
host.Log(diag.Info, "", "configuring pkgA provider...", 0)
|
|
close(done)
|
|
}()
|
|
return nil
|
|
},
|
|
}, nil
|
|
}),
|
|
deploytest.NewProviderLoader("pkgB", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return nil, errors.New("pkgB load failure")
|
|
}),
|
|
}
|
|
|
|
p := &TestPlan{}
|
|
provAURN := p.NewProviderURN("pkgA", "default", "")
|
|
provBURN := p.NewProviderURN("pkgB", "default", "")
|
|
|
|
old := &deploy.Snapshot{
|
|
Resources: []*resource.State{
|
|
{
|
|
Type: provAURN.Type(),
|
|
URN: provAURN,
|
|
Custom: true,
|
|
ID: "0",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
},
|
|
{
|
|
Type: provBURN.Type(),
|
|
URN: provBURN,
|
|
Custom: true,
|
|
ID: "1",
|
|
Inputs: resource.PropertyMap{},
|
|
Outputs: resource.PropertyMap{},
|
|
},
|
|
},
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
return nil
|
|
})
|
|
|
|
op := TestOp(Update)
|
|
sink := diag.DefaultSink(sinkWriter, sinkWriter, diag.FormatOptions{Color: colors.Raw})
|
|
options := UpdateOptions{host: deploytest.NewPluginHost(sink, sink, program, loaders...)}
|
|
project, target := p.GetProject(), p.GetTarget(old)
|
|
|
|
_, err := op.Run(project, target, options, true, nil, nil)
|
|
assert.Error(t, err)
|
|
|
|
close(sinkWriter.channel)
|
|
close(release)
|
|
<-done
|
|
}
|
|
|
|
func TestDeleteBeforeReplace(t *testing.T) {
|
|
// A
|
|
// _________|_________
|
|
// B C D
|
|
// ___|___ ___|___
|
|
// E F G H I J
|
|
// |__|
|
|
// K L
|
|
//
|
|
// For a given resource R in (A, C, D):
|
|
// - R will be the provider for its first dependent
|
|
// - A change to R will require that its second dependent be replaced
|
|
// - A change to R will not require that its third dependent be replaced
|
|
//
|
|
// In addition, K will have a requires-replacement property that depends on both F and G, and
|
|
// L will have a normal property that depends on both F and G.
|
|
//
|
|
// With that in mind, the following resources should require replacement: A, B, C, E, F, and K
|
|
|
|
p := &TestPlan{}
|
|
|
|
const resType = "pkgA:m:typA"
|
|
type propertyDependencies map[resource.PropertyKey][]resource.URN
|
|
|
|
urnA := p.NewProviderURN("pkgA", "A", "")
|
|
urnB := p.NewURN(resType, "B", "")
|
|
urnC := p.NewProviderURN("pkgA", "C", "")
|
|
urnD := p.NewProviderURN("pkgA", "D", "")
|
|
urnE := p.NewURN(resType, "E", "")
|
|
urnF := p.NewURN(resType, "F", "")
|
|
urnG := p.NewURN(resType, "G", "")
|
|
urnH := p.NewURN(resType, "H", "")
|
|
urnI := p.NewURN(resType, "I", "")
|
|
urnJ := p.NewURN(resType, "J", "")
|
|
urnK := p.NewURN(resType, "K", "")
|
|
urnL := p.NewURN(resType, "L", "")
|
|
|
|
newResource := func(urn resource.URN, id resource.ID, provider string, dependencies []resource.URN,
|
|
propertyDeps propertyDependencies) *resource.State {
|
|
|
|
inputs := resource.PropertyMap{}
|
|
for k := range propertyDeps {
|
|
inputs[k] = resource.NewStringProperty("foo")
|
|
}
|
|
|
|
return &resource.State{
|
|
Type: urn.Type(),
|
|
URN: urn,
|
|
Custom: true,
|
|
Delete: false,
|
|
ID: id,
|
|
Inputs: inputs,
|
|
Outputs: resource.PropertyMap{},
|
|
Dependencies: dependencies,
|
|
Provider: provider,
|
|
PropertyDependencies: propertyDeps,
|
|
}
|
|
}
|
|
|
|
old := &deploy.Snapshot{
|
|
Resources: []*resource.State{
|
|
newResource(urnA, "0", "", nil, nil),
|
|
newResource(urnB, "1", string(urnA)+"::0", nil, nil),
|
|
newResource(urnC, "2", "",
|
|
[]resource.URN{urnA},
|
|
propertyDependencies{"A": []resource.URN{urnA}}),
|
|
newResource(urnD, "3", "",
|
|
[]resource.URN{urnA},
|
|
propertyDependencies{"B": []resource.URN{urnA}}),
|
|
newResource(urnE, "4", string(urnC)+"::2", nil, nil),
|
|
newResource(urnF, "5", "",
|
|
[]resource.URN{urnC},
|
|
propertyDependencies{"A": []resource.URN{urnC}}),
|
|
newResource(urnG, "6", "",
|
|
[]resource.URN{urnC},
|
|
propertyDependencies{"B": []resource.URN{urnC}}),
|
|
newResource(urnH, "4", string(urnD)+"::3", nil, nil),
|
|
newResource(urnI, "5", "",
|
|
[]resource.URN{urnD},
|
|
propertyDependencies{"A": []resource.URN{urnD}}),
|
|
newResource(urnJ, "6", "",
|
|
[]resource.URN{urnD},
|
|
propertyDependencies{"B": []resource.URN{urnD}}),
|
|
newResource(urnK, "7", "",
|
|
[]resource.URN{urnF, urnG},
|
|
propertyDependencies{"A": []resource.URN{urnF, urnG}}),
|
|
newResource(urnL, "8", "",
|
|
[]resource.URN{urnF, urnG},
|
|
propertyDependencies{"B": []resource.URN{urnF, urnG}}),
|
|
},
|
|
}
|
|
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
DiffConfigF: func(olds, news resource.PropertyMap) (plugin.DiffResult, error) {
|
|
if !olds["A"].DeepEquals(news["A"]) {
|
|
return plugin.DiffResult{
|
|
ReplaceKeys: []resource.PropertyKey{"A"},
|
|
DeleteBeforeReplace: true,
|
|
}, nil
|
|
}
|
|
return plugin.DiffResult{}, nil
|
|
},
|
|
DiffF: func(urn resource.URN, id resource.ID,
|
|
olds, news resource.PropertyMap) (plugin.DiffResult, error) {
|
|
|
|
if !olds["A"].DeepEquals(news["A"]) {
|
|
return plugin.DiffResult{ReplaceKeys: []resource.PropertyKey{"A"}}, nil
|
|
}
|
|
return plugin.DiffResult{}, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
register := func(urn resource.URN, provider string, inputs resource.PropertyMap) resource.ID {
|
|
_, id, _, err := monitor.RegisterResource(urn.Type(), string(urn.Name()), true, "", false, nil, provider,
|
|
inputs, nil, false)
|
|
assert.NoError(t, err)
|
|
return id
|
|
}
|
|
|
|
idA := register(urnA, "", resource.PropertyMap{"A": resource.NewStringProperty("bar")})
|
|
register(urnB, string(urnA)+"::"+string(idA), nil)
|
|
idC := register(urnC, "", nil)
|
|
idD := register(urnD, "", nil)
|
|
register(urnE, string(urnC)+"::"+string(idC), nil)
|
|
register(urnF, "", nil)
|
|
register(urnG, "", nil)
|
|
register(urnH, string(urnD)+"::"+string(idD), nil)
|
|
register(urnI, "", nil)
|
|
register(urnJ, "", nil)
|
|
register(urnK, "", nil)
|
|
register(urnL, "", nil)
|
|
|
|
return nil
|
|
})
|
|
|
|
p.Options.host = deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
ExpectFailure: false,
|
|
SkipPreview: true,
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.NoError(t, err)
|
|
|
|
replaced := make(map[resource.URN]bool)
|
|
for _, entry := range j.Entries {
|
|
if entry.Step.Op() == deploy.OpReplace {
|
|
replaced[entry.Step.URN()] = true
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, map[resource.URN]bool{
|
|
urnA: true,
|
|
urnB: true,
|
|
urnC: true,
|
|
urnE: true,
|
|
urnF: true,
|
|
urnK: true,
|
|
}, replaced)
|
|
|
|
return err
|
|
},
|
|
}}
|
|
|
|
p.Run(t, old)
|
|
}
|
|
|
|
func TestPropertyDependenciesAdapter(t *testing.T) {
|
|
// Ensure that the eval source properly shims in property dependencies if none were reported (and does not if
|
|
// any were reported).
|
|
|
|
type propertyDependencies map[resource.PropertyKey][]resource.URN
|
|
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{}, nil
|
|
}),
|
|
}
|
|
|
|
const resType = "pkgA:m:typA"
|
|
var urnA, urnB, urnC, urnD resource.URN
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
|
|
register := func(name string, inputs resource.PropertyMap, inputDeps propertyDependencies,
|
|
dependencies []resource.URN) resource.URN {
|
|
|
|
urn, _, _, err := monitor.RegisterResource(resType, name, true, "", false, dependencies, "", inputs,
|
|
inputDeps, false)
|
|
assert.NoError(t, err)
|
|
|
|
return urn
|
|
}
|
|
|
|
urnA = register("A", nil, nil, nil)
|
|
urnB = register("B", nil, nil, nil)
|
|
urnC = register("C", resource.PropertyMap{
|
|
"A": resource.NewStringProperty("foo"),
|
|
"B": resource.NewStringProperty("bar"),
|
|
}, nil, []resource.URN{urnA, urnB})
|
|
urnD = register("D", resource.PropertyMap{
|
|
"A": resource.NewStringProperty("foo"),
|
|
"B": resource.NewStringProperty("bar"),
|
|
}, propertyDependencies{
|
|
"A": []resource.URN{urnB},
|
|
"B": []resource.URN{urnA, urnC},
|
|
}, []resource.URN{urnA, urnB, urnC})
|
|
|
|
return nil
|
|
})
|
|
|
|
host := deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p := &TestPlan{
|
|
Options: UpdateOptions{host: host},
|
|
Steps: []TestStep{{Op: Update}},
|
|
}
|
|
snap := p.Run(t, nil)
|
|
for _, res := range snap.Resources {
|
|
switch res.URN {
|
|
case urnA, urnB:
|
|
assert.Empty(t, res.Dependencies)
|
|
assert.Empty(t, res.PropertyDependencies)
|
|
case urnC:
|
|
assert.Equal(t, []resource.URN{urnA, urnB}, res.Dependencies)
|
|
assert.EqualValues(t, propertyDependencies{
|
|
"A": res.Dependencies,
|
|
"B": res.Dependencies,
|
|
}, res.PropertyDependencies)
|
|
case urnD:
|
|
assert.Equal(t, []resource.URN{urnA, urnB, urnC}, res.Dependencies)
|
|
assert.EqualValues(t, propertyDependencies{
|
|
"A": []resource.URN{urnB},
|
|
"B": []resource.URN{urnA, urnC},
|
|
}, res.PropertyDependencies)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExplicitDeleteBeforeReplace(t *testing.T) {
|
|
p := &TestPlan{}
|
|
|
|
loaders := []*deploytest.ProviderLoader{
|
|
deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
|
|
return &deploytest.Provider{
|
|
DiffF: func(urn resource.URN, id resource.ID,
|
|
olds, news resource.PropertyMap) (plugin.DiffResult, error) {
|
|
|
|
if !olds["A"].DeepEquals(news["A"]) {
|
|
return plugin.DiffResult{ReplaceKeys: []resource.PropertyKey{"A"}}, nil
|
|
}
|
|
return plugin.DiffResult{}, nil
|
|
},
|
|
}, nil
|
|
}),
|
|
}
|
|
|
|
const resType = "pkgA:index:typ"
|
|
|
|
inputsA := resource.NewPropertyMapFromMap(map[string]interface{}{"A": "foo"})
|
|
dbrA := false
|
|
inputsB := resource.NewPropertyMapFromMap(map[string]interface{}{"A": "foo"})
|
|
|
|
var provURN, urnA, urnB resource.URN
|
|
var provID resource.ID
|
|
var err error
|
|
program := deploytest.NewLanguageRuntime(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
|
|
provURN, provID, _, err = monitor.RegisterResource(
|
|
providers.MakeProviderType("pkgA"), "provA", true, "", false, nil, "", nil, nil, false)
|
|
assert.NoError(t, err)
|
|
|
|
if provID == "" {
|
|
provID = providers.UnknownID
|
|
}
|
|
provRef, err := providers.NewReference(provURN, provID)
|
|
assert.NoError(t, err)
|
|
provA := provRef.String()
|
|
|
|
urnA, _, _, err = monitor.RegisterResource(resType, "resA", true, "", false, nil, provA, inputsA, nil, dbrA)
|
|
assert.NoError(t, err)
|
|
|
|
inputDepsB := map[resource.PropertyKey][]resource.URN{"A": {urnA}}
|
|
urnB, _, _, err = monitor.RegisterResource(resType, "resB", true, "", false, []resource.URN{urnA}, provA,
|
|
inputsB, inputDepsB, false)
|
|
assert.NoError(t, err)
|
|
|
|
return nil
|
|
})
|
|
|
|
p.Options.host = deploytest.NewPluginHost(nil, nil, program, loaders...)
|
|
p.Steps = []TestStep{{Op: Update}}
|
|
snap := p.Run(t, nil)
|
|
|
|
// Change the value of resA.A. Only resA should be replaced, and the replacement should be create-before-delete.
|
|
inputsA["A"] = resource.NewStringProperty("bar")
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.NoError(t, err)
|
|
|
|
AssertSameSteps(t, []StepSummary{
|
|
{Op: deploy.OpSame, URN: provURN},
|
|
{Op: deploy.OpCreateReplacement, URN: urnA},
|
|
{Op: deploy.OpReplace, URN: urnA},
|
|
{Op: deploy.OpSame, URN: urnB},
|
|
{Op: deploy.OpDeleteReplaced, URN: urnA},
|
|
}, j.SuccessfulSteps())
|
|
|
|
return err
|
|
},
|
|
}}
|
|
snap = p.Run(t, snap)
|
|
|
|
// Change the registration of resA such that it requires delete-before-replace and change the value of resA.A. Both
|
|
// resA and resB should be replaced, and the replacements should be delete-before-replace.
|
|
dbrA, inputsA["A"] = true, resource.NewStringProperty("baz")
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.NoError(t, err)
|
|
|
|
AssertSameSteps(t, []StepSummary{
|
|
{Op: deploy.OpSame, URN: provURN},
|
|
{Op: deploy.OpDeleteReplaced, URN: urnB},
|
|
{Op: deploy.OpDeleteReplaced, URN: urnA},
|
|
{Op: deploy.OpReplace, URN: urnA},
|
|
{Op: deploy.OpCreateReplacement, URN: urnA},
|
|
{Op: deploy.OpReplace, URN: urnB},
|
|
{Op: deploy.OpCreateReplacement, URN: urnB},
|
|
}, j.SuccessfulSteps())
|
|
|
|
return err
|
|
},
|
|
}}
|
|
snap = p.Run(t, snap)
|
|
|
|
// Change the value of resB.A. Only resB should be replaced, and the replacement should be create-before-delete.
|
|
inputsB["A"] = resource.NewStringProperty("qux")
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.NoError(t, err)
|
|
|
|
AssertSameSteps(t, []StepSummary{
|
|
{Op: deploy.OpSame, URN: provURN},
|
|
{Op: deploy.OpSame, URN: urnA},
|
|
{Op: deploy.OpCreateReplacement, URN: urnB},
|
|
{Op: deploy.OpReplace, URN: urnB},
|
|
{Op: deploy.OpDeleteReplaced, URN: urnB},
|
|
}, j.SuccessfulSteps())
|
|
|
|
return err
|
|
},
|
|
}}
|
|
snap = p.Run(t, snap)
|
|
|
|
// Change the registration of resA such that it no longer requires delete-before-replace and change the value of
|
|
// resA.A. Only resA should be replaced, and the replacement should be delete-before-replace.
|
|
dbrA, inputsA["A"] = false, resource.NewStringProperty("zam")
|
|
p.Steps = []TestStep{{
|
|
Op: Update,
|
|
|
|
Validate: func(project workspace.Project, target deploy.Target, j *Journal, evts []Event, err error) error {
|
|
assert.NoError(t, err)
|
|
|
|
AssertSameSteps(t, []StepSummary{
|
|
{Op: deploy.OpSame, URN: provURN},
|
|
{Op: deploy.OpCreateReplacement, URN: urnA},
|
|
{Op: deploy.OpReplace, URN: urnA},
|
|
{Op: deploy.OpSame, URN: urnB},
|
|
{Op: deploy.OpDeleteReplaced, URN: urnA},
|
|
}, j.SuccessfulSteps())
|
|
|
|
return err
|
|
},
|
|
}}
|
|
p.Run(t, snap)
|
|
}
|