diff --git a/.husky/pre-commit b/.husky/pre-commit index e235e5d799..4f18d25215 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,3 @@ - #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 88968a2727..43d223ad65 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -8,6 +8,7 @@ import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, INTEGRATION_BITBUCKET_API_URL, + INTEGRATION_NORTHFLANK_API_URL, INTEGRATION_RAILWAY_API_URL, INTEGRATION_SET, INTEGRATION_VERCEL_API_URL, @@ -445,6 +446,79 @@ export const getIntegrationAuthBitBucketWorkspaces = async (req: Request, res: R }); }; +/** + * Return list of secret groups for Northflank project with id [appId] + * @param req + * @param res + * @returns + */ +export const getIntegrationAuthNorthflankSecretGroups = async (req: Request, res: Response) => { + const appId = req.query.appId as string; + + interface NorthflankSecretGroup { + id: string; + name: string; + description: string; + priority: number; + projectId: string; + } + + interface SecretGroup { + name: string; + groupId: string; + } + + const secretGroups: SecretGroup[] = []; + + if (appId && appId !== "") { + let page = 1; + const perPage = 10; + let hasMorePages = true; + + while(hasMorePages) { + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage), + filter: "all", + }); + + const { + data: { + data: { + secrets + } + } + } = await standardRequest.get<{ data: { secrets: NorthflankSecretGroup[] }}>( + `${INTEGRATION_NORTHFLANK_API_URL}/v1/projects/${appId}/secrets`, + { + params, + headers: { + Authorization: `Bearer ${req.accessToken}`, + "Accept-Encoding": "application/json", + }, + } + ); + + secrets.forEach((a: any) => { + secretGroups.push({ + name: a.name, + groupId: a.id + }); + }); + + if (secrets.length < perPage) { + hasMorePages = false; + } + + page++; + } + } + + return res.status(200).send({ + secretGroups + }); +} + /** * Delete integration authorization with id [integrationAuthId] * @param req @@ -461,3 +535,4 @@ export const deleteIntegrationAuth = async (req: Request, res: Response) => { integrationAuth }); }; + diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index c88a05d41c..fbbc1b7591 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -27,6 +27,8 @@ import { INTEGRATION_LARAVELFORGE_API_URL, INTEGRATION_NETLIFY, INTEGRATION_NETLIFY_API_URL, + INTEGRATION_NORTHFLANK, + INTEGRATION_NORTHFLANK_API_URL, INTEGRATION_RAILWAY, INTEGRATION_RAILWAY_API_URL, INTEGRATION_RENDER, @@ -161,7 +163,12 @@ const getApps = async ({ apps = await getAppsCloudflarePages({ accessToken, accountId: accessId - }) + }); + break; + case INTEGRATION_NORTHFLANK: + apps = await getAppsNorthflank({ + accessToken, + }); break; case INTEGRATION_BITBUCKET: apps = await getAppsBitBucket({ @@ -871,6 +878,39 @@ const getAppsBitBucket = async ({ return apps; } +/** Return list of projects for Northflank integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Northflank API + * @returns {Object[]} apps - names of Northflank apps + * @returns {String} apps.name - name of Northflank app + */ +const getAppsNorthflank = async ({ accessToken }: { accessToken: string }) => { + const { + data: { + data: { + projects + } + } + } = await standardRequest.get( + `${INTEGRATION_NORTHFLANK_API_URL}/v1/projects`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + }, + } + ); + + const apps = projects.map((a: any) => { + return { + name: a.name, + appId: a.id + }; + }); + + return apps; +}; + /** * Return list of projects for Supabase integration * @param {Object} obj diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 8faac12576..8839c44497 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -36,6 +36,8 @@ import { INTEGRATION_LARAVELFORGE_API_URL, INTEGRATION_NETLIFY, INTEGRATION_NETLIFY_API_URL, + INTEGRATION_NORTHFLANK, + INTEGRATION_NORTHFLANK_API_URL, INTEGRATION_RAILWAY, INTEGRATION_RAILWAY_API_URL, INTEGRATION_RENDER, @@ -69,7 +71,7 @@ const syncSecrets = async ({ integrationAuth, secrets, accessId, - accessToken, + accessToken }: { integration: IIntegration; integrationAuth: IIntegrationAuth; @@ -247,6 +249,13 @@ const syncSecrets = async ({ accessToken }); break; + case INTEGRATION_NORTHFLANK: + await syncSecretsNorthflank({ + integration, + secrets, + accessToken + }); + break; } }; @@ -2366,4 +2375,35 @@ const syncSecretsCloud66 = async ({ } }; +/** Sync/push [secrets] to Northflank + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Northflank integration + */ +const syncSecretsNorthflank = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + await standardRequest.patch( + `${INTEGRATION_NORTHFLANK_API_URL}/v1/projects/${integration.appId}/secrets/${integration.targetServiceId}`, + { + secrets: { + variables: secrets + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); +}; + export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 34dba6cfc8..d56c603e5c 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -16,6 +16,7 @@ import { INTEGRATION_HEROKU, INTEGRATION_LARAVELFORGE, INTEGRATION_NETLIFY, + INTEGRATION_NORTHFLANK, INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, @@ -65,6 +66,7 @@ export interface IIntegration { | "codefresh" | "digital-ocean-app-platform" | "cloud-66" + | "northflank" integrationAuth: Types.ObjectId; } @@ -159,6 +161,7 @@ const integrationSchema = new Schema( INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_CODEFRESH, INTEGRATION_CLOUD_66, + INTEGRATION_NORTHFLANK ], required: true, }, @@ -171,7 +174,7 @@ const integrationSchema = new Schema( type: String, required: true, default: "/", - }, + } }, { timestamps: true, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 100fe0db4d..dd8d0cd194 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -18,6 +18,7 @@ import { INTEGRATION_HEROKU, INTEGRATION_LARAVELFORGE, INTEGRATION_NETLIFY, + INTEGRATION_NORTHFLANK, INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, @@ -52,7 +53,8 @@ export interface IIntegrationAuth extends Document { | "digital-ocean-app-platform" | "bitbucket" | "cloud-66" - | "terraform-cloud"; + | "terraform-cloud" + | "northflank"; teamId: string; accountId: string; url: string; @@ -103,6 +105,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_CODEFRESH, INTEGRATION_CLOUD_66, + INTEGRATION_NORTHFLANK ], required: true, }, diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index 673d269d28..4fdc290e7c 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -155,6 +155,20 @@ router.get( integrationAuthController.getIntegrationAuthBitBucketWorkspaces ); +router.get( + "/:integrationAuthId/northflank/secret-groups", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT], + }), + requireIntegrationAuthorizationAuth({ + acceptedRoles: [ADMIN, MEMBER], + }), + param("integrationAuthId").exists().isString(), + query("appId").exists().isString(), + validateRequest, + integrationAuthController.getIntegrationAuthNorthflankSecretGroups +); + router.delete( "/:integrationAuthId", requireAuth({ diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 21d8e36c05..42066e3e59 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -32,6 +32,7 @@ export const INTEGRATION_BITBUCKET = "bitbucket"; export const INTEGRATION_CODEFRESH = "codefresh"; export const INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platform"; export const INTEGRATION_CLOUD_66 = "cloud-66"; +export const INTEGRATION_NORTHFLANK = "northflank"; export const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -52,7 +53,8 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_BITBUCKET, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, INTEGRATION_CODEFRESH, - INTEGRATION_CLOUD_66 + INTEGRATION_CLOUD_66, + INTEGRATION_NORTHFLANK ]); // integration types @@ -88,6 +90,7 @@ export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; export const INTEGRATION_CODEFRESH_API_URL = "https://g.codefresh.io/api"; export const INTEGRATION_DIGITAL_OCEAN_API_URL = "https://api.digitalocean.com"; export const INTEGRATION_CLOUD_66_API_URL = "https://app.cloud66.com/api"; +export const INTEGRATION_NORTHFLANK_API_URL = "https://api.northflank.com"; export const getIntegrationOptions = async () => { const INTEGRATION_OPTIONS = [ @@ -308,6 +311,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "", }, + { + name: "Northflank", + slug: "northflank", + image: "Northflank.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, ] return INTEGRATION_OPTIONS; diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index b504a53c56..f05c2ade0e 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -25,7 +25,8 @@ const integrationSlugNameMapping: Mapping = { "codefresh": "Codefresh", "digital-ocean-app-platform": "Digital Ocean App Platform", bitbucket: "BitBucket", - "cloud-66": "Cloud 66" + "cloud-66": "Cloud 66", + northflank: "Northflank" }; const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Northflank.png b/frontend/public/images/integrations/Northflank.png new file mode 100644 index 0000000000..f2d94060b4 Binary files /dev/null and b/frontend/public/images/integrations/Northflank.png differ diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index a227cdae54..e5e53c808a 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -3,8 +3,8 @@ export { useGetIntegrationAuthApps, useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, + useGetIntegrationAuthNorthflankSecretGroups, useGetIntegrationAuthRailwayEnvironments, useGetIntegrationAuthRailwayServices, useGetIntegrationAuthTeams, - useGetIntegrationAuthVercelBranches, -} from "./queries"; + useGetIntegrationAuthVercelBranches} from "./queries"; diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index a5aadd7341..bf9b5c940e 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -3,7 +3,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; -import { App, BitBucketWorkspace, Environment, IntegrationAuth, Service, Team } from "./types"; +import { + App, + BitBucketWorkspace, + Environment, + IntegrationAuth, + NorthflankSecretGroup, + Service, + Team +} from "./types"; const integrationAuthKeys = { getIntegrationAuthById: (integrationAuthId: string) => @@ -19,7 +27,6 @@ const integrationAuthKeys = { integrationAuthId: string; appId: string; }) => [{ integrationAuthId, appId }, "integrationAuthVercelBranches"] as const, - getIntegrationAuthRailwayEnvironments: ({ integrationAuthId, appId @@ -36,6 +43,13 @@ const integrationAuthKeys = { }) => [{ integrationAuthId, appId }, "integrationAuthRailwayServices"] as const, getIntegrationAuthBitBucketWorkspaces: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthBitbucketWorkspaces"] as const, + getIntegrationAuthNorthflankSecretGroups: ({ + integrationAuthId, + appId + }: { + integrationAuthId: string; + appId: string; + }) => [{ integrationAuthId, appId }, "integrationAuthNorthflankSecretGroups"] as const, }; const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -148,6 +162,27 @@ const fetchIntegrationAuthBitBucketWorkspaces = async (integrationAuthId: string return workspaces; }; +const fetchIntegrationAuthNorthflankSecretGroups = async ({ + integrationAuthId, + appId +}: { + integrationAuthId: string; + appId: string; +}) => { + const { + data: { secretGroups } + } = await apiRequest.get<{ secretGroups: NorthflankSecretGroup[] }>( + `/api/v1/integration-auth/${integrationAuthId}/northflank/secret-groups`, + { + params: { + appId + } + } + ); + + return secretGroups; +}; + export const useGetIntegrationAuthById = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), @@ -256,6 +291,27 @@ export const useGetIntegrationAuthBitBucketWorkspaces = (integrationAuthId: stri }); }; +export const useGetIntegrationAuthNorthflankSecretGroups = ({ + integrationAuthId, + appId +}: { + integrationAuthId: string; + appId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthNorthflankSecretGroups({ + integrationAuthId, + appId + }), + queryFn: () => + fetchIntegrationAuthNorthflankSecretGroups({ + integrationAuthId, + appId + }), + enabled: true + }); +}; + export const useDeleteIntegrationAuth = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 883f410407..1214600e81 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -13,6 +13,7 @@ export type App = { name: string; appId?: string; owner?: string; + secretGroups?: string[]; }; export type Team = { @@ -34,4 +35,9 @@ export type BitBucketWorkspace = { uuid: string; name: string; slug: string; +} + +export type NorthflankSecretGroup = { + name: string; + groupId: string; } \ No newline at end of file diff --git a/frontend/src/pages/api/integrations/createIntegration.ts b/frontend/src/pages/api/integrations/createIntegration.ts index a7bca11187..9235e7176c 100644 --- a/frontend/src/pages/api/integrations/createIntegration.ts +++ b/frontend/src/pages/api/integrations/createIntegration.ts @@ -34,7 +34,7 @@ const createIntegration = ({ owner, path, region, - secretPath + secretPath, }: Props) => SecurityClient.fetchCall("/api/v1/integration", { method: "POST", @@ -54,7 +54,7 @@ const createIntegration = ({ owner, path, region, - secretPath + secretPath, }) }).then(async (res) => { if (res && res.status === 200) { diff --git a/frontend/src/pages/integrations/northflank/authorize.tsx b/frontend/src/pages/integrations/northflank/authorize.tsx new file mode 100644 index 0000000000..8e2baa3cb1 --- /dev/null +++ b/frontend/src/pages/integrations/northflank/authorize.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; + +import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; +import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken"; + +export default function NorthflankCreateIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + if (apiKey.length === 0) { + setApiKeyErrorText("API Key cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "northflank", + accessToken: apiKey, + accessId: null, + url: null, + namespace: null + }); + + setIsLoading(false); + + router.push(`/integrations/northflank/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Northflank Integration + + setApiKey(e.target.value)} /> + + + +
+ ); +} + +NorthflankCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/northflank/create.tsx b/frontend/src/pages/integrations/northflank/create.tsx new file mode 100644 index 0000000000..4ebcf95ec7 --- /dev/null +++ b/frontend/src/pages/integrations/northflank/create.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById, + useGetIntegrationAuthNorthflankSecretGroups +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function NorthflankCreateIntegrationPage() { + const router = useRouter(); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + const [targetAppId, setTargetAppId] = useState(""); + const [targetSecretGroupId, setTargetSecretGroupId] = useState(null); + + const [isLoading, setIsLoading] = useState(false); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "" + }); + const { data: integrationAuthSecretGroups } = useGetIntegrationAuthNorthflankSecretGroups({ + integrationAuthId: (integrationAuthId as string) ?? "", + appId: targetAppId + }); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + // setTargetApp(integrationAuthApps[0].name); + setTargetAppId(integrationAuthApps[0].appId as string); + } else { + // setTargetApp("none"); + setTargetAppId("none"); + } + } + }, [integrationAuthApps]); + + useEffect(() => { + if (integrationAuthSecretGroups) { + if (integrationAuthSecretGroups.length > 0) { + // case: project has at least 1 secret group in Northflank + setTargetSecretGroupId(integrationAuthSecretGroups[0].groupId); + } else { + // case: project has no secret groups in Northflank + setTargetSecretGroupId("none"); + } + } + + }, [integrationAuthSecretGroups]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: integrationAuthApps?.find( + (integrationAuthApp) => integrationAuthApp.appId === targetAppId + )?.name ?? null, + appId: targetAppId, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: targetSecretGroupId, + owner: null, + path: null, + region: null, + secretPath + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetAppId ? ( +
+ + Northflank Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + {targetSecretGroupId && integrationAuthSecretGroups && ( + + + + )} + + +
+ ) : ( +
+ ); +} + +NorthflankCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 5d551b0b6d..c453cdf377 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -107,6 +107,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "cloud-66": link = `${window.location.origin}/integrations/cloud-66/authorize`; break; + case "northflank": + link = `${window.location.origin}/integrations/northflank/authorize`; + break; default: break; }