From 168437413f19770331c865852fe4a6e77da6c2df Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 9 Apr 2026 16:53:19 -0700 Subject: [PATCH 1/4] feat: deployment dependency policy can filter versions --- .../pkg/selector/langs/cel/cel.go | 30 +- .../pkg/selector/langs/cel/cel_test.go | 2 + .../deployment_dependency.go | 129 +++------ .../deployment_dependency_test.go | 215 ++++++++------ .../evaluator/deploymentdependency/getter.go | 43 ++- .../policyeval/getter_postgres.go | 9 +- .../policyeval/policyeval_test.go | 6 +- .../desiredrelease/reconcile_test.go | 5 +- .../test/controllers/harness/mocks.go | 11 +- .../test/controllers/policy_combined_test.go | 13 +- .../policy_deployment_dependency_test.go | 262 +++++++++++------- 11 files changed, 409 insertions(+), 316 deletions(-) diff --git a/apps/workspace-engine/pkg/selector/langs/cel/cel.go b/apps/workspace-engine/pkg/selector/langs/cel/cel.go index 826305fdb..f09113ab0 100644 --- a/apps/workspace-engine/pkg/selector/langs/cel/cel.go +++ b/apps/workspace-engine/pkg/selector/langs/cel/cel.go @@ -11,7 +11,7 @@ import ( ) var compiledEnv, _ = celutil.NewEnvBuilder(). - WithMapVariables("resource", "deployment", "environment"). + WithMapVariables("resource", "deployment", "environment", "version"). WithStandardExtensions(). BuildCached(12 * time.Hour) @@ -89,6 +89,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) @@ -102,6 +103,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. @@ -130,6 +136,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) @@ -201,6 +211,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 diff --git a/apps/workspace-engine/pkg/selector/langs/cel/cel_test.go b/apps/workspace-engine/pkg/selector/langs/cel/cel_test.go index c9fcfa512..8be54cddc 100644 --- a/apps/workspace-engine/pkg/selector/langs/cel/cel_test.go +++ b/apps/workspace-engine/pkg/selector/langs/cel/cel_test.go @@ -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) { diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go index 5fcb6226e..cf4b60f35 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go @@ -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" ) @@ -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, @@ -117,55 +63,52 @@ 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) { - return results.NewDeniedResult( - fmt.Sprintf( - "Deployment dependency: some upstream release targets not found for resource: %v", - scope.Resource.Id, - ), - ). - WithDetail("depends_on", dependsOn) - } + 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 + } + + version := e.getters.GetCurrentlyDeployedVersion(ctx, rt) + if version == nil { + continue + } - for _, upstreamReleaseTarget := range upstreamReleaseTargets { - if !e.checkUpstreamTargetHasSuccessfulRelease(upstreamReleaseTarget) { - return results.NewDeniedResult( + celCtx := cel.BuildEntityContext(nil, deployment, nil) + celCtx["version"] = cel.DeploymentVersionToMap(version) + matched, err := celutil.EvalBool(program, celCtx) + if err != nil { + continue + } + + 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", - ) + return results.NewDeniedResult( + fmt.Sprintf( + "Deployment dependency: no upstream release target with a successful release matches selector: %s", + dependsOn, + ), + ). + WithDetail("depends_on", dependsOn) } diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency_test.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency_test.go index c9c38a020..1e3ba8334 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency_test.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency_test.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "testing" - "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -13,9 +12,9 @@ import ( ) type mockGetters struct { - deployments map[string]*oapi.Deployment - releaseTargets map[string][]*oapi.ReleaseTarget - latestJobs map[string]*oapi.Job + deployments map[string]*oapi.Deployment + releaseTargets map[string][]*oapi.ReleaseTarget + deployedVersions map[string]*oapi.DeploymentVersion } func (m *mockGetters) GetDeployment(_ context.Context, id string) (*oapi.Deployment, error) { @@ -39,11 +38,14 @@ func (m *mockGetters) GetReleaseTargetsForResource( return m.releaseTargets[resourceID] } -func (m *mockGetters) GetLatestCompletedJobForReleaseTarget(rt *oapi.ReleaseTarget) *oapi.Job { - if m.latestJobs == nil || rt == nil { +func (m *mockGetters) GetCurrentlyDeployedVersion( + _ context.Context, + rt *oapi.ReleaseTarget, +) *oapi.DeploymentVersion { + if m.deployedVersions == nil || rt == nil { return nil } - return m.latestJobs[rt.Key()] + return m.deployedVersions[rt.Key()] } func generateDependencyRule(cel string) *oapi.PolicyRule { @@ -56,7 +58,7 @@ func generateDependencyRule(cel string) *oapi.PolicyRule { } func makeDeployment() *oapi.Deployment { - return &oapi.Deployment{Id: uuid.New().String()} + return &oapi.Deployment{Id: uuid.New().String(), Name: "dep-" + uuid.New().String()} } func makeReleaseTarget(resourceID, envID, deploymentID string) *oapi.ReleaseTarget { @@ -67,21 +69,14 @@ func makeReleaseTarget(resourceID, envID, deploymentID string) *oapi.ReleaseTarg } } -func successfulJob() *oapi.Job { - now := time.Now() - return &oapi.Job{ - Id: uuid.New().String(), - Status: oapi.JobStatusSuccessful, - CompletedAt: &now, - } -} - -func failedJob() *oapi.Job { - now := time.Now() - return &oapi.Job{ - Id: uuid.New().String(), - Status: oapi.JobStatusFailure, - CompletedAt: &now, +func makeVersion(deploymentID string) *oapi.DeploymentVersion { + return &oapi.DeploymentVersion{ + Id: uuid.New().String(), + Tag: "v1.0.0", + Name: "version-1", + DeploymentId: deploymentID, + Status: oapi.DeploymentVersionStatusReady, + Metadata: map[string]string{}, } } @@ -105,7 +100,7 @@ func TestDeploymentDependencyEvaluator_UnsatisfiedDependencyFails(t *testing.T) releaseTargets: map[string][]*oapi.ReleaseTarget{ resourceID: {rt1, rt2}, }, - latestJobs: map[string]*oapi.Job{}, + deployedVersions: map[string]*oapi.DeploymentVersion{}, } cel := fmt.Sprintf("deployment.id == '%s'", deployment1.Id) @@ -139,8 +134,8 @@ func TestDeploymentDependencyEvaluator_SatisfiedDependencyPasses(t *testing.T) { releaseTargets: map[string][]*oapi.ReleaseTarget{ resourceID: {rt1, rt2}, }, - latestJobs: map[string]*oapi.Job{ - rt1.Key(): successfulJob(), + deployedVersions: map[string]*oapi.DeploymentVersion{ + rt1.Key(): makeVersion(deployment1.Id), }, } @@ -156,100 +151,94 @@ func TestDeploymentDependencyEvaluator_SatisfiedDependencyPasses(t *testing.T) { assert.True(t, result.Allowed, "expected allowed when dependency is satisfied") } -func TestDeploymentDependencyEvaluator_MixedSatisfactionsFails(t *testing.T) { +func TestDeploymentDependencyEvaluator_NoMatchingDeploymentsFails(t *testing.T) { ctx := context.Background() deployment1 := makeDeployment() deployment2 := makeDeployment() - deployment3 := makeDeployment() resourceID := uuid.New().String() env1ID := uuid.New().String() env2ID := uuid.New().String() - env3ID := uuid.New().String() rt1 := makeReleaseTarget(resourceID, env1ID, deployment1.Id) rt2 := makeReleaseTarget(resourceID, env2ID, deployment2.Id) - rt3 := makeReleaseTarget(resourceID, env3ID, deployment3.Id) mock := &mockGetters{ deployments: map[string]*oapi.Deployment{ deployment1.Id: deployment1, deployment2.Id: deployment2, - deployment3.Id: deployment3, }, releaseTargets: map[string][]*oapi.ReleaseTarget{ - resourceID: {rt1, rt2, rt3}, - }, - latestJobs: map[string]*oapi.Job{ - rt1.Key(): successfulJob(), + resourceID: {rt1, rt2}, }, + deployedVersions: map[string]*oapi.DeploymentVersion{}, } - cel := fmt.Sprintf("deployment.id != '%s'", deployment3.Id) + cel := "deployment.id == 'non-existing-deployment'" rule := generateDependencyRule(cel) eval := NewEvaluator(mock, rule) result := eval.Evaluate(ctx, evaluator.EvaluatorScope{ - Environment: &oapi.Environment{Id: rt3.EnvironmentId}, - Resource: &oapi.Resource{Id: rt3.ResourceId}, - Deployment: &oapi.Deployment{Id: rt3.DeploymentId}, + Environment: &oapi.Environment{Id: rt2.EnvironmentId}, + Resource: &oapi.Resource{Id: rt2.ResourceId}, + Deployment: &oapi.Deployment{Id: rt2.DeploymentId}, }) - assert.False( - t, - result.Allowed, - "expected denied when some upstream release targets are not successful", - ) + assert.False(t, result.Allowed, "expected denied when no matching deployments are found") } -func TestDeploymentDependencyEvaluator_FailedJobsFails(t *testing.T) { +func TestDeploymentDependencyEvaluator_VersionSelectorFilters(t *testing.T) { ctx := context.Background() deployment1 := makeDeployment() deployment2 := makeDeployment() - deployment3 := makeDeployment() resourceID := uuid.New().String() env1ID := uuid.New().String() env2ID := uuid.New().String() - env3ID := uuid.New().String() rt1 := makeReleaseTarget(resourceID, env1ID, deployment1.Id) rt2 := makeReleaseTarget(resourceID, env2ID, deployment2.Id) - rt3 := makeReleaseTarget(resourceID, env3ID, deployment3.Id) + + version := makeVersion(deployment1.Id) + version.Tag = "v1.0.0" mock := &mockGetters{ deployments: map[string]*oapi.Deployment{ deployment1.Id: deployment1, deployment2.Id: deployment2, - deployment3.Id: deployment3, }, releaseTargets: map[string][]*oapi.ReleaseTarget{ - resourceID: {rt1, rt2, rt3}, + resourceID: {rt1, rt2}, }, - latestJobs: map[string]*oapi.Job{ - rt1.Key(): successfulJob(), - rt2.Key(): failedJob(), + deployedVersions: map[string]*oapi.DeploymentVersion{ + rt1.Key(): version, }, } - cel := fmt.Sprintf("deployment.id != '%s'", deployment3.Id) + // Version matches + cel := fmt.Sprintf("deployment.id == '%s' && version.tag == 'v1.0.0'", deployment1.Id) rule := generateDependencyRule(cel) - eval := NewEvaluator(mock, rule) - result := eval.Evaluate(ctx, evaluator.EvaluatorScope{ - Environment: &oapi.Environment{Id: rt3.EnvironmentId}, - Resource: &oapi.Resource{Id: rt3.ResourceId}, - Deployment: &oapi.Deployment{Id: rt3.DeploymentId}, + Environment: &oapi.Environment{Id: rt2.EnvironmentId}, + Resource: &oapi.Resource{Id: rt2.ResourceId}, + Deployment: &oapi.Deployment{Id: rt2.DeploymentId}, }) - assert.False( - t, - result.Allowed, - "expected denied when some upstream release targets are not successful", - ) + assert.True(t, result.Allowed, "expected allowed when version matches") + + // Version does not match + cel = fmt.Sprintf("deployment.id == '%s' && version.tag == 'v2.0.0'", deployment1.Id) + rule = generateDependencyRule(cel) + eval = NewEvaluator(mock, rule) + result = eval.Evaluate(ctx, evaluator.EvaluatorScope{ + Environment: &oapi.Environment{Id: rt2.EnvironmentId}, + Resource: &oapi.Resource{Id: rt2.ResourceId}, + Deployment: &oapi.Deployment{Id: rt2.DeploymentId}, + }) + assert.False(t, result.Allowed, "expected denied when version does not match") } -func TestDeploymentDependencyEvaluator_FailsIfLatestJobIsNotSuccessful(t *testing.T) { +func TestDeploymentDependencyEvaluator_VersionMetadataSelector(t *testing.T) { ctx := context.Background() deployment1 := makeDeployment() @@ -261,6 +250,9 @@ func TestDeploymentDependencyEvaluator_FailsIfLatestJobIsNotSuccessful(t *testin rt1 := makeReleaseTarget(resourceID, env1ID, deployment1.Id) rt2 := makeReleaseTarget(resourceID, env2ID, deployment2.Id) + version := makeVersion(deployment1.Id) + version.Metadata = map[string]string{"channel": "stable"} + mock := &mockGetters{ deployments: map[string]*oapi.Deployment{ deployment1.Id: deployment1, @@ -269,12 +261,15 @@ func TestDeploymentDependencyEvaluator_FailsIfLatestJobIsNotSuccessful(t *testin releaseTargets: map[string][]*oapi.ReleaseTarget{ resourceID: {rt1, rt2}, }, - latestJobs: map[string]*oapi.Job{ - rt1.Key(): failedJob(), + deployedVersions: map[string]*oapi.DeploymentVersion{ + rt1.Key(): version, }, } - cel := fmt.Sprintf("deployment.id == '%s'", deployment1.Id) + cel := fmt.Sprintf( + "deployment.id == '%s' && version.metadata.channel == 'stable'", + deployment1.Id, + ) rule := generateDependencyRule(cel) eval := NewEvaluator(mock, rule) result := eval.Evaluate(ctx, evaluator.EvaluatorScope{ @@ -282,85 +277,98 @@ func TestDeploymentDependencyEvaluator_FailsIfLatestJobIsNotSuccessful(t *testin Resource: &oapi.Resource{Id: rt2.ResourceId}, Deployment: &oapi.Deployment{Id: rt2.DeploymentId}, }) - assert.False(t, result.Allowed, "expected denied when latest job is not successful") + assert.True(t, result.Allowed, "expected allowed when version metadata matches") } -func TestDeploymentDependencyEvaluator_PassesIfLatestJobIsProgressingAndOtherJobsAreSuccessful( - t *testing.T, -) { +func TestDeploymentDependencyEvaluator_NoDeployedVersionFails(t *testing.T) { ctx := context.Background() deployment1 := makeDeployment() deployment2 := makeDeployment() + deployment3 := makeDeployment() resourceID := uuid.New().String() env1ID := uuid.New().String() env2ID := uuid.New().String() + env3ID := uuid.New().String() rt1 := makeReleaseTarget(resourceID, env1ID, deployment1.Id) rt2 := makeReleaseTarget(resourceID, env2ID, deployment2.Id) + rt3 := makeReleaseTarget(resourceID, env3ID, deployment3.Id) mock := &mockGetters{ deployments: map[string]*oapi.Deployment{ deployment1.Id: deployment1, deployment2.Id: deployment2, + deployment3.Id: deployment3, }, releaseTargets: map[string][]*oapi.ReleaseTarget{ - resourceID: {rt1, rt2}, - }, - latestJobs: map[string]*oapi.Job{ - rt1.Key(): successfulJob(), + resourceID: {rt1, rt2, rt3}, }, + deployedVersions: map[string]*oapi.DeploymentVersion{}, } - cel := fmt.Sprintf("deployment.id == '%s'", deployment1.Id) + cel := fmt.Sprintf("deployment.id != '%s'", deployment3.Id) rule := generateDependencyRule(cel) + eval := NewEvaluator(mock, rule) + result := eval.Evaluate(ctx, evaluator.EvaluatorScope{ - Environment: &oapi.Environment{Id: rt2.EnvironmentId}, - Resource: &oapi.Resource{Id: rt2.ResourceId}, - Deployment: &oapi.Deployment{Id: rt2.DeploymentId}, + Environment: &oapi.Environment{Id: rt3.EnvironmentId}, + Resource: &oapi.Resource{Id: rt3.ResourceId}, + Deployment: &oapi.Deployment{Id: rt3.DeploymentId}, }) - assert.True( + assert.False( t, result.Allowed, - "expected allowed when latest job is progressing and other jobs are successful", + "expected denied when no upstream release targets have deployed versions", ) } -func TestDeploymentDependencyEvaluator_NoMatchingDeploymentsFails(t *testing.T) { +func TestDeploymentDependencyEvaluator_PassesWhenAtLeastOneUpstreamMatches(t *testing.T) { ctx := context.Background() deployment1 := makeDeployment() deployment2 := makeDeployment() + deployment3 := makeDeployment() resourceID := uuid.New().String() env1ID := uuid.New().String() env2ID := uuid.New().String() + env3ID := uuid.New().String() rt1 := makeReleaseTarget(resourceID, env1ID, deployment1.Id) rt2 := makeReleaseTarget(resourceID, env2ID, deployment2.Id) + rt3 := makeReleaseTarget(resourceID, env3ID, deployment3.Id) mock := &mockGetters{ deployments: map[string]*oapi.Deployment{ deployment1.Id: deployment1, deployment2.Id: deployment2, + deployment3.Id: deployment3, }, releaseTargets: map[string][]*oapi.ReleaseTarget{ - resourceID: {rt1, rt2}, + resourceID: {rt1, rt2, rt3}, + }, + deployedVersions: map[string]*oapi.DeploymentVersion{ + rt1.Key(): makeVersion(deployment1.Id), + // rt2 has no deployed version }, - latestJobs: map[string]*oapi.Job{}, } - cel := "deployment.id == 'non-existing-deployment'" + cel := fmt.Sprintf("deployment.id == '%s'", deployment1.Id) rule := generateDependencyRule(cel) eval := NewEvaluator(mock, rule) result := eval.Evaluate(ctx, evaluator.EvaluatorScope{ - Environment: &oapi.Environment{Id: rt2.EnvironmentId}, - Resource: &oapi.Resource{Id: rt2.ResourceId}, - Deployment: &oapi.Deployment{Id: rt2.DeploymentId}, + Environment: &oapi.Environment{Id: rt3.EnvironmentId}, + Resource: &oapi.Resource{Id: rt3.ResourceId}, + Deployment: &oapi.Deployment{Id: rt3.DeploymentId}, }) - assert.False(t, result.Allowed, "expected denied when no matching deployments are found") + assert.True( + t, + result.Allowed, + "expected allowed when at least one upstream matches", + ) } func TestDeploymentDependencyEvaluator_NotEnoughUpstreamReleaseTargetsFails(t *testing.T) { @@ -381,7 +389,7 @@ func TestDeploymentDependencyEvaluator_NotEnoughUpstreamReleaseTargetsFails(t *t releaseTargets: map[string][]*oapi.ReleaseTarget{ resourceID: {rt2}, }, - latestJobs: map[string]*oapi.Job{}, + deployedVersions: map[string]*oapi.DeploymentVersion{}, } cel := fmt.Sprintf("deployment.id == '%s'", deployment1.Id) @@ -397,6 +405,29 @@ func TestDeploymentDependencyEvaluator_NotEnoughUpstreamReleaseTargetsFails(t *t assert.False( t, result.Allowed, - "expected denied when not enough upstream release targets are found", + "expected denied when upstream release target doesn't exist for resource", ) } + +func TestDeploymentDependencyEvaluator_NoReleaseTargetsForResource(t *testing.T) { + ctx := context.Background() + + resourceID := uuid.New().String() + envID := uuid.New().String() + deploymentID := uuid.New().String() + + mock := &mockGetters{ + deployments: map[string]*oapi.Deployment{}, + releaseTargets: map[string][]*oapi.ReleaseTarget{}, + deployedVersions: map[string]*oapi.DeploymentVersion{}, + } + + rule := generateDependencyRule("deployment.name == 'something'") + eval := NewEvaluator(mock, rule) + result := eval.Evaluate(ctx, evaluator.EvaluatorScope{ + Environment: &oapi.Environment{Id: envID}, + Resource: &oapi.Resource{Id: resourceID}, + Deployment: &oapi.Deployment{Id: deploymentID}, + }) + assert.False(t, result.Allowed, "expected denied when no release targets exist") +} diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/getter.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/getter.go index f9ee5dfb1..83f06aa76 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/getter.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/getter.go @@ -15,7 +15,7 @@ type deploymentGetter = store.DeploymentGetter type Getters interface { deploymentGetter GetReleaseTargetsForResource(ctx context.Context, resourceID string) []*oapi.ReleaseTarget - GetLatestCompletedJobForReleaseTarget(releaseTarget *oapi.ReleaseTarget) *oapi.Job + GetCurrentlyDeployedVersion(ctx context.Context, rt *oapi.ReleaseTarget) *oapi.DeploymentVersion } var _ Getters = (*PostgresGetters)(nil) @@ -58,29 +58,44 @@ func (p *PostgresGetters) GetReleaseTargetsForResource( return targets } -func (p *PostgresGetters) GetLatestCompletedJobForReleaseTarget( - releaseTarget *oapi.ReleaseTarget, -) *oapi.Job { - if releaseTarget == nil { +func (p *PostgresGetters) GetCurrentlyDeployedVersion( + ctx context.Context, + rt *oapi.ReleaseTarget, +) *oapi.DeploymentVersion { + if rt == nil { return nil } - row, err := p.queries.GetLatestCompletedJobForReleaseTarget( - context.Background(), - db.GetLatestCompletedJobForReleaseTargetParams{ - DeploymentID: uuid.MustParse(releaseTarget.DeploymentId), - EnvironmentID: uuid.MustParse(releaseTarget.EnvironmentId), - ResourceID: uuid.MustParse(releaseTarget.ResourceId), + row, err := p.queries.GetCurrentReleaseByReleaseTarget( + ctx, + db.GetCurrentReleaseByReleaseTargetParams{ + ResourceID: uuid.MustParse(rt.ResourceId), + EnvironmentID: uuid.MustParse(rt.EnvironmentId), + DeploymentID: uuid.MustParse(rt.DeploymentId), }, ) if err != nil { slog.Error( - "failed to get latest completed job for release target", + "failed to get current release for release target", "releaseTarget", - releaseTarget.Key(), + rt.Key(), "error", err, ) return nil } - return db.ToOapiJobFromLatestCompleted(row) + v := &oapi.DeploymentVersion{ + Id: row.VersionID.String(), + Name: row.VersionName, + Tag: row.VersionTag, + DeploymentId: row.DeploymentID.String(), + Status: oapi.DeploymentVersionStatus(row.VersionStatus), + Metadata: row.VersionMetadata, + } + if row.VersionCreatedAt.Valid { + v.CreatedAt = row.VersionCreatedAt.Time + } + if row.VersionMessage.Valid { + v.Message = &row.VersionMessage.String + } + return v } diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/getter_postgres.go b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/getter_postgres.go index 849d5da4c..b17f8848d 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/getter_postgres.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/getter_postgres.go @@ -63,8 +63,9 @@ func (g *PostgresGetter) GetReleaseTargetsForResource( return g.deploymentdependency.GetReleaseTargetsForResource(ctx, resourceID) } -func (g *PostgresGetter) GetLatestCompletedJobForReleaseTarget( - releaseTarget *oapi.ReleaseTarget, -) *oapi.Job { - return g.deploymentdependency.GetLatestCompletedJobForReleaseTarget(releaseTarget) +func (g *PostgresGetter) GetCurrentlyDeployedVersion( + ctx context.Context, + rt *oapi.ReleaseTarget, +) *oapi.DeploymentVersion { + return g.deploymentdependency.GetCurrentlyDeployedVersion(ctx, rt) } 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 67db7cef2..af4d275e6 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/policyeval/policyeval_test.go @@ -169,7 +169,11 @@ func (m *mockGetter) GetReleaseTargetsForResource( ) []*oapi.ReleaseTarget { return nil } -func (m *mockGetter) GetLatestCompletedJobForReleaseTarget(_ *oapi.ReleaseTarget) *oapi.Job { + +func (m *mockGetter) GetCurrentlyDeployedVersion( + _ context.Context, + _ *oapi.ReleaseTarget, +) *oapi.DeploymentVersion { return nil } func (m *mockGetter) GetReleaseByJobID(_ context.Context, _ string) (*oapi.Release, error) { diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go index 0a9e120f1..f36f53540 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go @@ -201,9 +201,10 @@ func (m *mockReconcileGetter) GetReleaseTargetsForResource( return nil } -func (m *mockReconcileGetter) GetLatestCompletedJobForReleaseTarget( +func (m *mockReconcileGetter) GetCurrentlyDeployedVersion( + _ context.Context, _ *oapi.ReleaseTarget, -) *oapi.Job { +) *oapi.DeploymentVersion { return nil } diff --git a/apps/workspace-engine/test/controllers/harness/mocks.go b/apps/workspace-engine/test/controllers/harness/mocks.go index 7b3963586..382a2fbb7 100644 --- a/apps/workspace-engine/test/controllers/harness/mocks.go +++ b/apps/workspace-engine/test/controllers/harness/mocks.go @@ -128,7 +128,7 @@ type DesiredReleaseGetter struct { ReleaseTargetsByResource map[string][]*oapi.ReleaseTarget AllReleaseTargetsList []*oapi.ReleaseTarget JobsByReleaseTarget map[string]map[string]*oapi.Job - LatestCompletedJobs map[string]*oapi.Job + CurrentlyDeployedVersions map[string]*oapi.DeploymentVersion JobVerificationStatuses map[string]oapi.JobVerificationStatus Deployments map[string]*oapi.Deployment Environments map[string]*oapi.Environment @@ -405,12 +405,13 @@ func (g *DesiredReleaseGetter) GetReleaseTargetsForResource( return nil } -func (g *DesiredReleaseGetter) GetLatestCompletedJobForReleaseTarget( +func (g *DesiredReleaseGetter) GetCurrentlyDeployedVersion( + _ context.Context, rt *oapi.ReleaseTarget, -) *oapi.Job { - if g.LatestCompletedJobs != nil { +) *oapi.DeploymentVersion { + if g.CurrentlyDeployedVersions != nil { key := rt.DeploymentId + ":" + rt.EnvironmentId + ":" + rt.ResourceId - return g.LatestCompletedJobs[key] + return g.CurrentlyDeployedVersions[key] } return nil } diff --git a/apps/workspace-engine/test/controllers/policy_combined_test.go b/apps/workspace-engine/test/controllers/policy_combined_test.go index 4d20e1b88..598e725a9 100644 --- a/apps/workspace-engine/test/controllers/policy_combined_test.go +++ b/apps/workspace-engine/test/controllers/policy_combined_test.go @@ -2,7 +2,6 @@ package controllers_test import ( "testing" - "time" "github.com/google/uuid" "workspace-engine/pkg/oapi" @@ -417,12 +416,14 @@ func TestCombinedPolicy_DependencyAndVersionSelector(t *testing.T) { }, }, } - completedAt := time.Now().Add(-10 * time.Minute) - p.ReleaseGetter.LatestCompletedJobs = map[string]*oapi.Job{ + p.ReleaseGetter.CurrentlyDeployedVersions = map[string]*oapi.DeploymentVersion{ upstreamRTKey: { - Id: uuid.New().String(), - Status: oapi.JobStatusSuccessful, - CompletedAt: &completedAt, + Id: uuid.New().String(), + Tag: "v2.0.0", + Name: "upstream-v2", + DeploymentId: upstreamDeploymentID.String(), + Status: oapi.DeploymentVersionStatusReady, + Metadata: map[string]string{}, }, } diff --git a/apps/workspace-engine/test/controllers/policy_deployment_dependency_test.go b/apps/workspace-engine/test/controllers/policy_deployment_dependency_test.go index e6d93700d..66a7015d3 100644 --- a/apps/workspace-engine/test/controllers/policy_deployment_dependency_test.go +++ b/apps/workspace-engine/test/controllers/policy_deployment_dependency_test.go @@ -2,7 +2,6 @@ package controllers_test import ( "testing" - "time" "github.com/google/uuid" "workspace-engine/pkg/oapi" @@ -10,7 +9,7 @@ import ( ) // --------------------------------------------------------------------------- -// Upstream dependency has successful job -> allowed +// Upstream dependency has successful release -> allowed // --------------------------------------------------------------------------- func TestDeploymentDependency_UpstreamSuccessful_Allowed(t *testing.T) { @@ -57,12 +56,14 @@ func TestDeploymentDependency_UpstreamSuccessful_Allowed(t *testing.T) { }, } - completedAt := time.Now().Add(-10 * time.Minute) - p.ReleaseGetter.LatestCompletedJobs = map[string]*oapi.Job{ + p.ReleaseGetter.CurrentlyDeployedVersions = map[string]*oapi.DeploymentVersion{ upstreamRTKey: { - Id: uuid.New().String(), - Status: oapi.JobStatusSuccessful, - CompletedAt: &completedAt, + Id: uuid.New().String(), + Tag: "v2.0.0", + Name: "upstream-v2", + DeploymentId: upstreamDeploymentID.String(), + Status: oapi.DeploymentVersionStatusReady, + Metadata: map[string]string{}, }, } @@ -73,10 +74,10 @@ func TestDeploymentDependency_UpstreamSuccessful_Allowed(t *testing.T) { } // --------------------------------------------------------------------------- -// Upstream dependency has no job -> blocked +// Upstream dependency has no successful release -> blocked // --------------------------------------------------------------------------- -func TestDeploymentDependency_UpstreamNoJob_Blocked(t *testing.T) { +func TestDeploymentDependency_UpstreamNoRelease_Blocked(t *testing.T) { deploymentID := uuid.New() upstreamDeploymentID := uuid.New() environmentID := uuid.New() @@ -118,71 +119,7 @@ func TestDeploymentDependency_UpstreamNoJob_Blocked(t *testing.T) { }, }, } - // No completed jobs for the upstream target. - p.ReleaseGetter.LatestCompletedJobs = map[string]*oapi.Job{} - - p.Run() - - p.AssertNoRelease(t) -} - -// --------------------------------------------------------------------------- -// Upstream dependency has failed job -> blocked -// --------------------------------------------------------------------------- - -func TestDeploymentDependency_UpstreamFailedJob_Blocked(t *testing.T) { - deploymentID := uuid.New() - upstreamDeploymentID := uuid.New() - environmentID := uuid.New() - resourceID := uuid.New() - - upstreamRTKey := upstreamDeploymentID.String() + ":" + environmentID.String() + ":" + resourceID.String() - - p := NewTestPipeline(t, - WithDeployment(DeploymentSelector("true"), DeploymentID(deploymentID)), - WithEnvironment(EnvironmentName("production"), EnvironmentID(environmentID)), - WithResource(ResourceName("srv-1"), ResourceKind("Server"), ResourceID(resourceID)), - WithVersion(VersionTag("v1.0.0")), - WithPolicy( - PolicySelector("true"), - PolicyEnabled(true), - WithPolicyRule( - WithDeploymentDependencyRule(`deployment.name == "upstream-app"`), - ), - ), - ) - - upstreamRT := &oapi.ReleaseTarget{ - DeploymentId: upstreamDeploymentID.String(), - EnvironmentId: environmentID.String(), - ResourceId: resourceID.String(), - } - - p.ReleaseGetter.Deployments = map[string]*oapi.Deployment{ - upstreamDeploymentID.String(): { - Id: upstreamDeploymentID.String(), - Name: "upstream-app", - }, - } - p.ReleaseGetter.ReleaseTargetsByResource = map[string][]*oapi.ReleaseTarget{ - resourceID.String(): { - upstreamRT, - { - DeploymentId: deploymentID.String(), - EnvironmentId: environmentID.String(), - ResourceId: resourceID.String(), - }, - }, - } - - completedAt := time.Now().Add(-10 * time.Minute) - p.ReleaseGetter.LatestCompletedJobs = map[string]*oapi.Job{ - upstreamRTKey: { - Id: uuid.New().String(), - Status: oapi.JobStatusFailure, - CompletedAt: &completedAt, - }, - } + p.ReleaseGetter.CurrentlyDeployedVersions = map[string]*oapi.DeploymentVersion{} p.Run() @@ -208,7 +145,6 @@ func TestDeploymentDependency_NoMatchingDeployments_Blocked(t *testing.T) { ), ) - // Empty deployments map means no matching deployments found. p.ReleaseGetter.Deployments = map[string]*oapi.Deployment{} p.Run() @@ -264,6 +200,133 @@ func TestDeploymentDependency_NonMatchingSelector_DoesNotBlock(t *testing.T) { p.AssertReleaseCreated(t) } +// --------------------------------------------------------------------------- +// Version selector scopes the dependency +// --------------------------------------------------------------------------- + +func TestDeploymentDependency_VersionSelector_Allowed(t *testing.T) { + deploymentID := uuid.New() + upstreamDeploymentID := uuid.New() + environmentID := uuid.New() + resourceID := uuid.New() + + upstreamRTKey := upstreamDeploymentID.String() + ":" + environmentID.String() + ":" + resourceID.String() + + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true"), DeploymentID(deploymentID)), + WithEnvironment(EnvironmentName("production"), EnvironmentID(environmentID)), + WithResource(ResourceName("srv-1"), ResourceKind("Server"), ResourceID(resourceID)), + WithVersion(VersionTag("v1.0.0")), + WithPolicy( + PolicySelector("true"), + PolicyEnabled(true), + WithPolicyRule( + WithDeploymentDependencyRule( + `deployment.name == "upstream-app" && version.tag.startsWith("v2.")`, + ), + ), + ), + ) + + p.ReleaseGetter.Deployments = map[string]*oapi.Deployment{ + upstreamDeploymentID.String(): { + Id: upstreamDeploymentID.String(), + Name: "upstream-app", + }, + } + p.ReleaseGetter.ReleaseTargetsByResource = map[string][]*oapi.ReleaseTarget{ + resourceID.String(): { + { + DeploymentId: upstreamDeploymentID.String(), + EnvironmentId: environmentID.String(), + ResourceId: resourceID.String(), + }, + { + DeploymentId: deploymentID.String(), + EnvironmentId: environmentID.String(), + ResourceId: resourceID.String(), + }, + }, + } + + p.ReleaseGetter.CurrentlyDeployedVersions = map[string]*oapi.DeploymentVersion{ + upstreamRTKey: { + Id: uuid.New().String(), + Tag: "v2.1.0", + Name: "upstream-v2.1", + DeploymentId: upstreamDeploymentID.String(), + Status: oapi.DeploymentVersionStatusReady, + Metadata: map[string]string{}, + }, + } + + p.Run() + + p.AssertReleaseCreated(t) + p.AssertReleaseVersion(t, 0, "v1.0.0") +} + +func TestDeploymentDependency_VersionSelector_WrongVersion_Blocked(t *testing.T) { + deploymentID := uuid.New() + upstreamDeploymentID := uuid.New() + environmentID := uuid.New() + resourceID := uuid.New() + + upstreamRTKey := upstreamDeploymentID.String() + ":" + environmentID.String() + ":" + resourceID.String() + + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true"), DeploymentID(deploymentID)), + WithEnvironment(EnvironmentName("production"), EnvironmentID(environmentID)), + WithResource(ResourceName("srv-1"), ResourceKind("Server"), ResourceID(resourceID)), + WithVersion(VersionTag("v1.0.0")), + WithPolicy( + PolicySelector("true"), + PolicyEnabled(true), + WithPolicyRule( + WithDeploymentDependencyRule( + `deployment.name == "upstream-app" && version.tag.startsWith("v2.")`, + ), + ), + ), + ) + + p.ReleaseGetter.Deployments = map[string]*oapi.Deployment{ + upstreamDeploymentID.String(): { + Id: upstreamDeploymentID.String(), + Name: "upstream-app", + }, + } + p.ReleaseGetter.ReleaseTargetsByResource = map[string][]*oapi.ReleaseTarget{ + resourceID.String(): { + { + DeploymentId: upstreamDeploymentID.String(), + EnvironmentId: environmentID.String(), + ResourceId: resourceID.String(), + }, + { + DeploymentId: deploymentID.String(), + EnvironmentId: environmentID.String(), + ResourceId: resourceID.String(), + }, + }, + } + + p.ReleaseGetter.CurrentlyDeployedVersions = map[string]*oapi.DeploymentVersion{ + upstreamRTKey: { + Id: uuid.New().String(), + Tag: "v1.5.0", + Name: "upstream-v1.5", + DeploymentId: upstreamDeploymentID.String(), + Status: oapi.DeploymentVersionStatusReady, + Metadata: map[string]string{}, + }, + } + + p.Run() + + p.AssertNoRelease(t) +} + // --------------------------------------------------------------------------- // Multiple upstream dependencies all successful -> allowed // --------------------------------------------------------------------------- @@ -318,17 +381,22 @@ func TestDeploymentDependency_MultipleUpstreams_AllSuccessful_Allowed(t *testing }, } - completedAt := time.Now().Add(-10 * time.Minute) - p.ReleaseGetter.LatestCompletedJobs = map[string]*oapi.Job{ + p.ReleaseGetter.CurrentlyDeployedVersions = map[string]*oapi.DeploymentVersion{ upstream1RTKey: { - Id: uuid.New().String(), - Status: oapi.JobStatusSuccessful, - CompletedAt: &completedAt, + Id: uuid.New().String(), + Tag: "v1.0.0", + Name: "upstream-1-v1", + DeploymentId: upstream1ID.String(), + Status: oapi.DeploymentVersionStatusReady, + Metadata: map[string]string{}, }, upstream2RTKey: { - Id: uuid.New().String(), - Status: oapi.JobStatusSuccessful, - CompletedAt: &completedAt, + Id: uuid.New().String(), + Tag: "v1.0.0", + Name: "upstream-2-v1", + DeploymentId: upstream2ID.String(), + Status: oapi.DeploymentVersionStatusReady, + Metadata: map[string]string{}, }, } @@ -339,10 +407,11 @@ func TestDeploymentDependency_MultipleUpstreams_AllSuccessful_Allowed(t *testing } // --------------------------------------------------------------------------- -// Multiple upstream dependencies, one fails -> blocked +// Multiple upstreams, one has no deployed version -> still allowed +// (ANY match semantics: at least one upstream matches the selector) // --------------------------------------------------------------------------- -func TestDeploymentDependency_MultipleUpstreams_OneFails_Blocked(t *testing.T) { +func TestDeploymentDependency_MultipleUpstreams_OneWithoutVersion_StillAllowed(t *testing.T) { deploymentID := uuid.New() upstream1ID := uuid.New() upstream2ID := uuid.New() @@ -350,7 +419,6 @@ func TestDeploymentDependency_MultipleUpstreams_OneFails_Blocked(t *testing.T) { resourceID := uuid.New() upstream1RTKey := upstream1ID.String() + ":" + environmentID.String() + ":" + resourceID.String() - upstream2RTKey := upstream2ID.String() + ":" + environmentID.String() + ":" + resourceID.String() p := NewTestPipeline(t, WithDeployment(DeploymentSelector("true"), DeploymentID(deploymentID)), @@ -392,23 +460,22 @@ func TestDeploymentDependency_MultipleUpstreams_OneFails_Blocked(t *testing.T) { }, } - completedAt := time.Now().Add(-10 * time.Minute) - p.ReleaseGetter.LatestCompletedJobs = map[string]*oapi.Job{ + p.ReleaseGetter.CurrentlyDeployedVersions = map[string]*oapi.DeploymentVersion{ upstream1RTKey: { - Id: uuid.New().String(), - Status: oapi.JobStatusSuccessful, - CompletedAt: &completedAt, - }, - upstream2RTKey: { - Id: uuid.New().String(), - Status: oapi.JobStatusFailure, - CompletedAt: &completedAt, + Id: uuid.New().String(), + Tag: "v1.0.0", + Name: "upstream-1-v1", + DeploymentId: upstream1ID.String(), + Status: oapi.DeploymentVersionStatusReady, + Metadata: map[string]string{}, }, + // upstream-2 has no deployed version } p.Run() - p.AssertNoRelease(t) + p.AssertReleaseCreated(t) + p.AssertReleaseVersion(t, 0, "v1.0.0") } // --------------------------------------------------------------------------- @@ -441,7 +508,6 @@ func TestDeploymentDependency_UpstreamMissingReleaseTarget_Blocked(t *testing.T) Name: "upstream-app", }, } - // Resource targets don't include the upstream deployment's target. p.ReleaseGetter.ReleaseTargetsByResource = map[string][]*oapi.ReleaseTarget{ resourceID.String(): { { From d579b52344aae5a38d96a6304f8b0bf7c18b09de Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 9 Apr 2026 16:57:20 -0700 Subject: [PATCH 2/4] update docs --- apps/api/openapi/openapi.json | 2 +- apps/api/openapi/schemas/policies.jsonnet | 2 +- apps/api/src/types/openapi.ts | 2 +- apps/workspace-engine/oapi/openapi.json | 2 +- .../oapi/spec/schemas/policy.jsonnet | 2 +- apps/workspace-engine/pkg/oapi/oapi.gen.go | 2 +- docs/policies/deployment-dependency.mdx | 91 ++++++++++++++++--- packages/workspace-engine-sdk/src/schema.ts | 2 +- 8 files changed, 87 insertions(+), 18 deletions(-) diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index ca72e83e9..7c29e4bb5 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -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" } }, diff --git a/apps/api/openapi/schemas/policies.jsonnet b/apps/api/openapi/schemas/policies.jsonnet index 5b9be8ca3..e08a57a9a 100644 --- a/apps/api/openapi/schemas/policies.jsonnet +++ b/apps/api/openapi/schemas/policies.jsonnet @@ -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.\').', }, }, }, diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index 883238336..871908f5e 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -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: { diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index 44b9f11ff..6e35411d1 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -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" } }, diff --git a/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet b/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet index 439e718f7..420deff16 100644 --- a/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet @@ -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.').", }, }, }, diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index c5e07c599..ea6106f21 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -326,7 +326,7 @@ type DeploymentAndSystems struct { // DeploymentDependencyRule defines model for DeploymentDependencyRule. type DeploymentDependencyRule struct { - // DependsOn CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. + // DependsOn 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 `json:"dependsOn"` } diff --git a/docs/policies/deployment-dependency.mdx b/docs/policies/deployment-dependency.mdx index ce481ca0b..3f97cca16 100644 --- a/docs/policies/deployment-dependency.mdx +++ b/docs/policies/deployment-dependency.mdx @@ -64,23 +64,43 @@ curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \ ## Properties - CEL expression to match upstream deployment(s) that must have a successful - release before this deployment can proceed. + CEL expression to match upstream release targets that must exist before this + deployment can proceed. The expression can reference both **deployment** and + **version** properties of the currently deployed upstream release. +### Available CEL Variables + +The `dependsOn` expression is evaluated against each release target on the same +resource that has a successful release. Both `deployment.*` and `version.*` +fields are available: + +| Variable | Type | Description | +|---|---|---| +| `deployment.id` | string | Deployment ID | +| `deployment.name` | string | Deployment name | +| `deployment.slug` | string | Deployment slug | +| `deployment.metadata` | map | Deployment metadata key-value pairs | +| `version.id` | string | Deployed version ID | +| `version.tag` | string | Version tag (e.g. `v2.1.0`) | +| `version.name` | string | Version name | +| `version.status` | string | Version status | +| `version.metadata` | map | Version metadata key-value pairs | +| `version.createdAt` | timestamp | When the version was created | + ## How It Works 1. **Release created** - A new version is released for a deployment with dependency rules. -2. **Dependency check** - Ctrlplane evaluates the `dependsOn` CEL expression to - find matching upstream deployments. -3. **Same-resource resolution** - For each matching upstream deployment, - Ctrlplane looks for a release target on the **same resource** as the current - target. This means the dependency is resolved per-resource, not globally. -4. **Status evaluation** - Each upstream release target must have a latest - completed job with a successful status. -5. **Deployment allowed** - Once all upstream dependencies are satisfied, the - deployment can proceed. +2. **Same-resource resolution** - Ctrlplane finds all release targets on the + **same resource** as the current target. +3. **Version resolution** - For each release target, Ctrlplane resolves the + deployment and its currently deployed version (from the latest successful + job). +4. **CEL evaluation** - The `dependsOn` expression is evaluated against each + `{deployment, version}` pair. +5. **Deployment allowed** - If at least one upstream release target matches the + selector, the deployment can proceed. ## Common Patterns @@ -189,6 +209,55 @@ resource "ctrlplane_policy" "services_require_shared_lib" { } ``` +### Version-Scoped Dependencies + +Require a specific version range of an upstream deployment: + + + +```hcl +resource "ctrlplane_policy" "api_requires_db_v2" { + name = "API Requires DB Migration v2" + selector = "deployment.name == 'api-service'" + + deployment_dependency { + depends_on_selector = "deployment.name == 'database-migration' && version.tag.startsWith('v2.')" + } +} +``` + + +```json +{ + "name": "API Requires DB Migration v2", + "selector": "deployment.name == 'api-service'", + "rules": [ + { + "deploymentDependency": { + "dependsOn": "deployment.name == 'database-migration' && version.tag.startsWith('v2.')" + } + } + ] +} +``` + + + +### Version Metadata Filtering + +Depend on an upstream deployment running a version with specific metadata: + +```hcl +resource "ctrlplane_policy" "frontend_requires_stable_api" { + name = "Frontend Requires Stable API" + selector = "deployment.name == 'frontend'" + + deployment_dependency { + depends_on_selector = "deployment.name == 'api-service' && version.metadata.channel == 'stable'" + } +} +``` + ### Infrastructure First Deploy infrastructure changes before application updates: diff --git a/packages/workspace-engine-sdk/src/schema.ts b/packages/workspace-engine-sdk/src/schema.ts index 87386b07f..352352d5c 100644 --- a/packages/workspace-engine-sdk/src/schema.ts +++ b/packages/workspace-engine-sdk/src/schema.ts @@ -272,7 +272,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; }; DeploymentVariable: { From b52eab161608c5d1c873e734fa2f61cc6e37a780 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 9 Apr 2026 17:29:04 -0700 Subject: [PATCH 3/4] cleanuyp --- .../pkg/selector/langs/cel/cel.go | 7 +++++ .../deployment_dependency.go | 28 +++++++++++++++++-- .../evaluator/deploymentdependency/getter.go | 18 +++++++----- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/workspace-engine/pkg/selector/langs/cel/cel.go b/apps/workspace-engine/pkg/selector/langs/cel/cel.go index f09113ab0..1901cc7b1 100644 --- a/apps/workspace-engine/pkg/selector/langs/cel/cel.go +++ b/apps/workspace-engine/pkg/selector/langs/cel/cel.go @@ -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) @@ -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) } diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go index cf4b60f35..dbad35be2 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go @@ -73,11 +73,25 @@ func (e *DeploymentDependencyEvaluator) Evaluate( WithDetail("depends_on", dependsOn) } + deployments, err := e.getters.GetAllDeployments(ctx, scope.Environment.WorkspaceId) + if err != nil { + span.RecordError(err) + return results.NewDeniedResult( + fmt.Sprintf("Deployment dependency: failed to get deployments: %v", err), + ). + WithDetail("error", err.Error()) + } + releaseTargets := e.getters.GetReleaseTargetsForResource(ctx, scope.Resource.Id) + var evalErrors []string for _, rt := range releaseTargets { - deployment, err := e.getters.GetDeployment(ctx, rt.DeploymentId) - if err != nil || deployment == nil { + 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 } @@ -90,6 +104,8 @@ func (e *DeploymentDependencyEvaluator) Evaluate( 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 } @@ -104,11 +120,17 @@ func (e *DeploymentDependencyEvaluator) Evaluate( } } - return results.NewDeniedResult( + 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 } diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/getter.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/getter.go index 83f06aa76..99c092620 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/getter.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/getter.go @@ -2,9 +2,11 @@ package deploymentdependency import ( "context" + "errors" "log/slog" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "workspace-engine/pkg/db" "workspace-engine/pkg/oapi" "workspace-engine/pkg/store" @@ -74,13 +76,15 @@ func (p *PostgresGetters) GetCurrentlyDeployedVersion( }, ) if err != nil { - slog.Error( - "failed to get current release for release target", - "releaseTarget", - rt.Key(), - "error", - err, - ) + if !errors.Is(err, pgx.ErrNoRows) { + slog.Error( + "failed to get current release for release target", + "releaseTarget", + rt.Key(), + "error", + err, + ) + } return nil } v := &oapi.DeploymentVersion{ From d5b700eaaf9df1439682cd7be0b3aa645a868de3 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 9 Apr 2026 17:37:40 -0700 Subject: [PATCH 4/4] lint --- .../deploymentdependency/deployment_dependency.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go index dbad35be2..fe006ffbb 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentdependency/deployment_dependency.go @@ -86,7 +86,8 @@ func (e *DeploymentDependencyEvaluator) Evaluate( var evalErrors []string for _, rt := range releaseTargets { - if rt.DeploymentId == scope.Deployment.Id && rt.EnvironmentId == scope.Environment.Id && rt.ResourceId == scope.Resource.Id { + if rt.DeploymentId == scope.Deployment.Id && rt.EnvironmentId == scope.Environment.Id && + rt.ResourceId == scope.Resource.Id { continue } @@ -105,7 +106,10 @@ func (e *DeploymentDependencyEvaluator) Evaluate( 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)) + evalErrors = append( + evalErrors, + fmt.Sprintf("rt %s: CEL evaluation error: %v", rt.Key(), err), + ) continue }