Skip to content

Commit 368db92

Browse files
feat: attempt to seperate config/credential resolution from the cli
1 parent 36ca1c8 commit 368db92

24 files changed

+1172
-532
lines changed

cloudsmith_cli/cli/commands/auth.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
import click
66

7+
from ...core.credentials.session import create_session
8+
from ...core.saml import get_idp_url
79
from .. import decorators, utils, validators
810
from ..exceptions import handle_api_exceptions
9-
from ..saml import create_configured_session, get_idp_url
1011
from ..webserver import AuthenticationWebRequestHandler, AuthenticationWebServer
1112
from .main import main
1213
from .tokens import create, request_api_key
@@ -20,7 +21,12 @@ def _perform_saml_authentication(
2021
opts, owner, enable_token_creation=False, use_stderr=False
2122
):
2223
"""Perform SAML authentication via web browser and local web server."""
23-
session = create_configured_session(opts)
24+
session = create_session(
25+
proxy=opts.api_proxy,
26+
ssl_verify=opts.api_ssl_verify,
27+
user_agent=opts.api_user_agent,
28+
headers=opts.api_headers,
29+
)
2430
api_host = opts.api_config.host
2531

2632
idp_url = get_idp_url(api_host, owner, session=session)

cloudsmith_cli/cli/config.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -152,21 +152,27 @@ def has_default_file(cls):
152152
@classmethod
153153
def load_config(cls, opts, path=None, profile=None):
154154
"""Load a configuration file into an options object."""
155-
if path and os.path.exists(path):
156-
if os.path.isdir(path):
157-
cls.config_searchpath.insert(0, path)
158-
else:
159-
cls.config_files.insert(0, path)
160-
161-
config = cls.read_config()
162-
values = config.get("default", {})
163-
cls._load_values_into_opts(opts, values)
164-
165-
if profile and profile != "default":
166-
values = config.get("profile:%s" % profile, {})
155+
orig_searchpath = cls.config_searchpath[:]
156+
orig_files = cls.config_files[:]
157+
try:
158+
if path and os.path.exists(path):
159+
if os.path.isdir(path):
160+
cls.config_searchpath.insert(0, path)
161+
else:
162+
cls.config_files.insert(0, path)
163+
164+
config = cls.read_config()
165+
values = config.get("default", {})
167166
cls._load_values_into_opts(opts, values)
168167

169-
return values
168+
if profile and profile != "default":
169+
values = config.get("profile:%s" % profile, {})
170+
cls._load_values_into_opts(opts, values)
171+
172+
return values
173+
finally:
174+
cls.config_searchpath = orig_searchpath
175+
cls.config_files = orig_files
170176

171177
@staticmethod
172178
def _load_values_into_opts(opts, values):

cloudsmith_cli/cli/decorators.py

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from cloudsmith_cli.cli import validators
88

99
from ..core.api.init import initialise_api as _initialise_api
10+
from ..core.config_resolver import ConfigResolver
11+
from ..core.credentials import CredentialContext, CredentialProviderChain
1012
from ..core.mcp import server
1113
from . import config, utils
1214

@@ -221,8 +223,17 @@ def wrapper(ctx, *args, **kwargs):
221223
return wrapper
222224

223225

224-
def initialise_api(f):
225-
"""Initialise the Cloudsmith API for use."""
226+
def _pop_boolean_option(kwargs, name, invert=False):
227+
"""Pop a boolean option from kwargs, optionally inverting it."""
228+
value = kwargs.pop(name)
229+
value = value if value is not None else None
230+
if value is not None and invert:
231+
value = not value
232+
return value
233+
234+
235+
def resolve_credentials(f):
236+
"""Resolve client configuration and credentials without initialising the API."""
226237

