Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support standalone deployment #2492

Merged
merged 8 commits into from
Dec 23, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions neuro-sdk/src/neuro_sdk/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class _ConfigData:
auth_config: _AuthConfig
auth_token: _AuthToken
url: URL
admin_url: URL
admin_url: Optional[URL]
version: str
cluster_name: str
org_name: Optional[str]
Expand Down Expand Up @@ -231,7 +231,7 @@ def api_url(self) -> URL:
return self._config_data.url

@property
def admin_url(self) -> URL:
def admin_url(self) -> Optional[URL]:
return self._config_data.admin_url

@property
Expand Down Expand Up @@ -410,7 +410,10 @@ def _load(path: Path) -> _ConfigData:
payload = cur.fetchone()

api_url = URL(payload["url"])
admin_url = URL(payload["admin_url"])
if not payload["admin_url"]:
admin_url = None
else:
admin_url = URL(payload["admin_url"])
auth_config = _deserialize_auth_config(payload)
clusters = _deserialize_clusters(payload)
version = payload["version"]
Expand Down Expand Up @@ -546,7 +549,10 @@ def _save(config: _ConfigData, path: Path, suppress_errors: bool = True) -> None
# Factory._save()
try:
url = str(config.url)
admin_url = str(config.admin_url)
if not config.admin_url:
admin_url = None
else:
admin_url = str(config.admin_url)
auth_config = _serialize_auth_config(config.auth_config)
clusters = _serialize_clusters(config.clusters)
version = config.version
Expand Down
24 changes: 14 additions & 10 deletions neuro-sdk/src/neuro_sdk/_config_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
AuthTokenClient,
HeadlessNegotiator,
_AuthToken,
create_standalone_token,
logout_from_browser,
)
from ._plugins import PluginManager
Expand Down Expand Up @@ -160,15 +161,20 @@ async def login(
raise ConfigError(f"Config at {self._path} already exists. Please logout")
async with _make_session(timeout, self._trace_configs) as session:
config_unauthorized = await get_server_config(session, url)
negotiator = AuthNegotiator(
session, config_unauthorized.auth_config, show_browser_cb
)
auth_token = await negotiator.get_token()

config_authorized = await get_server_config(
session, url, token=auth_token.token
)
config = self._gen_config(config_authorized, auth_token, url)
if config_unauthorized.clusters:
config_authorized = config_unauthorized
auth_token = create_standalone_token()
else:
negotiator = AuthNegotiator(
session, config_unauthorized.auth_config, show_browser_cb
)
auth_token = await negotiator.get_token()

config_authorized = await get_server_config(
session, url, token=auth_token.token
)
config = self._gen_config(config_authorized, auth_token, url)
self._save(config)

async def login_headless(
Expand Down Expand Up @@ -242,8 +248,6 @@ def _gen_config(
) -> _ConfigData:
from . import __version__

assert server_config.admin_url, "Authorized config should include admin_url"

cluster_name = next(iter(server_config.clusters))
org_name = next(iter(server_config.clusters[cluster_name].orgs))
config = _ConfigData(
Expand Down
4 changes: 3 additions & 1 deletion neuro-sdk/src/neuro_sdk/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ async def request(
real_headers: CIMultiDict[str] = CIMultiDict(headers)
else:
real_headers = CIMultiDict()
real_headers["Authorization"] = auth
if len(auth.split()) > 1:
# auth contains scheme and parameter
real_headers["Authorization"] = auth
if "Content-Type" not in real_headers:
if json is not None:
real_headers["Content-Type"] = "application/json"
Expand Down
8 changes: 8 additions & 0 deletions neuro-sdk/src/neuro_sdk/_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ def urlsafe_unpadded_b64encode(payload: bytes) -> str:
return base64.urlsafe_b64encode(payload).decode().rstrip("=")


# Used only for standalone platform deploymens
JWT_STANDALONE_SECRET = "neuro"

JWT_IDENTITY_CLAIM = "https://platform.neuromation.io/user"
JWT_IDENTITY_CLAIM_OPTIONS = ("identity", JWT_IDENTITY_CLAIM)

Expand Down Expand Up @@ -486,3 +489,8 @@ async def logout_from_browser(
) -> None:
logout_url = config.logout_url.update_query(client_id=config.client_id)
await show_browser_cb(logout_url)


def create_standalone_token() -> _AuthToken:
token = jwt.encode({JWT_IDENTITY_CLAIM: "user"}, JWT_STANDALONE_SECRET)
return _AuthToken.create_non_expiring(token)
16 changes: 16 additions & 0 deletions neuro-sdk/src/neuro_sdk/_s3_bucket_provider.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import ssl
from contextlib import asynccontextmanager
from datetime import datetime
from email.utils import parsedate_to_datetime
from typing import Any, AsyncIterator, Awaitable, Callable, Mapping, Optional, Union

import aiobotocore.session
import botocore.exceptions
import certifi
from aiobotocore.client import AioBaseClient
from aiobotocore.credentials import AioCredentials, AioRefreshableCredentials

Expand Down Expand Up @@ -76,11 +78,25 @@ async def _refresher() -> Mapping[str, str]:
secret_key=initial_credentials.credentials["secret_access_key"],
)

# Use system root CA certificates
# Currently you cannot override ssl context.
#
# Aiobotocore always sets it's own context if verify is None.
#
# If verify is not None aiohttp raises error `verify_ssl, ssl_context,
# fingerprint and ssl parameters are mutually exclusive`.
#
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS)
ssl_context.load_verify_locations(capath=certifi.where())
# config = AioConfig(connector_args={"ssl_context": ssl_context})

async with session.create_client(
"s3",
endpoint_url=initial_credentials.credentials.get("endpoint_url"),
region_name=initial_credentials.credentials.get("region_name"),
) as client:
# Dirty hack to override ssl context in aiobotocore
client._endpoint.http_session._session._connector._ssl = ssl_context
asvetlov marked this conversation as resolved.
Show resolved Hide resolved
yield cls(client, bucket, initial_credentials.credentials["bucket_name"])

@asyncgeneratorcontextmanager
Expand Down
2 changes: 1 addition & 1 deletion neuro-sdk/src/neuro_sdk/_server_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ async def get_server_config(
admin_url: Optional[URL] = None
if "admin_url" in payload:
admin_url = URL(payload["admin_url"])
if headers and (not clusters or not admin_url):
if headers and not clusters:
raise AuthError("Cannot authorize user")
return _ServerConfig(
admin_url=admin_url,
Expand Down
8 changes: 5 additions & 3 deletions neuro-sdk/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from dataclasses import replace
from decimal import Decimal
from pathlib import Path
from typing import Callable, Dict, Optional
from typing import Any, Callable, Dict, Optional

import aiohttp
import aiohttp.pytest_plugin
Expand Down Expand Up @@ -102,8 +102,8 @@ def go(
trace_id: str = "bd7a977555f6b982",
clusters: Optional[Dict[str, Cluster]] = None,
token_url: Optional[URL] = None,
admin_url: Optional[URL] = None,
plugin_manager: Optional[PluginManager] = None,
**kwargs: Any,
) -> Client:
url = URL(url_str)
if clusters is None:
Expand Down Expand Up @@ -167,8 +167,10 @@ def go(
real_auth_config = replace(auth_config, token_url=token_url)
else:
real_auth_config = auth_config
if admin_url is None:
if "admin_url" not in kwargs:
admin_url = URL(url) / ".." / ".." / "apis" / "admin" / "v1"
else:
admin_url = kwargs["admin_url"]
if plugin_manager is None:
plugin_manager = PluginManager()
cluster_name = next(iter(clusters))
Expand Down
60 changes: 60 additions & 0 deletions neuro-sdk/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,66 @@ async def handler(request: web.Request) -> web.Response:
}


async def test_fetch_without_admin_url(
aiohttp_server: _TestServerFactory, make_client: _MakeClient
) -> None:
registry_url = "https://registry2-dev.neu.ro"
storage_url = "https://storage2-dev.neu.ro"
users_url = "https://users2-dev.neu.ro"
monitoring_url = "https://jobs2-dev.neu.ro"
secrets_url = "https://secrets2-dev.neu.ro"
disks_url = "https://disks2-dev.neu.ro"
buckets_url = "https://buckets2-dev.neu.ro"
auth_url = "https://dev-neuro.auth0.com/authorize"
token_url = "https://dev-neuro.auth0.com/oauth/token"
logout_url = "https://dev-neuro.auth0.com/v2/logout"
client_id = "this_is_client_id"
audience = "https://platform.dev.neu.ro."
headless_callback_url = "https://dev.neu.ro/oauth/show-code"
success_redirect_url = "https://platform.neu.ro"
JSON = {
"auth_url": auth_url,
"token_url": token_url,
"logout_url": logout_url,
"client_id": client_id,
"audience": audience,
"headless_callback_url": headless_callback_url,
"success_redirect_url": success_redirect_url,
"clusters": [
{
"name": "default",
"orgs": [None, "some-org"],
"registry_url": registry_url,
"storage_url": storage_url,
"users_url": users_url,
"monitoring_url": monitoring_url,
"secrets_url": secrets_url,
"disks_url": disks_url,
"buckets_url": buckets_url,
"resource_presets": [
{
"name": "cpu-small",
"credits_per_hour": "10",
"cpu": 2,
"memory_mb": 2 * 1024,
}
],
}
],
}

async def handler(request: web.Request) -> web.Response:
return web.json_response(JSON)

app = web.Application()
app.add_routes([web.get("/config", handler)])
srv = await aiohttp_server(app)

async with make_client(srv.make_url("/"), admin_url=None) as client:
await client.config.fetch()
assert client.config.admin_url is None


async def test_fetch_dropped_selected_cluster(
aiohttp_server: _TestServerFactory, make_client: _MakeClient
) -> None:
Expand Down
29 changes: 27 additions & 2 deletions neuro-sdk/tests/test_config_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import pytest
from aiohttp import web
from aiohttp.test_utils import TestServer as _TestServer
from jose import jws
from yarl import URL

from neuro_sdk import (
Expand All @@ -23,6 +24,7 @@
__version__,
)
from neuro_sdk._config import _AuthConfig, _AuthToken, _ConfigData
from neuro_sdk._login import JWT_STANDALONE_SECRET

from tests import _TestServerFactory

Expand Down Expand Up @@ -76,7 +78,9 @@ async def mock_for_login_factory(
token: str,
aiohttp_unused_port: Callable[[], int],
) -> Callable[[MockForLoginControl], Awaitable[_TestServer]]:
async def _factory(control: MockForLoginControl) -> _TestServer:
async def _factory(
control: MockForLoginControl, auth_enabled: bool = True
) -> _TestServer:
callback_urls = [
f"http://127.0.0.1:{aiohttp_unused_port()}",
f"http://127.0.0.1:{aiohttp_unused_port()}",
Expand All @@ -96,7 +100,7 @@ async def config_handler(request: web.Request) -> web.Response:
"success_redirect_url": "http://example.com",
}

if (
if not auth_enabled or (
"Authorization" in request.headers
and "incorrect" not in request.headers["Authorization"]
):
Expand Down Expand Up @@ -278,6 +282,27 @@ async def test_normal_login(
nmrc_path = tmp_home / ".neuro"
assert Path(nmrc_path).exists(), "Config file not written after login "

async def test_login_to_server_without_auth(
self,
tmp_home: Path,
mock_for_login_factory: Callable[..., Awaitable[_TestServer]],
) -> None:
mock_for_login = await mock_for_login_factory(
MockForLoginControl(), auth_enabled=False
)
await Factory().login(self.show_dummy_browser, url=mock_for_login.make_url("/"))
nmrc_path = tmp_home / ".neuro"
assert Path(nmrc_path).exists(), "Config file not written after login "

client = await Factory(Path(nmrc_path)).get()
await client.close()
token = await client.config.token()

assert client.config.username == "user"
jws.verify(
token, JWT_STANDALONE_SECRET, algorithms="HS256"
) # verify it is standalone token


class TestLoginWithToken:
async def test_login_with_token_already_logged(self, config_dir: Path) -> None:
Expand Down