Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type * as SCHEMA from "@ctrlplane/db/schema";
import React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { IconBoltOff, IconCubeOff } from "@tabler/icons-react";
import { IconBoltOff, IconClock, IconCubeOff } from "@tabler/icons-react";
import { useInView } from "react-intersection-observer";

import { Skeleton } from "@ctrlplane/ui/skeleton";
Expand Down Expand Up @@ -47,6 +47,41 @@ const NoReleaseTargetsCell: React.FC<{
</div>
);

const BlockedByActiveJobsCell: React.FC<{
deploymentVersion: { id: string; tag: string };
deployment: { id: string; name: string; slug: string };
system: { slug: string };
}> = ({ deploymentVersion, deployment, system }) => {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();

const deploymentUrl = urls
.workspace(workspaceSlug)
.system(system.slug)
.deployment(deployment.slug)
.releases();

return (
<div className="flex h-full w-full items-center justify-center p-1">
<Link
href={deploymentUrl}
className="flex w-full items-center gap-2 rounded-md p-2"
>
<div className="rounded-full bg-neutral-400 p-1 dark:text-black">
<IconClock className="h-4 w-4" strokeWidth={2} />
</div>
<div className="flex flex-col">
<div className="max-w-36 truncate font-semibold">
{deploymentVersion.tag}
</div>
<div className="text-xs text-muted-foreground">
Waiting on another release
</div>
</div>
</Link>
</div>
);
};

const NoJobAgentCell: React.FC<{
tag: string;
system: { slug: string };
Expand Down Expand Up @@ -95,6 +130,14 @@ const DeploymentVersionEnvironmentCell: React.FC<
deploymentId: deployment.id,
});

const {
data: targetsWithActiveJobs,
isLoading: isTargetsWithActiveJobsLoading,
} = api.releaseTarget.activeJobs.useQuery({
environmentId: environment.id,
deploymentId: deployment.id,
});

const { data: policyEvaluations, isLoading: isPolicyEvaluationsLoading } =
api.policy.evaluate.useQuery({
environmentId: environment.id,
Expand All @@ -108,7 +151,10 @@ const DeploymentVersionEnvironmentCell: React.FC<
});

const isLoading =
isReleaseTargetsLoading || isPolicyEvaluationsLoading || isJobsLoading;
isReleaseTargetsLoading ||
isPolicyEvaluationsLoading ||
isJobsLoading ||
isTargetsWithActiveJobsLoading;
if (isLoading) return <SkeletonCell />;

const hasJobs = jobs != null && jobs.length > 0;
Expand Down Expand Up @@ -149,6 +195,12 @@ const DeploymentVersionEnvironmentCell: React.FC<
/>
);

const allActiveJobs = (targetsWithActiveJobs ?? []).flatMap((t) => t.jobs);
const isWaitingOnActiveJobs = allActiveJobs.some(
({ versionId }) => versionId !== deploymentVersion.id,
);
if (isWaitingOnActiveJobs) return <BlockedByActiveJobsCell {...props} />;

const hasNoJobAgent = deployment.jobAgentId == null;
if (hasNoJobAgent)
return <NoJobAgentCell tag={deploymentVersion.tag} {...props} />;
Expand Down
98 changes: 97 additions & 1 deletion packages/api/src/router/release-target.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import _ from "lodash";
import { isPresent } from "ts-is-present";
import { z } from "zod";

import { and, eq } from "@ctrlplane/db";
import { and, eq, notInArray } from "@ctrlplane/db";
import * as schema from "@ctrlplane/db/schema";
import { Permission } from "@ctrlplane/validators/auth";
import { exitedStatus } from "@ctrlplane/validators/jobs";

import { createTRPCRouter, protectedProcedure } from "../trpc";

Expand Down Expand Up @@ -77,4 +79,98 @@ export const releaseTargetRouter = createTRPCRouter({
limit: 500,
});
}),

activeJobs: protectedProcedure
.input(
z
.object({
resourceId: z.string().uuid().optional(),
environmentId: z.string().uuid().optional(),
deploymentId: z.string().uuid().optional(),
})
.refine(
(data) =>
data.resourceId != null ||
data.environmentId != null ||
data.deploymentId != null,
),
)
.meta({
authorizationCheck: async ({ canUser, input }) => {
const resourceResult =
input.resourceId != null
? await canUser.perform(Permission.ResourceGet).on({
type: "resource",
id: input.resourceId,
})
: true;

const environmentResult =
input.environmentId != null
? await canUser.perform(Permission.EnvironmentGet).on({
type: "environment",
id: input.environmentId,
})
: true;

const deploymentResult =
input.deploymentId != null
? await canUser.perform(Permission.DeploymentGet).on({
type: "deployment",
id: input.deploymentId,
})
: true;

return resourceResult && environmentResult && deploymentResult;
},
})
.query(async ({ ctx, input }) => {
const { resourceId, environmentId, deploymentId } = input;

const activeJobs = await ctx.db
.select()
.from(schema.job)
.innerJoin(
schema.releaseJob,
eq(schema.releaseJob.jobId, schema.job.id),
)
.innerJoin(
schema.release,
eq(schema.releaseJob.releaseId, schema.release.id),
)
.innerJoin(
schema.versionRelease,
eq(schema.release.versionReleaseId, schema.versionRelease.id),
)
.innerJoin(
schema.releaseTarget,
eq(schema.versionRelease.releaseTargetId, schema.releaseTarget.id),
)
.where(
and(
notInArray(schema.job.status, exitedStatus),
resourceId != null
? eq(schema.releaseTarget.resourceId, resourceId)
: undefined,
environmentId != null
? eq(schema.releaseTarget.environmentId, environmentId)
: undefined,
deploymentId != null
? eq(schema.releaseTarget.deploymentId, deploymentId)
: undefined,
),
);

return _.chain(activeJobs)
.groupBy((job) => job.release_target.id)
.map((jobsByTarget) => {
const releaseTarget = jobsByTarget[0]!.release_target;
const jobs = jobsByTarget.map((j) => ({
...j.job,
versionId: j.version_release.versionId,
}));
return { ...releaseTarget, jobs };
})
.value();
}),
});
127 changes: 1 addition & 126 deletions packages/job-dispatch/src/job-creation.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import type { Tx } from "@ctrlplane/db";
import _ from "lodash";

