Skip to content

Commit e0621a3

Browse files
authored
Merge branch 'master' into dependabot/pip/neuro-auth-client-24.8.0
2 parents b3da0fe + ea070c2 commit e0621a3

File tree

10 files changed

+296
-34
lines changed

10 files changed

+296
-34
lines changed

.pre-commit-config.yaml

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: 'v4.4.0'
3+
rev: 'v5.0.0'
44
hooks:
55
- id: check-merge-conflict
66
exclude: "rst$"
77
- repo: https://github.com/asottile/yesqa
8-
rev: v1.4.0
8+
rev: v1.5.0
99
hooks:
1010
- id: yesqa
1111
- repo: https://github.com/sondrelg/pep585-upgrade
@@ -15,22 +15,21 @@ repos:
1515
args:
1616
- --futures=true
1717
- repo: https://github.com/Zac-HD/shed
18-
rev: 2023.3.1
18+
rev: 2024.10.1
1919
hooks:
2020
- id: shed
2121
args:
2222
- --refactor
23-
- --py39-plus
2423
types_or:
2524
- python
2625
- markdown
2726
- rst
2827
- repo: https://github.com/PyCQA/flake8
29-
rev: 6.0.0
28+
rev: 7.1.1
3029
hooks:
3130
- id: flake8
3231
- repo: https://github.com/pre-commit/pre-commit-hooks
33-
rev: 'v4.4.0'
32+
rev: 'v5.0.0'
3433
hooks:
3534
- id: check-case-conflict
3635
- id: check-json
@@ -44,7 +43,7 @@ repos:
4443
- id: debug-statements
4544
# Another entry is required to apply file-contents-sorter to another file
4645
- repo: https://github.com/pre-commit/pre-commit-hooks
47-
rev: 'v4.4.0'
46+
rev: 'v5.0.0'
4847
hooks:
4948
- id: file-contents-sorter
5049
files: |
@@ -62,7 +61,7 @@ repos:
6261
# - -ignore
6362
# - 'SC1004:'
6463
- repo: https://github.com/sirosen/check-jsonschema
65-
rev: 0.22.0
64+
rev: 0.31.0
6665
hooks:
6766
- id: check-github-actions
6867
- id: check-github-workflows

minikube.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function minikube::start {
1414

1515
function minikube::load_images {
1616
echo "Loading images to minikube..."
17-
make gke_docker_pull_test_images
17+
make docker_pull_test_images
1818
}
1919

2020
function minikube::apply_all_configurations {

platform_secrets/api.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
HTTPInternalServerError,
1313
HTTPNoContent,
1414
HTTPNotFound,
15+
HTTPUnprocessableEntity,
1516
Request,
1617
Response,
1718
StreamResponse,
@@ -34,12 +35,13 @@
3435
from .config_factory import EnvironConfigFactory
3536
from .identity import untrusted_user
3637
from .kube_client import KubeClient
37-
from .service import Secret, SecretNotFound, Service
38+
from .service import CopyScopeMissingError, Secret, SecretNotFound, Service
3839
from .validators import (
3940
secret_key_validator,
4041
secret_list_response_validator,
4142
secret_request_validator,
4243
secret_response_validator,
44+
secret_unwrap_validator,
4345
)
4446

4547
logger = logging.getLogger(__name__)
@@ -80,6 +82,7 @@ def register(self, app: aiohttp.web.Application) -> None:
8082
aiohttp.web.post("", self.handle_post),
8183
aiohttp.web.get("", self.handle_get_all),
8284
aiohttp.web.delete("/{key}", self.handle_delete),
85+
aiohttp.web.post("/copy", self.handle_copy),
8386
]
8487
)
8588

@@ -199,6 +202,37 @@ async def handle_delete(self, request: Request) -> Response:
199202
return json_response(resp_payload, status=HTTPNotFound.status_code)
200203
raise HTTPNoContent()
201204

205+
async def handle_copy(self, request: Request) -> Response:
206+
payload = await request.json()
207+
payload = secret_unwrap_validator.check(payload)
208+
user = await self._get_untrusted_user(request)
209+
210+
org_name = payload["org_name"]
211+
project_name = payload["project_name"] or user.name
212+
213+
await check_permissions(
214+
request,
215+
[self._get_secrets_write_perm(project_name, org_name)],
216+
)
217+
218+
target_namespace = payload["target_namespace"]
219+
secret_names = payload["secret_names"]
220+
221+
try:
222+
await self._service.copy_to_namespace(
223+
org_name=org_name,
224+
project_name=project_name,
225+
target_namespace=target_namespace,
226+
secret_names=secret_names,
227+
)
228+
except CopyScopeMissingError as e:
229+
resp_payload = {"error": str(e)}
230+
return json_response(
231+
resp_payload, status=HTTPUnprocessableEntity.status_code
232+
)
233+
234+
return Response(status=HTTPCreated.status_code)
235+
202236

