mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-03-23 15:25:03 +01:00
Replace expressions engine (#133)
This commit is contained in:
195
internal/templateeval/evaluate.go
Normal file
195
internal/templateeval/evaluate.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package templateeval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
|
||||
exprparser "github.com/actions-oss/act-cli/internal/expr"
|
||||
"github.com/actions-oss/act-cli/pkg/schema"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ExpressionEvaluator struct {
|
||||
RestrictEval bool
|
||||
EvaluationContext v2.EvaluationContext
|
||||
}
|
||||
|
||||
func isImplExpr(snode *schema.Node) bool {
|
||||
def := snode.Schema.GetDefinition(snode.Definition)
|
||||
return def.String != nil && def.String.IsExpression
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateScalarYamlNode(_ context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
|
||||
var in string
|
||||
if err := node.Decode(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expr, isExpr, err := rewriteSubExpression(in, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snode == nil || !isExpr && !isImplExpr(snode) || snode.Schema.GetDefinition(snode.Definition).String.IsExpression || ee.RestrictEval && node.Tag != "!!expr" {
|
||||
return node, nil
|
||||
}
|
||||
parsed, err := exprparser.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canEvaluate := ee.canEvaluate(parsed, snode)
|
||||
if !canEvaluate {
|
||||
node.Tag = "!!expr"
|
||||
return node, nil
|
||||
}
|
||||
|
||||
eval := v2.NewEvaluator(&ee.EvaluationContext)
|
||||
res, err := eval.EvaluateRaw(expr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := &yaml.Node{}
|
||||
if err := ret.Encode(res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.Line = node.Line
|
||||
ret.Column = node.Column
|
||||
// Finally check if we found a schema validation error
|
||||
return ret, snode.UnmarshalYAML(ret)
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) canEvaluate(parsed exprparser.Node, snode *schema.Node) bool {
|
||||
canEvaluate := true
|
||||
for _, v := range snode.GetVariables() {
|
||||
canEvaluate = canEvaluate && ee.EvaluationContext.Variables.Get(v) != nil
|
||||
}
|
||||
for _, v := range snode.GetFunctions() {
|
||||
canEvaluate = canEvaluate && ee.EvaluationContext.Functions.Get(v.Name) != nil
|
||||
}
|
||||
exprparser.VisitNode(parsed, func(node exprparser.Node) {
|
||||
switch el := node.(type) {
|
||||
case *exprparser.FunctionNode:
|
||||
canEvaluate = canEvaluate && ee.EvaluationContext.Functions.Get(el.Name) != nil
|
||||
case *exprparser.ValueNode:
|
||||
canEvaluate = canEvaluate && (el.Kind != exprparser.TokenKindNamedValue || ee.EvaluationContext.Variables.Get(el.Value.(string)) != nil)
|
||||
}
|
||||
})
|
||||
return canEvaluate
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
|
||||
var ret *yaml.Node
|
||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||
for i := 0; i < len(node.Content)/2; i++ {
|
||||
k := node.Content[i*2]
|
||||
var sk string
|
||||
shouldInsert := k.Decode(&sk) == nil && insertDirective.MatchString(sk)
|
||||
changed := func() error {
|
||||
if ret == nil {
|
||||
ret = &yaml.Node{}
|
||||
if err := ret.Encode(node); err != nil {
|
||||
return err
|
||||
}
|
||||
ret.Content = ret.Content[:i*2]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var ek *yaml.Node
|
||||
if !shouldInsert {
|
||||
var err error
|
||||
ek, err = ee.evaluateYamlNodeInternal(ctx, k, snode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ek != nil {
|
||||
if err := changed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
ek = k
|
||||
}
|
||||
}
|
||||
v := node.Content[i*2+1]
|
||||
ev, err := ee.evaluateYamlNodeInternal(ctx, v, snode.GetNestedNode(ek.Value))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ev != nil {
|
||||
if err := changed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
ev = v
|
||||
}
|
||||
// Merge the nested map of the insert directive
|
||||
if shouldInsert {
|
||||
if ev.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
|
||||
}
|
||||
if err := changed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.Content = append(ret.Content, ev.Content...)
|
||||
} else if ret != nil {
|
||||
ret.Content = append(ret.Content, ek, ev)
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
|
||||
var ret *yaml.Node
|
||||
for i := 0; i < len(node.Content); i++ {
|
||||
v := node.Content[i]
|
||||
// Preserve nested sequences
|
||||
wasseq := v.Kind == yaml.SequenceNode
|
||||
ev, err := ee.evaluateYamlNodeInternal(ctx, v, snode.GetNestedNode("*"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ev != nil {
|
||||
if ret == nil {
|
||||
ret = &yaml.Node{}
|
||||
if err := ret.Encode(node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.Content = ret.Content[:i]
|
||||
}
|
||||
// GitHub has this undocumented feature to merge sequences / arrays
|
||||
// We have a nested sequence via evaluation, merge the arrays
|
||||
if ev.Kind == yaml.SequenceNode && !wasseq {
|
||||
ret.Content = append(ret.Content, ev.Content...)
|
||||
} else {
|
||||
ret.Content = append(ret.Content, ev)
|
||||
}
|
||||
} else if ret != nil {
|
||||
ret.Content = append(ret.Content, v)
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
return ee.evaluateScalarYamlNode(ctx, node, snode)
|
||||
case yaml.MappingNode:
|
||||
return ee.evaluateMappingYamlNode(ctx, node, snode)
|
||||
case yaml.SequenceNode:
|
||||
return ee.evaluateSequenceYamlNode(ctx, node, snode)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) error {
|
||||
ret, err := ee.evaluateYamlNodeInternal(ctx, node, snode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ret != nil {
|
||||
return ret.Decode(node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
94
internal/templateeval/evaluate_test.go
Normal file
94
internal/templateeval/evaluate_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package templateeval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
|
||||
"github.com/actions-oss/act-cli/pkg/schema"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestEval(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
yamlInput string
|
||||
restrict bool
|
||||
variables v2.CaseInsensitiveObject[any]
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "NoError",
|
||||
yamlInput: `on: push
|
||||
run-name: ${{ github.ref_name }}
|
||||
jobs:
|
||||
_:
|
||||
name: ${{ github.ref_name }}
|
||||
steps:
|
||||
- run: echo Hello World
|
||||
env:
|
||||
TAG: ${{ env.global }}`,
|
||||
restrict: false,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Error",
|
||||
yamlInput: `on: push
|
||||
run-name: ${{ fromjson('{}') }}
|
||||
jobs:
|
||||
_:
|
||||
name: ${{ github.ref_name }}
|
||||
steps:
|
||||
- run: echo Hello World
|
||||
env:
|
||||
TAG: ${{ env.global }}`,
|
||||
restrict: true,
|
||||
variables: v2.CaseInsensitiveObject[any]{
|
||||
"github": v2.CaseInsensitiveObject[any]{
|
||||
"ref_name": "self",
|
||||
},
|
||||
"vars": v2.CaseInsensitiveObject[any]{},
|
||||
"inputs": v2.CaseInsensitiveObject[any]{},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ee := &ExpressionEvaluator{
|
||||
EvaluationContext: v2.EvaluationContext{
|
||||
Variables: v2.CaseInsensitiveObject[any]{},
|
||||
Functions: v2.GetFunctions(),
|
||||
},
|
||||
}
|
||||
var node yaml.Node
|
||||
err := yaml.Unmarshal([]byte(tc.yamlInput), &node)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
|
||||
Definition: "workflow-root",
|
||||
Schema: schema.GetWorkflowSchema(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
if tc.restrict {
|
||||
ee.RestrictEval = true
|
||||
}
|
||||
if tc.variables != nil {
|
||||
ee.EvaluationContext.Variables = tc.variables
|
||||
}
|
||||
|
||||
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
|
||||
Definition: "workflow-root",
|
||||
Schema: schema.GetWorkflowSchema(),
|
||||
})
|
||||
if tc.expectErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
75
internal/templateeval/rewrite_subexpression.go
Normal file
75
internal/templateeval/rewrite_subexpression.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package templateeval
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func escapeFormatString(in string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
||||
}
|
||||
|
||||
func rewriteSubExpression(in string, forceFormat bool) (result string, isExpr bool, err error) {
|
||||
// missing closing pair is an error
|
||||
if !strings.Contains(in, "${{") {
|
||||
return in, false, nil
|
||||
}
|
||||
|
||||
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
||||
pos := 0
|
||||
exprStart := -1
|
||||
strStart := -1
|
||||
var results []string
|
||||
formatOut := ""
|
||||
for pos < len(in) {
|
||||
if strStart > -1 {
|
||||
matches := strPattern.FindStringIndex(in[pos:])
|
||||
if matches == nil {
|
||||
return "", false, fmt.Errorf("unclosed string at position %d in %s", pos, in)
|
||||
}
|
||||
|
||||
strStart = -1
|
||||
pos += matches[1]
|
||||
} else if exprStart > -1 {
|
||||
exprEnd := strings.Index(in[pos:], "}}")
|
||||
strStart = strings.Index(in[pos:], "'")
|
||||
|
||||
if exprEnd > -1 && strStart > -1 {
|
||||
if exprEnd < strStart {
|
||||
strStart = -1
|
||||
} else {
|
||||
exprEnd = -1
|
||||
}
|
||||
}
|
||||
|
||||
if exprEnd > -1 {
|
||||
formatOut += fmt.Sprintf("{%d}", len(results))
|
||||
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
||||
pos += exprEnd + 2
|
||||
exprStart = -1
|
||||
} else if strStart > -1 {
|
||||
pos += strStart + 1
|
||||
} else {
|
||||
return "", false, fmt.Errorf("unclosed expression at position %d in %s", pos, in)
|
||||
}
|
||||
} else {
|
||||
exprStart = strings.Index(in[pos:], "${{")
|
||||
if exprStart != -1 {
|
||||
formatOut += escapeFormatString(in[pos : pos+exprStart])
|
||||
exprStart = pos + exprStart + 3
|
||||
pos = exprStart
|
||||
} else {
|
||||
formatOut += escapeFormatString(in[pos:])
|
||||
pos = len(in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 1 && formatOut == "{0}" && !forceFormat {
|
||||
return results[0], true, nil
|
||||
}
|
||||
|
||||
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", "))
|
||||
return out, true, nil
|
||||
}
|
||||
115
internal/templateeval/rewrite_subexpression_test.go
Normal file
115
internal/templateeval/rewrite_subexpression_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package templateeval
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRewriteSubExpression_NoExpression(t *testing.T) {
|
||||
in := "Hello world"
|
||||
out, ok, err := rewriteSubExpression(in, false)
|
||||
assert.NoError(t, err)
|
||||
if ok {
|
||||
t.Fatalf("expected ok=false for no expression, got true with output %q", out)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("expected output %q, got %q", in, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_SingleExpression(t *testing.T) {
|
||||
in := "Hello ${{ 'world' }}"
|
||||
out, ok, err := rewriteSubExpression(in, false)
|
||||
assert.NoError(t, err)
|
||||
if !ok {
|
||||
t.Fatalf("expected ok=true for single expression, got false")
|
||||
}
|
||||
expected := "format('Hello {0}', 'world')"
|
||||
if out != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_MultipleExpressions(t *testing.T) {
|
||||
in := "Hello ${{ 'world' }}, you are ${{ 'awesome' }}"
|
||||
out, ok, err := rewriteSubExpression(in, false)
|
||||
assert.NoError(t, err)
|
||||
if !ok {
|
||||
t.Fatalf("expected ok=true for multiple expressions, got false")
|
||||
}
|
||||
expected := "format('Hello {0}, you are {1}', 'world', 'awesome')"
|
||||
if out != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_ForceFormatSingle(t *testing.T) {
|
||||
in := "Hello ${{ 'world' }}"
|
||||
out, ok, err := rewriteSubExpression(in, true)
|
||||
assert.NoError(t, err)
|
||||
if !ok {
|
||||
t.Fatalf("expected ok=true when forceFormat, got false")
|
||||
}
|
||||
expected := "format('Hello {0}', 'world')"
|
||||
if out != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_ForceFormatMultiple(t *testing.T) {
|
||||
in := "Hello ${{ 'world' }}, you are ${{ 'awesome' }}"
|
||||
out, ok, err := rewriteSubExpression(in, true)
|
||||
assert.NoError(t, err)
|
||||
if !ok {
|
||||
t.Fatalf("expected ok=true when forceFormat, got false")
|
||||
}
|
||||
expected := "format('Hello {0}, you are {1}', 'world', 'awesome')"
|
||||
if out != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_UnclosedExpression(t *testing.T) {
|
||||
in := "Hello ${{ 'world' " // missing closing }}
|
||||
_, _, err := rewriteSubExpression(in, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unclosed expression")
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_UnclosedString(t *testing.T) {
|
||||
in := "Hello ${{ 'world }}, you are ${{ 'awesome' }}"
|
||||
_, _, err := rewriteSubExpression(in, false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unclosed string")
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_EscapedStringLiteral(t *testing.T) {
|
||||
// Two single quotes represent an escaped quote inside a string
|
||||
in := "Hello ${{ 'It''s a test' }}"
|
||||
out, ok, err := rewriteSubExpression(in, false)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
expected := "format('Hello {0}', 'It''s a test')"
|
||||
assert.Equal(t, expected, out)
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_ExpressionAtEnd(t *testing.T) {
|
||||
// Expression ends exactly at the string end – should be valid
|
||||
in := "Hello ${{ 'world' }}"
|
||||
out, ok, err := rewriteSubExpression(in, false)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
expected := "format('Hello {0}', 'world')"
|
||||
assert.Equal(t, expected, out)
|
||||
}
|
||||
|
||||
func TestRewriteSubExpression_ExpressionNotAtEnd(t *testing.T) {
|
||||
// Expression followed by additional text – should still be valid
|
||||
in := "Hello ${{ 'world' }}, how are you?"
|
||||
out, ok, err := rewriteSubExpression(in, false)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
expected := "format('Hello {0}, how are you?', 'world')"
|
||||
assert.Equal(t, expected, out)
|
||||
}
|
||||
Reference in New Issue
Block a user