From b28971a9b2ac5ee962e65cd9fefdf3cff098cf1a Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:49:09 -0400 Subject: [PATCH 01/14] refactor(cluster): add stream_id to cluster record --- silverback/cluster/types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/silverback/cluster/types.py b/silverback/cluster/types.py index 0a35ee51..4f054d52 100644 --- a/silverback/cluster/types.py +++ b/silverback/cluster/types.py @@ -284,7 +284,8 @@ class ClusterInfo(BaseModel): name: str # User-friendly display name slug: str # Shorthand name, for CLI and URI usage - expiration: datetime | None = None # NOTE: self-hosted clusters have no expiration + expiration: datetime | None = None # NOTE: Self-hosted clusters have no expiration + stream_id: uuid.UUID | None = None # NOTE: If there is an ApePay payment stream for this created: datetime # When the resource was first created status: ResourceStatus From 1479dd43ae47f58a51d833df1a93ffa6d697ee36 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:49:50 -0400 Subject: [PATCH 02/14] refactor(client): add streaming payment methods to platform client --- setup.py | 1 + silverback/cluster/client.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/setup.py b/setup.py index 27035f80..17e58903 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ url="https://github.com/ApeWorX/silverback", include_package_data=True, install_requires=[ + "apepay>=0.3,<1", "click", # Use same version as eth-ape "eth-ape>=0.7,<1.0", "ethpm-types>=0.6.10", # lower pin only, `eth-ape` governs upper pin diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index 294331b2..d612cee3 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -2,6 +2,9 @@ from typing import ClassVar, Literal import httpx +from ape import Contract +from ape.contracts import ContractInstance +from apepay import Stream, StreamManager from pydantic import computed_field from silverback.version import version @@ -13,6 +16,7 @@ ClusterInfo, ClusterState, RegistryCredentialsInfo, + StreamInfo, VariableGroupInfo, WorkspaceInfo, ) @@ -362,6 +366,23 @@ def create_cluster( self.clusters.update({new_cluster.slug: new_cluster}) # NOTE: Update cache return new_cluster + def get_payment_stream(self, cluster: ClusterInfo, chain_id: int) -> Stream | None: + response = self.client.get( + f"/clusters/{cluster.id}/stream", + params=dict(workspace=str(self.id)), + ) + handle_error_with_response(response) + + if not (raw_stream_info := response.json()): + return None + + stream_info = StreamInfo.model_validate(raw_stream_info) + + if not stream_info.chain_id == chain_id: + return None + + return Stream(manager=StreamManager(stream_info.manager), id=stream_info.stream_id) + class PlatformClient(httpx.Client): def __init__(self, *args, **kwargs): @@ -411,3 +432,15 @@ def create_workspace( new_workspace = Workspace.model_validate_json(response.text) self.workspaces.update({new_workspace.slug: new_workspace}) # NOTE: Update cache return new_workspace + + def get_stream_manager(self, chain_id: int) -> StreamManager: + response = self.get(f"/streams/manager/{chain_id}") + handle_error_with_response(response) + return StreamManager(response.json()) + + def get_accepted_tokens(self, chain_id: int) -> dict[str, ContractInstance]: + response = self.get(f"/streams/tokens/{chain_id}") + handle_error_with_response(response) + return { + token_info["symbol"]: Contract(token_info["address"]) for token_info in response.json() + } From 07b863eebcadb7b13d3ac313a9ca6d0c164a265e Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:50:17 -0400 Subject: [PATCH 03/14] style(client): make mypy happy --- silverback/cluster/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index d612cee3..56172ab7 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -174,8 +174,7 @@ def start(self): response = self.cluster.put(f"/bots/{self.id}", json=dict(name=self.name)) handle_error_with_response(response) - @computed_field # type: ignore[prop-decorator] - @property + @computed_field def registry_credentials(self) -> RegistryCredentials | None: if self.registry_credentials_id: for v in self.cluster.registry_credentials.values(): From 7d1f9a3c85d40e99657faed4b7551486e28d6122 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:50:42 -0400 Subject: [PATCH 04/14] refactor(cli): add some helpful methods --- silverback/_click_ext.py | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/silverback/_click_ext.py b/silverback/_click_ext.py index d526e499..0da14523 100644 --- a/silverback/_click_ext.py +++ b/silverback/_click_ext.py @@ -1,7 +1,11 @@ +from datetime import datetime, timedelta from functools import update_wrapper from pathlib import Path import click +from ape import Contract, convert +from ape.contracts import ContractInstance +from ape.types import AddressType from fief_client import Fief from fief_client.integrations.cli import FiefAuth, FiefAuthNotAuthenticatedError @@ -30,6 +34,66 @@ def cls_import_callback(ctx, param, cls_name): raise click.BadParameter(message=f"Failed to import {param} class: '{cls_name}'.") +def contract_callback( + ctx: click.Context, param: click.Parameter, contract_address: str +) -> ContractInstance: + return Contract(convert(contract_address, AddressType)) + + +def token_amount_callback( + ctx: click.Context, + param: click.Parameter, + token_amount: str | None, +) -> int | None: + if token_amount is None: + return None + + return convert(token_amount, int) + + +def timedelta_callback( + ctx: click.Context, param: click.Parameter, timestamp_or_str: str | None +) -> timedelta | None: + if timestamp_or_str is None: + return None + + try: + timestamp = datetime.fromisoformat(timestamp_or_str) + except ValueError: + timestamp = None + + if timestamp: + if timestamp <= (now := datetime.now()): + raise click.BadParameter("Must be a time in the future.", ctx=ctx, param=param) + return timestamp - now + + elif " " in timestamp_or_str: + units_value = {} + for time_units in map(lambda s: s.strip(), timestamp_or_str.split(",")): + + time, units = time_units.split(" ") + if not units.endswith("s"): + units += "s" + + if units not in {"seconds", "minutes", "hours", "days", "weeks"}: + raise click.BadParameter( + f"Not spelled properly: '{time_units}'.", ctx=ctx, param=param + ) + + units_value[units] = int(time) + + return timedelta(**units_value) # type: ignore[arg-type] + + elif timestamp_or_str.isnumeric(): + return timedelta(seconds=int(timestamp_or_str)) + + raise click.BadParameter( + "Must be an ISO timestamp (in the future), or a timedelta like '1 week'.", + ctx=ctx, + param=param, + ) + + class OrderedCommands(click.Group): # NOTE: Override so we get the list ordered by definition order def list_commands(self, ctx: click.Context) -> list[str]: From 5b9d17db7c482c19179d6316ec473095cbc65e8a Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:51:24 -0400 Subject: [PATCH 05/14] feat(cluster): add commands to pay for, add funds to, and cancel streams --- silverback/_cli.py | 306 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 279 insertions(+), 27 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 191cc7a6..ec7df1c7 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -1,15 +1,19 @@ import asyncio import os +from datetime import timedelta import click import yaml # type: ignore[import-untyped] +from ape.api import AccountAPI, NetworkAPI from ape.cli import ( AccountAliasPromptChoice, ConnectedProviderCommand, + account_option, ape_cli_context, network_option, ) -from ape.exceptions import Abort +from ape.contracts import ContractInstance +from ape.exceptions import Abort, ApeException from fief_client.integrations.cli import FiefAuth from silverback._click_ext import ( @@ -19,10 +23,12 @@ cluster_client, display_login_message, platform_client, + timedelta_callback, + token_amount_callback, ) from silverback._importer import import_from_string from silverback.cluster.client import ClusterClient, PlatformClient -from silverback.cluster.types import ClusterTier +from silverback.cluster.types import ClusterTier, ResourceStatus from silverback.runner import PollingRunner, WebsocketRunner from silverback.worker import run_worker @@ -192,35 +198,121 @@ def list_clusters(platform: PlatformClient, workspace: str): "cluster_slug", help="Slug for new cluster (Defaults to `name.lower()`)", ) +@click.argument("workspace") +@platform_client +def new_cluster( + platform: PlatformClient, + workspace: str, + cluster_name: str | None, + cluster_slug: str | None, +): + """Create a new cluster in WORKSPACE""" + + if not (workspace_client := platform.workspaces.get(workspace)): + raise click.BadOptionUsage("workspace", f"Unknown workspace '{workspace}'") + + if cluster_name: + click.echo(f"name: {cluster_name}") + click.echo(f"slug: {cluster_slug or cluster_name.lower().replace(' ', '-')}") + + elif cluster_slug: + click.echo(f"slug: {cluster_slug}") + + cluster = workspace_client.create_cluster( + cluster_name=cluster_name, + cluster_slug=cluster_slug, + ) + click.echo(f"{click.style('SUCCESS', fg='green')}: Created '{cluster.name}'") + + if cluster.status == ResourceStatus.CREATED: + click.echo( + f"{click.style('WARNING', fg='yellow')}: To use this cluster, " + f"please pay via `silverback cluster pay create {workspace}/{cluster_slug}`" + ) + + +@cluster.group(cls=SectionedHelpGroup, section="Platform Commands (https://silverback.apeworx.io)") +def pay(): + """Pay for CLUSTER with Crypto using ApePay streaming payments""" + + +@pay.command(name="create", cls=ConnectedProviderCommand) +@account_option() +@click.argument("cluster_path") @click.option( "-t", "--tier", - default=ClusterTier.PERSONAL.name, + default=ClusterTier.PERSONAL.name.capitalize(), metavar="NAME", + type=click.Choice( + [ + ClusterTier.PERSONAL.name.capitalize(), + ClusterTier.PROFESSIONAL.name.capitalize(), + ] + ), help="Named set of options to use for cluster as a base (Defaults to Personal)", ) @click.option( "-c", "--config", "config_updates", + metavar="KEY VALUE", type=(str, str), multiple=True, - help="Config options to set for cluster (overrides value of -t/--tier)", + help="Config options to set for cluster (overrides values from -t/--tier selection)", +) +@click.option("--token", metavar="ADDRESS", help="Token Symbol or Address to use to fund stream") +@click.option( + "--amount", + "token_amount", + metavar="VALUE", + callback=token_amount_callback, + default=None, + help="Token amount to use to fund stream", +) +@click.option( + "--time", + "stream_time", + metavar="TIMESTAMP or TIMEDELTA", + callback=timedelta_callback, + default=None, + help="Time to fund stream for", ) -@click.argument("workspace") @platform_client -def new_cluster( +def create_payment_stream( platform: PlatformClient, - workspace: str, - cluster_name: str | None, - cluster_slug: str | None, + network: NetworkAPI, + account: AccountAPI, + cluster_path: str, tier: str, config_updates: list[tuple[str, str]], + token: ContractInstance | None, + token_amount: int | None, + stream_time: timedelta | None, ): - """Create a new cluster in WORKSPACE""" + """ + Create a new streaming payment for a given CLUSTER - if not (workspace_client := platform.workspaces.get(workspace)): - raise click.BadOptionUsage("workspace", f"Unknown workspace '{workspace}'") + NOTE: This action is irreversible! + """ + + if "/" not in cluster_path or len(cluster_path.split("/")) > 2: + raise click.BadArgumentUsage(f"Invalid cluster path: '{cluster_path}'") + + workspace_name, cluster_name = cluster_path.split("/") + if not (workspace_client := platform.workspaces.get(workspace_name)): + raise click.BadArgumentUsage(f"Unknown workspace: '{workspace_name}'") + + elif not (cluster := workspace_client.clusters.get(cluster_name)): + raise click.BadArgumentUsage( + f"Unknown cluster in workspace '{workspace_name}': '{cluster_name}'" + ) + + elif cluster.status != ResourceStatus.CREATED: + raise click.UsageError(f"Cannot fund '{cluster_path}': cluster has existing streams.") + + elif token_amount is None and stream_time is None: + raise click.UsageError("Must specify one of '--amount' or '--time'.") if not hasattr(ClusterTier, tier.upper()): raise click.BadOptionUsage("tier", f"Invalid choice: {tier}") @@ -230,30 +322,190 @@ def new_cluster( for k, v in config_updates: setattr(configuration, k, int(v) if v.isnumeric() else v) - if cluster_name: - click.echo(f"name: {cluster_name}") - click.echo(f"slug: {cluster_slug or cluster_name.lower().replace(' ', '-')}") + sm = platform.get_stream_manager(network.chain_id) + product = configuration.get_product_code(account.address, cluster.id) - elif cluster_slug: - click.echo(f"slug: {cluster_slug}") + if not token: + accepted_tokens = platform.get_accepted_tokens(network.chain_id) + token = accepted_tokens.get( + click.prompt( + "Select one of the following tokens to fund your stream with", + type=click.Choice(list(accepted_tokens)), + ) + ) + assert token # mypy happy + + if not token_amount: + assert stream_time # mypy happy + one_token = 10 ** token.decimals() + token_amount = int( + one_token + * ( + stream_time.total_seconds() + / sm.compute_stream_life( + account.address, token, one_token, [product] + ).total_seconds() + ) + ) + else: + stream_time = sm.compute_stream_life(account.address, token, token_amount, [product]) + + assert token_amount # mypy happy click.echo(yaml.safe_dump(dict(configuration=configuration.settings_display_dict()))) + click.echo(f"duration: {stream_time}\n") - if not click.confirm("Do you want to make a new cluster with this configuration?"): + if not click.confirm( + f"Do you want to use this configuration to fund Cluster '{cluster_path}'?" + ): return - cluster = workspace_client.create_cluster( - cluster_name=cluster_name, - cluster_slug=cluster_slug, + if not token.balanceOf(account) >= token_amount: + raise click.UsageError( + f"Do not have sufficient balance of '{token.symbol()}' to fund stream." + ) + + elif not token.allowance(account, sm.address) >= token_amount: + click.echo(f"Approve StreamManager({sm.address}) for '{token.symbol()}'") + token.approve( + sm.address, + 2**256 - 1 if click.confirm("Unlimited Approval?") else token_amount, + sender=account, + ) + + # NOTE: will ask for approvals and do additional checks + try: + stream = sm.create( + token, token_amount, [product], min_stream_life=stream_time, sender=account + ) + except ApeException as e: + raise click.UsageError(str(e)) from e + + click.echo(f"{click.style('SUCCESS', fg='green')}: Stream funded for {stream.time_left}.") + + +@pay.command(name="add-time", cls=ConnectedProviderCommand) +@account_option() +@click.argument("cluster_path", metavar="CLUSTER") +@click.option( + "--amount", + "token_amount", + metavar="VALUE", + callback=token_amount_callback, + default=None, + help="Token amount to use to fund stream", +) +@click.option( + "--time", + "stream_time", + metavar="TIMESTAMP or TIMEDELTA", + callback=timedelta_callback, + default=None, + help="Time to fund stream for", +) +@platform_client +def fund_payment_stream( + platform: PlatformClient, + network: NetworkAPI, + account: AccountAPI, + cluster_path: str, + token_amount: int | None, + stream_time: timedelta | None, +): + """ + Fund an existing streaming payment for the given CLUSTER + + NOTE: You can fund anyone else's Stream! + """ + + if "/" not in cluster_path or len(cluster_path.split("/")) > 2: + raise click.BadArgumentUsage(f"Invalid cluster path: '{cluster_path}'") + + workspace_name, cluster_name = cluster_path.split("/") + if not (workspace_client := platform.workspaces.get(workspace_name)): + raise click.BadArgumentUsage(f"Unknown workspace: '{workspace_name}'") + + elif not (cluster := workspace_client.clusters.get(cluster_name)): + raise click.BadArgumentUsage( + f"Unknown cluster in workspace '{workspace_name}': '{cluster_name}'" + ) + + elif cluster.status != ResourceStatus.RUNNING: + raise click.UsageError(f"Cannot fund '{cluster_info.name}': cluster is not running.") + + elif not (stream := workspace_client.get_payment_stream(cluster, network.chain_id)): + raise click.UsageError("Cluster is not funded via ApePay Stream") + + elif token_amount is None and stream_time is None: + raise click.UsageError("Must specify one of '--amount' or '--time'.") + + if not token_amount: + assert stream_time # mypy happy + one_token = 10 ** stream.token.decimals() + amount = int( + one_token + * ( + stream_time.total_seconds() + / stream.manager.compute_stream_life( + account.address, stream.token, one_token, stream.products + ).total_seconds() + ) + ) + + if not stream.token.balanceOf(account) >= amount: + raise click.UsageError("Do not have sufficient funding") + + elif not stream.token.allowance(account, stream.manager.address) >= amount: + click.echo(f"Approving StreamManager({stream.manager.address})") + stream.token.approve( + stream.manager.address, + 2**256 - 1 if click.confirm("Unlimited Approval?") else amount, + sender=account, + ) + + click.echo( + f"Funding Stream for Cluster '{cluster_path}' with " + f"{amount / 10**stream.token.decimals():0.4f} {stream.token.symbol()}" ) - click.echo(f"{click.style('SUCCESS', fg='green')}: Created '{cluster.name}'") - # TODO: Pay for cluster via new stream + stream.add_funds(amount, sender=account) + + +@pay.command(name="cancel", cls=ConnectedProviderCommand) +@account_option() +@click.argument("cluster_path", metavar="CLUSTER") +@platform_client +def cancel_payment_stream( + platform: PlatformClient, + network: NetworkAPI, + account: AccountAPI, + cluster_path: str, +): + """ + Shutdown CLUSTER and refund all funds to Stream owner + + NOTE: Only the Stream owner can perform this action! + """ + + if "/" not in cluster_path or len(cluster_path.split("/")) > 2: + raise click.BadArgumentUsage(f"Invalid cluster path: '{cluster_path}'") + + workspace_name, cluster_name = cluster_path.split("/") + if not (workspace_client := platform.workspaces.get(workspace_name)): + raise click.BadArgumentUsage(f"Unknown workspace: '{workspace_name}'") + + elif not (cluster := workspace_client.clusters.get(cluster_name)): + raise click.BadArgumentUsage( + f"Unknown cluster in workspace '{workspace_name}': '{cluster_name}'" + ) + + elif cluster.status != ResourceStatus.RUNNING: + raise click.UsageError(f"Cannot fund '{cluster_info.name}': cluster is not running.") + elif not (stream := workspace_client.get_payment_stream(cluster, network.chain_id)): + raise click.UsageError("Cluster is not funded via ApePay Stream") -# `silverback cluster pay WORKSPACE/NAME --account ALIAS --time "10 days"` -# TODO: Create a signature scheme for ClusterInfo -# (ClusterInfo configuration as plaintext, .id as nonce?) -# TODO: Test payment w/ Signature validation of extra data + if click.confirm("This action is irreversible, are you sure?"): + stream.cancel(sender=account) @cluster.command(name="info") From 1b670db62fead09d2bb10d3c3dbf7059170350ec Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:01:54 -0400 Subject: [PATCH 06/14] feat(cli): add note on how long it takes to deploy, and how to check --- silverback/_cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/silverback/_cli.py b/silverback/_cli.py index ec7df1c7..9eb597e0 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -383,6 +383,11 @@ def create_payment_stream( click.echo(f"{click.style('SUCCESS', fg='green')}: Stream funded for {stream.time_left}.") + click.echo( + f"{click.style('WARNING', fg='yellow')}: Cluster may take up to 1 hour to deploy." + " Check back in 10-15 minutes using `silverback cluster info` to start using your cluster." + ) + @pay.command(name="add-time", cls=ConnectedProviderCommand) @account_option() From cd9d314ef2625c1babdcfd8733bdd2fd7015cb77 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:10:08 -0400 Subject: [PATCH 07/14] fix(cli): bad variable reference --- silverback/_cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 9eb597e0..beae9f8f 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -447,7 +447,7 @@ def fund_payment_stream( if not token_amount: assert stream_time # mypy happy one_token = 10 ** stream.token.decimals() - amount = int( + token_amount = int( one_token * ( stream_time.total_seconds() @@ -457,22 +457,22 @@ def fund_payment_stream( ) ) - if not stream.token.balanceOf(account) >= amount: + if not stream.token.balanceOf(account) >= token_amount: raise click.UsageError("Do not have sufficient funding") - elif not stream.token.allowance(account, stream.manager.address) >= amount: + elif not stream.token.allowance(account, stream.manager.address) >= token_amount: click.echo(f"Approving StreamManager({stream.manager.address})") stream.token.approve( stream.manager.address, - 2**256 - 1 if click.confirm("Unlimited Approval?") else amount, + 2**256 - 1 if click.confirm("Unlimited Approval?") else token_amount, sender=account, ) click.echo( f"Funding Stream for Cluster '{cluster_path}' with " - f"{amount / 10**stream.token.decimals():0.4f} {stream.token.symbol()}" + f"{token_amount / 10**stream.token.decimals():0.4f} {stream.token.symbol()}" ) - stream.add_funds(amount, sender=account) + stream.add_funds(token_amount, sender=account) @pay.command(name="cancel", cls=ConnectedProviderCommand) From e6edef71e653fb7a5f8160f87e1dae333e34c33a Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:10:26 -0400 Subject: [PATCH 08/14] feat(cli): add more helpful notes to CLI for payments --- silverback/_cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index beae9f8f..52c57bcc 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -381,7 +381,7 @@ def create_payment_stream( except ApeException as e: raise click.UsageError(str(e)) from e - click.echo(f"{click.style('SUCCESS', fg='green')}: Stream funded for {stream.time_left}.") + click.echo(f"{click.style('SUCCESS', fg='green')}: Cluster funded for {stream.time_left}.") click.echo( f"{click.style('WARNING', fg='yellow')}: Cluster may take up to 1 hour to deploy." @@ -474,6 +474,8 @@ def fund_payment_stream( ) stream.add_funds(token_amount, sender=account) + click.echo(f"{click.style('SUCCESS', fg='green')}: Cluster funded for {stream.time_left}.") + @pay.command(name="cancel", cls=ConnectedProviderCommand) @account_option() @@ -509,9 +511,13 @@ def cancel_payment_stream( elif not (stream := workspace_client.get_payment_stream(cluster, network.chain_id)): raise click.UsageError("Cluster is not funded via ApePay Stream") - if click.confirm("This action is irreversible, are you sure?"): + if click.confirm( + click.style("This action is irreversible, are you sure?", bold=True, bg="red") + ): stream.cancel(sender=account) + click.echo(f"{click.style('WARNING', fg='yellow')}: Cluster cannot be used anymore.") + @cluster.command(name="info") @cluster_client From a317a8fbd84b4f90062a4ce3d1225a86fc83f2bb Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:14:56 -0400 Subject: [PATCH 09/14] chore(deps): ApePay didn't officially support Py 3.10 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 17e58903..39b665bb 100644 --- a/setup.py +++ b/setup.py @@ -61,7 +61,7 @@ url="https://github.com/ApeWorX/silverback", include_package_data=True, install_requires=[ - "apepay>=0.3,<1", + "apepay>=0.3.1,<1", "click", # Use same version as eth-ape "eth-ape>=0.7,<1.0", "ethpm-types>=0.6.10", # lower pin only, `eth-ape` governs upper pin From 7e9fd68a79ac6365957672b62475a4069379cbeb Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:27:58 -0400 Subject: [PATCH 10/14] docs(platform): add docs guide for payments --- docs/userguides/platform.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/userguides/platform.md b/docs/userguides/platform.md index b7d55917..20c42ec6 100644 --- a/docs/userguides/platform.md +++ b/docs/userguides/platform.md @@ -20,6 +20,23 @@ The Platform UI will let you create and manage Clusters using a graphical experi The CLI experience is for those working locally who don't want to visit the website, or are locally developing their applications. ``` +Once you have created your Cluster, you have to fund it so it is made available for your use. +To do that, use the [`silverback cluster pay create`][silverback-cluster-pay-create] command to fund your newly created cluster. +Please note that provisioning your cluster will take time, and it may take up to an hour for it to be ready. +Check back after 10-15 minutes using the [`silverback cluster info`][silverback-cluster-info] command to see when it's ready. + +At any point after the Cluster is funded, you can fund it with more funds via [`silverback cluster pay add-time`][silverback-cluster-pay-add-time] +command to extend the timeline that the Cluster is kept around for. +Note that it is possible for anyone to add more time to the Cluster, at any time and for any amount. + +If that timeline expires, the Platform will automatically de-provision your infrastructure, and it is not possible to reverse this! +The Platform may send you notifications when your Stream is close to expiring, but it is up to you to remember to fill it so it doesn't. +Note that your data collection will stay available for up to 30 days allowing you the ability to download any data you need. + +Lastly, if you ever feel like you no longer need your Cluster, you can cancel the funding for it and get a refund of the remaining funds. +If you are the owner of the Stream, you can do this via the [`silverback cluster pay cancel`][silverback-cluster-pay-cancel] command. +Only the owner may do this, so if you are not the owner you should contact them to have them do that action for you. + ## Connecting to your Cluster To connect to a cluster, you can use commands from the [`silverback cluster`][silverback-cluster] subcommand group. @@ -178,6 +195,9 @@ TODO: Downloading metrics from your Bot [silverback-cluster-health]: ../commands/cluster.html#silverback-cluster-health [silverback-cluster-info]: ../commands/cluster.html#silverback-cluster-info [silverback-cluster-new]: ../commands/cluster.html#silverback-cluster-new +[silverback-cluster-pay-add-time]: ../commands/cluster.html#silverback-cluster-pay-add-time +[silverback-cluster-pay-cancel]: ../commands/cluster.html#silverback-cluster-pay-cancel +[silverback-cluster-pay-create]: ../commands/cluster.html#silverback-cluster-pay-create [silverback-cluster-registry-auth-new]: ../commands/cluster.html#silverback-cluster-registry-auth-new [silverback-cluster-vars]: ../commands/cluster.html#silverback-cluster-vars [silverback-cluster-vars-info]: ../commands/cluster.html#silverback-cluster-vars-info From 93eacd7229c10543ac514fe72415fb1dc0f1e25d Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:31:00 -0400 Subject: [PATCH 11/14] fix(typing): switch order of computed_field and property --- silverback/cluster/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index 56172ab7..0e6c5373 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -174,6 +174,7 @@ def start(self): response = self.cluster.put(f"/bots/{self.id}", json=dict(name=self.name)) handle_error_with_response(response) + @property @computed_field def registry_credentials(self) -> RegistryCredentials | None: if self.registry_credentials_id: From b3bebf727eca4ff127e5ebf992c967caa56716a5 Mon Sep 17 00:00:00 2001 From: Doggie B <3859395+fubuloubu@users.noreply.github.com> Date: Tue, 8 Oct 2024 10:00:00 -0400 Subject: [PATCH 12/14] fix(client): revert change ordering of property/computed_filed --- silverback/cluster/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index 0e6c5373..d612cee3 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -174,8 +174,8 @@ def start(self): response = self.cluster.put(f"/bots/{self.id}", json=dict(name=self.name)) handle_error_with_response(response) + @computed_field # type: ignore[prop-decorator] @property - @computed_field def registry_credentials(self) -> RegistryCredentials | None: if self.registry_credentials_id: for v in self.cluster.registry_credentials.values(): From 7dbe052f2dd77a9afda88e51614b5fe31ab70797 Mon Sep 17 00:00:00 2001 From: Doggie B <3859395+fubuloubu@users.noreply.github.com> Date: Tue, 8 Oct 2024 10:03:30 -0400 Subject: [PATCH 13/14] docs(cli): add better note to CLI for cluster pay funding --- silverback/_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 52c57bcc..57e9f0ad 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -293,7 +293,7 @@ def create_payment_stream( """ Create a new streaming payment for a given CLUSTER - NOTE: This action is irreversible! + NOTE: This action cannot be cancelled! Streams must exist for at least 1 hour before cancelling. """ if "/" not in cluster_path or len(cluster_path.split("/")) > 2: From 4dd1b337079475cadd361ce0c87021c6042b4163 Mon Sep 17 00:00:00 2001 From: johnson2427 Date: Wed, 9 Oct 2024 08:31:52 -0500 Subject: [PATCH 14/14] fix: black and isort --- silverback/_cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 2ddfaa96..fe193e47 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -1,9 +1,8 @@ import asyncio import os -from datetime import timedelta - import shlex import subprocess +from datetime import timedelta from pathlib import Path import click