Skip to content

Commit

Permalink
AWS managed compute scanner (#236)
Browse files Browse the repository at this point in the history
Signed-off-by: Zachary Blasczyk <[email protected]>
Co-authored-by: Justin Brooks <[email protected]>
  • Loading branch information
zacharyblasczyk and jsbroks authored Dec 9, 2024
1 parent c111c90 commit 1dd8b9a
Show file tree
Hide file tree
Showing 30 changed files with 6,228 additions and 2,830 deletions.
5 changes: 5 additions & 0 deletions apps/event-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand All @@ -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:"
},
Expand Down
12 changes: 8 additions & 4 deletions apps/event-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions apps/event-worker/src/target-scan/aws.ts
Original file line number Diff line number Diff line change
@@ -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<AwsCredentials> => {
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);
};
186 changes: 186 additions & 0 deletions apps/event-worker/src/target-scan/eks.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading

0 comments on commit 1dd8b9a

Please sign in to comment.