Skip to content

Commit

Permalink
feat: adds silverback cluster registry auth commands (#120)
Browse files Browse the repository at this point in the history
* feat: adds `silverback cluster docker auth` commands

* docs: document docker commands

* fix: type errors

* docs: mdformat didn't like my ordering

* fix: docker_credentials_name can be null

* refactor: docker -> registry

* style: it's always mdformat

* feat: adds repository credentials to bot update command

* docs: include update command

Co-authored-by: El De-dog-lo <[email protected]>

---------

Co-authored-by: El De-dog-lo <[email protected]>
  • Loading branch information
mikeshultz and fubuloubu authored Oct 4, 2024
1 parent 1addd3a commit 9bb99fd
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 2 deletions.
5 changes: 5 additions & 0 deletions docs/commands/cluster.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/userguides/platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
Expand Down
138 changes: 136 additions & 2 deletions silverback/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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(
Expand All @@ -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"""
Expand All @@ -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)


Expand All @@ -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]))
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`
Expand Down
12 changes: 12 additions & 0 deletions silverback/_click_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
65 changes: 65 additions & 0 deletions silverback/cluster/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import ClassVar, Literal

import httpx
from pydantic import computed_field

from silverback.version import version

Expand All @@ -11,6 +12,7 @@
ClusterHealth,
ClusterInfo,
ClusterState,
RegistryCredentialsInfo,
VariableGroupInfo,
WorkspaceInfo,
)
Expand Down Expand Up @@ -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__`
Expand Down Expand Up @@ -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,
Expand All @@ -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())
Expand All @@ -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")
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand All @@ -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())
Expand Down
Loading

0 comments on commit 9bb99fd

Please sign in to comment.