diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py index e32004de2..de5cda5af 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/__init__.py @@ -5,9 +5,9 @@ from jumpstarter_cli_common import AliasedGroup, opt_log_level, version from .client_config import create_client_config, delete_client_config, list_client_configs, use_client_config +from .client_lease import client_lease from .client_login import client_login from .client_shell import client_shell -from .lease import lease from jumpstarter.common.utils import env @@ -30,7 +30,7 @@ def j(): client.add_command(delete_client_config) client.add_command(list_client_configs) client.add_command(use_client_config) -client.add_command(lease) +client.add_command(client_lease) client.add_command(client_login) client.add_command(client_shell) client.add_command(version) diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py index 3f755f2ef..2cf48df20 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_config.py @@ -2,6 +2,7 @@ import asyncclick as click from jumpstarter_cli_common import make_table +from jumpstarter_cli_common.exceptions import handle_exceptions from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta, UserConfigV1Alpha1 @@ -50,6 +51,7 @@ default="", ) @click.option("--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).") +@handle_exceptions def create_client_config( alias: str, namespace: str, @@ -98,6 +100,7 @@ def set_next_client(name: str): @click.command("delete-config", short_help="Delete a client config.") @click.argument("name", type=str) +@handle_exceptions def delete_client_config(name: str): """Delete a Jumpstarter client configuration.""" set_next_client(name) @@ -105,6 +108,7 @@ def delete_client_config(name: str): @click.command("list-configs", short_help="List available client configurations.") +@handle_exceptions def list_client_configs(): # Allow listing if there is no user config defined current_name = None @@ -130,6 +134,7 @@ def make_row(c: ClientConfigV1Alpha1): @click.command("use-config", short_help="Select the current client config.") @click.argument("name", type=str) +@handle_exceptions def use_client_config(name: str): """Select the current Jumpstarter client configuration to use.""" user_config = UserConfigV1Alpha1.load_or_create() diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/lease.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_lease.py similarity index 63% rename from packages/jumpstarter-cli-client/jumpstarter_cli_client/lease.py rename to packages/jumpstarter-cli-client/jumpstarter_cli_client/client_lease.py index 7f20e7c90..a830e2696 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/lease.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_lease.py @@ -1,5 +1,6 @@ import asyncclick as click from jumpstarter_cli_common import AliasedGroup +from jumpstarter_cli_common.exceptions import handle_exceptions from jumpstarter.common import MetadataFilter from jumpstarter.config import ( @@ -8,14 +9,15 @@ ) -@click.group(cls=AliasedGroup, short_help="") -def lease(): +@click.group(name="lease", cls=AliasedGroup, short_help="") +def client_lease(): """Manage leases held by the current client""" pass -@lease.command("list") +@client_lease.command("list") @click.argument("name", type=str, default="") +@handle_exceptions def lease_list(name): if name: config = ClientConfigV1Alpha1.load(name) @@ -28,30 +30,34 @@ def lease_list(name): print(lease) -@lease.command("release") +@client_lease.command("release") @click.argument("name", type=str, default="") @click.option("-l", "--lease", "lease", type=str, default="") @click.option("--all", "all_leases", is_flag=True) +@handle_exceptions def lease_release(name, lease, all_leases): if name: config = ClientConfigV1Alpha1.load(name) else: config = UserConfigV1Alpha1.load_or_create().config.current_client if not config: - raise ValueError("no client specified") + raise click.BadParameter("no client specified, and no default client set:" + + "specify a client name, or use jmp client use-config ", param_hint="name") if all_leases: for lease in config.list_leases(): config.release_lease(lease) else: if not lease: - raise ValueError("no lease specified") + raise click.BadParameter("no lease specified, provide one or use --all to release all leases", + param_hint="lease") config.release_lease(lease) -@lease.command("request") +@client_lease.command("request") @click.option("-l", "--label", "labels", type=(str, str), multiple=True) @click.argument("name", type=str, default="") +@handle_exceptions def lease_request(name, labels): """Request an exporter lease from the jumpstarter controller. @@ -74,16 +80,13 @@ def lease_request(name, labels): $ jmp lease release -l "${JMP_LEASE}" """ - try: - if name: - config = ClientConfigV1Alpha1.load(name) - else: - config = UserConfigV1Alpha1.load_or_create().config.current_client - if not config: - raise ValueError("No client specified") - lease = config.request_lease(metadata_filter=MetadataFilter(labels=dict(labels))) - print(lease.name) - except ValueError as e: - raise click.ClickException(str(e)) from e - except Exception as e: - raise e + if name: + config = ClientConfigV1Alpha1.load(name) + else: + config = UserConfigV1Alpha1.load_or_create().config.current_client + if not config: + raise click.BadParameter("no client specified, and no default client set:" + + "specify a client name, or use jmp client use-config ", param_hint="name") + lease = config.request_lease(metadata_filter=MetadataFilter(labels=dict(labels))) + print(lease.name) + diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_login.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_login.py index e9424d012..c007ce4ba 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_login.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_login.py @@ -1,6 +1,8 @@ import asyncclick as click +from jumpstarter_cli_common.exceptions import async_handle_exceptions from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_client_id +from jumpstarter.common.exceptions import FileNotFoundError from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta, UserConfigV1Alpha1 @@ -43,6 +45,7 @@ "--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).", default=None ) @opt_client_id +@async_handle_exceptions async def client_login( # noqa: C901 alias: str, endpoint: str, diff --git a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py index bdcd0ffb4..e3b14c0c3 100644 --- a/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py +++ b/packages/jumpstarter-cli-client/jumpstarter_cli_client/client_shell.py @@ -1,4 +1,5 @@ import asyncclick as click +from jumpstarter_cli_common.exceptions import handle_exceptions from jumpstarter.common import MetadataFilter from jumpstarter.common.utils import launch_shell @@ -12,6 +13,7 @@ @click.argument("name", type=str, default="") @click.option("-l", "--label", "labels", type=(str, str), multiple=True) @click.option("-n", "--lease", "lease_name", type=str) +@handle_exceptions def client_shell(name: str, labels, lease_name): """Spawns a shell connecting to a leased remote exporter""" if name: @@ -19,7 +21,9 @@ def client_shell(name: str, labels, lease_name): else: config = UserConfigV1Alpha1.load_or_create().config.current_client if not config: - raise ValueError("no client specified") + raise click.BadParameter("no client specified, and no default client set:" + + "specify a client name, or use jmp client use-config ", param_hint="name") + with config.lease(metadata_filter=MetadataFilter(labels=dict(labels)), lease_name=lease_name) as lease: with lease.serve_unix() as path: diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py new file mode 100644 index 000000000..148135784 --- /dev/null +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py @@ -0,0 +1,36 @@ +import asyncclick as click + +from jumpstarter.common.exceptions import JumpstarterException + + +class ClickExceptionRed(click.ClickException): + def format_message(self) -> str: + return click.style(self.message, fg='red') + +def async_handle_exceptions(func): + """Decorator to handle exceptions in async functions.""" + async def wrapped(*args, **kwargs): + try: + return await func(*args, **kwargs) + except JumpstarterException as e: + raise ClickExceptionRed(str(e)) from None + except click.ClickException: + raise # if it was already a click exception from the cli commands, just re-raise it + except Exception: + raise + + return wrapped + +def handle_exceptions(func): + """Decorator to handle exceptions in blocking functions.""" + def wrapped(*args, **kwargs): + try: + return func(*args, **kwargs) + except JumpstarterException as e: + raise ClickExceptionRed(str(e)) from None + except click.ClickException: + raise # if it was already a click exception from the cli commands, just re-raise it + except Exception: + raise + + return wrapped diff --git a/packages/jumpstarter/jumpstarter/client/exceptions.py b/packages/jumpstarter/jumpstarter/client/exceptions.py new file mode 100644 index 000000000..84331869f --- /dev/null +++ b/packages/jumpstarter/jumpstarter/client/exceptions.py @@ -0,0 +1,5 @@ +from jumpstarter.common import exceptions + + +class LeaseError(exceptions.JumpstarterException): + """Raised when a lease operation fails.""" diff --git a/packages/jumpstarter/jumpstarter/client/lease.py b/packages/jumpstarter/jumpstarter/client/lease.py index 23152ed67..b93b0ad25 100644 --- a/packages/jumpstarter/jumpstarter/client/lease.py +++ b/packages/jumpstarter/jumpstarter/client/lease.py @@ -8,9 +8,11 @@ from grpc.aio import Channel from jumpstarter_protocol import jumpstarter_pb2, jumpstarter_pb2_grpc, kubernetes_pb2 +from .exceptions import LeaseError from jumpstarter.client import client_from_path from jumpstarter.common import MetadataFilter, TemporaryUnixListener from jumpstarter.common.condition import condition_false, condition_message, condition_present_and_equal, condition_true +from jumpstarter.common.grpc import translate_grpc_exceptions from jumpstarter.common.streams import connect_router_stream from jumpstarter.config.tls import TLSConfigV1Alpha1 @@ -43,17 +45,26 @@ async def _create(self): duration_str = f"{duration.seconds}s" logger.debug("Creating lease request for labels %s for %s", self.metadata_filter.labels, duration_str) - self.name = ( - await self.controller.RequestLease( - jumpstarter_pb2.RequestLeaseRequest( - duration=duration, - selector=kubernetes_pb2.LabelSelector(match_labels=self.metadata_filter.labels), + with translate_grpc_exceptions(): + self.name = ( + await self.controller.RequestLease( + jumpstarter_pb2.RequestLeaseRequest( + duration=duration, + selector=kubernetes_pb2.LabelSelector(match_labels=self.metadata_filter.labels), + ) ) - ) - ).name + ).name logger.info("Created lease request for labels %s for %s", self.metadata_filter.labels, duration_str) def request(self): + """Request a lease, or verifies a lease which was already created. + + :return: lease + :rtype: Lease + :raises LeaseError: if lease is unsatisfiable + :raises LeaseError: if lease is not pending + :raises TimeoutError: if lease is not ready after timeout + """ return self.portal.call(self.request_async) async def request_async(self): @@ -61,8 +72,8 @@ async def request_async(self): :return: lease :rtype: Lease - :raises ValueError: if lease is unsatisfiable - :raises ValueError: if lease is not pending + :raises LeaseError: if lease is unsatisfiable + :raises LeaseError: if lease is not pending :raises TimeoutError: if lease is not ready after timeout """ if self.name: @@ -79,26 +90,28 @@ async def _acquire(self): with fail_after(300): # TODO: configurable timeout while True: logger.debug("Polling Lease %s", self.name) - result = await self.controller.GetLease(jumpstarter_pb2.GetLeaseRequest(name=self.name)) + with translate_grpc_exceptions(): + result = await self.controller.GetLease(jumpstarter_pb2.GetLeaseRequest(name=self.name)) # lease ready if condition_true(result.conditions, "Ready"): - logger.info("Lease %s acquired", self.name) + logger.debug("Lease %s acquired", self.name) return self # lease unsatisfiable if condition_true(result.conditions, "Unsatisfiable"): - logger.error("Lease %s cannot be satisfied: %s", self.name, + message = condition_message(result.conditions, "Unsatisfiable") + logger.debug("Lease %s cannot be satisfied: %s", self.name, condition_message(result.conditions, "Unsatisfiable")) - raise ValueError("lease unsatisfiable") + raise LeaseError(f"the lease cannot be satisfied: {message}") + # lease not pending if condition_false(result.conditions, "Pending"): - logger.Error("Lease %s is not in pending, but it isn't in Ready or Unsatisfiable state either", - self.name) - raise ValueError("lease not pending") + raise LeaseError( + f"Lease {self.name} is not in pending, but it isn't in Ready or Unsatisfiable state either") + # lease released if condition_present_and_equal(result.conditions, "Ready", "False", "Released"): - logger.error("The lease %s was released", self.name) - raise ValueError("lease released") + raise LeaseError(f"lease {self.name} released") await sleep(1) diff --git a/packages/jumpstarter/jumpstarter/common/exceptions.py b/packages/jumpstarter/jumpstarter/common/exceptions.py new file mode 100644 index 000000000..60b5326e5 --- /dev/null +++ b/packages/jumpstarter/jumpstarter/common/exceptions.py @@ -0,0 +1,45 @@ +import sys + + +class JumpstarterException(Exception): + """Base class for jumpstarter-specific errors. + + This class should not be raised directly, but should be used as a base + class for all jumpstarter-specific errors. + It handles the __cause__ attribute so the jumpstarter errors could be raised as + + raise SomeError("message") from original_exception + """ + def __init__(self, message:str): + super().__init__(message) + self.message = message + + def __str__(self): + if self.__cause__: + return f"{self.message} (Caused by: {self.__cause__})" + return f"{self.message}" + + def print(self, message:str|None = None): + ANSI_RED = "\033[91m" + ANSI_CLEAR = "\033[0m" + print(f"{ANSI_RED}{self}{ANSI_CLEAR}", file=sys.stderr) + +class ConnectionError(JumpstarterException): + """Raised when a connection to a jumpstarter server fails.""" + pass + +class ConfigurationError(JumpstarterException): + """Raised when a configuration error exists.""" + pass + +class ArgumentError(JumpstarterException): + """Raised when a cli argument is not valid.""" + pass + +class FileAccessError(JumpstarterException): + """Raised when a file access error occurs.""" + pass + +class FileNotFoundError(JumpstarterException): + """Raised when a file is not found.""" + pass diff --git a/packages/jumpstarter/jumpstarter/common/grpc.py b/packages/jumpstarter/jumpstarter/common/grpc.py index 376bb2fb1..9b4305dc7 100644 --- a/packages/jumpstarter/jumpstarter/common/grpc.py +++ b/packages/jumpstarter/jumpstarter/common/grpc.py @@ -1,19 +1,33 @@ import base64 import os +import socket import ssl +from contextlib import contextmanager from urllib.parse import urlparse import grpc +from jumpstarter.common.exceptions import ConfigurationError, ConnectionError + def ssl_channel_credentials(target: str, tls_config): + configure_grpc_env() if tls_config.insecure or os.getenv("JUMPSTARTER_GRPC_INSECURE") == "1": - parsed = urlparse(f"//{target}") - port = parsed.port if parsed.port else 443 - root_certificates = ssl.get_server_certificate((parsed.hostname, port)) - return grpc.ssl_channel_credentials(root_certificates=root_certificates.encode()) + try: + parsed = urlparse(f"//{target}") + port = parsed.port if parsed.port else 443 + except ValueError as e: + raise ConfigurationError(f"Failed parsing {target}") from e + + try: + root_certificates = ssl.get_server_certificate((parsed.hostname, port)) + return grpc.ssl_channel_credentials(root_certificates=root_certificates.encode()) + except socket.gaierror as e: + raise ConnectionError(f"Failed resolving {parsed.hostname}") from e + except ConnectionRefusedError as e: + raise ConnectionError(f"Failed connecting to {parsed.hostname}:{port}") from e + elif tls_config.ca != "": - # convert ca_certificate base64 encoded to pem encoded string ca_certificate = base64.b64decode(tls_config.ca) return grpc.ssl_channel_credentials(ca_certificate) else: @@ -22,3 +36,33 @@ def ssl_channel_credentials(target: str, tls_config): def aio_secure_channel(target: str, credentials: grpc.ChannelCredentials): return grpc.aio.secure_channel(target, credentials, options=(("grpc.lb_policy_name", "round_robin"),)) + +def configure_grpc_env(): + # disable informative logs by default, i.e.: + # WARNING: All log messages before absl::InitializeLog() is called are written to STDERR + # I0000 00:00:1739970744.889307 61962 ssl_transport_security.cc:1665] Handshake failed ... + if os.environ.get("GRPC_VERBOSITY") is None: + os.environ["GRPC_VERBOSITY"] = "ERROR" + if os.environ.get("GLOG_minloglevel") is None: + os.environ["GLOG_minloglevel"] = "2" + +@contextmanager +def translate_grpc_exceptions(): + """Translate grpc exceptions to JumpstarterExceptions.""" + try: + yield + except grpc.aio.AioRpcError as e: + if e.code().name == "UNAVAILABLE": + # tls or other connection errors + raise ConnectionError(f"grpc error: {e.details()}") from None + if e.code().name == "UNKNOWN": + # an error returned from our functions + raise ConnectionError(f"grpc controller responded: {e.details()}") from None + else: + raise ConnectionError("grpc error") from e + except grpc.RpcError as e: + raise ConnectionError("grpc error") from e + except ValueError as e: + raise ConfigurationError("grpc error") from e + except Exception as e: + raise e diff --git a/packages/jumpstarter/jumpstarter/config/client.py b/packages/jumpstarter/jumpstarter/config/client.py index 3df400701..43c73201e 100644 --- a/packages/jumpstarter/jumpstarter/config/client.py +++ b/packages/jumpstarter/jumpstarter/config/client.py @@ -14,7 +14,8 @@ from .grpc import call_credentials from .tls import TLSConfigV1Alpha1 from jumpstarter.common import MetadataFilter -from jumpstarter.common.grpc import aio_secure_channel, ssl_channel_credentials +from jumpstarter.common.exceptions import FileNotFoundError +from jumpstarter.common.grpc import aio_secure_channel, ssl_channel_credentials, translate_grpc_exceptions def _allow_from_env(): @@ -89,15 +90,18 @@ async def request_lease_async(self, metadata_filter: MetadataFilter, portal: Blo unsafe=self.drivers.unsafe, tls_config=self.tls, ) - return await lease.request_async() + with translate_grpc_exceptions(): + return await lease.request_async() async def list_leases_async(self): controller = jumpstarter_pb2_grpc.ControllerServiceStub(await self.channel()) - return (await controller.ListLeases(jumpstarter_pb2.ListLeasesRequest())).names + with translate_grpc_exceptions(): + return (await controller.ListLeases(jumpstarter_pb2.ListLeasesRequest())).names async def release_lease_async(self, name): controller = jumpstarter_pb2_grpc.ControllerServiceStub(await self.channel()) - await controller.ReleaseLease(jumpstarter_pb2.ReleaseLeaseRequest(name=name)) + with translate_grpc_exceptions(): + await controller.ReleaseLease(jumpstarter_pb2.ReleaseLeaseRequest(name=name)) @asynccontextmanager async def lease_async(self, metadata_filter: MetadataFilter, lease_name: str | None, portal: BlockingPortal): diff --git a/packages/jumpstarter/jumpstarter/config/client_config_test.py b/packages/jumpstarter/jumpstarter/config/client_config_test.py index 37433f22f..353c3b07f 100644 --- a/packages/jumpstarter/jumpstarter/config/client_config_test.py +++ b/packages/jumpstarter/jumpstarter/config/client_config_test.py @@ -7,6 +7,7 @@ import yaml from pydantic import ValidationError +from jumpstarter.common.exceptions import FileNotFoundError from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta from jumpstarter.config.env import JMP_DRIVERS_ALLOW, JMP_ENDPOINT, JMP_NAME, JMP_NAMESPACE, JMP_TOKEN