Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions go/fn/cel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright 2023 The kpt Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package fn

import (
"fmt"
"reflect"

"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
// TODO: including this requires many dependency updates, at some point
// we should do that so the CEL evaluation here is consistent with
// K8s. There are a few other lines to uncomment in that case.
//"k8s.io/apiserver/pkg/cel/library"
)

const (
PkgContextVarName = "package"
UntypedItemsVarName = "items"
)

func (ko *KubeObject) ToUntyped() (interface{}, error) {
return ko.obj.ToUntyped()
}

func (rl *ResourceList) ResolveCEL(celExpr string) (string, error) {
inputs, err := rl.untypedCELInputs()
if err != nil {
return "", err
}

return evalExpr(celExpr, inputs)
}

func (rl *ResourceList) untypedCELInputs() (map[string]interface{}, error) {
inputs := make(map[string]interface{})
var items []interface{}

for _, ko := range rl.Items {
ut, err := ko.ToUntyped()
if err != nil {
return nil, err
}
items = append(items, ut)
}

inputs[UntypedItemsVarName] = items

return inputs, nil
}

func evalExpr(expr string, inputs map[string]interface{}) (string, error) {
prog, err := compileExpr(expr)
if err != nil {
return "", err
}

val, _, err := prog.Eval(inputs)
if err != nil {
return "", err
}

result, err := val.ConvertToNative(reflect.TypeOf(""))
if err != nil {
return "", err
}

s, ok := result.(string)
if !ok {
return "", fmt.Errorf("expression returned non-string value: %v", result)
}

return s, nil
}

func unaryWithFunction(name string, adapter types.Adapter, path ...string) cel.EnvOption {
fnname := fmt.Sprintf("with_%s", name)
id := fmt.Sprintf("resourcelist_with_%s_string", name)
return cel.Function(fnname,
cel.MemberOverload(id, []*cel.Type{cel.ListType(cel.DynType), cel.StringType}, cel.DynType,
cel.BinaryBinding(func(arg1, arg2 ref.Val) ref.Val {
list := arg1.(traits.Lister)
result := types.NewDynamicList(adapter, []any{})

// loop through the list items to select the ones that match our criteria
i := list.Iterator()
for v := i.Next(); v != nil; v = i.Next() {
mapper, ok := v.(traits.Mapper)
if !ok {
// if the entry is not a mapper, just skip it
continue
}
// navigate through mappers for each field except the last
for _, field := range path[:len(path)-1] {
vv, ok := mapper.Find(adapter.NativeToValue(field))
if !ok {
// no value found for the field name
// skip this list entry
mapper = nil
break
}
mapper, ok = vv.(traits.Mapper)
if !ok {
// value found for the field name is not a mapper
// skip this list entry
mapper = nil
break
}
}
if mapper == nil {
// we could not successfully navigate the path, skip this list entry
continue
}
// now pull the last field from the path; the result should be our
// value you we want to check against
testVal, ok := mapper.Find(adapter.NativeToValue(path[len(path)-1]))
if !ok {
// no such field, skip this list entry
continue
}
// found the test value, compare it to the argument
// and add it to the results list if found
if testVal.Equal(arg2) == types.True {
newResult := result.Add(types.NewDynamicList(adapter, []any{v}))
result, ok = newResult.(traits.Lister)
if !ok {
continue
}
}
}
if result.Size() == types.IntOne {
// if we found exactly one result, then return that result rather
// than a list of one entry, avoiding the need to use the [0] indexing
// notation
return result.Get(types.IntZero)
}

// return the list result, so we can chain these
return result
}),
),
)
}

