Skip to content

Commit 33ebbed

Browse files
feat: deployment dependency can also filter by versions (#964)
1 parent d05d8bb commit 33ebbed

File tree

19 files changed

+535
-336
lines changed

19 files changed

+535
-336
lines changed

apps/api/openapi/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@
498498
"DeploymentDependencyRule": {
499499
"properties": {
500500
"dependsOn": {
501-
"description": "CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed.",
501+
"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.').",
502502
"type": "string"
503503
}
504504
},

apps/api/openapi/schemas/policies.jsonnet

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ local openapi = import '../lib/openapi.libsonnet';
220220
properties: {
221221
dependsOn: {
222222
type: 'string',
223-
description: 'CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed.',
223+
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.\').',
224224
},
225225
},
226226
},

apps/api/src/types/openapi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1236,7 +1236,7 @@ export interface components {
12361236
systems: components["schemas"]["System"][];
12371237
};
12381238
DeploymentDependencyRule: {
1239-
/** @description CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. */
1239+
/** @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.'). */
12401240
dependsOn: string;
12411241
};
12421242
DeploymentPlan: {

apps/workspace-engine/oapi/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@
284284
"DeploymentDependencyRule": {
285285
"properties": {
286286
"dependsOn": {
287-
"description": "CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed.",
287+
"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.').",
288288
"type": "string"
289289
}
290290
},

apps/workspace-engine/oapi/spec/schemas/policy.jsonnet

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ local openapi = import '../lib/openapi.libsonnet';
172172
properties: {
173173
dependsOn: {
174174
type: 'string',
175-
description: 'CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed.',
175+
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.').",
176176
},
177177
},
178178
},

apps/workspace-engine/pkg/oapi/oapi.gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/workspace-engine/pkg/selector/langs/cel/cel.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
)
1212

1313
var compiledEnv, _ = celutil.NewEnvBuilder().
14-
WithMapVariables("resource", "deployment", "environment").
14+
WithMapVariables("resource", "deployment", "environment", "version").
1515
WithStandardExtensions().
1616
BuildCached(12 * time.Hour)
1717

@@ -47,6 +47,7 @@ func (s *CelSelector) Matches(entity any) (bool, error) {
4747
"resource": map[string]any{},
4848
"deployment": map[string]any{},
4949
"environment": map[string]any{},
50+
"version": map[string]any{},
5051
}
5152

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

82+
_, isPointerVersion := entity.(*oapi.DeploymentVersion)
83+
_, isVersion := entity.(oapi.DeploymentVersion)
84+
if isPointerVersion || isVersion {
85+
celCtx["version"] = entityAsMap
86+
}
87+
8188
return celutil.EvalBool(s.Program, celCtx)
8289
}
8390

