Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
08980a0
feat(ingestion): add OCI Autonomous Database support for Oracle conne…
hassaansaleem28 Apr 18, 2026
e170023
fix bot suggestions
hassaansaleem28 Apr 18, 2026
623f99e
Update ingestion/src/metadata/ingestion/source/database/oracle/connec…
hassaansaleem28 Apr 18, 2026
2c6c311
Update ingestion/src/metadata/ingestion/source/database/oracle/connec…
hassaansaleem28 Apr 18, 2026
4f23116
Update ingestion/src/metadata/ingestion/source/database/oracle/connec…
hassaansaleem28 Apr 19, 2026
9deb3fd
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 Apr 19, 2026
d426b5d
fix copilot suggestions
hassaansaleem28 Apr 19, 2026
998d4fd
fix(oracle): cleanup inline wallet temp dir on connect failure
hassaansaleem28 Apr 19, 2026
9c0a3f0
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 Apr 24, 2026
ba98a45
docs(oracle): clarify walletContent base64 encoding for Docker/K8s
hassaansaleem28 Apr 25, 2026
43e1b80
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 Apr 25, 2026
e991b85
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 Apr 28, 2026
6453951
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 Apr 28, 2026
d76b997
fix(oracle): address review feedback on autonomous connection
hassaansaleem28 Apr 28, 2026
e06b07d
fix(oracle): drop anyOf from autonomous schema to keep generated mode…
hassaansaleem28 Apr 29, 2026
f0c823e
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 Apr 29, 2026
12b4169
Update ingestion/src/metadata/ingestion/source/database/oracle/connec…
hassaansaleem28 Apr 29, 2026
e4e67c5
Update openmetadata-spec/src/main/resources/json/schema/entity/servic…
hassaansaleem28 Apr 29, 2026
7cac5f3
Update openmetadata-ui/src/main/resources/ui/public/locales/en-US/Dat…
hassaansaleem28 Apr 29, 2026
6e3c514
Update openmetadata-spec/src/main/resources/json/schema/entity/servic…
hassaansaleem28 Apr 29, 2026
a1ad81f
fix(oracle): satisfy basedpyright on connection_arguments narrowing
hassaansaleem28 Apr 29, 2026
3e97044
fix(oracle): create wallet subdirectories with 0o700 permissions
hassaansaleem28 Apr 29, 2026
d292bca
fix(oracle): tolerate wrapped base64 in walletContent
hassaansaleem28 Apr 29, 2026
ecbfcc8
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 Apr 29, 2026
ff74d1a
fix(oracle): guard _mkdir_secure_within against unbounded recursion
hassaansaleem28 Apr 29, 2026
a3a4bea
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 Apr 29, 2026
62594a4
refactor(oracle): remove pass-through _get_autonomous_connection_config
hassaansaleem28 Apr 30, 2026
cd93ec2
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 4, 2026
ba9d067
Potential fix for pull request finding
hassaansaleem28 May 4, 2026
73d5ea6
Update generated TypeScript types
hassaansaleem28 May 4, 2026
dfa1f97
Update generated TypeScript types
hassaansaleem28 May 4, 2026
a50af0a
Potential fix for pull request finding
hassaansaleem28 May 4, 2026
d2a58c4
fix(oracle): restore X | None over Optional[X] for ruff UP045
hassaansaleem28 May 4, 2026
ee91fb9
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 4, 2026
6ecf6c3
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 5, 2026
cc88740
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 6, 2026
4ecc9a4
Potential fix for pull request finding
hassaansaleem28 May 6, 2026
90f9879
Potential fix for pull request finding
hassaansaleem28 May 6, 2026
1d1f94e
Potential fix for pull request finding
hassaansaleem28 May 6, 2026
473c89d
fix(oracle): switch _wallet_temp_dir back to X | None for ruff UP045
hassaansaleem28 May 6, 2026
4aa5095
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 6, 2026
5993897
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 6, 2026
cc9b365
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 7, 2026
8eedea1
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 7, 2026
2249cd7
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 7, 2026
8067108
Merge branch 'main' into issue-27443-oci-autonomous
hassaansaleem28 May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 165 additions & 12 deletions ingestion/src/metadata/ingestion/source/database/oracle/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@
"""
Source connection handler
"""
import base64
import io
import os
import shutil
import sys
import tempfile
import weakref
import zipfile
from copy import deepcopy
from typing import Optional
from typing import Any, Optional
from urllib.parse import quote_plus

