From 9e35b073746eecb4b22a02e94004885450a8ec13 Mon Sep 17 00:00:00 2001 From: aq17 Date: Fri, 20 Jan 2023 15:24:44 -0800 Subject: [PATCH] Expose `fn::method` function for resource method calls --- CHANGELOG_PENDING.md | 3 ++ pkg/pulumiyaml/analyser.go | 82 +++++++++++++++++++++++++++++++++----- pkg/pulumiyaml/ast/expr.go | 76 +++++++++++++++++++++++++++++++++++ pkg/pulumiyaml/run.go | 32 ++++++++++++++- pkg/pulumiyaml/run_test.go | 31 ++++++++++++++ 5 files changed, 213 insertions(+), 11 deletions(-) diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 99ce4387..19823367 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -7,6 +7,9 @@ provider `docker:Image` resource. [#423](https://github.com/pulumi/pulumi-yaml/pull/423) +- Introduce `fn::method` function for resource methods. + [#431](https://github.com/pulumi/pulumi-yaml/pull/431) + ### Bug Fixes - Avoid panicing for non-string map keys diff --git a/pkg/pulumiyaml/analyser.go b/pkg/pulumiyaml/analyser.go index ea7188cc..ae4bb320 100644 --- a/pkg/pulumiyaml/analyser.go +++ b/pkg/pulumiyaml/analyser.go @@ -64,12 +64,13 @@ func (tc *typeCache) TypeExpr(expr ast.Expr) schema.Type { } type typeCache struct { - resources map[*ast.ResourceDecl]schema.Type - configuration map[string]schema.Type - outputs map[string]schema.Type - exprs map[ast.Expr]schema.Type - resourceNames map[string]*ast.ResourceDecl - variableNames map[string]ast.Expr + resources map[*ast.ResourceDecl]schema.Type + configuration map[string]schema.Type + outputs map[string]schema.Type + exprs map[ast.Expr]schema.Type + resourceNames map[string]*ast.ResourceDecl + resourceMethods map[string][]*schema.Method + variableNames map[string]ast.Expr } func (tc *typeCache) registerResource(name string, resource *ast.ResourceDecl, typ schema.Type) { @@ -574,6 +575,8 @@ func (tc *typeCache) typeResource(r *Runner, node resourceNode) bool { return true } hint := pkg.ResourceTypeHint(typ) + tc.resourceMethods[node.Key.Value] = hint.Resource.Methods + var allProperties []string for _, prop := range hint.Resource.InputProperties { allProperties = append(allProperties, prop.Name) @@ -797,7 +800,7 @@ func (tc *typeCache) typeInvoke(ctx *evalContext, t *ast.InvokeExpr) bool { if o := hint.Outputs; o != nil { for _, output := range o.Properties { fields = append(fields, output.Name) - if strings.ToLower(t.Return.Value) == strings.ToLower(output.Name) { + if strings.EqualFold(t.Return.Value, output.Name) { returnType = output.Type validReturn = true } @@ -821,6 +824,62 @@ func (tc *typeCache) typeInvoke(ctx *evalContext, t *ast.InvokeExpr) bool { return true } +func (tc *typeCache) typeMethodCall(ctx *evalContext, t *ast.MethodExpr) bool { + res := tc.resourceNames[t.ResourceName.Property.String()] + version, err := ParseVersion(res.Options.Version) + if err != nil { + ctx.error(res.Options.Version, fmt.Sprintf("unable to parse resource provider version: %v", err)) + return true + } + // Attempt to resolve the method if a full token is given, i.e. google-native:container/v1:Cluster/getKubeconfig + pkg, functionName, err := ResolveFunction(ctx.pkgLoader, t.FuncToken.Value, version) + if err != nil { + // If invalid, look up resource's methods to derive the token from the name, i.e. getKubeconfig + matched := false + if methods, ok := tc.resourceMethods[t.ResourceName.Property.String()]; ok { + for _, m := range methods { + if m.Name == t.FuncToken.Value { + pkg, functionName, err = ResolveFunction(ctx.pkgLoader, m.Function.Token, version) + t.FuncToken.Value = functionName.String() + matched = true + break + } + } + } + if !matched && err != nil { + _, b := ctx.error(t, err.Error()) + return b + } + } + var existing []string + hint := pkg.FunctionTypeHint(functionName) + inputs := map[string]schema.Type{} + if hint.Inputs != nil { + for _, input := range hint.Inputs.Properties { + existing = append(existing, input.Name) + inputs[input.Name] = input.Type + } + } + fmtr := yamldiags.NonExistantFieldFormatter{ + ParentLabel: fmt.Sprintf("Invoke %s", functionName.String()), + Fields: existing, + MaxElements: 5, + } + if t.CallArgs != nil { + for _, prop := range t.CallArgs.Entries { + k := prop.Key.(*ast.StringExpr).Value + if typ, ok := inputs[k]; !ok { + summary, detail := fmtr.MessageWithDetail(k, k) + subject := prop.Key.Syntax().Syntax().Range() + ctx.addWarnDiag(subject, summary, detail) + } else { + tc.exprs[prop.Value] = typ + } + } + } + return true +} + func (tc *typeCache) typeSymbol(ctx *evalContext, t *ast.SymbolExpr) bool { var typ schema.Type = &schema.InvalidType{} if root, ok := tc.resourceNames[t.Property.RootName()]; ok { @@ -962,6 +1021,8 @@ func (tc *typeCache) typeExpr(ctx *evalContext, t ast.Expr) bool { switch t := t.(type) { case *ast.InvokeExpr: return tc.typeInvoke(ctx, t) + case *ast.MethodExpr: + return tc.typeMethodCall(ctx, t) case *ast.SymbolExpr: return tc.typeSymbol(ctx, t) case *ast.StringExpr: @@ -1158,9 +1219,10 @@ func newTypeCache() *typeCache { }, }, }, - resources: map[*ast.ResourceDecl]schema.Type{}, - configuration: map[string]schema.Type{}, - resourceNames: map[string]*ast.ResourceDecl{}, + resources: map[*ast.ResourceDecl]schema.Type{}, + configuration: map[string]schema.Type{}, + resourceNames: map[string]*ast.ResourceDecl{}, + resourceMethods: map[string][]*schema.Method{}, variableNames: map[string]ast.Expr{ PulumiVarName: pulumiExpr, }, diff --git a/pkg/pulumiyaml/ast/expr.go b/pkg/pulumiyaml/ast/expr.go index b2a8dab2..ec1c271c 100644 --- a/pkg/pulumiyaml/ast/expr.go +++ b/pkg/pulumiyaml/ast/expr.go @@ -472,6 +472,24 @@ func Invoke(token string, callArgs *ObjectExpr, callOpts InvokeOptionsDecl, ret } } +// MethodExpr is a function expression that invokes a Pulumi resource method by type token + resource. +type MethodExpr struct { + builtinNode + + FuncToken *StringExpr + ResourceName *SymbolExpr + CallArgs *ObjectExpr +} + +func MethodSyntax(node *syntax.ObjectNode, name *StringExpr, args *ObjectExpr, token *StringExpr, resourceName *SymbolExpr, callArgs *ObjectExpr) *MethodExpr { + return &MethodExpr{ + builtinNode: builtin(node, name, args), + FuncToken: token, + ResourceName: resourceName, + CallArgs: callArgs, + } +} + // ToJSON returns the underlying structure as a json string. type ToJSONExpr struct { builtinNode @@ -760,6 +778,8 @@ func tryParseFunction(node *syntax.ObjectNode) (Expr, syntax.Diagnostics, bool) switch strings.ToLower(kvp.Key.Value()) { case "fn::invoke": set("fn::invoke", parseInvoke) + case "fn::method": + set("fn::method", parseMethod) case "fn::join": set("fn::join", parseJoin) case "fn::tojson": @@ -898,6 +918,62 @@ func parseInvoke(node *syntax.ObjectNode, name *StringExpr, args Expr) (Expr, sy return InvokeSyntax(node, name, obj, function, arguments, opts, ret), diags } +func parseMethod(node *syntax.ObjectNode, name *StringExpr, args Expr) (Expr, syntax.Diagnostics) { + obj, ok := args.(*ObjectExpr) + if !ok { + return nil, syntax.Diagnostics{ExprError(args, "the argument to fn::method must be an object containing 'resource', 'method', and 'arguments'", "")} + } + var methodExpr, selfExpr, argumentsExpr Expr + var diags syntax.Diagnostics + + for i := 0; i < len(obj.Entries); i++ { + kvp := obj.Entries[i] + if str, ok := kvp.Key.(*StringExpr); ok { + switch strings.ToLower(str.Value) { + case "method": + diags.Extend(syntax.UnexpectedCasing(str.syntax.Syntax().Range(), "method", str.GetValue())) + methodExpr = kvp.Value + case "resource": + diags.Extend(syntax.UnexpectedCasing(str.syntax.Syntax().Range(), "resource", str.GetValue())) + selfExpr = kvp.Value + case "arguments": + diags.Extend(syntax.UnexpectedCasing(str.syntax.Syntax().Range(), "arguments", str.GetValue())) + argumentsExpr = kvp.Value + } + } + } + + method, ok := methodExpr.(*StringExpr) + if !ok { + if methodExpr == nil { + diags.Extend(ExprError(obj, "missing method name ('method')", "")) + } else { + diags.Extend(ExprError(methodExpr, "method name must be a string literal", "")) + } + } + + self, ok := selfExpr.(*SymbolExpr) + if !ok { + if selfExpr == nil { + diags.Extend(ExprError(obj, "missing resource name ('resource')", "")) + } else { + diags.Extend(ExprError(selfExpr, "resource key must reference a declared resource", "")) + } + } + + arguments, ok := argumentsExpr.(*ObjectExpr) + if !ok && argumentsExpr != nil { + diags.Extend(ExprError(argumentsExpr, "function arguments ('arguments') must be an object", "")) + } + + if diags.HasErrors() { + return nil, diags + } + + return MethodSyntax(node, name, obj, method, self, arguments), diags + +} + func parseJoin(node *syntax.ObjectNode, name *StringExpr, args Expr) (Expr, syntax.Diagnostics) { list, ok := args.(*ListExpr) if !ok || len(list.Elements) != 2 { diff --git a/pkg/pulumiyaml/run.go b/pkg/pulumiyaml/run.go index 20a20b7b..b8494c76 100644 --- a/pkg/pulumiyaml/run.go +++ b/pkg/pulumiyaml/run.go @@ -1212,7 +1212,6 @@ func (e *programEvaluator) registerResource(kvp resourceNode) (lateboundResource } isComponent = result } - constants := pkg.ResourceConstants(typ) for k, v := range constants { props[k] = v @@ -1359,6 +1358,8 @@ func (e *programEvaluator) evaluateExpr(x ast.Expr) (interface{}, bool) { return e.evaluatePropertyAccess(x, x.Property) case *ast.InvokeExpr: return e.evaluateBuiltinInvoke(x) + case *ast.MethodExpr: + return e.evaluateBuiltinMethodCall(x) case *ast.JoinExpr: return e.evaluateBuiltinJoin(x) case *ast.SplitExpr: @@ -1758,6 +1759,35 @@ func (e *programEvaluator) evaluateBuiltinInvoke(t *ast.InvokeExpr) (interface{} return performInvoke(args) } +func (e *programEvaluator) evaluateBuiltinMethodCall(t *ast.MethodExpr) (interface{}, bool) { + args, ok := e.evaluateExpr(t.CallArgs) + if !ok { + return nil, false + } + + performCall := e.lift(func(args ...interface{}) (interface{}, bool) { + r, ok := e.evaluateExpr(t.ResourceName) + if !ok { + e.error(t.ResourceName, fmt.Sprintf("unable to resolve resource name: %v", t.ResourceName)) + return nil, false + } + res := r.(lateboundResource) + + _, functionName, err := ResolveFunction(e.pkgLoader, t.FuncToken.Value, nil) + if err != nil { + return e.error(t, err.Error()) + } + + if output, err := e.pulumiCtx.Call(string(functionName), pulumi.ToMap(args[0].(map[string]interface{})), + pulumi.AnyOutput{}, res.CustomResource()); err != nil { + return e.error(t, err.Error()) + } else { + return output, true + } + }) + return performCall(args) +} + func (e *programEvaluator) evaluateBuiltinJoin(v *ast.JoinExpr) (interface{}, bool) { overallOk := true diff --git a/pkg/pulumiyaml/run_test.go b/pkg/pulumiyaml/run_test.go index 82831025..3ad855b7 100644 --- a/pkg/pulumiyaml/run_test.go +++ b/pkg/pulumiyaml/run_test.go @@ -30,6 +30,7 @@ var packageReadmeFile string const testComponentToken = "test:component:type" const testResourceToken = "test:resource:type" +const testResourceWithMethodsToken = "test:resourcewithmethods:type" type MockPackageLoader struct { packages map[string]Package @@ -101,6 +102,22 @@ func inputProperties(token string, props ...schema.Property) *schema.ResourceTyp } } +func inputPropertiesWithMethods(token string, methods []*schema.Method, props ...schema.Property) *schema.ResourceType { + p := make([]*schema.Property, 0, len(props)) + for _, prop := range props { + prop := prop + p = append(p, &prop) + } + return &schema.ResourceType{ + Resource: &schema.Resource{ + Token: token, + InputProperties: p, + Properties: p, + Methods: methods, + }, + } +} + func function(token string, inputs, outputs []schema.Property) *schema.Function { pIn := make([]*schema.Property, 0, len(inputs)) pOut := make([]*schema.Property, 0, len(outputs)) @@ -149,6 +166,20 @@ func newMockPackageMap() PackageLoader { Name: "foo", Type: schema.StringType, }) + case testResourceWithMethodsToken: + return inputPropertiesWithMethods(typeName, []*schema.Method{ + { + Name: "fooFunc", + Function: &schema.Function{ + Token: "test:method:type", + ReturnType: schema.BoolType, + }, + }, + }, + schema.Property{ + Name: "foo", + Type: schema.StringType, + }) default: return inputProperties(typeName) }