import { and, eq, isNotNull, ne, or, takeFirst } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import { eq, takeFirst } from "@ctrlplane/db";
import * as schema from "@ctrlplane/db/schema";
import { logger } from "@ctrlplane/logger";
import { JobStatus } from "@ctrlplane/validators/jobs";

import { dispatchReleaseJobTriggers } from "./job-dispatch.js";
import { isPassingAllPolicies } from "./policy-checker.js";
import { cancelOldReleaseJobTriggersOnJobDispatch } from "./release-sequencing.js";

export const createTriggeredRunbookJob = async (
db: Tx,
runbook: schema.Runbook,
Expand Down Expand Up @@ -57,123 +52,3 @@ export const createTriggeredRunbookJob = async (

return job;
};

/**
* When a job completes, there may be other jobs that should now be triggered
* because the completion of this job means that some policies are now passing.
*
* criteria requirement - "need n from QA to pass before deploying to staging"
* wait requirement - "in the same environment, need to wait for previous release to be deployed first"
* concurrency requirement - "only n releases in staging at a time"
* version dependency - "need to wait for deployment X version Y to be deployed first"
*
*
* This function looks at the job's release and deployment and finds all the
* other release that should be triggered and dispatches them.
*
* @param je
*/
export const onJobCompletion = async (je: schema.Job) => {
const triggers = await db
.select()
.from(schema.releaseJobTrigger)
.innerJoin(
schema.deploymentVersion,
eq(schema.releaseJobTrigger.versionId, schema.deploymentVersion.id),
)
.innerJoin(
schema.deployment,
eq(schema.deploymentVersion.deploymentId, schema.deployment.id),
)
.innerJoin(
schema.environment,
eq(schema.releaseJobTrigger.environmentId, schema.environment.id),
)
.where(eq(schema.releaseJobTrigger.jobId, je.id))
.then(takeFirst);

const isDependentOnTriggerForCriteria = and(
eq(schema.releaseJobTrigger.versionId, triggers.deployment_version.id),
eq(
schema.environmentPolicyDeployment.environmentId,
triggers.release_job_trigger.environmentId,
),
);

const isWaitingOnConcurrencyRequirementInSameRelease = and(
isNotNull(schema.environmentPolicy.concurrencyLimit),
eq(schema.environmentPolicy.id, triggers.environment.policyId),
eq(
schema.deploymentVersion.deploymentId,
triggers.deployment_version.deploymentId,
),
eq(schema.job.status, JobStatus.Pending),
);

const isDependentOnVersionOfTriggerDeployment = isNotNull(
schema.versionDependency.id,
);

const isWaitingOnJobToFinish = and(
eq(schema.environment.id, triggers.release_job_trigger.environmentId),
eq(schema.deployment.id, triggers.deployment.id),
ne(schema.deploymentVersion.id, triggers.deployment_version.id),
);

const affectedReleaseJobTriggers = await db
.select()
.from(schema.releaseJobTrigger)
.innerJoin(
schema.deploymentVersion,
eq(schema.releaseJobTrigger.versionId, schema.deploymentVersion.id),
)
.innerJoin(
schema.deployment,
eq(schema.deploymentVersion.deploymentId, schema.deployment.id),
)
.innerJoin(schema.job, eq(schema.releaseJobTrigger.jobId, schema.job.id))
.innerJoin(
schema.environment,
eq(schema.releaseJobTrigger.environmentId, schema.environment.id),
)
.leftJoin(
schema.environmentPolicy,
eq(schema.environment.policyId, schema.environmentPolicy.id),
)
.leftJoin(
schema.environmentPolicyDeployment,
eq(
schema.environmentPolicyDeployment.policyId,
schema.environmentPolicy.id,
),
)
.leftJoin(
schema.versionDependency,
and(
eq(
schema.versionDependency.versionId,
schema.releaseJobTrigger.versionId,
),
eq(schema.versionDependency.deploymentId, triggers.deployment.id),
),
)
.where(
and(
eq(schema.job.status, JobStatus.Pending),
or(
isDependentOnTriggerForCriteria,
isWaitingOnJobToFinish,
isWaitingOnConcurrencyRequirementInSameRelease,
isDependentOnVersionOfTriggerDeployment,
),
),
);

await dispatchReleaseJobTriggers(db)
.releaseTriggers(
affectedReleaseJobTriggers.map((t) => t.release_job_trigger),
)
.filter(isPassingAllPolicies)
.then(cancelOldReleaseJobTriggersOnJobDispatch)
.dispatch();
};
Loading
Loading