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 }