diff --git a/docs/commands/cluster.rst b/docs/commands/cluster.rst index 1dafd291..0599bdf2 100644 --- a/docs/commands/cluster.rst +++ b/docs/commands/cluster.rst @@ -15,6 +15,11 @@ Cloud Platform :nested: full :commands: new, list, info, update, remove +.. click:: silverback._cli:docker_auth + :prog: silverback cluster docker auth + :nested: full + :commands: new, list, info, update, remove + .. click:: silverback._cli:bots :prog: silverback cluster bots :nested: full diff --git a/docs/userguides/platform.md b/docs/userguides/platform.md index d6171cb0..b7d55917 100644 --- a/docs/userguides/platform.md +++ b/docs/userguides/platform.md @@ -79,6 +79,13 @@ You can only remove a Variable Group if it is not referenced by any existing Bot Once you have created all the Variable Group(s) that you need to operate your Bot, you can reference these groups by name when adding your Bot to the cluster. +## Private Container Registries + +If you are using a private container registry to store your images, you will need to provide your bot with the necessary credentials to access it. +First you will need to add your credentials to the cluster with the [`silverback cluster registry auth new`][silverback-cluster-registry-auth-new] command. + +Then you can provide the name of these credentials when creating your bot with the [`silverback cluster bots new`][silverback-cluster-bots-new] or [`silverback cluster bots update`][silverback-cluster-bots-update] commands. + ## Deploying your Bot You are finally ready to deploy your bot on the Cluster and get it running! @@ -171,6 +178,7 @@ 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-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 [silverback-cluster-vars-list]: ../commands/cluster.html#silverback-cluster-vars-list diff --git a/silverback/_cli.py b/silverback/_cli.py index ea796eef..191cc7a6 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -279,6 +279,86 @@ def cluster_health(cluster: ClusterClient): click.echo(yaml.safe_dump(cluster.health.model_dump())) +@cluster.group(cls=SectionedHelpGroup) +def registry(): + """Manage container registry configuration""" + + +@registry.group(cls=SectionedHelpGroup, name="auth") +def registry_auth(): + """Manage private container registry credentials""" + + +@registry_auth.command(name="list") +@cluster_client +def credentials_list(cluster: ClusterClient): + """List container registry credentials""" + + if creds := list(cluster.registry_credentials): + click.echo(yaml.safe_dump(creds)) + + else: + click.secho("No registry credentials present in this cluster", bold=True, fg="red") + + +@registry_auth.command(name="info") +@click.argument("name") +@cluster_client +def credentials_info(cluster: ClusterClient, name: str): + """Show info about registry credentials""" + + if not (creds := cluster.registry_credentials.get(name)): + raise click.UsageError(f"Unknown credentials '{name}'") + + click.echo(yaml.safe_dump(creds.model_dump(exclude={"id", "name"}))) + + +@registry_auth.command(name="new") +@click.argument("name") +@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. + """ + + username = click.prompt("Username") + password = click.prompt("Password", hide_input=True) + + creds = cluster.new_credentials( + name=name, hostname=registry, username=username, password=password + ) + click.echo(yaml.safe_dump(creds.model_dump(exclude={"id"}))) + + +@registry_auth.command(name="update") +@click.argument("name") +@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)): + raise click.UsageError(f"Unknown credentials '{name}'") + + username = click.prompt("Username") + password = click.prompt("Password", hide_input=True) + + creds = creds.update(hostname=registry, username=username, password=password) + click.echo(yaml.safe_dump(creds.model_dump(exclude={"id"}))) + + +@registry_auth.command(name="remove") +@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)): + raise click.UsageError(f"Unknown credentials '{name}'") + + creds.remove() # NOTE: No confirmation because can only delete if no references exist + click.secho(f"registry credentials '{creds.name}' removed.", fg="green", bold=True) + + @cluster.group(cls=SectionedHelpGroup) def vars(): """Manage groups of environment variables in a CLUSTER""" @@ -424,6 +504,12 @@ def bots(): @click.option("-n", "--network", required=True) @click.option("-a", "--account") @click.option("-g", "--group", "vargroups", multiple=True) +@click.option( + "-r", + "--registry-credentials", + "registry_credentials_name", + help="registry credentials to use to pull the image", +) @click.argument("name") @cluster_client def new_bot( @@ -432,6 +518,7 @@ def new_bot( network: str, account: str | None, vargroups: list[str], + registry_credentials_name: str | None, name: str, ): """Create a new bot in a CLUSTER with the given configuration""" @@ -441,17 +528,34 @@ def new_bot( environment = [cluster.variable_groups[vg_name].get_revision("latest") for vg_name 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 environment: click.echo("Environment:") click.echo(yaml.safe_dump([var for vg in environment for var in vg.variables])) + if registry_credentials_id: + click.echo(f"registry credentials: {registry_credentials_name}") if not click.confirm("Do you want to create and start running this bot?"): return - bot = cluster.new_bot(name, image, network, account=account, environment=environment) + bot = cluster.new_bot( + name, + image, + network, + account=account, + environment=environment, + registry_credentials_id=registry_credentials_id, + ) click.secho(f"Bot '{bot.name}' ({bot.id}) deploying...", fg="green", bold=True) @@ -477,7 +581,21 @@ def bot_info(cluster: ClusterClient, bot_name: str): raise click.UsageError(f"Unknown bot '{bot_name}'.") # NOTE: Skip machine `.id`, and we already know it is `.name` - click.echo(yaml.safe_dump(bot.model_dump(exclude={"id", "name", "environment"}))) + bot_dump = bot.model_dump( + exclude={ + "id", + "name", + "environment", + "registry_credentials_id", + "registry_credentials", + } + ) + if bot.registry_credentials: + bot_dump["registry_credentials"] = bot.registry_credentials.model_dump( + exclude={"id", "name"} + ) + + click.echo(yaml.safe_dump(bot_dump)) if bot.environment: click.echo("environment:") click.echo(yaml.safe_dump([var.name for var in bot.environment])) @@ -489,6 +607,12 @@ def bot_info(cluster: ClusterClient, bot_name: str): @click.option("-n", "--network") @click.option("-a", "--account") @click.option("-g", "--group", "vargroups", multiple=True) +@click.option( + "-r", + "--registry-credentials", + "registry_credentials_name", + help="registry credentials to use to pull the image", +) @click.argument("name", metavar="BOT") @cluster_client def update_bot( @@ -498,6 +622,7 @@ def update_bot( network: str | None, account: str | None, vargroups: list[str], + registry_credentials_name: str | None, name: str, ): """Update configuration of BOT in CLUSTER @@ -516,6 +641,14 @@ def update_bot( if network: click.echo(f"Network:\n old: {bot.network}\n new: {network}") + 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 + redeploy_required = False if image: redeploy_required = True @@ -549,6 +682,7 @@ def update_bot( network=network, account=account, environment=environment if set_environment else None, + registry_credentials_id=registry_credentials_id, ) # NOTE: Skip machine `.id` diff --git a/silverback/_click_ext.py b/silverback/_click_ext.py index 1810ec9d..d526e499 100644 --- a/silverback/_click_ext.py +++ b/silverback/_click_ext.py @@ -65,6 +65,18 @@ def new_decorator(f): return new_decorator + def group(self, *args, **kwargs): + section = kwargs.pop("section", "Commands") + decorator = super().command(*args, **kwargs) + + def new_decorator(f): + cmd = decorator(f) + cmd.section = section + self.sections.setdefault(section, []).append(cmd) + return cmd + + return new_decorator + def format_commands(self, ctx, formatter): for section, cmds in self.sections.items(): rows = [] diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index 36792bac..294331b2 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -2,6 +2,7 @@ from typing import ClassVar, Literal import httpx +from pydantic import computed_field from silverback.version import version @@ -11,6 +12,7 @@ ClusterHealth, ClusterInfo, ClusterState, + RegistryCredentialsInfo, VariableGroupInfo, WorkspaceInfo, ) @@ -52,6 +54,33 @@ def render_error(error: dict): assert response.status_code < 300, "Should follow redirects, so not sure what the issue is" +class RegistryCredentials(RegistryCredentialsInfo): + # NOTE: Client used only for this SDK + # NOTE: DI happens in `ClusterClient.__init__` + cluster: ClassVar["ClusterClient"] + + def __hash__(self) -> int: + return int(self.id) + + def update( + self, + name: str | None = None, + hostname: 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), + ) + handle_error_with_response(response) + return self + + def remove(self): + response = self.cluster.delete(f"/credentials/{self.id}") + handle_error_with_response(response) + + class VariableGroup(VariableGroupInfo): # NOTE: Client used only for this SDK # NOTE: DI happens in `ClusterClient.__init__` @@ -102,6 +131,7 @@ def update( network: str | None = None, account: str | None = None, environment: list[VariableGroupInfo] | None = None, + registry_credentials_id: str | None = None, ) -> "Bot": form: dict = dict( name=name, @@ -115,6 +145,9 @@ def update( dict(id=str(env.id), revision=env.revision) for env in environment ] + if registry_credentials_id: + form["registry_credentials_id"] = registry_credentials_id + response = self.cluster.put(f"/bots/{self.id}", json=form) handle_error_with_response(response) return Bot.model_validate(response.json()) @@ -137,6 +170,15 @@ 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 + 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: + return v + return None + @property def errors(self) -> list[str]: response = self.cluster.get(f"/bots/{self.id}/errors") @@ -163,6 +205,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # DI for other client classes + RegistryCredentials.cluster = self # Connect to cluster client VariableGroup.cluster = self # Connect to cluster client Bot.cluster = self # Connect to cluster client @@ -197,6 +240,24 @@ def health(self) -> ClusterHealth: handle_error_with_response(response) return ClusterHealth.model_validate(response.json()) + @property + def registry_credentials(self) -> dict[str, RegistryCredentials]: + response = self.get("/credentials") + handle_error_with_response(response) + return { + creds.name: creds for creds in map(RegistryCredentials.model_validate, response.json()) + } + + def new_credentials( + self, name: str, hostname: str, username: str, password: str + ) -> RegistryCredentials: + response = self.post( + "/credentials", + json=dict(name=name, hostname=hostname, username=username, password=password), + ) + handle_error_with_response(response) + return RegistryCredentials.model_validate(response.json()) + @property def variable_groups(self) -> dict[str, VariableGroup]: response = self.get("/variables") @@ -221,6 +282,7 @@ def new_bot( network: str, account: str | None = None, environment: list[VariableGroupInfo] | None = None, + registry_credentials_id: str | None = None, ) -> Bot: form: dict = dict( name=name, @@ -234,6 +296,9 @@ def new_bot( dict(id=str(env.id), revision=env.revision) for env in environment ] + 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 d674b767..0a35ee51 100644 --- a/silverback/cluster/types.py +++ b/silverback/cluster/types.py @@ -319,6 +319,14 @@ def cluster(self) -> ServiceHealth: return ServiceHealth(healthy=self.ars.healthy and self.ccs.healthy) +class RegistryCredentialsInfo(BaseModel): + id: str + name: str + hostname: str + created: datetime + updated: datetime + + class VariableGroupInfo(BaseModel): id: uuid.UUID name: str @@ -358,5 +366,6 @@ class BotInfo(BaseModel): network: str account: str | None revision: int + registry_credentials_id: str | None environment: list[EnvironmentVariable] = []