From 751cf5e7028db65c9cd13701baff42d6e9c768bb Mon Sep 17 00:00:00 2001 From: Aleko Zhelyazkov Date: Wed, 28 Oct 2026 14:12:10 +0200 Subject: [PATCH 1/2] PIM-9561 Add external db parameters to init_master and init_application_server SDK methods --- cterasdk/core/enum.py | 24 ++ cterasdk/core/external_database.py | 205 ++++++++++++++++++ cterasdk/core/setup.py | 132 ++++++++++- .../UserGuides/Portal/Administration.rst | 35 +++ tests/ut/core/admin/test_external_database.py | 112 ++++++++++ tests/ut/core/admin/test_setup.py | 109 +++++++++- 6 files changed, 606 insertions(+), 11 deletions(-) create mode 100644 cterasdk/core/external_database.py create mode 100644 tests/ut/core/admin/test_external_database.py diff --git a/cterasdk/core/enum.py b/cterasdk/core/enum.py index abc410d6..f41b3706 100644 --- a/cterasdk/core/enum.py +++ b/cterasdk/core/enum.py @@ -263,6 +263,30 @@ class SetupWizardStage: Finish = "finish" +class DatabaseMode: + """ + Portal database deployment mode during server initialization. + + :ivar str Embedded: Portal-managed local PostgreSQL (default) + :ivar str External: Customer-managed external PostgreSQL (Enterprise DB) + """ + Embedded = 'embedded' + External = 'external' + + +class ExternalDbAuthMethod: + """ + External database authentication method for Enterprise DB init. + + :ivar str Credentials: Username and password + :ivar str Certificate: TLS client certificate and private key + :ivar str Token: Token-based authentication (not yet supported by the portal) + """ + Credentials = 'credentials' + Certificate = 'certificate' + Token = 'token' + + class SlaveAuthenticaionMethod: """ Secondary Portal server authentication mode diff --git a/cterasdk/core/external_database.py b/cterasdk/core/external_database.py new file mode 100644 index 00000000..c0b6326e --- /dev/null +++ b/cterasdk/core/external_database.py @@ -0,0 +1,205 @@ +from cterasdk.common import Object +from cterasdk.core.enum import DatabaseMode, ExternalDbAuthMethod +from cterasdk.exceptions import CTERAException +from cterasdk.lib.crypto import PrivateKey, X509Certificate + +DEFAULT_EXT_DB_PORT = 5432 +DEFAULT_EXT_DB_NAME = 'postgres' +DEFAULT_EXT_DB_SSL_MODE = 'verify-full' + +_SSL_MODES_REQUIRING_CA = frozenset({'verify-ca', 'verify-full'}) + + +def auth_method_to_schema_auth_mode(auth_method): + if auth_method in (ExternalDbAuthMethod.Certificate, 'certificate', 'cert', 'Cert'): + return 'Cert' + if auth_method in (ExternalDbAuthMethod.Token, 'token', 'Token'): + return 'Token' + return 'Credentials' + + +def _normalize_database_mode(database_mode): + if database_mode is None: + return DatabaseMode.Embedded + if database_mode in (DatabaseMode.Embedded, DatabaseMode.External): + return database_mode + if str(database_mode).lower() == DatabaseMode.External: + return DatabaseMode.External + if str(database_mode).lower() == DatabaseMode.Embedded: + return DatabaseMode.Embedded + raise CTERAException(f"Invalid database_mode: {database_mode}") + + +def _normalize_auth_method(ext_db_auth_method): + if ext_db_auth_method is None: + return ExternalDbAuthMethod.Credentials + if ext_db_auth_method in ( + ExternalDbAuthMethod.Credentials, + ExternalDbAuthMethod.Certificate, + ExternalDbAuthMethod.Token, + ): + return ext_db_auth_method + normalized = str(ext_db_auth_method).lower() + if normalized in ('credentials', 'credential'): + return ExternalDbAuthMethod.Credentials + if normalized in ('certificate', 'cert'): + return ExternalDbAuthMethod.Certificate + if normalized == 'token': + return ExternalDbAuthMethod.Token + raise CTERAException(f"Invalid ext_db_auth_method: {ext_db_auth_method}") + + +def _normalize_ssl_mode(ssl_mode): + if ssl_mode is None: + return DEFAULT_EXT_DB_SSL_MODE + mode = str(ssl_mode).lower() + if mode == 'disable': + raise CTERAException('ext_db_ssl_mode disable is not supported for external database mode') + return ssl_mode + + +def _is_inline_pem(material): + return isinstance(material, str) and '-----BEGIN' in material + + +def _pem_certificate_content(cert_material): + if cert_material is None: + return None + if _is_inline_pem(cert_material): + loaded = X509Certificate.from_string(cert_material) + else: + loaded = X509Certificate.load_certificate(cert_material) + return loaded.pem_data.decode('utf-8') + + +def _pem_private_key_content(key_material): + if key_material is None: + return None + if _is_inline_pem(key_material): + loaded = PrivateKey.from_string(key_material) + else: + loaded = PrivateKey.load_private_key(key_material) + return loaded.pem_data.decode('utf-8') + + +def _any_external_field_set(**fields): + return any(value is not None for value in fields.values()) + + +def resolve_init_database_configuration( # pylint: disable=too-many-arguments,too-many-locals + database_mode=DatabaseMode.Embedded, + ext_db_host=None, + ext_db_port=DEFAULT_EXT_DB_PORT, + ext_db_name=DEFAULT_EXT_DB_NAME, + ext_db_user=None, + ext_db_password=None, + ext_db_auth_method=None, + ext_db_ssl_mode=None, + ext_db_ssl_ca_cert=None, + ext_db_ssl_client_cert=None, + ext_db_ssl_client_key=None, +): + mode = _normalize_database_mode(database_mode) + auth_method = _normalize_auth_method(ext_db_auth_method) + ssl_mode = _normalize_ssl_mode(ext_db_ssl_mode) + + ext_fields = { + 'ext_db_host': ext_db_host, + 'ext_db_port': ext_db_port if ext_db_port != DEFAULT_EXT_DB_PORT else None, + 'ext_db_name': ext_db_name if ext_db_name != DEFAULT_EXT_DB_NAME else None, + 'ext_db_user': ext_db_user, + 'ext_db_password': ext_db_password, + 'ext_db_auth_method': ext_db_auth_method, + 'ext_db_ssl_mode': ext_db_ssl_mode, + 'ext_db_ssl_ca_cert': ext_db_ssl_ca_cert, + 'ext_db_ssl_client_cert': ext_db_ssl_client_cert, + 'ext_db_ssl_client_key': ext_db_ssl_client_key, + } + + if mode == DatabaseMode.Embedded: + if _any_external_field_set(**ext_fields): + raise CTERAException( + 'External database parameters require database_mode=external') + return mode, None + + if auth_method == ExternalDbAuthMethod.Token: + raise CTERAException('ext_db_auth_method token is not yet supported by the portal') + + if not ext_db_host: + raise CTERAException('ext_db_host is required when database_mode is external') + if not ext_db_user: + raise CTERAException('ext_db_user is required when database_mode is external') + + if auth_method == ExternalDbAuthMethod.Credentials: + if not ext_db_password: + raise CTERAException( + 'ext_db_password is required when ext_db_auth_method is credentials') + elif auth_method == ExternalDbAuthMethod.Certificate: + if not ext_db_ssl_client_cert or not ext_db_ssl_client_key: + raise CTERAException( + 'ext_db_ssl_client_cert and ext_db_ssl_client_key are required for certificate authentication') + + ssl_mode_normalized = str(ssl_mode).lower() + if ssl_mode_normalized in _SSL_MODES_REQUIRING_CA and not ext_db_ssl_ca_cert: + raise CTERAException( + f'ext_db_ssl_ca_cert is required when ext_db_ssl_mode is {ssl_mode}') + + config = build_external_database_config( + host=ext_db_host, + port=ext_db_port, + database_name=ext_db_name, + username=ext_db_user, + password=ext_db_password, + ssl_mode=ssl_mode, + auth_method=auth_method, + ssl_ca_cert=ext_db_ssl_ca_cert, + ssl_client_cert=ext_db_ssl_client_cert, + ssl_client_key=ext_db_ssl_client_key, + ) + return mode, config + + +def build_external_database_config( + host, + port=DEFAULT_EXT_DB_PORT, + database_name=DEFAULT_EXT_DB_NAME, + username=None, + password=None, + ssl_mode=DEFAULT_EXT_DB_SSL_MODE, + auth_method=ExternalDbAuthMethod.Credentials, + ssl_ca_cert=None, + ssl_client_cert=None, + ssl_client_key=None, +): + config = Object() + config._classname = 'ExternalDatabaseConfig' # pylint: disable=protected-access + config.host = host + config.port = port + config.databaseName = database_name + config.username = username + config.sslMode = ssl_mode + config.authMode = auth_method_to_schema_auth_mode(auth_method) + + if password is not None: + config.password = password + + ca_pem = _pem_certificate_content(ssl_ca_cert) + if ca_pem is not None: + config.sslCaCert = ca_pem + + client_cert_pem = _pem_certificate_content(ssl_client_cert) + if client_cert_pem is not None: + config.authCert = client_cert_pem + + client_key_pem = _pem_private_key_content(ssl_client_key) + if client_key_pem is not None: + config.authKey = client_key_pem + + return config + + +def apply_database_mode_to_init_params(params, database_mode, external_database_config): + if database_mode != DatabaseMode.External: + return + params.databaseMode = DatabaseMode.External + params.externalDatabaseConfig = external_database_config diff --git a/cterasdk/core/setup.py b/cterasdk/core/setup.py index db914308..c934e84d 100644 --- a/cterasdk/core/setup.py +++ b/cterasdk/core/setup.py @@ -2,7 +2,14 @@ import time from .base_command import BaseCommand -from .enum import ServerMode, SetupWizardStage, SetupWizardStatus, SlaveAuthenticaionMethod +from .enum import DatabaseMode, ServerMode, SetupWizardStage, SetupWizardStatus, SlaveAuthenticaionMethod +from .external_database import ( + DEFAULT_EXT_DB_NAME, + DEFAULT_EXT_DB_PORT, + DEFAULT_EXT_DB_SSL_MODE, + apply_database_mode_to_init_params, + resolve_init_database_configuration, +) from ..common import Object, utf8_decode from ..convert import toxmlstr from ..clients.common import MultipartForm @@ -26,7 +33,27 @@ def _get_current_stage(self): self.stage = status.wizard return self.stage - def init_master(self, name, email, first_name, last_name, password, domain): + def init_master( # pylint: disable=too-many-arguments,too-many-locals + self, + name, + email, + first_name, + last_name, + password, + domain, + *, + database_mode=DatabaseMode.Embedded, + ext_db_host=None, + ext_db_port=DEFAULT_EXT_DB_PORT, + ext_db_name=DEFAULT_EXT_DB_NAME, + ext_db_user=None, + ext_db_password=None, + ext_db_auth_method=None, + ext_db_ssl_mode=None, + ext_db_ssl_ca_cert=None, + ext_db_ssl_client_cert=None, + ext_db_ssl_client_key=None, + ): """ Initialize the CTERA Portal master server. @@ -36,10 +63,45 @@ def init_master(self, name, email, first_name, last_name, password, domain): :param str last_name: The last name of the new user :param str password: Password for the new user :param str domain: The domain suffix for CTERA Portal + :param str database_mode: ``embedded`` (default) or ``external`` for Enterprise DB + :param str ext_db_host: External PostgreSQL hostname or VIP (required in external mode) + :param int ext_db_port: External PostgreSQL port (default 5432) + :param str ext_db_name: Database name on the external instance (default postgres) + :param str ext_db_user: External database username (required in external mode) + :param str ext_db_password: External database password (credentials auth) + :param str ext_db_auth_method: ``credentials`` (default), ``certificate``, or ``token`` + :param str ext_db_ssl_mode: ``require``, ``verify-ca``, or ``verify-full`` (default verify-full) + :param str ext_db_ssl_ca_cert: Path or PEM string for the CA certificate + :param str ext_db_ssl_client_cert: Path or PEM string for the client certificate (certificate auth) + :param str ext_db_ssl_client_key: Path or PEM string for the client private key (certificate auth) """ self._get_current_stage() - params = Setup._init_server_params(ServerMode.Master) + db_mode, ext_config = resolve_init_database_configuration( + database_mode=database_mode, + ext_db_host=ext_db_host, + ext_db_port=ext_db_port, + ext_db_name=ext_db_name, + ext_db_user=ext_db_user, + ext_db_password=ext_db_password, + ext_db_auth_method=ext_db_auth_method, + ext_db_ssl_mode=ext_db_ssl_mode, + ext_db_ssl_ca_cert=ext_db_ssl_ca_cert, + ext_db_ssl_client_cert=ext_db_ssl_client_cert, + ext_db_ssl_client_key=ext_db_ssl_client_key, + ) + if db_mode == DatabaseMode.External: + logger.info( + 'Initializing master with external database. %s', + { + 'host': ext_db_host, + 'port': ext_db_port, + 'database': ext_db_name, + 'ssl_mode': ext_db_ssl_mode or DEFAULT_EXT_DB_SSL_MODE, + }, + ) + + params = Setup._init_server_params(ServerMode.Master, db_mode, ext_config) self._init_server(params, True) if self.stage == SetupWizardStage.Portal: @@ -64,10 +126,10 @@ def init_master(self, name, email, first_name, last_name, password, domain): logger.warning('Portal already initialized. %s', {'host': self._core.host()}) self._core.startup.wait() - def _init_slave(self, ipaddr, secret): + def _init_slave(self, ipaddr, secret, database_mode=DatabaseMode.Embedded, ext_config=None): self._get_current_stage() response = self._core.ctera.execute('/setup/authenticaionMethod', 'askMasterForSlaveAuthenticaionMethod', ipaddr) - params = Setup._init_server_params(ServerMode.Slave) + params = Setup._init_server_params(ServerMode.Slave, database_mode, ext_config) params.slaveSettings.masterIpAddr = ipaddr if response == SlaveAuthenticaionMethod.Password: params.slaveSettings.masterPassword = secret @@ -95,25 +157,77 @@ def _init_server(self, params, wait=False): else: logger.warning('Server already initialized. %s', {'host': self._core.host()}) - def init_application_server(self, ipaddr, secret): + def init_application_server( # pylint: disable=too-many-arguments,too-many-locals + self, + ipaddr, + secret, + *, + database_mode=DatabaseMode.Embedded, + ext_db_host=None, + ext_db_port=DEFAULT_EXT_DB_PORT, + ext_db_name=DEFAULT_EXT_DB_NAME, + ext_db_user=None, + ext_db_password=None, + ext_db_auth_method=None, + ext_db_ssl_mode=None, + ext_db_ssl_ca_cert=None, + ext_db_ssl_client_cert=None, + ext_db_ssl_client_key=None, + ): """ Initialize a CTERA Portal Application Server. :param str ipaddr: The CTERA Portal master server IP address - :param str secret: A password or a PEM-encoded private key + :param str secret: A password or a PEM-encoded private key for SSH to the master + :param str database_mode: ``embedded`` (default) or ``external`` for Enterprise DB + :param str ext_db_host: External PostgreSQL hostname or VIP (required in external mode) + :param int ext_db_port: External PostgreSQL port (default 5432) + :param str ext_db_name: Database name on the external instance (default postgres) + :param str ext_db_user: External database username (required in external mode) + :param str ext_db_password: External database password (credentials auth) + :param str ext_db_auth_method: ``credentials`` (default), ``certificate``, or ``token`` + :param str ext_db_ssl_mode: ``require``, ``verify-ca``, or ``verify-full`` (default verify-full) + :param str ext_db_ssl_ca_cert: Path or PEM string for the CA certificate + :param str ext_db_ssl_client_cert: Path or PEM string for the client certificate (certificate auth) + :param str ext_db_ssl_client_key: Path or PEM string for the client private key (certificate auth) """ - self._init_slave(ipaddr, secret) + db_mode, ext_config = resolve_init_database_configuration( + database_mode=database_mode, + ext_db_host=ext_db_host, + ext_db_port=ext_db_port, + ext_db_name=ext_db_name, + ext_db_user=ext_db_user, + ext_db_password=ext_db_password, + ext_db_auth_method=ext_db_auth_method, + ext_db_ssl_mode=ext_db_ssl_mode, + ext_db_ssl_ca_cert=ext_db_ssl_ca_cert, + ext_db_ssl_client_cert=ext_db_ssl_client_cert, + ext_db_ssl_client_key=ext_db_ssl_client_key, + ) + if db_mode == DatabaseMode.External: + logger.info( + 'Initializing application server with external database. %s', + { + 'master': ipaddr, + 'host': ext_db_host, + 'port': ext_db_port, + 'database': ext_db_name, + 'ssl_mode': ext_db_ssl_mode or DEFAULT_EXT_DB_SSL_MODE, + }, + ) + self._init_slave(ipaddr, secret, db_mode, ext_config) logger.info('Initializing Application Server. %s', {'host': ipaddr}) self._core.startup.wait() @staticmethod - def _init_server_params(mode): + def _init_server_params(mode, database_mode=DatabaseMode.Embedded, external_database_config=None): params = Object() params._classname = 'InitServerParams' # pylint: disable=protected-access params.serverMode = mode if mode == ServerMode.Slave: params.slaveSettings = Object() params.slaveSettings._classname = 'SlaveServerSettings' # pylint: disable=protected-access + apply_database_mode_to_init_params(params, database_mode, external_database_config) return params def get_setup_status(self): diff --git a/docs/source/UserGuides/Portal/Administration.rst b/docs/source/UserGuides/Portal/Administration.rst index cd583ae1..72fed383 100644 --- a/docs/source/UserGuides/Portal/Administration.rst +++ b/docs/source/UserGuides/Portal/Administration.rst @@ -1959,6 +1959,24 @@ Setup admin.setup.init_master('admin', 'bruce.wayne@we.com', 'Bruce', 'Wayne', 'password1!', 'ctera.me') +Initialize a master against a customer-managed external PostgreSQL database (Enterprise DB). +Requires a Portal build that includes external database support (FM-421 / PIM-9542 family). +Every cluster node must use the same external endpoint and credentials. + +.. code-block:: python + + admin.setup.init_master( + 'admin', 'bruce.wayne@we.com', 'Bruce', 'Wayne', 'password1!', 'ctera.me', + database_mode='external', + ext_db_host='edb.customer.internal', + ext_db_port=5432, + ext_db_name='ctera_portal', + ext_db_user='ctera_app', + ext_db_password='your-db-password', + ext_db_ssl_mode='verify-full', + ext_db_ssl_ca_cert='/path/to/ca.pem', + ) + .. automethod:: cterasdk.core.setup.Setup.init_application_server :noindex: @@ -1974,6 +1992,23 @@ Setup master_pk = """...PEM-encoded private key...""" admin.setup.init_application_server(master_ipaddr, master_pk) +Join a secondary server to a master that uses an external database. +SSH credentials are still required to establish cluster trust with the master; +the SDK does not fetch the database password from the master in external mode. + +.. code-block:: python + + admin.setup.init_application_server( + master_ipaddr, + master_password, + database_mode='external', + ext_db_host='edb.customer.internal', + ext_db_user='ctera_app', + ext_db_password='your-db-password', + ext_db_ssl_mode='verify-full', + ext_db_ssl_ca_cert='/path/to/ca.pem', + ) + Miscellaneous ============= diff --git a/tests/ut/core/admin/test_external_database.py b/tests/ut/core/admin/test_external_database.py new file mode 100644 index 00000000..f078cd91 --- /dev/null +++ b/tests/ut/core/admin/test_external_database.py @@ -0,0 +1,112 @@ +import datetime + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +from cterasdk.core.enum import DatabaseMode, ExternalDbAuthMethod +from cterasdk.core.external_database import ( + auth_method_to_schema_auth_mode, + build_external_database_config, + resolve_init_database_configuration, +) +from cterasdk.exceptions import CTERAException +from tests.ut import base + + +class TestExternalDatabase(base.BaseTest): + + @staticmethod + def _generate_self_signed_material(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'test')]) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=1)) + .sign(key, hashes.SHA256()) + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode('utf-8') + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ).decode('utf-8') + return cert_pem, key_pem + + def test_auth_method_to_schema_auth_mode(self): + self.assertEqual(auth_method_to_schema_auth_mode(ExternalDbAuthMethod.Credentials), 'Credentials') + self.assertEqual(auth_method_to_schema_auth_mode(ExternalDbAuthMethod.Certificate), 'Cert') + self.assertEqual(auth_method_to_schema_auth_mode(ExternalDbAuthMethod.Token), 'Token') + + def test_resolve_embedded_returns_no_config(self): + mode, config = resolve_init_database_configuration(database_mode=DatabaseMode.Embedded) + self.assertEqual(mode, DatabaseMode.Embedded) + self.assertIsNone(config) + + def test_resolve_embedded_rejects_external_fields(self): + with self.assertRaises(CTERAException): + resolve_init_database_configuration( + database_mode=DatabaseMode.Embedded, + ext_db_host='db.example.com', + ) + + def test_resolve_external_requires_host_and_user(self): + with self.assertRaises(CTERAException): + resolve_init_database_configuration( + database_mode=DatabaseMode.External, + ext_db_user='app', + ext_db_password='secret', + ext_db_ssl_mode='require', + ) + + def test_resolve_external_rejects_disable_ssl(self): + with self.assertRaises(CTERAException): + resolve_init_database_configuration( + database_mode=DatabaseMode.External, + ext_db_host='db.example.com', + ext_db_user='app', + ext_db_password='secret', + ext_db_ssl_mode='disable', + ) + + def test_resolve_external_credentials_builds_config(self): + mode, config = resolve_init_database_configuration( + database_mode=DatabaseMode.External, + ext_db_host='db.example.com', + ext_db_port=5433, + ext_db_name='portal', + ext_db_user='app', + ext_db_password='secret', + ext_db_ssl_mode='require', + ) + self.assertEqual(mode, DatabaseMode.External) + self.assertEqual(config.host, 'db.example.com') + self.assertEqual(config.port, 5433) + self.assertEqual(config.databaseName, 'portal') + self.assertEqual(config.username, 'app') + self.assertEqual(config.password, 'secret') + self.assertEqual(config.sslMode, 'require') + self.assertEqual(config.authMode, 'Credentials') + + def test_build_external_database_config_with_certificate_material(self): + cert_pem, key_pem = self._generate_self_signed_material() + config = build_external_database_config( + host='db.example.com', + username='app', + password=None, + ssl_mode='verify-full', + auth_method=ExternalDbAuthMethod.Certificate, + ssl_ca_cert=cert_pem, + ssl_client_cert=cert_pem, + ssl_client_key=key_pem, + ) + self.assertEqual(config.authMode, 'Cert') + self.assertIn('BEGIN CERTIFICATE', config.sslCaCert) + self.assertIn('BEGIN CERTIFICATE', config.authCert) + self.assertIn('BEGIN', config.authKey) diff --git a/tests/ut/core/admin/test_setup.py b/tests/ut/core/admin/test_setup.py index 8c8c2ba4..4baf87de 100644 --- a/tests/ut/core/admin/test_setup.py +++ b/tests/ut/core/admin/test_setup.py @@ -2,7 +2,7 @@ from cterasdk import toxmlstr from cterasdk.core import setup -from cterasdk.core.enum import ServerMode, SlaveAuthenticaionMethod, SetupWizardStage, SetupWizardStatus +from cterasdk.core.enum import DatabaseMode, ServerMode, SlaveAuthenticaionMethod, SetupWizardStage, SetupWizardStatus from cterasdk.common import Object from tests.ut.core.admin import base_admin @@ -62,6 +62,43 @@ def _format_actual_parameters_to_dict(actual_param): d[multi_dict['name']] = value return d + def test_init_master_external_credentials(self): + self.patch_call("time.sleep") + self._init_setup() + self._global_admin.ctera.get = mock.MagicMock(side_effect=[ + TestCoreSetup._generate_status_response(SetupWizardStage.Server, SetupWizardStatus.NA, ''), + TestCoreSetup._generate_status_response(SetupWizardStage.Portal, SetupWizardStatus.NA, ''), + TestCoreSetup._generate_status_response(SetupWizardStage.Finish, SetupWizardStatus.NA, '') + ]) + self.patch_call("cterasdk.core.startup.Startup.wait") + + setup.Setup(self._global_admin).init_master( + self._admin_username, + self._admin_email, + self._admin_first_name, + self._admin_last_name, + self._admin_password, + self._domain, + database_mode=DatabaseMode.External, + ext_db_host='edb.customer.internal', + ext_db_port=5432, + ext_db_name='ctera_portal', + ext_db_user='ctera_app', + ext_db_password='db-secret', + ext_db_ssl_mode='require', + ) + + params = TestCoreSetup._get_init_server_params( + ServerMode.Master, + database_mode=DatabaseMode.External, + external_database_config=TestCoreSetup._get_external_database_config(ssl_mode='require'), + ) + expected_params = TestCoreSetup._get_form_data(params) + actual_param = TestCoreSetup._format_actual_parameters_to_dict( + self._global_admin.ctera.multipart.call_args[0][1]) + for key in actual_param.keys(): # pylint: disable=consider-using-dict-items, consider-iterating-dictionary + self._assert_equal_objects(actual_param[key], expected_params[key]) + def test_init_master_already_finished(self): self._init_setup() self._global_admin.ctera.get = mock.MagicMock(side_effect=[ @@ -119,6 +156,44 @@ def _test_init_application_server_success(self, authentication_method): self._assert_equal_objects(actual_param[key], expected_params[key]) mock_startup_wait.assert_called_once() + def test_init_application_server_external_password(self): + self.patch_call("time.sleep") + self._init_setup() + self._global_admin.ctera.get = mock.MagicMock(side_effect=[ + TestCoreSetup._generate_status_response(SetupWizardStage.Server, SetupWizardStatus.NA, ''), + TestCoreSetup._generate_status_response(SetupWizardStage.Finish, SetupWizardStatus.NA, '') + ]) + execute_side_effect = TestCoreSetup._create_init_slave_execute_function(SlaveAuthenticaionMethod.Password) + self._global_admin.ctera.execute = mock.MagicMock(side_effect=execute_side_effect) + self.patch_call("cterasdk.core.startup.Startup.wait") + + setup.Setup(self._global_admin).init_application_server( + self._master_ipaddr, + self._master_secret, + database_mode=DatabaseMode.External, + ext_db_host='edb.customer.internal', + ext_db_user='ctera_app', + ext_db_password='db-secret', + ext_db_ssl_mode='require', + ) + + params = TestCoreSetup._get_init_server_params( + ServerMode.Slave, + SlaveAuthenticaionMethod.Password, + self._master_ipaddr, + self._master_secret, + database_mode=DatabaseMode.External, + external_database_config=TestCoreSetup._get_external_database_config( + database_name='postgres', + ssl_mode='require', + ), + ) + expected_params = TestCoreSetup._get_form_data(params) + actual_param = TestCoreSetup._format_actual_parameters_to_dict( + self._global_admin.ctera.multipart.call_args[0][1]) + for key in actual_param.keys(): # pylint: disable=consider-using-dict-items, consider-iterating-dictionary + self._assert_equal_objects(actual_param[key], expected_params[key]) + def _get_init_portal_param(self): params = Object() params._classname = 'InitParams' # pylint: disable=protected-access @@ -177,7 +252,34 @@ def _get_form_data(params): return form_data @staticmethod - def _get_init_server_params(mode, authentication_method=None, master_ipaddr=None, master_secret=None): + def _get_external_database_config( + host='edb.customer.internal', + port=5432, + database_name='ctera_portal', + username='ctera_app', + password='db-secret', + ssl_mode='verify-full', + ): + config = Object() + config._classname = 'ExternalDatabaseConfig' # pylint: disable=protected-access + config.host = host + config.port = port + config.databaseName = database_name + config.username = username + config.sslMode = ssl_mode + config.authMode = 'Credentials' + config.password = password + return config + + @staticmethod + def _get_init_server_params( + mode, + authentication_method=None, + master_ipaddr=None, + master_secret=None, + database_mode=None, + external_database_config=None, + ): params = Object() params._classname = 'InitServerParams' # pylint: disable=protected-access params.serverMode = mode @@ -190,4 +292,7 @@ def _get_init_server_params(mode, authentication_method=None, master_ipaddr=None params.slaveSettings.masterPassword = master_secret elif authentication_method == SlaveAuthenticaionMethod.PrivateKey: params.slaveSettings.masterKey = master_secret + if database_mode == DatabaseMode.External: + params.databaseMode = DatabaseMode.External + params.externalDatabaseConfig = external_database_config return params From d97ade0383e73ae61e5c1fd9ef30f63253218268 Mon Sep 17 00:00:00 2001 From: Aleko Zhelyazkov Date: Wed, 28 Oct 2026 15:32:05 +0200 Subject: [PATCH 2/2] PIM-9561 Bugbot comments addressed --- cterasdk/core/external_database.py | 46 +++++++++++++++---- tests/ut/core/admin/test_external_database.py | 23 ++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/cterasdk/core/external_database.py b/cterasdk/core/external_database.py index c0b6326e..28e72f69 100644 --- a/cterasdk/core/external_database.py +++ b/cterasdk/core/external_database.py @@ -1,12 +1,16 @@ +from cryptography import x509 + from cterasdk.common import Object from cterasdk.core.enum import DatabaseMode, ExternalDbAuthMethod from cterasdk.exceptions import CTERAException -from cterasdk.lib.crypto import PrivateKey, X509Certificate +from cterasdk.lib.crypto import PrivateKey +from cterasdk.lib.storage import commonfs DEFAULT_EXT_DB_PORT = 5432 DEFAULT_EXT_DB_NAME = 'postgres' DEFAULT_EXT_DB_SSL_MODE = 'verify-full' +_ALLOWED_SSL_MODES = frozenset({'require', 'verify-ca', 'verify-full'}) _SSL_MODES_REQUIRING_CA = frozenset({'verify-ca', 'verify-full'}) @@ -52,24 +56,46 @@ def _normalize_auth_method(ext_db_auth_method): def _normalize_ssl_mode(ssl_mode): if ssl_mode is None: return DEFAULT_EXT_DB_SSL_MODE - mode = str(ssl_mode).lower() + mode = str(ssl_mode).lower().replace('_', '-') if mode == 'disable': raise CTERAException('ext_db_ssl_mode disable is not supported for external database mode') - return ssl_mode + if mode not in _ALLOWED_SSL_MODES: + raise CTERAException( + f'Invalid ext_db_ssl_mode: {ssl_mode}. ' + f'Allowed values: require, verify-ca, verify-full') + return mode def _is_inline_pem(material): return isinstance(material, str) and '-----BEGIN' in material +def _read_pem_text(material): + if material is None: + return None + if isinstance(material, bytes): + material = material.decode('utf-8') + if not isinstance(material, str): + raise CTERAException('Failed loading certificate.') + if '-----BEGIN' in material: + return material + if commonfs.exists(material): + with open(material, 'r', encoding='utf-8') as handle: + return handle.read() + raise CTERAException('Failed loading certificate.') + + def _pem_certificate_content(cert_material): - if cert_material is None: + pem = _read_pem_text(cert_material) + if pem is None: return None - if _is_inline_pem(cert_material): - loaded = X509Certificate.from_string(cert_material) - else: - loaded = X509Certificate.load_certificate(cert_material) - return loaded.pem_data.decode('utf-8') + try: + certificates = x509.load_pem_x509_certificates(pem.encode('utf-8')) + except ValueError as error: + raise CTERAException('Failed loading certificate.') from error + if not certificates: + raise CTERAException('Failed loading certificate.') + return pem def _pem_private_key_content(key_material): @@ -139,7 +165,7 @@ def resolve_init_database_configuration( # pylint: disable=too-many-arguments,t raise CTERAException( 'ext_db_ssl_client_cert and ext_db_ssl_client_key are required for certificate authentication') - ssl_mode_normalized = str(ssl_mode).lower() + ssl_mode_normalized = ssl_mode if ssl_mode_normalized in _SSL_MODES_REQUIRING_CA and not ext_db_ssl_ca_cert: raise CTERAException( f'ext_db_ssl_ca_cert is required when ext_db_ssl_mode is {ssl_mode}') diff --git a/tests/ut/core/admin/test_external_database.py b/tests/ut/core/admin/test_external_database.py index f078cd91..02521f04 100644 --- a/tests/ut/core/admin/test_external_database.py +++ b/tests/ut/core/admin/test_external_database.py @@ -75,6 +75,29 @@ def test_resolve_external_rejects_disable_ssl(self): ext_db_ssl_mode='disable', ) + def test_resolve_external_rejects_invalid_ssl_mode(self): + with self.assertRaises(CTERAException): + resolve_init_database_configuration( + database_mode=DatabaseMode.External, + ext_db_host='db.example.com', + ext_db_user='app', + ext_db_password='secret', + ext_db_ssl_mode='prefer', + ) + + def test_build_external_database_config_preserves_ca_bundle(self): + cert_pem, _ = self._generate_self_signed_material() + other_pem, _ = self._generate_self_signed_material() + bundle = cert_pem + other_pem + config = build_external_database_config( + host='db.example.com', + username='app', + password='secret', + ssl_mode='verify-full', + ssl_ca_cert=bundle, + ) + self.assertEqual(config.sslCaCert.count('BEGIN CERTIFICATE'), 2) + def test_resolve_external_credentials_builds_config(self): mode, config = resolve_init_database_configuration( database_mode=DatabaseMode.External,