Skip to content

Commit 578e04a

Browse files
authored
Implement project secrets (#2854)
* Implement secrets management API * Test secrets management API * test: add test cases for delete secrets endpoint * Check secrets exist before deleting * Add dstack secret CLI command * Interpolate env with secrets * Interpolate registry_auth with secrets * Validate secrets * Rebase migration * Document secrets
1 parent c5dd99d commit 578e04a

File tree

23 files changed

+1078
-75
lines changed

23 files changed

+1078
-75
lines changed

docs/docs/concepts/secrets.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Secrets
2+
3+
Secrets allow centralized management of sensitive values such as API keys and credentials. They are project-scoped, managed by project admins, and can be referenced in run configurations to pass sensitive values to runs in a secure manner.
4+
5+
!!! info "Secrets encryption"
6+
By default, secrets are stored in plaintext in the DB.
7+
Configure [server encryption](../guides/server-deployment.md#encryption) to store secrets encrypted.
8+
9+
## Manage secrets
10+
11+
### Set
12+
13+
Use the `dstack secret set` command to create a new secret:
14+
15+
<div class="termy">
16+
17+
```shell
18+
$ dstack secret set my_secret some_secret_value
19+
OK
20+
```
21+
22+
</div>
23+
24+
The same command can be used to update an existing secret:
25+
26+
<div class="termy">
27+
28+
```shell
29+
$ dstack secret set my_secret another_secret_value
30+
OK
31+
```
32+
33+
</div>
34+
35+
### List
36+
37+
Use the `dstack secret list` command to list all secrets set in a project:
38+
39+
<div class="termy">
40+
41+
```shell
42+
$ dstack secret
43+
NAME VALUE
44+
hf_token ******
45+
my_secret ******
46+
47+
```
48+
49+
</div>
50+
51+
### Get
52+
53+
The `dstack secret list` does not show secret values. To see a secret value, use the `dstack secret get` command:
54+
55+
<div class="termy">
56+
57+
```shell
58+
$ dstack secret get my_secret
59+
NAME VALUE
60+
my_secret some_secret_value
61+
62+
```
63+
64+
</div>
65+
66+
### Delete
67+
68+
Secrets can be deleted using the `dstack secret delete` command:
69+
70+
<div class="termy">
71+
72+
```shell
73+
$ dstack secret delete my_secret
74+
Delete the secret my_secret? [y/n]: y
75+
OK
76+
```
77+
78+
</div>
79+
80+
## Use secrets
81+
82+
You can use the `${{ secrets.<secret_name> }}` syntax to reference secrets in run configurations. Currently, secrets interpolation is supported in `env` and `registry_auth` properties.
83+
84+
### `env`
85+
86+
Suppose you need to pass a sensitive environment variable to a run such as `HF_TOKEN`. You'd first create a secret holding the environment variable value:
87+
88+
<div class="termy">
89+
90+
```shell
91+
$ dstack secret set hf_token {hf_token_value}
92+
OK
93+
```
94+
95+
</div>
96+
97+
and then reference the secret in `env`:
98+
99+
<div editor-title=".dstack.yml">
100+
101+
```yaml
102+
type: service
103+
env:
104+
- HF_TOKEN=${{ secrets.hf_token }}
105+
commands:
106+
...
107+
```
108+
109+
</div>
110+
111+
### `registry_auth`
112+
113+
If you need to pull a private Docker image, you can store registry credentials as secrets and reference them in `registry_auth`:
114+
115+
<div editor-title=".dstack.yml">
116+
117+
```yaml
118+
type: service
119+
image: nvcr.io/nim/deepseek-ai/deepseek-r1-distill-llama-8b
120+
registry_auth:
121+
username: $oauthtoken
122+
password: ${{ secrets.ngc_api_key }}
123+
```
124+
125+
</div>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# dstack secret
2+
3+
The `dstack secret` commands allow managing [Secrets](../../../concepts/secrets.md).
4+
5+
## dstack secret set
6+
7+
The `dstack secret set` command creates a new secret or updates an existing one.
8+
9+
##### Usage
10+
11+
<div class="termy">
12+
13+
```shell
14+
$ dstack secret set --help
15+
#GENERATE#
16+
```
17+
18+
</div>
19+
20+
## dstack secret list
21+
22+
The `dstack secret list` command lists all secrets set in a project.
23+
##### Usage
24+
25+
<div class="termy">
26+
27+
```shell
28+
$ dstack secret list --help
29+
#GENERATE#
30+
```
31+
32+
</div>
33+
34+
## dstack secret get
35+
36+
The `dstack secret get` command show the value of a specified secret.
37+
##### Usage
38+
39+
<div class="termy">
40+
41+
```shell
42+
$ dstack secret get --help
43+
#GENERATE#
44+
```
45+
46+
</div>
47+
48+
## dstack secret delete
49+
50+
The `dstack secret delete` command deletes the specified secret.
51+
52+
##### Usage
53+
54+
<div class="termy">
55+
56+
```shell
57+
$ dstack secret delete --help
58+
#GENERATE#
59+
```
60+
61+
</div>

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ nav:
221221
- Fleets: docs/concepts/fleets.md
222222
- Volumes: docs/concepts/volumes.md
223223
- Repos: docs/concepts/repos.md
224+
- Secrets: docs/concepts/secrets.md
224225
- Projects: docs/concepts/projects.md
225226
- Gateways: docs/concepts/gateways.md
226227
- Guides:
@@ -254,6 +255,7 @@ nav:
254255
- dstack offer: docs/reference/cli/dstack/offer.md
255256
- dstack volume: docs/reference/cli/dstack/volume.md
256257
- dstack gateway: docs/reference/cli/dstack/gateway.md
258+
- dstack secret: docs/reference/cli/dstack/secret.md
257259
- API:
258260
- Python API: docs/reference/api/python/index.md
259261
- REST API: docs/reference/api/rest/index.md
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import argparse
2+
3+
from dstack._internal.cli.commands import APIBaseCommand
4+
from dstack._internal.cli.services.completion import SecretNameCompleter
5+
from dstack._internal.cli.utils.common import (
6+
confirm_ask,
7+
console,
8+
)
9+
from dstack._internal.cli.utils.secrets import print_secrets_table
10+
11+
12+
class SecretCommand(APIBaseCommand):
13+
NAME = "secret"
14+
DESCRIPTION = "Manage secrets"
15+
16+
def _register(self):
17+
super()._register()
18+
self._parser.set_defaults(subfunc=self._list)
19+
subparsers = self._parser.add_subparsers(dest="action")
20+
21+
list_parser = subparsers.add_parser(
22+
"list", help="List secrets", formatter_class=self._parser.formatter_class
23+
)
24+
list_parser.set_defaults(subfunc=self._list)
25+
26+
get_parser = subparsers.add_parser(
27+
"get", help="Get secret value", formatter_class=self._parser.formatter_class
28+
)
29+
get_parser.add_argument(
30+
"name",
31+
help="The name of the secret",
32+
).completer = SecretNameCompleter()
33+
get_parser.set_defaults(subfunc=self._get)
34+
35+
set_parser = subparsers.add_parser(
36+
"set", help="Set secret", formatter_class=self._parser.formatter_class
37+
)
38+
set_parser.add_argument(
39+
"name",
40+
help="The name of the secret",
41+
)
42+
set_parser.add_argument(
43+
"value",
44+
help="The value of the secret",
45+
)
46+
set_parser.set_defaults(subfunc=self._set)
47+
48+
delete_parser = subparsers.add_parser(
49+
"delete",
50+
help="Delete secrets",
51+
formatter_class=self._parser.formatter_class,
52+
)
53+
delete_parser.add_argument(
54+
"name",
55+
help="The name of the secret",
56+
).completer = SecretNameCompleter()
57+
delete_parser.add_argument(
58+
"-y", "--yes", help="Don't ask for confirmation", action="store_true"
59+
)
60+
delete_parser.set_defaults(subfunc=self._delete)
61+
62+
def _command(self, args: argparse.Namespace):
63+
super()._command(args)
64+
args.subfunc(args)
65+
66+
def _list(self, args: argparse.Namespace):
67+
secrets = self.api.client.secrets.list(self.api.project)
68+
print_secrets_table(secrets)
69+
70+
def _get(self, args: argparse.Namespace):
71+
secret = self.api.client.secrets.get(self.api.project, name=args.name)
72+
print_secrets_table([secret])
73+
74+
def _set(self, args: argparse.Namespace):
75+
self.api.client.secrets.create_or_update(
76+
self.api.project,
77+
name=args.name,
78+
value=args.value,
79+
)
80+
console.print("[grey58]OK[/]")
81+
82+
def _delete(self, args: argparse.Namespace):
83+
if not args.yes and not confirm_ask(f"Delete the secret [code]{args.name}[/]?"):
84+
console.print("\nExiting...")
85+
return
86+
87+
with console.status("Deleting secret..."):
88+
self.api.client.secrets.delete(
89+
project_name=self.api.project,
90+
names=[args.name],
91+
)
92+
console.print("[grey58]OK[/]")

src/dstack/_internal/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from dstack._internal.cli.commands.offer import OfferCommand
1818
from dstack._internal.cli.commands.project import ProjectCommand
1919
from dstack._internal.cli.commands.ps import PsCommand
20+
from dstack._internal.cli.commands.secrets import SecretCommand
2021
from dstack._internal.cli.commands.server import ServerCommand
2122
from dstack._internal.cli.commands.stats import StatsCommand
2223
from dstack._internal.cli.commands.stop import StopCommand
@@ -72,6 +73,7 @@ def main():
7273
MetricsCommand.register(subparsers)
7374
ProjectCommand.register(subparsers)
7475
PsCommand.register(subparsers)
76+
SecretCommand.register(subparsers)
7577
ServerCommand.register(subparsers)
7678
StatsCommand.register(subparsers)
7779
StopCommand.register(subparsers)

src/dstack/_internal/cli/services/completion.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ def fetch_resource_names(self, api: Client) -> Iterable[str]:
7575
return [r.name for r in api.client.gateways.list(api.project)]
7676

7777

78+
class SecretNameCompleter(BaseAPINameCompleter):
79+
def fetch_resource_names(self, api: Client) -> Iterable[str]:
80+
return [r.name for r in api.client.secrets.list(api.project)]
81+
82+
7883
class ProjectNameCompleter(BaseCompleter):
7984
"""
8085
Completer for local project names.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import List
2+
3+
from rich.table import Table
4+
5+
from dstack._internal.cli.utils.common import add_row_from_dict, console
6+
from dstack._internal.core.models.secrets import Secret
7+
8+
9+
def print_secrets_table(secrets: List[Secret]) -> None:
10+
console.print(get_secrets_table(secrets))
11+
console.print()
12+
13+
14+
def get_secrets_table(secrets: List[Secret]) -> Table:
15+
table = Table(box=None)
16+
table.add_column("NAME", no_wrap=True)
17+
table.add_column("VALUE")
18+
19+
for secret in secrets:
20+
row = {
21+
"NAME": secret.name,
22+
"VALUE": secret.value or "*" * 6,
23+
}
24+
add_row_from_dict(table, row)
25+
return table
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
from typing import Optional
2+
from uuid import UUID
3+
14
from dstack._internal.core.models.common import CoreModel
25

36

47
class Secret(CoreModel):
8+
id: UUID
59
name: str
6-
value: str
10+
value: Optional[str] = None
711

812
def __str__(self) -> str:
9-
return f'Secret(name="{self.name}", value={"*" * len(self.value)})'
13+
displayed_value = "*"
14+
if self.value is not None:
15+
displayed_value = "*" * len(self.value)
16+
return f'Secret(name="{self.name}", value={displayed_value})'

0 commit comments

Comments
 (0)