diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/CreateRelease.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/CreateRelease.tsx index 58d7cee6..6701dd57 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/CreateRelease.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/CreateRelease.tsx @@ -59,7 +59,10 @@ const releaseForm = z.object({ systemId: z.string().uuid(), deploymentId: z.string().uuid(), version: z.string().min(1).max(255), - releaseDependencies: z.array(releaseDependency), + releaseDependencies: z.array(releaseDependency).refine((deps) => { + const deploymentIds = deps.map((d) => d.deploymentId); + return new Set(deploymentIds).size === deploymentIds.length; + }, "Cannot reuse a deployment in multiple release dependencies"), }); export const CreateReleaseDialog: React.FC<{ @@ -129,10 +132,7 @@ export const CreateReleaseDialog: React.FC<{ numOfReleaseJobTriggers === 0 ? `No targets to deploy release too.` : `Dispatching ${release.releaseJobTriggers.length} job configuration${release.releaseJobTriggers.length > 1 ? "s" : ""}.`, - { - dismissible: true, - duration: 2_000, - }, + { dismissible: true, duration: 2_000 }, ); props.onClose?.(); @@ -144,6 +144,8 @@ export const CreateReleaseDialog: React.FC<{ name: "releaseDependencies", }); + const formErrors = form.formState.errors.releaseDependencies ?? null; + return ( {children} @@ -239,31 +241,36 @@ export const CreateReleaseDialog: React.FC<{ {fields.map((_, index) => ( -
+
( - + + + )} /> @@ -273,19 +280,21 @@ export const CreateReleaseDialog: React.FC<{ name={`releaseDependencies.${index}.releaseFilter`} render={({ field: { value, onChange } }) => ( - - - + + + + + )} /> @@ -319,6 +328,12 @@ export const CreateReleaseDialog: React.FC<{
+ {formErrors?.root?.message && ( +
+ {formErrors.root.message} +
+ )} + diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/relationships/RelationshipsDiagramDependencies.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/relationships/RelationshipsDiagramDependencies.tsx index ac72a0e2..428cf72f 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/relationships/RelationshipsDiagramDependencies.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/relationships/RelationshipsDiagramDependencies.tsx @@ -271,6 +271,7 @@ export const TargetDiagramDependencies: React.FC<{ relationships: Array; targets: Array; releaseDependencies: (schema.ReleaseDependency & { + deploymentName: string; target?: string; })[]; }> = ({ targetId, relationships, targets, releaseDependencies }) => { @@ -385,12 +386,12 @@ export const TargetDiagramDependencies: React.FC<{ }} > - + {releaseDependencies.map((rd) => ( - {rd.deploymentId} + {rd.deploymentName} ))} diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/relationships/RelationshipsDiagram.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/relationships/RelationshipsDiagram.tsx index a57f2226..05e1742b 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/relationships/RelationshipsDiagram.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/relationships/RelationshipsDiagram.tsx @@ -237,15 +237,13 @@ const TargetDiagram: React.FC<{ })), ); const [edges, __, onEdgesChange] = useEdgesState( - relationships.map((t) => { - return { - id: `${t.sourceId}-${t.targetId}`, - source: t.sourceId, - target: t.targetId, - markerEnd: { type: MarkerType.Arrow, color: colors.neutral[700] }, - style: { stroke: colors.neutral[700] }, - }; - }), + relationships.map((t) => ({ + id: `${t.sourceId}-${t.targetId}`, + source: t.sourceId, + target: t.targetId, + markerEnd: { type: MarkerType.Arrow, color: colors.neutral[700] }, + style: { stroke: colors.neutral[700] }, + })), ); const onLayout = useOnLayout(); diff --git a/apps/webservice/src/app/api/v1/workspaces/[workspaceId]/targets/route.ts b/apps/webservice/src/app/api/v1/workspaces/[workspaceId]/targets/route.ts index 8fbcf4cb..fefb80fb 100644 --- a/apps/webservice/src/app/api/v1/workspaces/[workspaceId]/targets/route.ts +++ b/apps/webservice/src/app/api/v1/workspaces/[workspaceId]/targets/route.ts @@ -128,7 +128,7 @@ const bodySchema = z.array( ) .optional() .refine( - (vars) => new Set(vars?.map((v) => v.key)).size === vars?.length, + (vars) => vars?.length ?? 0 === new Set(vars?.map((v) => v.key)).size, "Duplicate variable keys are not allowed", ), }), diff --git a/packages/api/src/router/job.ts b/packages/api/src/router/job.ts index 525b5239..5ccb5dac 100644 --- a/packages/api/src/router/job.ts +++ b/packages/api/src/router/job.ts @@ -83,6 +83,7 @@ const processReleaseJobTriggerWithAdditionalDataRows = ( environment_policy_release_window: EnvironmentPolicyReleaseWindow | null; user?: User | null; release_dependency?: ReleaseDependency | null; + deployment_name?: { deploymentName: string; deploymentId: string } | null; }>, ) => _.chain(rows) @@ -99,7 +100,16 @@ const processReleaseJobTriggerWithAdditionalDataRows = ( target: v[0]!.target, release: { ...v[0]!.release, deployment: v[0]!.deployment }, environment: v[0]!.environment, - releaseDependencies: v.map((r) => r.release_dependency).filter(isPresent), + releaseDependencies: v + .map((r) => + r.release_dependency != null + ? { + ...r.release_dependency, + deploymentName: r.deployment_name!.deploymentName, + } + : null, + ) + .filter(isPresent), rolloutDate: v[0]!.environment_policy != null ? rolloutDateFromReleaseJobTrigger( @@ -294,6 +304,14 @@ const releaseJobTriggerRouter = createTRPCRouter({ canUser.perform(Permission.JobGet).on({ type: "job", id: input }), }) .query(async ({ ctx, input }) => { + const deploymentName = ctx.db + .select({ + deploymentName: deployment.name, + deploymentId: deployment.id, + }) + .from(deployment) + .as("deployment_name"); + const data = await releaseJobTriggerQuery(ctx.db) .leftJoin(user, eq(releaseJobTrigger.causedById, user.id)) .leftJoin(jobMetadata, eq(jobMetadata.jobId, job.id)) @@ -309,6 +327,10 @@ const releaseJobTriggerRouter = createTRPCRouter({ releaseDependency, eq(releaseDependency.releaseId, release.id), ) + .leftJoin( + deploymentName, + eq(deploymentName.deploymentId, releaseDependency.deploymentId), + ) .where(eq(job.id, input)) .then(processReleaseJobTriggerWithAdditionalDataRows) .then(takeFirst); diff --git a/packages/job-dispatch/src/index.ts b/packages/job-dispatch/src/index.ts index a46e9927..34b09a37 100644 --- a/packages/job-dispatch/src/index.ts +++ b/packages/job-dispatch/src/index.ts @@ -17,6 +17,7 @@ export * from "./policies/gradual-rollout.js"; export * from "./policies/manual-approval.js"; export * from "./policies/release-sequencing.js"; export * from "./policies/success-rate-criteria-passing.js"; +export * from "./policies/release-dependency.js"; export * from "./policies/release-string-check.js"; export * from "./policies/concurrency-policy.js"; export * from "./policies/release-window.js"; diff --git a/packages/job-dispatch/src/new-target.ts b/packages/job-dispatch/src/new-target.ts index 158576ac..e7f910a1 100644 --- a/packages/job-dispatch/src/new-target.ts +++ b/packages/job-dispatch/src/new-target.ts @@ -3,6 +3,7 @@ import type { Tx } from "@ctrlplane/db"; import { dispatchReleaseJobTriggers } from "./job-dispatch.js"; import { isPassingLockingPolicy } from "./lock-checker.js"; import { isPassingApprovalPolicy } from "./policies/manual-approval.js"; +import { isPassingReleaseDependencyPolicy } from "./policies/release-dependency.js"; import { createReleaseJobTriggers } from "./release-job-trigger.js"; /** @@ -20,7 +21,11 @@ export async function dispatchJobsForNewTargets( if (releaseJobTriggers.length === 0) return; await dispatchReleaseJobTriggers(db) - .filter(isPassingLockingPolicy, isPassingApprovalPolicy) + .filter( + isPassingLockingPolicy, + isPassingApprovalPolicy, + isPassingReleaseDependencyPolicy, + ) .releaseTriggers(releaseJobTriggers) .dispatch(); }