pulumi/sdk/go/common/resource/config/object.go

534 lines
13 KiB
Go
Raw Normal View History

// 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 config
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
)
var errSecureReprReserved = errors.New(`maps with the single key "secure" are reserved`)
// object is the internal object representation of a single config value. All operations on Value first decode the
// Value's string representation into its object representation. Secure strings are stored in objects as ciphertext.
type object struct {
value any
secure bool
}
// objectType describes the types of values that may be stored in the value field of an object
type objectType interface {
bool | int64 | float64 | string | []object | map[string]object
}
// newObject creates a new object with the given representation.
func newObject[T objectType](v T) object {
return object{value: v}
}
// newSecureObject creates a new secure object with the given ciphertext.
func newSecureObject(ciphertext string) object {
return object{value: ciphertext, secure: true}
}
// Secure returns true if the receiver is a secure string or a composite value that contains a secure string.
func (c object) Secure() bool {
switch v := c.value.(type) {
case []object:
for _, v := range v {
if v.Secure() {
return true
}
}
return false
case map[string]object:
for _, v := range v {
if v.Secure() {
return true
}
}
return false
case string:
return c.secure
default:
return false
}
}
// Decrypt decrypts any ciphertexts within the object and returns appropriately-shaped Plaintext values.
func (c object) Decrypt(ctx context.Context, decrypter Decrypter) (Plaintext, error) {
return c.decrypt(ctx, nil, decrypter)
}
func (c object) decrypt(ctx context.Context, path resource.PropertyPath, decrypter Decrypter) (Plaintext, error) {
switch v := c.value.(type) {
case bool:
return NewPlaintext(v), nil
case int64:
return NewPlaintext(v), nil
case float64:
return NewPlaintext(v), nil
case string:
if !c.secure {
return NewPlaintext(v), nil
}
plaintext, err := decrypter.DecryptValue(ctx, v)
if err != nil {
return Plaintext{}, fmt.Errorf("%v: %w", path, err)
}
return NewSecurePlaintext(plaintext), nil
case []object:
vs := make([]Plaintext, len(v))
for i, v := range v {
pv, err := v.decrypt(ctx, append(path, i), decrypter)
if err != nil {
return Plaintext{}, err
}
vs[i] = pv
}
return NewPlaintext(vs), nil
case map[string]object:
vs := make(map[string]Plaintext, len(v))
for k, v := range v {
pv, err := v.decrypt(ctx, append(path, k), decrypter)
if err != nil {
return Plaintext{}, err
}
vs[k] = pv
}
return NewPlaintext(vs), nil
case nil:
return Plaintext{}, nil
default:
contract.Failf("unexpected value of type %T", v)
return Plaintext{}, nil
}
}
// Merge merges the receiver onto the given base using JSON merge patch semantics. Merge does not modify the receiver or
// the base.
func (c object) Merge(base object) object {
if co, ok := c.value.(map[string]object); ok {
if bo, ok := base.value.(map[string]object); ok {
mo := make(map[string]object, len(co))
for k, v := range bo {
mo[k] = v
}
for k, v := range co {
mo[k] = v.Merge(mo[k])
}
return newObject(mo)
}
}
return c
}
// Get gets the member value at path. The path to the receiver is prefix.
func (c object) Get(path resource.PropertyPath) (_ object, ok bool, err error) {
if len(path) == 0 {
return c, true, nil
}
switch v := c.value.(type) {
case []object:
index, ok := path[0].(int)
if !ok || index < 0 || index >= len(v) {
return object{}, false, nil
}
elem := v[index]
return elem.Get(path[1:])
case map[string]object:
key, ok := path[0].(string)
if !ok {
return object{}, false, nil
}
elem, ok := v[key]
if !ok {
return object{}, false, nil
}
return elem.Get(path[1:])
default:
return object{}, false, nil
}
}
// Delete deletes the member value at path. The path to the receiver is prefix.
func (c *object) Delete(prefix, path resource.PropertyPath) error {
if len(path) == 0 {
return nil
}
prefix = append(prefix, path[0])
switch v := c.value.(type) {
case []object:
index, ok := path[0].(int)
if !ok || index < 0 || index >= len(v) {
return nil
}
if len(path) == 1 {
c.value = append(v[:index], v[index+1:]...)
return nil
}
elem := &v[index]
return elem.Delete(prefix, path[1:])
case map[string]object:
key, ok := path[0].(string)
if !ok {
return nil
}
// If we're deleting a property from this object, make sure that the result won't be mistaken for a secure
// value when it is encoded. Secure values are encoded as `{"secure": "ciphertext"}`.
if len(path) == 1 {
if len(v) == 2 {
keys := make([]string, 0, 2)
for k := range v {
if k != key {
keys = append(keys, k)
}
}
if len(keys) == 1 && keys[0] == "secure" {
if _, ok := v["secure"].value.(string); ok {
return fmt.Errorf("%v: %w", prefix, errSecureReprReserved)
}
}
}
delete(v, key)
return nil
}
elem, ok := v[key]
if !ok {
return nil
}
err := elem.Delete(prefix, path[1:])
v[key] = elem
return err
default:
return nil
}
}
func newContainer(accessor any) any {
switch accessor := accessor.(type) {
case int:
return make([]object, accessor+1)
case string:
return make(map[string]object)
default:
contract.Failf("unexpected accessor kind %T", accessor)
return nil
}
}
// Set sets the member value at path to new. The path to the receiver is prefix.
func (c *object) Set(prefix, path resource.PropertyPath, new object) error {
if len(path) == 0 {
*c = new
return nil
}
// Check the type of the receiver and create a new container if allowed.
switch c.value.(type) {
case []object, map[string]object:
// OK
case nil:
// This value is nil. Create a new container ny inferring the container type (i.e. array or object) from the
// accessor at the head of the path.
c.value = newContainer(path[0])
default:
// COMPAT: If this is the first level, we create a new container and overwrite the old value rather than issuing
// a type error.
if len(prefix) == 1 {
c.value, c.secure = newContainer(path[0]), false
} else {
switch path[0].(type) {
case int:
return fmt.Errorf("%v: expected an array", prefix)
case string:
return fmt.Errorf("%v: expected a map", prefix)
default:
contract.Failf("unreachable")
return nil
}
}
}
prefix = append(prefix, path[0])
switch v := c.value.(type) {
case []object:
index, ok := path[0].(int)
if !ok {
return fmt.Errorf("%v: key for an array must be an int", prefix)
}
if index < 0 || index > len(v) {
return fmt.Errorf("%v: array index out of range", prefix)
}
if index == len(v) {
v = append(v, object{})
c.value = v
}
elem := &v[index]
return elem.Set(prefix, path[1:], new)
case map[string]object:
key, ok := path[0].(string)
if !ok {
return fmt.Errorf("%v: key for a map must be a string", prefix)
}
// If we're adding a property tothis object, make sure that the result won't be mistaken for a secure
// value when it is encoded. Secure values are encoded as `{"secure": "ciphertext"}`.
if len(path) == 1 && len(v) == 0 && key == "secure" {
if _, ok := new.value.(string); ok {
return errSecureReprReserved
}
}
elem := v[key]
err := elem.Set(prefix, path[1:], new)
v[key] = elem
return err
default:
contract.Failf("unreachable")
return nil
}
}
// SecureValues returns the plaintext values for any secure strings contained in the receiver.
func (c object) SecureValues(dec Decrypter) ([]string, error) {
switch v := c.value.(type) {
case []object:
var values []string
for _, v := range v {
vs, err := v.SecureValues(dec)
if err != nil {
return nil, err
}
values = append(values, vs...)
}
return values, nil
case map[string]object:
var values []string
for _, v := range v {
vs, err := v.SecureValues(dec)
if err != nil {
return nil, err
}
values = append(values, vs...)
}
return values, nil
case string:
if c.secure {
plaintext, err := dec.DecryptValue(context.TODO(), v)
if err != nil {
return nil, err
}
return []string{plaintext}, nil
}
return nil, nil
default:
return nil, nil
}
}
// marshalValue converts the receiver into a Value.
func (c object) marshalValue() (v Value, err error) {
v.value, v.secure, v.object, err = c.MarshalString()
return
}
// marshalObjectValue converts the receiver into a shape that is compatible with Value.ToObject().
func (c object) marshalObjectValue(root bool) any {
switch v := c.value.(type) {
case []object:
vs := make([]any, len(v))
for i, v := range v {
vs[i] = v.marshalObjectValue(false)
}
return vs
case map[string]object:
vs := make(map[string]any, len(v))
for k, v := range v {
vs[k] = v.marshalObjectValue(false)
}
return vs
case string:
if !root && c.secure {
return map[string]any{"secure": c.value}
}
return c.value
default:
return c.value
}
}
// MarshalString returns the receiver's string representation. The string representation is accompanied by bools that
// indicate whether the receiver is secure and whether it is an object.
func (c object) MarshalString() (text string, secure, object bool, err error) {
switch v := c.value.(type) {
case bool, int64, float64:
bytes, err := c.MarshalJSON()
return string(bytes), false, false, err
case string:
return v, c.secure, false, nil
default:
bytes, err := c.MarshalJSON()
if err != nil {
return "", false, false, err
}
return string(bytes), c.Secure(), true, nil
}
}
// UnmarshalString unmarshals the string representation accompanied by secure and object metadata into the receiver.
func (c *object) UnmarshalString(text string, secure, object bool) error {
if !object {
c.value, c.secure = text, secure
return nil
}
return c.UnmarshalJSON([]byte(text))
}
func (c object) MarshalJSON() ([]byte, error) {
return json.Marshal(c.marshalObject())
}
func (c *object) UnmarshalJSON(b []byte) error {
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var v any
err := dec.Decode(&v)
if err != nil {
return err
}
*c, err = unmarshalObject(v)
return err
}
func (c object) MarshalYAML() (any, error) {
return c.marshalObject(), nil
}
func (c *object) UnmarshalYAML(unmarshal func(any) error) error {
var v any
err := unmarshal(&v)
if err != nil {
return err
}
*c, err = unmarshalObject(v)
return err
}
// unmarshalObject unmarshals a raw JSON or YAML value into an object. json.Number values are converted to int64 if
// possible and float64 otherwise.
func unmarshalObject(v any) (object, error) {
switch v := v.(type) {
case bool:
return newObject(v), nil
case json.Number:
if i, err := v.Int64(); err == nil {
return newObject(i), nil
}
f, err := v.Float64()
if err == nil {
return newObject(f), nil
}
return object{}, fmt.Errorf("unrepresentable number %v: %w", v, err)
case int:
return newObject(int64(v)), nil
case int64:
return newObject(v), nil
case float64:
return newObject(v), nil
case string:
return newObject(v), nil
case time.Time:
return newObject(v.String()), nil
case map[string]any:
if ok, ciphertext := isSecureValue(v); ok {
return newSecureObject(ciphertext), nil
}
m := make(map[string]object, len(v))
for k, v := range v {
sv, err := unmarshalObject(v)
if err != nil {
return object{}, err
}
m[k] = sv
}
return newObject(m), nil
case map[any]any:
m := make(map[string]any, len(v))
for k, v := range v {
m[fmt.Sprintf("%v", k)] = v
}
return unmarshalObject(m)
case []any:
a := make([]object, len(v))
for i, v := range v {
sv, err := unmarshalObject(v)
if err != nil {
return object{}, err
}
a[i] = sv
}
return newObject(a), nil
case nil:
return object{}, nil
default:
contract.Failf("unexpected wire type %T", v)
return object{}, nil
}
}
// marshalObject returns the value that should be passed to the JSON or YAML packages when marshaling the receiver.
func (c object) marshalObject() any {
if str, ok := c.value.(string); ok && c.secure {
type secureValue struct {
Secure string `json:"secure" yaml:"secure"`
}
return secureValue{Secure: str}
}
return c.value
}
// isSecureValue returns true if the object is a `map[string]any` of length one with a "secure" property of type string.
func isSecureValue(v any) (bool, string) {
if m, isMap := v.(map[string]any); isMap && len(m) == 1 {
if val, hasSecureKey := m["secure"]; hasSecureKey {
if valString, isString := val.(string); isString {
return true, valString
}
}
}
return false, ""
}
pass through property values to RunRequest (#14273) # Description This is an alternative to https://github.com/pulumi/pulumi/pull/14244. Instead of adding type information to the run request, pass the config through as property values. Property values are properly encoded on the wire, and can be unmarshalled on the other end including type information, so this should be a more future proof way to go forward. Eventually we'll want to parse the config directly into property values, but that can be left for the future, as it's a bigger change. ## Checklist - [x] I have run `make tidy` to update any new dependencies - [x] I have run `make lint` to verify my code passes the lint check - [x] I have formatted my code using `gofumpt` <!--- Please provide details if the checkbox below is to be left unchecked. --> - [ ] I have added tests that prove my fix is effective or that my feature works <!--- User-facing changes require a CHANGELOG entry. --> - [x] I have run `make changelog` and committed the `changelog/pending/<file>` documenting my change <!-- If the change(s) in this PR is a modification of an existing call to the Pulumi Cloud, then the service should honor older versions of the CLI where this change would not exist. You must then bump the API version in /pkg/backend/httpstate/client/api.go, as well as add it to the service. --> - [ ] Yes, there are changes in this PR that warrants bumping the Pulumi Cloud API version <!-- @Pulumi employees: If yes, you must submit corresponding changes in the service repo. -->
2023-10-20 10:44:16 +00:00
func (c object) toDecryptedPropertyValue(ctx context.Context, decrypter Decrypter) (resource.PropertyValue, error) {
plaintext, err := c.Decrypt(ctx, decrypter)
if err != nil {
return resource.PropertyValue{}, err
pass through property values to RunRequest (#14273) # Description This is an alternative to https://github.com/pulumi/pulumi/pull/14244. Instead of adding type information to the run request, pass the config through as property values. Property values are properly encoded on the wire, and can be unmarshalled on the other end including type information, so this should be a more future proof way to go forward. Eventually we'll want to parse the config directly into property values, but that can be left for the future, as it's a bigger change. ## Checklist - [x] I have run `make tidy` to update any new dependencies - [x] I have run `make lint` to verify my code passes the lint check - [x] I have formatted my code using `gofumpt` <!--- Please provide details if the checkbox below is to be left unchecked. --> - [ ] I have added tests that prove my fix is effective or that my feature works <!--- User-facing changes require a CHANGELOG entry. --> - [x] I have run `make changelog` and committed the `changelog/pending/<file>` documenting my change <!-- If the change(s) in this PR is a modification of an existing call to the Pulumi Cloud, then the service should honor older versions of the CLI where this change would not exist. You must then bump the API version in /pkg/backend/httpstate/client/api.go, as well as add it to the service. --> - [ ] Yes, there are changes in this PR that warrants bumping the Pulumi Cloud API version <!-- @Pulumi employees: If yes, you must submit corresponding changes in the service repo. -->
2023-10-20 10:44:16 +00:00
}
return plaintext.PropertyValue(), nil
pass through property values to RunRequest (#14273) # Description This is an alternative to https://github.com/pulumi/pulumi/pull/14244. Instead of adding type information to the run request, pass the config through as property values. Property values are properly encoded on the wire, and can be unmarshalled on the other end including type information, so this should be a more future proof way to go forward. Eventually we'll want to parse the config directly into property values, but that can be left for the future, as it's a bigger change. ## Checklist - [x] I have run `make tidy` to update any new dependencies - [x] I have run `make lint` to verify my code passes the lint check - [x] I have formatted my code using `gofumpt` <!--- Please provide details if the checkbox below is to be left unchecked. --> - [ ] I have added tests that prove my fix is effective or that my feature works <!--- User-facing changes require a CHANGELOG entry. --> - [x] I have run `make changelog` and committed the `changelog/pending/<file>` documenting my change <!-- If the change(s) in this PR is a modification of an existing call to the Pulumi Cloud, then the service should honor older versions of the CLI where this change would not exist. You must then bump the API version in /pkg/backend/httpstate/client/api.go, as well as add it to the service. --> - [ ] Yes, there are changes in this PR that warrants bumping the Pulumi Cloud API version <!-- @Pulumi employees: If yes, you must submit corresponding changes in the service repo. -->
2023-10-20 10:44:16 +00:00
}