Compare commits

..
37 Commits
Author SHA1 Message Date
kubernetes-prow[bot] 5a0830ae0b Merge pull request #1897 from Mukuwul/fix/nodeutilization-notsupported-error-message
fix(nodeutilization): correct misleading notSupportedError message
2026-08-29 13:34:05 +00:00
kubernetes-prow[bot] 9914e71ece Merge pull request #1884 from NPastorale/master
feat(deployment): adds revisionHistoryLimit as a configurable parameter
2026-08-29 11:04:03 +00:00
kubernetes-prow[bot] 8bbd0bd661 Merge pull request #1914 from a7i/refactor/e2e-dry-helpers
refactor(test): extract e2e test scripts into reusable lib modules
2026-08-25 11:23:43 +00:00
kubernetes-prow[bot] b66b54f6c0 Merge pull request #1906 from mattburgess/fix-logging-alpha-options
Fix LoggingAlphaOptions wiring
2026-08-25 11:23:36 +00:00
kubernetes-prow[bot] 5aca81876e Merge pull request #1892 from somaz94/feat/chart-host-users
feat: add opt-in hostUsers to the Helm chart
2026-08-24 18:45:01 +00:00
kubernetes-prow[bot] ef090e93de Merge pull request #1915 from PKNaveen/code-cleanup-EvictionRequests
(Code-cleanup) removed EvictionRequests
2026-08-18 11:53:05 +00:00
somaz bdb6f3e42d feat: add opt-in hostUsers to the Helm chart 2026-08-13 10:34:58 +09:00
kubernetes-prow[bot] 4233637de9 Merge pull request #1890 from somaz94/feat/chart-scheduler-runtime-class
feat: add opt-in schedulerName and runtimeClassName to the Helm chart
2026-08-11 12:07:14 +00:00
PKNaveen 6fe697934e removed EvictionRequests 2026-08-11 11:19:34 +00:00
Amir Alavi fb908401dd refactor(test): extract e2e test scripts into reusable lib modules
Split run-e2e-tests.sh into test/lib helpers for kind setup, dependency
install, shared utilities, and centralized version defaults.
2026-08-10 16:56:26 -04:00
kubernetes-prow[bot] ad19067697 Merge pull request #1913 from a7i/centralize-kubevirt-version
refactor(e2e): centralize kubevirt version from go.mod
2026-08-10 15:46:09 +00:00
Amir Alavi 73bcc99a5e refactor(e2e): centralize kubevirt version from go.mod
Derive KUBEVIRT_VERSION in run-e2e-tests.sh from go.mod and pass it to
e2e tests via --kubevirt-version-tag so container disk images stay in sync.
2026-08-10 10:13:12 -04:00
kubernetes-prow[bot] 82b96a392e Merge pull request #1910 from PKNaveen/myfeature
Updated Kubivert to latest v1.9.0
2026-08-10 14:12:10 +00:00
Amir Alavi 5c3469c3ff Bump kubevirt dependencies to v1.9.0
Bump KubeVirt dependencies to v1.9.0 in go.mod, go.sum, and vendor/.
Disable ImageVolume in e2e KubeVirt setup on k8s 1.34 only.

Signed-off-by: Amir Alavi <amiralavi7@gmail.com>
2026-08-10 09:32:41 -04:00
kubernetes-prow[bot] 9d09c0d65f Merge pull request #1908 from sammedsingalkar09/master
security: bump grpc and cel-go to fix vulnerabilities
2026-08-09 19:40:00 +00:00
sammedsingalkar09 0b2a396ac1 fix vuln 2026-08-09 13:11:37 -04:00
kubernetes-prow[bot] e08a32dfa8 Merge pull request #1911 from a7i/fix-e2e-lownodeutilization-metrics-timeout
fix(e2e): resolve LowNodeUtilization metrics timeout, test isolation, and runner resource exhaustion
2026-08-09 17:08:00 +00:00
Amir Alavi b5a214909c Fix e2e test descheduler deployment cleanup and KubeVirt live migration wait 2026-08-09 12:35:43 -04:00
Amir Alavi ec736d7140 fix(e2e): harden test suite isolation, resource usage, and leader election timing
- Isolate TestLiveMigrationInBackground into dedicated e2e-livemigration namespace with t.Cleanup teardown.
- Switch VMI containerdisk image from fedora (1.2GB, 512MB RAM) to cirros-container-disk-demo (12MB, 128MB RAM), reducing Kind memory pressure by 75%.
- Filter pod list API calls with LabelSelector=kubevirt.io=virt-launcher to reduce API server serialization load.
- Override --descheduling-interval to 3s in startDeschedulerServer (e2e_leaderelection_test.go) so leader descheduler re-evaluates pod lifetime periodically.
- Exclude context cancellation/deadline from isClientRateLimiterError in e2e_test.go so context deadlines are not swallowed.
- Add t.Cleanup and IsAlreadyExists handling to prevent leftover resources from causing cascading failures.

Signed-off-by: Amir Alavi <amiralavi7@gmail.com>
2026-08-09 11:18:09 -04:00
Amir Alavi 51ecda87fb fix(e2e): update CPU threshold, timeout, and metrics-server readiness for LowNodeUtilization
- Wait for deployment/metrics-server to be Available before running e2e tests in run-e2e-tests.sh.
- Replace infinite context cancellation polling with a 60s timeout in e2e_lownodeutilization_test.go to prevent 40m CI hangs.
- Set Thresholds.CPU to 20% and TargetThresholds.CPU to 40% for reliable single-pass overutilization detection.
- Add IsAlreadyExists and t.Cleanup handling for descheduler policy ConfigMap lifecycle.

Signed-off-by: Amir Alavi <amiralavi7@gmail.com>
2026-08-09 11:18:09 -04:00
Matt Burgess 39a2b0c685 gen docs 2026-07-30 18:45:17 +01:00
Matt Burgess 3691abd9ac Fix LoggingAlphaOptions wiring
Using --feature-gates=LoggingAlphaOptions=true resulted in the
descheduler not starting due to the feature gate not being recognised.
2026-07-29 21:39:35 +01:00
Mukul 4471d02801 fix(nodeutilization): correct misleading notSupportedError message
notSupportedError signals that a usage client cannot quantify per-pod
resource usage (for example the Prometheus client). Its Error() method
returned "maximum number of evicted pods per node reached", which is
unrelated to that condition and misleading when it surfaces in logs.

Return a message that actually describes the error.

Signed-off-by: Mukul <nmukul32@gmail.com>
2026-07-28 18:56:28 +05:30
somaz 0614367253 feat: add opt-in schedulerName and runtimeClassName to the Helm chart 2026-07-02 16:06:02 +09:00
kubernetes-prow[bot] 7d2b28bf2b Merge pull request #1885 from somaz94/feat/chart-pod-disruption-budget
feat: add opt-in PodDisruptionBudget to the Helm chart
2026-06-30 02:56:08 +00:00
kubernetes-prow[bot] cf2e95cb60 Merge pull request #1889 from sammedsingalkar09/master
deps: bump golang.org/x/net and x/crypto for security fixes
2026-06-29 09:42:09 +00:00
kubernetes-prow[bot] ea957b271b Merge pull request #1888 from yugstar/fix-1887-rbac-remove-pods-delete
Remove unnecessary pods delete permission from ClusterRole
2026-06-27 11:07:02 +00:00
sammedsingalkar09 bb14e73150 security vuln 2026-06-27 14:01:00 +05:30
Aman Raj 341aba2723 Remove unnecessary pods delete permission from ClusterRole
The descheduler ClusterRole granted both pods:delete and
pods/eviction:create. Eviction goes through the eviction subresource
(PolicyV1().Evictions().Evict, requiring pods/eviction:create), and there
are no direct pod Delete calls anywhere in the codebase, so the
pods:delete verb is unused. Drop it to follow least privilege.

Signed-off-by: Aman Raj <aman.yug@gmail.com>
2026-06-27 02:26:45 +05:30
somaz 607b578980 feat: add opt-in PodDisruptionBudget to the Helm chart 2026-06-23 11:02:19 +09:00
NPastorale 436b0ac076 feat(deployment): adds revisionHistoryLimit as a configurable parameter
Signed-off-by: NPastorale <npastorale@gmail.com>
2026-06-22 16:39:24 +02:00
Kubernetes Prow Robot 86183b15a2 Merge pull request #1880 from tiraboschi/fix_eviction_metric
fix: emit eviction metrics for background evictions that never get deleted
2026-06-08 15:43:47 +05:30
Simone Tiraboschi 81fb28c18c fix: emit eviction metrics for background evictions that never get deleted
Pods evicted in background may stay in Succeeded/Failed phase forever
without being garbage-collected, so the metric was silently dropped.
Similarly, entries whose background eviction timed out in cleanCache
were expired without recording an outcome.

- UpdateFunc: emit "success"/"error" metric when a pod transitions to
  PodSucceeded/PodFailed respectively, matching the label convention
  used elsewhere in the file
- cleanCache: add onAssumedTimeout callback on evictionRequestsCache,
  wired in NewPodEvictor to emit "error" for entries that exceed the
  assumed-eviction timeout
- Add TestEvictionInBackgroundMetrics_PodCompleted and
  TestEvictionInBackgroundMetrics_AssumedTimeout to cover both paths

