diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index dea45ac99..51cf43c45 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -1,6 +1,3 @@ -import { Octokit } from "@octokit/rest"; -import { IIntegrationAuth } from "../models"; -import { standardRequest } from "../config/request"; import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, @@ -13,6 +10,8 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CLOUDFLARE_PAGES_API_URL, + INTEGRATION_CLOUD_66, + INTEGRATION_CLOUD_66_API_URL, INTEGRATION_CODEFRESH, INTEGRATION_CODEFRESH_API_URL, INTEGRATION_FLYIO, @@ -37,6 +36,9 @@ import { INTEGRATION_VERCEL, INTEGRATION_VERCEL_API_URL } from "../variables"; +import { IIntegrationAuth } from "../models"; +import { Octokit } from "@octokit/rest"; +import { standardRequest } from "../config/request"; interface App { name: string; @@ -162,6 +164,11 @@ const getApps = async ({ accessToken, }); break; + case INTEGRATION_CLOUD_66: + apps = await getAppsCloud66({ + accessToken, + }); + break; } return apps; @@ -819,7 +826,6 @@ const getAppsBitBucket = async ({ * @returns {Object[]} apps - names of Supabase apps * @returns {String} apps.name - name of Supabase app */ - const getAppsCodefresh = async ({ accessToken, }: { @@ -842,4 +848,64 @@ const getAppsCodefresh = async ({ return apps; }; + +/** + * Return list of applications for Cloud66 integration + * @param {Object} obj + * @param {String} obj.accessToken - personal access token for Cloud66 API + * @returns {Object[]} apps - Cloud66 apps + * @returns {String} apps.name - name of Cloud66 app + * @returns {String} apps.appId - uid of Cloud66 app + */ +const getAppsCloud66 = async ({ accessToken }: { accessToken: string }) => { + interface Cloud66Apps { + uid: string; + name: string; + account_id: number; + git: string; + git_branch: string; + environment: string; + cloud: string; + fqdn: string; + language: string; + framework: string; + status: number; + health: number; + last_activity: string; + last_activity_iso: string; + maintenance_mode: boolean; + has_loadbalancer: boolean; + created_at: string; + updated_at: string; + deploy_directory: string; + cloud_status: string; + backend: string; + version: string; + revision: string; + is_busy: boolean; + account_name: string; + is_cluster: boolean; + is_inside_cluster: boolean; + cluster_name: any; + application_address: string; + configstore_namespace: string; + } + + const stacks = ( + await standardRequest.get(`${INTEGRATION_CLOUD_66_API_URL}/3/stacks`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data.response as Cloud66Apps[] + + const apps = stacks.map((app) => ({ + name: app.name, + appId: app.uid + })); + + return apps; +}; + export { getApps }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 2c2ed8d1f..aef9c52fe 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,14 +1,10 @@ -import _ from "lodash"; -import AWS from "aws-sdk"; import { CreateSecretCommand, GetSecretValueCommand, ResourceNotFoundException, SecretsManagerClient, - UpdateSecretCommand, + UpdateSecretCommand } from "@aws-sdk/client-secrets-manager"; -import { Octokit } from "@octokit/rest"; -import sodium from "libsodium-wrappers"; import { IIntegration, IIntegrationAuth } from "../models"; import { INTEGRATION_AWS_PARAMETER_STORE, @@ -22,6 +18,8 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CLOUDFLARE_PAGES_API_URL, + INTEGRATION_CLOUD_66, + INTEGRATION_CLOUD_66_API_URL, INTEGRATION_CODEFRESH, INTEGRATION_CODEFRESH_API_URL, INTEGRATION_FLYIO, @@ -47,6 +45,10 @@ import { INTEGRATION_VERCEL, INTEGRATION_VERCEL_API_URL } from "../variables"; +import AWS from "aws-sdk"; +import { Octokit } from "@octokit/rest"; +import _ from "lodash"; +import sodium from "libsodium-wrappers"; import { standardRequest } from "../config/request"; /** @@ -220,6 +222,13 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_CLOUD_66: + await syncSecretsCloud66({ + integration, + secrets, + accessToken + }); + break; } }; @@ -2068,10 +2077,11 @@ const syncSecretsBitBucket = async ({ } } -/* - * Sync/push [secrets] to Codefresh with name [integration.app] +/** + * Sync/push [secrets] to Codefresh project with name [integration.app] * @param {Object} obj * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth 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 Codefresh integration */ @@ -2101,4 +2111,106 @@ const syncSecretsCodefresh = async ({ ); }; +/** + * Sync/push [secrets] to Cloud66 application with name [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth 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 Cloud66 integration + */ +const syncSecretsCloud66 = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + interface Cloud66Secret { + id: number; + key: string; + value: string; + readonly: boolean; + created_at: string; + updated_at: string; + is_password: boolean; + is_generated: boolean; + history: any[]; + } + + // get all current secrets + const res = ( + await standardRequest.get( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ) + ) + .data + .response + .filter((secret: Cloud66Secret) => !secret.readonly || !secret.is_generated) + .reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), + {} + ); + + for await (const key of Object.keys(secrets)) { + if (key in res) { + // update existing secret + await standardRequest.put( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments/${key}`, + { + key, + value: secrets[key] + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } else { + // create new secret + await standardRequest.post( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments`, + { + key, + value: secrets[key] + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } + } + + for await (const key of Object.keys(res)) { + if (!(key in secrets)) { + // delete secret + await standardRequest.delete( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments/${key}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } + } +}; + export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 05e089f37..f3234b616 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,4 +1,3 @@ -import { Schema, Types, model } from "mongoose"; import { INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, @@ -7,6 +6,7 @@ import { INTEGRATION_CHECKLY, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUD_66, INTEGRATION_CODEFRESH, INTEGRATION_FLYIO, INTEGRATION_GITHUB, @@ -21,6 +21,7 @@ import { INTEGRATION_TRAVISCI, INTEGRATION_VERCEL } from "../variables"; +import { Schema, Types, model } from "mongoose"; export interface IIntegration { _id: Types.ObjectId; @@ -59,6 +60,7 @@ export interface IIntegration { | "cloudflare-pages" | "bitbucket" | "codefresh" + | "cloud-66" integrationAuth: Types.ObjectId; } @@ -149,7 +151,8 @@ const integrationSchema = new Schema( INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, - INTEGRATION_CODEFRESH + INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66, ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 409249bf3..b1f4e4c7e 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,4 +1,3 @@ -import { Document, Schema, Types, model } from "mongoose"; import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, @@ -9,6 +8,7 @@ import { INTEGRATION_BITBUCKET, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUD_66, INTEGRATION_CODEFRESH, INTEGRATION_FLYIO, INTEGRATION_GITHUB, @@ -23,6 +23,7 @@ import { INTEGRATION_TRAVISCI, INTEGRATION_VERCEL } from "../variables"; +import { Document, Schema, Types, model } from "mongoose"; export interface IIntegrationAuth extends Document { _id: Types.ObjectId; @@ -46,7 +47,8 @@ export interface IIntegrationAuth extends Document { | "checkly" | "cloudflare-pages" | "codefresh" - | "bitbucket"; + | "bitbucket" + | "cloud-66"; teamId: string; accountId: string; url: string; @@ -93,7 +95,8 @@ const integrationAuthSchema = new Schema( INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, - INTEGRATION_CODEFRESH + INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66, ], required: true, }, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index e99b5a3bf..c005ac715 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -29,6 +29,7 @@ export const INTEGRATION_HASHICORP_VAULT = "hashicorp-vault"; export const INTEGRATION_CLOUDFLARE_PAGES = "cloudflare-pages"; export const INTEGRATION_BITBUCKET = "bitbucket"; export const INTEGRATION_CODEFRESH = "codefresh"; +export const INTEGRATION_CLOUD_66 = "cloud-66"; export const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -46,7 +47,8 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, - INTEGRATION_CODEFRESH + INTEGRATION_CODEFRESH, + INTEGRATION_CLOUD_66 ]); // integration types @@ -79,6 +81,7 @@ export const INTEGRATION_CHECKLY_API_URL = "https://api.checklyhq.com"; export const INTEGRATION_CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com"; export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; export const INTEGRATION_CODEFRESH_API_URL = "https://g.codefresh.io/api"; +export const INTEGRATION_CLOUD_66_API_URL = "https://app.cloud66.com/api"; export const getIntegrationOptions = async () => { const INTEGRATION_OPTIONS = [ @@ -271,7 +274,16 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "", - } + }, + { + name: "Cloud 66", + slug: "cloud-66", + image: "Cloud 66.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, ] return INTEGRATION_OPTIONS; diff --git a/docs/images/integrations-cloud-66-access-token.png b/docs/images/integrations-cloud-66-access-token.png new file mode 100644 index 000000000..a3532a6b4 Binary files /dev/null and b/docs/images/integrations-cloud-66-access-token.png differ diff --git a/docs/images/integrations-cloud-66-copy-pat.png b/docs/images/integrations-cloud-66-copy-pat.png new file mode 100644 index 000000000..41d9e14af Binary files /dev/null and b/docs/images/integrations-cloud-66-copy-pat.png differ diff --git a/docs/images/integrations-cloud-66-create.png b/docs/images/integrations-cloud-66-create.png new file mode 100644 index 000000000..24b14c398 Binary files /dev/null and b/docs/images/integrations-cloud-66-create.png differ diff --git a/docs/images/integrations-cloud-66-dashboard.png b/docs/images/integrations-cloud-66-dashboard.png new file mode 100644 index 000000000..4598fc915 Binary files /dev/null and b/docs/images/integrations-cloud-66-dashboard.png differ diff --git a/docs/images/integrations-cloud-66-done.png b/docs/images/integrations-cloud-66-done.png new file mode 100644 index 000000000..0541374b6 Binary files /dev/null and b/docs/images/integrations-cloud-66-done.png differ diff --git a/docs/images/integrations-cloud-66-infisical-dashboard.png b/docs/images/integrations-cloud-66-infisical-dashboard.png new file mode 100644 index 000000000..e54eea726 Binary files /dev/null and b/docs/images/integrations-cloud-66-infisical-dashboard.png differ diff --git a/docs/images/integrations-cloud-66-paste-pat.png b/docs/images/integrations-cloud-66-paste-pat.png new file mode 100644 index 000000000..276da0e0d Binary files /dev/null and b/docs/images/integrations-cloud-66-paste-pat.png differ diff --git a/docs/images/integrations-cloud-66-pat-setup.png b/docs/images/integrations-cloud-66-pat-setup.png new file mode 100644 index 000000000..0d54641fc Binary files /dev/null and b/docs/images/integrations-cloud-66-pat-setup.png differ diff --git a/docs/images/integrations-cloud-66-pat.png b/docs/images/integrations-cloud-66-pat.png new file mode 100644 index 000000000..fd3434374 Binary files /dev/null and b/docs/images/integrations-cloud-66-pat.png differ diff --git a/docs/integrations/cloud/cloud-66.mdx b/docs/integrations/cloud/cloud-66.mdx new file mode 100644 index 000000000..86c28440e --- /dev/null +++ b/docs/integrations/cloud/cloud-66.mdx @@ -0,0 +1,55 @@ +--- +title: "Cloud 66" +description: "How to sync secrets from Infisical to Cloud 66" +--- + +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 Cloud 66 Access Token + +In Cloud 66 Dashboard, click on the top right icon > Account Settings > Access Token +![integrations cloud 66 dashboard](../../images/integrations-cloud-66-dashboard.png) +![integrations cloud 66 access token](../../images/integrations-cloud-66-access-token.png) + +Create new Personal Access Token. +![integrations cloud 66 personal access token](../../images/integrations-cloud-66-pat.png) + +Name it **infisical** and check **Public** and **Admin**. Then click "Create Token" +![integrations cloud 66 personal access token setup](../../images/integrations-cloud-66-pat-setup.png) + +Copy and save your token. +![integrations cloud 66 copy API token](../../images/integrations-cloud-66-copy-pat.png) + +### Go to Infisical Integration Page + +Click on the Cloud 66 tile and enter your API token to grant Infisical access to your Cloud 66 account. +![integrations cloud 66 tile in infisical dashboard](../../images/integrations-cloud-66-infisical-dashboard.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's necessary for Infisical to sync the environment variables to + the cloud platform. + + +Enter your Cloud 66 Personal Access Token here. Then click "Connect to Cloud 66". +![integrations cloud 66 tile in infisical dashboard](../../images/integrations-cloud-66-paste-pat.png) + + +## Start integration + +Select which Infisical environment secrets you want to sync to which Cloud 66 stacks and press create integration to start syncing secrets to Cloud 66. +![integrations laravel forge](../../images/integrations-cloud-66-create.png) + + + Any existing environment variables in Cloud 66 will be deleted when you start syncing. Make sure to add all the secrets into the Infisical dashboard first before doing any integrations. + + +Done! +![integrations laravel forge](../../images/integrations-cloud-66-done.png) diff --git a/docs/mint.json b/docs/mint.json index e93dc2c86..ba4324027 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -220,6 +220,7 @@ "integrations/cloud/checkly", "integrations/cloud/hashicorp-vault", "integrations/cloud/azure-key-vault", + "integrations/cloud/cloud-66", "integrations/cicd/githubactions", "integrations/cicd/gitlab", "integrations/cicd/circleci", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index d4c0aa729..996860826 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -22,7 +22,8 @@ const integrationSlugNameMapping: Mapping = { "hashicorp-vault": "Vault", "cloudflare-pages": "Cloudflare Pages", "codefresh": "Codefresh", - bitbucket: "BitBucket" + bitbucket: "BitBucket", + "cloud-66": "Cloud 66" }; const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Cloud 66.png b/frontend/public/images/integrations/Cloud 66.png new file mode 100644 index 000000000..20d841080 Binary files /dev/null and b/frontend/public/images/integrations/Cloud 66.png differ diff --git a/frontend/src/pages/integrations/cloud-66/authorize.tsx b/frontend/src/pages/integrations/cloud-66/authorize.tsx new file mode 100644 index 000000000..8c6434936 --- /dev/null +++ b/frontend/src/pages/integrations/cloud-66/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 Cloud66CreateIntegrationPage() { + 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("Access token cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "cloud-66", + accessId: null, + accessToken: apiKey, + url: null, + namespace: null + }); + + setIsLoading(false); + + router.push(`/integrations/cloud-66/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Cloud 66 Integration + + setApiKey(e.target.value)} /> + + + +
+ ); +} + +Cloud66CreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/cloud-66/create.tsx b/frontend/src/pages/integrations/cloud-66/create.tsx new file mode 100644 index 000000000..dbf566464 --- /dev/null +++ b/frontend/src/pages/integrations/cloud-66/create.tsx @@ -0,0 +1,155 @@ +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, +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function Cloud66CreateIntegrationPage() { + const router = useRouter(); + + 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 [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [targetApp, setTargetApp] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: + integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp) + ?.appId ?? null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, + 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 && + targetApp ? ( +
+ + Cloud 66 Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + +
+ ) : ( +
+ ); +} + +Cloud66CreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index a5be32502..9a3316822 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -98,6 +98,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "codefresh": link = `${window.location.origin}/integrations/codefresh/authorize`; break; + case "cloud-66": + link = `${window.location.origin}/integrations/cloud-66/authorize`; + break; default: break; }