Skip to content

Commit 42a14e6

Browse files
authored
Merge pull request #120 from cloudblue/LITE-33587-include-egress-proxy-helper-functionality
LITE-33587 Base EgressProxyClient implementation
2 parents ed69f2e + 495d0e7 commit 42a14e6

7 files changed

Lines changed: 868 additions & 4 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,8 @@ jobs:
5353
run: |
5454
poetry run pytest
5555
- name: SonarCloud
56-
uses: SonarSource/sonarcloud-github-action@master
56+
uses: SonarSource/sonarcloud-github-action@4006f663ecaf1f8093e8e4abb9227f6041f52216
5757
env:
58-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5958
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
6059
- name: SonarQube Quality Gate check
6160
uses: sonarsource/sonarqube-quality-gate-action@master

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ temp/
2020
coverage.xml
2121

2222
setup.py
23+
24+
# Docker test artifacts
25+
Dockerfile.test

connect/eaas/core/constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,12 @@
2121
PROXIED_CONNECT_API_ENDPOINTS_PUBLIC_PREFIX,
2222
PROXIED_CONNECT_API_ENDPOINTS_FILES_PREFIX,
2323
)
24+
25+
26+
EGRESS_PROXY_DEFAULT_MAX_RETRIES = 3
27+
EGRESS_PROXY_DEFAULT_PATH = 'proxy'
28+
EGRESS_PROXY_X_CONNECT_TARGET_URL_HEADER = 'X-Connect-Target-URL'
29+
EGRESS_PROXY_USER_AGENT_HEADER = 'User-Agent'
30+
EGRESS_PROXY_TLS_CLIENT_CERT_ENV_VAR = 'TLS_CLIENT_CERT'
31+
EGRESS_PROXY_TLS_CLIENT_KEY_ENV_VAR = 'TLS_CLIENT_KEY'
32+
EGRESS_PROXY_TLS_CA_CERT_ENV_VAR = 'TLS_CA_CERT'

