Skip to content

Commit

Permalink
feat: Google VPC Scanner last mile
Browse files Browse the repository at this point in the history
  • Loading branch information
zacharyblasczyk committed Dec 31, 2024
1 parent 141f048 commit acdfafe
Show file tree
Hide file tree
Showing 10 changed files with 4,634 additions and 72 deletions.
3 changes: 3 additions & 0 deletions apps/docs/pages/integrations/google-cloud/compute-scanner.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ and creates them for related deployments automatically.
Currently the compute scanner supports importing the following resources:

- Google Kubernetes Engine Clusters (GKE)
- Namespaces
- VClusters (Virtual Clusters)
- Google Virtual Private Cloud (VPC)

## Managed Compute Scanner

Expand Down
10 changes: 8 additions & 2 deletions apps/event-worker/src/resource-scan/google/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { logger } from "@ctrlplane/logger";

import { createResourceScanWorker } from "../utils.js";
import { getGkeResources } from "./gke.js";
import { getVpcResources } from "./vpc.js";

const log = logger.child({ label: "resource-scan/google" });

Expand All @@ -14,10 +15,15 @@ export const createGoogleResourceScanWorker = () =>
return [];
}

const resources = await getGkeResources(
const gkeResources = await getGkeResources(
rp.workspace,
rp.resource_provider_google,
);

return resources;
const vpcResources = await getVpcResources(
rp.workspace,
rp.resource_provider_google,
);

return [...gkeResources, ...vpcResources];
});
192 changes: 129 additions & 63 deletions apps/event-worker/src/resource-scan/google/vpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import type {
} from "@ctrlplane/db/schema";
import type { CloudVPCV1 } from "@ctrlplane/validators/resources";
import type { google } from "@google-cloud/compute/build/protos/protos.js";
import { NetworksClient } from "@google-cloud/compute";
import { NetworksClient, SubnetworksClient } from "@google-cloud/compute";
import _ from "lodash";
import { isPresent } from "ts-is-present";

import { logger } from "@ctrlplane/logger";
import { ReservedMetadataKey } from "@ctrlplane/validators/conditions";
Expand All @@ -16,61 +17,119 @@ import { getGoogleClient } from "./client.js";

const log = logger.child({ label: "resource-scan/google/vpc" });

const getNetworksClient = async (targetPrincipal?: string | null) =>
getGoogleClient(NetworksClient, targetPrincipal, "Networks Client");
type GoogleSubnetDetails = {
name: string;
region: string;
cidr: string;
type: "internal" | "external";
gatewayAddress: string;
secondaryCidrs: { name: string; cidr: string }[] | undefined;
};

const getNetworksClient = async (targetPrincipal?: string | null) => {
const [networksClient] = await getGoogleClient(
NetworksClient,
targetPrincipal,
"Networks Client",
);
const [subnetsClient] = await getGoogleClient(
SubnetworksClient,
targetPrincipal,
"Subnets Client",
);
return { networksClient, subnetsClient };
};

const getSubnetDetails = (
subnetsClient: SubnetworksClient,
project: string,
subnetSelfLink: string,
): Promise<GoogleSubnetDetails | null> => {
const parts = subnetSelfLink.split("/");
const region = parts.at(-3) ?? "";
const name = parts.at(-1) ?? "";

return subnetsClient
.list({
project,
region,
filter: `name eq ${name}`,
})
.then(([subnets]) =>
_.chain(subnets)
.find((subnet) => subnet.name === name)
.thru(
(subnet): GoogleSubnetDetails => ({
name,
region,
gatewayAddress: subnet.gatewayAddress ?? "",
cidr: subnet.ipCidrRange ?? "",
type: subnet.purpose === "INTERNAL" ? "internal" : "external",
secondaryCidrs: subnet.secondaryIpRanges?.map((r) => ({
name: r.rangeName ?? "",
cidr: r.ipCidrRange ?? "",
})),
}),
)
.value(),
);
};

