Merge branch 'main' into feat/terraformCloudIntegration

This commit is contained in:
carlosmonastyrski
2025-04-11 09:52:14 -03:00
123 changed files with 3534 additions and 318 deletions
@@ -7,7 +7,8 @@ export enum SecretSync {
AzureAppConfiguration = "azure-app-configuration",
Databricks = "databricks",
Humanitec = "humanitec",
TerraformCloud = "terraform-cloud"
TerraformCloud = "terraform-cloud",
Vercel = "vercel"
}
export enum SecretSyncInitialSyncBehavior {
@@ -27,6 +27,7 @@ import { GcpSyncFns } from "./gcp/gcp-sync-fns";
import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec";
import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns";
import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud";
import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel";
const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
@@ -37,7 +38,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION,
[SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION,
[SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION,
[SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION
[SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION,
[SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION
};
export const listSecretSyncOptions = () => {
@@ -125,6 +127,8 @@ export const SecretSyncFns = {
return HumanitecSyncFns.syncSecrets(secretSync, secretMap);
case SecretSync.TerraformCloud:
return TerraformCloudSyncFns.syncSecrets(secretSync, secretMap);
case SecretSync.Vercel:
return VercelSyncFns.syncSecrets(secretSync, secretMap);
default:
throw new Error(
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -172,6 +176,9 @@ export const SecretSyncFns = {
case SecretSync.TerraformCloud:
secretMap = await TerraformCloudSyncFns.getSecrets(secretSync);
break;
case SecretSync.Vercel:
secretMap = await VercelSyncFns.getSecrets(secretSync);
break;
default:
throw new Error(
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -216,6 +223,8 @@ export const SecretSyncFns = {
return HumanitecSyncFns.removeSecrets(secretSync, secretMap);
case SecretSync.TerraformCloud:
return TerraformCloudSyncFns.removeSecrets(secretSync, secretMap);
case SecretSync.Vercel:
return VercelSyncFns.removeSecrets(secretSync, secretMap);
default:
throw new Error(
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -10,7 +10,8 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.AzureAppConfiguration]: "Azure App Configuration",
[SecretSync.Databricks]: "Databricks",
[SecretSync.Humanitec]: "Humanitec",
[SecretSync.TerraformCloud]: "Terraform Cloud"
[SecretSync.TerraformCloud]: "Terraform Cloud",
[SecretSync.Vercel]: "Vercel"
};
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
@@ -22,5 +23,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration,
[SecretSync.Databricks]: AppConnection.Databricks,
[SecretSync.Humanitec]: AppConnection.Humanitec,
[SecretSync.TerraformCloud]: AppConnection.TerraformCloud
[SecretSync.TerraformCloud]: AppConnection.TerraformCloud,
[SecretSync.Vercel]: AppConnection.Vercel
};
@@ -55,6 +55,7 @@ import {
TTerraformCloudSyncListItem,
TTerraformCloudSyncWithCredentials
} from "./terraform-cloud";
import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel";
export type TSecretSync =
| TAwsParameterStoreSync
@@ -65,7 +66,8 @@ export type TSecretSync =
| TAzureAppConfigurationSync
| TDatabricksSync
| THumanitecSync
| TTerraformCloudSync;
| TTerraformCloudSync
| TVercelSync;
export type TSecretSyncWithCredentials =
| TAwsParameterStoreSyncWithCredentials
@@ -76,7 +78,8 @@ export type TSecretSyncWithCredentials =
| TAzureAppConfigurationSyncWithCredentials
| TDatabricksSyncWithCredentials
| THumanitecSyncWithCredentials
| TTerraformCloudSyncWithCredentials;
| TTerraformCloudSyncWithCredentials
| TVercelSyncWithCredentials;
export type TSecretSyncInput =
| TAwsParameterStoreSyncInput
@@ -87,7 +90,8 @@ export type TSecretSyncInput =
| TAzureAppConfigurationSyncInput
| TDatabricksSyncInput
| THumanitecSyncInput
| TTerraformCloudSyncInput;
| TTerraformCloudSyncInput
| TVercelSyncInput;
export type TSecretSyncListItem =
| TAwsParameterStoreSyncListItem
@@ -98,7 +102,8 @@ export type TSecretSyncListItem =
| TAzureAppConfigurationSyncListItem
| TDatabricksSyncListItem
| THumanitecSyncListItem
| TTerraformCloudSyncListItem;
| TTerraformCloudSyncListItem
| TVercelSyncListItem;
export type TSyncOptionsConfig = {
canImportSecrets: boolean;
@@ -0,0 +1,5 @@
export * from "./vercel-sync-constants";
export * from "./vercel-sync-enums";
export * from "./vercel-sync-fns";
export * from "./vercel-sync-schemas";
export * from "./vercel-sync-types";
@@ -0,0 +1,10 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
export const VERCEL_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Vercel",
destination: SecretSync.Vercel,
connection: AppConnection.Vercel,
canImportSecrets: true
};
@@ -0,0 +1,12 @@
export enum VercelSyncScope {
Application = "application",
Environment = "environment"
}
export const VercelEnvironmentType = {
Development: "development",
Preview: "preview",
Production: "production"
} as const;
export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType];
@@ -0,0 +1,313 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { request } from "@app/lib/config/request";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { VercelEnvironmentType } from "./vercel-sync-enums";
import { DefaultVercelEnvType, TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types";
function isVercelDefaultEnvType(value: string): value is DefaultVercelEnvType {
return Object.values(VercelEnvironmentType).map(String).includes(value);
}
const MAX_RETRIES = 5;
const sleep = async () =>
new Promise((resolve) => {
setTimeout(resolve, 60000);
});
const getVercelSecretsWithRetries = async (
secretSync: TVercelSyncWithCredentials,
attempt = 0
): Promise<VercelApiSecret[]> => {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
const params: { [key: string]: string } = {
decrypt: "true",
...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {})
};
try {
const { data } = await request.get<{ envs: VercelApiSecret[] }>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`,
{
params,
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
return data.envs;
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await getVercelSecretsWithRetries(secretSync, attempt + 1);
}
throw error;
}
};
const getDecryptedVercelSecret = async (
secretSync: TVercelSyncWithCredentials,
secret: VercelApiSecret,
attempt = 0
): Promise<VercelApiSecret> => {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
const params: { [key: string]: string } = {
decrypt: "true",
...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {})
};
try {
const { data: decryptedSecret } = await request.get(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}?teamId=${destinationConfig.teamId}`,
{
params,
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
return decryptedSecret as VercelApiSecret;
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await getDecryptedVercelSecret(secretSync, secret, attempt + 1);
}
throw error;
}
};
const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials): Promise<VercelApiSecret[]> => {
const { destinationConfig } = secretSync;
const secrets = await getVercelSecretsWithRetries(secretSync);
const filteredSecrets = secrets.filter((secret) => {
if (!isVercelDefaultEnvType(destinationConfig.env)) {
if (secret.customEnvironmentIds?.includes(destinationConfig.env)) {
return true;
}
return false;
}
if (secret.target.includes(destinationConfig.env)) {
// If it's preview environment with a branch specified
if (
destinationConfig.env === VercelEnvironmentType.Preview &&
destinationConfig.branch &&
secret.gitBranch &&
secret.gitBranch !== destinationConfig.branch
) {
return false;
}
return true;
}
return false;
});
// For secrets of type "encrypted", we need to get their decrypted value
const secretsWithValues = await Promise.all(
filteredSecrets.map(async (secret) => {
if (secret.type === "encrypted") {
const decryptedSecret = await getDecryptedVercelSecret(secretSync, secret);
return decryptedSecret;
}
return secret;
})
);
return secretsWithValues;
};
const deleteSecret = async (
secretSync: TVercelSyncWithCredentials,
vercelSecret: VercelApiSecret,
attempt = 0
): Promise<void> => {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
try {
await request.delete(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await deleteSecret(secretSync, vercelSecret, attempt + 1);
}
throw new SecretSyncError({
error,
secretKey: vercelSecret.key
});
}
};
const createSecret = async (
secretSync: TVercelSyncWithCredentials,
secretMap: TSecretMap,
key: string,
attempt = 0
): Promise<void> => {
try {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
await request.post(
`${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`,
{
key,
value: secretMap[key].value,
type: "encrypted",
target: isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [],
customEnvironmentIds: !isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [],
...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch
? { gitBranch: destinationConfig.branch }
: {})
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await createSecret(secretSync, secretMap, key, attempt + 1);
}
throw new SecretSyncError({
error,
secretKey: key
});
}
};
const updateSecret = async (
secretSync: TVercelSyncWithCredentials,
secretMap: TSecretMap,
vercelSecret: VercelApiSecret,
attempt = 0
): Promise<void> => {
try {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
let target = [...vercelSecret.target];
if (isVercelDefaultEnvType(destinationConfig.env) && !vercelSecret.target.includes(destinationConfig.env)) {
target = [...target, destinationConfig.env];
}
let customEnvironmentIds = [...(vercelSecret.customEnvironmentIds || [])];
if (
!isVercelDefaultEnvType(destinationConfig.env) &&
!vercelSecret.customEnvironmentIds?.includes(destinationConfig.env)
) {
customEnvironmentIds = [...customEnvironmentIds, destinationConfig.env];
}
await request.patch(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`,
{
...(vercelSecret.type !== "sensitive" && { key: vercelSecret.key }),
value: secretMap[vercelSecret.key].value,
type: vercelSecret.type,
target,
customEnvironmentIds,
...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch
? { gitBranch: destinationConfig.branch }
: {})
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await updateSecret(secretSync, secretMap, vercelSecret, attempt + 1);
}
throw new SecretSyncError({
error,
secretKey: vercelSecret.key
});
}
};
export const VercelSyncFns = {
syncSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => {
const vercelSecrets = await getVercelSecrets(secretSync);
const vercelSecretsMap = new Map(vercelSecrets.map((s) => [s.key, s]));
// Create or update secrets
for await (const key of Object.keys(secretMap)) {
const existingSecret = vercelSecretsMap.get(key);
if (!existingSecret) {
await createSecret(secretSync, secretMap, key);
} else if (existingSecret.value !== secretMap[key].value) {
await updateSecret(secretSync, secretMap, existingSecret);
}
}
// Delete secrets if disableSecretDeletion is not set
if (secretSync.syncOptions.disableSecretDeletion) return;
for await (const vercelSecret of vercelSecrets) {
if (!secretMap[vercelSecret.key]) {
await deleteSecret(secretSync, vercelSecret);
}
}
},
getSecrets: async (secretSync: TVercelSyncWithCredentials): Promise<TSecretMap> => {
const vercelSecrets = await getVercelSecrets(secretSync);
return Object.fromEntries(vercelSecrets.map((s) => [s.key, { value: s.value ?? "" }]));
},
removeSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => {
const vercelSecrets = await getVercelSecrets(secretSync);
for await (const vercelSecret of vercelSecrets) {
if (vercelSecret.key in secretMap) {
await deleteSecret(secretSync, vercelSecret);
}
}
}
};
@@ -0,0 +1,49 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
import { VercelEnvironmentType } from "./vercel-sync-enums";
const VercelSyncDestinationConfigSchema = z.object({
app: z.string().min(1, "App ID is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.app),
appName: z.string().min(1, "App Name is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.appName),
env: z.nativeEnum(VercelEnvironmentType).or(z.string()).describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.env),
branch: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.branch),
teamId: z.string().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.teamId)
});
const VercelSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const VercelSyncSchema = BaseSecretSyncSchema(SecretSync.Vercel, VercelSyncOptionsConfig).extend({
destination: z.literal(SecretSync.Vercel),
destinationConfig: VercelSyncDestinationConfigSchema
});
export const CreateVercelSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.Vercel,
VercelSyncOptionsConfig
).extend({
destinationConfig: VercelSyncDestinationConfigSchema
});
export const UpdateVercelSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.Vercel,
VercelSyncOptionsConfig
).extend({
destinationConfig: VercelSyncDestinationConfigSchema.optional()
});
export const VercelSyncListItemSchema = z.object({
name: z.literal("Vercel"),
connection: z.literal(AppConnection.Vercel),
destination: z.literal(SecretSync.Vercel),
canImportSecrets: z.literal(true)
});
@@ -0,0 +1,40 @@
import z from "zod";
import { TVercelConnection } from "@app/services/app-connection/vercel";
import { VercelEnvironmentType } from "./vercel-sync-enums";
import { CreateVercelSyncSchema, VercelSyncListItemSchema, VercelSyncSchema } from "./vercel-sync-schemas";
export type TVercelSyncListItem = z.infer<typeof VercelSyncListItemSchema>;
export type TVercelSync = z.infer<typeof VercelSyncSchema>;
export type TVercelSyncInput = z.infer<typeof CreateVercelSyncSchema>;
export type TVercelSyncWithCredentials = TVercelSync & {
connection: TVercelConnection;
};
export type VercelSecret = {
description: string;
is_secret: boolean;
key: string;
source: "app" | "env";
value: string;
};
export interface VercelApiSecret {
id: string;
key: string;
value: string;
type: string;
target: string[];
customEnvironmentIds?: string[];
gitBranch?: string;
createdAt?: number;
updatedAt?: number;
configurationId?: string;
system?: boolean;
}
export type DefaultVercelEnvType = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType];