diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentwindow/deploymentwindow.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentwindow/deploymentwindow.go index fb9407793..b8ede94fe 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentwindow/deploymentwindow.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentwindow/deploymentwindow.go @@ -69,10 +69,11 @@ func NewEvaluator(store *store.Store, policyRule *oapi.PolicyRule) evaluator.Eva }) } -// ScopeFields returns ReleaseTarget since deployment window evaluation needs to -// know if the target has a deployed version already. +// ScopeFields returns Version + ReleaseTarget since deployment window evaluation +// needs to know whether this specific version has already been deployed to this +// target. func (e *DeploymentWindowEvaluator) ScopeFields() evaluator.ScopeFields { - return evaluator.ScopeReleaseTarget + return evaluator.ScopeVersion | evaluator.ScopeReleaseTarget } // RuleType returns the rule type identifier for bypass matching. @@ -106,6 +107,34 @@ func formatDuration(d time.Duration) string { return fmt.Sprintf("%dh %dm", hours, minutes) } +func (e *DeploymentWindowEvaluator) hasPreviouslyDeployedVersion( + releaseTarget *oapi.ReleaseTarget, + versionId string, +) bool { + if releaseTarget == nil || versionId == "" { + return false + } + + jobs := e.store.Jobs.GetJobsForReleaseTarget(releaseTarget) + for _, job := range jobs { + if job.Status != oapi.JobStatusSuccessful || job.CompletedAt == nil { + continue + } + + release, ok := e.store.Releases.Get(job.ReleaseId) + if !ok || release == nil || release.Version.Id != versionId { + continue + } + + verificationStatus := e.store.JobVerifications.GetJobVerificationStatus(job.Id) + if verificationStatus == "" || verificationStatus == oapi.JobVerificationStatusPassed { + return true + } + } + + return false +} + // Evaluate checks if the current time is within a deployment window. func (e *DeploymentWindowEvaluator) Evaluate( ctx context.Context, @@ -114,7 +143,17 @@ func (e *DeploymentWindowEvaluator) Evaluate( _, span := tracer.Start(ctx, "DeploymentWindowEvaluator.Evaluate") defer span.End() - _, _, err := e.store.ReleaseTargets.GetCurrentRelease(ctx, scope.ReleaseTarget()) + releaseTarget := scope.ReleaseTarget() + candidateVersion := scope.Version + if candidateVersion != nil && e.hasPreviouslyDeployedVersion(releaseTarget, candidateVersion.Id) { + return results.NewAllowedResult("Version was previously deployed to this release target - deployment window ignored"). + WithDetail("reason", "version_previously_deployed"). + WithDetail("version_id", candidateVersion.Id). + WithDetail("version_tag", candidateVersion.Tag). + WithDetail("release_target", releaseTarget.Key()) + } + + _, _, err := e.store.ReleaseTargets.GetCurrentRelease(ctx, releaseTarget) if err != nil { return results.NewAllowedResult("No previous version deployed - deployment window ignored"). WithDetail("reason", "first_deployment") diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentwindow/deploymentwindow_test.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentwindow/deploymentwindow_test.go index b61faedcf..8f048e5f7 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentwindow/deploymentwindow_test.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/deploymentwindow/deploymentwindow_test.go @@ -71,7 +71,7 @@ func seedSuccessfulRelease( ctx context.Context, st *store.Store, releaseTarget *oapi.ReleaseTarget, -) { +) *oapi.DeploymentVersion { t.Helper() versionCreatedAt := time.Now().Add(-2 * time.Hour) @@ -99,6 +99,24 @@ func seedSuccessfulRelease( CreatedAt: completedAt, } st.Jobs.Upsert(ctx, job) + + return version +} + +func createCandidateVersion( + ctx context.Context, + st *store.Store, + deploymentId string, + tag string, +) *oapi.DeploymentVersion { + version := &oapi.DeploymentVersion{ + Id: uuid.New().String(), + DeploymentId: deploymentId, + Tag: tag, + CreatedAt: time.Now(), + } + st.DeploymentVersions.Upsert(ctx, version.Id, version) + return version } func setupScopeWithDeployedTarget(t *testing.T, st *store.Store) (context.Context, evaluator.EvaluatorScope) { @@ -106,9 +124,28 @@ func setupScopeWithDeployedTarget(t *testing.T, st *store.Store) (context.Contex ctx, releaseTarget := setupReleaseTarget(t, st) seedSuccessfulRelease(t, ctx, st, releaseTarget) + candidateVersion := createCandidateVersion(ctx, st, releaseTarget.DeploymentId, "v2.0.0") return ctx, evaluator.EvaluatorScope{ Environment: &oapi.Environment{Id: releaseTarget.EnvironmentId}, + Version: candidateVersion, + Resource: &oapi.Resource{Id: releaseTarget.ResourceId}, + Deployment: &oapi.Deployment{Id: releaseTarget.DeploymentId}, + } +} + +func setupScopeWithPreviouslyDeployedVersion( + t *testing.T, + st *store.Store, +) (context.Context, evaluator.EvaluatorScope) { + t.Helper() + + ctx, releaseTarget := setupReleaseTarget(t, st) + deployedVersion := seedSuccessfulRelease(t, ctx, st, releaseTarget) + + return ctx, evaluator.EvaluatorScope{ + Environment: &oapi.Environment{Id: releaseTarget.EnvironmentId}, + Version: deployedVersion, Resource: &oapi.Resource{Id: releaseTarget.ResourceId}, Deployment: &oapi.Deployment{Id: releaseTarget.DeploymentId}, } @@ -166,8 +203,9 @@ func TestDeploymentWindowEvaluator_ScopeFields(t *testing.T) { eval := NewEvaluator(st, rule) require.NotNil(t, eval, "expected non-nil evaluator") - // Deployment window needs release target to check for existing deployments - assert.Equal(t, evaluator.ScopeReleaseTarget, eval.ScopeFields()) + // Deployment window needs release target + version to evaluate whether this + // candidate version has already been deployed for the target. + assert.Equal(t, evaluator.ScopeVersion|evaluator.ScopeReleaseTarget, eval.ScopeFields()) } func TestDeploymentWindowEvaluator_RuleType(t *testing.T) { @@ -323,8 +361,10 @@ func TestDeploymentWindowEvaluator_IgnoresWindowWithoutDeployedVersion(t *testin require.NotNil(t, eval, "expected non-nil evaluator") ctx, releaseTarget := setupReleaseTarget(t, st) + candidateVersion := createCandidateVersion(ctx, st, releaseTarget.DeploymentId, "v1.0.0") scope := evaluator.EvaluatorScope{ Environment: &oapi.Environment{Id: releaseTarget.EnvironmentId}, + Version: candidateVersion, Resource: &oapi.Resource{Id: releaseTarget.ResourceId}, Deployment: &oapi.Deployment{Id: releaseTarget.DeploymentId}, } @@ -335,6 +375,31 @@ func TestDeploymentWindowEvaluator_IgnoresWindowWithoutDeployedVersion(t *testin assert.Equal(t, "first_deployment", result.Details["reason"]) } +func TestDeploymentWindowEvaluator_IgnoresWindowForPreviouslyDeployedVersion(t *testing.T) { + st := setupStore() + + // Outside this allow window by default, so this would normally be blocked. + rule := &oapi.PolicyRule{ + Id: "rule-1", + DeploymentWindow: &oapi.DeploymentWindowRule{ + Rrule: "FREQ=YEARLY;COUNT=1;DTSTART=20200101T000000Z", + DurationMinutes: 1, + AllowWindow: boolPtr(true), + }, + } + + eval := NewEvaluator(st, rule) + require.NotNil(t, eval, "expected non-nil evaluator") + + ctx, scope := setupScopeWithPreviouslyDeployedVersion(t, st) + result := eval.Evaluate(ctx, scope) + + assert.True(t, result.Allowed, "expected allowed when candidate version was previously deployed") + assert.Contains(t, result.Message, "deployment window ignored") + assert.Equal(t, "version_previously_deployed", result.Details["reason"]) + assert.Equal(t, scope.Version.Id, result.Details["version_id"]) +} + func TestDeploymentWindowEvaluator_NextEvaluationTime(t *testing.T) { st := setupStore()