From 9012012503bf00a4b11b768c295e229d82c50b9e Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sat, 17 Feb 2024 21:04:26 -0800 Subject: [PATCH] added basic heroku pipeline integration --- .../routes/v1/integration-auth-router.ts | 31 + .../integration-auth/integration-app-list.ts | 5 +- .../integration-auth-service.ts | 34 + .../integration-auth-types.ts | 10 + frontend/package-lock.json | 33 + frontend/package.json | 1 + .../components/v2/RadioGroup/RadioGroup.tsx | 40 ++ .../src/components/v2/RadioGroup/index.tsx | 2 + .../src/hooks/api/integrationAuth/queries.tsx | 34 + .../src/hooks/api/integrationAuth/types.ts | 11 + .../src/pages/integrations/heroku/create.tsx | 112 +++- .../IntegrationsSection.tsx | 4 +- package-lock.json | 603 ++++++++++++++++++ package.json | 3 + 14 files changed, 909 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/v2/RadioGroup/RadioGroup.tsx create mode 100644 frontend/src/components/v2/RadioGroup/index.tsx diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 4d7aa1b1e0..3f48a39d46 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -513,6 +513,37 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + url: "/:integrationAuthId/heroku/pipelines", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + pipelines: z + .object({ + app: z.object({ appId: z.string() }), + stage: z.string(), + pipeline: z.object({ name: z.string(), pipelineId: z.string() }) + }) + .array() + }) + } + }, + handler: async (req) => { + const pipelines = await server.services.integrationAuth.getHerokuPipelines({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + return { pipelines }; + } + }); + server.route({ url: "/:integrationAuthId/railway/environments", method: "GET", diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 17b1b63ad7..62de06eb08 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -109,7 +109,7 @@ const getAppsGCPSecretManager = async ({ accessToken }: { accessToken: string }) */ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { const res = ( - await request.get<{ name: string }[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, { + await request.get<{ name: string; id: string }[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, { headers: { Accept: "application/vnd.heroku+json; version=3", Authorization: `Bearer ${accessToken}` @@ -118,7 +118,8 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { ).data; const apps = res.map((a) => ({ - name: a.name + name: a.name, + appId: a.id })); return apps; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 0b3f9b4c3f..60f3d561c9 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -20,9 +20,11 @@ import { TDeleteIntegrationAuthsDTO, TGetIntegrationAuthDTO, TGetIntegrationAuthTeamCityBuildConfigDTO, + THerokuPipelineCoupling, TIntegrationAuthAppsDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthHerokuPipelinesDTO, TIntegrationAuthNorthflankSecretGroupDTO, TIntegrationAuthQoveryEnvironmentsDTO, TIntegrationAuthQoveryOrgsDTO, @@ -576,6 +578,37 @@ export const integrationAuthServiceFactory = ({ return []; }; + const getHerokuPipelines = async ({ id, actor, actorId, actorOrgId }: TIntegrationAuthHerokuPipelinesDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + const { data } = await request.get( + `${IntegrationUrls.HEROKU_API_URL}/pipeline-couplings`, + { + headers: { + Accept: "application/vnd.heroku+json; version=3", + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return data.map(({ app: { id: appId }, stage, pipeline: { id: pipelineId, name } }) => ({ + app: { appId }, + stage, + pipeline: { pipelineId, name } + })); + }; + const getRailwayEnvironments = async ({ id, actor, actorId, actorOrgId, appId }: TIntegrationAuthRailwayEnvDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); @@ -915,6 +948,7 @@ export const integrationAuthServiceFactory = ({ getQoveryApps, getQoveryEnvs, getQoveryJobs, + getHerokuPipelines, getQoveryOrgs, getQoveryProjects, getQoveryContainers, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 34c5d995a6..e3dbc83418 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -62,6 +62,10 @@ export type TIntegrationAuthQoveryScopesDTO = { environmentId: string; } & Omit; +export type TIntegrationAuthHerokuPipelinesDTO = { + id: string; +} & Omit; + export type TIntegrationAuthRailwayEnvDTO = { id: string; appId: string; @@ -129,6 +133,12 @@ export type TNorthflankSecretGroup = { projectId: string; }; +export type THerokuPipelineCoupling = { + app: { id: string }; + stage: string; + pipeline: { id: string; name: string }; +}; + export type TTeamCityBuildConfig = { id: string; name: string; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f28920f064..d6567504ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popper": "^1.1.3", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", @@ -4783,6 +4784,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz", + "integrity": "sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 53e383f426..2c63f7956e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popper": "^1.1.3", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", diff --git a/frontend/src/components/v2/RadioGroup/RadioGroup.tsx b/frontend/src/components/v2/RadioGroup/RadioGroup.tsx new file mode 100644 index 0000000000..1919e4f7a9 --- /dev/null +++ b/frontend/src/components/v2/RadioGroup/RadioGroup.tsx @@ -0,0 +1,40 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { twMerge } from "tailwind-merge"; + +export type RadioGroupProps = RadioGroupPrimitive.RadioGroupProps; + +// Note this component is not customizable (Heroku integration and potentially other pages depend on it) +export const RadioGroup = ({ className, children, ...props }: RadioGroupProps) => ( + +
+ + + + +
+
+ + + + +
+
+); \ No newline at end of file diff --git a/frontend/src/components/v2/RadioGroup/index.tsx b/frontend/src/components/v2/RadioGroup/index.tsx new file mode 100644 index 0000000000..bf62b2e6dd --- /dev/null +++ b/frontend/src/components/v2/RadioGroup/index.tsx @@ -0,0 +1,2 @@ +export type { RadioGroupProps } from "./RadioGroup"; +export { RadioGroup } from "./RadioGroup"; diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 83dfb5d1a4..d87bb66c82 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -8,6 +8,7 @@ import { BitBucketWorkspace, ChecklyGroup, Environment, + HerokuPipelineCoupling, IntegrationAuth, NorthflankSecretGroup, Org, @@ -63,6 +64,8 @@ const integrationAuthKeys = { environmentId: string; scope: "job" | "application" | "container"; }) => [{ integrationAuthId, environmentId, scope }, "integrationAuthQoveryScopes"] as const, + getIntegrationAuthHerokuPipelines: ({ integrationAuthId }: { integrationAuthId: string; }) => + [{ integrationAuthId}, "integrationAuthHerokuPipelines"] as const, getIntegrationAuthRailwayEnvironments: ({ integrationAuthId, appId @@ -289,6 +292,20 @@ const fetchIntegrationAuthQoveryScopes = async ({ return undefined; }; +const fetchIntegrationAuthHerokuPipelines = async ({ integrationAuthId }: { + integrationAuthId: string; +}) => { + const { + data: { pipelines } + } = await apiRequest.get<{ pipelines: HerokuPipelineCoupling[] }>( + `/api/v1/integration-auth/${integrationAuthId}/heroku/pipelines` + ); + + console.log(99999, pipelines) + + return pipelines; +}; + const fetchIntegrationAuthRailwayEnvironments = async ({ integrationAuthId, appId @@ -540,6 +557,23 @@ export const useGetIntegrationAuthQoveryScopes = ({ }); }; +export const useGetIntegrationAuthHerokuPipelines = ({ + integrationAuthId +}: { + integrationAuthId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthHerokuPipelines({ + integrationAuthId + }), + queryFn: () => + fetchIntegrationAuthHerokuPipelines({ + integrationAuthId + }), + enabled: true + }); +}; + export const useGetIntegrationAuthRailwayEnvironments = ({ integrationAuthId, appId diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 2a56526737..cfd25df005 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -17,6 +17,17 @@ export type App = { secretGroups?: string[]; }; +export type Pipeline = { + pipelineId: string; + name: string; +}; + +export type HerokuPipelineCoupling = { + app: { appId: string }; + stage: string; + pipeline: { pipelineId: string; name: string }; +}; + export type Team = { name: string; teamId: string; diff --git a/frontend/src/pages/integrations/heroku/create.tsx b/frontend/src/pages/integrations/heroku/create.tsx index 96cab1b6a7..e312f11f3c 100644 --- a/frontend/src/pages/integrations/heroku/create.tsx +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -7,7 +7,10 @@ import { faArrowUpRightFromSquare, faBookOpen, faBugs, faCircleInfo } from "@for import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import queryString from "query-string"; +import { RadioGroup } from "@app/components/v2/RadioGroup"; import { useCreateIntegration } from "@app/hooks/api"; +import { useGetIntegrationAuthHerokuPipelines } from "@app/hooks/api/integrationAuth/queries"; +import { App, Pipeline } from "@app/hooks/api/integrationAuth/types"; import { Button, @@ -22,11 +25,12 @@ import { useGetIntegrationAuthApps, useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; -import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +import { useCreateWsEnvironment, useGetWorkspaceById } from "../../../hooks/api/workspace"; export default function HerokuCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); + const { mutateAsync: mutateAsyncEnv } = useCreateWsEnvironment(); const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); @@ -36,9 +40,17 @@ export default function HerokuCreateIntegrationPage() { integrationAuthId: (integrationAuthId as string) ?? "" }); + const { data: integrationAuthPipelineCouplings } = useGetIntegrationAuthHerokuPipelines({ + integrationAuthId: (integrationAuthId as string) ?? "" + }); + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [uniquePipelines, setUniquePipelines] = useState(); + const [selectedPipeline, setSelectedPipeline] = useState(""); + const [selectedPipelineApps, setSelectedPipelineApps] = useState(); const [targetApp, setTargetApp] = useState(""); const [secretPath, setSecretPath] = useState("/"); + const [integrationType, setIntegrationType] = useState("App"); const [isLoading, setIsLoading] = useState(false); @@ -48,6 +60,36 @@ export default function HerokuCreateIntegrationPage() { } }, [workspace]); + useEffect(() => { + if (integrationAuthPipelineCouplings) { + const uniquePipelinesConst = Array.from( + new Set( + integrationAuthPipelineCouplings + .map(({ pipeline: { pipelineId, name } }) => ({ + name, + pipelineId + })) + .map((obj) => JSON.stringify(obj)) + )).map((str) => JSON.parse(str)) as { pipelineId: string; name: string }[] + setUniquePipelines(uniquePipelinesConst); + if (uniquePipelinesConst) { + if (uniquePipelinesConst!.length > 0) { + setSelectedPipeline(uniquePipelinesConst![0].name); + } else { + setSelectedPipeline("none"); + } + } + } + }, [integrationAuthPipelineCouplings]); + + useEffect(() => { + if (integrationAuthPipelineCouplings) { + setSelectedPipelineApps(integrationAuthApps?.filter(app => integrationAuthPipelineCouplings + .filter((pipelineCoupling) => pipelineCoupling.pipeline.name === selectedPipeline) + .map(coupling => coupling.app.appId).includes(String(app.appId)))) + } + }, [selectedPipeline]); + useEffect(() => { if (integrationAuthApps) { if (integrationAuthApps.length > 0) { @@ -64,13 +106,32 @@ export default function HerokuCreateIntegrationPage() { if (!integrationAuth?.id) return; - await mutateAsync({ - integrationAuthId: integrationAuth?.id, - isActive: true, - app: targetApp, - sourceEnvironment: selectedSourceEnvironment, - secretPath - }); + if (integrationType === "App") { + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + app: targetApp, + sourceEnvironment: selectedSourceEnvironment, + secretPath + }); + } else if (integrationType === "Pipeline") { + selectedPipelineApps?.map(async (app, index) => { + setTimeout(async () => { + await mutateAsyncEnv({ + workspaceId: String(localStorage.getItem("projectData.id")), + name: app.name, + slug: app.name.toLowerCase().replaceAll(" ", "-") + }); + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + app: app.name, + sourceEnvironment: app.name.toLowerCase().replaceAll(" ", "-"), + secretPath + }) + }, 1000*index) + }) + } setIsLoading(false); router.push(`/integrations/${localStorage.getItem("projectData.id")}`); @@ -118,6 +179,36 @@ export default function HerokuCreateIntegrationPage() { + setIntegrationType(val)} + /> + {integrationType === "Pipeline" && <> + + + +
+ After creating the integration, the following Heroku apps will be automatically synced to Infisical: +
+ {selectedPipelineApps?.map(app =>

-> {app.name}

)} +
+ From then on, every new app in the selected pipeline will be synced to Infisical, too. +
+ } + {integrationType === "App" && <> - + }