Application Integration¶
We’ll look at integration of CEL into another application from four perspectives:
We’ll start with Integration Essentials. This is the base case for integration into another application.
In Function Bindings, we’ll look at a more sophisticated integration. This extends the environment with custom functions. This can provide a well-defined interface between CEL expressions and your application’s functionality.
More Examples from the Go implementation shows how extend the environment using new types. Python’s use of duck typing removes some of the complexity of the Go implementation.
There are a few exception and error-handling cases covered in Exceptions and Errors.
The Cloud Custodian (C7N) Integration is rather complicated because the C7N is covers quite a large number of distinct data types.
Finally, External API will review some elements of the API that are part of the integration interface.
Integration Essentials¶
Here’s an example of variable bindings taken from a README
example:
>>> import celpy
>>> cel_source = """
... account.balance >= transaction.withdrawal
... || (account.overdraftProtection
... && account.overdraftLimit >= transaction.withdrawal - account.balance)
... """
>>> env = celpy.Environment()
>>> ast = env.compile(cel_source)
>>> prgm = env.program(ast)
>>> context = {
... "account": celpy.json_to_cel({"balance": 500, "overdraftProtection": False}),
... "transaction": celpy.json_to_cel({"withdrawal": 600})
... }
>>> result = prgm.evaluate(context)
>>> result
BoolType(False)
The cel_source
is an expression to be evaluated.
This references variables with names like account
, and transaction
.
All CEL evaluation uses an celpy.Environment
object.
The celpy.Environment
is used to provide type annotations for variables.
It can provide a few other properties, including an overall package name, sometimes needed when working with protobuf types.
The Environment.compile()
method creates a abstract syntax tree from the CEL source.
This will be used to create a final program to evaluate.
This method can raise the CELSyntaxError
exception.
The Environment.program()
method creates a runner out of an abstract syntax tree.
Compiling and building a program is a two-step process to permit optimization or some other transformation the AST prior to evaluation. The Lark parser (https://lark-parser.readthedocs.io/en/latest/classes.html) is used, and transformers are a first-class feature of this parser.
The context
mapping defines variables and provides their values.
This is used to evaluate the resulting program object.
The program will produce a value defined in the celpy.celtypes
module.
In this example, it’s a celpy.celtypes.BoolType
value.
The CEL types are all specializations of the obvious Python base types. To an extent, these Python classes are partially based on the object model in https://github.com/google/cel-go. We don’t need all the Go formalisms, however, and rely on Pythonic variants.
Simple example using builtin types¶
Here’s an example taken from
https://github.com/google/cel-go/blob/master/examples/README.md.
This will evaluate the CEL expression "Hello world! I'm " + name + "."
with "CEL"
passed as the name
variable.
This is the original Go code:
import (
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker/decls"
)
d := cel.Declarations(decls.NewVar("name", decls.String))
env, err := cel.NewEnv(d)
ast, iss := env.Compile(`"Hello world! I'm " + name + "."`)
// Check iss for compilation errors.
if iss.Err() != nil {
log.Fatalln(iss.Err())
}
prg, err := env.Program(ast)
out, _, err := prg.Eval(map[string]interface{}{
"name": "CEL",
})
fmt.Println(out)
// Output:Hello world! I'm CEL.
Here’s the Python version, following a similar outline:
>>> import celpy
>>> cel_source = """
... "Hello world! I'm " + name + "."
... """
>>> decls = {"name": celpy.celtypes.StringType}
>>> env = celpy.Environment(annotations=decls)
>>> ast = env.compile(cel_source)
>>> prgm = env.program(ast)
>>> context = {
... "name": "CEL"
... }
>>> result = prgm.evaluate(context)
>>> result
"Hello world! I'm CEL."
The steps include:
Create a
celpy.Environment
with annotations for any variables. These kinds of type definitions are atypical for Python, but are part of the definition of the CEL language.Use
celpy.Environment.compile()
to create an AST.Use
celpy.Environment.program()
to build acelpy.Runner
object that will do the final evaluation. This includes the environment and the AST.Use
celpy.Runner.evaluate()
to evaluate the program with specific values for the defined variables.
In the Go world, there’s a formal type adapter to convert input values to the objects used by CEL. For numerous types, a default adapter handles this.
In Python, on the other hand, we define the type conversions as features of the Python versions of the CEL types. This approach fits better with native Python programming.
Function Bindings¶
There are two function binding examples in https://github.com/google/cel-go/blob/master/examples/README.md.
There is a complication here that based on the way the Go resolves overloaded functions.
In Go, each overload of a function is described by a ("name", [args], result)
data structure.
The key of ("name", [args], result)
maps to a specific arg_name_arg()
or name_arg()
overloaded implementation for specific argument types.
This allows for multiple type-specific overload versions of a generic function.
For example, a ("greet", [StringType, StringType], StringType)
structure is expected to map to a function string_greet_string()
that has the expected signature.
This is emphatically not how Python generally works. We follow a more Pythonic approach is to provide a single, generic, function which examines the arguments and decides what to do. Outside type-checking, Python doesn’t depend on overloaded name resolution.
This means a Python function must then sort out type variants and handle argument value coercion on its own.
For most cases, the match/case
statement is helpful for this.
The functools.singledispatch()
decorator can also be helpful for this.
The two examples have slightly different approaches to the CEL expression. These are important in Go, but less important in Python.
Custom function in Go¶
We want to evaluate the CEL expression i.greet(you)
with:
i -> CEL
you -> world
greet -> "Hello %s! Nice to meet you, I'm %s."
The idea here is the new greet()
behaves like a method of a String.
The actual implementation, however, is not a method; it’s a function of two arguments.
First we need to declare two string variables and a greet()
function.
In Go, a NewInstanceOverload
must be used to provide annotations for variables and the function.
Here’s the Go implementation:
decls.NewVar("i", decls.String),
decls.NewVar("you", decls.String),
decls.NewFunction("greet",
decls.NewInstanceOverload("string_greet_string",
[]*exprpb.Type{decls.String, decls.String},
decls.String))
... // Create env and compile
We’ve omitted the Go details of creating an environment and compiling the CEL expression. These aren’t different from the previous examples.
Separately, a greetFunc()
function must be defined.
In Go, this function is then bound to the "string_greet_string"
overload,
ready for evaluation.
Here’s the Go implementation:
greetFunc := &functions.Overload{
Operator: "string_greet_string",
Binary: func(lhs ref.Val, rhs ref.Val) ref.Val {
return types.String(
fmt.Sprintf("Hello %s! Nice to meet you, I'm %s.\n", rhs, lhs))
}}
prg, err := env.Program(c, cel.Functions(greetFunc))
out, _, err := prg.Eval(map[string]interface{}{
"i": "CEL",
"you": "world",
})
fmt.Println(out)
// Output:Hello world! Nice to meet you, I'm CEL.
What’s essential is defining some type information, then defining variables and functions that fit those types.
The Python version has the same outline:
An
celpy.Environment
with type annotations for the two variables and the function.Compile the source.
3. Define the greet()
function. While the CEL syntax of i.greet(you)
looks like a method
of the i
variable’s class, the function is simply has two positional parameters.
Provide function implementation when creating the final
celpy.Runner
instance.Evaluate the program with specific values for the two variables.
>>> import celpy
>>> cel_source = """
... i.greet(you)
... """
>>> decls = {
... "i": celpy.celtypes.StringType,
... "you": celpy.celtypes.StringType,
... "greet": celpy.celtypes.FunctionType}
>>> env = celpy.Environment(annotations=decls)
>>> ast = env.compile(cel_source)
>>> def greet(lhs: celpy.celtypes.StringType, rhs: celpy.celtypes.StringType) -> celpy.celtypes.StringType:
... return "Hello {1:s}! Nice to meet you, I'm {0:s}.\\n".format(lhs, rhs)
>>> prgm = env.program(ast, functions=[greet])
>>> context = {
... "i": "CEL", "you": "world"
... }
>>> result = prgm.evaluate(context)
>>> result
"Hello world! Nice to meet you, I'm CEL.\\n"
The key concept here is to distinguish between three distinct attributes:
Type annotations associated with variables or functions.
The function implementations used to build the
celpy.Runner
. The method-like syntax ofi.greet(you)
is evaluated asgreet(i, you)
.The variable values, which provide a context in which the runner evaluates the CEL expression.
This reflects the idea that one CEL expression may be used to process data over and over again.
Define custom global function¶
In Go, this is a small, but important different.ce
We want to evaluate the expression shake_hands(i,you)
.
This uses a global function syntax instead of method syntax.
While Go has slight differences in how the function is defined, in Python, there is no change.
Here’s the Python version:
>>> import celpy
>>> cel_source = """
... shake_hands(i,you)
... """
>>> decls = {
... "i": celpy.celtypes.StringType,
... "you": celpy.celtypes.StringType,
... "shake_hands": celpy.celtypes.FunctionType}
>>> env = celpy.Environment(annotations=decls)
>>> ast = env.compile(cel_source)
>>> def shake_hands(lhs: celpy.celtypes.StringType, rhs: celpy.celtypes.StringType) -> celpy.celtypes.StringType:
... return f"{lhs} and {rhs} are shaking hands.\\n"
>>> prgm = env.program(ast, functions=[shake_hands])
>>> context = {
... "i": "CEL", "you": "world"
... }
>>> result = prgm.evaluate(context)
>>> result
'CEL and world are shaking hands.\\n'
The shake_hands()
function is essentially the same as the greet()
function in the previous example.
For more examples of how to use CEL from Go, see https://github.com/google/cel-go/tree/master/cel/cel_test.go
More Examples from the Go implementation¶
See https://github.com/google/cel-go/blob/master/README.md for five more examples.
// Check whether a resource name starts with a group name.
resource.name.startsWith("/groups/" + auth.claims.group)
// Determine whether the request is in the permitted time window.
request.time - resource.age < duration("24h")
// Check whether all resource names in a list match a given filter.
auth.claims.email_verified && resources.all(r, r.startsWith(auth.claims.email))
// Ensure all tweets are less than 140 chars
tweets.all(t, t.size() <= 140)
// Test whether the field is a non-default value if proto-based, or defined
// in the JSON case.
has(message.field)
Here’s the first example, resource.name.startsWith("/groups/" + auth.claims.group)
.
The Go code is as follows:
import(
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker/decls"
)
env, err := cel.NewEnv(
cel.Declarations(
decls.NewVar("name", decls.String),
decls.NewVar("group", decls.String)))
ast, issues := env.Compile(`name.startsWith("/groups/" + group)`)
if issues != nil && issues.Err() != nil {
log.Fatalf("type-check error: %s", issues.Err())
}
prg, err := env.Program(ast)
if err != nil {
log.Fatalf("program construction error: %s", err)
}
// The `out` var contains the output of a successful evaluation.
// The `details' var would contain intermediate evaluation state if enabled as
// a cel.ProgramOption. This can be useful for visualizing how the `out` value
// was arrive at.
out, details, err := prg.Eval(map[string]interface{}{
"name": "/groups/acme.co/documents/secret-stuff",
"group": "acme.co"})
fmt.Println(out) // 'true'
This has a Python implementation which is substantially similar. Here’s the Python code:
>>> import celpy
>>> decls = {
... "name": celpy.celtypes.StringType,
... "group": celpy.celtypes.StringType,
... }
>>> env = celpy.Environment(annotations=decls)
>>> ast = env.compile('name.startsWith("/groups/" + group)')
>>> prgm = env.program(ast)
>>> context = {
... "name": "/groups/acme.co/documents/secret-stuff",
... "group": "acme.co",
... }
>>> result = prgm.evaluate(context)
>>> result
BoolType(True)
The general outline of compile, create a celpy.Runner
, and use celpy.Runner.evaluate()
to evaluate the CEL expression in a specific context is the central point here.
Exceptions and Errors¶
Exceptions raised in Python world will (eventually) crash the CEL evaluation. This gives the author of an extension function the complete traceback to help fix the Python code. No masking or rewriting of Python exceptions ever occurs in extension functions.
A special celpy.CELEvalError
exception can be used in an extension function to permit CEL’s short-circuit logic processing to check and ignore an exception.
See the https://github.com/google/cel-go/blob/master/README.md#partial-state for more examples of how the short-circuit (partial state) operations work.
An extension function can return a celpy.CELEvalError
object instead of raising it.
This can allow processing to continue in spite of an uncomputable value.
from celpy import *
def my_extension(a: Value) -> Value:
try:
return celtypes.UintType(64 // a)
except DivideByZeroError as ex:
return CELEvalError(f"my_extension({a}) error")
The returned exception object allows short-circuit processing.
For example, the CEL expression false && my_extension(0)
evaluates to false
.
If computed, any celpy.CELEvalError
objects will be silently ignored because the short-circuit result is known from the presence of a false
value.
On the other hand, the CEL expression true && my_extension(0)
results in the celpy.CELEvalError
result from the extension function.
This will eventually be raised as an exception, so the framework using celpy
can track this run-time error.
Cloud Custodian (C7N) Integration¶
Custodian Filters can be evaluated by CEL. The idea is to extend the YAML-based DSL for policy documents to introduce easier-to-read expressions.
As noted in https://github.com/cloud-custodian/cloud-custodian/issues/5759, a filter might look like the following:
filters:
- type: cel
expr: |
resource.creationTimestamp < timestamp("2018-08-03T16:00:00-07:00") &&
resource.deleteProtection == false &&
((resource.name.startsWith("projects/project-123/zones/us-east1-b/instances/dev") ||
(resource.name.startsWith("projects/project-123/zones/us-east1-b/instances/prod"))) &&
resource.instanceSize == "m1.standard")
This replaces a complex sequence of nested - and:
and - or:
sub-documents with a CEL expression.
C7N processioning works by gathering resources, creating an instance of a subclass of the Filter
class, and evaluating an expression like take_action = list(filter(filter_instance, resource_list))
.
The C7N filter expression in a given policy document is composed of one or more atomic filter clauses, combined by and
, or
, and not
operators.
The filter as a whole is handled by the __call__()
methods of subclasses of the BooleanGroupFilter
class.
Central to making this work is making the CEL expression into a function that can be applied to the resource
object.
All CEL versions of a filter will need to have a the following two values in their activations:
- resource:
A
celtypes.MapType
document with the resource details.- now:
A
celtypes.TimestampType
object with the current time.
Baseline C7N Example¶
The essence of the integration is to provide a resource description to a function defined as a CEL expression, and receive a boolean result.
Here’s a base example:
>>> import celpy
>>> env = celpy.Environment()
>>> CEL = """
... resource.creationTimestamp < timestamp("2018-08-03T16:00:00-07:00") &&
... resource.deleteProtection == false &&
... ((resource.name.startsWith(
... "projects/project-123/zones/us-east1-b/instances/dev") ||
... (resource.name.startsWith(
... "projects/project-123/zones/us-east1-b/instances/prod"))) &&
... resource.instanceSize == "m1.standard")
... """
>>> ast = env.compile(CEL)
>>> functions = {}
>>> prgm = env.program(ast, functions)
>>> activation = {
... "resource":
... celpy.celtypes.MapType({
... "creationTimestamp": celpy.celtypes.TimestampType("2018-07-06T05:04:03Z"),
... "deleteProtection": celpy.celtypes.BoolType(False),
... "name": celpy.celtypes.StringType("projects/project-123/zones/us-east1-b/instances/dev/ec2"),
... "instanceSize": celpy.celtypes.StringType("m1.standard"),
... # MORE WOULD GO HERE
... })
... }
>>> prgm.evaluate(activation)
BoolType(True)
In this case, the context contained only one variable, resource
.
It didn’t require a definition of now
.
Bulk Filter Example¶
Pragmatically, C7N works via code somewhat like the following:
resources = [provider.describe(r) for r in provider.list(resource_type)]
map(action, list(filter(cel_program, resources)))
An action is applied to those resources that pass some filter test. Often, the action disables a resource to prevent data compromise. The filter looks for items not compliant with policies so they can be deleted or disabled.
The cel_program
in the above example is an executable CEL program wrapped into a C7N Filter
subclass.
>>> import celpy
>>> import datetime
>>> cel_functions = {}
>>> class Filter:
... def __call__(self, resource):
... raise NotImplementedError
...
>>> class CelFilter(Filter):
... env = celpy.Environment()
... def __init__(self, object):
... assert object["type"] == "cel", "Can't create CelFilter without filter: - type: \"cel\""
... assert "expr" in object, "Can't create CelFilter without filter: - expr: \"CEL expression\""
... ast = self.env.compile(object["expr"])
... self.prgm = self.env.program(ast, cel_functions)
... def __call__(self, resource):
... now = datetime.datetime.now(tz=datetime.timezone.utc)
... activation = {"resource": celpy.json_to_cel(resource), "now": celpy.celtypes.TimestampType(now)}
... return bool(self.prgm.evaluate(activation))
>>> tag_policy = {
... "filter": {
... "type": "cel",
... "expr": "! has(resource.tags.owner) || size(resource.tags.owner) == 0"
... }
... }
>>> resources = [
... {"name": "good", "tags": {"owner": "me"}},
... {"name": "bad1", "tags": {"not-owner": "oops"}},
... {"name": "bad2", "tags": {"owner": None}},
... ]
>>> tag_policy_filter = CelFilter(tag_policy["filter"])
>>> actionable = list(filter(tag_policy_filter, resources))
>>> actionable
[{'name': 'bad1', 'tags': {'not-owner': 'oops'}}, {'name': 'bad2', 'tags': {'owner': None}}]
For each resource, the tag_policy_filter
object applied an internal self.prgm
to the resource.
The internal self.prgm
was built from the policy expression, stated in CEL.
C7N Filter and Resource Types¶
The celpy.c7nlib
module provides filter subclasses that include CEL processing.
There are two kinds of C7N filters in use.
The
c7n.filters
package defines about 23 generic filter classes. These apply to aresource
object. Additionally, there’s a library of generic functions used for evaluation. Generally, the resource definition classes create values in a JSON document. These values reflect the state of the resource and any closely-related resources.The
c7n.resources
package defines a number of additional resource-specific filters. These classes can also provide additional resource-specific processing.
The atomic filter clauses within a policy document have two general forms:
Those with “op”. These expose a resource attribute value, a filter comparison value, and an operator. For example,
resource.creationTimestamp < timestamp("2018-08-03T16:00:00-07:00")
.Those without “op”. These tests are based on a boolean function embedded in the C7N resource definition class. For example,
! resource.deleteProtection
could rely on a attribute with a complex value computed from one or more resource attribute values.
The breakdown of filter
rules in the C7N policy schema has the following counts.
category |
count |
notes |
---|---|---|
(‘Common’, ‘Op’) |
21 |
Used for more than one resource type, exposes resource details to CEL |
(‘Common’, ‘No-Op’) |
15 |
Used for more than one resource type, does not expose resource details |
(‘Singleton’, ‘Op’) |
27 |
Used for exactly one resource type, exposes resource details to CEL |
(‘Singleton’, ‘No-Op’) |
47 |
Used for exactly one resource type, does not expose resource details |
(This is based on cloud-custodian-0.8.40.0, newer versions may have slighyly different numbers.)
External API¶
The key external components are the following:
-
This has two methods of interest:
-
This has one method of interest:
-
This is used to convert native Python JSON documents to the appropriate CEL types.