From 30534a29afa0259b5e3ba967b6d56dbc27395d76 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 30 Oct 2025 22:42:04 -0700 Subject: [PATCH 1/4] chore: release target concurrency rule --- .../openapi/releasetargets/releasetargets.go | 9 +++++ .../releasemanager/deployment/planner.go | 9 +++++ .../releasetargetconcurrency.go | 30 ++++++++++++++++ .../releasemanager/policy/evaluator/types.go | 7 ++++ .../releasemanager/policy/factory.go | 34 +++++++++++++++++++ .../releasemanager/policy/policymanager.go | 27 +++++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency.go diff --git a/apps/workspace-engine/pkg/server/openapi/releasetargets/releasetargets.go b/apps/workspace-engine/pkg/server/openapi/releasetargets/releasetargets.go index cf048a636..c37fe5570 100644 --- a/apps/workspace-engine/pkg/server/openapi/releasetargets/releasetargets.go +++ b/apps/workspace-engine/pkg/server/openapi/releasetargets/releasetargets.go @@ -86,12 +86,21 @@ func (s *ReleaseTargets) EvaluateReleaseTarget(c *gin.Context, workspaceId strin return } + targetDecision, err := policyManager.EvaluateTarget(c.Request.Context(), &req.ReleaseTarget, policies) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to evaluate target policies: " + err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ "policiesEvaulated": len(policies), "workspaceDecision": workspaceDecision, "versionDecision": versionDecision, "envVersionDecision": envVersionDecision, "envTargetVersionDecision": envTargetVersionDecision, + "targetDecision": targetDecision, }) } diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/planner.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/planner.go index 5b9f602d4..6f073684b 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/planner.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/planner.go @@ -204,6 +204,15 @@ func (p *Planner) findDeployableVersion( continue // Environment+version+target-scoped rules blocked deployment } + targetDecision, err := p.policyManager.EvaluateTarget(ctx, releaseTarget, policies) + if err != nil { + span.RecordError(err) + continue // Skip this version on error + } + if !targetDecision.CanDeploy() { + continue // Target-scoped rules blocked deployment + } + // Both checks passed - this version can be deployed return version } diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency.go new file mode 100644 index 000000000..57db42a9e --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency.go @@ -0,0 +1,30 @@ +package releasetargetconcurrency + +import ( + "context" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/workspace/releasemanager/policy/evaluator" + "workspace-engine/pkg/workspace/releasemanager/policy/results" + "workspace-engine/pkg/workspace/store" +) + +var _ evaluator.TargetScopedEvaluator = &ReleaseTargetConcurrencyEvaluator{} + +type ReleaseTargetConcurrencyEvaluator struct { + store *store.Store +} + +func NewReleaseTargetConcurrencyEvaluator(store *store.Store) *ReleaseTargetConcurrencyEvaluator { + return &ReleaseTargetConcurrencyEvaluator{store: store} +} + +func (e *ReleaseTargetConcurrencyEvaluator) Evaluate(ctx context.Context, releaseTarget *oapi.ReleaseTarget) (*oapi.RuleEvaluation, error) { + processingJobs := e.store.Jobs.GetJobsInProcessingStateForReleaseTarget(releaseTarget) + if len(processingJobs) > 0 { + return results.NewDeniedResult("Release target is already processing jobs"). + WithDetail("release_target_id", releaseTarget.Key()). + WithDetail("jobs", processingJobs), nil + } + + return results.NewAllowedResult("Release target is not processing jobs"), nil +} diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/types.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/types.go index f9e4f2f69..8aef2fe7a 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/types.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/types.go @@ -59,6 +59,13 @@ type VersionAndTargetScopedEvaluator interface { ) (*oapi.RuleEvaluation, error) } +type TargetScopedEvaluator interface { + Evaluate( + ctx context.Context, + releaseTarget *oapi.ReleaseTarget, + ) (*oapi.RuleEvaluation, error) +} + type EvaluationContext struct { Environment *oapi.Environment Version *oapi.DeploymentVersion diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/factory.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/factory.go index 027c82153..f4c068217 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/factory.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/factory.go @@ -7,6 +7,7 @@ import ( "workspace-engine/pkg/workspace/releasemanager/policy/evaluator/approval" "workspace-engine/pkg/workspace/releasemanager/policy/evaluator/environmentprogression" "workspace-engine/pkg/workspace/releasemanager/policy/evaluator/gradualrollout" + "workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency" "workspace-engine/pkg/workspace/store" ) @@ -116,6 +117,30 @@ func (f *EvaluatorFactory) EvaluateReleaseScopedPolicyRules( }) } +// EvaluateTargetScopedPolicyRules evaluates all target-scoped rules in a policy. +// Returns nil if any rule evaluation fails. +func (f *EvaluatorFactory) EvaluateTargetScopedPolicyRules( + ctx context.Context, + policy *oapi.Policy, + releaseTarget *oapi.ReleaseTarget, +) []*oapi.RuleEvaluation { + return evaluateRules(policy, func(rule *oapi.PolicyRule) ([]*oapi.RuleEvaluation, error) { + eval := f.createTargetScopedEvaluator(rule) + if eval == nil { + return nil, nil // Skip unknown rule types + } + ruleResults := make([]*oapi.RuleEvaluation, 0, len(eval)) + for _, eval := range eval { + result, err := eval.Evaluate(ctx, releaseTarget) + if err != nil { + return nil, err + } + ruleResults = append(ruleResults, result.WithRuleId(rule.Id)) + } + return ruleResults, nil + }) +} + // EvaluateWorkspaceScopedPolicyRules evaluates all workspace-scoped rules in a policy. // Returns nil if any rule evaluation fails. func (f *EvaluatorFactory) EvaluateWorkspaceScopedPolicyRules( @@ -187,6 +212,15 @@ func (f *EvaluatorFactory) createVersionScopedEvaluator(rule *oapi.PolicyRule) [ return evaluators } +// createTargetScopedEvaluator creates a target-scoped evaluator for the given rule. +// Returns nil for unknown rule types. +func (f *EvaluatorFactory) createTargetScopedEvaluator(rule *oapi.PolicyRule) []evaluator.TargetScopedEvaluator { + evaluators := []evaluator.TargetScopedEvaluator{ + releasetargetconcurrency.NewReleaseTargetConcurrencyEvaluator(f.store), + } + return evaluators +} + // createVersionAndTargetScopedEvaluator creates a version and target-scoped evaluator for the given rule. // Returns nil for unknown rule types. func (f *EvaluatorFactory) createVersionAndTargetScopedEvaluator(rule *oapi.PolicyRule) []evaluator.VersionAndTargetScopedEvaluator { diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/policymanager.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/policymanager.go index f654a6676..2cbb11f90 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/policymanager.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/policymanager.go @@ -194,3 +194,30 @@ func (m *Manager) EvaluateEnvironmentAndVersionAndTarget( return decision, nil } + +func (m *Manager) EvaluateTarget( + ctx context.Context, + releaseTarget *oapi.ReleaseTarget, + policies map[string]*oapi.Policy, +) (*oapi.DeployDecision, error) { + ctx, span := tracer.Start(ctx, "PolicyManager.EvaluateTarget") + defer span.End() + + decision := NewDeployDecision() + + // Fast path: no policies = allowed + if len(policies) == 0 { + return decision, nil + } + + for _, policy := range policies { + policyResult := results.NewPolicyEvaluation(results.WithPolicy(policy)) + ruleResults := m.evaluatorFactory.EvaluateTargetScopedPolicyRules(ctx, policy, releaseTarget) + for _, ruleResult := range ruleResults { + policyResult.AddRuleResult(*ruleResult) + } + decision.PolicyResults = append(decision.PolicyResults, *policyResult) + } + + return decision, nil +} From 0f064ec5d4b457de58c98fb7d75567390b41527f Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 30 Oct 2025 22:45:23 -0700 Subject: [PATCH 2/4] cleanup --- .../pkg/workspace/releasemanager/policy/factory.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/factory.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/factory.go index f4c068217..f71d91d4c 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/factory.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/factory.go @@ -214,11 +214,10 @@ func (f *EvaluatorFactory) createVersionScopedEvaluator(rule *oapi.PolicyRule) [ // createTargetScopedEvaluator creates a target-scoped evaluator for the given rule. // Returns nil for unknown rule types. -func (f *EvaluatorFactory) createTargetScopedEvaluator(rule *oapi.PolicyRule) []evaluator.TargetScopedEvaluator { - evaluators := []evaluator.TargetScopedEvaluator{ +func (f *EvaluatorFactory) createTargetScopedEvaluator(_ *oapi.PolicyRule) []evaluator.TargetScopedEvaluator { + return []evaluator.TargetScopedEvaluator{ releasetargetconcurrency.NewReleaseTargetConcurrencyEvaluator(f.store), } - return evaluators } // createVersionAndTargetScopedEvaluator creates a version and target-scoped evaluator for the given rule. From e7a7ad3235d21e6ebac155ad362c366973cafb95 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 30 Oct 2025 22:46:17 -0700 Subject: [PATCH 3/4] fix --- .../releasetargetconcurrency/releasetargetconcurrency.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency.go index 57db42a9e..43f981fe8 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency.go @@ -22,7 +22,7 @@ func (e *ReleaseTargetConcurrencyEvaluator) Evaluate(ctx context.Context, releas processingJobs := e.store.Jobs.GetJobsInProcessingStateForReleaseTarget(releaseTarget) if len(processingJobs) > 0 { return results.NewDeniedResult("Release target is already processing jobs"). - WithDetail("release_target_id", releaseTarget.Key()). + WithDetail("release_target_key", releaseTarget.Key()). WithDetail("jobs", processingJobs), nil } From 8e8cb5edad72fbb56bda88029831db0cbd6f3f48 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 30 Oct 2025 22:51:51 -0700 Subject: [PATCH 4/4] tests --- .../releasetargetconcurrency_test.go | 610 ++++++++++++++++++ 1 file changed, 610 insertions(+) create mode 100644 apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency_test.go diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency_test.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency_test.go new file mode 100644 index 000000000..05fa80a3c --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency/releasetargetconcurrency_test.go @@ -0,0 +1,610 @@ +package releasetargetconcurrency + +import ( + "context" + "testing" + "time" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/statechange" + "workspace-engine/pkg/workspace/store" +) + +// setupStoreWithReleaseAndJobs creates a test store with a release target and associated jobs +func setupStoreWithReleaseAndJobs(t *testing.T, releaseTarget *oapi.ReleaseTarget, jobs []*oapi.Job) *store.Store { + sc := statechange.NewChangeSet[any]() + st := store.New(sc) + ctx := context.Background() + + // Create resource + resource := &oapi.Resource{ + Id: releaseTarget.ResourceId, + Name: "test-resource", + Kind: "server", + Identifier: releaseTarget.ResourceId, + Config: map[string]any{}, + Metadata: map[string]string{}, + Version: "v1", + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + } + + if _, err := st.Resources.Upsert(ctx, resource); err != nil { + t.Fatalf("Failed to upsert resource: %v", err) + } + + // Create release + release := &oapi.Release{ + ReleaseTarget: *releaseTarget, + Version: oapi.DeploymentVersion{ + Id: "version-1", + Tag: "v1.0.0", + }, + } + + if err := st.Releases.Upsert(ctx, release); err != nil { + t.Fatalf("Failed to upsert release: %v", err) + } + + // Add jobs + for _, job := range jobs { + job.ReleaseId = release.ID() + st.Jobs.Upsert(ctx, job) + } + + return st +} + +func TestReleaseTargetConcurrencyEvaluator_NoJobs(t *testing.T) { + // Setup: Release target with no jobs + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, []*oapi.Job{}) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.Allowed { + t.Errorf("expected allowed when no jobs exist, got denied: %s", result.Message) + } + + if result.Message != "Release target is not processing jobs" { + t.Errorf("expected 'Release target is not processing jobs', got '%s'", result.Message) + } +} + +func TestReleaseTargetConcurrencyEvaluator_JobInProgress(t *testing.T) { + // Setup: Release target with a job in progress + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + jobs := []*oapi.Job{ + { + Id: "job-1", + Status: oapi.InProgress, + CreatedAt: time.Now(), + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Allowed { + t.Errorf("expected denied when job is in progress, got allowed: %s", result.Message) + } + + if result.Message != "Release target is already processing jobs" { + t.Errorf("expected 'Release target is already processing jobs', got '%s'", result.Message) + } + + if result.Details["release_target_key"] != releaseTarget.Key() { + t.Errorf("expected release_target_key=%s, got %v", releaseTarget.Key(), result.Details["release_target_key"]) + } + + jobsMap, ok := result.Details["jobs"].(map[string]*oapi.Job) + if !ok { + t.Fatal("expected jobs to be map[string]*oapi.Job") + } + + if len(jobsMap) != 1 { + t.Errorf("expected 1 job in details, got %d", len(jobsMap)) + } + + if _, exists := jobsMap["job-1"]; !exists { + t.Error("expected job-1 in details") + } +} + +func TestReleaseTargetConcurrencyEvaluator_JobPending(t *testing.T) { + // Setup: Release target with a pending job + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + jobs := []*oapi.Job{ + { + Id: "job-pending", + Status: oapi.Pending, + CreatedAt: time.Now(), + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Allowed { + t.Errorf("expected denied when job is pending, got allowed: %s", result.Message) + } + + jobsMap, _ := result.Details["jobs"].(map[string]*oapi.Job) + if len(jobsMap) != 1 { + t.Errorf("expected 1 job in details, got %d", len(jobsMap)) + } +} + +func TestReleaseTargetConcurrencyEvaluator_JobActionRequired(t *testing.T) { + // Setup: Release target with a job requiring action + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + jobs := []*oapi.Job{ + { + Id: "job-action-required", + Status: oapi.ActionRequired, + CreatedAt: time.Now(), + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Allowed { + t.Errorf("expected denied when job requires action, got allowed: %s", result.Message) + } + + jobsMap2, _ := result.Details["jobs"].(map[string]*oapi.Job) + if len(jobsMap2) != 1 { + t.Errorf("expected 1 job in details, got %d", len(jobsMap2)) + } +} + +func TestReleaseTargetConcurrencyEvaluator_MultipleProcessingJobs(t *testing.T) { + // Setup: Release target with multiple jobs in processing state + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + jobs := []*oapi.Job{ + { + Id: "job-1", + Status: oapi.InProgress, + CreatedAt: time.Now().Add(-1 * time.Hour), + }, + { + Id: "job-2", + Status: oapi.Pending, + CreatedAt: time.Now().Add(-30 * time.Minute), + }, + { + Id: "job-3", + Status: oapi.ActionRequired, + CreatedAt: time.Now(), + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Allowed { + t.Errorf("expected denied when multiple jobs are processing, got allowed: %s", result.Message) + } + + jobsInDetails, _ := result.Details["jobs"].(map[string]*oapi.Job) + if len(jobsInDetails) != 3 { + t.Errorf("expected 3 jobs in details, got %d", len(jobsInDetails)) + } +} + +func TestReleaseTargetConcurrencyEvaluator_OnlyCompletedJobs(t *testing.T) { + // Setup: Release target with only completed jobs + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + completedAt := time.Now() + jobs := []*oapi.Job{ + { + Id: "job-successful", + Status: oapi.Successful, + CreatedAt: time.Now().Add(-2 * time.Hour), + CompletedAt: &completedAt, + }, + { + Id: "job-failed", + Status: oapi.Failure, + CreatedAt: time.Now().Add(-1 * time.Hour), + CompletedAt: &completedAt, + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.Allowed { + t.Errorf("expected allowed when only completed jobs exist, got denied: %s", result.Message) + } +} + +func TestReleaseTargetConcurrencyEvaluator_MixedJobStatuses(t *testing.T) { + // Setup: Release target with both completed and processing jobs + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + completedAt := time.Now() + jobs := []*oapi.Job{ + { + Id: "job-completed", + Status: oapi.Successful, + CreatedAt: time.Now().Add(-2 * time.Hour), + CompletedAt: &completedAt, + }, + { + Id: "job-in-progress", + Status: oapi.InProgress, + CreatedAt: time.Now(), + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Allowed { + t.Errorf("expected denied when at least one job is processing, got allowed: %s", result.Message) + } + + jobsInDetails, _ := result.Details["jobs"].(map[string]*oapi.Job) + if len(jobsInDetails) != 1 { + t.Errorf("expected 1 processing job in details, got %d", len(jobsInDetails)) + } + + if _, exists := jobsInDetails["job-in-progress"]; !exists { + t.Error("expected job-in-progress in details") + } + + if _, exists := jobsInDetails["job-completed"]; exists { + t.Error("expected job-completed NOT to be in details") + } +} + +func TestReleaseTargetConcurrencyEvaluator_CancelledJobsIgnored(t *testing.T) { + // Setup: Release target with cancelled and skipped jobs (terminal states) + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + completedAt := time.Now() + jobs := []*oapi.Job{ + { + Id: "job-cancelled", + Status: oapi.Cancelled, + CreatedAt: time.Now().Add(-1 * time.Hour), + CompletedAt: &completedAt, + }, + { + Id: "job-skipped", + Status: oapi.Skipped, + CreatedAt: time.Now(), + CompletedAt: &completedAt, + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.Allowed { + t.Errorf("expected allowed when only terminal state jobs exist, got denied: %s", result.Message) + } +} + +func TestReleaseTargetConcurrencyEvaluator_DifferentReleaseTarget(t *testing.T) { + // Setup: Jobs exist but for a different release target + releaseTarget1 := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + releaseTarget2 := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-2", // Different resource + } + + jobs := []*oapi.Job{ + { + Id: "job-1", + Status: oapi.InProgress, + CreatedAt: time.Now(), + }, + } + + // Create jobs for releaseTarget1 + st := setupStoreWithReleaseAndJobs(t, releaseTarget1, jobs) + + // Create resource for releaseTarget2 + resource2 := &oapi.Resource{ + Id: releaseTarget2.ResourceId, + Name: "test-resource-2", + Kind: "server", + Identifier: releaseTarget2.ResourceId, + Config: map[string]any{}, + Metadata: map[string]string{}, + Version: "v1", + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + } + st.Resources.Upsert(context.Background(), resource2) + + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act: Evaluate releaseTarget2 (different from the one with jobs) + result, err := evaluator.Evaluate(context.Background(), releaseTarget2) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.Allowed { + t.Errorf("expected allowed for different release target, got denied: %s", result.Message) + } +} + +func TestReleaseTargetConcurrencyEvaluator_AllProcessingStates(t *testing.T) { + // Test each processing state individually + processingStates := []oapi.JobStatus{ + oapi.Pending, + oapi.InProgress, + oapi.ActionRequired, + } + + for _, status := range processingStates { + t.Run(string(status), func(t *testing.T) { + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + jobs := []*oapi.Job{ + { + Id: "job-1", + Status: status, + CreatedAt: time.Now(), + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Allowed { + t.Errorf("expected denied for status %s, got allowed: %s", status, result.Message) + } + + jobsInDetails, _ := result.Details["jobs"].(map[string]*oapi.Job) + if len(jobsInDetails) != 1 { + t.Errorf("expected 1 job in details for status %s, got %d", status, len(jobsInDetails)) + } + }) + } +} + +func TestReleaseTargetConcurrencyEvaluator_AllTerminalStates(t *testing.T) { + // Test that all terminal states are ignored + terminalStates := []oapi.JobStatus{ + oapi.Successful, + oapi.Failure, + oapi.Cancelled, + oapi.Skipped, + oapi.InvalidJobAgent, + oapi.InvalidIntegration, + oapi.ExternalRunNotFound, + } + + for _, status := range terminalStates { + t.Run(string(status), func(t *testing.T) { + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + completedAt := time.Now() + jobs := []*oapi.Job{ + { + Id: "job-1", + Status: status, + CreatedAt: time.Now().Add(-1 * time.Hour), + CompletedAt: &completedAt, + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.Allowed { + t.Errorf("expected allowed for terminal status %s, got denied: %s", status, result.Message) + } + }) + } +} + +func TestReleaseTargetConcurrencyEvaluator_NilReleaseTarget(t *testing.T) { + // Setup: Nil release target + sc := statechange.NewChangeSet[any]() + st := store.New(sc) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), nil) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // When nil release target, GetJobsInProcessingStateForReleaseTarget returns empty map + if !result.Allowed { + t.Errorf("expected allowed for nil release target, got denied: %s", result.Message) + } +} + +func TestReleaseTargetConcurrencyEvaluator_ResultStructure(t *testing.T) { + // Verify result has all expected fields and proper types + releaseTarget := &oapi.ReleaseTarget{ + DeploymentId: "deployment-1", + EnvironmentId: "env-1", + ResourceId: "resource-1", + } + + jobs := []*oapi.Job{ + { + Id: "job-1", + Status: oapi.InProgress, + CreatedAt: time.Now(), + }, + } + + st := setupStoreWithReleaseAndJobs(t, releaseTarget, jobs) + evaluator := NewReleaseTargetConcurrencyEvaluator(st) + + // Act + result, err := evaluator.Evaluate(context.Background(), releaseTarget) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Details == nil { + t.Fatal("expected Details to be initialized") + } + + if _, ok := result.Details["release_target_key"]; !ok { + t.Error("expected Details to contain 'release_target_key'") + } + + if _, ok := result.Details["jobs"]; !ok { + t.Error("expected Details to contain 'jobs'") + } + + if result.Message == "" { + t.Error("expected Message to be set") + } + + // Verify jobs is correct type + jobsMap, ok := result.Details["jobs"].(map[string]*oapi.Job) + if !ok { + t.Error("expected jobs to be map[string]*oapi.Job") + } + + if len(jobsMap) != 1 { + t.Errorf("expected 1 job, got %d", len(jobsMap)) + } +}