diff --git a/README.adoc b/README.adoc index 44a990f..19f22b2 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] diff --git a/src/commands/configurations/create.ts b/src/commands/configurations/create.ts index 8c47461..121319f 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 { alloyDbInstancePrompt } from './prompts/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, + alloyDbInstancePrompt, kubernetesContextPrompt, kubernetesNamespacePrompt, kubernetesServiceAccountPrompt, diff --git a/src/commands/configurations/prompts/alloydb-instance.ts b/src/commands/configurations/prompts/alloydb-instance.ts new file mode 100644 index 0000000..159ff23 --- /dev/null +++ b/src/commands/configurations/prompts/alloydb-instance.ts @@ -0,0 +1,32 @@ +import { pick } from 'lodash' +import { + fetchAlloyDbInstances, + AlloyDbInstance, +} 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: AlloyDbInstance) => { + 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 = fetchAlloyDbInstances(answers.googleCloudProject) + const filtered = searchByKey(instances, 'connectionName', input) + + return filtered.map(formatInstance) +}) + +export const alloyDbInstancePrompt = { + type: 'autocomplete', + name: 'databaseInstance', + message: 'Choose AlloyDB instance:', + source, + when: (answers: ConfigurationCreateAnswers) => answers.databaseType === 'alloydb', +} 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-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/index.ts b/src/lib/configurations/index.ts index fbd40e3..7b30381 100644 --- a/src/lib/configurations/index.ts +++ b/src/lib/configurations/index.ts @@ -4,12 +4,13 @@ import { deletePod, portForward, runCloudSqlProxyPod, + runAlloyDbProxyPod, 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 { store, CURRENT_VERSION } from './store' const storeKey = 'configurations' as const const searchKey = 'configurationName' as const @@ -17,7 +18,56 @@ const excludeProperties = ['googleCloudProject', 'confirmation'] as const export const configurationPath = store.path -export const getConfigurations = (): Configuration[] => store.get(storeKey) +type LegacyConfiguration = Omit & { + googleCloudSqlInstance: { + connectionName: string + port: number + } +} + +const isLegacyConfiguration = (config: Configuration | LegacyConfiguration): config is LegacyConfiguration => { + return 'googleCloudSqlInstance' in config && !('databaseType' in config) +} + +const migrateLegacyConfiguration = (legacy: LegacyConfiguration): Configuration => { + return { + configurationName: legacy.configurationName, + databaseType: 'cloudsql', + databaseInstance: { + connectionName: legacy.googleCloudSqlInstance.connectionName, + port: legacy.googleCloudSqlInstance.port, + }, + kubernetesContext: legacy.kubernetesContext, + kubernetesNamespace: legacy.kubernetesNamespace, + kubernetesServiceAccount: legacy.kubernetesServiceAccount, + localPort: legacy.localPort, + } +} + +const migrateConfigurationsIfNeeded = (): void => { + const currentVersion = store.get('version') + const configurations = store.get(storeKey) as (Configuration | LegacyConfiguration)[] + + // Check if migration is needed (no version or configurations in old format) + const needsMigration = !currentVersion || configurations.some(isLegacyConfiguration) + + if (needsMigration) { + const migratedConfigurations = configurations.map((config) => { + if (isLegacyConfiguration(config)) { + return migrateLegacyConfiguration(config) + } + return config + }) + + store.set(storeKey, migratedConfigurations) + store.set('version', CURRENT_VERSION) + } +} + +export const getConfigurations = (): Configuration[] => { + migrateConfigurationsIfNeeded() + return store.get(storeKey) +} export const getConfiguration = (name: string): Configuration | undefined => { const configurations = getConfigurations() @@ -30,6 +80,10 @@ export const saveConfiguration = (answers: ConfigurationCreateAnswers): void => const configurations = store.get(storeKey) appendOrReplaceByKey(configurations, configuration, searchKey) store.set(storeKey, configurations) + + if (!store.get('version')) { + store.set('version', CURRENT_VERSION) + } } export const deleteConfiguration = (configuratioName: string): void => { @@ -40,20 +94,27 @@ export const deleteConfiguration = (configuratioName: string): void => { export const execConfiguration = (configuration: Configuration) => { const pod = { - name: `sql-proxy-${kebabCase(configuration.configurationName)}-${randomString()}`, + name: `${configuration.databaseType === 'alloydb' ? 'alloydb' : 'sql'}-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) + if (configuration.databaseType === 'alloydb') { + runAlloyDbProxyPod(pod) + } + else { + runCloudSqlProxyPod(pod) + } + waitForPodReady(pod) portForward(pod) } diff --git a/src/lib/configurations/store.ts b/src/lib/configurations/store.ts index 9d98b04..92e193d 100644 --- a/src/lib/configurations/store.ts +++ b/src/lib/configurations/store.ts @@ -1,7 +1,10 @@ import Conf from 'conf' import { Configuration } from '../types' +export const CURRENT_VERSION = 2 + type Schema = { + version?: number configurations: Configuration[] } @@ -9,6 +12,9 @@ export const store = new Conf({ configName: 'configurations', projectSuffix: '', schema: { + version: { + type: 'number', + }, configurations: { type: 'array', default: [], @@ -16,7 +22,8 @@ export const store = new Conf({ type: 'object', properties: { configurationName: { type: 'string' }, - googleCloudSqlInstance: { + databaseType: { type: 'string' }, + 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..1846691 --- /dev/null +++ b/src/lib/gcloud/alloydb-instances.ts @@ -0,0 +1,65 @@ +import memoize from 'memoizee' +import { execCommandMultiline } from '../util/exec' +import { parseJson } from '../util/parsers' + +export type AlloyDbInstance = { + name: string + region: string + cluster: string + connectionName: string + port: number +} + +type AlloyDbInstanceData = { + name: string + databaseVersion?: string +} + +const parseInstance = (instanceData: AlloyDbInstanceData): AlloyDbInstance => { + // AlloyDB instance name format: projects/{project}/locations/{region}/clusters/{cluster}/instances/{instance} + const nameParts = instanceData.name.split('/') + const region = nameParts[3] + const cluster = nameParts[5] + const instance = nameParts[7] + + // Connection name format: Full resource path (same as name) + const connectionName = instanceData.name + const port = 5432 + + return { + name: instance, + region, + cluster, + connectionName, + port, + } +} + +export const fetchAlloyDbInstances = memoize( + (project: string): AlloyDbInstance[] => { + try { + const output = execCommandMultiline(` + gcloud alloydb instances list \ + --project=${project} \ + --format=json \ + --quiet + `) + + if (output.length === 0 || output[0].trim() === '') { + return [] + } + + const instances = parseJson(output.join('\n')) + + if (!Array.isArray(instances)) { + return [] + } + + return instances.map(parseInstance) + } + catch { + // If AlloyDB API is not enabled or there are no instances, return empty array + return [] + } + }, +) diff --git a/src/lib/kubectl/pods.ts b/src/lib/kubectl/pods.ts index 0d58221..51b401e 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,21 @@ 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 deletePod = (pod: ProxyPod) => { console.log(`Deleting pod '${bold(cyan(pod.name))}'.`) execCommand(` kubectl delete pod ${pod.name} \ @@ -35,7 +51,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 +63,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..7bd8ad2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,8 +1,16 @@ import { GoogleCloudSqlInstance } from './gcloud/sql-instances' +import { AlloyDbInstance } from './gcloud/alloydb-instances' + +export type DatabaseType = 'cloudsql' | 'alloydb' + +export type DatabaseInstance + = | Pick + | Pick export type Configuration = { configurationName: string - googleCloudSqlInstance: Pick + databaseType: DatabaseType + databaseInstance: DatabaseInstance kubernetesContext: string kubernetesNamespace: string kubernetesServiceAccount: string diff --git a/src/lib/util/parsers.ts b/src/lib/util/parsers.ts index 7787a23..201f082 100644 --- a/src/lib/util/parsers.ts +++ b/src/lib/util/parsers.ts @@ -4,3 +4,5 @@ export const toInt = (value: string): number => parseInt(value, 10) const trueValues = new Set(['true', 'yes', 'on', '1']) export const toBoolean = (value: string): boolean => trueValues.has(value) + +export const parseJson = (value: string): T => JSON.parse(value)