diff --git a/src/bentoml/__init__.py b/src/bentoml/__init__.py index 4255877337b..8485040e0e8 100644 --- a/src/bentoml/__init__.py +++ b/src/bentoml/__init__.py @@ -28,6 +28,8 @@ "use_arguments": "._internal.utils.args:use_arguments", "Bento": "._internal.bento:Bento", "BentoCloudClient": "._internal.cloud:BentoCloudClient", + "ApiToken": "._internal.cloud.api_token:ApiToken", + "ApiTokenAPI": "._internal.cloud.api_token:ApiTokenAPI", "Context": "._internal.context:ServiceContext", "server_context": "._internal.context:server_context", "Model": "._internal.models:Model", @@ -82,6 +84,8 @@ # BentoML built-in types from ._internal.bento import Bento from ._internal.cloud import BentoCloudClient + from ._internal.cloud.api_token import ApiToken + from ._internal.cloud.api_token import ApiTokenAPI from ._internal.configuration import load_config from ._internal.configuration import save_config from ._internal.configuration import set_serialization_strategy @@ -139,6 +143,7 @@ from . import monitoring # Monitoring API from . import cloud # Cloud API from . import deployment # deployment API + from . import api_token # API token management from . import validators # validators # isort: on @@ -290,6 +295,7 @@ monitoring = _LazyLoader("bentoml.monitoring", globals(), "bentoml.monitoring") cloud = _LazyLoader("bentoml.cloud", globals(), "bentoml.cloud") deployment = _LazyLoader("bentoml.deployment", globals(), "bentoml.deployment") + api_token = _LazyLoader("bentoml.api_token", globals(), "bentoml.api_token") validators = _LazyLoader("bentoml.validators", globals(), "bentoml.validators") del _LazyLoader, FrameworkImporter @@ -332,6 +338,8 @@ def __getattr__(name: str) -> Any: "Model", "monitoring", "BentoCloudClient", # BentoCloud REST API Client + "ApiToken", + "ApiTokenAPI", # bento APIs "list", "get", @@ -371,6 +379,7 @@ def __getattr__(name: str) -> Any: "gradio", "cloud", "deployment", + "api_token", "triton", "monitor", "load_config", diff --git a/src/bentoml/_internal/cloud/__init__.py b/src/bentoml/_internal/cloud/__init__.py index 83e90909c95..048c23684b2 100644 --- a/src/bentoml/_internal/cloud/__init__.py +++ b/src/bentoml/_internal/cloud/__init__.py @@ -4,6 +4,7 @@ from bentoml._internal.cloud.client import RestApiClient +from .api_token import ApiTokenAPI from .base import Spinner from .bento import BentoAPI from .config import DEFAULT_ENDPOINT @@ -29,6 +30,7 @@ class BentoCloudClient: model: Model API deployment: Deployment API secret: Secret API + api_token: API Token API """ client: RestApiClient @@ -36,6 +38,7 @@ class BentoCloudClient: model: ModelAPI deployment: DeploymentAPI secret: SecretAPI + api_token: ApiTokenAPI def __init__( self, @@ -57,6 +60,7 @@ def __init__( model = ModelAPI(client, spinner=spinner) deployment = DeploymentAPI(client) secret = SecretAPI(client) + api_token = ApiTokenAPI(client) self.__attrs_init__( client=client, @@ -64,6 +68,7 @@ def __init__( model=model, deployment=deployment, secret=secret, + api_token=api_token, ) @classmethod diff --git a/src/bentoml/_internal/cloud/api_token.py b/src/bentoml/_internal/cloud/api_token.py new file mode 100644 index 00000000000..b10623cd7e6 --- /dev/null +++ b/src/bentoml/_internal/cloud/api_token.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import typing as t +from datetime import datetime + +import attr +import yaml + +from ..utils.cattr import bentoml_cattr +from .schemas.schemasv1 import ApiTokenSchema +from .schemas.schemasv1 import CreateApiTokenSchema + +if t.TYPE_CHECKING: + from .client import RestApiClient + + +@attr.define(kw_only=True) +class ApiToken(ApiTokenSchema): + created_by: str = attr.field(default="") + + def to_dict(self) -> dict[str, t.Any]: + return bentoml_cattr.unstructure(self) + + def to_yaml(self) -> str: + dt = self.to_dict() + return yaml.dump(dt, sort_keys=False) + + @classmethod + def from_schema(cls, schema: ApiTokenSchema) -> ApiToken: + return cls( + name=schema.name, + uid=schema.uid, + resource_type=schema.resource_type, + labels=schema.labels, + created_at=schema.created_at, + updated_at=schema.updated_at, + deleted_at=schema.deleted_at, + description=schema.description, + scopes=schema.scopes, + user=schema.user, + organization=schema.organization, + expired_at=schema.expired_at, + last_used_at=schema.last_used_at, + is_expired=schema.is_expired, + is_api_token=schema.is_api_token, + is_organization_token=schema.is_organization_token, + is_global_access=schema.is_global_access, + token=schema.token, + created_by=schema.user.name, + ) + + +@attr.define +class ApiTokenAPI: + _client: RestApiClient + + def list(self, search: str | None = None) -> t.List[ApiToken]: + """ + List all API tokens. + + Args: + search (str | None): Optional search string to filter tokens. + + Returns: + List[ApiToken]: A list of ApiToken objects. + """ + tokens = self._client.v1.list_api_tokens(search=search) + return [ApiToken.from_schema(token) for token in tokens.items] + + def create( + self, + name: str, + description: str | None = None, + scopes: t.List[str] | None = None, + expired_at: datetime | None = None, + ) -> ApiToken: + """ + Create a new API token. + + Args: + name (str): Name of the token. + description (str | None): Description of the token. + scopes (List[str] | None): List of scopes for the token. + expired_at (datetime | None): Expiration datetime for the token. + + Returns: + ApiToken: The created API token (includes the token value). + """ + schema = CreateApiTokenSchema( + name=name, + description=description, + scopes=scopes, + expired_at=expired_at, + ) + token = self._client.v1.create_api_token(schema) + return ApiToken.from_schema(token) + + def get(self, token_uid: str) -> ApiToken | None: + """ + Get an API token by UID. + + Args: + token_uid (str): The UID of the token to get. + + Returns: + ApiToken | None: The API token if found, None otherwise. + """ + token = self._client.v1.get_api_token(token_uid) + if token is None: + return None + return ApiToken.from_schema(token) + + def delete(self, token_uid: str) -> None: + """ + Delete an API token. + + Args: + token_uid (str): The UID of the token to delete. + """ + self._client.v1.delete_api_token(token_uid) diff --git a/src/bentoml/_internal/cloud/client.py b/src/bentoml/_internal/cloud/client.py index 52aee5ed452..77dad1136af 100644 --- a/src/bentoml/_internal/cloud/client.py +++ b/src/bentoml/_internal/cloud/client.py @@ -16,6 +16,8 @@ from ...exceptions import CloudRESTApiClientError from ...exceptions import NotFound from ..configuration import BENTOML_VERSION +from .schemas.schemasv1 import ApiTokenListSchema +from .schemas.schemasv1 import ApiTokenSchema from .schemas.schemasv1 import BentoListSchema from .schemas.schemasv1 import BentoRepositorySchema from .schemas.schemasv1 import BentoSchema @@ -23,6 +25,7 @@ from .schemas.schemasv1 import ClusterFullSchema from .schemas.schemasv1 import ClusterListSchema from .schemas.schemasv1 import CompleteMultipartUploadSchema +from .schemas.schemasv1 import CreateApiTokenSchema from .schemas.schemasv1 import CreateBentoRepositorySchema from .schemas.schemasv1 import CreateBentoSchema from .schemas.schemasv1 import CreateDeploymentSchema as CreateDeploymentSchemaV1 @@ -564,6 +567,39 @@ def update_secret( self._check_resp(resp) return schema_from_object(resp.json(), SecretSchema) + def list_api_tokens( + self, + count: int = 100, + search: str | None = None, + start: int = 0, + ) -> ApiTokenListSchema: + url = "/api/v1/api_tokens" + params: dict[str, t.Any] = {"start": start, "count": count} + if search: + params["search"] = search + resp = self.session.get(url, params=params) + self._check_resp(resp) + return schema_from_object(resp.json(), ApiTokenListSchema) + + def create_api_token(self, req: CreateApiTokenSchema) -> ApiTokenSchema: + url = "/api/v1/api_tokens" + resp = self.session.post(url, json=schema_to_object(req)) + self._check_resp(resp) + return schema_from_object(resp.json(), ApiTokenSchema) + + def get_api_token(self, token_uid: str) -> ApiTokenSchema | None: + url = f"/api/v1/api_tokens/{token_uid}" + resp = self.session.get(url) + if self._is_not_found(resp): + return None + self._check_resp(resp) + return schema_from_object(resp.json(), ApiTokenSchema) + + def delete_api_token(self, token_uid: str) -> None: + url = f"/api/v1/api_tokens/{token_uid}" + resp = self.session.delete(url) + self._check_resp(resp) + class RestApiClientV2(BaseRestApiClient): def create_deployment( diff --git a/src/bentoml/_internal/cloud/schemas/schemasv1.py b/src/bentoml/_internal/cloud/schemas/schemasv1.py index 21f4619c564..4bba463682b 100644 --- a/src/bentoml/_internal/cloud/schemas/schemasv1.py +++ b/src/bentoml/_internal/cloud/schemas/schemasv1.py @@ -364,3 +364,37 @@ class UpdateSecretSchema: __forbid_extra_keys__ = False content: SecretContentSchema description: t.Optional[str] = attr.field(default=None) + + +@attr.define +class ApiTokenSchema(ResourceSchema): + __omit_if_default__ = True + __forbid_extra_keys__ = False + description: str + scopes: t.List[str] + user: UserSchema + organization: OrganizationSchema + expired_at: t.Optional[datetime] = attr.field(default=None) + last_used_at: t.Optional[datetime] = attr.field(default=None) + is_expired: bool = attr.field(default=False) + is_api_token: bool = attr.field(default=True) + is_organization_token: bool = attr.field(default=False) + is_global_access: bool = attr.field(default=False) + token: t.Optional[str] = attr.field(default=None) # Only returned on create + + +@attr.define +class ApiTokenListSchema(BaseListSchema): + __omit_if_default__ = True + __forbid_extra_keys__ = False + items: t.List[ApiTokenSchema] + + +@attr.define +class CreateApiTokenSchema: + __omit_if_default__ = True + __forbid_extra_keys__ = False + name: str + description: t.Optional[str] = attr.field(default=None) + scopes: t.Optional[t.List[str]] = attr.field(default=None) + expired_at: t.Optional[datetime] = attr.field(default=None) diff --git a/src/bentoml/api_token.py b/src/bentoml/api_token.py new file mode 100644 index 00000000000..ce7bdb1313c --- /dev/null +++ b/src/bentoml/api_token.py @@ -0,0 +1,124 @@ +""" +User facing python APIs for API token management +""" + +from __future__ import annotations + +import typing as t +from datetime import datetime + +from simple_di import Provide +from simple_di import inject + +from ._internal.cloud.api_token import ApiToken +from ._internal.configuration.containers import BentoMLContainer + +if t.TYPE_CHECKING: + from ._internal.cloud import BentoCloudClient + + +@inject +def list( + search: str | None = None, + _cloud_client: "BentoCloudClient" = Provide[BentoMLContainer.bentocloud_client], +) -> t.List[ApiToken]: + """List all API tokens. + + Args: + search: Optional search string to filter tokens by name + + Returns: + List of ApiToken objects + + Example: + >>> import bentoml + >>> tokens = bentoml.api_token.list(search="my-token") + >>> for token in tokens: + ... print(f"{token.name}: {token.uid}") + """ + return _cloud_client.api_token.list(search=search) + + +@inject +def create( + name: str, + description: str | None = None, + scopes: t.List[str] | None = None, + expired_at: datetime | None = None, + _cloud_client: "BentoCloudClient" = Provide[BentoMLContainer.bentocloud_client], +) -> ApiToken: + """Create a new API token. + + Args: + name: Name of the token + description: Optional description + scopes: List of scopes. Available scopes: + - api: General API access + - read_organization: Read organization data + - write_organization: Write organization data + - read_cluster: Read cluster data + - write_cluster: Write cluster data + expired_at: Optional expiration datetime + + Returns: + ApiToken object (includes token value - save it, it won't be shown again!) + + Example: + >>> import bentoml + >>> from datetime import datetime, timedelta + >>> token = bentoml.api_token.create( + ... name="ci-token", + ... description="CI/CD pipeline token", + ... scopes=["api", "read_cluster"], + ... expired_at=datetime.now() + timedelta(days=30) + ... ) + >>> print(f"Token: {token.token}") # Save this! + """ + return _cloud_client.api_token.create( + name=name, + description=description, + scopes=scopes, + expired_at=expired_at, + ) + + +@inject +def get( + token_uid: str, + _cloud_client: "BentoCloudClient" = Provide[BentoMLContainer.bentocloud_client], +) -> ApiToken | None: + """Get an API token by UID. + + Args: + token_uid: The UID of the token + + Returns: + ApiToken object or None if not found + + Example: + >>> import bentoml + >>> token = bentoml.api_token.get("token_abc123") + >>> if token: + ... print(f"Scopes: {token.scopes}") + """ + return _cloud_client.api_token.get(token_uid=token_uid) + + +@inject +def delete( + token_uid: str, + _cloud_client: "BentoCloudClient" = Provide[BentoMLContainer.bentocloud_client], +) -> None: + """Delete an API token. + + Args: + token_uid: The UID of the token to delete + + Example: + >>> import bentoml + >>> bentoml.api_token.delete("token_abc123") + """ + _cloud_client.api_token.delete(token_uid=token_uid) + + +__all__ = ["create", "get", "list", "delete"] diff --git a/src/bentoml_cli/api_token.py b/src/bentoml_cli/api_token.py new file mode 100644 index 00000000000..893c8af94ff --- /dev/null +++ b/src/bentoml_cli/api_token.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import typing as t + +import click +import rich + +import bentoml.api_token +from bentoml.exceptions import BentoMLException +from bentoml_cli.utils import BentoMLCommandGroup + + +@click.group(name="api-token", cls=BentoMLCommandGroup) +def api_token_command(): + """API Token management commands.""" + + +@api_token_command.command(name="list") +@click.option( + "--search", type=click.STRING, default=None, help="Search for tokens by name." +) +@click.option( + "-o", + "--output", + help="Display the output of this command.", + type=click.Choice(["json", "yaml", "table"]), + default="table", +) +def list_api_tokens( + search: str | None, + output: str, +) -> None: + """List all API tokens on BentoCloud.""" + import json as json_mod + + import yaml + from rich.syntax import Syntax + from rich.table import Table + + try: + tokens = bentoml.api_token.list(search=search) + except BentoMLException as e: + _raise_api_token_error(e, "list") + + if output == "table": + table = Table(box=None, expand=True) + table.add_column("Name", overflow="fold") + table.add_column("UID", overflow="fold") + table.add_column("Created_At", overflow="fold") + table.add_column("Expired_At", overflow="fold") + table.add_column("Last_Used_At", overflow="fold") + table.add_column("Scopes", overflow="fold") + + for token in tokens: + expired_at = ( + token.expired_at.strftime("%Y-%m-%d %H:%M:%S") + if token.expired_at + else "Never" + ) + last_used_at = ( + token.last_used_at.strftime("%Y-%m-%d %H:%M:%S") + if token.last_used_at + else "Never" + ) + table.add_row( + token.name, + token.uid, + token.created_at.strftime("%Y-%m-%d %H:%M:%S"), + expired_at, + last_used_at, + ", ".join(token.scopes) if token.scopes else "-", + ) + rich.print(table) + elif output == "json": + res: t.List[dict[str, t.Any]] = [t.to_dict() for t in tokens] + info = json_mod.dumps(res, indent=2, default=str) + rich.print(info) + elif output == "yaml": + res: t.List[dict[str, t.Any]] = [t.to_dict() for t in tokens] + info = yaml.dump(res, indent=2, sort_keys=False) + rich.print(Syntax(info, "yaml", background_color="default")) + + +def _parse_expiration(expires_str: str | None) -> "t.Any": + """Parse expiration string into datetime.""" + from datetime import datetime + from datetime import timedelta + + if not expires_str: + return None + + expires_str = expires_str.strip() + + # Try parsing duration format (e.g., 30d, 1w, 24h) + if expires_str[-1].lower() in ("d", "w", "h"): + try: + value = int(expires_str[:-1]) + unit = expires_str[-1].lower() + if unit == "d": + return datetime.now() + timedelta(days=value) + elif unit == "w": + return datetime.now() + timedelta(weeks=value) + elif unit == "h": + return datetime.now() + timedelta(hours=value) + except ValueError: + pass + + # Try parsing ISO date format + for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(expires_str, fmt) + except ValueError: + continue + + raise click.BadParameter( + f"Invalid expiration format: {expires_str}. " + "Use duration (e.g., '30d', '1w', '24h') or date (e.g., '2024-12-31')." + ) + + +@api_token_command.command(name="create") +@click.argument( + "name", + nargs=1, + type=click.STRING, + required=True, +) +@click.option( + "-d", + "--description", + type=click.STRING, + help="Description for the API token.", +) +@click.option( + "--scope", + "-s", + type=click.STRING, + multiple=True, + help="Scopes for the token (can be specified multiple times). " + "Available scopes: api, read_organization, write_organization, read_cluster, write_cluster.", +) +@click.option( + "--expires", + type=click.STRING, + help="Expiration time (e.g., '30d' for 30 days, '1w' for 1 week, or ISO date '2024-12-31').", +) +@click.option( + "-o", + "--output", + help="Display the output of this command.", + type=click.Choice(["json", "yaml", "table"]), + default="table", +) +def create_api_token( + name: str, + description: str | None, + scope: tuple[str, ...], + expires: str | None, + output: str, +) -> None: + """Create a new API token on BentoCloud. + + \b + Available scopes: + - api: General API access + - read_organization: Read organization data + - write_organization: Write organization data + - read_cluster: Read cluster data + - write_cluster: Write cluster data + + \b + Examples: + bentoml api-token create my-token --scope api --scope read_cluster + bentoml api-token create my-token -s api -s write_organization --expires 30d + """ + import json as json_mod + + import yaml + from rich.panel import Panel + from rich.syntax import Syntax + + expired_at = _parse_expiration(expires) + scopes = list(scope) if scope else None + + try: + token = bentoml.api_token.create( + name=name, + description=description, + scopes=scopes, + expired_at=expired_at, + ) + except BentoMLException as e: + _raise_api_token_error(e, "create") + + # Display the token value prominently since it's only shown once + if token.token: + rich.print( + Panel( + f"[bold green]{token.token}[/bold green]", + title="[bold yellow]API Token (save this - it won't be shown again!)[/bold yellow]", + border_style="yellow", + ) + ) + + if output == "table": + rich.print(f"\nToken [green]{token.name}[/] created successfully") + rich.print(f" UID: {token.uid}") + if token.description: + rich.print(f" Description: {token.description}") + if token.expired_at: + rich.print(f" Expires: {token.expired_at.strftime('%Y-%m-%d %H:%M:%S')}") + else: + rich.print(" Expires: Never") + elif output == "json": + info = json_mod.dumps(token.to_dict(), indent=2, default=str) + rich.print(info) + elif output == "yaml": + info = yaml.dump(token.to_dict(), indent=2, sort_keys=False) + rich.print(Syntax(info, "yaml", background_color="default")) + + +@api_token_command.command(name="get") +@click.argument( + "token_uid", + nargs=1, + type=click.STRING, + required=True, +) +@click.option( + "-o", + "--output", + help="Display the output of this command.", + type=click.Choice(["json", "yaml", "table"]), + default="table", +) +def get_api_token(token_uid: str, output: str) -> None: + """Get an API token by UID from BentoCloud.""" + import json as json_mod + + import yaml + from rich.syntax import Syntax + from rich.table import Table + + try: + token = bentoml.api_token.get(token_uid=token_uid) + except BentoMLException as e: + _raise_api_token_error(e, "get") + + if token is None: + raise BentoMLException(f"API token with UID '{token_uid}' not found") + + if output == "table": + table = Table(box=None, expand=True) + table.add_column("Field", style="bold") + table.add_column("Value", overflow="fold") + + table.add_row("Name", token.name) + table.add_row("UID", token.uid) + table.add_row("Description", token.description or "-") + table.add_row("Created At", token.created_at.strftime("%Y-%m-%d %H:%M:%S")) + table.add_row( + "Expired At", + token.expired_at.strftime("%Y-%m-%d %H:%M:%S") + if token.expired_at + else "Never", + ) + table.add_row( + "Last Used At", + token.last_used_at.strftime("%Y-%m-%d %H:%M:%S") + if token.last_used_at + else "Never", + ) + table.add_row("Is Expired", str(token.is_expired)) + table.add_row("Scopes", ", ".join(token.scopes) if token.scopes else "-") + table.add_row("Created By", token.created_by or token.user.name) + rich.print(table) + elif output == "json": + info = json_mod.dumps(token.to_dict(), indent=2, default=str) + rich.print(info) + elif output == "yaml": + info = yaml.dump(token.to_dict(), indent=2, sort_keys=False) + rich.print(Syntax(info, "yaml", background_color="default")) + + +@api_token_command.command(name="delete") +@click.argument( + "token_uid", + nargs=1, + type=click.STRING, + required=True, +) +def delete_api_token(token_uid: str) -> None: + """Delete an API token on BentoCloud.""" + try: + bentoml.api_token.delete(token_uid=token_uid) + rich.print(f"API token [green]{token_uid}[/] deleted successfully") + except BentoMLException as e: + _raise_api_token_error(e, "delete") + + +def _raise_api_token_error(err: "BentoMLException", action: str) -> "t.NoReturn": + from http import HTTPStatus + + if err.error_code == HTTPStatus.UNAUTHORIZED: + raise BentoMLException( + f"{err}\n* BentoCloud API token is required for authorization. Run `bentoml cloud login` command to login" + ) from None + elif err.error_code == HTTPStatus.NOT_FOUND: + raise BentoMLException("API token not found") from None + raise BentoMLException(f"Failed to {action} API token: {err}") diff --git a/src/bentoml_cli/cli.py b/src/bentoml_cli/cli.py index a6e2f1de821..04045d67470 100644 --- a/src/bentoml_cli/cli.py +++ b/src/bentoml_cli/cli.py @@ -7,6 +7,7 @@ def create_bentoml_cli() -> click.Command: from bentoml._internal.configuration import BENTOML_VERSION from bentoml._internal.context import server_context + from bentoml_cli.api_token import api_token_command from bentoml_cli.bentos import bento_command from bentoml_cli.cloud import cloud_command from bentoml_cli.containerize import containerize_command @@ -48,6 +49,7 @@ def bentoml_cli(): bentoml_cli.add_command(codespace) bentoml_cli.add_command(deployment_command) bentoml_cli.add_command(secret_command) + bentoml_cli.add_command(api_token_command) # Load commands from extensions for ep in get_entry_points("bentoml.commands"): bentoml_cli.add_command(ep.load())