Skip to content

Commit 9ccae54

Browse files
r4victorjvstme
andauthored
Add script to generate boilerplate code for new backend (#2397)
* Add script to generate boilerplate code for new backend * Update backend guide to use scripts/add_backend.py * Update src/dstack/_internal/core/backends/template/models.py.jinja Co-authored-by: jvstme <[email protected]> * Fixes after review --------- Co-authored-by: jvstme <[email protected]>
1 parent f29a1f8 commit 9ccae54

File tree

8 files changed

+317
-37
lines changed

8 files changed

+317
-37
lines changed

contributing/BACKENDS.md

Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -84,69 +84,67 @@ See the Appendix at the end of this document and make sure the provider meets th
8484

8585
#### 2.2. Set up the development environment
8686

87-
Follow [DEVELOPMENT.md](DEVELOPMENT.md)`.
87+
Follow [DEVELOPMENT.md](DEVELOPMENT.md).
8888

8989
#### 2.3. Add dependencies to setup.py
9090

9191
Add any dependencies required by your cloud provider to `setup.py`. Create a separate section with the provider's name for these dependencies, and ensure that you update the `all` section to include them as well.
9292

93-
#### 2.4. Implement the provider backend
93+
#### 2.4. Add a new backend type
9494

95-
##### 2.4.1. Define the backend type
95+
Add a new enumeration member for your provider to `BackendType` ([`src/dstack/_internal/core/models/backends/base.py`](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/models/backends/base.py)).
9696

97-
Add a new enumeration member for your provider to `BackendType` (`src/dstack/_internal/core/models/backends/base.py`).
98-
Use the name of the provider.
97+
#### 2.5. Create backend files and classes
9998

100-
##### 2.4.2. Create the backend directory
99+
`dstack` provides a helper script to generate all the necessary files and classes for a new backend.
100+
To add a new backend named `ExampleXYZ`, you should run:
101101

102-
Create a new directory under `src/dstack/_internal/core/backends` with the name of the backend type.
103-
104-
##### 2.4.3. Create the backend class
105-
106-
Under the backend directory you've created, create the `backend.py` file and define the
107-
backend class there (should extend `dstack._internal.core.backends.base.Backend`).
108-
109-
Refer to examples:
110-
[datacrunch](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/datacrunch/backend.py),
111-
[aws](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/aws/backend.py),
112-
[gcp](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/gcp/backend.py),
113-
[azure](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/azure/backend.py), etc.
102+
```shell
103+
python scripts/add_backend.py -n ExampleXYZ
104+
```
114105

115-
##### 2.4.4. Create the backend compute class
106+
It will create an `examplexyz` backend directory under `src/dstack/_internal/core/backends` with the following files:
116107

117-
Under the backend directory you've created, create the `compute.py` file and define the
118-
backend compute class that extends the `dstack._internal.core.backends.base.compute.Compute` class.
119-
It can also extend and implement `ComputeWith*` classes to support additional features such as fleets, volumes, gateways, placement groups, etc. For example, it should extend `ComputeWithCreateInstanceSupport` to support fleets.
108+
* `backend.py` with the `Backend` class implementation. You typically don't need to modify it.
109+
* `compute.py` with the `Compute` class implementation. This is the core of the backend that you need to implement.
110+
* `configurator.py` with the `Configurator` class implementation. It deals with validating and storing backend config. You need to adjust it with custom backend config validation.
111+
* `models.py` with all the backend config models used by `Backend`, `Compute`, `Configurator` and other parts of `dstack`.
120112

121-
Refer to examples:
122-
[datacrunch](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/datacrunch/compute.py),
123-
[aws](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/aws/compute.py),
124-
[gcp](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/gcp/compute.py),
125-
[azure](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/azure/compute.py), etc.
113+
##### 2.6. Adjust and register the backend config models
126114

127-
##### 2.4.5. Create and register the backend config models
128-
129-
Under the backend directory, create the `models.py` file and define the backend config model classes there.
130-
Every backend must define at least two models:
115+
Go to `models.py`. It'll contain two config models required for all backends:
131116

132117
* `*BackendConfig` that contains all backend parameters available for user configuration except for creds.
133118
* `*BackendConfigWithCreds` that contains all backends parameters available for user configuration and also creds.
134119

135-
These models are used in server/config.yaml, the API, and for backend configuration.
120+
Adjust generated config models by adding additional config parameters.
121+
Typically you'd need to only modify the `*BackendConfig` model since other models extend it.
136122

137-
The models should be added to `AnyBackendConfig*` unions in [`src/dstack/_internal/core/backends/models.py`](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/models.py).
123+
Then add these models to `AnyBackendConfig*` unions in [`src/dstack/_internal/core/backends/models.py`](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/models.py).
138124

139-
It's not required but recommended to also define `*BackendStoredConfig` that extends `*BackendConfig` to be able to store extra parameters in the DB. By the same logic, it's recommended to define `*Config` that extends `*BackendStoredConfig` with creds and use it as the main `Backend` and `Compute` config instead of using `*BackendConfigWithCreds` directly.
125+
The script also generates `*BackendStoredConfig` that extends `*BackendConfig` to be able to store extra parameters in the DB. By the same logic, it generates `*Config` that extends `*BackendStoredConfig` with creds and uses it as the main `Backend` and `Compute` config instead of using `*BackendConfigWithCreds` directly.
140126

141127
Refer to examples:
142128
[datacrunch](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/datacrunch/models.py),
143129
[aws](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/aws/models.py),
144130
[gcp](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/gcp/models.py),
145131
[azure](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/models.py), etc.
146132

147-
##### 2.4.6. Create and register the configurator class
133+
##### 2.7. Implement the backend compute class
134+
135+
Go to `compute.py` and implement `Compute` methods.
136+
Optionally, extend and implement `ComputeWith*` classes to support additional features such as fleets, volumes, gateways, placement groups, etc. For example, extend `ComputeWithCreateInstanceSupport` to support fleets.
137+
138+
Refer to examples:
139+
[datacrunch](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/datacrunch/compute.py),
140+
[aws](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/aws/compute.py),
141+
[gcp](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/gcp/compute.py),
142+
[azure](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/azure/compute.py), etc.
143+
144+
##### 2.8. Implement and register the configurator class
148145

149-
Under the backend directory, create the `configurator.py` file and and define the backend configurator class (must extend `dstack._internal.core.backends.base.configurator.Configurator`).
146+
Go to `configurator.py` and implement custom `Configurator` logic. At minimum, you should implement creds validation.
147+
You may also need to validate other config parameters if there are any.
150148

151149
Refer to examples: [datacrunch](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/datacrunch/configurator.py),
152150
[aws](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/aws/configurator.py),
@@ -155,7 +153,7 @@ Refer to examples: [datacrunch](https://github.com/dstackai/dstack/blob/master/s
155153

156154
Register configurator by appending it to `_CONFIGURATOR_CLASSES` in [`src/dstack/_internal/core/backends/configurators.py`](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/core/backends/configurators.py).
157155

158-
##### 2.4.7. (Optional) Override provisioning timeout
156+
##### 2.9. (Optional) Override provisioning timeout
159157

160158
If instances in the backend take more than 10 minutes to start, override the default provisioning timeout in
161159
[`src/dstack/_internal/server/background/tasks/common.py`](https://github.com/dstackai/dstack/blob/master/src/dstack/_internal/server/background/tasks/common.py).

scripts/add_backend.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import argparse
2+
from pathlib import Path
3+
4+
import jinja2
5+
6+
7+
def main():
8+
parser = argparse.ArgumentParser(
9+
description="This script generates boilerplate code for a new backend"
10+
)
11+
parser.add_argument(
12+
"-n",
13+
"--name",
14+
help=(
15+
"The backend name in CamelCase, e.g. AWS, Runpod, VastAI."
16+
" It'll be used for naming backend classes, models, etc."
17+
),
18+
required=True,
19+
)
20+
args = parser.parse_args()
21+
generate_backend_code(args.name)
22+
23+
24+
def generate_backend_code(backend_name: str):
25+
template_dir_path = Path(__file__).parent.parent.joinpath(
26+
"src/dstack/_internal/core/backends/template"
27+
)
28+
env = jinja2.Environment(
29+
loader=jinja2.FileSystemLoader(
30+
searchpath=template_dir_path,
31+
),
32+
keep_trailing_newline=True,
33+
)
34+
backend_dir_path = Path(__file__).parent.parent.joinpath(
35+
f"src/dstack/_internal/core/backends/{backend_name.lower()}"
36+
)
37+
backend_dir_path.mkdir(exist_ok=True)
38+
for filename in ["backend.py", "compute.py", "configurator.py", "models.py"]:
39+
template = env.get_template(f"{filename}.jinja")
40+
with open(backend_dir_path.joinpath(filename), "w+") as f:
41+
f.write(template.render({"backend_name": backend_name}))
42+
backend_dir_path.joinpath("__init__.py").write_text("")
43+
44+
45+
if __name__ == "__main__":
46+
main()

src/dstack/_internal/core/backends/base/compute.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ def __init__(self):
6060
def get_offers(
6161
self, requirements: Optional[Requirements] = None
6262
) -> List[InstanceOfferWithAvailability]:
63+
"""
64+
Returns offers with availability matching `requirements`.
65+
If the provider is added to gpuhunt, typically gets offers using `base.offers.get_catalog_offers()`
66+
and extends them with availability info.
67+
"""
6368
pass
6469

6570
@abstractmethod

src/dstack/_internal/core/backends/template/__init__.py

Whitespace-only changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from dstack._internal.core.backends.base.backend import Backend
2+
from dstack._internal.core.backends.{{ backend_name|lower }}.compute import {{ backend_name }}Compute
3+
from dstack._internal.core.backends.{{ backend_name|lower }}.models import {{ backend_name }}Config
4+
from dstack._internal.core.models.backends.base import BackendType
5+
6+
7+
class {{ backend_name }}Backend(Backend):
8+
TYPE = BackendType.{{ backend_name|upper }}
9+
COMPUTE_CLASS = {{ backend_name }}Compute
10+
11+
def __init__(self, config: {{ backend_name }}Config):
12+
self.config = config
13+
self._compute = {{ backend_name }}Compute(self.config)
14+
15+
def compute(self) -> {{ backend_name }}Compute:
16+
return self._compute
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from typing import List, Optional
2+
3+
from dstack._internal.core.backends.base.backend import Compute
4+
from dstack._internal.core.backends.base.compute import (
5+
ComputeWithCreateInstanceSupport,
6+
ComputeWithGatewaySupport,
7+
ComputeWithMultinodeSupport,
8+
ComputeWithPlacementGroupSupport,
9+
ComputeWithPrivateGatewaySupport,
10+
ComputeWithReservationSupport,
11+
ComputeWithVolumeSupport,
12+
)
13+
from dstack._internal.core.backends.base.offers import get_catalog_offers
14+
from dstack._internal.core.backends.{{ backend_name|lower }}.models import {{ backend_name }}Config
15+
from dstack._internal.core.models.backends.base import BackendType
16+
from dstack._internal.core.models.instances import (
17+
InstanceAvailability,
18+
InstanceConfiguration,
19+
InstanceOfferWithAvailability,
20+
)
21+
from dstack._internal.core.models.runs import Job, JobProvisioningData, Requirements, Run
22+
from dstack._internal.core.models.volumes import Volume
23+
from dstack._internal.utils.logging import get_logger
24+
25+
logger = get_logger(__name__)
26+
27+
28+
class {{ backend_name }}Compute(
29+
# TODO: Choose ComputeWith* classes to extend and implement
30+
# ComputeWithCreateInstanceSupport,
31+
# ComputeWithMultinodeSupport,
32+
# ComputeWithReservationSupport,
33+
# ComputeWithPlacementGroupSupport,
34+
# ComputeWithGatewaySupport,
35+
# ComputeWithPrivateGatewaySupport,
36+
# ComputeWithVolumeSupport,
37+
Compute,
38+
):
39+
def __init__(self, config: {{ backend_name }}Config):
40+
super().__init__()
41+
self.config = config
42+
43+
def get_offers(
44+
self, requirements: Optional[Requirements] = None
45+
) -> List[InstanceOfferWithAvailability]:
46+
# If the provider is added to gpuhunt, you'd typically get offers
47+
# using `get_catalog_offers()` and extend them with availability info.
48+
offers = get_catalog_offers(
49+
backend=BackendType.{{ backend_name|upper }},
50+
locations=self.config.regions or None,
51+
requirements=requirements,
52+
# configurable_disk_size=..., TODO: set in case of boot volume size limits
53+
)
54+
# TODO: Add availability info to offers
55+
return [
56+
InstanceOfferWithAvailability(
57+
**offer.dict(),
58+
availability=InstanceAvailability.UNKNOWN,
59+
)
60+
for offer in offers
61+
]
62+
63+
def create_instance(
64+
self,
65+
instance_offer: InstanceOfferWithAvailability,
66+
instance_config: InstanceConfiguration,
67+
) -> JobProvisioningData:
68+
# TODO: Implement if backend supports creating instances (VM-based).
69+
# Delete if backend can only run jobs (container-based).
70+
raise NotImplementedError()
71+
72+
def run_job(
73+
self,
74+
run: Run,
75+
job: Job,
76+
instance_offer: InstanceOfferWithAvailability,
77+
project_ssh_public_key: str,
78+
project_ssh_private_key: str,
79+
volumes: List[Volume],
80+
) -> JobProvisioningData:
81+
# TODO: Implement if create_instance() is not implemented. Delete otherwise.
82+
raise NotImplementedError()
83+
84+
def terminate_instance(
85+
self, instance_id: str, region: str, backend_data: Optional[str] = None
86+
):
87+
raise NotImplementedError()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import json
2+
3+
from dstack._internal.core.backends.base.configurator import (
4+
BackendRecord,
5+
Configurator,
6+
raise_invalid_credentials_error,
7+
)
8+
from dstack._internal.core.backends.{{ backend_name|lower }}.backend import {{ backend_name }}Backend
9+
from dstack._internal.core.backends.{{ backend_name|lower }}.models import (
10+
Any{{ backend_name }}BackendConfig,
11+
Any{{ backend_name }}Creds,
12+
{{ backend_name }}BackendConfig,
13+
{{ backend_name }}BackendConfigWithCreds,
14+
{{ backend_name }}Config,
15+
{{ backend_name }}Creds,
16+
{{ backend_name }}StoredConfig,
17+
)
18+
from dstack._internal.core.models.backends.base import (
19+
BackendType,
20+
)
21+
22+
# TODO: Add all supported regions and default regions
23+
REGIONS = []
24+
25+
26+
class {{ backend_name }}Configurator(Configurator):
27+
TYPE = BackendType.{{ backend_name|upper }}
28+
BACKEND_CLASS = {{ backend_name }}Backend
29+
30+
def validate_config(
31+
self, config: {{ backend_name }}BackendConfigWithCreds, default_creds_enabled: bool
32+
):
33+
self._validate_creds(config.creds)
34+
# TODO: Validate additional config parameters if any
35+
36+
def create_backend(
37+
self, project_name: str, config: {{ backend_name }}BackendConfigWithCreds
38+
) -> BackendRecord:
39+
if config.regions is None:
40+
config.regions = REGIONS
41+
return BackendRecord(
42+
config={{ backend_name }}StoredConfig(
43+
**{{ backend_name }}BackendConfig.__response__.parse_obj(config).dict()
44+
).json(),
45+
auth={{ backend_name }}Creds.parse_obj(config.creds).json(),
46+
)
47+
48+
def get_backend_config(
49+
self, record: BackendRecord, include_creds: bool
50+
) -> Any{{ backend_name }}BackendConfig:
51+
config = self._get_config(record)
52+
if include_creds:
53+
return {{ backend_name }}BackendConfigWithCreds.__response__.parse_obj(config)
54+
return {{ backend_name }}BackendConfig.__response__.parse_obj(config)
55+
56+
def get_backend(self, record: BackendRecord) -> {{ backend_name }}Backend:
57+
config = self._get_config(record)
58+
return {{ backend_name }}Backend(config=config)
59+
60+
def _get_config(self, record: BackendRecord) -> {{ backend_name }}Config:
61+
return {{ backend_name }}Config.__response__(
62+
**json.loads(record.config),
63+
creds={{ backend_name }}Creds.parse_raw(record.auth),
64+
)
65+
66+
def _validate_creds(self, creds: Any{{ backend_name }}Creds):
67+
# TODO: Implement API key or other creds validation
68+
# if valid:
69+
# return
70+
raise_invalid_credentials_error(fields=[["creds", "api_key"]])

0 commit comments

Comments
 (0)