diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index a7390abaa..ede705b8f 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -12,6 +12,7 @@ import { INTEGRATION_BITBUCKET_API_URL, INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_NORTHFLANK_API_URL, + INTEGRATION_QOVERY_API_URL, INTEGRATION_RAILWAY_API_URL, INTEGRATION_SET, INTEGRATION_VERCEL_API_URL, @@ -344,6 +345,317 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon }); }; +/** + * Return list of Qovery Orgs for a specific user + * @param req + * @param res + */ +export const getIntegrationAuthQoveryOrgs = async (req: Request, res: Response) => { + const { + params: { integrationAuthId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryOrgsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/organization`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + interface QoveryOrg { + id: string; + name: string; + } + + const orgs = data.results.map((a: QoveryOrg) => { + return { + name: a.name, + orgId: a.id, + }; + }); + + return res.status(200).send({ + orgs + }); +}; + +/** + * Return list of Qovery Projects for a specific orgId + * @param req + * @param res + */ +export const getIntegrationAuthQoveryProjects = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { orgId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryProjectsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/organization/${orgId}/project`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + interface QoveryProject { + id: string; + name: string; + } + + const projects = data.results.map((a: QoveryProject) => { + return { + name: a.name, + projectId: a.id, + }; + }); + + return res.status(200).send({ + projects + }); +}; + +/** + * Return list of Qovery Environments for a specific projectId + * @param req + * @param res + */ +export const getIntegrationAuthQoveryEnvironments = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { projectId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryEnvironmentsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/project/${projectId}/environment`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + interface QoveryEnvironment { + id: string; + name: string; + } + + const environments = data.results.map((a: QoveryEnvironment) => { + return { + name: a.name, + environmentId: a.id, + }; + }); + + return res.status(200).send({ + environments + }); +}; + +/** + * Return list of Qovery Apps for a specific environmentId + * @param req + * @param res + */ +export const getIntegrationAuthQoveryApps = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/application`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + interface QoveryApp { + id: string; + name: string; + } + + const apps = data.results.map((a: QoveryApp) => { + return { + name: a.name, + appId: a.id, + }; + }); + + return res.status(200).send({ + apps + }); +}; + +/** + * Return list of Qovery Containers for a specific environmentId + * @param req + * @param res + */ +export const getIntegrationAuthQoveryContainers = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/container`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + interface QoveryContainer { + id: string; + name: string; + } + + const containers = data.results.map((a: QoveryContainer) => { + return { + name: a.name, + appId: a.id, + }; + }); + + return res.status(200).send({ + containers + }); +}; + +/** + * Return list of Qovery Jobs for a specific environmentId + * @param req + * @param res + */ +export const getIntegrationAuthQoveryJobs = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/job`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + interface QoveryJob { + id: string; + name: string; + } + + const jobs = data.results.map((a: QoveryJob) => { + return { + name: a.name, + appId: a.id, + }; + }); + + return res.status(200).send({ + jobs + }); +}; + /** * Return list of Railway environments for Railway project with * id [appId] diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index cb45a9d95..220ff3544 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -40,6 +40,8 @@ import { INTEGRATION_NETLIFY_API_URL, INTEGRATION_NORTHFLANK, INTEGRATION_NORTHFLANK_API_URL, + INTEGRATION_QOVERY, + INTEGRATION_QOVERY_API_URL, INTEGRATION_RAILWAY, INTEGRATION_RAILWAY_API_URL, INTEGRATION_RENDER, @@ -219,6 +221,13 @@ const syncSecrets = async ({ accessToken }); break; + case INTEGRATION_QOVERY: + await syncSecretsQovery({ + integration, + secrets, + accessToken + }); + break; case INTEGRATION_TERRAFORM_CLOUD: await syncSecretsTerraformCloud({ integration, @@ -2126,6 +2135,96 @@ const syncSecretsCheckly = async ({ } }; +/** + * Sync/push [secrets] to Qovery app + * @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 Qovery integration + */ +const syncSecretsQovery = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: Record; + accessToken: string; +}) => { + const getSecretsRes = ( + await standardRequest.get(`${INTEGRATION_QOVERY_API_URL}/${integration.metadata?.scope?.toLowerCase()}/${integration.appId}/environmentVariable`, { + headers: { + Authorization: `Token ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data.results.reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.key]: {"id": secret.id, "value": secret.value} + }), + {} + ); + + // add secrets + for await (const key of Object.keys(secrets)) { + if (!(key in getSecretsRes)) { + // case: secret does not exist in qovery + // -> add secret + await standardRequest.post( + `${INTEGRATION_QOVERY_API_URL}/${integration.metadata?.scope?.toLowerCase()}/${integration.appId}/environmentVariable`, + { + key, + value: secrets[key].value + }, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); + } else { + // case: secret exists in qovery + // -> update/set secret + + if (secrets[key].value !== getSecretsRes[key].value) { + await standardRequest.put( + `${INTEGRATION_QOVERY_API_URL}/${integration.metadata?.scope?.toLowerCase()}/${integration.appId}/environmentVariable/${getSecretsRes[key].id}`, + { + key, + value: secrets[key].value + }, + { + headers: { + Authorization: `Token ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } + } + } + + // This one is dangerous because there might be a lot of qovery-specific secrets + + // for await (const key of Object.keys(getSecretsRes)) { + // if (!(key in secrets)) { + // console.log(3) + // // delete secret + // await standardRequest.delete(`${INTEGRATION_QOVERY_API_URL}/application/${integration.appId}/environmentVariable/${getSecretsRes[key].id}`, { + // headers: { + // Authorization: `Token ${accessToken}`, + // Accept: "application/json", + // "X-Qovery-Account": integration.appId + // } + // }); + // } + // } +}; + /** * Sync/push [secrets] to Terraform Cloud project with id [integration.appId] * @param {Object} obj diff --git a/backend/src/models/integration/integration.ts b/backend/src/models/integration/integration.ts index 9bc378f28..b1f508a3b 100644 --- a/backend/src/models/integration/integration.ts +++ b/backend/src/models/integration/integration.ts @@ -18,6 +18,7 @@ import { INTEGRATION_LARAVELFORGE, INTEGRATION_NETLIFY, INTEGRATION_NORTHFLANK, + INTEGRATION_QOVERY, INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, @@ -63,6 +64,7 @@ export interface IIntegration { | "travisci" | "supabase" | "checkly" + | "qovery" | "terraform-cloud" | "teamcity" | "hashicorp-vault" @@ -162,6 +164,7 @@ const integrationSchema = new Schema( INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_QOVERY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TEAMCITY, INTEGRATION_HASHICORP_VAULT, diff --git a/backend/src/models/integration/types.ts b/backend/src/models/integration/types.ts index 0c0b998e6..898dee04f 100644 --- a/backend/src/models/integration/types.ts +++ b/backend/src/models/integration/types.ts @@ -8,4 +8,5 @@ export type Metadata = { labelName: string; labelValue: string; } + scope?: "Job" | "Application" | "Container"; } \ No newline at end of file diff --git a/backend/src/models/integrationAuth/integrationAuth.ts b/backend/src/models/integrationAuth/integrationAuth.ts index 299ca9abe..312ee09d7 100644 --- a/backend/src/models/integrationAuth/integrationAuth.ts +++ b/backend/src/models/integrationAuth/integrationAuth.ts @@ -52,6 +52,7 @@ import { | "aws-parameter-store" | "aws-secret-manager" | "checkly" + | "qovery" | "cloudflare-pages" | "codefresh" | "digital-ocean-app-platform" diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index cfb27ee71..e28874788 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -60,6 +60,54 @@ router.get( integrationAuthController.getIntegrationAuthVercelBranches ); +router.get( + "/:integrationAuthId/qovery/orgs", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryOrgs +); + +router.get( + "/:integrationAuthId/qovery/projects", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryProjects +); + +router.get( + "/:integrationAuthId/qovery/environments", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryEnvironments +); + +router.get( + "/:integrationAuthId/qovery/apps", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryApps +); + +router.get( + "/:integrationAuthId/qovery/containers", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryContainers +); + +router.get( + "/:integrationAuthId/qovery/jobs", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryJobs +); + router.get( "/:integrationAuthId/railway/environments", requireAuth({ diff --git a/backend/src/validation/integration.ts b/backend/src/validation/integration.ts index 7795b0084..db32eefe1 100644 --- a/backend/src/validation/integration.ts +++ b/backend/src/validation/integration.ts @@ -82,7 +82,14 @@ export const CreateIntegrationV1 = z.object({ secretGCPLabel: z.object({ labelName: z.string(), labelValue: z.string() - }).optional() + }).optional(), + org: z.string().optional(), + orgId: z.string().optional(), + project: z.string().optional(), + projectId: z.string().optional(), + environment: z.string().optional(), + environmentId: z.string().optional(), + scope: z.string().optional() }).optional() }) }); diff --git a/backend/src/validation/integrationAuth.ts b/backend/src/validation/integrationAuth.ts index cf3f936a9..5e235dd07 100644 --- a/backend/src/validation/integrationAuth.ts +++ b/backend/src/validation/integrationAuth.ts @@ -113,6 +113,39 @@ export const GetIntegrationAuthVercelBranchesV1 = z.object({ }) }); +export const GetIntegrationAuthQoveryOrgsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryProjectsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + orgId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryEnvironmentsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + projectId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryScopesV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + environmentId: z.string().trim() + }) +}); + export const GetIntegrationAuthRailwayEnvironmentsV1 = z.object({ params: z.object({ integrationAuthId: z.string().trim() diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index e8bf78b19..6da5411fb 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -28,6 +28,7 @@ export const INTEGRATION_TRAVISCI = "travisci"; export const INTEGRATION_TEAMCITY = "teamcity"; export const INTEGRATION_SUPABASE = "supabase"; export const INTEGRATION_CHECKLY = "checkly"; +export const INTEGRATION_QOVERY = "qovery"; export const INTEGRATION_TERRAFORM_CLOUD = "terraform-cloud"; export const INTEGRATION_HASHICORP_VAULT = "hashicorp-vault"; export const INTEGRATION_CLOUDFLARE_PAGES = "cloudflare-pages"; @@ -53,6 +54,7 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_TEAMCITY, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_QOVERY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, @@ -93,6 +95,7 @@ export const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; export const INTEGRATION_SUPABASE_API_URL = "https://api.supabase.com"; export const INTEGRATION_LARAVELFORGE_API_URL = "https://forge.laravel.com"; export const INTEGRATION_CHECKLY_API_URL = "https://api.checklyhq.com"; +export const INTEGRATION_QOVERY_API_URL = "https://api.qovery.com"; export const INTEGRATION_TERRAFORM_CLOUD_API_URL = "https://app.terraform.io"; export const INTEGRATION_CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com"; export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; @@ -272,6 +275,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "", }, + { + name: "Qovery", + slug: "qovery", + image: "Qovery.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, { name: "HashiCorp Vault", slug: "hashicorp-vault", diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index bdf820ad2..0e0b4b11e 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,6 +4,12 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. +## September + +- Released an update to access controls; every user role now clearly defines and enforces a certain set of conditions across Infisical. +- Updated UI/UX for integrations. +- Added a native integration with [Qovery](https://infisical.com/docs/integrations/cloud/qovery). + ## August 2023 - Release Audit Logs V2. diff --git a/docs/images/integrations-qovery-api-token.png b/docs/images/integrations-qovery-api-token.png new file mode 100644 index 000000000..ca595d5f7 Binary files /dev/null and b/docs/images/integrations-qovery-api-token.png differ diff --git a/docs/images/integrations-qovery-auth.png b/docs/images/integrations-qovery-auth.png new file mode 100644 index 000000000..51fe4d838 Binary files /dev/null and b/docs/images/integrations-qovery-auth.png differ diff --git a/docs/images/integrations-qovery-infisical.png b/docs/images/integrations-qovery-infisical.png new file mode 100644 index 000000000..6264b5bc1 Binary files /dev/null and b/docs/images/integrations-qovery-infisical.png differ diff --git a/docs/images/integrations-qovery-qovery.png b/docs/images/integrations-qovery-qovery.png new file mode 100644 index 000000000..c38ffb50a Binary files /dev/null and b/docs/images/integrations-qovery-qovery.png differ diff --git a/docs/integrations/cloud/qovery.mdx b/docs/integrations/cloud/qovery.mdx new file mode 100644 index 000000000..fc13b8527 --- /dev/null +++ b/docs/integrations/cloud/qovery.mdx @@ -0,0 +1,39 @@ +--- +title: "Qovery" +description: "How to sync secrets from Infisical to Qovery" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Enter your Qovery API Token + +Obtain a Qovery API Token in Settings > API Token. + +![integrations qovery api token](../../images/integrations-qovery-api-token.png) + +Press on the Qovery tile and input your Qovery API TOken to grant Infisical access to your Qovery account. + +![integrations qovery authorization](../../images/integrations-qovery-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it is necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to Qovery and press create integration to start syncing secrets. + +![integrations Infisial settings](../../images/integrations-qovery-infisical.png) + +Select your Qovery organization, project, and environment to which you want to sync secrets to. Next to that, select which scope you want secrets to (Application, Job, or Container). After you are done, hit "Create Integration." + +![integrations Qovery settings](../../images/integrations-qovery-qovery.png) diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index fc05d817c..b90cf7c36 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -27,6 +27,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Northflank](/integrations/cloud/northflank) | Cloud | Available | | [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | | [Checkly](/integrations/cloud/checkly) | Cloud | Available | +| [Qovery](/integrations/cloud/qovery) | Cloud | Available | | [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | | [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | diff --git a/docs/mint.json b/docs/mint.json index 9eaeddb1f..474e67d63 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -235,6 +235,7 @@ "integrations/cloud/teamcity", "integrations/cloud/cloudflare-pages", "integrations/cloud/checkly", + "integrations/cloud/qovery", "integrations/cloud/hashicorp-vault", "integrations/cloud/azure-key-vault", "integrations/cloud/gcp-secret-manager", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 7425cb994..9e1bfb99d 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -19,6 +19,7 @@ const integrationSlugNameMapping: Mapping = { "travisci": "TravisCI", "supabase": "Supabase", "checkly": "Checkly", + "qovery": "Qovery", "terraform-cloud": "Terraform Cloud", "teamcity": "TeamCity", "hashicorp-vault": "Vault", diff --git a/frontend/public/images/integrations/Qovery.png b/frontend/public/images/integrations/Qovery.png new file mode 100644 index 000000000..17343046b Binary files /dev/null and b/frontend/public/images/integrations/Qovery.png differ diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 37e8df768..2b31fdb13 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -9,6 +9,8 @@ import { Environment, IntegrationAuth, NorthflankSecretGroup, + Org, + Project, Service, Team, TeamCityBuildConfig} from "./types"; @@ -27,6 +29,31 @@ const integrationAuthKeys = { integrationAuthId: string; appId: string; }) => [{ integrationAuthId, appId }, "integrationAuthVercelBranches"] as const, + getIntegrationAuthQoveryOrgs: (integrationAuthId: string) => + [{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const, + getIntegrationAuthQoveryProjects: ({ + integrationAuthId, + orgId + }: { + integrationAuthId: string; + orgId: string; + }) => [{ integrationAuthId, orgId }, "integrationAuthQoveryProjects"] as const, + getIntegrationAuthQoveryEnvironments: ({ + integrationAuthId, + projectId + }: { + integrationAuthId: string; + projectId: string; + }) => [{ integrationAuthId, projectId }, "integrationAuthQoveryEnvironments"] as const, + getIntegrationAuthQoveryScopes: ({ + integrationAuthId, + environmentId, + scope + }: { + integrationAuthId: string; + environmentId: string; + scope: "Job" | "Application" | "Container"; + }) => [{ integrationAuthId, environmentId, scope }, "integrationAuthQoveryScopes"] as const, getIntegrationAuthRailwayEnvironments: ({ integrationAuthId, appId @@ -120,6 +147,115 @@ const fetchIntegrationAuthVercelBranches = async ({ return branches; }; +const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => { + const { + data: { orgs } + } = await apiRequest.get<{ orgs: Org[] }>( + `/api/v1/integration-auth/${integrationAuthId}/qovery/orgs` + ); + + return orgs; +}; + +const fetchIntegrationAuthQoveryProjects = async ({ + integrationAuthId, + orgId +}: { + integrationAuthId: string; + orgId: string; +}) => { + const { + data: { projects } + } = await apiRequest.get<{ projects: Project[] }>( + `/api/v1/integration-auth/${integrationAuthId}/qovery/projects`, + { + params: { + orgId + } + } + ); + + return projects; +}; + +const fetchIntegrationAuthQoveryEnvironments = async ({ + integrationAuthId, + projectId +}: { + integrationAuthId: string; + projectId: string; +}) => { + const { + data: { environments } + } = await apiRequest.get<{ environments: Environment[] }>( + `/api/v1/integration-auth/${integrationAuthId}/qovery/environments`, + { + params: { + projectId + } + } + ); + + return environments; +}; + +const fetchIntegrationAuthQoveryScopes = async ({ + integrationAuthId, + environmentId, + scope +}: { + integrationAuthId: string; + environmentId: string; + scope: "Job" | "Application" | "Container"; +}) => { + if (scope === "Application") { + const { + data: { apps } + } = await apiRequest.get<{ apps: App[] }>( + `/api/v1/integration-auth/${integrationAuthId}/qovery/apps`, + { + params: { + environmentId + } + } + ); + + return apps; + } + + if (scope === "Container") { + const { + data: { containers } + } = await apiRequest.get<{ containers: App[] }>( + `/api/v1/integration-auth/${integrationAuthId}/qovery/containers`, + { + params: { + environmentId + } + } + ); + + return containers; + } + + if (scope === "Job") { + const { + data: { jobs } + } = await apiRequest.get<{ jobs: App[] }>( + `/api/v1/integration-auth/${integrationAuthId}/qovery/jobs`, + { + params: { + environmentId + } + } + ); + + return jobs; + } + + return undefined; +}; + const fetchIntegrationAuthRailwayEnvironments = async ({ integrationAuthId, appId @@ -269,6 +405,82 @@ export const useGetIntegrationAuthVercelBranches = ({ }); }; +export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthQoveryOrgs(integrationAuthId), + queryFn: () => + fetchIntegrationAuthQoveryOrgs(integrationAuthId), + enabled: true + }); +}; + +export const useGetIntegrationAuthQoveryProjects = ({ + integrationAuthId, + orgId +}: { + integrationAuthId: string; + orgId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthQoveryProjects({ + integrationAuthId, + orgId + }), + queryFn: () => + fetchIntegrationAuthQoveryProjects({ + integrationAuthId, + orgId + }), + enabled: true + }); +}; + +export const useGetIntegrationAuthQoveryEnvironments = ({ + integrationAuthId, + projectId +}: { + integrationAuthId: string; + projectId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthQoveryEnvironments({ + integrationAuthId, + projectId + }), + queryFn: () => + fetchIntegrationAuthQoveryEnvironments({ + integrationAuthId, + projectId + }), + enabled: true + }); +}; + +export const useGetIntegrationAuthQoveryScopes = ({ + integrationAuthId, + environmentId, + scope +}: { + integrationAuthId: string; + environmentId: string; + scope: "Job" | "Application" | "Container"; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthQoveryScopes({ + integrationAuthId, + environmentId, + scope + }), + queryFn: () => + fetchIntegrationAuthQoveryScopes({ + integrationAuthId, + environmentId, + scope + }), + 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 47f0fcfc9..2292e3222 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -26,6 +26,21 @@ export type Environment = { environmentId: string; }; +export type Container = { + name: string; + containerId: string; +}; + +export type Org = { + name: string; + orgId: string; +}; + +export type Project = { + name: string; + projectId: string; +}; + export type Service = { name: string; serviceId: string; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index de5aa6da0..61ad89191 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -59,6 +59,13 @@ export const useCreateIntegration = () => { metadata?: { secretPrefix?: string; secretSuffix?: string; + org?: string; + orgId?: string; + project?: string; + projectId?: string; + environment?: string; + environmentId?: string; + scope?: string; } }) => { const { data: { integration } } = await apiRequest.post("/api/v1/integration", { diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 199d02975..c2cc9ecf6 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -32,5 +32,9 @@ export type TIntegration = { __v: number; metadata?: { secretSuffix?: string; + scope: string; + org: string; + project: string; + environment: string; } }; diff --git a/frontend/src/pages/integrations/qovery/authorize.tsx b/frontend/src/pages/integrations/qovery/authorize.tsx new file mode 100644 index 000000000..a3bab2569 --- /dev/null +++ b/frontend/src/pages/integrations/qovery/authorize.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + useSaveIntegrationAccessToken +} from "@app/hooks/api"; + +import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; + +export default function QoveryCreateIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useSaveIntegrationAccessToken(); + + const [accessToken, setAccessToken] = useState(""); + const [accessTokenErrorText, setAccessTokenErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setAccessTokenErrorText(""); + if (accessToken.length === 0) { + setAccessTokenErrorText("Access token cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "qovery", + accessToken + }); + + setIsLoading(false); + + router.push(`/integrations/qovery/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Authorize Qovery Integration + + + + +
+
+ Qovery logo +
+ Qovery Integration + + +
+ + Docs + +
+
+ +
+
+ + setAccessToken(e.target.value)} + /> + + +
+
+ ); +} + +QoveryCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/qovery/create.tsx b/frontend/src/pages/integrations/qovery/create.tsx new file mode 100644 index 000000000..0c6ad1475 --- /dev/null +++ b/frontend/src/pages/integrations/qovery/create.tsx @@ -0,0 +1,403 @@ +import { useEffect, useState } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen, faBugs } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import queryString from "query-string"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem, + Tab, + TabList, + TabPanel, + Tabs +} from "@app/components/v2"; +import { + useCreateIntegration +} from "@app/hooks/api"; +import { useGetIntegrationAuthQoveryEnvironments, useGetIntegrationAuthQoveryOrgs, useGetIntegrationAuthQoveryProjects, useGetIntegrationAuthQoveryScopes } from "@app/hooks/api/integrationAuth/queries"; + +import { useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; + +enum TabSections { + InfisicalSettings = "infisicalSettings", + QoverySettings = "qoverySettings" +} + +export default function QoveryCreateIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useCreateIntegration(); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + + const [scope, setScope] = useState("Application"); + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + + const { data: integrationAuthOrgs } = useGetIntegrationAuthQoveryOrgs((integrationAuthId as string) ?? ""); + const [targetOrg, setTargetOrg] = useState(""); + const [targetOrgId, setTargetOrgId] = useState(""); + + const { data: integrationAuthProjects } = useGetIntegrationAuthQoveryProjects({ + integrationAuthId: (integrationAuthId as string) ?? "", + orgId: targetOrgId + }); + const [targetProject, setTargetProject] = useState(""); + const [targetProjectId, setTargetProjectId] = useState(""); + + const { data: integrationAuthEnvironments } = useGetIntegrationAuthQoveryEnvironments({ + integrationAuthId: (integrationAuthId as string) ?? "", + projectId: targetProjectId + }); + const [targetEnvironment, setTargetEnvironment] = useState(""); + const [targetEnvironmentId, setTargetEnvironmentId] = useState(""); + + const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthQoveryScopes({ + integrationAuthId: (integrationAuthId as string) ?? "", + environmentId: targetEnvironmentId, + scope: (scope as ("Job" | "Application" | "Container")) + }); + const [targetApp, setTargetApp] = useState(""); + const [targetAppId, setTargetAppId] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + + const scopes = ["Application", "Container", "Job"]; + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + setTargetAppId(String(integrationAuthApps[0].appId)); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetAppId(String(integrationAuthApps.filter(app => app.name === targetApp)[0].appId)); + } + } + }, [targetApp]); + + useEffect(() => { + if (integrationAuthOrgs) { + if (integrationAuthOrgs.length > 0) { + setTargetOrg(integrationAuthOrgs[0].name); + setTargetOrgId(String(integrationAuthOrgs[0].orgId)); + } else { + setTargetOrg("none"); + } + } + }, [integrationAuthOrgs]); + + useEffect(() => { + if (integrationAuthProjects) { + if (integrationAuthProjects.length > 0) { + setTargetProject(integrationAuthProjects[0].name); + setTargetProjectId(String(integrationAuthProjects[0].projectId)); + } else { + setTargetProject("none"); + } + } + }, [integrationAuthProjects]); + + useEffect(() => { + if (integrationAuthEnvironments) { + if (integrationAuthEnvironments.length > 0) { + setTargetEnvironment(integrationAuthEnvironments[0].name); + setTargetEnvironmentId(String(integrationAuthEnvironments[0].environmentId)); + } else { + setTargetEnvironment("none"); + } + } + }, [integrationAuthEnvironments]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await mutateAsync({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: targetAppId, + sourceEnvironment: selectedSourceEnvironment, + secretPath, + metadata: { + scope, + org: targetOrg, + orgId: targetOrgId, + project: targetProject, + projectId: targetProjectId, + environment: targetEnvironment, + environmentId: targetEnvironmentId, + } + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment ? ( +
+ + Set Up Qovery Integration + + + + +
+
+ Qovery logo +
+ Qovery Integration + + +
+ + Docs + +
+
+ +
+
+ + +
+ Infisical Settings + Qovery Settings +
+
+ + + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + + + + {integrationAuthOrgs && + + } + {integrationAuthProjects && + + } + {integrationAuthEnvironments && + + } + {(scope && integrationAuthApps) && + + } + + +
+ +
+ {/*
+
+
Pro Tips
+ After creating an integration, your secrets will start syncing immediately. This might cause an unexpected override of current secrets in Qovery with secrets from Infisical. +
*/} +
+ ) : ( +
+ + Set Up Qovery Integration + + + {isIntegrationAuthAppsLoading ? infisical loading indicator :
+ +

+ Something went wrong. Please contact + support@infisical.com + if the issue persists. +

+
} +
+ ); +} + +QoveryCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index f45e16ab6..c9fa6c585 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -87,6 +87,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "checkly": link = `${window.location.origin}/integrations/checkly/authorize`; break; + case "qovery": + link = `${window.location.origin}/integrations/qovery/authorize`; + break; case "railway": link = `${window.location.origin}/integrations/railway/authorize`; break; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 701766a9e..4d441a1fe 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -96,8 +96,30 @@ export const IntegrationsSection = ({ {integrationSlugNameMapping[integration.integration]}
+ {(integration.integration === "qovery") && ( +
+
+ +
+ {integration?.metadata?.org || "-"} +
+
+
+ +
+ {integration?.metadata?.project || "-"} +
+
+
+ +
+ {integration?.metadata?.environment || "-"} +
+
+
+ )}
- +
{integration.integration === "hashicorp-vault" ? `${integration.app} - path: ${integration.path}` @@ -131,7 +153,7 @@ export const IntegrationsSection = ({ I={ProjectPermissionActions.Delete} a={ProjectPermissionSub.Integrations} > - {(isAllowed) => ( + {(isAllowed: boolean) => (