From a621b2a2eba4f39c03068d5097a0f5fcdbcf0ca8 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:02:32 -0500 Subject: [PATCH 1/8] refactor(cli): remove unused code --- silverback/_cli.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index c3381bec..55cca3d8 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -765,11 +765,6 @@ def cluster_info(cluster: "ClusterClient"): # NOTE: This actually doesn't query the cluster's routes, which are protected click.echo(f"Cluster Version: v{cluster.version}") - # TODO: Add way to fetch config and display it (this doesn't work) - # if config := cluster.state.configuration: - # click.echo(yaml.safe_dump(config.settings_display_dict())) - # else: - # click.secho("No Cluster Configuration detected", fg="yellow", bold=True) @cluster.command(name="health") From 3d46d376f7b08d44e5615b0afecedc417bac8976 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:10:42 -0500 Subject: [PATCH 2/8] refactor(cluster,cli): cannot update Vargroup name after creation also removed "immutability" design from previously --- silverback/_cli.py | 7 ++----- silverback/cluster/client.py | 29 +++++------------------------ silverback/cluster/types.py | 1 - 3 files changed, 7 insertions(+), 30 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 55cca3d8..7a694fb6 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -913,7 +913,6 @@ def vargroup_info(cluster: "ClusterClient", name: str): @vars.command(name="update") -@click.option("--new-name", "new_name") # NOTE: No `-n` to match `bots update` @click.option( "-e", "--env", @@ -938,7 +937,6 @@ def vargroup_info(cluster: "ClusterClient", name: str): def update_vargroup( cluster: "ClusterClient", name: str, - new_name: str, updated_vars: dict[str, str], deleted_vars: tuple[str], ): @@ -959,9 +957,8 @@ def update_vargroup( click.echo( yaml.safe_dump( vg.update( - name=new_name, - # NOTE: Do not update variables if no updates are provided - variables=dict(**updated_vars, **{v: None for v in deleted_vars}) or None, + **updated_vars, + **{v: None for v in deleted_vars}, ).model_dump( exclude={"id"} ) # NOTE: Skip machine `.id` diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index 2392662b..48207e43 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -1,7 +1,7 @@ from collections import defaultdict from datetime import datetime from functools import cache -from typing import ClassVar, Literal +from typing import ClassVar import httpx from ape import Contract @@ -98,32 +98,13 @@ class VariableGroup(VariableGroupInfo): def __hash__(self) -> int: return int(self.id) - def update( - self, name: str | None = None, variables: dict[str, str | None] | None = None - ) -> "VariableGroup": - - if name is not None: - # Update metadata - response = self.cluster.put(f"/vars/{self.id}", json=dict(name=name)) - handle_error_with_response(response) - if variables is not None: - # Create a new revision - response = self.cluster.post(f"/vars/{self.id}", json=dict(variables=variables)) - handle_error_with_response(response) - return VariableGroup.model_validate(response.json()) - return self - - def get_revision(self, revision: int | Literal["latest"] = "latest") -> VariableGroupInfo: - # TODO: Add `/latest` revision route - if revision == "latest": - revision = -1 # NOTE: This works with how cluster does lookup - - response = self.cluster.get(f"/vars/{self.id}/{revision}") + def update(self, **variables: str | None) -> "VariableGroup": + response = self.cluster.patch(f"/vars/{self.id}", json=dict(variables=variables)) handle_error_with_response(response) - return VariableGroupInfo.model_validate(response.json()) + return VariableGroup.model_validate(response.json()) def remove(self): - response = self.cluster.delete(f"/vars/{self.name}") + response = self.cluster.delete(f"/vars/{self.id}") handle_error_with_response(response) diff --git a/silverback/cluster/types.py b/silverback/cluster/types.py index ba23f05d..f306eb04 100644 --- a/silverback/cluster/types.py +++ b/silverback/cluster/types.py @@ -332,7 +332,6 @@ class RegistryCredentialsInfo(BaseModel): class VariableGroupInfo(BaseModel): id: uuid.UUID name: str - revision: int variables: list[str] created: datetime From c41f3a3033d7b3d3dbc05bf08a0ed60069fa15d4 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:13:01 -0500 Subject: [PATCH 3/8] refactor(cluster,cli): cluster registry credentials were updated --- silverback/_cli.py | 6 ++++-- silverback/cluster/client.py | 20 +++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 7a694fb6..7001071c 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -813,11 +813,12 @@ def credentials_new(cluster: "ClusterClient", name: str, registry: str): password. """ + email = click.prompt("Email") username = click.prompt("Username") password = click.prompt("Password", hide_input=True) creds = cluster.new_credentials( - name=name, hostname=registry, username=username, password=password + name=name, email=email, hostname=registry, username=username, password=password ) click.echo(yaml.safe_dump(creds.model_dump(exclude={"id"}))) @@ -831,10 +832,11 @@ def credentials_update(cluster: "ClusterClient", name: str, registry: str | None if not (creds := cluster.registry_credentials.get(name)): raise click.UsageError(f"Unknown credentials '{name}'") + email = click.prompt("Email") username = click.prompt("Username") password = click.prompt("Password", hide_input=True) - creds = creds.update(hostname=registry, username=username, password=password) + creds = creds.update(hostname=registry, email=email, username=username, password=password) click.echo(yaml.safe_dump(creds.model_dump(exclude={"id"}))) diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index 48207e43..021ff9cc 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -73,20 +73,20 @@ def __hash__(self) -> int: def update( self, - name: str | None = None, hostname: str | None = None, + email: str | None = None, username: str | None = None, password: str | None = None, ) -> "RegistryCredentials": - response = self.cluster.put( - f"/credentials/{self.id}", - json=dict(name=name, hostname=hostname, username=username, password=password), + response = self.cluster.patch( + f"/credentials/{self.name}", + json=dict(hostname=hostname, email=email, username=username, password=password), ) handle_error_with_response(response) return self def remove(self): - response = self.cluster.delete(f"/credentials/{self.id}") + response = self.cluster.delete(f"/credentials/{self.name}") handle_error_with_response(response) @@ -252,11 +252,17 @@ def registry_credentials(self) -> dict[str, RegistryCredentials]: } def new_credentials( - self, name: str, hostname: str, username: str, password: str + self, name: str, hostname: str, email: str, username: str, password: str ) -> RegistryCredentials: response = self.post( "/credentials", - json=dict(name=name, hostname=hostname, username=username, password=password), + json=dict( + name=name, + hostname=hostname, + email=email, + username=username, + password=password, + ), ) handle_error_with_response(response) return RegistryCredentials.model_validate(response.json()) From f4a1380ea78390597df46124faac04af7115a5d0 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:16:49 -0500 Subject: [PATCH 4/8] feat(cluster): configuration info is moved to OpenAPI schema --- silverback/cluster/client.py | 8 +++----- silverback/cluster/types.py | 10 ---------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index 021ff9cc..fa4887ab 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -17,9 +17,9 @@ BotHealth, BotInfo, BotLogEntry, + ClusterConfiguration, ClusterHealth, ClusterInfo, - ClusterState, RegistryCredentialsInfo, StreamInfo, VariableGroupInfo, @@ -232,10 +232,8 @@ def version(self) -> str: return self.openapi_schema["info"]["version"] @property - def state(self) -> ClusterState: - response = self.get("/") - handle_error_with_response(response) - return ClusterState.model_validate(response.json()) + def configuration(self) -> ClusterConfiguration | None: + return self.openapi_schema["info"].get("x-config") @property def health(self) -> ClusterHealth: diff --git a/silverback/cluster/types.py b/silverback/cluster/types.py index f306eb04..8e55a72c 100644 --- a/silverback/cluster/types.py +++ b/silverback/cluster/types.py @@ -289,16 +289,6 @@ class ClusterInfo(BaseModel): last_updated: datetime # Last time the resource was changed (upgrade, provisioning, etc.) -class ClusterState(BaseModel): - """ - Cluster Build Information and Configuration, direct from cluster control service - """ - - version: str = Field(alias="cluster_version") # TODO: Rename in cluster - configuration: ClusterConfiguration | None = None # TODO: Add to cluster - # TODO: Add other useful summary fields for frontend use - - class ServiceHealth(BaseModel): healthy: bool From 9b1afc9b5849d93c0fe6bd99ce77b79d4aa8a4be Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:20:05 -0500 Subject: [PATCH 5/8] refactor(Cluster): update models to match Cluster changes upstream --- silverback/_cli.py | 262 +++++++++++++++++++---------------- silverback/cluster/client.py | 105 +++++++------- silverback/cluster/types.py | 30 ++-- 3 files changed, 208 insertions(+), 189 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 7001071c..a4a7d76e 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -1,5 +1,6 @@ import asyncio import os +from collections import defaultdict from datetime import datetime, timedelta, timezone from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -32,13 +33,11 @@ ) if TYPE_CHECKING: - from ape.api.accounts import AccountAPI - from ape.api.networks import NetworkAPI + from ape.api import AccountAPI, EcosystemAPI, NetworkAPI, ProviderAPI from ape.contracts import ContractInstance from fief_client.integrations.cli import FiefAuth from silverback.cluster.client import Bot, ClusterClient, PlatformClient - from silverback.cluster.types import VariableGroupInfo LOCAL_DATETIME = "%Y-%m-%d %H:%M:%S %Z" @@ -761,7 +760,7 @@ def cancel_payment_stream( @cluster.command(name="info") @cluster_client def cluster_info(cluster: "ClusterClient"): - """Get Configuration information about a CLUSTER""" + """Get configuration information about a CLUSTER""" # NOTE: This actually doesn't query the cluster's routes, which are protected click.echo(f"Cluster Version: v{cluster.version}") @@ -770,22 +769,22 @@ def cluster_info(cluster: "ClusterClient"): @cluster.command(name="health") @cluster_client def cluster_health(cluster: "ClusterClient"): - """Get Health information about a CLUSTER""" + """Get health information about a CLUSTER""" click.echo(yaml.safe_dump(cluster.health.model_dump())) @cluster.group(cls=SectionedHelpGroup) def registry(): - """Manage container registry configuration""" + """Manage container registry credentials in CLUSTER""" @registry.command(name="list") @cluster_client def credentials_list(cluster: "ClusterClient"): - """List container registry credentials""" + """List container registry credentials in CLUSTER""" - if creds := list(cluster.registry_credentials): + if creds := list(cluster.credentials): click.echo(yaml.safe_dump(creds)) else: @@ -796,9 +795,9 @@ def credentials_list(cluster: "ClusterClient"): @click.argument("name") @cluster_client def credentials_info(cluster: "ClusterClient", name: str): - """Show info about registry credentials""" + """Show info about credential NAME in CLUSTER's registry""" - if not (creds := cluster.registry_credentials.get(name)): + if not (creds := cluster.credentials.get(name)): raise click.UsageError(f"Unknown credentials '{name}'") click.echo(yaml.safe_dump(creds.model_dump(exclude={"id", "name"}))) @@ -809,8 +808,9 @@ def credentials_info(cluster: "ClusterClient", name: str): @click.argument("registry") @cluster_client def credentials_new(cluster: "ClusterClient", name: str, registry: str): - """Add registry private registry credentials. This command will prompt you for a username and - password. + """Add registry access credential NAME to CLUSTER's registry. + + NOTE: This command will prompt you for an EMAIL, USERNAME, and PASSWORD. """ email = click.prompt("Email") @@ -828,8 +828,11 @@ def credentials_new(cluster: "ClusterClient", name: str, registry: str): @click.option("-r", "--registry") @cluster_client def credentials_update(cluster: "ClusterClient", name: str, registry: str | None = None): - """Update registry registry credentials""" - if not (creds := cluster.registry_credentials.get(name)): + """Update credential NAME in CLUSTER's registry + + NOTE: This command will prompt you for an EMAIL, USERNAME, and PASSWORD. + """ + if not (creds := cluster.credentials.get(name)): raise click.UsageError(f"Unknown credentials '{name}'") email = click.prompt("Email") @@ -844,8 +847,8 @@ def credentials_update(cluster: "ClusterClient", name: str, registry: str | None @click.argument("name") @cluster_client def credentials_remove(cluster: "ClusterClient", name: str): - """Remove a set of registry credentials""" - if not (creds := cluster.registry_credentials.get(name)): + """Remove credential NAME from CLUSTER's registry""" + if not (creds := cluster.credentials.get(name)): raise click.UsageError(f"Unknown credentials '{name}'") creds.remove() # NOTE: No confirmation because can only delete if no references exist @@ -991,13 +994,12 @@ def bots(): @bots.command(name="new", section="Configuration Commands") @click.option("-i", "--image", required=True) -@click.option("-n", "--network", required=True) +@network_option(required=True) @click.option("-a", "--account") -@click.option("-g", "--group", "vargroups", multiple=True) +@click.option("-g", "--group", "environment", multiple=True) @click.option( - "-r", - "--registry-credentials", - "registry_credentials_name", + "--credential", + "credential_name", help="registry credentials to use to pull the image", ) @click.argument("name") @@ -1005,10 +1007,12 @@ def bots(): def new_bot( cluster: "ClusterClient", image: str, - network: str, + ecosystem: "EcosystemAPI", + network: "NetworkAPI", + provider: "ProviderAPI", account: str | None, - vargroups: list["VariableGroupInfo"], - registry_credentials_name: str | None, + environment: list[str], + credential_name: str | None, name: str, ): """Create a new bot in a CLUSTER with the given configuration""" @@ -1016,35 +1020,33 @@ def new_bot( if name in cluster.bots: raise click.UsageError(f"Cannot use name '{name}' to create bot") - vargroup = [group for group in vargroups] - - registry_credentials_id = None - if registry_credentials_name: - if not ( - creds := cluster.registry_credentials.get(registry_credentials_name) - ): # NOTE: Check if credentials exist - raise click.UsageError(f"Unknown registry credentials '{registry_credentials_name}'") - registry_credentials_id = creds.id - - click.echo(f"Name: {name}") - click.echo(f"Image: {image}") - click.echo(f"Network: {network}") - if vargroup: - click.echo("Vargroups:") - click.echo(yaml.safe_dump(vargroup)) - if registry_credentials_id: - click.echo(f"Registry credentials: {registry_credentials_name}") + # NOTE: Check if credentials exist + if credential_name is not None and credential_name not in cluster.credentials: + raise click.UsageError(f"Unknown registry credentials '{credential_name}'") + + click.echo(f"Name: '{name}'") + click.echo(f"Image: '{image}'") + click.echo(f"Network: '{ecosystem.name}:{network.name}:{provider.name}'") + if environment: + variable_groups = cluster.variable_groups + click.echo("Environment:") + click.echo(yaml.safe_dump([variable_groups[vg_name] for vg_name in environment])) + + if credential_name is not None: + click.echo(f"Registry credentials: {credential_name}") if not click.confirm("Do you want to create and start running this bot?"): return bot = cluster.new_bot( - name, - image, - network, + name=name, + image=image, + ecosystem=ecosystem.name, + network=network.name, + provider=provider.name, account=account, - vargroup=vargroup, - registry_credentials_id=registry_credentials_id, + environment=environment, + credential_name=credential_name, ) click.secho(f"Bot '{bot.name}' ({bot.id}) deploying...", fg="green", bold=True) @@ -1054,29 +1056,17 @@ def new_bot( def list_bots(cluster: "ClusterClient"): """List all bots in a CLUSTER (Regardless of status)""" - if bot_names := cluster.bots_list(): - grouped_bots: dict[str, dict[str, list[Bot]]] = {} - for bot_list in bot_names.values(): - for bot in bot_list: - ecosystem, network, provider = bot.network.split("-") - network_key = f"{network}-{provider}" - grouped_bots.setdefault(ecosystem, {}).setdefault(network_key, []).append(bot) - - for ecosystem in sorted(grouped_bots.keys()): - grouped_bots[ecosystem] = { - network: sorted(bots, key=lambda b: b.name) - for network, bots in sorted(grouped_bots[ecosystem].items()) - } - - output = "" - for ecosystem in grouped_bots: - output += f"{ecosystem}:\n" - for network in grouped_bots[ecosystem]: - output += f" {network}:\n" - for bot in grouped_bots[ecosystem][network]: - output += f" - {bot.name}\n" - - click.echo(output) + if bots := list(cluster.bots.values()): + groups: dict[str, dict[str, list["Bot"]]] = defaultdict(lambda: defaultdict(list)) + for bot in bots: + groups[bot.ecosystem][bot.network].append(bot) + + for ecosystem, networks in groups.items(): + click.echo(f"{ecosystem}:") + for network, bots_by_network in networks.items(): + click.echo(f" {network}:") + for bot in sorted(bots_by_network): + click.echo(f""" - {bot.name}""") else: click.secho("No bots in this cluster", bold=True, fg="red") @@ -1096,32 +1086,35 @@ def bot_info(cluster: "ClusterClient", bot_name: str): exclude={ "id", "name", - "vargroup", - "registry_credentials_id", - "registry_credentials", + "credential_name", + "environment", + "ecosystem", + "network", + "provider", } ) - if bot.registry_credentials: - bot_dump["registry_credentials"] = bot.registry_credentials.model_dump( - exclude={"id", "name"} - ) + bot_dump["network"] = f"{bot.ecosystem}:{bot.network}:{bot.provider}" + + if bot.credential: + bot_dump["credential"] = bot.credential.model_dump(exclude={"id", "name"}) click.echo(yaml.safe_dump(bot_dump)) - if bot.vargroup: - click.echo("Vargroups:") - click.echo(yaml.safe_dump([var.name for var in bot.vargroup])) + if bot.environment: + click.echo("environment:") + click.echo(yaml.safe_dump([var.model_dump() for var in bot.vargroups])) @bots.command(name="update", section="Configuration Commands") @click.option("--new-name", "new_name") # NOTE: No shorthand, because conflicts w/ `--network` @click.option("-i", "--image") -@click.option("-n", "--network") -@click.option("-a", "--account") +@click.option("-n", "--network", default=None) +@click.option("-a", "--account", default="") @click.option("-g", "--group", "vargroups", multiple=True) +@click.option("--clear-vars", "clear_vargroups", is_flag=True) @click.option( - "-r", - "--registry-credentials", - "registry_credentials_name", + "--credential", + "credential_name", + default="", help="registry credentials to use to pull the image", ) @click.argument("name", metavar="BOT") @@ -1132,8 +1125,9 @@ def update_bot( image: str | None, network: str | None, account: str | None, - vargroups: list["VariableGroupInfo"], - registry_credentials_name: str | None, + vargroups: list[str], + clear_vargroups: bool, + credential_name: str | None, name: str, ): """Update configuration of BOT in CLUSTER @@ -1146,39 +1140,52 @@ def update_bot( if not (bot := cluster.bots.get(name)): raise click.UsageError(f"Unknown bot '{name}'.") - if new_name: + if new_name is not None: click.echo(f"Name:\n old: {name}\n new: {new_name}") - if network: - click.echo(f"Network:\n old: {bot.network}\n new: {network}") + ecosystem, provider = None, None + if network is not None: + network_choice = network.split(":") + ecosystem = network_choice[0] or None + network = network_choice[1] or None if len(network_choice) >= 2 else None + provider = network_choice[2] or None if len(network_choice) == 3 else None + + if ( + (ecosystem is not None and bot.ecosystem != ecosystem) + or (network is not None and bot.network != network) + or (provider is not None and bot.provider != provider) + ): + click.echo("Network:") + click.echo(f" old: '{bot.ecosystem}:{bot.network}:{bot.provider}'") + new_network_choice = ( + f"{ecosystem or bot.ecosystem}:{network or bot.network}:{provider or bot.provider}" + ) + click.echo(f" new: '{new_network_choice}'") - registry_credentials_id = None - if registry_credentials_name: - if not ( - creds := cluster.registry_credentials.get(registry_credentials_name) - ): # NOTE: Check if credentials exist - raise click.UsageError(f"Unknown registry credentials '{registry_credentials_name}'") - registry_credentials_id = creds.id + if ( + credential_name is not None + and credential_name != "" + and credential_name not in cluster.credentials + ): # NOTE: Check if credentials exist + raise click.UsageError(f"Unknown credential '{credential_name}'") redeploy_required = False if image: redeploy_required = True click.echo(f"Image:\n old: {bot.image}\n new: {image}") - vargroup = [group for group in vargroups] - - set_vargroup = True - - if len(vargroup) == 0 and bot.vargroup: - set_vargroup = click.confirm("Do you want to clear all variable groups?") + if clear_vargroups: + click.echo("old-env:") + click.echo(yaml.safe_dump(bot.vargroups)) + click.echo("new-env: []") - elif vargroup != bot.vargroup: - click.echo("old-vargroup:") - click.echo(yaml.safe_dump(bot.vargroup)) - click.echo("new-vargroup:") - click.echo(yaml.safe_dump(vargroup)) + elif vargroups and vargroups != bot.environment: + click.echo("old-env:") + click.echo(yaml.safe_dump(bot.vargroups)) + click.echo("new-env:") + click.echo(yaml.safe_dump([cluster.variable_groups[vg] for vg in vargroups])) - redeploy_required |= set_vargroup + redeploy_required |= clear_vargroups if not click.confirm( f"Do you want to update '{name}'?" @@ -1190,24 +1197,41 @@ def update_bot( bot = bot.update( name=new_name, image=image, + ecosystem=ecosystem, network=network, + provider=provider, account=account, - vargroup=vargroup if set_vargroup else None, - registry_credentials_id=registry_credentials_id, + environment=vargroups if clear_vargroups else None, + credential_name=credential_name, ) # NOTE: Skip machine `.id` - click.echo(yaml.safe_dump(bot.model_dump(exclude={"id", "vargroup"}))) - if bot.vargroup: - click.echo("Vargroups:") - click.echo(yaml.safe_dump(vargroup)) + bot_dump = bot.model_dump( + exclude={ + "id", + "name", + "credential_name", + "environment", + "ecosystem", + "network", + "provider", + } + ) + bot_dump["network"] = f"{bot.ecosystem}:{bot.network}:{bot.provider}" + + if bot.credential: + bot_dump["credential"] = bot.credential.model_dump(exclude={"id", "name"}) + + click.echo(yaml.safe_dump(bot_dump)) + if bot.environment: + click.echo("environment:") + click.echo(yaml.safe_dump([var.model_dump() for var in bot.vargroups])) @bots.command(name="remove", section="Configuration Commands") @click.argument("name", metavar="BOT") -@click.option("-n", "--network", required=True) @cluster_client -def remove_bot(cluster: "ClusterClient", name: str, network: str): +def remove_bot(cluster: "ClusterClient", name: str): """Remove BOT from CLUSTER (Shutdown if running)""" if not (bot := cluster.bots.get(name)): @@ -1216,7 +1240,7 @@ def remove_bot(cluster: "ClusterClient", name: str, network: str): elif not click.confirm(f"Do you want to shutdown and delete '{name}'?"): return - bot.remove(network) + bot.remove() click.secho(f"Bot '{bot.name}' removed.", fg="green", bold=True) @@ -1229,7 +1253,7 @@ def bot_health(cluster: "ClusterClient", bot_name: str): if not (bot := cluster.bots.get(bot_name)): raise click.UsageError(f"Unknown bot '{bot_name}'.") - click.echo(yaml.safe_dump(bot.health.model_dump(exclude={"bot_id"}))) + click.echo("Bot is healthy" if bot.is_healthy else "Bot is not healthy") @bots.command(name="start", section="Bot Operation Commands") diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index fa4887ab..a05ca748 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -1,7 +1,6 @@ -from collections import defaultdict from datetime import datetime from functools import cache -from typing import ClassVar +from typing import ClassVar, Iterator import httpx from ape import Contract @@ -14,13 +13,14 @@ from silverback.version import version from .types import ( - BotHealth, BotInfo, BotLogEntry, ClusterConfiguration, ClusterHealth, ClusterInfo, RegistryCredentialsInfo, + ResourceStatus, + ServiceHealth, StreamInfo, VariableGroupInfo, WorkspaceInfo, @@ -113,62 +113,69 @@ class Bot(BotInfo): # NOTE: DI happens in `ClusterClient.__init__` cluster: ClassVar["ClusterClient"] + @property + def vargroups(self) -> list[VariableGroupInfo]: + vargroups = self.cluster.variable_groups + return [vargroups[vg_name] for vg_name in self.environment if vg_name in vargroups] + def update( self, name: str | None = None, image: str | None = None, + credential_name: str | None = "", + ecosystem: str | None = None, network: str | None = None, - account: str | None = None, - vargroup: list[VariableGroupInfo] | None = None, - registry_credentials_id: str | None = None, + provider: str | None = None, + account: str | None = "", + environment: list[str] | None = None, ) -> "Bot": form: dict = dict( name=name, - account=account, image=image, + credential_name=credential_name, + ecosystem=ecosystem, network=network, + provider=provider, + account=account, + environment=environment, ) - if vargroup: - form["vargroup"] = vargroup - - if registry_credentials_id: - form["registry_credentials_id"] = registry_credentials_id - - response = self.cluster.put(f"/bots/{self.name}", json=form) + response = self.cluster.put(f"/bots/{self.id}", json=form) handle_error_with_response(response) return Bot.model_validate(response.json()) @property - def health(self) -> BotHealth: - response = self.cluster.get("/health") # TODO: Migrate this endpoint - # response = self.cluster.get(f"/bots/{self.id}/health") + def status(self) -> ResourceStatus: + response = self.cluster.get(f"/bots/{self.id}/status") + handle_error_with_response(response) + return ResourceStatus(response.json()) + + @property + def is_healthy(self) -> bool: + response = self.cluster.get(f"/bots/{self.id}/health") handle_error_with_response(response) - raw_health = next(bot for bot in response.json()["bots"] if bot["bot_id"] == str(self.id)) - return BotHealth.model_validate(raw_health) # response.json()) TODO: Migrate this endpoint + return ServiceHealth.model_validate(response.json()).healthy def stop(self): - response = self.cluster.post(f"/bots/{self.name}/stop") + 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") TODO: Add `/start` - # NOTE: Currently, a noop PUT request will trigger a start - response = self.cluster.put(f"/bots/{self.name}", json=dict(name=self.name)) + response = self.cluster.post(f"/bots/{self.id}/start") handle_error_with_response(response) @computed_field # type: ignore[prop-decorator] @property - def registry_credentials(self) -> RegistryCredentials | None: - if self.registry_credentials_id: - for v in self.cluster.registry_credentials.values(): - if v.id == self.registry_credentials_id: + def credential(self) -> RegistryCredentials | None: + if self.credential_name: + for v in self.cluster.credentials.values(): + if v.id == self.credential_name: return v return None @property def errors(self) -> list[str]: - response = self.cluster.get(f"/bots/{self.name}/errors") + response = self.cluster.get(f"/bots/{self.id}/errors") handle_error_with_response(response) return response.json() @@ -177,8 +184,9 @@ def filter_logs( log_level: LogLevel = LogLevel.INFO, start_time: datetime | None = None, end_time: datetime | None = None, - ) -> list[BotLogEntry]: - query = {"log_level": log_level.name} + follow: bool = False, + ) -> Iterator[BotLogEntry]: + query: dict = dict(log_level=log_level.name, follow=follow) if start_time: query["start_time"] = start_time.isoformat() @@ -186,16 +194,17 @@ def filter_logs( if end_time: query["end_time"] = end_time.isoformat() - response = self.cluster.get(f"/bots/{self.name}/logs", params=query, timeout=120) + request = self.cluster.build_request("GET", f"/bots/{self.id}/logs", params=query) + response = self.cluster.send(request, stream=True) handle_error_with_response(response) - return [BotLogEntry.model_validate(log) for log in response.json()] + yield from map(BotLogEntry.model_validate_json, response.iter_lines()) @property def logs(self) -> list[BotLogEntry]: - return self.filter_logs() + return list(self.filter_logs()) - def remove(self, network: str): - response = self.cluster.delete(f"/bots/{self.name}", params={"network": network}) + def remove(self): + response = self.cluster.delete(f"/bots/{self.id}") handle_error_with_response(response) @@ -242,7 +251,7 @@ def health(self) -> ClusterHealth: return ClusterHealth.model_validate(response.json()) @property - def registry_credentials(self) -> dict[str, RegistryCredentials]: + def credentials(self) -> dict[str, RegistryCredentials]: response = self.get("/credentials") handle_error_with_response(response) return { @@ -282,36 +291,28 @@ def bots(self) -> dict[str, Bot]: handle_error_with_response(response) return {bot.name: bot for bot in map(Bot.model_validate, response.json())} - def bots_list(self) -> dict[str, list[Bot]]: - response = self.get("/bots") - handle_error_with_response(response) - bots_dict = defaultdict(list) - for bot in map(Bot.model_validate, response.json()): - bots_dict[bot.name].append(bot) - return dict(bots_dict) - def new_bot( self, name: str, image: str, + ecosystem: str, network: str, + provider: str, account: str | None = None, - vargroup: list[VariableGroupInfo] | None = None, - registry_credentials_id: str | None = None, + environment: list[str] | None = None, + credential_name: str | None = None, ) -> Bot: form: dict = dict( name=name, image=image, + ecosystem=ecosystem, network=network, + provider=provider, account=account, + environment=environment or [], + credential_name=credential_name, ) - if vargroup is not None: - form["vargroup"] = vargroup - - if registry_credentials_id: - form["registry_credentials_id"] = registry_credentials_id - response = self.post("/bots", json=form) handle_error_with_response(response) return Bot.model_validate(response.json()) diff --git a/silverback/cluster/types.py b/silverback/cluster/types.py index 8e55a72c..bc643583 100644 --- a/silverback/cluster/types.py +++ b/silverback/cluster/types.py @@ -294,11 +294,10 @@ class ServiceHealth(BaseModel): class ClusterHealth(BaseModel): - # TODO: Replace w/ cluster - ccs: ServiceHealth # NOTE: network => healthy - ars: dict[str, ServiceHealth] = {} - bots: dict[str, ServiceHealth] = {} + # TODO: Remove alias + networks: dict[str, ServiceHealth] = Field(default_factory=dict, alias="ars") + bots: dict[str, ServiceHealth] = Field(default_factory=dict) @field_validator("bots", mode="before") # TODO: Fix so this is default def convert_bot_health(cls, bots): @@ -307,7 +306,8 @@ def convert_bot_health(cls, bots): @computed_field def cluster(self) -> ServiceHealth: return ServiceHealth( - healthy=all(ars.healthy for ars in self.ars.values()) and self.ccs.healthy + healthy=all(n.healthy for n in self.networks.values()) + and all(b.healthy for b in self.bots.values()) ) @@ -336,30 +336,24 @@ class BotTaskStatus(BaseModel): stopped_reason: str | None -class BotHealth(BaseModel): - bot_id: uuid.UUID - task_status: BotTaskStatus | None - healthy: bool - - class BotInfo(BaseModel): id: uuid.UUID # TODO: Change `.instance_id` field to `id: UUID` name: str created: datetime image: str + credential_name: str | None + ecosystem: str network: str + provider: str account: str | None - revision: int - registry_credentials_id: str | None - - vargroup: list[VariableGroupInfo] = [] + environment: list[str] class BotLogEntry(BaseModel): + level: LogLevel = LogLevel.INFO + timestamp: datetime | None = None message: str - timestamp: datetime | None - level: LogLevel def __str__(self) -> str: - return f"{self.timestamp}: {self.message}" + return f"{self.timestamp} [{self.level}]: {self.message}" From 24c481674fe3b80469f41d516bd67d917c435859 Mon Sep 17 00:00:00 2001 From: El De-dog-lo <3859395+fubuloubu@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:21:37 -0500 Subject: [PATCH 6/8] refactor(Cluster): forgot one TODO --- silverback/cluster/types.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/silverback/cluster/types.py b/silverback/cluster/types.py index bc643583..082359be 100644 --- a/silverback/cluster/types.py +++ b/silverback/cluster/types.py @@ -295,8 +295,7 @@ class ServiceHealth(BaseModel): class ClusterHealth(BaseModel): # NOTE: network => healthy - # TODO: Remove alias - networks: dict[str, ServiceHealth] = Field(default_factory=dict, alias="ars") + networks: dict[str, ServiceHealth] = Field(default_factory=dict) bots: dict[str, ServiceHealth] = Field(default_factory=dict) @field_validator("bots", mode="before") # TODO: Fix so this is default From 346d252cff0efbfad00008b3ad28cced20813689 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:29:06 -0500 Subject: [PATCH 7/8] fix(cli): forgot to add key= function to sorted --- silverback/_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index a4a7d76e..89d22c88 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -1065,7 +1065,7 @@ def list_bots(cluster: "ClusterClient"): click.echo(f"{ecosystem}:") for network, bots_by_network in networks.items(): click.echo(f" {network}:") - for bot in sorted(bots_by_network): + for bot in sorted(bots_by_network, key=lambda b: b.name): click.echo(f""" - {bot.name}""") else: From b9d3b02bc4ba6acf1810b15c737afc93462f9c2b Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:06:06 -0500 Subject: [PATCH 8/8] fix(cli): display environments properly --- silverback/_cli.py | 48 +++++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 89d22c88..a47a01fd 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -1029,8 +1029,16 @@ def new_bot( click.echo(f"Network: '{ecosystem.name}:{network.name}:{provider.name}'") if environment: variable_groups = cluster.variable_groups - click.echo("Environment:") - click.echo(yaml.safe_dump([variable_groups[vg_name] for vg_name in environment])) + click.echo( + yaml.safe_dump( + { + "Environment": { + vg.name: vg.variables + for vg in map(variable_groups.__getitem__, environment) + } + } + ) + ) if credential_name is not None: click.echo(f"Registry credentials: {credential_name}") @@ -1109,8 +1117,8 @@ def bot_info(cluster: "ClusterClient", bot_name: str): @click.option("-i", "--image") @click.option("-n", "--network", default=None) @click.option("-a", "--account", default="") -@click.option("-g", "--group", "vargroups", multiple=True) -@click.option("--clear-vars", "clear_vargroups", is_flag=True) +@click.option("-g", "--group", "environment", multiple=True) +@click.option("--clear-vars", "clear_environment", is_flag=True) @click.option( "--credential", "credential_name", @@ -1125,8 +1133,8 @@ def update_bot( image: str | None, network: str | None, account: str | None, - vargroups: list[str], - clear_vargroups: bool, + environment: list[str], + clear_environment: bool, credential_name: str | None, name: str, ): @@ -1174,18 +1182,22 @@ def update_bot( redeploy_required = True click.echo(f"Image:\n old: {bot.image}\n new: {image}") - if clear_vargroups: - click.echo("old-env:") - click.echo(yaml.safe_dump(bot.vargroups)) - click.echo("new-env: []") - - elif vargroups and vargroups != bot.environment: - click.echo("old-env:") - click.echo(yaml.safe_dump(bot.vargroups)) - click.echo("new-env:") - click.echo(yaml.safe_dump([cluster.variable_groups[vg] for vg in vargroups])) + if clear_environment or (environment and bot.environment != list(environment)): + variable_groups = cluster.variable_groups + env = { + "old": { + vg.name: vg.variables for vg in map(variable_groups.__getitem__, bot.environment) + } + } + if not clear_environment: + env["new"] = { + vg.name: vg.variables for vg in map(variable_groups.__getitem__, environment) + } + else: + env["new"] = {} + click.echo(yaml.safe_dump({"Environment": env})) - redeploy_required |= clear_vargroups + redeploy_required |= clear_environment if not click.confirm( f"Do you want to update '{name}'?" @@ -1201,7 +1213,7 @@ def update_bot( network=network, provider=provider, account=account, - environment=vargroups if clear_vargroups else None, + environment=environment if clear_environment else None, credential_name=credential_name, )