github.com/nektos/act@v0.2.83/pkg/runner/expression.go (about) 1 package runner 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "path" 8 "reflect" 9 "regexp" 10 "strings" 11 "time" 12 13 _ "embed" 14 15 "github.com/nektos/act/pkg/common" 16 "github.com/nektos/act/pkg/container" 17 "github.com/nektos/act/pkg/exprparser" 18 "github.com/nektos/act/pkg/model" 19 "gopkg.in/yaml.v3" 20 ) 21 22 // ExpressionEvaluator is the interface for evaluating expressions 23 type ExpressionEvaluator interface { 24 evaluate(context.Context, string, exprparser.DefaultStatusCheck) (interface{}, error) 25 EvaluateYamlNode(context.Context, *yaml.Node) error 26 Interpolate(context.Context, string) string 27 } 28 29 // NewExpressionEvaluator creates a new evaluator 30 func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) ExpressionEvaluator { 31 return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv()) 32 } 33 34 func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map[string]string) ExpressionEvaluator { 35 var workflowCallResult map[string]*model.WorkflowCallResult 36 37 // todo: cleanup EvaluationEnvironment creation 38 using := make(map[string]exprparser.Needs) 39 strategy := make(map[string]interface{}) 40 if rc.Run != nil { 41 job := rc.Run.Job() 42 if job != nil && job.Strategy != nil { 43 strategy["fail-fast"] = job.Strategy.FailFast 44 strategy["max-parallel"] = job.Strategy.MaxParallel 45 } 46 47 jobs := rc.Run.Workflow.Jobs 48 jobNeeds := rc.Run.Job().Needs() 49 50 for _, needs := range jobNeeds { 51 using[needs] = exprparser.Needs{ 52 Outputs: jobs[needs].Outputs, 53 Result: jobs[needs].Result, 54 } 55 } 56 57 // only setup jobs context in case of workflow_call 58 // and existing expression evaluator (this means, jobs are at 59 // least ready to run) 60 if rc.caller != nil && rc.ExprEval != nil { 61 workflowCallResult = map[string]*model.WorkflowCallResult{} 62 63 for jobName, job := range jobs { 64 result := model.WorkflowCallResult{ 65 Outputs: map[string]string{}, 66 } 67 for k, v := range job.Outputs { 68 result.Outputs[k] = v 69 } 70 workflowCallResult[jobName] = &result 71 } 72 } 73 } 74 75 ghc := rc.getGithubContext(ctx) 76 inputs := getEvaluatorInputs(ctx, rc, nil, ghc) 77 78 ee := &exprparser.EvaluationEnvironment{ 79 Github: ghc, 80 Env: env, 81 Job: rc.getJobContext(), 82 Jobs: &workflowCallResult, 83 // todo: should be unavailable 84 // but required to interpolate/evaluate the step outputs on the job 85 Steps: rc.getStepsContext(), 86 Secrets: getWorkflowSecrets(ctx, rc), 87 Vars: getWorkflowVars(ctx, rc), 88 Strategy: strategy, 89 Matrix: rc.Matrix, 90 Needs: using, 91 Inputs: inputs, 92 HashFiles: getHashFilesFunction(ctx, rc), 93 } 94 if rc.JobContainer != nil { 95 ee.Runner = rc.JobContainer.GetRunnerContext(ctx) 96 } 97 return expressionEvaluator{ 98 interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ 99 Run: rc.Run, 100 WorkingDir: rc.Config.Workdir, 101 Context: "job", 102 }), 103 } 104 } 105 106 //go:embed hashfiles/index.js 107 var hashfiles string 108 109 // NewStepExpressionEvaluator creates a new evaluator 110 func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) ExpressionEvaluator { 111 return rc.NewStepExpressionEvaluatorExt(ctx, step, false) 112 } 113 114 // NewStepExpressionEvaluatorExt creates a new evaluator 115 func (rc *RunContext) NewStepExpressionEvaluatorExt(ctx context.Context, step step, rcInputs bool) ExpressionEvaluator { 116 ghc := rc.getGithubContext(ctx) 117 if rcInputs { 118 return rc.newStepExpressionEvaluator(ctx, step, ghc, getEvaluatorInputs(ctx, rc, nil, ghc)) 119 } 120 return rc.newStepExpressionEvaluator(ctx, step, ghc, getEvaluatorInputs(ctx, rc, step, ghc)) 121 } 122 123 func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, _ *model.GithubContext, inputs map[string]interface{}) ExpressionEvaluator { 124 // todo: cleanup EvaluationEnvironment creation 125 job := rc.Run.Job() 126 strategy := make(map[string]interface{}) 127 if job.Strategy != nil { 128 strategy["fail-fast"] = job.Strategy.FailFast 129 strategy["max-parallel"] = job.Strategy.MaxParallel 130 } 131 132 jobs := rc.Run.Workflow.Jobs 133 jobNeeds := rc.Run.Job().Needs() 134 135 using := make(map[string]exprparser.Needs) 136 for _, needs := range jobNeeds { 137 using[needs] = exprparser.Needs{ 138 Outputs: jobs[needs].Outputs, 139 Result: jobs[needs].Result, 140 } 141 } 142 143 ee := &exprparser.EvaluationEnvironment{ 144 Github: step.getGithubContext(ctx), 145 Env: *step.getEnv(), 146 Job: rc.getJobContext(), 147 Steps: rc.getStepsContext(), 148 Secrets: getWorkflowSecrets(ctx, rc), 149 Vars: getWorkflowVars(ctx, rc), 150 Strategy: strategy, 151 Matrix: rc.Matrix, 152 Needs: using, 153 // todo: should be unavailable 154 // but required to interpolate/evaluate the inputs in actions/composite 155 Inputs: inputs, 156 HashFiles: getHashFilesFunction(ctx, rc), 157 } 158 if rc.JobContainer != nil { 159 ee.Runner = rc.JobContainer.GetRunnerContext(ctx) 160 } 161 return expressionEvaluator{ 162 interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ 163 Run: rc.Run, 164 WorkingDir: rc.Config.Workdir, 165 Context: "step", 166 }), 167 } 168 } 169 170 func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.Value) (interface{}, error) { 171 hashFiles := func(v []reflect.Value) (interface{}, error) { 172 if rc.JobContainer != nil { 173 timeed, cancel := context.WithTimeout(ctx, time.Minute) 174 defer cancel() 175 name := "workflow/hashfiles/index.js" 176 hout := &bytes.Buffer{} 177 herr := &bytes.Buffer{} 178 patterns := []string{} 179 followSymlink := false 180 181 for i, p := range v { 182 s := p.String() 183 if i == 0 { 184 if strings.HasPrefix(s, "--") { 185 if strings.EqualFold(s, "--follow-symbolic-links") { 186 followSymlink = true 187 continue 188 } 189 return "", fmt.Errorf("Invalid glob option %s, available option: '--follow-symbolic-links'", s) 190 } 191 } 192 patterns = append(patterns, s) 193 } 194 env := map[string]string{} 195 for k, v := range rc.Env { 196 env[k] = v 197 } 198 env["patterns"] = strings.Join(patterns, "\n") 199 if followSymlink { 200 env["followSymbolicLinks"] = "true" 201 } 202 203 stdout, stderr := rc.JobContainer.ReplaceLogWriter(hout, herr) 204 _ = rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{ 205 Name: name, 206 Mode: 0o644, 207 Body: hashfiles, 208 }). 209 Then(rc.execJobContainer([]string{rc.GetNodeToolFullPath(ctx), path.Join(rc.JobContainer.GetActPath(), name)}, 210 env, "", "")). 211 Finally(func(context.Context) error { 212 rc.JobContainer.ReplaceLogWriter(stdout, stderr) 213 return nil 214 })(timeed) 215 output := hout.String() + "\n" + herr.String() 216 guard := "__OUTPUT__" 217 outstart := strings.Index(output, guard) 218 if outstart != -1 { 219 outstart += len(guard) 220 outend := strings.Index(output[outstart:], guard) 221 if outend != -1 { 222 return output[outstart : outstart+outend], nil 223 } 224 } 225 } 226 return "", nil 227 } 228 return hashFiles 229 } 230 231 type expressionEvaluator struct { 232 interpreter exprparser.Interpreter 233 } 234 235 func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (interface{}, error) { 236 logger := common.Logger(ctx) 237 logger.Debugf("evaluating expression '%s'", in) 238 evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck) 239 240 printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%t", evaluated), "::add-mask::***)") 241 logger.Debugf("expression '%s' evaluated to '%s'", in, printable) 242 243 return evaluated, err 244 } 245 246 func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) { 247 var in string 248 if err := node.Decode(&in); err != nil { 249 return nil, err 250 } 251 if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { 252 return nil, nil 253 } 254 expr, _ := rewriteSubExpression(ctx, in, false) 255 res, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone) 256 if err != nil { 257 return nil, err 258 } 259 ret := &yaml.Node{} 260 if err := ret.Encode(res); err != nil { 261 return nil, err 262 } 263 return ret, err 264 } 265 266 func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) { 267 var ret *yaml.Node 268 // GitHub has this undocumented feature to merge maps, called insert directive 269 insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`) 270 for i := 0; i < len(node.Content)/2; i++ { 271 changed := func() error { 272 if ret == nil { 273 ret = &yaml.Node{} 274 if err := ret.Encode(node); err != nil { 275 return err 276 } 277 ret.Content = ret.Content[:i*2] 278 } 279 return nil 280 } 281 k := node.Content[i*2] 282 v := node.Content[i*2+1] 283 ev, err := ee.evaluateYamlNodeInternal(ctx, v) 284 if err != nil { 285 return nil, err 286 } 287 if ev != nil { 288 if err := changed(); err != nil { 289 return nil, err 290 } 291 } else { 292 ev = v 293 } 294 var sk string 295 // Merge the nested map of the insert directive 296 if k.Decode(&sk) == nil && insertDirective.MatchString(sk) { 297 if ev.Kind != yaml.MappingNode { 298 return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind) 299 } 300 if err := changed(); err != nil { 301 return nil, err 302 } 303 ret.Content = append(ret.Content, ev.Content...) 304 } else { 305 ek, err := ee.evaluateYamlNodeInternal(ctx, k) 306 if err != nil { 307 return nil, err 308 } 309 if ek != nil { 310 if err := changed(); err != nil { 311 return nil, err 312 } 313 } else { 314 ek = k 315 } 316 if ret != nil { 317 ret.Content = append(ret.Content, ek, ev) 318 } 319 } 320 } 321 return ret, nil 322 } 323 324 func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) { 325 var ret *yaml.Node 326 for i := 0; i < len(node.Content); i++ { 327 v := node.Content[i] 328 // Preserve nested sequences 329 wasseq := v.Kind == yaml.SequenceNode 330 ev, err := ee.evaluateYamlNodeInternal(ctx, v) 331 if err != nil { 332 return nil, err 333 } 334 if ev != nil { 335 if ret == nil { 336 ret = &yaml.Node{} 337 if err := ret.Encode(node); err != nil { 338 return nil, err 339 } 340 ret.Content = ret.Content[:i] 341 } 342 // GitHub has this undocumented feature to merge sequences / arrays 343 // We have a nested sequence via evaluation, merge the arrays 344 if ev.Kind == yaml.SequenceNode && !wasseq { 345 ret.Content = append(ret.Content, ev.Content...) 346 } else { 347 ret.Content = append(ret.Content, ev) 348 } 349 } else if ret != nil { 350 ret.Content = append(ret.Content, v) 351 } 352 } 353 return ret, nil 354 } 355 356 func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node) (*yaml.Node, error) { 357 switch node.Kind { 358 case yaml.ScalarNode: 359 return ee.evaluateScalarYamlNode(ctx, node) 360 case yaml.MappingNode: 361 return ee.evaluateMappingYamlNode(ctx, node) 362 case yaml.SequenceNode: 363 return ee.evaluateSequenceYamlNode(ctx, node) 364 default: 365 return nil, nil 366 } 367 } 368 369 func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error { 370 ret, err := ee.evaluateYamlNodeInternal(ctx, node) 371 if err != nil { 372 return err 373 } 374 if ret != nil { 375 return ret.Decode(node) 376 } 377 return nil 378 } 379 380 func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string { 381 if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { 382 return in 383 } 384 385 expr, _ := rewriteSubExpression(ctx, in, true) 386 evaluated, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone) 387 if err != nil { 388 common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", expr, err) 389 return "" 390 } 391 392 value, ok := evaluated.(string) 393 if !ok { 394 panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr)) 395 } 396 397 return value 398 } 399 400 // EvalBool evaluates an expression against given evaluator 401 func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) { 402 nextExpr, _ := rewriteSubExpression(ctx, expr, false) 403 404 evaluated, err := evaluator.evaluate(ctx, nextExpr, defaultStatusCheck) 405 if err != nil { 406 return false, err 407 } 408 409 return exprparser.IsTruthy(evaluated), nil 410 } 411 412 func escapeFormatString(in string) string { 413 return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}") 414 } 415 416 func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { 417 if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { 418 return in, nil 419 } 420 421 strPattern := regexp.MustCompile("(?:''|[^'])*'") 422 pos := 0 423 exprStart := -1 424 strStart := -1 425 var results []string 426 formatOut := "" 427 for pos < len(in) { 428 if strStart > -1 { 429 matches := strPattern.FindStringIndex(in[pos:]) 430 if matches == nil { 431 panic("unclosed string.") 432 } 433 434 strStart = -1 435 pos += matches[1] 436 } else if exprStart > -1 { 437 exprEnd := strings.Index(in[pos:], "}}") 438 strStart = strings.Index(in[pos:], "'") 439 440 if exprEnd > -1 && strStart > -1 { 441 if exprEnd < strStart { 442 strStart = -1 443 } else { 444 exprEnd = -1 445 } 446 } 447 448 if exprEnd > -1 { 449 formatOut += fmt.Sprintf("{%d}", len(results)) 450 results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd])) 451 pos += exprEnd + 2 452 exprStart = -1 453 } else if strStart > -1 { 454 pos += strStart + 1 455 } else { 456 panic("unclosed expression.") 457 } 458 } else { 459 exprStart = strings.Index(in[pos:], "${{") 460 if exprStart != -1 { 461 formatOut += escapeFormatString(in[pos : pos+exprStart]) 462 exprStart = pos + exprStart + 3 463 pos = exprStart 464 } else { 465 formatOut += escapeFormatString(in[pos:]) 466 pos = len(in) 467 } 468 } 469 } 470 471 if len(results) == 1 && formatOut == "{0}" && !forceFormat { 472 return in, nil 473 } 474 475 out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", ")) 476 if in != out { 477 common.Logger(ctx).Debugf("expression '%s' rewritten to '%s'", in, out) 478 } 479 return out, nil 480 } 481 482 func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]interface{} { 483 inputs := map[string]interface{}{} 484 485 setupWorkflowInputs(ctx, &inputs, rc) 486 487 var env map[string]string 488 if step != nil { 489 env = *step.getEnv() 490 } else { 491 env = rc.GetEnv() 492 } 493 494 for k, v := range env { 495 if strings.HasPrefix(k, "INPUT_") { 496 inputs[strings.ToLower(strings.TrimPrefix(k, "INPUT_"))] = v 497 } 498 } 499 500 if rc.caller == nil && ghc.EventName == "workflow_dispatch" { 501 config := rc.Run.Workflow.WorkflowDispatchConfig() 502 if config != nil && config.Inputs != nil { 503 for k, v := range config.Inputs { 504 value := nestedMapLookup(ghc.Event, "inputs", k) 505 if value == nil { 506 value = v.Default 507 } 508 if v.Type == "boolean" { 509 inputs[k] = value == "true" 510 } else { 511 inputs[k] = value 512 } 513 } 514 } 515 } 516 517 if ghc.EventName == "workflow_call" { 518 config := rc.Run.Workflow.WorkflowCallConfig() 519 if config != nil && config.Inputs != nil { 520 for k, v := range config.Inputs { 521 value := nestedMapLookup(ghc.Event, "inputs", k) 522 if value == nil { 523 if err := v.Default.Decode(&value); err != nil { 524 common.Logger(ctx).Debugf("error decoding default value for %s: %v", k, err) 525 } 526 } 527 if v.Type == "boolean" { 528 inputs[k] = value == "true" 529 } else { 530 inputs[k] = value 531 } 532 } 533 } 534 } 535 return inputs 536 } 537 538 func setupWorkflowInputs(ctx context.Context, inputs *map[string]interface{}, rc *RunContext) { 539 if rc.caller != nil { 540 config := rc.Run.Workflow.WorkflowCallConfig() 541 542 for name, input := range config.Inputs { 543 value := rc.caller.runContext.Run.Job().With[name] 544 545 if value != nil { 546 node := yaml.Node{} 547 _ = node.Encode(value) 548 if rc.caller.runContext.ExprEval != nil { 549 // evaluate using the calling RunContext (outside) 550 _ = rc.caller.runContext.ExprEval.EvaluateYamlNode(ctx, &node) 551 } 552 _ = node.Decode(&value) 553 } 554 555 if value == nil && config != nil && config.Inputs != nil { 556 def := input.Default 557 if rc.ExprEval != nil { 558 // evaluate using the called RunContext (inside) 559 _ = rc.ExprEval.EvaluateYamlNode(ctx, &def) 560 } 561 _ = def.Decode(&value) 562 } 563 564 (*inputs)[name] = value 565 } 566 } 567 } 568 569 func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string { 570 if rc.caller != nil { 571 job := rc.caller.runContext.Run.Job() 572 secrets := job.Secrets() 573 574 if secrets == nil && job.InheritSecrets() { 575 secrets = rc.caller.runContext.Config.Secrets 576 } 577 578 if secrets == nil { 579 secrets = map[string]string{} 580 } 581 582 for k, v := range secrets { 583 secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v) 584 } 585 586 return secrets 587 } 588 589 return rc.Config.Secrets 590 } 591 592 func getWorkflowVars(_ context.Context, rc *RunContext) map[string]string { 593 return rc.Config.Vars 594 }