diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index 354f18e65..3cf1da2e5 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -482,6 +482,7 @@ "JsonSelector": { "properties": { "json": { + "additionalProperties": true, "type": "object" } }, @@ -1214,7 +1215,7 @@ "id": { "type": "string" }, - "links": { + "metadata": { "additionalProperties": { "type": "string" }, @@ -1227,7 +1228,8 @@ "required": [ "id", "createdAt", - "status" + "status", + "metadata" ], "type": "object" }, @@ -1240,6 +1242,16 @@ "type": "string" } }, + "required": [ + "id", + "resourceId", + "environmentId", + "deploymentId", + "environment", + "deployment", + "resource", + "jobs" + ], "type": "object" }, "type": "array" diff --git a/apps/workspace-engine/oapi/spec/paths/deployment-version.jsonnet b/apps/workspace-engine/oapi/spec/paths/deployment-version.jsonnet index dd395fd71..cd63a66d0 100644 --- a/apps/workspace-engine/oapi/spec/paths/deployment-version.jsonnet +++ b/apps/workspace-engine/oapi/spec/paths/deployment-version.jsonnet @@ -29,6 +29,7 @@ local openapi = import '../lib/openapi.libsonnet'; type: 'array', items: { type: 'object', + required: ['id', 'resourceId', 'environmentId', 'deploymentId', 'environment', 'deployment', 'resource', 'jobs'], properties: { // ReleaseTarget fields (flattened) id: { type: 'string' }, @@ -44,13 +45,13 @@ local openapi = import '../lib/openapi.libsonnet'; type: 'array', items: { type: 'object', - required: ['id', 'createdAt', 'status'], + required: ['id', 'createdAt', 'status', 'metadata'], properties: { id: { type: 'string' }, createdAt: { type: 'string', format: 'date-time' }, status: openapi.schemaRef('JobStatus'), externalId: { type: 'string' }, - links: { + metadata: { type: 'object', additionalProperties: { type: 'string' }, }, diff --git a/apps/workspace-engine/oapi/spec/schemas/core.jsonnet b/apps/workspace-engine/oapi/spec/schemas/core.jsonnet index 18773d563..779c7b7b9 100644 --- a/apps/workspace-engine/oapi/spec/schemas/core.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/core.jsonnet @@ -6,7 +6,7 @@ local openapi = import '../lib/openapi.libsonnet'; type: 'object', required: ['json'], properties: { - json: { type: 'object' }, + json: { type: 'object', additionalProperties: true }, }, }, diff --git a/packages/api/src/router/deployment-version-jobs-list.ts b/packages/api/src/router/deployment-version-jobs-list.ts index 1932d81ba..9d4bc1e47 100644 --- a/packages/api/src/router/deployment-version-jobs-list.ts +++ b/packages/api/src/router/deployment-version-jobs-list.ts @@ -1,207 +1,138 @@ import type { Tx } from "@ctrlplane/db"; -import _ from "lodash"; -import { isPresent } from "ts-is-present"; +import type { ResourceCondition } from "@ctrlplane/validators/resources"; +import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk"; import { z } from "zod"; -import { and, eq, isNull, or, takeFirst } from "@ctrlplane/db"; +import { eq, takeFirst } from "@ctrlplane/db"; import * as SCHEMA from "@ctrlplane/db/schema"; -import { getRolloutInfoForReleaseTarget } from "@ctrlplane/rule-engine"; -import { getApplicablePoliciesWithoutResourceScope } from "@ctrlplane/rule-engine/db"; +import { logger } from "@ctrlplane/logger"; import { Permission } from "@ctrlplane/validators/auth"; import { ReservedMetadataKey } from "@ctrlplane/validators/conditions"; -import { JobStatus } from "@ctrlplane/validators/jobs"; import { protectedProcedure } from "../trpc"; +import { getWorkspaceEngineClient } from "../workspace-engine-client"; -const releaseTargetsComparator = ( - a: { - jobs: { status: SCHEMA.JobStatus; createdAt: Date }[]; - resource: { name: string }; - rolloutTime?: Date; - }, - b: { - jobs: { status: SCHEMA.JobStatus; createdAt: Date }[]; - resource: { name: string }; - rolloutTime?: Date; - }, -) => { - const statusA = a.jobs.at(0)?.status; - const statusB = b.jobs.at(0)?.status; - - if (statusA == null && statusB != null) return 1; - if (statusA != null && statusB == null) return -1; - - if (statusA === JobStatus.Failure && statusB !== JobStatus.Failure) return -1; - if (statusA !== JobStatus.Failure && statusB === JobStatus.Failure) return 1; - - if (statusA != null && statusB != null && statusA !== statusB) - return statusA.localeCompare(statusB); - - const createdAtA = a.jobs.at(0)?.createdAt ?? new Date(0); - const createdAtB = b.jobs.at(0)?.createdAt ?? new Date(0); - - if (createdAtA.getTime() !== createdAtB.getTime()) - return createdAtB.getTime() - createdAtA.getTime(); - - const rolloutTimeA = a.rolloutTime ?? new Date(0); - const rolloutTimeB = b.rolloutTime ?? new Date(0); - if (rolloutTimeA.getTime() !== rolloutTimeB.getTime()) - return rolloutTimeA.getTime() - rolloutTimeB.getTime(); - - return a.resource.name.localeCompare(b.resource.name); -}; - -const getVersion = (db: Tx, versionId: string) => - db +const getWorkspaceId = async (tx: Tx, versionId: string) => + tx .select() .from(SCHEMA.deploymentVersion) - .where(eq(SCHEMA.deploymentVersion.id, versionId)) - .then(takeFirst); - -const getVersionSubquery = (db: Tx, versionId: string) => - db - .select({ - jobId: SCHEMA.job.id, - jobCreatedAt: SCHEMA.job.createdAt, - jobStatus: SCHEMA.job.status, - jobExternalId: SCHEMA.job.externalId, - jobMetadataKey: SCHEMA.jobMetadata.key, - jobMetadataValue: SCHEMA.jobMetadata.value, - releaseTargetId: SCHEMA.versionRelease.releaseTargetId, - }) - .from(SCHEMA.versionRelease) - .innerJoin( - SCHEMA.release, - eq(SCHEMA.release.versionReleaseId, SCHEMA.versionRelease.id), - ) - .innerJoin( - SCHEMA.releaseJob, - eq(SCHEMA.releaseJob.releaseId, SCHEMA.release.id), - ) - .innerJoin(SCHEMA.job, eq(SCHEMA.releaseJob.jobId, SCHEMA.job.id)) - .leftJoin(SCHEMA.jobMetadata, eq(SCHEMA.jobMetadata.jobId, SCHEMA.job.id)) - .where( - and( - eq(SCHEMA.versionRelease.versionId, versionId), - or( - eq(SCHEMA.jobMetadata.key, ReservedMetadataKey.Links), - isNull(SCHEMA.jobMetadata.key), - ), - ), - ) - .as("version_subquery"); - -const getReleaseTargets = (db: Tx, version: SCHEMA.DeploymentVersion) => { - const versionSubquery = getVersionSubquery(db, version.id); - - return db - .select() - .from(SCHEMA.releaseTarget) - .innerJoin( - SCHEMA.environment, - eq(SCHEMA.releaseTarget.environmentId, SCHEMA.environment.id), - ) .innerJoin( SCHEMA.deployment, - eq(SCHEMA.releaseTarget.deploymentId, SCHEMA.deployment.id), - ) - .innerJoin( - SCHEMA.resource, - eq(SCHEMA.resource.id, SCHEMA.releaseTarget.resourceId), + eq(SCHEMA.deploymentVersion.deploymentId, SCHEMA.deployment.id), ) - .leftJoin( - versionSubquery, - eq(versionSubquery.releaseTargetId, SCHEMA.releaseTarget.id), - ) - .where(and(eq(SCHEMA.releaseTarget.deploymentId, version.deploymentId))); + .innerJoin(SCHEMA.system, eq(SCHEMA.deployment.systemId, SCHEMA.system.id)) + .where(eq(SCHEMA.deploymentVersion.id, versionId)) + .then(takeFirst) + .then(({ system }) => system.workspaceId); + +type Job = { + createdAt: Date; + externalId: string | null; + id: string; + links: Record; + status: SCHEMA.JobStatus; }; -type ReleaseTarget = Awaited>[number]; - -const getTargetsGroupedByEnvironment = ( - db: Tx, - releaseTargets: ReleaseTarget[], -) => - _.chain(releaseTargets) - .groupBy((row) => row.release_target.id) - .map((rowsByTarget) => { - const releaseTarget = rowsByTarget[0]!.release_target; - const { environment, deployment, resource } = rowsByTarget[0]!; - - const jobs = rowsByTarget - .map((row) => { - const { version_subquery } = row; - if (version_subquery == null) return null; - - const { jobMetadataValue } = version_subquery; - const links = - jobMetadataValue == null - ? ({} as Record) - : (JSON.parse(jobMetadataValue) as Record); - return { - id: version_subquery.jobId, - createdAt: version_subquery.jobCreatedAt, - status: version_subquery.jobStatus, - externalId: version_subquery.jobExternalId, - links, - }; - }) - .filter(isPresent) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - return { ...releaseTarget, jobs, environment, deployment, resource }; - }) - .groupBy((rt) => rt.environment.id) - .values() - .value(); - -type GroupedTargetsByEnvironment = Awaited< - ReturnType ->[number]; +type DeploymentVersionJobsListResponse = { + environment: SCHEMA.Environment; + releaseTargets: { + deployment: SCHEMA.Deployment; + environment: SCHEMA.Environment; + resource: SCHEMA.Resource; + id: string; + resourceId: string; + environmentId: string; + deploymentId: string; + desiredReleaseId: string | null; + desiredVersionId: string | null; + jobs: Job[]; + }[]; +}[]; + +const convertOapiSelectorToResourceCondition = ( + selector?: WorkspaceEngine["schemas"]["Selector"], +): ResourceCondition | null => { + if (selector == null) return null; + if ("json" in selector) return selector.json as ResourceCondition; + return null; +}; -const getEnvironmentWithSortedTargets = async ( - db: Tx, - targetsByEnvironment: GroupedTargetsByEnvironment, - version: SCHEMA.DeploymentVersion, -) => { - const { environment, deployment } = targetsByEnvironment[0]!; - const policies = await getApplicablePoliciesWithoutResourceScope( - db, - environment.id, - deployment.id, - ); - const rolloutPolicy = policies.find( - (p) => p.environmentVersionRollout != null, - ); - if (rolloutPolicy == null) { - const sortedReleaseTargets = targetsByEnvironment.sort( - releaseTargetsComparator, - ); - return { environment, releaseTargets: sortedReleaseTargets }; +const getJobLinks = (metadata: Record) => { + const linksStr = metadata[ReservedMetadataKey.Links] ?? "{}"; + + try { + const links = JSON.parse(linksStr) as Record; + return links; + } catch (error) { + logger.error("Error parsing job links", { + error, + metadata, + }); + return {}; } +}; - const releaseTargetsWithRolloutInfoPromises = targetsByEnvironment.map( - async (target) => { - const rolloutInfo = await getRolloutInfoForReleaseTarget( - db, - target, - rolloutPolicy, - version, - ); - const rolloutTime = rolloutInfo.rolloutTime ?? undefined; - return { ...target, rolloutTime }; - }, - ); - - const releaseTargetsWithRolloutInfo = await Promise.all( - releaseTargetsWithRolloutInfoPromises, - ); - - const sortedReleaseTargets = releaseTargetsWithRolloutInfo.sort( - releaseTargetsComparator, - ); - - return { environment, releaseTargets: sortedReleaseTargets }; +const convertOapiEnvironmentToSchema = ( + environment: WorkspaceEngine["schemas"]["Environment"], +): SCHEMA.Environment => ({ + ...environment, + directory: "", + description: environment.description ?? null, + createdAt: new Date(environment.createdAt), + resourceSelector: convertOapiSelectorToResourceCondition( + environment.resourceSelector, + ), +}); + +const convertOapiDeploymentToSchema = ( + deployment: WorkspaceEngine["schemas"]["Deployment"], +): SCHEMA.Deployment => ({ + ...deployment, + resourceSelector: convertOapiSelectorToResourceCondition( + deployment.resourceSelector, + ), + description: deployment.description ?? "", + jobAgentId: deployment.jobAgentId ?? null, + retryCount: 0, + timeout: null, +}); + +const convertOapiResourceToSchema = ( + resource: WorkspaceEngine["schemas"]["Resource"], +): SCHEMA.Resource => ({ + ...resource, + providerId: resource.providerId ?? null, + createdAt: new Date(resource.createdAt), + deletedAt: resource.deletedAt ? new Date(resource.deletedAt) : null, + lockedAt: resource.lockedAt ? new Date(resource.lockedAt) : null, + updatedAt: resource.updatedAt ? new Date(resource.updatedAt) : null, +}); + +const convertOapiJobStatusToSchema = ( + status: WorkspaceEngine["schemas"]["JobStatus"], +): SCHEMA.JobStatus => { + switch (status) { + case "pending": + return "pending"; + case "inProgress": + return "in_progress"; + case "successful": + return "successful"; + case "cancelled": + return "cancelled"; + case "skipped": + return "skipped"; + case "failure": + return "failure"; + case "actionRequired": + return "action_required"; + case "invalidJobAgent": + return "invalid_job_agent"; + case "invalidIntegration": + return "invalid_integration"; + case "externalRunNotFound": + return "external_run_not_found"; + } }; export const deploymentVersionJobsList = protectedProcedure @@ -218,19 +149,43 @@ export const deploymentVersionJobsList = protectedProcedure id: input.versionId, }), }) - .query(async ({ ctx, input: { versionId } }) => { - const version = await getVersion(ctx.db, versionId); - const releaseTargets = await getReleaseTargets(ctx.db, version); - const targetsByEnvironment = getTargetsGroupedByEnvironment( - ctx.db, - releaseTargets, - ); - const environmentsWithSortedReleaseTargets = await Promise.all( - targetsByEnvironment.map((targetsByEnvironment) => - getEnvironmentWithSortedTargets(ctx.db, targetsByEnvironment, version), - ), - ); - return environmentsWithSortedReleaseTargets.sort((a, b) => - a.environment.name.localeCompare(b.environment.name), - ); - }); + .query( + async ({ ctx, input: { versionId } }) => { + const workspaceId = await getWorkspaceId(ctx.db, versionId); + const client = getWorkspaceEngineClient(); + const resp = await client.GET( + "/v1/workspaces/{workspaceId}/deployment-versions/{versionId}/jobs-list", + { + params: { + path: { + workspaceId, + versionId, + }, + }, + }, + ); + return (resp.data ?? []).map((env) => ({ + environment: convertOapiEnvironmentToSchema(env.environment), + releaseTargets: env.releaseTargets.map((releaseTarget) => ({ + deployment: convertOapiDeploymentToSchema(releaseTarget.deployment), + environment: convertOapiEnvironmentToSchema( + releaseTarget.environment, + ), + resource: convertOapiResourceToSchema(releaseTarget.resource), + id: releaseTarget.id, + resourceId: releaseTarget.resourceId, + environmentId: releaseTarget.environmentId, + deploymentId: releaseTarget.deploymentId, + desiredReleaseId: null, + desiredVersionId: null, + jobs: (releaseTarget.jobs ?? []).map((job) => ({ + createdAt: new Date(job.createdAt), + externalId: job.externalId ?? null, + id: job.id, + status: convertOapiJobStatusToSchema(job.status), + links: getJobLinks(job.metadata ?? {}), + })), + })), + })); + }, + ); diff --git a/packages/workspace-engine-sdk/src/schema.ts b/packages/workspace-engine-sdk/src/schema.ts index 4af53cfee..008d23b20 100644 --- a/packages/workspace-engine-sdk/src/schema.ts +++ b/packages/workspace-engine-sdk/src/schema.ts @@ -381,6 +381,28 @@ export interface components { resourceSelector?: components["schemas"]["Selector"]; systemId: string; }; + EnvironmentProgressionRule: { + dependsOnEnvironmentSelector: components["schemas"]["Selector"]; + id: string; + /** + * Format: int32 + * @description Maximum age of dependency deployment before blocking progression (prevents stale promotions) + */ + maximumAgeHours?: number; + /** + * Format: int32 + * @description Minimum time to wait after the depends on environment is in a success state before the current environment can be deployed + * @default 0 + */ + minimumSockTimeMinutes: number; + /** + * Format: float + * @default 100 + */ + minimumSuccessPercentage: number; + policyId: string; + successStatuses?: components["schemas"]["JobStatus"][]; + }; ErrorResponse: { /** @example Workspace not found */ error?: string; @@ -456,7 +478,9 @@ export interface components { job: components["schemas"]["Job"]; } & (unknown | unknown); JsonSelector: { - json: Record; + json: { + [key: string]: unknown; + }; }; LiteralValue: | components["schemas"]["BooleanValue"] @@ -490,6 +514,7 @@ export interface components { PolicyRule: { anyApproval?: components["schemas"]["AnyApprovalRule"]; createdAt: string; + environmentProgression?: components["schemas"]["EnvironmentProgressionRule"]; id: string; policyId: string; }; @@ -704,23 +729,23 @@ export interface operations { "application/json": { environment: components["schemas"]["Environment"]; releaseTargets: { - deployment?: components["schemas"]["Deployment"]; - deploymentId?: string; - environment?: components["schemas"]["Environment"]; - environmentId?: string; - id?: string; - jobs?: { + 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?: { + metadata: { [key: string]: string; }; status: components["schemas"]["JobStatus"]; }[]; - resource?: components["schemas"]["Resource"]; - resourceId?: string; + resource: components["schemas"]["Resource"]; + resourceId: string; }[]; }[]; };