You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by GitBox <gi...@apache.org> on 2022/11/30 06:15:07 UTC

[GitHub] [superset] betodealmeida commented on a diff in pull request #21912: feat(ssh-tunnelling): Setup SSH Tunneling Commands for Database Connections

betodealmeida commented on code in PR #21912:
URL: https://github.com/apache/superset/pull/21912#discussion_r1035492098


##########
superset/databases/commands/test_connection.py:
##########
@@ -90,6 +91,14 @@ def run(self) -> None:  # pylint: disable=too-many-statements
             database.set_sqlalchemy_uri(uri)
             database.db_engine_spec.mutate_db_for_connection_test(database)
 
+            # Generate tunnel if present in the properties
+            ssh_tunnel = None

Review Comment:
   ```suggestion
   ```



##########
superset/databases/dao.py:
##########
@@ -124,3 +125,13 @@ def get_related_objects(cls, database_id: int) -> Dict[str, Any]:
         return dict(
             charts=charts, dashboards=dashboards, sqllab_tab_states=sqllab_tab_states
         )
+
+    @classmethod
+    def get_ssh_tunnel(cls, database_id: int) -> Dict[str, Any]:

Review Comment:
   ```suggestion
       def get_ssh_tunnel(cls, database_id: int) -> Optional[SSHTunnel]:
   ```



##########
superset/migrations/versions/2022-10-20_10-48_f3c2d8ec8595_create_ssh_tunnel_credentials_tbl.py:
##########
@@ -0,0 +1,78 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""create_ssh_tunnel_credentials_tbl
+
+Revision ID: f3c2d8ec8595
+Revises: deb4c9d4a4ef
+Create Date: 2022-10-20 10:48:08.722861
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "f3c2d8ec8595"
+down_revision = "deb4c9d4a4ef"
+
+from uuid import uuid4
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy_utils import UUIDType
+
+from superset import app
+from superset.extensions import encrypted_field_factory
+
+app_config = app.config
+
+
+def upgrade():
+    op.create_table(
+        "ssh_tunnels",
+        # AuditMixinNullable
+        sa.Column("created_on", sa.DateTime(), nullable=True),
+        sa.Column("changed_on", sa.DateTime(), nullable=True),
+        sa.Column("created_by_fk", sa.Integer(), nullable=True),
+        sa.Column("changed_by_fk", sa.Integer(), nullable=True),
+        # ExtraJSONMixin
+        sa.Column("extra_json", sa.Text(), nullable=True),
+        # ImportExportMixin
+        sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4),
+        # SSHTunnelCredentials
+        sa.Column("id", sa.Integer(), primary_key=True),
+        sa.Column("database_id", sa.INTEGER(), sa.ForeignKey("dbs.id")),
+        sa.Column("server_address", encrypted_field_factory.create(sa.String(1024))),
+        sa.Column("server_port", encrypted_field_factory.create(sa.INTEGER())),
+        sa.Column("username", encrypted_field_factory.create(sa.String(1024))),
+        sa.Column(
+            "password", encrypted_field_factory.create(sa.String(1024)), nullable=True
+        ),
+        sa.Column(
+            "private_key",
+            encrypted_field_factory.create(sa.String(1024)),
+            nullable=True,
+        ),
+        sa.Column(
+            "private_key_password",
+            encrypted_field_factory.create(sa.String(1024)),
+            nullable=True,
+        ),
+        sa.Column("bind_host", encrypted_field_factory.create(sa.String(1024))),
+        sa.Column("bind_port", encrypted_field_factory.create(sa.INTEGER())),

Review Comment:
   We don't need these, right? They'll be computed from the SQLAlchemy URI.



##########
superset/models/core.py:
##########
@@ -368,14 +372,38 @@ def get_sqla_engine_with_context(
         schema: Optional[str] = None,
         nullpool: bool = True,
         source: Optional[utils.QuerySource] = None,
+        override_ssh_tunnel: Optional["SSHTunnel"] = None,
     ) -> Engine:
