From 08ff21e33d85d5e6ad53397acaf5e3175bd92893 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Sun, 15 Feb 2026 21:29:22 -0800 Subject: [PATCH 01/10] init --- apps/workspace-engine/oapi/openapi.json | 26 +++ .../oapi/spec/schemas/deployments.jsonnet | 11 + .../events/handler/deployment/deployment.go | 39 ++-- apps/workspace-engine/pkg/oapi/oapi.gen.go | 28 ++- .../pkg/workspace/jobs/factory.go | 195 +++++++++++++----- .../pkg/workspace/jobs/factory_test.go | 79 ++++--- .../releasemanager/deployment/executor.go | 74 +++---- .../deployment/executor_test.go | 41 ++-- 8 files changed, 311 insertions(+), 182 deletions(-) diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index 9f15e2625..2ba20bc1d 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -176,6 +176,12 @@ "jobAgentId": { "type": "string" }, + "jobAgents": { + "items": { + "$ref": "#/components/schemas/DeploymentJobAgent" + }, + "type": "array" + }, "metadata": { "additionalProperties": { "type": "string" @@ -238,6 +244,26 @@ ], "type": "object" }, + "DeploymentJobAgent": { + "properties": { + "config": { + "$ref": "#/components/schemas/JobAgentConfig" + }, + "if": { + "description": "CEL expression to determine if the job agent should be used", + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "ref", + "config", + "if" + ], + "type": "object" + }, "DeploymentVariable": { "properties": { "defaultValue": { diff --git a/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet b/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet index 4e950452a..6cb80527e 100644 --- a/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet @@ -12,11 +12,22 @@ local openapi = import '../lib/openapi.libsonnet'; systemIds: { type: 'array', items: { type: 'string' } }, jobAgentId: { type: 'string' }, jobAgentConfig: openapi.schemaRef('JobAgentConfig'), + jobAgents: { type: 'array', items: openapi.schemaRef('DeploymentJobAgent') }, resourceSelector: openapi.schemaRef('Selector'), metadata: { type: 'object', additionalProperties: { type: 'string' } }, }, }, + DeploymentJobAgent: { + type: 'object', + required: ['ref', 'config', 'if'], + properties: { + ref: { type: 'string' }, + config: openapi.schemaRef('JobAgentConfig'), + 'if': { type: 'string', description: 'CEL expression to determine if the job agent should be used' }, + }, + }, + DeploymentWithVariables: { type: 'object', required: ['deployment', 'variables'], diff --git a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go index 526e67908..9d8ccb019 100644 --- a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go +++ b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go @@ -286,7 +286,7 @@ func retriggerInvalidJobAgentJobs(ctx context.Context, ws *workspace.Workspace, } // Create a new job for this release (bypassing eligibility checks for explicit retrigger) - newJob, err := jobFactory.CreateJobForRelease(ctx, release, nil) + newJobs, err := jobFactory.CreateJobsForRelease(ctx, release, nil) if err != nil { log.Error("failed to create job for release during retrigger", "releaseId", release.ID(), @@ -295,24 +295,25 @@ func retriggerInvalidJobAgentJobs(ctx context.Context, ws *workspace.Workspace, continue } - // Upsert the new job - ws.Jobs().Upsert(ctx, newJob) - - log.Info("created new job for previously invalid job agent", - "newJobId", newJob.Id, - "originalJobId", job.Id, - "releaseId", release.ID(), - "deploymentId", release.ReleaseTarget.DeploymentId, - "status", newJob.Status) - - // Dispatch the job asynchronously if it's not InvalidJobAgent - if newJob.Status != oapi.JobStatusInvalidJobAgent { - if err := ws.JobAgentRegistry().Dispatch(ctx, newJob); err != nil { - message := err.Error() - newJob.Status = oapi.JobStatusInvalidIntegration - newJob.UpdatedAt = time.Now() - newJob.Message = &message - ws.Jobs().Upsert(ctx, newJob) + // Upsert the new jobs + for _, newJob := range newJobs { + ws.Jobs().Upsert(ctx, newJob) + + log.Info("created new job for previously invalid job agent", + "newJobId", newJob.Id, + "originalJobId", job.Id, + "releaseId", release.ID(), + "deploymentId", release.ReleaseTarget.DeploymentId, + "status", newJob.Status) + + if newJob.Status != oapi.JobStatusInvalidJobAgent { + if err := ws.JobAgentRegistry().Dispatch(ctx, newJob); err != nil { + message := err.Error() + newJob.Status = oapi.JobStatusInvalidIntegration + newJob.UpdatedAt = time.Now() + newJob.Message = &message + ws.Jobs().Upsert(ctx, newJob) + } } } } diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index 963820d90..4f9bd50d7 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -290,15 +290,16 @@ type DeployDecision struct { // Deployment defines model for Deployment. type Deployment struct { - Description *string `json:"description,omitempty"` - Id string `json:"id"` - JobAgentConfig JobAgentConfig `json:"jobAgentConfig"` - JobAgentId *string `json:"jobAgentId,omitempty"` - Metadata map[string]string `json:"metadata"` - Name string `json:"name"` - ResourceSelector *Selector `json:"resourceSelector,omitempty"` - Slug string `json:"slug"` - SystemIds []string `json:"systemIds"` + Description *string `json:"description,omitempty"` + Id string `json:"id"` + JobAgentConfig JobAgentConfig `json:"jobAgentConfig"` + JobAgentId *string `json:"jobAgentId,omitempty"` + JobAgents *[]DeploymentJobAgent `json:"jobAgents,omitempty"` + Metadata map[string]string `json:"metadata"` + Name string `json:"name"` + ResourceSelector *Selector `json:"resourceSelector,omitempty"` + Slug string `json:"slug"` + SystemIds []string `json:"systemIds"` } // DeploymentAndSystems defines model for DeploymentAndSystems. @@ -313,6 +314,15 @@ type DeploymentDependencyRule struct { DependsOn string `json:"dependsOn"` } +// DeploymentJobAgent defines model for DeploymentJobAgent. +type DeploymentJobAgent struct { + Config JobAgentConfig `json:"config"` + + // If CEL expression to determine if the job agent should be used + If string `json:"if"` + Ref string `json:"ref"` +} + // DeploymentVariable defines model for DeploymentVariable. type DeploymentVariable struct { DefaultValue *LiteralValue `json:"defaultValue,omitempty"` diff --git a/apps/workspace-engine/pkg/workspace/jobs/factory.go b/apps/workspace-engine/pkg/workspace/jobs/factory.go index ba41efda1..9f4c33ba4 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "time" + "workspace-engine/pkg/celutil" "workspace-engine/pkg/oapi" "workspace-engine/pkg/workspace/releasemanager/trace" "workspace-engine/pkg/workspace/store" @@ -17,6 +18,11 @@ import ( var tracer = otel.Tracer("workspace/releasemanager/jobs") +var jobAgentIfEnv, _ = celutil.NewEnvBuilder(). + WithMapVariables("release", "deployment", "environment", "resource"). + WithStandardExtensions(). + BuildCached(12 * time.Hour) + // Factory creates jobs for releases. type Factory struct { store *store.Store @@ -134,9 +140,108 @@ func (f *Factory) buildDispatchContext(release *oapi.Release, deployment *oapi.D }, nil } -// CreateJobForRelease creates a job for a given release (PURE FUNCTION, NO WRITES). +func (f *Factory) getJobAgents(release *oapi.Release, deployment *oapi.Deployment) ([]*oapi.JobAgent, error) { + if deployment.JobAgentId != nil && *deployment.JobAgentId != "" { + jobAgent, exists := f.store.JobAgents.Get(*deployment.JobAgentId) + if !exists { + return nil, fmt.Errorf("job agent %s not found", *deployment.JobAgentId) + } + return []*oapi.JobAgent{jobAgent}, nil + } + + if deployment.JobAgents == nil || len(*deployment.JobAgents) == 0 { + return []*oapi.JobAgent{}, nil + } + + environment, exists := f.store.Environments.Get(release.ReleaseTarget.EnvironmentId) + if !exists { + return nil, fmt.Errorf("environment %s not found", release.ReleaseTarget.EnvironmentId) + } + + resource, exists := f.store.Resources.Get(release.ReleaseTarget.ResourceId) + if !exists { + return nil, fmt.Errorf("resource %s not found", release.ReleaseTarget.ResourceId) + } + + releaseMap, err := celutil.EntityToMap(release) + if err != nil { + return nil, fmt.Errorf("failed to convert release to map: %w", err) + } + deploymentMap, err := celutil.EntityToMap(deployment) + if err != nil { + return nil, fmt.Errorf("failed to convert deployment to map: %w", err) + } + environmentMap, err := celutil.EntityToMap(environment) + if err != nil { + return nil, fmt.Errorf("failed to convert environment to map: %w", err) + } + resourceMap, err := celutil.EntityToMap(resource) + if err != nil { + return nil, fmt.Errorf("failed to convert resource to map: %w", err) + } + + celCtx := map[string]any{ + "release": releaseMap, + "deployment": deploymentMap, + "environment": environmentMap, + "resource": resourceMap, + } + + jobAgents := make([]*oapi.JobAgent, 0) + for _, deploymentJobAgent := range *deployment.JobAgents { + if deploymentJobAgent.If != "" { + program, err := jobAgentIfEnv.Compile(deploymentJobAgent.If) + if err != nil { + return nil, fmt.Errorf("failed to compile job agent if expression %q: %w", deploymentJobAgent.If, err) + } + + result, err := celutil.EvalBool(program, celCtx) + if err != nil { + return nil, fmt.Errorf("failed to evaluate job agent if expression: %w", err) + } + + if !result { + continue + } + } + + jobAgent, agentExists := f.store.JobAgents.Get(deploymentJobAgent.Ref) + if !agentExists { + return nil, fmt.Errorf("job agent %s not found", deploymentJobAgent.Ref) + } + jobAgents = append(jobAgents, jobAgent) + } + return jobAgents, nil +} + +func (f *Factory) buildJobForAgent(release *oapi.Release, deployment *oapi.Deployment, jobAgent *oapi.JobAgent) (*oapi.Job, error) { + jobId := uuid.New().String() + mergedConfig, err := f.buildJobAgentConfig(release, deployment, jobAgent) + if err != nil { + return nil, fmt.Errorf("failed to get merged job agent config: %w", err) + } + + dispatchContext, err := f.buildDispatchContext(release, deployment, jobAgent, mergedConfig) + if err != nil { + return nil, fmt.Errorf("failed to build dispatch context: %w", err) + } + + return &oapi.Job{ + Id: jobId, + ReleaseId: release.ID(), + JobAgentId: jobAgent.Id, + JobAgentConfig: mergedConfig, + Status: oapi.JobStatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Metadata: make(map[string]string), + DispatchContext: dispatchContext, + }, nil +} + +// CreateJobsForRelease creates a job for a given release (PURE FUNCTION, NO WRITES). // The job is configured with merged settings from JobAgent + Deployment. -func (f *Factory) CreateJobForRelease(ctx context.Context, release *oapi.Release, action *trace.Action) (*oapi.Job, error) { +func (f *Factory) CreateJobsForRelease(ctx context.Context, release *oapi.Release, action *trace.Action) ([]*oapi.Job, error) { _, span := tracer.Start(ctx, "CreateJobForRelease", oteltrace.WithAttributes( attribute.String("deployment.id", release.ReleaseTarget.DeploymentId), @@ -162,23 +267,30 @@ func (f *Factory) CreateJobForRelease(ctx context.Context, release *oapi.Release AddMetadata("deployment_name", deployment.Name) } - jobAgentId := deployment.JobAgentId - isJobAgentConfigured := jobAgentId != nil && *jobAgentId != "" - if !isJobAgentConfigured { - return f.noAgentConfiguredJob(release.ID(), "", deployment.Name, action), nil - } - - jobAgent, exists := f.store.JobAgents.Get(*jobAgentId) - if !exists { - return f.jobAgentNotFoundJob(release.ID(), *jobAgentId, deployment.Name, action), nil + jobAgents, err := f.getJobAgents(release, deployment) + if err != nil { + message := fmt.Sprintf("Failed to get job agents: %s", err.Error()) + return []*oapi.Job{{ + Id: uuid.New().String(), + ReleaseId: release.ID(), + JobAgentId: "", + JobAgentConfig: oapi.JobAgentConfig{}, + Status: oapi.JobStatusInvalidJobAgent, + Message: &message, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Metadata: make(map[string]string), + }}, nil } if action != nil { - action.AddStep("Validate job agent", trace.StepResultPass, - fmt.Sprintf("Job agent '%s' (type: %s) found and validated", jobAgent.Name, jobAgent.Type)). - AddMetadata("job_agent_id", jobAgent.Id). - AddMetadata("job_agent_name", jobAgent.Name). - AddMetadata("job_agent_type", jobAgent.Type) + for _, jobAgent := range jobAgents { + action.AddStep("Validate job agent", trace.StepResultPass, + fmt.Sprintf("Job agent '%s' (type: %s) found and validated", jobAgent.Name, jobAgent.Type)). + AddMetadata("job_agent_id", jobAgent.Id). + AddMetadata("job_agent_name", jobAgent.Name). + AddMetadata("job_agent_type", jobAgent.Type) + } } if action != nil { @@ -190,36 +302,27 @@ func (f *Factory) CreateJobForRelease(ctx context.Context, release *oapi.Release jobId := uuid.New().String() if action != nil { - action.AddStep("Create job", trace.StepResultPass, - fmt.Sprintf("Job created successfully with ID %s for release %s", jobId, release.ID())). - AddMetadata("job_id", jobId). - AddMetadata("job_status", string(oapi.JobStatusPending)). - AddMetadata("job_agent_id", *jobAgentId). - AddMetadata("release_id", release.ID()). - AddMetadata("version_tag", release.Version.Tag) - } - - mergedConfig, err := f.buildJobAgentConfig(release, deployment, jobAgent) - if err != nil { - return nil, fmt.Errorf("failed to get merged job agent config: %w", err) - } - - dispatchContext, err := f.buildDispatchContext(release, deployment, jobAgent, mergedConfig) - if err != nil { - return nil, fmt.Errorf("failed to build dispatch context: %w", err) - } - - return &oapi.Job{ - Id: jobId, - ReleaseId: release.ID(), - JobAgentId: *jobAgentId, - JobAgentConfig: mergedConfig, - Status: oapi.JobStatusPending, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Metadata: make(map[string]string), - DispatchContext: dispatchContext, - }, nil + for _, jobAgent := range jobAgents { + action.AddStep("Create job", trace.StepResultPass, + fmt.Sprintf("Job created successfully with ID %s for release %s", jobId, release.ID())). + AddMetadata("job_id", jobId). + AddMetadata("job_status", string(oapi.JobStatusPending)). + AddMetadata("job_agent_id", jobAgent.Id). + AddMetadata("release_id", release.ID()). + AddMetadata("version_tag", release.Version.Tag) + } + } + + jobs := make([]*oapi.Job, 0) + for _, jobAgent := range jobAgents { + job, err := f.buildJobForAgent(release, deployment, jobAgent) + if err != nil { + return nil, fmt.Errorf("failed to build job for agent: %w", err) + } + jobs = append(jobs, job) + } + + return jobs, nil } func (f *Factory) buildWorkflowJobConfig(wfJob *oapi.WorkflowJob, jobAgent *oapi.JobAgent) (oapi.JobAgentConfig, error) { diff --git a/apps/workspace-engine/pkg/workspace/jobs/factory_test.go b/apps/workspace-engine/pkg/workspace/jobs/factory_test.go index 72e0ea0a2..71e425809 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory_test.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory_test.go @@ -126,14 +126,10 @@ func TestFactory_CreateJobForRelease_NoJobAgentConfigured(t *testing.T) { release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) - // Should create a job with InvalidJobAgent status require.NoError(t, err) - require.NotNil(t, job) - require.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status) - require.NotNil(t, job.Message) - require.Contains(t, *job.Message, "No job agent configured") + require.Empty(t, jobs) } func TestFactory_CreateJobForRelease_JobAgentNotFound(t *testing.T) { @@ -150,15 +146,13 @@ func TestFactory_CreateJobForRelease_JobAgentNotFound(t *testing.T) { release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) - // Should create a job with InvalidJobAgent status require.NoError(t, err) - require.NotNil(t, job) - require.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status) - require.Equal(t, nonExistentAgentId, job.JobAgentId) - require.NotNil(t, job.Message) - require.Contains(t, *job.Message, "not found") + require.Len(t, jobs, 1) + require.Equal(t, oapi.JobStatusInvalidJobAgent, jobs[0].Status) + require.NotNil(t, jobs[0].Message) + require.Contains(t, *jobs[0].Message, "not found") } func TestFactory_CreateJobForRelease_DeploymentNotFound(t *testing.T) { @@ -169,11 +163,11 @@ func TestFactory_CreateJobForRelease_DeploymentNotFound(t *testing.T) { release := createTestRelease(t, "non-existent-deploy", "env-1", "resource-1", "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) // Should return an error require.Error(t, err) - require.Nil(t, job) + require.Nil(t, jobs) require.Contains(t, err.Error(), "not found") } @@ -210,12 +204,13 @@ func TestFactory_CreateJobForRelease_SetsCorrectJobFields(t *testing.T) { beforeCreation := time.Now() factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) afterCreation := time.Now() require.NoError(t, err) - require.NotNil(t, job) + require.Len(t, jobs, 1) + job := jobs[0] // Verify job ID is a valid UUID _, err = uuid.Parse(job.Id) @@ -275,13 +270,13 @@ func TestFactory_CreateJobForRelease_UniqueJobIds(t *testing.T) { jobIds := make(map[string]bool) for i := 0; i < 10; i++ { release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) require.NoError(t, err) - require.NotNil(t, job) + require.Len(t, jobs, 1) // Each job should have a unique ID - require.False(t, jobIds[job.Id], "Job ID should be unique") - jobIds[job.Id] = true + require.False(t, jobIds[jobs[0].Id], "Job ID should be unique") + jobIds[jobs[0].Id] = true } } @@ -303,14 +298,10 @@ func TestFactory_CreateJobForRelease_EmptyJobAgentId(t *testing.T) { release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) - // Should create a job with InvalidJobAgent status require.NoError(t, err) - require.NotNil(t, job) - require.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status) - require.NotNil(t, job.Message) - require.Contains(t, *job.Message, "No job agent configured") + require.Empty(t, jobs) } // ============================================================================= @@ -362,10 +353,11 @@ func TestFactory_CreateJobForRelease_BuildsDispatchContext(t *testing.T) { release := createTestRelease(t, deploymentId, environmentId, resourceId, "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) require.NoError(t, err) - require.NotNil(t, job) + require.Len(t, jobs, 1) + job := jobs[0] require.NotNil(t, job.DispatchContext) dc := job.DispatchContext @@ -384,10 +376,11 @@ func TestFactory_CreateJobForRelease_DispatchContextHasCorrectEntities(t *testin release := createTestRelease(t, deploymentId, environmentId, resourceId, "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) require.NoError(t, err) - dc := job.DispatchContext + require.Len(t, jobs, 1) + dc := jobs[0].DispatchContext require.Equal(t, deploymentId, dc.Deployment.Id) require.Equal(t, environmentId, dc.Environment.Id) @@ -407,12 +400,13 @@ func TestFactory_CreateJobForRelease_DispatchContextVariablesPointsToReleaseVari } factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) require.NoError(t, err) - require.NotNil(t, job.DispatchContext.Variables) + require.Len(t, jobs, 1) + require.NotNil(t, jobs[0].DispatchContext.Variables) - vars := *job.DispatchContext.Variables + vars := *jobs[0].DispatchContext.Variables appName, exists := vars["app_name"] require.True(t, exists) val, err := appName.AsStringValue() @@ -449,10 +443,11 @@ func TestFactory_CreateJobForRelease_MergesJobAgentConfig(t *testing.T) { release := createTestReleaseWithJobAgentConfig(t, "deploy-1", "env-1", "resource-1", "version-1", versionConfig) factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) require.NoError(t, err) - require.NotNil(t, job) + require.Len(t, jobs, 1) + job := jobs[0] // Version config wins for "shared" since it's applied last require.Equal(t, "from_version", job.JobAgentConfig["shared"]) @@ -482,10 +477,10 @@ func TestFactory_CreateJobForRelease_DispatchContextEnvironmentNotFound(t *testi release := createTestRelease(t, "deploy-1", "env-missing", "resource-1", "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) require.Error(t, err) - require.Nil(t, job) + require.Nil(t, jobs) require.Contains(t, err.Error(), "environment") require.Contains(t, err.Error(), "not found") } @@ -510,10 +505,10 @@ func TestFactory_CreateJobForRelease_DispatchContextResourceNotFound(t *testing. release := createTestRelease(t, "deploy-1", "env-1", "resource-missing", "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) require.Error(t, err) - require.Nil(t, job) + require.Nil(t, jobs) require.Contains(t, err.Error(), "resource") require.Contains(t, err.Error(), "not found") } @@ -764,9 +759,11 @@ func TestFactory_CreateJobForRelease_DeepMergesNestedConfig(t *testing.T) { release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + jobs, err := factory.CreateJobsForRelease(ctx, release, nil) require.NoError(t, err) + require.Len(t, jobs, 1) + job := jobs[0] nested, ok := job.JobAgentConfig["nested"].(map[string]any) require.True(t, ok) diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go index daf16961a..34db4be2c 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go @@ -8,7 +8,6 @@ import ( "workspace-engine/pkg/workspace/jobagents" "workspace-engine/pkg/workspace/jobs" "workspace-engine/pkg/workspace/releasemanager/trace" - "workspace-engine/pkg/workspace/releasemanager/trace/token" "workspace-engine/pkg/workspace/store" "go.opentelemetry.io/otel/attribute" @@ -35,7 +34,7 @@ func NewExecutor(store *store.Store, jobAgentRegistry *jobagents.Registry) *Exec // ExecuteRelease performs all write operations to deploy a release (WRITES TO STORE). // Precondition: Planner has already determined this release NEEDS to be deployed. // No additional "should we deploy" checks here - trust the planning phase. -func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Release, recorder *trace.ReconcileTarget) (job *oapi.Job, err error) { +func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Release, recorder *trace.ReconcileTarget) ([]*oapi.Job, error) { ctx, span := tracer.Start(ctx, "ExecuteRelease", oteltrace.WithAttributes( attribute.String("release.id", releaseToDeploy.ID()), @@ -71,7 +70,7 @@ func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Rel // Step 2: Create and persist new job (WRITE) span.AddEvent("Creating job for release") - newJob, err := e.jobFactory.CreateJobForRelease(ctx, releaseToDeploy, createJobAction) + newJobs, err := e.jobFactory.CreateJobsForRelease(ctx, releaseToDeploy, createJobAction) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to create job") @@ -81,30 +80,11 @@ func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Rel return nil, err } - // Set job ID on trace recorder so all subsequent spans are associated with this job - if recorder != nil { - recorder.SetJobID(newJob.Id) - } - - // Generate trace token for external executors BEFORE persisting - // This ensures the trace token is stored with the job for verification tracing - if recorder != nil && createJobAction != nil { - traceToken := token.GenerateDefaultTraceToken(recorder.RootTraceID(), newJob.Id) - createJobAction.AddMetadata("trace_token", traceToken) - createJobAction.AddMetadata("job_id", newJob.Id) - createJobAction.AddStep("Generate trace token", trace.StepResultPass, "Token generated for external executor") - - newJob.TraceToken = &traceToken - } - // Persist job with trace token span.AddEvent("Persisting job to store") - e.store.Jobs.Upsert(ctx, newJob) - span.SetAttributes( - attribute.Bool("job.created", true), - attribute.String("job.id", newJob.Id), - attribute.String("job.status", string(newJob.Status)), - ) + for _, job := range newJobs { + e.store.Jobs.Upsert(ctx, job) + } if createJobAction != nil { createJobAction.AddStep("Persist job", trace.StepResultPass, "Job persisted to store") @@ -112,26 +92,28 @@ func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Rel // Step 3: Dispatch job to integration (ASYNC) // Skip dispatch if job already has InvalidJobAgent status - if newJob.Status != oapi.JobStatusInvalidJobAgent { - span.AddEvent("Dispatching job to integration (async)", - oteltrace.WithAttributes(attribute.String("job.id", newJob.Id))) - - if createJobAction != nil { - createJobAction.AddStep("Dispatch job", trace.StepResultPass, "Job dispatched to integration") - } - - if err := e.jobAgentRegistry.Dispatch(ctx, newJob); err != nil { - message := fmt.Sprintf("Failed to dispatch job to integration: %s", err.Error()) - newJob.Status = oapi.JobStatusInvalidJobAgent - newJob.UpdatedAt = time.Now() - newJob.Message = &message - e.store.Jobs.Upsert(ctx, newJob) - } - } else { - span.AddEvent("Skipping job dispatch (InvalidJobAgent status)", - oteltrace.WithAttributes(attribute.String("job.id", newJob.Id))) - if createJobAction != nil { - createJobAction.AddStep("Skipping dispatch, unable to process job configuration.", trace.StepResultFail, "Job has InvalidJobAgent status") + for _, newJob := range newJobs { + if newJob.Status != oapi.JobStatusInvalidJobAgent { + span.AddEvent("Dispatching job to integration (async)", + oteltrace.WithAttributes(attribute.String("job.id", newJob.Id))) + + if createJobAction != nil { + createJobAction.AddStep("Dispatch job", trace.StepResultPass, "Job dispatched to integration") + } + + if err := e.jobAgentRegistry.Dispatch(ctx, newJob); err != nil { + message := fmt.Sprintf("Failed to dispatch job to integration: %s", err.Error()) + newJob.Status = oapi.JobStatusInvalidJobAgent + newJob.UpdatedAt = time.Now() + newJob.Message = &message + e.store.Jobs.Upsert(ctx, newJob) + } + } else { + span.AddEvent("Skipping job dispatch (InvalidJobAgent status)", + oteltrace.WithAttributes(attribute.String("job.id", newJob.Id))) + if createJobAction != nil { + createJobAction.AddStep("Skipping dispatch, unable to process job configuration.", trace.StepResultFail, "Job has InvalidJobAgent status") + } } } @@ -141,7 +123,7 @@ func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Rel } span.SetStatus(codes.Ok, "release executed successfully") - return newJob, nil + return newJobs, nil } // BuildRelease constructs a release object from its components. diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go index 909ebf63f..08141d6f4 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go @@ -128,11 +128,12 @@ func TestExecuteRelease_Success(t *testing.T) { release := createTestRelease(deploymentID, environmentID, resourceID, versionID, "v1.0.0") // Execute release - job, err := executor.ExecuteRelease(ctx, release, nil) + jobs, err := executor.ExecuteRelease(ctx, release, nil) // Assertions require.NoError(t, err) - require.NotNil(t, job) + require.Len(t, jobs, 1) + job := jobs[0] assert.Equal(t, release.ID(), job.ReleaseId) assert.Equal(t, oapi.JobStatusPending, job.Status) assert.Equal(t, jobAgentID, job.JobAgentId) @@ -149,7 +150,7 @@ func TestExecuteRelease_Success(t *testing.T) { assert.Equal(t, job.ReleaseId, storedJob.ReleaseId) } -func TestExecuteRelease_InvalidJobAgent(t *testing.T) { +func TestExecuteRelease_NoJobAgentConfigured(t *testing.T) { executor, testStore := setupTestExecutor(t) ctx := context.Background() @@ -177,18 +178,15 @@ func TestExecuteRelease_InvalidJobAgent(t *testing.T) { release := createTestRelease(deploymentID, environmentID, resourceID, versionID, "v1.0.0") // Execute release - job, err := executor.ExecuteRelease(ctx, release, nil) + jobs, err := executor.ExecuteRelease(ctx, release, nil) - // Assertions + // No job agent configured and no jobAgents list — returns empty jobs require.NoError(t, err) - require.NotNil(t, job) - assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status) - assert.Equal(t, "", job.JobAgentId) + require.Empty(t, jobs) - // Verify job was persisted with InvalidJobAgent status - storedJob, exists := testStore.Jobs.Get(job.Id) + // Verify release was still persisted + _, exists := testStore.Releases.Get(release.ID()) require.True(t, exists) - assert.Equal(t, oapi.JobStatusInvalidJobAgent, storedJob.Status) } func TestExecuteRelease_DeploymentNotFound(t *testing.T) { @@ -204,11 +202,11 @@ func TestExecuteRelease_DeploymentNotFound(t *testing.T) { release := createTestRelease(deploymentID, environmentID, resourceID, versionID, "v1.0.0") // Execute release - should fail because deployment doesn't exist - job, err := executor.ExecuteRelease(ctx, release, nil) + jobs, err := executor.ExecuteRelease(ctx, release, nil) // Assertions require.Error(t, err) - assert.Nil(t, job) + assert.Nil(t, jobs) assert.Contains(t, err.Error(), "deployment") } @@ -232,11 +230,12 @@ func TestExecuteRelease_SkipsDispatchForInvalidJobAgent(t *testing.T) { release := createTestRelease(deploymentID, environmentID, resourceID, versionID, "v1.0.0") // Execute release - job, err := executor.ExecuteRelease(ctx, release, nil) + jobs, err := executor.ExecuteRelease(ctx, release, nil) // Assertions require.NoError(t, err) - require.NotNil(t, job) + require.Len(t, jobs, 1) + job := jobs[0] assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status) // Give a moment for any async operations to complete @@ -280,18 +279,18 @@ func TestExecuteRelease_MultipleReleases(t *testing.T) { createTestRelease(deploymentID, environmentID, resourceID, uuid.New().String(), "v3.0.0"), } - jobs := make([]*oapi.Job, 0, len(releases)) + allJobs := make([]*oapi.Job, 0, len(releases)) for _, release := range releases { - job, err := executor.ExecuteRelease(ctx, release, nil) + jobs, err := executor.ExecuteRelease(ctx, release, nil) require.NoError(t, err) - require.NotNil(t, job) - jobs = append(jobs, job) + require.Len(t, jobs, 1) + allJobs = append(allJobs, jobs[0]) } // Verify all releases and jobs were persisted - assert.Len(t, jobs, 3) + assert.Len(t, allJobs, 3) - for i, job := range jobs { + for i, job := range allJobs { // Verify each job has correct release ID assert.Equal(t, releases[i].ID(), job.ReleaseId) From c3ebebb2d736d0a75d0ccf1a2824287ccc2f56d4 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Tue, 17 Feb 2026 10:23:19 -0800 Subject: [PATCH 02/10] cleanup --- .../events/handler/deployment/deployment.go | 43 ++-- .../jobagents/deployment_agent_selector.go | 107 +++++++++ .../pkg/workspace/jobs/factory.go | 215 +++--------------- .../pkg/workspace/jobs/factory_test.go | 152 ++++--------- .../releasemanager/deployment/executor.go | 112 +++++---- 5 files changed, 255 insertions(+), 374 deletions(-) create mode 100644 apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go diff --git a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go index 9d8ccb019..1bcb82669 100644 --- a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go +++ b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go @@ -285,8 +285,13 @@ func retriggerInvalidJobAgentJobs(ctx context.Context, ws *workspace.Workspace, continue } + agent, ok := ws.JobAgents().Get(job.JobAgentId) + if !ok || agent == nil { + continue + } + // Create a new job for this release (bypassing eligibility checks for explicit retrigger) - newJobs, err := jobFactory.CreateJobsForRelease(ctx, release, nil) + newJob, err := jobFactory.CreateJobForRelease(ctx, release, agent, nil) if err != nil { log.Error("failed to create job for release during retrigger", "releaseId", release.ID(), @@ -295,26 +300,24 @@ func retriggerInvalidJobAgentJobs(ctx context.Context, ws *workspace.Workspace, continue } - // Upsert the new jobs - for _, newJob := range newJobs { - ws.Jobs().Upsert(ctx, newJob) - - log.Info("created new job for previously invalid job agent", - "newJobId", newJob.Id, - "originalJobId", job.Id, - "releaseId", release.ID(), - "deploymentId", release.ReleaseTarget.DeploymentId, - "status", newJob.Status) - - if newJob.Status != oapi.JobStatusInvalidJobAgent { - if err := ws.JobAgentRegistry().Dispatch(ctx, newJob); err != nil { - message := err.Error() - newJob.Status = oapi.JobStatusInvalidIntegration - newJob.UpdatedAt = time.Now() - newJob.Message = &message - ws.Jobs().Upsert(ctx, newJob) - } + // Upsert the new job + ws.Jobs().Upsert(ctx, newJob) + log.Info("created new job for previously invalid job agent", + "newJobId", newJob.Id, + "originalJobId", job.Id, + "releaseId", release.ID(), + "deploymentId", release.ReleaseTarget.DeploymentId, + "status", newJob.Status) + + if newJob.Status != oapi.JobStatusInvalidJobAgent { + if err := ws.JobAgentRegistry().Dispatch(ctx, newJob); err != nil { + message := err.Error() + newJob.Status = oapi.JobStatusInvalidIntegration + newJob.UpdatedAt = time.Now() + newJob.Message = &message + ws.Jobs().Upsert(ctx, newJob) } } + } } diff --git a/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go new file mode 100644 index 000000000..43c054016 --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go @@ -0,0 +1,107 @@ +package jobagents + +import ( + "fmt" + "time" + "workspace-engine/pkg/celutil" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/workspace/store" +) + +var jobAgentIfEnv, _ = celutil.NewEnvBuilder(). + WithMapVariables("release", "deployment", "environment", "resource"). + WithStandardExtensions(). + BuildCached(12 * time.Hour) + +type DeploymentAgentsSelector struct { + store *store.Store + deployment *oapi.Deployment + release *oapi.Release +} + +func NewDeploymentAgentsSelector(store *store.Store, deployment *oapi.Deployment, release *oapi.Release) *DeploymentAgentsSelector { + return &DeploymentAgentsSelector{ + store: store, + deployment: deployment, + release: release, + } +} + +func (s *DeploymentAgentsSelector) getLegacyJobAgent() ([]*oapi.JobAgent, error) { + jobAgent, exists := s.store.JobAgents.Get(*s.deployment.JobAgentId) + if !exists { + return nil, fmt.Errorf("job agent %s not found", *s.deployment.JobAgentId) + } + return []*oapi.JobAgent{jobAgent}, nil +} + +func (s *DeploymentAgentsSelector) buildCelContext() (map[string]any, error) { + environment, exists := s.store.Environments.Get(s.release.ReleaseTarget.EnvironmentId) + if !exists { + return nil, fmt.Errorf("environment %s not found", s.release.ReleaseTarget.EnvironmentId) + } + resource, exists := s.store.Resources.Get(s.release.ReleaseTarget.ResourceId) + if !exists { + return nil, fmt.Errorf("resource %s not found", s.release.ReleaseTarget.ResourceId) + } + releaseMap, err := celutil.EntityToMap(s.release) + if err != nil { + return nil, fmt.Errorf("failed to convert release to map: %w", err) + } + deploymentMap, err := celutil.EntityToMap(s.deployment) + if err != nil { + return nil, fmt.Errorf("failed to convert deployment to map: %w", err) + } + environmentMap, err := celutil.EntityToMap(environment) + if err != nil { + return nil, fmt.Errorf("failed to convert environment to map: %w", err) + } + resourceMap, err := celutil.EntityToMap(resource) + if err != nil { + return nil, fmt.Errorf("failed to convert resource to map: %w", err) + } + return map[string]any{ + "release": releaseMap, + "deployment": deploymentMap, + "environment": environmentMap, + "resource": resourceMap, + }, nil +} + +func (s *DeploymentAgentsSelector) SelectAgents() ([]*oapi.JobAgent, error) { + if s.deployment.JobAgentId != nil && *s.deployment.JobAgentId != "" { + return s.getLegacyJobAgent() + } + + if s.deployment.JobAgents == nil || len(*s.deployment.JobAgents) == 0 { + return []*oapi.JobAgent{}, nil + } + + celCtx, err := s.buildCelContext() + if err != nil { + return nil, fmt.Errorf("failed to build cel context: %w", err) + } + + jobAgents := make([]*oapi.JobAgent, 0) + for _, deploymentJobAgent := range *s.deployment.JobAgents { + if deploymentJobAgent.If != "" { + program, err := jobAgentIfEnv.Compile(deploymentJobAgent.If) + if err != nil { + return nil, fmt.Errorf("failed to compile job agent if expression %q: %w", deploymentJobAgent.If, err) + } + result, err := celutil.EvalBool(program, celCtx) + if err != nil { + return nil, fmt.Errorf("failed to evaluate job agent if expression: %w", err) + } + if !result { + continue + } + } + jobAgent, agentExists := s.store.JobAgents.Get(deploymentJobAgent.Ref) + if !agentExists { + return nil, fmt.Errorf("job agent %s not found", deploymentJobAgent.Ref) + } + jobAgents = append(jobAgents, jobAgent) + } + return jobAgents, nil +} diff --git a/apps/workspace-engine/pkg/workspace/jobs/factory.go b/apps/workspace-engine/pkg/workspace/jobs/factory.go index 9f4c33ba4..21e76dab6 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "time" - "workspace-engine/pkg/celutil" "workspace-engine/pkg/oapi" "workspace-engine/pkg/workspace/releasemanager/trace" "workspace-engine/pkg/workspace/store" @@ -18,11 +17,6 @@ import ( var tracer = otel.Tracer("workspace/releasemanager/jobs") -var jobAgentIfEnv, _ = celutil.NewEnvBuilder(). - WithMapVariables("release", "deployment", "environment", "resource"). - WithStandardExtensions(). - BuildCached(12 * time.Hour) - // Factory creates jobs for releases. type Factory struct { store *store.Store @@ -35,7 +29,7 @@ func NewFactory(store *store.Store) *Factory { } } -func (f *Factory) noAgentConfiguredJob(releaseID, jobAgentID, deploymentName string, action *trace.Action) *oapi.Job { +func (f *Factory) NoAgentConfiguredJob(releaseID, jobAgentID, deploymentName string, action *trace.Action) *oapi.Job { message := fmt.Sprintf("No job agent configured for deployment '%s'", deploymentName) if action != nil { action.AddStep("Create InvalidJobAgent job", trace.StepResultPass, @@ -140,108 +134,9 @@ func (f *Factory) buildDispatchContext(release *oapi.Release, deployment *oapi.D }, nil } -func (f *Factory) getJobAgents(release *oapi.Release, deployment *oapi.Deployment) ([]*oapi.JobAgent, error) { - if deployment.JobAgentId != nil && *deployment.JobAgentId != "" { - jobAgent, exists := f.store.JobAgents.Get(*deployment.JobAgentId) - if !exists { - return nil, fmt.Errorf("job agent %s not found", *deployment.JobAgentId) - } - return []*oapi.JobAgent{jobAgent}, nil - } - - if deployment.JobAgents == nil || len(*deployment.JobAgents) == 0 { - return []*oapi.JobAgent{}, nil - } - - environment, exists := f.store.Environments.Get(release.ReleaseTarget.EnvironmentId) - if !exists { - return nil, fmt.Errorf("environment %s not found", release.ReleaseTarget.EnvironmentId) - } - - resource, exists := f.store.Resources.Get(release.ReleaseTarget.ResourceId) - if !exists { - return nil, fmt.Errorf("resource %s not found", release.ReleaseTarget.ResourceId) - } - - releaseMap, err := celutil.EntityToMap(release) - if err != nil { - return nil, fmt.Errorf("failed to convert release to map: %w", err) - } - deploymentMap, err := celutil.EntityToMap(deployment) - if err != nil { - return nil, fmt.Errorf("failed to convert deployment to map: %w", err) - } - environmentMap, err := celutil.EntityToMap(environment) - if err != nil { - return nil, fmt.Errorf("failed to convert environment to map: %w", err) - } - resourceMap, err := celutil.EntityToMap(resource) - if err != nil { - return nil, fmt.Errorf("failed to convert resource to map: %w", err) - } - - celCtx := map[string]any{ - "release": releaseMap, - "deployment": deploymentMap, - "environment": environmentMap, - "resource": resourceMap, - } - - jobAgents := make([]*oapi.JobAgent, 0) - for _, deploymentJobAgent := range *deployment.JobAgents { - if deploymentJobAgent.If != "" { - program, err := jobAgentIfEnv.Compile(deploymentJobAgent.If) - if err != nil { - return nil, fmt.Errorf("failed to compile job agent if expression %q: %w", deploymentJobAgent.If, err) - } - - result, err := celutil.EvalBool(program, celCtx) - if err != nil { - return nil, fmt.Errorf("failed to evaluate job agent if expression: %w", err) - } - - if !result { - continue - } - } - - jobAgent, agentExists := f.store.JobAgents.Get(deploymentJobAgent.Ref) - if !agentExists { - return nil, fmt.Errorf("job agent %s not found", deploymentJobAgent.Ref) - } - jobAgents = append(jobAgents, jobAgent) - } - return jobAgents, nil -} - -func (f *Factory) buildJobForAgent(release *oapi.Release, deployment *oapi.Deployment, jobAgent *oapi.JobAgent) (*oapi.Job, error) { - jobId := uuid.New().String() - mergedConfig, err := f.buildJobAgentConfig(release, deployment, jobAgent) - if err != nil { - return nil, fmt.Errorf("failed to get merged job agent config: %w", err) - } - - dispatchContext, err := f.buildDispatchContext(release, deployment, jobAgent, mergedConfig) - if err != nil { - return nil, fmt.Errorf("failed to build dispatch context: %w", err) - } - - return &oapi.Job{ - Id: jobId, - ReleaseId: release.ID(), - JobAgentId: jobAgent.Id, - JobAgentConfig: mergedConfig, - Status: oapi.JobStatusPending, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Metadata: make(map[string]string), - DispatchContext: dispatchContext, - }, nil -} - -// CreateJobsForRelease creates a job for a given release (PURE FUNCTION, NO WRITES). +// CreateJobForRelease creates a job for a given release (PURE FUNCTION, NO WRITES). // The job is configured with merged settings from JobAgent + Deployment. -func (f *Factory) CreateJobsForRelease(ctx context.Context, release *oapi.Release, action *trace.Action) ([]*oapi.Job, error) { +func (f *Factory) CreateJobForRelease(ctx context.Context, release *oapi.Release, jobAgent *oapi.JobAgent, action *trace.Action) (*oapi.Job, error) { _, span := tracer.Start(ctx, "CreateJobForRelease", oteltrace.WithAttributes( attribute.String("deployment.id", release.ReleaseTarget.DeploymentId), @@ -267,30 +162,12 @@ func (f *Factory) CreateJobsForRelease(ctx context.Context, release *oapi.Releas AddMetadata("deployment_name", deployment.Name) } - jobAgents, err := f.getJobAgents(release, deployment) - if err != nil { - message := fmt.Sprintf("Failed to get job agents: %s", err.Error()) - return []*oapi.Job{{ - Id: uuid.New().String(), - ReleaseId: release.ID(), - JobAgentId: "", - JobAgentConfig: oapi.JobAgentConfig{}, - Status: oapi.JobStatusInvalidJobAgent, - Message: &message, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Metadata: make(map[string]string), - }}, nil - } - if action != nil { - for _, jobAgent := range jobAgents { - action.AddStep("Validate job agent", trace.StepResultPass, - fmt.Sprintf("Job agent '%s' (type: %s) found and validated", jobAgent.Name, jobAgent.Type)). - AddMetadata("job_agent_id", jobAgent.Id). - AddMetadata("job_agent_name", jobAgent.Name). - AddMetadata("job_agent_type", jobAgent.Type) - } + action.AddStep("Validate job agent", trace.StepResultPass, + fmt.Sprintf("Job agent '%s' (type: %s) found and validated", jobAgent.Name, jobAgent.Type)). + AddMetadata("job_agent_id", jobAgent.Id). + AddMetadata("job_agent_name", jobAgent.Name). + AddMetadata("job_agent_type", jobAgent.Type) } if action != nil { @@ -302,27 +179,36 @@ func (f *Factory) CreateJobsForRelease(ctx context.Context, release *oapi.Releas jobId := uuid.New().String() if action != nil { - for _, jobAgent := range jobAgents { - action.AddStep("Create job", trace.StepResultPass, - fmt.Sprintf("Job created successfully with ID %s for release %s", jobId, release.ID())). - AddMetadata("job_id", jobId). - AddMetadata("job_status", string(oapi.JobStatusPending)). - AddMetadata("job_agent_id", jobAgent.Id). - AddMetadata("release_id", release.ID()). - AddMetadata("version_tag", release.Version.Tag) - } + action.AddStep("Create job", trace.StepResultPass, + fmt.Sprintf("Job created successfully with ID %s for release %s", jobId, release.ID())). + AddMetadata("job_id", jobId). + AddMetadata("job_status", string(oapi.JobStatusPending)). + AddMetadata("job_agent_id", jobAgent.Id). + AddMetadata("release_id", release.ID()). + AddMetadata("version_tag", release.Version.Tag) + } + + mergedConfig, err := f.buildJobAgentConfig(release, deployment, jobAgent) + if err != nil { + return nil, fmt.Errorf("failed to get merged job agent config: %w", err) } - jobs := make([]*oapi.Job, 0) - for _, jobAgent := range jobAgents { - job, err := f.buildJobForAgent(release, deployment, jobAgent) - if err != nil { - return nil, fmt.Errorf("failed to build job for agent: %w", err) - } - jobs = append(jobs, job) + dispatchContext, err := f.buildDispatchContext(release, deployment, jobAgent, mergedConfig) + if err != nil { + return nil, fmt.Errorf("failed to build dispatch context: %w", err) } - return jobs, nil + return &oapi.Job{ + Id: jobId, + ReleaseId: release.ID(), + JobAgentId: jobAgent.Id, + JobAgentConfig: mergedConfig, + Status: oapi.JobStatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Metadata: make(map[string]string), + DispatchContext: dispatchContext, + }, nil } func (f *Factory) buildWorkflowJobConfig(wfJob *oapi.WorkflowJob, jobAgent *oapi.JobAgent) (oapi.JobAgentConfig, error) { @@ -401,36 +287,3 @@ func (f *Factory) CreateJobForWorkflowJob(ctx context.Context, wfJob *oapi.Workf DispatchContext: dispatchContext, }, nil } - -func (f *Factory) BuildDispatchContextForLegacyReleaseJob(job *oapi.Job) (*oapi.DispatchContext, error) { - release, ok := f.store.Releases.Get(job.ReleaseId) - if !ok { - return nil, fmt.Errorf("release %s not found", job.ReleaseId) - } - - deployment, ok := f.store.Deployments.Get(release.ReleaseTarget.DeploymentId) - if !ok { - return nil, fmt.Errorf("deployment %s not found", release.ReleaseTarget.DeploymentId) - } - - if deployment.JobAgentId == nil || *deployment.JobAgentId == "" { - return nil, fmt.Errorf("deployment %s has no job agent configured", release.ReleaseTarget.DeploymentId) - } - - jobAgent, ok := f.store.JobAgents.Get(*deployment.JobAgentId) - if !ok { - return nil, fmt.Errorf("job agent %s not found", *deployment.JobAgentId) - } - - mergedConfig, err := f.buildJobAgentConfig(release, deployment, jobAgent) - if err != nil { - return nil, fmt.Errorf("failed to build job agent config: %w", err) - } - - dispatchContext, err := f.buildDispatchContext(release, deployment, jobAgent, mergedConfig) - if err != nil { - return nil, fmt.Errorf("failed to build dispatch context: %w", err) - } - - return dispatchContext, nil -} diff --git a/apps/workspace-engine/pkg/workspace/jobs/factory_test.go b/apps/workspace-engine/pkg/workspace/jobs/factory_test.go index 71e425809..6219f571a 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory_test.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory_test.go @@ -45,7 +45,6 @@ func createTestDeployment(t *testing.T, id string, jobAgentId *string, jobAgentC Id: id, Name: "test-deployment", Slug: "test-deployment", - SystemIds: []string{"system-1"}, JobAgentId: jobAgentId, JobAgentConfig: jobAgentConfig, ResourceSelector: mustCreateResourceSelector(t), @@ -57,7 +56,6 @@ func createTestEnvironment(t *testing.T, id string, systemId string, name string return &oapi.Environment{ Id: id, Name: name, - SystemIds: []string{systemId}, Metadata: map[string]string{}, CreatedAt: time.Now(), ResourceSelector: mustCreateResourceSelector(t), @@ -113,61 +111,21 @@ func createTestReleaseWithJobAgentConfig(t *testing.T, deploymentId, environment // Error Cases // ============================================================================= -func TestFactory_CreateJobForRelease_NoJobAgentConfigured(t *testing.T) { - st := setupTestStore() - ctx := context.Background() - - // Deployment has no job agent configured - deploymentConfig := mustCreateJobAgentConfig(t, `{"type": "custom"}`) - deployment := createTestDeployment(t, "deploy-1", nil, deploymentConfig) - - _ = st.Deployments.Upsert(ctx, deployment) - - release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") - - factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) - - require.NoError(t, err) - require.Empty(t, jobs) -} - -func TestFactory_CreateJobForRelease_JobAgentNotFound(t *testing.T) { - st := setupTestStore() - ctx := context.Background() - - // Deployment references a job agent that doesn't exist - nonExistentAgentId := "non-existent-agent" - deploymentConfig := mustCreateJobAgentConfig(t, `{"type": "custom"}`) - deployment := createTestDeployment(t, "deploy-1", &nonExistentAgentId, deploymentConfig) - - _ = st.Deployments.Upsert(ctx, deployment) - - release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") - - factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) - - require.NoError(t, err) - require.Len(t, jobs, 1) - require.Equal(t, oapi.JobStatusInvalidJobAgent, jobs[0].Status) - require.NotNil(t, jobs[0].Message) - require.Contains(t, *jobs[0].Message, "not found") -} - func TestFactory_CreateJobForRelease_DeploymentNotFound(t *testing.T) { st := setupTestStore() ctx := context.Background() + jobAgent := createTestJobAgent(t, "agent-1", "custom", mustCreateJobAgentConfig(t, `{}`)) + // Release references a deployment that doesn't exist release := createTestRelease(t, "non-existent-deploy", "env-1", "resource-1", "version-1") factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) // Should return an error require.Error(t, err) - require.Nil(t, jobs) + require.Nil(t, job) require.Contains(t, err.Error(), "not found") } @@ -204,13 +162,12 @@ func TestFactory_CreateJobForRelease_SetsCorrectJobFields(t *testing.T) { beforeCreation := time.Now() factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) afterCreation := time.Now() require.NoError(t, err) - require.Len(t, jobs, 1) - job := jobs[0] + require.NotNil(t, job) // Verify job ID is a valid UUID _, err = uuid.Parse(job.Id) @@ -270,45 +227,21 @@ func TestFactory_CreateJobForRelease_UniqueJobIds(t *testing.T) { jobIds := make(map[string]bool) for i := 0; i < 10; i++ { release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) - require.Len(t, jobs, 1) + require.NotNil(t, job) // Each job should have a unique ID - require.False(t, jobIds[jobs[0].Id], "Job ID should be unique") - jobIds[jobs[0].Id] = true + require.False(t, jobIds[job.Id], "Job ID should be unique") + jobIds[job.Id] = true } } -// ============================================================================= -// Empty Job Agent ID Tests -// ============================================================================= - -func TestFactory_CreateJobForRelease_EmptyJobAgentId(t *testing.T) { - st := setupTestStore() - ctx := context.Background() - - // Deployment has empty string job agent ID - emptyAgentId := "" - deploymentConfig := mustCreateJobAgentConfig(t, `{"type": "custom"}`) - deployment := createTestDeployment(t, "deploy-1", &emptyAgentId, deploymentConfig) - - _ = st.Deployments.Upsert(ctx, deployment) - - release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") - - factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) - - require.NoError(t, err) - require.Empty(t, jobs) -} - // ============================================================================= // Dispatch Context Tests (new factory responsibility) // ============================================================================= -func setupFullStore(t *testing.T) (*store.Store, string, string, string, string) { +func setupFullStore(t *testing.T) (*store.Store, *oapi.JobAgent, string, string, string) { t.Helper() st := setupTestStore() ctx := context.Background() @@ -322,10 +255,9 @@ func setupFullStore(t *testing.T) (*store.Store, string, string, string, string) deployment := createTestDeployment(t, deploymentId, &jobAgentId, mustCreateJobAgentConfig(t, `{"deploy_key": "deploy_val"}`)) environment := &oapi.Environment{ - Id: environmentId, - Name: "production", - SystemIds: []string{"system-1"}, - Metadata: map[string]string{}, + Id: environmentId, + Name: "production", + Metadata: map[string]string{}, } resource := &oapi.Resource{ @@ -343,21 +275,20 @@ func setupFullStore(t *testing.T) (*store.Store, string, string, string, string) _ = st.Environments.Upsert(ctx, environment) st.Resources.Upsert(ctx, resource) - return st, jobAgentId, deploymentId, environmentId, resourceId + return st, jobAgent, deploymentId, environmentId, resourceId } func TestFactory_CreateJobForRelease_BuildsDispatchContext(t *testing.T) { - st, _, deploymentId, environmentId, resourceId := setupFullStore(t) + st, jobAgent, deploymentId, environmentId, resourceId := setupFullStore(t) ctx := context.Background() release := createTestRelease(t, deploymentId, environmentId, resourceId, "version-1") factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) - require.Len(t, jobs, 1) - job := jobs[0] + require.NotNil(t, job) require.NotNil(t, job.DispatchContext) dc := job.DispatchContext @@ -370,17 +301,16 @@ func TestFactory_CreateJobForRelease_BuildsDispatchContext(t *testing.T) { } func TestFactory_CreateJobForRelease_DispatchContextHasCorrectEntities(t *testing.T) { - st, _, deploymentId, environmentId, resourceId := setupFullStore(t) + st, jobAgent, deploymentId, environmentId, resourceId := setupFullStore(t) ctx := context.Background() release := createTestRelease(t, deploymentId, environmentId, resourceId, "version-1") factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) - require.Len(t, jobs, 1) - dc := jobs[0].DispatchContext + dc := job.DispatchContext require.Equal(t, deploymentId, dc.Deployment.Id) require.Equal(t, environmentId, dc.Environment.Id) @@ -389,7 +319,7 @@ func TestFactory_CreateJobForRelease_DispatchContextHasCorrectEntities(t *testin } func TestFactory_CreateJobForRelease_DispatchContextVariablesPointsToReleaseVariables(t *testing.T) { - st, _, deploymentId, environmentId, resourceId := setupFullStore(t) + st, jobAgent, deploymentId, environmentId, resourceId := setupFullStore(t) ctx := context.Background() release := createTestRelease(t, deploymentId, environmentId, resourceId, "version-1") @@ -400,13 +330,12 @@ func TestFactory_CreateJobForRelease_DispatchContextVariablesPointsToReleaseVari } factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) - require.Len(t, jobs, 1) - require.NotNil(t, jobs[0].DispatchContext.Variables) + require.NotNil(t, job.DispatchContext.Variables) - vars := *jobs[0].DispatchContext.Variables + vars := *job.DispatchContext.Variables appName, exists := vars["app_name"] require.True(t, exists) val, err := appName.AsStringValue() @@ -428,7 +357,7 @@ func TestFactory_CreateJobForRelease_MergesJobAgentConfig(t *testing.T) { deployment := createTestDeployment(t, "deploy-1", &jobAgentId, deployConfig) environment := &oapi.Environment{ - Id: "env-1", Name: "prod", SystemIds: []string{"system-1"}, Metadata: map[string]string{}, + Id: "env-1", Name: "prod", Metadata: map[string]string{}, } resource := &oapi.Resource{ Id: "resource-1", Name: "server-1", Kind: "server", Identifier: "server-1", @@ -443,11 +372,10 @@ func TestFactory_CreateJobForRelease_MergesJobAgentConfig(t *testing.T) { release := createTestReleaseWithJobAgentConfig(t, "deploy-1", "env-1", "resource-1", "version-1", versionConfig) factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) - require.Len(t, jobs, 1) - job := jobs[0] + require.NotNil(t, job) // Version config wins for "shared" since it's applied last require.Equal(t, "from_version", job.JobAgentConfig["shared"]) @@ -461,7 +389,7 @@ func TestFactory_CreateJobForRelease_DispatchContextEnvironmentNotFound(t *testi ctx := context.Background() jobAgentId := "agent-1" - jobAgent := createTestJobAgent(t, jobAgentId, "custom", mustCreateJobAgentConfig(t, `{}`)) + agent := createTestJobAgent(t, jobAgentId, "custom", mustCreateJobAgentConfig(t, `{}`)) deployment := createTestDeployment(t, "deploy-1", &jobAgentId, mustCreateJobAgentConfig(t, `{}`)) resource := &oapi.Resource{ @@ -469,7 +397,7 @@ func TestFactory_CreateJobForRelease_DispatchContextEnvironmentNotFound(t *testi Config: map[string]interface{}{}, Metadata: map[string]string{}, CreatedAt: time.Now(), } - st.JobAgents.Upsert(ctx, jobAgent) + st.JobAgents.Upsert(ctx, agent) _ = st.Deployments.Upsert(ctx, deployment) st.Resources.Upsert(ctx, resource) // Deliberately not adding environment @@ -477,10 +405,10 @@ func TestFactory_CreateJobForRelease_DispatchContextEnvironmentNotFound(t *testi release := createTestRelease(t, "deploy-1", "env-missing", "resource-1", "version-1") factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, agent, nil) require.Error(t, err) - require.Nil(t, jobs) + require.Nil(t, job) require.Contains(t, err.Error(), "environment") require.Contains(t, err.Error(), "not found") } @@ -490,14 +418,14 @@ func TestFactory_CreateJobForRelease_DispatchContextResourceNotFound(t *testing. ctx := context.Background() jobAgentId := "agent-1" - jobAgent := createTestJobAgent(t, jobAgentId, "custom", mustCreateJobAgentConfig(t, `{}`)) + agent := createTestJobAgent(t, jobAgentId, "custom", mustCreateJobAgentConfig(t, `{}`)) deployment := createTestDeployment(t, "deploy-1", &jobAgentId, mustCreateJobAgentConfig(t, `{}`)) environment := &oapi.Environment{ - Id: "env-1", Name: "prod", SystemIds: []string{"system-1"}, Metadata: map[string]string{}, + Id: "env-1", Name: "prod", Metadata: map[string]string{}, } - st.JobAgents.Upsert(ctx, jobAgent) + st.JobAgents.Upsert(ctx, agent) _ = st.Deployments.Upsert(ctx, deployment) _ = st.Environments.Upsert(ctx, environment) // Deliberately not adding resource @@ -505,10 +433,10 @@ func TestFactory_CreateJobForRelease_DispatchContextResourceNotFound(t *testing. release := createTestRelease(t, "deploy-1", "env-1", "resource-missing", "version-1") factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, agent, nil) require.Error(t, err) - require.Nil(t, jobs) + require.Nil(t, job) require.Contains(t, err.Error(), "resource") require.Contains(t, err.Error(), "not found") } @@ -744,7 +672,7 @@ func TestFactory_CreateJobForRelease_DeepMergesNestedConfig(t *testing.T) { deployment := createTestDeployment(t, "deploy-1", &jobAgentId, deployConfig) environment := &oapi.Environment{ - Id: "env-1", Name: "prod", SystemIds: []string{"system-1"}, Metadata: map[string]string{}, + Id: "env-1", Name: "prod", Metadata: map[string]string{}, } resource := &oapi.Resource{ Id: "resource-1", Name: "server-1", Kind: "server", Identifier: "server-1", @@ -759,11 +687,9 @@ func TestFactory_CreateJobForRelease_DeepMergesNestedConfig(t *testing.T) { release := createTestRelease(t, "deploy-1", "env-1", "resource-1", "version-1") factory := NewFactory(st) - jobs, err := factory.CreateJobsForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) - require.Len(t, jobs, 1) - job := jobs[0] nested, ok := job.JobAgentConfig["nested"].(map[string]any) require.True(t, ok) diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go index 34db4be2c..c6073a572 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go @@ -31,6 +31,39 @@ func NewExecutor(store *store.Store, jobAgentRegistry *jobagents.Registry) *Exec } } +func (e *Executor) dispatchJobForAgent(ctx context.Context, release *oapi.Release, agent *oapi.JobAgent) (*oapi.Job, error) { + _, span := tracer.Start(ctx, "createJobForAgent", + oteltrace.WithAttributes( + attribute.String("agent.id", agent.Id), + attribute.String("agent.name", agent.Name), + attribute.String("agent.type", agent.Type), + )) + defer span.End() + + newJob, err := e.jobFactory.CreateJobForRelease(ctx, release, agent, nil) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create job") + return nil, err + } + + e.store.Jobs.Upsert(ctx, newJob) + + if newJob.Status == oapi.JobStatusInvalidJobAgent { + span.AddEvent("Skipping job dispatch (InvalidJobAgent status)", + oteltrace.WithAttributes(attribute.String("job.id", newJob.Id))) + return newJob, nil + } + + if err := e.jobAgentRegistry.Dispatch(ctx, newJob); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to dispatch job") + return nil, err + } + + return newJob, nil +} + // ExecuteRelease performs all write operations to deploy a release (WRITES TO STORE). // Precondition: Planner has already determined this release NEEDS to be deployed. // No additional "should we deploy" checks here - trust the planning phase. @@ -46,83 +79,42 @@ func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Rel )) defer span.End() - // Start execution phase trace if recorder is available - var execution *trace.ExecutionPhase - if recorder != nil { - execution = recorder.StartExecution() - defer execution.End() - } - - // Start action for job creation - var createJobAction *trace.Action - if execution != nil { - createJobAction = recorder.StartAction("Create and dispatch job") - } - if err := e.store.Releases.Upsert(ctx, releaseToDeploy); err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to persist release") - if createJobAction != nil { - createJobAction.AddStep("Persist release", trace.StepResultFail, fmt.Sprintf("Failed: %s", err.Error())) - } return nil, err } - // Step 2: Create and persist new job (WRITE) - span.AddEvent("Creating job for release") - newJobs, err := e.jobFactory.CreateJobsForRelease(ctx, releaseToDeploy, createJobAction) + deployment, exists := e.store.Deployments.Get(releaseToDeploy.ReleaseTarget.DeploymentId) + if !exists { + return nil, fmt.Errorf("deployment %s not found", releaseToDeploy.ReleaseTarget.DeploymentId) + } + + agents, err := jobagents.NewDeploymentAgentsSelector(e.store, deployment, releaseToDeploy).SelectAgents() if err != nil { span.RecordError(err) - span.SetStatus(codes.Error, "failed to create job") - if createJobAction != nil { - createJobAction.AddStep("Create job", trace.StepResultFail, fmt.Sprintf("Failed: %s", err.Error())) - } + span.SetStatus(codes.Error, "failed to get deployment agents") return nil, err } - // Persist job with trace token - span.AddEvent("Persisting job to store") - for _, job := range newJobs { - e.store.Jobs.Upsert(ctx, job) + if len(agents) == 0 { + failedJob := e.jobFactory.NoAgentConfiguredJob(releaseToDeploy.ID(), "", deployment.Name, nil) + e.store.Jobs.Upsert(ctx, failedJob) + return []*oapi.Job{failedJob}, nil } - if createJobAction != nil { - createJobAction.AddStep("Persist job", trace.StepResultPass, "Job persisted to store") - } - - // Step 3: Dispatch job to integration (ASYNC) - // Skip dispatch if job already has InvalidJobAgent status - for _, newJob := range newJobs { - if newJob.Status != oapi.JobStatusInvalidJobAgent { - span.AddEvent("Dispatching job to integration (async)", - oteltrace.WithAttributes(attribute.String("job.id", newJob.Id))) - - if createJobAction != nil { - createJobAction.AddStep("Dispatch job", trace.StepResultPass, "Job dispatched to integration") - } - - if err := e.jobAgentRegistry.Dispatch(ctx, newJob); err != nil { - message := fmt.Sprintf("Failed to dispatch job to integration: %s", err.Error()) - newJob.Status = oapi.JobStatusInvalidJobAgent - newJob.UpdatedAt = time.Now() - newJob.Message = &message - e.store.Jobs.Upsert(ctx, newJob) - } - } else { - span.AddEvent("Skipping job dispatch (InvalidJobAgent status)", - oteltrace.WithAttributes(attribute.String("job.id", newJob.Id))) - if createJobAction != nil { - createJobAction.AddStep("Skipping dispatch, unable to process job configuration.", trace.StepResultFail, "Job has InvalidJobAgent status") - } + newJobs := make([]*oapi.Job, 0) + for _, agent := range agents { + newJob, err := e.dispatchJobForAgent(ctx, releaseToDeploy, agent) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create job") + return nil, err } - } - // End the action - if createJobAction != nil { - createJobAction.End() + newJobs = append(newJobs, newJob) } - span.SetStatus(codes.Ok, "release executed successfully") return newJobs, nil } From fd3ebe7c7219ce0550255bea5a192eb286055793 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Tue, 17 Feb 2026 13:10:14 -0800 Subject: [PATCH 03/10] cleanup --- .../events/handler/deployment/deployment.go | 56 +++++++++++++++---- .../pkg/workspace/jobs/factory.go | 12 ++-- .../releasemanager/deployment/executor.go | 30 +++++++++- .../deployment/executor_test.go | 5 +- .../test/e2e/engine_deployment_test.go | 10 ++-- .../e2e/engine_job_agent_retrigger_test.go | 17 +++--- 6 files changed, 100 insertions(+), 30 deletions(-) diff --git a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go index d4441304c..92ca2ef29 100644 --- a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go +++ b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go @@ -3,6 +3,7 @@ package deployment import ( "context" "encoding/json" + "fmt" "sort" "time" @@ -137,7 +138,7 @@ func upsertTargets(ctx context.Context, ws *workspace.Workspace, releaseTargets return nil } -func reconcileTargets(ctx context.Context, ws *workspace.Workspace, deployment *oapi.Deployment, releaseTargets []*oapi.ReleaseTarget) error { +func reconcileTargets(ctx context.Context, ws *workspace.Workspace, deployment *oapi.Deployment, releaseTargets []*oapi.ReleaseTarget, skipEligibilityCheck bool) error { if deployment.JobAgentId != nil && *deployment.JobAgentId != "" { for _, rt := range releaseTargets { ws.ReleaseManager().DirtyDesiredRelease(rt) @@ -147,12 +148,48 @@ func reconcileTargets(ctx context.Context, ws *workspace.Workspace, deployment * for _, releaseTarget := range releaseTargets { _ = ws.ReleaseManager().ReconcileTarget(ctx, releaseTarget, releasemanager.WithTrigger(trace.TriggerDeploymentUpdated), + releasemanager.WithSkipEligibilityCheck(skipEligibilityCheck), ) } } return nil } +func getOldDeployment(ws *workspace.Workspace, deploymentID string) (oapi.Deployment, error) { + oldDeployment, ok := ws.Deployments().Get(deploymentID) + if !ok { + return oapi.Deployment{}, fmt.Errorf("deployment %s not found", deploymentID) + } + if oldDeployment == nil { + return oapi.Deployment{}, fmt.Errorf("deployment %s not found", deploymentID) + } + return *oldDeployment, nil +} + +func isJobAgentConfigurationChanged(oldDeployment *oapi.Deployment, newDeployment *oapi.Deployment) bool { + oldAgentId := "" + if oldDeployment.JobAgentId != nil { + oldAgentId = *oldDeployment.JobAgentId + } + newAgentId := "" + if newDeployment.JobAgentId != nil { + newAgentId = *newDeployment.JobAgentId + } + if oldAgentId != newAgentId { + return true + } + + oldConfig, _ := json.Marshal(oldDeployment.JobAgentConfig) + newConfig, _ := json.Marshal(newDeployment.JobAgentConfig) + if string(oldConfig) != string(newConfig) { + return true + } + + oldAgents, _ := json.Marshal(oldDeployment.JobAgents) + newAgents, _ := json.Marshal(newDeployment.JobAgents) + return string(oldAgents) != string(newAgents) +} + func HandleDeploymentUpdated( ctx context.Context, ws *workspace.Workspace, @@ -163,6 +200,11 @@ func HandleDeploymentUpdated( return err } + oldDeployment, err := getOldDeployment(ws, deployment.Id) + if err != nil { + return err + } + if err := ws.Deployments().Upsert(ctx, deployment); err != nil { return err } @@ -190,17 +232,11 @@ func HandleDeploymentUpdated( return err } - err = reconcileTargets(ctx, ws, deployment, addedReleaseTargets) - if err != nil { - return err + if isJobAgentConfigurationChanged(&oldDeployment, deployment) { + return reconcileTargets(ctx, ws, deployment, releaseTargets, true) } - jobsToRetrigger := getJobsToRetrigger(ws, deployment) - if len(jobsToRetrigger) > 0 { - retriggerInvalidJobAgentJobs(ctx, ws, jobsToRetrigger) - } - - return nil + return reconcileTargets(ctx, ws, deployment, addedReleaseTargets, false) } func HandleDeploymentDeleted( diff --git a/apps/workspace-engine/pkg/workspace/jobs/factory.go b/apps/workspace-engine/pkg/workspace/jobs/factory.go index 21e76dab6..daf8d7bca 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory.go @@ -52,20 +52,20 @@ func (f *Factory) NoAgentConfiguredJob(releaseID, jobAgentID, deploymentName str } } -func (f *Factory) jobAgentNotFoundJob(releaseID, jobAgentID, deploymentName string, action *trace.Action) *oapi.Job { - message := fmt.Sprintf("Job agent '%s' not found for deployment '%s'", jobAgentID, deploymentName) +func (f *Factory) InvalidDeploymentAgentsJob(releaseID, deploymentName string, action *trace.Action) *oapi.Job { + message := fmt.Sprintf("Invalid deployment agents for deployment '%s'", deploymentName) if action != nil { - action.AddStep("Create NoAgentFoundJob job", trace.StepResultPass, - fmt.Sprintf("Created NoAgentFoundJob job for release %s with job agent %s", releaseID, jobAgentID)). + action.AddStep("Create InvalidDeploymentAgentsJob job", trace.StepResultPass, + fmt.Sprintf("Created InvalidDeploymentAgentsJob job for release %s with deployment %s", releaseID, deploymentName)). AddMetadata("release_id", releaseID). - AddMetadata("job_agent_id", jobAgentID). + AddMetadata("deployment_name", deploymentName). AddMetadata("message", message) } return &oapi.Job{ Id: uuid.New().String(), ReleaseId: releaseID, - JobAgentId: jobAgentID, + JobAgentId: "", JobAgentConfig: oapi.JobAgentConfig{}, Status: oapi.JobStatusInvalidJobAgent, Message: &message, diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go index c6073a572..298b038d7 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go @@ -3,6 +3,7 @@ package deployment import ( "context" "fmt" + "strings" "time" "workspace-engine/pkg/oapi" "workspace-engine/pkg/workspace/jobagents" @@ -15,6 +16,14 @@ import ( oteltrace "go.opentelemetry.io/otel/trace" ) +func agentNames(agents []*oapi.JobAgent) []string { + names := make([]string, len(agents)) + for i, a := range agents { + names[i] = a.Name + } + return names +} + // Executor handles deployment execution (Phase 2: ACTION - Write operations). type Executor struct { store *store.Store @@ -79,6 +88,12 @@ func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Rel )) defer span.End() + var execution *trace.ExecutionPhase + if recorder != nil { + execution = recorder.StartExecution() + defer execution.End() + } + if err := e.store.Releases.Upsert(ctx, releaseToDeploy); err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to persist release") @@ -94,7 +109,20 @@ func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Rel if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to get deployment agents") - return nil, err + failedJob := e.jobFactory.InvalidDeploymentAgentsJob(releaseToDeploy.ID(), deployment.Name, nil) + e.store.Jobs.Upsert(ctx, failedJob) + return []*oapi.Job{failedJob}, nil + } + + if execution != nil { + execution.TriggerJob("create_jobs_for_deployment_agents", map[string]string{ + "deployment_id": deployment.Id, + "environment_id": releaseToDeploy.ReleaseTarget.EnvironmentId, + "resource_id": releaseToDeploy.ReleaseTarget.ResourceId, + "version_id": releaseToDeploy.Version.Id, + "version_tag": releaseToDeploy.Version.Tag, + "agents": strings.Join(agentNames(agents), ","), + }) } if len(agents) == 0 { diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go index 6a2549b77..b5e4a429f 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go @@ -178,7 +178,8 @@ func TestExecuteRelease_NoJobAgentConfigured(t *testing.T) { // No job agent configured and no jobAgents list — returns empty jobs require.NoError(t, err) - require.Empty(t, jobs) + require.Len(t, jobs, 1) + require.Equal(t, oapi.JobStatusInvalidJobAgent, jobs[0].Status) // Verify release was still persisted _, exists := testStore.Releases.Get(release.ID()) @@ -256,7 +257,7 @@ func TestExecuteRelease_MultipleReleases(t *testing.T) { jobAgentID := uuid.New().String() // Create necessary entities - jobAgent := createTestJobAgent(jobAgentID, workspaceID, "test-agent", "github") + jobAgent := createTestJobAgent(jobAgentID, workspaceID, "test-agent", "test-runner") testStore.JobAgents.Upsert(ctx, jobAgent) deployment := createTestDeploymentForExecutor(deploymentID, systemID, "test-deployment", jobAgentID) diff --git a/apps/workspace-engine/test/e2e/engine_deployment_test.go b/apps/workspace-engine/test/e2e/engine_deployment_test.go index 18f505f46..f34caf3ff 100644 --- a/apps/workspace-engine/test/e2e/engine_deployment_test.go +++ b/apps/workspace-engine/test/e2e/engine_deployment_test.go @@ -515,8 +515,9 @@ func TestEngine_AddingAgentToDeploymentRetriggersInvalidJobs(t *testing.T) { t.Fatalf("deployment not found") return } - d.JobAgentId = &jobAgentID - engine.PushEvent(ctx, handler.DeploymentUpdate, d) + dep := *d + dep.JobAgentId = &jobAgentID + engine.PushEvent(ctx, handler.DeploymentUpdate, &dep) allJobs = engine.Workspace().Jobs().Items() @@ -592,8 +593,9 @@ func TestEngine_FutureUpdatesDoNotRetriggerPreviouslyRetriggeredJobs(t *testing. t.Fatalf("deployment not found") return } - d.JobAgentId = &jobAgentID - engine.PushEvent(ctx, handler.DeploymentUpdate, d) + dep := *d + dep.JobAgentId = &jobAgentID + engine.PushEvent(ctx, handler.DeploymentUpdate, &dep) allJobs = engine.Workspace().Jobs().Items() diff --git a/apps/workspace-engine/test/e2e/engine_job_agent_retrigger_test.go b/apps/workspace-engine/test/e2e/engine_job_agent_retrigger_test.go index c48be0ef7..9a0a0115c 100644 --- a/apps/workspace-engine/test/e2e/engine_job_agent_retrigger_test.go +++ b/apps/workspace-engine/test/e2e/engine_job_agent_retrigger_test.go @@ -79,8 +79,9 @@ func TestEngine_JobAgentConfigurationRetriggersInvalidJobs(t *testing.T) { // Update deployment to use the job agent deployment, exists := engine.Workspace().Deployments().Get(deploymentID) require.True(t, exists, "deployment not found") - deployment.JobAgentId = &jobAgentID - engine.PushEvent(ctx, handler.DeploymentUpdate, deployment) + dep := *deployment + dep.JobAgentId = &jobAgentID + engine.PushEvent(ctx, handler.DeploymentUpdate, &dep) // Step 4: Verify new Pending jobs created allJobsAfterUpdate := engine.Workspace().Jobs().Items() @@ -195,12 +196,13 @@ func TestEngine_JobAgentConfigUpdateRetriggersInvalidJobs(t *testing.T) { // Update deployment with both job agent ID and custom config deployment, exists := engine.Workspace().Deployments().Get(deploymentID) require.True(t, exists, "deployment not found") - deployment.JobAgentId = &jobAgentID - deployment.JobAgentConfig = map[string]any{ + dep := *deployment + dep.JobAgentId = &jobAgentID + dep.JobAgentConfig = map[string]any{ "timeout": 600, // Override agent default "replicas": 3, // Add deployment-specific config } - engine.PushEvent(ctx, handler.DeploymentUpdate, deployment) + engine.PushEvent(ctx, handler.DeploymentUpdate, &dep) // Step 4: Verify new Pending job created with merged config allJobsAfterUpdate := engine.Workspace().Jobs().Items() @@ -326,8 +328,9 @@ func TestEngine_JobAgentConfigurationWithMultipleResources(t *testing.T) { // Update deployment to use the job agent deployment, exists := engine.Workspace().Deployments().Get(deploymentID) require.True(t, exists, "deployment not found") - deployment.JobAgentId = &jobAgentID - engine.PushEvent(ctx, handler.DeploymentUpdate, deployment) + dep := *deployment + dep.JobAgentId = &jobAgentID + engine.PushEvent(ctx, handler.DeploymentUpdate, &dep) // Verify new Pending jobs created for all resources allJobsAfterUpdate := engine.Workspace().Jobs().Items() From 4d10f384d69b40cecfb8ebaea40afe3cd99caba8 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Tue, 17 Feb 2026 13:21:51 -0800 Subject: [PATCH 04/10] add tests for array --- .../deployment_agent_selector_test.go | 659 ++++++++++++++++++ .../test/e2e/engine_deployment_test.go | 314 +++++++++ .../workspace-engine/test/integration/opts.go | 6 + 3 files changed, 979 insertions(+) create mode 100644 apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go diff --git a/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go new file mode 100644 index 000000000..c66a48504 --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go @@ -0,0 +1,659 @@ +package jobagents + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/statechange" + "workspace-engine/pkg/workspace/store" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ===== Test Helpers ===== + +func newTestStore() *store.Store { + cs := statechange.NewChangeSet[any]() + return store.New("test-workspace", cs) +} + +func makeJobAgent(id, name, agentType string) *oapi.JobAgent { + cfg := oapi.JobAgentConfig{} + _ = json.Unmarshal([]byte(`{"type":"custom"}`), &cfg) + return &oapi.JobAgent{ + Id: id, + WorkspaceId: "test-workspace", + Name: name, + Type: agentType, + Config: cfg, + } +} + +func makeDeployment(id, name string, jobAgentId *string, jobAgents *[]oapi.DeploymentJobAgent) *oapi.Deployment { + sel := &oapi.Selector{} + _ = sel.FromCelSelector(oapi.CelSelector{Cel: "true"}) + return &oapi.Deployment{ + Id: id, + Name: name, + Slug: name, + ResourceSelector: sel, + JobAgentId: jobAgentId, + JobAgentConfig: oapi.JobAgentConfig{}, + JobAgents: jobAgents, + Metadata: map[string]string{}, + } +} + +func makeEnvironment(id, name string) *oapi.Environment { + sel := &oapi.Selector{} + _ = sel.FromCelSelector(oapi.CelSelector{Cel: "true"}) + return &oapi.Environment{ + Id: id, + Name: name, + ResourceSelector: sel, + Metadata: map[string]string{}, + } +} + +func makeResource(id, name string, metadata map[string]string) *oapi.Resource { + return &oapi.Resource{ + Id: id, + Name: name, + Kind: "Kubernetes", + Identifier: name, + CreatedAt: time.Now(), + Config: map[string]any{}, + Metadata: metadata, + WorkspaceId: "test-workspace", + } +} + +func makeRelease(deploymentId, environmentId, resourceId string) *oapi.Release { + return &oapi.Release{ + ReleaseTarget: oapi.ReleaseTarget{ + DeploymentId: deploymentId, + EnvironmentId: environmentId, + ResourceId: resourceId, + }, + Version: oapi.DeploymentVersion{ + Id: uuid.New().String(), + Tag: "v1.0.0", + }, + Variables: map[string]oapi.LiteralValue{}, + EncryptedVariables: []string{}, + CreatedAt: time.Now().Format(time.RFC3339), + } +} + +func strPtr(s string) *string { return &s } + +// ===== Group 1: Legacy single-agent path ===== + +func TestSelectAgents_Legacy_AgentExists(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + agent := makeJobAgent(agentID, "legacy-agent", "runner") + s.JobAgents.Upsert(ctx, agent) + + deployment := makeDeployment(uuid.New().String(), "deploy", strPtr(agentID), nil) + release := makeRelease(deployment.Id, uuid.New().String(), uuid.New().String()) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 1) + assert.Equal(t, agentID, agents[0].Id) + assert.Equal(t, "legacy-agent", agents[0].Name) +} + +func TestSelectAgents_Legacy_AgentNotFound(t *testing.T) { + s := newTestStore() + + missingID := uuid.New().String() + deployment := makeDeployment(uuid.New().String(), "deploy", strPtr(missingID), nil) + release := makeRelease(deployment.Id, uuid.New().String(), uuid.New().String()) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.Error(t, err) + assert.Nil(t, agents) + assert.Contains(t, err.Error(), "not found") +} + +// ===== Group 2: No agent configured ===== + +func TestSelectAgents_NoAgent_NilJobAgentId_NilJobAgents(t *testing.T) { + s := newTestStore() + + deployment := makeDeployment(uuid.New().String(), "deploy", nil, nil) + release := makeRelease(deployment.Id, uuid.New().String(), uuid.New().String()) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.NoError(t, err) + assert.Empty(t, agents) +} + +func TestSelectAgents_NoAgent_NilJobAgentId_EmptyJobAgents(t *testing.T) { + s := newTestStore() + + empty := &[]oapi.DeploymentJobAgent{} + deployment := makeDeployment(uuid.New().String(), "deploy", nil, empty) + release := makeRelease(deployment.Id, uuid.New().String(), uuid.New().String()) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.NoError(t, err) + assert.Empty(t, agents) +} + +func TestSelectAgents_NoAgent_EmptyStringJobAgentId_NilJobAgents(t *testing.T) { + s := newTestStore() + + deployment := makeDeployment(uuid.New().String(), "deploy", strPtr(""), nil) + release := makeRelease(deployment.Id, uuid.New().String(), uuid.New().String()) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.NoError(t, err) + assert.Empty(t, agents) +} + +// ===== Group 3: Multi-agent path -- basic selection (no if conditions) ===== + +func TestSelectAgents_MultiAgent_SingleNoIf(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{{Ref: agentID, Config: oapi.JobAgentConfig{}}} + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 1) + assert.Equal(t, agentID, agents[0].Id) +} + +func TestSelectAgents_MultiAgent_MultipleNoIf_PreservesOrder(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + ids := []string{uuid.New().String(), uuid.New().String(), uuid.New().String()} + names := []string{"agent-a", "agent-b", "agent-c"} + envID := uuid.New().String() + resID := uuid.New().String() + + for i, id := range ids { + s.JobAgents.Upsert(ctx, makeJobAgent(id, names[i], "runner")) + } + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{ + {Ref: ids[0], Config: oapi.JobAgentConfig{}}, + {Ref: ids[1], Config: oapi.JobAgentConfig{}}, + {Ref: ids[2], Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 3) + for i, agent := range agents { + assert.Equal(t, ids[i], agent.Id) + assert.Equal(t, names[i], agent.Name) + } +} + +func TestSelectAgents_MultiAgent_RefNotFound(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + envID := uuid.New().String() + resID := uuid.New().String() + + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + missingRef := uuid.New().String() + ja := []oapi.DeploymentJobAgent{{Ref: missingRef, Config: oapi.JobAgentConfig{}}} + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.Error(t, err) + assert.Nil(t, agents) + assert.Contains(t, err.Error(), "not found") +} + +// ===== Group 4: Multi-agent path -- CEL if conditions ===== + +func TestSelectAgents_CEL_TrueLiteral(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "true", Config: oapi.JobAgentConfig{}}} + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 1) + assert.Equal(t, agentID, agents[0].Id) +} + +func TestSelectAgents_CEL_FalseLiteral(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "false", Config: oapi.JobAgentConfig{}}} + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + selector := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := selector.SelectAgents() + + require.NoError(t, err) + assert.Empty(t, agents) +} + +func TestSelectAgents_CEL_MixedConditions(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentA := uuid.New().String() + agentB := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentA, "agent-a", "runner")) + s.JobAgents.Upsert(ctx, makeJobAgent(agentB, "agent-b", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + tests := []struct { + name string + ifA string + ifB string + expectIDs []string + expectCount int + }{ + { + name: "first true second false", + ifA: "true", + ifB: "false", + expectIDs: []string{agentA}, + expectCount: 1, + }, + { + name: "first false second true", + ifA: "false", + ifB: "true", + expectIDs: []string{agentB}, + expectCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ja := []oapi.DeploymentJobAgent{ + {Ref: agentA, If: tt.ifA, Config: oapi.JobAgentConfig{}}, + {Ref: agentB, If: tt.ifB, Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, tt.expectCount) + for i, id := range tt.expectIDs { + assert.Equal(t, id, agents[i].Id) + } + }) + } +} + +func TestSelectAgents_CEL_AllTrue(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + ids := []string{uuid.New().String(), uuid.New().String(), uuid.New().String()} + envID := uuid.New().String() + resID := uuid.New().String() + + for i, id := range ids { + s.JobAgents.Upsert(ctx, makeJobAgent(id, fmt.Sprintf("agent-%d", i), "runner")) + } + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{ + {Ref: ids[0], If: "true", Config: oapi.JobAgentConfig{}}, + {Ref: ids[1], If: "true", Config: oapi.JobAgentConfig{}}, + {Ref: ids[2], If: "true", Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 3) + for i, agent := range agents { + assert.Equal(t, ids[i], agent.Id) + } +} + +func TestSelectAgents_CEL_AllFalse(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + ids := []string{uuid.New().String(), uuid.New().String(), uuid.New().String()} + envID := uuid.New().String() + resID := uuid.New().String() + + for i, id := range ids { + s.JobAgents.Upsert(ctx, makeJobAgent(id, fmt.Sprintf("agent-%d", i), "runner")) + } + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{ + {Ref: ids[0], If: "false", Config: oapi.JobAgentConfig{}}, + {Ref: ids[1], If: "false", Config: oapi.JobAgentConfig{}}, + {Ref: ids[2], If: "false", Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.NoError(t, err) + assert.Empty(t, agents) +} + +func TestSelectAgents_CEL_ResourceMetadataMatch(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{"region": "us-east-1"})) + + ja := []oapi.DeploymentJobAgent{ + {Ref: agentID, If: `resource.metadata.region == "us-east-1"`, Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 1) + assert.Equal(t, agentID, agents[0].Id) +} + +func TestSelectAgents_CEL_ResourceMetadataNoMatch(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{"region": "eu-west-1"})) + + ja := []oapi.DeploymentJobAgent{ + {Ref: agentID, If: `resource.metadata.region == "us-east-1"`, Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.NoError(t, err) + assert.Empty(t, agents) +} + +func TestSelectAgents_CEL_EnvironmentNameMatch(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "production")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{ + {Ref: agentID, If: `environment.name == "production"`, Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 1) + assert.Equal(t, agentID, agents[0].Id) +} + +func TestSelectAgents_CEL_DeploymentNameMatch(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{ + {Ref: agentID, If: `deployment.name == "my-deploy"`, Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "my-deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 1) + assert.Equal(t, agentID, agents[0].Id) +} + +// ===== Group 5: CEL context + error cases ===== + +func TestSelectAgents_CEL_EnvironmentNotInStore(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + missingEnvID := uuid.New().String() + ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "true", Config: oapi.JobAgentConfig{}}} + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, missingEnvID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.Error(t, err) + assert.Nil(t, agents) + assert.Contains(t, err.Error(), "environment") + assert.Contains(t, err.Error(), "not found") +} + +func TestSelectAgents_CEL_ResourceNotInStore(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + + missingResID := uuid.New().String() + ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "true", Config: oapi.JobAgentConfig{}}} + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, missingResID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.Error(t, err) + assert.Nil(t, agents) + assert.Contains(t, err.Error(), "resource") + assert.Contains(t, err.Error(), "not found") +} + +func TestSelectAgents_CEL_InvalidSyntax(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentID, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "!@#$", Config: oapi.JobAgentConfig{}}} + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.Error(t, err) + assert.Nil(t, agents) + assert.Contains(t, err.Error(), "failed to compile") +} + +func TestSelectAgents_CEL_FirstPassesSecondBadRef(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + agentA := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(agentA, "agent-a", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + missingRef := uuid.New().String() + ja := []oapi.DeploymentJobAgent{ + {Ref: agentA, If: "true", Config: oapi.JobAgentConfig{}}, + {Ref: missingRef, If: "true", Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.Error(t, err) + assert.Nil(t, agents) + assert.Contains(t, err.Error(), "not found") +} + +// ===== Group 6: Priority / precedence ===== + +func TestSelectAgents_LegacyTakesPrecedenceOverJobAgents(t *testing.T) { + s := newTestStore() + ctx := context.Background() + + legacyAgentID := uuid.New().String() + newAgentID := uuid.New().String() + envID := uuid.New().String() + resID := uuid.New().String() + + s.JobAgents.Upsert(ctx, makeJobAgent(legacyAgentID, "legacy-agent", "runner")) + s.JobAgents.Upsert(ctx, makeJobAgent(newAgentID, "new-agent", "runner")) + _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) + _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) + + ja := []oapi.DeploymentJobAgent{ + {Ref: newAgentID, Config: oapi.JobAgentConfig{}}, + } + deployment := makeDeployment(uuid.New().String(), "deploy", strPtr(legacyAgentID), &ja) + release := makeRelease(deployment.Id, envID, resID) + + sel := NewDeploymentAgentsSelector(s, deployment, release) + agents, err := sel.SelectAgents() + + require.NoError(t, err) + require.Len(t, agents, 1) + assert.Equal(t, legacyAgentID, agents[0].Id) + assert.Equal(t, "legacy-agent", agents[0].Name) +} diff --git a/apps/workspace-engine/test/e2e/engine_deployment_test.go b/apps/workspace-engine/test/e2e/engine_deployment_test.go index f34caf3ff..1a88ff09d 100644 --- a/apps/workspace-engine/test/e2e/engine_deployment_test.go +++ b/apps/workspace-engine/test/e2e/engine_deployment_test.go @@ -1007,3 +1007,317 @@ func BenchmarkEngine_DeploymentRemoval(b *testing.B) { engine.PushEvent(ctx, handler.DeploymentDelete, deployments[i]) } } + +// ===== Multi-Agent (JobAgents array) E2E Tests ===== + +func TestEngine_DeploymentJobAgentsArray_AllAgentsNoCondition(t *testing.T) { + agentK8s := uuid.New().String() + agentDocker := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(agentK8s), + integration.JobAgentName("Kubernetes Agent"), + ), + integration.WithJobAgent( + integration.JobAgentID(agentDocker), + integration.JobAgentName("Docker Agent"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("multi-agent-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: agentK8s, Config: oapi.JobAgentConfig{}}, + {Ref: agentDocker, Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("resource-1"), + ), + ) + + pendingJobs := engine.Workspace().Jobs().GetPending() + + jobAgentIDs := map[string]bool{} + for _, job := range pendingJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + t.Fatalf("release not found for job %s", job.Id) + } + if release.ReleaseTarget.DeploymentId == deploymentID { + jobAgentIDs[job.JobAgentId] = true + } + } + + assert.True(t, jobAgentIDs[agentK8s], "should have a job for the Kubernetes agent") + assert.True(t, jobAgentIDs[agentDocker], "should have a job for the Docker agent") + assert.Equal(t, 2, len(jobAgentIDs), "should have exactly 2 jobs (one per agent)") +} + +func TestEngine_DeploymentJobAgentsArray_WithIfConditionFilters(t *testing.T) { + agentK8s := uuid.New().String() + agentDocker := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(agentK8s), + integration.JobAgentName("Kubernetes Agent"), + ), + integration.WithJobAgent( + integration.JobAgentID(agentDocker), + integration.JobAgentName("Docker Agent"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("conditional-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: agentK8s, If: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentDocker, If: "true", Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("gcp-resource"), + integration.ResourceMetadata(map[string]string{"cloud": "gcp"}), + ), + ) + + pendingJobs := engine.Workspace().Jobs().GetPending() + + jobAgentIDs := map[string]bool{} + for _, job := range pendingJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + t.Fatalf("release not found for job %s", job.Id) + } + if release.ReleaseTarget.DeploymentId == deploymentID { + jobAgentIDs[job.JobAgentId] = true + } + } + + assert.True(t, jobAgentIDs[agentK8s], "k8s agent should match (resource cloud=gcp)") + assert.True(t, jobAgentIDs[agentDocker], "docker agent should match (if=true)") + assert.Equal(t, 2, len(jobAgentIDs), "both agents should produce jobs") +} + +func TestEngine_DeploymentJobAgentsArray_IfConditionExcludesAgent(t *testing.T) { + agentK8s := uuid.New().String() + agentDocker := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(agentK8s), + integration.JobAgentName("Kubernetes Agent"), + ), + integration.WithJobAgent( + integration.JobAgentID(agentDocker), + integration.JobAgentName("Docker Agent"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("filtered-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: agentK8s, If: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentDocker, If: `resource.metadata.cloud == "aws"`, Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("gcp-resource"), + integration.ResourceMetadata(map[string]string{"cloud": "gcp"}), + ), + ) + + pendingJobs := engine.Workspace().Jobs().GetPending() + + jobAgentIDs := map[string]bool{} + for _, job := range pendingJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + t.Fatalf("release not found for job %s", job.Id) + } + if release.ReleaseTarget.DeploymentId == deploymentID { + jobAgentIDs[job.JobAgentId] = true + } + } + + assert.True(t, jobAgentIDs[agentK8s], "k8s agent should match (cloud=gcp)") + assert.False(t, jobAgentIDs[agentDocker], "docker agent should NOT match (cloud!=aws)") + assert.Equal(t, 1, len(jobAgentIDs), "only one agent should produce a job") +} + +func TestEngine_DeploymentJobAgentsArray_AllConditionsFalse(t *testing.T) { + agentA := uuid.New().String() + agentB := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(agentA), + integration.JobAgentName("Agent A"), + ), + integration.WithJobAgent( + integration.JobAgentID(agentB), + integration.JobAgentName("Agent B"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("no-match-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: agentA, If: `environment.name == "staging"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentB, If: `environment.name == "staging"`, Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("resource-1"), + ), + ) + + allJobs := engine.Workspace().Jobs().Items() + + deploymentJobs := 0 + for _, job := range allJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + continue + } + if release.ReleaseTarget.DeploymentId == deploymentID { + deploymentJobs++ + assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status, + "when no agents match, should create an InvalidJobAgent job") + } + } + + assert.Equal(t, 1, deploymentJobs, "should have 1 job with InvalidJobAgent status") +} + +func TestEngine_DeploymentJobAgentsArray_MultipleResourcesDifferentAgents(t *testing.T) { + agentGCP := uuid.New().String() + agentAWS := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(agentGCP), + integration.JobAgentName("GCP Agent"), + ), + integration.WithJobAgent( + integration.JobAgentID(agentAWS), + integration.JobAgentName("AWS Agent"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("multi-cloud-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: agentGCP, If: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentAWS, If: `resource.metadata.cloud == "aws"`, Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("gcp-server"), + integration.ResourceMetadata(map[string]string{"cloud": "gcp"}), + ), + integration.WithResource( + integration.ResourceName("aws-server"), + integration.ResourceMetadata(map[string]string{"cloud": "aws"}), + ), + ) + + pendingJobs := engine.Workspace().Jobs().GetPending() + + type jobInfo struct { + agentID string + resourceID string + } + var jobs []jobInfo + for _, job := range pendingJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + t.Fatalf("release not found for job %s", job.Id) + } + if release.ReleaseTarget.DeploymentId == deploymentID { + jobs = append(jobs, jobInfo{ + agentID: job.JobAgentId, + resourceID: release.ReleaseTarget.ResourceId, + }) + } + } + + // 2 resources x 1 matching agent each = 2 pending jobs + assert.Equal(t, 2, len(jobs), "should have 2 pending jobs (one per resource)") + + gcpJobs := 0 + awsJobs := 0 + for _, j := range jobs { + if j.agentID == agentGCP { + gcpJobs++ + } + if j.agentID == agentAWS { + awsJobs++ + } + } + + assert.Equal(t, 1, gcpJobs, "GCP agent should have 1 job (for gcp-server)") + assert.Equal(t, 1, awsJobs, "AWS agent should have 1 job (for aws-server)") +} diff --git a/apps/workspace-engine/test/integration/opts.go b/apps/workspace-engine/test/integration/opts.go index dc50fa289..1f21591fa 100644 --- a/apps/workspace-engine/test/integration/opts.go +++ b/apps/workspace-engine/test/integration/opts.go @@ -390,6 +390,12 @@ func DeploymentJobAgent(jobAgentID string) DeploymentOption { } } +func DeploymentJobAgents(agents []oapi.DeploymentJobAgent) DeploymentOption { + return func(_ *TestWorkspace, d *oapi.Deployment, _ *eventsBuilder) { + d.JobAgents = &agents + } +} + func DeploymentCelResourceSelector(cel string) DeploymentOption { return func(_ *TestWorkspace, d *oapi.Deployment, _ *eventsBuilder) { s := &oapi.Selector{} From 27bfb7f550e2cdcb1046ab14c7912fdbe4fd328e Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 18 Feb 2026 19:09:58 -0800 Subject: [PATCH 05/10] rabbit comments --- .../events/handler/deployment/deployment.go | 63 +++++++++++-------- .../jobagents/deployment_agent_selector.go | 13 ++-- .../pkg/workspace/jobs/factory.go | 3 + .../releasemanager/deployment/executor.go | 14 ++++- .../deployment/executor_test.go | 1 - 5 files changed, 61 insertions(+), 33 deletions(-) diff --git a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go index 92ca2ef29..5b7123922 100644 --- a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go +++ b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go @@ -11,6 +11,7 @@ import ( "workspace-engine/pkg/oapi" "workspace-engine/pkg/selector" "workspace-engine/pkg/workspace" + "workspace-engine/pkg/workspace/jobagents" "workspace-engine/pkg/workspace/jobs" "workspace-engine/pkg/workspace/releasemanager" "workspace-engine/pkg/workspace/releasemanager/trace" @@ -202,7 +203,7 @@ func HandleDeploymentUpdated( oldDeployment, err := getOldDeployment(ws, deployment.Id) if err != nil { - return err + return HandleDeploymentCreated(ctx, ws, event) } if err := ws.Deployments().Upsert(ctx, deployment); err != nil { @@ -311,49 +312,61 @@ func getJobsToRetrigger(ws *workspace.Workspace, deployment *oapi.Deployment) [] // Note: This is an explicit retrigger operation for configuration fixes, so we bypass normal // eligibility checks (like retry limits). The old InvalidJobAgent job remains for history. func retriggerInvalidJobAgentJobs(ctx context.Context, ws *workspace.Workspace, jobsToRetrigger []*oapi.Job) { - // Create job factory and dispatcher jobFactory := jobs.NewFactory(ws.Store()) for _, job := range jobsToRetrigger { - // Get the release for this job release, ok := ws.Releases().Get(job.ReleaseId) if !ok || release == nil { continue } - agent, ok := ws.JobAgents().Get(job.JobAgentId) - if !ok || agent == nil { + deployment, ok := ws.Deployments().Get(release.ReleaseTarget.DeploymentId) + if !ok || deployment == nil { continue } - // Create a new job for this release (bypassing eligibility checks for explicit retrigger) - newJob, err := jobFactory.CreateJobForRelease(ctx, release, agent, nil) + agents, err := jobagents.NewDeploymentAgentsSelector(ws.Store(), deployment, release).SelectAgents() if err != nil { - log.Error("failed to create job for release during retrigger", + log.Error("failed to select agents for release during retrigger", "releaseId", release.ID(), "deploymentId", release.ReleaseTarget.DeploymentId, "error", err.Error()) continue } - // Upsert the new job - ws.Jobs().Upsert(ctx, newJob) - log.Info("created new job for previously invalid job agent", - "newJobId", newJob.Id, - "originalJobId", job.Id, - "releaseId", release.ID(), - "deploymentId", release.ReleaseTarget.DeploymentId, - "status", newJob.Status) - - if newJob.Status != oapi.JobStatusInvalidJobAgent { - if err := ws.JobAgentRegistry().Dispatch(ctx, newJob); err != nil { - message := err.Error() - newJob.Status = oapi.JobStatusInvalidIntegration - newJob.UpdatedAt = time.Now() - newJob.Message = &message - ws.Jobs().Upsert(ctx, newJob) - } + if len(agents) == 0 { + continue } + for _, agent := range agents { + newJob, err := jobFactory.CreateJobForRelease(ctx, release, agent, nil) + if err != nil { + log.Error("failed to create job for release during retrigger", + "releaseId", release.ID(), + "deploymentId", release.ReleaseTarget.DeploymentId, + "agentId", agent.Id, + "error", err.Error()) + continue + } + + ws.Jobs().Upsert(ctx, newJob) + log.Info("created new job for previously invalid job agent", + "newJobId", newJob.Id, + "originalJobId", job.Id, + "releaseId", release.ID(), + "deploymentId", release.ReleaseTarget.DeploymentId, + "agentId", agent.Id, + "status", newJob.Status) + + if newJob.Status != oapi.JobStatusInvalidJobAgent { + if err := ws.JobAgentRegistry().Dispatch(ctx, newJob); err != nil { + message := err.Error() + newJob.Status = oapi.JobStatusInvalidIntegration + newJob.UpdatedAt = time.Now() + newJob.Message = &message + ws.Jobs().Upsert(ctx, newJob) + } + } + } } } diff --git a/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go index 43c054016..fe7e7572c 100644 --- a/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go +++ b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go @@ -8,11 +8,6 @@ import ( "workspace-engine/pkg/workspace/store" ) -var jobAgentIfEnv, _ = celutil.NewEnvBuilder(). - WithMapVariables("release", "deployment", "environment", "resource"). - WithStandardExtensions(). - BuildCached(12 * time.Hour) - type DeploymentAgentsSelector struct { store *store.Store deployment *oapi.Deployment @@ -82,6 +77,14 @@ func (s *DeploymentAgentsSelector) SelectAgents() ([]*oapi.JobAgent, error) { return nil, fmt.Errorf("failed to build cel context: %w", err) } + jobAgentIfEnv, err := celutil.NewEnvBuilder(). + WithMapVariables("release", "deployment", "environment", "resource"). + WithStandardExtensions(). + BuildCached(12 * time.Hour) + if err != nil { + return nil, fmt.Errorf("failed to build job agent if cel environment: %w", err) + } + jobAgents := make([]*oapi.JobAgent, 0) for _, deploymentJobAgent := range *s.deployment.JobAgents { if deploymentJobAgent.If != "" { diff --git a/apps/workspace-engine/pkg/workspace/jobs/factory.go b/apps/workspace-engine/pkg/workspace/jobs/factory.go index daf8d7bca..97e38ead1 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory.go @@ -29,6 +29,7 @@ func NewFactory(store *store.Store) *Factory { } } +// NoAgentConfiguredJob creates a job for a release with no job agent configured. func (f *Factory) NoAgentConfiguredJob(releaseID, jobAgentID, deploymentName string, action *trace.Action) *oapi.Job { message := fmt.Sprintf("No job agent configured for deployment '%s'", deploymentName) if action != nil { @@ -52,6 +53,7 @@ func (f *Factory) NoAgentConfiguredJob(releaseID, jobAgentID, deploymentName str } } +// InvalidDeploymentAgentsJob creates a job for a release with invalid deployment agents. func (f *Factory) InvalidDeploymentAgentsJob(releaseID, deploymentName string, action *trace.Action) *oapi.Job { message := fmt.Sprintf("Invalid deployment agents for deployment '%s'", deploymentName) if action != nil { @@ -259,6 +261,7 @@ func (f *Factory) buildWorkflowJobDispatchContext(wfJob *oapi.WorkflowJob, jobAg }, nil } +// CreateJobForWorkflowJob creates a job for a given workflow job. func (f *Factory) CreateJobForWorkflowJob(ctx context.Context, wfJob *oapi.WorkflowJob) (*oapi.Job, error) { jobAgent, exists := f.store.JobAgents.Get(wfJob.Ref) if !exists { diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go index 298b038d7..ff7c598bd 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go @@ -40,6 +40,17 @@ func NewExecutor(store *store.Store, jobAgentRegistry *jobagents.Registry) *Exec } } +func (e *Executor) updateJobWithFailure(ctx context.Context, job *oapi.Job, err error) (*oapi.Job, error) { + job.Status = oapi.JobStatusFailure + message := err.Error() + job.Message = &message + now := time.Now().UTC() + job.CompletedAt = &now + job.UpdatedAt = now + e.store.Jobs.Upsert(ctx, job) + return job, nil +} + func (e *Executor) dispatchJobForAgent(ctx context.Context, release *oapi.Release, agent *oapi.JobAgent) (*oapi.Job, error) { _, span := tracer.Start(ctx, "createJobForAgent", oteltrace.WithAttributes( @@ -67,7 +78,7 @@ func (e *Executor) dispatchJobForAgent(ctx context.Context, release *oapi.Releas if err := e.jobAgentRegistry.Dispatch(ctx, newJob); err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to dispatch job") - return nil, err + return e.updateJobWithFailure(ctx, newJob, err) } return newJob, nil @@ -137,7 +148,6 @@ func (e *Executor) ExecuteRelease(ctx context.Context, releaseToDeploy *oapi.Rel if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to create job") - return nil, err } newJobs = append(newJobs, newJob) diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go index b5e4a429f..80b7ac422 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go @@ -176,7 +176,6 @@ func TestExecuteRelease_NoJobAgentConfigured(t *testing.T) { // Execute release jobs, err := executor.ExecuteRelease(ctx, release, nil) - // No job agent configured and no jobAgents list — returns empty jobs require.NoError(t, err) require.Len(t, jobs, 1) require.Equal(t, oapi.JobStatusInvalidJobAgent, jobs[0].Status) From 63eda5855b6d66773607cfa44bc9c578a9329f92 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 18 Feb 2026 19:39:51 -0800 Subject: [PATCH 06/10] update db schemas and rename to selector --- apps/workspace-engine/oapi/openapi.json | 8 +- .../oapi/spec/schemas/deployments.jsonnet | 4 +- .../pkg/db/deployments.sql.go | 21 +- apps/workspace-engine/pkg/db/models.go | 1 + .../pkg/db/queries/deployments.sql | 9 +- .../pkg/db/queries/schema.sql | 1 + apps/workspace-engine/pkg/db/sqlc.yaml | 3 + apps/workspace-engine/pkg/oapi/oapi.gen.go | 6 +- .../jobagents/deployment_agent_selector.go | 6 +- .../deployment_agent_selector_test.go | 38 +- .../store/repository/db/deployments/mapper.go | 17 + .../test/e2e/engine_deployment_test.go | 16 +- .../db/drizzle/0146_tidy_blazing_skull.sql | 1 + packages/db/drizzle/meta/0146_snapshot.json | 3296 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 9 +- packages/db/src/schema/deployment.ts | 7 + 16 files changed, 3392 insertions(+), 51 deletions(-) create mode 100644 packages/db/drizzle/0146_tidy_blazing_skull.sql create mode 100644 packages/db/drizzle/meta/0146_snapshot.json diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index 60ac65afc..c86963e7c 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -242,18 +242,18 @@ "config": { "$ref": "#/components/schemas/JobAgentConfig" }, - "if": { - "description": "CEL expression to determine if the job agent should be used", + "ref": { "type": "string" }, - "ref": { + "selector": { + "description": "CEL expression to determine if the job agent should be used", "type": "string" } }, "required": [ "ref", "config", - "if" + "selector" ], "type": "object" }, diff --git a/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet b/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet index 1adcaf9d2..d877b1baa 100644 --- a/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet @@ -19,11 +19,11 @@ local openapi = import '../lib/openapi.libsonnet'; DeploymentJobAgent: { type: 'object', - required: ['ref', 'config', 'if'], + required: ['ref', 'config', 'selector'], properties: { ref: { type: 'string' }, config: openapi.schemaRef('JobAgentConfig'), - 'if': { type: 'string', description: 'CEL expression to determine if the job agent should be used' }, + selector: { type: 'string', description: 'CEL expression to determine if the job agent should be used' }, }, }, diff --git a/apps/workspace-engine/pkg/db/deployments.sql.go b/apps/workspace-engine/pkg/db/deployments.sql.go index 2dd13bc25..8744ec447 100644 --- a/apps/workspace-engine/pkg/db/deployments.sql.go +++ b/apps/workspace-engine/pkg/db/deployments.sql.go @@ -45,7 +45,7 @@ func (q *Queries) DeleteSystemDeploymentByDeploymentID(ctx context.Context, depl } const getDeploymentByID = `-- name: GetDeploymentByID :one -SELECT id, name, description, job_agent_id, job_agent_config, resource_selector, metadata, workspace_id +SELECT id, name, description, job_agent_id, job_agent_config, job_agents, resource_selector, metadata, workspace_id FROM deployment WHERE id = $1 ` @@ -59,6 +59,7 @@ func (q *Queries) GetDeploymentByID(ctx context.Context, id uuid.UUID) (Deployme &i.Description, &i.JobAgentID, &i.JobAgentConfig, + &i.JobAgents, &i.ResourceSelector, &i.Metadata, &i.WorkspaceID, @@ -115,7 +116,7 @@ func (q *Queries) GetSystemIDsForDeployment(ctx context.Context, deploymentID uu } const listDeploymentsBySystemID = `-- name: ListDeploymentsBySystemID :many -SELECT d.id, d.name, d.description, d.job_agent_id, d.job_agent_config, d.resource_selector, d.metadata, d.workspace_id +SELECT d.id, d.name, d.description, d.job_agent_id, d.job_agent_config, d.job_agents, d.resource_selector, d.metadata, d.workspace_id FROM deployment d INNER JOIN system_deployment sd ON sd.deployment_id = d.id WHERE sd.system_id = $1 @@ -136,6 +137,7 @@ func (q *Queries) ListDeploymentsBySystemID(ctx context.Context, systemID uuid.U &i.Description, &i.JobAgentID, &i.JobAgentConfig, + &i.JobAgents, &i.ResourceSelector, &i.Metadata, &i.WorkspaceID, @@ -151,7 +153,7 @@ func (q *Queries) ListDeploymentsBySystemID(ctx context.Context, systemID uuid.U } const listDeploymentsByWorkspaceID = `-- name: ListDeploymentsByWorkspaceID :many -SELECT id, name, description, job_agent_id, job_agent_config, resource_selector, metadata, workspace_id +SELECT id, name, description, job_agent_id, job_agent_config, job_agents, resource_selector, metadata, workspace_id FROM deployment WHERE workspace_id = $1 LIMIT COALESCE($2::int, 5000) @@ -177,6 +179,7 @@ func (q *Queries) ListDeploymentsByWorkspaceID(ctx context.Context, arg ListDepl &i.Description, &i.JobAgentID, &i.JobAgentConfig, + &i.JobAgents, &i.ResourceSelector, &i.Metadata, &i.WorkspaceID, @@ -192,13 +195,14 @@ func (q *Queries) ListDeploymentsByWorkspaceID(ctx context.Context, arg ListDepl } const upsertDeployment = `-- name: UpsertDeployment :one -INSERT INTO deployment (id, name, description, job_agent_id, job_agent_config, resource_selector, metadata, workspace_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +INSERT INTO deployment (id, name, description, job_agent_id, job_agent_config, job_agents, resource_selector, metadata, workspace_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, job_agent_id = EXCLUDED.job_agent_id, - job_agent_config = EXCLUDED.job_agent_config, resource_selector = EXCLUDED.resource_selector, + job_agent_config = EXCLUDED.job_agent_config, job_agents = EXCLUDED.job_agents, + resource_selector = EXCLUDED.resource_selector, metadata = EXCLUDED.metadata, workspace_id = EXCLUDED.workspace_id -RETURNING id, name, description, job_agent_id, job_agent_config, resource_selector, metadata, workspace_id +RETURNING id, name, description, job_agent_id, job_agent_config, job_agents, resource_selector, metadata, workspace_id ` type UpsertDeploymentParams struct { @@ -207,6 +211,7 @@ type UpsertDeploymentParams struct { Description string JobAgentID uuid.UUID JobAgentConfig map[string]any + JobAgents []byte ResourceSelector pgtype.Text Metadata map[string]string WorkspaceID uuid.UUID @@ -219,6 +224,7 @@ func (q *Queries) UpsertDeployment(ctx context.Context, arg UpsertDeploymentPara arg.Description, arg.JobAgentID, arg.JobAgentConfig, + arg.JobAgents, arg.ResourceSelector, arg.Metadata, arg.WorkspaceID, @@ -230,6 +236,7 @@ func (q *Queries) UpsertDeployment(ctx context.Context, arg UpsertDeploymentPara &i.Description, &i.JobAgentID, &i.JobAgentConfig, + &i.JobAgents, &i.ResourceSelector, &i.Metadata, &i.WorkspaceID, diff --git a/apps/workspace-engine/pkg/db/models.go b/apps/workspace-engine/pkg/db/models.go index f7356906b..96cf52338 100644 --- a/apps/workspace-engine/pkg/db/models.go +++ b/apps/workspace-engine/pkg/db/models.go @@ -71,6 +71,7 @@ type Deployment struct { Description string JobAgentID uuid.UUID JobAgentConfig map[string]any + JobAgents []byte ResourceSelector pgtype.Text Metadata map[string]string WorkspaceID uuid.UUID diff --git a/apps/workspace-engine/pkg/db/queries/deployments.sql b/apps/workspace-engine/pkg/db/queries/deployments.sql index 7dc023a54..ca8737dc6 100644 --- a/apps/workspace-engine/pkg/db/queries/deployments.sql +++ b/apps/workspace-engine/pkg/db/queries/deployments.sql @@ -10,17 +10,18 @@ WHERE workspace_id = $1 LIMIT COALESCE(sqlc.narg('limit')::int, 5000); -- name: ListDeploymentsBySystemID :many -SELECT d.id, d.name, d.description, d.job_agent_id, d.job_agent_config, d.resource_selector, d.metadata, d.workspace_id +SELECT d.id, d.name, d.description, d.job_agent_id, d.job_agent_config, d.job_agents, d.resource_selector, d.metadata, d.workspace_id FROM deployment d INNER JOIN system_deployment sd ON sd.deployment_id = d.id WHERE sd.system_id = $1; -- name: UpsertDeployment :one -INSERT INTO deployment (id, name, description, job_agent_id, job_agent_config, resource_selector, metadata, workspace_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +INSERT INTO deployment (id, name, description, job_agent_id, job_agent_config, job_agents, resource_selector, metadata, workspace_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, job_agent_id = EXCLUDED.job_agent_id, - job_agent_config = EXCLUDED.job_agent_config, resource_selector = EXCLUDED.resource_selector, + job_agent_config = EXCLUDED.job_agent_config, job_agents = EXCLUDED.job_agents, + resource_selector = EXCLUDED.resource_selector, metadata = EXCLUDED.metadata, workspace_id = EXCLUDED.workspace_id RETURNING *; diff --git a/apps/workspace-engine/pkg/db/queries/schema.sql b/apps/workspace-engine/pkg/db/queries/schema.sql index 741ba1f43..f416716d6 100644 --- a/apps/workspace-engine/pkg/db/queries/schema.sql +++ b/apps/workspace-engine/pkg/db/queries/schema.sql @@ -19,6 +19,7 @@ CREATE TABLE deployment ( description TEXT NOT NULL DEFAULT '', job_agent_id UUID, job_agent_config JSONB NOT NULL DEFAULT '{}', + job_agents JSONB NOT NULL DEFAULT '[]', resource_selector TEXT DEFAULT 'false', metadata JSONB NOT NULL DEFAULT '{}', workspace_id UUID REFERENCES workspace(id) diff --git a/apps/workspace-engine/pkg/db/sqlc.yaml b/apps/workspace-engine/pkg/db/sqlc.yaml index 59faf3b24..b98608eda 100644 --- a/apps/workspace-engine/pkg/db/sqlc.yaml +++ b/apps/workspace-engine/pkg/db/sqlc.yaml @@ -52,6 +52,9 @@ sql: - column: "deployment.job_agent_config" go_type: type: "map[string]any" + - column: "deployment.job_agents" + go_type: + type: "[]byte" - column: "deployment.metadata" go_type: type: "map[string]string" diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index 3b5153f13..8b9236d7d 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -316,10 +316,10 @@ type DeploymentDependencyRule struct { // DeploymentJobAgent defines model for DeploymentJobAgent. type DeploymentJobAgent struct { Config JobAgentConfig `json:"config"` + Ref string `json:"ref"` - // If CEL expression to determine if the job agent should be used - If string `json:"if"` - Ref string `json:"ref"` + // Selector CEL expression to determine if the job agent should be used + Selector string `json:"selector"` } // DeploymentVariable defines model for DeploymentVariable. diff --git a/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go index fe7e7572c..6fdce37ec 100644 --- a/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go +++ b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go @@ -87,10 +87,10 @@ func (s *DeploymentAgentsSelector) SelectAgents() ([]*oapi.JobAgent, error) { jobAgents := make([]*oapi.JobAgent, 0) for _, deploymentJobAgent := range *s.deployment.JobAgents { - if deploymentJobAgent.If != "" { - program, err := jobAgentIfEnv.Compile(deploymentJobAgent.If) + if deploymentJobAgent.Selector != "" { + program, err := jobAgentIfEnv.Compile(deploymentJobAgent.Selector) if err != nil { - return nil, fmt.Errorf("failed to compile job agent if expression %q: %w", deploymentJobAgent.If, err) + return nil, fmt.Errorf("failed to compile job agent if expression %q: %w", deploymentJobAgent.Selector, err) } result, err := celutil.EvalBool(program, celCtx) if err != nil { diff --git a/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go index c66a48504..85a325a9a 100644 --- a/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go +++ b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go @@ -268,7 +268,7 @@ func TestSelectAgents_CEL_TrueLiteral(t *testing.T) { _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) - ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "true", Config: oapi.JobAgentConfig{}}} + ja := []oapi.DeploymentJobAgent{{Ref: agentID, Selector: "true", Config: oapi.JobAgentConfig{}}} deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -292,7 +292,7 @@ func TestSelectAgents_CEL_FalseLiteral(t *testing.T) { _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) - ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "false", Config: oapi.JobAgentConfig{}}} + ja := []oapi.DeploymentJobAgent{{Ref: agentID, Selector: "false", Config: oapi.JobAgentConfig{}}} deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -343,8 +343,8 @@ func TestSelectAgents_CEL_MixedConditions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ja := []oapi.DeploymentJobAgent{ - {Ref: agentA, If: tt.ifA, Config: oapi.JobAgentConfig{}}, - {Ref: agentB, If: tt.ifB, Config: oapi.JobAgentConfig{}}, + {Ref: agentA, Selector: tt.ifA, Config: oapi.JobAgentConfig{}}, + {Ref: agentB, Selector: tt.ifB, Config: oapi.JobAgentConfig{}}, } deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -376,9 +376,9 @@ func TestSelectAgents_CEL_AllTrue(t *testing.T) { _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) ja := []oapi.DeploymentJobAgent{ - {Ref: ids[0], If: "true", Config: oapi.JobAgentConfig{}}, - {Ref: ids[1], If: "true", Config: oapi.JobAgentConfig{}}, - {Ref: ids[2], If: "true", Config: oapi.JobAgentConfig{}}, + {Ref: ids[0], Selector: "true", Config: oapi.JobAgentConfig{}}, + {Ref: ids[1], Selector: "true", Config: oapi.JobAgentConfig{}}, + {Ref: ids[2], Selector: "true", Config: oapi.JobAgentConfig{}}, } deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -408,9 +408,9 @@ func TestSelectAgents_CEL_AllFalse(t *testing.T) { _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) ja := []oapi.DeploymentJobAgent{ - {Ref: ids[0], If: "false", Config: oapi.JobAgentConfig{}}, - {Ref: ids[1], If: "false", Config: oapi.JobAgentConfig{}}, - {Ref: ids[2], If: "false", Config: oapi.JobAgentConfig{}}, + {Ref: ids[0], Selector: "false", Config: oapi.JobAgentConfig{}}, + {Ref: ids[1], Selector: "false", Config: oapi.JobAgentConfig{}}, + {Ref: ids[2], Selector: "false", Config: oapi.JobAgentConfig{}}, } deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -435,7 +435,7 @@ func TestSelectAgents_CEL_ResourceMetadataMatch(t *testing.T) { _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{"region": "us-east-1"})) ja := []oapi.DeploymentJobAgent{ - {Ref: agentID, If: `resource.metadata.region == "us-east-1"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentID, Selector: `resource.metadata.region == "us-east-1"`, Config: oapi.JobAgentConfig{}}, } deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -461,7 +461,7 @@ func TestSelectAgents_CEL_ResourceMetadataNoMatch(t *testing.T) { _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{"region": "eu-west-1"})) ja := []oapi.DeploymentJobAgent{ - {Ref: agentID, If: `resource.metadata.region == "us-east-1"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentID, Selector: `resource.metadata.region == "us-east-1"`, Config: oapi.JobAgentConfig{}}, } deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -486,7 +486,7 @@ func TestSelectAgents_CEL_EnvironmentNameMatch(t *testing.T) { _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) ja := []oapi.DeploymentJobAgent{ - {Ref: agentID, If: `environment.name == "production"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentID, Selector: `environment.name == "production"`, Config: oapi.JobAgentConfig{}}, } deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -512,7 +512,7 @@ func TestSelectAgents_CEL_DeploymentNameMatch(t *testing.T) { _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) ja := []oapi.DeploymentJobAgent{ - {Ref: agentID, If: `deployment.name == "my-deploy"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentID, Selector: `deployment.name == "my-deploy"`, Config: oapi.JobAgentConfig{}}, } deployment := makeDeployment(uuid.New().String(), "my-deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -538,7 +538,7 @@ func TestSelectAgents_CEL_EnvironmentNotInStore(t *testing.T) { _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) missingEnvID := uuid.New().String() - ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "true", Config: oapi.JobAgentConfig{}}} + ja := []oapi.DeploymentJobAgent{{Ref: agentID, Selector: "true", Config: oapi.JobAgentConfig{}}} deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, missingEnvID, resID) @@ -562,7 +562,7 @@ func TestSelectAgents_CEL_ResourceNotInStore(t *testing.T) { _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) missingResID := uuid.New().String() - ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "true", Config: oapi.JobAgentConfig{}}} + ja := []oapi.DeploymentJobAgent{{Ref: agentID, Selector: "true", Config: oapi.JobAgentConfig{}}} deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, missingResID) @@ -587,7 +587,7 @@ func TestSelectAgents_CEL_InvalidSyntax(t *testing.T) { _ = s.Environments.Upsert(ctx, makeEnvironment(envID, "staging")) _, _ = s.Resources.Upsert(ctx, makeResource(resID, "res-1", map[string]string{})) - ja := []oapi.DeploymentJobAgent{{Ref: agentID, If: "!@#$", Config: oapi.JobAgentConfig{}}} + ja := []oapi.DeploymentJobAgent{{Ref: agentID, Selector: "!@#$", Config: oapi.JobAgentConfig{}}} deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) @@ -613,8 +613,8 @@ func TestSelectAgents_CEL_FirstPassesSecondBadRef(t *testing.T) { missingRef := uuid.New().String() ja := []oapi.DeploymentJobAgent{ - {Ref: agentA, If: "true", Config: oapi.JobAgentConfig{}}, - {Ref: missingRef, If: "true", Config: oapi.JobAgentConfig{}}, + {Ref: agentA, Selector: "true", Config: oapi.JobAgentConfig{}}, + {Ref: missingRef, Selector: "true", Config: oapi.JobAgentConfig{}}, } deployment := makeDeployment(uuid.New().String(), "deploy", nil, &ja) release := makeRelease(deployment.Id, envID, resID) diff --git a/apps/workspace-engine/pkg/workspace/store/repository/db/deployments/mapper.go b/apps/workspace-engine/pkg/workspace/store/repository/db/deployments/mapper.go index 84df238e9..950cb8412 100644 --- a/apps/workspace-engine/pkg/workspace/store/repository/db/deployments/mapper.go +++ b/apps/workspace-engine/pkg/workspace/store/repository/db/deployments/mapper.go @@ -55,6 +55,14 @@ func ToOapi(row db.Deployment) *oapi.Deployment { jobAgentId = &s } + var jobAgents *[]oapi.DeploymentJobAgent + if len(row.JobAgents) > 0 { + var agents []oapi.DeploymentJobAgent + if err := json.Unmarshal(row.JobAgents, &agents); err == nil && len(agents) > 0 { + jobAgents = &agents + } + } + var resourceSelector *oapi.Selector if row.ResourceSelector.Valid { resourceSelector = selectorFromString(row.ResourceSelector.String) @@ -66,6 +74,7 @@ func ToOapi(row db.Deployment) *oapi.Deployment { Description: descPtr, JobAgentId: jobAgentId, JobAgentConfig: jobAgentConfig, + JobAgents: jobAgents, ResourceSelector: resourceSelector, Metadata: metadata, } @@ -102,6 +111,13 @@ func ToUpsertParams(d *oapi.Deployment) (db.UpsertDeploymentParams, error) { jobAgentConfig = make(map[string]any) } + jobAgentsJSON := []byte("[]") + if d.JobAgents != nil && len(*d.JobAgents) > 0 { + if b, err := json.Marshal(d.JobAgents); err == nil { + jobAgentsJSON = b + } + } + selStr := selectorToString(d.ResourceSelector) resourceSelector := pgtype.Text{String: selStr, Valid: true} @@ -111,6 +127,7 @@ func ToUpsertParams(d *oapi.Deployment) (db.UpsertDeploymentParams, error) { Description: description, JobAgentID: jobAgentID, JobAgentConfig: jobAgentConfig, + JobAgents: jobAgentsJSON, ResourceSelector: resourceSelector, Metadata: metadata, WorkspaceID: uuid.Nil, // set by caller diff --git a/apps/workspace-engine/test/e2e/engine_deployment_test.go b/apps/workspace-engine/test/e2e/engine_deployment_test.go index 1a88ff09d..006f32a27 100644 --- a/apps/workspace-engine/test/e2e/engine_deployment_test.go +++ b/apps/workspace-engine/test/e2e/engine_deployment_test.go @@ -1089,8 +1089,8 @@ func TestEngine_DeploymentJobAgentsArray_WithIfConditionFilters(t *testing.T) { integration.DeploymentName("conditional-deploy"), integration.DeploymentCelResourceSelector("true"), integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ - {Ref: agentK8s, If: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, - {Ref: agentDocker, If: "true", Config: oapi.JobAgentConfig{}}, + {Ref: agentK8s, Selector: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentDocker, Selector: "true", Config: oapi.JobAgentConfig{}}, }), integration.WithDeploymentVersion( integration.DeploymentVersionTag("v1.0.0"), @@ -1147,8 +1147,8 @@ func TestEngine_DeploymentJobAgentsArray_IfConditionExcludesAgent(t *testing.T) integration.DeploymentName("filtered-deploy"), integration.DeploymentCelResourceSelector("true"), integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ - {Ref: agentK8s, If: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, - {Ref: agentDocker, If: `resource.metadata.cloud == "aws"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentK8s, Selector: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentDocker, Selector: `resource.metadata.cloud == "aws"`, Config: oapi.JobAgentConfig{}}, }), integration.WithDeploymentVersion( integration.DeploymentVersionTag("v1.0.0"), @@ -1205,8 +1205,8 @@ func TestEngine_DeploymentJobAgentsArray_AllConditionsFalse(t *testing.T) { integration.DeploymentName("no-match-deploy"), integration.DeploymentCelResourceSelector("true"), integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ - {Ref: agentA, If: `environment.name == "staging"`, Config: oapi.JobAgentConfig{}}, - {Ref: agentB, If: `environment.name == "staging"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentA, Selector: `environment.name == "staging"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentB, Selector: `environment.name == "staging"`, Config: oapi.JobAgentConfig{}}, }), integration.WithDeploymentVersion( integration.DeploymentVersionTag("v1.0.0"), @@ -1262,8 +1262,8 @@ func TestEngine_DeploymentJobAgentsArray_MultipleResourcesDifferentAgents(t *tes integration.DeploymentName("multi-cloud-deploy"), integration.DeploymentCelResourceSelector("true"), integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ - {Ref: agentGCP, If: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, - {Ref: agentAWS, If: `resource.metadata.cloud == "aws"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentGCP, Selector: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentAWS, Selector: `resource.metadata.cloud == "aws"`, Config: oapi.JobAgentConfig{}}, }), integration.WithDeploymentVersion( integration.DeploymentVersionTag("v1.0.0"), diff --git a/packages/db/drizzle/0146_tidy_blazing_skull.sql b/packages/db/drizzle/0146_tidy_blazing_skull.sql new file mode 100644 index 000000000..e3a48b585 --- /dev/null +++ b/packages/db/drizzle/0146_tidy_blazing_skull.sql @@ -0,0 +1 @@ +ALTER TABLE "deployment" ADD COLUMN "job_agents" jsonb DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0146_snapshot.json b/packages/db/drizzle/meta/0146_snapshot.json new file mode 100644 index 000000000..d0877087a --- /dev/null +++ b/packages/db/drizzle/meta/0146_snapshot.json @@ -0,0 +1,3296 @@ +{ + "id": "70e32f27-76aa-4168-b0b9-fe50480110bf", + "prevId": "fae64239-7c9e-452d-a32a-2e60f9270f53", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_session_token_unique": { + "name": "session_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "active_workspace_id": { + "name": "active_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "system_role": { + "name": "system_role", + "type": "system_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_active_workspace_id_workspace_id_fk": { + "name": "user_active_workspace_id_workspace_id_fk", + "tableFrom": "user", + "tableTo": "workspace", + "columnsFrom": [ + "active_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_key": { + "name": "user_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key_preview": { + "name": "key_preview", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_api_key_key_prefix_key_hash_index": { + "name": "user_api_key_key_prefix_key_hash_index", + "columns": [ + { + "expression": "key_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_key_user_id_user_id_fk": { + "name": "user_api_key_user_id_user_id_fk", + "tableFrom": "user_api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.changelog_entry": { + "name": "changelog_entry", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_data": { + "name": "entity_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "changelog_entry_workspace_id_workspace_id_fk": { + "name": "changelog_entry_workspace_id_workspace_id_fk", + "tableFrom": "changelog_entry", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "changelog_entry_workspace_id_entity_type_entity_id_pk": { + "name": "changelog_entry_workspace_id_entity_type_entity_id_pk", + "columns": [ + "workspace_id", + "entity_type", + "entity_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard": { + "name": "dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_workspace_id_workspace_id_fk": { + "name": "dashboard_workspace_id_workspace_id_fk", + "tableFrom": "dashboard", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_widget": { + "name": "dashboard_widget", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "widget": { + "name": "widget", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "w": { + "name": "w", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "h": { + "name": "h", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_widget_dashboard_id_dashboard_id_fk": { + "name": "dashboard_widget_dashboard_id_dashboard_id_fk", + "tableFrom": "dashboard_widget", + "tableTo": "dashboard", + "columnsFrom": [ + "dashboard_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_trace_span": { + "name": "deployment_trace_span", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "span_id": { + "name": "span_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_span_id": { + "name": "parent_span_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "release_target_key": { + "name": "release_target_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_trace_id": { + "name": "parent_trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_type": { + "name": "node_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_trace_span_trace_span_idx": { + "name": "deployment_trace_span_trace_span_idx", + "columns": [ + { + "expression": "trace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "span_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_trace_id_idx": { + "name": "deployment_trace_span_trace_id_idx", + "columns": [ + { + "expression": "trace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_parent_span_id_idx": { + "name": "deployment_trace_span_parent_span_id_idx", + "columns": [ + { + "expression": "parent_span_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_workspace_id_idx": { + "name": "deployment_trace_span_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_release_target_key_idx": { + "name": "deployment_trace_span_release_target_key_idx", + "columns": [ + { + "expression": "release_target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_release_id_idx": { + "name": "deployment_trace_span_release_id_idx", + "columns": [ + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_job_id_idx": { + "name": "deployment_trace_span_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_parent_trace_id_idx": { + "name": "deployment_trace_span_parent_trace_id_idx", + "columns": [ + { + "expression": "parent_trace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_created_at_idx": { + "name": "deployment_trace_span_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_phase_idx": { + "name": "deployment_trace_span_phase_idx", + "columns": [ + { + "expression": "phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_node_type_idx": { + "name": "deployment_trace_span_node_type_idx", + "columns": [ + { + "expression": "node_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_status_idx": { + "name": "deployment_trace_span_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_trace_span_workspace_id_workspace_id_fk": { + "name": "deployment_trace_span_workspace_id_workspace_id_fk", + "tableFrom": "deployment_trace_span", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_version": { + "name": "deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "job_agent_config": { + "name": "job_agent_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "deployment_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deployment_version_deployment_id_tag_index": { + "name": "deployment_version_deployment_id_tag_index", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_version_created_at_idx": { + "name": "deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_version_workspace_id_workspace_id_fk": { + "name": "deployment_version_workspace_id_workspace_id_fk", + "tableFrom": "deployment_version", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computed_deployment_resource": { + "name": "computed_deployment_resource", + "schema": "", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "computed_deployment_resource_deployment_id_deployment_id_fk": { + "name": "computed_deployment_resource_deployment_id_deployment_id_fk", + "tableFrom": "computed_deployment_resource", + "tableTo": "deployment", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "computed_deployment_resource_resource_id_resource_id_fk": { + "name": "computed_deployment_resource_resource_id_resource_id_fk", + "tableFrom": "computed_deployment_resource", + "tableTo": "resource", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "computed_deployment_resource_deployment_id_resource_id_pk": { + "name": "computed_deployment_resource_deployment_id_resource_id_pk", + "columns": [ + "deployment_id", + "resource_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment": { + "name": "deployment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_agent_id": { + "name": "job_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_agent_config": { + "name": "job_agent_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "job_agents": { + "name": "job_agents", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "resource_selector": { + "name": "resource_selector", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'false'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_workspace_id_workspace_id_fk": { + "name": "deployment_workspace_id_workspace_id_fk", + "tableFrom": "deployment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computed_environment_resource": { + "name": "computed_environment_resource", + "schema": "", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "computed_environment_resource_environment_id_environment_id_fk": { + "name": "computed_environment_resource_environment_id_environment_id_fk", + "tableFrom": "computed_environment_resource", + "tableTo": "environment", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "computed_environment_resource_resource_id_resource_id_fk": { + "name": "computed_environment_resource_resource_id_resource_id_fk", + "tableFrom": "computed_environment_resource", + "tableTo": "resource", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "computed_environment_resource_environment_id_resource_id_pk": { + "name": "computed_environment_resource_environment_id_resource_id_pk", + "columns": [ + "environment_id", + "resource_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "resource_selector": { + "name": "resource_selector", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'false'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "environment_workspace_id_workspace_id_fk": { + "name": "environment_workspace_id_workspace_id_fk", + "tableFrom": "environment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "event_workspace_id_workspace_id_fk": { + "name": "event_workspace_id_workspace_id_fk", + "tableFrom": "event", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource": { + "name": "resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resource_identifier_workspace_id_index": { + "name": "resource_identifier_workspace_id_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_provider_id_resource_provider_id_fk": { + "name": "resource_provider_id_resource_provider_id_fk", + "tableFrom": "resource", + "tableTo": "resource_provider", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_workspace_id_workspace_id_fk": { + "name": "resource_workspace_id_workspace_id_fk", + "tableFrom": "resource", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_schema": { + "name": "resource_schema", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "json_schema": { + "name": "json_schema", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "resource_schema_version_kind_workspace_id_index": { + "name": "resource_schema_version_kind_workspace_id_index", + "columns": [ + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_schema_workspace_id_workspace_id_fk": { + "name": "resource_schema_workspace_id_workspace_id_fk", + "tableFrom": "resource_schema", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_provider": { + "name": "resource_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "resource_provider_workspace_id_name_index": { + "name": "resource_provider_workspace_id_name_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_provider_workspace_id_workspace_id_fk": { + "name": "resource_provider_workspace_id_workspace_id_fk", + "tableFrom": "resource_provider", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system": { + "name": "system", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "system_workspace_id_workspace_id_fk": { + "name": "system_workspace_id_workspace_id_fk", + "tableFrom": "system", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_deployment": { + "name": "system_deployment", + "schema": "", + "columns": { + "system_id": { + "name": "system_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_deployment_system_id_system_id_fk": { + "name": "system_deployment_system_id_system_id_fk", + "tableFrom": "system_deployment", + "tableTo": "system", + "columnsFrom": [ + "system_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "system_deployment_deployment_id_deployment_id_fk": { + "name": "system_deployment_deployment_id_deployment_id_fk", + "tableFrom": "system_deployment", + "tableTo": "deployment", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "system_deployment_system_id_deployment_id_pk": { + "name": "system_deployment_system_id_deployment_id_pk", + "columns": [ + "system_id", + "deployment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_environment": { + "name": "system_environment", + "schema": "", + "columns": { + "system_id": { + "name": "system_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_environment_system_id_system_id_fk": { + "name": "system_environment_system_id_system_id_fk", + "tableFrom": "system_environment", + "tableTo": "system", + "columnsFrom": [ + "system_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "system_environment_environment_id_environment_id_fk": { + "name": "system_environment_environment_id_environment_id_fk", + "tableFrom": "system_environment", + "tableTo": "environment", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "system_environment_system_id_environment_id_pk": { + "name": "system_environment_system_id_environment_id_pk", + "columns": [ + "system_id", + "environment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "team_workspace_id_workspace_id_fk": { + "name": "team_workspace_id_workspace_id_fk", + "tableFrom": "team", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "team_member_team_id_user_id_index": { + "name": "team_member_team_id_user_id_index", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_agent_id": { + "name": "job_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_agent_config": { + "name": "job_agent_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "job_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'policy_passing'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_created_at_idx": { + "name": "job_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_status_idx": { + "name": "job_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_external_id_idx": { + "name": "job_external_id_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_job_agent_id_job_agent_id_fk": { + "name": "job_job_agent_id_job_agent_id_fk", + "tableFrom": "job", + "tableTo": "job_agent", + "columnsFrom": [ + "job_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_metadata": { + "name": "job_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "job_metadata_key_job_id_index": { + "name": "job_metadata_key_job_id_index", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_metadata_job_id_idx": { + "name": "job_metadata_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_metadata_job_id_job_id_fk": { + "name": "job_metadata_job_id_job_id_fk", + "tableFrom": "job_metadata", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_variable": { + "name": "job_variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "job_variable_job_id_key_index": { + "name": "job_variable_job_id_key_index", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_variable_job_id_job_id_fk": { + "name": "job_variable_job_id_job_id_fk", + "tableFrom": "job_variable", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_email_domain_matching": { + "name": "workspace_email_domain_matching", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verification_code": { + "name": "verification_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verification_email": { + "name": "verification_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_email_domain_matching_workspace_id_domain_index": { + "name": "workspace_email_domain_matching_workspace_id_domain_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_email_domain_matching_workspace_id_workspace_id_fk": { + "name": "workspace_email_domain_matching_workspace_id_workspace_id_fk", + "tableFrom": "workspace_email_domain_matching", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_email_domain_matching_role_id_role_id_fk": { + "name": "workspace_email_domain_matching_role_id_role_id_fk", + "tableFrom": "workspace_email_domain_matching", + "tableTo": "role", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invite_token": { + "name": "workspace_invite_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invite_token_role_id_role_id_fk": { + "name": "workspace_invite_token_role_id_role_id_fk", + "tableFrom": "workspace_invite_token", + "tableTo": "role", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invite_token_workspace_id_workspace_id_fk": { + "name": "workspace_invite_token_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invite_token", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invite_token_created_by_user_id_fk": { + "name": "workspace_invite_token_created_by_user_id_fk", + "tableFrom": "workspace_invite_token", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invite_token_token_unique": { + "name": "workspace_invite_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entity_role": { + "name": "entity_role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "scope_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entity_role_role_id_entity_type_entity_id_scope_id_scope_type_index": { + "name": "entity_role_role_id_entity_type_entity_id_scope_id_scope_type_index", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entity_role_role_id_role_id_fk": { + "name": "entity_role_role_id_role_id_fk", + "tableFrom": "entity_role", + "tableTo": "role", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "role_workspace_id_workspace_id_fk": { + "name": "role_workspace_id_workspace_id_fk", + "tableFrom": "role", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission": { + "name": "role_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "role_permission_role_id_permission_index": { + "name": "role_permission_role_id_permission_index", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release": { + "name": "release", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "release_resource_id_resource_id_fk": { + "name": "release_resource_id_resource_id_fk", + "tableFrom": "release", + "tableTo": "resource", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "release_environment_id_environment_id_fk": { + "name": "release_environment_id_environment_id_fk", + "tableFrom": "release", + "tableTo": "environment", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "release_deployment_id_deployment_id_fk": { + "name": "release_deployment_id_deployment_id_fk", + "tableFrom": "release", + "tableTo": "deployment", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "release_version_id_deployment_version_id_fk": { + "name": "release_version_id_deployment_version_id_fk", + "tableFrom": "release", + "tableTo": "deployment_version", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_variable": { + "name": "release_variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "release_id": { + "name": "release_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "release_variable_release_id_key_index": { + "name": "release_variable_release_id_key_index", + "columns": [ + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "release_variable_release_id_release_id_fk": { + "name": "release_variable_release_id_release_id_fk", + "tableFrom": "release_variable", + "tableTo": "release", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_agent": { + "name": "job_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "job_agent_workspace_id_name_index": { + "name": "job_agent_workspace_id_name_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_agent_workspace_id_workspace_id_fk": { + "name": "job_agent_workspace_id_workspace_id_fk", + "tableFrom": "job_agent", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.system_role": { + "name": "system_role", + "schema": "public", + "values": [ + "user", + "admin" + ] + }, + "public.deployment_version_status": { + "name": "deployment_version_status", + "schema": "public", + "values": [ + "unspecified", + "building", + "ready", + "failed", + "rejected", + "paused" + ] + }, + "public.job_reason": { + "name": "job_reason", + "schema": "public", + "values": [ + "policy_passing", + "policy_override", + "env_policy_override", + "config_policy_override" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "cancelled", + "skipped", + "in_progress", + "action_required", + "pending", + "failure", + "invalid_job_agent", + "invalid_integration", + "external_run_not_found", + "successful" + ] + }, + "public.entity_type": { + "name": "entity_type", + "schema": "public", + "values": [ + "user", + "team" + ] + }, + "public.scope_type": { + "name": "scope_type", + "schema": "public", + "values": [ + "deploymentVersion", + "resource", + "resourceProvider", + "workspace", + "environment", + "system", + "deployment" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index db094965d..d4e7c1825 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -1023,6 +1023,13 @@ "when": 1771314353033, "tag": "0145_thankful_toad_men", "breakpoints": true + }, + { + "idx": 146, + "version": "7", + "when": 1771471992587, + "tag": "0146_tidy_blazing_skull", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/deployment.ts b/packages/db/src/schema/deployment.ts index 74fc6e064..bad7255e5 100644 --- a/packages/db/src/schema/deployment.ts +++ b/packages/db/src/schema/deployment.ts @@ -16,6 +16,13 @@ export const deployment = pgTable("deployment", { .$type>() .notNull(), + jobAgents: jsonb("job_agents") + .default("[]") + .$type< + Array<{ ref: string; config: Record; selector: string }> + >() + .notNull(), + resourceSelector: text("resource_selector").default("false"), metadata: jsonb("metadata") From 38e43be049acdbfbbcb1839dc12daf024ce53dcc Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 18 Feb 2026 20:06:43 -0800 Subject: [PATCH 07/10] fix test --- .../test/e2e/engine_policy_deployment_dependency_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/workspace-engine/test/e2e/engine_policy_deployment_dependency_test.go b/apps/workspace-engine/test/e2e/engine_policy_deployment_dependency_test.go index ed2f22c17..5b2689169 100644 --- a/apps/workspace-engine/test/e2e/engine_policy_deployment_dependency_test.go +++ b/apps/workspace-engine/test/e2e/engine_policy_deployment_dependency_test.go @@ -42,9 +42,11 @@ func TestEngine_PolicyDeploymentDependency(t *testing.T) { engine := integration.NewTestWorkspace(t, integration.WithJobAgent( integration.JobAgentID(jobAgentVpcID), + integration.JobAgentName("VPC Agent"), ), integration.WithJobAgent( integration.JobAgentID(jobAgentClusterID), + integration.JobAgentName("Cluster Agent"), ), integration.WithSystem( integration.WithDeployment( From 0838c02fdff262e306d329d83ee9fd2f73e0d84b Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 18 Feb 2026 20:23:22 -0800 Subject: [PATCH 08/10] fix lint --- .../events/handler/deployment/deployment.go | 112 ------------------ 1 file changed, 112 deletions(-) diff --git a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go index 5b7123922..3828c0e0c 100644 --- a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go +++ b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go @@ -4,19 +4,13 @@ import ( "context" "encoding/json" "fmt" - "sort" - "time" "workspace-engine/pkg/events/handler" "workspace-engine/pkg/oapi" "workspace-engine/pkg/selector" "workspace-engine/pkg/workspace" - "workspace-engine/pkg/workspace/jobagents" - "workspace-engine/pkg/workspace/jobs" "workspace-engine/pkg/workspace/releasemanager" "workspace-engine/pkg/workspace/releasemanager/trace" - - "github.com/charmbracelet/log" ) func makeReleaseTargets(ctx context.Context, ws *workspace.Workspace, deployment *oapi.Deployment) ([]*oapi.ReleaseTarget, error) { @@ -264,109 +258,3 @@ func HandleDeploymentDeleted( return nil } - -type jobWithReleaseTarget struct { - Job *oapi.Job - ReleaseTarget *oapi.ReleaseTarget -} - -func getAllJobsWithReleaseTarget(ws *workspace.Workspace, deployment *oapi.Deployment) []*jobWithReleaseTarget { - allJobs := ws.Jobs().Items() - jobsSlice := make([]*jobWithReleaseTarget, 0) - for _, job := range allJobs { - release, ok := ws.Releases().Get(job.ReleaseId) - if !ok || release == nil { - continue - } - - if release.ReleaseTarget.DeploymentId != deployment.Id { - continue - } - - jobsSlice = append(jobsSlice, &jobWithReleaseTarget{Job: job, ReleaseTarget: &release.ReleaseTarget}) - } - sort.Slice(jobsSlice, func(i, j int) bool { - return jobsSlice[i].Job.CreatedAt.Before(jobsSlice[j].Job.CreatedAt) - }) - return jobsSlice -} - -func getJobsToRetrigger(ws *workspace.Workspace, deployment *oapi.Deployment) []*oapi.Job { - latestJobs := make(map[string]*oapi.Job) - jobsSlice := getAllJobsWithReleaseTarget(ws, deployment) - - for _, jobWithReleaseTarget := range jobsSlice { - latestJobs[jobWithReleaseTarget.ReleaseTarget.Key()] = jobWithReleaseTarget.Job - } - - jobsToRetrigger := make([]*oapi.Job, 0) - for _, job := range latestJobs { - if job.Status == oapi.JobStatusInvalidJobAgent { - jobsToRetrigger = append(jobsToRetrigger, job) - } - } - return jobsToRetrigger -} - -// retriggerInvalidJobAgentJobs creates new Pending jobs for all releases that currently have InvalidJobAgent jobs -// Note: This is an explicit retrigger operation for configuration fixes, so we bypass normal -// eligibility checks (like retry limits). The old InvalidJobAgent job remains for history. -func retriggerInvalidJobAgentJobs(ctx context.Context, ws *workspace.Workspace, jobsToRetrigger []*oapi.Job) { - jobFactory := jobs.NewFactory(ws.Store()) - - for _, job := range jobsToRetrigger { - release, ok := ws.Releases().Get(job.ReleaseId) - if !ok || release == nil { - continue - } - - deployment, ok := ws.Deployments().Get(release.ReleaseTarget.DeploymentId) - if !ok || deployment == nil { - continue - } - - agents, err := jobagents.NewDeploymentAgentsSelector(ws.Store(), deployment, release).SelectAgents() - if err != nil { - log.Error("failed to select agents for release during retrigger", - "releaseId", release.ID(), - "deploymentId", release.ReleaseTarget.DeploymentId, - "error", err.Error()) - continue - } - - if len(agents) == 0 { - continue - } - - for _, agent := range agents { - newJob, err := jobFactory.CreateJobForRelease(ctx, release, agent, nil) - if err != nil { - log.Error("failed to create job for release during retrigger", - "releaseId", release.ID(), - "deploymentId", release.ReleaseTarget.DeploymentId, - "agentId", agent.Id, - "error", err.Error()) - continue - } - - ws.Jobs().Upsert(ctx, newJob) - log.Info("created new job for previously invalid job agent", - "newJobId", newJob.Id, - "originalJobId", job.Id, - "releaseId", release.ID(), - "deploymentId", release.ReleaseTarget.DeploymentId, - "agentId", agent.Id, - "status", newJob.Status) - - if newJob.Status != oapi.JobStatusInvalidJobAgent { - if err := ws.JobAgentRegistry().Dispatch(ctx, newJob); err != nil { - message := err.Error() - newJob.Status = oapi.JobStatusInvalidIntegration - newJob.UpdatedAt = time.Now() - newJob.Message = &message - ws.Jobs().Upsert(ctx, newJob) - } - } - } - } -} From 65937309b29bef7ad9a4bb2837971c0ccc5333af Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 18 Feb 2026 20:25:47 -0800 Subject: [PATCH 09/10] fix fmt db --- packages/db/drizzle/meta/0146_snapshot.json | 410 +++++--------------- packages/db/drizzle/meta/_journal.json | 2 +- 2 files changed, 102 insertions(+), 310 deletions(-) diff --git a/packages/db/drizzle/meta/0146_snapshot.json b/packages/db/drizzle/meta/0146_snapshot.json index d0877087a..661c0d34c 100644 --- a/packages/db/drizzle/meta/0146_snapshot.json +++ b/packages/db/drizzle/meta/0146_snapshot.json @@ -112,12 +112,8 @@ "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -205,12 +201,8 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -220,9 +212,7 @@ "session_session_token_unique": { "name": "session_session_token_unique", "nullsNotDistinct": false, - "columns": [ - "session_token" - ] + "columns": ["session_token"] } }, "policies": {}, @@ -308,12 +298,8 @@ "name": "user_active_workspace_id_workspace_id_fk", "tableFrom": "user", "tableTo": "workspace", - "columnsFrom": [ - "active_workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["active_workspace_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -400,12 +386,8 @@ "name": "user_api_key_user_id_user_id_fk", "tableFrom": "user_api_key", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -525,12 +507,8 @@ "name": "changelog_entry_workspace_id_workspace_id_fk", "tableFrom": "changelog_entry", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -538,11 +516,7 @@ "compositePrimaryKeys": { "changelog_entry_workspace_id_entity_type_entity_id_pk": { "name": "changelog_entry_workspace_id_entity_type_entity_id_pk", - "columns": [ - "workspace_id", - "entity_type", - "entity_id" - ] + "columns": ["workspace_id", "entity_type", "entity_id"] } }, "uniqueConstraints": {}, @@ -599,12 +573,8 @@ "name": "dashboard_workspace_id_workspace_id_fk", "tableFrom": "dashboard", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -683,12 +653,8 @@ "name": "dashboard_widget_dashboard_id_dashboard_id_fk", "tableFrom": "dashboard_widget", "tableTo": "dashboard", - "columnsFrom": [ - "dashboard_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["dashboard_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1019,12 +985,8 @@ "name": "deployment_trace_span_workspace_id_workspace_id_fk", "tableFrom": "deployment_trace_span", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1156,12 +1118,8 @@ "name": "deployment_version_workspace_id_workspace_id_fk", "tableFrom": "deployment_version", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1195,12 +1153,8 @@ "name": "computed_deployment_resource_deployment_id_deployment_id_fk", "tableFrom": "computed_deployment_resource", "tableTo": "deployment", - "columnsFrom": [ - "deployment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1208,12 +1162,8 @@ "name": "computed_deployment_resource_resource_id_resource_id_fk", "tableFrom": "computed_deployment_resource", "tableTo": "resource", - "columnsFrom": [ - "resource_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1221,10 +1171,7 @@ "compositePrimaryKeys": { "computed_deployment_resource_deployment_id_resource_id_pk": { "name": "computed_deployment_resource_deployment_id_resource_id_pk", - "columns": [ - "deployment_id", - "resource_id" - ] + "columns": ["deployment_id", "resource_id"] } }, "uniqueConstraints": {}, @@ -1302,12 +1249,8 @@ "name": "deployment_workspace_id_workspace_id_fk", "tableFrom": "deployment", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1341,12 +1284,8 @@ "name": "computed_environment_resource_environment_id_environment_id_fk", "tableFrom": "computed_environment_resource", "tableTo": "environment", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1354,12 +1293,8 @@ "name": "computed_environment_resource_resource_id_resource_id_fk", "tableFrom": "computed_environment_resource", "tableTo": "resource", - "columnsFrom": [ - "resource_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1367,10 +1302,7 @@ "compositePrimaryKeys": { "computed_environment_resource_environment_id_resource_id_pk": { "name": "computed_environment_resource_environment_id_resource_id_pk", - "columns": [ - "environment_id", - "resource_id" - ] + "columns": ["environment_id", "resource_id"] } }, "uniqueConstraints": {}, @@ -1436,12 +1368,8 @@ "name": "environment_workspace_id_workspace_id_fk", "tableFrom": "environment", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1495,12 +1423,8 @@ "name": "event_workspace_id_workspace_id_fk", "tableFrom": "event", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1620,12 +1544,8 @@ "name": "resource_provider_id_resource_provider_id_fk", "tableFrom": "resource", "tableTo": "resource_provider", - "columnsFrom": [ - "provider_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1633,12 +1553,8 @@ "name": "resource_workspace_id_workspace_id_fk", "tableFrom": "resource", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1719,12 +1635,8 @@ "name": "resource_schema_workspace_id_workspace_id_fk", "tableFrom": "resource_schema", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1801,12 +1713,8 @@ "name": "resource_provider_workspace_id_workspace_id_fk", "tableFrom": "resource_provider", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1861,12 +1769,8 @@ "name": "system_workspace_id_workspace_id_fk", "tableFrom": "system", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1907,12 +1811,8 @@ "name": "system_deployment_system_id_system_id_fk", "tableFrom": "system_deployment", "tableTo": "system", - "columnsFrom": [ - "system_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["system_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1920,12 +1820,8 @@ "name": "system_deployment_deployment_id_deployment_id_fk", "tableFrom": "system_deployment", "tableTo": "deployment", - "columnsFrom": [ - "deployment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1933,10 +1829,7 @@ "compositePrimaryKeys": { "system_deployment_system_id_deployment_id_pk": { "name": "system_deployment_system_id_deployment_id_pk", - "columns": [ - "system_id", - "deployment_id" - ] + "columns": ["system_id", "deployment_id"] } }, "uniqueConstraints": {}, @@ -1974,12 +1867,8 @@ "name": "system_environment_system_id_system_id_fk", "tableFrom": "system_environment", "tableTo": "system", - "columnsFrom": [ - "system_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["system_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1987,12 +1876,8 @@ "name": "system_environment_environment_id_environment_id_fk", "tableFrom": "system_environment", "tableTo": "environment", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2000,10 +1885,7 @@ "compositePrimaryKeys": { "system_environment_system_id_environment_id_pk": { "name": "system_environment_system_id_environment_id_pk", - "columns": [ - "system_id", - "environment_id" - ] + "columns": ["system_id", "environment_id"] } }, "uniqueConstraints": {}, @@ -2041,12 +1923,8 @@ "name": "team_workspace_id_workspace_id_fk", "tableFrom": "team", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2109,12 +1987,8 @@ "name": "team_member_team_id_team_id_fk", "tableFrom": "team_member", "tableTo": "team", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["team_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2122,12 +1996,8 @@ "name": "team_member_user_id_user_id_fk", "tableFrom": "team_member", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2269,12 +2139,8 @@ "name": "job_job_agent_id_job_agent_id_fk", "tableFrom": "job", "tableTo": "job_agent", - "columnsFrom": [ - "job_agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["job_agent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -2358,12 +2224,8 @@ "name": "job_metadata_job_id_job_id_fk", "tableFrom": "job_metadata", "tableTo": "job", - "columnsFrom": [ - "job_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["job_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2439,12 +2301,8 @@ "name": "job_variable_job_id_job_id_fk", "tableFrom": "job_variable", "tableTo": "job", - "columnsFrom": [ - "job_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["job_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2493,9 +2351,7 @@ "workspace_slug_unique": { "name": "workspace_slug_unique", "nullsNotDistinct": false, - "columns": [ - "slug" - ] + "columns": ["slug"] } }, "policies": {}, @@ -2586,12 +2442,8 @@ "name": "workspace_email_domain_matching_workspace_id_workspace_id_fk", "tableFrom": "workspace_email_domain_matching", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2599,12 +2451,8 @@ "name": "workspace_email_domain_matching_role_id_role_id_fk", "tableFrom": "workspace_email_domain_matching", "tableTo": "role", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["role_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2664,12 +2512,8 @@ "name": "workspace_invite_token_role_id_role_id_fk", "tableFrom": "workspace_invite_token", "tableTo": "role", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["role_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2677,12 +2521,8 @@ "name": "workspace_invite_token_workspace_id_workspace_id_fk", "tableFrom": "workspace_invite_token", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2690,12 +2530,8 @@ "name": "workspace_invite_token_created_by_user_id_fk", "tableFrom": "workspace_invite_token", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2705,9 +2541,7 @@ "workspace_invite_token_token_unique": { "name": "workspace_invite_token_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -2804,12 +2638,8 @@ "name": "entity_role_role_id_role_id_fk", "tableFrom": "entity_role", "tableTo": "role", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["role_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2856,12 +2686,8 @@ "name": "role_workspace_id_workspace_id_fk", "tableFrom": "role", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2924,12 +2750,8 @@ "name": "role_permission_role_id_role_id_fk", "tableFrom": "role_permission", "tableTo": "role", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["role_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2989,12 +2811,8 @@ "name": "release_resource_id_resource_id_fk", "tableFrom": "release", "tableTo": "resource", - "columnsFrom": [ - "resource_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -3002,12 +2820,8 @@ "name": "release_environment_id_environment_id_fk", "tableFrom": "release", "tableTo": "environment", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -3015,12 +2829,8 @@ "name": "release_deployment_id_deployment_id_fk", "tableFrom": "release", "tableTo": "deployment", - "columnsFrom": [ - "deployment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -3028,12 +2838,8 @@ "name": "release_version_id_deployment_version_id_fk", "tableFrom": "release", "tableTo": "deployment_version", - "columnsFrom": [ - "version_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["version_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -3116,12 +2922,8 @@ "name": "release_variable_release_id_release_id_fk", "tableFrom": "release_variable", "tableTo": "release", - "columnsFrom": [ - "release_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["release_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -3197,12 +2999,8 @@ "name": "job_agent_workspace_id_workspace_id_fk", "tableFrom": "job_agent", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3218,10 +3016,7 @@ "public.system_role": { "name": "system_role", "schema": "public", - "values": [ - "user", - "admin" - ] + "values": ["user", "admin"] }, "public.deployment_version_status": { "name": "deployment_version_status", @@ -3264,10 +3059,7 @@ "public.entity_type": { "name": "entity_type", "schema": "public", - "values": [ - "user", - "team" - ] + "values": ["user", "team"] }, "public.scope_type": { "name": "scope_type", @@ -3293,4 +3085,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index d4e7c1825..8b556fac6 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -1032,4 +1032,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From 80dcbed82ef3b79d51f3b689ae11751e6f7f28d8 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 18 Feb 2026 21:11:18 -0800 Subject: [PATCH 10/10] add err tests --- .../test/e2e/engine_deployment_test.go | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) diff --git a/apps/workspace-engine/test/e2e/engine_deployment_test.go b/apps/workspace-engine/test/e2e/engine_deployment_test.go index 006f32a27..9b6bcb6c5 100644 --- a/apps/workspace-engine/test/e2e/engine_deployment_test.go +++ b/apps/workspace-engine/test/e2e/engine_deployment_test.go @@ -1321,3 +1321,364 @@ func TestEngine_DeploymentJobAgentsArray_MultipleResourcesDifferentAgents(t *tes assert.Equal(t, 1, gcpJobs, "GCP agent should have 1 job (for gcp-server)") assert.Equal(t, 1, awsJobs, "AWS agent should have 1 job (for aws-server)") } + +// ===== Error / Edge Case E2E Tests ===== + +func TestEngine_DeploymentJobAgentsArray_NonExistentAgentRef(t *testing.T) { + realAgentID := uuid.New().String() + fakeAgentID := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(realAgentID), + integration.JobAgentName("Real Agent"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("bad-ref-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: realAgentID, Config: oapi.JobAgentConfig{}}, + {Ref: fakeAgentID, Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("resource-1"), + ), + ) + + allJobs := engine.Workspace().Jobs().Items() + + deploymentJobs := 0 + for _, job := range allJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + continue + } + if release.ReleaseTarget.DeploymentId == deploymentID { + deploymentJobs++ + assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status, + "should create an InvalidJobAgent job when agent ref doesn't exist") + assert.NotNil(t, job.Message) + } + } + + assert.Equal(t, 1, deploymentJobs, + "should have exactly 1 job with InvalidJobAgent status for the failed selector") +} + +func TestEngine_DeploymentJobAgentsArray_AllRefsNonExistent(t *testing.T) { + fakeAgent1 := uuid.New().String() + fakeAgent2 := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("all-bad-refs-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: fakeAgent1, Config: oapi.JobAgentConfig{}}, + {Ref: fakeAgent2, Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("resource-1"), + ), + ) + + allJobs := engine.Workspace().Jobs().Items() + + deploymentJobs := 0 + for _, job := range allJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + continue + } + if release.ReleaseTarget.DeploymentId == deploymentID { + deploymentJobs++ + assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status) + assert.NotNil(t, job.Message) + } + } + + assert.Equal(t, 1, deploymentJobs, + "should have 1 InvalidJobAgent job when all agent refs are non-existent") +} + +func TestEngine_DeploymentJobAgentsArray_InvalidCelSelector(t *testing.T) { + agentID := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(agentID), + integration.JobAgentName("Valid Agent"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("bad-cel-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: agentID, Selector: "this is not valid cel !!!", Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("resource-1"), + ), + ) + + allJobs := engine.Workspace().Jobs().Items() + + deploymentJobs := 0 + for _, job := range allJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + continue + } + if release.ReleaseTarget.DeploymentId == deploymentID { + deploymentJobs++ + assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status, + "invalid CEL selector should produce an InvalidJobAgent job") + assert.NotNil(t, job.Message) + } + } + + assert.Equal(t, 1, deploymentJobs, + "should have 1 InvalidJobAgent job for invalid CEL selector") +} + +func TestEngine_DeploymentJobAgentsArray_ValidAgentFollowedByNonExistent(t *testing.T) { + validAgentID := uuid.New().String() + fakeAgentID := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(validAgentID), + integration.JobAgentName("Valid Agent"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("mixed-agents-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: validAgentID, Selector: "true", Config: oapi.JobAgentConfig{}}, + {Ref: fakeAgentID, Selector: "true", Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("resource-1"), + ), + ) + + allJobs := engine.Workspace().Jobs().Items() + + deploymentJobs := 0 + for _, job := range allJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + continue + } + if release.ReleaseTarget.DeploymentId == deploymentID { + deploymentJobs++ + assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status, + "when any agent ref fails, the entire selection fails with InvalidJobAgent") + } + } + + assert.Equal(t, 1, deploymentJobs, + "should have 1 InvalidJobAgent job — the non-existent ref poisons the whole batch") +} + +func TestEngine_DeploymentJobAgentsArray_NonExistentAgentFilteredOutBySelector(t *testing.T) { + validAgentID := uuid.New().String() + fakeAgentID := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithJobAgent( + integration.JobAgentID(validAgentID), + integration.JobAgentName("Valid Agent"), + ), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("filtered-bad-ref-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{ + {Ref: validAgentID, Selector: "true", Config: oapi.JobAgentConfig{}}, + {Ref: fakeAgentID, Selector: `resource.metadata.cloud == "aws"`, Config: oapi.JobAgentConfig{}}, + }), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("gcp-resource"), + integration.ResourceMetadata(map[string]string{"cloud": "gcp"}), + ), + ) + + pendingJobs := engine.Workspace().Jobs().GetPending() + + deploymentJobs := 0 + for _, job := range pendingJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + continue + } + if release.ReleaseTarget.DeploymentId == deploymentID { + deploymentJobs++ + assert.Equal(t, validAgentID, job.JobAgentId, + "only the valid agent should have a pending job") + } + } + + assert.Equal(t, 1, deploymentJobs, + "non-existent agent filtered out by selector should not cause an error") +} + +func TestEngine_DeploymentLegacyJobAgent_NonExistentRef(t *testing.T) { + fakeAgentID := uuid.New().String() + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("legacy-bad-ref"), + integration.DeploymentJobAgent(fakeAgentID), + integration.DeploymentCelResourceSelector("true"), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("resource-1"), + ), + ) + + allJobs := engine.Workspace().Jobs().Items() + + deploymentJobs := 0 + for _, job := range allJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + continue + } + if release.ReleaseTarget.DeploymentId == deploymentID { + deploymentJobs++ + assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status, + "legacy JobAgentId pointing to non-existent agent should produce InvalidJobAgent") + assert.NotNil(t, job.Message) + } + } + + assert.Equal(t, 1, deploymentJobs, + "should have 1 InvalidJobAgent job for non-existent legacy agent ref") +} + +func TestEngine_DeploymentJobAgentsArray_EmptyArray(t *testing.T) { + deploymentID := uuid.New().String() + + engine := integration.NewTestWorkspace( + t, + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentID), + integration.DeploymentName("empty-agents-deploy"), + integration.DeploymentCelResourceSelector("true"), + integration.DeploymentJobAgents([]oapi.DeploymentJobAgent{}), + integration.WithDeploymentVersion( + integration.DeploymentVersionTag("v1.0.0"), + ), + ), + integration.WithEnvironment( + integration.EnvironmentName("production"), + integration.EnvironmentCelResourceSelector("true"), + ), + ), + integration.WithResource( + integration.ResourceName("resource-1"), + ), + ) + + allJobs := engine.Workspace().Jobs().Items() + + deploymentJobs := 0 + for _, job := range allJobs { + release, ok := engine.Workspace().Releases().Get(job.ReleaseId) + if !ok { + continue + } + if release.ReleaseTarget.DeploymentId == deploymentID { + deploymentJobs++ + assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status, + "empty agents array should produce InvalidJobAgent (no agent configured)") + } + } + + assert.Equal(t, 1, deploymentJobs, + "should have 1 InvalidJobAgent job for empty agents array") +}