diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 0e5a9bf33..4598ab213 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -35,6 +35,7 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_SUPABASE, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_TEAMCITY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TERRAFORM_CLOUD_API_URL, INTEGRATION_TRAVISCI, @@ -151,6 +152,12 @@ const getApps = async ({ accessToken, }); break; + case INTEGRATION_TEAMCITY: + apps = await getAppsTeamCity({ + integrationAuth, + accessToken, + }); + break; case INTEGRATION_SUPABASE: apps = await getAppsSupabase({ accessToken, @@ -722,6 +729,39 @@ const getAppsGitlab = async ({ return apps; }; +/** + * Return list of projects for TeamCity integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for TeamCity API + * @returns {Object[]} apps - names and ids of TeamCity projects + * @returns {String} apps.name - name of TeamCity projects + */ +const getAppsTeamCity = async ({ + integrationAuth, + accessToken, +}: { + integrationAuth: IIntegrationAuth; + accessToken: string; +}) => { + const res = ( + await standardRequest.get(`${integrationAuth.url}/app/rest/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + ).data.project.slice(1); + + const apps = res.map((a: any) => { + return { + name: a.name, + appId: a.id, + }; + }); + + return apps; +}; + /** * Return list of projects for Supabase integration * @param {Object} obj diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 28103b57f..956e8e1bf 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -44,6 +44,7 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_SUPABASE, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_TEAMCITY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TERRAFORM_CLOUD_API_URL, INTEGRATION_TRAVISCI, @@ -233,6 +234,14 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_TEAMCITY: + await syncSecretsTeamCity({ + integrationAuth, + integration, + secrets, + accessToken, + }); + break; case INTEGRATION_BITBUCKET: await syncSecretsBitBucket({ integration, @@ -1971,6 +1980,89 @@ const syncSecretsTerraformCloud = async ({ } }; +/** + * Sync/push [secrets] to TeamCity project + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration + * @param {String} obj.accessToken - access token for TeamCity integration + */ +const syncSecretsTeamCity = async ({ + integrationAuth, + integration, + secrets, + accessToken, +}: { + integrationAuth: IIntegrationAuth; + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + interface TeamCitySecret { + name: string; + value: string; + } + + // get secrets from Teamcity + const res = ( + await standardRequest.get( + `${integrationAuth.url}/app/rest/projects/id:${integration.appId}/parameters`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + ) + .data + .property + .reduce( + (obj: any, secret: TeamCitySecret) => { + const secretName = secret.name.replace(/^env\./, ""); + return ({ + ...obj, + [secretName]: secret.value + }) + }, + {} + ); + + for await (const key of Object.keys(secrets)) { + if (!(key in res) || (key in res && secrets[key] !== res[key])) { + // case: secret does not exist in TeamCity or secret value has changed + // -> create/update secret + await standardRequest.post( + `${integrationAuth.url}/app/rest/projects/id:${integration.appId}/parameters`, + { + name: `env.${key}`, + value: secrets[key] + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } + } + + for await (const key of Object.keys(res)) { + if (!(key in secrets)) { + // delete secret + await standardRequest.delete( + `${integrationAuth.url}/app/rest/projects/id:${integration.appId}/parameters/env.${key}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } + } +}; + /** * Sync/push [secrets] to HashiCorp Vault path * @param {Object} obj diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index e6673e77e..579a7dbf2 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -20,6 +20,7 @@ import { INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, + INTEGRATION_TEAMCITY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL, @@ -61,6 +62,7 @@ export interface IIntegration { | "supabase" | "checkly" | "terraform-cloud" + | "teamcity" | "hashicorp-vault" | "cloudflare-pages" | "bitbucket" @@ -157,6 +159,7 @@ const integrationSchema = new Schema( INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_TEAMCITY, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CODEFRESH, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 6791868cd..66a26898f 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -22,6 +22,7 @@ import { INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, + INTEGRATION_TEAMCITY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL, @@ -55,6 +56,7 @@ export interface IIntegrationAuth extends Document { | "bitbucket" | "cloud-66" | "terraform-cloud" + | "teamcity" | "northflank" | "windmill"; teamId: string; @@ -99,6 +101,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_CIRCLECI, INTEGRATION_LARAVELFORGE, INTEGRATION_TRAVISCI, + INTEGRATION_TEAMCITY, INTEGRATION_SUPABASE, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index a649763c0..b08d2a990 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -23,6 +23,7 @@ export const INTEGRATION_FLYIO = "flyio"; export const INTEGRATION_LARAVELFORGE = "laravel-forge" export const INTEGRATION_CIRCLECI = "circleci"; export const INTEGRATION_TRAVISCI = "travisci"; +export const INTEGRATION_TEAMCITY = "teamcity"; export const INTEGRATION_SUPABASE = "supabase"; export const INTEGRATION_CHECKLY = "checkly"; export const INTEGRATION_TERRAFORM_CLOUD = "terraform-cloud"; @@ -46,6 +47,7 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_CIRCLECI, INTEGRATION_LARAVELFORGE, INTEGRATION_TRAVISCI, + INTEGRATION_TEAMCITY, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, INTEGRATION_TERRAFORM_CLOUD, @@ -233,6 +235,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "", }, + { + name: "TeamCity", + slug: "teamcity", + image: "TeamCity.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, { name: "Supabase", slug: "supabase", diff --git a/docs/images/integrations-teamcity-auth.png b/docs/images/integrations-teamcity-auth.png new file mode 100644 index 000000000..f0c95992f Binary files /dev/null and b/docs/images/integrations-teamcity-auth.png differ diff --git a/docs/images/integrations-teamcity-create.png b/docs/images/integrations-teamcity-create.png new file mode 100644 index 000000000..c5515581e Binary files /dev/null and b/docs/images/integrations-teamcity-create.png differ diff --git a/docs/images/integrations-teamcity-dashboard.png b/docs/images/integrations-teamcity-dashboard.png new file mode 100644 index 000000000..40c8c5409 Binary files /dev/null and b/docs/images/integrations-teamcity-dashboard.png differ diff --git a/docs/images/integrations-teamcity-projects.png b/docs/images/integrations-teamcity-projects.png new file mode 100644 index 000000000..579c195a6 Binary files /dev/null and b/docs/images/integrations-teamcity-projects.png differ diff --git a/docs/images/integrations-teamcity-serverurl.png b/docs/images/integrations-teamcity-serverurl.png new file mode 100644 index 000000000..599e3205b Binary files /dev/null and b/docs/images/integrations-teamcity-serverurl.png differ diff --git a/docs/images/integrations-teamcity-tokens.png b/docs/images/integrations-teamcity-tokens.png new file mode 100644 index 000000000..720ed90c6 Binary files /dev/null and b/docs/images/integrations-teamcity-tokens.png differ diff --git a/docs/images/integrations-teamcity.png b/docs/images/integrations-teamcity.png new file mode 100644 index 000000000..4fe92ac32 Binary files /dev/null and b/docs/images/integrations-teamcity.png differ diff --git a/docs/images/integrations-terraformcloud-create.png b/docs/images/integrations-terraformcloud-create.png index 7c0ee12ee..0861ee36f 100644 Binary files a/docs/images/integrations-terraformcloud-create.png and b/docs/images/integrations-terraformcloud-create.png differ diff --git a/docs/integrations/cloud/teamcity.mdx b/docs/integrations/cloud/teamcity.mdx new file mode 100644 index 000000000..90661a790 --- /dev/null +++ b/docs/integrations/cloud/teamcity.mdx @@ -0,0 +1,42 @@ +--- +title: "TeamCity" +description: "How to sync secrets from Infisical to TeamCity" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Enter your TeamCity API Token and Server URL + +Obtain a TeamCity API Token in Profile > Access Tokens + +![integrations teamcity dashboard](../../images/integrations-teamcity-dashboard.png) +![integrations teamcity tokens](../../images/integrations-teamcity-tokens.png) + +Obtain your TeamCity Server URL in Administration > Cloud Server Settings > Server URL + +![integrations teamcity projects](../../images/integrations-teamcity-projects.png) +![integrations teamcity server url](../../images/integrations-teamcity-serverurl.png) + +Press on the TeamCity tile and input your TeamCity API Token and Server URL to grant Infisical access to your TeamCity account. + +![integrations teamcity authorization](../../images/integrations-teamcity-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets, you want to sync to which TeamCity project and press create integration to start syncing secrets to TeamCity. + +![integrations teamcity](../../images/integrations-teamcity-create.png) +![integrations teamcity](../../images/integrations-teamcity.png) diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 067f7e039..d97280361 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -21,6 +21,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Laravel Forge](/integrations/cloud/laravel-forge) | Cloud | Available | | [Railway](/integrations/cloud/railway) | Cloud | Available | | [Terraform Cloud](/integrations/cloud/terraform-cloud) | Cloud | Available | +| [TeamCity](/integrations/cloud/teamcity) | Cloud | Available | | [Fly.io](/integrations/cloud/flyio) | Cloud | Available | | [Supabase](/integrations/cloud/supabase) | Cloud | Available | | [Northflank](/integrations/cloud/northflank) | Cloud | Available | diff --git a/docs/mint.json b/docs/mint.json index c229faed4..57ecbbc18 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -232,6 +232,7 @@ "integrations/cloud/supabase", "integrations/cloud/northflank", "integrations/cloud/terraform-cloud", + "integrations/cloud/teamcity", "integrations/cloud/cloudflare-pages", "integrations/cloud/checkly", "integrations/cloud/hashicorp-vault", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 3352999f6..bcfaf1943 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -19,7 +19,8 @@ const integrationSlugNameMapping: Mapping = { travisci: "TravisCI", supabase: "Supabase", checkly: "Checkly", - 'terraform-cloud': 'Terraform Cloud', + "terraform-cloud": "Terraform Cloud", + "teamcity": "TeamCity", "hashicorp-vault": "Vault", "cloudflare-pages": "Cloudflare Pages", "codefresh": "Codefresh", @@ -27,7 +28,7 @@ const integrationSlugNameMapping: Mapping = { bitbucket: "BitBucket", "cloud-66": "Cloud 66", northflank: "Northflank", - 'windmill': 'Windmill' + "windmill": "Windmill" } const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/TeamCity.png b/frontend/public/images/integrations/TeamCity.png new file mode 100644 index 000000000..c0042cda7 Binary files /dev/null and b/frontend/public/images/integrations/TeamCity.png differ diff --git a/frontend/src/pages/integrations/teamcity/authorize.tsx b/frontend/src/pages/integrations/teamcity/authorize.tsx new file mode 100644 index 000000000..b417e3c19 --- /dev/null +++ b/frontend/src/pages/integrations/teamcity/authorize.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; + +import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; +import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken"; + +export default function TeamCityCreateIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [serverUrl, setServerUrl] = useState(""); + const [serverUrlErrorText, setServerUrlErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + setServerUrlErrorText(""); + + if (apiKey.length === 0) { + setApiKeyErrorText("Access Token cannot be blank"); + return; + } + + if (serverUrl.length === 0) { + setServerUrlErrorText("Server URL cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "teamcity", + accessId: null, + accessToken: apiKey, + url: serverUrl, + namespace: null + }); + + setIsLoading(false); + + router.push(`/integrations/teamcity/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + TeamCity Integration + + setApiKey(e.target.value)} + /> + + + setServerUrl(e.target.value)} + /> + + + +
+ ); +} + +TeamCityCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/teamcity/create.tsx b/frontend/src/pages/integrations/teamcity/create.tsx new file mode 100644 index 000000000..a2aa86db4 --- /dev/null +++ b/frontend/src/pages/integrations/teamcity/create.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function TeamCityCreateIntegrationPage() { + const router = useRouter(); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "" + }); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [targetApp, setTargetApp] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: + integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp) + ?.appId ?? null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, + owner: null, + path: null, + region: null, + secretPath + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + TeamCity Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + +
+ ) : ( +
+ ); +} + +TeamCityCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 1f845b644..e267c13f5 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -113,6 +113,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "windmill": link = `${window.location.origin}/integrations/windmill/authorize`; break; + case "teamcity": + link = `${window.location.origin}/integrations/teamcity/authorize`; + break; default: break; }