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 (
+
+ );
+};
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 (
+
+ );
+};
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}
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+};
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.