From 8434d03e32ebbb4153d80827ec0998b73e8402f3 Mon Sep 17 00:00:00 2001 From: Jonathan Hess Date: Mon, 24 Feb 2025 16:55:57 -0700 Subject: [PATCH 1/2] test: E2E tests for domain name --- .github/workflows/tests.yml | 24 +++++- system-test/pg-connect.cjs | 144 ++++++++++++++++++++++++++++-------- system-test/pg-connect.mjs | 144 ++++++++++++++++++++++++++++-------- system-test/pg-connect.ts | 115 ++++++++++++++++++++++++---- 4 files changed, 343 insertions(+), 84 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6ef1a98e..3cf9150b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -154,13 +154,15 @@ jobs: MYSQL_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/MYSQL_DB POSTGRES_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CONNECTION_NAME POSTGRES_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER - POSTGRES_IAM_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE + POSTGRES_USER_IAM_NODE:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE POSTGRES_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_PASS POSTGRES_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_DB POSTGRES_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_CONNECTION_NAME POSTGRES_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_PASS POSTGRES_CUSTOMER_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_CONNECTION_NAME POSTGRES_CUSTOMER_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_PASS + POSTGRES_CUSTOMER_CAS_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_DOMAIN_NAME + POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME SQLSERVER_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_CONNECTION_NAME SQLSERVER_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_USER SQLSERVER_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_PASS @@ -181,13 +183,15 @@ jobs: MYSQL_DB: "${{ steps.secrets.outputs.MYSQL_DB }}" POSTGRES_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CONNECTION_NAME }}" POSTGRES_USER: "${{ steps.secrets.outputs.POSTGRES_USER }}" - POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_IAM_USER }}" + POSTGRES_USER_IAM_NODE: "${{ steps.secrets.outputs.POSTGRES_USER_IAM_NODE }}" POSTGRES_PASS: "${{ steps.secrets.outputs.POSTGRES_PASS }}" POSTGRES_DB: "${{ steps.secrets.outputs.POSTGRES_DB }}" POSTGRES_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CAS_CONNECTION_NAME }}" POSTGRES_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CAS_PASS }}" POSTGRES_CUSTOMER_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_CONNECTION_NAME }}" POSTGRES_CUSTOMER_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_PASS }}" + POSTGRES_CUSTOMER_CAS_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_DOMAIN_NAME }}" + POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME }}" SQLSERVER_CONNECTION_NAME: "${{ steps.secrets.outputs.SQLSERVER_CONNECTION_NAME }}" SQLSERVER_USER: "${{ steps.secrets.outputs.SQLSERVER_USER }}" SQLSERVER_PASS: "${{ steps.secrets.outputs.SQLSERVER_PASS }}" @@ -275,9 +279,15 @@ jobs: MYSQL_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/MYSQL_DB POSTGRES_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CONNECTION_NAME POSTGRES_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER - POSTGRES_IAM_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE + POSTGRES_USER_IAM_NODE:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE POSTGRES_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_PASS POSTGRES_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_DB + POSTGRES_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_CONNECTION_NAME + POSTGRES_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_PASS + POSTGRES_CUSTOMER_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_CONNECTION_NAME + POSTGRES_CUSTOMER_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_PASS + POSTGRES_CUSTOMER_CAS_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_DOMAIN_NAME + POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME SQLSERVER_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_CONNECTION_NAME SQLSERVER_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_USER SQLSERVER_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_PASS @@ -292,9 +302,15 @@ jobs: MYSQL_DB: "${{ steps.secrets.outputs.MYSQL_DB }}" POSTGRES_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CONNECTION_NAME }}" POSTGRES_USER: "${{ steps.secrets.outputs.POSTGRES_USER }}" - POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_IAM_USER }}" + POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_USER_IAM_NODE }}" POSTGRES_PASS: "${{ steps.secrets.outputs.POSTGRES_PASS }}" POSTGRES_DB: "${{ steps.secrets.outputs.POSTGRES_DB }}" + POSTGRES_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CAS_CONNECTION_NAME }}" + POSTGRES_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CAS_PASS }}" + POSTGRES_CUSTOMER_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_CONNECTION_NAME }}" + POSTGRES_CUSTOMER_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_PASS }}" + POSTGRES_CUSTOMER_CAS_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_DOMAIN_NAME }}" + POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME }}" SQLSERVER_CONNECTION_NAME: "${{ steps.secrets.outputs.SQLSERVER_CONNECTION_NAME }}" SQLSERVER_USER: "${{ steps.secrets.outputs.SQLSERVER_USER }}" SQLSERVER_PASS: "${{ steps.secrets.outputs.SQLSERVER_PASS }}" diff --git a/system-test/pg-connect.cjs b/system-test/pg-connect.cjs index cae81e51..decebba7 100644 --- a/system-test/pg-connect.cjs +++ b/system-test/pg-connect.cjs @@ -20,50 +20,57 @@ const {Client} = pg; t.test('open connection and retrieves standard pg tables', async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ - instanceConnectionName: process.env.POSTGRES_CONNECTION_NAME, - ipType: 'PUBLIC', - authType: 'PASSWORD', + instanceConnectionName: String(process.env.POSTGRES_CONNECTION_NAME), }); const client = new Client({ ...clientOpts, - user: process.env.POSTGRES_USER, - password: process.env.POSTGRES_PASS, - database: process.env.POSTGRES_DB, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_PASS), + database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); }); t.test('open IAM connection and retrieves standard pg tables', async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ - instanceConnectionName: process.env.POSTGRES_CONNECTION_NAME, - ipType: 'PUBLIC', - authType: 'IAM', + instanceConnectionName: String(process.env.POSTGRES_CONNECTION_NAME), + ipType: "PUBLIC", + authType: "IAM", }); const client = new Client({ ...clientOpts, - user: process.env.POSTGRES_IAM_USER, - database: process.env.POSTGRES_DB, + user: String(process.env.POSTGRES_USER_IAM_NODE), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } }); - client.connect(); + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); }); t.test( @@ -71,26 +78,29 @@ t.test( async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ - instanceConnectionName: process.env.POSTGRES_CAS_CONNECTION_NAME, - ipType: 'PUBLIC', - authType: 'PASSWORD', + instanceConnectionName: String(process.env.POSTGRES_CAS_CONNECTION_NAME), }); const client = new Client({ ...clientOpts, - user: process.env.POSTGRES_USER, - password: process.env.POSTGRES_CAS_PASS, - database: process.env.POSTGRES_DB, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CAS_PASS), + database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); } ); @@ -109,13 +119,83 @@ t.test( password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - await client.end(); - connector.close(); } ); + +t.test( + 'open connection to Domain Name instance retrieves standard pg tables', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + domainName: String(process.env.POSTGRES_CUSTOMER_CAS_DOMAIN_NAME), + }); + const client = new Client({ + ...clientOpts, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); + const { + rows: [result], + } = await client.query('SELECT NOW();'); + const returnedDate = result['now']; + t.ok(returnedDate.getTime(), 'should have valid returned date object'); + } +); + +t.test( + 'open connection to Domain Name invalid domain name rejects connection', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + domainName: String(process.env.POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME), + }); + const client = new Client({ + ...clientOpts, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + console.log('Ending...'); + try { + await client.end(); + } finally { + connector.close(); + console.log('Ended...'); + } + }); + try { + await client.connect(); + t.fail('Should throw exception'); + } catch (e) { + t.same(e.code, 'ERR_TLS_CERT_ALTNAME_INVALID'); + } finally { + t.end(); + } + } +); + diff --git a/system-test/pg-connect.mjs b/system-test/pg-connect.mjs index c4095262..5272f79e 100644 --- a/system-test/pg-connect.mjs +++ b/system-test/pg-connect.mjs @@ -17,53 +17,61 @@ import pg from 'pg'; import {Connector} from '@google-cloud/cloud-sql-connector'; const {Client} = pg; + t.test('open connection and retrieves standard pg tables', async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ - instanceConnectionName: process.env.POSTGRES_CONNECTION_NAME, - ipType: 'PUBLIC', - authType: 'PASSWORD', + instanceConnectionName: String(process.env.POSTGRES_CONNECTION_NAME), }); const client = new Client({ ...clientOpts, - user: process.env.POSTGRES_USER, - password: process.env.POSTGRES_PASS, - database: process.env.POSTGRES_DB, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_PASS), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } }); - client.connect(); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); }); t.test('open IAM connection and retrieves standard pg tables', async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ - instanceConnectionName: process.env.POSTGRES_CONNECTION_NAME, - ipType: 'PUBLIC', - authType: 'IAM', + instanceConnectionName: String(process.env.POSTGRES_CONNECTION_NAME), + ipType: "PUBLIC", + authType: "IAM", }); const client = new Client({ ...clientOpts, - user: process.env.POSTGRES_IAM_USER, - database: process.env.POSTGRES_DB, + user: String(process.env.POSTGRES_USER_IAM_NODE), + database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); }); t.test( @@ -71,26 +79,29 @@ t.test( async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ - instanceConnectionName: process.env.POSTGRES_CAS_CONNECTION_NAME, - ipType: 'PUBLIC', - authType: 'PASSWORD', + instanceConnectionName: String(process.env.POSTGRES_CAS_CONNECTION_NAME), }); const client = new Client({ ...clientOpts, - user: process.env.POSTGRES_USER, - password: process.env.POSTGRES_CAS_PASS, - database: process.env.POSTGRES_DB, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CAS_PASS), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } }); - client.connect(); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); } ); @@ -109,13 +120,82 @@ t.test( password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - await client.end(); - connector.close(); + } +); + +t.test( + 'open connection to Domain Name instance retrieves standard pg tables', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + domainName: String(process.env.POSTGRES_CUSTOMER_CAS_DOMAIN_NAME), + }); + const client = new Client({ + ...clientOpts, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); + const { + rows: [result], + } = await client.query('SELECT NOW();'); + const returnedDate = result['now']; + t.ok(returnedDate.getTime(), 'should have valid returned date object'); + } +); + +t.test( + 'open connection to Domain Name invalid domain name rejects connection', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + domainName: String(process.env.POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME), + }); + const client = new Client({ + ...clientOpts, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + console.log('Ending...'); + try { + await client.end(); + } finally { + connector.close(); + console.log('Ended...'); + } + }); + try { + await client.connect(); + t.fail('Should throw exception'); + } catch (e) { + t.same(e.code, 'ERR_TLS_CERT_ALTNAME_INVALID'); + } finally { + t.end(); + } } ); diff --git a/system-test/pg-connect.ts b/system-test/pg-connect.ts index c7b3abee..55936300 100644 --- a/system-test/pg-connect.ts +++ b/system-test/pg-connect.ts @@ -32,16 +32,21 @@ t.test('open connection and retrieves standard pg tables', async t => { password: String(process.env.POSTGRES_PASS), database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); }); t.test('open IAM connection and retrieves standard pg tables', async t => { @@ -53,19 +58,23 @@ t.test('open IAM connection and retrieves standard pg tables', async t => { }); const client = new Client({ ...clientOpts, - user: String(process.env.POSTGRES_IAM_USER), + user: String(process.env.POSTGRES_USER_IAM_NODE), database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); }); t.test( @@ -81,16 +90,21 @@ t.test( password: String(process.env.POSTGRES_CAS_PASS), database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - - await client.end(); - connector.close(); } ); @@ -109,13 +123,82 @@ t.test( password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), database: String(process.env.POSTGRES_DB), }); - client.connect(); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); + const { + rows: [result], + } = await client.query('SELECT NOW();'); + const returnedDate = result['now']; + t.ok(returnedDate.getTime(), 'should have valid returned date object'); + } +); + +t.test( + 'open connection to Domain Name instance retrieves standard pg tables', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + domainName: String(process.env.POSTGRES_CUSTOMER_CAS_DOMAIN_NAME), + }); + const client = new Client({ + ...clientOpts, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + try { + await client.end(); + } finally { + connector.close(); + } + }); + + await client.connect(); const { rows: [result], } = await client.query('SELECT NOW();'); const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); - await client.end(); - connector.close(); + } +); + +t.test( + 'open connection to Domain Name invalid domain name rejects connection', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + domainName: String(process.env.POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME), + }); + const client = new Client({ + ...clientOpts, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CUSTOMER_CAS_PASS), + database: String(process.env.POSTGRES_DB), + }); + t.after(async () => { + console.log('Ending...'); + try { + await client.end(); + } finally { + connector.close(); + console.log('Ended...'); + } + }); + try { + await client.connect(); + t.fail('Should throw exception'); + } catch (e) { + t.same(e.code, 'ERR_TLS_CERT_ALTNAME_INVALID'); + } finally { + t.end(); + } } ); From 4c9d1c4d732c010e860a336b2f96c28a5a855651 Mon Sep 17 00:00:00 2001 From: Jonathan Hess Date: Thu, 6 Mar 2025 12:58:29 -0700 Subject: [PATCH 2/2] feat: Periodicaly check if domain name changed and close connections to old database. wip: periodic check for domain change wip: periodic checks wip: close sockets on instance closed wip: readme for below --- .github/workflows/tests.yml | 8 +-- README.md | 82 +++++++++++++++++++++++++++++++ src/cloud-sql-instance.ts | 73 ++++++++++++++++++++++++++- src/connector.ts | 6 +++ system-test/pg-connect.cjs | 24 ++++----- system-test/pg-connect.mjs | 6 +-- system-test/pg-connect.ts | 2 +- test/cloud-sql-instance.ts | 14 ++++++ test/connector.ts | 98 +++++++++++++++++++++++++++++++++++-- 9 files changed, 289 insertions(+), 24 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3cf9150b..da51b98f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -154,7 +154,7 @@ jobs: MYSQL_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/MYSQL_DB POSTGRES_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CONNECTION_NAME POSTGRES_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER - POSTGRES_USER_IAM_NODE:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE + POSTGRES_IAM_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE POSTGRES_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_PASS POSTGRES_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_DB POSTGRES_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_CONNECTION_NAME @@ -183,7 +183,7 @@ jobs: MYSQL_DB: "${{ steps.secrets.outputs.MYSQL_DB }}" POSTGRES_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CONNECTION_NAME }}" POSTGRES_USER: "${{ steps.secrets.outputs.POSTGRES_USER }}" - POSTGRES_USER_IAM_NODE: "${{ steps.secrets.outputs.POSTGRES_USER_IAM_NODE }}" + POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_IAM_USER }}" POSTGRES_PASS: "${{ steps.secrets.outputs.POSTGRES_PASS }}" POSTGRES_DB: "${{ steps.secrets.outputs.POSTGRES_DB }}" POSTGRES_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CAS_CONNECTION_NAME }}" @@ -279,7 +279,7 @@ jobs: MYSQL_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/MYSQL_DB POSTGRES_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CONNECTION_NAME POSTGRES_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER - POSTGRES_USER_IAM_NODE:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE + POSTGRES_IAM_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE POSTGRES_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_PASS POSTGRES_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_DB POSTGRES_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_CONNECTION_NAME @@ -302,7 +302,7 @@ jobs: MYSQL_DB: "${{ steps.secrets.outputs.MYSQL_DB }}" POSTGRES_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CONNECTION_NAME }}" POSTGRES_USER: "${{ steps.secrets.outputs.POSTGRES_USER }}" - POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_USER_IAM_NODE }}" + POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_IAM_USER }}" POSTGRES_PASS: "${{ steps.secrets.outputs.POSTGRES_PASS }}" POSTGRES_DB: "${{ steps.secrets.outputs.POSTGRES_DB }}" POSTGRES_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CAS_CONNECTION_NAME }}" diff --git a/README.md b/README.md index e1abf9f8..6bd447e2 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,88 @@ variables. Here is a quick reference to supported values and their effect: - `GOOGLE_CLOUD_QUOTA_PROJECT`: Used to set a custom quota project to Cloud SQL APIs when defined. +## Using DNS domain names to identify instances + +The connector can be configured to use DNS to look up an instance. This would +allow you to configure your application to connect to a database instance, and +centrally configure which instance in your DNS zone. + +### Configure your DNS Records + +Add a DNS TXT record for the Cloud SQL instance to a **private** DNS server +or a private Google Cloud DNS Zone used by your application. + +**Note:** You are strongly discouraged from adding DNS records for your +Cloud SQL instances to a public DNS server. This would allow anyone on the +internet to discover the Cloud SQL instance name. + +For example: suppose you wanted to use the domain name +`prod-db.mycompany.example.com` to connect to your database instance +`my-project:region:my-instance`. You would create the following DNS record: + +- Record type: `TXT` +- Name: `prod-db.mycompany.example.com` – This is the domain name used by the application +- Value: `my-project:region:my-instance` – This is the instance name + +### Configure the connector + +Configure the connector as described above, replacing the connector ID with +the DNS name. + +Adapting the MySQL + database/sql example above: + +```js +import mysql from 'mysql2/promise'; +import {Connector} from '@google-cloud/cloud-sql-connector'; + +const connector = new Connector(); +const clientOpts = await connector.getOptions({ + domainName: 'prod-db.mycompany.example.com', + ipType: 'PUBLIC', +}); + +const pool = await mysql.createPool({ + ...clientOpts, + user: 'my-user', + password: 'my-password', + database: 'db-name', +}); +const conn = await pool.getConnection(); +const [result] = await conn.query(`SELECT NOW();`); +console.table(result); // prints returned time value from server + +await pool.end(); +connector.close(); +``` + +## Automatic failover using DNS domain names + +For example: suppose application is configured to connect using the +domain name `prod-db.mycompany.example.com`. Initially the private DNS +zone has a TXT record with the value `my-project:region:my-instance`. The +application establishes connections to the `my-project:region:my-instance` +Cloud SQL instance. Configure the connector using the `domainName` option: + +Then, to reconfigure the application to use a different database +instance, change the value of the `prod-db.mycompany.example.com` DNS record +from `my-project:region:my-instance` to `my-project:other-region:my-instance-2` + +The connector inside the application detects the change to this +DNS record. Now, when the application connects to its database using the +domain name `prod-db.mycompany.example.com`, it will connect to the +`my-project:other-region:my-instance-2` Cloud SQL instance. + +The connector will automatically close all existing connections to +`my-project:region:my-instance`. This will force the connection pools to +establish new connections. Also, it may cause database queries in progress +to fail. + +The connector will poll for changes to the DNS name every 30 seconds by default. +You may configure the frequency of the connections using the Connector's +`failoverPeriod` option. When this is set to 0, the connector will disable +polling and only check if the DNS record changed when it is creating a new +connection. + ## Support policy ### Major version lifecycle diff --git a/src/cloud-sql-instance.ts b/src/cloud-sql-instance.ts index a830acf3..6c11b2d6 100644 --- a/src/cloud-sql-instance.ts +++ b/src/cloud-sql-instance.ts @@ -14,13 +14,26 @@ import {IpAddressTypes, selectIpAddress} from './ip-addresses'; import {InstanceConnectionInfo} from './instance-connection-info'; -import {resolveInstanceName} from './parse-instance-connection-name'; +import { + isSameInstance, + resolveInstanceName, +} from './parse-instance-connection-name'; import {InstanceMetadata} from './sqladmin-fetcher'; import {generateKeys} from './crypto'; import {RSAKeys} from './rsa-keys'; import {SslCert} from './ssl-cert'; import {getRefreshInterval, isExpirationTimeValid} from './time'; import {AuthTypes} from './auth-types'; +import {CloudSQLConnectorError} from './errors'; + +// Private types that describe exactly the methods +// needed from tls.Socket to be able to close +// sockets when the DNS Name changes. +type EventFn = () => void; +type DestroyableSocket = { + destroy: (error?: Error) => void; + once: (name: string, handler: EventFn) => void; +}; interface Fetcher { getInstanceMetadata({ @@ -42,6 +55,7 @@ interface CloudSQLInstanceOptions { ipType: IpAddressTypes; limitRateInterval?: number; sqlAdminFetcher: Fetcher; + failoverPeriod?: number; } interface RefreshResult { @@ -74,9 +88,13 @@ export class CloudSQLInstance { // The ongoing refresh promise is referenced by the `next` property private next?: Promise; private scheduledRefreshID?: ReturnType | null = undefined; + private checkDomainID?: ReturnType | null = undefined; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ private throttle?: any; private closed = false; + private failoverPeriod: number; + private sockets = new Set(); + public readonly instanceInfo: InstanceConnectionInfo; public ephemeralCert?: SslCert; public host?: string; @@ -98,6 +116,7 @@ export class CloudSQLInstance { this.ipType = options.ipType || IpAddressTypes.PUBLIC; this.limitRateInterval = options.limitRateInterval || 30 * 1000; // 30 seconds this.sqlAdminFetcher = options.sqlAdminFetcher; + this.failoverPeriod = options.failoverPeriod || 30 * 1000; // 30 seconds } // p-throttle library has to be initialized in an async scope in order to @@ -153,6 +172,19 @@ export class CloudSQLInstance { return Promise.reject('closed'); } + // Lazy instantiation of the checkDomain interval on the first refresh + // This avoids issues with test cases that instantiate a CloudSqlInstance. + // If failoverPeriod is 0 (or negative) don't check for DNS updates. + if ( + this?.instanceInfo?.domainName && + !this.checkDomainID && + this.failoverPeriod > 0 + ) { + this.checkDomainID = setInterval(() => { + this.checkDomainChanged(); + }, this.failoverPeriod); + } + const currentRefreshId = this.scheduledRefreshID; // Since forceRefresh might be invoked during an ongoing refresh @@ -312,9 +344,48 @@ export class CloudSQLInstance { close(): void { this.closed = true; this.cancelRefresh(); + if (this.checkDomainID) { + clearInterval(this.checkDomainID); + this.checkDomainID = null; + } + for (const socket of this.sockets) { + socket.destroy( + new CloudSQLConnectorError({ + code: 'ERRCLOSED', + message: 'The connector was closed.', + }) + ); + } } isClosed(): boolean { return this.closed; } + async checkDomainChanged() { + if (!this.instanceInfo.domainName) { + return; + } + + const newInfo = await resolveInstanceName( + undefined, + this.instanceInfo.domainName + ); + if (!isSameInstance(this.instanceInfo, newInfo)) { + // Domain name changed. Close and remove, then create a new map entry. + this.close(); + } + } + addSocket(socket: DestroyableSocket) { + if (!this.instanceInfo.domainName) { + // This was not connected by domain name. Ignore all sockets. + return; + } + + // Add the socket to the list + this.sockets.add(socket); + // When the socket is closed, remove it. + socket.once('closed', () => { + this.sockets.delete(socket); + }); + } } diff --git a/src/connector.ts b/src/connector.ts index f7159a2c..30307c9c 100644 --- a/src/connector.ts +++ b/src/connector.ts @@ -44,6 +44,7 @@ export declare interface ConnectionOptions { ipType?: IpAddressTypes; instanceConnectionName: string; domainName?: string; + failoverPeriod?: number; limitRateInterval?: number; } @@ -129,6 +130,7 @@ class CloudSQLInstanceMap extends Map { const entry = this.get(key); if (entry) { if (entry.isResolved()) { + await entry.instance?.checkDomainChanged(); if (!entry.instance?.isClosed()) { // The instance is open and the domain has not changed. // use the cached instance. @@ -154,6 +156,7 @@ class CloudSQLInstanceMap extends Map { ipType: opts.ipType || IpAddressTypes.PUBLIC, limitRateInterval: opts.limitRateInterval || 30 * 1000, // 30 sec sqlAdminFetcher: this.sqlAdminFetcher, + failoverPeriod: opts.failoverPeriod, }); this.set(key, new CacheEntry(promise)); @@ -257,6 +260,9 @@ export class Connector { tlsSocket.once('secureConnect', async () => { cloudSqlInstance.setEstablishedConnection(); }); + + cloudSqlInstance.addSocket(tlsSocket); + return tlsSocket; } diff --git a/system-test/pg-connect.cjs b/system-test/pg-connect.cjs index decebba7..ddd86157 100644 --- a/system-test/pg-connect.cjs +++ b/system-test/pg-connect.cjs @@ -20,13 +20,13 @@ const {Client} = pg; t.test('open connection and retrieves standard pg tables', async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ - instanceConnectionName: String(process.env.POSTGRES_CONNECTION_NAME), + instanceConnectionName: process.env.POSTGRES_CONNECTION_NAME, }); const client = new Client({ ...clientOpts, - user: String(process.env.POSTGRES_USER), - password: String(process.env.POSTGRES_PASS), - database: String(process.env.POSTGRES_DB), + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASS, + database: process.env.POSTGRES_DB, }); t.after(async () => { try { @@ -48,14 +48,14 @@ t.test('open connection and retrieves standard pg tables', async t => { t.test('open IAM connection and retrieves standard pg tables', async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ - instanceConnectionName: String(process.env.POSTGRES_CONNECTION_NAME), - ipType: "PUBLIC", - authType: "IAM", + instanceConnectionName: process.env.POSTGRES_CONNECTION_NAME, + ipType: 'PUBLIC', + authType: 'IAM', }); const client = new Client({ ...clientOpts, - user: String(process.env.POSTGRES_USER_IAM_NODE), - database: String(process.env.POSTGRES_DB), + user: process.env.POSTGRES_IAM_USER, + database: process.env.POSTGRES_DB, }); t.after(async () => { try { @@ -82,9 +82,9 @@ t.test( }); const client = new Client({ ...clientOpts, - user: String(process.env.POSTGRES_USER), - password: String(process.env.POSTGRES_CAS_PASS), - database: String(process.env.POSTGRES_DB), + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_CAS_PASS, + database: process.env.POSTGRES_DB, }); t.after(async () => { try { diff --git a/system-test/pg-connect.mjs b/system-test/pg-connect.mjs index 5272f79e..6860bdc7 100644 --- a/system-test/pg-connect.mjs +++ b/system-test/pg-connect.mjs @@ -50,12 +50,12 @@ t.test('open IAM connection and retrieves standard pg tables', async t => { const connector = new Connector(); const clientOpts = await connector.getOptions({ instanceConnectionName: String(process.env.POSTGRES_CONNECTION_NAME), - ipType: "PUBLIC", - authType: "IAM", + ipType: 'PUBLIC', + authType: 'IAM', }); const client = new Client({ ...clientOpts, - user: String(process.env.POSTGRES_USER_IAM_NODE), + user: String(process.env.POSTGRES_IAM_USER), database: String(process.env.POSTGRES_DB), }); t.after(async () => { diff --git a/system-test/pg-connect.ts b/system-test/pg-connect.ts index 55936300..60fe3076 100644 --- a/system-test/pg-connect.ts +++ b/system-test/pg-connect.ts @@ -58,7 +58,7 @@ t.test('open IAM connection and retrieves standard pg tables', async t => { }); const client = new Client({ ...clientOpts, - user: String(process.env.POSTGRES_USER_IAM_NODE), + user: String(process.env.POSTGRES_IAM_USER), database: String(process.env.POSTGRES_DB), }); t.after(async () => { diff --git a/test/cloud-sql-instance.ts b/test/cloud-sql-instance.ts index c32bf748..9b4c2471 100644 --- a/test/cloud-sql-instance.ts +++ b/test/cloud-sql-instance.ts @@ -67,6 +67,7 @@ t.test('CloudSQLInstance', async t => { instanceConnectionName: 'my-project:us-east1:my-instance', sqlAdminFetcher: fetcher, }); + t.after(() => instance.close()); t.same( instance.ephemeralCert.cert, @@ -115,6 +116,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); await t.rejects( instance.refresh(), @@ -135,6 +137,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); instance.refresh = () => { if (refreshCount === 2) { const end = Date.now(); @@ -177,6 +180,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); await (() => new Promise((res): void => { let refreshCount = 0; @@ -233,6 +237,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); await (() => new Promise((res): void => { let refreshCount = 0; @@ -263,6 +268,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); await instance.refresh(); @@ -301,6 +307,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); let cancelRefreshCalled = false; let refreshCalled = false; @@ -338,6 +345,7 @@ t.test('CloudSQLInstance', async t => { sqlAdminFetcher: fetcher, }, }); + t.after(() => instance.close()); const start = Date.now(); // starts regular refresh cycle @@ -377,6 +385,7 @@ t.test('CloudSQLInstance', async t => { sqlAdminFetcher: fetcher, }, }); + t.after(() => instance.close()); const start = Date.now(); // starts out refresh logic let refreshCount = 1; @@ -424,6 +433,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); // starts a new refresh cycle but do not await on it instance.refresh(); @@ -451,6 +461,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); // simulates an ongoing instance, already has data await instance.refresh(); @@ -487,6 +498,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); await instance.refresh(); instance.setEstablishedConnection(); @@ -522,6 +534,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 50, }, }); + t.after(() => instance.close()); await instance.refresh(); instance.setEstablishedConnection(); @@ -589,6 +602,7 @@ t.test('CloudSQLInstance', async t => { limitRateInterval: 0, }, }); + t.after(() => instance.close()); await (() => new Promise((res): void => { let refreshCount = 0; diff --git a/test/connector.ts b/test/connector.ts index 7f74204f..19f23e49 100644 --- a/test/connector.ts +++ b/test/connector.ts @@ -218,6 +218,7 @@ t.test('start only a single instance info per connection name', async t => { return { ipType: IpAddressTypes.PUBLIC, authType: AuthTypes.PASSWORD, + checkDomainChanged() {}, isClosed() { return false; }, @@ -590,9 +591,100 @@ t.test('Connector by domain resolves and creates instance', async t => { // Ensure there is only one entry. t.same(connector.instances.size, 1); - const newInstance = connector.instances.get( + const oldInstance = connector.instances.get( 'db.example.com-PASSWORD-PUBLIC' ).instance; - t.same(newInstance.instanceInfo.domainName, 'db.example.com'); - t.same(newInstance.instanceInfo.instanceId, 'instance'); + t.same(oldInstance.instanceInfo.domainName, 'db.example.com'); + t.same(oldInstance.instanceInfo.instanceId, 'instance'); }); + +t.test( + 'Connector by domain resolves new instance after domain changes', + async t => { + const th = setupConnectorModule(t); + const connector = new th.Connector(); + t.after(() => { + connector.close(); + }); + + // Get options loads the instance + await connector.getOptions({ + ipType: 'PUBLIC', + authType: 'PASSWORD', + domainName: 'db.example.com', + }); + + // Ensure there is only one entry. + t.same(connector.instances.size, 1); + const oldInstance = connector.instances.get( + 'db.example.com-PASSWORD-PUBLIC' + ).instance; + t.same(oldInstance.instanceInfo.domainName, 'db.example.com'); + t.same(oldInstance.instanceInfo.instanceId, 'instance'); + + // getOptions after DNS response changes closes the old instance + // and loads a new one. + th.resolveTxtResponse = 'project:region2:instance2'; + await connector.getOptions({ + ipType: 'PUBLIC', + authType: 'PASSWORD', + domainName: 'db.example.com', + }); + t.same(connector.instances.size, 1); + const newInstance = connector.instances.get( + 'db.example.com-PASSWORD-PUBLIC' + ).instance; + t.same(newInstance.instanceInfo.domainName, 'db.example.com'); + t.same(newInstance.instanceInfo.instanceId, 'instance2'); + t.same(oldInstance.isClosed(), true, 'old instance is closed'); + + connector.close(); + } +); + +t.test( + 'Connector checks if name changes in background and closes connector', + async t => { + const th = setupConnectorModule(t); + const connector = new th.Connector(); + t.after(() => { + connector.close(); + }); + + // Get options loads the instance + await connector.getOptions({ + ipType: 'PUBLIC', + authType: 'PASSWORD', + domainName: 'db.example.com', + failoverPeriod: 10, // 10ms for testing + }); + + // Ensure there is only one entry. + t.same(connector.instances.size, 1); + const oldInstance = connector.instances.get( + 'db.example.com-PASSWORD-PUBLIC' + ).instance; + t.same(oldInstance.instanceInfo.domainName, 'db.example.com'); + t.same(oldInstance.instanceInfo.instanceId, 'instance'); + + // add a mock socket to the old instance + const mockSocket = { + destroyed: false, + once() {}, + destroy() { + this.destroyed = true; + }, + }; + oldInstance.addSocket(mockSocket); + + // getOptions after DNS response changes closes the old instance + // and loads a new one. + th.resolveTxtResponse = 'project:region2:instance2'; + await new Promise(res => { + setTimeout(res, 50); + }); + + t.same(oldInstance.isClosed(), true, 'old instance is closed'); + t.same(mockSocket.destroyed, true, 'old instance closed its sockets'); + } +);