const getNetworkResources = (
const getNetworkResources = async (
clients: { networksClient: NetworksClient; subnetsClient: SubnetworksClient },
project: string,
networks: google.cloud.compute.v1.INetwork[],
): CloudVPCV1[] =>
networks
.filter((n) => n.name != null)
.map((network) => {
return {
name: network.name!,
identifier: `${project}/${network.name}`,
version: "cloud/v1",
kind: "VPC",
config: {
): Promise<CloudVPCV1[]> =>
await Promise.all(
networks
.filter((n) => n.name != null)
.map(async (network) => {
const subnets = await Promise.all(
(network.subnetworks ?? []).map((subnet) =>
getSubnetDetails(clients.subnetsClient, project, subnet),
),
);
return {
name: network.name!,
provider: "google",
region: "global", // GCP VPC is global; subnets have regional scope
project,
cidr: network.IPv4Range ?? undefined,
mtu: network.mtu ?? undefined,
subnets: network.subnetworks?.map((subnet) => {
const parts = subnet.split("/");
const region = parts.at(-3) ?? "";
const name = parts.at(-1) ?? "";
return { name, region };
}),
},
metadata: omitNullUndefined({
[ReservedMetadataKey.ExternalId]: network.id?.toString(),
[ReservedMetadataKey.Links]: JSON.stringify({
"Google Console": `https://console.cloud.google.com/networking/networks/details/${network.name}?project=${project}`,
}),
"google/project": project,
"google/self-link": network.selfLink,
"google/creation-timestamp": network.creationTimestamp,
"google/description": network.description,
...network.peerings?.reduce(
(acc, peering) => ({
...acc,
[`google/peering/${peering.name}`]: JSON.stringify({
network: peering.network,
state: peering.state,
autoCreateRoutes: peering.autoCreateRoutes,
}),
identifier: `${project}/${network.name}`,
version: "cloud/v1" as const,
kind: "VPC" as const,
config: {
name: network.name!,
provider: "google" as const,
region: "global",
project,
cidr: network.IPv4Range ?? undefined,
mtu: network.mtu ?? undefined,
subnets: subnets.filter(isPresent),
},
metadata: omitNullUndefined({
[ReservedMetadataKey.ExternalId]: network.id?.toString(),
[ReservedMetadataKey.Links]: JSON.stringify({
"Google Console": `https://console.cloud.google.com/networking/networks/details/${network.name}?project=${project}`,
}),
{},
),
}),
};
});
"google/project": project,
"google/self-link": network.selfLink,
"google/creation-timestamp": network.creationTimestamp,
"google/description": network.description,
...network.peerings?.reduce(
(acc, peering) => ({
...acc,
[`google/peering/${peering.name}`]: JSON.stringify({
network: peering.network,
state: peering.state,
autoCreateRoutes: peering.autoCreateRoutes,
}),
}),
{},
),
}),
};
}),
);

const fetchProjectNetworks = async (
networksClient: NetworksClient,
clients: { networksClient: NetworksClient; subnetsClient: SubnetworksClient },
project: string,
workspaceId: string,
providerId: string,
Expand All @@ -80,14 +139,19 @@ const fetchProjectNetworks = async (
let pageToken: string | undefined | null;

do {
const [networkList, request] = await networksClient.list({
const [networkList, request] = await clients.networksClient.list({
project,
maxResults: 500,
pageToken,
});

const resources = await getNetworkResources(
clients,
project,
networkList,
);
networks.push(
...getNetworkResources(project, networkList).map((resource) => ({
...resources.map((resource) => ({
...resource,
workspaceId,
providerId,
Expand All @@ -97,15 +161,15 @@ const fetchProjectNetworks = async (
} while (pageToken != null);

return networks;
} catch (error: any) {
} catch (err) {
const error = err as { message?: string; code?: number };
const isPermissionError =
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
error.message?.includes("PERMISSION_DENIED") || error.code === 403;
error.message?.includes("PERMISSION_DENIED") ?? error.code === 403;
log.error(
`Unable to get VPCs for project: ${project} - ${
isPermissionError
? 'Missing required permissions. Please ensure the service account has the "Compute Network Viewer" role.'
: error.message
: (error.message ?? "Unknown error")
}`,
{ error, project },
);
Expand All @@ -124,14 +188,16 @@ export const getVpcResources = async (
{ workspaceId, config, googleServiceAccountEmail, resourceProviderId },
);

const [networksClient] = await getNetworksClient(googleServiceAccountEmail);
const resources: InsertResource[] = await _.chain(config.projectIds)
.map((id) =>
fetchProjectNetworks(networksClient, id, workspaceId, resourceProviderId),
)
.thru((promises) => Promise.all(promises))
.value()
.then((results) => results.flat());
const clients = await getNetworksClient(googleServiceAccountEmail);
const resources: InsertResource[] = config.importVpc
? await _.chain(config.projectIds)
.map((id) =>
fetchProjectNetworks(clients, id, workspaceId, resourceProviderId),
)
.thru((promises) => Promise.all(promises))
.value()
.then((results) => results.flat())
: [];

log.info(`Found ${resources.length} VPC resources`);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const createGoogleSchema = z.object({
importGke: z.boolean().default(false),
importNamespaces: z.boolean().default(false),
importVCluster: z.boolean().default(false),
importVpc: z.boolean().default(false),
});

export const GoogleDialog: React.FC<{
Expand All @@ -56,6 +57,7 @@ export const GoogleDialog: React.FC<{
importGke: true,
importNamespaces: false,
importVCluster: false,
importVpc: false,
},
mode: "onChange",
});
Expand All @@ -82,10 +84,8 @@ export const GoogleDialog: React.FC<{
...data,
workspaceId: workspace.id,
config: {
...data,
projectIds: data.projectIds.map((p) => p.value),
importGke: data.importGke,
importVCluster: data.importVCluster,
importNamespaces: data.importNamespaces,
},
});
await utils.resource.provider.byWorkspaceId.invalidate();
Expand Down Expand Up @@ -256,6 +256,25 @@ export const GoogleDialog: React.FC<{
)}
/>

<FormField
control={form.control}
name="importVpc"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Import VPC</FormLabel>
<FormDescription>Enable importing of VPCs</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>

<DialogFooter>
<Button type="submit">Create</Button>
</DialogFooter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,8 @@ export const UpdateGoogleProviderDialog: React.FC<{
...data,
resourceProviderId: providerId,
config: {
...data,
projectIds: data.projectIds.map((p) => p.value),
importGke: data.importGke,
importVCluster: data.importVCluster,
importNamespaces: data.importNamespaces,
},
repeatSeconds: data.repeatSeconds === 0 ? null : data.repeatSeconds,
});
Expand Down Expand Up @@ -288,6 +286,25 @@ export const UpdateGoogleProviderDialog: React.FC<{
)}
/>

<FormField
control={form.control}
name="importVpc"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Import VPC</FormLabel>
<FormDescription>Enable importing of VPCs</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>

<FormField
control={form.control}
name="repeatSeconds"
Expand Down
1 change: 1 addition & 0 deletions packages/db/drizzle/0051_fat_iron_lad.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "resource_provider_google" ADD COLUMN "import_vpc" boolean DEFAULT false NOT NULL;
Loading

0 comments on commit acdfafe

Please sign in to comment.