diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 3657fbb2784..ca52639e76f 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2650,6 +2650,9 @@ export const AppConnections = { VERCEL: { apiToken: "The API token used to authenticate with Vercel." }, + TRAVISCI: { + apiToken: "The API token used to authenticate with Travis CI." + }, CAMUNDA: { clientId: "The client ID used to authenticate with Camunda.", clientSecret: "The client secret used to authenticate with Camunda." @@ -3090,6 +3093,12 @@ export const SecretSyncs = { projectId: "The ID of the project on the external Infisical instance to sync secrets to.", environment: "The environment slug on the external Infisical instance to sync secrets to.", secretPath: "The secret path on the external Infisical instance to sync secrets to." + }, + TRAVIS_CI: { + repositoryId: "The ID of the Travis CI repository to sync secrets to.", + repositorySlug: "The slug (owner/repo) of the Travis CI repository to sync secrets to.", + branch: + "The branch of the Travis CI repository to sync secrets to. If omitted, secrets sync to the repository-level scope." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index 08178345eb1..912a8876860 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -54,7 +54,8 @@ export const registerAppConnectionEndpoints = { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 8a9fc98591a..6d315588481 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -53,6 +53,7 @@ import { registerSshConnectionRouter } from "./ssh-connection-router"; import { registerSupabaseConnectionRouter } from "./supabase-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; +import { registerTravisCIConnectionRouter } from "./travis-ci-connection-router"; import { registerVenafiConnectionRouter } from "./venafi-connection-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; import { registerWindmillConnectionRouter } from "./windmill-connection-router"; @@ -118,5 +119,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.TravisCI, + server, + sanitizedResponseSchema: SanitizedTravisCIConnectionSchema, + createSchema: CreateTravisCIConnectionSchema, + updateSchema: UpdateTravisCIConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/repositories`, + config: { + rateLimit: readLimit + }, + schema: { + operationId: "listTravisCIRepositories", + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const repositories = await server.services.appConnection.travisCI.listRepositories(connectionId, req.permission); + + return repositories; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/branches`, + config: { + rateLimit: readLimit + }, + schema: { + operationId: "listTravisCIBranches", + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + repositoryId: z.string().min(1, "Repository ID is required") + }), + response: { + 200: z + .object({ + name: z.string(), + isDefault: z.boolean() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { repositoryId } = req.query; + + const branches = await server.services.appConnection.travisCI.listBranches( + connectionId, + repositoryId, + req.permission + ); + + return branches; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index abe6d17f251..1f4fe629455 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -34,6 +34,7 @@ import { registerRenderSyncRouter } from "./render-sync-router"; import { registerSupabaseSyncRouter } from "./supabase-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; +import { registerTravisCISyncRouter } from "./travis-ci-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; import { registerWindmillSyncRouter } from "./windmill-sync-router"; import { registerZabbixSyncRouter } from "./zabbix-sync-router"; @@ -77,5 +78,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record = { [SecretSync.OnePass]: "OnePassword", [SecretSync.GitHub]: "GitHub", - [SecretSync.GitLab]: "GitLab" + [SecretSync.GitLab]: "GitLab", + [SecretSync.TravisCI]: "TravisCI" }; const destinationNameForOpId = specialCases[destination] ?? diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 5b10c51756f..efd04d9d923 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -63,6 +63,7 @@ import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase"; import { TeamCitySyncListItemSchema, TeamCitySyncSchema } from "@app/services/secret-sync/teamcity"; import { TerraformCloudSyncListItemSchema, TerraformCloudSyncSchema } from "@app/services/secret-sync/terraform-cloud"; +import { TravisCISyncListItemSchema, TravisCISyncSchema } from "@app/services/secret-sync/travis-ci"; import { VercelSyncListItemSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel"; import { WindmillSyncListItemSchema, WindmillSyncSchema } from "@app/services/secret-sync/windmill"; import { ZabbixSyncListItemSchema, ZabbixSyncSchema } from "@app/services/secret-sync/zabbix"; @@ -104,7 +105,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ OctopusDeploySyncSchema, CircleCISyncSchema, AzureEntraIdScimSyncSchema, - ExternalInfisicalSyncSchema + ExternalInfisicalSyncSchema, + TravisCISyncSchema ]); const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ @@ -144,7 +146,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ OctopusDeploySyncListItemSchema, CircleCISyncListItemSchema, AzureEntraIdScimSyncListItemSchema, - ExternalInfisicalSyncListItemSchema + ExternalInfisicalSyncListItemSchema, + TravisCISyncListItemSchema ]); export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/secret-sync-routers/travis-ci-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/travis-ci-sync-router.ts new file mode 100644 index 00000000000..cf4cc88158d --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/travis-ci-sync-router.ts @@ -0,0 +1,17 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + CreateTravisCISyncSchema, + TravisCISyncSchema, + UpdateTravisCISyncSchema +} from "@app/services/secret-sync/travis-ci"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerTravisCISyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.TravisCI, + server, + responseSchema: TravisCISyncSchema, + createSchema: CreateTravisCISyncSchema, + updateSchema: UpdateTravisCISyncSchema + }); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 53ea4f312e8..c9abe04f33d 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -55,7 +55,8 @@ export enum AppConnection { ExternalInfisical = "external-infisical", Doppler = "doppler", NetScaler = "netscaler", - Anthropic = "anthropic" + Anthropic = "anthropic", + TravisCI = "travis-ci" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index ea24b7e4c01..d5b84b37550 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -201,6 +201,11 @@ import { TerraformCloudConnectionMethod, validateTerraformCloudConnectionCredentials } from "./terraform-cloud"; +import { + getTravisCIConnectionListItem, + TravisCIConnectionMethod, + validateTravisCIConnectionCredentials +} from "./travis-ci"; import { getVenafiConnectionListItem, validateVenafiConnectionCredentials, VenafiConnectionMethod } from "./venafi"; import { VercelConnectionMethod } from "./vercel"; import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; @@ -294,7 +299,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getVenafiConnectionListItem(), getExternalInfisicalConnectionListItem(), getDopplerConnectionListItem(), - getNetScalerConnectionListItem() + getNetScalerConnectionListItem(), + getTravisCIConnectionListItem() ] .filter((option) => { switch (projectType) { @@ -441,6 +447,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AzureEntraId]: validateAzureEntraIdConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Venafi]: validateVenafiConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.NetScaler]: validateNetScalerConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.TravisCI]: validateTravisCIConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.ExternalInfisical]: ((config: TAppConnectionConfig) => validateExternalInfisicalConnectionCredentials( config as TExternalInfisicalConnectionConfig, @@ -493,6 +500,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case LaravelForgeConnectionMethod.ApiToken: case DbtConnectionMethod.ApiToken: case CircleCIConnectionMethod.ApiToken: + case TravisCIConnectionMethod.ApiToken: return "API Token"; case DNSMadeEasyConnectionMethod.APIKeySecret: return "API Key & Secret"; @@ -652,8 +660,9 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AzureEntraId]: platformManagedCredentialsNotSupported, [AppConnection.Venafi]: platformManagedCredentialsNotSupported, [AppConnection.ExternalInfisical]: platformManagedCredentialsNotSupported, + [AppConnection.NetScaler]: platformManagedCredentialsNotSupported, [AppConnection.Doppler]: platformManagedCredentialsNotSupported, - [AppConnection.NetScaler]: platformManagedCredentialsNotSupported + [AppConnection.TravisCI]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index f564dcffae5..02316e7aca6 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -57,7 +57,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.ExternalInfisical]: "Infisical", [AppConnection.Doppler]: "Doppler", [AppConnection.NetScaler]: "NetScaler", - [AppConnection.Anthropic]: "Anthropic" + [AppConnection.Anthropic]: "Anthropic", + [AppConnection.TravisCI]: "Travis CI" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -117,5 +118,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -462,6 +469,7 @@ export type TAppConnectionInput = { id: string } & ( | TDopplerConnectionInput | TNetScalerConnectionInput | TAnthropicConnectionInput + | TTravisCIConnectionInput ); export type TSqlConnectionInput = @@ -558,7 +566,8 @@ export type TAppConnectionConfig = | TExternalInfisicalConnectionConfig | TDopplerConnectionConfig | TNetScalerConnectionConfig - | TAnthropicConnectionConfig; + | TAnthropicConnectionConfig + | TTravisCIConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -617,7 +626,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateExternalInfisicalConnectionCredentialsSchema | TValidateDopplerConnectionCredentialsSchema | TValidateNetScalerConnectionCredentialsSchema - | TValidateAnthropicConnectionCredentialsSchema; + | TValidateAnthropicConnectionCredentialsSchema + | TValidateTravisCIConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/travis-ci/index.ts b/backend/src/services/app-connection/travis-ci/index.ts new file mode 100644 index 00000000000..0649d781031 --- /dev/null +++ b/backend/src/services/app-connection/travis-ci/index.ts @@ -0,0 +1,4 @@ +export * from "./travis-ci-connection-enums"; +export * from "./travis-ci-connection-fns"; +export * from "./travis-ci-connection-schemas"; +export * from "./travis-ci-connection-types"; diff --git a/backend/src/services/app-connection/travis-ci/travis-ci-connection-enums.ts b/backend/src/services/app-connection/travis-ci/travis-ci-connection-enums.ts new file mode 100644 index 00000000000..9e1a9644331 --- /dev/null +++ b/backend/src/services/app-connection/travis-ci/travis-ci-connection-enums.ts @@ -0,0 +1,3 @@ +export enum TravisCIConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/travis-ci/travis-ci-connection-fns.ts b/backend/src/services/app-connection/travis-ci/travis-ci-connection-fns.ts new file mode 100644 index 00000000000..ea602b68367 --- /dev/null +++ b/backend/src/services/app-connection/travis-ci/travis-ci-connection-fns.ts @@ -0,0 +1,153 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { TravisCIConnectionMethod } from "./travis-ci-connection-enums"; +import { + TravisCIBranch, + TravisCIRepository, + TTravisCIConnection, + TTravisCIConnectionConfig +} from "./travis-ci-connection-types"; + +const travisCIApiHeaders = (apiToken: string) => ({ + Authorization: `token ${apiToken}`, + "Travis-API-Version": "3", + Accept: "application/json" +}); + +type TravisCIPaginationMeta = { + is_last?: boolean; + next?: { "@href": string } | null; +}; + +const resolveNextTravisCIUrl = (pagination: TravisCIPaginationMeta | undefined): string | undefined => { + const nextHref = pagination?.next?.["@href"]; + if (pagination?.is_last || !nextHref) return undefined; + return nextHref.startsWith("http") ? nextHref : `${IntegrationUrls.TRAVISCI_API_URL}${nextHref}`; +}; + +export const getTravisCIConnectionListItem = () => { + return { + name: "Travis CI" as const, + app: AppConnection.TravisCI as const, + methods: Object.values(TravisCIConnectionMethod) as [TravisCIConnectionMethod.ApiToken] + }; +}; + +export const validateTravisCIConnectionCredentials = async (config: TTravisCIConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + await request.get(`${IntegrationUrls.TRAVISCI_API_URL}/user`, { + headers: travisCIApiHeaders(inputCredentials.apiToken) + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${ + error.response?.data ? JSON.stringify(error.response?.data) : error.message || "Unknown error" + }` + }); + } + throw new BadRequestError({ + message: `Unable to validate connection: ${(error as Error).message || "Verify credentials"}` + }); + } + + return inputCredentials; +}; + +export const listTravisCIRepositories = async (appConnection: TTravisCIConnection): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + try { + type TravisCIRepositoriesResponse = { + "@pagination"?: TravisCIPaginationMeta; + repositories?: { id: string | number; slug: string }[]; + }; + + const allRepos: { id: string | number; slug: string }[] = []; + let nextUrl: string | undefined = `${IntegrationUrls.TRAVISCI_API_URL}/repos`; + + while (nextUrl) { + // eslint-disable-next-line no-await-in-loop + const { data }: { data: TravisCIRepositoriesResponse } = await request.get(nextUrl, { + headers: travisCIApiHeaders(apiToken) + }); + + if (Array.isArray(data.repositories)) { + allRepos.push(...data.repositories); + } + + nextUrl = resolveNextTravisCIUrl(data["@pagination"]); + } + + return allRepos.map((repo) => ({ + id: String(repo.id), + slug: repo.slug, + name: repo.slug?.split("/")[1] ?? repo.slug + })); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch Travis CI repositories: ${ + error.response?.data ? JSON.stringify(error.response?.data) : error.message || "Unknown error" + }` + }); + } + throw error; + } +}; + +export const listTravisCIBranches = async ( + appConnection: TTravisCIConnection, + repositoryId: string +): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + try { + type TravisCIBranchesResponse = { + "@pagination"?: TravisCIPaginationMeta; + branches?: { name: string; default_branch?: boolean }[]; + }; + + const allBranches: { name: string; default_branch?: boolean }[] = []; + let nextUrl: string | undefined = + `${IntegrationUrls.TRAVISCI_API_URL}/repo/${encodeURIComponent(repositoryId)}/branches`; + + while (nextUrl) { + // eslint-disable-next-line no-await-in-loop + const { data }: { data: TravisCIBranchesResponse } = await request.get(nextUrl, { + headers: travisCIApiHeaders(apiToken) + }); + + if (Array.isArray(data.branches)) { + allBranches.push(...data.branches); + } + + nextUrl = resolveNextTravisCIUrl(data["@pagination"]); + } + + return allBranches.map((branch) => ({ + name: branch.name, + isDefault: Boolean(branch.default_branch) + })); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch Travis CI branches: ${ + error.response?.data ? JSON.stringify(error.response?.data) : error.message || "Unknown error" + }` + }); + } + throw error; + } +}; diff --git a/backend/src/services/app-connection/travis-ci/travis-ci-connection-schemas.ts b/backend/src/services/app-connection/travis-ci/travis-ci-connection-schemas.ts new file mode 100644 index 00000000000..989d7dd4db5 --- /dev/null +++ b/backend/src/services/app-connection/travis-ci/travis-ci-connection-schemas.ts @@ -0,0 +1,61 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; +import { TravisCIConnectionMethod } from "./travis-ci-connection-enums"; + +export const TravisCIConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.TRAVISCI.apiToken) +}); + +const BaseTravisCIConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.TravisCI) +}); + +export const TravisCIConnectionSchema = BaseTravisCIConnectionSchema.extend({ + method: z.literal(TravisCIConnectionMethod.ApiToken), + credentials: TravisCIConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedTravisCIConnectionSchema = z.discriminatedUnion("method", [ + BaseTravisCIConnectionSchema.extend({ + method: z.literal(TravisCIConnectionMethod.ApiToken), + credentials: TravisCIConnectionAccessTokenCredentialsSchema.pick({}) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.TravisCI]} (API Token)` })) +]); + +export const ValidateTravisCIConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(TravisCIConnectionMethod.ApiToken).describe(AppConnections.CREATE(AppConnection.TravisCI).method), + credentials: TravisCIConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.TravisCI).credentials + ) + }) +]); + +export const CreateTravisCIConnectionSchema = ValidateTravisCIConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.TravisCI) +); + +export const UpdateTravisCIConnectionSchema = z + .object({ + credentials: TravisCIConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.TravisCI).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.TravisCI)); + +export const TravisCIConnectionListItemSchema = z + .object({ + name: z.literal("Travis CI"), + app: z.literal(AppConnection.TravisCI), + methods: z.nativeEnum(TravisCIConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.TravisCI] })); diff --git a/backend/src/services/app-connection/travis-ci/travis-ci-connection-service.ts b/backend/src/services/app-connection/travis-ci/travis-ci-connection-service.ts new file mode 100644 index 00000000000..10c350f7339 --- /dev/null +++ b/backend/src/services/app-connection/travis-ci/travis-ci-connection-service.ts @@ -0,0 +1,39 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listTravisCIBranches, listTravisCIRepositories } from "./travis-ci-connection-fns"; +import { TTravisCIConnection } from "./travis-ci-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const travisCIConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listRepositories = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.TravisCI, connectionId, actor); + try { + return await listTravisCIRepositories(appConnection); + } catch (error) { + logger.error(error, "Failed to list Travis CI repositories"); + return []; + } + }; + + const listBranches = async (connectionId: string, repositoryId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.TravisCI, connectionId, actor); + try { + return await listTravisCIBranches(appConnection, repositoryId); + } catch (error) { + logger.error(error, "Failed to list Travis CI branches"); + return []; + } + }; + + return { + listRepositories, + listBranches + }; +}; diff --git a/backend/src/services/app-connection/travis-ci/travis-ci-connection-types.ts b/backend/src/services/app-connection/travis-ci/travis-ci-connection-types.ts new file mode 100644 index 00000000000..d1067591cad --- /dev/null +++ b/backend/src/services/app-connection/travis-ci/travis-ci-connection-types.ts @@ -0,0 +1,36 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateTravisCIConnectionSchema, + TravisCIConnectionSchema, + ValidateTravisCIConnectionCredentialsSchema +} from "./travis-ci-connection-schemas"; + +export type TTravisCIConnection = z.infer; + +export type TTravisCIConnectionInput = z.infer & { + app: AppConnection.TravisCI; +}; + +export type TValidateTravisCIConnectionCredentialsSchema = typeof ValidateTravisCIConnectionCredentialsSchema; + +export type TTravisCIConnectionConfig = DiscriminativePick< + TTravisCIConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TravisCIRepository = { + id: string; + name: string; + slug: string; +}; + +export type TravisCIBranch = { + name: string; + isDefault: boolean; +}; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index e2581d26c8d..eb1a3fd27e3 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -265,6 +265,7 @@ export const getIntegrationOptions = async () => { { name: "Travis CI", slug: "travisci", + syncSlug: "travis-ci", image: "Travis CI.png", isAvailable: true, type: "pat", diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 92dbd907fa9..e7557feb2ed 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -35,7 +35,8 @@ export enum SecretSync { OctopusDeploy = "octopus-deploy", CircleCI = "circleci", AzureEntraIdScim = "azure-entra-id-scim", - ExternalInfisical = "external-infisical" + ExternalInfisical = "external-infisical", + TravisCI = "travis-ci" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index cfd34bf4cad..5b35b681928 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -76,6 +76,7 @@ import { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps"; import { SUPABASE_SYNC_LIST_OPTION, SupabaseSyncFns } from "./supabase"; import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; +import { TRAVIS_CI_SYNC_LIST_OPTION, TravisCISyncFns } from "./travis-ci"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; import { WINDMILL_SYNC_LIST_OPTION, WindmillSyncFns } from "./windmill"; import { ZABBIX_SYNC_LIST_OPTION, ZabbixSyncFns } from "./zabbix"; @@ -117,7 +118,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.OctopusDeploy]: OCTOPUS_DEPLOY_SYNC_LIST_OPTION, [SecretSync.CircleCI]: CIRCLECI_SYNC_LIST_OPTION, [SecretSync.AzureEntraIdScim]: AZURE_ENTRA_ID_SCIM_SYNC_LIST_OPTION, - [SecretSync.ExternalInfisical]: EXTERNAL_INFISICAL_SYNC_LIST_OPTION + [SecretSync.ExternalInfisical]: EXTERNAL_INFISICAL_SYNC_LIST_OPTION, + [SecretSync.TravisCI]: TRAVIS_CI_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -378,6 +380,8 @@ export const SecretSyncFns = { // Key schema is intentionally not applied for Infisical-to-Infisical syncs to prevent // infinite sync loops where the prefixed key triggers another sync cycle. return ExternalInfisicalSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.TravisCI: + return TravisCISyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -518,6 +522,9 @@ export const SecretSyncFns = { case SecretSync.ExternalInfisical: secretMap = await ExternalInfisicalSyncFns.getSecrets(secretSync); break; + case SecretSync.TravisCI: + secretMap = await TravisCISyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -627,6 +634,8 @@ export const SecretSyncFns = { // Key schema is intentionally not applied for Infisical-to-Infisical syncs to prevent // infinite sync loops where the prefixed key triggers another sync cycle. return ExternalInfisicalSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.TravisCI: + return TravisCISyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 9fe6f036422..bfb0f2f9540 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -39,7 +39,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.OctopusDeploy]: "Octopus Deploy", [SecretSync.CircleCI]: "CircleCI", [SecretSync.AzureEntraIdScim]: "Azure Entra ID SCIM", - [SecretSync.ExternalInfisical]: "Infisical" + [SecretSync.ExternalInfisical]: "Infisical", + [SecretSync.TravisCI]: "Travis CI" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -79,7 +80,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.OctopusDeploy]: AppConnection.OctopusDeploy, [SecretSync.CircleCI]: AppConnection.CircleCI, [SecretSync.AzureEntraIdScim]: AppConnection.AzureEntraId, - [SecretSync.ExternalInfisical]: AppConnection.ExternalInfisical + [SecretSync.ExternalInfisical]: AppConnection.ExternalInfisical, + [SecretSync.TravisCI]: AppConnection.TravisCI }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -119,7 +121,8 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.OctopusDeploy]: SecretSyncPlanType.Regular, [SecretSync.CircleCI]: SecretSyncPlanType.Regular, [SecretSync.AzureEntraIdScim]: SecretSyncPlanType.Regular, - [SecretSync.ExternalInfisical]: SecretSyncPlanType.Regular + [SecretSync.ExternalInfisical]: SecretSyncPlanType.Regular, + [SecretSync.TravisCI]: SecretSyncPlanType.Regular }; export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { @@ -168,7 +171,8 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.OctopusDeploy]: [], [SecretSync.CircleCI]: [], [SecretSync.AzureEntraIdScim]: [], - [SecretSync.ExternalInfisical]: [] + [SecretSync.ExternalInfisical]: [], + [SecretSync.TravisCI]: ["repositorySlug"] }; const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; @@ -234,7 +238,8 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +type Throttle = { + wait: () => Promise; + bumpOn429: () => void; +}; + +const makeThrottle = (): Throttle => { + let currentDelayMs = BASE_DELAY_MS; + return { + wait: () => sleep(currentDelayMs), + bumpOn429: () => { + currentDelayMs = Math.min(currentDelayMs * 2, MAX_DELAY_MS); + } + }; +}; + +const makeRequestWithRetry = async (throttle: Throttle, requestFn: () => Promise, attempt = 0): Promise => { + await throttle.wait(); + try { + return await requestFn(); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 429 && attempt < MAX_RETRIES) { + throttle.bumpOn429(); + return makeRequestWithRetry(throttle, requestFn, attempt + 1); + } + throw error; + } +}; + +const travisCIApiHeaders = (apiToken: string) => ({ + Authorization: `token ${apiToken}`, + "Travis-API-Version": "3", + Accept: "application/json" +}); + +const getRepoEnvVars = async ( + apiToken: string, + repositoryId: string, + throttle: Throttle +): Promise => { + const { data } = await makeRequestWithRetry(throttle, () => + request.get<{ env_vars: TTravisCIEnvVar[] }>( + `${IntegrationUrls.TRAVISCI_API_URL}/repo/${encodeURIComponent(repositoryId)}/env_vars`, + { headers: travisCIApiHeaders(apiToken) } + ) + ); + + return data?.env_vars ?? []; +}; + +const filterByScope = ( + envVars: TTravisCIEnvVar[], + destinationConfig: TTravisCISyncWithCredentials["destinationConfig"] +): TTravisCIEnvVar[] => { + if (destinationConfig.branch) { + return envVars.filter((envVar) => envVar.branch === destinationConfig.branch); + } + + return envVars.filter((envVar) => envVar.branch === null); +}; + +type TTravisCIEnvVarUpsertBody = { + "env_var.name": string; + "env_var.value": string; + "env_var.public": boolean; + "env_var.branch"?: string; +}; + +const upsertRepoEnvVar = async ({ + apiToken, + repositoryId, + existingEnvVarId, + body, + throttle +}: { + apiToken: string; + repositoryId: string; + existingEnvVarId?: string; + body: TTravisCIEnvVarUpsertBody; + throttle: Throttle; +}): Promise => { + const headers = { ...travisCIApiHeaders(apiToken), "Content-Type": "application/json" }; + const encodedRepoId = encodeURIComponent(repositoryId); + + if (existingEnvVarId) { + await makeRequestWithRetry(throttle, () => + request.patch( + `${IntegrationUrls.TRAVISCI_API_URL}/repo/${encodedRepoId}/env_var/${encodeURIComponent(existingEnvVarId)}`, + body, + { headers } + ) + ); + return; + } + + await makeRequestWithRetry(throttle, () => + request.post(`${IntegrationUrls.TRAVISCI_API_URL}/repo/${encodedRepoId}/env_vars`, body, { headers }) + ); +}; + +export const TravisCISyncFns = { + async getSecrets(secretSync: TTravisCISyncWithCredentials): Promise { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + async syncSecrets(secretSync: TTravisCISyncWithCredentials, secretMap: TSecretMap): Promise { + const { + connection: { + credentials: { apiToken } + }, + destinationConfig, + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const throttle = makeThrottle(); + const envVars = await getRepoEnvVars(apiToken, destinationConfig.repositoryId, throttle); + const scopedEnvVars = filterByScope(envVars, destinationConfig); + const scopedByName = Object.fromEntries(scopedEnvVars.map((envVar) => [envVar.name, envVar])); + + for (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; + const body: TTravisCIEnvVarUpsertBody = { + "env_var.name": key, + "env_var.value": entry.value, + "env_var.public": false + }; + + const { branch } = destinationConfig; + if (typeof branch === "string" && branch.length > 0) { + body["env_var.branch"] = branch; + } + + await upsertRepoEnvVar({ + apiToken, + repositoryId: destinationConfig.repositoryId, + existingEnvVarId: scopedByName[key]?.id, + body, + throttle + }); + } catch (error) { + throw new SecretSyncError({ error, secretKey: key }); + } + } + + if (disableSecretDeletion) return; + + // check if it is possible to delete in bulk + + for (const envVar of scopedEnvVars) { + if (!matchesSchema(envVar.name, environment?.slug || "", keySchema)) continue; + if (envVar.name in secretMap) continue; + + try { + await makeRequestWithRetry(throttle, () => + request.delete( + `${IntegrationUrls.TRAVISCI_API_URL}/repo/${encodeURIComponent( + destinationConfig.repositoryId + )}/env_var/${encodeURIComponent(envVar.id)}`, + { headers: travisCIApiHeaders(apiToken) } + ) + ); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) continue; + throw new SecretSyncError({ error, secretKey: envVar.name }); + } + } + }, + + async removeSecrets(secretSync: TTravisCISyncWithCredentials, secretMap: TSecretMap): Promise { + const { + connection: { + credentials: { apiToken } + }, + destinationConfig + } = secretSync; + + const throttle = makeThrottle(); + const envVars = await getRepoEnvVars(apiToken, destinationConfig.repositoryId, throttle); + const scopedEnvVars = filterByScope(envVars, destinationConfig); + + for (const envVar of scopedEnvVars) { + if (!(envVar.name in secretMap)) continue; + + try { + await makeRequestWithRetry(throttle, () => + request.delete( + `${IntegrationUrls.TRAVISCI_API_URL}/repo/${encodeURIComponent( + destinationConfig.repositoryId + )}/env_var/${encodeURIComponent(envVar.id)}`, + { headers: travisCIApiHeaders(apiToken) } + ) + ); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) continue; + throw new SecretSyncError({ error, secretKey: envVar.name }); + } + } + } +}; diff --git a/backend/src/services/secret-sync/travis-ci/travis-ci-sync-schemas.ts b/backend/src/services/secret-sync/travis-ci/travis-ci-sync-schemas.ts new file mode 100644 index 00000000000..9354a10bf85 --- /dev/null +++ b/backend/src/services/secret-sync/travis-ci/travis-ci-sync-schemas.ts @@ -0,0 +1,60 @@ +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 { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + +const TravisCISyncDestinationConfigSchema = z.object({ + repositoryId: z + .string() + .trim() + .min(1, "Repository ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TRAVIS_CI.repositoryId), + repositorySlug: z + .string() + .trim() + .min(1, "Repository slug is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TRAVIS_CI.repositorySlug), + branch: z.string().trim().min(1).optional().describe(SecretSyncs.DESTINATION_CONFIG.TRAVIS_CI.branch) +}); + +const TravisCISyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const TravisCISyncSchema = BaseSecretSyncSchema(SecretSync.TravisCI, TravisCISyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.TravisCI), + destinationConfig: TravisCISyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.TravisCI] })); + +export const CreateTravisCISyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.TravisCI, + TravisCISyncOptionsConfig +).extend({ + destinationConfig: TravisCISyncDestinationConfigSchema +}); + +export const UpdateTravisCISyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.TravisCI, + TravisCISyncOptionsConfig +).extend({ + destinationConfig: TravisCISyncDestinationConfigSchema.optional() +}); + +export const TravisCISyncListItemSchema = z + .object({ + name: z.literal("Travis CI"), + connection: z.literal(AppConnection.TravisCI), + destination: z.literal(SecretSync.TravisCI), + canImportSecrets: z.literal(false), + canRemoveSecretsOnDeletion: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.TravisCI] })); diff --git a/backend/src/services/secret-sync/travis-ci/travis-ci-sync-types.ts b/backend/src/services/secret-sync/travis-ci/travis-ci-sync-types.ts new file mode 100644 index 00000000000..1f6d56cc7d3 --- /dev/null +++ b/backend/src/services/secret-sync/travis-ci/travis-ci-sync-types.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { TTravisCIConnection } from "@app/services/app-connection/travis-ci"; + +import { CreateTravisCISyncSchema, TravisCISyncListItemSchema, TravisCISyncSchema } from "./travis-ci-sync-schemas"; + +export type TTravisCISyncListItem = z.infer; + +export type TTravisCISync = z.infer; + +export type TTravisCISyncInput = z.infer; + +export type TTravisCISyncWithCredentials = TTravisCISync & { + connection: TTravisCIConnection; +}; + +export type TTravisCIEnvVar = { + id: string; + name: string; + value: string | null; + public: boolean; + branch: string | null; +}; diff --git a/docs/docs.json b/docs/docs.json index 11d4b62261d..19205588139 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -157,6 +157,7 @@ "integrations/app-connections/supabase", "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", + "integrations/app-connections/travis-ci", "integrations/app-connections/venafi", "integrations/app-connections/vercel", "integrations/app-connections/windmill", @@ -605,6 +606,7 @@ "integrations/secret-syncs/supabase", "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", + "integrations/secret-syncs/travis-ci", "integrations/secret-syncs/vercel", "integrations/secret-syncs/windmill", "integrations/secret-syncs/zabbix" diff --git a/docs/images/app-connections/travis-ci/travis-ci-app-connection-modal.png b/docs/images/app-connections/travis-ci/travis-ci-app-connection-modal.png new file mode 100644 index 00000000000..62cb11a9f1e Binary files /dev/null and b/docs/images/app-connections/travis-ci/travis-ci-app-connection-modal.png differ diff --git a/docs/images/app-connections/travis-ci/travis-ci-app-connection-option.png b/docs/images/app-connections/travis-ci/travis-ci-app-connection-option.png new file mode 100644 index 00000000000..d387b59c051 Binary files /dev/null and b/docs/images/app-connections/travis-ci/travis-ci-app-connection-option.png differ diff --git a/docs/images/app-connections/travis-ci/travis-ci-copy-token.png b/docs/images/app-connections/travis-ci/travis-ci-copy-token.png new file mode 100644 index 00000000000..38f9879c723 Binary files /dev/null and b/docs/images/app-connections/travis-ci/travis-ci-copy-token.png differ diff --git a/docs/images/secret-syncs/travis-ci/select-travis-ci-option.png b/docs/images/secret-syncs/travis-ci/select-travis-ci-option.png new file mode 100644 index 00000000000..08efa4864d1 Binary files /dev/null and b/docs/images/secret-syncs/travis-ci/select-travis-ci-option.png differ diff --git a/docs/images/secret-syncs/travis-ci/travis-ci-created.png b/docs/images/secret-syncs/travis-ci/travis-ci-created.png new file mode 100644 index 00000000000..b790d682902 Binary files /dev/null and b/docs/images/secret-syncs/travis-ci/travis-ci-created.png differ diff --git a/docs/images/secret-syncs/travis-ci/travis-ci-destination.png b/docs/images/secret-syncs/travis-ci/travis-ci-destination.png new file mode 100644 index 00000000000..2ee9811707a Binary files /dev/null and b/docs/images/secret-syncs/travis-ci/travis-ci-destination.png differ diff --git a/docs/images/secret-syncs/travis-ci/travis-ci-details.png b/docs/images/secret-syncs/travis-ci/travis-ci-details.png new file mode 100644 index 00000000000..2416613df95 Binary files /dev/null and b/docs/images/secret-syncs/travis-ci/travis-ci-details.png differ diff --git a/docs/images/secret-syncs/travis-ci/travis-ci-options.png b/docs/images/secret-syncs/travis-ci/travis-ci-options.png new file mode 100644 index 00000000000..333eb96710e Binary files /dev/null and b/docs/images/secret-syncs/travis-ci/travis-ci-options.png differ diff --git a/docs/images/secret-syncs/travis-ci/travis-ci-review.png b/docs/images/secret-syncs/travis-ci/travis-ci-review.png new file mode 100644 index 00000000000..e9861c8dc9d Binary files /dev/null and b/docs/images/secret-syncs/travis-ci/travis-ci-review.png differ diff --git a/docs/images/secret-syncs/travis-ci/travis-ci-source.png b/docs/images/secret-syncs/travis-ci/travis-ci-source.png new file mode 100644 index 00000000000..dbdb1e1d4e7 Binary files /dev/null and b/docs/images/secret-syncs/travis-ci/travis-ci-source.png differ diff --git a/docs/integrations/app-connections/travis-ci.mdx b/docs/integrations/app-connections/travis-ci.mdx new file mode 100644 index 00000000000..cfe483fee79 --- /dev/null +++ b/docs/integrations/app-connections/travis-ci.mdx @@ -0,0 +1,104 @@ +--- +title: "Travis CI Connection" +description: "Learn how to configure a Travis CI Connection for Infisical." +--- + +Infisical supports connecting to [Travis CI](https://www.travis-ci.com/) using a personal API Token. + + + The API Token must belong to a user with sufficient permissions to manage + environment variables on the repositories you plan to sync with Infisical. + + +## Create a Travis CI API Token + + + + Navigate to [https://app.travis-ci.com](https://app.travis-ci.com) and click your profile avatar in the top-right corner, then select **Settings**. + + + In the **Settings** tab, locate the **API authentication** section. Click **Copy Token** to reveal and copy your personal API token. + + ![Copy Travis CI API Token](/images/app-connections/travis-ci/travis-ci-copy-token.png) + + + Treat this token like a password — it grants access to every repository you + have permission to administer. Store it somewhere safe; Infisical will + encrypt it at rest once the connection is created. + + + + +## Create a Travis CI Connection in Infisical + + + + + + In your Infisical dashboard, open the **Integrations** tab in the target project and select **App Connections**. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Travis CI Connection** from the list. + + ![Select Travis CI Connection](/images/app-connections/travis-ci/travis-ci-app-connection-option.png) + + + Complete the form by providing: + - A descriptive **Name** for the connection + - An optional **Description** + - The **API Token** you copied from Travis CI + + ![Travis CI Connection Form](/images/app-connections/travis-ci/travis-ci-app-connection-modal.png) + + + After submitting, your **Travis CI Connection** is ready to be used by Secret Syncs and other Infisical features. + + + + + + + To create a Travis CI Connection via API, send a request to the [Create Travis CI Connection](/api-reference/endpoints/app-connections/travis-ci/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/travis-ci \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-travis-ci-connection", + "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "apiToken": "[API TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-travis-ci-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2026-04-17T19:46:34.831Z", + "updatedAt": "2026-04-17T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "example-credentials-hash", + "app": "travis-ci", + "method": "api-token", + "credentials": {} + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/travis-ci.mdx b/docs/integrations/secret-syncs/travis-ci.mdx new file mode 100644 index 00000000000..a2d333e6535 --- /dev/null +++ b/docs/integrations/secret-syncs/travis-ci.mdx @@ -0,0 +1,174 @@ +--- +title: "Travis CI Sync" +description: "Learn how to configure a Travis CI Sync for Infisical." +--- + +Infisical's Travis CI Sync keeps your Travis CI repository environment variables in sync with an Infisical project. Account-level environment variables (`account_env_vars`) are not managed by this sync, only repository-scoped environment variables are. + + + `account_env_vars` are not exposed by the Travis CI API for listing or creation, so Infisical cannot sync them. + + +**Prerequisites:** + +- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) +- Create a [Travis CI Connection](/integrations/app-connections/travis-ci) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + Select the **Travis CI** option from the list of destinations. + + ![Select Travis CI](/images/secret-syncs/travis-ci/select-travis-ci-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/travis-ci/travis-ci-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/travis-ci/travis-ci-destination.png) + + - **Travis CI Connection**: The Travis CI Connection to authenticate with. + - **Repository**: The Travis CI repository to sync secrets to. + - **Branch** *Optional*: The branch that synced environment variables will be scoped to on Travis CI. If left empty, the sync will manage environment variables that aren't scoped to any branch (i.e. repository-level variables). + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/travis-ci/travis-ci-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any environment variables at the destination not present in Infisical. + + Travis CI does not support importing secrets. + + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced when changes occur at the source location. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove environment variables from Travis CI during a sync. Enable this option if you intend to manage some environment variables manually outside of Infisical. + + + Configure the **Details** of your Travis CI Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/travis-ci/travis-ci-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Travis CI Sync configuration, then click **Create Sync**. + + ![Confirm Configuration](/images/secret-syncs/travis-ci/travis-ci-review.png) + + + If enabled, your Travis CI Sync will begin pushing your Infisical secrets to the configured repository (and branch, if scoped). + + ![Sync Created](/images/secret-syncs/travis-ci/travis-ci-created.png) + + + + + To create a **Travis CI Sync**, make an API request to the [Create Travis CI Sync](/api-reference/endpoints/secret-syncs/travis-ci/create) API endpoint. + + The `branch` field inside `destinationConfig` is optional. Omit it to manage repository-level environment variables, or set it to scope the sync to a specific branch on Travis CI. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/travis-ci \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-travis-ci-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "Push Infisical secrets to the Travis CI repository", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "destinationConfig": { + "repositoryId": "12345678", + "repositorySlug": "my-org/my-repo", + "branch": "main" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-travis-ci-sync", + "description": "Push Infisical secrets to the Travis CI repository", + "isAutoSyncEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2026-04-17T05:31:56Z", + "updatedAt": "2026-04-17T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2026-04-17T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "travis-ci", + "name": "my-travis-ci-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "destination": "travis-ci", + "destinationConfig": { + "repositoryId": "12345678", + "repositorySlug": "my-org/my-repo", + "branch": "main" + } + } + } + ``` + + diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index b625b514607..74e78bafd18 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -445,6 +445,14 @@ export const AppConnectionsBrowser = () => { description: "Learn how to connect Doppler for migrating data from Doppler to Infisical.", category: "DevOps Tools", + }, + { + name: "Travis CI", + slug: "travis-ci", + path: "/integrations/app-connections/travis-ci", + description: + "Learn how to connect Travis CI to pull secrets from Infisical.", + category: "CI/CD", } ].sort(function (a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx index de1a9e84f7f..08308736b43 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -43,7 +43,9 @@ export const SecretSyncsBrowser = () => { {"name": "Northflank", "slug": "northflank", "path": "/integrations/secret-syncs/northflank", "description": "Learn how to sync secrets from Infisical to Northflank projects.", "category": "Hosting"}, {"name": "Octopus Deploy", "slug": "octopus-deploy", "path": "/integrations/secret-syncs/octopus-deploy", "description": "Learn how to sync secrets from Infisical to Octopus Deploy.", "category": "DevOps Tools"}, {"name": "Azure Entra ID SCIM", "slug": "azure-entra-id-scim", "path": "/integrations/secret-syncs/azure-entra-id-scim", "description": "Learn how to sync SCIM provisioning tokens from Infisical to Azure Entra ID.", "category": "Identity & Auth"}, - {"name": "Infisical", "slug": "external-infisical", "path": "/integrations/secret-syncs/external-infisical", "description": "Learn how to sync secrets from one Infisical instance to another.", "category": "Security"} + {"name": "Infisical", "slug": "external-infisical", "path": "/integrations/secret-syncs/external-infisical", "description": "Learn how to sync secrets from one Infisical instance to another.", "category": "Security"}, + {"name": "Travis CI", "slug": "travis-ci", "path": "/integrations/secret-syncs/travis-ci", "description": "Learn how to sync secrets from Infisical to Travis CI.", "category": "CI/CD"}, + ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 00174af087f..32283235730 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -37,6 +37,7 @@ import { RenderSyncFields } from "./RenderSyncFields"; import { SupabaseSyncFields } from "./SupabaseSyncFields"; import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; +import { TravisCISyncFields } from "./TravisCISyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; import { WindmillSyncFields } from "./WindmillSyncFields"; import { ZabbixSyncFields } from "./ZabbixSyncFields"; @@ -121,6 +122,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.ExternalInfisical: return ; + case SecretSync.TravisCI: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TravisCISyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TravisCISyncFields.tsx new file mode 100644 index 00000000000..1034ec23f89 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TravisCISyncFields.tsx @@ -0,0 +1,102 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + TTravisCIBranch, + TTravisCIRepository, + useTravisCIConnectionListBranches, + useTravisCIConnectionListRepositories +} from "@app/hooks/api/appConnections/travis-ci"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const TravisCISyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.TravisCI } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentRepositoryId = useWatch({ name: "destinationConfig.repositoryId", control }); + + const { data: repositories = [], isPending: isRepositoriesPending } = + useTravisCIConnectionListRepositories(connectionId, { + enabled: Boolean(connectionId) + }); + + const { data: branches = [], isPending: isBranchesPending } = useTravisCIConnectionListBranches( + connectionId, + currentRepositoryId, + { + enabled: Boolean(connectionId && currentRepositoryId) + } + ); + + return ( + <> + { + setValue("destinationConfig.repositoryId", ""); + setValue("destinationConfig.repositorySlug", ""); + setValue("destinationConfig.branch", undefined); + }} + /> + ( + + repo.id === value) ?? null} + onChange={(option) => { + const repo = option as SingleValue; + onChange(repo?.id ?? ""); + setValue("destinationConfig.repositorySlug", repo?.slug ?? ""); + setValue("destinationConfig.branch", undefined); + }} + options={repositories} + placeholder="Select a repository..." + getOptionLabel={(option) => option.slug} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + branch.name === value) ?? null} + onChange={(option) => { + const branch = option as SingleValue; + onChange(branch?.name ?? undefined); + }} + options={branches} + placeholder="Select a branch..." + getOptionLabel={(option) => + option.isDefault ? `${option.name} (default)` : option.name + } + getOptionValue={(option) => option.name} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 827aebbb5d9..ca9f4e7bc5c 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -108,6 +108,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.CircleCI: case SecretSync.AzureEntraIdScim: case SecretSync.ExternalInfisical: + case SecretSync.TravisCI: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index ea2d1b50739..db1f38db869 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -50,6 +50,7 @@ import { RenderSyncOptionsReviewFields, RenderSyncReviewFields } from "./RenderS import { SupabaseSyncReviewFields } from "./SupabaseSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; +import { TravisCISyncReviewFields } from "./TravisCISyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields"; import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields"; @@ -198,6 +199,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.ExternalInfisical: DestinationFieldsComponent = ; break; + case SecretSync.TravisCI: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TravisCISyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TravisCISyncReviewFields.tsx new file mode 100644 index 00000000000..a5988f5edcf --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TravisCISyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TravisCISyncReviewFields = () => { + const { watch } = useFormContext(); + + const config = watch("destinationConfig"); + + return ( + <> + {config.repositorySlug} + {config.branch && {config.branch}} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index f9bc319b74c..1e9480d465d 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -34,6 +34,7 @@ import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; import { SupabaseSyncDestinationSchema } from "./supabase-sync-destination-schema"; import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; +import { TravisCISyncDestinationSchema } from "./travis-ci-sync-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; import { WindmillSyncDestinationSchema } from "./windmill-sync-destination-schema"; import { ZabbixSyncDestinationSchema } from "./zabbix-sync-destination-schema"; @@ -75,7 +76,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ ChefSyncDestinationSchema, CircleCISyncDestinationSchema, AzureEntraIdScimSyncDestinationSchema, - ExternalInfisicalSyncDestinationSchema + ExternalInfisicalSyncDestinationSchema, + TravisCISyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/travis-ci-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/travis-ci-sync-destination-schema.ts new file mode 100644 index 00000000000..f7b25ffd279 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/travis-ci-sync-destination-schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TravisCISyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.TravisCI), + destinationConfig: z.object({ + repositoryId: z.string().min(1, "Repository required"), + repositorySlug: z.string().min(1, "Repository required"), + branch: z.string().optional() + }) + }) +); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 3ddd68127a8..e070836258a 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -71,6 +71,7 @@ import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/rend import { SmbConnectionMethod } from "@app/hooks/api/appConnections/types/smb-connection"; import { SshConnectionMethod } from "@app/hooks/api/appConnections/types/ssh-connection"; import { SupabaseConnectionMethod } from "@app/hooks/api/appConnections/types/supabase-connection"; +import { TravisCIConnectionMethod } from "@app/hooks/api/appConnections/types/travis-ci-connection"; import { VenafiConnectionMethod } from "@app/hooks/api/appConnections/types/venafi-connection"; export const APP_CONNECTION_MAP: Record< @@ -162,7 +163,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.ExternalInfisical]: { name: "Infisical", image: "Infisical.png" }, [AppConnection.Doppler]: { name: "Doppler", image: "Doppler.png" }, [AppConnection.NetScaler]: { name: "NetScaler", image: "NetScaler.png" }, - [AppConnection.Anthropic]: { name: "Anthropic", image: "Anthropic.png" } + [AppConnection.Anthropic]: { name: "Anthropic", image: "Anthropic.png" }, + [AppConnection.TravisCI]: { name: "Travis CI", image: "Travis CI.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -204,6 +206,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case LaravelForgeConnectionMethod.ApiToken: case DbtConnectionMethod.ApiToken: case CircleCIConnectionMethod.ApiToken: + case TravisCIConnectionMethod.ApiToken: case DopplerConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case VenafiConnectionMethod.ApiKey: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 4357a8a3ecd..587b554e8ec 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -141,6 +141,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.OctopusDeploy]: AppConnection.OctopusDeploy, [SecretSync.CircleCI]: AppConnection.CircleCI, [SecretSync.AzureEntraIdScim]: AppConnection.AzureEntraId, - [SecretSync.ExternalInfisical]: AppConnection.ExternalInfisical + [SecretSync.ExternalInfisical]: AppConnection.ExternalInfisical, + [SecretSync.TravisCI]: AppConnection.TravisCI }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 77d7a365acc..36a6d212592 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -55,5 +55,6 @@ export enum AppConnection { ExternalInfisical = "external-infisical", Doppler = "doppler", NetScaler = "netscaler", - Anthropic = "anthropic" + Anthropic = "anthropic", + TravisCI = "travis-ci" } diff --git a/frontend/src/hooks/api/appConnections/travis-ci/index.ts b/frontend/src/hooks/api/appConnections/travis-ci/index.ts new file mode 100644 index 00000000000..2c1906d3698 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/travis-ci/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/travis-ci/queries.tsx b/frontend/src/hooks/api/appConnections/travis-ci/queries.tsx new file mode 100644 index 00000000000..dc71889d119 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/travis-ci/queries.tsx @@ -0,0 +1,68 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TTravisCIBranch, TTravisCIRepository } from "./types"; + +const travisCIConnectionKeys = { + all: [...appConnectionKeys.all, "travis-ci"] as const, + listRepositories: (connectionId: string) => + [...travisCIConnectionKeys.all, "repositories", connectionId] as const, + listBranches: (connectionId: string, repositoryId: string) => + [...travisCIConnectionKeys.all, "branches", connectionId, repositoryId] as const +}; + +export const useTravisCIConnectionListRepositories = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TTravisCIRepository[], + unknown, + TTravisCIRepository[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: travisCIConnectionKeys.listRepositories(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/travis-ci/${connectionId}/repositories` + ); + + return data; + }, + ...options + }); +}; + +export const useTravisCIConnectionListBranches = ( + connectionId: string, + repositoryId: string, + options?: Omit< + UseQueryOptions< + TTravisCIBranch[], + unknown, + TTravisCIBranch[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: travisCIConnectionKeys.listBranches(connectionId, repositoryId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/travis-ci/${connectionId}/branches`, + { + params: { repositoryId } + } + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/travis-ci/types.ts b/frontend/src/hooks/api/appConnections/travis-ci/types.ts new file mode 100644 index 00000000000..1519efe0f07 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/travis-ci/types.ts @@ -0,0 +1,10 @@ +export type TTravisCIRepository = { + id: string; + name: string; + slug: string; +}; + +export type TTravisCIBranch = { + name: string; + isDefault: boolean; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 2a0daae1126..9ea5b889883 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -243,6 +243,10 @@ export type TAnthropicConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Anthropic; }; +export type TTravisCIConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.TravisCI; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -300,7 +304,8 @@ export type TAppConnectionOption = | TExternalInfisicalConnectionOption | TDopplerConnectionOption | TNetScalerConnectionOption - | TAnthropicConnectionOption; + | TAnthropicConnectionOption + | TTravisCIConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -360,4 +365,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Doppler]: TDopplerConnectionOption; [AppConnection.NetScaler]: TNetScalerConnectionOption; [AppConnection.Anthropic]: TAnthropicConnectionOption; + [AppConnection.TravisCI]: TTravisCIConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2594dec8986..6431db85863 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -53,6 +53,7 @@ import { TSshConnection } from "./ssh-connection"; import { TSupabaseConnection } from "./supabase-connection"; import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; +import { TTravisCIConnection } from "./travis-ci-connection"; import { TVenafiConnection } from "./venafi-connection"; import { TVercelConnection } from "./vercel-connection"; import { TWindmillConnection } from "./windmill-connection"; @@ -109,6 +110,7 @@ export * from "./ssh-connection"; export * from "./supabase-connection"; export * from "./teamcity-connection"; export * from "./terraform-cloud-connection"; +export * from "./travis-ci-connection"; export * from "./venafi-connection"; export * from "./vercel-connection"; export * from "./windmill-connection"; @@ -170,8 +172,9 @@ export type TAppConnection = | TAzureEntraIdConnection | TVenafiConnection | TExternalInfisicalConnection + | TNetScalerConnection | TDopplerConnection - | TNetScalerConnection; + | TTravisCIConnection; export type TAvailableAppConnection = Pick; diff --git a/frontend/src/hooks/api/appConnections/types/travis-ci-connection.ts b/frontend/src/hooks/api/appConnections/types/travis-ci-connection.ts new file mode 100644 index 00000000000..0c5085ae0a3 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/travis-ci-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum TravisCIConnectionMethod { + ApiToken = "api-token" +} + +export type TTravisCIConnection = TRootAppConnection & { app: AppConnection.TravisCI } & { + method: TravisCIConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 2200b02e26a..20d8cc72a13 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -35,7 +35,8 @@ export enum SecretSync { OctopusDeploy = "octopus-deploy", CircleCI = "circleci", AzureEntraIdScim = "azure-entra-id-scim", - ExternalInfisical = "external-infisical" + ExternalInfisical = "external-infisical", + TravisCI = "travis-ci" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 21dc0d1c8c9..028d8267def 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -35,6 +35,7 @@ import { TRenderSync } from "./render-sync"; import { TSupabaseSync } from "./supabase"; import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; +import { TTravisCISync } from "./travis-ci-sync"; import { TVercelSync } from "./vercel-sync"; import { TWindmillSync } from "./windmill-sync"; import { TZabbixSync } from "./zabbix-sync"; @@ -86,7 +87,8 @@ export type TSecretSync = | TOctopusDeploySync | TCircleCISync | TAzureEntraIdScimSync - | TExternalInfisicalSync; + | TExternalInfisicalSync + | TTravisCISync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/travis-ci-sync.ts b/frontend/src/hooks/api/secretSyncs/types/travis-ci-sync.ts new file mode 100644 index 00000000000..58c5f595649 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/travis-ci-sync.ts @@ -0,0 +1,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 type TTravisCISync = TRootSecretSync & { + destination: SecretSync.TravisCI; + destinationConfig: { + repositoryId: string; + repositorySlug: string; + branch?: string; + }; + connection: { + app: AppConnection.TravisCI; + name: string; + id: string; + }; +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 408a252ebf1..7c0baccd603 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -67,6 +67,7 @@ import { SshConnectionForm } from "./SshConnectionForm"; import { SupabaseConnectionForm } from "./SupabaseConnectionForm"; import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; +import { TravisCIConnectionForm } from "./TravisCIConnectionForm"; import { VenafiConnectionForm } from "./VenafiConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; import { WindmillConnectionForm } from "./WindmillConnectionForm"; @@ -279,6 +280,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.NetScaler: return ; + case AppConnection.TravisCI: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -492,6 +495,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.NetScaler: return ; + case AppConnection.TravisCI: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TravisCIConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TravisCIConnectionForm.tsx new file mode 100644 index 00000000000..d73e3e55c16 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TravisCIConnectionForm.tsx @@ -0,0 +1,135 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + TravisCIConnectionMethod, + TTravisCIConnection +} from "@app/hooks/api/appConnections/types/travis-ci-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TTravisCIConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.TravisCI) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(TravisCIConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const TravisCIConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.TravisCI, + method: TravisCIConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 571353c10f2..736c36b3eca 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -34,6 +34,7 @@ import { RenderSyncDestinationCol } from "./RenderSyncDestinationCol"; import { SupabaseSyncDestinationCol } from "./SupabaseSyncDestinationCol"; import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; +import { TravisCISyncDestinationCol } from "./TravisCISyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol"; import { ZabbixSyncDestinationCol } from "./ZabbixSyncDestinationCol"; @@ -118,6 +119,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.ExternalInfisical: return ; + case SecretSync.TravisCI: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TravisCISyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TravisCISyncDestinationCol.tsx new file mode 100644 index 00000000000..0fc776df74a --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TravisCISyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TTravisCISync } from "@app/hooks/api/secretSyncs/types/travis-ci-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TTravisCISync; +}; + +export const TravisCISyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; 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 974fcc1051d..08e9de5a643 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 @@ -233,6 +233,12 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.projectId; secondaryText = `${destinationConfig.environment} - ${destinationConfig.secretPath}`; break; + case SecretSync.TravisCI: + primaryText = destinationConfig.repositorySlug; + secondaryText = destinationConfig.branch + ? `Branch - ${destinationConfig.branch}` + : "Repository"; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index fa4a2a729be..b365b3dbbe5 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -44,6 +44,7 @@ import { RenderSyncDestinationSection } from "./RenderSyncDestinationSection"; import { SupabaseSyncDestinationSection } from "./SupabaseSyncDestinationSection"; import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection"; +import { TravisCISyncDestinationSection } from "./TravisCISyncDestinationSection"; import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; import { WindmillSyncDestinationSection } from "./WindmillSyncDestinationSection"; import { ZabbixSyncDestinationSection } from "./ZabbixSyncDestinationSection"; @@ -175,6 +176,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.ExternalInfisical: DestinationComponents = ; break; + case SecretSync.TravisCI: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TravisCISyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TravisCISyncDestinationSection.tsx new file mode 100644 index 00000000000..b3ab5e320af --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TravisCISyncDestinationSection.tsx @@ -0,0 +1,19 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TTravisCISync } from "@app/hooks/api/secretSyncs/types/travis-ci-sync"; + +type Props = { + secretSync: TTravisCISync; +}; + +export const TravisCISyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + return ( + <> + {destinationConfig.repositorySlug} + {destinationConfig.branch && ( + {destinationConfig.branch} + )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 77e06a0be81..fb9565133e1 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -80,6 +80,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.CircleCI: case SecretSync.AzureEntraIdScim: case SecretSync.ExternalInfisical: + case SecretSync.TravisCI: AdditionalSyncOptionsComponent = null; break; default: