Skip to content

Commit 4d2c0f5

Browse files
zubenkoivanIvan Zubenko
and
Ivan Zubenko
authored
add update kube token task (#468)
Co-authored-by: Ivan Zubenko <ivan.zubenko@angloamerican.com>
1 parent 5b01fe7 commit 4d2c0f5

File tree

2 files changed

+118
-34
lines changed

2 files changed

+118
-34
lines changed

platform_secrets/kube_client.py

+40-34
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import asyncio
12
import json
23
import logging
34
import ssl
5+
from contextlib import suppress
46
from io import BytesIO
57
from pathlib import Path
68
from typing import Any, Optional
@@ -54,6 +56,7 @@ def __init__(
5456
auth_cert_key_path: Optional[str] = None,
5557
token: Optional[str] = None,
5658
token_path: Optional[str] = None,
59+
token_update_interval_s: int = 300,
5760
conn_timeout_s: int = 300,
5861
read_timeout_s: int = 100,
5962
conn_pool_size: int = 100,
@@ -70,13 +73,15 @@ def __init__(
7073
self._auth_cert_key_path = auth_cert_key_path
7174
self._token = token
7275
self._token_path = token_path
76+
self._token_update_interval_s = token_update_interval_s
7377

7478
self._conn_timeout_s = conn_timeout_s
7579
self._read_timeout_s = read_timeout_s
7680
self._conn_pool_size = conn_pool_size
7781
self._trace_configs = trace_configs
7882

7983
self._client: Optional[aiohttp.ClientSession] = None
84+
self._token_updater_task: Optional[asyncio.Task[None]] = None
8085

8186
self._dummy_secret_key = SECRET_DUMMY_KEY
8287

@@ -98,34 +103,36 @@ def _create_ssl_context(self) -> Optional[ssl.SSLContext]:
98103
return ssl_context
99104

100105
async def init(self) -> None:
101-
self._client = await self.create_http_client()
102-
103-
async def init_if_needed(self) -> None:
104-
if not self._client or self._client.closed:
105-
await self.init()
106-
107-
async def create_http_client(self) -> aiohttp.ClientSession:
108106
connector = aiohttp.TCPConnector(
109107
limit=self._conn_pool_size, ssl=self._create_ssl_context()
110108
)
111-
if self._auth_type == KubeClientAuthType.TOKEN:
112-
token = self._token
113-
if not token:
114-
assert self._token_path is not None
115-
token = Path(self._token_path).read_text()
116-
headers = {"Authorization": "Bearer " + token}
117-
else:
118-
headers = {}
109+
if self._token_path:
110+
self._token = Path(self._token_path).read_text()
111+
self._token_updater_task = asyncio.create_task(self._start_token_updater())
119112
timeout = aiohttp.ClientTimeout(
120113
connect=self._conn_timeout_s, total=self._read_timeout_s
121114
)
122-
return aiohttp.ClientSession(
115+
self._client = aiohttp.ClientSession(
123116
connector=connector,
124117
timeout=timeout,
125-
headers=headers,
126118
trace_configs=self._trace_configs,
127119
)
128120

121+
async def _start_token_updater(self) -> None:
122+
if not self._token_path:
123+
return
124+
while True:
125+
try:
126+
token = Path(self._token_path).read_text()
127+
if token != self._token:
128+
self._token = token
129+
logger.info("Kube token was refreshed")
130+
except asyncio.CancelledError:
131+
raise
132+
except Exception as exc:
133+
logger.exception("Failed to update kube token: %s", exc)
134+
await asyncio.sleep(self._token_update_interval_s)
135+
129136
@property
130137
def namespace(self) -> str:
131138
return self._namespace
@@ -134,6 +141,11 @@ async def close(self) -> None:
134141
if self._client:
135142
await self._client.close()
136143
self._client = None
144+
if self._token_updater_task:
145+
self._token_updater_task.cancel()
146+
with suppress(asyncio.CancelledError):
147+
await self._token_updater_task
148+
self._token_updater_task = None
137149

138150
async def __aenter__(self) -> "KubeClient":
139151
await self.init()
@@ -160,23 +172,22 @@ def _generate_secret_url(
160172
all_secrets_url = self._generate_all_secrets_url(namespace_name)
161173
return f"{all_secrets_url}/{secret_name}"
162174

175+
def _create_headers(
176+
self, headers: Optional[dict[str, Any]] = None
177+
) -> dict[str, Any]:
178+
headers = dict(headers) if headers else {}
179+
if self._auth_type == KubeClientAuthType.TOKEN and self._token:
180+
headers["Authorization"] = "Bearer " + self._token
181+
return headers
182+
163183
async def _request(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
164-
await self.init_if_needed()
184+
headers = self._create_headers(kwargs.pop("headers", None))
165185
assert self._client, "client is not initialized"
166-
doing_retry = kwargs.pop("doing_retry", False)
167-
168-
async with self._client.request(*args, **kwargs) as response:
186+
async with self._client.request(*args, headers=headers, **kwargs) as response:
169187
payload = await response.json()
170-
try:
188+
logging.debug("k8s response payload: %s", payload)
171189
self._raise_for_status(payload)
172190
return payload
173-
except KubeClientUnauthorized:
174-
if doing_retry:
175-
raise
176-
# K8s SA's token might be stale, need to refresh it and retry
177-
await self._reload_http_client()
178-
kwargs["doing_retry"] = True
179-
return await self._request(*args, **kwargs)
180191

181192
def _raise_for_status(self, payload: dict[str, Any]) -> None:
182193
kind = payload["kind"]
@@ -196,11 +207,6 @@ def _raise_for_status(self, payload: dict[str, Any]) -> None:
196207
raise ResourceConflict(payload["message"])
197208
raise KubeClientException(payload["message"])
198209

199-
async def _reload_http_client(self) -> None:
200-
await self.close()
201-
self._token = None
202-
await self.init()
203-
204210
async def create_secret(
205211
self,
206212
secret_name: str,

tests/integration/test_kube_client.py

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import os
5+
import tempfile
6+
from collections.abc import AsyncIterator, Iterator
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import aiohttp
11+
import aiohttp.web
12+
import pytest
13+
14+
from platform_secrets.config import KubeClientAuthType
15+
from platform_secrets.kube_client import KubeClient
16+
17+
from .conftest import create_local_app_server
18+
19+
20+
class TestKubeClientTokenUpdater:
21+
@pytest.fixture
22+
async def kube_app(self) -> aiohttp.web.Application:
23+
async def _get_secrets(request: aiohttp.web.Request) -> aiohttp.web.Response:
24+
auth = request.headers["Authorization"]
25+
token = auth.split()[-1]
26+
app["token"]["value"] = token
27+
return aiohttp.web.json_response({"kind": "SecretList", "items": []})
28+
29+
app = aiohttp.web.Application()
30+
app["token"] = {"value": ""}
31+
app.router.add_routes(
32+
[aiohttp.web.get("/api/v1/namespaces/default/secrets", _get_secrets)]
33+
)
34+
return app
35+
36+
@pytest.fixture
37+
async def kube_server(
38+
self, kube_app: aiohttp.web.Application, unused_tcp_port_factory: Any
39+
) -> AsyncIterator[str]:
40+
async with create_local_app_server(
41+
kube_app, port=unused_tcp_port_factory()
42+
) as address:
43+
yield f"http://{address.host}:{address.port}"
44+
45+
@pytest.fixture
46+
def kube_token_path(self) -> Iterator[str]:
47+
_, path = tempfile.mkstemp()
48+
Path(path).write_text("token-1")
49+
yield path
50+
os.remove(path)
51+
52+
@pytest.fixture
53+
async def kube_client(
54+
self, kube_server: str, kube_token_path: str
55+
) -> AsyncIterator[KubeClient]:
56+
async with KubeClient(
57+
base_url=kube_server,
58+
namespace="default",
59+
auth_type=KubeClientAuthType.TOKEN,
60+
token_path=kube_token_path,
61+
token_update_interval_s=1,
62+
) as client:
63+
yield client
64+
65+
async def test_token_periodically_updated(
66+
self,
67+
kube_app: aiohttp.web.Application,
68+
kube_client: KubeClient,
69+
kube_token_path: str,
70+
) -> None:
71+
await kube_client.list_secrets()
72+
assert kube_app["token"]["value"] == "token-1"
73+
74+
Path(kube_token_path).write_text("token-2")
75+
await asyncio.sleep(2)
76+
77+
await kube_client.list_secrets()
78+
assert kube_app["token"]["value"] == "token-2"

0 commit comments

Comments
 (0)