Merge pull request #761 from atimapreandrew/terraform-cloud-integration

Terraform cloud integration
This commit is contained in:
BlackMagiq
2023-07-26 23:16:51 +07:00
committed by GitHub
20 changed files with 490 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<IIntegration>(
INTEGRATION_TRAVISCI,
INTEGRATION_SUPABASE,
INTEGRATION_CHECKLY,
INTEGRATION_TERRAFORM_CLOUD,
INTEGRATION_HASHICORP_VAULT,
INTEGRATION_CLOUDFLARE_PAGES,
INTEGRATION_BITBUCKET,

View File

@@ -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<IIntegrationAuth>(
INTEGRATION_LARAVELFORGE,
INTEGRATION_TRAVISCI,
INTEGRATION_SUPABASE,
INTEGRATION_TERRAFORM_CLOUD,
INTEGRATION_HASHICORP_VAULT,
INTEGRATION_CLOUDFLARE_PAGES,
INTEGRATION_BITBUCKET,

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -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)
<Info>
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.
</Info>
## 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)

View File

@@ -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 |

View File

@@ -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",

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -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 (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Terraform Cloud Integration</CardTitle>
<FormControl
label="Terraform Cloud API Token"
errorText={apiKeyErrorText}
isError={apiKeyErrorText !== "" ?? false}
>
<Input placeholder="API Token" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
</FormControl>
<FormControl
label="Terraform Cloud Workspace ID"
errorText={workspacesIdErrorText}
isError={workspacesIdErrorText !== "" ?? false}
>
<Input placeholder="Workspace Id" value={workspacesId} onChange={(e) => setWorkSpacesId(e.target.value)} />
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
isLoading={isLoading}
>
Connect to Terraform Cloud
</Button>
</Card>
</div>
);
}
TerraformCloudCreateIntegrationPage.requireAuth = true;

View File

@@ -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 ? (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Terraform Cloud Integration</CardTitle>
<FormControl label="Project Environment" className="mt-4">
<Select
value={selectedSourceEnvironment}
onValueChange={(val) => setSelectedSourceEnvironment(val)}
className="w-full border border-mineshaft-500"
>
{workspace?.environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
<FormControl label="Secrets Path">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
placeholder="Provide a path, default is /"
/>
</FormControl>
<FormControl label="Category" className="mt-4" errorText={variableTypeErrorText}
isError={variableTypeErrorText !== "" ?? false}>
<Select
value={variableType}
onValueChange={(val) => setVariableType(val)}
className="w-full border border-mineshaft-500"
>
{
variableTypes.map((variable) => (
<SelectItem
value={variable.name}
key={`target-app-${variable.name}`}
>
{variable.name}
</SelectItem>
))
}
</Select>
</FormControl>
<FormControl label="Terraform Cloud Project" className="mt-4">
<Select
value={targetApp}
onValueChange={(val) => setTargetApp(val)}
className="w-full border border-mineshaft-500"
isDisabled={integrationAuthApps.length === 0}
>
{integrationAuthApps.length > 0 ? (
integrationAuthApps.map((integrationAuthApp) => (
<SelectItem
value={integrationAuthApp.name}
key={`target-app-${integrationAuthApp.name}`}
>
{integrationAuthApp.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No project found
</SelectItem>
)}
</Select>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
isLoading={isLoading}
isDisabled={integrationAuthApps.length === 0}
>
Create Integration
</Button>
</Card>
</div>
) : (
<div />
);
}
TerraformCloudCreateIntegrationPage.requireAuth = true;

View File

@@ -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;