import oracledb
Expand All @@ -30,6 +36,7 @@
OracleConnection as OracleConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.oracleConnection import (
OracleAutonomousConnection,
OracleDatabaseSchema,
OracleServiceName,
OracleTNSConnection,
Expand All @@ -41,6 +48,7 @@
create_generic_db_connection,
get_connection_args_common,
get_connection_options_dict,
init_empty_connection_arguments,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.connections.secrets import connection_with_options_secrets
Expand All @@ -67,22 +75,153 @@
class OracleConnection(BaseConnection[OracleConnectionConfig, Engine]):
def __init__(self, connection: OracleConnectionConfig):
super().__init__(connection)
self._wallet_temp_dir: Optional[str] = None
self._wallet_cleanup_finalizer: Optional[weakref.finalize] = None

def _set_wallet_temp_dir(self, wallet_temp_dir: str) -> None:
self._cleanup_wallet_temp_dir()
self._wallet_temp_dir = wallet_temp_dir
self._wallet_cleanup_finalizer = weakref.finalize(
self,
shutil.rmtree,
wallet_temp_dir,
ignore_errors=True,
)

def _cleanup_wallet_temp_dir(self) -> None:
wallet_temp_dir = self._wallet_temp_dir
if self._wallet_cleanup_finalizer and self._wallet_cleanup_finalizer.alive:
self._wallet_cleanup_finalizer()
elif wallet_temp_dir:
shutil.rmtree(wallet_temp_dir, ignore_errors=True)

self._wallet_cleanup_finalizer = None
self._wallet_temp_dir = None

def _is_autonomous_connection(self) -> bool:
return isinstance(
self.service_connection.oracleConnectionType, OracleAutonomousConnection
)

@staticmethod
def _get_autonomous_connection_config(
connection_type: OracleAutonomousConnection,
) -> Any:
return connection_type.root
Comment thread
hassaansaleem28 marked this conversation as resolved.
Outdated

@staticmethod
def _safe_extract_wallet_archive(zip_ref: zipfile.ZipFile, target_dir: str) -> None:
target_dir_real = os.path.realpath(target_dir)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would this work in Docker/K8s deployed pod? How are asking users to pass this zipfile?

Copy link
Copy Markdown
Contributor Author

@hassaansaleem28 hassaansaleem28 Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great catch @harshach, there are actually two paths:

walletPath : for on-prem/bare-metal where the wallet is already extracted on the ingestion host. Not usable in Docker/K8s without volume mounts.

walletContent : for Docker/K8s and fully UI-driven setup. Users download the wallet zip from the Oracle Cloud Console, base64-encode it locally (base64 -w 0 Wallet_mydb.zip), and paste the result into the walletContent password field. OpenMetadata stores it securely, decodes it at runtime, extracts to a temp dir inside the pod, and cleans up afterward.

i also noticed i missed running yarn parse-schema to regenerate the pre-built UI schema OracleAutonomousConnection is currently absent from the form. Will push that fix now.

safe_prefix = f"{target_dir_real}{os.sep}"

for member in zip_ref.infolist():
member_path = os.path.realpath(
os.path.join(target_dir_real, member.filename)
)

if (
not member_path.startswith(safe_prefix)
and member_path != target_dir_real
):
raise ValueError(
"Invalid walletContent. Wallet zip contains unsafe file paths."
)

if member.is_dir():
os.makedirs(member_path, exist_ok=True)
continue

os.makedirs(os.path.dirname(member_path), exist_ok=True)
with zip_ref.open(member, "r") as source_file, open(
member_path, "wb"
Comment thread
hassaansaleem28 marked this conversation as resolved.
Outdated
) as target_file:
shutil.copyfileobj(source_file, target_file)

def _extract_wallet_content(self, wallet_content: SecretStr) -> str:
try:
decoded_wallet = base64.b64decode(wallet_content.get_secret_value())
except (ValueError, TypeError) as exc:
raise ValueError(
"Invalid walletContent. Expected a base64-encoded wallet zip."
) from exc
Comment thread
hassaansaleem28 marked this conversation as resolved.
Outdated
Comment thread
hassaansaleem28 marked this conversation as resolved.
Outdated

wallet_temp_dir = tempfile.mkdtemp(prefix="oracle_wallet_")
self._set_wallet_temp_dir(wallet_temp_dir)

try:
with zipfile.ZipFile(io.BytesIO(decoded_wallet)) as zip_ref:
self._safe_extract_wallet_archive(zip_ref, wallet_temp_dir)
except (ValueError, zipfile.BadZipFile) as exc:
self._cleanup_wallet_temp_dir()
if isinstance(exc, zipfile.BadZipFile):
raise ValueError(
"Invalid walletContent. Expected a valid zip archive."
) from exc
raise

return wallet_temp_dir

def _configure_autonomous_connection_arguments(self) -> None:
connection_type = self.service_connection.oracleConnectionType
if not isinstance(connection_type, OracleAutonomousConnection):
return

autonomous_connection = self._get_autonomous_connection_config(connection_type)
if not self.service_connection.connectionArguments:
self.service_connection.connectionArguments = (
init_empty_connection_arguments()
)
elif self.service_connection.connectionArguments.root is None:
self.service_connection.connectionArguments.root = {}

connection_arguments = self.service_connection.connectionArguments.root

wallet_path = autonomous_connection.walletPath
if autonomous_connection.walletContent:
if self._wallet_temp_dir and os.path.isdir(self._wallet_temp_dir):
wallet_path = self._wallet_temp_dir
else:
wallet_path = self._extract_wallet_content(
autonomous_connection.walletContent
)
else:
self._cleanup_wallet_temp_dir()

if not wallet_path:
raise ValueError(
"Oracle Autonomous connections require either walletPath or walletContent."
)

connection_arguments["config_dir"] = wallet_path
connection_arguments["wallet_location"] = wallet_path

if autonomous_connection.walletPassword:
connection_arguments[
"wallet_password"
] = autonomous_connection.walletPassword.get_secret_value()
else:
connection_arguments.pop("wallet_password", None)

def _get_client(self) -> Engine:
"""
Create connection
"""
try:
if self.service_connection.instantClientDirectory:
logger.info(
f"Initializing Oracle thick client at {self.service_connection.instantClientDirectory}"
)
os.environ[LD_LIB_ENV] = self.service_connection.instantClientDirectory
oracledb.init_oracle_client(
lib_dir=self.service_connection.instantClientDirectory
)
except DatabaseError as err:
logger.info(f"Could not initialize Oracle thick client: {err}")
self._configure_autonomous_connection_arguments()
Comment thread
gitar-bot[bot] marked this conversation as resolved.

if not self._is_autonomous_connection():
try:
if self.service_connection.instantClientDirectory:
logger.info(
f"Initializing Oracle thick client at {self.service_connection.instantClientDirectory}"
)
os.environ[
LD_LIB_ENV
] = self.service_connection.instantClientDirectory
oracledb.init_oracle_client(
lib_dir=self.service_connection.instantClientDirectory
)
except DatabaseError as err:
logger.info(f"Could not initialize Oracle thick client: {err}")

Comment thread
hassaansaleem28 marked this conversation as resolved.
Comment thread
hassaansaleem28 marked this conversation as resolved.
return create_generic_db_connection(
connection=self.service_connection,
Expand Down Expand Up @@ -150,6 +289,13 @@ def get_connection_dict(self) -> dict:
connection_dict[
"host"
] = connection_copy.oracleConnectionType.oracleTNSConnection
elif isinstance(
connection_copy.oracleConnectionType, OracleAutonomousConnection
):
autonomous_connection = self._get_autonomous_connection_config(
connection_copy.oracleConnectionType
)
connection_dict["host"] = autonomous_connection.tnsAlias

# Add connection options if present
if connection_copy.connectionOptions and connection_copy.connectionOptions.root:
Expand Down Expand Up @@ -209,6 +355,13 @@ def _handle_connection_type(url: str, connection: OracleConnectionConfig) -> str
url += connection.oracleConnectionType.oracleTNSConnection
return url

if isinstance(connection.oracleConnectionType, OracleAutonomousConnection):
autonomous_connection = OracleConnection._get_autonomous_connection_config(
connection.oracleConnectionType
)
url += autonomous_connection.tnsAlias
return url

# If not TNS, we add the hostPort
url += connection.hostPort

Expand Down
146 changes: 146 additions & 0 deletions ingestion/tests/unit/test_source_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import base64
import io
import os
import zipfile
from unittest import TestCase
from unittest.mock import patch

from trino.auth import BasicAuthentication, JWTAuthentication, OAuth2Authentication

Expand Down Expand Up @@ -78,6 +83,7 @@
OracleConnection as OracleConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.oracleConnection import (
OracleAutonomousConnection,
OracleDatabaseSchema,
OracleScheme,
OracleServiceName,
Expand Down Expand Up @@ -1227,6 +1233,146 @@ def test_oracle_url(self):
)
assert OracleConnection.get_connection_url(oracle_conn_obj) == expected_url

expected_url = "oracle+cx_oracle://admin:password@myadb_high"
oracle_conn_obj = OracleConnectionConfig(
username="admin",
password="password",
oracleConnectionType=OracleAutonomousConnection(
tnsAlias="myadb_high",
walletPath="/tmp/my_wallet",
),
)
assert OracleConnection.get_connection_url(oracle_conn_obj) == expected_url

expected_url = [
"oracle+cx_oracle://admin:password@myadb_high?test_key_2=test_value_2&test_key_1=test_value_1",
"oracle+cx_oracle://admin:password@myadb_high?test_key_1=test_value_1&test_key_2=test_value_2",
]
oracle_conn_obj = OracleConnectionConfig(
username="admin",
password="password",
oracleConnectionType=OracleAutonomousConnection(
tnsAlias="myadb_high",
walletPath="/tmp/my_wallet",
),
connectionOptions=dict(
test_key_1="test_value_1", test_key_2="test_value_2"
),
)
assert OracleConnection.get_connection_url(oracle_conn_obj) in expected_url

@patch(
"metadata.ingestion.source.database.oracle.connection.oracledb.init_oracle_client"
)
@patch(
"metadata.ingestion.source.database.oracle.connection.create_generic_db_connection"
)
def test_oracle_autonomous_wallet_path_args(
self, mock_create_generic_db_connection, mock_init_oracle_client
):
connection = OracleConnectionConfig(
username="admin",
password="password",
instantClientDirectory="/instantclient",
oracleConnectionType=OracleAutonomousConnection(
tnsAlias="myadb_high",
walletPath="/tmp/my_wallet",
walletPassword="wallet_password",
),
)
oracle_connection = OracleConnection(connection)
mock_create_generic_db_connection.return_value = "dummy_engine"

oracle_connection._get_client()

assert mock_init_oracle_client.call_count == 0
assert (
oracle_connection.service_connection.connectionArguments.root["config_dir"]
== "/tmp/my_wallet"
)
assert (
oracle_connection.service_connection.connectionArguments.root[
"wallet_location"
]
== "/tmp/my_wallet"
)
assert (
oracle_connection.service_connection.connectionArguments.root[
"wallet_password"
]
== "wallet_password"
)

@patch(
"metadata.ingestion.source.database.oracle.connection.create_generic_db_connection"
)
def test_oracle_autonomous_wallet_content_args(
self, mock_create_generic_db_connection
):
wallet_bytes = io.BytesIO()
with zipfile.ZipFile(wallet_bytes, "w", zipfile.ZIP_DEFLATED) as zip_file:
zip_file.writestr("tnsnames.ora", "MYADB_HIGH=(DESCRIPTION=...)")

encoded_wallet = base64.b64encode(wallet_bytes.getvalue()).decode("utf-8")

connection = OracleConnectionConfig(
username="admin",
password="password",
oracleConnectionType=OracleAutonomousConnection(
tnsAlias="myadb_high",
walletContent=encoded_wallet,
),
)
oracle_connection = OracleConnection(connection)
mock_create_generic_db_connection.return_value = "dummy_engine"

oracle_connection._get_client()

wallet_dir = oracle_connection.service_connection.connectionArguments.root[
"config_dir"
]
assert os.path.isdir(wallet_dir)
assert os.path.exists(os.path.join(wallet_dir, "tnsnames.ora"))

# Repeated _get_client calls should reuse the same extracted wallet directory.
oracle_connection._get_client()
assert (
oracle_connection.service_connection.connectionArguments.root["config_dir"]
== wallet_dir
)

oracle_connection._cleanup_wallet_temp_dir()
assert not os.path.exists(wallet_dir)

@patch(
"metadata.ingestion.source.database.oracle.connection.create_generic_db_connection"
)
def test_oracle_autonomous_wallet_content_zip_slip_rejected(
self, mock_create_generic_db_connection
):
wallet_bytes = io.BytesIO()
with zipfile.ZipFile(wallet_bytes, "w", zipfile.ZIP_DEFLATED) as zip_file:
zip_file.writestr("../malicious.txt", "malicious")

encoded_wallet = base64.b64encode(wallet_bytes.getvalue()).decode("utf-8")

connection = OracleConnectionConfig(
username="admin",
password="password",
oracleConnectionType=OracleAutonomousConnection(
tnsAlias="myadb_high",
walletContent=encoded_wallet,
),
)
oracle_connection = OracleConnection(connection)
mock_create_generic_db_connection.return_value = "dummy_engine"

with self.assertRaises(ValueError) as error:
oracle_connection._get_client()

assert "unsafe file paths" in str(error.exception)
assert oracle_connection._wallet_temp_dir is None

def test_exasol_url(self):
from metadata.ingestion.source.database.exasol.connection import (
get_connection_url,
Expand Down
Loading
Loading