diff --git a/superset-frontend/cypress-base/cypress/integration/database/modal.test.ts b/superset-frontend/cypress-base/cypress/integration/database/modal.test.ts index a3260250aa47f..3cc34cb64fb99 100644 --- a/superset-frontend/cypress-base/cypress/integration/database/modal.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/database/modal.test.ts @@ -62,8 +62,8 @@ describe('Add database', () => { it('show error alerts on dynamic form for bad host', () => { // click postgres dynamic form cy.get('.preferred > :nth-child(1)').click(); - cy.get('input[name="host"]').focus().type('badhost'); - cy.get('input[name="port"]').focus().type('5432'); + cy.get('input[name="host"]').focus().type('badhost', { force: true }); + cy.get('input[name="port"]').focus().type('5432', { force: true }); cy.get('.ant-form-item-explain-error').contains( "The hostname provided can't be resolved", ); @@ -72,8 +72,8 @@ describe('Add database', () => { it('show error alerts on dynamic form for bad port', () => { // click postgres dynamic form cy.get('.preferred > :nth-child(1)').click(); - cy.get('input[name="host"]').focus().type('localhost'); - cy.get('input[name="port"]').focus().type('123'); + cy.get('input[name="host"]').focus().type('localhost', { force: true }); + cy.get('input[name="port"]').focus().type('123', { force: true }); cy.get('input[name="database"]').focus(); cy.get('.ant-form-item-explain-error').contains('The port is closed'); }); diff --git a/superset-frontend/src/types/Database.ts b/superset-frontend/src/types/Database.ts index c4491dbb9943d..575d69e2f2be0 100644 --- a/superset-frontend/src/types/Database.ts +++ b/superset-frontend/src/types/Database.ts @@ -27,4 +27,5 @@ export default interface Database { server_cert: string; sqlalchemy_uri: string; catalog: object; + parameters: any; } diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx index 99a414012b779..7078b49cbc8d5 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx @@ -17,6 +17,7 @@ * under the License. */ import React from 'react'; +import { isEmpty } from 'lodash'; import { SupersetTheme, t } from '@superset-ui/core'; import { AntdSwitch } from 'src/components'; import InfoTooltip from 'src/components/InfoTooltip'; @@ -250,3 +251,33 @@ export const forceSSLField = ({ /> ); + +export const SSHTunnelSwitch = ({ + isEditMode, + changeMethods, + db, +}: FieldPropTypes) => ( +
infoTooltip(theme)}> + { + changeMethods.onParametersChange({ + target: { + type: 'toggle', + name: 'ssh', + checked: true, + value: changed, + }, + }); + }} + data-test="ssh-tunnel-switch" + /> + {t('SSH Tunnel')} + +
+); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/index.tsx index f6284ae67d9a7..5dce73206f151 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/index.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/index.tsx @@ -31,6 +31,7 @@ import { portField, queryField, usernameField, + SSHTunnelSwitch, } from './CommonParameters'; import { validatedInputField } from './ValidatedInputField'; import { EncryptedField } from './EncryptedField'; @@ -55,6 +56,7 @@ export const FormFieldOrder = [ 'account', 'warehouse', 'role', + 'ssh', ]; export interface FieldPropTypes { @@ -102,6 +104,7 @@ const FORM_FIELD_MAP = { warehouse: validatedInputField, role: validatedInputField, account: validatedInputField, + ssh: SSHTunnelSwitch, }; interface DatabaseConnectionFormProps { diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.tsx index 32cc16b04f45b..cc5eda7d06ac6 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.tsx @@ -127,6 +127,10 @@ fetchMock.mock(AVAILABLE_DB_ENDPOINT, { description: 'Additional parameters', type: 'object', }, + ssh: { + description: 'Create SSH Tunnel', + type: 'boolean', + }, username: { description: 'Username', nullable: true, diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx index fdc41e9e221d4..97bd916fd6040 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx @@ -706,15 +706,18 @@ const DatabaseModal: FunctionComponent = ({ }); } - // make sure that button spinner animates - setLoading(true); - const errors = await getValidation(dbToUpdate, true); - if ((validationErrors && !isEmpty(validationErrors)) || errors) { + // only do validation for non ssh tunnel connections + if (!dbToUpdate?.ssh_tunnel) { + // make sure that button spinner animates + setLoading(true); + const errors = await getValidation(dbToUpdate, true); + if ((validationErrors && !isEmpty(validationErrors)) || errors) { + setLoading(false); + return; + } + // end spinner animation setLoading(false); - return; } - setLoading(false); - // end spinner animation const parameters_schema = isEditMode ? dbToUpdate.parameters_schema?.properties @@ -1431,18 +1434,7 @@ const DatabaseModal: FunctionComponent = ({ validationErrors={validationErrors} getPlaceholder={getPlaceholder} /> - - - - {useSSHTunneling && ( + {db?.parameters?.ssh && ( {renderSSHTunnelForm()} )} diff --git a/superset-frontend/src/views/CRUD/data/database/types.ts b/superset-frontend/src/views/CRUD/data/database/types.ts index c347948f7ed70..a6f04c97af271 100644 --- a/superset-frontend/src/views/CRUD/data/database/types.ts +++ b/superset-frontend/src/views/CRUD/data/database/types.ts @@ -68,6 +68,7 @@ export type DatabaseObject = { warehouse?: string; role?: string; account?: string; + ssh?: boolean; }; // Performance diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index 80a6c4793bbed..8cc03b311538e 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -692,123 +692,132 @@ export function useDatabaseValidation() { null, ); const getValidation = useCallback( - (database: Partial | null, onCreate = false) => - SupersetClient.post({ - endpoint: '/api/v1/database/validate_parameters/', - body: JSON.stringify(transformDB(database)), - headers: { 'Content-Type': 'application/json' }, - }) - .then(() => { - setValidationErrors(null); + (database: Partial | null, onCreate = false) => { + if (database?.parameters?.ssh) { + // when ssh tunnel is enabled we don't want to render any validation errors + setValidationErrors(null); + return []; + } + + return ( + SupersetClient.post({ + endpoint: '/api/v1/database/validate_parameters/', + body: JSON.stringify(transformDB(database)), + headers: { 'Content-Type': 'application/json' }, }) - // eslint-disable-next-line consistent-return - .catch(e => { - if (typeof e.json === 'function') { - return e.json().then(({ errors = [] }: JsonObject) => { - const parsedErrors = errors - .filter((error: { error_type: string }) => { - const skipValidationError = ![ - 'CONNECTION_MISSING_PARAMETERS_ERROR', - 'CONNECTION_ACCESS_DENIED_ERROR', - ].includes(error.error_type); - return skipValidationError || onCreate; - }) - .reduce( - ( - obj: {}, - { - error_type, - extra, - message, - }: { - error_type: string; - extra: { - invalid?: string[]; - missing?: string[]; - name: string; - catalog: { + .then(() => { + setValidationErrors(null); + }) + // eslint-disable-next-line consistent-return + .catch(e => { + if (typeof e.json === 'function') { + return e.json().then(({ errors = [] }: JsonObject) => { + const parsedErrors = errors + .filter((error: { error_type: string }) => { + const skipValidationError = ![ + 'CONNECTION_MISSING_PARAMETERS_ERROR', + 'CONNECTION_ACCESS_DENIED_ERROR', + ].includes(error.error_type); + return skipValidationError || onCreate; + }) + .reduce( + ( + obj: {}, + { + error_type, + extra, + message, + }: { + error_type: string; + extra: { + invalid?: string[]; + missing?: string[]; name: string; - url: string; - idx: number; + catalog: { + name: string; + url: string; + idx: number; + }; + issue_codes?: { + code?: number; + message?: string; + }[]; }; - issue_codes?: { - code?: number; - message?: string; - }[]; - }; - message: string; - }, - ) => { - if (extra.catalog) { - if (extra.catalog.name) { + message: string; + }, + ) => { + if (extra.catalog) { + if (extra.catalog.name) { + return { + ...obj, + error_type, + [extra.catalog.idx]: { + name: message, + }, + }; + } + if (extra.catalog.url) { + return { + ...obj, + error_type, + [extra.catalog.idx]: { + url: message, + }, + }; + } + return { ...obj, error_type, [extra.catalog.idx]: { name: message, + url: message, }, }; } - if (extra.catalog.url) { + // if extra.invalid doesn't exist then the + // error can't be mapped to a parameter + // so leave it alone + if (extra.invalid) { return { ...obj, + [extra.invalid[0]]: message, error_type, - [extra.catalog.idx]: { - url: message, - }, + }; + } + if (extra.missing) { + return { + ...obj, + error_type, + ...Object.assign( + {}, + ...extra.missing.map(field => ({ + [field]: 'This is a required field', + })), + ), + }; + } + if (extra.issue_codes?.length) { + return { + ...obj, + error_type, + description: message || extra.issue_codes[0]?.message, }; } - return { - ...obj, - error_type, - [extra.catalog.idx]: { - name: message, - url: message, - }, - }; - } - // if extra.invalid doesn't exist then the - // error can't be mapped to a parameter - // so leave it alone - if (extra.invalid) { - return { - ...obj, - [extra.invalid[0]]: message, - error_type, - }; - } - if (extra.missing) { - return { - ...obj, - error_type, - ...Object.assign( - {}, - ...extra.missing.map(field => ({ - [field]: 'This is a required field', - })), - ), - }; - } - if (extra.issue_codes?.length) { - return { - ...obj, - error_type, - description: message || extra.issue_codes[0]?.message, - }; - } - - return obj; - }, - {}, - ); - setValidationErrors(parsedErrors); - return parsedErrors; - }); - } - // eslint-disable-next-line no-console - console.error(e); - }), + return obj; + }, + {}, + ); + setValidationErrors(parsedErrors); + return parsedErrors; + }); + } + // eslint-disable-next-line no-console + console.error(e); + }) + ); + }, [setValidationErrors], ); diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index b789bbe70cea7..63a8044612e4d 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -1773,6 +1773,10 @@ class BasicParametersSchema(Schema): encryption = fields.Boolean( required=False, description=__("Use an encrypted connection to the database") ) + ssh = fields.Boolean( + required=False, + metadata={"description": __("Use an ssh tunnel connection to the database")}, + ) class BasicParametersType(TypedDict, total=False): diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index f4968edae9e7f..79c1319d59f8d 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -2437,6 +2437,10 @@ def test_available(self, app, get_available_engine_specs): "description": "Additional parameters", "type": "object", }, + "ssh": { + "description": "Use an ssh tunnel connection to the database", + "type": "boolean", + }, "username": { "description": "Username", "nullable": True, @@ -2512,6 +2516,10 @@ def test_available(self, app, get_available_engine_specs): "description": "Additional parameters", "type": "object", }, + "ssh": { + "description": "Use an ssh tunnel connection to the database", + "type": "boolean", + }, "username": { "description": "Username", "nullable": True, @@ -2587,6 +2595,10 @@ def test_available(self, app, get_available_engine_specs): "description": "Additional parameters", "type": "object", }, + "ssh": { + "description": "Use an ssh tunnel connection to the database", + "type": "boolean", + }, "username": { "description": "Username", "nullable": True, diff --git a/tests/integration_tests/db_engine_specs/postgres_tests.py b/tests/integration_tests/db_engine_specs/postgres_tests.py index a6145432c2a17..260b5a2f9540d 100644 --- a/tests/integration_tests/db_engine_specs/postgres_tests.py +++ b/tests/integration_tests/db_engine_specs/postgres_tests.py @@ -511,6 +511,10 @@ def test_base_parameters_mixin(): "description": "Additional parameters", "additionalProperties": {}, }, + "ssh": { + "description": "Use an ssh tunnel connection to the database", + "type": "boolean", + }, }, "required": ["database", "host", "port", "username"], }