pulumi/pkg/resource/stack/deployment.go
Pat Gavlin 1ecdc83a33 Implement more precise delete-before-replace semantics. (#2369)
This implements the new algorithm for deciding which resources must be
deleted due to a delete-before-replace operation.

We need to compute the set of resources that may be replaced by a
change to the resource under consideration. We do this by taking the
complete set of transitive dependents on the resource under
consideration and removing any resources that would not be replaced by
changes to their dependencies. We determine whether or not a resource
may be replaced by substituting unknowns for input properties that may
change due to deletion of the resources their value depends on and
calling the resource provider's Diff method.

This is perhaps clearer when described by example. Consider the
following dependency graph:

  A
__|__
B   C
|  _|_
D  E F

In this graph, all of B, C, D, E, and F transitively depend on A. It may
be the case, however, that changes to the specific properties of any of
those resources R that would occur if a resource on the path to A were
deleted and recreated may not cause R to be replaced. For example, the
edge from B to A may be a simple dependsOn edge such that a change to
B does not actually influence any of B's input properties. In that case,
neither B nor D would need to be deleted before A could be deleted.

In order to make the above algorithm a reality, the resource monitor
interface has been updated to include a map that associates an input
property key with the list of resources that input property depends on.
Older clients of the resource monitor will leave this map empty, in
which case all input properties will be treated as depending on all
dependencies of the resource. This is probably overly conservative, but
it is less conservative than what we currently implement, and is
certainly correct.
2019-01-28 09:46:30 -08:00

362 lines
12 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 stack
import (
"encoding/json"
"fmt"
"reflect"
"github.com/blang/semver"
"github.com/pkg/errors"
"github.com/pulumi/pulumi/pkg/apitype"
"github.com/pulumi/pulumi/pkg/apitype/migrate"
"github.com/pulumi/pulumi/pkg/resource"
"github.com/pulumi/pulumi/pkg/resource/deploy"
"github.com/pulumi/pulumi/pkg/util/contract"
"github.com/pulumi/pulumi/pkg/workspace"
)
const (
// DeploymentSchemaVersionOldestSupported is the oldest deployment schema that we
// still support, i.e. we can produce a `deploy.Snapshot` from. This will generally
// need to be at least one less than the current schema version so that old deployments can
// be migrated to the current schema.
DeploymentSchemaVersionOldestSupported = 1
)
var (
// ErrDeploymentSchemaVersionTooOld is returned from `DeserializeDeployment` if the
// untyped deployment being deserialized is too old to understand.
ErrDeploymentSchemaVersionTooOld = fmt.Errorf("this stack's deployment is too old")
// ErrDeploymentSchemaVersionTooNew is returned from `DeserializeDeployment` if the
// untyped deployment being deserialized is too new to understand.
ErrDeploymentSchemaVersionTooNew = fmt.Errorf("this stack's deployment version is too new")
)
// SerializeDeployment serializes an entire snapshot as a deploy record.
func SerializeDeployment(snap *deploy.Snapshot) *apitype.DeploymentV3 {
contract.Require(snap != nil, "snap")
// Capture the version information into a manifest.
manifest := apitype.ManifestV1{
Time: snap.Manifest.Time,
Magic: snap.Manifest.Magic,
Version: snap.Manifest.Version,
}
for _, plug := range snap.Manifest.Plugins {
var version string
if plug.Version != nil {
version = plug.Version.String()
}
manifest.Plugins = append(manifest.Plugins, apitype.PluginInfoV1{
Name: plug.Name,
Path: plug.Path,
Type: plug.Kind,
Version: version,
})
}
// Serialize all vertices and only include a vertex section if non-empty.
var resources []apitype.ResourceV3
for _, res := range snap.Resources {
resources = append(resources, SerializeResource(res))
}
var operations []apitype.OperationV2
for _, op := range snap.PendingOperations {
operations = append(operations, SerializeOperation(op))
}
return &apitype.DeploymentV3{
Manifest: manifest,
Resources: resources,
PendingOperations: operations,
}
}
// DeserializeUntypedDeployment deserializes an untyped deployment and produces a `deploy.Snapshot`
// from it. DeserializeDeployment will return an error if the untyped deployment's version is
// not within the range `DeploymentSchemaVersionCurrent` and `DeploymentSchemaVersionOldestSupported`.
func DeserializeUntypedDeployment(deployment *apitype.UntypedDeployment) (*deploy.Snapshot, error) {
contract.Require(deployment != nil, "deployment")
switch {
case deployment.Version > apitype.DeploymentSchemaVersionCurrent:
return nil, ErrDeploymentSchemaVersionTooNew
case deployment.Version < DeploymentSchemaVersionOldestSupported:
return nil, ErrDeploymentSchemaVersionTooOld
}
var v3deployment apitype.DeploymentV3
switch deployment.Version {
case 1:
var v1deployment apitype.DeploymentV1
if err := json.Unmarshal([]byte(deployment.Deployment), &v1deployment); err != nil {
return nil, err
}
v2deployment := migrate.UpToDeploymentV2(v1deployment)
v3deployment = migrate.UpToDeploymentV3(v2deployment)
case 2:
var v2deployment apitype.DeploymentV2
if err := json.Unmarshal([]byte(deployment.Deployment), &v2deployment); err != nil {
return nil, err
}
v3deployment = migrate.UpToDeploymentV3(v2deployment)
case 3:
if err := json.Unmarshal([]byte(deployment.Deployment), &v3deployment); err != nil {
return nil, err
}
default:
contract.Failf("unrecognized version: %d", deployment.Version)
}
return DeserializeDeploymentV3(v3deployment)
}
// DeserializeDeploymentV3 deserializes a typed DeploymentV3 into a `deploy.Snapshot`.
func DeserializeDeploymentV3(deployment apitype.DeploymentV3) (*deploy.Snapshot, error) {
// Unpack the versions.
manifest := deploy.Manifest{
Time: deployment.Manifest.Time,
Magic: deployment.Manifest.Magic,
Version: deployment.Manifest.Version,
}
for _, plug := range deployment.Manifest.Plugins {
var version *semver.Version
if v := plug.Version; v != "" {
sv, err := semver.ParseTolerant(v)
if err != nil {
return nil, err
}
version = &sv
}
manifest.Plugins = append(manifest.Plugins, workspace.PluginInfo{
Name: plug.Name,
Kind: plug.Type,
Version: version,
})
}
// For every serialized resource vertex, create a ResourceDeployment out of it.
var resources []*resource.State
for _, res := range deployment.Resources {
desres, err := DeserializeResource(res)
if err != nil {
return nil, err
}
resources = append(resources, desres)
}
var ops []resource.Operation
for _, op := range deployment.PendingOperations {
desop, err := DeserializeOperation(op)
if err != nil {
return nil, err
}
ops = append(ops, desop)
}
return deploy.NewSnapshot(manifest, resources, ops), nil
}
// SerializeResource turns a resource into a structure suitable for serialization.
func SerializeResource(res *resource.State) apitype.ResourceV3 {
contract.Assert(res != nil)
contract.Assertf(string(res.URN) != "", "Unexpected empty resource resource.URN")
// Serialize all input and output properties recursively, and add them if non-empty.
var inputs map[string]interface{}
if inp := res.Inputs; inp != nil {
inputs = SerializeProperties(inp)
}
var outputs map[string]interface{}
if outp := res.Outputs; outp != nil {
outputs = SerializeProperties(outp)
}
return apitype.ResourceV3{
URN: res.URN,
Custom: res.Custom,
Delete: res.Delete,
ID: res.ID,
Type: res.Type,
Parent: res.Parent,
Inputs: inputs,
Outputs: outputs,
Protect: res.Protect,
External: res.External,
Dependencies: res.Dependencies,
InitErrors: res.InitErrors,
Provider: res.Provider,
PropertyDependencies: res.PropertyDependencies,
PendingReplacement: res.PendingReplacement,
}
}
func SerializeOperation(op resource.Operation) apitype.OperationV2 {
res := SerializeResource(op.Resource)
return apitype.OperationV2{
Resource: res,
Type: apitype.OperationType(op.Type),
}
}
// SerializeProperties serializes a resource property bag so that it's suitable for serialization.
func SerializeProperties(props resource.PropertyMap) map[string]interface{} {
dst := make(map[string]interface{})
for _, k := range props.StableKeys() {
if v := SerializePropertyValue(props[k]); v != nil {
dst[string(k)] = v
}
}
return dst
}
// SerializePropertyValue serializes a resource property value so that it's suitable for serialization.
func SerializePropertyValue(prop resource.PropertyValue) interface{} {
// Skip nulls and "outputs"; the former needn't be serialized, and the latter happens if there is an output
// that hasn't materialized (either because we're serializing inputs or the provider didn't give us the value).
if prop.IsComputed() || !prop.HasValue() {
return nil
}
// For arrays, make sure to recurse.
if prop.IsArray() {
srcarr := prop.ArrayValue()
dstarr := make([]interface{}, len(srcarr))
for i, elem := range prop.ArrayValue() {
dstarr[i] = SerializePropertyValue(elem)
}
return dstarr
}
// Also for objects, recurse and use naked properties.
if prop.IsObject() {
return SerializeProperties(prop.ObjectValue())
}
// For assets, we need to serialize them a little carefully, so we can recover them afterwards.
if prop.IsAsset() {
return prop.AssetValue().Serialize()
} else if prop.IsArchive() {
return prop.ArchiveValue().Serialize()
}
// All others are returned as-is.
return prop.V
}
// DeserializeResource turns a serialized resource back into its usual form.
func DeserializeResource(res apitype.ResourceV3) (*resource.State, error) {
// Deserialize the resource properties, if they exist.
inputs, err := DeserializeProperties(res.Inputs)
if err != nil {
return nil, err
}
outputs, err := DeserializeProperties(res.Outputs)
if err != nil {
return nil, err
}
return resource.NewState(
res.Type, res.URN, res.Custom, res.Delete, res.ID,
inputs, outputs, res.Parent, res.Protect, res.External, res.Dependencies, res.InitErrors, res.Provider,
res.PropertyDependencies, res.PendingReplacement), nil
}
func DeserializeOperation(op apitype.OperationV2) (resource.Operation, error) {
res, err := DeserializeResource(op.Resource)
if err != nil {
return resource.Operation{}, err
}
return resource.NewOperation(res, resource.OperationType(op.Type)), nil
}
// DeserializeProperties deserializes an entire map of deploy properties into a resource property map.
func DeserializeProperties(props map[string]interface{}) (resource.PropertyMap, error) {
result := make(resource.PropertyMap)
for k, prop := range props {
desprop, err := DeserializePropertyValue(prop)
if err != nil {
return nil, err
}
result[resource.PropertyKey(k)] = desprop
}
return result, nil
}
// DeserializePropertyValue deserializes a single deploy property into a resource property value.
func DeserializePropertyValue(v interface{}) (resource.PropertyValue, error) {
if v != nil {
switch w := v.(type) {
case bool:
return resource.NewBoolProperty(w), nil
case float64:
return resource.NewNumberProperty(w), nil
case string:
return resource.NewStringProperty(w), nil
case []interface{}:
var arr []resource.PropertyValue
for _, elem := range w {
ev, err := DeserializePropertyValue(elem)
if err != nil {
return resource.PropertyValue{}, err
}
arr = append(arr, ev)
}
return resource.NewArrayProperty(arr), nil
case map[string]interface{}:
obj, err := DeserializeProperties(w)
if err != nil {
return resource.PropertyValue{}, err
}
// This could be an asset or archive; if so, recover its type.
objmap := obj.Mappable()
if sig, hasSig := objmap[resource.SigKey]; hasSig {
switch sig {
case resource.AssetSig:
asset, isasset, err := resource.DeserializeAsset(objmap)
if err != nil {
return resource.PropertyValue{}, err
}
contract.Assert(isasset)
return resource.NewAssetProperty(asset), nil
case resource.ArchiveSig:
archive, isarchive, err := resource.DeserializeArchive(objmap)
if err != nil {
return resource.PropertyValue{}, err
}
contract.Assert(isarchive)
return resource.NewArchiveProperty(archive), nil
case resource.SecretSig:
return resource.PropertyValue{},
errors.New("this version of the Pulumi SDK does not support first-class secrets")
default:
return resource.PropertyValue{}, errors.Errorf("unrecognized signature '%v' in property map", sig)
}
}
// Otherwise, it's just a weakly typed object map.
return resource.NewObjectProperty(obj), nil
default:
contract.Failf("Unrecognized property type: %v", reflect.ValueOf(v))
}
}
return resource.NewNullProperty(), nil
}