This test had been intermittently failing due to a race condition. Its implementation of `plugin.Provider.Read` was intended to ensure that the cancellation of a refresh operation occurred. As written, it was only able to ensure that the cancellation was requested. These changes ensure that cancellation has been acknowledged by the engine by implementing providing an implementation for `plugin.Provider.Cancel` that closes a channel on which the implementation of `Read` waits.
2394 lines
71 KiB
Go
2394 lines
71 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
|
|
}
|
|
|
|
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.
|
|
if e.Kind == 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:
|
|
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))
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if e.Kind != JournalEntryOutputs {
|
|
switch e.Step.Op() {
|
|
case deploy.OpCreate, deploy.OpCreateReplacement, deploy.OpRead, deploy.OpReadReplacement, deploy.OpUpdate:
|
|
doneOps[e.Step.New()] = true
|
|
case deploy.OpDelete, deploy.OpDeleteReplaced:
|
|
doneOps[e.Step.Old()] = true
|
|
}
|
|
}
|
|
|
|
if e.Kind != JournalEntrySuccess {
|
|
continue
|
|
}
|
|
|
|
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())
|
|
case deploy.OpDelete, deploy.OpDeleteReplaced:
|
|
dones[e.Step.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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 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 {
|
|
assert.Equal(t, deploy.OpDelete, entry.Step.Op())
|
|
}
|
|
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{})
|
|
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{})
|
|
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{})
|
|
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{})
|
|
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{})
|
|
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)
|
|
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{})
|
|
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)
|
|
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{})
|
|
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)
|
|
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{})
|
|
assert.NoError(t, err)
|
|
|
|
resB, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resB", true, "", false, []resource.URN{resA}, "",
|
|
resource.PropertyMap{})
|
|
assert.NoError(t, err)
|
|
|
|
resC, _, _, err := monitor.RegisterResource("pkgA:m:typA", "resC", true, "", false, []resource.URN{resB}, "",
|
|
resource.PropertyMap{})
|
|
assert.NoError(t, err)
|
|
|
|
_, _, _, err = monitor.RegisterResource("pkgA:m:typA", "resD", true, "", false, []resource.URN{resC}, "",
|
|
resource.PropertyMap{})
|
|
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{})
|
|
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)
|
|
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)
|
|
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)
|
|
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{})
|
|
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{})
|
|
assert.NoError(t, noErr)
|
|
|
|
_, _, _, noErr = mon.RegisterResource(
|
|
"singlename", "resC", false /* custom */, "", false, nil, "", resource.PropertyMap{})
|
|
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{})
|
|
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{})
|
|
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",
|
|
}))
|
|
|
|
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",
|
|
}))
|
|
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",
|
|
}))
|
|
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",
|
|
}))
|
|
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
|
|
}
|