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