feat: Support Checkly group variables (ENG-3478) (#4418)

* feat: checkly group sync

* fix: remove scope discriminator

* fix: forms

* fix: queries

* fix: 500 error

* fix: update docs

* lint: fix

* fix: review changes

* fix: PR changes

* fix: resolve group select UI not clearing

---------

Co-authored-by: Scott Wilson <[email protected]>
This commit is contained in:
Sid
2025-08-28 21:55:53 +05:30
committed by GitHub
co-authored by Scott Wilson
parent c7987772e3
commit a0da2f2d4c
15 changed files with 428 additions and 47 deletions
@@ -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 };
}
});
};
@@ -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<ChecklyGroupResponse>(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();
@@ -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
};
};
@@ -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[];
};
@@ -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
});
}
}
}
}
};
@@ -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 };