diff --git a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts index bbe3fbbfb..668bea065 100644 --- a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts @@ -53,4 +53,36 @@ export const registerChecklyConnectionRouter = async (server: FastifyZodProvider return { accounts }; } }); + + server.route({ + method: "GET", + url: `/:connectionId/accounts/:accountId/groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + accountId: z.string() + }), + response: { + 200: z.object({ + groups: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, accountId } = req.params; + + const groups = await server.services.appConnection.checkly.listGroups(connectionId, accountId, req.permission); + + return { groups }; + } + }); }; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts index 4e5db231f..35279d007 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts @@ -4,6 +4,7 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxi import { createRequestClient } 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 { ChecklyConnectionMethod } from "./checkly-connection-constants"; import { TChecklyAccount, TChecklyConnectionConfig, TChecklyVariable } from "./checkly-connection-types"; @@ -181,6 +182,122 @@ class ChecklyPublicClient { return res; } + + async getCheckGroups(connection: TChecklyConnectionConfig, accountId: string, limit = 50, page = 1) { + const res = await this.send<{ id: number; name: string }[]>(connection, { + accountId, + method: "GET", + url: `/v1/check-groups`, + params: { limit, page } + }); + + return res?.map((group) => ({ + id: group.id.toString(), + name: group.name + })); + } + + async getCheckGroup(connection: TChecklyConnectionConfig, accountId: string, groupId: string) { + try { + type ChecklyGroupResponse = { + id: number; + name: string; + environmentVariables: Array<{ + key: string; + value: string; + locked: boolean; + }>; + }; + + const res = await this.send(connection, { + accountId, + method: "GET", + url: `/v1/check-groups/${groupId}` + }); + + if (!res) return null; + + return { + id: res.id.toString(), + name: res.name, + environmentVariables: res.environmentVariables + }; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + throw error; + } + } + + async updateCheckGroupEnvironmentVariables( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + environmentVariables: Array<{ key: string; value: string; locked?: boolean }> + ) { + if (environmentVariables.length > 50) { + throw new SecretSyncError({ + message: "Checkly does not support syncing more than 50 variables to Check Group", + shouldRetry: false + }); + } + + const apiVariables = environmentVariables.map((v) => ({ + key: v.key, + value: v.value, + locked: v.locked ?? false, + secret: true + })); + + const group = await this.getCheckGroup(connection, accountId, groupId); + + await this.send(connection, { + accountId, + method: "PUT", + url: `/v2/check-groups/${groupId}`, + data: { name: group?.name, environmentVariables: apiVariables } + }); + + return this.getCheckGroup(connection, accountId, groupId); + } + + async getCheckGroupEnvironmentVariables(connection: TChecklyConnectionConfig, accountId: string, groupId: string) { + const group = await this.getCheckGroup(connection, accountId, groupId); + return group?.environmentVariables || []; + } + + async upsertCheckGroupEnvironmentVariables( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + variables: Array<{ key: string; value: string; locked?: boolean }> + ) { + const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId); + const varMap = new Map(existingVars.map((v) => [v.key, v])); + + for (const newVar of variables) { + varMap.set(newVar.key, { + key: newVar.key, + value: newVar.value, + locked: newVar.locked ?? false + }); + } + + return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, Array.from(varMap.values())); + } + + async deleteCheckGroupEnvironmentVariable( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + variableKey: string + ) { + const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId); + const filteredVars = existingVars.filter((v) => v.key !== variableKey); + + return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, filteredVars); + } } export const ChecklyPublicAPI = new ChecklyPublicClient(); diff --git a/backend/src/services/app-connection/checkly/checkly-connection-service.ts b/backend/src/services/app-connection/checkly/checkly-connection-service.ts index c3598320f..1312b4590 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-service.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-service.ts @@ -24,7 +24,19 @@ export const checklyConnectionService = (getAppConnection: TGetAppConnectionFunc } }; + const listGroups = async (connectionId: string, accountId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Checkly, connectionId, actor); + try { + const groups = await ChecklyPublicAPI.getCheckGroups(appConnection, accountId); + return groups!; + } catch (error) { + logger.error(error, "Failed to list accounts on Checkly"); + return []; + } + }; + return { - listAccounts + listAccounts, + listGroups }; }; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-types.ts b/backend/src/services/app-connection/checkly/checkly-connection-types.ts index e8bb242ba..195d319d6 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-types.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-types.ts @@ -33,3 +33,15 @@ export type TChecklyAccount = { name: string; runtimeId: string; }; + +export type TChecklyGroupEnvironmentVariable = { + key: string; + value: string; + locked: boolean; +}; + +export type TChecklyGroup = { + id: string; + name: string; + environmentVariables?: TChecklyGroupEnvironmentVariable[]; +}; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts index eded130bb..822773948 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts @@ -23,56 +23,120 @@ export const ChecklySyncFns = { const config = secretSync.destinationConfig; - const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + if (config.groupId) { + // Handle group environment variables + const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId + ); - const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable])); - for await (const key of Object.keys(secretMap)) { - try { + // Prepare all variables to update at once + const updatedVariables = { ...checklyGroupSecrets }; + + for (const key of Object.keys(secretMap)) { const entry = secretMap[key]; - // If value is empty, we skip the upsert - checkly does not allow empty values + // If value is empty, we skip adding it - checkly does not allow empty values if (entry.value.trim() === "") { - // Delete the secret from Checkly if its empty + // Delete the secret from the group if it's empty if (!disableSecretDeletion) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key - }); + delete updatedVariables[key]; } continue; // Skip empty values } - await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + // Add or update the variable + updatedVariables[key] = { key, value: entry.value, - secret: true, locked: true - }); + }; + } + + // Remove secrets that are not in the secretMap if deletion is enabled + if (!disableSecretDeletion) { + for (const key of Object.keys(checklyGroupSecrets)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + delete updatedVariables[key]; + } + } + } + + // Update all group environment variables at once + try { + await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId, + Object.values(updatedVariables) + ); } catch (error) { + if (error instanceof SecretSyncError) throw error; + throw new SecretSyncError({ error, - secretKey: key + secretKey: "group_update" }); } - } + } else { + // Handle global variables (existing logic) + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); - if (disableSecretDeletion) return; + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); - for await (const key of Object.keys(checklySecrets)) { - try { - // eslint-disable-next-line no-continue - if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + for await (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; - if (!secretMap[key]) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key + // If value is empty, we skip the upsert - checkly does not allow empty values + if (entry.value.trim() === "") { + // Delete the secret from Checkly if its empty + if (!disableSecretDeletion) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + continue; // Skip empty values + } + + await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + key, + value: entry.value, + secret: true, + locked: true + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(checklySecrets)) { + try { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key }); } - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key - }); } } }, @@ -80,23 +144,54 @@ export const ChecklySyncFns = { async removeSecrets(secretSync: TChecklySyncWithCredentials, secretMap: TSecretMap) { const config = secretSync.destinationConfig; - const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + if (config.groupId) { + // Handle group environment variables + const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId + ); - const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable])); + + // Filter out the secrets to remove + const remainingVariables = Object.keys(checklyGroupSecrets) + .filter((key) => !(key in secretMap)) + .map((key) => checklyGroupSecrets[key]); - for await (const secret of Object.keys(checklySecrets)) { try { - if (secret in secretMap) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key: secret - }); - } + await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId, + remainingVariables + ); } catch (error) { throw new SecretSyncError({ error, - secretKey: secret + secretKey: "group_remove" }); } + } else { + // Handle global variables (existing logic) + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + + for await (const secret of Object.keys(checklySecrets)) { + try { + if (secret in secretMap) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key: secret + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } } } }; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts index 04f444357..fc511b2d7 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts @@ -11,7 +11,17 @@ import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types" const ChecklySyncDestinationConfigSchema = z.object({ accountId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"), - accountName: z.string().min(1, "Account Name is required").max(255, "Account ID must be less than 255 characters") + accountName: z + .string() + .min(1, "Account Name is required") + .max(255, "Account ID must be less than 255 characters") + .optional(), + groupId: z.string().min(1, "Group ID is required").max(255, "Group ID must be less than 255 characters").optional(), + groupName: z + .string() + .min(1, "Group Name is required") + .max(255, "Group Name must be less than 255 characters") + .optional() }); const ChecklySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; diff --git a/docs/images/secret-syncs/checkly/checkly-sync-destination.png b/docs/images/secret-syncs/checkly/checkly-sync-destination.png index bb3df8d94..522418a3e 100644 Binary files a/docs/images/secret-syncs/checkly/checkly-sync-destination.png and b/docs/images/secret-syncs/checkly/checkly-sync-destination.png differ diff --git a/docs/integrations/secret-syncs/checkly.mdx b/docs/integrations/secret-syncs/checkly.mdx index 4599fe634..07382fb35 100644 --- a/docs/integrations/secret-syncs/checkly.mdx +++ b/docs/integrations/secret-syncs/checkly.mdx @@ -37,6 +37,7 @@ description: "Learn how to configure a Checkly Sync for Infisical." - **Checkly Connection**: The Checkly Connection to authenticate with. - **Account**: The Checkly account to sync secrets to. + - **Group**: The Checkly check group to sync secrets to (Optional). Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx index 50c645ee2..c55313898 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx @@ -5,14 +5,15 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se import { FilterableSelect, FormControl } from "@app/components/v2"; import { TChecklyAccount, - useChecklyConnectionListAccounts + useChecklyConnectionListAccounts, + useChecklyConnectionListGroups } from "@app/hooks/api/appConnections/checkly"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; export const ChecklySyncFields = () => { - const { control, setValue } = useFormContext< + const { control, setValue, watch } = useFormContext< TSecretSyncForm & { destination: SecretSync.Checkly } >(); @@ -25,12 +26,24 @@ export const ChecklySyncFields = () => { } ); + const accountId = watch("destinationConfig.accountId"); + + const { data: groups = [], isPending: isGroupsLoading } = useChecklyConnectionListGroups( + connectionId, + accountId, + { + enabled: Boolean(connectionId && accountId) + } + ); + return ( <> { setValue("destinationConfig.accountId", ""); setValue("destinationConfig.accountName", ""); + setValue("destinationConfig.groupId", undefined); + setValue("destinationConfig.groupName", undefined); }} /> { )} /> + + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.groupName", v?.name ?? undefined); + }} + options={groups} + placeholder="Select a group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx index d520699ce..0f86a0c06 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx @@ -6,7 +6,16 @@ import { SecretSync } from "@app/hooks/api/secretSyncs"; export const ChecklySyncReviewFields = () => { const { watch } = useFormContext(); - const accountName = watch("destinationConfig.accountName"); + const config = watch("destinationConfig"); - return {accountName}; + return ( + <> + + {config.accountName ?? config.accountId} + + {config.groupId && ( + {config.groupName ?? config.groupId} + )} + + ); }; diff --git a/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts index 9ada6e276..7684df981 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts @@ -8,7 +8,15 @@ export const ChecklySyncDestinationSchema = BaseSecretSyncSchema().merge( destination: z.literal(SecretSync.Checkly), destinationConfig: z.object({ accountId: z.string(), - accountName: z.string() + accountName: z.string(), + groupId: z + .string() + .nullish() + .transform((val) => val || undefined), + groupName: z + .string() + .nullish() + .transform((val) => val || undefined) }) }) ); diff --git a/frontend/src/hooks/api/appConnections/checkly/queries.tsx b/frontend/src/hooks/api/appConnections/checkly/queries.tsx index 452c21245..95156aaa4 100644 --- a/frontend/src/hooks/api/appConnections/checkly/queries.tsx +++ b/frontend/src/hooks/api/appConnections/checkly/queries.tsx @@ -8,7 +8,9 @@ import { TChecklyAccount } from "./types"; const checklyConnectionKeys = { all: [...appConnectionKeys.all, "checkly"] as const, listAccounts: (connectionId: string) => - [...checklyConnectionKeys.all, "workspace-scopes", connectionId] as const + [...checklyConnectionKeys.all, "workspace-scopes", connectionId] as const, + listGroups: (connectionId: string, accountId: string) => + [...checklyConnectionKeys.all, "groups", connectionId, accountId] as const }; export const useChecklyConnectionListAccounts = ( @@ -35,3 +37,29 @@ export const useChecklyConnectionListAccounts = ( ...options }); }; + +export const useChecklyConnectionListGroups = ( + connectionId: string, + accountId: string, + options?: Omit< + UseQueryOptions< + TChecklyAccount[], + unknown, + TChecklyAccount[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: checklyConnectionKeys.listGroups(connectionId, accountId), + queryFn: async () => { + const { data } = await apiRequest.get<{ groups: TChecklyAccount[] }>( + `/api/v1/app-connections/checkly/${connectionId}/accounts/${accountId}/groups` + ); + + return data.groups; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts b/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts index 40718d261..10a8601de 100644 --- a/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts @@ -3,11 +3,17 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; +export enum ChecklySyncScope { + Global = "global", + Group = "group" +} export type TChecklySync = TRootSecretSync & { destination: SecretSync.Checkly; destinationConfig: { accountId: string; accountName: string; + groupId?: string; + groupName?: string; }; connection: { app: AppConnection.Checkly; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 01c4024d7..bb0246236 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -175,8 +175,8 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { secondaryText = "Railway Project"; break; case SecretSync.Checkly: - primaryText = destinationConfig.accountName; - secondaryText = "Checkly Account"; + primaryText = destinationConfig.accountName || destinationConfig.accountId; + secondaryText = destinationConfig.groupName || destinationConfig.groupId || "Checkly Account"; break; case SecretSync.Supabase: primaryText = destinationConfig.projectName; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx index 4920bb16b..517398e73 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx @@ -8,5 +8,12 @@ type Props = { export const ChecklySyncDestinationSection = ({ secretSync }: Props) => { const { destinationConfig } = secretSync; - return {destinationConfig.accountName}; + return ( + <> + {destinationConfig.accountName} + {destinationConfig.groupId && ( + {destinationConfig.groupName} + )} + + ); };