Files
hatchet/internal/cel/cel.go
matt d6f8be2c0f Feat: OLAP Table for CEL Eval Failures (#2012)
* feat: add table, wire up partitioning

* feat: wire failures into the OLAP db from rabbit

* feat: bubble failures up to controller

* fix: naming

* fix: hack around enum type

* fix: typo

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: typos

* fix: migration name

* feat: log debug failure

* feat: pub message from debug endpoint to log failure

* fix: error handling

* fix: use ingestor

* fix: olap suffix

* fix: pass source through

* fix: dont log ingest failure

* fix: rm debug as enum opt

* chore: gen

* Feat: Webhooks (#1978)

* feat: migration + go gen

* feat: non unique source name

* feat: api types

* fix: rm cruft

* feat: initial api for webhooks

* feat: handle encryption of incoming keys

* fix: nil pointer errors

* fix: import

* feat: add endpoint for incoming webhooks

* fix: naming

* feat: start wiring up basic auth

* feat: wire up cel event parsing

* feat: implement authentication

* fix: hack for plain text content

* feat: add source to enum

* feat: add source name enum

* feat: db source name enum fix

* fix: use source name enums

* feat: nest sources

* feat: first pass at stripe

* fix: clean up source name passing

* fix: use unique name for webhook

* feat: populator test

* fix: null values

* fix: ordering

* fix: rm unnecessary index

* fix: validation

* feat: validation on create

* fix: lint

* fix: naming

* feat: wire triggering webhook name through to events table

* feat: cleanup + python gen + e2e test for basic auth

* feat: query to insert webhook validation errors

* refactor: auth handler

* fix: naming

* refactor: validation errors, part II

* feat: wire up writes through olap

* fix: linting, fallthrough case

* fix: validation

* feat: tests for failure cases for basic auth

* feat: expand tests

* fix: correctly return 404 out of task getter

* chore: generated stuff

* fix: rm cruft

* fix: longer sleep

* debug: print name + events to logs

* feat: limit to N

* feat: add limit env var

* debug: ci test

* fix: apply namespaces to keys

* fix: namespacing, part ii

* fix: sdk config

* fix: handle prefixing

* feat: handle partitioning logic

* chore: gen

* feat: add webhook limit

* feat: wire up limits

* fix: gen

* fix: reverse order of generic fallthrough

* fix: comment for potential unexpected behavior

* fix: add check constraints, improve error handling

* chore: gen

* chore: gen

* fix: improve naming

* feat: scaffold webhooks page

* feat: sidebar

* feat: first pass at page

* feat: improve feedback on UI

* feat: initial work on create modal

* feat: change default to basic

* fix: openapi spec discriminated union

* fix: go side

* feat: start wiring up placeholders for stripe and github

* feat: pre-populated fields for Stripe + Github

* feat: add name section

* feat: copy improvements, show URL

* feat: UI cleanup

* fix: check if tenant populator errors

* feat: add comments

* chore: gen again

* fix: default name

* fix: styling

* fix: improve stripe header processing

* feat: docs, part 1

* fix: lint

* fix: migration order

* feat: implement rate limit per-webhook

* feat: comment

* feat: clean up docs

* chore: gen

* fix: migration versions

* fix: olap naming

* fix: partitions

* chore: gen

* feat: store webhook cel eval failures properly

* fix: pk order

* fix: auth tweaks, move fetches out of populator

* fix: pgtype.Text instead of string pointer

* chore: gen

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-30 13:27:38 -04:00

393 lines
9.4 KiB
Go

package cel
import (
"crypto/sha256"
"fmt"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker/decls"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/ext"
"github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc"
"github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1"
expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
)
type CELParser struct {
workflowStrEnv *cel.Env
stepRunEnv *cel.Env
eventEnv *cel.Env
incomingWebhookEnv *cel.Env
}
var checksumDecl = decls.NewFunction("checksum",
decls.NewOverload("checksum_string",
[]*expr.Type{decls.String},
decls.String,
),
)
var checksum = cel.Function("checksum",
cel.MemberOverload(
"checksum_string_impl",
[]*cel.Type{cel.StringType},
cel.StringType,
cel.FunctionBinding(func(args ...ref.Val) ref.Val {
if len(args) != 1 {
return types.NewErr("checksum requires 1 argument")
}
str, ok := args[0].(types.String)
if !ok {
return types.NewErr("argument must be a string")
}
hash := sha256.Sum256([]byte(str))
return types.String(fmt.Sprintf("%x", hash))
})),
)
func NewCELParser() *CELParser {
workflowStrEnv, _ := cel.NewEnv(
cel.Declarations(
decls.NewVar("input", decls.NewMapType(decls.String, decls.Dyn)),
decls.NewVar("additional_metadata", decls.NewMapType(decls.String, decls.Dyn)),
decls.NewVar("workflow_run_id", decls.String),
checksumDecl,
),
checksum,
ext.Strings(),
)
stepRunEnv, _ := cel.NewEnv(
cel.Declarations(
decls.NewVar("input", decls.NewMapType(decls.String, decls.Dyn)),
decls.NewVar("additional_metadata", decls.NewMapType(decls.String, decls.Dyn)),
decls.NewVar("parents", decls.NewMapType(decls.String, decls.NewMapType(decls.String, decls.Dyn))),
decls.NewVar("workflow_run_id", decls.String),
checksumDecl,
),
checksum,
ext.Strings(),
)
eventEnv, _ := cel.NewEnv(
cel.Declarations(
decls.NewVar("input", decls.NewMapType(decls.String, decls.Dyn)),
decls.NewVar("additional_metadata", decls.NewMapType(decls.String, decls.Dyn)),
decls.NewVar("payload", decls.NewMapType(decls.String, decls.Dyn)),
decls.NewVar("event_id", decls.String),
decls.NewVar("event_key", decls.String),
checksumDecl,
),
ext.Strings(),
)
incomingWebhookEnv, _ := cel.NewEnv(
cel.Declarations(
decls.NewVar("input", decls.NewMapType(decls.String, decls.Dyn)),
checksumDecl,
),
)
return &CELParser{
workflowStrEnv: workflowStrEnv,
stepRunEnv: stepRunEnv,
eventEnv: eventEnv,
incomingWebhookEnv: incomingWebhookEnv,
}
}
type Input map[string]interface{}
type InputOpts func(Input)
func WithInput(input map[string]interface{}) InputOpts {
return func(w Input) {
w["input"] = input
}
}
func WithParents(parents any) InputOpts {
return func(w Input) {
w["parents"] = parents
}
}
func WithAdditionalMetadata(metadata map[string]interface{}) InputOpts {
return func(w Input) {
w["additional_metadata"] = metadata
}
}
func WithWorkflowRunID(workflowRunID string) InputOpts {
return func(w Input) {
w["workflow_run_id"] = workflowRunID
}
}
func WithPayload(payload map[string]interface{}) InputOpts {
return func(w Input) {
w["payload"] = payload
}
}
func WithEventID(eventID string) InputOpts {
return func(w Input) {
w["event_id"] = eventID
}
}
func WithEventKey(key string) InputOpts {
return func(w Input) {
w["event_key"] = key
}
}
func NewInput(opts ...InputOpts) Input {
res := make(map[string]interface{})
for _, opt := range opts {
opt(res)
}
return res
}
func (p *CELParser) ParseWorkflowString(workflowExp string) (cel.Program, error) {
ast, issues := p.workflowStrEnv.Compile(workflowExp)
if issues != nil && issues.Err() != nil {
return nil, issues.Err()
}
return p.workflowStrEnv.Program(ast)
}
func (p *CELParser) ParseAndEvalWorkflowString(workflowExp string, in Input) (string, error) {
prg, err := p.ParseWorkflowString(workflowExp)
if err != nil {
return "", err
}
var inMap map[string]interface{} = in
out, _, err := prg.Eval(inMap)
if err != nil {
return "", err
}
// Switch on the type of the output.
switch out.Type() {
case types.StringType:
return out.Value().(string), nil
default:
return "", fmt.Errorf("output must evaluate to a string: got %s", out.Type().TypeName())
}
}
type StepRunOutType string
const (
StepRunOutTypeString StepRunOutType = "string"
StepRunOutTypeInt StepRunOutType = "int"
)
type StepRunOut struct {
String *string
Int *int
Type StepRunOutType
}
func (p *CELParser) ParseStepRun(stepRunExpr string) (cel.Program, error) {
ast, issues := p.stepRunEnv.Compile(stepRunExpr)
if issues != nil && issues.Err() != nil {
return nil, issues.Err()
}
return p.stepRunEnv.Program(ast)
}
func (p *CELParser) ParseAndEvalStepRun(stepRunExpr string, in Input) (*StepRunOut, error) {
prg, err := p.ParseWorkflowString(stepRunExpr)
if err != nil {
return nil, err
}
var inMap map[string]interface{} = in
out, _, err := prg.Eval(inMap)
if err != nil {
return nil, err
}
res := &StepRunOut{}
switch out.Type() {
case cel.StringType:
str := out.Value().(string)
res.String = &str
res.Type = StepRunOutTypeString
case cel.IntType:
i := int(out.Value().(int64))
res.Int = &i
res.Type = StepRunOutTypeInt
case cel.DoubleType:
i := int(out.Value().(float64))
res.Int = &i
res.Type = StepRunOutTypeInt
default:
return nil, fmt.Errorf("output must evaluate to a string or integer: got %s", out.Type().TypeName())
}
return res, nil
}
func (p *CELParser) CheckStepRunOutAgainstKnown(out *StepRunOut, knownType dbsqlc.StepExpressionKind) error {
switch knownType {
case dbsqlc.StepExpressionKindDYNAMICRATELIMITKEY:
if out.String == nil {
prefix := "expected string output for dynamic rate limit key"
if out.Int != nil {
return fmt.Errorf("%s, got int", prefix)
}
return fmt.Errorf("%s, got unknown type", prefix)
}
case dbsqlc.StepExpressionKindDYNAMICRATELIMITVALUE:
if out.Int == nil {
prefix := "expected int output for dynamic rate limit value"
if out.String != nil {
return fmt.Errorf("%s, got string", prefix)
}
return fmt.Errorf("%s, got unknown type", prefix)
}
case dbsqlc.StepExpressionKindDYNAMICRATELIMITWINDOW:
if out.String == nil {
prefix := "expected string output for dynamic rate limit window"
if out.Int != nil {
return fmt.Errorf("%s, got int", prefix)
}
return fmt.Errorf("%s, got unknown type", prefix)
}
case dbsqlc.StepExpressionKindDYNAMICRATELIMITUNITS:
if out.Int == nil {
prefix := "expected int output for dynamic rate limit units"
if out.String != nil {
return fmt.Errorf("%s, got string", prefix)
}
return fmt.Errorf("%s, got unknown type", prefix)
}
}
return nil
}
func (p *CELParser) CheckStepRunOutAgainstKnownV1(out *StepRunOut, knownType sqlcv1.StepExpressionKind) error {
switch knownType {
case sqlcv1.StepExpressionKindDYNAMICRATELIMITKEY:
if out.String == nil {
prefix := "expected string output for dynamic rate limit key"
if out.Int != nil {
return fmt.Errorf("%s, got int", prefix)
}
return fmt.Errorf("%s, got unknown type", prefix)
}
case sqlcv1.StepExpressionKindDYNAMICRATELIMITVALUE:
if out.Int == nil {
prefix := "expected int output for dynamic rate limit value"
if out.String != nil {
return fmt.Errorf("%s, got string", prefix)
}
return fmt.Errorf("%s, got unknown type", prefix)
}
case sqlcv1.StepExpressionKindDYNAMICRATELIMITWINDOW:
if out.String == nil {
prefix := "expected string output for dynamic rate limit window"
if out.Int != nil {
return fmt.Errorf("%s, got int", prefix)
}
return fmt.Errorf("%s, got unknown type", prefix)
}
case sqlcv1.StepExpressionKindDYNAMICRATELIMITUNITS:
if out.Int == nil {
prefix := "expected int output for dynamic rate limit units"
if out.String != nil {
return fmt.Errorf("%s, got string", prefix)
}
return fmt.Errorf("%s, got unknown type", prefix)
}
}
return nil
}
func (p *CELParser) EvaluateEventExpression(expr string, input Input) (bool, error) {
ast, issues := p.eventEnv.Compile(expr)
if issues != nil && issues.Err() != nil {
return false, fmt.Errorf("failed to compile expression: %w", issues.Err())
}
program, err := p.eventEnv.Program(ast)
if err != nil {
return false, fmt.Errorf("failed to create program: %w", err)
}
var inMap map[string]interface{} = input
out, _, err := program.Eval(inMap)
if err != nil {
return false, fmt.Errorf("failed to evaluate expression: %w", err)
}
if out.Type() != types.BoolType {
return false, fmt.Errorf("expression did not evaluate to a boolean: got %s", out.Type().TypeName())
}
return out.Value().(bool), nil
}
func (p *CELParser) EvaluateIncomingWebhookExpression(expr string, input Input) (string, error) {
ast, issues := p.eventEnv.Compile(expr)
if issues != nil && issues.Err() != nil {
return "", fmt.Errorf("failed to compile expression: %w", issues.Err())
}
program, err := p.eventEnv.Program(ast)
if err != nil {
return "", fmt.Errorf("failed to create program: %w", err)
}
var inMap map[string]interface{} = input
out, _, err := program.Eval(inMap)
if err != nil {
return "", fmt.Errorf("failed to evaluate expression: %w", err)
}
if out.Type() != types.StringType {
return "", fmt.Errorf("expression did not evaluate to a string: got %s", out.Type().TypeName())
}
return out.Value().(string), nil
}