diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index c9b1152ba..c86963e7c 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" @@ -231,6 +237,26 @@ ], "type": "object" }, + "DeploymentJobAgent": { + "properties": { + "config": { + "$ref": "#/components/schemas/JobAgentConfig" + }, + "ref": { + "type": "string" + }, + "selector": { + "description": "CEL expression to determine if the job agent should be used", + "type": "string" + } + }, + "required": [ + "ref", + "config", + "selector" + ], + "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 cc25cbe9e..d877b1baa 100644 --- a/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/deployments.jsonnet @@ -11,11 +11,22 @@ local openapi = import '../lib/openapi.libsonnet'; description: { 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', 'selector'], + properties: { + ref: { type: 'string' }, + config: openapi.schemaRef('JobAgentConfig'), + selector: { type: 'string', description: 'CEL expression to determine if the job agent should be used' }, + }, + }, + DeploymentWithVariablesAndSystems: { type: 'object', required: ['deployment', 'variables', 'systems'], 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/events/handler/deployment/deployment.go b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go index 58684c9c5..3828c0e0c 100644 --- a/apps/workspace-engine/pkg/events/handler/deployment/deployment.go +++ b/apps/workspace-engine/pkg/events/handler/deployment/deployment.go @@ -3,18 +3,14 @@ package deployment import ( "context" "encoding/json" - "sort" - "time" + "fmt" "workspace-engine/pkg/events/handler" "workspace-engine/pkg/oapi" "workspace-engine/pkg/selector" "workspace-engine/pkg/workspace" - "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) { @@ -137,7 +133,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 +143,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 +195,11 @@ func HandleDeploymentUpdated( return err } + oldDeployment, err := getOldDeployment(ws, deployment.Id) + if err != nil { + return HandleDeploymentCreated(ctx, ws, event) + } + if err := ws.Deployments().Upsert(ctx, deployment); err != nil { return err } @@ -190,17 +227,11 @@ func HandleDeploymentUpdated( return err } - err = reconcileTargets(ctx, ws, deployment, addedReleaseTargets) - if err != nil { - return err - } - - jobsToRetrigger := getJobsToRetrigger(ws, deployment) - if len(jobsToRetrigger) > 0 { - retriggerInvalidJobAgentJobs(ctx, ws, jobsToRetrigger) + if isJobAgentConfigurationChanged(&oldDeployment, deployment) { + return reconcileTargets(ctx, ws, deployment, releaseTargets, true) } - return nil + return reconcileTargets(ctx, ws, deployment, addedReleaseTargets, false) } func HandleDeploymentDeleted( @@ -227,93 +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) { - // 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 - } - - // Create a new job for this release (bypassing eligibility checks for explicit retrigger) - newJob, err := jobFactory.CreateJobForRelease(ctx, release, nil) - if err != nil { - log.Error("failed to create job 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) - - // 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) - } - } - } -} diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index 59d68bad2..8b9236d7d 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -290,14 +290,15 @@ 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"` + 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"` } // DeploymentAndSystems defines model for DeploymentAndSystems. @@ -312,6 +313,15 @@ type DeploymentDependencyRule struct { DependsOn string `json:"dependsOn"` } +// DeploymentJobAgent defines model for DeploymentJobAgent. +type DeploymentJobAgent struct { + Config JobAgentConfig `json:"config"` + 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. type DeploymentVariable struct { DefaultValue *LiteralValue `json:"defaultValue,omitempty"` 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..6fdce37ec --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector.go @@ -0,0 +1,110 @@ +package jobagents + +import ( + "fmt" + "time" + "workspace-engine/pkg/celutil" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/workspace/store" +) + +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) + } + + 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.Selector != "" { + program, err := jobAgentIfEnv.Compile(deploymentJobAgent.Selector) + if err != nil { + 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 { + 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/jobagents/deployment_agent_selector_test.go b/apps/workspace-engine/pkg/workspace/jobagents/deployment_agent_selector_test.go new file mode 100644 index 000000000..85a325a9a --- /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, Selector: "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, Selector: "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, 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) + + 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], 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) + + 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], 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) + + 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, Selector: `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, Selector: `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, Selector: `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, Selector: `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, Selector: "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, Selector: "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, Selector: "!@#$", 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, 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) + + 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/pkg/workspace/jobs/factory.go b/apps/workspace-engine/pkg/workspace/jobs/factory.go index 4082e3e8d..97e38ead1 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory.go @@ -29,7 +29,8 @@ func NewFactory(store *store.Store) *Factory { } } -func (f *Factory) noAgentConfiguredJob(releaseID, jobAgentID, deploymentName string, action *trace.Action) *oapi.Job { +// 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 { action.AddStep("Create InvalidJobAgent job", trace.StepResultPass, @@ -52,20 +53,21 @@ 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) +// 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 { - 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, @@ -136,7 +138,7 @@ func (f *Factory) buildDispatchContext(release *oapi.Release, deployment *oapi.D // 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) CreateJobForRelease(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), @@ -162,17 +164,6 @@ 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 - } - if action != nil { action.AddStep("Validate job agent", trace.StepResultPass, fmt.Sprintf("Job agent '%s' (type: %s) found and validated", jobAgent.Name, jobAgent.Type)). @@ -194,7 +185,7 @@ func (f *Factory) CreateJobForRelease(ctx context.Context, release *oapi.Release 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("job_agent_id", jobAgent.Id). AddMetadata("release_id", release.ID()). AddMetadata("version_tag", release.Version.Tag) } @@ -212,7 +203,7 @@ func (f *Factory) CreateJobForRelease(ctx context.Context, release *oapi.Release return &oapi.Job{ Id: jobId, ReleaseId: release.ID(), - JobAgentId: *jobAgentId, + JobAgentId: jobAgent.Id, JobAgentConfig: mergedConfig, Status: oapi.JobStatusPending, CreatedAt: time.Now(), @@ -270,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/jobs/factory_test.go b/apps/workspace-engine/pkg/workspace/jobs/factory_test.go index 83e036a9c..6219f571a 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory_test.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory_test.go @@ -111,63 +111,17 @@ 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) - job, err := factory.CreateJobForRelease(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") -} - -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) - job, err := factory.CreateJobForRelease(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") -} - 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) - job, err := factory.CreateJobForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) // Should return an error require.Error(t, err) @@ -208,7 +162,7 @@ func TestFactory_CreateJobForRelease_SetsCorrectJobFields(t *testing.T) { beforeCreation := time.Now() factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) afterCreation := time.Now() @@ -273,7 +227,7 @@ 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) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) require.NotNil(t, job) @@ -283,39 +237,11 @@ func TestFactory_CreateJobForRelease_UniqueJobIds(t *testing.T) { } } -// ============================================================================= -// 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) - job, err := factory.CreateJobForRelease(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") -} - // ============================================================================= // 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() @@ -349,17 +275,17 @@ 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) - job, err := factory.CreateJobForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) require.NotNil(t, job) @@ -375,13 +301,13 @@ 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) - job, err := factory.CreateJobForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) dc := job.DispatchContext @@ -393,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") @@ -404,7 +330,7 @@ func TestFactory_CreateJobForRelease_DispatchContextVariablesPointsToReleaseVari } factory := NewFactory(st) - job, err := factory.CreateJobForRelease(ctx, release, nil) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) require.NotNil(t, job.DispatchContext.Variables) @@ -446,7 +372,7 @@ 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) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) require.NotNil(t, job) @@ -463,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{ @@ -471,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 @@ -479,7 +405,7 @@ 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) + job, err := factory.CreateJobForRelease(ctx, release, agent, nil) require.Error(t, err) require.Nil(t, job) @@ -492,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", 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 @@ -507,7 +433,7 @@ 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) + job, err := factory.CreateJobForRelease(ctx, release, agent, nil) require.Error(t, err) require.Nil(t, job) @@ -761,7 +687,7 @@ 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) + job, err := factory.CreateJobForRelease(ctx, release, jobAgent, nil) require.NoError(t, err) diff --git a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go index daf16961a..ff7c598bd 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor.go @@ -3,12 +3,12 @@ package deployment import ( "context" "fmt" + "strings" "time" "workspace-engine/pkg/oapi" "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" @@ -16,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 @@ -32,10 +40,54 @@ 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( + 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 e.updateJobWithFailure(ctx, newJob, 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. -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()), @@ -47,101 +99,61 @@ 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") - newJob, err := e.jobFactory.CreateJobForRelease(ctx, releaseToDeploy, createJobAction) - 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())) - } - return nil, err + deployment, exists := e.store.Deployments.Get(releaseToDeploy.ReleaseTarget.DeploymentId) + if !exists { + return nil, fmt.Errorf("deployment %s not found", releaseToDeploy.ReleaseTarget.DeploymentId) } - // Set job ID on trace recorder so all subsequent spans are associated with this job - if recorder != nil { - recorder.SetJobID(newJob.Id) + agents, err := jobagents.NewDeploymentAgentsSelector(e.store, deployment, releaseToDeploy).SelectAgents() + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get deployment agents") + failedJob := e.jobFactory.InvalidDeploymentAgentsJob(releaseToDeploy.ID(), deployment.Name, nil) + e.store.Jobs.Upsert(ctx, failedJob) + return []*oapi.Job{failedJob}, nil } - // 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 + 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), ","), + }) } - // 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)), - ) - - if createJobAction != nil { - createJobAction.AddStep("Persist job", trace.StepResultPass, "Job persisted to store") + if len(agents) == 0 { + failedJob := e.jobFactory.NoAgentConfiguredJob(releaseToDeploy.ID(), "", deployment.Name, nil) + e.store.Jobs.Upsert(ctx, failedJob) + return []*oapi.Job{failedJob}, nil } - // 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") + 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") } - } - // End the action - if createJobAction != nil { - createJobAction.End() + newJobs = append(newJobs, newJob) } - 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 ae12649f0..80b7ac422 100644 --- a/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go +++ b/apps/workspace-engine/pkg/workspace/releasemanager/deployment/executor_test.go @@ -126,11 +126,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) @@ -147,7 +148,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() @@ -173,18 +174,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 require.NoError(t, err) - require.NotNil(t, job) - assert.Equal(t, oapi.JobStatusInvalidJobAgent, job.Status) - assert.Equal(t, "", job.JobAgentId) + require.Len(t, jobs, 1) + require.Equal(t, oapi.JobStatusInvalidJobAgent, jobs[0].Status) - // 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) { @@ -200,11 +198,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") } @@ -228,11 +226,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 @@ -257,7 +256,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) @@ -276,18 +275,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) 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 18f505f46..9b6bcb6c5 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() @@ -1005,3 +1007,678 @@ 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, Selector: `resource.metadata.cloud == "gcp"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentDocker, Selector: "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, 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"), + ), + ), + 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, Selector: `environment.name == "staging"`, Config: oapi.JobAgentConfig{}}, + {Ref: agentB, Selector: `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, 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"), + ), + ), + 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)") +} + +// ===== 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") +} 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() 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( 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{} 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..661c0d34c --- /dev/null +++ b/packages/db/drizzle/meta/0146_snapshot.json @@ -0,0 +1,3088 @@ +{ + "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": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index db094965d..8b556fac6 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 } ] } 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")