Skip to content

Commit cda4583

Browse files
authored
Implement plugins (#2581)
* Implement plugins prototype * Add per-configuration ApplyPolicy methods * Add get_plugin_logger() * feat: Add tests for loading dstack plugins * Document get_plugin_logger() * Backport entry_points for Python 3.9 * Add plugin example * Document Concepts->Plugins * Add example on setting service-specific properties in plugins
1 parent fd0c144 commit cda4583

File tree

23 files changed

+732
-18
lines changed

23 files changed

+732
-18
lines changed

docs/docs/concepts/plugins.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Plugins
2+
3+
The `dstack` plugin system allows extending `dstack` server functionality using external Python packages.
4+
5+
!!! info "Experimental"
6+
Plugins are currently an _experimental_ feature.
7+
Backward compatibility is not guaranteed across releases.
8+
9+
## Enable plugins
10+
11+
To enable a plugin, list it under `plugins` in [`server/config.yml`](../reference/server/config.yml.md):
12+
13+
<div editor-title="server/config.yml">
14+
15+
```yaml
16+
plugins:
17+
- my_dstack_plugin
18+
- some_other_plugin
19+
projects:
20+
- name: main
21+
```
22+
23+
</div>
24+
25+
On the next server restart, you should see a log message indicating that the plugin is loaded.
26+
27+
## Create plugins
28+
29+
To create a plugin, create a Python package that implements a subclass of
30+
`dstack.plugins.Plugin` and exports this subclass as a "dstack.plugins" entry point.
31+
32+
1. Init the plugin package:
33+
34+
<div class="termy">
35+
36+
```shell
37+
$ uv init --library
38+
```
39+
40+
</div>
41+
42+
2. Define `ApplyPolicy` and `Plugin` subclasses:
43+
44+
<div editor-title="src/example_plugin/__init__.py">
45+
46+
```python
47+
from dstack.plugins import ApplyPolicy, Plugin, RunSpec, get_plugin_logger
48+
49+
logger = get_plugin_logger(__name__)
50+
51+
class ExamplePolicy(ApplyPolicy):
52+
def on_run_apply(self, user: str, project: str, spec: RunSpec) -> RunSpec:
53+
# ...
54+
return spec
55+
56+
class ExamplePlugin(Plugin):
57+
58+
def get_apply_policies(self) -> list[ApplyPolicy]:
59+
return [ExamplePolicy()]
60+
```
61+
62+
</div>
63+
64+
3. Specify a "dstack.plugins" entry point in `pyproject.toml`:
65+
66+
<div editor-title="pyproject.toml">
67+
68+
```toml
69+
[project.entry-points."dstack.plugins"]
70+
example_plugin = "example_plugin:ExamplePlugin"
71+
```
72+
73+
</div>
74+
75+
Then you can install the plugin package into your Python environment and enable it via `server/config.yml`.
76+
77+
??? info "Plugins in Docker"
78+
If you deploy `dstack` using a Docker image you can add plugins either
79+
by including them in your custom image built upon the `dstack` server image,
80+
or by mounting installed plugins as volumes.
81+
82+
## Apply policies
83+
84+
Currently the only plugin functionality is apply policies.
85+
Apply policies allow modifying specs of runs, fleets, volumes, and gateways submitted on `dstack apply`.
86+
Subclass `dstack.plugins.ApplyPolicy` to implement them.
87+
88+
Here's an example of how to enforce certain rules using apply policies:
89+
90+
<div editor-title="src/example_plugin/__init__.py">
91+
92+
```python
93+
class ExamplePolicy(ApplyPolicy):
94+
def on_run_apply(self, user: str, project: str, spec: RunSpec) -> RunSpec:
95+
# Forcing some limits
96+
spec.configuration.max_price = 2.0
97+
spec.configuration.max_duration = "1d"
98+
# Setting some extra tags
99+
if spec.configuration.tags is None:
100+
spec.configuration.tags = {}
101+
spec.configuration.tags |= {
102+
"team": "my_team",
103+
}
104+
# Forbid something
105+
if spec.configuration.privileged:
106+
logger.warning("User %s tries to run privileged containers", user)
107+
raise ValueError("Running privileged containers is forbidden")
108+
# Set some service-specific properties
109+
if isinstance(spec.configuration, Service):
110+
spec.configuration.https = True
111+
return spec
112+
```
113+
114+
</div>
115+
116+
For more information on the plugin development, see the [plugin example](https://github.com/dstackai/dstack/tree/master/examples/plugins/example_plugin).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.11
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
## Overview
2+
3+
This is a basic `dstack` plugin example.
4+
You can use it as a reference point when implementing new `dstack` plugins.
5+
6+
## Steps
7+
8+
1. Init the plugin package:
9+
10+
```
11+
uv init --library
12+
```
13+
14+
2. Define `ApplyPolicy` and `Plugin` subclasses:
15+
16+
```python
17+
from dstack.plugins import ApplyPolicy, Plugin, RunSpec, get_plugin_logger
18+
19+
20+
logger = get_plugin_logger(__name__)
21+
22+
23+
class ExamplePolicy(ApplyPolicy):
24+
25+
def on_run_apply(self, user: str, project: str, spec: RunSpec) -> RunSpec:
26+
# ...
27+
return spec
28+
29+
30+
class ExamplePlugin(Plugin):
31+
32+
def get_apply_policies(self) -> list[ApplyPolicy]:
33+
return [ExamplePolicy()]
34+
35+
```
36+
37+
3. Specify a "dstack.plugins" entry point in `pyproject.toml`:
38+
39+
```toml
40+
[project.entry-points."dstack.plugins"]
41+
example_plugin = "example_plugin:ExamplePlugin"
42+
```
43+
44+
4. Make sure to install the plugin and enable it in the `server/config.yml`:
45+
46+
```yaml
47+
plugins:
48+
- example_plugin
49+
projects:
50+
- name: main
51+
# ...
52+
```
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[project]
2+
name = "example-plugin"
3+
version = "0.1.0"
4+
description = "A dstack plugin example"
5+
readme = "README.md"
6+
authors = [
7+
{ name = "Victor Skvortsov", email = "victor@dstack.ai" }
8+
]
9+
requires-python = ">=3.9"
10+
dependencies = []
11+
12+
[build-system]
13+
requires = ["hatchling"]
14+
build-backend = "hatchling.build"
15+
16+
[project.entry-points."dstack.plugins"]
17+
example_plugin = "example_plugin:ExamplePlugin"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from dstack.api import Service
2+
from dstack.plugins import ApplyPolicy, GatewaySpec, Plugin, RunSpec, get_plugin_logger
3+
4+
logger = get_plugin_logger(__name__)
5+
6+
7+
class ExamplePolicy(ApplyPolicy):
8+
def on_run_apply(self, user: str, project: str, spec: RunSpec) -> RunSpec:
9+
# Forcing some limits
10+
spec.configuration.max_price = 2.0
11+
spec.configuration.max_duration = "1d"
12+
# Setting some extra tags
13+
if spec.configuration.tags is None:
14+
spec.configuration.tags = {}
15+
spec.configuration.tags |= {
16+
"team": "my_team",
17+
}
18+
# Forbid something
19+
if spec.configuration.privileged:
20+
logger.warning("User %s tries to run privileged containers", user)
21+
raise ValueError("Running privileged containers is forbidden")
22+
# Set some service-specific properties
23+
if isinstance(spec.configuration, Service):
24+
spec.configuration.https = True
25+
return spec
26+
27+
def on_gateway_apply(self, user: str, project: str, spec: GatewaySpec) -> GatewaySpec:
28+
# Forbid creating new gateways altogether
29+
raise ValueError("Creating gateways is forbidden")
30+
31+
32+
class ExamplePlugin(Plugin):
33+
def get_apply_policies(self) -> list[ApplyPolicy]:
34+
return [ExamplePolicy()]

examples/plugins/example_plugin/src/example_plugin/py.typed

Whitespace-only changes.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ nav:
206206
- Backends: docs/concepts/backends.md
207207
- Projects: docs/concepts/projects.md
208208
- Gateways: docs/concepts/gateways.md
209+
- Plugins: docs/concepts/plugins.md
209210
- Guides:
210211
- Protips: docs/guides/protips.md
211212
- Server deployment: docs/guides/server-deployment.md

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ server = [
125125
"python-json-logger>=3.1.0",
126126
"prometheus-client",
127127
"grpcio>=1.50",
128+
"backports.entry-points-selectable",
128129
]
129130
aws = [
130131
"boto3",

src/dstack/_internal/core/models/fleets.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ class FleetSpec(CoreModel):
269269
configuration_path: Optional[str] = None
270270
profile: Profile
271271
autocreated: bool = False
272+
# merged_profile stores profile parameters merged from profile and configuration.
273+
# Read profile parameters from merged_profile instead of profile directly.
272274
# TODO: make merged_profile a computed field after migrating to pydanticV2
273275
merged_profile: Annotated[Profile, Field(exclude=True)] = None
274276

src/dstack/_internal/core/models/runs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ class RunSpec(CoreModel):
357357
description="The contents of the SSH public key that will be used to connect to the run."
358358
),
359359
]
360+
# merged_profile stores profile parameters merged from profile and configuration.
361+
# Read profile parameters from merged_profile instead of profile directly.
360362
# TODO: make merged_profile a computed field after migrating to pydanticV2
361363
merged_profile: Annotated[Profile, Field(exclude=True)] = None
362364

0 commit comments

Comments
 (0)