Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/workspace-engine/oapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -691,6 +712,9 @@
"environmentProgression": {
"$ref": "#/components/schemas/EnvironmentProgressionRule"
},
"gradualRollout": {
"$ref": "#/components/schemas/GradualRolloutRule"
},
"id": {
"type": "string"
},
Expand Down
11 changes: 11 additions & 0 deletions apps/workspace-engine/oapi/spec/schemas/policy.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
},

Expand Down
8 changes: 8 additions & 0 deletions apps/workspace-engine/pkg/oapi/oapi.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +144 to +147
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reconsider error handling pattern.

Returning both a Denied result and an error creates ambiguity for callers—should they check the error or inspect the result? Typically, when an error occurs during evaluation, you'd return (nil, error) to signal that evaluation couldn't complete, reserving non-nil results for successful evaluations only.

Apply this pattern:

 rolloutPosition, err := e.getRolloutPositionForTarget(ctx, environment, version, releaseTarget)
 if err != nil {
-  return results.NewDeniedResult("Failed to get rollout position"), err
+  return nil, fmt.Errorf("failed to get rollout position: %w", err)
 }

</comment_end -->

🤖 Prompt for AI Agents
In
apps/workspace-engine/pkg/workspace/releasemanager/policy/evaluator/gradualrollout/gradualrollout.go
around lines 144–147, the code currently returns a Denied result plus an error
when getRolloutPositionForTarget fails; change this to return (nil, err) so
callers can rely on the error to indicate failure and non-nil results only for
successful evaluations. Concretely, remove creation/return of
results.NewDeniedResult on error and return nil with the propagated error;
ensure any upstream callers expect a nil result when an error is returned
(adjust tests or call sites if they currently inspect the result instead of the
error).


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
}
Loading
Loading