feat: chef secret-sync

This commit is contained in:
Piyush Gupta
2025-10-30 20:06:04 +05:30
parent fd010fe823
commit 30c0cfb8c2
36 changed files with 767 additions and 124 deletions
@@ -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 CHEF_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Chef",
destination: SecretSync.Chef,
connection: AppConnection.Chef,
canImportSecrets: true
};
@@ -0,0 +1,143 @@
import { getChefDataBagItem, updateChefDataBagItem } from "@app/services/app-connection/chef";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { ChefSecret, TChefDataBagItemContent, TChefSyncWithCredentials, TGetChefSecrets } from "./chef-sync-types";
const getChefSecretsRaw = async ({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName
}: TGetChefSecrets): Promise<TChefDataBagItemContent> => {
const dataBagItem = await getChefDataBagItem({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName
});
// Ensure the data bag item has an id field
if (!dataBagItem.id) {
dataBagItem.id = dataBagItemName;
}
return dataBagItem;
};
const getChefSecrets = async (secretSync: TChefSyncWithCredentials): Promise<ChefSecret[]> => {
const {
connection,
destinationConfig: { dataBagName, dataBagItemName }
} = secretSync;
const { serverUrl, userName, privateKey, orgName } = connection.credentials;
const dataBagItem = await getChefSecretsRaw({
serverUrl,
orgName,
userName,
privateKey,
dataBagName,
dataBagItemName
});
// Convert data bag item to key-value pairs
// Exclude the 'id' field as it's metadata
const secrets: ChefSecret[] = [];
Object.entries(dataBagItem).forEach(([key, value]) => {
if (key !== "id" && value !== null && value !== undefined) {
secrets.push({ key, value: String(value) });
}
});
return secrets;
};
const updateChefSecrets = async (
secretSync: TChefSyncWithCredentials,
secrets: Record<string, string | number | boolean>
) => {
const {
connection,
destinationConfig: { dataBagName, dataBagItemName }
} = secretSync;
const { serverUrl, userName, privateKey, orgName } = connection.credentials;
// Chef data bag items must have an 'id' field
const dataBagItemContent: TChefDataBagItemContent = {
id: dataBagItemName,
...secrets
};
await updateChefDataBagItem({
serverUrl,
orgName,
userName,
privateKey,
dataBagName,
dataBagItemName,
data: dataBagItemContent
});
};
export const ChefSyncFns = {
async syncSecrets(secretSync: TChefSyncWithCredentials, secretMap: TSecretMap) {
const {
environment,
syncOptions: { disableSecretDeletion, keySchema }
} = secretSync;
const secrets = await getChefSecrets(secretSync);
// Create a map of the existing secrets
const updatedSecretsMap = new Map(secrets.map((secret) => [secret.key, secret.value]));
// Add/update new secrets
for (const [key, { value }] of Object.entries(secretMap)) {
updatedSecretsMap.set(key, value);
}
// Delete secrets if not disabled
if (!disableSecretDeletion) {
secrets.forEach((secret) => {
if (!matchesSchema(secret.key, environment?.slug || "", keySchema)) return;
if (!secretMap[secret.key]) {
updatedSecretsMap.delete(secret.key);
}
});
}
// Convert map to object for Chef API
const updatedSecrets = Object.fromEntries(updatedSecretsMap.entries());
await updateChefSecrets(secretSync, updatedSecrets);
},
async getSecrets(secretSync: TChefSyncWithCredentials): Promise<TSecretMap> {
const secrets = await getChefSecrets(secretSync);
return Object.fromEntries(secrets.map((secret) => [secret.key, { value: secret.value }]));
},
async removeSecrets(secretSync: TChefSyncWithCredentials, secretMap: TSecretMap) {
const existingSecrets = await getChefSecrets(secretSync);
const newSecrets = existingSecrets.filter((secret) => !Object.hasOwn(secretMap, secret.key));
// If nothing changed, return early
if (newSecrets.length === existingSecrets.length) {
return;
}
// Convert to object for Chef API
const updatedSecrets = Object.fromEntries(newSecrets.map((secret) => [secret.key, secret.value]));
await updateChefSecrets(secretSync, updatedSecrets);
}
};
@@ -0,0 +1,46 @@
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";
const ChefSyncDestinationConfigSchema = z.object({
dataBagName: z
.string()
.min(1, "Data Bag Name is required")
.max(256, "Data Bag Name cannot exceed 256 characters")
.describe(SecretSyncs.DESTINATION_CONFIG.CHEF.dataBagName),
dataBagItemName: z
.string()
.min(1, "Data Bag Item Name is required")
.max(256, "Data Bag Item Name cannot exceed 256 characters")
.describe(SecretSyncs.DESTINATION_CONFIG.CHEF.dataBagItemName)
});
const ChefSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const ChefSyncSchema = BaseSecretSyncSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destination: z.literal(SecretSync.Chef),
destinationConfig: ChefSyncDestinationConfigSchema
});
export const CreateChefSyncSchema = GenericCreateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destinationConfig: ChefSyncDestinationConfigSchema
});
export const UpdateChefSyncSchema = GenericUpdateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destinationConfig: ChefSyncDestinationConfigSchema.optional()
});
export const ChefSyncListItemSchema = z.object({
name: z.literal("Chef"),
connection: z.literal(AppConnection.Chef),
destination: z.literal(SecretSync.Chef),
canImportSecrets: z.literal(true)
});
@@ -0,0 +1,34 @@
import z from "zod";
import { TChefConnection } from "@app/services/app-connection/chef";
import { ChefSyncListItemSchema, ChefSyncSchema, CreateChefSyncSchema } from "./chef-sync-schemas";
export type TChefSyncListItem = z.infer<typeof ChefSyncListItemSchema>;
export type TChefSync = z.infer<typeof ChefSyncSchema>;
export type TChefSyncInput = z.infer<typeof CreateChefSyncSchema>;
export type TChefSyncWithCredentials = TChefSync & {
connection: TChefConnection;
};
export type TGetChefSecrets = {
serverUrl?: string;
userName: string;
privateKey: string;
orgName: string;
dataBagName: string;
dataBagItemName: string;
};
export type TChefDataBagItemContent = {
id: string;
[key: string]: string | number | boolean | null;
};
export type ChefSecret = {
key: string;
value: string;
};
@@ -0,0 +1,4 @@
export * from "./chef-sync-constants";
export * from "./chef-sync-fns";
export * from "./chef-sync-schemas";
export * from "./chef-sync-types";
@@ -29,7 +29,8 @@ export enum SecretSync {
DigitalOceanAppPlatform = "digital-ocean-app-platform",
Netlify = "netlify",
Bitbucket = "bitbucket",
LaravelForge = "laravel-forge"
LaravelForge = "laravel-forge",
Chef = "chef"
}
export enum SecretSyncInitialSyncBehavior {
@@ -34,6 +34,7 @@ import { BITBUCKET_SYNC_LIST_OPTION, BitbucketSyncFns } from "./bitbucket";
import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda";
import { CHECKLY_SYNC_LIST_OPTION } from "./checkly/checkly-sync-constants";
import { ChecklySyncFns } from "./checkly/checkly-sync-fns";
import { CHEF_SYNC_LIST_OPTION, ChefSyncFns } from "./chef";
import { CLOUDFLARE_PAGES_SYNC_LIST_OPTION } from "./cloudflare-pages/cloudflare-pages-constants";
import { CloudflarePagesSyncFns } from "./cloudflare-pages/cloudflare-pages-fns";
import { CLOUDFLARE_WORKERS_SYNC_LIST_OPTION, CloudflareWorkersSyncFns } from "./cloudflare-workers";
@@ -49,8 +50,7 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault";
import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku";
import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec";
import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns";
import { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge";
import { LaravelForgeSyncFns } from "./laravel-forge/laravel-forge-sync-fns";
import { LARAVEL_FORGE_SYNC_LIST_OPTION, LaravelForgeSyncFns } from "./laravel-forge";
import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify";
import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants";
import { RailwaySyncFns } from "./railway/railway-sync-fns";
@@ -94,7 +94,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION,
[SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION,
[SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION,
[SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION
[SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION,
[SecretSync.Chef]: CHEF_SYNC_LIST_OPTION
};
export const listSecretSyncOptions = () => {
@@ -282,6 +283,8 @@ export const SecretSyncFns = {
return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.LaravelForge:
return LaravelForgeSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Chef:
return ChefSyncFns.syncSecrets(secretSync, schemaSecretMap);
default:
throw new Error(
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -401,6 +404,9 @@ export const SecretSyncFns = {
case SecretSync.LaravelForge:
secretMap = await LaravelForgeSyncFns.getSecrets(secretSync);
break;
case SecretSync.Chef:
secretMap = await ChefSyncFns.getSecrets(secretSync);
break;
default:
throw new Error(
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -496,6 +502,8 @@ export const SecretSyncFns = {
return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.LaravelForge:
return LaravelForgeSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.Chef:
return ChefSyncFns.removeSecrets(secretSync, schemaSecretMap);
default:
throw new Error(
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -33,7 +33,8 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform",
[SecretSync.Netlify]: "Netlify",
[SecretSync.Bitbucket]: "Bitbucket",
[SecretSync.LaravelForge]: "Laravel Forge"
[SecretSync.LaravelForge]: "Laravel Forge",
[SecretSync.Chef]: "Chef"
};
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
@@ -67,7 +68,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean,
[SecretSync.Netlify]: AppConnection.Netlify,
[SecretSync.Bitbucket]: AppConnection.Bitbucket,
[SecretSync.LaravelForge]: AppConnection.LaravelForge
[SecretSync.LaravelForge]: AppConnection.LaravelForge,
[SecretSync.Chef]: AppConnection.Chef
};
export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
@@ -101,7 +103,8 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular,
[SecretSync.Netlify]: SecretSyncPlanType.Regular,
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular,
[SecretSync.LaravelForge]: SecretSyncPlanType.Regular
[SecretSync.LaravelForge]: SecretSyncPlanType.Regular,
[SecretSync.Chef]: SecretSyncPlanType.Regular
};
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
@@ -144,7 +147,8 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
[SecretSync.DigitalOceanAppPlatform]: ["appName"],
[SecretSync.Netlify]: ["accountName", "siteName"],
[SecretSync.Bitbucket]: [],
[SecretSync.LaravelForge]: []
[SecretSync.LaravelForge]: [],
[SecretSync.Chef]: []
};
const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true;
@@ -204,5 +208,6 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record<SecretSync, DestinationDupl
[SecretSync.DigitalOceanAppPlatform]: defaultDuplicateCheck,
[SecretSync.Netlify]: defaultDuplicateCheck,
[SecretSync.Bitbucket]: defaultDuplicateCheck,
[SecretSync.LaravelForge]: defaultDuplicateCheck
[SecretSync.LaravelForge]: defaultDuplicateCheck,
[SecretSync.Chef]: defaultDuplicateCheck
};
@@ -21,6 +21,7 @@ import {
TCamundaSyncListItem,
TCamundaSyncWithCredentials
} from "@app/services/secret-sync/camunda";
import { TChefSync, TChefSyncInput, TChefSyncListItem, TChefSyncWithCredentials } from "@app/services/secret-sync/chef";
import {
TDatabricksSync,
TDatabricksSyncInput,
@@ -163,6 +164,7 @@ export type TSecretSync =
| TGitHubSync
| TGcpSync
| TAzureKeyVaultSync
| TChefSync
| TAzureAppConfigurationSync
| TAzureDevOpsSync
| TDatabricksSync
@@ -195,6 +197,7 @@ export type TSecretSyncWithCredentials =
| TGitHubSyncWithCredentials
| TGcpSyncWithCredentials
| TAzureKeyVaultSyncWithCredentials
| TChefSyncWithCredentials
| TAzureAppConfigurationSyncWithCredentials
| TAzureDevOpsSyncWithCredentials
| TDatabricksSyncWithCredentials
@@ -228,6 +231,7 @@ export type TSecretSyncInput =
| TGitHubSyncInput
| TGcpSyncInput
| TAzureKeyVaultSyncInput
| TChefSyncInput
| TAzureAppConfigurationSyncInput
| TAzureDevOpsSyncInput
| TDatabricksSyncInput
@@ -261,6 +265,7 @@ export type TSecretSyncListItem =
| TGitHubSyncListItem
| TGcpSyncListItem
| TAzureKeyVaultSyncListItem
| TChefSyncListItem
| TAzureAppConfigurationSyncListItem
| TAzureDevOpsSyncListItem
| TDatabricksSyncListItem