Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds silverback cluster registry auth commands #120

Merged
merged 11 commits into from
Oct 4, 2024
5 changes: 5 additions & 0 deletions docs/commands/cluster.rst
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions docs/userguides/platform.md
Original file line number Diff line number Diff line change
@@ -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] command.

## 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
138 changes: 136 additions & 2 deletions silverback/_cli.py
Original file line number Diff line number Diff line change
@@ -280,6 +280,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"""
@@ -425,6 +505,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(
@@ -433,6 +519,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"""
@@ -442,17 +529,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)


@@ -478,7 +582,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]))
@@ -490,6 +608,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(
@@ -499,6 +623,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
@@ -517,6 +642,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
@@ -550,6 +683,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`
12 changes: 12 additions & 0 deletions silverback/_click_ext.py
Original file line number Diff line number Diff line change
@@ -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 = []
65 changes: 65 additions & 0 deletions silverback/cluster/client.py
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@
from typing import ClassVar, Literal

import httpx
from pydantic import computed_field

from silverback.version import version

@@ -12,6 +13,7 @@
ClusterHealth,
ClusterInfo,
ClusterState,
RegistryCredentialsInfo,
VariableGroupInfo,
WorkspaceInfo,
)
@@ -53,6 +55,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__`
@@ -103,6 +132,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,
@@ -116,6 +146,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())
@@ -138,6 +171,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")
@@ -164,6 +206,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

@@ -198,6 +241,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")
@@ -222,6 +283,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,
@@ -235,6 +297,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())
9 changes: 9 additions & 0 deletions silverback/cluster/types.py
Original file line number Diff line number Diff line change
@@ -311,6 +311,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
@@ -350,5 +358,6 @@ class BotInfo(BaseModel):
network: str
account: str | None
revision: int
registry_credentials_id: str | None

environment: list[EnvironmentVariable] = []