From 336291891d40b6b88be6530dfecf0f7865ea5b76 Mon Sep 17 00:00:00 2001 From: Nick Cao Date: Tue, 27 May 2025 15:10:45 -0400 Subject: [PATCH] Allow specifying client config fully from envvars --- .../jumpstarter_cli_common/config.py | 11 +++- .../jumpstarter/jumpstarter/config/client.py | 63 +++++++++---------- .../jumpstarter/config/client_config_test.py | 1 - .../jumpstarter/jumpstarter/config/common.py | 6 +- .../jumpstarter/config/user_config_test.py | 7 +-- packages/jumpstarter/jumpstarter/utils/env.py | 12 +++- packages/jumpstarter/pyproject.toml | 1 + uv.lock | 25 ++++++++ 8 files changed, 82 insertions(+), 44 deletions(-) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py index 73dbeb152..bd64f395d 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py @@ -1,7 +1,9 @@ +from contextlib import suppress from functools import partial, reduce, wraps from pathlib import Path import click +from pydantic import ValidationError from jumpstarter.config.client import ClientConfigV1Alpha1 from jumpstarter.config.exporter import ExporterConfigV1Alpha1 @@ -52,7 +54,14 @@ def wrapper(*args, **kwds): # noqa: C901 match len(params): case 0: if client: - config = UserConfigV1Alpha1.load_or_create().config.current_client + config = None + + with suppress(ValidationError): + config = ClientConfigV1Alpha1() + + if config is None: + config = UserConfigV1Alpha1.load_or_create().config.current_client + if config is None: raise click.ClickException( f"none of {', '.join(options_names)} is specified, and default config is not set" diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 0d1887cfc..620f1753f 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -4,15 +4,16 @@ from datetime import timedelta from functools import wraps from pathlib import Path -from typing import ClassVar, Literal, Optional, Self +from typing import Annotated, ClassVar, Literal, Optional, Self import grpc import yaml from anyio.from_thread import BlockingPortal, start_blocking_portal -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from .common import CONFIG_PATH, ObjectMeta -from .env import JMP_DRIVERS_ALLOW, JMP_ENDPOINT, JMP_LEASE, JMP_NAME, JMP_NAMESPACE, JMP_TOKEN +from .env import JMP_LEASE from .grpc import call_credentials from .tls import TLSConfigV1Alpha1 from jumpstarter.client.grpc import ClientService @@ -20,17 +21,6 @@ from jumpstarter.common.grpc import aio_secure_channel, ssl_channel_credentials -def _allow_from_env(): - allow = os.environ.get(JMP_DRIVERS_ALLOW) - match allow: - case None: - return [], False - case "UNSAFE": - return [], True - case _: - return allow.split(","), False - - def _blocking_compat(f): @wraps(f) def wrapper(*args, **kwargs): @@ -44,28 +34,47 @@ def wrapper(*args, **kwargs): return wrapper -class ClientConfigV1Alpha1Drivers(BaseModel): - allow: list[str] = Field(default_factory=[]) +class ClientConfigV1Alpha1Drivers(BaseSettings): + model_config = SettingsConfigDict(env_prefix="JMP_DRIVERS_") + + allow: Annotated[list[str], NoDecode] = Field(default_factory=list) unsafe: bool = Field(default=False) + @field_validator("allow", mode="before") + @classmethod + def decode_allow(cls, v: str | list[str]) -> list[str]: + if not isinstance(v, list): + return list(v.split(",")) + else: + return v -class ClientConfigV1Alpha1(BaseModel): + @model_validator(mode="after") + def decode_unsafe(self) -> Self: + if "UNSAFE" in self.allow: + self.unsafe = True + + return self + + +class ClientConfigV1Alpha1(BaseSettings): CLIENT_CONFIGS_PATH: ClassVar[Path] = CONFIG_PATH / "clients" + model_config = SettingsConfigDict(env_prefix="JMP_") + alias: str = Field(default="default") path: Path | None = Field(default=None) apiVersion: Literal["jumpstarter.dev/v1alpha1"] = Field(default="jumpstarter.dev/v1alpha1") kind: Literal["ClientConfig"] = Field(default="ClientConfig") - metadata: ObjectMeta + metadata: ObjectMeta = Field(default_factory=ObjectMeta) endpoint: str tls: TLSConfigV1Alpha1 = Field(default_factory=TLSConfigV1Alpha1) token: str grpcOptions: dict[str, str | int] | None = Field(default_factory=dict) - drivers: ClientConfigV1Alpha1Drivers + drivers: ClientConfigV1Alpha1Drivers = Field(default_factory=ClientConfigV1Alpha1Drivers) async def channel(self): credentials = grpc.composite_channel_credentials( @@ -201,19 +210,7 @@ def try_from_env(cls): @classmethod def from_env(cls): - allow, unsafe = _allow_from_env() - return cls( - metadata=ObjectMeta( - namespace=os.environ.get(JMP_NAMESPACE), - name=os.environ.get(JMP_NAME), - ), - endpoint=os.environ.get(JMP_ENDPOINT), - token=os.environ.get(JMP_TOKEN), - drivers=ClientConfigV1Alpha1Drivers( - allow=allow, - unsafe=unsafe, - ), - ) + return cls() @classmethod def _get_path(cls, alias: str) -> Path: @@ -290,4 +287,4 @@ def dump_json(self): def dump_yaml(self): return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2) - model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + model_config = SettingsConfigDict(arbitrary_types_allowed=True, populate_by_name=True) diff --git a/packages/jumpstarter/jumpstarter/config/client_config_test.py b/packages/jumpstarter/jumpstarter/config/client_config_test.py index 24652a53c..2bd87b168 100644 --- a/packages/jumpstarter/jumpstarter/config/client_config_test.py +++ b/packages/jumpstarter/jumpstarter/config/client_config_test.py @@ -72,7 +72,6 @@ def test_client_config_from_env_allow_unsafe(monkeypatch: pytest.MonkeyPatch): assert config.metadata.name == "testclient" assert config.token == "dGhpc2lzYXRva2VuLTEyMzQxMjM0MTIzNEyMzQtc2Rxd3Jxd2VycXdlcnF3ZXJxd2VyLTEyMzQxMjM0MTIz" assert config.endpoint == "jumpstarter.my-lab.com:1443" - assert config.drivers.allow == [] assert config.drivers.unsafe is True diff --git a/packages/jumpstarter/jumpstarter/config/common.py b/packages/jumpstarter/jumpstarter/config/common.py index 47e59a8e2..3919efae0 100644 --- a/packages/jumpstarter/jumpstarter/config/common.py +++ b/packages/jumpstarter/jumpstarter/config/common.py @@ -1,7 +1,7 @@ from os import getenv from pathlib import Path -from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict from xdg_base_dirs import xdg_config_home from .env import JMP_CLIENT_CONFIG_HOME @@ -10,6 +10,8 @@ CONFIG_PATH = Path(getenv(JMP_CLIENT_CONFIG_HOME, xdg_config_home() / "jumpstarter")) -class ObjectMeta(BaseModel): +class ObjectMeta(BaseSettings): + model_config = SettingsConfigDict(env_prefix="JMP_") + namespace: str | None name: str diff --git a/packages/jumpstarter/jumpstarter/config/user_config_test.py b/packages/jumpstarter/jumpstarter/config/user_config_test.py index 28b7cdf62..3a583b13a 100644 --- a/packages/jumpstarter/jumpstarter/config/user_config_test.py +++ b/packages/jumpstarter/jumpstarter/config/user_config_test.py @@ -4,8 +4,7 @@ import pytest -from jumpstarter.config.client import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers -from jumpstarter.config.common import ObjectMeta +from jumpstarter.config.client import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta from jumpstarter.config.user import UserConfigV1Alpha1, UserConfigV1Alpha1Config @@ -60,7 +59,7 @@ def test_user_config_load_no_current_client(monkeypatch: pytest.MonkeyPatch): ClientConfigV1Alpha1, "load", return_value=ClientConfigV1Alpha1( - name="testclient", + alias="testclient", metadata=ObjectMeta(namespace="default", name="testclient"), endpoint="abc", token="123", @@ -87,7 +86,7 @@ def test_user_config_load_current_client_empty(monkeypatch: pytest.MonkeyPatch): ClientConfigV1Alpha1, "load", return_value=ClientConfigV1Alpha1( - name="testclient", + alias="testclient", metadata=ObjectMeta(namespace="default", name="testclient"), endpoint="abc", token="123", diff --git a/packages/jumpstarter/jumpstarter/utils/env.py b/packages/jumpstarter/jumpstarter/utils/env.py index 9c00bc29d..83d7590bc 100644 --- a/packages/jumpstarter/jumpstarter/utils/env.py +++ b/packages/jumpstarter/jumpstarter/utils/env.py @@ -4,7 +4,7 @@ from anyio.from_thread import start_blocking_portal from jumpstarter.client import client_from_path -from jumpstarter.config.client import _allow_from_env +from jumpstarter.config.client import ClientConfigV1Alpha1Drivers from jumpstarter.config.env import JUMPSTARTER_HOST @@ -21,9 +21,15 @@ async def env_async(portal, stack): if host is None: raise RuntimeError(f"{JUMPSTARTER_HOST} not set") - allow, unsafe = _allow_from_env() + drivers = ClientConfigV1Alpha1Drivers() - async with client_from_path(host, portal, stack, allow=allow, unsafe=unsafe) as client: + async with client_from_path( + host, + portal, + stack, + allow=drivers.allow, + unsafe=drivers.unsafe, + ) as client: try: yield client finally: diff --git a/packages/jumpstarter/pyproject.toml b/packages/jumpstarter/pyproject.toml index 47d200b9f..a593ff5a2 100644 --- a/packages/jumpstarter/pyproject.toml +++ b/packages/jumpstarter/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "tqdm>=4.66.5", "pydantic>=2.8.2", "xdg-base-dirs>=6.0.2", + "pydantic-settings>=2.9.1", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 32d7d67c1..784b2b99f 100644 --- a/uv.lock +++ b/uv.lock @@ -1000,6 +1000,7 @@ dependencies = [ { name = "anyio" }, { name = "jumpstarter-protocol" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pyyaml" }, { name = "tqdm" }, { name = "xdg-base-dirs" }, @@ -1023,6 +1024,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.4.0,!=4.6.2" }, { name = "jumpstarter-protocol", editable = "packages/jumpstarter-protocol" }, { name = "pydantic", specifier = ">=2.8.2" }, + { name = "pydantic-settings", specifier = ">=2.9.1" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "tqdm", specifier = ">=4.66.5" }, { name = "xdg-base-dirs", specifier = ">=6.0.2" }, @@ -2884,6 +2886,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, +] + [[package]] name = "pygls" version = "1.3.1" @@ -3084,6 +3100,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, +] + [[package]] name = "pyudev" version = "0.24.3"