add initial sync options to terraform cloud integration

This commit is contained in:
Vladyslav Matsiiako
2024-04-20 21:40:07 -07:00
parent d11ded9abc
commit c959fa6fdd
4 changed files with 233 additions and 38 deletions

View File

@@ -2157,16 +2157,29 @@ const syncSecretsQovery = async ({
* @param {String} obj.accessToken - access token for Terraform Cloud API
*/
const syncSecretsTerraformCloud = async ({
createManySecretsRawFn,
updateManySecretsRawFn,
integration,
secrets,
accessToken
accessToken,
integrationDAL
}: {
integration: TIntegrations;
secrets: Record<string, { value: string; comment?: string }>;
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
integration: TIntegrations & {
projectId: string;
environment: {
id: string;
name: string;
slug: string;
};
};
secrets: Record<string, { value: string; comment?: string } | null>;
accessToken: string;
integrationDAL: Pick<TIntegrationDALFactory, "updateById">;
}) => {
// get secrets from Terraform Cloud
const getSecretsRes = (
const terraformSecrets = (
await request.get<{ data: { attributes: { key: string; value: string }; id: string }[] }>(
`${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars`,
{
@@ -2184,9 +2197,74 @@ const syncSecretsTerraformCloud = async ({
{} as Record<string, { attributes: { key: string; value: string }; id: string }>
);
const secretsToAdd: { [key: string]: string } = {};
const secretsToUpdate: { [key: string]: string } = {};
const metadata = z.record(z.any()).parse(integration.metadata);
Object.keys(terraformSecrets).forEach((key) => {
if (!integration.lastUsed) {
// first time using integration
// -> apply initial sync behavior
switch (metadata.initialSyncBehavior) {
case IntegrationInitialSyncBehavior.PREFER_TARGET: {
if (!(key in secrets)) {
secretsToAdd[key] = terraformSecrets[key].attributes.value;
} else if (secrets[key]?.value !== terraformSecrets[key].attributes.value) {
secretsToUpdate[key] = terraformSecrets[key].attributes.value;
}
secrets[key] = {
value: terraformSecrets[key].attributes.value
};
break;
}
case IntegrationInitialSyncBehavior.PREFER_SOURCE: {
if (!(key in secrets)) {
secrets[key] = {
value: terraformSecrets[key].attributes.value
};
secretsToAdd[key] = terraformSecrets[key].attributes.value;
}
break;
}
default: {
break;
}
}
} else if (!(key in secrets)) secrets[key] = null;
});
if (Object.keys(secretsToAdd).length) {
await createManySecretsRawFn({
projectId: integration.projectId,
environment: integration.environment.slug,
path: integration.secretPath,
secrets: Object.keys(secretsToAdd).map((key) => ({
secretName: key,
secretValue: secretsToAdd[key],
type: SecretType.Shared,
secretComment: ""
}))
});
}
if (Object.keys(secretsToUpdate).length) {
await updateManySecretsRawFn({
projectId: integration.projectId,
environment: integration.environment.slug,
path: integration.secretPath,
secrets: Object.keys(secretsToUpdate).map((key) => ({
secretName: key,
secretValue: secretsToUpdate[key],
type: SecretType.Shared,
secretComment: ""
}))
});
}
// create or update secrets on Terraform Cloud
for await (const key of Object.keys(secrets)) {
if (!(key in getSecretsRes)) {
if (!(key in terraformSecrets)) {
// case: secret does not exist in Terraform Cloud
// -> add secret
await request.post(
@@ -2196,7 +2274,7 @@ const syncSecretsTerraformCloud = async ({
type: "vars",
attributes: {
key,
value: secrets[key].value,
value: secrets[key]?.value,
category: integration.targetService
}
}
@@ -2210,17 +2288,17 @@ const syncSecretsTerraformCloud = async ({
}
);
// case: secret exists in Terraform Cloud
} else if (secrets[key].value !== getSecretsRes[key].attributes.value) {
} else if (secrets[key]?.value !== terraformSecrets[key].attributes.value) {
// -> update secret
await request.patch(
`${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`,
`${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${terraformSecrets[key].id}`,
{
data: {
type: "vars",
id: getSecretsRes[key].id,
id: terraformSecrets[key].id,
attributes: {
...getSecretsRes[key],
value: secrets[key].value
...terraformSecrets[key],
value: secrets[key]?.value
}
}
},
@@ -2235,11 +2313,11 @@ const syncSecretsTerraformCloud = async ({
}
}
for await (const key of Object.keys(getSecretsRes)) {
for await (const key of Object.keys(terraformSecrets)) {
if (!(key in secrets)) {
// case: delete secret
await request.delete(
`${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`,
`${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${terraformSecrets[key].id}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -2250,6 +2328,10 @@ const syncSecretsTerraformCloud = async ({
);
}
}
await integrationDAL.updateById(integration.id, {
lastUsed: new Date()
});
};
/**
@@ -3291,9 +3373,12 @@ export const syncIntegrationSecrets = async ({
break;
case Integrations.TERRAFORM_CLOUD:
await syncSecretsTerraformCloud({
createManySecretsRawFn,
updateManySecretsRawFn,
integration,
secrets,
accessToken
accessToken,
integrationDAL
});
break;
case Integrations.HASHICORP_VAULT:

View File

@@ -1,5 +1,10 @@
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";
@@ -49,12 +54,56 @@ export default function TerraformCloudCreateIntegrationPage() {
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>
<Head>
<title>Authorize Terraform Cloud Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="After adding the details below, you will be prompted to set up an integration for a particular Infisical project and environment."
>
<div className="flex flex-row items-center">
<div className="inline flex items-center">
<Image
src="/images/integrations/Terraform.png"
height={35}
width={35}
alt="Terraform logo"
/>
</div>
<span className="ml-1.5">Terraform Cloud Integration </span>
<Link href="https://infisical.com/docs/integrations/cloud/terraform-cloud" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<FormControl
label="Terraform Cloud Workspace ID"
errorText={workspacesIdErrorText}
isError={workspacesIdErrorText !== "" ?? false}
className="px-6"
>
<Input
placeholder="Workspace Id"
value={workspacesId}
onChange={(e) => setWorkSpacesId(e.target.value)}
/>
</FormControl>
<FormControl
label="Terraform Cloud API Token"
errorText={apiKeyErrorText}
isError={apiKeyErrorText !== "" ?? false}
className="px-6"
>
<Input
placeholder="API Token"
@@ -64,21 +113,11 @@ export default function TerraformCloudCreateIntegrationPage() {
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"
colorSchema="primary"
variant="outline_bg"
className="mb-6 mt-2 ml-auto mr-6 w-min"
isLoading={isLoading}
>
Connect to Terraform Cloud

View File

@@ -1,8 +1,14 @@
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 } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import queryString from "query-string";
import { useCreateIntegration } from "@app/hooks/api";
import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types";
import {
Button,
@@ -19,6 +25,15 @@ import {
} from "../../../hooks/api/integrationAuth";
import { useGetWorkspaceById } from "../../../hooks/api/workspace";
const initialSyncBehaviors = [
{
label: "No Import - Overwrite all values in Terraform Cloud",
value: IntegrationSyncBehavior.OVERWRITE_TARGET
},
{ label: "Import non-sensitive - Prefer values from Terraform Cloud", value: IntegrationSyncBehavior.PREFER_TARGET },
{ label: "Import non-sensitive - Prefer values from Infisical", value: IntegrationSyncBehavior.PREFER_SOURCE }
];
const variableTypes = [{ name: "env" }, { name: "terraform" }];
export default function TerraformCloudCreateIntegrationPage() {
@@ -39,6 +54,7 @@ export default function TerraformCloudCreateIntegrationPage() {
const [variableType, setVariableType] = useState("");
const [variableTypeErrorText, setVariableTypeErrorText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [initialSyncBehavior, setInitialSyncBehavior] = useState("prefer-source")
useEffect(() => {
if (workspace) {
@@ -78,7 +94,10 @@ export default function TerraformCloudCreateIntegrationPage() {
)?.appId,
sourceEnvironment: selectedSourceEnvironment,
targetService: variableType,
secretPath
secretPath,
metadata: {
initialSyncBehavior
}
});
setIsLoading(false);
@@ -95,9 +114,40 @@ export default function TerraformCloudCreateIntegrationPage() {
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">
<Head>
<title>Create Terraform Cloud Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="Specify the encironment and path within Infisical that you want to push to which project in Terraform."
>
<div className="flex flex-row items-center">
<div className="inline flex items-center">
<Image
src="/images/integrations/Terraform.png"
height={35}
width={35}
alt="Terraform logo"
/>
</div>
<span className="ml-1.5">Terraform Cloud Integration </span>
<Link href="https://infisical.com/docs/integrations/cloud/terraform-cloud" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<FormControl label="Project Environment" className="px-6">
<Select
value={selectedSourceEnvironment}
onValueChange={(val) => setSelectedSourceEnvironment(val)}
@@ -113,7 +163,7 @@ export default function TerraformCloudCreateIntegrationPage() {
))}
</Select>
</FormControl>
<FormControl label="Secrets Path">
<FormControl label="Secrets Path" className="px-6">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
@@ -122,7 +172,7 @@ export default function TerraformCloudCreateIntegrationPage() {
</FormControl>
<FormControl
label="Category"
className="mt-4"
className="px-6"
errorText={variableTypeErrorText}
isError={variableTypeErrorText !== "" ?? false}
>
@@ -138,7 +188,7 @@ export default function TerraformCloudCreateIntegrationPage() {
))}
</Select>
</FormControl>
<FormControl label="Terraform Cloud Project" className="mt-4">
<FormControl label="Terraform Cloud Project" className="px-6">
<Select
value={targetApp}
onValueChange={(val) => setTargetApp(val)}
@@ -161,10 +211,22 @@ export default function TerraformCloudCreateIntegrationPage() {
)}
</Select>
</FormControl>
<FormControl label="Initial Sync Behavior" className="px-6">
<Select value={initialSyncBehavior} onValueChange={(e) => setInitialSyncBehavior(e)} className="w-full border border-mineshaft-600">
{initialSyncBehaviors.map((b) => {
return (
<SelectItem value={b.value} key={`sync-behavior-${b.value}`}>
{b.label}
</SelectItem>
);
})}
</Select>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
colorSchema="primary"
variant="outline_bg"
className="mb-6 mt-2 ml-auto mr-6 w-min"
isLoading={isLoading}
isDisabled={integrationAuthApps.length === 0}
>

View File

@@ -130,6 +130,7 @@ export const IntegrationsSection = ({
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(integration.integration === "aws-parameter-store" && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
"Repository") ||
@@ -168,6 +169,14 @@ export const IntegrationsSection = ({
</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Category" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{(integration.integration === "checkly" ||
integration.integration === "github") && (
<div className="ml-2">