diff --git a/silverback/_cli.py b/silverback/_cli.py index 859f1695..60811b39 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -389,6 +389,50 @@ def bot(): """Commands for managing bots in a CLUSTER""" +@bot.command(name="new") +@click.option("-n", "--name", required=True) +@click.option("-i", "--image", required=True) +@click.option("-n", "--network", required=True) +@click.option("-a", "--account") +@click.option("-g", "--group", "groups", multiple=True) +def new_bot( + client: ClusterClient, + name: str, + image: str, + network: str, + account: str | None, + groups: list[str], +): + """Create a new bot in CLUSTER""" + + if name in client.bots: + raise click.UsageError(f"Cannot use name '{name}' to create bot") + + environment = list() + rendered_environment = dict() + for env_id in groups: + if "/" in env_id: + env_name, revision = env_id.split("/") + env = client.envs[env_name].revisions[int(revision)] + + else: + env = client.envs[env_id] + + environment.append(env) + + for var_name in env.variables: + rendered_environment[var_name] = f"{env.name}/{env.revision}" + + display = render_dict_as_yaml(rendered_environment, prepend="\n ") + click.echo(f"Environment:\n {display}") + + if not click.confirm("Do you want to create this bot?"): + return + + bot = client.new_bot(name, image, network, account=account, environment=environment) + click.secho(f"Bot ({bot.id}) deploying...", fg="green", bold=True) + + @bot.command(name="list") def list_bots(client: ClusterClient): """ @@ -406,3 +450,130 @@ def list_bots(client: ClusterClient): click.secho("No bots in this cluster", bold=True, fg="red") +@bot.command(name="status") +@click.argument("bot_name", metavar="BOT") +def show_bot_status(client: ClusterClient, bot_name: str): + """Show status of BOT in CLUSTER""" + + if not (bot := client.bots.get(bot_name)): + raise click.UsageError(f"Unknown bot '{bot_name}'.") + + click.echo(render_dict_as_yaml(bot)) + + +@bot.command(name="update") +@click.option("-n", "--name", "new_name") +@click.option("-i", "--image") +@click.option("-n", "--network") +@click.option("-a", "--account") +@click.option("-g", "--group", "groups", multiple=True) +@click.argument("bot_name", metavar="BOT") +def update_bot( + client: ClusterClient, + bot_name: str, + new_name: str, + image: str, + network: str, + account: str | None, + groups: list[str], +): + """Update configuration of BOT in CLUSTER""" + + if new_name in client.bots: + raise click.UsageError(f"Cannot use name '{new_name}' to update bot '{bot_name}'") + + if not (bot := client.bots.get(bot_name)): + raise click.UsageError(f"Unknown bot '{bot_name}'.") + + environment = list() + rendered_environment = dict() + for env_id in groups: + if "/" in env_id: + env_name, revision = env_id.split("/") + env = client.envs[env_name].revisions[int(revision)] + + else: + env = client.envs[env_id] + + environment.append(env) + + for var_name in env.variables: + rendered_environment[var_name] = f"{env.name}/{env.revision}" + + set_environment = True + + if len(environment) == 0: + set_environment = click.confirm("Do you want to clear all environment variables?") + + else: + display = render_dict_as_yaml(rendered_environment, prepend="\n ") + click.echo(f"Environment:\n {display}") + + if not click.confirm("Do you want to create this bot?"): + return + + bot.update( + name=new_name, + image=image, + network=network, + account=account, + environment=environment if set_environment else None, + ) + + +@bot.command(name="rm") +@click.argument("name", metavar="BOT") +def rm_bot(client: ClusterClient, name: str): + """Remove BOT from CLUSTER""" + + if not (bot := client.bots.get(name)): + raise click.UsageError(f"Unknown bot '{name}'.") + + bot.remove() + click.secho(f"Bot '{bot.name}' removed.", fg="green", bold=True) + + +@bot.command(name="start") +@click.argument("name", metavar="BOT") +def start_bot(client: ClusterClient, name: str): + """Start BOT running in CLUSTER (if stopped or terminated)""" + + if not (bot := client.bots.get(name)): + raise click.UsageError(f"Unknown bot '{name}'.") + + bot.start() + + +@bot.command(name="stop") +@click.argument("name", metavar="BOT") +def stop_bot(client: ClusterClient, name: str): + """Stop BOT running in CLUSTER (if running)""" + + if not (bot := client.bots.get(name)): + raise click.UsageError(f"Unknown bot '{name}'.") + + bot.stop() + + +@bot.command(name="logs") +@click.argument("name", metavar="BOT") +def show_bot_logs(client: ClusterClient, name: str): + """Show runtime logs for BOT in CLUSTER""" + + if not (bot := client.bots.get(name)): + raise click.UsageError(f"Unknown bot '{name}'.") + + for log in bot.logs: + click.echo(log) + + +@bot.command(name="errors") +@click.argument("name", metavar="BOT") +def show_bot_errors(client: ClusterClient, name: str): + """Show errors for BOT in CLUSTER""" + + if not (bot := client.bots.get(name)): + raise click.UsageError(f"Unknown bot '{name}'.") + + for log in bot.errors: + click.echo(log) diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index f41d7a06..59661cb6 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -1,4 +1,3 @@ -import uuid from functools import cache from typing import ClassVar @@ -70,6 +69,59 @@ def remove(self): handle_error_with_response(response) +class Bot(BotInfo): + # NOTE: Client used only for this SDK + # NOTE: DI happens in `ClusterClient.__init__` + cluster: ClassVar["ClusterClient"] + + def update( + self, + name: str | None = None, + image: str | None = None, + network: str | None = None, + account: str | None = None, + environment: list[EnvInfo] | None = None, + ): + form: dict = dict( + name=name, + network=network, + account=account, + image=image, + ) + + if environment: + form["environment"] = [ + dict(id=str(env.id), revision=env.revision) for env in environment + ] + + response = self.cluster.put(f"/bots/{self.id}", json=form) + handle_error_with_response(response) + + def stop(self): + response = self.cluster.post(f"/bots/{self.id}/stop") + handle_error_with_response(response) + + def start(self): + response = self.cluster.post(f"/bots/{self.id}/start") + handle_error_with_response(response) + + @property + def errors(self) -> list[str]: + response = self.cluster.get(f"/bots/{self.id}/errors") + handle_error_with_response(response) + return response.json() + + @property + def logs(self) -> list[str]: + response = self.cluster.get(f"/bots/{self.id}/logs") + handle_error_with_response(response) + return response.json() + + def remove(self): + response = self.cluster.delete(f"/bots/{self.id}") + handle_error_with_response(response) + + class ClusterClient(httpx.Client): def __init__(self, *args, **kwargs): kwargs["headers"] = {**kwargs.get("headers", {}), **DEFAULT_HEADERS} @@ -77,6 +129,7 @@ def __init__(self, *args, **kwargs): # DI for other client classes Env.cluster = self # Connect to cluster client + Bot.cluster = self # Connect to cluster client def send(self, request, *args, **kwargs): try: @@ -108,10 +161,34 @@ def new_env(self, name: str, variables: dict[str, str]) -> EnvInfo: return EnvInfo.model_validate(response.json()) @property - def bots(self) -> dict[str, BotInfo]: - response = self.get("/bot") # TODO: rename `/bots` + def bots(self) -> dict[str, Bot]: + response = self.get("/bots") # TODO: rename `/bots` + handle_error_with_response(response) + return {bot.slug: bot for bot in map(Bot.model_validate, response.json())} + + def new_bot( + self, + name: str, + image: str, + network: str, + account: str | None = None, + environment: list[EnvInfo] | None = None, + ) -> Bot: + form: dict = dict( + name=name, + image=image, + network=network, + account=account, + ) + + if environment is not None: + form["environment"] = [ + dict(id=str(env.id), revision=env.revision) for env in environment + ] + + response = self.post("/bots", json=form) handle_error_with_response(response) - return {bot.slug: bot for bot in map(BotInfo.model_validate, response.json())} + return Bot.model_validate(response.json()) def build_display_fields(self) -> dict[str, str | dict[str, str]]: state = self.state