Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@
"DeploymentDependencyRule": {
"properties": {
"dependsOn": {
"description": "CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed.",
"description": "CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. The expression can reference both deployment properties (deployment.id, deployment.name, deployment.slug, deployment.metadata) and the currently deployed version properties (version.id, version.tag, version.name, version.status, version.metadata, version.createdAt). For example: deployment.name == 'db-migration' && version.tag.startsWith('v2.').",
"type": "string"
}
},
Expand Down
2 changes: 1 addition & 1 deletion apps/api/openapi/schemas/policies.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ local openapi = import '../lib/openapi.libsonnet';
properties: {
dependsOn: {
type: 'string',
description: 'CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed.',
description: 'CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. The expression can reference both deployment properties (deployment.id, deployment.name, deployment.slug, deployment.metadata) and the currently deployed version properties (version.id, version.tag, version.name, version.status, version.metadata, version.createdAt). For example: deployment.name == \'db-migration\' && version.tag.startsWith(\'v2.\').',
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/types/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,7 +1236,7 @@ export interface components {
systems: components["schemas"]["System"][];
};
DeploymentDependencyRule: {
/** @description CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. */
/** @description CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. The expression can reference both deployment properties (deployment.id, deployment.name, deployment.slug, deployment.metadata) and the currently deployed version properties (version.id, version.tag, version.name, version.status, version.metadata, version.createdAt). For example: deployment.name == 'db-migration' && version.tag.startsWith('v2.'). */
dependsOn: string;
};
DeploymentPlan: {
Expand Down
2 changes: 1 addition & 1 deletion apps/workspace-engine/oapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@
"DeploymentDependencyRule": {
"properties": {
"dependsOn": {
"description": "CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed.",
"description": "CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. The expression can reference both deployment properties (deployment.id, deployment.name, deployment.slug, deployment.metadata) and the currently deployed version properties (version.id, version.tag, version.name, version.status, version.metadata, version.createdAt). For example: deployment.name == 'db-migration' && version.tag.startsWith('v2.').",
"type": "string"
}
},
Expand Down
2 changes: 1 addition & 1 deletion apps/workspace-engine/oapi/spec/schemas/policy.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ local openapi = import '../lib/openapi.libsonnet';
properties: {
dependsOn: {
type: 'string',
description: 'CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed.',
description: "CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. The expression can reference both deployment properties (deployment.id, deployment.name, deployment.slug, deployment.metadata) and the currently deployed version properties (version.id, version.tag, version.name, version.status, version.metadata, version.createdAt). For example: deployment.name == 'db-migration' && version.tag.startsWith('v2.').",
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion apps/workspace-engine/pkg/oapi/oapi.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 36 additions & 1 deletion apps/workspace-engine/pkg/selector/langs/cel/cel.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
)

var compiledEnv, _ = celutil.NewEnvBuilder().
WithMapVariables("resource", "deployment", "environment").
WithMapVariables("resource", "deployment", "environment", "version").
WithStandardExtensions().
BuildCached(12 * time.Hour)

Expand Down Expand Up @@ -47,6 +47,7 @@ func (s *CelSelector) Matches(entity any) (bool, error) {
"resource": map[string]any{},
"deployment": map[string]any{},
"environment": map[string]any{},
"version": map[string]any{},
}

entityAsMap, err := structToMap(entity)
Expand Down Expand Up @@ -78,6 +79,12 @@ func (s *CelSelector) Matches(entity any) (bool, error) {
celCtx["job"] = entityAsMap
}

_, isPointerVersion := entity.(*oapi.DeploymentVersion)
_, isVersion := entity.(oapi.DeploymentVersion)
if isPointerVersion || isVersion {
celCtx["version"] = entityAsMap
}

return celutil.EvalBool(s.Program, celCtx)
}

Expand All @@ -89,6 +96,7 @@ func BuildEntityContext(r *oapi.Resource, d *oapi.Deployment, e *oapi.Environmen
"resource": map[string]any{},
"deployment": map[string]any{},
"environment": map[string]any{},
"version": map[string]any{},
}
if r != nil {
ctx["resource"] = resourceToMap(r)
Expand All @@ -102,6 +110,11 @@ func BuildEntityContext(r *oapi.Resource, d *oapi.Deployment, e *oapi.Environmen
return ctx
}

// DeploymentVersionToMap converts a DeploymentVersion to a CEL-evaluable map.
func DeploymentVersionToMap(v *oapi.DeploymentVersion) map[string]any {
return deploymentVersionToMap(v)
}

// CompileProgram compiles a CEL expression into a Program using the shared
// cached environment. This is useful when callers need direct access to the
// compiled program for evaluation with a custom context.
Expand Down Expand Up @@ -130,6 +143,10 @@ func structToMap(v any) (map[string]any, error) {
return jobToMap(entity), nil
case oapi.Job:
return jobToMap(&entity), nil
case *oapi.DeploymentVersion:
return deploymentVersionToMap(entity), nil
case oapi.DeploymentVersion:
return deploymentVersionToMap(&entity), nil
}

return celutil.EntityToMap(v)
Expand Down Expand Up @@ -201,6 +218,24 @@ func environmentToMap(e *oapi.Environment) map[string]any {
return m
}

func deploymentVersionToMap(v *oapi.DeploymentVersion) map[string]any {
m := make(map[string]any, 8)
m["id"] = v.Id
m["name"] = v.Name
m["tag"] = v.Tag
m["deploymentId"] = v.DeploymentId
m["status"] = v.Status
m["createdAt"] = v.CreatedAt
m["metadata"] = v.Metadata
if v.Metadata == nil {
m["metadata"] = make(map[string]any)
}
if v.Message != nil {
m["message"] = *v.Message
}
return m
}

func jobToMap(j *oapi.Job) map[string]any {
m := make(map[string]any, 10)
m["id"] = j.Id
Expand Down
2 changes: 2 additions & 0 deletions apps/workspace-engine/pkg/selector/langs/cel/cel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ func TestBuildEntityContext_AllNil(t *testing.T) {
require.Contains(t, ctx, "resource")
require.Contains(t, ctx, "deployment")
require.Contains(t, ctx, "environment")
require.Contains(t, ctx, "version")

assert.Equal(t, map[string]any{}, ctx["resource"])
assert.Equal(t, map[string]any{}, ctx["deployment"])
assert.Equal(t, map[string]any{}, ctx["environment"])
assert.Equal(t, map[string]any{}, ctx["version"])
}

func TestBuildEntityContext_AllPopulated(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import (

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"workspace-engine/pkg/celutil"
"workspace-engine/pkg/oapi"
"workspace-engine/pkg/selector"
cel "workspace-engine/pkg/selector/langs/cel"
"workspace-engine/pkg/workspace/releasemanager/policy/evaluator"
"workspace-engine/pkg/workspace/releasemanager/policy/results"
)
Expand Down Expand Up @@ -48,61 +49,6 @@ func (e *DeploymentDependencyEvaluator) Complexity() int {
return 3
}

func (e *DeploymentDependencyEvaluator) findMatchingDeployments(
ctx context.Context,
scope evaluator.EvaluatorScope,
) ([]*oapi.Deployment, error) {
deploymentSelector := e.rule.DependsOn
deployments, err := e.getters.GetAllDeployments(ctx, scope.Environment.WorkspaceId)
if err != nil {
return nil, fmt.Errorf("failed to get deployments: %w", err)
}
matchingDeployments := make([]*oapi.Deployment, 0)
for _, deployment := range deployments {
matched, err := selector.Match(ctx, deploymentSelector, deployment)
if err != nil {
return nil, fmt.Errorf("failed to match deployment selector: %w", err)
}
if matched {
matchingDeployments = append(matchingDeployments, deployment)
}
}
return matchingDeployments, nil
}

func (e *DeploymentDependencyEvaluator) getUpstreamReleaseTargets(
ctx context.Context,
matchingDeployments []*oapi.Deployment,
resourceID string,
) []*oapi.ReleaseTarget {
upstreamReleaseTargets := make([]*oapi.ReleaseTarget, 0, len(matchingDeployments))
resourceTargets := e.getters.GetReleaseTargetsForResource(ctx, resourceID)
deploymentToTargetMap := make(map[string]*oapi.ReleaseTarget)

for _, resourceTarget := range resourceTargets {
deploymentToTargetMap[resourceTarget.DeploymentId] = resourceTarget
}

for _, matchingDeployment := range matchingDeployments {
if target, ok := deploymentToTargetMap[matchingDeployment.Id]; ok {
upstreamReleaseTargets = append(upstreamReleaseTargets, target)
}
}

return upstreamReleaseTargets
}

func (e *DeploymentDependencyEvaluator) checkUpstreamTargetHasSuccessfulRelease(
upstreamReleaseTarget *oapi.ReleaseTarget,
) bool {
latestJob := e.getters.GetLatestCompletedJobForReleaseTarget(upstreamReleaseTarget)
if latestJob == nil {
return false
}

return latestJob.Status == oapi.JobStatusSuccessful && latestJob.CompletedAt != nil
}

func (e *DeploymentDependencyEvaluator) Evaluate(
ctx context.Context,
scope evaluator.EvaluatorScope,
Expand All @@ -117,55 +63,78 @@ func (e *DeploymentDependencyEvaluator) Evaluate(
attribute.String("dependsOn", dependsOn),
)

matchingDeployments, err := e.findMatchingDeployments(ctx, scope)
program, err := cel.CompileProgram(dependsOn)
if err != nil {
span.RecordError(err)
return results.NewDeniedResult(
fmt.Sprintf("Deployment dependency: failed to find matching deployments: %v", err),
fmt.Sprintf("Deployment dependency: failed to compile selector: %v", err),
).
WithDetail("error", err.Error()).
WithDetail("deployment_id", scope.Deployment.Id)
}

if len(matchingDeployments) == 0 {
return results.NewDeniedResult(
fmt.Sprintf(
"Deployment dependency: no matching deployments found for selector: %v",
dependsOn,
),
).
WithDetail("depends_on", dependsOn)
}

upstreamReleaseTargets := e.getUpstreamReleaseTargets(
ctx,
matchingDeployments,
scope.Resource.Id,
)
if len(upstreamReleaseTargets) != cap(upstreamReleaseTargets) {
deployments, err := e.getters.GetAllDeployments(ctx, scope.Environment.WorkspaceId)
if err != nil {
span.RecordError(err)
return results.NewDeniedResult(
fmt.Sprintf(
"Deployment dependency: some upstream release targets not found for resource: %v",
scope.Resource.Id,
),
fmt.Sprintf("Deployment dependency: failed to get deployments: %v", err),
).
WithDetail("depends_on", dependsOn)
WithDetail("error", err.Error())
}

for _, upstreamReleaseTarget := range upstreamReleaseTargets {
if !e.checkUpstreamTargetHasSuccessfulRelease(upstreamReleaseTarget) {
return results.NewDeniedResult(
releaseTargets := e.getters.GetReleaseTargetsForResource(ctx, scope.Resource.Id)

var evalErrors []string
for _, rt := range releaseTargets {
if rt.DeploymentId == scope.Deployment.Id && rt.EnvironmentId == scope.Environment.Id &&
rt.ResourceId == scope.Resource.Id {
continue
}

deployment := deployments[rt.DeploymentId]
if deployment == nil {
continue
}

Comment on lines +85 to +98
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation does two DB lookups per release target (GetDeployment + GetCurrentlyDeployedVersion). If a resource has many release targets, this can become an N+1 query pattern during policy evaluation. Consider fetching deployments in bulk (e.g., GetAllDeployments once per workspace) and/or adding a batch query that returns {deployment, currentVersion} for all release targets on a resource.

Suggested change
releaseTargets := e.getters.GetReleaseTargetsForResource(ctx, scope.Resource.Id)
for _, rt := range releaseTargets {
deployment, err := e.getters.GetDeployment(ctx, rt.DeploymentId)
if err != nil || deployment == nil {
continue
}
releaseTargets := e.getters.GetReleaseTargetsForResource(ctx, scope.Resource.Id)
deploymentsByID := make(map[string]*oapi.Deployment, len(releaseTargets))
for _, rt := range releaseTargets {
if _, ok := deploymentsByID[rt.DeploymentId]; ok {
continue
}
deployment, err := e.getters.GetDeployment(ctx, rt.DeploymentId)
if err != nil || deployment == nil {
continue
}
deploymentsByID[rt.DeploymentId] = deployment
}
for _, rt := range releaseTargets {
deployment, ok := deploymentsByID[rt.DeploymentId]
if !ok || deployment == nil {
continue
}

Copilot uses AI. Check for mistakes.
version := e.getters.GetCurrentlyDeployedVersion(ctx, rt)
if version == nil {
continue
}

celCtx := cel.BuildEntityContext(nil, deployment, nil)
celCtx["version"] = cel.DeploymentVersionToMap(version)
matched, err := celutil.EvalBool(program, celCtx)
if err != nil {
span.RecordError(err)
evalErrors = append(
evalErrors,
fmt.Sprintf("rt %s: CEL evaluation error: %v", rt.Key(), err),
)
continue
}
Comment on lines +88 to +114
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors from GetDeployment / GetCurrentlyDeployedVersion / CEL evaluation are silently ignored via continue. When the selector is invalid for the provided context (e.g., missing fields) or the DB call fails, the final denied message becomes misleading (“no upstream…matches”) and makes debugging difficult. Consider recording these errors (span/log) and/or returning a denied result that includes the first evaluation error and the release target key.

Copilot uses AI. Check for mistakes.

if matched {
return results.NewAllowedResult(
fmt.Sprintf(
"Deployment dependency: upstream release target %s has no successful release",
upstreamReleaseTarget.Key(),
"Deployment dependency: upstream %s has matching deployed version %s",
deployment.Name,
version.Tag,
),
).
WithDetail("upstream_release_target_key", upstreamReleaseTarget.Key())
)
}
}

return results.
NewAllowedResult(
"Deployment dependency: all upstream release targets have successful releases",
)
result := results.NewDeniedResult(
fmt.Sprintf(
"Deployment dependency: no upstream release target with a successful release matches selector: %s",
dependsOn,
),
).
WithDetail("depends_on", dependsOn)

if len(evalErrors) > 0 {
result = result.WithDetail("errors", fmt.Sprintf("%v", evalErrors))
}

return result
}
Loading
Loading