Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,61 @@ events that passed the Filter will then trigger an [Action](#Action).
filter: context.data.status.readyReplicas == context.data.status.replicas
```

A filter that is a single `$( )` is a [CEL](https://cel.dev) expression rather than CUE. It reads the same `context`, and
must evaluate to a bool:

```yaml
filter: $(context.data.status.readyReplicas == context.data.status.replicas)
```

Reading a field or map key that is not there is an error, which drops the event and is logged. Guard what only some
objects carry: `has(x.y)` tests field `y` of `x`, which must itself be there, and `"key" in m` tests a map key, including
keys such as `app.oam.dev/name`. CEL's `&&` and `||` skip what cannot change the result, so guards chain:

```yaml
filter: $(has(context.data.metadata.labels) && "tier" in context.data.metadata.labels && context.data.metadata.labels["tier"] == "web")
```

A CEL filter that does not compile stops kube-trigger at start, naming its trigger.

### Expressions in action properties

Action properties can hold `$( )` CEL expressions over the same `context`, rendered for each event. An expression that is
a property's whole value keeps its type; one inside text is joined into it:

```yaml
action:
type: patch-resource
properties:
resource:
apiVersion: apps/v1
kind: Deployment
name: $(context.data.metadata.labels["app"])
namespace: $(context.data.metadata.namespace)
patch:
type: merge
data:
metadata:
annotations:
last-trigger: $(context.event.type)-$(context.data.metadata.name)
```

A literal `$(` in action properties is written `$$(`. This matters for `$(VAR)`, Kubernetes' own syntax for a dependent
environment variable, and for shell substitution in a command, for example `["sh", "-c", "echo $$(date)"]`. An expression
that does not compile, including an unescaped `$(date)`, stops kube-trigger at start, naming its trigger and property.

Expressions in action properties are on by default, and can be turned off:

| Running | How to turn them off |
| --- | --- |
| A standalone kube-trigger | `--enable-cel-expressions=false` |
| One TriggerService | The annotation `trigger.oam.dev/cel-expressions: "false"` on it |
| Every TriggerService | The manager's `--enable-cel-expressions=false` |

The manager's `--require-cel-expression-opt-in` reverses the default: only TriggerServices annotated
`trigger.oam.dev/cel-expressions: "true"` render expressions. The annotation is read when the TriggerService's worker is
created, so set it before creating the TriggerService, or delete the worker Deployment to have it recreated.

### Actions

An Action is a job that does what the user specified when an event happens. For example, the user can send
Expand Down Expand Up @@ -363,6 +418,18 @@ Default: `100`
|-------------------|-----------------|----------------------|
| `--registry-size` | `REGISTRY_SIZE` | `.spec.registrySize` |

### CEL Expressions

Render `$( )` CEL expressions in action properties, where a literal `$(` is written `$$(`. See
[Expressions in action properties](#expressions-in-action-properties). A filter that is a single `$( )` is CEL whatever
this is set to.

Default: `true`

| CLI | ENV | KubeTrigger CRD |
|----------------------------|--------|---------------------------------------------------------------------------|
| `--enable-cel-expressions` | `TODO` | `metadata.annotations["trigger.oam.dev/cel-expressions"]` (`"false"` opts out) |
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

## Roadmap

### v0.0.1-alpha.x
Expand Down
6 changes: 6 additions & 0 deletions api/v1alpha1/triggerservice_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ type TriggerServiceSpec struct {
Triggers []TriggerMeta `json:"triggers"`
}

// AnnotationCELExpressions sets whether one TriggerService's worker renders $( )
// expressions in its actions' properties: "false" opts it out, and "true" opts
// it in where the manager requires opt-in. Any other value leaves the manager's
// default. It takes effect when the TriggerService's worker is next created.
const AnnotationCELExpressions = "trigger.oam.dev/cel-expressions"

// +kubebuilder:object:root=true

// TriggerService is the Schema for the kubetriggerconfigs API.
Expand Down
12 changes: 10 additions & 2 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ func main() {
"Enable leader election for controller manager. "+
"Enabling this will ensure there is only one active controller manager.")

var celExpressions, requireCELExpressionOptIn bool
flag.BoolVar(&celExpressions, "enable-cel-expressions", true,
"Let workers render $( ) CEL expressions in action properties, where a literal $( is written $$(. "+
"A TriggerService annotated "+standardv1alpha1.AnnotationCELExpressions+": \"false\" opts out.")
flag.BoolVar(&requireCELExpressionOptIn, "require-cel-expression-opt-in", false,
"Only render expressions for TriggerServices annotated "+standardv1alpha1.AnnotationCELExpressions+": \"true\".")
opts := zap.Options{
Development: true,
}
Expand Down Expand Up @@ -91,8 +97,10 @@ func main() {
}

if err = (&triggerservice.Reconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
CELExpressions: celExpressions,
RequireCELExpressionOptIn: requireCELExpressionOptIn,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "TriggerService")
os.Exit(1)
Expand Down
4 changes: 4 additions & 0 deletions config/definition/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ spec:
"--workers=\(parameter.config.workers)",
"--log-level=\(parameter.config.logLevel)",
"--multi-cluster-config-type=\(parameter.config.multiClusterConfigType)",
// Set by the manager from its CEL switches and the TriggerService's annotation.
if triggerService.celExpressions != _|_ {
"--enable-cel-expressions=\(triggerService.celExpressions)"
},
]
image: parameter.image
name: "kube-trigger"
Expand Down
85 changes: 85 additions & 0 deletions controllers/triggerservice/cel_expressions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
Copyright 2026 The KubeVela 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 triggerservice

import (
"os"
"testing"

"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"

standardv1alpha1 "github.com/kubevela/kube-trigger/api/v1alpha1"
)

func TestCELExpressionsFor(t *testing.T) {
annotated := func(v string) *standardv1alpha1.TriggerService {
return &standardv1alpha1.TriggerService{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"trigger.oam.dev/cel-expressions": v}}}
}
plain := &standardv1alpha1.TriggerService{}
on := Reconciler{CELExpressions: true}
optInOnly := Reconciler{CELExpressions: true, RequireCELExpressionOptIn: true}

testcases := map[string]struct {
r Reconciler
ts *standardv1alpha1.TriggerService
want bool
}{
"on, no annotation": {r: on, ts: plain, want: true},
"on, opted out": {r: on, ts: annotated("false"), want: false},
"on, other values change nothing": {r: on, ts: annotated("no"), want: true},
"off for every TriggerService": {r: Reconciler{}, ts: annotated("true"), want: false},
"opt-in required, opted in": {r: optInOnly, ts: annotated("true"), want: true},
"opt-in required, not opted in": {r: optInOnly, ts: plain, want: false},
"opt-in required, other value": {r: optInOnly, ts: annotated("yes"), want: false},
}

for name, tc := range testcases {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, tc.r.celExpressionsFor(tc.ts))
})
}
}

// workerArgs renders the default worker template's container args.
func workerArgs(t *testing.T, triggerService map[string]string) []string {
b, err := os.ReadFile("../../config/definition/default.yaml")
require.NoError(t, err)
var def struct {
Spec struct {
Templates map[string]string `json:"templates"`
} `json:"spec"`
}
require.NoError(t, yaml.Unmarshal(b, &def))
v := cuecontext.New().CompileString(def.Spec.Templates["main.cue"])
v = v.FillPath(cue.ParsePath("triggerService"), triggerService)
var args []string
require.NoError(t, v.LookupPath(cue.ParsePath("deployment.spec.template.spec.containers[0].args")).Decode(&args))
return args
}

func TestWorkerTemplatePassesCELExpressions(t *testing.T) {
// The worker defaults to on, so the manager's choice is passed both ways.
for _, v := range []string{"true", "false"} {
args := workerArgs(t, map[string]string{"name": "shop", "namespace": "default", "celExpressions": v})
assert.Contains(t, args, "--enable-cel-expressions="+v)
}
}
31 changes: 29 additions & 2 deletions controllers/triggerservice/triggerservice_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package triggerservice
import (
"context"
"fmt"
"strconv"

"cuelang.org/go/cue"
"github.com/crossplane/crossplane-runtime/pkg/meta"
Expand Down Expand Up @@ -48,6 +49,29 @@ import (
type Reconciler struct {
client.Client
Scheme *runtime.Scheme
// CELExpressions lets workers render $( ) expressions in action
// properties. With it on, a literal $( such as Kubernetes' own $(VAR) is
// written $$(.
CELExpressions bool
// RequireCELExpressionOptIn keeps expressions to TriggerServices annotated
// for them.
RequireCELExpressionOptIn bool
}

// celExpressionsFor reports whether ts's worker renders $( ) expressions: on
// while the manager allows them, unless ts opts out, or, where opt-in is
// required, has not opted in.
func (r *Reconciler) celExpressionsFor(ts *standardv1alpha1.TriggerService) bool {
if !r.CELExpressions {
return false
}
switch ts.Annotations[standardv1alpha1.AnnotationCELExpressions] {
case "false":
return false
case "true":
return true
}
return !r.RequireCELExpressionOptIn
}

var (
Expand Down Expand Up @@ -112,10 +136,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
func (r *Reconciler) createWorker(ctx context.Context, ts *standardv1alpha1.TriggerService) error {
templateName := "default"
opts := make([]cuex.CompileOption, 0)
opts = append(opts, cuex.WithExtraData("triggerService", map[string]string{
triggerService := map[string]string{
"name": ts.Name,
"namespace": ts.Namespace,
}))
}
// The worker's flag defaults to on, so the choice is passed either way.
triggerService["celExpressions"] = strconv.FormatBool(r.celExpressionsFor(ts))
Comment thread
briankane marked this conversation as resolved.
Resolved
opts = append(opts, cuex.WithExtraData("triggerService", triggerService))
if ts.Spec.Worker != nil {
if ts.Spec.Worker.Template != "" {
templateName = ts.Spec.Worker.Template
Expand Down
3 changes: 2 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ vela addon enable kube-trigger
## What we want to achieve?

- use a `resource-watcher` Source to listen to update events of ConfigMaps
- use a `cue-validator` Filter to only keep the ConfigMaps that we are interested in
- use a Filter, written in CUE here (a filter that is a single `$( )` is CEL), to only keep the ConfigMaps that we are
interested in
- trigger an `bump-application-revision` Action to update Application.

As a result:
Expand Down
3 changes: 2 additions & 1 deletion examples/triggerservice-image-update.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ spec:
action:
type: task
properties:
cmd: [/bin/sh, -c, "echo Image: ${SOURCE_NAME} in namespace: ${SOURCE_NAMESPACE} has been successfully rebased at $(date)"]
# $$(date) is the shell's $(date); a single $( would be read as a CEL expression.
cmd: [/bin/sh, -c, "echo Image: ${SOURCE_NAME} in namespace: ${SOURCE_NAMESPACE} has been successfully rebased at $$(date)"]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
image: busybox
name: image-update-task
ttlSecondsAfterFinished: 600
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
k8s.io/klog/v2 v2.140.0
k8s.io/utils v0.0.0-20260626114624-be93311217bd
sigs.k8s.io/controller-runtime v0.25.2
sigs.k8s.io/yaml v1.6.0
)

require (
Expand Down Expand Up @@ -132,7 +133,6 @@ require (
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

replace sigs.k8s.io/apiserver-network-proxy/konnectivity-client => sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.36
9 changes: 6 additions & 3 deletions pkg/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/kubevela/pkg/cue/cuex"
"github.com/kubevela/pkg/util/template/definition"
"github.com/mitchellh/hashstructure/v2"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/kubevela/kube-trigger/api/v1alpha1"
Expand All @@ -43,8 +44,10 @@ var _ executor.Job = &Job{}

// New creates a new job. It will fetch cached Action instance from Registry
// using provided ActionMeta. sourceType and event will be passed to the Action.Run
// method.
func New(ctx context.Context, cli client.Client, meta v1alpha1.ActionMeta, contextData map[string]interface{}) (*Job, error) {
// method. properties are the ones the job runs with, which may be meta's
// rendered for this event; the job's ID comes from meta as written, so jobs
// from one trigger share it.
func New(ctx context.Context, cli client.Client, meta v1alpha1.ActionMeta, properties *runtime.RawExtension, contextData map[string]interface{}) (*Job, error) {
template, err := definition.NewTemplateLoader(ctx, cli).LoadTemplate(ctx, meta.Type, definition.WithType(types.DefinitionTypeTriggerAction))
if err != nil {
return nil, err
Expand All @@ -58,7 +61,7 @@ func New(ctx context.Context, cli client.Client, meta v1alpha1.ActionMeta, conte
template: template.Compile(),
sourceType: meta.Type,
context: contextData,
properties: meta.Properties,
properties: properties,
}

return &ret, nil
Expand Down
17 changes: 13 additions & 4 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ import (

// nolint:revive
const (
FlagLogLevel = "log-level"
FlagConfig = "config"
FlagConfigShort = "c"
FlagLogLevel = "log-level"
// FlagCELExpressions turns on $( ) expressions in action properties.
FlagCELExpressions = "enable-cel-expressions"
FlagConfig = "config"
FlagConfigShort = "c"

FlagQueueSize = "queue-size"
FlagWorkers = "workers"
Expand Down Expand Up @@ -133,6 +135,7 @@ func addFlags(opt *option, f *pflag.FlagSet) {
f.IntVar(&opt.RetryDelay, FlagRetryDelay, defaultRetryDelay, "First delay to retry actions in seconds, subsequent delay will grow exponentially")
f.IntVar(&opt.Timeout, FlagTimeout, defaultTimeout, "Timeout for running each action")
f.IntVar(&opt.RegistrySize, FlagRegistrySize, defaultRegistrySize, "Cache size for filters and actions")
f.BoolVar(&opt.CELExpressions, FlagCELExpressions, true, "Render $( ) CEL expressions in action properties, where a literal $( is written $$(. A filter that is a single $( ) is CEL either way")
Comment thread
briankane marked this conversation as resolved.
Resolved
f.StringVar(&k8sresourcewatcher.MultiClusterConfigType, "multi-cluster-config-type", k8sresourcewatcher.TypeClusterGateway, "Multi-cluster config type, supported types: cluster-gateway, cluster-gateway-kubeconfig")
f.BoolVar(&enableLeaderElection, FlagLeaderElect, false, "Enable leader election for kube-trigger. Enabling this will ensure there is only one active kube-trigger.")
f.DurationVar(&leaseDuration, FlagLeaderElectionLeaseDuration, defaultLeaseDuration, "The duration that non-leader candidates will wait to force acquire leadership.")
Expand Down Expand Up @@ -184,6 +187,9 @@ func run(ctx context.Context) error {
if err != nil {
return errors.Wrapf(err, "cannot validate config")
}
if err = conf.ValidateExpressions(opt.CELExpressions); err != nil {
return errors.Wrapf(err, "cannot validate config")
}

defer utilruntime.HandleCrash()

Expand Down Expand Up @@ -212,7 +218,10 @@ func run(ctx context.Context) error {
}

// Create a EventHandler
eh := eventhandler.NewFromConfig(ctx, cli, w.Action, w.Filter, exe)
eh, err := eventhandler.NewFromConfig(ctx, cli, w.Action, w.Filter, exe, eventhandler.Options{CELExpressions: opt.CELExpressions})
if err != nil {
return errors.Wrapf(err, "cannot set up trigger for source %s", w.Source.Type)
}

// Initialize Source, with user-provided prop and event handler
err = source.Init(w.Source.Properties, eh)
Expand Down
4 changes: 3 additions & 1 deletion pkg/cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ type option struct {
MaxRetry int
RetryDelay int
ActionRetry bool
Timeout int
// CELExpressions renders $( ) expressions in action properties.
CELExpressions bool
Timeout int

RegistrySize int
}
Expand Down
Loading
Loading