Skip to content

Commit

Permalink
refactor: add workspaces
Browse files Browse the repository at this point in the history
  • Loading branch information
fubuloubu committed Jul 9, 2024
1 parent 9ae2acb commit a18aeb5
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 44 deletions.
52 changes: 44 additions & 8 deletions silverback/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -235,6 +262,7 @@ def list_clusters(platform_client: PlatformClient):


@cluster.command(name="new")
@click.option("-w", "--workspace", "workspace_name")
@click.option(
"-n",
"--name",
Expand All @@ -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,
)
Expand All @@ -270,17 +302,21 @@ def new_cluster(


@cluster.command()
@click.option("-w", "--workspace", "workspace_name")
@click.option(
"-c",
"--cluster",
"cluster_name",
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:
Expand Down
105 changes: 71 additions & 34 deletions silverback/platform/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,30 @@
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)
DEFAULT_PROFILE = "production"


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)

@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
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
14 changes: 12 additions & 2 deletions silverback/platform/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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

0 comments on commit a18aeb5

Please sign in to comment.