github.com/crossplane-contrib/function-cue@v0.2.2-0.20240508161918-5100fcb5a058/internal/fn/fn.go (about)

     1  // Licensed to Elasticsearch B.V. under one or more contributor
     2  // license agreements. See the NOTICE file distributed with
     3  // this work for additional information regarding copyright
     4  // ownership. Elasticsearch B.V. licenses this file to you under
     5  // the Apache License, Version 2.0 (the "License"); you may
     6  // not use this file except in compliance with the License.
     7  // You may obtain a copy of the License at
     8  //
     9  //     http://www.apache.org/licenses/LICENSE-2.0
    10  //
    11  // Unless required by applicable law or agreed to in writing,
    12  // software distributed under the License is distributed on an
    13  // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    14  // KIND, either express or implied.  See the License for the
    15  // specific language governing permissions and limitations
    16  // under the License.
    17  
    18  package fn
    19  
    20  import (
    21  	"context"
    22  	"fmt"
    23  	"log"
    24  
    25  	"cuelang.org/go/cue"
    26  	"cuelang.org/go/cue/cuecontext"
    27  	"cuelang.org/go/cue/parser"
    28  
    29  	input "github.com/crossplane-contrib/function-cue/input/v1beta1"
    30  	"github.com/crossplane/crossplane-runtime/pkg/logging"
    31  	"github.com/crossplane/function-sdk-go"
    32  	fnv1beta1 "github.com/crossplane/function-sdk-go/proto/v1beta1"
    33  	"github.com/crossplane/function-sdk-go/request"
    34  	"github.com/crossplane/function-sdk-go/response"
    35  	"github.com/pkg/errors"
    36  	"google.golang.org/protobuf/encoding/protojson"
    37  	"google.golang.org/protobuf/types/known/structpb"
    38  )
    39  
    40  const debugAnnotation = "cue.fn.crossplane.io/debug"
    41  
    42  // Options are options for the cue runner.
    43  type Options struct {
    44  	Logger logging.Logger
    45  	Debug  bool
    46  }
    47  
    48  // Cue runs cue scripts that adhere to a specific interface.
    49  type Cue struct {
    50  	fnv1beta1.UnimplementedFunctionRunnerServiceServer
    51  	log   logging.Logger
    52  	debug bool
    53  }
    54  
    55  // New creates a cue runner.
    56  func New(opts Options) (*Cue, error) {
    57  	if opts.Logger == nil {
    58  		var err error
    59  		opts.Logger, err = function.NewLogger(opts.Debug)
    60  		if err != nil {
    61  			return nil, err
    62  		}
    63  	}
    64  	return &Cue{
    65  		log:   opts.Logger,
    66  		debug: opts.Debug,
    67  	}, nil
    68  }
    69  
    70  // DebugOptions are per-eval debug options.
    71  type DebugOptions struct {
    72  	Enabled bool // enable input/ output debugging
    73  	Raw     bool // do not remove any "noise" attributes in the input object
    74  	Script  bool // render the final script as a debug output
    75  }
    76  
    77  type EvalOptions struct {
    78  	RequestVar          string
    79  	ResponseVar         string
    80  	DesiredOnlyResponse bool
    81  	Debug               DebugOptions
    82  }
    83  
    84  // Eval evaluates the supplied script with an additional script that includes the supplied request and returns the
    85  // response.
    86  func (f *Cue) Eval(in *fnv1beta1.RunFunctionRequest, script string, opts EvalOptions) (*fnv1beta1.RunFunctionResponse, error) {
    87  	// input request only contains properties as documented in the interface, not the whole object
    88  	req := &fnv1beta1.RunFunctionRequest{
    89  		Observed: in.GetObserved(),
    90  		Desired:  in.GetDesired(),
    91  		Context:  in.GetContext(),
    92  	}
    93  	// extract request as object
    94  	reqBytes, err := protojson.MarshalOptions{Indent: "  "}.Marshal(req)
    95  	if err != nil {
    96  		return nil, errors.Wrap(err, "proto json marshal")
    97  	}
    98  
    99  	preamble := fmt.Sprintf("%s: ", opts.RequestVar)
   100  	if opts.Debug.Enabled {
   101  		log.Printf("[request:begin]\n%s %s\n[request:end]\n", preamble, f.getDebugString(reqBytes, opts.Debug.Raw))
   102  	}
   103  
   104  	finalScript := fmt.Sprintf("%s\n%s %s\n", script, preamble, reqBytes)
   105  	if opts.Debug.Script {
   106  		log.Printf("[script:begin]\n%s\n[script:end]\n", finalScript)
   107  	}
   108  
   109  	runtime := cuecontext.New()
   110  	val := runtime.CompileBytes([]byte(finalScript))
   111  	if val.Err() != nil {
   112  		return nil, errors.Wrap(val.Err(), "compile cue code")
   113  	}
   114  
   115  	if opts.ResponseVar != "" {
   116  		e, err := parser.ParseExpr("expression", opts.ResponseVar)
   117  		if err != nil {
   118  			return nil, errors.Wrap(err, "parse response expression")
   119  		}
   120  		val = val.Context().BuildExpr(e,
   121  			cue.Scope(val),
   122  			cue.InferBuiltins(true),
   123  		)
   124  		if val.Err() != nil {
   125  			return nil, errors.Wrap(val.Err(), "build response expression")
   126  		}
   127  	}
   128  
   129  	resBytes, err := val.MarshalJSON() // this can fail if value is not concrete
   130  	if err != nil {
   131  		return nil, errors.Wrap(err, "marshal cue output")
   132  	}
   133  	if opts.Debug.Enabled {
   134  		preamble = ""
   135  		if opts.ResponseVar != "" {
   136  			preamble = opts.ResponseVar + ":"
   137  		}
   138  		log.Printf("[response:begin]\n%s %s\n[response:end]\n", preamble, f.getDebugString(resBytes, opts.Debug.Raw))
   139  	}
   140  
   141  	var ret fnv1beta1.RunFunctionResponse
   142  	if opts.DesiredOnlyResponse {
   143  		var state fnv1beta1.State
   144  		err = protojson.Unmarshal(resBytes, &state)
   145  		if err == nil {
   146  			ret.Desired = &state
   147  		}
   148  	} else {
   149  		err = protojson.Unmarshal(resBytes, &ret)
   150  	}
   151  	if err != nil {
   152  		return nil, errors.Wrap(err, "unmarshal cue output using proto json")
   153  	}
   154  	return &ret, nil
   155  }
   156  
   157  // RunFunction runs the function. It expects a single script that is complete, except for a request
   158  // variable that the function runner supplies.
   159  func (f *Cue) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequest) (outRes *fnv1beta1.RunFunctionResponse, finalErr error) {
   160  	// setup response with desired state set up upstream functions
   161  	res := response.To(req, response.DefaultTTL)
   162  
   163  	logger := f.log
   164  	// automatically handle errors and response logging
   165  	defer func() {
   166  		if finalErr == nil {
   167  			logger.Info("cue module executed successfully")
   168  			response.Normal(outRes, "cue module executed successfully")
   169  			return
   170  		}
   171  		logger.Info(finalErr.Error())
   172  		response.Fatal(res, finalErr)
   173  		outRes = res
   174  	}()
   175  
   176  	// setup logging and debugging
   177  	oxr, err := request.GetObservedCompositeResource(req)
   178  	if err != nil {
   179  		return nil, errors.Wrap(err, "get observed composite")
   180  	}
   181  	tag := req.GetMeta().GetTag()
   182  	if tag != "" {
   183  		logger = f.log.WithValues("tag", tag)
   184  	}
   185  	logger = logger.WithValues(
   186  		"xr-version", oxr.Resource.GetAPIVersion(),
   187  		"xr-kind", oxr.Resource.GetKind(),
   188  		"xr-name", oxr.Resource.GetName(),
   189  	)
   190  	logger.Info("Running Function")
   191  	debugThis := false
   192  	annotations := oxr.Resource.GetAnnotations()
   193  	if annotations != nil && annotations[debugAnnotation] == "true" {
   194  		debugThis = true
   195  	}
   196  
   197  	// get inputs
   198  	in := &input.CueInput{}
   199  	if err := request.GetInput(req, in); err != nil {
   200  		return nil, errors.Wrap(err, "unable to get input")
   201  	}
   202  	if in.Script == "" {
   203  		return nil, fmt.Errorf("input script was not specified")
   204  	}
   205  	if in.DebugNew {
   206  		if len(req.GetObserved().GetResources()) == 0 {
   207  			debugThis = true
   208  		}
   209  	}
   210  	// set up the request and response variables
   211  	requestVar := "#request"
   212  	if in.RequestVar != "" {
   213  		requestVar = in.RequestVar
   214  	}
   215  	var responseVar string
   216  	switch in.ResponseVar {
   217  	case ".":
   218  		responseVar = ""
   219  	case "":
   220  		responseVar = "response"
   221  	default:
   222  		responseVar = in.ResponseVar
   223  	}
   224  	state, err := f.Eval(req, in.Script, EvalOptions{
   225  		RequestVar:          requestVar,
   226  		ResponseVar:         responseVar,
   227  		DesiredOnlyResponse: in.LegacyDesiredOnlyResponse,
   228  		Debug: DebugOptions{
   229  			Enabled: f.debug || in.Debug || debugThis,
   230  			Raw:     in.DebugRaw,
   231  			Script:  in.DebugScript,
   232  		},
   233  	})
   234  	if err != nil {
   235  		return res, errors.Wrap(err, "eval script")
   236  	}
   237  	return f.mergeResponse(res, state)
   238  }
   239  
   240  func (f *Cue) mergeResponse(res *fnv1beta1.RunFunctionResponse, cueResponse *fnv1beta1.RunFunctionResponse) (*fnv1beta1.RunFunctionResponse, error) {
   241  	// selectively add returned resources without deleting any previous desired state
   242  	if res.Desired == nil {
   243  		res.Desired = &fnv1beta1.State{}
   244  	}
   245  	if res.Desired.Resources == nil {
   246  		res.Desired.Resources = map[string]*fnv1beta1.Resource{}
   247  	}
   248  	// only set desired composite if the cue script actually returns it
   249  	// TODO: maybe use fieldpath.Pave to only extract status
   250  	if cueResponse.Desired.GetComposite() != nil {
   251  		res.Desired.Composite = cueResponse.Desired.GetComposite()
   252  	}
   253  	// set desired resources from cue output
   254  	for k, v := range cueResponse.Desired.GetResources() {
   255  		res.Desired.Resources[k] = v
   256  	}
   257  	// merge the context if cueResponse has something in it
   258  	if cueResponse.Context != nil {
   259  		ctxMap := map[string]interface{}{}
   260  		// set up base map, if found
   261  		if res.Context != nil {
   262  			ctxMap = res.Context.AsMap()
   263  		}
   264  		// merge values from cueResponse
   265  		for k, v := range cueResponse.Context.AsMap() {
   266  			ctxMap[k] = v
   267  		}
   268  		s, err := structpb.NewStruct(ctxMap)
   269  		if err != nil {
   270  			return nil, errors.Wrap(err, "set response context")
   271  		}
   272  		res.Context = s
   273  	}
   274  	// TODO: allow the cue layer to set warnings in cueResponse?
   275  	return res, nil
   276  }