diff --git a/.envrc b/.envrc
new file mode 100644
index 000000000..ec0f0d787
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,13 @@
+# shellcheck shell=bash
+
+# Load any local environment variables
+dotenv_if_exists .env.local
+source_env_if_exists .envrc.local
+
+# put 'USE_FLOX=n' in .env.local to disable.
+# if you wish to enable other flox environments relative to this directory, add
+# appropriate `use flox --dir=
` entries in .envrc.local
+if [ "${USE_FLOX:-y}" = "y" ]; then
+ use flox
+fi
+
diff --git a/apps/workspace-engine/go.mod b/apps/workspace-engine/go.mod
index 9f25b8e43..34b418a42 100644
--- a/apps/workspace-engine/go.mod
+++ b/apps/workspace-engine/go.mod
@@ -24,7 +24,6 @@ require (
github.com/open-policy-agent/opa v1.15.2
github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible
github.com/prometheus/common v0.66.1
- github.com/spandigital/cel2sql/v3 v3.8.2
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
@@ -274,7 +273,9 @@ require (
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
+ github.com/stretchr/objx v0.5.3 // indirect
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
+ github.com/testcontainers/testcontainers-go v0.42.0 // indirect
github.com/testcontainers/testcontainers-go/modules/compose v0.41.0 // indirect
github.com/upper/db/v4 v4.10.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
@@ -292,6 +293,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yashtewari/glob-intersection v0.2.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.58.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
diff --git a/apps/workspace-engine/go.sum b/apps/workspace-engine/go.sum
index 1de767ecd..15664711a 100644
--- a/apps/workspace-engine/go.sum
+++ b/apps/workspace-engine/go.sum
@@ -2,18 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
-cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
-cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
-cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
-cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
-cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
-cloud.google.com/go/bigquery v1.76.0 h1:wnfVSXN6GEMlsAoHWdhzTC8NMsptOx2hsqPiI+lTs3I=
-cloud.google.com/go/bigquery v1.76.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
-cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
-cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8=
cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
@@ -68,8 +58,6 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
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/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE=
-github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
@@ -453,18 +441,12 @@ github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
-github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 h1:UBg1xk+oAsIVbFuGg6hdfAm7EvCv3EL80vFxJNsslqw=
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
-github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
-github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
-github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
@@ -794,9 +776,6 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
-github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
-github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
-github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -882,8 +861,6 @@ github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnB
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
-github.com/spandigital/cel2sql/v3 v3.8.2 h1:Pk/5G7Flqh1N8QHAPRwVRzJmA1OWDCV5q9UZRYzLT4A=
-github.com/spandigital/cel2sql/v3 v3.8.2/go.mod h1:e4dGSiXUQgVo5UIRw3L35tKofnJJ0Fqwj7V/VsXDDc0=
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
@@ -901,8 +878,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
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=
-github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -928,12 +905,6 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/compose v0.41.0 h1:6ttsQ6IilJYMoTFI2gu9l7KmKlnlY9XGkP0wtgh4rF4=
github.com/testcontainers/testcontainers-go/modules/compose v0.41.0/go.mod h1:6PfaNLXsylvZE5CID8QMZ4fWjLHORvqm1xcGBncdzAY=
-github.com/testcontainers/testcontainers-go/modules/gcloud v0.42.0 h1:EdLf2NCpo43CxTfC0x2R0sW3+HqzevC78pgnH9niyYc=
-github.com/testcontainers/testcontainers-go/modules/gcloud v0.42.0/go.mod h1:5CMn4WViUGbOGORdjWvvGEkptvM9I/vwecYTsyKoPkg=
-github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0 h1:Yhv1k7vDpyzZePntg5R5Oj4ZMCyWpAfpJeRu1ROsgiU=
-github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0/go.mod h1:Z7SCTuiZlghAdRjkv3Ir0iXJKC2T2avbtxLR0DRe+ng=
-github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
-github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 h1:QB54BJwA6x8QU9nHY3xJSZR2kX9bgpZekRKGkLTmEXA=
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375/go.mod h1:xRroudyp5iVtxKqZCrA6n2TLFRBf8bmnjr1UD4x+z7g=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
@@ -998,8 +969,6 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
-github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
@@ -1184,8 +1153,6 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
-golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -1244,12 +1211,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
-golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/api v0.277.0 h1:HJfyJUiNeBBUMai7ez8u14wkp/gH/I4wpGbbO9o+cSk=
-google.golang.org/api v0.277.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
diff --git a/apps/workspace-engine/pkg/store/resources/get_resources.go b/apps/workspace-engine/pkg/store/resources/get_resources.go
index 70be2fd0b..08b9924ac 100644
--- a/apps/workspace-engine/pkg/store/resources/get_resources.go
+++ b/apps/workspace-engine/pkg/store/resources/get_resources.go
@@ -62,7 +62,7 @@ func (p *PostgresGetResources) GetResources(
baseQuery += " AND " + filter.Clause
args = append(args, filter.Args...)
- log.Info("get resources optimization sql filter: %s", "filter", filter.Clause)
+ log.Info("get resources optimization", "filter", filter.Clause)
}
}
diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/versionselector/pushdown.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/versionselector/pushdown.go
index f01ef5044..379630575 100644
--- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/versionselector/pushdown.go
+++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/versionselector/pushdown.go
@@ -1,70 +1,42 @@
package versionselector
import (
- "sync"
-
- "github.com/google/cel-go/cel"
- "github.com/spandigital/cel2sql/v3"
- "github.com/spandigital/cel2sql/v3/pg"
-)
-
-// pushdownEnv is the CEL environment used to attempt SQL pushdown of a
-// versionselector rule. It declares ONLY `version` because the iterator runs
-// per-deployment and only version-scoped predicates can prune candidate rows
-// at query time. Selectors that reference environment/resource/deployment will
-// fail to compile here and fall back to runtime CEL evaluation.
-var (
- pushdownEnv *cel.Env
- pushdownEnvOnce sync.Once
- pushdownEnvErr error
+ "workspace-engine/pkg/celutil"
)
-func getPushdownEnv() (*cel.Env, error) {
- pushdownEnvOnce.Do(func() {
- versionSchema := pg.NewSchema([]pg.FieldSchema{
- {Name: "id", Type: "uuid"},
- {Name: "tag", Type: "text"},
- {Name: "name", Type: "text"},
- {Name: "status", Type: "text"},
- {Name: "created_at", Type: "timestamptz"},
- })
-
- pushdownEnv, pushdownEnvErr = cel.NewEnv(
- cel.CustomTypeProvider(pg.NewTypeProvider(map[string]pg.Schema{
- "DeploymentVersion": versionSchema,
- })),
- cel.Variable("version", cel.ObjectType("DeploymentVersion")),
- )
- })
- return pushdownEnv, pushdownEnvErr
-}
+// versionExtractor is configured for the `version` CEL variable so a CEL
+// expression like `version.tag == "v1"` translates to `tag = $N`. The schema
+// here mirrors the columns selected by the candidate-version iterator query;
+// adding fields here is how we expand pushdown coverage to new shapes.
+//
+// We intentionally only declare flat columns plus the metadata JSONB field —
+// selectors that touch environment/resource/deployment fall through and are
+// evaluated row-by-row by the runtime CEL evaluator, which is the source of
+// truth.
+var versionExtractor = celutil.NewSQLExtractor("version").
+ WithColumn("id", "id").
+ WithColumn("tag", "tag").
+ WithColumn("name", "name").
+ WithColumn("status", "status").
+ WithJSONBField("metadata", "metadata")
-// TryPushDown attempts to convert a versionselector CEL expression into a SQL
-// WHERE clause that can be appended to the candidate-version query. Returns
-// ok=false for any expression cel2sql cannot translate (selectors that read
-// environment/resource/deployment, function calls outside the supported set,
-// JSONB/metadata access not declared in the schema, etc.) so callers can fall
-// back to the row-by-row CEL evaluator.
+// TryPushDown attempts to convert a versionselector CEL expression into a
+// parameterized SQL WHERE fragment that can be appended to the candidate
+// query. startParam is the next available `$N` placeholder number.
+//
+// Returns ok=false when nothing could be extracted — the runtime CEL
+// evaluator still runs over every yielded row, so correctness is preserved
+// regardless. Pushdown is purely a candidate-set narrowing optimization.
//
-// IMPORTANT: pushdown is an optimization, not a replacement. The CEL
-// evaluator must still run per-version at reconcile time — pushdown narrows
-// the candidate set, but its translation may diverge from CEL's runtime
-// semantics in edge cases. The runtime evaluator is the source of truth.
-func TryPushDown(selector string) (clause string, ok bool) {
+// The underlying extractor parameterizes string literals (no inlining), so
+// SQL injection is structurally prevented rather than relying on escaping.
+func TryPushDown(selector string, startParam int) (clause string, args []any, ok bool) {
if selector == "" {
- return "", false
- }
- env, err := getPushdownEnv()
- if err != nil {
- return "", false
- }
- ast, issues := env.Compile(selector)
- if issues != nil && issues.Err() != nil {
- return "", false
+ return "", nil, false
}
- sql, err := cel2sql.Convert(ast)
- if err != nil || sql == "" {
- return "", false
+ f, err := versionExtractor.Extract(selector, startParam)
+ if err != nil || f == nil || f.Clause == "" {
+ return "", nil, false
}
- return sql, true
+ return f.Clause, f.Args, true
}
diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/versionselector/pushdown_test.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/versionselector/pushdown_test.go
index 7a8508a9b..3fa172a58 100644
--- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/versionselector/pushdown_test.go
+++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/versionselector/pushdown_test.go
@@ -8,55 +8,62 @@ import (
)
// TestTryPushDown_SupportedShapes locks in which CEL expression shapes the
-// library currently translates. If any of these flip from ok=true to false on
-// a library upgrade, we want a loud test failure rather than silent loss of
-// the optimization.
+// in-house SQLExtractor currently translates. If any of these flip from
+// ok=true to false on a refactor of celutil, we want a loud test failure
+// rather than silent loss of the optimization.
func TestTryPushDown_SupportedShapes(t *testing.T) {
cases := []struct {
- name string
- selector string
+ name string
+ selector string
+ // First emitted SQL token after WHERE. Validates the column mapping
+ // landed on the right table column and that placeholders advance.
wantContain string
}{
- {
- name: "literal equality",
- selector: `version.tag == "v1.2.3"`,
- wantContain: "v1.2.3",
- },
- {
- name: "inequality",
- selector: `version.tag != "broken"`,
- wantContain: "broken",
- },
+ {name: "literal equality", selector: `version.tag == "v1.2.3"`, wantContain: "tag ="},
+ {name: "inequality", selector: `version.tag != "broken"`, wantContain: "tag !="},
{
name: "boolean and",
selector: `version.tag == "x" && version.name == "y"`,
wantContain: "AND",
},
{
- name: "boolean or",
- selector: `version.tag == "x" || version.tag == "y"`,
- wantContain: "OR",
+ name: "in list",
+ selector: `version.tag in ["a", "b", "c"]`,
+ wantContain: "tag IN",
},
{
name: "startsWith",
selector: `version.tag.startsWith("v1.")`,
- wantContain: "v1.",
+ wantContain: "tag LIKE",
+ },
+ {
+ name: "metadata key access",
+ selector: `version.metadata["env"] == "prod"`,
+ wantContain: "metadata->>",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- clause, ok := TryPushDown(tc.selector)
+ clause, args, ok := TryPushDown(tc.selector, 5)
if !ok {
- t.Logf(
- "selector did NOT push down (will fall back to runtime CEL): %q",
- tc.selector,
- )
- return // capability gap, not a hard failure — optimization is best-effort
+ t.Fatalf("expected pushdown to succeed for %q", tc.selector)
+ }
+ t.Logf("selector=%q → SQL=%s args=%v", tc.selector, clause, args)
+ assert.Contains(t, clause, tc.wantContain)
+ assert.NotEmpty(t, args, "parameterized output must produce args")
+ // Parameterized values must not be inlined as SQL literals.
+ for _, a := range args {
+ if s, isStr := a.(string); isStr {
+ assert.NotContains(
+ t,
+ clause,
+ "'"+s+"'",
+ "value %q should be a parameter, not inlined",
+ s,
+ )
+ }
}
- t.Logf("selector=%q → SQL=%s", tc.selector, clause)
- assert.Contains(t, clause, tc.wantContain,
- "emitted SQL should mention the literal value somehow (escaped or parameterized)")
})
}
}
@@ -69,60 +76,71 @@ func TestTryPushDown_FailsClosed(t *testing.T) {
`environment.name == "prod"`,
`resource.kind == "Server"`,
`deployment.name == "api"`,
- `version.tag == "x" && environment.name == "prod"`,
``, // empty
}
for _, sel := range cases {
t.Run(sel, func(t *testing.T) {
- _, ok := TryPushDown(sel)
+ clause, args, ok := TryPushDown(sel, 5)
assert.False(t, ok, "selector %q must NOT push down", sel)
+ assert.Empty(t, clause)
+ assert.Empty(t, args)
})
}
}
-// TestTryPushDown_StringEscaping is the safety-critical test. If a user
-// stores a malicious string literal in a versionselector rule (an attacker
-// who has policy-write access already has way more than this — but defense
-// in depth), the emitted SQL must escape single quotes properly. This test
-// fails the build if cel2sql ever emits unescaped literals.
-func TestTryPushDown_StringEscaping(t *testing.T) {
- malicious := `version.tag == "test'; DROP TABLE deployment_version; --"`
- clause, ok := TryPushDown(malicious)
+// TestTryPushDown_PartialAndDoesPushSubset documents extractor behavior on
+// mixed selectors: top-level conjuncts that resolve are extracted, the rest
+// are silently dropped (runtime CEL still evaluates the full expression).
+func TestTryPushDown_PartialAndDoesPushSubset(t *testing.T) {
+ clause, args, ok := TryPushDown(`version.tag == "x" && environment.name == "prod"`, 5)
if !ok {
- t.Skip("library refused malicious input — that's also acceptable")
+ t.Skip("extractor refused the mixed expression — also acceptable")
}
- t.Logf("emitted SQL: %s", clause)
-
- // Acceptable shapes (any one of these proves safe handling):
- // 1. Doubled single quote: 'test''; DROP TABLE...'
- // 2. Backslash escape: 'test\'; DROP TABLE...'
- // 3. Postgres E-string: E'test\'; DROP TABLE...'
- // 4. Parameterized output: $1, $2, etc. (no literal at all)
- doubled := strings.Contains(clause, `''`)
- backslash := strings.Contains(clause, `\'`)
- parameterized := strings.Contains(clause, "$") && !strings.Contains(clause, "DROP")
+ t.Logf("clause=%s args=%v", clause, args)
+ assert.Contains(t, clause, "tag =", "version.tag conjunct should push down")
+ assert.NotContains(t, clause, "environment", "environment conjunct must not appear in SQL")
+}
- safe := doubled || backslash || parameterized
- assert.True(t, safe,
- "emitted SQL must escape single quotes or parameterize literals; got: %q", clause)
+// TestTryPushDown_NoInjection ensures the parameterized output never inlines
+// raw user input, even when the user crafts a malicious CEL string literal.
+// Because the in-house extractor parameterizes ALL string values, this is
+// structurally guaranteed — but we test it anyway as a regression guard.
+func TestTryPushDown_NoInjection(t *testing.T) {
+ malicious := `version.tag == "test'; DROP TABLE deployment_version; --"`
+ clause, args, ok := TryPushDown(malicious, 5)
+ if !ok {
+ t.Fatal("expected literal-equality on version.tag to push down")
+ }
+ assert.NotContains(
+ t,
+ clause,
+ "DROP TABLE",
+ "raw payload must not appear in clause text",
+ )
+ assert.NotContains(t, clause, "test'", "single-quoted payload must not be inlined")
- // Critical: the unescaped attack pattern must NOT appear verbatim. If the
- // library inlines `test'; DROP TABLE...` as-is, this assertion catches it.
- assert.NotContains(t, clause, `test'; DROP TABLE`,
- "raw single-quote injection pattern present in emitted SQL — UNSAFE")
+ found := false
+ for _, a := range args {
+ if s, ok := a.(string); ok && strings.Contains(s, "DROP TABLE") {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "the payload should land in args (parameterized), not the clause")
}
-// TestTryPushDown_JSONBAccess documents whether metadata-key access works.
-// Result not asserted — just logged so we know if it's available without
-// extending the schema. Most version selectors don't use metadata, so this
-// being unsupported is acceptable for the POC.
-func TestTryPushDown_JSONBAccess(t *testing.T) {
- clause, ok := TryPushDown(`version.metadata["env"] == "prod"`)
+// TestTryPushDown_AdvancesParamNumbers verifies that successive Extract calls
+// using the running startParam produce non-overlapping placeholders.
+func TestTryPushDown_AdvancesParamNumbers(t *testing.T) {
+ clause1, args1, ok := TryPushDown(`version.tag == "a"`, 5)
+ if !ok {
+ t.Fatal("first extract should succeed")
+ }
+ clause2, args2, ok := TryPushDown(`version.name == "b"`, 5+len(args1))
if !ok {
- t.Log(
- "JSONB metadata access NOT supported by current schema — fine, scope to flat fields for now",
- )
- return
+ t.Fatal("second extract should succeed")
}
- t.Logf("metadata access produced: %s", clause)
+ t.Logf("clause1=%s args1=%v\nclause2=%s args2=%v", clause1, args1, clause2, args2)
+ assert.Contains(t, clause1, "$5")
+ assert.Contains(t, clause2, "$6")
}
diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/getters.go b/apps/workspace-engine/svc/controllers/desiredrelease/getters.go
index 6f4ae6ee8..95727e90e 100644
--- a/apps/workspace-engine/svc/controllers/desiredrelease/getters.go
+++ b/apps/workspace-engine/svc/controllers/desiredrelease/getters.go
@@ -28,18 +28,20 @@ type Getter interface {
// found, so the implementation must lazily page through history rather
// than buffering all versions up front.
//
- // extraWhere is an optional list of SQL fragments that get AND-joined
- // into the candidate query as a pushdown filter. Each fragment is
- // expected to reference columns via the alias `version` (e.g.
- // `version.tag = 'v1.2.3'`). Fragments must be safely escaped before
- // reaching this method — they are concatenated into the SQL string
- // directly. When the iterator can't push down (no fragments supplied,
- // or the consumer doesn't extract any), it behaves identically to the
- // non-pushdown path.
+ // pushdownClauses is an optional list of SQL WHERE fragments that get
+ // AND-joined into the candidate query. Each fragment is expected to
+ // reference unqualified deployment_version columns and contain `$N`
+ // placeholders that index into pushdownArgs starting at $5 (positions
+ // $1-$4 are reserved for deploymentID, limit, and the keyset cursor).
+ // Fragments come from celutil.SQLExtractor — they parameterize all
+ // values, so SQL injection is structurally prevented rather than
+ // relying on escaping. With no fragments supplied, the iterator
+ // behaves identically to the non-pushdown path.
IterCandidateVersions(
ctx context.Context,
deploymentID uuid.UUID,
- extraWhere []string,
+ pushdownClauses []string,
+ pushdownArgs []any,
) iter.Seq2[*oapi.DeploymentVersion, error]
// GetApprovalRecords(ctx context.Context, versionID, environmentID string) ([]*oapi.UserApprovalRecord, error)
diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/getters_postgres.go b/apps/workspace-engine/svc/controllers/desiredrelease/getters_postgres.go
index 49ea57599..6077e65ce 100644
--- a/apps/workspace-engine/svc/controllers/desiredrelease/getters_postgres.go
+++ b/apps/workspace-engine/svc/controllers/desiredrelease/getters_postgres.go
@@ -119,22 +119,24 @@ const candidateVersionColumns = `id, name, tag, config, job_agent_config, deploy
//
// The first batch is deduplicated via singleflight so a burst of concurrent
// reconciles for release targets sharing a deployment collapses to one DB
-// round trip. The singleflight key includes a hash of extraWhere so consumers
-// applying different pushdown filters do not share results. Subsequent batches
-// (consumed only when the first 500 rows are exhausted without finding an
-// eligible version) are fetched independently.
+// round trip. The singleflight key includes a hash of pushdownClauses so
+// consumers applying different pushdown filters do not share results.
+// Subsequent batches (consumed only when the first 500 rows are exhausted
+// without finding an eligible version) are fetched independently.
//
-// extraWhere fragments are inlined into the SQL via string concatenation. They
-// MUST come from a trusted source that emits already-escaped SQL — e.g. the
-// versionselector.TryPushDown helper, which uses cel2sql with verified literal
-// escaping. Never pass user-supplied raw strings here.
+// pushdownClauses are SQL WHERE fragments emitted by celutil.SQLExtractor
+// with `$N` placeholders that resolve against pushdownArgs starting at $5
+// ($1-$4 are reserved for deploymentID, limit, afterCreatedAt, afterID).
+// All values are passed as real query parameters — never inlined — so SQL
+// injection is structurally prevented.
func (g *PostgresGetter) IterCandidateVersions(
ctx context.Context,
deploymentID uuid.UUID,
- extraWhere []string,
+ pushdownClauses []string,
+ pushdownArgs []any,
) iter.Seq2[*oapi.DeploymentVersion, error] {
return func(yield func(*oapi.DeploymentVersion, error) bool) {
- firstBatch, err := g.fetchFirstBatch(ctx, deploymentID, extraWhere)
+ firstBatch, err := g.fetchFirstBatch(ctx, deploymentID, pushdownClauses, pushdownArgs)
if err != nil {
yield(nil, err)
return
@@ -159,7 +161,7 @@ func (g *PostgresGetter) IterCandidateVersions(
for {
rows, err := queryCandidateVersionsBatch(
- ctx, deploymentID, extraWhere, afterCreatedAt, afterID,
+ ctx, deploymentID, pushdownClauses, pushdownArgs, afterCreatedAt, afterID,
)
if err != nil {
yield(nil, err)
@@ -188,21 +190,26 @@ func (g *PostgresGetter) IterCandidateVersions(
// fetchFirstBatch loads the newest candidateVersionBatchSize deployable
// versions for a deployment, sharing the result across concurrent callers via
-// singleflight keyed by (deploymentID, hash(extraWhere)). The returned slice
-// is immutable: callers must not mutate it.
+// singleflight keyed by (deploymentID, hash(clauses), hash(args)). Two
+// selectors can compile to the same clause text but bind different values
+// (e.g. `tag == "v1"` and `tag == "v2"` both produce `tag = $5`), so the
+// args MUST participate in the key — otherwise concurrent reconciles for
+// different RTs of the same deployment would receive each other's results.
+// The returned slice is immutable.
func (g *PostgresGetter) fetchFirstBatch(
ctx context.Context,
deploymentID uuid.UUID,
- extraWhere []string,
+ pushdownClauses []string,
+ pushdownArgs []any,
) ([]db.DeploymentVersion, error) {
- key := deploymentID.String() + "|" + hashWhere(extraWhere)
+ key := deploymentID.String() + "|" + hashClauses(pushdownClauses) + ":" + hashArgs(pushdownArgs)
// Detach the singleflight closure from the first caller's cancellation
// so one caller's ctx cancellation doesn't fail the shared query for
// every other waiter on the same key. Trace context is preserved.
qCtx := context.WithoutCancel(ctx)
v, err, _ := g.firstBatchSF.Do(key, func() (any, error) {
return queryCandidateVersionsBatch(
- qCtx, deploymentID, extraWhere, pgtype.Timestamptz{}, uuid.Nil,
+ qCtx, deploymentID, pushdownClauses, pushdownArgs, pgtype.Timestamptz{}, uuid.Nil,
)
})
if err != nil {
@@ -217,10 +224,15 @@ func (g *PostgresGetter) fetchFirstBatch(
// the column list and base WHERE are kept structurally identical to
// ListDeployableVersionsByDeploymentIDAfter so future schema changes flow
// through both paths in lockstep.
+//
+// The pushdown clauses use $N placeholders starting at $5; pushdownArgs are
+// appended to the base [$1=deploymentID, $2=limit, $3=afterCreatedAt,
+// $4=afterID] argument list in the same order.
func queryCandidateVersionsBatch(
ctx context.Context,
deploymentID uuid.UUID,
- extraWhere []string,
+ pushdownClauses []string,
+ pushdownArgs []any,
afterCreatedAt pgtype.Timestamptz,
afterID uuid.UUID,
) ([]db.DeploymentVersion, error) {
@@ -228,8 +240,8 @@ func queryCandidateVersionsBatch(
defer span.End()
pushdownCount := 0
- for _, f := range extraWhere {
- if f != "" {
+ for _, c := range pushdownClauses {
+ if c != "" {
pushdownCount++
}
}
@@ -237,11 +249,11 @@ func queryCandidateVersionsBatch(
var b strings.Builder
b.WriteString("SELECT ")
b.WriteString(candidateVersionColumns)
- b.WriteString(` FROM deployment_version version
+ b.WriteString(` FROM deployment_version
WHERE deployment_id = $1
AND status NOT IN ('rejected', 'building')
AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid))`)
- for _, frag := range extraWhere {
+ for _, frag := range pushdownClauses {
if frag == "" {
continue
}
@@ -251,13 +263,16 @@ WHERE deployment_id = $1
}
b.WriteString("\nORDER BY created_at DESC, id DESC\nLIMIT $2")
- rows, err := db.GetPool(ctx).Query(
- ctx, b.String(),
+ args := make([]any, 0, 4+len(pushdownArgs))
+ args = append(args,
deploymentID,
int64(candidateVersionBatchSize),
afterCreatedAt,
afterID,
)
+ args = append(args, pushdownArgs...)
+
+ rows, err := db.GetPool(ctx).Query(ctx, b.String(), args...)
if err != nil {
span.RecordError(err)
return nil, fmt.Errorf("list versions for deployment %s: %w", deploymentID, err)
@@ -294,16 +309,36 @@ WHERE deployment_id = $1
return out, nil
}
-// hashWhere produces a stable cache-key suffix for a set of pushdown
-// fragments. Order matters — callers should pass fragments in canonical order
+// hashClauses produces a stable cache-key suffix for a set of pushdown
+// clauses. Order matters — callers should pass clauses in canonical order
// if they want different orderings to share a cache slot.
-func hashWhere(extraWhere []string) string {
- if len(extraWhere) == 0 {
+func hashClauses(clauses []string) string {
+ if len(clauses) == 0 {
+ return ""
+ }
+ h := sha256.New()
+ for _, c := range clauses {
+ h.Write([]byte(c))
+ h.Write([]byte{0})
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// hashArgs produces a stable cache-key suffix for a set of pushdown args.
+// Order matters and must match the clause-arg pairing exactly.
+//
+// The current celutil.SQLExtractor only emits string-typed args (CEL string
+// literals via extractValue), so fmt's %v gives a deterministic
+// representation. If future schema additions allow non-string types whose
+// %v rendering depends on map iteration order or other non-determinism, the
+// caller must pre-canonicalize before reaching this point.
+func hashArgs(args []any) string {
+ if len(args) == 0 {
return ""
}
h := sha256.New()
- for _, frag := range extraWhere {
- h.Write([]byte(frag))
+ for _, a := range args {
+ fmt.Fprintf(h, "%v", a)
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/getters_postgres_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/getters_postgres_test.go
index 63f7c0405..15d2de08a 100644
--- a/apps/workspace-engine/svc/controllers/desiredrelease/getters_postgres_test.go
+++ b/apps/workspace-engine/svc/controllers/desiredrelease/getters_postgres_test.go
@@ -176,7 +176,7 @@ func collectVersions(
) []*oapi.DeploymentVersion {
t.Helper()
var out []*oapi.DeploymentVersion
- for v, err := range getter.IterCandidateVersions(ctx, deploymentID, nil) {
+ for v, err := range getter.IterCandidateVersions(ctx, deploymentID, nil, nil) {
require.NoError(t, err)
out = append(out, v)
}
diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/pushdown_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/pushdown_test.go
index 0c9d07b58..835126136 100644
--- a/apps/workspace-engine/svc/controllers/desiredrelease/pushdown_test.go
+++ b/apps/workspace-engine/svc/controllers/desiredrelease/pushdown_test.go
@@ -8,35 +8,39 @@ import (
)
func TestCollectPushdownClauses(t *testing.T) {
- t.Run("nil policies → empty slice", func(t *testing.T) {
- assert.Empty(t, collectPushdownClauses(nil))
+ t.Run("nil policies → empty result", func(t *testing.T) {
+ clauses, args := collectPushdownClauses(nil)
+ assert.Empty(t, clauses)
+ assert.Empty(t, args)
})
t.Run("disabled policy is skipped", func(t *testing.T) {
- clauses := collectPushdownClauses([]*oapi.Policy{
+ clauses, args := collectPushdownClauses([]*oapi.Policy{
{
Enabled: false,
Rules: []oapi.PolicyRule{
- {VersionSelector: &oapi.VersionSelectorRule{Selector: `version.tag == "x"`}},
+ {
+ VersionSelector: &oapi.VersionSelectorRule{
+ Selector: `version.tag == "x"`,
+ },
+ },
},
},
})
assert.Empty(t, clauses)
+ assert.Empty(t, args)
})
t.Run("rule without VersionSelector is skipped", func(t *testing.T) {
- clauses := collectPushdownClauses([]*oapi.Policy{
- {
- Enabled: true,
- Rules: []oapi.PolicyRule{{}},
- },
+ clauses, args := collectPushdownClauses([]*oapi.Policy{
+ {Enabled: true, Rules: []oapi.PolicyRule{{}}},
})
assert.Empty(t, clauses)
+ assert.Empty(t, args)
})
t.Run("untranslatable selector falls back silently", func(t *testing.T) {
- // References environment, which our pushdown schema doesn't expose.
- clauses := collectPushdownClauses([]*oapi.Policy{
+ clauses, args := collectPushdownClauses([]*oapi.Policy{
{
Enabled: true,
Rules: []oapi.PolicyRule{
@@ -48,11 +52,12 @@ func TestCollectPushdownClauses(t *testing.T) {
},
},
})
- assert.Empty(t, clauses, "selectors that can't push down must produce no clause")
+ assert.Empty(t, clauses)
+ assert.Empty(t, args)
})
- t.Run("translatable selector emits clause", func(t *testing.T) {
- clauses := collectPushdownClauses([]*oapi.Policy{
+ t.Run("translatable selector emits clause + arg", func(t *testing.T) {
+ clauses, args := collectPushdownClauses([]*oapi.Policy{
{
Enabled: true,
Rules: []oapi.PolicyRule{
@@ -65,23 +70,55 @@ func TestCollectPushdownClauses(t *testing.T) {
},
})
assert.Len(t, clauses, 1)
- assert.Contains(t, clauses[0], "v1.2.3")
+ assert.Contains(t, clauses[0], "tag =")
+ assert.Contains(t, clauses[0], "$5", "first pushdown arg lands at $5")
+ assert.Equal(t, []any{"v1.2.3"}, args)
+ })
+
+ t.Run("multiple translatable rules → consecutive placeholders", func(t *testing.T) {
+ clauses, args := collectPushdownClauses([]*oapi.Policy{
+ {
+ Enabled: true,
+ Rules: []oapi.PolicyRule{
+ {
+ VersionSelector: &oapi.VersionSelectorRule{
+ Selector: `version.tag == "x"`,
+ },
+ },
+ {
+ VersionSelector: &oapi.VersionSelectorRule{
+ Selector: `version.name == "y"`,
+ },
+ },
+ },
+ },
+ })
+ assert.Len(t, clauses, 2)
+ assert.Contains(t, clauses[0], "$5")
+ assert.Contains(t, clauses[1], "$6", "second clause picks up after first arg")
+ assert.Equal(t, []any{"x", "y"}, args)
})
- t.Run("multiple translatable clauses returned in stable order", func(t *testing.T) {
- policies := []*oapi.Policy{
+ t.Run("untranslatable rules don't consume placeholder slots", func(t *testing.T) {
+ clauses, args := collectPushdownClauses([]*oapi.Policy{
{
Enabled: true,
Rules: []oapi.PolicyRule{
- {VersionSelector: &oapi.VersionSelectorRule{Selector: `version.tag == "z"`}},
- {VersionSelector: &oapi.VersionSelectorRule{Selector: `version.tag == "a"`}},
+ {
+ VersionSelector: &oapi.VersionSelectorRule{
+ Selector: `environment.name == "prod"`,
+ },
+ },
+ {
+ VersionSelector: &oapi.VersionSelectorRule{
+ Selector: `version.tag == "v1"`,
+ },
+ },
},
},
- }
- clauses1 := collectPushdownClauses(policies)
- clauses2 := collectPushdownClauses(policies)
- assert.Equal(t, clauses1, clauses2, "same input must produce identical output")
- assert.Len(t, clauses1, 2)
- assert.LessOrEqual(t, clauses1[0], clauses1[1], "clauses must be sorted")
+ })
+ assert.Len(t, clauses, 1)
+ assert.Contains(t, clauses[0], "$5", "translatable clause still numbers from $5")
+ assert.Equal(t, []any{"v1"}, args)
})
}
diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile.go b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile.go
index 5822f90e2..9f0530d0d 100644
--- a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile.go
+++ b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile.go
@@ -56,13 +56,13 @@ func (r *reconciler) loadInput(ctx context.Context) (err error) {
func (r *reconciler) findDeployableVersion(ctx context.Context) *time.Time {
oapiRT := r.rt.ToOAPI()
evals := policyeval.CollectEvaluators(ctx, r.getter, oapiRT, r.policies)
- pushdown := collectPushdownClauses(r.policies)
+ clauses, args := collectPushdownClauses(r.policies)
result, err := policyeval.FindDeployableVersion(
ctx,
r.getter,
oapiRT,
- r.getter.IterCandidateVersions(ctx, r.rt.DeploymentID, pushdown),
+ r.getter.IterCandidateVersions(ctx, r.rt.DeploymentID, clauses, args),
evals,
*r.scope,
)
@@ -158,18 +158,23 @@ func Reconcile(
return &ReconcileResult{NextReconcileAt: nextTime}, nil
}
+// pushdownBaseParam is the next available `$N` placeholder after the four
+// base parameters used by the candidate-version query (deploymentID, limit,
+// afterCreatedAt, afterID).
+const pushdownBaseParam = 5
+
// collectPushdownClauses inspects a release target's policies for
-// versionselector rules and translates each into a SQL WHERE fragment via
-// versionselector.TryPushDown. Rules that can't be translated (selectors
-// referencing environment/resource/deployment, complex CEL, etc.) are
-// silently skipped — the runtime CEL evaluator still runs per-version, so
-// correctness is preserved; only the candidate-set narrowing is lost.
+// versionselector rules and translates each into a parameterized SQL WHERE
+// fragment via versionselector.TryPushDown. Rules that can't be translated
+// (selectors referencing environment/resource/deployment, OR composition,
+// non-string values, etc.) are silently skipped — the runtime CEL evaluator
+// still runs per-version, so correctness is preserved; only the
+// candidate-set narrowing is lost.
//
-// The returned slice is sorted by clause text so concurrent reconciles
-// against the same deployment with the same selector set produce the same
-// singleflight cache key.
-func collectPushdownClauses(policies []*oapi.Policy) []string {
- var clauses []string
+// Each fragment carries `$N` placeholders into the returned args slice, with
+// numbering picked up from where the previous fragment left off so all
+// fragments can be appended into the same query without collisions.
+func collectPushdownClauses(policies []*oapi.Policy) (clauses []string, args []any) {
for _, p := range policies {
if p == nil || !p.Enabled {
continue
@@ -178,31 +183,18 @@ func collectPushdownClauses(policies []*oapi.Policy) []string {
if rule.VersionSelector == nil {
continue
}
- clause, ok := versionselector.TryPushDown(rule.VersionSelector.Selector)
+ clause, clauseArgs, ok := versionselector.TryPushDown(
+ rule.VersionSelector.Selector,
+ pushdownBaseParam+len(args),
+ )
if !ok {
continue
}
clauses = append(clauses, clause)
+ args = append(args, clauseArgs...)
}
}
- if len(clauses) > 1 {
- // Stable order so the singleflight key is deterministic across
- // reconciles that see the same logical clause set.
- sortStrings(clauses)
- }
- return clauses
-}
-
-// sortStrings is an in-place insertion sort. We use it instead of
-// sort.Strings to keep the import surface small for this hot path.
-func sortStrings(s []string) {
- for i := 1; i < len(s); i++ {
- j := i
- for j > 0 && s[j-1] > s[j] {
- s[j-1], s[j] = s[j], s[j-1]
- j--
- }
- }
+ return clauses, args
}
func recordErr(span trace.Span, msg string, err error) error {
diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go
index 583198cf6..fdf463c65 100644
--- a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go
+++ b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go
@@ -49,6 +49,7 @@ func (m *mockReconcileGetter) IterCandidateVersions(
_ context.Context,
_ uuid.UUID,
_ []string,
+ _ []any,
) iter.Seq2[*oapi.DeploymentVersion, error] {
return func(yield func(*oapi.DeploymentVersion, error) bool) {
for _, v := range m.versions {
diff --git a/apps/workspace-engine/test/controllers/harness/mocks.go b/apps/workspace-engine/test/controllers/harness/mocks.go
index 8e01e4a31..190c3d654 100644
--- a/apps/workspace-engine/test/controllers/harness/mocks.go
+++ b/apps/workspace-engine/test/controllers/harness/mocks.go
@@ -171,6 +171,7 @@ func (g *DesiredReleaseGetter) IterCandidateVersions(
_ context.Context,
_ uuid.UUID,
_ []string,
+ _ []any,
) iter.Seq2[*oapi.DeploymentVersion, error] {
return func(yield func(*oapi.DeploymentVersion, error) bool) {
for _, v := range g.Versions {