227238
@click.option(
228239
"--api-host", envvar="CLOUDSMITH_API_HOST", help="The API host to connect to."
@@ -252,6 +263,75 @@ def initialise_api(f):
252263
envvar="CLOUDSMITH_API_HEADERS",
253264
help="A CSV list of extra headers (key=value) to send to the API.",
254265
)
266+
@click.pass_context
267+
@functools.wraps(f)
268+
def wrapper(ctx, *args, **kwargs):
269+
# pylint: disable=missing-docstring
270+
opts = config.get_or_create_options(ctx)
271+
272+
cli_api_host = kwargs.pop("api_host")
273+
cli_api_proxy = kwargs.pop("api_proxy")
274+
cli_ssl_verify = _pop_boolean_option(
275+
kwargs, "without_api_ssl_verify", invert=True
276+
)
277+
cli_api_user_agent = kwargs.pop("api_user_agent")
278+
cli_api_headers = kwargs.pop("api_headers")
279+
280+
client_config = ConfigResolver().resolve(
281+
api_host=cli_api_host,
282+
proxy=cli_api_proxy,
283+
ssl_verify=cli_ssl_verify,
284+
user_agent=cli_api_user_agent,
285+
headers=cli_api_headers,
286+
debug=opts.debug,
287+
profile=ctx.meta.get("profile"),
288+
config_file=ctx.meta.get("config_file"),
289+
)
290+
291+
opts.api_host = client_config.api_host
292+
opts.api_proxy = client_config.proxy
293+
opts.api_ssl_verify = client_config.ssl_verify
294+
opts.api_user_agent = client_config.user_agent
295+
opts.api_headers = client_config.headers
296+
297+
credential_context = CredentialContext(
298+
config=client_config,
299+
creds_file_path=ctx.meta.get("creds_file"),
300+
profile=ctx.meta.get("profile"),
301+
debug=opts.debug,
302+
cli_api_key=opts.api_key,
303+
)
304+
305+
chain = CredentialProviderChain()
306+
307+
credential = chain.resolve(credential_context)
308+
309+
if credential_context.keyring_refresh_failed:
310+
click.secho(
311+
"An error occurred when attempting to refresh your SSO access token. "
312+
"To refresh this session, run 'cloudsmith auth'",
313+
fg="yellow",
314+
err=True,
315+
)
316+
if credential:
317+
click.secho(
318+
"Falling back to API key authentication.",
319+
fg="yellow",
320+
err=True,
321+
)
322+
323+
opts.client_config = client_config
324+
opts.credential = credential
325+
326+
kwargs["opts"] = opts
327+
return ctx.invoke(f, *args, **kwargs)
328+
329+
return wrapper
330+
331+
332+
def initialise_api(f):
333+
"""Initialise the Cloudsmith API for use. Depends on resolve_credentials."""
334+
255335
@click.option(
256336
"-R",
257337
"--without-rate-limit",
@@ -294,49 +374,50 @@ def initialise_api(f):
294374
@functools.wraps(f)
295375
def wrapper(ctx, *args, **kwargs):
296376
# pylint: disable=missing-docstring
297-
def _set_boolean(name, invert=False):
298-
value = kwargs.pop(name)
299-
value = value if value is not None else None
300-
if value is not None and invert:
301-
value = not value
302-
return value
303-
304377
opts = config.get_or_create_options(ctx)
305-
opts.api_host = kwargs.pop("api_host")
306-
opts.api_proxy = kwargs.pop("api_proxy")
307-
opts.api_ssl_verify = _set_boolean("without_api_ssl_verify", invert=True)
308-
opts.api_user_agent = kwargs.pop("api_user_agent")
309-
opts.api_headers = kwargs.pop("api_headers")
310-
opts.rate_limit = _set_boolean("without_rate_limit", invert=True)
378+
379+
opts.rate_limit = _pop_boolean_option(kwargs, "without_rate_limit", invert=True)
311380
opts.rate_limit_warning = kwargs.pop("rate_limit_warning")
312381
opts.error_retry_max = kwargs.pop("error_retry_max")
313382
opts.error_retry_backoff = kwargs.pop("error_retry_backoff")
314383
opts.error_retry_codes = kwargs.pop("error_retry_codes")
315384
opts.error_retry_cb = report_retry
316385

386+
client_config = opts.client_config
387+
credential = opts.credential
388+
389+
resolved_key = None
390+
resolved_access_token = None
391+
if credential:
392+
if credential.auth_type == "bearer":
393+
resolved_access_token = credential.api_key
394+
else:
395+
resolved_key = credential.api_key
396+
317397
def call_print_rate_limit_info_with_opts(rate_info):
318398
utils.print_rate_limit_info(opts, rate_info)
319399

320400
opts.api_config = _initialise_api(
321-
debug=opts.debug,
322-
host=opts.api_host,
323-
key=opts.api_key,
324-
proxy=opts.api_proxy,
325-
ssl_verify=opts.api_ssl_verify,
326-
user_agent=opts.api_user_agent,
327-
headers=opts.api_headers,
401+
debug=client_config.debug,
402+
host=client_config.api_host,
403+
key=resolved_key,
404+
proxy=client_config.proxy,
405+
ssl_verify=client_config.ssl_verify,
406+
user_agent=client_config.user_agent,
407+
headers=client_config.headers,
328408
rate_limit=opts.rate_limit,
329409
rate_limit_callback=call_print_rate_limit_info_with_opts,
330410
error_retry_max=opts.error_retry_max,
331411
error_retry_backoff=opts.error_retry_backoff,
332412
error_retry_codes=opts.error_retry_codes,
333413
error_retry_cb=opts.error_retry_cb,
414+
access_token=resolved_access_token,
334415
)
335416

336417
kwargs["opts"] = opts
337418
return ctx.invoke(f, *args, **kwargs)
338419

339-
return wrapper
420+
return resolve_credentials(wrapper)
340421

341422

342423
def initialise_mcp(f):

cloudsmith_cli/cli/saml.py

Lines changed: 0 additions & 111 deletions
This file was deleted.

cloudsmith_cli/cli/tests/commands/test_auth.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@
1111
@pytest.fixture
1212
def mock_saml_session():
1313
"""Mock the SAML session creation."""
14-
with patch(
15-
"cloudsmith_cli.cli.commands.auth.create_configured_session"
16-
) as mock_session:
14+
with patch("cloudsmith_cli.cli.commands.auth.create_session") as mock_session:
1715
mock_session.return_value = MagicMock()
1816
yield mock_session
1917

cloudsmith_cli/cli/tests/test_saml.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import requests
55

66
from ...core.api.exceptions import ApiException
7-
from ..saml import exchange_2fa_token, get_idp_url, refresh_access_token
7+
from ...core.saml import exchange_2fa_token, get_idp_url, refresh_access_token
88

99

1010
@pytest.fixture

cloudsmith_cli/cli/webserver.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ..core.api.exceptions import ApiException
1111
from ..core.api.init import initialise_api
1212
from ..core.keyring import store_sso_tokens
13-
from .saml import exchange_2fa_token
13+
from ..core.saml import exchange_2fa_token
1414

1515

1616
def get_template_path(template_name):

0 commit comments

Comments
 (0)