Signed-off-by: Simone Tiraboschi <stirabos@redhat.com>
2026-06-03 19:08:15 +02:00
Kubernetes Prow Robot 3588dacd57 Merge pull request #1879 from a7i/descheduler-v0.36.0-helm
[descheduler v0.36.0] update helm chart
2026-05-20 17:15:48 +05:30
Kubernetes Prow Robot 9cba672e8f Merge pull request #1876 from a7i/copilot-ignore-vendor-master
chore(copilot): ignore vendor directory
2026-05-20 02:53:46 +05:30
Amir Alavi 5ac9278456 [descheduler v0.36.0] update helm chart 2026-05-19 16:43:16 -04:00
Amir Alavi 5fd4c1ea00 chore(copilot): ignore vendor directory 2026-05-16 21:59:11 -04:00
410 changed files with 21919 additions and 29750 deletions
+1
View File
@@ -0,0 +1 @@
vendor/**
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
descheduler-version: ["v0.36.0"]
descheduler-api: ["v1alpha2"]
manifest: ["deployment"]
kind-version: ["v0.31.0"] # keep in sync with test/run-e2e-tests.sh
kind-version: ["v0.31.0"] # keep in sync with test/lib/e2e-versions.env
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
+2 -2
View File
@@ -1,7 +1,7 @@
apiVersion: v1
name: descheduler
version: 0.35.0
appVersion: 0.35.0
version: 0.36.0
appVersion: 0.36.0
description: Descheduler for Kubernetes is used to rebalance clusters by evicting pods that can potentially be scheduled on better nodes. In the current implementation, descheduler does not schedule replacement of evicted pods but relies on the default scheduler for that.
keywords:
- kubernetes
+8
View File
@@ -62,9 +62,17 @@ The following table lists the configurable parameters of the _descheduler_ chart
| `ttlSecondsAfterFinished` | If set, configure `ttlSecondsAfterFinished` for the _descheduler_ job | `nil` |
| `deschedulingInterval` | If using kind:Deployment, sets time between consecutive descheduler executions. | `5m` |
| `replicas` | The replica count for Deployment | `1` |
| `revisionHistoryLimit | The revision history limit for Deployment | `10` |
| `leaderElection` | The options for high availability when running replicated components | _see values.yaml_ |
| `podDisruptionBudget.enabled` | If `true` and `kind` is `Deployment`, create a PodDisruptionBudget for the descheduler | `false` |
| `podDisruptionBudget.minAvailable` | Minimum descheduler pods that must stay available (mutually exclusive with `maxUnavailable`) | `1` |
| `podDisruptionBudget.maxUnavailable`| Maximum descheduler pods that can be unavailable (mutually exclusive with `minAvailable`) | `""` |
| `podDisruptionBudget.annotations` | Annotations to add to the PodDisruptionBudget | `{}` |
| `cmdOptions` | The options to pass to the _descheduler_ command | _see values.yaml_ |
| `priorityClassName` | The name of the priority class to add to pods | `system-cluster-critical` |
| `schedulerName` | The name of the scheduler used to schedule the descheduler cronjob/deployment pods | `""` |
| `runtimeClassName` | The RuntimeClass applied to the descheduler cronjob/deployment pods | `""` |
| `hostUsers` | If set (`true`/`false`), sets `hostUsers` on the pod spec to control user-namespace sharing | `nil` |
| `rbac.create` | If `true`, create & use RBAC resources | `true` |
| `resources` | Descheduler container CPU and memory requests/limits | _see values.yaml_ |
| `serviceAccount.create` | If `true`, create a service account for the cron job | `true` |
@@ -17,7 +17,7 @@ rules:
verbs: ["get", "watch", "list"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "watch", "list", "delete"]
verbs: ["get", "watch", "list"]
- apiGroups: [""]
resources: ["pods/eviction"]
verbs: ["create"]
@@ -87,6 +87,15 @@ spec:
{{- if .Values.priorityClassName }}
priorityClassName: {{ .Values.priorityClassName }}
{{- end }}
{{- if .Values.schedulerName }}
schedulerName: {{ .Values.schedulerName }}
{{- end }}
{{- if .Values.runtimeClassName }}
runtimeClassName: {{ .Values.runtimeClassName }}
{{- end }}
{{- if kindIs "bool" .Values.hostUsers }}
hostUsers: {{ .Values.hostUsers }}
{{- end }}
serviceAccountName: {{ template "descheduler.serviceAccountName" . }}
{{- if kindIs "bool" .Values.automountServiceAccountToken }}
automountServiceAccountToken: {{ .Values.automountServiceAccountToken }}
@@ -18,6 +18,7 @@ spec:
{{- else }}
replicas: 1
{{- end }}
revisionHistoryLimit: {{ .Values.revisionHistoryLimit }}
selector:
matchLabels:
{{- include "descheduler.selectorLabels" . | nindent 6 }}
@@ -41,6 +42,15 @@ spec:
{{- if .Values.priorityClassName }}
priorityClassName: {{ .Values.priorityClassName }}
{{- end }}
{{- if .Values.schedulerName }}
schedulerName: {{ .Values.schedulerName }}
{{- end }}
{{- if .Values.runtimeClassName }}
runtimeClassName: {{ .Values.runtimeClassName }}
{{- end }}
{{- if kindIs "bool" .Values.hostUsers }}
hostUsers: {{ .Values.hostUsers }}
{{- end }}
serviceAccountName: {{ template "descheduler.serviceAccountName" . }}
{{- if kindIs "bool" .Values.automountServiceAccountToken }}
automountServiceAccountToken: {{ .Values.automountServiceAccountToken }}
@@ -0,0 +1,30 @@
{{- if and (eq .Values.kind "Deployment") (.Values.podDisruptionBudget).enabled }}
{{- if .Capabilities.APIVersions.Has "policy/v1/PodDisruptionBudget" }}
apiVersion: policy/v1
{{- else }}
apiVersion: policy/v1beta1
{{- end }}
kind: PodDisruptionBudget
metadata:
name: {{ template "descheduler.fullname" . }}
namespace: {{ include "descheduler.namespace" . }}
labels:
{{- include "descheduler.labels" . | nindent 4 }}
{{- with .Values.podDisruptionBudget.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
{{- end }}
{{- with .Values.podDisruptionBudget.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
{{- include "descheduler.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -15,3 +15,53 @@ tests:
asserts:
- isKind:
of: CronJob
- it: sets schedulerName when provided
set:
schedulerName: custom-scheduler
template: templates/cronjob.yaml
asserts:
- equal:
path: spec.jobTemplate.spec.template.spec.schedulerName
value: custom-scheduler
- it: sets runtimeClassName when provided
set:
runtimeClassName: gvisor
template: templates/cronjob.yaml
asserts:
- equal:
path: spec.jobTemplate.spec.template.spec.runtimeClassName
value: gvisor
- it: omits schedulerName and runtimeClassName by default
template: templates/cronjob.yaml
asserts:
- notExists:
path: spec.jobTemplate.spec.template.spec.schedulerName
- notExists:
path: spec.jobTemplate.spec.template.spec.runtimeClassName
- it: sets hostUsers to false when configured
set:
hostUsers: false
template: templates/cronjob.yaml
asserts:
- equal:
path: spec.jobTemplate.spec.template.spec.hostUsers
value: false
- it: sets hostUsers to true when configured
set:
hostUsers: true
template: templates/cronjob.yaml
asserts:
- equal:
path: spec.jobTemplate.spec.template.spec.hostUsers
value: true
- it: omits hostUsers by default
template: templates/cronjob.yaml
asserts:
- notExists:
path: spec.jobTemplate.spec.template.spec.hostUsers
@@ -47,3 +47,53 @@ tests:
- contains:
path: spec.template.spec.containers[0].args
content: --leader-elect-resource-namespace=typo
- it: sets schedulerName when provided
set:
schedulerName: custom-scheduler
template: templates/deployment.yaml
asserts:
- equal:
path: spec.template.spec.schedulerName
value: custom-scheduler
- it: sets runtimeClassName when provided
set:
runtimeClassName: gvisor
template: templates/deployment.yaml
asserts:
- equal:
path: spec.template.spec.runtimeClassName
value: gvisor
- it: omits schedulerName and runtimeClassName by default
template: templates/deployment.yaml
asserts:
- notExists:
path: spec.template.spec.schedulerName
- notExists:
path: spec.template.spec.runtimeClassName
- it: sets hostUsers to false when configured
set:
hostUsers: false
template: templates/deployment.yaml
asserts:
- equal:
path: spec.template.spec.hostUsers
value: false
- it: sets hostUsers to true when configured
set:
hostUsers: true
template: templates/deployment.yaml
asserts:
- equal:
path: spec.template.spec.hostUsers
value: true
- it: omits hostUsers by default
template: templates/deployment.yaml
asserts:
- notExists:
path: spec.template.spec.hostUsers
@@ -0,0 +1,75 @@
suite: Test Descheduler PodDisruptionBudget
templates:
- "*.yaml"
release:
name: descheduler
tests:
- it: is not created for the default CronJob kind even when enabled
template: templates/poddisruptionbudget.yaml
set:
podDisruptionBudget:
enabled: true
asserts:
- hasDocuments:
count: 0
- it: is not created for Deployment kind unless enabled
template: templates/poddisruptionbudget.yaml
set:
kind: Deployment
asserts:
- hasDocuments:
count: 0
- it: creates a PodDisruptionBudget for Deployment kind when enabled
template: templates/poddisruptionbudget.yaml
set:
kind: Deployment
podDisruptionBudget:
enabled: true
asserts:
- isKind:
of: PodDisruptionBudget
- equal:
path: spec.minAvailable
value: 1
- notExists:
path: spec.maxUnavailable
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/name"]
value: descheduler
- it: supports maxUnavailable instead of minAvailable
template: templates/poddisruptionbudget.yaml
set:
kind: Deployment
podDisruptionBudget:
enabled: true
minAvailable: ""
maxUnavailable: 1
asserts:
- equal:
path: spec.maxUnavailable
value: 1
- notExists:
path: spec.minAvailable
- it: supports unhealthyPodEvictionPolicy and annotations
template: templates/poddisruptionbudget.yaml
set:
kind: Deployment
podDisruptionBudget:
enabled: true
unhealthyPodEvictionPolicy: IfHealthyBudget
annotations:
example.com/team: platform
asserts:
- equal:
path: spec.unhealthyPodEvictionPolicy
value: IfHealthyBudget
- equal:
path: metadata.annotations["example.com/team"]
value: platform
+29
View File
@@ -68,6 +68,8 @@ deschedulingInterval: 5m
# only if that node is in the same zone as at least one already-running descheduler
replicas: 1
revisionHistoryLimit: 10
# Specifies whether Leader Election resources should be created
# Required when running as a Deployment
# NOTE: Leader election can't be activated if DryRun enabled
@@ -80,6 +82,20 @@ leaderElection: {}
# resourceName: "descheduler"
# resourceNamespace: "kube-system"
# Pod Disruption Budget for the descheduler Deployment. Optional.
# Only rendered when kind is "Deployment". The descheduler runs a single replica
# by default, so this is disabled by default. Enable it for highly available
# setups (replicas > 1 with leaderElection enabled) to keep a descheduler pod
# available during voluntary node disruptions.
podDisruptionBudget:
enabled: false
# minAvailable and maxUnavailable are mutually exclusive; set only one.
minAvailable: 1
maxUnavailable: ""
# See https://kubernetes.io/docs/tasks/run-application/configure-pdb/
# unhealthyPodEvictionPolicy: IfHealthyBudget
annotations: {}
command:
- "/bin/descheduler"
@@ -150,6 +166,19 @@ deschedulerPolicy:
priorityClassName: system-cluster-critical
# schedulerName is the name of the scheduler used to schedule the descheduler pods.
# Leave empty to use the cluster default scheduler.
schedulerName: ""
# runtimeClassName is the RuntimeClass applied to the descheduler pods.
# Leave empty to use the cluster default runtime.
runtimeClassName: ""
# Set the Pod spec `hostUsers` field to control user-namespace sharing.
# Default is not set (the cluster default applies). Set to `false` to opt into
# user-namespace isolation, or `true` to share the host user namespace.
# hostUsers: false
nodeSelector: {}
# foo: bar
+6 -4
View File
@@ -28,13 +28,13 @@ import (
"sigs.k8s.io/descheduler/cmd/descheduler/app/options"
"sigs.k8s.io/descheduler/pkg/descheduler"
"sigs.k8s.io/descheduler/pkg/features"
"sigs.k8s.io/descheduler/pkg/tracing"
"k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/server/healthz"
"k8s.io/apiserver/pkg/server/mux"
"k8s.io/component-base/featuregate"
"k8s.io/component-base/logs"
logsapi "k8s.io/component-base/logs/api/v1"
_ "k8s.io/component-base/logs/json/register"
@@ -49,7 +49,6 @@ func NewDeschedulerCommand(out io.Writer) *cobra.Command {
klog.ErrorS(err, "unable to initialize server")
}
featureGate := featuregate.NewFeatureGate()
logConfig := logsapi.NewLoggingConfiguration()
cmd := &cobra.Command{
@@ -58,7 +57,10 @@ func NewDeschedulerCommand(out io.Writer) *cobra.Command {
Long: "The descheduler evicts pods which may be bound to less desired nodes",
PreRunE: func(cmd *cobra.Command, args []string) error {
logs.InitLogs()
if logsapi.ValidateAndApply(logConfig, featureGate); err != nil {
if err := features.DefaultMutableFeatureGate.SetFromMap(s.FeatureGates); err != nil {
return err
}
if err := logsapi.ValidateAndApply(logConfig, features.DefaultMutableFeatureGate); err != nil {
return err
}
descheduler.SetupPlugins()
@@ -80,9 +82,9 @@ func NewDeschedulerCommand(out io.Writer) *cobra.Command {
}
cmd.SetOut(out)
flags := cmd.Flags()
runtime.Must(logsapi.AddFeatureGates(features.DefaultMutableFeatureGate))
s.AddFlags(flags)
runtime.Must(logsapi.AddFeatureGates(featureGate))
logsapi.AddFlags(logConfig, flags)
return cmd
+71
View File
@@ -0,0 +1,71 @@
/*
Copyright 2026 The Kubernetes 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 app
import (
"io"
"strings"
"testing"
)
func TestNewDeschedulerCommand_LoggingAlphaOptions(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "LoggingAlphaOptions=false rejects --log-json-split-stream",
args: []string{
"--logging-format=json",
"--log-json-split-stream",
"--feature-gates=LoggingAlphaOptions=false",
},
wantErr: "LoggingAlphaOptions is disabled",
},
{
name: "LoggingAlphaOptions=true accepts --log-json-split-stream",
args: []string{
"--logging-format=json",
"--log-json-split-stream",
"--feature-gates=LoggingAlphaOptions=true",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmd := NewDeschedulerCommand(io.Discard)
if err := cmd.ParseFlags(tc.args); err != nil {
t.Fatalf("ParseFlags(%v): %v", tc.args, err)
}
err := cmd.PreRunE(cmd, cmd.Flags().Args())
if tc.wantErr == "" {
if err != nil {
t.Fatalf("expected PreRunE to succeed, got error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected PreRunE to fail with %q, got nil", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected PreRunE error to contain %q, got :%v", tc.wantErr, err)
}
})
}
}
+3
View File
@@ -26,7 +26,10 @@ descheduler [flags]
--feature-gates mapStringBool A set of key=value pairs that describe feature gates for alpha/experimental features. Options are:
AllAlpha=true|false (ALPHA - default=false)
AllBeta=true|false (BETA - default=false)
ContextualLogging=true|false (BETA - default=true)
EvictionsInBackground=true|false (ALPHA - default=false)
LoggingAlphaOptions=true|false (ALPHA - default=false)
LoggingBetaOptions=true|false (BETA - default=true)
-h, --help help for descheduler
--http2-max-streams-per-connection int The limit that the server gives to clients for the maximum number of streams in an HTTP/2 connection. Zero means to use golang's default.
--kubeconfig string File with kube configuration. Deprecated, use client-connection-kubeconfig instead.
+19
View File
@@ -29,6 +29,25 @@ View all CLI options.
```
## Run Tests
### All-in-one e2e (kind cluster created by the test script)
```
KIND_E2E=1 make test-e2e
```
Optional skip flags (set to any non-empty value to skip that step):
| Variable | Skips |
|----------|-------|
| `SKIP_INSTALL` | kind node-image build and cluster create |
| `SKIP_KUBECTL_INSTALL` | kubectl download |
| `SKIP_KIND_INSTALL` | kind binary download |
| `SKIP_KUBEVIRT_INSTALL` | KubeVirt operator install |
| `SKIP_METRICS_SERVER_INSTALL` | metrics-server install |
### Manual kind cluster (iterative development)
```
GOOS=linux make dev-image
make kind-multi-node
+17 -18
View File
@@ -17,7 +17,7 @@ require (
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
google.golang.org/grpc v1.81.1
google.golang.org/grpc v1.82.1
k8s.io/api v0.36.1
k8s.io/apimachinery v0.36.1
k8s.io/apiserver v0.36.1
@@ -28,8 +28,8 @@ require (
k8s.io/klog/v2 v2.140.0
k8s.io/metrics v0.36.1
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2
kubevirt.io/api v1.8.2
kubevirt.io/client-go v1.8.2
kubevirt.io/api v1.9.0
kubevirt.io/client-go v1.9.0
sigs.k8s.io/mdtoc v1.4.0
sigs.k8s.io/yaml v1.6.0
)
@@ -38,7 +38,7 @@ require (
cel.dev/expr v0.25.1 // indirect
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@@ -75,13 +75,13 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab // indirect
github.com/google/cel-go v0.26.0 // indirect
github.com/google/cel-go v0.30.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -96,7 +96,6 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.etcd.io/etcd/api/v3 v3.6.8 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.6.8 // indirect
@@ -110,18 +109,18 @@ require (
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
@@ -137,7 +136,7 @@ require (
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/release-utils v0.8.3 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
)
replace k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102
+34 -33
View File
@@ -7,8 +7,8 @@ github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cq
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
@@ -141,8 +141,8 @@ github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IR
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo=
github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -170,8 +170,8 @@ github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -268,8 +268,6 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -364,8 +362,8 @@ golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvm
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
@@ -392,8 +390,9 @@ golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -431,8 +430,8 @@ golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -455,8 +454,8 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -496,8 +495,8 @@ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=
@@ -526,8 +525,8 @@ golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -549,8 +548,8 @@ golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -581,8 +580,9 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
@@ -600,15 +600,15 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -690,10 +690,10 @@ k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
kubevirt.io/api v1.8.2 h1:h8AACJlity2n8gRE6e9PgikLAwzmNz6gzVjT1kbsDZY=
kubevirt.io/api v1.8.2/go.mod h1:EUaPeD1GQK4IIEp8TVtk5dsFlYQGzFfcWi4CV4rs0rY=
kubevirt.io/client-go v1.8.2 h1:jKJe5j/M7GDfpqTreoBA+BVoyQPCPTbLpuGHHaBfjdk=
kubevirt.io/client-go v1.8.2/go.mod h1:eoH34Z/Wa1JuHeIezT/tcGDMa6zLLEf3FPRx5beJU3k=
kubevirt.io/api v1.9.0 h1:FWqx9yGk/GHUP5o4IthGXZ8FeAlgVNv2It48wD86UUo=
kubevirt.io/api v1.9.0/go.mod h1:tFKAVaJMm7bXCMLxOoUdAqBprKAXoCa0sp0gBmnfBgY=
kubevirt.io/client-go v1.9.0 h1:ksR37OV0Zft6g/9YPFEjevlkRpTvWuyVE44zj9xGuGQ=
kubevirt.io/client-go v1.9.0/go.mod h1:cgGTWTqnghJdyiGXx4y9KXAHholmF2BwodUKXuQa9Ns=
kubevirt.io/containerized-data-importer-api v1.64.0 h1:yBLY6qEogUp8F+XSJvabIkx9uEEDeToYDfRr0mbUxJc=
kubevirt.io/containerized-data-importer-api v1.64.0/go.mod h1:VGp35wxpLXU18b7cnEpmcThI3AjcZUSfg/Zfql44U4o=
kubevirt.io/controller-lifecycle-operator-sdk/api v0.2.4 h1:fZYvD3/Vnitfkx6IJxjLAk8ugnZQ7CXVYcRfkSKmuZY=
@@ -711,8 +711,9 @@ sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxO
sigs.k8s.io/release-utils v0.8.3 h1:KtOtA4qDmzJyeQ2zkDsFVI25+NViwms/o5eL2NftFdA=
sigs.k8s.io/release-utils v0.8.3/go.mod h1:fp82Fma06OXBhEJ+GUJKqvcplDBomruK1R/1fWJnsrQ=
sigs.k8s.io/structured-merge-diff/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+2
View File
@@ -1,3 +1,5 @@
# Used by e2e tests (test/run-e2e-tests.sh) and make kind-multi-node.
# Worker nodes carry topology zone labels required by topology spread constraint e2e tests.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
+1 -1
View File
@@ -15,7 +15,7 @@ rules:
verbs: ["get", "watch", "list"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "watch", "list", "delete"]
verbs: ["get", "watch", "list"]
- apiGroups: [""]
resources: ["pods/eviction"]
verbs: ["create"]
+5 -3
View File
@@ -235,8 +235,10 @@ func initDescheduler(t *testing.T, ctx context.Context, featureGates featuregate
if dryRun {
if err := wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, 5*time.Second, true, func(ctx context.Context) (bool, error) {
for _, obj := range objects {
// Only check for nodes - secrets are handled by namespacedSharedInformerFactory
if _, ok := obj.(*v1.Node); !ok {
// Only check for nodes and pods - secrets are handled by namespacedSharedInformerFactory
switch obj.(type) {
case *v1.Node, *v1.Pod:
default:
continue
}
exists, err := descheduler.kubeClientSandbox.hasRuntimeObjectInIndexer(obj)
@@ -256,7 +258,7 @@ func initDescheduler(t *testing.T, ctx context.Context, featureGates featuregate
}
return true, nil
}); err != nil {
t.Fatalf("nodes did not propagate to the indexer: %v", err)
t.Fatalf("objects did not propagate to the indexer: %v", err)
}
}
+22 -8
View File
@@ -69,6 +69,7 @@ type evictionRequestsCache struct {
requestsPerNamespace map[string]uint
requestsTotal uint
assumedRequestTimeoutSeconds uint
onAssumedTimeout func(item evictionRequestItem)
}
func newEvictionRequestsCache(assumedRequestTimeoutSeconds uint) *evictionRequestsCache {
@@ -95,6 +96,9 @@ func (erc *evictionRequestsCache) cleanCache(ctx context.Context) {
requestAgeSeconds := uint(metav1.Now().Sub(item.assumedTimestamp.Local()).Seconds())
if requestAgeSeconds > erc.assumedRequestTimeoutSeconds {
klog.V(4).InfoS("Assumed eviction request in background timed out, deleting", "timeout", erc.assumedRequestTimeoutSeconds, "podNamespace", item.podNamespace, "podName", item.podName)
if erc.onAssumedTimeout != nil {
erc.onAssumedTimeout(item)
}
erc.deleteItem(uid)
}
}
@@ -290,6 +294,12 @@ func NewPodEvictor(
if featureGates.Enabled(features.EvictionsInBackground) {
erCache := newEvictionRequestsCache(assumedEvictionRequestTimeoutSeconds)
if podEvictor.metricsEnabled {
erCache.onAssumedTimeout = func(item evictionRequestItem) {
metrics.PodsEvicted.With(map[string]string{"result": "error", "strategy": item.strategyName, "namespace": item.podNamespace, "node": item.podNodeName, "profile": item.profileName}).Inc()
metrics.PodsEvictedTotal.With(map[string]string{"result": "error", "strategy": item.strategyName, "namespace": item.podNamespace, "node": item.podNodeName, "profile": item.profileName}).Inc()
}
}
handlerRegistration, err := podInformer.AddEventHandler(
cache.ResourceEventHandlerFuncs{
@@ -330,8 +340,18 @@ func NewPodEvictor(
}
// Remove completed/suceeeded or failed pods from the cache
if newPod.Status.Phase == v1.PodSucceeded || newPod.Status.Phase == v1.PodFailed {
klog.V(3).InfoS("Pod with eviction in background completed. Removing pod from the cache.", "pod", klog.KObj(newPod))
erCache.deletePod(newPod)
if item, exists := erCache.getPod(newPod); exists {
klog.V(3).InfoS("Pod with eviction in background completed. Removing pod from the cache.", "pod", klog.KObj(newPod))
if item.evictionAssumed && podEvictor.metricsEnabled {
result := "success"
if newPod.Status.Phase == v1.PodFailed {
result = "error"
}
metrics.PodsEvicted.With(map[string]string{"result": result, "strategy": item.strategyName, "namespace": item.podNamespace, "node": item.podNodeName, "profile": item.profileName}).Inc()
metrics.PodsEvictedTotal.With(map[string]string{"result": result, "strategy": item.strategyName, "namespace": item.podNamespace, "node": item.podNodeName, "profile": item.profileName}).Inc()
}
erCache.deletePod(newPod)
}
return
}
// Ignore any pod that does not have eviction in progress
@@ -458,12 +478,6 @@ func (pe *PodEvictor) evictionRequestsPerNamespace(ns string) uint {
}
}
func (pe *PodEvictor) EvictionRequests(node *v1.Node) uint {
pe.mu.RLock()
defer pe.mu.RUnlock()
return pe.evictionRequestsTotal()
}
func (pe *PodEvictor) TotalEvictionRequests() uint {
pe.mu.RLock()
defer pe.mu.RUnlock()
+153
View File
@@ -683,6 +683,159 @@ func TestEvictionInBackgroundMetrics_InformerRace(t *testing.T) {
metricstest.AssertVectorCount(t, "descheduler_pods_evicted_total", map[string]string{"result": "success"}, 1)
}
// TestEvictionInBackgroundMetrics_PodCompleted verifies that when a pod
// transitions to a terminal phase via UpdateFunc (without ever being deleted),
// the correct metric is emitted: "success" for PodSucceeded, "error" for PodFailed.
func TestEvictionInBackgroundMetrics_PodCompleted(t *testing.T) {
testCases := []struct {
name string
phase v1.PodPhase
expectedResult string
}{
{name: "PodSucceeded emits success", phase: v1.PodSucceeded, expectedResult: "success"},
{name: "PodFailed emits error", phase: v1.PodFailed, expectedResult: "error"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), wait.ForeverTestTimeout)
defer cancel()
deschedulermetrics.Register()
deschedulermetrics.PodsEvicted.Reset()
deschedulermetrics.PodsEvictedTotal.Reset()
node1 := test.BuildTestNode("n1", 2000, 3000, 10, nil)
ownerRef1 := test.GetReplicaSetOwnerRefList()
p1 := test.BuildTestPod("p1", 100, 0, node1.Name, func(pod *v1.Pod) {
pod.Namespace = "dev"
pod.ObjectMeta.OwnerReferences = ownerRef1
pod.Annotations = map[string]string{
EvictionRequestAnnotationKey: "",
}
})
client := fakeclientset.NewSimpleClientset(node1, p1)
sharedInformerFactory := informers.NewSharedInformerFactory(client, 0)
_, eventRecorder := utils.GetRecorderAndBroadcaster(ctx, client)
podEvictor, err := NewPodEvictor(
ctx,
client,
eventRecorder,
sharedInformerFactory.Core().V1().Pods().Informer(),
initFeatureGates(),
NewOptions().WithMetricsEnabled(true),
)
if err != nil {
t.Fatalf("Unexpected error when creating a pod evictor: %v", err)
}
client.PrependReactor("create", "pods", func(action core.Action) (bool, runtime.Object, error) {
if action.GetSubresource() != "eviction" {
return false, nil, nil
}
return true, nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Reason: metav1.StatusReasonTooManyRequests,
Message: "Eviction triggered evacuation",
},
}
})
sharedInformerFactory.Start(ctx.Done())
sharedInformerFactory.WaitForCacheSync(ctx.Done())
evictOpts := EvictOptions{StrategyName: "TestStrategy", ProfileName: "TestProfile"}
podEvictor.EvictPod(ctx, p1, evictOpts)
metricstest.AssertVectorCount(t, "descheduler_pods_evicted_total", map[string]string{"result": "background"}, 1)
// Transition the pod to a terminal phase without deleting it.
p1Updated := p1.DeepCopy()
p1Updated.Status.Phase = tc.phase
client.CoreV1().Pods(p1.Namespace).UpdateStatus(ctx, p1Updated, metav1.UpdateOptions{})
// Wait for UpdateFunc to fire and remove the pod from the cache.
if err := wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, wait.ForeverTestTimeout, true, func(ctx context.Context) (bool, error) {
return !podEvictor.erCache.hasPod(p1), nil
}); err != nil {
t.Fatalf("Timed out waiting for background eviction to complete: %v", err)
}
metricstest.AssertVectorCount(t, "descheduler_pods_evicted_total", map[string]string{"result": tc.expectedResult}, 1)
})
}
}
// TestEvictionInBackgroundMetrics_AssumedTimeout verifies that when an assumed
// eviction entry expires in cleanCache, the "error" metric is emitted.
func TestEvictionInBackgroundMetrics_AssumedTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), wait.ForeverTestTimeout)
defer cancel()
deschedulermetrics.Register()
deschedulermetrics.PodsEvicted.Reset()
deschedulermetrics.PodsEvictedTotal.Reset()
node1 := test.BuildTestNode("n1", 2000, 3000, 10, nil)
ownerRef1 := test.GetReplicaSetOwnerRefList()
p1 := test.BuildTestPod("p1", 100, 0, node1.Name, func(pod *v1.Pod) {
pod.Namespace = "dev"
pod.ObjectMeta.OwnerReferences = ownerRef1
pod.Annotations = map[string]string{
EvictionRequestAnnotationKey: "",
}
})
client := fakeclientset.NewSimpleClientset(node1, p1)
sharedInformerFactory := informers.NewSharedInformerFactory(client, 0)
_, eventRecorder := utils.GetRecorderAndBroadcaster(ctx, client)
podEvictor, err := NewPodEvictor(
ctx,
client,
eventRecorder,
sharedInformerFactory.Core().V1().Pods().Informer(),
initFeatureGates(),
NewOptions().WithMetricsEnabled(true),
)
if err != nil {
t.Fatalf("Unexpected error when creating a pod evictor: %v", err)
}
client.PrependReactor("create", "pods", func(action core.Action) (bool, runtime.Object, error) {
if action.GetSubresource() != "eviction" {
return false, nil, nil
}
return true, nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Reason: metav1.StatusReasonTooManyRequests,
Message: "Eviction triggered evacuation",
},
}
})
sharedInformerFactory.Start(ctx.Done())
sharedInformerFactory.WaitForCacheSync(ctx.Done())
evictOpts := EvictOptions{StrategyName: "TestStrategy", ProfileName: "TestProfile"}
podEvictor.EvictPod(ctx, p1, evictOpts)
metricstest.AssertVectorCount(t, "descheduler_pods_evicted_total", map[string]string{"result": "background"}, 1)
// Back-date all assumed entries so they appear expired, then run cleanCache.
podEvictor.erCache.mu.Lock()
for uid, item := range podEvictor.erCache.requests {
item.assumedTimestamp = metav1.NewTime(time.Now().Add(-time.Hour))
podEvictor.erCache.requests[uid] = item
}
podEvictor.erCache.mu.Unlock()
podEvictor.erCache.cleanCache(ctx)
metricstest.AssertVectorCount(t, "descheduler_pods_evicted_total", map[string]string{"result": "error"}, 1)
}
func assertEqualEvents(t *testing.T, expected []string, actual <-chan string) {
t.Logf("Assert for events: %v", expected)
c := time.After(wait.ForeverTestTimeout)
+1 -1
View File
@@ -46,4 +46,4 @@ var defaultDeschedulerFeatureGates = map[featuregate.Feature]featuregate.Feature
// Tests that need to modify feature gates for the duration of their test should use:
//
// defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.<FeatureName>, <value>)()
var DefaultMutableFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate()
var DefaultMutableFeatureGate featuregate.MutableVersionedFeatureGate = featuregate.NewFeatureGate()
@@ -50,7 +50,7 @@ type notSupportedError struct {
}
func (e notSupportedError) Error() string {
return "maximum number of evicted pods per node reached"
return "pod usage quantification is not supported by this usage client"
}
func newNotSupportedError(usageClientType UsageClientType) *notSupportedError {
+2 -44
View File
@@ -189,52 +189,10 @@ func TestRemoveDuplicates(t *testing.T) {
tc.removeDuplicatesArgs.Namespaces = &api.Namespaces{
Include: []string{testNamespace.Name},
}
deschedulerPolicyConfigMapObj, err := deschedulerPolicyConfigMap(removeDuplicatesPolicy(tc.removeDuplicatesArgs, tc.evictorArgs))
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
t.Logf("Creating %q policy CM with RemoveDuplicates configured...", deschedulerPolicyConfigMapObj.Name)
_, err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Create(ctx, deschedulerPolicyConfigMapObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
defer func() {
t.Logf("Deleting %q CM...", deschedulerPolicyConfigMapObj.Name)
err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Delete(ctx, deschedulerPolicyConfigMapObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
}()
createPolicyConfigMap(t, ctx, clientSet, removeDuplicatesPolicy(tc.removeDuplicatesArgs, tc.evictorArgs))
deschedulerDeploymentObj := deschedulerDeployment(testNamespace.Name)
t.Logf("Creating descheduler deployment %v", deschedulerDeploymentObj.Name)
_, err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Create(ctx, deschedulerDeploymentObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
deschedulerPodName := ""
defer func() {
if deschedulerPodName != "" {
printPodLogs(ctx, t, clientSet, deschedulerPodName)
}
t.Logf("Deleting %q deployment...", deschedulerDeploymentObj.Name)
err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
waitForPodsToDisappear(ctx, t, clientSet, deschedulerDeploymentObj.Labels, deschedulerDeploymentObj.Namespace)
}()
t.Logf("Waiting for the descheduler pod running")
deschedulerPods := waitForPodsRunning(ctx, t, clientSet, deschedulerDeploymentObj.Labels, 1, deschedulerDeploymentObj.Namespace)
if len(deschedulerPods) != 0 {
deschedulerPodName = deschedulerPods[0].Name
}
createDeschedulerDeploymentWithCleanup(t, ctx, clientSet, deschedulerDeploymentObj)
// Run RemoveDuplicates strategy
var meetsExpectations bool
+238 -191
View File
@@ -34,12 +34,16 @@ import (
const (
vmiCount = 3
// virtLauncherSelector selects KubeVirt virt-launcher pods only,
// avoiding listing unrelated pods in the namespace.
virtLauncherSelector = "kubevirt.io=virt-launcher"
)
func virtualMachineInstance(idx int) *kvcorev1.VirtualMachineInstance {
func virtualMachineInstance(idx int, namespace string) *kvcorev1.VirtualMachineInstance {
return &kvcorev1.VirtualMachineInstance{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("kubevirtvmi-%v", idx),
Name: fmt.Sprintf("kubevirtvmi-%v", idx),
Namespace: namespace,
Annotations: map[string]string{
"descheduler.alpha.kubernetes.io/request-evict-only": "",
},
@@ -58,20 +62,12 @@ func virtualMachineInstance(idx int) *kvcorev1.VirtualMachineInstance {
},
},
},
{
Name: "cloudinitdisk",
DiskDevice: kvcorev1.DiskDevice{
Disk: &kvcorev1.DiskTarget{
Bus: kvcorev1.DiskBusVirtio,
},
},
},
},
Rng: &kvcorev1.Rng{},
},
Resources: kvcorev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse("1024M"),
corev1.ResourceMemory: resource.MustParse("128M"),
},
},
},
@@ -81,27 +77,7 @@ func virtualMachineInstance(idx int) *kvcorev1.VirtualMachineInstance {
Name: "containerdisk",
VolumeSource: kvcorev1.VolumeSource{
ContainerDisk: &kvcorev1.ContainerDiskSource{
Image: "quay.io/kubevirt/fedora-with-test-tooling-container-disk:20240710_1265d1090",
},
},
},
{
Name: "cloudinitdisk",
VolumeSource: kvcorev1.VolumeSource{
CloudInitNoCloud: &kvcorev1.CloudInitNoCloudSource{
UserData: `#cloud-config
password: fedora
chpasswd: { expire: False }
packages:
- nginx
runcmd:
- [ "systemctl", "enable", "--now", "nginx" ]`,
NetworkData: `version: 2
ethernets:
eth0:
addresses: [ fd10:0:2::2/120 ]
dhcp4: true
gateway6: fd10:0:2::1`,
Image: kubevirtCirrosContainerDiskImage(),
},
},
},
@@ -110,31 +86,83 @@ ethernets:
}
}
func waitForKubevirtReady(t *testing.T, ctx context.Context, kvClient kubevirtclient.Interface) {
obj, err := kvClient.KubevirtV1().KubeVirts("kubevirt").Get(ctx, "kubevirt", metav1.GetOptions{})
if err != nil {
t.Fatalf("Unable to get kubevirt/kubevirt: %v", err)
}
available := false
for _, condition := range obj.Status.Conditions {
if condition.Type == kvcorev1.KubeVirtConditionAvailable {
if condition.Status == corev1.ConditionTrue {
available = true
func formatContainerStatuses(pod *corev1.Pod) string {
var parts []string
formatList := func(prefix string, statuses []corev1.ContainerStatus) {
for _, cs := range statuses {
stateStr := "unknown"
if cs.State.Waiting != nil {
stateStr = fmt.Sprintf("waiting(reason=%q, message=%q)", cs.State.Waiting.Reason, cs.State.Waiting.Message)
} else if cs.State.Terminated != nil {
stateStr = fmt.Sprintf("terminated(exitCode=%d, reason=%q, message=%q)", cs.State.Terminated.ExitCode, cs.State.Terminated.Reason, cs.State.Terminated.Message)
} else if cs.State.Running != nil {
stateStr = "running"
}
parts = append(parts, fmt.Sprintf("%s%s: state=%s, ready=%v, restarts=%d", prefix, cs.Name, stateStr, cs.Ready, cs.RestartCount))
}
}
if !available {
t.Fatalf("Kubevirt is not available")
formatList("init:", pod.Status.InitContainerStatuses)
formatList("", pod.Status.ContainerStatuses)
return strings.Join(parts, "; ")
}
// ensureVMIsLiveMigratable waits until every VMI reports the LiveMigratable condition with status True.
func ensureVMIsLiveMigratable(t *testing.T, ctx context.Context, kvClient kubevirtclient.Interface, namespace string) {
t.Helper()
err := wait.PollUntilContextTimeout(ctx, 3*time.Second, 180*time.Second, true, func(ctx context.Context) (bool, error) {
vmiList, err := kvClient.KubevirtV1().VirtualMachineInstances(namespace).List(ctx, metav1.ListOptions{})
if err != nil || len(vmiList.Items) != vmiCount {
return false, nil
}
for _, vmi := range vmiList.Items {
migratable := false
for _, c := range vmi.Status.Conditions {
if c.Type == kvcorev1.VirtualMachineInstanceIsMigratable && c.Status == corev1.ConditionTrue {
migratable = true
break
}
}
if !migratable {
return false, nil
}
}
return true, nil
})
if err != nil {
t.Fatalf("VMIs never became LiveMigratable: %v", err)
}
klog.Infof("All VMIs are LiveMigratable")
}
func waitForKubevirtReady(t *testing.T, ctx context.Context, kvClient kubevirtclient.Interface) {
t.Helper()
err := wait.PollUntilContextTimeout(ctx, 3*time.Second, 180*time.Second, true, func(ctx context.Context) (bool, error) {
obj, err := kvClient.KubevirtV1().KubeVirts("kubevirt").Get(ctx, "kubevirt", metav1.GetOptions{})
if err != nil {
klog.Infof("Unable to get kubevirt/kubevirt: %v", err)
return false, nil
}
for _, condition := range obj.Status.Conditions {
if condition.Type == kvcorev1.KubeVirtConditionAvailable && condition.Status == corev1.ConditionTrue {
return true, nil
}
}
return false, nil
})
if err != nil {
t.Fatalf("Kubevirt is not available: %v", err)
}
klog.Infof("Kubevirt is available")
}
func allVMIsHaveRunningPods(t *testing.T, ctx context.Context, kubeClient clientset.Interface, kvClient kubevirtclient.Interface) (bool, error) {
func allVMIsHaveRunningPods(t *testing.T, ctx context.Context, kubeClient clientset.Interface, kvClient kubevirtclient.Interface, namespace string) (bool, error) {
klog.Infof("Checking all vmi active pods are running")
uidMap := make(map[types.UID]*corev1.Pod)
podList, err := kubeClient.CoreV1().Pods("default").List(ctx, metav1.ListOptions{})
podList, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: virtLauncherSelector,
})
if err != nil {
if strings.Contains(err.Error(), "client rate limiter") {
if isClientRateLimiterError(err) {
klog.Infof("Unable to list pods: %v", err)
return false, nil
}
@@ -148,7 +176,7 @@ func allVMIsHaveRunningPods(t *testing.T, ctx context.Context, kubeClient client
uidMap[item.UID] = &pod
}
vmiList, err := kvClient.KubevirtV1().VirtualMachineInstances("default").List(ctx, metav1.ListOptions{})
vmiList, err := kvClient.KubevirtV1().VirtualMachineInstances(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
klog.Infof("Unable to list VMIs: %v", err)
return false, err
@@ -165,14 +193,19 @@ func allVMIsHaveRunningPods(t *testing.T, ctx context.Context, kubeClient client
klog.Infof("Active pod %v not found", activePod)
return false, nil
}
klog.Infof("Checking whether active pod %v (uid=%v) is running", uidMap[activePod].Name, activePod)
// ignore completed/failed pods
if uidMap[activePod].Status.Phase == corev1.PodFailed || uidMap[activePod].Status.Phase == corev1.PodSucceeded {
klog.Infof("Ignoring active pod %v, phase=%v", uidMap[activePod].Name, uidMap[activePod].Status.Phase)
pod := uidMap[activePod]
klog.Infof("Checking whether active pod %v (uid=%v) is running", pod.Name, activePod)
if pod.Status.Phase == corev1.PodFailed {
details := fmt.Sprintf("pod %s (phase=Failed, reason=%q, message=%q, containers=[%s])", pod.Name, pod.Status.Reason, pod.Status.Message, formatContainerStatuses(pod))
klog.Infof("Active pod failed: %s", details)
continue
}
if uidMap[activePod].Status.Phase != corev1.PodRunning {
klog.Infof("activePod %v is not running: %v\n", uidMap[activePod].Name, uidMap[activePod].Status.Phase)
if pod.Status.Phase == corev1.PodSucceeded {
klog.Infof("Ignoring active pod %v, phase=%v", pod.Name, pod.Status.Phase)
continue
}
if pod.Status.Phase != corev1.PodRunning {
klog.Infof("activePod %v is not running: %v\n", pod.Name, pod.Status.Phase)
return false, nil
}
atLeastOneVmiIsRunning = true
@@ -186,7 +219,7 @@ func allVMIsHaveRunningPods(t *testing.T, ctx context.Context, kubeClient client
return true, nil
}
func podLifeTimePolicy() *apiv1alpha2.DeschedulerPolicy {
func podLifeTimePolicy(namespace string) *apiv1alpha2.DeschedulerPolicy {
return &apiv1alpha2.DeschedulerPolicy{
Profiles: []apiv1alpha2.DeschedulerProfile{
{
@@ -198,7 +231,7 @@ func podLifeTimePolicy() *apiv1alpha2.DeschedulerPolicy {
Object: &podlifetime.PodLifeTimeArgs{
MaxPodLifeTimeSeconds: utilptr.To[uint](1), // set it to immediate eviction
Namespaces: &api.Namespaces{
Include: []string{"default"},
Include: []string{namespace},
},
},
},
@@ -229,10 +262,12 @@ func podLifeTimePolicy() *apiv1alpha2.DeschedulerPolicy {
}
}
func kVirtRunningPodNames(t *testing.T, ctx context.Context, kubeClient clientset.Interface) []string {
func kVirtRunningPodNames(t *testing.T, ctx context.Context, kubeClient clientset.Interface, namespace string) []string {
names := []string{}
if err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) {
podList, err := kubeClient.CoreV1().Pods("default").List(ctx, metav1.ListOptions{})
podList, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: virtLauncherSelector,
})
if err != nil {
if isClientRateLimiterError(err) {
t.Log(err)
@@ -243,9 +278,6 @@ func kVirtRunningPodNames(t *testing.T, ctx context.Context, kubeClient clientse
}
for _, item := range podList.Items {
if !strings.HasPrefix(item.Name, "virt-launcher-kubevirtvmi-") {
t.Fatalf("Only pod names with 'virt-launcher-kubevirtvmi-' prefix are expected, got %q instead", item.Name)
}
if item.Status.Phase == corev1.PodRunning {
names = append(names, item.Name)
}
@@ -258,13 +290,13 @@ func kVirtRunningPodNames(t *testing.T, ctx context.Context, kubeClient clientse
return names
}
func observeLiveMigration(t *testing.T, ctx context.Context, kubeClient clientset.Interface, usedRunningPodNames map[string]struct{}) {
func observeLiveMigration(t *testing.T, ctx context.Context, kubeClient clientset.Interface, namespace string, usedRunningPodNames map[string]struct{}) {
prevTotal := uint(0)
jumps := 0
// keep running the descheduling cycle until the migration is triggered and completed few times or times out
for i := 0; i < 240; i++ {
// monitor how many pods get evicted
names := kVirtRunningPodNames(t, ctx, kubeClient)
names := kVirtRunningPodNames(t, ctx, kubeClient, namespace)
klog.Infof("vmi pods: %#v\n", names)
// The number of pods need to be kept between vmiCount and vmiCount+1.
// At most two pods are expected to have virt-launcher-kubevirtvmi-X prefix name in common.
@@ -299,16 +331,18 @@ func observeLiveMigration(t *testing.T, ctx context.Context, kubeClient clientse
if prevTotal != 0 && prevTotal != total {
jumps++
}
// Expect at least 3 finished live migrations (two should be enough as well, though ...)
if jumps >= 6 {
// Expect at least 2 finished live migrations
if jumps >= 4 {
break
}
prevTotal = total
time.Sleep(time.Second)
time.Sleep(4 * time.Second)
}
if jumps < 6 {
podList, err := kubeClient.CoreV1().Pods("default").List(ctx, metav1.ListOptions{})
if jumps < 4 {
podList, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: virtLauncherSelector,
})
if err != nil {
klog.Infof("Unable to list pods: %v", err)
} else {
@@ -317,9 +351,9 @@ func observeLiveMigration(t *testing.T, ctx context.Context, kubeClient clientse
}
}
t.Fatalf("Expected at least 3 finished live migrations, got less: %v", jumps/2.0)
t.Fatalf("Expected at least 2 finished live migrations, got less: %v", jumps/2.0)
}
klog.Infof("The live migration finished 3 times")
klog.Infof("The live migration finished 2 times")
// len(usedRunningPodNames) is expected to be vmiCount + jumps/2 + 1 (one more live migration could still be initiated)
klog.Infof("len(usedRunningPodNames): %v, upper limit: %v\n", len(usedRunningPodNames), vmiCount+jumps/2+1)
@@ -328,7 +362,7 @@ func observeLiveMigration(t *testing.T, ctx context.Context, kubeClient clientse
}
if err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) {
names := kVirtRunningPodNames(t, ctx, kubeClient)
names := kVirtRunningPodNames(t, ctx, kubeClient, namespace)
klog.Infof("vmi pods: %#v\n", names)
lNames := len(names)
if lNames != vmiCount {
@@ -346,7 +380,13 @@ func createAndWaitForDeschedulerRunning(t *testing.T, ctx context.Context, kubeC
klog.Infof("Creating descheduler deployment %v", deschedulerDeploymentObj.Name)
_, err := kubeClient.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Create(ctx, deschedulerDeploymentObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q deployment: %v", deschedulerDeploymentObj.Name, err)
if apierrors.IsAlreadyExists(err) {
_ = kubeClient.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
_, err = kubeClient.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Create(ctx, deschedulerDeploymentObj, metav1.CreateOptions{})
}
if err != nil {
t.Fatalf("Error creating %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
}
klog.Infof("Waiting for the descheduler pod running")
@@ -385,143 +425,46 @@ func createKubevirtClient() (kubevirtclient.Interface, error) {
return kubevirtclient.NewForConfig(config)
}
func TestLiveMigrationInBackground(t *testing.T) {
initPluginRegistry()
ctx := context.Background()
kubeClient, err := client.CreateClient(componentbaseconfig.ClientConnectionConfiguration{Kubeconfig: os.Getenv("KUBECONFIG")}, "")
if err != nil {
t.Fatalf("Error during kubernetes client creation with %v", err)
func setupE2ELiveMigrationNamespace(t *testing.T, ctx context.Context, kubeClient clientset.Interface, kvClient kubevirtclient.Interface, vmiNamespace string) {
t.Helper()
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: vmiNamespace}}
if _, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) {
t.Fatalf("Unable to create namespace %v: %v", vmiNamespace, err)
}
kvClient, err := createKubevirtClient()
if err != nil {
t.Fatalf("Error during kvClient creation with %v", err)
}
waitForKubevirtReady(t, ctx, kvClient)
// Delete all VMIs
defer func() {
t.Cleanup(func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
for i := 1; i <= vmiCount; i++ {
vmi := virtualMachineInstance(i)
err := kvClient.KubevirtV1().VirtualMachineInstances("default").Delete(context.Background(), vmi.Name, metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
vmi := virtualMachineInstance(i, vmiNamespace)
if err := kvClient.KubevirtV1().VirtualMachineInstances(vmiNamespace).Delete(cleanupCtx, vmi.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
klog.Infof("Unable to delete vmi %v: %v", vmi.Name, err)
}
}
wait.PollUntilContextTimeout(ctx, 5*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) {
podList, err := kubeClient.CoreV1().Pods("default").List(ctx, metav1.ListOptions{})
wait.PollUntilContextTimeout(cleanupCtx, 5*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) {
podList, err := kubeClient.CoreV1().Pods(vmiNamespace).List(ctx, metav1.ListOptions{
LabelSelector: virtLauncherSelector,
})
if err != nil {
return false, err
}
lPods := len(podList.Items)
if lPods > 0 {
klog.Infof("Waiting until all pods under default namespace are gone, %v remaining", lPods)
klog.Infof("Waiting until all virt-launcher pods under %v namespace are gone, %v remaining", vmiNamespace, lPods)
return false, nil
}
return true, nil
})
}()
// Create N vmis and wait for the corresponding vm pods to be ready and running
for i := 1; i <= vmiCount; i++ {
vmi := virtualMachineInstance(i)
_, err = kvClient.KubevirtV1().VirtualMachineInstances("default").Create(context.Background(), vmi, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Unable to create KubeVirt vmi: %v\n", err)
if err := kubeClient.CoreV1().Namespaces().Delete(cleanupCtx, vmiNamespace, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
klog.Infof("Unable to delete namespace %v: %v", vmiNamespace, err)
}
}
// Wait until all VMIs have running pods
if err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 300*time.Second, true, func(ctx context.Context) (bool, error) {
return allVMIsHaveRunningPods(t, ctx, kubeClient, kvClient)
}); err != nil {
t.Fatalf("Error waiting for all vmi active pods to be running: %v", err)
}
usedRunningPodNames := make(map[string]struct{})
// vmiCount number of names is expected
names := kVirtRunningPodNames(t, ctx, kubeClient)
klog.Infof("vmi pods: %#v\n", names)
if len(names) != vmiCount {
t.Fatalf("Expected %v vmi pods, got %v instead", vmiCount, len(names))
}
for _, name := range names {
usedRunningPodNames[name] = struct{}{}
}
policy := podLifeTimePolicy()
// Allow only a single eviction simultaneously
policy.MaxNoOfPodsToEvictPerNamespace = utilptr.To[uint](1)
// Deploy the descheduler with the configured policy
deschedulerPolicyConfigMapObj, err := deschedulerPolicyConfigMap(policy)
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
klog.Infof("Creating %q policy CM with RemovePodsHavingTooManyRestarts configured...", deschedulerPolicyConfigMapObj.Name)
_, err = kubeClient.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Create(ctx, deschedulerPolicyConfigMapObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
defer func() {
klog.Infof("Deleting %q CM...", deschedulerPolicyConfigMapObj.Name)
err = kubeClient.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Delete(ctx, deschedulerPolicyConfigMapObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
}()
deschedulerDeploymentObj := deschedulerDeployment("kube-system")
// Set the descheduling interval to 10s
deschedulerDeploymentObj.Spec.Template.Spec.Containers[0].Args = []string{"--policy-config-file", "/policy-dir/policy.yaml", "--descheduling-interval", "10s", "--v", "4", "--feature-gates", "EvictionsInBackground=true"}
deschedulerPodName := ""
defer func() {
if deschedulerPodName != "" {
printPodLogs(ctx, t, kubeClient, deschedulerPodName)
}
klog.Infof("Deleting %q deployment...", deschedulerDeploymentObj.Name)
err = kubeClient.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return
}
t.Fatalf("Unable to delete %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
waitForPodsToDisappear(ctx, t, kubeClient, deschedulerDeploymentObj.Labels, deschedulerDeploymentObj.Namespace)
}()
deschedulerPodName = createAndWaitForDeschedulerRunning(t, ctx, kubeClient, deschedulerDeploymentObj)
observeLiveMigration(t, ctx, kubeClient, usedRunningPodNames)
printPodLogs(ctx, t, kubeClient, deschedulerPodName)
klog.Infof("Deleting the current descheduler pod")
err = kubeClient.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Error deleting %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
remainingPods := make(map[string]struct{})
for _, name := range kVirtRunningPodNames(t, ctx, kubeClient) {
remainingPods[name] = struct{}{}
}
klog.Infof("Configuring the descheduler policy %v for PodLifetime with no limits", deschedulerPolicyConfigMapObj.Name)
policy.MaxNoOfPodsToEvictPerNamespace = nil
updateDeschedulerPolicy(t, ctx, kubeClient, policy)
deschedulerDeploymentObj = deschedulerDeployment("kube-system")
deschedulerDeploymentObj.Spec.Template.Spec.Containers[0].Args = []string{"--policy-config-file", "/policy-dir/policy.yaml", "--descheduling-interval", "100m", "--v", "4", "--feature-gates", "EvictionsInBackground=true"}
deschedulerPodName = createAndWaitForDeschedulerRunning(t, ctx, kubeClient, deschedulerDeploymentObj)
})
}
func waitForVMIEvictionsWithNoLimits(t *testing.T, ctx context.Context, kubeClient clientset.Interface, vmiNamespace string, remainingPods map[string]struct{}) {
t.Helper()
klog.Infof("Waiting until all pods are evicted (no limit set)")
if err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 120*time.Second, true, func(ctx context.Context) (bool, error) {
names := kVirtRunningPodNames(t, ctx, kubeClient)
names := kVirtRunningPodNames(t, ctx, kubeClient, vmiNamespace)
for _, name := range names {
if _, exists := remainingPods[name]; exists {
klog.Infof("Waiting for %v to disappear", name)
@@ -539,3 +482,107 @@ func TestLiveMigrationInBackground(t *testing.T) {
t.Fatalf("Error waiting for %v new vmi active pods to be running: %v", vmiCount, err)
}
}
func TestLiveMigrationInBackground(t *testing.T) {
if *kubevirtVersionTag == "" {
t.Fatal("--kubevirt-version-tag must be set for KubeVirt e2e tests")
}
initPluginRegistry()
ctx := context.Background()
kubeClient, err := client.CreateClient(componentbaseconfig.ClientConnectionConfiguration{Kubeconfig: os.Getenv("KUBECONFIG")}, "")
if err != nil {
t.Fatalf("Error during kubernetes client creation with %v", err)
}
kvClient, err := createKubevirtClient()
if err != nil {
t.Fatalf("Error during kvClient creation with %v", err)
}
waitForKubevirtReady(t, ctx, kvClient)
vmiNamespace := "e2e-livemigration"
setupE2ELiveMigrationNamespace(t, ctx, kubeClient, kvClient, vmiNamespace)
for i := 1; i <= vmiCount; i++ {
vmi := virtualMachineInstance(i, vmiNamespace)
_, err = kvClient.KubevirtV1().VirtualMachineInstances(vmiNamespace).Create(context.Background(), vmi, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Unable to create KubeVirt vmi: %v\n", err)
}
}
if err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 300*time.Second, true, func(ctx context.Context) (bool, error) {
return allVMIsHaveRunningPods(t, ctx, kubeClient, kvClient, vmiNamespace)
}); err != nil {
t.Fatalf("Error waiting for all vmi active pods to be running: %v", err)
}
ensureVMIsLiveMigratable(t, ctx, kvClient, vmiNamespace)
if err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 300*time.Second, true, func(ctx context.Context) (bool, error) {
return allVMIsHaveRunningPods(t, ctx, kubeClient, kvClient, vmiNamespace)
}); err != nil {
t.Fatalf("Error waiting for all vmi active pods to be running after recreate: %v", err)
}
usedRunningPodNames := make(map[string]struct{})
names := kVirtRunningPodNames(t, ctx, kubeClient, vmiNamespace)
klog.Infof("vmi pods: %#v\n", names)
if len(names) != vmiCount {
t.Fatalf("Expected %v vmi pods, got %v instead", vmiCount, len(names))
}
for _, name := range names {
usedRunningPodNames[name] = struct{}{}
}
policy := podLifeTimePolicy(vmiNamespace)
policy.MaxNoOfPodsToEvictPerNamespace = utilptr.To[uint](1)
deschedulerPolicyConfigMapObj := createPolicyConfigMap(t, ctx, kubeClient, policy)
deschedulerDeploymentObj := deschedulerDeployment("kube-system")
deschedulerDeploymentObj.Spec.Template.Spec.Containers[0].Args = []string{"--policy-config-file", "/policy-dir/policy.yaml", "--descheduling-interval", "10s", "--v", "4", "--feature-gates", "EvictionsInBackground=true"}
deschedulerPodName := ""
t.Cleanup(func() {
if deschedulerPodName != "" {
printPodLogs(context.Background(), t, kubeClient, deschedulerPodName)
}
klog.Infof("Deleting %q deployment...", deschedulerDeploymentObj.Name)
if err := kubeClient.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(context.Background(), deschedulerDeploymentObj.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
klog.Infof("Unable to delete %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
waitForPodsToDisappear(context.Background(), t, kubeClient, deschedulerDeploymentObj.Labels, deschedulerDeploymentObj.Namespace)
})
deschedulerPodName = createAndWaitForDeschedulerRunning(t, ctx, kubeClient, deschedulerDeploymentObj)
observeLiveMigration(t, ctx, kubeClient, vmiNamespace, usedRunningPodNames)
printPodLogs(ctx, t, kubeClient, deschedulerPodName)
klog.Infof("Deleting the current descheduler pod")
err = kubeClient.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Error deleting %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
remainingPods := make(map[string]struct{})
for _, name := range kVirtRunningPodNames(t, ctx, kubeClient, vmiNamespace) {
remainingPods[name] = struct{}{}
}
klog.Infof("Configuring the descheduler policy %v for PodLifetime with no limits", deschedulerPolicyConfigMapObj.Name)
policy.MaxNoOfPodsToEvictPerNamespace = nil
updateDeschedulerPolicy(t, ctx, kubeClient, policy)
deschedulerDeploymentObj = deschedulerDeployment("kube-system")
deschedulerDeploymentObj.Spec.Template.Spec.Containers[0].Args = []string{"--policy-config-file", "/policy-dir/policy.yaml", "--descheduling-interval", "100m", "--v", "4", "--feature-gates", "EvictionsInBackground=true"}
deschedulerPodName = createAndWaitForDeschedulerRunning(t, ctx, kubeClient, deschedulerDeploymentObj)
waitForVMIEvictionsWithNoLimits(t, ctx, kubeClient, vmiNamespace, remainingPods)
}
+2 -44
View File
@@ -145,52 +145,10 @@ func TestFailedPods(t *testing.T) {
Include: []string{testNamespace.Name},
}
deschedulerPolicyConfigMapObj, err := deschedulerPolicyConfigMap(removeFailedPodsPolicy(tc.removeFailedPodsArgs, evictorArgs))
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
t.Logf("Creating %q policy CM with RemoveDuplicates configured...", deschedulerPolicyConfigMapObj.Name)
_, err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Create(ctx, deschedulerPolicyConfigMapObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
defer func() {
t.Logf("Deleting %q CM...", deschedulerPolicyConfigMapObj.Name)
err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Delete(ctx, deschedulerPolicyConfigMapObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
}()
createPolicyConfigMap(t, ctx, clientSet, removeFailedPodsPolicy(tc.removeFailedPodsArgs, evictorArgs))
deschedulerDeploymentObj := deschedulerDeployment(testNamespace.Name)
t.Logf("Creating descheduler deployment %v", deschedulerDeploymentObj.Name)
_, err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Create(ctx, deschedulerDeploymentObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
deschedulerPodName := ""
defer func() {
if deschedulerPodName != "" {
printPodLogs(ctx, t, clientSet, deschedulerPodName)
}
t.Logf("Deleting %q deployment...", deschedulerDeploymentObj.Name)
err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
waitForPodsToDisappear(ctx, t, clientSet, deschedulerDeploymentObj.Labels, deschedulerDeploymentObj.Namespace)
}()
t.Logf("Waiting for the descheduler pod running")
deschedulerPods := waitForPodsRunning(ctx, t, clientSet, deschedulerDeploymentObj.Labels, 1, deschedulerDeploymentObj.Namespace)
if len(deschedulerPods) != 0 {
deschedulerPodName = deschedulerPods[0].Name
}
createDeschedulerDeploymentWithCleanup(t, ctx, clientSet, deschedulerDeploymentObj)
// Run RemoveDuplicates strategy
var meetsExpectations bool
+13 -21
View File
@@ -135,9 +135,9 @@ func TestLeaderElection(t *testing.T) {
t.Logf("Removed kube-system/descheduler lease")
t.Log("Starting deschedulers")
pod1Name, deploy1, cm1 := startDeschedulerServer(t, ctx, clientSet, ns1)
pod1Name, deploy1, _ := startDeschedulerServer(t, ctx, clientSet, ns1)
time.Sleep(1 * time.Second)
pod2Name, deploy2, cm2 := startDeschedulerServer(t, ctx, clientSet, ns2)
pod2Name, deploy2, _ := startDeschedulerServer(t, ctx, clientSet, ns2)
defer func() {
for _, podName := range []string{pod1Name, pod2Name} {
printPodLogs(ctx, t, clientSet, podName)
@@ -153,14 +153,6 @@ func TestLeaderElection(t *testing.T) {
waitForPodsToDisappear(ctx, t, clientSet, deploy.Labels, deploy.Namespace)
}
for _, cm := range []*v1.ConfigMap{cm1, cm2} {
t.Logf("Deleting %q CM...", cm.Name)
err = clientSet.CoreV1().ConfigMaps(cm.Namespace).Delete(ctx, cm.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q CM: %v", cm.Name, err)
}
}
clientSet.CoordinationV1().Leases("kube-system").Delete(ctx, "descheduler", metav1.DeleteOptions{})
}()
@@ -223,21 +215,21 @@ func startDeschedulerServer(t *testing.T, ctx context.Context, clientSet clients
EvictFailedBarePods: false,
}
deschedulerPolicyConfigMapObj, err := deschedulerPolicyConfigMap(podlifetimePolicy(podLifeTimeArgs, evictorArgs))
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
deschedulerPolicyConfigMapObj.Name = fmt.Sprintf("%s-%s", deschedulerPolicyConfigMapObj.Name, testName)
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
t.Logf("Creating %q policy CM with RemoveDuplicates configured...", deschedulerPolicyConfigMapObj.Name)
_, err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Create(ctx, deschedulerPolicyConfigMapObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
createConfigMapWithCleanup(t, ctx, clientSet, deschedulerPolicyConfigMapObj)
deschedulerDeploymentObj := deschedulerDeployment(testName)
deschedulerDeploymentObj.Name = fmt.Sprintf("%s-%s", deschedulerDeploymentObj.Name, testName)
args := deschedulerDeploymentObj.Spec.Template.Spec.Containers[0].Args
deschedulerDeploymentObj.Spec.Template.Spec.Containers[0].Args = append(args, "--leader-elect", "--leader-elect-retry-period", "1s")
deschedulerDeploymentObj.Spec.Template.Spec.Containers[0].Args = []string{
"--policy-config-file", "/policy-dir/policy.yaml",
"--descheduling-interval", "3s",
"--v", "4",
"--leader-elect",
"--leader-elect-retry-period", "1s",
}
deschedulerDeploymentObj.Spec.Template.Spec.Volumes = []v1.Volume{
{
Name: "policy-volume",
+21 -57
View File
@@ -105,7 +105,10 @@ func TestLowNodeUtilizationKubernetesMetrics(t *testing.T) {
if _, err := clientSet.CoreV1().Namespaces().Create(ctx, testNamespace, metav1.CreateOptions{}); err != nil {
t.Fatalf("Unable to create ns %v: %v", testNamespace.Name, err)
}
defer clientSet.CoreV1().Namespaces().Delete(ctx, testNamespace.Name, metav1.DeleteOptions{})
t.Cleanup(func() {
clientSet.CoreV1().Namespaces().Delete(ctx, testNamespace.Name, metav1.DeleteOptions{})
})
t.Log("Creating duplicates pods")
testLabel := map[string]string{"app": "test-lownodeutilization-kubernetes-metrics", "name": "test-lownodeutilization-kubernetes-metrics"}
@@ -164,11 +167,11 @@ func TestLowNodeUtilizationKubernetesMetrics(t *testing.T) {
expectedEvictedPodCount: 2,
lowNodeUtilizationArgs: &nodeutilization.LowNodeUtilizationArgs{
Thresholds: api.ResourceThresholds{
v1.ResourceCPU: 30,
v1.ResourceCPU: 8,
v1.ResourcePods: 30,
},
TargetThresholds: api.ResourceThresholds{
v1.ResourceCPU: 50,
v1.ResourceCPU: 15,
v1.ResourcePods: 50,
},
MetricsUtilization: &nodeutilization.MetricsUtilization{
@@ -194,21 +197,22 @@ func TestLowNodeUtilizationKubernetesMetrics(t *testing.T) {
}
return
}
defer func() {
t.Cleanup(func() {
clientSet.AppsV1().Deployments(deploymentObj.Namespace).Delete(ctx, deploymentObj.Name, metav1.DeleteOptions{})
waitForPodsToDisappear(ctx, t, clientSet, deploymentObj.Labels, deploymentObj.Namespace)
}()
})
waitForPodsRunning(ctx, t, clientSet, deploymentObj.Labels, tc.replicasNum, deploymentObj.Namespace)
// wait until workerNodes[0].Name has the right actual cpu utilization and all the testing pods are running
// and producing ~12 cores in total
wait.PollUntilContextCancel(ctx, 5*time.Second, true, func(context.Context) (done bool, err error) {
// and producing ~4 cores in total
if err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 60*time.Second, true, func(ctx context.Context) (done bool, err error) {
item, err := metricsClient.MetricsV1beta1().NodeMetricses().Get(ctx, workerNodes[0].Name, metav1.GetOptions{})
if err != nil {
t.Logf("unable to list nodemetricses: %v", err)
return false, nil
}
t.Logf("Waiting for %q nodemetrics cpu utilization to get over 12, currently %v", workerNodes[0].Name, item.Usage.Cpu().Value())
if item.Usage.Cpu().Value() < 12 {
t.Logf("Waiting for %q nodemetrics cpu utilization to get over 3, currently %v", workerNodes[0].Name, item.Usage.Cpu().Value())
if item.Usage.Cpu().Value() < 3 {
return false, nil
}
totalCpu := resource.NewMilliQuantity(0, resource.DecimalSI)
@@ -225,60 +229,20 @@ func TestLowNodeUtilizationKubernetesMetrics(t *testing.T) {
totalCpu.Add(container.Usage[v1.ResourceCPU])
}
}
// Value() will round up (e.g. 11.1 -> 12), which is still ok
t.Logf("Waiting for totalCpu to get to 12 at least, got %v\n", totalCpu.Value())
return totalCpu.Value() >= 12, nil
})
// Value() will round up (e.g. 3.1 -> 4), which is still ok
t.Logf("Waiting for totalCpu to get to 3 at least, got %v\n", totalCpu.Value())
return totalCpu.Value() >= 3, nil
}); err != nil {
t.Fatalf("Error waiting for node/pod metrics: %v", err)
}
preRunNames := sets.NewString(getCurrentPodNames(ctx, clientSet, testNamespace.Name, t)...)
// Deploy the descheduler with the configured policy
deschedulerPolicyConfigMapObj, err := deschedulerPolicyConfigMap(lowNodeUtilizationPolicy(tc.lowNodeUtilizationArgs, tc.evictorArgs, tc.metricsCollectorEnabled))
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
t.Logf("Creating %q policy CM with LowNodeUtilization configured...", deschedulerPolicyConfigMapObj.Name)
_, err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Create(ctx, deschedulerPolicyConfigMapObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
defer func() {
t.Logf("Deleting %q CM...", deschedulerPolicyConfigMapObj.Name)
err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Delete(ctx, deschedulerPolicyConfigMapObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
}()
createPolicyConfigMap(t, ctx, clientSet, lowNodeUtilizationPolicy(tc.lowNodeUtilizationArgs, tc.evictorArgs, tc.metricsCollectorEnabled))
deschedulerDeploymentObj := deschedulerDeployment(testNamespace.Name)
t.Logf("Creating descheduler deployment %v", deschedulerDeploymentObj.Name)
_, err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Create(ctx, deschedulerDeploymentObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
deschedulerPodName := ""
defer func() {
if deschedulerPodName != "" {
printPodLogs(ctx, t, clientSet, deschedulerPodName)
}
t.Logf("Deleting %q deployment...", deschedulerDeploymentObj.Name)
err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
waitForPodsToDisappear(ctx, t, clientSet, deschedulerDeploymentObj.Labels, deschedulerDeploymentObj.Namespace)
}()
t.Logf("Waiting for the descheduler pod running")
deschedulerPods := waitForPodsRunning(ctx, t, clientSet, deschedulerDeploymentObj.Labels, 1, deschedulerDeploymentObj.Namespace)
if len(deschedulerPods) != 0 {
deschedulerPodName = deschedulerPods[0].Name
}
createDeschedulerDeploymentWithCleanup(t, ctx, clientSet, deschedulerDeploymentObj)
// Run LowNodeUtilization plugin
var meetsExpectations bool
+1 -15
View File
@@ -289,21 +289,7 @@ func TestProtectPodsWithPVC(t *testing.T) {
t.Fatalf("Error creating %q CM: %v", policycm.Name, err)
}
t.Logf("creating %q policy CM with PodsWithPVC protection enabled...", policycm.Name)
if _, err = cli.CoreV1().ConfigMaps(policycm.Namespace).Create(
ctx, policycm, metav1.CreateOptions{},
); err != nil {
t.Fatalf("error creating %q CM: %v", policycm.Name, err)
}
defer func() {
t.Logf("deleting %q CM...", policycm.Name)
if err := cli.CoreV1().ConfigMaps(policycm.Namespace).Delete(
ctx, policycm.Name, metav1.DeleteOptions{},
); err != nil {
t.Fatalf("unable to delete %q CM: %v", policycm.Name, err)
}
}()
createConfigMapWithCleanup(t, ctx, cli, policycm)
desdep := deschedulerDeployment(namespace.Name)
t.Logf("creating descheduler deployment %v", desdep.Name)
+87 -4
View File
@@ -31,6 +31,7 @@ import (
appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/core/v1"
schedulingv1 "k8s.io/api/scheduling/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
@@ -67,11 +68,16 @@ import (
)
var (
deschedulerImage = flag.String("descheduler-image", "", "descheduler image to set in the pod spec")
podRunAsUserId = flag.Int64("pod-run-as-user-id", 0, ".spec.securityContext.runAsUser setting, not set if 0")
podRunAsGroupId = flag.Int64("pod-run-as-group-id", 0, ".spec.securityContext.runAsGroup setting, not set if 0")
deschedulerImage = flag.String("descheduler-image", "", "descheduler image to set in the pod spec")
kubevirtVersionTag = flag.String("kubevirt-version-tag", "", "KubeVirt release tag for container disk images in KubeVirt e2e tests (e.g. v1.9.0)")
podRunAsUserId = flag.Int64("pod-run-as-user-id", 0, ".spec.securityContext.runAsUser setting, not set if 0")
podRunAsGroupId = flag.Int64("pod-run-as-group-id", 0, ".spec.securityContext.runAsGroup setting, not set if 0")
)
func kubevirtCirrosContainerDiskImage() string {
return fmt.Sprintf("quay.io/kubevirt/cirros-container-disk-demo:%s", *kubevirtVersionTag)
}
func TestMain(m *testing.M) {
flag.Parse()
@@ -84,7 +90,12 @@ func TestMain(m *testing.M) {
}
func isClientRateLimiterError(err error) bool {
return strings.Contains(err.Error(), "client rate limiter")
if err == nil {
return false
}
return strings.Contains(err.Error(), "client rate limiter") &&
!strings.Contains(err.Error(), "context deadline exceeded") &&
!strings.Contains(err.Error(), "context canceled")
}
func initFeatureGates() featuregate.FeatureGate {
@@ -112,6 +123,78 @@ func deschedulerPolicyConfigMap(policy *deschedulerapiv1alpha2.DeschedulerPolicy
return cm, nil
}
// createPolicyConfigMap generates, creates (or recreates if already existing), and registers cleanup for a descheduler policy ConfigMap.
func createPolicyConfigMap(t *testing.T, ctx context.Context, kubeClient clientset.Interface, policy *deschedulerapiv1alpha2.DeschedulerPolicy) *v1.ConfigMap {
t.Helper()
cm, err := deschedulerPolicyConfigMap(policy)
if err != nil {
t.Fatalf("Error creating policy CM object: %v", err)
}
return createConfigMapWithCleanup(t, ctx, kubeClient, cm)
}
// createConfigMapWithCleanup creates (or recreates if already existing) a ConfigMap and registers a t.Cleanup callback to delete it.
func createConfigMapWithCleanup(t *testing.T, ctx context.Context, kubeClient clientset.Interface, cm *v1.ConfigMap) *v1.ConfigMap {
t.Helper()
t.Logf("Creating %q policy CM...", cm.Name)
_, err := kubeClient.CoreV1().ConfigMaps(cm.Namespace).Create(ctx, cm, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
_ = kubeClient.CoreV1().ConfigMaps(cm.Namespace).Delete(ctx, cm.Name, metav1.DeleteOptions{})
_, err = kubeClient.CoreV1().ConfigMaps(cm.Namespace).Create(ctx, cm, metav1.CreateOptions{})
}
if err != nil {
t.Fatalf("Error creating %q CM: %v", cm.Name, err)
}
}
t.Cleanup(func() {
t.Logf("Deleting %q CM...", cm.Name)
if err := kubeClient.CoreV1().ConfigMaps(cm.Namespace).Delete(context.Background(), cm.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
t.Logf("Unable to delete %q CM: %v", cm.Name, err)
}
})
return cm
}
// createDeschedulerDeploymentWithCleanup creates (or recreates if already existing) a descheduler deployment and registers a t.Cleanup callback.
func createDeschedulerDeploymentWithCleanup(t *testing.T, ctx context.Context, kubeClient clientset.Interface, deployment *appsv1.Deployment) string {
t.Helper()
t.Logf("Creating descheduler deployment %v", deployment.Name)
_, err := kubeClient.AppsV1().Deployments(deployment.Namespace).Create(ctx, deployment, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
_ = kubeClient.AppsV1().Deployments(deployment.Namespace).Delete(ctx, deployment.Name, metav1.DeleteOptions{})
_, err = kubeClient.AppsV1().Deployments(deployment.Namespace).Create(ctx, deployment, metav1.CreateOptions{})
}
if err != nil {
t.Fatalf("Error creating %q deployment: %v", deployment.Name, err)
}
}
deschedulerPodName := ""
t.Cleanup(func() {
if deschedulerPodName != "" {
printPodLogs(context.Background(), t, kubeClient, deschedulerPodName)
}
t.Logf("Deleting %q deployment...", deployment.Name)
if err := kubeClient.AppsV1().Deployments(deployment.Namespace).Delete(context.Background(), deployment.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
t.Logf("Unable to delete %q deployment: %v", deployment.Name, err)
}
waitForPodsToDisappear(context.Background(), t, kubeClient, deployment.Labels, deployment.Namespace)
})
t.Logf("Waiting for the descheduler pod running")
deschedulerPods := waitForPodsRunning(ctx, t, kubeClient, deployment.Labels, 1, deployment.Namespace)
if len(deschedulerPods) != 0 {
deschedulerPodName = deschedulerPods[0].Name
}
return deschedulerPodName
}
func deschedulerDeployment(testName string) *appsv1.Deployment {
deploymentObject := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
+2 -43
View File
@@ -153,51 +153,10 @@ func TestTooManyRestarts(t *testing.T) {
rs.DefaultFeatureGates = initFeatureGates()
preRunNames := sets.NewString(getCurrentPodNames(ctx, clientSet, testNamespace.Name, t)...)
// Deploy the descheduler with the configured policy
deschedulerPolicyConfigMapObj, err := deschedulerPolicyConfigMap(tc.policy)
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
t.Logf("Creating %q policy CM with RemovePodsHavingTooManyRestarts configured...", deschedulerPolicyConfigMapObj.Name)
_, err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Create(ctx, deschedulerPolicyConfigMapObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
defer func() {
t.Logf("Deleting %q CM...", deschedulerPolicyConfigMapObj.Name)
err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Delete(ctx, deschedulerPolicyConfigMapObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
}()
createPolicyConfigMap(t, ctx, clientSet, tc.policy)
deschedulerDeploymentObj := deschedulerDeployment(testNamespace.Name)
t.Logf("Creating descheduler deployment %v", deschedulerDeploymentObj.Name)
_, err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Create(ctx, deschedulerDeploymentObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
deschedulerPodName := ""
defer func() {
if deschedulerPodName != "" {
printPodLogs(ctx, t, clientSet, deschedulerPodName)
}
t.Logf("Deleting %q deployment...", deschedulerDeploymentObj.Name)
err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
waitForPodsToDisappear(ctx, t, clientSet, deschedulerDeploymentObj.Labels, deschedulerDeploymentObj.Namespace)
}()
t.Logf("Waiting for the descheduler pod running")
deschedulerPods := waitForPodsRunning(ctx, t, clientSet, deschedulerDeploymentObj.Labels, 1, deschedulerDeploymentObj.Namespace)
if len(deschedulerPods) != 0 {
deschedulerPodName = deschedulerPods[0].Name
}
createDeschedulerDeploymentWithCleanup(t, ctx, clientSet, deschedulerDeploymentObj)
// Run RemovePodsHavingTooManyRestarts strategy
if err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 50*time.Second, true, func(ctx context.Context) (bool, error) {
currentRunNames := sets.NewString(getCurrentPodNames(ctx, clientSet, testNamespace.Name, t)...)
+2 -43
View File
@@ -219,50 +219,9 @@ func TestTopologySpreadConstraint(t *testing.T) {
Include: []string{testNamespace.Name},
},
}
deschedulerPolicyConfigMapObj, err := deschedulerPolicyConfigMap(topologySpreadConstraintPolicy(constraintArgs, evictorArgs))
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
t.Logf("Creating %q policy CM with RemovePodsHavingTooManyRestarts configured...", deschedulerPolicyConfigMapObj.Name)
_, err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Create(ctx, deschedulerPolicyConfigMapObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
defer func() {
t.Logf("Deleting %q CM...", deschedulerPolicyConfigMapObj.Name)
err = clientSet.CoreV1().ConfigMaps(deschedulerPolicyConfigMapObj.Namespace).Delete(ctx, deschedulerPolicyConfigMapObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q CM: %v", deschedulerPolicyConfigMapObj.Name, err)
}
}()
createPolicyConfigMap(t, ctx, clientSet, topologySpreadConstraintPolicy(constraintArgs, evictorArgs))
deschedulerDeploymentObj := deschedulerDeployment(testNamespace.Name)
t.Logf("Creating descheduler deployment %v", deschedulerDeploymentObj.Name)
_, err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Create(ctx, deschedulerDeploymentObj, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
deschedulerPodName := ""
defer func() {
if deschedulerPodName != "" {
printPodLogs(ctx, t, clientSet, deschedulerPodName)
}
t.Logf("Deleting %q deployment...", deschedulerDeploymentObj.Name)
err = clientSet.AppsV1().Deployments(deschedulerDeploymentObj.Namespace).Delete(ctx, deschedulerDeploymentObj.Name, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("Unable to delete %q deployment: %v", deschedulerDeploymentObj.Name, err)
}
waitForPodsToDisappear(ctx, t, clientSet, deschedulerDeploymentObj.Labels, deschedulerDeploymentObj.Namespace)
}()
t.Logf("Waiting for the descheduler pod running")
deschedulerPods := waitForPodsRunning(ctx, t, clientSet, deschedulerDeploymentObj.Labels, 1, deschedulerDeploymentObj.Namespace)
if len(deschedulerPods) != 0 {
deschedulerPodName = deschedulerPods[0].Name
}
createDeschedulerDeploymentWithCleanup(t, ctx, clientSet, deschedulerDeploymentObj)
// Run RemovePodsHavingTooManyRestarts strategy
var meetsEvictedExpectations bool
+2
View File
@@ -1,3 +1,5 @@
# Used by the manifests GitHub Actions workflow (.github/workflows/manifests.yaml).
# Three-node cluster without topology zone labels; not used by e2e tests.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Copyright 2017 The Kubernetes 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.
e2e_repo_root() {
local script_dir="$1"
cd "${script_dir}/.." && pwd
}
collect_logs() {
local namespace pod
echo "Collecting pods and logs"
for namespace in "$@"; do
kubectl get pods -n "${namespace}"
for pod in $(kubectl get pods -n "${namespace}" -o name); do
echo "Logs for ${pod}"
kubectl logs -n "${namespace}" "${pod}"
done
done
}
kind_load_image() {
local image="$1"
if [ "${CONTAINER_ENGINE}" = "podman" ]; then
local archive
archive="/tmp/$(echo "${image}" | tr '/:' '_').tar"
podman save "${image}" -o "${archive}"
${KIND_SUDO} kind load image-archive "${archive}"
rm "${archive}"
else
${KIND_SUDO} kind load docker-image "${image}"
fi
}
+6
View File
@@ -0,0 +1,6 @@
# E2E tool version defaults. Override via environment variables.
# Keep .github/workflows/manifests.yaml matrix versions in sync with these values.
K8S_VERSION=${KUBERNETES_VERSION:-v1.36.1}
KIND_VERSION=${KIND_VERSION:-v0.31.0}
METRICS_SERVER_VERSION=${METRICS_SERVER_VERSION:-v0.8.1}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Copyright 2017 The Kubernetes 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.
kubectl create -f "https://github.com/kubevirt/kubevirt/releases/download/${KUBEVIRT_VERSION}/kubevirt-operator.yaml"
kubectl create -f "https://github.com/kubevirt/kubevirt/releases/download/${KUBEVIRT_VERSION}/kubevirt-cr.yaml"
# TODO(1.37): drop this patch when the k8s 1.34 e2e lane is removed. ImageVolume needs k8s >= 1.35.
if [[ "${K8S_VERSION}" == v1.34* ]]; then
kubectl -n kubevirt patch kubevirt kubevirt --type=merge --patch '{"spec":{"configuration":{"developerConfiguration":{"useEmulation":true,"disabledFeatureGates":["ImageVolume"]}}}}'
else
kubectl -n kubevirt patch kubevirt kubevirt --type=merge --patch '{"spec":{"configuration":{"developerConfiguration":{"useEmulation":true}}}}'
fi
kubectl wait --timeout=300s --for=condition=Available -n kubevirt kv/kubevirt
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Copyright 2017 The Kubernetes 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.
kubectl apply -f "https://github.com/kubernetes-sigs/metrics-server/releases/download/${METRICS_SERVER_VERSION}/components.yaml"
kubectl patch -n kube-system deployment metrics-server --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl wait --timeout=180s --for=condition=Available -n kube-system deployment/metrics-server
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Copyright 2017 The Kubernetes 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.
KIND_NODE_IMAGE=${KIND_NODE_IMAGE:-localhost/kindest/node:${K8S_VERSION}}
if [ -z "${SKIP_KUBECTL_INSTALL}" ]; then
curl -Lo kubectl "https://dl.k8s.io/release/${K8S_VERSION}/bin/linux/amd64/kubectl" && chmod +x kubectl && mv kubectl /usr/local/bin/
fi
if [ -z "${SKIP_KIND_INSTALL}" ]; then
wget "https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64"
chmod +x kind-linux-amd64
mv kind-linux-amd64 kind
export PATH=$PATH:$PWD
fi
if [ -z "${SKIP_INSTALL}" ]; then
${KIND_SUDO} kind build node-image "${K8S_VERSION}" --image "${KIND_NODE_IMAGE}"
${KIND_SUDO} kind create cluster --image "${KIND_NODE_IMAGE}" --config="${REPO_ROOT}/hack/kind_config.yaml"
fi
${CONTAINER_ENGINE} pull registry.k8s.io/pause
kind_load_image registry.k8s.io/pause
kind_load_image "${DESCHEDULER_IMAGE}"
${KIND_SUDO} kind get kubeconfig > /tmp/admin.conf
export KUBECONFIG="/tmp/admin.conf"
mkdir -p ~/gopath/src/sigs.k8s.io/
+22 -72
View File
@@ -18,100 +18,50 @@ set -x
set -o errexit
set -o nounset
BASEDIR=$(dirname "$0")
# shellcheck source=test/lib/e2e-common.sh
source "${BASEDIR}/lib/e2e-common.sh"
# shellcheck source=test/lib/e2e-versions.env
source "${BASEDIR}/lib/e2e-versions.env"
REPO_ROOT="$(e2e_repo_root "${BASEDIR}")"
# Set to empty if unbound/empty
SKIP_INSTALL=${SKIP_INSTALL:-}
KIND_E2E=${KIND_E2E:-}
CONTAINER_ENGINE=${CONTAINER_ENGINE:-docker}
KIND_SUDO=${KIND_SUDO:-}
KIND_VERSION=${KIND_VERSION:-v0.31.0}
SKIP_KUBECTL_INSTALL=${SKIP_KUBECTL_INSTALL:-}
SKIP_KIND_INSTALL=${SKIP_KIND_INSTALL:-}
SKIP_KUBEVIRT_INSTALL=${SKIP_KUBEVIRT_INSTALL:-}
# v1.9.0-alpha.0 (or newer) is required for Kubernetes v1.36+, which
# enforces stricter CRD numeric format validation
# (https://github.com/kubernetes/kubernetes/pull/136582) and rejects the
# pre-fix VMI checksum status schema present in v1.8.x. Fixed upstream
# by https://github.com/kubevirt/kubevirt/pull/17469 (not backported to
# v1.8.x). See https://github.com/kubevirt/kubevirt/issues/17858.
KUBEVIRT_VERSION=${KUBEVIRT_VERSION:-v1.9.0-alpha.0}
SKIP_METRICS_SERVER_INSTALL=${SKIP_METRICS_SERVER_INSTALL:-}
KUBEVIRT_VERSION=${KUBEVIRT_VERSION:-$(grep 'kubevirt.io/api ' "${REPO_ROOT}/go.mod" | awk '{print $2}')}
# Build a descheduler image
IMAGE_TAG=v$(date +%Y%m%d)-$(git describe --tags)
BASEDIR=$(dirname "$0")
VERSION="${IMAGE_TAG}" make -C ${BASEDIR}/.. image
VERSION="${IMAGE_TAG}" make -C "${REPO_ROOT}" image
export DESCHEDULER_IMAGE="docker.io/library/descheduler:${IMAGE_TAG}"
echo "DESCHEDULER_IMAGE: ${DESCHEDULER_IMAGE}"
# This just runs e2e tests.
if [ -n "$KIND_E2E" ]; then
K8S_VERSION=${KUBERNETES_VERSION:-v1.36.1}
KIND_NODE_IMAGE=${KIND_NODE_IMAGE:-localhost/kindest/node:${K8S_VERSION}}
if [ -z "${SKIP_KUBECTL_INSTALL}" ]; then
curl -Lo kubectl https://dl.k8s.io/release/${K8S_VERSION}/bin/linux/amd64/kubectl && chmod +x kubectl && mv kubectl /usr/local/bin/
fi
if [ -z "${SKIP_KIND_INSTALL}" ]; then
wget https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64
chmod +x kind-linux-amd64
mv kind-linux-amd64 kind
export PATH=$PATH:$PWD
fi
# If we did not set SKIP_INSTALL
if [ -z "$SKIP_INSTALL" ]; then
${KIND_SUDO} kind build node-image ${K8S_VERSION} --image ${KIND_NODE_IMAGE}
${KIND_SUDO} kind create cluster --image ${KIND_NODE_IMAGE} --config=./hack/kind_config.yaml
fi
${CONTAINER_ENGINE} pull registry.k8s.io/pause
if [ "${CONTAINER_ENGINE}" == "podman" ]; then
podman save registry.k8s.io/pause -o /tmp/pause.tar
${KIND_SUDO} kind load image-archive /tmp/pause.tar
rm /tmp/pause.tar
podman save ${DESCHEDULER_IMAGE} -o /tmp/descheduler.tar
${KIND_SUDO} kind load image-archive /tmp/descheduler.tar
rm /tmp/descheduler.tar
else
${KIND_SUDO} kind load docker-image registry.k8s.io/pause
${KIND_SUDO} kind load docker-image ${DESCHEDULER_IMAGE}
fi
${KIND_SUDO} kind get kubeconfig > /tmp/admin.conf
export KUBECONFIG="/tmp/admin.conf"
mkdir -p ~/gopath/src/sigs.k8s.io/
if [ -n "${KIND_E2E}" ]; then
# shellcheck source=test/lib/setup-kind.sh
source "${BASEDIR}/lib/setup-kind.sh"
fi
# Deploy rbac, sa and binding for a descheduler running through a deployment
kubectl apply -f kubernetes/base/rbac.yaml
kubectl apply -f "${REPO_ROOT}/kubernetes/base/rbac.yaml"
collect_logs() {
echo "Collecting pods and logs"
kubectl get pods -n default
kubectl get pods -n kubevirt
for pod in $(kubectl get pods -n default -o name); do
echo "Logs for ${pod}"
kubectl logs -n default ${pod}
done
for pod in $(kubectl get pods -n kubevirt -o name); do
echo "Logs for ${pod}"
kubectl logs -n kubevirt ${pod}
done
}
trap "collect_logs" ERR
trap 'collect_logs default kubevirt' ERR
if [ -z "${SKIP_KUBEVIRT_INSTALL}" ]; then
kubectl create -f https://github.com/kubevirt/kubevirt/releases/download/${KUBEVIRT_VERSION}/kubevirt-operator.yaml
kubectl create -f https://github.com/kubevirt/kubevirt/releases/download/${KUBEVIRT_VERSION}/kubevirt-cr.yaml
kubectl wait --timeout=180s --for=condition=Available -n kubevirt kv/kubevirt
kubectl -n kubevirt patch kubevirt kubevirt --type=merge --patch '{"spec":{"configuration":{"developerConfiguration":{"useEmulation":true}}}}'
# shellcheck source=test/lib/install-kubevirt.sh
source "${BASEDIR}/lib/install-kubevirt.sh"
fi
METRICS_SERVER_VERSION="v0.8.1"
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/${METRICS_SERVER_VERSION}/components.yaml
kubectl patch -n kube-system deployment metrics-server --type=json \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
if [ -z "${SKIP_METRICS_SERVER_INSTALL}" ]; then
# shellcheck source=test/lib/install-metrics-server.sh
source "${BASEDIR}/lib/install-metrics-server.sh"
fi
PRJ_PREFIX="sigs.k8s.io/descheduler"
go test ${PRJ_PREFIX}/test/e2e/ -v -timeout 0 --args --descheduler-image ${DESCHEDULER_IMAGE} --pod-run-as-user-id 1000 --pod-run-as-group-id 1000
go test ${PRJ_PREFIX}/test/e2e/ -v -timeout 0 --args --descheduler-image "${DESCHEDULER_IMAGE}" --kubevirt-version-tag "${KUBEVIRT_VERSION}" --pod-run-as-user-id 1000 --pod-run-as-group-id 1000
+4 -4
View File
@@ -17,9 +17,9 @@ ANTLR4 that it is compatible with (I.E. uses the /v4 path).
However, this was found to be problematic, as it meant that with the runtime embedded so far underneath the root
of the repo, the `go get` and related commands could not properly resolve the location of the go runtime source code.
This meant that the reference to the runtime in your `go.mod` file would refer to the correct source code, but would not
list the release tag such as @4.12.0 - this was confusing, to say the least.
list the release tag such as @4.13.1 - this was confusing, to say the least.
As of 4.12.1, the runtime is now available as a go module in its own repo, and can be imported as `github.com/antlr4-go/antlr`
As of 4.13.0, the runtime is now available as a go module in its own repo, and can be imported as `github.com/antlr4-go/antlr`
(the go get command should also be used with this path). See the main documentation for the ANTLR4 project for more information,
which is available at [ANTLR docs]. The documentation for using the Go runtime is available at [Go runtime docs].
@@ -49,7 +49,7 @@ Here is a general/recommended template for an ANTLR based recognizer in Go:
.
parser
mygrammar.g4
antlr-4.12.1-complete.jar
antlr-4.13.1-complete.jar
generate.go
generate.sh
parsing - generated code goes here
@@ -71,7 +71,7 @@ And the generate.sh file will look similar to this:
#!/bin/sh
alias antlr4='java -Xmx500M -cp "./antlr4-4.12.1-complete.jar:$CLASSPATH" org.antlr.v4.Tool'
alias antlr4='java -Xmx500M -cp "./antlr4-4.13.1-complete.jar:$CLASSPATH" org.antlr.v4.Tool'
antlr4 -Dlanguage=Go -no-visitor -package parsing *.g4
depending on whether you want visitors or listeners or any other ANTLR options. Not that another option here
+3 -5
View File
@@ -4,8 +4,6 @@
package antlr
import "sync"
// ATNInvalidAltNumber is used to represent an ALT number that has yet to be calculated or
// which is invalid for a particular struct such as [*antlr.BaseRuleContext]
var ATNInvalidAltNumber int
@@ -56,9 +54,9 @@ type ATN struct {
//
states []ATNState
mu sync.Mutex
stateMu sync.RWMutex
edgeMu sync.RWMutex
mu Mutex
stateMu RWMutex
edgeMu RWMutex
}
// NewATN returns a new ATN struct representing the given grammarType and is used
-3
View File
@@ -73,9 +73,6 @@ func NewATNConfig1(c *ATNConfig, state ATNState, context *PredictionContext) *AT
// NewATNConfig creates a new ATNConfig instance given an existing config, a state, a context and a semantic context, other 'constructors'
// are just wrappers around this one.
func NewATNConfig(c *ATNConfig, state ATNState, context *PredictionContext, semanticContext SemanticContext) *ATNConfig {
if semanticContext == nil {
panic("semanticContext cannot be nil") // TODO: Remove this - probably put here for some bug that is now fixed
}
b := &ATNConfig{}
b.InitATNConfig(c, state, c.GetAlt(), context, semanticContext)
b.cType = parserConfig
+1 -1
View File
@@ -148,7 +148,7 @@ func (is *InputStream) GetTextFromInterval(i Interval) string {
}
func (*InputStream) GetSourceName() string {
return ""
return "Obtained from string"
}
// String returns the entire input stream as a string
+2 -3
View File
@@ -8,7 +8,6 @@ import (
"container/list"
"runtime/debug"
"sort"
"sync"
)
// Collectable is an interface that a struct should implement if it is to be
@@ -587,12 +586,12 @@ type VisitRecord struct {
type VisitList struct {
cache *list.List
lock sync.RWMutex
lock RWMutex
}
var visitListPool = VisitList{
cache: list.New(),
lock: sync.RWMutex{},
lock: RWMutex{},
}
// NewVisitRecord returns a new VisitRecord instance from the pool if available.
+1 -1
View File
@@ -207,7 +207,7 @@ func (b *BaseLexer) NextToken() Token {
for {
b.thetype = TokenInvalidType
ttype := b.safeMatch()
ttype := b.safeMatch() // Defaults to LexerSkip
if b.input.LA(1) == TokenEOF {
b.hitEOF = true
+1
View File
@@ -40,6 +40,7 @@ func (la *LL1Analyzer) getDecisionLookahead(s ATNState) []*IntervalSet {
for alt := 0; alt < count; alt++ {
look[alt] = NewIntervalSet()
// TODO: This is one of the reasons that ATNConfigs are allocated and freed all the time - fix this tomorrow jim!
lookBusy := NewJStore[*ATNConfig, Comparator[*ATNConfig]](aConfEqInst, ClosureBusyCollection, "LL1Analyzer.getDecisionLookahead for lookBusy")
la.look1(s.GetTransitions()[alt].getTarget(), nil, BasePredictionContextEMPTY, look[alt], lookBusy, NewBitSet(), false, false)
+41
View File
@@ -0,0 +1,41 @@
//go:build !antlr.nomutex
// +build !antlr.nomutex
package antlr
import "sync"
// Mutex is a simple mutex implementation which just delegates to sync.Mutex, it
// is used to provide a mutex implementation for the antlr package, which users
// can turn off with the build tag -tags antlr.nomutex
type Mutex struct {
mu sync.Mutex
}
func (m *Mutex) Lock() {
m.mu.Lock()
}
func (m *Mutex) Unlock() {
m.mu.Unlock()
}
type RWMutex struct {
mu sync.RWMutex
}
func (m *RWMutex) Lock() {
m.mu.Lock()
}
func (m *RWMutex) Unlock() {
m.mu.Unlock()
}
func (m *RWMutex) RLock() {
m.mu.RLock()
}
func (m *RWMutex) RUnlock() {
m.mu.RUnlock()
}
+32
View File
@@ -0,0 +1,32 @@
//go:build antlr.nomutex
// +build antlr.nomutex
package antlr
type Mutex struct{}
func (m *Mutex) Lock() {
// No-op
}
func (m *Mutex) Unlock() {
// No-op
}
type RWMutex struct{}
func (m *RWMutex) Lock() {
// No-op
}
func (m *RWMutex) Unlock() {
// No-op
}
func (m *RWMutex) RLock() {
// No-op
}
func (m *RWMutex) RUnlock() {
// No-op
}
+1 -3
View File
@@ -10,8 +10,6 @@ import (
"strings"
)
var ()
// ClosureBusy is a store of ATNConfigs and is a tiny abstraction layer over
// a standard JStore so that we can use Lazy instantiation of the JStore, mostly
// to avoid polluting the stats module with a ton of JStore instances with nothing in them.
@@ -883,7 +881,7 @@ func (p *ParserATNSimulator) getPredicatePredictions(ambigAlts *BitSet, altToPre
// the ERROR state was reached, outerContext as the initial parser context from the paper
// or the parser stack at the instant before prediction commences.
//
// Teh func returns the value to return from [AdaptivePredict], or
// The func returns the value to return from [AdaptivePredict], or
// [ATNInvalidAltNumber] if a suitable alternative was not
// identified and [AdaptivePredict] should report an error instead.
func (p *ParserATNSimulator) getSynValidOrSemInvalidAltThatFinishedDecisionEntryRule(configs *ATNConfigSet, outerContext ParserRuleContext) int {
+30 -30
View File
@@ -6,7 +6,6 @@ package antlr
import (
"fmt"
"golang.org/x/exp/slices"
"strconv"
)
@@ -101,7 +100,7 @@ func NewArrayPredictionContext(parents []*PredictionContext, returnStates []int)
hash = murmurUpdate(hash, returnState)
}
hash = murmurFinish(hash, len(parents)<<1)
nec := &PredictionContext{}
nec.cachedHash = hash
nec.pcType = PredictionContextArray
@@ -115,6 +114,9 @@ func (p *PredictionContext) Hash() int {
}
func (p *PredictionContext) Equals(other Collectable[*PredictionContext]) bool {
if p == other {
return true
}
switch p.pcType {
case PredictionContextEmpty:
otherP := other.(*PredictionContext)
@@ -138,13 +140,11 @@ func (p *PredictionContext) ArrayEquals(o Collectable[*PredictionContext]) bool
if p.cachedHash != other.Hash() {
return false // can't be same if hash is different
}
// Must compare the actual array elements and not just the array address
//
return slices.Equal(p.returnStates, other.returnStates) &&
slices.EqualFunc(p.parents, other.parents, func(x, y *PredictionContext) bool {
return x.Equals(y)
})
return intSlicesEqual(p.returnStates, other.returnStates) &&
pcSliceEqual(p.parents, other.parents)
}
func (p *PredictionContext) SingletonEquals(other Collectable[*PredictionContext]) bool {
@@ -152,23 +152,23 @@ func (p *PredictionContext) SingletonEquals(other Collectable[*PredictionContext
return false
}
otherP := other.(*PredictionContext)
if otherP == nil {
if otherP == nil || otherP.pcType != PredictionContextSingleton {
return false
}
if p.cachedHash != otherP.Hash() {
return false // Can't be same if hash is different
}
if p.returnState != otherP.getReturnState(0) {
return false
}
// Both parents must be nil if one is
if p.parentCtx == nil {
return otherP.parentCtx == nil
}
return p.parentCtx.Equals(otherP.parentCtx)
}
@@ -225,27 +225,27 @@ func (p *PredictionContext) String() string {
return "$"
case PredictionContextSingleton:
var up string
if p.parentCtx == nil {
up = ""
} else {
up = p.parentCtx.String()
}
if len(up) == 0 {
if p.returnState == BasePredictionContextEmptyReturnState {
return "$"
}
return strconv.Itoa(p.returnState)
}
return strconv.Itoa(p.returnState) + " " + up
case PredictionContextArray:
if p.isEmpty() {
return "[]"
}
s := "["
for i := 0; i < len(p.returnStates); i++ {
if i > 0 {
@@ -263,7 +263,7 @@ func (p *PredictionContext) String() string {
}
}
return s + "]"
default:
return "unknown"
}
@@ -309,18 +309,18 @@ func predictionContextFromRuleContext(a *ATN, outerContext RuleContext) *Predict
parent := predictionContextFromRuleContext(a, outerContext.GetParent().(RuleContext))
state := a.states[outerContext.GetInvokingState()]
transition := state.GetTransitions()[0]
return SingletonBasePredictionContextCreate(parent, transition.(*RuleTransition).followState.GetStateNumber())
}
func merge(a, b *PredictionContext, rootIsWildcard bool, mergeCache *JPCMap) *PredictionContext {
// Share same graph if both same
//
if a == b || a.Equals(b) {
return a
}
if a.pcType == PredictionContextSingleton && b.pcType == PredictionContextSingleton {
return mergeSingletons(a, b, rootIsWildcard, mergeCache)
}
@@ -334,7 +334,7 @@ func merge(a, b *PredictionContext, rootIsWildcard bool, mergeCache *JPCMap) *Pr
return b
}
}
// Convert either Singleton or Empty to arrays, so that we can merge them
//
ara := convertToArray(a)
@@ -395,7 +395,7 @@ func mergeSingletons(a, b *PredictionContext, rootIsWildcard bool, mergeCache *J
return previous
}
}
rootMerge := mergeRoot(a, b, rootIsWildcard)
if rootMerge != nil {
if mergeCache != nil {
@@ -564,7 +564,7 @@ func mergeArrays(a, b *PredictionContext, rootIsWildcard bool, mergeCache *JPCMa
i := 0 // walks a
j := 0 // walks b
k := 0 // walks target M array
mergedReturnStates := make([]int, len(a.returnStates)+len(b.returnStates))
mergedParents := make([]*PredictionContext, len(a.returnStates)+len(b.returnStates))
// walk and merge to yield mergedParents, mergedReturnStates
@@ -626,9 +626,9 @@ func mergeArrays(a, b *PredictionContext, rootIsWildcard bool, mergeCache *JPCMa
mergedParents = mergedParents[0:k]
mergedReturnStates = mergedReturnStates[0:k]
}
M := NewArrayPredictionContext(mergedParents, mergedReturnStates)
// if we created same array as a or b, return that instead
// TODO: JI track whether this is possible above during merge sort for speed and possibly avoid an allocation
if M.Equals(a) {
@@ -650,7 +650,7 @@ func mergeArrays(a, b *PredictionContext, rootIsWildcard bool, mergeCache *JPCMa
return b
}
combineCommonParents(&mergedParents)
if mergeCache != nil {
mergeCache.Put(a, b, M)
}
@@ -666,7 +666,7 @@ func mergeArrays(a, b *PredictionContext, rootIsWildcard bool, mergeCache *JPCMa
//goland:noinspection GoUnusedFunction
func combineCommonParents(parents *[]*PredictionContext) {
uniqueParents := NewJStore[*PredictionContext, Comparator[*PredictionContext]](pContextEqInst, PredictionContextCollection, "combineCommonParents for PredictionContext")
for p := 0; p < len(*parents); p++ {
parent := (*parents)[p]
_, _ = uniqueParents.Put(parent)
@@ -685,7 +685,7 @@ func getCachedBasePredictionContext(context *PredictionContext, contextCache *Pr
if present {
return existing
}
existing, present = contextCache.Get(context)
if present {
visited.Put(context, existing)
@@ -722,6 +722,6 @@ func getCachedBasePredictionContext(context *PredictionContext, contextCache *Pr
contextCache.add(updated)
visited.Put(updated, updated)
visited.Put(context, updated)
return updated
}
+1 -1
View File
@@ -56,7 +56,7 @@ var tokenTypeMapCache = make(map[string]int)
var ruleIndexMapCache = make(map[string]int)
func (b *BaseRecognizer) checkVersion(toolVersion string) {
runtimeVersion := "4.12.0"
runtimeVersion := "4.13.1"
if runtimeVersion != toolVersion {
fmt.Println("ANTLR runtime and generated code versions disagree: " + runtimeVersion + "!=" + toolVersion)
}
+1 -2
View File
@@ -9,7 +9,6 @@ import (
"path/filepath"
"sort"
"strconv"
"sync"
)
// This file allows the user to collect statistics about the runtime of the ANTLR runtime. It is not enabled by default
@@ -30,7 +29,7 @@ type goRunStats struct {
// within this package.
//
jStats []*JStatRec
jStatsLock sync.RWMutex
jStatsLock RWMutex
topN int
topNByMax []*JStatRec
topNByUsed []*JStatRec
+41 -41
View File
@@ -104,6 +104,25 @@ func (b *BaseToken) GetSource() *TokenSourceCharStreamPair {
return b.source
}
func (b *BaseToken) GetText() string {
if b.text != "" {
return b.text
}
input := b.GetInputStream()
if input == nil {
return ""
}
n := input.Size()
if b.GetStart() < n && b.GetStop() < n {
return input.GetTextFromInterval(NewInterval(b.GetStart(), b.GetStop()))
}
return "<EOF>"
}
func (b *BaseToken) SetText(text string) {
b.text = text
}
func (b *BaseToken) GetTokenIndex() int {
return b.tokenIndex
}
@@ -120,6 +139,28 @@ func (b *BaseToken) GetInputStream() CharStream {
return b.source.charStream
}
func (b *BaseToken) String() string {
txt := b.GetText()
if txt != "" {
txt = strings.Replace(txt, "\n", "\\n", -1)
txt = strings.Replace(txt, "\r", "\\r", -1)
txt = strings.Replace(txt, "\t", "\\t", -1)
} else {
txt = "<no text>"
}
var ch string
if b.GetChannel() > 0 {
ch = ",channel=" + strconv.Itoa(b.GetChannel())
} else {
ch = ""
}
return "[@" + strconv.Itoa(b.GetTokenIndex()) + "," + strconv.Itoa(b.GetStart()) + ":" + strconv.Itoa(b.GetStop()) + "='" +
txt + "',<" + strconv.Itoa(b.GetTokenType()) + ">" +
ch + "," + strconv.Itoa(b.GetLine()) + ":" + strconv.Itoa(b.GetColumn()) + "]"
}
type CommonToken struct {
BaseToken
}
@@ -170,44 +211,3 @@ func (c *CommonToken) clone() *CommonToken {
t.text = c.GetText()
return t
}
func (c *CommonToken) GetText() string {
if c.text != "" {
return c.text
}
input := c.GetInputStream()
if input == nil {
return ""
}
n := input.Size()
if c.start < n && c.stop < n {
return input.GetTextFromInterval(NewInterval(c.start, c.stop))
}
return "<EOF>"
}
func (c *CommonToken) SetText(text string) {
c.text = text
}
func (c *CommonToken) String() string {
txt := c.GetText()
if txt != "" {
txt = strings.Replace(txt, "\n", "\\n", -1)
txt = strings.Replace(txt, "\r", "\\r", -1)
txt = strings.Replace(txt, "\t", "\\t", -1)
} else {
txt = "<no text>"
}
var ch string
if c.channel > 0 {
ch = ",channel=" + strconv.Itoa(c.channel)
} else {
ch = ""
}
return "[@" + strconv.Itoa(c.tokenIndex) + "," + strconv.Itoa(c.start) + ":" + strconv.Itoa(c.stop) + "='" +
txt + "',<" + strconv.Itoa(c.tokenType) + ">" +
ch + "," + strconv.Itoa(c.line) + ":" + strconv.Itoa(c.column) + "]"
}
+53
View File
@@ -326,3 +326,56 @@ func isDirectory(dir string) (bool, error) {
}
return fileInfo.IsDir(), err
}
// intSlicesEqual returns true if the two slices of ints are equal, and is a little
// faster than slices.Equal.
func intSlicesEqual(s1, s2 []int) bool {
if s1 == nil && s2 == nil {
return true
}
if s1 == nil || s2 == nil {
return false
}
if len(s1) == 0 && len(s2) == 0 {
return true
}
if len(s1) == 0 || len(s2) == 0 || len(s1) != len(s2) {
return false
}
// If the slices are using the same memory, then they are the same slice
if &s1[0] == &s2[0] {
return true
}
for i, v := range s1 {
if v != s2[i] {
return false
}
}
return true
}
func pcSliceEqual(s1, s2 []*PredictionContext) bool {
if s1 == nil && s2 == nil {
return true
}
if s1 == nil || s2 == nil {
return false
}
if len(s1) == 0 && len(s2) == 0 {
return true
}
if len(s1) == 0 || len(s2) == 0 || len(s1) != len(s2) {
return false
}
// If the slices are using the same memory, then they are the same slice
if &s1[0] == &s2[0] {
return true
}
for i, v := range s1 {
if !v.Equals(s2[i]) {
return false
}
}
return true
}
+14 -1
View File
@@ -10,6 +10,7 @@ go_library(
"cel.go",
"decls.go",
"env.go",
"fieldpaths.go",
"folding.go",
"inlining.go",
"io.go",
@@ -21,10 +22,11 @@ go_library(
"prompt.go",
"validator.go",
],
embedsrcs = ["//cel/templates"],
embedsrcs = ["templates/authoring.tmpl"],
importpath = "github.com/google/cel-go/cel",
visibility = ["//visibility:public"],
deps = [
"//cel/async:go_default_library",
"//checker:go_default_library",
"//checker/decls:go_default_library",
"//common:go_default_library",
@@ -43,6 +45,7 @@ go_library(
"//interpreter:go_default_library",
"//parser:go_default_library",
"@dev_cel_expr//:expr",
"@dev_cel_expr//conformance/proto3:go_default_library",
"@org_golang_google_genproto_googleapis_api//expr/v1alpha1:go_default_library",
"@org_golang_google_protobuf//proto:go_default_library",
"@org_golang_google_protobuf//reflect/protodesc:go_default_library",
@@ -63,10 +66,12 @@ go_test(
"cel_test.go",
"decls_test.go",
"env_test.go",
"fieldpaths_test.go",
"folding_test.go",
"inlining_test.go",
"io_test.go",
"optimizer_test.go",
"program_async_test.go",
"prompt_test.go",
"validator_test.go",
],
@@ -78,8 +83,10 @@ go_test(
],
embedsrcs = [
"//cel/testdata:prompts",
"//cel/testdata:test_fds_with_source_info",
],
deps = [
"//cel/async:go_default_library",
"//common/operators:go_default_library",
"//common/overloads:go_default_library",
"//common/types:go_default_library",
@@ -89,6 +96,7 @@ go_test(
"//test:go_default_library",
"//test/proto2pb:go_default_library",
"//test/proto3pb:go_default_library",
"@com_github_google_go_cmp//cmp:go_default_library",
"@org_golang_google_genproto_googleapis_api//expr/v1alpha1:go_default_library",
"@org_golang_google_protobuf//encoding/prototext:go_default_library",
"@org_golang_google_protobuf//proto:go_default_library",
@@ -96,3 +104,8 @@ go_test(
"@org_golang_google_protobuf//types/known/wrapperspb:go_default_library",
],
)
exports_files(
["templates/authoring.tmpl"],
visibility = ["//visibility:public"],
)
+35
View File
@@ -0,0 +1,35 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
package(
licenses = ["notice"], # Apache 2.0
)
go_library(
name = "go_default_library",
srcs = [
"async.go",
],
importpath = "github.com/google/cel-go/cel/async",
visibility = ["//visibility:public"],
deps = [
"//common/decls:go_default_library",
"//common/functions:go_default_library",
"//common/types:go_default_library",
"//common/types/ref:go_default_library",
"//interpreter:go_default_library",
],
)
go_test(
name = "go_default_test",
srcs = [
"async_test.go",
],
deps = [
":go_default_library",
"//common/decls:go_default_library",
"//common/functions:go_default_library",
"//common/types:go_default_library",
"//common/types/ref:go_default_library",
],
)
+235
View File
@@ -0,0 +1,235 @@
// Copyright 2026 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 async provides helpers for configuring and executing asynchronous CEL functions,
// including drain strategies, retry, timeout, concurrency limiting, and caching wrappers.
package async
import (
"context"
"errors"
"time"
"github.com/google/cel-go/common/decls"
"github.com/google/cel-go/common/functions"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/interpreter"
)
// Call describes a pending or completed asynchronous function call.
// This interface exposes a safe, read-only view of the internal interpreter state.
type Call = interpreter.AsyncCall
// Observer provides callbacks for monitoring the lifecycle of asynchronous function calls.
//
// Implementations must be safe for concurrent use: the start and finish callbacks run on different
// goroutines, and finish callbacks for distinct calls may run concurrently. See
// interpreter.AsyncObserver for details.
type Observer = interpreter.AsyncObserver
// BlockingOp is a blocking asynchronous function operation.
type BlockingOp = functions.BlockingAsyncOp
// DrainAction dictates what ConcurrentEval should do after inspecting completions.
type DrainAction struct {
// Reevaluate indicates that the AST should be re-evaluated immediately.
// If true, WaitDuration is ignored.
Reevaluate bool
// WaitDuration indicates how long the evaluator should wait for additional
// completions before deciding to re-evaluate. A duration of 0 means wait
// indefinitely (block on the next completion).
WaitDuration time.Duration
}
// DrainStrategy controls when ConcurrentEval re-evaluates after async completions.
//
// The evaluator consults the strategy each time a completion is received.
type DrainStrategy interface {
// NextAction evaluates the current state of asynchronous evaluation and
// determines the next step.
//
// - completed: The set of completions accumulated in the current batch.
// - active: The number of async calls currently launched but unresolved.
NextAction(completed []Call, active int) DrainAction
}
// DrainNone returns a strategy that re-evaluates after every single completion.
// This is the default strategy.
func DrainNone() DrainStrategy {
return drainNone{}
}
type drainNone struct{}
func (drainNone) NextAction(completed []Call, active int) DrainAction {
return DrainAction{Reevaluate: active == 0 || len(completed) > 0}
}
// DrainReady returns a strategy that waits for a short duration after the first
// completion to batch any other functions that complete at roughly the same time.
func DrainReady(debounce time.Duration) DrainStrategy {
return drainReady{debounce: debounce}
}
type drainReady struct {
debounce time.Duration
}
func (d drainReady) NextAction(completed []Call, active int) DrainAction {
if active == 0 {
return DrainAction{Reevaluate: true} // Nothing left to wait for
}
if len(completed) == 0 {
return DrainAction{Reevaluate: false, WaitDuration: 0} // Wait indefinitely for first
}
return DrainAction{Reevaluate: false, WaitDuration: d.debounce} // Wait for debounce period
}
// DrainAll returns a strategy that waits for all currently pending calls to
// complete before re-evaluating.
//
// Note: This strategy is optimal for independent async calls, but will over-wait
// if some calls depend on the results of others.
func DrainAll() DrainStrategy {
return drainAll{}
}
type drainAll struct{}
func (drainAll) NextAction(completed []Call, active int) DrainAction {
return DrainAction{Reevaluate: active == 0}
}
// Timeout wraps a BlockingAsyncOp with a per-call timeout.
//
// The timeout is enforced even when the wrapped function ignores its context: the function runs on
// its own goroutine and Timeout selects on the deadline, returning a timeout error when it
// fires. A function that ignores cancellation cannot be forcibly stopped (Go cannot kill a
// goroutine), so its goroutine continues running in the background until it returns on its own;
// only its result is abandoned. This is the recommended way to bound functions that may hang or
// are not under the caller's control. The extra goroutine is incurred only by Timeout-wrapped
// calls, not by async evaluation in general.
func Timeout(fn functions.BlockingAsyncOp, timeout time.Duration) functions.BlockingAsyncOp {
return func(ctx context.Context, args ...ref.Val) ref.Val {
tCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
resCh := make(chan ref.Val, 1)
go func() { resCh <- fn(tCtx, args...) }()
select {
case res := <-resCh:
return res
case <-tCtx.Done():
return types.NewErr("operation timed out after %v: %v", timeout, tCtx.Err())
}
}
}
// TimeoutBinding wraps a BlockingAsyncOp with a per-call timeout and returns an OverloadOpt.
func TimeoutBinding(fn functions.BlockingAsyncOp, timeout time.Duration) decls.OverloadOpt {
return decls.AsyncBinding(Timeout(fn, timeout))
}
// RetryOption configures the behavior of RetryBinding.
type RetryOption func(*retryConfig)
type retryConfig struct {
maxAttempts int
backoff time.Duration
}
// RetryAttempts sets the maximum number of attempts (including the first one).
func RetryAttempts(attempts int) RetryOption {
return func(c *retryConfig) {
c.maxAttempts = attempts
}
}
// RetryBackoff sets the fixed backoff duration between attempts.
func RetryBackoff(backoff time.Duration) RetryOption {
return func(c *retryConfig) {
c.backoff = backoff
}
}
// RetryableError is an interface that errors can implement to signal whether they are retryable.
type RetryableError interface {
error
IsRetryable() bool
}
// Retry wraps a BlockingAsyncOp with a retry policy.
// It will retry the operation if it returns a types.Err that wraps a RetryableError returning true for IsRetryable.
func Retry(fn functions.BlockingAsyncOp, opts ...RetryOption) functions.BlockingAsyncOp {
config := &retryConfig{
maxAttempts: 3,
backoff: 100 * time.Millisecond,
}
for _, opt := range opts {
opt(config)
}
return func(ctx context.Context, args ...ref.Val) ref.Val {
var lastErr ref.Val
var backoff *time.Timer
defer func() {
if backoff != nil {
backoff.Stop()
}
}()
for i := 0; i < config.maxAttempts; i++ {
if i > 0 {
// Reuse a single timer across attempts and stop it on cancellation so the
// pending timer is not left to fire after the call returns.
if backoff == nil {
backoff = time.NewTimer(config.backoff)
} else {
backoff.Reset(config.backoff)
}
select {
case <-backoff.C:
case <-ctx.Done():
backoff.Stop()
return types.NewErr("operation cancelled during retry: %v", ctx.Err())
}
}
res := fn(ctx, args...)
if !types.IsError(res) {
return res
}
err := res.(*types.Err)
lastErr = res
if !isRetryable(err) {
return res
}
}
return lastErr
}
}
// RetryBinding wraps a BlockingAsyncOp with a retry policy and returns an OverloadOpt.
func RetryBinding(fn functions.BlockingAsyncOp, opts ...RetryOption) decls.OverloadOpt {
return decls.AsyncBinding(Retry(fn, opts...))
}
func isRetryable(err *types.Err) bool {
var re RetryableError
if errors.As(err, &re) {
return re.IsRetryable()
}
return false
}
+21
View File
@@ -17,3 +17,24 @@
// CEL is a non-Turing complete expression language designed to parse, check, and evaluate
// expressions against user-defined environments.
package cel
// Compile is a convenience function that constructs a new Env using the provided EnvOption values,
// compiles the expression string, and plans an executable Program.
//
// Warning: Creating a new environment for every compilation is expensive. Environment setup should be done once
// and shared across expression compilations when the options remain the same.
func Compile(expression string, opts ...EnvOption) (Program, error) {
env, err := NewEnv(opts...)
if err != nil {
return nil, err
}
ast, iss := env.Compile(expression)
if iss.Err() != nil {
return nil, iss.Err()
}
prg, err := env.Program(ast, EvalOptions(OptOptimize))
if err != nil {
return nil, err
}
return prg, nil
}
+26
View File
@@ -346,6 +346,32 @@ func LateFunctionBinding() OverloadOpt {
return decls.LateFunctionBinding()
}
// AsyncBinding provides the implementation of an asynchronous overload. The provided function
// is called in its own goroutine with the provided context. The function should block until
// the result is available, and the framework manages goroutine and channel lifecycle.
//
// This follows the same pattern used by gRPC-Go and other major Go frameworks where user
// code is synchronous and the framework manages concurrency.
//
// Context contract: the function MUST return promptly once its context is cancelled. The
// framework cannot forcibly terminate the goroutine running the function, so a function that
// ignores cancellation will leak its goroutine and hold a concurrency slot (see
// AsyncMaxConcurrency) until it returns on its own. For functions that may hang or that are not
// under your control, wrap them with async.TimeoutBinding to bound their runtime.
func AsyncBinding(fn functions.BlockingAsyncOp) OverloadOpt {
return decls.AsyncBinding(fn)
}
// SingletonAsyncBinding creates a singleton async function definition from a blocking function,
// to be used with all function overloads. The provided function is called in its own goroutine
// with the provided context.
//
// Note, this approach works well if operand is expected to have a specific trait which it implements,
// e.g. traits.ContainerType. Otherwise, prefer per-overload async bindings.
func SingletonAsyncBinding(fn functions.BlockingAsyncOp, traits ...int) FunctionOpt {
return decls.SingletonAsyncBinding(fn, traits...)
}
// OverloadIsNonStrict enables the function to be called with error and unknown argument values.
//
// Note: do not use this option unless absoluately necessary as it should be an uncommon feature.
+165 -25
View File
@@ -18,6 +18,8 @@ import (
"errors"
"fmt"
"math"
"slices"
"strings"
"sync"
"github.com/google/cel-go/checker"
@@ -27,6 +29,7 @@ import (
"github.com/google/cel-go/common/containers"
"github.com/google/cel-go/common/decls"
"github.com/google/cel-go/common/env"
"github.com/google/cel-go/common/functions"
"github.com/google/cel-go/common/stdlib"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
@@ -45,6 +48,10 @@ type Source = common.Source
type Ast struct {
source Source
impl *celast.AST
// loadErr captures an error detected while loading the AST (e.g. an over-deep AST ingested via
// ParsedExprToAst / CheckedExprToAst) so it can be surfaced when the Ast is checked or planned
// instead of recursing into the checker or planner on adversarially deep input.
loadErr error
}
// NativeRep converts the AST to a Go-native representation.
@@ -138,10 +145,14 @@ type Env struct {
provider types.Provider
features map[int]bool
appliedFeatures map[int]bool
limits map[limitID]int
libraries map[string]SingletonLibrary
validators []ASTValidator
costOptions []checker.CostOption
funcBindOnce sync.Once
functionBindings []*functions.Overload
// Internal parser representation
prsr *parser.Parser
prsrOpts []parser.Option
@@ -177,6 +188,16 @@ func (e *Env) ToConfig(name string) (*env.Config, error) {
conf.AddImports(env.NewImport(typeName))
}
// Serialize features
for featID, enabled := range e.features {
featName, found := featureNameByID(featID)
if !found {
// If the feature isn't named, it isn't intended to be publicly exposed
continue
}
conf.AddFeatures(env.NewFeature(featName, enabled))
}
libOverloads := map[string][]string{}
for libName, lib := range e.libraries {
// Track the options which have been configured by a library and
@@ -237,7 +258,7 @@ func (e *Env) ToConfig(name string) (*env.Config, error) {
fields := e.contextProto.Fields()
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
variable, err := fieldToVariable(field)
variable, err := fieldToVariable(field, e.HasFeature(featureJSONFieldNames))
if err != nil {
return nil, fmt.Errorf("could not serialize context field variable %q, reason: %w", field.FullName(), err)
}
@@ -272,16 +293,45 @@ func (e *Env) ToConfig(name string) (*env.Config, error) {
}
}
// Serialize features
for featID, enabled := range e.features {
featName, found := featureNameByID(featID)
if !found {
// If the feature isn't named, it isn't intended to be publicly exposed
for id, val := range e.limits {
limitName, found := limitNameByID(id)
if !found || val == 0 {
// skip if explicitly defaulted or not supported in config
continue
}
conf.AddFeatures(env.NewFeature(featName, enabled))
conf.AddLimits(env.NewLimit(limitName, val))
}
// Sort repeated fields in config where reasonable to make the export
// stable.
slices.SortFunc(conf.Imports, func(a *env.Import, b *env.Import) int {
return strings.Compare(a.Name, b.Name)
})
slices.SortFunc(conf.Extensions, func(a *env.Extension, b *env.Extension) int {
return strings.Compare(a.Name, b.Name)
})
slices.SortFunc(conf.Variables, func(a *env.Variable, b *env.Variable) int {
return strings.Compare(a.Name, b.Name)
})
slices.SortFunc(conf.Functions, func(a *env.Function, b *env.Function) int {
return strings.Compare(a.Name, b.Name)
})
slices.SortFunc(conf.Validators, func(a *env.Validator, b *env.Validator) int {
return strings.Compare(a.Name, b.Name)
})
slices.SortFunc(conf.Features, func(a *env.Feature, b *env.Feature) int {
return strings.Compare(a.Name, b.Name)
})
slices.SortFunc(conf.Limits, func(a *env.Limit, b *env.Limit) int {
return strings.Compare(a.Name, b.Name)
})
return conf, nil
}
@@ -315,23 +365,25 @@ func NewEnv(opts ...EnvOption) (*Env, error) {
// See the EnvOption helper functions for the options that can be used to configure the
// environment.
func NewCustomEnv(opts ...EnvOption) (*Env, error) {
registry, err := types.NewRegistry()
registry, err := types.NewProtoRegistry()
if err != nil {
return nil, err
}
return (&Env{
variables: []*decls.VariableDecl{},
functions: map[string]*decls.FunctionDecl{},
macros: []parser.Macro{},
Container: containers.DefaultContainer,
adapter: registry,
provider: registry,
features: map[int]bool{},
appliedFeatures: map[int]bool{},
libraries: map[string]SingletonLibrary{},
validators: []ASTValidator{},
progOpts: []ProgramOption{},
costOptions: []checker.CostOption{},
variables: []*decls.VariableDecl{},
functions: map[string]*decls.FunctionDecl{},
functionBindings: []*functions.Overload{},
macros: []parser.Macro{},
Container: containers.DefaultContainer,
adapter: registry,
provider: registry,
features: map[int]bool{},
appliedFeatures: map[int]bool{},
limits: map[limitID]int{},
libraries: map[string]SingletonLibrary{},
validators: []ASTValidator{},
progOpts: []ProgramOption{},
costOptions: []checker.CostOption{},
}).configure(opts)
}
@@ -347,6 +399,20 @@ func NewCustomEnv(opts ...EnvOption) (*Env, error) {
// It is possible to have both non-nil Ast and Issues values returned from this call: however,
// the mere presence of an Ast does not imply that it is valid for use.
func (e *Env) Check(ast *Ast) (*Ast, *Issues) {
// Surface any error recorded while the Ast was loaded (e.g. an over-deep AST rejected by
// ParsedExprToAst / CheckedExprToAst) before recursing into the type checker on it.
if ast != nil && ast.loadErr != nil {
errs := common.NewErrors(ast.Source())
errs.ReportErrorString(common.NoLocation, ast.loadErr.Error())
return nil, NewIssuesWithSourceInfo(errs, ast.NativeRep().SourceInfo())
}
if nodeLimit := e.configuredExpressionNodeLimit(); nodeLimit > 0 && ast != nil && ast.NativeRep() != nil {
if count := celast.NodeCount(ast.NativeRep()); count > nodeLimit {
errs := common.NewErrors(ast.Source())
errs.ReportErrorString(common.NoLocation, fmt.Sprintf("expression node count exceeds limit: count %d, limit %d", count, nodeLimit))
return nil, NewIssuesWithSourceInfo(errs, ast.NativeRep().SourceInfo())
}
}
// Construct the internal checker env, erroring if there is an issue adding the declarations.
chk, err := e.initChecker()
if err != nil {
@@ -388,6 +454,24 @@ func (e *Env) Check(ast *Ast) (*Ast, *Issues) {
return ast, nil
}
// configuredExpressionSizeLimit returns the effective expression size code point limit.
// A zero value means "use the parser default".
func (e *Env) configuredExpressionSizeLimit() int {
if l := e.limits[limitCodePointSize]; l != 0 {
return l
}
return 100_000
}
// configuredExpressionNodeLimit returns the effective expression node limit.
// A zero value means "use default".
func (e *Env) configuredExpressionNodeLimit() int {
if l := e.limits[limitExpressionNodeCount]; l != 0 {
return l
}
return 100_000
}
// Compile combines the Parse and Check phases CEL program compilation to produce an Ast and
// associated issues.
//
@@ -397,7 +481,11 @@ func (e *Env) Check(ast *Ast) (*Ast, *Issues) {
//
// Note, for parse-only uses of CEL use Parse.
func (e *Env) Compile(txt string) (*Ast, *Issues) {
return e.CompileSource(common.NewTextSource(txt))
src, err := common.NewTextSourceWithLimit(txt, e.configuredExpressionSizeLimit())
if err != nil {
return nil, ErrorAsIssues(err)
}
return e.CompileSource(src)
}
// CompileSource combines the Parse and Check phases CEL program compilation to produce an Ast and
@@ -492,6 +580,10 @@ func (e *Env) Extend(opts ...EnvOption) (*Env, error) {
for k, v := range e.appliedFeatures {
appliedFeaturesCopy[k] = v
}
limitsCopy := make(map[limitID]int, len(e.limits))
for k, v := range e.limits {
limitsCopy[k] = v
}
funcsCopy := make(map[string]*decls.FunctionDecl, len(e.functions))
for k, v := range e.functions {
funcsCopy[k] = v
@@ -502,6 +594,7 @@ func (e *Env) Extend(opts ...EnvOption) (*Env, error) {
}
validatorsCopy := make([]ASTValidator, len(e.validators))
copy(validatorsCopy, e.validators)
costOptsCopy := make([]checker.CostOption, len(e.costOptions))
copy(costOptsCopy, e.costOptions)
@@ -514,6 +607,7 @@ func (e *Env) Extend(opts ...EnvOption) (*Env, error) {
progOpts: progOptsCopy,
adapter: adapter,
features: featuresCopy,
limits: limitsCopy,
appliedFeatures: appliedFeaturesCopy,
libraries: libsCopy,
validators: validatorsCopy,
@@ -596,7 +690,10 @@ func (e *Env) Validators() []ASTValidator {
// This form of Parse creates a Source value for the input `txt` and forwards to the
// ParseSource method.
func (e *Env) Parse(txt string) (*Ast, *Issues) {
src := common.NewTextSource(txt)
src, err := common.NewTextSourceWithLimit(txt, e.configuredExpressionSizeLimit())
if err != nil {
return nil, ErrorAsIssues(err)
}
return e.ParseSource(src)
}
@@ -617,6 +714,12 @@ func (e *Env) ParseSource(src Source) (*Ast, *Issues) {
// Program generates an evaluable instance of the Ast within the environment (Env).
func (e *Env) Program(ast *Ast, opts ...ProgramOption) (Program, error) {
// Surface any error recorded while the Ast was loaded (e.g. an over-deep AST rejected by
// ParsedExprToAst / CheckedExprToAst) rather than recursing into the planner on it. This is a
// cheap field read; the depth traversal itself runs once at conversion time, not here.
if ast != nil && ast.loadErr != nil {
return nil, ast.loadErr
}
return e.PlanProgram(ast.NativeRep(), opts...)
}
@@ -780,11 +883,35 @@ func (e *Env) configure(opts []EnvOption) (*Env, error) {
if e.HasFeature(featureIdentEscapeSyntax) {
prsrOpts = append(prsrOpts, parser.EnableIdentEscapeSyntax(true))
}
if l := e.limits[limitParseErrorRecovery]; l != 0 {
prsrOpts = append(prsrOpts, parser.ErrorRecoveryLimit(l))
}
if l := e.limits[limitCodePointSize]; l != 0 {
prsrOpts = append(prsrOpts, parser.ExpressionSizeCodePointLimit(l))
}
if l := e.limits[limitParseRecursionDepth]; l != 0 {
prsrOpts = append(prsrOpts, parser.MaxRecursionDepth(l))
}
if l := e.limits[limitExpressionNodeCount]; l != 0 {
prsrOpts = append(prsrOpts, parser.MaxExpressionNodeCount(l))
}
e.prsr, err = parser.NewParser(prsrOpts...)
if err != nil {
return nil, err
}
// Enable JSON field names is using a proto-based *types.Registry
if e.HasFeature(featureJSONFieldNames) {
reg, isReg := e.provider.(*types.Registry)
if !isReg {
return nil, fmt.Errorf("JSONFieldNames() option is only compatible with *types.Registry providers")
}
err := reg.WithJSONFieldNames(true)
if err != nil {
return nil, err
}
}
// Ensure that the checker init happens eagerly rather than lazily.
if e.HasFeature(featureEagerlyValidateDeclarations) {
_, err := e.initChecker()
@@ -803,6 +930,8 @@ func (e *Env) initChecker() (*checker.Env, error) {
chkOpts = append(chkOpts,
checker.CrossTypeNumericComparisons(
e.HasFeature(featureCrossTypeNumericComparisons)))
chkOpts = append(chkOpts,
checker.JSONFieldNames(e.HasFeature(featureJSONFieldNames)))
ce, err := checker.NewEnv(e.Container, e.provider, chkOpts...)
if err != nil {
@@ -872,6 +1001,16 @@ type Issues struct {
info *celast.SourceInfo
}
// ErrorAsIssues wraps a Golang error into a CEL common error and issue set.
//
// This is a convenience method for early returning from an expression validation call path due to
// internal state or configuration which is unrelated to the source being validated.
func ErrorAsIssues(err error) *Issues {
errs := common.NewErrors(common.NewTextSource(""))
errs.ReportErrorString(common.NoLocation, err.Error())
return NewIssues(errs)
}
// NewIssues returns an Issues struct from a common.Errors object.
func NewIssues(errs *common.Errors) *Issues {
return NewIssuesWithSourceInfo(errs, nil)
@@ -980,9 +1119,10 @@ func (p *interopCELTypeProvider) FindStructFieldType(structType, fieldName strin
return nil, false
}
return &types.FieldType{
Type: t,
IsSet: ft.IsSet,
GetFrom: ft.GetFrom,
Type: t,
IsSet: ft.IsSet,
GetFrom: ft.GetFrom,
IsJSONField: ft.IsJSONField,
}, true
}
return nil, false
+163
View File
@@ -0,0 +1,163 @@
package cel
import (
"slices"
"strings"
"github.com/google/cel-go/common"
"github.com/google/cel-go/common/types"
)
// fieldPath represents a selection path to a field from a variable in a CEL environment.
type fieldPath struct {
celType *Type
// path represents the selection path to the field.
path string
description string
isLeaf bool
}
// Documentation implements the Documentor interface.
func (f *fieldPath) Documentation() *common.Doc {
return common.NewFieldDoc(f.path, f.celType.String(), f.description)
}
type documentationProvider interface {
// FindStructFieldDescription returns documentation for a field if available.
// Returns false if the field could not be found.
FindStructFieldDescription(typeName, fieldName string) (string, bool)
}
type backtrack struct {
// provider used to resolve types.
provider types.Provider
// paths of fields that have been visited along the path.
path []string
// types of fields that have been visited along the path. used to avoid cycles.
types []*Type
}
func (b *backtrack) push(pathStep string, celType *Type) {
b.path = append(b.path, pathStep)
b.types = append(b.types, celType)
}
func (b *backtrack) pop() {
b.path = b.path[:len(b.path)-1]
b.types = b.types[:len(b.types)-1]
}
func formatPath(path []string) string {
var buffer strings.Builder
for i, p := range path {
if i == 0 {
buffer.WriteString(p)
continue
}
if strings.HasPrefix(p, "[") {
buffer.WriteString(p)
continue
}
buffer.WriteString(".")
buffer.WriteString(p)
}
return buffer.String()
}
func (b *backtrack) expandFieldPaths(celType *Type, paths []*fieldPath) []*fieldPath {
if slices.ContainsFunc(b.types[:len(b.types)-1], func(t *Type) bool { return t.String() == celType.String() }) {
// Cycle detected, so stop expanding.
paths[len(paths)-1].isLeaf = false
return paths
}
switch celType.Kind() {
case types.StructKind:
fields, ok := b.provider.FindStructFieldNames(celType.String())
if !ok {
// Caller added this type to the path, so it must be a leaf.
paths[len(paths)-1].isLeaf = true
return paths
}
for _, field := range fields {
fieldType, ok := b.provider.FindStructFieldType(celType.String(), field)
if !ok {
// Field not found, either hidden or an error.
continue
}
b.push(field, celType)
description := ""
if docProvider, ok := b.provider.(documentationProvider); ok {
description, _ = docProvider.FindStructFieldDescription(celType.String(), field)
}
path := &fieldPath{
celType: fieldType.Type,
path: formatPath(b.path),
description: description,
isLeaf: false,
}
paths = append(paths, path)
paths = b.expandFieldPaths(fieldType.Type, paths)
b.pop()
}
return paths
case types.MapKind:
if len(celType.Parameters()) != 2 {
// dynamic map, so treat as a leaf.
paths[len(paths)-1].isLeaf = true
return paths
}
mapKeyType := celType.Parameters()[0]
mapValueType := celType.Parameters()[1]
// Add a placeholder for the map key kind (the zero value).
keyIdentifier := ""
switch mapKeyType.Kind() {
case types.StringKind:
keyIdentifier = "[\"\"]"
case types.IntKind:
keyIdentifier = "[0]"
case types.UintKind:
keyIdentifier = "[0u]"
case types.BoolKind:
keyIdentifier = "[false]"
default:
// Caller added this type to the path, so it must be a leaf.
paths[len(paths)-1].isLeaf = true
return paths
}
b.push(keyIdentifier, mapValueType)
defer b.pop()
return b.expandFieldPaths(mapValueType, paths)
case types.ListKind:
if len(celType.Parameters()) != 1 {
// dynamic list, so treat as a leaf.
paths[len(paths)-1].isLeaf = true
return paths
}
listElemType := celType.Parameters()[0]
b.push("[0]", listElemType)
defer b.pop()
return b.expandFieldPaths(listElemType, paths)
default:
paths[len(paths)-1].isLeaf = true
}
return paths
}
// fieldPathsForType expands the reachable fields from the given root identifier.
func fieldPathsForType(provider types.Provider, identifier string, celType *Type) []*fieldPath {
b := &backtrack{
provider: provider,
path: []string{identifier},
types: []*Type{celType},
}
paths := []*fieldPath{
{
celType: celType,
path: identifier,
isLeaf: false,
},
}
return b.expandFieldPaths(celType, paths)
}
+160 -32
View File
@@ -15,6 +15,8 @@
package cel
import (
"context"
"errors"
"fmt"
"github.com/google/cel-go/common/ast"
@@ -38,7 +40,7 @@ func MaxConstantFoldIterations(limit int) ConstantFoldingOption {
}
}
// Adds an Activation which provides known values for the folding evaluator
// FoldKnownValues adds an Activation which provides known values for the folding evaluator
//
// Any values the activation provides will be used by the constant folder and turned into
// literals in the AST.
@@ -93,18 +95,18 @@ func (opt *constantFoldingOptimizer) Optimize(ctx *OptimizerContext, a *ast.AST)
for _, fold := range foldableExprs {
// If the expression could be folded because it's a non-strict call, and the
// branches are pruned, continue to the next fold.
if fold.Kind() == ast.CallKind && maybePruneBranches(ctx, fold) {
if fold.Kind() == ast.CallKind && maybePruneBranches(ctx, a, fold) {
continue
}
// Late-bound function calls cannot be folded.
if fold.Kind() == ast.CallKind && isLateBoundFunctionCall(ctx, a, fold) {
if fold.Kind() == ast.CallKind && isLateBoundFunctionCall(ctx, fold) {
continue
}
// Otherwise, assume all context is needed to evaluate the expression.
err := opt.tryFold(ctx, a, fold)
// Ignore errors for identifiers, since there is no guarantee that the environment
// Ignore errors for identifiers or subexpressions that cannot be folded, since there is no guarantee that the environment
// has a value for them.
if err != nil && fold.Kind() != ast.IdentKind {
if err != nil && fold.Kind() != ast.IdentKind && !errors.Is(err, errCannotFold) {
ctx.ReportErrorAtID(fold.ID(), "constant-folding evaluation failed: %v", err.Error())
return a
}
@@ -142,24 +144,19 @@ func (opt *constantFoldingOptimizer) Optimize(ctx *OptimizerContext, a *ast.AST)
return a
}
var errCannotFold = errors.New("subexpression cannot be folded")
// tryFold attempts to evaluate a sub-expression to a literal.
//
// If the evaluation succeeds, the input expr value will be modified to become a literal, otherwise
// the method will return an error.
func (opt *constantFoldingOptimizer) tryFold(ctx *OptimizerContext, a *ast.AST, expr ast.Expr) error {
// Assume all context is needed to evaluate the expression.
subAST := &Ast{
impl: ast.NewCheckedAST(ast.NewAST(expr, a.SourceInfo()), a.TypeMap(), a.ReferenceMap()),
}
prg, err := ctx.Program(subAST)
if err != nil {
return err
}
activation := opt.knownValues
if activation == nil {
activation = NoVars()
}
out, _, err := prg.Eval(activation)
navExpr := expr.(ast.NavigableExpr)
out, err := evaluateExpr(ctx, a, navExpr, activation)
if err != nil {
return err
}
@@ -168,7 +165,31 @@ func (opt *constantFoldingOptimizer) tryFold(ctx *OptimizerContext, a *ast.AST,
return nil
}
func isLateBoundFunctionCall(ctx *OptimizerContext, a *ast.AST, expr ast.Expr) bool {
func evaluateExpr(ctx *OptimizerContext, a *ast.AST, navigableExpr ast.NavigableExpr, activation Activation) (ref.Val, error) {
partialActivation, err := ctx.PartialVars(activation)
if err != nil {
return nil, err
}
subAST := &Ast{
impl: ast.NewCheckedAST(ast.NewAST(navigableExpr, a.SourceInfo()), a.TypeMap(), a.ReferenceMap()),
}
prg, err := ctx.Program(subAST)
if err != nil {
return nil, err
}
// Folding will not attempt to call async functions which are all marked as late-bound,
// but the presence of such functions requires the use of `ConcurrentEval` in order to
// avoid an early return error which blocks async functions from running in `Eval` and
// `ContextEval` call paths.
resCh := prg.ConcurrentEval(context.Background(), partialActivation)
res := <-resCh
if res.Err != nil || types.IsUnknown(res.Val) {
return nil, errCannotFold
}
return res.Val, nil
}
func isLateBoundFunctionCall(ctx *OptimizerContext, expr ast.Expr) bool {
call := expr.AsCall()
function := ctx.Functions()[call.FunctionName()]
if function == nil {
@@ -181,12 +202,12 @@ func isLateBoundFunctionCall(ctx *OptimizerContext, a *ast.AST, expr ast.Expr) b
// a branch can be removed. Evaluation will naturally prune logical and / or calls,
// but conditional will not be pruned cleanly, so this is one small area where the
// constant folding step reimplements a portion of the evaluator.
func maybePruneBranches(ctx *OptimizerContext, expr ast.NavigableExpr) bool {
func maybePruneBranches(ctx *OptimizerContext, a *ast.AST, expr ast.NavigableExpr) bool {
call := expr.AsCall()
args := call.Args()
switch call.FunctionName() {
case operators.LogicalAnd, operators.LogicalOr:
return maybeShortcircuitLogic(ctx, call.FunctionName(), args, expr)
return maybeShortcircuitLogic(ctx, a, call.FunctionName(), args, expr)
case operators.Conditional:
cond := args[0]
truthy := args[1]
@@ -207,11 +228,17 @@ func maybePruneBranches(ctx *OptimizerContext, expr ast.NavigableExpr) bool {
return true
}
needle := args[0]
if needle.Kind() == ast.LiteralKind && haystack.Kind() == ast.ListKind {
needleValue := needle.AsLiteral()
if (needle.Kind() == ast.LiteralKind || isSelfEqualIdent(needle)) && haystack.Kind() == ast.ListKind {
needleIsLit := needle.Kind() == ast.LiteralKind
needleLitVal := needle.AsLiteral()
needleIdentVal := needle.AsIdent()
list := haystack.AsList()
for _, e := range list.Elements() {
if e.Kind() == ast.LiteralKind && e.AsLiteral().Equal(needleValue) == types.True {
for _, elem := range list.Elements() {
if needleIsLit && elem.Kind() == ast.LiteralKind && elem.AsLiteral().Equal(needleLitVal) == types.True {
ctx.UpdateExpr(expr, ctx.NewLiteral(types.True))
return true
}
if !needleIsLit && elem.Kind() == ast.IdentKind && elem.AsIdent() == needleIdentVal {
ctx.UpdateExpr(expr, ctx.NewLiteral(types.True))
return true
}
@@ -221,7 +248,7 @@ func maybePruneBranches(ctx *OptimizerContext, expr ast.NavigableExpr) bool {
return false
}
func maybeShortcircuitLogic(ctx *OptimizerContext, function string, args []ast.Expr, expr ast.NavigableExpr) bool {
func maybeShortcircuitLogic(ctx *OptimizerContext, a *ast.AST, function string, args []ast.Expr, expr ast.NavigableExpr) bool {
shortcircuit := types.False
skip := types.True
if function == operators.LogicalOr {
@@ -244,10 +271,14 @@ func maybeShortcircuitLogic(ctx *OptimizerContext, function string, args []ast.E
}
if len(newArgs) == 0 {
newArgs = append(newArgs, args[0])
ctx.UpdateExpr(expr, newArgs[0])
return true
}
if len(newArgs) == len(args) {
return false
}
if len(newArgs) == 1 {
if !isBoolType(a, newArgs[0]) {
return false
}
ctx.UpdateExpr(expr, newArgs[0])
return true
}
@@ -255,6 +286,16 @@ func maybeShortcircuitLogic(ctx *OptimizerContext, function string, args []ast.E
return true
}
func isBoolType(a *ast.AST, e ast.Expr) bool {
if a != nil && a.GetType(e.ID()) == types.BoolType {
return true
}
if e.Kind() == ast.LiteralKind && e.AsLiteral().Type() == types.BoolType {
return true
}
return false
}
// pruneOptionalElements works from the bottom up to resolve optional elements within
// aggregate literals.
//
@@ -285,9 +326,9 @@ func pruneOptionalListElements(ctx *OptimizerContext, e ast.Expr) {
updatedElems := []ast.Expr{}
updatedIndices := []int32{}
newOptIndex := -1
for _, e := range elems {
for i, e := range elems {
newOptIndex++
if !l.IsOptional(int32(newOptIndex)) {
if !l.IsOptional(int32(i)) {
updatedElems = append(updatedElems, e)
continue
}
@@ -501,7 +542,7 @@ func (opt *constantFoldingOptimizer) constantExprMatcher(ctx *OptimizerContext,
sel := e.AsSelect() // guaranteed to be a navigable value
return constantMatcher(sel.Operand().(ast.NavigableExpr))
case ast.IdentKind:
return opt.knownValues != nil && a.ReferenceMap()[e.ID()] != nil
return opt.knownValues != nil && a.ReferenceMap()[e.ID()] != nil && !hasComprehensionVar(e)
case ast.ComprehensionKind:
if isNestedComprehension(e) {
return false
@@ -513,12 +554,15 @@ func (opt *constantFoldingOptimizer) constantExprMatcher(ctx *OptimizerContext,
nested := e.AsComprehension()
vars[nested.AccuVar()] = true
vars[nested.IterVar()] = true
if nested.IterVar2() != "" {
vars[nested.IterVar2()] = true
}
}
if e.Kind() == ast.IdentKind && !vars[e.AsIdent()] {
constantExprs = false
}
// Late-bound function calls cannot be folded.
if e.Kind() == ast.CallKind && isLateBoundFunctionCall(ctx, a, e) {
if e.Kind() == ast.CallKind && isLateBoundFunctionCall(ctx, e) {
constantExprs = false
}
})
@@ -554,17 +598,33 @@ func constantCallMatcher(e ast.NavigableExpr) bool {
return true
}
}
if fnName == operators.Equals || fnName == operators.NotEquals {
if hasComprehensionVar(e) {
return false
}
if isExprConstantOfKind(children[0], types.BoolType) || isExprConstantOfKind(children[1], types.BoolType) {
return true
}
}
if fnName == operators.In {
if hasComprehensionVar(e) {
return false
}
haystack := children[1]
if haystack.Kind() == ast.ListKind && haystack.AsList().Size() == 0 {
return true
}
needle := children[0]
if needle.Kind() == ast.LiteralKind && haystack.Kind() == ast.ListKind {
needleValue := needle.AsLiteral()
if (needle.Kind() == ast.LiteralKind || isSelfEqualIdent(needle)) && haystack.Kind() == ast.ListKind {
needleIsLit := needle.Kind() == ast.LiteralKind
needleLitVal := needle.AsLiteral()
needleIdentVal := needle.AsIdent()
list := haystack.AsList()
for _, e := range list.Elements() {
if e.Kind() == ast.LiteralKind && e.AsLiteral().Equal(needleValue) == types.True {
for _, elem := range list.Elements() {
if needleIsLit && elem.Kind() == ast.LiteralKind && elem.AsLiteral().Equal(needleLitVal) == types.True {
return true
}
if !needleIsLit && elem.Kind() == ast.IdentKind && elem.AsIdent() == needleIdentVal {
return true
}
}
@@ -579,6 +639,74 @@ func constantCallMatcher(e ast.NavigableExpr) bool {
return true
}
// isSelfEqualIdent indicates whether the expression is an identifier whose static type
// guarantees that its runtime value is equal to itself.
//
// Matching an identifier against a list element by name only proves list membership when the
// value the name resolves to is self-equal. A double may be NaN, which is not equal to itself,
// and dyn, abstract, and struct types may all hold a NaN at runtime, so the check is limited
// to the scalar types which cannot, and to the aggregate types whose type parameters are
// themselves self-equal.
func isSelfEqualIdent(e ast.Expr) bool {
if e.Kind() != ast.IdentKind {
return false
}
nav, ok := e.(ast.NavigableExpr)
if !ok {
return false
}
return isSelfEqualType(nav.Type())
}
// isSelfEqualType indicates whether all runtime values of the given type are equal to themselves.
func isSelfEqualType(t *types.Type) bool {
if t == nil {
return false
}
switch t.Kind() {
case types.BoolKind, types.BytesKind, types.DurationKind, types.IntKind,
types.NullTypeKind, types.StringKind, types.TimestampKind, types.TypeKind,
types.UintKind:
return true
case types.ListKind, types.MapKind:
// Aggregates compare element-wise, so they are self-equal exactly when their type
// parameters are. A list(dyn) or map(string, double) may still contain a NaN.
for _, p := range t.Parameters() {
if !isSelfEqualType(p) {
return false
}
}
return true
default:
return false
}
}
func isExprConstantOfKind(e ast.Expr, t *types.Type) bool {
return e.Kind() == ast.LiteralKind && e.AsLiteral().Type() == t
}
func hasComprehensionVar(e ast.NavigableExpr) bool {
idents := ast.MatchDescendants(e, ast.KindMatcher(ast.IdentKind))
for _, identNode := range idents {
identName := identNode.AsIdent()
curr := identNode
parent, found := curr.Parent()
for found {
if parent.Kind() == ast.ComprehensionKind {
compre := parent.AsComprehension()
if (compre.AccuVar() == identName || compre.IterVar() == identName || compre.IterVar2() == identName) &&
curr.ID() != compre.IterRange().ID() && curr.ID() != compre.AccuInit().ID() {
return true
}
}
curr = parent
parent, found = parent.Parent()
}
}
return false
}
func isNestedComprehension(e ast.NavigableExpr) bool {
parent, found := e.Parent()
for found {
+65 -10
View File
@@ -178,9 +178,38 @@ func (opt *inliningOptimizer) rewritePresenceExpr(ctx *OptimizerContext, prev, i
))
return
}
if zeroValExpr, ok := zeroValueExpr(ctx, inlinedType); ok {
ctx.UpdateExpr(prev,
ctx.NewCall(operators.NotEquals,
inlined, zeroValExpr))
return
}
ctx.ReportErrorAtID(prev.ID(), "unable to inline expression type %v into presence test", inlinedType)
}
// zeroValueExpr creates an expression representing the empty or zero value for the given type
// Note: bytes, lists, maps, and strings are supported via the `SizerType` trait.
func zeroValueExpr(ctx *OptimizerContext, t *Type) (ast.Expr, bool) {
// Note: bytes, strings, lists, and maps are covered by the "sizer-type" check
switch t.Kind() {
case types.BoolKind:
return ctx.NewLiteral(types.False), true
case types.DoubleKind:
return ctx.NewLiteral(types.Double(0)), true
case types.DurationKind:
return ctx.NewCall(overloads.TypeConvertDuration, ctx.NewLiteral(types.String("0s"))), true
case types.IntKind:
return ctx.NewLiteral(types.IntZero), true
case types.TimestampKind:
return ctx.NewCall(overloads.TypeConvertTimestamp, ctx.NewLiteral(types.Int(0))), true
case types.StructKind:
return ctx.NewStruct(t.TypeName(), []ast.EntryExpr{}), true
case types.UintKind:
return ctx.NewLiteral(types.Uint(0)), true
}
return nil, false
}
// isBindable indicates whether the inlined type can be used within a cel.bind() if the expression
// being replaced occurs within a presence test. Value types with a size() method or field selection
// support can be bound.
@@ -212,17 +241,43 @@ func isBindable(matches []ast.NavigableExpr, inlined ast.Expr, inlinedType *Type
// field selection. This may be a future refinement.
func (opt *inliningOptimizer) matchVariable(varName string) ast.ExprMatcher {
return func(e ast.NavigableExpr) bool {
if e.Kind() == ast.IdentKind && e.AsIdent() == varName {
return true
name, found := maybeAsVariableName(e)
if !found || name != varName {
return false
}
if e.Kind() == ast.SelectKind {
sel := e.AsSelect()
// While the `ToQualifiedName` call could take the select directly, this
// would skip presence tests from possible matches, which we would like
// to include.
qualName, found := containers.ToQualifiedName(sel.Operand())
return found && qualName+"."+sel.FieldName() == varName
// Determine whether the variable being referenced has been shadowed by a comprehension
p, hasParent := e.Parent()
for hasParent {
if p.Kind() != ast.ComprehensionKind {
p, hasParent = p.Parent()
continue
}
// If the inline variable name matches any of the comprehension variables at any scope,
// return false as the variable has been shadowed.
compre := p.AsComprehension()
if varName == compre.AccuVar() || varName == compre.IterVar() || varName == compre.IterVar2() {
return false
}
p, hasParent = p.Parent()
}
return false
return true
}
}
func maybeAsVariableName(e ast.NavigableExpr) (string, bool) {
if e.Kind() == ast.IdentKind {
return e.AsIdent(), true
}
if e.Kind() == ast.SelectKind {
sel := e.AsSelect()
// While the `ToQualifiedName` call could take the select directly, this
// would skip presence tests from possible matches, which we would like
// to include.
if qualName, found := containers.ToQualifiedName(sel.Operand()); found {
return qualName + "." + sel.FieldName(), true
}
}
return "", false
}
+26 -2
View File
@@ -52,7 +52,12 @@ func CheckedExprToAstWithSource(checkedExpr *exprpb.CheckedExpr, src Source) (*A
if err != nil {
return nil, err
}
return &Ast{source: src, impl: checked}, nil
out := &Ast{source: src, impl: checked}
if err := checkLoadedASTDepth(checked); err != nil {
out.loadErr = err
return out, err
}
return out, nil
}
// AstToCheckedExpr converts an Ast to an protobuf CheckedExpr value.
@@ -83,7 +88,26 @@ func ParsedExprToAstWithSource(parsedExpr *exprpb.ParsedExpr, src Source) *Ast {
src = common.NewInfoSource(parsedExpr.GetSourceInfo())
}
e, _ := ast.ProtoToExpr(parsedExpr.GetExpr())
return &Ast{source: src, impl: ast.NewAST(e, info)}
out := &Ast{source: src, impl: ast.NewAST(e, info)}
// ParsedExprToAstWithSource has no error return, so record an over-depth violation on the Ast
// to be surfaced when it is later checked or planned.
out.loadErr = checkLoadedASTDepth(out.impl)
return out
}
// checkLoadedASTDepth guards ASTs that enter through the proto conversion helpers
// (ParsedExprToAst / CheckedExprToAst) against nesting deeper than the parser's recursion limit.
// Those entry points bypass the parser, so without this check a deeply nested loaded AST could
// exhaust the Go stack during later checking or planning. It returns a normal error rather than
// risking that overflow; the traversal itself is bounded so it stays safe on the same input.
//
// Embedders that fully control their AST inputs can skip this by building the AST through the
// common/ast package directly instead of these conversion helpers.
func checkLoadedASTDepth(a *ast.AST) error {
if ast.ExceedsDepth(a, defaultMaxASTDepth) {
return fmt.Errorf("input exceeds maximum expression nesting depth: %d", defaultMaxASTDepth)
}
return nil
}
// AstToParsedExpr converts an Ast to an protobuf ParsedExpr value.
+41 -27
View File
@@ -182,7 +182,6 @@ func (lib *stdLibrary) CompileOptions() []EnvOption {
if err = lib.subset.Validate(); err != nil {
return nil, err
}
e.variables = append(e.variables, stdlib.Types()...)
for _, fn := range funcs {
existing, found := e.functions[fn.Name()]
if found {
@@ -591,7 +590,7 @@ func (lib *optionalLib) CompileOptions() []EnvOption {
// ProgramOptions implements the Library interface method.
func (lib *optionalLib) ProgramOptions() []ProgramOption {
return []ProgramOption{
CustomDecorator(decorateOptionalOr),
CustomDecoratorV2(decorateOptionalOr),
}
}
@@ -684,7 +683,7 @@ func EnableErrorOnBadPresenceTest(value bool) EnvOption {
return features(featureEnableErrorOnBadPresenceTest, value)
}
func decorateOptionalOr(i interpreter.Interpretable) (interpreter.Interpretable, error) {
func decorateOptionalOr(i interpreter.InterpretableV2) (interpreter.InterpretableV2, error) {
call, ok := i.(interpreter.InterpretableCall)
if !ok {
return i, nil
@@ -721,8 +720,8 @@ func decorateOptionalOr(i interpreter.Interpretable) (interpreter.Interpretable,
// the second optional expression is evaluated and returned.
type evalOptionalOr struct {
id int64
lhs interpreter.Interpretable
rhs interpreter.Interpretable
lhs interpreter.InterpretableV2
rhs interpreter.InterpretableV2
}
// ID implements the Interpretable interface method.
@@ -730,27 +729,34 @@ func (opt *evalOptionalOr) ID() int64 {
return opt.id
}
func (opt *evalOptionalOr) Exec(frame *interpreter.ExecutionFrame) ref.Val {
// short-circuit lhs.
optLHS := opt.lhs.Exec(frame)
switch val := optLHS.(type) {
case *types.Err, *types.Unknown:
return optLHS
case *types.Optional:
if val.HasValue() {
return optLHS
}
return opt.rhs.Exec(frame)
default:
return types.NoSuchOverloadErr()
}
}
// Eval evaluates the left-hand side optional to determine whether it contains a value, else
// proceeds with the right-hand side evaluation.
func (opt *evalOptionalOr) Eval(ctx interpreter.Activation) ref.Val {
// short-circuit lhs.
optLHS := opt.lhs.Eval(ctx)
optVal, ok := optLHS.(*types.Optional)
if !ok {
return optLHS
}
if optVal.HasValue() {
return optVal
}
return opt.rhs.Eval(ctx)
return opt.Exec(interpreter.AsFrame(ctx))
}
// evalOptionalOrValue selects between an optional or a concrete value. If the optional has a value,
// its value is returned, otherwise the alternative value expression is evaluated and returned.
type evalOptionalOrValue struct {
id int64
lhs interpreter.Interpretable
rhs interpreter.Interpretable
lhs interpreter.InterpretableV2
rhs interpreter.InterpretableV2
}
// ID implements the Interpretable interface method.
@@ -758,19 +764,27 @@ func (opt *evalOptionalOrValue) ID() int64 {
return opt.id
}
func (opt *evalOptionalOrValue) Exec(frame *interpreter.ExecutionFrame) ref.Val {
// short-circuit lhs.
optLHS := opt.lhs.Exec(frame)
switch val := optLHS.(type) {
case *types.Err, *types.Unknown:
return optLHS
case *types.Optional:
if val.HasValue() {
return val.GetValue()
}
return opt.rhs.Exec(frame)
default:
return types.NoSuchOverloadErr()
}
}
// Eval evaluates the left-hand side optional to determine whether it contains a value, else
// proceeds with the right-hand side evaluation.
func (opt *evalOptionalOrValue) Eval(ctx interpreter.Activation) ref.Val {
// short-circuit lhs.
optLHS := opt.lhs.Eval(ctx)
optVal, ok := optLHS.(*types.Optional)
if !ok {
return optLHS
}
if optVal.HasValue() {
return optVal.GetValue()
}
return opt.rhs.Eval(ctx)
return opt.Exec(interpreter.AsFrame(ctx))
}
type timeLegacyLibrary struct{}
+46 -8
View File
@@ -15,6 +15,7 @@
package cel
import (
"fmt"
"sort"
"github.com/google/cel-go/common"
@@ -29,17 +30,43 @@ import (
// passes to ensure that the final optimized output is a valid expression with metadata consistent
// with what would have been generated from a parsed and checked expression.
//
// Note: source position information is best-effort and likely wrong, but optimized expressions
// Note: source position information is best-effort and incomplete, but optimized expressions
// should be suitable for calls to parser.Unparse.
type StaticOptimizer struct {
optimizers []ASTOptimizer
// If set, Optimize() will use this Source instead of the one from the AST.
sourceOverride *Source
}
type OptimizerOption func(*StaticOptimizer) (*StaticOptimizer, error)
// NewStaticOptimizer creates a StaticOptimizer with a sequence of ASTOptimizer's to be applied
// to a checked expression.
func NewStaticOptimizer(optimizers ...ASTOptimizer) *StaticOptimizer {
return &StaticOptimizer{
optimizers: optimizers,
func NewStaticOptimizer(options ...any) (*StaticOptimizer, error) {
so := &StaticOptimizer{}
var err error
for _, opt := range options {
switch v := opt.(type) {
case ASTOptimizer:
so.optimizers = append(so.optimizers, v)
case OptimizerOption:
so, err = v(so)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported option: %v", v)
}
}
return so, nil
}
// OptimizeWithSource overrides the source used by the optimizer.
// Note this will cause the source info from the AST passed to Optimize() to be discarded.
func OptimizeWithSource(source Source) OptimizerOption {
return func(so *StaticOptimizer) (*StaticOptimizer, error) {
so.sourceOverride = &source
return so, nil
}
}
@@ -49,15 +76,21 @@ func NewStaticOptimizer(optimizers ...ASTOptimizer) *StaticOptimizer {
func (opt *StaticOptimizer) Optimize(env *Env, a *Ast) (*Ast, *Issues) {
// Make a copy of the AST to be optimized.
optimized := ast.Copy(a.NativeRep())
source := a.Source()
sourceInfo := optimized.SourceInfo()
if opt.sourceOverride != nil {
source = *opt.sourceOverride
sourceInfo = ast.NewSourceInfo(*opt.sourceOverride)
}
ids := newIDGenerator(ast.MaxID(a.NativeRep()))
// Create the optimizer context, could be pooled in the future.
issues := NewIssues(common.NewErrors(a.Source()))
issues := NewIssues(common.NewErrors(source))
baseFac := ast.NewExprFactory()
exprFac := &optimizerExprFactory{
idGenerator: ids,
fac: baseFac,
sourceInfo: optimized.SourceInfo(),
sourceInfo: sourceInfo,
}
ctx := &OptimizerContext{
optimizerExprFactory: exprFac,
@@ -80,7 +113,7 @@ func (opt *StaticOptimizer) Optimize(env *Env, a *Ast) (*Ast, *Issues) {
// Recheck the updated expression for any possible type-agreement or validation errors.
parsed := &Ast{
source: a.Source(),
source: source,
impl: ast.NewAST(expr, info)}
checked, iss := ctx.Check(parsed)
if iss.Err() != nil {
@@ -91,7 +124,7 @@ func (opt *StaticOptimizer) Optimize(env *Env, a *Ast) (*Ast, *Issues) {
// Return the optimized result.
return &Ast{
source: a.Source(),
source: source,
impl: optimized,
}, nil
}
@@ -100,6 +133,8 @@ func (opt *StaticOptimizer) Optimize(env *Env, a *Ast) (*Ast, *Issues) {
// that the ids within the expression correspond to the ids within macros.
func normalizeIDs(idGen ast.IDGenerator, optimized ast.Expr, info *ast.SourceInfo) {
optimized.RenumberIDs(idGen)
info.RenumberIDs(idGen)
if len(info.MacroCalls()) == 0 {
return
}
@@ -260,6 +295,9 @@ func (opt *optimizerExprFactory) CopyASTAndMetadata(a *ast.AST) ast.Expr {
for macroID, call := range copyInfo.MacroCalls() {
opt.SetMacroCall(macroID, call)
}
for id, offset := range copyInfo.OffsetRanges() {
opt.sourceInfo.SetOffsetRange(id, offset)
}
return copyExpr
}
+187 -30
View File
@@ -24,6 +24,7 @@ import (
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/dynamicpb"
"github.com/google/cel-go/cel/async"
"github.com/google/cel-go/checker"
"github.com/google/cel-go/common/containers"
"github.com/google/cel-go/common/decls"
@@ -71,12 +72,16 @@ const (
// Enable escape syntax for field identifiers (`).
featureIdentEscapeSyntax
// Enable accessing fields by JSON names within protobuf messages
featureJSONFieldNames
)
var featureIDsToNames = map[int]string{
featureEnableMacroCallTracking: "cel.feature.macro_call_tracking",
featureCrossTypeNumericComparisons: "cel.feature.cross_type_numeric_comparisons",
featureIdentEscapeSyntax: "cel.feature.backtick_escape_syntax",
featureJSONFieldNames: "cel.feature.json_field_names",
}
func featureNameByID(id int) (string, bool) {
@@ -93,6 +98,51 @@ func featureIDByName(name string) (int, bool) {
return 0, false
}
// limitID is used as a key for configurable limits. These are options that
// support exporting to YAML environment config.
type limitID int
const (
_ = limitID(iota)
// The number of recursive calls permitted in parsing.
limitParseRecursionDepth
// The number of code points permitted in an input expression string.
limitCodePointSize
// The number of attempts to recover from a parse error.
limitParseErrorRecovery
// The maximum nesting depth permitted for ASTs loaded outside the parser.
limitMaxASTDepth
// The maximum number of expression nodes permitted in parsing (including macro expansion).
limitExpressionNodeCount
)
// defaultMaxASTDepth mirrors the parser's default maxRecursionDepth (250) and
// is applied to ASTs that enter through non-parser ingestion paths (e.g. via
// ParsedExprToAst / CheckedExprToAst) when no explicit limit is configured.
const defaultMaxASTDepth = 250
var limitIDsToNames = map[limitID]string{
limitCodePointSize: "cel.limit.expression_code_points",
limitParseErrorRecovery: "cel.limit.parse_error_recovery",
limitParseRecursionDepth: "cel.limit.parse_recursion_depth",
limitMaxASTDepth: "cel.limit.max_ast_depth",
limitExpressionNodeCount: "cel.limit.expression_node_count",
}
func limitNameByID(id limitID) (string, bool) {
v, ok := limitIDsToNames[id]
return v, ok
}
func limitIDByName(name string) (limitID, bool) {
for k, v := range limitIDsToNames {
if v == name {
return k, true
}
}
return limitID(0), false
}
// EnvOption is a functional interface for configuring the environment.
type EnvOption func(e *Env) (*Env, error)
@@ -275,9 +325,9 @@ func Abbrevs(qualifiedNames ...string) EnvOption {
}
}
// customTypeRegistry is an internal-only interface containing the minimum methods required to support
// protoTypeRegistry is an internal-only interface containing the minimum methods required to support
// custom types. It is a subset of methods from ref.TypeRegistry.
type customTypeRegistry interface {
type protoTypeRegistry interface {
RegisterDescriptor(protoreflect.FileDescriptor) error
RegisterType(...ref.Type) error
}
@@ -294,7 +344,7 @@ type customTypeRegistry interface {
// Note: This option must be specified after the CustomTypeProvider option when used together.
func Types(addTypes ...any) EnvOption {
return func(e *Env) (*Env, error) {
reg, isReg := e.provider.(customTypeRegistry)
reg, isReg := e.provider.(protoTypeRegistry)
if !isReg {
return nil, fmt.Errorf("custom types not supported by provider: %T", e.provider)
}
@@ -331,7 +381,7 @@ func Types(addTypes ...any) EnvOption {
// extension or by re-using the same EnvOption with another NewEnv() call.
func TypeDescs(descs ...any) EnvOption {
return func(e *Env) (*Env, error) {
reg, isReg := e.provider.(customTypeRegistry)
reg, isReg := e.provider.(protoTypeRegistry)
if !isReg {
return nil, fmt.Errorf("custom types not supported by provider: %T", e.provider)
}
@@ -379,7 +429,7 @@ func TypeDescs(descs ...any) EnvOption {
}
}
func registerFileSet(reg customTypeRegistry, fileSet *descpb.FileDescriptorSet) error {
func registerFileSet(reg protoTypeRegistry, fileSet *descpb.FileDescriptorSet) error {
files, err := protodesc.NewFiles(fileSet)
if err != nil {
return fmt.Errorf("protodesc.NewFiles(%v) failed: %v", fileSet, err)
@@ -387,7 +437,7 @@ func registerFileSet(reg customTypeRegistry, fileSet *descpb.FileDescriptorSet)
return registerFiles(reg, files)
}
func registerFiles(reg customTypeRegistry, files *protoregistry.Files) error {
func registerFiles(reg protoTypeRegistry, files *protoregistry.Files) error {
var err error
files.RangeFiles(func(fd protoreflect.FileDescriptor) bool {
err = reg.RegisterDescriptor(fd)
@@ -396,6 +446,15 @@ func registerFiles(reg customTypeRegistry, files *protoregistry.Files) error {
return err
}
// JSONFieldNames supports accessing protocol buffer fields by json-name.
//
// Enabling JSON field name support will create a copy of the types.Registry with fields indexed
// by JSON name, and whether JSON name or Proto-style names are supported will be inferred from
// the AST extensions metadata.
func JSONFieldNames(enabled bool) EnvOption {
return features(featureJSONFieldNames, enabled)
}
// ProgramOption is a functional interface for configuring evaluation bindings and behaviors.
type ProgramOption func(p *prog) (*prog, error)
@@ -409,6 +468,14 @@ func CustomDecorator(dec interpreter.InterpretableDecorator) ProgramOption {
}
}
// CustomDecoratorV2 appends an InterpreterDecoratorV2 to the program.
func CustomDecoratorV2(dec interpreter.InterpretableDecoratorV2) ProgramOption {
return func(p *prog) (*prog, error) {
p.plannerOptions = append(p.plannerOptions, interpreter.CustomDecoratorV2(dec))
return p, nil
}
}
// Functions adds function overloads that extend or override the set of CEL built-ins.
//
// Deprecated: use Function() instead to declare the function, its overload signatures,
@@ -523,6 +590,17 @@ func configToEnvOptions(config *env.Config, provider types.Provider, optFactorie
envOpts = append(envOpts, Abbrevs(imp.Name))
}
// Configure features and common limits.
for _, feat := range config.Features {
// Note, if a feature is not found, it is skipped as it is possible the feature
// is not intended to be supported publicly. In the future, a refinement of
// to this strategy to report unrecognized features and validators should probably
// be covered as a standard ConfigOptionFactory
if id, found := featureIDByName(feat.Name); found {
envOpts = append(envOpts, features(id, feat.Enabled))
}
}
// Configure the context variable declaration
if config.ContextVariable != nil {
typeName := config.ContextVariable.TypeName
@@ -564,14 +642,9 @@ func configToEnvOptions(config *env.Config, provider types.Provider, optFactorie
envOpts = append(envOpts, FunctionDecls(funcs...))
}
// Configure features
for _, feat := range config.Features {
// Note, if a feature is not found, it is skipped as it is possible the feature
// is not intended to be supported publicly. In the future, a refinement of
// to this strategy to report unrecognized features and validators should probably
// be covered as a standard ConfigOptionFactory
if id, found := featureIDByName(feat.Name); found {
envOpts = append(envOpts, features(id, feat.Enabled))
for _, limit := range config.Limits {
if id, found := limitIDByName(limit.Name); found {
envOpts = append(envOpts, setLimit(id, limit.Value))
}
}
@@ -674,6 +747,47 @@ func InterruptCheckFrequency(checkFrequency uint) ProgramOption {
}
}
// AsyncCallObserver sets the observer for monitoring asynchronous function calls during ConcurrentEval.
func AsyncCallObserver(observer async.Observer) ProgramOption {
return func(p *prog) (*prog, error) {
p.asyncObserver = observer
return p, nil
}
}
// AsyncCompletionBufferSize sets the size of the buffer for the async completion channel.
// By default, the channel is unbuffered.
func AsyncCompletionBufferSize(size int) ProgramOption {
return func(p *prog) (*prog, error) {
p.asyncCompletionBufferSize = size
return p, nil
}
}
// AsyncMaxConcurrency sets the maximum number of concurrently launched async calls during
// ConcurrentEval. This bounds the number of in-flight async goroutines, so a wide fan-out (such
// as an async call inside a comprehension over a large list) cannot exhaust memory.
//
// A value of 0 (unset) applies a built-in default bound. A positive value sets an explicit bound.
// A negative value disables the limiter (unbounded launches) and should only be used when
// concurrency is bounded by other means.
func AsyncMaxConcurrency(maxConcurrency int) ProgramOption {
return func(p *prog) (*prog, error) {
p.asyncMaxConcurrency = maxConcurrency
return p, nil
}
}
// ConcurrentDrainStrategy configures the strategy for when to re-evaluate the program
// during a ConcurrentEval call after receiving asynchronous completion signals.
// By default, the program re-evaluates immediately after every completion.
func ConcurrentDrainStrategy(strategy async.DrainStrategy) ProgramOption {
return func(p *prog) (*prog, error) {
p.drainStrategy = strategy
return p, nil
}
}
// CostEstimatorOptions configure type-check time options for estimating expression cost.
func CostEstimatorOptions(costOpts ...checker.CostOption) EnvOption {
return func(e *Env) (*Env, error) {
@@ -727,8 +841,11 @@ func fieldToCELType(field protoreflect.FieldDescriptor) (*Type, error) {
return nil, fmt.Errorf("field %s type %s not implemented", field.FullName(), field.Kind().String())
}
func fieldToVariable(field protoreflect.FieldDescriptor) (*decls.VariableDecl, error) {
func fieldToVariable(field protoreflect.FieldDescriptor, jsonFieldNames bool) (*decls.VariableDecl, error) {
name := string(field.Name())
if jsonFieldNames {
name = field.JSONName()
}
if field.IsMap() {
mapKey := field.MapKey()
mapValue := field.MapValue()
@@ -759,6 +876,8 @@ func fieldToVariable(field protoreflect.FieldDescriptor) (*decls.VariableDecl, e
// DeclareContextProto returns an option to extend CEL environment with declarations from the given context proto.
// Each field of the proto defines a variable of the same name in the environment.
// https://github.com/google/cel-spec/blob/master/doc/langdef.md#evaluation-environment
//
// If using JSONFieldNames(), ensure that the option is set before DeclareContextProto is provided.
func DeclareContextProto(descriptor protoreflect.MessageDescriptor) EnvOption {
return func(e *Env) (*Env, error) {
if e.contextProto != nil {
@@ -768,9 +887,10 @@ func DeclareContextProto(descriptor protoreflect.MessageDescriptor) EnvOption {
e.contextProto = descriptor
fields := descriptor.Fields()
vars := make([]*decls.VariableDecl, 0, fields.Len())
jsonFieldNames := e.HasFeature(featureJSONFieldNames)
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
variable, err := fieldToVariable(field)
variable, err := fieldToVariable(field, jsonFieldNames)
if err != nil {
return nil, err
}
@@ -789,11 +909,15 @@ func DeclareContextProto(descriptor protoreflect.MessageDescriptor) EnvOption {
//
// Consider using with `DeclareContextProto` to simplify variable type declarations and publishing when using
// protocol buffers.
func ContextProtoVars(ctx proto.Message) (Activation, error) {
//
// Use the types.JSONFieldNames(true) option to populate the context proto vars using the JSON field names.
func ContextProtoVars(ctx proto.Message, opts ...types.RegistryOption) (Activation, error) {
if ctx == nil || !ctx.ProtoReflect().IsValid() {
return interpreter.EmptyActivation(), nil
}
reg, err := types.NewRegistry(ctx)
regOpts := []types.RegistryOption{types.ProtoTypeDefs(ctx)}
regOpts = append(regOpts, opts...)
reg, err := types.NewProtoRegistry(regOpts...)
if err != nil {
return nil, err
}
@@ -803,15 +927,19 @@ func ContextProtoVars(ctx proto.Message) (Activation, error) {
vars := make(map[string]any, fields.Len())
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
sft, found := reg.FindStructFieldType(typeName, field.TextName())
fieldName := field.TextName()
if reg.JSONFieldNames() {
fieldName = field.JSONName()
}
sft, found := reg.FindStructFieldType(typeName, fieldName)
if !found {
return nil, fmt.Errorf("no such field: %s", field.TextName())
return nil, fmt.Errorf("no such field: %s", fieldName)
}
fieldVal, err := sft.GetFrom(ctx)
if err != nil {
return nil, err
}
vars[field.TextName()] = fieldVal
vars[fieldName] = fieldVal
}
return NewActivation(vars)
}
@@ -847,22 +975,51 @@ func features(flag int, enabled bool) EnvOption {
}
}
// ParserRecursionLimit adjusts the AST depth the parser will tolerate.
// Defaults defined in the parser package.
func ParserRecursionLimit(limit int) EnvOption {
func setLimit(id limitID, limit int) EnvOption {
if limit < 0 {
limit = -1
}
return func(e *Env) (*Env, error) {
e.prsrOpts = append(e.prsrOpts, parser.MaxRecursionDepth(limit))
e.limits[id] = limit
return e, nil
}
}
// ParserExpressionSizeLimit adjusts the number of code points the expression parser is allowed to parse.
// ParserRecursionLimit adjusts the AST depth the parser will tolerate.
// Defaults defined in the parser package.
func ParserRecursionLimit(limit int) EnvOption {
return setLimit(limitParseRecursionDepth, limit)
}
// ParserErrorRecoveryLimit sets the number of attemtps the parser will take
// to recover after encountering an error.
func ParserErrorRecoveryLimit(limit int) EnvOption {
return setLimit(limitParseErrorRecovery, limit)
}
// ParserExpressionSizeLimit adjusts the number of code points the expression parser is allowed to parse.
// Defaults are defined in the parser package. A negative value means unbounded.
func ParserExpressionSizeLimit(limit int) EnvOption {
return func(e *Env) (*Env, error) {
e.prsrOpts = append(e.prsrOpts, parser.ExpressionSizeCodePointLimit(limit))
return e, nil
}
return setLimit(limitCodePointSize, limit)
}
// ExpressionNodeLimit adjusts the maximum number of expression nodes permitted during parsing
// and checking, including nodes created by macro expansion. Defaults are defined in the parser
// package (100,000). A negative value means unbounded.
func ExpressionNodeLimit(limit int) EnvOption {
return setLimit(limitExpressionNodeCount, limit)
}
// ExpressionNestingDepthLimit records the maximum nesting depth permitted for ASTs in the
// environment configuration so that the value round-trips through env.Config export/import.
//
// ASTs loaded outside the parser (e.g. via ParsedExprToAst / CheckedExprToAst) bypass the
// parser's recursion limit, so those conversion paths validate nesting depth against the
// parser-matching default (250) to avoid a Go stack overflow during later checking or planning.
// Embedders that fully control their AST inputs and want to skip the check can construct the AST
// through the common/ast package directly rather than the cel conversion helpers.
func ExpressionNestingDepthLimit(limit int) EnvOption {
return setLimit(limitMaxASTDepth, limit)
}
// EnableHiddenAccumulatorName sets the parser to use the identifier '@result' for accumulators
+304 -172
View File
@@ -16,10 +16,13 @@ package cel
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/google/cel-go/cel/async"
"github.com/google/cel-go/common/ast"
"github.com/google/cel-go/common/functions"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/interpreter"
@@ -51,6 +54,21 @@ type Program interface {
//
// The output contract for `ContextEval` is otherwise identical to the `Eval` method.
ContextEval(context.Context, any) (ref.Val, *EvalDetails, error)
// ConcurrentEval evaluates the program concurrently, returning a channel that will receive
// the final EvalResult when all asynchronous operations complete, or the context expires.
//
// The vars value may either be an `Activation` or `map[string]any`.
//
// Liveness: ConcurrentEval relies on context cancellation to terminate. If an async function
// never returns and does not honor its context, and the supplied context has no deadline, the
// call will block indefinitely. Always pass a context with a deadline or cancellation.
//
// Error handling is fail-fast: as soon as a re-evaluation pass yields an error, that error is
// returned and any still in-flight async calls are cancelled (their contexts are done) and
// their results discarded. Async functions should therefore be free of unwanted side effects
// on partial evaluation, or guard them with idempotency/cancellation handling.
ConcurrentEval(context.Context, any) <-chan EvalResult
}
// Activation used to resolve identifiers by name and references by id.
@@ -143,6 +161,13 @@ func (ed *EvalDetails) ActualCost() *uint64 {
return &cost
}
// EvalResult encapsulates the response from a ConcurrentEval call.
type EvalResult struct {
Val ref.Val
EvalDetails *EvalDetails
Err error
}
// prog is the internal implementation of the Program interface.
type prog struct {
*Env
@@ -158,11 +183,21 @@ type prog struct {
regexOptimizations []*interpreter.RegexOptimization
// Interpretable configured from an Ast and aggregate decorator set based on program options.
interpretable interpreter.Interpretable
interpretable interpreter.InterpretableV2
observable *interpreter.ObservableInterpretable
callCostEstimator interpreter.ActualCostEstimator
costOptions []interpreter.CostTrackerOption
costLimit *uint64
// hasAsync indicates the planned expression contains an asynchronous function call, which can
// only be resolved by ConcurrentEval.
hasAsync bool
// Async evaluation configuration used by ConcurrentEval.
drainStrategy async.DrainStrategy
asyncObserver async.Observer
asyncCompletionBufferSize int
asyncMaxConcurrency int
}
// newProgram creates a program instance with an environment, an ast, and an optional list of
@@ -180,6 +215,7 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) {
plannerOptions: []interpreter.PlannerOption{},
dispatcher: disp,
costOptions: []interpreter.CostTrackerOption{},
drainStrategy: async.DrainReady(100 * time.Microsecond),
}
// Configure the program via the ProgramOption values.
@@ -191,15 +227,35 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) {
}
}
// Add the function bindings created via Function() options.
for _, fn := range e.functions {
bindings, err := fn.Bindings()
if err != nil {
return nil, err
e.funcBindOnce.Do(func() {
var bindings []*functions.Overload
e.functionBindings = []*functions.Overload{}
for _, fn := range e.functions {
bindings, err = fn.Bindings()
if err != nil {
return
}
e.functionBindings = append(e.functionBindings, bindings...)
}
err = disp.Add(bindings...)
if err != nil {
return nil, err
})
if err != nil {
return nil, err
}
// Add the function bindings created via Function() options.
err = disp.Add(e.functionBindings...)
if err != nil {
return nil, err
}
// Determine whether the environment declares any asynchronous function. Async is a property of
// the binding, so its presence is known from the environment alone, without inspecting the
// program plan. The synchronous entry points (Eval, ContextEval) reject programs from an env
// with async functions; callers needing synchronous evaluation should use a non-async env.
for _, b := range e.functionBindings {
if b.Async != nil {
p.hasAsync = true
break
}
}
@@ -208,6 +264,12 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) {
attrFactorOpts := []interpreter.AttrFactoryOption{
interpreter.EnableErrorOnBadPresenceTest(p.HasFeature(featureEnableErrorOnBadPresenceTest)),
}
if a.SourceInfo().HasExtension("json_name", ast.NewExtensionVersion(1, 1)) {
if !e.HasFeature(featureJSONFieldNames) {
return nil, errors.New("the AST extension 'json_name' requires the option cel.JSONFieldNames(true)")
}
}
// Configure the type provider, considering whether the AST indicates whether it supports JSON field names
if p.evalOpts&OptPartialEval == OptPartialEval {
attrFactory = interpreter.NewPartialAttributeFactory(e.Container, e.adapter, e.provider, attrFactorOpts...)
} else {
@@ -245,8 +307,16 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) {
if p.costLimit != nil {
costOpts = append(costOpts, interpreter.CostTrackerLimit(*p.costLimit))
}
// Creating a new cost tracker for each evaluation causes significant work that
// needs to be repeated for each evaluation even though the cost tracker is
// mostly read-only once constructed. Therefore it gets constructed
// once now and later a cheap clone is used for each evaluation.
tracker, err := interpreter.NewCostTracker(p.callCostEstimator, costOpts...)
if err != nil {
return nil, fmt.Errorf("construct cost tracker: %w", err)
}
trackerFactory := func() (*interpreter.CostTracker, error) {
return interpreter.NewCostTracker(p.callCostEstimator, costOpts...)
return tracker.Clone()
}
var observers []interpreter.PlannerOption
if p.evalOpts&(OptExhaustiveEval|OptTrackState) != 0 {
@@ -295,23 +365,25 @@ func (p *prog) Eval(input any) (out ref.Val, det *EvalDetails, err error) {
}
}
}()
// Build a hierarchical activation if there are default vars set.
var vars Activation
switch v := input.(type) {
case Activation:
vars = v
case map[string]any:
vars = activationPool.Setup(v)
defer activationPool.Put(vars)
default:
return nil, nil, fmt.Errorf("invalid input, wanted Activation or map[string]any, got: (%T)%v", input, input)
// Asynchronous calls cannot be resolved by a single-pass evaluation. Reject before doing any
// work (this also covers ContextEval, which delegates here); ConcurrentEval does not call Eval.
if p.hasAsync {
return nil, nil, errAsyncRequiresConcurrentEval
}
if p.defaultVars != nil {
vars = interpreter.NewHierarchicalActivation(p.defaultVars, vars)
// Build a hierarchical activation if there are default vars set.
var frame *interpreter.ExecutionFrame
if f, ok := input.(*interpreter.ExecutionFrame); ok {
frame = f
} else {
frame, err = p.newExecutionFrame(input)
if err != nil {
return nil, nil, err
}
defer frame.Close()
}
if p.observable != nil {
det = &EvalDetails{}
out = p.observable.ObserveEval(vars, func(observed any) {
out = p.observable.ObserveExec(frame, func(observed any) {
switch o := observed.(type) {
case interpreter.EvalState:
det.state = o
@@ -320,7 +392,7 @@ func (p *prog) Eval(input any) (out ref.Val, det *EvalDetails, err error) {
}
})
} else {
out = p.interpretable.Eval(vars)
out = p.interpretable.Exec(frame)
}
// The output of an internal Eval may have a value (`v`) that is a types.Err. This step
// translates the CEL value to a Go error response. This interface does not quite match the
@@ -336,160 +408,220 @@ func (p *prog) ContextEval(ctx context.Context, input any) (ref.Val, *EvalDetail
if ctx == nil {
return nil, nil, fmt.Errorf("context can not be nil")
}
// Configure the input, making sure to wrap Activation inputs in the special ctxActivation which
// exposes the #interrupted variable and manages rate-limited checks of the ctx.Done() state.
var vars Activation
switch v := input.(type) {
case Activation:
vars = ctxActivationPool.Setup(v, ctx.Done(), p.interruptCheckFrequency)
defer ctxActivationPool.Put(vars)
case map[string]any:
rawVars := activationPool.Setup(v)
defer activationPool.Put(rawVars)
vars = ctxActivationPool.Setup(rawVars, ctx.Done(), p.interruptCheckFrequency)
defer ctxActivationPool.Put(vars)
default:
return nil, nil, fmt.Errorf("invalid input, wanted Activation or map[string]any, got: (%T)%v", input, input)
frame, err := p.newExecutionFrame(input)
if err != nil {
return nil, nil, err
}
return p.Eval(vars)
defer frame.Close()
frame.SetContext(ctx, p.interruptCheckFrequency)
out, det, errEval := p.Eval(frame)
if errEval != nil && errors.Is(errEval, interpreter.InterruptError{}) {
return out, det, fmt.Errorf("%w: %w", errEval, context.Cause(ctx))
}
return out, det, errEval
}
type ctxEvalActivation struct {
parent Activation
interrupt <-chan struct{}
interruptCheckCount uint
interruptCheckFrequency uint
// newExecutionFrame creates an ExecutionFrame for the given input without a timeout context.
func (p *prog) newExecutionFrame(input any) (*interpreter.ExecutionFrame, error) {
frame, err := interpreter.NewExecutionFrame(input)
if err != nil {
return nil, err
}
if p.defaultVars != nil {
// Update the frame's activation in place.
frame.Activation = interpreter.NewHierarchicalActivation(p.defaultVars, frame.Activation)
}
return frame, nil
}
// ResolveName implements the Activation interface method, but adds a special #interrupted variable
// which is capable of testing whether a 'done' signal is provided from a context.Context channel.
func (a *ctxEvalActivation) ResolveName(name string) (any, bool) {
if name == "#interrupted" {
a.interruptCheckCount++
if a.interruptCheckCount%a.interruptCheckFrequency == 0 {
// newAsyncFrame creates an ExecutionFrame configured for asynchronous evaluation under the
// given context, wiring the observer and concurrency limit from the program options.
func (p *prog) newAsyncFrame(ctx context.Context, input any) (*interpreter.ExecutionFrame, error) {
frame, err := p.newExecutionFrame(input)
if err != nil {
return nil, err
}
if err := frame.SetContext(ctx, p.interruptCheckFrequency); err != nil {
frame.Close()
return nil, err
}
frame.SetAsyncObserver(p.asyncObserver)
frame.SetAsyncMaxConcurrency(resolveAsyncMaxConcurrency(p.asyncMaxConcurrency))
return frame, nil
}
// defaultAsyncMaxConcurrency bounds the number of concurrently launched async calls when the
// program does not configure AsyncMaxConcurrency. It exists so that a wide fan-out (e.g. an async
// call inside a comprehension over a large list) cannot spawn an unbounded number of goroutines.
const defaultAsyncMaxConcurrency = 100
// resolveAsyncMaxConcurrency maps the configured concurrency to the effective launch limit:
// - 0 (unset): apply defaultAsyncMaxConcurrency.
// - >0: use the configured value.
// - <0: unlimited (no launch limiter); use only if the caller bounds concurrency another way.
func resolveAsyncMaxConcurrency(configured int) int {
if configured == 0 {
return defaultAsyncMaxConcurrency
}
return configured
}
// resolveCompletionBufferSize returns the size of the async completion channel. When unset, it
// defaults to the effective launch concurrency so that all in-flight calls can report completion
// without blocking. An unbuffered channel would make a completed call hold its launch slot until
// the evaluator drained it, throttling effective concurrency to the drain rate.
func (p *prog) resolveCompletionBufferSize() int {
if p.asyncCompletionBufferSize > 0 {
return p.asyncCompletionBufferSize
}
limit := resolveAsyncMaxConcurrency(p.asyncMaxConcurrency)
if limit < 0 {
// Unlimited launches: fall back to the default bound for the buffer so it stays finite.
return defaultAsyncMaxConcurrency
}
return limit
}
// ConcurrentEval implements the Program interface.
func (p *prog) ConcurrentEval(ctx context.Context, input any) <-chan EvalResult {
resCh := make(chan EvalResult, 1)
if ctx == nil {
resCh <- EvalResult{Err: errors.New("context can not be nil")}
close(resCh)
return resCh
}
go func() {
defer close(resCh)
// Ensure concurrent eval handles panic / recovery properly
defer func() {
if r := recover(); r != nil {
switch t := r.(type) {
case interpreter.EvalCancelledError:
resCh <- EvalResult{Err: t}
default:
resCh <- EvalResult{Err: fmt.Errorf("internal error: %v", r)}
}
}
}()
frame, err := p.newAsyncFrame(ctx, input)
if err != nil {
resCh <- EvalResult{Err: err}
return
}
defer frame.Close()
// Completions are signaled to this channel as async calls finish. The asyncCallState
// fan-in also selects on ctx.Done(), so the sender will not leak if this loop returns early.
completions := make(chan int64, p.resolveCompletionBufferSize())
frame.SetCompletions(completions)
for {
var out ref.Val
var det *EvalDetails
if p.observable != nil {
det = &EvalDetails{}
out = p.observable.ObserveExec(frame, func(observed any) {
switch o := observed.(type) {
case interpreter.EvalState:
det.state = o
case *interpreter.CostTracker:
det.costTracker = o
}
})
} else {
out = p.interpretable.Exec(frame)
}
// Communicate errors quickly.
if types.IsError(out) {
var err error = out.(*types.Err)
if errors.Is(err, interpreter.InterruptError{}) {
err = fmt.Errorf("%w: %w", err, context.Cause(ctx))
}
resCh <- EvalResult{Val: out, EvalDetails: det, Err: err}
return
}
// A concrete (non-unknown) result is final.
unk, isUnknown := out.(*types.Unknown)
if !isUnknown || !unk.HasUnknownFunction() {
resCh <- EvalResult{Val: out, EvalDetails: det, Err: nil}
return
}
// Post-execution dispatch: launch only the async calls required by the unknown result.
frame.DispatchPendingAsyncCalls(unk.IDs())
// The result depends on one or more unresolved async calls. Wait for completions and
// re-evaluate according to the configured drain strategy.
var batch []async.Call
// Wait for at least one completion (or cancellation).
select {
case <-a.interrupt:
return true, true
default:
return nil, false
case id := <-completions:
if call := frame.AsyncCall(id); call != nil {
batch = append(batch, call)
}
case <-ctx.Done():
resCh <- EvalResult{Val: out, EvalDetails: det, Err: ctx.Err()}
return
}
// Accumulate completions and consult the strategy.
var timer *time.Timer
reevaluate := false
for !reevaluate {
active := frame.ActiveAsyncCalls()
action := p.drainStrategy.NextAction(batch, active)
if action.Reevaluate {
break
}
var timeoutCh <-chan time.Time
if action.WaitDuration > 0 {
if timer == nil {
timer = time.NewTimer(action.WaitDuration)
} else {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(action.WaitDuration)
}
timeoutCh = timer.C
}
select {
case id := <-completions:
if call := frame.AsyncCall(id); call != nil {
batch = append(batch, call)
}
case <-timeoutCh:
reevaluate = true
case <-ctx.Done():
if timer != nil {
timer.Stop()
}
resCh <- EvalResult{Val: out, EvalDetails: det, Err: ctx.Err()}
return
}
}
if timer != nil {
timer.Stop()
}
}
return nil, false
}
return a.parent.ResolveName(name)
}()
return resCh
}
func (a *ctxEvalActivation) Parent() Activation {
return a.parent
}
func (a *ctxEvalActivation) AsPartialActivation() (interpreter.PartialActivation, bool) {
pa, ok := a.parent.(interpreter.PartialActivation)
return pa, ok
}
func newCtxEvalActivationPool() *ctxEvalActivationPool {
return &ctxEvalActivationPool{
Pool: sync.Pool{
New: func() any {
return &ctxEvalActivation{}
},
},
}
}
type ctxEvalActivationPool struct {
sync.Pool
}
// Setup initializes a pooled Activation with the ability check for context.Context cancellation
func (p *ctxEvalActivationPool) Setup(vars Activation, done <-chan struct{}, interruptCheckRate uint) *ctxEvalActivation {
a := p.Pool.Get().(*ctxEvalActivation)
a.parent = vars
a.interrupt = done
a.interruptCheckCount = 0
a.interruptCheckFrequency = interruptCheckRate
return a
}
type evalActivation struct {
vars map[string]any
lazyVars map[string]any
}
// ResolveName looks up the value of the input variable name, if found.
//
// Lazy bindings may be supplied within the map-based input in either of the following forms:
// - func() any
// - func() ref.Val
//
// The lazy binding will only be invoked once per evaluation.
//
// Values which are not represented as ref.Val types on input may be adapted to a ref.Val using
// the types.Adapter configured in the environment.
func (a *evalActivation) ResolveName(name string) (any, bool) {
v, found := a.vars[name]
if !found {
return nil, false
}
switch obj := v.(type) {
case func() ref.Val:
if resolved, found := a.lazyVars[name]; found {
return resolved, true
}
lazy := obj()
a.lazyVars[name] = lazy
return lazy, true
case func() any:
if resolved, found := a.lazyVars[name]; found {
return resolved, true
}
lazy := obj()
a.lazyVars[name] = lazy
return lazy, true
default:
return obj, true
}
}
// Parent implements the Activation interface
func (a *evalActivation) Parent() Activation {
return nil
}
func newEvalActivationPool() *evalActivationPool {
return &evalActivationPool{
Pool: sync.Pool{
New: func() any {
return &evalActivation{lazyVars: make(map[string]any)}
},
},
}
}
type evalActivationPool struct {
sync.Pool
}
// Setup initializes a pooled Activation object with the map input.
func (p *evalActivationPool) Setup(vars map[string]any) *evalActivation {
a := p.Pool.Get().(*evalActivation)
a.vars = vars
return a
}
func (p *evalActivationPool) Put(value any) {
a := value.(*evalActivation)
for k := range a.lazyVars {
delete(a.lazyVars, k)
}
p.Pool.Put(a)
}
var (
// activationPool is an internally managed pool of Activation values that wrap map[string]any inputs
activationPool = newEvalActivationPool()
// ctxActivationPool is an internally managed pool of Activation values that expose a special #interrupted variable
ctxActivationPool = newCtxEvalActivationPool()
)
// errAsyncRequiresConcurrentEval is returned by the synchronous entry points (Eval, ContextEval)
// when the expression contains asynchronous function calls, which only ConcurrentEval can resolve.
var errAsyncRequiresConcurrentEval = errors.New(
"expression contains asynchronous function calls; use ConcurrentEval")
+83 -5
View File
@@ -23,15 +23,48 @@ import (
"github.com/google/cel-go/common"
"github.com/google/cel-go/common/operators"
"github.com/google/cel-go/common/overloads"
"github.com/google/cel-go/common/types"
)
//go:embed templates/authoring.tmpl
var authoringPrompt string
// splitImpl splits a string into a list of strings.
//
// Normalizes extracted comments (trim common prefix whitespace and extra trailing newlines).
func splitImpl(str string) []string {
str = strings.TrimRight(str, " \n\t\r")
out := strings.Split(str, "\n")
if len(out) == 0 {
return nil
}
negative := strings.TrimLeft(out[0], " \t")
lenNegative := len(negative)
lenOut := len(out[0])
if lenNegative == lenOut {
return out
}
prefix := out[0][:lenOut-lenNegative]
trimmed := make([]string, len(out))
for i, line := range out {
if line == "" {
trimmed[i] = ""
continue
}
if !strings.HasPrefix(line, prefix) {
return out
}
trimmed[i] = strings.TrimPrefix(line, prefix)
}
return trimmed
}
// AuthoringPrompt creates a prompt template from a CEL environment for the purpose of AI-assisted authoring.
func AuthoringPrompt(env *Env) (*Prompt, error) {
funcMap := template.FuncMap{
"split": func(str string) []string { return strings.Split(str, "\n") },
"split": splitImpl,
"newlineToSpace": func(str string) string { return strings.ReplaceAll(str, "\n", " ") },
}
tmpl := template.New("cel").Funcs(funcMap)
tmpl, err := tmpl.Parse(authoringPrompt)
@@ -47,6 +80,17 @@ func AuthoringPrompt(env *Env) (*Prompt, error) {
}, nil
}
// AuthoringPromptWithFieldPaths creates a prompt template from a CEL environment for the purpose of AI-assisted authoring.
// Includes documentation for all of the reachable field paths in the environment.
func AuthoringPromptWithFieldPaths(env *Env) (*Prompt, error) {
p, err := AuthoringPrompt(env)
if err != nil {
return nil, err
}
p.fieldPaths = true
return p, nil
}
// Prompt represents the core components of an LLM prompt based on a CEL environment.
//
// All fields of the prompt may be overwritten / modified with support for rendering the
@@ -64,14 +108,22 @@ type Prompt struct {
// tmpl is the text template base-configuration for rendering text.
tmpl *template.Template
// fieldPaths is a flag to include reachable field paths in the prompt.
fieldPaths bool
// env reference used to collect variables, functions, and macros available to the prompt.
env *Env
}
type promptVariable struct {
*common.Doc
FieldPaths []*common.Doc
}
type promptInst struct {
*Prompt
Variables []*common.Doc
Variables []*promptVariable
Macros []*common.Doc
Functions []*common.Doc
UserPrompt string
@@ -79,11 +131,36 @@ type promptInst struct {
// Render renders the user prompt with the associated context from the prompt template
// for use with LLM generators.
//
// User-supplied input is passed as template data via the UserPrompt field, which
// Go's text/template renders as a literal string value. Template action delimiters
// such as {{.Persona}} in the user prompt are never evaluated as template directives
// because text/template only executes directives present in the template definition
// itself, not in data values interpolated at render time.
func (p *Prompt) Render(userPrompt string) string {
var buffer strings.Builder
vars := make([]*common.Doc, len(p.env.Variables()))
vars := make([]*promptVariable, len(p.env.Variables()))
for i, v := range p.env.Variables() {
vars[i] = v.Documentation()
vars[i] = &promptVariable{Doc: v.Documentation()}
if p.fieldPaths && v.Type().Kind() == types.StructKind {
var fieldPaths []*common.Doc
paths := fieldPathsForType(p.env.CELTypeProvider(), v.Name(), v.Type())
if len(paths) < 2 {
paths = nil
} else {
// First path is the variable which is already documented.
paths = paths[1:]
}
for _, path := range paths {
fieldPaths = append(fieldPaths, path.Documentation())
}
sort.SliceStable(fieldPaths, func(i, j int) bool {
return fieldPaths[i].Name < fieldPaths[j].Name
})
vars[i].FieldPaths = fieldPaths
}
}
sort.SliceStable(vars, func(i, j int) bool {
return vars[i].Name < vars[j].Name
@@ -107,7 +184,8 @@ func (p *Prompt) Render(userPrompt string) string {
Variables: vars,
Macros: macs,
Functions: funcs,
UserPrompt: userPrompt}
UserPrompt: userPrompt,
}
p.tmpl.Execute(&buffer, inst)
return buffer.String()
}
+30 -8
View File
@@ -1,8 +1,29 @@
{{define "variable"}}{{.Name}} is a {{.Type}}
{{define "fieldPath" }}
* path: `{{.Name}}`
type: `{{.Type}}`
{{- if .Description }}
description:
{{range split .Description }} {{.}}
{{end}}
{{- end -}}
{{- end -}}
{{define "variable" -}}
* name: `{{.Name}}`
type: `{{.Type}}`
{{- if .Description}}
description:
{{range split .Description}} {{.}}
{{end -}}
{{- end -}}
{{- if .FieldPaths }}
attributes:
{{- range .FieldPaths }}{{ template "fieldPath" . }}{{end}}
{{- end -}}
{{- end -}}
{{define "macro" -}}
{{.Name}} macro{{if .Description}} - {{range split .Description}}{{.}} {{end}}
{{.Name}} macro{{if .Description}} - {{newlineToSpace .Description}}
{{end}}
{{range .Children}}{{range split .Description}} {{.}}
{{end}}
@@ -18,7 +39,7 @@
{{- end -}}
{{define "function" -}}
{{.Name}}{{if .Description}} - {{range split .Description}}{{.}} {{end}}
{{.Name}}{{if .Description}} - {{newlineToSpace .Description}}
{{end}}
{{range .Children}}{{template "overload" .}}{{end}}
{{- end -}}
@@ -32,25 +53,26 @@ Only use the following variables, macros, and functions in expressions.
{{if .Variables}}
Variables:
{{range .Variables}}* {{template "variable" .}}
{{range .Variables -}}
{{template "variable" .}}
{{end -}}
{{- end -}}
{{end -}}
{{if .Macros}}
Macros:
{{range .Macros}}* {{template "macro" .}}
{{end -}}
{{end -}}
{{if .Functions}}
Functions:
{{range .Functions}}* {{template "function" .}}
{{end -}}
{{end -}}
{{- end -}}
{{- end -}}
{{.GeneralUsage}}
{{.UserPrompt}}
+119 -12
View File
@@ -15,6 +15,7 @@
package cel
import (
"context"
"fmt"
"reflect"
"regexp"
@@ -25,11 +26,12 @@ import (
)
const (
durationValidatorName = "cel.validator.duration"
regexValidatorName = "cel.validator.matches"
timestampValidatorName = "cel.validator.timestamp"
homogeneousValidatorName = "cel.validator.homogeneous_literals"
nestingLimitValidatorName = "cel.validator.comprehension_nesting_limit"
durationValidatorName = "cel.validator.duration"
regexValidatorName = "cel.validator.matches"
timestampValidatorName = "cel.validator.timestamp"
homogeneousValidatorName = "cel.validator.homogeneous_literals"
nestingLimitValidatorName = "cel.validator.comprehension_nesting_limit"
bindNestingLimitValidatorName = "cel.validator.bind_nesting_limit"
// HomogeneousAggregateLiteralExemptFunctions is the ValidatorConfig key used to configure
// the set of function names which are exempt from homogeneous type checks. The expected type
@@ -45,6 +47,14 @@ var (
astValidatorFactories = map[string]ASTValidatorFactory{
nestingLimitValidatorName: func(val *env.Validator) (ASTValidator, error) {
if limit, found := val.ConfigValue("limit"); found {
// In case of protos, config value is of type by google.protobuf.Value, which numeric values are always a double.
if val, isDouble := limit.(float64); isDouble {
if val != float64(int64(val)) {
return nil, fmt.Errorf("invalid validator: %s, limit value is not a whole number: %v", nestingLimitValidatorName, limit)
}
return ValidateComprehensionNestingLimit(int(val)), nil
}
if val, isInt := limit.(int); isInt {
return ValidateComprehensionNestingLimit(val), nil
}
@@ -52,6 +62,23 @@ var (
}
return nil, fmt.Errorf("invalid validator: %s missing limit", nestingLimitValidatorName)
},
bindNestingLimitValidatorName: func(val *env.Validator) (ASTValidator, error) {
if limit, found := val.ConfigValue("limit"); found {
// In case of protos, config value is of type by google.protobuf.Value, which numeric values are always a double.
if val, isDouble := limit.(float64); isDouble {
if val != float64(int64(val)) {
return nil, fmt.Errorf("invalid validator: %s, limit value is not a whole number: %v", bindNestingLimitValidatorName, limit)
}
return ValidateBindNestingLimit(int(val)), nil
}
if val, isInt := limit.(int); isInt {
return ValidateBindNestingLimit(val), nil
}
return nil, fmt.Errorf("invalid validator: %s unsupported limit type: %v", bindNestingLimitValidatorName, limit)
}
return nil, fmt.Errorf("invalid validator: %s missing limit", bindNestingLimitValidatorName)
},
durationValidatorName: func(*env.Validator) (ASTValidator, error) {
return ValidateDurationLiterals(), nil
},
@@ -72,12 +99,20 @@ type ASTValidatorFactory func(*env.Validator) (ASTValidator, error)
// ASTValidators configures a set of ASTValidator instances into the target environment.
//
// Validators are applied in the order in which the are specified and are treated as singletons.
// The same ASTValidator with a given name will not be applied more than once.
// Validators are applied in the order in which they are specified.
// If an ASTValidator with the same name is already configured, it will be replaced.
func ASTValidators(validators ...ASTValidator) EnvOption {
return func(e *Env) (*Env, error) {
for _, v := range validators {
if !e.HasValidator(v.Name()) {
found := false
for i, existing := range e.validators {
if existing.Name() == v.Name() {
e.validators[i] = v
found = true
break
}
}
if !found {
e.validators = append(e.validators, v)
}
}
@@ -224,6 +259,13 @@ func ValidateComprehensionNestingLimit(limit int) ASTValidator {
return nestingLimitValidator{limit: limit}
}
// ValidateBindNestingLimit ensures that cel.bind() macro nesting does not exceed the specified limit.
//
// This validator can be useful for preventing arbitrarily nested cel.bind() macro calls.
func ValidateBindNestingLimit(limit int) ASTValidator {
return bindNestingLimitValidator{limit: limit}
}
type argChecker func(env *Env, call, arg ast.Expr) error
func newFormatValidator(funcName string, argNum int, check argChecker) formatValidator {
@@ -276,8 +318,9 @@ func evalCall(env *Env, call, arg ast.Expr) error {
if err != nil {
return err
}
_, _, err = prg.Eval(NoVars())
return err
resCh := prg.ConcurrentEval(context.Background(), NoVars())
res := <-resCh
return res.Err
}
func compileRegex(_ *Env, _, arg ast.Expr) error {
@@ -422,8 +465,7 @@ func (v nestingLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AST, i
}
// When the comprehension has an empty range, continue to the next ancestor
// as this comprehension does not have any associated cost.
iterRange := e.AsComprehension().IterRange()
if iterRange.Kind() == ast.ListKind && iterRange.AsList().Size() == 0 {
if isEmptyRangeComprehension(e) {
e, hasParent = e.Parent()
continue
}
@@ -437,3 +479,68 @@ func (v nestingLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AST, i
}
}
}
type bindNestingLimitValidator struct {
limit int
}
// Name returns the name of the cel.bind nesting limit validator.
func (v bindNestingLimitValidator) Name() string {
return bindNestingLimitValidatorName
}
// ToConfig converts the ASTValidator to an env.Validator specifying the validator name and the nesting limit
// as an integer value: {"limit": int}
func (v bindNestingLimitValidator) ToConfig() *env.Validator {
return env.NewValidator(v.Name()).SetConfig(map[string]any{"limit": v.limit})
}
// Validate implements the ASTValidator interface method.
func (v bindNestingLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AST, iss *Issues) {
root := ast.NavigateAST(a)
comprehensions := ast.MatchDescendants(root, ast.KindMatcher(ast.ComprehensionKind))
var celBinds []ast.NavigableExpr
for _, comp := range comprehensions {
if isCelBind(comp) {
celBinds = append(celBinds, comp)
}
}
if len(celBinds) <= v.limit {
return
}
for _, comp := range celBinds {
count := 0
e := comp
hasParent := true
for hasParent {
if isCelBind(e) {
count++
if count > v.limit {
iss.ReportErrorAtID(comp.ID(), "cel.bind exceeds nesting limit")
break
}
}
e, hasParent = e.Parent()
}
}
}
func isEmptyRangeComprehension(e ast.NavigableExpr) bool {
if e.Kind() != ast.ComprehensionKind {
return false
}
iterRange := e.AsComprehension().IterRange()
return iterRange.Kind() == ast.ListKind && iterRange.AsList().Size() == 0
}
func isCelBind(e ast.NavigableExpr) bool {
if !isEmptyRangeComprehension(e) {
return false
}
compre := e.AsComprehension()
loopCond := compre.LoopCondition()
loopStep := compre.LoopStep()
return compre.IterVar() == unusedIterVar &&
loopCond.Kind() == ast.LiteralKind && loopCond.AsLiteral().Value() == false &&
loopStep.Kind() == ast.IdentKind && loopStep.AsIdent() == compre.AccuVar()
}
+56 -11
View File
@@ -19,6 +19,8 @@ package checker
import (
"fmt"
"reflect"
"slices"
"strings"
"github.com/google/cel-go/common"
"github.com/google/cel-go/common/ast"
@@ -65,6 +67,15 @@ func Check(parsed *ast.AST, source common.Source, env *Env) (*ast.AST, *common.E
for id, t := range c.TypeMap() {
c.SetType(id, substitute(c.mappings, t, true))
}
// Remove source info for IDs without a corresponding AST node. This can happen because
// check() deletes some nodes while rewriting the AST. For example the Select operand is
// deleted when a variable reference is replaced with a Ident expression.
c.AST.ClearUnusedIDs()
if env.jsonFieldNames {
c.AST.SourceInfo().AddExtension(
ast.NewExtension("json_name", ast.NewExtensionVersion(1, 1), ast.ComponentRuntime),
)
}
return c.AST, errs
}
@@ -104,11 +115,15 @@ func (c *checker) check(e ast.Expr) {
func (c *checker) checkIdent(e ast.Expr) {
identName := e.AsIdent()
// Check to see if the identifier is declared.
if ident := c.env.LookupIdent(identName); ident != nil {
if ident := c.env.resolveSimpleIdent(identName); ident != nil {
name := strings.TrimPrefix(ident.Name(), ".")
if ident.requiresDisambiguation {
name = "." + name
}
c.setType(e, ident.Type())
c.setReference(e, ast.NewIdentReference(ident.Name(), ident.Value()))
c.setReference(e, ast.NewIdentReference(name, ident.Value()))
// Overwrite the identifier with its fully qualified name.
e.SetKindCase(c.NewIdent(e.ID(), ident.Name()))
e.SetKindCase(c.NewIdent(e.ID(), name))
return
}
@@ -119,18 +134,22 @@ func (c *checker) checkIdent(e ast.Expr) {
func (c *checker) checkSelect(e ast.Expr) {
sel := e.AsSelect()
// Before traversing down the tree, try to interpret as qualified name.
qname, found := containers.ToQualifiedName(e)
qualifiers, found := c.computeQualifiers(e)
if found {
ident := c.env.LookupIdent(qname)
ident := c.env.resolveQualifiedIdent(qualifiers...)
if ident != nil {
// We don't check for a TestOnly expression here since the `found` result is
// always going to be false for TestOnly expressions.
// Rewrite the node to be a variable reference to the resolved fully-qualified
// variable name.
name := ident.Name()
if ident.requiresDisambiguation {
name = "." + name
}
c.setType(e, ident.Type())
c.setReference(e, ast.NewIdentReference(ident.Name(), ident.Value()))
e.SetKindCase(c.NewIdent(e.ID(), ident.Name()))
c.setReference(e, ast.NewIdentReference(name, ident.Value()))
e.SetKindCase(c.NewIdent(e.ID(), name))
return
}
}
@@ -142,6 +161,29 @@ func (c *checker) checkSelect(e ast.Expr) {
c.setType(e, substitute(c.mappings, resultType, false))
}
// computeQualifiers computes the qualified names parts of a select expression.
func (c *checker) computeQualifiers(e ast.Expr) ([]string, bool) {
var qualifiers []string
for e.Kind() == ast.SelectKind {
sel := e.AsSelect()
// test only expressions are not considered for qualified name selection.
if sel.IsTestOnly() {
return qualifiers, false
}
// otherwise append the select field name to the qualifier list (reverse order)
qualifiers = append(qualifiers, sel.FieldName())
e = sel.Operand()
// If the next operand is an identifier, then append it, reverse the name sequence
// and return it to the caller.s
if e.Kind() == ast.IdentKind {
qualifiers = append(qualifiers, e.AsIdent())
slices.Reverse(qualifiers)
return qualifiers, true
}
}
return qualifiers, false
}
func (c *checker) checkOptSelect(e ast.Expr) {
// Collect metadata related to the opt select call packaged by the parser.
call := e.AsCall()
@@ -234,7 +276,7 @@ func (c *checker) checkCall(e ast.Expr) {
// Regular static call with simple name.
if !call.IsMemberFunction() {
// Check for the existence of the function.
fn := c.env.LookupFunction(fnName)
fn := c.env.lookupFunction(fnName)
if fn == nil {
c.errors.undeclaredReference(e.ID(), c.location(e), c.env.container.Name(), fnName)
c.setType(e, types.ErrorType)
@@ -256,7 +298,7 @@ func (c *checker) checkCall(e ast.Expr) {
qualifiedPrefix, maybeQualified := containers.ToQualifiedName(target)
if maybeQualified {
maybeQualifiedName := qualifiedPrefix + "." + fnName
fn := c.env.LookupFunction(maybeQualifiedName)
fn := c.env.lookupFunction(maybeQualifiedName)
if fn != nil {
// The function name is namespaced and so preserving the target operand would
// be an inaccurate representation of the desired evaluation behavior.
@@ -269,7 +311,7 @@ func (c *checker) checkCall(e ast.Expr) {
// Regular instance call.
c.check(target)
fn := c.env.LookupFunction(fnName)
fn := c.env.lookupFunction(fnName)
// Function found, attempt overload resolution.
if fn != nil {
c.resolveOverloadOrError(e, fn, target, args)
@@ -441,7 +483,7 @@ func (c *checker) checkCreateStruct(e ast.Expr) {
msgVal := e.AsStruct()
// Determine the type of the message.
resultType := types.ErrorType
ident := c.env.LookupIdent(msgVal.TypeName())
ident := c.env.resolveTypeIdent(msgVal.TypeName())
if ident == nil {
c.errors.undeclaredReference(
e.ID(), c.location(e), c.env.container.Name(), msgVal.TypeName())
@@ -681,6 +723,9 @@ func (c *checker) lookupFieldType(exprID int64, structType, fieldName string) (*
}
if ft, found := c.env.provider.FindStructFieldType(structType, fieldName); found {
if c.env.jsonFieldNames && !ft.IsJSONField {
c.errors.undefinedField(exprID, c.locationByID(exprID), fieldName)
}
return ft.Type, found
}
+17 -4
View File
@@ -159,6 +159,11 @@ func (se SizeEstimate) Union(size SizeEstimate) SizeEstimate {
return result
}
// AsCost converts a size estimates to an equivalent cost estimate.
func (se SizeEstimate) AsCost() CostEstimate {
return se.MultiplyByCostFactor(1)
}
// CostEstimate represents an estimated cost range and provides add and multiply operations
// that do not overflow.
type CostEstimate struct {
@@ -786,18 +791,26 @@ func (c *coster) functionCost(e ast.Expr, function, overloadID string, target *A
return CallEstimate{CostEstimate: c.sizeOrUnknown(args[1]).MultiplyByCostFactor(1).Add(argCostSum())}
}
// O(nm) functions
case overloads.MatchesString:
case overloads.Matches, overloads.MatchesString:
// https://swtch.com/~rsc/regexp/regexp1.html applies to RE2 implementation supported by CEL
if target != nil && len(args) == 1 {
var strNode, regexNode AstNode
if overloadID == overloads.MatchesString && target != nil && len(args) == 1 {
strNode = *target
regexNode = args[0]
} else if overloadID == overloads.Matches && target == nil && len(args) == 2 {
strNode = args[0]
regexNode = args[1]
}
if strNode != nil && regexNode != nil {
// Add one to string length for purposes of cost calculation to prevent product of string and regex to be 0
// in case where string is empty but regex is still expensive.
strCost := c.sizeOrUnknown(*target).Add(SizeEstimate{Min: 1, Max: 1}).MultiplyByCostFactor(common.StringTraversalCostFactor)
strCost := c.sizeOrUnknown(strNode).Add(SizeEstimate{Min: 1, Max: 1}).MultiplyByCostFactor(common.StringTraversalCostFactor)
// We don't know how many expressions are in the regex, just the string length (a huge
// improvement here would be to somehow get a count the number of expressions in the regex or
// how many states are in the regex state machine and use that to measure regex cost).
// For now, we're making a guess that each expression in a regex is typically at least 4 chars
// in length.
regexCost := c.sizeOrUnknown(args[0]).MultiplyByCostFactor(common.RegexStringLengthCostFactor)
regexCost := c.sizeOrUnknown(regexNode).MultiplyByCostFactor(common.RegexStringLengthCostFactor)
return CallEstimate{CostEstimate: strCost.Multiply(regexCost).Add(argCostSum())}
}
case overloads.ContainsString:
+123 -32
View File
@@ -74,6 +74,7 @@ type Env struct {
declarations *Scopes
aggLitElemType aggregateLiteralElementType
filteredOverloadIDs map[string]struct{}
jsonFieldNames bool
}
// NewEnv returns a new *Env with the given parameters.
@@ -104,6 +105,7 @@ func NewEnv(container *containers.Container, provider types.Provider, opts ...Op
declarations: declarations,
aggLitElemType: aggLitElemType,
filteredOverloadIDs: filteredOverloadIDs,
jsonFieldNames: envOptions.jsonFieldNames,
}, nil
}
@@ -129,45 +131,111 @@ func (e *Env) AddFunctions(declarations ...*decls.FunctionDecl) error {
return formatError(errMsgs)
}
// LookupIdent returns a Decl proto for typeName as an identifier in the Env.
// Returns nil if no such identifier is found in the Env.
func (e *Env) LookupIdent(name string) *decls.VariableDecl {
// newAttrResolution creates a new attribute resolution value.
func newAttrResolution(ident *decls.VariableDecl, requiresDisambiguation bool) *attributeResolution {
return &attributeResolution{
VariableDecl: ident,
requiresDisambiguation: requiresDisambiguation,
}
}
// attributeResolution wraps an existing variable and denotes whether disambiguation is needed
// during variable resolution.
type attributeResolution struct {
*decls.VariableDecl
// requiresDisambiguation indicates the variable name should be dot-prefixed.
requiresDisambiguation bool
}
// resolveSimpleIdent determines the resolved attribute for a single identifier.
func (e *Env) resolveSimpleIdent(name string) *attributeResolution {
local := e.lookupLocalIdent(name)
if local != nil && !strings.HasPrefix(name, ".") {
return newAttrResolution(local, false)
}
for _, candidate := range e.container.ResolveCandidateNames(name) {
if ident := e.declarations.FindIdent(candidate); ident != nil {
return ident
}
// Next try to import the name as a reference to a message type. If found,
// the declaration is added to the outest (global) scope of the
// environment, so next time we can access it faster.
if t, found := e.provider.FindStructType(candidate); found {
decl := decls.NewVariable(candidate, t)
e.declarations.AddIdent(decl)
return decl
}
if i, found := e.provider.FindIdent(candidate); found {
if t, ok := i.(*types.Type); ok {
decl := decls.NewVariable(candidate, types.NewTypeTypeWithParam(t))
e.declarations.AddIdent(decl)
return decl
}
}
// Next try to import this as an enum value by splitting the name in a type prefix and
// the enum inside.
if enumValue := e.provider.EnumValue(candidate); enumValue.Type() != types.ErrType {
decl := decls.NewConstant(candidate, types.IntType, enumValue)
e.declarations.AddIdent(decl)
return decl
if ident := e.lookupGlobalIdent(candidate); ident != nil {
return newAttrResolution(ident, local != nil)
}
}
return nil
}
// LookupFunction returns a Decl proto for typeName as a function in env.
// resolveQualifiedIdent determines the resolved attribute for a qualified identifier.
func (e *Env) resolveQualifiedIdent(qualifiers ...string) *attributeResolution {
if len(qualifiers) == 1 {
return e.resolveSimpleIdent(qualifiers[0])
}
local := e.lookupLocalIdent(qualifiers[0])
if local != nil && !strings.HasPrefix(qualifiers[0], ".") {
// this should resolve through a field selection rather than a qualified identifier
return nil
}
// The qualifiers are concatenated together to indicate the qualified name to search
// for as a global identifier. Since select expressions are resolved from leaf to root
// if the fully concatenated string doesn't match a global identifier, indicate that
// no variable was found to continue the traversal up to the next simpler name.
varName := strings.Join(qualifiers, ".")
for _, candidate := range e.container.ResolveCandidateNames(varName) {
if ident := e.lookupGlobalIdent(candidate); ident != nil {
return newAttrResolution(ident, local != nil)
}
}
return nil
}
// resolveTypeIdent returns a Decl proto for typeName as an identifier in the Env.
// Returns nil if no such identifier is found in the Env.
func (e *Env) resolveTypeIdent(name string) *decls.VariableDecl {
for _, candidate := range e.container.ResolveCandidateNames(name) {
// Try to import the name as a reference to a message type.
if i, found := e.provider.FindIdent(candidate); found {
if t, ok := i.(*types.Type); ok {
return decls.NewVariable(candidate, types.NewTypeTypeWithParam(t))
}
}
// Next, try to find the struct type.
if t, found := e.provider.FindStructType(candidate); found {
return decls.NewVariable(candidate, t)
}
}
return nil
}
// lookupLocalIdent finds the variable candidate in a local scope, returning nil if
// the candidate variable name is not a local variable.
func (e *Env) lookupLocalIdent(candidate string) *decls.VariableDecl {
return e.declarations.FindLocalIdent(candidate)
}
// lookupGlobalIdent finds a candidate variable name in the root scope, returning
// nil if the identifier is not in the global scope.
func (e *Env) lookupGlobalIdent(candidate string) *decls.VariableDecl {
// Try to resolve the global identifier first.
if ident := e.declarations.FindGlobalIdent(candidate); ident != nil {
return ident
}
// Next try to import the name as a reference to a message type.
if i, found := e.provider.FindIdent(candidate); found {
if t, ok := i.(*types.Type); ok {
return decls.NewVariable(candidate, types.NewTypeTypeWithParam(t))
}
}
if t, found := e.provider.FindStructType(candidate); found {
return decls.NewVariable(candidate, t)
}
// Next try to import this as an enum value by splitting the name in a type prefix and
// the enum inside.
if enumValue := e.provider.EnumValue(candidate); enumValue.Type() != types.ErrType {
return decls.NewConstant(candidate, types.IntType, enumValue)
}
return nil
}
// lookupFunction returns a Decl proto for typeName as a function in env.
// Returns nil if no such function is found in env.
func (e *Env) LookupFunction(name string) *decls.FunctionDecl {
func (e *Env) lookupFunction(name string) *decls.FunctionDecl {
for _, candidate := range e.container.ResolveCandidateNames(name) {
if fn := e.declarations.FindFunction(candidate); fn != nil {
return fn
@@ -207,12 +275,31 @@ func (e *Env) setFunction(fn *decls.FunctionDecl) []errorMsg {
return errMsgs
}
func maybeMergeConstant(a *decls.VariableDecl, b *decls.VariableDecl) (*decls.VariableDecl, errorMsg) {
if b.Value() != nil {
if a.Value() == nil {
return b, ""
}
eq, ok := a.Value().Equal(b.Value()).Value().(bool)
if ok && eq {
return a, ""
}
return nil, constantConflictError(b.Name())
}
return a, ""
}
// addIdent adds the Decl to the declarations in the Env.
// Returns a non-empty errorMsg if the identifier is already declared in the scope.
func (e *Env) addIdent(decl *decls.VariableDecl) errorMsg {
current := e.declarations.FindIdentInScope(decl.Name())
if current != nil {
if current.DeclarationIsEquivalent(decl) {
decl, errMsg := maybeMergeConstant(current, decl)
if errMsg != "" {
return errMsg
}
e.declarations.AddIdent(decl)
return ""
}
return overlappingIdentifierError(decl.Name())
@@ -259,6 +346,10 @@ func (e *Env) exitScope() *Env {
// may be accumulated into an error at a later point in execution.
type errorMsg string
func constantConflictError(name string) errorMsg {
return errorMsg(fmt.Sprintf("conflicting constant definitions for name '%s'", name))
}
func overlappingIdentifierError(name string) errorMsg {
return errorMsg(fmt.Sprintf("overlapping identifier for name '%s'", name))
}
+9
View File
@@ -18,6 +18,7 @@ type options struct {
crossTypeNumericComparisons bool
homogeneousAggregateLiterals bool
validatedDeclarations *Scopes
jsonFieldNames bool
}
// Option is a functional option for configuring the type-checker
@@ -40,3 +41,11 @@ func ValidatedDeclarations(env *Env) Option {
return nil
}
}
// JSONFieldNames enables the use of json names instead of the standard protobuf snake_case field names
func JSONFieldNames(enabled bool) Option {
return func(opts *options) error {
opts.jsonFieldNames = enabled
return nil
}
}
+25
View File
@@ -15,6 +15,8 @@
package checker
import (
"strings"
"github.com/google/cel-go/common/decls"
)
@@ -76,6 +78,7 @@ func (s *Scopes) AddIdent(decl *decls.VariableDecl) {
// found.
// Note: The search is performed from innermost to outermost.
func (s *Scopes) FindIdent(name string) *decls.VariableDecl {
name = strings.TrimPrefix(name, ".")
if ident, found := s.scopes.idents[name]; found {
return ident
}
@@ -89,12 +92,33 @@ func (s *Scopes) FindIdent(name string) *decls.VariableDecl {
// nil if one does not exist.
// Note: The search is only performed on the current scope and does not search outer scopes.
func (s *Scopes) FindIdentInScope(name string) *decls.VariableDecl {
name = strings.TrimPrefix(name, ".")
if ident, found := s.scopes.idents[name]; found {
return ident
}
return nil
}
// FindLocalIdent finds a locally scoped variable with a given name, ignoring the root scope.
func (s *Scopes) FindLocalIdent(name string) *decls.VariableDecl {
if s == nil || s.parent == nil {
return nil
}
if ident := s.FindIdentInScope(name); ident != nil {
return ident
}
return s.parent.FindLocalIdent(name)
}
// FindGlobalIdent finds an identifier in the global scope, ignoring all local scopes.
func (s *Scopes) FindGlobalIdent(name string) *decls.VariableDecl {
scope := s
for scope.parent != nil {
scope = scope.parent
}
return scope.FindIdentInScope(name)
}
// SetFunction adds the function Decl to the current scope.
// Note: Any previous entry for a function in the current scope with the same name is overwritten.
func (s *Scopes) SetFunction(fn *decls.FunctionDecl) {
@@ -105,6 +129,7 @@ func (s *Scopes) SetFunction(fn *decls.FunctionDecl) {
// The search is performed from innermost to outermost.
// Returns nil if no such function in Scopes.
func (s *Scopes) FindFunction(name string) *decls.FunctionDecl {
name = strings.TrimPrefix(name, ".")
if fn, found := s.scopes.functions[name]; found {
return fn
}
+147
View File
@@ -16,6 +16,8 @@
package ast
import (
"slices"
"github.com/google/cel-go/common"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
@@ -160,6 +162,34 @@ func MaxID(a *AST) int64 {
return visitor.maxID + 1
}
// IDs returns the set of AST node IDs, including macro calls.
func (a *AST) IDs() map[int64]bool {
visitor := make(idVisitor)
PostOrderVisit(a.Expr(), visitor)
for _, call := range a.SourceInfo().MacroCalls() {
PostOrderVisit(call, visitor)
}
return visitor
}
// NodeCount returns the total number of expression nodes in the AST, including macro calls.
func NodeCount(a *AST) int {
if a == nil {
return 0
}
return len(a.IDs())
}
// ClearUnusedIDs removes IDs not used in the AST or macro calls from SourceInfo.
func (a *AST) ClearUnusedIDs() {
ids := a.IDs()
for id := range a.SourceInfo().OffsetRanges() {
if !ids[id] {
a.SourceInfo().ClearOffsetRange(id)
}
}
}
// Heights computes the heights of all AST expressions and returns a map from expression id to height.
func Heights(a *AST) map[int64]int {
visitor := make(heightVisitor)
@@ -209,6 +239,11 @@ func CopySourceInfo(info *SourceInfo) *SourceInfo {
for id, call := range info.macroCalls {
callsCopy[id] = defaultFactory.CopyExpr(call)
}
var extCopy []Extension
if len(info.extensions) > 0 {
extCopy = make([]Extension, len(info.extensions))
copy(extCopy, info.extensions)
}
return &SourceInfo{
syntax: info.syntax,
desc: info.desc,
@@ -217,6 +252,7 @@ func CopySourceInfo(info *SourceInfo) *SourceInfo {
baseCol: info.baseCol,
offsetRanges: rangesCopy,
macroCalls: callsCopy,
extensions: extCopy,
}
}
@@ -230,6 +266,26 @@ type SourceInfo struct {
baseCol int32
offsetRanges map[int64]OffsetRange
macroCalls map[int64]Expr
// extensions indicate versioned optional features which affect the execution of one or more CEL component.
extensions []Extension
}
// RenumberIDs performs an in-place update of the expression IDs within the SourceInfo.
func (s *SourceInfo) RenumberIDs(idGen IDGenerator) {
if s == nil {
return
}
oldIDs := []int64{}
for id := range s.offsetRanges {
oldIDs = append(oldIDs, id)
}
slices.Sort(oldIDs)
newRanges := make(map[int64]OffsetRange)
for _, id := range oldIDs {
newRanges[idGen(id)] = s.offsetRanges[id]
}
s.offsetRanges = newRanges
}
// SyntaxVersion returns the syntax version associated with the text expression.
@@ -365,6 +421,12 @@ func (s *SourceInfo) ComputeOffset(line, col int32) int32 {
line = s.baseLine + line
col = s.baseCol + col
}
return s.ComputeOffsetAbsolute(line, col)
}
// ComputeOffsetAbsolute calculates the 0-based character offset from a 1-based line and 0-based column
// based on the absolute line and column of the SourceInfo.
func (s *SourceInfo) ComputeOffsetAbsolute(line, col int32) int32 {
if line == 1 {
return col
}
@@ -375,6 +437,34 @@ func (s *SourceInfo) ComputeOffset(line, col int32) int32 {
return offset + col
}
// Extensions returns the set of extensions present in the source.
func (s *SourceInfo) Extensions() []Extension {
var extensions []Extension
if s == nil {
return extensions
}
return s.extensions
}
// HasExtension returns whether the source info contains the extension which satisfies the minimum version requirement.
//
// For an extension to be considered 'present' it must have the same major version as the minVersion and a minor version
// at least as great as the lowest minor version specified.
func (s *SourceInfo) HasExtension(id string, minVersion ExtensionVersion) bool {
for _, ext := range s.Extensions() {
return ext.ID == id && ext.Version.Major == minVersion.Major && ext.Version.Minor >= minVersion.Minor
}
return false
}
// AddExtension adds an extension record into the SourceInfo.
func (s *SourceInfo) AddExtension(ext Extension) {
if s == nil {
return
}
s.extensions = append(s.extensions, ext)
}
// OffsetRange captures the start and stop positions of a section of text in the input expression.
type OffsetRange struct {
Start int32
@@ -444,6 +534,53 @@ func (r *ReferenceInfo) Equals(other *ReferenceInfo) bool {
return true
}
// NewExtension creates an Extension to be recorded on the SourceInfo.
func NewExtension(id string, version ExtensionVersion, components ...ExtensionComponent) Extension {
return Extension{
ID: id,
Version: version,
Components: components,
}
}
// Extension represents a versioned, optional feature present in the AST that affects CEL component behavior.
type Extension struct {
// ID indicates the unique name of the extension.
ID string
// Version indicates the major / minor version.
Version ExtensionVersion
// Components enumerates the CEL components affected by the feature.
Components []ExtensionComponent
}
// NewExtensionVersion creates a new extension version with a major, minor version.
func NewExtensionVersion(major, minor int64) ExtensionVersion {
return ExtensionVersion{Major: major, Minor: minor}
}
// ExtensionVersion represents a semantic version with a major and minor number.
type ExtensionVersion struct {
// Major version of the extension.
// All versions with the same major number are expected to be compatible with all minor version changes.
Major int64
// Minor version of the extension which indicates that some small non-semantic change has been made to
// the extension.
Minor int64
}
// ExtensionComponent indicates which CEL component is affected.
type ExtensionComponent int
const (
// ComponentParser means the feature affects expression parsing.
ComponentParser ExtensionComponent = iota + 1
// ComponentTypeChecker means the feature affects type-checking.
ComponentTypeChecker
// ComponentRuntime alters program planning or evaluation of the AST.
ComponentRuntime
)
type maxIDVisitor struct {
maxID int64
*baseVisitor
@@ -533,3 +670,13 @@ func (hv heightVisitor) maxEntryHeight(entries ...EntryExpr) int {
}
return max
}
type idVisitor map[int64]bool
func (v idVisitor) VisitExpr(e Expr) {
v[e.ID()] = true
}
func (v idVisitor) VisitEntryExpr(e EntryExpr) {
v[e.ID()] = true
}
+49
View File
@@ -27,6 +27,19 @@ import (
structpb "google.golang.org/protobuf/types/known/structpb"
)
var (
pbComponentMap = map[exprpb.SourceInfo_Extension_Component]ExtensionComponent{
exprpb.SourceInfo_Extension_COMPONENT_PARSER: ComponentParser,
exprpb.SourceInfo_Extension_COMPONENT_TYPE_CHECKER: ComponentTypeChecker,
exprpb.SourceInfo_Extension_COMPONENT_RUNTIME: ComponentRuntime,
}
componentPBMap = map[ExtensionComponent]exprpb.SourceInfo_Extension_Component{
ComponentParser: exprpb.SourceInfo_Extension_COMPONENT_PARSER,
ComponentTypeChecker: exprpb.SourceInfo_Extension_COMPONENT_TYPE_CHECKER,
ComponentRuntime: exprpb.SourceInfo_Extension_COMPONENT_RUNTIME,
}
)
// ToProto converts an AST to a CheckedExpr protobouf.
func ToProto(ast *AST) (*exprpb.CheckedExpr, error) {
refMap := make(map[int64]*exprpb.Reference, len(ast.ReferenceMap()))
@@ -534,6 +547,25 @@ func SourceInfoToProto(info *SourceInfo) (*exprpb.SourceInfo, error) {
}
sourceInfo.MacroCalls[id] = call
}
for _, ext := range info.Extensions() {
var components []exprpb.SourceInfo_Extension_Component
for _, c := range ext.Components {
comp, found := componentPBMap[c]
if found {
components = append(components, comp)
}
}
ver := &exprpb.SourceInfo_Extension_Version{
Major: ext.Version.Major,
Minor: ext.Version.Minor,
}
pbExt := &exprpb.SourceInfo_Extension{
Id: ext.ID,
Version: ver,
AffectedComponents: components,
}
sourceInfo.Extensions = append(sourceInfo.Extensions, pbExt)
}
return sourceInfo, nil
}
@@ -556,6 +588,23 @@ func ProtoToSourceInfo(info *exprpb.SourceInfo) (*SourceInfo, error) {
}
sourceInfo.SetMacroCall(id, call)
}
for _, pbExt := range info.GetExtensions() {
var components []ExtensionComponent
for _, c := range pbExt.GetAffectedComponents() {
comp, found := pbComponentMap[*c.Enum()]
if found {
components = append(components, comp)
}
}
sourceInfo.AddExtension(NewExtension(
pbExt.GetId(),
NewExtensionVersion(
pbExt.GetVersion().GetMajor(),
pbExt.GetVersion().GetMinor(),
),
components...,
))
}
return sourceInfo, nil
}
+23
View File
@@ -181,6 +181,29 @@ func PreOrderVisit(expr Expr, visitor Visitor) {
visit(expr, visitor, preOrder, 0, 0)
}
// ExceedsDepth determines whether the AST contains expressions nested deeper than the specified
// maxDepth. The root expression has depth 0, so a maxDepth of 250 permits expressions nested up
// to and including 250 levels deep.
//
// The traversal is bounded: it descends at most maxDepth+1 levels, so it remains safe to call on
// adversarially deep inputs that could otherwise exhaust the Go stack during later checking or
// planning. A non-positive maxDepth disables the check and returns false.
func ExceedsDepth(a *AST, maxDepth int) bool {
if a == nil || maxDepth <= 0 {
return false
}
exceedsDepth := false
visitor := NewExprVisitor(func(e Expr) {
if nav, ok := e.(NavigableExpr); ok && nav.Depth() >= maxDepth {
exceedsDepth = true
}
})
// Bound the walk to maxDepth+1 levels so it never recurses past the first level that exceeds
// the limit, keeping the check itself safe on the deep inputs it guards against.
visit(NavigateAST(a), visitor, postOrder, 0, maxDepth+1)
return exceedsDepth
}
type visitOrder int
const (
+8 -7
View File
@@ -227,7 +227,7 @@ func Abbrevs(qualifiedNames ...string) ContainerOption {
}
alias := qn[ind+1:]
var err error
c, err = aliasAs("abbreviation", qn, alias)(c)
c, err = aliasAs("abbreviation", qn, alias, true)(c)
if err != nil {
return nil, err
}
@@ -236,31 +236,32 @@ func Abbrevs(qualifiedNames ...string) ContainerOption {
}
}
// Alias associates a fully-qualified name with a user-defined alias.
// Alias associates a name with a user-defined alias.
//
// In general, Abbrevs is preferred to Alias since the names generated from the Abbrevs option
// are more easily traced back to source code. The Alias option is useful for propagating alias
// configuration from one Container instance to another, and may also be useful for remapping
// poorly chosen protobuf message / package names.
//
// Note: all of the rules that apply to Abbrevs also apply to Alias.
func Alias(qualifiedName, alias string) ContainerOption {
return aliasAs("alias", qualifiedName, alias)
return aliasAs("alias", qualifiedName, alias, false)
}
func aliasAs(kind, qualifiedName, alias string) ContainerOption {
func aliasAs(kind, qualifiedName, alias string, requireQualified bool) ContainerOption {
return func(c *Container) (*Container, error) {
if len(alias) == 0 || strings.Contains(alias, ".") {
return nil, fmt.Errorf(
"%s must be non-empty and simple (not qualified): %s=%s", kind, kind, alias)
}
if len(qualifiedName) == 0 {
return nil, fmt.Errorf("%s must refer to a valid name: %s", kind, qualifiedName)
}
if qualifiedName[0:1] == "." {
return nil, fmt.Errorf("qualified name must not begin with a leading '.': %s",
qualifiedName)
}
ind := strings.LastIndex(qualifiedName, ".")
if ind <= 0 || ind == len(qualifiedName)-1 {
if ind == len(qualifiedName)-1 || (requireQualified && ind <= 0) {
return nil, fmt.Errorf("%s must refer to a valid qualified name: %s",
kind, qualifiedName)
}
+15
View File
@@ -312,3 +312,18 @@ func (w *debugWriter) removeIndent() {
func (w *debugWriter) String() string {
return w.buffer.String()
}
type idAdorner struct{}
func (a *idAdorner) GetMetadata(elem any) string {
e, isExpr := elem.(ast.Expr)
if !isExpr {
return ""
}
return fmt.Sprintf("@id:%d ", e.ID())
}
// ToDebugStringWithIDs returns a string representation with AST node IDs.
func ToDebugStringWithIDs(e ast.Expr) string {
return ToAdornedDebugString(e, &idAdorner{})
}
+105 -11
View File
@@ -16,6 +16,7 @@
package decls
import (
"context"
"fmt"
"strings"
@@ -270,7 +271,7 @@ func (f *FunctionDecl) AddOverload(overload *OverloadDecl) error {
if oID == overload.ID() {
if o.SignatureEquals(overload) && o.IsNonStrict() == overload.IsNonStrict() {
// Allow redefinition of an overload implementation so long as the signatures match.
if overload.hasBinding() {
if overload.HasBinding() {
f.overloads[oID] = overload
}
// Allow redefinition of the doc string.
@@ -303,13 +304,25 @@ func (f *FunctionDecl) OverloadDecls() []*OverloadDecl {
return overloads
}
// HasSingletonBinding indicates whether the function has a singleton binding definition.
func (f *FunctionDecl) HasSingletonBinding() bool {
if f == nil {
return false
}
return f.singleton != nil
}
// HasLateBinding returns true if the function has late bindings. A function cannot mix late bindings with other bindings.
func (f *FunctionDecl) HasLateBinding() bool {
if f == nil {
return false
}
if f.singleton != nil && f.singleton.Async != nil {
return true
}
for _, oID := range f.overloadOrdinals {
if f.overloads[oID].HasLateBinding() {
o := f.overloads[oID]
if o.HasLateBinding() {
return true
}
}
@@ -328,12 +341,13 @@ func (f *FunctionDecl) Bindings() ([]*functions.Overload, error) {
for _, oID := range f.overloadOrdinals {
o := f.overloads[oID]
hasLateBinding = hasLateBinding || o.HasLateBinding()
if o.hasBinding() {
if o.HasBinding() {
overload := &functions.Overload{
Operator: o.ID(),
Unary: o.guardedUnaryOp(f.Name(), f.disableTypeGuards),
Binary: o.guardedBinaryOp(f.Name(), f.disableTypeGuards),
Function: o.guardedFunctionOp(f.Name(), f.disableTypeGuards),
Async: o.guardedAsyncOp(f.Name(), f.disableTypeGuards),
OperandTrait: o.OperandTrait(),
NonStrict: o.IsNonStrict(),
}
@@ -354,6 +368,7 @@ func (f *FunctionDecl) Bindings() ([]*functions.Overload, error) {
Unary: f.singleton.Unary,
Binary: f.singleton.Binary,
Function: f.singleton.Function,
Async: f.singleton.Async,
OperandTrait: f.singleton.OperandTrait,
},
}
@@ -372,6 +387,7 @@ func (f *FunctionDecl) Bindings() ([]*functions.Overload, error) {
Unary: overloads[0].Unary,
Binary: overloads[0].Binary,
Function: overloads[0].Function,
Async: overloads[0].Async,
NonStrict: overloads[0].NonStrict,
OperandTrait: overloads[0].OperandTrait,
}), nil
@@ -530,6 +546,30 @@ func SingletonFunctionBinding(fn functions.FunctionOp, traits ...int) FunctionOp
}
}
// SingletonAsyncBinding creates a singleton async function definition to be used with all function overloads.
// The provided function is called in its own goroutine with the provided context. The function should
// block until the result is available, and the framework manages goroutine and channel lifecycle.
//
// Note, this approach works well if operand is expected to have a specific trait which it implements,
// e.g. traits.ContainerType. Otherwise, prefer per-overload async bindings.
func SingletonAsyncBinding(fn functions.BlockingAsyncOp, traits ...int) FunctionOpt {
trait := 0
for _, t := range traits {
trait = trait | t
}
return func(f *FunctionDecl) (*FunctionDecl, error) {
if f.singleton != nil {
return nil, fmt.Errorf("function already has a singleton binding: %s", f.Name())
}
f.singleton = &functions.Overload{
Operator: f.Name(),
Async: wrapAsyncOp(fn),
OperandTrait: trait,
}
return f, nil
}
}
// Overload defines a new global overload with an overload id, argument types, and result type. Through the
// use of OverloadOpt options, the overload may also be configured with a binding, an operand trait, and to
// be non-strict.
@@ -614,6 +654,8 @@ type OverloadDecl struct {
binaryOp functions.BinaryOp
// functionOp is a catch-all for zero-arity and three-plus arity functions.
functionOp functions.FunctionOp
// asyncOp is an asynchronous function binding that returns a channel.
asyncOp functions.AsyncOp
}
// Examples returns a list of string examples for the overload.
@@ -669,7 +711,7 @@ func (o *OverloadDecl) HasLateBinding() bool {
if o == nil {
return false
}
return o.hasLateBinding
return o.hasLateBinding || o.asyncOp != nil
}
// OperandTrait returns the trait mask of the first operand to the overload call, e.g.
@@ -740,9 +782,9 @@ func (o *OverloadDecl) SignatureOverlaps(other *OverloadDecl) bool {
return argsOverlap
}
// hasBinding indicates whether the overload already has a definition.
func (o *OverloadDecl) hasBinding() bool {
return o != nil && (o.unaryOp != nil || o.binaryOp != nil || o.functionOp != nil)
// HasBinding indicates whether the overload already has a definition.
func (o *OverloadDecl) HasBinding() bool {
return o != nil && (o.unaryOp != nil || o.binaryOp != nil || o.functionOp != nil || o.asyncOp != nil)
}
// guardedUnaryOp creates an invocation guard around the provided unary operator, if one is defined.
@@ -784,6 +826,22 @@ func (o *OverloadDecl) guardedFunctionOp(funcName string, disableTypeGuards bool
}
}
// guardedAsyncOp creates an invocation guard around the provided async function binding, if one is provided.
func (o *OverloadDecl) guardedAsyncOp(funcName string, disableTypeGuards bool) functions.AsyncOp {
if o.asyncOp == nil {
return nil
}
return func(ctx context.Context, args ...ref.Val) <-chan ref.Val {
if !o.matchesRuntimeSignature(disableTypeGuards, args...) {
ch := make(chan ref.Val, 1)
ch <- MaybeNoSuchOverload(funcName, args...)
close(ch)
return ch
}
return o.asyncOp(ctx, args...)
}
}
// matchesRuntimeUnarySignature indicates whether the argument type is runtime assiganble to the overload's expected argument.
func (o *OverloadDecl) matchesRuntimeUnarySignature(disableTypeGuards bool, arg ref.Val) bool {
return matchRuntimeArgType(o.IsNonStrict(), disableTypeGuards, o.ArgTypes()[0], arg) &&
@@ -817,6 +875,8 @@ func matchRuntimeArgType(nonStrict, disableTypeGuards bool, argType *types.Type,
if nonStrict && (disableTypeGuards || types.IsUnknownOrError(arg)) {
return true
}
// Note, early returns and unknown aggregation happen in the interpretable.go file; however, this check is here
// for defense in depth or for scenarios where someone manipulates bindings to offer their own dispatch logic.
if types.IsUnknownOrError(arg) {
return false
}
@@ -842,7 +902,7 @@ func OverloadExamples(examples ...string) OverloadOpt {
// type-guard which ensures runtime type agreement between the overload signature and runtime argument types.
func UnaryBinding(binding functions.UnaryOp) OverloadOpt {
return func(o *OverloadDecl) (*OverloadDecl, error) {
if o.hasBinding() {
if o.HasBinding() {
return nil, fmt.Errorf("overload already has a binding: %s", o.ID())
}
if len(o.ArgTypes()) != 1 {
@@ -860,7 +920,7 @@ func UnaryBinding(binding functions.UnaryOp) OverloadOpt {
// type-guard which ensures runtime type agreement between the overload signature and runtime argument types.
func BinaryBinding(binding functions.BinaryOp) OverloadOpt {
return func(o *OverloadDecl) (*OverloadDecl, error) {
if o.hasBinding() {
if o.HasBinding() {
return nil, fmt.Errorf("overload already has a binding: %s", o.ID())
}
if len(o.ArgTypes()) != 2 {
@@ -878,7 +938,7 @@ func BinaryBinding(binding functions.BinaryOp) OverloadOpt {
// type-guard which ensures runtime type agreement between the overload signature and runtime argument types.
func FunctionBinding(binding functions.FunctionOp) OverloadOpt {
return func(o *OverloadDecl) (*OverloadDecl, error) {
if o.hasBinding() {
if o.HasBinding() {
return nil, fmt.Errorf("overload already has a binding: %s", o.ID())
}
if o.hasLateBinding {
@@ -889,11 +949,45 @@ func FunctionBinding(binding functions.FunctionOp) OverloadOpt {
}
}
// AsyncBinding provides the implementation of an asynchronous overload. The provided function
// is called in its own goroutine with the provided context. The function should block until
// the result is available, and the framework manages goroutine and channel lifecycle.
//
// This follows the same pattern used by gRPC-Go and other major Go frameworks where user
// code is synchronous and the framework manages concurrency.
func AsyncBinding(fn functions.BlockingAsyncOp) OverloadOpt {
return func(o *OverloadDecl) (*OverloadDecl, error) {
if o.HasBinding() {
return nil, fmt.Errorf("overload already has a binding: %s", o.ID())
}
if o.hasLateBinding {
return nil, fmt.Errorf("overload already has a late binding: %s", o.ID())
}
o.asyncOp = wrapAsyncOp(fn)
return o, nil
}
}
// wrapAsyncOp adapts a blocking function into the channel-based AsyncOp used internally.
//
// The blocking function is invoked synchronously and its result delivered on a buffered channel.
// The interpreter always invokes an AsyncOp from a dedicated goroutine, so running the blocking
// call inline here keeps the framework to a single goroutine per async call rather than spawning
// an additional one to bridge blocking-to-channel.
func wrapAsyncOp(fn functions.BlockingAsyncOp) functions.AsyncOp {
return func(ctx context.Context, args ...ref.Val) <-chan ref.Val {
ch := make(chan ref.Val, 1)
ch <- fn(ctx, args...)
close(ch)
return ch
}
}
// LateFunctionBinding indicates that the function has a binding which is not known at compile time.
// This is useful for functions which have side-effects or are not deterministically computable.
func LateFunctionBinding() OverloadOpt {
return func(o *OverloadDecl) (*OverloadDecl, error) {
if o.hasBinding() {
if o.HasBinding() {
return nil, fmt.Errorf("overload already has a binding: %s", o.ID())
}
o.hasLateBinding = true
+13
View File
@@ -37,6 +37,8 @@ const (
DocMacro
// DocExample represents example documentation.
DocExample
// DocField represents documentation for a struct field.
DocField
)
// Doc holds the documentation details for a specific program element like
@@ -163,6 +165,17 @@ func NewExampleDoc(ex string) *Doc {
}
}
// NewFieldDoc creates a new Doc struct for documenting a struct field.
func NewFieldDoc(name, celType, description string, examples ...*Doc) *Doc {
return &Doc{
Kind: DocField,
Name: name,
Type: celType,
Description: description,
Children: examples,
}
}
// Documentor is an interface for types that can provide their own documentation.
type Documentor interface {
// Documentation returns the documentation coded by the DocKind to assist
+5 -1
View File
@@ -23,12 +23,14 @@ go_library(
name = "go_default_library",
srcs = [
"env.go",
"io.go",
],
importpath = "github.com/google/cel-go/common/env",
deps = [
"//common:go_default_library",
"//common/decls:go_default_library",
"//common/types:go_default_library",
"@in_yaml_go_yaml_v3//:go_default_library",
],
)
@@ -37,6 +39,7 @@ go_test(
size = "small",
srcs = [
"env_test.go",
"io_test.go",
],
data = glob(["testdata/**"]),
embed = [":go_default_library"],
@@ -45,6 +48,7 @@ go_test(
"//common/operators:go_default_library",
"//common/overloads:go_default_library",
"//common/types:go_default_library",
"@in_gopkg_yaml_v3//:go_default_library",
"@com_github_google_go_cmp//cmp:go_default_library",
"@in_yaml_go_yaml_v3//:go_default_library",
],
)
+117 -2
View File
@@ -50,6 +50,7 @@ type Config struct {
Functions []*Function `yaml:"functions,omitempty"`
Validators []*Validator `yaml:"validators,omitempty"`
Features []*Feature `yaml:"features,omitempty"`
Limits []*Limit `yaml:"limits,omitempty"`
}
// Validate validates the whole configuration is well-formed.
@@ -92,6 +93,11 @@ func (c *Config) Validate() error {
errs = append(errs, err)
}
}
for _, limit := range c.Limits {
if err := limit.Validate(); err != nil {
errs = append(errs, err)
}
}
for _, val := range c.Validators {
if err := val.Validate(); err != nil {
errs = append(errs, err)
@@ -122,7 +128,7 @@ func (c *Config) AddVariableDecls(vars ...*decls.VariableDecl) *Config {
return c.AddVariables(convVars...)
}
// AddVariables adds one or more vairables to the config.
// AddVariables adds one or more variables to the config.
func (c *Config) AddVariables(vars ...*Variable) *Config {
c.Variables = append(c.Variables, vars...)
return c
@@ -206,6 +212,12 @@ func (c *Config) AddFeatures(feats ...*Feature) *Config {
return c
}
// AddLimits appends one or more limits to the config.
func (c *Config) AddLimits(limits ...*Limit) *Config {
c.Limits = append(c.Limits, limits...)
return c
}
// NewImport returns a serializable import value from the qualified type name.
func NewImport(name string) *Import {
return &Import{Name: name}
@@ -246,7 +258,9 @@ type Variable struct {
// Type represents the type declaration for the variable.
//
// Deprecated: use the embedded *TypeDesc fields directly.
// When serialized, 'type' is used for shorthand specifier string.
//
// Use GetType() for getting the effective type.
Type *TypeDesc `yaml:"type,omitempty"`
// TypeDesc is an embedded set of fields allowing for the specification of the Variable type.
@@ -264,6 +278,9 @@ func (v *Variable) Validate() error {
if err := v.GetType().Validate(); err != nil {
return fmt.Errorf("invalid variable %q: %w", v.Name, err)
}
if v.GetType().IsTypeParam {
return fmt.Errorf("invalid variable %q: variables cannot be type parameters", v.Name)
}
return nil
}
@@ -734,6 +751,29 @@ func (feat *Feature) Validate() error {
return nil
}
// Limit represents a named limit in the CEL environment. This is used to control
// the complexity tolerated before failing parsing, type checking, or planning.
type Limit struct {
Name string `yaml:"name"`
Value int `yaml:"value"`
}
// NewLimit creates a new limit.
func NewLimit(name string, value int) *Limit {
return &Limit{name, value}
}
// Validate validates a limit.
func (l *Limit) Validate() error {
if l == nil {
return errors.New("invalid limit: nil")
}
if l.Name == "" {
return errors.New("invalid limit: missing name")
}
return nil
}
// NewTypeDesc describes a simple or complex type with parameters.
func NewTypeDesc(typeName string, params ...*TypeDesc) *TypeDesc {
return &TypeDesc{TypeName: typeName, Params: params}
@@ -796,11 +836,47 @@ func (td *TypeDesc) Validate() error {
return fmt.Errorf("invalid type: optional_type expects 1 parameter, got %d", len(td.Params))
}
return td.Params[0].Validate()
case "type":
if len(td.Params) == 0 {
return nil
}
if len(td.Params) != 1 {
return fmt.Errorf("invalid type: type expects 0 or 1 parameters, got %d", len(td.Params))
}
return td.Params[0].Validate()
default:
}
return nil
}
func formatSpecifierImpl(td *TypeDesc, sb *strings.Builder) {
if td.IsTypeParam {
sb.WriteRune('~')
sb.WriteString(td.TypeName)
return
}
sb.WriteString(td.TypeName)
l := len(td.Params)
if l < 1 {
return
}
sb.WriteRune('<')
for i, p := range td.Params {
formatSpecifierImpl(p, sb)
if i < l-1 {
sb.WriteString(", ")
}
}
sb.WriteRune('>')
}
// SpecifierFormat returns the short text representation of the type. e.g. "map<string, int>"
func (td *TypeDesc) SpecifierFormat() string {
var sb strings.Builder
formatSpecifierImpl(td, &sb)
return sb.String()
}
// AsCELType converts the serializable object to a *types.Type value.
func (td *TypeDesc) AsCELType(tp types.Provider) (*types.Type, error) {
err := td.Validate()
@@ -810,6 +886,27 @@ func (td *TypeDesc) AsCELType(tp types.Provider) (*types.Type, error) {
switch td.TypeName {
case "dyn":
return types.DynType, nil
// short aliases for WKTs
case "duration":
return types.DurationType, nil
case "timestamp":
return types.TimestampType, nil
case "any":
return types.AnyType, nil
case "null", "null_type":
return types.NullType, nil
case "bool_wrapper":
return types.NewNullableType(types.BoolType), nil
case "bytes_wrapper":
return types.NewNullableType(types.BytesType), nil
case "double_wrapper":
return types.NewNullableType(types.DoubleType), nil
case "int_wrapper":
return types.NewNullableType(types.IntType), nil
case "uint_wrapper":
return types.NewNullableType(types.UintType), nil
case "string_wrapper":
return types.NewNullableType(types.StringType), nil
case "map":
kt, err := td.Params[0].AsCELType(tp)
if err != nil {
@@ -832,6 +929,15 @@ func (td *TypeDesc) AsCELType(tp types.Provider) (*types.Type, error) {
return nil, err
}
return types.NewOptionalType(et), nil
case "type":
if len(td.Params) == 0 {
return types.TypeType, nil
}
pt, err := td.Params[0].AsCELType(tp)
if err != nil {
return nil, err
}
return types.NewTypeTypeWithParam(pt), nil
default:
if td.IsTypeParam {
return types.NewTypeParamType(td.TypeName), nil
@@ -874,6 +980,15 @@ func SerializeTypeDesc(t *types.Type) *TypeDesc {
for _, p := range t.Parameters() {
params = append(params, SerializeTypeDesc(p))
}
// Special types, these aren't useful for describing environments.
switch t.Kind() {
case types.ErrorKind:
typeName = "*error*"
case types.UnknownKind:
typeName = "*unknown*"
case types.UnspecifiedKind:
typeName = "*unspecified type*"
}
return NewTypeDesc(typeName, params...)
}
+271
View File
@@ -0,0 +1,271 @@
// Copyright 2026 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 env
import (
"errors"
"fmt"
"go.yaml.in/yaml/v3"
)
type internalTypeDesc struct {
TypeName string `yaml:"type_name"`
Params []*TypeDesc `yaml:"params,omitempty"`
IsTypeParam bool `yaml:"is_type_param,omitempty"`
}
// Embedding TypeDesc in variable causes issues with customizing
// unmarshalling / marshalling. Work around with a parallel type.
type internalVariable struct {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
// Type represents the type declaration for the variable.
Type *TypeDesc `yaml:"type,omitempty"`
TypeName string `yaml:"type_name"`
Params []*TypeDesc `yaml:"params,omitempty"`
IsTypeParam bool `yaml:"is_type_param,omitempty"`
}
// UnmarshalYAML implements yaml.Unmarshal
func (v *Variable) UnmarshalYAML(n *yaml.Node) error {
buf := internalVariable{}
err := n.Decode(&buf)
if err != nil {
return err
}
v.Name = buf.Name
v.Description = buf.Description
if buf.TypeName != "" {
v.TypeDesc = &TypeDesc{
TypeName: buf.TypeName,
Params: buf.Params,
IsTypeParam: buf.IsTypeParam,
}
} else if buf.Type != nil {
v.TypeDesc = buf.Type
}
return nil
}
// MarshalYAML implements yaml.Marshaler
func (v *Variable) MarshalYAML() (any, error) {
// The presence of an unmarshaller alters the default marshaller behavior so
// provide a simple marshal implementation.
buf := internalVariable{
Name: v.Name,
Description: v.Description,
}
if t := v.GetType(); t != nil {
buf.TypeName = t.TypeName
buf.Params = t.Params
buf.IsTypeParam = t.IsTypeParam
}
return &buf, nil
}
// UnmarshalYAML implements yaml.Unmarshaler
func (td *TypeDesc) UnmarshalYAML(n *yaml.Node) error {
if td == nil {
return fmt.Errorf("unexpected Unmarshal for TypeDesc at: %d", n.Line)
}
if n.Kind == yaml.ScalarNode {
o, err := ParseTypeDesc(n.Value)
if err != nil {
return err
}
*td = *o
return nil
}
if n.Kind != yaml.MappingNode {
return errors.New("unsupported yaml for TypeDesc")
}
buf := internalTypeDesc{}
err := n.Decode(&buf)
if err != nil {
return err
}
td.TypeName = buf.TypeName
td.Params = buf.Params
td.IsTypeParam = buf.IsTypeParam
return nil
}
type typeDescParser struct {
text string
pos int
length int
}
// ParseTypeDesc parses a TypeDesc from the type specifier format: "map<string, int>"
func ParseTypeDesc(text string) (*TypeDesc, error) {
p := &typeDescParser{text: text, length: len(text)}
res, err := p.parseTypeElem()
if err != nil {
return nil, fmt.Errorf("failed to parse type %q: %v", text, err)
}
p.skipWhitespace()
if p.pos < p.length {
return nil, fmt.Errorf("unexpected character %q at position %d in %q", p.text[p.pos], p.pos, text)
}
return res, nil
}
func (p *typeDescParser) parseConcreteType() (*TypeDesc, error) {
id, err := p.parseNamespaceIdentifier()
if err != nil {
return nil, err
}
if p.pos < p.length && p.text[p.pos] == '<' {
p.pos++ // consume '<'
var params []*TypeDesc
for {
p.skipWhitespace()
param, err := p.parseTypeElem()
if err != nil {
return nil, err
}
params = append(params, param)
p.skipWhitespace()
if p.pos < p.length && p.text[p.pos] == ',' {
p.pos++ // consume ','
continue
}
if p.pos < p.length && p.text[p.pos] == '>' {
p.pos++ // consume '>'
break
}
return nil, fmt.Errorf("expected ',' or '>' at position %d", p.pos)
}
return NewTypeDesc(id, params...), nil
}
return NewTypeDesc(id), nil
}
func (p *typeDescParser) parseTypeElem() (*TypeDesc, error) {
p.skipWhitespace()
if p.pos < p.length && p.text[p.pos] == '~' {
p.pos++ // consume '~'
id, err := p.parseTypeParamIdent()
if err != nil {
return nil, err
}
return NewTypeParam(id), nil
}
return p.parseConcreteType()
}
func (p *typeDescParser) parseNamespaceIdentifier() (string, error) {
p.skipWhitespace()
var id string
for p.pos < p.length && p.text[p.pos] != '<' {
c := p.text[p.pos]
if c == '.' {
id += "."
p.pos++ // consume '.'
}
ident, err := p.parseIdentifier()
if err != nil {
return "", err
}
id += ident
p.skipWhitespace()
if p.pos < p.length && p.text[p.pos] != '.' {
break
}
}
if id == "" {
return "", fmt.Errorf("missing identifier at position %d", p.pos)
}
return id, nil
}
func (p *typeDescParser) parseIdentifier() (string, error) {
p.skipWhitespace()
if p.pos >= p.length {
return "", fmt.Errorf("unexpected end of input")
}
start := p.pos
c := p.text[p.pos]
if !isAlpha(c) && c != '_' {
return "", fmt.Errorf("identifier is expected, but %q was found at position %d", c, p.pos)
}
p.pos++
for p.pos < p.length {
c := p.text[p.pos]
if !isAlphaNumeric(c) && c != '_' {
break
}
p.pos++
}
return p.text[start:p.pos], nil
}
func (p *typeDescParser) parseTypeParamIdent() (string, error) {
p.skipWhitespace()
if p.pos >= p.length {
return "", fmt.Errorf("unexpected end of input")
}
c := p.text[p.pos]
if !isAlpha(c) {
return "", fmt.Errorf("invalid type parameter identifier %q at position %d, must be a single character from A-Z", c, p.pos)
}
p.pos++
if p.pos < p.length && isAlpha(p.text[p.pos]) {
return "", fmt.Errorf("invalid type param, must have a single alphabetic character at position %d", p.pos)
}
return string(c), nil
}
func (p *typeDescParser) skipWhitespace() {
for p.pos < p.length && p.text[p.pos] == ' ' {
p.pos++
}
}
func isAlpha(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
func isAlphaNumeric(c byte) bool {
return isAlpha(c) || (c >= '0' && c <= '9')
}
// ConfigFromYAML returns a config from YAML source.
//
// Adds custom parsing logic for normalizing shorthand for specifiying some fields
// in a YAML document (mainly the type-specifier shorthand).
//
// Using yaml.Unmarshal with any implementation should be sufficient for most
// cases.
func ConfigFromYAML(data []byte) (*Config, error) {
c := &Config{}
e := yaml.Unmarshal(data, c)
if e != nil {
return nil, e
}
return c, nil
}
// ConfigToYAML returns the config serialized to YAML
//
// Provided as a convenience wrapper around a tested YAML Marshaler.
func ConfigToYAML(c *Config) ([]byte, error) {
return yaml.Marshal(c)
}
+26 -6
View File
@@ -15,7 +15,11 @@
// Package functions defines the standard builtin functions supported by the interpreter
package functions
import "github.com/google/cel-go/common/types/ref"
import (
"context"
"github.com/google/cel-go/common/types/ref"
)
// Overload defines a named overload of a function, indicating an operand trait
// which must be present on the first argument to the overload as well as one
@@ -41,21 +45,37 @@ type Overload struct {
// Binary defines the overload with a BinaryOp implementation. May be nil.
Binary BinaryOp
// Function defines the overload with a FunctionOp implementation. May be
// nil.
// Function defines the overload with a FunctionOp implementation. May be nil.
Function FunctionOp
// Async defines the overload with an AsyncOp implementation. May be nil.
Async AsyncOp
// NonStrict specifies whether the Overload will tolerate arguments that
// are types.Err or types.Unknown.
NonStrict bool
}
// UnaryOp is a function that takes a single value and produces an output.
type UnaryOp func(value ref.Val) ref.Val
type UnaryOp func(ref.Val) ref.Val
// BinaryOp is a function that takes two values and produces an output.
type BinaryOp func(lhs ref.Val, rhs ref.Val) ref.Val
type BinaryOp func(ref.Val, ref.Val) ref.Val
// FunctionOp is a function with accepts zero or more arguments and produces
// a value or error as a result.
type FunctionOp func(values ...ref.Val) ref.Val
type FunctionOp func(...ref.Val) ref.Val
// AsyncOp is a function that accepts zero or more arguments and produces
// a value or error asynchronously via a channel.
//
// AsyncOp is an internal interface intended for use by CEL to manage goroutines and
// channels associated with async calls. For public API usage, use BlockingAsyncOp.
// Implementers should listen for context cancellation on the provided context for
// resource cleanup.
type AsyncOp func(context.Context, ...ref.Val) <-chan ref.Val
// BlockingAsyncOp is a function that accepts zero or more arguments and blocks until
// the result is available. When used with AsyncBinding, the framework runs the function
// in its own goroutine and manages channel lifecycle internally.
type BlockingAsyncOp func(context.Context, ...ref.Val) ref.Val
-1
View File
@@ -291,7 +291,6 @@ const (
const (
DurationToDuration = "duration_to_duration"
StringToDuration = "string_to_duration"
IntToDuration = "int64_to_duration"
)
// Convert to dyn
+52 -30
View File
@@ -16,6 +16,7 @@
package runes
import (
"fmt"
"strings"
"unicode/utf8"
)
@@ -113,45 +114,64 @@ var _ Buffer = &supplementalBuffer{}
var nilBuffer = &emptyBuffer{}
// SizeLimitError indicates that the input exceeded the configured code point limit.
type SizeLimitError struct {
Size int
Limit int
}
func (e *SizeLimitError) Error() string {
return fmt.Sprintf("expression code point size exceeds limit: size: %d, limit %d", e.Size, e.Limit)
}
// NewBuffer returns an efficient implementation of Buffer for the given text based on the ranges of
// the encoded code points contained within.
//
// Code points are represented as an array of byte, uint16, or rune. This approach ensures that
// each index represents a code point by itself without needing to use an array of rune. At first
// we assume all code points are less than or equal to '\u007f'. If this holds true, the
// underlying storage is a byte array containing only ASCII characters. If we encountered a code
// point above this range but less than or equal to '\uffff' we allocate a uint16 array, copy the
// elements of previous byte array to the uint16 array, and continue. If this holds true, the
// underlying storage is a uint16 array containing only Unicode characters in the Basic Multilingual
// Plane. If we encounter a code point above '\uffff' we allocate an rune array, copy the previous
// elements of the byte or uint16 array, and continue. The underlying storage is an rune array
// containing any Unicode character.
func NewBuffer(data string) Buffer {
buf, _ := newBuffer(data, false)
buf, _, _ := newBufferWithLimit(data, false, -1)
return buf
}
// NewBufferAndLineOffsets returns an efficient implementation of Buffer for the given text based on
// the ranges of the encoded code points contained within, as well as returning the line offsets.
//
// Code points are represented as an array of byte, uint16, or rune. This approach ensures that
// each index represents a code point by itself without needing to use an array of rune. At first
// we assume all code points are less than or equal to '\u007f'. If this holds true, the
// underlying storage is a byte array containing only ASCII characters. If we encountered a code
// point above this range but less than or equal to '\uffff' we allocate a uint16 array, copy the
// elements of previous byte array to the uint16 array, and continue. If this holds true, the
// underlying storage is a uint16 array containing only Unicode characters in the Basic Multilingual
// Plane. If we encounter a code point above '\uffff' we allocate an rune array, copy the previous
// elements of the byte or uint16 array, and continue. The underlying storage is an rune array
// containing any Unicode character.
func NewBufferAndLineOffsets(data string) (Buffer, []int32) {
return newBuffer(data, true)
buf, offs, _ := newBufferWithLimit(data, true, -1)
return buf, offs
}
func newBuffer(data string, lines bool) (Buffer, []int32) {
if len(data) == 0 {
return nilBuffer, []int32{0}
// NewBufferAndLineOffsetsWithLimit returns an efficient implementation of Buffer for the given text
// and enforces a code point limit while constructing the buffer.
func NewBufferAndLineOffsetsWithLimit(data string, limit int) (Buffer, []int32, error) {
if limit < 0 || len(data) <= limit {
return newBufferWithLimit(data, true, -1)
}
return newBufferWithLimit(data, true, limit)
}
func countRemainingCodePoints(data string, idx int, count int) int {
for idx < len(data) {
_, s := utf8.DecodeRuneInString(data[idx:])
idx += s
count++
}
return count
}
func newBufferWithLimit(data string, lines bool, limit int) (Buffer, []int32, error) {
if len(data) == 0 {
return nilBuffer, []int32{0}, nil
}
if limit >= 0 && len(data) > limit {
size := countRemainingCodePoints(data, 0, 0)
if size > limit {
return nil, nil, &SizeLimitError{
Size: size,
Limit: limit,
}
}
}
// The resulting buffers store one element per code point, so the worst case
// element count never exceeds len(data).
var (
idx = 0
off int32 = 0
@@ -195,7 +215,8 @@ func newBuffer(data string, lines bool) (Buffer, []int32) {
}
return &asciiBuffer{
arr: buf8,
}, offs
}, offs, nil
copy16:
for idx < len(data) {
r, s := utf8.DecodeRuneInString(data[idx:])
@@ -222,7 +243,8 @@ copy16:
}
return &basicBuffer{
arr: buf16,
}, offs
}, offs, nil
copy32:
for idx < len(data) {
r, s := utf8.DecodeRuneInString(data[idx:])
@@ -238,5 +260,5 @@ copy32:
}
return &supplementalBuffer{
arr: buf32,
}, offs
}, offs, nil
}
+23
View File
@@ -74,6 +74,12 @@ func NewTextSource(text string) Source {
return NewStringSource(text, "<input>")
}
// NewTextSourceWithLimit creates a new Source from the input text string while
// enforcing a maximum code point count when needed.
func NewTextSourceWithLimit(text string, limit int) (Source, error) {
return NewStringSourceWithLimit(text, "<input>", limit)
}
// NewStringSource creates a new Source from the given contents and description.
func NewStringSource(contents string, description string) Source {
// Compute line offsets up front as they are referred to frequently.
@@ -85,6 +91,23 @@ func NewStringSource(contents string, description string) Source {
}
}
// NewStringSourceWithLimit creates a new Source from the given contents and
// description while enforcing a maximum code point count when needed.
func NewStringSourceWithLimit(contents string, description string, limit int) (Source, error) {
if limit < 0 || len(contents) <= limit {
return NewStringSource(contents, description), nil
}
buf, offs, err := runes.NewBufferAndLineOffsetsWithLimit(contents, limit)
if err != nil {
return nil, err
}
return &sourceImpl{
Buffer: buf,
description: description,
lineOffsets: offs,
}, nil
}
// NewInfoSource creates a new Source from a SourceInfo.
func NewInfoSource(info *exprpb.SourceInfo) Source {
return &sourceImpl{
+18 -2
View File
@@ -16,6 +16,7 @@
package stdlib
import (
"math"
"strconv"
"strings"
"time"
@@ -310,6 +311,9 @@ func init() {
argTypes(types.DurationType, types.DurationType), types.BoolType,
decls.OverloadExamples(`duration('1ms') < duration('1s') // true`)),
decls.SingletonBinaryBinding(func(lhs, rhs ref.Val) ref.Val {
if isNaN(lhs) || isNaN(rhs) {
return types.False
}
cmp := lhs.(traits.Comparer).Compare(rhs)
if cmp == types.IntNegOne {
return types.True
@@ -367,6 +371,9 @@ func init() {
argTypes(types.DurationType, types.DurationType), types.BoolType,
decls.OverloadExamples(`duration('1ms') <= duration('1s') // true`)),
decls.SingletonBinaryBinding(func(lhs, rhs ref.Val) ref.Val {
if isNaN(lhs) || isNaN(rhs) {
return types.False
}
cmp := lhs.(traits.Comparer).Compare(rhs)
if cmp == types.IntNegOne || cmp == types.IntZero {
return types.True
@@ -424,6 +431,9 @@ func init() {
argTypes(types.DurationType, types.DurationType), types.BoolType,
decls.OverloadExamples(`duration('1ms') > duration('1us') // true`)),
decls.SingletonBinaryBinding(func(lhs, rhs ref.Val) ref.Val {
if isNaN(lhs) || isNaN(rhs) {
return types.False
}
cmp := lhs.(traits.Comparer).Compare(rhs)
if cmp == types.IntOne {
return types.True
@@ -481,6 +491,9 @@ func init() {
argTypes(types.DurationType, types.DurationType), types.BoolType,
decls.OverloadExamples(`duration('60s') >= duration('1m') // true`)),
decls.SingletonBinaryBinding(func(lhs, rhs ref.Val) ref.Val {
if isNaN(lhs) || isNaN(rhs) {
return types.False
}
cmp := lhs.(traits.Comparer).Compare(rhs)
if cmp == types.IntOne || cmp == types.IntZero {
return types.True
@@ -605,8 +618,6 @@ func init() {
decls.Overload(overloads.DurationToDuration, argTypes(types.DurationType), types.DurationType,
decls.OverloadExamples(`duration(duration('1s')) // duration('1s')`),
decls.UnaryBinding(identity)),
decls.Overload(overloads.IntToDuration, argTypes(types.IntType), types.DurationType,
decls.UnaryBinding(convertToType(types.DurationType))),
decls.Overload(overloads.StringToDuration, argTypes(types.StringType), types.DurationType,
decls.OverloadExamples(`duration('1h2m3s') // duration('3723s')`),
decls.UnaryBinding(convertToType(types.DurationType)))),
@@ -928,6 +939,11 @@ func noBinaryOverrides(rhs, lhs ref.Val) ref.Val {
return types.NoSuchOverloadErr()
}
func isNaN(val ref.Val) bool {
d, ok := val.(types.Double)
return ok && math.IsNaN(float64(d))
}
func noFunctionOverrides(args ...ref.Val) ref.Val {
return types.NoSuchOverloadErr()
}
-1
View File
@@ -40,7 +40,6 @@ go_library(
"//common/types/pb:go_default_library",
"//common/types/ref:go_default_library",
"//common/types/traits:go_default_library",
"@com_github_stoewer_go_strcase//:go_default_library",
"@dev_cel_expr//:expr",
"@org_golang_google_genproto_googleapis_api//expr/v1alpha1:go_default_library",
"@org_golang_google_protobuf//encoding/protojson:go_default_library",
+1 -1
View File
@@ -69,7 +69,7 @@ func (b Bool) ConvertToNative(typeDesc reflect.Type) (any, error) {
case boolWrapperType:
// Convert the bool to a wrapperspb.BoolValue.
return wrapperspb.Bool(bool(b)), nil
case jsonValueType:
case JSONValueType:
// Return the bool as a new structpb.Value.
return structpb.NewBoolValue(bool(b)), nil
default:
+5 -2
View File
@@ -44,7 +44,10 @@ func (b Bytes) Add(other ref.Val) ref.Val {
if !ok {
return ValOrErr(other, "no such overload")
}
return append(b, otherBytes...)
sum := make([]byte, 0, len(b)+len(otherBytes))
sum = append(sum, b...)
sum = append(sum, otherBytes...)
return Bytes(sum)
}
// Compare implements traits.Comparer interface method by lexicographic ordering.
@@ -79,7 +82,7 @@ func (b Bytes) ConvertToNative(typeDesc reflect.Type) (any, error) {
case byteWrapperType:
// Convert the bytes to a wrapperspb.BytesValue.
return wrapperspb.Bytes([]byte(b)), nil
case jsonValueType:
case JSONValueType:
// CEL follows the proto3 to JSON conversion by encoding bytes to a string via base64.
// The encoding below matches the golang 'encoding/json' behavior during marshaling,
// which uses base64.StdEncoding.
+1 -1
View File
@@ -89,7 +89,7 @@ func (d Double) ConvertToNative(typeDesc reflect.Type) (any, error) {
case floatWrapperType:
// Convert to a wrapperspb.FloatValue (with truncation).
return wrapperspb.Float(float32(d)), nil
case jsonValueType:
case JSONValueType:
// Note, there are special cases for proto3 to json conversion that
// expect the floating point value to be converted to a NaN,
// Infinity, or -Infinity string values, but the jsonpb string
+1 -1
View File
@@ -106,7 +106,7 @@ func (d Duration) ConvertToNative(typeDesc reflect.Type) (any, error) {
case durationValueType:
// Unwrap the CEL value to its underlying proto value.
return dpb.New(d.Duration), nil
case jsonValueType:
case JSONValueType:
// CEL follows the proto3 to JSON conversion.
// Note, using jsonpb would wrap the result in extra double quotes.
v := d.ConvertToType(StringType)
+3
View File
@@ -113,6 +113,9 @@ func ValOrErr(val ref.Val, format string, args ...any) ref.Val {
// WrapErr wraps an existing Go error value into a CEL Err value.
func WrapErr(err error) ref.Val {
if err, ok := err.(*Err); ok {
return err
}
return &Err{error: err}
}
+1 -1
View File
@@ -120,7 +120,7 @@ func (i Int) ConvertToNative(typeDesc reflect.Type) (any, error) {
case int64WrapperType:
// Convert the value to a wrapperspb.Int64Value.
return wrapperspb.Int64(int64(i)), nil
case jsonValueType:
case JSONValueType:
// The proto-to-JSON conversion rules would convert all 64-bit integer values to JSON
// decimal strings. Because CEL ints might come from the automatic widening of 32-bit
// values in protos, the JSON type is chosen dynamically based on the value.

Some files were not shown because too many files have changed in this diff Show More