// compileExpr returns a compiled CEL expression.
func compileExpr(expr string) (cel.Program, error) {
var opts []cel.EnvOption
opts = append(opts, cel.HomogeneousAggregateLiterals())
opts = append(opts, cel.EagerlyValidateDeclarations(true), cel.DefaultUTCTimeZone(true))

// TODO: uncomment after updating to latest k8s
//opts = append(opts, library.ExtensionLibs...)

opts = append(opts, cel.Variable(UntypedItemsVarName, cel.ListType(cel.DynType)))

env, err := cel.NewEnv(opts...)
if err != nil {
return nil, err
}

env, err = env.Extend(
unaryWithFunction("apiVersion", env.CELTypeAdapter(), "apiVersion"),
unaryWithFunction("kind", env.CELTypeAdapter(), "kind"),
unaryWithFunction("name", env.CELTypeAdapter(), "metadata", "name"),
unaryWithFunction("namespace", env.CELTypeAdapter(), "metadata", "namespace"),
)
if err != nil {
return nil, err
}

ast, issues := env.Compile(expr)
if issues != nil {
return nil, issues.Err()
}

_, err = cel.AstToCheckedExpr(ast)
if err != nil {
return nil, err
}
return env.Program(ast,
cel.EvalOptions(cel.OptOptimize),
// TODO: uncomment after updating to latest k8s
//cel.OptimizeRegex(library.ExtensionLibRegexOptimizations...),
)
}
150 changes: 150 additions & 0 deletions go/fn/cel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright 2022 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package fn

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestResolveCEL(t *testing.T) {
rl, err := ParseResourceList([]byte(`
apiVersion: config.kubernetes.io/v1
kind: ResourceList
items:
- apiVersion: v1
kind: Namespace
metadata:
name: example
- apiVersion: v1
kind: Namespace
metadata:
name: example2
annotations:
foo: bar
- apiVersion: v1
kind: ConfigMap
metadata:
name: example2
namespace: ns-1
- apiVersion: v1
kind: ConfigMap
metadata:
name: example2
namespace: ns-2
data:
mykey: myvalue
- apiVersion: v1
kind: ConfigMap
metadata:
name: example3
namespace: ns-2
- apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
hey: there
annotations:
config.kubernetes.io/index: '0'
config.kubernetes.io/path: 'resources.yaml'
internal.config.kubernetes.io/index: '0'
internal.config.kubernetes.io/path: 'resources.yaml'
internal.config.kubernetes.io/seqindent: 'compact'
spec:
replicas: 3
selector:
matchLabels:
app: nginx
paused: true
strategy:
type: Recreate
template:
metadata:
labels:
app: nginx
spec:
nodeSelector:
disktype: ssd
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
fakeStringSlice:
- test1
- test2
`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

testCases := map[string]struct {
expr string
expResult string
expError string
}{
"string literal": {
expr: `"foo"`,
expResult: "foo",
},
"int literal": {
expr: `15`,
expError: "unsupported type conversion from 'int' to string",
},
"int literal conversion": {
expr: `string(15)`,
expResult: "15",
},
"string var": {
expr: `items.filter(i, i.kind == "Deployment")[0].spec.strategy.type`,
expResult: "Recreate",
},
"with_kind": {
expr: `items.with_kind("Deployment").spec.strategy.type`,
expResult: "Recreate",
},
"with_name": {
expr: `items.with_name("nginx-deployment").spec.strategy.type`,
expResult: "Recreate",
},
"with_namespace.with_name": {
expr: `items.with_namespace("ns-2").with_name("example2").data.mykey`,
expResult: "myvalue",
},
"with_kind.with_name": {
expr: `items.with_kind("Namespace").with_name("example2").metadata.annotations["foo"]`,
expResult: "bar",
},
"with_apiversion.with_name.with_kind": {
expr: `items.with_apiVersion("v1").with_name("example2").with_kind("Namespace").metadata.annotations["foo"]`,
expResult: "bar",
},
}

for tn, tc := range testCases {
t.Run(tn, func(t *testing.T) {
result, err := rl.ResolveCEL(tc.expr)
if tc.expError != "" {
assert.EqualError(t, err, tc.expError)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expResult, result)
}
})
}
}
15 changes: 11 additions & 4 deletions go/fn/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/go-errors/errors v1.0.1
github.com/google/go-cmp v0.5.9
github.com/stretchr/testify v1.8.0
google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5 // indirect
k8s.io/apimachinery v0.24.0
// We must not include any core k8s APIs (e.g. k8s.io/api) in
// the dependencies, depending on them will likely to cause version skew for
Expand All @@ -16,16 +17,19 @@ require (

)

require github.com/google/cel-go v0.18.0

require (
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-logr/logr v1.2.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/swag v0.21.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kr/pretty v0.3.0 // indirect
Expand All @@ -34,10 +38,13 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/xlab/treeprint v1.1.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/text v0.7.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230803162519-f966b187b2e5 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
Loading