Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Google VPC Scanner last mile #276

Merged
merged 17 commits into from
Jan 21, 2025
Merged
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
198 changes: 126 additions & 72 deletions apps/event-worker/src/resource-scan/google/vpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import type {
ResourceProviderGoogle,
Workspace,
} from "@ctrlplane/db/schema";
import type { CloudVPCV1 } from "@ctrlplane/validators/resources";
import type {
CloudSubnetV1,
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 @@ -17,60 +21,103 @@ 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");
Promise.all([
getGoogleClient(NetworksClient, targetPrincipal, "Networks Client"),
getGoogleClient(SubnetworksClient, targetPrincipal, "Subnets Client"),
]).then(([[networksClient], [subnetsClient]]) => ({
networksClient,
subnetsClient,
}));

const getNetworkResources = (
const getSubnetDetails = (
subnetsClient: SubnetworksClient,
project: string,
networks: google.cloud.compute.v1.INetwork[],
): CloudVPCV1[] =>
networks
.filter((n) => n.name != null)
.map((network) => {
subnetSelfLink: string,
): Promise<CloudSubnetV1 | null> => {
const parts = subnetSelfLink.split("/");
const region = parts.at(-3) ?? "";
const name = parts.at(-1) ?? "";
zacharyblasczyk marked this conversation as resolved.
Show resolved Hide resolved

return subnetsClient
.list({
project,
region,
filter: `name eq ${name}`,
})
.then(([subnets]) => {
const subnet = subnets.find((subnet) => subnet.name === name);
if (subnet === undefined) return null;

return {
name: network.name!,
identifier: `${project}/${network.name}`,
version: "cloud/v1",
kind: "VPC",
config: {
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,
}),
}),
{},
),
}),
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 ?? "",
})),
};
});
};

const getNetworkResources = async (
clients: { networksClient: NetworksClient; subnetsClient: SubnetworksClient },
project: string,
networks: google.cloud.compute.v1.INetwork[],
): 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!,
identifier: `${project}/${network.name}`,
version: "cloud/v1",
kind: "VPC",
config: {
name: network.name!,
provider: "google",
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
? Object.fromEntries(
network.peerings.flatMap((peering) => [
[`google/peering/${peering.name}/network`, peering.network],
[`google/peering/${peering.name}/state`, peering.state],
[
`google/peering/${peering.name}/auto-create-routes`,
peering.autoCreateRoutes?.toString() ?? "false",
],
]),
)
: {}),
}),
};
}),
);

const fetchProjectNetworks = async (
networksClient: NetworksClient,
clients: { networksClient: NetworksClient; subnetsClient: SubnetworksClient },
project: string,
workspaceId: string,
providerId: string,
Expand All @@ -80,35 +127,40 @@ 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,
})),
);
pageToken = request?.pageToken;
pageToken = request?.pageToken ?? null;
} while (pageToken != null);

return networks;
} catch (error: any) {
const isPermissionError =
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
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, project },
);
} catch (err) {
const { message, code } = err as { message?: string; code?: number };
const errorMessage =
message?.includes("PERMISSION_DENIED") || code === 403
? 'Missing required permissions. Please ensure the service account has the "Compute Network Viewer" role.'
: (message ?? "Unknown error");

log.error(`Unable to get VPCs for project: ${project} - ${errorMessage}`, {
error: err,
project,
});
return [];
}
};
Expand All @@ -124,14 +176,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/0059_acoustic_flatman.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
Loading