Skip to content

Commit 397f6f8

Browse files
committed
feat(alloyDB): add alloyDB
Relates SUITEDEV-39317
1 parent fad093b commit 397f6f8

File tree

10 files changed

+164
-15
lines changed

10 files changed

+164
-15
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: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
deletePod,
55
portForward,
66
runCloudSqlProxyPod,
7+
runAlloyDbProxyPod,
78
waitForPodReady,
89
} from '../kubectl/pods'
910
import { Configuration, ConfigurationCreateAnswers } from '../types'
@@ -40,20 +41,27 @@ export const deleteConfiguration = (configuratioName: string): void => {
4041

4142
export const execConfiguration = (configuration: Configuration) => {
4243
const pod = {
43-
name: `sql-proxy-${kebabCase(configuration.configurationName)}-${randomString()}`,
44+
name: `${configuration.databaseType === 'alloydb' ? 'alloydb' : 'sql'}-proxy-${kebabCase(configuration.configurationName)}-${randomString()}`,
4445
context: configuration.kubernetesContext,
4546
namespace: configuration.kubernetesNamespace,
4647
serviceAccount: configuration.kubernetesServiceAccount,
47-
instance: configuration.googleCloudSqlInstance.connectionName,
48+
instance: configuration.databaseInstance.connectionName,
4849
localPort: configuration.localPort,
49-
remotePort: configuration.googleCloudSqlInstance.port,
50+
remotePort: configuration.databaseInstance.port,
51+
databaseType: configuration.databaseType,
5052
}
5153

5254
exitHook(() => {
5355
deletePod(pod)
5456
})
5557

56-
runCloudSqlProxyPod(pod)
58+
if (configuration.databaseType === 'alloydb') {
59+
runAlloyDbProxyPod(pod)
60+
}
61+
else {
62+
runCloudSqlProxyPod(pod)
63+
}
64+
5765
waitForPodReady(pod)
5866
portForward(pod)
5967
}
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

src/lib/util/parsers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export const toInt = (value: string): number => parseInt(value, 10)
44

55
const trueValues = new Set(['true', 'yes', 'on', '1'])
66
export const toBoolean = (value: string): boolean => trueValues.has(value)
7+
8+
export const parseJson = <T = unknown>(value: string): T => JSON.parse(value)

0 commit comments

Comments
 (0)