mirror of https://github.com/pulumi/pulumi.git
1174 lines
35 KiB
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
|
|
}
|