From b5637dddd6f73bbccc369fc3b5530a69a98398c8 Mon Sep 17 00:00:00 2001 From: Max Schettler Date: Wed, 29 Apr 2026 15:37:24 +0200 Subject: [PATCH 1/2] Enable certificate requests by cert-manager --- simplyblock_core/rpc_client.py | 2 +- simplyblock_core/settings.py | 29 ++++-- simplyblock_core/snode_client.py | 2 +- .../api/internal/storage_node/kubernetes.py | 4 +- .../templates/storage_deploy_spdk.yaml.j2 | 9 ++ tests/test_settings.py | 99 +++++++++++++++++++ tests/test_storage_deploy_template.py | 59 +++++++++++ 7 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 tests/test_settings.py create mode 100644 tests/test_storage_deploy_template.py diff --git a/simplyblock_core/rpc_client.py b/simplyblock_core/rpc_client.py index 269be37c1..58f473144 100755 --- a/simplyblock_core/rpc_client.py +++ b/simplyblock_core/rpc_client.py @@ -106,7 +106,7 @@ def __init__(self, host, port, username, password, timeout=180, retry=3): self.timeout = timeout self.session = requests.session() if settings.tls_enabled: - self.session.verify = str(settings.certificate_authority) + self.session.verify = str(settings.tls_certificate_authority) self.session.auth = (self.username, self.password) retries = Retry(total=retry, backoff_factor=1, connect=retry, read=retry, allowed_methods=self.DEFAULT_ALLOWED_METHODS) diff --git a/simplyblock_core/settings.py b/simplyblock_core/settings.py index eb2931ce0..449865de6 100644 --- a/simplyblock_core/settings.py +++ b/simplyblock_core/settings.py @@ -1,19 +1,32 @@ from pathlib import Path +from typing import Literal +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="sb_", case_sensitive=False) + tls_enabled: bool = False + tls_provider: Literal["openshift", "cert-manager"] = "openshift" tls_certificate: Path = Path("/etc/simplyblock/tls/tls.crt") tls_key: Path = Path("/etc/simplyblock/tls/tls.key") - certificate_authority: Path = Path("/etc/simplyblock/tls/ca.crt") + tls_certificate_authority: Path = Path("/etc/simplyblock/tls/ca.crt") - @property - def tls_enabled(self) -> bool: - return all([ - self.tls_certificate.is_file(), - self.tls_key.is_file(), - self.certificate_authority.is_file(), - ]) + @model_validator(mode="after") + def validate_tls_files(self): + if not self.tls_enabled: + return self + + required_paths = ["tls_certificate", "tls_key", "tls_certificate_authority"] + if (missing := [ + f"{name}={path}" + for name in required_paths + if not (path := getattr(self, name)).is_file() + ]): + raise ValueError( + "SB_TLS_ENABLED=true requires TLS files to exist: " + + ", ".join(missing) + ) + return self diff --git a/simplyblock_core/snode_client.py b/simplyblock_core/snode_client.py index 6bbd5cb2b..7edccce96 100644 --- a/simplyblock_core/snode_client.py +++ b/simplyblock_core/snode_client.py @@ -25,7 +25,7 @@ def __init__(self, host, timeout=300, retry=5): self.timeout = timeout self.session = requests.session() if settings.tls_enabled: - self.session.verify = str(settings.certificate_authority) + self.session.verify = str(settings.tls_certificate_authority) self.session.headers['Content-Type'] = "application/json" retries = Retry(total=retry, backoff_factor=1, connect=retry, read=retry) self.session.mount("http://", HTTPAdapter(max_retries=retries)) diff --git a/simplyblock_web/api/internal/storage_node/kubernetes.py b/simplyblock_web/api/internal/storage_node/kubernetes.py index 8194db391..6b4985282 100644 --- a/simplyblock_web/api/internal/storage_node/kubernetes.py +++ b/simplyblock_web/api/internal/storage_node/kubernetes.py @@ -281,6 +281,7 @@ class SPDKParams(BaseModel): })}}}, }) def spdk_process_start(body: SPDKParams): + settings = Settings() ssd_pcie_params = " ".join(" -A " + addr for addr in body.ssd_pcie) if body.ssd_pcie else "none" ssd_pcie_list = " ".join(body.ssd_pcie) @@ -373,7 +374,8 @@ def spdk_process_start(body: SPDKParams): 'FW_PORT': body.firewall_port, 'CPU_TOPOLOGY_ENABLED': cpu_topology_enabled, 'RESERVED_SYSTEM_CPUS': reserved_system_cpus, - 'TLS_ENABLED': Settings().tls_enabled, + 'TLS_ENABLED': settings.tls_enabled, + 'TLS_PROVIDER': settings.tls_provider, } if ubuntu_host: diff --git a/simplyblock_web/templates/storage_deploy_spdk.yaml.j2 b/simplyblock_web/templates/storage_deploy_spdk.yaml.j2 index 6f6e58979..a334b87d1 100644 --- a/simplyblock_web/templates/storage_deploy_spdk.yaml.j2 +++ b/simplyblock_web/templates/storage_deploy_spdk.yaml.j2 @@ -58,6 +58,10 @@ spec: path: /var/log/pods {% if TLS_ENABLED %} - name: tls + {% if TLS_PROVIDER == "cert-manager" %} + secret: + secretName: simplyblock-spdk-proxy-tls + {% else %} projected: sources: - secret: @@ -68,6 +72,7 @@ spec: - key: service-ca.crt path: ca.crt {% endif %} + {% endif %} containers: - name: spdk-container @@ -155,6 +160,10 @@ spec: value: "True" - name: TIMEOUT value: "300" + - name: SB_TLS_ENABLED + value: "{{ TLS_ENABLED }}" + - name: SB_TLS_PROVIDER + value: "{{ TLS_PROVIDER }}" {% if CPU_TOPOLOGY_ENABLED %} resources: limits: diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 000000000..a5bb984fb --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,99 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from pydantic import ValidationError + +from simplyblock_core.settings import Settings + + +def _tls_env(**overrides): + env = { + "SB_TLS_ENABLED": "false", + "SB_TLS_PROVIDER": "openshift", + } + env.update(overrides) + return env + + +def _write_tls_file(directory: str, name: str) -> str: + path = Path(directory) / name + path.write_text("test\n", encoding="utf-8") + return str(path) + + +class TestSettings(unittest.TestCase): + + def test_tls_disabled_does_not_infer_from_file_presence(self): + with tempfile.TemporaryDirectory() as tmpdir: + cert = _write_tls_file(tmpdir, "tls.crt") + key = _write_tls_file(tmpdir, "tls.key") + ca = _write_tls_file(tmpdir, "ca.crt") + with patch.dict( + os.environ, + _tls_env( + SB_TLS_ENABLED="false", + SB_TLS_CERTIFICATE=cert, + SB_TLS_KEY=key, + SB_TLS_CERTIFICATE_AUTHORITY=ca, + ), + clear=True, + ): + settings = Settings() + + self.assertFalse(settings.tls_enabled) + self.assertEqual(settings.tls_provider, "openshift") + + def test_tls_enabled_requires_all_configured_files(self): + with tempfile.TemporaryDirectory() as tmpdir: + cert = _write_tls_file(tmpdir, "tls.crt") + with patch.dict( + os.environ, + _tls_env( + SB_TLS_ENABLED="true", + SB_TLS_CERTIFICATE=cert, + SB_TLS_KEY=str(Path(tmpdir) / "missing.key"), + SB_TLS_CERTIFICATE_AUTHORITY=str(Path(tmpdir) / "missing-ca.crt"), + ), + clear=True, + ): + with self.assertRaises(ValidationError) as exc_info: + Settings() + + msg = str(exc_info.exception) + self.assertIn("SB_TLS_ENABLED=true requires TLS files to exist", msg) + self.assertIn("tls_key=", msg) + self.assertIn("tls_certificate_authority=", msg) + + def test_tls_enabled_accepts_existing_configured_files(self): + with tempfile.TemporaryDirectory() as tmpdir: + cert = _write_tls_file(tmpdir, "tls.crt") + key = _write_tls_file(tmpdir, "tls.key") + ca = _write_tls_file(tmpdir, "ca.crt") + with patch.dict( + os.environ, + _tls_env( + SB_TLS_ENABLED="true", + SB_TLS_PROVIDER="cert-manager", + SB_TLS_CERTIFICATE=cert, + SB_TLS_KEY=key, + SB_TLS_CERTIFICATE_AUTHORITY=ca, + ), + clear=True, + ): + settings = Settings() + + self.assertTrue(settings.tls_enabled) + self.assertEqual(settings.tls_provider, "cert-manager") + self.assertEqual(settings.tls_certificate_authority, Path(ca)) + + def test_tls_provider_rejects_mixed_case_openshift(self): + with patch.dict( + os.environ, + _tls_env(SB_TLS_PROVIDER="OpenShift"), + clear=True, + ): + with self.assertRaises(ValidationError): + Settings() diff --git a/tests/test_storage_deploy_template.py b/tests/test_storage_deploy_template.py new file mode 100644 index 000000000..b8c501543 --- /dev/null +++ b/tests/test_storage_deploy_template.py @@ -0,0 +1,59 @@ +import unittest +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + + +TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "simplyblock_web" / "templates" + + +def _render_storage_deploy(tls_provider: str) -> str: + env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + trim_blocks=True, + lstrip_blocks=True, + ) + template = env.get_template("storage_deploy_spdk.yaml.j2") + return template.render( + SPDK_IMAGE="spdk:test", + L_CORES="0-1", + SPDK_MEM=1024, + CORES=2, + SERVER_IP="10.0.0.10", + RPC_PORT=8080, + RPC_USERNAME="admin", + RPC_PASSWORD="secret", + HOSTNAME="node-a", + NAMESPACE="simplyblock", + SIMPLYBLOCK_DOCKER_IMAGE="proxy:test", + GRAYLOG_SERVER_IP="10.0.0.20", + MODE="kubernetes", + CLUSTER_ID="cluster1", + SSD_PCIE="none", + PCI_ALLOWED="", + TOTAL_HP="", + NSOCKET=0, + FW_PORT=50001, + CPU_TOPOLOGY_ENABLED=False, + MEM_MEGA=1536, + MEM2_MEGA=1024, + TLS_ENABLED=True, + TLS_PROVIDER=tls_provider, + ) + + +class TestStorageDeployTemplate(unittest.TestCase): + + def test_openshift_uses_service_ca_key(self): + rendered = _render_storage_deploy("openshift") + self.assertIn("key: service-ca.crt", rendered) + self.assertIn('name: SB_TLS_PROVIDER', rendered) + self.assertIn('value: "openshift"', rendered) + + def test_cert_manager_mounts_secret_directly(self): + rendered = _render_storage_deploy("cert-manager") + self.assertIn("secretName: simplyblock-spdk-proxy-tls", rendered) + self.assertNotIn("simplyblock-certificate-authority", rendered) + self.assertNotIn("projected:", rendered) + self.assertIn('name: SB_TLS_PROVIDER', rendered) + self.assertIn('value: "cert-manager"', rendered) From d6a5febe8d000883d17eceaa173996f266b0b9da Mon Sep 17 00:00:00 2001 From: Max Schettler Date: Thu, 30 Apr 2026 13:55:04 +0200 Subject: [PATCH 2/2] Allow separate configuration of client and server TLS --- simplyblock_core/models/storage_node.py | 4 +- simplyblock_core/rpc_client.py | 4 +- .../services/spdk_http_proxy_server.py | 2 +- simplyblock_core/settings.py | 55 ++++++++--- simplyblock_core/snode_client.py | 4 +- .../api/internal/storage_node/kubernetes.py | 2 +- simplyblock_web/app.py | 4 +- simplyblock_web/node_webapp.py | 2 +- tests/test_settings.py | 99 ------------------- 9 files changed, 53 insertions(+), 123 deletions(-) delete mode 100644 tests/test_settings.py diff --git a/simplyblock_core/models/storage_node.py b/simplyblock_core/models/storage_node.py index 83ae1c1a2..331e21873 100644 --- a/simplyblock_core/models/storage_node.py +++ b/simplyblock_core/models/storage_node.py @@ -150,7 +150,7 @@ def client(self, **kwargs): """Return API client to this node """ host = self.api_endpoint - if Settings().tls_enabled: + if Settings().tls_connect != "disabled": port = self.api_endpoint.rsplit(":", 1)[1] host = f"{self._k8s_node_label()}.simplyblock-storage-node-api.{self.cr_namespace}.svc.cluster.local:{port}" return SNodeClient(host, **kwargs) @@ -159,7 +159,7 @@ def rpc_client(self, **kwargs): """Return rpc client to this node """ host = self.mgmt_ip - if Settings().tls_enabled: + if Settings().tls_connect != "disabled": host = f"{self._k8s_node_label()}.simplyblock-spdk-proxy.{self.cr_namespace}.svc.cluster.local" return RPCClient( host, self.rpc_port, diff --git a/simplyblock_core/rpc_client.py b/simplyblock_core/rpc_client.py index 58f473144..254f65447 100755 --- a/simplyblock_core/rpc_client.py +++ b/simplyblock_core/rpc_client.py @@ -99,13 +99,13 @@ def __init__(self, host, port, username, password, timeout=180, retry=3): self.host = host self.port = port settings = Settings() - scheme = "https" if settings.tls_enabled else "http" + scheme = "https" if settings.tls_connect != "disabled" else "http" self.url = '%s://%s:%s/' % (scheme, self.host, self.port) self.username = username self.password = password self.timeout = timeout self.session = requests.session() - if settings.tls_enabled: + if settings.tls_connect != "disabled": self.session.verify = str(settings.tls_certificate_authority) self.session.auth = (self.username, self.password) retries = Retry(total=retry, backoff_factor=1, connect=retry, read=retry, diff --git a/simplyblock_core/services/spdk_http_proxy_server.py b/simplyblock_core/services/spdk_http_proxy_server.py index 5e71742eb..dba46a8a3 100644 --- a/simplyblock_core/services/spdk_http_proxy_server.py +++ b/simplyblock_core/services/spdk_http_proxy_server.py @@ -258,7 +258,7 @@ def run_server(host, port, user, password, is_threading_enabled=False): ServerHandler.key = key httpd = (ThreadingHTTPServer if is_threading_enabled else HTTPServer)((host, port), ServerHandler) settings = Settings() - if settings.tls_enabled: + if settings.tls_serve: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(settings.tls_certificate, settings.tls_key) httpd.socket = context.wrap_socket(httpd.socket, server_side=True) diff --git a/simplyblock_core/settings.py b/simplyblock_core/settings.py index 449865de6..7b7b12aea 100644 --- a/simplyblock_core/settings.py +++ b/simplyblock_core/settings.py @@ -1,32 +1,61 @@ from pathlib import Path -from typing import Literal +from typing import Annotated, Literal, Optional -from pydantic import model_validator +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="sb_", case_sensitive=False) - tls_enabled: bool = False - tls_provider: Literal["openshift", "cert-manager"] = "openshift" + tls_serve: Annotated[ + bool, + Field( + description="Run servers in TLS mode. Requires certificate and key to be present." + ), + ] = False + tls_connect: Annotated[ + Literal["disabled", "anonymous"], + Field(description="Connect to internal services via TLS."), + ] = "disabled" + tls_provider: Annotated[ + Optional[Literal["openshift", "cert-manager"]], + Field(description="Provider for TLS certificates in the cluster."), + ] = None tls_certificate: Path = Path("/etc/simplyblock/tls/tls.crt") tls_key: Path = Path("/etc/simplyblock/tls/tls.key") tls_certificate_authority: Path = Path("/etc/simplyblock/tls/ca.crt") @model_validator(mode="after") def validate_tls_files(self): - if not self.tls_enabled: + if not self.tls_serve and self.tls_connect == "disabled": return self - required_paths = ["tls_certificate", "tls_key", "tls_certificate_authority"] - if (missing := [ - f"{name}={path}" - for name in required_paths - if not (path := getattr(self, name)).is_file() - ]): + if self.tls_serve and ( + missing := [ + name + for name in ["tls_certificate", "tls_key"] + if not getattr(self, name).is_file() + ] + ): raise ValueError( - "SB_TLS_ENABLED=true requires TLS files to exist: " - + ", ".join(missing) + "SB_TLS_SERVE=true requires TLS files to exist: " + ", ".join(missing) + ) + + if ( + self.tls_connect != "disabled" + and not self.tls_certificate_authority.is_file() + ): + raise ValueError( + "SB_TLS_CONNECT != 'disabled' requires certificate authority to exist" + ) + + return self + + @model_validator(mode="after") + def validate_tls_provider(self): + if self.tls_connect != "disabled" and self.tls_provider is None: + raise ValueError( + "TLS provider needs to be configured for TLS connections to be used" ) return self diff --git a/simplyblock_core/snode_client.py b/simplyblock_core/snode_client.py index 7edccce96..f807244a3 100644 --- a/simplyblock_core/snode_client.py +++ b/simplyblock_core/snode_client.py @@ -20,11 +20,11 @@ class SNodeClient: def __init__(self, host, timeout=300, retry=5): settings = Settings() - scheme = "https" if settings.tls_enabled else "http" + scheme = "https" if settings.tls_connect != "disabled" else "http" self.url = f'{scheme}://{host}/snode/' self.timeout = timeout self.session = requests.session() - if settings.tls_enabled: + if settings.tls_connect != "disabled": self.session.verify = str(settings.tls_certificate_authority) self.session.headers['Content-Type'] = "application/json" retries = Retry(total=retry, backoff_factor=1, connect=retry, read=retry) diff --git a/simplyblock_web/api/internal/storage_node/kubernetes.py b/simplyblock_web/api/internal/storage_node/kubernetes.py index 6b4985282..1a088918b 100644 --- a/simplyblock_web/api/internal/storage_node/kubernetes.py +++ b/simplyblock_web/api/internal/storage_node/kubernetes.py @@ -374,7 +374,7 @@ def spdk_process_start(body: SPDKParams): 'FW_PORT': body.firewall_port, 'CPU_TOPOLOGY_ENABLED': cpu_topology_enabled, 'RESERVED_SYSTEM_CPUS': reserved_system_cpus, - 'TLS_ENABLED': settings.tls_enabled, + 'TLS_ENABLED': settings.tls_serve, 'TLS_PROVIDER': settings.tls_provider, } diff --git a/simplyblock_web/app.py b/simplyblock_web/app.py index fe5b6c377..a827baa94 100644 --- a/simplyblock_web/app.py +++ b/simplyblock_web/app.py @@ -93,8 +93,8 @@ def main() -> None: access_log=False, proxy_headers=True, forwarded_allow_ips='192.168.1.0/24', - ssl_certfile=settings.tls_certificate if settings.tls_enabled else None, - ssl_keyfile=settings.tls_key if settings.tls_enabled else None, + ssl_certfile=settings.tls_certificate if settings.tls_serve else None, + ssl_keyfile=settings.tls_key if settings.tls_serve else None, ) server: uvicorn.Server = uvicorn.Server(config) server.run() diff --git a/simplyblock_web/node_webapp.py b/simplyblock_web/node_webapp.py index 772ed9f7d..df601ac3e 100644 --- a/simplyblock_web/node_webapp.py +++ b/simplyblock_web/node_webapp.py @@ -44,5 +44,5 @@ def status(): app.register_api(internal_api.storage_node.kubernetes.api) settings = Settings() - ssl_ctx = (settings.tls_certificate, settings.tls_key) if settings.tls_enabled else None + ssl_ctx = (settings.tls_certificate, settings.tls_key) if settings.tls_serve else None app.run(host='0.0.0.0', debug=constants.LOG_WEB_DEBUG, ssl_context=ssl_ctx) diff --git a/tests/test_settings.py b/tests/test_settings.py deleted file mode 100644 index a5bb984fb..000000000 --- a/tests/test_settings.py +++ /dev/null @@ -1,99 +0,0 @@ -import os -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from pydantic import ValidationError - -from simplyblock_core.settings import Settings - - -def _tls_env(**overrides): - env = { - "SB_TLS_ENABLED": "false", - "SB_TLS_PROVIDER": "openshift", - } - env.update(overrides) - return env - - -def _write_tls_file(directory: str, name: str) -> str: - path = Path(directory) / name - path.write_text("test\n", encoding="utf-8") - return str(path) - - -class TestSettings(unittest.TestCase): - - def test_tls_disabled_does_not_infer_from_file_presence(self): - with tempfile.TemporaryDirectory() as tmpdir: - cert = _write_tls_file(tmpdir, "tls.crt") - key = _write_tls_file(tmpdir, "tls.key") - ca = _write_tls_file(tmpdir, "ca.crt") - with patch.dict( - os.environ, - _tls_env( - SB_TLS_ENABLED="false", - SB_TLS_CERTIFICATE=cert, - SB_TLS_KEY=key, - SB_TLS_CERTIFICATE_AUTHORITY=ca, - ), - clear=True, - ): - settings = Settings() - - self.assertFalse(settings.tls_enabled) - self.assertEqual(settings.tls_provider, "openshift") - - def test_tls_enabled_requires_all_configured_files(self): - with tempfile.TemporaryDirectory() as tmpdir: - cert = _write_tls_file(tmpdir, "tls.crt") - with patch.dict( - os.environ, - _tls_env( - SB_TLS_ENABLED="true", - SB_TLS_CERTIFICATE=cert, - SB_TLS_KEY=str(Path(tmpdir) / "missing.key"), - SB_TLS_CERTIFICATE_AUTHORITY=str(Path(tmpdir) / "missing-ca.crt"), - ), - clear=True, - ): - with self.assertRaises(ValidationError) as exc_info: - Settings() - - msg = str(exc_info.exception) - self.assertIn("SB_TLS_ENABLED=true requires TLS files to exist", msg) - self.assertIn("tls_key=", msg) - self.assertIn("tls_certificate_authority=", msg) - - def test_tls_enabled_accepts_existing_configured_files(self): - with tempfile.TemporaryDirectory() as tmpdir: - cert = _write_tls_file(tmpdir, "tls.crt") - key = _write_tls_file(tmpdir, "tls.key") - ca = _write_tls_file(tmpdir, "ca.crt") - with patch.dict( - os.environ, - _tls_env( - SB_TLS_ENABLED="true", - SB_TLS_PROVIDER="cert-manager", - SB_TLS_CERTIFICATE=cert, - SB_TLS_KEY=key, - SB_TLS_CERTIFICATE_AUTHORITY=ca, - ), - clear=True, - ): - settings = Settings() - - self.assertTrue(settings.tls_enabled) - self.assertEqual(settings.tls_provider, "cert-manager") - self.assertEqual(settings.tls_certificate_authority, Path(ca)) - - def test_tls_provider_rejects_mixed_case_openshift(self): - with patch.dict( - os.environ, - _tls_env(SB_TLS_PROVIDER="OpenShift"), - clear=True, - ): - with self.assertRaises(ValidationError): - Settings()