diff --git a/silverback/_cli.py b/silverback/_cli.py index c6abf75d..5865e7c6 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -211,12 +211,39 @@ def login(platform_client: PlatformClient): ) +@cluster.command() +def workspaces(platform_client: PlatformClient): + """List available workspaces""" + + user_id = platform_client.userinfo["sub"] + if workspaces := platform_client.workspaces: + for workspace_slug, workspace_info in workspaces.items(): + click.echo(f"{workspace_slug}:") + click.echo(f" id: {workspace_info.id}") + click.echo(f" name: {workspace_info.name}") + is_owner = str(workspace_info.owner_id) == user_id + click.echo(f" owner: {is_owner}") + + else: + click.secho( + "No workspaces available for this account. " + "Go to https://silverback.apeworx.io to sign up and create a new workspace", + bold=True, + fg="red", + ) + + @cluster.command(name="list") -def list_clusters(platform_client: PlatformClient): +@click.option("-w", "--workspace", "workspace_name") +def list_clusters(platform_client: PlatformClient, workspace_name: str): """List available clusters""" - if clusters := platform_client.clusters: - for cluster_name, cluster_info in clusters.items(): - click.echo(f"{cluster_name}:") + if not (workspace := platform_client.workspaces.get(workspace_name)): + raise click.BadOptionUsage("workspace_name", f"Unknown workspace '{workspace_name}'") + + if clusters := workspace.clusters: + for cluster_slug, cluster_info in clusters.items(): + click.echo(f"{cluster_slug}:") + click.echo(f" name: {cluster_info.name}") click.echo(f" status: {cluster_info.status}") click.echo(" configuration:") click.echo(f" cpu: {256 * 2 ** cluster_info.configuration.cpu / 1024} vCPU") @@ -235,6 +262,7 @@ def list_clusters(platform_client: PlatformClient): @cluster.command(name="new") +@click.option("-w", "--workspace", "workspace_name") @click.option( "-n", "--name", @@ -250,16 +278,20 @@ def list_clusters(platform_client: PlatformClient): @click.option("-c", "--config", "config_updates", type=(str, str), multiple=True) def new_cluster( platform_client: PlatformClient, + workspace_name: str, cluster_name: str, tier: str, config_updates: list[tuple[str, str]], ): """Create a new cluster""" + if not (workspace := platform_client.workspaces.get(workspace_name)): + raise click.BadOptionUsage("workspace_name", f"Unknown workspace '{workspace_name}'") + base_configuration = getattr(ClusterTier, tier.upper()).configuration() upgrades = ClusterConfiguration( **{k: int(v) if v.isnumeric() else v for k, v in config_updates} ) - cluster = platform_client.create_cluster( + cluster = workspace.create_cluster( cluster_name=cluster_name, configuration=base_configuration | upgrades, ) @@ -270,6 +302,7 @@ def new_cluster( @cluster.command() +@click.option("-w", "--workspace", "workspace_name") @click.option( "-c", "--cluster", @@ -277,10 +310,13 @@ def new_cluster( help="Name of cluster to connect with.", required=True, ) -def bots(platform_client: PlatformClient, cluster_name: str): +def bots(platform_client: PlatformClient, workspace_name: str, cluster_name: str): """List all bots in a cluster""" - if not (cluster := platform_client.clusters.get(cluster_name)): - if clusters := "', '".join(platform_client.clusters): + if not (workspace := platform_client.workspaces.get(workspace_name)): + raise click.BadOptionUsage("workspace_name", f"Unknown workspace '{workspace_name}'") + + if not (cluster := workspace.clusters.get(cluster_name)): + if clusters := "', '".join(workspace.clusters): message = f"'{cluster_name}' is not a valid cluster, must be one of: '{clusters}'." else: diff --git a/silverback/platform/client.py b/silverback/platform/client.py index dfe5f5fb..fb66d4bf 100644 --- a/silverback/platform/client.py +++ b/silverback/platform/client.py @@ -11,7 +11,7 @@ from silverback.platform.types import ClusterConfiguration from silverback.version import version -from .types import BotInfo, ClusterInfo +from .types import BotInfo, ClusterInfo, WorkspaceInfo CREDENTIALS_FOLDER = Path.home() / ".silverback" CREDENTIALS_FOLDER.mkdir(exist_ok=True) @@ -19,8 +19,9 @@ class ClusterClient(ClusterInfo): + workspace: WorkspaceInfo # NOTE: Client used only for this SDK - platform_client: ClassVar[httpx.Client | None] = None + _client: ClassVar[httpx.Client] def __hash__(self) -> int: return int(self.id) @@ -28,12 +29,12 @@ def __hash__(self) -> int: @property @cache def client(self) -> httpx.Client: - assert self.platform_client, "Forgot to link platform client" + assert self._client, "Forgot to link platform client" # NOTE: DI happens in `PlatformClient.client` return httpx.Client( - base_url=f"{self.platform_client.base_url}/clusters/{self.name}", - cookies=self.platform_client.cookies, - headers=self.platform_client.headers, + base_url=f"{self._client.base_url}/{self.workspace.slug}/{self.slug}", + cookies=self._client.cookies, + headers=self._client.headers, ) @property @@ -47,6 +48,60 @@ def bots(self) -> dict[str, BotInfo]: return {} +class WorkspaceClient(WorkspaceInfo): + # NOTE: Client used only for this SDK + # NOTE: DI happens in `PlatformClient.client` + client: ClassVar[httpx.Client] + + def __hash__(self) -> int: + return int(self.id) + + def parse_cluster(self, data: dict) -> ClusterClient: + return ClusterClient.model_validate(dict(**data, workspace=self)) + + @property + @cache + def clusters(self) -> dict[str, ClusterClient]: + response = self.client.get("/clusters", params=dict(org=str(self.id))) + response.raise_for_status() + clusters = response.json() + # TODO: Support paging + return {cluster.slug: cluster for cluster in map(self.parse_cluster, clusters)} + + def create_cluster( + self, + cluster_slug: str = "", + cluster_name: str = "", + configuration: ClusterConfiguration = ClusterConfiguration(), + ) -> ClusterClient: + body: dict = dict(configuration=configuration.model_dump()) + + if cluster_slug: + body["slug"] = cluster_slug + + if cluster_name: + body["name"] = cluster_name + + if ( + response := self.client.post( + "/clusters/", + params=dict(org=str(self.id)), + json=body, + ) + ).status_code >= 400: + message = response.text + try: + message = response.json().get("detail", response.text) + except Exception: + pass + + raise RuntimeError(message) + + new_cluster = ClusterClient.model_validate_json(response.text) + self.clusters.update({new_cluster.slug: new_cluster}) # NOTE: Update cache + return new_cluster + + class PlatformClient: def __init__(self, profile_name: str = DEFAULT_PROFILE): if not (profile_toml := (CREDENTIALS_FOLDER / "profile.toml")).exists(): @@ -94,8 +149,9 @@ def client(self) -> httpx.Client: except Exception: raise RuntimeError(f"Error with API Host at '{self.base_url}'.") - # DI for `ClusterClient` - ClusterClient.platform_client = client # Connect to client + # DI for other client classes + WorkspaceClient.client = client # Connect to client + ClusterClient._client = client # Connect to client return client @property @@ -108,31 +164,12 @@ def access_token_info(self) -> FiefAccessTokenInfo: @property @cache - def clusters(self) -> dict[str, ClusterClient]: - response = self.client.get("/clusters/") + def workspaces(self) -> dict[str, WorkspaceClient]: + response = self.client.get("/organizations") response.raise_for_status() - clusters = response.json() + workspaces = response.json() # TODO: Support paging - return {cluster.name: cluster for cluster in map(ClusterClient.parse_obj, clusters)} - - def create_cluster( - self, - cluster_name: str = "", - configuration: ClusterConfiguration = ClusterConfiguration(), - ) -> ClusterClient: - if ( - response := self.client.post( - "/clusters/", - params=dict(name=cluster_name), - json=configuration.model_dump(), - ) - ).status_code >= 400: - message = response.text - try: - message = response.json().get("detail", response.text) - except Exception: - pass - - raise RuntimeError(message) - - return ClusterClient.parse_raw(response.text) + return { + workspace.slug: workspace + for workspace in map(WorkspaceClient.model_validate, workspaces) + } diff --git a/silverback/platform/types.py b/silverback/platform/types.py index e361ee64..2a5d19a8 100644 --- a/silverback/platform/types.py +++ b/silverback/platform/types.py @@ -10,9 +10,11 @@ UINT8_MAX = 2**8 - 1 -class BotInfo(BaseModel): +class WorkspaceInfo(BaseModel): id: uuid.UUID + owner_id: uuid.UUID name: str + slug: str class ClusterConfiguration(BaseModel): @@ -101,7 +103,7 @@ def encode(self) -> int: class ClusterTier(enum.IntEnum): - """Default configuration for different tier suggestions""" + """Suggestions for different tier configurations""" PERSONAL = ClusterConfiguration( cpu="0.25 vCPU", @@ -137,8 +139,16 @@ class ClusterInfo(BaseModel): # NOTE: Raw API object (gets exported) id: uuid.UUID # NOTE: Keep this private, used as a temporary secret key for payment name: str + slug: str configuration: ClusterConfiguration created: datetime status: ClusterStatus last_updated: datetime + + +class BotInfo(BaseModel): + id: uuid.UUID + name: str + + # TODO: More fields