-        yield self._get_sqla_engine(schema=schema, nullpool=nullpool, source=source)
+        ssh_params: Dict[str, Any] = {}
+        from superset.databases.dao import (  # pylint: disable=import-outside-toplevel
+            DatabaseDAO,
+        )
+
+        if ssh_tunnel := override_ssh_tunnel or DatabaseDAO.get_ssh_tunnel(
+            database_id=self.id
+        ):
+            # if ssh_tunnel is available build engine with information
+            url = make_url_safe(self.sqlalchemy_uri_decrypted)
+            ssh_tunnel.bind_host = url.host
+            ssh_tunnel.bind_port = url.port
+            ssh_params = ssh_tunnel.parameters()
+            with sshtunnel.open_tunnel(**ssh_params) as server:
+                yield self._get_sqla_engine(
+                    schema=schema,
+                    nullpool=nullpool,
+                    source=source,
+                    ssh_tunnel_server=server,
+                )
+
+        else:
+            yield self._get_sqla_engine(schema=schema, nullpool=nullpool, source=source)

Review Comment:
   You can write this a bit more cleanly:
   
   ```python
   if ssh_tunnel := ...:
       ...
       engine_context = sshtunnel.open_tunnel(**ssh_params)
   else:
       engine_context = contextlib.nullcontext()
   
   with engine_context as context:
       yield self._get_sqla_engine(schema=schema, nullpool=nullpool, source=source, ssh_tunnel_server=context)
   ```
   
   Also, ideally we wouldn't have to pass `ssh_tunnel_server=server` to `_get_sqla_engine`, since it would be nice to decouple the SSH tunnel from it. Maybe passing the SQL Alchemy URI directly here.



##########
superset/databases/ssh_tunnel/models.py:
##########
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any, Dict
+
+import sqlalchemy as sa
+from flask_appbuilder import Model
+from sqlalchemy.orm import backref, relationship
+from sqlalchemy_utils import EncryptedType
+
+from superset import app
+from superset.models.core import Database
+from superset.models.helpers import (
+    AuditMixinNullable,
+    ExtraJSONMixin,
+    ImportExportMixin,
+)
+
+app_config = app.config
+
+
+class SSHTunnel(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin):
+    """
+    A ssh tunnel configuration in a database.
+    """
+
+    __tablename__ = "ssh_tunnels"
+
+    id = sa.Column(sa.Integer, primary_key=True)
+    database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False)
+    database: Database = relationship(
+        "Database",
+        backref=backref("ssh_tunnels", cascade="all, delete-orphan"),
+        foreign_keys=[database_id],
+    )
+
+    server_address = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))
+    server_port = sa.Column(EncryptedType(sa.Integer, app_config["SECRET_KEY"]))
+    username = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))
+
+    # basic authentication
+    password = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+
+    # password protected pkey authentication
+    private_key = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+    private_key_password = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+
+    bind_host = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))

Review Comment:
   They also wouldn't need to be encrypted.



##########
superset/databases/dao.py:
##########
@@ -124,3 +125,13 @@ def get_related_objects(cls, database_id: int) -> Dict[str, Any]:
         return dict(
             charts=charts, dashboards=dashboards, sqllab_tab_states=sqllab_tab_states
         )
+
+    @classmethod
+    def get_ssh_tunnel(cls, database_id: int) -> SSHTunnel:

Review Comment:
   ```suggestion
       def get_ssh_tunnel(cls, database_id: int) -> Optional[SSHTunnel]:
   ```



##########
superset/migrations/versions/2022-10-20_10-48_f3c2d8ec8595_create_ssh_tunnel_credentials_tbl.py:
##########
@@ -0,0 +1,78 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""create_ssh_tunnel_credentials_tbl
+
+Revision ID: f3c2d8ec8595
+Revises: deb4c9d4a4ef
+Create Date: 2022-10-20 10:48:08.722861
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "f3c2d8ec8595"
+down_revision = "deb4c9d4a4ef"
+
+from uuid import uuid4
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy_utils import UUIDType
+
+from superset import app
+from superset.extensions import encrypted_field_factory
+
+app_config = app.config
+
+
+def upgrade():
+    op.create_table(
+        "ssh_tunnels",
+        # AuditMixinNullable
+        sa.Column("created_on", sa.DateTime(), nullable=True),
+        sa.Column("changed_on", sa.DateTime(), nullable=True),
+        sa.Column("created_by_fk", sa.Integer(), nullable=True),
+        sa.Column("changed_by_fk", sa.Integer(), nullable=True),
+        # ExtraJSONMixin
+        sa.Column("extra_json", sa.Text(), nullable=True),
+        # ImportExportMixin
+        sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4),
+        # SSHTunnelCredentials
+        sa.Column("id", sa.Integer(), primary_key=True),
+        sa.Column("database_id", sa.INTEGER(), sa.ForeignKey("dbs.id")),
+        sa.Column("server_address", encrypted_field_factory.create(sa.String(1024))),
+        sa.Column("server_port", encrypted_field_factory.create(sa.INTEGER())),

