Skip to content
Open
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
15 changes: 15 additions & 0 deletions apps/workspace-engine/oapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,21 @@
],
"type": "object"
},
"ReleaseTargetForceDeployEvent": {
"properties": {
"releaseTarget": {
"$ref": "#/components/schemas/ReleaseTarget"
},
"version": {
"$ref": "#/components/schemas/DeploymentVersion"
}
},
"required": [
"releaseTarget",
"version"
],
"type": "object"
},
"ReleaseTargetState": {
"properties": {
"currentRelease": {
Expand Down
3 changes: 2 additions & 1 deletion apps/workspace-engine/oapi/spec/main.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
(import 'schemas/relationship.jsonnet') +
(import 'schemas/jobs.jsonnet') +
(import 'schemas/deployments.jsonnet') +
(import 'schemas/verification.jsonnet'),
(import 'schemas/verification.jsonnet') +
(import 'schemas/release-targets.jsonnet'),
},
}
12 changes: 12 additions & 0 deletions apps/workspace-engine/oapi/spec/schemas/release-targets.jsonnet
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
local openapi = import '../lib/openapi.libsonnet';

{
ReleaseTargetForceDeployEvent: {
type: 'object',
required: ['releaseTarget', 'version'],
properties: {
releaseTarget: openapi.schemaRef('ReleaseTarget'),
version: openapi.schemaRef('DeploymentVersion'),
},
},
}
9 changes: 9 additions & 0 deletions apps/workspace-engine/pkg/events/handler/redeploy/redeploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,12 @@ func HandleReleaseTargetDeploy(ctx context.Context, ws *workspace.Workspace, eve

return nil
}

func HandleReleaseTargetForceDeploy(ctx context.Context, ws *workspace.Workspace, event handler.RawEvent) error {
releaseTargetForceDeployEvent := &oapi.ReleaseTargetForceDeployEvent{}
if err := json.Unmarshal(event.Data, releaseTargetForceDeployEvent); err != nil {
return err
}

return ws.ReleaseManager().ForceDeploy(ctx, &releaseTargetForceDeployEvent.ReleaseTarget, &releaseTargetForceDeployEvent.Version)
}
6 changes: 6 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
Expand Up @@ -58,6 +58,7 @@ type planDeploymentConfig struct {
resourceRelatedEntities map[string][]*oapi.EntityRelation
recorder *trace.ReconcileTarget
earliestVersionForEvaluation *oapi.DeploymentVersion
forceDeployVersion *oapi.DeploymentVersion
}

func WithResourceRelatedEntities(entities map[string][]*oapi.EntityRelation) planDeploymentOptions {
Expand All @@ -78,6 +79,12 @@ func WithVersionAndNewer(version *oapi.DeploymentVersion) planDeploymentOptions
}
}

func WithForceDeployVersion(version *oapi.DeploymentVersion) planDeploymentOptions {
return func(cfg *planDeploymentConfig) {
cfg.forceDeployVersion = version
}
}

// Returns:
// - *oapi.Release: The desired release to deploy
// - nil: No deployable release (no versions or all blocked by policies)
Expand Down Expand Up @@ -126,9 +133,14 @@ func (p *Planner) PlanDeployment(ctx context.Context, releaseTarget *oapi.Releas
return nil, nil
}

deployableVersion := cfg.forceDeployVersion

// Step 2: Find first version that passes user-defined policies
span.AddEvent("Step 2: Finding deployable version")
deployableVersion := p.findDeployableVersion(ctx, candidateVersions, releaseTarget, planning)
if deployableVersion == nil {
span.AddEvent("Step 2: Finding deployable version")
deployableVersion = p.findDeployableVersion(ctx, candidateVersions, releaseTarget, planning)
}
Comment on lines +136 to +142
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 | 🟠 Major

🧩 Analysis chain

Verify validation of forced deployment versions upstream.

The forced version bypasses all policy-based selection (Step 2), which could lead to deploying versions in invalid states or belonging to different deployments if not validated upstream.

Ensure the caller (likely manager.ForceDeploy) validates:

  • The forced version belongs to the correct deployment (matching releaseTarget.DeploymentId)
  • The version status is deployable (not "building", "failed", or "paused")
  • Proper authorization checks are in place since policies are bypassed

Run the following script to check validation in the force deploy flow:


🏁 Script executed:

#!/bin/bash
# Check ForceDeploy implementation and event handler for validation

echo "=== Checking manager.ForceDeploy implementation ==="
ast-grep --pattern $'func ($$$) ForceDeploy($$$) {
  $$$
}'

echo ""
echo "=== Checking HandleReleaseTargetForceDeploy for validation ==="
ast-grep --pattern $'func HandleReleaseTargetForceDeploy($$$) {
  $$$
}'

echo ""
echo "=== Checking if version.DeploymentId is validated ==="
rg -n 'DeploymentId.*==|version.*Deployment' --type=go -g '**/manager.go' -g '**/redeploy.go'

Length of output: 495



🏁 Script executed:

