diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 69810d025..c88a05d41 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -33,6 +33,8 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_SUPABASE, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_TERRAFORM_CLOUD_API_URL, INTEGRATION_TRAVISCI, INTEGRATION_TRAVISCI_API_URL, INTEGRATION_VERCEL, @@ -134,6 +136,12 @@ const getApps = async ({ serverId: accessId }); break; + case INTEGRATION_TERRAFORM_CLOUD: + apps = await getAppsTerraformCloud({ + accessToken, + workspacesId: accessId, + }); + break; case INTEGRATION_TRAVISCI: apps = await getAppsTravisCI({ accessToken, @@ -563,6 +571,43 @@ const getAppsTravisCI = async ({ accessToken }: { accessToken: string }) => { return apps; }; +/** + * Return list of projects for Terraform Cloud integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Terraform Cloud API + * @param {String} obj.workspacesId - workspace id of Terraform Cloud projects + * @returns {Object[]} apps - names and ids of Terraform Cloud projects + * @returns {String} apps.name - name of Terraform Cloud projects + */ +const getAppsTerraformCloud = async ({ + accessToken, + workspacesId +}: { + accessToken: string; + workspacesId?: string; +}) => { + const res = ( + await standardRequest.get(`${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${workspacesId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + ).data.data; + + const apps = [] + + const appsObj = { + name: res?.attributes.name, + appId: res?.id, + }; + + apps.push(appsObj) + + return apps; +}; + + /** * Return list of repositories for GitLab integration * @param {Object} obj diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 9afb0e4c5..8faac1257 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -42,6 +42,8 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_SUPABASE, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_TERRAFORM_CLOUD_API_URL, INTEGRATION_TRAVISCI, INTEGRATION_TRAVISCI_API_URL, INTEGRATION_VERCEL, @@ -193,6 +195,13 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_TERRAFORM_CLOUD: + await syncSecretsTerraformCloud({ + integration, + secrets, + accessToken, + }); + break; case INTEGRATION_HASHICORP_VAULT: await syncSecretsHashiCorpVault({ integration, @@ -1840,6 +1849,106 @@ const syncSecretsCheckly = async ({ } }; +/** + * Sync/push [secrets] to Terraform Cloud project with id [integration.appId] + * @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 Terraform Cloud API + */ +const syncSecretsTerraformCloud = async ({ + integration, + secrets, + accessToken, +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + // get secrets from Terraform Cloud + const getSecretsRes = ( + await standardRequest.get(`${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + } + )) + .data + .data + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.attributes.key]: secret + }), {}); + + // create or update secrets on Terraform Cloud + for await (const key of Object.keys(secrets)) { + if (!(key in getSecretsRes)) { + // case: secret does not exist in Terraform Cloud + // -> add secret + await standardRequest.post( + `${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars`, + { + data: { + type: "vars", + attributes: { + key, + value: secrets[key], + category: integration.targetService, + }, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + } + ); + } else { + // case: secret exists in Terraform Cloud + if (secrets[key] !== getSecretsRes[key].attributes.value) { + // -> update secret + await standardRequest.patch( + `${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`, + { + data: { + type: "vars", + id: getSecretsRes[key].id, + attributes: { + ...getSecretsRes[key], + value: secrets[key] + }, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + } + ); + } + } + } + + for await (const key of Object.keys(getSecretsRes)) { + if (!(key in secrets)) { + // case: delete secret + await standardRequest.delete(`${INTEGRATION_TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + }) + } + } +}; + /** * Sync/push [secrets] to HashiCorp Vault path * @param {Object} obj diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index e79e85ce9..34dba6cfc 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -19,6 +19,7 @@ import { INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL } from "../variables"; @@ -57,6 +58,7 @@ export interface IIntegration { | "travisci" | "supabase" | "checkly" + | "terraform-cloud" | "hashicorp-vault" | "cloudflare-pages" | "bitbucket" @@ -150,6 +152,7 @@ const integrationSchema = new Schema( INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 8d1ea1e56..100fe0db4 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -21,6 +21,7 @@ import { INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL } from "../variables"; @@ -50,7 +51,8 @@ export interface IIntegrationAuth extends Document { | "codefresh" | "digital-ocean-app-platform" | "bitbucket" - | "cloud-66"; + | "cloud-66" + | "terraform-cloud"; teamId: string; accountId: string; url: string; @@ -94,6 +96,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_LARAVELFORGE, INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 95c5b7116..21d8e36c0 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -25,6 +25,7 @@ export const INTEGRATION_CIRCLECI = "circleci"; export const INTEGRATION_TRAVISCI = "travisci"; export const INTEGRATION_SUPABASE = "supabase"; export const INTEGRATION_CHECKLY = "checkly"; +export const INTEGRATION_TERRAFORM_CLOUD = "terraform-cloud"; export const INTEGRATION_HASHICORP_VAULT = "hashicorp-vault"; export const INTEGRATION_CLOUDFLARE_PAGES = "cloudflare-pages"; export const INTEGRATION_BITBUCKET = "bitbucket"; @@ -45,6 +46,7 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_BITBUCKET, @@ -80,6 +82,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_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"; export const INTEGRATION_CODEFRESH_API_URL = "https://g.codefresh.io/api"; @@ -206,6 +209,15 @@ export const getIntegrationOptions = async () => { clientId: await getClientIdGitLab(), docsLink: "", }, + { + name: "Terraform Cloud", + slug: "terraform-cloud", + image: "Terraform Cloud.png", + isAvailable: true, + type: "pat", + cliendId: "", + docsLink: "", + }, { name: "Travis CI", slug: "travisci", diff --git a/docs/images/integrations-terraformcloud-auth.png b/docs/images/integrations-terraformcloud-auth.png new file mode 100644 index 000000000..930fdfafa Binary files /dev/null and b/docs/images/integrations-terraformcloud-auth.png differ diff --git a/docs/images/integrations-terraformcloud-create.png b/docs/images/integrations-terraformcloud-create.png new file mode 100644 index 000000000..7c0ee12ee Binary files /dev/null and b/docs/images/integrations-terraformcloud-create.png differ diff --git a/docs/images/integrations-terraformcloud-dashboard.png b/docs/images/integrations-terraformcloud-dashboard.png new file mode 100644 index 000000000..d73c8c265 Binary files /dev/null and b/docs/images/integrations-terraformcloud-dashboard.png differ diff --git a/docs/images/integrations-terraformcloud-tokens.png b/docs/images/integrations-terraformcloud-tokens.png new file mode 100644 index 000000000..604c70132 Binary files /dev/null and b/docs/images/integrations-terraformcloud-tokens.png differ diff --git a/docs/images/integrations-terraformcloud-workspaceid.png b/docs/images/integrations-terraformcloud-workspaceid.png new file mode 100644 index 000000000..32558566f Binary files /dev/null and b/docs/images/integrations-terraformcloud-workspaceid.png differ diff --git a/docs/images/integrations-terraformcloud-workspaces.png b/docs/images/integrations-terraformcloud-workspaces.png new file mode 100644 index 000000000..08366778e Binary files /dev/null and b/docs/images/integrations-terraformcloud-workspaces.png differ diff --git a/docs/images/integrations-terraformcloud.png b/docs/images/integrations-terraformcloud.png new file mode 100644 index 000000000..02a6ca2df Binary files /dev/null and b/docs/images/integrations-terraformcloud.png differ diff --git a/docs/integrations/cloud/terraform-cloud.mdx b/docs/integrations/cloud/terraform-cloud.mdx new file mode 100644 index 000000000..9000fe194 --- /dev/null +++ b/docs/integrations/cloud/terraform-cloud.mdx @@ -0,0 +1,42 @@ +--- +title: "Terraform Cloud" +description: "How to sync secrets from Infisical to Terraform Cloud" +--- + +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 Terraform Cloud API Token and Workspace Id + +Obtain a Terraform Cloud API Token in User Settings > Tokens + +![integrations terraform cloud dashboard](../../images/integrations-terraformcloud-dashboard.png) +![integrations terraform cloud tokens](../../images/integrations-terraformcloud-tokens.png) + +Obtain your Terraform Cloud Workspace Id in Projects & Workspaces > Workspace > ID + +![integrations terraform cloud projects & workspaces](../../images/integrations-terraformcloud-workspaces.png) +![integrations terraform cloud workspace id](../../images/integrations-terraformcloud-workspaceid.png) + +Press on the Terraform Cloud tile and input your Terraform Cloud API Token and Workspace Id to grant Infisical access to your Terraform Cloud account. + +![integrations terraform cloud authorization](../../images/integrations-terraformcloud-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's necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets and Terraform Cloud variable type you want to sync to which Terraform Cloud workspace/project and press create integration to start syncing secrets to Terraform Cloud. + +![integrations terraform cloud](../../images/integrations-terraformcloud-create.png) +![integrations terraform cloud](../../images/integrations-terraformcloud.png) diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 988c0a064..d04b0349f 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -20,6 +20,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Render](/integrations/cloud/render) | Cloud | Available | | [Laravel Forge](/integrations/cloud/laravel-forge) | Cloud | Available | | [Railway](/integrations/cloud/railway) | Cloud | Available | +| [Terraform Cloud](/integrations/cloud/terraform-cloud) | Cloud | Available | | [Fly.io](/integrations/cloud/flyio) | Cloud | Available | | [Supabase](/integrations/cloud/supabase) | Cloud | Available | | [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | diff --git a/docs/mint.json b/docs/mint.json index b20a9f9fe..c81ff67da 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -137,7 +137,6 @@ "self-hosting/deployment-options/standalone-infisical", "self-hosting/deployment-options/fly.io", "self-hosting/deployment-options/render", - "self-hosting/deployment-options/laravel-forge", "self-hosting/deployment-options/digital-ocean-marketplace" ] }, @@ -223,6 +222,7 @@ "integrations/cloud/flyio", "integrations/cloud/laravel-forge", "integrations/cloud/supabase", + "integrations/cloud/terraform-cloud", "integrations/cloud/cloudflare-pages", "integrations/cloud/checkly", "integrations/cloud/hashicorp-vault", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 61490758e..b504a53c5 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", + 'terraform-cloud': 'Terraform Cloud', "hashicorp-vault": "Vault", "cloudflare-pages": "Cloudflare Pages", "codefresh": "Codefresh", diff --git a/frontend/public/images/integrations/Terraform Cloud.png b/frontend/public/images/integrations/Terraform Cloud.png new file mode 100644 index 000000000..c0000e98c Binary files /dev/null and b/frontend/public/images/integrations/Terraform Cloud.png differ diff --git a/frontend/src/pages/integrations/terraform-cloud/authorize.tsx b/frontend/src/pages/integrations/terraform-cloud/authorize.tsx new file mode 100644 index 000000000..c569bdcca --- /dev/null +++ b/frontend/src/pages/integrations/terraform-cloud/authorize.tsx @@ -0,0 +1,80 @@ +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 TerraformCloudCreateIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [workspacesId, setWorkSpacesId] = useState(""); + const [workspacesIdErrorText, setWorkspacesIdErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + setWorkspacesIdErrorText(""); + + if (apiKey.length === 0) { + setApiKeyErrorText("API Token cannot be blank"); + return; + } + + if (workspacesId.length === 0) { + setWorkspacesIdErrorText("Workspace Id cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "terraform-cloud", + accessId: workspacesId, + accessToken: apiKey, + url: null, + namespace: null + }); + + setIsLoading(false); + + router.push(`/integrations/terraform-cloud/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Terraform Cloud Integration + + setApiKey(e.target.value)} /> + + + setWorkSpacesId(e.target.value)} /> + + + +
+ ); +} + +TerraformCloudCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/terraform-cloud/create.tsx b/frontend/src/pages/integrations/terraform-cloud/create.tsx new file mode 100644 index 000000000..47c33948c --- /dev/null +++ b/frontend/src/pages/integrations/terraform-cloud/create.tsx @@ -0,0 +1,189 @@ +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"; + +const variableTypes = [ + { name: "env" }, + { name: "terraform" } +]; + +export default function TerraformCloudCreateIntegrationPage() { + 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 [variableType, setVariableType] = useState(""); + const [variableTypeErrorText, setVariableTypeErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + setVariableType(variableTypes[0].name); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setVariableTypeErrorText(""); + if (variableType.length === 0 ) { + setVariableTypeErrorText("Variable Type cannot be blank!") + 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: variableType, + 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 ? ( +
+ + Terraform Cloud Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + + + + +
+ ) : ( +
+ ); +} + +TerraformCloudCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 1beb4038e..5d551b0b6 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -86,6 +86,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "railway": link = `${window.location.origin}/integrations/railway/authorize`; break; + case "terraform-cloud": + link = `${window.location.origin}/integrations/terraform-cloud/authorize`; + break; case "hashicorp-vault": link = `${window.location.origin}/integrations/hashicorp-vault/authorize`; break;