From 00b24d1ffabfb56a4d6414d1afeed898605ed8d4 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 25 Aug 2024 22:09:55 -0400 Subject: [PATCH] add google cloud gke sync --- packages/validators/eslint.config.js | 3 +- packages/validators/package.json | 8 +- packages/validators/src/index.ts | 2 +- .../src/targets/application/v1/instance.ts | 20 --- packages/validators/src/targets/index.ts | 1 + .../validators/src/targets/kubernetes-v1.ts | 42 ++++++ .../src/targets/kubernetes/v1/cluster.ts | 24 ---- .../src/targets/salesforce/v1/account.ts | 24 ---- .../src/targets/terraform/v1/workspace.ts | 32 ----- pnpm-lock.yaml | 26 +++- .../google-cloud/compute-scanner/package.json | 5 + .../compute-scanner/src/gke-connect.ts | 43 +++++++ .../google-cloud/compute-scanner/src/gke.ts | 121 ++++++++++++++++++ .../google-cloud/compute-scanner/src/index.ts | 35 +++++ .../google-cloud/compute-scanner/src/utils.ts | 9 ++ 15 files changed, 287 insertions(+), 108 deletions(-) delete mode 100644 packages/validators/src/targets/application/v1/instance.ts create mode 100644 packages/validators/src/targets/index.ts create mode 100644 packages/validators/src/targets/kubernetes-v1.ts delete mode 100644 packages/validators/src/targets/kubernetes/v1/cluster.ts delete mode 100644 packages/validators/src/targets/salesforce/v1/account.ts delete mode 100644 packages/validators/src/targets/terraform/v1/workspace.ts create mode 100644 providers/google-cloud/compute-scanner/src/gke-connect.ts create mode 100644 providers/google-cloud/compute-scanner/src/gke.ts create mode 100644 providers/google-cloud/compute-scanner/src/utils.ts diff --git a/packages/validators/eslint.config.js b/packages/validators/eslint.config.js index 3b3d3ce9..6c56c279 100644 --- a/packages/validators/eslint.config.js +++ b/packages/validators/eslint.config.js @@ -1,9 +1,10 @@ -import baseConfig from "@ctrlplane/eslint-config/base"; +import baseConfig, { requireJsSuffix } from "@ctrlplane/eslint-config/base"; /** @type {import('typescript-eslint').Config} */ export default [ { ignores: ["dist/**"], }, + ...requireJsSuffix, ...baseConfig, ]; diff --git a/packages/validators/package.json b/packages/validators/package.json index 9ea117c0..c5926455 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -5,8 +5,12 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.ts", - "default": "./src/index.ts" + "types": "./src/index.ts", + "default": "./dist/index.js" + }, + "./targets": { + "types": "./src/targets/index.ts", + "default": "./dist/targets/index.js" } }, "license": "MIT", diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 57ffeb91..d8df6ab8 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -1 +1 @@ -export * from "./config-file"; +export * from "./config-file.js"; diff --git a/packages/validators/src/targets/application/v1/instance.ts b/packages/validators/src/targets/application/v1/instance.ts deleted file mode 100644 index bf2e98f5..00000000 --- a/packages/validators/src/targets/application/v1/instance.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from "zod"; - -export const instance = z.object({ - version: z.literal("application/v1"), - kind: z.literal("Instance"), - name: z.string(), - provider: z.string(), - config: z.object({ - identifier: z.string(), - inputs: z.array( - z.object({ - key: z.string(), - value: z.string(), - }), - ), - }), - labels: z.record(z.string()).and(z.object({})), -}); - -export type Instance = z.infer; diff --git a/packages/validators/src/targets/index.ts b/packages/validators/src/targets/index.ts new file mode 100644 index 00000000..7fae17a0 --- /dev/null +++ b/packages/validators/src/targets/index.ts @@ -0,0 +1 @@ +export * from "./kubernetes-v1.js"; diff --git a/packages/validators/src/targets/kubernetes-v1.ts b/packages/validators/src/targets/kubernetes-v1.ts new file mode 100644 index 00000000..0ec819a0 --- /dev/null +++ b/packages/validators/src/targets/kubernetes-v1.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +const clusterConfig = z.object({ + name: z.string(), + server: z.object({ + certificateAuthorityData: z.string(), + endpoint: z.string().url(), + }), +}); +export const kubernetesClusterApiV1 = z.object({ + version: z.literal("kubernetes/v1"), + kind: z.literal("ClusterAPI"), + identifier: z.string(), + name: z.string(), + config: clusterConfig, + labels: z.record(z.string()).and( + z + .object({ + "kubernetes/version": z.string(), + "kubernetes/distribution": z.string(), + "kubernetes/master-version": z.string(), + "kubernetes/master-version-major": z.string(), + "kubernetes/master-version-minor": z.string(), + "kubernetes/master-version-patch": z.string(), + "kubernetes/autoscaling-enabled": z.string().optional(), + }) + .partial(), + ), +}); + +export type KubernetesClusterAPIV1 = z.infer; + +export const kubernetesNamespaceV1 = z.object({ + version: z.literal("kubernetes/v1"), + kind: z.literal("Namespace"), + identifier: z.string(), + name: z.string(), + config: clusterConfig.and(z.object({ namespace: z.string() })), + labels: z.record(z.string()).and(z.object({}).partial()), +}); + +export type KubernetesNamespaceV1 = z.infer; diff --git a/packages/validators/src/targets/kubernetes/v1/cluster.ts b/packages/validators/src/targets/kubernetes/v1/cluster.ts deleted file mode 100644 index c290e04f..00000000 --- a/packages/validators/src/targets/kubernetes/v1/cluster.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -export const clusterApi = z.object({ - version: z.literal("kubernetes/v1"), - kind: z.literal("ClusterAPI"), - name: z.string(), - provider: z.string(), - config: z.object({ - name: z.string(), - server: z.object({ - certificateAuthorityData: z.string(), - server: z.string().url(), - }), - }), - labels: z.record(z.string()).and( - z - .object({ - "kubernetes.io/version": z.string(), - }) - .partial(), - ), -}); - -export type ClusterAPI = z.infer; diff --git a/packages/validators/src/targets/salesforce/v1/account.ts b/packages/validators/src/targets/salesforce/v1/account.ts deleted file mode 100644 index e7c82982..00000000 --- a/packages/validators/src/targets/salesforce/v1/account.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -export const account = z.object({ - version: z.literal("salesfroce/v1"), - kind: z.literal("Account"), - name: z.string(), - provider: z.string(), - config: z.object({ - id: z.string(), - name: z.string(), - industry: z.string(), - active: z.boolean(), - nps: z.string(), - }), - labels: z.record(z.string()).and( - z - .object({ - "naics.com/code": z.string(), - }) - .partial(), - ), -}); - -export type Account = z.infer; diff --git a/packages/validators/src/targets/terraform/v1/workspace.ts b/packages/validators/src/targets/terraform/v1/workspace.ts deleted file mode 100644 index e9b4087c..00000000 --- a/packages/validators/src/targets/terraform/v1/workspace.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from "zod"; - -export const workspace = z.object({ - version: z.literal("terraform/v1"), - kind: z.literal("Workspace"), - name: z.string(), - provider: z.string(), - config: z.object({ - backend: z.literal("terraform-cloud"), - terraformVersion: z.string(), - inputs: z.array( - z.object({ - key: z.string(), - value: z.string(), - default: z.string().optional(), - category: z.literal("hcl").or(z.literal("env")).default("hcl"), - required: z.boolean().default(false), - sensitive: z.boolean().default(false), - }), - ), - }), - labels: z.record(z.string()).and( - z - .object({ - "terraform.io/organization": z.string(), - "terraform.io/workspace": z.string(), - }) - .partial(), - ), -}); - -export type Workspace = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 815666b4..679f0717 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1085,9 +1085,15 @@ importers: '@ctrlplane/node-sdk': specifier: workspace:* version: link:../../../packages/node-sdk + '@ctrlplane/validators': + specifier: workspace:* + version: link:../../../packages/validators '@google-cloud/container': specifier: ^5.16.0 version: 5.16.0 + '@kubernetes/client-node': + specifier: ^0.21.0 + version: 0.21.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) '@t3-oss/env-core': specifier: ^0.10.1 version: 0.10.1(typescript@5.5.4)(zod@3.23.8) @@ -1097,9 +1103,15 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 + google-auth-library: + specifier: ^9.13.0 + version: 9.13.0 handlebars: specifier: ^4.7.8 version: 4.7.8 + lodash: + specifier: ^4.17.21 + version: 4.17.21 p-retry: specifier: ^6.2.0 version: 6.2.0 @@ -1119,6 +1131,9 @@ importers: '@ctrlplane/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript + '@types/lodash': + specifier: ^4.17.5 + version: 4.17.7 eslint: specifier: 'catalog:' version: 9.9.0(jiti@1.21.6) @@ -1139,7 +1154,7 @@ importers: version: 1.13.4(eslint@9.9.0(jiti@1.21.6)) eslint-plugin-import: specifier: ^2.29.1 - version: 2.29.1(eslint@9.9.0(jiti@1.21.6)) + version: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.3))(eslint@9.9.0(jiti@1.21.6)) eslint-plugin-jsx-a11y: specifier: ^6.8.0 version: 6.9.0(eslint@9.9.0(jiti@1.21.6)) @@ -13818,16 +13833,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(eslint-import-resolver-node@0.3.9)(eslint@9.9.0(jiti@1.21.6)): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint@9.9.0(jiti@1.21.6)): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 7.16.1(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.3) eslint: 9.9.0(jiti@1.21.6) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(eslint@9.9.0(jiti@1.21.6)): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.3))(eslint@9.9.0(jiti@1.21.6)): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -13837,7 +13853,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.9.0(jiti@1.21.6) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(eslint-import-resolver-node@0.3.9)(eslint@9.9.0(jiti@1.21.6)) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint@9.9.0(jiti@1.21.6)) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -13847,6 +13863,8 @@ snapshots: object.values: 1.2.0 semver: 6.3.1 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 7.16.1(eslint@9.9.0(jiti@1.21.6))(typescript@5.5.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack diff --git a/providers/google-cloud/compute-scanner/package.json b/providers/google-cloud/compute-scanner/package.json index d9f3b23a..7b79e0a3 100644 --- a/providers/google-cloud/compute-scanner/package.json +++ b/providers/google-cloud/compute-scanner/package.json @@ -21,11 +21,15 @@ "dependencies": { "@ctrlplane/logger": "workspace:*", "@ctrlplane/node-sdk": "workspace:*", + "@ctrlplane/validators": "workspace:*", "@google-cloud/container": "^5.16.0", + "@kubernetes/client-node": "^0.21.0", "@t3-oss/env-core": "^0.10.1", "cron": "^3.1.7", "dotenv": "^16.4.5", + "google-auth-library": "^9.13.0", "handlebars": "^4.7.8", + "lodash": "^4.17.21", "p-retry": "^6.2.0", "semver": "^7.6.2", "zod": "catalog:" @@ -34,6 +38,7 @@ "@ctrlplane/eslint-config": "workspace:*", "@ctrlplane/prettier-config": "workspace:*", "@ctrlplane/tsconfig": "workspace:*", + "@types/lodash": "^4.17.5", "eslint": "catalog:", "prettier": "catalog:", "typescript": "^5.4.5" diff --git a/providers/google-cloud/compute-scanner/src/gke-connect.ts b/providers/google-cloud/compute-scanner/src/gke-connect.ts new file mode 100644 index 00000000..057fe84a --- /dev/null +++ b/providers/google-cloud/compute-scanner/src/gke-connect.ts @@ -0,0 +1,43 @@ +import type { ClusterManagerClient } from "@google-cloud/container"; +import { KubeConfig } from "@kubernetes/client-node"; +import { GoogleAuth } from "google-auth-library"; + +const sourceCredentials = new GoogleAuth({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], +}); + +export const connectToCluster = async ( + clusterClient: ClusterManagerClient, + project: string, + clusterName: string, + clusterLocation: string, +) => { + const [credentials] = await clusterClient.getCluster({ + name: `projects/${project}/locations/${clusterLocation}/clusters/${clusterName}`, + }); + const kubeConfig = new KubeConfig(); + kubeConfig.loadFromOptions({ + clusters: [ + { + name: clusterName, + server: `https://${credentials.endpoint}`, + caData: credentials.masterAuth!.clusterCaCertificate!, + }, + ], + users: [ + { + name: clusterName, + token: (await sourceCredentials.getAccessToken())!, + }, + ], + contexts: [ + { + name: clusterName, + user: clusterName, + cluster: clusterName, + }, + ], + currentContext: clusterName, + }); + return kubeConfig; +}; diff --git a/providers/google-cloud/compute-scanner/src/gke.ts b/providers/google-cloud/compute-scanner/src/gke.ts new file mode 100644 index 00000000..3973739b --- /dev/null +++ b/providers/google-cloud/compute-scanner/src/gke.ts @@ -0,0 +1,121 @@ +import type { KubernetesClusterAPIV1 } from "@ctrlplane/validators/targets"; +import type { google } from "@google-cloud/container/build/protos/protos.js"; +import Container from "@google-cloud/container"; +import { CoreV1Api } from "@kubernetes/client-node"; +import handlebars from "handlebars"; +import _ from "lodash"; +import { SemVer } from "semver"; + +import { logger } from "@ctrlplane/logger"; +import { kubernetesNamespaceV1 } from "@ctrlplane/validators/targets"; + +import { env } from "./config.js"; +import { connectToCluster } from "./gke-connect.js"; +import { omitNullUndefined } from "./utils.js"; + +export const gkeLogger = logger.child({ label: "gke" }); + +const clusterClient = new Container.v1.ClusterManagerClient(); + +const getClusters = async () => { + const request = { parent: `projects/${env.GOOGLE_PROJECT_ID}/locations/-` }; + const [response] = await clusterClient.listClusters(request); + const { clusters } = response; + return clusters; +}; + +const template = handlebars.compile(env.CTRLPLANE_GKE_TARGET_NAME); +const targetName = (cluster: google.container.v1.ICluster) => + template({ cluster, projectId: env.GOOGLE_PROJECT_ID }); + +export const getKubernetesClusters = async (): Promise< + Array<{ + cluster: google.container.v1.ICluster; + target: KubernetesClusterAPIV1; + }> +> => { + gkeLogger.info("Scanning Google Cloud GKE clusters"); + const clusters = (await getClusters()) ?? []; + return clusters.map((cluster) => { + const masterVersion = new SemVer(cluster.currentMasterVersion ?? "0"); + const nodeVersion = new SemVer(cluster.currentNodeVersion ?? "0"); + const autoscaling = String( + cluster.autoscaling?.enableNodeAutoprovisioning ?? false, + ); + + const appUrl = `https://console.cloud.google.com/kubernetes/clusters/details/${cluster.location}/${cluster.name}/details?project=${env.GOOGLE_PROJECT_ID}`; + + return { + cluster, + target: { + version: "kubernetes/v1", + kind: "ClusterAPI", + name: targetName(cluster), + identifier: `${env.GOOGLE_PROJECT_ID}/${cluster.name}`, + config: { + name: cluster.name!, + server: { + certificateAuthorityData: + cluster.masterAuth?.clusterCaCertificate ?? "", + endpoint: `https://${cluster.endpoint}`, + }, + }, + labels: omitNullUndefined({ + "ctrlplane/url": appUrl, + + "kubernetes/distribution": "gke", + "kubernetes/status": cluster.status, + "kubernetes/node-count": String(cluster.currentNodeCount ?? 0), + + "kubernetes/master-version": masterVersion.version, + "kubernetes/master-version-major": String(masterVersion.major), + "kubernetes/master-version-minor": String(masterVersion.minor), + "kubernetes/master-version-patch": String(masterVersion.patch), + + "kubernetes/node-version": nodeVersion.version, + "kubernetes/node-version-major": String(nodeVersion.major), + "kubernetes/node-version-minor": String(nodeVersion.minor), + "kubernetes/node-version-patch": String(nodeVersion.patch), + + "kubernetes/autoscaling-enabled": autoscaling, + }), + }, + }; + }); +}; + +export const getKubernetesNamespace = async ( + clusters: Array<{ + cluster: google.container.v1.ICluster; + target: KubernetesClusterAPIV1; + }>, +) => { + gkeLogger.info("Coverting GKE clusters to namespaces"); + + const namespaceTargets = clusters.map(async ({ cluster, target }) => { + const kubeConfig = await connectToCluster( + clusterClient, + env.GOOGLE_PROJECT_ID, + cluster.name!, + cluster.location!, + ); + const k8sApi = kubeConfig.makeApiClient(CoreV1Api); + const namespaces = await k8sApi + .listNamespace() + .then((r) => r.body.items.filter((n) => n.metadata != null)); + return namespaces.map((n) => + kubernetesNamespaceV1.parse( + _.merge(target, { + kind: "Namespace", + identifier: `${env.GOOGLE_PROJECT_ID}/${cluster.name}/${n.metadata!.name}`, + config: { namespace: n.metadata!.name }, + labels: { + "kubernetes/namespace": n.metadata!.name, + }, + }), + ), + ); + }); + + return Promise.all(namespaceTargets).then((v) => v.flat()); +}; diff --git a/providers/google-cloud/compute-scanner/src/index.ts b/providers/google-cloud/compute-scanner/src/index.ts index 142f6783..8499ccb7 100644 --- a/providers/google-cloud/compute-scanner/src/index.ts +++ b/providers/google-cloud/compute-scanner/src/index.ts @@ -3,11 +3,46 @@ import { CronJob } from "cron"; import { logger } from "@ctrlplane/logger"; import { env } from "./config.js"; +import { + getKubernetesClusters, + getKubernetesNamespace, + gkeLogger, +} from "./gke.js"; +import { api } from "./sdk.js"; + +const getScannerId = async () => { + try { + const { id } = await api.upsertTargetProvider({ + workspace: env.CTRLPLANE_WORKSPACE, + name: env.CTRLPLANE_SCANNER_NAME, + }); + return id; + } catch (error) { + logger.error(error); + logger.error( + `Failed to get scanner ID. This could be caused by incorrect workspace (${env.CTRLPLANE_WORKSPACE}), or API Key`, + { error }, + ); + } + return null; +}; const scan = async () => { + const id = await getScannerId(); + if (id == null) return; + + logger.info(`Scanner ID: ${id}`, { id }); logger.info("Running google compute scanner", { date: new Date().toISOString(), }); + + const targets = await getKubernetesClusters(); + gkeLogger.info(`Found ${targets.length} clusters`, { count: targets.length }); + + const namespaces = await getKubernetesNamespace(targets); + gkeLogger.info(`Found ${namespaces.length} namespaces`, { + count: namespaces.length, + }); }; logger.info( diff --git a/providers/google-cloud/compute-scanner/src/utils.ts b/providers/google-cloud/compute-scanner/src/utils.ts new file mode 100644 index 00000000..dc99eda3 --- /dev/null +++ b/providers/google-cloud/compute-scanner/src/utils.ts @@ -0,0 +1,9 @@ +export function omitNullUndefined(obj: object) { + return Object.entries(obj).reduce>( + (acc, [key, value]) => { + if (value !== null && value !== undefined) acc[key] = value; + return acc; + }, + {}, + ); +}