pulumi/pkg/resource/stack/deployment.go

747 lines
25 KiB
Go

// Copyright 2016-2022, 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 (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"strings"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/pkg/v3/secrets"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype/migrate"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/archive"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/asset"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/slice"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/santhosh-tekuri/jsonschema/v5"
)
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
// computedValue is a magic number we emit for a value of a resource.Property value
// whenever we need to serialize a resource.Computed. (Since the real/actual value
// is not known.) This allows us to persist engine events and resource states that
// indicate a value will changed... but is unknown what it will change to.
computedValuePlaceholder = "04da6b54-80e4-46f7-96ec-b56ff0331ba9"
)
var (
// ErrDeploymentSchemaVersionTooOld is returned from `DeserializeDeployment` if the
// untyped deployment being deserialized is too old to understand.
ErrDeploymentSchemaVersionTooOld = errors.New("this stack's deployment is too old")
// ErrDeploymentSchemaVersionTooNew is returned from `DeserializeDeployment` if the
// untyped deployment being deserialized is too new to understand.
ErrDeploymentSchemaVersionTooNew = errors.New("this stack's deployment version is too new")
)
var (
deploymentSchema *jsonschema.Schema
propertyValueSchema *jsonschema.Schema
)
func init() {
compiler := jsonschema.NewCompiler()
compiler.LoadURL = func(s string) (io.ReadCloser, error) {
var schema string
switch s {
case apitype.DeploymentSchemaID:
schema = apitype.DeploymentSchema()
case apitype.ResourceSchemaID:
schema = apitype.ResourceSchema()
case apitype.PropertyValueSchemaID:
schema = apitype.PropertyValueSchema()
default:
return jsonschema.LoadURL(s)
}
return io.NopCloser(strings.NewReader(schema)), nil
}
deploymentSchema = compiler.MustCompile(apitype.DeploymentSchemaID)
propertyValueSchema = compiler.MustCompile(apitype.PropertyValueSchemaID)
}
// ValidateUntypedDeployment validates a deployment against the Deployment JSON schema.
func ValidateUntypedDeployment(deployment *apitype.UntypedDeployment) error {
bytes, err := json.Marshal(deployment)
if err != nil {
return err
}
var raw interface{}
if err := json.Unmarshal(bytes, &raw); err != nil {
return err
}
return deploymentSchema.Validate(raw)
}
// SerializeDeployment serializes an entire snapshot as a deploy record.
func SerializeDeployment(ctx context.Context, snap *deploy.Snapshot, showSecrets bool) (*apitype.DeploymentV3, error) {
contract.Requiref(snap != nil, "snap", "must not be nil")
// Capture the version information into a manifest.
manifest := snap.Manifest.Serialize()
sm := snap.SecretsManager
var enc config.Encrypter
if sm != nil {
e, err := sm.Encrypter()
if err != nil {
return nil, fmt.Errorf("getting encrypter for deployment: %w", err)
}
enc = e
} else {
enc = config.NewPanicCrypter()
}
// Serialize all vertices and only include a vertex section if non-empty.
resources := slice.Prealloc[apitype.ResourceV3](len(snap.Resources))
for _, res := range snap.Resources {
sres, err := SerializeResource(ctx, res, enc, showSecrets)
if err != nil {
return nil, fmt.Errorf("serializing resources: %w", err)
}
resources = append(resources, sres)
}
operations := slice.Prealloc[apitype.OperationV2](len(snap.PendingOperations))
for _, op := range snap.PendingOperations {
sop, err := SerializeOperation(ctx, op, enc, showSecrets)
if err != nil {
return nil, err
}
operations = append(operations, sop)
}
var secretsProvider *apitype.SecretsProvidersV1
if sm != nil {
secretsProvider = &apitype.SecretsProvidersV1{
Type: sm.Type(),
State: sm.State(),
}
}
metadata := apitype.SnapshotMetadataV1{}
if snap.Metadata.IntegrityErrorMetadata != nil {
metadata.IntegrityErrorMetadata = &apitype.SnapshotIntegrityErrorMetadataV1{
Version: snap.Metadata.IntegrityErrorMetadata.Version,
Command: snap.Metadata.IntegrityErrorMetadata.Command,
Error: snap.Metadata.IntegrityErrorMetadata.Error,
}
}
return &apitype.DeploymentV3{
Manifest: manifest,
Resources: resources,
SecretsProviders: secretsProvider,
PendingOperations: operations,
Metadata: metadata,
}, nil
}
// UnmarshalUntypedDeployment unmarshals a raw untyped deployment into an up to date deployment object.
func UnmarshalUntypedDeployment(
ctx context.Context,
deployment *apitype.UntypedDeployment,
) (*apitype.DeploymentV3, error) {
contract.Requiref(deployment != nil, "deployment", "must not be nil")
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 &v3deployment, nil
}
// 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(
ctx context.Context,
deployment *apitype.UntypedDeployment,
secretsProv secrets.Provider,
) (*deploy.Snapshot, error) {
v3deployment, err := UnmarshalUntypedDeployment(ctx, deployment)
if err != nil {
return nil, err
}
return DeserializeDeploymentV3(ctx, *v3deployment, secretsProv)
}
// DeserializeDeploymentV3 deserializes a typed DeploymentV3 into a `deploy.Snapshot`.
func DeserializeDeploymentV3(
ctx context.Context,
deployment apitype.DeploymentV3,
secretsProv secrets.Provider,
) (*deploy.Snapshot, error) {
// Unpack the versions.
manifest, err := deploy.DeserializeManifest(deployment.Manifest)
if err != nil {
return nil, err
}
var secretsManager secrets.Manager
if deployment.SecretsProviders != nil && deployment.SecretsProviders.Type != "" {
if secretsProv == nil {
return nil, errors.New("deployment uses a SecretsProvider but no SecretsProvider was provided")
}
sm, err := secretsProv.OfType(deployment.SecretsProviders.Type, deployment.SecretsProviders.State)
if err != nil {
return nil, err
}
secretsManager = sm
}
var dec config.Decrypter
var enc config.Encrypter
if secretsManager == nil {
var ciphertexts []string
for _, res := range deployment.Resources {
collectCiphertexts(&ciphertexts, res.Inputs)
collectCiphertexts(&ciphertexts, res.Outputs)
}
if len(ciphertexts) > 0 {
// If there are ciphertexts, but we couldn't set up a secrets manager, error out early
// to avoid panic'ing later on. This snapshot is broken and needs to be repaired
// manually.
return nil, errors.New("snapshot contains encrypted secrets but no secrets manager could be found")
}
dec = config.NewPanicCrypter()
enc = config.NewPanicCrypter()
} else {
d, err := secretsManager.Decrypter()
if err != nil {
return nil, err
}
// Do a first pass through state and collect all of the secrets that need decrypting.
// We will collect all secrets and decrypt them all at once, rather than just-in-time.
// We do this to avoid serial calls to the decryption endpoint which can result in long
// wait times in stacks with a large number of secrets.
var ciphertexts []string
for _, res := range deployment.Resources {
collectCiphertexts(&ciphertexts, res.Inputs)
collectCiphertexts(&ciphertexts, res.Outputs)
}
// Decrypt the collected secrets and create a decrypter that will use the result as a cache.
cache, err := d.BulkDecrypt(ctx, ciphertexts)
if err != nil {
return nil, err
}
dec = newMapDecrypter(d, cache)
e, err := secretsManager.Encrypter()
if err != nil {
return nil, err
}
enc = e
}
// For every serialized resource vertex, create a ResourceDeployment out of it.
resources := slice.Prealloc[*resource.State](len(deployment.Resources))
for _, res := range deployment.Resources {
desres, err := DeserializeResource(res, dec, enc)
if err != nil {
return nil, err
}
resources = append(resources, desres)
}
ops := slice.Prealloc[resource.Operation](len(deployment.PendingOperations))
for _, op := range deployment.PendingOperations {
desop, err := DeserializeOperation(op, dec, enc)
if err != nil {
return nil, err
}
ops = append(ops, desop)
}
metadata := deploy.SnapshotMetadata{}
if deployment.Metadata.IntegrityErrorMetadata != nil {
metadata.IntegrityErrorMetadata = &deploy.SnapshotIntegrityErrorMetadata{
Version: deployment.Metadata.IntegrityErrorMetadata.Version,
Command: deployment.Metadata.IntegrityErrorMetadata.Command,
Error: deployment.Metadata.IntegrityErrorMetadata.Error,
}
}
return deploy.NewSnapshot(*manifest, secretsManager, resources, ops, metadata), nil
}
// SerializeResource turns a resource into a structure suitable for serialization.
func SerializeResource(
ctx context.Context, res *resource.State, enc config.Encrypter, showSecrets bool,
) (apitype.ResourceV3, error) {
contract.Requiref(res != nil, "res", "must not be nil")
contract.Requiref(res.URN != "", "res", "must have a URN")
res.Lock.Lock()
defer res.Lock.Unlock()
// Serialize all input and output properties recursively, and add them if non-empty.
var inputs map[string]interface{}
if inp := res.Inputs; inp != nil {
sinp, err := SerializeProperties(ctx, inp, enc, showSecrets)
if err != nil {
return apitype.ResourceV3{}, err
}
inputs = sinp
}
var outputs map[string]interface{}
if outp := res.Outputs; outp != nil {
soutp, err := SerializeProperties(ctx, outp, enc, showSecrets)
if err != nil {
return apitype.ResourceV3{}, err
}
outputs = soutp
}
v3Resource := 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,
AdditionalSecretOutputs: res.AdditionalSecretOutputs,
Aliases: res.Aliases,
ImportID: res.ImportID,
RetainOnDelete: res.RetainOnDelete,
DeletedWith: res.DeletedWith,
Created: res.Created,
Modified: res.Modified,
SourcePosition: res.SourcePosition,
IgnoreChanges: res.IgnoreChanges,
}
if res.CustomTimeouts.IsNotEmpty() {
v3Resource.CustomTimeouts = &res.CustomTimeouts
}
return v3Resource, nil
}
// SerializeOperation serializes a resource in a pending state.
func SerializeOperation(
ctx context.Context, op resource.Operation, enc config.Encrypter, showSecrets bool,
) (apitype.OperationV2, error) {
res, err := SerializeResource(ctx, op.Resource, enc, showSecrets)
if err != nil {
return apitype.OperationV2{}, fmt.Errorf("serializing resource: %w", err)
}
return apitype.OperationV2{
Resource: res,
Type: apitype.OperationType(op.Type),
}, nil
}
// SerializeProperties serializes a resource property bag so that it's suitable for serialization.
func SerializeProperties(ctx context.Context, props resource.PropertyMap, enc config.Encrypter,
showSecrets bool,
) (map[string]interface{}, error) {
dst := make(map[string]interface{})
for _, k := range props.StableKeys() {
v, err := SerializePropertyValue(ctx, props[k], enc, showSecrets)
if err != nil {
return nil, err
}
dst[string(k)] = v
}
return dst, nil
}
// SerializePropertyValue serializes a resource property value so that it's suitable for serialization.
func SerializePropertyValue(ctx context.Context, prop resource.PropertyValue, enc config.Encrypter,
showSecrets bool,
) (interface{}, error) {
// Serialize nulls as nil.
if prop.IsNull() {
return nil, nil
}
// A computed value marks something that will be determined at a later time. (e.g. the result of
// a computation that we don't perform during a preview operation.) We serialize a magic constant
// to record its existence.
if prop.IsComputed() || prop.IsOutput() {
return computedValuePlaceholder, nil
}
// For arrays, make sure to recurse.
if prop.IsArray() {
srcarr := prop.ArrayValue()
dstarr := make([]interface{}, len(srcarr))
for i, elem := range prop.ArrayValue() {
selem, err := SerializePropertyValue(ctx, elem, enc, showSecrets)
if err != nil {
return nil, err
}
dstarr[i] = selem
}
return dstarr, nil
}
// Also for objects, recurse and use naked properties.
if prop.IsObject() {
return SerializeProperties(ctx, prop.ObjectValue(), enc, showSecrets)
}
// For assets, we need to serialize them a little carefully, so we can recover them afterwards.
if prop.IsAsset() {
return prop.AssetValue().Serialize(), nil
} else if prop.IsArchive() {
return prop.ArchiveValue().Serialize(), nil
}
// We serialize resource references using a map-based representation similar to assets, archives, and secrets.
if prop.IsResourceReference() {
ref := prop.ResourceReferenceValue()
serialized := map[string]interface{}{
resource.SigKey: resource.ResourceReferenceSig,
"urn": string(ref.URN),
"packageVersion": ref.PackageVersion,
}
if id, hasID := ref.IDString(); hasID {
serialized["id"] = id
}
return serialized, nil
}
if prop.IsSecret() {
// Since we are going to encrypt property value, we can elide encrypting sub-elements. We'll mark them as
// "secret" so we retain that information when deserializing the overall structure, but there is no
// need to double encrypt everything.
value, err := SerializePropertyValue(ctx, prop.SecretValue().Element, config.NopEncrypter, showSecrets)
if err != nil {
return nil, err
}
bytes, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encoding serialized property value: %w", err)
}
plaintext := string(bytes)
secret := apitype.SecretV1{
Sig: resource.SecretSig,
}
if showSecrets {
secret.Plaintext = plaintext
} else {
// If the encrypter is a cachingCrypter, call through its encryptSecret method, which will look for a matching
// *resource.Secret + plaintext in its cache in order to avoid re-encrypting the value.
var ciphertext string
if cachingCrypter, ok := enc.(*cachingCrypter); ok {
ciphertext, err = cachingCrypter.encryptSecret(ctx, prop.SecretValue(), plaintext)
} else {
ciphertext, err = enc.EncryptValue(ctx, plaintext)
}
if err != nil {
return nil, fmt.Errorf("failed to encrypt secret value: %w", err)
}
contract.AssertNoErrorf(err, "marshalling underlying secret value to JSON")
secret.Ciphertext = ciphertext
}
return secret, nil
}
// All others are returned as-is.
return prop.V, nil
}
// collectCiphertexts collects encrypted secrets from resource properties.
func collectCiphertexts(ciphertexts *[]string, prop interface{}) {
switch prop := prop.(type) {
case []interface{}:
for _, v := range prop {
collectCiphertexts(ciphertexts, v)
}
case map[string]interface{}:
if prop[resource.SigKey] == resource.SecretSig {
if ciphertext, cipherOk := prop["ciphertext"].(string); cipherOk {
*ciphertexts = append(*ciphertexts, ciphertext)
}
} else {
for _, v := range prop {
collectCiphertexts(ciphertexts, v)
}
}
}
}
// DeserializeResource turns a serialized resource back into its usual form.
func DeserializeResource(res apitype.ResourceV3, dec config.Decrypter, enc config.Encrypter) (*resource.State, error) {
// Deserialize the resource properties, if they exist.
inputs, err := DeserializeProperties(res.Inputs, dec, enc)
if err != nil {
return nil, err
}
outputs, err := DeserializeProperties(res.Outputs, dec, enc)
if err != nil {
return nil, err
}
if res.URN == "" {
return nil, errors.New("resource missing required 'urn' field")
}
if res.Type == "" {
return nil, fmt.Errorf("resource '%s' missing required 'type' field", res.URN)
}
if !res.Custom && res.ID != "" {
return nil, fmt.Errorf("resource '%s' has 'custom' false but non-empty ID", res.URN)
}
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, res.AdditionalSecretOutputs, res.Aliases, res.CustomTimeouts,
res.ImportID, res.RetainOnDelete, res.DeletedWith, res.Created, res.Modified, res.SourcePosition, res.IgnoreChanges,
), nil
}
// DeserializeOperation hydrates a pending resource/operation pair.
func DeserializeOperation(op apitype.OperationV2, dec config.Decrypter,
enc config.Encrypter,
) (resource.Operation, error) {
res, err := DeserializeResource(op.Resource, dec, enc)
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{}, dec config.Decrypter,
enc config.Encrypter,
) (resource.PropertyMap, error) {
result := make(resource.PropertyMap)
for k, prop := range props {
desprop, err := DeserializePropertyValue(prop, dec, enc)
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{}, dec config.Decrypter,
enc config.Encrypter,
) (resource.PropertyValue, error) {
ctx := context.TODO()
if v != nil {
switch w := v.(type) {
case bool:
return resource.NewBoolProperty(w), nil
case float64:
return resource.NewNumberProperty(w), nil
case string:
if w == computedValuePlaceholder {
return resource.MakeComputed(resource.NewStringProperty("")), nil
}
return resource.NewStringProperty(w), nil
case []interface{}:
arr := make([]resource.PropertyValue, len(w))
for i, elem := range w {
ev, err := DeserializePropertyValue(elem, dec, enc)
if err != nil {
return resource.PropertyValue{}, err
}
arr[i] = ev
}
return resource.NewArrayProperty(arr), nil
case map[string]interface{}:
obj, err := DeserializeProperties(w, dec, enc)
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 asset.AssetSig:
asset, isasset, err := asset.Deserialize(objmap)
if err != nil {
return resource.PropertyValue{}, err
}
contract.Assertf(isasset, "resource with asset signature is not an asset")
return resource.NewAssetProperty(asset), nil
case archive.ArchiveSig:
archive, isarchive, err := archive.Deserialize(objmap)
if err != nil {
return resource.PropertyValue{}, err
}
contract.Assertf(isarchive, "resource with archive signature is not an archive")
return resource.NewArchiveProperty(archive), nil
case resource.SecretSig:
ciphertext, cipherOk := objmap["ciphertext"].(string)
plaintext, plainOk := objmap["plaintext"].(string)
if (!cipherOk && !plainOk) || (plainOk && cipherOk) {
return resource.PropertyValue{}, errors.New(
"malformed secret value: one of `ciphertext` or `plaintext` must be supplied")
}
if plainOk {
encryptedText, err := enc.EncryptValue(ctx, plaintext)
if err != nil {
return resource.PropertyValue{}, fmt.Errorf("encrypting secret value: %w", err)
}
ciphertext = encryptedText
} else {
unencryptedText, err := dec.DecryptValue(ctx, ciphertext)
if err != nil {
return resource.PropertyValue{}, fmt.Errorf("error decrypting secret value: %w", err)
}
plaintext = unencryptedText
}
var elem interface{}
if err := json.Unmarshal([]byte(plaintext), &elem); err != nil {
return resource.PropertyValue{}, err
}
ev, err := DeserializePropertyValue(elem, config.NopDecrypter, enc)
if err != nil {
return resource.PropertyValue{}, err
}
prop := resource.MakeSecret(ev)
// If the decrypter is a cachingCrypter, insert the plain- and ciphertext into the cache with the
// new *resource.Secret as the key.
if cachingCrypter, ok := dec.(*cachingCrypter); ok {
cachingCrypter.insert(prop.SecretValue(), plaintext, ciphertext)
}
return prop, nil
case resource.ResourceReferenceSig:
var packageVersion string
if packageVersionV, ok := objmap["packageVersion"]; ok {
packageVersion, ok = packageVersionV.(string)
if !ok {
return resource.PropertyValue{},
errors.New("malformed resource reference: packageVersion must be a string")
}
}
urnStr, ok := objmap["urn"].(string)
if !ok {
return resource.PropertyValue{}, errors.New("malformed resource reference: missing urn")
}
urn := resource.URN(urnStr)
// deserializeID handles two cases, one of which arose from a bug in a refactoring of resource.ResourceReference.
// This bug caused the raw ID PropertyValue to be serialized as a map[string]interface{}. In the normal case, the
// ID is serialized as a string.
deserializeID := func() (string, bool, error) {
idV, ok := objmap["id"]
if !ok {
return "", false, nil
}
switch idV := idV.(type) {
case string:
return idV, true, nil
case map[string]interface{}:
switch v := idV["V"].(type) {
case nil:
// This happens for component resource references, which do not have an associated ID.
return "", false, nil
case string:
// This happens for custom resource references, which do have an associated ID.
return v, true, nil
case map[string]interface{}:
// This happens for custom resource references with an unknown ID. In this case, the ID should be
// deserialized as the empty string.
return "", true, nil
}
}
return "", false, errors.New("malformed resource reference: id must be a string")
}
id, hasID, err := deserializeID()
if err != nil {
return resource.PropertyValue{}, err
}
if hasID {
return resource.MakeCustomResourceReference(urn, resource.ID(id), packageVersion), nil
}
return resource.MakeComponentResourceReference(urn, packageVersion), nil
default:
return resource.PropertyValue{}, fmt.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 %T: %v", v, reflect.ValueOf(v))
}
}
return resource.NewNullProperty(), nil
}