diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/PolicyEvaluationsCell.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/PolicyEvaluationsCell.tsx index 2c61ad6f0..5315b3d53 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/PolicyEvaluationsCell.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/PolicyEvaluationsCell.tsx @@ -26,6 +26,7 @@ import { ReservedMetadataKey } from "@ctrlplane/validators/conditions"; import { urls } from "~/app/urls"; import { api } from "~/trpc/react"; +import { VersionDependencyBadge } from "./policy-evaluations/VersionDependencyBadge"; type PolicyEvaluation = RouterOutputs["policy"]["evaluate"]["releaseTarget"]; @@ -115,12 +116,12 @@ const getPolicyBlockingByRollout = (policyEvaluations?: PolicyEvaluation) => { const policy = policyEvaluations.policies.find((p) => p.id === policyId); if (policy == null) return null; - if (rolloutTime == null) return { policy, rolloutTime: null, passing: false }; + if (rolloutTime == null) return { policy, rolloutTime: null }; const now = new Date(); - if (isAfter(now, rolloutTime)) return { policy, rolloutTime, passing: true }; + if (isAfter(now, rolloutTime)) return null; - return { policy, rolloutTime, passing: false }; + return { policy, rolloutTime }; }; const getPoliciesBlockingByConcurrency = ( @@ -209,10 +210,20 @@ const BlockingReleaseTargetJobTooltip: React.FC<{ ); }; +const getBlockingVersionDependencies = ( + policyEvaluations?: PolicyEvaluation, +) => { + if (policyEvaluations == null) return []; + const { versionDependency } = policyEvaluations.rules; + return versionDependency.filter((v) => !v.isSatisfied); +}; + export const PolicyEvaluationsCell: React.FC<{ + resource: { id: string; name: string }; releaseTargetId: string; - versionId: string; -}> = ({ releaseTargetId, versionId }) => { + version: { id: string; tag: string }; +}> = ({ resource, releaseTargetId, version }) => { + const versionId = version.id; const { data: policyEvaluations, isLoading } = api.policy.evaluate.releaseTarget.useQuery({ releaseTargetId, @@ -236,13 +247,16 @@ export const PolicyEvaluationsCell: React.FC<{ getPoliciesBlockingByConcurrency(policyEvaluations); const blockingReleaseTargetJob = getBlockingReleaseTargetJob(policyEvaluations); + const blockingVersionDependencies = + getBlockingVersionDependencies(policyEvaluations); const isBlocked = policiesBlockingByApproval.length > 0 || policiesBlockingByVersionSelector.length > 0 || policyBlockingByRollout != null || policiesBlockingByConcurrency.length > 0 || - blockingReleaseTargetJob != null; + blockingReleaseTargetJob != null || + blockingVersionDependencies.length > 0; if (!isBlocked) return
No jobs
; @@ -276,7 +290,7 @@ export const PolicyEvaluationsCell: React.FC<{ )} - {policyBlockingByRollout != null && !policyBlockingByRollout.passing && ( + {policyBlockingByRollout != null && (
@@ -287,6 +301,14 @@ export const PolicyEvaluationsCell: React.FC<{ )} + {blockingVersionDependencies.length > 0 && ( + + )} + {blockingReleaseTargetJob != null && ( )} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/ReleaseTargetRow.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/ReleaseTargetRow.tsx index 468cc86e0..8c6bb4601 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/ReleaseTargetRow.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/ReleaseTargetRow.tsx @@ -173,7 +173,7 @@ export const ReleaseTargetRow: React.FC<{ resource: { id: string; name: string }; environment: { id: string; name: string }; deployment: { id: string; name: string }; - version: { id: string }; + version: { id: string; tag: string }; jobs: Array<{ id: string; status: SCHEMA.JobStatus; @@ -222,7 +222,8 @@ export const ReleaseTargetRow: React.FC<{ diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/policy-evaluations/VersionDependencyBadge.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/policy-evaluations/VersionDependencyBadge.tsx new file mode 100644 index 000000000..51ec74178 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(sidebar)/jobs/_components/policy-evaluations/VersionDependencyBadge.tsx @@ -0,0 +1,171 @@ +import type * as schema from "@ctrlplane/db/schema"; +import React from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { IconSitemapFilled } from "@tabler/icons-react"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ctrlplane/ui/tooltip"; + +import { DeploymentVersionConditionBadge } from "~/app/[workspaceSlug]/(app)/_components/deployments/version/condition/DeploymentVersionConditionBadge"; +import { ResourceIcon } from "~/app/[workspaceSlug]/(app)/_components/resources/ResourceIcon"; +import { urls } from "~/app/urls"; + +type Dependency = schema.VersionDependency & { + resourcesForDependency: schema.Resource[]; + deployment: schema.Deployment; +}; + +const SingleResourceCell: React.FC<{ + resource: schema.Resource; +}> = ({ resource }) => { + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + const resourceUrl = urls + .workspace(workspaceSlug) + .resource(resource.id) + .deployments(); + + return ( + + + + {resource.name} + + + ); +}; + +const ResourceCell: React.FC<{ + resources: schema.Resource[]; +}> = ({ resources }) => { + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + + if (resources.length === 0) + return ( + +
No resources
+
+ ); + + if (resources.length === 1) + return ; + + return ( + + + + + + {resources.length} resources + + + + {resources.map((resource) => ( + + + {resource.name} + + ))} + + + + + ); +}; + +const DependencyRow: React.FC<{ + dependency: Dependency; +}> = ({ dependency }) => ( + + {dependency.deployment.name} + + {dependency.versionSelector != null && ( + + )} + {dependency.versionSelector == null && ( +
No version selector
+ )} +
+ +
+); + +export const VersionDependencyBadge: React.FC<{ + resource: { id: string; name: string }; + version: { tag: string }; + dependencyResults: Dependency[]; +}> = ({ resource, version, dependencyResults }) => ( + + +
+ + Missing dependencies +
+
+ + + {resource.name} is missing dependencies + + {resource.name} is missing the following dependencies specified for + version {version.tag} + + + + + + + Deployment + Version Selector + Resources checked + + + + {dependencyResults.map((dependency) => ( + + ))} + +
+
+
+); diff --git a/packages/api/src/router/policy/evaluate.ts b/packages/api/src/router/policy/evaluate.ts index 69accc8a2..4714bf9f6 100644 --- a/packages/api/src/router/policy/evaluate.ts +++ b/packages/api/src/router/policy/evaluate.ts @@ -4,11 +4,13 @@ import { TRPCError } from "@trpc/server"; import { isPresent } from "ts-is-present"; import { z } from "zod"; -import { and, eq, selector } from "@ctrlplane/db"; +import { and, eq, inArray, selector, takeFirst } from "@ctrlplane/db"; +import { getResourceParents } from "@ctrlplane/db/queries"; import * as schema from "@ctrlplane/db/schema"; import { getConcurrencyRule, getRolloutInfoForReleaseTarget, + getVersionDependencyRule, mergePolicies, ReleaseTargetConcurrencyRule, versionAnyApprovalRule, @@ -63,12 +65,14 @@ const getFilterReasons = async ( policies: Policy[], version: Version[], versionId: string, - ruleGetter: (policy: Policy) => Array>, + ruleGetter: ( + policy: Policy, + ) => Array> | Promise>>, ) => { return Object.fromEntries( await Promise.all( policies.map(async (policy) => { - const rules = ruleGetter(policy); + const rules = await ruleGetter(policy); const rejectionReasons = await Promise.all( rules.map(async (rule) => { const result = await rule.filter(version); @@ -106,6 +110,71 @@ const getConcurrencyBlocked = async ( ), ); +const getResourceFromReleaseTarget = async (db: Tx, releaseTargetId: string) => + db + .select() + .from(schema.releaseTarget) + .innerJoin( + schema.resource, + eq(schema.releaseTarget.resourceId, schema.resource.id), + ) + .where(eq(schema.releaseTarget.id, releaseTargetId)) + .then(takeFirst) + .then((r) => r.resource); + +const getVersionDependencyInfo = async ( + db: Tx, + releaseTargetId: string, + dependency: schema.VersionDependency, +) => { + const deployment = await db + .select() + .from(schema.deployment) + .where(eq(schema.deployment.id, dependency.deploymentId)) + .then(takeFirst); + + const resource = await getResourceFromReleaseTarget(db, releaseTargetId); + const { relationships } = await getResourceParents(db, resource.id); + const parentResourceIds = Object.values(relationships).map( + ({ source }) => source.id, + ); + const parentResources = + parentResourceIds.length > 0 + ? await db + .select() + .from(schema.resource) + .where(inArray(schema.resource.id, parentResourceIds)) + : []; + + const resourcesForDependency: schema.Resource[] = [ + resource, + ...parentResources, + ]; + return { resourcesForDependency, deployment }; +}; + +const getVersionDependency = async ( + db: Tx, + releaseTargetId: string, + version: schema.DeploymentVersion, +) => { + const rule = await getVersionDependencyRule(releaseTargetId); + const result = await rule.filter([version]); + const dependencyResult = result.dependencyResults[version.id]; + if (dependencyResult == null) return []; + + const allDependenciesPromise = dependencyResult.map( + async (dependencyResult) => { + const { isSatisfied, dependency } = dependencyResult; + const { resourcesForDependency, deployment } = + await getVersionDependencyInfo(db, releaseTargetId, dependency); + return { ...dependency, isSatisfied, resourcesForDependency, deployment }; + }, + ); + + return Promise.all(allDependenciesPromise); +}; + export const evaluateEnvironment = protectedProcedure .input( z.object({ @@ -287,6 +356,12 @@ export const evaluateReleaseTarget = protectedProcedure version, ); + const versionDependency = await getVersionDependency( + ctx.db, + releaseTargetId, + version, + ); + return { policies, rules: { @@ -311,6 +386,7 @@ export const evaluateReleaseTarget = protectedProcedure ), ), ), + versionDependency, }, }; }); diff --git a/packages/rule-engine/src/manager/version-manager-rules/index.ts b/packages/rule-engine/src/manager/version-manager-rules/index.ts index b12d5be35..15aab7d11 100644 --- a/packages/rule-engine/src/manager/version-manager-rules/index.ts +++ b/packages/rule-engine/src/manager/version-manager-rules/index.ts @@ -2,3 +2,4 @@ export * from "./environment-version-rollout.js"; export * from "./version-approval.js"; export * from "./concurrency.js"; export * from "./release-target-lock-rule.js"; +export * from "./version-dependency.js";