diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/FlowDiagram.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/FlowDiagram.tsx new file mode 100644 index 000000000..7e5e4152f --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/FlowDiagram.tsx @@ -0,0 +1,80 @@ +"use client"; + +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { NodeTypes } from "reactflow"; +import ReactFlow, { MarkerType, useEdgesState, useNodesState } from "reactflow"; +import colors from "tailwindcss/colors"; + +import { ArrowEdge } from "~/app/[workspaceSlug]/(app)/_components/reactflow/ArrowEdge"; +import { useLayoutAndFitView } from "~/app/[workspaceSlug]/(app)/_components/reactflow/layout"; +import { EnvironmentNode } from "./nodes/EnvironmentNode"; +import { TriggerNode } from "./nodes/TriggerNode"; + +const nodeTypes: NodeTypes = { + environment: EnvironmentNode, + trigger: TriggerNode, +}; + +const markerEnd = { + type: MarkerType.Arrow, + color: colors.neutral[700], +}; + +export const FlowDiagram: React.FC<{ + workspace: SCHEMA.Workspace; + deploymentVersion: SCHEMA.DeploymentVersion; + envs: Array; +}> = ({ workspace, deploymentVersion, envs }) => { + const [nodes, _, onNodesChange] = useNodesState<{ label: string }>([ + { + id: "trigger", + type: "trigger", + position: { x: 0, y: 0 }, + data: { ...deploymentVersion, label: deploymentVersion.name }, + }, + ...envs.map((env) => { + return { + id: env.id, + type: "environment", + position: { x: 0, y: 0 }, + data: { + workspaceId: workspace.id, + versionId: deploymentVersion.id, + versionTag: deploymentVersion.tag, + deploymentId: deploymentVersion.deploymentId, + environmentId: env.id, + environmentName: env.name, + label: env.name, + }, + }; + }), + ]); + + const [edges, __, onEdgesChange] = useEdgesState([ + ...envs.map((env) => ({ + id: env.id, + source: "trigger", + target: env.id, + markerEnd, + })), + ]); + + const { setReactFlowInstance } = useLayoutAndFitView(nodes, { + direction: "LR", + padding: 0.16, + }); + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/StatusIcons.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/StatusIcons.tsx new file mode 100644 index 000000000..110f733ec --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/StatusIcons.tsx @@ -0,0 +1,38 @@ +import { IconCheck, IconLoader2, IconMinus, IconX } from "@tabler/icons-react"; + +import { cn } from "@ctrlplane/ui"; + +export const Passing: React.FC = () => ( +
+ +
+); + +export const Failing: React.FC = () => ( +
+ +
+); + +export const Waiting: React.FC<{ className?: string }> = ({ className }) => ( +
+ +
+); + +export const Loading: React.FC = () => ( +
+ +
+); + +export const Cancelled: React.FC = () => ( +
+ +
+); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/checks/Approval.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/checks/Approval.tsx new file mode 100644 index 000000000..ab2e5452f --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/checks/Approval.tsx @@ -0,0 +1,70 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ctrlplane/ui/tooltip"; + +import { api } from "~/trpc/react"; +import { Loading, Passing, Waiting } from "../StatusIcons"; + +export const ApprovalCheck: React.FC<{ + workspaceId: string; + environmentId: string; + versionId: string; +}> = ({ workspaceId, environmentId, versionId }) => { + const { data, isLoading } = + api.deployment.version.checks.approval.status.useQuery({ + workspaceId, + environmentId, + versionId, + }); + + const isApproved = data?.approved ?? false; + const rejectionReasonEntries = Array.from( + data?.rejectionReasons.entries() ?? [], + ); + + if (isLoading) + return ( +
+ Loading approval status +
+ ); + + if (isApproved) + return ( +
+ Approved +
+ ); + + if (rejectionReasonEntries.length > 0) { + return ( + + + +
+ Not enough approvals +
+
+ +
    + {rejectionReasonEntries.map(([reason, comment]) => ( +
  • + {reason}: {comment} +
  • + ))} +
+
+
+
+ ); + } + + return ( +
+ Not enough approvals +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/nodes/EnvironmentNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/nodes/EnvironmentNode.tsx new file mode 100644 index 000000000..d8d04e125 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/nodes/EnvironmentNode.tsx @@ -0,0 +1,55 @@ +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { NodeProps } from "reactflow"; +import { IconPlant } from "@tabler/icons-react"; +import { Handle, Position } from "reactflow"; +import colors from "tailwindcss/colors"; + +import { cn } from "@ctrlplane/ui"; +import { Separator } from "@ctrlplane/ui/separator"; + +import { ApprovalCheck } from "../checks/Approval"; + +type EnvironmentNodeProps = NodeProps<{ + workspaceId: string; + policy?: SCHEMA.EnvironmentPolicy; + versionId: string; + versionTag: string; + deploymentId: string; + environmentId: string; + environmentName: string; +}>; + +export const EnvironmentNode: React.FC = ({ data }) => ( + <> +
+
+
+ +
+ {data.environmentName} +
+ +
+ +
+
+ + + +); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/nodes/TriggerNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/nodes/TriggerNode.tsx new file mode 100644 index 000000000..091f19540 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/_components/flow-diagram/nodes/TriggerNode.tsx @@ -0,0 +1,30 @@ +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { NodeProps } from "reactflow"; +import React from "react"; +import { IconBolt, IconTarget } from "@tabler/icons-react"; +import { Handle, Position } from "reactflow"; + +type TriggerNodeProps = NodeProps; + +export const TriggerNode: React.FC = ({ data }) => ( + <> +
+
+ Trigger +
+
+
+
+ +
+ {data.label} +
+
+
+ + +); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/page.tsx new file mode 100644 index 000000000..4740d1a99 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/checksv2/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { IconMenu2 } from "@tabler/icons-react"; + +import { SidebarTrigger } from "@ctrlplane/ui/sidebar"; + +import { ReactFlowProvider } from "~/app/[workspaceSlug]/(app)/_components/reactflow/ReactFlowProvider"; +import { Sidebars } from "~/app/[workspaceSlug]/sidebars"; +import { api } from "~/trpc/server"; +import { FlowDiagram } from "./_components/flow-diagram/FlowDiagram"; + +type PageProps = { + params: Promise<{ + workspaceSlug: string; + systemSlug: string; + deploymentSlug: string; + releaseId: string; + }>; +}; + +export async function generateMetadata(props: PageProps): Promise { + const params = await props.params; + const deployment = await api.deployment.bySlug(params); + if (deployment == null) return notFound(); + + const deploymentVersion = await api.deployment.version.byId(params.releaseId); + if (deploymentVersion == null) return notFound(); + + return { + title: `${deploymentVersion.tag} | ${deployment.name} | ${deployment.system.name} | ${deployment.system.workspace.name}`, + }; +} + +export default async function ChecksPage(props: PageProps) { + const params = await props.params; + const deploymentVersionPromise = api.deployment.version.byId( + params.releaseId, + ); + const deploymentPromise = api.deployment.bySlug(params); + const [deploymentVersion, deployment] = await Promise.all([ + deploymentVersionPromise, + deploymentPromise, + ]); + if (deploymentVersion == null || deployment == null) return notFound(); + + const { system } = deployment; + const environments = await api.deployment.version.checks.environmentsToCheck( + deployment.id, + ); + return ( +
+ + + + + + +
+ ); +} diff --git a/packages/api/package.json b/packages/api/package.json index 70539ef19..1ba8cef40 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -29,6 +29,7 @@ "@ctrlplane/events": "workspace:*", "@ctrlplane/job-dispatch": "workspace:*", "@ctrlplane/logger": "workspace:*", + "@ctrlplane/rule-engine": "workspace:*", "@ctrlplane/secrets": "workspace:*", "@ctrlplane/validators": "workspace:*", "@octokit/auth-app": "catalog:", diff --git a/packages/api/src/router/deployment-version-checks.ts b/packages/api/src/router/deployment-version-checks.ts new file mode 100644 index 000000000..41ade354c --- /dev/null +++ b/packages/api/src/router/deployment-version-checks.ts @@ -0,0 +1,106 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { and, eq } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { + getVersionApprovalRules, + VersionReleaseManager, +} from "@ctrlplane/rule-engine"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +const approvalRouter = createTRPCRouter({ + status: protectedProcedure + .input( + z.object({ + workspaceId: z.string().uuid(), + versionId: z.string().uuid(), + environmentId: z.string().uuid(), + }), + ) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser.perform(Permission.DeploymentVersionGet).on({ + type: "deploymentVersion", + id: input.versionId, + }), + }) + .query( + async ({ ctx, input: { versionId, environmentId, workspaceId } }) => { + const v = await ctx.db.query.deploymentVersion.findFirst({ + where: eq(SCHEMA.deploymentVersion.id, versionId), + with: { metadata: true }, + }); + + if (v == null) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Deployment version not found: ${versionId}`, + }); + } + + const metadata = Object.fromEntries( + v.metadata.map((m) => [m.key, m.value]), + ); + const version = { ...v, metadata }; + + const { deploymentId } = version; + // since the resource does not affect the approval rules, we can just use any release target + // for the given deployment and environment + const rt = await ctx.db.query.releaseTarget.findFirst({ + where: and( + eq(SCHEMA.releaseTarget.deploymentId, deploymentId), + eq(SCHEMA.releaseTarget.environmentId, environmentId), + ), + }); + if (rt == null) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Release target not found: ${deploymentId} ${environmentId}`, + }); + } + + const releaseTarget = { ...rt, workspaceId }; + const manager = new VersionReleaseManager(ctx.db, releaseTarget); + const { chosenCandidate, rejectionReasons } = await manager.evaluate({ + versions: [version], + rules: getVersionApprovalRules, + }); + return { + approved: chosenCandidate != null, + rejectionReasons, + }; + }, + ), +}); + +export const deploymentVersionChecksRouter = createTRPCRouter({ + environmentsToCheck: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: async ({ ctx, canUser, input }) => { + const deployment = await ctx.db.query.deployment.findFirst({ + where: eq(SCHEMA.deployment.id, input), + }); + if (deployment == null) return false; + return canUser + .perform(Permission.EnvironmentList) + .on({ type: "system", id: deployment.systemId }); + }, + }) + .query(async ({ ctx, input: deploymentId }) => { + const rows = await ctx.db + .selectDistinctOn([SCHEMA.environment.id]) + .from(SCHEMA.releaseTarget) + .innerJoin( + SCHEMA.environment, + eq(SCHEMA.releaseTarget.environmentId, SCHEMA.environment.id), + ) + .where(eq(SCHEMA.releaseTarget.deploymentId, deploymentId)) + .orderBy(SCHEMA.environment.id); + return rows.map((r) => r.environment); + }), + approval: approvalRouter, +}); diff --git a/packages/api/src/router/deployment-version.ts b/packages/api/src/router/deployment-version.ts index e69c98781..9f3faca08 100644 --- a/packages/api/src/router/deployment-version.ts +++ b/packages/api/src/router/deployment-version.ts @@ -46,6 +46,7 @@ import { } from "@ctrlplane/validators/releases"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { deploymentVersionChecksRouter } from "./deployment-version-checks"; import { versionDeployRouter } from "./version-deploy"; import { deploymentVersionMetadataKeysRouter } from "./version-metadata-keys"; @@ -1004,4 +1005,5 @@ export const versionRouter = createTRPCRouter({ }), metadataKeys: deploymentVersionMetadataKeysRouter, + checks: deploymentVersionChecksRouter, }); diff --git a/packages/rule-engine/src/index.ts b/packages/rule-engine/src/index.ts index d314068f7..68f412ec7 100644 --- a/packages/rule-engine/src/index.ts +++ b/packages/rule-engine/src/index.ts @@ -1,4 +1,5 @@ export * from "./manager/version-rule-engine.js"; +export * from "./manager/version-manager-rules.js"; export * from "./rules/index.js"; export * from "./utils/merge-policies.js"; export * from "./types.js"; diff --git a/packages/rule-engine/src/manager/version-manager-rules.ts b/packages/rule-engine/src/manager/version-manager-rules.ts index 791a2c1b0..95b08d3f3 100644 --- a/packages/rule-engine/src/manager/version-manager-rules.ts +++ b/packages/rule-engine/src/manager/version-manager-rules.ts @@ -60,6 +60,14 @@ const versionUserApprovalRule = ( ); }; +export const getVersionApprovalRules = ( + policy: Policy | null, +): RuleEngineFilter[] => [ + ...versionUserApprovalRule(policy?.versionUserApprovals), + ...versionAnyApprovalRule(policy?.versionAnyApprovals), + ...versionRoleApprovalRule(policy?.versionRoleApprovals), +]; + export const getRules = ( policy: Policy | null, ): RuleEngineFilter[] => { diff --git a/packages/rule-engine/src/manager/version-manager.ts b/packages/rule-engine/src/manager/version-manager.ts index 0558d112e..0a84fcf3f 100644 --- a/packages/rule-engine/src/manager/version-manager.ts +++ b/packages/rule-engine/src/manager/version-manager.ts @@ -14,13 +14,19 @@ import { db as dbClient } from "@ctrlplane/db/client"; import * as schema from "@ctrlplane/db/schema"; import { JobStatus } from "@ctrlplane/validators/jobs"; -import type { Policy, RuleEngineContext } from "../types.js"; +import type { Version } from "../manager/version-rule-engine.js"; +import type { Policy, RuleEngineContext, RuleEngineFilter } from "../types.js"; import type { ReleaseManager, ReleaseTarget } from "./types.js"; import { getApplicablePolicies } from "../db/get-applicable-policies.js"; import { VersionRuleEngine } from "../manager/version-rule-engine.js"; import { mergePolicies } from "../utils/merge-policies.js"; import { getRules } from "./version-manager-rules.js"; +type VersionEvaluateOptions = { + rules?: (p: Policy | null) => RuleEngineFilter[]; + versions?: Version[]; +}; + export class VersionReleaseManager implements ReleaseManager { private cachedPolicy: Policy | null = null; constructor( @@ -156,7 +162,7 @@ export class VersionReleaseManager implements ReleaseManager { return this.cachedPolicy; } - async evaluate() { + async evaluate(options?: VersionEvaluateOptions) { const ctx: RuleEngineContext | undefined = await this.db.query.releaseTarget.findFirst({ where: eq(schema.releaseTarget.id, this.releaseTarget.id), @@ -171,10 +177,11 @@ export class VersionReleaseManager implements ReleaseManager { throw new Error(`Release target ${this.releaseTarget.id} not found`); const policy = await this.getPolicy(); - const rules = getRules(policy); + const rules = (options?.rules ?? getRules)(policy); const engine = new VersionRuleEngine(rules); - const versions = await this.findVersionsForEvaluate(); + const versions = + options?.versions ?? (await this.findVersionsForEvaluate()); const result = await engine.evaluate(ctx, versions); return result; } diff --git a/packages/rule-engine/src/rules/deployment-deny-rule.ts b/packages/rule-engine/src/rules/deployment-deny-rule.ts index ecda16191..6551b63bf 100644 --- a/packages/rule-engine/src/rules/deployment-deny-rule.ts +++ b/packages/rule-engine/src/rules/deployment-deny-rule.ts @@ -5,7 +5,7 @@ import { isSameDay, isWithinInterval, } from "date-fns"; -import rrule from "rrule"; +import * as rrule from "rrule"; import type { RuleEngineContext, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b640bc877..8ddfc065f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -847,6 +847,9 @@ importers: '@ctrlplane/logger': specifier: workspace:* version: link:../logger + '@ctrlplane/rule-engine': + specifier: workspace:* + version: link:../rule-engine '@ctrlplane/secrets': specifier: workspace:* version: link:../secrets