diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 3b86b0fdb..06b245659 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,362 @@ 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 + ); + + interface Project { + name: string; + projectId: string; + } + + interface QoveryProject { + id: string; + name: string; + } + + let projects: Project[] = []; + + if (orgId && orgId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/organization/${orgId}/project`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + 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 project with id [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 + ); + + interface Environment { + name: string; + environmentId: string; + } + + interface QoveryEnvironment { + id: string; + name: string; + } + + let environments: Environment[] = []; + + if (projectId && projectId !== "" && projectId !== "none") { // TODO: fix + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/project/${projectId}/environment`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + 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 environment with id [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 + ); + + interface App { + name: string; + appId: string; + } + + interface QoveryApp { + id: string; + name: string; + } + + let apps: App[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/application`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + 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 environment with id [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 + ); + + interface Container { + name: string; + appId: string; + } + + interface QoveryContainer { + id: string; + name: string; + } + + let containers: Container[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/container`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + 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 environment with id [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 + ); + + interface Job { + name: string; + appId: string; + } + + interface QoveryJob { + id: string; + name: string; + } + + let jobs: Job[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/job`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + 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/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index b847b382c..e709d9d0c 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -34,6 +34,7 @@ export const createIntegration = async (req: Request, res: Response) => { appId, owner, region, + scope, targetService, targetServiceId, integrationAuthId, @@ -42,7 +43,7 @@ export const createIntegration = async (req: Request, res: Response) => { metadata } } = await validateRequest(reqValidator.CreateIntegrationV1, req); - + const integrationAuth = await IntegrationAuth.findById(integrationAuthId) .populate<{ workspace: IWorkspace }>("workspace") .select( @@ -90,6 +91,7 @@ export const createIntegration = async (req: Request, res: Response) => { owner, path, region, + scope, secretPath, integration: integrationAuth.integration, integrationAuth: new Types.ObjectId(integrationAuthId), diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index cb45a9d95..9cd92af09 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,97 @@ 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.scope}/${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.scope}/${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.scope}/${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..7b9957393 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, @@ -45,6 +46,7 @@ export interface IIntegration { targetServiceId: string; path: string; region: string; + scope: string; secretPath: string; integration: | "azure-key-vault" @@ -63,6 +65,7 @@ export interface IIntegration { | "travisci" | "supabase" | "checkly" + | "qovery" | "terraform-cloud" | "teamcity" | "hashicorp-vault" @@ -119,11 +122,13 @@ const integrationSchema = new Schema( }, targetService: { // railway-specific service + // qovery-specific project type: String, default: null, }, targetServiceId: { // railway-specific service + // qovery specific project type: String, default: null, }, @@ -143,6 +148,11 @@ const integrationSchema = new Schema( type: String, default: null, }, + scope: { + // qovery-specific scope + type: String, + default: null + }, integration: { type: String, enum: [ @@ -162,6 +172,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..5c4387bba 100644 --- a/backend/src/models/integration/types.ts +++ b/backend/src/models/integration/types.ts @@ -1,6 +1,3 @@ - -// TODO: in the future separate metadata -// into distinct types by integration export type Metadata = { secretPrefix?: string; secretSuffix?: string; 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..20c02efc5 100644 --- a/backend/src/validation/integration.ts +++ b/backend/src/validation/integration.ts @@ -76,13 +76,14 @@ export const CreateIntegrationV1 = z.object({ owner: z.string().trim().optional(), path: z.string().trim().optional(), region: z.string().trim().optional(), + scope: z.string().trim().optional(), metadata: z.object({ secretPrefix: z.string().optional(), secretSuffix: z.string().optional(), secretGCPLabel: z.object({ labelName: z.string(), labelValue: z.string() - }).optional() + }).optional(), }).optional() }) }); diff --git a/backend/src/validation/integrationAuth.ts b/backend/src/validation/integrationAuth.ts index 74cede2b9..feeee4931 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 5caa975b2..3adfad4a8 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, @@ -94,6 +96,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"; @@ -273,6 +276,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/integrations-qovery-auth.png b/docs/images/integrations/qovery/integrations-qovery-auth.png new file mode 100644 index 000000000..3619bc87b Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-auth.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-create-1.png b/docs/images/integrations/qovery/integrations-qovery-create-1.png new file mode 100644 index 000000000..cfbc05895 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-create-1.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-create-2.png b/docs/images/integrations/qovery/integrations-qovery-create-2.png new file mode 100644 index 000000000..7c186b6b5 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-create-2.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-token.png b/docs/images/integrations/qovery/integrations-qovery-token.png new file mode 100644 index 000000000..aa1b81b7d Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-token.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery.png b/docs/images/integrations/qovery/integrations-qovery.png new file mode 100644 index 000000000..4a8f5c400 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery.png differ diff --git a/docs/integrations/cloud/qovery.mdx b/docs/integrations/cloud/qovery.mdx new file mode 100644 index 000000000..98539dc8f --- /dev/null +++ b/docs/integrations/cloud/qovery.mdx @@ -0,0 +1,43 @@ +--- +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/integrations-qovery-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/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 qovery create](../../images/integrations/qovery/integrations-qovery-create-1.png) + +![integrations qovery create](../../images/integrations/qovery/integrations-qovery-create-2.png) + + + Infisical supports syncing secrets to various Qovery scopes including applications, jobs, or containers. + + +![integrations qovery settings](../../images/integrations/qovery/integrations-qovery.png) \ No newline at end of file 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 260b174b0..19e26d54e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -238,6 +238,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 981944e2a..9213919e0 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,121 @@ 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; +}) => { + if (orgId === "none") return []; + + 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; +}) => { + if (projectId === "none") return []; + + 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 (environmentId === "none") return []; + + 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 @@ -271,6 +413,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..0ccc0a3ce 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -40,6 +40,7 @@ export const useCreateIntegration = () => { owner, path, region, + scope, secretPath, metadata }: { @@ -56,6 +57,7 @@ export const useCreateIntegration = () => { owner?: string; path?: string; region?: string; + scope?: string; metadata?: { secretPrefix?: string; secretSuffix?: string; @@ -73,6 +75,7 @@ export const useCreateIntegration = () => { targetServiceId, owner, path, + scope, region, secretPath, metadata 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..ecf46a31f --- /dev/null +++ b/frontend/src/pages/integrations/qovery/create.tsx @@ -0,0 +1,409 @@ +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"; + +const qoveryScopes = [ + { label: "Application", value: "application" }, + { label: "Container", value: "container" }, + { label: "Job", value: "job" } +]; + +enum TabSections { + InfisicalSettings = "infisicalSettings", + QoverySettings = "qoverySettings" +} + +/** + * Follow the logic: + * - Access token + * -> Get organizations (orgId) - select an organization + * -> Get projects belonging to the organization - select project + * -> Get environments belonging to the project - select environmnet + * -> Get qovery scopes / application (this is the application to sync to) + * + * Do we even need to get organizations? + */ + +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 [targetOrgId, setTargetOrgId] = useState(""); + + const { data: integrationAuthProjects } = useGetIntegrationAuthQoveryProjects({ + integrationAuthId: (integrationAuthId as string) ?? "", + orgId: targetOrgId + }); + const [targetProjectId, setTargetProjectId] = useState(""); + + const { data: integrationAuthEnvironments } = useGetIntegrationAuthQoveryEnvironments({ + integrationAuthId: (integrationAuthId as string) ?? "", + projectId: targetProjectId + }); + const [targetEnvironmentId, setTargetEnvironmentId] = useState(""); + + const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthQoveryScopes({ + integrationAuthId: (integrationAuthId as string) ?? "", + environmentId: targetEnvironmentId, + scope: (scope as ("job" | "application" | "container")) + }); + const [targetAppId, setTargetAppId] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetAppId(String(integrationAuthApps[0].appId)); + } else { + setTargetAppId("none"); + } + } + }, [integrationAuthApps]); + + useEffect(() => { + if (integrationAuthOrgs) { + if (integrationAuthOrgs.length > 0) { + setTargetOrgId(String(integrationAuthOrgs[0].orgId)); + } else { + setTargetOrgId("none"); + } + } + }, [integrationAuthOrgs]); + + useEffect(() => { + if (integrationAuthProjects) { + if (integrationAuthProjects.length > 0) { + setTargetProjectId(String(integrationAuthProjects[0].projectId)); + } else { + setTargetProjectId("none"); + } + } + }, [integrationAuthProjects]); + + useEffect(() => { + if (integrationAuthEnvironments) { + if (integrationAuthEnvironments.length > 0) { + setTargetEnvironmentId(String(integrationAuthEnvironments[0].environmentId)); + } else { + setTargetEnvironmentId("none"); + } + } + }, [integrationAuthEnvironments]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + const targetOrg = integrationAuthOrgs?.find((integrationAuthOrg) => integrationAuthOrg.orgId === targetOrgId)?.name; + const targetProject = integrationAuthProjects?.find((integrationAuthProject) => integrationAuthProject.projectId === targetProjectId)?.name; + const targetEnvironment = integrationAuthEnvironments?.find((integrationAuthEnvironment) => integrationAuthEnvironment.environmentId === targetEnvironmentId)?.name; + const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId)?.name; + + await mutateAsync({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: targetAppId, + scope, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment, + targetEnvironmentId, + targetService: targetProject, + targetServiceId: targetProjectId, + owner: targetOrg, + secretPath + }); + + 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..04f7e5bd9 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?.owner || "-"} +
+
+
+ +
+ {integration?.targetService || "-"} +
+
+
+ +
+ {integration?.targetEnvironment || "-"} +
+
+
+ )}
- +
{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) => (