pulumi/pkg/codegen/pcl/binder.go

495 lines
14 KiB
Go
Raw Normal View History

// Copyright 2016-2020, 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 pcl
import (
"fmt"
"os"
2023-05-26 10:32:19 +00:00
"path/filepath"
"sort"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/model"
"github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/syntax"
"github.com/pulumi/pulumi/pkg/v3/codegen/schema"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/zclconf/go-cty/cty"
)
all: Reformat with gofumpt Per team discussion, switching to gofumpt. [gofumpt][1] is an alternative, stricter alternative to gofmt. It addresses other stylistic concerns that gofmt doesn't yet cover. [1]: https://github.com/mvdan/gofumpt See the full list of [Added rules][2], but it includes: - Dropping empty lines around function bodies - Dropping unnecessary variable grouping when there's only one variable - Ensuring an empty line between multi-line functions - simplification (`-s` in gofmt) is always enabled - Ensuring multi-line function signatures end with `) {` on a separate line. [2]: https://github.com/mvdan/gofumpt#Added-rules gofumpt is stricter, but there's no lock-in. All gofumpt output is valid gofmt output, so if we decide we don't like it, it's easy to switch back without any code changes. gofumpt support is built into the tooling we use for development so this won't change development workflows. - golangci-lint includes a gofumpt check (enabled in this PR) - gopls, the LSP for Go, includes a gofumpt option (see [installation instrutions][3]) [3]: https://github.com/mvdan/gofumpt#installation This change was generated by running: ```bash gofumpt -w $(rg --files -g '*.go' | rg -v testdata | rg -v compilation_error) ``` The following files were manually tweaked afterwards: - pkg/cmd/pulumi/stack_change_secrets_provider.go: one of the lines overflowed and had comments in an inconvenient place - pkg/cmd/pulumi/destroy.go: `var x T = y` where `T` wasn't necessary - pkg/cmd/pulumi/policy_new.go: long line because of error message - pkg/backend/snapshot_test.go: long line trying to assign three variables in the same assignment I have included mention of gofumpt in the CONTRIBUTING.md.
2023-03-03 16:36:39 +00:00
const (
pulumiPackage = "pulumi"
LogicalNamePropertyKey = "__logicalName"
)
type ComponentProgramBinderArgs struct {
AllowMissingVariables bool
AllowMissingProperties bool
SkipResourceTypecheck bool
SkipInvokeTypecheck bool
SkipRangeTypecheck bool
PreferOutputVersionedInvokes bool
BinderDirPath string
BinderLoader schema.Loader
ComponentSource string
ComponentNodeRange hcl.Range
}
type ComponentProgramBinder = func(ComponentProgramBinderArgs) (*Program, hcl.Diagnostics, error)
type bindOptions struct {
allowMissingVariables bool
allowMissingProperties bool
skipResourceTypecheck bool
skipInvokeTypecheck bool
skipRangeTypecheck bool
preferOutputVersionedInvokes bool
loader schema.Loader
packageCache *PackageCache
// the directory path of the PCL program being bound
// we use this to locate the source of the component blocks
// which refer to a component resource in a relative directory
dirPath string
componentProgramBinder ComponentProgramBinder
}
func (opts bindOptions) modelOptions() []model.BindOption {
var options []model.BindOption
if opts.allowMissingVariables {
options = append(options, model.AllowMissingVariables)
}
if opts.skipRangeTypecheck {
options = append(options, model.SkipRangeTypechecking)
}
return options
}
type binder struct {
options bindOptions
referencedPackages map[string]schema.PackageReference
schemaTypes map[schema.Type]model.Type
tokens syntax.TokenMap
nodes []Node
root *model.Scope
}
type BindOption func(*bindOptions)
func AllowMissingVariables(options *bindOptions) {
options.allowMissingVariables = true
}
func AllowMissingProperties(options *bindOptions) {
options.allowMissingProperties = true
}
func SkipResourceTypechecking(options *bindOptions) {
options.skipResourceTypecheck = true
}
func SkipRangeTypechecking(options *bindOptions) {
options.skipRangeTypecheck = true
}
func PreferOutputVersionedInvokes(options *bindOptions) {
options.preferOutputVersionedInvokes = true
}
func SkipInvokeTypechecking(options *bindOptions) {
options.skipInvokeTypecheck = true
}
func PluginHost(host plugin.Host) BindOption {
return Loader(schema.NewPluginLoader(host))
}
func Loader(loader schema.Loader) BindOption {
return func(options *bindOptions) {
options.loader = loader
}
}
func Cache(cache *PackageCache) BindOption {
return func(options *bindOptions) {
options.packageCache = cache
}
}
func DirPath(path string) BindOption {
return func(options *bindOptions) {
options.dirPath = path
}
}
func ComponentBinder(binder ComponentProgramBinder) BindOption {
return func(options *bindOptions) {
options.componentProgramBinder = binder
}
}
// NonStrictBindOptions returns a set of bind options that make the binder lenient about type checking.
// Changing errors into warnings when possible
func NonStrictBindOptions() []BindOption {
return []BindOption{
AllowMissingVariables,
AllowMissingProperties,
SkipResourceTypechecking,
SkipInvokeTypechecking,
SkipRangeTypechecking,
}
}
// BindProgram performs semantic analysis on the given set of HCL2 files that represent a single program. The given
// host, if any, is used for loading any resource plugins necessary to extract schema information.
func BindProgram(files []*syntax.File, opts ...BindOption) (*Program, hcl.Diagnostics, error) {
var options bindOptions
for _, o := range opts {
o(&options)
}
if options.loader == nil {
cwd, err := os.Getwd()
if err != nil {
return nil, nil, err
}
ctx, err := plugin.NewContext(nil, nil, nil, nil, cwd, nil, false, nil)
if err != nil {
return nil, nil, err
}
options.loader = schema.NewPluginLoader(ctx.Host)
defer contract.IgnoreClose(ctx)
}
if options.packageCache == nil {
options.packageCache = NewPackageCache()
}
b := &binder{
options: options,
tokens: syntax.NewTokenMapForFiles(files),
referencedPackages: map[string]schema.PackageReference{},
schemaTypes: map[schema.Type]model.Type{},
root: model.NewRootScope(syntax.None),
}
// Define null.
b.root.Define("null", &model.Constant{
Name: "null",
ConstantValue: cty.NullVal(cty.DynamicPseudoType),
})
// Define builtin functions.
for name, fn := range pulumiBuiltins(options) {
b.root.DefineFunction(name, fn)
}
// Define the invoke function.
b.root.DefineFunction(Invoke, model.NewFunction(model.GenericFunctionSignature(b.bindInvokeSignature)))
var diagnostics hcl.Diagnostics
// Sort files in source order, then declare all top-level nodes in each.
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
})
for _, f := range files {
fileDiags, err := b.declareNodes(f)
if err != nil {
return nil, nil, err
}
diagnostics = append(diagnostics, fileDiags...)
}
// Now bind the nodes.
for _, n := range b.nodes {
diagnostics = append(diagnostics, b.bindNode(n)...)
}
if diagnostics.HasErrors() {
return nil, diagnostics, diagnostics
}
return &Program{
Nodes: b.nodes,
files: files,
binder: b,
}, diagnostics, nil
}
2023-05-26 10:32:19 +00:00
// Used by language plugins to bind a PCL program in the given directory.
func BindDirectory(
directory string,
loader schema.ReferenceLoader,
extraOptions ...BindOption,
) (*Program, hcl.Diagnostics, error) {
2023-05-26 10:32:19 +00:00
parser := syntax.NewParser()
// Load all .pp files in the directory
files, err := os.ReadDir(directory)
if err != nil {
return nil, nil, err
}
var parseDiagnostics hcl.Diagnostics
2023-05-26 10:32:19 +00:00
for _, file := range files {
if file.IsDir() {
continue
}
fileName := file.Name()
path := filepath.Join(directory, fileName)
if filepath.Ext(path) == ".pp" {
file, err := os.Open(path)
if err != nil {
return nil, nil, err
}
err = parser.ParseFile(file, filepath.Base(path))
if err != nil {
return nil, nil, err
}
parseDiagnostics = append(parseDiagnostics, parser.Diagnostics...)
}
}
if parseDiagnostics.HasErrors() {
return nil, parseDiagnostics, nil
}
opts := []BindOption{
2023-05-26 10:32:19 +00:00
Loader(loader),
DirPath(directory),
ComponentBinder(ComponentProgramBinderFromFileSystem()),
}
opts = append(opts, extraOptions...)
program, bindDiagnostics, err := BindProgram(parser.Files, opts...)
2023-05-26 10:32:19 +00:00
// err will be the same as bindDiagnostics if there are errors, but we don't want to return that here.
// err _could_ also be a context setup error in which case bindDiagnotics will be nil and that we do want to return.
if bindDiagnostics != nil {
err = nil
}
allDiagnostics := append(parseDiagnostics, bindDiagnostics...)
return program, allDiagnostics, err
}
func makeObjectPropertiesOptional(objectType *model.ObjectType) *model.ObjectType {
for property, propertyType := range objectType.Properties {
if !model.IsOptionalType(propertyType) {
objectType.Properties[property] = model.NewOptionalType(propertyType)
}
}
return objectType
}
// declareNodes declares all of the top-level nodes in the given file. This includes config, resources, outputs, and
// locals.
// Temporarily, we load all resources first, as convert sets the highest package version seen
// under all resources' options. Once this is supported for invokes, the order of declaration will not
// impact which package is actually loaded.
func (b *binder) declareNodes(file *syntax.File) (hcl.Diagnostics, error) {
var diagnostics hcl.Diagnostics
for _, item := range model.SourceOrderBody(file.Body) {
switch item := item.(type) {
case *hclsyntax.Block:
switch item.Type {
case "resource":
if len(item.Labels) != 2 {
diagnostics = append(diagnostics, labelsErrorf(item, "resource variables must have exactly two labels"))
}
resource := &Resource{
syntax: item,
}
declareDiags := b.declareNode(item.Labels[0], resource)
diagnostics = append(diagnostics, declareDiags...)
if err := b.loadReferencedPackageSchemas(resource); err != nil {
return nil, err
}
}
}
}
for _, item := range model.SourceOrderBody(file.Body) {
switch item := item.(type) {
case *hclsyntax.Attribute:
v := &LocalVariable{syntax: item}
attrDiags := b.declareNode(item.Name, v)
diagnostics = append(diagnostics, attrDiags...)
if err := b.loadReferencedPackageSchemas(v); err != nil {
return nil, err
}
case *hclsyntax.Block:
switch item.Type {
case "config":
name, typ := "<unnamed>", model.Type(model.DynamicType)
switch len(item.Labels) {
case 1:
name = item.Labels[0]
case 2:
name = item.Labels[0]
typeExpr, diags := model.BindExpressionText(item.Labels[1], model.TypeScope, item.LabelRanges[1].Start)
diagnostics = append(diagnostics, diags...)
if typeExpr == nil {
return diagnostics, fmt.Errorf("cannot bind expression: %v", diagnostics)
}
typ = typeExpr.Type()
switch configType := typ.(type) {
case *model.ObjectType:
typ = makeObjectPropertiesOptional(configType)
case *model.ListType:
switch elementType := configType.ElementType.(type) {
case *model.ObjectType:
modifiedElementType := makeObjectPropertiesOptional(elementType)
typ = model.NewListType(modifiedElementType)
}
case *model.MapType:
switch elementType := configType.ElementType.(type) {
case *model.ObjectType:
modifiedElementType := makeObjectPropertiesOptional(elementType)
typ = model.NewMapType(modifiedElementType)
}
}
default:
diagnostics = append(diagnostics, labelsErrorf(item, "config variables must have exactly one or two labels"))
}
// TODO(pdg): check body for valid contents
v := &ConfigVariable{
typ: typ,
syntax: item,
}
diags := b.declareNode(name, v)
diagnostics = append(diagnostics, diags...)
if err := b.loadReferencedPackageSchemas(v); err != nil {
return nil, err
}
case "output":
name, typ := "<unnamed>", model.Type(model.DynamicType)
switch len(item.Labels) {
case 1:
name = item.Labels[0]
case 2:
name = item.Labels[0]
typeExpr, diags := model.BindExpressionText(item.Labels[1], model.TypeScope, item.LabelRanges[1].Start)
diagnostics = append(diagnostics, diags...)
typ = typeExpr.Type()
default:
diagnostics = append(diagnostics, labelsErrorf(item, "config variables must have exactly one or two labels"))
}
v := &OutputVariable{
typ: typ,
syntax: item,
}
diags := b.declareNode(name, v)
diagnostics = append(diagnostics, diags...)
if err := b.loadReferencedPackageSchemas(v); err != nil {
return nil, err
}
case "component":
if len(item.Labels) != 2 {
diagnostics = append(diagnostics, labelsErrorf(item, "components must have exactly two labels"))
continue
}
name := item.Labels[0]
source := item.Labels[1]
v := &Component{
2023-06-08 19:43:54 +00:00
name: name,
syntax: item,
source: source,
VariableType: model.DynamicType,
}
diags := b.declareNode(name, v)
diagnostics = append(diagnostics, diags...)
}
}
}
return diagnostics, nil
}
// Evaluate a constant string attribute that's internal to Pulumi, e.g.: the Logical Name on a resource or output.
func getStringAttrValue(attr *model.Attribute) (string, *hcl.Diagnostic) {
switch lit := attr.Syntax.Expr.(type) {
case *hclsyntax.LiteralValueExpr:
if lit.Val.Type() != cty.String {
return "", stringAttributeError(attr)
}
return lit.Val.AsString(), nil
case *hclsyntax.TemplateExpr:
if len(lit.Parts) != 1 {
return "", stringAttributeError(attr)
}
part, ok := lit.Parts[0].(*hclsyntax.LiteralValueExpr)
if !ok || part.Val.Type() != cty.String {
return "", stringAttributeError(attr)
}
return part.Val.AsString(), nil
default:
return "", stringAttributeError(attr)
}
}
// Returns the value of constant boolean attribute
func getBooleanAttributeValue(attr *model.Attribute) (bool, *hcl.Diagnostic) {
switch lit := attr.Syntax.Expr.(type) {
case *hclsyntax.LiteralValueExpr:
if lit.Val.Type() != cty.Bool {
return false, boolAttributeError(attr)
}
return lit.Val.True(), nil
default:
return false, boolAttributeError(attr)
}
}
// declareNode declares a single top-level node. If a node with the same name has already been declared, it returns an
// appropriate diagnostic.
func (b *binder) declareNode(name string, n Node) hcl.Diagnostics {
if !b.root.Define(name, n) {
existing, _ := b.root.BindReference(name)
return hcl.Diagnostics{errorf(existing.SyntaxNode().Range(), "%q already declared", name)}
}
b.nodes = append(b.nodes, n)
return nil
}
func (b *binder) bindExpression(node hclsyntax.Node) (model.Expression, hcl.Diagnostics) {
return model.BindExpression(node, b.root, b.tokens, b.options.modelOptions()...)
}
func modelExprToString(expr *model.Expression) string {
if expr == nil {
return ""
}
return (*expr).(*model.TemplateExpression).
Parts[0].(*model.LiteralValueExpression).Value.AsString()
}