#!/bin/bash
# Extract ForceDeploy implementation
echo "=== ForceDeploy implementation (manager.go:288) ==="
sed -n '288,320p' apps/workspace-engine/pkg/workspace/releasemanager/manager.go

echo ""
echo "=== DeploymentId validation context (versions/manager.go around line 47) ==="
sed -n '40,60p' apps/workspace-engine/pkg/workspace/releasemanager/versions/manager.go

echo ""
echo "=== Check version status validation ==="
rg -n 'Status|state' apps/workspace-engine/pkg/workspace/releasemanager/manager.go -A2 -B2 | head -40

Length of output: 3880



🏁 Script executed:

#!/bin/bash
# Check how forced version is handled in planner
echo "=== Checking planner.go for forced version handling ==="
rg -n 'forceDeployVersion|ForceDeployVersion' apps/workspace-engine/pkg/workspace/releasemanager/deployment/planner.go -B2 -A5

echo ""
echo "=== Check findDeployableVersion in planner ==="
rg -n 'func.*findDeployableVersion' apps/workspace-engine/pkg/workspace/releasemanager/deployment/planner.go -A20 | head -50

echo ""
echo "=== Check if version is validated before deployment ==="
rg -n 'Status|Deployable' apps/workspace-engine/pkg/workspace/releasemanager/deployment/ --type=go | head -20

Length of output: 4849



🏁 Script executed:

#!/bin/bash
# Check what happens after deployableVersion is selected
echo "=== Planner flow after selecting deployableVersion (lines 143-165) ==="
sed -n '143,165p' apps/workspace-engine/pkg/workspace/releasemanager/deployment/planner.go

echo ""
echo "=== Check orchestrator for version validation ==="
rg -n 'deployableVersion|validat' apps/workspace-engine/pkg/workspace/releasemanager/deployment/orchestrator.go --type=go -B2 -A2 | head -60

echo ""
echo "=== Check if deployment executor validates version status ==="
rg -n 'Status.*Ready|Status.*Building|Status.*Failed' apps/workspace-engine/pkg/workspace/releasemanager/ --type=go -l | head -10

Length of output: 1957


Add validation for forced deployment versions to prevent deploying invalid states or wrong deployments.

The forced version (line 136) bypasses all policy validation (Step 2) without checking the version status (could be "Building", "Failed", or "Paused") or confirming it belongs to the correct deployment. Add validation after line 142:

if deployableVersion != nil && deployableVersion.DeploymentId != releaseTarget.DeploymentId {
	span.AddEvent("Forced version belongs to different deployment")
	span.SetStatus(codes.Error, "invalid_forced_version")
	return nil, fmt.Errorf("forced version %s does not belong to deployment %s", deployableVersion.Id, releaseTarget.DeploymentId)
}

if deployableVersion != nil && deployableVersion.Status != oapi.DeploymentVersionStatusReady {
	span.AddEvent("Forced version is not ready for deployment", attribute.String("status", string(deployableVersion.Status)))
	span.SetStatus(codes.Error, "invalid_version_status")
	return nil, fmt.Errorf("forced version %s is in %s state, cannot deploy", deployableVersion.Id, deployableVersion.Status)
}

Also add validation in ForceDeploy (manager.go:288) before passing the version to ReconcileTarget, or clearly document that callers are responsible for validation.

🤖 Prompt for AI Agents
In apps/workspace-engine/pkg/workspace/releasemanager/deployment/planner.go
around lines 136-142, the current logic accepts cfg.forceDeployVersion without
validation allowing forced deployments of versions that belong to other
deployments or that are in invalid statuses (e.g., Building, Failed, Paused);
add checks after the existing block to verify deployableVersion != nil then (1)
ensure deployableVersion.DeploymentId equals releaseTarget.DeploymentId and
return a clear error/trace/span event if not, and (2) ensure
deployableVersion.Status == oapi.DeploymentVersionStatusReady and return a clear
error/trace/span event if not; also add equivalent validation in manager.go at
ForceDeploy (around line 288) before calling ReconcileTarget (or document
callers must validate) so forced versions cannot bypass policy/status checks.


