diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 30ed66b6..729f329a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -167,6 +167,10 @@ jobs: POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME POSTGRES_MCP_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_MCP_CONNECTION_NAME POSTGRES_MCP_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_MCP_PASS + POSTGRES_AIDE_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_AIDE_CONNECTION_NAME + POSTGRES_AIDE_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_AIDE_USER + POSTGRES_AIDE_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_AIDE_PASS + POSTGRES_AIDE_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_AIDE_DB 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 @@ -200,6 +204,10 @@ jobs: POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME }}" POSTGRES_MCP_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_MCP_CONNECTION_NAME }}" POSTGRES_MCP_PASS: "${{ steps.secrets.outputs.POSTGRES_MCP_PASS }}" + POSTGRES_AIDE_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_AIDE_CONNECTION_NAME }}" + POSTGRES_AIDE_USER: "${{ steps.secrets.outputs.POSTGRES_AIDE_USER }}" + POSTGRES_AIDE_PASS: "${{ steps.secrets.outputs.POSTGRES_AIDE_PASS }}" + POSTGRES_AIDE_DB: "${{ steps.secrets.outputs.POSTGRES_AIDE_DB }}" 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/build.sh b/build.sh index 4c363e26..e0ed4927 100755 --- a/build.sh +++ b/build.sh @@ -136,6 +136,14 @@ function write_e2e_env(){ POSTGRES_CUSTOMER_CAS_INVALID_DOMAIN_NAME=POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME POSTGRES_MCP_CONNECTION_NAME=POSTGRES_MCP_CONNECTION_NAME POSTGRES_MCP_PASS=POSTGRES_MCP_PASS + POSTGRES_AIDE_CONNECTION_NAME=POSTGRES_AIDE_CONNECTION_NAME + POSTGRES_AIDE_USER=POSTGRES_AIDE_USER + POSTGRES_AIDE_PASS=POSTGRES_AIDE_PASS + POSTGRES_AIDE_DB=POSTGRES_AIDE_DB + POSTGRES_FALLBACK_CONNECTION_NAME=POSTGRES_FALLBACK_CONNECTION_NAME + POSTGRES_FALLBACK_USER=POSTGRES_FALLBACK_USER + POSTGRES_FALLBACK_PASS=POSTGRES_FALLBACK_PASS + POSTGRES_FALLBACK_DB=POSTGRES_FALLBACK_DB SQLSERVER_CONNECTION_NAME=SQLSERVER_CONNECTION_NAME SQLSERVER_USER=SQLSERVER_USER SQLSERVER_PASS=SQLSERVER_PASS diff --git a/package-lock.json b/package-lock.json index 6f51e554..a0110618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,15 @@ "version": "1.11.3", "license": "Apache-2.0", "dependencies": { + "@google-cloud/sql": "^0.25.0", "@googleapis/sqladmin": "^38.0.0", + "@grpc/grpc-js": "^1.14.4", "gaxios": "^8.0.0", "google-auth-library": "^11.0.2", "p-throttle": "^8.1.0" }, "devDependencies": { + "@grpc/proto-loader": "^0.8.1", "@prisma/client": "^5.22.0", "@sequelize/core": "^7.0.0-alpha.29", "@sequelize/mssql": "^7.0.0-alpha.29", @@ -27,7 +30,7 @@ "gts": "^5.3.1", "knex": "^3.3.0", "mssql": "^12.7.0", - "mysql2": "^3.24.2", + "mysql2": "^3.24.3", "nock": "^14.0.17", "pg": "^8.23.0", "prisma": "^5.22.0", @@ -603,6 +606,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@google-cloud/sql": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@google-cloud/sql/-/sql-0.25.0.tgz", + "integrity": "sha512-OKAA98q2M25rQcqcFvbQniix4zTFcbeSyauj+PcPd+6ngn7eXvAUqpuCRVxbIGmA8r5OtE6Ru/oCJ1xwcUR2vA==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@googleapis/sqladmin": { "version": "38.0.0", "resolved": "https://registry.npmjs.org/@googleapis/sqladmin/-/sqladmin-38.0.0.tgz", @@ -615,6 +630,95 @@ "node": ">=12.0.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -812,6 +916,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.9", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", @@ -1263,6 +1377,63 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@sequelize/core": { "version": "7.0.0-alpha.48", "resolved": "https://registry.npmjs.org/@sequelize/core/-/core-7.0.0-alpha.48.tgz", @@ -2450,7 +2621,6 @@ "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -4064,6 +4234,32 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4085,6 +4281,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4165,7 +4370,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4961,7 +5165,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5210,6 +5413,257 @@ "node": ">=22" } }, + "node_modules/google-gax": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.8.tgz", + "integrity": "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/google-gax/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/google-gax/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/google-gax/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/google-gax/node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/google-gax/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/google-gax/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-gax/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/google-gax/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", @@ -6051,7 +6505,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -6243,7 +6696,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -7070,6 +7522,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -7130,7 +7588,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/loose-envify": { @@ -7561,9 +8018,9 @@ "license": "ISC" }, "node_modules/mysql2": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.2.tgz", - "integrity": "sha512-l9kXeKGwd6VCbSjmpO/bLWb+YCYpbvE4whte8ec6InXebvCNyEEydyRCnRU+TSHNqRLJ8B+Tk1uWb+vMCJgJzA==", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", + "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", "dev": true, "license": "MIT", "dependencies": { @@ -7931,6 +8388,15 @@ "node": ">=8" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -7954,7 +8420,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8686,6 +9151,41 @@ "node": ">= 8" } }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9036,6 +9536,15 @@ "dev": true, "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -9163,6 +9672,19 @@ "dev": true, "license": "MIT" }, + "node_modules/retry-request": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.4.tgz", + "integrity": "sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9659,6 +10181,21 @@ "node": ">=8" } }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -9670,7 +10207,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -9811,6 +10347,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10111,6 +10653,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/teeny-request": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.4.tgz", + "integrity": "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", @@ -10649,7 +11206,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/uri-js": { @@ -10668,6 +11224,12 @@ "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "license": "BSD" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", @@ -10994,7 +11556,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -11063,7 +11624,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 1a3fdca7..9f4e752a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "allow-incomplete-coverage": true }, "devDependencies": { + "@grpc/proto-loader": "^0.8.1", "@prisma/client": "^5.22.0", "@sequelize/core": "^7.0.0-alpha.29", "@sequelize/mssql": "^7.0.0-alpha.29", @@ -70,7 +71,7 @@ "gts": "^5.3.1", "knex": "^3.3.0", "mssql": "^12.7.0", - "mysql2": "^3.24.2", + "mysql2": "^3.24.3", "nock": "^14.0.17", "pg": "^8.23.0", "prisma": "^5.22.0", @@ -87,7 +88,9 @@ "url": "git+https://github.com/GoogleCloudPlatform/cloud-sql-nodejs-connector" }, "dependencies": { + "@google-cloud/sql": "^0.25.0", "@googleapis/sqladmin": "^38.0.0", + "@grpc/grpc-js": "^1.14.4", "gaxios": "^8.0.0", "google-auth-library": "^11.0.2", "p-throttle": "^8.1.0" diff --git a/scripts/fixup.cjs b/scripts/fixup.cjs index baa16e53..55f80768 100644 --- a/scripts/fixup.cjs +++ b/scripts/fixup.cjs @@ -39,12 +39,12 @@ async function fixupImportFileExtensions() { const replaceExtension = source => source.replace(/(^import.*from '\.\/.*)(';)$/gm, '$1.js$2'); - const recursiveReadDir = async path => { - const dirResults = await readdir(mjsDistFolder, {withFileTypes: true}); + const recursiveReadDir = async dirPath => { + const dirResults = await readdir(dirPath, {withFileTypes: true}); for (const entry of dirResults) { - const path = resolve(mjsDistFolder, entry.name); + const path = resolve(dirPath, entry.name); if (entry.isDirectory()) { - recursiveReadDir(path); + await recursiveReadDir(path); } else if (path.endsWith('.js') || path.endsWith('.d.ts')) { mjsFilePaths.push(path); } diff --git a/src/cloud-sql-instance.ts b/src/cloud-sql-instance.ts index 00db0774..49195fe0 100644 --- a/src/cloud-sql-instance.ts +++ b/src/cloud-sql-instance.ts @@ -63,10 +63,10 @@ interface CloudSQLInstanceOptions { } interface RefreshResult { - ephemeralCert: SslCert; + ephemeralCert?: SslCert; host: string; - privateKey: string; - serverCaCert: SslCert; + privateKey?: string; + serverCaCert?: SslCert; } export class CloudSQLInstance { @@ -243,10 +243,12 @@ export class CloudSQLInstance { // then we go ahead and update values this.updateValues(nextValues); - const refreshInterval = getRefreshInterval( - /* c8 ignore next */ - String(this.ephemeralCert?.expirationTime) - ); + let refreshInterval = 3600000; // 1 hour default + if (this.ephemeralCert) { + refreshInterval = getRefreshInterval( + this.ephemeralCert.expirationTime + ); + } this.scheduleRefresh(refreshInterval); // This is the end of the successful refresh chain, so now @@ -289,30 +291,37 @@ export class CloudSQLInstance { return Promise.reject('closed'); } - const rsaKeys: RSAKeys = await generateKeys(); const metadata: InstanceMetadata = await this.sqlAdminFetcher.getInstanceMetadata(this.instanceInfo); - const ephemeralCert = await this.sqlAdminFetcher.getEphemeralCertificate( - this.instanceInfo, - rsaKeys.publicKey, - this.authType - ); - let host; - if (this.instanceInfo && this.instanceInfo.domainName) { - try { - const ips = await resolveARecord(this.instanceInfo.domainName); - if (ips && ips.length > 0) { - host = ips[0]; + let host = ''; + let ephemeralCert; + let privateKey; + + if (this.ipType !== IpAddressTypes.SQL_DATA) { + const rsaKeys: RSAKeys = await generateKeys(); + ephemeralCert = await this.sqlAdminFetcher.getEphemeralCertificate( + this.instanceInfo, + rsaKeys.publicKey, + this.authType + ); + privateKey = rsaKeys.privateKey; + + if (this.instanceInfo && this.instanceInfo.domainName) { + try { + const ips = await resolveARecord(this.instanceInfo.domainName); + if (ips && ips.length > 0) { + host = ips[0]; + } + } catch (e) { + // ignore error, fallback to metadata IP } - } catch (e) { - // ignore error, fallback to metadata IP + } + if (!host) { + host = selectIpAddress(metadata.ipAddresses, this.ipType); } } - if (!host) { - host = selectIpAddress(metadata.ipAddresses, this.ipType); - } - const privateKey = rsaKeys.privateKey; + const serverCaCert = metadata.serverCaCert; this.serverCaMode = metadata.serverCaMode; this.dnsName = metadata.dnsName; @@ -347,6 +356,9 @@ export class CloudSQLInstance { privateKey, serverCaCert, }: Partial): boolean { + if (this.ipType === IpAddressTypes.SQL_DATA) { + return true; + } if (!ephemeralCert || !host || !privateKey || !serverCaCert) { return false; } diff --git a/src/connector.ts b/src/connector.ts index c998ac06..8cc7bd10 100644 --- a/src/connector.ts +++ b/src/connector.ts @@ -13,7 +13,8 @@ // limitations under the License. import {createServer, Server, Socket} from 'node:net'; -import tls from 'node:tls'; +import {TLSSocket} from 'node:tls'; + import {promisify} from 'node:util'; import {AuthClient, GoogleAuth} from 'google-auth-library'; import {CloudSQLInstance} from './cloud-sql-instance'; @@ -22,6 +23,9 @@ import {IpAddressTypes} from './ip-addresses'; import {AuthTypes} from './auth-types'; import {SQLAdminFetcher} from './sqladmin-fetcher'; import {CloudSQLConnectorError} from './errors'; +import {resolveInstanceName} from './parse-instance-connection-name'; +import {SqlDataClient} from './sql-data-client'; +import {InstanceConnectionInfo} from './instance-connection-info'; // These Socket types are subsets from nodejs definitely typed repo, ref: // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/ae0fe42ff0e6e820e8ae324acf4f8e944aa1b2b7/types/node/v18/net.d.ts#L437 @@ -31,14 +35,20 @@ export declare interface UnixSocketOptions { writableAll?: boolean | undefined; } -// ConnectionOptions are the arguments that the user can provide -// to the Connector.getOptions method when calling it, e.g: +export function cooldownBackoff(base: number, attempt: number): number { + const multi = 1.618; + const exp = attempt - 1 + Math.random(); + return Math.floor(base * Math.pow(multi, exp)); +} + +// Connector.getOptions accepts a ConnectionOptions object to configure how +// the connector will connect to the Cloud SQL instance. +// // const connector = new Connector() // const connectionOptions:ConnectionOptions = { // ipType: 'PUBLIC', // instanceConnectionName: 'PROJECT:REGION:INSTANCE', // }; -// await connector.getOptions(connectionOptions); export declare interface ConnectionOptions { authType?: AuthTypes; ipType?: IpAddressTypes; @@ -46,6 +56,11 @@ export declare interface ConnectionOptions { domainName?: string; failoverPeriod?: number; limitRateInterval?: number; + sqlDataEndpoint?: string; + sqlDataStreamTimeout?: number; + sqlDataKeepAliveTimeMs?: number; + sqlDataKeepAliveTimeoutMs?: number; + resourceExhaustedCooldownPeriod?: number; } export declare interface SocketConnectionOptions extends ConnectionOptions { @@ -53,11 +68,11 @@ export declare interface SocketConnectionOptions extends ConnectionOptions { } interface StreamFunction { - (): tls.TLSSocket; + (): Socket; } interface PromisedStreamFunction { - (): Promise; + (): Promise; } // DriverOptions is the interface describing the object returned by @@ -178,6 +193,13 @@ class CloudSQLInstanceMap extends Map { } } +export interface SqlDataState { + allowed: boolean; + cooldownUntil: number; + lastErr?: Error; + backoffCounter: number; +} + export interface ConnectorOptions { auth?: GoogleAuth | AuthClient; sqlAdminAPIEndpoint?: string; @@ -187,6 +209,11 @@ export interface ConnectorOptions { */ universeDomain?: string; userAgent?: string; + sqlDataEndpoint?: string; + sqlDataStreamTimeout?: number; + sqlDataKeepAliveTimeMs?: number; + sqlDataKeepAliveTimeoutMs?: number; + resourceExhaustedCooldownPeriod?: number; } // The Connector class is the main public API to interact @@ -196,6 +223,14 @@ export class Connector { private readonly sqlAdminFetcher: SQLAdminFetcher; private readonly localProxies: Set; private readonly sockets: Set; + private readonly sqlDataEndpoint?: string; + private readonly sqlDataStreamTimeout?: number; + private readonly sqlDataKeepAliveTimeMs?: number; + private readonly sqlDataKeepAliveTimeoutMs?: number; + private readonly resourceExhaustedCooldownPeriod: number; + private readonly sqlDataTunnels = new Map(); + private readonly sqlDataStates = new Map(); + private readonly sqlDataFallbackIpTypes = new Map(); constructor(opts: ConnectorOptions = {}) { this.sqlAdminFetcher = new SQLAdminFetcher({ @@ -207,6 +242,25 @@ export class Connector { this.instances = new CloudSQLInstanceMap(this.sqlAdminFetcher); this.localProxies = new Set(); this.sockets = new Set(); + this.sqlDataEndpoint = opts.sqlDataEndpoint; + this.sqlDataStreamTimeout = opts.sqlDataStreamTimeout; + this.sqlDataKeepAliveTimeMs = opts.sqlDataKeepAliveTimeMs; + this.sqlDataKeepAliveTimeoutMs = opts.sqlDataKeepAliveTimeoutMs; + this.resourceExhaustedCooldownPeriod = + opts.resourceExhaustedCooldownPeriod ?? 5000; + } + + private getSqlDataState(connectionName: string): SqlDataState { + let state = this.sqlDataStates.get(connectionName); + if (!state) { + state = { + allowed: true, + cooldownUntil: 0, + backoffCounter: 0, + }; + this.sqlDataStates.set(connectionName, state); + } + return state; } // Connector.getOptions is a method that accepts a Cloud SQL instance @@ -222,55 +276,215 @@ export class Connector { // const res = await pool.query('SELECT * FROM pg_catalog.pg_tables;') async getOptions(opts: ConnectionOptions): Promise { const {instances} = this; - await instances.loadInstance(opts); + + const instanceInfo = await resolveInstanceName( + opts.instanceConnectionName, + opts.domainName, + this.sqlAdminFetcher + ); + const connectionName = `${instanceInfo.projectId}:${instanceInfo.regionId}:${instanceInfo.instanceId}`; + + let ipType = opts.ipType || IpAddressTypes.PUBLIC; + + const state = this.getSqlDataState(connectionName); + + if (ipType === IpAddressTypes.SQL_DATA) { + if (!state.allowed) { + ipType = + this.sqlDataFallbackIpTypes.get(connectionName) || + (await this.getFallbackIpType(instanceInfo)); + } else if (state.cooldownUntil && Date.now() < state.cooldownUntil) { + throw new CloudSQLConnectorError({ + message: `Resource exhausted: cooldown active for ${connectionName}`, + code: 'ERESOURCEEXHAUSTED', + errors: state.lastErr ? [state.lastErr] : [], + }); + } + } + + const resolvedOpts: ConnectionOptions = { + ...opts, + ipType, + }; + + await instances.loadInstance(resolvedOpts); + + if (ipType === IpAddressTypes.SQL_DATA) { + return await this.developerEditionOptions( + connectionName, + instances, + resolvedOpts, + instanceInfo + ); + } return { - stream() { - const cloudSqlInstance = instances.getInstance(opts); - const { - instanceInfo, - ephemeralCert, - host, - port, - privateKey, - serverCaCert, - dnsName, - } = cloudSqlInstance; - - if ( - instanceInfo && - ephemeralCert && - host && - port && - privateKey && - serverCaCert - ) { - const tlsSocket = getSocket({ - instanceInfo, - ephemeralCert, - host, - port, - privateKey, - serverCaCert, - instanceDnsName: dnsName, - serverName: instanceInfo.domainName || dnsName, // use the configured domain name, or the instance dnsName. - }); - tlsSocket.once('error', () => { - cloudSqlInstance.forceRefresh(); - }); - tlsSocket.once('secureConnect', async () => { - cloudSqlInstance.setEstablishedConnection(); - }); + stream: () => this.createDirectSocket(instances, resolvedOpts), + }; + } - cloudSqlInstance.addSocket(tlsSocket); + private async getFallbackIpType( + instanceInfo: InstanceConnectionInfo + ): Promise { + const metadata = + await this.sqlAdminFetcher.getInstanceMetadata(instanceInfo); + if (metadata.ipAddresses.private) { + return IpAddressTypes.PRIVATE; + } + if (metadata.ipAddresses.psc) { + return IpAddressTypes.PSC; + } + if (metadata.ipAddresses.public) { + return IpAddressTypes.PUBLIC; + } + return IpAddressTypes.PUBLIC; + } + + private createDirectSocket( + instances: CloudSQLInstanceMap, + opts: ConnectionOptions + ): TLSSocket { + const cloudSqlInstance = instances.getInstance(opts); + const { + instanceInfo, + ephemeralCert, + host, + port, + privateKey, + serverCaCert, + dnsName, + } = cloudSqlInstance; + + if ( + instanceInfo && + ephemeralCert && + host && + port && + privateKey && + serverCaCert + ) { + const tlsSocket = getSocket({ + instanceInfo, + ephemeralCert, + host, + port, + privateKey, + serverCaCert, + instanceDnsName: dnsName, + serverName: instanceInfo.domainName || dnsName, // use the configured domain name, or the instance dnsName. + }); + tlsSocket.once('error', () => { + cloudSqlInstance.forceRefresh(); + }); + tlsSocket.once('secureConnect', async () => { + cloudSqlInstance.setEstablishedConnection(); + }); + + cloudSqlInstance.addSocket(tlsSocket); + + return tlsSocket; + } + + throw new CloudSQLConnectorError({ + message: 'Invalid Cloud SQL Instance info', + code: 'EBADINSTANCEINFO', + }); + } + + private async developerEditionOptions( + connectionName: string, + instances: CloudSQLInstanceMap, + opts: ConnectionOptions, + instanceInfo: InstanceConnectionInfo + ): Promise { + const state = this.getSqlDataState(connectionName); + const cooldownPeriod = + opts.resourceExhaustedCooldownPeriod ?? + this.resourceExhaustedCooldownPeriod; + + let tunnel = this.sqlDataTunnels.get(connectionName); + if (!tunnel) { + const getDirectSocket = async () => { + const fallbackIpType = await this.getFallbackIpType(instanceInfo); + this.sqlDataFallbackIpTypes.set(connectionName, fallbackIpType); + const fallbackOpts: ConnectionOptions = { + ...opts, + ipType: fallbackIpType, + }; + await instances.loadInstance(fallbackOpts); + return this.createDirectSocket(instances, fallbackOpts); + }; + + tunnel = new SqlDataClient({ + instanceConnectionName: connectionName, + auth: this.sqlAdminFetcher.adminAuth, + endpoint: opts.sqlDataEndpoint || this.sqlDataEndpoint, + streamTimeout: opts.sqlDataStreamTimeout || this.sqlDataStreamTimeout, + keepAliveTimeMs: + opts.sqlDataKeepAliveTimeMs || this.sqlDataKeepAliveTimeMs, + keepAliveTimeoutMs: + opts.sqlDataKeepAliveTimeoutMs || this.sqlDataKeepAliveTimeoutMs, + getDirectSocket, + onUnsupported: () => { + state.allowed = false; + }, + onResourceExhausted: (err: Error) => { + if (state.backoffCounter < 5) { + state.backoffCounter++; + } + const backoff = cooldownBackoff(cooldownPeriod, state.backoffCounter); + state.cooldownUntil = Date.now() + backoff; + state.lastErr = err; + }, + onSuccess: () => { + state.backoffCounter = 0; + state.cooldownUntil = 0; + state.lastErr = undefined; + }, + }); + this.sqlDataTunnels.set(connectionName, tunnel); + } + + const tunnelPort = await tunnel.start(); + + return { + stream: () => { + if (!state.allowed) { + const fallbackIpType = + this.sqlDataFallbackIpTypes.get(connectionName) || + IpAddressTypes.PUBLIC; + const fallbackOpts: ConnectionOptions = { + ...opts, + ipType: fallbackIpType, + }; + return this.createDirectSocket(instances, fallbackOpts); + } - return tlsSocket; + if (state.cooldownUntil && Date.now() < state.cooldownUntil) { + throw new CloudSQLConnectorError({ + message: `Resource exhausted: cooldown active for ${connectionName}`, + code: 'ERESOURCEEXHAUSTED', + errors: state.lastErr ? [state.lastErr] : [], + }); } - throw new CloudSQLConnectorError({ - message: 'Invalid Cloud SQL Instance info', - code: 'EBADINSTANCEINFO', + const socket = new Socket(); + socket.connect(tunnelPort, '127.0.0.1'); + socket.setKeepAlive(true, 30 * 1000); + + const cloudSqlInstance = instances.getInstance(opts); + socket.once('connect', () => { + cloudSqlInstance.setEstablishedConnection(); }); + socket.once('error', () => { + cloudSqlInstance.forceRefresh(); + }); + + cloudSqlInstance.addSocket(socket); + + socket.connect = () => socket; + + return socket; }, }; } @@ -379,5 +593,8 @@ export class Connector { for (const socket of this.sockets) { socket.destroy(); } + for (const tunnel of this.sqlDataTunnels.values()) { + tunnel.close(); + } } } diff --git a/src/index.ts b/src/index.ts index d29fe5db..6e27d436 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,16 +18,20 @@ import { ConnectionOptions, DriverOptions, UnixSocketOptions, + SqlDataState, + cooldownBackoff, } from './connector'; import {IpAddressTypes} from './ip-addresses'; import {AuthTypes} from './auth-types'; export { Connector, + cooldownBackoff, type ConnectorOptions, type ConnectionOptions, type DriverOptions, type UnixSocketOptions, + type SqlDataState, }; export {IpAddressTypes}; export {AuthTypes}; diff --git a/src/ip-addresses.ts b/src/ip-addresses.ts index 973e0d14..eeb4bf06 100644 --- a/src/ip-addresses.ts +++ b/src/ip-addresses.ts @@ -18,6 +18,7 @@ export enum IpAddressTypes { PUBLIC = 'PUBLIC', PRIVATE = 'PRIVATE', PSC = 'PSC', + SQL_DATA = 'SQL_DATA', } export declare interface IpAddresses { @@ -67,6 +68,17 @@ export function selectIpAddress( return getPrivateIpAddress(ipAddresses); case IpAddressTypes.PSC: return getPSCIpAddress(ipAddresses); + case IpAddressTypes.SQL_DATA: + if (ipAddresses.private) { + return getPrivateIpAddress(ipAddresses); + } + if (ipAddresses.psc) { + return getPSCIpAddress(ipAddresses); + } + if (ipAddresses.public) { + return getPublicIpAddress(ipAddresses); + } + return ''; default: throw new CloudSQLConnectorError({ message: 'Cannot connect to instance, it has no supported IP addresses', diff --git a/src/sql-data-client.ts b/src/sql-data-client.ts new file mode 100644 index 00000000..2a3dae24 --- /dev/null +++ b/src/sql-data-client.ts @@ -0,0 +1,340 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as net from 'node:net'; +import * as grpc from '@grpc/grpc-js'; +import {AuthClient, GoogleAuth} from 'google-auth-library'; +import {v1beta4, protos} from '@google-cloud/sql'; +import {CloudSQLConnectorError} from './errors'; + +export interface SqlDataClientOptions { + instanceConnectionName: string; + auth: GoogleAuth; + endpoint?: string; + streamTimeout?: number; + keepAliveTimeMs?: number; + keepAliveTimeoutMs?: number; + channelCredentials?: grpc.ChannelCredentials; + getDirectSocket?: () => Promise | net.Socket; + onUnsupported?: () => void; + onResourceExhausted?: (err: Error) => void; + onSuccess?: () => void; +} + +export class SqlDataClient { + private readonly instanceConnectionName: string; + private readonly auth: GoogleAuth; + private readonly endpoint: string; + private readonly streamTimeout: number; + private readonly keepAliveTimeMs: number; + private readonly keepAliveTimeoutMs: number; + private readonly projectId: string; + private readonly regionId: string; + private readonly instanceId: string; + private readonly channelCredentials?: grpc.ChannelCredentials; + private readonly getDirectSocket?: () => Promise | net.Socket; + private readonly onUnsupported?: () => void; + private readonly onResourceExhausted?: (err: Error) => void; + private readonly onSuccess?: () => void; + private readonly activeSockets = new Set(); + private server?: net.Server; + private client?: v1beta4.SqlDataServiceClient; + private port?: number; + + constructor(opts: SqlDataClientOptions) { + this.instanceConnectionName = opts.instanceConnectionName; + this.auth = opts.auth; + this.endpoint = opts.endpoint || 'sqladmin.googleapis.com'; + this.streamTimeout = opts.streamTimeout || 2 * 60 * 60 * 1000; // 2 hours + this.keepAliveTimeMs = opts.keepAliveTimeMs ?? 30 * 1000; // 30 seconds + this.keepAliveTimeoutMs = opts.keepAliveTimeoutMs ?? 10 * 1000; // 10 seconds + this.channelCredentials = opts.channelCredentials; + this.getDirectSocket = opts.getDirectSocket; + this.onUnsupported = opts.onUnsupported; + this.onResourceExhausted = opts.onResourceExhausted; + this.onSuccess = opts.onSuccess; + + const parts = this.instanceConnectionName.split(':'); + if (parts.length !== 3) { + throw new CloudSQLConnectorError({ + message: `Invalid instance connection name: ${this.instanceConnectionName}`, + code: 'EBADINSTANCECONNECTIONNAME', + }); + } + this.projectId = parts[0]; + this.regionId = parts[1]; + this.instanceId = parts[2]; + } + + private getClient(): v1beta4.SqlDataServiceClient { + if (this.client) { + return this.client; + } + + let servicePath = this.endpoint.replace(/^https?:\/\//, ''); + let port = 443; + if (servicePath.includes(':')) { + const parts = servicePath.split(':'); + servicePath = parts[0]; + port = parseInt(parts[1], 10); + } + + const clientOptions = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + auth: this.auth as any, + servicePath, + sslCreds: this.channelCredentials, + port, + 'grpc.keepalive_time_ms': this.keepAliveTimeMs, + 'grpc.keepalive_timeout_ms': this.keepAliveTimeoutMs, + 'grpc.keepalive_permit_without_calls': 1, + }; + + this.client = new v1beta4.SqlDataServiceClient(clientOptions); + return this.client; + } + + async start(): Promise { + if (this.port !== undefined) { + return this.port; + } + return new Promise((resolvePromise, rejectPromise) => { + this.server = net.createServer(async socket => { + try { + await this.handleConnection(socket); + } catch (err: unknown) { + socket.destroy(err as Error); + } + }); + + this.server.on('error', err => { + rejectPromise(err); + }); + + this.server.listen(0, '127.0.0.1', () => { + const addr = this.server?.address() as net.AddressInfo; + this.port = addr.port; + resolvePromise(this.port); + }); + }); + } + + async close(): Promise { + for (const socket of this.activeSockets) { + socket.destroy(); + } + this.activeSockets.clear(); + + if (this.client) { + try { + await this.client.close(); + } catch { + // ignore + } + this.client = undefined; + } + if (this.server) { + return new Promise(resolvePromise => { + this.server?.close(() => { + resolvePromise(); + }); + }); + } + } + + private async handleConnection(socket: net.Socket): Promise { + this.activeSockets.add(socket); + socket.once('close', () => { + this.activeSockets.delete(socket); + }); + + let isFallback = false; + let isEstablished = false; + let isClosed = false; + const clientBuffer: Buffer[] = []; + let directSocket: net.Socket | undefined; + + const client = this.getClient(); + + const instanceResource = `projects/${this.projectId}/instances/${this.instanceId}`; + const locationResource = `locations/${this.regionId}`; + + const stream = client.streamSqlData({ + otherArgs: { + headers: { + 'x-goog-request-params': `instance_id=${instanceResource}&location_id=${locationResource}`, + }, + timeout: this.streamTimeout, + }, + }); + + const startSessionMsg = { + startSession: { + locationId: locationResource, + instanceId: instanceResource, + }, + }; + stream.write(startSessionMsg); + + stream.on( + 'data', + (response: protos.google.cloud.sql.v1beta4.IStreamSqlDataResponse) => { + if (isFallback || isClosed) { + return; + } + if (!isEstablished) { + isEstablished = true; + clientBuffer.length = 0; + this.onSuccess?.(); + } + if (response.data && response.data.data) { + const ok = socket.write(response.data.data); + if (!ok) { + stream.pause(); + } + } + if (response.terminateSession) { + const status = response.terminateSession.status; + const code = status ? status.code : 'UNKNOWN'; + const msg = status ? status.message : 'Session terminated by server'; + const termErr = new Error( + `gRPC Stream terminated by server: Code ${code}, Message: ${msg}` + ); + if ( + status && + (status.code === grpc.status.RESOURCE_EXHAUSTED || + (status.code as unknown as string) === 'RESOURCE_EXHAUSTED') + ) { + this.onResourceExhausted?.(termErr); + } + socket.destroy(termErr); + } + } + ); + + socket.on('drain', () => { + if (!isFallback && !isClosed) { + stream.resume(); + } + }); + + stream.on('drain', () => { + if (!isFallback && !isClosed) { + socket.resume(); + } + }); + + stream.on('error', async (err: grpc.ServiceError) => { + if (isFallback || isClosed) { + return; + } + if ( + err.code === grpc.status.FAILED_PRECONDITION && + !isEstablished && + this.getDirectSocket + ) { + // FAILED_PRECONDITION: Instance does not support SQL_DATA. + isFallback = true; + socket.pause(); + this.onUnsupported?.(); + try { + stream.destroy(); + } catch { + // ignore + } + try { + directSocket = await this.getDirectSocket(); + while (clientBuffer.length > 0) { + const chunk = clientBuffer.shift(); + if (chunk) { + directSocket.write(chunk); + } + } + socket.pipe(directSocket); + directSocket.pipe(socket); + + directSocket.on('error', directErr => { + socket.destroy(directErr); + }); + directSocket.on('close', () => { + socket.end(); + }); + socket.resume(); + } catch (directErr) { + socket.destroy(directErr as Error); + } + return; + } + if (err.code === grpc.status.RESOURCE_EXHAUSTED) { + this.onResourceExhausted?.(err); + } + socket.destroy(err); + }); + + stream.on('end', () => { + if (isFallback || isClosed) { + return; + } + socket.end(); + }); + + socket.on('data', chunk => { + if (isFallback) { + // Handled by pipe(directSocket) + return; + } + if (!isEstablished) { + clientBuffer.push(chunk); + } + const ok = stream.write({ + data: { + firstByteOffset: 0, + data: chunk, + }, + }); + if (!ok) { + socket.pause(); + } + }); + + socket.on('end', () => { + isClosed = true; + if (isFallback) { + // Handled by pipe(directSocket) + return; + } + const terminateSessionMsg = { + terminateSession: { + status: { + code: 0, + message: 'Client closed connection', + }, + }, + }; + stream.write(terminateSessionMsg); + stream.end(); + }); + + socket.on('error', err => { + isClosed = true; + if (isFallback) { + if (directSocket) { + directSocket.destroy(err); + } + } else { + stream.destroy(err); + } + }); + } +} diff --git a/src/sqladmin-fetcher.ts b/src/sqladmin-fetcher.ts index 566cc6af..a6ebe2d0 100644 --- a/src/sqladmin-fetcher.ts +++ b/src/sqladmin-fetcher.ts @@ -26,7 +26,7 @@ import {AuthTypes} from './auth-types'; export interface InstanceMetadata { ipAddresses: IpAddresses; - serverCaCert: SslCert; + serverCaCert?: SslCert; serverCaMode: string; dnsName: string; } @@ -88,7 +88,7 @@ export interface SQLAdminFetcherOptions { export class SQLAdminFetcher { private readonly client: sqladmin_v1beta4.Sqladmin; private readonly auth: GoogleAuth; - private readonly adminAuth: GoogleAuth; + public readonly adminAuth: GoogleAuth; constructor({ loginAuth, @@ -186,12 +186,8 @@ export class SQLAdminFetcher { ipAddresses.psc = dnsName.replace(/\.$/, ''); } - if (!ipAddresses.public && !ipAddresses.private && !ipAddresses.psc) { - throw new CloudSQLConnectorError({ - message: 'Cannot connect to instance, it has no supported IP addresses', - code: 'ENOSQLADMINIPADDRESS', - }); - } + // Do not throw here if there are no IP addresses. + // IP address validation will be performed inside selectIpAddress. return ipAddresses; } @@ -226,12 +222,6 @@ export class SQLAdminFetcher { ); const {serverCaCert} = res.data; - if (!serverCaCert || !serverCaCert.cert || !serverCaCert.expirationTime) { - throw new CloudSQLConnectorError({ - message: 'Cannot connect to instance, no valid CA certificate found', - code: 'ENOSQLADMINCERT', - }); - } const {region} = res.data; if (!region) { @@ -265,10 +255,13 @@ export class SQLAdminFetcher { return { ipAddresses, - serverCaCert: { - cert: serverCaCert.cert, - expirationTime: serverCaCert.expirationTime, - }, + serverCaCert: + serverCaCert && serverCaCert.cert && serverCaCert.expirationTime + ? { + cert: serverCaCert.cert, + expirationTime: serverCaCert.expirationTime, + } + : undefined, serverCaMode: res.data.serverCaMode || '', dnsName: serverName || '', }; diff --git a/system-test/pg-connect.cjs b/system-test/pg-connect.cjs index 8c52b160..9cea62ad 100644 --- a/system-test/pg-connect.cjs +++ b/system-test/pg-connect.cjs @@ -232,3 +232,81 @@ t.test('open connection to MCP instance retrieves standard pg tables', async t = const returnedDate = result['now']; t.ok(returnedDate.getTime(), 'should have valid returned date object'); }); + +t.test('open connection to AIDE instance retrieves standard pg tables', async t => { + const connectionName = process.env.POSTGRES_AIDE_CONNECTION_NAME; + const user = process.env.POSTGRES_AIDE_USER; + const pass = process.env.POSTGRES_AIDE_PASS; + const database = process.env.POSTGRES_AIDE_DB; + if (!connectionName || !user || !pass || !database) { + t.skip('POSTGRES_AIDE_* env vars not set, skipping'); + return; + } + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: 'SQL_DATA', + }); + const client = new Client({ + ...clientOpts, + user, + password: pass, + database, + }); + 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 standard instance with SQL_DATA ipType falls back to public IP and retrieves standard pg tables', + async t => { + const connectionName = process.env.POSTGRES_FALLBACK_CONNECTION_NAME; + const user = process.env.POSTGRES_FALLBACK_USER; + const pass = process.env.POSTGRES_FALLBACK_PASS; + const database = process.env.POSTGRES_FALLBACK_DB; + if (!connectionName || !user || !pass || !database) { + t.skip('POSTGRES_FALLBACK_* env vars not set, skipping'); + return; + } + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: 'SQL_DATA', + }); + const client = new Client({ + ...clientOpts, + user, + password: pass, + database, + }); + 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'); + } +); + diff --git a/system-test/pg-connect.mjs b/system-test/pg-connect.mjs index 2476492d..cf3d93d3 100644 --- a/system-test/pg-connect.mjs +++ b/system-test/pg-connect.mjs @@ -236,3 +236,84 @@ t.test( t.ok(returnedDate.getTime(), 'should have valid returned date object'); } ); + +t.test( + 'open connection to AIDE instance retrieves standard pg tables', + async t => { + const connectionName = process.env.POSTGRES_AIDE_CONNECTION_NAME; + const user = process.env.POSTGRES_AIDE_USER; + const pass = process.env.POSTGRES_AIDE_PASS; + const database = process.env.POSTGRES_AIDE_DB; + if (!connectionName || !user || !pass || !database) { + t.skip('POSTGRES_AIDE_* env vars not set, skipping'); + return; + } + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: 'SQL_DATA', + }); + const client = new Client({ + ...clientOpts, + user, + password: pass, + database, + }); + 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 standard instance with SQL_DATA ipType falls back to public IP and retrieves standard pg tables', + async t => { + const connectionName = process.env.POSTGRES_FALLBACK_CONNECTION_NAME; + const user = process.env.POSTGRES_FALLBACK_USER; + const pass = process.env.POSTGRES_FALLBACK_PASS; + const database = process.env.POSTGRES_FALLBACK_DB; + if (!connectionName || !user || !pass || !database) { + t.skip('POSTGRES_FALLBACK_* env vars not set, skipping'); + return; + } + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: 'SQL_DATA', + }); + const client = new Client({ + ...clientOpts, + user, + password: pass, + database, + }); + 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'); + } +); + diff --git a/system-test/pg-connect.ts b/system-test/pg-connect.ts index 436504d6..366b5974 100644 --- a/system-test/pg-connect.ts +++ b/system-test/pg-connect.ts @@ -239,3 +239,83 @@ t.test( t.ok(returnedDate.getTime(), 'should have valid returned date object'); } ); + +t.test( + 'open connection to AIDE instance retrieves standard pg tables', + async t => { + const connectionName = process.env.POSTGRES_AIDE_CONNECTION_NAME; + const user = process.env.POSTGRES_AIDE_USER; + const pass = process.env.POSTGRES_AIDE_PASS; + const database = process.env.POSTGRES_AIDE_DB; + if (!connectionName || !user || !pass || !database) { + t.skip('POSTGRES_AIDE_* env vars not set, skipping'); + return; + } + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: IpAddressTypes.SQL_DATA, + }); + const client = new Client({ + ...clientOpts, + user, + password: pass, + database, + }); + 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 standard instance with SQL_DATA ipType falls back to public IP and retrieves standard pg tables', + async t => { + const connectionName = process.env.POSTGRES_FALLBACK_CONNECTION_NAME; + const user = process.env.POSTGRES_FALLBACK_USER; + const pass = process.env.POSTGRES_FALLBACK_PASS; + const database = process.env.POSTGRES_FALLBACK_DB; + if (!connectionName || !user || !pass || !database) { + t.skip('POSTGRES_FALLBACK_* env vars not set, skipping'); + return; + } + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: IpAddressTypes.SQL_DATA, + }); + const client = new Client({ + ...clientOpts, + user, + password: pass, + database, + }); + 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'); + } +); diff --git a/test/connector.ts b/test/connector.ts index dda4eb95..424fbcc2 100644 --- a/test/connector.ts +++ b/test/connector.ts @@ -14,12 +14,13 @@ import {EventEmitter} from 'node:events'; import t from 'tap'; -import {Connector} from '../src/connector'; +import {Connector, cooldownBackoff} from '../src/connector'; import {setupCredentials} from './fixtures/setup-credentials'; import {IpAddressTypes} from '../src/ip-addresses'; import {CA_CERT, CLIENT_CERT, CLIENT_KEY} from './fixtures/certs'; import {AuthTypes} from '../src/auth-types'; import {SQLAdminFetcherOptions} from '../src/sqladmin-fetcher'; +import {SqlDataClientOptions} from '../src/sql-data-client'; t.test('Connector', async t => { setupCredentials(t); // setup google-auth credentials mocks @@ -851,3 +852,447 @@ t.test('Connector startLocalProxy manages and cleans up sockets', async t => { 'remaining stream socket destroyed' ); }); + +t.test( + 'Connector getOptions with SQL_DATA does not mutate caller options', + async t => { + setupCredentials(t); + const {Connector} = t.mockRequire('../src/connector', { + '../src/sqladmin-fetcher': { + SQLAdminFetcher: class { + getInstanceMetadata() { + return Promise.resolve({ + ipAddresses: { + public: '127.0.0.1', + }, + serverCaCert: { + cert: CA_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }, + }); + } + }, + }, + '../src/sql-data-client': { + SqlDataClient: class { + start() { + return Promise.resolve(54321); + } + close() { + return Promise.resolve(); + } + }, + }, + }); + + const connector = new Connector(); + const inputOpts = { + ipType: IpAddressTypes.SQL_DATA, + instanceConnectionName: 'my-project:us-east1:my-instance', + }; + + const opts = await connector.getOptions(inputOpts); + t.same(typeof opts.stream, 'function', 'should return stream function'); + t.same( + inputOpts.ipType, + IpAddressTypes.SQL_DATA, + 'should not mutate inputOpts.ipType' + ); + + connector.close(); + } +); + +t.test( + 'Connector getOptions fallback selects private IP when public is not available', + async t => { + setupCredentials(t); + let capturedFallbackOpts: SqlDataClientOptions | undefined; + const {Connector} = t.mockRequire('../src/connector', { + '../src/sqladmin-fetcher': { + SQLAdminFetcher: class { + getInstanceMetadata() { + return Promise.resolve({ + ipAddresses: { + private: '10.0.0.1', + }, + serverCaCert: { + cert: CA_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }, + }); + } + getEphemeralCertificate() { + return Promise.resolve({ + cert: CLIENT_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }); + } + }, + }, + '../src/cloud-sql-instance': t.mockRequire('../src/cloud-sql-instance', { + '../src/crypto': { + generateKeys: async () => ({ + publicKey: '-----BEGIN PUBLIC KEY-----', + privateKey: CLIENT_KEY, + }), + }, + }), + '../src/sql-data-client': { + SqlDataClient: class { + private readonly opts: SqlDataClientOptions; + constructor(opts: SqlDataClientOptions) { + this.opts = opts; + capturedFallbackOpts = opts; + } + start() { + return Promise.resolve(54321); + } + close() { + return Promise.resolve(); + } + }, + }, + }); + + const connector = new Connector(); + await connector.getOptions({ + ipType: IpAddressTypes.SQL_DATA, + instanceConnectionName: 'my-project:us-east1:my-instance', + sqlDataKeepAliveTimeMs: 45000, + sqlDataKeepAliveTimeoutMs: 15000, + }); + + t.same( + capturedFallbackOpts?.keepAliveTimeMs, + 45000, + 'should pass keepAliveTimeMs' + ); + t.same( + capturedFallbackOpts?.keepAliveTimeoutMs, + 15000, + 'should pass keepAliveTimeoutMs' + ); + + // Invoke getDirectSocket to test fallback resolution + const directSocket = await capturedFallbackOpts?.getDirectSocket?.(); + t.ok(directSocket, 'should return direct TLS socket'); + + connector.close(); + } +); + +t.test( + 'Connector getOptions fallback prefers PRIVATE over PSC and PUBLIC', + async t => { + setupCredentials(t); + let capturedFallbackOpts: SqlDataClientOptions | undefined; + const {Connector} = t.mockRequire('../src/connector', { + '../src/sqladmin-fetcher': { + SQLAdminFetcher: class { + getInstanceMetadata() { + return Promise.resolve({ + ipAddresses: { + public: '127.0.0.1', + private: '10.0.0.1', + psc: 'abcde.12345.us-central1.sql.goog', + }, + serverCaCert: { + cert: CA_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }, + }); + } + getEphemeralCertificate() { + return Promise.resolve({ + cert: CLIENT_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }); + } + }, + }, + '../src/cloud-sql-instance': t.mockRequire('../src/cloud-sql-instance', { + '../src/crypto': { + generateKeys: async () => ({ + publicKey: '-----BEGIN PUBLIC KEY-----', + privateKey: CLIENT_KEY, + }), + }, + }), + '../src/sql-data-client': { + SqlDataClient: class { + constructor(opts: SqlDataClientOptions) { + capturedFallbackOpts = opts; + } + start() { + return Promise.resolve(54321); + } + close() { + return Promise.resolve(); + } + }, + }, + }); + + const connector = new Connector(); + await connector.getOptions({ + ipType: IpAddressTypes.SQL_DATA, + instanceConnectionName: 'my-project:us-east1:my-instance', + }); + + // Invoke getDirectSocket and verify fallback IP is PRIVATE + const directSocket = await capturedFallbackOpts?.getDirectSocket?.(); + t.ok(directSocket, 'should return direct TLS socket'); + t.same( + connector['sqlDataFallbackIpTypes'].get( + 'my-project:us-east1:my-instance' + ), + IpAddressTypes.PRIVATE, + 'should select PRIVATE as highest fallback priority' + ); + + connector.close(); + } +); + +t.test( + 'Connector getOptions fallback prefers PSC over PUBLIC when PRIVATE is missing', + async t => { + setupCredentials(t); + let capturedFallbackOpts: SqlDataClientOptions | undefined; + const {Connector} = t.mockRequire('../src/connector', { + '../src/sqladmin-fetcher': { + SQLAdminFetcher: class { + getInstanceMetadata() { + return Promise.resolve({ + ipAddresses: { + public: '127.0.0.1', + psc: 'abcde.12345.us-central1.sql.goog', + }, + serverCaCert: { + cert: CA_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }, + }); + } + getEphemeralCertificate() { + return Promise.resolve({ + cert: CLIENT_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }); + } + }, + }, + '../src/cloud-sql-instance': t.mockRequire('../src/cloud-sql-instance', { + '../src/crypto': { + generateKeys: async () => ({ + publicKey: '-----BEGIN PUBLIC KEY-----', + privateKey: CLIENT_KEY, + }), + }, + }), + '../src/sql-data-client': { + SqlDataClient: class { + constructor(opts: SqlDataClientOptions) { + capturedFallbackOpts = opts; + } + start() { + return Promise.resolve(54321); + } + close() { + return Promise.resolve(); + } + }, + }, + }); + + const connector = new Connector(); + await connector.getOptions({ + ipType: IpAddressTypes.SQL_DATA, + instanceConnectionName: 'my-project:us-east1:my-instance', + }); + + // Invoke getDirectSocket and verify fallback IP is PSC + const directSocket = await capturedFallbackOpts?.getDirectSocket?.(); + t.ok(directSocket, 'should return direct TLS socket'); + t.same( + connector['sqlDataFallbackIpTypes'].get( + 'my-project:us-east1:my-instance' + ), + IpAddressTypes.PSC, + 'should select PSC over PUBLIC when PRIVATE is missing' + ); + + connector.close(); + } +); + +t.test('cooldownBackoff calculates expected backoff with jitter', async t => { + const base = 500; // 500ms + + // Attempt 1: exp in [0, 1) -> 500 * [1, 1.618) = [500, 809] + for (let i = 0; i < 20; i++) { + const b1 = cooldownBackoff(base, 1); + t.ok( + b1 >= 500 && b1 <= 809, + `attempt 1 backoff ${b1} should be in [500, 809]` + ); + } + + // Attempt 2: exp in [1, 2) -> 500 * [1.618, 2.618) = [809, 1309] + for (let i = 0; i < 20; i++) { + const b2 = cooldownBackoff(base, 2); + t.ok( + b2 >= 809 && b2 <= 1309, + `attempt 2 backoff ${b2} should be in [809, 1309]` + ); + } + + // Attempt 3: exp in [2, 3) -> 500 * [2.618, 4.236) = [1309, 2118] + for (let i = 0; i < 20; i++) { + const b3 = cooldownBackoff(base, 3); + t.ok( + b3 >= 1309 && b3 <= 2118, + `attempt 3 backoff ${b3} should be in [1309, 2118]` + ); + } +}); + +t.test('Connector handles ResourceExhausted cooldown and reset', async t => { + setupCredentials(t); + let capturedOpts: SqlDataClientOptions | undefined; + const {Connector} = t.mockRequire('../src/connector', { + '../src/sqladmin-fetcher': { + SQLAdminFetcher: class { + getInstanceMetadata() { + return Promise.resolve({ + ipAddresses: { + public: '127.0.0.1', + }, + serverCaCert: { + cert: CA_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }, + }); + } + getEphemeralCertificate() { + return Promise.resolve({ + cert: CLIENT_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }); + } + }, + }, + '../src/cloud-sql-instance': t.mockRequire('../src/cloud-sql-instance', { + '../src/crypto': { + generateKeys: async () => ({ + publicKey: '-----BEGIN PUBLIC KEY-----', + privateKey: CLIENT_KEY, + }), + }, + }), + '../src/sql-data-client': { + SqlDataClient: class { + constructor(opts: SqlDataClientOptions) { + capturedOpts = opts; + } + start() { + return Promise.resolve(54321); + } + close() { + return Promise.resolve(); + } + }, + }, + }); + + const cooldownPeriod = 500; // 500ms + const connector = new Connector({ + resourceExhaustedCooldownPeriod: cooldownPeriod, + }); + + const driverOptions = await connector.getOptions({ + ipType: IpAddressTypes.SQL_DATA, + instanceConnectionName: 'my-project:us-east1:my-instance', + }); + + t.ok(capturedOpts, 'SqlDataClientOptions should be captured'); + + // 1. Initial State: no cooldown + // Calling stream() should succeed (connects to tunnel) + const sock1 = driverOptions.stream(); + t.ok(sock1, 'stream should return socket'); + sock1.destroy(); + + // 2. First failure (ResourceExhausted) + const dummyErr = new Error('Resource busy'); + capturedOpts?.onResourceExhausted?.(dummyErr); + + // 3. Second call during active cooldown should fail immediately + await t.rejects( + async () => { + await connector.getOptions({ + ipType: IpAddressTypes.SQL_DATA, + instanceConnectionName: 'my-project:us-east1:my-instance', + }); + }, + { + name: 'CloudSQLConnectorError', + code: 'ERESOURCEEXHAUSTED', + }, + 'getOptions should throw ERESOURCEEXHAUSTED during cooldown' + ); + + t.throws( + () => { + driverOptions.stream(); + }, + { + name: 'CloudSQLConnectorError', + code: 'ERESOURCEEXHAUSTED', + }, + 'stream should throw ERESOURCEEXHAUSTED during cooldown' + ); + + // Wait for first cooldown to expire (~500ms to 809ms) + await new Promise(resolve => setTimeout(resolve, 900)); + + // Now getOptions and stream() should succeed again + const driverOptions2 = await connector.getOptions({ + ipType: IpAddressTypes.SQL_DATA, + instanceConnectionName: 'my-project:us-east1:my-instance', + }); + const sock2 = driverOptions2.stream(); + t.ok(sock2, 'stream should return socket after cooldown expires'); + sock2.destroy(); + + // 4. Second failure increments backoff counter to 2 + capturedOpts?.onResourceExhausted?.(dummyErr); + + t.throws( + () => { + driverOptions2.stream(); + }, + { + name: 'CloudSQLConnectorError', + code: 'ERESOURCEEXHAUSTED', + }, + 'stream should throw ERESOURCEEXHAUSTED after second failure' + ); + + // Wait for second cooldown to expire (~809ms to 1309ms) + await new Promise(resolve => setTimeout(resolve, 1400)); + + // 5. Success resets backoff counter and clears cooldown + capturedOpts?.onSuccess?.(); + + const driverOptions3 = await connector.getOptions({ + ipType: IpAddressTypes.SQL_DATA, + instanceConnectionName: 'my-project:us-east1:my-instance', + }); + const sock3 = driverOptions3.stream(); + t.ok(sock3, 'stream should return socket after success reset'); + sock3.destroy(); + + connector.close(); +}); diff --git a/test/ip-addresses.ts b/test/ip-addresses.ts index 1f0c782d..fabfb54b 100644 --- a/test/ip-addresses.ts +++ b/test/ip-addresses.ts @@ -74,3 +74,57 @@ t.same( 'abcde.12345.us-central1.sql.goog', 'should select psc ip' ); + +t.same( + selectIpAddress( + { + public: '0.0.0.0', + private: '0.0.0.2', + }, + IpAddressTypes.SQL_DATA + ), + '0.0.0.2', + 'should select private ip as fallback for SQL_DATA when public is also present' +); + +t.same( + selectIpAddress( + { + public: '0.0.0.0', + psc: 'abcde.12345.us-central1.sql.goog', + }, + IpAddressTypes.SQL_DATA + ), + 'abcde.12345.us-central1.sql.goog', + 'should select psc ip as fallback for SQL_DATA when public is also present' +); + +t.same( + selectIpAddress( + { + public: '0.0.0.0', + private: '0.0.0.2', + psc: 'abcde.12345.us-central1.sql.goog', + }, + IpAddressTypes.SQL_DATA + ), + '0.0.0.2', + 'should select private ip as highest priority fallback for SQL_DATA' +); + +t.same( + selectIpAddress( + { + public: '0.0.0.0', + }, + IpAddressTypes.SQL_DATA + ), + '0.0.0.0', + 'should select public ip as fallback for SQL_DATA if private and psc are missing' +); + +t.same( + selectIpAddress({}, IpAddressTypes.SQL_DATA), + '', + 'should return empty string if no private, psc, or public ip fallback found for SQL_DATA' +); diff --git a/test/sql-data-client.ts b/test/sql-data-client.ts new file mode 100644 index 00000000..eb8d99bb --- /dev/null +++ b/test/sql-data-client.ts @@ -0,0 +1,592 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import t from 'tap'; +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; +import * as net from 'node:net'; +import {resolve} from 'node:path'; +import {SqlDataClient} from '../src/sql-data-client'; +import {AuthClient} from 'google-auth-library'; + +const protoPath = resolve( + __dirname, + '../node_modules/@google-cloud/sql/build/protos/google/cloud/sql/v1beta4/cloud_sql_data.proto' +); +const packageDefinition = protoLoader.loadSync(protoPath, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [ + resolve(__dirname, '../node_modules/@google-cloud/sql/build/protos'), + resolve(__dirname, '../node_modules/google-gax/build/protos'), + ], +}); +const protoDescriptor = grpc.loadPackageDefinition(packageDefinition); +const googleProto = protoDescriptor.google as grpc.GrpcObject; +const cloudProto = googleProto.cloud as grpc.GrpcObject; +const sqlProto = cloudProto.sql as grpc.GrpcObject; +const v1beta4 = sqlProto.v1beta4 as grpc.GrpcObject; +interface StreamSqlDataRequest { + start_session?: { + location_id: string; + instance_id: string; + session_id?: string; + }; + data?: { + first_byte_offset: number; + data: Buffer; + }; +} + +// Prevent grpc-js from routing localhost test connections through corporate proxies +process.env.NO_PROXY = '127.0.0.1,localhost,::1'; +process.env.no_proxy = '127.0.0.1,localhost,::1'; + +const SqlDataServiceClientClass = + v1beta4.SqlDataService as grpc.ServiceClientConstructor; + +const mockAuth = { + getRequestHeaders: async () => ({ + authorization: 'Bearer fake-token', + }), + getUniverseDomain: async () => 'googleapis.com', +} as unknown as AuthClient; + +// Helper to start fake gRPC server +function startFakeServer( + handler: ( + call: grpc.ServerDuplexStream + ) => void +): Promise<{server: grpc.Server; port: number}> { + const server = new grpc.Server(); + server.addService(SqlDataServiceClientClass.service, { + StreamSqlData: handler, + }); + return new Promise((res, rej) => { + server.bindAsync( + '127.0.0.1:0', + grpc.ServerCredentials.createInsecure(), + (err, port) => { + if (err) { + rej(err); + } else { + res({server, port}); + } + } + ); + }); +} + +t.test('SqlDataClient socket tunnel', async t => { + t.test('should transfer data bidirectionally', async t => { + let serverReceivedStartSession = false; + let serverReceivedClientData = false; + let serverReceivedEnd = false; + let clientReceivedServerData = false; + + let resolveServerEnded: () => void; + const serverEnded = new Promise(res => { + resolveServerEnded = res; + }); + + const testClientData = Buffer.from('client hello'); + const testServerData = Buffer.from('server hello'); + + const {server, port} = await startFakeServer(call => { + call.on('data', request => { + if (request.start_session) { + serverReceivedStartSession = true; + // Send SessionMetadata as handshake response + call.write({ + session_metadata: { + supported_features: ['SQL_DATA_FEATURE_UNSPECIFIED'], + }, + }); + } else if (request.data) { + const dataBuf = request.data.data; + if (dataBuf.equals(testClientData)) { + serverReceivedClientData = true; + // Respond back with server data + call.write({ + data: { + first_byte_offset: 0, + data: testServerData, + }, + }); + } + } + }); + call.on('end', () => { + serverReceivedEnd = true; + call.end(); + resolveServerEnded(); + }); + }); + + t.teardown(() => { + server.forceShutdown(); + }); + + const client = new SqlDataClient({ + instanceConnectionName: 'proj:reg:inst', + auth: mockAuth, + endpoint: `127.0.0.1:${port}`, + channelCredentials: grpc.credentials.createInsecure(), + }); + + const localPort = await client.start(); + t.ok(localPort > 0, 'should start local server and return a port'); + + // Create a local socket connection + const socket = net.connect({port: localPort, host: '127.0.0.1'}); + + await new Promise((resolve, reject) => { + socket.on('connect', () => { + socket.write(testClientData); + }); + + socket.on('data', data => { + if (data.equals(testServerData)) { + clientReceivedServerData = true; + socket.end(); // close socket + } + }); + + socket.on('end', () => { + resolve(); + }); + + socket.on('error', err => { + reject(err); + }); + }); + + await client.close(); + await serverEnded; // Wait for the gRPC stream on server to finish + + // Verify all assertions + t.ok( + serverReceivedStartSession, + 'server should have received start_session' + ); + t.ok(serverReceivedClientData, 'server should have received client data'); + t.ok(clientReceivedServerData, 'client should have received server data'); + t.ok(serverReceivedEnd, 'server should have received end stream signal'); + }); + + t.test( + 'should fallback to async direct socket when server returns FAILED_PRECONDITION (9)', + async t => { + let unsupportedCalled = false; + const testClientData = Buffer.from('client hello'); + const testDirectResponse = Buffer.from('direct response'); + + // Start a mock direct backend server + const directServer = net.createServer(directConn => { + directConn.on('data', data => { + if (data.equals(testClientData)) { + directConn.write(testDirectResponse); + } + }); + }); + const directPort = await new Promise(res => { + directServer.listen(0, '127.0.0.1', () => { + res((directServer.address() as net.AddressInfo).port); + }); + }); + + t.teardown(() => { + directServer.close(); + }); + + // Start a mock gRPC server returning FAILED_PRECONDITION + const {server, port} = await startFakeServer(call => { + call.on('data', request => { + if (request.start_session) { + const err = { + code: grpc.status.FAILED_PRECONDITION, + details: 'SQL_DATA is not supported for this instance', + }; + call.emit('error', err); + } + }); + }); + + t.teardown(() => { + server.forceShutdown(); + }); + + const client = new SqlDataClient({ + instanceConnectionName: 'proj:reg:inst', + auth: mockAuth, + endpoint: `127.0.0.1:${port}`, + channelCredentials: grpc.credentials.createInsecure(), + getDirectSocket: async () => + net.connect({port: directPort, host: '127.0.0.1'}), + onUnsupported: () => { + unsupportedCalled = true; + }, + }); + + const localPort = await client.start(); + const socket = net.connect({port: localPort, host: '127.0.0.1'}); + + let receivedData = Buffer.alloc(0); + await new Promise((resolve, reject) => { + socket.on('connect', () => { + socket.write(testClientData); + }); + socket.on('data', chunk => { + receivedData = Buffer.concat([receivedData, chunk]); + if (receivedData.equals(testDirectResponse)) { + socket.end(); + } + }); + socket.on('end', () => { + resolve(); + }); + socket.on('error', err => { + reject(err); + }); + }); + + await client.close(); + + t.ok(unsupportedCalled, 'onUnsupported callback should have been called'); + t.same( + receivedData, + testDirectResponse, + 'client should receive direct response via fallback' + ); + } + ); + + t.test( + 'should destroy client socket on non-FAILED_PRECONDITION error', + async t => { + const {server, port} = await startFakeServer(call => { + call.on('data', request => { + if (request.start_session) { + const err = { + code: grpc.status.UNAVAILABLE, + details: 'Service unavailable', + }; + call.emit('error', err); + } + }); + }); + + t.teardown(() => { + server.forceShutdown(); + }); + + const client = new SqlDataClient({ + instanceConnectionName: 'proj:reg:inst', + auth: mockAuth, + endpoint: `127.0.0.1:${port}`, + channelCredentials: grpc.credentials.createInsecure(), + }); + + const localPort = await client.start(); + const socket = net.connect({port: localPort, host: '127.0.0.1'}); + + let socketClosed = false; + await new Promise(resolve => { + socket.on('connect', () => { + socket.write(Buffer.from('hello')); + }); + socket.on('error', () => { + socketClosed = true; + resolve(); + }); + socket.on('close', () => { + socketClosed = true; + resolve(); + }); + }); + + await client.close(); + + t.ok(socketClosed, 'client socket should be closed on server error'); + } + ); + + t.test('should handle server terminateSession response', async t => { + const {server, port} = await startFakeServer(call => { + call.on('data', request => { + if (request.start_session) { + call.write({ + session_metadata: { + supported_features: ['SQL_DATA_FEATURE_UNSPECIFIED'], + }, + }); + call.write({ + terminate_session: { + status: { + code: 10, // ABORTED + message: 'Session aborted by server', + }, + }, + }); + } + }); + }); + + t.teardown(() => { + server.forceShutdown(); + }); + + const client = new SqlDataClient({ + instanceConnectionName: 'proj:reg:inst', + auth: mockAuth, + endpoint: `https://127.0.0.1:${port}`, + channelCredentials: grpc.credentials.createInsecure(), + }); + + const localPort = await client.start(); + const socket = net.connect({port: localPort, host: '127.0.0.1'}); + + let socketClosed = false; + await new Promise(resolve => { + socket.on('connect', () => { + socket.write(Buffer.from('hello')); + }); + socket.on('error', () => { + socketClosed = true; + resolve(); + }); + socket.on('close', () => { + socketClosed = true; + resolve(); + }); + }); + + await client.close(); + + t.ok(socketClosed, 'client socket should be closed on terminateSession'); + }); + + t.test( + 'should destroy active sockets when client.close() is called', + async t => { + const {server, port} = await startFakeServer(call => { + call.on('data', request => { + if (request.start_session) { + call.write({ + session_metadata: { + supported_features: ['SQL_DATA_FEATURE_UNSPECIFIED'], + }, + }); + } + }); + }); + + t.teardown(() => { + server.forceShutdown(); + }); + + const client = new SqlDataClient({ + instanceConnectionName: 'proj:reg:inst', + auth: mockAuth, + endpoint: `127.0.0.1:${port}`, + channelCredentials: grpc.credentials.createInsecure(), + }); + + const localPort = await client.start(); + const socket = net.connect({port: localPort, host: '127.0.0.1'}); + + let socketClosed = false; + await new Promise(resolve => { + socket.on('connect', async () => { + socket.on('error', () => { + socketClosed = true; + resolve(); + }); + socket.on('close', () => { + socketClosed = true; + resolve(); + }); + await client.close(); + }); + }); + + t.ok(socketClosed, 'socket should be closed when client closes'); + } + ); + + t.test( + 'should call onResourceExhausted when server emits RESOURCE_EXHAUSTED error', + async t => { + const {server, port} = await startFakeServer(call => { + call.on('data', request => { + if (request.start_session) { + call.emit('error', { + code: grpc.status.RESOURCE_EXHAUSTED, + details: 'Resource busy', + }); + } + }); + }); + + t.teardown(() => { + server.forceShutdown(); + }); + + let resourceExhaustedCalled = false; + let errorReceived: Error | undefined; + + const client = new SqlDataClient({ + instanceConnectionName: 'proj:reg:inst', + auth: mockAuth, + endpoint: `127.0.0.1:${port}`, + channelCredentials: grpc.credentials.createInsecure(), + onResourceExhausted: err => { + resourceExhaustedCalled = true; + errorReceived = err; + }, + }); + + const localPort = await client.start(); + t.teardown(async () => { + await client.close(); + }); + + const socket = net.connect({port: localPort, host: '127.0.0.1'}); + await new Promise(resolve => { + socket.on('connect', () => { + socket.write(Buffer.from('hello')); + }); + socket.on('error', () => { + resolve(); + }); + socket.on('close', () => { + resolve(); + }); + }); + + t.ok( + resourceExhaustedCalled, + 'onResourceExhausted should be called on RESOURCE_EXHAUSTED error' + ); + t.ok(errorReceived, 'should receive the error'); + } + ); + + t.test( + 'should call onResourceExhausted when server sends terminateSession with RESOURCE_EXHAUSTED', + async t => { + const {server, port} = await startFakeServer(call => { + call.on('data', request => { + if (request.start_session) { + call.write({ + terminate_session: { + status: { + code: grpc.status.RESOURCE_EXHAUSTED, + message: 'Server resource exhausted', + }, + }, + }); + } + }); + }); + + t.teardown(() => { + server.forceShutdown(); + }); + + let resourceExhaustedCalled = false; + + const client = new SqlDataClient({ + instanceConnectionName: 'proj:reg:inst', + auth: mockAuth, + endpoint: `127.0.0.1:${port}`, + channelCredentials: grpc.credentials.createInsecure(), + onResourceExhausted: () => { + resourceExhaustedCalled = true; + }, + }); + + const localPort = await client.start(); + t.teardown(async () => { + await client.close(); + }); + + const socket = net.connect({port: localPort, host: '127.0.0.1'}); + await new Promise(resolve => { + socket.on('connect', () => { + socket.write(Buffer.from('hello')); + }); + socket.on('error', () => { + resolve(); + }); + socket.on('close', () => { + resolve(); + }); + }); + + t.ok( + resourceExhaustedCalled, + 'onResourceExhausted should be called on terminateSession with RESOURCE_EXHAUSTED' + ); + } + ); + + t.test('should call onSuccess on first server data packet', async t => { + const {server, port} = await startFakeServer(call => { + call.on('data', request => { + if (request.start_session) { + call.write({ + data: { + data: Buffer.from('hello from server'), + }, + }); + } + }); + }); + + t.teardown(() => { + server.forceShutdown(); + }); + + let successCalled = false; + + const client = new SqlDataClient({ + instanceConnectionName: 'proj:reg:inst', + auth: mockAuth, + endpoint: `127.0.0.1:${port}`, + channelCredentials: grpc.credentials.createInsecure(), + onSuccess: () => { + successCalled = true; + }, + }); + + const localPort = await client.start(); + t.teardown(async () => { + await client.close(); + }); + + const socket = net.connect({port: localPort, host: '127.0.0.1'}); + await new Promise(resolve => { + socket.on('data', () => { + socket.end(); + resolve(); + }); + }); + + t.ok(successCalled, 'onSuccess should be called when data packet arrives'); + }); +}); diff --git a/test/sqladmin-fetcher.ts b/test/sqladmin-fetcher.ts index 4cd95d2e..8047c3c8 100644 --- a/test/sqladmin-fetcher.ts +++ b/test/sqladmin-fetcher.ts @@ -273,13 +273,8 @@ t.test('getInstanceMetadata no ip', async t => { }); const fetcher = new SQLAdminFetcher(); - t.rejects( - fetcher.getInstanceMetadata(instanceConnectionInfo), - { - code: 'ENOSQLADMINIPADDRESS', - }, - 'should throw no ip type found' - ); + const res = await fetcher.getInstanceMetadata(instanceConnectionInfo); + t.strictSame(res.ipAddresses, {}, 'should return empty ipAddresses'); }); t.test('getInstanceMetadata no valid cert', async t => { @@ -293,12 +288,11 @@ t.test('getInstanceMetadata no valid cert', async t => { }); const fetcher = new SQLAdminFetcher(); - t.rejects( - fetcher.getInstanceMetadata(instanceConnectionInfo), - { - code: 'ENOSQLADMINCERT', - }, - 'should throw no cert error' + const res = await fetcher.getInstanceMetadata(instanceConnectionInfo); + t.strictSame( + res.serverCaCert, + undefined, + 'should return undefined serverCaCert' ); });