From a8309f44b21456f5ca2656240ed13cc7b2fff9e3 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Mon, 18 May 2026 16:35:26 -0400 Subject: [PATCH 1/2] feat: release target eligible versions v1 endpoint --- apps/api/openapi/openapi.json | 124 ++++++++ .../api/openapi/paths/release-targets.jsonnet | 33 ++ .../routes/v1/workspaces/release-targets.ts | 34 +- apps/api/src/types/openapi.ts | 83 +++++ apps/workspace-engine/oapi/openapi.json | 124 ++++++++ .../oapi/spec/paths/release_targets.jsonnet | 33 ++ apps/workspace-engine/pkg/oapi/oapi.gen.go | 74 +++++ .../desiredrelease/policyeval/policyeval.go | 48 +++ .../policyeval/policyeval_test.go | 180 +++++++++++ .../release_targets/eligible_versions.go | 149 +++++++++ e2e/api/schema.ts | 83 +++++ e2e/package.json | 1 + .../release-target-eligible-versions.spec.ts | 293 ++++++++++++++++++ packages/workspace-engine-sdk/src/schema.ts | 83 +++++ 14 files changed, 1341 insertions(+), 1 deletion(-) create mode 100644 apps/workspace-engine/svc/http/server/openapi/release_targets/eligible_versions.go create mode 100644 e2e/tests/api/release-target-eligible-versions.spec.ts diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index fa994bfee..9ef60675a 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -7492,6 +7492,130 @@ "summary": "Get the desired release for a release target" } }, + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions": { + "post": { + "description": "Returns deployment versions that currently pass every policy rule for this release target. An optional CEL filter narrows the result; pagination is applied to the filtered set. Use the \"version\" variable in the CEL expression to access version properties.", + "operationId": "listEligibleVersionsForReleaseTarget", + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Key of the release target", + "in": "path", + "name": "releaseTargetKey", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of items to return", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Number of items to skip", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "filter": { + "description": "CEL expression to filter eligible versions. Defaults to \"true\" (all eligible versions).", + "type": "string" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/DeploymentVersion" + }, + "type": "array" + }, + "limit": { + "description": "Maximum number of items returned", + "type": "integer" + }, + "offset": { + "description": "Number of items skipped", + "type": "integer" + }, + "total": { + "description": "Total number of items available", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "type": "object" + } + } + }, + "description": "Eligible versions for the release target" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "List versions eligible for a release target" + } + }, "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/jobs": { "get": { "description": "Returns a list of jobs for a release target {releaseTargetKey}.", diff --git a/apps/api/openapi/paths/release-targets.jsonnet b/apps/api/openapi/paths/release-targets.jsonnet index 5f0555ffa..61c26be06 100644 --- a/apps/api/openapi/paths/release-targets.jsonnet +++ b/apps/api/openapi/paths/release-targets.jsonnet @@ -90,6 +90,39 @@ local openapi = import '../lib/openapi.libsonnet'; }, }, + '/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions': { + post: { + summary: 'List versions eligible for a release target', + operationId: 'listEligibleVersionsForReleaseTarget', + description: 'Returns deployment versions that currently pass every policy rule for this release target. An optional CEL filter narrows the result; pagination is applied to the filtered set. Use the "version" variable in the CEL expression to access version properties.', + parameters: [ + openapi.workspaceIdParam(), + openapi.releaseTargetKeyParam(), + openapi.limitParam(), + openapi.offsetParam(), + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + filter: { + type: 'string', + description: 'CEL expression to filter eligible versions. Defaults to "true" (all eligible versions).', + }, + }, + }, + }, + }, + }, + responses: openapi.paginatedResponse(openapi.schemaRef('DeploymentVersion'), 'Eligible versions for the release target') + + openapi.notFoundResponse() + + openapi.badRequestResponse(), + }, + }, + '/v1/workspaces/{workspaceId}/release-targets/resource-preview': { post: { summary: 'Preview release targets for a resource', diff --git a/apps/api/src/routes/v1/workspaces/release-targets.ts b/apps/api/src/routes/v1/workspaces/release-targets.ts index fd467dfac..3a9402bdb 100644 --- a/apps/api/src/routes/v1/workspaces/release-targets.ts +++ b/apps/api/src/routes/v1/workspaces/release-targets.ts @@ -236,6 +236,34 @@ const getReleaseTargetState: AsyncTypedHandler< res.status(200).json(data); }; +const listEligibleVersionsForReleaseTarget: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions", + "post" +> = async (req, res) => { + const { workspaceId, releaseTargetKey } = req.params; + const { limit, offset } = req.query; + const { filter } = req.body; + + const { data, error, response } = await getClientFor(workspaceId).POST( + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions", + { + params: { + path: { workspaceId, releaseTargetKey }, + query: { limit, offset }, + }, + body: { filter }, + }, + ); + + if (error != null) + throw new ApiError( + error.error ?? "Failed to list eligible versions", + response.status >= 400 && response.status < 500 ? response.status : 502, + ); + + res.status(200).json(data); +}; + const getReleaseTargetStates: AsyncTypedHandler< "/v1/workspaces/{workspaceId}/release-targets/state", "post" @@ -436,7 +464,11 @@ const previewReleaseTargetsForResource: AsyncTypedHandler< const releaseTargetKeyRouter = Router({ mergeParams: true }) .get("/jobs", asyncHandler(getReleaseTargetJobs)) .get("/desired-release", asyncHandler(getReleaseTargetDesiredRelease)) - .get("/state", asyncHandler(getReleaseTargetState)); + .get("/state", asyncHandler(getReleaseTargetState)) + .post( + "/eligible-versions", + asyncHandler(listEligibleVersionsForReleaseTarget), + ); export const releaseTargetsRouter = Router({ mergeParams: true }) .post("/state", asyncHandler(getReleaseTargetStates)) diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index 75608ce65..144dd5195 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -655,6 +655,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * List versions eligible for a release target + * @description Returns deployment versions that currently pass every policy rule for this release target. An optional CEL filter narrows the result; pagination is applied to the filtered set. Use the "version" variable in the CEL expression to access version properties. + */ + post: operations["listEligibleVersionsForReleaseTarget"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/jobs": { parameters: { query?: never; @@ -4984,6 +5004,69 @@ export interface operations { }; }; }; + listEligibleVersionsForReleaseTarget: { + parameters: { + query?: { + /** @description Maximum number of items to return */ + limit?: number; + /** @description Number of items to skip */ + offset?: number; + }; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description Key of the release target */ + releaseTargetKey: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description CEL expression to filter eligible versions. Defaults to "true" (all eligible versions). */ + filter?: string; + }; + }; + }; + responses: { + /** @description Eligible versions for the release target */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["DeploymentVersion"][]; + /** @description Maximum number of items returned */ + limit: number; + /** @description Number of items skipped */ + offset: number; + /** @description Total number of items available */ + total: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getJobsForReleaseTarget: { parameters: { query?: { diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index 51b267f29..f9f1f1b6f 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -3584,6 +3584,130 @@ "summary": "List deployments" } }, + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions": { + "post": { + "description": "Returns deployment versions that currently pass every policy rule for this release target. An optional CEL filter narrows the result; pagination is applied to the filtered set. Use the \"version\" variable in the CEL expression to access version properties.", + "operationId": "listEligibleVersionsForReleaseTarget", + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Key of the release target", + "in": "path", + "name": "releaseTargetKey", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of items to return", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Number of items to skip", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "filter": { + "description": "CEL expression to filter eligible versions. Defaults to \"true\" (all eligible versions).", + "type": "string" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/DeploymentVersion" + }, + "type": "array" + }, + "limit": { + "description": "Maximum number of items returned", + "type": "integer" + }, + "offset": { + "description": "Number of items skipped", + "type": "integer" + }, + "total": { + "description": "Total number of items available", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "type": "object" + } + } + }, + "description": "Eligible versions for the release target" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "List versions eligible for a release target" + } + }, "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/state": { "get": { "operationId": "getReleaseTargetState", diff --git a/apps/workspace-engine/oapi/spec/paths/release_targets.jsonnet b/apps/workspace-engine/oapi/spec/paths/release_targets.jsonnet index b96869c51..80d76ff45 100644 --- a/apps/workspace-engine/oapi/spec/paths/release_targets.jsonnet +++ b/apps/workspace-engine/oapi/spec/paths/release_targets.jsonnet @@ -38,4 +38,37 @@ local openapi = import '../lib/openapi.libsonnet'; + openapi.badRequestResponse(), }, }, + + '/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions': { + post: { + summary: 'List versions eligible for a release target', + operationId: 'listEligibleVersionsForReleaseTarget', + description: 'Returns deployment versions that currently pass every policy rule for this release target. An optional CEL filter narrows the result; pagination is applied to the filtered set. Use the "version" variable in the CEL expression to access version properties.', + parameters: [ + openapi.workspaceIdParam(), + openapi.releaseTargetKeyParam(), + openapi.limitParam(), + openapi.offsetParam(), + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + filter: { + type: 'string', + description: 'CEL expression to filter eligible versions. Defaults to "true" (all eligible versions).', + }, + }, + }, + }, + }, + }, + responses: openapi.paginatedResponse(openapi.schemaRef('DeploymentVersion'), 'Eligible versions for the release target') + + openapi.notFoundResponse() + + openapi.badRequestResponse(), + }, + }, } diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index b1aa4627d..0117ca83c 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -1545,6 +1545,21 @@ type ListDeploymentsParams struct { Cel *string `form:"cel,omitempty" json:"cel,omitempty"` } +// ListEligibleVersionsForReleaseTargetJSONBody defines parameters for ListEligibleVersionsForReleaseTarget. +type ListEligibleVersionsForReleaseTargetJSONBody struct { + // Filter CEL expression to filter eligible versions. Defaults to "true" (all eligible versions). + Filter *string `json:"filter,omitempty"` +} + +// ListEligibleVersionsForReleaseTargetParams defines parameters for ListEligibleVersionsForReleaseTarget. +type ListEligibleVersionsForReleaseTargetParams struct { + // Limit Maximum number of items to return + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of items to skip + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + // ComputeAggergateJSONBody defines parameters for ComputeAggergate. type ComputeAggergateJSONBody struct { // Filter CEL expression to filter resources. Defaults to "true" (all resources). @@ -1582,6 +1597,9 @@ type CreateWorkflowRunJSONBody struct { // ValidateResourceSelectorJSONRequestBody defines body for ValidateResourceSelector for application/json ContentType. type ValidateResourceSelectorJSONRequestBody ValidateResourceSelectorJSONBody +// ListEligibleVersionsForReleaseTargetJSONRequestBody defines body for ListEligibleVersionsForReleaseTarget for application/json ContentType. +type ListEligibleVersionsForReleaseTargetJSONRequestBody ListEligibleVersionsForReleaseTargetJSONBody + // ComputeAggergateJSONRequestBody defines body for ComputeAggergate for application/json ContentType. type ComputeAggergateJSONRequestBody ComputeAggergateJSONBody @@ -2518,6 +2536,9 @@ type ServerInterface interface { // List deployments // (GET /v1/workspaces/{workspaceId}/deployments) ListDeployments(c *gin.Context, workspaceId string, params ListDeploymentsParams) + // List versions eligible for a release target + // (POST /v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions) + ListEligibleVersionsForReleaseTarget(c *gin.Context, workspaceId string, releaseTargetKey string, params ListEligibleVersionsForReleaseTargetParams) // Get the state of a release target // (GET /v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/state) GetReleaseTargetState(c *gin.Context, workspaceId string, releaseTargetKey string) @@ -2677,6 +2698,58 @@ func (siw *ServerInterfaceWrapper) ListDeployments(c *gin.Context) { siw.Handler.ListDeployments(c, workspaceId, params) } +// ListEligibleVersionsForReleaseTarget operation middleware +func (siw *ServerInterfaceWrapper) ListEligibleVersionsForReleaseTarget(c *gin.Context) { + + var err error + + // ------------- Path parameter "workspaceId" ------------- + var workspaceId string + + err = runtime.BindStyledParameterWithOptions("simple", "workspaceId", c.Param("workspaceId"), &workspaceId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter workspaceId: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "releaseTargetKey" ------------- + var releaseTargetKey string + + err = runtime.BindStyledParameterWithOptions("simple", "releaseTargetKey", c.Param("releaseTargetKey"), &releaseTargetKey, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter releaseTargetKey: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params ListEligibleVersionsForReleaseTargetParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.ListEligibleVersionsForReleaseTarget(c, workspaceId, releaseTargetKey, params) +} + // GetReleaseTargetState operation middleware func (siw *ServerInterfaceWrapper) GetReleaseTargetState(c *gin.Context) { @@ -2842,6 +2915,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/v1/jobs/:jobId/verification-status", wrapper.GetJobVerificationStatus) router.POST(options.BaseURL+"/v1/validate/resource-selector", wrapper.ValidateResourceSelector) router.GET(options.BaseURL+"/v1/workspaces/:workspaceId/deployments", wrapper.ListDeployments) + router.POST(options.BaseURL+"/v1/workspaces/:workspaceId/release-targets/:releaseTargetKey/eligible-versions", wrapper.ListEligibleVersionsForReleaseTarget) router.GET(options.BaseURL+"/v1/workspaces/:workspaceId/release-targets/:releaseTargetKey/state", wrapper.GetReleaseTargetState) router.POST(options.BaseURL+"/v1/workspaces/:workspaceId/resources/aggregates", wrapper.ComputeAggergate) router.POST(options.BaseURL+"/v1/workspaces/:workspaceId/resources/query", wrapper.QueryResources) diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval.go b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval.go index 91ef68107..c3f3e9ca7 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval.go @@ -181,6 +181,54 @@ func FindDeployableVersion( }, nil } +// ListDeployableVersions iterates candidate versions and returns every version +// that passes the full evaluator set for the given release target. Unlike +// FindDeployableVersion it does not short-circuit on the first allowed version; +// callers receive the complete eligible set in iteration order. +func ListDeployableVersions( + ctx context.Context, + getter Getter, + rt *oapi.ReleaseTarget, + versions iter.Seq2[*oapi.DeploymentVersion, error], + evals []evaluator.Evaluator, + scope evaluator.EvaluatorScope, +) ([]*oapi.DeploymentVersion, error) { + _, span := tracer.Start(ctx, "ListDeployableVersions") + defer span.End() + + var eligible []*oapi.DeploymentVersion + var scanned int + + for version, err := range versions { + if err != nil { + return nil, fmt.Errorf("iter candidate versions: %w", err) + } + scanned++ + scope.Version = version + + skips, err := getter.GetPolicySkips(ctx, version.Id, rt.EnvironmentId, rt.ResourceId) + if err != nil { + return nil, fmt.Errorf("get policy skips: %w", err) + } + + evaluations, err := evaluateVersion(ctx, evals, scope, skips) + if err != nil { + return nil, fmt.Errorf("evaluate version: %w", err) + } + + if evaluations.Allowed() { + eligible = append(eligible, version) + } + } + + span.SetAttributes( + attribute.String("deployment.id", rt.DeploymentId), + attribute.Int("versions.scanned", scanned), + attribute.Int("versions.eligible", len(eligible)), + ) + return eligible, nil +} + // buildSkipSet returns the set of rule IDs that have a non-expired policy skip. func buildSkipSet(skips []*oapi.PolicySkip) map[string]oapi.PolicySkip { set := make(map[string]oapi.PolicySkip, len(skips)) diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go index 9a38ac4c0..0dac796b5 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go @@ -925,6 +925,186 @@ func TestBuildSkipSet(t *testing.T) { }) } +// --------------------------------------------------------------------------- +// ListDeployableVersions tests +// --------------------------------------------------------------------------- + +func TestListDeployableVersions(t *testing.T) { + ctx := context.Background() + getter := &mockGetter{} + rt := &oapi.ReleaseTarget{EnvironmentId: "env-1", ResourceId: "r-1", DeploymentId: "d-1"} + + versionIDs := func(versions []*oapi.DeploymentVersion) []string { + ids := make([]string, len(versions)) + for i, v := range versions { + ids[i] = v.Id + } + return ids + } + + t.Run("returns empty for empty versions", func(t *testing.T) { + evals := []evaluator.Evaluator{ + &mockEvaluator{result: allowResult(), scopeFields: evaluator.ScopeVersion}, + } + got, err := ListDeployableVersions(ctx, getter, rt, iterVersions(nil), evals, fullScope()) + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("returns all versions when every evaluator allows", func(t *testing.T) { + versions := []*oapi.DeploymentVersion{version("v1"), version("v2"), version("v3")} + evals := []evaluator.Evaluator{ + &mockEvaluator{result: allowResult(), scopeFields: evaluator.ScopeVersion}, + } + got, err := ListDeployableVersions( + ctx, + getter, + rt, + iterVersions(versions), + evals, + fullScope(), + ) + require.NoError(t, err) + assert.Equal(t, []string{"v1", "v2", "v3"}, versionIDs(got)) + }) + + t.Run("excludes denied versions and preserves iteration order", func(t *testing.T) { + e := &conditionalEvaluator{ + scopeFields: evaluator.ScopeVersion, + fn: func(_ context.Context, scope evaluator.EvaluatorScope) *oapi.RuleEvaluation { + if scope.Version.Id == "v2" { + return denyResult() + } + return allowResult() + }, + } + versions := []*oapi.DeploymentVersion{version("v1"), version("v2"), version("v3")} + got, err := ListDeployableVersions( + ctx, + getter, + rt, + iterVersions(versions), + []evaluator.Evaluator{e}, + fullScope(), + ) + require.NoError(t, err) + assert.Equal(t, []string{"v1", "v3"}, versionIDs(got)) + }) + + t.Run("iterates every version, does not short-circuit on first allowed", func(t *testing.T) { + var seen []string + e := &conditionalEvaluator{ + scopeFields: evaluator.ScopeVersion, + fn: func(_ context.Context, scope evaluator.EvaluatorScope) *oapi.RuleEvaluation { + seen = append(seen, scope.Version.Id) + return allowResult() + }, + } + versions := []*oapi.DeploymentVersion{version("v1"), version("v2"), version("v3")} + got, err := ListDeployableVersions( + ctx, + getter, + rt, + iterVersions(versions), + []evaluator.Evaluator{e}, + fullScope(), + ) + require.NoError(t, err) + assert.Equal(t, []string{"v1", "v2", "v3"}, seen, "evaluator must be called for every version") + assert.Equal(t, []string{"v1", "v2", "v3"}, versionIDs(got)) + }) + + t.Run("returns empty when every version is denied", func(t *testing.T) { + evals := []evaluator.Evaluator{ + &mockEvaluator{result: denyResult(), scopeFields: evaluator.ScopeVersion}, + } + versions := []*oapi.DeploymentVersion{version("v1"), version("v2")} + got, err := ListDeployableVersions( + ctx, + getter, + rt, + iterVersions(versions), + evals, + fullScope(), + ) + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("no evaluators means every version is eligible", func(t *testing.T) { + versions := []*oapi.DeploymentVersion{version("v1"), version("v2")} + got, err := ListDeployableVersions( + ctx, + getter, + rt, + iterVersions(versions), + nil, + fullScope(), + ) + require.NoError(t, err) + assert.Equal(t, []string{"v1", "v2"}, versionIDs(got)) + }) + + t.Run("policy skip lets an otherwise-denied version through", func(t *testing.T) { + skipGetter := &mockGetter{ + policySkips: []*oapi.PolicySkip{ + {Id: "skip-1", RuleId: "rule-1", VersionId: "v1", CreatedAt: time.Now()}, + }, + } + e := &mockEvaluator{ + result: denyResult(), + scopeFields: evaluator.ScopeVersion, + ruleID: "rule-1", + } + versions := []*oapi.DeploymentVersion{version("v1")} + got, err := ListDeployableVersions( + ctx, + skipGetter, + rt, + iterVersions(versions), + []evaluator.Evaluator{e}, + fullScope(), + ) + require.NoError(t, err) + assert.Equal(t, []string{"v1"}, versionIDs(got)) + }) + + t.Run("returns error when GetPolicySkips fails", func(t *testing.T) { + errGetter := &mockGetter{policySkipsErr: errors.New("db connection failed")} + versions := []*oapi.DeploymentVersion{version("v1")} + evals := []evaluator.Evaluator{ + &mockEvaluator{result: allowResult(), scopeFields: evaluator.ScopeVersion}, + } + got, err := ListDeployableVersions( + ctx, + errGetter, + rt, + iterVersions(versions), + evals, + fullScope(), + ) + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "get policy skips") + }) + + t.Run("returns error when iterator yields an error", func(t *testing.T) { + failingIter := func(yield func(*oapi.DeploymentVersion, error) bool) { + if !yield(version("v1"), nil) { + return + } + yield(nil, errors.New("iter blew up")) + } + evals := []evaluator.Evaluator{ + &mockEvaluator{result: allowResult(), scopeFields: evaluator.ScopeVersion}, + } + got, err := ListDeployableVersions(ctx, getter, rt, failingIter, evals, fullScope()) + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "iter candidate versions") + }) +} + // --------------------------------------------------------------------------- // Conditional evaluator (version-dependent mock) // --------------------------------------------------------------------------- diff --git a/apps/workspace-engine/svc/http/server/openapi/release_targets/eligible_versions.go b/apps/workspace-engine/svc/http/server/openapi/release_targets/eligible_versions.go new file mode 100644 index 000000000..cf98ced08 --- /dev/null +++ b/apps/workspace-engine/svc/http/server/openapi/release_targets/eligible_versions.go @@ -0,0 +1,149 @@ +package release_targets + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "workspace-engine/pkg/celutil" + "workspace-engine/pkg/db" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/store/policies" + "workspace-engine/pkg/store/releasetargets" + "workspace-engine/svc/controllers/desiredrelease" + "workspace-engine/svc/controllers/desiredrelease/policyeval" +) + +func (rt *ReleaseTargets) ListEligibleVersionsForReleaseTarget( + c *gin.Context, + workspaceId string, + releaseTargetKey string, + params oapi.ListEligibleVersionsForReleaseTargetParams, +) { + ctx := c.Request.Context() + + target, err := parseReleaseTargetKey(releaseTargetKey) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + workspaceUUID, err := uuid.Parse(workspaceId) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id: " + err.Error()}) + return + } + + var body oapi.ListEligibleVersionsForReleaseTargetJSONBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()}) + return + } + + filter := "true" + if body.Filter != nil && *body.Filter != "" { + filter = *body.Filter + } + + celEnv, err := celutil.NewEnvBuilder(). + WithMapVariables("version"). + WithStandardExtensions(). + BuildCached(12 * time.Hour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build CEL env: " + err.Error()}) + return + } + if err := celEnv.Validate(filter); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filter expression: " + err.Error()}) + return + } + prg, err := celEnv.Compile(filter) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filter expression: " + err.Error()}) + return + } + + drt := &desiredrelease.ReleaseTarget{WorkspaceID: workspaceUUID} + if err := drt.FromOapi(&target); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + getter := desiredrelease.NewPostgresGetter( + db.GetQueries(ctx), + releasetargets.NewGetReleaseTargetsForDeployment(), + releasetargets.NewGetReleaseTargetsForDeploymentAndEnvironment(), + policies.NewPostgresGetPoliciesForReleaseTarget(), + releasetargets.NewGetJobsForReleaseTarget(), + ) + + exists, err := getter.ReleaseTargetExists(ctx, drt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "check release target exists: " + err.Error()}) + return + } + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "release target not found"}) + return + } + + scope, err := getter.GetReleaseTargetScope(ctx, drt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "get release target scope: " + err.Error()}) + return + } + + oapiRT := drt.ToOAPI() + rtPolicies, err := getter.GetPoliciesForReleaseTarget(ctx, oapiRT) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "get policies: " + err.Error()}) + return + } + + evals := policyeval.CollectEvaluators(ctx, getter, oapiRT, rtPolicies) + versions := getter.IterCandidateVersions(ctx, drt.DeploymentID, nil, nil) + + eligible, err := policyeval.ListDeployableVersions(ctx, getter, oapiRT, versions, evals, *scope) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "list deployable versions: " + err.Error()}) + return + } + + filtered := make([]*oapi.DeploymentVersion, 0, len(eligible)) + for _, v := range eligible { + vMap, err := celutil.EntityToMap(v) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "convert version: " + err.Error()}) + return + } + ok, err := celutil.EvalBool(prg, map[string]any{"version": vMap}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "evaluate filter: " + err.Error()}) + return + } + if ok { + filtered = append(filtered, v) + } + } + + limit := 50 + if params.Limit != nil { + limit = *params.Limit + } + offset := 0 + if params.Offset != nil { + offset = *params.Offset + } + + total := len(filtered) + start := min(offset, total) + end := min(start+limit, total) + + c.JSON(http.StatusOK, gin.H{ + "items": filtered[start:end], + "total": total, + "limit": limit, + "offset": offset, + }) +} diff --git a/e2e/api/schema.ts b/e2e/api/schema.ts index 75608ce65..144dd5195 100644 --- a/e2e/api/schema.ts +++ b/e2e/api/schema.ts @@ -655,6 +655,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * List versions eligible for a release target + * @description Returns deployment versions that currently pass every policy rule for this release target. An optional CEL filter narrows the result; pagination is applied to the filtered set. Use the "version" variable in the CEL expression to access version properties. + */ + post: operations["listEligibleVersionsForReleaseTarget"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/jobs": { parameters: { query?: never; @@ -4984,6 +5004,69 @@ export interface operations { }; }; }; + listEligibleVersionsForReleaseTarget: { + parameters: { + query?: { + /** @description Maximum number of items to return */ + limit?: number; + /** @description Number of items to skip */ + offset?: number; + }; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description Key of the release target */ + releaseTargetKey: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description CEL expression to filter eligible versions. Defaults to "true" (all eligible versions). */ + filter?: string; + }; + }; + }; + responses: { + /** @description Eligible versions for the release target */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["DeploymentVersion"][]; + /** @description Maximum number of items returned */ + limit: number; + /** @description Number of items skipped */ + offset: number; + /** @description Total number of items available */ + total: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getJobsForReleaseTarget: { parameters: { query?: { diff --git a/e2e/package.json b/e2e/package.json index 12098f0cd..9f9f7937c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -21,6 +21,7 @@ "test:deployment-version-list": "pnpm exec playwright test --project=api-tests tests/api/deployment-version-list.spec.ts", "test:deployment-version-list:heavy": "RUN_HEAVY_TESTS=1 pnpm exec playwright test --project=api-tests tests/api/deployment-version-list.spec.ts", "test:release-targets": "pnpm exec playwright test tests/api/release-targets.spec.ts", + "test:release-target-eligible-versions": "pnpm exec playwright test --project=api-tests tests/api/release-target-eligible-versions.spec.ts", "test:yaml": "pnpm exec playwright test tests/api/yaml-import.spec.ts", "test:yaml-prefixed": "pnpm exec playwright test tests/api/random-prefix-yaml.spec.ts", "test:yaml-template": "pnpm exec playwright test tests/api/template-yaml.spec.ts", diff --git a/e2e/tests/api/release-target-eligible-versions.spec.ts b/e2e/tests/api/release-target-eligible-versions.spec.ts new file mode 100644 index 000000000..fb1abff3d --- /dev/null +++ b/e2e/tests/api/release-target-eligible-versions.spec.ts @@ -0,0 +1,293 @@ +import { faker } from "@faker-js/faker"; +import { expect } from "@playwright/test"; + +import { eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + +import { test } from "../fixtures"; + +test.describe("Release Target Eligible Versions API", () => { + let systemId: string; + let deploymentId: string; + let environmentId: string; + let resourceId: string; + let releaseTargetKey: string; + + test.beforeAll(async ({ api, workspace }) => { + const systemRes = await api.POST("/v1/workspaces/{workspaceId}/systems", { + params: { path: { workspaceId: workspace.id } }, + body: { name: `evt-system-${faker.string.alphanumeric(8)}` }, + }); + expect(systemRes.response.status).toBe(202); + systemId = systemRes.data!.id; + + const depName = `evt-dep-${faker.string.alphanumeric(8)}`; + const depRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments", + { + params: { path: { workspaceId: workspace.id } }, + body: { name: depName, slug: depName }, + }, + ); + expect(depRes.response.status).toBe(202); + deploymentId = depRes.data!.id; + await api.PUT( + "/v1/workspaces/{workspaceId}/systems/{systemId}/deployments/{deploymentId}", + { + params: { + path: { workspaceId: workspace.id, systemId, deploymentId }, + }, + }, + ); + + const envRes = await api.POST( + "/v1/workspaces/{workspaceId}/environments", + { + params: { path: { workspaceId: workspace.id } }, + body: { name: `evt-env-${faker.string.alphanumeric(8)}` }, + }, + ); + expect(envRes.response.status).toBe(202); + environmentId = envRes.data!.id; + await api.PUT( + "/v1/workspaces/{workspaceId}/systems/{systemId}/environments/{environmentId}", + { + params: { + path: { workspaceId: workspace.id, systemId, environmentId }, + }, + }, + ); + + const identifier = `evt-res-${faker.string.alphanumeric(8)}`; + const putRes = await api.PUT( + "/v1/workspaces/{workspaceId}/resources/identifier/{identifier}", + { + params: { path: { workspaceId: workspace.id, identifier } }, + body: { + name: identifier, + kind: "TestKind", + version: "1.0.0", + config: {}, + metadata: {}, + }, + }, + ); + expect(putRes.response.status).toBe(202); + + const getResourceRes = await api.GET( + "/v1/workspaces/{workspaceId}/resources/identifier/{identifier}", + { params: { path: { workspaceId: workspace.id, identifier } } }, + ); + expect(getResourceRes.response.status).toBe(200); + resourceId = getResourceRes.data!.id; + + // Bypass reconciliation: insert computed_*_resource link rows directly so + // the (resource, environment, deployment) triple is a valid release target + // without waiting on the workspace-engine to fan things out. + const now = new Date(); + await db + .insert(schema.computedDeploymentResource) + .values({ deploymentId, resourceId, lastEvaluatedAt: now }); + await db + .insert(schema.computedEnvironmentResource) + .values({ environmentId, resourceId, lastEvaluatedAt: now }); + + releaseTargetKey = `${resourceId}-${environmentId}-${deploymentId}`; + }); + + test.afterAll(async ({ api, workspace }) => { + // FK cascade from deployment → deployment_version was dropped in migration + // 0139 (see deployment-version-list.spec.ts), so versions need explicit + // delete before the deployment goes. + await db + .delete(schema.deploymentVersion) + .where(eq(schema.deploymentVersion.deploymentId, deploymentId)); + + await api.DELETE("/v1/workspaces/{workspaceId}/systems/{systemId}", { + params: { path: { workspaceId: workspace.id, systemId } }, + }); + }); + + test.afterEach(async () => { + await db + .delete(schema.deploymentVersion) + .where(eq(schema.deploymentVersion.deploymentId, deploymentId)); + }); + + const insertVersions = async ( + workspaceId: string, + versions: { tag: string }[], + ) => { + const baseTime = Date.now(); + await db.insert(schema.deploymentVersion).values( + versions.map((v, i) => ({ + name: v.tag, + tag: v.tag, + deploymentId, + workspaceId, + createdAt: new Date(baseTime + i * 1000), + })), + ); + }; + + test("returns all versions when no policies block", async ({ + api, + workspace, + }) => { + await insertVersions(workspace.id, [ + { tag: "v1.0.0" }, + { tag: "v1.1.0" }, + { tag: "v2.0.0" }, + ]); + + const res = await api.POST( + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions", + { + params: { path: { workspaceId: workspace.id, releaseTargetKey } }, + body: {}, + }, + ); + + expect(res.response.status).toBe(200); + expect(res.data!.total).toBe(3); + expect(res.data!.items.map((v) => v.tag).sort()).toEqual([ + "v1.0.0", + "v1.1.0", + "v2.0.0", + ]); + }); + + test("excludes versions blocked by a versionSelector policy", async ({ + api, + workspace, + }) => { + await insertVersions(workspace.id, [ + { tag: "v1.0.0" }, + { tag: "v1.1.0" }, + { tag: "v2.0.0" }, + ]); + + const policyRes = await api.POST( + "/v1/workspaces/{workspaceId}/policies", + { + params: { path: { workspaceId: workspace.id } }, + body: { + name: `evt-policy-${faker.string.alphanumeric(8)}`, + selector: "true", + rules: [ + { + versionSelector: { + selector: 'version.tag.startsWith("v1.")', + description: "v1.x only", + }, + }, + ], + }, + }, + ); + expect(policyRes.response.status).toBe(202); + const policyId = policyRes.data!.id; + + try { + const res = await api.POST( + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions", + { + params: { path: { workspaceId: workspace.id, releaseTargetKey } }, + body: {}, + }, + ); + + expect(res.response.status).toBe(200); + expect(res.data!.total).toBe(2); + expect(res.data!.items.map((v) => v.tag).sort()).toEqual([ + "v1.0.0", + "v1.1.0", + ]); + } finally { + await api.DELETE("/v1/workspaces/{workspaceId}/policies/{policyId}", { + params: { path: { workspaceId: workspace.id, policyId } }, + }); + } + }); + + test("CEL filter narrows the eligible set", async ({ api, workspace }) => { + await insertVersions(workspace.id, [ + { tag: "v1.0.0" }, + { tag: "v1.1.0" }, + { tag: "v2.0.0" }, + ]); + + const res = await api.POST( + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions", + { + params: { path: { workspaceId: workspace.id, releaseTargetKey } }, + body: { filter: 'version.tag.startsWith("v1.")' }, + }, + ); + + expect(res.response.status).toBe(200); + expect(res.data!.total).toBe(2); + expect(res.data!.items.map((v) => v.tag).sort()).toEqual([ + "v1.0.0", + "v1.1.0", + ]); + }); + + test("pagination slices the filtered set, total reflects full count", async ({ + api, + workspace, + }) => { + await insertVersions( + workspace.id, + Array.from({ length: 5 }, (_, i) => ({ tag: `v1.0.${i}` })), + ); + + const res = await api.POST( + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions", + { + params: { + path: { workspaceId: workspace.id, releaseTargetKey }, + query: { limit: 2, offset: 2 }, + }, + body: {}, + }, + ); + + expect(res.response.status).toBe(200); + expect(res.data!.total).toBe(5); + expect(res.data!.limit).toBe(2); + expect(res.data!.offset).toBe(2); + expect(res.data!.items).toHaveLength(2); + }); + + test("returns 404 for a non-existent release target", async ({ + api, + workspace, + }) => { + const fakeKey = `${faker.string.uuid()}-${faker.string.uuid()}-${faker.string.uuid()}`; + const res = await api.POST( + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions", + { + params: { + path: { workspaceId: workspace.id, releaseTargetKey: fakeKey }, + }, + body: {}, + }, + ); + + expect(res.response.status).toBe(404); + }); + + test("returns 400 for a malformed CEL filter", async ({ api, workspace }) => { + const res = await api.POST( + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions", + { + params: { path: { workspaceId: workspace.id, releaseTargetKey } }, + body: { filter: "this is not cel @#$%" }, + }, + ); + + expect(res.response.status).toBe(400); + }); +}); diff --git a/packages/workspace-engine-sdk/src/schema.ts b/packages/workspace-engine-sdk/src/schema.ts index eb2f1a476..470b81c05 100644 --- a/packages/workspace-engine-sdk/src/schema.ts +++ b/packages/workspace-engine-sdk/src/schema.ts @@ -92,6 +92,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/eligible-versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * List versions eligible for a release target + * @description Returns deployment versions that currently pass every policy rule for this release target. An optional CEL filter narrows the result; pagination is applied to the filtered set. Use the "version" variable in the CEL expression to access version properties. + */ + post: operations["listEligibleVersionsForReleaseTarget"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/state": { parameters: { query?: never; @@ -1602,6 +1622,69 @@ export interface operations { }; }; }; + listEligibleVersionsForReleaseTarget: { + parameters: { + query?: { + /** @description Maximum number of items to return */ + limit?: number; + /** @description Number of items to skip */ + offset?: number; + }; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description Key of the release target */ + releaseTargetKey: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description CEL expression to filter eligible versions. Defaults to "true" (all eligible versions). */ + filter?: string; + }; + }; + }; + responses: { + /** @description Eligible versions for the release target */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["DeploymentVersion"][]; + /** @description Maximum number of items returned */ + limit: number; + /** @description Number of items skipped */ + offset: number; + /** @description Total number of items available */ + total: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getReleaseTargetState: { parameters: { query?: never; From 41eb16f08b6ded3dc41105c3ac200854923ec28b Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Mon, 18 May 2026 17:22:24 -0400 Subject: [PATCH 2/2] cleanup --- .../policyeval/policyeval_test.go | 7 +- .../release_targets/eligible_versions.go | 88 +++++++++++++------ 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go index 0dac796b5..c689494b0 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go @@ -1010,7 +1010,12 @@ func TestListDeployableVersions(t *testing.T) { fullScope(), ) require.NoError(t, err) - assert.Equal(t, []string{"v1", "v2", "v3"}, seen, "evaluator must be called for every version") + assert.Equal( + t, + []string{"v1", "v2", "v3"}, + seen, + "evaluator must be called for every version", + ) assert.Equal(t, []string{"v1", "v2", "v3"}, versionIDs(got)) }) diff --git a/apps/workspace-engine/svc/http/server/openapi/release_targets/eligible_versions.go b/apps/workspace-engine/svc/http/server/openapi/release_targets/eligible_versions.go index cf98ced08..ad2a5da71 100644 --- a/apps/workspace-engine/svc/http/server/openapi/release_targets/eligible_versions.go +++ b/apps/workspace-engine/svc/http/server/openapi/release_targets/eligible_versions.go @@ -1,10 +1,12 @@ package release_targets import ( + "iter" "net/http" "time" "github.com/gin-gonic/gin" + "github.com/google/cel-go/cel" "github.com/google/uuid" "workspace-engine/pkg/celutil" "workspace-engine/pkg/db" @@ -15,6 +17,40 @@ import ( "workspace-engine/svc/controllers/desiredrelease/policyeval" ) +// filterVersionsByCEL wraps a candidate-version iterator with a CEL predicate +// so policy evaluation only runs on versions the user is actually searching +// for. Per-version eval errors (post-Compile runtime issues) skip silently — +// malformed expressions are caught at compile time before this is called. +func filterVersionsByCEL( + in iter.Seq2[*oapi.DeploymentVersion, error], + prg cel.Program, +) iter.Seq2[*oapi.DeploymentVersion, error] { + return func(yield func(*oapi.DeploymentVersion, error) bool) { + for v, err := range in { + if err != nil { + if !yield(nil, err) { + return + } + continue + } + vMap, mapErr := celutil.EntityToMap(v) + if mapErr != nil { + if !yield(nil, mapErr) { + return + } + continue + } + ok, evalErr := celutil.EvalBool(prg, map[string]any{"version": vMap}) + if evalErr != nil || !ok { + continue + } + if !yield(v, nil) { + return + } + } + } +} + func (rt *ReleaseTargets) ListEligibleVersionsForReleaseTarget( c *gin.Context, workspaceId string, @@ -51,11 +87,10 @@ func (rt *ReleaseTargets) ListEligibleVersionsForReleaseTarget( WithStandardExtensions(). BuildCached(12 * time.Hour) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build CEL env: " + err.Error()}) - return - } - if err := celEnv.Validate(filter); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filter expression: " + err.Error()}) + c.JSON( + http.StatusInternalServerError, + gin.H{"error": "failed to build CEL env: " + err.Error()}, + ) return } prg, err := celEnv.Compile(filter) @@ -80,7 +115,10 @@ func (rt *ReleaseTargets) ListEligibleVersionsForReleaseTarget( exists, err := getter.ReleaseTargetExists(ctx, drt) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "check release target exists: " + err.Error()}) + c.JSON( + http.StatusInternalServerError, + gin.H{"error": "check release target exists: " + err.Error()}, + ) return } if !exists { @@ -90,7 +128,10 @@ func (rt *ReleaseTargets) ListEligibleVersionsForReleaseTarget( scope, err := getter.GetReleaseTargetScope(ctx, drt) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "get release target scope: " + err.Error()}) + c.JSON( + http.StatusInternalServerError, + gin.H{"error": "get release target scope: " + err.Error()}, + ) return } @@ -102,31 +143,20 @@ func (rt *ReleaseTargets) ListEligibleVersionsForReleaseTarget( } evals := policyeval.CollectEvaluators(ctx, getter, oapiRT, rtPolicies) - versions := getter.IterCandidateVersions(ctx, drt.DeploymentID, nil, nil) + versions := filterVersionsByCEL( + getter.IterCandidateVersions(ctx, drt.DeploymentID, nil, nil), + prg, + ) - eligible, err := policyeval.ListDeployableVersions(ctx, getter, oapiRT, versions, evals, *scope) + filtered, err := policyeval.ListDeployableVersions(ctx, getter, oapiRT, versions, evals, *scope) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "list deployable versions: " + err.Error()}) + c.JSON( + http.StatusInternalServerError, + gin.H{"error": "list deployable versions: " + err.Error()}, + ) return } - filtered := make([]*oapi.DeploymentVersion, 0, len(eligible)) - for _, v := range eligible { - vMap, err := celutil.EntityToMap(v) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "convert version: " + err.Error()}) - return - } - ok, err := celutil.EvalBool(prg, map[string]any{"version": vMap}) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "evaluate filter: " + err.Error()}) - return - } - if ok { - filtered = append(filtered, v) - } - } - limit := 50 if params.Limit != nil { limit = *params.Limit @@ -135,6 +165,10 @@ func (rt *ReleaseTargets) ListEligibleVersionsForReleaseTarget( if params.Offset != nil { offset = *params.Offset } + if limit < 0 || offset < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "limit and offset must be non-negative"}) + return + } total := len(filtered) start := min(offset, total)