Review Comment:
   Unless I'm mistaken we don't need these encrypted.



##########
superset/databases/ssh_tunnel/commands/create.py:
##########
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import logging
+from typing import Any, Dict, List, Optional
+
+from flask_appbuilder.models.sqla import Model
+from marshmallow import ValidationError
+
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAOCreateFailedError
+from superset.databases.dao import DatabaseDAO
+from superset.databases.ssh_tunnel.commands.exceptions import SSHTunnelCreateFailedError
+from superset.databases.ssh_tunnel.dao import SSHTunnelDAO
+
+logger = logging.getLogger(__name__)
+
+
+class CreateSSHTunnelCommand(BaseCommand):
+    def __init__(self, database_id: int, data: Dict[str, Any]):
+        self._properties = data.copy()
+        self._properties["database_id"] = database_id
+
+    def run(self) -> Model:
+        self.validate()
+
+        try:
+            tunnel = SSHTunnelDAO.create(self._properties, commit=False)
+        except DAOCreateFailedError as ex:
+            raise SSHTunnelCreateFailedError() from ex
+
+        return tunnel
+
+    def validate(self) -> None:
+        # TODO(hughhh): check to make sure the server port is not localhost
+        # using the config.SSH_TUNNEL_MANAGER

Review Comment:
   If the command is called when the tunnel is added to the DB then we don't want to check the localhost here; we want to check every time the connection is made, since the value of the config might change after the ssh tunnel has been created.



##########
superset/databases/ssh_tunnel/models.py:
##########
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any, Dict
+
+import sqlalchemy as sa
+from flask_appbuilder import Model
+from sqlalchemy.orm import backref, relationship
+from sqlalchemy_utils import EncryptedType
+
+from superset import app
+from superset.models.core import Database
+from superset.models.helpers import (
+    AuditMixinNullable,
+    ExtraJSONMixin,
+    ImportExportMixin,
+)
+
+app_config = app.config
+
+
+class SSHTunnel(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin):
+    """
+    A ssh tunnel configuration in a database.
+    """
+
+    __tablename__ = "ssh_tunnels"
+
+    id = sa.Column(sa.Integer, primary_key=True)
+    database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False)
+    database: Database = relationship(
+        "Database",
+        backref=backref("ssh_tunnels", cascade="all, delete-orphan"),
+        foreign_keys=[database_id],
+    )
+
+    server_address = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))
+    server_port = sa.Column(EncryptedType(sa.Integer, app_config["SECRET_KEY"]))

Review Comment:
   We don't need to encrypt these.



##########
superset/migrations/versions/2022-10-20_10-48_f3c2d8ec8595_create_ssh_tunnel_credentials_tbl.py:
##########
@@ -0,0 +1,80 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""create_ssh_tunnel_credentials_tbl
+
+Revision ID: f3c2d8ec8595
+Revises: deb4c9d4a4ef
+Create Date: 2022-10-20 10:48:08.722861
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "f3c2d8ec8595"
+down_revision = "deb4c9d4a4ef"
+
+from uuid import uuid4
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy_utils import EncryptedType, UUIDType
+
+from superset import app
+
+app_config = app.config
+
+
+def upgrade():

Review Comment:
   @craig-rueda I think we're always going to have this discussion... :)
   
   Having migrations in separate PRs really helps when you need to cherry-pick a PR with a migration, since when that happens you also need to cherry-pick every PR that has a migration in between, regardless of what it is. If those intermediary PRs are harmless DB migrations the cherry-pick is **much** easier.
   
   My recommendation has been: work on a single PR, since as you said the migrations tend to evolve during development. When the PR is ready, split it into 2. This of course assumes that the DB migration can live independently from the code (eg, it adds a new table or a new column).



