From 08183c7d2e4e9da85fab4347558b2b8ce89e45dd Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 23 Jul 2026 16:47:18 -0400 Subject: [PATCH] feat(installer): add SSL/HTTPS support to Observability install Add --ssl-cert-file / --ssl-key-file to `obs install`, mirroring the existing TestGen SSL flow. When both are provided, the cert+key are bind-mounted into the UI container and SSL_CERT_FILE/SSL_KEY_FILE are set so its nginx serves HTTPS; the printed service URLs switch to https. All Observability service URLs are proxied through the UI nginx, so applying TLS there covers every displayed endpoint. Align the two products' SSL codepaths: - Validate the cert/key arg pair in the compose step's pre_execute so a mismatch fails fast in the validation phase (parity with TestGen), rather than mid-install. - Record the `used_custom_cert` analytics property as a bool on both products; TestGen previously stored the key-file path string. Add Observability SSL test coverage matching TestGen: arg-pair mismatch aborts, compose contains the SSL env + bind mounts when enabled, and the placeholders are stripped when disabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- dk-installer.py | 50 +++++++++++++++++++++++++++++++++++++-- tests/test_obs_install.py | 44 +++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/dk-installer.py b/dk-installer.py index 08f5084..02f2ba5 100755 --- a/dk-installer.py +++ b/dk-installer.py @@ -1737,9 +1737,10 @@ def execute(self, action, args): def on_action_success(self, action, args): cred_file_path = action.data_folder.joinpath(CREDENTIALS_FILE.format(args.prod)) + protocol = "https" if args.ssl_cert_file and args.ssl_key_file else "http" with CONSOLE.tee(cred_file_path) as console_tee: for service, url_tpl in OBS_SERVICES_URLS: - console_tee(f"{service:>20}: {url_tpl.format('http://localhost', args.port)}") + console_tee(f"{service:>20}: {url_tpl.format(f'{protocol}://localhost', args.port)}") console_tee("") console_tee(f"Username: {self._user_data['username']}") console_tee(f"Password: {self._user_data['password']}", skip_logging=True) @@ -1769,6 +1770,12 @@ def execute(self, action, args): class ObsCreateComposeFileStep(CreateComposeFileStepBase): + def pre_execute(self, action, args): + super().pre_execute(action, args) + if bool(args.ssl_cert_file) != bool(args.ssl_key_file): + CONSOLE.msg("Both --ssl-cert-file and --ssl-key-file must be provided to use SSL certificates.") + raise AbortAction + def get_compose_file_contents(self, action, args): action.analytics.additional_properties["used_custom_image"] = any( ( @@ -1776,6 +1783,7 @@ def get_compose_file_contents(self, action, args): args.be_image != OBS_DEF_BE_IMAGE, ) ) + action.analytics.additional_properties["used_custom_cert"] = bool(args.ssl_cert_file and args.ssl_key_file) compose_file_content = textwrap.dedent( """ name: ${DK_OBSERVABILITY_COMPOSE_NAME:-} @@ -1880,12 +1888,14 @@ def get_compose_file_contents(self, action, args): condition: service_healthy environment: OBSERVABILITY_AUTH_METHOD: ${DK_OBSERVABILITY_AUTH_METHOD:-basic} + __SSL_UI_ENVIRONMENT__ links: - "observability_backend:observability-api" - "observability_backend:event-api" - "observability_backend:agent-api" ports: - "${DK_OBSERVABILITY_HTTP_PORT:-8082}:8082" + __SSL_UI_VOLUMES__ networks: datakitchen: @@ -1913,6 +1923,28 @@ def get_compose_file_contents(self, action, args): compose_file_content, ) + # Fill (or strip) the UI TLS placeholders. When a cert+key are provided, + # they are bind-mounted into the UI container and SSL_CERT_FILE/SSL_KEY_FILE + # are set so its nginx serves HTTPS; otherwise the UI stays on plain HTTP. + if args.ssl_cert_file and args.ssl_key_file: + compose_file_content = compose_file_content.replace( + " __SSL_UI_ENVIRONMENT__", + " SSL_CERT_FILE: /dk/ssl/cert.crt\n SSL_KEY_FILE: /dk/ssl/cert.key", + ) + compose_file_content = compose_file_content.replace( + " __SSL_UI_VOLUMES__", + " volumes:\n" + f" - type: bind\n" + f" source: {args.ssl_cert_file}\n" + f" target: /dk/ssl/cert.crt\n" + f" - type: bind\n" + f" source: {args.ssl_key_file}\n" + f" target: /dk/ssl/cert.key", + ) + else: + compose_file_content = compose_file_content.replace(" __SSL_UI_ENVIRONMENT__\n", "") + compose_file_content = compose_file_content.replace(" __SSL_UI_VOLUMES__\n", "") + return compose_file_content @@ -1967,6 +1999,20 @@ def get_parser(self, sub_parsers): default=OBS_DEF_UI_IMAGE, help="Observability UI image to use for the install. Defaults to %(default)s", ) + parser.add_argument( + "--ssl-cert-file", + dest="ssl_cert_file", + action="store", + default=None, + help="Path to SSL certificate file. When provided together with --ssl-key-file, the UI serves HTTPS.", + ) + parser.add_argument( + "--ssl-key-file", + dest="ssl_key_file", + action="store", + default=None, + help="Path to SSL key file. When provided together with --ssl-cert-file, the UI serves HTTPS.", + ) class DemoContainerAction(Action): @@ -2219,7 +2265,7 @@ def get_credentials_from_compose_file(self, file_contents): return username, password def get_compose_file_contents(self, action, args): - action.analytics.additional_properties["used_custom_cert"] = args.ssl_cert_file and args.ssl_key_file + action.analytics.additional_properties["used_custom_cert"] = bool(args.ssl_cert_file and args.ssl_key_file) action.analytics.additional_properties["used_custom_image"] = args.image != TESTGEN_DEFAULT_IMAGE ssl_variables = ( diff --git a/tests/test_obs_install.py b/tests/test_obs_install.py index 188771d..604e7ea 100644 --- a/tests/test_obs_install.py +++ b/tests/test_obs_install.py @@ -5,7 +5,7 @@ import pytest -from tests.installer import ObsInstallAction, AbortAction, ComposeVerifyExistingInstallStep +from tests.installer import ObsInstallAction, AbortAction, ComposeVerifyExistingInstallStep, ObsCreateComposeFileStep @pytest.fixture @@ -72,3 +72,45 @@ def test_obs_existing_install_abort(obs_install_action, compose_path, stdout_moc with patch.object(obs_install_action, "steps", new=[ComposeVerifyExistingInstallStep]): with pytest.raises(AbortAction): obs_install_action.execute() + + +@pytest.mark.integration +@pytest.mark.parametrize("arg_to_set", ("ssl_cert_file", "ssl_key_file")) +def test_obs_create_compose_file_abort_args(arg_to_set, obs_install_action, args_mock, console_msg_mock): + setattr(args_mock, arg_to_set, "/some/file/path") + + with patch.object(obs_install_action, "steps", new=[ObsCreateComposeFileStep]): + with pytest.raises(AbortAction): + obs_install_action.execute() + + console_msg_mock.assert_any_msg_contains( + "Both --ssl-cert-file and --ssl-key-file must be provided to use SSL certificates.", + ) + + +@pytest.mark.integration +def test_obs_compose_contains_ssl(obs_install_action, args_mock, compose_path): + args_mock.ssl_cert_file = "/path/to/cert.crt" + args_mock.ssl_key_file = "/path/to/cert.key" + + with patch.object(obs_install_action, "steps", new=[ObsCreateComposeFileStep]): + obs_install_action.execute() + + contents = compose_path.read_text() + assert "SSL_CERT_FILE: /dk/ssl/cert.crt" in contents + assert "SSL_KEY_FILE: /dk/ssl/cert.key" in contents + assert "source: /path/to/cert.crt" in contents + assert "source: /path/to/cert.key" in contents + assert "__SSL_UI_ENVIRONMENT__" not in contents + assert "__SSL_UI_VOLUMES__" not in contents + + +@pytest.mark.integration +def test_obs_compose_without_ssl(obs_install_action, args_mock, compose_path): + with patch.object(obs_install_action, "steps", new=[ObsCreateComposeFileStep]): + obs_install_action.execute() + + contents = compose_path.read_text() + assert "SSL_CERT_FILE" not in contents + assert "__SSL_UI_ENVIRONMENT__" not in contents + assert "__SSL_UI_VOLUMES__" not in contents