package workspace import ( "context" "encoding/json" "fmt" "os" "testing" "github.com/pulumi/esc" "github.com/pulumi/pulumi/sdk/v3/go/common/encoding" "github.com/pulumi/pulumi/sdk/v3/go/common/resource/config" "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) func TestProjectRuntimeInfoRoundtripYAML(t *testing.T) { t.Parallel() doTest := func(marshal func(interface{}) ([]byte, error), unmarshal func([]byte, interface{}) error) { ri := NewProjectRuntimeInfo("nodejs", nil) byts, err := marshal(ri) assert.NoError(t, err) var riRountrip ProjectRuntimeInfo err = unmarshal(byts, &riRountrip) assert.NoError(t, err) assert.Equal(t, "nodejs", riRountrip.Name()) assert.Nil(t, riRountrip.Options()) ri = NewProjectRuntimeInfo("nodejs", map[string]interface{}{ "typescript": true, "stringOption": "hello", }) byts, err = marshal(ri) assert.NoError(t, err) err = unmarshal(byts, &riRountrip) assert.NoError(t, err) assert.Equal(t, "nodejs", riRountrip.Name()) assert.Equal(t, true, riRountrip.Options()["typescript"]) assert.Equal(t, "hello", riRountrip.Options()["stringOption"]) } doTest(yaml.Marshal, yaml.Unmarshal) doTest(json.Marshal, json.Unmarshal) } func TestProjectValidationForNameAndRuntime(t *testing.T) { t.Parallel() var err error // Test lack of name proj := Project{} err = proj.Validate() assert.EqualError(t, err, "project is missing a 'name' attribute") // Test lack of runtime proj.Name = "a project" err = proj.Validate() assert.EqualError(t, err, "project is missing a 'runtime' attribute") // Test success proj.Runtime = NewProjectRuntimeInfo("test", nil) err = proj.Validate() assert.NoError(t, err) } 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: &integerType, Items: nil, Default: "hello", } project.Config = invalidConfig err := project.Validate() assert.ErrorContains(t, err, "The default value specified for configuration key 'instanceSize' is not of the expected type 'integer'") invalidValues := make([]interface{}, 0) invalidValues = append(invalidValues, "hello") // default value here has type array<string> // config type specified is array<array<string>> // should fail! arrayType := "array" invalidConfigWithArray := make(map[string]ProjectConfigType) invalidConfigWithArray["values"] = ProjectConfigType{ Type: &arrayType, Items: &ProjectConfigItemsType{ Type: "array", Items: &ProjectConfigItemsType{ Type: "string", }, }, Default: invalidValues, } project.Config = invalidConfigWithArray err = project.Validate() assert.ErrorContains(t, err, "The default value specified for configuration key 'values' is not of the expected type 'array<array<string>>'") } 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: &integerType, Items: nil, Default: 1, } project.Config = validConfig err := project.Validate() assert.NoError(t, err, "There should be no validation error") // validValues = ["hello"] validValues := make([]interface{}, 0) validValues = append(validValues, "hello") // validValuesArray = [["hello"]] validValuesArray := make([]interface{}, 0) validValuesArray = append(validValuesArray, validValues) // default value here has type array<array<string>> // config type specified is also array<array<string>> // should succeed arrayType := "array" validConfigWithArray := make(map[string]ProjectConfigType) validConfigWithArray["values"] = ProjectConfigType{ Type: &arrayType, Items: &ProjectConfigItemsType{ Type: "array", Items: &ProjectConfigItemsType{ Type: "string", }, }, Default: validValuesArray, } project.Config = validConfigWithArray err = project.Validate() assert.NoError(t, err, "There should be no validation error") } func writeAndLoad(t *testing.T, str string) (*Project, error) { tmp, err := os.CreateTemp("", "*.json") assert.NoError(t, err) path := tmp.Name() err = os.WriteFile(path, []byte(str), 0o600) assert.NoError(t, err) return LoadProject(path) } func TestProjectLoadJSON(t *testing.T) { t.Parallel() // Test wrong type t.Run("wrong type", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, "\"hello \"") // Assert. assert.ErrorContains(t, err, "expected project to be an object, was 'string'") }) t.Run("missing name attribute", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, "{}") // Assert. assert.ErrorContains(t, err, "project is missing a 'name' attribute") }) t.Run("bad name", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, "{\"name\": \"\"}") // Assert. assert.ErrorContains(t, err, "project is missing a non-empty string 'name' attribute") }) t.Run("missing runtime", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, "{\"name\": \"project\"}") // Assert. assert.ErrorContains(t, err, "project is missing a 'runtime' attribute") }) t.Run("multiple errors 1", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, "{\"name\": \"project\", \"runtime\": 4}") // Assert. // The order can vary here, so we use Contains and not Equals. expected := []string{ "3 errors occurred:", "* #/runtime: oneOf failed", "* #/runtime: expected string, but got number", "* #/runtime: expected object, but got number", } for _, e := range expected { assert.ErrorContains(t, err, e) } }) t.Run("multiple errors, 2", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, "{\"name\": \"project\", \"runtime\": \"test\", \"backend\": 4, \"main\": {}}") // Assert. // The order can vary here, so we use Contains and not Equals. expected := []string{ "2 errors occurred:", "* #/main: expected string or null, but got object", "* #/backend: expected object or null, but got number", } for _, e := range expected { assert.ErrorContains(t, err, e) } }) t.Run("success", func(t *testing.T) { t.Parallel() // Act. proj, err := writeAndLoad(t, "{\"name\": \"project\", \"runtime\": \"test\"}") // Assert. assert.NoError(t, err) assert.Equal(t, tokens.PackageName("project"), proj.Name) assert.Equal(t, "test", proj.Runtime.Name()) }) t.Run("null optionals should work", func(t *testing.T) { t.Parallel() // Act. proj, err := writeAndLoad(t, "{\"name\": \"project\", \"runtime\": \"test\", "+ "\"description\": null, \"main\": null, \"backend\": null}") // Assert. assert.NoError(t, err) assert.Nil(t, proj.Description) assert.Equal(t, "", proj.Main) }) } func TestProjectLoadJSONInformativeErrors(t *testing.T) { t.Parallel() t.Run("a missing name attribute", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, `{"Name": "project", "runtime": "test"}`) // Assert. assert.ErrorContains(t, err, "project is missing a 'name' attribute") assert.ErrorContains(t, err, "found 'Name' instead") }) t.Run("a missing runtime attribute", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, `{"name": "project", "rutnime": "test"}`) // Assert. assert.ErrorContains(t, err, "project is missing a 'runtime' attribute") assert.ErrorContains(t, err, "found 'rutnime' instead") }) t.Run("a minor spelling mistake in a schema field", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, `{ "name": "project", "runtime": "test", "template": { "displatName": "foo" } }`) // Assert. assert.ErrorContains(t, err, "'displatName' not allowed; did you mean 'displayName'?") }) t.Run("a major spelling mistake in a schema field", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, `{ "name": "project", "runtime": "test", "template": { "displayNameDisplayName": "foo" } }`) // Assert. assert.ErrorContains(t, err, "'displayNameDisplayName' not allowed") assert.ErrorContains(t, err, "'displayNameDisplayName' not allowed; the allowed attributes are "+ "'config', 'description', 'displayName', 'important', 'metadata' and 'quickstart'") }) t.Run("specific errors when only a single attribute is expected", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, `{ "name": "project", "runtime": "test", "backend": { "url": "https://pulumi.com", "name": "test" } }`) // Assert. assert.ErrorContains(t, err, "'name' not allowed") assert.ErrorContains(t, err, "'name' not allowed; the only allowed attribute is 'url'") }) t.Run("a minor spelling mistake even deeper in the schema", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, `{ "name": "project", "runtime": "test", "plugins": { "providers": [ { "nome": "test" } ] } }`) // Assert. assert.ErrorContains(t, err, "'nome' not allowed; did you mean 'name'") }) t.Run("a major spelling mistake even deeper in the schema", func(t *testing.T) { t.Parallel() // Act. _, err := writeAndLoad(t, `{ "name": "project", "runtime": "test", "plugins": { "providers": [ { "displayName": "test" } ] } }`) // Assert. assert.ErrorContains(t, err, "'displayName' not allowed") assert.ErrorContains(t, err, "'displayName' not allowed; the allowed attributes are "+ "'name', 'path' and 'version'") }) } func deleteFile(t *testing.T, file *os.File) { if file != nil { err := os.Remove(file.Name()) assert.NoError(t, err, "Error while deleting file") } } func loadProjectFromText(t *testing.T, content string) (*Project, error) { tmp, err := os.CreateTemp("", "*.yaml") assert.NoError(t, err) path := tmp.Name() err = os.WriteFile(path, []byte(content), 0o600) assert.NoError(t, err) defer deleteFile(t, tmp) return LoadProject(path) } func loadProjectStackFromText(t *testing.T, project *Project, content string) (*ProjectStack, error) { tmp, err := os.CreateTemp("", "*.yaml") assert.NoError(t, err) path := tmp.Name() err = os.WriteFile(path, []byte(content), 0o600) assert.NoError(t, err) defer deleteFile(t, tmp) return LoadProjectStack(project, path) } func loadProjectStackFromJSONText(t *testing.T, project *Project, content string) (*ProjectStack, error) { tmp, err := os.CreateTemp("", "*.json") assert.NoError(t, err) path := tmp.Name() err = os.WriteFile(path, []byte(content), 0o600) assert.NoError(t, err) defer deleteFile(t, tmp) return LoadProjectStack(project, path) } func TestProjectLoadsConfigSchemas(t *testing.T) { t.Parallel() projectContent := ` name: test runtime: dotnet config: integerSchemaFull: type: integer description: a very important value default: 1 integerSchemaSimple: 20 textSchemaFull: type: string default: t3.micro textSchemaSimple: t4.large booleanSchemaFull: type: boolean default: true booleanSchemaSimple: false simpleArrayOfStrings: type: array items: type: string default: [hello] arrayOfArrays: type: array items: type: array items: type: string secretString: type: string secret: true ` project, err := loadProjectFromText(t, projectContent) assert.NoError(t, err, "Should be able to load the project") assert.Equal(t, 9, len(project.Config), "There are 9 config type definition") // full integer config schema integerSchemFull, ok := project.Config["integerSchemaFull"] assert.True(t, ok, "should be able to read integerSchemaFull") 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) assert.Nil(t, integerSchemFull.Items, "Primtive config type doesn't have an items type") integerSchemaSimple, ok := project.Config["integerSchemaSimple"] assert.True(t, ok, "should be able to read integerSchemaSimple") 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.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, "", 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.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, "", 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.TypeName()) assert.False(t, simpleArrayOfStrings.Secret) assert.NotNil(t, simpleArrayOfStrings.Items) assert.Equal(t, "string", simpleArrayOfStrings.Items.Type) arrayValues := simpleArrayOfStrings.Default.([]interface{}) assert.Equal(t, "hello", arrayValues[0]) arrayOfArrays, ok := project.Config["arrayOfArrays"] assert.True(t, ok, "should be able to read arrayOfArrays") assert.Equal(t, "array", arrayOfArrays.TypeName()) assert.False(t, arrayOfArrays.Secret) assert.NotNil(t, arrayOfArrays.Items) assert.Equal(t, "array", arrayOfArrays.Items.Type) assert.NotNil(t, arrayOfArrays.Items.Items) assert.Equal(t, "string", arrayOfArrays.Items.Items.Type) secretString, ok := project.Config["secretString"] assert.True(t, ok, "should be able to read secretString") assert.Equal(t, "string", secretString.TypeName()) assert.Equal(t, "", secretString.Description) assert.Equal(t, nil, secretString.Default) assert.True(t, secretString.Secret) assert.Nil(t, secretString.Items) } func getConfigValue(t *testing.T, stackConfig config.Map, key string) string { 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) value, valueError := configValue.Value(config.NopDecrypter) assert.NoErrorf(t, valueError, "Error while getting the value for key %v", key) 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 := ` name: test runtime: dotnet config: instanceSize: t3.micro instanceCount: 20 protect: true` projectStackYaml := ` config: test:instanceSize: t4.large` 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), 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, "t4.large", getConfigValue(t, stack.Config, "test:instanceSize")) // instanceCount and protect are inherited from the project assert.Equal(t, "20", getConfigValue(t, stack.Config, "test:instanceCount")) assert.Equal(t, "true", getConfigValue(t, stack.Config, "test:protect")) } func TestNamespacedConfigValuesAreInheritedCorrectly(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: aws:region: us-west-1 pulumi:disable-default-providers: ["*"] instanceSize: t3.micro` projectStackYaml := ` config: test:instanceSize: t4.large` 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), 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, "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) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: aws:region: us-west-1 instanceSize: t3.micro` projectStackYaml := ` config: instanceSize: t4.large` 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), 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, "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")) } 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), 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.ErrorContains(t, projectError, "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.ErrorContains(t, projectError, "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.ErrorContains(t, projectError, "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.ErrorContains(t, projectError, "Configuration key 'aws:region' is not namespaced by the project and should not define a default value") assert.ErrorContains(t, projectError, "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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), 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 := ` name: test runtime: dotnet config: values: type: array items: type: string default: [value]` projectStackYaml := ` config: test:values: someValue ` 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), config.NewPanicCrypter()) assert.ErrorContains(t, configError, "Stack 'dev' with configuration key 'values' must be of type 'array<string>'") } func TestLoadingConfigIsRewrittenToStackConfigDir(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: ./some/path` project, projectError := loadProjectFromText(t, projectYaml) assert.NoError(t, projectError, "Shold be able to load the project") assert.Equal(t, "./some/path", project.StackConfigDir, "Stack config dir is read from the config property") assert.Equal(t, 0, len(project.Config), "Config should be empty") } func TestDefningBothConfigAndStackConfigDirErrorsOut(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: ./some/path stackConfigDir: ./some/other/path` project, projectError := loadProjectFromText(t, projectYaml) assert.Nil(t, project, "Should NOT be able to load the project") assert.ErrorContains(t, projectError, "Should not use both config and stackConfigDir") } func TestConfigObjectAndStackConfigDirSuccessfullyLoadProject(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet stackConfigDir: ./some/other/path config: value: hello ` project, projectError := loadProjectFromText(t, projectYaml) assert.Nil(t, projectError, "There is no error") assert.NotNil(t, project, "The project can be loaded correctly") assert.Equal(t, "./some/other/path", project.StackConfigDir) assert.Equal(t, 1, len(project.Config), "there is one config value") } func TestStackConfigIntegerTypeIsCorrectlyValidated(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: importantNumber: type: integer ` projectStackYamlValid := ` config: test:importantNumber: 20 ` projectStackYamlInvalid := ` config: test:importantNumber: hello ` ctx := context.Background() project, projectError := loadProjectFromText(t, projectYaml) assert.NoError(t, projectError, "Shold be able to load the project") stack, stackError := loadProjectStackFromText(t, project, projectStackYamlValid) assert.NoError(t, stackError, "Should be able to read the stack") configError := ValidateStackConfigAndApplyProjectConfig( ctx, "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), config.NewPanicCrypter()) assert.NoError(t, configError, "there should no config type error") invalidStackConfig, stackError := loadProjectStackFromText(t, project, projectStackYamlInvalid) assert.NoError(t, stackError, "Should be able to read the stack") configError = ValidateStackConfigAndApplyProjectConfig( ctx, "dev", project, esc.Value{}, invalidStackConfig.Config, config.NewPanicCrypter(), config.NewPanicCrypter()) assert.ErrorContains(t, configError, "Stack 'dev' with configuration key 'importantNumber' must be of type 'integer'") } func TestStackConfigErrorsWhenMissingStackValueForConfigTypeWithNoDefault(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: values: type: array items: type: string` projectStackYaml := `` 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), config.NewPanicCrypter()) assert.ErrorContains(t, configError, "Stack 'dev' is missing configuration value 'values'") } func TestStackConfigErrorsWhenMissingTwoStackValueForConfigTypeWithNoDefault(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: another: type: string values: type: array items: type: string` projectStackYaml := `` 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), config.NewPanicCrypter()) assert.ErrorContains(t, configError, "Stack 'dev' is missing configuration values 'another' and 'values'") } func TestStackConfigErrorsWhenMissingMultipleStackValueForConfigTypeWithNoDefault(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: hello: type: integer values: type: array items: type: string world: type: string` projectStackYaml := `` 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), config.NewPanicCrypter()) assert.ErrorContains(t, configError, "Stack 'dev' is missing configuration values 'hello', 'values' and 'world'") } func TestStackConfigDoesNotErrorWhenProjectHasNotDefinedConfig(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet` 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( context.Background(), "dev", project, esc.Value{}, stack.Config, config.NewPanicCrypter(), config.NewPanicCrypter()) assert.Nil(t, configError, "there should not be a config type error") } func TestStackConfigSecretIsCorrectlyValidated(t *testing.T) { t.Parallel() projectYaml := ` name: test runtime: dotnet config: importantNumber: type: integer secret: true ` crypter := config.Base64Crypter encryptedValue, err := crypter.EncryptValue(context.Background(), "20") assert.NoError(t, err) projectStackYamlValid := fmt.Sprintf(` config: test:importantNumber: secure: %s `, encryptedValue) projectStackYamlInvalid := ` config: test:importantNumber: 20 ` ctx := context.Background() project, projectError := loadProjectFromText(t, projectYaml) assert.NoError(t, projectError, "Shold be able to load the project") stack, stackError := loadProjectStackFromText(t, project, projectStackYamlValid) assert.NoError(t, stackError, "Should be able to read the stack") configError := ValidateStackConfigAndApplyProjectConfig( ctx, "dev", project, esc.Value{}, stack.Config, crypter, crypter) assert.NoError(t, configError, "there should no config type error") invalidStackConfig, stackError := loadProjectStackFromText(t, project, projectStackYamlInvalid) assert.NoError(t, stackError, "Should be able to read the stack") configError = ValidateStackConfigAndApplyProjectConfig( ctx, "dev", project, esc.Value{}, invalidStackConfig.Config, crypter, crypter) assert.ErrorContains(t, configError, "Stack 'dev' with configuration key 'importantNumber' must be encrypted as it's secret") } //nolint:lll func TestEnvironmentMerge(t *testing.T) { t.Parallel() projectYAML := ` name: test runtime: nodejs` stackYAML := ` config: test:boolean: true test:number: 42 test:string: foo test:array: - first - second test:object: foo: bar object: baz: 42` env := esc.NewValue(map[string]esc.Value{ "test:boolean": esc.NewValue(false), "test:number": esc.NewValue(json.Number("42")), "test:string": esc.NewValue("esc"), "test:array": esc.NewValue([]esc.Value{esc.NewValue("second"), esc.NewValue("first")}), "test:secret": esc.NewSecret("hunter2"), "test:object": esc.NewValue(map[string]esc.Value{ "boolean": esc.NewValue(true), "number": esc.NewValue(json.Number("42")), "string": esc.NewValue("esc"), "array": esc.NewValue([]esc.Value{esc.NewValue("first"), esc.NewValue("second")}), "object": esc.NewValue(map[string]esc.Value{"foo": esc.NewValue("bar")}), "foo": esc.NewValue("qux"), }), }) project, projectError := loadProjectFromText(t, projectYAML) require.NoError(t, projectError, "Shold be able to load the project") stack, stackError := loadProjectStackFromText(t, project, stackYAML) require.NoError(t, stackError, "Should be able to read the stack") configError := ValidateStackConfigAndApplyProjectConfig( context.Background(), "dev", project, env, stack.Config, config.Base64Crypter, config.Base64Crypter) require.NoError(t, configError, "there should not be a config type error") secureKeys := stack.Config.SecureKeys() assert.Equal(t, []config.Key{config.MustMakeKey("test", "secret")}, secureKeys) m, err := stack.Config.Decrypt(config.Base64Crypter) require.NoError(t, err) expected := map[config.Key]string{ config.MustMakeKey("test", "array"): "[\"first\",\"second\"]", config.MustMakeKey("test", "boolean"): "true", config.MustMakeKey("test", "number"): "42", config.MustMakeKey("test", "object"): "{\"array\":[\"first\",\"second\"],\"boolean\":true,\"foo\":\"bar\",\"number\":42,\"object\":{\"baz\":42,\"foo\":\"bar\"},\"string\":\"esc\"}", config.MustMakeKey("test", "string"): "foo", config.MustMakeKey("test", "secret"): "hunter2", } assert.Equal(t, expected, m) } func TestProjectLoadYAML(t *testing.T) { t.Parallel() // Test wrong type _, err := loadProjectFromText(t, "\"hello\"") assert.ErrorContains(t, err, "expected project to be an object") // Test bad key _, err = loadProjectFromText(t, "4: hello") assert.ErrorContains(t, err, "expected only string keys, got '%!s(int=4)'") // Test nested bad key _, err = loadProjectFromText(t, "hello:\n 6: bad") assert.ErrorContains(t, err, "project is missing a 'name' attribute") // Test lack of name _, err = loadProjectFromText(t, "{}") assert.ErrorContains(t, err, "project is missing a 'name' attribute") // Test bad name _, err = loadProjectFromText(t, "name:") assert.ErrorContains(t, err, "project is missing a non-empty string 'name' attribute") // Test missing runtime _, err = loadProjectFromText(t, "name: project") assert.ErrorContains(t, err, "project is missing a 'runtime' attribute") // Test other schema errors _, err = loadProjectFromText(t, "name: project\nruntime: 4") // These can vary in order, so contains not equals check expected := []string{ "3 errors occurred:", "* #/runtime: oneOf failed", "* #/runtime: expected string, but got number", "* #/runtime: expected object, but got number", } for _, e := range expected { assert.ErrorContains(t, err, e) } _, err = loadProjectFromText(t, "name: project\nruntime: test\nbackend: 4\nmain: {}") expected = []string{ "2 errors occurred:", "* #/main: expected string or null, but got object", "* #/backend: expected object or null, but got number", } for _, e := range expected { assert.ErrorContains(t, err, e) } // Test success proj, err := loadProjectFromText(t, "name: project\nruntime: test") assert.NoError(t, err) assert.Equal(t, tokens.PackageName("project"), proj.Name) assert.Equal(t, "test", proj.Runtime.Name()) // Test null optionals should work proj, err = loadProjectFromText(t, "name: project\nruntime: test\ndescription:\nmain: null\nbackend:\n") assert.NoError(t, err) assert.Nil(t, proj.Description) assert.Equal(t, "", proj.Main) } func TestProjectSaveLoadRoundtrip(t *testing.T) { t.Parallel() tests := []struct { name string project Project }{ { name: "Numeric name", project: Project{ Name: "1234", Runtime: NewProjectRuntimeInfo("python", nil), }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() tmp, err := os.CreateTemp("", "*.yaml") require.NoError(t, err) defer deleteFile(t, tmp) path := tmp.Name() err = tt.project.Save(path) require.NoError(t, err) loadedProject, err := LoadProject(path) require.NoError(t, err) require.NotNil(t, loadedProject) // Clear the raw data before we compare loadedProject.raw = nil assert.Equal(t, tt.project, *loadedProject) }) } } func TestProjectEditRoundtrip(t *testing.T) { t.Parallel() tests := []struct { name string yaml string edit func(*Project) expected string }{ { name: "Change name", yaml: "name: test\nruntime: python\n", edit: func(proj *Project) { proj.Name = "new" }, expected: "name: new\nruntime: python\n", }, { name: "Add runtime option", yaml: "name: test\nruntime: python\n", edit: func(proj *Project) { proj.Runtime = NewProjectRuntimeInfo( proj.Runtime.Name(), map[string]interface{}{ "setting": "test", }) }, expected: "name: test\nruntime:\n name: python\n options:\n setting: test\n", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() tmp, err := os.CreateTemp("", "*.yaml") require.NoError(t, err) defer deleteFile(t, tmp) path := tmp.Name() err = os.WriteFile(path, []byte(tt.yaml), 0o600) require.NoError(t, err) loadedProject, err := LoadProject(path) require.NoError(t, err) require.NotNil(t, loadedProject) tt.edit(loadedProject) err = loadedProject.Save(path) require.NoError(t, err) actualYaml, err := os.ReadFile(path) require.NoError(t, err) assert.Equal(t, tt.expected, string(actualYaml)) }) } } func TestEnvironmentAppend(t *testing.T) { t.Parallel() t.Run("JSON list", func(t *testing.T) { t.Parallel() projectYaml := `name: test runtime: yaml` projectStackJSON := "{}" project, err := loadProjectFromText(t, projectYaml) require.NoError(t, err) stack, err := loadProjectStackFromJSONText(t, project, projectStackJSON) require.NoError(t, err) stack.Environment = stack.Environment.Append("env") marshaled, err := encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, "{\n \"environment\": [\n \"env\"\n ]\n}\n", string(marshaled)) stack.Environment = stack.Environment.Append("env2") marshaled, err = encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, "{\n \"environment\": [\n \"env\",\n \"env2\"\n ]\n}\n", string(marshaled)) }) t.Run("JSON literal", func(t *testing.T) { t.Parallel() projectYaml := `name: test runtime: yaml` projectStackJSON := `{ "environment": { "values": { "pulumiConfig": { "aws:region": "us-west-2" } } } } ` project, err := loadProjectFromText(t, projectYaml) require.NoError(t, err) stack, err := loadProjectStackFromJSONText(t, project, projectStackJSON) require.NoError(t, err) expected := `{ "environment": { "imports": [ "env" ], "values": { "pulumiConfig": { "aws:region": "us-west-2" } } } } ` stack.Environment = stack.Environment.Append("env") marshaled, err := encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) expected = `{ "environment": { "imports": [ "env", "env2" ], "values": { "pulumiConfig": { "aws:region": "us-west-2" } } } } ` stack.Environment = stack.Environment.Append("env2") marshaled, err = encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) }) t.Run("YAML list", func(t *testing.T) { t.Parallel() projectYaml := `name: test runtime: yaml` projectStackYaml := "" project, err := loadProjectFromText(t, projectYaml) require.NoError(t, err) stack, err := loadProjectStackFromText(t, project, projectStackYaml) require.NoError(t, err) stack.Environment = stack.Environment.Append("env") marshaled, err := encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, "environment:\n - env\n", string(marshaled)) stack.Environment = stack.Environment.Append("env2") marshaled, err = encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, "environment:\n - env\n - env2\n", string(marshaled)) }) t.Run("YAML literal", func(t *testing.T) { t.Parallel() projectYaml := `name: test runtime: yaml` projectStackYaml := `environment: values: pulumiConfig: aws:region: us-west-2` project, err := loadProjectFromText(t, projectYaml) require.NoError(t, err) stack, err := loadProjectStackFromText(t, project, projectStackYaml) require.NoError(t, err) expected := `environment: imports: - env values: pulumiConfig: aws:region: us-west-2 ` stack.Environment = stack.Environment.Append("env") marshaled, err := encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) expected = `environment: imports: - env - env2 values: pulumiConfig: aws:region: us-west-2 ` stack.Environment = stack.Environment.Append("env2") marshaled, err = encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) }) } func TestEnvironmentRemove(t *testing.T) { t.Parallel() t.Run("JSON list", func(t *testing.T) { t.Parallel() projectYaml := `name: test runtime: yaml` projectStackJSON := `{ "environment": [ "env2", "env", "env2" ] } ` project, err := loadProjectFromText(t, projectYaml) require.NoError(t, err) stack, err := loadProjectStackFromJSONText(t, project, projectStackJSON) require.NoError(t, err) expected := `{ "environment": [ "env2", "env" ] } ` stack.Environment = stack.Environment.Remove("env2") marshaled, err := encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) expected = `{ "environment": [ "env2" ] } ` stack.Environment = stack.Environment.Remove("env") marshaled, err = encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) stack.Environment = stack.Environment.Remove("env2") marshaled, err = encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, "{}\n", string(marshaled)) }) t.Run("JSON literal", func(t *testing.T) { t.Parallel() projectYaml := `name: test runtime: yaml` projectStackJSON := `{ "environment": { "imports": [ { "env2": { "merge": false } }, "env", "env2" ], "values": { "pulumiConfig": { "aws:region": "us-west-2" } } } } ` project, err := loadProjectFromText(t, projectYaml) require.NoError(t, err) stack, err := loadProjectStackFromJSONText(t, project, projectStackJSON) require.NoError(t, err) expected := `{ "environment": { "imports": [ { "env2": { "merge": false } }, "env" ], "values": { "pulumiConfig": { "aws:region": "us-west-2" } } } } ` stack.Environment = stack.Environment.Remove("env2") marshaled, err := encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) expected = `{ "environment": { "imports": [ "env" ], "values": { "pulumiConfig": { "aws:region": "us-west-2" } } } } ` stack.Environment = stack.Environment.Remove("env2") marshaled, err = encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) expected = `{ "environment": { "imports": [], "values": { "pulumiConfig": { "aws:region": "us-west-2" } } } } ` stack.Environment = stack.Environment.Remove("env") marshaled, err = encoding.JSON.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) }) t.Run("YAML list", func(t *testing.T) { t.Parallel() projectYaml := `name: test runtime: yaml` projectStackYaml := `environment: - env2 - env - env2 ` project, err := loadProjectFromText(t, projectYaml) require.NoError(t, err) stack, err := loadProjectStackFromText(t, project, projectStackYaml) require.NoError(t, err) expected := `environment: - env2 - env ` stack.Environment = stack.Environment.Remove("env2") marshaled, err := encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) stack.Environment = stack.Environment.Remove("env") marshaled, err = encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, "environment:\n - env2\n", string(marshaled)) stack.Environment = stack.Environment.Remove("env2") marshaled, err = encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, "{}\n", string(marshaled)) }) t.Run("YAML literal", func(t *testing.T) { t.Parallel() projectYaml := `name: test runtime: yaml` projectStackYaml := `environment: imports: - {env2: {merge: false}} - env - env2 values: pulumiConfig: aws:region: us-west-2` project, err := loadProjectFromText(t, projectYaml) require.NoError(t, err) stack, err := loadProjectStackFromText(t, project, projectStackYaml) require.NoError(t, err) expected := `environment: imports: - {env2: {merge: false}} - env values: pulumiConfig: aws:region: us-west-2 ` stack.Environment = stack.Environment.Remove("env2") marshaled, err := encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) expected = `environment: imports: - env values: pulumiConfig: aws:region: us-west-2 ` stack.Environment = stack.Environment.Remove("env2") marshaled, err = encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) expected = `environment: values: pulumiConfig: aws:region: us-west-2 ` stack.Environment = stack.Environment.Remove("env") marshaled, err = encoding.YAML.Marshal(stack) require.NoError(t, err) assert.Equal(t, expected, string(marshaled)) }) }