Skip to content

Commit fd0e048

Browse files
committed
feat(cbc): support PEM cert values via env vars and cert_path/key_path params
Replace the cert tuple parameter with symmetric cert_path/key_path params. Add CLOUD_SDK_CBC_CERT / CLOUD_SDK_CBC_KEY env vars so PEM values can be supplied directly (e.g. from K8s secrets) without writing to disk first — create_client() handles the temp-file lifecycle automatically.
1 parent fe8134d commit fd0e048

4 files changed

Lines changed: 83 additions & 31 deletions

File tree

‎src/sap_cloud_sdk/cbc/client.py‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ class DefaultClient:
117117
118118
**Production** (any ``https://`` or non-loopback URL): subdomain-per-tenant
119119
routing rewrites the URL subdomain to the ``cbc_tenant_id`` for each request;
120-
mTLS credentials must be provided via ``cert``, ``cert_pem``/``key_pem``, or
121-
``ssl_context``.
120+
mTLS credentials must be provided via ``cert_path``/``key_path``,
121+
``cert_pem``/``key_pem``, or ``ssl_context``.
122122
123123
**Local / mock** (``http://localhost``, ``http://127.0.0.1``, ``http://[::1]``):
124124
no subdomain replacement, no mTLS — detected automatically from the URL.
@@ -138,7 +138,8 @@ class DefaultClient:
138138
139139
client = DefaultClient(
140140
base_url="https://cbc.example.ondemand.com",
141-
cert=(Path("/run/secrets/tls.crt"), Path("/run/secrets/tls.key")),
141+
cert_path=Path("/run/secrets/tls.crt"),
142+
key_path=Path("/run/secrets/tls.key"),
142143
)
143144
144145
Args:
@@ -147,10 +148,10 @@ class DefaultClient:
147148
http_client: Optional pre-configured ``httpx.Client`` — takes full
148149
precedence over all mTLS arguments. Use for testing.
149150
ssl_context: Optional pre-built :class:`ssl.SSLContext` with mTLS loaded.
150-
cert: ``(cert_path, key_path)`` tuple of :class:`pathlib.Path` objects.
151-
cert_pem: Raw PEM string for the client certificate. Requires
152-
``key_pem`` to also be set. Written to a temp file deleted after
153-
the first connection.
151+
cert_path: Path to the PEM client certificate file. Requires ``key_path``.
152+
key_path: Path to the PEM private key file. Requires ``cert_path``.
153+
cert_pem: Raw PEM string for the client certificate. Requires ``key_pem``.
154+
Written to a temp file deleted after the first connection.
154155
key_pem: Raw PEM string for the private key. Requires ``cert_pem``.
155156
"""
156157

@@ -159,7 +160,8 @@ def __init__(
159160
base_url: str,
160161
http_client: httpx.Client | None = None,
161162
ssl_context: ssl.SSLContext | None = None,
162-
cert: tuple[Path, Path] | None = None,
163+
cert_path: Path | None = None,
164+
key_path: Path | None = None,
163165
cert_pem: str | None = None,
164166
key_pem: str | None = None,
165167
replace_subdomain: bool | None = None,
@@ -176,8 +178,8 @@ def __init__(
176178
)
177179

178180
if http_client is None and ssl_context is None:
179-
if cert is not None:
180-
transport = _LazyCertTransport(str(cert[0]), str(cert[1]))
181+
if cert_path is not None and key_path is not None:
182+
transport = _LazyCertTransport(str(cert_path), str(key_path))
181183
http_client = httpx.Client(transport=transport)
182184
elif cert_pem is not None and key_pem is not None:
183185
with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf:
@@ -444,13 +446,11 @@ def create_client(*, config: CBCConfig | None = None) -> CBCClient:
444446
from sap_cloud_sdk.cbc.config import load_from_env
445447

446448
resolved: CBCConfig = config if config is not None else load_from_env()
447-
cert = (
448-
(resolved.cert_path, resolved.key_path)
449-
if resolved.cert_path and resolved.key_path
450-
else None
451-
)
452449
return DefaultClient(
453450
base_url=resolved.base_url,
454-
cert=cert,
451+
cert_path=resolved.cert_path,
452+
key_path=resolved.key_path,
453+
cert_pem=resolved.cert_pem,
454+
key_pem=resolved.key_pem,
455455
replace_subdomain=resolved.replace_subdomain,
456456
)

‎src/sap_cloud_sdk/cbc/config.py‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
CLOUD_SDK_CBC_URL CBC service base URL (required)
88
CLOUD_SDK_CBC_CERT_PATH Path to PEM client certificate file
99
CLOUD_SDK_CBC_KEY_PATH Path to PEM private key file
10+
CLOUD_SDK_CBC_CERT PEM client certificate value (alternative to CERT_PATH)
11+
CLOUD_SDK_CBC_KEY PEM private key value (alternative to KEY_PATH)
1012
"""
1113

1214
from __future__ import annotations
@@ -20,6 +22,8 @@
2022
ENV_URL = "CLOUD_SDK_CBC_URL"
2123
ENV_CERT_PATH = "CLOUD_SDK_CBC_CERT_PATH"
2224
ENV_KEY_PATH = "CLOUD_SDK_CBC_KEY_PATH"
25+
ENV_CERT = "CLOUD_SDK_CBC_CERT"
26+
ENV_KEY = "CLOUD_SDK_CBC_KEY"
2327
ENV_REPLACE_SUBDOMAIN = "CLOUD_SDK_CBC_REPLACE_SUBDOMAIN"
2428

2529

@@ -31,6 +35,8 @@ class CBCConfig:
3135
base_url: CBC service base URL.
3236
cert_path: Path to the PEM client certificate file, or ``None`` for local/mock mode.
3337
key_path: Path to the PEM private key file, or ``None`` for local/mock mode.
38+
cert_pem: PEM client certificate value. Alternative to ``cert_path``.
39+
key_pem: PEM private key value. Alternative to ``key_path``.
3440
replace_subdomain: Whether to rewrite the URL subdomain to the CBC tenant ID
3541
on each request. ``None`` (default) auto-detects: loopback URLs disable it,
3642
all others enable it. Set explicitly to ``False`` for HTTPS mock servers.
@@ -39,6 +45,8 @@ class CBCConfig:
3945
base_url: str
4046
cert_path: Path | None = None
4147
key_path: Path | None = None
48+
cert_pem: str | None = None
49+
key_pem: str | None = None
4250
replace_subdomain: bool | None = None
4351

4452

@@ -47,10 +55,13 @@ def load_from_env() -> CBCConfig:
4755
4856
Resolution order (first match wins):
4957
50-
1. **Credential triplet** — ``CLOUD_SDK_CBC_CERT_PATH``,
51-
``CLOUD_SDK_CBC_KEY_PATH``, and ``CLOUD_SDK_CBC_URL`` must all be set.
52-
The path vars must point to existing PEM files.
53-
2. **URL only** — loopback addresses (``http://localhost``,
58+
1. **Path triplet** — ``CLOUD_SDK_CBC_CERT_PATH``, ``CLOUD_SDK_CBC_KEY_PATH``,
59+
and ``CLOUD_SDK_CBC_URL`` must all be set. The path vars must point to
60+
existing PEM files.
61+
2. **Value triplet** — ``CLOUD_SDK_CBC_CERT``, ``CLOUD_SDK_CBC_KEY``, and
62+
``CLOUD_SDK_CBC_URL`` must all be set. PEM values are written to temp
63+
files deleted after the first connection.
64+
3. **URL only** — loopback addresses (``http://localhost``,
5465
``http://127.0.0.1``) trigger local/mock mode (no mTLS, no subdomain
5566
replacement). Non-loopback URLs produce a client without mTLS.
5667
@@ -63,28 +74,45 @@ def load_from_env() -> CBCConfig:
6374
path env var points to a non-existent file.
6475
"""
6576
url = os.environ.get(ENV_URL)
77+
replace_subdomain = _read_env_bool(ENV_REPLACE_SUBDOMAIN)
6678

67-
cert = _read_env_path(ENV_CERT_PATH)
68-
key = _read_env_path(ENV_KEY_PATH)
69-
if cert and key and url:
79+
cert_path = _read_env_path(ENV_CERT_PATH)
80+
key_path = _read_env_path(ENV_KEY_PATH)
81+
if cert_path and key_path and url:
7082
return CBCConfig(
7183
base_url=url,
72-
cert_path=cert,
73-
key_path=key,
74-
replace_subdomain=_read_env_bool(ENV_REPLACE_SUBDOMAIN),
84+
cert_path=cert_path,
85+
key_path=key_path,
86+
replace_subdomain=replace_subdomain,
7587
)
76-
if cert or key:
88+
if cert_path or key_path:
7789
raise CBCConfigError(
7890
"CBC env-var credential triplet is incomplete. "
7991
f"Set all of {ENV_CERT_PATH}, {ENV_KEY_PATH}, and {ENV_URL} — or none."
8092
)
8193

94+
cert_pem = os.environ.get(ENV_CERT)
95+
key_pem = os.environ.get(ENV_KEY)
96+
if cert_pem and key_pem and url:
97+
return CBCConfig(
98+
base_url=url,
99+
cert_pem=cert_pem,
100+
key_pem=key_pem,
101+
replace_subdomain=replace_subdomain,
102+
)
103+
if cert_pem or key_pem:
104+
raise CBCConfigError(
105+
"CBC env-var credential pair is incomplete. "
106+
f"Set both {ENV_CERT} and {ENV_KEY} together with {ENV_URL} — or none."
107+
)
108+
82109
if url:
83-
return CBCConfig(base_url=url, replace_subdomain=_read_env_bool(ENV_REPLACE_SUBDOMAIN))
110+
return CBCConfig(base_url=url, replace_subdomain=replace_subdomain)
84111

85112
raise CBCConfigError(
86113
f"No CBC configuration found. Set {ENV_URL} at minimum, "
87-
f"or provide mTLS credentials via {ENV_CERT_PATH} / {ENV_KEY_PATH}."
114+
f"or provide mTLS credentials via {ENV_CERT_PATH} / {ENV_KEY_PATH} "
115+
f"or {ENV_CERT} / {ENV_KEY}."
88116
)
89117

90118

‎src/sap_cloud_sdk/cbc/user-guide.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,16 @@ except CBCNetworkError:
134134
| Variable | Required | Description |
135135
|---|---|---|
136136
| `CLOUD_SDK_CBC_URL` | yes | Base URL of the CBC service |
137-
| `CLOUD_SDK_CBC_CERT_PATH` | prod only | Path to the mTLS client certificate (PEM) |
138-
| `CLOUD_SDK_CBC_KEY_PATH` | prod only | Path to the mTLS private key (PEM) |
137+
| `CLOUD_SDK_CBC_CERT_PATH` | prod only | Path to the mTLS client certificate (PEM file) |
138+
| `CLOUD_SDK_CBC_KEY_PATH` | prod only | Path to the mTLS private key (PEM file) |
139+
| `CLOUD_SDK_CBC_CERT` | prod only | mTLS client certificate value (PEM string, alternative to `CERT_PATH`) |
140+
| `CLOUD_SDK_CBC_KEY` | prod only | mTLS private key value (PEM string, alternative to `KEY_PATH`) |
139141
| `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN` | no | Override subdomain replacement (`true`/`false`). Auto-detected from URL when unset. |
140142

141143
Local mode (loopback URL) requires only `CLOUD_SDK_CBC_URL`.
142144

145+
`CERT_PATH`/`KEY_PATH` (file paths) take precedence over `CERT`/`KEY` (values) when both are set.
146+
143147
## Using a test double
144148

145149
`CBCClient` is a `Protocol` — implement it directly in tests:

‎tests/cbc/unit/test_config.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import pytest
66

77
from sap_cloud_sdk.cbc.config import (
8+
ENV_CERT,
89
ENV_CERT_PATH,
10+
ENV_KEY,
911
ENV_KEY_PATH,
1012
ENV_URL,
1113
_read_env_path,
@@ -58,6 +60,24 @@ def test_raises_for_incomplete_triplet(self, monkeypatch, tmp_path):
5860
with pytest.raises(CBCConfigError, match="incomplete"):
5961
load_from_env()
6062

63+
def test_raises_for_incomplete_cert_pem_pair(self, monkeypatch):
64+
monkeypatch.setenv(ENV_CERT, "-----BEGIN CERTIFICATE-----")
65+
monkeypatch.delenv(ENV_KEY, raising=False)
66+
monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com")
67+
with pytest.raises(CBCConfigError, match="incomplete"):
68+
load_from_env()
69+
70+
def test_returns_config_with_cert_pem_pair(self, monkeypatch):
71+
monkeypatch.setenv(ENV_CERT, "-----BEGIN CERTIFICATE-----")
72+
monkeypatch.setenv(ENV_KEY, "-----BEGIN PRIVATE KEY-----")
73+
monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com")
74+
monkeypatch.delenv(ENV_CERT_PATH, raising=False)
75+
monkeypatch.delenv(ENV_KEY_PATH, raising=False)
76+
cfg = load_from_env()
77+
assert cfg.cert_pem == "-----BEGIN CERTIFICATE-----"
78+
assert cfg.key_pem == "-----BEGIN PRIVATE KEY-----"
79+
assert cfg.cert_path is None
80+
6181
def test_raises_for_missing_cert_file(self, monkeypatch, tmp_path):
6282
monkeypatch.setenv(ENV_CERT_PATH, str(tmp_path / "missing.crt"))
6383
with pytest.raises(CBCConfigError, match="does not exist"):

0 commit comments

Comments
 (0)