Skip to content

Commit 3653ce2

Browse files
epmartiniedosrecki
authored andcommitted
feat!(alloyDB): add alloyDB
BREAKING CHANGE: Configuration file format changes in order to support both Cloud SQL and AlloyDB instances. Migration from v1 to v2 should be automatic on the first run of the v2 of the CLI. Relates SUITEDEV-39317
1 parent bfdd612 commit 3653ce2

File tree

11 files changed

+227
-18
lines changed

11 files changed

+227
-18
lines changed

README.adoc

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
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"]
88
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"]
99

10-
A CLI app which establishes a connection to a private Google Cloud SQL instance and port-forwards it to a local machine.
10+
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.
1111

12-
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.
12+
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.
1313

1414
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.
1515

@@ -43,8 +43,12 @@ _Package_ sections.
4343
* Install https://kubernetes.io/docs/tasks/tools/#kubectl[`kubectl`] tool
4444
* Authenticate to Google Cloud: `gcloud auth login`
4545
* Get GKE cluster credentials: `gcloud container clusters get-credentials`
46-
* 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
47-
* 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`
46+
* https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity[Configure workload identity] in GKE namespace(s) and assign appropriate IAM roles:
47+
** _Cloud SQL Client_ role for Cloud SQL instances
48+
** _AlloyDB Client_ role for AlloyDB instances
49+
* Enable required APIs for project(s):
50+
** Cloud SQL Admin API for Cloud SQL instances: `gcloud services enable sqladmin.googleapis.com --project=$PROJECT`
51+
** AlloyDB API for AlloyDB instances: `gcloud services enable alloydb.googleapis.com --project=$PROJECT`
4852

4953
=== Run
5054
[source,bash]

src/commands/configurations/create.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import inquirer from 'inquirer'
33
import autocomplete from 'inquirer-autocomplete-prompt'
44
import { saveConfiguration } from '../../lib/configurations'
55
import { ConfigurationCreateAnswers } from '../../lib/types'
6+
import { alloyDbInstancePrompt } from './prompts/alloydb-instance'
67
import { configurationNamePrompt } from './prompts/configuration-name'
78
import { confirmationPrompt } from './prompts/confirmation'
9+
import { databaseTypePrompt } from './prompts/database-type'
810
import { googleCloudProjectPrompt } from './prompts/google-cloud-project'
911
import { googleCloudSqlInstancePrompt } from './prompts/google-cloud-sql-instance'
1012
import { kubernetesContextPrompt } from './prompts/kubernetes-context'
@@ -17,7 +19,9 @@ export const createConfiguration = async () => {
1719

1820
const answers = await inquirer.prompt<ConfigurationCreateAnswers>([
1921
googleCloudProjectPrompt,
22+
databaseTypePrompt,
2023
googleCloudSqlInstancePrompt,
24+
alloyDbInstancePrompt,
2125
kubernetesContextPrompt,
2226
kubernetesNamespacePrompt,
2327
kubernetesServiceAccountPrompt,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { pick } from 'lodash'
2+
import {
3+
fetchAlloyDbInstances,
4+
AlloyDbInstance,
5+
} from '../../../lib/gcloud/alloydb-instances'
6+
import { ConfigurationCreateAnswers } from '../../../lib/types'
7+
import { searchByKey } from '../../../lib/util/search'
8+
import { tryCatch } from '../../../lib/util/error'
9+
10+
const formatInstance = (instance: AlloyDbInstance) => {
11+
const { name, region, cluster } = instance
12+
return {
13+
name: `${name} (cluster: ${cluster}, region: ${region})`,
14+
short: name,
15+
value: pick(instance, 'connectionName', 'port'),
16+
}
17+
}
18+
19+
const source = tryCatch((answers: ConfigurationCreateAnswers, input?: string) => {
20+
const instances = fetchAlloyDbInstances(answers.googleCloudProject)
21+
const filtered = searchByKey(instances, 'connectionName', input)
22+
23+
return filtered.map(formatInstance)
24+
})
25+
26+
export const alloyDbInstancePrompt = {
27+
type: 'autocomplete',
28+
name: 'databaseInstance',
29+
message: 'Choose AlloyDB instance:',
30+
source,
31+
when: (answers: ConfigurationCreateAnswers) => answers.databaseType === 'alloydb',
32+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const databaseTypePrompt = {
2+
type: 'list',
3+
name: 'databaseType',
4+
message: 'Choose database type:',
5+
choices: [
6+
{ name: 'Cloud SQL', value: 'cloudsql' },
7+
{ name: 'AlloyDB', value: 'alloydb' },
8+
],
9+
}

src/commands/configurations/prompts/google-cloud-sql-instance.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ const source = tryCatch((answers: ConfigurationCreateAnswers, input?: string) =>
2525

2626
export const googleCloudSqlInstancePrompt = {
2727
type: 'autocomplete',
28-
name: 'googleCloudSqlInstance',
28+
name: 'databaseInstance',
2929
message: 'Choose Google Cloud SQL instance:',
3030
source,
31+
when: (answers: ConfigurationCreateAnswers) => answers.databaseType === 'cloudsql',
3132
}

src/lib/configurations/index.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,70 @@ import {
44
deletePod,
55
portForward,
66
runCloudSqlProxyPod,
7+
runAlloyDbProxyPod,
78
waitForPodReady,
89
} from '../kubectl/pods'
910
import { Configuration, ConfigurationCreateAnswers } from '../types'
1011
import { appendOrReplaceByKey, deleteByKey, findByKey } from '../util/array'
1112
import { randomString } from '../util/string'
12-
import { store } from './store'
13+
import { store, CURRENT_VERSION } from './store'
1314

1415
const storeKey = 'configurations' as const
1516
const searchKey = 'configurationName' as const
1617
const excludeProperties = ['googleCloudProject', 'confirmation'] as const
1718

1819
export const configurationPath = store.path
1920

20-
export const getConfigurations = (): Configuration[] => store.get(storeKey)
21+
type LegacyConfiguration = Omit<Configuration, 'databaseType' | 'databaseInstance'> & {
22+
googleCloudSqlInstance: {
23+
connectionName: string
24+
port: number
25+
}
26+
}
27+
28+
const isLegacyConfiguration = (config: Configuration | LegacyConfiguration): config is LegacyConfiguration => {
29+
return 'googleCloudSqlInstance' in config && !('databaseType' in config)
30+
}
31+
32+
const migrateLegacyConfiguration = (legacy: LegacyConfiguration): Configuration => {
33+
return {
34+
configurationName: legacy.configurationName,
35+
databaseType: 'cloudsql',
36+
databaseInstance: {
37+
connectionName: legacy.googleCloudSqlInstance.connectionName,
38+
port: legacy.googleCloudSqlInstance.port,
39+
},
40+
kubernetesContext: legacy.kubernetesContext,
41+
kubernetesNamespace: legacy.kubernetesNamespace,
42+
kubernetesServiceAccount: legacy.kubernetesServiceAccount,
43+
localPort: legacy.localPort,
44+
}
45+
}
46+
47+
const migrateConfigurationsIfNeeded = (): void => {
48+
const currentVersion = store.get('version')
49+
const configurations = store.get(storeKey) as (Configuration | LegacyConfiguration)[]
50+
51+
// Check if migration is needed (no version or configurations in old format)
52+
const needsMigration = !currentVersion || configurations.some(isLegacyConfiguration)
53+
54+
if (needsMigration) {
55+
const migratedConfigurations = configurations.map((config) => {
56+
if (isLegacyConfiguration(config)) {
57+
return migrateLegacyConfiguration(config)
58+
}
59+
return config
60+
})
61+
62+
store.set(storeKey, migratedConfigurations)
63+
store.set('version', CURRENT_VERSION)
64+
}
65+
}
66+
67+
export const getConfigurations = (): Configuration[] => {
68+
migrateConfigurationsIfNeeded()
69+
return store.get(storeKey)
70+
}
2171

2272
export const getConfiguration = (name: string): Configuration | undefined => {
2373
const configurations = getConfigurations()
@@ -30,6 +80,10 @@ export const saveConfiguration = (answers: ConfigurationCreateAnswers): void =>
3080
const configurations = store.get(storeKey)
3181
appendOrReplaceByKey(configurations, configuration, searchKey)
3282
store.set(storeKey, configurations)
83+
84+
if (!store.get('version')) {
85+
store.set('version', CURRENT_VERSION)
86+
}
3387
}
3488

3589
export const deleteConfiguration = (configuratioName: string): void => {
@@ -40,20 +94,27 @@ export const deleteConfiguration = (configuratioName: string): void => {
4094

4195
export const execConfiguration = (configuration: Configuration) => {
4296
const pod = {
43-
name: `sql-proxy-${kebabCase(configuration.configurationName)}-${randomString()}`,
97+
name: `${configuration.databaseType === 'alloydb' ? 'alloydb' : 'sql'}-proxy-${kebabCase(configuration.configurationName)}-${randomString()}`,
4498
context: configuration.kubernetesContext,
4599
namespace: configuration.kubernetesNamespace,
46100
serviceAccount: configuration.kubernetesServiceAccount,
47-
instance: configuration.googleCloudSqlInstance.connectionName,
101+
instance: configuration.databaseInstance.connectionName,
48102
localPort: configuration.localPort,
49-
remotePort: configuration.googleCloudSqlInstance.port,
103+
remotePort: configuration.databaseInstance.port,
104+
databaseType: configuration.databaseType,
50105
}
51106

52107
exitHook(() => {
53108
deletePod(pod)
54109
})
55110

56-
runCloudSqlProxyPod(pod)
111+
if (configuration.databaseType === 'alloydb') {
112+
runAlloyDbProxyPod(pod)
113+
}
114+
else {
115+
runCloudSqlProxyPod(pod)
116+
}
117+
57118
waitForPodReady(pod)
58119
portForward(pod)
59120
}

src/lib/configurations/store.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
import Conf from 'conf'
22
import { Configuration } from '../types'
33

4+
export const CURRENT_VERSION = 2
5+
46
type Schema = {
7+
version?: number
58
configurations: Configuration[]
69
}
710

811
export const store = new Conf<Schema>({
912
configName: 'configurations',
1013
projectSuffix: '',
1114
schema: {
15+
version: {
16+
type: 'number',
17+
},
1218
configurations: {
1319
type: 'array',
1420
default: [],
1521
items: {
1622
type: 'object',
1723
properties: {
1824
configurationName: { type: 'string' },
19-
googleCloudSqlInstance: {
25+
databaseType: { type: 'string' },
26+
databaseInstance: {
2027
type: 'object',
2128
properties: {
2229
connectionName: { type: 'string' },
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import memoize from 'memoizee'
2+
import { execCommandMultiline } from '../util/exec'
3+
import { parseJson } from '../util/parsers'
4+
5+
export type AlloyDbInstance = {
6+
name: string
7+
region: string
8+
cluster: string
9+
connectionName: string
10+
port: number
11+
}
12+
13+
type AlloyDbInstanceData = {
14+
name: string
15+
databaseVersion?: string
16+
}
17+
18+
const parseInstance = (instanceData: AlloyDbInstanceData): AlloyDbInstance => {
19+
// AlloyDB instance name format: projects/{project}/locations/{region}/clusters/{cluster}/instances/{instance}
20+
const nameParts = instanceData.name.split('/')
21+
const region = nameParts[3]
22+
const cluster = nameParts[5]
23+
const instance = nameParts[7]
24+
25+
// Connection name format: Full resource path (same as name)
26+
const connectionName = instanceData.name
27+
const port = 5432
28+
29+
return {
30+
name: instance,
31+
region,
32+
cluster,
33+
connectionName,
34+
port,
35+
}
36+
}
37+
38+
export const fetchAlloyDbInstances = memoize(
39+
(project: string): AlloyDbInstance[] => {
40+
try {
41+
const output = execCommandMultiline(`
42+
gcloud alloydb instances list \
43+
--project=${project} \
44+
--format=json \
45+
--quiet
46+
`)
47+
48+
if (output.length === 0 || output[0].trim() === '') {
49+
return []
50+
}
51+
52+
const instances = parseJson(output.join('\n'))
53+
54+
if (!Array.isArray(instances)) {
55+
return []
56+
}
57+
58+
return instances.map(parseInstance)
59+
}
60+
catch {
61+
// If AlloyDB API is not enabled or there are no instances, return empty array
62+
return []
63+
}
64+
},
65+
)

src/lib/kubectl/pods.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { bold, cyan } from 'chalk'
22
import { execCommand, execCommandAttached } from '../util/exec'
3+
import { DatabaseType } from '../types'
34

4-
type CloudSqlProxyPod = {
5+
type ProxyPod = {
56
name: string
67
context: string
78
namespace: string
89
serviceAccount: string
910
instance: string
1011
localPort: number
1112
remotePort: number
13+
databaseType?: DatabaseType
1214
}
1315

14-
export const runCloudSqlProxyPod = (pod: CloudSqlProxyPod): string => {
16+
export const runCloudSqlProxyPod = (pod: ProxyPod): string => {
1517
return execCommand(`
1618
kubectl run \
1719
--image=gcr.io/cloud-sql-connectors/cloud-sql-proxy \
@@ -25,7 +27,21 @@ export const runCloudSqlProxyPod = (pod: CloudSqlProxyPod): string => {
2527
`)
2628
}
2729

28-
export const deletePod = (pod: CloudSqlProxyPod) => {
30+
export const runAlloyDbProxyPod = (pod: ProxyPod): string => {
31+
return execCommand(`
32+
kubectl run \
33+
--image=gcr.io/alloydb-connectors/alloydb-auth-proxy \
34+
--context="${pod.context}" \
35+
--namespace="${pod.namespace}" \
36+
--overrides='{"spec": {"serviceAccount": "${pod.serviceAccount}"}}' \
37+
--annotations="cluster-autoscaler.kubernetes.io/safe-to-evict=true" \
38+
--labels=app=google-cloud-alloydb \
39+
${pod.name} \
40+
-- --address=0.0.0.0 --port=${pod.remotePort} --auto-iam-authn --structured-logs '${pod.instance}'
41+
`)
42+
}
43+
44+
export const deletePod = (pod: ProxyPod) => {
2945
console.log(`Deleting pod '${bold(cyan(pod.name))}'.`)
3046
execCommand(`
3147
kubectl delete pod ${pod.name} \
@@ -35,7 +51,7 @@ export const deletePod = (pod: CloudSqlProxyPod) => {
3551
console.log(`Pod '${bold(cyan(pod.name))}' deleted.`)
3652
}
3753

38-
export const waitForPodReady = (pod: CloudSqlProxyPod) => {
54+
export const waitForPodReady = (pod: ProxyPod) => {
3955
console.log(`Waiting for pod '${bold(cyan(pod.name))}'.`)
4056
execCommand(`
4157
kubectl wait pod ${pod.name} \
@@ -47,7 +63,7 @@ export const waitForPodReady = (pod: CloudSqlProxyPod) => {
4763
console.log(`Pod '${bold(cyan(pod.name))}' is ready.`)
4864
}
4965

50-
export const portForward = (pod: CloudSqlProxyPod) => {
66+
export const portForward = (pod: ProxyPod) => {
5167
console.log(`Starting port forwarding to pod '${bold(cyan(pod.name))}'.`)
5268
execCommandAttached(`
5369
kubectl port-forward ${pod.name} ${pod.localPort}:${pod.remotePort} \

src/lib/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import { GoogleCloudSqlInstance } from './gcloud/sql-instances'
2+
import { AlloyDbInstance } from './gcloud/alloydb-instances'
3+
4+
export type DatabaseType = 'cloudsql' | 'alloydb'
5+
6+
export type DatabaseInstance
7+
= | Pick<GoogleCloudSqlInstance, 'connectionName' | 'port'>
8+
| Pick<AlloyDbInstance, 'connectionName' | 'port'>
29

310
export type Configuration = {
411
configurationName: string
5-
googleCloudSqlInstance: Pick<GoogleCloudSqlInstance, 'connectionName' | 'port'>
12+
databaseType: DatabaseType
13+
databaseInstance: DatabaseInstance
614
kubernetesContext: string
715
kubernetesNamespace: string
816
kubernetesServiceAccount: string

0 commit comments

Comments
 (0)