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 {