203237
@middleware
204238
async def handle_exceptions(

platform_secrets/kube_client.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import logging
44
import ssl
5+
import typing
56
from contextlib import suppress
67
from io import BytesIO
78
from pathlib import Path
@@ -107,7 +108,7 @@ async def init(self) -> None:
107108
limit=self._conn_pool_size, ssl=self._create_ssl_context()
108109
)
109110
if self._token_path:
110-
self._token = Path(self._token_path).read_text()
111+
self._token = self._token_from_path()
111112
self._token_updater_task = asyncio.create_task(self._start_token_updater())
112113
timeout = aiohttp.ClientTimeout(
113114
connect=self._conn_timeout_s, total=self._read_timeout_s
@@ -123,7 +124,7 @@ async def _start_token_updater(self) -> None:
123124
return
124125
while True:
125126
try:
126-
token = Path(self._token_path).read_text()
127+
token = self._token_from_path()
127128
if token != self._token:
128129
self._token = token
129130
logger.info("Kube token was refreshed")
@@ -133,6 +134,10 @@ async def _start_token_updater(self) -> None:
133134
logger.exception("Failed to update kube token: %s", exc)
134135
await asyncio.sleep(self._token_update_interval_s)
135136

137+
def _token_from_path(self) -> str:
138+
token_path = typing.cast(str, self._token_path)
139+
return Path(token_path).read_text().strip()
140+
136141
@property
137142
def namespace(self) -> str:
138143
return self._namespace
@@ -158,9 +163,13 @@ async def __aexit__(self, *args: Any) -> None:
158163
def _api_v1_url(self) -> str:
159164
return f"{self._base_url}/api/v1"
160165

166+
@property
167+
def _namespaces_url(self) -> str:
168+
return f"{self._api_v1_url}/namespaces"
169+
161170
def _generate_namespace_url(self, namespace_name: Optional[str] = None) -> str:
162171
namespace_name = namespace_name or self._namespace
163-
return f"{self._api_v1_url}/namespaces/{namespace_name}"
172+
return f"{self._namespaces_url}/{namespace_name}"
164173

165174
def _generate_all_secrets_url(self, namespace_name: Optional[str] = None) -> str:
166175
namespace_url = self._generate_namespace_url(namespace_name)
@@ -210,24 +219,34 @@ def _raise_for_status(self, payload: dict[str, Any]) -> None:
210219
async def create_secret(
211220
self,
212221
secret_name: str,
213-
data: dict[str, str],
222+
data: Union[str, dict[str, str]],
214223
labels: dict[str, str],
215224
*,
216225
namespace_name: Optional[str] = None,
226+
replace_on_conflict: bool = False,
217227
) -> None:
218228
url = self._generate_all_secrets_url(namespace_name)
219-
data = data.copy()
220-
data[self._dummy_secret_key] = ""
229+
if isinstance(data, dict):
230+
data_payload = data.copy()
231+
data_payload[self._dummy_secret_key] = ""
232+
else:
233+
data_payload = {secret_name: data}
234+
221235
payload = {
222236
"apiVersion": "v1",
223237
"kind": "Secret",
224238
"metadata": {"name": secret_name, "labels": labels},
225-
"data": data,
239+
"data": data_payload,
226240
"type": "Opaque",
227241
}
228-
headers = {"Content-Type": "application/json"}
229-
req_data = BytesIO(json.dumps(payload).encode())
230-
await self._request(method="POST", url=url, headers=headers, data=req_data)
242+
try:
243+
await self._request(method="POST", url=url, json=payload)
244+
except ResourceConflict as e:
245+
if not replace_on_conflict:
246+
raise e
247+
# replace a secret
248+
url = f"{url}/{secret_name}"
249+
await self._request(method="PUT", url=url, json=payload)
231250

232251
async def add_secret_key(
233252
self,
@@ -281,3 +300,17 @@ async def list_secrets(
281300
for item in items:
282301
self._cleanup_secret_payload(item)
283302
return items
303+
304+
async def create_namespace(self, name: str) -> None:
305+
"""Creates a namespace. Ignores conflict errors"""
306+
url = URL(self._namespaces_url)
307+
payload = {
308+
"apiVersion": "v1",
309+
"kind": "Namespace",
310+
"metadata": {"name": name},
311+
}
312+
try:
313+
await self._request(method="POST", url=url, json=payload)
314+
except ResourceConflict:
315+
# ignore on conflict
316+
return

platform_secrets/service.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import logging
24
import re
35
from dataclasses import dataclass, field
@@ -14,16 +16,23 @@
1416
logger = logging.getLogger()
1517

1618
SECRET_API_ORG_LABEL = "platform.neuromation.io/secret-api-org-name"
19+
APPS_SECRET_NAME = "apps-secrets"
1720

1821
NO_ORG = "NO_ORG"
1922

2023

2124
class SecretNotFound(Exception):
2225
@classmethod
23-
def create(cls, secret_key: str) -> "SecretNotFound":
26+
def create(cls, secret_key: str) -> SecretNotFound:
2427
return cls(f"Secret {secret_key!r} not found")
2528

2629

30+
class CopyScopeMissingError(Exception):
31+
@classmethod
32+
def create(cls, missing_keys: set[str]) -> CopyScopeMissingError:
33+
return cls(f"Missing secrets: {', '.join(missing_keys)}")
34+
35+
2736
@dataclass(frozen=True)
2837
class Secret:
2938
key: str
@@ -118,6 +127,44 @@ async def get_all_secrets(
118127
]
119128
return result
120129

130+
async def copy_to_namespace(
131+
self,
132+
org_name: str,
133+
project_name: str,
134+
target_namespace: str,
135+
secret_names: list[str],
136+
) -> None:
137+
"""
138+
Unwraps secrets from a dict and extracts them to a dedicated namespace.
139+
"""
140+
secrets = await self.get_all_secrets(
141+
with_values=True,
142+
org_name=org_name,
143+
project_name=project_name,
144+
)
145+
secrets_scope = set(secret_names)
146+
147+
missing_secret_names = secrets_scope - {secret.key for secret in secrets}
148+
if missing_secret_names:
149+
raise CopyScopeMissingError.create(missing_secret_names)
150+
151+
# ensure namespace is there
152+
await self._kube_client.create_namespace(name=target_namespace)
153+
154+
data = {
155+
secret.key: secret.value
156+
for secret in secrets
157+
if secret.key in secrets_scope
158+
}
159+
160+
await self._kube_client.create_secret(
161+
APPS_SECRET_NAME,
162+
data=data,
163+
labels={},
164+
namespace_name=target_namespace,
165+
replace_on_conflict=True,
166+
)
167+
121168
async def migrate_user_to_project_secrets(self) -> None:
122169
# TODO: remove migration after deploy to prod
123170
user_secrets = [

platform_secrets/validators.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,11 @@ def check_base64(value: str) -> str:
4343
}
4444
)
4545
secret_list_response_validator = t.List(secret_response_validator)
46+
secret_unwrap_validator = t.Dict(
47+
{
48+
t.Key("org_name"): t.String(min_length=1, max_length=253) | t.Null(),
49+
t.Key("project_name"): t.String(min_length=1, max_length=253) | t.Null(),
50+
t.Key("target_namespace"): t.String(min_length=1, max_length=253),
51+
t.Key("secret_names"): t.List(t.String(), min_length=1),
52+
}
53+
)

setup.cfg

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,22 @@ platforms = any
1616
include_package_data = True
1717
install_requires =
1818
aiohttp==3.10.10
19-
yarl==1.17.1
19+
yarl==1.18.3
2020
multidict==6.0.5
2121
neuro-auth-client==24.8.0
2222
trafaret==2.1.1
23-
neuro-logging==24.4.0
23+
neuro-logging==25.1.0
2424

2525
[options.entry_points]
2626
console_scripts =
2727
platform-secrets = platform_secrets.api:main
2828

2929
[options.extras_require]
3030
dev =
31-
mypy==1.13.0
32-
pre-commit==4.0.1
31+
mypy==1.14.1
32+
pre-commit==4.1.0
3333
pytest==8.3.3
34-
pytest-asyncio==0.23.8
34+
pytest-asyncio==0.25.2
3535
pytest-cov==6.0.0
3636

3737
[flake8]

tests/integration/conftest.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ def config_factory(
3737
auth_config: PlatformAuthConfig, kube_config: KubeConfig, cluster_name: str
3838
) -> Callable[..., Config]:
3939
def _f(**kwargs: Any) -> Config:
40-
defaults = dict(
41-
server=ServerConfig(host="0.0.0.0", port=8080),
42-
platform_auth=auth_config,
43-
kube=kube_config,
44-
cluster_name=cluster_name,
45-
)
40+
defaults = {
41+
"server": ServerConfig(host="0.0.0.0", port=8080),
42+
"platform_auth": auth_config,
43+
"kube": kube_config,
44+
"cluster_name": cluster_name,
45+
}
4646
kwargs = {**defaults, **kwargs}
4747
return Config(**kwargs)
4848

tests/integration/conftest_auth.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from yarl import URL
1010

1111
from platform_secrets.config import PlatformAuthConfig
12-
1312
from tests.integration.conftest import get_service_url, random_name
1413

1514

0 commit comments

Comments
 (0)