@@ -89,6 +96,7 @@ func BuildEntityContext(r *oapi.Resource, d *oapi.Deployment, e *oapi.Environmen
8996
"resource": map[string]any{},
9097
"deployment": map[string]any{},
9198
"environment": map[string]any{},
99+
"version": map[string]any{},
92100
}
93101
if r != nil {
94102
ctx["resource"] = resourceToMap(r)
@@ -102,6 +110,11 @@ func BuildEntityContext(r *oapi.Resource, d *oapi.Deployment, e *oapi.Environmen
102110
return ctx
103111
}
104112

113+
// DeploymentVersionToMap converts a DeploymentVersion to a CEL-evaluable map.
114+
func DeploymentVersionToMap(v *oapi.DeploymentVersion) map[string]any {
115+
return deploymentVersionToMap(v)
116+
}
117+
105118
// CompileProgram compiles a CEL expression into a Program using the shared
106119
// cached environment. This is useful when callers need direct access to the
107120
// compiled program for evaluation with a custom context.
@@ -130,6 +143,10 @@ func structToMap(v any) (map[string]any, error) {
130143
return jobToMap(entity), nil
131144
case oapi.Job:
132145
return jobToMap(&entity), nil
146+
case *oapi.DeploymentVersion:
147+
return deploymentVersionToMap(entity), nil
148+
case oapi.DeploymentVersion:
149+
return deploymentVersionToMap(&entity), nil
133150
}
134151

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

221+
func deploymentVersionToMap(v *oapi.DeploymentVersion) map[string]any {
222+
m := make(map[string]any, 8)
223+
m["id"] = v.Id
224+
m["name"] = v.Name
225+
m["tag"] = v.Tag
226+
m["deploymentId"] = v.DeploymentId
227+
m["status"] = v.Status
228+
m["createdAt"] = v.CreatedAt
229+
m["metadata"] = v.Metadata
230+
if v.Metadata == nil {
231+
m["metadata"] = make(map[string]any)
232+
}
233+
if v.Message != nil {
234+
m["message"] = *v.Message
235+
}
236+
return m
237+
}
238+
204239
func jobToMap(j *oapi.Job) map[string]any {
205240
m := make(map[string]any, 10)
206241
m["id"] = j.Id

apps/workspace-engine/pkg/selector/langs/cel/cel_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ func TestBuildEntityContext_AllNil(t *testing.T) {
1515
require.Contains(t, ctx, "resource")
1616
require.Contains(t, ctx, "deployment")
1717
require.Contains(t, ctx, "environment")
18+
require.Contains(t, ctx, "version")
1819

1920
assert.Equal(t, map[string]any{}, ctx["resource"])
2021
assert.Equal(t, map[string]any{}, ctx["deployment"])
2122
assert.Equal(t, map[string]any{}, ctx["environment"])
23+
assert.Equal(t, map[string]any{}, ctx["version"])
2224
}
2325

2426
func TestBuildEntityContext_AllPopulated(t *testing.T) {

apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go

Lines changed: 59 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import (
66

77
"go.opentelemetry.io/otel"
88
"go.opentelemetry.io/otel/attribute"
9+
"workspace-engine/pkg/celutil"
910
"workspace-engine/pkg/oapi"
10-
"workspace-engine/pkg/selector"
11+
cel "workspace-engine/pkg/selector/langs/cel"
1112
"workspace-engine/pkg/workspace/releasemanager/policy/evaluator"
1213
"workspace-engine/pkg/workspace/releasemanager/policy/results"
1314
)
@@ -48,61 +49,6 @@ func (e *DeploymentDependencyEvaluator) Complexity() int {
4849
return 3
4950
}
5051

51-
func (e *DeploymentDependencyEvaluator) findMatchingDeployments(
52-
ctx context.Context,
53-
scope evaluator.EvaluatorScope,
54-
) ([]*oapi.Deployment, error) {
55-
deploymentSelector := e.rule.DependsOn
56-
deployments, err := e.getters.GetAllDeployments(ctx, scope.Environment.WorkspaceId)
57-
if err != nil {
58-
return nil, fmt.Errorf("failed to get deployments: %w", err)
59-
}
60-
matchingDeployments := make([]*oapi.Deployment, 0)
61-
for _, deployment := range deployments {
62-
matched, err := selector.Match(ctx, deploymentSelector, deployment)
63-
if err != nil {
64-
return nil, fmt.Errorf("failed to match deployment selector: %w", err)
65-
}
66-
if matched {
67-
matchingDeployments = append(matchingDeployments, deployment)
68-
}
69-
}
70-
return matchingDeployments, nil
71-
}
72-
73-
func (e *DeploymentDependencyEvaluator) getUpstreamReleaseTargets(
74-
ctx context.Context,
75-
matchingDeployments []*oapi.Deployment,
76-
resourceID string,
77-
) []*oapi.ReleaseTarget {
78-
upstreamReleaseTargets := make([]*oapi.ReleaseTarget, 0, len(matchingDeployments))
79-
resourceTargets := e.getters.GetReleaseTargetsForResource(ctx, resourceID)
80-
deploymentToTargetMap := make(map[string]*oapi.ReleaseTarget)
81-
82-
for _, resourceTarget := range resourceTargets {
83-
deploymentToTargetMap[resourceTarget.DeploymentId] = resourceTarget
84-
}
85-
86-
for _, matchingDeployment := range matchingDeployments {
87-
if target, ok := deploymentToTargetMap[matchingDeployment.Id]; ok {
88-
upstreamReleaseTargets = append(upstreamReleaseTargets, target)
89-
}
90-
}
91-
92-
return upstreamReleaseTargets
93-
}
94-
95-
func (e *DeploymentDependencyEvaluator) checkUpstreamTargetHasSuccessfulRelease(
96-
upstreamReleaseTarget *oapi.ReleaseTarget,
97-
) bool {
98-
latestJob := e.getters.GetLatestCompletedJobForReleaseTarget(upstreamReleaseTarget)
99-
if latestJob == nil {
100-
return false
101-
}
102-
103-
return latestJob.Status == oapi.JobStatusSuccessful && latestJob.CompletedAt != nil
104-
}
105-
10652
func (e *DeploymentDependencyEvaluator) Evaluate(
10753
ctx context.Context,
10854
scope evaluator.EvaluatorScope,
@@ -117,55 +63,78 @@ func (e *DeploymentDependencyEvaluator) Evaluate(
11763
attribute.String("dependsOn", dependsOn),
11864
)
11965

120-
matchingDeployments, err := e.findMatchingDeployments(ctx, scope)
66+
program, err := cel.CompileProgram(dependsOn)
12167
if err != nil {
12268
span.RecordError(err)
12369
return results.NewDeniedResult(
124-
fmt.Sprintf("Deployment dependency: failed to find matching deployments: %v", err),
70+
fmt.Sprintf("Deployment dependency: failed to compile selector: %v", err),
12571
).
12672
WithDetail("error", err.Error()).
127-
WithDetail("deployment_id", scope.Deployment.Id)
128-
}
129-
130-
if len(matchingDeployments) == 0 {
131-
return results.NewDeniedResult(
132-
fmt.Sprintf(
133-
"Deployment dependency: no matching deployments found for selector: %v",
134-
dependsOn,
135-
),
136-
).
13773
WithDetail("depends_on", dependsOn)
13874
}
13975

140-
upstreamReleaseTargets := e.getUpstreamReleaseTargets(
141-
ctx,
142-
matchingDeployments,
143-
scope.Resource.Id,
144-
)
145-
if len(upstreamReleaseTargets) != cap(upstreamReleaseTargets) {
76+
deployments, err := e.getters.GetAllDeployments(ctx, scope.Environment.WorkspaceId)
77+
if err != nil {
78+
span.RecordError(err)
14679
return results.NewDeniedResult(
147-
fmt.Sprintf(
148-
"Deployment dependency: some upstream release targets not found for resource: %v",
149-
scope.Resource.Id,
150-
),
80+
fmt.Sprintf("Deployment dependency: failed to get deployments: %v", err),
15181
).
152-
WithDetail("depends_on", dependsOn)
82+
WithDetail("error", err.Error())
15383
}
15484

155-
for _, upstreamReleaseTarget := range upstreamReleaseTargets {
156-
if !e.checkUpstreamTargetHasSuccessfulRelease(upstreamReleaseTarget) {
157-
return results.NewDeniedResult(
85+
releaseTargets := e.getters.GetReleaseTargetsForResource(ctx, scope.Resource.Id)
86+
87+
var evalErrors []string
88+
for _, rt := range releaseTargets {
89+
if rt.DeploymentId == scope.Deployment.Id && rt.EnvironmentId == scope.Environment.Id &&
90+
rt.ResourceId == scope.Resource.Id {
91+
continue
92+
}
93+
94+
deployment := deployments[rt.DeploymentId]
95+
if deployment == nil {
96+
continue
97+
}
98+
99+
version := e.getters.GetCurrentlyDeployedVersion(ctx, rt)
100+
if version == nil {
101+
continue
102+
}
103+
104+
celCtx := cel.BuildEntityContext(nil, deployment, nil)
105+
celCtx["version"] = cel.DeploymentVersionToMap(version)
106+
matched, err := celutil.EvalBool(program, celCtx)
107+
if err != nil {
108+
span.RecordError(err)
109+
evalErrors = append(
110+
evalErrors,
111+
fmt.Sprintf("rt %s: CEL evaluation error: %v", rt.Key(), err),
112+
)
113+
continue
114+
}
115+
116+
if matched {
117+
return results.NewAllowedResult(
158118
fmt.Sprintf(
159-
"Deployment dependency: upstream release target %s has no successful release",
160-
upstreamReleaseTarget.Key(),
119+
"Deployment dependency: upstream %s has matching deployed version %s",
120+
deployment.Name,
121+
version.Tag,
161122
),
162-
).
163-
WithDetail("upstream_release_target_key", upstreamReleaseTarget.Key())
123+
)
164124
}
165125
}
166126

167-
return results.
168-
NewAllowedResult(
169-
"Deployment dependency: all upstream release targets have successful releases",
170-
)
127+
result := results.NewDeniedResult(
128+
fmt.Sprintf(
129+
"Deployment dependency: no upstream release target with a successful release matches selector: %s",
130+
dependsOn,
131+
),
132+
).
133+
WithDetail("depends_on", dependsOn)
134+
135+
if len(evalErrors) > 0 {
136+
result = result.WithDetail("errors", fmt.Sprintf("%v", evalErrors))
137+
}
138+
139+
return result
171140
}

0 commit comments

Comments
 (0)