diff --git a/apps/event-worker/package.json b/apps/event-worker/package.json index ffaf8d5cd..8cc395f71 100644 --- a/apps/event-worker/package.json +++ b/apps/event-worker/package.json @@ -12,6 +12,9 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { + "@aws-sdk/client-ec2": "^3.701.0", + "@aws-sdk/client-eks": "^3.699.0", + "@aws-sdk/client-sts": "^3.699.0", "@ctrlplane/db": "workspace:*", "@ctrlplane/job-dispatch": "workspace:*", "@ctrlplane/logger": "workspace:*", @@ -20,6 +23,7 @@ "@kubernetes/client-node": "^0.22.0", "@octokit/auth-app": "^7.1.0", "@octokit/rest": "catalog:", + "@smithy/types": "^3.7.1", "@t3-oss/env-core": "catalog:", "bullmq": "catalog:", "cron": "^3.1.7", @@ -29,6 +33,7 @@ "lodash": "catalog:", "ms": "^2.1.3", "semver": "^7.6.2", + "ts-is-present": "^1.2.2", "uuid": "^10.0.0", "zod": "catalog:" }, diff --git a/apps/event-worker/src/index.ts b/apps/event-worker/src/index.ts index 8d478c866..1ee7bc36b 100644 --- a/apps/event-worker/src/index.ts +++ b/apps/event-worker/src/index.ts @@ -2,15 +2,19 @@ import { logger } from "@ctrlplane/logger"; import { createDispatchExecutionJobWorker } from "./job-dispatch/index.js"; import { redis } from "./redis.js"; -import { createResourceScanWorker } from "./target-scan/index.js"; +import { + createAwsResourceScanWorker, + createGoogleResourceScanWorker, +} from "./target-scan/index.js"; -const resourceScanWorker = createResourceScanWorker(); +const resourceGoogleScanWorker = createGoogleResourceScanWorker(); +const resourceAwsScanWorker = createAwsResourceScanWorker(); const dispatchExecutionJobWorker = createDispatchExecutionJobWorker(); const shutdown = () => { logger.warn("Exiting..."); - - resourceScanWorker.close(); + resourceAwsScanWorker.close(); + resourceGoogleScanWorker.close(); dispatchExecutionJobWorker.close(); redis.quit(); diff --git a/apps/event-worker/src/target-scan/aws.ts b/apps/event-worker/src/target-scan/aws.ts new file mode 100644 index 000000000..e128a7d56 --- /dev/null +++ b/apps/event-worker/src/target-scan/aws.ts @@ -0,0 +1,59 @@ +import type { Credentials } from "@aws-sdk/client-sts"; +import type { AwsCredentialIdentity } from "@smithy/types"; +import { EC2Client } from "@aws-sdk/client-ec2"; +import { EKSClient } from "@aws-sdk/client-eks"; +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; + +const sourceClient = new STSClient({ region: "us-east-1" }); + +export class AwsCredentials { + static from(credentials: Credentials) { + return new AwsCredentials(credentials); + } + + private constructor(private readonly credentials: Credentials) {} + + toIdentity(): AwsCredentialIdentity { + if ( + this.credentials.AccessKeyId == null || + this.credentials.SecretAccessKey == null + ) + throw new Error("Missing required AWS credentials"); + + return { + accessKeyId: this.credentials.AccessKeyId, + secretAccessKey: this.credentials.SecretAccessKey, + sessionToken: this.credentials.SessionToken ?? undefined, + }; + } + + ec2(region?: string) { + return new EC2Client({ region, credentials: this.toIdentity() }); + } + + eks(region?: string) { + return new EKSClient({ region, credentials: this.toIdentity() }); + } + + sts(region?: string) { + return new STSClient({ region, credentials: this.toIdentity() }); + } +} + +export const assumeWorkspaceRole = async (roleArn: string) => + assumeRole(sourceClient, roleArn); + +export const assumeRole = async ( + client: STSClient, + roleArn: string, +): Promise => { + const { Credentials: CustomerCredentials } = await client.send( + new AssumeRoleCommand({ + RoleArn: roleArn, + RoleSessionName: "CtrlplaneScanner", + }), + ); + if (CustomerCredentials == null) + throw new Error(`Failed to assume AWS role ${roleArn}`); + return AwsCredentials.from(CustomerCredentials); +}; diff --git a/apps/event-worker/src/target-scan/eks.ts b/apps/event-worker/src/target-scan/eks.ts new file mode 100644 index 000000000..7991dff23 --- /dev/null +++ b/apps/event-worker/src/target-scan/eks.ts @@ -0,0 +1,186 @@ +import type { Cluster, EKSClient } from "@aws-sdk/client-eks"; +import type { STSClient } from "@aws-sdk/client-sts"; +import type { ResourceProviderAws, Workspace } from "@ctrlplane/db/schema"; +import type { KubernetesClusterAPIV1 } from "@ctrlplane/validators/resources"; +import { DescribeRegionsCommand } from "@aws-sdk/client-ec2"; +import { + DescribeClusterCommand, + ListClustersCommand, +} from "@aws-sdk/client-eks"; +import _ from "lodash"; +import { isPresent } from "ts-is-present"; + +import { logger } from "@ctrlplane/logger"; +import { ReservedMetadataKey } from "@ctrlplane/validators/conditions"; + +import type { AwsCredentials } from "./aws.js"; +import { omitNullUndefined } from "../utils.js"; +import { assumeRole, assumeWorkspaceRole } from "./aws.js"; + +const log = logger.child({ label: "resource-scan/eks" }); + +const convertEksClusterToKubernetesResource = ( + accountId: string, + cluster: Cluster, +): KubernetesClusterAPIV1 => { + const region = cluster.endpoint?.split(".")[2]; + + const partition = + cluster.arn?.split(":")[1] ?? + (region?.startsWith("us-gov-") ? "aws-us-gov" : "aws"); + + const appUrl = `https://${ + partition === "aws-us-gov" + ? `console.${region}.${partition}` + : "console.aws.amazon" + }.com/eks/home?region=${region}#/clusters/${cluster.name}`; + + const version = cluster.version!; + const [major, minor] = version.split("."); + + return { + name: cluster.name ?? "", + identifier: `aws/${accountId}/eks/${cluster.name}`, + version: "kubernetes/v1" as const, + kind: "ClusterAPI" as const, + config: { + name: cluster.name!, + auth: { + method: "aws/eks" as const, + region: region!, + clusterName: cluster.name!, + }, + status: cluster.status ?? "UNKNOWN", + server: { + certificateAuthorityData: cluster.certificateAuthority?.data, + endpoint: cluster.endpoint!, + }, + }, + metadata: omitNullUndefined({ + [ReservedMetadataKey.Links]: JSON.stringify({ "AWS Console": appUrl }), + [ReservedMetadataKey.ExternalId]: cluster.arn ?? "", + [ReservedMetadataKey.KubernetesFlavor]: "eks", + [ReservedMetadataKey.KubernetesVersion]: cluster.version, + + "aws/arn": cluster.arn, + "aws/region": region, + "aws/platform-version": cluster.platformVersion, + + "kubernetes/status": cluster.status, + "kubernetes/version-major": major, + "kubernetes/version-minor": minor, + + ...(cluster.tags ?? {}), + }), + }; +}; + +const getAwsRegions = async (credentials: AwsCredentials) => + credentials + .ec2() + .send(new DescribeRegionsCommand({})) + .then(({ Regions = [] }) => Regions.map((region) => region.RegionName)); + +const getClusters = async (client: EKSClient) => + client + .send(new ListClustersCommand({})) + .then((response) => response.clusters ?? []); + +const createEksClusterScannerForRegion = ( + client: AwsCredentials, + customerRoleArn: string, +) => { + const accountId = /arn:aws:iam::(\d+):/.exec(customerRoleArn)?.[1]; + if (accountId == null) throw new Error("Missing account ID"); + + return async (region: string) => { + const eksClient = client.eks(region); + const clusters = await getClusters(eksClient); + log.info( + `Found ${clusters.length} clusters for ${customerRoleArn} in region ${region}`, + ); + + return _.chain(clusters) + .map((name) => + eksClient + .send(new DescribeClusterCommand({ name })) + .then(({ cluster }) => cluster), + ) + .thru((promises) => Promise.all(promises)) + .value() + .then((clusterDetails) => + clusterDetails + .filter(isPresent) + .map((cluster) => + convertEksClusterToKubernetesResource(accountId, cluster), + ), + ); + }; +}; + +const scanEksClustersByAssumedRole = async ( + workspaceClient: STSClient, + customerRoleArn: string, +) => { + const client = await assumeRole(workspaceClient, customerRoleArn); + const regions = await getAwsRegions(client); + + log.info( + `Scanning ${regions.length} AWS regions for EKS clusters in account ${customerRoleArn}`, + ); + + const regionalClusterScanner = createEksClusterScannerForRegion( + client, + customerRoleArn, + ); + + return _.chain(regions) + .filter(isPresent) + .map(regionalClusterScanner) + .thru((promises) => Promise.all(promises)) + .value() + .then((results) => results.flat()); +}; + +export const getEksResources = async ( + workspace: Workspace, + config: ResourceProviderAws, +) => { + const { awsRoleArn: workspaceRoleArn } = workspace; + if (workspaceRoleArn == null) return []; + + log.info( + `Scanning for EKS cluters with assumed role arns ${config.awsRoleArns.join(", ")} using role ${workspaceRoleArn}`, + { + workspaceId: workspace.id, + config, + workspaceRoleArn, + }, + ); + + const credentials = await assumeWorkspaceRole(workspaceRoleArn); + const workspaceStsClient = credentials.sts(); + + const resources = await _.chain(config.awsRoleArns) + .map((customerRoleArn) => + scanEksClustersByAssumedRole(workspaceStsClient, customerRoleArn), + ) + .thru((promises) => Promise.all(promises)) + .value() + .then((results) => results.flat()) + .then((resources) => + resources.map((resource) => ({ + ...resource, + workspaceId: workspace.id, + providerId: config.resourceProviderId, + })), + ); + + const resourceTypes = _.countBy(resources, (resource) => + [resource.kind, resource.version].join("/"), + ); + + log.info(`Found ${resources.length} resources`, { resourceTypes }); + + return resources; +}; diff --git a/apps/event-worker/src/target-scan/index.ts b/apps/event-worker/src/target-scan/index.ts index bfc5a5e3c..8a304ab0c 100644 --- a/apps/event-worker/src/target-scan/index.ts +++ b/apps/event-worker/src/target-scan/index.ts @@ -7,6 +7,7 @@ import { eq, takeFirstOrNull } from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; import { resourceProvider, + resourceProviderAws, resourceProviderGoogle, workspace, } from "@ctrlplane/db/schema"; @@ -15,17 +16,23 @@ import { logger } from "@ctrlplane/logger"; import { Channel } from "@ctrlplane/validators/events"; import { redis } from "../redis.js"; +import { getEksResources } from "./eks.js"; import { getGkeResources } from "./gke.js"; +const log = logger.child({ label: "resource-scan" }); + const resourceScanQueue = new Queue(Channel.ResourceScan, { connection: redis, }); + const removeResourceJob = (job: Job) => job.repeatJobKey != null ? resourceScanQueue.removeRepeatableByKey(job.repeatJobKey) : null; -export const createResourceScanWorker = () => +const createResourceScanWorker = ( + scanResources: (rp: any) => Promise, +) => new Worker( Channel.ResourceScan, async (job) => { @@ -40,51 +47,39 @@ export const createResourceScanWorker = () => resourceProviderGoogle, eq(resourceProvider.id, resourceProviderGoogle.resourceProviderId), ) + .leftJoin( + resourceProviderAws, + eq(resourceProvider.id, resourceProviderAws.resourceProviderId), + ) .then(takeFirstOrNull); if (rp == null) { - logger.error( - `Resource provider with ID ${resourceProviderId} not found.`, - ); + log.error(`Resource provider with ID ${resourceProviderId} not found.`); await removeResourceJob(job); return; } - logger.info( + log.info( `Received scanning request for "${rp.resource_provider.name}" (${resourceProviderId}).`, ); - const resources: InsertResource[] = []; - - if (rp.resource_provider_google != null) { - logger.info("Found Google config, scanning for GKE resources"); - try { - const gkeResources = await getGkeResources( - rp.workspace, - rp.resource_provider_google, - ); - resources.push(...gkeResources); - } catch (error: any) { - logger.error(`Error scanning GKE resources: ${error.message}`, { - error, - }); - } - } - try { - logger.info( + const resources = await scanResources(rp); + + log.info( `Upserting ${resources.length} resources for provider ${rp.resource_provider.id}`, ); + if (resources.length > 0) { await upsertResources(db, resources); } else { - logger.info( + log.info( `No resources found for provider ${rp.resource_provider.id}, skipping upsert.`, ); } } catch (error: any) { - logger.error( - `Error upserting resources for provider ${rp.resource_provider.id}: ${error.message}`, + log.error( + `Error scanning/upserting resources for provider ${rp.resource_provider.id}: ${error.message}`, { error }, ); } @@ -96,3 +91,37 @@ export const createResourceScanWorker = () => concurrency: 10, }, ); + +export const createGoogleResourceScanWorker = () => + createResourceScanWorker(async (rp) => { + if (rp.resource_provider_google == null) { + log.info( + `No Google provider found for resource provider ${rp.resource_provider.id}, skipping scan`, + ); + return []; + } + + const resources = await getGkeResources( + rp.workspace, + rp.resource_provider_google, + ); + + return resources; + }); + +export const createAwsResourceScanWorker = () => + createResourceScanWorker(async (rp) => { + if (rp.resource_provider_aws == null) { + log.info( + `No AWS provider found for resource provider ${rp.resource_provider.id}, skipping scan`, + ); + return []; + } + + const resources = await getEksResources( + rp.workspace, + rp.resource_provider_aws, + ); + + return resources; + }); diff --git a/apps/event-worker/src/target-scan/kube.ts b/apps/event-worker/src/target-scan/kube.ts index e4d2abaec..a7106a1cf 100644 --- a/apps/event-worker/src/target-scan/kube.ts +++ b/apps/event-worker/src/target-scan/kube.ts @@ -33,9 +33,16 @@ export const getKubeConfig = async ( } }; +type Namespace = { + metadata?: { + name?: string; + labels?: Record; + }; +}; + export const createNamespaceResource = ( clusterResource: InsertResource, - namespace: any, + namespace: Namespace, project: string, cluster: google.container.v1.ICluster, ) => { diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/ProviderActionsDropdown.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/ProviderActionsDropdown.tsx index 9697d3e86..fe9254060 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/ProviderActionsDropdown.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/ProviderActionsDropdown.tsx @@ -2,6 +2,7 @@ import type { ResourceProvider, + ResourceProviderAws, ResourceProviderGoogle, } from "@ctrlplane/db/schema"; import { useState } from "react"; @@ -28,10 +29,12 @@ import { } from "@ctrlplane/ui/dropdown-menu"; import { api } from "~/trpc/react"; +import { UpdateAwsProviderDialog } from "./integrations/aws/UpdateAwsProviderDialog"; import { UpdateGoogleProviderDialog } from "./integrations/google/UpdateGoogleProviderDialog"; type Provider = ResourceProvider & { googleConfig: ResourceProviderGoogle | null; + awsConfig: ResourceProviderAws | null; }; export const ProviderActionsDropdown: React.FC<{ @@ -39,6 +42,8 @@ export const ProviderActionsDropdown: React.FC<{ }> = ({ provider }) => { const [open, setOpen] = useState(false); const utils = api.useUtils(); + const isManagedProvider = + provider.googleConfig != null || provider.awsConfig != null; const deleteProvider = api.resource.provider.delete.useMutation({ onSuccess: () => utils.resource.provider.byWorkspaceId.invalidate(), @@ -75,7 +80,19 @@ export const ProviderActionsDropdown: React.FC<{ )} - {provider.googleConfig != null && ( + {provider.awsConfig != null && ( + setOpen(false)} + > + e.preventDefault()}> + Edit + + + )} + {isManagedProvider && ( { await sync.mutateAsync(provider.id); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/AwsActionButton.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/AwsActionButton.tsx new file mode 100644 index 000000000..3df382b92 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/AwsActionButton.tsx @@ -0,0 +1,52 @@ +"use client"; + +import type { Workspace } from "@ctrlplane/db/schema"; +import React from "react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@ctrlplane/ui/button"; +import { toast } from "@ctrlplane/ui/toast"; + +import { api } from "~/trpc/react"; +import { AwsDialog } from "./aws/AwsDialog"; + +type AwsActionButtonProps = { + workspace: Workspace; +}; + +export const AwsActionButton: React.FC = ({ + workspace, +}) => { + const createAwsRole = + api.workspace.integrations.aws.createAwsRole.useMutation(); + + const router = useRouter(); + if (workspace.awsRoleArn != null) + return ( + + + + ); + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/GoogleActionButton.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/GoogleActionButton.tsx index 859302a43..effb0dcda 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/GoogleActionButton.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/GoogleActionButton.tsx @@ -5,6 +5,7 @@ import React from "react"; import { useRouter } from "next/navigation"; import { Button } from "@ctrlplane/ui/button"; +import { toast } from "@ctrlplane/ui/toast"; import { api } from "~/trpc/react"; import { GoogleDialog } from "./google/GoogleDialog"; @@ -38,7 +39,17 @@ export const GoogleActionButton: React.FC = ({ onClick={async () => createServiceAccount .mutateAsync(workspace.id) + .then((result) => + toast.success( + `Google Service Account ${result.googleServiceAccountEmail} created for ${result.name}`, + ), + ) .then(() => router.refresh()) + .catch((error) => { + toast.error( + `Failed to create Google Service Account. ${error.message}`, + ); + }) } > Enable diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/aws/AwsDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/aws/AwsDialog.tsx new file mode 100644 index 000000000..52b8eb57b --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/aws/AwsDialog.tsx @@ -0,0 +1,205 @@ +"use client"; + +import type { Workspace } from "@ctrlplane/db/schema"; +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { IconBulb, IconCheck, IconCopy } from "@tabler/icons-react"; +import { useFieldArray } from "react-hook-form"; +import { useCopyToClipboard } from "react-use"; +import { z } from "zod"; + +import { cn } from "@ctrlplane/ui"; +import { Alert, AlertDescription, AlertTitle } from "@ctrlplane/ui/alert"; +import { Button } from "@ctrlplane/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@ctrlplane/ui/form"; +import { Input } from "@ctrlplane/ui/input"; +import { Label } from "@ctrlplane/ui/label"; + +import { api } from "~/trpc/react"; + +export const createAwsSchema = z.object({ + name: z.string(), + awsRoleArns: z.array( + z.object({ + value: z + .string() + .regex( + /^arn:aws:iam::[0-9]{12}:role\/[a-zA-Z0-9+=,.@\-_/]+$/, + "Invalid AWS Role ARN format. Expected format: arn:aws:iam:::role/", + ), + }), + ), +}); + +export const AwsDialog: React.FC<{ + workspace: Workspace; + children: React.ReactNode; +}> = ({ children, workspace }) => { + const form = useForm({ + schema: createAwsSchema, + defaultValues: { + name: "", + awsRoleArns: [{ value: "" }], + }, + mode: "onSubmit", + }); + const { fields, append } = useFieldArray({ + name: "awsRoleArns", + control: form.control, + }); + + const [isCopied, setIsCopied] = useState(false); + const [, copy] = useCopyToClipboard(); + const handleCopy = () => { + copy(workspace.awsRoleArn ?? ""); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 1000); + }; + + const router = useRouter(); + const utils = api.useUtils(); + const create = api.resource.provider.managed.aws.create.useMutation(); + const onSubmit = form.handleSubmit(async (data) => { + await create.mutateAsync({ + ...data, + workspaceId: workspace.id, + config: { + awsRoleArns: data.awsRoleArns.map((a) => a.value), + }, + }); + await utils.resource.provider.byWorkspaceId.invalidate(); + router.refresh(); + router.push(`/${workspace.slug}/target-providers`); + }); + return ( + + {children} + +
+ + + Configure AWS Provider + + AWS provider allows you to configure and import EKS clusters + from your AWS accounts. + + + + + AWS Provider + + To utilize the AWS provider, it's necessary to grant our role + access to your AWS accounts and set up the required + permissions. For detailed instructions, please refer to our{" "} + + documentation + + . + + + + +
+ +
+ + +
+
+ + ( + + Provider Name + + + + + + )} + /> + +
+ {fields.map((field, index) => ( + ( + + + AWS Role ARNs + + + + + + + )} + /> + ))} + +
+ + + + + + +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/aws/UpdateAwsProviderDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/aws/UpdateAwsProviderDialog.tsx new file mode 100644 index 000000000..1a99afe4d --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/aws/UpdateAwsProviderDialog.tsx @@ -0,0 +1,260 @@ +"use client"; + +import type { ResourceProviderAws } from "@ctrlplane/db/schema"; +import { useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { IconBulb, IconCheck, IconCopy, IconX } from "@tabler/icons-react"; +import { useCopyToClipboard } from "react-use"; +import { z } from "zod"; + +import { cn } from "@ctrlplane/ui"; +import { Alert, AlertDescription, AlertTitle } from "@ctrlplane/ui/alert"; +import { Button } from "@ctrlplane/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useFieldArray, + useForm, +} from "@ctrlplane/ui/form"; +import { Input } from "@ctrlplane/ui/input"; +import { Label } from "@ctrlplane/ui/label"; + +import { api } from "~/trpc/react"; +import { createAwsSchema } from "./AwsDialog"; + +const formSchema = createAwsSchema.and( + z.object({ + repeatSeconds: z.number().min(0), + }), +); + +export const UpdateAwsProviderDialog: React.FC<{ + providerId: string; + name: string; + awsConfig: ResourceProviderAws | null; + onClose?: () => void; + children: React.ReactNode; +}> = ({ providerId, awsConfig, name, onClose, children }) => { + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + const workspace = api.workspace.bySlug.useQuery(workspaceSlug); + const form = useForm({ + schema: formSchema, + defaultValues: { + name, + ...awsConfig, + awsRoleArns: awsConfig?.awsRoleArns.map((a) => ({ value: a })) ?? [], + repeatSeconds: 0, + }, + mode: "onChange", + }); + + const utils = api.useUtils(); + const update = api.resource.provider.managed.aws.update.useMutation(); + const onSubmit = form.handleSubmit(async (data) => { + if (workspace.data == null) return; + await update.mutateAsync({ + ...data, + resourceProviderId: providerId, + config: { + awsRoleArns: data.awsRoleArns.map((a) => a.value), + }, + repeatSeconds: data.repeatSeconds === 0 ? null : data.repeatSeconds, + }); + await utils.resource.provider.byWorkspaceId.invalidate(); + setOpen(false); + onClose?.(); + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "awsRoleArns", + }); + + const [open, setOpen] = useState(false); + + const [isCopied, setIsCopied] = useState(false); + const [, copy] = useCopyToClipboard(); + const handleCopy = () => { + copy(workspace.data?.awsRoleArn ?? ""); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 1000); + }; + + return ( + { + setOpen(o); + if (!o) { + form.reset(); + onClose?.(); + } + }} + > + {children} + +
+ + + Configure AWS Provider + + AWS provider allows you to configure and import EKS clusters + from your AWS accounts. + + + + + AWS Provider + + To utilize the AWS provider, it's necessary to grant our role + access to your AWS accounts and set up the required + permissions. For detailed instructions, please refer to our{" "} + + documentation + + . + + + + +
+ +
+ + +
+
+ + ( + + Provider Name + + + + + + )} + /> + +
+ {fields.map((field, index) => ( + ( + + + AWS Account Role Arns + + +
+ + + {fields.length > 1 && ( + + )} +
+
+ +
+ )} + /> + ))} + +
+ + ( + + Scan Frequency (seconds) + + field.onChange(e.target.valueAsNumber)} + /> + + + )} + /> + + + + + + +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/page.tsx index 4857ba410..48e5622d8 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/integrations/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import React from "react"; import { notFound } from "next/navigation"; import { + SiAmazon, SiGooglecloud, SiKubernetes, SiTerraform, @@ -13,6 +14,7 @@ import { Button } from "@ctrlplane/ui/button"; import { Card } from "@ctrlplane/ui/card"; import { api } from "~/trpc/server"; +import { AwsActionButton } from "./AwsActionButton"; import { GoogleActionButton } from "./GoogleActionButton"; export const metadata: Metadata = { @@ -79,7 +81,7 @@ const TargetProviders: React.FC<{ workspaceSlug: string }> = async ({ anything.

- {/* + @@ -95,8 +97,8 @@ const TargetProviders: React.FC<{ workspaceSlug: string }> = async ({ - Configure - */} + + diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/page.tsx index 5c4fb3e11..b1e06d1bc 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/target-providers/page.tsx @@ -77,26 +77,27 @@ export default async function TargetProvidersPage({
{provider.name} - {provider.googleConfig == null && ( - - - - - Custom - - - - A custom provider is when you are running your own - agent instead of using managed agents built inside - Ctrlplane. Your agent directly calls Ctrlplane's API - to create targets. - - - - )} + {provider.googleConfig == null && + provider.awsConfig == null && ( + + + + + Custom + + + + A custom provider is when you are running your own + agent instead of using managed agents built inside + Ctrlplane. Your agent directly calls Ctrlplane's + API to create targets. + + + + )} { onChange({ ...value, - startTime: - t != null - ? t.toDate( - Intl.DateTimeFormat().resolvedOptions() - .timeZone, - ) - : new Date(), + startTime: t.toDate( + Intl.DateTimeFormat().resolvedOptions() + .timeZone, + ), }); }} />{" "} @@ -164,13 +161,10 @@ export const RolloutAndTiming: React.FC<{ onChange={(t) => { onChange({ ...value, - endTime: - t != null - ? t.toDate( - Intl.DateTimeFormat().resolvedOptions() - .timeZone, - ) - : new Date(), + endTime: t.toDate( + Intl.DateTimeFormat().resolvedOptions() + .timeZone, + ), }); }} aria-label="End Time" diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/filter/DateConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/filter/DateConditionRender.tsx index 7a17a8a06..2ad79c61e 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/filter/DateConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/filter/DateConditionRender.tsx @@ -82,7 +82,7 @@ export const DateConditionRender: React.FC = ({
value != null && setDate(value)} + onChange={(value) => setDate(value)} aria-label={type} variant="filter" /> diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/JobHistoryChart.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/JobHistoryChart.tsx index f6d5e8b06..b7453564a 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/JobHistoryChart.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/JobHistoryChart.tsx @@ -272,7 +272,7 @@ export const JobHistoryChart: React.FC<{ animationDuration={animationDuration} fill={color} onClick={(e) => { - const start = new Date((e as any).date); + const start = new Date(e.date); const end = addDays(start, 1); const afterStartCondition: JobCondition = { diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/aws/AwsIntegration.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/aws/AwsIntegration.tsx new file mode 100644 index 000000000..d5dcb9f43 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/aws/AwsIntegration.tsx @@ -0,0 +1,120 @@ +"use client"; + +import type { Workspace } from "@ctrlplane/db/schema"; +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { SiAmazonwebservices } from "@icons-pack/react-simple-icons"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { useCopyToClipboard } from "react-use"; + +import { Button } from "@ctrlplane/ui/button"; +import { Card } from "@ctrlplane/ui/card"; +import { toast } from "@ctrlplane/ui/toast"; + +import { api } from "~/trpc/react"; + +export const AwsIntegration: React.FC<{ + workspace: Workspace; +}> = ({ workspace }) => { + const createAwsRole = + api.workspace.integrations.aws.createAwsRole.useMutation(); + const deleteAwsRole = + api.workspace.integrations.aws.deleteAwsRole.useMutation(); + const router = useRouter(); + const [isCopied, setIsCopied] = useState(false); + const [, copy] = useCopyToClipboard(); + const handleCopy = () => { + copy(workspace.awsRoleArn ?? ""); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 1000); + }; + + const isIntegrationEnabled = workspace.awsRoleArn != null; + + return ( +
+
+ +
+

Aws

+

+ Sync deployment resources, trigger AWS workflows and more. +

+
+
+ + +
+
+

Role

+ +

+ This integration creates a role that can be configured in your AWS + accounts. +

+
+ + {isIntegrationEnabled ? ( + + ) : ( + + )} +
+ + {isIntegrationEnabled && ( + <> +
+ +
+
+ + {workspace.awsRoleArn} + + +
+ +
+ Enabled +
+
+
+ + )} + +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/aws/page.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/aws/page.tsx new file mode 100644 index 000000000..dbf2424fd --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/aws/page.tsx @@ -0,0 +1,16 @@ +import { notFound } from "next/navigation"; + +import { api } from "~/trpc/server"; +import { AwsIntegration } from "./AwsIntegration"; + +export const metadata = { title: "AWS Integrations - Settings" }; + +export default async function AWSIntegrationPage({ + params, +}: { + params: { workspaceSlug: string }; +}) { + const workspace = await api.workspace.bySlug(params.workspaceSlug); + if (workspace == null) notFound(); + return ; +} diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/google/GoogleIntegration.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/google/GoogleIntegration.tsx index 2296dedff..a3fee4f72 100644 --- a/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/google/GoogleIntegration.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/(integration)/google/GoogleIntegration.tsx @@ -9,6 +9,7 @@ import { useCopyToClipboard } from "react-use"; import { Button } from "@ctrlplane/ui/button"; import { Card } from "@ctrlplane/ui/card"; +import { toast } from "@ctrlplane/ui/toast"; import { api } from "~/trpc/react"; @@ -60,7 +61,13 @@ export const GoogleIntegration: React.FC<{ onClick={() => deleteServiceAccount .mutateAsync(workspace.id) + .then(() => toast.success("Google Service Account deleted")) .then(() => router.refresh()) + .catch((error) => { + toast.error( + `Failed to delete service account. ${error.message}`, + ); + }) } > Disable @@ -72,7 +79,13 @@ export const GoogleIntegration: React.FC<{ onClick={() => createServiceAccount .mutateAsync(workspace.id) + .then(() => toast.success("Google Service Account created")) .then(() => router.refresh()) + .catch((error) => { + toast.error( + `Failed to create service account. ${error.message}`, + ); + }) } > Enable diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/page.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/page.tsx index 8e3462184..de0e5ec60 100644 --- a/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/integrations/page.tsx @@ -1,5 +1,9 @@ import Link from "next/link"; -import { SiGithub, SiGooglecloud } from "@icons-pack/react-simple-icons"; +import { + SiAmazon, + SiGithub, + SiGooglecloud, +} from "@icons-pack/react-simple-icons"; import { Card } from "@ctrlplane/ui/card"; @@ -58,6 +62,18 @@ export default function IntegrationsPage({ + + + + + AWS + +

+ Sync deployment resources, trigger AWS workflows and more. +

+
+
+ @@ -65,7 +81,7 @@ export default function IntegrationsPage({ Google

- Sync deployment targets, trigger google workflows and more. + Sync deployment resources, trigger google workflows and more.

diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/workspace/overview/page.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/overview/page.tsx index 85bd8ab1c..030204930 100644 --- a/apps/webservice/src/app/[workspaceSlug]/settings/workspace/overview/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/settings/workspace/overview/page.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import { + SiAmazon, SiGithub, SiGooglecloud, - SiKubernetes, } from "@icons-pack/react-simple-icons"; import { IconBook, @@ -154,7 +154,7 @@ export default function OverviewPage({
Google Cloud
- Sync deployment targets, trigger google workflows and more. + Sync deployment resource, trigger google workflows and more.
- +
-
Kubernetes
+
AWS
- Sync deployment targets, trigger k8s jobs and more. + Sync deployment resources, trigger AWS workflows and more.