diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index 0f5a4d2d8..96dcaeddb 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -370,6 +370,27 @@ ], "type": "object" }, + "GradualRolloutRule": { + "properties": { + "id": { + "type": "string" + }, + "policyId": { + "type": "string" + }, + "timeScaleInterval": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "policyId", + "timeScaleInterval" + ], + "type": "object" + }, "IntegerValue": { "type": "integer" }, @@ -691,6 +712,9 @@ "environmentProgression": { "$ref": "#/components/schemas/EnvironmentProgressionRule" }, + "gradualRollout": { + "$ref": "#/components/schemas/GradualRolloutRule" + }, "id": { "type": "string" }, diff --git a/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet b/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet index 202b0f723..641ffc0ce 100644 --- a/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet @@ -48,6 +48,17 @@ local openapi = import '../lib/openapi.libsonnet'; createdAt: { type: 'string' }, anyApproval: openapi.schemaRef('AnyApprovalRule'), environmentProgression: openapi.schemaRef('EnvironmentProgressionRule'), + gradualRollout: openapi.schemaRef('GradualRolloutRule'), + }, + }, + + GradualRolloutRule: { + type: 'object', + required: ['id', 'policyId', 'timeScaleInterval'], + properties: { + id: { type: 'string' }, + policyId: { type: 'string' }, + timeScaleInterval: { type: 'integer', format: 'int32', minimum: 0 }, }, }, diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index 41a0b66a9..b70263c09 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -223,6 +223,13 @@ type GithubEntity struct { Slug string `json:"slug"` } +// GradualRolloutRule defines model for GradualRolloutRule. +type GradualRolloutRule struct { + Id string `json:"id"` + PolicyId string `json:"policyId"` + TimeScaleInterval int32 `json:"timeScaleInterval"` +} + // IntegerValue defines model for IntegerValue. type IntegerValue = int @@ -330,6 +337,7 @@ type PolicyRule struct { AnyApproval *AnyApprovalRule `json:"anyApproval,omitempty"` CreatedAt string `json:"createdAt"` EnvironmentProgression *EnvironmentProgressionRule `json:"environmentProgression,omitempty"` + GradualRollout *GradualRolloutRule `json:"gradualRollout,omitempty"` Id string `json:"id"` PolicyId string `json:"policyId"` } diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/gradualrollout/gradualrollout.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/gradualrollout/gradualrollout.go new file mode 100644 index 000000000..36fc6f357 --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/gradualrollout/gradualrollout.go @@ -0,0 +1,158 @@ +package gradualrollout + +import ( + "context" + "errors" + "fmt" + "hash/fnv" + "sort" + "time" + "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.EnvironmentAndVersionAndTargetScopedEvaluator = &GradualRolloutEvaluator{} + +var fnvHashingFn = func(releaseTarget *oapi.ReleaseTarget, versionID string) (uint64, error) { + h := fnv.New64a() + h.Write([]byte(releaseTarget.Key() + versionID)) + return h.Sum64(), nil +} + +type GradualRolloutEvaluator struct { + store *store.Store + rule *oapi.GradualRolloutRule + hashingFn func(releaseTarget *oapi.ReleaseTarget, versionID string) (uint64, error) + timeGetter func() time.Time +} + +func NewGradualRolloutEvaluator(store *store.Store, rule *oapi.GradualRolloutRule) *GradualRolloutEvaluator { + return &GradualRolloutEvaluator{ + store: store, + rule: rule, + hashingFn: fnvHashingFn, + timeGetter: func() time.Time { + return time.Now() + }, + } +} + +func (e *GradualRolloutEvaluator) getRolloutStartTime(ctx context.Context, environment *oapi.Environment, version *oapi.DeploymentVersion, releaseTarget *oapi.ReleaseTarget) (*time.Time, error) { + policiesForTarget, err := e.store.ReleaseTargets.GetPolicies(ctx, releaseTarget) + if err != nil { + return nil, err + } + + maxMinApprovals := int32(0) + + for _, policy := range policiesForTarget { + if !policy.Enabled { + continue + } + for _, rule := range policy.Rules { + if rule.AnyApproval != nil && rule.AnyApproval.MinApprovals > maxMinApprovals { + maxMinApprovals = rule.AnyApproval.MinApprovals + } + } + } + + if maxMinApprovals == 0 { + return &version.CreatedAt, nil + } + + approvalRecords := e.store.UserApprovalRecords.GetApprovalRecords(version.Id, environment.Id) + if len(approvalRecords) < int(maxMinApprovals) { + return nil, nil + } + + firstApprovalRecordSatisfyingMinimumRequired := approvalRecords[maxMinApprovals-1] + approvalTime, err := time.Parse(time.RFC3339, firstApprovalRecordSatisfyingMinimumRequired.CreatedAt) + if err != nil { + return nil, err + } + + return &approvalTime, nil +} + +func (e *GradualRolloutEvaluator) getRolloutPositionForTarget(ctx context.Context, environment *oapi.Environment, version *oapi.DeploymentVersion, releaseTarget *oapi.ReleaseTarget) (int32, error) { + allReleaseTargets, err := e.store.ReleaseTargets.Items(ctx) + if err != nil { + return 0, err + } + + var relevantTargets []*oapi.ReleaseTarget + for _, target := range allReleaseTargets { + if target.EnvironmentId == environment.Id && target.DeploymentId == version.DeploymentId { + relevantTargets = append(relevantTargets, target) + } + } + + // Create a slice with target IDs and their hash values + type targetWithHash struct { + target *oapi.ReleaseTarget + hash uint64 + } + + targetsWithHashes := make([]targetWithHash, len(relevantTargets)) + for i, target := range relevantTargets { + hash, err := e.hashingFn(target, version.Id) + if err != nil { + return 0, err + } + targetsWithHashes[i] = targetWithHash{ + target: target, + hash: hash, + } + } + + // Sort by hash value + sort.Slice(targetsWithHashes, func(i, j int) bool { + return targetsWithHashes[i].hash < targetsWithHashes[j].hash + }) + + // Find position of the current release target + for i, t := range targetsWithHashes { + if t.target.Key() == releaseTarget.Key() { + return int32(i), nil + } + } + + return 0, errors.New("release target not found in sorted list") +} + +func (e *GradualRolloutEvaluator) getDeploymentOffset(rolloutPosition int32, timeScaleInterval int32) time.Duration { + return time.Duration(rolloutPosition) * time.Duration(timeScaleInterval) * time.Minute +} + +func (e *GradualRolloutEvaluator) Evaluate(ctx context.Context, environment *oapi.Environment, version *oapi.DeploymentVersion, releaseTarget *oapi.ReleaseTarget) (*oapi.RuleEvaluation, error) { + now := e.timeGetter() + rolloutStartTime, err := e.getRolloutStartTime(ctx, environment, version, releaseTarget) + if err != nil { + return nil, err + } + + if rolloutStartTime == nil { + return results.NewPendingResult(results.ActionTypeWait, "Rollout has not started yet"), nil + } + + if now.Before(*rolloutStartTime) { + return results.NewPendingResult(results.ActionTypeWait, "Rollout has not started yet"), nil + } + + rolloutPosition, err := e.getRolloutPositionForTarget(ctx, environment, version, releaseTarget) + if err != nil { + return results.NewDeniedResult("Failed to get rollout position"), err + } + + deploymentOffset := e.getDeploymentOffset(rolloutPosition, e.rule.TimeScaleInterval) + deploymentTime := rolloutStartTime.Add(deploymentOffset) + + if now.Before(deploymentTime) { + reason := fmt.Sprintf("Rollout will start at %s for this release target", deploymentTime.Format(time.RFC3339)) + return results.NewPendingResult(results.ActionTypeWait, reason), nil + } + + return results.NewAllowedResult("Rollout has progressed to this release target"), nil +} diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/gradualrollout/gradualrollout_test.go b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/gradualrollout/gradualrollout_test.go new file mode 100644 index 000000000..e264623b6 --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/gradualrollout/gradualrollout_test.go @@ -0,0 +1,493 @@ +package gradualrollout + +import ( + "context" + "fmt" + "strconv" + "testing" + "time" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/statechange" + "workspace-engine/pkg/workspace/store" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func generateResourceSelector() *oapi.Selector { + selector := &oapi.Selector{} + _ = selector.FromJsonSelector(oapi.JsonSelector{ + Json: map[string]any{ + "type": "identifier", + "operator": "starts-with", + "value": "test-resource-", + }, + }) + return selector +} + +func generateMatchAllSelector() *oapi.Selector { + selector := &oapi.Selector{} + _ = selector.FromCelSelector(oapi.CelSelector{ + Cel: "true", + }) + return selector +} + +func generateEnvironment(ctx context.Context, systemID string, store *store.Store) *oapi.Environment { + environment := &oapi.Environment{ + SystemId: systemID, + Id: uuid.New().String(), + ResourceSelector: generateResourceSelector(), + } + store.Environments.Upsert(ctx, environment) + return environment +} + +func generateDeployment(ctx context.Context, systemID string, store *store.Store) *oapi.Deployment { + deployment := &oapi.Deployment{ + SystemId: systemID, + Id: uuid.New().String(), + ResourceSelector: generateResourceSelector(), + } + store.Deployments.Upsert(ctx, deployment) + return deployment +} + +func generateResources(ctx context.Context, numResources int, store *store.Store) []*oapi.Resource { + resources := make([]*oapi.Resource, numResources) + for i := 0; i < numResources; i++ { + resource := &oapi.Resource{ + Id: uuid.New().String(), + Identifier: fmt.Sprintf("test-resource-%d", i), + Kind: "service", + } + store.Resources.Upsert(ctx, resource) + resources[i] = resource + } + return resources +} + +func generateDeploymentVersion(ctx context.Context, deploymentID string, createdAt time.Time, store *store.Store) *oapi.DeploymentVersion { + deploymentVersion := &oapi.DeploymentVersion{ + Id: uuid.New().String(), + DeploymentId: deploymentID, + Tag: "v1", + CreatedAt: createdAt, + } + store.DeploymentVersions.Upsert(ctx, deploymentVersion.Id, deploymentVersion) + return deploymentVersion +} + +// Mock hasher that just returns the number of the resource as its hash +func getHashingFunc(st *store.Store) func(releaseTarget *oapi.ReleaseTarget, versionID string) (uint64, error) { + return func(releaseTarget *oapi.ReleaseTarget, versionID string) (uint64, error) { + + resource, ok := st.Resources.Get(releaseTarget.ResourceId) + if !ok { + return 0, fmt.Errorf("resource not found: %s", releaseTarget.ResourceId) + } + resourceNumString := resource.Identifier[len("test-resource-"):] + resourceNum, err := strconv.Atoi(resourceNumString) + if err != nil { + return 0, fmt.Errorf("failed to convert resource number to int: %w", err) + } + return uint64(resourceNum), nil + } +} + +func TestGradualRolloutEvaluator_BasicLinearRollout(t *testing.T) { + ctx := t.Context() + + sc := statechange.NewChangeSet[any]() + st := store.New(sc) + + systemID := uuid.New().String() + environment := generateEnvironment(ctx, systemID, st) + deployment := generateDeployment(ctx, systemID, st) + + resources := generateResources(ctx, 3, st) + + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + versionCreatedAt := baseTime + version := generateDeploymentVersion(ctx, deployment.Id, versionCreatedAt, st) + + hashingFn := getHashingFunc(st) + oneHourLater := baseTime.Add(1 * time.Hour) + timeGetter := func() time.Time { + return oneHourLater + } + + rule := &oapi.GradualRolloutRule{ + TimeScaleInterval: 60, + } + evaluator := GradualRolloutEvaluator{ + store: st, + rule: rule, + hashingFn: hashingFn, + timeGetter: timeGetter, + } + + key1 := fmt.Sprintf("%s-%s-%s", resources[0].Id, environment.Id, deployment.Id) + releaseTarget1 := st.ReleaseTargets.Get(key1) + if releaseTarget1 == nil { + t.Fatalf("release target not found: %s", key1) + } + + key2 := fmt.Sprintf("%s-%s-%s", resources[1].Id, environment.Id, deployment.Id) + releaseTarget2 := st.ReleaseTargets.Get(key2) + if releaseTarget2 == nil { + t.Fatalf("release target not found: %s", key2) + } + + key3 := fmt.Sprintf("%s-%s-%s", resources[2].Id, environment.Id, deployment.Id) + releaseTarget3 := st.ReleaseTargets.Get(key3) + if releaseTarget3 == nil { + t.Fatalf("release target not found: %s", key3) + } + + result1, err := evaluator.Evaluate(ctx, environment, version, releaseTarget1) + if err != nil { + t.Fatalf("error evaluating release target 1: %v", err) + } + + assert.True(t, result1.Allowed) + assert.False(t, result1.ActionRequired) + assert.Nil(t, result1.ActionType) + assert.Equal(t, result1.Message, "Rollout has progressed to this release target") + + result2, err := evaluator.Evaluate(ctx, environment, version, releaseTarget2) + if err != nil { + t.Fatalf("error evaluating release target 2: %v", err) + } + + assert.True(t, result2.Allowed) + assert.False(t, result2.ActionRequired) + assert.Nil(t, result2.ActionType) + assert.Equal(t, result2.Message, "Rollout has progressed to this release target") + + result3, err := evaluator.Evaluate(ctx, environment, version, releaseTarget3) + if err != nil { + t.Fatalf("error evaluating release target 3: %v", err) + } + + assert.False(t, result3.Allowed) + assert.True(t, result3.ActionRequired) + assert.Equal(t, *result3.ActionType, oapi.Wait) + assert.Equal(t, result3.Message, "Rollout will start at 2025-01-01T02:00:00Z for this release target") +} + +func TestGradualRolloutEvaluator_ZeroTimeScaleIntervalStartsImmediately(t *testing.T) { + ctx := t.Context() + + sc := statechange.NewChangeSet[any]() + st := store.New(sc) + + systemID := uuid.New().String() + environment := generateEnvironment(ctx, systemID, st) + deployment := generateDeployment(ctx, systemID, st) + + resources := generateResources(ctx, 3, st) + + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + versionCreatedAt := baseTime + version := generateDeploymentVersion(ctx, deployment.Id, versionCreatedAt, st) + + hashingFn := getHashingFunc(st) + oneHourLater := baseTime.Add(1 * time.Hour) + timeGetter := func() time.Time { + return oneHourLater + } + + rule := &oapi.GradualRolloutRule{ + TimeScaleInterval: 0, + } + evaluator := GradualRolloutEvaluator{ + store: st, + rule: rule, + hashingFn: hashingFn, + timeGetter: timeGetter, + } + + key1 := fmt.Sprintf("%s-%s-%s", resources[0].Id, environment.Id, deployment.Id) + releaseTarget1 := st.ReleaseTargets.Get(key1) + if releaseTarget1 == nil { + t.Fatalf("release target not found: %s", key1) + } + + key2 := fmt.Sprintf("%s-%s-%s", resources[1].Id, environment.Id, deployment.Id) + releaseTarget2 := st.ReleaseTargets.Get(key2) + if releaseTarget2 == nil { + t.Fatalf("release target not found: %s", key2) + } + + key3 := fmt.Sprintf("%s-%s-%s", resources[2].Id, environment.Id, deployment.Id) + releaseTarget3 := st.ReleaseTargets.Get(key3) + if releaseTarget3 == nil { + t.Fatalf("release target not found: %s", key3) + } + + result1, err := evaluator.Evaluate(ctx, environment, version, releaseTarget1) + if err != nil { + t.Fatalf("error evaluating release target 1: %v", err) + } + + assert.True(t, result1.Allowed) + assert.False(t, result1.ActionRequired) + assert.Nil(t, result1.ActionType) + assert.Equal(t, result1.Message, "Rollout has progressed to this release target") + + result2, err := evaluator.Evaluate(ctx, environment, version, releaseTarget2) + if err != nil { + t.Fatalf("error evaluating release target 2: %v", err) + } + + assert.True(t, result2.Allowed) + assert.False(t, result2.ActionRequired) + assert.Nil(t, result2.ActionType) + assert.Equal(t, result2.Message, "Rollout has progressed to this release target") + + result3, err := evaluator.Evaluate(ctx, environment, version, releaseTarget3) + if err != nil { + t.Fatalf("error evaluating release target 3: %v", err) + } + + assert.True(t, result3.Allowed) + assert.False(t, result3.ActionRequired) + assert.Nil(t, result3.ActionType) + assert.Equal(t, result3.Message, "Rollout has progressed to this release target") +} + +func TestGradualRolloutEvaluator_UnsatisfiedApprovalRequirement(t *testing.T) { + ctx := t.Context() + + sc := statechange.NewChangeSet[any]() + st := store.New(sc) + + systemID := uuid.New().String() + environment := generateEnvironment(ctx, systemID, st) + deployment := generateDeployment(ctx, systemID, st) + + resources := generateResources(ctx, 3, st) + + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + versionCreatedAt := baseTime + version := generateDeploymentVersion(ctx, deployment.Id, versionCreatedAt, st) + + hashingFn := getHashingFunc(st) + twoHoursLater := baseTime.Add(2 * time.Hour) + timeGetter := func() time.Time { + return twoHoursLater + } + + rule := &oapi.GradualRolloutRule{ + TimeScaleInterval: 60, + } + evaluator := GradualRolloutEvaluator{ + store: st, + rule: rule, + hashingFn: hashingFn, + timeGetter: timeGetter, + } + + approvalPolicy := &oapi.Policy{ + Enabled: true, + Selectors: []oapi.PolicyTargetSelector{ + { + ResourceSelector: generateResourceSelector(), + DeploymentSelector: generateMatchAllSelector(), + EnvironmentSelector: generateMatchAllSelector(), + }, + }, + Rules: []oapi.PolicyRule{ + { + AnyApproval: &oapi.AnyApprovalRule{ + MinApprovals: 2, + }, + }, + }, + } + + st.Policies.Upsert(ctx, approvalPolicy) + st.UserApprovalRecords.Upsert(ctx, &oapi.UserApprovalRecord{ + VersionId: version.Id, + EnvironmentId: environment.Id, + UserId: "user-1", + Status: oapi.ApprovalStatusApproved, + CreatedAt: baseTime.Format(time.RFC3339), + }) + + key1 := fmt.Sprintf("%s-%s-%s", resources[0].Id, environment.Id, deployment.Id) + releaseTarget1 := st.ReleaseTargets.Get(key1) + if releaseTarget1 == nil { + t.Fatalf("release target not found: %s", key1) + } + + key2 := fmt.Sprintf("%s-%s-%s", resources[1].Id, environment.Id, deployment.Id) + releaseTarget2 := st.ReleaseTargets.Get(key2) + if releaseTarget2 == nil { + t.Fatalf("release target not found: %s", key2) + } + + key3 := fmt.Sprintf("%s-%s-%s", resources[2].Id, environment.Id, deployment.Id) + releaseTarget3 := st.ReleaseTargets.Get(key3) + if releaseTarget3 == nil { + t.Fatalf("release target not found: %s", key3) + } + + result1, err := evaluator.Evaluate(ctx, environment, version, releaseTarget1) + if err != nil { + t.Fatalf("error evaluating release target 1: %v", err) + } + + assert.False(t, result1.Allowed) + assert.True(t, result1.ActionRequired) + assert.Equal(t, *result1.ActionType, oapi.Wait) + assert.Equal(t, result1.Message, "Rollout has not started yet") + + result2, err := evaluator.Evaluate(ctx, environment, version, releaseTarget2) + if err != nil { + t.Fatalf("error evaluating release target 2: %v", err) + } + + assert.False(t, result2.Allowed) + assert.True(t, result2.ActionRequired) + assert.Equal(t, *result2.ActionType, oapi.Wait) + assert.Equal(t, result2.Message, "Rollout has not started yet") + + result3, err := evaluator.Evaluate(ctx, environment, version, releaseTarget3) + if err != nil { + t.Fatalf("error evaluating release target 3: %v", err) + } + + assert.False(t, result3.Allowed) + assert.True(t, result3.ActionRequired) + assert.Equal(t, *result3.ActionType, oapi.Wait) + assert.Equal(t, result3.Message, "Rollout has not started yet") +} + +func TestGradualRolloutEvaluator_SatisfiedApprovalRequirement(t *testing.T) { + ctx := t.Context() + + sc := statechange.NewChangeSet[any]() + st := store.New(sc) + + systemID := uuid.New().String() + environment := generateEnvironment(ctx, systemID, st) + deployment := generateDeployment(ctx, systemID, st) + + resources := generateResources(ctx, 3, st) + + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + oneHourLater := baseTime.Add(1 * time.Hour) + twoHoursLater := baseTime.Add(2 * time.Hour) + + versionCreatedAt := baseTime + version := generateDeploymentVersion(ctx, deployment.Id, versionCreatedAt, st) + + hashingFn := getHashingFunc(st) + timeGetter := func() time.Time { + return twoHoursLater + } + + rule := &oapi.GradualRolloutRule{ + TimeScaleInterval: 60, + } + evaluator := GradualRolloutEvaluator{ + store: st, + rule: rule, + hashingFn: hashingFn, + timeGetter: timeGetter, + } + + approvalPolicy := &oapi.Policy{ + Enabled: true, + Selectors: []oapi.PolicyTargetSelector{ + { + ResourceSelector: generateResourceSelector(), + DeploymentSelector: generateMatchAllSelector(), + EnvironmentSelector: generateMatchAllSelector(), + }, + }, + Rules: []oapi.PolicyRule{ + { + AnyApproval: &oapi.AnyApprovalRule{ + MinApprovals: 2, + }, + }, + }, + } + + st.Policies.Upsert(ctx, approvalPolicy) + st.UserApprovalRecords.Upsert(ctx, &oapi.UserApprovalRecord{ + VersionId: version.Id, + EnvironmentId: environment.Id, + UserId: "user-1", + Status: oapi.ApprovalStatusApproved, + CreatedAt: baseTime.Format(time.RFC3339), + }) + + st.UserApprovalRecords.Upsert(ctx, &oapi.UserApprovalRecord{ + VersionId: version.Id, + EnvironmentId: environment.Id, + UserId: "user-2", + Status: oapi.ApprovalStatusApproved, + CreatedAt: oneHourLater.Format(time.RFC3339), + }) + + st.UserApprovalRecords.Upsert(ctx, &oapi.UserApprovalRecord{ + VersionId: version.Id, + EnvironmentId: environment.Id, + UserId: "user-3", + Status: oapi.ApprovalStatusApproved, + CreatedAt: twoHoursLater.Format(time.RFC3339), + }) + + key1 := fmt.Sprintf("%s-%s-%s", resources[0].Id, environment.Id, deployment.Id) + releaseTarget1 := st.ReleaseTargets.Get(key1) + if releaseTarget1 == nil { + t.Fatalf("release target not found: %s", key1) + } + + key2 := fmt.Sprintf("%s-%s-%s", resources[1].Id, environment.Id, deployment.Id) + releaseTarget2 := st.ReleaseTargets.Get(key2) + if releaseTarget2 == nil { + t.Fatalf("release target not found: %s", key2) + } + + key3 := fmt.Sprintf("%s-%s-%s", resources[2].Id, environment.Id, deployment.Id) + releaseTarget3 := st.ReleaseTargets.Get(key3) + if releaseTarget3 == nil { + t.Fatalf("release target not found: %s", key3) + } + + result1, err := evaluator.Evaluate(ctx, environment, version, releaseTarget1) + if err != nil { + t.Fatalf("error evaluating release target 1: %v", err) + } + + assert.True(t, result1.Allowed) + assert.False(t, result1.ActionRequired) + assert.Nil(t, result1.ActionType) + assert.Equal(t, result1.Message, "Rollout has progressed to this release target") + + result2, err := evaluator.Evaluate(ctx, environment, version, releaseTarget2) + if err != nil { + t.Fatalf("error evaluating release target 2: %v", err) + } + + assert.True(t, result2.Allowed) + assert.False(t, result2.ActionRequired) + assert.Nil(t, result2.ActionType) + assert.Equal(t, result2.Message, "Rollout has progressed to this release target") + + result3, err := evaluator.Evaluate(ctx, environment, version, releaseTarget3) + if err != nil { + t.Fatalf("error evaluating release target 3: %v", err) + } + + assert.False(t, result3.Allowed) + assert.True(t, result3.ActionRequired) + assert.Equal(t, *result3.ActionType, oapi.Wait) + assert.Equal(t, result3.Message, "Rollout will start at 2025-01-01T03:00:00Z for this release target") +} 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 792ba1029..59657dd8d 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/types.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/types.go @@ -51,3 +51,12 @@ type EnvironmentAndVersionScopedEvaluator interface { version *oapi.DeploymentVersion, ) (*oapi.RuleEvaluation, error) } + +type EnvironmentAndVersionAndTargetScopedEvaluator interface { + Evaluate( + ctx context.Context, + environment *oapi.Environment, + version *oapi.DeploymentVersion, + releaseTarget *oapi.ReleaseTarget, + ) (*oapi.RuleEvaluation, error) +} diff --git a/apps/workspace-engine/pkg/workspace/store/user_approval_records.go b/apps/workspace-engine/pkg/workspace/store/user_approval_records.go index faa183835..3a1308785 100644 --- a/apps/workspace-engine/pkg/workspace/store/user_approval_records.go +++ b/apps/workspace-engine/pkg/workspace/store/user_approval_records.go @@ -2,6 +2,8 @@ package store import ( "context" + "sort" + "time" "workspace-engine/pkg/changeset" "workspace-engine/pkg/oapi" "workspace-engine/pkg/workspace/store/repository" @@ -55,3 +57,21 @@ func (u *UserApprovalRecords) GetApprovers(versionId, environmentId string) []st } return approvers } + +func (u *UserApprovalRecords) GetApprovalRecords(versionId, environmentId string) []*oapi.UserApprovalRecord { + records := make([]*oapi.UserApprovalRecord, 0) + for record := range u.repo.UserApprovalRecords.IterBuffered() { + if record.Val.VersionId == versionId && record.Val.EnvironmentId == environmentId && record.Val.Status == oapi.ApprovalStatusApproved { + records = append(records, record.Val) + } + } + sort.Slice(records, func(i, j int) bool { + ti, ei := time.Parse(time.RFC3339, records[i].CreatedAt) + tj, ej := time.Parse(time.RFC3339, records[j].CreatedAt) + if ei != nil || ej != nil { + return records[i].CreatedAt < records[j].CreatedAt + } + return ti.Before(tj) + }) + return records +}