if deployableVersion == nil {
span.AddEvent("No deployable version found (blocked by policies)")
span.SetAttributes(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func (o *DeploymentOrchestrator) Reconcile(
deployment.WithResourceRelatedEntities(options.resourceRelationships),
deployment.WithTraceRecorder(recorder),
deployment.WithVersionAndNewer(options.earliestVersionForEvaluation),
deployment.WithForceDeployVersion(options.forceDeployVersion),
)
if err != nil {
span.RecordError(err)
Expand Down
10 changes: 10 additions & 0 deletions apps/workspace-engine/pkg/workspace/releasemanager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ func (m *Manager) Redeploy(ctx context.Context, releaseTarget *oapi.ReleaseTarge
WithTrigger(trace.TriggerManual))
}

func (m *Manager) ForceDeploy(ctx context.Context, releaseTarget *oapi.ReleaseTarget, version *oapi.DeploymentVersion) error {
ctx, span := tracer.Start(ctx, "ForceDeploy")
defer span.End()

return m.ReconcileTarget(ctx, releaseTarget,
WithSkipEligibilityCheck(true),
WithTrigger(trace.TriggerManual),
WithForceDeployVersion(version))
}
Comment on lines +288 to +296
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 | 🔴 Critical

Add validation and documentation.

The ForceDeploy method has several concerns compared to the similar Redeploy method:

  1. Missing in-progress job check: Unlike Redeploy (lines 262-281), this doesn't check if a job is already processing. This could create multiple concurrent jobs for the same release target, leading to race conditions and wasted resources.

  2. Missing input validation: No nil check on the version parameter or verification that the version belongs to the target's deployment.

  3. Missing documentation: No godoc explaining what "force deploy" means, how it differs from Redeploy, or when to use it.

Consider applying this pattern:

+// ForceDeploy forces deployment of a specific version for a release target, 
+// bypassing policy-based version selection and eligibility checks.
+//
+// Unlike Redeploy which uses the policy-determined version, ForceDeploy deploys
+// the exact version specified, regardless of policies. This is useful for rollbacks
+// or emergency deployments.
+//
+// Returns error if:
+//   - version is nil
+//   - version doesn't belong to the target's deployment
+//   - A job is already in progress for this release target
+//   - Planning or execution fails
 func (m *Manager) ForceDeploy(ctx context.Context, releaseTarget *oapi.ReleaseTarget, version *oapi.DeploymentVersion) error {
 	ctx, span := tracer.Start(ctx, "ForceDeploy")
 	defer span.End()
 
+	// Validate inputs
+	if version == nil {
+		err := fmt.Errorf("version cannot be nil")
+		span.RecordError(err)
+		span.SetStatus(codes.Error, "invalid input")
+		return err
+	}
+
+	if version.DeploymentId != releaseTarget.DeploymentId {
+		err := fmt.Errorf("version %s does not belong to deployment %s", version.Id, releaseTarget.DeploymentId)
+		span.RecordError(err)
+		span.SetStatus(codes.Error, "version mismatch")
+		return err
+	}
+
+	// Check if there's already a job in progress for this release target
+	inProgressJobs := m.store.Jobs.GetJobsInProcessingStateForReleaseTarget(releaseTarget)
+	if len(inProgressJobs) > 0 {
+		var jobId string
+		var jobStatus oapi.JobStatus
+		for _, job := range inProgressJobs {
+			jobId = job.Id
+			jobStatus = job.Status
+			break
+		}
+
+		err := fmt.Errorf("cannot force deploy: job %s already in progress (status: %s)", jobId, jobStatus)
+		span.RecordError(err)
+		span.SetStatus(codes.Error, "job in progress")
+		log.Warn("ForceDeploy blocked: job already in progress",
+			"releaseTargetKey", releaseTarget.Key(),
+			"jobId", jobId,
+			"jobStatus", jobStatus)
+		return err
+	}
+
 	return m.ReconcileTarget(ctx, releaseTarget,
 		WithSkipEligibilityCheck(true),
 		WithTrigger(trace.TriggerManual),
 		WithForceDeployVersion(version))
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/workspace-engine/pkg/workspace/releasemanager/manager.go around lines
288 to 296, the ForceDeploy method lacks the in-progress job check, input
validation, and documentation present on Redeploy; add a godoc comment
explaining what ForceDeploy does and how it differs from Redeploy (e.g., forces
a specific deployment version, bypasses eligibility checks), then in the method
body perform the same "is job already processing" check used by Redeploy to
return ErrJobProcessing if a job exists, validate that the version parameter is
non-nil and that the version.DeploymentID (or equivalent) matches the
releaseTarget's deployment ID (return an appropriate error if not), and only
then call ReconcileTarget with WithSkipEligibilityCheck(true),
WithTrigger(trace.TriggerManual), and WithForceDeployVersion(version).


// reconcileTargetWithRelationships is like ReconcileTarget but accepts pre-computed resource relationships.
// This is an optimization to avoid recomputing relationships for multiple release targets that share the same resource.
// After reconciliation completes, it caches the computed state for use by other APIs.
Expand Down
7 changes: 7 additions & 0 deletions apps/workspace-engine/pkg/workspace/releasemanager/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type options struct {
trigger trace.TriggerReason
resourceRelationships map[string][]*oapi.EntityRelation
earliestVersionForEvaluation *oapi.DeploymentVersion
forceDeployVersion *oapi.DeploymentVersion

// StateCache options
bypassCache bool
Expand Down Expand Up @@ -42,6 +43,12 @@ func WithVersionAndNewer(version *oapi.DeploymentVersion) Option {
}
}

func WithForceDeployVersion(version *oapi.DeploymentVersion) Option {
return func(opts *options) {
opts.forceDeployVersion = version
}
}

// StateCache options

func WithResourceRelationships(relationships map[string][]*oapi.EntityRelation) Option {
Expand Down
Loading