pulumi/sdk/go/common/workspace/project.go

1174 lines
35 KiB
Go

// Copyright 2016-2022, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package workspace
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"github.com/pulumi/esc/ast"
"github.com/pulumi/esc/eval"
"github.com/texttheater/golang-levenshtein/levenshtein"
"github.com/hashicorp/go-multierror"
"github.com/pgavlin/fx"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"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/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
"github.com/santhosh-tekuri/jsonschema/v5"
"golang.org/x/exp/maps"
"gopkg.in/yaml.v3"
)
const (
arrayTypeName = "array"
integerTypeName = "integer"
stringTypeName = "string"
booleanTypeName = "boolean"
)
//go:embed project.json
var projectSchema string
var ProjectSchema *jsonschema.Schema
func init() {
compiler := jsonschema.NewCompiler()
compiler.LoadURL = func(u string) (io.ReadCloser, error) {
if u == "blob://project.json" {
return io.NopCloser(strings.NewReader(projectSchema)), nil
}
return jsonschema.LoadURL(u)
}
ProjectSchema = compiler.MustCompile("blob://project.json")
}
// Analyzers is a list of analyzers to run on this project.
type Analyzers []tokens.QName
// ProjectTemplate is a Pulumi project template manifest.
type ProjectTemplate struct {
// DisplayName is an optional user friendly name of the template.
DisplayName string `json:"displayName,omitempty" yaml:"displayName,omitempty"`
// Description is an optional description of the template.
Description string `json:"description,omitempty" yaml:"description,omitempty"`
// Quickstart contains optional text to be displayed after template creation.
Quickstart string `json:"quickstart,omitempty" yaml:"quickstart,omitempty"`
// Config is an optional template config.
Config map[string]ProjectTemplateConfigValue `json:"config,omitempty" yaml:"config,omitempty"`
// Important indicates the template is important.
Important bool `json:"important,omitempty" yaml:"important,omitempty"`
// Metadata are key/value pairs used to attach additional metadata to a template.
Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}
// ProjectTemplateConfigValue is a config value included in the project template manifest.
type ProjectTemplateConfigValue struct {
// Description is an optional description for the config value.
Description string `json:"description,omitempty" yaml:"description,omitempty"`
// Default is an optional default value for the config value.
Default string `json:"default,omitempty" yaml:"default,omitempty"`
// Secret may be set to true to indicate that the config value should be encrypted.
Secret bool `json:"secret,omitempty" yaml:"secret,omitempty"`
}
// ProjectBackend is the configuration for where the backend state is stored. If unset, will use the
// system's currently logged-in backend.
//
// Use the same URL format that is passed to "pulumi login", see
// https://www.pulumi.com/docs/cli/commands/pulumi_login/
//
// To explicitly use the Pulumi Cloud backend, use URL "https://api.pulumi.com"
type ProjectBackend struct {
// URL is optional field to explicitly set backend url
URL string `json:"url,omitempty" yaml:"url,omitempty"`
}
type ProjectOptions struct {
// Refresh is the ability to always run a refresh as part of a pulumi update / preview / destroy
Refresh string `json:"refresh,omitempty" yaml:"refresh,omitempty"`
}
type PluginOptions struct {
Name string `json:"name" yaml:"name"`
Version string `json:"version,omitempty" yaml:"version,omitempty"`
Path string `json:"path" yaml:"path"`
}
type Plugins struct {
Providers []PluginOptions `json:"providers,omitempty" yaml:"providers,omitempty"`
Languages []PluginOptions `json:"languages,omitempty" yaml:"languages,omitempty"`
Analyzers []PluginOptions `json:"analyzers,omitempty" yaml:"analyzers,omitempty"`
}
type ProjectConfigItemsType struct {
Type string `json:"type,omitempty" yaml:"type,omitempty"`
Items *ProjectConfigItemsType `json:"items,omitempty" yaml:"items,omitempty"`
}
type ProjectConfigType struct {
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
// in terms of the JSON tags) so we can directly marshall and unmarshall this struct using go-yaml an have the fields
// in the serialized object match the order they are defined in this struct.
//
// TODO[pulumi/pulumi#423]: use DOM based marshalling so we can roundtrip the seralized structure perfectly.
type Project struct {
// Name is a required fully qualified name.
Name tokens.PackageName `json:"name" yaml:"name"`
// Runtime is a required runtime that executes code.
Runtime ProjectRuntimeInfo `json:"runtime" yaml:"runtime"`
// Main is an optional override for the program's main entry-point location.
Main string `json:"main,omitempty" yaml:"main,omitempty"`
// Description is an optional informational description.
Description *string `json:"description,omitempty" yaml:"description,omitempty"`
// Author is an optional author that created this project.
Author *string `json:"author,omitempty" yaml:"author,omitempty"`
// Website is an optional website for additional info about this project.
Website *string `json:"website,omitempty" yaml:"website,omitempty"`
// License is the optional license governing this project's usage.
License *string `json:"license,omitempty" yaml:"license,omitempty"`
// Config has been renamed to StackConfigDir.
Config map[string]ProjectConfigType `json:"config,omitempty" yaml:"config,omitempty"`
// StackConfigDir indicates where to store the Pulumi.<stack-name>.yaml files, combined with the folder
// Pulumi.yaml is in.
StackConfigDir string `json:"stackConfigDir,omitempty" yaml:"stackConfigDir,omitempty"`
// Template is an optional template manifest, if this project is a template.
Template *ProjectTemplate `json:"template,omitempty" yaml:"template,omitempty"`
// Backend is an optional backend configuration
Backend *ProjectBackend `json:"backend,omitempty" yaml:"backend,omitempty"`
// Options is an optional set of project options
Options *ProjectOptions `json:"options,omitempty" yaml:"options,omitempty"`
Plugins *Plugins `json:"plugins,omitempty" yaml:"plugins,omitempty"`
// Handle additional keys, albeit in a way that will remove comments and trivia.
AdditionalKeys map[string]interface{} `yaml:",inline"`
// The original byte representation of the file, used to attempt trivia-preserving edits
raw []byte
}
func (proj Project) RawValue() []byte {
return proj.raw
}
func isPrimitiveValue(value interface{}) bool {
switch value.(type) {
case string, int, bool:
return true
default:
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}.
// if the user defines both values as strings, we error out.
func RewriteConfigPathIntoStackConfigDir(project map[string]interface{}) (map[string]interface{}, error) {
config, hasConfig := project["config"]
_, hasStackConfigDir := project["stackConfigDir"]
if hasConfig {
configText, configIsText := config.(string)
if configIsText && hasStackConfigDir {
return nil, errors.New("Should not use both config and stackConfigDir to define the stack directory. " +
"Use only stackConfigDir instead.")
} else if configIsText && !hasStackConfigDir {
// then we have config: {value}. Move this to stackConfigDir: {value}
project["stackConfigDir"] = configText
// reset the config property
project["config"] = nil
return project, nil
}
}
return project, nil
}
// RewriteShorthandConfigValues rewrites short-hand version of configuration into a configuration type
// for example the following config block definition:
//
// config:
// instanceSize: t3.mirco
// aws:region: us-west-2
//
// will be rewritten into a typed value:
//
// config:
// 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
}
config, ok := configMap.(map[string]interface{})
if !ok {
return project
}
for key, value := range config {
if isPrimitiveValue(value) || isArray(value) {
configTypeDefinition := make(map[string]interface{})
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
}
}
return project
}
// Cast any map[interface{}] from the yaml decoder to map[string]
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 {
strMap := make(map[string]interface{})
for key, value := range objMap {
if strKey, ok := key.(string); ok {
innerValue, err := cast(value)
if err != nil {
return nil, err
}
strMap[strKey] = innerValue
} else {
return nil, fmt.Errorf("expected only string keys, got '%s'", key)
}
}
return strMap, nil
} else if objArray, ok := value.([]interface{}); ok {
strArray := make([]interface{}, len(objArray))
for key, value := range objArray {
innerValue, err := cast(value)
if err != nil {
return nil, err
}
strArray[key] = innerValue
}
return strArray, nil
}
return value, nil
}
return cast(raw)
}
func SimplifyMarshalledProject(raw interface{}) (map[string]interface{}, error) {
result, err := SimplifyMarshalledValue(raw)
if err != nil {
return nil, err
}
var ok bool
var obj map[string]interface{}
if obj, ok = result.(map[string]interface{}); !ok {
return nil, fmt.Errorf("expected project to be an object, was '%T'", result)
}
return obj, nil
}
func ValidateProject(raw interface{}) error {
project, err := SimplifyMarshalledProject(raw)
if err != nil {
return err
}
// Manually validate keys that need more validation than the raw JSON schema
// can provide.
name, ok := project["name"]
if !ok {
closest := findClosestKey("name", project, maxValidationAttributeDistance)
if closest != "" {
return fmt.Errorf(
"project is missing a 'name' attribute; found '%s' instead",
closest,
)
}
return errors.New("project is missing a 'name' attribute")
}
if strName, ok := name.(string); !ok || strName == "" {
return errors.New("project is missing a non-empty string 'name' attribute")
}
if _, ok := project["runtime"]; !ok {
closest := findClosestKey("runtime", project, maxValidationAttributeDistance)
if closest != "" {
return fmt.Errorf(
"project is missing a 'runtime' attribute; found '%s' instead",
closest,
)
}
return errors.New("project is missing a 'runtime' attribute")
}
// We'll catch everything else with JSON schema, though we'll still try to
// suggest fixes for common mistakes.
if err = ProjectSchema.Validate(project); err == nil {
return nil
}
validationError, ok := err.(*jsonschema.ValidationError)
if !ok {
return err
}
notAllowedRe := regexp.MustCompile(`'(\w[a-zA-Z0-9_]*)' not allowed$`)
var errs *multierror.Error
var appendError func(err *jsonschema.ValidationError)
appendError = func(err *jsonschema.ValidationError) {
if err.InstanceLocation != "" && err.Message != "" {
errorf := func(path, message string, args ...interface{}) error {
contract.Requiref(path != "", "path", "path must not be empty")
return fmt.Errorf("%s: %s", path, fmt.Sprintf(message, args...))
}
msg := err.Message
if match := notAllowedRe.FindStringSubmatch(msg); match != nil {
attrName := match[1]
attributes := getSchemaPathAttributes(err.InstanceLocation)
closest := findClosestKey(attrName, attributes, maxValidationAttributeDistance)
if closest != "" {
msg = fmt.Sprintf("%s; did you mean '%s'?", msg, closest)
} else if len(attributes) > 0 {
valid := make([]string, 0, len(attributes))
for k := range attributes {
valid = append(valid, "'"+k+"'")
}
if len(valid) > 1 {
sort.StringSlice.Sort(valid)
msg = fmt.Sprintf("%s; the allowed attributes are %v and %s",
msg, strings.Join(valid[:len(valid)-1], ", "), valid[len(valid)-1])
} else {
msg = fmt.Sprintf("%s; the only allowed attribute is %s", msg, valid[0])
}
}
}
errs = multierror.Append(errs, errorf("#"+err.InstanceLocation, "%v", msg))
}
for _, err := range err.Causes {
appendError(err)
}
}
appendError(validationError)
return errs
}
// maxValidationAttributeDistance is the maximum Levenshtein distance we'll
// tolerate when searching for attribute names the user might have meant to
// type.
const maxValidationAttributeDistance = 2
// findClosestKey finds the closest attribute name in the given map to the
// supplied needle, where "closest" means the name with the smallest Levenshtein
// distance from the needle. The haystack will be sorted so that in the event
// multiple attributes have the same distance, the result will be deterministic
// (and be the first alphabetically).
func findClosestKey(
needle string,
haystack map[string]interface{},
maxDistance int,
) string {
match := ""
closest := maxDistance + 1
keys := maps.Keys(haystack)
slices.Sort(keys)
for _, key := range keys {
d := levenshtein.DistanceForStrings(
[]rune(strings.ToLower(needle)),
[]rune(strings.ToLower(key)),
levenshtein.DefaultOptionsWithSub,
)
if d == 0 {
// We can't do better than 0 so we can short circuit in this case. Note
// that a distance of 0 is possible since we lowercase the strings prior
// to checking the Levenshtein distance, so e.g. "Name" and "NAME" will
// become "name"/"name" and yield a distance of 0.
return key
} else if d < closest {
closest = d
match = key
} else {
continue
}
}
return match
}
// getSchemaPathAttributes walks the given path into the project schema and
// returns a list of attributes that can be subsequently traversed at the end of
// that path.
func getSchemaPathAttributes(path string) map[string]interface{} {
elements := strings.Split(path, "/")
isNumber := regexp.MustCompile(`^\d+$`)
curr := ProjectSchema
for len(elements) > 0 {
attr := elements[0]
elements = elements[1:]
if attr == "" {
continue
}
// If this schema node references another, continue from there.
if curr.Ref != nil {
curr = curr.Ref
}
// Check properties for matching attributes.
if schema, ok := curr.Properties[attr]; ok {
curr = schema
continue
}
// Check additional properties.
if curr.AdditionalProperties != nil {
if additional, ok := curr.AdditionalProperties.(map[string]*jsonschema.Schema); ok {
if schema, ok := additional[attr]; ok {
curr = schema
continue
}
}
}
// If the attribute is numeric, check for an array that can be indexed.
if isNumber.MatchString(attr) && curr.Items2020 != nil {
curr = curr.Items2020
continue
}
// In all other cases we can't traverse the supplied path.
return nil
}
// If we end on a reference, resolve it to the actual element before
// enumerating attributes.
if curr.Ref != nil {
curr = curr.Ref
}
knownProperties := make(map[string]interface{})
for k, v := range curr.Properties {
knownProperties[k] = v
}
if curr.AdditionalProperties != nil {
if additional, ok := curr.AdditionalProperties.(map[string]*jsonschema.Schema); ok {
for k, v := range additional {
knownProperties[k] = v
}
}
}
return knownProperties
}
func InferFullTypeName(typeName string, itemsType *ProjectConfigItemsType) string {
if itemsType != nil {
return fmt.Sprintf("array<%v>", InferFullTypeName(itemsType.Type, itemsType.Items))
}
return typeName
}
// ValidateConfig validates the config value against its config type definition.
// We use this to validate the default config values alongside their type definition but
// also to validate config values coming from individual stacks.
func ValidateConfigValue(typeName string, itemsType *ProjectConfigItemsType, value interface{}) bool {
if typeName == stringTypeName {
_, ok := value.(string)
return ok
}
if typeName == integerTypeName {
_, ok := value.(int)
if ok {
return true
}
// Config values come from YAML which by default will return floats not int. If it's a whole number
// we'll allow it here though
f, ok := value.(float64)
if ok && f == math.Trunc(f) {
return true
}
// Allow strings here if they parse as integers
valueAsText, isText := value.(string)
if isText {
_, integerParseError := strconv.Atoi(valueAsText)
return integerParseError == nil
}
return false
}
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") {
return true
}
_, ok = value.(bool)
return ok
}
items, isArray := value.([]interface{})
if !isArray || itemsType == nil {
return false
}
// validate each item
for _, item := range items {
itemType := itemsType.Type
underlyingItems := itemsType.Items
if !ValidateConfigValue(itemType, underlyingItems, item) {
return false
}
}
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")
}
if proj.Runtime.Name() == "" {
return errors.New("project is missing a 'runtime' attribute")
}
projectName := proj.Name.String()
for configKey, configType := range proj.Config {
if configType.Default != nil && configType.Value != nil {
return fmt.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 fmt.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 fmt.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 fmt.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 fmt.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 fmt.Errorf("Configuration key '%v' is namespaced and must provide an attribute 'value'", configKey)
}
}
}
return nil
}
// Save writes a project definition to a file.
func (proj *Project) Save(path string) error {
contract.Requiref(path != "", "path", "must not be empty")
contract.Requiref(proj != nil, "proj", "must not be nil")
err := proj.Validate()
contract.Requiref(err == nil, "proj", "Validate(): %v", err)
return save(path, proj, false /*mkDirAll*/)
}
type PolicyPackProject struct {
// Runtime is a required runtime that executes code.
Runtime ProjectRuntimeInfo `json:"runtime" yaml:"runtime"`
// Version specifies the version of the policy pack. If set, it will override the
// version specified in `package.json` for Node.js policy packs.
Version string `json:"version,omitempty" yaml:"version,omitempty"`
// Main is an optional override for the program's main entry-point location.
Main string `json:"main,omitempty" yaml:"main,omitempty"`
// Description is an optional informational description.
Description *string `json:"description,omitempty" yaml:"description,omitempty"`
// Author is an optional author that created this project.
Author *string `json:"author,omitempty" yaml:"author,omitempty"`
// Website is an optional website for additional info about this project.
Website *string `json:"website,omitempty" yaml:"website,omitempty"`
// License is the optional license governing this project's usage.
License *string `json:"license,omitempty" yaml:"license,omitempty"`
// The original byte representation of the file, used to attempt trivia-preserving edits
raw []byte
}
func (proj PolicyPackProject) RawValue() []byte {
return proj.raw
}
func (proj *PolicyPackProject) Validate() error {
if proj.Runtime.Name() == "" {
return errors.New("project is missing a 'runtime' attribute")
}
return nil
}
// Save writes a project definition to a file.
func (proj *PolicyPackProject) Save(path string) error {
contract.Requiref(path != "", "path", "must not be empty")
contract.Requiref(proj != nil, "proj", "must not be nil")
contract.Requiref(proj.Validate() == nil, "proj", "Validate()")
return save(path, proj, false /*mkDirAll*/)
}
type PluginProject struct {
// Runtime is a required runtime that executes code.
Runtime ProjectRuntimeInfo `json:"runtime" yaml:"runtime"`
}
func (proj *PluginProject) Validate() error {
if proj.Runtime.Name() == "" {
return errors.New("project is missing a 'runtime' attribute")
}
return nil
}
type Environment struct {
envs []string
message json.RawMessage
node *yaml.Node
}
func NewEnvironment(envs []string) *Environment {
return &Environment{envs: envs}
}
func (e *Environment) Definition() []byte {
switch {
case e == nil:
// If there's no environment, return nil.
return nil
case len(e.envs) != 0:
// If the environment was a list of environments, create an anonymous environment and return it.
bytes, err := json.Marshal(map[string]any{"imports": e.envs})
if err != nil {
return nil
}
return bytes
case e.message != nil:
// If the environment was encoded as JSON, return the raw JSON.
return e.message
case e.node != nil:
// Re-encode the YAML and return it.
bytes, err := yaml.Marshal(e.node)
if err != nil {
return nil
}
return bytes
default:
return nil
}
}
func (e *Environment) Imports() []string {
def, diags, err := eval.LoadYAMLBytes("yaml", e.Definition())
if err != nil || len(diags) != 0 || def == nil {
return nil
}
names := fx.ToSlice(fx.Map(fx.IterSlice(def.Imports.GetElements()), func(imp *ast.ImportDecl) string {
return imp.Environment.GetValue()
}))
if len(def.Values.GetEntries()) != 0 {
names = append(names, "yaml")
}
return names
}
func (e *Environment) Append(envs ...string) *Environment {
switch {
case e == nil:
// The stack has no environment block. Create one that imports the named environments.
return NewEnvironment(envs)
case e.message != nil:
// The environment definition is inline JSON. Append the named environments to the import list,
// creating the list if necessary.
var m map[string]any
if err := json.Unmarshal(e.message, &m); err == nil {
imports, _ := m["imports"].([]any)
anys := fx.ToSlice(fx.Map(fx.IterSlice(envs), func(e string) any { return e }))
m["imports"] = append(imports, anys...)
if new, err := json.Marshal(m); err == nil {
e.message = new
}
}
return e
case e.node != nil:
// The environment definition is inline YAML.
// - If there is no import list, add one, then append the named envs to the import list
// - If there is an import list, append the named envs to the import list
root := e.node
if root.Kind == yaml.MappingNode {
var imports *yaml.Node
for i := 0; i < len(root.Content); i += 2 {
key := root.Content[i]
if key.Kind == yaml.ScalarNode && key.Value == "imports" {
imports = root.Content[i+1]
break
}
}
if imports == nil {
root.Content = append([]*yaml.Node{
{
Kind: yaml.ScalarNode,
Style: root.Style,
Tag: "!!str",
Value: "imports",
},
{
Kind: yaml.SequenceNode,
Style: root.Style,
},
}, root.Content...)
imports = root.Content[1]
}
if imports.Kind == yaml.SequenceNode {
nodes := fx.ToSlice(fx.Map(fx.IterSlice(envs), func(env string) *yaml.Node {
return &yaml.Node{
Kind: yaml.ScalarNode,
Style: imports.Style,
Tag: "!!str",
Value: env,
}
}))
imports.Content = append(imports.Content, nodes...)
return e
}
}
return e
default:
// The environment definition is just a list of environments. Append to the list.
e.envs = append(e.envs, envs...)
return e
}
}
func (e *Environment) Remove(env string) *Environment {
switch {
case e == nil:
// There is no environment block, so there's nothing to remove.
return nil
case e.message != nil:
// The environment definition is inline JSON. Find the last occurrence of the named environment in the import
// list and remove it.
var m map[string]any
if err := json.Unmarshal(e.message, &m); err == nil {
if imports, ok := m["imports"].([]any); ok {
for i := len(imports) - 1; i >= 0; i-- {
match := false
switch e := imports[i].(type) {
case string:
match = e == env
case map[string]any:
match = len(e) == 1 && maps.Keys(e)[0] == env
}
if match {
m["imports"] = append(imports[:i], imports[i+1:]...)
if new, err := json.Marshal(m); err == nil {
e.message = new
}
return e
}
}
}
}
return e
case e.node != nil:
// The environment definition is inline YAML. Find the last occurrence of the named environment in the import
// list and remove it.
root := e.node
if root.Kind == yaml.MappingNode {
for i := 0; i < len(root.Content); i += 2 {
key := root.Content[i]
if key.Kind == yaml.ScalarNode && key.Value == "imports" {
value := root.Content[i+1]
if value.Kind == yaml.SequenceNode {
for j := len(value.Content) - 1; j >= 0; j-- {
n := value.Content[j]
match := false
switch n.Kind {
case yaml.ScalarNode:
match = n.Value == env
case yaml.MappingNode:
match = len(n.Content) == 2 && n.Content[0].Value == env
case yaml.SequenceNode, yaml.AliasNode, yaml.DocumentNode:
// These nodes never match, so we can ignore them here.
}
if match {
value.Content = append(value.Content[:j], value.Content[j+1:]...)
if len(value.Content) == 0 {
root.Content = append(root.Content[:i], root.Content[i+2:]...)
}
return e
}
}
}
}
}
}
return e
default:
// The environment definition is just a list of environments. Find the last occurrence of the named environment
// in the list and remove it.
for i := len(e.envs) - 1; i >= 0; i-- {
n := e.envs[i]
if n == env {
e.envs = append(e.envs[:i], e.envs[i+1:]...)
if len(e.envs) == 0 {
return nil
}
return e
}
}
return e
}
}
func (e Environment) MarshalJSON() ([]byte, error) {
if e.message == nil {
return json.Marshal(e.envs)
}
return json.Marshal(e.message)
}
func (e *Environment) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(b, &e.envs); err == nil {
return nil
}
return json.Unmarshal(b, &e.message)
}
func (e Environment) MarshalYAML() (any, error) {
if e.node == nil {
return e.envs, nil
}
return e.node, nil
}
func (e *Environment) UnmarshalYAML(n *yaml.Node) error {
if err := n.Decode(&e.envs); err == nil {
return nil
}
e.node = n
return nil
}
// ProjectStack holds stack specific information about a project.
type ProjectStack struct {
// SecretsProvider is this stack's secrets provider.
SecretsProvider string `json:"secretsprovider,omitempty" yaml:"secretsprovider,omitempty"`
// EncryptedKey is the KMS-encrypted ciphertext for the data key used for secrets encryption.
// Only used for cloud-based secrets providers.
EncryptedKey string `json:"encryptedkey,omitempty" yaml:"encryptedkey,omitempty"`
// EncryptionSalt is this stack's base64 encoded encryption salt. Only used for
// passphrase-based secrets providers.
EncryptionSalt string `json:"encryptionsalt,omitempty" yaml:"encryptionsalt,omitempty"`
// Config is an optional config bag.
Config config.Map `json:"config,omitempty" yaml:"config,omitempty"`
// Environment is an optional environment definition or list of environments.
Environment *Environment `json:"environment,omitempty" yaml:"environment,omitempty"`
// The original byte representation of the file, used to attempt trivia-preserving edits
raw []byte
}
func (ps ProjectStack) EnvironmentBytes() []byte {
return ps.Environment.Definition()
}
func (ps ProjectStack) RawValue() []byte {
return ps.raw
}
// Save writes a project definition to a file.
func (ps *ProjectStack) Save(path string) error {
contract.Requiref(path != "", "path", "must not be empty")
contract.Requiref(ps != nil, "ps", "must not be nil")
return save(path, ps, true /*mkDirAll*/)
}
type ProjectRuntimeInfo struct {
name string
options map[string]interface{}
}
type ProjectStackDeployment struct {
DeploymentSettings apitype.DeploymentSettings `json:"settings" yaml:"settings"`
}
func (psd *ProjectStackDeployment) Save(path string) error {
contract.Requiref(path != "", "path", "must not be empty")
contract.Requiref(psd != nil, "ps", "must not be nil")
return save(path, psd, true /*mkDirAll*/)
}
func NewProjectRuntimeInfo(name string, options map[string]interface{}) ProjectRuntimeInfo {
return ProjectRuntimeInfo{
name: name,
options: options,
}
}
func (info *ProjectRuntimeInfo) Name() string {
return info.name
}
func (info *ProjectRuntimeInfo) Options() map[string]interface{} {
return info.options
}
func (info *ProjectRuntimeInfo) SetOption(key string, value interface{}) {
if info.options == nil {
info.options = make(map[string]interface{})
}
info.options[key] = value
}
func (info ProjectRuntimeInfo) MarshalYAML() (interface{}, error) {
if len(info.options) == 0 {
return info.name, nil
}
return map[string]interface{}{
"name": info.name,
"options": info.options,
}, nil
}
func (info ProjectRuntimeInfo) MarshalJSON() ([]byte, error) {
if len(info.options) == 0 {
return json.Marshal(info.name)
}
return json.Marshal(map[string]interface{}{
"name": info.name,
"options": info.options,
})
}
func (info *ProjectRuntimeInfo) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &info.name); err == nil {
return nil
}
var payload struct {
Name string `json:"name"`
Options map[string]interface{} `json:"options"`
}
if err := json.Unmarshal(data, &payload); err == nil {
info.name = payload.Name
info.options = payload.Options
return nil
}
return errors.New("runtime section must be a string or an object with name and options attributes")
}
func (info *ProjectRuntimeInfo) UnmarshalYAML(unmarshal func(interface{}) error) error {
if err := unmarshal(&info.name); err == nil {
return nil
}
var payload struct {
Name string `yaml:"name"`
Options map[string]interface{} `yaml:"options"`
}
if err := unmarshal(&payload); err == nil {
info.name = payload.Name
info.options = payload.Options
return nil
}
return errors.New("runtime section must be a string or an object with name and options attributes")
}
func marshallerForPath(path string) (encoding.Marshaler, error) {
ext := filepath.Ext(path)
m, has := encoding.Marshalers[ext]
if !has {
return nil, fmt.Errorf("no marshaler found for file format '%v'", ext)
}
return m, nil
}
func save(path string, value interface{}, mkDirAll bool) error {
contract.Requiref(path != "", "path", "must not be empty")
contract.Requiref(value != nil, "value", "must not be nil")
m, err := marshallerForPath(path)
if err != nil {
return err
}
b, err := m.Marshal(value)
if err != nil {
return err
}
if mkDirAll {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
}
//nolint:gosec
return os.WriteFile(path, b, 0o644)
}
// To mitigate an import cycle, we define this here.
const PulumiTagsConfigKey = "pulumi:tags"
// AddConfigStackTags sets the project tags config to the given map of tags.
func (proj *Project) AddConfigStackTags(tags map[string]string) {
if proj.Config == nil {
proj.Config = map[string]ProjectConfigType{}
}
configTags, has := proj.Config["pulumi:tags"]
if !has {
configTags = ProjectConfigType{
Value: map[string]string{},
}
}
if configTags.Value == nil {
configTags.Value = map[string]string{}
}
tagMap, ok := configTags.Value.(map[string]string)
if !ok {
logging.Warningf("overwriting non-object `%s` project config", "pulumi:tags")
tagMap = map[string]string{}
}
for k, v := range tags {
tagMap[k] = v
}
proj.Config["pulumi:tags"] = configTags
}