connect/eaas/core/egress_proxy.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import json
2+
import os
3+
import tempfile
4+
5+
from cnct import ConnectClient
6+
7+
from connect.eaas.core.constants import (
8+
EGRESS_PROXY_DEFAULT_MAX_RETRIES,
9+
EGRESS_PROXY_DEFAULT_PATH,
10+
EGRESS_PROXY_TLS_CA_CERT_ENV_VAR,
11+
EGRESS_PROXY_TLS_CLIENT_CERT_ENV_VAR,
12+
EGRESS_PROXY_TLS_CLIENT_KEY_ENV_VAR,
13+
EGRESS_PROXY_USER_AGENT_HEADER,
14+
EGRESS_PROXY_X_CONNECT_TARGET_URL_HEADER,
15+
)
16+
from connect.eaas.core.models import EgressProxy, EgressProxyCertificates
17+
18+
19+
class EgressProxyClient(ConnectClient):
20+
"""Client for interacting with the Vendor Proxy API."""
21+
22+
PROXY_PATH = EGRESS_PROXY_DEFAULT_PATH
23+
24+
def __init__(
25+
self,
26+
proxy: EgressProxy,
27+
certificates: EgressProxyCertificates,
28+
):
29+
self.proxy = proxy
30+
self.cert_file = self._create_temp_cert_file(certificates.client_cert)
31+
self.key_file = self._create_temp_cert_file(certificates.client_key)
32+
self.ca_file = self._create_temp_cert_file(certificates.ca_cert)
33+
34+
super().__init__(
35+
endpoint=self.proxy.url,
36+
api_key=None,
37+
max_retries=EGRESS_PROXY_DEFAULT_MAX_RETRIES,
38+
use_specs=False,
39+
)
40+
41+
@staticmethod
42+
def _create_temp_cert_file(cert_content):
43+
"""Create a temporary file with certificate content."""
44+
temp_file = tempfile.NamedTemporaryFile(
45+
mode='w',
46+
delete=False,
47+
suffix='.pem',
48+
)
49+
temp_file.write(cert_content)
50+
temp_file.close()
51+
return temp_file.name
52+
53+
@classmethod
54+
def require_proxy(cls, account_id: str):
55+
"""
56+
Check if a proxy is required for the given account ID.
57+
58+
Args:
59+
account_id: The account ID to check (e.g., 'PA-063-101')
60+
Returns:
61+
dict | None: Proxy configuration dictionary if it exists
62+
for the account, None otherwise.
63+
"""
64+
egress_config = json.loads(os.getenv('EGRESS_PROXIES_CONFIG') or '{}')
65+
return egress_config.get(account_id)
66+
67+
@classmethod
68+
def from_env(cls, account_id: str):
69+
"""
70+
Create a VendorProxyClient instance from environment variables.
71+
72+
Args:
73+
account_id: The account ID to get proxy config for
74+
(e.g., 'PA-063-101')
75+
76+
Environment variables:
77+
EGRESS_PROXIES_CONFIG: JSON string with proxy configurations
78+
TLS_CLIENT_KEY: PEM-encoded private key
79+
TLS_CLIENT_CERT: PEM-encoded client certificate
80+
TLS_CA_CERT: PEM-encoded CA certificate
81+
"""
82+
# Load proxy configuration
83+
proxy_config = cls.require_proxy(account_id)
84+
85+
if not proxy_config:
86+
raise ValueError(
87+
f"No proxy configuration found for account {account_id}",
88+
)
89+
90+
proxy = EgressProxy(owner_id=account_id, **proxy_config)
91+
92+
if not all(key in os.environ for key in (
93+
EGRESS_PROXY_TLS_CLIENT_CERT_ENV_VAR,
94+
EGRESS_PROXY_TLS_CLIENT_KEY_ENV_VAR,
95+
EGRESS_PROXY_TLS_CA_CERT_ENV_VAR,
96+
)):
97+
raise ValueError("Missing TLS certificate environment variables")
98+
99+
certificates = EgressProxyCertificates(
100+
client_cert=os.environ[EGRESS_PROXY_TLS_CLIENT_CERT_ENV_VAR],
101+
client_key=os.environ[EGRESS_PROXY_TLS_CLIENT_KEY_ENV_VAR],
102+
ca_cert=os.environ[EGRESS_PROXY_TLS_CA_CERT_ENV_VAR],
103+
)
104+
105+
return cls(proxy=proxy, certificates=certificates)
106+
107+
def send_proxied_request(self, *, target_url, target_method, **kwargs):
108+
"""Send a request to the Vendor Proxy API."""
109+
kwargs['json'] = kwargs.pop('payload', None) or None
110+
return self.execute(
111+
target_method,
112+
self.PROXY_PATH,
113+
target_url=target_url,
114+
**kwargs,
115+
)
116+
117+
def _prepare_call_kwargs(self, kwargs):
118+
target_url = kwargs.pop('target_url')
119+
kwargs = super()._prepare_call_kwargs(kwargs)
120+
headers = self._update_headers(target_url, kwargs['headers'])
121+
self._validate_headers(headers)
122+
kwargs['headers'] = headers
123+
kwargs.setdefault('cert', (self.cert_file, self.key_file))
124+
kwargs.setdefault('verify', self.ca_file)
125+
return kwargs
126+
127+
def _update_headers(self, target_url, headers):
128+
_, rest = headers.get(EGRESS_PROXY_USER_AGENT_HEADER).split('/', 1)
129+
headers[EGRESS_PROXY_USER_AGENT_HEADER] = (
130+
f'connect-egress-proxy-{self.proxy.id}/{rest}'
131+
)
132+
headers[EGRESS_PROXY_X_CONNECT_TARGET_URL_HEADER] = target_url
133+
headers.pop('Authorization', None)
134+
return headers
135+
136+
def _validate_headers(self, headers):
137+
for header in self.proxy.headers:
138+
if header['name'] not in headers and header.get('required', False):
139+
raise ValueError(
140+
f"Missing required header: '{header['name']}'",
141+
)

connect/eaas/core/models.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1-
from dataclasses import dataclass
1+
from dataclasses import dataclass, field
22

33

44
@dataclass
55
class Context:
66
extension_id: str
77
environment_id: str
88
environment_type: str
9+
10+
11+
@dataclass
12+
class EgressProxy:
13+
id: str
14+
url: str
15+
owner_id: str
16+
headers: list[dict] = field(default_factory=list)
17+
18+
19+
@dataclass
20+
class EgressProxyCertificates:
21+
client_cert: str
22+
client_key: str
23+
ca_cert: str

sonar-project.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
sonar.projectName=Connect EaaS Core
2-
sonar.projectKey=connect-eaas-core
2+
sonar.projectKey=cloudblue_connect-eaas-core
33
sonar.organization=cloudbluesonarcube
44

55
sonar.language=py

0 commit comments

Comments
 (0)