##########
superset/databases/ssh_tunnel/models.py:
##########
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any, Dict
+
+import sqlalchemy as sa
+from flask_appbuilder import Model
+from sqlalchemy.orm import backref, relationship
+from sqlalchemy_utils import EncryptedType
+
+from superset import app
+from superset.models.core import Database
+from superset.models.helpers import (
+    AuditMixinNullable,
+    ExtraJSONMixin,
+    ImportExportMixin,
+)
+
+app_config = app.config
+
+
+class SSHTunnel(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin):
+    """
+    A ssh tunnel configuration in a database.
+    """
+
+    __tablename__ = "ssh_tunnels"
+
+    id = sa.Column(sa.Integer, primary_key=True)
+    database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False)
+    database: Database = relationship(
+        "Database",
+        backref=backref("ssh_tunnels", cascade="all, delete-orphan"),
+        foreign_keys=[database_id],
+    )
+
+    server_address = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))
+    server_port = sa.Column(EncryptedType(sa.Integer, app_config["SECRET_KEY"]))
+    username = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))
+
+    # basic authentication
+    password = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+
+    # password protected pkey authentication
+    private_key = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+    private_key_password = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+
+    bind_host = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))

Review Comment:
   Me too, we can read these from the SQLAlchemy URI.



##########
superset/databases/ssh_tunnel/models.py:
##########
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any, Dict
+
+import sqlalchemy as sa
+from flask_appbuilder import Model
+from sqlalchemy.orm import backref, relationship
+from sqlalchemy_utils import EncryptedType
+
+from superset import app
+from superset.models.core import Database
+from superset.models.helpers import (
+    AuditMixinNullable,
+    ExtraJSONMixin,
+    ImportExportMixin,
+)
+
+app_config = app.config
+
+
+class SSHTunnel(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin):
+    """
+    A ssh tunnel configuration in a database.
+    """
+
+    __tablename__ = "ssh_tunnels"
+
+    id = sa.Column(sa.Integer, primary_key=True)
+    database_id = sa.Column(sa.Integer, sa.ForeignKey("dbs.id"), nullable=False)
+    database: Database = relationship(
+        "Database",
+        backref=backref("ssh_tunnels", cascade="all, delete-orphan"),
+        foreign_keys=[database_id],
+    )
+
+    server_address = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))
+    server_port = sa.Column(EncryptedType(sa.Integer, app_config["SECRET_KEY"]))
+    username = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))
+
+    # basic authentication
+    password = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+
+    # password protected pkey authentication
+    private_key = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+    private_key_password = sa.Column(
+        EncryptedType(sa.String, app_config["SECRET_KEY"]), nullable=True
+    )
+
+    bind_host = sa.Column(EncryptedType(sa.String, app_config["SECRET_KEY"]))
+    bind_port = sa.Column(EncryptedType(sa.Integer, app_config["SECRET_KEY"]))
+
+    def parameters(self) -> Dict[str, Any]:
+        params = {
+            "ssh_address_or_host": self.server_address,
+            "ssh_port": self.server_port,
+            "ssh_username": self.username,
+            "remote_bind_address": (self.bind_host, self.bind_port),
+            "local_bind_address": ("127.0.0.1",),
+        }
+
+        if self.password:
+            params["ssh_password"] = self.password
+        elif self.private_key:
+            params["ssh_pkey"] = self.private_key
+            params["ssh_private_key_password"] = self.private_key_password

Review Comment:
   Can we please use either `pkey` or `private_key` to indicate a private key?



##########
superset/migrations/versions/2022-10-20_10-48_f3c2d8ec8595_create_ssh_tunnel_credentials_tbl.py:
##########
@@ -0,0 +1,78 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""create_ssh_tunnel_credentials_tbl
+
+Revision ID: f3c2d8ec8595
+Revises: deb4c9d4a4ef
+Create Date: 2022-10-20 10:48:08.722861
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "f3c2d8ec8595"
+down_revision = "deb4c9d4a4ef"
+
+from uuid import uuid4
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy_utils import UUIDType
+
+from superset import app
+from superset.extensions import encrypted_field_factory
+
+app_config = app.config
+
+
+def upgrade():
+    op.create_table(
+        "ssh_tunnels",
+        # AuditMixinNullable
+        sa.Column("created_on", sa.DateTime(), nullable=True),
+        sa.Column("changed_on", sa.DateTime(), nullable=True),
+        sa.Column("created_by_fk", sa.Integer(), nullable=True),
+        sa.Column("changed_by_fk", sa.Integer(), nullable=True),
+        # ExtraJSONMixin
+        sa.Column("extra_json", sa.Text(), nullable=True),
+        # ImportExportMixin
+        sa.Column("uuid", UUIDType(binary=True), primary_key=False, default=uuid4),
+        # SSHTunnelCredentials
+        sa.Column("id", sa.Integer(), primary_key=True),
+        sa.Column("database_id", sa.INTEGER(), sa.ForeignKey("dbs.id")),

Review Comment:
   ^



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org