Skip to content
Merged
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
18 changes: 14 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 All @@ -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]
----
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 { 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'
Expand All @@ -17,7 +19,9 @@ export const createConfiguration = async () => {

const answers = await inquirer.prompt<ConfigurationCreateAnswers>([
googleCloudProjectPrompt,
databaseTypePrompt,
googleCloudSqlInstancePrompt,
googleAlloyDbInstancePrompt,
kubernetesContextPrompt,
kubernetesNamespacePrompt,
kubernetesServiceAccountPrompt,
Expand Down
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' },
],
}
32 changes: 32 additions & 0 deletions src/commands/configurations/prompts/google-alloydb-instance.ts
Original file line number Diff line number Diff line change
@@ -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',
}
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',
}
5 changes: 5 additions & 0 deletions src/lib/configurations/constants.ts
Original file line number Diff line number Diff line change
@@ -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'
26 changes: 15 additions & 11 deletions src/lib/configurations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
41 changes: 41 additions & 0 deletions src/lib/configurations/migrations/migrate-v1-v2.ts
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 13 additions & 1 deletion src/lib/configurations/store.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
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<Schema>({
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: [],
items: {
type: 'object',
properties: {
configurationName: { type: 'string' },
googleCloudSqlInstance: {
databaseType: { type: 'string', enum: ['cloudsql', 'alloydb'] },
databaseInstance: {
type: 'object',
properties: {
connectionName: { type: 'string' },
Expand Down
44 changes: 44 additions & 0 deletions src/lib/gcloud/alloydb-instances.ts
Original file line number Diff line number Diff line change
@@ -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 []
}
},
)
35 changes: 30 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,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} \
Expand All @@ -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} \
Expand All @@ -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} \
Expand Down
Loading