diff --git a/README.adoc b/README.adoc index 44a990f..09f8b2e 100644 --- a/README.adoc +++ b/README.adoc @@ -7,9 +7,9 @@ image:https://img.shields.io/github/package-json/v/edosrecki/google-cloud-sql-cli/master?color=blue&label=google-cloud-sql["google-cloud-sql CLI Version"] image:https://img.shields.io/github/actions/workflow/status/edosrecki/google-cloud-sql-cli/continuous-integration.yml["Build Status", link="https://github.com/edosrecki/google-cloud-sql-cli/actions"] -A CLI app which establishes a connection to a private Google Cloud SQL instance and port-forwards it to a local machine. +A CLI app which establishes a connection to a private Google Cloud SQL instance or AlloyDB instance and port-forwards it to a local machine. -Connection is established by running a Google Cloud SQL Auth Proxy pod in a Google Kubernetes Engine cluster which runs in the same VPC network as the private Cloud SQL instance. Connection is then port-forwarded to the local machine, where a user can connect to the instance on localhost. **Corresponding workload identity has to be configured in the cluster, with service account which has Cloud SQL Client role on the given SQL instance.** Configurations in the app can be saved for practical future usage. +Connection is established by running a Google Cloud SQL Auth Proxy pod (for Cloud SQL) or AlloyDB Auth Proxy pod (for AlloyDB) in a Google Kubernetes Engine cluster which runs in the same VPC network as the private database instance. Connection is then port-forwarded to the local machine, where a user can connect to the instance on localhost. **Corresponding workload identity has to be configured in the cluster, with service account which has Cloud SQL Client role (for Cloud SQL instances) or AlloyDB Client role (for AlloyDB instances) on the given database instance.** Configurations in the app can be saved for practical future usage. The app relies on local `gcloud` and `kubectl` commands which have to be configured and authenticated with the proper Google Cloud user and GKE Kubernetes cluster. @@ -43,8 +43,12 @@ _Package_ sections. * Install https://kubernetes.io/docs/tasks/tools/#kubectl[`kubectl`] tool * Authenticate to Google Cloud: `gcloud auth login` * Get GKE cluster credentials: `gcloud container clusters get-credentials` -* https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity[Configure workload identity] in GKE namespace(s) and assign _Cloud SQL Client_ role in IAM for Cloud SQL instances that you want to use -* Enable Cloud SQL Admin API for project(s) that host Cloud SQL instances that you want to use: `gcloud services enable sqladmin.googleapis.com --project=$PROJECT` +* https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity[Configure workload identity] in GKE namespace(s) and assign appropriate IAM roles: +** _Cloud SQL Client_ role for Cloud SQL instances +** _AlloyDB Client_ role for AlloyDB instances +* Enable required APIs for project(s): +** Cloud SQL Admin API for Cloud SQL instances: `gcloud services enable sqladmin.googleapis.com --project=$PROJECT` +** AlloyDB API for AlloyDB instances: `gcloud services enable alloydb.googleapis.com --project=$PROJECT` === Run [source,bash] @@ -69,6 +73,12 @@ psql -h localhost -p $LOCAL_PORT -U $USER cat $(google-cloud-sql configurations path) ---- +== Migrations + +=== v1 to v2 + +Migration from v1 to v2 is done automatically when the app is run for the first time after upgrade to version 2.0.0. + == Build [source,bash] ---- diff --git a/src/commands/configurations/create.ts b/src/commands/configurations/create.ts index 8c47461..d966178 100644 --- a/src/commands/configurations/create.ts +++ b/src/commands/configurations/create.ts @@ -3,8 +3,10 @@ import inquirer from 'inquirer' import autocomplete from 'inquirer-autocomplete-prompt' import { saveConfiguration } from '../../lib/configurations' import { ConfigurationCreateAnswers } from '../../lib/types' +import { googleAlloyDbInstancePrompt } from './prompts/google-alloydb-instance' import { configurationNamePrompt } from './prompts/configuration-name' import { confirmationPrompt } from './prompts/confirmation' +import { databaseTypePrompt } from './prompts/database-type' import { googleCloudProjectPrompt } from './prompts/google-cloud-project' import { googleCloudSqlInstancePrompt } from './prompts/google-cloud-sql-instance' import { kubernetesContextPrompt } from './prompts/kubernetes-context' @@ -17,7 +19,9 @@ export const createConfiguration = async () => { const answers = await inquirer.prompt([ googleCloudProjectPrompt, + databaseTypePrompt, googleCloudSqlInstancePrompt, + googleAlloyDbInstancePrompt, kubernetesContextPrompt, kubernetesNamespacePrompt, kubernetesServiceAccountPrompt, diff --git a/src/commands/configurations/prompts/database-type.ts b/src/commands/configurations/prompts/database-type.ts new file mode 100644 index 0000000..1ff97ba --- /dev/null +++ b/src/commands/configurations/prompts/database-type.ts @@ -0,0 +1,9 @@ +export const databaseTypePrompt = { + type: 'list', + name: 'databaseType', + message: 'Choose database type:', + choices: [ + { name: 'Cloud SQL', value: 'cloudsql' }, + { name: 'AlloyDB', value: 'alloydb' }, + ], +} diff --git a/src/commands/configurations/prompts/google-alloydb-instance.ts b/src/commands/configurations/prompts/google-alloydb-instance.ts new file mode 100644 index 0000000..e5d3dc8 --- /dev/null +++ b/src/commands/configurations/prompts/google-alloydb-instance.ts @@ -0,0 +1,32 @@ +import { pick } from 'lodash' +import { + fetchGoogleAlloyDbInstances, + GoogleAlloyDbInstance, +} from '../../../lib/gcloud/alloydb-instances' +import { ConfigurationCreateAnswers } from '../../../lib/types' +import { searchByKey } from '../../../lib/util/search' +import { tryCatch } from '../../../lib/util/error' + +const formatInstance = (instance: GoogleAlloyDbInstance) => { + const { name, region, cluster } = instance + return { + name: `${name} (cluster: ${cluster}, region: ${region})`, + short: name, + value: pick(instance, 'connectionName', 'port'), + } +} + +const source = tryCatch((answers: ConfigurationCreateAnswers, input?: string) => { + const instances = fetchGoogleAlloyDbInstances(answers.googleCloudProject) + const filtered = searchByKey(instances, 'connectionName', input) + + return filtered.map(formatInstance) +}) + +export const googleAlloyDbInstancePrompt = { + type: 'autocomplete', + name: 'databaseInstance', + message: 'Choose Google AlloyDB instance:', + source, + when: (answers: ConfigurationCreateAnswers) => answers.databaseType === 'alloydb', +} diff --git a/src/commands/configurations/prompts/google-cloud-sql-instance.ts b/src/commands/configurations/prompts/google-cloud-sql-instance.ts index d86dc94..dc7387f 100644 --- a/src/commands/configurations/prompts/google-cloud-sql-instance.ts +++ b/src/commands/configurations/prompts/google-cloud-sql-instance.ts @@ -25,7 +25,8 @@ const source = tryCatch((answers: ConfigurationCreateAnswers, input?: string) => export const googleCloudSqlInstancePrompt = { type: 'autocomplete', - name: 'googleCloudSqlInstance', + name: 'databaseInstance', message: 'Choose Google Cloud SQL instance:', source, + when: (answers: ConfigurationCreateAnswers) => answers.databaseType === 'cloudsql', } diff --git a/src/lib/configurations/constants.ts b/src/lib/configurations/constants.ts new file mode 100644 index 0000000..600df6d --- /dev/null +++ b/src/lib/configurations/constants.ts @@ -0,0 +1,5 @@ +export const versionKey = 'version' as const +export const configurationsKey = 'configurations' as const + +// Must be semver ('conf' library requirement) +export const currentVersion = '2.0.0' diff --git a/src/lib/configurations/index.ts b/src/lib/configurations/index.ts index fbd40e3..b8d2f89 100644 --- a/src/lib/configurations/index.ts +++ b/src/lib/configurations/index.ts @@ -3,21 +3,23 @@ import { omit, kebabCase } from 'lodash' import { deletePod, portForward, - runCloudSqlProxyPod, + runProxyPod, waitForPodReady, } from '../kubectl/pods' import { Configuration, ConfigurationCreateAnswers } from '../types' import { appendOrReplaceByKey, deleteByKey, findByKey } from '../util/array' import { randomString } from '../util/string' import { store } from './store' +import { configurationsKey } from './constants' -const storeKey = 'configurations' as const const searchKey = 'configurationName' as const const excludeProperties = ['googleCloudProject', 'confirmation'] as const export const configurationPath = store.path -export const getConfigurations = (): Configuration[] => store.get(storeKey) +export const getConfigurations = (): Configuration[] => { + return store.get(configurationsKey) +} export const getConfiguration = (name: string): Configuration | undefined => { const configurations = getConfigurations() @@ -27,33 +29,35 @@ export const getConfiguration = (name: string): Configuration | undefined => { export const saveConfiguration = (answers: ConfigurationCreateAnswers): void => { const configuration = omit(answers, excludeProperties) - const configurations = store.get(storeKey) + const configurations = store.get(configurationsKey) appendOrReplaceByKey(configurations, configuration, searchKey) - store.set(storeKey, configurations) + store.set(configurationsKey, configurations) } export const deleteConfiguration = (configuratioName: string): void => { - const configurations = store.get(storeKey) + const configurations = store.get(configurationsKey) deleteByKey(configurations, searchKey, configuratioName) - store.set(storeKey, configurations) + store.set(configurationsKey, configurations) } export const execConfiguration = (configuration: Configuration) => { const pod = { - name: `sql-proxy-${kebabCase(configuration.configurationName)}-${randomString()}`, + name: `${configuration.databaseType}-proxy-${kebabCase(configuration.configurationName)}-${randomString()}`, context: configuration.kubernetesContext, namespace: configuration.kubernetesNamespace, serviceAccount: configuration.kubernetesServiceAccount, - instance: configuration.googleCloudSqlInstance.connectionName, + instance: configuration.databaseInstance.connectionName, localPort: configuration.localPort, - remotePort: configuration.googleCloudSqlInstance.port, + remotePort: configuration.databaseInstance.port, + databaseType: configuration.databaseType, } exitHook(() => { deletePod(pod) }) - runCloudSqlProxyPod(pod) + runProxyPod(pod) + waitForPodReady(pod) portForward(pod) } diff --git a/src/lib/configurations/migrations/migrate-v1-v2.ts b/src/lib/configurations/migrations/migrate-v1-v2.ts new file mode 100644 index 0000000..aa7537f --- /dev/null +++ b/src/lib/configurations/migrations/migrate-v1-v2.ts @@ -0,0 +1,41 @@ +import Conf from 'conf' +import { Configuration } from '../../types' + +interface V1Configuration { + configurationName: string + googleCloudSqlInstance: { + connectionName: string + port: number + } + kubernetesContext: string + kubernetesNamespace: string + kubernetesServiceAccount: string + localPort: number +} + +export type V1Store = Conf<{ + configurations: V1Configuration[] +}> + +type V2Configuration = Configuration + +const migrateConfigurationV1ToV2 = (v1: V1Configuration): V2Configuration => ({ + configurationName: v1.configurationName, + databaseType: 'cloudsql', + databaseInstance: { + connectionName: v1.googleCloudSqlInstance.connectionName, + port: v1.googleCloudSqlInstance.port, + }, + kubernetesContext: v1.kubernetesContext, + kubernetesNamespace: v1.kubernetesNamespace, + kubernetesServiceAccount: v1.kubernetesServiceAccount, + localPort: v1.localPort, +}) + +export const migrateV1ToV2 = (store: V1Store): void => { + const v1Configurations = store.get('configurations') + const v2Configurations: V2Configuration[] = v1Configurations.map(migrateConfigurationV1ToV2) + + // store.set('version', '2') + store.set('configurations', v2Configurations) +} diff --git a/src/lib/configurations/store.ts b/src/lib/configurations/store.ts index 9d98b04..4b12f54 100644 --- a/src/lib/configurations/store.ts +++ b/src/lib/configurations/store.ts @@ -1,14 +1,25 @@ import Conf from 'conf' import { Configuration } from '../types' +import { migrateV1ToV2, V1Store } from './migrations/migrate-v1-v2' +import { currentVersion } from './constants' type Schema = { + version: number configurations: Configuration[] } export const store = new Conf({ configName: 'configurations', projectSuffix: '', + projectVersion: currentVersion, + migrations: { + '2.0.0': store => migrateV1ToV2(store as unknown as V1Store), + }, schema: { + version: { + type: 'string', + default: currentVersion, + }, configurations: { type: 'array', default: [], @@ -16,7 +27,8 @@ export const store = new Conf({ type: 'object', properties: { configurationName: { type: 'string' }, - googleCloudSqlInstance: { + databaseType: { type: 'string', enum: ['cloudsql', 'alloydb'] }, + databaseInstance: { type: 'object', properties: { connectionName: { type: 'string' }, diff --git a/src/lib/gcloud/alloydb-instances.ts b/src/lib/gcloud/alloydb-instances.ts new file mode 100644 index 0000000..4ce1fda --- /dev/null +++ b/src/lib/gcloud/alloydb-instances.ts @@ -0,0 +1,44 @@ +import memoize from 'memoizee' +import { execCommandMultiline } from '../util/exec' + +export type GoogleAlloyDbInstance = { + name: string + region: string + cluster: string + connectionName: string + port: number +} + +const parseInstance = (connectionName: string): GoogleAlloyDbInstance => { + // projects/{project}/locations/{region}/clusters/{cluster}/instances/{instance} + const nameParts = connectionName.split('/') + const region = nameParts[3] + const cluster = nameParts[5] + const instance = nameParts[7] + + return { + name: instance, + region, + cluster, + connectionName, + port: 5432, + } +} + +export const fetchGoogleAlloyDbInstances = memoize( + (project: string): GoogleAlloyDbInstance[] => { + try { + const instances = execCommandMultiline(` + gcloud alloydb instances list \ + --project=${project} \ + --format='csv(name)' \ + --quiet + `) + + return instances.slice(1).map(parseInstance) + } + catch { + return [] + } + }, +) diff --git a/src/lib/kubectl/pods.ts b/src/lib/kubectl/pods.ts index 0d58221..f687217 100644 --- a/src/lib/kubectl/pods.ts +++ b/src/lib/kubectl/pods.ts @@ -1,7 +1,8 @@ import { bold, cyan } from 'chalk' import { execCommand, execCommandAttached } from '../util/exec' +import { DatabaseType } from '../types' -type CloudSqlProxyPod = { +type ProxyPod = { name: string context: string namespace: string @@ -9,9 +10,10 @@ type CloudSqlProxyPod = { instance: string localPort: number remotePort: number + databaseType: DatabaseType } -export const runCloudSqlProxyPod = (pod: CloudSqlProxyPod): string => { +export const runCloudSqlProxyPod = (pod: ProxyPod): string => { return execCommand(` kubectl run \ --image=gcr.io/cloud-sql-connectors/cloud-sql-proxy \ @@ -25,7 +27,30 @@ export const runCloudSqlProxyPod = (pod: CloudSqlProxyPod): string => { `) } -export const deletePod = (pod: CloudSqlProxyPod) => { +export const runAlloyDbProxyPod = (pod: ProxyPod): string => { + return execCommand(` + kubectl run \ + --image=gcr.io/alloydb-connectors/alloydb-auth-proxy \ + --context="${pod.context}" \ + --namespace="${pod.namespace}" \ + --overrides='{"spec": {"serviceAccount": "${pod.serviceAccount}"}}' \ + --annotations="cluster-autoscaler.kubernetes.io/safe-to-evict=true" \ + --labels=app=google-cloud-alloydb \ + ${pod.name} \ + -- --address=0.0.0.0 --port=${pod.remotePort} --auto-iam-authn --structured-logs '${pod.instance}' + `) +} + +export const runProxyPod = (pod: ProxyPod) => { + if (pod.databaseType === 'alloydb') { + runAlloyDbProxyPod(pod) + } + else { + runCloudSqlProxyPod(pod) + } +} + +export const deletePod = (pod: ProxyPod) => { console.log(`Deleting pod '${bold(cyan(pod.name))}'.`) execCommand(` kubectl delete pod ${pod.name} \ @@ -35,7 +60,7 @@ export const deletePod = (pod: CloudSqlProxyPod) => { console.log(`Pod '${bold(cyan(pod.name))}' deleted.`) } -export const waitForPodReady = (pod: CloudSqlProxyPod) => { +export const waitForPodReady = (pod: ProxyPod) => { console.log(`Waiting for pod '${bold(cyan(pod.name))}'.`) execCommand(` kubectl wait pod ${pod.name} \ @@ -47,7 +72,7 @@ export const waitForPodReady = (pod: CloudSqlProxyPod) => { console.log(`Pod '${bold(cyan(pod.name))}' is ready.`) } -export const portForward = (pod: CloudSqlProxyPod) => { +export const portForward = (pod: ProxyPod) => { console.log(`Starting port forwarding to pod '${bold(cyan(pod.name))}'.`) execCommandAttached(` kubectl port-forward ${pod.name} ${pod.localPort}:${pod.remotePort} \ diff --git a/src/lib/types.ts b/src/lib/types.ts index ffa682a..fe5f1ae 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,8 +1,14 @@ -import { GoogleCloudSqlInstance } from './gcloud/sql-instances' +export type DatabaseType = 'cloudsql' | 'alloydb' + +export type DatabaseInstance = { + connectionName: string + port: number +} export type Configuration = { configurationName: string - googleCloudSqlInstance: Pick + databaseType: DatabaseType + databaseInstance: DatabaseInstance kubernetesContext: string kubernetesNamespace: string kubernetesServiceAccount: string