diff --git a/.github/workflows/lintcommit.js b/.github/workflows/lintcommit.js index 4f329223eef..47e194653a3 100644 --- a/.github/workflows/lintcommit.js +++ b/.github/workflows/lintcommit.js @@ -57,6 +57,7 @@ const scopes = new Set([ 'telemetry', 'toolkit', 'ui', + 'sagemakerunifiedstudio', ]) void scopes diff --git a/.gitignore b/.gitignore index 3541dbf9cae..58b3fc5b72a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ src.gen/* **/src/codewhisperer/client/codewhispererclient.d.ts **/src/codewhisperer/client/codewhispereruserclient.d.ts **/src/auth/sso/oidcclientpkce.d.ts +**/src/sagemakerunifiedstudio/shared/client/gluecatalogapi.d.ts +**/src/sagemakerunifiedstudio/shared/client/sqlworkbench.d.ts # Generated by tests **/src/testFixtures/**/bin diff --git a/package-lock.json b/package-lock.json index 03f036e2314..3cde8bc9641 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7492,6 +7492,1029 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-datazone": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-datazone/-/client-datazone-3.848.0.tgz", + "integrity": "sha512-m9x9G6oQHUVJvt9JsTdU41/nimz11MMmQLptQVgIJcD6VHoHoVXppvPntK7GUkH0T6+0gw63RugGd7kB+xofBQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/credential-provider-node": "3.848.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/client-sso": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.848.0.tgz", + "integrity": "sha512-mD+gOwoeZQvbecVLGoCmY6pS7kg02BHesbtIxUj+PeBqYoZV5uLvjUOmuGfw1SfoSobKvS11urxC9S7zxU/Maw==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/core": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.846.0.tgz", + "integrity": "sha512-7CX0pM906r4WSS68fCTNMTtBCSkTtf3Wggssmx13gD40gcWEZXsU00KzPp1bYheNRyPlAq3rE22xt4wLPXbuxA==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.7.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.846.0.tgz", + "integrity": "sha512-QuCQZET9enja7AWVISY+mpFrEIeHzvkx/JEEbHYzHhUkxcnC2Kq2c0bB7hDihGD0AZd3Xsm653hk1O97qu69zg==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.846.0.tgz", + "integrity": "sha512-Jh1iKUuepdmtreMYozV2ePsPcOF5W9p3U4tWhi3v6nDvz0GsBjzjAROW+BW8XMz9vAD3I9R+8VC3/aq63p5nlw==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.848.0.tgz", + "integrity": "sha512-r6KWOG+En2xujuMhgZu7dzOZV3/M5U/5+PXrG8dLQ3rdPRB3vgp5tc56KMqLwm/EXKRzAOSuw/UE4HfNOAB8Hw==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/credential-provider-env": "3.846.0", + "@aws-sdk/credential-provider-http": "3.846.0", + "@aws-sdk/credential-provider-process": "3.846.0", + "@aws-sdk/credential-provider-sso": "3.848.0", + "@aws-sdk/credential-provider-web-identity": "3.848.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.848.0.tgz", + "integrity": "sha512-AblNesOqdzrfyASBCo1xW3uweiSro4Kft9/htdxLeCVU1KVOnFWA5P937MNahViRmIQm2sPBCqL8ZG0u9lnh5g==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.846.0", + "@aws-sdk/credential-provider-http": "3.846.0", + "@aws-sdk/credential-provider-ini": "3.848.0", + "@aws-sdk/credential-provider-process": "3.846.0", + "@aws-sdk/credential-provider-sso": "3.848.0", + "@aws-sdk/credential-provider-web-identity": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.846.0.tgz", + "integrity": "sha512-mEpwDYarJSH+CIXnnHN0QOe0MXI+HuPStD6gsv3z/7Q6ESl8KRWon3weFZCDnqpiJMUVavlDR0PPlAFg2MQoPg==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.848.0.tgz", + "integrity": "sha512-pozlDXOwJZL0e7w+dqXLgzVDB7oCx4WvtY0sk6l4i07uFliWF/exupb6pIehFWvTUcOvn5aFTTqcQaEzAD5Wsg==", + "dependencies": { + "@aws-sdk/client-sso": "3.848.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/token-providers": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.848.0.tgz", + "integrity": "sha512-D1fRpwPxtVDhcSc/D71exa2gYweV+ocp4D3brF0PgFd//JR3XahZ9W24rVnTQwYEcK9auiBZB89Ltv+WbWN8qw==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.840.0.tgz", + "integrity": "sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/middleware-logger": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.840.0.tgz", + "integrity": "sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.840.0.tgz", + "integrity": "sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.848.0.tgz", + "integrity": "sha512-rjMuqSWJEf169/ByxvBqfdei1iaduAnfolTshsZxwcmLIUtbYrFUmts0HrLQqsAG8feGPpDLHA272oPl+NTCCA==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@smithy/core": "^3.7.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/nested-clients": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.848.0.tgz", + "integrity": "sha512-joLsyyo9u61jnZuyYzo1z7kmS7VgWRAkzSGESVzQHfOA1H2PYeUFek6vLT4+c9xMGrX/Z6B0tkRdzfdOPiatLg==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.840.0.tgz", + "integrity": "sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/token-providers": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.848.0.tgz", + "integrity": "sha512-oNPyM4+Di2Umu0JJRFSxDcKQ35+Chl/rAwD47/bS0cDPI8yrao83mLXLeDqpRPHyQW4sXlP763FZcuAibC0+mg==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/util-endpoints": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.848.0.tgz", + "integrity": "sha512-fY/NuFFCq/78liHvRyFKr+aqq1aA/uuVSANjzr5Ym8c+9Z3HRPE9OrExAHoMrZ6zC8tHerQwlsXYYH5XZ7H+ww==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.840.0.tgz", + "integrity": "sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.848.0.tgz", + "integrity": "sha512-Zz1ft9NiLqbzNj/M0jVNxaoxI2F4tGXN0ZbZIj+KJ+PbJo+w5+Jo6d0UDAtbj3AEd79pjcCaP4OA9NTVzItUdw==", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@aws-sdk/xml-builder": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.821.0.tgz", + "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/config-resolver": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.4.tgz", + "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/core": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.7.1.tgz", + "integrity": "sha512-ExRCsHnXFtBPnM7MkfKBPcBBdHw1h/QS/cbNw4ho95qnyNHvnpmGbR39MIAv9KggTr5qSPxRSEL+hRXlyGyGQw==", + "dependencies": { + "@smithy/middleware-serde": "^4.0.8", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/credential-provider-imds": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.6.tgz", + "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/fetch-http-handler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.0.tgz", + "integrity": "sha512-mADw7MS0bYe2OGKkHYMaqarOXuDwRbO6ArD91XhHcl2ynjGCFF+hvqf0LyQcYxkA1zaWjefSkU7Ne9mqgApSgQ==", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/hash-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.4.tgz", + "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/invalid-dependency": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.4.tgz", + "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/middleware-content-length": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.4.tgz", + "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/middleware-endpoint": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.16.tgz", + "integrity": "sha512-plpa50PIGLqzMR2ANKAw2yOW5YKS626KYKqae3atwucbz4Ve4uQ9K9BEZxDLIFmCu7hKLcrq2zmj4a+PfmUV5w==", + "dependencies": { + "@smithy/core": "^3.7.1", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/middleware-retry": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.17.tgz", + "integrity": "sha512-gsCimeG6BApj0SBecwa1Be+Z+JOJe46iy3B3m3A8jKJHf7eIihP76Is4LwLrbJ1ygoS7Vg73lfqzejmLOrazUA==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/service-error-classification": "^4.0.6", + "@smithy/smithy-client": "^4.4.8", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/middleware-serde": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.8.tgz", + "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/middleware-stack": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.4.tgz", + "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/node-config-provider": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", + "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/node-http-handler": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.0.tgz", + "integrity": "sha512-vqfSiHz2v8b3TTTrdXi03vNz1KLYYS3bhHCDv36FYDqxT7jvTll1mMnCrkD+gOvgwybuunh/2VmvOMqwBegxEg==", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/property-provider": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", + "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/protocol-http": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/querystring-builder": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.4.tgz", + "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-uri-escape": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/querystring-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", + "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/service-error-classification": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.6.tgz", + "integrity": "sha512-RRoTDL//7xi4tn5FrN2NzH17jbgmnKidUqd4KvquT0954/i6CXXkh1884jBiunq24g9cGtPBEXlU40W6EpNOOg==", + "dependencies": { + "@smithy/types": "^4.3.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", + "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/signature-v4": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.2.tgz", + "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/smithy-client": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.8.tgz", + "integrity": "sha512-pcW691/lx7V54gE+dDGC26nxz8nrvnvRSCJaIYD6XLPpOInEZeKdV/SpSux+wqeQ4Ine7LJQu8uxMvobTIBK0w==", + "dependencies": { + "@smithy/core": "^3.7.1", + "@smithy/middleware-endpoint": "^4.1.16", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/url-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", + "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", + "dependencies": { + "@smithy/querystring-parser": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.24.tgz", + "integrity": "sha512-UkQNgaQ+bidw1MgdgPO1z1k95W/v8Ej/5o/T/Is8PiVUYPspl/ZxV6WO/8DrzZQu5ULnmpB9CDdMSRwgRc21AA==", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.8", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.24.tgz", + "integrity": "sha512-phvGi/15Z4MpuQibTLOYIumvLdXb+XIJu8TA55voGgboln85jytA3wiD7CkUE8SNcWqkkb+uptZKPiuFouX/7g==", + "dependencies": { + "@smithy/config-resolver": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.8", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-endpoints": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", + "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-middleware": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.4.tgz", + "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-retry": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.6.tgz", + "integrity": "sha512-+YekoF2CaSMv6zKrA6iI/N9yva3Gzn4L6n35Luydweu5MMPYpiGZlWqehPHDHyNbnyaYlz/WJyYAZnC+loBDZg==", + "dependencies": { + "@smithy/service-error-classification": "^4.0.6", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-stream": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.3.tgz", + "integrity": "sha512-cQn412DWHHFNKrQfbHY8vSFI3nTROY1aIKji9N0tpp8gUABRilr7wdf8fqBbSlXresobM+tQFNk6I+0LXK/YZg==", + "dependencies": { + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@aws-sdk/client-datazone/node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, "node_modules/@aws-sdk/client-ec2": { "version": "3.695.0", "license": "Apache-2.0", @@ -7505,18 +8528,2122 @@ "@aws-sdk/middleware-host-header": "3.693.0", "@aws-sdk/middleware-logger": "3.693.0", "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-sdk-ec2": "3.693.0", + "@aws-sdk/middleware-sdk-ec2": "3.693.0", + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@aws-sdk/util-user-agent-browser": "3.693.0", + "@aws-sdk/util-user-agent-node": "3.693.0", + "@smithy/config-resolver": "^3.0.11", + "@smithy/core": "^2.5.2", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-node": "^3.0.9", + "@smithy/invalid-dependency": "^3.0.9", + "@smithy/middleware-content-length": "^3.0.11", + "@smithy/middleware-endpoint": "^3.2.2", + "@smithy/middleware-retry": "^3.0.26", + "@smithy/middleware-serde": "^3.0.9", + "@smithy/middleware-stack": "^3.0.9", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/url-parser": "^3.0.9", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.26", + "@smithy/util-defaults-mode-node": "^3.0.26", + "@smithy/util-endpoints": "^2.1.5", + "@smithy/util-middleware": "^3.0.9", + "@smithy/util-retry": "^3.0.9", + "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.1.8", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/client-sso": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/middleware-host-header": "3.693.0", + "@aws-sdk/middleware-logger": "3.693.0", + "@aws-sdk/middleware-recursion-detection": "3.693.0", + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@aws-sdk/util-user-agent-browser": "3.693.0", + "@aws-sdk/util-user-agent-node": "3.693.0", + "@smithy/config-resolver": "^3.0.11", + "@smithy/core": "^2.5.2", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-node": "^3.0.9", + "@smithy/invalid-dependency": "^3.0.9", + "@smithy/middleware-content-length": "^3.0.11", + "@smithy/middleware-endpoint": "^3.2.2", + "@smithy/middleware-retry": "^3.0.26", + "@smithy/middleware-serde": "^3.0.9", + "@smithy/middleware-stack": "^3.0.9", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/url-parser": "^3.0.9", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.26", + "@smithy/util-defaults-mode-node": "^3.0.26", + "@smithy/util-endpoints": "^2.1.5", + "@smithy/util-middleware": "^3.0.9", + "@smithy/util-retry": "^3.0.9", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/credential-provider-node": "3.693.0", + "@aws-sdk/middleware-host-header": "3.693.0", + "@aws-sdk/middleware-logger": "3.693.0", + "@aws-sdk/middleware-recursion-detection": "3.693.0", + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@aws-sdk/util-user-agent-browser": "3.693.0", + "@aws-sdk/util-user-agent-node": "3.693.0", + "@smithy/config-resolver": "^3.0.11", + "@smithy/core": "^2.5.2", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-node": "^3.0.9", + "@smithy/invalid-dependency": "^3.0.9", + "@smithy/middleware-content-length": "^3.0.11", + "@smithy/middleware-endpoint": "^3.2.2", + "@smithy/middleware-retry": "^3.0.26", + "@smithy/middleware-serde": "^3.0.9", + "@smithy/middleware-stack": "^3.0.9", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/url-parser": "^3.0.9", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.26", + "@smithy/util-defaults-mode-node": "^3.0.26", + "@smithy/util-endpoints": "^2.1.5", + "@smithy/util-middleware": "^3.0.9", + "@smithy/util-retry": "^3.0.9", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.693.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/client-sts": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.693.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/credential-provider-node": "3.693.0", + "@aws-sdk/middleware-host-header": "3.693.0", + "@aws-sdk/middleware-logger": "3.693.0", + "@aws-sdk/middleware-recursion-detection": "3.693.0", + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@aws-sdk/util-user-agent-browser": "3.693.0", + "@aws-sdk/util-user-agent-node": "3.693.0", + "@smithy/config-resolver": "^3.0.11", + "@smithy/core": "^2.5.2", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-node": "^3.0.9", + "@smithy/invalid-dependency": "^3.0.9", + "@smithy/middleware-content-length": "^3.0.11", + "@smithy/middleware-endpoint": "^3.2.2", + "@smithy/middleware-retry": "^3.0.26", + "@smithy/middleware-serde": "^3.0.9", + "@smithy/middleware-stack": "^3.0.9", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/url-parser": "^3.0.9", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.26", + "@smithy/util-defaults-mode-node": "^3.0.26", + "@smithy/util-endpoints": "^2.1.5", + "@smithy/util-middleware": "^3.0.9", + "@smithy/util-retry": "^3.0.9", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/core": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/core": "^2.5.2", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/property-provider": "^3.1.9", + "@smithy/protocol-http": "^4.1.6", + "@smithy/signature-v4": "^4.2.2", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/util-middleware": "^3.0.9", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/property-provider": "^3.1.9", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/util-stream": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.693.0", + "@aws-sdk/credential-provider-env": "3.693.0", + "@aws-sdk/credential-provider-http": "3.693.0", + "@aws-sdk/credential-provider-process": "3.693.0", + "@aws-sdk/credential-provider-sso": "3.693.0", + "@aws-sdk/credential-provider-web-identity": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/credential-provider-imds": "^3.2.6", + "@smithy/property-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.693.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.693.0", + "@aws-sdk/credential-provider-http": "3.693.0", + "@aws-sdk/credential-provider-ini": "3.693.0", + "@aws-sdk/credential-provider-process": "3.693.0", + "@aws-sdk/credential-provider-sso": "3.693.0", + "@aws-sdk/credential-provider-web-identity": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/credential-provider-imds": "^3.2.6", + "@smithy/property-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.693.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/token-providers": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/property-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/property-provider": "^3.1.9", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.693.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/middleware-logger": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@smithy/core": "^2.5.2", + "@smithy/protocol-http": "^4.1.6", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/types": "^3.7.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.9", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/token-providers": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/property-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.693.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/util-endpoints": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/types": "^3.7.0", + "@smithy/util-endpoints": "^2.1.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/types": "^3.7.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-glue": { + "version": "3.852.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-glue/-/client-glue-3.852.0.tgz", + "integrity": "sha512-5IyZt/gKr0NoUHWGM112ikXrZs+VsA/09bwKDmp4/j250tfaZqgC1zhfBNFkyNisj1JQ0XYjwfzkLnYWlT3Pyw==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/credential-provider-node": "3.848.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/client-sso": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.848.0.tgz", + "integrity": "sha512-mD+gOwoeZQvbecVLGoCmY6pS7kg02BHesbtIxUj+PeBqYoZV5uLvjUOmuGfw1SfoSobKvS11urxC9S7zxU/Maw==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/core": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.846.0.tgz", + "integrity": "sha512-7CX0pM906r4WSS68fCTNMTtBCSkTtf3Wggssmx13gD40gcWEZXsU00KzPp1bYheNRyPlAq3rE22xt4wLPXbuxA==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.7.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.846.0.tgz", + "integrity": "sha512-QuCQZET9enja7AWVISY+mpFrEIeHzvkx/JEEbHYzHhUkxcnC2Kq2c0bB7hDihGD0AZd3Xsm653hk1O97qu69zg==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.846.0.tgz", + "integrity": "sha512-Jh1iKUuepdmtreMYozV2ePsPcOF5W9p3U4tWhi3v6nDvz0GsBjzjAROW+BW8XMz9vAD3I9R+8VC3/aq63p5nlw==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.848.0.tgz", + "integrity": "sha512-r6KWOG+En2xujuMhgZu7dzOZV3/M5U/5+PXrG8dLQ3rdPRB3vgp5tc56KMqLwm/EXKRzAOSuw/UE4HfNOAB8Hw==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/credential-provider-env": "3.846.0", + "@aws-sdk/credential-provider-http": "3.846.0", + "@aws-sdk/credential-provider-process": "3.846.0", + "@aws-sdk/credential-provider-sso": "3.848.0", + "@aws-sdk/credential-provider-web-identity": "3.848.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.848.0.tgz", + "integrity": "sha512-AblNesOqdzrfyASBCo1xW3uweiSro4Kft9/htdxLeCVU1KVOnFWA5P937MNahViRmIQm2sPBCqL8ZG0u9lnh5g==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.846.0", + "@aws-sdk/credential-provider-http": "3.846.0", + "@aws-sdk/credential-provider-ini": "3.848.0", + "@aws-sdk/credential-provider-process": "3.846.0", + "@aws-sdk/credential-provider-sso": "3.848.0", + "@aws-sdk/credential-provider-web-identity": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.846.0.tgz", + "integrity": "sha512-mEpwDYarJSH+CIXnnHN0QOe0MXI+HuPStD6gsv3z/7Q6ESl8KRWon3weFZCDnqpiJMUVavlDR0PPlAFg2MQoPg==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.848.0.tgz", + "integrity": "sha512-pozlDXOwJZL0e7w+dqXLgzVDB7oCx4WvtY0sk6l4i07uFliWF/exupb6pIehFWvTUcOvn5aFTTqcQaEzAD5Wsg==", + "dependencies": { + "@aws-sdk/client-sso": "3.848.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/token-providers": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.848.0.tgz", + "integrity": "sha512-D1fRpwPxtVDhcSc/D71exa2gYweV+ocp4D3brF0PgFd//JR3XahZ9W24rVnTQwYEcK9auiBZB89Ltv+WbWN8qw==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.840.0.tgz", + "integrity": "sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/middleware-logger": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.840.0.tgz", + "integrity": "sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.840.0.tgz", + "integrity": "sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.848.0.tgz", + "integrity": "sha512-rjMuqSWJEf169/ByxvBqfdei1iaduAnfolTshsZxwcmLIUtbYrFUmts0HrLQqsAG8feGPpDLHA272oPl+NTCCA==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@smithy/core": "^3.7.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/nested-clients": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.848.0.tgz", + "integrity": "sha512-joLsyyo9u61jnZuyYzo1z7kmS7VgWRAkzSGESVzQHfOA1H2PYeUFek6vLT4+c9xMGrX/Z6B0tkRdzfdOPiatLg==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.840.0.tgz", + "integrity": "sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/token-providers": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.848.0.tgz", + "integrity": "sha512-oNPyM4+Di2Umu0JJRFSxDcKQ35+Chl/rAwD47/bS0cDPI8yrao83mLXLeDqpRPHyQW4sXlP763FZcuAibC0+mg==", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/util-endpoints": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.848.0.tgz", + "integrity": "sha512-fY/NuFFCq/78liHvRyFKr+aqq1aA/uuVSANjzr5Ym8c+9Z3HRPE9OrExAHoMrZ6zC8tHerQwlsXYYH5XZ7H+ww==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.840.0.tgz", + "integrity": "sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.848.0.tgz", + "integrity": "sha512-Zz1ft9NiLqbzNj/M0jVNxaoxI2F4tGXN0ZbZIj+KJ+PbJo+w5+Jo6d0UDAtbj3AEd79pjcCaP4OA9NTVzItUdw==", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@aws-sdk/xml-builder": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.821.0.tgz", + "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/config-resolver": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.4.tgz", + "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/core": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.7.2.tgz", + "integrity": "sha512-JoLw59sT5Bm8SAjFCYZyuCGxK8y3vovmoVbZWLDPTH5XpPEIwpFd9m90jjVMwoypDuB/SdVgje5Y4T7w50lJaw==", + "dependencies": { + "@smithy/middleware-serde": "^4.0.8", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/credential-provider-imds": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.6.tgz", + "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/fetch-http-handler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.0.tgz", + "integrity": "sha512-mADw7MS0bYe2OGKkHYMaqarOXuDwRbO6ArD91XhHcl2ynjGCFF+hvqf0LyQcYxkA1zaWjefSkU7Ne9mqgApSgQ==", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/hash-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.4.tgz", + "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/invalid-dependency": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.4.tgz", + "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/middleware-content-length": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.4.tgz", + "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/middleware-endpoint": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.17.tgz", + "integrity": "sha512-S3hSGLKmHG1m35p/MObQCBCdRsrpbPU8B129BVzRqRfDvQqPMQ14iO4LyRw+7LNizYc605COYAcjqgawqi+6jA==", + "dependencies": { + "@smithy/core": "^3.7.2", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/middleware-retry": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.18.tgz", + "integrity": "sha512-bYLZ4DkoxSsPxpdmeapvAKy7rM5+25gR7PGxq2iMiecmbrRGBHj9s75N74Ylg+aBiw9i5jIowC/cLU2NR0qH8w==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/service-error-classification": "^4.0.6", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/middleware-serde": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.8.tgz", + "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/middleware-stack": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.4.tgz", + "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/node-config-provider": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", + "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/node-http-handler": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.0.tgz", + "integrity": "sha512-vqfSiHz2v8b3TTTrdXi03vNz1KLYYS3bhHCDv36FYDqxT7jvTll1mMnCrkD+gOvgwybuunh/2VmvOMqwBegxEg==", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/property-provider": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", + "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/protocol-http": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/querystring-builder": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.4.tgz", + "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-uri-escape": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/querystring-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", + "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/service-error-classification": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.6.tgz", + "integrity": "sha512-RRoTDL//7xi4tn5FrN2NzH17jbgmnKidUqd4KvquT0954/i6CXXkh1884jBiunq24g9cGtPBEXlU40W6EpNOOg==", + "dependencies": { + "@smithy/types": "^4.3.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", + "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/signature-v4": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.2.tgz", + "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/smithy-client": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.9.tgz", + "integrity": "sha512-mbMg8mIUAWwMmb74LoYiArP04zWElPzDoA1jVOp3or0cjlDMgoS6WTC3QXK0Vxoc9I4zdrX0tq6qsOmaIoTWEQ==", + "dependencies": { + "@smithy/core": "^3.7.2", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/url-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", + "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", + "dependencies": { + "@smithy/querystring-parser": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.25.tgz", + "integrity": "sha512-pxEWsxIsOPLfKNXvpgFHBGFC3pKYKUFhrud1kyooO9CJai6aaKDHfT10Mi5iiipPXN/JhKAu3qX9o75+X85OdQ==", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.25.tgz", + "integrity": "sha512-+w4n4hKFayeCyELZLfsSQG5mCC3TwSkmRHv4+el5CzFU8ToQpYGhpV7mrRzqlwKkntlPilT1HJy1TVeEvEjWOQ==", + "dependencies": { + "@smithy/config-resolver": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-endpoints": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", + "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-middleware": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.4.tgz", + "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-retry": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.6.tgz", + "integrity": "sha512-+YekoF2CaSMv6zKrA6iI/N9yva3Gzn4L6n35Luydweu5MMPYpiGZlWqehPHDHyNbnyaYlz/WJyYAZnC+loBDZg==", + "dependencies": { + "@smithy/service-error-classification": "^4.0.6", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-stream": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.3.tgz", + "integrity": "sha512-cQn412DWHHFNKrQfbHY8vSFI3nTROY1aIKji9N0tpp8gUABRilr7wdf8fqBbSlXresobM+tQFNk6I+0LXK/YZg==", + "dependencies": { + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@aws-sdk/client-glue/node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, + "node_modules/@aws-sdk/client-iam": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.693.0", + "@aws-sdk/client-sts": "3.693.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/credential-provider-node": "3.693.0", + "@aws-sdk/middleware-host-header": "3.693.0", + "@aws-sdk/middleware-logger": "3.693.0", + "@aws-sdk/middleware-recursion-detection": "3.693.0", + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@aws-sdk/util-user-agent-browser": "3.693.0", + "@aws-sdk/util-user-agent-node": "3.693.0", + "@smithy/config-resolver": "^3.0.11", + "@smithy/core": "^2.5.2", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-node": "^3.0.9", + "@smithy/invalid-dependency": "^3.0.9", + "@smithy/middleware-content-length": "^3.0.11", + "@smithy/middleware-endpoint": "^3.2.2", + "@smithy/middleware-retry": "^3.0.26", + "@smithy/middleware-serde": "^3.0.9", + "@smithy/middleware-stack": "^3.0.9", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/url-parser": "^3.0.9", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.26", + "@smithy/util-defaults-mode-node": "^3.0.26", + "@smithy/util-endpoints": "^2.1.5", + "@smithy/util-middleware": "^3.0.9", + "@smithy/util-retry": "^3.0.9", + "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.1.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/middleware-host-header": "3.693.0", + "@aws-sdk/middleware-logger": "3.693.0", + "@aws-sdk/middleware-recursion-detection": "3.693.0", + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@aws-sdk/util-user-agent-browser": "3.693.0", + "@aws-sdk/util-user-agent-node": "3.693.0", + "@smithy/config-resolver": "^3.0.11", + "@smithy/core": "^2.5.2", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-node": "^3.0.9", + "@smithy/invalid-dependency": "^3.0.9", + "@smithy/middleware-content-length": "^3.0.11", + "@smithy/middleware-endpoint": "^3.2.2", + "@smithy/middleware-retry": "^3.0.26", + "@smithy/middleware-serde": "^3.0.9", + "@smithy/middleware-stack": "^3.0.9", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/url-parser": "^3.0.9", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.26", + "@smithy/util-defaults-mode-node": "^3.0.26", + "@smithy/util-endpoints": "^2.1.5", + "@smithy/util-middleware": "^3.0.9", + "@smithy/util-retry": "^3.0.9", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/credential-provider-node": "3.693.0", + "@aws-sdk/middleware-host-header": "3.693.0", + "@aws-sdk/middleware-logger": "3.693.0", + "@aws-sdk/middleware-recursion-detection": "3.693.0", + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@aws-sdk/util-user-agent-browser": "3.693.0", + "@aws-sdk/util-user-agent-node": "3.693.0", + "@smithy/config-resolver": "^3.0.11", + "@smithy/core": "^2.5.2", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-node": "^3.0.9", + "@smithy/invalid-dependency": "^3.0.9", + "@smithy/middleware-content-length": "^3.0.11", + "@smithy/middleware-endpoint": "^3.2.2", + "@smithy/middleware-retry": "^3.0.26", + "@smithy/middleware-serde": "^3.0.9", + "@smithy/middleware-stack": "^3.0.9", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/url-parser": "^3.0.9", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.26", + "@smithy/util-defaults-mode-node": "^3.0.26", + "@smithy/util-endpoints": "^2.1.5", + "@smithy/util-middleware": "^3.0.9", + "@smithy/util-retry": "^3.0.9", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.693.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sts": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.693.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/credential-provider-node": "3.693.0", + "@aws-sdk/middleware-host-header": "3.693.0", + "@aws-sdk/middleware-logger": "3.693.0", + "@aws-sdk/middleware-recursion-detection": "3.693.0", + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@aws-sdk/util-user-agent-browser": "3.693.0", + "@aws-sdk/util-user-agent-node": "3.693.0", + "@smithy/config-resolver": "^3.0.11", + "@smithy/core": "^2.5.2", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-node": "^3.0.9", + "@smithy/invalid-dependency": "^3.0.9", + "@smithy/middleware-content-length": "^3.0.11", + "@smithy/middleware-endpoint": "^3.2.2", + "@smithy/middleware-retry": "^3.0.26", + "@smithy/middleware-serde": "^3.0.9", + "@smithy/middleware-stack": "^3.0.9", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/url-parser": "^3.0.9", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.26", + "@smithy/util-defaults-mode-node": "^3.0.26", + "@smithy/util-endpoints": "^2.1.5", + "@smithy/util-middleware": "^3.0.9", + "@smithy/util-retry": "^3.0.9", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/core": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/core": "^2.5.2", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/property-provider": "^3.1.9", + "@smithy/protocol-http": "^4.1.6", + "@smithy/signature-v4": "^4.2.2", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/util-middleware": "^3.0.9", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/node-http-handler": "^3.3.0", + "@smithy/property-provider": "^3.1.9", + "@smithy/protocol-http": "^4.1.6", + "@smithy/smithy-client": "^3.4.3", + "@smithy/types": "^3.7.0", + "@smithy/util-stream": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.693.0", + "@aws-sdk/credential-provider-env": "3.693.0", + "@aws-sdk/credential-provider-http": "3.693.0", + "@aws-sdk/credential-provider-process": "3.693.0", + "@aws-sdk/credential-provider-sso": "3.693.0", + "@aws-sdk/credential-provider-web-identity": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/credential-provider-imds": "^3.2.6", + "@smithy/property-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.693.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.693.0", + "@aws-sdk/credential-provider-http": "3.693.0", + "@aws-sdk/credential-provider-ini": "3.693.0", + "@aws-sdk/credential-provider-process": "3.693.0", + "@aws-sdk/credential-provider-sso": "3.693.0", + "@aws-sdk/credential-provider-web-identity": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/credential-provider-imds": "^3.2.6", + "@smithy/property-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.693.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/token-providers": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/property-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/property-provider": "^3.1.9", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.693.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-logger": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/protocol-http": "^4.1.6", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@aws-sdk/util-endpoints": "3.693.0", + "@smithy/core": "^2.5.2", + "@smithy/protocol-http": "^4.1.6", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/types": "^3.7.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.9", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/token-providers": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/property-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.693.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-endpoints": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/types": "^3.7.0", + "@smithy/util-endpoints": "^2.1.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.692.0", + "@smithy/types": "^3.7.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.693.0", + "@aws-sdk/types": "3.692.0", + "@smithy/node-config-provider": "^3.1.10", + "@smithy/types": "^3.7.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-lambda": { + "version": "3.637.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.637.0", + "@aws-sdk/client-sts": "3.637.0", + "@aws-sdk/core": "3.635.0", + "@aws-sdk/credential-provider-node": "3.637.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.637.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.637.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.4.0", + "@smithy/eventstream-serde-browser": "^3.0.6", + "@smithy/eventstream-serde-config-resolver": "^3.0.3", + "@smithy/eventstream-serde-node": "^3.0.5", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.2.0", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.15", + "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-stream": "^3.1.3", + "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-lambda/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-lambda/node_modules/@smithy/fetch-http-handler": { + "version": "3.2.4", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.0", + "@smithy/querystring-builder": "^3.0.3", + "@smithy/types": "^3.3.0", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-lambda/node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-lambda/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-lambda/node_modules/@smithy/util-utf8/node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.693.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.693.0", + "@aws-sdk/client-sts": "3.693.0", + "@aws-sdk/core": "3.693.0", + "@aws-sdk/credential-provider-node": "3.693.0", + "@aws-sdk/middleware-bucket-endpoint": "3.693.0", + "@aws-sdk/middleware-expect-continue": "3.693.0", + "@aws-sdk/middleware-flexible-checksums": "3.693.0", + "@aws-sdk/middleware-host-header": "3.693.0", + "@aws-sdk/middleware-location-constraint": "3.693.0", + "@aws-sdk/middleware-logger": "3.693.0", + "@aws-sdk/middleware-recursion-detection": "3.693.0", + "@aws-sdk/middleware-sdk-s3": "3.693.0", + "@aws-sdk/middleware-ssec": "3.693.0", "@aws-sdk/middleware-user-agent": "3.693.0", "@aws-sdk/region-config-resolver": "3.693.0", + "@aws-sdk/signature-v4-multi-region": "3.693.0", "@aws-sdk/types": "3.692.0", "@aws-sdk/util-endpoints": "3.693.0", "@aws-sdk/util-user-agent-browser": "3.693.0", "@aws-sdk/util-user-agent-node": "3.693.0", + "@aws-sdk/xml-builder": "3.693.0", "@smithy/config-resolver": "^3.0.11", "@smithy/core": "^2.5.2", + "@smithy/eventstream-serde-browser": "^3.0.12", + "@smithy/eventstream-serde-config-resolver": "^3.0.9", + "@smithy/eventstream-serde-node": "^3.0.11", "@smithy/fetch-http-handler": "^4.1.0", + "@smithy/hash-blob-browser": "^3.1.8", "@smithy/hash-node": "^3.0.9", + "@smithy/hash-stream-node": "^3.1.8", "@smithy/invalid-dependency": "^3.0.9", + "@smithy/md5-js": "^3.0.9", "@smithy/middleware-content-length": "^3.0.11", "@smithy/middleware-endpoint": "^3.2.2", "@smithy/middleware-retry": "^3.0.26", @@ -7536,1099 +10663,1135 @@ "@smithy/util-endpoints": "^2.1.5", "@smithy/util-middleware": "^3.0.9", "@smithy/util-retry": "^3.0.9", + "@smithy/util-stream": "^3.3.0", "@smithy/util-utf8": "^3.0.0", "@smithy/util-waiter": "^3.1.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3-control/-/client-s3-control-3.859.0.tgz", + "integrity": "sha512-vzhOtDH4BCdn30+Crg1QxGXbhZIh4Ia84/qNx2EtupkM2UrO6uaZ91qGl175QWU4TcG+mlf/yA/bvrwenhbF6w==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/credential-provider-node": "3.859.0", + "@aws-sdk/middleware-bucket-endpoint": "3.840.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-sdk-s3-control": "3.848.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-blob-browser": "^4.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/hash-stream-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/md5-js": "^4.0.4", + "@smithy/middleware-apply-body-checksum": "^4.1.2", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", "@types/uuid": "^9.0.1", "tslib": "^2.6.2", "uuid": "^9.0.1" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/client-sso": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/client-sso": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.858.0.tgz", + "integrity": "sha512-iXuZQs4KH6a3Pwnt0uORalzAZ5EXRPr3lBYAsdNwkP8OYyoUz5/TE3BLyw7ceEh0rj4QKGNnNALYo1cDm0EV8w==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/middleware-host-header": "3.693.0", - "@aws-sdk/middleware-logger": "3.693.0", - "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/region-config-resolver": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@aws-sdk/util-user-agent-browser": "3.693.0", - "@aws-sdk/util-user-agent-node": "3.693.0", - "@smithy/config-resolver": "^3.0.11", - "@smithy/core": "^2.5.2", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/hash-node": "^3.0.9", - "@smithy/invalid-dependency": "^3.0.9", - "@smithy/middleware-content-length": "^3.0.11", - "@smithy/middleware-endpoint": "^3.2.2", - "@smithy/middleware-retry": "^3.0.26", - "@smithy/middleware-serde": "^3.0.9", - "@smithy/middleware-stack": "^3.0.9", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/url-parser": "^3.0.9", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.26", - "@smithy/util-defaults-mode-node": "^3.0.26", - "@smithy/util-endpoints": "^2.1.5", - "@smithy/util-middleware": "^3.0.9", - "@smithy/util-retry": "^3.0.9", - "@smithy/util-utf8": "^3.0.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/core": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.858.0.tgz", + "integrity": "sha512-iWm4QLAS+/XMlnecIU1Y33qbBr1Ju+pmWam3xVCPlY4CSptKpVY+2hXOnmg9SbHAX9C005fWhrIn51oDd00c9A==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.7.2", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.858.0.tgz", + "integrity": "sha512-kZsGyh2BoSRguzlcGtzdLhw/l/n3KYAC+/l/H0SlsOq3RLHF6tO/cRdsLnwoix2bObChHUp03cex63o1gzdx/Q==", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.858.0.tgz", + "integrity": "sha512-GDnfYl3+NPJQ7WQQYOXEA489B212NinpcIDD7rpsB6IWUPo8yDjT5NceK4uUkIR3MFpNCGt9zd/z6NNLdB2fuQ==", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.859.0.tgz", + "integrity": "sha512-KsccE1T88ZDNhsABnqbQj014n5JMDilAroUErFbGqu5/B3sXqUsYmG54C/BjvGTRUFfzyttK9lB9P9h6ddQ8Cw==", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/credential-provider-env": "3.858.0", + "@aws-sdk/credential-provider-http": "3.858.0", + "@aws-sdk/credential-provider-process": "3.858.0", + "@aws-sdk/credential-provider-sso": "3.859.0", + "@aws-sdk/credential-provider-web-identity": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.859.0.tgz", + "integrity": "sha512-ZRDB2xU5aSyTR/jDcli30tlycu6RFvQngkZhBs9Zoh2BiYXrfh2MMuoYuZk+7uD6D53Q2RIEldDHR9A/TPlRuA==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.858.0", + "@aws-sdk/credential-provider-http": "3.858.0", + "@aws-sdk/credential-provider-ini": "3.859.0", + "@aws-sdk/credential-provider-process": "3.858.0", + "@aws-sdk/credential-provider-sso": "3.859.0", + "@aws-sdk/credential-provider-web-identity": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.858.0.tgz", + "integrity": "sha512-l5LJWZJMRaZ+LhDjtupFUKEC5hAjgvCRrOvV5T60NCUBOy0Ozxa7Sgx3x+EOwiruuoh3Cn9O+RlbQlJX6IfZIw==", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.859.0.tgz", + "integrity": "sha512-BwAqmWIivhox5YlFRjManFF8GoTvEySPk6vsJNxDsmGsabY+OQovYxFIYxRCYiHzH7SFjd4Lcd+riJOiXNsvRw==", + "dependencies": { + "@aws-sdk/client-sso": "3.858.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/token-providers": "3.859.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.858.0.tgz", + "integrity": "sha512-8iULWsH83iZDdUuiDsRb83M0NqIlXjlDbJUIddVsIrfWp4NmanKw77SV6yOZ66nuJjPsn9j7RDb9bfEPCy5SWA==", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.840.0.tgz", + "integrity": "sha512-+gkQNtPwcSMmlwBHFd4saVVS11In6ID1HczNzpM3MXKXRBfSlbZJbCt6wN//AZ8HMklZEik4tcEOG0qa9UY8SQ==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.840.0.tgz", + "integrity": "sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/middleware-logger": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.840.0.tgz", + "integrity": "sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.840.0.tgz", + "integrity": "sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/client-sso-oidc": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.858.0.tgz", + "integrity": "sha512-pC3FT/sRZ6n5NyXiTVu9dpf1D9j3YbJz3XmeOOwJqO/Mib2PZyIQktvNMPgwaC5KMVB1zWqS5bmCwxpMOnq0UQ==", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/credential-provider-node": "3.693.0", - "@aws-sdk/middleware-host-header": "3.693.0", - "@aws-sdk/middleware-logger": "3.693.0", - "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/region-config-resolver": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@aws-sdk/util-user-agent-browser": "3.693.0", - "@aws-sdk/util-user-agent-node": "3.693.0", - "@smithy/config-resolver": "^3.0.11", - "@smithy/core": "^2.5.2", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/hash-node": "^3.0.9", - "@smithy/invalid-dependency": "^3.0.9", - "@smithy/middleware-content-length": "^3.0.11", - "@smithy/middleware-endpoint": "^3.2.2", - "@smithy/middleware-retry": "^3.0.26", - "@smithy/middleware-serde": "^3.0.9", - "@smithy/middleware-stack": "^3.0.9", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/url-parser": "^3.0.9", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.26", - "@smithy/util-defaults-mode-node": "^3.0.26", - "@smithy/util-endpoints": "^2.1.5", - "@smithy/util-middleware": "^3.0.9", - "@smithy/util-retry": "^3.0.9", - "@smithy/util-utf8": "^3.0.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@smithy/core": "^3.7.2", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.693.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/client-sts": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/nested-clients": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.858.0.tgz", + "integrity": "sha512-ChdIj80T2whoWbovmO7o8ICmhEB2S9q4Jes9MBnKAPm69PexcJAK2dQC8yI4/iUP8b3+BHZoUPrYLWjBxIProQ==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.693.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/credential-provider-node": "3.693.0", - "@aws-sdk/middleware-host-header": "3.693.0", - "@aws-sdk/middleware-logger": "3.693.0", - "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/region-config-resolver": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@aws-sdk/util-user-agent-browser": "3.693.0", - "@aws-sdk/util-user-agent-node": "3.693.0", - "@smithy/config-resolver": "^3.0.11", - "@smithy/core": "^2.5.2", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/hash-node": "^3.0.9", - "@smithy/invalid-dependency": "^3.0.9", - "@smithy/middleware-content-length": "^3.0.11", - "@smithy/middleware-endpoint": "^3.2.2", - "@smithy/middleware-retry": "^3.0.26", - "@smithy/middleware-serde": "^3.0.9", - "@smithy/middleware-stack": "^3.0.9", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/url-parser": "^3.0.9", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.26", - "@smithy/util-defaults-mode-node": "^3.0.26", - "@smithy/util-endpoints": "^2.1.5", - "@smithy/util-middleware": "^3.0.9", - "@smithy/util-retry": "^3.0.9", - "@smithy/util-utf8": "^3.0.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/core": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.840.0.tgz", + "integrity": "sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/core": "^2.5.2", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/property-provider": "^3.1.9", - "@smithy/protocol-http": "^4.1.6", - "@smithy/signature-v4": "^4.2.2", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/util-middleware": "^3.0.9", - "fast-xml-parser": "4.4.1", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/token-providers": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.859.0.tgz", + "integrity": "sha512-6P2wlvm9KBWOvRNn0Pt8RntnXg8fzOb5kEShvWsOsAocZeqKNaYbihum5/Onq1ZPoVtkdb++8eWDocDnM4k85Q==", "dependencies": { - "@aws-sdk/core": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/property-provider": "^3.1.9", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/util-stream": "^3.3.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", "dependencies": { - "@aws-sdk/core": "3.693.0", - "@aws-sdk/credential-provider-env": "3.693.0", - "@aws-sdk/credential-provider-http": "3.693.0", - "@aws-sdk/credential-provider-process": "3.693.0", - "@aws-sdk/credential-provider-sso": "3.693.0", - "@aws-sdk/credential-provider-web-identity": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/credential-provider-imds": "^3.2.6", - "@smithy/property-provider": "^3.1.9", - "@smithy/shared-ini-file-loader": "^3.1.10", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.693.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/util-arn-parser": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.804.0.tgz", + "integrity": "sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==", "dependencies": { - "@aws-sdk/credential-provider-env": "3.693.0", - "@aws-sdk/credential-provider-http": "3.693.0", - "@aws-sdk/credential-provider-ini": "3.693.0", - "@aws-sdk/credential-provider-process": "3.693.0", - "@aws-sdk/credential-provider-sso": "3.693.0", - "@aws-sdk/credential-provider-web-identity": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/credential-provider-imds": "^3.2.6", - "@smithy/property-provider": "^3.1.9", - "@smithy/shared-ini-file-loader": "^3.1.10", - "@smithy/types": "^3.7.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/util-endpoints": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.848.0.tgz", + "integrity": "sha512-fY/NuFFCq/78liHvRyFKr+aqq1aA/uuVSANjzr5Ym8c+9Z3HRPE9OrExAHoMrZ6zC8tHerQwlsXYYH5XZ7H+ww==", "dependencies": { - "@aws-sdk/client-sso": "3.693.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/token-providers": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/property-provider": "^3.1.9", - "@smithy/shared-ini-file-loader": "^3.1.10", - "@smithy/types": "^3.7.0", + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-endpoints": "^3.0.6", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.840.0.tgz", + "integrity": "sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==", "dependencies": { - "@aws-sdk/core": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/property-provider": "^3.1.9", - "@smithy/types": "^3.7.0", + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.858.0.tgz", + "integrity": "sha512-T1m05QlN8hFpx5/5duMjS8uFSK5e6EXP45HQRkZULVkL3DK+jMaxsnh3KLl5LjUoHn/19M4HM0wNUBhYp4Y2Yw==", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "peerDependencies": { - "@aws-sdk/client-sts": "^3.693.0" + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@aws-sdk/xml-builder": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.821.0.tgz", + "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/middleware-logger": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/chunked-blob-reader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", + "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/types": "^3.7.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.693.0", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@smithy/core": "^2.5.2", - "@smithy/protocol-http": "^4.1.6", - "@smithy/types": "^3.7.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", + "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", + "dependencies": { + "@smithy/util-base64": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/config-resolver": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.4.tgz", + "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/types": "^3.7.0", - "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-middleware": "^3.0.9", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/token-providers": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/core": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.7.2.tgz", + "integrity": "sha512-JoLw59sT5Bm8SAjFCYZyuCGxK8y3vovmoVbZWLDPTH5XpPEIwpFd9m90jjVMwoypDuB/SdVgje5Y4T7w50lJaw==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/property-provider": "^3.1.9", - "@smithy/shared-ini-file-loader": "^3.1.10", - "@smithy/types": "^3.7.0", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sso-oidc": "^3.693.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/util-endpoints": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/credential-provider-imds": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.6.tgz", + "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/types": "^3.7.0", - "@smithy/util-endpoints": "^2.1.5", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/fetch-http-handler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.0.tgz", + "integrity": "sha512-mADw7MS0bYe2OGKkHYMaqarOXuDwRbO6ArD91XhHcl2ynjGCFF+hvqf0LyQcYxkA1zaWjefSkU7Ne9mqgApSgQ==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/types": "^3.7.0", - "bowser": "^2.11.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/hash-blob-browser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.4.tgz", + "integrity": "sha512-WszRiACJiQV3QG6XMV44i5YWlkrlsM5Yxgz4jvsksuu7LDXA6wAtypfPajtNTadzpJy3KyJPoWehYpmZGKUFIQ==", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/types": "^3.7.0", + "@smithy/chunked-blob-reader": "^5.0.0", + "@smithy/chunked-blob-reader-native": "^4.0.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@smithy/is-array-buffer": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/hash-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.4.tgz", + "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==", "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@smithy/util-buffer-from": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/hash-stream-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.4.tgz", + "integrity": "sha512-wHo0d8GXyVmpmMh/qOR0R7Y46/G1y6OR8U+bSTB4ppEzRxd1xVAQ9xOE9hOc0bSjhz0ujCPAbfNLkLrpa6cevg==", "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", + "@smithy/types": "^4.3.1", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-ec2/node_modules/@smithy/util-utf8": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/invalid-dependency": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.4.tgz", + "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==", "dependencies": { - "@smithy/util-buffer-from": "^3.0.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.693.0", - "@aws-sdk/client-sts": "3.693.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/credential-provider-node": "3.693.0", - "@aws-sdk/middleware-host-header": "3.693.0", - "@aws-sdk/middleware-logger": "3.693.0", - "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/region-config-resolver": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@aws-sdk/util-user-agent-browser": "3.693.0", - "@aws-sdk/util-user-agent-node": "3.693.0", - "@smithy/config-resolver": "^3.0.11", - "@smithy/core": "^2.5.2", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/hash-node": "^3.0.9", - "@smithy/invalid-dependency": "^3.0.9", - "@smithy/middleware-content-length": "^3.0.11", - "@smithy/middleware-endpoint": "^3.2.2", - "@smithy/middleware-retry": "^3.0.26", - "@smithy/middleware-serde": "^3.0.9", - "@smithy/middleware-stack": "^3.0.9", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/url-parser": "^3.0.9", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.26", - "@smithy/util-defaults-mode-node": "^3.0.26", - "@smithy/util-endpoints": "^2.1.5", - "@smithy/util-middleware": "^3.0.9", - "@smithy/util-retry": "^3.0.9", - "@smithy/util-utf8": "^3.0.0", - "@smithy/util-waiter": "^3.1.8", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/md5-js": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.4.tgz", + "integrity": "sha512-uGLBVqcOwrLvGh/v/jw423yWHq/ofUGK1W31M2TNspLQbUV1Va0F5kTxtirkoHawODAZcjXTSGi7JwbnPcDPJg==", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/middleware-host-header": "3.693.0", - "@aws-sdk/middleware-logger": "3.693.0", - "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/region-config-resolver": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@aws-sdk/util-user-agent-browser": "3.693.0", - "@aws-sdk/util-user-agent-node": "3.693.0", - "@smithy/config-resolver": "^3.0.11", - "@smithy/core": "^2.5.2", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/hash-node": "^3.0.9", - "@smithy/invalid-dependency": "^3.0.9", - "@smithy/middleware-content-length": "^3.0.11", - "@smithy/middleware-endpoint": "^3.2.2", - "@smithy/middleware-retry": "^3.0.26", - "@smithy/middleware-serde": "^3.0.9", - "@smithy/middleware-stack": "^3.0.9", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/url-parser": "^3.0.9", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.26", - "@smithy/util-defaults-mode-node": "^3.0.26", - "@smithy/util-endpoints": "^2.1.5", - "@smithy/util-middleware": "^3.0.9", - "@smithy/util-retry": "^3.0.9", - "@smithy/util-utf8": "^3.0.0", + "@smithy/types": "^4.3.1", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso-oidc": { - "version": "3.693.0", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/credential-provider-node": "3.693.0", - "@aws-sdk/middleware-host-header": "3.693.0", - "@aws-sdk/middleware-logger": "3.693.0", - "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/region-config-resolver": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@aws-sdk/util-user-agent-browser": "3.693.0", - "@aws-sdk/util-user-agent-node": "3.693.0", - "@smithy/config-resolver": "^3.0.11", - "@smithy/core": "^2.5.2", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/hash-node": "^3.0.9", - "@smithy/invalid-dependency": "^3.0.9", - "@smithy/middleware-content-length": "^3.0.11", - "@smithy/middleware-endpoint": "^3.2.2", - "@smithy/middleware-retry": "^3.0.26", - "@smithy/middleware-serde": "^3.0.9", - "@smithy/middleware-stack": "^3.0.9", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/url-parser": "^3.0.9", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.26", - "@smithy/util-defaults-mode-node": "^3.0.26", - "@smithy/util-endpoints": "^2.1.5", - "@smithy/util-middleware": "^3.0.9", - "@smithy/util-retry": "^3.0.9", - "@smithy/util-utf8": "^3.0.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/middleware-content-length": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.4.tgz", + "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.693.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sts": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/middleware-endpoint": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.17.tgz", + "integrity": "sha512-S3hSGLKmHG1m35p/MObQCBCdRsrpbPU8B129BVzRqRfDvQqPMQ14iO4LyRw+7LNizYc605COYAcjqgawqi+6jA==", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.693.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/credential-provider-node": "3.693.0", - "@aws-sdk/middleware-host-header": "3.693.0", - "@aws-sdk/middleware-logger": "3.693.0", - "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/region-config-resolver": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@aws-sdk/util-user-agent-browser": "3.693.0", - "@aws-sdk/util-user-agent-node": "3.693.0", - "@smithy/config-resolver": "^3.0.11", - "@smithy/core": "^2.5.2", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/hash-node": "^3.0.9", - "@smithy/invalid-dependency": "^3.0.9", - "@smithy/middleware-content-length": "^3.0.11", - "@smithy/middleware-endpoint": "^3.2.2", - "@smithy/middleware-retry": "^3.0.26", - "@smithy/middleware-serde": "^3.0.9", - "@smithy/middleware-stack": "^3.0.9", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/url-parser": "^3.0.9", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.26", - "@smithy/util-defaults-mode-node": "^3.0.26", - "@smithy/util-endpoints": "^2.1.5", - "@smithy/util-middleware": "^3.0.9", - "@smithy/util-retry": "^3.0.9", - "@smithy/util-utf8": "^3.0.0", + "@smithy/core": "^3.7.2", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-middleware": "^4.0.4", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/core": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/middleware-retry": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.18.tgz", + "integrity": "sha512-bYLZ4DkoxSsPxpdmeapvAKy7rM5+25gR7PGxq2iMiecmbrRGBHj9s75N74Ylg+aBiw9i5jIowC/cLU2NR0qH8w==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/core": "^2.5.2", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/property-provider": "^3.1.9", - "@smithy/protocol-http": "^4.1.6", - "@smithy/signature-v4": "^4.2.2", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/util-middleware": "^3.0.9", - "fast-xml-parser": "4.4.1", - "tslib": "^2.6.2" + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/service-error-classification": "^4.0.6", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "tslib": "^2.6.2", + "uuid": "^9.0.1" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/middleware-serde": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.8.tgz", + "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==", "dependencies": { - "@aws-sdk/core": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/property-provider": "^3.1.9", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/util-stream": "^3.3.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/middleware-stack": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.4.tgz", + "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==", "dependencies": { - "@aws-sdk/core": "3.693.0", - "@aws-sdk/credential-provider-env": "3.693.0", - "@aws-sdk/credential-provider-http": "3.693.0", - "@aws-sdk/credential-provider-process": "3.693.0", - "@aws-sdk/credential-provider-sso": "3.693.0", - "@aws-sdk/credential-provider-web-identity": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/credential-provider-imds": "^3.2.6", - "@smithy/property-provider": "^3.1.9", - "@smithy/shared-ini-file-loader": "^3.1.10", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.693.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/node-config-provider": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", + "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", "dependencies": { - "@aws-sdk/credential-provider-env": "3.693.0", - "@aws-sdk/credential-provider-http": "3.693.0", - "@aws-sdk/credential-provider-ini": "3.693.0", - "@aws-sdk/credential-provider-process": "3.693.0", - "@aws-sdk/credential-provider-sso": "3.693.0", - "@aws-sdk/credential-provider-web-identity": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/credential-provider-imds": "^3.2.6", - "@smithy/property-provider": "^3.1.9", - "@smithy/shared-ini-file-loader": "^3.1.10", - "@smithy/types": "^3.7.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/node-http-handler": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.0.tgz", + "integrity": "sha512-vqfSiHz2v8b3TTTrdXi03vNz1KLYYS3bhHCDv36FYDqxT7jvTll1mMnCrkD+gOvgwybuunh/2VmvOMqwBegxEg==", "dependencies": { - "@aws-sdk/client-sso": "3.693.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/token-providers": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/property-provider": "^3.1.9", - "@smithy/shared-ini-file-loader": "^3.1.10", - "@smithy/types": "^3.7.0", + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/property-provider": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", + "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", "dependencies": { - "@aws-sdk/core": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/property-provider": "^3.1.9", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.693.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/protocol-http": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-logger": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/querystring-builder": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.4.tgz", + "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1", + "@smithy/util-uri-escape": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/querystring-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", + "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/service-error-classification": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.6.tgz", + "integrity": "sha512-RRoTDL//7xi4tn5FrN2NzH17jbgmnKidUqd4KvquT0954/i6CXXkh1884jBiunq24g9cGtPBEXlU40W6EpNOOg==", "dependencies": { - "@aws-sdk/core": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@smithy/core": "^2.5.2", - "@smithy/protocol-http": "^4.1.6", - "@smithy/types": "^3.7.0", + "@smithy/types": "^4.3.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", + "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", + "dependencies": { + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/signature-v4": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.2.tgz", + "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/types": "^3.7.0", - "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-middleware": "^3.0.9", + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/token-providers": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/smithy-client": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.9.tgz", + "integrity": "sha512-mbMg8mIUAWwMmb74LoYiArP04zWElPzDoA1jVOp3or0cjlDMgoS6WTC3QXK0Vxoc9I4zdrX0tq6qsOmaIoTWEQ==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/property-provider": "^3.1.9", - "@smithy/shared-ini-file-loader": "^3.1.10", - "@smithy/types": "^3.7.0", + "@smithy/core": "^3.7.2", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sso-oidc": "^3.693.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-endpoints": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/types": "^3.7.0", - "@smithy/util-endpoints": "^2.1.5", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.693.0", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.692.0", - "@smithy/types": "^3.7.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.693.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/url-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", + "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/types": "^3.7.0", + "@smithy/querystring-parser": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@smithy/is-array-buffer": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@smithy/util-buffer-from": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-iam/node_modules/@smithy/util-utf8": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", "dependencies": { - "@smithy/util-buffer-from": "^3.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-lambda": { - "version": "3.637.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.637.0", - "@aws-sdk/client-sts": "3.637.0", - "@aws-sdk/core": "3.635.0", - "@aws-sdk/credential-provider-node": "3.637.0", - "@aws-sdk/middleware-host-header": "3.620.0", - "@aws-sdk/middleware-logger": "3.609.0", - "@aws-sdk/middleware-recursion-detection": "3.620.0", - "@aws-sdk/middleware-user-agent": "3.637.0", - "@aws-sdk/region-config-resolver": "3.614.0", - "@aws-sdk/types": "3.609.0", - "@aws-sdk/util-endpoints": "3.637.0", - "@aws-sdk/util-user-agent-browser": "3.609.0", - "@aws-sdk/util-user-agent-node": "3.614.0", - "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.4.0", - "@smithy/eventstream-serde-browser": "^3.0.6", - "@smithy/eventstream-serde-config-resolver": "^3.0.3", - "@smithy/eventstream-serde-node": "^3.0.5", - "@smithy/fetch-http-handler": "^3.2.4", - "@smithy/hash-node": "^3.0.3", - "@smithy/invalid-dependency": "^3.0.3", - "@smithy/middleware-content-length": "^3.0.5", - "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.15", - "@smithy/middleware-serde": "^3.0.3", - "@smithy/middleware-stack": "^3.0.3", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/node-http-handler": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", - "@smithy/types": "^3.3.0", - "@smithy/url-parser": "^3.0.3", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.15", - "@smithy/util-defaults-mode-node": "^3.0.15", - "@smithy/util-endpoints": "^2.0.5", - "@smithy/util-middleware": "^3.0.3", - "@smithy/util-retry": "^3.0.3", - "@smithy/util-stream": "^3.1.3", - "@smithy/util-utf8": "^3.0.0", - "@smithy/util-waiter": "^3.1.2", + "@smithy/is-array-buffer": "^4.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-lambda/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", "dependencies": { - "@smithy/types": "^3.3.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-lambda/node_modules/@smithy/fetch-http-handler": { - "version": "3.2.4", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.25.tgz", + "integrity": "sha512-pxEWsxIsOPLfKNXvpgFHBGFC3pKYKUFhrud1kyooO9CJai6aaKDHfT10Mi5iiipPXN/JhKAu3qX9o75+X85OdQ==", "dependencies": { - "@smithy/protocol-http": "^4.1.0", - "@smithy/querystring-builder": "^3.0.3", - "@smithy/types": "^3.3.0", - "@smithy/util-base64": "^3.0.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-lambda/node_modules/@smithy/is-array-buffer": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.25.tgz", + "integrity": "sha512-+w4n4hKFayeCyELZLfsSQG5mCC3TwSkmRHv4+el5CzFU8ToQpYGhpV7mrRzqlwKkntlPilT1HJy1TVeEvEjWOQ==", "dependencies": { + "@smithy/config-resolver": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-lambda/node_modules/@smithy/util-utf8": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-endpoints": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", + "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", "dependencies": { - "@smithy/util-buffer-from": "^3.0.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-lambda/node_modules/@smithy/util-utf8/node_modules/@smithy/util-buffer-from": { - "version": "3.0.0", - "license": "Apache-2.0", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.693.0", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.693.0", - "@aws-sdk/client-sts": "3.693.0", - "@aws-sdk/core": "3.693.0", - "@aws-sdk/credential-provider-node": "3.693.0", - "@aws-sdk/middleware-bucket-endpoint": "3.693.0", - "@aws-sdk/middleware-expect-continue": "3.693.0", - "@aws-sdk/middleware-flexible-checksums": "3.693.0", - "@aws-sdk/middleware-host-header": "3.693.0", - "@aws-sdk/middleware-location-constraint": "3.693.0", - "@aws-sdk/middleware-logger": "3.693.0", - "@aws-sdk/middleware-recursion-detection": "3.693.0", - "@aws-sdk/middleware-sdk-s3": "3.693.0", - "@aws-sdk/middleware-ssec": "3.693.0", - "@aws-sdk/middleware-user-agent": "3.693.0", - "@aws-sdk/region-config-resolver": "3.693.0", - "@aws-sdk/signature-v4-multi-region": "3.693.0", - "@aws-sdk/types": "3.692.0", - "@aws-sdk/util-endpoints": "3.693.0", - "@aws-sdk/util-user-agent-browser": "3.693.0", - "@aws-sdk/util-user-agent-node": "3.693.0", - "@aws-sdk/xml-builder": "3.693.0", - "@smithy/config-resolver": "^3.0.11", - "@smithy/core": "^2.5.2", - "@smithy/eventstream-serde-browser": "^3.0.12", - "@smithy/eventstream-serde-config-resolver": "^3.0.9", - "@smithy/eventstream-serde-node": "^3.0.11", - "@smithy/fetch-http-handler": "^4.1.0", - "@smithy/hash-blob-browser": "^3.1.8", - "@smithy/hash-node": "^3.0.9", - "@smithy/hash-stream-node": "^3.1.8", - "@smithy/invalid-dependency": "^3.0.9", - "@smithy/md5-js": "^3.0.9", - "@smithy/middleware-content-length": "^3.0.11", - "@smithy/middleware-endpoint": "^3.2.2", - "@smithy/middleware-retry": "^3.0.26", - "@smithy/middleware-serde": "^3.0.9", - "@smithy/middleware-stack": "^3.0.9", - "@smithy/node-config-provider": "^3.1.10", - "@smithy/node-http-handler": "^3.3.0", - "@smithy/protocol-http": "^4.1.6", - "@smithy/smithy-client": "^3.4.3", - "@smithy/types": "^3.7.0", - "@smithy/url-parser": "^3.0.9", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.26", - "@smithy/util-defaults-mode-node": "^3.0.26", - "@smithy/util-endpoints": "^2.1.5", - "@smithy/util-middleware": "^3.0.9", - "@smithy/util-retry": "^3.0.9", - "@smithy/util-stream": "^3.3.0", - "@smithy/util-utf8": "^3.0.0", - "@smithy/util-waiter": "^3.1.8", + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-middleware": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.4.tgz", + "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==", + "dependencies": { + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-retry": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.6.tgz", + "integrity": "sha512-+YekoF2CaSMv6zKrA6iI/N9yva3Gzn4L6n35Luydweu5MMPYpiGZlWqehPHDHyNbnyaYlz/WJyYAZnC+loBDZg==", + "dependencies": { + "@smithy/service-error-classification": "^4.0.6", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-stream": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.3.tgz", + "integrity": "sha512-cQn412DWHHFNKrQfbHY8vSFI3nTROY1aIKji9N0tpp8gUABRilr7wdf8fqBbSlXresobM+tQFNk6I+0LXK/YZg==", + "dependencies": { + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3-control/node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" } }, + "node_modules/@aws-sdk/client-s3-control/node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": { "version": "3.693.0", "license": "Apache-2.0", @@ -13656,6 +16819,189 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3-control": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3-control/-/middleware-sdk-s3-control-3.848.0.tgz", + "integrity": "sha512-1zozD+IKFzFE9RLOCBOGPjhi+jUj0bLxf0ntqBMBJKX9Cf5zqvVuck7mCY19+m0/B+GuSAoiQm2yPV6dcgN17g==", + "dependencies": { + "@aws-sdk/middleware-bucket-endpoint": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.840.0.tgz", + "integrity": "sha512-+gkQNtPwcSMmlwBHFd4saVVS11In6ID1HczNzpM3MXKXRBfSlbZJbCt6wN//AZ8HMklZEik4tcEOG0qa9UY8SQ==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@aws-sdk/util-arn-parser": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.804.0.tgz", + "integrity": "sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@aws-sdk/util-endpoints": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.848.0.tgz", + "integrity": "sha512-fY/NuFFCq/78liHvRyFKr+aqq1aA/uuVSANjzr5Ym8c+9Z3HRPE9OrExAHoMrZ6zC8tHerQwlsXYYH5XZ7H+ww==", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/node-config-provider": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", + "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/property-provider": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", + "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/protocol-http": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/querystring-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", + "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", + "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/url-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", + "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", + "dependencies": { + "@smithy/querystring-parser": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3-control/node_modules/@smithy/util-endpoints": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", + "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/middleware-sdk-s3/node_modules/@aws-sdk/core": { "version": "3.693.0", "license": "Apache-2.0", @@ -16529,6 +19875,54 @@ "node": ">=16.0.0" } }, + "node_modules/@smithy/middleware-apply-body-checksum": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/middleware-apply-body-checksum/-/middleware-apply-body-checksum-4.1.2.tgz", + "integrity": "sha512-YK7yIjjW67Fat8uk2CsUDaQwfcvA1RPaoLKKDZycf7QZ3QlmPUuLLDsMVrJWPy/2mahJjpcaAfzZnK7cXDlVAQ==", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-apply-body-checksum/node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-apply-body-checksum/node_modules/@smithy/protocol-http": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-apply-body-checksum/node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/middleware-content-length": { "version": "3.0.13", "license": "Apache-2.0", @@ -29894,12 +33288,15 @@ "@aws-sdk/client-cloudwatch-logs": "<3.731.0", "@aws-sdk/client-codecatalyst": "<3.731.0", "@aws-sdk/client-cognito-identity": "<3.731.0", + "@aws-sdk/client-datazone": "^3.848.0", "@aws-sdk/client-docdb": "<3.731.0", "@aws-sdk/client-docdb-elastic": "<3.731.0", "@aws-sdk/client-ec2": "<3.731.0", + "@aws-sdk/client-glue": "^3.852.0", "@aws-sdk/client-iam": "<3.731.0", "@aws-sdk/client-lambda": "<3.731.0", "@aws-sdk/client-s3": "<3.731.0", + "@aws-sdk/client-s3-control": "^3.830.0", "@aws-sdk/client-sagemaker": "<3.696.0", "@aws-sdk/client-sfn": "<3.731.0", "@aws-sdk/client-ssm": "<3.731.0", diff --git a/packages/core/package.json b/packages/core/package.json index 7be37423006..71286bdb10d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -421,26 +421,61 @@ "fontCharacter": "\\f1e0" } }, - "aws-schemas-registry": { + "aws-sagemakerunifiedstudio-catalog": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1e1" } }, - "aws-schemas-schema": { + "aws-sagemakerunifiedstudio-spaces": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1e2" } }, - "aws-stepfunctions-preview": { + "aws-sagemakerunifiedstudio-spaces-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1e3" } + }, + "aws-sagemakerunifiedstudio-symbol-int": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e4" + } + }, + "aws-sagemakerunifiedstudio-table": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e5" + } + }, + "aws-schemas-registry": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e6" + } + }, + "aws-schemas-schema": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e7" + } + }, + "aws-stepfunctions-preview": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e8" + } } } }, @@ -539,12 +574,15 @@ "@aws-sdk/client-cloudwatch-logs": "<3.731.0", "@aws-sdk/client-codecatalyst": "<3.731.0", "@aws-sdk/client-cognito-identity": "<3.731.0", + "@aws-sdk/client-datazone": "^3.848.0", "@aws-sdk/client-docdb": "<3.731.0", "@aws-sdk/client-docdb-elastic": "<3.731.0", "@aws-sdk/client-ec2": "<3.731.0", + "@aws-sdk/client-glue": "^3.852.0", "@aws-sdk/client-iam": "<3.731.0", "@aws-sdk/client-lambda": "<3.731.0", "@aws-sdk/client-s3": "<3.731.0", + "@aws-sdk/client-s3-control": "^3.830.0", "@aws-sdk/client-sagemaker": "<3.696.0", "@aws-sdk/client-ssm": "<3.731.0", "@aws-sdk/client-sso": "<3.731.0", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index fa5f102e7c8..63ab57e32a7 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -231,6 +231,9 @@ "AWS.command.s3.createFolder": "Create Folder...", "AWS.command.s3.uploadFile": "Upload Files...", "AWS.command.s3.uploadFileToParent": "Upload to Parent...", + "AWS.command.smus.switchProject": "Switch Project", + "AWS.command.smus.refreshProject": "Refresh Project", + "AWS.command.smus.signOut": "Sign Out", "AWS.command.sagemaker.filterSpaces": "Filter Sagemaker Spaces", "AWS.command.stepFunctions.createStateMachineFromTemplate": "Create a new Step Functions state machine", "AWS.command.stepFunctions.publishStateMachine": "Publish state machine to Step Functions", @@ -297,6 +300,7 @@ "AWS.appcomposer.explorerTitle": "Infrastructure Composer", "AWS.cdk.explorerTitle": "CDK", "AWS.codecatalyst.explorerTitle": "CodeCatalyst", + "AWS.sagemakerunifiedstudio.explorerTitle": "SageMaker Unified Studio", "AWS.cwl.limit.desc": "Maximum amount of log entries pulled per request from CloudWatch Logs. For LiveTail, when the limit is reached, the oldest events will be removed to accomodate new events. (max 10000)", "AWS.samcli.deploy.bucket.recentlyUsed": "Buckets recently used for SAM deployments", "AWS.submenu.amazonqEditorContextSubmenu.title": "Amazon Q", diff --git a/packages/core/resources/icons/aws/sagemakerunifiedstudio/catalog.svg b/packages/core/resources/icons/aws/sagemakerunifiedstudio/catalog.svg new file mode 100644 index 00000000000..4bd5988c386 --- /dev/null +++ b/packages/core/resources/icons/aws/sagemakerunifiedstudio/catalog.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/core/resources/icons/aws/sagemakerunifiedstudio/spaces-dark.svg b/packages/core/resources/icons/aws/sagemakerunifiedstudio/spaces-dark.svg new file mode 100644 index 00000000000..3d3950ef9be --- /dev/null +++ b/packages/core/resources/icons/aws/sagemakerunifiedstudio/spaces-dark.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/core/resources/icons/aws/sagemakerunifiedstudio/spaces.svg b/packages/core/resources/icons/aws/sagemakerunifiedstudio/spaces.svg new file mode 100644 index 00000000000..e559fa399c7 --- /dev/null +++ b/packages/core/resources/icons/aws/sagemakerunifiedstudio/spaces.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/core/resources/icons/aws/sagemakerunifiedstudio/symbol-int.svg b/packages/core/resources/icons/aws/sagemakerunifiedstudio/symbol-int.svg new file mode 100644 index 00000000000..18aa022e10f --- /dev/null +++ b/packages/core/resources/icons/aws/sagemakerunifiedstudio/symbol-int.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/core/resources/icons/aws/sagemakerunifiedstudio/table.svg b/packages/core/resources/icons/aws/sagemakerunifiedstudio/table.svg new file mode 100644 index 00000000000..a8ac2aac05d --- /dev/null +++ b/packages/core/resources/icons/aws/sagemakerunifiedstudio/table.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/core/scripts/build/generateServiceClient.ts b/packages/core/scripts/build/generateServiceClient.ts index 5d1854527b9..de601e6ee44 100644 --- a/packages/core/scripts/build/generateServiceClient.ts +++ b/packages/core/scripts/build/generateServiceClient.ts @@ -241,6 +241,14 @@ void (async () => { serviceJsonPath: 'src/codewhisperer/client/user-service-2.json', serviceName: 'CodeWhispererUserClient', }, + { + serviceJsonPath: 'src/sagemakerunifiedstudio/shared/client/gluecatalogapi.json', + serviceName: 'GlueCatalogApi', + }, + { + serviceJsonPath: 'src/sagemakerunifiedstudio/shared/client/sqlworkbench.json', + serviceName: 'SQLWorkbench', + }, ] await generateServiceClients(serviceClientDefinitions) })() diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index 6962b85bfa9..fa837435036 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -219,6 +219,30 @@ export class Auth implements AuthService, ConnectionManager { } } + /** + * Gets the SSO access token for a connection + * @param connection The SSO connection to get the token for + * @returns Promise resolving to the access token string + */ + @withTelemetryContext({ name: 'getSsoAccessToken', class: authClassName }) + public async getSsoAccessToken(connection: Pick): Promise { + const profile = this.store.getProfileOrThrow(connection.id) + + if (profile.type !== 'sso') { + throw new Error(`Connection ${connection.id} is not an SSO connection`) + } + + const provider = this.getSsoTokenProvider(connection.id, profile) + // Calling existing getToken private method - It will handle setting the connection state etc. + const token = await this._getToken(connection.id, provider) + + if (!token?.accessToken) { + throw new Error(`No access token available for connection ${connection.id}`) + } + + return token.accessToken + } + public async useConnection({ id }: Pick): Promise public async useConnection({ id }: Pick): Promise @withTelemetryContext({ name: 'useConnection', class: authClassName }) @@ -923,10 +947,22 @@ export class Auth implements AuthService, ConnectionManager { if (previousState === 'valid') { // Non-token expiration errors can happen. We must log it here, otherwise they are lost. getLogger().warn(`auth: valid connection became invalid. Last error: %s`, this.#validationErrors.get(id)) - const timeout = new Timeout(60000) this.#invalidCredentialsTimeouts.set(id, timeout) + // Check if this is a SMUS profile - if so, skip the generic prompt + // as SMUS has its own reauthentication flow + const isSmusConnection = profile.type === 'sso' && 'domainUrl' in profile && 'domainId' in profile + if (isSmusConnection) { + getLogger().debug(`auth: Skipping generic reauthentication prompt for SMUS connection ${id}`) + // For SMUS connections, just throw the InvalidConnection error + // The SMUS auth provider will handle showing the appropriate prompt + throw new ToolkitError('Connection is invalid or expired. Try logging in again.', { + code: errorCode.invalidConnection, + cause: this.#validationErrors.get(id), + }) + } + const connLabel = profile.metadata.label ?? (profile.type === 'sso' ? this.getSsoProfileLabel(profile) : id) const message = localize( 'aws.auth.invalidConnection', diff --git a/packages/core/src/auth/secondaryAuth.ts b/packages/core/src/auth/secondaryAuth.ts index 01ccf6b799a..f8ea5d9b44f 100644 --- a/packages/core/src/auth/secondaryAuth.ts +++ b/packages/core/src/auth/secondaryAuth.ts @@ -18,7 +18,7 @@ import { withTelemetryContext } from '../shared/telemetry/util' import { isNetworkError } from '../shared/errors' import globals from '../shared/extensionGlobals' -export type ToolId = 'codecatalyst' | 'codewhisperer' | 'testId' +export type ToolId = 'codecatalyst' | 'codewhisperer' | 'testId' | 'smus' let currentConn: Auth['activeConnection'] const auths = new Map() diff --git a/packages/core/src/awsService/sagemaker/commands.ts b/packages/core/src/awsService/sagemaker/commands.ts index 0075d7e5dff..64266c556e1 100644 --- a/packages/core/src/awsService/sagemaker/commands.ts +++ b/packages/core/src/awsService/sagemaker/commands.ts @@ -20,6 +20,7 @@ import { ToolkitError } from '../../shared/errors' import { showConfirmationMessage } from '../../shared/utilities/messages' import { RemoteSessionError } from '../../shared/remoteSession' import { ConnectFromRemoteWorkspaceMessage, InstanceTypeError } from './constants' +import { SagemakerUnifiedStudioSpaceNode } from '../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode' const localize = nls.loadMessageBundle() @@ -101,6 +102,8 @@ export async function deeplinkConnect( connectionIdentifier, ctx.extensionContext, 'sm_dl', + false /* isSMUS */, + undefined /* node */, session, wsUrl, token, @@ -125,7 +128,11 @@ export async function deeplinkConnect( } } -export async function stopSpace(node: SagemakerSpaceNode, ctx: vscode.ExtensionContext) { +export async function stopSpace( + node: SagemakerSpaceNode | SagemakerUnifiedStudioSpaceNode, + ctx: vscode.ExtensionContext, + sageMakerClient?: SagemakerClient +) { const spaceName = node.spaceApp.SpaceName! const confirmed = await showConfirmationMessage({ prompt: `You are about to stop this space. Any active resource will also be stopped. Are you sure you want to stop the space?`, @@ -137,8 +144,8 @@ export async function stopSpace(node: SagemakerSpaceNode, ctx: vscode.ExtensionC if (!confirmed) { return } - - const client = new SagemakerClient(node.regionCode) + // In case of SMUS, we pass in a SM Client and for SM AI, it creates a new SM Client. + const client = sageMakerClient ? sageMakerClient : new SagemakerClient(node.regionCode) try { await client.deleteApp({ DomainId: node.spaceApp.DomainId!, @@ -151,36 +158,50 @@ export async function stopSpace(node: SagemakerSpaceNode, ctx: vscode.ExtensionC if (error.name === 'AccessDeniedException') { throw new ToolkitError('You do not have permission to stop spaces. Please contact your administrator', { cause: error, + code: error.name, }) } else { - throw err + throw new ToolkitError(`Failed to stop space ${spaceName}: ${(error as Error).message}`, { + cause: error, + code: error.name, + }) } } await tryRefreshNode(node) } -export async function openRemoteConnect(node: SagemakerSpaceNode, ctx: vscode.ExtensionContext) { +export async function openRemoteConnect( + node: SagemakerSpaceNode | SagemakerUnifiedStudioSpaceNode, + ctx: vscode.ExtensionContext, + sageMakerClient?: SagemakerClient +) { if (isRemoteWorkspace()) { void vscode.window.showErrorMessage(ConnectFromRemoteWorkspaceMessage) return } if (node.getStatus() === 'Stopped') { - const client = new SagemakerClient(node.regionCode) + // In case of SMUS, we pass in a SM Client and for SM AI, it creates a new SM Client. + const client = sageMakerClient ? sageMakerClient : new SagemakerClient(node.regionCode) try { await client.startSpace(node.spaceApp.SpaceName!, node.spaceApp.DomainId!) await tryRefreshNode(node) const appType = node.spaceApp.SpaceSettingsSummary?.AppType if (!appType) { - throw new ToolkitError('AppType is undefined for the selected space. Cannot start remote connection.') + throw new ToolkitError('AppType is undefined for the selected space. Cannot start remote connection.', { + code: 'undefinedAppType', + }) } await client.waitForAppInService(node.spaceApp.DomainId!, node.spaceApp.SpaceName!, appType) await tryRemoteConnection(node, ctx) } catch (err: any) { // Ignore InstanceTypeError since it means the user decided not to use an instanceType with more memory if (err.code !== InstanceTypeError) { - throw err + throw new ToolkitError(`Remote connection failed: ${(err as Error).message}`, { + cause: err as Error, + code: err.code, + }) } } } else if (node.getStatus() === 'Running') { diff --git a/packages/core/src/awsService/sagemaker/credentialMapping.ts b/packages/core/src/awsService/sagemaker/credentialMapping.ts index 60d4e94260e..3eb54feed36 100644 --- a/packages/core/src/awsService/sagemaker/credentialMapping.ts +++ b/packages/core/src/awsService/sagemaker/credentialMapping.ts @@ -13,6 +13,8 @@ import { Auth } from '../../auth/auth' import { SpaceMappings, SsmConnectionInfo } from './types' import { getLogger } from '../../shared/logger/logger' import { parseArn } from './detached-server/utils' +import { SagemakerUnifiedStudioSpaceNode } from '../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode' +import { SageMakerUnifiedStudioSpacesParentNode } from '../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode' const mappingFileName = '.sagemaker-space-profiles' const mappingFilePath = path.join(os.homedir(), '.aws', mappingFileName) @@ -44,9 +46,9 @@ export async function saveMappings(data: SpaceMappings): Promise { /** * Persists the current profile to the appropriate space mapping based on connection type and profile format. - * @param appArn - The identifier for the SageMaker space. + * @param spaceArn - The arn for the SageMaker space. */ -export async function persistLocalCredentials(appArn: string): Promise { +export async function persistLocalCredentials(spaceArn: string): Promise { const currentProfileId = Auth.instance.getCurrentProfileId() if (!currentProfileId) { throw new ToolkitError('No current profile ID available for saving space credentials.') @@ -55,33 +57,48 @@ export async function persistLocalCredentials(appArn: string): Promise { if (currentProfileId.startsWith('sso:')) { const credentials = globals.loginManager.store.credentialsCache[currentProfileId] await setSpaceSsoProfile( - appArn, + spaceArn, credentials.credentials.accessKeyId, credentials.credentials.secretAccessKey, credentials.credentials.sessionToken ?? '' ) } else { - await setSpaceIamProfile(appArn, currentProfileId) + await setSpaceIamProfile(spaceArn, currentProfileId) } } +/** + * Persists the current selected SMUS Project Role creds to the appropriate space mapping. + * @param spaceArn - The identifier for the SageMaker Space. + */ +export async function persistSmusProjectCreds(spaceArn: string, node: SagemakerUnifiedStudioSpaceNode): Promise { + const nodeParent = node.getParent() as SageMakerUnifiedStudioSpacesParentNode + const authProvider = nodeParent.getAuthProvider() + const projectId = nodeParent.getProjectId() + const projectAuthProvider = await authProvider.getProjectCredentialProvider(projectId) + await projectAuthProvider.getCredentials() + await setSmusSpaceSsoProfile(spaceArn, projectId) + // Trigger SSH credential refresh for the project + projectAuthProvider.startProactiveCredentialRefresh() +} + /** * Persists deep link credentials for a SageMaker space using a derived refresh URL based on environment. * - * @param appArn - ARN of the SageMaker space. + * @param spaceArn - ARN of the SageMaker space. * @param domain - The domain ID associated with the space. * @param session - SSM session ID. * @param wsUrl - SSM WebSocket URL. * @param token - Bearer token for the session. */ export async function persistSSMConnection( - appArn: string, + spaceArn: string, domain: string, session?: string, wsUrl?: string, token?: string ): Promise { - const { region } = parseArn(appArn) + const { region } = parseArn(spaceArn) const endpoint = DevSettings.instance.get('endpoints', {})['sagemaker'] ?? '' // TODO: Hardcoded to 'jupyterlab' due to a bug in Studio that only supports refreshing @@ -107,7 +124,7 @@ export async function persistSSMConnection( : `${envSubdomain}.studio.${region}.asfiovnxocqpcry.com` const refreshUrl = `https://studio-${domain}.${baseDomain}/${appSubDomain}` - await setSpaceCredentials(appArn, refreshUrl, { + await setSpaceCredentials(spaceArn, refreshUrl, { sessionId: session ?? '-', url: wsUrl ?? '-', token: token ?? '-', @@ -116,51 +133,63 @@ export async function persistSSMConnection( /** * Sets or updates an IAM credential profile for a given space. - * @param spaceName - The name of the SageMaker space. + * @param spaceArn - The name of the SageMaker space. * @param profileName - The local AWS profile name to associate. */ -export async function setSpaceIamProfile(spaceName: string, profileName: string): Promise { +export async function setSpaceIamProfile(spaceArn: string, profileName: string): Promise { const data = await loadMappings() data.localCredential ??= {} - data.localCredential[spaceName] = { type: 'iam', profileName } + data.localCredential[spaceArn] = { type: 'iam', profileName } await saveMappings(data) } /** * Sets or updates an SSO credential profile for a given space. - * @param spaceName - The name of the SageMaker space. + * @param spaceArn - The arn of the SageMaker space. * @param accessKey - Temporary access key from SSO. * @param secret - Temporary secret key from SSO. * @param token - Session token from SSO. */ export async function setSpaceSsoProfile( - spaceName: string, + spaceArn: string, accessKey: string, secret: string, token: string ): Promise { const data = await loadMappings() data.localCredential ??= {} - data.localCredential[spaceName] = { type: 'sso', accessKey, secret, token } + data.localCredential[spaceArn] = { type: 'sso', accessKey, secret, token } + await saveMappings(data) +} + +/** + * Sets the SM Space to map to SageMaker Unified Studio Project. + * @param spaceArn - The arn of the SageMaker Unified Studio space. + * @param projectId - The project ID associated with the SageMaker Unified Studio space. + */ +export async function setSmusSpaceSsoProfile(spaceArn: string, projectId: string): Promise { + const data = await loadMappings() + data.localCredential ??= {} + data.localCredential[spaceArn] = { type: 'sso', smusProjectId: projectId } await saveMappings(data) } /** * Stores SSM connection information for a given space, typically from a deep link session. * This initializes the request as 'fresh' and includes a refresh URL if provided. - * @param spaceName - The name of the SageMaker space. + * @param spaceArn - The arn of the SageMaker space. * @param refreshUrl - URL to use for refreshing session tokens. * @param credentials - The session information used to initiate the connection. */ export async function setSpaceCredentials( - spaceName: string, + spaceArn: string, refreshUrl: string, credentials: SsmConnectionInfo ): Promise { const data = await loadMappings() data.deepLink ??= {} - data.deepLink[spaceName] = { + data.deepLink[spaceArn] = { refreshUrl, requests: { 'initial-connection': { diff --git a/packages/core/src/awsService/sagemaker/detached-server/credentials.ts b/packages/core/src/awsService/sagemaker/detached-server/credentials.ts index 5b2a7fdbc64..748679309c8 100644 --- a/packages/core/src/awsService/sagemaker/detached-server/credentials.ts +++ b/packages/core/src/awsService/sagemaker/detached-server/credentials.ts @@ -36,15 +36,30 @@ export async function resolveCredentialsFor(connectionIdentifier: string): Promi return fromIni({ profile: name }) } case 'sso': { - const { accessKey, secret, token } = profile - if (!accessKey || !secret || !token) { + if ('accessKey' in profile && 'secret' in profile && 'token' in profile) { + const { accessKey, secret, token } = profile + if (!accessKey || !secret || !token) { + throw new Error(`Missing SSO credentials for "${connectionIdentifier}"`) + } + return { + accessKeyId: accessKey, + secretAccessKey: secret, + sessionToken: token, + } + } else if ('smusProjectId' in profile) { + // Handle SMUS project ID case + const { accessKey, secret, token } = mapping.smusProjects?.[profile.smusProjectId] || {} + if (!accessKey || !secret || !token) { + throw new Error(`Missing ProjectRole credentials for SMUS Space "${connectionIdentifier}"`) + } + return { + accessKeyId: accessKey, + secretAccessKey: secret, + sessionToken: token, + } + } else { throw new Error(`Missing SSO credentials for "${connectionIdentifier}"`) } - return { - accessKeyId: accessKey, - secretAccessKey: secret, - sessionToken: token, - } } default: throw new Error(`Unsupported profile type "${profile}"`) diff --git a/packages/core/src/awsService/sagemaker/detached-server/errorPage.ts b/packages/core/src/awsService/sagemaker/detached-server/errorPage.ts index e7c02c3e2f2..bff3e62ae61 100644 --- a/packages/core/src/awsService/sagemaker/detached-server/errorPage.ts +++ b/packages/core/src/awsService/sagemaker/detached-server/errorPage.ts @@ -15,6 +15,7 @@ import { open } from './utils' export enum ExceptionType { ACCESS_DENIED = 'AccessDeniedException', DEFAULT = 'Default', + EXPIRED_TOKEN = 'ExpiredTokenException', INTERNAL_FAILURE = 'InternalFailure', RESOURCE_LIMIT_EXCEEDED = 'ResourceLimitExceeded', THROTTLING = 'ThrottlingException', @@ -31,13 +32,18 @@ export const getVSCodeErrorTitle = (error: SageMakerServiceException): string => return ErrorText.StartSession[ExceptionType.DEFAULT].Title } -export const getVSCodeErrorText = (error: SageMakerServiceException): string => { +export const getVSCodeErrorText = (error: SageMakerServiceException, isSmus?: boolean): string => { const exceptionType = error.name as ExceptionType switch (exceptionType) { case ExceptionType.ACCESS_DENIED: case ExceptionType.VALIDATION: return ErrorText.StartSession[exceptionType].Text.replace('{message}', error.message) + case ExceptionType.EXPIRED_TOKEN: + // Use SMUS-specific message if in SMUS context + return isSmus + ? ErrorText.StartSession[ExceptionType.EXPIRED_TOKEN].SmusText + : ErrorText.StartSession[exceptionType].Text case ExceptionType.INTERNAL_FAILURE: case ExceptionType.RESOURCE_LIMIT_EXCEEDED: case ExceptionType.THROTTLING: @@ -57,6 +63,12 @@ export const ErrorText = { Title: 'Unexpected system error', Text: 'We encountered an unexpected error: [{exceptionType}]. Please contact your administrator and provide them with this error so they can investigate the issue.', }, + [ExceptionType.EXPIRED_TOKEN]: { + Title: 'Authentication expired', + Text: 'Your session has expired. Please refresh your credentials and try again.', + SmusText: + 'Your session has expired. This is likely due to network connectivity issues after machine sleep/resume. Please wait 10-30 seconds for automatic credential refresh, then try again. If the issue persists, try reconnecting through AWS Toolkit.', + }, [ExceptionType.INTERNAL_FAILURE]: { Title: 'Failed to connect remotely to VSCode', Text: 'Unable to establish remote connection to VSCode. This could be due to several factors. Please try again by clicking the VSCode button. If the problem persists, please contact your admin.', diff --git a/packages/core/src/awsService/sagemaker/detached-server/routes/getSession.ts b/packages/core/src/awsService/sagemaker/detached-server/routes/getSession.ts index a39b4c1c812..0c9ce74ad30 100644 --- a/packages/core/src/awsService/sagemaker/detached-server/routes/getSession.ts +++ b/packages/core/src/awsService/sagemaker/detached-server/routes/getSession.ts @@ -6,7 +6,7 @@ // Disabled: detached server files cannot import vscode. /* eslint-disable aws-toolkits/no-console-log */ import { IncomingMessage, ServerResponse } from 'http' -import { startSagemakerSession, parseArn } from '../utils' +import { startSagemakerSession, parseArn, isSmusConnection } from '../utils' import { resolveCredentialsFor } from '../credentials' import url from 'url' import { SageMakerServiceException } from '@amzn/sagemaker-client' @@ -33,6 +33,8 @@ export async function handleGetSession(req: IncomingMessage, res: ServerResponse } const { region } = parseArn(connectionIdentifier) + // Detect if this is a SMUS connection for specialized error handling + const isSmus = await isSmusConnection(connectionIdentifier) try { const session = await startSagemakerSession({ region, connectionIdentifier, credentials }) @@ -48,7 +50,7 @@ export async function handleGetSession(req: IncomingMessage, res: ServerResponse const error = err as SageMakerServiceException console.error(`Failed to start SageMaker session for ${connectionIdentifier}:`, err) const errorTitle = getVSCodeErrorTitle(error) - const errorText = getVSCodeErrorText(error) + const errorText = getVSCodeErrorText(error, isSmus) await openErrorPage(errorTitle, errorText) res.writeHead(500, { 'Content-Type': 'text/plain' }) res.end('Failed to start SageMaker session') diff --git a/packages/core/src/awsService/sagemaker/detached-server/utils.ts b/packages/core/src/awsService/sagemaker/detached-server/utils.ts index de01041d4ad..cfac5984e9b 100644 --- a/packages/core/src/awsService/sagemaker/detached-server/utils.ts +++ b/packages/core/src/awsService/sagemaker/detached-server/utils.ts @@ -96,7 +96,6 @@ export async function readMapping() { try { const content = await fs.readFile(mappingFilePath, 'utf-8') console.log(`Mapping file path: ${mappingFilePath}`) - console.log(`Conents: ${content}`) return JSON.parse(content) } catch (err) { throw new Error(`Failed to read mapping file: ${err instanceof Error ? err.message : String(err)}`) @@ -122,6 +121,24 @@ async function processWriteQueue() { } } +/** + * Detects if the connection identifier is using SMUS credentials + * @param connectionIdentifier - The connection identifier to check + * @returns Promise - true if SMUS, false otherwise + */ +export async function isSmusConnection(connectionIdentifier: string): Promise { + try { + const mapping = await readMapping() + const profile = mapping.localCredential?.[connectionIdentifier] + + // Check if profile exists and has smusProjectId + return profile && 'smusProjectId' in profile + } catch (err) { + // If we can't read the mapping, assume not SMUS to avoid breaking existing functionality + return false + } +} + /** * Writes the mapping to a temp file and atomically renames it to the target path. * Uses a queue to prevent race conditions when multiple requests try to write simultaneously. diff --git a/packages/core/src/awsService/sagemaker/explorer/sagemakerSpaceNode.ts b/packages/core/src/awsService/sagemaker/explorer/sagemakerSpaceNode.ts index 6151224a510..1d93d325193 100644 --- a/packages/core/src/awsService/sagemaker/explorer/sagemakerSpaceNode.ts +++ b/packages/core/src/awsService/sagemaker/explorer/sagemakerSpaceNode.ts @@ -4,16 +4,16 @@ */ import * as vscode from 'vscode' -import { AppType } from '@aws-sdk/client-sagemaker' import { SagemakerClient, SagemakerSpaceApp } from '../../../shared/clients/sagemaker' import { AWSResourceNode } from '../../../shared/treeview/nodes/awsResourceNode' import { AWSTreeNodeBase } from '../../../shared/treeview/nodes/awsTreeNodeBase' import { SagemakerParentNode } from './sagemakerParentNode' -import { generateSpaceStatus } from '../utils' -import { getIcon } from '../../../shared/icons' import { getLogger } from '../../../shared/logger/logger' +import { SagemakerUnifiedStudioSpaceNode } from '../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode' +import { SagemakerSpace } from '../sagemakerSpace' export class SagemakerSpaceNode extends AWSTreeNodeBase implements AWSResourceNode { + private smSpace: SagemakerSpace public constructor( public readonly parent: SagemakerParentNode, public readonly client: SagemakerClient, @@ -21,139 +21,61 @@ export class SagemakerSpaceNode extends AWSTreeNodeBase implements AWSResourceNo public readonly spaceApp: SagemakerSpaceApp ) { super('') + this.smSpace = new SagemakerSpace(this.client, this.regionCode, this.spaceApp) this.updateSpace(spaceApp) - this.contextValue = this.getContext() + this.contextValue = this.smSpace.getContext() } public updateSpace(spaceApp: SagemakerSpaceApp) { - this.setSpaceStatus(spaceApp.Status ?? '', spaceApp.App?.Status ?? '') - this.label = this.buildLabel() - this.description = this.buildDescription() - this.tooltip = new vscode.MarkdownString(this.buildTooltip()) - this.iconPath = this.getAppIcon() - + this.smSpace.updateSpace(spaceApp) + this.updateFromSpace() if (this.isPending()) { this.parent.trackPendingNode(this.DomainSpaceKey) } } - public setSpaceStatus(spaceStatus: string, appStatus: string) { - this.spaceApp.Status = spaceStatus - if (this.spaceApp.App) { - this.spaceApp.App.Status = appStatus - } + private updateFromSpace() { + this.label = this.smSpace.label + this.description = this.smSpace.description + this.tooltip = this.smSpace.tooltip + this.iconPath = this.smSpace.iconPath + this.contextValue = this.smSpace.contextValue } public isPending(): boolean { - return this.getStatus() !== 'Running' && this.getStatus() !== 'Stopped' + return this.smSpace.isPending() } public getStatus(): string { - return generateSpaceStatus(this.spaceApp.Status, this.spaceApp.App?.Status) + return this.smSpace.getStatus() } public async getAppStatus() { - const app = await this.client.describeApp({ - DomainId: this.spaceApp.DomainId, - AppName: this.spaceApp.App?.AppName, - AppType: this.spaceApp.SpaceSettingsSummary?.AppType, - SpaceName: this.spaceApp.SpaceName, - }) - - return app.Status ?? 'Unknown' + return this.smSpace.getAppStatus() } public get name(): string { - return this.spaceApp.SpaceName ?? `(no name)` + return this.smSpace.name } public get arn(): string { - return 'placeholder-arn' + return this.smSpace.arn } public async getAppArn() { - const appDetails = await this.client.describeApp({ - DomainId: this.spaceApp.DomainId, - AppName: this.spaceApp.App?.AppName, - AppType: this.spaceApp.SpaceSettingsSummary?.AppType, - SpaceName: this.spaceApp.SpaceName, - }) - - return appDetails.AppArn + return this.smSpace.getAppArn() } public async getSpaceArn() { - const appDetails = await this.client.describeSpace({ - DomainId: this.spaceApp.DomainId, - SpaceName: this.spaceApp.SpaceName, - }) - - return appDetails.SpaceArn + return this.smSpace.getSpaceArn() } public async updateSpaceAppStatus() { - const space = await this.client.describeSpace({ - DomainId: this.spaceApp.DomainId, - SpaceName: this.spaceApp.SpaceName, - }) - - const app = await this.client.describeApp({ - DomainId: this.spaceApp.DomainId, - AppName: this.spaceApp.App?.AppName, - AppType: this.spaceApp.SpaceSettingsSummary?.AppType, - SpaceName: this.spaceApp.SpaceName, - }) - - this.updateSpace({ - ...space, - App: app, - DomainSpaceKey: this.spaceApp.DomainSpaceKey, - }) - } - - private buildLabel(): string { - const status = generateSpaceStatus(this.spaceApp.Status, this.spaceApp.App?.Status) - return `${this.name} (${status})` - } - - private buildDescription(): string { - return `${this.spaceApp.SpaceSharingSettingsSummary?.SharingType ?? 'Unknown'} space` - } - private buildTooltip() { - const spaceName = this.spaceApp?.SpaceName ?? '-' - const appType = this.spaceApp?.SpaceSettingsSummary?.AppType ?? '-' - const domainId = this.spaceApp?.DomainId ?? '-' - const owner = this.spaceApp?.OwnershipSettingsSummary?.OwnerUserProfileName ?? '-' - - return `**Space:** ${spaceName} \n\n**Application:** ${appType} \n\n**Domain ID:** ${domainId} \n\n**User Profile:** ${owner}` - } - - private getAppIcon() { - if (this.spaceApp.SpaceSettingsSummary?.AppType === AppType.CodeEditor) { - return getIcon('aws-sagemaker-code-editor') - } - - if (this.spaceApp.SpaceSettingsSummary?.AppType === AppType.JupyterLab) { - return getIcon('aws-sagemaker-jupyter-lab') - } - } - - private getContext() { - const status = this.getStatus() - if (status === 'Running' && this.spaceApp.SpaceSettingsSummary?.RemoteAccess === 'ENABLED') { - return 'awsSagemakerSpaceRunningRemoteEnabledNode' - } else if (status === 'Running' && this.spaceApp.SpaceSettingsSummary?.RemoteAccess === 'DISABLED') { - return 'awsSagemakerSpaceRunningRemoteDisabledNode' - } else if (status === 'Stopped' && this.spaceApp.SpaceSettingsSummary?.RemoteAccess === 'ENABLED') { - return 'awsSagemakerSpaceStoppedRemoteEnabledNode' - } else if ( - status === 'Stopped' && - (!this.spaceApp.SpaceSettingsSummary?.RemoteAccess || - this.spaceApp.SpaceSettingsSummary?.RemoteAccess === 'DISABLED') - ) { - return 'awsSagemakerSpaceStoppedRemoteDisabledNode' + await this.smSpace.updateSpaceAppStatus() + this.updateFromSpace() + if (this.isPending()) { + this.parent.trackPendingNode(this.DomainSpaceKey) } - return 'awsSagemakerSpaceNode' } public get DomainSpaceKey(): string { @@ -166,13 +88,15 @@ export class SagemakerSpaceNode extends AWSTreeNodeBase implements AWSResourceNo } } -export async function tryRefreshNode(node?: SagemakerSpaceNode) { +export async function tryRefreshNode(node?: SagemakerSpaceNode | SagemakerUnifiedStudioSpaceNode) { if (node) { try { // For SageMaker spaces, refresh just the individual space node to avoid expensive // operation of refreshing all spaces in the domain await node.updateSpaceAppStatus() - await vscode.commands.executeCommand('aws.refreshAwsExplorerNode', node) + node instanceof SagemakerSpaceNode + ? await vscode.commands.executeCommand('aws.refreshAwsExplorerNode', node) + : await node.refreshNode() } catch (e) { getLogger().error('refreshNode failed: %s', (e as Error).message) } diff --git a/packages/core/src/awsService/sagemaker/model.ts b/packages/core/src/awsService/sagemaker/model.ts index 20a667a0bfa..cd0c1e43173 100644 --- a/packages/core/src/awsService/sagemaker/model.ts +++ b/packages/core/src/awsService/sagemaker/model.ts @@ -10,7 +10,7 @@ import { sshAgentSocketVariable, startSshAgent, startVscodeRemote } from '../../ import { createBoundProcess, ensureDependencies } from '../../shared/remoteSession' import { SshConfig } from '../../shared/sshConfig' import * as path from 'path' -import { persistLocalCredentials, persistSSMConnection } from './credentialMapping' +import { persistLocalCredentials, persistSmusProjectCreds, persistSSMConnection } from './credentialMapping' import * as os from 'os' import _ from 'lodash' import { fs } from '../../shared/fs/fs' @@ -21,13 +21,17 @@ import { DevSettings } from '../../shared/settings' import { ToolkitError } from '../../shared/errors' import { SagemakerSpaceNode } from './explorer/sagemakerSpaceNode' import { sleep } from '../../shared/utilities/timeoutUtils' +import { SagemakerUnifiedStudioSpaceNode } from '../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode' const logger = getLogger('sagemaker') -export async function tryRemoteConnection(node: SagemakerSpaceNode, ctx: vscode.ExtensionContext) { +export async function tryRemoteConnection( + node: SagemakerSpaceNode | SagemakerUnifiedStudioSpaceNode, + ctx: vscode.ExtensionContext +) { const spaceArn = (await node.getSpaceArn()) as string - const remoteEnv = await prepareDevEnvConnection(spaceArn, ctx, 'sm_lc') - + const isSMUS = node instanceof SagemakerUnifiedStudioSpaceNode + const remoteEnv = await prepareDevEnvConnection(spaceArn, ctx, 'sm_lc', isSMUS, node) try { await startVscodeRemote( remoteEnv.SessionProcess, @@ -44,9 +48,11 @@ export async function tryRemoteConnection(node: SagemakerSpaceNode, ctx: vscode. } export async function prepareDevEnvConnection( - appArn: string, + spaceArn: string, ctx: vscode.ExtensionContext, connectionType: string, + isSMUS: boolean, + node: SagemakerSpaceNode | SagemakerUnifiedStudioSpaceNode | undefined, session?: string, wsUrl?: string, token?: string, @@ -66,13 +72,17 @@ export async function prepareDevEnvConnection( } const hostnamePrefix = connectionType - const hostname = `${hostnamePrefix}_${appArn.replace(/\//g, '__').replace(/:/g, '_._')}` + const hostname = `${hostnamePrefix}_${spaceArn.replace(/\//g, '__').replace(/:/g, '_._')}` // save space credential mapping if (connectionType === 'sm_lc') { - await persistLocalCredentials(appArn) + if (!isSMUS) { + await persistLocalCredentials(spaceArn) + } else { + await persistSmusProjectCreds(spaceArn, node as SagemakerUnifiedStudioSpaceNode) + } } else if (connectionType === 'sm_dl') { - await persistSSMConnection(appArn, domain ?? '', session, wsUrl, token) + await persistSSMConnection(spaceArn, domain ?? '', session, wsUrl, token) } await startLocalServer(ctx) diff --git a/packages/core/src/awsService/sagemaker/sagemakerSpace.ts b/packages/core/src/awsService/sagemaker/sagemakerSpace.ts new file mode 100644 index 00000000000..14ac03d9c0e --- /dev/null +++ b/packages/core/src/awsService/sagemaker/sagemakerSpace.ts @@ -0,0 +1,229 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as path from 'path' +import { AppType } from '@aws-sdk/client-sagemaker' +import { SagemakerClient, SagemakerSpaceApp } from '../../shared/clients/sagemaker' +import { getIcon, IconPath } from '../../shared/icons' +import { generateSpaceStatus, updateIdleFile, startMonitoringTerminalActivity, ActivityCheckInterval } from './utils' +import { UserActivity } from '../../shared/extensionUtilities' +import { getLogger } from '../../shared/logger/logger' + +export class SagemakerSpace { + public label: string = '' + public contextValue: string = '' + public description?: string + private spaceApp: SagemakerSpaceApp + public tooltip?: vscode.MarkdownString + public iconPath?: IconPath + public refreshCallback?: () => Promise + + public constructor( + private readonly client: SagemakerClient, + public readonly regionCode: string, + spaceApp: SagemakerSpaceApp, + private readonly isSMUSSpace: boolean = false + ) { + this.spaceApp = spaceApp + this.updateSpace(spaceApp) + this.contextValue = this.getContext() + } + + public updateSpace(spaceApp: SagemakerSpaceApp) { + this.setSpaceStatus(spaceApp.Status ?? '', spaceApp.App?.Status ?? '') + // Only update RemoteAccess property to minimize impact due to minor structural differences between variables + if (this.spaceApp.SpaceSettingsSummary && spaceApp.SpaceSettingsSummary?.RemoteAccess) { + this.spaceApp.SpaceSettingsSummary.RemoteAccess = spaceApp.SpaceSettingsSummary.RemoteAccess + } + this.label = this.buildLabel() + this.description = this.isSMUSSpace ? undefined : this.buildDescription() + this.tooltip = new vscode.MarkdownString(this.buildTooltip()) + this.iconPath = this.getAppIcon() + this.contextValue = this.getContext() + } + + public setSpaceStatus(spaceStatus: string, appStatus: string) { + this.spaceApp.Status = spaceStatus + if (this.spaceApp.App) { + this.spaceApp.App.Status = appStatus + } + } + + public isPending(): boolean { + return this.getStatus() !== 'Running' && this.getStatus() !== 'Stopped' + } + + public getStatus(): string { + return generateSpaceStatus(this.spaceApp.Status, this.spaceApp.App?.Status) + } + + public async getAppStatus() { + const app = await this.client.describeApp({ + DomainId: this.spaceApp.DomainId, + AppName: this.spaceApp.App?.AppName, + AppType: this.spaceApp.SpaceSettingsSummary?.AppType, + SpaceName: this.spaceApp.SpaceName, + }) + + return app.Status ?? 'Unknown' + } + + public get name(): string { + return this.spaceApp.SpaceName ?? `(no name)` + } + + public get arn(): string { + return 'placeholder-arn' + } + + // TODO: Verify this method is still needed to retrieve the app ARN or build based on provided details + public async getAppArn() { + const appDetails = await this.client.describeApp({ + DomainId: this.spaceApp.DomainId, + AppName: this.spaceApp.App?.AppName, + AppType: this.spaceApp?.SpaceSettingsSummary?.AppType, + SpaceName: this.spaceApp.SpaceName, + }) + + return appDetails.AppArn + } + + // TODO: Verify this method is still needed to retrieve the app ARN or build based on provided details + public async getSpaceArn() { + const spaceDetails = await this.client.describeSpace({ + DomainId: this.spaceApp.DomainId, + SpaceName: this.spaceApp.SpaceName, + }) + + return spaceDetails.SpaceArn + } + + public async updateSpaceAppStatus() { + const space = await this.client.describeSpace({ + DomainId: this.spaceApp.DomainId, + SpaceName: this.spaceApp.SpaceName, + }) + + const app = await this.client.describeApp({ + DomainId: this.spaceApp.DomainId, + AppName: this.spaceApp.App?.AppName, + AppType: this.spaceApp?.SpaceSettingsSummary?.AppType, + SpaceName: this.spaceApp.SpaceName, + }) + + // AWS DescribeSpace API returns full details with property names like 'SpaceSettings' + // but our internal SagemakerSpaceApp type expects 'SpaceSettingsSummary' (from ListSpaces API) + // We destructure and rename properties to maintain type compatibility + const { + SpaceSettings: spaceSettingsSummary, + OwnershipSettings: ownershipSettingsSummary, + SpaceSharingSettings: spaceSharingSettingsSummary, + ...spaceDetails + } = space + this.updateSpace({ + SpaceSettingsSummary: spaceSettingsSummary, + OwnershipSettingsSummary: ownershipSettingsSummary, + SpaceSharingSettingsSummary: spaceSharingSettingsSummary, + ...spaceDetails, + App: app, + DomainSpaceKey: this.spaceApp.DomainSpaceKey, + }) + } + + public buildLabel(): string { + const status = generateSpaceStatus(this.spaceApp.Status, this.spaceApp.App?.Status) + return `${this.name} (${status})` + } + + public buildDescription(): string { + return `${this.spaceApp.SpaceSharingSettingsSummary?.SharingType ?? 'Unknown'} space` + } + + public buildTooltip() { + const spaceName = this.spaceApp?.SpaceName ?? '-' + const appType = this.spaceApp?.SpaceSettingsSummary?.AppType || '-' + const domainId = this.spaceApp?.DomainId ?? '-' + const owner = this.spaceApp?.OwnershipSettingsSummary?.OwnerUserProfileName || '-' + const instanceType = this.spaceApp?.App?.ResourceSpec?.InstanceType ?? '-' + if (this.isSMUSSpace) { + return `**Space:** ${spaceName} \n\n**Application:** ${appType} \n\n**Instance Type:** ${instanceType}` + } + return `**Space:** ${spaceName} \n\n**Application:** ${appType} \n\n**Domain ID:** ${domainId} \n\n**User Profile:** ${owner}` + } + + public getAppIcon() { + const appType = this.spaceApp.SpaceSettingsSummary?.AppType + if (appType === AppType.JupyterLab) { + return getIcon('aws-sagemaker-jupyter-lab') + } + if (appType === AppType.CodeEditor) { + return getIcon('aws-sagemaker-code-editor') + } + } + + public getContext(): string { + const status = this.getStatus() + if (status === 'Running' && this.spaceApp.SpaceSettingsSummary?.RemoteAccess === 'ENABLED') { + return 'awsSagemakerSpaceRunningRemoteEnabledNode' + } else if (status === 'Running' && this.spaceApp.SpaceSettingsSummary?.RemoteAccess === 'DISABLED') { + return 'awsSagemakerSpaceRunningRemoteDisabledNode' + } else if (status === 'Running' && this.isSMUSSpace) { + return 'awsSagemakerSpaceRunningNode' + } else if (status === 'Stopped' && this.spaceApp.SpaceSettingsSummary?.RemoteAccess === 'ENABLED') { + return 'awsSagemakerSpaceStoppedRemoteEnabledNode' + } else if ( + (status === 'Stopped' && !this.spaceApp.SpaceSettingsSummary?.RemoteAccess) || + this.spaceApp.SpaceSettingsSummary?.RemoteAccess === 'DISABLED' + ) { + return 'awsSagemakerSpaceStoppedRemoteDisabledNode' + } + return this.isSMUSSpace ? 'smusSpaceNode' : 'awsSagemakerSpaceNode' + } + + public get DomainSpaceKey(): string { + return this.spaceApp.DomainSpaceKey! + } +} + +/** + * Sets up user activity monitoring for SageMaker spaces + */ +export async function setupUserActivityMonitoring(extensionContext: vscode.ExtensionContext): Promise { + const logger = getLogger() + logger.info('setupUserActivityMonitoring: Starting user activity monitoring setup') + + const tmpDirectory = '/tmp/' + const idleFilePath = path.join(tmpDirectory, '.sagemaker-last-active-timestamp') + logger.debug(`setupUserActivityMonitoring: Using idle file path: ${idleFilePath}`) + + try { + const userActivity = new UserActivity(ActivityCheckInterval) + userActivity.onUserActivity(() => { + logger.debug('setupUserActivityMonitoring: User activity detected, updating idle file') + void updateIdleFile(idleFilePath) + }) + + let terminalActivityInterval: NodeJS.Timeout | undefined = startMonitoringTerminalActivity(idleFilePath) + logger.debug('setupUserActivityMonitoring: Started terminal activity monitoring') + // Write initial timestamp + await updateIdleFile(idleFilePath) + logger.info('setupUserActivityMonitoring: Initial timestamp written successfully') + extensionContext.subscriptions.push(userActivity, { + dispose: () => { + logger.info('setupUserActivityMonitoring: Disposing user activity monitoring') + if (terminalActivityInterval) { + clearInterval(terminalActivityInterval) + terminalActivityInterval = undefined + } + }, + }) + + logger.info('setupUserActivityMonitoring: User activity monitoring setup completed successfully') + } catch (error) { + logger.error(`setupUserActivityMonitoring: Error during setup: ${error}`) + throw error + } +} diff --git a/packages/core/src/awsService/sagemaker/types.ts b/packages/core/src/awsService/sagemaker/types.ts index 9b06058ef62..82f4d4f92d6 100644 --- a/packages/core/src/awsService/sagemaker/types.ts +++ b/packages/core/src/awsService/sagemaker/types.ts @@ -6,11 +6,13 @@ export interface SpaceMappings { localCredential?: { [spaceName: string]: LocalCredentialProfile } deepLink?: { [spaceName: string]: DeeplinkSession } + smusProjects?: { [smusProjectId: string]: { accessKey: string; secret: string; token: string } } } export type LocalCredentialProfile = | { type: 'iam'; profileName: string } | { type: 'sso'; accessKey: string; secret: string; token: string } + | { type: 'sso'; smusProjectId: string } export interface DeeplinkSession { requests: Record diff --git a/packages/core/src/extensionNode.ts b/packages/core/src/extensionNode.ts index 97785456e9b..a8a7855913e 100644 --- a/packages/core/src/extensionNode.ts +++ b/packages/core/src/extensionNode.ts @@ -42,6 +42,7 @@ import { activate as activateDocumentDb } from './docdb/activation' import { activate as activateIamPolicyChecks } from './awsService/accessanalyzer/activation' import { activate as activateNotifications } from './notifications/activation' import { activate as activateSagemaker } from './awsService/sagemaker/activation' +import { activate as activateSageMakerUnifiedStudio } from './sagemakerunifiedstudio/activation' import { SchemaService } from './shared/schemas' import { AwsResourceManager } from './dynamicResources/awsResourceManager' import globals from './shared/extensionGlobals' @@ -197,6 +198,9 @@ export async function activate(context: vscode.ExtensionContext) { await handleAmazonQInstall() } + + await activateSageMakerUnifiedStudio(context) + await activateApplicationComposer(context) await activateThreatComposerEditor(context) diff --git a/packages/core/src/sagemakerunifiedstudio/activation.ts b/packages/core/src/sagemakerunifiedstudio/activation.ts new file mode 100644 index 00000000000..093cde20d88 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/activation.ts @@ -0,0 +1,19 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { activate as activateConnectionMagicsSelector } from './connectionMagicsSelector/activation' +import { activate as activateExplorer } from './explorer/activation' +import { isSageMaker } from '../shared/extensionUtilities' +import { initializeResourceMetadata } from './shared/utils/resourceMetadataUtils' + +export async function activate(extensionContext: vscode.ExtensionContext): Promise { + // Only run when environment is a SageMaker Unified Studio space + if (isSageMaker('SMUS') || isSageMaker('SMUS-SPACE-REMOTE-ACCESS')) { + await initializeResourceMetadata() + await activateConnectionMagicsSelector(extensionContext) + } + await activateExplorer(extensionContext) +} diff --git a/packages/core/src/sagemakerunifiedstudio/auth/model.ts b/packages/core/src/sagemakerunifiedstudio/auth/model.ts new file mode 100644 index 00000000000..6e60fa20e96 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/auth/model.ts @@ -0,0 +1,68 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SsoProfile, SsoConnection } from '../../auth/connection' + +/** + * Scope for SageMaker Unified Studio authentication + */ +export const scopeSmus = 'datazone:domain:access' + +/** + * SageMaker Unified Studio profile extending the base SSO profile + */ +export interface SmusProfile extends SsoProfile { + readonly domainUrl: string + readonly domainId: string +} + +/** + * SageMaker Unified Studio connection extending the base SSO connection + */ +export interface SmusConnection extends SmusProfile, SsoConnection { + readonly id: string + readonly label: string +} + +/** + * Creates a SageMaker Unified Studio profile + * @param domainUrl The SageMaker Unified Studio domain URL + * @param domainId The SageMaker Unified Studio domain ID + * @param startUrl The SSO start URL (issuer URL) + * @param region The AWS region + * @returns A SageMaker Unified Studio profile + */ +export function createSmusProfile( + domainUrl: string, + domainId: string, + startUrl: string, + region: string, + scopes = [scopeSmus] +): SmusProfile & { readonly scopes: string[] } { + return { + scopes, + type: 'sso', + startUrl, + ssoRegion: region, + domainUrl, + domainId, + } +} + +/** + * Checks if a connection is a valid SageMaker Unified Studio connection + * @param conn Connection to check + * @returns True if the connection is a valid SMUS connection + */ +export function isValidSmusConnection(conn?: any): conn is SmusConnection { + if (!conn || conn.type !== 'sso') { + return false + } + // Check if the connection has the required SMUS scope + const hasScope = Array.isArray(conn.scopes) && conn.scopes.includes(scopeSmus) + // Check if the connection has the required SMUS properties + const hasSmusProps = 'domainUrl' in conn && 'domainId' in conn + return !!hasScope && !!hasSmusProps +} diff --git a/packages/core/src/sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider.ts b/packages/core/src/sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider.ts new file mode 100644 index 00000000000..828e848f810 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider.ts @@ -0,0 +1,235 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../../../shared/logger/logger' +import { ToolkitError } from '../../../shared/errors' +import * as AWS from '@aws-sdk/types' +import { CredentialsId, CredentialsProvider, CredentialsProviderType } from '../../../auth/providers/credentials' + +import { DataZoneClient } from '../../shared/client/datazoneClient' +import { SmusAuthenticationProvider } from './smusAuthenticationProvider' +import { CredentialType } from '../../../shared/telemetry/telemetry' +import { SmusCredentialExpiry, validateCredentialFields } from '../../shared/smusUtils' + +/** + * Credentials provider for SageMaker Unified Studio Connection credentials + * Uses DataZone API to get connection credentials for a specific connection * + * This provider implements independent caching with 10-minute expiry + */ +export class ConnectionCredentialsProvider implements CredentialsProvider { + private readonly logger = getLogger() + private credentialCache?: { + credentials: AWS.Credentials + expiresAt: Date + } + + constructor( + private readonly smusAuthProvider: SmusAuthenticationProvider, + private readonly connectionId: string + ) {} + + /** + * Gets the connection ID + * @returns Connection ID + */ + public getConnectionId(): string { + return this.connectionId + } + + /** + * Gets the credentials ID + * @returns Credentials ID + */ + public getCredentialsId(): CredentialsId { + return { + credentialSource: 'temp', + credentialTypeId: `${this.smusAuthProvider.getDomainId()}:${this.connectionId}`, + } + } + + /** + * Gets the provider type + * @returns Provider type + */ + public getProviderType(): CredentialsProviderType { + return 'temp' + } + + /** + * Gets the telemetry type + * @returns Telemetry type + */ + public getTelemetryType(): CredentialType { + return 'other' + } + + /** + * Gets the default region + * @returns Default region + */ + public getDefaultRegion(): string | undefined { + return this.smusAuthProvider.getDomainRegion() + } + + /** + * Gets the hash code + * @returns Hash code + */ + public getHashCode(): string { + const hashCode = `smus-connection:${this.smusAuthProvider.getDomainId()}:${this.connectionId}` + return hashCode + } + + /** + * Determines if the provider can auto-connect + * @returns Promise resolving to boolean + */ + public async canAutoConnect(): Promise { + return false // SMUS requires manual authentication + } + + /** + * Determines if the provider is available + * @returns Promise resolving to boolean + */ + public async isAvailable(): Promise { + try { + return this.smusAuthProvider.isConnected() + } catch (err) { + this.logger.error('SMUS Connection: Error checking if auth provider is connected: %s', err) + return false + } + } + + /** + * Gets Connection credentials with independent caching + * @returns Promise resolving to credentials + */ + public async getCredentials(): Promise { + this.logger.debug(`SMUS Connection: Getting credentials for connection ${this.connectionId}`) + + // Check cache first (10-minute expiry) + if (this.credentialCache && this.credentialCache.expiresAt > new Date()) { + this.logger.debug( + `SMUS Connection: Using cached connection credentials for connection ${this.connectionId}` + ) + return this.credentialCache.credentials + } + + this.logger.debug( + `SMUS Connection: Calling GetConnection to fetch credentials for connection ${this.connectionId}` + ) + + try { + const datazoneClient = await DataZoneClient.getInstance(this.smusAuthProvider) + const getConnectionResponse = await datazoneClient.getConnection({ + domainIdentifier: this.smusAuthProvider.getDomainId(), + identifier: this.connectionId, + withSecret: true, + }) + + this.logger.debug(`SMUS Connection: Successfully retrieved connection details for ${this.connectionId}`) + + // Extract connection credentials + const connectionCredentials = getConnectionResponse.connectionCredentials + if (!connectionCredentials) { + throw new ToolkitError( + `No connection credentials available in response for connection ${this.connectionId}`, + { + code: 'NoConnectionCredentials', + } + ) + } + + // Validate credential fields + validateCredentialFields( + connectionCredentials, + 'InvalidConnectionCredentials', + 'connection credential response', + true + ) + + // Create AWS credentials with expiration + // Use the expiration from the response if available, otherwise default to 10 minutes + let expiresAt: Date + if (connectionCredentials.expiration) { + // The API returns expiration as a string or Date, handle both cases + expiresAt = + connectionCredentials.expiration instanceof Date + ? connectionCredentials.expiration + : new Date(connectionCredentials.expiration) + } else { + expiresAt = new Date(Date.now() + SmusCredentialExpiry.connectionExpiryMs) + } + + const awsCredentials: AWS.Credentials = { + accessKeyId: connectionCredentials.accessKeyId as string, + secretAccessKey: connectionCredentials.secretAccessKey as string, + sessionToken: connectionCredentials.sessionToken as string, + expiration: expiresAt, + } + + // Cache connection credentials (10-minute expiry) + const cacheExpiresAt = new Date(Date.now() + SmusCredentialExpiry.connectionExpiryMs) + this.credentialCache = { + credentials: awsCredentials, + expiresAt: cacheExpiresAt, + } + + this.logger.debug( + `SMUS Connection: Successfully cached connection credentials for connection ${this.connectionId}, expires in %s minutes`, + Math.round((cacheExpiresAt.getTime() - Date.now()) / 60000) + ) + + return awsCredentials + } catch (err) { + this.logger.error( + `SMUS Connection: Failed to get connection credentials for connection ${this.connectionId}: %s`, + err + ) + + // Re-throw ToolkitErrors with specific codes (NoConnectionCredentials, InvalidConnectionCredentials) + if ( + err instanceof ToolkitError && + (err.code === 'NoConnectionCredentials' || err.code === 'InvalidConnectionCredentials') + ) { + throw err + } + + // Wrap other errors in ConnectionCredentialsFetchFailed + throw new ToolkitError(`Failed to get connection credentials for ${this.connectionId}: ${err}`, { + code: 'ConnectionCredentialsFetchFailed', + cause: err instanceof Error ? err : undefined, + }) + } + } + + /** + * Invalidates cached connection credentials + * Clears the internal cache without fetching new credentials + */ + public invalidate(): void { + this.logger.debug(`SMUS Connection: Invalidating cached credentials for connection ${this.connectionId}`) + // Clear cache to force fresh fetch on next getCredentials() call + this.credentialCache = undefined + this.logger.debug( + `SMUS Connection: Successfully invalidated connection credentials cache for connection ${this.connectionId}` + ) + } + + /** + * Disposes of the provider and cleans up resources + */ + public dispose(): void { + this.logger.debug( + `SMUS Connection: Disposing connection credentials provider for connection ${this.connectionId}` + ) + // Clear cache to clean up resources + this.invalidate() + this.logger.debug( + `SMUS Connection: Successfully disposed connection credentials provider for connection ${this.connectionId}` + ) + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/auth/providers/domainExecRoleCredentialsProvider.ts b/packages/core/src/sagemakerunifiedstudio/auth/providers/domainExecRoleCredentialsProvider.ts new file mode 100644 index 00000000000..968749a9c9c --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/auth/providers/domainExecRoleCredentialsProvider.ts @@ -0,0 +1,325 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../../../shared/logger/logger' +import { ToolkitError } from '../../../shared/errors' +import * as AWS from '@aws-sdk/types' +import { CredentialsId, CredentialsProvider, CredentialsProviderType } from '../../../auth/providers/credentials' +import fetch from 'node-fetch' +import globals from '../../../shared/extensionGlobals' +import { CredentialType } from '../../../shared/telemetry/telemetry' +import { SmusCredentialExpiry, SmusTimeouts, SmusErrorCodes, validateCredentialFields } from '../../shared/smusUtils' + +/** + * Credentials provider for SageMaker Unified Studio Domain Execution Role (DER) + * Uses SSO tokens to get DER credentials via the /sso/redeem-token endpoint + * + * This provider implements internal caching with 10-minute expiry and handles + * its own credential lifecycle independently + */ +export class DomainExecRoleCredentialsProvider implements CredentialsProvider { + private readonly logger = getLogger() + private credentialCache?: { + credentials: AWS.Credentials + expiresAt: Date + } + + constructor( + private readonly domainUrl: string, + private readonly domainId: string, + private readonly ssoRegion: string, + private readonly getAccessToken: () => Promise // Function to get SSO access token for the Connection + ) {} + + /** + * Gets the domain ID + * @returns Domain ID + */ + public getDomainId(): string { + return this.domainId + } + + /** + * Gets the domain URL + * @returns Domain URL + */ + public getDomainUrl(): string { + return this.domainUrl + } + + /** + * Gets the credentials ID + * @returns Credentials ID + */ + public getCredentialsId(): CredentialsId { + return { + credentialSource: 'sso', + credentialTypeId: this.domainId, + } + } + + /** + * Gets the provider type + * @returns Provider type + */ + public getProviderType(): CredentialsProviderType { + return 'sso' + } + + /** + * Gets the telemetry type + * @returns Telemetry type + */ + public getTelemetryType(): CredentialType { + return 'ssoProfile' + } + + /** + * Gets the default region + * @returns Default region + */ + public getDefaultRegion(): string | undefined { + return this.ssoRegion + } + + /** + * Gets the hash code + * @returns Hash code + */ + public getHashCode(): string { + const hashCode = `smus-der:${this.domainId}:${this.ssoRegion}` + return hashCode + } + + /** + * Determines if the provider can auto-connect + * @returns Promise resolving to boolean + */ + public async canAutoConnect(): Promise { + return false // SMUS requires manual authentication + } + + /** + * Determines if the provider is available + * @returns Promise resolving to boolean + */ + public async isAvailable(): Promise { + try { + // Check if we can get an access token + await this.getAccessToken() + return true + } catch { + return false + } + } + + /** + * Gets Domain Execution Role (DER) credentials with internal caching + * @returns Promise resolving to credentials + */ + public async getCredentials(): Promise { + this.logger.debug(`SMUS DER: Getting DER credentials for domain ${this.domainId}`) + + // Check cache first (10-minute expiry with 5-minute buffer for proactive refresh) + if (this.credentialCache && this.credentialCache.expiresAt > new Date()) { + this.logger.debug(`SMUS DER: Using cached DER credentials for domain ${this.domainId}`) + return this.credentialCache.credentials + } + + this.logger.debug(`SMUS DER: Fetching credentials from API for domain ${this.domainId}`) + + try { + // Get current SSO access token + const accessToken = await this.getAccessToken() + if (!accessToken) { + throw new ToolkitError('No access token available for DER credential refresh', { + code: 'NoTokenAvailable', + }) + } + + this.logger.debug(`SMUS DER: Got access token for refresh for domain ${this.domainId}`) + + // Call SMUS redeem token API to get DER credentials + const redeemUrl = new URL('/sso/redeem-token', this.domainUrl) + this.logger.debug(`SMUS DER: Calling redeem token endpoint: ${redeemUrl.toString()}`) + + const requestBody = { + domainId: this.domainId, + accessToken, + } + + const requestHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'aws-toolkit-vscode', + } + + let response + try { + response = await fetch(redeemUrl.toString(), { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody), + timeout: SmusTimeouts.apiCallTimeoutMs, + }) + } catch (fetchError) { + // Handle timeout errors specifically + if ( + fetchError instanceof Error && + (fetchError.name === 'AbortError' || fetchError.message.includes('timeout')) + ) { + throw new ToolkitError( + `Redeem token request timed out after ${SmusTimeouts.apiCallTimeoutMs / 1000} seconds`, + { + code: SmusErrorCodes.ApiTimeout, + cause: fetchError, + } + ) + } + // Re-throw other fetch errors + throw fetchError + } + + this.logger.debug(`SMUS DER: Redeem token response status: ${response.status} for domain ${this.domainId}`) + + if (!response.ok) { + // Try to get response body for more details + let responseBody = '' + try { + responseBody = await response.text() + this.logger.debug(`SMUS DER: Error response body for domain ${this.domainId}: ${responseBody}`) + } catch (bodyErr) { + this.logger.debug( + `SMUS DER: Could not read error response body for domain ${this.domainId}: ${bodyErr}` + ) + } + + throw new ToolkitError( + `Failed to redeem access token: ${response.status} ${response.statusText}${responseBody ? ` - ${responseBody}` : ''}`, + { code: SmusErrorCodes.RedeemAccessTokenFailed } + ) + } + + const responseText = await response.text() + + const data = JSON.parse(responseText) as { + credentials: { + accessKeyId: string + secretAccessKey: string + sessionToken: string + expiration: string + } + } + this.logger.debug(`SMUS DER: Successfully received credentials from API for domain ${this.domainId}`) + + // Validate the response data structure + if (!data.credentials) { + throw new ToolkitError('Missing credentials object in API response', { + code: 'InvalidCredentialResponse', + }) + } + + const credentials = data.credentials + + // Validate the credential fields + validateCredentialFields(credentials, 'InvalidCredentialResponse', 'API response') + + // Create credentials with expiration + let credentialExpiresAt: Date + if (credentials.expiration) { + // Handle both epoch timestamps and ISO date strings + let parsedExpiration: Date + + // Check if expiration is a numeric string (epoch timestamp) + const expirationNum = Number(credentials.expiration) + if (!isNaN(expirationNum) && expirationNum > 0) { + // Treat as epoch timestamp in seconds and convert to milliseconds + const timestampMs = expirationNum * 1000 + parsedExpiration = new Date(timestampMs) + this.logger.debug( + `SMUS DER: Parsed epoch timestamp ${credentials.expiration} (seconds) as ${parsedExpiration.toISOString()}` + ) + } else { + // Treat as ISO date string + parsedExpiration = new Date(credentials.expiration) + if (!isNaN(parsedExpiration.getTime())) { + this.logger.debug( + `SMUS DER: Parsed ISO date string ${credentials.expiration} as ${parsedExpiration.toISOString()}` + ) + } else { + this.logger.debug( + `SMUS DER: Failed to parse ISO date string ${credentials.expiration} - invalid date format` + ) + } + } + + // Check if the parsed date is valid + if (isNaN(parsedExpiration.getTime())) { + this.logger.warn( + `SMUS DER: Invalid expiration value: ${credentials.expiration}, using default expiration` + ) + credentialExpiresAt = new Date(Date.now() + SmusCredentialExpiry.derExpiryMs) + } else { + credentialExpiresAt = parsedExpiration + } + if (!isNaN(credentialExpiresAt.getTime())) { + this.logger.debug(`SMUS DER: Credential expires at ${credentialExpiresAt.toISOString()}`) + } else { + this.logger.debug(`SMUS DER: Invalid credential expiration date, using default`) + } + } else { + this.logger.debug(`SMUS DER: No expiration provided, using default`) + credentialExpiresAt = new Date(Date.now() + SmusCredentialExpiry.derExpiryMs) + } + + const awsCredentials: AWS.Credentials = { + accessKeyId: credentials.accessKeyId as string, + secretAccessKey: credentials.secretAccessKey as string, + sessionToken: credentials.sessionToken as string, + expiration: credentialExpiresAt, + } + + // Cache DER credentials with 10-minute expiry (5-minute buffer for proactive refresh) + const cacheExpiresAt = new globals.clock.Date(Date.now() + SmusCredentialExpiry.derExpiryMs) + this.credentialCache = { + credentials: awsCredentials, + expiresAt: cacheExpiresAt, + } + + this.logger.debug( + 'SMUS DER: Successfully cached DER credentials for domain %s, cache expires in %s minutes', + this.domainId, + Math.round((cacheExpiresAt.getTime() - Date.now()) / 60000) + ) + + return awsCredentials + } catch (err) { + this.logger.error('SMUS DER: Failed to fetch credentials for domain %s: %s', this.domainId, err) + throw new ToolkitError(`Failed to fetch DER credentials for domain ${this.domainId}: ${err}`, { + code: 'DerCredentialsFetchFailed', + cause: err instanceof Error ? err : undefined, + }) + } + } + + /** + * Invalidates cached DER credentials + * Clears the internal cache without fetching new credentials + */ + public invalidate(): void { + this.logger.debug(`SMUS DER: Invalidating cached DER credentials for domain ${this.domainId}`) + // Clear cache to force fresh fetch on next getCredentials() call + this.credentialCache = undefined + this.logger.debug(`SMUS DER: Successfully invalidated DER credentials cache for domain ${this.domainId}`) + } + /** + * Disposes of the provider and cleans up resources + */ + public dispose(): void { + this.logger.debug(`SMUS DER: Disposing DER credentials provider for domain ${this.domainId}`) + this.invalidate() + this.logger.debug(`SMUS DER: Successfully disposed DER credentials provider for domain ${this.domainId}`) + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/auth/providers/projectRoleCredentialsProvider.ts b/packages/core/src/sagemakerunifiedstudio/auth/providers/projectRoleCredentialsProvider.ts new file mode 100644 index 00000000000..5eb42e1fd5f --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/auth/providers/projectRoleCredentialsProvider.ts @@ -0,0 +1,363 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../../../shared/logger/logger' +import { ToolkitError } from '../../../shared/errors' +import * as AWS from '@aws-sdk/types' +import { CredentialsId, CredentialsProvider, CredentialsProviderType } from '../../../auth/providers/credentials' + +import { DataZoneClient } from '../../shared/client/datazoneClient' +import { SmusAuthenticationProvider } from './smusAuthenticationProvider' +import { CredentialType } from '../../../shared/telemetry/telemetry' +import { SmusCredentialExpiry, validateCredentialFields } from '../../shared/smusUtils' +import { loadMappings, saveMappings } from '../../../awsService/sagemaker/credentialMapping' + +/** + * Credentials provider for SageMaker Unified Studio Project Role credentials + * Uses Domain Execution Role (DER) credentials to get project-scoped credentials + * via the DataZone GetEnvironmentCredentials API + * + * This provider implements independent caching with 10-minute expiry and can be used + * with any AWS SDK client (S3Client, LambdaClient, etc.) + */ +export class ProjectRoleCredentialsProvider implements CredentialsProvider { + private readonly logger = getLogger() + private credentialCache?: { + credentials: AWS.Credentials + expiresAt: Date + } + private refreshTimer?: NodeJS.Timeout + private readonly refreshInterval = 10 * 60 * 1000 // 10 minutes + private readonly checkInterval = 10 * 1000 // 10 seconds - check frequently, refresh based on actual time + private sshRefreshActive = false + private lastRefreshTime?: Date + + constructor( + private readonly smusAuthProvider: SmusAuthenticationProvider, + private readonly projectId: string + ) {} + + /** + * Gets the project ID + * @returns Project ID + */ + public getProjectId(): string { + return this.projectId + } + + /** + * Gets the credentials ID + * @returns Credentials ID + */ + public getCredentialsId(): CredentialsId { + return { + credentialSource: 'temp', + credentialTypeId: `${this.smusAuthProvider.getDomainId()}:${this.projectId}`, + } + } + + /** + * Gets the provider type + * @returns Provider type + */ + public getProviderType(): CredentialsProviderType { + return 'temp' + } + + /** + * Gets the telemetry type + * @returns Telemetry type + */ + public getTelemetryType(): CredentialType { + return 'other' + } + + /** + * Gets the default region + * @returns Default region + */ + public getDefaultRegion(): string | undefined { + return this.smusAuthProvider.getDomainRegion() + } + + /** + * Gets the hash code + * @returns Hash code + */ + public getHashCode(): string { + const hashCode = `smus-project:${this.smusAuthProvider.getDomainId()}:${this.projectId}` + return hashCode + } + + /** + * Determines if the provider can auto-connect + * @returns Promise resolving to boolean + */ + public async canAutoConnect(): Promise { + return false // SMUS requires manual authentication + } + + /** + * Determines if the provider is available + * @returns Promise resolving to boolean + */ + public async isAvailable(): Promise { + return this.smusAuthProvider.isConnected() + } + + /** + * Gets Project Role credentials with independent caching + * @returns Promise resolving to credentials + */ + public async getCredentials(): Promise { + this.logger.debug(`SMUS Project: Getting credentials for project ${this.projectId}`) + + // Check cache first (10-minute expiry) + if (this.credentialCache && this.credentialCache.expiresAt > new Date()) { + this.logger.debug(`SMUS Project: Using cached project credentials for project ${this.projectId}`) + return this.credentialCache.credentials + } + + this.logger.debug(`SMUS Project: Fetching project credentials from API for project ${this.projectId}`) + + try { + const dataZoneClient = await DataZoneClient.getInstance(this.smusAuthProvider) + const response = await dataZoneClient.getProjectDefaultEnvironmentCreds(this.projectId) + + this.logger.debug( + `SMUS Project: Successfully received response from GetEnvironmentCredentials API for project ${this.projectId}` + ) + + // Validate credential fields - credentials are returned directly in the response + validateCredentialFields(response, 'InvalidProjectCredentialResponse', 'project credential response') + + // Create AWS credentials with expiration + // Use the expiration from the response if available, otherwise default to 10 minutes + let expiresAt: Date + if (response.expiration) { + // The API returns expiration as a string, parse it to Date + expiresAt = new Date(response.expiration) + } else { + expiresAt = new Date(Date.now() + SmusCredentialExpiry.projectExpiryMs) + } + + const awsCredentials: AWS.Credentials = { + accessKeyId: response.accessKeyId as string, + secretAccessKey: response.secretAccessKey as string, + sessionToken: response.sessionToken as string, + expiration: expiresAt, + } + + // Cache project credentials + this.credentialCache = { + credentials: awsCredentials, + expiresAt: expiresAt, + } + + this.logger.debug( + 'SMUS Project: Successfully cached project credentials for project %s, expires in %s minutes', + this.projectId, + Math.round((expiresAt.getTime() - Date.now()) / 60000) + ) + + // Write project credentials to mapping file to be used by Sagemaker local server for remote connections + await this.writeCredentialsToMapping(awsCredentials) + + return awsCredentials + } catch (err) { + this.logger.error('SMUS Project: Failed to get project credentials for project %s: %s', this.projectId, err) + + // Handle InvalidGrantException specially - indicates need for reauthentication + if (err instanceof Error && err.name === 'InvalidGrantException') { + // Invalidate cache when authentication fails + this.invalidate() + throw new ToolkitError( + `Failed to get project credentials for project ${this.projectId}: ${err.message}. Reauthentication required.`, + { + code: 'InvalidRefreshToken', + cause: err, + } + ) + } + + throw new ToolkitError(`Failed to get project credentials for project ${this.projectId}: ${err}`, { + code: 'ProjectCredentialsFetchFailed', + cause: err instanceof Error ? err : undefined, + }) + } + } + + /** + * Writes project credentials to mapping file for local server usage + */ + private async writeCredentialsToMapping(awsCredentials: AWS.Credentials): Promise { + try { + const mapping = await loadMappings() + mapping.smusProjects ??= {} + mapping.smusProjects[this.projectId] = { + accessKey: awsCredentials.accessKeyId, + secret: awsCredentials.secretAccessKey, + token: awsCredentials.sessionToken || '', + } + await saveMappings(mapping) + } catch (err) { + this.logger.warn('SMUS Project: Failed to write project credentials to mapping file: %s', err) + } + } + + /** + * Starts proactive credential refresh for SSH connections + * + * Uses an expiry-based approach with safety buffer: + * - Checks every 10 seconds using setTimeout + * - Refreshes when credentials expire within 5 minutes (safety buffer) + * - Falls back to 10-minute time-based refresh if no expiry information available + * - Handles sleep/resume because it uses wall-clock time for expiry checks + * + * This means credentials are refreshed just before they expire, reducing + * unnecessary API calls while ensuring credentials remain valid. + */ + public startProactiveCredentialRefresh(): void { + if (this.sshRefreshActive) { + this.logger.debug(`SMUS Project: SSH refresh already active for project ${this.projectId}`) + return + } + + this.logger.info(`SMUS Project: Starting SSH credential refresh for project ${this.projectId}`) + this.sshRefreshActive = true + this.lastRefreshTime = new Date() // Initialize refresh time + + // Start the check timer (checks every 10 seconds, refreshes every 10 minutes based on actual time) + this.scheduleNextCheck() + } + + /** + * Stops proactive credential refresh + * Called when SSH connection ends or SMUS disconnects + */ + public stopProactiveCredentialRefresh(): void { + if (!this.sshRefreshActive) { + return + } + + this.logger.info(`SMUS Project: Stopping SSH credential refresh for project ${this.projectId}`) + this.sshRefreshActive = false + this.lastRefreshTime = undefined + + // Clean up timer + if (this.refreshTimer) { + clearTimeout(this.refreshTimer) + this.refreshTimer = undefined + } + } + + /** + * Schedules the next credential check (every 10 seconds) + * Refreshes credentials when they expire within 5 minutes (safety buffer) + * Falls back to 10-minute time-based refresh if no expiry information available + * This handles sleep/resume scenarios correctly + */ + private scheduleNextCheck(): void { + if (!this.sshRefreshActive) { + return + } + // Check every 10 seconds, but only refresh every 10 minutes based on actual time elapsed + this.refreshTimer = setTimeout(async () => { + try { + const now = new Date() + // Check if we need to refresh based on actual time elapsed + if (this.shouldPerformRefresh(now)) { + await this.refresh() + } + // Schedule next check if still active + if (this.sshRefreshActive) { + this.scheduleNextCheck() + } + } catch (error) { + this.logger.error( + `SMUS Project: Failed to refresh credentials for project ${this.projectId}: %O`, + error + ) + // Continue trying even if refresh fails. Dispose will handle stopping the refresh. + if (this.sshRefreshActive) { + this.scheduleNextCheck() + } + } + }, this.checkInterval) + } + + /** + * Determines if a credential refresh should be performed based on credential expiration + * This handles sleep/resume scenarios properly and is more efficient than time-based refresh + */ + private shouldPerformRefresh(now: Date): boolean { + if (!this.lastRefreshTime || !this.credentialCache) { + // First refresh or no cached credentials + this.logger.debug(`SMUS Project: First refresh - no previous credentials for ${this.projectId}`) + return true + } + + // Check if credentials expire soon (with 5-minute safety buffer) + const safetyBufferMs = 5 * 60 * 1000 // 5 minutes before expiry + const expiryTime = this.credentialCache.credentials.expiration?.getTime() + + if (!expiryTime) { + // No expiry info - fall back to time-based refresh as safety net + const timeSinceLastRefresh = now.getTime() - this.lastRefreshTime.getTime() + const shouldRefresh = timeSinceLastRefresh >= this.refreshInterval + return shouldRefresh + } + + const timeUntilExpiry = expiryTime - now.getTime() + const shouldRefresh = timeUntilExpiry < safetyBufferMs + return shouldRefresh + } + + /** + * Performs credential refresh by invalidating cache and fetching fresh credentials + */ + private async refresh(): Promise { + const now = new Date() + const expiryTime = this.credentialCache?.credentials.expiration?.getTime() + + if (expiryTime) { + const minutesUntilExpiry = Math.round((expiryTime - now.getTime()) / 60000) + this.logger.debug( + `SMUS Project: Refreshing credentials for project ${this.projectId} - expires in ${minutesUntilExpiry} minutes` + ) + } else { + const minutesSinceLastRefresh = this.lastRefreshTime + ? Math.round((now.getTime() - this.lastRefreshTime.getTime()) / 60000) + : 0 + this.logger.debug( + `SMUS Project: Refreshing credentials for project ${this.projectId} - time-based refresh after ${minutesSinceLastRefresh} minutes` + ) + } + + await this.getCredentials() + this.lastRefreshTime = new Date() + } + + /** + * Invalidates cached project credentials + * Clears the internal cache without fetching new credentials + */ + public invalidate(): void { + this.logger.debug(`SMUS Project: Invalidating cached credentials for project ${this.projectId}`) + // Clear cache to force fresh fetch on next getCredentials() call + this.credentialCache = undefined + this.logger.debug( + `SMUS Project: Successfully invalidated project credentials cache for project ${this.projectId}` + ) + } + + /** + * Disposes of the provider and cleans up resources + */ + public dispose(): void { + this.stopProactiveCredentialRefresh() + this.invalidate() + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider.ts b/packages/core/src/sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider.ts new file mode 100644 index 00000000000..7f331ae4f46 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider.ts @@ -0,0 +1,575 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { Auth } from '../../../auth/auth' +import { getSecondaryAuth } from '../../../auth/secondaryAuth' +import { ToolkitError } from '../../../shared/errors' +import { withTelemetryContext } from '../../../shared/telemetry/util' +import { SsoConnection } from '../../../auth/connection' +import { showReauthenticateMessage } from '../../../shared/utilities/messages' +import * as localizedText from '../../../shared/localizedText' +import { ToolkitPromptSettings } from '../../../shared/settings' +import { setContext, getContext } from '../../../shared/vscode/setContext' +import { getLogger } from '../../../shared/logger/logger' +import { SmusUtils, SmusErrorCodes } from '../../shared/smusUtils' +import { createSmusProfile, isValidSmusConnection, SmusConnection } from '../model' +import { DomainExecRoleCredentialsProvider } from './domainExecRoleCredentialsProvider' +import { ProjectRoleCredentialsProvider } from './projectRoleCredentialsProvider' +import { ConnectionCredentialsProvider } from './connectionCredentialsProvider' +import { ConnectionClientStore } from '../../shared/client/connectionClientStore' +import { getResourceMetadata } from '../../shared/utils/resourceMetadataUtils' +import { fromIni } from '@aws-sdk/credential-providers' +import { randomUUID } from '../../../shared/crypto' + +/** + * Sets the context variable for SageMaker Unified Studio connection state + * @param isConnected Whether SMUS is connected + */ +export function setSmusConnectedContext(isConnected: boolean): Promise { + return setContext('aws.smus.connected', isConnected) +} + +/** + * Sets the context variable for SMUS space environment state + * @param inSmusSpace Whether we're in SMUS space environment + */ +export function setSmusSpaceEnvironmentContext(inSmusSpace: boolean): Promise { + return setContext('aws.smus.inSmusSpaceEnvironment', inSmusSpace) +} +const authClassName = 'SmusAuthenticationProvider' + +/** + * Authentication provider for SageMaker Unified Studio + * Manages authentication state and credentials for SMUS + */ +export class SmusAuthenticationProvider { + private readonly logger = getLogger() + public readonly onDidChangeActiveConnection = this.secondaryAuth.onDidChangeActiveConnection + private readonly onDidChangeEmitter = new vscode.EventEmitter() + public readonly onDidChange = this.onDidChangeEmitter.event + private credentialsProviderCache = new Map() + private projectCredentialProvidersCache = new Map() + private connectionCredentialProvidersCache = new Map() + + public constructor( + public readonly auth = Auth.instance, + public readonly secondaryAuth = getSecondaryAuth( + auth, + 'smus', + 'SageMaker Unified Studio', + isValidSmusConnection + ) + ) { + this.onDidChangeActiveConnection(async () => { + // Stop SSH credential refresh for all projects when connection changes + this.stopAllSshCredentialRefresh() + + // Invalidate any cached credentials for the previous connection + await this.invalidateAllCredentialsInCache() + // Clear credentials provider cache when connection changes + this.credentialsProviderCache.clear() + // Clear project provider cache when connection changes + this.projectCredentialProvidersCache.clear() + // Clear connection provider cache when connection changes + this.connectionCredentialProvidersCache.clear() + // Clear all clients in client store when connection changes + ConnectionClientStore.getInstance().clearAll() + await setSmusConnectedContext(this.isConnected()) + await setSmusSpaceEnvironmentContext(SmusUtils.isInSmusSpaceEnvironment()) + this.onDidChangeEmitter.fire() + }) + + // Set initial context in case event does not trigger + void setSmusConnectedContext(this.isConnectionValid()) + void setSmusSpaceEnvironmentContext(SmusUtils.isInSmusSpaceEnvironment()) + } + + /** + * Stops SSH credential refresh for all projects + * Called when SMUS connection changes or extension deactivates + */ + public stopAllSshCredentialRefresh(): void { + this.logger.debug('SMUS Auth: Stopping SSH credential refresh for all projects') + for (const provider of this.projectCredentialProvidersCache.values()) { + provider.stopProactiveCredentialRefresh() + } + } + + /** + * Gets the active connection + */ + public get activeConnection() { + if (getContext('aws.smus.inSmusSpaceEnvironment')) { + const resourceMetadata = getResourceMetadata()! + if (resourceMetadata.AdditionalMetadata!.DataZoneDomainRegion) { + return { + domainId: resourceMetadata.AdditionalMetadata!.DataZoneDomainId!, + ssoRegion: resourceMetadata.AdditionalMetadata!.DataZoneDomainRegion!, + // The following fields won't be needed in SMUS space environment + // Craft the domain url with known information + // Use randome id as placeholder + domainUrl: `https://${resourceMetadata.AdditionalMetadata!.DataZoneDomainId!}.sagemaker.${resourceMetadata.AdditionalMetadata!.DataZoneDomainRegion!}.on.aws/`, + id: randomUUID(), + } + } else { + throw new ToolkitError('Domain region not found in metadata file.') + } + } + return this.secondaryAuth.activeConnection + } + + /** + * Checks if using a saved connection + */ + public get isUsingSavedConnection() { + return this.secondaryAuth.hasSavedConnection + } + + /** + * Checks if the connection is valid + */ + public isConnectionValid(): boolean { + // When in SMUS space, the extension is already running in projet context and sign in is not needed + // Set isConnectionValid to always true + if (getContext('aws.smus.inSmusSpaceEnvironment')) { + return true + } + return this.activeConnection !== undefined && !this.secondaryAuth.isConnectionExpired + } + + /** + * Checks if connected to SMUS + */ + public isConnected(): boolean { + // When in SMUS space, the extension is already running in projet context and sign in is not needed + // Set isConnected to always true + if (getContext('aws.smus.inSmusSpaceEnvironment')) { + return true + } + return this.activeConnection !== undefined + } + + /** + * Restores the previous connection + * Uses a promise to prevent multiple simultaneous restore calls + */ + public async restore() { + await this.secondaryAuth.restoreConnection() + } + + /** + * Authenticates with SageMaker Unified Studio using a domain URL + * @param domainUrl The SageMaker Unified Studio domain URL + * @returns Promise resolving to the connection + */ + @withTelemetryContext({ name: 'connectToSmus', class: authClassName }) + public async connectToSmus(domainUrl: string): Promise { + const logger = getLogger() + + try { + // Extract domain info using SmusUtils + const { domainId, region } = SmusUtils.extractDomainInfoFromUrl(domainUrl) + + // Validate domain ID + if (!domainId) { + throw new ToolkitError('Invalid domain URL format', { code: 'InvalidDomainUrl' }) + } + + logger.info(`SMUS: Connecting to domain ${domainId} in region ${region}`) + + // Check if we already have a connection for this domain + const existingConn = (await this.auth.listConnections()).find( + (c): c is SmusConnection => + isValidSmusConnection(c) && (c as any).domainUrl?.toLowerCase() === domainUrl.toLowerCase() + ) + + if (existingConn) { + const connectionState = this.auth.getConnectionState(existingConn) + logger.info(`SMUS: Found existing connection ${existingConn.id} with state: ${connectionState}`) + + // If connection is valid, use it directly without triggering new auth flow + if (connectionState === 'valid') { + logger.info('SMUS: Using existing valid connection') + + // Use the existing connection + const result = await this.secondaryAuth.useNewConnection(existingConn) + + // Auto-invoke project selection after successful sign-in (but not in SMUS space environment) + if (!SmusUtils.isInSmusSpaceEnvironment()) { + void vscode.commands.executeCommand('aws.smus.switchProject') + } + + return result + } + + // If connection is invalid or expired, reauthenticate + if (connectionState === 'invalid') { + logger.info('SMUS: Existing connection is invalid, reauthenticating') + const reauthenticatedConn = await this.reauthenticate(existingConn) + + // Create the SMUS connection wrapper + const smusConn: SmusConnection = { + ...reauthenticatedConn, + domainUrl, + domainId, + } + + const result = await this.secondaryAuth.useNewConnection(smusConn) + logger.debug(`SMUS: Reauthenticated connection successfully, id=${result.id}`) + + // Auto-invoke project selection after successful reauthentication (but not in SMUS space environment) + if (!SmusUtils.isInSmusSpaceEnvironment()) { + void vscode.commands.executeCommand('aws.smus.switchProject') + } + + return result + } + } + + // No existing connection found, create a new one + logger.info('SMUS: No existing connection found, creating new connection') + + // Get SSO instance info from DataZone + const ssoInstanceInfo = await SmusUtils.getSsoInstanceInfo(domainUrl) + + // Create a new connection with appropriate scope based on domain URL + const profile = createSmusProfile(domainUrl, domainId, ssoInstanceInfo.issuerUrl, ssoInstanceInfo.region) + const newConn = await this.auth.createConnection(profile) + logger.debug(`SMUS: Created new connection ${newConn.id}`) + + const smusConn: SmusConnection = { + ...newConn, + domainUrl, + domainId, + } + + const result = await this.secondaryAuth.useNewConnection(smusConn) + + // Auto-invoke project selection after successful sign-in (but not in SMUS space environment) + if (!SmusUtils.isInSmusSpaceEnvironment()) { + void vscode.commands.executeCommand('aws.smus.switchProject') + } + + return result + } catch (e) { + throw ToolkitError.chain(e, 'Failed to connect to SageMaker Unified Studio', { + code: 'FailedToConnect', + }) + } + } + + /** + * Reauthenticates an existing connection + * @param conn Connection to reauthenticate + * @returns Promise resolving to the reauthenticated connection + */ + @withTelemetryContext({ name: 'reauthenticate', class: authClassName }) + public async reauthenticate(conn: SsoConnection) { + try { + return await this.auth.reauthenticate(conn) + } catch (err) { + throw ToolkitError.chain(err, 'Unable to reauthenticate SageMaker Unified Studio connection.') + } + } + + /** + * Shows a reauthentication prompt to the user + * @param conn Connection to reauthenticate + */ + public async showReauthenticationPrompt(conn: SsoConnection): Promise { + await showReauthenticateMessage({ + message: localizedText.connectionExpired('SageMaker Unified Studio'), + connect: localizedText.reauthenticate, + suppressId: 'smusConnectionExpired', + settings: ToolkitPromptSettings.instance, + source: 'SageMaker Unified Studio', + reauthFunc: async () => { + await this.reauthenticate(conn) + }, + }) + } + + /** + * Gets the current SSO access token for the active connection + * @returns Promise resolving to the access token string + * @throws ToolkitError if unable to retrieve access token + */ + public async getAccessToken(): Promise { + const logger = getLogger() + + if (!this.activeConnection) { + throw new ToolkitError('No active SMUS connection available', { code: SmusErrorCodes.NoActiveConnection }) + } + + try { + const accessToken = await this.auth.getSsoAccessToken(this.activeConnection) + logger.debug(`SMUS: Successfully retrieved SSO access token for connection ${this.activeConnection.id}`) + + return accessToken + } catch (err) { + logger.error( + `SMUS: Failed to retrieve SSO access token for connection ${this.activeConnection.id}: %s`, + err + ) + + // Check if this is a reauth error that should be handled by showing SMUS-specific prompt + if (err instanceof ToolkitError && err.code === 'InvalidConnection') { + // Re-throw the error to maintain the error flow + logger.debug( + `SMUS: Auth connection has been marked invalid - Likely due to expiry. Reauthentication flow will be triggered, ignoring error` + ) + } + + throw new ToolkitError(`Failed to retrieve SSO access token for connection ${this.activeConnection.id}`, { + code: SmusErrorCodes.RedeemAccessTokenFailed, + cause: err instanceof Error ? err : undefined, + }) + } + } + + /** + * Gets or creates a project credentials provider for the specified project + * @param projectId The project ID to get credentials for + * @returns Promise resolving to the project credentials provider + */ + public async getProjectCredentialProvider(projectId: string): Promise { + const logger = getLogger() + + if (!this.activeConnection) { + throw new ToolkitError('No active SMUS connection available', { code: SmusErrorCodes.NoActiveConnection }) + } + + logger.debug(`SMUS: Getting project provider for project ${projectId}`) + + // Check if we already have a cached provider for this project + if (this.projectCredentialProvidersCache.has(projectId)) { + logger.debug('SMUS: Using cached project provider') + return this.projectCredentialProvidersCache.get(projectId)! + } + + logger.debug('SMUS: Creating new project provider') + // Create a new project provider and cache it + const projectProvider = new ProjectRoleCredentialsProvider(this, projectId) + this.projectCredentialProvidersCache.set(projectId, projectProvider) + + logger.debug('SMUS: Cached new project provider') + + return projectProvider + } + + /** + * Gets or creates a connection credentials provider for the specified connection + * @param connectionId The connection ID to get credentials for + * @param projectId The project ID that owns the connection + * @param region The region for the connection + * @returns Promise resolving to the connection credentials provider + */ + public async getConnectionCredentialsProvider( + connectionId: string, + projectId: string, + region: string + ): Promise { + const logger = getLogger() + + if (!this.activeConnection) { + throw new ToolkitError('No active SMUS connection available', { code: SmusErrorCodes.NoActiveConnection }) + } + + const cacheKey = `${this.activeConnection.domainId}:${projectId}:${connectionId}` + logger.debug(`SMUS: Getting connection provider for connection ${connectionId}`) + + // Check if we already have a cached provider for this connection + if (this.connectionCredentialProvidersCache.has(cacheKey)) { + logger.debug('SMUS: Using cached connection provider') + return this.connectionCredentialProvidersCache.get(cacheKey)! + } + + logger.debug('SMUS: Creating new connection provider') + // Create a new connection provider and cache it + const connectionProvider = new ConnectionCredentialsProvider(this, connectionId) + this.connectionCredentialProvidersCache.set(cacheKey, connectionProvider) + + logger.debug('SMUS: Cached new connection provider') + + return connectionProvider + } + + /** + * Gets the domain ID from the active connection + * @returns Domain ID + */ + public getDomainId(): string { + if (getContext('aws.smus.inSmusSpaceEnvironment')) { + return getResourceMetadata()!.AdditionalMetadata!.DataZoneDomainId! + } + + if (!this.activeConnection) { + throw new ToolkitError('No active SMUS connection available', { code: SmusErrorCodes.NoActiveConnection }) + } + return this.activeConnection.domainId + } + + /** + * Gets the domain URL from the active connection + * @returns Domain URL + */ + public getDomainUrl(): string { + if (!this.activeConnection) { + throw new ToolkitError('No active SMUS connection available', { code: SmusErrorCodes.NoActiveConnection }) + } + return this.activeConnection.domainUrl + } + + public getDomainRegion(): string { + if (getContext('aws.smus.inSmusSpaceEnvironment')) { + const resourceMetadata = getResourceMetadata()! + if (resourceMetadata.AdditionalMetadata!.DataZoneDomainRegion) { + return resourceMetadata.AdditionalMetadata!.DataZoneDomainRegion + } else { + throw new ToolkitError('Domain region not found in metadata file.') + } + } + + if (!this.activeConnection) { + throw new ToolkitError('No active SMUS connection available', { code: SmusErrorCodes.NoActiveConnection }) + } + return this.activeConnection.ssoRegion + } + + /** + * Gets or creates a cached credentials provider for the active connection + * @returns Promise resolving to the credentials provider + */ + public async getDerCredentialsProvider(): Promise { + const logger = getLogger() + + if (getContext('aws.smus.inSmusSpaceEnvironment')) { + // When in SMUS space, DomainExecutionRoleCreds can be found in config file + // Read the credentials from credential profile DomainExecutionRoleCreds + const credentials = fromIni({ profile: 'DomainExecutionRoleCreds' }) + return { + getCredentials: async () => await credentials(), + } + } + + if (!this.activeConnection) { + throw new ToolkitError('No active SMUS connection available', { code: SmusErrorCodes.NoActiveConnection }) + } + + // Create a cache key based on the connection details + const cacheKey = `${this.activeConnection.ssoRegion}:${this.activeConnection.domainId}` + + logger.debug(`SMUS: Getting credentials provider for cache key: ${cacheKey}`) + + // Check if we already have a cached provider + if (this.credentialsProviderCache.has(cacheKey)) { + logger.debug('SMUS: Using cached credentials provider') + return this.credentialsProviderCache.get(cacheKey) + } + + logger.debug('SMUS: Creating new credentials provider') + + // Create a new provider and cache it + const provider = new DomainExecRoleCredentialsProvider( + this.activeConnection.domainUrl, + this.activeConnection.domainId, + this.activeConnection.ssoRegion, + async () => await this.getAccessToken() + ) + + this.credentialsProviderCache.set(cacheKey, provider) + logger.debug('SMUS: Cached new credentials provider') + + return provider + } + + /** + * Invalidates all cached credentials (for all connections) + * Used during connection changes or logout + */ + private async invalidateAllCredentialsInCache(): Promise { + const logger = getLogger() + logger.debug('SMUS: Invalidating all cached credentials') + + // Clear all cached DER providers and their internal credentials + for (const [cacheKey, provider] of this.credentialsProviderCache.entries()) { + try { + provider.invalidate() // This will clear the provider's internal cache + logger.debug(`SMUS: Invalidated credentials for cache key: ${cacheKey}`) + } catch (err) { + logger.warn(`SMUS: Failed to invalidate credentials for cache key ${cacheKey}: %s`, err) + } + } + + // Clear all cached project providers and their internal credentials + + await this.invalidateAllProjectCredentialsInCache() + // Clear all cached connection providers and their internal credentials + for (const [cacheKey, connectionProvider] of this.connectionCredentialProvidersCache.entries()) { + try { + connectionProvider.invalidate() // This will clear the connection provider's internal cache + logger.debug(`SMUS: Invalidated connection credentials for cache key: ${cacheKey}`) + } catch (err) { + logger.warn(`SMUS: Failed to invalidate connection credentials for cache key ${cacheKey}: %s`, err) + } + } + } + + /** + * Invalidates all project cached credentials + */ + public async invalidateAllProjectCredentialsInCache(): Promise { + const logger = getLogger() + logger.debug('SMUS: Invalidating all cached project credentials') + + for (const [projectId, projectProvider] of this.projectCredentialProvidersCache.entries()) { + try { + projectProvider.invalidate() // This will clear the project provider's internal cache + logger.debug(`SMUS: Invalidated project credentials for project: ${projectId}`) + } catch (err) { + logger.warn(`SMUS: Failed to invalidate project credentials for project ${projectId}: %s`, err) + } + } + } + + /** + * Stops SSH credential refresh and cleans up resources + */ + public dispose(): void { + this.logger.debug('SMUS Auth: Disposing authentication provider and all cached providers') + + // Dispose all project providers + for (const provider of this.projectCredentialProvidersCache.values()) { + provider.dispose() + } + this.projectCredentialProvidersCache.clear() + + // Dispose all connection providers + for (const provider of this.connectionCredentialProvidersCache.values()) { + provider.dispose() + } + this.connectionCredentialProvidersCache.clear() + + // Dispose all DER providers in the general cache + for (const provider of this.credentialsProviderCache.values()) { + if (provider && typeof provider.dispose === 'function') { + provider.dispose() + } + } + this.credentialsProviderCache.clear() + this.logger.debug('SMUS Auth: Successfully disposed authentication provider') + } + + static #instance: SmusAuthenticationProvider | undefined + + public static get instance(): SmusAuthenticationProvider | undefined { + return SmusAuthenticationProvider.#instance + } + + public static fromContext() { + return (this.#instance ??= new this()) + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/activation.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/activation.ts new file mode 100644 index 00000000000..97ffadacc69 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/activation.ts @@ -0,0 +1,80 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { Constants } from './models/constants' +import { + getStatusBarProviders, + showConnectionQuickPick, + showProjectQuickPick, + parseNotebookCells, +} from './commands/commands' + +/** + * Activates the SageMaker Unified Studio Connection Magics Selector feature. + * + * @param extensionContext The extension context + */ +export async function activate(extensionContext: vscode.ExtensionContext): Promise { + extensionContext.subscriptions.push( + vscode.commands.registerCommand(Constants.CONNECTION_COMMAND, () => showConnectionQuickPick()), + vscode.commands.registerCommand(Constants.PROJECT_COMMAND, () => showProjectQuickPick()) + ) + + if ('NotebookEdit' in vscode) { + const { connectionProvider, projectProvider, separatorProvider } = getStatusBarProviders() + + extensionContext.subscriptions.push( + vscode.notebooks.registerNotebookCellStatusBarItemProvider('jupyter-notebook', connectionProvider), + vscode.notebooks.registerNotebookCellStatusBarItemProvider('jupyter-notebook', projectProvider), + vscode.notebooks.registerNotebookCellStatusBarItemProvider('jupyter-notebook', separatorProvider) + ) + + extensionContext.subscriptions.push( + vscode.window.onDidChangeActiveNotebookEditor(async () => { + await parseNotebookCells() + }) + ) + + extensionContext.subscriptions.push(vscode.workspace.onDidChangeTextDocument(handleTextDocumentChange)) + + void parseNotebookCells() + } +} + +/** + * Handles text document changes to update status bar when cells are manually edited + */ +function handleTextDocumentChange(event: vscode.TextDocumentChangeEvent): void { + if (event.document.uri.scheme !== 'vscode-notebook-cell') { + return + } + + const editor = vscode.window.activeNotebookEditor + if (!editor) { + return + } + + let changedCell: vscode.NotebookCell | undefined + for (let i = 0; i < editor.notebook.cellCount; i++) { + const cell = editor.notebook.cellAt(i) + if (cell.document.uri.toString() === event.document.uri.toString()) { + changedCell = cell + break + } + } + + if (changedCell && changedCell.kind === vscode.NotebookCellKind.Code) { + const { notebookStateManager } = require('./services/notebookStateManager') + + notebookStateManager.parseCellMagic(changedCell) + + setTimeout(() => { + const { connectionProvider, projectProvider } = getStatusBarProviders() + connectionProvider.refreshCellStatusBar() + projectProvider.refreshCellStatusBar() + }, 100) + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/client/connectedSpaceDataZoneClient.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/client/connectedSpaceDataZoneClient.ts new file mode 100644 index 00000000000..8f0998e295f --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/client/connectedSpaceDataZoneClient.ts @@ -0,0 +1,109 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataZone, ListConnectionsCommandOutput } from '@aws-sdk/client-datazone' +import { getLogger } from '../../../shared/logger/logger' + +/** + * Represents a DataZone connection + */ +export interface DataZoneConnection { + connectionId: string + name: string + type: string + props?: Record +} + +/** + * DataZone client for use in a SageMaker Unified Studio connected space + * Uses the user's current AWS credentials (project role credentials) + */ +export class ConnectedSpaceDataZoneClient { + private datazoneClient: DataZone | undefined + private readonly logger = getLogger() + + constructor( + private readonly region: string, + private readonly customEndpoint?: string + ) {} + + /** + * Gets the DataZone client, initializing it if necessary + * Uses default AWS credentials from the environment (project role) + * Supports custom endpoints for non-production environments + */ + private getDataZoneClient(): DataZone { + if (!this.datazoneClient) { + try { + const clientConfig: any = { + region: this.region, + } + + // Use custom endpoint if provided (for non-prod environments) + if (this.customEndpoint) { + clientConfig.endpoint = this.customEndpoint + this.logger.debug( + `ConnectedSpaceDataZoneClient: Using custom DataZone endpoint: ${this.customEndpoint}` + ) + } else { + this.logger.debug( + `ConnectedSpaceDataZoneClient: Using default AWS DataZone endpoint for region: ${this.region}` + ) + } + + this.logger.debug('ConnectedSpaceDataZoneClient: Creating DataZone client with default credentials') + this.datazoneClient = new DataZone(clientConfig) + this.logger.debug('ConnectedSpaceDataZoneClient: Successfully created DataZone client') + } catch (err) { + this.logger.error('ConnectedSpaceDataZoneClient: Failed to create DataZone client: %s', err as Error) + throw err + } + } + return this.datazoneClient + } + + /** + * Lists the connections in a DataZone domain and project + * @param domainId The DataZone domain identifier + * @param projectId The DataZone project identifier + * @returns List of connections + */ + public async listConnections(domainId: string, projectId: string): Promise { + try { + this.logger.info( + `ConnectedSpaceDataZoneClient: Listing connections for domain ${domainId}, project ${projectId}` + ) + + const datazoneClient = this.getDataZoneClient() + + const response: ListConnectionsCommandOutput = await datazoneClient.listConnections({ + domainIdentifier: domainId, + projectIdentifier: projectId, + }) + + if (!response.items || response.items.length === 0) { + this.logger.info( + `ConnectedSpaceDataZoneClient: No connections found for domain ${domainId}, project ${projectId}` + ) + return [] + } + + const connections: DataZoneConnection[] = response.items.map((connection) => ({ + connectionId: connection.connectionId || '', + name: connection.name || '', + type: connection.type || '', + props: connection.props || {}, + })) + + this.logger.info( + `ConnectedSpaceDataZoneClient: Found ${connections.length} connections for domain ${domainId}, project ${projectId}` + ) + return connections + } catch (err) { + this.logger.error('ConnectedSpaceDataZoneClient: Failed to list connections: %s', err as Error) + throw err + } + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/commands/commands.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/commands/commands.ts new file mode 100644 index 00000000000..01e269004c7 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/commands/commands.ts @@ -0,0 +1,195 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { connectionOptionsService } from '../services/connectionOptionsService' +import { notebookStateManager } from '../services/notebookStateManager' +import { + ConnectionStatusBarProvider, + ProjectStatusBarProvider, + SeparatorStatusBarProvider, +} from '../providers/notebookStatusBarProviders' +import { Constants } from '../models/constants' + +let connectionProvider: ConnectionStatusBarProvider | undefined +let projectProvider: ProjectStatusBarProvider | undefined +let separatorProvider: SeparatorStatusBarProvider | undefined + +/** + * Gets the status bar providers for registration, auto-initializing if needed + */ +export function getStatusBarProviders(): { + connectionProvider: ConnectionStatusBarProvider + projectProvider: ProjectStatusBarProvider + separatorProvider: SeparatorStatusBarProvider +} { + if (!connectionProvider) { + connectionProvider = new ConnectionStatusBarProvider(3, Constants.CONNECTION_COMMAND) + } + if (!projectProvider) { + projectProvider = new ProjectStatusBarProvider(2, Constants.PROJECT_COMMAND) + } + if (!separatorProvider) { + separatorProvider = new SeparatorStatusBarProvider(1) + } + + return { + connectionProvider, + projectProvider, + separatorProvider, + } +} + +/** + * Sets the selected connection for a cell and updates the magic command + */ +export async function setSelectedConnection(cell: vscode.NotebookCell, connectionLabel: string): Promise { + notebookStateManager.setSelectedConnection(cell, connectionLabel, true) + await notebookStateManager.updateCellWithMagic(cell) +} + +/** + * Sets the selected project for a cell and updates the magic command + */ +export async function setSelectedProject(cell: vscode.NotebookCell, projectLabel: string): Promise { + notebookStateManager.setSelectedProject(cell, projectLabel) + await notebookStateManager.updateCellWithMagic(cell) +} + +/** + * Shows a quick pick menu for selecting a connection type and sets the connection for the active cell + */ +export async function showConnectionQuickPick(): Promise { + const editor = vscode.window.activeNotebookEditor + if (!editor) { + return + } + + const cell = editor.selection.start !== undefined ? editor.notebook.cellAt(editor.selection.start) : undefined + if (!cell) { + return + } + + await connectionOptionsService.updateConnectionAndProjectOptions() + + const connectionOptions = connectionOptionsService.getConnectionOptionsSync() + + // Sort connections based on preferred connection order + const sortedOptions = connectionOptions.sort((a, b) => { + // Comparison logic + const aIndex = Constants.CONNECTION_QUICK_PICK_ORDER.indexOf(a.label as any) + const bIndex = Constants.CONNECTION_QUICK_PICK_ORDER.indexOf(b.label as any) + + // If both are in the priority list, sort by their position + if (aIndex !== -1 && bIndex !== -1) { + return aIndex - bIndex + } + // If only 'a' is in the priority list, it comes first + if (aIndex !== -1) { + return -1 + } + // If only 'b' is in the priority list, it comes first + if (bIndex !== -1) { + return 1 + } + // If neither is in the priority list, maintain original order + return 0 + }) + + const quickPickItems: vscode.QuickPickItem[] = sortedOptions.map((option) => { + return { + label: option.label, + description: `(${option.magic})`, + iconPath: new vscode.ThemeIcon('plug'), + } + }) + + const selected = await vscode.window.showQuickPick(quickPickItems, { + placeHolder: Constants.CONNECTION_QUICK_PICK_LABEL_PLACEHOLDER, + }) + + if (selected) { + const connectionLabel = selected.detail || selected.label + await setSelectedConnection(cell, connectionLabel) + } +} + +/** + * Shows a quick pick menu for selecting a project type and sets the project for the active cell + */ +export async function showProjectQuickPick(): Promise { + const editor = vscode.window.activeNotebookEditor + if (!editor) { + return + } + + const cell = editor.selection.start !== undefined ? editor.notebook.cellAt(editor.selection.start) : undefined + if (!cell) { + return + } + + const connection = notebookStateManager.getSelectedConnection(cell) + if (!connection) { + return + } + + await connectionOptionsService.updateConnectionAndProjectOptions() + + const options = notebookStateManager.getProjectOptionsForConnection(cell) + if (options.length === 0) { + return + } + + const projectQuickPickItems: vscode.QuickPickItem[] = options.map((option) => { + return { + label: option.project, + description: `(${option.connection})`, + iconPath: new vscode.ThemeIcon('server'), + } + }) + + const selected = await vscode.window.showQuickPick(projectQuickPickItems, { + placeHolder: Constants.PROJECT_QUICK_PICK_LABEL_PLACEHOLDER, + }) + + if (selected) { + if (!selected.label) { + return + } + + await setSelectedProject(cell, selected.label) + } +} + +/** + * Refreshes the status bar items + */ +export function refreshStatusBarItems(): void { + connectionProvider?.refreshCellStatusBar() + projectProvider?.refreshCellStatusBar() + separatorProvider?.refreshCellStatusBar() +} + +/** + * Parses all notebook cells to current cell magics + */ +export async function parseNotebookCells(): Promise { + await connectionOptionsService.updateConnectionAndProjectOptions() + + const editor = vscode.window.activeNotebookEditor + if (!editor) { + return + } + + for (let i = 0; i < editor.notebook.cellCount; i++) { + const cell = editor.notebook.cellAt(i) + + if (cell.kind === vscode.NotebookCellKind.Code && cell.document.languageId !== 'markdown') { + notebookStateManager.parseCellMagic(cell) + } + } + + refreshStatusBarItems() +} diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/index.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/index.ts new file mode 100644 index 00000000000..0f7f429b5e6 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/index.ts @@ -0,0 +1,11 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export { activate } from './activation' + +export * from './models/constants' +export * from './models/types' +export * from './services/connectionOptionsService' +export * from './services/notebookStateManager' diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/models/constants.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/models/constants.ts new file mode 100644 index 00000000000..d94d4c9f3f7 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/models/constants.ts @@ -0,0 +1,153 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ConnectionTypeProperties } from './types' + +export const Constants = { + // Connection types + CONNECTION_TYPE_EMR_EC2: 'SPARK_EMR_EC2', + CONNECTION_TYPE_EMR_SERVERLESS: 'SPARK_EMR_SERVERLESS', + CONNECTION_TYPE_GLUE: 'SPARK_GLUE', + CONNECTION_TYPE_SPARK: 'SPARK', + CONNECTION_TYPE_REDSHIFT: 'REDSHIFT', + CONNECTION_TYPE_ATHENA: 'ATHENA', + CONNECTION_TYPE_IAM: 'IAM', + + // UI labels and placeholders + CONNECTION_QUICK_PICK_LABEL_PLACEHOLDER: 'Select Connection', + CONNECTION_STATUS_BAR_ITEM_LABEL: 'Select Connection', + CONNECTION_STATUS_BAR_ITEM_ICON: '$(plug)', + DEFAULT_CONNECTION_STATUS_BAR_ITEM_LABEL: 'Connection', + PROJECT_QUICK_PICK_LABEL_PLACEHOLDER: 'Select Compute', + PROJECT_STATUS_BAR_ITEM_LABEL: 'Select Compute', + PROJECT_STATUS_BAR_ITEM_ICON: '$(server)', + DEFAULT_PROJECT_STATUS_BAR_ITEM_LABEL: 'Compute', + CONNECTION_QUICK_PICK_ORDER: ['Local Python', 'PySpark', 'ScalaSpark', 'SQL'] as const, + + // Command IDs + CONNECTION_COMMAND: 'aws.smus.connectionmagics.selectConnection', + PROJECT_COMMAND: 'aws.smus.connectionmagics.selectProject', + + // Magic string literals + LOCAL_PYTHON: 'Local Python', + PYSPARK: 'PySpark', + SCALA_SPARK: 'ScalaSpark', + SQL: 'SQL', + MAGIC_PREFIX: '%%', + LOCAL_MAGIC: '%%local', + NAME_FLAG_LONG: '--name', + NAME_FLAG_SHORT: '-n', + SAGEMAKER_CONNECTION_METADATA_KEY: 'sagemakerConnection', + MARKDOWN_LANGUAGE: 'markdown', + PROJECT_PYTHON: 'project.python', + PROJECT_SPARK_COMPATIBILITY: 'project.spark.compatibility', +} as const + +/** + * Maps connection types to their display properties + */ +export const connectionTypePropertiesMap: Record = { + [Constants.CONNECTION_TYPE_GLUE]: { + labels: ['PySpark', 'SQL'], // Glue supports both PySpark and SQL + magic: '%%pyspark', + language: 'python', + category: 'spark', + }, + [Constants.CONNECTION_TYPE_EMR_EC2]: { + labels: ['PySpark', 'SQL'], // EMR supports both PySpark and SQL + magic: '%%pyspark', + language: 'python', + category: 'spark', + }, + [Constants.CONNECTION_TYPE_EMR_SERVERLESS]: { + labels: ['PySpark', 'SQL'], // EMR supports both PySpark and SQL + magic: '%%pyspark', + language: 'python', + category: 'spark', + }, + [Constants.CONNECTION_TYPE_REDSHIFT]: { + labels: ['SQL'], // Redshift only supports SQL + magic: '%%sql', + language: 'sql', + category: 'sql', + }, + [Constants.CONNECTION_TYPE_ATHENA]: { + labels: ['SQL'], // Athena only supports SQL + magic: '%%sql', + language: 'sql', + category: 'sql', + }, +} + +/** + * Maps connection labels to their display properties + */ +export const connectionLabelPropertiesMap: Record< + string, + { description: string; magic: string; language: string; category: string } +> = { + PySpark: { + description: 'Python with Spark', + magic: '%%pyspark', + language: 'python', + category: 'spark', + }, + SQL: { + description: 'SQL Query', + magic: '%%sql', + language: 'sql', + category: 'sql', + }, + ScalaSpark: { + description: 'Scala with Spark', + magic: '%%scalaspark', + language: 'python', // Scala is not a supported language mode, defaulting to Python + category: 'spark', + }, + 'Local Python': { + description: 'Python', + magic: '%%local', + language: 'python', + category: 'python', + }, + IAM: { + description: 'IAM Connection', + magic: '%%iam', + language: 'python', + category: 'iam', + }, +} + +/** + * Maps connection types to their platform display names for grouping + */ +export const connectionTypeToComputeNameMap: Record = { + [Constants.CONNECTION_TYPE_GLUE]: 'Glue', + [Constants.CONNECTION_TYPE_REDSHIFT]: 'Redshift', + [Constants.CONNECTION_TYPE_ATHENA]: 'Athena', + [Constants.CONNECTION_TYPE_EMR_EC2]: 'EMR EC2', + [Constants.CONNECTION_TYPE_EMR_SERVERLESS]: 'EMR Serverless', +} + +/** + * Maps magic commands to their corresponding connection types + */ +export const magicCommandToConnectionMap: Record = { + '%%spark': 'PySpark', + '%%pyspark': 'PySpark', + '%%scalaspark': 'ScalaSpark', + '%%local': 'Local Python', + '%%sql': 'SQL', +} as const + +/** + * Default project names for each connection type + */ +export const defaultProjectsByConnection: Record = { + 'Local Python': ['project.python'], + PySpark: ['project.spark.compatibility'], + ScalaSpark: ['project.spark.compatibility'], + SQL: ['project.spark.compatibility'], +} as const diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/models/types.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/models/types.ts new file mode 100644 index 00000000000..b14daab1ce8 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/models/types.ts @@ -0,0 +1,68 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SageMaker Connection Summary interface + */ +export interface SageMakerConnectionSummary { + name: string + type: string +} + +/** + * Connection option type definition + */ +export interface ConnectionOption { + label: string + description: string + magic: string + language: string + category: string +} + +/** + * Project option group type definition + */ +export interface ProjectOptionGroup { + connection: string + projects: string[] +} + +/** + * Project option type definition + */ +export interface ProjectOption { + connection: string + project: string +} + +/** + * Connection to project mapping type definition + */ +export interface ConnectionProjectMapping { + connection: string + projectOptions: ProjectOptionGroup[] +} + +/** + * Represents the state of a notebook cell's connection settings + */ +export interface CellState { + connection?: string + project?: string + isUserSelection?: boolean + originalMagicCommand?: string + lastParsedContent?: string +} + +/** + * Maps connection types to their display properties + */ +export interface ConnectionTypeProperties { + labels: string[] + magic: string + language: string + category: string +} diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/providers/notebookStatusBarProviders.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/providers/notebookStatusBarProviders.ts new file mode 100644 index 00000000000..8551f615110 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/providers/notebookStatusBarProviders.ts @@ -0,0 +1,143 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { notebookStateManager } from '../services/notebookStateManager' +import { Constants } from '../models/constants' + +/** + * Abstract base class for notebook status bar providers. + */ +export abstract class BaseNotebookStatusBarProvider implements vscode.NotebookCellStatusBarItemProvider { + protected item: vscode.NotebookCellStatusBarItem + protected onDidChangeCellStatusBarItemsEmitter = new vscode.EventEmitter() + protected priority: number + protected icon?: string + protected command?: string + protected tooltip?: string + + public constructor(priority: number, icon?: string, command?: string, tooltip?: string) { + this.priority = priority + this.icon = icon + this.command = command + this.tooltip = tooltip + this.item = new vscode.NotebookCellStatusBarItem('', vscode.NotebookCellStatusBarAlignment.Right) + this.item.priority = priority + } + + /** + * Abstract method that each provider must implement to provide their specific status bar item. + */ + public abstract provideCellStatusBarItems( + cell: vscode.NotebookCell, + token: vscode.CancellationToken + ): vscode.ProviderResult + + /** + * Creates a status bar item with the provided text and applies common settings. + */ + protected createStatusBarItem(text: string, isClickable: boolean = true): vscode.NotebookCellStatusBarItem { + const displayText = this.icon ? `${this.icon} ${text}` : text + const item = new vscode.NotebookCellStatusBarItem(displayText, vscode.NotebookCellStatusBarAlignment.Right) + item.priority = this.priority + + if (isClickable && this.command) { + item.command = this.command + item.tooltip = this.tooltip + } + + return item + } + + /** + * Refreshes the cell status bar items. + */ + public refreshCellStatusBar(): void { + this.onDidChangeCellStatusBarItemsEmitter.fire() + } + + /** + * Event that fires when the cell status bar items have changed. + */ + public get onDidChangeCellStatusBarItems(): vscode.Event { + return this.onDidChangeCellStatusBarItemsEmitter.event + } +} + +/** + * Status bar provider for connection selection in notebook cells. + */ +export class ConnectionStatusBarProvider extends BaseNotebookStatusBarProvider { + public constructor(priority: number, command: string) { + super(priority, Constants.CONNECTION_STATUS_BAR_ITEM_ICON, command, Constants.CONNECTION_STATUS_BAR_ITEM_LABEL) + } + + public provideCellStatusBarItems( + cell: vscode.NotebookCell, + token: vscode.CancellationToken + ): vscode.ProviderResult { + // Don't show on non-code or markdown code cells + if (cell.kind !== vscode.NotebookCellKind.Code || cell.document.languageId === 'markdown') { + return undefined + } + + const connection = notebookStateManager.getSelectedConnection(cell) + + const displayText = connection || Constants.DEFAULT_CONNECTION_STATUS_BAR_ITEM_LABEL + const item = this.createStatusBarItem(displayText) + + return item + } +} + +/** + * Status bar provider for project selection in notebook cells. + */ +export class ProjectStatusBarProvider extends BaseNotebookStatusBarProvider { + public constructor(priority: number, command: string) { + super(priority, Constants.PROJECT_STATUS_BAR_ITEM_ICON, command, Constants.PROJECT_STATUS_BAR_ITEM_LABEL) + } + + public provideCellStatusBarItems( + cell: vscode.NotebookCell, + token: vscode.CancellationToken + ): vscode.ProviderResult { + // Don't show on non-code or markdown code cells + if (cell.kind !== vscode.NotebookCellKind.Code || cell.document.languageId === 'markdown') { + return undefined + } + + const project = notebookStateManager.getSelectedProject(cell) + + const displayText = project || Constants.DEFAULT_PROJECT_STATUS_BAR_ITEM_LABEL + const item = this.createStatusBarItem(displayText) + + return item + } +} + +/** + * Status bar provider for displaying a separator between items in notebook cells. + */ +export class SeparatorStatusBarProvider extends BaseNotebookStatusBarProvider { + public constructor(priority: number, separatorText: string = '|') { + super(priority) + + this.item = new vscode.NotebookCellStatusBarItem(separatorText, vscode.NotebookCellStatusBarAlignment.Right) + this.item.priority = priority + } + + public provideCellStatusBarItems( + cell: vscode.NotebookCell, + token: vscode.CancellationToken + ): vscode.ProviderResult { + // Don't show on non-code or markdown code cells + if (cell.kind !== vscode.NotebookCellKind.Code || cell.document.languageId === 'markdown') { + return undefined + } + + return this.item + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/services/connectionOptionsService.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/services/connectionOptionsService.ts new file mode 100644 index 00000000000..901c2e5a60f --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/services/connectionOptionsService.ts @@ -0,0 +1,293 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../../../shared/logger/logger' +import { + Constants, + connectionTypePropertiesMap, + connectionLabelPropertiesMap, + connectionTypeToComputeNameMap, +} from '../models/constants' +import { + ConnectionOption, + ProjectOptionGroup, + ConnectionProjectMapping, + SageMakerConnectionSummary, +} from '../models/types' +import { ConnectedSpaceDataZoneClient } from '../client/connectedSpaceDataZoneClient' +import { getResourceMetadata } from '../../shared/utils/resourceMetadataUtils' + +let datazoneClient: ConnectedSpaceDataZoneClient | undefined + +/** + * Gets or creates the module-scoped DataZone client instance + */ +function getDataZoneClient(): ConnectedSpaceDataZoneClient { + if (!datazoneClient) { + const resourceMetadata = getResourceMetadata() + + if (!resourceMetadata?.AdditionalMetadata?.DataZoneDomainRegion) { + throw new Error('DataZone domain region not found in resource metadata') + } + + const region = resourceMetadata.AdditionalMetadata.DataZoneDomainRegion + const customEndpoint = resourceMetadata.AdditionalMetadata?.DataZoneEndpoint + + datazoneClient = new ConnectedSpaceDataZoneClient(region, customEndpoint) + } + return datazoneClient +} + +/** + * Service for managing connection options and project mappings + */ +class ConnectionOptionsService { + private connectionOptions: ConnectionOption[] = [] + private projectOptions: ConnectionProjectMapping[] = [] + private cachedConnections: SageMakerConnectionSummary[] = [] + + constructor() {} + + /** + * Gets the appropriate connection option for a given label + */ + private getConnectionOptionForLabel(label: string): ConnectionOption | undefined { + const labelProps = connectionLabelPropertiesMap[label] + if (!labelProps) { + return undefined + } + + return { + label, + description: labelProps.description, + magic: labelProps.magic, + language: labelProps.language, + category: labelProps.category, + } + } + + /** + * Gets filtered connections from DataZone, excluding IAM connections and processing SPARK connections + */ + private async getFilteredConnections(forceRefresh: boolean = false): Promise { + if (this.cachedConnections.length > 0 && !forceRefresh) { + return this.cachedConnections + } + + try { + const resourceMetadata = getResourceMetadata() + + if (!resourceMetadata?.AdditionalMetadata?.DataZoneDomainId) { + throw new Error('DataZone domain ID not found in resource metadata') + } + + if (!resourceMetadata?.AdditionalMetadata?.DataZoneProjectId) { + throw new Error('DataZone project ID not found in resource metadata') + } + + const connections = await getDataZoneClient().listConnections( + resourceMetadata.AdditionalMetadata.DataZoneDomainId, + resourceMetadata.AdditionalMetadata.DataZoneProjectId + ) + + const processedConnections: SageMakerConnectionSummary[] = [] + + for (const connection of connections) { + if ( + connection.type === Constants.CONNECTION_TYPE_REDSHIFT || + connection.type === Constants.CONNECTION_TYPE_ATHENA + ) { + processedConnections.push({ + name: connection.name || '', + type: connection.type || '', + }) + } else if (connection.type === Constants.CONNECTION_TYPE_SPARK) { + if ('sparkGlueProperties' in (connection.props || {})) { + processedConnections.push({ + name: connection.name || '', + type: Constants.CONNECTION_TYPE_GLUE, + }) + } else if ( + 'sparkEmrProperties' in (connection.props || {}) && + 'computeArn' in (connection.props?.sparkEmrProperties || {}) + ) { + const computeArn = connection.props?.sparkEmrProperties?.computeArn || '' + + if (computeArn.includes('cluster')) { + processedConnections.push({ + name: connection.name || '', + type: Constants.CONNECTION_TYPE_EMR_EC2, + }) + } else if (computeArn.includes('applications')) { + processedConnections.push({ + name: connection.name || '', + type: Constants.CONNECTION_TYPE_EMR_SERVERLESS, + }) + } + } + } + } + + this.cachedConnections = processedConnections + return processedConnections + } catch (error) { + getLogger().error('Failed to list DataZone connections: %s', error as Error) + return [] + } + } + + /** + * Adds custom Local Python option to the options list + */ + private addLocalPythonOption(options: ConnectionOption[], addedLabels: Set): void { + const localPythonOption = this.getConnectionOptionForLabel('Local Python') + if (localPythonOption) { + options.push(localPythonOption) + addedLabels.add('Local Python') + } + } + + /** + * Gets the available connection options, either from DataZone connections or defaults + * @returns Array of connection options + */ + public async getConnectionOptions(): Promise { + try { + const connections = await this.getFilteredConnections() + + if (connections.length === 0) { + return [] + } + + const options: ConnectionOption[] = [] + const addedLabels = new Set() + + this.addLocalPythonOption(options, addedLabels) + + for (const connection of connections) { + const typeProps = connectionTypePropertiesMap[connection.type] + if (typeProps) { + for (const label of typeProps.labels) { + if (!addedLabels.has(label)) { + const connectionOption = this.getConnectionOptionForLabel(label) + if (connectionOption) { + options.push(connectionOption) + addedLabels.add(label) + } + } + } + } + } + + if (addedLabels.has(Constants.PYSPARK) && !addedLabels.has(Constants.SCALA_SPARK)) { + const scalaSparkOption = this.getConnectionOptionForLabel(Constants.SCALA_SPARK) + if (scalaSparkOption) { + options.push(scalaSparkOption) + } + } + + return options + } catch (error) { + getLogger().error('Failed to get connection options: %s', error as Error) + return [] + } + } + + /** + * Gets the project options for a specific connection type + * @param connectionType The connection type + * @returns Project options for the connection type + */ + public async getProjectOptionsForConnectionType(connectionType: string): Promise { + try { + const connections = await this.getFilteredConnections() + + if (connections.length === 0) { + return [] + } + + const effectiveConnectionType = connectionType === 'ScalaSpark' ? 'PySpark' : connectionType + const filteredConnections: Record = {} + + for (const connection of connections) { + const typeProps = connectionTypePropertiesMap[connection.type] + + if (typeProps && typeProps.labels.includes(effectiveConnectionType)) { + const compute = connectionTypeToComputeNameMap[connection.type] || 'Unknown' + + if (!filteredConnections[compute]) { + filteredConnections[compute] = [] + } + filteredConnections[compute].push(connection.name) + } + } + + const projectOptions: ProjectOptionGroup[] = [] + for (const [compute, projects] of Object.entries(filteredConnections)) { + projectOptions.push({ connection: compute, projects }) + } + + return projectOptions + } catch (error) { + getLogger().error('Failed to get project options: %s', error as Error) + return [] + } + } + + /** + * Updates the connection and project options from DataZone + */ + public async updateConnectionAndProjectOptions(): Promise { + try { + this.connectionOptions = await this.getConnectionOptions() + + if (this.connectionOptions.length === 0) { + this.projectOptions = [] + return + } + + const newProjectOptions: ConnectionProjectMapping[] = [] + + newProjectOptions.push({ + connection: 'Local Python', + projectOptions: [{ connection: 'Local', projects: ['project.python'] }], + }) + + for (const option of this.connectionOptions) { + if (option.label !== 'Local Python') { + const projectOpts = await this.getProjectOptionsForConnectionType(option.label) + if (projectOpts.length > 0) { + newProjectOptions.push({ + connection: option.label, + projectOptions: projectOpts, + }) + } + } + } + + this.projectOptions = newProjectOptions + } catch (error) { + getLogger().error('Failed to update connection and project options: %s', error as Error) + this.connectionOptions = [] + this.projectOptions = [] + } + } + + /** + * Gets the current cached connection options + */ + public getConnectionOptionsSync(): ConnectionOption[] { + return this.connectionOptions + } + + /** + * Gets the current cached project options + */ + public getProjectOptionsSync(): ConnectionProjectMapping[] { + return this.projectOptions + } +} + +export const connectionOptionsService = new ConnectionOptionsService() diff --git a/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/services/notebookStateManager.ts b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/services/notebookStateManager.ts new file mode 100644 index 00000000000..80654b64ac0 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/connectionMagicsSelector/services/notebookStateManager.ts @@ -0,0 +1,420 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { CellState, ProjectOption } from '../models/types' +import { connectionOptionsService } from './connectionOptionsService' +import { getLogger } from '../../../shared/logger/logger' +import { magicCommandToConnectionMap, defaultProjectsByConnection, Constants } from '../models/constants' + +/** + * State manager for tracking notebook cell states and selections + */ +class NotebookStateManager { + private cellStates: Map = new Map() + + constructor() {} + + /** + * Gets the cell state for a specific cell + */ + private getCellState(cell: vscode.NotebookCell): CellState { + const cellId = cell.document.uri.toString() + if (!this.cellStates.has(cellId)) { + this.cellStates.set(cellId, {}) + } + return this.cellStates.get(cellId)! + } + + /** + * Sets metadata on a cell + */ + private async setCellMetadata(cell: vscode.NotebookCell, key: string, value: any): Promise { + try { + const edit = new vscode.WorkspaceEdit() + const notebookEdit = vscode.NotebookEdit.updateCellMetadata(cell.index, { + ...cell.metadata, + [key]: value, + }) + edit.set(cell.notebook.uri, [notebookEdit]) + await vscode.workspace.applyEdit(edit) + } catch (error) { + getLogger().warn('setCellMetadata: Failed to set metadata, falling back to in-memory storage') + } + } + + /** + * Gets the selected connection for a cell + */ + public getSelectedConnection(cell: vscode.NotebookCell): string | undefined { + const connection = cell.metadata?.[Constants.SAGEMAKER_CONNECTION_METADATA_KEY] as string + if (connection) { + return connection + } + + const state = this.getCellState(cell) + const currentCellContent = cell.document.getText() + + if (!state.connection || (!state.isUserSelection && state.lastParsedContent !== currentCellContent)) { + this.parseCellMagic(cell) + const updatedState = this.getCellState(cell) + updatedState.lastParsedContent = currentCellContent + + return updatedState.connection + } + + return state.connection + } + + /** + * Sets the selected connection for a cell + */ + public setSelectedConnection( + cell: vscode.NotebookCell, + value: string | undefined, + isUserSelection: boolean = false + ): void { + const state = this.getCellState(cell) + const previousConnection = state.connection + state.connection = value + + if (isUserSelection) { + state.isUserSelection = true + + if (value) { + void this.setCellMetadata(cell, Constants.SAGEMAKER_CONNECTION_METADATA_KEY, value) + } + } + + if (value === Constants.LOCAL_PYTHON || value === undefined) { + if (value === Constants.LOCAL_PYTHON && previousConnection !== value) { + state.project = undefined + this.setDefaultProjectForConnection(cell, Constants.LOCAL_PYTHON) + } else if (value === Constants.LOCAL_PYTHON && previousConnection === value) { + if (!state.project) { + this.setDefaultProjectForConnection(cell, Constants.LOCAL_PYTHON) + } + } else { + state.project = undefined + } + } else if (previousConnection !== value) { + state.project = undefined + this.setDefaultProjectForConnection(cell, value) + } + } + + /** + * Gets the selected project for a cell + */ + public getSelectedProject(cell: vscode.NotebookCell): string | undefined { + return this.getCellState(cell).project + } + + /** + * Sets the selected project for a cell + */ + public setSelectedProject(cell: vscode.NotebookCell, value: string | undefined): void { + const state = this.getCellState(cell) + state.project = value + } + + /** + * Gets the magic command for a cell using simplified format for UI operations + */ + public getMagicCommand(cell: vscode.NotebookCell): string | undefined { + const connection = this.getSelectedConnection(cell) + if (!connection) { + return + } + + if (connection === Constants.LOCAL_PYTHON) { + const state = this.getCellState(cell) + const hasLocalMagic = state.originalMagicCommand?.startsWith(Constants.LOCAL_MAGIC) + + if (!hasLocalMagic) { + return undefined + } + } + + const connectionOptions = connectionOptionsService.getConnectionOptionsSync() + + const connectionOption = connectionOptions.find((option) => option.label === connection) + if (!connectionOption) { + return undefined + } + + const project = this.getSelectedProject(cell) + + if (!project) { + return connectionOption.magic + } + + return `${connectionOption.magic} ${project}` + } + + /** + * Parses a cell's content to detect magic commands and updates the state manager + * @param cell The notebook cell to parse + */ + public parseCellMagic(cell: vscode.NotebookCell): void { + if ( + !cell || + cell.kind !== vscode.NotebookCellKind.Code || + cell.document.languageId === Constants.MARKDOWN_LANGUAGE + ) { + return + } + + const state = this.getCellState(cell) + if (state.isUserSelection) { + return + } + + const cellText = cell.document.getText() + const lines = cellText.split('\n') + + const firstLine = lines[0].trim() + if (!firstLine.startsWith(Constants.MAGIC_PREFIX)) { + this.setSelectedConnection(cell, Constants.LOCAL_PYTHON) + return + } + + const parsed = this.parseMagicCommandLine(firstLine) + if (!parsed) { + return + } + + const connectionType = magicCommandToConnectionMap[parsed.magic] + if (!connectionType) { + this.setSelectedConnection(cell, Constants.LOCAL_PYTHON) + this.setDefaultProjectForConnection(cell, Constants.LOCAL_PYTHON) + return + } + + const cellState = this.getCellState(cell) + cellState.originalMagicCommand = firstLine + + this.setSelectedConnection(cell, connectionType) + + if (parsed.project) { + this.setSelectedProject(cell, parsed.project) + } else { + this.setDefaultProjectForConnection(cell, connectionType) + } + } + + /** + * Parses a magic command line to extract magic and project parameters + * Supports formats: %%magic, %%magic project, %%magic --name project, %%magic -n project + */ + private parseMagicCommandLine(line: string): { magic: string; project?: string } | undefined { + const tokens = line.split(/\s+/) + if (tokens.length === 0 || !tokens[0].startsWith(Constants.MAGIC_PREFIX)) { + return undefined + } + + const magic = tokens[0] + let project: string | undefined + + if (tokens.length === 2) { + // Format: %%magic project + project = tokens[1] + } else if (tokens.length >= 3) { + // Format: %%magic --name project or %%magic -n project + const flagIndex = tokens.findIndex( + (token) => token === Constants.NAME_FLAG_LONG || token === Constants.NAME_FLAG_SHORT + ) + if (flagIndex !== -1 && flagIndex + 1 < tokens.length) { + project = tokens[flagIndex + 1] + } + } + + return { magic, project } + } + + /** + * Sets default project for a connection when no explicit project is specified + */ + private setDefaultProjectForConnection(cell: vscode.NotebookCell, connectionType: string): void { + const projectOptions = connectionOptionsService.getProjectOptionsSync() + + const mapping = projectOptions.find((option) => option.connection === connectionType) + if (!mapping || mapping.projectOptions.length === 0) { + return + } + + const defaultProjects = defaultProjectsByConnection[connectionType] || [] + + for (const defaultProject of defaultProjects) { + for (const projectOption of mapping.projectOptions) { + if (projectOption.projects.includes(defaultProject)) { + this.setSelectedProject(cell, defaultProject) + return + } + } + } + + const firstProjectOption = mapping.projectOptions[0] + if (firstProjectOption.projects.length > 0) { + this.setSelectedProject(cell, firstProjectOption.projects[0]) + } + } + + /** + * Updates the current cell with the magic command and sets the cell language + * @param cell The notebook cell to update + */ + public async updateCellWithMagic(cell: vscode.NotebookCell): Promise { + const connection = this.getSelectedConnection(cell) + if (!connection) { + return + } + + const connectionOptions = connectionOptionsService.getConnectionOptionsSync() + const connectionOption = connectionOptions.find((option) => option.label === connection) + if (!connectionOption) { + return + } + + try { + await vscode.languages.setTextDocumentLanguage(cell.document, connectionOption.language) + + const cellText = cell.document.getText() + const lines = cellText.split('\n') + const firstLine = lines[0] || '' + const isMagicCommand = firstLine.trim().startsWith(Constants.MAGIC_PREFIX) + + let newCellContent = cellText + + if (connection === Constants.LOCAL_PYTHON) { + const state = this.getCellState(cell) + const hasLocalMagic = state.originalMagicCommand?.startsWith(Constants.LOCAL_MAGIC) + + if (hasLocalMagic) { + const magicCommand = this.getMagicCommand(cell) + if (magicCommand) { + if (isMagicCommand) { + newCellContent = magicCommand + '\n' + lines.slice(1).join('\n') + } else { + newCellContent = magicCommand + '\n' + cellText + } + } + } else { + if (isMagicCommand) { + newCellContent = lines.slice(1).join('\n') + } + } + } else { + const magicCommand = this.getMagicCommand(cell) + + if (magicCommand) { + if (!magicCommand.startsWith(Constants.MAGIC_PREFIX)) { + return + } + + if (isMagicCommand) { + newCellContent = magicCommand + '\n' + lines.slice(1).join('\n') + } else { + newCellContent = magicCommand + '\n' + cellText + } + } + } + + if (newCellContent !== cellText) { + await this.updateCellContent(cell, newCellContent) + } + } catch (error) { + getLogger().error(`Error updating cell with magic command: ${error}`) + } + } + + /** + * Updates the content of a notebook cell using the most appropriate API for the environment + * @param cell The notebook cell to update + * @param newContent The new content for the cell + */ + private async updateCellContent(cell: vscode.NotebookCell, newContent: string): Promise { + try { + if (vscode.workspace.applyEdit && (vscode as any).NotebookEdit) { + const edit = new vscode.WorkspaceEdit() + const notebookUri = cell.notebook.uri + const cellIndex = cell.index + + const newCellData = new vscode.NotebookCellData(cell.kind, newContent, cell.document.languageId) + + const notebookEdit = (vscode as any).NotebookEdit.replaceCells( + new vscode.NotebookRange(cellIndex, cellIndex + 1), + [newCellData] + ) + edit.set(notebookUri, [notebookEdit]) + + const success = await vscode.workspace.applyEdit(edit) + if (success) { + return + } + } + } catch (error) { + getLogger().error(`NotebookEdit failed, attempting to update cell content with WorkspaceEdit: ${error}`) + } + + try { + const edit = new vscode.WorkspaceEdit() + + const fullRange = new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(cell.document.lineCount, 0) + ) + + edit.replace(cell.document.uri, fullRange, newContent) + + const success = await vscode.workspace.applyEdit(edit) + if (!success) { + getLogger().error('WorkspaceEdit failed to apply') + } + } catch (error) { + getLogger().error(`Failed to update cell content with WorkspaceEdit: ${error}`) + + try { + const document = cell.document + if (document && 'getText' in document && 'uri' in document) { + const edit = new vscode.WorkspaceEdit() + const fullText = document.getText() + const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(fullText.length)) + edit.replace(document.uri, fullRange, newContent) + await vscode.workspace.applyEdit(edit) + } + } catch (finalError) { + getLogger().error(`All cell update methods failed: ${finalError}`) + } + } + } + + /** + * Gets the project options for the selected connection in a cell + */ + public getProjectOptionsForConnection(cell: vscode.NotebookCell): ProjectOption[] { + const connection = this.getSelectedConnection(cell) + if (!connection) { + return [] + } + + const projectOptions = connectionOptionsService.getProjectOptionsSync() + const mapping = projectOptions.find((option) => option.connection === connection) + if (!mapping) { + return [] + } + + const options: ProjectOption[] = [] + for (const projectOption of mapping.projectOptions) { + for (const project of projectOption.projects) { + options.push({ connection: projectOption.connection, project: project }) + } + } + + return options + } +} + +export const notebookStateManager = new NotebookStateManager() diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/activation.ts b/packages/core/src/sagemakerunifiedstudio/explorer/activation.ts new file mode 100644 index 00000000000..65aed68e670 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/activation.ts @@ -0,0 +1,160 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { ResourceTreeDataProvider } from '../../shared/treeview/resourceTreeDataProvider' +import { + smusLoginCommand, + smusLearnMoreCommand, + smusSignOutCommand, + SageMakerUnifiedStudioRootNode, + selectSMUSProject, +} from './nodes/sageMakerUnifiedStudioRootNode' +import { DataZoneClient } from '../shared/client/datazoneClient' +import { openRemoteConnect, stopSpace } from '../../awsService/sagemaker/commands' +import { SagemakerUnifiedStudioSpaceNode } from './nodes/sageMakerUnifiedStudioSpaceNode' +import { SageMakerUnifiedStudioProjectNode } from './nodes/sageMakerUnifiedStudioProjectNode' +import { getLogger } from '../../shared/logger/logger' +import { setSmusConnectedContext, SmusAuthenticationProvider } from '../auth/providers/smusAuthenticationProvider' +import { setupUserActivityMonitoring } from '../../awsService/sagemaker/sagemakerSpace' +import { telemetry } from '../../shared/telemetry/telemetry' +import { SageMakerUnifiedStudioSpacesParentNode } from './nodes/sageMakerUnifiedStudioSpacesParentNode' +import { isSageMaker } from '../../shared/extensionUtilities' + +export async function activate(extensionContext: vscode.ExtensionContext): Promise { + // Initialize the SMUS authentication provider + const logger = getLogger() + logger.debug('SMUS: Initializing authentication provider') + // Create the auth provider instance (this will trigger restore() in the constructor) + const smusAuthProvider = SmusAuthenticationProvider.fromContext() + await smusAuthProvider.restore() + // Set initial auth context after restore + void setSmusConnectedContext(smusAuthProvider.isConnected()) + logger.debug('SMUS: Authentication provider initialized') + + // Create the SMUS projects tree view + const smusRootNode = new SageMakerUnifiedStudioRootNode(smusAuthProvider, extensionContext) + const treeDataProvider = new ResourceTreeDataProvider({ getChildren: () => smusRootNode.getChildren() }) + + // Register the tree view + const treeView = vscode.window.createTreeView('aws.smus.rootView', { treeDataProvider }) + treeDataProvider.refresh() + + // Register the commands + extensionContext.subscriptions.push( + smusLoginCommand.register(), + smusLearnMoreCommand.register(), + smusSignOutCommand.register(), + treeView, + vscode.commands.registerCommand('aws.smus.rootView.refresh', () => { + treeDataProvider.refresh() + }), + + vscode.commands.registerCommand( + 'aws.smus.projectView', + async (projectNode?: SageMakerUnifiedStudioProjectNode) => { + return await selectSMUSProject(projectNode) + } + ), + + vscode.commands.registerCommand('aws.smus.refreshProject', async () => { + const projectNode = smusRootNode.getProjectSelectNode() + await projectNode.refreshNode() + }), + + vscode.commands.registerCommand('aws.smus.switchProject', async () => { + // Get the project node from the root node to ensure we're using the same instance + const projectNode = smusRootNode.getProjectSelectNode() + return await selectSMUSProject(projectNode) + }), + + vscode.commands.registerCommand('aws.smus.stopSpace', async (node: SagemakerUnifiedStudioSpaceNode) => { + if (!validateNode(node)) { + return + } + await telemetry.smus_stopSpace.run(async (span) => { + span.record({ + smusSpaceKey: node.resource.DomainSpaceKey, + smusDomainRegion: node.resource.regionCode, + smusDomainId: ( + node.resource.getParent() as SageMakerUnifiedStudioSpacesParentNode + )?.getAuthProvider()?.activeConnection?.domainId, + smusProjectId: ( + node.resource.getParent() as SageMakerUnifiedStudioSpacesParentNode + )?.getProjectId(), + }) + await stopSpace(node.resource, extensionContext, node.resource.sageMakerClient) + }) + }), + + vscode.commands.registerCommand( + 'aws.smus.openRemoteConnection', + async (node: SagemakerUnifiedStudioSpaceNode) => { + if (!validateNode(node)) { + return + } + await telemetry.smus_startSpace.run(async (span) => { + span.record({ + smusSpaceKey: node.resource.DomainSpaceKey, + smusDomainRegion: node.resource.regionCode, + smusDomainId: ( + node.resource.getParent() as SageMakerUnifiedStudioSpacesParentNode + )?.getAuthProvider()?.activeConnection?.domainId, + smusProjectId: ( + node.resource.getParent() as SageMakerUnifiedStudioSpacesParentNode + )?.getProjectId(), + }) + await openRemoteConnect(node.resource, extensionContext, node.resource.sageMakerClient) + }) + } + ), + + vscode.commands.registerCommand('aws.smus.reauthenticate', async (connection?: any) => { + if (connection) { + try { + await smusAuthProvider.reauthenticate(connection) + // Refresh the tree view after successful reauthentication + treeDataProvider.refresh() + // Show success message + void vscode.window.showInformationMessage( + 'Successfully reauthenticated with SageMaker Unified Studio' + ) + } catch (error) { + // Show error message if reauthentication fails + void vscode.window.showErrorMessage(`Failed to reauthenticate: ${error}`) + logger.error('SMUS: Reauthentication failed: %O', error) + } + } + }), + // Dispose DataZoneClient when extension is deactivated + { dispose: () => DataZoneClient.dispose() }, + // Dispose SMUS auth provider when extension is deactivated + { dispose: () => smusAuthProvider.dispose() } + ) + + // Track user activity for autoshutdown feature when in SageMaker Unified Studio environment + if (isSageMaker('SMUS-SPACE-REMOTE-ACCESS')) { + logger.info('SageMaker Unified Studio environment detected, setting up user activity monitoring') + try { + await setupUserActivityMonitoring(extensionContext) + } catch (error) { + logger.error(`Error in UserActivityMonitoring: ${error}`) + throw error + } + } else { + logger.info('Not in SageMaker Unified Studio remote environment, skipping user activity monitoring') + } +} + +/** + * Checks if a node is undefined and shows a warning message if so. + */ +function validateNode(node: SagemakerUnifiedStudioSpaceNode): boolean { + if (!node) { + void vscode.window.showWarningMessage('Space information is being refreshed. Please try again shortly.') + return false + } + return true +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/lakehouseStrategy.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/lakehouseStrategy.ts new file mode 100644 index 00000000000..43c51997d3b --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/lakehouseStrategy.ts @@ -0,0 +1,596 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { getLogger } from '../../../shared/logger/logger' +import { DataZoneConnection } from '../../shared/client/datazoneClient' +import { GlueCatalog, GlueCatalogClient } from '../../shared/client/glueCatalogClient' +import { GlueClient } from '../../shared/client/glueClient' +import { ConnectionClientStore } from '../../shared/client/connectionClientStore' +import { + NODE_ID_DELIMITER, + NodeType, + NodeData, + DATA_DEFAULT_LAKEHOUSE_CONNECTION_NAME_REGEXP, + DATA_DEFAULT_ATHENA_CONNECTION_NAME_REGEXP, + DATA_DEFAULT_IAM_CONNECTION_NAME_REGEXP, + AWS_DATA_CATALOG, + DatabaseObjects, + NO_DATA_FOUND_MESSAGE, +} from './types' +import { + getLabel, + isLeafNode, + getIconForNodeType, + getTooltip, + createColumnTreeItem, + getColumnType, + createErrorItem, +} from './utils' +import { createPlaceholderItem } from '../../../shared/treeview/utils' +import { Column, Database, Table } from '@aws-sdk/client-glue' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' +import { telemetry } from '../../../shared/telemetry/telemetry' +import { getContext } from '../../../shared/vscode/setContext' + +/** + * Lakehouse data node for SageMaker Unified Studio + */ +export class LakehouseNode implements TreeNode { + private childrenNodes: TreeNode[] | undefined + private isLoading = false + private readonly logger = getLogger() + + constructor( + public readonly data: NodeData, + private readonly childrenProvider?: (node: LakehouseNode) => Promise + ) {} + + public get id(): string { + return this.data.id + } + + public get resource(): any { + return this.data.value || {} + } + + public async getChildren(): Promise { + // Return cached children if available + if (this.childrenNodes && !this.isLoading) { + return this.childrenNodes + } + + // Return empty array for leaf nodes + if (isLeafNode(this.data)) { + return [] + } + + // If we have a children provider, use it + if (this.childrenProvider) { + try { + this.isLoading = true + const childrenNodes = await this.childrenProvider(this) + this.childrenNodes = childrenNodes + this.isLoading = false + return this.childrenNodes + } catch (err) { + this.isLoading = false + this.logger.error(`Failed to get children for node ${this.data.id}: ${(err as Error).message}`) + + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'getChildren', this.id) as LakehouseNode] + } + } + + return [] + } + + public async getTreeItem(): Promise { + const label = getLabel(this.data) + const isLeaf = isLeafNode(this.data) + + // For column nodes, show type as secondary text + if (this.data.nodeType === NodeType.REDSHIFT_COLUMN && this.data.value?.type) { + return createColumnTreeItem(label, this.data.value.type, this.data.nodeType) + } + + const collapsibleState = isLeaf + ? vscode.TreeItemCollapsibleState.None + : vscode.TreeItemCollapsibleState.Collapsed + + const item = new vscode.TreeItem(label, collapsibleState) + + // Set icon based on node type + item.iconPath = getIconForNodeType(this.data.nodeType, this.data.isContainer) + + // Set context value for command enablement + item.contextValue = this.data.nodeType + + // Set tooltip + item.tooltip = getTooltip(this.data) + + return item + } + + public getParent(): TreeNode | undefined { + return this.data.parent + } +} + +/** + * Creates a Lakehouse connection node + */ +export function createLakehouseConnectionNode( + connection: DataZoneConnection, + connectionCredentialsProvider: ConnectionCredentialsProvider, + region: string +): LakehouseNode { + const logger = getLogger() + + // Create Glue clients + const clientStore = ConnectionClientStore.getInstance() + const glueCatalogClient = clientStore.getGlueCatalogClient( + connection.connectionId, + region, + connectionCredentialsProvider + ) + const glueClient = clientStore.getGlueClient(connection.connectionId, region, connectionCredentialsProvider) + + // Create the connection node + return new LakehouseNode( + { + id: connection.connectionId, + nodeType: NodeType.CONNECTION, + value: { connection }, + path: { + connection: connection.name, + }, + }, + async (node) => { + return telemetry.smus_renderLakehouseNode.run(async (span) => { + const isInSmusSpace = getContext('aws.smus.inSmusSpaceEnvironment') + + span.record({ + smusToolkitEnv: isInSmusSpace ? 'smus_space' : 'local', + smusDomainId: connection.domainId, + smusProjectId: connection.projectId, + smusConnectionId: connection.connectionId, + smusConnectionType: connection.type, + smusProjectRegion: connection.location?.awsRegion, + }) + try { + logger.info(`Loading Lakehouse catalogs for connection ${connection.name}`) + + // Check if this is a default connection + const isDefaultConnection = + DATA_DEFAULT_IAM_CONNECTION_NAME_REGEXP.test(connection.name) || + DATA_DEFAULT_LAKEHOUSE_CONNECTION_NAME_REGEXP.test(connection.name) || + DATA_DEFAULT_ATHENA_CONNECTION_NAME_REGEXP.test(connection.name) + + // Follow the reference pattern with Promise.allSettled + const [awsDataCatalogResult, catalogsResult] = await Promise.allSettled([ + // AWS Data Catalog node (only for default connections) + isDefaultConnection + ? Promise.resolve([createAwsDataCatalogNode(node, glueClient)]) + : Promise.resolve([]), + // Get catalogs by calling Glue API + getCatalogs(glueCatalogClient, glueClient, node), + ]) + + const awsDataCatalog = awsDataCatalogResult.status === 'fulfilled' ? awsDataCatalogResult.value : [] + const apiCatalogs = catalogsResult.status === 'fulfilled' ? catalogsResult.value : [] + const errors: LakehouseNode[] = [] + + if (awsDataCatalogResult.status === 'rejected') { + const errorMessage = (awsDataCatalogResult.reason as Error).message + void vscode.window.showErrorMessage(errorMessage) + errors.push(createErrorItem(errorMessage, 'aws-data-catalog', node.id) as LakehouseNode) + } + + if (catalogsResult.status === 'rejected') { + const errorMessage = (catalogsResult.reason as Error).message + void vscode.window.showErrorMessage(errorMessage) + errors.push(createErrorItem(errorMessage, 'catalogs', node.id) as LakehouseNode) + } + + const allNodes = [...awsDataCatalog, ...apiCatalogs, ...errors] + return allNodes.length > 0 + ? allNodes + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as LakehouseNode] + } catch (err) { + logger.error(`Failed to get Lakehouse catalogs: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'lakehouse-catalogs', node.id) as LakehouseNode] + } + }) + } + ) +} + +/** + * Creates AWS Data Catalog node for default connections + */ +function createAwsDataCatalogNode(parent: LakehouseNode, glueClient: GlueClient): LakehouseNode { + return new LakehouseNode( + { + id: `${parent.id}${NODE_ID_DELIMITER}${AWS_DATA_CATALOG}`, + nodeType: NodeType.GLUE_CATALOG, + value: { + catalog: { name: AWS_DATA_CATALOG, type: 'AWS' }, + catalogName: AWS_DATA_CATALOG, + }, + path: { + ...parent.data.path, + catalog: AWS_DATA_CATALOG, + }, + parent, + }, + async (node) => { + const allDatabases = [] + let nextToken: string | undefined + + do { + const { databases, nextToken: token } = await glueClient.getDatabases( + undefined, + 'ALL', + ['NAME'], + nextToken + ) + allDatabases.push(...databases) + nextToken = token + } while (nextToken) + + return allDatabases.length > 0 + ? allDatabases.map((database) => createDatabaseNode(database.Name || '', database, glueClient, node)) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as LakehouseNode] + } + ) +} + +export interface CatalogTree { + parent: GlueCatalog + children?: GlueCatalog[] +} + +/** + * Builds catalog tree from flat catalog list + * + * AWS Glue catalogs can have parent-child relationships, but the API returns them as a flat list. + * This function reconstructs the hierarchical tree structure needed for proper UI display. + * + * Two-pass algorithm is required because: + * 1. First pass: Create a lookup map of all catalogs by name for O(1) access during relationship building + * 2. Second pass: Build parent-child relationships by linking catalogs that reference ParentCatalogNames + * + * Without the first pass, we'd need O(n²) time to find parent catalogs for each child catalog. + */ +function buildCatalogTree(catalogs: GlueCatalog[]): CatalogTree[] { + const catalogMap: Record = {} + const rootCatalogs: CatalogTree[] = [] + + // First pass: create a map of all catalogs with their metadata + // This allows us to quickly look up any catalog by name when building parent-child relationships in the second pass + for (const catalog of catalogs) { + if (catalog.Name) { + catalogMap[catalog.Name] = { parent: catalog, children: [] } + } + } + + // Second pass: build the hierarchical tree structure by linking children to their parents + // Catalogs with ParentCatalogNames become children, others become root-level catalogs + for (const catalog of catalogs) { + if (catalog.Name) { + if (catalog.ParentCatalogNames && catalog.ParentCatalogNames.length > 0) { + const parentName = catalog.ParentCatalogNames[0] + const parent = catalogMap[parentName] + if (parent) { + if (!parent.children) { + parent.children = [] + } + parent.children.push(catalog) + } + } else { + rootCatalogs.push(catalogMap[catalog.Name]) + } + } + } + rootCatalogs.sort((a, b) => { + const timeA = new Date(a.parent.CreateTime ?? 0).getTime() + const timeB = new Date(b.parent.CreateTime ?? 0).getTime() + return timeA - timeB // For oldest first + }) + + return rootCatalogs +} + +/** + * Gets catalogs from the GlueCatalogClient + */ +async function getCatalogs( + glueCatalogClient: GlueCatalogClient, + glueClient: GlueClient, + parent: LakehouseNode +): Promise { + const allCatalogs = [] + let nextToken: string | undefined + + do { + const { catalogs, nextToken: token } = await glueCatalogClient.getCatalogs(nextToken) + allCatalogs.push(...catalogs) + nextToken = token + } while (nextToken) + + const catalogs = allCatalogs + const tree = buildCatalogTree(catalogs) + + return tree.map((catalog) => { + const parentCatalog = catalog.parent + + // If parent catalog has children, create node that shows child catalogs + if (catalog.children && catalog.children.length > 0) { + return new LakehouseNode( + { + id: parentCatalog.Name || parentCatalog.CatalogId || '', + nodeType: NodeType.GLUE_CATALOG, + value: { + catalog: parentCatalog, + catalogName: parentCatalog.Name || '', + }, + path: { + ...parent.data.path, + catalog: parentCatalog.CatalogId || '', + }, + parent, + }, + async (node: LakehouseNode) => { + // Parent catalogs only show child catalogs + const childCatalogs = + catalog.children?.map((childCatalog) => + createCatalogNode(childCatalog.CatalogId || '', childCatalog, glueClient, node, false) + ) || [] + return childCatalogs + } + ) + } + + // For catalogs without children, create regular catalog node + return createCatalogNode(parentCatalog.CatalogId || '', parentCatalog, glueClient, parent, false) + }) +} + +/** + * Creates a catalog node + */ +function createCatalogNode( + catalogId: string, + catalog: GlueCatalog, + glueClient: GlueClient, + parent: LakehouseNode, + isParent: boolean = false +): LakehouseNode { + const logger = getLogger() + + return new LakehouseNode( + { + id: catalog.Name || catalogId, + nodeType: NodeType.GLUE_CATALOG, + value: { + catalog, + catalogName: catalog.Name || catalogId, + }, + path: { + ...parent.data.path, + catalog: catalogId, + }, + parent, + }, + // Child catalogs load databases, parent catalogs will have their children provider overridden + isParent + ? async () => [] // Placeholder, will be overridden for parent catalogs with children + : async (node) => { + try { + logger.info(`Loading databases for catalog ${catalogId}`) + + const allDatabases = [] + let nextToken: string | undefined + + do { + const { databases, nextToken: token } = await glueClient.getDatabases( + catalogId, + undefined, + ['NAME'], + nextToken + ) + allDatabases.push(...databases) + nextToken = token + } while (nextToken) + + return allDatabases.length > 0 + ? allDatabases.map((database) => + createDatabaseNode(database.Name || '', database, glueClient, node) + ) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as LakehouseNode] + } catch (err) { + logger.error(`Failed to get databases for catalog ${catalogId}: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'catalog-databases', node.id) as LakehouseNode] + } + } + ) +} + +/** + * Creates a database node + */ +function createDatabaseNode( + databaseName: string, + database: Database, + glueClient: GlueClient, + parent: LakehouseNode +): LakehouseNode { + const logger = getLogger() + + return new LakehouseNode( + { + id: databaseName, + nodeType: NodeType.GLUE_DATABASE, + value: { + database, + databaseName, + }, + path: { + ...parent.data.path, + database: databaseName, + }, + parent, + }, + async (node) => { + try { + logger.info(`Loading tables for database ${databaseName}`) + + const allTables = [] + let nextToken: string | undefined + const catalogId = parent.data.path?.catalog === AWS_DATA_CATALOG ? undefined : parent.data.path?.catalog + + do { + const { tables, nextToken: token } = await glueClient.getTables( + databaseName, + catalogId, + ['NAME', 'TABLE_TYPE'], + nextToken + ) + allTables.push(...tables) + nextToken = token + } while (nextToken) + + // Group tables and views separately + const tables = allTables.filter((table) => table.TableType !== DatabaseObjects.VIRTUAL_VIEW) + const views = allTables.filter((table) => table.TableType === DatabaseObjects.VIRTUAL_VIEW) + + const containerNodes: LakehouseNode[] = [] + + // Create tables container if there are tables + if (tables.length > 0) { + containerNodes.push(createContainerNode(NodeType.GLUE_TABLE, tables, glueClient, node)) + } + + // Create views container if there are views + if (views.length > 0) { + containerNodes.push(createContainerNode(NodeType.GLUE_VIEW, views, glueClient, node)) + } + + return containerNodes.length > 0 + ? containerNodes + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as LakehouseNode] + } catch (err) { + logger.error(`Failed to get tables for database ${databaseName}: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'database-tables', node.id) as LakehouseNode] + } + } + ) +} + +/** + * Creates a table node + */ +function createTableNode( + tableName: string, + table: Table, + glueClient: GlueClient, + parent: LakehouseNode +): LakehouseNode { + const logger = getLogger() + + return new LakehouseNode( + { + id: tableName, + nodeType: NodeType.GLUE_TABLE, + value: { + table, + tableName, + }, + path: { + ...parent.data.path, + table: tableName, + }, + parent, + }, + async (node) => { + try { + logger.info(`Loading columns for table ${tableName}`) + + const databaseName = node.data.path?.database || '' + const catalogId = node.data.path?.catalog === AWS_DATA_CATALOG ? undefined : node.data.path?.catalog + const tableDetails = await glueClient.getTable(databaseName, tableName, catalogId) + const columns = tableDetails?.StorageDescriptor?.Columns || [] + const partitions = tableDetails?.PartitionKeys || [] + + const allColumns = [...columns, ...partitions] + return allColumns.length > 0 + ? allColumns.map((column) => createColumnNode(column.Name || '', column, node)) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as LakehouseNode] + } catch (err) { + logger.error(`Failed to get columns for table ${tableName}: ${(err as Error).message}`) + return [] + } + } + ) +} + +/** + * Creates a column node + */ +function createColumnNode(columnName: string, column: Column, parent: LakehouseNode): LakehouseNode { + const columnType = getColumnType(column?.Type) + + return new LakehouseNode({ + id: `${parent.id}${NODE_ID_DELIMITER}${columnName}`, + nodeType: NodeType.REDSHIFT_COLUMN, + value: { + name: columnName, + type: columnType, + }, + path: { + ...parent.data.path, + column: columnName, + }, + parent, + }) +} + +/** + * Creates a container node for grouping objects by type + */ +function createContainerNode( + nodeType: NodeType, + items: Table[], + glueClient: GlueClient, + parent: LakehouseNode +): LakehouseNode { + return new LakehouseNode( + { + id: `${parent.id}${NODE_ID_DELIMITER}${nodeType}-container`, + nodeType: nodeType, + value: { + items, + }, + path: parent.data.path, + parent, + isContainer: true, + }, + async (node) => { + // Map items to nodes + return items.length > 0 + ? items.map((item) => createTableNode(item.Name || '', item, glueClient, node)) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as LakehouseNode] + } + ) +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/redshiftStrategy.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/redshiftStrategy.ts new file mode 100644 index 00000000000..8857aec1449 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/redshiftStrategy.ts @@ -0,0 +1,1047 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { getLogger } from '../../../shared/logger/logger' +import { DataZoneConnection } from '../../shared/client/datazoneClient' +import { ConnectionConfig, createRedshiftConnectionConfig } from '../../shared/client/sqlWorkbenchClient' +import { ConnectionClientStore } from '../../shared/client/connectionClientStore' +import { NODE_ID_DELIMITER, NodeType, ResourceType, NodeData, NO_DATA_FOUND_MESSAGE } from './types' +import { + getLabel, + isLeafNode, + getIconForNodeType, + createColumnTreeItem, + isRedLakeDatabase, + getTooltip, + getColumnType, + createErrorItem, +} from './utils' +import { createPlaceholderItem } from '../../../shared/treeview/utils' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' +import { GlueCatalog } from '../../shared/client/glueCatalogClient' +import { telemetry } from '../../../shared/telemetry/telemetry' +import { getContext } from '../../../shared/vscode/setContext' + +/** + * Redshift data node for SageMaker Unified Studio + */ +export class RedshiftNode implements TreeNode { + private childrenNodes: TreeNode[] | undefined + private isLoading = false + private readonly logger = getLogger() + + constructor( + public readonly data: NodeData, + private readonly childrenProvider?: (node: RedshiftNode) => Promise + ) {} + + public get id(): string { + return this.data.id + } + + public get resource(): any { + return this.data.value || {} + } + + public async getChildren(): Promise { + // Return cached children if available + if (this.childrenNodes && !this.isLoading) { + return this.childrenNodes + } + + // Return empty array for leaf nodes + if (isLeafNode(this.data)) { + return [] + } + + // If we have a children provider, use it + if (this.childrenProvider) { + try { + this.isLoading = true + const childrenNodes = await this.childrenProvider(this) + this.childrenNodes = childrenNodes + this.isLoading = false + return this.childrenNodes + } catch (err) { + this.isLoading = false + this.logger.error(`Failed to get children for node ${this.data.id}: ${(err as Error).message}`) + + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'getChildren', this.id) as RedshiftNode] + } + } + + return [] + } + + public async getTreeItem(): Promise { + const label = getLabel(this.data) + const isLeaf = isLeafNode(this.data) + + // For column nodes, create a TreeItem with label and description (column type) + if (this.data.nodeType === NodeType.REDSHIFT_COLUMN && this.data.value?.type) { + return createColumnTreeItem(label, this.data.value.type, this.data.nodeType) + } + + // For other nodes, use standard TreeItem + const collapsibleState = isLeaf + ? vscode.TreeItemCollapsibleState.None + : vscode.TreeItemCollapsibleState.Collapsed + + const item = new vscode.TreeItem(label, collapsibleState) + + // Set icon based on node type + item.iconPath = getIconForNodeType(this.data.nodeType, this.data.isContainer) + + // Set context value for command enablement + item.contextValue = this.data.nodeType + + // Set tooltip + item.tooltip = getTooltip(this.data) + + return item + } + + public getParent(): TreeNode | undefined { + return this.data.parent + } +} + +/** + * Creates a Redshift connection node + */ +export function createRedshiftConnectionNode( + connection: DataZoneConnection, + connectionCredentialsProvider: ConnectionCredentialsProvider +): RedshiftNode { + return new RedshiftNode( + { + id: connection.connectionId, + nodeType: NodeType.CONNECTION, + value: { connection, connectionCredentialsProvider }, + path: { + connection: connection.name, + }, + }, + async (node) => { + return telemetry.smus_renderRedshiftNode.run(async (span) => { + const isInSmusSpace = getContext('aws.smus.inSmusSpaceEnvironment') + + span.record({ + smusToolkitEnv: isInSmusSpace ? 'smus_space' : 'local', + smusDomainId: connection.domainId, + smusProjectId: connection.projectId, + smusConnectionId: connection.connectionId, + smusConnectionType: connection.type, + smusProjectRegion: connection.location?.awsRegion, + }) + const logger = getLogger() + logger.info(`Loading Redshift resources for connection ${connection.name}`) + + const connectionParams = extractConnectionParams(connection) + if (!connectionParams) { + return [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } + + const isGlueCatalogDatabase = isRedLakeDatabase(connectionParams.database) + + // Create connection config with all available information + const connectionConfig = await createRedshiftConnectionConfig( + connectionParams.host, + connectionParams.database, + connectionParams.accountId, + connectionParams.region, + connectionParams.secretArn, + isGlueCatalogDatabase + ) + + // Wake up the database with a simple query + await wakeUpDatabase( + connectionConfig, + connectionParams.region, + connectionCredentialsProvider, + connection + ) + + const clientStore = ConnectionClientStore.getInstance() + const sqlClient = clientStore.getSQLWorkbenchClient( + connection.connectionId, + connectionParams.region, + connectionCredentialsProvider + ) + + // Fetch Glue catalogs for filtering purposes only + // This will help determine which catalogs are accessible within the project + let glueCatalogs: GlueCatalog[] = [] + try { + glueCatalogs = await listGlueCatalogs( + connection.connectionId, + connectionParams.region, + connectionCredentialsProvider + ) + } catch (err) { + logger.warn(`Failed to fetch Glue catalogs for filtering: ${(err as Error).message}`) + } + + // Fetch databases and catalogs using getResources + const [databasesResult, catalogsResult] = await Promise.allSettled([ + fetchResources(sqlClient, connectionConfig, ResourceType.DATABASE), + fetchResources(sqlClient, connectionConfig, ResourceType.CATALOG), + ]) + + const databases = databasesResult.status === 'fulfilled' ? databasesResult.value : [] + const catalogs = catalogsResult.status === 'fulfilled' ? catalogsResult.value : [] + const allNodes: RedshiftNode[] = [] + + // Filter databases + const filteredDatabases = databases.filter( + (r: any) => + r.type === ResourceType.DATABASE || + r.type === ResourceType.EXTERNAL_DATABASE || + r.type === ResourceType.SHARED_DATABASE + ) + + // Filter catalogs using listGlueCatalogs results + const filteredCatalogs = catalogs.filter((catalog: any) => { + if (catalog.displayName?.toLowerCase() === 'awsdatacatalog') { + return true // Always include AWS Data Catalog + } + // Filter using Glue catalogs list + return glueCatalogs.some((glueCatalog) => catalog.displayName?.endsWith(glueCatalog.Name ?? '')) + }) + + // Add database nodes + if (filteredDatabases.length === 0) { + if (databasesResult.status === 'rejected') { + const errorMessage = `Failed to fetch databases - ${databasesResult.reason?.message || databasesResult.reason}.` + void vscode.window.showErrorMessage(errorMessage) + allNodes.push(createErrorItem(errorMessage, 'databases', node.id) as RedshiftNode) + } else { + allNodes.push(createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode) + } + } else { + allNodes.push( + ...filteredDatabases.map((db: any) => + createDatabaseNode(db.displayName, connectionConfig, node) + ) + ) + } + + // Add catalog nodes + if (filteredCatalogs.length === 0) { + if (catalogsResult.status === 'rejected') { + const errorMessage = `Failed to fetch catalogs - ${catalogsResult.reason?.message || catalogsResult.reason}` + void vscode.window.showErrorMessage(errorMessage) + allNodes.push(createErrorItem(errorMessage, 'catalogs', node.id) as RedshiftNode) + } else { + allNodes.push(createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode) + } + } else { + allNodes.push( + ...filteredCatalogs.map((catalog: any) => + createCatalogNode( + catalog.displayName || catalog.identifier || '', + catalog, + connectionConfig, + node + ) + ) + ) + } + + return allNodes + }) + } + ) +} + +/** + * Extracts connection parameters from DataZone connection + */ +function extractConnectionParams(connection: DataZoneConnection) { + const redshiftProps = connection.props?.redshiftProperties || {} + const jdbcConnection = connection.props?.jdbcConnection || {} + + let host = jdbcConnection.host + if (!host && jdbcConnection.jdbcUrl) { + // Example: jdbc:redshift://test-cluster.123456789012.us-east-1.redshift.amazonaws.com:5439/dev + // match[0] = entire URL, match[1] = host, match[2] = port, match[3] = database + const match = jdbcConnection.jdbcUrl.match(/jdbc:redshift:\/\/([^:]+):(\d+)\/(.+)/) + if (match) { + host = match[1] + } + } + + const database = jdbcConnection.dbname || redshiftProps.databaseName + const secretArn = jdbcConnection.secretId || redshiftProps.credentials?.secretArn + const accountId = connection.location?.awsAccountId + const region = connection.location?.awsRegion + + if (!host || !database || !accountId || !region) { + return undefined + } + + return { host, database, secretArn, accountId, region } +} + +/** + * Wake up the database with a simple query + */ +async function wakeUpDatabase( + connectionConfig: ConnectionConfig, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider, + connection: DataZoneConnection +) { + const logger = getLogger() + const clientStore = ConnectionClientStore.getInstance() + const sqlClient = clientStore.getSQLWorkbenchClient(connection.connectionId, region, connectionCredentialsProvider) + try { + await sqlClient.executeQuery(connectionConfig, 'select 1 from sys_query_history limit 1;') + } catch (e) { + logger.debug(`Wake-up query failed: ${(e as Error).message}`) + } +} + +/** + * Creates a database node + */ +function createDatabaseNode( + databaseName: string, + connectionConfig: ConnectionConfig, + parent: RedshiftNode +): RedshiftNode { + const logger = getLogger() + + return new RedshiftNode( + { + id: databaseName, + nodeType: NodeType.REDSHIFT_DATABASE, + value: { + database: databaseName, + connectionConfig, + identifier: databaseName, + type: ResourceType.DATABASE, + childObjectTypes: [ResourceType.SCHEMA, ResourceType.EXTERNAL_SCHEMA, ResourceType.SHARED_SCHEMA], + }, + path: { + ...parent.data.path, + database: databaseName, + }, + parent, + }, + async (node) => { + try { + // Get the original credentials from the root connection node + const rootCredentials = getRootCredentials(parent) + + // Create SQL client with the original credentials + const clientStore = ConnectionClientStore.getInstance() + const sqlClient = clientStore.getSQLWorkbenchClient( + connectionConfig.id, + connectionConfig.id.split(':')[3], // region + rootCredentials + ) + + // Update connection config with the database + const dbConnectionConfig = { + ...connectionConfig, + database: databaseName, + } + + // Get schemas + const allResources = [] + let nextToken: string | undefined + + do { + const response = await sqlClient.getResources({ + connection: dbConnectionConfig, + resourceType: ResourceType.SCHEMA, + includeChildren: true, + maxItems: 100, + parents: [ + { + parentId: databaseName, + parentType: ResourceType.DATABASE, + }, + ], + forceRefresh: true, + pageToken: nextToken, + }) + allResources.push(...(response.resources || [])) + nextToken = response.nextToken + } while (nextToken) + + const schemas = allResources.filter( + (r: any) => + r.type === ResourceType.SCHEMA || + r.type === ResourceType.EXTERNAL_SCHEMA || + r.type === ResourceType.SHARED_SCHEMA + ) + + if (schemas.length === 0) { + return [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } + + // Map schemas to nodes + return schemas.map((schema: any) => createSchemaNode(schema.displayName, dbConnectionConfig, node)) + } catch (err) { + logger.error(`Failed to get schemas: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'schemas', node.id) as RedshiftNode] + } + } + ) +} + +/** + * Creates a schema node + */ +function createSchemaNode(schemaName: string, connectionConfig: ConnectionConfig, parent: RedshiftNode): RedshiftNode { + const logger = getLogger() + + return new RedshiftNode( + { + id: schemaName, + nodeType: NodeType.REDSHIFT_SCHEMA, + value: { + schema: schemaName, + connectionConfig, + identifier: schemaName, + type: ResourceType.SCHEMA, + childObjectTypes: [ + ResourceType.TABLE, + ResourceType.VIEW, + ResourceType.FUNCTION, + ResourceType.STORED_PROCEDURE, + ResourceType.EXTERNAL_TABLE, + ResourceType.CATALOG_TABLE, + ResourceType.DATA_CATALOG_TABLE, + ], + }, + path: { + ...parent.data.path, + schema: schemaName, + }, + parent, + }, + async (node) => { + try { + // Get the original credentials from the root connection node + const rootCredentials = getRootCredentials(parent) + + // Create SQL client with the original credentials + const clientStore = ConnectionClientStore.getInstance() + const rootConnection = getRootConnection(parent) + const sqlClient = clientStore.getSQLWorkbenchClient( + rootConnection.connectionId, + connectionConfig.id.split(':')[3], // region + rootCredentials + ) + + // Get schema objects + // Make sure we're using the correct database in the connection config + const schemaConnectionConfig = { + ...connectionConfig, + database: parent.data.path?.database || connectionConfig.database, + } + + // Create request params object for logging + const requestParams = { + connection: schemaConnectionConfig, + resourceType: ResourceType.TABLE, + includeChildren: true, + maxItems: 100, + parents: [ + { + parentId: schemaName, + parentType: ResourceType.SCHEMA, + }, + { + parentId: schemaConnectionConfig.database, + parentType: ResourceType.DATABASE, + }, + ], + forceRefresh: true, + } + + const allResources = [] + let nextToken: string | undefined + + do { + const response = await sqlClient.getResources({ + ...requestParams, + pageToken: nextToken, + }) + allResources.push(...(response.resources || [])) + nextToken = response.nextToken + } while (nextToken) + + // Group resources by type + const tables = allResources.filter( + (r: any) => + r.type === ResourceType.TABLE || + r.type === ResourceType.EXTERNAL_TABLE || + r.type === ResourceType.CATALOG_TABLE || + r.type === ResourceType.DATA_CATALOG_TABLE + ) + const views = allResources.filter((r: any) => r.type === ResourceType.VIEW) + const functions = allResources.filter((r: any) => r.type === ResourceType.FUNCTION) + const procedures = allResources.filter((r: any) => r.type === ResourceType.STORED_PROCEDURE) + + // Create container nodes for each type + const containerNodes: RedshiftNode[] = [] + + // Tables container + if (tables.length > 0) { + containerNodes.push(createContainerNode(NodeType.REDSHIFT_TABLE, tables, connectionConfig, node)) + } + + // Views container + if (views.length > 0) { + containerNodes.push(createContainerNode(NodeType.REDSHIFT_VIEW, views, connectionConfig, node)) + } + + // Functions container + if (functions.length > 0) { + containerNodes.push( + createContainerNode(NodeType.REDSHIFT_FUNCTION, functions, connectionConfig, node) + ) + } + + // Stored procedures container + if (procedures.length > 0) { + containerNodes.push( + createContainerNode(NodeType.REDSHIFT_STORED_PROCEDURE, procedures, connectionConfig, node) + ) + } + + if (containerNodes.length === 0) { + return [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } + + return containerNodes + } catch (err) { + logger.error(`Failed to get schema contents: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'schema-contents', node.id) as RedshiftNode] + } + } + ) +} + +/** + * Creates a container node for grouping objects by type + */ +function createContainerNode( + nodeType: NodeType, + resources: any[], + connectionConfig: ConnectionConfig, + parent: RedshiftNode +): RedshiftNode { + return new RedshiftNode( + { + id: `${parent.id}${NODE_ID_DELIMITER}${nodeType}-container`, + nodeType: nodeType, + value: { + connectionConfig, + resources, + }, + path: parent.data.path, + parent, + isContainer: true, + }, + async (node) => { + // Map resources to nodes + if (nodeType === NodeType.REDSHIFT_TABLE && parent.data.value?.type === ResourceType.CATALOG_DATABASE) { + // For catalog tables, use catalog table node + return resources.length > 0 + ? resources.map((resource: any) => + createCatalogTableNode(resource.displayName, resource, connectionConfig, node) + ) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } + return resources.length > 0 + ? resources.map((resource: any) => + createObjectNode(resource.displayName, nodeType, resource, connectionConfig, node) + ) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } + ) +} + +/** + * Creates an object node (table, view, function, etc.) + */ +function createObjectNode( + name: string, + nodeType: NodeType, + resource: any, + connectionConfig: ConnectionConfig, + parent: RedshiftNode +): RedshiftNode { + const logger = getLogger() + + return new RedshiftNode( + { + id: `${parent.id}${NODE_ID_DELIMITER}${name}`, + nodeType: nodeType, + value: { + ...resource, + connectionConfig, + }, + path: { + ...parent.data.path, + [nodeType]: name, + }, + parent, + }, + async (node) => { + // Only tables have columns + if (nodeType !== NodeType.REDSHIFT_TABLE) { + return [] + } + + try { + // Get the original credentials from the root connection node + const rootCredentials = getRootCredentials(parent) + + // Create SQL client with the original credentials + const clientStore = ConnectionClientStore.getInstance() + const rootConnection = getRootConnection(parent) + const sqlClient = clientStore.getSQLWorkbenchClient( + rootConnection.connectionId, + connectionConfig.id.split(':')[3], // region + rootCredentials + ) + + // Get schema and database from path + const schemaName = node.data.path?.schema + const databaseName = node.data.path?.database + const tableName = node.data.path?.table + + if (!schemaName || !databaseName || !tableName) { + logger.error('Missing schema, database, or table name in path') + return [] + } + + // Create request params for getResources to get columns + const requestParams = { + connection: connectionConfig, + resourceType: ResourceType.COLUMNS, + includeChildren: true, + maxItems: 100, + parents: [ + { + parentId: tableName, + parentType: ResourceType.TABLE, + }, + { + parentId: schemaName, + parentType: ResourceType.SCHEMA, + }, + { + parentId: databaseName, + parentType: ResourceType.DATABASE, + }, + ], + forceRefresh: true, + } + + // Call getResources to get columns + const allColumns = [] + let nextToken: string | undefined + + do { + const response = await sqlClient.getResources({ + ...requestParams, + pageToken: nextToken, + }) + allColumns.push(...(response.resources || [])) + nextToken = response.nextToken + } while (nextToken) + + // Create column nodes from API response + return allColumns.length > 0 + ? allColumns.map((column: any) => { + // Extract column type from resourceMetadata + let columnType = 'UNKNOWN' + if (column.resourceMetadata && Array.isArray(column.resourceMetadata)) { + const typeMetadata = column.resourceMetadata.find( + (meta: any) => meta.key === 'COLUMN_TYPE' + ) + if (typeMetadata) { + columnType = typeMetadata.value + } + } + + columnType = getColumnType(columnType) + + return createColumnNode( + column.displayName, + { + name: column.displayName, + type: columnType, + }, + connectionConfig, + node + ) + }) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } catch (err) { + logger.error(`Failed to get columns: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'columns', node.id) as RedshiftNode] + } + } + ) +} + +/** + * Creates a column node + */ +function createColumnNode( + name: string, + columnInfo: { name: string; type: string }, + connectionConfig: ConnectionConfig, + parent: RedshiftNode +): RedshiftNode { + return new RedshiftNode({ + id: `${parent.id}${NODE_ID_DELIMITER}${name}`, + nodeType: NodeType.REDSHIFT_COLUMN, + value: { + name, + type: columnInfo.type, + connectionConfig, + }, + path: { + ...parent.data.path, + column: name, + }, + parent, + }) +} + +/** + * Gets the root connection from a node + */ +function getRootConnection(node: RedshiftNode): DataZoneConnection { + // Start with the current node + let currentNode = node + + // Traverse up to the root connection node + while (currentNode.data.parent) { + currentNode = currentNode.data.parent + } + + // Get connection from the root node + return currentNode.data.value?.connection +} + +/** + * Gets the original credentials from the root connection node + */ +function getRootCredentials(node: RedshiftNode): ConnectionCredentialsProvider { + // Start with the current node + let currentNode = node + + // Traverse up to the root connection node + while (currentNode.data.parent) { + currentNode = currentNode.data.parent + } + + // Get credentials from the root node + const credentials = currentNode.data.value?.connectionCredentialsProvider + + // Return credentials or fallback to dummy credentials + return ( + credentials || { + accessKeyId: 'dummy', + secretAccessKey: 'dummy', + } + ) +} + +/** + * Fetch glue catalogs, this will help determine which catalogs are accessible within the project + */ +async function listGlueCatalogs( + connectionId: string, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider +): Promise { + const clientStore = ConnectionClientStore.getInstance() + const glueCatalogClient = clientStore.getGlueCatalogClient(connectionId, region, connectionCredentialsProvider) + + const allCatalogs = [] + let nextToken: string | undefined + + do { + const { catalogs, nextToken: token } = await glueCatalogClient.getCatalogs(nextToken) + allCatalogs.push(...catalogs) + nextToken = token + } while (nextToken) + + return allCatalogs +} + +/** + * Main logic to fetch catalog and database resources using getResources + */ +async function fetchResources( + sqlClient: any, + connectionConfig: ConnectionConfig, + resourceType: ResourceType, + parents: any[] = [] +): Promise { + const allResources = [] + let nextToken: string | undefined + + do { + const requestParams = { + connection: connectionConfig, + resourceType, + includeChildren: true, + maxItems: 100, + parents, + forceRefresh: true, + pageToken: nextToken, + } + const response = await sqlClient.getResources(requestParams) + allResources.push(...(response.resources || [])) + nextToken = response.nextToken + } while (nextToken) + + return allResources +} + +/** + * Creates a catalog database node + */ +function createCatalogDatabaseNode( + databaseName: string, + database: any, + connectionConfig: ConnectionConfig, + parent: RedshiftNode +): RedshiftNode { + return new RedshiftNode( + { + id: `${parent.id}${NODE_ID_DELIMITER}${databaseName}`, + nodeType: NodeType.REDSHIFT_CATALOG_DATABASE, + value: { + ...database, + connectionConfig, + identifier: databaseName, + type: ResourceType.CATALOG_DATABASE, + }, + path: { + ...parent.data.path, + database: databaseName, + }, + parent, + }, + async (node) => { + try { + const rootCredentials = getRootCredentials(parent) + const clientStore = ConnectionClientStore.getInstance() + const rootConnection = getRootConnection(parent) + const sqlClient = clientStore.getSQLWorkbenchClient( + rootConnection.connectionId, + connectionConfig.id.split(':')[3], + rootCredentials + ) + + // Use getResources to fetch tables within this catalog database + const tables = await fetchResources(sqlClient, connectionConfig, ResourceType.CATALOG_TABLE, [ + { + parentId: database.identifier, + parentType: ResourceType.CATALOG_DATABASE, + }, + { + parentId: parent.data.value?.catalog?.identifier || parent.data.path?.catalog, + parentType: ResourceType.CATALOG, + }, + ]) + + if (tables.length === 0) { + return [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } + + // Create container node for tables + return [createContainerNode(NodeType.REDSHIFT_TABLE, tables, connectionConfig, node)] + } catch (err) { + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'catalog-tables', node.id) as RedshiftNode] + } + } + ) +} + +/** + * Creates a catalog table node + */ +function createCatalogTableNode( + tableName: string, + table: any, + connectionConfig: ConnectionConfig, + parent: RedshiftNode +): RedshiftNode { + return new RedshiftNode( + { + id: `${parent.id}${NODE_ID_DELIMITER}${tableName}`, + nodeType: NodeType.REDSHIFT_TABLE, + value: { + ...table, + connectionConfig, + }, + path: { + ...parent.data.path, + table: tableName, + }, + parent, + }, + async (node) => { + try { + const rootCredentials = getRootCredentials(parent) + const clientStore = ConnectionClientStore.getInstance() + const rootConnection = getRootConnection(parent) + const sqlClient = clientStore.getSQLWorkbenchClient( + rootConnection.connectionId, + connectionConfig.id.split(':')[3], + rootCredentials + ) + + // Use getResources to fetch columns within this catalog table + // Need to traverse up to find the actual database and catalog nodes + let databaseNode = parent + while (databaseNode && databaseNode.data.nodeType !== NodeType.REDSHIFT_CATALOG_DATABASE) { + databaseNode = databaseNode.data.parent + } + + let catalogNode = databaseNode?.data.parent + while (catalogNode && catalogNode.data.nodeType !== NodeType.REDSHIFT_CATALOG) { + catalogNode = catalogNode.data.parent + } + + const parents = [ + { + parentId: table.identifier, + parentType: ResourceType.CATALOG_TABLE, + }, + { + parentId: databaseNode?.data.value?.identifier, + parentType: ResourceType.CATALOG_DATABASE, + }, + { + parentId: catalogNode?.data.value?.catalog?.identifier || catalogNode?.data.value?.identifier, + parentType: ResourceType.CATALOG, + }, + ] + + const columns = await fetchResources(sqlClient, connectionConfig, ResourceType.CATALOG_COLUMN, parents) + + return columns.length > 0 + ? columns.map((column: any) => { + let columnType = 'UNKNOWN' + if (column.resourceMetadata && Array.isArray(column.resourceMetadata)) { + const typeMetadata = column.resourceMetadata.find( + (meta: any) => meta.key === 'COLUMN_TYPE' + ) + if (typeMetadata) { + columnType = typeMetadata.value + } + } + + columnType = getColumnType(columnType) + + return createColumnNode( + column.displayName, + { + name: column.displayName, + type: columnType, + }, + connectionConfig, + node + ) + }) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } catch (err) { + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'catalog-columns', node.id) as RedshiftNode] + } + } + ) +} + +/** + * Creates a catalog node + */ +function createCatalogNode( + catalogName: string, + catalog: any, + connectionConfig: ConnectionConfig, + parent: RedshiftNode +): RedshiftNode { + return new RedshiftNode( + { + id: `${parent.id}${NODE_ID_DELIMITER}${catalogName}`, + nodeType: NodeType.REDSHIFT_CATALOG, + value: { + catalog, + catalogName, + connectionConfig, + identifier: catalogName, + type: ResourceType.CATALOG, + }, + path: { + ...parent.data.path, + catalog: catalogName, + }, + parent, + }, + async (node) => { + try { + const rootCredentials = getRootCredentials(parent) + const clientStore = ConnectionClientStore.getInstance() + const rootConnection = getRootConnection(parent) + const sqlClient = clientStore.getSQLWorkbenchClient( + rootConnection.connectionId, + connectionConfig.id.split(':')[3], + rootCredentials + ) + + // Use getResources to fetch databases within this catalog + const databases = await fetchResources(sqlClient, connectionConfig, ResourceType.CATALOG_DATABASE, [ + { + parentId: catalog.identifier, + parentType: ResourceType.CATALOG, + }, + ]) + + if (databases.length === 0) { + return [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } + + return databases.length > 0 + ? databases.map((database: any) => + createCatalogDatabaseNode(database.displayName, database, connectionConfig, node) + ) + : [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as RedshiftNode] + } catch (err) { + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'catalog-databases', node.id) as RedshiftNode] + } + } + ) +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/s3Strategy.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/s3Strategy.ts new file mode 100644 index 00000000000..9c1130c2553 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/s3Strategy.ts @@ -0,0 +1,608 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { getLogger } from '../../../shared/logger/logger' +import { DataZoneConnection } from '../../shared/client/datazoneClient' +import { S3Client } from '../../shared/client/s3Client' +import { ConnectionClientStore } from '../../shared/client/connectionClientStore' +import { NODE_ID_DELIMITER, NodeType, ConnectionType, NodeData, NO_DATA_FOUND_MESSAGE } from './types' +import { getLabel, isLeafNode, getIconForNodeType, getTooltip, createErrorItem } from './utils' +import { createPlaceholderItem } from '../../../shared/treeview/utils' +import { + ListCallerAccessGrantsCommand, + GetDataAccessCommand, + ListCallerAccessGrantsEntry, +} from '@aws-sdk/client-s3-control' +import { S3, ListObjectsV2Command } from '@aws-sdk/client-s3' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' +import { telemetry } from '../../../shared/telemetry/telemetry' +import { getContext } from '../../../shared/vscode/setContext' + +// Regex to match default S3 connection names +// eslint-disable-next-line @typescript-eslint/naming-convention +export const DATA_DEFAULT_S3_CONNECTION_NAME_REGEXP = /^(project\.s3_default_folder)|(default\.s3)$/ + +/** + * S3 data node for SageMaker Unified Studio + */ +export class S3Node implements TreeNode { + private readonly logger = getLogger() + private childrenNodes: TreeNode[] | undefined + private isLoading = false + + constructor( + public readonly data: NodeData, + private readonly childrenProvider?: (node: S3Node) => Promise + ) {} + + public get id(): string { + return this.data.id + } + + public get resource(): any { + return this.data.value || {} + } + + public async getChildren(): Promise { + // Return cached children if available + if (this.childrenNodes && !this.isLoading) { + return this.childrenNodes + } + + // Return empty array for leaf nodes + if (isLeafNode(this.data)) { + return [] + } + + // If we have a children provider, use it + if (this.childrenProvider) { + try { + this.isLoading = true + const childrenNodes = await this.childrenProvider(this) + this.childrenNodes = childrenNodes + this.isLoading = false + return this.childrenNodes + } catch (err) { + this.isLoading = false + this.logger.error(`Failed to get children for node ${this.data.id}: ${(err as Error).message}`) + + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'getChildren', this.id) as S3Node] + } + } + + return [] + } + + public async getTreeItem(): Promise { + const collapsibleState = isLeafNode(this.data) + ? vscode.TreeItemCollapsibleState.None + : vscode.TreeItemCollapsibleState.Collapsed + + const label = getLabel(this.data) + const item = new vscode.TreeItem(label, collapsibleState) + + // Set icon based on node type + item.iconPath = getIconForNodeType(this.data.nodeType, this.data.isContainer) + + // Set context value for command enablement + item.contextValue = this.data.nodeType + + // Set tooltip + item.tooltip = getTooltip(this.data) + + return item + } + + public getParent(): TreeNode | undefined { + return this.data.parent + } +} + +/** + * Creates an S3 connection node + */ +export function createS3ConnectionNode( + connection: DataZoneConnection, + connectionCredentialsProvider: ConnectionCredentialsProvider, + region: string +): S3Node { + const logger = getLogger() + + // Parse S3 URI from connection + const s3Info = parseS3Uri(connection) + if (!s3Info) { + logger.warn(`No S3 URI found in connection properties for connection ${connection.name}`) + const errorMessage = 'No S3 URI configured' + void vscode.window.showErrorMessage(errorMessage) + return createErrorItem(errorMessage, 'connection', connection.connectionId) as S3Node + } + + // Get S3 client from store + const clientStore = ConnectionClientStore.getInstance() + const s3Client = clientStore.getS3Client(connection.connectionId, region, connectionCredentialsProvider) + + // Check if this is a default S3 connection + const isDefaultConnection = DATA_DEFAULT_S3_CONNECTION_NAME_REGEXP.test(connection.name) + + // Create the connection node + return new S3Node( + { + id: connection.connectionId, + nodeType: NodeType.CONNECTION, + connectionType: ConnectionType.S3, + value: { connection }, + path: { + connection: connection.name, + bucket: s3Info.bucket, + }, + }, + async (node) => { + return telemetry.smus_renderS3Node.run(async (span) => { + const isInSmusSpace = getContext('aws.smus.inSmusSpaceEnvironment') + + span.record({ + smusToolkitEnv: isInSmusSpace ? 'smus_space' : 'local', + smusDomainId: connection.domainId, + smusProjectId: connection.projectId, + smusConnectionId: connection.connectionId, + smusConnectionType: connection.type, + smusProjectRegion: connection.location?.awsRegion, + }) + try { + if (isDefaultConnection && s3Info.prefix) { + // For default connections, show the full path as the first node + const fullPath = `${s3Info.bucket}/${s3Info.prefix}` + return [ + new S3Node( + { + id: fullPath, + nodeType: NodeType.S3_BUCKET, + connectionType: ConnectionType.S3, + value: { bucket: s3Info.bucket, prefix: s3Info.prefix }, + path: { + connection: connection.name, + bucket: s3Info.bucket, + key: s3Info.prefix, + label: fullPath, + }, + parent: node, + }, + async (bucketNode) => { + try { + // List objects starting from the prefix + const allPaths = [] + let nextToken: string | undefined + + do { + const result = await s3Client.listPaths( + s3Info.bucket, + s3Info.prefix, + nextToken + ) + allPaths.push(...result.paths) + nextToken = result.nextToken + } while (nextToken) + + if (allPaths.length === 0) { + return [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as S3Node] + } + + // Convert paths to nodes + return allPaths.map((path) => { + const nodeId = `${path.bucket}-${path.prefix || 'root'}` + + return new S3Node( + { + id: nodeId, + nodeType: path.isFolder ? NodeType.S3_FOLDER : NodeType.S3_FILE, + connectionType: ConnectionType.S3, + value: path, + path: { + connection: connection.name, + bucket: path.bucket, + key: path.prefix, + label: path.displayName, + }, + parent: bucketNode, + }, + path.isFolder ? createFolderChildrenProvider(s3Client, path) : undefined + ) + }) + } catch (err) { + logger.error(`Failed to list bucket contents: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [ + createErrorItem( + errorMessage, + 'bucket-contents-default', + bucketNode.id + ) as S3Node, + ] + } + } + ), + ] + } else { + // For non-default connections, show bucket as the first node + return [ + new S3Node( + { + id: s3Info.bucket, + nodeType: NodeType.S3_BUCKET, + connectionType: ConnectionType.S3, + value: { bucket: s3Info.bucket }, + path: { + connection: connection.name, + bucket: s3Info.bucket, + }, + parent: node, + }, + async (bucketNode) => { + try { + // List objects in the bucket + const allPaths = [] + let nextToken: string | undefined + + do { + const result = await s3Client.listPaths( + s3Info.bucket, + s3Info.prefix, + nextToken + ) + allPaths.push(...result.paths) + nextToken = result.nextToken + } while (nextToken) + + if (allPaths.length === 0) { + return [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as S3Node] + } + + // Convert paths to nodes + return allPaths.map((path) => { + const nodeId = `${path.bucket}-${path.prefix || 'root'}` + + return new S3Node( + { + id: nodeId, + nodeType: path.isFolder ? NodeType.S3_FOLDER : NodeType.S3_FILE, + connectionType: ConnectionType.S3, + value: path, + path: { + connection: connection.name, + bucket: path.bucket, + key: path.prefix, + label: path.displayName, + }, + parent: bucketNode, + }, + path.isFolder ? createFolderChildrenProvider(s3Client, path) : undefined + ) + }) + } catch (err) { + logger.error(`Failed to list bucket contents: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [ + createErrorItem( + errorMessage, + 'bucket-contents-regular', + bucketNode.id + ) as S3Node, + ] + } + } + ), + ] + } + } catch (err) { + logger.error(`Failed to create bucket node: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'bucket-node', node.id) as S3Node] + } + }) + } + ) +} + +/** + * Creates S3 access grant nodes for project.s3_default_folder connections + */ +export async function createS3AccessGrantNodes( + connection: DataZoneConnection, + connectionCredentialsProvider: ConnectionCredentialsProvider, + region: string, + accountId: string | undefined +): Promise { + if (connection.name !== 'project.s3_default_folder' || !accountId) { + return [] + } + + return await listCallerAccessGrants(connectionCredentialsProvider, region, accountId, connection.connectionId) +} + +/** + * Creates a children provider function for a folder node + */ +function createFolderChildrenProvider(s3Client: S3Client, folderPath: any): (node: S3Node) => Promise { + const logger = getLogger() + + return async (node: S3Node) => { + try { + // List objects in the folder + const allPaths = [] + let nextToken: string | undefined + + do { + const result = await s3Client.listPaths(folderPath.bucket, folderPath.prefix, nextToken) + allPaths.push(...result.paths) + nextToken = result.nextToken + } while (nextToken) + + if (allPaths.length === 0) { + return [createPlaceholderItem(NO_DATA_FOUND_MESSAGE) as S3Node] + } + + // Convert paths to nodes + return allPaths.map((path) => { + const nodeId = `${path.bucket}-${path.prefix || 'root'}` + + return new S3Node( + { + id: nodeId, + nodeType: path.isFolder ? NodeType.S3_FOLDER : NodeType.S3_FILE, + connectionType: ConnectionType.S3, + value: path, + path: { + connection: node.data.path?.connection, + bucket: path.bucket, + key: path.prefix, + label: path.displayName, + }, + parent: node, + }, + path.isFolder ? createFolderChildrenProvider(s3Client, path) : undefined + ) + }) + } catch (err) { + logger.error(`Failed to list folder contents: ${(err as Error).message}`) + const errorMessage = (err as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'folder-contents', node.id) as S3Node] + } + } +} + +/** + * Parse S3 URI from connection + */ +function parseS3Uri(connection: DataZoneConnection): { bucket: string; prefix?: string } | undefined { + const s3Properties = connection.props?.s3Properties + const s3Uri = s3Properties?.s3Uri + + if (!s3Uri) { + return undefined + } + + // Parse S3 URI: s3://bucket-name/prefix/path/ + const uriWithoutPrefix = s3Uri.replace('s3://', '') + // Since the URI ends with a slash, the last item will be an empty string, so ignore it in the parts. + const parts = uriWithoutPrefix.split('/').slice(0, -1) + const bucket = parts[0] + + // If parts only contains 1 item, then only a bucket was provided, and the key is empty. + const prefix = parts.length > 1 ? parts.slice(1).join('/') + '/' : undefined + + return { bucket, prefix } +} + +async function listCallerAccessGrants( + connectionCredentialsProvider: ConnectionCredentialsProvider, + region: string, + accountId: string, + connectionId: string +): Promise { + const logger = getLogger() + try { + const clientStore = ConnectionClientStore.getInstance() + const s3ControlClient = clientStore.getS3ControlClient(connectionId, region, connectionCredentialsProvider) + + const allGrants: ListCallerAccessGrantsEntry[] = [] + let nextToken: string | undefined + + do { + const command = new ListCallerAccessGrantsCommand({ + AccountId: accountId, + NextToken: nextToken, + }) + + const response = await s3ControlClient.send(command) + const grants = response.CallerAccessGrantsList?.filter((entry) => !!entry) ?? [] + allGrants.push(...grants) + nextToken = response.NextToken + } while (nextToken) + + logger.info(`Listed ${allGrants.length} caller access grants`) + + const accessGrantNodes = allGrants.map((grant) => + getRootNodeFromS3AccessGrant(grant, accountId, region, connectionCredentialsProvider, connectionId) + ) + return accessGrantNodes + } catch (error) { + logger.error(`Failed to list caller access grants: ${(error as Error).message}`) + return [] + } +} + +function parseS3UriForAccessGrant(s3Uri: string): { bucket: string; key: string } { + const uriWithoutPrefix = s3Uri.replace('s3://', '') + const parts = uriWithoutPrefix.split('/').slice(0, -1) + const bucket = parts[0] + const key = parts.length > 1 ? parts.slice(1).join('/') + '/' : '' + return { bucket, key } +} + +function getRootNodeFromS3AccessGrant( + s3AccessGrant: ListCallerAccessGrantsEntry, + accountId: string, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider, + connectionId: string +): S3Node { + const s3Uri = s3AccessGrant.GrantScope + let bucket: string | undefined + let key: string | undefined + let nodeId = '' + let label: string + + if (s3Uri) { + const { bucket: parsedBucket, key: parsedKey } = parseS3UriForAccessGrant(s3Uri) + bucket = parsedBucket + key = parsedKey + label = s3Uri.replace('s3://', '').replace('*', '') + nodeId = label + } else { + label = s3AccessGrant.GrantScope ?? '' + } + + return new S3Node( + { + id: nodeId, + nodeType: NodeType.S3_ACCESS_GRANT, + connectionType: ConnectionType.S3, + value: s3AccessGrant, + path: { accountId, bucket, key, label }, + }, + async (node) => { + return await fetchAccessGrantChildren(node, accountId, region, connectionCredentialsProvider, connectionId) + } + ) +} + +async function fetchAccessGrantChildren( + node: S3Node, + accountId: string, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider, + connectionId: string +): Promise { + const logger = getLogger() + const path = node.data.path + + try { + const clientStore = ConnectionClientStore.getInstance() + const s3ControlClient = clientStore.getS3ControlClient(connectionId, region, connectionCredentialsProvider) + + const target = `s3://${path?.bucket ?? ''}/${path?.key ?? ''}*` + + const getDataAccessCommand = new GetDataAccessCommand({ + AccountId: accountId, + Target: target, + Permission: 'READ', + }) + + const grantCredentialsProvider = async () => { + const response = await s3ControlClient.send(getDataAccessCommand) + if ( + !response.Credentials?.AccessKeyId || + !response.Credentials?.SecretAccessKey || + !response.Credentials?.SessionToken + ) { + throw new Error('Missing required credentials from access grant response') + } + return { + accessKeyId: response.Credentials.AccessKeyId, + secretAccessKey: response.Credentials.SecretAccessKey, + sessionToken: response.Credentials.SessionToken, + expiration: response.Credentials.Expiration, + } + } + + const s3ClientWithGrant = new S3({ + credentials: grantCredentialsProvider, + region, + }) + + const response = await s3ClientWithGrant.send( + new ListObjectsV2Command({ + Bucket: path?.bucket ?? '', + Prefix: path?.key ?? '', + Delimiter: '/', + MaxKeys: 100, + }) + ) + + const children: S3Node[] = [] + + // Add folders + if (response.CommonPrefixes) { + for (const prefix of response.CommonPrefixes) { + const folderName = + prefix.Prefix?.split('/') + .filter((name) => !!name) + .at(-1) + '/' + children.push( + new S3Node( + { + id: `${node.id}${NODE_ID_DELIMITER}${folderName}`, + nodeType: NodeType.S3_FOLDER, + connectionType: ConnectionType.S3, + value: prefix, + path: { + accountId, + bucket: path?.bucket, + key: prefix.Prefix, + label: folderName, + }, + parent: node, + }, + async (folderNode) => { + return await fetchAccessGrantChildren( + folderNode, + accountId, + region, + connectionCredentialsProvider, + connectionId + ) + } + ) + ) + } + } + + // Add files + if (response.Contents) { + for (const content of response.Contents.filter((content) => content.Key !== response.Prefix)) { + const fileName = content.Key?.split('/').at(-1) ?? '' + children.push( + new S3Node({ + id: `${node.id}${NODE_ID_DELIMITER}${fileName}`, + nodeType: NodeType.S3_FILE, + connectionType: ConnectionType.S3, + value: content, + path: { + bucket: path?.bucket, + key: content.Key, + label: fileName, + }, + parent: node, + }) + ) + } + } + + return children + } catch (error) { + logger.error(`Failed to fetch access grant children: ${(error as Error).message}`) + const errorMessage = (error as Error).message + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'access-grant-children', node.id) as S3Node] + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioAuthInfoNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioAuthInfoNode.ts new file mode 100644 index 00000000000..ff25f64cf74 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioAuthInfoNode.ts @@ -0,0 +1,90 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { SageMakerUnifiedStudioRootNode } from './sageMakerUnifiedStudioRootNode' +import { SmusAuthenticationProvider } from '../../auth/providers/smusAuthenticationProvider' + +/** + * Node representing the SageMaker Unified Studio authentication information + */ +export class SageMakerUnifiedStudioAuthInfoNode implements TreeNode { + public readonly id = 'smusAuthInfoNode' + public readonly resource = this + private readonly authProvider: SmusAuthenticationProvider + + private readonly onDidChangeEmitter = new vscode.EventEmitter() + public readonly onDidChangeTreeItem = this.onDidChangeEmitter.event + + constructor(private readonly parent?: SageMakerUnifiedStudioRootNode) { + this.authProvider = SmusAuthenticationProvider.fromContext() + + // Subscribe to auth provider connection changes to refresh the node + this.authProvider.onDidChange(() => { + this.onDidChangeEmitter.fire() + }) + } + + public getTreeItem(): vscode.TreeItem { + // Use the cached authentication provider to check connection status + const isConnected = this.authProvider.isConnected() + const isValid = this.authProvider.isConnectionValid() + + // Get the domain ID and region from auth provider + let domainId = 'Unknown' + let region = 'Unknown' + + if (isConnected && this.authProvider.activeConnection) { + const conn = this.authProvider.activeConnection + domainId = conn.domainId || 'Unknown' + region = conn.ssoRegion || 'Unknown' + } + + // Create display based on connection status + let label: string + let iconPath: vscode.ThemeIcon + let tooltip: string + + if (isConnected && isValid) { + label = `Domain: ${domainId}` + iconPath = new vscode.ThemeIcon('key', new vscode.ThemeColor('charts.green')) + tooltip = `Connected to SageMaker Unified Studio\nDomain ID: ${domainId}\nRegion: ${region}\nStatus: Connected` + } else if (isConnected && !isValid) { + label = `Domain: ${domainId} (Expired) - Click to reauthenticate` + iconPath = new vscode.ThemeIcon('warning', new vscode.ThemeColor('charts.yellow')) + tooltip = `Connection to SageMaker Unified Studio has expired\nDomain ID: ${domainId}\nRegion: ${region}\nStatus: Expired - Click to reauthenticate` + } else { + label = 'Not Connected' + iconPath = new vscode.ThemeIcon('circle-slash', new vscode.ThemeColor('charts.red')) + tooltip = 'Not connected to SageMaker Unified Studio\nPlease sign in to access your projects' + } + + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None) + + // Add region as description (appears to the right) if connected + if (isConnected) { + item.description = region + } + + // Add command for reauthentication when connection is expired + if (isConnected && !isValid) { + item.command = { + command: 'aws.smus.reauthenticate', + title: 'Reauthenticate', + arguments: [this.authProvider.activeConnection], + } + } + + item.tooltip = tooltip + item.contextValue = 'smusAuthInfo' + item.iconPath = iconPath + return item + } + + public getParent(): TreeNode | undefined { + return this.parent + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode.ts new file mode 100644 index 00000000000..01293e7e523 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode.ts @@ -0,0 +1,66 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { getIcon } from '../../../shared/icons' +import { SageMakerUnifiedStudioSpacesParentNode } from './sageMakerUnifiedStudioSpacesParentNode' +import { SageMakerUnifiedStudioProjectNode } from './sageMakerUnifiedStudioProjectNode' +import { SagemakerClient } from '../../../shared/clients/sagemaker' +import { SmusAuthenticationProvider } from '../../auth/providers/smusAuthenticationProvider' +import { SageMakerUnifiedStudioConnectionParentNode } from './sageMakerUnifiedStudioConnectionParentNode' +import { ConnectionType } from '@aws-sdk/client-datazone' + +export class SageMakerUnifiedStudioComputeNode implements TreeNode { + public readonly id = 'smusComputeNode' + public readonly resource = this + private spacesNode: SageMakerUnifiedStudioSpacesParentNode | undefined + + constructor( + public readonly parent: SageMakerUnifiedStudioProjectNode, + private readonly extensionContext: vscode.ExtensionContext, + public readonly authProvider: SmusAuthenticationProvider, + private readonly sagemakerClient: SagemakerClient + ) {} + + public async getTreeItem(): Promise { + const item = new vscode.TreeItem('Compute', vscode.TreeItemCollapsibleState.Expanded) + item.iconPath = getIcon('vscode-chip') + item.contextValue = this.getContext() + return item + } + + public async getChildren(): Promise { + const childrenNodes: TreeNode[] = [] + const projectId = this.parent.getProject()?.id + + if (projectId) { + childrenNodes.push( + new SageMakerUnifiedStudioConnectionParentNode(this, ConnectionType.REDSHIFT, 'Data warehouse') + ) + childrenNodes.push( + new SageMakerUnifiedStudioConnectionParentNode(this, ConnectionType.SPARK, 'Data processing') + ) + this.spacesNode = new SageMakerUnifiedStudioSpacesParentNode( + this, + projectId, + this.extensionContext, + this.authProvider, + this.sagemakerClient + ) + childrenNodes.push(this.spacesNode) + } + + return childrenNodes + } + + public getParent(): TreeNode | undefined { + return this.parent + } + + private getContext(): string { + return 'smusComputeNode' + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionNode.ts new file mode 100644 index 00000000000..969efa9823d --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionNode.ts @@ -0,0 +1,63 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { getLogger } from '../../../shared/logger/logger' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { SageMakerUnifiedStudioConnectionParentNode } from './sageMakerUnifiedStudioConnectionParentNode' +import { ConnectionSummary, ConnectionType } from '@aws-sdk/client-datazone' + +export class SageMakerUnifiedStudioConnectionNode implements TreeNode { + public resource: SageMakerUnifiedStudioConnectionNode + contextValue: string + private readonly logger = getLogger() + id: string + public constructor( + private readonly parent: SageMakerUnifiedStudioConnectionParentNode, + private readonly connection: ConnectionSummary + ) { + this.id = connection.name ?? '' + this.resource = this + this.contextValue = this.getContext() + this.logger.debug(`SageMaker Space Node created: ${this.id}`) + } + + public async getTreeItem(): Promise { + const item = new vscode.TreeItem(this.id, vscode.TreeItemCollapsibleState.None) + item.contextValue = this.getContext() + item.tooltip = new vscode.MarkdownString(this.buildTooltip()) + return item + } + private buildTooltip(): string { + if (this.connection.type === ConnectionType.REDSHIFT) { + const tooltip = ''.concat( + '### Compute Details\n\n', + `**Type** \n${this.connection.type}\n\n`, + `**Environment ID** \n${this.connection.environmentId}\n\n`, + `**JDBC URL** \n${this.connection.props?.redshiftProperties?.jdbcUrl}` + ) + return tooltip + } else if (this.connection.type === ConnectionType.SPARK) { + const tooltip = ''.concat( + '### Compute Details\n\n', + `**Type** \n${this.connection.type}\n\n`, + `**Glue version** \n${this.connection.props?.sparkGlueProperties?.glueVersion}\n\n`, + `**Worker type** \n${this.connection.props?.sparkGlueProperties?.workerType}\n\n`, + `**Number of workers** \n${this.connection.props?.sparkGlueProperties?.numberOfWorkers}\n\n`, + `**Idle timeout (minutes)** \n${this.connection.props?.sparkGlueProperties?.idleTimeout}\n\n` + ) + return tooltip + } else { + return '' + } + } + private getContext(): string { + return 'SageMakerUnifiedStudioConnectionNode' + } + + public getParent(): TreeNode | undefined { + return this.parent + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionParentNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionParentNode.ts new file mode 100644 index 00000000000..a04377f0133 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionParentNode.ts @@ -0,0 +1,65 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioComputeNode } from './sageMakerUnifiedStudioComputeNode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { ListConnectionsCommandOutput, ConnectionType } from '@aws-sdk/client-datazone' +import { SageMakerUnifiedStudioConnectionNode } from './sageMakerUnifiedStudioConnectionNode' +import { DataZoneClient } from '../../shared/client/datazoneClient' + +// eslint-disable-next-line id-length +export class SageMakerUnifiedStudioConnectionParentNode implements TreeNode { + public resource: SageMakerUnifiedStudioConnectionParentNode + contextValue: string + public connections: ListConnectionsCommandOutput | undefined + public constructor( + private readonly parent: SageMakerUnifiedStudioComputeNode, + private readonly connectionType: ConnectionType, + public id: string + ) { + this.resource = this + this.contextValue = this.getContext() + } + + public async getTreeItem(): Promise { + const item = new vscode.TreeItem(this.id, vscode.TreeItemCollapsibleState.Collapsed) + item.contextValue = this.getContext() + return item + } + + public async getChildren(): Promise { + const client = await DataZoneClient.getInstance(this.parent.authProvider) + this.connections = await client.fetchConnections( + this.parent.parent.project?.domainId, + this.parent.parent.project?.id, + this.connectionType + ) + const childrenNodes = [] + if (!this.connections?.items || this.connections.items.length === 0) { + return [ + { + id: 'smusNoConnections', + resource: {}, + getTreeItem: () => + new vscode.TreeItem('[No connections found]', vscode.TreeItemCollapsibleState.None), + getParent: () => this, + }, + ] + } + for (const connection of this.connections.items) { + childrenNodes.push(new SageMakerUnifiedStudioConnectionNode(this, connection)) + } + return childrenNodes + } + + private getContext(): string { + return 'SageMakerUnifiedStudioConnectionParentNode' + } + + public getParent(): TreeNode | undefined { + return this.parent + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioDataNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioDataNode.ts new file mode 100644 index 00000000000..4294a3e42f4 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioDataNode.ts @@ -0,0 +1,250 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { getIcon } from '../../../shared/icons' + +import { getLogger } from '../../../shared/logger/logger' +import { DataZoneClient, DataZoneConnection, DataZoneProject } from '../../shared/client/datazoneClient' +import { createS3ConnectionNode, createS3AccessGrantNodes } from './s3Strategy' +import { createRedshiftConnectionNode } from './redshiftStrategy' +import { createLakehouseConnectionNode } from './lakehouseStrategy' +import { SageMakerUnifiedStudioProjectNode } from './sageMakerUnifiedStudioProjectNode' +import { isFederatedConnection, createErrorItem } from './utils' +import { createPlaceholderItem } from '../../../shared/treeview/utils' +import { ConnectionType, NO_DATA_FOUND_MESSAGE } from './types' +import { SmusAuthenticationProvider } from '../../auth/providers/smusAuthenticationProvider' + +/** + * Tree node representing a Data folder that contains S3 and Redshift connections + */ +export class SageMakerUnifiedStudioDataNode implements TreeNode { + public readonly id = 'smusDataExplorer' + public readonly resource = {} + private readonly logger = getLogger() + private childrenNodes: TreeNode[] | undefined + private readonly authProvider: SmusAuthenticationProvider + + constructor( + private readonly parent: SageMakerUnifiedStudioProjectNode, + initialChildren: TreeNode[] = [] + ) { + this.childrenNodes = initialChildren.length > 0 ? initialChildren : undefined + this.authProvider = SmusAuthenticationProvider.fromContext() + } + + public getTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem('Data', vscode.TreeItemCollapsibleState.Collapsed) + item.iconPath = getIcon('vscode-library') + item.contextValue = 'dataFolder' + return item + } + + public async getChildren(): Promise { + if (this.childrenNodes !== undefined) { + return this.childrenNodes + } + + try { + const project = this.parent.getProject() + if (!project) { + const errorMessage = 'No project information available' + this.logger.error(errorMessage) + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'project', this.id)] + } + + const datazoneClient = await DataZoneClient.getInstance(this.authProvider) + const connections = await datazoneClient.listConnections(project.domainId, undefined, project.id) + this.logger.info(`Found ${connections.length} connections for project ${project.id}`) + + if (connections.length === 0) { + this.childrenNodes = [createPlaceholderItem(NO_DATA_FOUND_MESSAGE)] + return this.childrenNodes + } + + const dataNodes = await this.createConnectionNodes(project, connections) + this.childrenNodes = dataNodes + return dataNodes + } catch (err) { + const project = this.parent.getProject() + const projectInfo = project ? `project: ${project.id}, domain: ${project.domainId}` : 'unknown project' + const errorMessage = 'Failed to get connections' + this.logger.error(`Failed to get connections for ${projectInfo}: ${(err as Error).message}`) + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, 'connections', this.id)] + } + } + + public getParent(): TreeNode | undefined { + return this.parent + } + + private async createConnectionNodes( + project: DataZoneProject, + connections: DataZoneConnection[] + ): Promise { + const region = this.authProvider.getDomainRegion() + const dataNodes: TreeNode[] = [] + + const s3Connections = connections.filter((conn) => (conn.type as ConnectionType) === ConnectionType.S3) + const redshiftConnections = connections.filter( + (conn) => (conn.type as ConnectionType) === ConnectionType.REDSHIFT + ) + const lakehouseConnections = connections.filter( + (conn) => (conn.type as ConnectionType) === ConnectionType.LAKEHOUSE + ) + + // Add Lakehouse nodes first + for (const connection of lakehouseConnections) { + const node = await this.createLakehouseNode(project, connection, region) + dataNodes.push(node) + } + + // Add Redshift nodes second + for (const connection of redshiftConnections) { + if (connection.name.startsWith('project.lakehouse')) { + continue + } + if (isFederatedConnection(connection)) { + continue + } + const node = await this.createRedshiftNode(project, connection, region) + dataNodes.push(node) + } + + // Add S3 Bucket parent node last + if (s3Connections.length > 0) { + const bucketNode = this.createBucketParentNode(project, s3Connections, region) + dataNodes.push(bucketNode) + } + + this.logger.info(`Created ${dataNodes.length} total connection nodes`) + return dataNodes + } + + private async createS3Node( + project: DataZoneProject, + connection: DataZoneConnection, + region: string + ): Promise { + try { + const datazoneClient = await DataZoneClient.getInstance(this.authProvider) + const getConnectionResponse = await datazoneClient.getConnection({ + domainIdentifier: project.domainId, + identifier: connection.connectionId, + withSecret: true, + }) + + const connectionCredentialsProvider = await this.authProvider.getConnectionCredentialsProvider( + connection.connectionId, + project.id, + getConnectionResponse.location?.awsRegion || region + ) + + const s3ConnectionNode = createS3ConnectionNode( + connection, + connectionCredentialsProvider, + getConnectionResponse.location?.awsRegion || region + ) + + const accessGrantNodes = await createS3AccessGrantNodes( + connection, + connectionCredentialsProvider, + getConnectionResponse.location?.awsRegion || region, + getConnectionResponse.location?.awsAccountId + ) + + return [s3ConnectionNode, ...accessGrantNodes] + } catch (connErr) { + const errorMessage = `Failed to get S3 connection - ${(connErr as Error).message}` + this.logger.error(`Failed to get S3 connection details: ${(connErr as Error).message}`) + void vscode.window.showErrorMessage(errorMessage) + return [createErrorItem(errorMessage, `s3-${connection.connectionId}`, this.id)] + } + } + + private async createRedshiftNode( + project: DataZoneProject, + connection: DataZoneConnection, + region: string + ): Promise { + try { + const datazoneClient = await DataZoneClient.getInstance(this.authProvider) + const getConnectionResponse = await datazoneClient.getConnection({ + domainIdentifier: project.domainId, + identifier: connection.connectionId, + withSecret: true, + }) + + const connectionCredentialsProvider = await this.authProvider.getConnectionCredentialsProvider( + connection.connectionId, + project.id, + getConnectionResponse.location?.awsRegion || region + ) + + return createRedshiftConnectionNode(connection, connectionCredentialsProvider) + } catch (connErr) { + const errorMessage = `Failed to get Redshift connection - ${(connErr as Error).message}` + this.logger.error(`Failed to get Redshift connection details: ${(connErr as Error).message}`) + void vscode.window.showErrorMessage(errorMessage) + return createErrorItem(errorMessage, `redshift-${connection.connectionId}`, this.id) + } + } + + private async createLakehouseNode( + project: DataZoneProject, + connection: DataZoneConnection, + region: string + ): Promise { + try { + const datazoneClient = await DataZoneClient.getInstance(this.authProvider) + const getConnectionResponse = await datazoneClient.getConnection({ + domainIdentifier: project.domainId, + identifier: connection.connectionId, + withSecret: true, + }) + + const connectionCredentialsProvider = await this.authProvider.getConnectionCredentialsProvider( + connection.connectionId, + project.id, + getConnectionResponse.location?.awsRegion || region + ) + + return createLakehouseConnectionNode(connection, connectionCredentialsProvider, region) + } catch (connErr) { + const errorMessage = `Failed to get Lakehouse connection - ${(connErr as Error).message}` + this.logger.error(`Failed to get Lakehouse connection details: ${(connErr as Error).message}`) + void vscode.window.showErrorMessage(errorMessage) + return createErrorItem(errorMessage, `lakehouse-${connection.connectionId}`, this.id) + } + } + + private createBucketParentNode( + project: DataZoneProject, + s3Connections: DataZoneConnection[], + region: string + ): TreeNode { + return { + id: 'bucket-parent', + resource: {}, + getTreeItem: () => { + const item = new vscode.TreeItem('Buckets', vscode.TreeItemCollapsibleState.Collapsed) + item.contextValue = 'bucketFolder' + return item + }, + getChildren: async () => { + const s3Nodes: TreeNode[] = [] + for (const connection of s3Connections) { + const nodes = await this.createS3Node(project, connection, region) + s3Nodes.push(...nodes) + } + return s3Nodes + }, + getParent: () => this, + } + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode.ts new file mode 100644 index 00000000000..d47933e9948 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode.ts @@ -0,0 +1,241 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { getLogger } from '../../../shared/logger/logger' +import { telemetry } from '../../../shared/telemetry/telemetry' +import { AwsCredentialIdentity } from '@aws-sdk/types' +import { SageMakerUnifiedStudioDataNode } from './sageMakerUnifiedStudioDataNode' +import { DataZoneClient, DataZoneProject } from '../../shared/client/datazoneClient' +import { SageMakerUnifiedStudioRootNode } from './sageMakerUnifiedStudioRootNode' +import { SagemakerClient } from '../../../shared/clients/sagemaker' +import { SmusAuthenticationProvider } from '../../auth/providers/smusAuthenticationProvider' +import { SageMakerUnifiedStudioComputeNode } from './sageMakerUnifiedStudioComputeNode' +import { getIcon } from '../../../shared/icons' +import { getResourceMetadata } from '../../shared/utils/resourceMetadataUtils' +import { getContext } from '../../../shared/vscode/setContext' + +/** + * Tree node representing a SageMaker Unified Studio project + */ +export class SageMakerUnifiedStudioProjectNode implements TreeNode { + public readonly id = 'smusProjectNode' + public readonly resource = this + private readonly onDidChangeEmitter = new vscode.EventEmitter() + public readonly onDidChangeTreeItem = this.onDidChangeEmitter.event + public readonly onDidChangeChildren = this.onDidChangeEmitter.event + public project?: DataZoneProject + private logger = getLogger() + private sagemakerClient?: SagemakerClient + private hasShownFirstTimeMessage = false + private isFirstTimeSelection = false + + constructor( + private readonly parent: SageMakerUnifiedStudioRootNode, + private readonly authProvider: SmusAuthenticationProvider, + private readonly extensionContext: vscode.ExtensionContext + ) { + // If we're in SMUS space environment, set project from resource metadata + if (getContext('aws.smus.inSmusSpaceEnvironment')) { + const resourceMetadata = getResourceMetadata()! + if (resourceMetadata.AdditionalMetadata!.DataZoneProjectId) { + this.project = { + id: resourceMetadata!.AdditionalMetadata!.DataZoneProjectId!, + name: 'Current Project', + domainId: resourceMetadata!.AdditionalMetadata!.DataZoneDomainId!, + } + // Fetch the actual project name asynchronously + void this.fetchProjectName() + } + } + } + + public async getTreeItem(): Promise { + if (this.project) { + const item = new vscode.TreeItem('Project: ' + this.project.name, vscode.TreeItemCollapsibleState.Expanded) + item.contextValue = 'smusSelectedProject' + item.tooltip = `Project: ${this.project.name}\nID: ${this.project.id}` + item.iconPath = getIcon('vscode-folder-opened') + return item + } + + const item = new vscode.TreeItem('Select a project', vscode.TreeItemCollapsibleState.Expanded) + item.contextValue = 'smusProjectSelectPicker' + item.command = { + command: 'aws.smus.projectView', + title: 'Select Project', + arguments: [this], + } + item.iconPath = getIcon('vscode-folder-opened') + + return item + } + + public async getChildren(): Promise { + if (!this.project) { + return [] + } + + return telemetry.smus_renderProjectChildrenNode.run(async (span) => { + try { + const isInSmusSpace = getContext('aws.smus.inSmusSpaceEnvironment') + + span.record({ + smusToolkitEnv: isInSmusSpace ? 'smus_space' : 'local', + smusDomainId: this.project?.domainId, + smusProjectId: this.project?.id, + smusDomainRegion: this.authProvider.getDomainRegion(), + }) + + // Skip access check if we're in SMUS space environment (already in project space) + if (!getContext('aws.smus.inSmusSpaceEnvironment')) { + const hasAccess = await this.checkProjectCredsAccess(this.project!.id) + if (!hasAccess) { + return [ + { + id: 'smusProjectAccessDenied', + resource: {}, + getTreeItem: () => { + const item = new vscode.TreeItem( + 'You do not have access to this project. Contact your administrator.', + vscode.TreeItemCollapsibleState.None + ) + return item + }, + getParent: () => this, + }, + ] + } + } + + const dataNode = new SageMakerUnifiedStudioDataNode(this) + + // If we're in SMUS space environment, only show data node + if (getContext('aws.smus.inSmusSpaceEnvironment')) { + return [dataNode] + } + + const dzClient = await DataZoneClient.getInstance(this.authProvider) + if (!this.project?.id) { + throw new Error('Project ID is required') + } + const toolingEnv = await dzClient.getToolingEnvironment(this.project.id) + const spaceAwsAccountRegion = toolingEnv.awsAccountRegion + + if (!spaceAwsAccountRegion) { + throw new Error('No AWS account region found in tooling environment') + } + if (this.isFirstTimeSelection && !this.hasShownFirstTimeMessage) { + this.hasShownFirstTimeMessage = true + void vscode.window.showInformationMessage( + 'Find your space in the Explorer panel under SageMaker Unified Studio. Hover over any space and click the connection icon to connect remotely.' + ) + } + this.sagemakerClient = await this.initializeSagemakerClient(spaceAwsAccountRegion) + const computeNode = new SageMakerUnifiedStudioComputeNode( + this, + this.extensionContext, + this.authProvider, + this.sagemakerClient + ) + return [dataNode, computeNode] + } catch (err) { + this.logger.error('Failed to select project: %s', (err as Error).message) + throw err + } + }) + } + + public getParent(): TreeNode | undefined { + return this.parent + } + + public async refreshNode(): Promise { + this.onDidChangeEmitter.fire() + } + + public async setProject(project: any): Promise { + await this.cleanupProjectResources() + this.isFirstTimeSelection = !this.project + this.project = project + } + + public getProject(): DataZoneProject | undefined { + return this.project + } + + public async clearProject(): Promise { + await this.cleanupProjectResources() + // Don't clear project if we're in SMUS space environment + if (!getContext('aws.smus.inSmusSpaceEnvironment')) { + this.project = undefined + } + await this.refreshNode() + } + + private async cleanupProjectResources(): Promise { + await this.authProvider.invalidateAllProjectCredentialsInCache() + if (this.sagemakerClient) { + this.sagemakerClient.dispose() + this.sagemakerClient = undefined + } + } + + private async checkProjectCredsAccess(projectId: string): Promise { + // TODO: Ideally we should be checking user project access by calling fetchAllProjectMemberships + // and checking if user is part of that, or get user groups and check if any of the groupIds + // exists in the project memberships for more comprehensive access validation. + try { + const projectProvider = await this.authProvider.getProjectCredentialProvider(projectId) + this.logger.info(`Successfully obtained project credentials provider for project ${projectId}`) + await projectProvider.getCredentials() + return true + } catch (err) { + // If err.name is 'AccessDeniedException', it means user doesn't have access to the project + // We can safely return false in that case without logging the error + if ((err as any).name === 'AccessDeniedException') { + this.logger.debug( + 'Access denied when obtaining project credentials, user likely lacks project access or role permissions' + ) + } + return false + } + } + + private async fetchProjectName(): Promise { + if (!this.project || !getContext('aws.smus.inSmusSpaceEnvironment')) { + return + } + + try { + const dzClient = await DataZoneClient.getInstance(this.authProvider) + const projectDetails = await dzClient.getProject(this.project.id) + + if (projectDetails && projectDetails.name) { + this.project.name = projectDetails.name + // Refresh the tree item to show the updated name + this.onDidChangeEmitter.fire() + } + } catch (err) { + // No need to show error, this is just to dynamically show project name + // If we fail to fetch project name, we will just show the default name + this.logger.debug(`Failed to fetch project name: ${(err as Error).message}`) + } + } + + private async initializeSagemakerClient(regionCode: string): Promise { + if (!this.project) { + throw new Error('No project selected for initializing SageMaker client') + } + const projectProvider = await this.authProvider.getProjectCredentialProvider(this.project.id) + this.logger.info(`Successfully obtained project credentials provider for project ${this.project.id}`) + const awsCredentialProvider = async (): Promise => { + return await projectProvider.getCredentials() + } + const sagemakerClient = new SagemakerClient(regionCode, awsCredentialProvider) + return sagemakerClient + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioRootNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioRootNode.ts new file mode 100644 index 00000000000..a72db66ea69 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioRootNode.ts @@ -0,0 +1,462 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { getIcon } from '../../../shared/icons' +import { getLogger } from '../../../shared/logger/logger' +import { DataZoneClient, DataZoneProject } from '../../shared/client/datazoneClient' +import { Commands } from '../../../shared/vscode/commands2' +import { telemetry } from '../../../shared/telemetry/telemetry' +import { createQuickPick } from '../../../shared/ui/pickerPrompter' +import { SageMakerUnifiedStudioProjectNode } from './sageMakerUnifiedStudioProjectNode' +import { SageMakerUnifiedStudioAuthInfoNode } from './sageMakerUnifiedStudioAuthInfoNode' +import { SmusUtils } from '../../shared/smusUtils' +import { SmusAuthenticationProvider } from '../../auth/providers/smusAuthenticationProvider' +import { ToolkitError } from '../../../../src/shared/errors' +import { errorCode } from '../../shared/errors' + +const contextValueSmusRoot = 'sageMakerUnifiedStudioRoot' +const contextValueSmusLogin = 'sageMakerUnifiedStudioLogin' +const contextValueSmusLearnMore = 'sageMakerUnifiedStudioLearnMore' +const projectPickerTitle = 'Select a SageMaker Unified Studio project you want to open' +const projectPickerPlaceholder = 'Select project' + +export class SageMakerUnifiedStudioRootNode implements TreeNode { + public readonly id = 'smusRootNode' + public readonly resource = this + private readonly logger = getLogger() + private readonly projectNode: SageMakerUnifiedStudioProjectNode + private readonly authInfoNode: SageMakerUnifiedStudioAuthInfoNode + private readonly onDidChangeEmitter = new vscode.EventEmitter() + public readonly onDidChangeTreeItem = this.onDidChangeEmitter.event + public readonly onDidChangeChildren = this.onDidChangeEmitter.event + + public constructor( + private readonly authProvider: SmusAuthenticationProvider, + private readonly extensionContext: vscode.ExtensionContext + ) { + this.authInfoNode = new SageMakerUnifiedStudioAuthInfoNode(this) + this.projectNode = new SageMakerUnifiedStudioProjectNode(this, this.authProvider, this.extensionContext) + + // Subscribe to auth provider connection changes to refresh the node + this.authProvider.onDidChange(async () => { + // Clear the project when connection changes + await this.projectNode.clearProject() + this.onDidChangeEmitter.fire() + // Immediately refresh the tree view to show authenticated state + try { + await vscode.commands.executeCommand('aws.smus.rootView.refresh') + } catch (refreshErr) { + this.logger.debug( + `Failed to refresh views after connection state change: ${(refreshErr as Error).message}` + ) + } + }) + } + + public getTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem('SageMaker Unified Studio', vscode.TreeItemCollapsibleState.Expanded) + item.contextValue = contextValueSmusRoot + item.iconPath = getIcon('vscode-database') + + // Set description based on authentication state + if (!this.isAuthenticated()) { + item.description = 'Not authenticated' + } else { + item.description = 'Connected' + } + + return item + } + + public async getChildren(): Promise { + const isAuthenticated = this.isAuthenticated() + const hasExpiredConnection = this.hasExpiredConnection() + + this.logger.debug( + `SMUS Root Node getChildren: isAuthenticated=${isAuthenticated}, hasExpiredConnection=${hasExpiredConnection}` + ) + + // Check for expired connection first + if (hasExpiredConnection) { + // Show auth info node with expired indication + return [this.authInfoNode] // This will show expired connection info + } + + // Check authentication state + if (!isAuthenticated) { + // Show login option and learn more link when not authenticated + return [ + { + id: 'smusLogin', + resource: {}, + getTreeItem: () => { + const item = new vscode.TreeItem('Sign in to get started', vscode.TreeItemCollapsibleState.None) + item.contextValue = contextValueSmusLogin + item.iconPath = getIcon('vscode-account') + + // Set up the login command + item.command = { + command: 'aws.smus.login', + title: 'Sign in to SageMaker Unified Studio', + } + + return item + }, + getParent: () => this, + }, + { + id: 'smusLearnMore', + resource: {}, + getTreeItem: () => { + const item = new vscode.TreeItem( + 'Learn more about SageMaker Unified Studio', + vscode.TreeItemCollapsibleState.None + ) + item.contextValue = contextValueSmusLearnMore + item.iconPath = getIcon('vscode-question') + + // Set up the learn more command + item.command = { + command: 'aws.smus.learnMore', + title: 'Learn more about SageMaker Unified Studio', + } + + return item + }, + getParent: () => this, + }, + ] + } + + // When authenticated, show auth info and projects + return [this.authInfoNode, this.projectNode] + } + + public getProjectSelectNode(): SageMakerUnifiedStudioProjectNode { + return this.projectNode + } + + public getAuthInfoNode(): SageMakerUnifiedStudioAuthInfoNode { + return this.authInfoNode + } + + public refresh(): void { + this.onDidChangeEmitter.fire() + } + + /** + * Checks if the user has authenticated to SageMaker Unified Studio + * This is validated by checking existing Connections for SMUS or resource metadata. + */ + private isAuthenticated(): boolean { + try { + // Check if the connection is valid using the authentication provider + const result = this.authProvider.isConnectionValid() + this.logger.debug(`SMUS Root Node: Authentication check result: ${result}`) + return result + } catch (err) { + this.logger.debug('Authentication check failed: %s', (err as Error).message) + return false + } + } + + private hasExpiredConnection(): boolean { + try { + const activeConnection = this.authProvider.activeConnection + const isConnectionValid = this.authProvider.isConnectionValid() + + this.logger.debug( + `SMUS Root Node: activeConnection=${!!activeConnection}, isConnectionValid=${isConnectionValid}` + ) + + // Check if there's an active connection but it's expired/invalid + const hasExpiredConnection = activeConnection && !isConnectionValid + + if (hasExpiredConnection) { + this.logger.debug('SMUS Root Node: Connection is expired, showing reauthentication prompt') + // Show reauthentication prompt to user + void this.authProvider.showReauthenticationPrompt(activeConnection as any) + return true + } + return false + } catch (err) { + this.logger.debug('Failed to check expired connection: %s', (err as Error).message) + return false + } + } +} + +/** + * Command to open the SageMaker Unified Studio documentation + */ +export const smusLearnMoreCommand = Commands.declare('aws.smus.learnMore', () => async () => { + const logger = getLogger() + try { + // Open the SageMaker Unified Studio documentation + await vscode.env.openExternal(vscode.Uri.parse('https://aws.amazon.com/sagemaker/unified-studio/')) + + // Log telemetry + telemetry.record({ + name: 'smus_learnMoreClicked', + result: 'Succeeded', + passive: false, + }) + } catch (err) { + logger.error('Failed to open SageMaker Unified Studio documentation: %s', (err as Error).message) + + // Log failure telemetry + telemetry.record({ + name: 'smus_learnMoreClicked', + result: 'Failed', + passive: false, + }) + } +}) + +/** + * Command to login to SageMaker Unified Studio + */ +export const smusLoginCommand = Commands.declare('aws.smus.login', () => async () => { + const logger = getLogger() + return telemetry.smus_login.run(async (span) => { + try { + // Get DataZoneClient instance for URL validation + + // Show domain URL input dialog + const domainUrl = await vscode.window.showInputBox({ + title: 'SageMaker Unified Studio Authentication', + prompt: 'Enter your SageMaker Unified Studio Domain URL', + placeHolder: 'https://.sagemaker..on.aws', + validateInput: (value) => SmusUtils.validateDomainUrl(value), + }) + + if (!domainUrl) { + // User cancelled + logger.debug('User cancelled domain URL input') + return + } + + // Show a simple status bar message instead of progress dialog + vscode.window.setStatusBarMessage('Connecting to SageMaker Unified Studio...', 10000) + + try { + // Get the authentication provider instance + const authProvider = SmusAuthenticationProvider.fromContext() + + // Connect to SMUS using the authentication provider + const connection = await authProvider.connectToSmus(domainUrl) + + if (!connection) { + throw new ToolkitError('Failed to establish connection', { + code: errorCode.failedAuthConnecton, + }) + } + + // Extract domain ID and region for logging + const domainId = connection.domainId + const region = connection.ssoRegion + + logger.info(`Connected to SageMaker Unified Studio domain: ${domainId} in region ${region}`) + span.record({ + smusDomainId: domainId, + awsRegion: region, + }) + + // Show success message + void vscode.window.showInformationMessage( + `Successfully connected to SageMaker Unified Studio domain: ${domainId}` + ) + + // Clear the status bar message + vscode.window.setStatusBarMessage('Connected to SageMaker Unified Studio', 3000) + + // Immediately refresh the tree view to show authenticated state + try { + await vscode.commands.executeCommand('aws.smus.rootView.refresh') + } catch (refreshErr) { + logger.debug(`Failed to refresh views after login: ${(refreshErr as Error).message}`) + } + } catch (connectionErr) { + // Clear the status bar message + vscode.window.setStatusBarMessage('Connection to SageMaker Unified Studio Failed') + + // Log the error and re-throw to be handled by the outer catch block + logger.error('Connection failed: %s', (connectionErr as Error).message) + throw new ToolkitError('Connection failed.', { + cause: connectionErr as Error, + code: (connectionErr as Error).name, + }) + } + } catch (err) { + void vscode.window.showErrorMessage( + `SageMaker Unified Studio: Failed to initiate login: ${(err as Error).message}` + ) + logger.error('Failed to initiate login: %s', (err as Error).message) + throw new ToolkitError('Failed to initiate login.', { + cause: err as Error, + code: (err as Error).name, + }) + } + }) +}) + +/** + * Command to sign out from SageMaker Unified Studio + */ +export const smusSignOutCommand = Commands.declare('aws.smus.signOut', () => async () => { + const logger = getLogger() + return telemetry.smus_signOut.run(async (span) => { + try { + // Get the authentication provider instance + const authProvider = SmusAuthenticationProvider.fromContext() + + // Check if there's an active connection to sign out from + if (!authProvider.isConnected()) { + void vscode.window.showInformationMessage( + 'No active SageMaker Unified Studio connection to sign out from.' + ) + return + } + + // Get connection details for logging + const activeConnection = authProvider.activeConnection + const domainId = activeConnection?.domainId + const region = activeConnection?.ssoRegion + + // Show status message + vscode.window.setStatusBarMessage('Signing out from SageMaker Unified Studio...', 5000) + + span.record({ + smusDomainId: domainId, + awsRegion: region, + }) + + // Delete the connection (this will also invalidate tokens and clear cache) + if (activeConnection) { + await authProvider.secondaryAuth.deleteConnection() + logger.info(`Signed out from SageMaker Unified Studio${domainId}`) + } + + // Show success message + void vscode.window.showInformationMessage('Successfully signed out from SageMaker Unified Studio.') + + // Clear the status bar message + vscode.window.setStatusBarMessage('Signed out from SageMaker Unified Studio', 3000) + + // Refresh the tree view to show the sign-in state + try { + await vscode.commands.executeCommand('aws.smus.rootView.refresh') + } catch (refreshErr) { + logger.debug(`Failed to refresh views after sign out: ${(refreshErr as Error).message}`) + throw new ToolkitError('Failed to refresh views after sign out.', { + cause: refreshErr as Error, + code: (refreshErr as Error).name, + }) + } + } catch (err) { + void vscode.window.showErrorMessage( + `SageMaker Unified Studio: Failed to sign out: ${(err as Error).message}` + ) + logger.error('Failed to sign out: %s', (err as Error).message) + + // Log failure telemetry + throw new ToolkitError('Failed to sign out.', { + cause: err as Error, + code: (err as Error).name, + }) + } + }) +}) + +function isAccessDenied(error: Error): boolean { + return error.name.includes('AccessDenied') +} + +function createProjectQuickPickItems(projects: DataZoneProject[]) { + return projects + .sort( + (a, b) => + (b.updatedAt ? new Date(b.updatedAt).getTime() : 0) - + (a.updatedAt ? new Date(a.updatedAt).getTime() : 0) + ) + .filter((project) => project.name !== 'GenerativeAIModelGovernanceProject') + .map((project) => ({ + label: project.name, + detail: 'ID: ' + project.id, + description: project.description, + data: project, + })) +} + +async function showQuickPick(items: any[]) { + const quickPick = createQuickPick(items, { + title: projectPickerTitle, + placeholder: projectPickerPlaceholder, + }) + return await quickPick.prompt() +} + +export async function selectSMUSProject(projectNode?: SageMakerUnifiedStudioProjectNode) { + const logger = getLogger() + + return telemetry.smus_accessProject.run(async (span) => { + try { + const authProvider = SmusAuthenticationProvider.fromContext() + if (!authProvider.activeConnection) { + logger.error('No active connection to display project view') + return + } + + const client = await DataZoneClient.getInstance(authProvider) + logger.debug('DataZone client instance obtained successfully') + + const allProjects = await client.fetchAllProjects() + const items = createProjectQuickPickItems(allProjects) + + if (items.length === 0) { + logger.info('No projects found in the domain') + void vscode.window.showInformationMessage('No projects found in the domain') + await showQuickPick([{ label: 'No projects found', detail: '', description: '', data: {} }]) + return + } + + const selectedProject = await showQuickPick(items) + span.record({ + smusDomainId: authProvider.getDomainId(), + smusProjectId: (selectedProject as DataZoneProject).id as string | undefined, + smusDomainRegion: authProvider.getDomainRegion(), + }) + if ( + selectedProject && + typeof selectedProject === 'object' && + selectedProject !== null && + !('type' in selectedProject) && + projectNode + ) { + await projectNode.setProject(selectedProject) + await vscode.commands.executeCommand('aws.smus.rootView.refresh') + } + + return selectedProject + } catch (err) { + const error = err as Error + + if (isAccessDenied(error)) { + await showQuickPick([ + { + label: '$(error)', + description: "You don't have permissions to view projects. Please contact your administrator", + }, + ]) + return + } + + logger.error('Failed to select project: %s', error.message) + void vscode.window.showErrorMessage(`Failed to select project: ${error.message}`) + } + }) +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode.ts new file mode 100644 index 00000000000..53ae501d967 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode.ts @@ -0,0 +1,108 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { SagemakerClient, SagemakerSpaceApp } from '../../../shared/clients/sagemaker' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { SageMakerUnifiedStudioSpacesParentNode } from './sageMakerUnifiedStudioSpacesParentNode' +import { SagemakerSpace } from '../../../awsService/sagemaker/sagemakerSpace' + +export class SagemakerUnifiedStudioSpaceNode implements TreeNode { + private smSpace: SagemakerSpace + private readonly onDidChangeEmitter = new vscode.EventEmitter() + public readonly onDidChangeTreeItem = this.onDidChangeEmitter.event + public readonly onDidChangeChildren = this.onDidChangeEmitter.event + + public constructor( + private readonly parent: SageMakerUnifiedStudioSpacesParentNode, + public readonly sageMakerClient: SagemakerClient, + public readonly regionCode: string, + public readonly spaceApp: SagemakerSpaceApp, + isSMUSSpace: boolean + ) { + this.smSpace = new SagemakerSpace(this.sageMakerClient, this.regionCode, this.spaceApp, isSMUSSpace) + } + + public getTreeItem(): vscode.TreeItem { + return { + label: this.smSpace.label, + description: this.smSpace.description, + tooltip: this.smSpace.tooltip, + iconPath: this.smSpace.iconPath, + contextValue: this.smSpace.contextValue, + collapsibleState: vscode.TreeItemCollapsibleState.None, + } + } + + public getChildren(): TreeNode[] { + return [] + } + + public getParent(): TreeNode | undefined { + return this.parent + } + + public async refreshNode(): Promise { + this.onDidChangeEmitter.fire() + } + + public get id(): string { + return 'smusSpaceNode' + this.name + } + + public get resource() { + return this + } + + // Delegate all core functionality to SageMakerSpace instance + public updateSpace(spaceApp: SagemakerSpaceApp) { + this.smSpace.updateSpace(spaceApp) + if (this.isPending()) { + this.parent.trackPendingNode(this.DomainSpaceKey) + } + } + + public setSpaceStatus(spaceStatus: string, appStatus: string) { + this.smSpace.setSpaceStatus(spaceStatus, appStatus) + } + public isPending(): boolean { + return this.smSpace.isPending() + } + public getStatus(): string { + return this.smSpace.getStatus() + } + public async getAppStatus() { + return this.smSpace.getAppStatus() + } + public get name(): string { + return this.smSpace.name + } + public get arn(): string { + return this.smSpace.arn + } + public async getAppArn() { + return this.smSpace.getAppArn() + } + public async getSpaceArn() { + return this.smSpace.getSpaceArn() + } + public async updateSpaceAppStatus() { + await this.smSpace.updateSpaceAppStatus() + + if (this.isPending()) { + this.parent.trackPendingNode(this.DomainSpaceKey) + } + return + } + public buildTooltip() { + return this.smSpace.buildTooltip() + } + public getAppIcon() { + return this.smSpace.getAppIcon() + } + public get DomainSpaceKey(): string { + return this.smSpace.DomainSpaceKey + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode.ts new file mode 100644 index 00000000000..4531c117978 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode.ts @@ -0,0 +1,231 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioComputeNode } from './sageMakerUnifiedStudioComputeNode' +import { updateInPlace } from '../../../shared/utilities/collectionUtils' +import { DataZoneClient } from '../../shared/client/datazoneClient' +import { DescribeDomainResponse } from '@amzn/sagemaker-client' +import { getDomainUserProfileKey } from '../../../awsService/sagemaker/utils' +import { getLogger } from '../../../shared/logger/logger' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { SagemakerClient, SagemakerSpaceApp } from '../../../shared/clients/sagemaker' +import { UserProfileMetadata } from '../../../awsService/sagemaker/explorer/sagemakerParentNode' +import { SagemakerUnifiedStudioSpaceNode } from './sageMakerUnifiedStudioSpaceNode' +import { PollingSet } from '../../../shared/utilities/pollingSet' +import { SmusAuthenticationProvider } from '../../auth/providers/smusAuthenticationProvider' +import { SmusUtils } from '../../shared/smusUtils' +import { getIcon } from '../../../shared/icons' + +export class SageMakerUnifiedStudioSpacesParentNode implements TreeNode { + public readonly id = 'smusSpacesParentNode' + public readonly resource = this + private readonly sagemakerSpaceNodes: Map = new Map() + private spaceApps: Map = new Map() + private domainUserProfiles: Map = new Map() + private readonly logger = getLogger() + private readonly onDidChangeEmitter = new vscode.EventEmitter() + public readonly onDidChangeTreeItem = this.onDidChangeEmitter.event + public readonly onDidChangeChildren = this.onDidChangeEmitter.event + public readonly pollingSet: PollingSet = new PollingSet(5, this.updatePendingNodes.bind(this)) + private spaceAwsAccountRegion: string | undefined + + public constructor( + private readonly parent: SageMakerUnifiedStudioComputeNode, + private readonly projectId: string, + private readonly extensionContext: vscode.ExtensionContext, + private readonly authProvider: SmusAuthenticationProvider, + private readonly sagemakerClient: SagemakerClient + ) {} + + public async getTreeItem(): Promise { + const item = new vscode.TreeItem('Spaces', vscode.TreeItemCollapsibleState.Expanded) + item.iconPath = { + light: vscode.Uri.joinPath( + this.extensionContext.extensionUri, + 'resources/icons/aws/sagemakerunifiedstudio/spaces-dark.svg' + ), + dark: vscode.Uri.joinPath( + this.extensionContext.extensionUri, + 'resources/icons/aws/sagemakerunifiedstudio/spaces.svg' + ), + } + item.contextValue = 'smusSpacesNode' + item.description = 'Hover over any space and click the connection icon to connect remotely' + item.tooltip = item.description + return item + } + + public async getChildren(): Promise { + try { + await this.updateChildren() + } catch (err) { + const error = err as Error + if (error.name === 'AccessDeniedException') { + return this.getAccessDeniedChildren() + } + return this.getNoSpacesFoundChildren() + } + const nodes = [...this.sagemakerSpaceNodes.values()] + if (nodes.length === 0) { + return this.getNoSpacesFoundChildren() + } + return nodes + } + + private getNoSpacesFoundChildren(): TreeNode[] { + return [ + { + id: 'smusNoSpaces', + resource: {}, + getTreeItem: () => new vscode.TreeItem('[No Spaces found]', vscode.TreeItemCollapsibleState.None), + getParent: () => this, + }, + ] + } + + private getAccessDeniedChildren(): TreeNode[] { + return [ + { + id: 'smusAccessDenied', + resource: {}, + getTreeItem: () => { + const item = new vscode.TreeItem( + "You don't have permission to view spaces. Please contact your administrator.", + vscode.TreeItemCollapsibleState.None + ) + item.iconPath = getIcon('vscode-error') + return item + }, + getParent: () => this, + }, + ] + } + + public getParent(): TreeNode | undefined { + return this.parent + } + + public getProjectId(): string { + return this.projectId + } + + public getAuthProvider(): SmusAuthenticationProvider { + return this.authProvider + } + + public async refreshNode(): Promise { + this.onDidChangeEmitter.fire() + } + + public trackPendingNode(domainSpaceKey: string) { + this.pollingSet.add(domainSpaceKey) + } + + public getSpaceNodes(spaceKey: string): SagemakerUnifiedStudioSpaceNode { + const childNode = this.sagemakerSpaceNodes.get(spaceKey) + if (childNode) { + return childNode + } else { + throw new Error(`Node with id ${spaceKey} from polling set not found`) + } + } + + public async getSageMakerDomainId(): Promise { + const activeConnection = this.authProvider.activeConnection + if (!activeConnection) { + this.logger.error('There is no active connection to get SageMaker domain ID') + throw new Error('No active connection found to get SageMaker domain ID') + } + + this.logger.debug('SMUS: Getting DataZone client instance') + const datazoneClient = await DataZoneClient.getInstance(this.authProvider) + if (!datazoneClient) { + throw new Error('DataZone client is not initialized') + } + + const toolingEnv = await datazoneClient.getToolingEnvironment(this.projectId) + this.spaceAwsAccountRegion = toolingEnv.awsAccountRegion + if (toolingEnv.provisionedResources) { + for (const resource of toolingEnv.provisionedResources) { + if (resource.name === 'sageMakerDomainId') { + if (!resource.value) { + throw new Error('SageMaker domain ID not found in tooling environment') + } + getLogger().debug(`Found SageMaker domain ID: ${resource.value}`) + return resource.value + } + } + } + throw new Error('No SageMaker domain found in the tooling environment') + } + + private async updatePendingNodes() { + for (const spaceKey of this.pollingSet.values()) { + const childNode = this.getSpaceNodes(spaceKey) + await this.updatePendingSpaceNode(childNode) + } + } + + private async updatePendingSpaceNode(node: SagemakerUnifiedStudioSpaceNode) { + await node.updateSpaceAppStatus() + if (!node.isPending()) { + this.pollingSet.delete(node.DomainSpaceKey) + await node.refreshNode() + } + } + + private async updateChildren(): Promise { + const datazoneClient = await DataZoneClient.getInstance(this.authProvider) + // Will be of format: 'ABCA4NU3S7PEOLDQPLXYZ:user-12345678-d061-70a4-0bf2-eeee67a6ab12' + const userId = await datazoneClient.getUserId() + const ssoUserProfileId = SmusUtils.extractSSOIdFromUserId(userId || '') + const sagemakerDomainId = await this.getSageMakerDomainId() + const [spaceApps, domains] = await this.sagemakerClient.fetchSpaceAppsAndDomains( + sagemakerDomainId, + false /* filterSmusDomains */ + ) + // Filter spaceApps to only show spaces owned by current user + const filteredSpaceApps = new Map() + for (const [key, app] of spaceApps.entries()) { + const userProfile = app.OwnershipSettingsSummary?.OwnerUserProfileName + if (ssoUserProfileId === userProfile) { + filteredSpaceApps.set(key, app) + } + } + this.spaceApps = filteredSpaceApps + this.domainUserProfiles.clear() + + for (const app of this.spaceApps.values()) { + const domainId = app.DomainId + const userProfile = app.OwnershipSettingsSummary?.OwnerUserProfileName + if (!domainId || !userProfile) { + continue + } + + const domainUserProfileKey = getDomainUserProfileKey(domainId, userProfile) + this.domainUserProfiles.set(domainUserProfileKey, { + domain: domains.get(domainId) as DescribeDomainResponse, + }) + } + + updateInPlace( + this.sagemakerSpaceNodes, + this.spaceApps.keys(), + (key) => this.sagemakerSpaceNodes.get(key)!.updateSpace(this.spaceApps.get(key)!), + (key) => + new SagemakerUnifiedStudioSpaceNode( + this as any, + this.sagemakerClient, + this.spaceAwsAccountRegion || + (() => { + throw new Error('No AWS account region found in tooling environment') + })(), + this.spaceApps.get(key)!, + true /* isSMUSSpace */ + ) + ) + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/types.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/types.ts new file mode 100644 index 00000000000..a94d25fccc4 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/types.ts @@ -0,0 +1,207 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Node delimiter for creating unique IDs +// eslint-disable-next-line @typescript-eslint/naming-convention +export const NODE_ID_DELIMITER = '/' + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const AWS_DATA_CATALOG = 'AwsDataCatalog' +// eslint-disable-next-line @typescript-eslint/naming-convention +export const DATA_DEFAULT_IAM_CONNECTION_NAME_REGEXP = /^(project\.iam)|(default\.iam)$/ +// eslint-disable-next-line @typescript-eslint/naming-convention, id-length +export const DATA_DEFAULT_LAKEHOUSE_CONNECTION_NAME_REGEXP = /^(project\.default_lakehouse)|(default\.catalog)$/ +// eslint-disable-next-line @typescript-eslint/naming-convention, id-length +export const DATA_DEFAULT_ATHENA_CONNECTION_NAME_REGEXP = /^(project\.athena)|(default\.sql)$/ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const DATA_DEFAULT_S3_CONNECTION_NAME_REGEXP = /^(project\.s3_default_folder)|(default\.s3)$/ + +// Database object types +export enum DatabaseObjects { + EXTERNAL_TABLE = 'EXTERNAL_TABLE', + VIRTUAL_VIEW = 'VIRTUAL_VIEW', +} + +// Ref: https://docs.aws.amazon.com/athena/latest/ug/data-types.html +export const lakeHouseColumnTypes = { + NUMERIC: ['TINYINT', 'SMALLINT', 'INT', 'INTEGER', 'BIGINT', 'FLOAT', 'REAL', 'DOUBLE', 'DECIMAL'], + STRING: ['CHAR', 'STRING', 'VARCHAR', 'UUID'], + TIME: ['DATE', 'TIMESTAMP', 'INTERVAL'], + BOOLEAN: ['BOOLEAN'], + BINARY: ['BINARY', 'VARBINARY'], + COMPLEX: ['ARRAY', 'MAP', 'STRUCT', 'ROW', 'JSON'], +} + +// Ref: https://docs.aws.amazon.com/redshift/latest/dg/c_Supported_data_types.html +export const redshiftColumnTypes = { + NUMERIC: ['SMALLINT', 'INT2', 'INTEGER', 'INT', 'BIGINT', 'DECIMAL', 'NUMERIC', 'REAL', 'FLOAT', 'DOUBLE'], + STRING: ['CHAR', 'CHARACTER', 'NCHAR', 'BPCHAR', 'VARCHAR', 'VARCHAR', 'VARYING', 'NVARCHAR', 'TEXT'], + TIME: ['TIME', 'TIMETZ', 'TIMESTAMP', 'TIMESTAMPTZ', 'INTERVAL'], + BOOLEAN: ['BOOLEAN', 'BOOL'], + BINARY: ['VARBYTE', 'VARBINARY', 'BINARY', 'VARYING'], + COMPLEX: ['HLLSKETCH', 'SUPER', 'GEOMETRY', 'GEOGRAPHY'], +} + +/** + * Node types for different resources + */ +export enum NodeType { + // Common types + CONNECTION = 'connection', + ERROR = 'error', + LOADING = 'loading', + EMPTY = 'empty', + + // S3 types + S3_BUCKET = 's3-bucket', + S3_FOLDER = 'folder', + S3_FILE = 'file', + S3_ACCESS_GRANT = 's3-access-grant', + + // Redshift types + REDSHIFT_CLUSTER = 'redshift-cluster', + REDSHIFT_DATABASE = 'database', + REDSHIFT_SCHEMA = 'schema', + REDSHIFT_TABLE = 'table', + REDSHIFT_VIEW = 'view', + REDSHIFT_FUNCTION = 'function', + REDSHIFT_STORED_PROCEDURE = 'storedProcedure', + REDSHIFT_COLUMN = 'column', + REDSHIFT_CONTAINER = 'container', + + // Glue types + GLUE_CATALOG = 'catalog', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + GLUE_DATABASE = 'database', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + GLUE_TABLE = 'table', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + GLUE_VIEW = 'view', + + // Redshift-specific catalog types + REDSHIFT_CATALOG = 'redshift-catalog', + REDSHIFT_CATALOG_DATABASE = 'redshift-catalog-database', +} + +/** + * Connection types + */ +export enum ConnectionType { + S3 = 'S3', + REDSHIFT = 'REDSHIFT', + ATHENA = 'ATHENA', + GLUE = 'GLUE', + LAKEHOUSE = 'LAKEHOUSE', +} + +/** + * Resource types for Redshift + */ +export enum ResourceType { + DATABASE = 'DATABASE', + CATALOG_DATABASE = 'CATALOG_DATABASE', + SCHEMA = 'SCHEMA', + TABLE = 'TABLE', + VIEW = 'VIEW', + FUNCTION = 'FUNCTION', + STORED_PROCEDURE = 'STORED_PROCEDURE', + COLUMNS = 'COLUMNS', + CATALOG = 'CATALOG', + EXTERNAL_DATABASE = 'EXTERNAL_DATABASE', + SHARED_DATABASE = 'SHARED_DATABASE', + EXTERNAL_SCHEMA = 'EXTERNAL_SCHEMA', + SHARED_SCHEMA = 'SHARED_SCHEMA', + EXTERNAL_TABLE = 'EXTERNAL_TABLE', + CATALOG_TABLE = 'CATALOG_TABLE', + DATA_CATALOG_TABLE = 'DATA_CATALOG_TABLE', + CATALOG_COLUMN = 'CATALOG_COLUMN', +} + +/** + * Node path information + */ +export interface NodePath { + connection?: string + bucket?: string + key?: string + catalog?: string + database?: string + schema?: string + table?: string + column?: string + cluster?: string + label?: string + [key: string]: any +} + +/** + * Node data interface for tree nodes + */ +export interface NodeData { + id: string + nodeType: NodeType + connectionType?: ConnectionType + value?: any + path?: NodePath + parent?: any + isContainer?: boolean + children?: any[] +} + +/** + * Redshift deployment types + */ +export enum RedshiftType { + Serverless = 'SERVERLESS', + ServerlessDev = 'SERVERLESS_DEV', + ServerlessQA = 'SERVERLESS_QA', + Cluster = 'CLUSTER', + ClusterDev = 'CLUSTER_DEV', + ClusterQA = 'CLUSTER_QA', +} + +/** + * Authentication types for database integration connections + */ +export enum DatabaseIntegrationConnectionAuthenticationTypes { + FEDERATED = '4', + TEMPORARY_CREDENTIALS_WITH_IAM = '5', + SECRET = '6', + IDC_ENHANCED_IAM_CREDENTIALS = '8', +} + +/** + * Redshift service model URLs + */ +export const RedshiftServiceModelUrl = { + REDSHIFT_SERVERLESS_URL: 'redshift-serverless.amazonaws.com', + REDSHIFT_CLUSTER_URL: 'redshift.amazonaws.com', +} + +/** + * Client types for ClientStore + */ +export enum ClientType { + S3Client = 'S3Client', + S3ControlClient = 'S3ControlClient', + SQLWorkbenchClient = 'SQLWorkbenchClient', + GlueClient = 'GlueClient', + GlueCatalogClient = 'GlueCatalogClient', +} + +/** + * Node types that are always leaf nodes + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const LEAF_NODE_TYPES = [ + NodeType.S3_FILE, + NodeType.REDSHIFT_COLUMN, + NodeType.ERROR, + NodeType.LOADING, + NodeType.EMPTY, +] + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const NO_DATA_FOUND_MESSAGE = '[No data found]' diff --git a/packages/core/src/sagemakerunifiedstudio/explorer/nodes/utils.ts b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/utils.ts new file mode 100644 index 00000000000..10b52f83728 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/explorer/nodes/utils.ts @@ -0,0 +1,385 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { getIcon, IconPath, addColor } from '../../../shared/icons' +import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' +import { + NODE_ID_DELIMITER, + NodeType, + RedshiftServiceModelUrl, + RedshiftType, + ConnectionType, + NodeData, + LEAF_NODE_TYPES, + DATA_DEFAULT_LAKEHOUSE_CONNECTION_NAME_REGEXP, + redshiftColumnTypes, + lakeHouseColumnTypes, +} from './types' +import { DataZoneConnection } from '../../shared/client/datazoneClient' + +/** + * Gets the label for a node based on its data + */ +export function getLabel(data: { + id: string + nodeType: NodeType + isContainer?: boolean + path?: { key?: string; label?: string } + value?: any +}): string { + // For S3 access grant nodes, use S3 (label) format + if (data.nodeType === NodeType.S3_ACCESS_GRANT && data.path?.label) { + return `S3 (${data.path.label})` + } + + // For connection nodes, use the connection name + if (data.nodeType === NodeType.CONNECTION && data.value?.connection?.name) { + if ( + data.value?.connection?.type === ConnectionType.LAKEHOUSE && + DATA_DEFAULT_LAKEHOUSE_CONNECTION_NAME_REGEXP.test(data.value?.connection?.name) + ) { + return 'Lakehouse' + } + const formattedType = data.value?.connection?.type?.replace(/([A-Z]+(?:_[A-Z]+)*)/g, (match: string) => { + const words = match.split('_') + return words.map((word: string) => word.charAt(0) + word.slice(1).toLowerCase()).join(' ') + }) + return `${formattedType} (${data.value.connection.name})` + } + + // For container nodes, use the node type + if (data.isContainer) { + switch (data.nodeType) { + case NodeType.REDSHIFT_TABLE: + return 'Tables' + case NodeType.REDSHIFT_VIEW: + return 'Views' + case NodeType.REDSHIFT_FUNCTION: + return 'Functions' + case NodeType.REDSHIFT_STORED_PROCEDURE: + return 'Stored Procedures' + default: + return data.nodeType + } + } + + // For path-based nodes, use the last part of the path + if (data.path?.label) { + return data.path.label + } + + // For S3 folders, add a trailing slash + if (data.nodeType === NodeType.S3_FOLDER) { + const key = data.path?.key || '' + const parts = key.split('/') + return parts[parts.length - 2] + '/' + } + + // For S3 files, use the filename + if (data.nodeType === NodeType.S3_FILE) { + const key = data.path?.key || '' + const parts = key.split('/') + return parts[parts.length - 1] + } + + // For other nodes, use the last part of the ID + const parts = data.id.split(NODE_ID_DELIMITER) + return parts[parts.length - 1] +} + +/** + * Determines if a node is a leaf node + */ +export function isLeafNode(data: { nodeType: NodeType; isContainer?: boolean }): boolean { + // Container nodes are never leaf nodes + if (data.isContainer) { + return false + } + + return LEAF_NODE_TYPES.includes(data.nodeType) +} + +/** + * Gets the icon for a node type + */ +export function getIconForNodeType(nodeType: NodeType, isContainer?: boolean): vscode.ThemeIcon | IconPath | undefined { + switch (nodeType) { + case NodeType.CONNECTION: + case NodeType.S3_ACCESS_GRANT: + return undefined + case NodeType.S3_BUCKET: + return getIcon('aws-s3-bucket') + case NodeType.S3_FOLDER: + return getIcon('vscode-folder') + case NodeType.S3_FILE: + return getIcon('vscode-file') + case NodeType.REDSHIFT_CLUSTER: + return getIcon('aws-redshift-cluster') + case NodeType.REDSHIFT_DATABASE: + case NodeType.GLUE_DATABASE: + return new vscode.ThemeIcon('database') + case NodeType.REDSHIFT_SCHEMA: + return getIcon('aws-redshift-schema') + case NodeType.REDSHIFT_TABLE: + case NodeType.GLUE_TABLE: + return isContainer ? new vscode.ThemeIcon('table') : getIcon('aws-redshift-table') + case NodeType.REDSHIFT_VIEW: + return isContainer ? new vscode.ThemeIcon('list-tree') : new vscode.ThemeIcon('eye') + case NodeType.REDSHIFT_FUNCTION: + case NodeType.REDSHIFT_STORED_PROCEDURE: + return isContainer ? new vscode.ThemeIcon('list-tree') : new vscode.ThemeIcon('symbol-method') + case NodeType.GLUE_CATALOG: + return getIcon('aws-sagemakerunifiedstudio-catalog') + case NodeType.REDSHIFT_CATALOG: + return new vscode.ThemeIcon('database') + case NodeType.REDSHIFT_CATALOG_DATABASE: + return getIcon('aws-redshift-schema') + case NodeType.ERROR: + return new vscode.ThemeIcon('error') + case NodeType.LOADING: + return new vscode.ThemeIcon('loading~spin') + case NodeType.EMPTY: + return new vscode.ThemeIcon('info') + default: + return getIcon('vscode-circle-outline') + } +} + +/** + * Creates a standard tree item for a node + */ +export function createTreeItem( + label: string, + nodeType: NodeType, + isLeaf: boolean, + isContainer?: boolean, + tooltip?: string +): vscode.TreeItem { + const collapsibleState = isLeaf ? vscode.TreeItemCollapsibleState.None : vscode.TreeItemCollapsibleState.Collapsed + + const item = new vscode.TreeItem(label, collapsibleState) + + // Set icon based on node type + item.iconPath = getIconForNodeType(nodeType, isContainer) + + // Set context value for command enablement + item.contextValue = nodeType + + // Set tooltip if provided + if (tooltip) { + item.tooltip = tooltip + } + + return item +} + +/** + * Gets the column type category from a raw column type string + */ +export function getColumnType(columnTypeString?: string): string { + if (!columnTypeString) { + return 'UNKNOWN' + } + + const lowerType = columnTypeString.toLowerCase() + + // Search in both redshift and lakehouse column types + const allTypes = [...Object.values(redshiftColumnTypes).flat(), ...Object.values(lakeHouseColumnTypes).flat()].map( + (type) => type.toLowerCase() + ) + + return allTypes.find((key) => lowerType.startsWith(key)) || 'UNKNOWN' +} + +/** + * Gets the icon for a column based on its type + */ +function getColumnIcon(columnType: string): vscode.ThemeIcon | IconPath { + const upperType = columnType.toUpperCase() + + // Check if it's a numeric type + if ( + lakeHouseColumnTypes.NUMERIC.some((type) => upperType.includes(type)) || + redshiftColumnTypes.NUMERIC.some((type) => upperType.includes(type)) + ) { + return getIcon('aws-sagemakerunifiedstudio-symbol-int') + } + + // Check if it's a string type + if ( + lakeHouseColumnTypes.STRING.some((type) => upperType.includes(type)) || + redshiftColumnTypes.STRING.some((type) => upperType.includes(type)) + ) { + return getIcon('vscode-symbol-key') + } + + // Check if it's a time type + if ( + lakeHouseColumnTypes.TIME.some((type) => upperType.includes(type)) || + redshiftColumnTypes.TIME.some((type) => upperType.includes(type)) + ) { + return getIcon('vscode-calendar') + } + + // Default icon for unknown types + return new vscode.ThemeIcon('symbol-field') +} + +/** + * Creates a tree item for a column node with type information + */ +export function createColumnTreeItem(label: string, columnType: string, nodeType: NodeType): vscode.TreeItem { + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None) + + // Add column type as description (secondary text) + item.description = columnType + + // Set icon based on column type + item.iconPath = getColumnIcon(columnType) + + // Set context value for command enablement + item.contextValue = nodeType + + // Set tooltip + item.tooltip = `${label}: ${columnType}` + + return item +} + +/** + * Creates an error node + */ +export function createErrorTreeItem(message: string): vscode.TreeItem { + const item = new vscode.TreeItem(message, vscode.TreeItemCollapsibleState.None) + item.iconPath = new vscode.ThemeIcon('error') + return item +} + +/** + * Creates an error item with unique ID and proper styling + */ +export function createErrorItem(message: string, context: string, parentId: string): TreeNode { + return { + id: `${parentId}-error-${context}-${Date.now()}`, + resource: message, + getTreeItem: () => { + const item = new vscode.TreeItem(message, vscode.TreeItemCollapsibleState.None) + item.iconPath = addColor(getIcon('vscode-error'), 'testing.iconErrored') + return item + }, + } +} + +export const isRedLakeDatabase = (databaseName?: string) => { + if (!databaseName) { + return false + } + const regex = /[\w\d\-_]+@[\w\d\-_]+/gs + return regex.test(databaseName) +} + +/** + * Gets the tooltip for a node + * @param data The node data + * @returns The tooltip text + */ +export function getTooltip(data: NodeData): string { + const label = getLabel(data) + + switch (data.nodeType) { + // Common node types + case NodeType.CONNECTION: + return data.connectionType === ConnectionType.REDSHIFT + ? `Redshift Connection: ${label}` + : `Connection: ${label}\nType: ${data.connectionType}` + + // S3 node types + case NodeType.S3_BUCKET: + return `S3 Bucket: ${data.path?.bucket}` + case NodeType.S3_FOLDER: + return `Folder: ${label}\nBucket: ${data.path?.bucket}` + case NodeType.S3_FILE: + return `File: ${label}\nBucket: ${data.path?.bucket}` + + // Redshift node types + case NodeType.REDSHIFT_CLUSTER: + return `Redshift Cluster: ${label}` + case NodeType.REDSHIFT_DATABASE: + return `Database: ${label}` + case NodeType.REDSHIFT_SCHEMA: + return `Schema: ${label}` + case NodeType.REDSHIFT_TABLE: + return data.isContainer ? `Tables in ${data.path?.schema}` : `Table: ${data.path?.schema}.${label}` + case NodeType.REDSHIFT_VIEW: + return data.isContainer ? `Views in ${data.path?.schema}` : `View: ${data.path?.schema}.${label}` + case NodeType.REDSHIFT_FUNCTION: + return data.isContainer ? `Functions in ${data.path?.schema}` : `Function: ${data.path?.schema}.${label}` + case NodeType.REDSHIFT_STORED_PROCEDURE: + return data.isContainer + ? `Stored Procedures in ${data.path?.schema}` + : `Stored Procedure: ${data.path?.schema}.${label}` + + // Glue node types + case NodeType.GLUE_CATALOG: + return `Glue Catalog: ${label}` + case NodeType.GLUE_DATABASE: + return `Glue Database: ${label}` + case NodeType.GLUE_TABLE: + return `Glue Table: ${label}` + + // Default + default: + return label + } +} + +/** + * Gets the Redshift type from a host + * @param host Redshift host + * @returns Redshift type or null if not recognized + */ +export function getRedshiftTypeFromHost(host?: string): RedshiftType | undefined { + /* + 'default-workgroup.{accountID}.us-west-2.redshift-serverless.amazonaws.com' - SERVERLESS + 'default-rs-cluster.{id}.us-west-2.redshift.amazonaws.com' - CLUSTER + 'default-rs-cluster.{id}.us-west-2.redshift.amazonaws.com:5439/dev' - CLUSTER + */ + if (!host) { + return undefined + } + + const cleanHost = host.split(':')[0] + const parts = cleanHost.split('.') + if (parts.length < 3) { + return undefined + } + + const domain = parts.slice(parts.length - 3).join('.') + + if (domain === RedshiftServiceModelUrl.REDSHIFT_SERVERLESS_URL) { + return RedshiftType.Serverless + } else if (domain === RedshiftServiceModelUrl.REDSHIFT_CLUSTER_URL) { + return RedshiftType.Cluster + } else { + return undefined + } +} + +/** + * Determines if a connection is a federated connection by checking its type. + * A connection is considered federated if it's either: + * 1. A Redshift connection with Glue properties, or + * 2. A connection type that exists in GlueConnectionType + * + * @param connection + * @returns - boolean + */ +export function isFederatedConnection(connection?: DataZoneConnection): boolean { + if (connection?.type === ConnectionType.REDSHIFT) { + return !!connection?.props?.glueProperties + } + return false +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/README.md b/packages/core/src/sagemakerunifiedstudio/shared/client/README.md new file mode 100644 index 00000000000..17cc4767beb --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/README.md @@ -0,0 +1 @@ +# Common business logic and APIs for SageMaker Unified Studio features diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/connectionClientStore.ts b/packages/core/src/sagemakerunifiedstudio/shared/client/connectionClientStore.ts new file mode 100644 index 00000000000..edf317f6479 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/connectionClientStore.ts @@ -0,0 +1,138 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { S3Client } from './s3Client' +import { SQLWorkbenchClient } from './sqlWorkbenchClient' +import { GlueClient } from './glueClient' +import { GlueCatalogClient } from './glueCatalogClient' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' +import { ClientType } from '../../explorer/nodes/types' +import { S3ControlClient } from '@aws-sdk/client-s3-control' +import { getLogger } from '../../../shared/logger/logger' + +/** + * Client store for managing service clients per connection + */ +export class ConnectionClientStore { + private static instance: ConnectionClientStore + private clientCache: Record> = {} + + private constructor() {} + + public static getInstance(): ConnectionClientStore { + if (!ConnectionClientStore.instance) { + ConnectionClientStore.instance = new ConnectionClientStore() + } + return ConnectionClientStore.instance + } + + /** + * Gets or creates a client for a specific connection + */ + public getClient(connectionId: string, clientType: string, factory: () => T): T { + if (!this.clientCache[connectionId]) { + this.clientCache[connectionId] = {} + } + + if (!this.clientCache[connectionId][clientType]) { + this.clientCache[connectionId][clientType] = factory() + } + + return this.clientCache[connectionId][clientType] + } + + /** + * Gets or creates an S3Client for a connection + */ + public getS3Client( + connectionId: string, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider + ): S3Client { + return this.getClient( + connectionId, + ClientType.S3Client, + () => new S3Client(region, connectionCredentialsProvider) + ) + } + + /** + * Gets or creates a SQLWorkbenchClient for a connection + */ + public getSQLWorkbenchClient( + connectionId: string, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider + ): SQLWorkbenchClient { + return this.getClient(connectionId, ClientType.SQLWorkbenchClient, () => + SQLWorkbenchClient.createWithCredentials(region, connectionCredentialsProvider) + ) + } + + /** + * Gets or creates a GlueClient for a connection + */ + public getGlueClient( + connectionId: string, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider + ): GlueClient { + return this.getClient( + connectionId, + ClientType.GlueClient, + () => new GlueClient(region, connectionCredentialsProvider) + ) + } + + /** + * Gets or creates a GlueCatalogClient for a connection + */ + public getGlueCatalogClient( + connectionId: string, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider + ): GlueCatalogClient { + return this.getClient(connectionId, ClientType.GlueCatalogClient, () => + GlueCatalogClient.createWithCredentials(region, connectionCredentialsProvider) + ) + } + + /** + * Gets or creates an S3ControlClient for a connection + */ + public getS3ControlClient( + connectionId: string, + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider + ): S3ControlClient { + return this.getClient(connectionId, ClientType.S3ControlClient, () => { + const credentialsProvider = async () => { + const credentials = await connectionCredentialsProvider.getCredentials() + return { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + expiration: credentials.expiration, + } + } + return new S3ControlClient({ region, credentials: credentialsProvider }) + }) + } + + /** + * Clears all cached clients for a connection + */ + public clearConnection(connectionId: string): void { + delete this.clientCache[connectionId] + } + + /** + * Clears all cached clients + */ + public clearAll(): void { + getLogger().info('SMUS Connection: Clearing all cached clients') + this.clientCache = {} + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/credentialsAdapter.ts b/packages/core/src/sagemakerunifiedstudio/shared/client/credentialsAdapter.ts new file mode 100644 index 00000000000..88d08c93b86 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/credentialsAdapter.ts @@ -0,0 +1,60 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as AWS from 'aws-sdk' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' +import { getLogger } from '../../../shared/logger/logger' + +/** + * Adapts a ConnectionCredentialsProvider (SDK v3) to work with SDK v2's CredentialProviderChain + */ +export function adaptConnectionCredentialsProvider( + connectionCredentialsProvider: ConnectionCredentialsProvider +): AWS.CredentialProviderChain { + const provider = () => { + // Create SDK v2 Credentials that will resolve the provider when needed + const credentials = new AWS.Credentials({ + accessKeyId: '', + secretAccessKey: '', + sessionToken: '', + }) + + // Override the get method to use the connection credentials provider + credentials.get = (callback) => { + getLogger().debug('Attempting to get credentials from ConnectionCredentialsProvider') + + connectionCredentialsProvider + .getCredentials() + .then((creds) => { + getLogger().debug('Successfully got credentials') + + credentials.accessKeyId = creds.accessKeyId as string + credentials.secretAccessKey = creds.secretAccessKey as string + credentials.sessionToken = creds.sessionToken as string + credentials.expireTime = creds.expiration as Date + callback() + }) + .catch((err) => { + getLogger().debug(`Failed to get credentials: ${err}`) + + callback(err) + }) + } + + // Override needsRefresh to delegate to the connection credentials provider + credentials.needsRefresh = () => { + return true // Always call refresh, this is okay because there is caching existing in credential provider + } + + // Override refresh to use the connection credentials provider + credentials.refresh = (callback) => { + credentials.get(callback) + } + + return credentials + } + + return new AWS.CredentialProviderChain([provider]) +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/datazoneClient.ts b/packages/core/src/sagemakerunifiedstudio/shared/client/datazoneClient.ts new file mode 100644 index 00000000000..ffa0e7bfbf3 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/datazoneClient.ts @@ -0,0 +1,792 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ConnectionCredentials, + ConnectionSummary, + DataZone, + GetConnectionCommandOutput, + GetEnvironmentCredentialsCommandOutput, + ListConnectionsCommandOutput, + PhysicalEndpoint, + RedshiftPropertiesOutput, + S3PropertiesOutput, + ConnectionType, + GluePropertiesOutput, + GetEnvironmentCommandOutput, +} from '@aws-sdk/client-datazone' +import { getLogger } from '../../../shared/logger/logger' +import type { SmusAuthenticationProvider } from '../../auth/providers/smusAuthenticationProvider' +import { DefaultStsClient } from '../../../shared/clients/stsClient' + +/** + * Represents a DataZone project + */ +export interface DataZoneProject { + id: string + name: string + description?: string + domainId: string + createdAt?: Date + updatedAt?: Date +} + +/** + * Represents JDBC connection properties + */ +export interface JdbcConnection { + jdbcIamUrl?: string + jdbcUrl?: string + username?: string + password?: string + secretId?: string + isProvisionedSecret?: boolean + redshiftTempDir?: string + host?: string + engine?: string + port?: number + dbname?: string + [key: string]: any +} + +/** + * Represents a DataZone connection + */ +export interface DataZoneConnection { + connectionId: string + name: string + description?: string + type: string + domainId: string + environmentId?: string + projectId: string + props?: { + s3Properties?: S3PropertiesOutput + redshiftProperties?: RedshiftPropertiesOutput + glueProperties?: GluePropertiesOutput + jdbcConnection?: JdbcConnection + [key: string]: any + } + /** + * Connection credentials when retrieved with withSecret=true + */ + connectionCredentials?: ConnectionCredentials + /** + * Location information parsed from physical endpoints + */ + location?: { + accessRole?: string + awsRegion?: string + awsAccountId?: string + iamConnectionId?: string + } +} + +// Constants for DataZone environment configuration +const toolingBlueprintName = 'Tooling' +const sageMakerProviderName = 'Amazon SageMaker' + +/** + * Client for interacting with AWS DataZone API with DER credential support + * + * This client integrates with SmusAuthenticationProvider to provide authenticated + * DataZone operations using Domain Execution Role (DER) credentials. + * + * One instance per connection/domainId is maintained to avoid duplication. + */ +export class DataZoneClient { + /** + * Parse a Redshift connection info object from JDBC URL + * @param jdbcURL Example JDBC URL: jdbc:redshift://redshift-serverless-workgroup-3zzw0fjmccdixz.123456789012.us-east-1.redshift-serverless.amazonaws.com:5439/dev + * @returns A object contains info of host, engine, port, dbName + */ + private getRedshiftConnectionInfoFromJdbcURL(jdbcURL: string) { + if (!jdbcURL) { + return + } + + const [, engine, hostWithLeadingSlashes, portAndDBName] = jdbcURL.split(':') + const [port, dbName] = portAndDBName.split('/') + return { + host: hostWithLeadingSlashes.split('/')[2], + engine, + port, + dbName, + } + } + + /** + * Builds a JDBC connection object from Redshift properties + * @param redshiftProps The Redshift properties + * @returns A JDBC connection object + */ + private buildJdbcConnectionFromRedshiftProps(redshiftProps: RedshiftPropertiesOutput): JdbcConnection { + const redshiftConnectionInfo = this.getRedshiftConnectionInfoFromJdbcURL(redshiftProps.jdbcUrl ?? '') + + return { + jdbcIamUrl: redshiftProps.jdbcIamUrl, + jdbcUrl: redshiftProps.jdbcUrl, + username: redshiftProps.credentials?.usernamePassword?.username, + password: redshiftProps.credentials?.usernamePassword?.password, + secretId: redshiftProps.credentials?.secretArn, + isProvisionedSecret: redshiftProps.isProvisionedSecret, + redshiftTempDir: redshiftProps.redshiftTempDir, + host: redshiftConnectionInfo?.host, + engine: redshiftConnectionInfo?.engine, + port: Number(redshiftConnectionInfo?.port), + dbname: redshiftConnectionInfo?.dbName, + } + } + + private datazoneClient: DataZone | undefined + private static instances = new Map() + private readonly logger = getLogger() + + private constructor( + private readonly authProvider: SmusAuthenticationProvider, + private readonly domainId: string, + private readonly region: string + ) {} + + /** + * Gets an authenticated DataZoneClient instance using DER credentials + * One instance per connection/domainId is maintained + * @param authProvider The SMUS authentication provider + * @returns Promise resolving to authenticated DataZoneClient instance + */ + public static async getInstance(authProvider: SmusAuthenticationProvider): Promise { + const logger = getLogger() + + if (!authProvider.isConnected()) { + throw new Error('SMUS authentication provider is not connected') + } + + const activeConnection = authProvider.activeConnection! + const instanceKey = `${activeConnection.domainId}:${activeConnection.ssoRegion}` + + logger.debug(`DataZoneClient: Getting instance for domain: ${instanceKey}`) + + // Check if we already have an instance for this domain/region + if (DataZoneClient.instances.has(instanceKey)) { + const existingInstance = DataZoneClient.instances.get(instanceKey)! + logger.debug('DataZoneClient: Using existing instance') + return existingInstance + } + + // Create new instance + logger.debug('DataZoneClient: Creating new instance') + const instance = new DataZoneClient(authProvider, activeConnection.domainId, activeConnection.ssoRegion) + DataZoneClient.instances.set(instanceKey, instance) + + // Set up cleanup when connection changes + const disposable = authProvider.onDidChangeActiveConnection(() => { + logger.debug(`DataZoneClient: Connection changed, cleaning up instance for: ${instanceKey}`) + DataZoneClient.instances.delete(instanceKey) + instance.datazoneClient = undefined + disposable.dispose() + }) + + logger.info(`DataZoneClient: Created instance for domain ${activeConnection.domainId}`) + return instance + } + + /** + * Disposes all instances and cleans up resources + */ + public static dispose(): void { + const logger = getLogger() + logger.debug('DataZoneClient: Disposing all instances') + + for (const [key, instance] of DataZoneClient.instances.entries()) { + instance.datazoneClient = undefined + logger.debug(`DataZoneClient: Disposed instance for: ${key}`) + } + + DataZoneClient.instances.clear() + } + + /** + * Gets the DataZone domain ID + * @returns DataZone domain ID + */ + public getDomainId(): string { + return this.domainId + } + + /** + * Gets the AWS region + * @returns AWS region + */ + public getRegion(): string { + return this.region + } + + /** + * Gets the default tooling environment credentials for a DataZone project + * @param projectId The DataZone project identifier + * @returns Promise resolving to environment credentials + * @throws Error if tooling blueprint or environment is not found + */ + public async getProjectDefaultEnvironmentCreds(projectId: string): Promise { + try { + this.logger.debug( + `Getting project default environment credentials for domain ${this.domainId}, project ${projectId}` + ) + const datazoneClient = await this.getDataZoneClient() + + this.logger.debug('Listing environment blueprints') + const domainBlueprints = await datazoneClient.listEnvironmentBlueprints({ + domainIdentifier: this.domainId, + managed: true, + name: toolingBlueprintName, + }) + + const toolingBlueprint = domainBlueprints.items?.[0] + if (!toolingBlueprint) { + this.logger.error('Failed to get tooling blueprint') + throw new Error('Failed to get tooling blueprint') + } + this.logger.debug(`Found tooling blueprint with ID: ${toolingBlueprint.id}, listing environments`) + + const listEnvs = await datazoneClient.listEnvironments({ + domainIdentifier: this.domainId, + projectIdentifier: projectId, + environmentBlueprintIdentifier: toolingBlueprint.id, + provider: sageMakerProviderName, + }) + + const defaultEnv = listEnvs.items?.find((env) => env.name === toolingBlueprintName) + if (!defaultEnv) { + this.logger.error('Failed to find default Tooling environment') + throw new Error('Failed to find default Tooling environment') + } + this.logger.debug(`Found default environment with ID: ${defaultEnv.id}, getting environment credentials`) + + const defaultEnvCreds = await datazoneClient.getEnvironmentCredentials({ + domainIdentifier: this.domainId, + environmentIdentifier: defaultEnv.id, + }) + + return defaultEnvCreds + } catch (err) { + this.logger.error('Failed to get project default environment credentials: %s', err as Error) + throw err + } + } + + /** + * Gets the DataZone client, initializing it if necessary + */ + private async getDataZoneClient(): Promise { + if (!this.datazoneClient) { + try { + this.logger.debug('DataZoneClient: Creating authenticated DataZone client with DER credentials') + + const credentialsProvider = async () => { + const credentials = await (await this.authProvider.getDerCredentialsProvider()).getCredentials() + return { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + expiration: credentials.expiration, + } + } + + this.datazoneClient = new DataZone({ + region: this.region, + credentials: credentialsProvider, + }) + this.logger.debug('DataZoneClient: Successfully created authenticated DataZone client') + } catch (err) { + this.logger.error('DataZoneClient: Failed to create DataZone client: %s', err as Error) + throw err + } + } + return this.datazoneClient + } + + /** + * Lists project memberships in a DataZone project with pagination support + * @param options Options for listing project memberships + * @returns Paginated list of DataZone project permissions with nextToken + */ + public async listProjectMemberships(options: { + projectIdentifier: string + maxResults?: number + nextToken?: string + }): Promise<{ memberships: any[]; nextToken?: string }> { + try { + this.logger.info( + `DataZoneClient: Listing project memberships for project ${options.projectIdentifier} in domain ${this.domainId}` + ) + + const datazoneClient = await this.getDataZoneClient() + + const response = await datazoneClient.listProjectMemberships({ + domainIdentifier: this.domainId, + projectIdentifier: options.projectIdentifier, + maxResults: options.maxResults, + nextToken: options.nextToken, + }) + + if (!response.members || response.members.length === 0) { + this.logger.info( + `DataZoneClient: No project memberships found for project ${options.projectIdentifier}` + ) + return { memberships: [] } + } + + this.logger.debug( + `DataZoneClient: Found ${response.members.length} project memberships for project ${options.projectIdentifier}` + ) + return { memberships: response.members, nextToken: response.nextToken } + } catch (err) { + this.logger.error('DataZoneClient: Failed to list project memberships: %s', (err as Error).message) + throw err + } + } + + /** + * Fetches all project memberships in a DataZone project by handling pagination automatically + * @param projectIdentifier The DataZone project identifier + * @returns Promise resolving to an array of all project memberships + */ + public async fetchAllProjectMemberships(projectIdentifier: string): Promise { + try { + let allMemberships: any[] = [] + let nextToken: string | undefined + do { + const maxResultsPerPage = 50 + const response = await this.listProjectMemberships({ + projectIdentifier, + nextToken, + maxResults: maxResultsPerPage, + }) + allMemberships = [...allMemberships, ...response.memberships] + nextToken = response.nextToken + } while (nextToken) + + this.logger.debug(`DataZoneClient: Fetched a total of ${allMemberships.length} project memberships`) + return allMemberships + } catch (err) { + this.logger.error('DataZoneClient: Failed to fetch all project memberships: %s', (err as Error).message) + throw err + } + } + + /** + * Lists projects in a DataZone domain with pagination support + * @param options Options for listing projects + * @returns Paginated list of DataZone projects with nextToken + */ + public async listProjects(options?: { + maxResults?: number + userIdentifier?: string + groupIdentifier?: string + name?: string + nextToken?: string + }): Promise<{ projects: DataZoneProject[]; nextToken?: string }> { + try { + this.logger.info(`DataZoneClient: Listing projects for domain ${this.domainId} in region ${this.region}`) + + const datazoneClient = await this.getDataZoneClient() + + // Call the DataZone API to list projects with pagination + const response = await datazoneClient.listProjects({ + domainIdentifier: this.domainId, + maxResults: options?.maxResults, + userIdentifier: options?.userIdentifier, + groupIdentifier: options?.groupIdentifier, + name: options?.name, + nextToken: options?.nextToken, + }) + + if (!response.items || response.items.length === 0) { + this.logger.info(`DataZoneClient: No projects found for domain ${this.domainId}`) + return { projects: [] } + } + + // Map the response to our DataZoneProject interface + const projects: DataZoneProject[] = response.items.map((project) => ({ + id: project.id || '', + name: project.name || '', + description: project.description, + domainId: this.domainId, + createdAt: project.createdAt ? new Date(project.createdAt) : undefined, + updatedAt: project.updatedAt ? new Date(project.updatedAt) : undefined, + })) + + this.logger.debug(`DataZoneClient: Found ${projects.length} projects for domain ${this.domainId}`) + return { projects, nextToken: response.nextToken } + } catch (err) { + this.logger.error('DataZoneClient: Failed to list projects: %s', (err as Error).message) + throw err + } + } + + /** + * Fetches all projects in a DataZone domain by handling pagination automatically + * @param options Options for listing projects (excluding nextToken which is handled internally) + * @returns Promise resolving to an array of all DataZone projects + */ + public async fetchAllProjects(options?: { + userIdentifier?: string + groupIdentifier?: string + name?: string + }): Promise { + try { + let allProjects: DataZoneProject[] = [] + let nextToken: string | undefined + do { + const maxResultsPerPage = 50 + const response = await this.listProjects({ + ...options, + nextToken, + maxResults: maxResultsPerPage, + }) + allProjects = [...allProjects, ...response.projects] + nextToken = response.nextToken + } while (nextToken) + + this.logger.debug(`DataZoneClient: Fetched a total of ${allProjects.length} projects`) + return allProjects + } catch (err) { + this.logger.error('DataZoneClient: Failed to fetch all projects: %s', (err as Error).message) + throw err + } + } + + /** + * Gets a specific project by ID + * @param projectId The project identifier + * @returns Promise resolving to the project details + */ + public async getProject(projectId: string): Promise { + try { + this.logger.info(`DataZoneClient: Getting project ${projectId} in domain ${this.domainId}`) + + const datazoneClient = await this.getDataZoneClient() + + const response = await datazoneClient.getProject({ + domainIdentifier: this.domainId, + identifier: projectId, + }) + + const project: DataZoneProject = { + id: response.id || '', + name: response.name || '', + description: response.description, + domainId: this.domainId, + createdAt: response.createdAt ? new Date(response.createdAt) : undefined, + updatedAt: response.lastUpdatedAt ? new Date(response.lastUpdatedAt) : undefined, + } + + this.logger.debug(`DataZoneClient: Retrieved project ${projectId} with name: ${project.name}`) + return project + } catch (err) { + this.logger.error('DataZoneClient: Failed to get project: %s', err as Error) + throw err + } + } + + /* + * Processes a connection response to add jdbcConnection if it's a Redshift connection + * @param connection The connection object to process + * @param connectionType The connection type + */ + private processRedshiftConnection(connection: ConnectionSummary): void { + if ( + connection && + connection.props && + 'redshiftProperties' in connection.props && + connection.props.redshiftProperties && + connection.type?.toLowerCase().includes('redshift') + ) { + const redshiftProps = connection.props.redshiftProperties as RedshiftPropertiesOutput + const props = connection.props as Record + + if (!props.jdbcConnection) { + props.jdbcConnection = this.buildJdbcConnectionFromRedshiftProps(redshiftProps) + } + } + } + + /** + * Parses location from physical endpoints + * @param physicalEndpoints Array of physical endpoints + * @returns Location object or undefined + */ + private parseLocationFromPhysicalEndpoints(physicalEndpoints?: PhysicalEndpoint[]): DataZoneConnection['location'] { + if (physicalEndpoints && physicalEndpoints.length > 0) { + const physicalEndpoint = physicalEndpoints[0] + return { + accessRole: physicalEndpoint.awsLocation?.accessRole, + awsRegion: physicalEndpoint.awsLocation?.awsRegion, + awsAccountId: physicalEndpoint.awsLocation?.awsAccountId, + iamConnectionId: physicalEndpoint.awsLocation?.iamConnectionId, + } + } + return undefined + } + + /** + * Gets a specific connection by ID + * @param params Parameters for getting a connection + * @returns The connection details + */ + public async getConnection(params: { + domainIdentifier: string + identifier: string + withSecret?: boolean + }): Promise { + try { + this.logger.info( + `DataZoneClient: Getting connection ${params.identifier} in domain ${params.domainIdentifier}` + ) + + const datazoneClient = await this.getDataZoneClient() + + // Call the DataZone API to get connection + const response: GetConnectionCommandOutput = await datazoneClient.getConnection({ + domainIdentifier: params.domainIdentifier, + identifier: params.identifier, + withSecret: params.withSecret !== undefined ? params.withSecret : true, + }) + + // Process the connection to add jdbcConnection if it's a Redshift connection + this.processRedshiftConnection(response) + + // Parse location from physical endpoints + const location = this.parseLocationFromPhysicalEndpoints(response.physicalEndpoints) + + // Return as DataZoneConnection, currently only required fields are added + // Can always include new fields in DataZoneConnection when needed + const connection: DataZoneConnection = { + connectionId: response.connectionId || '', + name: response.name || '', + description: response.description, + type: response.type || '', + domainId: params.domainIdentifier, + projectId: response.projectId || '', + props: response.props || {}, + connectionCredentials: response.connectionCredentials, + location, + } + + return connection + } catch (err) { + this.logger.error('DataZoneClient: Failed to get connection: %s', err as Error) + throw err + } + } + + public async fetchConnections( + domain: string | undefined, + project: string | undefined, + ConnectionType: ConnectionType + ): Promise { + const datazoneClient = await this.getDataZoneClient() + return datazoneClient.listConnections({ + domainIdentifier: domain, + projectIdentifier: project, + type: ConnectionType, + }) + } + /** + * Lists connections in a DataZone environment + * @param domainId The DataZone domain identifier + * @param environmentId The DataZone environment identifier + * @param projectId The DataZone project identifier + * @returns List of DataZone connections + */ + public async listConnections( + domainId: string, + environmentId: string | undefined, + projectId: string + ): Promise { + try { + this.logger.info( + `DataZoneClient: Listing connections for environment ${environmentId} in domain ${domainId}` + ) + + const datazoneClient = await this.getDataZoneClient() + let allConnections: DataZoneConnection[] = [] + let nextToken: string | undefined + + do { + // Call the DataZone API to list connections with pagination + const response: ListConnectionsCommandOutput = await datazoneClient.listConnections({ + domainIdentifier: domainId, + projectIdentifier: projectId, + environmentIdentifier: environmentId, + nextToken, + maxResults: 50, + }) + + if (response.items && response.items.length > 0) { + // Map the response to our DataZoneConnection interface + const connections: DataZoneConnection[] = response.items.map((connection) => { + // Process the connection to add jdbcConnection if it's a Redshift connection + this.processRedshiftConnection(connection) + + // Parse location from physical endpoints + const location = this.parseLocationFromPhysicalEndpoints(connection.physicalEndpoints) + + return { + connectionId: connection.connectionId || '', + name: connection.name || '', + description: '', + type: connection.type || '', + domainId, + environmentId, + projectId, + props: connection.props || {}, + location, + } + }) + allConnections = [...allConnections, ...connections] + } + + nextToken = response.nextToken + } while (nextToken) + + this.logger.info(`DataZoneClient: Fetched a total of ${allConnections.length} connections`) + return allConnections + } catch (err) { + this.logger.error('DataZoneClient: Failed to list connections: %s', err as Error) + throw err + } + } + + /** + * Gets the tooling environment ID for a project + * @param domainId The DataZone domain identifier + * @param projectId The DataZone project identifier + * @returns Promise resolving to the tooling environment ID + */ + public async getToolingEnvironmentId(domainId: string, projectId: string): Promise { + this.logger.debug(`Getting tooling environment ID for domain ${domainId}, project ${projectId}`) + const datazoneClient = await this.getDataZoneClient() + + let domainBlueprints + try { + // Get the tooling blueprint + domainBlueprints = await datazoneClient.listEnvironmentBlueprints({ + domainIdentifier: domainId, + managed: true, + name: toolingBlueprintName, + }) + } catch (err) { + this.logger.error( + 'Failed to list environment blueprints for domain %s, %s', + domainId, + (err as Error).message + ) + throw err + } + + const toolingBlueprint = domainBlueprints.items?.[0] + if (!toolingBlueprint) { + this.logger.error('No tooling blueprint found for domain %s', domainId) + throw new Error('No tooling blueprint found') + } + + // List environments for the project + let listEnvs + try { + this.logger.debug(`Listing environments for project ${projectId} with blueprint ${toolingBlueprint.id}`) + listEnvs = await datazoneClient.listEnvironments({ + domainIdentifier: domainId, + projectIdentifier: projectId, + environmentBlueprintIdentifier: toolingBlueprint.id, + provider: sageMakerProviderName, + }) + } catch (err) { + this.logger.error( + 'Failed to list environments for domainId: %s, projectId: %s, %s', + domainId, + projectId, + (err as Error).message + ) + throw err + } + + const defaultEnv = listEnvs.items?.find((env) => env.name === toolingBlueprintName) + if (!defaultEnv || !defaultEnv.id) { + this.logger.error( + 'No default Tooling environment found for domainId: %s, projectId: %s', + domainId, + projectId + ) + throw new Error('No default Tooling environment found for project') + } + this.logger.debug(`Found tooling environment with ID: ${defaultEnv.id}`) + return defaultEnv.id + } + + /** + * Gets environment details + * @param domainId The DataZone domain identifier + * @param environmentId The environment identifier + * @returns Promise resolving to environment details + */ + public async getEnvironmentDetails( + environmentId: string + ): Promise { + try { + this.logger.debug( + `Getting environment details for domain ${this.getDomainId()}, environment ${environmentId}` + ) + const datazoneClient = await this.getDataZoneClient() + + const environment = await datazoneClient.getEnvironment({ + domainIdentifier: this.getDomainId(), + identifier: environmentId, + }) + + this.logger.debug(`Retrieved environment details for ${environmentId}`) + return environment + } catch (err) { + this.logger.error('Failed to get environment details: %s', err as Error) + throw err + } + } + + /** + * Gets the tooling environment details for a project + * @param projectId The project ID + * @returns The tooling environment details + */ + public async getToolingEnvironment(projectId: string): Promise { + const logger = getLogger() + + const datazoneClient = await DataZoneClient.getInstance(this.authProvider) + if (!datazoneClient) { + throw new Error('DataZone client is not initialized') + } + + const toolingEnvId = await datazoneClient + .getToolingEnvironmentId(datazoneClient.getDomainId(), projectId) + .catch((err) => { + logger.error('Failed to get tooling environment ID for project %s', projectId) + throw new Error(`Failed to get tooling environment ID: ${err.message}`) + }) + + if (!toolingEnvId) { + throw new Error('No default environment found for project') + } + + return await datazoneClient.getEnvironmentDetails(toolingEnvId) + } + + public async getUserId(): Promise { + const derCredProvider = await this.authProvider.getDerCredentialsProvider() + this.logger.debug(`Calling STS GetCallerIdentity using DER credentials of ${this.getDomainId()}`) + const stsClient = new DefaultStsClient(this.getRegion(), await derCredProvider.getCredentials()) + const callerIdentity = await stsClient.getCallerIdentity() + this.logger.debug(`Retrieved caller identity, UserId: ${callerIdentity.UserId}`) + return callerIdentity.UserId + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/glueCatalogClient.ts b/packages/core/src/sagemakerunifiedstudio/shared/client/glueCatalogClient.ts new file mode 100644 index 00000000000..bbd3c440478 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/glueCatalogClient.ts @@ -0,0 +1,136 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Service } from 'aws-sdk' +import globals from '../../../shared/extensionGlobals' +import { getLogger } from '../../../shared/logger/logger' +import * as GlueCatalogApi from './gluecatalogapi' +import apiConfig = require('./gluecatalogapi.json') +import { ServiceConfigurationOptions } from 'aws-sdk/lib/service' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' +import { adaptConnectionCredentialsProvider } from './credentialsAdapter' + +/** + * Represents a Glue catalog + */ +export type GlueCatalog = GlueCatalogApi.Types.Catalog + +/** + * Client for interacting with Glue Catalog API + */ +export class GlueCatalogClient { + private glueClient: GlueCatalogApi | undefined + private static instance: GlueCatalogClient | undefined + private readonly logger = getLogger() + + private constructor( + private readonly region: string, + private readonly connectionCredentialsProvider?: ConnectionCredentialsProvider + ) {} + + /** + * Gets a singleton instance of the GlueCatalogClient + * @returns GlueCatalogClient instance + */ + public static getInstance(region: string): GlueCatalogClient { + if (!GlueCatalogClient.instance) { + GlueCatalogClient.instance = new GlueCatalogClient(region) + } + return GlueCatalogClient.instance + } + + /** + * Creates a new GlueCatalogClient instance with specific credentials + * @param region AWS region + * @param credentials AWS credentials + * @returns GlueCatalogClient instance with credentials + */ + public static createWithCredentials( + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider + ): GlueCatalogClient { + return new GlueCatalogClient(region, connectionCredentialsProvider) + } + + /** + * Gets the AWS region + * @returns AWS region + */ + public getRegion(): string { + return this.region + } + + /** + * Lists Glue catalogs with pagination support + * @param nextToken Optional pagination token + * @returns Object containing catalogs and nextToken + */ + public async getCatalogs(nextToken?: string): Promise<{ catalogs: GlueCatalog[]; nextToken?: string }> { + try { + this.logger.info(`GlueCatalogClient: Getting catalogs in region ${this.region}`) + + const glueClient = await this.getGlueCatalogClient() + + // Call the GetCatalogs API with pagination + const response = await glueClient + .getCatalogs({ + Recursive: true, + NextToken: nextToken, + }) + .promise() + + const catalogs: GlueCatalog[] = response.CatalogList || [] + + this.logger.info(`GlueCatalogClient: Found ${catalogs.length} catalogs in this page`) + return { + catalogs, + nextToken: response.NextToken, + } + } catch (err) { + this.logger.error('GlueCatalogClient: Failed to get catalogs: %s', err as Error) + throw err + } + } + + /** + * Gets the Glue client, initializing it if necessary + */ + private async getGlueCatalogClient(): Promise { + if (!this.glueClient) { + try { + if (this.connectionCredentialsProvider) { + // Create client with provided credentials + this.glueClient = (await globals.sdkClientBuilder.createAwsService( + Service, + { + apiConfig: apiConfig, + region: this.region, + credentialProvider: adaptConnectionCredentialsProvider(this.connectionCredentialsProvider), + } as ServiceConfigurationOptions, + undefined, + false + )) as GlueCatalogApi + } else { + // Use the SDK client builder for default credentials + this.glueClient = (await globals.sdkClientBuilder.createAwsService( + Service, + { + apiConfig: apiConfig, + region: this.region, + } as ServiceConfigurationOptions, + undefined, + false + )) as GlueCatalogApi + } + + this.logger.debug('GlueCatalogClient: Successfully created Glue client') + } catch (err) { + this.logger.error('GlueCatalogClient: Failed to create Glue client: %s', err as Error) + throw err + } + } + return this.glueClient + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/glueClient.ts b/packages/core/src/sagemakerunifiedstudio/shared/client/glueClient.ts new file mode 100644 index 00000000000..15034a488cf --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/glueClient.ts @@ -0,0 +1,166 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Glue, + GetDatabasesCommand, + GetTablesCommand, + GetTableCommand, + Table, + ResourceShareType, + DatabaseAttributes, + TableAttributes, + Database, +} from '@aws-sdk/client-glue' +import { getLogger } from '../../../shared/logger/logger' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' + +/** + * Client for interacting with AWS Glue API using public SDK + */ +export class GlueClient { + private glueClient: Glue | undefined + private readonly logger = getLogger() + + constructor( + private readonly region: string, + private readonly connectionCredentialsProvider: ConnectionCredentialsProvider + ) {} + + /** + * Gets databases from a catalog + * @param catalogId Optional catalog ID (uses default if not provided) + * @param nextToken Optional pagination token + * @returns List of databases + */ + public async getDatabases( + catalogId?: string, + resourceShareType?: ResourceShareType, + attributesToGet?: DatabaseAttributes[], + nextToken?: string + ): Promise<{ databases: Database[]; nextToken?: string }> { + try { + this.logger.info(`GlueClient: Getting databases for catalog ${catalogId || 'default'}`) + + const glueClient = await this.getGlueClient() + const response = await glueClient.send( + new GetDatabasesCommand({ + CatalogId: catalogId, + ResourceShareType: resourceShareType, + AttributesToGet: attributesToGet, + NextToken: nextToken, + MaxResults: 100, + }) + ) + + const databases = response.DatabaseList || [] + this.logger.info(`GlueClient: Found ${databases.length} databases`) + + return { + databases, + nextToken: response.NextToken, + } + } catch (err) { + this.logger.error('GlueClient: Failed to get databases: %s', err as Error) + throw err + } + } + + /** + * Gets tables from a database + * @param databaseName Database name + * @param catalogId Optional catalog ID + * @param nextToken Optional pagination token + * @returns List of tables + */ + public async getTables( + databaseName: string, + catalogId?: string, + attributesToGet?: TableAttributes[], + nextToken?: string + ): Promise<{ tables: Table[]; nextToken?: string }> { + try { + this.logger.info(`GlueClient: Getting tables for database ${databaseName}`) + + const glueClient = await this.getGlueClient() + const response = await glueClient.send( + new GetTablesCommand({ + DatabaseName: databaseName, + CatalogId: catalogId, + AttributesToGet: attributesToGet, + NextToken: nextToken, + MaxResults: 100, + }) + ) + + const tables = response.TableList || [] + this.logger.info(`GlueClient: Found ${tables.length} tables`) + + return { + tables, + nextToken: response.NextToken, + } + } catch (err) { + this.logger.error('GlueClient: Failed to get tables: %s', err as Error) + throw err + } + } + + /** + * Gets table details including columns + * @param databaseName Database name + * @param tableName Table name + * @param catalogId Optional catalog ID + * @returns Table details with columns + */ + public async getTable(databaseName: string, tableName: string, catalogId?: string): Promise { + try { + this.logger.info(`GlueClient: Getting table ${tableName} from database ${databaseName}`) + + const glueClient = await this.getGlueClient() + const response = await glueClient.send( + new GetTableCommand({ + DatabaseName: databaseName, + Name: tableName, + CatalogId: catalogId, + }) + ) + + return response.Table + } catch (err) { + this.logger.error('GlueClient: Failed to get table: %s', err as Error) + throw err + } + } + + /** + * Gets the Glue client, initializing it if necessary + */ + private async getGlueClient(): Promise { + if (!this.glueClient) { + try { + const credentialsProvider = async () => { + const credentials = await this.connectionCredentialsProvider.getCredentials() + return { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + expiration: credentials.expiration, + } + } + + this.glueClient = new Glue({ + region: this.region, + credentials: credentialsProvider, + }) + this.logger.debug('GlueClient: Successfully created Glue client') + } catch (err) { + this.logger.error('GlueClient: Failed to create Glue client: %s', err as Error) + throw err + } + } + return this.glueClient + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/gluecatalogapi.json b/packages/core/src/sagemakerunifiedstudio/shared/client/gluecatalogapi.json new file mode 100644 index 00000000000..ecd3705c096 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/gluecatalogapi.json @@ -0,0 +1,2695 @@ +{ + "version": "2.0", + "metadata": { + "apiVersion": "2022-07-26", + "auth": ["aws.auth#sigv4"], + "endpointPrefix": "glue", + "jsonVersion": "1.1", + "protocol": "json", + "protocols": ["json"], + "serviceFullName": "Glue Private Service", + "serviceId": "GlueCatalogAPI", + "signatureVersion": "v4", + "signingName": "glue", + "targetPrefix": "AWSGlue", + "uid": "gluecatalogapi-2022-07-26" + }, + "operations": { + "DescribeConnectionType": { + "name": "DescribeConnectionType", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "DescribeConnectionTypeRequest" + }, + "output": { + "shape": "DescribeConnectionTypeResponse" + }, + "errors": [ + { + "shape": "InternalServiceException" + }, + { + "shape": "InvalidInputException" + }, + { + "shape": "AccessDeniedException" + }, + { + "shape": "ValidationException" + } + ] + }, + "GetCatalog": { + "name": "GetCatalog", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetCatalogRequest" + }, + "output": { + "shape": "GetCatalogResponse" + }, + "errors": [ + { + "shape": "InternalServiceException" + }, + { + "shape": "FederationSourceException" + }, + { + "shape": "InvalidInputException" + }, + { + "shape": "GlueEncryptionException" + }, + { + "shape": "EntityNotFoundException" + }, + { + "shape": "OperationTimeoutException" + }, + { + "shape": "AccessDeniedException" + } + ] + }, + "GetCatalogs": { + "name": "GetCatalogs", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetCatalogsRequest" + }, + "output": { + "shape": "GetCatalogsResponse" + }, + "errors": [ + { + "shape": "InternalServiceException" + }, + { + "shape": "InvalidInputException" + }, + { + "shape": "GlueEncryptionException" + }, + { + "shape": "FederationSourceException" + }, + { + "shape": "EntityNotFoundException" + }, + { + "shape": "OperationTimeoutException" + }, + { + "shape": "AccessDeniedException" + } + ] + }, + "GetCompletion": { + "name": "GetCompletion", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetCompletionRequest" + }, + "output": { + "shape": "GetCompletionResponse" + }, + "errors": [ + { + "shape": "AlreadyExistsException" + }, + { + "shape": "InternalServiceException" + }, + { + "shape": "InvalidInputException" + }, + { + "shape": "EntityNotFoundException" + }, + { + "shape": "OperationTimeoutException" + }, + { + "shape": "AccessDeniedException" + }, + { + "shape": "ValidationException" + } + ] + }, + "GetEntityRecords": { + "name": "GetEntityRecords", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetEntityRecordsRequest" + }, + "output": { + "shape": "GetEntityRecordsResponse" + }, + "errors": [ + { + "shape": "InvalidInputException" + }, + { + "shape": "GlueEncryptionException" + }, + { + "shape": "FederationSourceException" + }, + { + "shape": "EntityNotFoundException" + }, + { + "shape": "OperationTimeoutException" + }, + { + "shape": "AccessDeniedException" + }, + { + "shape": "ValidationException" + } + ] + }, + "GetJobRun": { + "name": "GetJobRun", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetJobRunRequest" + }, + "output": { + "shape": "GetJobRunResponse" + }, + "errors": [ + { + "shape": "InternalServiceException" + }, + { + "shape": "InvalidInputException" + }, + { + "shape": "EntityNotFoundException" + }, + { + "shape": "OperationTimeoutException" + } + ] + }, + "GetJobRuns": { + "name": "GetJobRuns", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetJobRunsRequest" + }, + "output": { + "shape": "GetJobRunsResponse" + }, + "errors": [ + { + "shape": "InternalServiceException" + }, + { + "shape": "InvalidInputException" + }, + { + "shape": "EntityNotFoundException" + }, + { + "shape": "OperationTimeoutException" + } + ] + }, + "GetTable": { + "name": "GetTable", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetTableRequest" + }, + "output": { + "shape": "GetTableResponse" + }, + "errors": [ + { + "shape": "ResourceNotReadyException" + }, + { + "shape": "FederationSourceRetryableException" + }, + { + "shape": "InternalServiceException" + }, + { + "shape": "InvalidInputException" + }, + { + "shape": "GlueEncryptionException" + }, + { + "shape": "FederationSourceException" + }, + { + "shape": "EntityNotFoundException" + }, + { + "shape": "OperationTimeoutException" + } + ] + }, + "ListConnectionTypes": { + "name": "ListConnectionTypes", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "ListConnectionTypesRequest" + }, + "output": { + "shape": "ListConnectionTypesResponse" + }, + "errors": [ + { + "shape": "InternalServiceException" + }, + { + "shape": "AccessDeniedException" + } + ] + }, + "StartCompletion": { + "name": "StartCompletion", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "StartCompletionRequest" + }, + "output": { + "shape": "StartCompletionResponse" + }, + "errors": [ + { + "shape": "AlreadyExistsException" + }, + { + "shape": "InternalServiceException" + }, + { + "shape": "InvalidInputException" + }, + { + "shape": "EntityNotFoundException" + }, + { + "shape": "OperationTimeoutException" + }, + { + "shape": "AccessDeniedException" + }, + { + "shape": "ValidationException" + } + ] + } + }, + "shapes": { + "AccessDeniedException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "documentation": "

This exception is thrown when the client doesn't have permission for the operation they requested.

", + "exception": true + }, + "AllowedValue": { + "type": "structure", + "required": ["DisplayName", "Description", "Value"], + "members": { + "DisplayName": { + "shape": "AllowedValueDisplayNameString" + }, + "Description": { + "shape": "AllowedValueDescriptionString" + }, + "Value": { + "shape": "AllowedValueValueString" + } + } + }, + "AllowedValueDescriptionString": { + "type": "string", + "max": 1024, + "min": 0 + }, + "AllowedValueDisplayNameString": { + "type": "string", + "max": 128, + "min": 1 + }, + "AllowedValueValueString": { + "type": "string", + "max": 128, + "min": 1 + }, + "AllowedValues": { + "type": "list", + "member": { + "shape": "AllowedValue" + } + }, + "AlreadyExistsException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "documentation": "

This exception occurs when a user submits for an already existing script

", + "exception": true + }, + "ApiVersion": { + "type": "string", + "max": 256, + "min": 1, + "pattern": "[a-zA-Z0-9.-]*" + }, + "ArnString": { + "type": "string", + "max": 2048, + "min": 20 + }, + "AttemptCount": { + "type": "integer", + "box": true + }, + "AttributeCondition": { + "type": "structure", + "members": { + "Expression": { + "shape": "ExpressionString" + }, + "Scope": { + "shape": "ScopeString" + } + } + }, + "AuthConfiguration": { + "type": "structure", + "required": ["AuthenticationType", "SecretArn"], + "members": { + "AuthenticationType": { + "shape": "Property" + }, + "SecretArn": { + "shape": "Property" + }, + "OAuth2Properties": { + "shape": "PropertiesMap" + }, + "BasicAuthenticationProperties": { + "shape": "PropertiesMap" + }, + "CustomAuthenticationProperties": { + "shape": "PropertiesMap" + } + } + }, + "AuthenticationType": { + "type": "string", + "enum": ["BASIC", "OAUTH2", "CUSTOM"] + }, + "AuthenticationTypes": { + "type": "list", + "member": { + "shape": "AuthenticationType" + } + }, + "BlobParametersMap": { + "type": "map", + "key": { + "shape": "KeyString" + }, + "value": { + "shape": "BlobParametersMapValue" + } + }, + "BlobParametersMapValue": { + "type": "blob" + }, + "Bool": { + "type": "boolean", + "box": true + }, + "Boolean": { + "type": "boolean", + "box": true + }, + "BooleanValue": { + "type": "boolean", + "box": true + }, + "Capabilities": { + "type": "structure", + "required": ["SupportedAuthenticationTypes", "SupportedDataOperations", "SupportedComputeEnvironments"], + "members": { + "SupportedAuthenticationTypes": { + "shape": "AuthenticationTypes" + }, + "SupportedDataOperations": { + "shape": "DataOperations" + }, + "SupportedComputeEnvironments": { + "shape": "ComputeEnvironments" + } + } + }, + "Catalog": { + "type": "structure", + "members": { + "CatalogId": { + "shape": "CatalogIdString" + }, + "Name": { + "shape": "CatalogNameString" + }, + "Description": { + "shape": "GlueCommonDescriptionString" + }, + "ResourceArn": { + "shape": "ResourceArnString" + }, + "Parameters": { + "shape": "ParametersMap" + }, + "DataParameters": { + "shape": "BlobParametersMap" + }, + "CatalogType": { + "shape": "CatalogType" + }, + "CreateTime": { + "shape": "Timestamp" + }, + "UpdateTime": { + "shape": "Timestamp" + }, + "TargetCatalog": { + "shape": "TargetCatalog" + }, + "FederatedCatalog": { + "shape": "FederatedCatalog" + }, + "CatalogProperties": { + "shape": "CatalogPropertiesOutput" + }, + "CatalogIdentifier": { + "shape": "CatalogIdentifier" + }, + "ParentCatalogIdentifiers": { + "shape": "CatalogIdentifierList" + }, + "ParentCatalogNames": { + "shape": "CatalogNameList" + }, + "CreateTableDefaultPermissions": { + "shape": "PrincipalPermissionsList" + }, + "CreateDatabaseDefaultPermissions": { + "shape": "PrincipalPermissionsList" + } + } + }, + "CatalogIdString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "CatalogIdentifier": { + "type": "string", + "max": 100, + "min": 0, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "CatalogIdentifierList": { + "type": "list", + "member": { + "shape": "CatalogIdentifier" + } + }, + "CatalogList": { + "type": "list", + "member": { + "shape": "Catalog" + } + }, + "CatalogNameList": { + "type": "list", + "member": { + "shape": "CatalogNameString" + } + }, + "CatalogNameString": { + "type": "string", + "max": 30, + "min": 1, + "pattern": "(?!(.*[.\\/\\\\]|aws:)).*" + }, + "CatalogPropertiesOutput": { + "type": "structure", + "members": { + "DataLakeAccessProperties": { + "shape": "DataLakeAccessPropertiesOutput" + }, + "IcebergOptimizationProperties": { + "shape": "IcebergOptimizationPropertiesOutput" + } + } + }, + "CatalogType": { + "type": "string", + "enum": [ + "REDSHIFT_CATALOG", + "FEDERATED", + "NATIVE", + "REDSHIFT", + "LINKCONTAINER", + "LINK_FEDERATED", + "LINK_NATIVE", + "LINK_REDSHIFT" + ] + }, + "Column": { + "type": "structure", + "required": ["Name"], + "members": { + "Name": { + "shape": "NameString" + }, + "Type": { + "shape": "TypeString" + }, + "Comment": { + "shape": "CommentString" + }, + "Parameters": { + "shape": "ParametersMap" + } + } + }, + "ColumnList": { + "type": "list", + "member": { + "shape": "Column" + } + }, + "ColumnValueStringList": { + "type": "list", + "member": { + "shape": "ColumnValuesString" + } + }, + "ColumnValuesString": { + "type": "string" + }, + "CommentString": { + "type": "string", + "max": 255, + "min": 0, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "CompletionIdString": { + "type": "string", + "max": 36, + "min": 36, + "pattern": ".*[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}.*" + }, + "CompletionStatus": { + "type": "string", + "enum": ["SUBMITTED", "SUCCEEDED", "FAILED", "RUNNING", "EXPIRED", "DELETED"] + }, + "CompletionString": { + "type": "string", + "max": 30720, + "min": 1 + }, + "ComputeEnvironment": { + "type": "string", + "enum": ["SPARK", "PYTHON", "ATHENA"] + }, + "ComputeEnvironmentConfiguration": { + "type": "structure", + "required": [ + "Name", + "Description", + "ComputeEnvironment", + "SupportedAuthenticationTypes", + "AdditionalConnectionProperties", + "AdditionalConnectionOptions", + "ConnectionPropertyNameOverrides", + "ConnectionOptionNameOverrides", + "ConnectionPropertyExclusions", + "ConnectionOptionExclusions", + "ConnectionPropertiesRequiredOverrides" + ], + "members": { + "Name": { + "shape": "ComputeEnvironmentName" + }, + "Description": { + "shape": "String" + }, + "ComputeEnvironment": { + "shape": "ComputeEnvironment" + }, + "SupportedAuthenticationTypes": { + "shape": "AuthenticationTypes" + }, + "AdditionalConnectionProperties": { + "shape": "PropertiesMap" + }, + "AdditionalConnectionOptions": { + "shape": "PropertiesMap" + }, + "ConnectionPropertyNameOverrides": { + "shape": "PropertyNameOverrides" + }, + "ConnectionOptionNameOverrides": { + "shape": "PropertyNameOverrides" + }, + "ConnectionPropertyExclusions": { + "shape": "ListOfString" + }, + "ConnectionOptionExclusions": { + "shape": "ListOfString" + }, + "ConnectionPropertiesRequiredOverrides": { + "shape": "ListOfString" + }, + "PhysicalConnectionPropertiesRequired": { + "shape": "Bool" + } + } + }, + "ComputeEnvironmentConfigurationMap": { + "type": "map", + "key": { + "shape": "ComputeEnvironmentName" + }, + "value": { + "shape": "ComputeEnvironmentConfiguration" + } + }, + "ComputeEnvironmentName": { + "type": "string", + "max": 128, + "min": 1 + }, + "ComputeEnvironments": { + "type": "list", + "member": { + "shape": "ComputeEnvironment" + } + }, + "ConditionStatement": { + "type": "map", + "key": { + "shape": "String" + }, + "value": { + "shape": "String" + } + }, + "ConditionStatements": { + "type": "list", + "member": { + "shape": "ConditionStatement" + } + }, + "ConnectionOptions": { + "type": "map", + "key": { + "shape": "OptionKey" + }, + "value": { + "shape": "OptionValue" + } + }, + "ConnectionType": { + "type": "string", + "enum": [ + "JDBC", + "SFTP", + "REDSHIFT", + "ATHENA", + "MONGODB", + "KAFKA", + "NETWORK", + "YARNRESOURCEMANAGER", + "MARKETPLACE", + "HIVE_METASTORE", + "CUSTOM", + "SALESFORCE", + "VIEW_VALIDATION_REDSHIFT", + "VIEW_VALIDATION_ATHENA" + ] + }, + "ConnectionTypeBrief": { + "type": "structure", + "members": { + "ConnectionType": { + "shape": "ConnectionType" + }, + "DisplayName": { + "shape": "DisplayName" + }, + "Vendor": { + "shape": "Vendor" + }, + "Description": { + "shape": "Description" + }, + "Categories": { + "shape": "ListOfString" + }, + "Capabilities": { + "shape": "Capabilities" + }, + "LogoUrl": { + "shape": "UrlString" + }, + "DocumentationUrl": { + "shape": "UrlString" + }, + "ConnectionTypeVariants": { + "shape": "ConnectionTypeVariantList" + } + } + }, + "ConnectionTypeList": { + "type": "list", + "member": { + "shape": "ConnectionTypeBrief" + } + }, + "ConnectionTypeVariant": { + "type": "structure", + "members": { + "ConnectionTypeVariantName": { + "shape": "DisplayName" + }, + "DisplayName": { + "shape": "DisplayName" + }, + "Description": { + "shape": "Description" + }, + "LogoUrl": { + "shape": "UrlString" + }, + "DocumentationUrl": { + "shape": "UrlString" + } + } + }, + "ConnectionTypeVariantList": { + "type": "list", + "member": { + "shape": "ConnectionTypeVariant" + } + }, + "DataAccessModeEnum": { + "type": "string", + "enum": ["LakeFormation", "Hybrid", "Other"] + }, + "DataLakeAccessPropertiesOutput": { + "type": "structure", + "members": { + "DataLakeAccess": { + "shape": "Boolean" + }, + "DataTransferRole": { + "shape": "GlueCommonIAMRoleArn" + }, + "KmsKey": { + "shape": "ResourceArnString" + }, + "ManagedWorkgroupName": { + "shape": "GlueCommonNameString" + }, + "ManagedWorkgroupStatus": { + "shape": "GlueCommonNameString" + }, + "NamespaceArn": { + "shape": "ResourceArnString" + }, + "RedshiftDatabaseName": { + "shape": "GlueCommonNameString" + }, + "StatusMessage": { + "shape": "GlueCommonNameString" + }, + "CatalogType": { + "shape": "GlueCommonNameString" + } + } + }, + "DataLakePrincipal": { + "type": "structure", + "members": { + "DataLakePrincipalIdentifier": { + "shape": "DataLakePrincipalString" + }, + "AttributeCondition": { + "shape": "AttributeCondition" + } + } + }, + "DataLakePrincipalString": { + "type": "string", + "max": 255, + "min": 1 + }, + "DataOperation": { + "type": "string", + "enum": ["READ", "WRITE"] + }, + "DataOperations": { + "type": "list", + "member": { + "shape": "DataOperation" + } + }, + "DataType": { + "type": "string", + "enum": ["STRING", "INTEGER", "BOOLEAN", "STRING_LIST"] + }, + "DatabaseIdString": { + "type": "string", + "max": 100, + "min": 0, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "DescribeConnectionTypeRequest": { + "type": "structure", + "members": { + "ConnectionType": { + "shape": "NameString" + } + } + }, + "DescribeConnectionTypeResponse": { + "type": "structure", + "members": { + "ConnectionType": { + "shape": "NameString" + }, + "DisplayName": { + "shape": "DisplayName" + }, + "Vendor": { + "shape": "Vendor" + }, + "Description": { + "shape": "Description" + }, + "LogoUrl": { + "shape": "UrlString" + }, + "DocumentationUrl": { + "shape": "UrlString" + }, + "Categories": { + "shape": "ListOfString" + }, + "Capabilities": { + "shape": "Capabilities" + }, + "ConnectionProperties": { + "shape": "PropertiesMap" + }, + "SparkConnectionProperties": { + "shape": "PropertiesMap" + }, + "AthenaConnectionProperties": { + "shape": "PropertiesMap" + }, + "ConnectionOptions": { + "shape": "PropertiesMap" + }, + "AuthenticationConfiguration": { + "shape": "AuthConfiguration" + }, + "ComputeEnvironmentConfigurations": { + "shape": "ComputeEnvironmentConfigurationMap" + }, + "PhysicalConnectionRequirements": { + "shape": "PropertiesMap" + } + } + }, + "Description": { + "type": "string", + "max": 1024, + "min": 0 + }, + "DescriptionErrorString": { + "type": "string", + "max": 400000, + "min": 0 + }, + "DescriptionString": { + "type": "string", + "max": 2048, + "min": 0, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\r\\n\\t]*.*" + }, + "DisplayName": { + "type": "string", + "max": 128, + "min": 1 + }, + "EntityFieldName": { + "type": "string" + }, + "EntityName": { + "type": "string" + }, + "EntityNotFoundException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + }, + "fromFederationSource": { + "shape": "NullableBoolean" + } + }, + "documentation": "

This exception is thrown when the requested entity is not found in the server side.

", + "exception": true + }, + "ErrorDetail": { + "type": "structure", + "members": { + "ErrorCode": { + "shape": "NameString" + }, + "ErrorMessage": { + "shape": "DescriptionString" + } + } + }, + "ExecutionClass": { + "type": "string", + "enum": ["FLEX", "STANDARD"] + }, + "ExecutionTime": { + "type": "integer", + "box": true + }, + "ExpressionString": { + "type": "string" + }, + "FederatedCatalog": { + "type": "structure", + "members": { + "Identifier": { + "shape": "GlueCommonFederationIdentifier" + }, + "ConnectionName": { + "shape": "GlueCommonNameString" + } + } + }, + "FederatedTable": { + "type": "structure", + "members": { + "Identifier": { + "shape": "FederationIdentifier" + }, + "DatabaseIdentifier": { + "shape": "FederationIdentifier" + }, + "ProfileName": { + "shape": "NameString" + }, + "ConnectionName": { + "shape": "NameString" + }, + "ConnectionType": { + "shape": "NameString" + } + } + }, + "FederationIdentifier": { + "type": "string", + "max": 512, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "FederationSourceException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "exception": true + }, + "FederationSourceRetryableException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "exception": true + }, + "FilterPredicate": { + "type": "string", + "max": 2048, + "min": 1, + "pattern": "[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\r\\n\\t]*" + }, + "FormatString": { + "type": "string", + "max": 128, + "min": 0, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "GenericMap": { + "type": "map", + "key": { + "shape": "GenericString" + }, + "value": { + "shape": "GenericString" + } + }, + "GenericString": { + "type": "string" + }, + "GetCatalogRequest": { + "type": "structure", + "required": ["Name"], + "members": { + "Name": { + "shape": "CatalogNameString" + }, + "ParentCatalogId": { + "shape": "CatalogIdString" + }, + "CatalogId": { + "shape": "CatalogIdString" + }, + "CatalogIdentifier": { + "shape": "CatalogIdentifier" + }, + "ContextMap": { + "shape": "RequestContextMap" + }, + "FederateToSource": { + "shape": "Boolean" + } + } + }, + "GetCatalogResponse": { + "type": "structure", + "required": ["Catalog"], + "members": { + "Catalog": { + "shape": "Catalog" + }, + "DataParameters": { + "shape": "BlobParametersMap" + } + } + }, + "GetCatalogsRequest": { + "type": "structure", + "members": { + "ParentCatalogId": { + "shape": "CatalogIdString" + }, + "NextToken": { + "shape": "NextToken" + }, + "MaxResults": { + "shape": "PageSize" + }, + "Recursive": { + "shape": "NullableBoolean" + }, + "ContextMap": { + "shape": "RequestContextMap" + } + } + }, + "GetCatalogsResponse": { + "type": "structure", + "required": ["CatalogList"], + "members": { + "CatalogList": { + "shape": "CatalogList" + }, + "NextToken": { + "shape": "NextToken" + } + } + }, + "GetCompletionRequest": { + "type": "structure", + "required": ["CompletionId"], + "members": { + "CompletionId": { + "shape": "CompletionIdString" + } + } + }, + "GetCompletionResponse": { + "type": "structure", + "required": ["CompletionId", "LastModifiedOn", "Status"], + "members": { + "CompletionId": { + "shape": "CompletionIdString" + }, + "StartedOn": { + "shape": "startedOn" + }, + "LastModifiedOn": { + "shape": "lastModifiedOn" + }, + "ErrorMessage": { + "shape": "HashString" + }, + "CompletedOn": { + "shape": "completedOn" + }, + "Status": { + "shape": "CompletionStatus" + }, + "Completion": { + "shape": "CompletionString" + }, + "SourceURLs": { + "shape": "SourceUrlList" + }, + "Tags": { + "shape": "TagsMap" + } + } + }, + "GetEntityRecordsRequest": { + "type": "structure", + "required": ["EntityName", "Limit"], + "members": { + "EntityName": { + "shape": "EntityName" + }, + "Limit": { + "shape": "Limit" + }, + "ConnectionName": { + "shape": "NameString" + }, + "CatalogId": { + "shape": "CatalogIdString" + }, + "NextToken": { + "shape": "NextToken" + }, + "DataStoreApiVersion": { + "shape": "ApiVersion" + }, + "ConnectionOptions": { + "shape": "ConnectionOptions" + }, + "FilterPredicate": { + "shape": "FilterPredicate" + }, + "OrderBy": { + "shape": "String" + }, + "SelectedFields": { + "shape": "SelectedFields" + }, + "StagingConfiguration": { + "shape": "StagingConfiguration" + } + } + }, + "GetEntityRecordsResponse": { + "type": "structure", + "members": { + "Records": { + "shape": "Records" + }, + "NextToken": { + "shape": "NextToken" + } + } + }, + "GetJobRunRequest": { + "type": "structure", + "required": ["JobName", "RunId"], + "members": { + "JobName": { + "shape": "NameString" + }, + "RunId": { + "shape": "IdString" + }, + "PredecessorsIncluded": { + "shape": "BooleanValue" + } + } + }, + "GetJobRunResponse": { + "type": "structure", + "members": { + "JobRun": { + "shape": "JobRun" + } + } + }, + "GetJobRunsRequest": { + "type": "structure", + "required": ["JobName"], + "members": { + "JobName": { + "shape": "NameString" + }, + "NextToken": { + "shape": "OrchestrationToken" + }, + "MaxResults": { + "shape": "OrchestrationPageSize200" + } + } + }, + "GetJobRunsResponse": { + "type": "structure", + "members": { + "JobRuns": { + "shape": "JobRunList" + }, + "NextToken": { + "shape": "OrchestrationToken" + } + } + }, + "GetTableRequest": { + "type": "structure", + "required": ["DatabaseName", "Name"], + "members": { + "DatabaseName": { + "shape": "NameString" + }, + "Name": { + "shape": "NameString" + }, + "CatalogId": { + "shape": "CatalogIdString" + }, + "TransactionId": { + "shape": "TransactionIdString" + }, + "QueryAsOfTime": { + "shape": "Timestamp" + }, + "IncludeAccessMode": { + "shape": "NullableBoolean" + }, + "IncludeStatusDetails": { + "shape": "NullableBoolean" + }, + "AttributesToGet": { + "shape": "TableAttributesList" + }, + "CatalogIdentifier": { + "shape": "CatalogIdentifier" + }, + "DatabaseIdentifier": { + "shape": "DatabaseIdString" + }, + "TableIdentifier": { + "shape": "TableIdString" + }, + "ContextMap": { + "shape": "RequestContextMap" + } + } + }, + "GetTableResponse": { + "type": "structure", + "members": { + "Table": { + "shape": "Table" + }, + "UseAdvancedFiltering": { + "shape": "NullableBoolean" + } + } + }, + "GlueCommonDescriptionString": { + "type": "string", + "max": 2048, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "GlueCommonFederationIdentifier": { + "type": "string", + "max": 512, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "GlueCommonIAMRoleArn": { + "type": "string", + "pattern": "arn:aws(-(cn|us-gov|iso(-[bef])?))?:iam::[0-9]{12}:role/.+.*" + }, + "GlueCommonNameString": { + "type": "string", + "max": 155, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "GlueEncryptionException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "exception": true + }, + "GlueResourceArn": { + "type": "string", + "pattern": ".*arn:aws(-(cn|us-gov|iso(-[bef])?))?:glue:.*" + }, + "GlueVersionString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": "(\\w+\\.)+\\w+" + }, + "HashString": { + "type": "string", + "max": 255, + "min": 1 + }, + "IcebergOptimizationPropertiesOutput": { + "type": "structure", + "members": { + "RoleArn": { + "shape": "GlueCommonIAMRoleArn" + }, + "Compaction": { + "shape": "ParametersMap" + }, + "Retention": { + "shape": "ParametersMap" + }, + "OrphanFileDeletion": { + "shape": "ParametersMap" + }, + "LastUpdatedTime": { + "shape": "Timestamp" + } + } + }, + "IdString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "Integer": { + "type": "integer", + "box": true + }, + "IntegerFlag": { + "type": "integer", + "box": true, + "max": 1, + "min": 0 + }, + "IntegerValue": { + "type": "integer", + "box": true + }, + "InternalServiceException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "documentation": "

This exception is thrown when a call fails due to internal error.

", + "exception": true, + "fault": true + }, + "InvalidInputException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + }, + "fromFederationSource": { + "shape": "NullableBoolean" + } + }, + "documentation": "

This exception is thrown when the format of the input is incorrect.

", + "exception": true + }, + "JobMode": { + "type": "string", + "enum": ["SCRIPT", "VISUAL", "NOTEBOOK"] + }, + "JobRun": { + "type": "structure", + "members": { + "Id": { + "shape": "IdString" + }, + "Attempt": { + "shape": "AttemptCount" + }, + "PreviousRunId": { + "shape": "IdString" + }, + "TriggerName": { + "shape": "NameString" + }, + "JobName": { + "shape": "NameString" + }, + "JobMode": { + "shape": "JobMode" + }, + "JobRunQueuingEnabled": { + "shape": "NullableBoolean" + }, + "StartedOn": { + "shape": "TimestampValue" + }, + "LastModifiedOn": { + "shape": "TimestampValue" + }, + "CompletedOn": { + "shape": "TimestampValue" + }, + "JobRunState": { + "shape": "JobRunState" + }, + "Arguments": { + "shape": "GenericMap" + }, + "ErrorMessage": { + "shape": "DescriptionErrorString" + }, + "PredecessorRuns": { + "shape": "PredecessorList" + }, + "AllocatedCapacity": { + "shape": "IntegerValue" + }, + "ExecutionTime": { + "shape": "ExecutionTime" + }, + "Timeout": { + "shape": "Timeout" + }, + "MaxCapacity": { + "shape": "NullableDouble" + }, + "WorkerType": { + "shape": "WorkerType" + }, + "NumberOfWorkers": { + "shape": "NullableInteger" + }, + "SecurityConfiguration": { + "shape": "NameString" + }, + "LogGroupName": { + "shape": "LogGroupString" + }, + "NotificationProperty": { + "shape": "NotificationProperty" + }, + "GlueVersion": { + "shape": "GlueVersionString" + }, + "ExecutionClass": { + "shape": "ExecutionClass" + }, + "MinFlexWorkers": { + "shape": "NullableInteger" + }, + "DPUSeconds": { + "shape": "NullableDouble" + }, + "ExecutionArguments": { + "shape": "GenericMap" + }, + "ProfileName": { + "shape": "NameString" + }, + "StateDetail": { + "shape": "OrchestrationMessageString" + }, + "MaintenanceWindow": { + "shape": "MaintenanceWindow" + }, + "UpgradeAnalysisMetadata": { + "shape": "UpgradeAnalysisMetadata" + } + } + }, + "JobRunList": { + "type": "list", + "member": { + "shape": "JobRun" + } + }, + "JobRunState": { + "type": "string", + "enum": [ + "STARTING", + "RUNNING", + "STOPPING", + "STOPPED", + "SUCCEEDED", + "FAILED", + "TIMEOUT", + "ERROR", + "WAITING", + "EXPIRED" + ] + }, + "KeyString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "LakeFormationPermissionEnforcedEnum": { + "type": "string", + "enum": ["AllUsers", "SomeUsers", "NoUser"] + }, + "Limit": { + "type": "long", + "box": true, + "max": 1000, + "min": 1 + }, + "ListConnectionTypesRequest": { + "type": "structure", + "members": { + "MaxResults": { + "shape": "PageSize" + }, + "NextToken": { + "shape": "NextToken" + } + } + }, + "ListConnectionTypesResponse": { + "type": "structure", + "members": { + "ConnectionTypes": { + "shape": "ConnectionTypeList" + }, + "NextToken": { + "shape": "NextToken" + } + } + }, + "ListOfString": { + "type": "list", + "member": { + "shape": "String" + } + }, + "LocationMap": { + "type": "map", + "key": { + "shape": "ColumnValuesString" + }, + "value": { + "shape": "ColumnValuesString" + } + }, + "LocationString": { + "type": "string", + "max": 2056, + "min": 0, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\r\\n\\t]*.*" + }, + "LocationStringList": { + "type": "list", + "member": { + "shape": "LocationString" + } + }, + "LogGroupString": { + "type": "string", + "max": 400000, + "min": 0 + }, + "MaintenanceWindow": { + "type": "string", + "pattern": "(Sun|Mon|Tue|Wed|Thu|Fri|Sat):([01]?[0-9]|2[0-3])" + }, + "Maximum": { + "type": "integer", + "box": true + }, + "Minimum": { + "type": "integer", + "box": true + }, + "NameString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "NameStringList": { + "type": "list", + "member": { + "shape": "NameString" + } + }, + "NextToken": { + "type": "string" + }, + "NonNegativeInteger": { + "type": "integer", + "box": true, + "min": 0 + }, + "NotificationProperty": { + "type": "structure", + "members": { + "NotifyDelayAfter": { + "shape": "NotifyDelayAfter" + } + } + }, + "NotifyDelayAfter": { + "type": "integer", + "box": true, + "min": 1 + }, + "NullableBoolean": { + "type": "boolean", + "box": true + }, + "NullableDouble": { + "type": "double", + "box": true + }, + "NullableInteger": { + "type": "integer", + "box": true + }, + "OperationTimeoutException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "documentation": "

This exception occurs when the server throws a timeout

", + "exception": true + }, + "OptionKey": { + "type": "string", + "max": 256, + "min": 1, + "pattern": "[\\w]*" + }, + "OptionValue": { + "type": "string", + "max": 256, + "min": 1, + "pattern": "[\\S]*" + }, + "OrchestrationMessageString": { + "type": "string", + "max": 400000, + "min": 0 + }, + "OrchestrationPageSize200": { + "type": "integer", + "box": true, + "max": 200, + "min": 1 + }, + "OrchestrationToken": { + "type": "string", + "max": 400000, + "min": 0 + }, + "Order": { + "type": "structure", + "required": ["Column", "SortOrder"], + "members": { + "Column": { + "shape": "NameString" + }, + "SortOrder": { + "shape": "IntegerFlag" + } + } + }, + "OrderList": { + "type": "list", + "member": { + "shape": "Order" + } + }, + "OutputLocation": { + "type": "string" + }, + "PageSize": { + "type": "integer", + "box": true, + "max": 1000, + "min": 1 + }, + "ParametersMap": { + "type": "map", + "key": { + "shape": "KeyString" + }, + "value": { + "shape": "ParametersMapValue" + }, + "max": 50, + "min": 0 + }, + "ParametersMapValue": { + "type": "string", + "max": 512000, + "min": 0 + }, + "Permission": { + "type": "string", + "enum": [ + "ALL", + "SELECT", + "ALTER", + "DROP", + "DELETE", + "INSERT", + "DESCRIBE", + "CREATE_DATABASE", + "CREATE_TABLE", + "DATA_LOCATION_ACCESS", + "READ", + "WRITE", + "CREATE_LF_TAG", + "ASSOCIATE", + "UPDATE", + "GRANT_WITH_LF_TAG_EXPRESSION", + "CREATE_LF_TAG_EXPRESSION" + ] + }, + "PermissionList": { + "type": "list", + "member": { + "shape": "Permission" + } + }, + "Phase": { + "type": "string", + "enum": ["AUTHENTICATION", "CONNECTION_CREATION"] + }, + "Predecessor": { + "type": "structure", + "members": { + "JobName": { + "shape": "NameString" + }, + "RunId": { + "shape": "IdString" + } + } + }, + "PredecessorList": { + "type": "list", + "member": { + "shape": "Predecessor" + } + }, + "PrimitiveInteger": { + "type": "integer", + "box": true + }, + "PrincipalPermissions": { + "type": "structure", + "members": { + "Principal": { + "shape": "DataLakePrincipal" + }, + "Permissions": { + "shape": "PermissionList" + } + } + }, + "PrincipalPermissionsList": { + "type": "list", + "member": { + "shape": "PrincipalPermissions" + } + }, + "PromptString": { + "type": "string", + "max": 30720, + "min": 1 + }, + "PropertiesMap": { + "type": "map", + "key": { + "shape": "PropertyName" + }, + "value": { + "shape": "Property" + } + }, + "Property": { + "type": "structure", + "members": { + "Name": { + "shape": "PropertyName" + }, + "DisplayName": { + "shape": "PropertyName" + }, + "Description": { + "shape": "PropertyDescriptionString" + }, + "DataType": { + "shape": "DataType" + }, + "Required": { + "shape": "Bool" + }, + "ConditionallyRequired": { + "shape": "ConditionStatements" + }, + "DefaultValue": { + "shape": "String" + }, + "Phase": { + "shape": "Phase" + }, + "PropertyTypes": { + "shape": "PropertyTypes" + }, + "AllowedValues": { + "shape": "AllowedValues" + }, + "Validations": { + "shape": "Validations" + }, + "DataOperationScopes": { + "shape": "DataOperations" + }, + "Order": { + "shape": "PrimitiveInteger" + }, + "DocumentationUrl": { + "shape": "String" + }, + "Reference": { + "shape": "String" + }, + "Format": { + "shape": "String" + } + } + }, + "PropertyDescriptionString": { + "type": "string", + "max": 1024, + "min": 0 + }, + "PropertyName": { + "type": "string", + "max": 128, + "min": 1 + }, + "PropertyNameOverrides": { + "type": "map", + "key": { + "shape": "PropertyName" + }, + "value": { + "shape": "PropertyName" + } + }, + "PropertyType": { + "type": "string", + "enum": ["USER_INPUT", "SECRET", "READ_ONLY", "UNUSED"] + }, + "PropertyTypes": { + "type": "list", + "member": { + "shape": "PropertyType" + } + }, + "Record": { + "type": "structure", + "members": {}, + "document": true, + "sensitive": true + }, + "Records": { + "type": "list", + "member": { + "shape": "Record" + } + }, + "RequestContextKey": { + "type": "string", + "max": 1024, + "min": 1 + }, + "RequestContextMap": { + "type": "map", + "key": { + "shape": "RequestContextKey" + }, + "value": { + "shape": "RequestContextValue" + }, + "max": 50, + "min": 0 + }, + "RequestContextValue": { + "type": "string", + "max": 10240, + "min": 0 + }, + "ResourceAction": { + "type": "string", + "enum": ["CREATE", "UPDATE"] + }, + "ResourceArnString": { + "type": "string" + }, + "ResourceNotReadyException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "exception": true + }, + "ResourceState": { + "type": "string", + "enum": ["QUEUED", "IN_PROGRESS", "SUCCESS", "STOPPED", "FAILED"] + }, + "SchemaId": { + "type": "structure", + "members": { + "SchemaArn": { + "shape": "GlueResourceArn" + }, + "SchemaName": { + "shape": "SchemaRegistryNameString" + }, + "RegistryName": { + "shape": "SchemaRegistryNameString" + } + } + }, + "SchemaReference": { + "type": "structure", + "members": { + "SchemaId": { + "shape": "SchemaId" + }, + "SchemaVersionId": { + "shape": "SchemaVersionIdString" + }, + "SchemaVersionNumber": { + "shape": "VersionLongNumber" + } + } + }, + "SchemaRegistryNameString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": ".*[a-zA-Z0-9-_$#.]+.*" + }, + "SchemaVersionIdString": { + "type": "string", + "max": 36, + "min": 36, + "pattern": ".*[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}.*" + }, + "ScopeString": { + "type": "string", + "max": 25, + "min": 25 + }, + "ScriptLocationString": { + "type": "string", + "max": 400000, + "min": 0 + }, + "SelectedFields": { + "type": "list", + "member": { + "shape": "EntityFieldName" + } + }, + "SerDeInfo": { + "type": "structure", + "members": { + "Name": { + "shape": "NameString" + }, + "SerializationLibrary": { + "shape": "NameString" + }, + "Parameters": { + "shape": "ParametersMap" + } + } + }, + "SkewedInfo": { + "type": "structure", + "members": { + "SkewedColumnNames": { + "shape": "NameStringList" + }, + "SkewedColumnValues": { + "shape": "ColumnValueStringList" + }, + "SkewedColumnValueLocationMaps": { + "shape": "LocationMap" + } + } + }, + "SourceUrlList": { + "type": "list", + "member": { + "shape": "HashString" + }, + "max": 3, + "min": 1 + }, + "StagingConfiguration": { + "type": "structure", + "members": { + "OutputLocation": { + "shape": "OutputLocation" + } + } + }, + "StartCompletionContext": { + "type": "list", + "member": { + "shape": "StartCompletionContextItem" + } + }, + "StartCompletionContextItem": { + "type": "map", + "key": { + "shape": "HashString" + }, + "value": { + "shape": "HashString" + } + }, + "StartCompletionRequest": { + "type": "structure", + "required": ["Prompt"], + "members": { + "Prompt": { + "shape": "PromptString" + }, + "Tags": { + "shape": "TagsMap" + }, + "Context": { + "shape": "StartCompletionContext" + } + } + }, + "StartCompletionResponse": { + "type": "structure", + "required": ["CompletionId", "ConversationId"], + "members": { + "CompletionId": { + "shape": "CompletionIdString" + }, + "ConversationId": { + "shape": "CompletionIdString" + } + } + }, + "StatusDetails": { + "type": "structure", + "members": { + "RequestedChange": { + "shape": "Table" + }, + "ViewValidations": { + "shape": "ViewValidationList" + } + } + }, + "StorageDescriptor": { + "type": "structure", + "members": { + "Columns": { + "shape": "ColumnList" + }, + "Location": { + "shape": "LocationString" + }, + "AdditionalLocations": { + "shape": "LocationStringList" + }, + "InputFormat": { + "shape": "FormatString" + }, + "OutputFormat": { + "shape": "FormatString" + }, + "Compressed": { + "shape": "Boolean" + }, + "NumberOfBuckets": { + "shape": "Integer" + }, + "SerDeInfo": { + "shape": "SerDeInfo" + }, + "BucketColumns": { + "shape": "NameStringList" + }, + "SortColumns": { + "shape": "OrderList" + }, + "Parameters": { + "shape": "ParametersMap" + }, + "SkewedInfo": { + "shape": "SkewedInfo" + }, + "StoredAsSubDirectories": { + "shape": "Boolean" + }, + "SchemaReference": { + "shape": "SchemaReference" + } + } + }, + "String": { + "type": "string" + }, + "Table": { + "type": "structure", + "required": ["Name"], + "members": { + "Name": { + "shape": "NameString" + }, + "DatabaseName": { + "shape": "NameString" + }, + "Description": { + "shape": "DescriptionString" + }, + "Owner": { + "shape": "NameString" + }, + "CreateTime": { + "shape": "Timestamp" + }, + "UpdateTime": { + "shape": "Timestamp" + }, + "LastAccessTime": { + "shape": "Timestamp" + }, + "LastAnalyzedTime": { + "shape": "Timestamp" + }, + "Retention": { + "shape": "NonNegativeInteger" + }, + "StorageDescriptor": { + "shape": "StorageDescriptor" + }, + "PartitionKeys": { + "shape": "ColumnList" + }, + "ViewOriginalText": { + "shape": "ViewTextString" + }, + "ViewExpandedText": { + "shape": "ViewTextString" + }, + "TableType": { + "shape": "TableTypeString" + }, + "Parameters": { + "shape": "ParametersMap" + }, + "DataParameters": { + "shape": "BlobParametersMap" + }, + "CreatedBy": { + "shape": "NameString" + }, + "IsRegisteredWithLakeFormation": { + "shape": "Boolean" + }, + "LakeFormationPermissionEnforced": { + "shape": "LakeFormationPermissionEnforcedEnum" + }, + "DataAccessMode": { + "shape": "DataAccessModeEnum" + }, + "TargetTable": { + "shape": "TableIdentifier" + }, + "FederatedTable": { + "shape": "FederatedTable" + }, + "CatalogId": { + "shape": "CatalogIdString" + }, + "IsRowFilteringEnabled": { + "shape": "Boolean" + }, + "VersionId": { + "shape": "VersionString" + }, + "CatalogIdentifier": { + "shape": "CatalogIdentifier" + }, + "TableId": { + "shape": "TableIdString" + }, + "DatabaseId": { + "shape": "DatabaseIdString" + }, + "ViewDefinition": { + "shape": "ViewDefinition" + }, + "DataProvider": { + "shape": "NameString" + }, + "IsMultiDialectView": { + "shape": "Boolean" + }, + "Status": { + "shape": "TableStatus" + } + } + }, + "TableAttributes": { + "type": "string", + "enum": ["NAME", "VERSION_ID", "DATA_ACCESS_MODE", "DEFAULT", "ALL", "TABLE_TYPE", "DESCRIPTION"] + }, + "TableAttributesList": { + "type": "list", + "member": { + "shape": "TableAttributes" + } + }, + "TableIdString": { + "type": "string", + "max": 100, + "min": 0, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "TableIdentifier": { + "type": "structure", + "members": { + "CatalogId": { + "shape": "CatalogIdString" + }, + "DatabaseName": { + "shape": "NameString" + }, + "Name": { + "shape": "NameString" + }, + "Region": { + "shape": "NameString" + }, + "DatabaseId": { + "shape": "DatabaseIdString" + } + } + }, + "TableStatus": { + "type": "structure", + "members": { + "RequestedBy": { + "shape": "NameString" + }, + "UpdatedBy": { + "shape": "NameString" + }, + "RequestTime": { + "shape": "Timestamp" + }, + "UpdateTime": { + "shape": "Timestamp" + }, + "Action": { + "shape": "ResourceAction" + }, + "State": { + "shape": "ResourceState" + }, + "Error": { + "shape": "ErrorDetail" + }, + "Details": { + "shape": "StatusDetails" + } + } + }, + "TableTypeString": { + "type": "string", + "max": 255, + "min": 0 + }, + "TagKey": { + "type": "string", + "max": 128, + "min": 1 + }, + "TagValue": { + "type": "string", + "max": 256, + "min": 0 + }, + "TagsMap": { + "type": "map", + "key": { + "shape": "TagKey" + }, + "value": { + "shape": "TagValue" + }, + "max": 50, + "min": 0 + }, + "TargetCatalog": { + "type": "structure", + "members": { + "CatalogArn": { + "shape": "ResourceArnString" + }, + "CatalogIdentifier": { + "shape": "CatalogIdentifier" + }, + "AutoDiscovery": { + "shape": "Boolean" + } + } + }, + "Timeout": { + "type": "integer", + "box": true + }, + "Timestamp": { + "type": "timestamp" + }, + "TimestampValue": { + "type": "timestamp" + }, + "TransactionIdString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": ".*[\\p{L}\\p{N}\\p{P}]*.*" + }, + "TypeString": { + "type": "string", + "max": 20000, + "min": 0, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "UpgradeAnalysisMetadata": { + "type": "structure", + "members": { + "ValidationJobRunId": { + "shape": "NameString" + }, + "GlueVersion": { + "shape": "NameString" + }, + "ScriptLocation": { + "shape": "ScriptLocationString" + }, + "AnalysisId": { + "shape": "IdString" + } + } + }, + "UrlString": { + "type": "string" + }, + "Validation": { + "type": "structure", + "members": { + "ValidationType": { + "shape": "ValidationType" + }, + "Patterns": { + "shape": "ListOfString" + }, + "Description": { + "shape": "ValidationDescriptionString" + }, + "MaxLength": { + "shape": "Maximum" + }, + "Maximum": { + "shape": "Maximum" + }, + "Minimum": { + "shape": "Minimum" + } + } + }, + "ValidationDescriptionString": { + "type": "string", + "max": 1024, + "min": 0 + }, + "ValidationDryRunOpts": { + "type": "structure", + "members": { + "SerializedMockEngineResult": { + "shape": "String" + }, + "ErrorMessage": { + "shape": "String" + }, + "MinimumReceiveCount": { + "shape": "Integer" + } + } + }, + "ValidationException": { + "type": "structure", + "members": { + "message": { + "shape": "String" + } + }, + "documentation": "

This exception occurs when the dag cannot be successfully validated

", + "exception": true + }, + "ValidationType": { + "type": "string", + "enum": ["REGEX", "RANGE"] + }, + "Validations": { + "type": "list", + "member": { + "shape": "Validation" + } + }, + "Vendor": { + "type": "string", + "max": 128, + "min": 1 + }, + "VersionLongNumber": { + "type": "long", + "box": true, + "max": 100000, + "min": 1 + }, + "VersionString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": ".*[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\t]*.*" + }, + "ViewDefinition": { + "type": "structure", + "members": { + "IsProtected": { + "shape": "Boolean" + }, + "Definer": { + "shape": "ArnString" + }, + "SubObjects": { + "shape": "ViewSubObjectsList" + }, + "Representations": { + "shape": "ViewRepresentationList" + } + } + }, + "ViewDialect": { + "type": "string", + "enum": ["REDSHIFT", "ATHENA", "SPARK"] + }, + "ViewDialectVersionString": { + "type": "string", + "max": 255, + "min": 1, + "pattern": ".*[a-zA-Z0-9_.-]+.*" + }, + "ViewRepresentation": { + "type": "structure", + "members": { + "Dialect": { + "shape": "ViewDialect" + }, + "DialectVersion": { + "shape": "ViewDialectVersionString" + }, + "ViewOriginalText": { + "shape": "ViewTextString" + }, + "ViewExpandedText": { + "shape": "ViewTextString" + }, + "ValidationConnection": { + "shape": "NameString" + }, + "IsStale": { + "shape": "Boolean" + }, + "ValidationDryRunOpts": { + "shape": "ValidationDryRunOpts" + } + } + }, + "ViewRepresentationList": { + "type": "list", + "member": { + "shape": "ViewRepresentation" + }, + "max": 1000, + "min": 1 + }, + "ViewSubObjectsList": { + "type": "list", + "member": { + "shape": "ArnString" + }, + "max": 10, + "min": 0 + }, + "ViewTextString": { + "type": "string", + "max": 409600, + "min": 0 + }, + "ViewValidation": { + "type": "structure", + "members": { + "Dialect": { + "shape": "ViewDialect" + }, + "DialectVersion": { + "shape": "ViewDialectVersionString" + }, + "ViewValidationText": { + "shape": "ViewTextString" + }, + "UpdateTime": { + "shape": "Timestamp" + }, + "State": { + "shape": "ResourceState" + }, + "Error": { + "shape": "ErrorDetail" + } + } + }, + "ViewValidationList": { + "type": "list", + "member": { + "shape": "ViewValidation" + } + }, + "WorkerType": { + "type": "string", + "enum": ["Standard", "G_1X", "G_2X", "G_4X", "G_8X", "G_025X", "Z_2X"] + }, + "completedOn": { + "type": "long", + "box": true + }, + "lastModifiedOn": { + "type": "long", + "box": true + }, + "startedOn": { + "type": "long", + "box": true + } + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/s3Client.ts b/packages/core/src/sagemakerunifiedstudio/shared/client/s3Client.ts new file mode 100644 index 00000000000..d86c3904a07 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/s3Client.ts @@ -0,0 +1,147 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { S3 } from '@aws-sdk/client-s3' +import { getLogger } from '../../../shared/logger/logger' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' + +/** + * Represents an S3 path (bucket or prefix) + */ +export interface S3Path { + bucket: string + prefix?: string + displayName: string + isFolder: boolean + size?: number + lastModified?: Date +} + +/** + * Client for interacting with AWS S3 API using project credentials + */ +export class S3Client { + private s3Client: S3 | undefined + private readonly logger = getLogger() + + constructor( + private readonly region: string, + private readonly connectionCredentialsProvider: ConnectionCredentialsProvider + ) {} + + /** + * Lists S3 paths (folders and objects) using prefix-based navigation + * Uses S3's hierarchical folder-like structure by leveraging prefixes and delimiters + * @param bucket S3 bucket name to list objects from + * @param prefix Optional prefix to filter objects (acts like a folder path) + * @param continuationToken Optional continuation token for pagination + * @returns Object containing paths and nextToken for pagination + */ + public async listPaths( + bucket: string, + prefix?: string, + continuationToken?: string + ): Promise<{ paths: S3Path[]; nextToken?: string }> { + try { + this.logger.info(`S3Client: Listing paths in bucket ${bucket} with prefix ${prefix || 'root'}`) + + const s3Client = await this.getS3Client() + + // Call S3 ListObjectsV2 API with delimiter to simulate folder structure + // Delimiter '/' treats forward slashes as folder separators + // This returns both CommonPrefixes (folders) and Contents (files) + const response = await s3Client.listObjectsV2({ + Bucket: bucket, + Prefix: prefix, // Filter objects that start with this prefix + Delimiter: '/', // Treat '/' as folder separator for hierarchical listing + ContinuationToken: continuationToken, // For pagination + }) + + const paths: S3Path[] = [] + + // Process CommonPrefixes - these represent "folders" in S3 + // CommonPrefixes are object keys that share a common prefix up to the delimiter + if (response.CommonPrefixes) { + for (const commonPrefix of response.CommonPrefixes) { + if (commonPrefix.Prefix) { + // Extract folder name by removing the parent prefix and trailing slash + // Example: if prefix="folder1/" and commonPrefix="folder1/subfolder/" + // folderName becomes "subfolder" + const folderName = commonPrefix.Prefix.replace(prefix || '', '').replace('/', '') + paths.push({ + bucket, + prefix: commonPrefix.Prefix, // Full S3 prefix for this folder + displayName: folderName, // Human-readable folder name + isFolder: true, // Mark as folder for UI rendering + }) + } + } + } + + // Process Contents - these represent actual S3 objects (files) + if (response.Contents) { + for (const object of response.Contents) { + // Skip if no key or if key matches the prefix exactly (folder itself) + if (object.Key && object.Key !== prefix) { + // Extract file name by removing the parent prefix + // Example: if prefix="folder1/" and object.Key="folder1/file.txt" + // fileName becomes "file.txt" + const fileName = object.Key.replace(prefix || '', '') + + // Only include actual files (not folder markers ending with '/') + if (fileName && !fileName.endsWith('/')) { + paths.push({ + bucket, + prefix: object.Key, // Full S3 object key + displayName: fileName, // Human-readable file name + isFolder: false, // Mark as file for UI rendering + size: object.Size, // File size in bytes + lastModified: object.LastModified, // Last modification timestamp + }) + } + } + } + } + + this.logger.info(`S3Client: Found ${paths.length} paths in bucket ${bucket}`) + return { + paths, + nextToken: response.NextContinuationToken, + } + } catch (err) { + this.logger.error('S3Client: Failed to list paths: %s', err as Error) + throw err + } + } + + /** + * Gets the S3 client, initializing it if necessary + */ + private async getS3Client(): Promise { + if (!this.s3Client) { + try { + const credentialsProvider = async () => { + const credentials = await this.connectionCredentialsProvider.getCredentials() + return { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + expiration: credentials.expiration, + } + } + + this.s3Client = new S3({ + region: this.region, + credentials: credentialsProvider, + }) + this.logger.debug('S3Client: Successfully created S3 client') + } catch (err) { + this.logger.error('S3Client: Failed to create S3 client: %s', err as Error) + throw err + } + } + return this.s3Client + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/sqlWorkbenchClient.ts b/packages/core/src/sagemakerunifiedstudio/shared/client/sqlWorkbenchClient.ts new file mode 100644 index 00000000000..5513f139d2b --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/sqlWorkbenchClient.ts @@ -0,0 +1,318 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Service } from 'aws-sdk' +import { ServiceConfigurationOptions } from 'aws-sdk/lib/service' +import globals from '../../../shared/extensionGlobals' +import { getLogger } from '../../../shared/logger/logger' +import * as SQLWorkbench from './sqlworkbench' +import apiConfig = require('./sqlworkbench.json') +import { v4 as uuidv4 } from 'uuid' +import { getRedshiftTypeFromHost } from '../../explorer/nodes/utils' +import { DatabaseIntegrationConnectionAuthenticationTypes, RedshiftType } from '../../explorer/nodes/types' +import { ConnectionCredentialsProvider } from '../../auth/providers/connectionCredentialsProvider' +import { adaptConnectionCredentialsProvider } from './credentialsAdapter' + +/** + * Connection configuration for SQL Workbench + */ +export interface ConnectionConfig { + id: string + type: string + databaseType: string + connectableResourceIdentifier: string + connectableResourceType: string + database: string + auth?: { + secretArn?: string + } +} + +/** + * Resource parent information + */ +export interface ParentResource { + parentId: string + parentType: string +} + +/** + * Gets a SQL Workbench ARN + * @param region AWS region + * @param accountId Optional AWS account ID (will be determined if not provided) + * @returns SQL Workbench ARN + */ +export async function generateSqlWorkbenchArn(region: string, accountId: string): Promise { + return `arn:aws:sqlworkbench:${region}:${accountId}:connection/${uuidv4()}` +} + +/** + * Creates a connection configuration for Redshift + */ +export async function createRedshiftConnectionConfig( + host: string, + database: string, + accountId: string, + region: string, + secretArn?: string, + isGlueCatalogDatabase?: boolean +): Promise { + // Get Redshift deployment type from host + const redshiftDeploymentType = getRedshiftTypeFromHost(host) + + // Extract resource identifier from host + const resourceIdentifier = host.split('.')[0] + + if (!resourceIdentifier) { + throw new Error('Resource identifier could not be determined from host') + } + + // Create connection ID using the proper ARN format + const connectionId = await generateSqlWorkbenchArn(region, accountId) + + // Determine if serverless or cluster based on deployment type + const isServerless = + redshiftDeploymentType === RedshiftType.Serverless || + redshiftDeploymentType === RedshiftType.ServerlessDev || + redshiftDeploymentType === RedshiftType.ServerlessQA + + const isCluster = + redshiftDeploymentType === RedshiftType.Cluster || + redshiftDeploymentType === RedshiftType.ClusterDev || + redshiftDeploymentType === RedshiftType.ClusterQA + + // Validate the Redshift type + if (!isServerless && !isCluster) { + throw new Error(`Unsupported Redshift type for host: ${host}`) + } + + // Determine auth type based on the provided parameters + let authType: string + + if (secretArn) { + authType = DatabaseIntegrationConnectionAuthenticationTypes.SECRET + } else if (isCluster) { + authType = DatabaseIntegrationConnectionAuthenticationTypes.TEMPORARY_CREDENTIALS_WITH_IAM + } else { + // For serverless + authType = DatabaseIntegrationConnectionAuthenticationTypes.FEDERATED + } + + // Enforce specific authentication type for S3Table/RedLake databases + if (isGlueCatalogDatabase) { + authType = isServerless + ? DatabaseIntegrationConnectionAuthenticationTypes.FEDERATED + : DatabaseIntegrationConnectionAuthenticationTypes.TEMPORARY_CREDENTIALS_WITH_IAM + } + + // Create the connection configuration + const connectionConfig: ConnectionConfig = { + id: connectionId, + type: authType, + databaseType: 'REDSHIFT', + connectableResourceIdentifier: resourceIdentifier, + connectableResourceType: isServerless ? 'WORKGROUP' : 'CLUSTER', + database: database, + } + + // Add auth object for SECRET authentication type + if ( + (authType as DatabaseIntegrationConnectionAuthenticationTypes) === + DatabaseIntegrationConnectionAuthenticationTypes.SECRET && + secretArn + ) { + connectionConfig.auth = { secretArn } + } + + return connectionConfig +} + +/** + * Client for interacting with SQL Workbench API + */ +export class SQLWorkbenchClient { + private sqlClient: SQLWorkbench | undefined + private static instance: SQLWorkbenchClient | undefined + private readonly logger = getLogger() + + private constructor( + private readonly region: string, + private readonly connectionCredentialsProvider?: ConnectionCredentialsProvider + ) {} + + /** + * Gets a singleton instance of the SQLWorkbenchClient + * @returns SQLWorkbenchClient instance + */ + public static getInstance(region: string): SQLWorkbenchClient { + if (!SQLWorkbenchClient.instance) { + SQLWorkbenchClient.instance = new SQLWorkbenchClient(region) + } + return SQLWorkbenchClient.instance + } + + /** + * Creates a new SQLWorkbenchClient instance with specific credentials + * @param region AWS region + * @param connectionCredentialsProvider ConnectionCredentialsProvider + * @returns SQLWorkbenchClient instance with credentials provider + */ + public static createWithCredentials( + region: string, + connectionCredentialsProvider: ConnectionCredentialsProvider + ): SQLWorkbenchClient { + return new SQLWorkbenchClient(region, connectionCredentialsProvider) + } + + /** + * Gets the AWS region + * @returns AWS region + */ + public getRegion(): string { + return this.region + } + + /** + * Gets resources from SQL Workbench + * @param params Request parameters + * @returns Raw response from getResources API + */ + public async getResources(params: { + connection: ConnectionConfig + resourceType: string + includeChildren?: boolean + maxItems?: number + parents?: ParentResource[] + pageToken?: string + forceRefresh?: boolean + }): Promise { + try { + this.logger.info(`SQLWorkbenchClient: Getting resources in region ${this.region}`) + + const sqlClient = await this.getSQLClient() + + const requestParams = { + connection: params.connection, + type: params.resourceType, + maxItems: params.maxItems || 100, + parents: params.parents || [], + pageToken: params.pageToken, + forceRefresh: params.forceRefresh || true, + accountSettings: {}, + } + + // Call the GetResources API + const response = await sqlClient.getResources(requestParams).promise() + + return { + resources: response.resources || [], + nextToken: response.nextToken, + } + } catch (err) { + this.logger.error('SQLWorkbenchClient: Failed to get resources: %s', err as Error) + throw err + } + } + + /** + * Execute a SQL query + * @param connectionConfig Connection configuration + * @param query SQL query to execute + * @returns Query execution ID + */ + public async executeQuery(connectionConfig: ConnectionConfig, query: string): Promise { + try { + this.logger.info(`SQLWorkbenchClient: Executing query in region ${this.region}`) + + const sqlClient = await this.getSQLClient() + + // Call the ExecuteQuery API + const response = await sqlClient + .executeQuery({ + connection: connectionConfig as any, + databaseType: 'REDSHIFT', + accountSettings: {}, + executionContext: [ + { + parentType: 'DATABASE', + parentId: connectionConfig.database || '', + }, + ], + query, + queryExecutionType: 'NO_SESSION', + queryResponseDeliveryType: 'ASYNC', + maxItems: 100, + ignoreHistory: true, + tabId: 'data_explorer', + }) + .promise() + + // Log the response + this.logger.info( + `SQLWorkbenchClient: Query execution started with ID: ${response.queryExecutions?.[0]?.queryExecutionId}` + ) + + return response.queryExecutions?.[0]?.queryExecutionId + } catch (err) { + this.logger.error('SQLWorkbenchClient: Failed to execute query: %s', err as Error) + throw err + } + } + + /** + * Gets the SQL client, initializing it if necessary + */ + /** + * Gets the SQL Workbench endpoint URL for the given region + * @param region AWS region + * @returns SQL Workbench endpoint URL + */ + private getSQLWorkbenchEndpoint(region: string): string { + return `https://api-v2.sqlworkbench.${region}.amazonaws.com` + } + + private async getSQLClient(): Promise { + if (!this.sqlClient) { + try { + // Get the endpoint URL for the region + const endpoint = this.getSQLWorkbenchEndpoint(this.region) + this.logger.info(`Using SQL Workbench endpoint: ${endpoint}`) + + if (this.connectionCredentialsProvider) { + // Create client with provided credentials + this.sqlClient = (await globals.sdkClientBuilder.createAwsService( + Service, + { + apiConfig: apiConfig, + region: this.region, + endpoint: endpoint, + credentialProvider: adaptConnectionCredentialsProvider(this.connectionCredentialsProvider), + } as ServiceConfigurationOptions, + undefined, + false + )) as SQLWorkbench + } else { + // Use the SDK client builder for default credentials + this.sqlClient = (await globals.sdkClientBuilder.createAwsService( + Service, + { + apiConfig: apiConfig, + region: this.region, + endpoint: endpoint, + } as ServiceConfigurationOptions, + undefined, + false + )) as SQLWorkbench + } + + this.logger.debug('SQLWorkbenchClient: Successfully created SQL client') + } catch (err) { + this.logger.error('SQLWorkbenchClient: Failed to create SQL client: %s', err as Error) + throw err + } + } + return this.sqlClient + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/client/sqlworkbench.json b/packages/core/src/sagemakerunifiedstudio/shared/client/sqlworkbench.json new file mode 100644 index 00000000000..e403ec34a88 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/client/sqlworkbench.json @@ -0,0 +1,2102 @@ +{ + "version": "2.0", + "metadata": { + "apiVersion": "2024-02-12", + "auth": ["aws.auth#sigv4"], + "endpointPrefix": "sqlworkbench", + "protocol": "rest-json", + "protocols": ["rest-json"], + "serviceFullName": "AmazonSQLWorkbench", + "serviceId": "SQLWorkbench", + "signatureVersion": "v4", + "signingName": "sqlworkbench", + "uid": "sqlworkbench-2024-02-12" + }, + "operations": { + "CancelQueries": { + "name": "CancelQueries", + "http": { + "method": "POST", + "requestUri": "/database/cancelQueries", + "responseCode": 200 + }, + "input": { "shape": "CancelQueriesRequest" }, + "output": { "shape": "CancelQueriesResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "CreateConnection": { + "name": "CreateConnection", + "http": { + "method": "PUT", + "requestUri": "/connections", + "responseCode": 200 + }, + "input": { "shape": "CreateConnectionRequest" }, + "output": { "shape": "CreateConnectionResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "DeleteConnection": { + "name": "DeleteConnection", + "http": { + "method": "DELETE", + "requestUri": "/connections/{connectionId}", + "responseCode": 200 + }, + "input": { "shape": "DeleteConnectionRequest" }, + "output": { "shape": "DeleteConnectionResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "ExecuteQuery": { + "name": "ExecuteQuery", + "http": { + "method": "POST", + "requestUri": "/database/executeQuery", + "responseCode": 200 + }, + "input": { "shape": "ExecuteQueryRequest" }, + "output": { "shape": "ExecuteQueryResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "ExportQueryResults": { + "name": "ExportQueryResults", + "http": { + "method": "POST", + "requestUri": "/database/exportResults", + "responseCode": 200 + }, + "input": { "shape": "ExportQueryResultsRequest" }, + "output": { "shape": "ExportQueryResultsResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "GetConnectableResources": { + "name": "GetConnectableResources", + "http": { + "method": "POST", + "requestUri": "/database/getConnectableResources", + "responseCode": 200 + }, + "input": { "shape": "GetConnectableResourcesRequest" }, + "output": { "shape": "GetConnectableResourcesResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "GetConnection": { + "name": "GetConnection", + "http": { + "method": "GET", + "requestUri": "/connections/{connectionId}", + "responseCode": 200 + }, + "input": { "shape": "GetConnectionRequest" }, + "output": { "shape": "GetConnectionResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "GetDatabaseConfigurations": { + "name": "GetDatabaseConfigurations", + "http": { + "method": "POST", + "requestUri": "/database/configurations", + "responseCode": 200 + }, + "input": { "shape": "GetDatabaseConfigurationsRequest" }, + "output": { "shape": "GetDatabaseConfigurationsResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "GetQueryExecutionHistory": { + "name": "GetQueryExecutionHistory", + "http": { + "method": "POST", + "requestUri": "/queryExecutionHistory/details", + "responseCode": 200 + }, + "input": { "shape": "GetQueryExecutionHistoryRequest" }, + "output": { "shape": "GetQueryExecutionHistoryResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "GetQueryResult": { + "name": "GetQueryResult", + "http": { + "method": "POST", + "requestUri": "/database/getQueryResults", + "responseCode": 200 + }, + "input": { "shape": "GetQueryResultRequest" }, + "output": { "shape": "GetQueryResultResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "GetResources": { + "name": "GetResources", + "http": { + "method": "POST", + "requestUri": "/database/getResources", + "responseCode": 200 + }, + "input": { "shape": "GetResourcesRequest" }, + "output": { "shape": "GetResourcesResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "GetTabStates": { + "name": "GetTabStates", + "http": { + "method": "POST", + "requestUri": "/tab/state", + "responseCode": 200 + }, + "input": { "shape": "GetTabStatesRequest" }, + "output": { "shape": "GetTabStatesResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "ListQueryExecutionHistory": { + "name": "ListQueryExecutionHistory", + "http": { + "method": "POST", + "requestUri": "/queryExecutionHistory/list", + "responseCode": 200 + }, + "input": { "shape": "ListQueryExecutionHistoryRequest" }, + "output": { "shape": "ListQueryExecutionHistoryResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "ListTagsForResource": { + "name": "ListTagsForResource", + "http": { + "method": "GET", + "requestUri": "/tags/{resourceArn}", + "responseCode": 200 + }, + "input": { "shape": "ListTagsForResourceRequest" }, + "output": { "shape": "ListTagsForResourceResponse" }, + "errors": [ + { "shape": "BadRequestError" }, + { "shape": "ThrottlingException" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "PollQueryExecutionEvents": { + "name": "PollQueryExecutionEvents", + "http": { + "method": "POST", + "requestUri": "/database/pollQueryExecutionEvents", + "responseCode": 200 + }, + "input": { "shape": "PollQueryExecutionEventsRequest" }, + "output": { "shape": "PollQueryExecutionEventsResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "BadRequestError" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "TagResource": { + "name": "TagResource", + "http": { + "method": "POST", + "requestUri": "/tags/{resourceArn}", + "responseCode": 204 + }, + "input": { "shape": "TagResourceRequest" }, + "output": { "shape": "TagResourceResponse" }, + "errors": [ + { "shape": "BadRequestError" }, + { "shape": "ThrottlingException" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "UntagResource": { + "name": "UntagResource", + "http": { + "method": "DELETE", + "requestUri": "/tags/{resourceArn}", + "responseCode": 204 + }, + "input": { "shape": "UntagResourceRequest" }, + "output": { "shape": "UntagResourceResponse" }, + "errors": [ + { "shape": "BadRequestError" }, + { "shape": "ThrottlingException" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ], + "idempotent": true + }, + "UpdateConnection": { + "name": "UpdateConnection", + "http": { + "method": "POST", + "requestUri": "/connections", + "responseCode": 200 + }, + "input": { "shape": "UpdateConnectionRequest" }, + "output": { "shape": "UpdateConnectionResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "ConflictException" }, + { "shape": "InternalServerError" }, + { "shape": "ValidationException" } + ] + }, + "VerifyResourcesExistForTagris": { + "name": "VerifyResourcesExistForTagris", + "http": { + "method": "POST", + "requestUri": "/verifyResourcesExistForTagris", + "responseCode": 200 + }, + "input": { "shape": "TagrisVerifyResourcesExistInput" }, + "output": { "shape": "TagrisVerifyResourcesExistOutput" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "InternalServerError" }, + { "shape": "TagrisInvalidParameterException" }, + { "shape": "TagrisAccessDeniedException" }, + { "shape": "TagrisInvalidArnException" }, + { "shape": "ResourceNotFoundException" }, + { "shape": "TagrisInternalServiceException" }, + { "shape": "ServiceQuotaExceededException" }, + { "shape": "AccessDeniedException" }, + { "shape": "TagrisPartialResourcesExistResultsException" }, + { "shape": "TagrisThrottledException" }, + { "shape": "ConflictException" }, + { "shape": "ValidationException" } + ] + } + }, + "shapes": { + "AccessDeniedException": { + "type": "structure", + "required": ["message"], + "members": { + "message": { "shape": "String" }, + "code": { "shape": "ErrorCode" } + }, + "error": { + "httpStatusCode": 403, + "senderFault": true + }, + "exception": true + }, + "AckIds": { + "type": "list", + "member": { "shape": "AckIdsMemberString" } + }, + "AckIdsMemberString": { + "type": "string", + "max": 100, + "min": 0 + }, + "Arn": { + "type": "string", + "max": 1011, + "min": 20 + }, + "AvailableConnectionConfigurationOptions": { + "type": "list", + "member": { "shape": "AvailableConnectionConfigurationOptionsMemberString" } + }, + "AvailableConnectionConfigurationOptionsMemberString": { + "type": "string", + "max": 50, + "min": 0 + }, + "BadRequestError": { + "type": "structure", + "required": ["message"], + "members": { + "message": { "shape": "String" }, + "code": { "shape": "ErrorCode" } + }, + "error": { + "httpStatusCode": 400, + "senderFault": true + }, + "exception": true + }, + "Boolean": { + "type": "boolean", + "box": true + }, + "CancelQueriesRequest": { + "type": "structure", + "required": ["queryExecutionIds", "databaseType"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "queryExecutionIds": { "shape": "CancelQueriesRequestQueryExecutionIdsList" }, + "databaseType": { + "shape": "DatabaseType", + "location": "querystring", + "locationName": "databaseType" + } + } + }, + "CancelQueriesRequestQueryExecutionIdsList": { + "type": "list", + "member": { "shape": "CancelQueriesRequestQueryExecutionIdsListMemberString" }, + "max": 100, + "min": 1 + }, + "CancelQueriesRequestQueryExecutionIdsListMemberString": { + "type": "string", + "max": 100, + "min": 1 + }, + "CancelQueriesResponse": { + "type": "structure", + "required": ["cancelQueryResponses"], + "members": { + "cancelQueryResponses": { "shape": "CancelQueryResponses" } + } + }, + "CancelQueryResponse": { + "type": "structure", + "required": ["queryExecutionId"], + "members": { + "queryExecutionId": { "shape": "CancelQueryResponseQueryExecutionIdString" }, + "queryCancellationStatus": { "shape": "QueryCancellationStatus" } + } + }, + "CancelQueryResponseQueryExecutionIdString": { + "type": "string", + "max": 1000, + "min": 0 + }, + "CancelQueryResponses": { + "type": "list", + "member": { "shape": "CancelQueryResponse" } + }, + "ChildObjectTypes": { + "type": "list", + "member": { "shape": "ChildObjectTypesMemberString" } + }, + "ChildObjectTypesMemberString": { + "type": "string", + "max": 50, + "min": 0 + }, + "Columns": { + "type": "list", + "member": { "shape": "QueryResultCellValue" } + }, + "ConflictException": { + "type": "structure", + "required": ["message"], + "members": { + "message": { "shape": "String" }, + "code": { "shape": "ErrorCode" } + }, + "error": { + "httpStatusCode": 409, + "senderFault": true + }, + "exception": true + }, + "ConnectableResource": { + "type": "structure", + "required": ["displayName", "identifier", "childObjectTypes", "availableConnectionConfigurationOptions"], + "members": { + "displayName": { "shape": "ResourceDisplayName" }, + "identifier": { "shape": "ResourceIdentifier" }, + "type": { "shape": "ConnectableResourceTypeString" }, + "unavailable": { "shape": "Boolean" }, + "tooltipTranslationKey": { "shape": "ConnectableResourceTooltipTranslationKeyString" }, + "childObjectTypes": { "shape": "ChildObjectTypes" }, + "availableConnectionConfigurationOptions": { "shape": "AvailableConnectionConfigurationOptions" } + } + }, + "ConnectableResourceTooltipTranslationKeyString": { + "type": "string", + "max": 50, + "min": 0 + }, + "ConnectableResourceTypeString": { + "type": "string", + "max": 50, + "min": 0 + }, + "ConnectableResourceTypes": { + "type": "list", + "member": { "shape": "ConnectableResourceTypesMemberString" } + }, + "ConnectableResourceTypesMemberString": { + "type": "string", + "max": 50, + "min": 0 + }, + "ConnectableResources": { + "type": "list", + "member": { "shape": "ConnectableResource" } + }, + "Connection": { + "type": "structure", + "members": { + "id": { + "shape": "String", + "documentation": "

Id of the connection

" + }, + "name": { + "shape": "ConnectionName", + "documentation": "

Name of the connection

" + }, + "authenticationType": { + "shape": "ConnectionAuthenticationTypes", + "documentation": "

Number representing the type of authentication to use (2 = IAM, 3 = Username and Password). Today we only support the types 2 and 3

" + }, + "secretArn": { + "shape": "String", + "documentation": "

Secret that is linked to this connection

" + }, + "databaseName": { + "shape": "DatabaseName", + "documentation": "

Name of the database where the query is run

" + }, + "clusterId": { + "shape": "String", + "documentation": "

Id of the cluster of the connection

" + }, + "dbUser": { + "shape": "DbUser", + "documentation": "

User of the database

" + }, + "isServerless": { "shape": "Boolean" }, + "isProd": { "shape": "String" }, + "isEnabled": { "shape": "String" }, + "userSettings": { "shape": "UserSettings" }, + "recordDate": { "shape": "String" }, + "updatedDate": { "shape": "String" }, + "tags": { "shape": "Tags" }, + "databaseType": { "shape": "DatabaseType" }, + "connectableResourceType": { "shape": "String" }, + "connectableResourceIdentifier": { "shape": "ResourceIdentifier" } + } + }, + "ConnectionAuthenticationTypes": { + "type": "string", + "enum": ["2", "3", "4", "5", "6", "7", "8"], + "sensitive": true + }, + "ConnectionName": { + "type": "string", + "sensitive": true + }, + "ConnectionProperties": { + "type": "map", + "key": { "shape": "ConnectionPropertyKey" }, + "value": { "shape": "ConnectionPropertyValue" }, + "max": 50, + "min": 1 + }, + "ConnectionPropertyKey": { + "type": "string", + "max": 1000, + "min": 1 + }, + "ConnectionPropertyValue": { + "type": "string", + "max": 1000, + "min": 0 + }, + "CreateConnectionRequest": { + "type": "structure", + "required": ["name", "databaseName", "authenticationType"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "name": { + "shape": "CreateConnectionRequestNameString", + "documentation": "

Name of the connection

" + }, + "databaseName": { + "shape": "CreateConnectionRequestDatabaseNameString", + "documentation": "

Name of the database used for this connection

" + }, + "authenticationType": { + "shape": "CreateConnectionRequestAuthenticationTypeEnum", + "documentation": "

Number representing the type of authentication to use (2 = IAM, 3 = Username and Password, 4 = Federated connection)

" + }, + "isProd": { "shape": "CreateConnectionRequestIsProdString" }, + "userSettings": { "shape": "UserSettings" }, + "secretArn": { + "shape": "CreateConnectionRequestSecretArnString", + "documentation": "

secretArn for redshift cluster

" + }, + "clusterId": { + "shape": "CreateConnectionRequestClusterIdString", + "documentation": "

Id of the cluster used for this connection

" + }, + "isServerless": { + "shape": "Boolean", + "documentation": "

Is serverless connection

" + }, + "dbUser": { + "shape": "DbUser", + "documentation": "

User of the database used for this connection

" + }, + "isStoreNewSecret": { "shape": "CreateConnectionRequestIsStoreNewSecretString" }, + "username": { + "shape": "DbUser", + "documentation": "

Username used in the Username_Password connection type

" + }, + "password": { + "shape": "CreateConnectionRequestPasswordString", + "documentation": "

Password of the user used for this connection

" + }, + "tags": { "shape": "Tags" }, + "host": { + "shape": "CreateConnectionRequestHostString", + "documentation": "

Host address used for creating secret for Username_Password connection type

" + }, + "secretName": { "shape": "CreateConnectionRequestSecretNameString" }, + "description": { "shape": "CreateConnectionRequestDescriptionString" }, + "databaseType": { "shape": "DatabaseType" }, + "connectableResourceIdentifier": { + "shape": "CreateConnectionRequestConnectableResourceIdentifierString", + "documentation": "

Id of the connectable resource used for this connection

" + }, + "connectableResourceType": { + "shape": "CreateConnectionRequestConnectableResourceTypeString", + "documentation": "

Type of the connectable resource used for this connection

" + } + } + }, + "CreateConnectionRequestAuthenticationTypeEnum": { + "type": "string", + "enum": ["2", "3", "4", "5", "6", "7", "8"], + "max": 1, + "min": 1, + "sensitive": true + }, + "CreateConnectionRequestClusterIdString": { + "type": "string", + "max": 63, + "min": 1 + }, + "CreateConnectionRequestConnectableResourceIdentifierString": { + "type": "string", + "max": 63, + "min": 1, + "sensitive": true + }, + "CreateConnectionRequestConnectableResourceTypeString": { + "type": "string", + "max": 63, + "min": 1 + }, + "CreateConnectionRequestDatabaseNameString": { + "type": "string", + "max": 64, + "min": 1, + "sensitive": true + }, + "CreateConnectionRequestDescriptionString": { + "type": "string", + "max": 1000, + "min": 0 + }, + "CreateConnectionRequestHostString": { + "type": "string", + "max": 1000, + "min": 0 + }, + "CreateConnectionRequestIsProdString": { + "type": "string", + "max": 1000, + "min": 0 + }, + "CreateConnectionRequestIsStoreNewSecretString": { + "type": "string", + "max": 1000, + "min": 0 + }, + "CreateConnectionRequestNameString": { + "type": "string", + "max": 512, + "min": 1, + "sensitive": true + }, + "CreateConnectionRequestPasswordString": { + "type": "string", + "max": 64, + "min": 8, + "sensitive": true + }, + "CreateConnectionRequestSecretArnString": { + "type": "string", + "max": 1000, + "min": 1 + }, + "CreateConnectionRequestSecretNameString": { + "type": "string", + "max": 1000, + "min": 0 + }, + "CreateConnectionResponse": { + "type": "structure", + "members": { + "data": { "shape": "Connection" } + } + }, + "DatabaseAuthenticationMethod": { + "type": "string", + "enum": ["USERNAME_PASSWORD", "TEMPORARY_CREDENTIALS_WITH_IAM"] + }, + "DatabaseAuthenticationMethods": { + "type": "list", + "member": { "shape": "DatabaseAuthenticationMethod" } + }, + "DatabaseAuthenticationOption": { + "type": "structure", + "required": ["connectableResourceType", "authenticationMethods"], + "members": { + "connectableResourceType": { "shape": "String" }, + "authenticationMethods": { "shape": "DatabaseAuthenticationMethods" } + } + }, + "DatabaseAuthenticationOptions": { + "type": "list", + "member": { "shape": "DatabaseAuthenticationOption" } + }, + "DatabaseConfiguration": { + "type": "structure", + "required": [ + "databaseType", + "authenticationOptions", + "connectableResourceTypes", + "sessionSupported", + "eventAcknowledgementSupported", + "appendingLimitToQuerySupported", + "queryStatsSupported" + ], + "members": { + "databaseType": { "shape": "DatabaseType" }, + "authenticationOptions": { "shape": "DatabaseAuthenticationOptions" }, + "connectableResourceTypes": { "shape": "ConnectableResourceTypes" }, + "sessionSupported": { "shape": "Boolean" }, + "eventAcknowledgementSupported": { "shape": "Boolean" }, + "appendingLimitToQuerySupported": { "shape": "Boolean" }, + "queryStatsSupported": { "shape": "Boolean" } + } + }, + "DatabaseConfigurations": { + "type": "list", + "member": { "shape": "DatabaseConfiguration" } + }, + "DatabaseConnectionAccountSettings": { + "type": "structure", + "members": { + "masterKeyArn": { "shape": "KmsKeyArn" } + } + }, + "DatabaseConnectionConfiguration": { + "type": "structure", + "required": ["id", "type", "databaseType", "connectableResourceIdentifier", "connectableResourceType"], + "members": { + "id": { "shape": "DatabaseConnectionConfigurationIdString" }, + "type": { "shape": "DatabaseIntegrationConnectionAuthenticationTypes" }, + "auth": { "shape": "DatabaseConnectionConfigurationAuth" }, + "databaseType": { "shape": "DatabaseType" }, + "connectableResourceIdentifier": { "shape": "ResourceIdentifier" }, + "connectableResourceType": { "shape": "DatabaseConnectionConfigurationConnectableResourceTypeString" }, + "database": { "shape": "DatabaseName" } + } + }, + "DatabaseConnectionConfigurationAuth": { + "type": "structure", + "members": { + "secretArn": { "shape": "SecretKeyArn" }, + "username": { "shape": "DatabaseConnectionConfigurationAuthUsernameString" }, + "password": { "shape": "DatabaseConnectionConfigurationAuthPasswordString" } + } + }, + "DatabaseConnectionConfigurationAuthPasswordString": { + "type": "string", + "max": 1000, + "min": 0, + "sensitive": true + }, + "DatabaseConnectionConfigurationAuthUsernameString": { + "type": "string", + "max": 1000, + "min": 0, + "sensitive": true + }, + "DatabaseConnectionConfigurationConnectableResourceTypeString": { + "type": "string", + "max": 50, + "min": 0 + }, + "DatabaseConnectionConfigurationIdString": { + "type": "string", + "max": 2048, + "min": 32 + }, + "DatabaseIntegrationConnectionAuthenticationTypes": { + "type": "string", + "enum": ["4", "5", "6", "8"], + "sensitive": true + }, + "DatabaseName": { + "type": "string", + "max": 150, + "min": 0, + "sensitive": true + }, + "DatabaseType": { + "type": "string", + "enum": ["REDSHIFT", "ATHENA"] + }, + "DbUser": { + "type": "string", + "max": 127, + "min": 1, + "pattern": "[a-zA-Z0-9_][a-zA-Z_0-9+.@$-]*", + "sensitive": true + }, + "DeleteConnectionRequest": { + "type": "structure", + "required": ["connectionId"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "connectionId": { + "shape": "DeleteConnectionRequestConnectionIdString", + "documentation": "

Id of connection to delete

", + "location": "uri", + "locationName": "connectionId" + } + } + }, + "DeleteConnectionRequestConnectionIdString": { + "type": "string", + "max": 1000, + "min": 1 + }, + "DeleteConnectionResponse": { + "type": "structure", + "members": {} + }, + "ErrorCode": { + "type": "string", + "enum": ["QUERY_EXECUTION_NOT_FOUND", "QUERY_EXECUTION_ACCESS_DENIED"] + }, + "ExecuteQueryRequest": { + "type": "structure", + "required": ["query", "queryExecutionType", "queryResponseDeliveryType", "maxItems"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "connectionId": { "shape": "ExecuteQueryRequestConnectionIdString" }, + "databaseType": { + "shape": "DatabaseType", + "location": "querystring", + "locationName": "databaseType" + }, + "connection": { "shape": "DatabaseConnectionConfiguration" }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "tabId": { "shape": "ExecuteQueryRequestTabIdString" }, + "executionContext": { "shape": "ExecuteQueryRequestExecutionContextList" }, + "query": { "shape": "ExecuteQueryRequestQueryString" }, + "queryExecutionType": { "shape": "QueryExecutionType" }, + "sessionId": { "shape": "ExecuteQueryRequestSessionIdString" }, + "queryResponseDeliveryType": { "shape": "QueryResponseDeliveryType" }, + "maxItems": { "shape": "ExecuteQueryRequestMaxItemsInteger" }, + "limitQueryResults": { "shape": "ExecuteQueryRequestLimitQueryResultsInteger" }, + "isExplain": { "shape": "Boolean" }, + "ignoreHistory": { "shape": "Boolean" }, + "timeoutMillis": { "shape": "ExecuteQueryRequestTimeoutMillisInteger" } + } + }, + "ExecuteQueryRequestConnectionIdString": { + "type": "string", + "max": 2048, + "min": 32 + }, + "ExecuteQueryRequestExecutionContextList": { + "type": "list", + "member": { "shape": "ParentResource" }, + "max": 100, + "min": 0 + }, + "ExecuteQueryRequestLimitQueryResultsInteger": { + "type": "integer", + "box": true, + "max": 1000, + "min": 0 + }, + "ExecuteQueryRequestMaxItemsInteger": { + "type": "integer", + "box": true, + "max": 100, + "min": 20 + }, + "ExecuteQueryRequestQueryString": { + "type": "string", + "max": 1000000, + "min": 0, + "sensitive": true + }, + "ExecuteQueryRequestSessionIdString": { + "type": "string", + "max": 100, + "min": 0 + }, + "ExecuteQueryRequestTabIdString": { + "type": "string", + "max": 100, + "min": 1 + }, + "ExecuteQueryRequestTimeoutMillisInteger": { + "type": "integer", + "box": true, + "max": 120000, + "min": 0 + }, + "ExecuteQueryResponse": { + "type": "structure", + "required": ["queryExecutions"], + "members": { + "sessionId": { "shape": "ExecuteQueryResponseSessionIdString" }, + "queryExecutions": { "shape": "QueryExecutions" }, + "statusCode": { + "shape": "statusCode", + "location": "statusCode" + } + } + }, + "ExecuteQueryResponseSessionIdString": { + "type": "string", + "max": 100, + "min": 0 + }, + "ExportQueryResultsRequest": { + "type": "structure", + "required": ["queryExecutionId", "databaseType"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "queryExecutionId": { "shape": "ExportQueryResultsRequestQueryExecutionIdString" }, + "databaseType": { + "shape": "DatabaseType", + "location": "querystring", + "locationName": "databaseType" + }, + "fileType": { "shape": "FileType" } + } + }, + "ExportQueryResultsRequestQueryExecutionIdString": { + "type": "string", + "max": 100, + "min": 1 + }, + "ExportQueryResultsResponse": { + "type": "structure", + "required": ["queryResult", "contentType", "fileName"], + "members": { + "queryResult": { "shape": "StreamingBlob" }, + "contentType": { + "shape": "String", + "location": "header", + "locationName": "Content-Type" + }, + "fileName": { + "shape": "String", + "location": "header", + "locationName": "Content-Disposition" + } + }, + "payload": "queryResult" + }, + "FileType": { + "type": "string", + "enum": ["JSON", "CSV"] + }, + "FullQueryText": { + "type": "string", + "max": 1000000, + "min": 0, + "sensitive": true + }, + "GetConnectableResourcesRequest": { + "type": "structure", + "required": ["type", "maxItems", "databaseType"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "type": { "shape": "GetConnectableResourcesRequestTypeString" }, + "maxItems": { "shape": "GetConnectableResourcesRequestMaxItemsInteger" }, + "pageToken": { "shape": "PageToken" }, + "databaseType": { + "shape": "DatabaseType", + "location": "querystring", + "locationName": "databaseType" + } + } + }, + "GetConnectableResourcesRequestMaxItemsInteger": { + "type": "integer", + "box": true, + "max": 50, + "min": 20 + }, + "GetConnectableResourcesRequestTypeString": { + "type": "string", + "max": 150, + "min": 0 + }, + "GetConnectableResourcesResponse": { + "type": "structure", + "required": ["connectableResources"], + "members": { + "connectableResources": { "shape": "ConnectableResources" }, + "nextToken": { "shape": "String" } + } + }, + "GetConnectionRequest": { + "type": "structure", + "required": ["connectionId"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "connectionId": { + "shape": "GetConnectionRequestConnectionIdString", + "documentation": "

Id of connection to delete

", + "location": "uri", + "locationName": "connectionId" + } + } + }, + "GetConnectionRequestConnectionIdString": { + "type": "string", + "max": 1000, + "min": 1 + }, + "GetConnectionResponse": { + "type": "structure", + "members": { + "data": { "shape": "Connection" } + } + }, + "GetDatabaseConfigurationsRequest": { + "type": "structure", + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" } + } + }, + "GetDatabaseConfigurationsResponse": { + "type": "structure", + "members": { + "configurations": { "shape": "DatabaseConfigurations" } + } + }, + "GetQueryExecutionHistoryRequest": { + "type": "structure", + "required": ["queryExecutionId"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "queryExecutionId": { "shape": "GetQueryExecutionHistoryRequestQueryExecutionIdString" }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" } + } + }, + "GetQueryExecutionHistoryRequestQueryExecutionIdString": { + "type": "string", + "max": 100, + "min": 1 + }, + "GetQueryExecutionHistoryResponse": { + "type": "structure", + "members": { + "id": { "shape": "String" }, + "querySourceId": { "shape": "String" }, + "queryStartTime": { "shape": "Long" }, + "queryEndTime": { "shape": "Long" }, + "status": { "shape": "QueryExecutionStatus" }, + "queryText": { "shape": "FullQueryText" }, + "serializedMetadata": { "shape": "SerializedMetadata" }, + "serializedQueryStats": { "shape": "SerializedQueryStats" }, + "databaseType": { "shape": "DatabaseType" } + } + }, + "GetQueryResultRequest": { + "type": "structure", + "required": ["queryExecutionId", "databaseType"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "queryExecutionId": { "shape": "GetQueryResultRequestQueryExecutionIdString" }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "pageToken": { "shape": "PageToken" }, + "databaseType": { + "shape": "DatabaseType", + "location": "querystring", + "locationName": "databaseType" + }, + "pageSize": { "shape": "GetQueryResultRequestPageSizeInteger" } + } + }, + "GetQueryResultRequestPageSizeInteger": { + "type": "integer", + "box": true, + "min": 0 + }, + "GetQueryResultRequestQueryExecutionIdString": { + "type": "string", + "max": 100, + "min": 1 + }, + "GetQueryResultResponse": { + "type": "structure", + "members": { + "queryResult": { "shape": "QueryResult" }, + "nextToken": { "shape": "String" }, + "previousToken": { "shape": "String" } + } + }, + "GetResourcesRequest": { + "type": "structure", + "required": ["parents", "type", "maxItems"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "connectionId": { "shape": "GetResourcesRequestConnectionIdString" }, + "databaseType": { + "shape": "DatabaseType", + "location": "querystring", + "locationName": "databaseType" + }, + "connection": { "shape": "DatabaseConnectionConfiguration" }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "parents": { "shape": "ParentResources" }, + "type": { "shape": "GetResourcesRequestTypeString" }, + "maxItems": { "shape": "GetResourcesRequestMaxItemsInteger" }, + "pageToken": { "shape": "PageToken" }, + "forceRefresh": { "shape": "Boolean" }, + "forceRefreshRecursive": { "shape": "Boolean" } + } + }, + "GetResourcesRequestConnectionIdString": { + "type": "string", + "max": 2048, + "min": 32 + }, + "GetResourcesRequestMaxItemsInteger": { + "type": "integer", + "box": true, + "max": 100, + "min": 20 + }, + "GetResourcesRequestTypeString": { + "type": "string", + "max": 150, + "min": 0 + }, + "GetResourcesResponse": { + "type": "structure", + "members": { + "resources": { "shape": "Resources" }, + "nextToken": { "shape": "String" }, + "statusCode": { + "shape": "statusCode", + "location": "statusCode" + }, + "connectionProperties": { "shape": "ConnectionProperties" } + } + }, + "GetTabStatesRequest": { + "type": "structure", + "required": ["tabId"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "tabId": { "shape": "String" } + } + }, + "GetTabStatesResponse": { + "type": "structure", + "required": ["queryExecutionStates"], + "members": { + "queryExecutionStates": { "shape": "QueryExecutionStates" }, + "sessionId": { "shape": "String" } + } + }, + "Integer": { + "type": "integer", + "box": true + }, + "InternalServerError": { + "type": "structure", + "required": ["message"], + "members": { + "message": { "shape": "String" }, + "code": { "shape": "ErrorCode" } + }, + "error": { "httpStatusCode": 500 }, + "exception": true, + "fault": true + }, + "KmsKeyArn": { + "type": "string", + "max": 1000, + "min": 0, + "pattern": "arn:.*" + }, + "ListQueryExecutionHistoryRequest": { + "type": "structure", + "required": ["maxItems"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "maxItems": { "shape": "ListQueryExecutionHistoryRequestMaxItemsInteger" }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "pageToken": { "shape": "ListQueryExecutionHistoryRequestPageTokenString" }, + "querySourceId": { "shape": "ListQueryExecutionHistoryRequestQuerySourceIdString" }, + "databaseType": { "shape": "DatabaseType" }, + "status": { "shape": "QueryExecutionStatus" }, + "startTime": { "shape": "QueryHistoryTimestamp" }, + "endTime": { "shape": "QueryHistoryTimestamp" }, + "containsText": { "shape": "ListQueryExecutionHistoryRequestContainsTextString" } + } + }, + "ListQueryExecutionHistoryRequestContainsTextString": { + "type": "string", + "max": 100, + "min": 0 + }, + "ListQueryExecutionHistoryRequestMaxItemsInteger": { + "type": "integer", + "box": true, + "max": 100, + "min": 1 + }, + "ListQueryExecutionHistoryRequestPageTokenString": { + "type": "string", + "max": 10000, + "min": 0 + }, + "ListQueryExecutionHistoryRequestQuerySourceIdString": { + "type": "string", + "max": 100, + "min": 0 + }, + "ListQueryExecutionHistoryResponse": { + "type": "structure", + "required": ["items"], + "members": { + "items": { "shape": "QueryExecutionHistoryPreviews" }, + "nextToken": { "shape": "ListQueryExecutionHistoryResponseNextTokenString" } + } + }, + "ListQueryExecutionHistoryResponseNextTokenString": { + "type": "string", + "max": 1000, + "min": 0 + }, + "ListTagsForResourceRequest": { + "type": "structure", + "required": ["resourceArn"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "resourceArn": { + "shape": "Arn", + "location": "uri", + "locationName": "resourceArn" + } + } + }, + "ListTagsForResourceResponse": { + "type": "structure", + "required": ["tags"], + "members": { + "tags": { "shape": "Tags" } + } + }, + "Long": { + "type": "long", + "box": true + }, + "PageToken": { + "type": "string", + "max": 1000, + "min": 0 + }, + "ParentResource": { + "type": "structure", + "required": ["parentId", "parentType"], + "members": { + "parentId": { "shape": "ParentResourceParentIdString" }, + "parentType": { "shape": "ParentResourceParentTypeString" } + } + }, + "ParentResourceParentIdString": { + "type": "string", + "max": 1000, + "min": 1, + "sensitive": true + }, + "ParentResourceParentTypeString": { + "type": "string", + "max": 100, + "min": 1 + }, + "ParentResources": { + "type": "list", + "member": { "shape": "ParentResource" } + }, + "PollQueryExecutionEventsRequest": { + "type": "structure", + "required": ["queryExecutionIds", "databaseType"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "queryExecutionIds": { "shape": "PollQueryExecutionEventsRequestQueryExecutionIdsList" }, + "accountSettings": { "shape": "DatabaseConnectionAccountSettings" }, + "databaseType": { + "shape": "DatabaseType", + "location": "querystring", + "locationName": "databaseType" + }, + "ackIds": { "shape": "AckIds" } + } + }, + "PollQueryExecutionEventsRequestQueryExecutionIdsList": { + "type": "list", + "member": { "shape": "PollQueryExecutionEventsRequestQueryExecutionIdsListMemberString" }, + "max": 100, + "min": 1 + }, + "PollQueryExecutionEventsRequestQueryExecutionIdsListMemberString": { + "type": "string", + "max": 100, + "min": 1 + }, + "PollQueryExecutionEventsResponse": { + "type": "structure", + "members": { + "events": { "shape": "QueryExecutionEvents" } + } + }, + "QueryCancellationStatus": { + "type": "string", + "enum": ["CANCELLED", "DOES_NOT_EXISTS", "ALREADY_FINISHED", "CANCELLATION_FAILED"] + }, + "QueryExecution": { + "type": "structure", + "required": ["queryExecutionId"], + "members": { + "queryExecutionStatus": { "shape": "QueryExecutionStatus" }, + "queryExecutionId": { "shape": "QueryExecutionQueryExecutionIdString" }, + "queryResult": { "shape": "QueryResult" }, + "queryText": { "shape": "QueryText" } + } + }, + "QueryExecutionEvent": { + "type": "structure", + "required": ["queryExecutionEventType", "queryExecutionId"], + "members": { + "queryExecutionEventType": { "shape": "QueryExecutionEventType" }, + "queryExecutionId": { "shape": "QueryExecutionEventQueryExecutionIdString" }, + "queryExecutionStatus": { "shape": "QueryExecutionStatus" }, + "queryResult": { "shape": "QueryResult" }, + "nextToken": { "shape": "String" }, + "ackId": { "shape": "String" } + } + }, + "QueryExecutionEventQueryExecutionIdString": { + "type": "string", + "max": 100, + "min": 0 + }, + "QueryExecutionEventType": { + "type": "string", + "enum": ["QUERY_EXECUTION_STATUS", "QUERY_EXECUTION_RESULT"] + }, + "QueryExecutionEvents": { + "type": "list", + "member": { "shape": "QueryExecutionEvent" } + }, + "QueryExecutionHistoryPreview": { + "type": "structure", + "members": { + "id": { "shape": "String" }, + "querySourceId": { "shape": "String" }, + "queryStartTime": { "shape": "Long" }, + "queryEndTime": { "shape": "Long" }, + "status": { "shape": "QueryExecutionStatus" }, + "queryTextPreview": { "shape": "QueryTextPreview" }, + "serializedMetadata": { "shape": "SerializedMetadata" }, + "databaseType": { "shape": "DatabaseType" } + } + }, + "QueryExecutionHistoryPreviews": { + "type": "list", + "member": { "shape": "QueryExecutionHistoryPreview" } + }, + "QueryExecutionQueryExecutionIdString": { + "type": "string", + "max": 100, + "min": 0 + }, + "QueryExecutionState": { + "type": "structure", + "required": ["queryExecutionId", "status", "databaseType"], + "members": { + "queryExecutionId": { "shape": "String" }, + "status": { "shape": "String" }, + "databaseType": { "shape": "DatabaseType" } + } + }, + "QueryExecutionStates": { + "type": "list", + "member": { "shape": "QueryExecutionState" } + }, + "QueryExecutionStatus": { + "type": "string", + "enum": ["SCHEDULED", "RUNNING", "FAILED", "CANCELLED", "FINISHED"] + }, + "QueryExecutionType": { + "type": "string", + "enum": ["PERSIST_SESSION", "NO_SESSION"] + }, + "QueryExecutionWarning": { + "type": "structure", + "members": { + "message": { "shape": "QueryExecutionWarningMessage" }, + "level": { "shape": "QueryExecutionWarningLevel" } + } + }, + "QueryExecutionWarningLevel": { + "type": "string", + "enum": ["INFO", "WARNING"] + }, + "QueryExecutionWarningMessage": { + "type": "string", + "max": 1000, + "min": 0, + "sensitive": true + }, + "QueryExecutionWarnings": { + "type": "list", + "member": { "shape": "QueryExecutionWarning" } + }, + "QueryExecutions": { + "type": "list", + "member": { "shape": "QueryExecution" } + }, + "QueryHistoryTimestamp": { + "type": "long", + "box": true + }, + "QueryResponseDeliveryType": { + "type": "string", + "enum": ["SYNC", "ASYNC"] + }, + "QueryResult": { + "type": "structure", + "members": { + "queryExecutionStatus": { "shape": "QueryExecutionStatus" }, + "headers": { "shape": "QueryResultHeaders" }, + "rows": { "shape": "Rows" }, + "affectedRows": { "shape": "Integer" }, + "totalRowCount": { "shape": "Integer" }, + "elapsedTime": { "shape": "Long" }, + "errorMessage": { "shape": "QueryResultErrorMessage" }, + "errorPosition": { "shape": "Integer" }, + "queryResultWarningCode": { "shape": "QueryResultQueryResultWarningCodeString" }, + "warnings": { "shape": "QueryExecutionWarnings" }, + "queryExecutionId": { "shape": "String" }, + "sessionId": { "shape": "String" }, + "queryText": { "shape": "QueryText" }, + "statementType": { "shape": "StatementType" }, + "serializedMetadata": { "shape": "SerializedMetadata" }, + "connectionProperties": { "shape": "ConnectionProperties" } + } + }, + "QueryResultCellType": { + "type": "string", + "enum": ["STRING", "BOOLEAN", "INTEGER", "BIG_INTEGER", "FLOAT", "BIG_DECIMAL", "DATE", "TIME", "DATETIME"] + }, + "QueryResultCellValue": { + "type": "string", + "sensitive": true + }, + "QueryResultErrorMessage": { + "type": "string", + "max": 1000, + "min": 0, + "sensitive": true + }, + "QueryResultHeader": { + "type": "structure", + "required": ["displayName", "type"], + "members": { + "displayName": { "shape": "QueryResultHeaderDisplayName" }, + "type": { "shape": "QueryResultCellType" } + } + }, + "QueryResultHeaderDisplayName": { + "type": "string", + "sensitive": true + }, + "QueryResultHeaders": { + "type": "list", + "member": { "shape": "QueryResultHeader" } + }, + "QueryResultQueryResultWarningCodeString": { + "type": "string", + "max": 100, + "min": 0 + }, + "QueryText": { + "type": "string", + "sensitive": true + }, + "QueryTextPreview": { + "type": "string", + "max": 150, + "min": 0, + "sensitive": true + }, + "Resource": { + "type": "structure", + "required": ["displayName", "identifier", "childObjectTypes"], + "members": { + "displayName": { "shape": "ResourceDisplayName" }, + "identifier": { "shape": "ResourceIdentifier" }, + "type": { "shape": "ResourceTypeString" }, + "unavailable": { "shape": "Boolean" }, + "tooltipTranslationKey": { "shape": "ResourceTooltipTranslationKeyString" }, + "childObjectTypes": { "shape": "ChildObjectTypes" }, + "allowedActions": { "shape": "ResourceActions" }, + "resourceMetadata": { "shape": "ResourceMetadataItems" } + } + }, + "ResourceAction": { + "type": "string", + "enum": ["Drop", "Truncate", "GenerateDefinition", "GenerateSelectQuery"] + }, + "ResourceActions": { + "type": "list", + "member": { "shape": "ResourceAction" } + }, + "ResourceDisplayName": { + "type": "string", + "max": 150, + "min": 0, + "sensitive": true + }, + "ResourceIdentifier": { + "type": "string", + "max": 150, + "min": 0, + "sensitive": true + }, + "ResourceMetadata": { + "type": "structure", + "members": { + "key": { "shape": "String" }, + "value": { "shape": "String" } + } + }, + "ResourceMetadataItems": { + "type": "list", + "member": { "shape": "ResourceMetadata" } + }, + "ResourceNotFoundException": { + "type": "structure", + "required": ["message"], + "members": { + "message": { "shape": "String" }, + "code": { "shape": "ErrorCode" } + }, + "error": { + "httpStatusCode": 404, + "senderFault": true + }, + "exception": true + }, + "ResourceTooltipTranslationKeyString": { + "type": "string", + "max": 50, + "min": 0 + }, + "ResourceTypeString": { + "type": "string", + "max": 50, + "min": 0 + }, + "Resources": { + "type": "list", + "member": { "shape": "Resource" } + }, + "Row": { + "type": "structure", + "members": { + "row": { "shape": "Columns" } + } + }, + "Rows": { + "type": "list", + "member": { "shape": "Row" } + }, + "SecretKeyArn": { + "type": "string", + "max": 1000, + "min": 0, + "pattern": "arn:.*" + }, + "SerializedMetadata": { + "type": "string", + "max": 1000000, + "min": 0, + "sensitive": true + }, + "SerializedQueryStats": { + "type": "string", + "max": 1000000, + "min": 0, + "sensitive": true + }, + "ServiceQuotaExceededException": { + "type": "structure", + "required": ["message"], + "members": { + "message": { "shape": "String" }, + "code": { "shape": "ErrorCode" } + }, + "error": { + "httpStatusCode": 402, + "senderFault": true + }, + "exception": true + }, + "SqlworkbenchSource": { + "type": "string", + "enum": ["SUS", "RQEV2"] + }, + "StatementType": { + "type": "string", + "enum": ["DQL", "DML", "DDL", "DCL", "Utility"] + }, + "StreamingBlob": { + "type": "blob", + "streaming": true + }, + "String": { "type": "string" }, + "TagKey": { + "type": "string", + "max": 128, + "min": 1, + "pattern": "([\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*)" + }, + "TagKeyList": { + "type": "list", + "member": { "shape": "TagKey" }, + "max": 6500, + "min": 1 + }, + "TagResourceRequest": { + "type": "structure", + "required": ["resourceArn", "tags"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "resourceArn": { + "shape": "Arn", + "location": "uri", + "locationName": "resourceArn" + }, + "tags": { "shape": "Tags" } + } + }, + "TagResourceResponse": { + "type": "structure", + "members": {} + }, + "TagValue": { + "type": "string", + "max": 256, + "min": 0, + "pattern": "([\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*)" + }, + "TagrisAccessDeniedException": { + "type": "structure", + "members": { + "message": { "shape": "TagrisExceptionMessage" } + }, + "exception": true + }, + "TagrisAccountId": { + "type": "string", + "max": 12, + "min": 12 + }, + "TagrisAmazonResourceName": { + "type": "string", + "max": 1011, + "min": 1 + }, + "TagrisExceptionMessage": { + "type": "string", + "max": 2048, + "min": 0 + }, + "TagrisInternalId": { + "type": "string", + "max": 64, + "min": 0 + }, + "TagrisInternalServiceException": { + "type": "structure", + "members": { + "message": { "shape": "TagrisExceptionMessage" } + }, + "exception": true, + "fault": true + }, + "TagrisInvalidArnException": { + "type": "structure", + "members": { + "message": { "shape": "TagrisExceptionMessage" }, + "sweepListItem": { "shape": "TagrisSweepListItem" } + }, + "exception": true + }, + "TagrisInvalidParameterException": { + "type": "structure", + "members": { + "message": { "shape": "TagrisExceptionMessage" } + }, + "exception": true + }, + "TagrisPartialResourcesExistResultsException": { + "type": "structure", + "members": { + "message": { "shape": "TagrisExceptionMessage" }, + "resourceExistenceInformation": { "shape": "TagrisSweepListResult" } + }, + "exception": true + }, + "TagrisStatus": { + "type": "string", + "enum": ["ACTIVE", "NOT_ACTIVE"] + }, + "TagrisSweepList": { + "type": "list", + "member": { "shape": "TagrisSweepListItem" } + }, + "TagrisSweepListItem": { + "type": "structure", + "members": { + "TagrisAccountId": { "shape": "TagrisAccountId" }, + "TagrisAmazonResourceName": { "shape": "TagrisAmazonResourceName" }, + "TagrisInternalId": { "shape": "TagrisInternalId" }, + "TagrisVersion": { "shape": "TagrisVersion" } + } + }, + "TagrisSweepListResult": { + "type": "map", + "key": { "shape": "TagrisAmazonResourceName" }, + "value": { "shape": "TagrisStatus" } + }, + "TagrisThrottledException": { + "type": "structure", + "members": { + "message": { "shape": "TagrisExceptionMessage" } + }, + "exception": true + }, + "TagrisVerifyResourcesExistInput": { + "type": "structure", + "required": ["TagrisSweepList"], + "members": { + "TagrisSweepList": { "shape": "TagrisSweepList" } + } + }, + "TagrisVerifyResourcesExistOutput": { + "type": "structure", + "required": ["TagrisSweepListResult"], + "members": { + "TagrisSweepListResult": { "shape": "TagrisSweepListResult" } + } + }, + "TagrisVersion": { "type": "long" }, + "Tags": { + "type": "map", + "key": { "shape": "TagKey" }, + "value": { "shape": "TagValue" }, + "max": 50, + "min": 1 + }, + "ThrottlingException": { + "type": "structure", + "required": ["message"], + "members": { + "message": { "shape": "String" }, + "code": { "shape": "ErrorCode" } + }, + "error": { + "httpStatusCode": 429, + "senderFault": true + }, + "exception": true + }, + "UntagResourceRequest": { + "type": "structure", + "required": ["resourceArn", "tagKeys"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "resourceArn": { + "shape": "Arn", + "location": "uri", + "locationName": "resourceArn" + }, + "tagKeys": { + "shape": "TagKeyList", + "location": "querystring", + "locationName": "tagKeys" + } + } + }, + "UntagResourceResponse": { + "type": "structure", + "members": {} + }, + "UpdateConnectionRequest": { + "type": "structure", + "required": ["id", "authenticationType"], + "members": { + "sqlworkbenchSource": { + "shape": "SqlworkbenchSource", + "location": "header", + "locationName": "sqlworkbench-source" + }, + "id": { + "shape": "UpdateConnectionRequestIdString", + "documentation": "

Id of the connection to update

" + }, + "name": { + "shape": "UpdateConnectionRequestNameString", + "documentation": "

Name of the connection

" + }, + "databaseName": { + "shape": "UpdateConnectionRequestDatabaseNameString", + "documentation": "

Name of the database used for this connection

" + }, + "authenticationType": { + "shape": "UpdateConnectionRequestAuthenticationTypeEnum", + "documentation": "

Number representing the type of authentication to use (2 = IAM, 3 = Username and Password, 4 = Federated connection)

" + }, + "secretArn": { + "shape": "UpdateConnectionRequestSecretArnString", + "documentation": "

secretArn for redshift cluster

" + }, + "clusterId": { + "shape": "UpdateConnectionRequestClusterIdString", + "documentation": "

Id of the cluster used for this connection

" + }, + "isServerless": { + "shape": "Boolean", + "documentation": "

Is serverless connection

" + }, + "dbUser": { + "shape": "DbUser", + "documentation": "

User of the database used for this connection

" + }, + "username": { + "shape": "DbUser", + "documentation": "

Username used in the Username_Password connection type

" + }, + "password": { + "shape": "UpdateConnectionRequestPasswordString", + "documentation": "

Password of the user used for this connection

" + }, + "host": { + "shape": "String", + "documentation": "

Host address used for creating secret for Username_Password connection type

" + }, + "databaseType": { "shape": "DatabaseType" }, + "connectableResourceIdentifier": { + "shape": "UpdateConnectionRequestConnectableResourceIdentifierString", + "documentation": "

Id of the connectable resource used for this connection

" + }, + "connectableResourceType": { + "shape": "UpdateConnectionRequestConnectableResourceTypeString", + "documentation": "

Type of the connectable resource used for this connection

" + } + } + }, + "UpdateConnectionRequestAuthenticationTypeEnum": { + "type": "string", + "enum": ["2", "3", "4", "5", "6", "7", "8"], + "max": 1, + "min": 1, + "sensitive": true + }, + "UpdateConnectionRequestClusterIdString": { + "type": "string", + "max": 63, + "min": 1 + }, + "UpdateConnectionRequestConnectableResourceIdentifierString": { + "type": "string", + "max": 63, + "min": 1, + "sensitive": true + }, + "UpdateConnectionRequestConnectableResourceTypeString": { + "type": "string", + "max": 63, + "min": 1 + }, + "UpdateConnectionRequestDatabaseNameString": { + "type": "string", + "max": 64, + "min": 1, + "sensitive": true + }, + "UpdateConnectionRequestIdString": { + "type": "string", + "max": 2048, + "min": 32 + }, + "UpdateConnectionRequestNameString": { + "type": "string", + "max": 512, + "min": 1, + "sensitive": true + }, + "UpdateConnectionRequestPasswordString": { + "type": "string", + "max": 64, + "min": 8, + "sensitive": true + }, + "UpdateConnectionRequestSecretArnString": { + "type": "string", + "max": 1000, + "min": 1 + }, + "UpdateConnectionResponse": { + "type": "structure", + "members": { + "data": { "shape": "Connection" } + } + }, + "UserSettings": { + "type": "string", + "sensitive": true + }, + "ValidationException": { + "type": "structure", + "required": ["message"], + "members": { + "message": { "shape": "String" }, + "code": { "shape": "ErrorCode" } + }, + "error": { + "httpStatusCode": 400, + "senderFault": true + }, + "exception": true + }, + "statusCode": { + "type": "integer", + "box": true, + "max": 500, + "min": 100 + } + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/errors.ts b/packages/core/src/sagemakerunifiedstudio/shared/errors.ts new file mode 100644 index 00000000000..1d582c22d74 --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/errors.ts @@ -0,0 +1,8 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const errorCode = { + failedAuthConnecton: 'FailedAuthConnecton', +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/smusUtils.ts b/packages/core/src/sagemakerunifiedstudio/shared/smusUtils.ts new file mode 100644 index 00000000000..3fe1dd9b27b --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/smusUtils.ts @@ -0,0 +1,353 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../../shared/logger/logger' +import { ToolkitError } from '../../shared/errors' +import { isSageMaker } from '../../shared/extensionUtilities' +import { getResourceMetadata } from './utils/resourceMetadataUtils' +import fetch from 'node-fetch' + +/** + * Represents SSO instance information retrieved from DataZone + */ +export interface SsoInstanceInfo { + issuerUrl: string + ssoInstanceId: string + clientId: string + region: string +} + +/** + * Response from DataZone /sso/login endpoint + */ +interface DataZoneSsoLoginResponse { + redirectUrl: string +} + +/** + * Credential expiry time constants for SMUS providers (in milliseconds) + */ +export const SmusCredentialExpiry = { + /** Domain Execution Role (DER) credentials expiry time: 10 minutes */ + derExpiryMs: 10 * 60 * 1000, + /** Project Role credentials expiry time: 10 minutes */ + projectExpiryMs: 10 * 60 * 1000, + /** Connection credentials expiry time: 10 minutes */ + connectionExpiryMs: 10 * 60 * 1000, +} as const + +/** + * Error codes for SMUS-related operations + */ +export const SmusErrorCodes = { + /** Error code for when no active SMUS connection is available */ + NoActiveConnection: 'NoActiveConnection', + /** Error code for when API calls timeout */ + ApiTimeout: 'ApiTimeout', + /** Error code for when SMUS login fails */ + SmusLoginFailed: 'SmusLoginFailed', + /** Error code for when redeeming access token fails */ + RedeemAccessTokenFailed: 'RedeemAccessTokenFailed', +} as const + +/** + * Timeout constants for SMUS API calls (in milliseconds) + */ +export const SmusTimeouts = { + /** Default timeout for API calls: 10 seconds */ + apiCallTimeoutMs: 10 * 1000, +} as const + +/** + * Interface for AWS credential objects that need validation + */ +interface CredentialObject { + accessKeyId?: unknown + secretAccessKey?: unknown + sessionToken?: unknown + expiration?: unknown +} + +/** + * Validates AWS credential fields and throws appropriate errors if invalid + * @param credentials The credential object to validate + * @param errorCode The error code to use in ToolkitError + * @param contextMessage The context message for error messages (e.g., "API response", "project credential response") + * @throws ToolkitError if any credential field is invalid + */ +export function validateCredentialFields( + credentials: CredentialObject, + errorCode: string, + contextMessage: string, + validateExpireTime: boolean = false +): void { + if (!credentials.accessKeyId || typeof credentials.accessKeyId !== 'string') { + throw new ToolkitError(`Invalid accessKeyId in ${contextMessage}: ${typeof credentials.accessKeyId}`, { + code: errorCode, + }) + } + if (!credentials.secretAccessKey || typeof credentials.secretAccessKey !== 'string') { + throw new ToolkitError(`Invalid secretAccessKey in ${contextMessage}: ${typeof credentials.secretAccessKey}`, { + code: errorCode, + }) + } + if (!credentials.sessionToken || typeof credentials.sessionToken !== 'string') { + throw new ToolkitError(`Invalid sessionToken in ${contextMessage}: ${typeof credentials.sessionToken}`, { + code: errorCode, + }) + } + if (validateExpireTime) { + if (!credentials.expiration || !(credentials.expiration instanceof Date)) { + throw new ToolkitError(`Invalid expireTime in ${contextMessage}: ${typeof credentials.expiration}`, { + code: errorCode, + }) + } + } +} + +/** + * Utility class for SageMaker Unified Studio domain URL parsing and validation + */ +export class SmusUtils { + private static readonly logger = getLogger() + + /** + * Extracts the domain ID from a SageMaker Unified Studio domain URL + * @param domainUrl The SageMaker Unified Studio domain URL + * @returns The extracted domain ID or undefined if not found + */ + public static extractDomainIdFromUrl(domainUrl: string): string | undefined { + try { + // Domain URL format: https://dzd_d3hr1nfjbtwui1.sagemaker.us-east-2.on.aws + const url = new URL(domainUrl) + const hostname = url.hostname + + // Extract domain ID from hostname (dzd_d3hr1nfjbtwui1 or dzd-d3hr1nfjbtwui1) + const domainIdMatch = hostname.match(/^(dzd[-_][a-zA-Z0-9_-]{1,36})\./) + return domainIdMatch?.[1] + } catch (error) { + this.logger.error('Failed to extract domain ID from URL: %s', error as Error) + return undefined + } + } + + /** + * Extracts the AWS region from a SageMaker Unified Studio domain URL + * @param domainUrl The SageMaker Unified Studio domain URL + * @param fallbackRegion Fallback region if extraction fails (default: 'us-east-1') + * @returns The extracted AWS region or the fallback region if not found + */ + public static extractRegionFromUrl(domainUrl: string, fallbackRegion: string = 'us-east-1'): string { + try { + // Domain URL formats: + // - https://dzd_d3hr1nfjbtwui1.sagemaker.us-east-2.on.aws + // - https://dzd_4gickdfsxtoxg0.sagemaker-gamma.us-west-2.on.aws + const url = new URL(domainUrl) + const hostname = url.hostname + + // Extract region from hostname, handling both prod and non-prod stages + // Pattern matches: .sagemaker[-stage].{region}.on.aws + const regionMatch = hostname.match(/\.sagemaker(?:-[a-z]+)?\.([a-z0-9-]+)\.on\.aws$/) + return regionMatch?.[1] || fallbackRegion + } catch (error) { + this.logger.error('Failed to extract region from URL: %s', error as Error) + return fallbackRegion + } + } + + /** + * Extracts both domain ID and region from a SageMaker Unified Studio domain URL + * @param domainUrl The SageMaker Unified Studio domain URL + * @param fallbackRegion Fallback region if extraction fails (default: 'us-east-1') + * @returns Object containing domainId and region + */ + public static extractDomainInfoFromUrl( + domainUrl: string, + fallbackRegion: string = 'us-east-1' + ): { domainId: string | undefined; region: string } { + return { + domainId: this.extractDomainIdFromUrl(domainUrl), + region: this.extractRegionFromUrl(domainUrl, fallbackRegion), + } + } + + /** + * Validates the domain URL format for SageMaker Unified Studio + * @param value The URL to validate + * @returns Error message if invalid, undefined if valid + */ + public static validateDomainUrl(value: string): string | undefined { + if (!value || value.trim() === '') { + return 'Domain URL is required' + } + + const trimmedValue = value.trim() + + // Check HTTPS requirement + if (!trimmedValue.startsWith('https://')) { + return 'Domain URL must use HTTPS (https://)' + } + + // Check basic URL format + try { + const url = new URL(trimmedValue) + + // Check if it looks like a SageMaker Unified Studio domain + if (!url.hostname.includes('sagemaker') || !url.hostname.includes('on.aws')) { + return 'URL must be a valid SageMaker Unified Studio domain (e.g., https://dzd_xxxxxxxxx.sagemaker.us-east-1.on.aws)' + } + + // Extract domain ID to validate + const domainId = this.extractDomainIdFromUrl(trimmedValue) + + if (!domainId) { + return 'URL must contain a valid domain ID (starting with dzd- or dzd_)' + } + + return undefined // Valid + } catch (err) { + return 'Invalid URL format' + } + } + + /** + * Makes HTTP call to DataZone /sso/login endpoint + * @param domainUrl The SageMaker Unified Studio domain URL + * @param domainId The extracted domain ID + * @returns Promise resolving to the login response + * @throws ToolkitError if the API call fails + */ + private static async callDataZoneLogin(domainUrl: string, domainId: string): Promise { + const loginUrl = new URL('/sso/login', domainUrl) + const requestBody = { + domainId: domainId, + } + + try { + const response = await fetch(loginUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'aws-toolkit-vscode', + }, + body: JSON.stringify(requestBody), + timeout: SmusTimeouts.apiCallTimeoutMs, + }) + + if (!response.ok) { + throw new ToolkitError(`SMUS login failed: ${response.status} ${response.statusText}`, { + code: SmusErrorCodes.SmusLoginFailed, + }) + } + + return (await response.json()) as DataZoneSsoLoginResponse + } catch (error) { + // Handle timeout errors specifically + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('timeout'))) { + throw new ToolkitError( + `DataZone login request timed out after ${SmusTimeouts.apiCallTimeoutMs / 1000} seconds`, + { + code: SmusErrorCodes.ApiTimeout, + cause: error, + } + ) + } + // Re-throw other errors as-is + throw error + } + } + + /** + * Gets SSO instance information by calling DataZone /sso/login endpoint + * This extracts the proper SSO instance ID and issuer URL needed for OAuth client registration + * + * @param domainUrl The SageMaker Unified Studio domain URL + * @returns Promise resolving to SSO instance information + * @throws ToolkitError if the API call fails or response is invalid + */ + public static async getSsoInstanceInfo(domainUrl: string): Promise { + try { + this.logger.info(`SMUS Auth: Getting SSO instance info from DataZone for domainurl: ${domainUrl}`) + + // Extract domain ID from the domain URL + const domainId = this.extractDomainIdFromUrl(domainUrl) + if (!domainId) { + throw new ToolkitError('Invalid domain URL format', { code: 'InvalidDomainUrl' }) + } + + // Call DataZone /sso/login endpoint to get redirect URL with SSO instance info + const loginData = await this.callDataZoneLogin(domainUrl, domainId) + if (!loginData.redirectUrl) { + throw new ToolkitError('No redirect URL received from DataZone login', { code: 'InvalidLoginResponse' }) + } + + // Parse the redirect URL to extract SSO instance information + const redirectUrl = new URL(loginData.redirectUrl) + const clientIdParam = redirectUrl.searchParams.get('client_id') + if (!clientIdParam) { + throw new ToolkitError('No client_id found in DataZone redirect URL', { code: 'InvalidRedirectUrl' }) + } + + // Decode the client_id ARN: arn:aws:sso::785498918019:application/ssoins-6684636af7e1a207/apl-5f60548b7f5677a2 + const decodedClientId = decodeURIComponent(clientIdParam) + const arnParts = decodedClientId.split('/') + if (arnParts.length < 2) { + throw new ToolkitError('Invalid client_id ARN format', { code: 'InvalidArnFormat' }) + } + + const ssoInstanceId = arnParts[1] // Extract ssoins-6684636af7e1a207 + const issuerUrl = `https://identitycenter.amazonaws.com/${ssoInstanceId}` + + // Extract region from domain URL + const region = this.extractRegionFromUrl(domainUrl) + + this.logger.info('SMUS Auth: Extracted SSO instance info: %s', ssoInstanceId) + + return { + issuerUrl, + ssoInstanceId, + clientId: decodedClientId, + region, + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error' + this.logger.error('SMUS Auth: Failed to get SSO instance info: %s', errorMsg) + + if (error instanceof ToolkitError) { + throw error + } + + throw new ToolkitError(`Failed to get SSO instance info: ${errorMsg}`, { + code: 'SsoInstanceInfoFailed', + cause: error instanceof Error ? error : undefined, + }) + } + } + /** + * Extracts SSO ID from a user ID in the format "user-" + * @param userId The user ID to extract SSO ID from + * @returns The extracted SSO ID + * @throws Error if the userId format is invalid + */ + public static extractSSOIdFromUserId(userId: string): string { + const match = userId.match(/user-(.+)$/) + if (!match) { + this.logger.error(`Invalid UserId format: ${userId}`) + throw new Error(`Invalid UserId format: ${userId}`) + } + return match[1] + } + + /** + * Checks if we're in SMUS space environment (should hide certain UI elements) + * @returns True if in SMUS space environment with DataZone domain ID + */ + public static isInSmusSpaceEnvironment(): boolean { + const isSMUSspace = isSageMaker('SMUS') || isSageMaker('SMUS-SPACE-REMOTE-ACCESS') + const resourceMetadata = getResourceMetadata() + return isSMUSspace && !!resourceMetadata?.AdditionalMetadata?.DataZoneDomainId + } +} diff --git a/packages/core/src/sagemakerunifiedstudio/shared/utils/resourceMetadataUtils.ts b/packages/core/src/sagemakerunifiedstudio/shared/utils/resourceMetadataUtils.ts new file mode 100644 index 00000000000..61ce0430ecd --- /dev/null +++ b/packages/core/src/sagemakerunifiedstudio/shared/utils/resourceMetadataUtils.ts @@ -0,0 +1,93 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fs } from '../../../shared/fs/fs' +import { getLogger } from '../../../shared/logger/logger' +import { isSageMaker } from '../../../shared/extensionUtilities' + +/** + * Resource metadata schema used by `resource-metadata.json` in SageMaker Unified Studio spaces + */ +export type ResourceMetadata = { + AppType?: string + DomainId?: string + SpaceName?: string + UserProfileName?: string + ExecutionRoleArn?: string + ResourceArn?: string + ResourceName?: string + AppImageVersion?: string + AdditionalMetadata?: { + DataZoneDomainId?: string + DataZoneDomainRegion?: string + DataZoneEndpoint?: string + DataZoneEnvironmentId?: string + DataZoneProjectId?: string + DataZoneScopeName?: string + DataZoneStage?: string + DataZoneUserId?: string + PrivateSubnets?: string + ProjectS3Path?: string + SecurityGroup?: string + } + ResourceArnCaseSensitive?: string + IpAddressType?: string +} & Record + +const resourceMetadataPath = '/opt/ml/metadata/resource-metadata.json' +let resourceMetadata: ResourceMetadata | undefined = undefined + +/** + * Gets the cached resource metadata (must be initialized with `initializeResourceMetadata()` first) + * @returns ResourceMetadata object or undefined if not yet initialized + */ +export function getResourceMetadata(): ResourceMetadata | undefined { + return resourceMetadata +} + +/** + * Initializes resource metadata by reading and parsing the resource-metadata.json file + */ +export async function initializeResourceMetadata(): Promise { + const logger = getLogger() + + if (!isSageMaker('SMUS') && !isSageMaker('SMUS-SPACE-REMOTE-ACCESS')) { + logger.debug(`Not in SageMaker Unified Studio space, skipping initialization of resource metadata`) + return + } + + try { + if (!(await resourceMetadataFileExists())) { + logger.debug(`Resource metadata file not found at: ${resourceMetadataPath}`) + } + + const fileContent = await fs.readFileText(resourceMetadataPath) + resourceMetadata = JSON.parse(fileContent) as ResourceMetadata + logger.debug(`Successfully read resource metadata from: ${resourceMetadataPath}`) + } catch (error) { + logger.error(`Failed to read or parse resource metadata file: ${error as Error}`) + } +} + +/** + * Checks if the resource-metadata.json file exists + * @returns True if the file exists, false otherwise + */ +export async function resourceMetadataFileExists(): Promise { + try { + return await fs.existsFile(resourceMetadataPath) + } catch (error) { + const logger = getLogger() + logger.error(`Failed to check if resource metadata file exists: ${error as Error}`) + return false + } +} + +/** + * Resets the cached resource metadata + */ +export function resetResourceMetadata(): void { + resourceMetadata = undefined +} diff --git a/packages/core/src/shared/awsClientBuilder.ts b/packages/core/src/shared/awsClientBuilder.ts index bdec40957cb..b6464317a3e 100644 --- a/packages/core/src/shared/awsClientBuilder.ts +++ b/packages/core/src/shared/awsClientBuilder.ts @@ -82,8 +82,11 @@ export class DefaultAWSClientBuilder implements AWSClientBuilder { const listeners = Array.isArray(onRequest) ? onRequest : [onRequest] const opt = { ...options } delete opt.onRequestSetup + if (opt.credentialProvider) { + opt.credentials = await opt.credentialProvider.resolvePromise() + } - if (!opt.credentials && !opt.token) { + if (!opt.credentials && !opt.token && !opt.credentialProvider) { const shim = this.awsContext.credentialsShim if (!shim) { diff --git a/packages/core/src/shared/clients/clientWrapper.ts b/packages/core/src/shared/clients/clientWrapper.ts index a90d009eb18..beb117a9bf6 100644 --- a/packages/core/src/shared/clients/clientWrapper.ts +++ b/packages/core/src/shared/clients/clientWrapper.ts @@ -19,22 +19,13 @@ export abstract class ClientWrapper implements vscode.Dispo public constructor( public readonly regionCode: string, - private readonly clientType: AwsClientConstructor, - private readonly isSageMaker: boolean = false + private readonly clientType: AwsClientConstructor ) {} protected getClient(ignoreCache: boolean = false) { const args = { serviceClient: this.clientType, region: this.regionCode, - ...(this.isSageMaker - ? { - clientOptions: { - endpoint: `https://sagemaker.${this.regionCode}.amazonaws.com`, - region: this.regionCode, - }, - } - : {}), } return ignoreCache ? globals.sdkClientBuilderV3.createAwsService(args) diff --git a/packages/core/src/shared/clients/sagemaker.ts b/packages/core/src/shared/clients/sagemaker.ts index 8a8e138dd85..ff086ed1d9e 100644 --- a/packages/core/src/shared/clients/sagemaker.ts +++ b/packages/core/src/shared/clients/sagemaker.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode' import { AppDetails, + AppType, CreateAppCommand, CreateAppCommandInput, CreateAppCommandOutput, @@ -48,14 +49,42 @@ import { getDomainSpaceKey } from '../../awsService/sagemaker/utils' import { getLogger } from '../logger/logger' import { ToolkitError } from '../errors' import { yes, no, continueText, cancel } from '../localizedText' +import { AwsCredentialIdentity } from '@aws-sdk/types' +import globals from '../extensionGlobals' export interface SagemakerSpaceApp extends SpaceDetails { App?: AppDetails DomainSpaceKey: string } + export class SagemakerClient extends ClientWrapper { - public constructor(public override readonly regionCode: string) { - super(regionCode, SageMakerClient, true) + public constructor( + public override readonly regionCode: string, + private readonly credentialsProvider?: () => Promise + ) { + super(regionCode, SageMakerClient) + } + + protected override getClient(ignoreCache: boolean = false) { + if (!this.client || ignoreCache) { + const args = { + serviceClient: SageMakerClient, + region: this.regionCode, + clientOptions: { + endpoint: `https://sagemaker.${this.regionCode}.amazonaws.com`, + region: this.regionCode, + ...(this.credentialsProvider && { credentials: this.credentialsProvider }), + }, + } + this.client = globals.sdkClientBuilderV3.createAwsService(args) + } + return this.client + } + + public override dispose() { + getLogger().debug('SagemakerClient: Disposing client %O', this.client) + this.client?.destroy() + this.client = undefined } public listSpaces(request: ListSpacesCommandInput = {}): AsyncCollection { @@ -200,27 +229,37 @@ export class SagemakerClient extends ClientWrapper { } } - public async fetchSpaceAppsAndDomains(): Promise< - [Map, Map] - > { - try { - const appMap: Map = await this.listApps() - .flatten() - .filter((app) => !!app.DomainId && !!app.SpaceName) - .filter((app) => app.AppType === 'JupyterLab' || app.AppType === 'CodeEditor') - .toMap((app) => getDomainSpaceKey(app.DomainId || '', app.SpaceName || '')) - - const spaceApps: Map = await this.listSpaces() - .flatten() - .filter((space) => !!space.DomainId && !!space.SpaceName) - .map((space) => { - const key = getDomainSpaceKey(space.DomainId || '', space.SpaceName || '') - return { ...space, App: appMap.get(key), DomainSpaceKey: key } - }) - .toMap((space) => getDomainSpaceKey(space.DomainId || '', space.SpaceName || '')) + public async listSpaceApps(domainId?: string): Promise> { + // Create options object conditionally if domainId is provided + const options = domainId ? { DomainIdEquals: domainId } : undefined + + const appMap: Map = await this.listApps(options) + .flatten() + .filter((app) => !!app.DomainId && !!app.SpaceName) + .filter((app) => app.AppType === AppType.JupyterLab || app.AppType === AppType.CodeEditor) + .toMap((app) => getDomainSpaceKey(app.DomainId || '', app.SpaceName || '')) + + const spaceApps: Map = await this.listSpaces(options) + .flatten() + .filter((space) => !!space.DomainId && !!space.SpaceName) + .map((space) => { + const key = getDomainSpaceKey(space.DomainId || '', space.SpaceName || '') + return { ...space, App: appMap.get(key), DomainSpaceKey: key } + }) + .toMap((space) => getDomainSpaceKey(space.DomainId || '', space.SpaceName || '')) + return spaceApps + } + public async fetchSpaceAppsAndDomains( + domainId?: string, + filterSmusDomains: boolean = true + ): Promise<[Map, Map]> { + try { + const spaceApps = await this.listSpaceApps(domainId) // Get de-duped list of domain IDs for all of the spaces - const domainIds: string[] = [...new Set([...spaceApps].map(([_, spaceApp]) => spaceApp.DomainId || ''))] + const domainIds: string[] = domainId + ? [domainId] + : [...new Set([...spaceApps].map(([_, spaceApp]) => spaceApp.DomainId || ''))] // Get details for each domain const domains: [string, DescribeDomainResponse][] = await Promise.all( @@ -235,9 +274,11 @@ export class SagemakerClient extends ClientWrapper { const filteredSpaceApps = new Map( [...spaceApps] - // Filter out SageMaker Unified Studio domains - .filter(([_, spaceApp]) => - isEmpty(domainsMap.get(spaceApp.DomainId || '')?.DomainSettings?.UnifiedStudioSettings) + // Filter out SageMaker Unified Studio domains only if filterSmusDomains is true + .filter( + ([_, spaceApp]) => + !filterSmusDomains || + isEmpty(domainsMap.get(spaceApp.DomainId || '')?.DomainSettings?.UnifiedStudioSettings) ) ) diff --git a/packages/core/src/shared/extensionUtilities.ts b/packages/core/src/shared/extensionUtilities.ts index 80bedf1e0f6..b8b5780c612 100644 --- a/packages/core/src/shared/extensionUtilities.ts +++ b/packages/core/src/shared/extensionUtilities.ts @@ -188,7 +188,7 @@ export function isCloud9(flavor: 'classic' | 'codecatalyst' | 'any' = 'any'): bo * @param appName to identify the proper SM instance * @returns true if the current system is SageMaker(SMAI or SMUS) */ -export function isSageMaker(appName: 'SMAI' | 'SMUS' = 'SMAI'): boolean { +export function isSageMaker(appName: 'SMAI' | 'SMUS' | 'SMUS-SPACE-REMOTE-ACCESS' = 'SMAI'): boolean { // Check for SageMaker-specific environment variables first let hasSMEnvVars: boolean = false if (hasSageMakerEnvVars()) { @@ -201,6 +201,9 @@ export function isSageMaker(appName: 'SMAI' | 'SMUS' = 'SMAI'): boolean { return vscode.env.appName === sageMakerAppname && hasSMEnvVars case 'SMUS': return vscode.env.appName === sageMakerAppname && isSageMakerUnifiedStudio() && hasSMEnvVars + case 'SMUS-SPACE-REMOTE-ACCESS': + // When is true, the AWS toolkit is running in remote SSH conenction to SageMaker Unified Studio space + return vscode.env.appName !== sageMakerAppname && isSageMakerUnifiedStudio() && hasSMEnvVars default: return false } diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts index e8e6a3bff44..0e9ab819046 100644 --- a/packages/core/src/shared/globalState.ts +++ b/packages/core/src/shared/globalState.ts @@ -8,7 +8,7 @@ import { getLogger } from './logger/logger' import * as redshift from '../awsService/redshift/models/models' import { TypeConstructor, cast } from './utilities/typeConstructors' -type ToolId = 'codecatalyst' | 'codewhisperer' | 'testId' +type ToolId = 'codecatalyst' | 'codewhisperer' | 'testId' | 'smus' export type ToolIdStateKey = `${ToolId}.savedConnectionId` export type JsonSchemasKey = 'devfileSchemaVersion' | 'samAndCfnSchemaVersion' diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index 1128eef8ab6..1ca57d4669f 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -238,6 +238,46 @@ "name": "executedCount", "type": "int", "description": "The number of executed operations" + }, + { + "name": "smusDomainId", + "type": "string", + "description": "SMUS domain identifier" + }, + { + "name": "smusProjectId", + "type": "string", + "description": "SMUS project identifier" + }, + { + "name": "smusSpaceKey", + "type": "string", + "description": "SMUS space composite key consisting of domainId and spaceName" + }, + { + "name": "smusToolkitEnv", + "type": "string", + "description": "The environment user is running SMUS extension against" + }, + { + "name": "smusDomainRegion", + "type": "string", + "description": "The SMUS domain region" + }, + { + "name": "smusProjectRegion", + "type": "string", + "description": "The SMUS project region" + }, + { + "name": "smusConnectionId", + "type": "string", + "description": "SMUS connection identifier" + }, + { + "name": "smusConnectionType", + "type": "string", + "description": "SMUS connection type" } ], "metrics": [ @@ -1257,6 +1297,201 @@ "required": false } ] + }, + { + "name": "smus_login", + "description": "Emitted whenever a user signin to SMUS", + "metadata": [ + { + "type": "smusDomainId", + "required": false + } + ] + }, + { + "name": "smus_signOut", + "description": "Emitted whenever a user signouts SMUS", + "metadata": [ + { + "type": "smusDomainId", + "required": false + } + ] + }, + { + "name": "smus_accessProject", + "description": "Emitted whenever a user accesses a SMUS project", + "metadata": [ + { + "type": "smusDomainId", + "required": false + }, + { + "type": "smusProjectId", + "required": false + }, + { + "type": "smusDomainRegion", + "required": false + } + ] + }, + { + "name": "smus_renderProjectChildrenNode", + "description": "Emitted whenever children node of project is rendered", + "metadata": [ + { + "type": "smusToolkitEnv", + "required": false + }, + { + "type": "smusDomainId", + "required": false + }, + { + "type": "smusProjectId", + "required": false + }, + { + "type": "smusDomainRegion", + "required": false + } + ], + "passive": true + }, + { + "name": "smus_startSpace", + "description": "Emitted whenever a user starts a SMUS space", + "metadata": [ + { + "type": "smusDomainId", + "required": false + }, + { + "type": "smusProjectId", + "required": false + }, + { + "type": "smusSpaceKey", + "required": false + }, + { + "type": "smusDomainRegion", + "required": false + } + ] + }, + { + "name": "smus_stopSpace", + "description": "Emitted whenever a user stop a SMUS space", + "metadata": [ + { + "type": "smusDomainId", + "required": false + }, + { + "type": "smusProjectId", + "required": false + }, + { + "type": "smusSpaceKey", + "required": false + }, + { + "type": "smusDomainRegion", + "required": false + } + ] + }, + { + "name": "smus_renderS3Node", + "description": "Emitted whenever rendering a s3 node", + "metadata": [ + { + "type": "smusToolkitEnv", + "required": false + }, + { + "type": "smusDomainId", + "required": false + }, + { + "type": "smusProjectId", + "required": false + }, + { + "type": "smusProjectRegion", + "required": false + }, + { + "type": "smusConnectionId", + "required": false + }, + { + "type": "smusConnectionType", + "required": false + } + ] + }, + { + "name": "smus_renderRedshiftNode", + "description": "Emitted whenever rendering a Redshift node", + "metadata": [ + { + "type": "smusToolkitEnv", + "required": false + }, + { + "type": "smusDomainId", + "required": false + }, + { + "type": "smusProjectId", + "required": false + }, + { + "type": "smusProjectRegion", + "required": false + }, + { + "type": "smusConnectionId", + "required": false + }, + { + "type": "smusConnectionType", + "required": false + } + ] + }, + { + "name": "smus_renderLakehouseNode", + "description": "Emitted whenever rendering a Lakehouse node", + "metadata": [ + { + "type": "smusToolkitEnv", + "required": false + }, + { + "type": "smusDomainId", + "required": false + }, + { + "type": "smusProjectId", + "required": false + }, + { + "type": "smusProjectRegion", + "required": false + }, + { + "type": "smusConnectionId", + "required": false + }, + { + "type": "smusConnectionType", + "required": false + } + ] } ] } diff --git a/packages/core/src/shared/vscode/setContext.ts b/packages/core/src/shared/vscode/setContext.ts index 7cfaf4092f8..3d45d93e14a 100644 --- a/packages/core/src/shared/vscode/setContext.ts +++ b/packages/core/src/shared/vscode/setContext.ts @@ -30,6 +30,8 @@ export type contextKey = | 'aws.stepFunctions.isWorkflowStudioFocused' | 'aws.toolkit.notifications.show' | 'aws.amazonq.editSuggestionActive' + | 'aws.smus.connected' + | 'aws.smus.inSmusSpaceEnvironment' // Deprecated/legacy names. New keys should start with "aws.". | 'codewhisperer.activeLine' | 'gumby.isPlanAvailable' diff --git a/packages/core/src/test/awsService/sagemaker/credentialMapping.test.ts b/packages/core/src/test/awsService/sagemaker/credentialMapping.test.ts index 06f19a5e890..3134f11e5e0 100644 --- a/packages/core/src/test/awsService/sagemaker/credentialMapping.test.ts +++ b/packages/core/src/test/awsService/sagemaker/credentialMapping.test.ts @@ -5,10 +5,22 @@ import * as sinon from 'sinon' import * as assert from 'assert' -import { persistLocalCredentials, persistSSMConnection } from '../../../awsService/sagemaker/credentialMapping' +import { + persistLocalCredentials, + persistSSMConnection, + persistSmusProjectCreds, + loadMappings, + saveMappings, + setSpaceIamProfile, + setSpaceSsoProfile, + setSmusSpaceSsoProfile, + setSpaceCredentials, +} from '../../../awsService/sagemaker/credentialMapping' import { Auth } from '../../../auth' import { DevSettings, fs } from '../../../shared' import globals from '../../../shared/extensionGlobals' +import { SagemakerUnifiedStudioSpaceNode } from '../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode' +import { SageMakerUnifiedStudioSpacesParentNode } from '../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode' describe('credentialMapping', () => { describe('persistLocalCredentials', () => { @@ -207,4 +219,230 @@ describe('credentialMapping', () => { }) }) }) + + describe('persistSmusProjectCreds', () => { + const appArn = 'arn:aws:sagemaker:us-west-2:123456789012:space/d-f0lwireyzpjp/test-space' + const projectId = 'test-project-id' + let sandbox: sinon.SinonSandbox + let mockNode: sinon.SinonStubbedInstance + let mockParent: sinon.SinonStubbedInstance + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockNode = sandbox.createStubInstance(SagemakerUnifiedStudioSpaceNode) + mockParent = sandbox.createStubInstance(SageMakerUnifiedStudioSpacesParentNode) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('persists SMUS project credentials', async () => { + const mockCredentialProvider = { + getCredentials: sandbox.stub().resolves(), + startProactiveCredentialRefresh: sandbox.stub(), + } + + const mockAuthProvider = { + getProjectCredentialProvider: sandbox.stub().resolves(mockCredentialProvider), + } + + mockNode.getParent.returns(mockParent as any) + mockParent.getAuthProvider.returns(mockAuthProvider as any) + mockParent.getProjectId.returns(projectId) + + sandbox.stub(fs, 'existsFile').resolves(false) + const writeStub = sandbox.stub(fs, 'writeFile').resolves() + + await persistSmusProjectCreds(appArn, mockNode as any) + + assert.ok(writeStub.calledOnce) + const raw = writeStub.firstCall.args[1] + const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) + assert.deepStrictEqual(data.localCredential?.[appArn], { + type: 'sso', + smusProjectId: projectId, + }) + + // Verify the correct methods were called + assert.ok(mockAuthProvider.getProjectCredentialProvider.calledWith(projectId)) + assert.ok(mockCredentialProvider.getCredentials.calledOnce) + assert.ok(mockCredentialProvider.startProactiveCredentialRefresh.calledOnce) + }) + }) + + describe('loadMappings', () => { + let sandbox: sinon.SinonSandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns empty object when file does not exist', async () => { + sandbox.stub(fs, 'existsFile').resolves(false) + + const result = await loadMappings() + + assert.deepStrictEqual(result, {}) + }) + + it('loads and parses existing mappings', async () => { + const mockData = { localCredential: { 'test-arn': { type: 'iam' as const, profileName: 'test' } } } + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(mockData)) + + const result = await loadMappings() + + assert.deepStrictEqual(result, mockData) + }) + + it('returns empty object on parse error', async () => { + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves('invalid json') + + const result = await loadMappings() + + assert.deepStrictEqual(result, {}) + }) + }) + + describe('saveMappings', () => { + let sandbox: sinon.SinonSandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('saves mappings to file', async () => { + const writeStub = sandbox.stub(fs, 'writeFile').resolves() + const testData = { localCredential: { 'test-arn': { type: 'iam' as const, profileName: 'test' } } } + + await saveMappings(testData) + + assert.ok(writeStub.calledOnce) + const [, content, options] = writeStub.firstCall.args + assert.strictEqual(content, JSON.stringify(testData, undefined, 2)) + assert.deepStrictEqual(options, { mode: 0o600, atomic: true }) + }) + }) + + describe('setSpaceIamProfile', () => { + let sandbox: sinon.SinonSandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('sets IAM profile for space', async () => { + sandbox.stub(fs, 'existsFile').resolves(false) + const writeStub = sandbox.stub(fs, 'writeFile').resolves() + + await setSpaceIamProfile('test-space', 'test-profile') + + const raw = writeStub.firstCall.args[1] + const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) + assert.deepStrictEqual(data.localCredential?.['test-space'], { + type: 'iam', + profileName: 'test-profile', + }) + }) + }) + + describe('setSpaceSsoProfile', () => { + let sandbox: sinon.SinonSandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('sets SSO profile for space', async () => { + sandbox.stub(fs, 'existsFile').resolves(false) + const writeStub = sandbox.stub(fs, 'writeFile').resolves() + + await setSpaceSsoProfile('test-space', 'access-key', 'secret', 'token') + + const raw = writeStub.firstCall.args[1] + const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) + assert.deepStrictEqual(data.localCredential?.['test-space'], { + type: 'sso', + accessKey: 'access-key', + secret: 'secret', + token: 'token', + }) + }) + }) + + describe('setSmusSpaceSsoProfile', () => { + let sandbox: sinon.SinonSandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('sets SMUS SSO profile for space', async () => { + sandbox.stub(fs, 'existsFile').resolves(false) + const writeStub = sandbox.stub(fs, 'writeFile').resolves() + + await setSmusSpaceSsoProfile('test-space', 'project-id') + + const raw = writeStub.firstCall.args[1] + const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) + assert.deepStrictEqual(data.localCredential?.['test-space'], { + type: 'sso', + smusProjectId: 'project-id', + }) + }) + }) + + describe('setSpaceCredentials', () => { + let sandbox: sinon.SinonSandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('sets space credentials with refresh URL', async () => { + sandbox.stub(fs, 'existsFile').resolves(false) + const writeStub = sandbox.stub(fs, 'writeFile').resolves() + const credentials = { sessionId: 'sess', url: 'ws://test', token: 'token' } + + await setSpaceCredentials('test-space', 'https://refresh.url', credentials) + + const raw = writeStub.firstCall.args[1] + const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) + assert.deepStrictEqual(data.deepLink?.['test-space'], { + refreshUrl: 'https://refresh.url', + requests: { + 'initial-connection': { + ...credentials, + status: 'fresh', + }, + }, + }) + }) + }) }) diff --git a/packages/core/src/test/awsService/sagemaker/detached-server/credentials.test.ts b/packages/core/src/test/awsService/sagemaker/detached-server/credentials.test.ts index a979c2186d3..3db189f8390 100644 --- a/packages/core/src/test/awsService/sagemaker/detached-server/credentials.test.ts +++ b/packages/core/src/test/awsService/sagemaker/detached-server/credentials.test.ts @@ -73,6 +73,69 @@ describe('resolveCredentialsFor', () => { }) }) + it('resolves SSO credentials with SMUS project ID', async () => { + sinon.stub(utils, 'readMapping').resolves({ + localCredential: { + [connectionId]: { + type: 'sso', + smusProjectId: 'project123', + }, + }, + smusProjects: { + project123: { + accessKey: 'smus-key', + secret: 'smus-secret', + token: 'smus-token', + }, + }, + }) + + const creds = await resolveCredentialsFor(connectionId) + assert.deepStrictEqual(creds, { + accessKeyId: 'smus-key', + secretAccessKey: 'smus-secret', + sessionToken: 'smus-token', + }) + }) + + it('throws if SMUS project credentials are missing', async () => { + sinon.stub(utils, 'readMapping').resolves({ + localCredential: { + [connectionId]: { + type: 'sso', + smusProjectId: 'project123', + }, + }, + smusProjects: { + project123: { + accessKey: '', + secret: 'smus-secret', + token: 'smus-token', + }, + }, + }) + + await assert.rejects(() => resolveCredentialsFor(connectionId), { + message: `Missing ProjectRole credentials for SMUS Space "${connectionId}"`, + }) + }) + + it('throws if SMUS project is not found', async () => { + sinon.stub(utils, 'readMapping').resolves({ + localCredential: { + [connectionId]: { + type: 'sso', + smusProjectId: 'nonexistent', + }, + }, + smusProjects: {}, + }) + + await assert.rejects(() => resolveCredentialsFor(connectionId), { + message: `Missing ProjectRole credentials for SMUS Space "${connectionId}"`, + }) + }) + it('throws for unsupported profile types', async () => { sinon.stub(utils, 'readMapping').resolves({ localCredential: { diff --git a/packages/core/src/test/awsService/sagemaker/explorer/sagemakerParentNode.test.ts b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerParentNode.test.ts index 8fccfe4bfd9..b7cf98496fc 100644 --- a/packages/core/src/test/awsService/sagemaker/explorer/sagemakerParentNode.test.ts +++ b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerParentNode.test.ts @@ -23,7 +23,7 @@ describe('sagemakerParentNode', function () { let testNode: SagemakerParentNode let client: SagemakerClient let fetchSpaceAppsAndDomainsStub: sinon.SinonStub< - [], + [domainId?: string | undefined, filterSmusDomains?: boolean | undefined], Promise<[Map, Map]> > let getCallerIdentityStub: sinon.SinonStub<[], Promise> diff --git a/packages/core/src/test/awsService/sagemaker/explorer/sagemakerSpaceNode.test.ts b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerSpaceNode.test.ts index 57b4d7a80c6..b0fc6d78c0f 100644 --- a/packages/core/src/test/awsService/sagemaker/explorer/sagemakerSpaceNode.test.ts +++ b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerSpaceNode.test.ts @@ -69,10 +69,9 @@ describe('SagemakerSpaceNode', function () { }) it('returns ARN from describeApp', async function () { - describeAppStub.resolves({ AppArn: 'arn:aws:sagemaker:1234:app/TestApp' }) + describeAppStub.resolves({ AppArn: 'arn:aws:sagemaker:1234:app/TestApp', $metadata: {} }) - const node = new SagemakerSpaceNode(testParent, client, testRegion, testSpaceApp) - const arn = await node.getAppArn() + const arn = await testSpaceAppNode.getAppArn() assert.strictEqual(arn, 'arn:aws:sagemaker:1234:app/TestApp') sinon.assert.calledOnce(describeAppStub) @@ -84,10 +83,40 @@ describe('SagemakerSpaceNode', function () { }) }) - it('updates status with new spaceApp', async function () { - const newStatus = 'Starting' + it('returns space ARN from describeSpace', async function () { + const describeSpaceStub = sinon.stub(SagemakerClient.prototype, 'describeSpace') + describeSpaceStub.resolves({ SpaceArn: 'arn:aws:sagemaker:1234:space/TestSpace', $metadata: {} }) + + const arn = await testSpaceAppNode.getSpaceArn() + + assert.strictEqual(arn, 'arn:aws:sagemaker:1234:space/TestSpace') + sinon.assert.calledOnce(describeSpaceStub) + }) + + it('updates status with new spaceApp', function () { const newSpaceApp = { ...testSpaceApp, App: { AppName: 'TestApp', Status: 'Pending' } } as SagemakerSpaceApp testSpaceAppNode.updateSpace(newSpaceApp) - assert.strictEqual(testSpaceAppNode.getStatus(), newStatus) + assert.strictEqual(testSpaceAppNode.getStatus(), 'Starting') + }) + + it('delegates to SagemakerSpace for properties', function () { + const node = new SagemakerSpaceNode(testParent, client, testRegion, testSpaceApp) + + // Verify that properties are managed by SagemakerSpace + assert.strictEqual(node.name, 'TestSpace') + assert.strictEqual(node.label, 'TestSpace (Running)') + assert.strictEqual(node.description, 'Private space') + assert.ok(node.tooltip instanceof vscode.MarkdownString) + }) + + it('updates space app status', async function () { + const describeSpaceStub = sinon.stub(SagemakerClient.prototype, 'describeSpace') + describeSpaceStub.resolves({ SpaceName: 'TestSpace', Status: 'InService', $metadata: {} }) + describeAppStub.resolves({ AppName: 'TestApp', Status: 'InService', $metadata: {} }) + + await testSpaceAppNode.updateSpaceAppStatus() + + sinon.assert.calledOnce(describeSpaceStub) + sinon.assert.calledOnce(describeAppStub) }) }) diff --git a/packages/core/src/test/awsService/sagemaker/model.test.ts b/packages/core/src/test/awsService/sagemaker/model.test.ts index 892baf2f77b..e6a9637ed15 100644 --- a/packages/core/src/test/awsService/sagemaker/model.test.ts +++ b/packages/core/src/test/awsService/sagemaker/model.test.ts @@ -59,6 +59,30 @@ describe('SageMaker Model', () => { assert.ok(existsStub.callCount >= 3, 'should have retried for file existence') }) + + it('throws ToolkitError when info file never appears', async function () { + sandbox.stub(fs, 'existsFile').resolves(false) + sandbox.stub(require('fs'), 'openSync').returns(42) + sandbox.replace( + require('../../../awsService/sagemaker/model'), + 'stopLocalServer', + sandbox.stub().resolves() + ) + sandbox.replace( + require('../../../awsService/sagemaker/utils'), + 'spawnDetachedServer', + sandbox.stub().returns({ unref: sandbox.stub() }) + ) + sandbox.stub(DevSettings.instance, 'get').returns({}) + + try { + await startLocalServer(ctx) + assert.ok(false, 'Expected error not thrown') + } catch (err) { + assert.ok(err instanceof ToolkitError) + assert.ok(err.message.includes('Timed out waiting for local server info file')) + } + }) }) describe('stopLocalServer', function () { @@ -106,6 +130,17 @@ describe('SageMaker Model', () => { } }) + it('logs warning when process not found (ESRCH)', async function () { + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(validJson) + sandbox.stub(fs, 'delete').resolves() + sandbox.stub(process, 'kill').throws({ code: 'ESRCH', message: 'no such process' }) + + await stopLocalServer(ctx) + + assertLogsContain(`no process found with PID ${validPid}. It may have already exited.`, false, 'warn') + }) + it('throws ToolkitError when killing process fails for another reason', async function () { sandbox.stub(fs, 'existsFile').resolves(true) sandbox.stub(fs, 'readFileText').resolves(validJson) @@ -120,6 +155,27 @@ describe('SageMaker Model', () => { assert.strictEqual(err.message, 'failed to stop local server') } }) + + it('logs warning when PID is invalid', async function () { + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify({ pid: 'invalid' })) + sandbox.stub(fs, 'delete').resolves() + + await stopLocalServer(ctx) + + assertLogsContain('no valid PID found in info file.', false, 'warn') + }) + + it('logs warning when file deletion fails', async function () { + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(validJson) + sandbox.stub(process, 'kill').returns(true) + sandbox.stub(fs, 'delete').rejects(new Error('delete failed')) + + await stopLocalServer(ctx) + + assertLogsContain('could not delete info file: delete failed', false, 'warn') + }) }) describe('removeKnownHost', function () { @@ -152,6 +208,38 @@ describe('SageMaker Model', () => { sinon.match((value: string) => value.trim() === expectedOutput), { atomic: true } ) + assertLogsContain(`Removed '${hostname}' from known_hosts`, false, 'debug') + }) + + it('handles hostname in comma-separated list', async function () { + sandbox.stub(fs, 'existsFile').resolves(true) + + const inputContent = `host1,${hostname},host2 ssh-rsa AAAA\nother.host ssh-rsa BBBB` + const expectedOutput = `other.host ssh-rsa BBBB` + + sandbox.stub(fs, 'readFileText').resolves(inputContent) + const writeStub = sandbox.stub(fs, 'writeFile').resolves() + + await removeKnownHost(hostname) + + sinon.assert.calledWith( + writeStub, + knownHostsPath, + sinon.match((value: string) => value.trim() === expectedOutput), + { atomic: true } + ) + }) + + it('does not write file when hostname not found', async function () { + sandbox.stub(fs, 'existsFile').resolves(true) + + const inputContent = `other.host ssh-rsa AAAA\nsome.other.com ssh-rsa BBBB` + sandbox.stub(fs, 'readFileText').resolves(inputContent) + const writeStub = sandbox.stub(fs, 'writeFile').resolves() + + await removeKnownHost(hostname) + + sinon.assert.notCalled(writeStub) }) it('logs warning when known_hosts does not exist', async function () { diff --git a/packages/core/src/test/awsService/sagemaker/sagemakerSpace.test.ts b/packages/core/src/test/awsService/sagemaker/sagemakerSpace.test.ts new file mode 100644 index 00000000000..2a52b08a3a6 --- /dev/null +++ b/packages/core/src/test/awsService/sagemaker/sagemakerSpace.test.ts @@ -0,0 +1,129 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { SagemakerSpace } from '../../../awsService/sagemaker/sagemakerSpace' +import { SagemakerClient, SagemakerSpaceApp } from '../../../shared/clients/sagemaker' +import sinon from 'sinon' + +describe('SagemakerSpace', function () { + let mockClient: sinon.SinonStubbedInstance + let mockSpaceApp: SagemakerSpaceApp + + beforeEach(function () { + mockClient = sinon.createStubInstance(SagemakerClient) + mockSpaceApp = { + SpaceName: 'test-space', + Status: 'InService', + DomainId: 'test-domain', + DomainSpaceKey: 'test-key', + SpaceSettingsSummary: { + AppType: 'JupyterLab', + RemoteAccess: 'ENABLED', + }, + } + }) + + afterEach(function () { + sinon.restore() + }) + + describe('updateSpaceAppStatus', function () { + it('should correctly map DescribeSpace API response to SagemakerSpaceApp type', async function () { + // Mock DescribeSpace response (uses full property names) + const mockDescribeSpaceResponse = { + SpaceName: 'updated-space', + Status: 'InService', + DomainId: 'test-domain', + SpaceSettings: { + // Note: 'SpaceSettings' not 'SpaceSettingsSummary' + AppType: 'CodeEditor', + RemoteAccess: 'DISABLED', + }, + OwnershipSettings: { + OwnerUserProfileName: 'test-user', + }, + SpaceSharingSettings: { + SharingType: 'Private', + }, + $metadata: { requestId: 'test-request-id' }, + } + + // Mock DescribeApp response + const mockDescribeAppResponse = { + AppName: 'test-app', + Status: 'InService', + ResourceSpec: { + InstanceType: 'ml.t3.medium', + }, + $metadata: { requestId: 'test-request-id' }, + } + + mockClient.describeSpace.resolves(mockDescribeSpaceResponse) + mockClient.describeApp.resolves(mockDescribeAppResponse) + + const space = new SagemakerSpace(mockClient as any, 'us-east-1', mockSpaceApp) + const updateSpaceSpy = sinon.spy(space, 'updateSpace') + + await space.updateSpaceAppStatus() + + // Verify updateSpace was called with correctly mapped properties + assert.ok(updateSpaceSpy.calledOnce) + const updateSpaceArgs = updateSpaceSpy.getCall(0).args[0] + + // Verify property name mapping from DescribeSpace to SagemakerSpaceApp + assert.strictEqual(updateSpaceArgs.SpaceSettingsSummary?.AppType, 'CodeEditor') + assert.strictEqual(updateSpaceArgs.SpaceSettingsSummary?.RemoteAccess, 'DISABLED') + assert.strictEqual(updateSpaceArgs.OwnershipSettingsSummary?.OwnerUserProfileName, 'test-user') + assert.strictEqual(updateSpaceArgs.SpaceSharingSettingsSummary?.SharingType, 'Private') + + // Verify other properties are preserved + assert.strictEqual(updateSpaceArgs.SpaceName, 'updated-space') + assert.strictEqual(updateSpaceArgs.Status, 'InService') + assert.strictEqual(updateSpaceArgs.DomainId, 'test-domain') + assert.strictEqual(updateSpaceArgs.App, mockDescribeAppResponse) + assert.strictEqual(updateSpaceArgs.DomainSpaceKey, 'test-key') + + // Verify original API property names are not present + assert.ok(!('SpaceSettings' in updateSpaceArgs)) + assert.ok(!('OwnershipSettings' in updateSpaceArgs)) + assert.ok(!('SpaceSharingSettings' in updateSpaceArgs)) + }) + + it('should handle missing optional properties gracefully', async function () { + // Mock minimal DescribeSpace response + const mockDescribeSpaceResponse = { + SpaceName: 'minimal-space', + Status: 'InService', + DomainId: 'test-domain', + $metadata: { requestId: 'test-request-id' }, + // No SpaceSettings, OwnershipSettings, or SpaceSharingSettings + } + + const mockDescribeAppResponse = { + AppName: 'test-app', + Status: 'InService', + $metadata: { requestId: 'test-request-id' }, + } + + mockClient.describeSpace.resolves(mockDescribeSpaceResponse) + mockClient.describeApp.resolves(mockDescribeAppResponse) + + const space = new SagemakerSpace(mockClient as any, 'us-east-1', mockSpaceApp) + const updateSpaceSpy = sinon.spy(space, 'updateSpace') + + await space.updateSpaceAppStatus() + + // Should not throw and should handle undefined properties + assert.ok(updateSpaceSpy.calledOnce) + const updateSpaceArgs = updateSpaceSpy.getCall(0).args[0] + + assert.strictEqual(updateSpaceArgs.SpaceName, 'minimal-space') + assert.strictEqual(updateSpaceArgs.SpaceSettingsSummary, undefined) + assert.strictEqual(updateSpaceArgs.OwnershipSettingsSummary, undefined) + assert.strictEqual(updateSpaceArgs.SpaceSharingSettingsSummary, undefined) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/auth/connectionCredentialsProvider.test.ts b/packages/core/src/test/sagemakerunifiedstudio/auth/connectionCredentialsProvider.test.ts new file mode 100644 index 00000000000..951e391d181 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/auth/connectionCredentialsProvider.test.ts @@ -0,0 +1,215 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { ConnectionCredentialsProvider } from '../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' +import { SmusAuthenticationProvider } from '../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' +import { DataZoneClient } from '../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { ToolkitError } from '../../../shared/errors' + +describe('ConnectionCredentialsProvider', function () { + let mockAuthProvider: sinon.SinonStubbedInstance + let mockDataZoneClient: sinon.SinonStubbedInstance + let connectionProvider: ConnectionCredentialsProvider + let dataZoneClientStub: sinon.SinonStub + + const testConnectionId = 'conn-123456' + const testDomainId = 'dzd_testdomain' + const testRegion = 'us-east-2' + + const mockConnectionCredentials = { + accessKeyId: 'AKIA-CONNECTION-KEY', + secretAccessKey: 'connection-secret-key', + sessionToken: 'connection-session-token', + expiration: new Date(Date.now() + 3600000), // 1 hour from now + } + + const mockGetConnectionResponse = { + connectionId: testConnectionId, + name: 'Test Connection', + type: 'S3', + domainId: testDomainId, + projectId: 'project-123', + connectionCredentials: mockConnectionCredentials, + } + + beforeEach(function () { + // Mock auth provider + mockAuthProvider = { + isConnected: sinon.stub().returns(true), + getDomainId: sinon.stub().returns(testDomainId), + getDomainRegion: sinon.stub().returns(testRegion), + activeConnection: { + ssoRegion: testRegion, + }, + } as any + + // Mock DataZone client + mockDataZoneClient = { + getConnection: sinon.stub().resolves(mockGetConnectionResponse), + } as any + + // Stub DataZoneClient.getInstance + dataZoneClientStub = sinon.stub(DataZoneClient, 'getInstance').resolves(mockDataZoneClient as any) + + connectionProvider = new ConnectionCredentialsProvider(mockAuthProvider as any, testConnectionId) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('should create provider with correct properties', function () { + assert.strictEqual(connectionProvider.getConnectionId(), testConnectionId) + assert.strictEqual(connectionProvider.getDefaultRegion(), testRegion) + }) + }) + + describe('getCredentialsId', function () { + it('should return correct credentials ID', function () { + const credentialsId = connectionProvider.getCredentialsId() + assert.strictEqual(credentialsId.credentialSource, 'temp') + assert.strictEqual(credentialsId.credentialTypeId, `${testDomainId}:${testConnectionId}`) + }) + }) + + describe('getHashCode', function () { + it('should return correct hash code', function () { + const hashCode = connectionProvider.getHashCode() + assert.strictEqual(hashCode, `smus-connection:${testDomainId}:${testConnectionId}`) + }) + }) + + describe('isAvailable', function () { + it('should return true when auth provider is connected', async function () { + mockAuthProvider.isConnected.returns(true) + const isAvailable = await connectionProvider.isAvailable() + assert.strictEqual(isAvailable, true) + }) + + it('should return false when auth provider is not connected', async function () { + mockAuthProvider.isConnected.returns(false) + const isAvailable = await connectionProvider.isAvailable() + assert.strictEqual(isAvailable, false) + }) + + it('should return false when auth provider throws error', async function () { + mockAuthProvider.isConnected.throws(new Error('Connection error')) + const isAvailable = await connectionProvider.isAvailable() + assert.strictEqual(isAvailable, false) + }) + }) + + describe('canAutoConnect', function () { + it('should return false', async function () { + const canAutoConnect = await connectionProvider.canAutoConnect() + assert.strictEqual(canAutoConnect, false) + }) + }) + + describe('getCredentials', function () { + it('should fetch and return connection credentials', async function () { + const credentials = await connectionProvider.getCredentials() + + assert.strictEqual(credentials.accessKeyId, mockConnectionCredentials.accessKeyId) + assert.strictEqual(credentials.secretAccessKey, mockConnectionCredentials.secretAccessKey) + assert.strictEqual(credentials.sessionToken, mockConnectionCredentials.sessionToken) + assert(credentials.expiration instanceof Date) + + // Verify DataZone client was called correctly + sinon.assert.calledOnce(dataZoneClientStub) + sinon.assert.calledWith(mockDataZoneClient.getConnection, { + domainIdentifier: testDomainId, + identifier: testConnectionId, + withSecret: true, + }) + }) + + it('should use cached credentials on subsequent calls', async function () { + // First call + const credentials1 = await connectionProvider.getCredentials() + // Second call + const credentials2 = await connectionProvider.getCredentials() + + assert.strictEqual(credentials1, credentials2) + // DataZone client should only be called once due to caching + sinon.assert.calledOnce(mockDataZoneClient.getConnection) + }) + + it('should throw error when no connection credentials available', async function () { + mockDataZoneClient.getConnection.resolves({ + ...mockGetConnectionResponse, + connectionCredentials: undefined, + }) + + await assert.rejects( + () => connectionProvider.getCredentials(), + (err: ToolkitError) => { + assert.strictEqual(err.code, 'NoConnectionCredentials') + return true + } + ) + }) + + it('should throw error when connection credentials are invalid', async function () { + mockDataZoneClient.getConnection.resolves({ + ...mockGetConnectionResponse, + connectionCredentials: { + accessKeyId: '', // Invalid empty string + secretAccessKey: 'valid-secret', + sessionToken: 'valid-token', + }, + }) + + await assert.rejects( + () => connectionProvider.getCredentials(), + (err: ToolkitError) => { + assert.strictEqual(err.code, 'InvalidConnectionCredentials') + return true + } + ) + }) + + it('should throw error when DataZone client fails', async function () { + const dataZoneError = new Error('DataZone API error') + mockDataZoneClient.getConnection.rejects(dataZoneError) + + await assert.rejects( + () => connectionProvider.getCredentials(), + (err: ToolkitError) => { + assert.strictEqual(err.code, 'ConnectionCredentialsFetchFailed') + return true + } + ) + }) + }) + + describe('invalidate', function () { + it('should clear cached credentials', async function () { + // Get credentials to populate cache + await connectionProvider.getCredentials() + sinon.assert.calledOnce(mockDataZoneClient.getConnection) + + // Invalidate cache + connectionProvider.invalidate() + + // Get credentials again - should make new API call + await connectionProvider.getCredentials() + sinon.assert.calledTwice(mockDataZoneClient.getConnection) + }) + }) + + describe('provider metadata', function () { + it('should return correct provider type', function () { + assert.strictEqual(connectionProvider.getProviderType(), 'temp') + }) + + it('should return correct telemetry type', function () { + assert.strictEqual(connectionProvider.getTelemetryType(), 'other') + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/auth/domainExecRoleCredentialsProvider.test.ts b/packages/core/src/test/sagemakerunifiedstudio/auth/domainExecRoleCredentialsProvider.test.ts new file mode 100644 index 00000000000..7e8cdd8632d --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/auth/domainExecRoleCredentialsProvider.test.ts @@ -0,0 +1,583 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { DomainExecRoleCredentialsProvider } from '../../../sagemakerunifiedstudio/auth/providers/domainExecRoleCredentialsProvider' +import { ToolkitError } from '../../../shared/errors' +import fetch from 'node-fetch' +import { SmusTimeouts } from '../../../sagemakerunifiedstudio/shared/smusUtils' + +describe('DomainExecRoleCredentialsProvider', function () { + let derProvider: DomainExecRoleCredentialsProvider + let mockGetAccessToken: sinon.SinonStub + let fetchStub: sinon.SinonStub + + const testDomainId = 'dzd_testdomain' + const testDomainUrl = 'https://test-domain.sagemaker.us-east-2.on.aws' + const testSsoRegion = 'us-east-2' + const testAccessToken = 'test-access-token-12345' + + const mockCredentialsResponse = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + }, + } + + beforeEach(function () { + // Mock access token function + mockGetAccessToken = sinon.stub().resolves(testAccessToken) + + // Mock fetch + fetchStub = sinon.stub(fetch, 'default' as any).resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(mockCredentialsResponse)), + json: sinon.stub().resolves(mockCredentialsResponse), + } as any) + + derProvider = new DomainExecRoleCredentialsProvider( + testDomainUrl, + testDomainId, + testSsoRegion, + mockGetAccessToken + ) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('should initialize with correct properties', function () { + assert.strictEqual(derProvider.getDomainId(), testDomainId) + assert.strictEqual(derProvider.getDomainUrl(), testDomainUrl) + assert.strictEqual(derProvider.getDefaultRegion(), testSsoRegion) + }) + }) + + describe('getCredentialsId', function () { + it('should return correct credentials ID', function () { + const credentialsId = derProvider.getCredentialsId() + assert.strictEqual(credentialsId.credentialSource, 'sso') + assert.strictEqual(credentialsId.credentialTypeId, testDomainId) + }) + }) + + describe('getProviderType', function () { + it('should return sso provider type', function () { + assert.strictEqual(derProvider.getProviderType(), 'sso') + }) + }) + + describe('getTelemetryType', function () { + it('should return ssoProfile telemetry type', function () { + assert.strictEqual(derProvider.getTelemetryType(), 'ssoProfile') + }) + }) + + describe('getHashCode', function () { + it('should return correct hash code', function () { + const hashCode = derProvider.getHashCode() + assert.strictEqual(hashCode, `smus-der:${testDomainId}:${testSsoRegion}`) + }) + }) + + describe('canAutoConnect', function () { + it('should return false', async function () { + const result = await derProvider.canAutoConnect() + assert.strictEqual(result, false) + }) + }) + + describe('isAvailable', function () { + it('should return true when access token is available', async function () { + const result = await derProvider.isAvailable() + assert.strictEqual(result, true) + assert.ok(mockGetAccessToken.called) + }) + + it('should return false when access token throws error', async function () { + mockGetAccessToken.rejects(new Error('Token error')) + const result = await derProvider.isAvailable() + assert.strictEqual(result, false) + }) + }) + + describe('getCredentials', function () { + it('should fetch and cache DER credentials', async function () { + const credentials = await derProvider.getCredentials() + + // Verify access token was fetched + assert.ok(mockGetAccessToken.called) + + // Verify fetch was called with correct parameters + assert.ok(fetchStub.called) + const fetchCall = fetchStub.firstCall + assert.strictEqual(fetchCall.args[0], `${testDomainUrl}/sso/redeem-token`) + + const fetchOptions = fetchCall.args[1] + assert.strictEqual(fetchOptions.method, 'POST') + assert.strictEqual(fetchOptions.headers['Content-Type'], 'application/json') + assert.strictEqual(fetchOptions.headers['Accept'], 'application/json') + assert.strictEqual(fetchOptions.headers['User-Agent'], 'aws-toolkit-vscode') + + const requestBody = JSON.parse(fetchOptions.body) + assert.strictEqual(requestBody.domainId, testDomainId) + assert.strictEqual(requestBody.accessToken, testAccessToken) + + // Verify timeout is set + assert.strictEqual(fetchOptions.timeout, SmusTimeouts.apiCallTimeoutMs) + assert.strictEqual(fetchOptions.timeout, 10000) // 10 seconds + + // Verify returned credentials + assert.strictEqual(credentials.accessKeyId, mockCredentialsResponse.credentials.accessKeyId) + assert.strictEqual(credentials.secretAccessKey, mockCredentialsResponse.credentials.secretAccessKey) + assert.strictEqual(credentials.sessionToken, mockCredentialsResponse.credentials.sessionToken) + assert.ok(credentials.expiration) + }) + + it('should use cached credentials when available', async function () { + // First call should fetch credentials + const credentials1 = await derProvider.getCredentials() + + // Second call should use cache + const credentials2 = await derProvider.getCredentials() + + // Fetch should only be called once + assert.strictEqual(fetchStub.callCount, 1) + assert.strictEqual(mockGetAccessToken.callCount, 1) + + // Credentials should be the same + assert.strictEqual(credentials1, credentials2) + }) + + it('should handle missing access token', async function () { + mockGetAccessToken.resolves('') + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'DerCredentialsFetchFailed' && err.message.includes('No access token available') + } + ) + }) + + it('should handle HTTP errors from redeem token API', async function () { + fetchStub.resolves({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: sinon.stub().resolves('Invalid token'), + } as any) + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'DerCredentialsFetchFailed' && err.message.includes('401') + } + ) + }) + + it('should handle timeout errors', async function () { + const timeoutError = new Error('Request timeout') + timeoutError.name = 'AbortError' + fetchStub.rejects(timeoutError) + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return ( + err.code === 'DerCredentialsFetchFailed' && err.message.includes('timed out after 10 seconds') + ) + } + ) + }) + + it('should handle network errors', async function () { + const networkError = new Error('Network error') + fetchStub.rejects(networkError) + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'DerCredentialsFetchFailed' + } + ) + }) + + it('should handle missing credentials object in response', async function () { + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify({})), + json: sinon.stub().resolves({}), // Missing credentials object + } as any) + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return ( + err.code === 'DerCredentialsFetchFailed' && err.message.includes('Missing credentials object') + ) + } + ) + }) + + it('should handle invalid accessKeyId in response', async function () { + const invalidResponse = { + credentials: { + accessKeyId: '', // Invalid empty string + secretAccessKey: 'valid-secret', + sessionToken: 'valid-token', + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(invalidResponse)), + json: sinon.stub().resolves(invalidResponse), + } as any) + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'DerCredentialsFetchFailed' && err.message.includes('Invalid accessKeyId') + } + ) + }) + + it('should handle invalid secretAccessKey in response', async function () { + const invalidResponse = { + credentials: { + accessKeyId: 'valid-key', + secretAccessKey: undefined, // Invalid null value + sessionToken: 'valid-token', + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(invalidResponse)), + json: sinon.stub().resolves(invalidResponse), + } as any) + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'DerCredentialsFetchFailed' && err.message.includes('Invalid secretAccessKey') + } + ) + }) + + it('should handle invalid sessionToken in response', async function () { + const invalidResponse = { + credentials: { + accessKeyId: 'valid-key', + secretAccessKey: 'valid-secret', + sessionToken: undefined, // Invalid undefined value + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(invalidResponse)), + json: sinon.stub().resolves(invalidResponse), + } as any) + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'DerCredentialsFetchFailed' && err.message.includes('Invalid sessionToken') + } + ) + }) + + it('should set default expiration when not provided in response', async function () { + const credentials = await derProvider.getCredentials() + + // Should have expiration set to 10 mins from now + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = Date.now() + 10 * 60 * 1000 // 10 minutes + const timeDiff = Math.abs(expirationTime - expectedTime) + assert.ok(timeDiff < 5000, 'Expiration should be 10 mins from now') + }) + + it('should use expiration from API response when provided as ISO string', async function () { + const futureExpiration = new Date(Date.now() + 2 * 60 * 60 * 1000) // 2 hours from now + const responseWithExpiration = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + expiration: futureExpiration.toISOString(), // API returns expiration as ISO string + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(responseWithExpiration)), + json: sinon.stub().resolves(responseWithExpiration), + } as any) + + const credentials = await derProvider.getCredentials() + + // Should use the expiration from the API response + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = futureExpiration.getTime() + const timeDiff = Math.abs(expirationTime - expectedTime) + assert.ok(timeDiff < 1000, 'Should use expiration from API response') + }) + + it('should handle epoch timestamp in seconds from API response', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 3600 // 1 hour from now in seconds + const responseWithEpochExpiration = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + expiration: futureTime.toString(), // Epoch timestamp in seconds as string + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(responseWithEpochExpiration)), + json: sinon.stub().resolves(responseWithEpochExpiration), + } as any) + + const credentials = await derProvider.getCredentials() + + // Should correctly parse epoch timestamp and convert to Date + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = futureTime * 1000 // Convert to milliseconds + const timeDiff = Math.abs(expirationTime - expectedTime) + assert.ok(timeDiff < 1000, 'Should correctly parse epoch timestamp in seconds') + }) + + it('should handle epoch timestamp as number from API response', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 7200 // 2 hours from now in seconds + const responseWithEpochExpiration = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + expiration: futureTime, // Epoch timestamp in seconds as number + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(responseWithEpochExpiration)), + json: sinon.stub().resolves(responseWithEpochExpiration), + } as any) + + const credentials = await derProvider.getCredentials() + + // Should correctly parse epoch timestamp and convert to Date + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = futureTime * 1000 // Convert to milliseconds + const timeDiff = Math.abs(expirationTime - expectedTime) + assert.ok(timeDiff < 1000, 'Should correctly parse epoch timestamp as number') + }) + + it('should handle zero epoch timestamp gracefully', async function () { + const responseWithZeroExpiration = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + expiration: '0', // Zero is not > 0, so treated as ISO string "0" which represents year 0 + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(responseWithZeroExpiration)), + json: sinon.stub().resolves(responseWithZeroExpiration), + } as any) + + const credentials = await derProvider.getCredentials() + + // "0" is parsed as a valid date (year 0), not as an invalid date + // So it should use the parsed date, not the default expiration + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = new Date('0').getTime() // Year 0 + assert.strictEqual(expirationTime, expectedTime, 'Should use parsed date for year 0') + }) + + it('should handle negative epoch timestamp gracefully', async function () { + const responseWithNegativeExpiration = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + expiration: '-1', // Negative is not > 0, so treated as ISO string "-1" which represents year -1 + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(responseWithNegativeExpiration)), + json: sinon.stub().resolves(responseWithNegativeExpiration), + } as any) + + const credentials = await derProvider.getCredentials() + + // "-1" is parsed as a valid date (year -1), not as an invalid date + // So it should use the parsed date, not the default expiration + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = new Date('-1').getTime() // Year -1 + assert.strictEqual(expirationTime, expectedTime, 'Should use parsed date for year -1') + }) + + it('should handle JSON parsing errors', async function () { + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves('invalid json'), + json: sinon.stub().rejects(new Error('Invalid JSON')), + } as any) + + await assert.rejects( + () => derProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'DerCredentialsFetchFailed' + } + ) + }) + + it('should handle invalid expiration string in response', async function () { + const responseWithInvalidExpiration = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + expiration: 'invalid-date-string', // Invalid date string + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(responseWithInvalidExpiration)), + json: sinon.stub().resolves(responseWithInvalidExpiration), + } as any) + + const credentials = await derProvider.getCredentials() + + // Should fall back to default expiration when date parsing fails + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + + // Should be a valid timestamp (not NaN) using the default expiration + assert.ok(!isNaN(expirationTime), 'Should have valid expiration timestamp') + + // Should be close to now + 10 minutes (default expiration) + const expectedTime = Date.now() + 10 * 60 * 1000 + const timeDiff = Math.abs(expirationTime - expectedTime) + assert.ok(timeDiff < 5000, 'Should fall back to default expiration for invalid date string') + }) + + it('should handle empty expiration string in response', async function () { + const responseWithEmptyExpiration = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + expiration: '', // Empty string + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(responseWithEmptyExpiration)), + json: sinon.stub().resolves(responseWithEmptyExpiration), + } as any) + + const credentials = await derProvider.getCredentials() + + // Should fall back to default expiration for empty string + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = Date.now() + 10 * 60 * 1000 // Default 10 minutes + const timeDiff = Math.abs(expirationTime - expectedTime) + assert.ok(timeDiff < 5000, 'Should use default expiration for empty string') + }) + + it('should handle non-numeric string that looks like a number', async function () { + const responseWithInvalidNumber = { + credentials: { + accessKeyId: 'AKIA-DER-KEY', + secretAccessKey: 'der-secret-key', + sessionToken: 'der-session-token', + expiration: '123abc', // Non-numeric string + }, + } + + fetchStub.resolves({ + ok: true, + status: 200, + statusText: 'OK', + text: sinon.stub().resolves(JSON.stringify(responseWithInvalidNumber)), + json: sinon.stub().resolves(responseWithInvalidNumber), + } as any) + + const credentials = await derProvider.getCredentials() + + // Should fall back to default expiration for invalid numeric string + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = Date.now() + 10 * 60 * 1000 // Default 10 minutes + const timeDiff = Math.abs(expirationTime - expectedTime) + assert.ok(timeDiff < 5000, 'Should use default expiration for invalid numeric string') + }) + }) + + describe('invalidate', function () { + it('should clear cache and force fresh fetch on next call', async function () { + // First call to populate cache + await derProvider.getCredentials() + assert.strictEqual(fetchStub.callCount, 1) + + // Invalidate should clear cache + derProvider.invalidate() + + // Next call should fetch fresh credentials + await derProvider.getCredentials() + assert.strictEqual(fetchStub.callCount, 2) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/auth/model.test.ts b/packages/core/src/test/sagemakerunifiedstudio/auth/model.test.ts new file mode 100644 index 00000000000..a6ca72736e9 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/auth/model.test.ts @@ -0,0 +1,232 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { + createSmusProfile, + isValidSmusConnection, + scopeSmus, + SmusConnection, +} from '../../../sagemakerunifiedstudio/auth/model' +import { SsoConnection } from '../../../auth/connection' + +describe('SMUS Auth Model', function () { + const testDomainUrl = 'https://dzd_domainId.sagemaker.us-east-2.on.aws' + const testDomainId = 'dzd_domainId' + const testStartUrl = 'https://identitycenter.amazonaws.com/ssoins-testInstanceId' + const testRegion = 'us-east-2' + + describe('scopeSmus', function () { + it('should have correct scope value', function () { + assert.strictEqual(scopeSmus, 'datazone:domain:access') + }) + }) + + describe('createSmusProfile', function () { + it('should create profile with default scopes', function () { + const profile = createSmusProfile(testDomainUrl, testDomainId, testStartUrl, testRegion) + + assert.strictEqual(profile.domainUrl, testDomainUrl) + assert.strictEqual(profile.domainId, testDomainId) + assert.strictEqual(profile.startUrl, testStartUrl) + assert.strictEqual(profile.ssoRegion, testRegion) + assert.strictEqual(profile.type, 'sso') + assert.deepStrictEqual(profile.scopes, [scopeSmus]) + }) + + it('should create profile with custom scopes', function () { + const customScopes = ['custom:scope', 'another:scope'] + const profile = createSmusProfile(testDomainUrl, testDomainId, testStartUrl, testRegion, customScopes) + + assert.strictEqual(profile.domainUrl, testDomainUrl) + assert.strictEqual(profile.domainId, testDomainId) + assert.strictEqual(profile.startUrl, testStartUrl) + assert.strictEqual(profile.ssoRegion, testRegion) + assert.strictEqual(profile.type, 'sso') + assert.deepStrictEqual(profile.scopes, customScopes) + }) + + it('should create profile with all required properties', function () { + const profile = createSmusProfile(testDomainUrl, testDomainId, testStartUrl, testRegion) + + // Check SsoProfile properties + assert.strictEqual(profile.type, 'sso') + assert.strictEqual(profile.startUrl, testStartUrl) + assert.strictEqual(profile.ssoRegion, testRegion) + assert.ok(Array.isArray(profile.scopes)) + + // Check SmusProfile properties + assert.strictEqual(profile.domainUrl, testDomainUrl) + assert.strictEqual(profile.domainId, testDomainId) + }) + }) + + describe('isValidSmusConnection', function () { + it('should return true for valid SMUS connection', function () { + const validConnection = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + scopes: [scopeSmus], + label: 'Test SMUS Connection', + domainUrl: testDomainUrl, + domainId: testDomainId, + } as SmusConnection + + assert.strictEqual(isValidSmusConnection(validConnection), true) + }) + + it('should return false for connection without SMUS scope', function () { + const connectionWithoutScope = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + scopes: ['sso:account:access'], + label: 'Test Connection', + domainUrl: testDomainUrl, + domainId: testDomainId, + } as any + + assert.strictEqual(isValidSmusConnection(connectionWithoutScope), false) + }) + + it('should return false for connection without SMUS properties', function () { + const connectionWithoutSmusProps = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + scopes: [scopeSmus], + label: 'Test Connection', + } as SsoConnection + + assert.strictEqual(isValidSmusConnection(connectionWithoutSmusProps), false) + }) + + it('should return false for non-SSO connection', function () { + const nonSsoConnection = { + id: 'test-connection-id', + type: 'iam', + label: 'Test IAM Connection', + domainUrl: testDomainUrl, + domainId: testDomainId, + scopes: [scopeSmus], + } + + assert.strictEqual(isValidSmusConnection(nonSsoConnection), false) + }) + + it('should return false for undefined connection', function () { + assert.strictEqual(isValidSmusConnection(undefined), false) + }) + + it('should return false for null connection', function () { + assert.strictEqual(isValidSmusConnection(undefined), false) + }) + + it('should return false for connection without scopes', function () { + const connectionWithoutScopes = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + label: 'Test Connection', + domainUrl: testDomainUrl, + domainId: testDomainId, + } + + assert.strictEqual(isValidSmusConnection(connectionWithoutScopes), false) + }) + + it('should return false for connection with empty scopes array', function () { + const connectionWithEmptyScopes = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + scopes: [], + label: 'Test Connection', + domainUrl: testDomainUrl, + domainId: testDomainId, + } + + assert.strictEqual(isValidSmusConnection(connectionWithEmptyScopes), false) + }) + + it('should return true for connection with SMUS scope among other scopes', function () { + const connectionWithMultipleScopes = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + scopes: ['sso:account:access', scopeSmus, 'other:scope'], + label: 'Test SMUS Connection', + domainUrl: testDomainUrl, + domainId: testDomainId, + } as SmusConnection + + assert.strictEqual(isValidSmusConnection(connectionWithMultipleScopes), true) + }) + + it('should return false for connection missing domainUrl', function () { + const connectionMissingDomainUrl = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + scopes: [scopeSmus], + label: 'Test Connection', + domainId: testDomainId, + } + + assert.strictEqual(isValidSmusConnection(connectionMissingDomainUrl), false) + }) + + it('should return false for connection missing domainId', function () { + const connectionMissingDomainId = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + scopes: [scopeSmus], + label: 'Test Connection', + domainUrl: testDomainUrl, + } + + assert.strictEqual(isValidSmusConnection(connectionMissingDomainId), false) + }) + }) + + describe('SmusConnection interface', function () { + it('should extend both SmusProfile and SsoConnection', function () { + const connection = { + id: 'test-connection-id', + type: 'sso', + startUrl: testStartUrl, + ssoRegion: testRegion, + scopes: [scopeSmus], + label: 'Test SMUS Connection', + domainUrl: testDomainUrl, + domainId: testDomainId, + } as SmusConnection + + // Should have Connection properties + assert.strictEqual(connection.id, 'test-connection-id') + assert.strictEqual(connection.label, 'Test SMUS Connection') + + // Should have SsoConnection properties + assert.strictEqual(connection.type, 'sso') + assert.strictEqual(connection.startUrl, testStartUrl) + assert.strictEqual(connection.ssoRegion, testRegion) + assert.ok(Array.isArray(connection.scopes)) + + // Should have SmusProfile properties + assert.strictEqual(connection.domainUrl, testDomainUrl) + assert.strictEqual(connection.domainId, testDomainId) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/auth/projectRoleCredentialsProvider.test.ts b/packages/core/src/test/sagemakerunifiedstudio/auth/projectRoleCredentialsProvider.test.ts new file mode 100644 index 00000000000..6dd206593f8 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/auth/projectRoleCredentialsProvider.test.ts @@ -0,0 +1,241 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { ProjectRoleCredentialsProvider } from '../../../sagemakerunifiedstudio/auth/providers/projectRoleCredentialsProvider' +import { DataZoneClient } from '../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { ToolkitError } from '../../../shared/errors' + +describe('ProjectRoleCredentialsProvider', function () { + let mockDataZoneClient: sinon.SinonStubbedInstance + let mockSmusAuthProvider: any + let projectProvider: ProjectRoleCredentialsProvider + let dataZoneClientStub: sinon.SinonStub + + const testProjectId = 'test-project-123' + const testDomainId = 'dzd_testdomain' + const testRegion = 'us-east-2' + + const mockGetEnvironmentCredentialsResponse = { + accessKeyId: 'AKIA-PROJECT-KEY', + secretAccessKey: 'project-secret-key', + sessionToken: 'project-session-token', + expiration: new Date(Date.now() + 14 * 60 * 1000), // 14 minutes as Date object + $metadata: { + httpStatusCode: 200, + requestId: 'test-request-id', + }, + } + + beforeEach(function () { + // Mock SMUS auth provider + mockSmusAuthProvider = { + getDomainId: sinon.stub().returns(testDomainId), + getDomainRegion: sinon.stub().returns(testRegion), + isConnected: sinon.stub().returns(true), + } as any + + // Mock DataZone client + mockDataZoneClient = { + getProjectDefaultEnvironmentCreds: sinon.stub().resolves(mockGetEnvironmentCredentialsResponse), + } as any + + // Stub DataZoneClient.getInstance + dataZoneClientStub = sinon.stub(DataZoneClient, 'getInstance').resolves(mockDataZoneClient as any) + + projectProvider = new ProjectRoleCredentialsProvider(mockSmusAuthProvider, testProjectId) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('should initialize with DER provider and project ID', function () { + assert.strictEqual(projectProvider.getProjectId(), testProjectId) + }) + }) + + describe('getCredentialsId', function () { + it('should return correct credentials ID', function () { + const credentialsId = projectProvider.getCredentialsId() + assert.strictEqual(credentialsId.credentialSource, 'temp') + assert.strictEqual(credentialsId.credentialTypeId, `${testDomainId}:${testProjectId}`) + }) + }) + + describe('getProviderType', function () { + it('should return sso provider type', function () { + assert.strictEqual(projectProvider.getProviderType(), 'temp') + }) + }) + + describe('getTelemetryType', function () { + it('should return smusProfile telemetry type', function () { + assert.strictEqual(projectProvider.getTelemetryType(), 'other') + }) + }) + + describe('getDefaultRegion', function () { + it('should return DER provider default region', function () { + assert.strictEqual(projectProvider.getDefaultRegion(), testRegion) + }) + }) + + describe('getHashCode', function () { + it('should return correct hash code', function () { + const hashCode = projectProvider.getHashCode() + assert.strictEqual(hashCode, `smus-project:${testDomainId}:${testProjectId}`) + }) + }) + + describe('canAutoConnect', function () { + it('should return false', async function () { + const result = await projectProvider.canAutoConnect() + assert.strictEqual(result, false) + }) + }) + + describe('isAvailable', function () { + it('should delegate to SMUS auth provider', async function () { + const result = await projectProvider.isAvailable() + assert.strictEqual(result, true) + assert.ok(mockSmusAuthProvider.isConnected.called) + }) + }) + + describe('getCredentials', function () { + it('should fetch and cache project credentials', async function () { + const credentials = await projectProvider.getCredentials() + + // Verify DataZone client getInstance was called + assert.ok(dataZoneClientStub.calledWith(mockSmusAuthProvider)) + + // Verify getProjectDefaultEnvironmentCreds was called + assert.ok(mockDataZoneClient.getProjectDefaultEnvironmentCreds.called) + assert.ok(mockDataZoneClient.getProjectDefaultEnvironmentCreds.calledWith(testProjectId)) + + // Verify returned credentials + assert.strictEqual(credentials.accessKeyId, mockGetEnvironmentCredentialsResponse.accessKeyId) + assert.strictEqual(credentials.secretAccessKey, mockGetEnvironmentCredentialsResponse.secretAccessKey) + assert.strictEqual(credentials.sessionToken, mockGetEnvironmentCredentialsResponse.sessionToken) + assert.ok(credentials.expiration) + }) + + it('should use cached credentials when available', async function () { + // First call should fetch credentials + const credentials1 = await projectProvider.getCredentials() + + // Second call should use cache + const credentials2 = await projectProvider.getCredentials() + + // DataZone client method should only be called once + assert.strictEqual(mockDataZoneClient.getProjectDefaultEnvironmentCreds.callCount, 1) + + // Credentials should be the same + assert.strictEqual(credentials1, credentials2) + }) + + it('should handle DataZone client errors', async function () { + const error = new Error('DataZone client failed') + mockDataZoneClient.getProjectDefaultEnvironmentCreds.rejects(error) + + await assert.rejects( + () => projectProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'ProjectCredentialsFetchFailed' && err.message.includes(testProjectId) + } + ) + }) + + it('should handle GetEnvironmentCredentials API errors', async function () { + const error = new Error('API call failed') + mockDataZoneClient.getProjectDefaultEnvironmentCreds.rejects(error) + + await assert.rejects( + () => projectProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'ProjectCredentialsFetchFailed' + } + ) + }) + + it('should handle missing credentials in response', async function () { + mockDataZoneClient.getProjectDefaultEnvironmentCreds.resolves({ + accessKeyId: undefined, + $metadata: { + httpStatusCode: 200, + requestId: 'test-request-id', + }, + }) + + await assert.rejects( + () => projectProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'ProjectCredentialsFetchFailed' + } + ) + }) + + it('should handle invalid credential fields', async function () { + const invalidResponse = { + accessKeyId: '', // Invalid empty string + secretAccessKey: 'valid-secret', + sessionToken: 'valid-token', + $metadata: { + httpStatusCode: 200, + requestId: 'test-request-id', + }, + } + mockDataZoneClient.getProjectDefaultEnvironmentCreds.resolves(invalidResponse) + + await assert.rejects( + () => projectProvider.getCredentials(), + (err: ToolkitError) => { + return err.code === 'ProjectCredentialsFetchFailed' + } + ) + }) + + it('should use default expiration when not provided in response', async function () { + const responseWithoutExpiration = { + accessKeyId: 'AKIA-PROJECT-KEY', + secretAccessKey: 'project-secret-key', + sessionToken: 'project-session-token', + // No expiration field + $metadata: { + httpStatusCode: 200, + requestId: 'test-request-id', + }, + } + mockDataZoneClient.getProjectDefaultEnvironmentCreds.resolves(responseWithoutExpiration) + + const credentials = await projectProvider.getCredentials() + + // Should have expiration set to ~10 minutes from now + assert.ok(credentials.expiration) + const expirationTime = credentials.expiration!.getTime() + const expectedTime = Date.now() + 10 * 60 * 1000 + const timeDiff = Math.abs(expirationTime - expectedTime) + assert.ok(timeDiff < 5000, 'Expiration should be ~10 minutes from now') + }) + }) + + describe('invalidate', function () { + it('should clear cache and force fresh fetch on next call', async function () { + // First call to populate cache + await projectProvider.getCredentials() + assert.strictEqual(mockDataZoneClient.getProjectDefaultEnvironmentCreds.callCount, 1) + + // Invalidate should clear cache + projectProvider.invalidate() + + // Next call should fetch fresh credentials + await projectProvider.getCredentials() + assert.strictEqual(mockDataZoneClient.getProjectDefaultEnvironmentCreds.callCount, 2) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/auth/smusAuthenticationProvider.test.ts b/packages/core/src/test/sagemakerunifiedstudio/auth/smusAuthenticationProvider.test.ts new file mode 100644 index 00000000000..e00a050aedc --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/auth/smusAuthenticationProvider.test.ts @@ -0,0 +1,410 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' + +// Mock the setContext function BEFORE importing modules that use it +const setContextModule = require('../../../shared/vscode/setContext') +const setContextStubGlobal = sinon.stub(setContextModule, 'setContext').resolves() + +import { SmusAuthenticationProvider } from '../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' +import { SmusConnection } from '../../../sagemakerunifiedstudio/auth/model' +import { DataZoneClient } from '../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { SmusUtils } from '../../../sagemakerunifiedstudio/shared/smusUtils' +import { ToolkitError } from '../../../shared/errors' +import * as messages from '../../../shared/utilities/messages' + +describe('SmusAuthenticationProvider', function () { + let mockAuth: any + let mockSecondaryAuth: any + let mockDataZoneClient: sinon.SinonStubbedInstance + let smusAuthProvider: SmusAuthenticationProvider + let extractDomainInfoStub: sinon.SinonStub + let getSsoInstanceInfoStub: sinon.SinonStub + let isInSmusSpaceEnvironmentStub: sinon.SinonStub + let executeCommandStub: sinon.SinonStub + let mockSecondaryAuthState: { + activeConnection: SmusConnection | undefined + hasSavedConnection: boolean + isConnectionExpired: boolean + } + + const testDomainUrl = 'https://dzd_domainId.sagemaker.us-east-2.on.aws' + const testDomainId = 'dzd_domainId' + const testRegion = 'us-east-2' + const testSsoInstanceInfo = { + issuerUrl: 'https://identitycenter.amazonaws.com/ssoins-testInstanceId', + ssoInstanceId: 'ssoins-testInstanceId', + clientId: 'arn:aws:sso::123456789:application/ssoins-testInstanceId/apl-testAppId', + region: testRegion, + } + + const mockSmusConnection: SmusConnection = { + id: 'test-connection-id', + type: 'sso', + startUrl: 'https://identitycenter.amazonaws.com/ssoins-testInstanceId', + ssoRegion: testRegion, + scopes: ['datazone:domain:access'], + label: 'Test SMUS Connection', + domainUrl: testDomainUrl, + domainId: testDomainId, + getToken: sinon.stub().resolves({ accessToken: 'mock-token', expiresAt: new Date() }), + getRegistration: sinon.stub().resolves({ clientId: 'mock-client', expiresAt: new Date() }), + } + + beforeEach(function () { + mockAuth = { + createConnection: sinon.stub().resolves(mockSmusConnection), + listConnections: sinon.stub().resolves([]), + getConnectionState: sinon.stub().returns('valid'), + reauthenticate: sinon.stub().resolves(mockSmusConnection), + } as any + + // Create a mock object with configurable properties + mockSecondaryAuthState = { + activeConnection: mockSmusConnection as SmusConnection | undefined, + hasSavedConnection: false, + isConnectionExpired: false, + } + + mockSecondaryAuth = { + get activeConnection() { + return mockSecondaryAuthState.activeConnection + }, + get hasSavedConnection() { + return mockSecondaryAuthState.hasSavedConnection + }, + get isConnectionExpired() { + return mockSecondaryAuthState.isConnectionExpired + }, + onDidChangeActiveConnection: sinon.stub().returns({ dispose: sinon.stub() }), + restoreConnection: sinon.stub().resolves(), + useNewConnection: sinon.stub().resolves(mockSmusConnection), + deleteConnection: sinon.stub().resolves(), + } + + mockDataZoneClient = { + // Add any DataZoneClient methods that might be used + } as any + + // Stub static methods + sinon.stub(DataZoneClient, 'getInstance').returns(mockDataZoneClient as any) + extractDomainInfoStub = sinon + .stub(SmusUtils, 'extractDomainInfoFromUrl') + .returns({ domainId: testDomainId, region: testRegion }) + getSsoInstanceInfoStub = sinon.stub(SmusUtils, 'getSsoInstanceInfo').resolves(testSsoInstanceInfo) + isInSmusSpaceEnvironmentStub = sinon.stub(SmusUtils, 'isInSmusSpaceEnvironment').returns(false) + executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves() + sinon.stub(require('../../../auth/secondaryAuth'), 'getSecondaryAuth').returns(mockSecondaryAuth) + + smusAuthProvider = new SmusAuthenticationProvider(mockAuth, mockSecondaryAuth) + + // Reset the executeCommand stub for clean state + executeCommandStub.resetHistory() + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('should initialize with auth and secondary auth', function () { + assert.strictEqual(smusAuthProvider.auth, mockAuth) + assert.strictEqual(smusAuthProvider.secondaryAuth, mockSecondaryAuth) + }) + + it('should register event listeners', function () { + assert.ok(mockSecondaryAuth.onDidChangeActiveConnection.called) + }) + + it('should set initial context', async function () { + // Context should be set during construction (async call) + // Wait a bit for the async call to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + assert.ok(setContextStubGlobal.called) + }) + }) + + describe('activeConnection', function () { + it('should return secondary auth active connection', function () { + assert.strictEqual(smusAuthProvider.activeConnection, mockSmusConnection) + }) + }) + + describe('isUsingSavedConnection', function () { + it('should return secondary auth hasSavedConnection value', function () { + mockSecondaryAuthState.hasSavedConnection = true + assert.strictEqual(smusAuthProvider.isUsingSavedConnection, true) + + mockSecondaryAuthState.hasSavedConnection = false + assert.strictEqual(smusAuthProvider.isUsingSavedConnection, false) + }) + }) + + describe('isConnectionValid', function () { + it('should return true when connection exists and is not expired', function () { + mockSecondaryAuthState.activeConnection = mockSmusConnection + mockSecondaryAuthState.isConnectionExpired = false + + assert.strictEqual(smusAuthProvider.isConnectionValid(), true) + }) + + it('should return false when no connection exists', function () { + mockSecondaryAuthState.activeConnection = undefined + + assert.strictEqual(smusAuthProvider.isConnectionValid(), false) + }) + + it('should return false when connection is expired', function () { + mockSecondaryAuthState.activeConnection = mockSmusConnection + mockSecondaryAuthState.isConnectionExpired = true + + assert.strictEqual(smusAuthProvider.isConnectionValid(), false) + }) + }) + + describe('isConnected', function () { + it('should return true when active connection exists', function () { + mockSecondaryAuthState.activeConnection = mockSmusConnection + assert.strictEqual(smusAuthProvider.isConnected(), true) + }) + + it('should return false when no active connection', function () { + mockSecondaryAuthState.activeConnection = undefined + assert.strictEqual(smusAuthProvider.isConnected(), false) + }) + }) + + describe('restore', function () { + it('should call secondary auth restoreConnection', async function () { + await smusAuthProvider.restore() + assert.ok(mockSecondaryAuth.restoreConnection.called) + }) + }) + + describe('connectToSmus', function () { + it('should create new connection when none exists', async function () { + mockAuth.listConnections.resolves([]) + + const result = await smusAuthProvider.connectToSmus(testDomainUrl) + + assert.strictEqual(result, mockSmusConnection) + assert.ok(extractDomainInfoStub.calledWith(testDomainUrl)) + assert.ok(getSsoInstanceInfoStub.calledWith(testDomainUrl)) + assert.ok(mockAuth.createConnection.called) + assert.ok(mockSecondaryAuth.useNewConnection.called) + assert.ok(executeCommandStub.calledWith('aws.smus.switchProject')) + }) + + it('should reuse existing valid connection', async function () { + const existingConnection = { ...mockSmusConnection, domainUrl: testDomainUrl.toLowerCase() } + mockAuth.listConnections.resolves([existingConnection]) + mockAuth.getConnectionState.returns('valid') + + const result = await smusAuthProvider.connectToSmus(testDomainUrl) + + assert.strictEqual(result, mockSmusConnection) + assert.ok(mockAuth.createConnection.notCalled) + assert.ok(mockSecondaryAuth.useNewConnection.calledWith(existingConnection)) + assert.ok(executeCommandStub.calledWith('aws.smus.switchProject')) + }) + + it('should reauthenticate existing invalid connection', async function () { + const existingConnection = { ...mockSmusConnection, domainUrl: testDomainUrl.toLowerCase() } + mockAuth.listConnections.resolves([existingConnection]) + mockAuth.getConnectionState.returns('invalid') + + const result = await smusAuthProvider.connectToSmus(testDomainUrl) + + assert.strictEqual(result, mockSmusConnection) + assert.ok(mockAuth.reauthenticate.calledWith(existingConnection)) + assert.ok(mockSecondaryAuth.useNewConnection.called) + assert.ok(executeCommandStub.calledWith('aws.smus.switchProject')) + }) + + it('should throw error for invalid domain URL', async function () { + extractDomainInfoStub.returns({ domainId: undefined, region: testRegion }) + + await assert.rejects( + () => smusAuthProvider.connectToSmus('invalid-url'), + (err: ToolkitError) => { + // The error is wrapped with FailedToConnect, but the original error should be in the cause + return err.code === 'FailedToConnect' && (err.cause as any)?.code === 'InvalidDomainUrl' + } + ) + // Should not trigger project selection on error + assert.ok(executeCommandStub.notCalled) + }) + + it('should handle SmusUtils errors', async function () { + const error = new Error('SmusUtils error') + getSsoInstanceInfoStub.rejects(error) + + await assert.rejects( + () => smusAuthProvider.connectToSmus(testDomainUrl), + (err: ToolkitError) => err.code === 'FailedToConnect' + ) + // Should not trigger project selection on error + assert.ok(executeCommandStub.notCalled) + }) + + it('should handle auth creation errors', async function () { + const error = new Error('Auth creation failed') + mockAuth.createConnection.rejects(error) + + await assert.rejects( + () => smusAuthProvider.connectToSmus(testDomainUrl), + (err: ToolkitError) => err.code === 'FailedToConnect' + ) + // Should not trigger project selection on error + assert.ok(executeCommandStub.notCalled) + }) + + it('should not trigger project selection in SMUS space environment', async function () { + isInSmusSpaceEnvironmentStub.returns(true) + mockAuth.listConnections.resolves([]) + + const result = await smusAuthProvider.connectToSmus(testDomainUrl) + + assert.strictEqual(result, mockSmusConnection) + assert.ok(mockAuth.createConnection.called) + assert.ok(mockSecondaryAuth.useNewConnection.called) + assert.ok(executeCommandStub.notCalled) + }) + + it('should not trigger project selection when reusing connection in SMUS space environment', async function () { + isInSmusSpaceEnvironmentStub.returns(true) + const existingConnection = { ...mockSmusConnection, domainUrl: testDomainUrl.toLowerCase() } + mockAuth.listConnections.resolves([existingConnection]) + mockAuth.getConnectionState.returns('valid') + + const result = await smusAuthProvider.connectToSmus(testDomainUrl) + + assert.strictEqual(result, mockSmusConnection) + assert.ok(mockSecondaryAuth.useNewConnection.calledWith(existingConnection)) + assert.ok(executeCommandStub.notCalled) + }) + + it('should not trigger project selection when reauthenticating in SMUS space environment', async function () { + isInSmusSpaceEnvironmentStub.returns(true) + const existingConnection = { ...mockSmusConnection, domainUrl: testDomainUrl.toLowerCase() } + mockAuth.listConnections.resolves([existingConnection]) + mockAuth.getConnectionState.returns('invalid') + + const result = await smusAuthProvider.connectToSmus(testDomainUrl) + + assert.strictEqual(result, mockSmusConnection) + assert.ok(mockAuth.reauthenticate.calledWith(existingConnection)) + assert.ok(mockSecondaryAuth.useNewConnection.called) + assert.ok(executeCommandStub.notCalled) + }) + }) + + describe('reauthenticate', function () { + it('should call auth reauthenticate', async function () { + const result = await smusAuthProvider.reauthenticate(mockSmusConnection) + + assert.strictEqual(result, mockSmusConnection) + assert.ok(mockAuth.reauthenticate.calledWith(mockSmusConnection)) + }) + + it('should wrap auth errors in ToolkitError', async function () { + const error = new Error('Reauthentication failed') + mockAuth.reauthenticate.rejects(error) + + await assert.rejects( + () => smusAuthProvider.reauthenticate(mockSmusConnection), + (err: ToolkitError) => err.message.includes('Unable to reauthenticate') + ) + }) + }) + + describe('showReauthenticationPrompt', function () { + it('should show reauthentication message', async function () { + const showReauthenticateMessageStub = sinon.stub(messages, 'showReauthenticateMessage').resolves() + + await smusAuthProvider.showReauthenticationPrompt(mockSmusConnection) + + assert.ok(showReauthenticateMessageStub.called) + const callArgs = showReauthenticateMessageStub.firstCall.args[0] + assert.ok(callArgs.message.includes('SageMaker Unified Studio')) + assert.strictEqual(callArgs.suppressId, 'smusConnectionExpired') + }) + }) + + describe('getAccessToken', function () { + beforeEach(function () { + mockSecondaryAuthState.activeConnection = mockSmusConnection + mockAuth.getSsoAccessToken = sinon.stub().resolves('mock-access-token') + mockAuth.invalidateConnection = sinon.stub() + }) + + it('should return access token when successful', async function () { + const token = await smusAuthProvider.getAccessToken() + + assert.strictEqual(token, 'mock-access-token') + assert.ok(mockAuth.getSsoAccessToken.calledWith(mockSmusConnection)) + }) + + it('should throw error when no active connection', async function () { + mockSecondaryAuthState.activeConnection = undefined + + await assert.rejects( + () => smusAuthProvider.getAccessToken(), + (err: ToolkitError) => err.code === 'NoActiveConnection' + ) + }) + + it('should handle InvalidGrantException and mark connection for reauthentication', async function () { + const invalidGrantError = new Error('UnknownError') + invalidGrantError.name = 'InvalidGrantException' + mockAuth.getSsoAccessToken.rejects(invalidGrantError) + + await assert.rejects( + () => smusAuthProvider.getAccessToken(), + (err: ToolkitError) => { + return ( + err.code === 'RedeemAccessTokenFailed' && + err.message.includes('Failed to retrieve SSO access token for connection') + ) + } + ) + + // Verify connection was NOT invalidated (current implementation doesn't handle InvalidGrantException specially) + assert.ok(mockAuth.invalidateConnection.notCalled) + }) + + it('should handle other errors normally', async function () { + const genericError = new Error('Network error') + mockAuth.getSsoAccessToken.rejects(genericError) + + await assert.rejects( + () => smusAuthProvider.getAccessToken(), + (err: ToolkitError) => + err.message.includes('Failed to retrieve SSO access token for connection') && + err.code === 'RedeemAccessTokenFailed' + ) + + // Verify connection was NOT invalidated for generic errors + assert.ok(mockAuth.invalidateConnection.notCalled) + }) + }) + + describe('fromContext', function () { + it('should return singleton instance', function () { + const instance1 = SmusAuthenticationProvider.fromContext() + const instance2 = SmusAuthenticationProvider.fromContext() + + assert.strictEqual(instance1, instance2) + }) + + it('should return instance property', function () { + const instance = SmusAuthenticationProvider.fromContext() + assert.strictEqual(SmusAuthenticationProvider.instance, instance) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/connectionMagicsSelector/activation.test.ts b/packages/core/src/test/sagemakerunifiedstudio/connectionMagicsSelector/activation.test.ts new file mode 100644 index 00000000000..86e37c76444 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/connectionMagicsSelector/activation.test.ts @@ -0,0 +1,11 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert' + +describe('Connection magic selector test', function () { + it('example test', function () { + assert.ok(true) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/activation.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/activation.test.ts new file mode 100644 index 00000000000..fcb76325293 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/activation.test.ts @@ -0,0 +1,483 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { activate } from '../../../sagemakerunifiedstudio/explorer/activation' +import { + SmusAuthenticationProvider, + setSmusConnectedContext, +} from '../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' +import { DataZoneClient } from '../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { ResourceTreeDataProvider } from '../../../shared/treeview/resourceTreeDataProvider' +import { SageMakerUnifiedStudioRootNode } from '../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioRootNode' +import { getLogger } from '../../../shared/logger/logger' +import { getTestWindow } from '../../shared/vscode/window' +import { SeverityLevel } from '../../shared/vscode/message' +import * as extensionUtilities from '../../../shared/extensionUtilities' + +describe('SMUS Explorer Activation', function () { + let mockExtensionContext: vscode.ExtensionContext + let mockSmusAuthProvider: sinon.SinonStubbedInstance + let mockTreeView: sinon.SinonStubbedInstance> + let mockTreeDataProvider: sinon.SinonStubbedInstance + let mockSmusRootNode: sinon.SinonStubbedInstance + let createTreeViewStub: sinon.SinonStub + let registerCommandStub: sinon.SinonStub + let dataZoneDisposeStub: sinon.SinonStub + let setupUserActivityMonitoringStub: sinon.SinonStub + + beforeEach(function () { + mockExtensionContext = { + subscriptions: [], + } as any + + mockSmusAuthProvider = { + restore: sinon.stub().resolves(), + isConnected: sinon.stub().returns(true), + reauthenticate: sinon.stub().resolves(), + onDidChange: sinon.stub().callsFake((_listener: () => void) => ({ dispose: sinon.stub() })), + activeConnection: { + id: 'test-connection', + domainId: 'test-domain', + ssoRegion: 'us-east-1', + }, + } as any + + mockTreeView = { + dispose: sinon.stub(), + } as any + + mockTreeDataProvider = { + refresh: sinon.stub(), + } as any + + mockSmusRootNode = { + getChildren: sinon.stub().resolves([]), + getProjectSelectNode: sinon.stub().returns({ refreshNode: sinon.stub().resolves() }), + } as any + + // Stub vscode APIs + createTreeViewStub = sinon.stub(vscode.window, 'createTreeView').returns(mockTreeView as any) + registerCommandStub = sinon.stub(vscode.commands, 'registerCommand').returns({ dispose: sinon.stub() } as any) + + // Stub SmusAuthenticationProvider + sinon.stub(SmusAuthenticationProvider, 'fromContext').returns(mockSmusAuthProvider as any) + + // Stub DataZoneClient + dataZoneDisposeStub = sinon.stub(DataZoneClient, 'dispose') + + // Stub SageMakerUnifiedStudioRootNode constructor + sinon.stub(SageMakerUnifiedStudioRootNode.prototype, 'getChildren').returns(mockSmusRootNode.getChildren()) + sinon + .stub(SageMakerUnifiedStudioRootNode.prototype, 'getProjectSelectNode') + .returns(mockSmusRootNode.getProjectSelectNode()) + + // Stub ResourceTreeDataProvider constructor + sinon.stub(ResourceTreeDataProvider.prototype, 'refresh').value(mockTreeDataProvider.refresh) + + // Stub logger + sinon.stub({ getLogger }, 'getLogger').returns({ + debug: sinon.stub(), + info: sinon.stub(), + error: sinon.stub(), + } as any) + + // Stub setSmusConnectedContext + sinon.stub({ setSmusConnectedContext }, 'setSmusConnectedContext').resolves() + + // Stub setupUserActivityMonitoring + setupUserActivityMonitoringStub = sinon + .stub(require('../../../awsService/sagemaker/sagemakerSpace'), 'setupUserActivityMonitoring') + .resolves() + + // Stub isSageMaker to return true for SMUS + sinon.stub(extensionUtilities, 'isSageMaker').returns(true) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('activate', function () { + it('should initialize SMUS authentication provider and call restore', async function () { + await activate(mockExtensionContext) + + assert.ok((SmusAuthenticationProvider.fromContext as sinon.SinonStub).called) + assert.ok(mockSmusAuthProvider.restore.called) + }) + + it('should create tree view with correct configuration', async function () { + await activate(mockExtensionContext) + + assert.ok(createTreeViewStub.calledWith('aws.smus.rootView')) + const createTreeViewArgs = createTreeViewStub.firstCall.args[1] + assert.ok('treeDataProvider' in createTreeViewArgs) + }) + + it('should register all required commands', async function () { + await activate(mockExtensionContext) + + // Check that commands are registered + const registeredCommands = registerCommandStub.getCalls().map((call) => call.args[0]) + + assert.ok(registeredCommands.includes('aws.smus.rootView.refresh')) + assert.ok(registeredCommands.includes('aws.smus.projectView')) + assert.ok(registeredCommands.includes('aws.smus.refreshProject')) + assert.ok(registeredCommands.includes('aws.smus.switchProject')) + assert.ok(registeredCommands.includes('aws.smus.stopSpace')) + assert.ok(registeredCommands.includes('aws.smus.openRemoteConnection')) + assert.ok(registeredCommands.includes('aws.smus.reauthenticate')) + }) + + it('should add all disposables to extension context subscriptions', async function () { + await activate(mockExtensionContext) + + // Should have multiple subscriptions added + assert.ok(mockExtensionContext.subscriptions.length > 0) + }) + + it('should refresh tree data provider on initialization', async function () { + await activate(mockExtensionContext) + + assert.ok(mockTreeDataProvider.refresh.called) + }) + + it('should register DataZone client disposal', async function () { + await activate(mockExtensionContext) + + // Find the DataZone dispose subscription - it should be the last one added + const subscriptions = mockExtensionContext.subscriptions + assert.ok(subscriptions.length > 0) + + // The DataZone dispose subscription should be among the subscriptions + let dataZoneDisposeFound = false + for (const subscription of subscriptions) { + if (subscription && typeof subscription.dispose === 'function') { + // Try calling dispose and see if it calls DataZoneClient.dispose + const callCountBefore = dataZoneDisposeStub.callCount + subscription.dispose() + if (dataZoneDisposeStub.callCount > callCountBefore) { + dataZoneDisposeFound = true + break + } + } + } + + assert.ok(dataZoneDisposeFound, 'Should register DataZone client disposal') + }) + + describe('command handlers', function () { + beforeEach(async function () { + await activate(mockExtensionContext) + }) + + it('should handle aws.smus.rootView.refresh command', async function () { + const refreshCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.rootView.refresh') + + assert.ok(refreshCommand) + + // Execute the command handler + await refreshCommand.args[1]() + + assert.ok(mockTreeDataProvider.refresh.called) + }) + + it('should handle aws.smus.reauthenticate command with connection', async function () { + const reauthCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.reauthenticate') + + assert.ok(reauthCommand) + + const mockConnection = { + id: 'test-connection', + type: 'sso', + startUrl: 'https://identitycenter.amazonaws.com/ssoins-testInstanceId', + ssoRegion: 'us-east-1', + scopes: ['datazone:domain:access'], + label: 'Test Connection', + } as any + + const testWindow = getTestWindow() + + // Execute the command handler with connection + await reauthCommand.args[1](mockConnection) + + assert.ok(mockSmusAuthProvider.reauthenticate.calledWith(mockConnection)) + assert.ok(mockTreeDataProvider.refresh.called) + + // Check that an information message was shown + const infoMessages = testWindow.shownMessages.filter( + (msg) => msg.severity === SeverityLevel.Information + ) + assert.ok(infoMessages.length > 0, 'Should show information message') + assert.ok(infoMessages.some((msg) => msg.message.includes('Successfully reauthenticated'))) + }) + + it('should handle aws.smus.reauthenticate command without connection', async function () { + const reauthCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.reauthenticate') + + assert.ok(reauthCommand) + + // Execute the command handler without connection + await reauthCommand.args[1]() + + assert.ok(mockSmusAuthProvider.reauthenticate.notCalled) + }) + + it('should handle reauthentication errors', async function () { + const reauthCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.reauthenticate') + + assert.ok(reauthCommand) + + const mockConnection = { + id: 'test-connection', + type: 'sso', + startUrl: 'https://identitycenter.amazonaws.com/ssoins-testInstanceId', + ssoRegion: 'us-east-1', + scopes: ['datazone:domain:access'], + label: 'Test Connection', + } as any + const error = new Error('Reauthentication failed') + mockSmusAuthProvider.reauthenticate.rejects(error) + + const testWindow = getTestWindow() + + // Execute the command handler + await reauthCommand.args[1](mockConnection) + + // Check that an error message was shown + const errorMessages = testWindow.shownMessages.filter((msg) => msg.severity === SeverityLevel.Error) + assert.ok(errorMessages.length > 0, 'Should show error message') + assert.ok(errorMessages.some((msg) => msg.message.includes('Failed to reauthenticate'))) + }) + + it('should handle aws.smus.refreshProject command', async function () { + const refreshProjectCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.refreshProject') + + assert.ok(refreshProjectCommand) + + // Execute the command handler + await refreshProjectCommand.args[1]() + + // Verify that getProjectSelectNode was called and refreshNode was called on the returned node + assert.ok(mockSmusRootNode.getProjectSelectNode.called) + const projectNode = mockSmusRootNode.getProjectSelectNode() + assert.ok((projectNode.refreshNode as sinon.SinonStub).called) + }) + + it('should handle aws.smus.stopSpace command with valid node', async function () { + const stopSpaceCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.stopSpace') + + assert.ok(stopSpaceCommand) + + const mockSpaceNode = { + resource: { + sageMakerClient: {}, + DomainSpaceKey: 'test-space-key', + regionCode: 'us-east-1', + getParent: sinon.stub().returns({ + getAuthProvider: sinon.stub().returns({ + activeConnection: { domainId: 'test-domain' }, + }), + getProjectId: sinon.stub().returns('test-project'), + }), + }, + getParent: sinon.stub().returns({ + getAuthProvider: sinon.stub().returns({ + activeConnection: { domainId: 'test-domain' }, + }), + getProjectId: sinon.stub().returns('test-project'), + }), + } as any + + // Mock the stopSpace function + const stopSpaceStub = sinon.stub() + sinon.stub(require('../../../awsService/sagemaker/commands'), 'stopSpace').value(stopSpaceStub) + + // Execute the command handler + await stopSpaceCommand.args[1](mockSpaceNode) + + assert.ok( + stopSpaceStub.calledWith( + mockSpaceNode.resource, + mockExtensionContext, + mockSpaceNode.resource.sageMakerClient + ) + ) + }) + + it('should handle aws.smus.stopSpace command with invalid node', async function () { + const stopSpaceCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.stopSpace') + + assert.ok(stopSpaceCommand) + + const testWindow = getTestWindow() + + // Execute the command handler with undefined node + await stopSpaceCommand.args[1](undefined) + + // Check that a warning message was shown + const warningMessages = testWindow.shownMessages.filter((msg) => msg.severity === SeverityLevel.Warning) + assert.ok(warningMessages.length > 0, 'Should show warning message') + assert.ok(warningMessages.some((msg) => msg.message.includes('Space information is being refreshed'))) + }) + + it('should handle aws.smus.openRemoteConnection command with valid node', async function () { + const openRemoteCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.openRemoteConnection') + + assert.ok(openRemoteCommand) + + const mockSpaceNode = { + resource: { + sageMakerClient: {}, + DomainSpaceKey: 'test-space-key', + regionCode: 'us-east-1', + getParent: sinon.stub().returns({ + getAuthProvider: sinon.stub().returns({ + activeConnection: { domainId: 'test-domain' }, + }), + getProjectId: sinon.stub().returns('test-project'), + }), + }, + getParent: sinon.stub().returns({ + getAuthProvider: sinon.stub().returns({ + activeConnection: { domainId: 'test-domain' }, + }), + getProjectId: sinon.stub().returns('test-project'), + }), + } as any + + // Mock the openRemoteConnect function + const openRemoteConnectStub = sinon.stub() + sinon + .stub(require('../../../awsService/sagemaker/commands'), 'openRemoteConnect') + .value(openRemoteConnectStub) + + // Execute the command handler + await openRemoteCommand.args[1](mockSpaceNode) + + assert.ok( + openRemoteConnectStub.calledWith( + mockSpaceNode.resource, + mockExtensionContext, + mockSpaceNode.resource.sageMakerClient + ) + ) + }) + + it('should handle aws.smus.openRemoteConnection command with invalid node', async function () { + const openRemoteCommand = registerCommandStub + .getCalls() + .find((call) => call.args[0] === 'aws.smus.openRemoteConnection') + + assert.ok(openRemoteCommand) + + const testWindow = getTestWindow() + + // Execute the command handler with undefined node + await openRemoteCommand.args[1](undefined) + + // Check that a warning message was shown + const warningMessages = testWindow.shownMessages.filter((msg) => msg.severity === SeverityLevel.Warning) + assert.ok(warningMessages.length > 0, 'Should show warning message') + assert.ok(warningMessages.some((msg) => msg.message.includes('Space information is being refreshed'))) + }) + }) + + it('should propagate auth provider initialization errors', async function () { + const error = new Error('Auth provider initialization failed') + mockSmusAuthProvider.restore.rejects(error) + + // Should throw the error since there's no error handling in activate() + await assert.rejects(() => activate(mockExtensionContext), /Auth provider initialization failed/) + }) + + it('should create root node with auth provider', async function () { + await activate(mockExtensionContext) + + // Verify that SageMakerUnifiedStudioRootNode was created with the auth provider + assert.ok(createTreeViewStub.called) + const treeDataProvider = createTreeViewStub.firstCall.args[1].treeDataProvider + assert.ok(treeDataProvider) + }) + + // TODO: Fix the activation test + it.skip('should setup user activity monitoring', async function () { + await activate(mockExtensionContext) + + assert.ok(setupUserActivityMonitoringStub.called) + }) + }) + + describe('command registration', function () { + it('should register commands with correct names', async function () { + await activate(mockExtensionContext) + + const expectedCommands = [ + 'aws.smus.rootView.refresh', + 'aws.smus.projectView', + 'aws.smus.refreshProject', + 'aws.smus.switchProject', + 'aws.smus.stopSpace', + 'aws.smus.openRemoteConnection', + 'aws.smus.reauthenticate', + ] + + const registeredCommands = registerCommandStub.getCalls().map((call) => call.args[0]) + + for (const command of expectedCommands) { + assert.ok(registeredCommands.includes(command), `Command ${command} should be registered`) + } + }) + + it('should register commands that return disposables', async function () { + await activate(mockExtensionContext) + + for (const call of registerCommandStub.getCalls()) { + const disposable = call.returnValue + assert.ok(disposable && typeof disposable.dispose === 'function') + } + }) + }) + + describe('resource cleanup', function () { + it('should dispose DataZone client on extension deactivation', async function () { + await activate(mockExtensionContext) + + // Find and execute the DataZone dispose subscription + const disposeSubscription = mockExtensionContext.subscriptions.find( + (sub) => sub.dispose && sub.dispose.toString().includes('DataZoneClient.dispose') + ) + + if (disposeSubscription) { + disposeSubscription.dispose() + assert.ok(dataZoneDisposeStub.called) + } + }) + + it('should add tree view to subscriptions for disposal', async function () { + await activate(mockExtensionContext) + + assert.ok(mockExtensionContext.subscriptions.includes(mockTreeView)) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/lakehouseStrategy.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/lakehouseStrategy.test.ts new file mode 100644 index 00000000000..6039e5e4e02 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/lakehouseStrategy.test.ts @@ -0,0 +1,462 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { + LakehouseNode, + createLakehouseConnectionNode, +} from '../../../../sagemakerunifiedstudio/explorer/nodes/lakehouseStrategy' +import { GlueCatalogClient } from '../../../../sagemakerunifiedstudio/shared/client/glueCatalogClient' +import { GlueClient } from '../../../../sagemakerunifiedstudio/shared/client/glueClient' +import { ConnectionClientStore } from '../../../../sagemakerunifiedstudio/shared/client/connectionClientStore' +import { NodeType } from '../../../../sagemakerunifiedstudio/explorer/nodes/types' +import { ConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' + +describe('LakehouseStrategy', function () { + let sandbox: sinon.SinonSandbox + let mockGlueCatalogClient: sinon.SinonStubbedInstance + let mockGlueClient: sinon.SinonStubbedInstance + + const mockConnection = { + connectionId: 'lakehouse-conn-123', + name: 'test-lakehouse-connection', + type: 'ATHENA', + domainId: 'domain-123', + projectId: 'project-123', + } + + const mockCredentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + + mockGlueCatalogClient = { + getCatalogs: sandbox.stub(), + } as any + + mockGlueClient = { + getDatabases: sandbox.stub(), + getTables: sandbox.stub(), + getTable: sandbox.stub(), + } as any + + sandbox.stub(GlueCatalogClient, 'createWithCredentials').returns(mockGlueCatalogClient as any) + sandbox.stub(GlueClient.prototype, 'getDatabases').callsFake(mockGlueClient.getDatabases) + sandbox.stub(GlueClient.prototype, 'getTables').callsFake(mockGlueClient.getTables) + sandbox.stub(GlueClient.prototype, 'getTable').callsFake(mockGlueClient.getTable) + + const mockClientStore = { + getGlueClient: sandbox.stub().returns(mockGlueClient), + getGlueCatalogClient: sandbox.stub().returns(mockGlueCatalogClient), + } + sandbox.stub(ConnectionClientStore, 'getInstance').returns(mockClientStore as any) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('LakehouseNode', function () { + it('should initialize with correct properties', function () { + const nodeData = { + id: 'test-node', + nodeType: NodeType.CONNECTION, + value: { test: 'value' }, + } + + const node = new LakehouseNode(nodeData) + + assert.strictEqual(node.id, 'test-node') + assert.deepStrictEqual(node.resource, { test: 'value' }) + }) + + it('should return empty array for leaf nodes', async function () { + const nodeData = { + id: 'leaf-node', + nodeType: NodeType.REDSHIFT_COLUMN, + value: {}, + } + + const node = new LakehouseNode(nodeData) + const children = await node.getChildren() + + assert.strictEqual(children.length, 0) + }) + + it('should return error node when children provider fails', async function () { + const nodeData = { + id: 'error-node', + nodeType: NodeType.CONNECTION, + value: {}, + } + + const failingProvider = async () => { + throw new Error('Provider failed') + } + + const node = new LakehouseNode(nodeData, failingProvider) + const children = await node.getChildren() + + assert.strictEqual(children.length, 1) + assert.ok(children[0].id.startsWith('error-node-error-getChildren-')) + }) + + it('should create correct tree item for column node', async function () { + const nodeData = { + id: 'column-node', + nodeType: NodeType.REDSHIFT_COLUMN, + value: { name: 'test_column', type: 'varchar' }, + } + + const node = new LakehouseNode(nodeData) + const treeItem = await node.getTreeItem() + + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + assert.strictEqual(treeItem.description, 'varchar') + }) + + it('should cache children after first load', async function () { + const provider = sandbox + .stub() + .resolves([new LakehouseNode({ id: 'child', nodeType: NodeType.GLUE_DATABASE })]) + const node = new LakehouseNode({ id: 'parent', nodeType: NodeType.CONNECTION }, provider) + + await node.getChildren() + await node.getChildren() + + assert.ok(provider.calledOnce) + }) + }) + + describe('createLakehouseConnectionNode', function () { + it('should create connection node with correct structure', function () { + const node = createLakehouseConnectionNode( + mockConnection as any, + mockCredentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + + assert.strictEqual(node.id, 'lakehouse-conn-123') + assert.strictEqual(node.data.nodeType, NodeType.CONNECTION) + assert.strictEqual(node.data.path?.connection, 'test-lakehouse-connection') + }) + + it('should create AWS Data Catalog node for default connections', async function () { + const defaultConnection = { + ...mockConnection, + name: 'project.default_lakehouse', + } + + mockGlueCatalogClient.getCatalogs.resolves({ catalogs: [], nextToken: undefined }) + mockGlueClient.getDatabases.resolves({ + databases: [{ Name: 'default-db' }], + nextToken: undefined, + }) + + const node = createLakehouseConnectionNode( + defaultConnection as any, + mockCredentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + const children = await node.getChildren() + + const awsDataCatalogNode = children.find((child) => child.id.includes('AwsDataCatalog')) as LakehouseNode + assert.ok(awsDataCatalogNode) + assert.strictEqual(awsDataCatalogNode.data.nodeType, NodeType.GLUE_CATALOG) + }) + + it('should not create AWS Data Catalog node for non-default connections', async function () { + mockGlueCatalogClient.getCatalogs.resolves({ catalogs: [], nextToken: undefined }) + + const node = createLakehouseConnectionNode( + mockConnection as any, + mockCredentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + const children = await node.getChildren() + + const awsDataCatalogNode = children.find((child) => child.id.includes('AwsDataCatalog')) + assert.strictEqual(awsDataCatalogNode, undefined) + }) + + it('should handle errors gracefully', async function () { + mockGlueCatalogClient.getCatalogs.rejects(new Error('Catalog error')) + mockGlueClient.getDatabases.rejects(new Error('Database error')) + + const node = createLakehouseConnectionNode( + mockConnection as any, + mockCredentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + const children = await node.getChildren() + + assert.ok(children.length > 0) + assert.ok(children.some((child) => child.id.startsWith('lakehouse-conn-123-error-'))) + }) + + it('should create placeholder when no catalogs found', async function () { + mockGlueCatalogClient.getCatalogs.resolves({ catalogs: [], nextToken: undefined }) + + const node = createLakehouseConnectionNode( + mockConnection as any, + mockCredentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + const children = await node.getChildren() + + assert.ok(children.some((child) => child.resource === '[No data found]')) + }) + }) + + describe('Catalog nodes', function () { + it('should create catalog nodes from API', async function () { + mockGlueCatalogClient.getCatalogs.resolves({ + catalogs: [{ CatalogId: 'test-catalog', CatalogType: 'HIVE' }], + }) + mockGlueClient.getDatabases.resolves({ + databases: [{ Name: 'test-db' }], + nextToken: undefined, + }) + + const node = createLakehouseConnectionNode( + mockConnection as any, + mockCredentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + const children = await node.getChildren() + + assert.ok(children.length > 0) + assert.ok(mockGlueCatalogClient.getCatalogs.called) + }) + + it('should handle catalog database pagination', async function () { + const catalogNode = new LakehouseNode( + { + id: 'catalog-node', + nodeType: NodeType.GLUE_CATALOG, + path: { catalog: 'test-catalog' }, + }, + async () => { + const allDatabases = [] + let nextToken: string | undefined + do { + const { databases, nextToken: token } = await mockGlueClient.getDatabases( + 'test-catalog', + undefined, + undefined, + nextToken + ) + allDatabases.push(...databases) + nextToken = token + } while (nextToken) + return allDatabases.map( + (db) => new LakehouseNode({ id: db.Name || '', nodeType: NodeType.GLUE_DATABASE }) + ) + } + ) + + mockGlueClient.getDatabases + .onFirstCall() + .resolves({ databases: [{ Name: 'db1' }], nextToken: 'token1' }) + .onSecondCall() + .resolves({ databases: [{ Name: 'db2' }], nextToken: undefined }) + + const children = await catalogNode.getChildren() + + assert.strictEqual(children.length, 2) + assert.ok(mockGlueClient.getDatabases.calledTwice) + }) + }) + + describe('Database nodes', function () { + it('should handle table pagination', async function () { + const databaseNode = new LakehouseNode( + { + id: 'database-node', + nodeType: NodeType.GLUE_DATABASE, + path: { catalog: 'test-catalog', database: 'test-db' }, + }, + async () => { + const allTables = [] + let nextToken: string | undefined + do { + const { tables, nextToken: token } = await mockGlueClient.getTables( + 'test-db', + 'test-catalog', + undefined, + nextToken + ) + allTables.push(...tables) + nextToken = token + } while (nextToken) + return allTables.map( + (table) => new LakehouseNode({ id: table.Name || '', nodeType: NodeType.GLUE_TABLE }) + ) + } + ) + + mockGlueClient.getTables + .onFirstCall() + .resolves({ tables: [{ Name: 'table1' }], nextToken: 'token1' }) + .onSecondCall() + .resolves({ tables: [{ Name: 'table2' }], nextToken: undefined }) + + const children = await databaseNode.getChildren() + + assert.strictEqual(children.length, 2) + assert.ok(mockGlueClient.getTables.calledTwice) + }) + + it('should handle AWS Data Catalog database queries', async function () { + const databaseNode = new LakehouseNode( + { + id: 'database-node', + nodeType: NodeType.GLUE_DATABASE, + path: { catalog: 'aws-data-catalog', database: 'test-db' }, + }, + async () => { + const catalogId = undefined + const { tables } = await mockGlueClient.getTables('test-db', catalogId) + return tables.map( + (table) => new LakehouseNode({ id: table.Name || '', nodeType: NodeType.GLUE_TABLE }) + ) + } + ) + + mockGlueClient.getTables.resolves({ tables: [{ Name: 'aws-table' }], nextToken: undefined }) + + const children = await databaseNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.ok(mockGlueClient.getTables.calledWith('test-db', undefined)) + }) + }) + + describe('Table nodes', function () { + it('should create table node and load columns', async function () { + const tableNode = new LakehouseNode( + { + id: 'table-node', + nodeType: NodeType.GLUE_TABLE, + path: { database: 'test-db', table: 'test-table' }, + }, + async () => { + const tableDetails = await mockGlueClient.getTable('test-db', 'test-table') + const columns = tableDetails?.StorageDescriptor?.Columns || [] + const partitions = tableDetails?.PartitionKeys || [] + return [...columns, ...partitions].map( + (col) => + new LakehouseNode({ + id: `column-${col.Name}`, + nodeType: NodeType.REDSHIFT_COLUMN, + value: { name: col.Name, type: col.Type }, + }) + ) + } + ) + + mockGlueClient.getTable.resolves({ + StorageDescriptor: { + Columns: [{ Name: 'col1', Type: 'string' }], + }, + PartitionKeys: [{ Name: 'partition_col', Type: 'date' }], + Name: undefined, + }) + + const children = await tableNode.getChildren() + + assert.strictEqual(children.length, 2) + assert.ok(mockGlueClient.getTable.calledWith('test-db', 'test-table')) + }) + + it('should handle table with no columns', async function () { + const tableNode = new LakehouseNode( + { + id: 'empty-table-node', + nodeType: NodeType.GLUE_TABLE, + path: { database: 'test-db', table: 'empty-table' }, + }, + async () => { + const tableDetails = await mockGlueClient.getTable('test-db', 'empty-table') + const columns = tableDetails?.StorageDescriptor?.Columns || [] + const partitions = tableDetails?.PartitionKeys || [] + return [...columns, ...partitions].map( + (col) => + new LakehouseNode({ + id: `column-${col.Name}`, + nodeType: NodeType.REDSHIFT_COLUMN, + value: { name: col.Name, type: col.Type }, + }) + ) + } + ) + + mockGlueClient.getTable.resolves({ + StorageDescriptor: { Columns: [] }, + PartitionKeys: [], + Name: undefined, + }) + + const children = await tableNode.getChildren() + + assert.strictEqual(children.length, 0) + }) + + it('should handle table getTable errors gracefully', async function () { + const tableNode = new LakehouseNode( + { + id: 'error-table-node', + nodeType: NodeType.GLUE_TABLE, + path: { database: 'test-db', table: 'error-table' }, + }, + async () => { + try { + await mockGlueClient.getTable('test-db', 'error-table') + return [] + } catch (err) { + return [] + } + } + ) + + mockGlueClient.getTable.rejects(new Error('Table not found')) + + const children = await tableNode.getChildren() + + assert.strictEqual(children.length, 0) + }) + }) + + describe('Column nodes', function () { + it('should create column node with correct properties', function () { + const parentNode = new LakehouseNode({ + id: 'parent-table', + nodeType: NodeType.GLUE_TABLE, + path: { database: 'test-db', table: 'test-table' }, + }) + + const columnNode = new LakehouseNode({ + id: 'parent-table/test-column', + nodeType: NodeType.REDSHIFT_COLUMN, + value: { name: 'test-column', type: 'varchar' }, + path: { database: 'test-db', table: 'test-table', column: 'test-column' }, + parent: parentNode, + }) + + assert.strictEqual(columnNode.id, 'parent-table/test-column') + assert.strictEqual(columnNode.resource.name, 'test-column') + assert.strictEqual(columnNode.resource.type, 'varchar') + assert.strictEqual(columnNode.getParent(), parentNode) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/redshiftStrategy.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/redshiftStrategy.test.ts new file mode 100644 index 00000000000..31ec0e8bb24 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/redshiftStrategy.test.ts @@ -0,0 +1,358 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { + RedshiftNode, + createRedshiftConnectionNode, +} from '../../../../sagemakerunifiedstudio/explorer/nodes/redshiftStrategy' +import { SQLWorkbenchClient } from '../../../../sagemakerunifiedstudio/shared/client/sqlWorkbenchClient' +import * as sqlWorkbenchClient from '../../../../sagemakerunifiedstudio/shared/client/sqlWorkbenchClient' +import { ConnectionClientStore } from '../../../../sagemakerunifiedstudio/shared/client/connectionClientStore' +import { NodeType } from '../../../../sagemakerunifiedstudio/explorer/nodes/types' +import { ConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' + +describe('redshiftStrategy', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('RedshiftNode', function () { + describe('constructor', function () { + it('should create node with correct properties', function () { + const nodeData = { + id: 'test-id', + nodeType: NodeType.REDSHIFT_CLUSTER, + value: { clusterName: 'test-cluster' }, + } + + const node = new RedshiftNode(nodeData) + + assert.strictEqual(node.id, 'test-id') + assert.strictEqual(node.data.nodeType, NodeType.REDSHIFT_CLUSTER) + assert.deepStrictEqual(node.resource, { clusterName: 'test-cluster' }) + }) + }) + + describe('getChildren', function () { + it('should return cached children if available', async function () { + const nodeData = { + id: 'test-id', + nodeType: NodeType.REDSHIFT_CLUSTER, + } + + const node = new RedshiftNode(nodeData) + // Simulate cached children + ;(node as any).childrenNodes = [{ id: 'cached-child' }] + + const children = await node.getChildren() + assert.strictEqual(children.length, 1) + assert.strictEqual((children[0] as any).id, 'cached-child') + }) + + it('should return empty array for leaf nodes', async function () { + const nodeData = { + id: 'test-id', + nodeType: NodeType.REDSHIFT_COLUMN, + } + + const node = new RedshiftNode(nodeData) + const children = await node.getChildren() + assert.strictEqual(children.length, 0) + }) + }) + + describe('getTreeItem', function () { + it('should return correct tree item for regular nodes', async function () { + const nodeData = { + id: 'test-cluster', + nodeType: NodeType.REDSHIFT_CLUSTER, + value: { clusterName: 'test-cluster' }, + } + + const node = new RedshiftNode(nodeData) + const treeItem = await node.getTreeItem() + + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed) + assert.strictEqual(treeItem.contextValue, NodeType.REDSHIFT_CLUSTER) + }) + + it('should return column tree item for column nodes', async function () { + const nodeData = { + id: 'test-column', + nodeType: NodeType.REDSHIFT_COLUMN, + value: { type: 'VARCHAR(255)' }, + } + + const node = new RedshiftNode(nodeData) + const treeItem = await node.getTreeItem() + + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + assert.strictEqual(treeItem.description, 'VARCHAR(255)') + }) + + it('should return leaf tree item for leaf nodes', async function () { + const nodeData = { + id: 'test-column', + nodeType: NodeType.REDSHIFT_COLUMN, + } + + const node = new RedshiftNode(nodeData) + const treeItem = await node.getTreeItem() + + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + }) + }) + + describe('getParent', function () { + it('should return parent node', function () { + const parentData = { id: 'parent', nodeType: NodeType.REDSHIFT_CLUSTER } + const parent = new RedshiftNode(parentData) + + const nodeData = { + id: 'child', + nodeType: NodeType.REDSHIFT_DATABASE, + parent: parent, + } + + const node = new RedshiftNode(nodeData) + assert.strictEqual(node.getParent(), parent) + }) + }) + }) + + describe('createRedshiftConnectionNode', function () { + let mockSQLClient: sinon.SinonStubbedInstance + + beforeEach(function () { + mockSQLClient = { + executeQuery: sandbox.stub(), + getResources: sandbox.stub(), + } as any + + sandbox.stub(SQLWorkbenchClient, 'createWithCredentials').returns(mockSQLClient as any) + sandbox.stub(sqlWorkbenchClient, 'createRedshiftConnectionConfig').resolves({ + id: 'test-connection-id', + type: '4', + databaseType: 'REDSHIFT', + connectableResourceIdentifier: 'test-cluster', + connectableResourceType: 'CLUSTER', + database: 'test-db', + }) + + const mockClientStore = { + getSQLWorkbenchClient: sandbox.stub().returns(mockSQLClient), + } + sandbox.stub(ConnectionClientStore, 'getInstance').returns(mockClientStore as any) + }) + + it.skip('should create Redshift connection node with JDBC URL', async function () { + const connection = { + connectionId: 'conn-123', + name: 'Test Redshift Connection', + type: 'RedshiftConnection', + props: { + jdbcConnection: { + jdbcUrl: 'jdbc:redshift://test-cluster.123456789012.us-east-1.redshift.amazonaws.com:5439/dev', + dbname: 'test-db', + }, + redshiftProperties: {}, + }, + location: { + awsAccountId: '', + awsRegion: 'us-east-1', + }, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + mockSQLClient.executeQuery.resolves('query-id') + mockSQLClient.getResources.resolves({ + resources: [ + { + displayName: 'test-db', + type: 'DATABASE', + identifier: '', + childObjectTypes: [], + }, + ], + }) + + const node = createRedshiftConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider + ) + + assert.strictEqual(node.data.nodeType, NodeType.CONNECTION) + assert.strictEqual(node.data.value.connection.name, 'Test Redshift Connection') + + // Test children provider - now creates database nodes directly + const children = await node.getChildren() + assert.strictEqual(children.length, 1) + assert.strictEqual((children[0] as RedshiftNode).data.nodeType, NodeType.REDSHIFT_DATABASE) + }) + + it.skip('should create connection node with host from jdbcConnection', async function () { + const connection = { + connectionId: 'conn-123', + name: 'Test Connection', + type: 'RedshiftConnection', + props: { + jdbcConnection: { + host: 'test-host.redshift.amazonaws.com', + dbname: 'test-db', + }, + redshiftProperties: {}, + }, + location: { + awsAccountId: '', + awsRegion: 'us-east-1', + }, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + mockSQLClient.executeQuery.resolves('query-id') + mockSQLClient.getResources.resolves({ resources: [] }) + + const node = createRedshiftConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider + ) + const children = await node.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual((children[0] as RedshiftNode).data.nodeType, NodeType.REDSHIFT_DATABASE) + }) + + it('should return placeholder when connection params are missing', async function () { + const connection = { + connectionId: 'conn-123', + name: 'Test Connection', + type: 'RedshiftConnection', + props: { + jdbcConnection: {}, + redshiftProperties: {}, + }, + location: {}, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + const node = createRedshiftConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider + ) + const children = await node.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].resource, '[No data found]') + }) + + it.skip('should handle workgroup name in host', async function () { + const connection = { + connectionId: 'conn-123', + name: 'Test Connection', + type: 'RedshiftConnection', + props: { + jdbcConnection: { + host: 'test-host.redshift-serverless.amazonaws.com', + dbname: 'test-db', + }, + redshiftProperties: { + storage: { + workgroupName: 'test-workgroup', + }, + }, + }, + location: { + awsAccountId: '', + awsRegion: 'us-east-1', + }, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + mockSQLClient.executeQuery.resolves('query-id') + mockSQLClient.getResources.resolves({ resources: [] }) + + const node = createRedshiftConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider + ) + const children = await node.getChildren() + + assert.strictEqual(children.length, 1) + }) + + it.skip('should handle connection errors gracefully', async function () { + const connection = { + connectionId: 'conn-123', + name: 'Test Connection', + type: 'RedshiftConnection', + props: { + jdbcConnection: { + host: 'test-host.redshift.amazonaws.com', + dbname: 'test-db', + }, + }, + location: { + awsAccountId: '', + awsRegion: 'us-east-1', + }, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + // Make createRedshiftConnectionConfig throw an error + ;(sqlWorkbenchClient.createRedshiftConnectionConfig as sinon.SinonStub).rejects( + new Error('Connection config failed') + ) + + const node = createRedshiftConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider + ) + + // The error should be handled gracefully and return an error node + const children = await node.getChildren() + assert.strictEqual(children.length, 1) + assert.strictEqual((children[0] as any).id.includes('error'), true) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/s3Strategy.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/s3Strategy.test.ts new file mode 100644 index 00000000000..9b193ea0106 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/s3Strategy.test.ts @@ -0,0 +1,287 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { S3Node, createS3ConnectionNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/s3Strategy' +import { S3Client } from '../../../../sagemakerunifiedstudio/shared/client/s3Client' +import { ConnectionClientStore } from '../../../../sagemakerunifiedstudio/shared/client/connectionClientStore' +import { NodeType, ConnectionType } from '../../../../sagemakerunifiedstudio/explorer/nodes/types' +import { ConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' + +describe('s3Strategy', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('S3Node', function () { + describe('constructor', function () { + it('should create node with correct properties', function () { + const node = new S3Node({ + id: 'test-id', + nodeType: NodeType.S3_BUCKET, + connectionType: ConnectionType.S3, + value: { bucket: 'test-bucket' }, + path: { bucket: 'test-bucket' }, + }) + + assert.strictEqual(node.id, 'test-id') + assert.strictEqual(node.data.nodeType, NodeType.S3_BUCKET) + assert.strictEqual(node.data.connectionType, ConnectionType.S3) + }) + }) + + describe('getChildren', function () { + it('should return empty array for leaf nodes', async function () { + const node = new S3Node({ + id: 'test-id', + nodeType: NodeType.S3_FILE, + connectionType: ConnectionType.S3, + }) + + const children = await node.getChildren() + assert.strictEqual(children.length, 0) + }) + + it('should handle children provider errors', async function () { + const errorProvider = async () => { + throw new Error('Provider error') + } + + const node = new S3Node( + { + id: 'test-id', + nodeType: NodeType.S3_BUCKET, + connectionType: ConnectionType.S3, + }, + errorProvider + ) + + const children = await node.getChildren() + assert.strictEqual(children.length, 1) + assert.ok(children[0].id.startsWith('test-id-error-getChildren-')) + }) + }) + + describe('getTreeItem', function () { + it('should return correct tree item for non-leaf node', async function () { + const node = new S3Node({ + id: 'test-id', + nodeType: NodeType.S3_BUCKET, + connectionType: ConnectionType.S3, + }) + + const treeItem = await node.getTreeItem() + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed) + assert.strictEqual(treeItem.contextValue, NodeType.S3_BUCKET) + }) + + it('should return correct tree item for leaf node', async function () { + const node = new S3Node({ + id: 'test-id', + nodeType: NodeType.S3_FILE, + connectionType: ConnectionType.S3, + }) + + const treeItem = await node.getTreeItem() + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + }) + }) + }) + + describe('createS3ConnectionNode', function () { + let mockS3Client: sinon.SinonStubbedInstance + + beforeEach(function () { + mockS3Client = { + listPaths: sandbox.stub(), + } as any + + sandbox.stub(S3Client.prototype, 'constructor' as any) + sandbox.stub(S3Client.prototype, 'listPaths').callsFake(mockS3Client.listPaths) + + const mockClientStore = { + getS3Client: sandbox.stub().returns(mockS3Client), + } + sandbox.stub(ConnectionClientStore, 'getInstance').returns(mockClientStore as any) + }) + + it('should create S3 connection node successfully for non-default connection', function () { + const connection = { + connectionId: 'conn-123', + name: 'Test S3 Connection', + type: 'S3Connection', + props: { + s3Properties: { + s3Uri: 's3://test-bucket/prefix/', + }, + }, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + const node = createS3ConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + + assert.strictEqual(node.data.nodeType, NodeType.CONNECTION) + assert.strictEqual(node.data.connectionType, ConnectionType.S3) + }) + + it('should create S3 connection node for default connection with full path', function () { + const connection = { + connectionId: 'conn-123', + name: 'project.s3_default_folder', + type: 'S3Connection', + props: { + s3Properties: { + s3Uri: 's3://test-bucket/domain/project/', + }, + }, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + const node = createS3ConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + + assert.strictEqual(node.data.nodeType, NodeType.CONNECTION) + assert.strictEqual(node.data.connectionType, ConnectionType.S3) + }) + + it('should return error node when no S3 URI found', function () { + const connection = { + connectionId: 'conn-123', + name: 'Test S3 Connection', + type: 'S3Connection', + props: {}, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + const node = createS3ConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + + assert.ok(node.id.startsWith('conn-123-error-connection-')) + }) + + it('should handle bucket listing for non-default connection', async function () { + const connection = { + connectionId: 'conn-123', + name: 'Test S3 Connection', + type: 'S3Connection', + props: { + s3Properties: { + s3Uri: 's3://test-bucket/', + }, + }, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + mockS3Client.listPaths.resolves({ + paths: [ + { + bucket: 'test-bucket', + prefix: 'file.txt', + displayName: 'file.txt', + isFolder: false, + }, + ], + nextToken: undefined, + }) + + const node = createS3ConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + const children = await node.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual((children[0] as S3Node).data.nodeType, NodeType.S3_BUCKET) + }) + + it('should handle bucket listing for default connection with full path display', async function () { + const connection = { + connectionId: 'conn-123', + name: 'project.s3_default_folder', + type: 'S3Connection', + props: { + s3Properties: { + s3Uri: 's3://test-bucket/domain/project/', + }, + }, + } + + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + + mockS3Client.listPaths.resolves({ + paths: [ + { + bucket: 'test-bucket', + prefix: 'domain/project/dev/', + displayName: 'dev', + isFolder: true, + }, + ], + nextToken: undefined, + }) + + const node = createS3ConnectionNode( + connection as any, + credentialsProvider as ConnectionCredentialsProvider, + 'us-east-1' + ) + const children = await node.getChildren() + + assert.strictEqual(children.length, 1) + const bucketNode = children[0] as S3Node + assert.strictEqual(bucketNode.data.nodeType, NodeType.S3_BUCKET) + // For default connection, should show full path + assert.strictEqual(bucketNode.data.path?.label, 'test-bucket/domain/project/') + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioAuthInfoNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioAuthInfoNode.test.ts new file mode 100644 index 00000000000..ebf2eae2cb0 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioAuthInfoNode.test.ts @@ -0,0 +1,291 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioAuthInfoNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioAuthInfoNode' +import { SmusAuthenticationProvider } from '../../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' +import { SmusConnection } from '../../../../sagemakerunifiedstudio/auth/model' + +describe('SageMakerUnifiedStudioAuthInfoNode', function () { + let authInfoNode: SageMakerUnifiedStudioAuthInfoNode + let mockAuthProvider: any + let mockConnection: SmusConnection + let currentActiveConnection: SmusConnection | undefined + + beforeEach(function () { + mockConnection = { + id: 'test-connection-id', + type: 'sso', + startUrl: 'https://identitycenter.amazonaws.com/ssoins-testInstanceId', + ssoRegion: 'us-east-2', + scopes: ['datazone:domain:access'], + label: 'Test SMUS Connection', + domainUrl: 'https://dzd_domainId.sagemaker.us-east-2.on.aws', + domainId: 'dzd_domainId', + // Mock the required methods from SsoConnection + getToken: sinon.stub().resolves(), + getRegistration: sinon.stub().resolves(), + } as any + + // Initialize the current active connection + currentActiveConnection = mockConnection + + // Create mock auth provider with getter for activeConnection + mockAuthProvider = { + isConnected: sinon.stub().returns(true), + isConnectionValid: sinon.stub().returns(true), + onDidChange: sinon.stub().callsFake((listener: () => void) => ({ dispose: sinon.stub() })), + get activeConnection() { + return currentActiveConnection + }, + set activeConnection(value: SmusConnection | undefined) { + currentActiveConnection = value + }, + } + + // Stub SmusAuthenticationProvider.fromContext + sinon.stub(SmusAuthenticationProvider, 'fromContext').returns(mockAuthProvider as any) + + authInfoNode = new SageMakerUnifiedStudioAuthInfoNode() + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('should initialize with correct properties', function () { + assert.strictEqual(authInfoNode.id, 'smusAuthInfoNode') + assert.strictEqual(authInfoNode.resource, authInfoNode) + }) + + it('should register for auth provider changes', function () { + assert.ok(mockAuthProvider.onDidChange.called) + }) + + it('should have onDidChangeTreeItem event', function () { + assert.ok(typeof authInfoNode.onDidChangeTreeItem === 'function') + }) + }) + + describe('getTreeItem', function () { + describe('when connected and valid', function () { + beforeEach(function () { + mockAuthProvider.isConnected.returns(true) + mockAuthProvider.isConnectionValid.returns(true) + mockAuthProvider.activeConnection = mockConnection + }) + + it('should return connected tree item', function () { + const treeItem = authInfoNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Domain: dzd_domainId') + assert.strictEqual(treeItem.description, 'us-east-2') + assert.strictEqual(treeItem.contextValue, 'smusAuthInfo') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + + // Check icon + assert.ok(treeItem.iconPath instanceof vscode.ThemeIcon) + assert.strictEqual((treeItem.iconPath as vscode.ThemeIcon).id, 'key') + + // Check tooltip + const tooltip = treeItem.tooltip as string + assert.ok(tooltip?.includes('Connected to SageMaker Unified Studio')) + assert.ok(tooltip?.includes('dzd_domainId')) + assert.ok(tooltip?.includes('us-east-2')) + assert.ok(tooltip?.includes('Status: Connected')) + + // Should not have command when valid + assert.strictEqual(treeItem.command, undefined) + }) + }) + + describe('when connected but expired', function () { + beforeEach(function () { + mockAuthProvider.isConnected.returns(true) + mockAuthProvider.isConnectionValid.returns(false) + mockAuthProvider.activeConnection = mockConnection + }) + + it('should return expired tree item with reauthenticate command', function () { + const treeItem = authInfoNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Domain: dzd_domainId (Expired) - Click to reauthenticate') + assert.strictEqual(treeItem.description, 'us-east-2') + assert.strictEqual(treeItem.contextValue, 'smusAuthInfo') + + // Check icon + assert.ok(treeItem.iconPath instanceof vscode.ThemeIcon) + assert.strictEqual((treeItem.iconPath as vscode.ThemeIcon).id, 'warning') + + // Check tooltip + const tooltip = treeItem.tooltip as string + assert.ok(tooltip?.includes('Connection to SageMaker Unified Studio has expired')) + assert.ok(tooltip?.includes('Status: Expired - Click to reauthenticate')) + + // Should have reauthenticate command + assert.ok(treeItem.command) + assert.strictEqual(treeItem.command.command, 'aws.smus.reauthenticate') + assert.strictEqual(treeItem.command.title, 'Reauthenticate') + assert.deepStrictEqual(treeItem.command.arguments, [mockConnection]) + }) + }) + + describe('when not connected', function () { + beforeEach(function () { + mockAuthProvider.isConnected.returns(false) + mockAuthProvider.isConnectionValid.returns(false) + mockAuthProvider.activeConnection = undefined + }) + + it('should return not connected tree item', function () { + const treeItem = authInfoNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Not Connected') + assert.strictEqual(treeItem.description, undefined) + assert.strictEqual(treeItem.contextValue, 'smusAuthInfo') + + // Check icon + assert.ok(treeItem.iconPath instanceof vscode.ThemeIcon) + assert.strictEqual((treeItem.iconPath as vscode.ThemeIcon).id, 'circle-slash') + + // Check tooltip + const tooltip = treeItem.tooltip as string + assert.ok(tooltip?.includes('Not connected to SageMaker Unified Studio')) + assert.ok(tooltip?.includes('Please sign in to access your projects')) + + // Should not have command when not connected + assert.strictEqual(treeItem.command, undefined) + }) + }) + + describe('with missing connection details', function () { + beforeEach(function () { + const incompleteConnection = { + ...mockConnection, + domainId: undefined, + ssoRegion: undefined, + } as any + + mockAuthProvider.isConnected.returns(true) + mockAuthProvider.isConnectionValid.returns(true) + mockAuthProvider.activeConnection = incompleteConnection + }) + + it('should handle missing domain ID and region gracefully', function () { + const treeItem = authInfoNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Domain: Unknown') + assert.strictEqual(treeItem.description, 'Unknown') + + const tooltip = treeItem.tooltip as string + assert.ok(tooltip?.includes('Domain ID: Unknown')) + assert.ok(tooltip?.includes('Region: Unknown')) + }) + }) + }) + + describe('getParent', function () { + it('should return undefined', function () { + assert.strictEqual(authInfoNode.getParent(), undefined) + }) + }) + + describe('event handling', function () { + it('should fire onDidChangeTreeItem when auth provider changes', function () { + const eventSpy = sinon.spy() + authInfoNode.onDidChangeTreeItem(eventSpy) + + // Simulate auth provider change + const onDidChangeCallback = mockAuthProvider.onDidChange.firstCall.args[0] + onDidChangeCallback() + + assert.ok(eventSpy.called) + }) + + it('should dispose event listener properly', function () { + const disposeSpy = sinon.spy() + mockAuthProvider.onDidChange.returns({ dispose: disposeSpy }) + + // Create new node to trigger event listener registration + new SageMakerUnifiedStudioAuthInfoNode() + + // The dispose should be available for cleanup + assert.ok(mockAuthProvider.onDidChange.called) + }) + }) + + describe('theme icon colors', function () { + it('should use green color for connected state', function () { + mockAuthProvider.isConnected.returns(true) + mockAuthProvider.isConnectionValid.returns(true) + + const treeItem = authInfoNode.getTreeItem() + const icon = treeItem.iconPath as vscode.ThemeIcon + + assert.ok(icon.color instanceof vscode.ThemeColor) + assert.strictEqual((icon.color as any).id, 'charts.green') + }) + + it('should use yellow color for expired state', function () { + mockAuthProvider.isConnected.returns(true) + mockAuthProvider.isConnectionValid.returns(false) + + const treeItem = authInfoNode.getTreeItem() + const icon = treeItem.iconPath as vscode.ThemeIcon + + assert.ok(icon.color instanceof vscode.ThemeColor) + assert.strictEqual((icon.color as any).id, 'charts.yellow') + }) + + it('should use red color for not connected state', function () { + mockAuthProvider.isConnected.returns(false) + + const treeItem = authInfoNode.getTreeItem() + const icon = treeItem.iconPath as vscode.ThemeIcon + + assert.ok(icon.color instanceof vscode.ThemeColor) + assert.strictEqual((icon.color as any).id, 'charts.red') + }) + }) + + describe('tooltip content', function () { + it('should include all relevant information for connected state', function () { + mockAuthProvider.isConnected.returns(true) + mockAuthProvider.isConnectionValid.returns(true) + + const treeItem = authInfoNode.getTreeItem() + const tooltip = treeItem.tooltip as string + + assert.ok(tooltip.includes('Connected to SageMaker Unified Studio')) + assert.ok(tooltip.includes(`Domain ID: ${mockConnection.domainId}`)) + assert.ok(tooltip.includes(`Region: ${mockConnection.ssoRegion}`)) + assert.ok(tooltip.includes('Status: Connected')) + }) + + it('should include expiration information for expired state', function () { + mockAuthProvider.isConnected.returns(true) + mockAuthProvider.isConnectionValid.returns(false) + + const treeItem = authInfoNode.getTreeItem() + const tooltip = treeItem.tooltip as string + + assert.ok(tooltip.includes('Connection to SageMaker Unified Studio has expired')) + assert.ok(tooltip.includes('Status: Expired - Click to reauthenticate')) + }) + + it('should include sign-in prompt for not connected state', function () { + mockAuthProvider.isConnected.returns(false) + + const treeItem = authInfoNode.getTreeItem() + const tooltip = treeItem.tooltip as string + + assert.ok(tooltip.includes('Not connected to SageMaker Unified Studio')) + assert.ok(tooltip.includes('Please sign in to access your projects')) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode.test.ts new file mode 100644 index 00000000000..fc74eeab435 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode.test.ts @@ -0,0 +1,93 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioComputeNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode' +import { SageMakerUnifiedStudioProjectNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode' +import { SageMakerUnifiedStudioSpacesParentNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode' +import { SagemakerClient } from '../../../../shared/clients/sagemaker' +import { SmusAuthenticationProvider } from '../../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' + +describe('SageMakerUnifiedStudioComputeNode', function () { + let computeNode: SageMakerUnifiedStudioComputeNode + let mockParent: SageMakerUnifiedStudioProjectNode + let mockExtensionContext: vscode.ExtensionContext + let mockAuthProvider: SmusAuthenticationProvider + let mockSagemakerClient: SagemakerClient + + beforeEach(function () { + mockParent = { + getProject: sinon.stub(), + } as any + + mockExtensionContext = { + subscriptions: [], + extensionUri: vscode.Uri.file('/test'), + } as any + + mockAuthProvider = {} as any + mockSagemakerClient = {} as any + + computeNode = new SageMakerUnifiedStudioComputeNode( + mockParent, + mockExtensionContext, + mockAuthProvider, + mockSagemakerClient + ) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('creates instance with correct properties', function () { + assert.strictEqual(computeNode.id, 'smusComputeNode') + assert.strictEqual(computeNode.resource, computeNode) + }) + }) + + describe('getTreeItem', function () { + it('returns correct tree item', async function () { + const treeItem = await computeNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Compute') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Expanded) + assert.strictEqual(treeItem.contextValue, 'smusComputeNode') + assert.ok(treeItem.iconPath) + }) + }) + + describe('getChildren', function () { + it('returns empty array when no project is selected', async function () { + ;(mockParent.getProject as sinon.SinonStub).returns(undefined) + + const children = await computeNode.getChildren() + + assert.deepStrictEqual(children, []) + }) + + it('returns connection nodes and spaces node when project is selected', async function () { + const mockProject = { id: 'project-123', name: 'Test Project' } + ;(mockParent.getProject as sinon.SinonStub).returns(mockProject) + + const children = await computeNode.getChildren() + + assert.strictEqual(children.length, 3) + assert.strictEqual(children[0].id, 'Data warehouse') + assert.strictEqual(children[1].id, 'Data processing') + assert.ok(children[2] instanceof SageMakerUnifiedStudioSpacesParentNode) + }) + }) + + describe('getParent', function () { + it('returns parent node', function () { + const parent = computeNode.getParent() + assert.strictEqual(parent, mockParent) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionNode.test.ts new file mode 100644 index 00000000000..a85d63302a6 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionNode.test.ts @@ -0,0 +1,144 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioConnectionNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionNode' +import { SageMakerUnifiedStudioConnectionParentNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionParentNode' +import { ConnectionType, ConnectionSummary } from '@aws-sdk/client-datazone' +import { getLogger } from '../../../../shared/logger/logger' + +describe('SageMakerUnifiedStudioConnectionNode', function () { + let connectionNode: SageMakerUnifiedStudioConnectionNode + let mockParent: sinon.SinonStubbedInstance + + const mockRedshiftConnection: ConnectionSummary = { + connectionId: 'conn-1', + name: 'Test Redshift Connection', + type: ConnectionType.REDSHIFT, + environmentId: 'env-1', + domainId: 'domain-1', + domainUnitId: 'unit-1', + physicalEndpoints: [], + props: { + redshiftProperties: { + jdbcUrl: 'jdbc:redshift://test-cluster:5439/testdb', + }, + }, + } + + const mockSparkConnection: ConnectionSummary = { + connectionId: 'conn-2', + name: 'Test Spark Connection', + type: ConnectionType.SPARK, + environmentId: 'env-2', + domainId: 'domain-2', + domainUnitId: 'unit-2', + physicalEndpoints: [], + props: { + sparkGlueProperties: { + glueVersion: '4.0', + workerType: 'G.1X', + numberOfWorkers: 2, + idleTimeout: 30, + }, + }, + } + + beforeEach(function () { + mockParent = {} as any + sinon.stub(getLogger(), 'debug') + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('creates instance with correct properties for Redshift connection', function () { + connectionNode = new SageMakerUnifiedStudioConnectionNode(mockParent as any, mockRedshiftConnection) + + assert.strictEqual(connectionNode.id, 'Test Redshift Connection') + assert.strictEqual(connectionNode.resource, connectionNode) + assert.strictEqual(connectionNode.contextValue, 'SageMakerUnifiedStudioConnectionNode') + }) + + it('creates instance with empty id when connection name is undefined', function () { + const connectionWithoutName = { ...mockRedshiftConnection, name: undefined } + connectionNode = new SageMakerUnifiedStudioConnectionNode(mockParent as any, connectionWithoutName) + + assert.strictEqual(connectionNode.id, '') + }) + }) + + describe('getTreeItem', function () { + it('returns correct tree item for Redshift connection', async function () { + connectionNode = new SageMakerUnifiedStudioConnectionNode(mockParent as any, mockRedshiftConnection) + + const treeItem = await connectionNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Test Redshift Connection') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + assert.strictEqual(treeItem.contextValue, 'SageMakerUnifiedStudioConnectionNode') + assert.ok(treeItem.tooltip instanceof vscode.MarkdownString) + }) + + it('returns correct tree item for Spark connection', async function () { + connectionNode = new SageMakerUnifiedStudioConnectionNode(mockParent as any, mockSparkConnection) + + const treeItem = await connectionNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Test Spark Connection') + assert.ok(treeItem.tooltip instanceof vscode.MarkdownString) + }) + }) + + describe('tooltip generation', function () { + it('generates correct tooltip for Redshift connection', async function () { + connectionNode = new SageMakerUnifiedStudioConnectionNode(mockParent as any, mockRedshiftConnection) + + const treeItem = await connectionNode.getTreeItem() + const tooltip = (treeItem.tooltip as vscode.MarkdownString).value + + assert(tooltip.includes('REDSHIFT')) + assert(tooltip.includes('env-1')) + assert(tooltip.includes('jdbc:redshift://test-cluster:5439/testdb')) + }) + + it('generates correct tooltip for Spark connection', async function () { + connectionNode = new SageMakerUnifiedStudioConnectionNode(mockParent as any, mockSparkConnection) + + const treeItem = await connectionNode.getTreeItem() + const tooltip = (treeItem.tooltip as vscode.MarkdownString).value + + assert(tooltip.includes('SPARK')) + assert(tooltip.includes('4.0')) + assert(tooltip.includes('G.1X')) + assert(tooltip.includes('2')) + assert(tooltip.includes('30')) + }) + + it('generates empty tooltip for unknown connection type', async function () { + const unknownConnection = { ...mockRedshiftConnection, type: 'UNKNOWN' as ConnectionType } + connectionNode = new SageMakerUnifiedStudioConnectionNode(mockParent as any, unknownConnection) + + const treeItem = await connectionNode.getTreeItem() + const tooltip = (treeItem.tooltip as vscode.MarkdownString).value + + assert.strictEqual(tooltip, '') + }) + }) + + describe('getParent', function () { + it('returns the parent node', function () { + connectionNode = new SageMakerUnifiedStudioConnectionNode(mockParent as any, mockRedshiftConnection) + + const parent = connectionNode.getParent() + + assert.strictEqual(parent, mockParent) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionParentNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionParentNode.test.ts new file mode 100644 index 00000000000..686c85a0055 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionParentNode.test.ts @@ -0,0 +1,234 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioConnectionParentNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionParentNode' +import { SageMakerUnifiedStudioComputeNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode' +import { SageMakerUnifiedStudioConnectionNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioConnectionNode' +import { DataZoneClient } from '../../../../sagemakerunifiedstudio/shared/client/datazoneClient' + +import { ConnectionType, ListConnectionsCommandOutput, ConnectionSummary } from '@aws-sdk/client-datazone' +import { getLogger } from '../../../../shared/logger/logger' + +describe('SageMakerUnifiedStudioConnectionParentNode', function () { + let connectionParentNode: SageMakerUnifiedStudioConnectionParentNode + let mockComputeNode: sinon.SinonStubbedInstance + + let mockDataZoneClient: sinon.SinonStubbedInstance + + const mockProject = { + id: 'project-123', + domainId: 'domain-123', + } + + const mockConnectionsOutput: ListConnectionsCommandOutput = { + items: [ + { + connectionId: 'conn-1', + name: 'Test Connection 1', + type: ConnectionType.REDSHIFT, + environmentId: 'env-1', + } as ConnectionSummary, + { + connectionId: 'conn-2', + name: 'Test Connection 2', + type: ConnectionType.REDSHIFT, + environmentId: 'env-2', + } as ConnectionSummary, + ], + $metadata: {}, + } + + beforeEach(function () { + // Create mock objects + mockDataZoneClient = { + fetchConnections: sinon.stub(), + } as any + + mockComputeNode = { + authProvider: {} as any, + parent: { + project: mockProject, + } as any, + } as any + + // Stub static methods + sinon.stub(DataZoneClient, 'getInstance').resolves(mockDataZoneClient as any) + sinon.stub(getLogger(), 'debug') + + connectionParentNode = new SageMakerUnifiedStudioConnectionParentNode( + mockComputeNode as any, + ConnectionType.REDSHIFT, + 'Data warehouse' + ) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('creates instance with correct properties', function () { + assert.strictEqual(connectionParentNode.id, 'Data warehouse') + assert.strictEqual(connectionParentNode.resource, connectionParentNode) + assert.strictEqual(connectionParentNode.contextValue, 'SageMakerUnifiedStudioConnectionParentNode') + }) + }) + + describe('getTreeItem', function () { + it('returns correct tree item', async function () { + const treeItem = await connectionParentNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Data warehouse') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed) + assert.strictEqual(treeItem.contextValue, 'SageMakerUnifiedStudioConnectionParentNode') + }) + }) + + describe('getChildren', function () { + it('returns connection nodes when connections exist', async function () { + mockDataZoneClient.fetchConnections.resolves(mockConnectionsOutput) + + const children = await connectionParentNode.getChildren() + + assert.strictEqual(children.length, 2) + assert(children[0] instanceof SageMakerUnifiedStudioConnectionNode) + assert(children[1] instanceof SageMakerUnifiedStudioConnectionNode) + + // Verify fetchConnections was called with correct parameters + assert( + mockDataZoneClient.fetchConnections.calledOnceWith( + mockProject.domainId, + mockProject.id, + ConnectionType.REDSHIFT + ) + ) + }) + + it('returns no connections node when no connections exist', async function () { + const emptyOutput: ListConnectionsCommandOutput = { items: [], $metadata: {} } + mockDataZoneClient.fetchConnections.resolves(emptyOutput) + + const children = await connectionParentNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].id, 'smusNoConnections') + const treeItem = await children[0].getTreeItem() + assert.strictEqual(treeItem.label, '[No connections found]') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + }) + + it('returns no connections node when connections items is undefined', async function () { + const undefinedOutput: ListConnectionsCommandOutput = { items: undefined, $metadata: {} } + mockDataZoneClient.fetchConnections.resolves(undefinedOutput) + + const children = await connectionParentNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].id, 'smusNoConnections') + }) + + it('handles missing project information gracefully', async function () { + const nodeWithoutProject = new SageMakerUnifiedStudioConnectionParentNode( + { + authProvider: {} as any, + parent: { + project: undefined, + } as any, + } as any, + ConnectionType.SPARK, + 'Data processing' + ) + + mockDataZoneClient.fetchConnections.resolves({ items: [], $metadata: {} }) + + const children = await nodeWithoutProject.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].id, 'smusNoConnections') + assert(mockDataZoneClient.fetchConnections.calledOnceWith(undefined, undefined, ConnectionType.SPARK)) + }) + }) + + describe('getParent', function () { + it('returns the parent compute node', function () { + const parent = connectionParentNode.getParent() + assert.strictEqual(parent, mockComputeNode) + }) + }) + + describe('error handling', function () { + it('handles DataZoneClient.getInstance error', async function () { + sinon.restore() + sinon.stub(DataZoneClient, 'getInstance').rejects(new Error('Client error')) + sinon.stub(getLogger(), 'debug') + + try { + await connectionParentNode.getChildren() + assert.fail('Expected error to be thrown') + } catch (error) { + assert.strictEqual((error as Error).message, 'Client error') + } + }) + + it('handles fetchConnections error', async function () { + mockDataZoneClient.fetchConnections.rejects(new Error('Fetch error')) + + try { + await connectionParentNode.getChildren() + assert.fail('Expected error to be thrown') + } catch (error) { + assert.strictEqual((error as Error).message, 'Fetch error') + } + }) + }) + + describe('connections property', function () { + it('sets connections property after getChildren call', async function () { + mockDataZoneClient.fetchConnections.resolves(mockConnectionsOutput) + + await connectionParentNode.getChildren() + + assert.strictEqual(connectionParentNode.connections, mockConnectionsOutput) + }) + }) + + describe('different connection types', function () { + it('works with SPARK connection type', async function () { + const sparkNode = new SageMakerUnifiedStudioConnectionParentNode( + mockComputeNode as any, + ConnectionType.SPARK, + 'Spark connections' + ) + + const sparkOutput = { + items: [ + { + connectionId: 'spark-1', + name: 'Spark Connection', + type: ConnectionType.SPARK, + environmentId: 'env-spark', + } as ConnectionSummary, + ], + $metadata: {}, + } + + mockDataZoneClient.fetchConnections.resolves(sparkOutput) + + const children = await sparkNode.getChildren() + + assert.strictEqual(children.length, 1) + assert( + mockDataZoneClient.fetchConnections.calledWith( + mockProject.domainId, + mockProject.id, + ConnectionType.SPARK + ) + ) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioDataNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioDataNode.test.ts new file mode 100644 index 00000000000..991e5955989 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioDataNode.test.ts @@ -0,0 +1,235 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioDataNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioDataNode' +import { SageMakerUnifiedStudioProjectNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode' +import { DataZoneClient, DataZoneProject } from '../../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { SmusAuthenticationProvider } from '../../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' +import * as s3Strategy from '../../../../sagemakerunifiedstudio/explorer/nodes/s3Strategy' +import * as redshiftStrategy from '../../../../sagemakerunifiedstudio/explorer/nodes/redshiftStrategy' +import * as lakehouseStrategy from '../../../../sagemakerunifiedstudio/explorer/nodes/lakehouseStrategy' + +describe('SageMakerUnifiedStudioDataNode', function () { + let sandbox: sinon.SinonSandbox + let dataNode: SageMakerUnifiedStudioDataNode + let mockParent: sinon.SinonStubbedInstance + let mockDataZoneClient: sinon.SinonStubbedInstance + let mockAuthProvider: sinon.SinonStubbedInstance + let mockProjectCredentialProvider: any + + const mockProject: DataZoneProject = { + id: 'project-123', + name: 'Test Project', + domainId: 'domain-123', + } + + const mockCredentials = { + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + $metadata: {}, + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + + mockParent = { + getProject: sandbox.stub().returns(mockProject), + } as any + + mockProjectCredentialProvider = { + getCredentials: sandbox.stub().resolves(mockCredentials), + } + + mockAuthProvider = { + getProjectCredentialProvider: sandbox.stub().resolves(mockProjectCredentialProvider), + getConnectionCredentialsProvider: sandbox.stub().resolves(mockProjectCredentialProvider), + getDomainRegion: sandbox.stub().returns('us-east-1'), + } as any + + mockDataZoneClient = { + getInstance: sandbox.stub(), + getProjectDefaultEnvironmentCreds: sandbox.stub(), + listConnections: sandbox.stub(), + getConnection: sandbox.stub(), + getRegion: sandbox.stub().returns('us-east-1'), + } as any + + sandbox.stub(DataZoneClient, 'getInstance').returns(mockDataZoneClient as any) + sandbox.stub(SmusAuthenticationProvider, 'fromContext').returns(mockAuthProvider as any) + sandbox.stub(s3Strategy, 'createS3ConnectionNode').returns({ + id: 's3-node', + getChildren: () => Promise.resolve([]), + getTreeItem: () => ({}) as any, + getParent: () => undefined, + } as any) + sandbox.stub(s3Strategy, 'createS3AccessGrantNodes').resolves([]) + sandbox.stub(redshiftStrategy, 'createRedshiftConnectionNode').returns({ + id: 'redshift-node', + getChildren: () => Promise.resolve([]), + getTreeItem: () => ({}) as any, + getParent: () => undefined, + } as any) + sandbox.stub(lakehouseStrategy, 'createLakehouseConnectionNode').returns({ + id: 'lakehouse-node', + getChildren: () => Promise.resolve([]), + getTreeItem: () => ({}) as any, + getParent: () => undefined, + } as any) + + dataNode = new SageMakerUnifiedStudioDataNode(mockParent as any) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('constructor', function () { + it('should initialize with correct properties', function () { + assert.strictEqual(dataNode.id, 'smusDataExplorer') + assert.deepStrictEqual(dataNode.resource, {}) + }) + + it('should initialize with provided children', function () { + const initialChildren = [{ id: 'child1' } as any] + const nodeWithChildren = new SageMakerUnifiedStudioDataNode(mockParent as any, initialChildren) + // Children should be cached + assert.ok(nodeWithChildren) + }) + }) + + describe('getTreeItem', function () { + it('should return correct tree item', function () { + const treeItem = dataNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Data') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed) + assert.strictEqual(treeItem.contextValue, 'dataFolder') + }) + }) + + describe('getParent', function () { + it('should return parent node', function () { + assert.strictEqual(dataNode.getParent(), mockParent) + }) + }) + + describe('getChildren', function () { + it('should return cached children if available', async function () { + const initialChildren = [{ id: 'cached' } as any] + const nodeWithCache = new SageMakerUnifiedStudioDataNode(mockParent as any, initialChildren) + + const children = await nodeWithCache.getChildren() + assert.strictEqual(children, initialChildren) + }) + + it('should return error node when no project available', async function () { + mockParent.getProject.returns(undefined) + + const children = await dataNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.ok(children[0].id.startsWith('smusDataExplorer-error-project-')) + }) + + it('should return error node when credentials are missing', async function () { + mockProjectCredentialProvider.getCredentials.resolves(undefined) + + const children = await dataNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.ok(children[0].id.startsWith('smusDataExplorer-error-connections-')) + }) + + it('should return placeholder when no connections found', async function () { + mockDataZoneClient.listConnections.resolves([]) + + const children = await dataNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].resource, '[No data found]') + }) + + it('should create Bucket parent node and Redshift nodes for connections', async function () { + const mockConnections = [ + { connectionId: 's3-conn', type: 'S3', name: 's3-connection' }, + { connectionId: 'redshift-conn', type: 'REDSHIFT', name: 'redshift-connection' }, + ] + + mockDataZoneClient.listConnections.resolves(mockConnections as any) + mockDataZoneClient.getConnection + .onFirstCall() + .resolves({ + location: { awsRegion: 'us-east-1', awsAccountId: '' }, + connectionCredentials: mockCredentials, + connectionId: '', + name: '', + type: '', + domainId: '', + projectId: '', + }) + .onSecondCall() + .resolves({ + location: { awsRegion: 'us-east-1', awsAccountId: '' }, + connectionCredentials: mockCredentials, + connectionId: '', + name: '', + type: '', + domainId: '', + projectId: '', + }) + + const children = await dataNode.getChildren() + + // Should have Bucket parent node and Redshift node + assert.strictEqual(children.length, 2) + + // Check for Bucket parent node + const bucketNode = children.find((child) => child.id === 'bucket-parent') + assert.ok(bucketNode, 'Should have bucket parent node') + + // Verify Bucket node has correct tree item + const bucketTreeItem = await bucketNode!.getTreeItem() + assert.strictEqual(bucketTreeItem.label, 'Buckets') + assert.strictEqual(bucketTreeItem.contextValue, 'bucketFolder') + + // Verify S3 nodes are created when Bucket node is expanded + await bucketNode!.getChildren!() + assert.ok((s3Strategy.createS3ConnectionNode as sinon.SinonStub).calledOnce) + + assert.ok((redshiftStrategy.createRedshiftConnectionNode as sinon.SinonStub).calledOnce) + }) + + it('should handle connection detail errors gracefully', async function () { + const mockConnections = [{ connectionId: 's3-conn', type: 'S3', name: 's3-connection' }] + + mockDataZoneClient.listConnections.resolves(mockConnections as any) + mockDataZoneClient.getConnection.rejects(new Error('Connection error')) + + const children = await dataNode.getChildren() + + // Should have Bucket parent node even with connection errors + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].id, 'bucket-parent') + + // Error should occur when expanding the Bucket node + const bucketChildren = await children[0].getChildren!() + assert.strictEqual(bucketChildren.length, 1) + assert.ok(bucketChildren[0].id.startsWith('smusDataExplorer-error-s3-')) + }) + + it('should return error node when general error occurs', async function () { + mockAuthProvider.getProjectCredentialProvider.rejects(new Error('General error')) + + const children = await dataNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.ok(children[0].id.startsWith('smusDataExplorer-error-connections-')) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode.test.ts new file mode 100644 index 00000000000..02a0c1078d9 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode.test.ts @@ -0,0 +1,343 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioProjectNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode' +import { DataZoneClient, DataZoneProject } from '../../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { getLogger } from '../../../../shared/logger/logger' +import { telemetry } from '../../../../shared/telemetry/telemetry' +import { SagemakerClient } from '../../../../shared/clients/sagemaker' +import { SageMakerUnifiedStudioDataNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioDataNode' +import { SageMakerUnifiedStudioComputeNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode' +import * as vscodeUtils from '../../../../shared/vscode/setContext' + +describe('SageMakerUnifiedStudioProjectNode', function () { + let projectNode: SageMakerUnifiedStudioProjectNode + let mockDataZoneClient: sinon.SinonStubbedInstance + + const mockProject: DataZoneProject = { + id: 'project-123', + name: 'Test Project', + description: 'Test Description', + domainId: 'domain-123', + } + + beforeEach(function () { + // Create mock parent + const mockParent = {} as any + + // Create mock auth provider + const mockAuthProvider = { + activeConnection: { domainId: 'test-domain', ssoRegion: 'us-west-2' }, + invalidateAllProjectCredentialsInCache: sinon.stub(), + getProjectCredentialProvider: sinon.stub(), + getDomainRegion: sinon.stub().returns('us-west-2'), + } as any + + // Create mock extension context + const mockExtensionContext = { + subscriptions: [], + workspaceState: { + get: sinon.stub(), + update: sinon.stub(), + }, + globalState: { + get: sinon.stub(), + update: sinon.stub(), + }, + } as any + + projectNode = new SageMakerUnifiedStudioProjectNode(mockParent, mockAuthProvider, mockExtensionContext) + + sinon.stub(getLogger(), 'info') + sinon.stub(getLogger(), 'warn') + + // Stub telemetry + sinon.stub(telemetry, 'record') + + // Create mock DataZone client + mockDataZoneClient = { + getProjectDefaultEnvironmentCreds: sinon.stub(), + getUserId: sinon.stub(), + fetchAllProjectMemberships: sinon.stub(), + getDomainId: sinon.stub().returns('test-domain-id'), + getToolingEnvironmentId: sinon.stub(), + getEnvironmentDetails: sinon.stub(), + getToolingEnvironment: sinon.stub(), + } as any + + // Stub DataZoneClient static methods + sinon.stub(DataZoneClient, 'getInstance').returns(mockDataZoneClient as any) + + // Stub SagemakerClient constructor + sinon.stub(SagemakerClient.prototype, 'dispose') + + // Stub child node constructors to prevent actual instantiation + sinon.stub(SageMakerUnifiedStudioDataNode.prototype, 'constructor' as any).returns({}) + sinon.stub(SageMakerUnifiedStudioComputeNode.prototype, 'constructor' as any).returns({}) + + // Stub getContext to return false for SMUS space environment + sinon.stub(vscodeUtils, 'getContext').returns(false) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('creates instance with correct properties', function () { + assert.strictEqual(projectNode.id, 'smusProjectNode') + assert.strictEqual(projectNode.resource, projectNode) + }) + }) + + describe('getTreeItem', function () { + it('returns correct tree item when no project is selected', async function () { + const treeItem = await projectNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Select a project') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Expanded) + assert.strictEqual(treeItem.contextValue, 'smusProjectSelectPicker') + assert.ok(treeItem.command) + assert.strictEqual(treeItem.command?.command, 'aws.smus.projectView') + }) + + it('returns correct tree item when project is selected', async function () { + await projectNode.setProject(mockProject) + const treeItem = await projectNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Project: ' + mockProject.name) + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Expanded) + assert.strictEqual(treeItem.contextValue, 'smusSelectedProject') + assert.strictEqual(treeItem.tooltip, `Project: ${mockProject.name}\nID: ${mockProject.id}`) + }) + }) + + describe('getParent', function () { + it('returns parent node', function () { + const parent = projectNode.getParent() + assert.ok(parent) + }) + }) + + describe('setProject', function () { + it('updates the project and calls cleanupProjectResources', async function () { + const cleanupSpy = sinon.spy(projectNode as any, 'cleanupProjectResources') + await projectNode.setProject(mockProject) + assert.strictEqual(projectNode['project'], mockProject) + assert(cleanupSpy.calledOnce) + }) + }) + + describe('clearProject', function () { + it('clears the project, calls cleanupProjectResources and fires change event', async function () { + await projectNode.setProject(mockProject) + const cleanupSpy = sinon.spy(projectNode as any, 'cleanupProjectResources') + const emitterSpy = sinon.spy(projectNode['onDidChangeEmitter'], 'fire') + + await projectNode.clearProject() + + assert.strictEqual(projectNode['project'], undefined) + assert(cleanupSpy.calledOnce) + assert(emitterSpy.calledOnce) + }) + }) + + describe('getProject', function () { + it('returns undefined when no project is set', function () { + assert.strictEqual(projectNode.getProject(), undefined) + }) + + it('returns project when set', async function () { + await projectNode.setProject(mockProject) + assert.strictEqual(projectNode.getProject(), mockProject) + }) + }) + + describe('refreshNode', function () { + it('fires change event', async function () { + const emitterSpy = sinon.spy(projectNode['onDidChangeEmitter'], 'fire') + await projectNode.refreshNode() + assert(emitterSpy.calledOnce) + }) + }) + + describe('getChildren', function () { + it('returns empty array when no project is selected', async function () { + const children = await projectNode.getChildren() + assert.deepStrictEqual(children, []) + }) + + it('returns data and compute nodes when project is selected and user has access', async function () { + await projectNode.setProject(mockProject) + const mockCredProvider = { + getCredentials: sinon.stub().resolves({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + projectNode['authProvider'].getProjectCredentialProvider = sinon.stub().resolves(mockCredProvider) + + // Mock getToolingEnvironment method + mockDataZoneClient.getToolingEnvironment.resolves({ + id: 'env-123', + awsAccountRegion: 'us-east-1', + projectId: undefined, + domainId: undefined, + createdBy: undefined, + name: undefined, + provider: undefined, + $metadata: {}, + }) + + const children = await projectNode.getChildren() + assert.strictEqual(children.length, 2) + }) + + it('returns access denied message when user does not have project access', async function () { + await projectNode.setProject(mockProject) + + // Mock access check to return false by throwing AccessDeniedException + const accessError = new Error('Access denied') + accessError.name = 'AccessDeniedException' + projectNode['authProvider'].getProjectCredentialProvider = sinon.stub().rejects(accessError) + + const children = await projectNode.getChildren() + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].id, 'smusProjectAccessDenied') + + const treeItem = await children[0].getTreeItem() + assert.strictEqual(treeItem.label, 'You do not have access to this project. Contact your administrator.') + }) + + it('throws error when initializeSagemakerClient fails', async function () { + await projectNode.setProject(mockProject) + const credError = new Error('Failed to initialize SageMaker client') + + // First call succeeds for access check, second call fails for initializeSagemakerClient + const mockCredProvider = { + getCredentials: sinon.stub().resolves({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + projectNode['authProvider'].getProjectCredentialProvider = sinon + .stub() + .onFirstCall() + .resolves(mockCredProvider) + .onSecondCall() + .rejects(credError) + + // Mock getToolingEnvironment method + mockDataZoneClient.getToolingEnvironment.resolves({ + id: 'env-123', + awsAccountRegion: 'us-east-1', + projectId: undefined, + domainId: undefined, + createdBy: undefined, + name: undefined, + provider: undefined, + $metadata: {}, + }) + + await assert.rejects(async () => await projectNode.getChildren(), credError) + }) + }) + + describe('initializeSagemakerClient', function () { + it('throws error when no project is selected', async function () { + await assert.rejects( + async () => await projectNode['initializeSagemakerClient']('us-east-1'), + /No project selected for initializing SageMaker client/ + ) + }) + + it('creates SagemakerClient with project credentials', async function () { + await projectNode.setProject(mockProject) + const mockCredProvider = { + getCredentials: sinon.stub().resolves({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + projectNode['authProvider'].getProjectCredentialProvider = sinon.stub().resolves(mockCredProvider) + + const client = await projectNode['initializeSagemakerClient']('us-east-1') + assert.ok(client instanceof SagemakerClient) + assert( + (projectNode['authProvider'].getProjectCredentialProvider as sinon.SinonStub).calledWith(mockProject.id) + ) + }) + }) + + describe('checkProjectCredsAccess', function () { + it('returns true when user has project access', async function () { + const mockCredProvider = { + getCredentials: sinon.stub().resolves({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + projectNode['authProvider'].getProjectCredentialProvider = sinon.stub().resolves(mockCredProvider) + + const hasAccess = await projectNode['checkProjectCredsAccess']('project-123') + assert.strictEqual(hasAccess, true) + }) + + it('returns false when user does not have project access', async function () { + const accessError = new Error('Access denied') + accessError.name = 'AccessDeniedException' + projectNode['authProvider'].getProjectCredentialProvider = sinon.stub().rejects(accessError) + + const hasAccess = await projectNode['checkProjectCredsAccess']('project-123') + assert.strictEqual(hasAccess, false) + }) + + it('returns false when getCredentials fails', async function () { + const mockCredProvider = { + getCredentials: sinon.stub().rejects(new Error('Credentials error')), + } + projectNode['authProvider'].getProjectCredentialProvider = sinon.stub().resolves(mockCredProvider) + + const hasAccess = await projectNode['checkProjectCredsAccess']('project-123') + assert.strictEqual(hasAccess, false) + }) + + it('returns false when access check throws non-AccessDeniedException error', async function () { + projectNode['authProvider'].getProjectCredentialProvider = sinon.stub().rejects(new Error('Other error')) + + const hasAccess = await projectNode['checkProjectCredsAccess']('project-123') + assert.strictEqual(hasAccess, false) + }) + }) + + describe('cleanupProjectResources', function () { + it('invalidates credentials and disposes existing sagemaker client', async function () { + // Set up existing sagemaker client with mock + const mockClient = { dispose: sinon.stub() } as any + projectNode['sagemakerClient'] = mockClient + + await projectNode['cleanupProjectResources']() + + assert((projectNode['authProvider'].invalidateAllProjectCredentialsInCache as sinon.SinonStub).calledOnce) + assert(mockClient.dispose.calledOnce) + assert.strictEqual(projectNode['sagemakerClient'], undefined) + }) + + it('handles case when no sagemaker client exists', async function () { + projectNode['sagemakerClient'] = undefined + + await projectNode['cleanupProjectResources']() + + assert((projectNode['authProvider'].invalidateAllProjectCredentialsInCache as sinon.SinonStub).calledOnce) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioRootNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioRootNode.test.ts new file mode 100644 index 00000000000..f89413e9528 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioRootNode.test.ts @@ -0,0 +1,603 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { + SageMakerUnifiedStudioRootNode, + selectSMUSProject, +} from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioRootNode' +import { SageMakerUnifiedStudioProjectNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioProjectNode' +import { DataZoneClient, DataZoneProject } from '../../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { SageMakerUnifiedStudioAuthInfoNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioAuthInfoNode' +import { SmusAuthenticationProvider } from '../../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' +import * as pickerPrompter from '../../../../shared/ui/pickerPrompter' +import { getTestWindow } from '../../../shared/vscode/window' +import { assertTelemetry } from '../../../../../src/test/testUtil' + +describe('SmusRootNode', function () { + let rootNode: SageMakerUnifiedStudioRootNode + let mockDataZoneClient: sinon.SinonStubbedInstance + + const testDomainId = 'test-domain-123' + const mockProject: DataZoneProject = { + id: 'project-123', + name: 'Test Project', + description: 'Test Description', + domainId: testDomainId, + } + + beforeEach(function () { + // Create mock extension context + const mockExtensionContext = { + subscriptions: [], + workspaceState: { + get: sinon.stub(), + update: sinon.stub(), + }, + globalState: { + get: sinon.stub(), + update: sinon.stub(), + }, + } as any + + // Create a mock auth provider + const mockAuthProvider = { + isConnected: sinon.stub().returns(true), + isConnectionValid: sinon.stub().returns(true), + activeConnection: { domainId: testDomainId, ssoRegion: 'us-west-2' }, + onDidChange: sinon.stub().returns({ dispose: sinon.stub() }), + } as any + + rootNode = new SageMakerUnifiedStudioRootNode(mockAuthProvider, mockExtensionContext) + + // Mock domain ID is handled by the mock auth provider + + // Create mock DataZone client + mockDataZoneClient = { + getDomainId: sinon.stub().returns(testDomainId), + listProjects: sinon.stub(), + } as any + + // Stub DataZoneClient static methods + sinon.stub(DataZoneClient, 'getInstance').returns(mockDataZoneClient as any) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('should initialize id and resource properties', function () { + // Create a mock auth provider + const mockAuthProvider = { + isConnected: sinon.stub().returns(true), + isConnectionValid: sinon.stub().returns(true), + activeConnection: { domainId: testDomainId, ssoRegion: 'us-west-2' }, + onDidChange: sinon.stub().returns({ dispose: sinon.stub() }), + } as any + + const mockExtensionContext = { + subscriptions: [], + workspaceState: { + get: sinon.stub(), + update: sinon.stub(), + }, + globalState: { + get: sinon.stub(), + update: sinon.stub(), + }, + } as any + + const node = new SageMakerUnifiedStudioRootNode(mockAuthProvider, mockExtensionContext) + assert.strictEqual(node.id, 'smusRootNode') + assert.strictEqual(node.resource, node) + assert.ok(node.getAuthInfoNode() instanceof SageMakerUnifiedStudioAuthInfoNode) + assert.ok(node.getProjectSelectNode() instanceof SageMakerUnifiedStudioProjectNode) + assert.strictEqual(typeof node.onDidChangeTreeItem, 'function') + assert.strictEqual(typeof node.onDidChangeChildren, 'function') + }) + }) + + describe('getTreeItem', function () { + it('returns correct tree item when authenticated', async function () { + const treeItem = rootNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'SageMaker Unified Studio') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Expanded) + assert.strictEqual(treeItem.contextValue, 'sageMakerUnifiedStudioRoot') + assert.strictEqual(treeItem.description, 'Connected') + assert.ok(treeItem.iconPath) + }) + + it('returns correct tree item when not authenticated', async function () { + // Create a mock auth provider for unauthenticated state + const mockAuthProvider = { + isConnected: sinon.stub().returns(false), + isConnectionValid: sinon.stub().returns(false), + activeConnection: undefined, + onDidChange: sinon.stub().returns({ dispose: sinon.stub() }), + } as any + + const mockExtensionContext = { + subscriptions: [], + workspaceState: { + get: sinon.stub(), + update: sinon.stub(), + }, + globalState: { + get: sinon.stub(), + update: sinon.stub(), + }, + } as any + + const unauthenticatedNode = new SageMakerUnifiedStudioRootNode(mockAuthProvider, mockExtensionContext) + const treeItem = unauthenticatedNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'SageMaker Unified Studio') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Expanded) + assert.strictEqual(treeItem.contextValue, 'sageMakerUnifiedStudioRoot') + assert.strictEqual(treeItem.description, 'Not authenticated') + assert.ok(treeItem.iconPath) + }) + }) + + describe('getChildren', function () { + it('returns login node when not authenticated (empty domain ID)', async function () { + // Create a mock auth provider for unauthenticated state + const mockAuthProvider = { + isConnected: sinon.stub().returns(false), + isConnectionValid: sinon.stub().returns(false), + activeConnection: undefined, + onDidChange: sinon.stub().returns({ dispose: sinon.stub() }), + } as any + + const mockExtensionContext = { + subscriptions: [], + workspaceState: { + get: sinon.stub(), + update: sinon.stub(), + }, + globalState: { + get: sinon.stub(), + update: sinon.stub(), + }, + } as any + + const unauthenticatedNode = new SageMakerUnifiedStudioRootNode(mockAuthProvider, mockExtensionContext) + const children = await unauthenticatedNode.getChildren() + + assert.strictEqual(children.length, 2) + assert.strictEqual(children[0].id, 'smusLogin') + assert.strictEqual(children[1].id, 'smusLearnMore') + + // Check login node + const loginTreeItem = await children[0].getTreeItem() + assert.strictEqual(loginTreeItem.label, 'Sign in to get started') + assert.strictEqual(loginTreeItem.contextValue, 'sageMakerUnifiedStudioLogin') + assert.deepStrictEqual(loginTreeItem.command, { + command: 'aws.smus.login', + title: 'Sign in to SageMaker Unified Studio', + }) + + // Check learn more node + const learnMoreTreeItem = await children[1].getTreeItem() + assert.strictEqual(learnMoreTreeItem.label, 'Learn more about SageMaker Unified Studio') + assert.strictEqual(learnMoreTreeItem.contextValue, 'sageMakerUnifiedStudioLearnMore') + assert.deepStrictEqual(learnMoreTreeItem.command, { + command: 'aws.smus.learnMore', + title: 'Learn more about SageMaker Unified Studio', + }) + }) + + it('returns login node when DataZone client throws error', async function () { + // Create a mock auth provider that throws an error + const mockAuthProvider = { + isConnected: sinon.stub().throws(new Error('Auth provider error')), + isConnectionValid: sinon.stub().returns(false), + activeConnection: undefined, + onDidChange: sinon.stub().returns({ dispose: sinon.stub() }), + } as any + + const mockExtensionContext = { + subscriptions: [], + workspaceState: { + get: sinon.stub(), + update: sinon.stub(), + }, + globalState: { + get: sinon.stub(), + update: sinon.stub(), + }, + } as any + + const errorNode = new SageMakerUnifiedStudioRootNode(mockAuthProvider, mockExtensionContext) + const children = await errorNode.getChildren() + + assert.strictEqual(children.length, 2) + assert.strictEqual(children[0].id, 'smusLogin') + assert.strictEqual(children[1].id, 'smusLearnMore') + + // Check login node + const loginTreeItem = await children[0].getTreeItem() + assert.strictEqual(loginTreeItem.label, 'Sign in to get started') + assert.strictEqual(loginTreeItem.contextValue, 'sageMakerUnifiedStudioLogin') + + // Check learn more node + const learnMoreTreeItem = await children[1].getTreeItem() + assert.strictEqual(learnMoreTreeItem.label, 'Learn more about SageMaker Unified Studio') + assert.strictEqual(learnMoreTreeItem.contextValue, 'sageMakerUnifiedStudioLearnMore') + }) + + it('returns root nodes when authenticated', async function () { + mockDataZoneClient.listProjects.resolves({ projects: [mockProject], nextToken: undefined }) + + const children = await rootNode.getChildren() + + assert.strictEqual(children.length, 2) + assert.ok(children[0] instanceof SageMakerUnifiedStudioAuthInfoNode) + assert.ok(children[1] instanceof SageMakerUnifiedStudioProjectNode) + // The first child is the auth info node, the second is the project node + assert.strictEqual(children[0].id, 'smusAuthInfoNode') + assert.strictEqual(children[1].id, 'smusProjectNode') + + assert.strictEqual(children.length, 2) + assert.strictEqual(children[1].id, 'smusProjectNode') + + const treeItem = await children[1].getTreeItem() + assert.strictEqual(treeItem.label, 'Select a project') + assert.strictEqual(treeItem.contextValue, 'smusProjectSelectPicker') + assert.deepStrictEqual(treeItem.command, { + command: 'aws.smus.projectView', + title: 'Select Project', + arguments: [children[1]], + }) + }) + + it('returns auth info node when connection is expired', async function () { + // Create a mock auth provider with expired connection + const mockAuthProvider = { + isConnected: sinon.stub().returns(true), + isConnectionValid: sinon.stub().returns(false), + activeConnection: { domainId: testDomainId, ssoRegion: 'us-west-2' }, + onDidChange: sinon.stub().returns({ dispose: sinon.stub() }), + showReauthenticationPrompt: sinon.stub(), + } as any + + const mockExtensionContext = { + subscriptions: [], + workspaceState: { + get: sinon.stub(), + update: sinon.stub(), + }, + globalState: { + get: sinon.stub(), + update: sinon.stub(), + }, + } as any + + const expiredNode = new SageMakerUnifiedStudioRootNode(mockAuthProvider, mockExtensionContext) + const children = await expiredNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.ok(children[0] instanceof SageMakerUnifiedStudioAuthInfoNode) + assert.ok(mockAuthProvider.showReauthenticationPrompt.calledOnce) + }) + }) + + describe('refresh', function () { + it('fires change events', function () { + const onDidChangeTreeItemSpy = sinon.spy() + const onDidChangeChildrenSpy = sinon.spy() + + rootNode.onDidChangeTreeItem(onDidChangeTreeItemSpy) + rootNode.onDidChangeChildren(onDidChangeChildrenSpy) + + rootNode.refresh() + + assert(onDidChangeTreeItemSpy.calledOnce) + assert(onDidChangeChildrenSpy.calledOnce) + }) + }) +}) + +describe('SelectSMUSProject', function () { + let mockDataZoneClient: sinon.SinonStubbedInstance + let mockProjectNode: sinon.SinonStubbedInstance + let createQuickPickStub: sinon.SinonStub + let executeCommandStub: sinon.SinonStub + + const testDomainId = 'test-domain-123' + const mockProject: DataZoneProject = { + id: 'project-123', + name: 'Test Project', + description: 'Test Description', + domainId: testDomainId, + updatedAt: new Date(), + } + + const mockProject2: DataZoneProject = { + id: 'project-456', + name: 'Another Project', + description: 'Another Description', + domainId: testDomainId, + updatedAt: new Date(Date.now() - 86400000), // 1 day ago + } + + beforeEach(function () { + // Create mock DataZone client + mockDataZoneClient = { + getDomainId: sinon.stub().returns(testDomainId), + listProjects: sinon.stub(), + fetchAllProjects: sinon.stub(), + } as any + + // Create mock project node + mockProjectNode = { + setProject: sinon.stub(), + getProject: sinon.stub().returns(undefined), + project: undefined, + } as any + + // Stub DataZoneClient static methods + sinon.stub(DataZoneClient, 'getInstance').returns(mockDataZoneClient as any) + + // Stub SmusAuthenticationProvider + sinon.stub(SmusAuthenticationProvider, 'fromContext').returns({ + isConnected: sinon.stub().returns(true), + isConnectionValid: sinon.stub().returns(true), + activeConnection: { domainId: testDomainId, ssoRegion: 'us-west-2' }, + getDomainId: sinon.stub().returns(testDomainId), + getDomainRegion: sinon.stub().returns('us-west-2'), + } as any) + + // Stub quickPick - return the project directly (not wrapped in an item) + const mockQuickPick = { + prompt: sinon.stub().resolves(mockProject), + } + createQuickPickStub = sinon.stub(pickerPrompter, 'createQuickPick').returns(mockQuickPick as any) + + // Stub vscode.commands.executeCommand + executeCommandStub = sinon.stub(vscode.commands, 'executeCommand') + }) + + afterEach(function () { + sinon.restore() + }) + + it('fetches all projects and sets the project for first time', async function () { + mockDataZoneClient.fetchAllProjects.resolves([mockProject, mockProject2]) + + const result = await selectSMUSProject(mockProjectNode as any) + + assert.strictEqual(result, mockProject) + assert.ok(mockDataZoneClient.fetchAllProjects.calledOnce) + assert.ok(mockDataZoneClient.fetchAllProjects.calledWith()) + assert.ok(createQuickPickStub.calledOnce) + assert.ok(mockProjectNode.setProject.calledOnce) + assert.ok(executeCommandStub.calledWith('aws.smus.rootView.refresh')) + assertTelemetry('smus_accessProject', { + result: 'Succeeded', + smusProjectId: mockProject.id, + }) + }) + + it('filters out GenerativeAIModelGovernanceProject', async function () { + const governanceProject: DataZoneProject = { + id: 'governance-123', + name: 'GenerativeAIModelGovernanceProject', + description: 'Governance project', + domainId: testDomainId, + updatedAt: new Date(), + } + + mockDataZoneClient.fetchAllProjects.resolves([mockProject, governanceProject, mockProject2]) + + await selectSMUSProject(mockProjectNode as any) + + // Verify that the governance project is filtered out + const quickPickCall = createQuickPickStub.getCall(0) + const items = quickPickCall.args[0] + assert.strictEqual(items.length, 2) // Should only have mockProject and mockProject2 + assert.ok(!items.some((item: any) => item.data.name === 'GenerativeAIModelGovernanceProject')) + }) + + it('handles no active connection', async function () { + sinon.restore() + sinon.stub(SmusAuthenticationProvider, 'fromContext').returns({ + activeConnection: undefined, + getDomainId: sinon.stub().returns(undefined), + } as any) + + const result = await selectSMUSProject(mockProjectNode as any) + assert.strictEqual(result, undefined) + + assertTelemetry('smus_accessProject', { + result: 'Succeeded', + }) + }) + + it('fetches all projects and switches the current project', async function () { + mockProjectNode = { + setProject: sinon.stub(), + getProject: sinon.stub().returns(mockProject), + project: mockProject, + } as any + mockDataZoneClient.fetchAllProjects.resolves([mockProject, mockProject2]) + + // Stub quickPick to return mockProject2 for the second test + const mockQuickPick = { + prompt: sinon.stub().resolves(mockProject2), + } + createQuickPickStub.restore() // Remove the previous stub + createQuickPickStub = sinon.stub(pickerPrompter, 'createQuickPick').returns(mockQuickPick as any) + + const result = await selectSMUSProject(mockProjectNode as any) + + assert.strictEqual(result, mockProject2) + assert.ok(mockDataZoneClient.fetchAllProjects.calledOnce) + assert.ok(mockDataZoneClient.fetchAllProjects.calledWith()) + assert.ok(createQuickPickStub.calledOnce) + assert.ok(mockProjectNode.setProject.calledOnce) + assert.ok(executeCommandStub.calledWith('aws.smus.rootView.refresh')) + assertTelemetry('smus_accessProject', { + result: 'Succeeded', + smusProjectId: mockProject2.id, + }) + }) + + it('shows message when no projects found', async function () { + mockDataZoneClient.fetchAllProjects.resolves([]) + + const result = await selectSMUSProject(mockProjectNode as any) + + assert.strictEqual(result, undefined) + assert.ok(!mockProjectNode.setProject.called) + }) + + it('handles API errors gracefully', async function () { + const error = new Error('API error') + mockDataZoneClient.fetchAllProjects.rejects(error) + + const result = await selectSMUSProject(mockProjectNode as any) + assert.strictEqual(result, undefined) + + assert.ok(!mockProjectNode.setProject.called) + assertTelemetry('smus_accessProject', { + result: 'Succeeded', + }) + }) + + it('handles case when user cancels project selection', async function () { + mockDataZoneClient.fetchAllProjects.resolves([mockProject, mockProject2]) + + // Make quickPick return undefined (user cancelled) + const mockQuickPick = { + prompt: sinon.stub().resolves(undefined), + } + createQuickPickStub.returns(mockQuickPick as any) + + const result = await selectSMUSProject(mockProjectNode as any) + + // Should return undefined + assert.strictEqual(result, undefined) + + // Verify project was not set + assert.ok(!mockProjectNode.setProject.called) + + // Verify refresh command was not called + assert.ok(!executeCommandStub.called) + }) + + it('handles empty projects list correctly', async function () { + mockDataZoneClient.fetchAllProjects.resolves([]) + + const result = await selectSMUSProject(mockProjectNode as any) + + assert.strictEqual(result, undefined) + assert.ok(mockDataZoneClient.fetchAllProjects.calledOnce) + assert.ok(!mockProjectNode.setProject.called) + assert.ok(!executeCommandStub.called) + }) +}) + +describe('selectSMUSProject - Additional Tests', function () { + let mockDataZoneClient: sinon.SinonStubbedInstance + let mockProjectNode: sinon.SinonStubbedInstance + let createQuickPickStub: sinon.SinonStub + let executeCommandStub: sinon.SinonStub + + const testDomainId = 'test-domain-123' + const mockProject: DataZoneProject = { + id: 'project-123', + name: 'Test Project', + description: 'Test Description', + domainId: testDomainId, + updatedAt: new Date(), + } + + beforeEach(function () { + mockDataZoneClient = { + getDomainId: sinon.stub().returns(testDomainId), + fetchAllProjects: sinon.stub(), + } as any + + mockProjectNode = { + setProject: sinon.stub(), + } as any + + sinon.stub(DataZoneClient, 'getInstance').returns(mockDataZoneClient as any) + sinon.stub(SmusAuthenticationProvider, 'fromContext').returns({ + activeConnection: { domainId: testDomainId, ssoRegion: 'us-west-2' }, + getDomainId: sinon.stub().returns(testDomainId), + getDomainRegion: sinon.stub().returns('us-west-2'), + } as any) + + const mockQuickPick = { + prompt: sinon.stub().resolves(mockProject), + } + createQuickPickStub = sinon.stub(pickerPrompter, 'createQuickPick').returns(mockQuickPick as any) + executeCommandStub = sinon.stub(vscode.commands, 'executeCommand') + }) + + afterEach(function () { + sinon.restore() + }) + + it('handles access denied error gracefully', async function () { + const accessDeniedError = new Error('Access denied') + accessDeniedError.name = 'AccessDeniedError' + mockDataZoneClient.fetchAllProjects.rejects(accessDeniedError) + + const result = await selectSMUSProject(mockProjectNode as any) + + assert.strictEqual(result, undefined) + assert.ok( + createQuickPickStub.calledWith([ + { + label: '$(error)', + description: "You don't have permissions to view projects. Please contact your administrator", + }, + ]) + ) + }) + + it('shows "No projects found" message when projects list is empty', async function () { + mockDataZoneClient.fetchAllProjects.resolves([]) + + const result = await selectSMUSProject(mockProjectNode as any) + + assert.strictEqual(result, undefined) + const testWindow = getTestWindow() + assert.ok(testWindow.shownMessages.some((msg) => msg.message === 'No projects found in the domain')) + assert.ok( + createQuickPickStub.calledWith([ + { + label: 'No projects found', + detail: '', + description: '', + data: {}, + }, + ]) + ) + }) + + it('handles invalid selected project object', async function () { + mockDataZoneClient.fetchAllProjects.resolves([mockProject]) + + // Mock quickPick to return an object with 'type' property (invalid selection) + const mockQuickPick = { + prompt: sinon.stub().resolves({ type: 'invalid', data: mockProject }), + } + createQuickPickStub.returns(mockQuickPick as any) + + const result = await selectSMUSProject(mockProjectNode as any) + + assert.deepStrictEqual(result, { type: 'invalid', data: mockProject }) + assert.ok(!mockProjectNode.setProject.called) + assert.ok(!executeCommandStub.called) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode.test.ts new file mode 100644 index 00000000000..a44b2ec3e7d --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode.test.ts @@ -0,0 +1,280 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { SagemakerUnifiedStudioSpaceNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode' +import { SageMakerUnifiedStudioSpacesParentNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode' +import { SagemakerClient, SagemakerSpaceApp } from '../../../../shared/clients/sagemaker' +import { SagemakerSpace } from '../../../../awsService/sagemaker/sagemakerSpace' + +describe('SagemakerUnifiedStudioSpaceNode', function () { + let spaceNode: SagemakerUnifiedStudioSpaceNode + let mockParent: SageMakerUnifiedStudioSpacesParentNode + let mockSagemakerClient: SagemakerClient + let mockSpaceApp: SagemakerSpaceApp + let mockSagemakerSpace: sinon.SinonStubbedInstance + let trackPendingNodeStub: sinon.SinonStub + + beforeEach(function () { + trackPendingNodeStub = sinon.stub() + mockParent = { + trackPendingNode: trackPendingNodeStub, + } as any + + mockSagemakerClient = { + describeApp: sinon.stub(), + describeSpace: sinon.stub(), + } as any + + mockSpaceApp = { + SpaceName: 'test-space', + DomainId: 'domain-123', + Status: 'InService', + DomainSpaceKey: 'domain-123:test-space', + App: { + AppName: 'test-app', + Status: 'InService', + }, + } as any + + mockSagemakerSpace = { + label: 'test-space (Running)', + description: 'Private space', + tooltip: new vscode.MarkdownString('Space tooltip'), + iconPath: { light: 'light-icon', dark: 'dark-icon' }, + contextValue: 'smusSpaceNode', + updateSpace: sinon.stub(), + setSpaceStatus: sinon.stub(), + isPending: sinon.stub().returns(false), + getStatus: sinon.stub().returns('Running'), + getAppStatus: sinon.stub().resolves('InService'), + name: 'test-space', + arn: 'arn:aws:sagemaker:us-west-2:123456789012:space/test-space', + getAppArn: sinon.stub().resolves('arn:aws:sagemaker:us-west-2:123456789012:app/test-app'), + getSpaceArn: sinon.stub().resolves('arn:aws:sagemaker:us-west-2:123456789012:space/test-space'), + updateSpaceAppStatus: sinon.stub().resolves(), + buildTooltip: sinon.stub().returns('Space tooltip'), + getAppIcon: sinon.stub().returns({ light: 'light-icon', dark: 'dark-icon' }), + DomainSpaceKey: 'domain-123:test-space', + } as any + + sinon.stub(SagemakerSpace.prototype, 'constructor' as any).returns(mockSagemakerSpace) + + spaceNode = new SagemakerUnifiedStudioSpaceNode( + mockParent, + mockSagemakerClient, + 'us-west-2', + mockSpaceApp, + true + ) + + // Replace the internal smSpace with our mock + ;(spaceNode as any).smSpace = mockSagemakerSpace + }) + + afterEach(function () { + sinon.restore() + }) + + describe('constructor', function () { + it('creates instance with correct properties', function () { + assert.strictEqual(spaceNode.id, 'smusSpaceNodetest-space') + assert.strictEqual(spaceNode.resource, spaceNode) + assert.strictEqual(spaceNode.regionCode, 'us-west-2') + assert.strictEqual(spaceNode.spaceApp, mockSpaceApp) + }) + }) + + describe('getTreeItem', function () { + it('returns correct tree item', function () { + const treeItem = spaceNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'test-space (Running)') + assert.strictEqual(treeItem.description, 'Private space') + assert.strictEqual(treeItem.contextValue, 'smusSpaceNode') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + assert.ok(treeItem.iconPath) + assert.ok(treeItem.tooltip) + }) + }) + + describe('getChildren', function () { + it('returns empty array', function () { + const children = spaceNode.getChildren() + assert.deepStrictEqual(children, []) + }) + }) + + describe('getParent', function () { + it('returns parent node', function () { + const parent = spaceNode.getParent() + assert.strictEqual(parent, mockParent) + }) + }) + + describe('refreshNode', function () { + it('fires change event', async function () { + const emitterSpy = sinon.spy(spaceNode['onDidChangeEmitter'], 'fire') + await spaceNode.refreshNode() + assert(emitterSpy.calledOnce) + }) + }) + + describe('updateSpace', function () { + it('updates space and tracks pending node when pending', function () { + mockSagemakerSpace.isPending.returns(true) + const newSpaceApp = { ...mockSpaceApp, Status: 'Pending' } + + spaceNode.updateSpace(newSpaceApp) + + assert(mockSagemakerSpace.updateSpace.calledWith(newSpaceApp)) + assert(trackPendingNodeStub.calledWith('domain-123:test-space')) + }) + + it('updates space without tracking when not pending', function () { + mockSagemakerSpace.isPending.returns(false) + const newSpaceApp = { ...mockSpaceApp, Status: 'InService' } + + spaceNode.updateSpace(newSpaceApp) + + assert(mockSagemakerSpace.updateSpace.calledWith(newSpaceApp)) + assert(trackPendingNodeStub.notCalled) + }) + }) + + describe('setSpaceStatus', function () { + it('delegates to SagemakerSpace', function () { + spaceNode.setSpaceStatus('InService', 'Running') + assert(mockSagemakerSpace.setSpaceStatus.calledWith('InService', 'Running')) + }) + }) + + describe('isPending', function () { + it('delegates to SagemakerSpace', function () { + const result = spaceNode.isPending() + assert(mockSagemakerSpace.isPending.called) + assert.strictEqual(result, false) + }) + }) + + describe('getStatus', function () { + it('delegates to SagemakerSpace', function () { + const result = spaceNode.getStatus() + assert(mockSagemakerSpace.getStatus.called) + assert.strictEqual(result, 'Running') + }) + }) + + describe('getAppStatus', function () { + it('delegates to SagemakerSpace', async function () { + const result = await spaceNode.getAppStatus() + assert(mockSagemakerSpace.getAppStatus.called) + assert.strictEqual(result, 'InService') + }) + }) + + describe('name property', function () { + it('returns space name', function () { + assert.strictEqual(spaceNode.name, 'test-space') + }) + }) + + describe('arn property', function () { + it('returns space arn', function () { + assert.strictEqual(spaceNode.arn, 'arn:aws:sagemaker:us-west-2:123456789012:space/test-space') + }) + }) + + describe('getAppArn', function () { + it('delegates to SagemakerSpace', async function () { + const result = await spaceNode.getAppArn() + assert(mockSagemakerSpace.getAppArn.called) + assert.strictEqual(result, 'arn:aws:sagemaker:us-west-2:123456789012:app/test-app') + }) + }) + + describe('getSpaceArn', function () { + it('delegates to SagemakerSpace', async function () { + const result = await spaceNode.getSpaceArn() + assert(mockSagemakerSpace.getSpaceArn.called) + assert.strictEqual(result, 'arn:aws:sagemaker:us-west-2:123456789012:space/test-space') + }) + }) + + describe('updateSpaceAppStatus', function () { + it('updates status and tracks pending node when pending', async function () { + mockSagemakerSpace.isPending.returns(true) + + await spaceNode.updateSpaceAppStatus() + + assert(mockSagemakerSpace.updateSpaceAppStatus.called) + assert(trackPendingNodeStub.calledWith('domain-123:test-space')) + }) + + it('updates status without tracking when not pending', async function () { + mockSagemakerSpace.isPending.returns(false) + + await spaceNode.updateSpaceAppStatus() + + assert(mockSagemakerSpace.updateSpaceAppStatus.called) + assert(trackPendingNodeStub.notCalled) + }) + }) + + describe('buildTooltip', function () { + it('delegates to SagemakerSpace', function () { + const result = spaceNode.buildTooltip() + assert(mockSagemakerSpace.buildTooltip.called) + assert.strictEqual(result, 'Space tooltip') + }) + }) + + describe('getAppIcon', function () { + it('delegates to SagemakerSpace', function () { + const result = spaceNode.getAppIcon() + assert(mockSagemakerSpace.getAppIcon.called) + assert.deepStrictEqual(result, { light: 'light-icon', dark: 'dark-icon' }) + }) + }) + + describe('DomainSpaceKey property', function () { + it('returns domain space key', function () { + assert.strictEqual(spaceNode.DomainSpaceKey, 'domain-123:test-space') + }) + }) + + describe('SagemakerSpace getContext for SMUS', function () { + it('returns awsSagemakerSpaceRunningNode for running SMUS space with undefined RemoteAccess', function () { + // Create a space app without RemoteAccess setting (undefined) + const smusSpaceApp = { + SpaceName: 'test-space', + DomainId: 'domain-123', + Status: 'InService', + DomainSpaceKey: 'domain-123:test-space', + App: { + AppName: 'test-app', + Status: 'InService', + }, + SpaceSettingsSummary: { + // RemoteAccess is undefined + }, + } as any + + // Create a real SagemakerSpace instance for SMUS to test the actual getContext logic + const realSagemakerSpace = new SagemakerSpace( + mockSagemakerClient, + 'us-west-2', + smusSpaceApp, + true // isSMUSSpace = true + ) + + const context = realSagemakerSpace.getContext() + + assert.strictEqual(context, 'awsSagemakerSpaceRunningNode') + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode.test.ts new file mode 100644 index 00000000000..31481e70953 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode.test.ts @@ -0,0 +1,421 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { SageMakerUnifiedStudioSpacesParentNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpacesParentNode' +import { SageMakerUnifiedStudioComputeNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioComputeNode' +import { SagemakerUnifiedStudioSpaceNode } from '../../../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode' +import { DataZoneClient } from '../../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { SagemakerClient } from '../../../../shared/clients/sagemaker' +import { SmusAuthenticationProvider } from '../../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' +import { getLogger } from '../../../../shared/logger/logger' +import { SmusUtils } from '../../../../sagemakerunifiedstudio/shared/smusUtils' + +describe('SageMakerUnifiedStudioSpacesParentNode', function () { + let spacesNode: SageMakerUnifiedStudioSpacesParentNode + let mockParent: SageMakerUnifiedStudioComputeNode + let mockExtensionContext: vscode.ExtensionContext + let mockAuthProvider: SmusAuthenticationProvider + let mockSagemakerClient: sinon.SinonStubbedInstance + let mockDataZoneClient: sinon.SinonStubbedInstance + + beforeEach(function () { + mockParent = {} as any + mockExtensionContext = { + extensionUri: vscode.Uri.file('/test'), + } as any + mockAuthProvider = { + activeConnection: { domainId: 'test-domain', ssoRegion: 'us-west-2' }, + } as any + mockSagemakerClient = sinon.createStubInstance(SagemakerClient) + mockSagemakerClient.fetchSpaceAppsAndDomains.resolves([new Map(), new Map()]) + + mockDataZoneClient = { + getInstance: sinon.stub(), + getUserId: sinon.stub(), + getDomainId: sinon.stub(), + getRegion: sinon.stub(), + getToolingEnvironmentId: sinon.stub(), + getEnvironmentDetails: sinon.stub(), + getToolingEnvironment: sinon.stub(), + } as any + + sinon.stub(DataZoneClient, 'getInstance').resolves(mockDataZoneClient as any) + sinon.stub(getLogger(), 'debug') + sinon.stub(getLogger(), 'error') + sinon.stub(SmusUtils, 'extractSSOIdFromUserId').returns('user-12345') + + spacesNode = new SageMakerUnifiedStudioSpacesParentNode( + mockParent, + 'project-123', + mockExtensionContext, + mockAuthProvider, + mockSagemakerClient as any + ) + }) + + afterEach(function () { + spacesNode.pollingSet.clear() + sinon.restore() + }) + + describe('constructor', function () { + it('creates instance with correct properties', function () { + assert.strictEqual(spacesNode.id, 'smusSpacesParentNode') + assert.strictEqual(spacesNode.resource, spacesNode) + }) + }) + + describe('getTreeItem', function () { + it('returns correct tree item', async function () { + const treeItem = await spacesNode.getTreeItem() + + assert.strictEqual(treeItem.label, 'Spaces') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.Expanded) + assert.strictEqual(treeItem.contextValue, 'smusSpacesNode') + assert.ok(treeItem.iconPath) + }) + }) + + describe('getParent', function () { + it('returns parent node', function () { + const parent = spacesNode.getParent() + assert.strictEqual(parent, mockParent) + }) + }) + + describe('getProjectId', function () { + it('returns project ID', function () { + assert.strictEqual(spacesNode.getProjectId(), 'project-123') + }) + }) + + describe('getAuthProvider', function () { + it('returns auth provider', function () { + assert.strictEqual(spacesNode.getAuthProvider(), mockAuthProvider) + }) + }) + + describe('refreshNode', function () { + it('fires change event', async function () { + const emitterSpy = sinon.spy(spacesNode['onDidChangeEmitter'], 'fire') + await spacesNode.refreshNode() + assert(emitterSpy.calledOnce) + }) + }) + + describe('trackPendingNode', function () { + it('adds node to polling set', function () { + const addSpy = sinon.spy(spacesNode.pollingSet, 'add') + spacesNode.trackPendingNode('test-key') + assert(addSpy.calledWith('test-key')) + }) + }) + + describe('getSpaceNodes', function () { + it('returns space node when found', function () { + const mockSpaceNode = {} as SagemakerUnifiedStudioSpaceNode + spacesNode['sagemakerSpaceNodes'].set('test-key', mockSpaceNode) + + const result = spacesNode.getSpaceNodes('test-key') + assert.strictEqual(result, mockSpaceNode) + }) + + it('throws error when node not found', function () { + assert.throws( + () => spacesNode.getSpaceNodes('non-existent'), + /Node with id non-existent from polling set not found/ + ) + }) + }) + + describe('getSageMakerDomainId', function () { + it('throws error when no active connection', async function () { + const mockAuthProviderNoConnection = { + activeConnection: undefined, + } as any + + const spacesNodeNoConnection = new SageMakerUnifiedStudioSpacesParentNode( + mockParent, + 'project-123', + mockExtensionContext, + mockAuthProviderNoConnection, + mockSagemakerClient as any + ) + + await assert.rejects( + async () => await spacesNodeNoConnection.getSageMakerDomainId(), + /No active connection found to get SageMaker domain ID/ + ) + }) + + it('throws error when DataZone client not initialized', async function () { + ;(DataZoneClient.getInstance as sinon.SinonStub).resolves(undefined) + + await assert.rejects( + async () => await spacesNode.getSageMakerDomainId(), + /DataZone client is not initialized/ + ) + }) + + it('throws error when tooling environment ID not found', async function () { + mockDataZoneClient.getDomainId.returns('domain-123') + const error = new Error('Failed to get tooling environment ID: Environment not found') + mockDataZoneClient.getToolingEnvironment.rejects(error) + + await assert.rejects( + async () => await spacesNode.getSageMakerDomainId(), + /Failed to get tooling environment ID: Environment not found/ + ) + }) + + it('throws error when no default environment found', async function () { + mockDataZoneClient.getDomainId.returns('domain-123') + const error = new Error('No default environment found for project') + mockDataZoneClient.getToolingEnvironment.rejects(error) + + await assert.rejects( + async () => await spacesNode.getSageMakerDomainId(), + /No default environment found for project/ + ) + }) + + it('throws error when SageMaker domain ID not found in resources', async function () { + mockDataZoneClient.getDomainId.returns('domain-123') + mockDataZoneClient.getToolingEnvironment.resolves({ + projectId: 'project-123', + domainId: 'domain-123', + createdBy: 'user', + name: 'test-env', + awsAccountRegion: 'us-west-2', + provisionedResources: [{ name: 'otherResource', value: 'value', type: 'OTHER' }], + } as any) + + await assert.rejects( + async () => await spacesNode.getSageMakerDomainId(), + /No SageMaker domain found in the tooling environment/ + ) + }) + + it('returns SageMaker domain ID when found', async function () { + mockDataZoneClient.getDomainId.returns('domain-123') + mockDataZoneClient.getToolingEnvironment.resolves({ + projectId: 'project-123', + domainId: 'domain-123', + createdBy: 'user', + name: 'test-env', + awsAccountRegion: 'us-west-2', + provisionedResources: [ + { + name: 'sageMakerDomainId', + value: 'sagemaker-domain-123', + type: 'SAGEMAKER_DOMAIN', + }, + ], + } as any) + + const result = await spacesNode.getSageMakerDomainId() + assert.strictEqual(result, 'sagemaker-domain-123') + }) + }) + + describe('getChildren', function () { + let updateChildrenStub: sinon.SinonStub + let mockSpaceNode1: SagemakerUnifiedStudioSpaceNode + let mockSpaceNode2: SagemakerUnifiedStudioSpaceNode + + beforeEach(function () { + updateChildrenStub = sinon.stub(spacesNode as any, 'updateChildren').resolves() + mockSpaceNode1 = { id: 'space1' } as any + mockSpaceNode2 = { id: 'space2' } as any + }) + + it('returns space nodes when spaces exist', async function () { + spacesNode['sagemakerSpaceNodes'].set('space1', mockSpaceNode1) + spacesNode['sagemakerSpaceNodes'].set('space2', mockSpaceNode2) + + const children = await spacesNode.getChildren() + + assert.strictEqual(children.length, 2) + assert(children.includes(mockSpaceNode1)) + assert(children.includes(mockSpaceNode2)) + assert(updateChildrenStub.calledOnce) + }) + + it('returns no spaces found node when no spaces exist', async function () { + const children = await spacesNode.getChildren() + + assert.strictEqual(children.length, 1) + const noSpacesNode = children[0] + assert.strictEqual(noSpacesNode.id, 'smusNoSpaces') + + const treeItem = await noSpacesNode.getTreeItem() + assert.strictEqual(treeItem.label, '[No Spaces found]') + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + }) + + it('returns no spaces found node when updateChildren throws error', async function () { + updateChildrenStub.rejects(new Error('Update failed')) + + const children = await spacesNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].id, 'smusNoSpaces') + }) + + it('returns access denied node when AccessDeniedException is thrown', async function () { + const accessDeniedError = new Error('Access denied') + accessDeniedError.name = 'AccessDeniedException' + updateChildrenStub.rejects(accessDeniedError) + + const children = await spacesNode.getChildren() + + assert.strictEqual(children.length, 1) + const accessDeniedNode = children[0] + assert.strictEqual(accessDeniedNode.id, 'smusAccessDenied') + + const treeItem = await accessDeniedNode.getTreeItem() + assert.ok(treeItem) + assert.strictEqual( + treeItem.label, + "You don't have permission to view spaces. Please contact your administrator." + ) + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + assert.ok(treeItem.iconPath) + assert.strictEqual((treeItem.iconPath as vscode.ThemeIcon).id, 'error') + }) + }) + + describe('updatePendingNodes', function () { + it('updates pending space nodes and removes from polling set when not pending', async function () { + const mockSpaceNode = { + DomainSpaceKey: 'test-key', + updateSpaceAppStatus: sinon.stub().resolves(), + isPending: sinon.stub().returns(false), + refreshNode: sinon.stub().resolves(), + } as any + + spacesNode['sagemakerSpaceNodes'].set('test-key', mockSpaceNode) + spacesNode.pollingSet.add('test-key') + + await spacesNode['updatePendingNodes']() + + assert(mockSpaceNode.updateSpaceAppStatus.calledOnce) + assert(mockSpaceNode.refreshNode.calledOnce) + assert(!spacesNode.pollingSet.has('test-key')) + }) + + it('keeps pending nodes in polling set', async function () { + const mockSpaceNode = { + DomainSpaceKey: 'test-key', + updateSpaceAppStatus: sinon.stub().resolves(), + isPending: sinon.stub().returns(true), + refreshNode: sinon.stub().resolves(), + } as any + + spacesNode['sagemakerSpaceNodes'].set('test-key', mockSpaceNode) + spacesNode.pollingSet.add('test-key') + + await spacesNode['updatePendingNodes']() + + assert(mockSpaceNode.updateSpaceAppStatus.calledOnce) + assert(mockSpaceNode.refreshNode.notCalled) + assert(spacesNode.pollingSet.has('test-key')) + }) + }) + + describe('getAccessDeniedChildren', function () { + it('returns access denied tree node with error icon', async function () { + const accessDeniedChildren = spacesNode['getAccessDeniedChildren']() + + assert.strictEqual(accessDeniedChildren.length, 1) + const accessDeniedNode = accessDeniedChildren[0] + assert.strictEqual(accessDeniedNode.id, 'smusAccessDenied') + + const treeItem = await accessDeniedNode.getTreeItem() + assert.ok(treeItem) + assert.strictEqual( + treeItem.label, + "You don't have permission to view spaces. Please contact your administrator." + ) + assert.strictEqual(treeItem.collapsibleState, vscode.TreeItemCollapsibleState.None) + assert.ok(treeItem.iconPath) + assert.strictEqual((treeItem.iconPath as vscode.ThemeIcon).id, 'error') + }) + }) + + describe('updateChildren', function () { + beforeEach(function () { + mockDataZoneClient.getUserId.resolves('ABCA4NU3S7PEOLDQPLXYZ:user-12345678-d061-70a4-0bf2-eeee67a6ab12') + mockDataZoneClient.getDomainId.returns('domain-123') + mockDataZoneClient.getRegion.returns('us-west-2') + mockDataZoneClient.getToolingEnvironment.resolves({ + awsAccountRegion: 'us-west-2', + provisionedResources: [{ name: 'sageMakerDomainId', value: 'sagemaker-domain-123' }], + } as any) + }) + + it('filters spaces by current user ownership', async function () { + const spaceApps = new Map([ + [ + 'space1', + { + DomainId: 'domain-123', + OwnershipSettingsSummary: { OwnerUserProfileName: 'user-12345' }, + DomainSpaceKey: 'space1', + }, + ], + [ + 'space2', + { + DomainId: 'domain-123', + OwnershipSettingsSummary: { OwnerUserProfileName: 'other-user' }, + DomainSpaceKey: 'space2', + }, + ], + ]) + const domains = new Map([['domain-123', { DomainId: 'domain-123' }]]) + + mockSagemakerClient.fetchSpaceAppsAndDomains.resolves([spaceApps, domains]) + + await spacesNode['updateChildren']() + + assert.strictEqual(spacesNode['spaceApps'].size, 1) + assert(spacesNode['spaceApps'].has('space1')) + assert(!spacesNode['spaceApps'].has('space2')) + }) + + it('creates space nodes for filtered spaces', async function () { + const spaceApps = new Map([ + [ + 'space1', + { + DomainId: 'domain-123', + OwnershipSettingsSummary: { OwnerUserProfileName: 'user-12345' }, + DomainSpaceKey: 'space1', + }, + ], + ]) + const domains = new Map([['domain-123', { DomainId: 'domain-123' }]]) + + mockSagemakerClient.fetchSpaceAppsAndDomains.resolves([spaceApps, domains]) + + await spacesNode['updateChildren']() + + assert.strictEqual(spacesNode['sagemakerSpaceNodes'].size, 1) + assert(spacesNode['sagemakerSpaceNodes'].has('space1')) + }) + + it('throws AccessDeniedException when fetchSpaceAppsAndDomains fails with access denied', async function () { + const accessDeniedError = new Error('Access denied to spaces') + accessDeniedError.name = 'AccessDeniedException' + mockSagemakerClient.fetchSpaceAppsAndDomains.rejects(accessDeniedError) + + await assert.rejects(async () => await spacesNode['updateChildren'](), /Access denied to spaces/) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/utils.test.ts b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/utils.test.ts new file mode 100644 index 00000000000..cd92aa42981 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/explorer/nodes/utils.test.ts @@ -0,0 +1,252 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as vscode from 'vscode' +import { + getLabel, + isLeafNode, + getIconForNodeType, + createTreeItem, + createColumnTreeItem, + createErrorTreeItem, + isRedLakeDatabase, + getTooltip, + getRedshiftTypeFromHost, +} from '../../../../sagemakerunifiedstudio/explorer/nodes/utils' +import { NodeType, ConnectionType, RedshiftType } from '../../../../sagemakerunifiedstudio/explorer/nodes/types' + +describe('utils', function () { + describe('getLabel', function () { + it('should return container labels for container nodes', function () { + assert.strictEqual(getLabel({ id: 'test', nodeType: NodeType.REDSHIFT_TABLE, isContainer: true }), 'Tables') + assert.strictEqual(getLabel({ id: 'test', nodeType: NodeType.REDSHIFT_VIEW, isContainer: true }), 'Views') + assert.strictEqual( + getLabel({ id: 'test', nodeType: NodeType.REDSHIFT_FUNCTION, isContainer: true }), + 'Functions' + ) + assert.strictEqual( + getLabel({ id: 'test', nodeType: NodeType.REDSHIFT_STORED_PROCEDURE, isContainer: true }), + 'Stored Procedures' + ) + }) + + it('should return path label when available', function () { + assert.strictEqual( + getLabel({ id: 'test', nodeType: NodeType.S3_FILE, path: { label: 'custom-label' } }), + 'custom-label' + ) + }) + + it('should return S3 folder name with trailing slash', function () { + assert.strictEqual( + getLabel({ id: 'test', nodeType: NodeType.S3_FOLDER, path: { key: 'folder/subfolder/' } }), + 'subfolder/' + ) + }) + + it('should return S3 file name', function () { + assert.strictEqual( + getLabel({ id: 'test', nodeType: NodeType.S3_FILE, path: { key: 'folder/file.txt' } }), + 'file.txt' + ) + }) + + it('should return last part of ID as fallback', function () { + assert.strictEqual(getLabel({ id: 'parent/child/node', nodeType: NodeType.CONNECTION }), 'node') + }) + }) + + describe('isLeafNode', function () { + it('should return false for container nodes', function () { + assert.strictEqual(isLeafNode({ nodeType: NodeType.REDSHIFT_TABLE, isContainer: true }), false) + }) + + it('should return true for leaf node types', function () { + assert.strictEqual(isLeafNode({ nodeType: NodeType.S3_FILE }), true) + assert.strictEqual(isLeafNode({ nodeType: NodeType.REDSHIFT_COLUMN }), true) + assert.strictEqual(isLeafNode({ nodeType: NodeType.ERROR }), true) + assert.strictEqual(isLeafNode({ nodeType: NodeType.LOADING }), true) + assert.strictEqual(isLeafNode({ nodeType: NodeType.EMPTY }), true) + }) + + it('should return false for non-leaf node types', function () { + assert.strictEqual(isLeafNode({ nodeType: NodeType.CONNECTION }), false) + assert.strictEqual(isLeafNode({ nodeType: NodeType.REDSHIFT_CLUSTER }), false) + }) + }) + + describe('getIconForNodeType', function () { + it('should return correct icons for different node types', function () { + const errorIcon = getIconForNodeType(NodeType.ERROR) + const loadingIcon = getIconForNodeType(NodeType.LOADING) + + assert.ok(errorIcon instanceof vscode.ThemeIcon) + assert.strictEqual((errorIcon as vscode.ThemeIcon).id, 'error') + assert.ok(loadingIcon instanceof vscode.ThemeIcon) + assert.strictEqual((loadingIcon as vscode.ThemeIcon).id, 'loading~spin') + }) + + it('should return different icons for container vs non-container nodes', function () { + const containerIcon = getIconForNodeType(NodeType.REDSHIFT_TABLE, true) + const nonContainerIcon = getIconForNodeType(NodeType.REDSHIFT_TABLE, false) + + assert.ok(containerIcon instanceof vscode.ThemeIcon) + assert.ok(nonContainerIcon instanceof vscode.ThemeIcon) + assert.strictEqual((containerIcon as vscode.ThemeIcon).id, 'table') + assert.strictEqual((nonContainerIcon as vscode.ThemeIcon).id, 'aws-redshift-table') + }) + + it('should return custom icon for GLUE_CATALOG', function () { + const catalogIcon = getIconForNodeType(NodeType.GLUE_CATALOG) + + // The catalog icon should be a custom icon, not a ThemeIcon + assert.ok(catalogIcon) + // We can't easily test the exact icon path in unit tests, but we can verify it's not a ThemeIcon + assert.ok( + !(catalogIcon instanceof vscode.ThemeIcon) || + (catalogIcon as any).id === 'aws-sagemakerunifiedstudio-catalog' + ) + }) + }) + + describe('createTreeItem', function () { + it('should create tree item with correct properties', function () { + const item = createTreeItem('Test Label', NodeType.CONNECTION, false, false, 'Test Tooltip') + + assert.strictEqual(item.label, 'Test Label') + assert.strictEqual(item.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed) + assert.strictEqual(item.contextValue, NodeType.CONNECTION) + assert.strictEqual(item.tooltip, 'Test Tooltip') + }) + + it('should create leaf node with None collapsible state', function () { + const item = createTreeItem('Leaf Node', NodeType.S3_FILE, true) + + assert.strictEqual(item.collapsibleState, vscode.TreeItemCollapsibleState.None) + }) + }) + + describe('createColumnTreeItem', function () { + it('should create column tree item with type description', function () { + const item = createColumnTreeItem('column_name', 'VARCHAR(255)', NodeType.REDSHIFT_COLUMN) + + assert.strictEqual(item.label, 'column_name') + assert.strictEqual(item.description, 'VARCHAR(255)') + assert.strictEqual(item.collapsibleState, vscode.TreeItemCollapsibleState.None) + assert.strictEqual(item.contextValue, NodeType.REDSHIFT_COLUMN) + assert.strictEqual(item.tooltip, 'column_name: VARCHAR(255)') + }) + }) + + describe('createErrorTreeItem', function () { + it('should create error tree item', function () { + const item = createErrorTreeItem('Error message') + + assert.strictEqual(item.label, 'Error message') + assert.strictEqual(item.collapsibleState, vscode.TreeItemCollapsibleState.None) + assert.ok(item.iconPath instanceof vscode.ThemeIcon) + assert.strictEqual((item.iconPath as vscode.ThemeIcon).id, 'error') + }) + }) + + describe('isRedLakeDatabase', function () { + it('should return true for RedLake database names', function () { + assert.strictEqual(isRedLakeDatabase('database@catalog'), true) + assert.strictEqual(isRedLakeDatabase('my-db@my-catalog'), true) + assert.strictEqual(isRedLakeDatabase('test_db@test_catalog'), true) + }) + + it('should return false for regular database names', function () { + assert.strictEqual(isRedLakeDatabase('regular_database'), false) + assert.strictEqual(isRedLakeDatabase('dev'), false) + assert.strictEqual(isRedLakeDatabase(''), false) + assert.strictEqual(isRedLakeDatabase(undefined), false) + }) + }) + + describe('getTooltip', function () { + it('should return correct tooltip for connection nodes', function () { + const redshiftData = { + id: 'conn1', + nodeType: NodeType.CONNECTION, + connectionType: ConnectionType.REDSHIFT, + } + const s3Data = { + id: 'conn2', + nodeType: NodeType.CONNECTION, + connectionType: ConnectionType.S3, + } + + assert.strictEqual(getTooltip(redshiftData), 'Redshift Connection: conn1') + assert.strictEqual(getTooltip(s3Data), 'Connection: conn2\nType: S3') + }) + + it('should return correct tooltip for S3 nodes', function () { + const bucketData = { + id: 'bucket1', + nodeType: NodeType.S3_BUCKET, + path: { bucket: 'my-bucket' }, + } + const fileData = { + id: 'file1', + nodeType: NodeType.S3_FILE, + path: { bucket: 'my-bucket', key: 'folder/file.txt' }, + } + + assert.strictEqual(getTooltip(bucketData), 'S3 Bucket: my-bucket') + assert.strictEqual(getTooltip(fileData), 'File: file.txt\nBucket: my-bucket') + }) + + it('should return correct tooltip for Redshift container nodes', function () { + const containerData = { + id: 'tables', + nodeType: NodeType.REDSHIFT_TABLE, + isContainer: true, + path: { schema: 'public' }, + } + + assert.strictEqual(getTooltip(containerData), 'Tables in public') + }) + + it('should return correct tooltip for Redshift object nodes', function () { + const tableData = { + id: 'table1', + nodeType: NodeType.REDSHIFT_TABLE, + path: { schema: 'public' }, + } + + assert.strictEqual(getTooltip(tableData), 'Table: public.table1') + }) + }) + + describe('getRedshiftTypeFromHost', function () { + it('should return undefined for invalid hosts', function () { + assert.strictEqual(getRedshiftTypeFromHost(undefined), undefined) + assert.strictEqual(getRedshiftTypeFromHost(''), undefined) + assert.strictEqual(getRedshiftTypeFromHost('invalid-host'), undefined) + }) + + it('should identify serverless hosts', function () { + const serverlessHost = 'workgroup.123456789012.us-east-1.redshift-serverless.amazonaws.com' + assert.strictEqual(getRedshiftTypeFromHost(serverlessHost), RedshiftType.Serverless) + }) + + it('should identify cluster hosts', function () { + const clusterHost = 'cluster.123456789012.us-east-1.redshift.amazonaws.com' + assert.strictEqual(getRedshiftTypeFromHost(clusterHost), RedshiftType.Cluster) + }) + + it('should handle hosts with port numbers', function () { + const hostWithPort = 'cluster.123456789012.us-east-1.redshift.amazonaws.com:5439' + assert.strictEqual(getRedshiftTypeFromHost(hostWithPort), RedshiftType.Cluster) + }) + + it('should return undefined for unrecognized domains', function () { + const unknownHost = 'host.example.com' + assert.strictEqual(getRedshiftTypeFromHost(unknownHost), undefined) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/client/clientStore.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/client/clientStore.test.ts new file mode 100644 index 00000000000..e2c14ace96a --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/client/clientStore.test.ts @@ -0,0 +1,148 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { ConnectionClientStore } from '../../../../sagemakerunifiedstudio/shared/client/connectionClientStore' +import { S3Client } from '../../../../sagemakerunifiedstudio/shared/client/s3Client' +import { SQLWorkbenchClient } from '../../../../sagemakerunifiedstudio/shared/client/sqlWorkbenchClient' +import { GlueClient } from '../../../../sagemakerunifiedstudio/shared/client/glueClient' +import { GlueCatalogClient } from '../../../../sagemakerunifiedstudio/shared/client/glueCatalogClient' +import { ConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' + +describe('ClientStore', function () { + let sandbox: sinon.SinonSandbox + let clientStore: ConnectionClientStore + + const mockCredentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + clientStore = ConnectionClientStore.getInstance() + }) + + afterEach(function () { + sandbox.restore() + clientStore.clearAll() + }) + + describe('getInstance', function () { + it('should return singleton instance', function () { + const instance1 = ConnectionClientStore.getInstance() + const instance2 = ConnectionClientStore.getInstance() + assert.strictEqual(instance1, instance2) + }) + }) + + describe('getClient', function () { + it('should create and cache client', function () { + const factory = sandbox.stub().returns({ test: 'client' }) + + const client1 = clientStore.getClient('conn-1', 'TestClient', factory) + const client2 = clientStore.getClient('conn-1', 'TestClient', factory) + + assert.strictEqual(client1, client2) + assert.ok(factory.calledOnce) + }) + + it('should create separate clients for different connections', function () { + const factory = sandbox.stub() + factory.onFirstCall().returns({ test: 'client1' }) + factory.onSecondCall().returns({ test: 'client2' }) + + const client1 = clientStore.getClient('conn-1', 'TestClient', factory) + const client2 = clientStore.getClient('conn-2', 'TestClient', factory) + + assert.notStrictEqual(client1, client2) + assert.ok(factory.calledTwice) + }) + }) + + describe('getS3Client', function () { + it('should create S3Client with credentials provider', function () { + sandbox.stub(S3Client.prototype, 'constructor' as any) + + const client = clientStore.getS3Client( + 'conn-1', + 'us-east-1', + mockCredentialsProvider as ConnectionCredentialsProvider + ) + + assert.ok(client instanceof S3Client) + }) + }) + + describe('getSQLWorkbenchClient', function () { + it('should create SQLWorkbenchClient with credentials provider', function () { + const stub = sandbox.stub(SQLWorkbenchClient, 'createWithCredentials').returns({} as any) + + clientStore.getSQLWorkbenchClient( + 'conn-1', + 'us-east-1', + mockCredentialsProvider as ConnectionCredentialsProvider + ) + + assert.ok(stub.calledOnce) + }) + }) + + describe('getGlueClient', function () { + it('should create GlueClient with credentials provider', function () { + sandbox.stub(GlueClient.prototype, 'constructor' as any) + + const client = clientStore.getGlueClient( + 'conn-1', + 'us-east-1', + mockCredentialsProvider as ConnectionCredentialsProvider + ) + + assert.ok(client instanceof GlueClient) + }) + }) + + describe('getGlueCatalogClient', function () { + it('should create GlueCatalogClient with credentials provider', function () { + const stub = sandbox.stub(GlueCatalogClient, 'createWithCredentials').returns({} as any) + + clientStore.getGlueCatalogClient( + 'conn-1', + 'us-east-1', + mockCredentialsProvider as ConnectionCredentialsProvider + ) + + assert.ok(stub.calledOnce) + }) + }) + + describe('clearConnection', function () { + it('should clear cached clients for specific connection', function () { + const factory = sandbox.stub().returns({ test: 'client' }) + + clientStore.getClient('conn-1', 'TestClient', factory) + clientStore.clearConnection('conn-1') + clientStore.getClient('conn-1', 'TestClient', factory) + + assert.strictEqual(factory.callCount, 2) + }) + }) + + describe('clearAll', function () { + it('should clear all cached clients', function () { + const factory = sandbox.stub().returns({ test: 'client' }) + + clientStore.getClient('conn-1', 'TestClient', factory) + clientStore.clearAll() + clientStore.getClient('conn-1', 'TestClient', factory) + + assert.strictEqual(factory.callCount, 2) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/client/credentialsAdapter.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/client/credentialsAdapter.test.ts new file mode 100644 index 00000000000..cfb2cfbce6e --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/client/credentialsAdapter.test.ts @@ -0,0 +1,53 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import * as AWS from 'aws-sdk' +import { adaptConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/shared/client/credentialsAdapter' + +describe('credentialsAdapter', function () { + let sandbox: sinon.SinonSandbox + let mockConnectionCredentialsProvider: any + + beforeEach(function () { + sandbox = sinon.createSandbox() + mockConnectionCredentialsProvider = { + getCredentials: sandbox.stub(), + } + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('adaptConnectionCredentialsProvider', function () { + it('should create CredentialProviderChain', function () { + const chain = adaptConnectionCredentialsProvider(mockConnectionCredentialsProvider) + assert.ok(chain instanceof AWS.CredentialProviderChain) + }) + + it('should create credentials with provider function', function () { + const chain = adaptConnectionCredentialsProvider(mockConnectionCredentialsProvider) + assert.ok(chain.providers) + assert.strictEqual(chain.providers.length, 1) + assert.strictEqual(typeof chain.providers[0], 'function') + }) + + it('should create AWS Credentials object', function () { + const chain = adaptConnectionCredentialsProvider(mockConnectionCredentialsProvider) + const provider = chain.providers[0] as () => AWS.Credentials + const credentials = provider() + assert.ok(credentials instanceof AWS.Credentials) + }) + + it('should set needsRefresh to always return true', function () { + const chain = adaptConnectionCredentialsProvider(mockConnectionCredentialsProvider) + const provider = chain.providers[0] as () => AWS.Credentials + const credentials = provider() + assert.strictEqual(credentials.needsRefresh(), true) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/client/datazoneClient.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/client/datazoneClient.test.ts new file mode 100644 index 00000000000..38dbd5e33f5 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/client/datazoneClient.test.ts @@ -0,0 +1,483 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { DataZoneClient } from '../../../../sagemakerunifiedstudio/shared/client/datazoneClient' +import { SmusAuthenticationProvider } from '../../../../sagemakerunifiedstudio/auth/providers/smusAuthenticationProvider' +import { GetEnvironmentCommandOutput } from '@aws-sdk/client-datazone/dist-types/commands/GetEnvironmentCommand' + +describe('DataZoneClient', () => { + let dataZoneClient: DataZoneClient + let mockAuthProvider: any + const testDomainId = 'dzd_domainId' + const testRegion = 'us-east-2' + + beforeEach(async () => { + // Create mock connection object + const mockConnection = { + domainId: testDomainId, + ssoRegion: testRegion, + } + + // Create mock auth provider + mockAuthProvider = { + isConnected: sinon.stub().returns(true), + getDomainId: sinon.stub().returns(testDomainId), + getDomainRegion: sinon.stub().returns(testRegion), + activeConnection: mockConnection, + onDidChangeActiveConnection: sinon.stub().returns({ + dispose: sinon.stub(), + }), + } as any + + // Set up the DataZoneClient using getInstance since constructor is private + DataZoneClient.dispose() + dataZoneClient = await DataZoneClient.getInstance(mockAuthProvider) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('getInstance', () => { + it('should return singleton instance', async () => { + const instance1 = await DataZoneClient.getInstance(mockAuthProvider) + const instance2 = await DataZoneClient.getInstance(mockAuthProvider) + + assert.strictEqual(instance1, instance2) + }) + + it('should create new instance after dispose', async () => { + const instance1 = await DataZoneClient.getInstance(mockAuthProvider) + DataZoneClient.dispose() + const instance2 = await DataZoneClient.getInstance(mockAuthProvider) + + assert.notStrictEqual(instance1, instance2) + }) + }) + + describe('dispose', () => { + it('should clear singleton instance', async () => { + const instance = await DataZoneClient.getInstance(mockAuthProvider) + DataZoneClient.dispose() + + // Should create new instance after dispose + const newInstance = await DataZoneClient.getInstance(mockAuthProvider) + assert.notStrictEqual(instance, newInstance) + }) + }) + + describe('getRegion', () => { + it('should return configured region', () => { + const result = dataZoneClient.getRegion() + assert.strictEqual(typeof result, 'string') + assert.ok(result.length > 0) + }) + }) + + describe('listProjects', () => { + it('should list projects with pagination', async () => { + const mockDataZone = { + listProjects: sinon.stub().resolves({ + items: [ + { + id: 'project-1', + name: 'Project 1', + description: 'First project', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-02'), + }, + ], + nextToken: 'next-token', + }), + } + + // Mock the getDataZoneClient method + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + const result = await dataZoneClient.listProjects({ + maxResults: 10, + }) + + assert.strictEqual(result.projects.length, 1) + assert.strictEqual(result.projects[0].id, 'project-1') + assert.strictEqual(result.projects[0].name, 'Project 1') + assert.strictEqual(result.projects[0].domainId, testDomainId) + assert.strictEqual(result.nextToken, 'next-token') + }) + + it('should handle empty results', async () => { + const mockDataZone = { + listProjects: sinon.stub().resolves({ + items: [], + nextToken: undefined, + }), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + const result = await dataZoneClient.listProjects() + + assert.strictEqual(result.projects.length, 0) + assert.strictEqual(result.nextToken, undefined) + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + sinon.stub(dataZoneClient as any, 'getDataZoneClient').rejects(error) + + await assert.rejects(() => dataZoneClient.listProjects(), error) + }) + }) + + describe('getProjectDefaultEnvironmentCreds', () => { + it('should get environment credentials for project', async () => { + const mockCredentials = { + accessKeyId: 'AKIATEST', + secretAccessKey: 'secret', + sessionToken: 'token', + } + + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().resolves({ + items: [{ id: 'blueprint-1', name: 'Tooling' }], + }), + listEnvironments: sinon.stub().resolves({ + items: [{ id: 'env-1', name: 'Tooling' }], + }), + getEnvironmentCredentials: sinon.stub().resolves(mockCredentials), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + const result = await dataZoneClient.getProjectDefaultEnvironmentCreds('project-1') + + assert.deepStrictEqual(result, mockCredentials) + assert.ok( + mockDataZone.listEnvironmentBlueprints.calledWith({ + domainIdentifier: testDomainId, + managed: true, + name: 'Tooling', + }) + ) + assert.ok( + mockDataZone.listEnvironments.calledWith({ + domainIdentifier: testDomainId, + projectIdentifier: 'project-1', + environmentBlueprintIdentifier: 'blueprint-1', + provider: 'Amazon SageMaker', + }) + ) + assert.ok( + mockDataZone.getEnvironmentCredentials.calledWith({ + domainIdentifier: testDomainId, + environmentIdentifier: 'env-1', + }) + ) + }) + + it('should throw error when tooling blueprint not found', async () => { + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().resolves({ + items: [], + }), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + await assert.rejects( + () => dataZoneClient.getProjectDefaultEnvironmentCreds('project-1'), + /Failed to get tooling blueprint/ + ) + }) + + it('should throw error when default environment not found', async () => { + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().resolves({ + items: [{ id: 'blueprint-1', name: 'Tooling' }], + }), + listEnvironments: sinon.stub().resolves({ + items: [], + }), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + await assert.rejects( + () => dataZoneClient.getProjectDefaultEnvironmentCreds('project-1'), + /Failed to find default Tooling environment/ + ) + }) + }) + + describe('fetchAllProjects', function () { + it('fetches all projects by handling pagination', async function () { + const client = await DataZoneClient.getInstance(mockAuthProvider) + + // Create a stub for listProjects that returns paginated results + const listProjectsStub = sinon.stub() + + // First call returns first page with nextToken + listProjectsStub.onFirstCall().resolves({ + projects: [ + { + id: 'project-1', + name: 'Project 1', + description: 'First project', + domainId: testDomainId, + }, + ], + nextToken: 'next-page-token', + }) + + // Second call returns second page with no nextToken + listProjectsStub.onSecondCall().resolves({ + projects: [ + { + id: 'project-2', + name: 'Project 2', + description: 'Second project', + domainId: testDomainId, + }, + ], + nextToken: undefined, + }) + + // Replace the listProjects method with our stub + client.listProjects = listProjectsStub + + // Call fetchAllProjects + const result = await client.fetchAllProjects() + + // Verify results + assert.strictEqual(result.length, 2) + assert.strictEqual(result[0].id, 'project-1') + assert.strictEqual(result[1].id, 'project-2') + + // Verify listProjects was called correctly + assert.strictEqual(listProjectsStub.callCount, 2) + assert.deepStrictEqual(listProjectsStub.firstCall.args[0], { + maxResults: 50, + nextToken: undefined, + }) + assert.deepStrictEqual(listProjectsStub.secondCall.args[0], { + maxResults: 50, + nextToken: 'next-page-token', + }) + }) + + it('returns empty array when no projects found', async function () { + const client = await DataZoneClient.getInstance(mockAuthProvider) + + // Create a stub for listProjects that returns empty results + const listProjectsStub = sinon.stub().resolves({ + projects: [], + nextToken: undefined, + }) + + // Replace the listProjects method with our stub + client.listProjects = listProjectsStub + + // Call fetchAllProjects + const result = await client.fetchAllProjects() + + // Verify results + assert.strictEqual(result.length, 0) + assert.strictEqual(listProjectsStub.callCount, 1) + }) + + it('handles errors gracefully', async function () { + const client = await DataZoneClient.getInstance(mockAuthProvider) + + // Create a stub for listProjects that throws an error + const listProjectsStub = sinon.stub().rejects(new Error('API error')) + + // Replace the listProjects method with our stub + client.listProjects = listProjectsStub + + // Call fetchAllProjects and expect it to throw + await assert.rejects(() => client.fetchAllProjects(), /API error/) + }) + }) + + describe('getToolingEnvironmentId', () => { + it('should get tooling environment ID successfully', async () => { + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().resolves({ + items: [{ id: 'blueprint-1', name: 'Tooling' }], + }), + listEnvironments: sinon.stub().resolves({ + items: [{ id: 'env-1', name: 'Tooling' }], + }), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + const result = await dataZoneClient.getToolingEnvironmentId('domain-1', 'project-1') + + assert.strictEqual(result, 'env-1') + }) + + it('should handle listEnvironmentBlueprints error', async () => { + const error = new Error('Blueprint API Error') + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().rejects(error), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + await assert.rejects(() => dataZoneClient.getToolingEnvironmentId('domain-1', 'project-1'), error) + }) + + it('should handle listEnvironments error', async () => { + const error = new Error('Environment API Error') + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().resolves({ + items: [{ id: 'blueprint-1', name: 'Tooling' }], + }), + listEnvironments: sinon.stub().rejects(error), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + await assert.rejects(() => dataZoneClient.getToolingEnvironmentId('domain-1', 'project-1'), error) + }) + }) + + describe('getToolingEnvironment', () => { + beforeEach(() => { + mockAuthProvider = {} as SmusAuthenticationProvider + }) + + it('should return environment details when successful', async () => { + const mockEnvironment: GetEnvironmentCommandOutput = { + id: 'env-123', + awsAccountRegion: 'us-east-1', + projectId: undefined, + domainId: undefined, + createdBy: undefined, + name: undefined, + provider: undefined, + $metadata: {}, + } + + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().resolves({ + items: [{ id: 'blueprint-1', name: 'Tooling' }], + }), + listEnvironments: sinon.stub().resolves({ + items: [{ id: 'env-1', name: 'Tooling' }], + }), + getEnvironment: sinon.stub().resolves(mockEnvironment), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + const result = await dataZoneClient.getToolingEnvironment('project-123') + + assert.strictEqual(result, mockEnvironment) + }) + + it('should throw error when no tooling environment ID found', async () => { + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().resolves({ + items: [{ id: 'blueprint-1', name: 'Tooling' }], + }), + listEnvironments: sinon.stub().resolves({ + items: [], + }), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + await assert.rejects( + () => dataZoneClient.getToolingEnvironment('project-123'), + /Failed to get tooling environment ID: No default Tooling environment found for project/ + ) + }) + + it('should throw error when getToolingEnvironmentId fails', async () => { + const mockDataZone = { + listEnvironmentBlueprints: sinon.stub().rejects(new Error('API error')), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + await assert.rejects(() => dataZoneClient.getToolingEnvironment('project-123'), /API error/) + }) + }) + + describe('fetchAllProjectMemberships', () => { + it('should fetch all project memberships with pagination', async () => { + const mockDataZone = { + listProjectMemberships: sinon.stub(), + } + + // First call returns first page with nextToken + mockDataZone.listProjectMemberships.onFirstCall().resolves({ + members: [ + { + memberDetails: { + user: { + userId: 'user-1', + }, + }, + }, + ], + nextToken: 'next-token', + }) + + // Second call returns second page without nextToken + mockDataZone.listProjectMemberships.onSecondCall().resolves({ + members: [ + { + memberDetails: { + user: { + userId: 'user-2', + }, + }, + }, + ], + nextToken: undefined, + }) + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + const result = await dataZoneClient.fetchAllProjectMemberships('project-1') + + assert.strictEqual(result.length, 2) + assert.strictEqual(result[0].memberDetails?.user?.userId, 'user-1') + assert.strictEqual(result[1].memberDetails?.user?.userId, 'user-2') + assert.strictEqual(mockDataZone.listProjectMemberships.callCount, 2) + }) + + it('should handle empty memberships', async () => { + const mockDataZone = { + listProjectMemberships: sinon.stub().resolves({ + members: [], + nextToken: undefined, + }), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + const result = await dataZoneClient.fetchAllProjectMemberships('project-1') + + assert.strictEqual(result.length, 0) + }) + + it('should handle API errors', async () => { + const error = new Error('Membership API Error') + const mockDataZone = { + listProjectMemberships: sinon.stub().rejects(error), + } + + sinon.stub(dataZoneClient as any, 'getDataZoneClient').resolves(mockDataZone) + + await assert.rejects(() => dataZoneClient.fetchAllProjectMemberships('project-1'), error) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/client/glueClient.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/client/glueClient.test.ts new file mode 100644 index 00000000000..cd34fe7703e --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/client/glueClient.test.ts @@ -0,0 +1,202 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { GlueClient } from '../../../../sagemakerunifiedstudio/shared/client/glueClient' +import { Glue, GetDatabasesCommand, GetTablesCommand, GetTableCommand } from '@aws-sdk/client-glue' +import { ConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' + +describe('GlueClient', function () { + let sandbox: sinon.SinonSandbox + let glueClient: GlueClient + let mockGlue: sinon.SinonStubbedInstance + + const mockCredentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + + mockGlue = { + send: sandbox.stub(), + } as any + + sandbox.stub(Glue.prototype, 'send').callsFake(mockGlue.send) + + glueClient = new GlueClient('us-east-1', mockCredentialsProvider as ConnectionCredentialsProvider) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('getDatabases', function () { + it('should get databases successfully', async function () { + const mockResponse = { + DatabaseList: [ + { Name: 'database1', Description: 'Test database 1' }, + { Name: 'database2', Description: 'Test database 2' }, + ], + NextToken: 'next-token', + } + + mockGlue.send.resolves(mockResponse) + + const result = await glueClient.getDatabases('test-catalog', undefined, undefined, 'start-token') + + assert.strictEqual(result.databases.length, 2) + assert.strictEqual(result.databases[0].Name, 'database1') + assert.strictEqual(result.nextToken, 'next-token') + + const sendCall = mockGlue.send.getCall(0) + const command = sendCall.args[0] as GetDatabasesCommand + assert.ok(command instanceof GetDatabasesCommand) + }) + + it('should get databases without catalog ID', async function () { + const mockResponse = { + DatabaseList: [{ Name: 'default-db' }], + } + + mockGlue.send.resolves(mockResponse) + + const result = await glueClient.getDatabases() + + assert.strictEqual(result.databases.length, 1) + assert.strictEqual(result.databases[0].Name, 'default-db') + assert.strictEqual(result.nextToken, undefined) + }) + + it('should handle errors when getting databases', async function () { + const error = new Error('Access denied') + mockGlue.send.rejects(error) + + await assert.rejects( + async () => { + await glueClient.getDatabases('test-catalog') + }, + { + message: 'Access denied', + } + ) + }) + }) + + describe('getTables', function () { + it('should get tables successfully', async function () { + const mockResponse = { + TableList: [ + { Name: 'table1', DatabaseName: 'test-db' }, + { Name: 'table2', DatabaseName: 'test-db' }, + ], + NextToken: 'next-token', + } + + mockGlue.send.resolves(mockResponse) + + const result = await glueClient.getTables('test-db', 'test-catalog', undefined, 'start-token') + + assert.strictEqual(result.tables.length, 2) + assert.strictEqual(result.tables[0].Name, 'table1') + assert.strictEqual(result.nextToken, 'next-token') + + const sendCall = mockGlue.send.getCall(0) + const command = sendCall.args[0] as GetTablesCommand + assert.ok(command instanceof GetTablesCommand) + }) + + it('should get tables without catalog ID', async function () { + const mockResponse = { + TableList: [{ Name: 'default-table' }], + } + + mockGlue.send.resolves(mockResponse) + + const result = await glueClient.getTables('test-db') + + assert.strictEqual(result.tables.length, 1) + assert.strictEqual(result.tables[0].Name, 'default-table') + }) + + it('should handle errors when getting tables', async function () { + const error = new Error('Database not found') + mockGlue.send.rejects(error) + + await assert.rejects( + async () => { + await glueClient.getTables('nonexistent-db') + }, + { + message: 'Database not found', + } + ) + }) + }) + + describe('getTable', function () { + it('should get table details successfully', async function () { + const mockResponse = { + Table: { + Name: 'test-table', + DatabaseName: 'test-db', + StorageDescriptor: { + Columns: [ + { Name: 'col1', Type: 'string' }, + { Name: 'col2', Type: 'int' }, + ], + }, + PartitionKeys: [{ Name: 'partition_col', Type: 'date' }], + }, + } + + mockGlue.send.resolves(mockResponse) + + const result = await glueClient.getTable('test-db', 'test-table', 'test-catalog') + + assert.strictEqual(result?.Name, 'test-table') + assert.strictEqual(result?.StorageDescriptor?.Columns?.length, 2) + assert.strictEqual(result?.PartitionKeys?.length, 1) + + const sendCall = mockGlue.send.getCall(0) + const command = sendCall.args[0] as GetTableCommand + assert.ok(command instanceof GetTableCommand) + }) + + it('should get table without catalog ID', async function () { + const mockResponse = { + Table: { + Name: 'default-table', + DatabaseName: 'default-db', + }, + } + + mockGlue.send.resolves(mockResponse) + + const result = await glueClient.getTable('default-db', 'default-table') + + assert.strictEqual(result?.Name, 'default-table') + }) + + it('should handle errors when getting table', async function () { + const error = new Error('Table not found') + mockGlue.send.rejects(error) + + await assert.rejects( + async () => { + await glueClient.getTable('test-db', 'nonexistent-table') + }, + { + message: 'Table not found', + } + ) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/client/gluePrivateClient.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/client/gluePrivateClient.test.ts new file mode 100644 index 00000000000..22a97d82caf --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/client/gluePrivateClient.test.ts @@ -0,0 +1,143 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import globals from '../../../../shared/extensionGlobals' +import { GlueCatalogClient } from '../../../../sagemakerunifiedstudio/shared/client/glueCatalogClient' +import { ConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' + +describe('GlueCatalogClient', function () { + let sandbox: sinon.SinonSandbox + let mockGlueClient: any + let mockSdkClientBuilder: any + + beforeEach(function () { + sandbox = sinon.createSandbox() + + mockGlueClient = { + getCatalogs: sandbox.stub().returns({ + promise: sandbox.stub().resolves({ + CatalogList: [ + { + Name: 'test-catalog', + CatalogType: 'HIVE', + Parameters: { key1: 'value1' }, + }, + ], + }), + }), + } + + mockSdkClientBuilder = { + createAwsService: sandbox.stub().resolves(mockGlueClient), + } + + sandbox.stub(globals, 'sdkClientBuilder').value(mockSdkClientBuilder) + }) + + afterEach(function () { + sandbox.restore() + // Reset singleton instance + ;(GlueCatalogClient as any).instance = undefined + }) + + describe('getInstance', function () { + it('should create singleton instance', function () { + const client1 = GlueCatalogClient.getInstance('us-east-1') + const client2 = GlueCatalogClient.getInstance('us-east-1') + + assert.strictEqual(client1, client2) + }) + + it('should return region correctly', function () { + const client = GlueCatalogClient.getInstance('us-west-2') + assert.strictEqual(client.getRegion(), 'us-west-2') + }) + }) + + describe('createWithCredentials', function () { + it('should create client with credentials', function () { + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + + const client = GlueCatalogClient.createWithCredentials( + 'us-east-1', + credentialsProvider as ConnectionCredentialsProvider + ) + assert.strictEqual(client.getRegion(), 'us-east-1') + }) + }) + + describe('getCatalogs', function () { + it('should return catalogs successfully', async function () { + const client = GlueCatalogClient.getInstance('us-east-1') + const catalogs = await client.getCatalogs() + + assert.strictEqual(catalogs.catalogs.length, 1) + assert.strictEqual(catalogs.catalogs[0].Name, 'test-catalog') + assert.strictEqual(catalogs.catalogs[0].CatalogType, 'HIVE') + assert.deepStrictEqual(catalogs.catalogs[0].Parameters, { key1: 'value1' }) + }) + + it('should return empty array when no catalogs found', async function () { + mockGlueClient.getCatalogs.returns({ + promise: sandbox.stub().resolves({ CatalogList: [] }), + }) + + const client = GlueCatalogClient.getInstance('us-east-1') + const catalogs = await client.getCatalogs() + + assert.strictEqual(catalogs.catalogs.length, 0) + }) + + it('should handle API errors', async function () { + const error = new Error('API Error') + mockGlueClient.getCatalogs.returns({ + promise: sandbox.stub().rejects(error), + }) + + const client = GlueCatalogClient.getInstance('us-east-1') + + await assert.rejects(async () => await client.getCatalogs(), error) + }) + + it('should create client with credentials when provided', async function () { + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + + const client = GlueCatalogClient.createWithCredentials( + 'us-east-1', + credentialsProvider as ConnectionCredentialsProvider + ) + await client.getCatalogs() + + assert.ok(mockSdkClientBuilder.createAwsService.calledOnce) + const callArgs = mockSdkClientBuilder.createAwsService.getCall(0).args[1] + assert.ok(callArgs.credentialProvider) + assert.strictEqual(callArgs.region, 'us-east-1') + }) + + it('should create client without credentials when not provided', async function () { + const client = GlueCatalogClient.getInstance('us-east-1') + await client.getCatalogs() + + assert.ok(mockSdkClientBuilder.createAwsService.calledOnce) + const callArgs = mockSdkClientBuilder.createAwsService.getCall(0).args[1] + assert.strictEqual(callArgs.region, 'us-east-1') + assert.ok(!callArgs.credentials) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/client/s3Client.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/client/s3Client.test.ts new file mode 100644 index 00000000000..714ced3d446 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/client/s3Client.test.ts @@ -0,0 +1,306 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { S3Client } from '../../../../sagemakerunifiedstudio/shared/client/s3Client' +import { S3 } from '@aws-sdk/client-s3' +import { ConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' + +describe('S3Client', function () { + let sandbox: sinon.SinonSandbox + let mockS3: sinon.SinonStubbedInstance + let s3Client: S3Client + + const mockCredentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + + mockS3 = { + listObjectsV2: sandbox.stub(), + } as any + + sandbox.stub(S3.prototype, 'constructor' as any) + sandbox.stub(S3.prototype, 'listObjectsV2').callsFake(mockS3.listObjectsV2) + + s3Client = new S3Client('us-east-1', mockCredentialsProvider as ConnectionCredentialsProvider) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('constructor', function () { + it('should create client with correct properties', function () { + const client = new S3Client('us-west-2', mockCredentialsProvider as ConnectionCredentialsProvider) + assert.ok(client) + }) + }) + + describe('listPaths', function () { + it('should list folders and files successfully', async function () { + const mockResponse = { + CommonPrefixes: [{ Prefix: 'folder1/' }, { Prefix: 'folder2/' }], + Contents: [ + { + Key: 'file1.txt', + Size: 1024, + LastModified: new Date('2023-01-01'), + }, + { + Key: 'file2.txt', + Size: 2048, + LastModified: new Date('2023-01-02'), + }, + ], + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('test-bucket') + + assert.strictEqual(result.paths.length, 4) + const paths = result.paths + + // Check folders + assert.strictEqual(paths[0].displayName, 'folder1') + assert.strictEqual(paths[0].isFolder, true) + assert.strictEqual(paths[0].bucket, 'test-bucket') + assert.strictEqual(paths[0].prefix, 'folder1/') + + assert.strictEqual(paths[1].displayName, 'folder2') + assert.strictEqual(paths[1].isFolder, true) + + // Check files + assert.strictEqual(paths[2].displayName, 'file1.txt') + assert.strictEqual(paths[2].isFolder, false) + assert.strictEqual(paths[2].size, 1024) + assert.deepStrictEqual(paths[2].lastModified, new Date('2023-01-01')) + + assert.strictEqual(paths[3].displayName, 'file2.txt') + assert.strictEqual(paths[3].isFolder, false) + assert.strictEqual(paths[3].size, 2048) + }) + + it('should list paths with prefix', async function () { + const mockResponse = { + CommonPrefixes: [{ Prefix: 'prefix/subfolder/' }], + Contents: [ + { + Key: 'prefix/file.txt', + Size: 512, + LastModified: new Date('2023-01-01'), + }, + ], + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('test-bucket', 'prefix/') + + assert.strictEqual(result.paths.length, 2) + const paths = result.paths + assert.strictEqual(paths[0].displayName, 'subfolder') + assert.strictEqual(paths[0].isFolder, true) + assert.strictEqual(paths[1].displayName, 'file.txt') + assert.strictEqual(paths[1].isFolder, false) + + // Verify API call + assert.ok(mockS3.listObjectsV2.calledOnce) + const callArgs = mockS3.listObjectsV2.getCall(0).args[0] + assert.strictEqual(callArgs.Bucket, 'test-bucket') + assert.strictEqual(callArgs.Prefix, 'prefix/') + assert.strictEqual(callArgs.Delimiter, '/') + assert.strictEqual(callArgs.ContinuationToken, undefined) + }) + + it('should return empty array when no objects found', async function () { + const mockResponse = { + CommonPrefixes: [], + Contents: [], + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('empty-bucket') + + assert.strictEqual(result.paths.length, 0) + }) + + it('should handle response with only folders', async function () { + const mockResponse = { + CommonPrefixes: [{ Prefix: 'folder1/' }, { Prefix: 'folder2/' }], + Contents: undefined, + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('test-bucket') + + assert.strictEqual(result.paths.length, 2) + const paths = result.paths + assert.strictEqual(paths[0].isFolder, true) + assert.strictEqual(paths[1].isFolder, true) + }) + + it('should handle response with only files', async function () { + const mockResponse = { + CommonPrefixes: undefined, + Contents: [ + { + Key: 'file1.txt', + Size: 1024, + LastModified: new Date('2023-01-01'), + }, + ], + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('test-bucket') + + assert.strictEqual(result.paths.length, 1) + const paths = result.paths + assert.strictEqual(paths[0].isFolder, false) + assert.strictEqual(paths[0].displayName, 'file1.txt') + }) + + it('should filter out folder markers and prefix matches', async function () { + const mockResponse = { + CommonPrefixes: [{ Prefix: 'folder/' }], + Contents: [ + { + Key: 'prefix/', + Size: 0, + LastModified: new Date('2023-01-01'), + }, + { + Key: 'prefix/file.txt', + Size: 1024, + LastModified: new Date('2023-01-01'), + }, + { + Key: 'prefix/folder/', + Size: 0, + LastModified: new Date('2023-01-01'), + }, + ], + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('test-bucket', 'prefix/') + + // Should only include the folder from CommonPrefixes and the file (not folder markers) + assert.strictEqual(result.paths.length, 2) + const paths = result.paths + assert.strictEqual(paths[0].displayName, 'folder') + assert.strictEqual(paths[0].isFolder, true) + assert.strictEqual(paths[1].displayName, 'file.txt') + assert.strictEqual(paths[1].isFolder, false) + }) + + it('should handle API errors', async function () { + const error = new Error('S3 API Error') + mockS3.listObjectsV2.rejects(error) + + await assert.rejects(async () => await s3Client.listPaths('test-bucket'), error) + }) + + it('should handle missing object properties gracefully', async function () { + const mockResponse = { + CommonPrefixes: [{ Prefix: undefined }, { Prefix: 'valid-folder/' }], + Contents: [ + { + Key: undefined, + Size: 1024, + }, + { + Key: 'valid-file.txt', + Size: undefined, + LastModified: undefined, + }, + ], + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('test-bucket') + + // Should only include valid entries + assert.strictEqual(result.paths.length, 2) + const paths = result.paths + assert.strictEqual(paths[0].displayName, 'valid-folder') + assert.strictEqual(paths[0].isFolder, true) + assert.strictEqual(paths[1].displayName, 'valid-file.txt') + assert.strictEqual(paths[1].isFolder, false) + assert.strictEqual(paths[1].size, undefined) + assert.strictEqual(paths[1].lastModified, undefined) + }) + + it('should create S3 client on first use', async function () { + const mockResponse = { CommonPrefixes: [], Contents: [] } + mockS3.listObjectsV2.resolves(mockResponse) + + await s3Client.listPaths('test-bucket') + + // Verify S3 client was created with correct parameters + assert.ok(S3.prototype.constructor) + }) + + it('should reuse existing S3 client on subsequent calls', async function () { + const mockResponse = { CommonPrefixes: [], Contents: [] } + mockS3.listObjectsV2.resolves(mockResponse) + + // Make multiple calls + await s3Client.listPaths('test-bucket') + await s3Client.listPaths('test-bucket') + + // S3 constructor should only be called once (during first call) + assert.ok(mockS3.listObjectsV2.calledTwice) + }) + + it('should handle ContinuationToken for pagination', async function () { + const mockResponse = { + CommonPrefixes: [{ Prefix: 'folder1/' }], + Contents: [{ Key: 'file1.txt', Size: 1024 }], + NextContinuationToken: 'next-token-123', + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('test-bucket', 'prefix/', 'continuation-token') + + assert.strictEqual(result.paths.length, 2) + assert.strictEqual(result.nextToken, 'next-token-123') + + // Verify ContinuationToken was passed + const callArgs = mockS3.listObjectsV2.getCall(0).args[0] + assert.strictEqual(callArgs.ContinuationToken, 'continuation-token') + }) + + it('should return undefined nextToken when no more pages', async function () { + const mockResponse = { + CommonPrefixes: [{ Prefix: 'folder1/' }], + Contents: [{ Key: 'file1.txt', Size: 1024 }], + NextContinuationToken: undefined, + } + + mockS3.listObjectsV2.resolves(mockResponse) + + const result = await s3Client.listPaths('test-bucket') + + assert.strictEqual(result.paths.length, 2) + assert.strictEqual(result.nextToken, undefined) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/client/sqlWorkbenchClient.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/client/sqlWorkbenchClient.test.ts new file mode 100644 index 00000000000..e4b1dc50a85 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/client/sqlWorkbenchClient.test.ts @@ -0,0 +1,249 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { + SQLWorkbenchClient, + generateSqlWorkbenchArn, + createRedshiftConnectionConfig, +} from '../../../../sagemakerunifiedstudio/shared/client/sqlWorkbenchClient' +import { STSClient } from '@aws-sdk/client-sts' +import globals from '../../../../shared/extensionGlobals' +import { ConnectionCredentialsProvider } from '../../../../sagemakerunifiedstudio/auth/providers/connectionCredentialsProvider' + +describe('SQLWorkbenchClient', function () { + let sandbox: sinon.SinonSandbox + let mockSqlClient: any + let mockSdkClientBuilder: any + + beforeEach(function () { + sandbox = sinon.createSandbox() + + mockSqlClient = { + getResources: sandbox.stub().returns({ + promise: sandbox.stub().resolves({ + resources: [{ name: 'test-resource' }], + nextToken: 'next-token', + }), + }), + executeQuery: sandbox.stub().returns({ + promise: sandbox.stub().resolves({ + queryExecutions: [{ queryExecutionId: 'test-execution-id' }], + }), + }), + } + + mockSdkClientBuilder = { + createAwsService: sandbox.stub().resolves(mockSqlClient), + } + + sandbox.stub(globals, 'sdkClientBuilder').value(mockSdkClientBuilder) + }) + + afterEach(function () { + sandbox.restore() + // Reset singleton instance + ;(SQLWorkbenchClient as any).instance = undefined + }) + + describe('getInstance', function () { + it('should create singleton instance', function () { + const client1 = SQLWorkbenchClient.getInstance('us-east-1') + const client2 = SQLWorkbenchClient.getInstance('us-east-1') + + assert.strictEqual(client1, client2) + }) + + it('should return region correctly', function () { + const client = SQLWorkbenchClient.getInstance('us-west-2') + assert.strictEqual(client.getRegion(), 'us-west-2') + }) + }) + + describe('createWithCredentials', function () { + it('should create client with credentials', function () { + const credentialsProvider = { + getCredentials: async () => ({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + }), + } + + const client = SQLWorkbenchClient.createWithCredentials( + 'us-east-1', + credentialsProvider as ConnectionCredentialsProvider + ) + assert.strictEqual(client.getRegion(), 'us-east-1') + }) + }) + + describe('getResources', function () { + it('should get resources with connection', async function () { + const client = SQLWorkbenchClient.getInstance('us-east-1') + const connectionConfig = { + id: 'test-id', + type: 'test-type', + databaseType: 'REDSHIFT', + connectableResourceIdentifier: 'test-identifier', + connectableResourceType: 'CLUSTER', + database: 'test-db', + } + + const result = await client.getResources({ + connection: connectionConfig, + resourceType: 'TABLE', + maxItems: 50, + }) + + assert.deepStrictEqual(result.resources, [{ name: 'test-resource' }]) + assert.strictEqual(result.nextToken, 'next-token') + }) + + it('should handle API errors', async function () { + const error = new Error('API Error') + mockSqlClient.getResources.returns({ + promise: sandbox.stub().rejects(error), + }) + + const client = SQLWorkbenchClient.getInstance('us-east-1') + + await assert.rejects( + async () => + await client.getResources({ + connection: { + id: '', + type: '', + databaseType: '', + connectableResourceIdentifier: '', + connectableResourceType: '', + database: '', + }, + resourceType: '', + }), + error + ) + }) + }) + + describe('executeQuery', function () { + it('should execute query successfully', async function () { + const client = SQLWorkbenchClient.getInstance('us-east-1') + const connectionConfig = { + id: 'test-id', + type: 'test-type', + databaseType: 'REDSHIFT', + connectableResourceIdentifier: 'test-identifier', + connectableResourceType: 'CLUSTER', + database: 'test-db', + } + + const result = await client.executeQuery(connectionConfig, 'SELECT 1') + + assert.strictEqual(result, 'test-execution-id') + }) + + it('should handle query execution errors', async function () { + const error = new Error('Query Error') + mockSqlClient.executeQuery.returns({ + promise: sandbox.stub().rejects(error), + }) + + const client = SQLWorkbenchClient.getInstance('us-east-1') + const connectionConfig = { + id: 'test-id', + type: 'test-type', + databaseType: 'REDSHIFT', + connectableResourceIdentifier: 'test-identifier', + connectableResourceType: 'CLUSTER', + database: 'test-db', + } + + await assert.rejects(async () => await client.executeQuery(connectionConfig, 'SELECT 1'), error) + }) + }) +}) + +describe('generateSqlWorkbenchArn', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('should generate ARN with provided account ID', async function () { + const arn = await generateSqlWorkbenchArn('us-east-1', '123456789012') + + assert.ok(arn.startsWith('arn:aws:sqlworkbench:us-east-1:123456789012:connection/')) + assert.ok(arn.includes('-')) + }) +}) + +describe('createRedshiftConnectionConfig', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + sandbox.stub(STSClient.prototype, 'send').resolves({ Account: '123456789012' }) + }) + + afterEach(function () { + sandbox.restore() + }) + + it('should create serverless connection config', async function () { + const config = await createRedshiftConnectionConfig( + 'test-workgroup.123456789012.us-east-1.redshift-serverless.amazonaws.com', + 'test-db', + '123456789012', + 'us-east-1', + '', + false + ) + + assert.strictEqual(config.databaseType, 'REDSHIFT') + assert.strictEqual(config.connectableResourceType, 'WORKGROUP') + assert.strictEqual(config.connectableResourceIdentifier, 'test-workgroup') + assert.strictEqual(config.database, 'test-db') + assert.strictEqual(config.type, '4') // FEDERATED + }) + + it('should create cluster connection config', async function () { + const config = await createRedshiftConnectionConfig( + 'test-cluster.123456789012.us-east-1.redshift.amazonaws.com', + 'test-db', + '123456789012', + 'us-east-1', + '', + false + ) + + assert.strictEqual(config.databaseType, 'REDSHIFT') + assert.strictEqual(config.connectableResourceType, 'CLUSTER') + assert.strictEqual(config.connectableResourceIdentifier, 'test-cluster') + assert.strictEqual(config.database, 'test-db') + assert.strictEqual(config.type, '5') // TEMPORARY_CREDENTIALS_WITH_IAM + }) + + it('should create config with secret authentication', async function () { + const config = await createRedshiftConnectionConfig( + 'test-cluster.123456789012.us-east-1.redshift.amazonaws.com', + 'test-db', + '123456789012', + 'us-east-1', + 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + false + ) + + assert.strictEqual(config.type, '6') // SECRET + assert.ok(config.auth) + assert.strictEqual(config.auth.secretArn, 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret') + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/smusUtils.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/smusUtils.test.ts new file mode 100644 index 00000000000..e8f17d486a3 --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/smusUtils.test.ts @@ -0,0 +1,463 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { + SmusUtils, + SmusErrorCodes, + SmusTimeouts, + SmusCredentialExpiry, + validateCredentialFields, +} from '../../../sagemakerunifiedstudio/shared/smusUtils' +import { ToolkitError } from '../../../shared/errors' +import * as extensionUtilities from '../../../shared/extensionUtilities' +import * as resourceMetadataUtils from '../../../sagemakerunifiedstudio/shared/utils/resourceMetadataUtils' +import fetch from 'node-fetch' + +describe('SmusUtils', () => { + const testDomainUrl = 'https://dzd_domainId.sagemaker.us-east-2.on.aws' + const testDomainIdLowercase = 'dzd_domainid' // Domain IDs get lowercased by URL parsing + const testRegion = 'us-east-2' + + afterEach(() => { + sinon.restore() + }) + + describe('extractDomainIdFromUrl', () => { + it('should extract domain ID from valid URL', () => { + const result = SmusUtils.extractDomainIdFromUrl(testDomainUrl) + assert.strictEqual(result, testDomainIdLowercase) + }) + + it('should return undefined for invalid URL', () => { + const result = SmusUtils.extractDomainIdFromUrl('invalid-url') + assert.strictEqual(result, undefined) + }) + + it('should handle URLs with dzd- prefix', () => { + const urlWithDash = 'https://dzd-domainId.sagemaker.us-east-2.on.aws' + const result = SmusUtils.extractDomainIdFromUrl(urlWithDash) + assert.strictEqual(result, 'dzd-domainid') + }) + + it('should handle URLs with dzd_ prefix', () => { + const urlWithUnderscore = 'https://dzd_domainId.sagemaker.us-east-2.on.aws' + const result = SmusUtils.extractDomainIdFromUrl(urlWithUnderscore) + assert.strictEqual(result, testDomainIdLowercase) + }) + }) + + describe('extractRegionFromUrl', () => { + it('should extract region from valid URL', () => { + const result = SmusUtils.extractRegionFromUrl(testDomainUrl) + assert.strictEqual(result, testRegion) + }) + + it('should return fallback region for invalid URL', () => { + const result = SmusUtils.extractRegionFromUrl('invalid-url', 'us-west-2') + assert.strictEqual(result, 'us-west-2') + }) + + it('should return default fallback region when not specified', () => { + const result = SmusUtils.extractRegionFromUrl('invalid-url') + assert.strictEqual(result, 'us-east-1') + }) + + it('should handle different regions', () => { + const urlWithDifferentRegion = 'https://dzd_test.sagemaker.eu-west-1.on.aws' + const result = SmusUtils.extractRegionFromUrl(urlWithDifferentRegion) + assert.strictEqual(result, 'eu-west-1') + }) + + it('should handle non-prod stages', () => { + const urlWithStage = 'https://dzd_test.sagemaker-gamma.us-west-2.on.aws' + const result = SmusUtils.extractRegionFromUrl(urlWithStage) + assert.strictEqual(result, 'us-west-2') + }) + }) + + describe('extractDomainInfoFromUrl', () => { + it('should extract both domain ID and region', () => { + const result = SmusUtils.extractDomainInfoFromUrl(testDomainUrl) + assert.strictEqual(result.domainId, testDomainIdLowercase) + assert.strictEqual(result.region, testRegion) + }) + + it('should use fallback region when extraction fails', () => { + const result = SmusUtils.extractDomainInfoFromUrl('invalid-url', 'us-west-2') + assert.strictEqual(result.domainId, undefined) + assert.strictEqual(result.region, 'us-west-2') + }) + }) + + describe('validateDomainUrl', () => { + it('should return undefined for valid URL', () => { + const result = SmusUtils.validateDomainUrl(testDomainUrl) + assert.strictEqual(result, undefined) + }) + + it('should return error for empty URL', () => { + const result = SmusUtils.validateDomainUrl('') + assert.strictEqual(result, 'Domain URL is required') + }) + + it('should return error for whitespace-only URL', () => { + const result = SmusUtils.validateDomainUrl(' ') + assert.strictEqual(result, 'Domain URL is required') + }) + + it('should return error for non-HTTPS URL', () => { + const result = SmusUtils.validateDomainUrl('http://dzd_test.sagemaker.us-east-1.on.aws') + assert.strictEqual(result, 'Domain URL must use HTTPS (https://)') + }) + + it('should return error for non-SageMaker domain', () => { + const result = SmusUtils.validateDomainUrl('https://example.com') + assert.strictEqual( + result, + 'URL must be a valid SageMaker Unified Studio domain (e.g., https://dzd_xxxxxxxxx.sagemaker.us-east-1.on.aws)' + ) + }) + + it('should return error for URL without domain ID', () => { + const result = SmusUtils.validateDomainUrl('https://invalid.sagemaker.us-east-1.on.aws') + assert.strictEqual(result, 'URL must contain a valid domain ID (starting with dzd- or dzd_)') + }) + + it('should return error for invalid URL format', () => { + const result = SmusUtils.validateDomainUrl('not-a-url') + assert.strictEqual(result, 'Domain URL must use HTTPS (https://)') + }) + + it('should handle URLs with dzd- prefix', () => { + const urlWithDash = 'https://dzd-domainId.sagemaker.us-east-2.on.aws' + const result = SmusUtils.validateDomainUrl(urlWithDash) + assert.strictEqual(result, undefined) + }) + + it('should handle URLs with dzd_ prefix', () => { + const urlWithUnderscore = 'https://dzd_domainId.sagemaker.us-east-2.on.aws' + const result = SmusUtils.validateDomainUrl(urlWithUnderscore) + assert.strictEqual(result, undefined) + }) + + it('should trim whitespace from URL', () => { + const urlWithWhitespace = ' https://dzd_domainId.sagemaker.us-east-2.on.aws ' + const result = SmusUtils.validateDomainUrl(urlWithWhitespace) + assert.strictEqual(result, undefined) + }) + }) + + describe('constants', () => { + it('should export SmusErrorCodes with correct values', () => { + assert.strictEqual(SmusErrorCodes.NoActiveConnection, 'NoActiveConnection') + assert.strictEqual(SmusErrorCodes.ApiTimeout, 'ApiTimeout') + assert.strictEqual(SmusErrorCodes.SmusLoginFailed, 'SmusLoginFailed') + assert.strictEqual(SmusErrorCodes.RedeemAccessTokenFailed, 'RedeemAccessTokenFailed') + }) + + it('should export SmusTimeouts with correct values', () => { + assert.strictEqual(SmusTimeouts.apiCallTimeoutMs, 10 * 1000) + }) + + it('should export SmusCredentialExpiry with correct values', () => { + assert.strictEqual(SmusCredentialExpiry.derExpiryMs, 10 * 60 * 1000) + assert.strictEqual(SmusCredentialExpiry.projectExpiryMs, 10 * 60 * 1000) + assert.strictEqual(SmusCredentialExpiry.connectionExpiryMs, 10 * 60 * 1000) + }) + }) + + describe('getSsoInstanceInfo', () => { + let fetchStub: sinon.SinonStub + + beforeEach(() => { + fetchStub = sinon.stub(fetch, 'default' as any) + }) + + afterEach(() => { + fetchStub.restore() + }) + + it('should throw error for invalid domain URL', async () => { + await assert.rejects( + () => SmusUtils.getSsoInstanceInfo('invalid-url'), + (error: any) => { + assert.strictEqual(error.code, 'InvalidDomainUrl') + return true + } + ) + }) + + it('should throw error for URL without domain ID', async () => { + await assert.rejects( + () => SmusUtils.getSsoInstanceInfo('https://invalid.sagemaker.us-east-1.on.aws'), + (error: any) => { + assert.strictEqual(error.code, 'InvalidDomainUrl') + return true + } + ) + }) + + it('should handle timeout errors', async () => { + const timeoutError = new Error('Request timeout') + timeoutError.name = 'AbortError' + fetchStub.rejects(timeoutError) + + await assert.rejects( + () => SmusUtils.getSsoInstanceInfo(testDomainUrl), + (error: any) => { + assert.strictEqual(error.code, SmusErrorCodes.ApiTimeout) + assert.ok(error.message.includes('timed out after 10 seconds')) + return true + } + ) + }) + + it('should handle login failure errors', async () => { + fetchStub.resolves({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }) + + await assert.rejects( + () => SmusUtils.getSsoInstanceInfo(testDomainUrl), + (error: any) => { + assert.strictEqual(error.code, SmusErrorCodes.SmusLoginFailed) + assert.ok(error.message.includes('401')) + return true + } + ) + }) + + it('should successfully extract SSO instance info', async () => { + const mockResponse = { + ok: true, + json: sinon.stub().resolves({ + redirectUrl: + 'https://example.com/oauth/authorize?client_id=arn%3Aaws%3Asso%3A%3A123456789%3Aapplication%2Fssoins-123%2Fapl-456', + }), + } + fetchStub.resolves(mockResponse) + + const result = await SmusUtils.getSsoInstanceInfo(testDomainUrl) + + assert.strictEqual(result.ssoInstanceId, 'ssoins-123') + assert.strictEqual(result.issuerUrl, 'https://identitycenter.amazonaws.com/ssoins-123') + assert.strictEqual(result.clientId, 'arn:aws:sso::123456789:application/ssoins-123/apl-456') + assert.strictEqual(result.region, testRegion) + }) + + it('should throw error for missing redirect URL', async () => { + const mockResponse = { + ok: true, + json: sinon.stub().resolves({}), + } + fetchStub.resolves(mockResponse) + + await assert.rejects( + () => SmusUtils.getSsoInstanceInfo(testDomainUrl), + (error: any) => { + assert.strictEqual(error.code, 'InvalidLoginResponse') + return true + } + ) + }) + + it('should throw error for missing client_id in redirect URL', async () => { + const mockResponse = { + ok: true, + json: sinon.stub().resolves({ + redirectUrl: 'https://example.com/oauth/authorize', + }), + } + fetchStub.resolves(mockResponse) + + await assert.rejects( + () => SmusUtils.getSsoInstanceInfo(testDomainUrl), + (error: any) => { + assert.strictEqual(error.code, 'InvalidRedirectUrl') + return true + } + ) + }) + + it('should throw error for invalid ARN format', async () => { + const mockResponse = { + ok: true, + json: sinon.stub().resolves({ + redirectUrl: 'https://example.com/oauth/authorize?client_id=invalid-arn', + }), + } + fetchStub.resolves(mockResponse) + + await assert.rejects( + () => SmusUtils.getSsoInstanceInfo(testDomainUrl), + (error: any) => { + assert.strictEqual(error.code, 'InvalidArnFormat') + return true + } + ) + }) + }) + + describe('extractSSOIdFromUserId', () => { + it('should extract SSO ID from valid user ID', () => { + const result = SmusUtils.extractSSOIdFromUserId('user-12345678-abcd-efgh-ijkl-123456789012') + assert.strictEqual(result, '12345678-abcd-efgh-ijkl-123456789012') + }) + + it('should throw error for invalid user ID format', () => { + assert.throws( + () => SmusUtils.extractSSOIdFromUserId('invalid-format'), + /Invalid UserId format: invalid-format/ + ) + }) + + it('should throw error for empty user ID', () => { + assert.throws(() => SmusUtils.extractSSOIdFromUserId(''), /Invalid UserId format: /) + }) + + it('should throw error for user ID without prefix', () => { + assert.throws( + () => SmusUtils.extractSSOIdFromUserId('12345678-abcd-efgh-ijkl-123456789012'), + /Invalid UserId format: 12345678-abcd-efgh-ijkl-123456789012/ + ) + }) + }) + + describe('validateCredentialFields', () => { + it('should not throw for valid credentials', () => { + const validCredentials = { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + sessionToken: + 'AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE', + } + + assert.doesNotThrow(() => { + validateCredentialFields(validCredentials, 'TestError', 'test context') + }) + }) + + it('should throw for missing accessKeyId', () => { + const invalidCredentials = { + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + sessionToken: 'token', + } + + assert.throws( + () => validateCredentialFields(invalidCredentials, 'TestError', 'test context'), + (error: any) => { + assert.ok(error instanceof ToolkitError) + assert.strictEqual(error.code, 'TestError') + assert.ok(error.message.includes('Invalid accessKeyId in test context')) + return true + } + ) + }) + + it('should throw for invalid accessKeyId type', () => { + const invalidCredentials = { + accessKeyId: 123, + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + sessionToken: 'token', + } + + assert.throws( + () => validateCredentialFields(invalidCredentials, 'TestError', 'test context'), + (error: any) => { + assert.ok(error instanceof ToolkitError) + assert.strictEqual(error.code, 'TestError') + assert.ok(error.message.includes('Invalid accessKeyId in test context: number')) + return true + } + ) + }) + + it('should throw for missing secretAccessKey', () => { + const invalidCredentials = { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + sessionToken: 'token', + } + + assert.throws( + () => validateCredentialFields(invalidCredentials, 'TestError', 'test context'), + (error: any) => { + assert.ok(error instanceof ToolkitError) + assert.strictEqual(error.code, 'TestError') + assert.ok(error.message.includes('Invalid secretAccessKey in test context')) + return true + } + ) + }) + + it('should throw for missing sessionToken', () => { + const invalidCredentials = { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + } + + assert.throws( + () => validateCredentialFields(invalidCredentials, 'TestError', 'test context'), + (error: any) => { + assert.ok(error instanceof ToolkitError) + assert.strictEqual(error.code, 'TestError') + assert.ok(error.message.includes('Invalid sessionToken in test context')) + return true + } + ) + }) + }) + + describe('isInSmusSpaceEnvironment', () => { + let isSageMakerStub: sinon.SinonStub + let getResourceMetadataStub: sinon.SinonStub + + beforeEach(() => { + isSageMakerStub = sinon.stub(extensionUtilities, 'isSageMaker') + getResourceMetadataStub = sinon.stub(resourceMetadataUtils, 'getResourceMetadata') + }) + + it('should return true when in SMUS space with DataZone domain ID', () => { + isSageMakerStub.withArgs('SMUS').returns(true) + getResourceMetadataStub.returns({ + AdditionalMetadata: { + DataZoneDomainId: 'dz-domain-123', + }, + }) + + const result = SmusUtils.isInSmusSpaceEnvironment() + assert.strictEqual(result, true) + }) + + it('should return false when not in SMUS space', () => { + isSageMakerStub.withArgs('SMUS').returns(false) + isSageMakerStub.withArgs('SMUS-SPACE-REMOTE-ACCESS').returns(false) + + const result = SmusUtils.isInSmusSpaceEnvironment() + assert.strictEqual(result, false) + }) + + it('should return false when in SMUS space but no resource metadata', () => { + isSageMakerStub.withArgs('SMUS').returns(true) + getResourceMetadataStub.returns(undefined) + + const result = SmusUtils.isInSmusSpaceEnvironment() + assert.strictEqual(result, false) + }) + + it('should return false when in SMUS space but no DataZone domain ID', () => { + isSageMakerStub.withArgs('SMUS').returns(true) + getResourceMetadataStub.returns({ + AdditionalMetadata: {}, + }) + + const result = SmusUtils.isInSmusSpaceEnvironment() + assert.strictEqual(result, false) + }) + }) +}) diff --git a/packages/core/src/test/sagemakerunifiedstudio/shared/utils/resourceMetadataUtils.test.ts b/packages/core/src/test/sagemakerunifiedstudio/shared/utils/resourceMetadataUtils.test.ts new file mode 100644 index 00000000000..3580a730fbc --- /dev/null +++ b/packages/core/src/test/sagemakerunifiedstudio/shared/utils/resourceMetadataUtils.test.ts @@ -0,0 +1,292 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { fs } from '../../../../shared/fs/fs' +import * as extensionUtilities from '../../../../shared/extensionUtilities' +import { + initializeResourceMetadata, + getResourceMetadata, + resourceMetadataFileExists, + resetResourceMetadata, + ResourceMetadata, +} from '../../../../sagemakerunifiedstudio/shared/utils/resourceMetadataUtils' + +describe('resourceMetadataUtils', function () { + let sandbox: sinon.SinonSandbox + + const mockMetadata: ResourceMetadata = { + AppType: 'JupyterServer', + DomainId: 'domain-12345', + SpaceName: 'test-space', + UserProfileName: 'test-user', + ExecutionRoleArn: 'arn:aws:iam::123456789012:role/test-role', + ResourceArn: 'arn:aws:sagemaker:us-west-2:123456789012:app/domain-12345/test-user/jupyterserver/test-app', + ResourceName: 'test-app', + AppImageVersion: '1.0.0', + AdditionalMetadata: { + DataZoneDomainId: 'dz-domain-123', + DataZoneDomainRegion: 'us-west-2', + DataZoneEndpoint: 'https://datazone.us-west-2.amazonaws.com', + DataZoneEnvironmentId: 'env-123', + DataZoneProjectId: 'project-456', + DataZoneScopeName: 'test-scope', + DataZoneStage: 'prod', + DataZoneUserId: 'user-789', + PrivateSubnets: 'subnet-123,subnet-456', + ProjectS3Path: 's3://test-bucket/project/', + SecurityGroup: 'sg-123456789', + }, + ResourceArnCaseSensitive: + 'arn:aws:sagemaker:us-west-2:123456789012:app/domain-12345/test-user/JupyterServer/test-app', + IpAddressType: 'IPv4', + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + resetResourceMetadata() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('initializeResourceMetadata()', function () { + it('should initialize metadata when file exists and is valid JSON', async function () { + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(mockMetadata)) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.deepStrictEqual(result, mockMetadata) + }) + + it('should not initialize when not in SMUS environment', async function () { + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(false) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.strictEqual(result, undefined) + }) + + it('should not throw when file does not exist', async function () { + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(false) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.strictEqual(result, undefined) + }) + + it('should handle invalid JSON gracefully', async function () { + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves('{ invalid json }') + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.strictEqual(result, undefined) + }) + + it('should handle file read errors gracefully', async function () { + const error = new Error('File read error') + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').rejects(error) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.strictEqual(result, undefined) + }) + + it('should handle metadata with missing optional fields', async function () { + const minimalMetadata: ResourceMetadata = { + DomainId: 'domain-123', + } + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(minimalMetadata)) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.deepStrictEqual(result, minimalMetadata) + }) + + it('should handle metadata with empty AdditionalMetadata', async function () { + const metadataWithEmptyAdditional: ResourceMetadata = { + DomainId: 'domain-123', + AdditionalMetadata: {}, + } + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(metadataWithEmptyAdditional)) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.deepStrictEqual(result, metadataWithEmptyAdditional) + }) + + it('should handle empty JSON file', async function () { + const emptyMetadata: ResourceMetadata = {} + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(emptyMetadata)) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.deepStrictEqual(result, emptyMetadata) + }) + + it('should handle very large JSON files', async function () { + const largeMetadata = { + ...mockMetadata, + LargeField: 'x'.repeat(10000), + } + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(largeMetadata)) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.strictEqual((result as any).LargeField?.length, 10000) + }) + + it('should handle JSON with unexpected additional fields', async function () { + const metadataWithExtraFields = { + ...mockMetadata, + UnexpectedField: 'unexpected-value', + AdditionalMetadata: { + ...mockMetadata.AdditionalMetadata, + UnexpectedNestedField: 'unexpected-nested-value', + }, + } + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(metadataWithExtraFields)) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.strictEqual((result as any).UnexpectedField, 'unexpected-value') + assert.strictEqual((result as any).AdditionalMetadata?.UnexpectedNestedField, 'unexpected-nested-value') + }) + + it('should handle JSON with undefined values', async function () { + const metadataWithUndefined = { + DomainId: undefined, + AdditionalMetadata: { + DataZoneDomainId: undefined, + }, + } + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(metadataWithUndefined)) + + await initializeResourceMetadata() + const result = getResourceMetadata() + + assert.strictEqual(result?.DomainId, undefined) + assert.strictEqual(result?.AdditionalMetadata?.DataZoneDomainId, undefined) + }) + }) + + describe('getResourceMetadata()', function () { + it('should return undefined when not initialized', function () { + const result = getResourceMetadata() + assert.strictEqual(result, undefined) + }) + + it('should return cached metadata after initialization', async function () { + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(mockMetadata)) + + await initializeResourceMetadata() + + const result = getResourceMetadata() + assert.deepStrictEqual(result, mockMetadata) + }) + + it('should return the same instance on multiple calls', async function () { + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + sandbox.stub(fs, 'existsFile').resolves(true) + sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(mockMetadata)) + + await initializeResourceMetadata() + + const result1 = getResourceMetadata() + const result2 = getResourceMetadata() + + assert.strictEqual(result1, result2) + assert.deepStrictEqual(result1, mockMetadata) + }) + }) + + describe('resetResourceMetadata()', function () { + it('should reset cached metadata and allow re-initialization', async function () { + sandbox.stub(extensionUtilities, 'isSageMaker').withArgs('SMUS').returns(true) + const existsFileStub = sandbox.stub(fs, 'existsFile').resolves(true) + const readFileTextStub = sandbox.stub(fs, 'readFileText').resolves(JSON.stringify(mockMetadata)) + + await initializeResourceMetadata() + const cached1 = getResourceMetadata() + assert.deepStrictEqual(cached1, mockMetadata) + + sinon.assert.calledOnce(existsFileStub) + sinon.assert.calledOnce(readFileTextStub) + + resetResourceMetadata() + + const cached2 = getResourceMetadata() + assert.strictEqual(cached2, undefined) + + await initializeResourceMetadata() + const cached3 = getResourceMetadata() + assert.deepStrictEqual(cached3, mockMetadata) + + sinon.assert.calledTwice(existsFileStub) + sinon.assert.calledTwice(readFileTextStub) + }) + }) + + describe('resourceMetadataFileExists()', function () { + it('should return true when file exists', async function () { + const existsStub = sandbox.stub(fs, 'existsFile').resolves(true) + + const result = await resourceMetadataFileExists() + + assert.strictEqual(result, true) + sinon.assert.calledOnceWithExactly(existsStub, '/opt/ml/metadata/resource-metadata.json') + }) + + it('should return false when file does not exist', async function () { + sandbox.stub(fs, 'existsFile').resolves(false) + + const result = await resourceMetadataFileExists() + + assert.strictEqual(result, false) + }) + + it('should return false and log error when fs.existsFile throws', async function () { + const error = new Error('Permission denied') + sandbox.stub(fs, 'existsFile').rejects(error) + + const result = await resourceMetadataFileExists() + + assert.strictEqual(result, false) + }) + }) +}) diff --git a/packages/core/src/test/shared/clients/sagemakerClient.test.ts b/packages/core/src/test/shared/clients/sagemakerClient.test.ts index 888d2222692..ecd60af5ad1 100644 --- a/packages/core/src/test/shared/clients/sagemakerClient.test.ts +++ b/packages/core/src/test/shared/clients/sagemakerClient.test.ts @@ -6,9 +6,11 @@ import * as sinon from 'sinon' import * as assert from 'assert' import { SagemakerClient } from '../../../shared/clients/sagemaker' -import { AppDetails, SpaceDetails, DescribeDomainCommandOutput } from '@aws-sdk/client-sagemaker' +import { AppDetails, SpaceDetails, DescribeDomainCommandOutput, AppType } from '@aws-sdk/client-sagemaker' import { DescribeDomainResponse } from '@amzn/sagemaker-client' import { intoCollection } from '../../../shared/utilities/collectionUtils' +import { ToolkitError } from '../../../shared/errors' +import { getTestWindow } from '../vscode/window' describe('SagemakerClient.fetchSpaceAppsAndDomains', function () { const region = 'test-region' @@ -91,10 +93,6 @@ describe('SagemakerClient.fetchSpaceAppsAndDomains', function () { listAppsStub.returns(intoCollection([{ AppName: 'app1', DomainId: 'domain1', SpaceName: 'space1' }])) const [spaceApps] = await client.fetchSpaceAppsAndDomains() - for (const space of spaceApps) { - console.log(space[0]) - console.log(space[1]) - } const spaceAppKey2 = 'domain2__space2' const spaceAppKey3 = 'domain2__space3' @@ -104,121 +102,287 @@ describe('SagemakerClient.fetchSpaceAppsAndDomains', function () { assert.strictEqual(spaceApps.get(spaceAppKey3)?.App, undefined) }) - describe('SagemakerClient.startSpace', function () { - const region = 'test-region' - let client: SagemakerClient - let describeSpaceStub: sinon.SinonStub - let updateSpaceStub: sinon.SinonStub - let waitForSpaceStub: sinon.SinonStub - let createAppStub: sinon.SinonStub - - beforeEach(function () { - client = new SagemakerClient(region) - describeSpaceStub = sinon.stub(client, 'describeSpace') - updateSpaceStub = sinon.stub(client, 'updateSpace') - waitForSpaceStub = sinon.stub(client as any, 'waitForSpaceInService') - createAppStub = sinon.stub(client, 'createApp') - }) + it('filters out unified studio domains when filterSmusDomains is true', async function () { + const [spaceApps] = await client.fetchSpaceAppsAndDomains(undefined, true) - afterEach(function () { - sinon.restore() - }) + assert.strictEqual(spaceApps.size, 3) + assert.ok(!spaceApps.has('domain3__space4')) + }) + + it('includes unified studio domains when filterSmusDomains is false', async function () { + const [spaceApps] = await client.fetchSpaceAppsAndDomains(undefined, false) + + assert.strictEqual(spaceApps.size, 4) + assert.ok(spaceApps.has('domain3__space4')) + }) + + it('handles AccessDeniedException and shows error message', async function () { + sinon.stub(client, 'listSpaceApps').rejects({ name: 'AccessDeniedException' }) + + await assert.rejects(client.fetchSpaceAppsAndDomains()) + + const messages = getTestWindow().shownMessages + assert.ok(messages.some((m) => m.message.includes('AccessDeniedException'))) + }) +}) + +describe('SagemakerClient.listSpaceApps', function () { + const region = 'test-region' + let client: SagemakerClient + + const appDetails: AppDetails[] = [ + { AppName: 'app1', DomainId: 'domain1', SpaceName: 'space1', AppType: AppType.CodeEditor }, + { AppName: 'app2', DomainId: 'domain2', SpaceName: 'space2', AppType: AppType.JupyterLab }, + { AppName: 'app3', DomainId: 'domain2', SpaceName: 'space3', AppType: 'Studio' as any }, + ] + + const spaceDetails: SpaceDetails[] = [ + { SpaceName: 'space1', DomainId: 'domain1' }, + { SpaceName: 'space2', DomainId: 'domain2' }, + { SpaceName: 'space3', DomainId: 'domain2' }, + ] + + beforeEach(function () { + client = new SagemakerClient(region) + sinon.stub(client, 'listApps').returns(intoCollection([appDetails])) + sinon.stub(client, 'listSpaces').returns(intoCollection([spaceDetails])) + }) + + afterEach(function () { + sinon.restore() + }) + + it('returns space apps with correct mapping', async function () { + const spaceApps = await client.listSpaceApps() + + assert.strictEqual(spaceApps.size, 3) + assert.strictEqual(spaceApps.get('domain1__space1')?.App?.AppName, 'app1') + assert.strictEqual(spaceApps.get('domain2__space2')?.App?.AppName, 'app2') + assert.strictEqual(spaceApps.get('domain2__space3')?.App, undefined) // Studio app filtered out + }) + + it('filters by domain when domainId provided', async function () { + const newClient = new SagemakerClient(region) + const listAppsStub = sinon.stub(newClient, 'listApps').returns(intoCollection([])) + const listSpacesStub = sinon.stub(newClient, 'listSpaces').returns(intoCollection([])) + + await newClient.listSpaceApps('domain1') + + sinon.assert.calledWith(listAppsStub, { DomainIdEquals: 'domain1' }) + sinon.assert.calledWith(listSpacesStub, { DomainIdEquals: 'domain1' }) + }) +}) + +describe('SagemakerClient.waitForAppInService', function () { + const region = 'test-region' + let client: SagemakerClient + let describeAppStub: sinon.SinonStub + + beforeEach(function () { + client = new SagemakerClient(region) + describeAppStub = sinon.stub(client, 'describeApp') + }) + + afterEach(function () { + sinon.restore() + }) - it('enables remote access and starts the app', async function () { - describeSpaceStub.resolves({ - SpaceSettings: { - RemoteAccess: 'DISABLED', - AppType: 'CodeEditor', - CodeEditorAppSettings: { - DefaultResourceSpec: { - InstanceType: 'ml.t3.large', - SageMakerImageArn: 'arn:aws:sagemaker:us-west-2:img', - SageMakerImageVersionAlias: '1.0.0', - }, + it('resolves when app reaches InService status', async function () { + describeAppStub.resolves({ Status: 'InService' }) + + await client.waitForAppInService('domain1', 'space1', 'CodeEditor') + + sinon.assert.calledOnce(describeAppStub) + }) + + it('throws error when app status is Failed', async function () { + describeAppStub.resolves({ Status: 'Failed' }) + + await assert.rejects( + client.waitForAppInService('domain1', 'space1', 'CodeEditor'), + /App failed to start. Status: Failed/ + ) + }) + + it('throws error when app status is DeleteFailed', async function () { + describeAppStub.resolves({ Status: 'DeleteFailed' }) + + await assert.rejects( + client.waitForAppInService('domain1', 'space1', 'CodeEditor'), + /App failed to start. Status: DeleteFailed/ + ) + }) + + it('times out after max retries', async function () { + describeAppStub.resolves({ Status: 'Pending' }) + + await assert.rejects( + client.waitForAppInService('domain1', 'space1', 'CodeEditor', 2, 10), + /Timed out waiting for app/ + ) + }) +}) + +describe('SagemakerClient.startSpace', function () { + const region = 'test-region' + let client: SagemakerClient + let describeSpaceStub: sinon.SinonStub + let updateSpaceStub: sinon.SinonStub + let waitForSpaceStub: sinon.SinonStub + let createAppStub: sinon.SinonStub + + beforeEach(function () { + client = new SagemakerClient(region) + describeSpaceStub = sinon.stub(client, 'describeSpace') + updateSpaceStub = sinon.stub(client, 'updateSpace') + waitForSpaceStub = sinon.stub(client as any, 'waitForSpaceInService') + createAppStub = sinon.stub(client, 'createApp') + }) + + afterEach(function () { + sinon.restore() + }) + + it('enables remote access and starts the app', async function () { + describeSpaceStub.resolves({ + SpaceSettings: { + RemoteAccess: 'DISABLED', + AppType: 'CodeEditor', + CodeEditorAppSettings: { + DefaultResourceSpec: { + InstanceType: 'ml.t3.large', + SageMakerImageArn: 'arn:aws:sagemaker:us-west-2:img', + SageMakerImageVersionAlias: '1.0.0', }, }, - }) + }, + }) - updateSpaceStub.resolves({}) - waitForSpaceStub.resolves() - createAppStub.resolves({}) + updateSpaceStub.resolves({}) + waitForSpaceStub.resolves() + createAppStub.resolves({}) - await client.startSpace('my-space', 'my-domain') + await client.startSpace('my-space', 'my-domain') - sinon.assert.calledOnce(updateSpaceStub) - sinon.assert.calledOnce(waitForSpaceStub) - sinon.assert.calledOnce(createAppStub) - }) + sinon.assert.calledOnce(updateSpaceStub) + sinon.assert.calledOnce(waitForSpaceStub) + sinon.assert.calledOnce(createAppStub) + }) - it('skips enabling remote access if already enabled', async function () { - describeSpaceStub.resolves({ - SpaceSettings: { - RemoteAccess: 'ENABLED', - AppType: 'CodeEditor', - CodeEditorAppSettings: { - DefaultResourceSpec: { - InstanceType: 'ml.t3.large', - SageMakerImageArn: 'arn:aws:sagemaker:us-west-2:img', - SageMakerImageVersionAlias: '1.0.0', - }, + it('skips enabling remote access if already enabled', async function () { + describeSpaceStub.resolves({ + SpaceSettings: { + RemoteAccess: 'ENABLED', + AppType: 'CodeEditor', + CodeEditorAppSettings: { + DefaultResourceSpec: { + InstanceType: 'ml.t3.large', + SageMakerImageArn: 'arn:aws:sagemaker:us-west-2:img', + SageMakerImageVersionAlias: '1.0.0', }, }, - }) + }, + }) - createAppStub.resolves({}) + createAppStub.resolves({}) - await client.startSpace('my-space', 'my-domain') + await client.startSpace('my-space', 'my-domain') - sinon.assert.notCalled(updateSpaceStub) - sinon.assert.notCalled(waitForSpaceStub) - sinon.assert.calledOnce(createAppStub) + sinon.assert.notCalled(updateSpaceStub) + sinon.assert.notCalled(waitForSpaceStub) + sinon.assert.calledOnce(createAppStub) + }) + + it('throws error on unsupported app type', async function () { + describeSpaceStub.resolves({ + SpaceSettings: { + RemoteAccess: 'ENABLED', + AppType: 'Studio', + }, }) - it('throws error on unsupported app type', async function () { - describeSpaceStub.resolves({ - SpaceSettings: { - RemoteAccess: 'ENABLED', - AppType: 'Studio', + await assert.rejects(client.startSpace('my-space', 'my-domain'), /Unsupported AppType "Studio"/) + }) + + it('uses fallback resource spec when none provided', async function () { + describeSpaceStub.resolves({ + SpaceSettings: { + RemoteAccess: 'ENABLED', + AppType: 'JupyterLab', + JupyterLabAppSettings: { + DefaultResourceSpec: { + InstanceType: 'ml.t3.large', + }, }, + }, + }) + + createAppStub.resolves({}) + + await client.startSpace('my-space', 'my-domain') + + sinon.assert.calledOnceWithExactly( + createAppStub, + sinon.match.hasNested('ResourceSpec', { + InstanceType: 'ml.t3.large', + SageMakerImageArn: 'arn:aws:sagemaker:us-west-2:542918446943:image/sagemaker-distribution-cpu', + SageMakerImageVersionAlias: '3.2.0', }) + ) + }) - await assert.rejects(client.startSpace('my-space', 'my-domain'), /Unsupported AppType "Studio"/) - }) + it('handles AccessDeniedException gracefully', async function () { + describeSpaceStub.rejects({ name: 'AccessDeniedException', message: 'no access' }) + + await assert.rejects(client.startSpace('my-space', 'my-domain'), /You do not have permission to start spaces/) + }) - it('uses fallback resource spec when none provided', async function () { - describeSpaceStub.resolves({ - SpaceSettings: { - RemoteAccess: 'ENABLED', - AppType: 'JupyterLab', - JupyterLabAppSettings: { - DefaultResourceSpec: { - InstanceType: 'ml.t3.large', - }, + it('prompts user for insufficient memory instance type', async function () { + describeSpaceStub.resolves({ + SpaceName: 'my-space', + SpaceSettings: { + RemoteAccess: 'ENABLED', + AppType: 'CodeEditor', + CodeEditorAppSettings: { + DefaultResourceSpec: { + InstanceType: 'ml.t3.medium', // Insufficient memory type }, }, - }) + }, + }) - createAppStub.resolves({}) + createAppStub.resolves({}) - await client.startSpace('my-space', 'my-domain') + const promise = client.startSpace('my-space', 'my-domain') - sinon.assert.calledOnceWithExactly( - createAppStub, - sinon.match.hasNested('ResourceSpec', { - InstanceType: 'ml.t3.large', - SageMakerImageArn: 'arn:aws:sagemaker:us-west-2:542918446943:image/sagemaker-distribution-cpu', - SageMakerImageVersionAlias: '3.2.0', - }) - ) - }) + // Wait for the error message to appear and select "Yes" + await getTestWindow().waitForMessage(/not supported for remote access/) + getTestWindow().getFirstMessage().selectItem('Yes') - it('handles AccessDeniedException gracefully', async function () { - describeSpaceStub.rejects({ name: 'AccessDeniedException', message: 'no access' }) + await promise + sinon.assert.calledOnce(createAppStub) + }) - await assert.rejects( - client.startSpace('my-space', 'my-domain'), - /You do not have permission to start spaces/ - ) + it('throws error when user declines insufficient memory upgrade', async function () { + describeSpaceStub.resolves({ + SpaceName: 'my-space', + SpaceSettings: { + RemoteAccess: 'ENABLED', + AppType: 'CodeEditor', + CodeEditorAppSettings: { + DefaultResourceSpec: { + InstanceType: 'ml.t3.medium', + }, + }, + }, }) + + const promise = client.startSpace('my-space', 'my-domain') + + // Wait for the error message to appear and select "No" + await getTestWindow().waitForMessage(/not supported for remote access/) + getTestWindow().getFirstMessage().selectItem('No') + + await assert.rejects(promise, (err: ToolkitError) => err.message === 'InstanceType has insufficient memory.') }) }) diff --git a/packages/toolkit/.changes/next-release/Feature-11b05698-7406-4ccd-afd2-a22d3adbfa67.json b/packages/toolkit/.changes/next-release/Feature-11b05698-7406-4ccd-afd2-a22d3adbfa67.json new file mode 100644 index 00000000000..ba3abe449fd --- /dev/null +++ b/packages/toolkit/.changes/next-release/Feature-11b05698-7406-4ccd-afd2-a22d3adbfa67.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Feature to support the access of SageMakerUnified Studio resources from the local VSCode IDE" +} diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index e51656f6ae6..cd5ba5546ce 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -779,6 +779,11 @@ "name": "%AWS.codecatalyst.explorerTitle%", "when": "(!isCloud9 && !aws.isSageMaker || isCloud9CodeCatalyst) && !aws.explorer.showAuthView" }, + { + "id": "aws.smus.rootView", + "name": "%AWS.sagemakerunifiedstudio.explorerTitle%", + "when": "!aws.explorer.showAuthView" + }, { "type": "webview", "id": "aws.toolkit.AmazonCommonAuth", @@ -1257,6 +1262,14 @@ { "command": "aws.sagemaker.filterSpaceApps", "when": "false" + }, + { + "command": "aws.smus.switchProject", + "when": "false" + }, + { + "command": "aws.smus.refreshProject", + "when": "false" } ], "editor/title": [ @@ -1319,6 +1332,21 @@ } ], "view/title": [ + { + "command": "aws.smus.switchProject", + "when": "view == aws.smus.rootView && !aws.isWebExtHost && aws.smus.connected && !aws.smus.inSmusSpaceEnvironment", + "group": "smus@0" + }, + { + "command": "aws.smus.refreshProject", + "when": "view == aws.smus.rootView && !aws.isWebExtHost && aws.smus.connected", + "group": "smus@1" + }, + { + "command": "aws.smus.signOut", + "when": "view == aws.smus.rootView && !aws.isWebExtHost && aws.smus.connected && !aws.smus.inSmusSpaceEnvironment", + "group": "smus@2" + }, { "command": "aws.toolkit.submitFeedback", "when": "view == aws.explorer && !aws.isWebExtHost", @@ -1460,18 +1488,38 @@ "command": "aws.stepfunctions.openWithWorkflowStudio", "when": "isFileSystemResource && resourceFilename =~ /^.*\\.asl\\.(json|yml|yaml)$/", "group": "z_aws@1" + }, + { + "command": "aws.smus.notebookscheduling.createjob", + "when": "resourceExtname == .ipynb", + "group": "z_aws@1" + }, + { + "command": "aws.smus.notebookscheduling.viewjobs", + "when": "resourceExtname == .ipynb", + "group": "z_aws@1" } ], "view/item/context": [ { "command": "aws.sagemaker.stopSpace", "group": "inline@0", - "when": "viewItem =~ /^(awsSagemakerSpaceRunningRemoteEnabledNode|awsSagemakerSpaceRunningRemoteDisabledNode)$/" + "when": "view != aws.smus.rootView && viewItem =~ /^(awsSagemakerSpaceRunningRemoteEnabledNode|awsSagemakerSpaceRunningRemoteDisabledNode)$/" + }, + { + "command": "aws.smus.stopSpace", + "group": "inline@0", + "when": "view == aws.smus.rootView && viewItem =~ /^(awsSagemakerSpaceRunningRemoteEnabledNode|awsSagemakerSpaceRunningRemoteDisabledNode|awsSagemakerSpaceRunningNode)$/" }, { "command": "aws.sagemaker.openRemoteConnection", "group": "inline@1", - "when": "viewItem =~ /^(awsSagemakerSpaceRunningRemoteEnabledNode|awsSagemakerSpaceStoppedRemoteEnabledNode|awsSagemakerSpaceStoppedRemoteDisabledNode)$/" + "when": "view != aws.smus.rootView && viewItem =~ /^(awsSagemakerSpaceRunningRemoteEnabledNode|awsSagemakerSpaceStoppedRemoteEnabledNode|awsSagemakerSpaceStoppedRemoteDisabledNode)$/" + }, + { + "command": "aws.smus.openRemoteConnection", + "group": "inline@1", + "when": "view == aws.smus.rootView && viewItem =~ /^(awsSagemakerSpaceRunningRemoteEnabledNode|awsSagemakerSpaceStoppedRemoteEnabledNode|awsSagemakerSpaceStoppedRemoteDisabledNode)$/" }, { "command": "_aws.toolkit.notifications.dismiss", @@ -1633,6 +1681,11 @@ "when": "view == aws.explorer && viewItem == awsSagemakerParentNode", "group": "inline@1" }, + { + "command": "aws.smus.refreshProject", + "when": "view == aws.smus.rootView && viewItem == smusSelectedProject", + "group": "inline@1" + }, { "command": "aws.toolkit.lambda.createServerlessLandProject", "when": "view == aws.explorer && viewItem == awsLambdaNode || viewItem == awsRegionNode", @@ -2389,6 +2442,11 @@ ] }, "commands": [ + { + "command": "aws.smus.openSpaceRemoteConnection", + "title": "Connect to SageMaker-Unified-Studio Space", + "icon": "$(remote-explorer)" + }, { "command": "_aws.toolkit.notifications.dismiss", "title": "%AWS.generic.dismiss%", @@ -2642,6 +2700,18 @@ } } }, + { + "command": "aws.smus.openRemoteConnection", + "title": "Connect to SageMaker Space", + "icon": "$(remote-explorer)", + "category": "%AWS.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + } + }, { "command": "aws.sagemaker.stopSpace", "title": "Stop SageMaker Space", @@ -2654,6 +2724,56 @@ } } }, + { + "command": "aws.smus.stopSpace", + "title": "Stop SageMaker Space", + "icon": "$(debug-stop)", + "category": "%AWS.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + } + }, + { + "command": "aws.smus.switchProject", + "title": "%AWS.command.smus.switchProject%", + "category": "%AWS.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + } + }, + { + "command": "aws.smus.refreshProject", + "title": "%AWS.command.smus.refreshProject%", + "category": "%AWS.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "icon": { + "dark": "resources/icons/vscode/dark/refresh.svg", + "light": "resources/icons/vscode/light/refresh.svg" + }, + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + } + }, + { + "command": "aws.smus.signOut", + "title": "%AWS.command.smus.signOut%", + "category": "%AWS.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "icon": "$(sign-out)", + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + } + }, { "command": "aws.ec2.startInstance", "title": "%AWS.command.ec2.startInstance%", @@ -4283,6 +4403,16 @@ "category": "%AWS.title.cn%" } } + }, + { + "command": "aws.smus.notebookscheduling.createjob", + "title": "Create Notebook Job", + "category": "Job" + }, + { + "command": "aws.smus.notebookscheduling.viewjobs", + "title": "View Notebook Jobs", + "category": "Job" } ], "jsonValidation": [ @@ -4823,26 +4953,61 @@ "fontCharacter": "\\f1e0" } }, - "aws-schemas-registry": { + "aws-sagemakerunifiedstudio-catalog": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1e1" } }, - "aws-schemas-schema": { + "aws-sagemakerunifiedstudio-spaces": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1e2" } }, - "aws-stepfunctions-preview": { + "aws-sagemakerunifiedstudio-spaces-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1e3" } + }, + "aws-sagemakerunifiedstudio-symbol-int": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e4" + } + }, + "aws-sagemakerunifiedstudio-table": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e5" + } + }, + "aws-schemas-registry": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e6" + } + }, + "aws-schemas-schema": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e7" + } + }, + "aws-stepfunctions-preview": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e8" + } } }, "notebooks": [