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..f2e3a436 100755 --- a/build.sh +++ b/build.sh @@ -136,6 +136,10 @@ 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 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 c6332079..4e1803c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,10 @@ "version": "1.11.3", "license": "Apache-2.0", "dependencies": { + "@google-cloud/sql": "^0.25.0", "@googleapis/sqladmin": "^37.0.0", + "@grpc/grpc-js": "^1.14.4", + "@grpc/proto-loader": "^0.8.1", "gaxios": "^7.3.1", "google-auth-library": "^10.9.1", "p-throttle": "^8.1.0" @@ -582,6 +585,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": "37.0.0", "resolved": "https://registry.npmjs.org/@googleapis/sqladmin/-/sqladmin-37.0.0.tgz", @@ -594,6 +609,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", @@ -791,6 +895,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", @@ -1242,6 +1356,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", @@ -2429,7 +2600,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" @@ -4029,6 +4199,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", @@ -4050,6 +4246,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", @@ -4129,7 +4334,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" @@ -4924,7 +5128,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.*" @@ -5136,6 +5339,243 @@ "node": ">=18" } }, + "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/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", @@ -5950,7 +6390,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", @@ -6142,7 +6581,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": { @@ -6969,6 +7407,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", @@ -7029,7 +7473,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": { @@ -7823,6 +8266,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", @@ -7846,7 +8298,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" @@ -8578,6 +9029,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.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "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", @@ -8928,6 +9414,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", @@ -9055,6 +9550,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", @@ -9551,6 +10059,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", @@ -9562,7 +10085,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" @@ -9703,6 +10225,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", @@ -10003,6 +10531,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", @@ -10541,7 +11084,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": { @@ -10560,6 +11102,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", @@ -10886,7 +11434,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": { @@ -10955,7 +11502,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 f8566d59..1a6f4afb 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", @@ -87,7 +88,9 @@ "url": "git+https://github.com/GoogleCloudPlatform/cloud-sql-nodejs-connector" }, "dependencies": { + "@google-cloud/sql": "^0.25.0", "@googleapis/sqladmin": "^37.0.0", + "@grpc/grpc-js": "^1.14.4", "gaxios": "^7.3.1", "google-auth-library": "^10.9.1", "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 8d9f0e91..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; } @@ -461,7 +473,7 @@ export class CloudSQLInstance { // Add the socket to the list this.sockets.add(socket); // When the socket is closed, remove it. - socket.once('closed', () => { + socket.once('close', () => { this.sockets.delete(socket); }); } diff --git a/src/connector.ts b/src/connector.ts index a665fad5..c09123fc 100644 --- a/src/connector.ts +++ b/src/connector.ts @@ -13,15 +13,19 @@ // 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'; import {getSocket} from './socket'; -import {IpAddressTypes} from './ip-addresses'; +import {IpAddressTypes, selectIpAddress} 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 @@ -46,6 +50,10 @@ export declare interface ConnectionOptions { domainName?: string; failoverPeriod?: number; limitRateInterval?: number; + sqlDataEndpoint?: string; + sqlDataStreamTimeout?: number; + sqlDataKeepAliveTimeMs?: number; + sqlDataKeepAliveTimeoutMs?: number; } export declare interface SocketConnectionOptions extends ConnectionOptions { @@ -53,11 +61,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 @@ -187,6 +195,10 @@ export interface ConnectorOptions { */ universeDomain?: string; userAgent?: string; + sqlDataEndpoint?: string; + sqlDataStreamTimeout?: number; + sqlDataKeepAliveTimeMs?: number; + sqlDataKeepAliveTimeoutMs?: number; } // The Connector class is the main public API to interact @@ -196,6 +208,13 @@ 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 sqlDataTunnels = new Map(); + private readonly sqlDataUnsupportedInstances = new Set(); + private readonly sqlDataFallbackIpTypes = new Map(); constructor(opts: ConnectorOptions = {}) { this.sqlAdminFetcher = new SQLAdminFetcher({ @@ -207,6 +226,10 @@ 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; } // Connector.getOptions is a method that accepts a Cloud SQL instance @@ -222,55 +245,186 @@ 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; + + if ( + ipType === IpAddressTypes.SQL_DATA && + this.sqlDataUnsupportedInstances.has(connectionName) + ) { + ipType = + this.sqlDataFallbackIpTypes.get(connectionName) || + (await this.getFallbackIpType(instanceInfo)); + } + + 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(); - }); - - cloudSqlInstance.addSocket(tlsSocket); - - return tlsSocket; + stream: () => this.createDirectSocket(instances, resolvedOpts), + }; + } + + private async getFallbackIpType( + instanceInfo: InstanceConnectionInfo + ): Promise { + const metadata = + await this.sqlAdminFetcher.getInstanceMetadata(instanceInfo); + const selectedIp = selectIpAddress( + metadata.ipAddresses, + IpAddressTypes.SQL_DATA + ); + if ( + metadata.ipAddresses.private && + selectedIp === metadata.ipAddresses.private + ) { + return IpAddressTypes.PRIVATE; + } + if (metadata.ipAddresses.psc && selectedIp === metadata.ipAddresses.psc) { + return IpAddressTypes.PSC; + } + 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 { + 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: () => { + this.sqlDataUnsupportedInstances.add(connectionName); + }, + }); + this.sqlDataTunnels.set(connectionName, tunnel); + } + + const tunnelPort = await tunnel.start(); + + return { + stream: () => { + if (this.sqlDataUnsupportedInstances.has(connectionName)) { + const fallbackIpType = + this.sqlDataFallbackIpTypes.get(connectionName) || + IpAddressTypes.PUBLIC; + const fallbackOpts: ConnectionOptions = { + ...opts, + ipType: fallbackIpType, + }; + return this.createDirectSocket(instances, fallbackOpts); } - 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; }, }; } @@ -369,5 +523,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/ip-addresses.ts b/src/ip-addresses.ts index 973e0d14..a3d7941f 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.public) { + return getPublicIpAddress(ipAddresses); + } + if (ipAddresses.private) { + return getPrivateIpAddress(ipAddresses); + } + if (ipAddresses.psc) { + return getPSCIpAddress(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..a06e6d04 --- /dev/null +++ b/src/sql-data-client.ts @@ -0,0 +1,318 @@ +// 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; +} + +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 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; + + 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; + } + 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'; + socket.destroy( + new Error( + `gRPC Stream terminated by server: Code ${code}, Message: ${msg}` + ) + ); + } + } + ); + + 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 === 9 && !isEstablished && this.getDirectSocket) { + // FAILED_PRECONDITION: Instance does not support SQL_DATA. + isFallback = true; + 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(); + }); + } catch (directErr) { + socket.destroy(directErr as Error); + } + return; + } + 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 5c652000..014957b7 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, @@ -172,12 +172,8 @@ export class SQLAdminFetcher { ipAddresses.psc = dnsName; } - 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; } @@ -211,13 +207,7 @@ export class SQLAdminFetcher { res.data.pscEnabled ); - 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 serverCaCert = res.data.serverCaCert; const {region} = res.data; if (!region) { @@ -247,10 +237,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..0ca29c1d 100644 --- a/system-test/pg-connect.cjs +++ b/system-test/pg-connect.cjs @@ -232,3 +232,40 @@ 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'); +}); diff --git a/system-test/pg-connect.mjs b/system-test/pg-connect.mjs index 2476492d..f60dc288 100644 --- a/system-test/pg-connect.mjs +++ b/system-test/pg-connect.mjs @@ -236,3 +236,43 @@ 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'); + } +); diff --git a/system-test/pg-connect.ts b/system-test/pg-connect.ts index 436504d6..f088556e 100644 --- a/system-test/pg-connect.ts +++ b/system-test/pg-connect.ts @@ -239,3 +239,43 @@ 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'); + } +); diff --git a/test/connector.ts b/test/connector.ts index 6210a603..ae28e1e4 100644 --- a/test/connector.ts +++ b/test/connector.ts @@ -20,6 +20,7 @@ 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 @@ -694,3 +695,132 @@ t.test( t.same(mockSocket.destroyed, true, 'old instance closed its sockets'); } ); + +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(); + } +); diff --git a/test/ip-addresses.ts b/test/ip-addresses.ts index 1f0c782d..83f96508 100644 --- a/test/ip-addresses.ts +++ b/test/ip-addresses.ts @@ -74,3 +74,43 @@ 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.0', + 'should select public ip as fallback for SQL_DATA' +); + +t.same( + selectIpAddress( + { + private: '0.0.0.2', + }, + IpAddressTypes.SQL_DATA + ), + '0.0.0.2', + 'should select private ip as fallback for SQL_DATA if public is missing' +); + +t.same( + selectIpAddress( + { + 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 if public and private are missing' +); + +t.same( + selectIpAddress({}, IpAddressTypes.SQL_DATA), + '', + 'should return empty string if no public, private, or psc 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..7e6a42c4 --- /dev/null +++ b/test/sql-data-client.ts @@ -0,0 +1,430 @@ +// 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'); + } + ); +}); diff --git a/test/sqladmin-fetcher.ts b/test/sqladmin-fetcher.ts index 0ffed779..5fd0fc3e 100644 --- a/test/sqladmin-fetcher.ts +++ b/test/sqladmin-fetcher.ts @@ -233,13 +233,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 => { @@ -253,12 +248,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' ); });