diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index dea45ac99..34dae9417 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; @@ -842,4 +849,60 @@ const getAppsCodefresh = async ({ return apps; }; + +/** + * Return list of projects for Cloud 66 integration + */ + +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..d8e6b6116 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; } }; @@ -2101,4 +2110,79 @@ const syncSecretsCodefresh = async ({ ); }; + +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 currentSecrets = ( + await standardRequest.get( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ) + ).data.response as Cloud66Secret[]; + + // filter out generated secrets + const secretsToDelete = currentSecrets.filter((s) => !s.is_generated); + + // delete all current secrets + await Promise.all( + secretsToDelete.map( + async (s) => + await standardRequest.delete( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments/${s.key}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ) + ) + ); + + // add new secrets + await Promise.all( + Object.entries(secrets).map( + async ([key, value]) => + await standardRequest.post( + `${INTEGRATION_CLOUD_66_API_URL}/3/stacks/${integration.appId}/environments`, + { + key, + value + }, + { + 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/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..0af32b56d --- /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("API Key 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..326e417e5 --- /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; }