diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index f1fd0da65..81e778fc7 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -1183,10 +1183,6 @@ }, "resourceId": { "type": "string" - }, - "rolloutTime": { - "format": "date-time", - "type": "string" } }, "type": "object" diff --git a/apps/workspace-engine/oapi/spec/paths/deployment-version.jsonnet b/apps/workspace-engine/oapi/spec/paths/deployment-version.jsonnet index abfbd71ed..dd395fd71 100644 --- a/apps/workspace-engine/oapi/spec/paths/deployment-version.jsonnet +++ b/apps/workspace-engine/oapi/spec/paths/deployment-version.jsonnet @@ -57,8 +57,6 @@ local openapi = import '../lib/openapi.libsonnet'; }, }, }, - // Optional rollout time - rolloutTime: { type: 'string', format: 'date-time' }, }, }, }, diff --git a/apps/workspace-engine/pkg/db/changeset.go b/apps/workspace-engine/pkg/db/changeset.go index 78569dfae..3f6afd735 100644 --- a/apps/workspace-engine/pkg/db/changeset.go +++ b/apps/workspace-engine/pkg/db/changeset.go @@ -5,12 +5,13 @@ import ( "fmt" "workspace-engine/pkg/changeset" "workspace-engine/pkg/oapi" + "workspace-engine/pkg/workspace/store" "github.com/jackc/pgx/v5" "go.opentelemetry.io/otel/attribute" ) -func FlushChangeset(ctx context.Context, cs *changeset.ChangeSet[any], workspaceID string) error { +func FlushChangeset(ctx context.Context, cs *changeset.ChangeSet[any], workspaceID string, store *store.Store) error { ctx, span := tracer.Start(ctx, "DBFlushChangeset") defer span.End() @@ -37,7 +38,7 @@ func FlushChangeset(ctx context.Context, cs *changeset.ChangeSet[any], workspace defer func() { _ = tx.Rollback(ctx) }() for _, change := range cs.Changes { - if err := applyChange(ctx, tx, change, workspaceID); err != nil { + if err := applyChange(ctx, tx, change, workspaceID, store); err != nil { return err } } @@ -51,7 +52,7 @@ func FlushChangeset(ctx context.Context, cs *changeset.ChangeSet[any], workspace return nil } -func applyChange(ctx context.Context, conn pgx.Tx, change changeset.Change[any], workspaceID string) error { +func applyChange(ctx context.Context, conn pgx.Tx, change changeset.Change[any], workspaceID string, store *store.Store) error { if e, ok := change.Entity.(*oapi.Resource); ok && e != nil { if change.Type == changeset.ChangeTypeDelete { return deleteResource(ctx, e.Id, conn) @@ -133,7 +134,7 @@ func applyChange(ctx context.Context, conn pgx.Tx, change changeset.Change[any], if change.Type == changeset.ChangeTypeDelete { return deleteJob(ctx, e.Id, conn) } - return writeJob(ctx, e, conn) + return writeJob(ctx, e, store, conn) } if e, ok := change.Entity.(*oapi.ReleaseTarget); ok && e != nil { @@ -148,16 +149,18 @@ func applyChange(ctx context.Context, conn pgx.Tx, change changeset.Change[any], type DbChangesetConsumer struct { workspaceID string + store *store.Store } var _ changeset.ChangesetConsumer[any] = (*DbChangesetConsumer)(nil) -func NewChangesetConsumer(workspaceID string) *DbChangesetConsumer { +func NewChangesetConsumer(workspaceID string, store *store.Store) *DbChangesetConsumer { return &DbChangesetConsumer{ workspaceID: workspaceID, + store: store, } } func (c *DbChangesetConsumer) FlushChangeset(ctx context.Context, changeset *changeset.ChangeSet[any]) error { - return FlushChangeset(ctx, changeset, c.workspaceID) + return FlushChangeset(ctx, changeset, c.workspaceID, c.store) } diff --git a/apps/workspace-engine/pkg/db/jobs.go b/apps/workspace-engine/pkg/db/jobs.go index 2b24c64af..7e0af52a4 100644 --- a/apps/workspace-engine/pkg/db/jobs.go +++ b/apps/workspace-engine/pkg/db/jobs.go @@ -2,8 +2,10 @@ package db import ( "context" + "fmt" "time" "workspace-engine/pkg/oapi" + "workspace-engine/pkg/workspace/store" "github.com/jackc/pgx/v5" ) @@ -129,6 +131,20 @@ const JOB_UPSERT_QUERY = ` updated_at = EXCLUDED.updated_at ` +const RELEASE_JOB_CHECK_QUERY = ` + SELECT EXISTS(SELECT 1 FROM release_job WHERE release_id = $1 AND job_id = $2) +` + +const RELEASE_JOB_INSERT_QUERY = ` + INSERT INTO release_job (release_id, job_id) + VALUES ($1, $2) +` + +func writeReleaseJob(ctx context.Context, releaseId string, jobId string, tx pgx.Tx) error { + _, err := tx.Exec(ctx, RELEASE_JOB_INSERT_QUERY, releaseId, jobId) + return err +} + func convertOapiJobStatusToStr(status oapi.JobStatus) string { switch status { case oapi.Pending: @@ -156,7 +172,11 @@ func convertOapiJobStatusToStr(status oapi.JobStatus) string { } } -func writeJob(ctx context.Context, job *oapi.Job, tx pgx.Tx) error { +func writeJob(ctx context.Context, job *oapi.Job, store *store.Store, tx pgx.Tx) error { + release, ok := store.Releases.Get(job.ReleaseId) + if !ok { + return fmt.Errorf("release not found for job %s", job.Id) + } statusStr := convertOapiJobStatusToStr(job.Status) _, err := tx.Exec( ctx, @@ -170,7 +190,16 @@ func writeJob(ctx context.Context, job *oapi.Job, tx pgx.Tx) error { job.StartedAt, job.CompletedAt, job.UpdatedAt) - return err + if err != nil { + return err + } + + if job.ReleaseId != "" { + if err := writeReleaseJob(ctx, release.UUID().String(), job.Id, tx); err != nil { + return err + } + } + return nil } const DELETE_JOB_QUERY = ` diff --git a/apps/workspace-engine/pkg/db/jobs_test.go b/apps/workspace-engine/pkg/db/jobs_test.go index ff93edf8e..befa6a0ff 100644 --- a/apps/workspace-engine/pkg/db/jobs_test.go +++ b/apps/workspace-engine/pkg/db/jobs_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" "workspace-engine/pkg/oapi" + wsStore "workspace-engine/pkg/workspace/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" @@ -86,7 +87,7 @@ func validateRetrievedJobs(t *testing.T, actualJobs []*oapi.Job, expectedJobs [] } // Helper to create prerequisites for a job -func createJobPrerequisites(t *testing.T, workspaceID string, conn *pgxpool.Conn) (releaseID, jobAgentID string) { +func createJobPrerequisites(t *testing.T, workspaceID string, conn *pgxpool.Conn, release *oapi.Release) (releaseUUID, jobAgentID string) { t.Helper() ctx := t.Context() @@ -118,14 +119,8 @@ func createJobPrerequisites(t *testing.T, workspaceID string, conn *pgxpool.Conn t.Fatalf("failed to commit: %v", err) } - // Create a release - tx, err = conn.Begin(ctx) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - defer tx.Rollback(ctx) - - release := &oapi.Release{ + // Initialize release object with the created IDs + *release = oapi.Release{ ReleaseTarget: oapi.ReleaseTarget{ ResourceId: resourceID, EnvironmentId: environmentID, @@ -141,6 +136,13 @@ func createJobPrerequisites(t *testing.T, workspaceID string, conn *pgxpool.Conn Variables: map[string]oapi.LiteralValue{}, } + // Create the release in the database + tx, err = conn.Begin(ctx) + if err != nil { + t.Fatalf("failed to begin tx: %v", err) + } + defer tx.Rollback(ctx) + if err := writeRelease(ctx, release, workspaceID, tx); err != nil { t.Fatalf("failed to create release: %v", err) } @@ -149,15 +151,15 @@ func createJobPrerequisites(t *testing.T, workspaceID string, conn *pgxpool.Conn t.Fatalf("failed to commit: %v", err) } - // Get the release ID that was created - var createdReleaseID string + // Get the database release ID that was created + var dbReleaseID string err = conn.QueryRow(ctx, `SELECT r.id FROM release r INNER JOIN version_release vr ON vr.id = r.version_release_id INNER JOIN release_target rt ON rt.id = vr.release_target_id WHERE rt.resource_id = $1 AND rt.environment_id = $2 AND rt.deployment_id = $3 AND vr.version_id = $4 LIMIT 1`, - resourceID, environmentID, deploymentID, versionID).Scan(&createdReleaseID) + resourceID, environmentID, deploymentID, versionID).Scan(&dbReleaseID) if err != nil { t.Fatalf("failed to get release id: %v", err) } @@ -165,30 +167,8 @@ func createJobPrerequisites(t *testing.T, workspaceID string, conn *pgxpool.Conn // Keep systemID to avoid "declared but not used" error _ = systemID - return createdReleaseID, jobAgentID -} - -// Helper to create release_job association -func createReleaseJobAssociation(t *testing.T, conn *pgxpool.Conn, releaseID, jobID string) { - t.Helper() - ctx := t.Context() - - tx, err := conn.Begin(ctx) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - defer tx.Rollback(ctx) - - _, err = tx.Exec(ctx, - "INSERT INTO release_job (release_id, job_id) VALUES ($1, $2)", - releaseID, jobID) - if err != nil { - t.Fatalf("failed to create release_job association: %v", err) - } - - if err := tx.Commit(ctx); err != nil { - t.Fatalf("failed to commit: %v", err) - } + // Return the deterministic UUID generated from the release + return release.UUID().String(), jobAgentID } // Helper to cleanup jobs after tests @@ -221,7 +201,14 @@ func cleanupJobs(t *testing.T, conn *pgxpool.Conn, jobIDs ...string) { func TestDBJobs_BasicWrite(t *testing.T) { workspaceID, conn := setupTestWithWorkspace(t) - releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn) + var release oapi.Release + releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn, &release) + + // Create store and add release to it (indexed by both hash ID and UUID) + testStore := wsStore.New() + testStore.Releases.Upsert(t.Context(), &release) + // Also index by UUID for job lookup + testStore.Repo().Releases.Set(releaseID, &release) tx, err := conn.Begin(t.Context()) if err != nil { @@ -246,7 +233,7 @@ func TestDBJobs_BasicWrite(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -256,27 +243,43 @@ func TestDBJobs_BasicWrite(t *testing.T) { t.Fatalf("failed to commit: %v", err) } - // Create release_job association - createReleaseJobAssociation(t, conn, releaseID, jobID) - // Register cleanup t.Cleanup(func() { cleanupJobs(t, conn, jobID) }) - // Verify job was created + // Verify job was created and release_job association was automatically created actualJobs, err := getJobs(t.Context(), workspaceID) if err != nil { t.Fatalf("expected no errors, got %v", err) } validateRetrievedJobs(t, actualJobs, []*oapi.Job{job}) + + // Verify release_job association exists + var count int + err = conn.QueryRow(t.Context(), + "SELECT COUNT(*) FROM release_job WHERE release_id = $1 AND job_id = $2", + releaseID, jobID).Scan(&count) + if err != nil { + t.Fatalf("failed to check release_job: %v", err) + } + if count != 1 { + t.Fatalf("expected 1 release_job association, got %d", count) + } } func TestDBJobs_BasicWriteAndUpdate(t *testing.T) { workspaceID, conn := setupTestWithWorkspace(t) - releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn) + var release oapi.Release + releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn, &release) + + // Create store and add release to it (indexed by both hash ID and UUID) + testStore := wsStore.New() + testStore.Releases.Upsert(t.Context(), &release) + // Also index by UUID for job lookup + testStore.Repo().Releases.Set(releaseID, &release) // Create job tx, err := conn.Begin(t.Context()) @@ -301,7 +304,7 @@ func TestDBJobs_BasicWriteAndUpdate(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -311,8 +314,6 @@ func TestDBJobs_BasicWriteAndUpdate(t *testing.T) { t.Fatalf("failed to commit: %v", err) } - createReleaseJobAssociation(t, conn, releaseID, jobID) - // Register cleanup t.Cleanup(func() { cleanupJobs(t, conn, jobID) @@ -333,7 +334,7 @@ func TestDBJobs_BasicWriteAndUpdate(t *testing.T) { job.JobAgentConfig = map[string]interface{}{"updated": "config"} job.UpdatedAt = time.Now() - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -355,7 +356,14 @@ func TestDBJobs_BasicWriteAndUpdate(t *testing.T) { func TestDBJobs_CompleteJobLifecycle(t *testing.T) { workspaceID, conn := setupTestWithWorkspace(t) - releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn) + var release oapi.Release + releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn, &release) + + // Create store and add release to it (indexed by both hash ID and UUID) + testStore := wsStore.New() + testStore.Releases.Upsert(t.Context(), &release) + // Also index by UUID for job lookup + testStore.Repo().Releases.Set(releaseID, &release) // Create job in pending state tx, err := conn.Begin(t.Context()) @@ -380,7 +388,7 @@ func TestDBJobs_CompleteJobLifecycle(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -390,8 +398,6 @@ func TestDBJobs_CompleteJobLifecycle(t *testing.T) { t.Fatalf("failed to commit: %v", err) } - createReleaseJobAssociation(t, conn, releaseID, jobID) - // Register cleanup t.Cleanup(func() { cleanupJobs(t, conn, jobID) @@ -409,7 +415,7 @@ func TestDBJobs_CompleteJobLifecycle(t *testing.T) { job.StartedAt = &startedAt job.UpdatedAt = time.Now() - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -431,7 +437,7 @@ func TestDBJobs_CompleteJobLifecycle(t *testing.T) { job.CompletedAt = &completedAt job.UpdatedAt = time.Now() - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -453,7 +459,14 @@ func TestDBJobs_CompleteJobLifecycle(t *testing.T) { func TestDBJobs_BasicWriteAndDelete(t *testing.T) { workspaceID, conn := setupTestWithWorkspace(t) - releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn) + var release oapi.Release + releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn, &release) + + // Create store and add release to it (indexed by both hash ID and UUID) + testStore := wsStore.New() + testStore.Releases.Upsert(t.Context(), &release) + // Also index by UUID for job lookup + testStore.Repo().Releases.Set(releaseID, &release) // Create job tx, err := conn.Begin(t.Context()) @@ -478,7 +491,7 @@ func TestDBJobs_BasicWriteAndDelete(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -488,8 +501,6 @@ func TestDBJobs_BasicWriteAndDelete(t *testing.T) { t.Fatalf("failed to commit: %v", err) } - createReleaseJobAssociation(t, conn, releaseID, jobID) - // Register cleanup (will be no-op if test deletes the job) t.Cleanup(func() { cleanupJobs(t, conn, jobID) @@ -530,7 +541,14 @@ func TestDBJobs_BasicWriteAndDelete(t *testing.T) { func TestDBJobs_MultipleJobsForSameRelease(t *testing.T) { workspaceID, conn := setupTestWithWorkspace(t) - releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn) + var release oapi.Release + releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn, &release) + + // Create store and add release to it (indexed by both hash ID and UUID) + testStore := wsStore.New() + testStore.Releases.Upsert(t.Context(), &release) + // Also index by UUID for job lookup + testStore.Repo().Releases.Set(releaseID, &release) tx, err := conn.Begin(t.Context()) if err != nil { @@ -570,12 +588,12 @@ func TestDBJobs_MultipleJobsForSameRelease(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job1, tx) + err = writeJob(t.Context(), job1, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } - err = writeJob(t.Context(), job2, tx) + err = writeJob(t.Context(), job2, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -585,9 +603,6 @@ func TestDBJobs_MultipleJobsForSameRelease(t *testing.T) { t.Fatalf("failed to commit: %v", err) } - createReleaseJobAssociation(t, conn, releaseID, job1ID) - createReleaseJobAssociation(t, conn, releaseID, job2ID) - // Register cleanup t.Cleanup(func() { cleanupJobs(t, conn, job1ID, job2ID) @@ -605,7 +620,14 @@ func TestDBJobs_MultipleJobsForSameRelease(t *testing.T) { func TestDBJobs_ComplexJobAgentConfig(t *testing.T) { workspaceID, conn := setupTestWithWorkspace(t) - releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn) + var release oapi.Release + releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn, &release) + + // Create store and add release to it (indexed by both hash ID and UUID) + testStore := wsStore.New() + testStore.Releases.Upsert(t.Context(), &release) + // Also index by UUID for job lookup + testStore.Repo().Releases.Set(releaseID, &release) tx, err := conn.Begin(t.Context()) if err != nil { @@ -637,7 +659,7 @@ func TestDBJobs_ComplexJobAgentConfig(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -647,8 +669,6 @@ func TestDBJobs_ComplexJobAgentConfig(t *testing.T) { t.Fatalf("failed to commit: %v", err) } - createReleaseJobAssociation(t, conn, releaseID, jobID) - // Register cleanup t.Cleanup(func() { cleanupJobs(t, conn, jobID) @@ -666,7 +686,14 @@ func TestDBJobs_ComplexJobAgentConfig(t *testing.T) { func TestDBJobs_AllJobStatuses(t *testing.T) { workspaceID, conn := setupTestWithWorkspace(t) - releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn) + var release oapi.Release + releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn, &release) + + // Create store and add release to it (indexed by both hash ID and UUID) + testStore := wsStore.New() + testStore.Releases.Upsert(t.Context(), &release) + // Also index by UUID for job lookup + testStore.Repo().Releases.Set(releaseID, &release) statuses := []oapi.JobStatus{ oapi.Pending, @@ -706,7 +733,7 @@ func TestDBJobs_AllJobStatuses(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job, tx) + err = writeJob(t.Context(), job, testStore, tx) if err != nil { t.Fatalf("expected no errors for status %s, got %v", status, err) } @@ -719,10 +746,9 @@ func TestDBJobs_AllJobStatuses(t *testing.T) { t.Fatalf("failed to commit: %v", err) } - // Create release_job associations + // Collect job IDs for cleanup jobIDs := make([]string, 0, len(jobs)) for _, job := range jobs { - createReleaseJobAssociation(t, conn, releaseID, job.Id) jobIDs = append(jobIDs, job.Id) } @@ -745,10 +771,24 @@ func TestDBJobs_WorkspaceIsolation(t *testing.T) { workspaceID2, conn2 := setupTestWithWorkspace(t) // Create prerequisites in workspace 1 - releaseID1, jobAgentID1 := createJobPrerequisites(t, workspaceID1, conn1) + var release1 oapi.Release + releaseID1, jobAgentID1 := createJobPrerequisites(t, workspaceID1, conn1, &release1) + + // Create store for workspace 1 (indexed by both hash ID and UUID) + testStore1 := wsStore.New() + testStore1.Releases.Upsert(t.Context(), &release1) + // Also index by UUID for job lookup + testStore1.Repo().Releases.Set(releaseID1, &release1) // Create prerequisites in workspace 2 - releaseID2, jobAgentID2 := createJobPrerequisites(t, workspaceID2, conn2) + var release2 oapi.Release + releaseID2, jobAgentID2 := createJobPrerequisites(t, workspaceID2, conn2, &release2) + + // Create store for workspace 2 (indexed by both hash ID and UUID) + testStore2 := wsStore.New() + testStore2.Releases.Upsert(t.Context(), &release2) + // Also index by UUID for job lookup + testStore2.Repo().Releases.Set(releaseID2, &release2) // Create job in workspace 1 tx1, err := conn1.Begin(t.Context()) @@ -772,7 +812,7 @@ func TestDBJobs_WorkspaceIsolation(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job1, tx1) + err = writeJob(t.Context(), job1, testStore1, tx1) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -782,8 +822,6 @@ func TestDBJobs_WorkspaceIsolation(t *testing.T) { t.Fatalf("failed to commit tx1: %v", err) } - createReleaseJobAssociation(t, conn1, releaseID1, job1ID) - // Register cleanup for workspace 1 t.Cleanup(func() { cleanupJobs(t, conn1, job1ID) @@ -810,7 +848,7 @@ func TestDBJobs_WorkspaceIsolation(t *testing.T) { CompletedAt: nil, } - err = writeJob(t.Context(), job2, tx2) + err = writeJob(t.Context(), job2, testStore2, tx2) if err != nil { t.Fatalf("expected no errors, got %v", err) } @@ -820,8 +858,6 @@ func TestDBJobs_WorkspaceIsolation(t *testing.T) { t.Fatalf("failed to commit tx2: %v", err) } - createReleaseJobAssociation(t, conn2, releaseID2, job2ID) - // Register cleanup for workspace 2 t.Cleanup(func() { cleanupJobs(t, conn2, job2ID) @@ -865,3 +901,167 @@ func TestDBJobs_EmptyWorkspace(t *testing.T) { t.Fatalf("expected 0 jobs, got %d", len(actualJobs)) } } + +// TestDBJobs_WriteAndRetrieveWithReleaseJob tests the complete flow of writing jobs +// and retrieving them, ensuring release_job associations are automatically created +func TestDBJobs_WriteAndRetrieveWithReleaseJob(t *testing.T) { + workspaceID, conn := setupTestWithWorkspace(t) + + var release oapi.Release + releaseID, jobAgentID := createJobPrerequisites(t, workspaceID, conn, &release) + + // Create store and add release to it (indexed by both hash ID and UUID) + testStore := wsStore.New() + testStore.Releases.Upsert(t.Context(), &release) + // Also index by UUID for job lookup + testStore.Repo().Releases.Set(releaseID, &release) + + // Create multiple jobs with different statuses + tx, err := conn.Begin(t.Context()) + if err != nil { + t.Fatalf("failed to begin tx: %v", err) + } + defer tx.Rollback(t.Context()) + + now := time.Now() + + // Job 1: Pending + job1ID := uuid.New().String() + job1 := &oapi.Job{ + Id: job1ID, + ReleaseId: releaseID, + JobAgentId: jobAgentID, + ExternalId: nil, + Status: oapi.Pending, + JobAgentConfig: map[string]interface{}{"attempt": 1.0}, + CreatedAt: now, + UpdatedAt: now, + StartedAt: nil, + CompletedAt: nil, + } + + // Job 2: In Progress with started time + job2ID := uuid.New().String() + startedAt := now.Add(1 * time.Minute) + externalID2 := "github-run-123" + job2 := &oapi.Job{ + Id: job2ID, + ReleaseId: releaseID, + JobAgentId: jobAgentID, + ExternalId: &externalID2, + Status: oapi.InProgress, + JobAgentConfig: map[string]interface{}{"attempt": 2.0}, + CreatedAt: now, + UpdatedAt: now.Add(1 * time.Minute), + StartedAt: &startedAt, + CompletedAt: nil, + } + + // Job 3: Successful with completed time + job3ID := uuid.New().String() + startedAt3 := now.Add(2 * time.Minute) + completedAt3 := now.Add(5 * time.Minute) + externalID3 := "github-run-456" + job3 := &oapi.Job{ + Id: job3ID, + ReleaseId: releaseID, + JobAgentId: jobAgentID, + ExternalId: &externalID3, + Status: oapi.Successful, + JobAgentConfig: map[string]interface{}{"attempt": 3.0, "retry": false}, + CreatedAt: now, + UpdatedAt: now.Add(5 * time.Minute), + StartedAt: &startedAt3, + CompletedAt: &completedAt3, + } + + // Write all jobs - should automatically create release_job associations + if err := writeJob(t.Context(), job1, testStore, tx); err != nil { + t.Fatalf("failed to write job1: %v", err) + } + if err := writeJob(t.Context(), job2, testStore, tx); err != nil { + t.Fatalf("failed to write job2: %v", err) + } + if err := writeJob(t.Context(), job3, testStore, tx); err != nil { + t.Fatalf("failed to write job3: %v", err) + } + + if err := tx.Commit(t.Context()); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Register cleanup + t.Cleanup(func() { + cleanupJobs(t, conn, job1ID, job2ID, job3ID) + }) + + // Retrieve all jobs and verify they match what we wrote + actualJobs, err := getJobs(t.Context(), workspaceID) + if err != nil { + t.Fatalf("failed to get jobs: %v", err) + } + + expectedJobs := []*oapi.Job{job1, job2, job3} + validateRetrievedJobs(t, actualJobs, expectedJobs) + + // Verify that jobs are correctly linked to the release + var jobCount int + err = conn.QueryRow(t.Context(), + `SELECT COUNT(DISTINCT j.id) FROM job j + INNER JOIN release_job rj ON rj.job_id = j.id + WHERE rj.release_id = $1`, + releaseID).Scan(&jobCount) + if err != nil { + t.Fatalf("failed to count jobs for release: %v", err) + } + if jobCount != 3 { + t.Fatalf("expected 3 jobs linked to release, got %d", jobCount) + } + + // Test updating a job and verify the release_job association persists + tx, err = conn.Begin(t.Context()) + if err != nil { + t.Fatalf("failed to begin tx for update: %v", err) + } + defer tx.Rollback(t.Context()) + + // Update job1 to in-progress + updateStartedAt := time.Now() + job1.Status = oapi.InProgress + job1.StartedAt = &updateStartedAt + job1.UpdatedAt = time.Now() + + if err := writeJob(t.Context(), job1, testStore, tx); err != nil { + t.Fatalf("failed to update job1: %v", err) + } + + if err := tx.Commit(t.Context()); err != nil { + t.Fatalf("failed to commit update: %v", err) + } + + // Verify the job was actually updated + updatedJobs, err := getJobs(t.Context(), workspaceID) + if err != nil { + t.Fatalf("failed to get jobs after update: %v", err) + } + + var foundUpdatedJob *oapi.Job + for _, job := range updatedJobs { + if job.Id == job1ID { + foundUpdatedJob = job + break + } + } + + if foundUpdatedJob == nil { + t.Fatalf("updated job not found") + } + + if foundUpdatedJob.Status != oapi.InProgress { + t.Errorf("expected status %s, got %s", oapi.InProgress, foundUpdatedJob.Status) + } + + if foundUpdatedJob.StartedAt == nil { + t.Error("expected StartedAt to be set after update") + } +} diff --git a/apps/workspace-engine/pkg/db/releases.go b/apps/workspace-engine/pkg/db/releases.go index 18f9b09f8..a8c074f8e 100644 --- a/apps/workspace-engine/pkg/db/releases.go +++ b/apps/workspace-engine/pkg/db/releases.go @@ -197,8 +197,8 @@ func writeVariableSetRelease(ctx context.Context, release *oapi.Release, release } const RELEASE_INSERT_QUERY = ` - INSERT INTO release (version_release_id, variable_release_id) - VALUES ($1, $2) + INSERT INTO release (id, version_release_id, variable_release_id) + VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET version_release_id = EXCLUDED.version_release_id, variable_release_id = EXCLUDED.variable_release_id RETURNING id ` @@ -218,7 +218,7 @@ func writeRelease(ctx context.Context, release *oapi.Release, workspaceID string return err } - _, err = tx.Exec(ctx, RELEASE_INSERT_QUERY, versionReleaseID, variableSetReleaseID) + _, err = tx.Exec(ctx, RELEASE_INSERT_QUERY, release.UUID().String(), versionReleaseID, variableSetReleaseID) return err } diff --git a/apps/workspace-engine/pkg/db/releases_test.go b/apps/workspace-engine/pkg/db/releases_test.go index 80e2c1c23..70dd3e9ac 100644 --- a/apps/workspace-engine/pkg/db/releases_test.go +++ b/apps/workspace-engine/pkg/db/releases_test.go @@ -494,83 +494,6 @@ func TestDBReleases_SameTargetDifferentVersions(t *testing.T) { // Keep systemID to avoid "declared but not used" error _ = systemID } - -func TestDBReleases_WriteSameReleaseTwiceCreatesDuplicates(t *testing.T) { - workspaceID, conn := setupTestWithWorkspace(t) - - systemID, deploymentID, versionID, resourceID, environmentID := createReleasePrerequisites( - t, workspaceID, conn) - - // Create a release - tx, err := conn.Begin(t.Context()) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - defer tx.Rollback(t.Context()) - - release := &oapi.Release{ - ReleaseTarget: oapi.ReleaseTarget{ - ResourceId: resourceID, - EnvironmentId: environmentID, - DeploymentId: deploymentID, - }, - Version: oapi.DeploymentVersion{ - Id: versionID, - Name: fmt.Sprintf("version-%s", versionID[:8]), - Tag: "v1.0.0", - DeploymentId: deploymentID, - Status: oapi.DeploymentVersionStatusReady, - }, - Variables: map[string]oapi.LiteralValue{ - "ENV": stringLiteral("production"), - }, - } - - err = writeRelease(t.Context(), release, workspaceID, tx) - if err != nil { - t.Fatalf("expected no errors on first write, got %v", err) - } - - err = tx.Commit(t.Context()) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - // Write the exact same release again - tx, err = conn.Begin(t.Context()) - if err != nil { - t.Fatalf("failed to begin tx: %v", err) - } - defer tx.Rollback(t.Context()) - - err = writeRelease(t.Context(), release, workspaceID, tx) - if err != nil { - t.Fatalf("expected no errors on second write, got %v", err) - } - - err = tx.Commit(t.Context()) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - // Verify two releases exist (duplicates are allowed) - actualReleases, err := getReleases(t.Context(), workspaceID) - if err != nil { - t.Fatalf("expected no errors, got %v", err) - } - - // Should have 2 releases now since there's no uniqueness constraint - if len(actualReleases) != 2 { - t.Fatalf("expected 2 releases (duplicates allowed), got %d", len(actualReleases)) - } - - // Both should have the same data - validateRetrievedReleases(t, actualReleases, []*oapi.Release{release, release}) - - // Keep systemID to avoid "declared but not used" error - _ = systemID -} - func TestDBReleases_MultipleResourcesSameDeployment(t *testing.T) { workspaceID, conn := setupTestWithWorkspace(t) diff --git a/apps/workspace-engine/pkg/oapi/oapi.go b/apps/workspace-engine/pkg/oapi/oapi.go index bad6e1d7e..593add609 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.go +++ b/apps/workspace-engine/pkg/oapi/oapi.go @@ -10,6 +10,8 @@ import ( "fmt" "sort" "strings" + + "github.com/google/uuid" ) func (r *Release) ID() string { @@ -39,6 +41,10 @@ func (r *Release) ID() string { return hex.EncodeToString(hash[:]) } +func (r *Release) UUID() uuid.UUID { + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(r.ID())) +} + func toString(v any) string { switch t := v.(type) { case string: diff --git a/apps/workspace-engine/pkg/server/openapi/deploymentversions/comparator.go b/apps/workspace-engine/pkg/server/openapi/deploymentversions/comparator.go new file mode 100644 index 000000000..cd40e1b15 --- /dev/null +++ b/apps/workspace-engine/pkg/server/openapi/deploymentversions/comparator.go @@ -0,0 +1,53 @@ +package deploymentversions + +import ( + "strings" + "workspace-engine/pkg/oapi" +) + +func compareReleaseTargets(a *fullReleaseTarget, b *fullReleaseTarget) int { + var statusA *oapi.JobStatus + var statusB *oapi.JobStatus + + if len(a.Jobs) > 0 { + statusA = &a.Jobs[0].Status + } + if len(b.Jobs) > 0 { + statusB = &b.Jobs[0].Status + } + + // Handle nil status cases + if statusA == nil && statusB != nil { + return 1 + } + if statusA != nil && statusB == nil { + return -1 + } + + if statusA != nil && statusB != nil { + if *statusA == oapi.Failure && *statusB != oapi.Failure { + return -1 + } + if *statusA != oapi.Failure && *statusB == oapi.Failure { + return 1 + } + + if *statusA != *statusB { + return strings.Compare(string(*statusA), string(*statusB)) + } + } + + var createdAtA, createdAtB int64 + if len(a.Jobs) > 0 { + createdAtA = a.Jobs[0].CreatedAt.Unix() + } + if len(b.Jobs) > 0 { + createdAtB = b.Jobs[0].CreatedAt.Unix() + } + + if createdAtA != createdAtB { + return int(createdAtB - createdAtA) + } + + return strings.Compare(a.Resource.Name, b.Resource.Name) +} diff --git a/apps/workspace-engine/pkg/server/openapi/deploymentversions/comparator_test.go b/apps/workspace-engine/pkg/server/openapi/deploymentversions/comparator_test.go new file mode 100644 index 000000000..962a79883 --- /dev/null +++ b/apps/workspace-engine/pkg/server/openapi/deploymentversions/comparator_test.go @@ -0,0 +1,335 @@ +package deploymentversions + +import ( + "testing" + "time" + "workspace-engine/pkg/oapi" +) + +// Helper function to create a job with specific status and createdAt +func createJob(status oapi.JobStatus, createdAt time.Time) *oapi.Job { + return &oapi.Job{ + Status: status, + CreatedAt: createdAt, + } +} + +// Helper function to create a fullReleaseTarget +func createFullReleaseTarget(jobs []*oapi.Job, resourceName string) *fullReleaseTarget { + return &fullReleaseTarget{ + Jobs: jobs, + Resource: &oapi.Resource{ + Name: resourceName, + }, + } +} + +func TestCompareReleaseTargets_NilJobs(t *testing.T) { + t.Run("both have no jobs", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{}, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{}, "resource-b") + + result := compareReleaseTargets(a, b) + if result >= 0 { + t.Errorf("expected resource-a < resource-b, got %d", result) + } + }) + + t.Run("a has no jobs, b has jobs", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{}, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Pending, time.Now()), + }, "resource-b") + + result := compareReleaseTargets(a, b) + if result <= 0 { + t.Errorf("expected a > b (a with no jobs should come after), got %d", result) + } + }) + + t.Run("a has jobs, b has no jobs", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Pending, time.Now()), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{}, "resource-b") + + result := compareReleaseTargets(a, b) + if result >= 0 { + t.Errorf("expected a < b (a with jobs should come first), got %d", result) + } + }) +} + +func TestCompareReleaseTargets_FailureStatus(t *testing.T) { + now := time.Now() + + t.Run("a is failure, b is not", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Failure, now), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, now), + }, "resource-b") + + result := compareReleaseTargets(a, b) + if result >= 0 { + t.Errorf("expected failure to come first (negative), got %d", result) + } + }) + + t.Run("a is not failure, b is failure", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, now), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Failure, now), + }, "resource-b") + + result := compareReleaseTargets(a, b) + if result <= 0 { + t.Errorf("expected failure (b) to come first (positive), got %d", result) + } + }) + + t.Run("both are failures", func(t *testing.T) { + olderTime := time.Now().Add(-1 * time.Hour) + newerTime := time.Now() + + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Failure, newerTime), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Failure, olderTime), + }, "resource-b") + + result := compareReleaseTargets(a, b) + // Newer should come first (a should be less than b) + if result >= 0 { + t.Errorf("expected newer failure to come first (negative), got %d", result) + } + }) +} + +func TestCompareReleaseTargets_StatusComparison(t *testing.T) { + now := time.Now() + + t.Run("different statuses lexicographic comparison", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.InProgress, now), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Pending, now), + }, "resource-b") + + result := compareReleaseTargets(a, b) + // "inProgress" < "pending" lexicographically + if result >= 0 { + t.Errorf("expected inProgress < pending lexicographically, got %d", result) + } + }) + + t.Run("same status different createdAt", func(t *testing.T) { + olderTime := time.Now().Add(-1 * time.Hour) + newerTime := time.Now() + + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Pending, newerTime), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Pending, olderTime), + }, "resource-b") + + result := compareReleaseTargets(a, b) + // Newer should come first (a should be less than b) + if result >= 0 { + t.Errorf("expected newer job to come first (negative), got %d", result) + } + }) +} + +func TestCompareReleaseTargets_CreatedAtComparison(t *testing.T) { + t.Run("newer createdAt comes first", func(t *testing.T) { + olderTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + newerTime := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, newerTime), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, olderTime), + }, "resource-b") + + result := compareReleaseTargets(a, b) + // Newer should come first (negative result) + if result >= 0 { + t.Errorf("expected newer job to come first, got %d", result) + } + }) + + t.Run("older createdAt comes after", func(t *testing.T) { + olderTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + newerTime := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, olderTime), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, newerTime), + }, "resource-b") + + result := compareReleaseTargets(a, b) + // Older should come after (positive result) + if result <= 0 { + t.Errorf("expected older job to come after, got %d", result) + } + }) +} + +func TestCompareReleaseTargets_ResourceNameTiebreaker(t *testing.T) { + now := time.Now() + + t.Run("same status and createdAt, different resource names", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, now), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, now), + }, "resource-b") + + result := compareReleaseTargets(a, b) + // "resource-a" < "resource-b" lexicographically + if result >= 0 { + t.Errorf("expected resource-a < resource-b, got %d", result) + } + }) + + t.Run("same status and createdAt, reverse resource names", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, now), + }, "resource-z") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, now), + }, "resource-a") + + result := compareReleaseTargets(a, b) + // "resource-z" > "resource-a" lexicographically + if result <= 0 { + t.Errorf("expected resource-z > resource-a, got %d", result) + } + }) + + t.Run("no jobs, different resource names", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{}, "alpha") + b := createFullReleaseTarget([]*oapi.Job{}, "beta") + + result := compareReleaseTargets(a, b) + // "alpha" < "beta" + if result >= 0 { + t.Errorf("expected alpha < beta, got %d", result) + } + }) +} + +func TestCompareReleaseTargets_ComplexScenarios(t *testing.T) { + now := time.Now() + oneHourAgo := now.Add(-1 * time.Hour) + twoDaysAgo := now.Add(-48 * time.Hour) + + t.Run("multiple jobs, only first is considered", func(t *testing.T) { + a := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Failure, now), + createJob(oapi.Successful, twoDaysAgo), + }, "resource-a") + b := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, now), + createJob(oapi.Failure, twoDaysAgo), + }, "resource-b") + + result := compareReleaseTargets(a, b) + // a has failure (first job), b has success (first job) + // Failure should come first + if result >= 0 { + t.Errorf("expected failure to prioritize, got %d", result) + } + }) + + t.Run("realistic sorting scenario", func(t *testing.T) { + targets := []*fullReleaseTarget{ + createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, oneHourAgo), + }, "server-1"), + createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Failure, now), + }, "server-2"), + createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.InProgress, now), + }, "server-3"), + createFullReleaseTarget([]*oapi.Job{}, "server-4"), + createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Successful, now), + }, "server-5"), + } + + // Expected order after sorting: + // 1. server-2 (failure, most recent) + // 2. server-3 (inProgress, now) + // 3. server-5 (successful, now) + // 4. server-1 (successful, one hour ago) + // 5. server-4 (no jobs) + + // Test that failure comes first + if compareReleaseTargets(targets[1], targets[0]) >= 0 { + t.Error("failure should come before success") + } + if compareReleaseTargets(targets[1], targets[2]) >= 0 { + t.Error("failure should come before inProgress") + } + + // Test that no jobs comes last + if compareReleaseTargets(targets[3], targets[0]) <= 0 { + t.Error("no jobs should come after jobs") + } + + // Test that newer comes before older + if compareReleaseTargets(targets[4], targets[0]) >= 0 { + t.Error("newer job should come before older job") + } + }) +} + +func TestCompareReleaseTargets_AllJobStatuses(t *testing.T) { + now := time.Now() + statuses := []oapi.JobStatus{ + oapi.ActionRequired, + oapi.Cancelled, + oapi.ExternalRunNotFound, + oapi.Failure, + oapi.InProgress, + oapi.InvalidIntegration, + oapi.InvalidJobAgent, + oapi.Pending, + oapi.Skipped, + oapi.Successful, + } + + t.Run("failure always prioritized", func(t *testing.T) { + failureTarget := createFullReleaseTarget([]*oapi.Job{ + createJob(oapi.Failure, now), + }, "failure") + + for _, status := range statuses { + if status == oapi.Failure { + continue + } + + otherTarget := createFullReleaseTarget([]*oapi.Job{ + createJob(status, now), + }, "other") + + result := compareReleaseTargets(failureTarget, otherTarget) + if result >= 0 { + t.Errorf("failure should come before %s, got %d", status, result) + } + } + }) +} diff --git a/apps/workspace-engine/pkg/server/openapi/deploymentversions/server.go b/apps/workspace-engine/pkg/server/openapi/deploymentversions/server.go index 32455e4d7..3ff9c6f71 100644 --- a/apps/workspace-engine/pkg/server/openapi/deploymentversions/server.go +++ b/apps/workspace-engine/pkg/server/openapi/deploymentversions/server.go @@ -1,11 +1,159 @@ package deploymentversions import ( + "fmt" + "net/http" + "sort" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/server/openapi/utils" + "workspace-engine/pkg/workspace" + "github.com/gin-gonic/gin" ) type DeploymentVersions struct{} +func getSystemEnvironments(ws *workspace.Workspace, systemId string) []*oapi.Environment { + environments := make([]*oapi.Environment, 0) + for environment := range ws.Environments().IterBuffered() { + if environment.Val.SystemId == systemId { + environments = append(environments, environment.Val) + } + } + + return environments +} + +func getEnvironmentReleaseTargets(releaseTargets []*oapi.ReleaseTarget, environmentId string) []*oapi.ReleaseTarget { + environmentReleaseTargets := make([]*oapi.ReleaseTarget, 0) + for _, releaseTarget := range releaseTargets { + if releaseTarget.EnvironmentId == environmentId { + environmentReleaseTargets = append(environmentReleaseTargets, releaseTarget) + } + } + return environmentReleaseTargets +} + +func getDeploymentReleaseTargets(c *gin.Context, ws *workspace.Workspace, deploymentId string) ([]*oapi.ReleaseTarget, error) { + releaseTargets := make([]*oapi.ReleaseTarget, 0) + allReleaseTargets, err := ws.ReleaseTargets().Items(c.Request.Context()) + if err != nil { + return nil, err + } + for _, releaseTarget := range allReleaseTargets { + if releaseTarget.DeploymentId == deploymentId { + releaseTargets = append(releaseTargets, releaseTarget) + } + } + return releaseTargets, nil +} + +func getReleaseTargetJobs(ws *workspace.Workspace, releaseTarget *oapi.ReleaseTarget) ([]*oapi.Job, error) { + jobsMap := ws.Jobs().GetJobsForReleaseTarget(releaseTarget) + jobs := make([]*oapi.Job, 0) + for _, job := range jobsMap { + jobs = append(jobs, job) + } + + sort.Slice(jobs, func(i, j int) bool { + return jobs[i].CreatedAt.After(jobs[j].CreatedAt) + }) + return jobs, nil +} + +type fullReleaseTarget struct { + *oapi.ReleaseTarget + Jobs []*oapi.Job `json:"jobs"` + Environment *oapi.Environment `json:"environment,omitempty"` + Deployment *oapi.Deployment `json:"deployment,omitempty"` + Resource *oapi.Resource `json:"resource,omitempty"` +} + +type environmentWithTargets struct { + Environment *oapi.Environment `json:"environment"` + ReleaseTargets []*fullReleaseTarget `json:"releaseTargets"` +} + +func getFullReleaseTarget(ws *workspace.Workspace, releaseTarget *oapi.ReleaseTarget) (*fullReleaseTarget, error) { + jobs, err := getReleaseTargetJobs(ws, releaseTarget) + if err != nil { + return nil, err + } + resource, ok := ws.Resources().Get(releaseTarget.ResourceId) + if !ok { + return nil, fmt.Errorf("resource %s not found", releaseTarget.ResourceId) + } + return &fullReleaseTarget{ + ReleaseTarget: releaseTarget, + Jobs: jobs, + Resource: resource, + }, nil +} + func (s *DeploymentVersions) GetDeploymentVersionJobsList(c *gin.Context, workspaceId string, versionId string) { - panic("not implemented") + ws, err := utils.GetWorkspace(c, workspaceId) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + version, ok := ws.DeploymentVersions().Get(versionId) + if !ok { + c.JSON(http.StatusNotFound, gin.H{ + "error": fmt.Errorf("deployment version %s not found", versionId).Error(), + }) + return + } + + deployment, ok := ws.Deployments().Get(version.DeploymentId) + if !ok { + c.JSON(http.StatusNotFound, gin.H{ + "error": fmt.Errorf("deployment %s not found", version.DeploymentId).Error(), + }) + return + } + + environments := getSystemEnvironments(ws, deployment.SystemId) + releaseTargets, err := getDeploymentReleaseTargets(c, ws, deployment.Id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + envsWithTargets := make([]*environmentWithTargets, 0) + + for _, environment := range environments { + environmentReleaseTargets := getEnvironmentReleaseTargets(releaseTargets, environment.Id) + fullReleaseTargets := make([]*fullReleaseTarget, 0) + for _, releaseTarget := range environmentReleaseTargets { + fullReleaseTarget, err := getFullReleaseTarget(ws, releaseTarget) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + fullReleaseTarget.Environment = environment + fullReleaseTarget.Deployment = deployment + fullReleaseTargets = append(fullReleaseTargets, fullReleaseTarget) + } + + sort.Slice(fullReleaseTargets, func(i, j int) bool { + return compareReleaseTargets(fullReleaseTargets[i], fullReleaseTargets[j]) < 0 + }) + envWithTarget := &environmentWithTargets{ + Environment: environment, + ReleaseTargets: fullReleaseTargets, + } + envsWithTargets = append(envsWithTargets, envWithTarget) + } + + sort.Slice(envsWithTargets, func(i, j int) bool { + return envsWithTargets[i].Environment.Name < envsWithTargets[j].Environment.Name + }) + c.JSON(http.StatusOK, envsWithTargets) } diff --git a/apps/workspace-engine/pkg/workspace/populate_workspace.go b/apps/workspace-engine/pkg/workspace/populate_workspace.go index 4af26f822..9a1456745 100644 --- a/apps/workspace-engine/pkg/workspace/populate_workspace.go +++ b/apps/workspace-engine/pkg/workspace/populate_workspace.go @@ -47,6 +47,16 @@ func PopulateWorkspaceWithInitialState(ctx context.Context, ws *Workspace) error return err } } + for _, job := range initialWorkspaceState.Jobs() { + for _, initialRelease := range initialWorkspaceState.Releases() { + if initialRelease.UUID().String() == job.ReleaseId { + job.ReleaseId = initialRelease.ID() + break + } + } + + ws.Jobs().Upsert(ctx, job) + } for _, jobAgent := range initialWorkspaceState.JobAgents() { ws.JobAgents().Upsert(ctx, jobAgent) } diff --git a/apps/workspace-engine/pkg/workspace/workspace.go b/apps/workspace-engine/pkg/workspace/workspace.go index c609dec45..2add75144 100644 --- a/apps/workspace-engine/pkg/workspace/workspace.go +++ b/apps/workspace-engine/pkg/workspace/workspace.go @@ -19,7 +19,7 @@ var _ gob.GobDecoder = (*Workspace)(nil) func New(id string) *Workspace { s := store.New() rm := releasemanager.New(s) - cc := db.NewChangesetConsumer(id) + cc := db.NewChangesetConsumer(id, s) ws := &Workspace{ ID: id, store: s, diff --git a/apps/workspace-engine/test/e2e/engine_deployment_version_jobs_list_test.go b/apps/workspace-engine/test/e2e/engine_deployment_version_jobs_list_test.go new file mode 100644 index 000000000..443dd2fce --- /dev/null +++ b/apps/workspace-engine/test/e2e/engine_deployment_version_jobs_list_test.go @@ -0,0 +1,587 @@ +package e2e + +import ( + "context" + "reflect" + "sort" + "testing" + "time" + "workspace-engine/pkg/oapi" + "workspace-engine/test/integration" + + "github.com/google/uuid" +) + +// Helper to create a fullReleaseTarget (matching the internal struct used by deploymentversions) +type fullReleaseTarget struct { + *oapi.ReleaseTarget + Jobs []*oapi.Job + Environment *oapi.Environment + Deployment *oapi.Deployment + Resource *oapi.Resource +} + +// Helper to get full release target with all related data +func getFullReleaseTarget( + rt *oapi.ReleaseTarget, + jobs []*oapi.Job, + env *oapi.Environment, + deployment *oapi.Deployment, + resource *oapi.Resource, +) *fullReleaseTarget { + return &fullReleaseTarget{ + ReleaseTarget: rt, + Jobs: jobs, + Environment: env, + Deployment: deployment, + Resource: resource, + } +} + +func TestEngine_DeploymentVersionJobsList_BasicCreation(t *testing.T) { + jobAgentId := uuid.New().String() + deploymentId := uuid.New().String() + versionId := uuid.New().String() + + engine := integration.NewTestWorkspace(t, + integration.WithJobAgent(integration.JobAgentID(jobAgentId)), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentId), + integration.DeploymentName("api-service"), + integration.DeploymentJobAgent(jobAgentId), + integration.WithDeploymentVersion(integration.DeploymentVersionID(versionId)), + ), + integration.WithEnvironment(integration.EnvironmentName("staging")), + ), + integration.WithResource(integration.ResourceName("server-1")), + ) + + ctx := context.Background() + ws := engine.Workspace() + + // Wait for initial processing + time.Sleep(100 * time.Millisecond) + + // Verify deployment version exists + version, ok := ws.DeploymentVersions().Get(versionId) + if !ok { + t.Fatal("deployment version not found") + } + + // Verify release targets were created + releaseTargets, err := ws.ReleaseTargets().Items(ctx) + if err != nil { + t.Fatalf("failed to get release targets: %v", err) + } + + if len(releaseTargets) != 1 { + t.Fatalf("expected 1 release target, got %d", len(releaseTargets)) + } + + // Verify deployment matches + deployment, ok := ws.Deployments().Get(version.DeploymentId) + if !ok { + t.Fatal("deployment not found") + } + if deployment.Id != deploymentId { + t.Errorf("expected deployment ID %s, got %s", deploymentId, deployment.Id) + } +} + +func TestEngine_DeploymentVersionJobsList_JobsCreatedForAllTargets(t *testing.T) { + jobAgentId := uuid.New().String() + deploymentId := uuid.New().String() + versionId := uuid.New().String() + + engine := integration.NewTestWorkspace(t, + integration.WithJobAgent(integration.JobAgentID(jobAgentId)), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentId), + integration.DeploymentName("api-service"), + integration.DeploymentJobAgent(jobAgentId), + integration.WithDeploymentVersion(integration.DeploymentVersionID(versionId)), + ), + integration.WithEnvironment(integration.EnvironmentName("production")), + ), + integration.WithResource(integration.ResourceName("server-1")), + integration.WithResource(integration.ResourceName("server-2")), + integration.WithResource(integration.ResourceName("server-3")), + ) + + ctx := context.Background() + ws := engine.Workspace() + + // Wait for jobs to be created + time.Sleep(500 * time.Millisecond) + + // Verify release targets (1 environment * 3 resources = 3 targets) + releaseTargets, err := ws.ReleaseTargets().Items(ctx) + if err != nil { + t.Fatalf("failed to get release targets: %v", err) + } + if len(releaseTargets) != 3 { + t.Fatalf("expected 3 release targets, got %d", len(releaseTargets)) + } + + // Verify each release target has jobs + for _, rt := range releaseTargets { + jobs := ws.Jobs().GetJobsForReleaseTarget(rt) + if len(jobs) == 0 { + resource, _ := ws.Resources().Get(rt.ResourceId) + t.Errorf("expected jobs for release target with resource %s, got none", resource.Name) + } + } +} + +func TestEngine_DeploymentVersionJobsList_SortingOrder(t *testing.T) { + jobAgentId := uuid.New().String() + deploymentId := uuid.New().String() + versionId := uuid.New().String() + + engine := integration.NewTestWorkspace(t, + integration.WithJobAgent(integration.JobAgentID(jobAgentId)), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentId), + integration.DeploymentName("api-service"), + integration.DeploymentJobAgent(jobAgentId), + integration.WithDeploymentVersion(integration.DeploymentVersionID(versionId)), + ), + integration.WithEnvironment(integration.EnvironmentName("production")), + ), + // Create resources with names that would NOT be in alphabetical order + // to verify sorting is working correctly + integration.WithResource(integration.ResourceName("z-server")), + integration.WithResource(integration.ResourceName("a-server")), + integration.WithResource(integration.ResourceName("m-server")), + ) + + ctx := context.Background() + ws := engine.Workspace() + + // Wait for jobs to be created + time.Sleep(500 * time.Millisecond) + + releaseTargets, err := ws.ReleaseTargets().Items(ctx) + if err != nil { + t.Fatalf("failed to get release targets: %v", err) + } + if len(releaseTargets) != 3 { + t.Fatalf("expected 3 release targets, got %d", len(releaseTargets)) + } + + // Set different statuses to test sorting + // We want: failure first, then inProgress, then successful + // Use deterministic timestamps to ensure predictable ordering + baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + timestampIndex := 0 + + for _, rt := range releaseTargets { + resource, _ := ws.Resources().Get(rt.ResourceId) + jobs := ws.Jobs().GetJobsForReleaseTarget(rt) + + for _, job := range jobs { + switch resource.Name { + case "z-server": + job.Status = oapi.Failure // Should come first despite "z" name + job.CreatedAt = baseTime.Add(time.Duration(timestampIndex) * time.Millisecond) + case "a-server": + job.Status = oapi.InProgress + job.CreatedAt = baseTime.Add(time.Duration(timestampIndex) * time.Millisecond) + case "m-server": + job.Status = oapi.Successful + job.CreatedAt = baseTime.Add(time.Duration(timestampIndex) * time.Millisecond) + } + timestampIndex++ + } + } + + // Get environment + var env *oapi.Environment + for e := range ws.Environments().IterBuffered() { + env = e.Val + break + } + if env == nil { + t.Fatal("no environment found") + } + + deployment, _ := ws.Deployments().Get(deploymentId) + + // Build full release targets list similar to what the endpoint does + fullTargets := []*fullReleaseTarget{} + for _, rt := range releaseTargets { + resource, _ := ws.Resources().Get(rt.ResourceId) + jobs := ws.Jobs().GetJobsForReleaseTarget(rt) + + // Convert jobs map to sorted slice (most recent first) + jobSlice := make([]*oapi.Job, 0, len(jobs)) + for _, job := range jobs { + jobSlice = append(jobSlice, job) + } + + fullTargets = append(fullTargets, getFullReleaseTarget( + rt, jobSlice, env, deployment, resource, + )) + } + + // This is the comparator from the actual implementation + compareReleaseTargets := func(a, b *fullReleaseTarget) int { + var statusA *oapi.JobStatus + var statusB *oapi.JobStatus + + if len(a.Jobs) > 0 { + statusA = &a.Jobs[0].Status + } + if len(b.Jobs) > 0 { + statusB = &b.Jobs[0].Status + } + + if statusA == nil && statusB != nil { + return 1 + } + if statusA != nil && statusB == nil { + return -1 + } + + if statusA != nil && statusB != nil { + if *statusA == oapi.Failure && *statusB != oapi.Failure { + return -1 + } + if *statusA != oapi.Failure && *statusB == oapi.Failure { + return 1 + } + + if *statusA != *statusB { + if string(*statusA) < string(*statusB) { + return -1 + } + return 1 + } + } + + var createdAtA, createdAtB int64 + if len(a.Jobs) > 0 { + createdAtA = a.Jobs[0].CreatedAt.Unix() + } + if len(b.Jobs) > 0 { + createdAtB = b.Jobs[0].CreatedAt.Unix() + } + + if createdAtA != createdAtB { + return int(createdAtB - createdAtA) + } + + if a.Resource.Name < b.Resource.Name { + return -1 + } else if a.Resource.Name > b.Resource.Name { + return 1 + } + return 0 + } + + // Sort using the comparator + sort.Slice(fullTargets, func(i, j int) bool { + return compareReleaseTargets(fullTargets[i], fullTargets[j]) < 0 + }) + + // Verify sorting: failure should be first + if len(fullTargets) > 0 && len(fullTargets[0].Jobs) > 0 { + firstStatus := fullTargets[0].Jobs[0].Status + if firstStatus != oapi.Failure { + t.Errorf("expected first target to have failure status, got %s (resource: %s)", + firstStatus, fullTargets[0].Resource.Name) + } + if fullTargets[0].Resource.Name != "z-server" { + t.Errorf("expected z-server to be first due to failure status, got %s", + fullTargets[0].Resource.Name) + } + } +} + +func TestEngine_DeploymentVersionJobsList_MultipleEnvironments(t *testing.T) { + jobAgentId := uuid.New().String() + deploymentId := uuid.New().String() + versionId := uuid.New().String() + + engine := integration.NewTestWorkspace(t, + integration.WithJobAgent(integration.JobAgentID(jobAgentId)), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentId), + integration.DeploymentName("api-service"), + integration.DeploymentJobAgent(jobAgentId), + integration.WithDeploymentVersion(integration.DeploymentVersionID(versionId)), + ), + integration.WithEnvironment(integration.EnvironmentName("staging")), + integration.WithEnvironment(integration.EnvironmentName("production")), + integration.WithEnvironment(integration.EnvironmentName("development")), + ), + integration.WithResource(integration.ResourceName("server-1")), + integration.WithResource(integration.ResourceName("server-2")), + ) + + ctx := context.Background() + ws := engine.Workspace() + + // Wait for jobs to be created + time.Sleep(500 * time.Millisecond) + + // Verify release targets (3 environments * 2 resources = 6 targets) + releaseTargets, err := ws.ReleaseTargets().Items(ctx) + if err != nil { + t.Fatalf("failed to get release targets: %v", err) + } + if len(releaseTargets) != 6 { + t.Fatalf("expected 6 release targets, got %d", len(releaseTargets)) + } + + // Group by environment + envTargets := make(map[string][]*oapi.ReleaseTarget) + for _, rt := range releaseTargets { + envTargets[rt.EnvironmentId] = append(envTargets[rt.EnvironmentId], rt) + } + + // Verify each environment has 2 targets + if len(envTargets) != 3 { + t.Fatalf("expected 3 environments, got %d", len(envTargets)) + } + + for envId, targets := range envTargets { + if len(targets) != 2 { + env, _ := ws.Environments().Get(envId) + t.Errorf("environment %s expected 2 targets, got %d", env.Name, len(targets)) + } + } + + // Verify each target has jobs + jobCount := 0 + for _, rt := range releaseTargets { + jobs := ws.Jobs().GetJobsForReleaseTarget(rt) + jobCount += len(jobs) + } + + if jobCount != 6 { + t.Errorf("expected 6 jobs total (one per target), got %d", jobCount) + } +} + +func TestEngine_DeploymentVersionJobsList_EnvironmentSorting(t *testing.T) { + jobAgentId := uuid.New().String() + deploymentId := uuid.New().String() + versionId := uuid.New().String() + + engine := integration.NewTestWorkspace(t, + integration.WithJobAgent(integration.JobAgentID(jobAgentId)), + integration.WithSystem( + integration.SystemName("test-system"), + integration.WithDeployment( + integration.DeploymentID(deploymentId), + integration.DeploymentName("api-service"), + integration.DeploymentJobAgent(jobAgentId), + integration.WithDeploymentVersion(integration.DeploymentVersionID(versionId)), + ), + // Create environments in non-alphabetical order + integration.WithEnvironment(integration.EnvironmentName("zebra")), + integration.WithEnvironment(integration.EnvironmentName("alpha")), + integration.WithEnvironment(integration.EnvironmentName("delta")), + ), + ) + + ws := engine.Workspace() + deployment, _ := ws.Deployments().Get(deploymentId) + + // Get all environments for this system + environments := []*oapi.Environment{} + for env := range ws.Environments().IterBuffered() { + if env.Val.SystemId == deployment.SystemId { + environments = append(environments, env.Val) + } + } + + if len(environments) != 3 { + t.Fatalf("expected 3 environments, got %d", len(environments)) + } + + // Sort environments by name (as the endpoint should do) + sort.Slice(environments, func(i, j int) bool { + return environments[i].Name < environments[j].Name + }) + + // Verify order is alphabetical + expectedOrder := []string{"alpha", "delta", "zebra"} + for i, env := range environments { + if env.Name != expectedOrder[i] { + t.Errorf("expected environment %d to be %s, got %s", i, expectedOrder[i], env.Name) + } + } +} + +// Test to verify the exact comparator behavior matches TypeScript +func TestEngine_DeploymentVersionJobsList_ComparatorBehavior(t *testing.T) { + now := time.Now() + oneHourAgo := now.Add(-1 * time.Hour) + + testCases := []struct { + name string + aStatus *oapi.JobStatus + aCreatedAt *time.Time + aResourceName string + bStatus *oapi.JobStatus + bCreatedAt *time.Time + bResourceName string + expectedResult string // "ab", or "a==b" + }{ + { + name: "no jobs vs has jobs", + aStatus: nil, + bStatus: &[]oapi.JobStatus{oapi.Pending}[0], + bCreatedAt: &now, + bResourceName: "b", + aResourceName: "a", + expectedResult: "a>b", // a should come after b + }, + { + name: "failure vs success", + aStatus: &[]oapi.JobStatus{oapi.Failure}[0], + aCreatedAt: &now, + aResourceName: "a", + bStatus: &[]oapi.JobStatus{oapi.Successful}[0], + bCreatedAt: &now, + bResourceName: "b", + expectedResult: "a 0 { + actualResult = "a>b" + } else { + actualResult = "a==b" + } + + if actualResult != tc.expectedResult { + t.Errorf("expected %s, got %s", tc.expectedResult, actualResult) + } + }) + } +} + +// Helper function that mimics the actual compareReleaseTargets logic for testing +func compareForTest(a, b *fullReleaseTarget) int { + var statusA *oapi.JobStatus + var statusB *oapi.JobStatus + + if len(a.Jobs) > 0 { + statusA = &a.Jobs[0].Status + } + if len(b.Jobs) > 0 { + statusB = &b.Jobs[0].Status + } + + if statusA == nil && statusB != nil { + return 1 + } + if statusA != nil && statusB == nil { + return -1 + } + + if statusA != nil && statusB != nil { + if *statusA == oapi.Failure && *statusB != oapi.Failure { + return -1 + } + if *statusA != oapi.Failure && *statusB == oapi.Failure { + return 1 + } + + if *statusA != *statusB { + if string(*statusA) < string(*statusB) { + return -1 + } + return 1 + } + } + + var createdAtA, createdAtB int64 + if len(a.Jobs) > 0 { + createdAtA = a.Jobs[0].CreatedAt.Unix() + } + if len(b.Jobs) > 0 { + createdAtB = b.Jobs[0].CreatedAt.Unix() + } + + if createdAtA != createdAtB { + return int(createdAtB - createdAtA) + } + + if a.Resource.Name < b.Resource.Name { + return -1 + } else if a.Resource.Name > b.Resource.Name { + return 1 + } + return 0 +} + +func init() { + // Suppress reflect warnings in tests + _ = reflect.TypeOf(fullReleaseTarget{}) +} diff --git a/packages/workspace-engine-sdk/src/schema.ts b/packages/workspace-engine-sdk/src/schema.ts index 3cf6eba2d..514505aaf 100644 --- a/packages/workspace-engine-sdk/src/schema.ts +++ b/packages/workspace-engine-sdk/src/schema.ts @@ -24,6 +24,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/deployment-versions/{versionId}/jobs-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get deployment version jobs list + * @description Returns jobs grouped by environment and release target for a deployment version. + */ + get: operations["getDeploymentVersionJobsList"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get deployment + * @description Returns a specific deployment by ID. + */ + get: operations["getDeployment"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/resources": { parameters: { query?: never; @@ -44,7 +84,7 @@ export interface paths { patch?: never; trace?: never; }; - "/v1/workspaces/{workspaceId}/entities/{entityType}/{entityId}/relationships": { + "/v1/workspaces/{workspaceId}/entities/{relatableEntityType}/{entityId}/relationships": { parameters: { query?: never; header?: never; @@ -64,6 +104,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/environments/{environmentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get environment + * @description Returns a specific environment by ID. + */ + get: operations["getEnvironment"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/environments/{environmentId}/resources": { parameters: { query?: never; @@ -84,6 +144,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/policies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List policies + * @description Returns a list of policies for workspace {workspaceId}. + */ + get: operations["listPolicies"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/policies/{policyId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get policy + * @description Returns a specific policy by ID. + */ + get: operations["getPolicy"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/policies/{policyId}/release-targets": { parameters: { query?: never; @@ -144,6 +244,66 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/resources/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Query resources with CEL expression + * @description Returns resources that match the provided CEL expression. Use the "resource" variable in your expression to access resource properties. + */ + post: operations["queryResources"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/resources/{resourceIdentifier}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get resource by identifier + * @description Returns a specific resource by its identifier. + */ + get: operations["getResourceByIdentifier"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/systems/{systemId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get system + * @description Returns a specific system by ID. + */ + get: operations["getSystem"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -469,7 +629,7 @@ export interface components { responses: never; parameters: { /** @description Type of the entity (deployment, environment, or resource) */ - entityType: components["schemas"]["RelatableEntityType"]; + relatableEntityType: components["schemas"]["RelatableEntityType"]; }; requestBodies: never; headers: never; @@ -499,6 +659,113 @@ export interface operations { }; }; }; + getDeploymentVersionJobsList: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the deployment version */ + versionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Jobs list grouped by environment and release target */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + environment: components["schemas"]["Environment"]; + releaseTargets: { + deployment?: components["schemas"]["Deployment"]; + deploymentId?: string; + environment?: components["schemas"]["Environment"]; + environmentId?: string; + id?: string; + jobs?: { + /** Format: date-time */ + createdAt: string; + externalId?: string; + id: string; + links?: { + [key: string]: string; + }; + status: components["schemas"]["JobStatus"]; + }[]; + resource?: components["schemas"]["Resource"]; + resourceId?: string; + }[]; + }[]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getDeployment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the deployment */ + deploymentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The requested deployment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Deployment"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getDeploymentResources: { parameters: { query?: never; @@ -543,7 +810,7 @@ export interface operations { /** @description ID of the workspace */ workspaceId: string; /** @description Type of the entity (deployment, environment, or resource) */ - entityType: "deployment" | "environment" | "resource"; + relatableEntityType: components["parameters"]["relatableEntityType"]; /** @description ID of the entity */ entityId: string; }; @@ -584,6 +851,49 @@ export interface operations { }; }; }; + getEnvironment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the environment */ + environmentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The requested environment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Environment"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getEnvironmentResources: { parameters: { query?: never; @@ -620,6 +930,83 @@ export interface operations { }; }; }; + listPolicies: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A list of policies */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + policies?: components["schemas"]["Policy"][]; + }; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getPolicy: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the policy */ + policyId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The requested policy */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Policy"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getReleaseTargetsForPolicy: { parameters: { query?: never; @@ -731,4 +1118,128 @@ export interface operations { }; }; }; + queryResources: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Selector"]; + }; + }; + responses: { + /** @description List of matching resources */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + resources?: components["schemas"]["Resource"][]; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getResourceByIdentifier: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description Identifier of the resource */ + resourceIdentifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The requested resource */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Resource"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getSystem: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the system */ + systemId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The requested system */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["System"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; }