Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions src/commands/configurations/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -17,7 +19,9 @@ export const createConfiguration = async () => {

const answers = await inquirer.prompt<ConfigurationCreateAnswers>([
googleCloudProjectPrompt,
databaseTypePrompt,
googleCloudSqlInstancePrompt,
alloyDbInstancePrompt,
kubernetesContextPrompt,
kubernetesNamespacePrompt,
kubernetesServiceAccountPrompt,
Expand Down
32 changes: 32 additions & 0 deletions src/commands/configurations/prompts/alloydb-instance.ts
Original file line number Diff line number Diff line change
@@ -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',
}
9 changes: 9 additions & 0 deletions src/commands/configurations/prompts/database-type.ts
Original file line number Diff line number Diff line change
@@ -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' },
],
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
73 changes: 67 additions & 6 deletions src/lib/configurations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,70 @@ 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
const excludeProperties = ['googleCloudProject', 'confirmation'] as const

export const configurationPath = store.path

export const getConfigurations = (): Configuration[] => store.get(storeKey)
type LegacyConfiguration = Omit<Configuration, 'databaseType' | 'databaseInstance'> & {
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()
Expand All @@ -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 => {
Expand All @@ -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)
}
9 changes: 8 additions & 1 deletion src/lib/configurations/store.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import Conf from 'conf'
import { Configuration } from '../types'

export const CURRENT_VERSION = 2

type Schema = {
version?: number
configurations: Configuration[]
}

export const store = new Conf<Schema>({
configName: 'configurations',
projectSuffix: '',
schema: {
version: {
type: 'number',
},
configurations: {
type: 'array',
default: [],
items: {
type: 'object',
properties: {
configurationName: { type: 'string' },
googleCloudSqlInstance: {
databaseType: { type: 'string' },
databaseInstance: {
type: 'object',
properties: {
connectionName: { type: 'string' },
Expand Down
65 changes: 65 additions & 0 deletions src/lib/gcloud/alloydb-instances.ts
Original file line number Diff line number Diff line change
@@ -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 []
}
},
)
26 changes: 21 additions & 5 deletions src/lib/kubectl/pods.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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
serviceAccount: string
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 \
Expand All @@ -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} \
Expand All @@ -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} \
Expand All @@ -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} \
Expand Down
10 changes: 9 additions & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -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<GoogleCloudSqlInstance, 'connectionName' | 'port'>
| Pick<AlloyDbInstance, 'connectionName' | 'port'>

export type Configuration = {
configurationName: string
googleCloudSqlInstance: Pick<GoogleCloudSqlInstance, 'connectionName' | 'port'>
databaseType: DatabaseType
databaseInstance: DatabaseInstance
kubernetesContext: string
kubernetesNamespace: string
kubernetesServiceAccount: string
Expand Down
Loading