diff --git a/src/bentoml/_internal/cloud/client.py b/src/bentoml/_internal/cloud/client.py index ec04e7e2e99..9f0dde6204f 100644 --- a/src/bentoml/_internal/cloud/client.py +++ b/src/bentoml/_internal/cloud/client.py @@ -22,6 +22,7 @@ from .schemas.schemasv1 import CreateDeploymentSchema as CreateDeploymentSchemaV1 from .schemas.schemasv1 import CreateModelRepositorySchema from .schemas.schemasv1 import CreateModelSchema +from .schemas.schemasv1 import CreateSecretSchema from .schemas.schemasv1 import DeploymentFullSchema from .schemas.schemasv1 import DeploymentListSchema from .schemas.schemasv1 import FinishUploadBentoSchema @@ -32,8 +33,11 @@ from .schemas.schemasv1 import OrganizationSchema from .schemas.schemasv1 import PreSignMultipartUploadUrlSchema from .schemas.schemasv1 import ResourceInstanceSchema +from .schemas.schemasv1 import SecretListSchema +from .schemas.schemasv1 import SecretSchema from .schemas.schemasv1 import UpdateBentoSchema from .schemas.schemasv1 import UpdateDeploymentSchema +from .schemas.schemasv1 import UpdateSecretSchema from .schemas.schemasv1 import UserSchema from .schemas.schemasv2 import CreateDeploymentSchema as CreateDeploymentSchemaV2 from .schemas.schemasv2 import DeploymentFullSchema as DeploymentFullSchemaV2 @@ -561,6 +565,47 @@ def get_latest_model( models = resp.json()["items"] return schema_from_object(models[0], ModelSchema) if models else None + def list_secrets( + self, + count: int | None = None, + q: str | None = None, + search: str | None = None, + start: int | None = None, + ) -> SecretListSchema: + url = urljoin(self.endpoint, "/api/v1/org_secrets") + if not count: + count = 10 + if not start: + start = 0 + resp = self.session.get( + url, + params={ + "count": count, + "q": q, + "search": search, + "start": start, + }, + ) + self._check_resp(resp) + return schema_from_json(resp.text, SecretListSchema) + + def create_secret(self, secret: CreateSecretSchema) -> SecretSchema: + url = urljoin(self.endpoint, "/api/v1/org_secrets") + resp = self.session.post(url, content=schema_to_json(secret)) + self._check_resp(resp) + return schema_from_json(resp.text, SecretSchema) + + def delete_secret(self, name: str): + url = urljoin(self.endpoint, f"/api/v1/org_secrets/{name}") + resp = self.session.delete(url) + self._check_resp(resp) + + def update_secret(self, name: str, secret: UpdateSecretSchema) -> SecretSchema: + url = urljoin(self.endpoint, f"/api/v1/org_secrets/{name}") + resp = self.session.patch(url, content=schema_to_json(secret)) + self._check_resp(resp) + return schema_from_json(resp.text, SecretSchema) + class RestApiClientV2(BaseRestApiClient): def create_deployment( diff --git a/src/bentoml/_internal/cloud/deployment.py b/src/bentoml/_internal/cloud/deployment.py index 028c0bf5099..db5192a8804 100644 --- a/src/bentoml/_internal/cloud/deployment.py +++ b/src/bentoml/_internal/cloud/deployment.py @@ -62,6 +62,7 @@ class DeploymentConfigParameters: instance_type: str | None = None strategy: str | None = None envs: t.List[dict[str, t.Any]] | None = None + secrets: t.List[str] | None = (None,) extras: dict[str, t.Any] | None = None config_dict: dict[str, t.Any] | None = None config_file: str | t.TextIO | None = None @@ -83,6 +84,7 @@ def verify( or self.instance_type or self.strategy or self.envs + or self.secrets or self.extras ) @@ -107,6 +109,7 @@ def verify( ("cluster", self.cluster), ("access_authorization", self.access_authorization), ("envs", self.envs), + ("secrets", self.secrets), ] if v is not None } diff --git a/src/bentoml/_internal/cloud/schemas/schemasv1.py b/src/bentoml/_internal/cloud/schemas/schemasv1.py index a2c96494863..8e87505d860 100644 --- a/src/bentoml/_internal/cloud/schemas/schemasv1.py +++ b/src/bentoml/_internal/cloud/schemas/schemasv1.py @@ -320,3 +320,50 @@ class DeploymentFullSchema(DeploymentSchema): __omit_if_default__ = True __forbid_extra_keys__ = True urls: list[str] + + +@attr.define +class SecretItem: + key: str + sub_path: t.Optional[str] = attr.field(default=None) + value: t.Optional[str] = attr.field(default=None) + + +@attr.define +class SecretContentSchema: + type: str + items: t.List[SecretItem] + path: t.Optional[str] = attr.field(default=None) + + +@attr.define +class SecretSchema(ResourceSchema): + __omit_if_default__ = True + __forbid_extra_keys__ = False + description: str + creator: UserSchema + content: SecretContentSchema + + +@attr.define +class SecretListSchema(BaseListSchema): + __omit_if_default__ = True + __forbid_extra_keys__ = False + items: t.List[SecretSchema] + + +@attr.define +class CreateSecretSchema: + __omit_if_default__ = True + __forbid_extra_keys__ = False + name: str + description: str + content: SecretContentSchema + + +@attr.define +class UpdateSecretSchema: + __omit_if_default__ = True + __forbid_extra_keys__ = False + description: str + content: SecretContentSchema diff --git a/src/bentoml/_internal/cloud/schemas/schemasv2.py b/src/bentoml/_internal/cloud/schemas/schemasv2.py index 4158153e7fd..41416c5b2f7 100644 --- a/src/bentoml/_internal/cloud/schemas/schemasv2.py +++ b/src/bentoml/_internal/cloud/schemas/schemasv2.py @@ -46,6 +46,7 @@ class DeploymentConfigSchema: __forbid_extra_keys__ = False access_authorization: bool = attr.field(default=False) envs: t.Optional[t.List[EnvItemSchema]] = attr.field(default=None) + secrets: t.Optional[t.List[str]] = attr.field(default=None) services: t.Dict[str, DeploymentServiceConfig] = attr.field(factory=dict) diff --git a/src/bentoml/_internal/cloud/secret.py b/src/bentoml/_internal/cloud/secret.py new file mode 100644 index 00000000000..23d045fdb87 --- /dev/null +++ b/src/bentoml/_internal/cloud/secret.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import typing as t + +import attr +import yaml + +from ..utils import bentoml_cattr +from .config import get_rest_api_client +from .schemas.schemasv1 import CreateSecretSchema +from .schemas.schemasv1 import SecretContentSchema +from .schemas.schemasv1 import SecretItem +from .schemas.schemasv1 import SecretSchema +from .schemas.schemasv1 import UpdateSecretSchema + + +@attr.define +class SecretInfo(SecretSchema): + created_by: str + + def to_dict(self) -> dict[str, t.Any]: + return bentoml_cattr.unstructure(self) + + def to_yaml(self): + dt = self.to_dict() + return yaml.dump(dt, sort_keys=False) + + @classmethod + def from_secret_schema(cls, secret_schema: SecretSchema) -> SecretInfo: + return cls( + name=secret_schema.name, + uid=secret_schema.uid, + resource_type=secret_schema.resource_type, + labels=secret_schema.labels, + created_at=secret_schema.created_at, + updated_at=secret_schema.updated_at, + deleted_at=secret_schema.deleted_at, + created_by=secret_schema.creator.name, + description=secret_schema.description, + creator=secret_schema.creator, + content=secret_schema.content, + ) + + +@attr.define +class Secret: + @classmethod + def list( + cls, + context: str | None = None, + search: str | None = None, + ) -> t.List[SecretInfo]: + cloud_rest_client = get_rest_api_client(context) + secrets = cloud_rest_client.v1.list_secrets(search=search) + return [SecretInfo.from_secret_schema(secret) for secret in secrets.items] + + @classmethod + def create( + cls, + context: str | None = None, + name: str | None = None, + description: str | None = None, + type: str | None = None, + path: str | None = None, + key_vals: t.List[t.Tuple[str, str]] = [], + ) -> SecretInfo: + secret_schema = CreateSecretSchema( + name=name, + description=description, + content=SecretContentSchema( + type=type, + path=path, + items=[ + SecretItem(key=key_val[0], value=key_val[1]) for key_val in key_vals + ], + ), + ) + cloud_rest_client = get_rest_api_client(context) + secret = cloud_rest_client.v1.create_secret(secret_schema) + return SecretInfo.from_secret_schema(secret) + + @classmethod + def delete( + cls, + context: str | None = None, + name: str | None = None, + ): + if name is None: + raise ValueError("name is required") + cloud_rest_client = get_rest_api_client(context) + cloud_rest_client.v1.delete_secret(name) + + @classmethod + def update( + cls, + context: str | None = None, + name: str | None = None, + description: str | None = None, + type: str | None = None, + path: str | None = None, + key_vals: t.List[t.Tuple[str, str]] = [], + ) -> SecretInfo: + secret_schema = UpdateSecretSchema( + description=description, + content=SecretContentSchema( + type=type, + path=path, + items=[ + SecretItem(key=key_val[0], value=key_val[1]) for key_val in key_vals + ], + ), + ) + cloud_rest_client = get_rest_api_client(context) + secret = cloud_rest_client.v1.update_secret(name, secret_schema) + return SecretInfo.from_secret_schema(secret) diff --git a/src/bentoml_cli/cli.py b/src/bentoml_cli/cli.py index 2770624278f..8e34ca3495c 100644 --- a/src/bentoml_cli/cli.py +++ b/src/bentoml_cli/cli.py @@ -14,6 +14,7 @@ def create_bentoml_cli() -> click.Command: from bentoml_cli.deployment import deployment_command from bentoml_cli.env import env_command from bentoml_cli.models import model_command + from bentoml_cli.secret import secret_command from bentoml_cli.serve import serve_command from bentoml_cli.start import start_command from bentoml_cli.utils import BentoMLCommandGroup @@ -45,6 +46,7 @@ def bentoml_cli(): bentoml_cli.add_command(containerize_command) bentoml_cli.add_command(deploy_command) bentoml_cli.add_command(deployment_command) + bentoml_cli.add_command(secret_command) if psutil.WINDOWS: import sys diff --git a/src/bentoml_cli/deployment.py b/src/bentoml_cli/deployment.py index 8bbf02aa306..ee7e830ce2c 100644 --- a/src/bentoml_cli/deployment.py +++ b/src/bentoml_cli/deployment.py @@ -88,6 +88,12 @@ def raise_deployment_config_error(err: BentoMLException, action: str) -> t.NoRet help="List of environment variables pass by --env key=value, --env ...", multiple=True, ) +@click.option( + "--secret", + type=click.STRING, + help="List of secret names pass by --secret name1, --secret name2, ...", + multiple=True, +) @click.option( "-f", "--config-file", @@ -126,6 +132,7 @@ def deploy_command( instance_type: str | None, strategy: str | None, env: tuple[str] | None, + secret: tuple[str] | None, config_file: str | t.TextIO | None, config_dict: str | None, wait: bool, @@ -147,6 +154,7 @@ def deploy_command( instance_type=instance_type, strategy=strategy, env=env, + secret=secret, config_file=config_file, config_dict=config_dict, wait=wait, @@ -474,6 +482,12 @@ def apply( # type: ignore help="List of environment variables pass by --env key=value, --env ...", multiple=True, ) +@click.option( + "--secret", + type=click.STRING, + help="List of secret names pass by --secret name1, --secret name2, ...", + multiple=True, +) @click.option( "-f", "--config-file", @@ -512,6 +526,7 @@ def create( instance_type: str | None, strategy: str | None, env: tuple[str] | None, + secret: tuple[str] | None, config_file: str | t.TextIO | None, config_dict: str | None, wait: bool, @@ -533,6 +548,7 @@ def create( instance_type=instance_type, strategy=strategy, env=env, + secret=secret, config_file=config_file, config_dict=config_dict, wait=wait, @@ -706,6 +722,7 @@ def create_deployment( instance_type: str | None, strategy: str | None, env: tuple[str] | None, + secret: tuple[str] | None, config_file: str | t.TextIO | None, config_dict: str | None, wait: bool, @@ -729,6 +746,7 @@ def create_deployment( if env is not None else None ), + secrets=secret, config_file=config_file, config_dict=cfg_dict, ) diff --git a/src/bentoml_cli/secret.py b/src/bentoml_cli/secret.py new file mode 100644 index 00000000000..0be4ebabee0 --- /dev/null +++ b/src/bentoml_cli/secret.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import json +import os +import typing as t +from http import HTTPStatus + +import click +import yaml +from rich.syntax import Syntax +from rich.table import Table + +from bentoml._internal.cloud.secret import Secret +from bentoml._internal.utils import resolve_user_filepath +from bentoml._internal.utils import rich_console as console +from bentoml.exceptions import BentoMLException +from bentoml_cli.utils import BentoMLCommandGroup + +if t.TYPE_CHECKING: + from click import Context + from click import Parameter + + from .utils import SharedOptions + + +@click.group(name="secret", cls=BentoMLCommandGroup) +def secret_command(): + """Secret Subcommands Groups""" + + +@secret_command.command(name="list") +@click.option( + "--search", type=click.STRING, default=None, help="Search for list request." +) +@click.pass_obj +@click.option( + "-o", + "--output", + help="Display the output of this command.", + type=click.Choice(["json", "yaml", "table"]), + default="table", +) +def list( + shared_options: SharedOptions, + search: str | None, + output: t.Literal["json", "yaml", "table"], +): + """List all secrets on BentoCloud.""" + secrets = Secret.list(context=shared_options.cloud_context, search=search) + if output == "table": + table = Table(box=None, expand=True) + table.add_column("Secret", overflow="fold") + table.add_column("Created_At", overflow="fold") + table.add_column("Mount_As", overflow="fold") + table.add_column("Keys", overflow="fold") + table.add_column("Path", overflow="fold") + + for secret in secrets: + keys = [item.key for item in secret.content.items] + mountAs = secret.content.type + if mountAs == "env": + mountAs = "Environment Variable" + elif mountAs == "mountfile": + mountAs = "File" + table.add_row( + secret.name, + secret.created_at.strftime("%Y-%m-%d %H:%M:%S"), + mountAs, + ", ".join(keys), + secret.content.path if secret.content.path else "-", + ) + console.print(table) + elif output == "json": + res: t.List[dict[str, t.Any]] = [s.to_dict() for s in secrets] + info = json.dumps(res, indent=2, default=str) + console.print(info) + elif output == "yaml": + res: t.List[dict[str, t.Any]] = [s.to_dict() for s in secrets] + info = yaml.dump(res, indent=2, sort_keys=False) + console.print(Syntax(info, "yaml", background_color="default")) + + +def parse_kvs_argument_callback( + ctx: Context, + params: Parameter, + value: t.Any, # pylint: disable=unused-argument +) -> t.List[t.Tuple[str, str]]: + """ + split "key1=value1 key2=value2" into [("key1", "value1"), ("key2", "value2")], + """ + key_vals: t.List[t.Tuple[str, str]] = [] + for key_val in value: + key, val = key_val.split("=") + if not key or not val: + raise click.BadParameter(f"Invalid key-value pair: {key_val}") + key_vals.append((key, val)) + return key_vals + + +def parse_from_literal_argument_callback( + ctx: Context, + params: Parameter, + value: t.Any, # pylint: disable=unused-argument +) -> t.List[t.Tuple[str, str]]: + """ + split "key1=value1 key2=value2" into [("key1", "value1"), ("key2", "value2")], + """ + from_literal: t.List[t.Tuple[str, str]] = [] + for key_val in value: + key, val = key_val.split("=") + if not key or not val: + raise click.BadParameter(f"Invalid key-value pair: {key_val}") + from_literal.append((key, val)) + return from_literal + + +def parse_from_file_argument_callback( + ctx: Context, + params: Parameter, + value: t.Any, # pylint: disable=unused-argument +) -> t.List[t.Tuple[str, str]]: + """ + split "key1=value1 key2=value2" into [("key1", "value1"), ("key2", "value2")], + """ + from_file: t.List[t.Tuple[str, str]] = [] + for key_path in value: + key, path = key_path.split("=") + path = resolve_user_filepath(path, ctx=None) + if not key or not path: + raise click.BadParameter(f"Invalid key-path pair: {key_path}") + if not os.path.exists(path) or not os.path.isfile(path): + raise click.BadParameter(f"Invalid path: {path}") + # read the file content + with open(path, "r") as f: + val = f.read() + from_file.append((key, val)) + return from_file + + +def raise_secret_error(err: BentoMLException, action: str) -> t.NoReturn: + if err.error_code == HTTPStatus.UNAUTHORIZED: + raise BentoMLException( + f"{err}\n* BentoCloud sign up: https://cloud.bentoml.com/\n" + "* Login with your API token: " + "https://docs.bentoml.com/en/latest/bentocloud/how-tos/manage-access-token.html" + ) + raise BentoMLException(f"Failed to {action} secret due to: {err}") + + +def map_choice_to_type(ctx: Context, params: Parameter, value: t.Any): + mappings = {"env": "env", "file": "mountfile"} + return mappings[value] + + +@secret_command.command(name="create") +@click.pass_obj +@click.argument( + "name", + nargs=1, + type=click.STRING, + required=True, +) +@click.argument( + "key_vals", + nargs=-1, + type=click.STRING, + callback=parse_kvs_argument_callback, +) +@click.option( + "-d", + "--description", + type=click.STRING, + help="Secret description", +) +@click.option( + "-t", + "--type", + type=click.Choice(["env", "file"]), + help="Mount as Environment Variable or File", + default="env", + callback=map_choice_to_type, +) +@click.option( + "-p", + "--path", + type=click.STRING, + help="Path where the secret will be mounted in the container. The path must be under the ($BENTOML_HOME) directory.", +) +@click.option( + "-l", + "--from-literal", + type=click.STRING, + help="Pass key value pairs by --from-literal key1=value1 key2=value2", + callback=parse_from_literal_argument_callback, + multiple=True, +) +@click.option( + "-f", + "--from-file", + type=click.STRING, + help="Pass key value pairs by --from-file key1=./path_to_file1 key2=./path_to_file2", + callback=parse_from_file_argument_callback, + multiple=True, +) +def create( + shared_options: SharedOptions, + name: str, + description: str | None, + type: t.Literal["env", "mountfile"], + path: str | None, + key_vals: t.List[t.Tuple[str, str]], + from_literal: t.List[t.Tuple[str, str]], + from_file: t.List[t.Tuple[str, str]], +): + """Create a secret on BentoCloud.""" + try: + if from_literal and from_file: + raise BentoMLException( + "options --from-literal and --from-file can not be used together" + ) + + key_vals.extend(from_literal) + key_vals.extend(from_file) + + if not key_vals: + raise BentoMLException( + "no key-value pairs provided, please use --from-literal or --from-file or provide key-value pairs" + ) + + if type == "mountfile" and not path: + path = "$BENTOML_HOME" + secret = Secret.create( + context=shared_options.cloud_context, + name=name, + description=description, + type=type, + path=path, + key_vals=key_vals, + ) + click.echo(f"Secret {secret.name} created successfully") + except Exception as e: + raise_secret_error(e, "create") + + +@secret_command.command(name="delete") +@click.pass_obj +@click.argument( + "name", + nargs=1, + type=click.STRING, + required=True, +) +def delete(shared_options: SharedOptions, name: str): + """Delete a secret on BentoCloud.""" + try: + Secret.delete(context=shared_options.cloud_context, name=name) + click.echo(f"Secret {name} deleted successfully") + except Exception as e: + raise_secret_error(e, "delete") + + +@secret_command.command(name="apply") +@click.pass_obj +@click.argument( + "name", + nargs=1, + type=click.STRING, + required=True, +) +@click.argument( + "key_vals", + nargs=-1, + type=click.STRING, + callback=parse_kvs_argument_callback, +) +@click.option( + "-d", + "--description", + type=click.STRING, + help="Secret description", +) +@click.option( + "-t", + "--type", + type=click.Choice(["env", "file"]), + help="Mount as Environment Variable or File", + default="env", + callback=map_choice_to_type, +) +@click.option( + "-p", + "--path", + type=click.STRING, + help="Path where the secret will be mounted in the container. The path must be under the ($BENTOML_HOME) directory.", +) +@click.option( + "-l", + "--from-literal", + type=click.STRING, + help="Pass key value pairs by --from-literal key1=value1 key2=value2", + callback=parse_from_literal_argument_callback, + multiple=True, +) +@click.option( + "-f", + "--from-file", + type=click.STRING, + help="Pass key value pairs by --from-file key1=./path_to_file1 key2=./path_to_file2", + callback=parse_from_file_argument_callback, + multiple=True, +) +def apply( + shared_options: SharedOptions, + name: str, + description: str | None, + type: t.Literal["env", "mountfile"], + path: str | None, + key_vals: t.List[t.Tuple[str, str]], + from_literal: t.List[t.Tuple[str, str]], + from_file: t.List[t.Tuple[str, str]], +): + """Apply a secret update on BentoCloud.""" + try: + if from_literal and from_file: + raise BentoMLException( + "options --from-literal and --from-file can not be used together" + ) + + key_vals.extend(from_literal) + key_vals.extend(from_file) + + if not key_vals: + raise BentoMLException( + "no key-value pairs provided, please use --from-literal or --from-file or provide key-value pairs" + ) + + if type == "mountfile" and not path: + path = "$BENTOML_HOME" + secret = Secret.update( + context=shared_options.cloud_context, + name=name, + description=description, + type=type, + path=path, + key_vals=key_vals, + ) + click.echo(f"Secret {secret.name} applied successfully") + except Exception as e: + raise_secret_error(e, "apply")