From 88558b271b48ea649d5fa499bbd664d021327169 Mon Sep 17 00:00:00 2001 From: Zaid Ajaj Date: Sun, 30 Oct 2022 23:42:39 +0100 Subject: [PATCH] typing made optional in hierarchical config and relaxed stack config validation --- ...onal-typing-and-extended-short-syntax.yaml | 4 + pkg/cmd/pulumi/import.go | 3 +- pkg/cmd/pulumi/logs.go | 3 +- pkg/cmd/pulumi/preview.go | 3 +- pkg/cmd/pulumi/refresh.go | 3 +- pkg/cmd/pulumi/up.go | 6 +- pkg/cmd/pulumi/watch.go | 3 +- sdk/go/common/workspace/config.go | 156 +++++----- sdk/go/common/workspace/project.go | 140 +++++++-- sdk/go/common/workspace/project.json | 21 +- sdk/go/common/workspace/project_test.go | 274 +++++++++++++----- 11 files changed, 418 insertions(+), 198 deletions(-) create mode 100644 changelog/pending/20221029--cli-config--optional-typing-and-extended-short-syntax.yaml diff --git a/changelog/pending/20221029--cli-config--optional-typing-and-extended-short-syntax.yaml b/changelog/pending/20221029--cli-config--optional-typing-and-extended-short-syntax.yaml new file mode 100644 index 000000000000..c7d4cce839e8 --- /dev/null +++ b/changelog/pending/20221029--cli-config--optional-typing-and-extended-short-syntax.yaml @@ -0,0 +1,4 @@ +changes: +- type: feat + scope: cli/config + description: Typing made optional, extended short-hand values to arrays and correctly pass stack name to config validator diff --git a/pkg/cmd/pulumi/import.go b/pkg/cmd/pulumi/import.go index 5dea64c51398..52c029b69e7a 100644 --- a/pkg/cmd/pulumi/import.go +++ b/pkg/cmd/pulumi/import.go @@ -503,7 +503,8 @@ func newImportCmd() *cobra.Command { return result.FromError(fmt.Errorf("getting stack decrypter: %w", err)) } - configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stack, proj, cfg.Config, decrypter) + stackName := s.Ref().Name().String() + configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stackName, proj, cfg.Config, decrypter) if configErr != nil { return result.FromError(fmt.Errorf("validating stack config: %w", configErr)) } diff --git a/pkg/cmd/pulumi/logs.go b/pkg/cmd/pulumi/logs.go index e2aead4a174f..bedb863624ad 100644 --- a/pkg/cmd/pulumi/logs.go +++ b/pkg/cmd/pulumi/logs.go @@ -84,7 +84,8 @@ func newLogsCmd() *cobra.Command { return fmt.Errorf("getting stack decrypter: %w", err) } - configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stack, proj, cfg.Config, decrypter) + stackName := s.Ref().Name().String() + configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stackName, proj, cfg.Config, decrypter) if configErr != nil { return fmt.Errorf("validating stack config: %w", configErr) } diff --git a/pkg/cmd/pulumi/preview.go b/pkg/cmd/pulumi/preview.go index e9f141722c18..f6424241be99 100644 --- a/pkg/cmd/pulumi/preview.go +++ b/pkg/cmd/pulumi/preview.go @@ -185,7 +185,8 @@ func newPreviewCmd() *cobra.Command { return result.FromError(fmt.Errorf("getting stack decrypter: %w", err)) } - configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stack, proj, cfg.Config, decrypter) + stackName := s.Ref().Name().String() + configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stackName, proj, cfg.Config, decrypter) if configErr != nil { return result.FromError(fmt.Errorf("validating stack config: %w", configErr)) } diff --git a/pkg/cmd/pulumi/refresh.go b/pkg/cmd/pulumi/refresh.go index 25ead2fe2cb4..bc8151c1143a 100644 --- a/pkg/cmd/pulumi/refresh.go +++ b/pkg/cmd/pulumi/refresh.go @@ -188,7 +188,8 @@ func newRefreshCmd() *cobra.Command { return result.FromError(fmt.Errorf("getting stack decrypter: %w", err)) } - configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stack, proj, cfg.Config, decrypter) + stackName := s.Ref().Name().String() + configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stackName, proj, cfg.Config, decrypter) if configErr != nil { return result.FromError(fmt.Errorf("validating stack config: %w", configErr)) } diff --git a/pkg/cmd/pulumi/up.go b/pkg/cmd/pulumi/up.go index 7e109e3c89fa..c87a0e5da925 100644 --- a/pkg/cmd/pulumi/up.go +++ b/pkg/cmd/pulumi/up.go @@ -119,7 +119,8 @@ func newUpCmd() *cobra.Command { return result.FromError(fmt.Errorf("getting stack decrypter: %w", err)) } - configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stack, proj, cfg.Config, decrypter) + stackName := s.Ref().Name().String() + configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stackName, proj, cfg.Config, decrypter) if configErr != nil { return result.FromError(fmt.Errorf("validating stack config: %w", configErr)) } @@ -342,7 +343,8 @@ func newUpCmd() *cobra.Command { return result.FromError(fmt.Errorf("getting stack decrypter: %w", err)) } - configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stack, proj, cfg.Config, decrypter) + stackName := s.Ref().String() + configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stackName, proj, cfg.Config, decrypter) if configErr != nil { return result.FromError(fmt.Errorf("validating stack config: %w", configErr)) } diff --git a/pkg/cmd/pulumi/watch.go b/pkg/cmd/pulumi/watch.go index eeb1df36ace8..aeb81340e7f7 100644 --- a/pkg/cmd/pulumi/watch.go +++ b/pkg/cmd/pulumi/watch.go @@ -122,7 +122,8 @@ func newWatchCmd() *cobra.Command { return result.FromError(fmt.Errorf("getting stack decrypter: %w", err)) } - configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stack, proj, cfg.Config, decrypter) + stackName := s.Ref().Name().String() + configErr := workspace.ValidateStackConfigAndApplyProjectConfig(stackName, proj, cfg.Config, decrypter) if configErr != nil { return result.FromError(fmt.Errorf("validating stack config: %w", configErr)) } diff --git a/sdk/go/common/workspace/config.go b/sdk/go/common/workspace/config.go index e51b289f1617..7549691af3b8 100644 --- a/sdk/go/common/workspace/config.go +++ b/sdk/go/common/workspace/config.go @@ -45,25 +45,6 @@ func missingStackConfigurationKeysError(missingKeys []string, stackName string) formatMissingKeys(missingKeys)) } -func missingProjectConfigurationKeysError(missingProjectKeys []string, stackName string) error { - valueOrValues := "value" - if len(missingProjectKeys) > 1 { - valueOrValues = "values" - } - - isOrAre := "is" - if len(missingProjectKeys) > 1 { - isOrAre = "are" - } - - return fmt.Errorf( - "Stack '%v' uses configuration %v %v which %v not defined by the project configuration", - stackName, - valueOrValues, - formatMissingKeys(missingProjectKeys), - isOrAre) -} - type StackName = string type ProjectConfigKey = string type StackConfigValidator = func(StackName, ProjectConfigKey, ProjectConfigType, config.Value, config.Decrypter) error @@ -97,8 +78,8 @@ func DefaultStackConfigValidator( } } - if !ValidateConfigValue(projectConfigType.Type, projectConfigType.Items, content) { - typeName := InferFullTypeName(projectConfigType.Type, projectConfigType.Items) + if !ValidateConfigValue(*projectConfigType.Type, projectConfigType.Items, content) { + typeName := InferFullTypeName(*projectConfigType.Type, projectConfigType.Items) validationError := fmt.Errorf( "Stack '%v' with configuration key '%v' must be of type '%v'", stackName, @@ -111,6 +92,34 @@ func DefaultStackConfigValidator( return nil } +// The validator which does not validate anything +// used when we only want to merge the project config onto the stack config +func NoopStackConfigValidator( + stackName string, + projectConfigKey string, + projectConfigType ProjectConfigType, + stackValue config.Value, + dec config.Decrypter) error { + return nil +} + +func createConfigValue(rawValue interface{}) (config.Value, error) { + if isPrimitiveValue(rawValue) { + configValueContent := fmt.Sprintf("%v", rawValue) + return config.NewValue(configValueContent), nil + } + value, err := SimplifyMarshalledValue(rawValue) + if err != nil { + return config.Value{}, err + } + configValueJSON, jsonError := json.Marshal(value) + if jsonError != nil { + return config.Value{}, jsonError + } + return config.NewObjectValue(string(configValueJSON)), nil + +} + func ValidateStackConfigAndMergeProjectConfig( stackName string, project *Project, @@ -118,29 +127,9 @@ func ValidateStackConfigAndMergeProjectConfig( lazyDecrypter func() config.Decrypter, validate StackConfigValidator) error { - if len(project.Config) > 0 { - // only when the project defines config values, do we need to validate the stack config - // for each stack config key, check if it is in the project config - stackConfigKeysNotDefinedByProject := []string{} - for key := range stackConfig { - namespacedKey := fmt.Sprintf("%s:%s", key.Namespace(), key.Name()) - if key.Namespace() == string(project.Name) { - // then the namespace is implied and can be omitted - namespacedKey = key.Name() - } - - if _, ok := project.Config[namespacedKey]; !ok { - stackConfigKeysNotDefinedByProject = append(stackConfigKeysNotDefinedByProject, namespacedKey) - } - } - - if len(stackConfigKeysNotDefinedByProject) > 0 { - return missingProjectConfigurationKeysError(stackConfigKeysNotDefinedByProject, stackName) - } - } - var decrypter config.Decrypter missingConfigurationKeys := make([]string, 0) + projectName := project.Name.String() for projectConfigKey, projectConfigType := range project.Config { var key config.Key if strings.Contains(projectConfigKey, ":") { @@ -153,59 +142,61 @@ func ValidateStackConfigAndMergeProjectConfig( key = parsedKey } else { // key is not namespaced - // use the project as namespace - key = config.MustMakeKey(string(project.Name), projectConfigKey) + // use the project as default namespace + key = config.MustMakeKey(projectName, projectConfigKey) } - stackValue, found, err := stackConfig.Get(key, true) + stackValue, foundOnStack, err := stackConfig.Get(key, true) if err != nil { return fmt.Errorf("Error while getting stack config value for key '%v': %v", key.String(), err) } hasDefault := projectConfigType.Default != nil - if !found && !hasDefault { - // add it to the list to collect all missing configuration keys, + hasValue := projectConfigType.Value != nil + + if !foundOnStack && !hasValue && !hasDefault && key.Namespace() == projectName { + // add it to the list of missing project configuration keys in the stack + // which are required by the project // then return them as a single error missingConfigurationKeys = append(missingConfigurationKeys, projectConfigKey) - } else if !found && hasDefault { - // not found at the stack level - // but has a default value at the project level - // assign the value to the stack - var configValue config.Value - - if projectConfigType.Type == "array" { - // for array types, JSON-ify the default value - configValueJSON, jsonError := json.Marshal(projectConfigType.Default) - if jsonError != nil { - return jsonError - } - configValue = config.NewObjectValue(string(configValueJSON)) + continue + } - } else { - // for primitive types - // pass the values as is - configValueContent := fmt.Sprintf("%v", projectConfigType.Default) - configValue = config.NewValue(configValueContent) + if !foundOnStack && (hasValue || hasDefault) { + // either value or default value is provided + var value interface{} + if hasValue { + value = projectConfigType.Value + } + if hasDefault { + value = projectConfigType.Default + } + // it is not found on the stack we are currently validating / merging values with + // then we assign the value to that stack whatever that value is + configValue, err := createConfigValue(value) + if err != nil { + return err } - setError := stackConfig.Set(key, configValue, true) if setError != nil { return setError } - } else { - // Validate stack level value against the config defined at the project level - if validate != nil { - // we have a validator - if decrypter == nil && lazyDecrypter != nil { - // initialize the decrypter once - decrypter = lazyDecrypter() - } - if decrypter != nil { - validationError := validate(stackName, projectConfigKey, projectConfigType, stackValue, decrypter) - if validationError != nil { - return validationError - } + continue + } + + // Validate stack level value against the config defined at the project level + if projectConfigType.IsExplicitlyTyped() { + // we have a validator + if decrypter == nil { + // initialize the decrypter once + decrypter = lazyDecrypter() + } + + if decrypter != nil { + validationError := validate(stackName, projectConfigKey, projectConfigType, stackValue, decrypter) + if validationError != nil { + return validationError } } } @@ -238,5 +229,10 @@ func ValidateStackConfigAndApplyProjectConfig( // This is because sometimes during pulumi config ls and pulumi config get, if users are // using PassphraseDecrypter, we don't want to always prompt for the values when not necessary func ApplyProjectConfig(stackName string, project *Project, stackConfig config.Map) error { - return ValidateStackConfigAndMergeProjectConfig(stackName, project, stackConfig, nil, nil) + emptyDecrypter := func() config.Decrypter { + return nil + } + + return ValidateStackConfigAndMergeProjectConfig(stackName, project, stackConfig, + emptyDecrypter, NoopStackConfigValidator) } diff --git a/sdk/go/common/workspace/project.go b/sdk/go/common/workspace/project.go index 94dc06682641..5581fb607963 100644 --- a/sdk/go/common/workspace/project.go +++ b/sdk/go/common/workspace/project.go @@ -34,6 +34,13 @@ import ( "github.com/santhosh-tekuri/jsonschema/v5" ) +const ( + arrayTypeName = "array" + integerTypeName = "integer" + stringTypeName = "string" + booleanTypeName = "boolean" +) + //go:embed project.json var projectSchema string @@ -104,13 +111,29 @@ type ProjectConfigItemsType struct { } type ProjectConfigType struct { - Type string `json:"type,omitempty" yaml:"type,omitempty"` + Type *string `json:"type,omitempty" yaml:"type,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Items *ProjectConfigItemsType `json:"items,omitempty" yaml:"items,omitempty"` Default interface{} `json:"default,omitempty" yaml:"default,omitempty"` + Value interface{} `json:"value,omitempty" yaml:"value,omitempty"` Secret bool `json:"secret,omitempty" yaml:"secret,omitempty"` } +// IsExplicitlyTyped returns whether the project config type is explicitly typed. +// When that is the case, we validate stack config values against this type, given that +// the stack config value is namespaced by the project. +func (configType *ProjectConfigType) IsExplicitlyTyped() bool { + return configType.Type != nil +} + +func (configType *ProjectConfigType) TypeName() string { + if configType.Type != nil { + return *configType.Type + } + + return "" +} + // Project is a Pulumi project manifest. // // We explicitly add yaml tags (instead of using the default behavior from https://github.com/ghodss/yaml which works @@ -157,19 +180,20 @@ type Project struct { AdditionalKeys map[string]interface{} `yaml:",inline"` } -func isPrimitiveValue(value interface{}) (string, bool) { +func isPrimitiveValue(value interface{}) bool { switch value.(type) { - case string: - return "string", true - case int: - return "integer", true - case bool: - return "boolean", true + case string, int, bool: + return true default: - return "", false + return false } } +func isArray(value interface{}) bool { + _, ok := value.([]interface{}) + return ok +} + // RewriteConfigPathIntoStackConfigDir checks if the project is using the old "config" property // to declare a path to the stack configuration directory. If that is the case, we rewrite it // such that the value in config: {value} is moved to stackConfigDir: {value}. @@ -199,17 +223,22 @@ func RewriteConfigPathIntoStackConfigDir(project map[string]interface{}) (map[st // for example the following config block definition: // // config: -// instanceSize: t3.mirco +// instanceSize: t3.mirco +// aws:region: us-west-2 // // will be rewritten into a typed value: // // config: -// instanceSize: -// type: string -// default: t3.mirco +// instanceSize: +// default: t3.micro +// aws:region: +// value: us-west-2 +// +// Note that short-hand values without namespaces (project config) are turned into a type +// where as short-hand values with namespaces (such as aws:region) are turned into a value. func RewriteShorthandConfigValues(project map[string]interface{}) map[string]interface{} { configMap, foundConfig := project["config"] - + projectName := project["name"].(string) if !foundConfig { // no config defined, return as is return project @@ -222,12 +251,19 @@ func RewriteShorthandConfigValues(project map[string]interface{}) map[string]int } for key, value := range config { - typeName, isLiteral := isPrimitiveValue(value) - if isLiteral { + + if isPrimitiveValue(value) || isArray(value) { configTypeDefinition := make(map[string]interface{}) - configTypeDefinition["type"] = typeName - configTypeDefinition["default"] = value + if configKeyIsNamespacedByProject(projectName, key) { + // then this is a project namespaced config _type_ with a default value + configTypeDefinition["default"] = value + } else { + // then this is a non-project namespaced config _value_ + configTypeDefinition["value"] = value + } + config[key] = configTypeDefinition + continue } } @@ -235,7 +271,7 @@ func RewriteShorthandConfigValues(project map[string]interface{}) map[string]int } // Cast any map[interface{}] from the yaml decoder to map[string] -func SimplifyMarshalledProject(raw interface{}) (map[string]interface{}, error) { +func SimplifyMarshalledValue(raw interface{}) (interface{}, error) { var cast func(value interface{}) (interface{}, error) cast = func(value interface{}) (interface{}, error) { if objMap, ok := value.(map[interface{}]interface{}); ok { @@ -265,7 +301,12 @@ func SimplifyMarshalledProject(raw interface{}) (map[string]interface{}, error) } return value, nil } - result, err := cast(raw) + + return cast(raw) +} + +func SimplifyMarshalledProject(raw interface{}) (map[string]interface{}, error) { + result, err := SimplifyMarshalledValue(raw) if err != nil { return nil, err } @@ -340,12 +381,12 @@ func InferFullTypeName(typeName string, itemsType *ProjectConfigItemsType) strin // also to validate config values coming from individual stacks. func ValidateConfigValue(typeName string, itemsType *ProjectConfigItemsType, value interface{}) bool { - if typeName == "string" { + if typeName == stringTypeName { _, ok := value.(string) return ok } - if typeName == "integer" { + if typeName == integerTypeName { _, ok := value.(int) if ok { return true @@ -366,7 +407,7 @@ func ValidateConfigValue(typeName string, itemsType *ProjectConfigItemsType, val return false } - if typeName == "boolean" { + if typeName == booleanTypeName { // check to see if the value is a literal string "true" | "false" literalValue, ok := value.(string) if ok && (literalValue == "true" || literalValue == "false") { @@ -395,6 +436,10 @@ func ValidateConfigValue(typeName string, itemsType *ProjectConfigItemsType, val return true } +func configKeyIsNamespacedByProject(projectName string, configKey string) bool { + return !strings.Contains(configKey, ":") || strings.HasPrefix(configKey, projectName+":") +} + func (proj *Project) Validate() error { if proj.Name == "" { return errors.New("project is missing a 'name' attribute") @@ -403,14 +448,49 @@ func (proj *Project) Validate() error { return errors.New("project is missing a 'runtime' attribute") } + projectName := proj.Name.String() for configKey, configType := range proj.Config { - if configType.Default != nil { - // when the default value is specified, validate it against the type - if !ValidateConfigValue(configType.Type, configType.Items, configType.Default) { - inferredTypeName := InferFullTypeName(configType.Type, configType.Items) - return errors.Errorf("The default value specified for configuration key '%v' is not of the expected type '%v'", - configKey, - inferredTypeName) + if configType.Default != nil && configType.Value != nil { + return errors.Errorf("project config '%v' cannot have both a 'default' and 'value' attribute", configKey) + } + + configTypeName := configType.TypeName() + + if configKeyIsNamespacedByProject(projectName, configKey) { + // namespaced by project + if configType.IsExplicitlyTyped() && configType.TypeName() == arrayTypeName && configType.Items == nil { + return errors.Errorf("The configuration key '%v' declares an array "+ + "but does not specify the underlying type via the 'items' attribute", configKey) + } + + // when we have a config _type_ with a schema + if configType.IsExplicitlyTyped() && configType.Default != nil { + if !ValidateConfigValue(configTypeName, configType.Items, configType.Default) { + inferredTypeName := InferFullTypeName(configTypeName, configType.Items) + return errors.Errorf("The default value specified for configuration key '%v' is not of the expected type '%v'", + configKey, + inferredTypeName) + } + } + + } else { + // when not namespaced by project, there shouldn't be a type, only a value + if configType.IsExplicitlyTyped() { + return errors.Errorf("Configuration key '%v' is not namespaced by the project and should not define a type", + configKey) + } + + // default values are part of a type schema + // when not namespaced by project, there is no type schema, only a value + if configType.Default != nil { + return errors.Errorf("Configuration key '%v' is not namespaced by the project and "+ + "should not define a default value. "+ + "Did you mean to use the 'value' attribute instead of 'default'?", configKey) + } + + // when not namespaced by project, there should be a value + if configType.Value == nil { + return errors.Errorf("Configuration key '%v' is namespaced and must provide an attribute 'value'", configKey) } } } diff --git a/sdk/go/common/workspace/project.json b/sdk/go/common/workspace/project.json index f1b1d137e0b3..368c73417da1 100644 --- a/sdk/go/common/workspace/project.json +++ b/sdk/go/common/workspace/project.json @@ -96,6 +96,9 @@ { "type":"boolean" }, + { + "type": "array" + }, { "$ref":"#/$defs/configTypeDeclaration" } @@ -296,21 +299,6 @@ "title":"ConfigTypeDeclaration", "type":"object", "additionalProperties":false, - "required":[ - "type" - ], - "if":{ - "properties":{ - "type":{ - "const":"array" - } - } - }, - "then":{ - "required":[ - "items" - ] - }, "properties":{ "type":{ "$ref":"#/$defs/simpleConfigType" @@ -324,7 +312,8 @@ "secret":{ "type":"boolean" }, - "default":{ } + "default":{ }, + "value": { } } } } diff --git a/sdk/go/common/workspace/project_test.go b/sdk/go/common/workspace/project_test.go index 00d685b2d5be..c147522e1666 100644 --- a/sdk/go/common/workspace/project_test.go +++ b/sdk/go/common/workspace/project_test.go @@ -70,8 +70,9 @@ func TestProjectValidationFailsForIncorrectDefaultValueType(t *testing.T) { t.Parallel() project := Project{Name: "test", Runtime: NewProjectRuntimeInfo("dotnet", nil)} invalidConfig := make(map[string]ProjectConfigType) + integerType := "integer" invalidConfig["instanceSize"] = ProjectConfigType{ - Type: "integer", + Type: &integerType, Items: nil, Default: "hello", } @@ -87,9 +88,10 @@ func TestProjectValidationFailsForIncorrectDefaultValueType(t *testing.T) { // default value here has type array // config type specified is array> // should fail! + arrayType := "array" invalidConfigWithArray := make(map[string]ProjectConfigType) invalidConfigWithArray["values"] = ProjectConfigType{ - Type: "array", + Type: &arrayType, Items: &ProjectConfigItemsType{ Type: "array", Items: &ProjectConfigItemsType{ @@ -109,9 +111,10 @@ func TestProjectValidationFailsForIncorrectDefaultValueType(t *testing.T) { func TestProjectValidationSucceedsForCorrectDefaultValueType(t *testing.T) { t.Parallel() project := Project{Name: "test", Runtime: NewProjectRuntimeInfo("dotnet", nil)} + integerType := "integer" validConfig := make(map[string]ProjectConfigType) validConfig["instanceSize"] = ProjectConfigType{ - Type: "integer", + Type: &integerType, Items: nil, Default: 1, } @@ -130,9 +133,10 @@ func TestProjectValidationSucceedsForCorrectDefaultValueType(t *testing.T) { // default value here has type array> // config type specified is also array> // should succeed + arrayType := "array" validConfigWithArray := make(map[string]ProjectConfigType) validConfigWithArray["values"] = ProjectConfigType{ - Type: "array", + Type: &arrayType, Items: &ProjectConfigItemsType{ Type: "array", Items: &ProjectConfigItemsType{ @@ -277,7 +281,7 @@ config: // full integer config schema integerSchemFull, ok := project.Config["integerSchemaFull"] assert.True(t, ok, "should be able to read integerSchemaFull") - assert.Equal(t, "integer", integerSchemFull.Type) + assert.Equal(t, "integer", integerSchemFull.TypeName()) assert.Equal(t, "a very important value", integerSchemFull.Description) assert.Equal(t, 1, integerSchemFull.Default) assert.False(t, integerSchemFull.Secret) @@ -285,38 +289,41 @@ config: integerSchemaSimple, ok := project.Config["integerSchemaSimple"] assert.True(t, ok, "should be able to read integerSchemaSimple") - assert.Equal(t, "integer", integerSchemaSimple.Type, "integer type is inferred correctly") + assert.Equal(t, "", integerSchemaSimple.TypeName(), "not explicitly typed") + assert.False(t, integerSchemaSimple.IsExplicitlyTyped()) assert.False(t, integerSchemaSimple.Secret) assert.Equal(t, 20, integerSchemaSimple.Default, "Default integer value is parsed correctly") textSchemaFull, ok := project.Config["textSchemaFull"] assert.True(t, ok, "should be able to read textSchemaFull") - assert.Equal(t, "string", textSchemaFull.Type) + assert.Equal(t, "string", textSchemaFull.TypeName()) assert.False(t, textSchemaFull.Secret) assert.Equal(t, "t3.micro", textSchemaFull.Default) assert.Equal(t, "", textSchemaFull.Description) textSchemaSimple, ok := project.Config["textSchemaSimple"] assert.True(t, ok, "should be able to read textSchemaSimple") - assert.Equal(t, "string", textSchemaSimple.Type) + assert.Equal(t, "", textSchemaSimple.TypeName(), "not explicitly typed") + assert.False(t, textSchemaSimple.IsExplicitlyTyped()) assert.False(t, textSchemaSimple.Secret) assert.Equal(t, "t4.large", textSchemaSimple.Default) booleanSchemaFull, ok := project.Config["booleanSchemaFull"] assert.True(t, ok, "should be able to read booleanSchemaFull") - assert.Equal(t, "boolean", booleanSchemaFull.Type) + assert.Equal(t, "boolean", booleanSchemaFull.TypeName()) assert.False(t, booleanSchemaFull.Secret) assert.Equal(t, true, booleanSchemaFull.Default) booleanSchemaSimple, ok := project.Config["booleanSchemaSimple"] assert.True(t, ok, "should be able to read booleanSchemaSimple") - assert.Equal(t, "boolean", booleanSchemaSimple.Type) + assert.Equal(t, "", booleanSchemaSimple.TypeName(), "not explicitly typed") + assert.False(t, booleanSchemaSimple.IsExplicitlyTyped()) assert.False(t, booleanSchemaSimple.Secret) assert.Equal(t, false, booleanSchemaSimple.Default) simpleArrayOfStrings, ok := project.Config["simpleArrayOfStrings"] assert.True(t, ok, "should be able to read simpleArrayOfStrings") - assert.Equal(t, "array", simpleArrayOfStrings.Type) + assert.Equal(t, "array", simpleArrayOfStrings.TypeName()) assert.False(t, simpleArrayOfStrings.Secret) assert.NotNil(t, simpleArrayOfStrings.Items) assert.Equal(t, "string", simpleArrayOfStrings.Items.Type) @@ -325,7 +332,7 @@ config: arrayOfArrays, ok := project.Config["arrayOfArrays"] assert.True(t, ok, "should be able to read arrayOfArrays") - assert.Equal(t, "array", arrayOfArrays.Type) + assert.Equal(t, "array", arrayOfArrays.TypeName()) assert.False(t, arrayOfArrays.Secret) assert.NotNil(t, arrayOfArrays.Items) assert.Equal(t, "array", arrayOfArrays.Items.Type) @@ -334,7 +341,7 @@ config: secretString, ok := project.Config["secretString"] assert.True(t, ok, "should be able to read secretString") - assert.Equal(t, "string", secretString.Type) + assert.Equal(t, "string", secretString.TypeName()) assert.Equal(t, "", secretString.Description) assert.Equal(t, nil, secretString.Default) assert.True(t, secretString.Secret) @@ -351,6 +358,19 @@ func getConfigValue(t *testing.T, stackConfig config.Map, key string) string { return value } +func getConfigValueUnmarshalled(t *testing.T, stackConfig config.Map, key string) interface{} { + parsedKey, err := config.ParseKey(key) + assert.NoErrorf(t, err, "There should be no error parsing the config key '%v'", key) + configValue, foundValue := stackConfig[parsedKey] + assert.Truef(t, foundValue, "Couldn't find a value for config key %v", key) + valueJSON, valueError := configValue.Value(config.NopDecrypter) + assert.NoErrorf(t, valueError, "Error while getting the value for key %v", key) + var value interface{} + err = json.Unmarshal([]byte(valueJSON), &value) + assert.NoErrorf(t, err, "Error while unmarshalling value for key %v", key) + return value +} + func TestStackConfigIsInheritedFromProjectConfig(t *testing.T) { t.Parallel() projectYaml := ` @@ -387,6 +407,7 @@ name: test runtime: dotnet config: aws:region: us-west-1 + pulumi:disable-default-providers: ["*"] instanceSize: t3.micro` projectStackYaml := ` @@ -399,12 +420,13 @@ config: assert.NoError(t, stackError, "Should be able to read the stack") configError := ValidateStackConfigAndApplyProjectConfig("dev", project, stack.Config, config.NewPanicCrypter()) assert.NoError(t, configError, "Config override should be valid") - - assert.Equal(t, 2, len(stack.Config), "Stack config now has three values") + assert.Equal(t, 3, len(stack.Config), "Stack config now has three values") // value of instanceSize is overwritten from the stack assert.Equal(t, "t4.large", getConfigValue(t, stack.Config, "test:instanceSize")) // aws:region is namespaced and is inherited from the project assert.Equal(t, "us-west-1", getConfigValue(t, stack.Config, "aws:region")) + assert.Equal(t, "[\"*\"]", getConfigValue(t, stack.Config, "pulumi:disable-default-providers")) + assert.Equal(t, []interface{}{"*"}, getConfigValueUnmarshalled(t, stack.Config, "pulumi:disable-default-providers")) } func TestLoadingStackConfigWithoutNamespacingTheProject(t *testing.T) { @@ -434,6 +456,178 @@ config: assert.Equal(t, "us-west-1", getConfigValue(t, stack.Config, "aws:region")) } +func TestUntypedProjectConfigValuesAreNotValidated(t *testing.T) { + t.Parallel() + projectYaml := ` +name: test +runtime: dotnet +config: + instanceSize: t3.micro + aws:region: us-west-1` + + projectStackYaml := ` +config: + instanceSize: 9999 + aws:region: 42` + + project, projectError := loadProjectFromText(t, projectYaml) + assert.NoError(t, projectError, "Shold be able to load the project") + stack, stackError := loadProjectStackFromText(t, project, projectStackYaml) + assert.NoError(t, stackError, "Should be able to read the stack") + configError := ValidateStackConfigAndApplyProjectConfig("dev", project, stack.Config, config.NewPanicCrypter()) + assert.NoError(t, configError, "Config override should be valid") + assert.Equal(t, 2, len(stack.Config), "Stack config now has three values") + // value of instanceSize is overwritten from the stack + assert.Equal(t, "9999", getConfigValue(t, stack.Config, "test:instanceSize")) + assert.Equal(t, "42", getConfigValue(t, stack.Config, "aws:region")) +} + +func TestUntypedProjectConfigValuesWithOnlyDefaultOrOnlyValue(t *testing.T) { + t.Parallel() + projectYaml := ` +name: test +runtime: dotnet +config: + instanceSize: + default: t3.micro + region: + value: us-west-1` + + projectStackYaml := ` +config: + aws:answer: 42` + + project, projectError := loadProjectFromText(t, projectYaml) + assert.NoError(t, projectError, "Shold be able to load the project") + stack, stackError := loadProjectStackFromText(t, project, projectStackYaml) + assert.NoError(t, stackError, "Should be able to read the stack") + configError := ValidateStackConfigAndApplyProjectConfig("dev", project, stack.Config, config.NewPanicCrypter()) + assert.NoError(t, configError, "Config override should be valid") + assert.Equal(t, 3, len(stack.Config), "Stack config now has three values") + // value of instanceSize is overwritten from the stack + assert.Equal(t, "t3.micro", getConfigValue(t, stack.Config, "test:instanceSize")) + assert.Equal(t, "us-west-1", getConfigValue(t, stack.Config, "test:region")) + assert.Equal(t, "42", getConfigValue(t, stack.Config, "aws:answer")) +} + +func TestUntypedStackConfigValuesDoNeedProjectDeclaration(t *testing.T) { + t.Parallel() + projectYaml := ` +name: test +runtime: dotnet +config: + createVpc: true` + + projectStackYaml := ` +config: + instanceSize: 42` + + project, projectError := loadProjectFromText(t, projectYaml) + assert.NoError(t, projectError, "Shold be able to load the project") + stack, stackError := loadProjectStackFromText(t, project, projectStackYaml) + assert.NoError(t, stackError, "Should be able to read the stack") + configError := ValidateStackConfigAndApplyProjectConfig("dev", project, stack.Config, config.NewPanicCrypter()) + assert.NoError(t, configError, "Config override should be valid") + assert.Equal(t, 2, len(stack.Config), "Stack config now has three values") + // value of instanceSize is overwritten from the stack + assert.Equal(t, "42", getConfigValue(t, stack.Config, "test:instanceSize")) + assert.Equal(t, "true", getConfigValue(t, stack.Config, "test:createVpc")) +} + +func TestNamespacedProjectConfigShouldNotBeExplicitlyTyped(t *testing.T) { + t.Parallel() + projectYaml := ` +name: test +runtime: dotnet +config: + aws:region: + type: string + value: + region: us-west-1` + + _, projectError := loadProjectFromText(t, projectYaml) + assert.Contains(t, projectError.Error(), + "Configuration key 'aws:region' is not namespaced by the project and should not define a type") +} + +func TestProjectConfigCannotHaveBothValueAndDefault(t *testing.T) { + t.Parallel() + projectYaml := ` +name: test +runtime: dotnet +config: + instanceSize: + type: string + default: t3.micro + value: t4.large` + + _, projectError := loadProjectFromText(t, projectYaml) + assert.Contains(t, projectError.Error(), + "project config 'instanceSize' cannot have both a 'default' and 'value' attribute") +} + +func TestProjectConfigCannotBeTypedArrayWithoutItems(t *testing.T) { + t.Parallel() + projectYaml := ` +name: test +runtime: dotnet +config: + instanceSize: + type: array + default: [t3.micro, t4.large]` + + _, projectError := loadProjectFromText(t, projectYaml) + assert.Contains(t, projectError.Error(), + "The configuration key 'instanceSize' declares an array "+ + "but does not specify the underlying type via the 'items' attribute") +} + +func TestNamespacedProjectConfigShouldNotBeProvideDefault(t *testing.T) { + t.Parallel() + projectYaml := ` +name: test +runtime: dotnet +config: + aws:region: + default: us-west-1` + + _, projectError := loadProjectFromText(t, projectYaml) + assert.Contains(t, projectError.Error(), + "Configuration key 'aws:region' is not namespaced by the project and should not define a default value") + assert.Contains(t, projectError.Error(), + "Did you mean to use the 'value' attribute instead of 'default'?") +} + +func TestUntypedProjectConfigObjectValuesPassedDownToStack(t *testing.T) { + t.Parallel() + projectYaml := ` +name: test +runtime: dotnet +config: + instanceSize: + value: + hello: world + aws:config: + value: + region: us-west-1` + + projectStackYaml := ` +config: + aws:whatever: 42` + + project, projectError := loadProjectFromText(t, projectYaml) + assert.NoError(t, projectError, "Shold be able to load the project") + stack, stackError := loadProjectStackFromText(t, project, projectStackYaml) + assert.NoError(t, stackError, "Should be able to read the stack") + configError := ValidateStackConfigAndApplyProjectConfig("dev", project, stack.Config, config.NewPanicCrypter()) + assert.NoError(t, configError, "Config override should be valid") + assert.Equal(t, 3, len(stack.Config), "Stack config now has three values") + // value of instanceSize is overwritten from the stack + assert.Equal(t, "{\"hello\":\"world\"}", getConfigValue(t, stack.Config, "test:instanceSize")) + assert.Equal(t, "{\"region\":\"us-west-1\"}", getConfigValue(t, stack.Config, "aws:config")) + assert.Equal(t, "42", getConfigValue(t, stack.Config, "aws:whatever")) +} + func TestStackConfigErrorsWhenStackValueIsNotCorrectlyTyped(t *testing.T) { t.Parallel() projectYaml := ` @@ -613,56 +807,6 @@ config: assert.Contains(t, configError.Error(), "Stack 'dev' is missing configuration values 'hello', 'values' and 'world'") } -func TestStackConfigErrorsWhenUsingConfigValuesNotDefinedByProject(t *testing.T) { - t.Parallel() - projectYaml := ` -name: test -runtime: dotnet -config: - hello: - type: integer` - - projectStackYaml := ` -config: - hello: 21 - world: 42` - - project, projectError := loadProjectFromText(t, projectYaml) - assert.NoError(t, projectError, "Shold be able to load the project") - stack, stackError := loadProjectStackFromText(t, project, projectStackYaml) - assert.NoError(t, stackError, "Should be able to read the stack") - configError := ValidateStackConfigAndApplyProjectConfig("dev", project, stack.Config, config.NewPanicCrypter()) - assert.NotNil(t, configError, "there should be a config type error") - expectedErrorMsg := "Stack 'dev' uses configuration value 'world' which is not defined by the project configuration" - assert.Contains(t, configError.Error(), expectedErrorMsg) -} - -func TestStackConfigErrorsWhenUsingMultipleConfigValuesNotDefinedByProject(t *testing.T) { - t.Parallel() - projectYaml := ` -name: test -runtime: dotnet -config: - hello: - type: integer` - - projectStackYaml := ` -config: - hello: 21 - world: 42 - another: 42` - - project, projectError := loadProjectFromText(t, projectYaml) - assert.NoError(t, projectError, "Shold be able to load the project") - stack, stackError := loadProjectStackFromText(t, project, projectStackYaml) - assert.NoError(t, stackError, "Should be able to read the stack") - configError := ValidateStackConfigAndApplyProjectConfig("dev", project, stack.Config, config.NewPanicCrypter()) - assert.NotNil(t, configError, "there should be a config type error") - expectedErrorMsg := "Stack 'dev' uses configuration values 'another' and 'world'" + - " which are not defined by the project configuration" - assert.Contains(t, configError.Error(), expectedErrorMsg) -} - func TestStackConfigDoesNotErrorWhenProjectHasNotDefinedConfig(t *testing.T) { t.Parallel() projectYaml := `