Merge pull request #2883 from Infisical/feat/sync-circle-ci-context

feat: circle ci context integration
This commit is contained in:
Sheen
2024-12-17 02:12:32 +08:00
committed by GitHub
19 changed files with 671 additions and 337 deletions
@@ -1185,4 +1185,50 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
return { spaces }; return { spaces };
} }
}); });
server.route({
method: "GET",
url: "/:integrationAuthId/circleci/organizations",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
params: z.object({
integrationAuthId: z.string().trim()
}),
response: {
200: z.object({
organizations: z
.object({
name: z.string(),
slug: z.string(),
projects: z
.object({
name: z.string(),
id: z.string()
})
.array(),
contexts: z
.object({
name: z.string(),
id: z.string()
})
.array()
})
.array()
})
}
},
handler: async (req) => {
const organizations = await server.services.integrationAuth.getCircleCIOrganizations({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.integrationAuthId
});
return { organizations };
}
});
}; };
@@ -0,0 +1,5 @@
export type TCircleCIContext = {
id: string;
name: string;
created_at: string;
};
@@ -17,6 +17,8 @@ import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { TGenericPermission, TProjectPermission } from "@app/lib/types"; import { TGenericPermission, TProjectPermission } from "@app/lib/types";
import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationDALFactory } from "../integration/integration-dal";
@@ -24,6 +26,7 @@ import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types"; import { KmsDataKey } from "../kms/kms-types";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { getApps } from "./integration-app-list"; import { getApps } from "./integration-app-list";
import { TCircleCIContext } from "./integration-app-types";
import { TIntegrationAuthDALFactory } from "./integration-auth-dal"; import { TIntegrationAuthDALFactory } from "./integration-auth-dal";
import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema"; import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema";
import { import {
@@ -31,6 +34,7 @@ import {
TBitbucketEnvironment, TBitbucketEnvironment,
TBitbucketWorkspace, TBitbucketWorkspace,
TChecklyGroups, TChecklyGroups,
TCircleCIOrganization,
TDeleteIntegrationAuthByIdDTO, TDeleteIntegrationAuthByIdDTO,
TDeleteIntegrationAuthsDTO, TDeleteIntegrationAuthsDTO,
TDuplicateGithubIntegrationAuthDTO, TDuplicateGithubIntegrationAuthDTO,
@@ -42,6 +46,7 @@ import {
TIntegrationAuthBitbucketEnvironmentsDTO, TIntegrationAuthBitbucketEnvironmentsDTO,
TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthBitbucketWorkspaceDTO,
TIntegrationAuthChecklyGroupsDTO, TIntegrationAuthChecklyGroupsDTO,
TIntegrationAuthCircleCIOrganizationDTO,
TIntegrationAuthGithubEnvsDTO, TIntegrationAuthGithubEnvsDTO,
TIntegrationAuthGithubOrgsDTO, TIntegrationAuthGithubOrgsDTO,
TIntegrationAuthHerokuPipelinesDTO, TIntegrationAuthHerokuPipelinesDTO,
@@ -1578,6 +1583,120 @@ export const integrationAuthServiceFactory = ({
return []; return [];
}; };
const getCircleCIOrganizations = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
id
}: TIntegrationAuthCircleCIOrganizationDTO) => {
const integrationAuth = await integrationAuthDAL.findById(id);
if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
integrationAuth.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId);
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
const { data: organizations }: { data: TCircleCIOrganization[] } = await request.get(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`,
{
headers: {
"Circle-Token": `${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
let projects: {
orgName: string;
projectName: string;
projectId?: string;
}[] = [];
try {
const projectRes = (
await request.get<{ reponame: string; username: string; vcs_url: string }[]>(
`${IntegrationUrls.CIRCLECI_API_URL}/v1.1/projects`,
{
headers: {
"Circle-Token": accessToken,
"Accept-Encoding": "application/json"
}
}
)
).data;
projects = projectRes.map((a) => ({
orgName: a.username, // username maps to unique organization name in CircleCI
projectName: a.reponame, // reponame maps to project name within an organization in CircleCI
projectId: a.vcs_url.split("/").pop() // vcs_url maps to the project id in CircleCI
}));
} catch (error) {
logger.error(error);
}
const projectsByOrg = groupBy(
projects.map((p) => ({
orgName: p.orgName,
name: p.projectName,
id: p.projectId as string
})),
(p) => p.orgName
);
const getOrgContexts = async (orgSlug: string) => {
type NextPageToken = string | null | undefined;
try {
const contexts: TCircleCIContext[] = [];
let nextPageToken: NextPageToken;
while (nextPageToken !== null) {
// eslint-disable-next-line no-await-in-loop
const { data } = await request.get<{
items: TCircleCIContext[];
next_page_token: NextPageToken;
}>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context`, {
headers: {
"Circle-Token": accessToken,
"Accept-Encoding": "application/json"
},
params: new URLSearchParams({
"owner-slug": orgSlug,
...(nextPageToken ? { "page-token": nextPageToken } : {})
})
});
contexts.push(...data.items);
nextPageToken = data.next_page_token;
}
return contexts?.map((context) => ({
name: context.name,
id: context.id
}));
} catch (error) {
logger.error(error);
}
};
return Promise.all(
organizations.map(async (org) => ({
name: org.name,
slug: org.slug,
projects: projectsByOrg[org.name] ?? [],
contexts: (await getOrgContexts(org.slug)) ?? []
}))
);
};
const deleteIntegrationAuths = async ({ const deleteIntegrationAuths = async ({
projectId, projectId,
integration, integration,
@@ -1790,6 +1909,7 @@ export const integrationAuthServiceFactory = ({
getTeamcityBuildConfigs, getTeamcityBuildConfigs,
getBitbucketWorkspaces, getBitbucketWorkspaces,
getBitbucketEnvironments, getBitbucketEnvironments,
getCircleCIOrganizations,
getIntegrationAccessToken, getIntegrationAccessToken,
duplicateIntegrationAuth, duplicateIntegrationAuth,
getOctopusDeploySpaces, getOctopusDeploySpaces,
@@ -128,6 +128,10 @@ export type TGetIntegrationAuthTeamCityBuildConfigDTO = {
appId: string; appId: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TIntegrationAuthCircleCIOrganizationDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TVercelBranches = { export type TVercelBranches = {
ref: string; ref: string;
lastCommit: string; lastCommit: string;
@@ -189,6 +193,14 @@ export type TTeamCityBuildConfig = {
webUrl: string; webUrl: string;
}; };
export type TCircleCIOrganization = {
id: string;
vcsType: string;
name: string;
avatarUrl: string;
slug: string;
};
export type TIntegrationsWithEnvironment = TIntegrations & { export type TIntegrationsWithEnvironment = TIntegrations & {
environment?: environment?:
| { | {
@@ -215,6 +227,11 @@ export enum OctopusDeployScope {
// add tenant, variable set, etc. // add tenant, variable set, etc.
} }
export enum CircleCiScope {
Project = "project",
Context = "context"
}
export type TOctopusDeployVariableSet = { export type TOctopusDeployVariableSet = {
Id: string; Id: string;
OwnerId: string; OwnerId: string;
@@ -76,7 +76,6 @@ export enum IntegrationUrls {
RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2", RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2",
FLYIO_API_URL = "https://api.fly.io/graphql", FLYIO_API_URL = "https://api.fly.io/graphql",
CIRCLECI_API_URL = "https://circleci.com/api", CIRCLECI_API_URL = "https://circleci.com/api",
DATABRICKS_API_URL = "https:/xxxx.com/api",
TRAVISCI_API_URL = "https://api.travis-ci.com", TRAVISCI_API_URL = "https://api.travis-ci.com",
SUPABASE_API_URL = "https://api.supabase.com", SUPABASE_API_URL = "https://api.supabase.com",
LARAVELFORGE_API_URL = "https://forge.laravel.com", LARAVELFORGE_API_URL = "https://forge.laravel.com",
@@ -218,9 +217,9 @@ export const getIntegrationOptions = async () => {
docsLink: "" docsLink: ""
}, },
{ {
name: "Circle CI", name: "CircleCI",
slug: "circleci", slug: "circleci",
image: "Circle CI.png", image: "CircleCI.png",
isAvailable: true, isAvailable: true,
type: "pat", type: "pat",
clientId: "", clientId: "",
@@ -39,7 +39,12 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/
import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationDALFactory } from "../integration/integration-dal";
import { IntegrationMetadataSchema } from "../integration/integration-schema"; import { IntegrationMetadataSchema } from "../integration/integration-schema";
import { IntegrationAuthMetadataSchema } from "./integration-auth-schema"; import { IntegrationAuthMetadataSchema } from "./integration-auth-schema";
import { OctopusDeployScope, TIntegrationsWithEnvironment, TOctopusDeployVariableSet } from "./integration-auth-types"; import {
CircleCiScope,
OctopusDeployScope,
TIntegrationsWithEnvironment,
TOctopusDeployVariableSet
} from "./integration-auth-types";
import { import {
IntegrationInitialSyncBehavior, IntegrationInitialSyncBehavior,
IntegrationMappingBehavior, IntegrationMappingBehavior,
@@ -2245,6 +2250,77 @@ const syncSecretsCircleCI = async ({
secrets: Record<string, { value: string; comment?: string }>; secrets: Record<string, { value: string; comment?: string }>;
accessToken: string; accessToken: string;
}) => { }) => {
if (integration.scope === CircleCiScope.Context) {
// sync secrets to CircleCI
await Promise.all(
Object.keys(secrets).map(async (key) =>
request.put(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${key}`,
{
value: secrets[key].value
},
{
headers: {
"Circle-Token": accessToken,
"Content-Type": "application/json"
}
}
)
)
);
// get secrets from CircleCI
const getSecretsRes = async () => {
type EnvVars = {
variable: string;
created_at: string;
updated_at: string;
context_id: string;
};
let nextPageToken: string | null | undefined;
const envVars: EnvVars[] = [];
while (nextPageToken !== null) {
const res = await request.get<{
items: EnvVars[];
next_page_token: string | null;
}>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable`, {
headers: {
"Circle-Token": accessToken,
"Accept-Encoding": "application/json"
},
params: nextPageToken
? new URLSearchParams({
"page-token": nextPageToken
})
: undefined
});
envVars.push(...res.data.items);
nextPageToken = res.data.next_page_token;
}
return envVars;
};
// delete secrets from CircleCI
await Promise.all(
(await getSecretsRes()).map(async (sec) => {
if (!(sec.variable in secrets)) {
return request.delete(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${sec.variable}`,
{
headers: {
"Circle-Token": accessToken,
"Content-Type": "application/json"
}
}
);
}
})
);
} else {
const getProjectSlug = async () => { const getProjectSlug = async () => {
const requestConfig = { const requestConfig = {
headers: { headers: {
@@ -2341,6 +2417,7 @@ const syncSecretsCircleCI = async ({
} }
}) })
); );
}
}; };
/** /**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 555 KiB

+15 -6
View File
@@ -11,21 +11,30 @@ Prerequisites:
<Step title="Authorize Infisical for CircleCI"> <Step title="Authorize Infisical for CircleCI">
Obtain an API token in User Settings > Personal API Tokens Obtain an API token in User Settings > Personal API Tokens
![integrations circleci token](../../images/integrations/circleci/integrations-circleci-token.png) ![integrations circleci token](/images/integrations/circleci/integrations-circleci-token.png)
Navigate to your project's integrations tab in Infisical. Navigate to your project's integrations tab in Infisical.
![integrations](../../images/integrations.png) ![integrations](/images/integrations.png)
Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account. Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account.
![integrations circleci authorization](../../images/integrations/circleci/integrations-circleci-auth.png) ![integrations circleci authorization](/images/integrations/circleci/integrations-circleci-auth.png)
</Step> </Step>
<Step title="Start integration"> <Step title="Start integration">
Select which Infisical environment secrets you want to sync to which CircleCI project and press create integration to start syncing secrets to CircleCI. Select which Infisical environment secrets you want to sync to which CircleCI project or context.
<Tabs>
<Tab title="Project">
![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-project.png)
</Tab>
<Tab title="Context">
![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-context.png)
</Tab>
</Tabs>
Finally, press create integration to start syncing secrets to CircleCI.
![integrations circleci](/images/integrations/circleci/integrations-circleci.png)
![create integration circleci](../../images/integrations/circleci/integrations-circleci-create.png)
![integrations circleci](../../images/integrations/circleci/integrations-circleci.png)
</Step> </Step>
</Steps> </Steps>

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -7,6 +7,7 @@ export {
useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthBitBucketWorkspaces,
useGetIntegrationAuthById, useGetIntegrationAuthById,
useGetIntegrationAuthChecklyGroups, useGetIntegrationAuthChecklyGroups,
useGetIntegrationAuthCircleCIOrganizations,
useGetIntegrationAuthGithubEnvs, useGetIntegrationAuthGithubEnvs,
useGetIntegrationAuthGithubOrgs, useGetIntegrationAuthGithubOrgs,
useGetIntegrationAuthNorthflankSecretGroups, useGetIntegrationAuthNorthflankSecretGroups,
@@ -8,6 +8,7 @@ import {
BitBucketEnvironment, BitBucketEnvironment,
BitBucketWorkspace, BitBucketWorkspace,
ChecklyGroup, ChecklyGroup,
CircleCIOrganization,
Environment, Environment,
HerokuPipelineCoupling, HerokuPipelineCoupling,
IntegrationAuth, IntegrationAuth,
@@ -128,7 +129,9 @@ const integrationAuthKeys = {
integrationAuthId, integrationAuthId,
...params ...params
}: TGetIntegrationAuthOctopusDeployScopeValuesDTO) => }: TGetIntegrationAuthOctopusDeployScopeValuesDTO) =>
[{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const,
getIntegrationAuthCircleCIOrganizations: (integrationAuthId: string) =>
[{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const
}; };
const fetchIntegrationAuthById = async (integrationAuthId: string) => { const fetchIntegrationAuthById = async (integrationAuthId: string) => {
@@ -510,6 +513,15 @@ const fetchIntegrationAuthOctopusDeployScopeValues = async ({
return data; return data;
}; };
const fetchIntegrationAuthCircleCIOrganizations = async (integrationAuthId: string) => {
const {
data: { organizations }
} = await apiRequest.get<{
organizations: CircleCIOrganization[];
}>(`/api/v1/integration-auth/${integrationAuthId}/circleci/organizations`);
return organizations;
};
export const useGetIntegrationAuthById = (integrationAuthId: string) => { export const useGetIntegrationAuthById = (integrationAuthId: string) => {
return useQuery({ return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId),
@@ -884,6 +896,13 @@ export const useGetIntegrationAuthTeamCityBuildConfigs = ({
}); });
}; };
export const useGetIntegrationAuthCircleCIOrganizations = (integrationAuthId: string) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthCircleCIOrganizations(integrationAuthId),
queryFn: () => fetchIntegrationAuthCircleCIOrganizations(integrationAuthId)
});
};
export const useAuthorizeIntegration = () => { export const useAuthorizeIntegration = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -105,6 +105,19 @@ export enum OctopusDeployScope {
// tenant, variable set // tenant, variable set
} }
export type CircleCIOrganization = {
name: string;
slug: string;
projects: {
name: string;
id: string;
}[];
contexts: {
name: string;
id: string;
}[];
};
export type TGetIntegrationAuthOctopusDeployScopeValuesDTO = { export type TGetIntegrationAuthOctopusDeployScopeValuesDTO = {
integrationAuthId: string; integrationAuthId: string;
spaceId: string; spaceId: string;
@@ -125,3 +138,8 @@ export type TOctopusDeployVariableSetScopeValues = {
Name: string; Name: string;
}[]; }[];
}; };
export enum CircleCiScope {
Context = "context",
Project = "project"
}
@@ -56,7 +56,7 @@ export default function CircleCICreateIntegrationPage() {
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<div className="inline flex items-center pb-0.5"> <div className="inline flex items-center pb-0.5">
<Image <Image
src="/images/integrations/Circle CI.png" src="/images/integrations/CircleCI.png"
height={30} height={30}
width={30} width={30}
alt="CircleCI logo" alt="CircleCI logo"
@@ -1,155 +1,150 @@
import { useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form";
import Head from "next/head";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
faArrowUpRightFromSquare,
faBookOpen,
faBugs,
faCircleInfo
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import queryString from "query-string"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { useCreateIntegration } from "@app/hooks/api";
import { import {
Button, Button,
Card, Card,
CardTitle, CardTitle,
FilterableSelect,
FormControl, FormControl,
Input,
Select, Select,
SelectItem SelectItem,
} from "../../../components/v2"; Spinner
import { } from "@app/components/v2";
useGetIntegrationAuthApps, import { SecretPathInput } from "@app/components/v2/SecretPathInput";
useGetIntegrationAuthById import { useWorkspace } from "@app/context";
} from "../../../hooks/api/integrationAuth"; import { useCreateIntegration } from "@app/hooks/api";
import { useGetWorkspaceById } from "../../../hooks/api/workspace"; import { useGetIntegrationAuthCircleCIOrganizations } from "@app/hooks/api/integrationAuth";
import { CircleCiScope } from "@app/hooks/api/integrationAuth/types";
const formSchema = z.discriminatedUnion("scope", [
z.object({
scope: z.literal(CircleCiScope.Context),
secretPath: z.string().default("/"),
sourceEnvironment: z.object({ name: z.string(), slug: z.string() }),
targetOrg: z.object({ name: z.string().min(1), slug: z.string().min(1) }),
targetContext: z.object({ name: z.string().min(1), id: z.string().min(1) })
}),
z.object({
scope: z.literal(CircleCiScope.Project),
secretPath: z.string().default("/"),
sourceEnvironment: z.object({ name: z.string(), slug: z.string() }),
targetOrg: z.object({ name: z.string().min(1), slug: z.string().min(1) }),
targetProject: z.object({ name: z.string().min(1), id: z.string().min(1) })
})
]);
type TFormData = z.infer<typeof formSchema>;
export default function CircleCICreateIntegrationPage() { export default function CircleCICreateIntegrationPage() {
const router = useRouter(); const router = useRouter();
const { mutateAsync } = useCreateIntegration(); const { mutateAsync, isLoading: isCreatingIntegration } = useCreateIntegration();
const { currentWorkspace, isLoading: isProjectLoading } = useWorkspace();
const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); const integrationAuthId = router.query.integrationAuthId as string;
const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); const { control, watch, handleSubmit, setValue } = useForm<TFormData>({
const { data: integrationAuth, isLoading: isintegrationAuthLoading } = useGetIntegrationAuthById( resolver: zodResolver(formSchema),
(integrationAuthId as string) ?? "" defaultValues: {
); secretPath: "/",
const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = sourceEnvironment: currentWorkspace?.environments[0],
useGetIntegrationAuthApps({ scope: CircleCiScope.Project
integrationAuthId: (integrationAuthId as string) ?? "" }
}); });
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); const selectedScope = watch("scope");
const [targetOrganization, setTargetOrganization] = useState(""); const selectedOrg = watch("targetOrg");
const [secretPath, setSecretPath] = useState("/");
const [targetProjectId, setTargetProjectId] = useState(""); const { data: circleCIOrganizations, isLoading: isCircleCIOrganizationsLoading } =
useGetIntegrationAuthCircleCIOrganizations(integrationAuthId);
const [isLoading, setIsLoading] = useState(false); const selectedOrganizationEntry = selectedOrg
? circleCIOrganizations?.find((org) => org.slug === selectedOrg.slug)
: undefined;
useEffect(() => { const onSubmit = async (data: TFormData) => {
if (workspace) {
setSelectedSourceEnvironment(workspace.environments[0].slug);
}
}, [workspace]);
const handleButtonClick = async () => {
try { try {
if (!integrationAuth?.id) return; if (data.scope === CircleCiScope.Context) {
if (!targetProjectId || targetOrganization === "none") {
createNotification({
type: "error",
text: "Please select a project"
});
setIsLoading(false);
return;
}
setIsLoading(true);
const selectedApp = integrationAuthApps?.find(
(integrationAuthApp) => integrationAuthApp.appId === targetProjectId
);
if (!selectedApp) {
createNotification({
type: "error",
text: "Invalid project selected"
});
setIsLoading(false);
return;
}
await mutateAsync({ await mutateAsync({
integrationAuthId: integrationAuth?.id, scope: data.scope,
integrationAuthId,
isActive: true, isActive: true,
app: selectedApp.name, // project name sourceEnvironment: data.sourceEnvironment.slug,
owner: selectedApp.owner, // organization name app: data.targetContext.name,
appId: selectedApp.appId, // project id (used for syncing) appId: data.targetContext.id,
sourceEnvironment: selectedSourceEnvironment, owner: data.targetOrg.name,
secretPath secretPath: data.secretPath
}); });
} else {
await mutateAsync({
scope: data.scope,
integrationAuthId,
isActive: true,
app: data.targetProject.name, // project name
owner: data.targetOrg.name, // organization name
appId: data.targetProject.id, // project id (used for syncing)
sourceEnvironment: data.sourceEnvironment.slug,
secretPath: data.secretPath
});
}
setIsLoading(false); createNotification({
type: "success",
router.push(`/integrations/${localStorage.getItem("projectData.id")}`); text: "Successfully created integration"
});
router.push(`/integrations/${currentWorkspace?.id}`);
} catch (err) { } catch (err) {
createNotification({
type: "error",
text: "Failed to create integration"
});
console.error(err); console.error(err);
} }
}; };
const filteredProjects = useMemo(() => { if (isProjectLoading || isCircleCIOrganizationsLoading)
if (!integrationAuthApps) return []; return (
<div className="flex h-full w-full items-center justify-center p-24">
<Spinner />
</div>
);
return integrationAuthApps.filter((integrationAuthApp) => { return (
return integrationAuthApp.owner === targetOrganization; <form
}); onSubmit={handleSubmit(onSubmit)}
}, [integrationAuthApps, targetOrganization]); className="flex h-full w-full items-center justify-center"
const filteredOrganizations = useMemo(() => {
const organizations = new Set<string>();
if (integrationAuthApps) {
integrationAuthApps.forEach((integrationAuthApp) => {
if (!integrationAuthApp.owner) return;
organizations.add(integrationAuthApp.owner);
});
}
return Array.from(organizations);
}, [integrationAuthApps]);
return integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<Head>
<title>Set Up CircleCI Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="Choose which environment or folder in Infisical you want to sync to CircleCI environment variables."
> >
<div className="flex flex-row items-center"> <Card className="max-w-lg rounded-md p-8 pt-4">
<div className="flex items-center pb-0.5"> <CardTitle
className="w-full px-0 text-left text-xl"
subTitle="Choose which environment or folder in Infisical you want to sync to CircleCI."
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-row items-center gap-1.5">
<Image <Image
src="/images/integrations/Circle CI.png" src="/images/integrations/CircleCI.png"
height={30} height={30}
width={30} width={30}
alt="CircleCI logo" alt="CircleCI logo"
/> />
<span className="">CircleCI Context Integration </span>
</div> </div>
<span className="ml-1.5">CircleCI Integration </span>
<Link href="https://infisical.com/docs/integrations/cicd/circleci" passHref> <Link
<a target="_blank" rel="noopener noreferrer"> href="https://infisical.com/docs/integrations/cicd/circleci"
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100"> target="_blank"
rel="noopener noreferrer"
passHref
>
<div className="ml-2 mb-1 flex cursor-pointer flex-row items-center gap-0.5 rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" /> <FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs Docs
<FontAwesomeIcon <FontAwesomeIcon
@@ -157,137 +152,158 @@ export default function CircleCICreateIntegrationPage() {
className="ml-1.5 mb-[0.07rem] text-xxs" className="ml-1.5 mb-[0.07rem] text-xxs"
/> />
</div> </div>
</a>
</Link> </Link>
</div> </div>
</CardTitle> </CardTitle>
<Controller
<FormControl label="Project Environment" className="px-6"> control={control}
<Select name="sourceEnvironment"
value={selectedSourceEnvironment} render={({ field: { value, onChange }, fieldState: { error } }) => (
onValueChange={(val) => setSelectedSourceEnvironment(val)} <FormControl
className="w-full border border-mineshaft-500" errorText={error?.message}
isError={Boolean(error)}
label="Project Environment"
> >
{workspace?.environments.map((sourceEnvironment) => ( <FilterableSelect
<SelectItem getOptionValue={(option) => option.slug}
value={sourceEnvironment.slug} value={value}
key={`source-environment-${sourceEnvironment.slug}`} getOptionLabel={(option) => option.name}
> onChange={onChange}
{sourceEnvironment.name} options={currentWorkspace?.environments}
</SelectItem> placeholder="Select a project environment"
))} isDisabled={!currentWorkspace?.environments.length}
</Select>
</FormControl>
<FormControl label="Secrets Path" className="px-6">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
placeholder="Provide a path, default is /"
/> />
</FormControl> </FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secrets Path" errorText={error?.message} isError={Boolean(error)}>
<SecretPathInput {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="targetOrg"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
label="CircleCI Organization"
>
<FilterableSelect
getOptionValue={(option) => option.slug}
value={value}
getOptionLabel={(option) => option.name}
onChange={(e) => {
setValue("targetProject", {
name: "",
id: ""
});
setValue("targetContext", {
name: "",
id: ""
});
<FormControl label="CircleCI Organization" className="px-6"> onChange(e);
}}
options={circleCIOrganizations}
placeholder={
circleCIOrganizations?.length
? "Select an organization..."
: "No organizations found..."
}
isDisabled={!circleCIOrganizations?.length}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="scope"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Scope" errorText={error?.message} isError={Boolean(error)}>
<Select <Select
value={targetOrganization} defaultValue={field.value}
onValueChange={(val) => { onValueChange={(e) => {
setTargetOrganization(val); onChange(e);
setTargetProjectId("none");
}} }}
className="w-full border border-mineshaft-500" className="w-full border border-mineshaft-500"
isDisabled={filteredOrganizations.length === 0}
> >
{filteredOrganizations.length > 0 ? ( <SelectItem value={CircleCiScope.Project}>Project</SelectItem>
filteredOrganizations.map((org) => ( <SelectItem value={CircleCiScope.Context}>Context</SelectItem>
<SelectItem value={org} key={`target-org-${org}`}>
{org}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No organizations found
</SelectItem>
)}
</Select> </Select>
</FormControl> </FormControl>
{targetOrganization && (
<FormControl label="CircleCI Project ID" className="px-6">
<Select
value={targetProjectId}
onValueChange={(val) => {
setTargetProjectId(val);
}}
className="w-full border border-mineshaft-500"
isDisabled={filteredProjects.length === 0}
>
{filteredProjects.length > 0 ? (
filteredProjects.map((project) => (
<SelectItem value={project.appId!} key={`target-project-${project.owner}`}>
{project.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)} )}
</Select> />
{selectedScope === CircleCiScope.Context && selectedOrganizationEntry && (
<Controller
control={control}
name="targetContext"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
label="CircleCI Context"
>
<FilterableSelect
value={value}
getOptionValue={(option) => option.id!}
getOptionLabel={(option) => option.name}
onChange={onChange}
options={selectedOrganizationEntry?.contexts}
placeholder={
selectedOrganizationEntry.contexts?.length
? "Select a context..."
: "No contexts found..."
}
isDisabled={!selectedOrganizationEntry.contexts?.length}
/>
</FormControl> </FormControl>
)} )}
/>
)}
{selectedScope === CircleCiScope.Project && selectedOrganizationEntry && (
<Controller
control={control}
name="targetProject"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
label="CircleCI Project"
>
<FilterableSelect
value={value}
getOptionValue={(option) => option.id!}
getOptionLabel={(option) => option.name}
onChange={onChange}
options={selectedOrganizationEntry?.projects}
placeholder={
selectedOrganizationEntry.projects?.length
? "Select a project..."
: "No projects found..."
}
isDisabled={!selectedOrganizationEntry.projects?.length}
/>
</FormControl>
)}
/>
)}
<Button <Button
onClick={handleButtonClick} type="submit"
colorSchema="primary" colorSchema="primary"
variant="outline_bg" className="mt-4"
className="mb-6 mt-2 ml-auto mr-6 w-min" isLoading={isCreatingIntegration}
isLoading={isLoading} isDisabled={isCreatingIntegration}
isDisabled={integrationAuthApps.length === 0}
> >
Create Integration Create Integration
</Button> </Button>
</Card> </Card>
<div className="mt-6 w-full max-w-md border-t border-mineshaft-800" /> </form>
<div className="mt-6 flex w-full max-w-lg flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4">
<div className="flex flex-row items-center">
<FontAwesomeIcon icon={faCircleInfo} className="text-xl text-mineshaft-200" />{" "}
<span className="text-md ml-3 text-mineshaft-100">Pro Tip</span>
</div>
<span className="mt-4 text-sm text-mineshaft-300">
After creating an integration, your secrets will start syncing immediately. This might
cause an unexpected override of current secrets in CircleCI with secrets from Infisical.
</span>
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Head>
<title>Set Up CircleCI Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
{isIntegrationAuthAppsLoading || isintegrationAuthLoading ? (
<img
src="/images/loading/loading.gif"
height={70}
width={120}
alt="infisical loading indicator"
/>
) : (
<div className="flex h-max max-w-md flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6 text-center text-mineshaft-200">
<FontAwesomeIcon icon={faBugs} className="inlineli my-2 text-6xl" />
<p>
Something went wrong. Please contact{" "}
<a
className="inline cursor-pointer text-mineshaft-100 underline decoration-primary-500 underline-offset-4 opacity-80 duration-200 hover:opacity-100"
target="_blank"
rel="noopener noreferrer"
href="mailto:[email protected]"
>
support@infisical.com
</a>{" "}
if the issue persists.
</p>
</div>
)}
</div>
); );
} }
@@ -1,6 +1,7 @@
import { integrationSlugNameMapping } from "public/data/frequentConstants"; import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { FormLabel } from "@app/components/v2"; import { FormLabel } from "@app/components/v2";
import { CircleCiScope } from "@app/hooks/api/integrationAuth/types";
import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
type Props = { type Props = {
@@ -46,6 +47,11 @@ export const IntegrationConnectionSection = ({ integration }: Props) => {
case "qovery": case "qovery":
return integration.scope; return integration.scope;
case "circleci": case "circleci":
if (integration.scope === CircleCiScope.Context) {
return "Context";
}
return "Project";
case "terraform-cloud": case "terraform-cloud":
return "Project"; return "Project";
case "aws-secret-manager": case "aws-secret-manager":
@@ -77,7 +83,6 @@ export const IntegrationConnectionSection = ({ integration }: Props) => {
return `${integration.owner}`; return `${integration.owner}`;
} }
return `${integration.owner}/${integration.app}`; return `${integration.owner}/${integration.app}`;
case "aws-parameter-store": case "aws-parameter-store":
case "rundeck": case "rundeck":
return `${integration.path}`; return `${integration.path}`;
@@ -1,4 +1,5 @@
import { FormLabel } from "@app/components/v2"; import { FormLabel } from "@app/components/v2";
import { CircleCiScope } from "@app/hooks/api/integrationAuth/types";
import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types"; import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types";
type Props = { type Props = {
@@ -52,7 +53,8 @@ export const IntegrationDetails = ({ integration }: Props) => {
<FormLabel <FormLabel
label={ label={
(integration.integration === "qovery" && integration?.scope) || (integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "circleci" && "Project") || (integration.integration === "circleci" &&
(integration.scope === CircleCiScope.Context ? "Context" : "Project")) ||
(integration.integration === "bitbucket" && "Repository") || (integration.integration === "bitbucket" && "Repository") ||
(integration.integration === "octopus-deploy" && "Project") || (integration.integration === "octopus-deploy" && "Project") ||
(integration.integration === "aws-secret-manager" && "Secret") || (integration.integration === "aws-secret-manager" && "Secret") ||