diff --git a/apps/web/app/routes/ws/deployments/_components/EnvironmentActionsPanel.tsx b/apps/web/app/routes/ws/deployments/_components/EnvironmentActionsPanel.tsx index d73455d06..b3a468190 100644 --- a/apps/web/app/routes/ws/deployments/_components/EnvironmentActionsPanel.tsx +++ b/apps/web/app/routes/ws/deployments/_components/EnvironmentActionsPanel.tsx @@ -1,9 +1,12 @@ import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk"; +import type React from "react"; import _ from "lodash"; import { CheckCircle, Server, Shield } from "lucide-react"; +import { toast } from "sonner"; import { trpc } from "~/api/trpc"; import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; import { Dialog, DialogContent, @@ -20,6 +23,7 @@ import { useWorkspace } from "~/components/WorkspaceProvider"; type DeploymentVersion = WorkspaceEngine["schemas"]["DeploymentVersion"]; type ReleaseTarget = WorkspaceEngine["schemas"]["ReleaseTargetWithState"]; +type Environment = WorkspaceEngine["schemas"]["Environment"]; const getReleaseTargetKey = (rt: ReleaseTarget) => { return `${rt.releaseTarget.resourceId}-${rt.releaseTarget.environmentId}-${rt.releaseTarget.deploymentId}`; @@ -54,6 +58,63 @@ const PoliciesSection: React.FC<{ policies: string[] }> = ({ policies }) => { ); }; +const PendingActionsSection: React.FC<{ + version: DeploymentVersion; + environment: Environment; +}> = ({ version, environment }) => { + const { workspace } = useWorkspace(); + const approveMutation = trpc.deploymentVersions.approve.useMutation(); + const decisionsQuery = trpc.decisions.environmentVersion.useQuery({ + workspaceId: workspace.id, + environmentId: environment.id, + versionId: version.id, + }); + const pendingActions = (decisionsQuery.data ?? []).flatMap( + (action) => action.ruleResults, + ); + const approvalAction = pendingActions.find( + (action) => action.actionType === "approval", + ); + + if (approvalAction == null) return null; + + const onClick = () => + approveMutation + .mutateAsync({ + workspaceId: workspace.id, + deploymentVersionId: version.id, + environmentId: environment.id, + status: "approved", + }) + .then(() => toast.success("Approval record queued successfully")); + + return ( +
+
+
+ Pending actions +
+
+ {pendingActions.map((action, idx) => ( +
+
+ {version.tag} - {action.message} +
+ +
+ ))} +
+
+ ); +}; + const ResourceItem: React.FC<{ releaseTarget: ReleaseTarget; }> = ({ releaseTarget: rt }) => { @@ -191,6 +252,10 @@ export const EnvironmentActionsPanel: React.FC<
{/* Policies */} {/* */} + {/* Resources grouped by version */}
diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 31420f4e5..0e62152a9 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -1,3 +1,5 @@ +import { decisionsRouter } from "./routes/decisions.js"; +import { deploymentVersionsRouter } from "./routes/deployment-versions.js"; import { deploymentsRouter } from "./routes/deployments.js"; import { environmentRouter } from "./routes/environments.js"; import { githubRouter } from "./routes/github.js"; @@ -19,6 +21,7 @@ export const appRouter = router({ resource: resourcesRouter, workspace: workspaceRouter, deployment: deploymentsRouter, + deploymentVersions: deploymentVersionsRouter, system: systemsRouter, environment: environmentRouter, validate: validateRouter, @@ -29,4 +32,5 @@ export const appRouter = router({ resourceProviders: resourceProvidersRouter, jobAgents: jobAgentsRouter, github: githubRouter, + decisions: decisionsRouter, }); diff --git a/packages/trpc/src/routes/decisions.ts b/packages/trpc/src/routes/decisions.ts new file mode 100644 index 000000000..956217245 --- /dev/null +++ b/packages/trpc/src/routes/decisions.ts @@ -0,0 +1,90 @@ +import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk"; +import { z } from "zod"; + +import { getClientFor } from "@ctrlplane/workspace-engine-sdk"; + +import { protectedProcedure, router } from "../trpc.js"; + +const getOneReleaseTarget = async ( + workspaceId: string, + environmentId: string, + deploymentId: string, +) => { + const response = await getClientFor(workspaceId).GET( + "/v1/workspaces/{workspaceId}/environments/{environmentId}/release-targets", + { params: { path: { workspaceId, environmentId } } }, + ); + return (response.data?.items ?? []).find( + (target) => target.deployment.id === deploymentId, + ); +}; + +const getDeploymentVersion = async ( + workspaceId: string, + deploymentVersionId: string, +) => { + const response = await getClientFor(workspaceId).GET( + "/v1/workspaces/{workspaceId}/deploymentversions/{deploymentVersionId}", + { params: { path: { workspaceId, deploymentVersionId } } }, + ); + if (response.data == null) throw new Error("Deployment version not found"); + return response.data; +}; + +const getPolicyResults = async ( + workspaceId: string, + releaseTarget: WorkspaceEngine["schemas"]["ReleaseTargetWithState"], + version: WorkspaceEngine["schemas"]["DeploymentVersion"], +) => { + const decision = await getClientFor(workspaceId).POST( + "/v1/workspaces/{workspaceId}/release-targets/evaluate", + { + params: { path: { workspaceId } }, + body: { + releaseTarget: { + deploymentId: releaseTarget.deployment.id, + environmentId: releaseTarget.environment.id, + resourceId: releaseTarget.resource.id, + }, + version, + }, + }, + ); + return decision.data?.versionDecision?.policyResults ?? []; +}; + +const getEnvironmentScopedResults = ( + policyResults: WorkspaceEngine["schemas"]["DeployDecision"]["policyResults"], +) => + policyResults.filter((result) => + result.ruleResults.some( + (rule) => rule.actionType === "approval" && !rule.allowed, + ), + ); + +export const decisionsRouter = router({ + environmentVersion: protectedProcedure + .input( + z.object({ + workspaceId: z.uuid(), + environmentId: z.uuid(), + versionId: z.uuid(), + }), + ) + .query(async ({ input }) => { + const { workspaceId, environmentId, versionId } = input; + const version = await getDeploymentVersion(workspaceId, versionId); + const releaseTarget = await getOneReleaseTarget( + workspaceId, + environmentId, + version.deploymentId, + ); + if (releaseTarget == null) return []; + const policyResults = await getPolicyResults( + workspaceId, + releaseTarget, + version, + ); + return getEnvironmentScopedResults(policyResults); + }), +}); diff --git a/packages/trpc/src/routes/deployment-versions.ts b/packages/trpc/src/routes/deployment-versions.ts new file mode 100644 index 000000000..2750f773f --- /dev/null +++ b/packages/trpc/src/routes/deployment-versions.ts @@ -0,0 +1,38 @@ +import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk"; +import { z } from "zod"; + +import { Event, sendGoEvent } from "@ctrlplane/events"; + +import { protectedProcedure, router } from "../trpc.js"; + +export const deploymentVersionsRouter = router({ + approve: protectedProcedure + .input( + z.object({ + workspaceId: z.string().uuid(), + deploymentVersionId: z.string(), + environmentId: z.string(), + status: z.enum(["approved", "rejected"]).default("approved"), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + const record: WorkspaceEngine["schemas"]["UserApprovalRecord"] = { + userId, + versionId: input.deploymentVersionId, + environmentId: input.environmentId, + status: input.status, + createdAt: new Date().toISOString(), + }; + + await sendGoEvent({ + workspaceId: input.workspaceId, + eventType: Event.UserApprovalRecordCreated, + timestamp: Date.now(), + data: record, + }); + + return record; + }), +});