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
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 Docker Registries

If you are using a private Docker registry to store your container 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 docker auth new`][silverback-cluster-docker-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.
mikeshultz marked this conversation as resolved.
Show resolved Hide resolved

## Deploying your Bot

You are finally ready to deploy your bot on the Cluster and get it running!
Expand Down Expand Up @@ -168,6 +175,7 @@ TODO: Downloading metrics from your Bot
[silverback-cluster-bots-start]: ../commands/cluster.html#silverback-cluster-bots-start
[silverback-cluster-bots-stop]: ../commands/cluster.html#silverback-cluster-bots-stop
[silverback-cluster-bots-update]: ../commands/cluster.html#silverback-cluster-bots-update
[silverback-cluster-docker-auth-new]: ../commands/cluster.html#silverback-cluster-docker-auth-new
[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
Expand Down
120 changes: 118 additions & 2 deletions silverback/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,86 @@ def cluster_health(cluster: ClusterClient):
click.echo(yaml.safe_dump(cluster.health.model_dump()))


@cluster.group(cls=SectionedHelpGroup)
def docker():
"""Manage Docker configuration"""
mikeshultz marked this conversation as resolved.
Show resolved Hide resolved


@docker.group(cls=SectionedHelpGroup, name="auth")
def docker_auth():
"""Manage Docker private registry credentials"""


@docker_auth.command(name="list")
@cluster_client
def credentials_list(cluster: ClusterClient):
"""List Docker registry credentials"""

if creds := list(cluster.docker_credentials):
click.echo(yaml.safe_dump(creds))

else:
click.secho("No Docker credentials present in this cluster", bold=True, fg="red")


@docker_auth.command(name="info")
@click.argument("name")
@cluster_client
def credentials_info(cluster: ClusterClient, name: str):
"""Show info about Docker credentials"""

if not (creds := cluster.docker_credentials.get(name)):
raise click.UsageError(f"Unknown credentials '{name}'")

click.echo(yaml.safe_dump(creds.model_dump(exclude={"id", "name"})))


@docker_auth.command(name="new")
@click.argument("name")
@click.argument("registry")
@cluster_client
def credentials_new(cluster: ClusterClient, name: str, registry: str):
"""Add Docker 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"})))


@docker_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 Docker registry credentials"""
if not (creds := cluster.docker_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"})))


@docker_auth.command(name="remove")
@click.argument("name")
@cluster_client
def credentials_remove(cluster: ClusterClient, name: str):
"""Remove a set of Docker credentials"""
if not (creds := cluster.docker_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"Docker 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 @@ -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(
"-d",
"--docker-credentials",
"docker_credentials_name",
help="Docker credentials to use to pull the image",
)
@click.argument("name")
@cluster_client
def new_bot(
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -433,6 +519,7 @@ def new_bot(
network: str,
account: str | None,
vargroups: list[str],
docker_credentials_name: str,
mikeshultz marked this conversation as resolved.
Show resolved Hide resolved
name: str,
):
"""Create a new bot in a CLUSTER with the given configuration"""
Expand All @@ -442,17 +529,34 @@ def new_bot(

environment = [cluster.variable_groups[vg_name].get_revision("latest") for vg_name in vargroups]

docker_credentials_id = None
if docker_credentials_name:
if not (
creds := cluster.docker_credentials.get(docker_credentials_name)
): # NOTE: Check if credentials exist
raise click.UsageError(f"Unknown docker credentials '{docker_credentials_name}'")
docker_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 docker_credentials_id:
click.echo(f"Docker Credentials: {docker_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,
docker_credentials_id=docker_credentials_id,
)
click.secho(f"Bot '{bot.name}' ({bot.id}) deploying...", fg="green", bold=True)


Expand All @@ -478,7 +582,19 @@ 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",
"docker_credentials_id",
"docker_credentials",
}
)
if bot.docker_credentials:
bot_dump["docker_credentials"] = bot.docker_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 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
61 changes: 61 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 @@ -12,6 +13,7 @@
ClusterHealth,
ClusterInfo,
ClusterState,
DockerCredentialsInfo,
VariableGroupInfo,
WorkspaceInfo,
)
Expand Down Expand Up @@ -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 DockerCredentials(DockerCredentialsInfo):
# 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,
) -> "DockerCredentials":
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 @@ -138,6 +167,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 docker_credentials(self) -> DockerCredentials | None:
if self.docker_credentials_id:
for v in self.cluster.docker_credentials.values():
if v.id == self.docker_credentials_id:
return v
return None

@property
def errors(self) -> list[str]:
response = self.cluster.get(f"/bots/{self.id}/errors")
Expand All @@ -164,6 +202,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# DI for other client classes
DockerCredentials.cluster = self # Connect to cluster client
VariableGroup.cluster = self # Connect to cluster client
Bot.cluster = self # Connect to cluster client

Expand Down Expand Up @@ -198,6 +237,24 @@ def health(self) -> ClusterHealth:
handle_error_with_response(response)
return ClusterHealth.model_validate(response.json())

@property
def docker_credentials(self) -> dict[str, DockerCredentials]:
response = self.get("/credentials")
handle_error_with_response(response)
return {
creds.name: creds for creds in map(DockerCredentials.model_validate, response.json())
}

def new_credentials(
self, name: str, hostname: str, username: str, password: str
) -> DockerCredentials:
response = self.post(
"/credentials",
json=dict(name=name, hostname=hostname, username=username, password=password),
)
handle_error_with_response(response)
return DockerCredentials.model_validate(response.json())

@property
def variable_groups(self) -> dict[str, VariableGroup]:
response = self.get("/variables")
Expand All @@ -222,6 +279,7 @@ def new_bot(
network: str,
account: str | None = None,
environment: list[VariableGroupInfo] | None = None,
docker_credentials_id: str | None = None,
) -> Bot:
form: dict = dict(
name=name,
Expand All @@ -235,6 +293,9 @@ def new_bot(
dict(id=str(env.id), revision=env.revision) for env in environment
]

if docker_credentials_id:
form["docker_credentials_id"] = docker_credentials_id

response = self.post("/bots", json=form)
handle_error_with_response(response)
return Bot.model_validate(response.json())
Expand Down
9 changes: 9 additions & 0 deletions silverback/cluster/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,14 @@ def cluster(self) -> ServiceHealth:
return ServiceHealth(healthy=self.ars.healthy and self.ccs.healthy)


class DockerCredentialsInfo(BaseModel):
mikeshultz marked this conversation as resolved.
Show resolved Hide resolved
id: str
name: str
hostname: str
created: datetime
updated: datetime


class VariableGroupInfo(BaseModel):
id: uuid.UUID
name: str
Expand Down Expand Up @@ -350,5 +358,6 @@ class BotInfo(BaseModel):
network: str
account: str | None
revision: int
docker_credentials_id: str | None

environment: list[EnvironmentVariable] = []
Loading