Skip to content

Commit 9bb99fd

Browse files
feat: adds silverback cluster registry auth commands (#120)
* 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 <3859395+fubuloubu@users.noreply.github.com> --------- Co-authored-by: El De-dog-lo <3859395+fubuloubu@users.noreply.github.com>
1 parent 1addd3a commit 9bb99fd

File tree

6 files changed

+235
-2
lines changed

6 files changed

+235
-2
lines changed

docs/commands/cluster.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ Cloud Platform
1515
:nested: full
1616
:commands: new, list, info, update, remove
1717

18+
.. click:: silverback._cli:docker_auth
19+
:prog: silverback cluster docker auth
20+
:nested: full
21+
:commands: new, list, info, update, remove
22+
1823
.. click:: silverback._cli:bots
1924
:prog: silverback cluster bots
2025
:nested: full

docs/userguides/platform.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ You can only remove a Variable Group if it is not referenced by any existing Bot
7979

8080
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.
8181

82+
## Private Container Registries
83+
84+
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.
85+
First you will need to add your credentials to the cluster with the [`silverback cluster registry auth new`][silverback-cluster-registry-auth-new] command.
86+
87+
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.
88+
8289
## Deploying your Bot
8390

8491
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
171178
[silverback-cluster-health]: ../commands/cluster.html#silverback-cluster-health
172179
[silverback-cluster-info]: ../commands/cluster.html#silverback-cluster-info
173180
[silverback-cluster-new]: ../commands/cluster.html#silverback-cluster-new
181+
[silverback-cluster-registry-auth-new]: ../commands/cluster.html#silverback-cluster-registry-auth-new
174182
[silverback-cluster-vars]: ../commands/cluster.html#silverback-cluster-vars
175183
[silverback-cluster-vars-info]: ../commands/cluster.html#silverback-cluster-vars-info
176184
[silverback-cluster-vars-list]: ../commands/cluster.html#silverback-cluster-vars-list

silverback/_cli.py

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,86 @@ def cluster_health(cluster: ClusterClient):
279279
click.echo(yaml.safe_dump(cluster.health.model_dump()))
280280

281281

282+
@cluster.group(cls=SectionedHelpGroup)
283+
def registry():
284+
"""Manage container registry configuration"""
285+
286+
287+
@registry.group(cls=SectionedHelpGroup, name="auth")
288+
def registry_auth():
289+
"""Manage private container registry credentials"""
290+
291+
292+
@registry_auth.command(name="list")
293+
@cluster_client
294+
def credentials_list(cluster: ClusterClient):
295+
"""List container registry credentials"""
296+
297+
if creds := list(cluster.registry_credentials):
298+
click.echo(yaml.safe_dump(creds))
299+
300+
else:
301+
click.secho("No registry credentials present in this cluster", bold=True, fg="red")
302+
303+
304+
@registry_auth.command(name="info")
305+
@click.argument("name")
306+
@cluster_client
307+
def credentials_info(cluster: ClusterClient, name: str):
308+
"""Show info about registry credentials"""
309+
310+
if not (creds := cluster.registry_credentials.get(name)):
311+
raise click.UsageError(f"Unknown credentials '{name}'")
312+
313+
click.echo(yaml.safe_dump(creds.model_dump(exclude={"id", "name"})))
314+
315+
316+
@registry_auth.command(name="new")
317+
@click.argument("name")
318+
@click.argument("registry")
319+
@cluster_client
320+
def credentials_new(cluster: ClusterClient, name: str, registry: str):
321+
"""Add registry private registry credentials. This command will prompt you for a username and
322+
password.
323+
"""
324+
325+
username = click.prompt("Username")
326+
password = click.prompt("Password", hide_input=True)
327+
328+
creds = cluster.new_credentials(
329+
name=name, hostname=registry, username=username, password=password
330+
)
331+
click.echo(yaml.safe_dump(creds.model_dump(exclude={"id"})))
332+
333+
334+
@registry_auth.command(name="update")
335+
@click.argument("name")
336+
@click.option("-r", "--registry")
337+
@cluster_client
338+
def credentials_update(cluster: ClusterClient, name: str, registry: str | None = None):
339+
"""Update registry registry credentials"""
340+
if not (creds := cluster.registry_credentials.get(name)):
341+
raise click.UsageError(f"Unknown credentials '{name}'")
342+
343+
username = click.prompt("Username")
344+
password = click.prompt("Password", hide_input=True)
345+
346+
creds = creds.update(hostname=registry, username=username, password=password)
347+
click.echo(yaml.safe_dump(creds.model_dump(exclude={"id"})))
348+
349+
350+
@registry_auth.command(name="remove")
351+
@click.argument("name")
352+
@cluster_client
353+
def credentials_remove(cluster: ClusterClient, name: str):
354+
"""Remove a set of registry credentials"""
355+
if not (creds := cluster.registry_credentials.get(name)):
356+
raise click.UsageError(f"Unknown credentials '{name}'")
357+
358+
creds.remove() # NOTE: No confirmation because can only delete if no references exist
359+
click.secho(f"registry credentials '{creds.name}' removed.", fg="green", bold=True)
360+
361+
282362
@cluster.group(cls=SectionedHelpGroup)
283363
def vars():
284364
"""Manage groups of environment variables in a CLUSTER"""
@@ -424,6 +504,12 @@ def bots():
424504
@click.option("-n", "--network", required=True)
425505
@click.option("-a", "--account")
426506
@click.option("-g", "--group", "vargroups", multiple=True)
507+
@click.option(
508+
"-r",
509+
"--registry-credentials",
510+
"registry_credentials_name",
511+
help="registry credentials to use to pull the image",
512+
)
427513
@click.argument("name")
428514
@cluster_client
429515
def new_bot(
@@ -432,6 +518,7 @@ def new_bot(
432518
network: str,
433519
account: str | None,
434520
vargroups: list[str],
521+
registry_credentials_name: str | None,
435522
name: str,
436523
):
437524
"""Create a new bot in a CLUSTER with the given configuration"""
@@ -441,17 +528,34 @@ def new_bot(
441528

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

531+
registry_credentials_id = None
532+
if registry_credentials_name:
533+
if not (
534+
creds := cluster.registry_credentials.get(registry_credentials_name)
535+
): # NOTE: Check if credentials exist
536+
raise click.UsageError(f"Unknown registry credentials '{registry_credentials_name}'")
537+
registry_credentials_id = creds.id
538+
444539
click.echo(f"Name: {name}")
445540
click.echo(f"Image: {image}")
446541
click.echo(f"Network: {network}")
447542
if environment:
448543
click.echo("Environment:")
449544
click.echo(yaml.safe_dump([var for vg in environment for var in vg.variables]))
545+
if registry_credentials_id:
546+
click.echo(f"registry credentials: {registry_credentials_name}")
450547

451548
if not click.confirm("Do you want to create and start running this bot?"):
452549
return
453550

454-
bot = cluster.new_bot(name, image, network, account=account, environment=environment)
551+
bot = cluster.new_bot(
552+
name,
553+
image,
554+
network,
555+
account=account,
556+
environment=environment,
557+
registry_credentials_id=registry_credentials_id,
558+
)
455559
click.secho(f"Bot '{bot.name}' ({bot.id}) deploying...", fg="green", bold=True)
456560

457561

@@ -477,7 +581,21 @@ def bot_info(cluster: ClusterClient, bot_name: str):
477581
raise click.UsageError(f"Unknown bot '{bot_name}'.")
478582

479583
# NOTE: Skip machine `.id`, and we already know it is `.name`
480-
click.echo(yaml.safe_dump(bot.model_dump(exclude={"id", "name", "environment"})))
584+
bot_dump = bot.model_dump(
585+
exclude={
586+
"id",
587+
"name",
588+
"environment",
589+
"registry_credentials_id",
590+
"registry_credentials",
591+
}
592+
)
593+
if bot.registry_credentials:
594+
bot_dump["registry_credentials"] = bot.registry_credentials.model_dump(
595+
exclude={"id", "name"}
596+
)
597+
598+
click.echo(yaml.safe_dump(bot_dump))
481599
if bot.environment:
482600
click.echo("environment:")
483601
click.echo(yaml.safe_dump([var.name for var in bot.environment]))
@@ -489,6 +607,12 @@ def bot_info(cluster: ClusterClient, bot_name: str):
489607
@click.option("-n", "--network")
490608
@click.option("-a", "--account")
491609
@click.option("-g", "--group", "vargroups", multiple=True)
610+
@click.option(
611+
"-r",
612+
"--registry-credentials",
613+
"registry_credentials_name",
614+
help="registry credentials to use to pull the image",
615+
)
492616
@click.argument("name", metavar="BOT")
493617
@cluster_client
494618
def update_bot(
@@ -498,6 +622,7 @@ def update_bot(
498622
network: str | None,
499623
account: str | None,
500624
vargroups: list[str],
625+
registry_credentials_name: str | None,
501626
name: str,
502627
):
503628
"""Update configuration of BOT in CLUSTER
@@ -516,6 +641,14 @@ def update_bot(
516641
if network:
517642
click.echo(f"Network:\n old: {bot.network}\n new: {network}")
518643

644+
registry_credentials_id = None
645+
if registry_credentials_name:
646+
if not (
647+
creds := cluster.registry_credentials.get(registry_credentials_name)
648+
): # NOTE: Check if credentials exist
649+
raise click.UsageError(f"Unknown registry credentials '{registry_credentials_name}'")
650+
registry_credentials_id = creds.id
651+
519652
redeploy_required = False
520653
if image:
521654
redeploy_required = True
@@ -549,6 +682,7 @@ def update_bot(
549682
network=network,
550683
account=account,
551684
environment=environment if set_environment else None,
685+
registry_credentials_id=registry_credentials_id,
552686
)
553687

554688
# NOTE: Skip machine `.id`

silverback/_click_ext.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ def new_decorator(f):
6565

6666
return new_decorator
6767

68+
def group(self, *args, **kwargs):
69+
section = kwargs.pop("section", "Commands")
70+
decorator = super().command(*args, **kwargs)
71+
72+
def new_decorator(f):
73+
cmd = decorator(f)
74+
cmd.section = section
75+
self.sections.setdefault(section, []).append(cmd)
76+
return cmd
77+
78+
return new_decorator
79+
6880
def format_commands(self, ctx, formatter):
6981
for section, cmds in self.sections.items():
7082
rows = []

silverback/cluster/client.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import ClassVar, Literal
33

44
import httpx
5+
from pydantic import computed_field
56

67
from silverback.version import version
78

@@ -11,6 +12,7 @@
1112
ClusterHealth,
1213
ClusterInfo,
1314
ClusterState,
15+
RegistryCredentialsInfo,
1416
VariableGroupInfo,
1517
WorkspaceInfo,
1618
)
@@ -52,6 +54,33 @@ def render_error(error: dict):
5254
assert response.status_code < 300, "Should follow redirects, so not sure what the issue is"
5355

5456

57+
class RegistryCredentials(RegistryCredentialsInfo):
58+
# NOTE: Client used only for this SDK
59+
# NOTE: DI happens in `ClusterClient.__init__`
60+
cluster: ClassVar["ClusterClient"]
61+
62+
def __hash__(self) -> int:
63+
return int(self.id)
64+
65+
def update(
66+
self,
67+
name: str | None = None,
68+
hostname: str | None = None,
69+
username: str | None = None,
70+
password: str | None = None,
71+
) -> "RegistryCredentials":
72+
response = self.cluster.put(
73+
f"/credentials/{self.id}",
74+
json=dict(name=name, hostname=hostname, username=username, password=password),
75+
)
76+
handle_error_with_response(response)
77+
return self
78+
79+
def remove(self):
80+
response = self.cluster.delete(f"/credentials/{self.id}")
81+
handle_error_with_response(response)
82+
83+
5584
class VariableGroup(VariableGroupInfo):
5685
# NOTE: Client used only for this SDK
5786
# NOTE: DI happens in `ClusterClient.__init__`
@@ -102,6 +131,7 @@ def update(
102131
network: str | None = None,
103132
account: str | None = None,
104133
environment: list[VariableGroupInfo] | None = None,
134+
registry_credentials_id: str | None = None,
105135
) -> "Bot":
106136
form: dict = dict(
107137
name=name,
@@ -115,6 +145,9 @@ def update(
115145
dict(id=str(env.id), revision=env.revision) for env in environment
116146
]
117147

148+
if registry_credentials_id:
149+
form["registry_credentials_id"] = registry_credentials_id
150+
118151
response = self.cluster.put(f"/bots/{self.id}", json=form)
119152
handle_error_with_response(response)
120153
return Bot.model_validate(response.json())
@@ -137,6 +170,15 @@ def start(self):
137170
response = self.cluster.put(f"/bots/{self.id}", json=dict(name=self.name))
138171
handle_error_with_response(response)
139172

173+
@computed_field # type: ignore[prop-decorator]
174+
@property
175+
def registry_credentials(self) -> RegistryCredentials | None:
176+
if self.registry_credentials_id:
177+
for v in self.cluster.registry_credentials.values():
178+
if v.id == self.registry_credentials_id:
179+
return v
180+
return None
181+
140182
@property
141183
def errors(self) -> list[str]:
142184
response = self.cluster.get(f"/bots/{self.id}/errors")
@@ -163,6 +205,7 @@ def __init__(self, *args, **kwargs):
163205
super().__init__(*args, **kwargs)
164206

165207
# DI for other client classes
208+
RegistryCredentials.cluster = self # Connect to cluster client
166209
VariableGroup.cluster = self # Connect to cluster client
167210
Bot.cluster = self # Connect to cluster client
168211

@@ -197,6 +240,24 @@ def health(self) -> ClusterHealth:
197240
handle_error_with_response(response)
198241
return ClusterHealth.model_validate(response.json())
199242

243+
@property
244+
def registry_credentials(self) -> dict[str, RegistryCredentials]:
245+
response = self.get("/credentials")
246+
handle_error_with_response(response)
247+
return {
248+
creds.name: creds for creds in map(RegistryCredentials.model_validate, response.json())
249+
}
250+
251+
def new_credentials(
252+
self, name: str, hostname: str, username: str, password: str
253+
) -> RegistryCredentials:
254+
response = self.post(
255+
"/credentials",
256+
json=dict(name=name, hostname=hostname, username=username, password=password),
257+
)
258+
handle_error_with_response(response)
259+
return RegistryCredentials.model_validate(response.json())
260+
200261
@property
201262
def variable_groups(self) -> dict[str, VariableGroup]:
202263
response = self.get("/variables")
@@ -221,6 +282,7 @@ def new_bot(
221282
network: str,
222283
account: str | None = None,
223284
environment: list[VariableGroupInfo] | None = None,
285+
registry_credentials_id: str | None = None,
224286
) -> Bot:
225287
form: dict = dict(
226288
name=name,
@@ -234,6 +296,9 @@ def new_bot(
234296
dict(id=str(env.id), revision=env.revision) for env in environment
235297
]
236298

299+
if registry_credentials_id:
300+
form["registry_credentials_id"] = registry_credentials_id
301+
237302
response = self.post("/bots", json=form)
238303
handle_error_with_response(response)
239304
return Bot.model_validate(response.json())

0 commit comments

Comments
 (0)